回溯算法详解


前言

在正式接触回溯算法之前,我们先来看一道题目leetcode.77组合

给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。

看到这个题目,首先我们可以想到利用for循环不断遍历来获取所有组合,但是题目中的k值并不是一直为2。如果是3或者更大的数呢,很显然这种方法是行不通的。这里就需要用到我们接下来要说的回溯算法。
那么接下来我们就以leetcode上的这道题目为例来讲一下什么是回溯算法。


一、什么是回溯算法?

回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。

回溯算法能解决如下问题:

组合问题:N个数里面按一定规则找出k个数的集合
排列问题:N个数按一定规则全排列,有几种排列方式
切割问题:一个字符串按一定规则有几种切割方式
子集问题:一个N个数的集合里有多少符合条件的子集
棋盘问题:N皇后,解数独等等

二、回溯的模板

上文我们提到了回溯是递归的产物,那么我们再说一下递归的三部曲:
1.确定函数的参数和返回值
2.确定终止条件
3.确定单层处理逻辑(每层递归所需要处理的信息)
根据上面的步骤我们可以得出以下代码

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

三、组合问题

由于回溯算法本身不易理解,所以我们以leetcode77.组合这道题目来进一步分析一下回溯算法的过程。
前面我们提到了二叉树遍历,所以为了易于理解我们把问题转化为树型结构。
在这里插入图片描述

1. 在四个数中取出一个数。
2. 在剩余三个数中取一个数得到一个我们想要的结果集合,回溯到上一个节点再取另一个数,直到取完为止。
3. 每次取完就回溯到上一个节点,直到取完所有节点。

从上述步骤我们可以看出,整个完整的过程其实就是DFS遍历N叉树的过程。
我们可以得出以下代码(golang版本)

var res [][]int
var path []int
func combine(n int, k int) [][]int {
    res =res[:0]
    path=path[:0]
    dfs(n,k,1)
    return res
}
func dfs (n,k,startIndex int){
    if len(path)==k{//取到足够的数,向结果集合中添加
        res = append(res,append([]int{},path...))
        return
    }
    for i:=startIndex;i<=n;i++{
        path = append(path,i)//向path中加入本层取的数
        dfs(n,k,i+1)
        path = path[:len(path)-1]//返回上一层并删除最后一个数
    }
}

在这个过程中我们是遍历了所有的节点,但是从图中我们可以很明显的看出来,有一部分节点是不满足题意的,所以我们可以对回溯进行一个剪枝优化。那么怎么剪枝呢?
剪枝优化
根据组合这道题,我们可以得出两种数:我们所需要取的数以及我们还能取的数
我们只需要保证我们所需要取的数我们还能取的数少即可。
代码如下:

var res [][]int
var path []int
func combine(n int, k int) [][]int {
    res =res[:0]
    path=path[:0]
    dfs(n,k,1)
    return res
}
func dfs (n,k,startIndex int){
    if len(path)==k{//取到足够的数,向结果集合中添加
        res = append(res,append([]int{},path...))
        return
    }
    //k-len(path) 还需要取的数
    //n-startIndex+1 剩余的数
    if n-startIndex+1<k-len(path){
        return
    }
    for i:=startIndex;i<=n;i++{
        path = append(path,i)//向path中加入本层取的数
        dfs(n,k,i+1)
        path = path[:len(path)-1]//返回上一层并删除最后一个数
    }
}

四、组合总和

题目来源:leetcode39.组合总和
组合总和是在组合问题上的一种延申,他们的整体思路是一样的,只是各自的终止条件以及剪枝操作不同。
我们先看一下树型结构。
在这里插入图片描述

我们要注意的是题目中要求可以无限重复使用一个数,所以我们在向下遍历即递归过程中我们传递的起始位置不再加一,这样就可以遍历同一个数。
从图中我们可以看出,sum并不一定等同于target,所以我们的终止条件便是sum==target,之后的剪枝操作便很容易想到sum>tartget
代码实现:

var res [][]int
var path []int
func combinationSum(candidates []int, target int) [][]int {
    res = res[:0]
    path = path[:0]
    dfs(candidates,0,0,target)
    return res
}
func dfs (candidates []int,start,sum,target int){
    if sum ==target{
        tmp :=make([]int,len(path))
        copy(tmp,path)
        res = append(res,tmp)
        return
    }
    if sum>target{
        return
    }
    for i:=start;i<len(candidates);i++{
        path = append(path,candidates[i])
        dfs(candidates,i,sum+candidates[i],target)
        path = path[:len(path)-1]
    }
}

五、组合总和II

题目来源:组合总和II
组合总和II是组合总和问题的一种延申,其实就是在集合中会出现重复的数。
我们先来看一下转换之后的树型结构。
在这里插入图片描述

我们可以看到虽然我们遍历出了和为3的所有组合,但是其中存在大量的重复组合。
所以我们的剪枝操作不光要判断和是否大于3,还要保证在每一层中不会遍历重复的数。
我们可以对原数组进行排序,并在每一层中都判断此次所要取的数是否与上一次所取的数相等,即在横向遍历(for循环)中加入if (nums[i]==nums[i-1){continue}
注意:要保证ii-1都是在本层,即(i-1>=start===>i>start
接下来我们看一下代码实现

var res[][]int
func combinationSum2(candidates []int, target int) [][]int {
    res = res[:0]
    sort.Ints(candidates)
    dfs(candidates,[]int{},0,0,target)
    return res
}
func dfs (candidates,path []int,sum,start,target int){
    if sum>target{
        return
    }
    if sum ==target{
        res = append(res, append([]int{},path...))
        return
    }
    for i:=start;i<len(candidates);i++{
        if i>start&&candidates[i]==candidates[i-1]{	//在本层中i与i-1位置的数重复,跳过i
            continue
        }
        path = append(path,candidates[i])
        dfs(candidates,path,sum+candidates[i],i+1,target)
        path = path[:len(path)-1]
    }
}

六、全排列

题目来源:leetcode46.全排列
老样子我们先看看转化之后的树形结构
在这里插入图片描述

前面我们讲的组合问题是在树形结构中的同一层中,如果某一层取了一个数,那么其它层便不会取这个数,所以我们设置了一个起始位置start,然后再排列中我们每个元素都需要取到,所以我们便不需要设置起始位置了,每次都在0开始遍历。但是我们仍要注意不要遍历同一个位置的元素,所以我们使用了used来对每一个位置进行标记。
接下来看代码

var res [][]int
var path []int
func permute(nums []int) [][]int {
	res = res[:0]
    path = path[:0]
    used :=make([]bool,len(nums))
	dfs(len(nums), nums,used)
	return res
}
func dfs(n int, nums []int,used []bool) {
	if len(path) == n {
		tmp := make([]int, len(path))
		copy(tmp, path)
		res = append(res, tmp)
	}
	for i := 0; i < n; i++ {
        if used[i]{ //在这一层已经遍历过i的,跳过
            continue
        }
		path = append(path, nums[i])
        used[i] = true
		dfs(len(nums), nums,used)
        used[i] = false
		path = path[:len(path)-1]
	}
}

  • 5
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

小阿GO

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

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

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

打赏作者

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

抵扣说明:

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

余额充值