面试必会算法之递归
递归的基本性质就是函数调用,在处理问题的时候,递归往往是把一个大规模的问题不断地变小然后进行推导的过程。
递归(Recursion)
算法思想
递归算法是一种调用自身函数的算法(二叉树的许多性质在定义上就满足递归)。
举例 (汉诺塔问题)
有三个塔 A、B、C,一开始的时候,在塔 A 上放着 n 个盘子,它们自底向上按照从大到小的顺序叠放。现在要求将塔 A 中所有的盘子搬到塔 C 上,让你打印出搬运的步骤。在搬运的过程中,每次只能搬运一个盘子,另外,任何时候,无论在哪个塔上,大盘子不能放在小盘子的上面。
解法
1.从最终的结果出发,要把 n 个盘子按照大小顺序叠放在塔 C 上,就需要将塔 A 的底部最大的盘子搬到塔 C;
2.为了实现步骤 1,需要将除了这个最大盘子之外的其余盘子都放到塔 B 上。
由上可知,将原来的问题规模从 n 个盘子变成了 n-1 个盘子,即将 n-1 个盘子转移到塔 B 上。
代码
void hano(char A, char B, char C, int n) {
if (n > 0) {
hano(A, C, B, n - 1);
print(A + "->" +C);
hano(B, A, C, n - 1);
}
}
由上述总结出递归的算法思想,将一个问题的规模变小,然后再利用从小规模问题中得出的结果,结合当前的值或者情况,得出最终的结果。
通俗来说,把要实现的递归函数看成是已经实现好的, 直接利用解决一些子问题,然后需要考虑的就是如何根据子问题的解以及当前面对的情况得出答案。这种算法也被称为自顶向下(Top-Down)的算法。
例题分析一
LeetCode 第 91 题,解码的方法。
一条包含字母 A-Z 的消息通过以下方式进行了编码:
‘A’ -> 1
‘B’ -> 2
…
‘Z’ -> 26
给定一个只包含数字的非空字符串,请计算解码方法的总数。
解题思路
1.就例题中的第二个例子,给定编码后的消息是字符串“226”,如果对其中“22”的解码有 m 种可能,那么,加多一个“6”在最后,相当于在最终解密出来的字符串里多了一个“F”字符而已,总体的解码还是只有 m 种。
2.对于“6”而言,如果它的前面是”1”或者“2”,那么它就有可能是“16”,“26”,所以还可以再往前看一个字符,发现它是“26”。而前面的解码组合是 k 个,那么在这 k 个解出的编码里,添加一个“Z”,所以总的解码个数就是 m+k。
int numDecodings(String s) {
if (s.charAt(0) == '0') return 0;
char[] chars = s.toCharArray();
return decode(chars, chars.length - 1);
}
// 字符串转换成字符数组,利用递归函数 decode,从最后一个字符向前递归
int decode(char[] chars, int index) {
// 处理到了第一个字符,只能有一种解码方法,返回 1
if (index <= 0) return 1;
int count = 0;
char curr = chars[index];
char prev = chars[index - 1];
// 当前字符比 “0” 大,则直接利用它之前的字符串所求得的结果
if (curr > '0') {
count = decode(chars, index - 1);
}
// 由前一个字符和当前字符所构成的数字,值必须要在 1 到 26 之间,否则无法进行解码
if (prev == '1' || (prev == '2' && curr <= '6')) {
count += decode(chars, index - 2);
}
return count;
}
解题模板
通过上述例题,来归纳总结一下递归函数的解题模版
解题步骤
- 判断当前情况是否非法,如果非法就立即返回,这一步也被称为完整性检查(Sanity Check)。例如,看看当前处理的情况是否越界,是否出现了不满足条件的情况。通常,这一部分代码都是写在最前面的
- 判断是否满足结束递归的条件。在这一步当中,处理的基本上都是一些推导过程当中所定义的初始情况。
- 将问题的规模缩小,递归调用。在归并排序和快速排序中,我们将问题的规模缩小了一半,而在汉诺塔和解码的例子中,我们将问题的规模缩小了一个。
- 利用在小规模问题中的答案,结合当前的数据进行整合,得出最终的答案。
代码实现
function fn(n) {
// 第一步:判断输入或者状态是否非法?
if (input/state is invalid) {
return; //如果是有返回类型,返回一个对结果没有影响的值即可
}
// 第二步:判读递归是否应当结束?
if (match condition) {
return some value;
}
// 第三步:缩小问题规模
result1 = fn(n1)
result2 = fn(n2)
...
// 第四步: 整合结果
return combine(result1, result2)
}
例题分析二
LeetCode 第 247 题:找到所有长度为 n 的中心对称数。
示例
输入: n = 2
输出: ["11","69","88","96"]
解题思路
- 当 n=0 的时候,应该输出空字符串:“ ”。
- 当 n=1 的时候,也就是长度为 1 的中心对称数有:0,1,8。
- 当 n=2 的时候,长度为 2 的中心对称数有:11, 69,88,96。注意:00 并不是一个合法的结果。
- 当 n=3 的时候,只需要在长度为 1 的合法中心对称数的基础上,不断地在两边添加 11,69,88,96 就可以了。
[101, 609, 808, 906, 111, 619, 818, 916, 181, 689, 888, 986]
随着 n 不断地增长,我们只需要在长度为 n-2 的中心对称数两边添加 11,69,88,96 即可。
代码实现
List<String> helper(int n, int m) {
// 第一步:判断输入或者状态是否非法?
if (n < 0 || m < 0 || n > m) {
throw new IllegalArgumentException("invalid input");
}
// 第二步:判读递归是否应当结束?
if (n == 0) return new ArrayList<String>(Arrays.asList(""));
if (n == 1) return new ArrayList<String>(Arrays.asList("0", "1", "8"));
// 第三步:缩小问题规模
List<String> list = helper(n - 2, m);
// 第四步: 整合结果
List<String> res = new ArrayList<String>();
for (int i = 0; i < list.size(); i++) {
String s = list.get(i);
if (n != m) res.add("0" + s + "0");
res.add("1" + s + "1");
res.add("6" + s + "9");
res.add("8" + s + "8");
res.add("9" + s + "6");
}
return res;
}
算法分析
分析非递归算法的时间复杂度非常直接,例如,前面分析过冒泡排序以及插入排序的时间复杂度,分析方法就是数有多少层循环,由于每层循环里面执行的操作都是对比和交换,时间复杂度是 O(1),所以,最终的时间复杂度就是将每层循环的长度相乘。
分析递归算法推荐两种方法:
- 迭代法
- 公式法
迭代法
举例:分析汉诺塔递归函数的时间复杂度。
void hano(char A, char B, char C, int n) {
if (n > 0) {
hano(A, C, B, n - 1);
move(A, C);
hano(B, A, C, n - 1);
}
}
假设这个递归函数的运行时间是 T(n)。
- if 语句(一般取 if 块或 else 块之间最大的时间复杂度)中,比较和判断 n 的大小,CPU 的执行时间为 1 个单位。
- 两次调用递归函数,每次都使问题的规模减少 1 个,得到两倍的 T(n-1)。打印输出的语句,CPU 的执行时间也为 1 个单位。因此得出:T(n) = 1 + 2×T(n - 1) + 1。
此处 if 语句和打印输出语句的执行时间与问题规模 n 无关,因此它们的算法时间复杂度可以记为 O(1),表达式变为:T(n) = 2×T(n - 1) + O(1)。
当 n=0 的时候,T(0) = 1,因为当没有盘子的时候,if 语句也要进行一次比较,判断 n 是否大于 0。
3. 用迭代法将 T(n) 进行展开。
T(n - 1) = 2×T(n - 2) + 1,以此类推,不断地代入到 T(n) 的表达式当中,得到如下关系:
T(n) = 2× (2×T(n - 2) + 1) + 1 = 22×T(n - 2) + (2 + 1)
T(n) = 2×(2× (2×T(n - 3) + 1) + 1) + 1 = 23×T(n - 3) + (4 + 2 + 1)
T(n) = 2×(2×(2×(2×T(n - 4) + 1) + 1) + 1) + 1 = 24×T(n - 4) + (8 + 4 + 2 + 1)
…
T(n) = 2k×T(n - k) + (2k - 1)
其中,1 + 2 + 4 + 8 + … 是一个等比数列,由求和公式得到 2k - 1。当 k 等于 n 的时候,T(n) = 2n×T(0) + (2n - 1),由于 T(0) 等于 1,所以最终 T(n) = 2×2n - 1。
对 T(n) 求 O 的值得到:O(n) = O(T(n)) = O(2×2n - 1) ,忽略掉常量和系数,O(n) = O(2n)。
所以,整个算法的时间复杂度就是 O(2n)。
而很难通过迭代法推导出比较复杂的时间复杂度的时候,可以借用公式法。
公式法
公式法可以说是计算递归函数复杂度最方便的工具,当递归函数的时间执行函数满足如下的关系式时,我们可以利用公式法:T(n) = a×T(n/b) + f(n)。
其中,f(n) 是每次递归完毕之后额外的计算执行时间。例如,在归并排序中,每次递归处理完两边的数组后,我们需要执行合并的操作,那么这个操作的执行时间就是 f(n)。
当参数 a、b 都确定的时候,光看递归的部分,它的时间复杂度就是:O(n^logba)。
由于时间复杂度求的是上界(upper bound),通过对比递归部分的时间复杂度和 f(n) 的大小关系,得出最后的整体时间复杂度。牢记以下三种情况和相应公式:
- 当递归部分的执行时间 nlog(b)a 大于 f(n) 的时候,最终的时间复杂度就是 O(n^logba)。
- 当递归部分的执行时间 nlog(b)a 小于 f(n) 的时候,最终的时间复杂度就是 f(n)。
- 当递归部分的执行时间 nlog(b)a 等于 f(n) 的时候,最终的时间复杂度就是 O(n^logba)logn。
举例 1:分析归并排序的时间复杂度。
T(n) = 2T(n/2) + n
a = 2,b = 2,f(n) = n
logba = 1,n1 = n
符合第三种情况,最终的时间复杂度就是 O(nlogn)。
举例 2:分析下面函数的时间复杂度。
int recursiveFn(int n) {
if (n == 0) {
return 0;
}
return recursiveFn(n / 4) + recursiveFn(n / 4);
}
得出时间执行函数:T(n) = 2×T(n/4) + 1,a = 2,b = 4,f(n) = 1。
代入公式得到:n^log42 = n0.5,当 n>1 的时候,n0.5>1,因此,时间复杂度就是 O(n0.5)。
举例 3:已知时间执行函数如下,分析时间复杂度。
T(n) = 3×T(n/2) + n2
a = 3,b = 2,f(n) = n2
最复杂的操作发生在递归完成之后,符合第二种情况。
代入公式得到:n^log23 = n1.48<n2,最后递归的时间复杂度是 O(n2)。
OYO笔试题
在不使用额外的数据结构和开辟额外的空间情况下,翻转字符串(可以使用单一过程变量,比如String、Integer)。
输入:"This is nowcoder"
输出:"redocwon si sihT"
思路
取出每次取出最后一个字符,然后把剩下的字符当做新的字符串再次翻转,直到这个字符串只剩下一个,返回这个即可。比如这,取出最后一个,然后返回这个字符和变量为新字符串的这个方法。
代码实现
public static String reverseString(String iniString) {
if (iniString==null||iniString.length() <= 1)
return iniString;
String result = iniString.substring(iniString.length() - 1, iniString.length());
return result+ reverseString(iniString.substring(0, iniString.length() - 1));
}
时间复杂度计算
题目中调用递归次数为字符串的长度n,然后查看每次调用的差别,这里substring的复杂度为O(n),n为子串长度,这里每次都是一个字符,即为O(1),if判断也是一个单位。就可以得到这个公示:T(n) = T(n - 1) + O(1)。当n=0的时候,T(0)=1,因为为空的时候,还是需要判断。用迭代法将T(n)展开。
T(n-1) = T(n - 2)+1,以此类推,不断地代入到 T(n) 的表达式当中,得到如下关系:
T(n) = (T(n-2)+1)+1 = T(n-2)+2;
T(n) = ((T(n-3)+1)+1)+1 = T(n-3) + 3;
T(n) = (((T(n-4)+1)+1)+1)+1 = T(n-4) + 4;
T(n) = T(n-k)+k;
当k = n时:
T(n) = T(n - n) +n = T(0)+n = n+1;
对 T(n) 求 O 的值得到 O(T(n)) = O(n+1),去掉常数和系数,得到 O(T(n)) = O(n),即时间复杂度为O(n)。
小结
对于时间复杂度的分析是算法面试中非常重要的一环,掌握好迭代法和公式法,对于分析大多数面试题都有非常重要的帮助,需要通过不断地练习,来熟练运用它们。
结语
递归和回溯可以说是算法面试中最重要的算法考察点之一,很多其他算法都有它们的影子。例如,二叉树的定义和遍历就利用到了递归的性质;归并排序、快速排序的时候也运用了递归;还有动态规划,它其实是对递归的一种优化;还二分搜索,其实也可以利用递归去实现。
注意:要能熟练掌握好分析递归复杂度的方法,必须得有比较扎实的数学基础,比如对等差数列、等比数列等求和公式要牢记。
建议:LeetCode 上对递归和回溯的题目分类做得很好,有丰富的题库,建议大家多做。