强化面试中常用的算法 - 递归、回溯

强化面试中常用的算法 - 递归、回溯

目录

1 回顾
2 递归和回溯
      2.1 递归
      2.2 回溯
3 总结

1 回顾

      在上一个播客中,我总结了几种经典的排序算法,如果是在工作中,要对某个数组进行排序的话,我们直接利用函数库中的排序函数就好了,那么为什么在面试的时候还要考它们呢,原因就是面试主要考察是你分析问题和处理问题的能力,排序算法可以说是算法当中较为基础和简单的,但它们当中的一些思想还是很有用的,例如归并排序和快速排序采用的分治法就是高效的算法思想。
      今天给大家总结一下在面试中,经常被问到的算法,它们就是递归以及回溯。

2 递归和回溯

递归的基本性质:函数调⽤本身

      ‣ 把⼤规模的问题不断地变⼩,再进⾏推导的过程

回溯:利⽤递归的性质

      ‣ 从问题的起始点出发,不断尝试
      ‣ 返回⼀步甚⾄多步再做选择,直到抵达终点的过程

2.1 递归

      递归算法是⼀种调⽤⾃身函数的算法,我们在介绍二叉树的时候就提到了它,二叉树的很多性质,在定义上就满足递归的性质。递归的一大特点,就是他往往可以使一个看似复杂的问题变得简洁和易于理解。
经典案例: 汉诺塔(⼜称河内塔)

代码实现:

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);	
	}	
}

算法思想

      ‣ 要懂得如何将⼀个问题的规模变⼩
      ‣ 再利⽤从⼩规模问题中得出的结果
      ‣ 结合当前的值或者情况,得出最终的结果

通俗理解(自顶向下的方法)

      ‣ 把要实现的递归函数,看成已经实现好的
      ‣ 直接利⽤解决⼀些⼦问题
      ‣ 思考:如何根据⼦问题的解以及当前⾯对的情况得出答案

例题
      LeetCode91. 解码⽅法
      一条包含字母 A-Z 的消息通过以下映射进行了 编码 :

‘A’ -> 1
‘B’ -> 2

‘Z’ -> 26

      要解码已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,“11106” 可以映射为:
      1.“AAJF” ,将消息分组为 (1 1 10 6)
      2.“KJF” ,将消息分组为 (11 10 6)
      注意,消息不能分组为 (1 11 06) ,因为 “06” 不能映射为 “F” ,这是由于 “6” 和 “06” 在映射中并不等价。
      给你一个只含数字的 非空 字符串 s ,请计算并返回 解码 方法的 总数 。
      题目数据保证答案肯定是一个 32 位 的整数。

示例 1:
输入:s = “12”
输出:2
解释:它可以解码为 “AB”(1 2)或者 “L”(12)。

示例 2:
输入:s = “226”
输出:3
解释:它可以解码为 “BZ” (2 26), “VF” (22 6), 或者 “BBF” (2 2 6) 。

示例 3:
输入:s = “0”
输出:0
解释:没有字符映射到以 0 开头的数字。
含有 0 的有效映射是 ‘J’ -> “10” 和 ‘T’-> “20” 。
由于没有字符,因此没有有效的方法对此进行解码,因为所有数字都需要映射。

示例 4:
输入:s = “06”
输出:0
解释:“06” 不能映射到 “F” ,因为字符串含有前导 0(“6” 和 “06” 在映射中并不等价)。

解题思路:

      对于这道题目而言,我们首先想想能否把问题的规模变小,并且会对我们的分析有帮助。
      就例二来看,给定字符串"226",如果不看最后一个字符6,对于22来说,如果我们给定22的解码有m种可能,那么在这些m种可能的基础上,加多一个6在最后边,无非相当于在最终解密出来的字符串里多了一个f字符而已,最终的结果还是m种。
      继续分析,对于6而言,如果前一个字符是1或者2,那么它就可以构成16或者26,所以我们还可以再往前看一个字符,发现它是26,这个时候如果我们知道除了它之外,前面的解码组合是k个,那么我们也可以在这k个解出的编码里,添加上一个z就可以了,所以总的解码个数是m+k。

int numDecodings(String	s){
	char[]	chars =	s.toCharArray();
	return	decode(chars,chars.length -	1);
}
int decode(char[] chars,int	index){
	if(index <=	0{
		return 1;
	}
	int	count =	0;
	char curr =	chars[index];
	char prev =	chars[index	- 1];
	if (curr >	'0') {
		count =	decode(chars, index	- 1);
	}
	if(prev	 < '2' || (prev	== '2' && curr <= '6')){
		count += decode(chars, index - 2);		
	}
	return	count;
}

      这也就是⾃顶向下,与其相反的就是⾃底向上,动态规划就是典型的⾃底向上算法。

递归写法结构总结:

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)
}

递归函数有以下几个步骤组成:

      ‣ 判断当前情况是否⾮法,如果⾮法就⽴即返回,也称为完整性检查(Sanity Check)
      ‣ 判断是否满⾜结束递归的条件
      ‣ 将问题的规模缩⼩,递归调⽤
      ‣ 利⽤在⼩规模问题中的答案,结合当前的数据进⾏整合,得出最终的答案

例题
      LeetCode247.中心对称数II
      中⼼对称数是指⼀个数字在旋转了 180 度之后看起来依旧相同的数字(或者上下颠倒地看)。
      找到所有⻓度为 n 的中⼼对称数。

示例:
输⼊: n = 2
输出: [“11”,“69”,“88”,“96”]

解题思路

      我们先来看看一些最基本的情况:
      当n=0的时候,应该输出空字符串;
      当n=1的时候,也就是长度为1的中心对称数呢,应该有三个,也就是0,1,8;
      当n=2的时候,长度为2的中心对称数呢,有四个,也就是11,69,8,96。这里需要注意的是,00并不是一个合法的结果。
      那么当n=3时,我们只需要在长度为1的合法的中心对称数的基础上,不断的在两边添加11,69,88,96就可以了,比如101,609,808,906等;
      同理对于n=4的情况下也是一样的。比如1961,6969,8968,9966等。

代码实现:

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;	
}

思路:
      ‣ ⾸先判断输⼊的值是否合法
      ‣ 当处理 n = 0 以及 n = 1 时的情况,也就是做⼀些递归结束条件的判读
      ‣ 将问题的规模缩⼩变为 n - 2
      ‣ 在 n - 2 的基础上,添加 11,69,88,96 即可

2 种递归算法解决时间复杂度分析

‣ 迭代法

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);	
	}	
}

时间复杂度:O(n) = 2n

‣ 公式法 - 计算递归函数复杂度最⽅便的⼯具

      当递归函数的时间执⾏函数满⾜如下的关系式时,可以利⽤公式法:T(n) = a ⋅ T(n/b) + f(n) f(n)指每次递归完毕后,额外的计算执⾏时间,当参数 a,b 都确定时,只看递归部分,时间复杂度就是:O(nlogba)

      只需要牢记 3 种可能会出现的情况以及处理它们的公式即可

      ‣ 情况⼀:
      当递归部分的执⾏时间O(nlogba) > f(n)的时候,最终的时间复杂度就是O(nlogba)

      ‣ 情况⼆:
      当递归部分的执⾏时间O(nlogba) < f(n)的时候,最终的时间复杂度就是f(n)

      ‣ 情况三:
      当递归部分的执⾏时间O(nlogba) = f(n)的时候,最终的时间复杂度就是O(nlogba) logn

例⼦⼀:
      递归排序的时间执⾏函数:T(n) = 2 × T(n/2) + n
                                                  a = 2, b = 2, f(n) = n
                                                  logba = 1, n1 = n
      因此,符合第三种情况,最终的时间复杂度就是 O(nlogn)

例⼦⼆:

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
      代⼊公式,得到:nlog42= n \sqrt{n} n
      当n > 1时, n \sqrt{n} n > 1则时间复杂度就是:O( n \sqrt{n} n )

例⼦三:

      对于第⼆种情况,它表示最复杂的⼯作发⽣在递归完成之后的操作,例如:T(n) = 3 × T(n/2) + n2
                         a = 3, b = 2, f(n) = n2
      代⼊公式,得到:nlog23 = n1.48 < n2
      最后,递归的时间复杂度是 O(n2)

2.2 回溯

      回溯算法是⼀种试探算法,与暴⼒搜索最⼤的区别:
      在回溯算法中,是⼀步步向前试探,对每⼀步探测的情况评估,再决定是否继续,可避免⾛弯路

回溯算法的精华

      ‣ 出现⾮法的情况时,可退到之前的情景,可返回⼀步或多步
      ‣ 再去尝试别的路径和办法
      想要采⽤回溯算法,就必须保证:每次都有多种尝试的可能

解决问题的套路

function fn(n){
	//	第⼀步:判断输⼊或者状态是否⾮法?
	if(input/state is invalid) {
		return;
	}
	//	第⼆步:判读递归是否应当结束?
	if(match condition)	{
		return some value;
	}
	//	遍历所有可能出现的情况
	for	(all possible cases) {
	//	第三步:	尝试下⼀步的可能性
		solution.push(case)
	//	递归
		result = fn(m)
	//	第四步:回溯到上⼀步
		solution.pop(case)
	}
}

一般步骤:

      ‣ ⾸先判断当前情况是否⾮法,如果⾮法就⽴即返回
      ‣ 看看当前情况是否已经满⾜条件?如果是,就将当前
结果保存起来并返回
      ‣ 在当前情况下,遍历所有可能出现的情况,并进⾏递归
      ‣ 递归完毕后,⽴即回溯,回溯的⽅法就是取消前⼀步进⾏的尝试

例题
      LeetCode39.组合总和

题目:
      给定一个无重复元素的正整数数组 candidates 和一个正整数 target ,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。
      candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。
      对于给定的输入,保证和为 target 的唯一组合数少于 150 个。

示例 1:
输入: candidates = [2,3,6,7], target = 7
输出: [[7],[2,2,3]]

示例 2:
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:
输入: candidates = [2], target = 1
输出: []

示例 4:
输入: candidates = [1], target = 1
输出: [[1]]

示例 5:
输入: candidates = [1], target = 2
输出: [[1,1]]

分析:

      题目要求的是所有不重复的子集,而且子集里面的元素的值的总和等于一个给定的目标。暴力的做法就是lou罗列出所有的子集的总和,然后一个一个的去判断,他们的总和是否为给定的目标值,很显然这样的解法是非常慢的。我们可以从一个空的集合开始,小心翼翼的往里面添加元素,每次添加的时候,检查一下当前的总和是否等于给定的目标,如果总和已经超出了目标,说明没有必要在尝试其他的元素了,接着返回并尝试其他的元素,如果总和等于目标,就把当前的组合添加到结果当中,表明我们找到了一组满足要求的组合。同时返回,并试图寻找其他的组合。

代码:

int[][] combinationSum(int[] candidates,int	target){
	int[][]	results;
	backtracking(candidates, target, 0, [],	results);
		return results;
	}
	void backtracking = (int[] candidates, int target, int start, int[] solution,int[][] results) => {
	if(target < 0) {
		return;
	}
	if(target === 0) {
		results.push(solution);
		return;
	}
	for (int i = start;i < candidates.length; i++){
		solution.push(candidates[i]);
		backtracking(candidates, target	- candidates[i], i , solution, results);
		solution.pop();
	}
}

例题2

LeetCode52. N皇后 II

题目:
n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回 n 皇后问题 不同的解决方案的数量。

示例 1:
在这里插入图片描述
输入:n = 4
输出:2
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:
输入:n = 1
输出:1

分析:

      直观的做法是暴力枚举将 N 个皇后放置在 N×N 棋盘上的所有可能的情况,并对每一种情况判断是否满足皇后彼此之间不相互攻击。暴力枚举的时间复杂度是非常高的,因此必须利用限制条件加以优化。
      显然,每个皇后必须位于不同行和不同列,因此将 N 个皇后放置在 N×N 的棋盘上,一定是每一行有且仅有一个皇后,每一列有且仅有一个皇后,且任何两个皇后都不能在同一条斜线上。基于上述发现,可以通过回溯的方式得到可能的解的数量。
      回溯的具体做法是:依次在每一行放置一个皇后,每次新放置的皇后都不能和已经放置的皇后之间有攻击,即新放置的皇后不能和任何一个已经放置的皇后在同一列以及同一条斜线上。当 N 个皇后都放置完毕,则找到一个可能的解,将可能的解的数量加1。
      由于每个皇后必须位于不同列,因此已经放置的皇后所在的列不能放置别的皇后。第一个皇后有 N 列可以选择,第二个皇后最多有 N−1 列可以选择,第三个皇后最多有 N−2 列可以选择(如果考虑到不能在同一条斜线上,可能的选择数量更少),因此所有可能的情况不会超过 N! 种,遍历这些情况的时间复杂度是O(N!)。
      为了降低总时间复杂度,每次放置皇后时需要快速判断每个位置是否可以放置皇后,显然,最理想的情况是在 O(1) 的时间内判断该位置所在的列和两条斜线上是否已经有皇后。

boolean check(int row,int col,int[]	columns){
	for(int	r = 0;r < row; r++){
		if(columns[r] == col || row - r == Math.abs(columns[r]	- col)){
			return false;
		}
	}
	return true;
}
int	count;
int totalNQueens(int n) {
	count = 0;
	backtracking(n,	0, new int[n]);
	return	count;
}
void backtracking(int n,int	row,int[] columns){
	//	是否在所有n⾏⾥都摆放好了皇后?
	if(row == n){
		count++;	//	找到了新的摆放⽅法
		return;
	}
	//	尝试着将皇后放置在当前⾏中的每⼀列
	for(int	col	= 0;col < n;col++)	{
		columns[row] = col;
		//	检查是否合法,如果合法就继续到下⼀⾏
		if(check(row, col, columns)){
			backtracking(n,	row	+	1,	columns);
		}
		//	如果不合法,就不要把皇后放在这列中(回溯)
		columns[row] = -1;
	}
}

回溯其实是⽤递归实现的

      因此在分析回溯的时间复杂度时,其实就是在对递归函数进⾏分析,⽅法和之前介绍的⼀样

分析⼀下 N 皇后的时间复杂度

假设 backtracking 函数的执⾏时间是 T(n)
‣⾸先每次都必须遍历所有的列,这⾥⼀共有n列
      -先要利⽤ check 函数检查当前的摆放⽅法会不会产⽣冲突
      -检查的时间复杂度由当前所在的⾏决定
      -但其上限是 n,即需要检查 n ⾏,所以这⾥的总时间复杂度就是 O(n2)
‣ 接下来,递归地尝试着每种摆放
      -当放好了第 1 个皇后,接下来要处理的之后n − 1个皇后
      -问题的规模减少了⼀个,于是执⾏时间变成了 T(n − 1)
‣ 最终得到T(n)的表达式:T(n) = n × T(n − 1) + O(n2)
得出O(T(n)) = n!

3. 总结

      递归和回溯可以说是算法⾯试中最重要的算法考察点之⼀,很多其他算法都有它们的影⼦
      ‣ ⼆叉树的定义和遍历
      ‣ 归并排序、快速排序
      ‣ 动态规划
      ‣ ⼆分搜索
      熟练掌握分析递归复杂度的⽅法,必须得有⽐较扎实的数学基础,⽐如要牢记等差数列、等⽐数列等求和公式。
      ‣ ⼒扣上对递归和回溯的题⽬分类做得很好,有丰富的题库,建议⼤家多做。

       本文章是小朱近日刷算法题,看视频之后做的一些笔记和总结,希望大家有所收获,不足之处,请指正,如果对你有帮助可以点赞加收藏。

  • 3
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小朱不猪

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值