LeetCode 刷题笔记——递归与回溯的理解
马上就要入职了。在入职之前受师兄点拨,疯狂刷 LeetCode,整个痛并快乐着的过程中,在算法和数据结构方面受益良多。
在刷题过程中,很快的就遇到了闻名已久的递归 (Recursive)。首次遇到递归,是 LeetCode 的第 17 题:Letter Combinations of a Phone Number。解这道题的时候,虽然之前没有专门学过,但最先就想到了递归的解法,无师自通的把这个题解开了还让我得意了许久~
不过后来又遇到了第 22 题:Generate Parentheses,本来以为是一个很简单的,可以无脑用递归解决的问题,直接废了我一个上午…… 后来网上查了一下,它们说要用回溯 (Backtrack)的方法理解并解答。一看代码,形式同样也是反复调用函数自身,感觉这和递归并没什么区别啊?
于是多做了几道关于递归和回溯的问题,并在网上找了一些大神们的言论,自己对递归和回溯进行一些总结如下。
参考地址:
- 题库:
- 递归与回溯的区别解释:
- 答题思路与源码
一. 递归与回溯
首先先说明一下对递归 (Recursive)与回溯 (Backtrack)的理解。
1. 递归 (Recursive)
程序调用自身的编程技巧称为递归( recursion)。
递归做为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。
——摘自《百度百科》
通常来说,为了描述问题的某一状态,必须用到该状态的上一个状态;而如果要描述上一个状态,又必须用到上一个状态的上一个状态…… 这样用自己来定义自己的方法就是递归。
2. 回溯 (Backtrack)
回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
——摘自《百度百科》
回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法。
按照《以Generate Parentheses为例,backtrack的题到底该怎么去思考?》帖子中的解释,回溯的思路基本如下:当前局面下,我们有若干种选择,所以我们对每一种选择进行尝试。如果发现某种选择违反了某些限定条件,此时 return;如果尝试某种选择到了最后,发现该选择是正确解,那么就将其加入到解集中。
在这种思想下,我们需要清晰的找出三个要素:选择 (Options),限制 (Restraints),结束条件 (Termination)。
注:
对于这种思想的解释,后面会有 LeetCode 的例题进行解释说明。
3. 递归与回溯的区别
递归是一种算法结构。递归会出现在子程序中,形式上表现为直接或间接的自己调用自己。典型的例子是阶乘,计算规律为:
int fac(int n) { if(n == 1) return n; else return (n*fac(n - 1)); }
回溯是一种算法思想,它是用递归实现的。回溯的过程类似于穷举法,但回溯有“剪枝”功能,即自我判断过程。例如有求和问题,给定有 7 个元素的组合 [1, 2, 3, 4, 5, 6, 7],求加和为 7 的子集。累加计算中,选择 1+2+3+4 时,判断得到结果为 10 大于 7,那么后面的 5, 6, 7 就没有必要计算了。这种方法属于搜索过程中的优化,即“剪枝”功能。
用一个比较通俗的说法来解释递归和回溯:
我们在路上走着,前面是一个多岔路口,因为我们并不知道应该走哪条路,所以我们需要尝试。尝试的过程就是一个函数。
我们选择了一个方向,后来发现又有一个多岔路口,这时候又需要进行一次选择。所以我们需要在上一次尝试结果的基础上,再做一次尝试,即在函数内部再调用一次函数,这就是递归的过程。
这样重复了若干次之后,发现这次选择的这条路走不通,这时候我们知道我们上一个路口选错了,所以我们要回到上一个路口重新选择其他路,这就是回溯的思想。
二. 例题
当前笔者在 LeetCode 中做到的与递归与回溯相关的题目有:
- LeetCode 17: Letter Combinations of a Phone Number:给出数字字符串,以电话按键为映射,返回所有可能的字符串;
- LeetCode 22: Generate Parentheses:生成 n 对括号,并穷举所有可能性;
- LeetCode 46: permutations:对数组进行排列组合;
在 Backtracking 标签中,有 30+ 道与递归、回溯相关的例题。笔者这里只用几道题进行示例。以后如果遇到更好的例题,会继续进行更新。
1. 递归例题
(1) LeetCode 17: Letter Combinations of a Phone Number
例题说明:给出一个数字字符串,返回这些数字所有可能的字符串组合。数字向字符的映射如下图所示:

例:
输入: “23”
输出: [“ad”, “ae”, “af”, “bd”, “be”, “bf”, “cd”, “ce”, “cf”]
主要的思路是将输入的数字字符串从后向前遍历,每个字符进行单字符的映射,并将所有单字符映射与目标集合中的所有字符串拼接,形成新的字符串集合。拼接的过程,就是递归实现的部分。
具体的实现思路如下:
- 建立字典映射表;
- 从后向前遍历当前数字字符串;
- 若当前数字字符串长度超过 1,则从当前字符串的第 2 位到末尾作为子字符串,将该子串作为输入参数,重新输入该函数,这里即为递归的实现。
- 字典中查找当前字符串的首位数字对应的所有字符,并对目标集合进行双重遍历,实现首位数字对应字符与目标集合中所有字符串的拼接;
笔者提交的 C++ 具体实现代码如下:
class Solution { public: vector<string> letterCombinations(string digits) { vector<string> res, dst; // 初始化字典映射 unordered_map<int, string> dict; dict.insert(make_pair<int, string>(2, "abc")); dict.insert(make_pair<int, string>(3, "def")); dict.insert(make_pair<int, string>(4, "ghi")); dict.insert(make_pair<int, string>(5, "jkl")); dict.insert(make_pair<int, string>(6, "mno")); dict.insert(make_pair<int, string>(7, "pqrs")); dict.insert(make_pair<int, string>(8, "tuv")); dict.insert(make_pair<int, string>(9, "wxyz")); // 检查输入字符是否在 2 - 9 范围内 for(int i = 0; i < digits.size(); ++i) if(digits[i] < '2' || digits[i] > '9') { vector<string> nullstrs; return nullstrs; } // 递归思路 if(digits.size() > 1) { string sub_digits = digits.substr(1, digits.length() - 1); res = letterCombinations(sub_digits); } // 查找字符串首字母对应的所有字符 string keystr = dict[digits[0] - '0']; if(digits.length() == 1) { for(int m = 0; m < keystr.length(); ++m) { string tmp = ""; tmp += keystr[m]; dst.push_back(tmp); } } // 双重循环,将vector<string> 与 char 组合,形成一个新的 vector<string> for(int m = 0; m < keystr.length(); ++m) { for(int n = 0; n < res.size(); ++n) { string tmpstr = keystr[m] + res[n]; dst.push_back(tmpstr); } } return dst; } };
(2) LeetCode 46: permutations
例题说明:给出一组互不相同的数字形成的集合,返回所有的排列组合。
例:
输入: [1, 2, 3]
输出:
[ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]
主要的思路是遍历输入集合,抽取当前数组其中一个元素,此时将集合分为两部分:抽取元素,以及剩余元素组成的子集合。对子集合不断进行递归操作,最后将先前抽取的元素放置在每次递归返回的结果尾部。
具体的实现思路如下:
- 设立递归的返回条件:输入集合元素数量小于等于 1,则立即返回;
- 遍历输入集合所有元素:
- 将集合分为两部分:挑选集合中任一个元素,以及剩余元素组成的子集;
- 对子集进行递归,返回一个集合;
- 将先前挑出来的元素放置在递归后返回的集合中;
笔者提交的 C++ 具体实现代码如下:
class Solution { public: vector<vector<int>> permute(vector<int>& nums) { vector<vector<int> > res, dst; // nums 容量小于等于 1,立即返回 if(nums.size() <= 1) { dst.push_back(nums); return dst; } // 遍历 nums 所有元素 for(int i = 0; i < nums.size(); ++i) { // 将 nums 分离成一个元素,其他部分分为一个子集 // num: 分离元素 // set: 子集 int num = nums[i]; vector<int> set = nums; set.erase(set.begin() + i); // 递归 res = permute(set); for(int j = 0; j < res.size(); ++j) { // subPermu: 递归结果的单一变量 vector<int> subPermu = res[j]; subPermu.push_back(num); dst.push_back(subPermu); } } return dst; } };
2. 回溯例题
回溯问题给我个人的感觉,就是感觉在做之前设想了各种边界条件和可能情况,结果都没有什么卵用,最终出来的结果还各种错误。但看了网上的回溯解法之后,发现人家的解法就设立了几个看似简单的边界条件,返回条件,然后用一些递归形式的函数就完美解决问题了……
回溯的代码形式看似简单,但思想深度是十分惊人的。笔者个人强烈建议,面对不能理解的回溯问题,最好用单步调试的方法,逐步观察变量的变化,分析所有步骤之间的联系性。笔者的回溯调试过程中,每次按下“继续调试”按钮,都会惊呼一句卧槽真 TM 牛逼…… 自此之后,笔者感觉到自己触及了回溯的边边角角。
目前做的关于回溯的问题比较少,但 LeetCode 的第 22 题:Generate Parentheses,十分具有代表性。
(1) LeetCode 22: Generate Parentheses
例题说明:给定 n 对括号,写出一个函数,令其产生所有正常格式括号的组合。
例:输入 n = 3,输出解集为:
[ "((()))", "(()())", "(())()", "()(())", "()()()" ]
针对该题,在帖子《以Generate Parentheses为例,backtrack的题到底该怎么去思考?》中,从前文提到的选择、限制、结束条件的角度出发,进行了专门的解释:
该问题的选择:任何时刻都有两种选择:
A. 加左括号
B. 加右括号
该问题的限制:
A. 如果左括号已经用完,则不能再加左括号了;
B. 如果已经出现的右括号和左括号一样多,则不能再加右括号了(因为这样的话新加入的右括号一定无法匹配);
该问题的结束条件:
左右括号全部用完;
此外,还要考虑到该题的其他问题:
结束之后的正确性:左右括号同时用完,一定是正解(一方面左右括号个数相等,另一方面每个右括号都一定有配对的左括号)。
递归函数传入参数:
A. 左右括号的数目(因为限制条件和结束条件中有“用完”“一样多”的字样);
B. 当前字符串 sublist,解集 res;
笔者提交的 C++ 具体实现代码如下:
class Solution { public: vector<string> generateParenthesis(int n) { vector<string> res; backtrack_Parentheses("", res, n, n); return res; } void backtrack_Parentheses(string sublist, vector<string>& res, int left, int right) { if(left == 0 && right == 0) { res.push_back(sublist); return ; } if (left > right) return; if (left > 0) backtrack_Parentheses(sublist + "(", res, left - 1, right); if (right > 0) backtrack_Parentheses(sublist + ")", res, left, right - 1); } };
(2) LeetCode 46: Permutations
前面用递归法对 LeetCode 46: permutations 进行了处理,这里用的是回溯思想进行处理。我们用上面解析回溯的思路对这个问题进行分析。
该问题的选择:将哪个元素挑选出来,将集合分为单一元素与子集?
该问题的限制:递归过程中,输入参数容量不能少于两个;
该问题的结束条件:将原集合的所有元素遍历完毕;
将上述问题考虑清楚,即可写出上面二. 1. (2) 的 C++ 代码。
总结
递归与回溯,都需要胆大心细的逻辑能力,都是很难理解的解题方法。对于一个问题,如果描述它的某一状态必须用到该状态的上一个状态,且如果要描述上一个状态,又必须用到上一个状态的上一个状态,递归与回溯都十分适合这种解题思路。笔者认为,只有勤加练习,而且在初练时最好用单步调试的方法对逻辑进行理解,才能熟练掌握递归与回溯的思想。
来源:CSDN
作者:琦小虾
链接:https://blog.csdn.net/ajianyingxiaoqinghan/article/details/79682147