题目不多说了。见https://oj.leetcode.com/problems/word-ladder-ii/
这一题我反复修改了两天半。尝试过各种思路,总是报TLE。终于知道这一题为什么是leetcode上通过率最低的一道题了,它对时限的要求实在太苛刻了。
在我AC版本代码的前一个版本,最好也就过了单词长度为7的test case。然后就TLE了。
到底问题在哪儿?我从算法,STL数据结构,代码优化各种角度思考。比较可惜的是,直到最后我也没有弄清为啥能AC,为啥会TLE。(都是我写的代码,都是我的思路,太诡异了。。。)
但不管如何,通过这一题,学到的还真是挺多。这里总结下吧。
拿到这一题的时候,首先想到的就是爆搜。依次替换单词中的字母,然后依次为基础进行搜索。
是BFS还是DFS呢?
先引用下Stack Overflow上的两个解答
That heavily depends on the structure of the search tree and the number and location of solutions. If you know a solution is not far from the root of the tree, a breadth first search (BFS) might be better. If the tree is very deep and solutions are rare, depth first search (DFS) might take an extremely long time, but BFS could be faster. If the tree is very wide, a BFS might need too much memory, so it might be completely impractical. If solutions are frequent but located deep in the tree, BFS could be impractical. If the search tree is very deep you will need to restrict the search depth for depth first search (DFS), anyway (for example with iterative deepening).
--------------------------------------------------------------------------------------
BFS is going to use more memory depending on the branching factor... however, BFS is a complete algorithm... meaning if you are using it to search for something in the lowest depth possible, BFS will give you the optimal solution. BFS space complexity is O(b^d)
... the branching factor raised to the depth (can be A LOT of memory).
DFS on the other hand, is much better about space however it may find a suboptimal solution. Meaning, if you are just searching for a path from one vertex to another, you may find the suboptimal solution (and stop there) before you find the real shortest path. DFS space complexity is O(|V|)
... meaning that the most memory it can take up is the longest possible path.
They have the same time complexity.
其实这一题很容易在脑海汇中勾勒一下DFS/BFS搜索树的大致样子。
如果选用DFS(即广义上的爆搜递归)
void search(string &word, string &end, unordered_set<string> &dict, int level) { if(word == end) return; if( level == dict.size()) return; for(int i = 0; i < word.length(); i++) { for(int ch = 'a'; j <='z'; j++) { string tmp = word; if(tmp[i] == ch) continue; tmp[i] = ch; if(dict.count(tmp) > 0) search(tmp, end, dict, level+1); } }
如此,必须要遍历整棵搜索树,记录所有可能的解路径,然后比较最短的输出,重复节点很多,时间复杂度相当大。有人问可以剪枝么,答案是这里没法剪。如果把已经访问过的剪掉,那么就会出现搜索不完全的情况。
看来直接上来爆搜是不行的。效率低的不能忍。
这样看,如果将相邻的两个单词(即只差一个字母的单词)相互连在一起,这就是一个图嘛。经典的图算法,dijiska算法不就是求解最短路径的算法么。
那么就说直接邻接表建图,然后dijkstra算法求解咯,当然是可以的,边缘权值设为1就行。而且这种思路工程化,模块化思路很明显,比较不容易出错。但此种情况下时间需建图,然后再调用dijkstra,光是后者复杂度就为o(n^2),所以仍有可能超时,或者说,至少还不是最优方法。
建图后进行DFS呢。很可惜,对于一个无向有环图,DFS只能遍历节点,求最短路径什么的还是别想了。(注意,这里对图进行DFS搜索也会生成一颗搜索树,但是与上文提到的递归爆搜得到的搜索树完全不一样哦,主要是因为对图进行DFS得不到严谨的前后关系,而这是最短路径必须具备的)
好了,我们来看看一个例子
如何对这个图进行数据结构上的优化,算法上的优化是解决问题的关键。
通过观察,容易发现这个图没有边权值,也就是所用dijkstra算法显得没必要了,简单的BFS就行,呵呵,BFS是可以求这类图的最短路径的,
正如wiki所言:若所有边的长度相等,广度优先搜索算法是最佳解——亦即它找到的第一个解,距离根节点的边数目一定最少。
所以,从出发点开始,第一次"遍历"到终点时过的那条路径就是最短的路径。而且是时间复杂度为O(|V|+|E|)。时间复杂度较dijkstra小,尤其是在边没那么多的时候。
到此为止了么。当然不是,还可以优化。
回到最原始的问题,这个图够好么?它能反映问题的本质么。所谓问题的本质,有这么两点,一是具有严格的前后关系(因为要输出所有变换序列),二是图中的边数量是否过大,能够减小一些呢?
其实,一个相对完美的图应该是这样的
这个图有两个很明显的特点,一是有向图,具有鲜明的层次特性,二是边没有冗余。此图完美的描述了解的结构。
所以,我们建图也要有一定策略,也许你们会问,我是怎么想出来的。
其实,可以这样想,我们对一个单词w进行单个字母的变换,得到w1 w2 w3...,本轮的这些替换结果直接作为当前单词w的后继节点,借助BFS的思想,将这些节点保存起来,下一轮开始的时候提取将这些后继节点作为新的父节点,然后重复这样的步骤。
这里,我们需要对节点“分层”。上图很明显分为了三层。这里没有用到队列,但是思想和队列一致的。因为队列无法体现层次关系,所以建图的时候,必须设立两个数据结构,用来保存当前层和下层,交替使用这两个数据结构保存父节点和后继节点。
同时,还需要保证,当前层的所有节点必须不同于所有高层的节点。试想,如果tot下面又接了一个pot,那么由此构造的路径只会比tot的同层pot构造出的路径长。如何完成这样的任务呢?可以这样,我们把所有高层节点从字典集合中删除,然后供给当前层选取单词。这样,当前层选取的单词就不会与上层的重复了。注意,每次更新字典的时候是在当前层处理完毕之后在更新,切不可得到一个单词就更新字典。例如我们得到了dog,不能马上把dog从待字典集合中删除,否则,下次hog生成dog时在字典中找不到dog,从而导致结果不完整。简单的说,同层的节点可以重复。上图也可以把dog化成两个节点,由dot和hog分别指向。我这里为了简单就没这么画了。
最后生成的数据结构应该这样,类似邻接表
hot---> hop, tot, dot, pot, hog
dot--->dog
hog--->dog, cog
ok。至此,问题算是基本解决了,剩下的就是如何生成路径。其实很简单,对于这种“特殊”的图,我们可以直接DFS搜索,节点碰到目标单词就返回。
这就完了,不能优化了?不,还可以优化。
可以看到,在生成路径的时候,如果能够从下至上搜索的话,就可以避免那些无用的节点,比如hop pot tot这类的,大大提升效率。其实也简单,构造数据结构时,交换一下节点,如下图
dog--->dot, hog
cog--->hog
hop--->hot
tot--->hot
dot--->hot
pot--->hot
hog--->hot
说白了,构造一个反向邻接表即可。
对了,还没说整个程序的终止条件。如果找到了,把当前层搜完就退出。如果没找到,字典迟早会被清空,这时候退出就行。
说了这么多,上代码吧
1 class Solution { 2 public: 3 vector<string> temp_path; 4 vector<vector<string>> result_path; 5 6 void GeneratePath(unordered_map<string, unordered_set<string>> &path, const string &start, const string &end) 7 { 8 temp_path.push_back(start); 9 if(start == end) 10 { 11 vector<string> ret = temp_path; 12 reverse(ret.begin(),ret.end()); 13 result_path.push_back(ret); 14 return; 15 } 16 17 for(auto it = path[start].begin(); it != path[start].end(); ++it) 18 { 19 GeneratePath(path, *it, end); 20 temp_path.pop_back(); 21 } 22 } 23 vector<vector<string>> findLadders(string start, string end, unordered_set<string> &dict) 24 { 25 temp_path.clear(); 26 result_path.clear(); 27 28 unordered_set<string> current_step; 29 unordered_set<string> next_step; 30 31 unordered_map<string, unordered_set<string>> path; 32 33 unordered_set<string> unvisited = dict; 34 35 if(unvisited.count(start) > 0) 36 unvisited.erase(start); 37 38 current_step.insert(start); 39 40 while( current_step.count(end) == 0 && unvisited.size() > 0 ) 41 { 42 for(auto pcur = current_step.begin(); pcur != current_step.end(); ++pcur) 43 { 44 string word = *pcur; 45 46 for(int i = 0; i < start.length(); ++i) 47 { 48 for(int j = 0; j < 26; j++) 49 { 50 string tmp = word; 51 if( tmp[i] == 'a' + j ) 52 continue; 53 tmp[i] = 'a' + j; 54 if( unvisited.count(tmp) > 0 ) 55 { 56 next_step.insert(tmp); 57 path[tmp].insert(word); 58 } 59 } 60 } 61 } 62 63 if(next_step.empty()) break; 64 for(auto it = next_step.begin() ; it != next_step.end(); ++it) 65 { 66 unvisited.erase(*it); 67 } 68 69 current_step = next_step; 70 next_step.clear(); 71 } 72 73 if(current_step.count(end) > 0) 74 GeneratePath(path, end, start); 75 76 return result_path; 77 } 78 };
此外,这里还有一份代码,写的比较乱,但用的传统队列的思想,用两个标记变量来指示层数的变化。也AC了。
class Solution { public: vector<vector<string>> output; vector<string> cur; void FindPath(unordered_map<string, unordered_set<string>> &graph, const string &start, const string &end) { cur.push_back(start); if(start == end) { vector<string> ret = cur; reverse(ret.begin(),ret.end()); output.push_back(ret); return; } for(auto it2 = graph[start].begin(); it2 != graph[start].end(); ++it2) { FindPath(graph, *it2, end); cur.pop_back(); } } vector<vector<string>> findLadders(string start, string end, unordered_set<string> & _dict) { unordered_set<string> dict = _dict; if(dict.count(start) >0) dict.erase(start); output.clear(); cur.clear(); unordered_map<string, unordered_set<string>> graph; queue<string> q; unordered_map<string, int> depth; q.push(start); depth[start] = 0; bool found = false; int cur_deep = 0; int pre_deep = 0; while(!q.empty()) { string word = q.front(); q.pop(); pre_deep = cur_deep; cur_deep = depth[word]; if(pre_deep != cur_deep) { if(depth.count(end) > 0) { found = true; break; } else if(depth.size() == dict.size() + 1) break; } for( int i = 0; i < start.length(); ++i) { for(char ch = 'a'; ch <= 'z'; ch++) { string tmp = word; if(tmp[i] != ch) { tmp[i] = ch; int t = depth.count(tmp); if((t == 0 && dict.count(tmp) > 0) || (t > 0 && depth[tmp] == cur_deep + 1) ) { graph[tmp].insert(word);
if(t == 0)
{ q.push(tmp);
depth[tmp] = cur_deep + 1;
} } } } } } if(found) { FindPath(graph, end, start); } return output; } };