专题三:穷举vs暴搜vs深搜vs回溯vs剪枝

100 阅读7分钟

0 什么是回溯算法

回溯算法是⼀种经典的递归算法,通常⽤于解决组合问题、排列问题和搜索问题等。

回溯算法的基本思想:从⼀个初始状态开始,按照⼀定的规则向前搜索,当搜索到某个状态⽆法前进时,回退到前⼀个状态,再按照其他的规则搜索。回溯算法在搜索过程中维护⼀个状态树,通过遍历状态树来实现对所有可能解的搜索。

回溯算法的核心思想:“试错”,即在搜索过程中不断地做出选择,如果选择正确,则继续向前搜索;否则,回退到上⼀个状态,重新做出选择。回溯算法通常⽤于解决具有多个解,且每个解都需要搜索才能找到的问题。

回溯算法的模板

void backtrack(vector<int>& path, vector<int>& choice, ...) {
	// 满⾜结束条件
	if (/* 满⾜结束条件 */) {
		// 将路径添加到结果集中
		res.push_back(path);
		return;
	}
        
	// 遍历所有选择
	for (int i = 0; i < choices.size(); i++) {
		// 做出选择
		path.push_back(choices[i]);
		// 做出当前选择后继续搜索
		backtrack(path, choices);
		// 撤销选择
		path.pop_back();
	}
}

其中, path 表⽰当前已经做出的选择, choices 表⽰当前可以做的选择。在回溯算法中,我们需要做出选择,然后递归地调⽤回溯函数。如果满⾜结束条件,则将当前路径添加到结果集中;否则,我们需要撤销选择,回到上⼀个状态,然后继续搜索其他的选择。

回溯算法的时间复杂度通常较⾼,因为它需要遍历所有可能的解。但是,回溯算法的空间复杂度较低,因为它只需要维护⼀个状态树。在实际应⽤中,回溯算法通常需要通过剪枝等⽅法进⾏优化,以减少搜索的次数,从⽽提⾼算法的效率。

回溯算法的应⽤

组合问题

组合问题是指从给定的⼀组数(不重复)中选取出所有可能的 k 个数的组合。例如,给定数集[1,2,3],要求选取 k=2 个数的所有组合。

结果为:

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

排列问题

排列问题是指从给定的⼀组数(不重复)中选取出所有可能的 k 个数的排列。例如,给定数集[1,2,3],要求选取 k=2 个数的所有排列。

结果为:

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

⼦集问题

⼦集问题是指从给定的⼀组数中选取出所有可能的⼦集,其中每个⼦集中的元素可以按照任意顺序排列。例如,给定数集 [1,2,3],要求选取所有可能的⼦集。

结果为:

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

总结

回溯算法是⼀种非常重要的算法,可以解决许多组合问题、排列问题和搜索问题等。回溯算法的核心思想是搜索状态树,通过遍历状态树来实现对所有可能解的搜索。回溯算法的模板⾮常简单,但是实现起来需要注意⼀些细节,⽐如如何做出选择、如何撤销选择等。

1 全排列

1.1 题目链接

46. 全排列

1.2 题目描述

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

 

示例 1:

输入: nums = [1,2,3]
输出: [[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:

输入: nums = [0,1]
输出: [[0,1],[1,0]]

示例 3:

输入: nums = [1]
输出: [[1]]

 

提示:

  • 1 <= nums.length <= 6
  • -10 <= nums[i] <= 10
  • nums 中的所有整数 互不相同

1.3 解法

算法思路

典型的回溯题⽬,我们需要在每⼀个位置上考虑所有的可能情况并且不能出现重复。通过深度优先搜索的⽅式,不断地枚举每个数在当前位置的可能性,并回溯到上⼀个状态,直到枚举完所有可能性,得到正确的结果。

每个数是否可以放⼊当前位置,只需要判断这个数在之前是否出现即可。具体地,在这道题⽬中,我们可以通过⼀个递归函数 backtrack 和标记数组 visited 来实现全排列。

递归函数设计

void backtrack(vector<vector<int>>& res, vector<int>& nums,
vector<bool>& visited, vector<int>& ans, int step, int len)

参数:step(当前需要填⼊的位置),len(数组⻓度);

返回值:⽆;

函数作⽤:查找所有合理的排列并存储在答案列表中。

递归流程如下

  1. ⾸先定义⼀个⼆维数组 res ⽤来存放所有可能的排列,⼀个⼀维数组 ans ⽤来存放每个状态的排列,⼀个⼀维数组 visited 标记元素,然后从第⼀个位置开始进⾏递归;
  2. 在每个递归的状态中,我们维护⼀个步数 step,表⽰当前已经处理了⼏个数字;
  3. 递归结束条件:当 step 等于 nums 数组的⻓度时,说明我们已经处理完了所有数字,将当前数组存⼊结果中;
  4. 在每个递归状态中,枚举所有下标 i,若这个下标未被标记,则使⽤ nums 数组中当前下标的元素:
    • a. 将 visited[i] 标记为 1;
    • b. ans 数组中第 step 个元素被 nums[i] 覆盖;
    • c. 对第 step+1 个位置进⾏递归;
    • d. 将 visited[i] 重新赋值为 0,表⽰回溯;
  5. 最后,返回 res。
  • 特别地,我们可以不使⽤标记数组,直接遍历 step 之后的元素(未被使⽤),然后将其与需要递归的位置进⾏交换即可。

1.4 C++算法代码:

class Solution {
public:
    vector<vector<int>> ret;
    vector<int> path;
    bool check[7];

    vector<vector<int>> permute(vector<int>& nums) {
        dfs(nums);
        return ret;
    }

    void dfs(vector<int> & nums)
    {
        if(path.size() == nums.size())
        {
            ret.push_back(path);
            return ;
        }

        for(int i = 0; i < nums.size(); i++)
        {
            if(!check[i])
            {
                path.push_back(nums[i]);
                check[i] = true;
                dfs(nums);
                // 回溯 -> 恢复现场
                path.pop_back();
                check[i] = false;
            }
        }
    }
};

2 子集

2.1 题目链接

78. 子集

2.2 题目描述

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的

子集

(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

 

示例 1:

输入: nums = [1,2,3]
输出: [[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:

输入: nums = [0]
输出: [[],[0]]

 

提示:

  • 1 <= nums.length <= 10
  • -10 <= nums[i] <= 10
  • nums 中的所有元素 互不相同

2.3 解法

算法思路

为了获得 nums 数组的所有⼦集,我们需要对数组中的每个元素进⾏选择或不选择的操作,即 nums数组⼀定存在 2^(数组⻓度) 个⼦集。对于查找⼦集,具体可以定义⼀个数组,来记录当前的状态,并对其进⾏递归。

对于每个元素有两种选择:1. 不进⾏任何操作;2. 将其添加⾄当前状态的集合。在递归时我们需要保证递归结束时当前的状态与进⾏递归操作前的状态不变,⽽当我们在选择进⾏步骤 2 进⾏递归时,当前状态会发⽣变化,因此我们需要在递归结束时撤回添加操作,即进⾏回溯。

递归函数设计

void dfs(vector<vector<int>>& res, vector<int>& ans, vector<int>& nums, int step)

参数:step(当前需要处理的元素下标);

返回值:⽆;

函数作⽤:查找集合的所有⼦集并存储在答案列表中。

递归流程如下

  1. 递归结束条件:如果当前需要处理的元素下标越界,则记录当前状态并直接返回;
  2. 在递归过程中,对于每个元素,我们有两种选择:
    • 不选择当前元素,直接递归到下⼀个元素;
    • 选择当前元素,将其添加到数组末尾后递归到下⼀个元素,然后在递归结束时撤回添加操作;
  3. 所有符合条件的状态都被记录下来,返回即可。

2.4 C++算法代码:

class Solution {
    vector<vector<int>> ret;
    vector<int> path;
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        dfs(nums, 0);
        return ret;
    }

    void dfs(vector<int> & nums, int pos)
    {
        ret.push_back(path);
        for(int i = pos; i < nums.size(); i++)
        {
            path.push_back(nums[i]);
            dfs(nums, i + 1);
            path.pop_back();  // 恢复现场
        }
    }
};