本文是对斯坦福大学公开课《抽象编程》第10课内容的总结。
递归
打印一个字符集合的全排列
要解决这个问题,可以先模拟一下全排列的过程。假设字符集是{‘A’, ‘B’, ‘C’, ‘D’}:首先,从{‘A’, ‘B’, ‘C’, ‘D’}中任选一个字符作为排列结果的第一个字符,例如,我们可以先选择 ‘A’ 作为第一个字符;然后,在{‘B’, ‘C’, ‘D’}中为排列结果选择第二个字符,例如,我们可以选择 ‘B’ 作为第二个字符;再在{‘C’, ‘D’}中选择第三个字符,例如,我们选择 ‘C’ 作为第三个字符;最后在{‘D’}中选择第四个字符,显然,我们只能选择’D’作为第四个字符了。
事实上,一旦第一个字符确定下来,可以对所有剩下的字符进行全排列,并把得到的排列结果附加在刚才选定的第一个字符’A’之后,就得到了所有以’A’开头的排列结果;同理,我们可以分别得到所有以 ‘B’, ‘C’, ‘D’ 开头的排列结果。
显然,这是一个非常典型的递归求解过程。根据上面的描述,我们需要一个参数表示当前已经排列好的前若干个字符,也需要一个参数表示当前还有哪些字符可选。如果当前已经没有可选字符了,则打印当前已经排列好的字符并结束递归。所以,该递归算法可以实现如下:
void RecPermute(string sofar, string rest) {
if (rest == "") {
cout << sofar << endl;
} else {
for (int i = 0; i < rest.length(); ++i) {
string next = sofar + rest[i];
string remaining = rest.substr(0, i)
+ rest.substr(i + 1);
RecPermute(next, remaining);
}
}
}
void ListPermutations(string s) {
RecPermute("", s);
}
打印一个字符集合的所有子集
与全排列不同,这是一个组合问题。先来模拟一下求所有子集的过程。假设字符集是{‘A’, ‘B’, ‘C’, ‘D’}:首先,选择是否将 ‘A’ 放入子集;然后,选择是否将 ‘B’ 放入子集;再选择是否将 ‘C’ 放入子集;最后,选择是否将 ‘D’ 放入子集。
实际上,无论排列过程还是组合过程,都可以看做是一个不断进行选择的过程。在排列过程中,我们需要不断从所有剩余的字符中选择下一个要加入排列结果的字符;在组合过程中,我们必须不断选择是否要将当前的字符加入子集。
同样,这个问题可以用递归方式求解。实现如下:
void RecSubsets(string sofar, string rest) {
if (rest == "") {
cout << sofar << endl;
} else {
RecSubsets(sofar + rest[0], rest.substr(1));
RecSubsets(sofar, rest.substr(1));
}
}
void ListSubsets(string str) {
RecSubsets("", str);
}
回溯
无论是求全排列还是所有子集,都需要递归穷举所有可能的结果。然而,存在这样一类递归问题,由于预先设定了某些限制条件,使得它们不需要穷举每一种可能:在某一层递归中,逐一尝试所有可能,如果在尝试过程中找到满足限制条件的解,则直接返回,从而避免进行更多递归;否则,取消当前所做的选择,即进行回溯,继续尝试下一种选择。可以将回溯算法总结为下面的框架:
bool Solve(consiguration conf) {
if (no more choices)
return (conf is goal state);
for (all available choices) {
try one choice c;
if (Solve(conf with choice c made)) return true;
unmake choice c;
}
return false;
}
确定一个字符串是否是字典中某个单词的变位词
这个问题可以化归为一个全排列问题。给定一个字符串,我们可以先递归求出它的全排列,然后将每个排列结果和字典中的所有单词做对比,只要有一个排列结果能够和某个单词匹配上,问题就得到了肯定的解答。
然而,与普通的全排列问题不同,我们并不需要考察每一种可能。因为对于一个排列结果,只要它存在于字典中,就可以立即返回,其余的可能性可以统统不顾。事实上,此问题比较特殊:即使当前的排列结果不在字典中,也不必进行回溯,因为无论当前的排列结果是否在字典中,都不会对问题的状态造成影响。实现代码如下:
bool IsAnagram(string sofar, string rest, lexicon &lex) {
if (rest == "") {
return lex.containsWord(sofar);
} else {
for (int i = 0; i < rest.length(); ++i) {
if (IsAnagram(sofar + rest[i],
rest.substr(0, i) + rest.substr(i + 1),
lex))
return true;
}
}
return false;
}
N皇后问题
如何将N个皇后放在n*n棋盘上,使它们不能互相攻击。任意两个皇后互相攻击的条件是:或处于同一行,或处于同一列,或处于某条对角线上。显然,不能互相攻击是一种限制条件,这个条件促使我们按照回溯法的算法框架进行思考。
假设第一个皇后被放置在棋盘的(1, 1)位置,则第二个皇后只能摆放在第2列,且它不能被摆放在棋盘的(2, 2)位置,因为这会使它和第一个皇后处于对角线上而互相攻击,所以可以假设第二个皇后被放在了棋盘的(3, 2)位置。依此类推,假设前N-1个皇后都已经按照要求摆放完毕,当我们要摆放第N个皇后时,发现无论如何都不能使它满足限制条件,于是就可以怀疑是第N-1个皇后放错了位置;这时,必须将第N-1个皇后从棋盘上拿起,即进行“回溯”,并把它放在一个新位置上。注意,第N-1个皇后一定位于第N-1列上(假设列编号从1开始),新旧位置之间只是行号不同。如果第N-1个皇后尝试完所有行之后,依然不能得到满足限制条件的解,我们就可以怀疑是第N-2个皇后摆放位置有问题,从而将第N-2个皇后重新进行摆放。依此类推,直到为N个皇后找到满足限制条件的摆放位置。用回溯法解决此问题,实现如下:
bool Solve(Grid<bool> &board, int col) {
if (col >= board.numCols())
return true;
for (int rowToTry = 0; rowToTry < board.numRows; ++rowToTry) {
if (IsSafe(board, rowToTry, col)) {
PlaceQuene(board, rowToTry, col);
if (Solve(board, col + 1))
return true;
RemoveQuene(board, rowToTry, col);
}
}
return false;
}