最小操作数
题目大意: 给定一个字典和两个单词。问能否从给定的第一个单词开始,每次只改变一个字母,最终变为第二个单词。要求除第一个单词和最后一个单词外,中间的单词都在给定的字典内。给出最小的最短的变换序列。如果有多组解,给出全部的解。字典里的单词都同样长,全由小写字母组成。
分析:本题是一个广度优先搜索的典型问题。
简介:图的两种搜索方法:常见的图搜索算法有两种,分别简介如下:
(1) 深度优先搜索 (Depth First Search,DFS):
比如走迷宫问题,我们从入口开始沿着边不断地向“深处”走,能走则走。当遇到岔路时,我们按一定顺序选择第一条岔路继续下去,当遇到死路时,我们退回到最后一个岔路的位置再选择第二条岔路继续下去,直到我们遍历了图的所有边。这种搜索方法包含了递归回溯的思想。DFS有很多应用,例如有向图的强连通分量问题、拓扑排序问题都可以利用该思想解决。
在实现上,我们通常采取堆栈作为数据结构,堆栈里保存的是我们经过的节点。当走到死路回退时,我们从堆栈里弹出这个没用的节点而回到上级节点继续尝试搜索。Dfs的优点是比较省空间(因为只保存了一条路径),程序写起来简单(特别是可以利用系统自带的堆栈写递归程序,而不需要自己写堆栈时)。但是dfs也有缺点,最明显的一条是当它找到一条路径(一个解)时,是任意的一个解,如果我们需要找到一个最优解,通常的dfs算法一般要遍历所有的解才可能找出。于是,人们为了提出了很多改进,最简单的是所谓分支定界,在每一步时把不可能是最优解的那些路“堵死”。比较复杂的是迭代加深dfs等。
(2) 广度优先搜索,也叫宽度优先搜索 (Bredth First Search, BFS):
BFS的思想不如DFS那么直观。还是以走迷宫问题为例子,我们从入口开始,先把只走一步能到达的位置进行标记,再从只走一步能达到的位置再走一步标记新的节点……最终看能不能标记到出口。
BFS做了什么?BFS做的事情是把一个图进行分层。起点在第0层,从起点开始只走一步能达到的未知在第一层。我们把从每一层的位置开始,标记只走一步能达到的未知作为下一层的节点。重复的节点因为在本层或者更早的层出现过,我们不予标记。这样可以用诸如数学归纳法似的形式化方法证明从起点到第i层的点至少需要走i步才可以到达。
可见bfs可以处理“最少步数”的问题,每到达一个未知,一定是经过从起点达到未知最少的步数。Bfs有这个优点,但是它的缺点是存储空间通常比dfs大很多。我们只要最优解,可是同时存了许多没必要出现的解。为此,后人提出了A*算法之类的作为改进。BFS通常采用队列实现,每层的节点按层的先后顺序加入队列,第i层的节点逐个出队,第(i+1)层的节点逐个入队。
言归正传,相信大家已经能想到了本题我们应该采用bfs算法。那么我们还需要解决两个问题:
(1) 本题的图是什么?
BFS是图的搜索算法之一,而本题没有给定图,其实很少有题目会直接告诉你一张图,然后让你遍历。图是隐式的结构,需要我们自己建立。涉及到图就有这么几个问题要思考,节点是什么?边如何建立?图是有方向的还是无方向的?
对于本题,我们的图的节点是字典里地单词。当然,我们可以把给定的两个单词也加入字典中进行统一处理。两个节点有连边,对应着我们可以把一个单词按照规则变为另外一个单词。比如我们有单词hat,它应该与单词cat有一条连边,因为我们可以把h变为c,同样我们也可以把c变为h,所以我们建立的连边应该是无向的。
如何建图?有两种办法,第一我们可以把字典里的任意两个单词,通过循环判断一下这两个单词是否只有一个位置上的字母不同。我们简单分析一下这个方法的复杂度,假设字典里有n个单词,我们遍历任意两个单词的复杂度是O(n2),假设每个单词长度为length,我们判断两个单词是否连边的复杂度是O(length),所以这个建图的总复杂度是O(n2*length)。当n比较大时,这个复杂度非常高。
第二种方法是,我们把字典里地每个单词的每个位置的字母修改一下,从字典里查找一下,修改后的单词是否在字典里出现过。这个算法的复杂度简单分析如下:我们需要遍历字典里地每一个单词O(n),尝试修改每个位置的每个字母,对每个位置我们需要尝试26个字母(其实是25个,因为要改得和原来不同),因此这部分复杂度是O(26*length),总复杂度是O(26 * n * length)。
两种方法孰优孰劣,只能看一下字典里单词个数与长度之间的关系了。但是通常情况下,我们可认为字典里的单词个数非常多,也就是n比较大,因此可能第二种方法效果会好一些。
如果我们选择第二种方法建图,我们对每个单词每个未知需要尝试26次修改,事实上我们可以利用图是无向的这一特点,我们对每个位置试图把该位置的字母变到字典序更大的字母。例如,我们只考虑cat变成hat,而不考虑hat变成cat,因为再之前已经把无向边建立了。这样,只进行一半的修改次数,从而减少程序的运行时间。当然这个优化从复杂度上来讲是常数的,因此称为常数优化(我们经常忽略O背后隐藏的常数)。
(2) 如何记录单词序列?
对于最简单的bfs,我们是如何记录路径的?如果只需要记录一条最短路径的话,我们可以对每个走到的位置,记录走到它的前一个位置。这样到终点后,我们可以不断找到它的前一个位置。我们利用了最短路径的一个特点:即第二次经过一个节点的时候,路径长度不比第一次经过它时短。因此这样的路径是没有圈的。
但是本题需要记录全部的路径。我们第二次经过一个节点时,路径长度可能会和第一次经过一个节点时路径长度一样。这是因为,我们可能在第i层中有多个节点可以到达第(i + 1)层的同一个位置,这样那个位置有多条路径都是最短路径。但是我们也有办法解决——我们记录经过这个位置的前面所有位置的集合。这样一个节点的前驱不是一个节点,而是一个节点的集合。当然这里,我们需要注意判断,第二次经过一个第(i+ 1)层的位置时是从第i层经过的还是从更靠后的层经过的,如果是后者,我们同样不能保留它前面那个位置作为前驱。
解决了以上两个问题,我们最终得到的是什么?如果有解的话,我们最终得到的是从终点开始,的前一个可能单词的集合,对每个单词,我们都有能得到它的上一个单词的集合,直到起点。这就是前面bfs分层之后的图,我们从终点开始遍历这个图的到起点的所有路径,就得到了所有的解,这个遍历我们可以采用之前介绍的dfs方法(路径的数目可能非常多)。
其实,为了简单起见,我们可以从终点开始bfs,因为记录路径记录的是之前的节点,也就是反向的。这样最终可以按顺序从起点遍历到终点的所有路径。
代码:
class Solution { public: // help 函数负责找到所有的路径 void help(intx,vector<int> &d, vector<string> &word,vector<vector<int> > &next,vector<string> &path,vector<vector<string> > &answer) { path.push_back(word[x]); if (d[x] == 0) { //已经达到终点了 answer.push_back(path); } else { int i; for (i = 0; i <next[x].size(); ++i) { help(next[x][i],d, word, next,path,answer); } } path.pop_back(); //回溯 } vector<vector<string>> findLadders(string start, string end, set<string>& dict) { vector<vector<string> > answer; if (start == end) { //起点终点恰好相等 return answer; } //把起点终点加入字典的map dict.insert(start); dict.insert(end); set<string>::iterator dt; vector<string> word; map<string,int>allword; //把set转换为map,这样每个单词都有编号了。 for (dt = dict.begin(); dt!= dict.end(); ++dt) { word.push_back(*dt); allword.insert(make_pair(*dt, allword.size())); } //建立连边 邻接表 vector<vector<int> > con; int i,j,n =word.size(),temp,len = word[0].length(); con.resize(n); for (i = 0; i < n; ++i){ for (j = 0; j <len; ++j) { char c; for (c =word[i][j] + 1; c <= 'z'; ++c) { //每个单词每个位置变更大 char last =word[i][j]; word[i][j] =c; map<string,int>::iterator t = allword.find(word[i]); if (t !=allword.end()) { con[i].push_back(t->second); con[t->second].push_back(i); } word[i][j] =last; } } } //以下是标准bfs过程 queue<int> q; vector<int> d; d.resize(n, -1); int from = allword[start],to = allword[end]; d[to] = 0; //d记录的是路径长度,-1表示没经过 q.push(to); vector<vector<int> > next; next.resize(n); while (!q.empty()) { int x = q.front(), now= d[x] + 1; if ((d[from] >= 0)&& (now > d[from])) { break; } q.pop(); for (i = 0; i <con[x].size(); ++i) { int y = con[x][i]; if (d[y] < 0) { //第一次经过 d[y] = now; q.push(y); next[y].push_back(x); } else if (d[y] ==now) { //是从上一层经过的,所以要保存 next[y].push_back(x); } } } if (d[from] >= 0) { //有解 vector<string>path; help(from, d,word,next, path,answer); } return answer; } };