一,問題描述
在英文單詞表中,有一些單詞非常相似,它們可以通過只變換一個字符而得到另一個單詞。比如:hive-->five;wine-->line;line-->nine;nine-->mine.....
那么,就存在這樣一個問題:給定一個單詞作為起始單詞(相當於圖的源點),給定另一個單詞作為終點,求從起點單詞經過的最少變換(每次變換只會變換一個字符),變成終點單詞。
這個問題,其實就是最短路徑問題。
由於最短路徑問題中,求解源點到終點的最短路徑與求解源點到圖中所有頂點的最短路徑復雜度差不多,故求解兩個單詞之間的最短路徑相當於求解源點單詞到所有單詞之間的最短路徑。
給定所有的英文單詞,大約有89000個,我們需要找出通過單個字母的替換可以變成至少15個其他單詞的單詞?程序如何實現?
給定兩個單詞,一個作為源點,另一個作為終點,需要找出從源點開始,經過最少次單個字母替換,變成終點單詞,這條變換路徑中經過了哪些單詞?
比如:(zero-->five):(zero-->hero-->here-->hire-->five)
二,算法分析
假設所有的單詞存儲在一個txt文件中,每行一個單詞。
現在的問題主要有兩個:①從文件中讀取單詞,並構造一個圖;②圖的最短路徑算法--Dijkstra算法實現。
由於單詞A替換一個字符變成單詞B,那么反過來單詞B替換一個字符也可以變成單詞A(自反性)【wine-->fine; fine-->wine】。故圖是一個無向圖。
構造圖的算法分析:
現在更進一步,假設單詞已經讀取到一個List<String>中,圖采用鄰接表形式存儲,構造圖其實就是:如何根據List<String> 構造一個Map<String,List<String>>
其中Map中的Key是某個單詞,Value則是該單詞的“鄰接單詞”列表,鄰接單詞即:該單詞經過一個字符的替換變成另一個單詞。
如:wine的鄰接單詞有:fine、line、nine.....
一個最直接的想法就是:
由於單詞都在List<String>中存儲,那么從第1個單詞開始,依次掃描第2個至第N個單詞,判斷第1個單詞是否與第 2,3,.....N個單詞只差一個字符。這樣一遍掃描,找出了List<String>中第1個單詞的鄰接表。
繼續,對於第2個單詞,依次掃描第3,4,....N個單詞,找出List<String>中第2個單詞的鄰接表。
.......
上述過程可描述成如下循環:
for(int i = 0; i < N; i++) for(int j = i+1; j < N; j++)//N 表示單詞表中所有單詞個數
//do something....
顯然,上述構造圖的算法的時間復雜度為O(N^2)。具體代碼如下:
1 public static Map<String, List<String>> computeAdjacentWords2(List<String> theWords){ 2 Map<String, List<String>> adjWords = new TreeMap<>(); 3 String[] words = new String[theWords.size()]; 4 words = theWords.toArray(words); 5 6 for(int i = 0; i < words.length; i++) 7 for(int j = i+1; j < words.length; j++)//在整個單詞表中的所有單詞之間進行比較 8 if(oneCharOff(words[i], words[j])) 9 { 10 update(adjWords, words[i], words[j]);//無向圖,i--j 11 update(adjWords, words[j], words[i]);//j--i 12 } 13 return adjWords; 14 }
注意第4行,它將List轉換成了數組,這樣可以提高程序的執行效率。因為,若不轉換成數組,在隨后的第6、7行for循環中,在執行時泛型擦除,將頻繁向下轉型(Object轉型成String)
另外兩個工具方法如下:
//判斷兩個單詞 只替換一個字符變成另一單詞 private static boolean oneCharOff(String word1, String word2) { if (word1.length() != word2.length())//單詞長度不相等,肯定不符合條件. return false; int diffs = 0; for (int i = 0; i < word1.length(); i++) if (word1.charAt(i) != word2.charAt(i)) if (++diffs > 1) return false; return diffs == 1; } //將單詞添加到鄰接表中 private static <T> void update(Map<T, List<String>> m, T key, String value) { List<String> lst = m.get(key); if (lst == null) {//該 Key是第一次出現 lst = new ArrayList<String>(); m.put(key, lst); } lst.add(value); }
Dijkstra算法分析:
上面已經提到,這是一個無向圖,無向圖的最短路徑問題,無向圖的Dijkstra算法實現要比帶權的有向圖簡單得多。簡單的原因在於:無向圖的Dijkstra實現只需要一個隊列,采用“廣度”遍歷的思想從源點開始向外擴散求解圖中其他頂點到源點的距離,之所以這樣,是因為無向圖一旦訪問到某個頂點,更新它的前驅頂點后,它的前驅頂點以后都不會再變了(參考博文)。而對於有向圖,某個頂點的前驅頂點可能會被多次更新。因此,需要更復雜的數據結構來”貪心“選擇下一個距離最短的頂點。
1 /** 2 * 使用Dijkstra算法求解無向圖 從 start 到 end 的最短路徑 3 * @param adjcentWords 保存單詞Map,Map<String, List<string>>key:表示某個單詞, Value:與該單詞只差一個字符的單詞 4 * @param start 起始單詞 5 * @param end 結束單詞 6 * @return 從start 轉換成 end 經過的中間單詞 7 */ 8 public static List<String> findChain(Map<String, List<String>> adjcentWords, String start, String end){ 9 Map<String, String> previousWord = new HashMap<String, String>();//Key:某個單詞,Value:該單詞的前驅單詞 10 Queue<String> queue = new LinkedList<>(); 11 12 queue.offer(start); 13 while(!queue.isEmpty()){ 14 String preWord = queue.poll(); 15 List<String> adj = adjcentWords.get(preWord); 16 17 for (String word : adj) { 18 //代表這個word的'距離'(前驅單詞)沒有被更新過.(第一次遍歷到該word),每個word的'距離'只會被更新一次. 19 if(previousWord.get(word) == null){//理解為什么需要if判斷 20 previousWord.put(word, preWord); 21 queue.offer(word); 22 } 23 24 } 25 } 26 previousWord.put(start, null);//記得把源點的前驅頂點添加進去 27 return geChainFromPreviousMap(previousWord, start, end); 28 }
第19行進行if判斷的原因是:還是前面提到的,每個頂點的前驅只會更新一次。當第一次遍歷到 'word'時,它的前驅頂點'preWord'就被永久確定下來了。
當在后面可能再次從另外一個頂點遍歷到該'word'時,這個頂點不可能是'word'的前驅頂點了。因為:這條到'word'的路徑不可能是最短的了。這就是”廣度“ 搜索的思想!
三,構造圖的算法改進
這里將構造圖的算法改進單獨作為一節,是因為它很好地用到了“分類的思想”,在處理大量的數據時,先將相關的數據分類,然后以類為單位,一個一個地處理類中的所有數據。
分類要覆蓋所有的數據,相當於概率論中的對 數據集合S的一個全划分。
將列表List<String>中的單詞構造圖,本質上查找每個單詞的所有鄰接單詞。顯然如果兩個單詞的長度不相等,它們就不可能構成鄰接關系。
因此,可以把單詞表中所有的單詞先按單詞的長度進行分類,分成長度為1的單詞、長度為2的單詞....長度為N的單詞。分成了N個集合,這N個集合就是單詞表的一個全划分,因為對於單詞表中的任何一個單詞,它一定屬於這N個集合中的某一個。
因此,先將按長度進行分類。然后再對每一類中的單詞進行判斷。改進后的代碼如下:
1 /** 2 * 根據單詞構造鄰接表 3 * @param theWords 包含所有單詞List 4 * @return Map<String, List<string>>key:表示某個單詞, Value:與該單詞只差一個字符的單詞 5 */ 6 public static Map<String, List<String>> computeAdjacentWords( 7 List<String> theWords) { 8 Map<String, List<String>> adjWords = new TreeMap<>(); 9 Map<Integer, List<String>> wordsByLength = new TreeMap<>();//單詞分類,Key表示單詞長度,Value表示長度相同的單詞集合 10 11 for (String word : theWords) 12 update(wordsByLength, word.length(), word); 13 14 for (List<String> groupWords : wordsByLength.values()) {//分組處理單詞 15 String[] words = new String[groupWords.size()]; 16 groupWords.toArray(words); 17 18 for (int i = 0; i < words.length; i++) 19 for (int j = i + 1; j < words.length; j++)//只在一個組內所有的單詞之間進行比較 20 if (oneCharOff(words[i], words[j])) { 21 update(adjWords, words[i], words[j]); 22 update(adjWords, words[j], words[i]); 23 } 24 25 } 26 return adjWords; 27 }
第11行至12行,完成單詞分類,將單詞按長度分類保存在一個Map中。Map的Key表示單詞長度,Value表示所有長度相同的單詞集合。如: <4, five,line,good,high....>
第18行至19行的for循環,現在只需要對一個分類里面的所有單詞進行比較了。而上面第2點(算法分析)中貼出的computeAdjacentWords2()方法中的第6、7行for循環則是對所有的單詞進行遍歷。
可以看出,改進后的算法比較的次數少了。但是從時間復雜度的角度來看,仍是O(N^2)。且額外用了一個Map<Integer, List<String>>來保存每個分類。
四,總結
這個單詞轉換問題讓我認識到了圖論算法的重要性。以前覺得圖的算法高大上,遙不可及,原來它的應用如此實在。
Dijkstra算法是一個典型的貪心算法。對於帶權的有向圖的Dijkstra算法實現需要用到最小堆。最小堆的DelMin操作最壞情況下的復雜度為O(logN),很符合Dijkstra中貪心選取下一個距離最小的頂點。其次,要注意的是:當選取了某個頂點之后,該頂點的所有鄰接點的距離都可能被更新,這里需要進行堆調整,可視為將這些鄰接點執行decreaseKey(weight)操作。但是,有個問題,我們需要找到該頂點的所有鄰接點!而對最小堆中的某個元素進行查找操作是低效的!(為什么網上大部分的基於最小堆實現的Dijkstra算法都沒有考慮查找鄰接點且對它執行decreaseKey操作????)因此,Dijkstra算法的實現才會借助對查找效率更好的斐波拉契堆或者配對堆來實現。
其次,對待求解的大問題進行分類,將大問題分解成若干小的類別的問題,這是一種分治的思想。只”比較“(處理)相關的元素而不是”比較“所有的元素,有效地減少了程序的時間復雜度。
五,完整代碼實現
1 import java.io.BufferedReader; 2 import java.io.File; 3 import java.io.FileReader; 4 import java.io.IOException; 5 import java.util.ArrayList; 6 import java.util.HashMap; 7 import java.util.LinkedList; 8 import java.util.List; 9 import java.util.Map; 10 import java.util.Queue; 11 import java.util.TreeMap; 12 13 public class WordLadder { 14 15 /* 16 * 從文件中將單詞讀入到List<String>. 假設一行一個單詞,單詞沒有重復 17 */ 18 public static List<String> read(final String filepath) { 19 List<String> wordList = new ArrayList<String>(); 20 21 File file = new File(filepath); 22 FileReader fr = null; 23 BufferedReader br = null; 24 String lines = null; 25 String word = null; 26 try { 27 fr = new FileReader(file); 28 br = new BufferedReader(fr); 29 String line = null; 30 int index = -1; 31 while ((lines = br.readLine()) != null) { 32 // word = line.substring(0, line.indexOf(" ")).trim(); 33 line = lines.trim(); 34 index = line.indexOf(" "); 35 if (index == -1) 36 continue; 37 word = line.substring(0, line.indexOf(" ")); 38 wordList.add(word); 39 } 40 } catch (IOException e) { 41 e.printStackTrace(); 42 } finally { 43 try { 44 fr.close(); 45 br.close(); 46 } catch (IOException e) { 47 48 } 49 } 50 51 return wordList; 52 } 53 54 /** 55 * 根據單詞構造鄰接表 56 * @param theWords 包含所有單詞List 57 * @return Map<String, List<string>>key:表示某個單詞, Value:與該單詞只差一個字符的單詞 58 */ 59 public static Map<String, List<String>> computeAdjacentWords( 60 List<String> theWords) { 61 Map<String, List<String>> adjWords = new TreeMap<>(); 62 Map<Integer, List<String>> wordsByLength = new TreeMap<>(); 63 64 for (String word : theWords) 65 update(wordsByLength, word.length(), word); 66 67 for (List<String> groupWords : wordsByLength.values()) { 68 String[] words = new String[groupWords.size()]; 69 groupWords.toArray(words); 70 71 for (int i = 0; i < words.length; i++) 72 for (int j = i + 1; j < words.length; j++) 73 if (oneCharOff(words[i], words[j])) { 74 update(adjWords, words[i], words[j]); 75 update(adjWords, words[j], words[i]); 76 } 77 78 } 79 return adjWords; 80 } 81 82 public static Map<String, List<String>> computeAdjacentWords2(List<String> theWords){ 83 Map<String, List<String>> adjWords = new TreeMap<>(); 84 String[] words = new String[theWords.size()]; 85 words = theWords.toArray(words); 86 87 for(int i = 0; i < words.length; i++) 88 for(int j = i+1; j < words.length; j++) 89 if(oneCharOff(words[i], words[j])) 90 { 91 update(adjWords, words[i], words[j]);//無向圖,i--j 92 update(adjWords, words[j], words[i]);//j--i 93 } 94 return adjWords; 95 } 96 97 98 //判斷兩個單詞 只替換一個字符變成另一單詞 99 private static boolean oneCharOff(String word1, String word2) { 100 if (word1.length() != word2.length())//單詞長度不相等,肯定不符合條件. 101 return false; 102 int diffs = 0; 103 for (int i = 0; i < word1.length(); i++) 104 if (word1.charAt(i) != word2.charAt(i)) 105 if (++diffs > 1) 106 return false; 107 return diffs == 1; 108 } 109 110 //將單詞添加到鄰接表中 111 private static <T> void update(Map<T, List<String>> m, T key, String value) { 112 List<String> lst = m.get(key); 113 if (lst == null) {//該 Key是第一次出現 114 lst = new ArrayList<String>(); 115 m.put(key, lst); 116 } 117 lst.add(value); 118 } 119 120 121 /** 122 * 使用Dijkstra算法求解從 start 到 end 的最短路徑 123 * @param adjcentWords 保存單詞Map,Map<String, List<string>>key:表示某個單詞, Value:與該單詞只差一個字符的單詞 124 * @param start 起始單詞 125 * @param end 結束單詞 126 * @return 從start 轉換成 end 經過的中間單詞 127 */ 128 public static List<String> findChain(Map<String, List<String>> adjcentWords, String start, String end){ 129 Map<String, String> previousWord = new HashMap<String, String>();//Key:某個單詞,Value:該單詞的前驅單詞 130 Queue<String> queue = new LinkedList<>(); 131 132 queue.offer(start); 133 while(!queue.isEmpty()){ 134 String preWord = queue.poll(); 135 List<String> adj = adjcentWords.get(preWord); 136 137 for (String word : adj) { 138 //代表這個word的'距離'(前驅單詞)沒有被更新過.(第一次遍歷到該word),每個word的'距離'只會被更新一次. 139 if(previousWord.get(word) == null){//理解為什么需要if判斷 140 previousWord.put(word, preWord); 141 queue.offer(word); 142 } 143 144 } 145 } 146 previousWord.put(start, null);//記得把源點的前驅頂點添加進去 147 return geChainFromPreviousMap(previousWord, start, end); 148 } 149 150 private static List<String> geChainFromPreviousMap(Map<String, String> previousWord, String start, String end){ 151 LinkedList<String> result = null; 152 153 if(previousWord.get(end) != null){ 154 result = new LinkedList<>(); 155 for(String pre = end; pre != null; pre = previousWord.get(pre)) 156 result.addFirst(pre); 157 } 158 return result; 159 } 160 }
處理的單詞TXT文件格式如下: