一,问题描述
在英文单词表中,有一些单词非常相似,它们可以通过只变换一个字符而得到另一个单词。比如: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文件格式如下: