【算法手记04】回溯算法

回溯是递归的副产品,只要有递归,就会有对应的回溯过程。
回溯实际上就是“撤销上一次递归操作”的一个过程。

回溯法是由递归+循环组成的,其中每次循环执行的次数应该是可知的。
每一次完成递归都会收集一次可能的结果,因此结果集的大小是不确定的,需要使用递归去找,我们称之为纵向搜索;
而每次循环会从待找集合中依次遍历,是一个横向搜索的过程。

模板

void backtracking(参数){
	if(终止条件){
		收集结果
		return;
	}
	//单层搜索,横向遍历
	for(集合){
		处理节点;
		//纵向遍历
		backtracking();
		回溯(撤销)
	}
}

回溯三部曲

  1. 确定递归函数参数和返回值
  2. 确定终止条件
  3. 确定单层搜索(递归)的逻辑

每一个回溯算法都可以抽象为N叉树。画图可以更清晰的理解算法过程。

组合问题

lc77.
给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。

示例 1:

输入:n = 4, k = 2
输出:

[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

示例 2:

输入:n = 1, k = 1
输出:

[
  [1]
]

提示:

  • 1 <= n <= 20
  • 1 <= k <= n
  1. 确定递归函数参数和返回值。 递归函数参数一般包含待遍历集合、起始搜索下标。返回值一般为void。本题中,待遍历集合则为[1,...,n],由于它是一个连续的自然数列,我们传入n即可,通过for循环就可以遍历了。同时,还需要传入组合大小k。
  2. 确定终止条件。注意,终止条件并不是整个搜索的终止条件,而是每一次纵向遍历的终止条件,也就是这条搜索路径的叶子结点。满足这个条件时,就表明该条路径搜索结束了,应该要判断是否需要收集结果了。本题中,如果当前组合中的数的已经达到k个,那么就应该终止。单次收集结果放入一维数组中,收集的结果应该加入二维数组中。为了方便,一般使用集合类。本题中使用List
  3. 确定单层搜索逻辑。此过程也就是for函数的内容。一般而言,单层搜索逻辑为处理节点-递归-撤销处理(回溯)三个步骤。本题中,处理节点即为向组合中添加当前节点,撤销处理也就是移出最新添加的节点。

image.png

public static List<List<Integer>> combine(int n, int k) {  
  
    LinkedList<Integer> path = new LinkedList<>();  
    List<List<Integer>> result = new ArrayList<>();  
    backtrackingCombine(path,result,n,k,1);  
    return result;  
  
}  
static void backtrackingCombine(LinkedList<Integer> path,List<List<Integer>> result,int n,int k,int startIndex){  
    //终止条件  
    if(path.size()==k){  
        //收集结果  
        result.add(new LinkedList<>(path));  
        return;  
    }  
    for(int i = startIndex;i<=n;i++){  
        path.add(i);//处理结点  
        backtrackingCombine(path,result,n,k,i+1);//递归到下一层  
        path.removeLast();//回溯,撤销处理  
    }  
  
}

本题中需要关注的点有:

  1. 初始时,startIndex从1开始;
  2. 收集结果时,不能直接把path加入到结果集中,因为这么做只会将path的浅拷贝加入集合中。我们此时需要进行深拷贝。
  3. 回溯时,注意撤销的是path的最后一个结点。由于java的ArrayList不提供直接删除最后一个结点的方式,虽然也可以使用remove(path.size()-1)的方式删除,并且本题中是完全合法的,但是由于remove重载了remove(Object o)remove(int index)两种实现,在List集合元素是Integer类型的情况下,也许某种情况下会出现歧义。因此选用LinkedList

组合剪枝

在上面的代码中,我们会发现以下情况是不必要考虑的:

  • 第一次回溯时,for循环来到i = 2及之后时;
  • 第二次回溯时,for循环来到i = 3及之后时;
  • 第三次回溯时,for循环来到i = 4及之后时。
    因为这种情况下,已选元素加上所有剩余可选元素也达不到要求的元素个数。此时我们就应该舍弃掉这种可能。这种方式称为剪枝

image.png

我们会发现,在组合问题中,剪枝通常发生在横向遍历中,即使用for循环遍历剩余可选结果集这个过程中。因此,我们只需要在for循环的终止条件上进行改动即可。

假设需要选的元素个数为 k k k,总共有 n n n个元素,已选的元素个数为 x x x,则至少还需要 k − x k-x kx个元素。为了后面能选上 k − x k-x kx个元素,for循环的i最多能取到 n − ( k − x ) + 1 n-(k-x)+1 n(kx)+1.

  • 为什么要用n减?以第一次选数为例,当你选择1时,后续还能选到2、3、4;当你选择2时,后面只能选到3、4.
  • 为什么要+1?因为本题中横向搜索域是左闭的,统计选取个数的时候需要包括起始位置。看下面的图就会很清楚:
    image.png
    n = 4 , k = 4 , x = 1 n=4,k=4,x=1 n=4,k=4,x=1时,还需要选取3个元素。从4往前数3个数(包括4)就是4-2+1=2了。

代码实现也很简单,x其实也就是是path.size()。改变for循环中的循环终止条件即可。如下:

if(path.size()==k){  
    //收集结果  
    result.add(new LinkedList<>(path));  
    return;  
}  
for(int i = startIndex;i<=n-(k-path.size())+1;i++){  
    path.add(i);//处理结点  
    backtrackingCombine(path,result,n,k,i+1);//递归到下一层  
    path.removeLast();//回溯,撤销处理  
}
  • 5
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值