leetcode刷题笔记9-回溯算法

回溯算法

代码随想录刷题笔记

代码随想录 (programmercarl.com)

回溯算法理论基础

回溯法也可以叫做回溯搜索法,它是一种搜索的方式。

回溯与递归的关系:

回溯是递归的副产品,只要有递归就会有回溯。

回溯函数也就是递归函数,指的都是一个函数

回溯法的效率

回溯法是一个纯暴力的搜索,效率并不高。

因为回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。

那么既然回溯法并不高效为什么还要用它呢?

因为没得选,一些问题能暴力搜出来就不错了,撑死了再剪枝一下,还没有更高效的解法。

回溯法解决的问题

回溯法,一般可以解决如下几种问题:

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

相信大家看着这些之后会发现,每个问题,都不简单!

另外,会有一些同学可能分不清什么是组合,什么是排列?

**组合是不强调元素顺序的,排列是强调元素顺序**。

例如:{1, 2} 和 {2, 1} 在组合上,就是一个集合,因为不强调顺序,而要是排列的话,{1, 2} 和 {2, 1} 就是两个集合了。

记住组合无序,排列有序,就可以了。

如何理解回溯法

回溯法解决的问题都可以抽象为树形结构

因为回溯法解决的都是在集合中递归查找子集集合的大小就构成了树的宽度,递归的深度,都构成的树的深度

递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

回溯法模板

在讲二叉树的递归 (opens new window)中我们说了递归三部曲,这里我再给大家列出回溯三部曲。

  • 回溯函数模板返回值以及参数

在回溯算法中,我的习惯是函数起名字为backtracking

回溯算法中函数返回值一般为void

再来看一下参数,因为回溯算法需要的参数可不像二叉树递归的时候那么容易一次性确定下来,所以一般是先写逻辑,然后需要什么参数,就填什么参数

但后面的回溯题目的讲解中,为了方便大家理解,我在一开始就帮大家把参数确定下来。

回溯函数伪代码如下:

void backtracking(参数){
	...
}
  • 回溯函数终止条件

什么时候达到了终止条件,树中就可以看出,一般来说搜到叶子节点了,也就找到了满足条件的一条答案,把这个答案存放起来,并结束本层递归。

所以回溯函数终止条件伪代码如下:

if (终止条件) {
    存放结果;
    return;
}
  • 回溯搜索的遍历过程

在上面我们提到了,回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。

如图:20210130173631174

注意图中,我特意举例集合大小和孩子的数量是相等的!

回溯函数遍历过程伪代码如下:

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

for循环就是遍历集合区间,可以理解一个节点有多少个孩子,这个for循环就执行多少次。

backtracking这里自己调用自己,实现递归。

大家可以从图中看出for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。

分析完过程,回溯算法模板框架如下:

回溯三部曲:

  • 确定回溯函数参数
  • 确定终止条件
  • 确定单层遍历逻辑
void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

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

组合问题

77. 组合

给定两个整数 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]]

解题思路:

如果k的较大不可能使用暴力解法,嵌套k层for循环实现。

所以可以使用回溯算法模拟该过程,虽然回溯法也是暴力,但是回溯法可以使用递归来解决嵌套层数的问题

递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了

回溯算法搜索过程(树形结构)20201123195223940

每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围

图中可以发现n相当于树的宽度,k相当于树的深度

那么如何在这个树上遍历,然后收集到我们要的结果集呢?

图中每次搜索到了叶子节点,我们就找到了一个结果

相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。

回溯法三部曲

  • 递归函数的参数以及返回值

    返回值:void

    函数名:backtracking

    参数:

    • 定义两个全局变量(函数里参数太多影响可读性,所以可以定义全局变量)

      • 存放组合—一维数组path

      • 所有组合的结果集—二维数组result

        vector<vector<int>> result; // 存放符合条件结果的集合
        vector<int> path; // 用来存放符合条件结果
        
    • n,k — 几个数,组合大小

    • startIndex—起始索引(确定下一层递归从哪个数开始取,也就是要搜索的起始位置)

  • 回溯函数终止条件

    到叶子节点就会得出一个结果,由于path就是放一个组合的,所以如果path的大小等于k时,就是终止条件开始回溯。

    if (path.size() == k){
        result.push_back(path);
        return;
    }
    
  • 单层搜索的过程

    回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。20201123195242899

    所以for循环每次从startIndex开始遍历,然后用path保存取到的节点i

    for(int i = startIndex; i <=n; i++){	//控制树的横向遍历
        path.push_back(i);	//处理节点
        backtracking(n,k,i+1);	// 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
        path.pop();	//回溯,撤销处理的节点
    }
    

    可以看出backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回。

参考代码:

class Solution {
private:
    vector<vector<int>> result; //存放符合条件结果的集合
    vector<int> path;   //存放符合条件结果
    void backtracking(int n, int k, int startIndex){
        //终止条件
        if(path.size() == k){
            result.push_back(path); //存放结果
            return;
        }
        for(int i = startIndex; i <= n; i++){
            path.push_back(i);  //处理节点
            backtracking(n, k, i+1);    //递归
            path.pop_back();    //回溯,撤销处理的节点
        }
    }
public:
    vector<vector<int>> combine(int n, int k) {
        backtracking(n,k,1);
        return result;
    }
};
组合问题总结

组合问题是回溯法解决的经典问题,我们开始的时候给大家列举一个很形象的例子,就是n为100,k为50的话,直接想法就需要50层for循环。

从而引出了回溯法就是解决这种k层for循环嵌套的问题。

然后进一步把回溯法的搜索过程抽象为树形结构,可以直观的看出搜索的过程。

接着用回溯法三部曲,逐步分析了函数参数、终止条件和单层搜索的过程。

剪枝优化

例如当n=4,k=4时,那么第一层for循环的时候,从元素2开始的遍历都没有意义了。 在第二层for循环,从元素3开始的遍历都没有意义了。20210130194335207-20230310134409532

图中每一个节点(图中为矩形),就代表本层的一个for循环,那么每一层的for循环从第二个数开始遍历的话,都没有意义,都是无效遍历。

所以,可以剪枝的地方就在递归中每一层的for循环所选择的起始位置

如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了

注意代码中i,就是for循环里选择的起始位置。

for (int i = startIndex; i <= n; i++) {

优化过程:

  1. 已经选择的元素个数:path.size();

  2. 还需要的元素个数:k - path.size();

  3. 在集合n中至多要从该起始位置:n - (k - path.size()) + 1,开始遍历。

    为什么要+1? 因为实在[1,n]包括n,之间选取大小为k的组合,举一个例子,n = 4,k = 3 ,假设目前path.size()为0, 那么起始位置最大选几呢?n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。也就是从2开始搜索,可以是组合[2,3,4]

所以优化后的for循环:

for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置

小结:

把整个回溯过程抽象为一棵树形结构,然后可以直观的看出,剪枝究竟是剪的哪里。

216. 组合总和 III

找出所有相加之和为 n 的 k 个数的组合,且满足下列条件:

  • 只使用数字1到9

  • 每个数字 最多使用一次

返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

示例 1:

输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。

示例 2:

输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]
解释:
1 + 2 + 6 = 9
1 + 3 + 5 = 9
2 + 3 + 4 = 9
没有其他符合的组合了。

示例 3:

输入: k = 4, n = 1
输出: []
解释: 不存在有效的组合。
在[1,9]范围内使用4个不同的数字,我们可以得到的最小和是1+2+3+4 = 10,因为10 > 1,没有有效的组合。

解题思路:

相对于77. 组合 (opens new window),在何为n的k个数的组合中,多了一个限制,就是整个集合中元素的范围固定在[1,…,9],

k为树的深度(树的一条路径取一个元素)

9(整个集合最多有9个数)为树的宽度(组合中元素范围[1,9]

具体过程例如k(个数) = 2, n(和) = 4的组合。20201123195717975

注意,例如取2之后,下一层只能在[3,9]中取剩余元素,这样才能保证不重复例如[1,3]和[3,1]是同一种组合,没有先后顺序。

回溯三部曲
  • 确定递归函数参数

    返回值:void

    函数名:backtracking

    参数:

    • 定义两个全局变量(函数里参数太多影响可读性,所以可以定义全局变量)

      • 存放组合—一维数组path

      • 所有组合的结果集—二维数组result

        vector<vector<int>> result; // 存放符合条件结果的集合
        vector<int> path; // 用来存放符合条件结果
        
    • targetSum 目标和,也就是题目中的n

    • k k个数的集合

    • sum 已经收集的元素(path)的总和

    • startIndex 下层循环搜索的起始位置

    vector<vector<int>> result;
    vector<int> path;
    void backtracking(int targetSum, int k, int sum, int startIndex)
    
  • 确定终止条件

    k控制树的深度,因为取k个元素,树再往下深就没有意义了,所以当结果中元素个数等于k(path.size() == k),为终止条件,如果此时sum 与 targetSum相等,那么就是符合条件的结果,将其加入到结果集合中即可。

    if(path.size() == k){
        if(sum == targetSum)	result.push_back(path);
        return;	// 如果path.size() == k 但sum != targetSum 直接返回
    }
    
  • 单层搜索过程

    本题和77. 组合 区别之一就是集合固定的就是9个数[1,…,9],所以for循环固定i<=9

    处理过程就是path收集每次选取的元素,相当于树形结构的边,sum用于统计path里所有元素的和

    for(int i = 1; i <= 9; i++){
        sum+= i;
        path.push_back(i);
        backtracking(targetSum, k, sum, i+1)// 注意i+1调整startIndex
        sum -= i;	//回溯
        path.pop_back();	//回溯
    }
    

    别忘了处理过程 和 回溯过程是一一对应的,处理有加,回溯就要有减!

    模板:

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

参考代码:

class Solution {
private:
    vector<vector<int>> result; // 存放结果集
    vector<int> path; // 符合条件的结果
    // targetSum:目标和,也就是题目中的n。
    // k:题目中要求k个数的集合。
    // sum:已经收集的元素的总和,也就是path里元素的总和。
    // startIndex:下一层for循环搜索的起始位置。
    void backtracking(int targetSum, int k, int sum, int startIndex) {
        if (path.size() == k) {
            if (sum == targetSum) result.push_back(path);
            return; // 如果path.size() == k 但sum != targetSum 直接返回
        }
        for (int i = startIndex; i <= 9; i++) {
            sum += i; // 处理
            path.push_back(i); // 处理
            backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
            sum -= i; // 回溯
            path.pop_back(); // 回溯
        }
    }

public:
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear(); // 可以不加
        path.clear();   // 可以不加
        backtracking(n, k, 0, 1);
        return result;
    }
};
剪枝优化:

这里有两个可以剪枝的地方:

剪枝1(集合和大小),当组合的和已经大于n,此时无意义

        if(sum > targetSum){
            return;
        }

剪枝2(集合元素个数),当集合中总个数少于k,之后取得所有都无意义

for(int i = startIndex; i <= 9 - (k - path.size()) + 1; i++){
    
}

参考代码:

class Solution {
private:
    vector<vector<int>> result; // 存放结果集
    vector<int> path; // 符合条件的结果
    void backtracking(int targetSum, int k, int sum, int startIndex) {
        if (sum > targetSum) { // 剪枝1
            return; // 如果path.size() == k 但sum != targetSum 直接返回
        }
        if (path.size() == k) {
            if (sum == targetSum) result.push_back(path);
            return;
        }
        for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) { // 剪枝2
            sum += i; // 处理
            path.push_back(i); // 处理
            backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
            sum -= i; // 回溯
            path.pop_back(); // 回溯
        }
    }

public:
    vector<vector<int>> combinationSum3(int k, int n) {
        result.clear(); // 可以不加
        path.clear();   // 可以不加
        backtracking(n, k, 0, 1);
        return result;
    }
};
17. 电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

200px-telephone-keypad2svg

示例 1:

输入:digits = "23"
输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]

示例 2:

输入:digits = ""
输出:[]

示例 3:

输入:digits = "2"
输出:["a","b","c"]

解题思路:

本题也是组合的衍生题目,那么接下来要解决的就是如下问题:

  1. 数字和字母如何映射
  2. 两个字母就两个for循环,字符如果很多,那么for循环根本写不完
  3. 输入1*#按键等异常情况

1. 数字和字母如何映射

可以使用map或者定义一个二维数组来做映射。

const string letterMap[10] = {
    "", // 0
    "", // 1
    "abc", // 2
    "def", // 3
    "ghi", // 4
    "jkl", // 5
    "mno", // 6
    "pqrs", // 7
    "tuv", // 8
    "wxyz", // 9
};

2. 回溯法解决n个for循环的问题

树的深度:输入字符串数字的个数

树的宽度:每个节点对应的字母个数

例如:输入:“23”,抽象为树形结构,如图所示:20201123200304469

图中可以看出遍历的深度,就是输入"23"的长度,而叶子节点就是我们要收集的结果,输出[“ad”, “ae”, “af”, “bd”, “be”, “bf”, “cd”, “ce”, “cf”]。

回溯三部曲:

  • 确定回溯函数参数

    • 全局变量

      • string s 收集当前叶子节点的结果
      • vector result 存放符合条件的结果
    • const string& digits 题目中给定的字符串

      注意这里使用的是const string& 表示只读

      & 的目的是引用,避免再了复制一个std::string

      const 是为了限定它只读

      const string s 的话还是要再复制一次岂不是很浪费,既然已经是只读了为啥不直接用引用。

    • int index

      注意这个index可不是 77.组合 (opens new window)216.组合总和III (opens new window)中的startIndex了。因为前两题是在一个集合里(例如1~9中收集元素)结果中不能有重复的数字出现,所以使用startIndex来控制避免得到重复的组合;而本题中,实在很多个集合中(取决于输入字符串长度)来选择相应的组合,不需要startIndex来控制集合中之前遍历过哪些元素,也就是在树型结构中,树的下一层不需要选择当前的后一位,而是当前数字代表的所有字符串

      这个index是记录遍历第几个数字了,就是用来遍历digits的(题目中给出数字字符串),同时index也表示树的深度。

    vector<string> result;
    string s;
    void backtracking(const string& digits, int index)	//& 的目的是引用,避免再了复制一个std::string
    
    //const 是为了限定它只读
    
    //const string s 的话还是要再复制一次岂不是很浪费,既然已经是只读了为啥不直接用引用。
    
  • 确定终止条件

    终止条件就是如果index 等于 输入的数字个数(digits.size)了(本来index就是用来遍历digits的)。

    然后收集结果,结束本层递归。

    注意,也就是指到字符串最后一位的下一位才结束,因为最后一位也要有对应的操作,并不是真正的结束

    if (index == digits.size()) {
        result.push_back(s);	//收获结果
        return;
    }
    
  • 确定单层遍历逻辑

    首先取出本层递归指向的数字所代表的字母

    这里注意,去除的是字符‘2’,需要-'0’操作才能将其变为一个真正的数字

    int digit = digits[index] - '0';        // 将index指向的数字转为int
    string letters = letterMap[digit];      // 取数字对应的字符集
    

    接着遍历字符集,取出每个字符并且加入到s中。注意for循环i从0开始取,因为本题每一个数字代表的是不同的集合,也就是求不同集合之间的组合,每个集合都是独立的,都要从每个字符集的第一位开始取。

    for(int i = 0; i < letters.size(); i++){
        s.push_back(letters[i]);
        backtracking(digits, index + 1);    // 递归,注意index+1,一下层要处理下一个数字了
        s.pop_back();                       // 回溯
    }
    

参考代码:

class Solution {
private:
    const string letterMap[10] = {
        "", // 0
        "", // 1
        "abc", // 2
        "def", // 3
        "ghi", // 4
        "jkl", // 5
        "mno", // 6
        "pqrs", // 7
        "tuv", // 8
        "wxyz", // 9
    };
public:
    vector<string> result;
    string s;
    void backtracking(const string& digits, int index) {
        if (index == digits.size()) {
            result.push_back(s);
            return;
        }
        int digit = digits[index] - '0';        // 将index指向的数字转为int
        string letters = letterMap[digit];      // 取数字对应的字符集
        for (int i = 0; i < letters.size(); i++) {
            s.push_back(letters[i]);            // 处理
            backtracking(digits, index + 1);    // 递归,注意index+1,一下层要处理下一个数字了
            s.pop_back();                       // 回溯
        }
    }
    vector<string> letterCombinations(string digits) {
        s.clear();
        result.clear();
        if (digits.size() == 0) {
            return result;
        }
        backtracking(digits, 0);
        return result;
    }
};
小结

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

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

回溯是递归的副产品,只要有递归就会有回溯

回溯问题解题步骤
  1. 将问题抽象为树形结构

    当遇到for循环嵌套数量过多无法操作时,可以使用递归控制for循环嵌套的数量

    for循环横向遍历,递归纵向遍历,回溯不断调整结果集

  2. 按照回溯三部曲,分析回溯算法

    • 确定回溯函数参数
    • 确定终止条件
    • 确定单层遍历逻辑
  3. 回溯法模板

    void backtracking(参数) {
        if (终止条件) {
            存放结果;
            return;
        }
    
        for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
            处理节点;
            backtracking(路径,选择列表); // 递归
            回溯,撤销处理结果
        }
    }
    
  4. 剪枝操作

    • 根据题意可以将一些不符合题目要求的,往后遍历已经没有意义的剪掉
    • for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够 题目要求的k个元素了,就没有必要搜索了。

注意:不同集合之间的组合还是同一集合中的组合,for循环的起始位置i的选择不同。

组合、剪枝—回溯法基础+剪枝

回溯算法:求组合问题! (opens new window)中,

回溯法的魅力,用递归控制for循环嵌套的数量!

本题我把回溯问题抽象为树形结构,可以直观的看出其搜索的过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集

回溯算法:组合问题再剪剪枝 (opens new window)中把回溯法代码做了剪枝优化,在文中依然把问题抽象为一个树形结构,大家可以一目了然剪的究竟是哪里。

剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够 题目要求的k个元素了,就没有必要搜索了

回溯算法:求组合总和! (opens new window)中,相当于 回溯算法:求组合问题! (opens new window)加了一个元素总和的限制。

整体思路还是一样的,本题的剪枝会好想一些,即:已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉

剪枝—for循环起始范围剪枝

在本题中,依然还可以有一个剪枝,就是回溯算法:组合问题再剪剪枝 (opens new window)中提到的,对for循环选择的起始范围的剪枝。

所以,剪枝的代码,可以把for循环,加上 i <= 9 - (k - path.size()) + 1 的限制!

组合-多个集合求组合

回溯算法:电话号码的字母组合 (opens new window)中,开始用多个集合来求组合,还是熟悉的模板题目,但是有一些细节。

例如这里for循环,可不像是在 回溯算法:求组合问题! (opens new window)回溯算法:求组合总和! (opens new window)中从startIndex开始遍历的。

因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而回溯算法:求组合问题! (opens new window)回溯算法:求组合总和! (opens new window)都是是求同一个集合中的组合!

如果大家在现场面试的时候,一定要注意各种输入异常的情况,例如本题输入1 * #按键。

39. 组合总和

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

示例 1:

输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]]
解释:
2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意 2 可以使用多次。
7 也是一个候选, 7 = 7 。
仅有这两种组合。

示例 2:

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

示例 3:

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

提示:

1 <= candidates.length <= 30
2 <= candidates[i] <= 40
candidates 的所有元素 互不相同
1 <= target <= 40

解题思路:

本题中,没有限定组合元素的数量,并且元素可以重复使用,而是给出总和,找出和为target所有组合的集合。

由于没有限定组合元素的数量,所以深度不是以前直观的组合中的个数,而是需要通过元素组成的组合的和来决定树的深度

由于元素可以重复使用,所以每个子节点中的元素可以包括他上层到下层时选取的元素,以达到重复选取的效果,但要注意,由于是从同一集合中选取元素,所以起始值选择包括他但不包括该元素之前的元素也就是i从startIndex开始。

所以树形结构为:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FuZdSZOT-1680611644515)(D:/%25E9%259D%25A2%25E8%25AF%2595/assets/20201223170730367.png)]

注意图中叶子节点的返回条件,因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回!

回溯三部曲
  • 确定回溯函数参数

    • 定义两个全局变量,二维数组result存放结果集,数组path存放符合条件的结果。(这两个变量可以作为函数参数传入)

    • 题目中给出的参数

      集合candidates, 和目标值target

    • int sum 统计单一结果path里的总和

    • int startIndex 控制for循环起始位置

    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex)
    
  • 确定终止条件

    终止只有两种情况,sum大于target和sum等于target。

    sum等于target的时候,需要收集结果。

        if(sum >= target){
            if(sum == target)   result.push_back(path);
            return;
        }
    
  • 确定单层遍历逻辑

    单层for循环依然是从startIndex开始,搜索candidates集合。

    注意本题和77.组合 (opens new window)216.组合总和III (opens new window)的一个区别是:本题元素为可重复选取的

    也就是在递归调用时,传入的startIndex为i也就是本元素位置开始可以选取

    for (int i = startIndex; i < candidates.size(); i++) {
        sum += candidates[i];
        path.push_back(candidates[i]);
        backtracking(candidates, target, sum, i); // 关键点:不用i+1了,表示可以重复读取当前的数
        sum -= candidates[i];   // 回溯
        path.pop_back();        // 回溯
    }
    
参考代码:
class Solution {
private:
    vector<int> path;
    vector<vector<int>> result;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex){
        if(sum >= target){
            if(sum == target)   result.push_back(path);
            return;
        }
        for(int i = startIndex;i < candidates.size() ; i++){
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i);// 不用i+1了,表示可以重复读取当前的数
            sum -= candidates[i];
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        backtracking(candidates, target, 0, 0);
        return result;
    }
};
剪枝优化

剪枝,一般在for循环里操作。

之前版本,对于sum已经大于target的情况,其实是依然进入了下一层递归,只是下一层递归结束判断的时候,会判断sum > target的话就返回。

其实如果已经知道下一层的sum会大于target,就没有必要进入下一层递归了。

解决方法:

对总集合排序之后,如果下一层的sum(就是本层的 sum + candidates[i])已经大于target,就可以结束本轮for循环的遍历

如图:20201223170809182

for循环剪枝代码:

for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)

注意首先要对candidates进行排序

sort(candidates.begin(), candidates.end()); // 需要排序
class Solution {
private:
    vector<int> path;
    vector<vector<int>> result;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex){
        if(sum == target){
            result.push_back(path);
            return;
        }
        // 如果 sum + candidates[i] > target 就终止遍历
        for(int i = startIndex;i < candidates.size() && sum + candidates[i] <= target; i++){
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i);// 不用i+1了,表示可以重复读取当前的数
            sum -= candidates[i];
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());     //需要排序
        backtracking(candidates, target, 0, 0);
        return result;
    }
};
40. 组合总和 II

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次

注意:解集不能包含重复的组合。

示例 1:

输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

示例 2:

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

提示:

1 <= candidates.length <= 100
1 <= candidates[i] <= 50
1 <= target <= 30

解题思路:

这道题目和39.组合总和 (opens new window)如下区别:

  1. 本题candidates 中的每个数字在每个组合中只能使用一次。
  2. 本题数组candidates的元素是有重复的,而39.组合总和 (opens new window)是无重复元素的数组candidates

最后本题和39.组合总和 (opens new window)要求一样,解集不能包含重复的组合。

本题的难点在于区别2中:集合(数组candidates)有重复元素,但还不能有重复的组合

例如实例1中,candidates = [10,1,2,7,6,1,5],第一个1和7,可组合为[1,7],第二个1和7,也可以组合为[1,7],而结果不能包含重复的组合。而之前的解法就会搜索出两个组合,就需要去重了。

有一个思路:把所有组合求出来,再用set或者map去重,但是这么做很容易超时!

所以,需要在搜索的过程中就去掉重复组合所谓去重,其实就是使用过的元素不能重复选取。

都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过,一个维度是同一树层上使用过。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因。

那么问题来了,我们是要同一树层上使用过,还是同一树枝上使用过呢?

回看一下题目,元素在同一个组合内是可以重复的,怎么重复都没事,但两个组合不能相同。

所以我们要去重的是同一树层上的“使用过”,同一树枝上的都是一个组合里的元素,不用去重。也就是我们要树层去重,不需要树枝去重。

为了理解去重我们来举一个例子,candidates = [1, 1, 2], target = 3,(方便起见candidates已经排序了)

强调一下,树层去重的话,需要对数组排序!

排序的作用:让大小相同的元素放在一起,例如上述例子中,第一个1开头的元素搜索到[1,2]结果,那么第二个1开头的元素也必定会搜索到[1,2]这个结果,所以在操作过程中,第二以及以上次重复元素就不需要再次搜索了,第一次出现的元素已经把所有搜索结果包含了,这样就起到了去重的操作,结果集中不会有重复的组合。也就是树层去重的意义。

选择过程树形结构如图所示:

20230310000918

可以看到图中,每个节点相对于 39.组合总和 (opens new window)我多加了used数组,这个used数组下面会重点介绍

回溯三部曲
  • 确定回溯函数参数

    39.组合总和 (opens new window)套路相同,此题还需要加一个bool型数组used,用来记录同一树枝上的元素是否使用过。

    这个集合去重的重任就是used来完成的。

    • 两个全局变量
      • path
      • result
    vector<vector<int>> result; // 存放组合集合
    vector<int> path;           // 符合条件的组合
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
    
  • 确定终止条件

    39.组合总和 (opens new window)相同,终止条件为 sum > targetsum == target

    if (sum > target) { // 这个条件其实可以省略
        return;
    }
    if (sum == target) {
        result.push_back(path);
        return;
    }
    

    sum > target 这个条件其实可以省略,因为在递归单层遍历的时候,会有剪枝的操作,下面会介绍到。

  • 确定单层遍历逻辑

    这里与39.组合总和 (opens new window)最大的不同就是要去重了。

    前面我们提到:要去重的是“同一树层上的使用过”,如何判断同一树层上元素(相同的元素)是否使用过了呢。

    首先注意,需要对candidates中元素进行排序

    接着,树层去重,也就是当candidates中如果当前元素和前一个元素重复了并且时同一树层才能够进行去重操作(也就是直接跳过该节点的搜索不处理,continue,不需要继续向下搜索)if(candidates[i] == candidates[i - 1] &&used[i - 1] == 0 && i > 0 )// 第一个条件判断的是排序后相邻两元素重复,第二个条件是保证数层上去重,而不是树枝上去重

    for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
        // used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
        // used[i - 1] == false,说明同一树层candidates[i - 1]使用过
        // 要对同一树层使用过的元素进行跳过
        if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
            continue;
        }
        sum += candidates[i];
        path.push_back(candidates[i]);
        used[i] = true;
        backtracking(candidates, target, sum, i + 1, used); // 和39.组合总和的区别1:这里是i+1,每个数字在每个组合中只能使用一次
        used[i] = false;
        sum -= candidates[i];
        path.pop_back();
    }
    

    注意sum + candidates[i] <= target为剪枝操作,在39.组合总和 (opens new window)有讲解过!

本题同样是求组合总和,但就是因为其数组candidates有重复元素,而要求不能有重复的组合,所以相对于39.组合总和 (opens new window)难度提升了不少。

参考代码
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
        if (sum == target) {
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
            // used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
            // used[i - 1] == false,说明同一树层candidates[i - 1]使用过
            // 要对同一树层使用过的元素进行跳过
            if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
                continue;
            }
            sum += candidates[i];
            path.push_back(candidates[i]);
            used[i] = true;
            backtracking(candidates, target, sum, i + 1, used); // 和39.组合总和的区别1,这里是i+1,每个数字在每个组合中只能使用一次
            used[i] = false;
            sum -= candidates[i];
            path.pop_back();
        }
    }

public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        vector<bool> used(candidates.size(), false);
        path.clear();
        result.clear();
        // 首先把给candidates排序,让其相同的元素都挨在一起。
        sort(candidates.begin(), candidates.end());
        backtracking(candidates, target, 0, 0, used);
        return result;
    }
};

切割问题

131. 分割回文串

给你一个字符串 s,请你将 s 分割成一些子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

回文串 是正着读和反着读都一样的字符串。

示例 1:

输入:s = "aab"
输出:[["a","a","b"],["aa","b"]]

示例 2:

输入:s = "a"
输出:[["a"]]

提示:

1 <= s.length <= 16
s 仅由小写英文字母组成

解题思路

这种题目,想用for循环暴力解法,可能都不那么容易写出来,所以要换一种暴力的方式,就是回溯。

分割问题,和组合问题中选取元素逻辑类似

所以切割问题,也可以抽象为一棵树形结构,如图:

131.分割回文串

递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。

此时可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。

本题这涉及到两个关键问题:

  1. 切割问题,有不同的切割方式
  2. 判断回文
回溯三部曲:
  • 确定回溯函数参数

    • 全局变量

      一维数组path存放切割后回文的子串

      二维数组result存放结果集

    • startIndex

      切割位置的起始位置,切割过的字串不能重复切割,和组合问题类似

    vector<vector<string>> result;
    vector<string> path; // 放已经回文的子串
    void backtracking (const string& s, int startIndex) {
    
  • 确定终止条件

    当切割线移动到字符串末尾为终止条件,那么,如何切割线到最后呢?

    startIndex就是这条切割线,所以当startIndex >= s.size() 时(ps:大于为了防止i+1后比size还大),切割线移动到字符串末尾。

    void backtracking (const string& s, int startIndex) {
        // 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
        if (startIndex >= s.size()) {
            result.push_back(path);
            return;
        }
    }
    
  • 确定单层遍历逻辑

    来看看在递归循环中如何截取子串呢?

    for (int i = startIndex; i < s.size(); i++)循环中,我们 定义了起始位置startIndex,那么 [startIndex, i] 就是要截取的子串

    注意:startIndex是本层固定的(递归函数传入),而i是for循环中不断递增的。

    首先判断这个子串是不是回文,

    ​ 如果是回文,就将这个是回文的区间字串加入在vector<string> path中,path中记录切割过的回文子串。

    ​ 如果不是回文,直接continue

    for (int i = startIndex; i < s.size(); i++) {
        if (isPalindrome(s, startIndex, i)) { // 是回文子串
            // 获取[startIndex,i]在s中的子串
            string str = s.substr(startIndex, i - startIndex + 1);
            path.push_back(str);
        } else {                // 如果不是则直接跳过
            continue;
        }
        backtracking(s, i + 1); // 寻找i+1为起始位置的子串
        path.pop_back();        // 回溯过程,弹出本次已经填在的子串
    }
    
判断回文串

最后我们看一下回文子串要如何判断了,判断一个字符串是否是回文。

可以使用双指针法,一个指针从前向后,一个指针从后向前,如果前后指针所指向的元素是相等的,就是回文字符串了。

那么判断回文的C++代码如下:

 bool isPalindrome(const string& s, int start, int end) {
     for (int i = start, j = end; i < j; i++, j--) {
         if (s[i] != s[j]) {
             return false;
         }
     }
     return true;
 }

如果大家对双指针法有生疏了,传送门:双指针法:总结篇!(opens new window)

参考代码:
class Solution {
private:
    vector<vector<string>> result;
    vector<string> path; // 放已经回文的子串
    void backtracking (const string& s, int startIndex) {
        // 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
        if (startIndex >= s.size()) {
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < s.size(); i++) {
            if (isPalindrome(s, startIndex, i)) {   // 是回文子串
                // 获取[startIndex,i]在s中的子串
                string str = s.substr(startIndex, i - startIndex + 1);    //截取从startIndex开始,长度为 (i - startIndex + 1)的字串
                path.push_back(str);
            } else {                                // 不是回文,跳过
                continue;
            }
            backtracking(s, i + 1); // 寻找i+1为起始位置的子串
            path.pop_back(); // 回溯过程,弹出本次已经填在的子串
        }
    }
    bool isPalindrome(const string& s, int start, int end) {
        for (int i = start, j = end; i < j; i++, j--) {
            if (s[i] != s[j]) {
                return false;
            }
        }
        return true;
    }
public:
    vector<vector<string>> partition(string s) {
        result.clear();
        path.clear();
        backtracking(s, 0);
        return result;
    }
};
优化

上面的代码还存在一定的优化空间, 在于如何更高效的计算一个子字符串是否是回文字串。上述代码isPalindrome函数运用双指针的方法来判定对于一个字符串s, 给定起始下标和终止下标, 截取出的子字符串是否是回文字串。但是其中有一定的重复计算存在:

例如给定字符串"abcde", 在已知"bcd"不是回文字串时, 不再需要去双指针操作"abcde"而可以直接判定它一定不是回文字串。

具体来说, 给定一个字符串s, 长度为n, 它成为回文字串的充分必要条件是s[0] == s[n-1]s[1:n-1]是回文字串。

大家如果熟悉动态规划这种算法的话, 我们可以高效地事先一次性计算出, 针对一个字符串s, 它的任何子串是否是回文字串, 然后在我们的回溯函数中直接查询即可, 省去了双指针移动判定这一步骤.

具体参考代码如下:

class Solution {
private:
    vector<vector<string>> result;
    vector<string> path; // 放已经回文的子串
    vector<vector<bool>> isPalindrome; // 放事先计算好的是否回文子串的结果
    void backtracking (const string& s, int startIndex) {
        // 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
        if (startIndex >= s.size()) {
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < s.size(); i++) {
            if (isPalindrome[startIndex][i]) {   // 是回文子串
                // 获取[startIndex,i]在s中的子串
                string str = s.substr(startIndex, i - startIndex + 1);
                path.push_back(str);
            } else {                                // 不是回文,跳过
                continue;
            }
            backtracking(s, i + 1); // 寻找i+1为起始位置的子串
            path.pop_back(); // 回溯过程,弹出本次已经填在的子串
        }
    }
    void computePalindrome(const string& s) {
        // isPalindrome[i][j] 代表 s[i:j](双边包括)是否是回文字串 
        isPalindrome.resize(s.size(), vector<bool>(s.size(), false)); // 根据字符串s, 刷新布尔矩阵的大小
        for (int i = s.size() - 1; i >= 0; i--) { 
            // 需要倒序计算, 保证在i行时, i+1行已经计算好了
            for (int j = i; j < s.size(); j++) {
                if (j == i) {isPalindrome[i][j] = true;}
                else if (j - i == 1) {isPalindrome[i][j] = (s[i] == s[j]);}
                else {isPalindrome[i][j] = (s[i] == s[j] && isPalindrome[i+1][j-1]);}
            }
        }
    }
public:
    vector<vector<string>> partition(string s) {
        result.clear();
        path.clear();
        computePalindrome(s);
        backtracking(s, 0);
        return result;
    }
};
总结

本体难点:

  • 切割问题可以抽象为组合问题
  • 如何模拟那些切割线
  • 切割问题中递归如何终止
  • 在递归循环中如何截取子串
  • 如何判断回文
93. 复原 IP 地址

有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 ‘.’ 分隔。

例如:“0.1.2.201” 和 “192.168.1.1” 是 有效 IP 地址,但是 “0.011.255.245”、“192.168.1.312” 和 “192.168@1.1” 是 无效 IP 地址。
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 ‘.’ 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。

示例 1:

输入:s = "25525511135"
输出:["255.255.11.135","255.255.111.35"]

示例 2:

输入:s = "0000"
输出:["0.0.0.0"]

示例 3:

输入:s = "101023"
输出:["1.0.10.23","1.0.102.3","10.1.0.23","10.10.2.3","101.0.2.3"]

提示:

1 <= s.length <= 20
s 仅由数字组成

解题思路

这道题其实也是切割问题,切割问题就可以使用回溯搜索法把所有可能性搜出来,和刚做过的131.分割回文串 (opens new window)就十分类似了。

切割问题可以抽象为树型结构,如图:

93.复原IP地址

本题难点:

切割

字串合法性检查

回溯三部曲:
  • 确定回溯函数参数

    • 全局变量 vector result 记录符合条件的结果
    • startIndex 记录下一层递归分割的起始位置,保证不能重复分割
    • pointNum,记录添加 ‘.’ 的数量。
    vector<string> result;// 记录结果
    // startIndex: 搜索的起始位置,pointNum:添加逗点的数量
    void backtracking(string& s, int startIndex, int pointNum) {
    
  • 确定终止条件

    结合puintNum,所以如果pointNum数量为3(树的深度)就是合法的数据。

    每次加一个’.‘,对该’.'之前的子串做合法性判断。

    但是合法的ip地址有三个’.‘和四个子串,所以第三个’.'结束之后,需要额外对最后子串做合法性判断

    if (pointNum == 3) { // 逗点数量为3时,分隔结束
        // 判断第四段子字符串是否合法,如果合法就放进result中
        if (isValid(s, startIndex, s.size() - 1)) {
            result.push_back(s);
        }
        return;
    }
    
  • 确定单层遍历逻辑

    131.分割回文串 (opens new window)中已经讲过在循环遍历中如何截取子串。

    for (int i = startIndex; i < s.size(); i++)循环中 [startIndex, i] 这个区间就是截取的子串,需要判断这个子串是否合法。

    注意:startIndex是本层固定的(递归函数传入),而i是for循环中不断递增的。

    如果合法就在字符串后面加上符号.表示已经分割。

    s.insert(s.begin() + i + 1 , '.'); // 在i的后面插入一个逗点

    如果不合法结束本层循环(break),如图中剪掉的分支:

    93.复原IP地址

    递归和回溯过程:

    递归调用时,下一层递归的startIndex要从i+2开始(因为需要在字符串中加入了分隔符.),同时记录分割符的数量pointNum 要 +1。

    回溯的时候,就将刚刚加入的分隔符. 删掉就可以了,pointNum也要-1。s.erase(s.begin() + i + 1); // 回溯删掉逗点

判断字串是否合法

合法性到如下合法性判断需要以下条件:

  • 段位以0为开头的数字不合法
  • 段位里有非正整数字符不合法
  • 段位如果大于255了不合法
// 判断字符串s在左闭又闭区间[start, end]所组成的数字是否合法
bool isValid(const string& s, int start, int end) {
    if (start > end) {
        return false;
    }
    if (s[start] == '0' && start != end) { // 0开头的数字不合法
            return false;
    }
    int num = 0;
    for (int i = start; i <= end; i++) {
        if (s[i] > '9' || s[i] < '0') { // 遇到非数字字符不合法
            return false;
        }
        num = num * 10 + (s[i] - '0');
        if (num > 255) { // 如果大于255了不合法
            return false;
        }
    }
    return true;
}
参考代码
class Solution {
private:
    vector<string> result;// 记录结果
    // startIndex: 搜索的起始位置,pointNum:添加逗点的数量
    void backtracking(string& s, int startIndex, int pointNum) {
        if (pointNum == 3) { // 逗点数量为3时,分隔结束
            // 判断第四段子字符串是否合法,如果合法就放进result中
            if (isValid(s, startIndex, s.size() - 1)) {
                result.push_back(s);
            }
            return;
        }
        for (int i = startIndex; i < s.size(); i++) {
            if (isValid(s, startIndex, i)) { // 判断 [startIndex,i] 这个区间的子串是否合法
                s.insert(s.begin() + i + 1 , '.');  // 在i的后面插入一个逗点
                pointNum++;
                backtracking(s, i + 2, pointNum);   // 插入逗点之后下一个子串的起始位置为i+2
                pointNum--;                         // 回溯
                s.erase(s.begin() + i + 1);         // 回溯删掉逗点
            } else break; // 不合法,直接结束本层循环
        }
    }
    // 判断字符串s在左闭又闭区间[start, end]所组成的数字是否合法
    bool isValid(const string& s, int start, int end) {
        if (start > end) {
            return false;
        }
        if (s[start] == '0' && start != end) { // 0开头的数字不合法
                return false;
        }
        int num = 0;
        for (int i = start; i <= end; i++) {
            if (s[i] > '9' || s[i] < '0') { // 遇到非数字字符不合法
                return false;
            }
            num = num * 10 + (s[i] - '0');
            if (num > 255) { // 如果大于255了不合法
                return false;
            }
        }
        return true;
    }
public:
    vector<string> restoreIpAddresses(string s) {
        result.clear();
        if (s.size() < 4 || s.size() > 12) return result; // 算是剪枝了
        backtracking(s, 0, 0);
        return result;
    }
};

子集问题

78. 子集

给你一个整数数组 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 中的所有元素 互不相同

解题思路

求子集问题和77.组合 (opens new window)131.分割回文串 (opens new window)又不一样了。

如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!

其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。

那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!

以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:

78.子集

从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合

所以子集问题:每一层递归(每个节点)都需要收获结果放入结果集

回溯三部曲:
  • 确定回溯函数参数

    • 全局变量
      • 数组path为子集收集元素
      • 二维数组result存放子集组合。
    • int startIndex 保证集合中取过的元素不会重复取
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& nums, int startIndex) {
    
  • 确定终止条件

    观察树形结构的叶子节点可以发现:当剩余集合为空时,为终止条件。也就是当startIndex指向nums元素末尾时,终止。

    if (startIndex >= nums.size()) {
        return;
    }
    

    其实终止条件可以不用写,因为for循环以及控制i<num.size(),所以当startIndex >= nums.size()时,不会走for循环逻辑,所以本层递归结束(return)。

  • 确定单层遍历逻辑

    求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树

    那么单层递归逻辑代码如下:

    for (int i = startIndex; i < nums.size(); i++) {
        path.push_back(nums[i]);    // 子集收集元素
        backtracking(nums, i + 1);  // 注意从i+1开始,元素不重复取
        path.pop_back();            // 回溯
    }
    

    注意,将结果放入结果集应该在函数的第一行进行。因为,进入一层递归就要把当前path放入结果集。在终止条件之上是因为,如果在之下,会漏掉自己

参考代码
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& nums, int startIndex) {
        result.push_back(path); // 收集子集,要放在终止添加的上面,否则会漏掉自己
        if (startIndex >= nums.size()) { // 终止条件可以不加
            return;
        }
        for (int i = startIndex; i < nums.size(); i++) {
            path.push_back(nums[i]);
            backtracking(nums, i + 1);
            path.pop_back();
        }
    }
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        result.clear();
        path.clear();
        backtracking(nums, 0);
        return result;
    }
};
总结
组合—总和有限制,组合个数无限制

回溯算法:求组合总和(二) (opens new window)中,和回溯算法:求组合问题! (opens new window)回溯算法:求组合总和! (opens new window)和区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。

本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?

如果是一个集合来求组合的话,就需要startIndex,例如:回溯算法:求组合问题! (opens new window)回溯算法:求组合总和! (opens new window)

如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:回溯算法:电话号码的字母组合(opens new window)

注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路,后面我再讲解排列的时候就重点介绍

最后还给出了本题的剪枝优化,如下:

for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)

这个优化如果是初学者的话并不容易想到。

在求和问题中,排序之后加剪枝是常见的套路!

组合、排列—去重问题

回溯算法:求组合总和(三) (opens new window)中依旧讲解组合总和问题,本题集合元素会有重复,但要求解集不能包含重复的组合

所以难就难在去重问题上了。

“树枝去重”和“树层去重”

都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上“使用过”,一个维度是同一树层上“使用过”。40.组合总和II1

我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:

  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过,用来进行树枝去重
  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过,用来进行树层去重

对于去重,其实排列问题也是一样的道理。

切割—分割回文串

回溯算法:分割回文串 (opens new window)中,我们开始讲解切割问题,虽然最后代码看起来好像是一道模板题,但是从分析到学会套用这个模板,是比较难的。

我列出如下几个难点:

  • 切割问题其实类似组合问题
  • 如何模拟那些切割线
  • 切割问题中递归如何终止
  • 在递归循环中如何截取子串
  • 如何判断回文

如果想到了用求解组合问题的思路来解决 切割问题本题就成功一大半了,接下来就可以对着模板照葫芦画瓢。

但后序如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了

除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1

切割—进阶复原IP地址

回溯算法:复原IP地址,复原IP照回溯算法:分割回文串 (opens new window)就多了一些限制,例如只能分四段,而且还是更改字符串,插入逗点。

树形图如下:

93.复原IP地址

本题还可以有一个剪枝,合法ip长度为12,如果s的长度超过了12就不是有效IP地址,直接返回!

代码如下:

if (s.size() > 12) return result; // 剪枝

我之前给出的C++代码没有加这个限制,也没有超时,因为在第四段超过长度之后,就会截止了,所以就算给出特别长的字符串,搜索的范围也是有限的(递归只会到第三层),及时就会返回了。

子集—收集所有节点

回溯算法:求子集问题! (opens new window)中讲解了子集问题,在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果

如图:

78.子集

认清这个本质之后,今天的题目就是一道模板题了。

其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了,本来我们就要遍历整棵树。

因为每次递归的下一层就是从i+1开始的。

如果要写终止条件,注意:result.push_back(path);要放在终止条件的上面,如下:

result.push_back(path); // 收集子集,要放在终止添加的上面,否则会漏掉自己
if (startIndex >= nums.size()) { // 终止条件可以不加
    return;
}

洗礼之后,发现子集问题还真的有点简单了,其实这就是一道标准的模板题。

但是要清楚子集问题和组合问题、分割问题的的区别,子集是收集树形结构中树的所有节点的结果

而组合问题、分割问题是收集树形结构中叶子节点的结果

90. 子集 II

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。

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

示例 1:

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

示例 2:

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

提示:

1 <= nums.length <= 10
-10 <= nums[i] <= 10

解题思路:

这道题目和78.子集 (opens new window)区别就是集合里有重复元素了,而且求取的子集要去重

那么关于回溯算法中的去重问题,40.组合总和II (opens new window)中已经详细讲解过了,和本题是一个套路

用示例中的[1, 2, 2] 来举例,如图所示:

(注意去重需要先对集合排序

90.子集II

从图中可以看出,同一树层上重复取2 就要过滤掉,同一树枝上就可以重复取2,因为同一树枝上元素的集合才是唯一子集!也就是要树层去重,不要树枝去重!

used数组此时就起到了去重的操作,可以看出,如果是树枝上两个元素重复的话,那么前一个元素在used数组中为1。而如果时数层上两个元素重复的话,那么前一个元素在used数组中为0,这也是树层去重的重要判断条件。具体逻辑为:

if(i > 0 && nums[i] == nums[i - 1] && used[i - 1] == 0){
                continue;
            }

本题就是其实就是回溯算法:求子集问题! (opens new window)的基础上加上了去重,去重我们在回溯算法:求组合总和(三) (opens new window)也讲过了,所以我就直接给出代码了:

参考代码
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& nums, int startIndex, vector<bool>& used) {
        result.push_back(path);
        for (int i = startIndex; i < nums.size(); i++) {
            // used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
            // used[i - 1] == false,说明同一树层candidates[i - 1]使用过
            // 而我们要对同一树层使用过的元素进行跳过
            if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
                continue;
            }
            path.push_back(nums[i]);
            used[i] = true;
            backtracking(nums, i + 1, used);
            used[i] = false;
            path.pop_back();
        }
    }

public:
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        result.clear();
        path.clear();
        vector<bool> used(nums.size(), false);
        sort(nums.begin(), nums.end()); // 去重需要排序
        backtracking(nums, 0, used);
        return result;
    }
};
491. 递增子序列

给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。

数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。

示例 1:

输入:nums = [4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]

示例 2:

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

提示:

1 <= nums.length <= 15
-100 <= nums[i] <= 100

解题思路:

注意这道题不能提前排序,要读懂题意,是根据nums中元素的顺序,找出对应的递增子序列,如果提前排序后,那么就改变了nums的顺序,那么找出的递增子序列就不符合题意了。所以不能使用之前的去重逻辑(因为之前去重逻辑需要先排序)

本题给出的示例,还是一个有序数组 [4, 6, 7, 7],这更容易误导大家按照排序的思路去做了。

为了有鲜明的对比,我用[4, 7, 6, 7]这个数组来举例,抽象为树形结构如图:

491. 递增子序列1

回溯三部曲:
  • 确定回溯函数参数

    本题求子序列,很明显一个元素不能重复使用,所以需要startIndex,调整下一层递归的起始位置。

    代码如下:

    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& nums, int startIndex)
    
  • 确定终止条件

    本题其实类似求子集问题,也是要遍历树形结构找每一个节点,所以和回溯算法:求子集问题! (opens new window)一样,可以不加终止条件,startIndex每次都会加1,并不会无限递归。

    但本题收集结果有所不同,题目要求递增子序列大小至少为2,所以代码如下:

    if (path.size() > 1) {
        result.push_back(path);
        // 注意这里不要加return,因为要取树上的所有节点
    }
    
  • 确定单层遍历逻辑

    在树型结构中可以看出:同一父节点下的同层上使用过的元素就不能再使用了

    所以在搜索过程中主要要去掉的是不符合递增的子集和数层重复的节点

    去掉不合法分支逻辑:

    ​ 在for循环前,需要定义一个set来对本层元素进行去重unordered_set<int> uset;

    也就是在for循环中,每次取一个数就将其放入set中,在取数过程中,如果发现该元素在set中出现过,那么就将其去掉,也就达到了树层去重的逻辑。

    ​ 在for循环中,如果当前元素要是小于子集中最右边的元素,那么就不符合递增原则,就不能将其放入到path中,注意使用continue跳出本次循环,因为还可以在集合中寻找下一个元素放入path中。

    if ((!path.empty() && nums[i] < path.back())
                || uset.find(nums[i]) != uset.end()) {
                continue;
        }
    

    ps:set.find()用法:

    set :: find()函数是预定义的函数,用于检查元素是否属于集合。 
    	如果元素属于集合,则它返回确切的迭代器位置,
    	否则返回st.end() 。
    

    单层搜索代码:

    unordered_set<int> uset; // 使用set来对本层元素进行去重
    for (int i = startIndex; i < nums.size(); i++) {
        if ((!path.empty() && nums[i] < path.back())
                || uset.find(nums[i]) != uset.end()) {
                continue;
        }
        uset.insert(nums[i]); // 记录这个元素在本层用过了,本层后面不能再用了
        path.push_back(nums[i]);
        backtracking(nums, i + 1);
        path.pop_back();
    }
    

    递归函数上面的uset.insert(nums[i]);,下面却没有对应的pop之类的操作, 这也是需要注意的点,unordered_set<int> uset; 是记录本层元素是否重复使用,新的一层uset都会重新定义(清空),所以要知道uset只负责本层!

参考代码
// 版本一
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& nums, int startIndex) {
        if (path.size() > 1) {
            result.push_back(path);
            // 注意这里不要加return,要取树上的节点
        }
        unordered_set<int> uset; // 使用set对本层元素进行去重
        for (int i = startIndex; i < nums.size(); i++) {
            if ((!path.empty() && nums[i] < path.back())
                    || uset.find(nums[i]) != uset.end()) {
                    continue;
            }
            uset.insert(nums[i]); // 记录这个元素在本层用过了,本层后面不能再用了
            path.push_back(nums[i]);
            backtracking(nums, i + 1);
            path.pop_back();
        }
    }
public:
    vector<vector<int>> findSubsequences(vector<int>& nums) {
        result.clear();
        path.clear();
        backtracking(nums, 0);
        return result;
    }
};
优化

以上代码用我用了unordered_set<int>来记录本层元素是否重复使用。

其实用数组来做哈希,效率就高了很多

注意题目中说了,数值范围[-100,100],所以完全可以用数组来做哈希。

程序运行的时候对unordered_set 频繁的insert,unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值)相对费时间,而且每次重新定义set,insert的时候其底层的符号表也要做相应的扩充,也是费事的。

那么优化后的代码如下:

// 版本二
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& nums, int startIndex) {
        if (path.size() > 1) {
            result.push_back(path);
        }
        int used[201] = {0}; // 这里使用数组来进行去重操作,题目说数值范围[-100, 100]
        for (int i = startIndex; i < nums.size(); i++) {
            if ((!path.empty() && nums[i] < path.back())
                    || used[nums[i] + 100] == 1) {
                    continue;
            }
            used[nums[i] + 100] = 1; // 记录这个元素在本层用过了,本层后面不能再用了
            path.push_back(nums[i]);
            backtracking(nums, i + 1);
            path.pop_back();
        }
    }
public:
    vector<vector<int>> findSubsequences(vector<int>& nums) {
        result.clear();
        path.clear();
        backtracking(nums, 0);
        return result;
    }
};

这份代码在leetcode上提交,要比版本一耗时要好的多。

所以正如在哈希表:总结篇!(每逢总结必经典) (opens new window)中说的那样,数组,set,map都可以做哈希表,而且数组干的活,map和set都能干,但如果数值范围小的话能用数组尽量用数组

排列问题

46. 全排列

给定一个不含重复数字的数组 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 中的所有整数 互不相同

解题思路

本题中,nums无重复数字,所以不需要去重操作

排列与组合区别:

​ [1,2]和[2,1] 是同一个组合,但是是两个排列,

​ 所以组合是没有顺序的(元素相同顺序不同是一个组合),而排列强调集合中的元素顺序(元素相同顺序不同是不同排列)。

组合类问题,使用startIndex来避免重复取同一元素以及避免产生相同元素顺序不同的情况。

排列问题,使用used数组来记录元素是否使用,进行树枝去重也就是避免重复去同一元素以及标记未取过的元素以便下一层选取。

以[1,2,3]为例,抽象成树形结构如下:

树深度:集合元素个数

选取的结果:叶子节点

46.全排列

回溯三部曲:
  • 确定回溯函数参数

    • 全局变量
      • 数组path为子集收集元素
      • 二维数组result存放子集组合。
    • used数组,标记使用过的元素,避免重复选取
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (vector<int>& nums, vector<bool>& used)
    
  • 确定终止条件

    叶子节点处收集结果,也就是当path大小和nums集合中元素大小相同时,到达叶子节点,收获结果。

    // 此时说明找到了一组
    if (path.size() == nums.size()) {
        result.push_back(path);
        return;
    }
    
  • 确定单层遍历逻辑

    这里和77.组合问题 (opens new window)131.切割问题 (opens new window)78.子集问题 (opens new window)最大的不同就是for循环里不用startIndex了。

    因为排列问题,每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次1。

    而used数组,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次

    for (int i = 0; i < nums.size(); i++) {
        if (used[i] == true) continue; // path里已经收录的元素,直接跳过
        used[i] = true;
        path.push_back(nums[i]);
        backtracking(nums, used);
        path.pop_back();
        used[i] = false;
    }
    
参考代码
class Solution {
public:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (vector<int>& nums, vector<bool>& used) {
        // 此时说明找到了一组
        if (path.size() == nums.size()) {
            result.push_back(path);
            return;
        }
        for (int i = 0; i < nums.size(); i++) {
            if (used[i] == true) continue; // path里已经收录的元素,直接跳过
            used[i] = true;
            path.push_back(nums[i]);
            backtracking(nums, used);
            path.pop_back();
            used[i] = false;
        }
    }
    vector<vector<int>> permute(vector<int>& nums) {
        result.clear();
        path.clear();
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return result;
    }
};
总结

排列问题的不同:

  • 每层都是从0开始搜索而不是startIndex
  • 需要used数组记录path里都放了哪些元素
47. 全排列 II

给定一个可包含重复数字的序列 nums ,按任意顺序 返回所有不重复的全排列。

示例 1:

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

示例 2:

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

提示:

1 <= nums.length <= 8
-10 <= nums[i] <= 10

解题思路

这道题目和46.全排列 (opens new window)的区别在与给定一个可包含重复数字的序列,要返回所有不重复的全排列

这里又涉及到去重了。

40.组合总和II (opens new window)90.子集II (opens new window)我们分别详细讲解了组合问题和子集问题如何去重。

那么排列问题其实也是一样的套路。

还要强调的是去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了

我以示例中的 [1,1,2]为例 (为了方便举例,已经排序)抽象为一棵树,去重过程如图:

47.全排列II1

图中我们对同一树层,前一位(也就是nums[i-1])如果使用过,那么就进行去重。

一般来说:组合问题和排列问题是在树形结构的叶子节点上收集结果,而子集问题就是取树上所有节点的结果

46.全排列 (opens new window)中已经详细讲解了排列问题的写法,在40.组合总和II (opens new window)90.子集II (opens new window)中详细讲解了去重的写法,所以这次我就不用回溯三部曲分析了,直接给出代码,如下:

参考代码
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking (vector<int>& nums, vector<bool>& used) {
        // 此时说明找到了一组
        if (path.size() == nums.size()) {
            result.push_back(path);
            return;
        }
        for (int i = 0; i < nums.size(); i++) {
            // used[i - 1] == true,说明同一树枝nums[i - 1]使用过
            // used[i - 1] == false,说明同一树层nums[i - 1]使用过
            // 如果同一树层nums[i - 1]使用过则直接跳过
            if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
                continue;
            }
            if (used[i] == false) {
                used[i] = true;
                path.push_back(nums[i]);
                backtracking(nums, used);
                path.pop_back();
                used[i] = false;
            }
        }
    }
public:
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        result.clear();
        path.clear();
        sort(nums.begin(), nums.end()); // 排序
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return result;
    }
};
拓展

大家发现,去重最为关键的代码为:

if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
    continue;
}

如果改成 used[i - 1] == true, 也是正确的!,去重代码如下:

if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == true) {
    continue;
}

这是为什么呢,

如果要对树层中前一位去重,就用used[i - 1] == false

如果要对树枝前一位去重用used[i - 1] == true

对于排列问题,树层上去重和树枝上去重,都是可以的,但是树层上去重效率更高!

用输入: [1,1,1] 来举一个例子。

树层上去重(used[i - 1] == false),的树形结构如下:

47.全排列II2

树枝上去重(used[i - 1] == true)的树型结构如下:

47.全排列II3

大家应该很清晰的看到,树层上对前一位去重非常彻底,效率很高,树枝上对前一位去重虽然最后可以得到答案,但是做了很多无用搜索

小结
子集—去重

回溯算法:求子集问题(二) (opens new window)中,开始针对子集问题进行去重。

本题就是回溯算法:求子集问题! (opens new window)的基础上加上了去重,去重我们在回溯算法:求组合总和(三) (opens new window)也讲过了。

所以本题对大家应该并不难。

树形结构如下:

90.子集II

子集—去重

回溯算法:递增子序列 (opens new window)中,处处都能看到子集的身影,但处处是陷阱,值得好好琢磨琢磨!

树形结构如下: 491. 递增子序列1

回溯算法:递增子序列 (opens new window)留言区大家有很多疑问,主要还是和回溯算法:求子集问题(二) (opens new window)混合在了一起。

排列—与组合区别

我们已经分析了组合问题,分割问题,子集问题,那么回溯算法:排列问题! (opens new window)又不一样了。

排列是有序的,也就是说[1,2] 和[2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。

可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。

如图: 46.全排列

大家此时可以感受出排列问题的不同:

  • 每层都是从0开始搜索而不是startIndex
  • 需要used数组记录path里都放了哪些元素了
排列—去重

排列问题也要去重了,在回溯算法:排列问题(二) (opens new window)中又一次强调了“树层去重”和“树枝去重”。

树形结构如下:

47.全排列II1

这道题目神奇的地方就是used[i - 1] == false也可以,used[i - 1] == true也可以!

我就用输入: [1,1,1] 来举一个例子。

树层上去重(used[i - 1] == false),的树形结构如下:

47.全排列II2.png

树枝上去重(used[i - 1] == true)的树型结构如下:

47.全排列II3

可以清晰的看到使用(used[i - 1] == false),即树层去重,效率更高!

性能分析

之前并没有分析各个问题的时间复杂度和空间复杂度,这次来说一说。

这块网上的资料鱼龙混杂,一些所谓的经典面试书籍根本不讲回溯算法,算法书籍对这块也避而不谈,感觉就像是算法里模糊的边界。

所以这块就说一说我个人理解,对内容持开放态度,集思广益,欢迎大家来讨论!

子集问题分析:

  • 时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为 O ( 2 n ) O(2^n) O(2n),构造每一组子集都需要填进数组,又有需要 O ( n ) O(n) O(n),最终时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n)
  • 空间复杂度: O ( n ) O(n) O(n),递归深度为n,所以系统栈所用空间为 O ( n ) O(n) O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为 O ( n ) O(n) O(n)

排列问题分析:

  • 时间复杂度: O ( n ! ) O(n!) O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。每个叶子节点都会有一个构造全排列填进数组的操作(对应的代码:result.push_back(path)),该操作的复杂度为 O ( n ) O(n) O(n)。所以,最终时间复杂度为:n * n!,简化为 O ( n ! ) O(n!) O(n!)
  • 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。

组合问题分析:

  • 时间复杂度: O ( n × 2 n ) O(n × 2^n) O(n×2n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
  • 空间复杂度: O ( n ) O(n) O(n),和子集问题同理。

一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!

待解决

332. 重新安排行程

难度困难

棋盘问题

51. N 皇后

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。

示例 1:

img

输入:n = 4
输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]
解释:如上图所示,4 皇后问题存在两个不同的解法。

示例 2:

输入:n = 1
输出:[["Q"]]

提示:

1 <= n <= 9

解题思路

n皇后问题是回溯算法解决的经典问题,但是用回溯解决多了组合、切割、子集、排列问题之后,棋盘问题的不同之处在:二维矩阵中找结果。

首先来看一下皇后们的约束条件:

  1. 不能同行
  2. 不能同列
  3. 不能同斜线

确定完约束条件,来看看究竟要怎么去搜索皇后们的位置,其实搜索皇后的位置,可以抽象为一棵树。

下面我用一个 3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:

51.N皇后

从图中,可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。

那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了

回溯三部曲:
  • 确定回溯函数参数

    • 全局变量二维数组result来记录最终结果
    • n 棋盘的大小
    • row 记录当前遍历到棋盘的第几层
    • vector& chessboard 棋盘
    vector<vector<string>> result;
    void backtracking(int n, int row, vector<string>& chessboard) {
    
  • 确定终止条件

    当递归到棋盘最底层(也就是叶子节点)的时候(row == n),就可以收集结果并返回了。

    注意此时已进行合法性判断,只有满足条件的结果才能走到这一步放入结果集

    代码如下:

    if (row == n) {
        result.push_back(chessboard);
        return;
    }
    
  • 确定单层遍历逻辑

    递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。

    每次都是要从新的一行的起始位置开始搜,所以都是从0开始。

    代码如下:

    for (int col = 0; col < n; col++) {
        if (isValid(row, col, chessboard, n)) { // 验证合法就可以放
            chessboard[row][col] = 'Q'; // 放置皇后
            backtracking(n, row + 1, chessboard);
            chessboard[row][col] = '.'; // 回溯,撤销皇后
        }
    }
    
  • 验证棋盘是否合法

    按照如下标准去重:

    1. 不能同行
    2. 不能同列
    3. 不能同斜线 (45度和135度角)

    代码如下:

    bool isValid(int row, int col, vector<string>& chessboard, int n) {
        // 检查列
        for (int i = 0; i < row; i++) { // 这是一个剪枝
            if (chessboard[i][col] == 'Q') {
                return false;
            }
        }
        // 检查 45度角是否有皇后
        for (int i = row - 1, j = col - 1; i >=0 && j >= 0; i--, j--) {
            if (chessboard[i][j] == 'Q') {
                return false;
            }
        }
        // 检查 135度角是否有皇后
        for(int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
            if (chessboard[i][j] == 'Q') {
                return false;
            }
        }
        return true;
    }
    

在这份代码中,不需要进行同行的检查,因为在单层搜索的过程中,每一层递归,只会选for循环(也就是同一行)里的一个元素,所以不用去重了。

参考代码
class Solution {
private:
vector<vector<string>> result;
// n 为输入的棋盘大小
// row 是当前递归到棋盘的第几行了
void backtracking(int n, int row, vector<string>& chessboard) {
    if (row == n) {
        result.push_back(chessboard);
        return;
    }
    for (int col = 0; col < n; col++) {
        if (isValid(row, col, chessboard, n)) { // 验证合法就可以放
            chessboard[row][col] = 'Q'; // 放置皇后
            backtracking(n, row + 1, chessboard);
            chessboard[row][col] = '.'; // 回溯,撤销皇后
        }
    }
}
bool isValid(int row, int col, vector<string>& chessboard, int n) {
    // 检查列
    for (int i = 0; i < row; i++) { // 这是一个剪枝
        if (chessboard[i][col] == 'Q') {
            return false;
        }
    }
    // 检查 45度角是否有皇后
    for (int i = row - 1, j = col - 1; i >=0 && j >= 0; i--, j--) {
        if (chessboard[i][j] == 'Q') {
            return false;
        }
    }
    // 检查 135度角是否有皇后
    for(int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
        if (chessboard[i][j] == 'Q') {
            return false;
        }
    }
    return true;
}
public:
    vector<vector<string>> solveNQueens(int n) {
        result.clear();
        std::vector<std::string> chessboard(n, std::string(n, '.'));
        backtracking(n, 0, chessboard);
        return result;
    }
};

可以看出,除了验证棋盘合法性的代码,省下来部分就是按照回溯法模板来的。

总结

本题是我们解决棋盘问题的第一道题目。

棋盘的宽度就是for循环的长度,递归的深度就是棋盘的高度,这样就可以套进回溯法的模板里了

37. 解数独

回溯法总结

关于回溯算法,你该了解这些! (opens new window)中我们详细的介绍了回溯算法的理论知识,不同于教科书般的讲解,这里介绍的回溯法的效率,解决的问题以及模板都是在刷题的过程中非常实用!

回溯是递归的副产品,只要有递归就会有回溯,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。

回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。

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

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

我在回溯算法系列讲解中就按照这个顺序给大家讲解,可以说深入浅出,步步到位

回溯法确实不好理解,所以需要把回溯法抽象为一个图形来理解就容易多了,在后面的每一道回溯法的题目我都将遍历过程抽象为树形结构方便大家的理解。

关于回溯算法,你该了解这些! (opens new window)还用了回溯三部曲来分析回溯算法,并给出了回溯法的模板:

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

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

这个模板会伴随整个回溯法系列!

#组合问题
#组合问题

回溯算法:求组合问题! (opens new window)中,我们开始用回溯法解决第一道题目:组合问题。

我在文中开始的时候给大家列举k层for循环例子,进而得出都是同样是暴力解法,为什么要用回溯法!

此时大家应该深有体会回溯法的魅力,用递归控制for循环嵌套的数量!

本题我把回溯问题抽象为树形结构,如题:

77.组合1

可以直观的看出其搜索的过程:for循环横向遍历,递归纵向遍历,回溯不断调整结果集,这个理念贯穿整个回溯法系列,也是我做了很多回溯的题目,不断摸索其规律才总结出来的。

优化回溯算法只有剪枝一种方法,在回溯算法:组合问题再剪剪枝 (opens new window)中把回溯法代码做了剪枝优化,树形结构如图:

77.组合4

大家可以一目了然剪的究竟是哪里。

回溯算法:求组合问题! (opens new window)剪枝精髓是:for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了,就没有必要搜索了

在for循环上做剪枝操作是回溯法剪枝的常见套路! 后面的题目还会经常用到。

#组合总和
#组合总和(一)

回溯算法:求组合总和! (opens new window)中,相当于 回溯算法:求组合问题! (opens new window)加了一个元素总和的限制。

树形结构如图: 216.组合总和III

整体思路还是一样的,本题的剪枝会好想一些,即:已选元素总和如果已经大于n(题中要求的和)了,那么往后遍历就没有意义了,直接剪掉,如图:

216.组合总和III1

在本题中,依然还可以有一个剪枝,就是回溯算法:组合问题再剪剪枝 (opens new window)中提到的,对for循环选择的起始范围的剪枝。

所以剪枝的代码可以在for循环加上 i <= 9 - (k - path.size()) + 1 的限制!

#组合总和(二)

回溯算法:求组合总和(二) (opens new window)中讲解的组合总和问题,和回溯算法:求组合问题! (opens new window)回溯算法:求组合总和! (opens new window)和区别是:本题没有数量要求,可以无限重复,但是有总和的限制,所以间接的也是有个数的限制。

不少同学都是看到可以重复选择,就义无反顾的把startIndex去掉了。

本题还需要startIndex来控制for循环的起始位置,对于组合问题,什么时候需要startIndex呢?

我举过例子,如果是一个集合来求组合的话,就需要startIndex,例如:回溯算法:求组合问题! (opens new window)回溯算法:求组合总和! (opens new window)

如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex,例如:回溯算法:电话号码的字母组合(opens new window)

注意以上我只是说求组合的情况,如果是排列问题,又是另一套分析的套路

树形结构如下:

39.组合总和

最后还给出了本题的剪枝优化,如下:

for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++)

1

优化后树形结构如下:

39.组合总和1

#组合总和(三)

回溯算法:求组合总和(三) (opens new window)中集合元素会有重复,但要求解集不能包含重复的组合。

所以难就难在去重问题上了

这个去重问题,相信做过的录友都知道有多么的晦涩难懂。网上的题解一般就说“去掉重复”,但说不清怎么个去重,代码一甩就完事了。

为了讲解这个去重问题,Carl自创了两个词汇,“树枝去重”和“树层去重”

都知道组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上“使用过”,一个维度是同一树层上“使用过”。没有理解这两个层面上的“使用过” 是造成大家没有彻底理解去重的根本原因

40.组合总和II1

我在图中将used的变化用橘黄色标注上,可以看出在candidates[i] == candidates[i - 1]相同的情况下:

  • used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
  • used[i - 1] == false,说明同一树层candidates[i - 1]使用过

这块去重的逻辑很抽象,网上搜的题解基本没有能讲清楚的,如果大家之前思考过这个问题或者刷过这道题目,看到这里一定会感觉通透了很多!

对于去重,其实排列和子集问题也是一样的道理。

#多个集合求组合

回溯算法:电话号码的字母组合 (opens new window)中,开始用多个集合来求组合,还是熟悉的模板题目,但是有一些细节。

例如这里for循环,可不像是在 回溯算法:求组合问题! (opens new window)回溯算法:求组合总和! (opens new window)中从startIndex开始遍历的。

因为本题每一个数字代表的是不同集合,也就是求不同集合之间的组合,而回溯算法:求组合问题! (opens new window)回溯算法:求组合总和! (opens new window)都是是求同一个集合中的组合!

树形结构如下:

17. 电话号码的字母组合

如果大家在现场面试的时候,一定要注意各种输入异常的情况,例如本题输入1 * #按键。

其实本题不算难,但也处处是细节,还是要反复琢磨。

#切割问题

回溯算法:分割回文串 (opens new window)中,我们开始讲解切割问题,虽然最后代码看起来好像是一道模板题,但是从分析到学会套用这个模板,是比较难的。

我列出如下几个难点:

  • 切割问题其实类似组合问题
  • 如何模拟那些切割线
  • 切割问题中递归如何终止
  • 在递归循环中如何截取子串
  • 如何判断回文

如果想到了用求解组合问题的思路来解决 切割问题本题就成功一大半了,接下来就可以对着模板照葫芦画瓢。

但后序如何模拟切割线,如何终止,如何截取子串,其实都不好想,最后判断回文算是最简单的了

所以本题应该是一个道hard题目了。

除了这些难点,本题还有细节,例如:切割过的地方不能重复切割所以递归函数需要传入i + 1

树形结构如下:

131.分割回文串

#子集问题
#子集问题(一)

回溯算法:求子集问题! (opens new window)中讲解了子集问题,在树形结构中子集问题是要收集所有节点的结果,而组合问题是收集叶子节点的结果

如图:

78.子集

认清这个本质之后,今天的题目就是一道模板题了。

本题其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了,本来我们就要遍历整棵树。

有的同学可能担心不写终止条件会不会无限递归?

并不会,因为每次递归的下一层就是从i+1开始的。

如果要写终止条件,注意:result.push_back(path);要放在终止条件的上面,如下:

result.push_back(path); // 收集子集,要放在终止添加的上面,否则会漏掉结果
if (startIndex >= nums.size()) { // 终止条件可以不加
    return;
}
#子集问题(二)

回溯算法:求子集问题(二) (opens new window)中,开始针对子集问题进行去重。

本题就是回溯算法:求子集问题! (opens new window)的基础上加上了去重,去重我们在回溯算法:求组合总和(三) (opens new window)也讲过了,一样的套路。

树形结构如下:

90.子集II

#递增子序列

回溯算法:递增子序列 (opens new window)中,处处都能看到子集的身影,但处处是陷阱,值得好好琢磨琢磨!

树形结构如下: 491. 递增子序列1

很多同学都会把这道题目和回溯算法:求子集问题(二) (opens new window)混在一起。

回溯算法:求子集问题(二) (opens new window)也可以使用set针对同一父节点本层去重,但子集问题一定要排序,为什么呢?

我用没有排序的集合{2,1,2,2}来举个例子画一个图,如下:

90.子集II2

相信这个图胜过千言万语的解释了

#排列问题
#排列问题(一)

回溯算法:排列问题! (opens new window)又不一样了。

排列是有序的,也就是说 [1,2] 和 [2,1] 是两个集合,这和之前分析的子集以及组合所不同的地方。

可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。

如图:

46.全排列

大家此时可以感受出排列问题的不同:

  • 每层都是从0开始搜索而不是startIndex
  • 需要used数组记录path里都放了哪些元素了
#排列问题(二)

排列问题也要去重了,在回溯算法:排列问题(二) (opens new window)中又一次强调了“树层去重”和“树枝去重”。

树形结构如下:

47.全排列II1

这道题目神奇的地方就是used[i - 1] == false也可以,used[i - 1] == true也可以!

我就用输入: [1,1,1] 来举一个例子。

树层上去重(used[i - 1] == false),的树形结构如下:

47.全排列II2.png

树枝上去重(used[i - 1] == true)的树型结构如下:

47.全排列II3

可以清晰的看到使用(used[i - 1] == false),即树层去重,效率更高!

本题used数组即是记录path里都放了哪些元素,同时也用来去重,一举两得。

#去重问题

以上我都是统一使用used数组来去重的,其实使用set也可以用来去重!

本周小结!(回溯算法系列三)续集 (opens new window)中给出了子集、组合、排列问题使用set来去重的解法以及具体代码,并纠正一些同学的常见错误写法。

同时详细分析了 使用used数组去重 和 使用set去重 两种写法的性能差异:

使用set去重的版本相对于used数组的版本效率都要低很多,大家在leetcode上提交,能明显发现。

原因在回溯算法:递增子序列 (opens new window)中也分析过,主要是因为程序运行的时候对unordered_set 频繁的insert,unordered_set需要做哈希映射(也就是把key通过hash function映射为唯一的哈希值)相对费时间,而且insert的时候其底层的符号表也要做相应的扩充,也是费时的。

而使用used数组在时间复杂度上几乎没有额外负担!

使用set去重,不仅时间复杂度高了,空间复杂度也高了,在本周小结!(回溯算法系列三) (opens new window)中分析过,组合,子集,排列问题的空间复杂度都是O(n),但如果使用set去重,空间复杂度就变成了O(n^2),因为每一层递归都有一个set集合,系统栈空间是n,每一个空间都有set集合。

那有同学可能疑惑 用used数组也是占用O(n)的空间啊?

used数组可是全局变量,每层与每层之间公用一个used数组,所以空间复杂度是O(n + n),最终空间复杂度还是O(n)。

#重新安排行程(图论额外拓展)

之前说过,有递归的地方就有回溯,深度优先搜索也是用递归来实现的,所以往往伴随着回溯。

回溯算法:重新安排行程 (opens new window)其实也算是图论里深搜的题目,但是我用回溯法的套路来讲解这道题目,算是给大家拓展一下思路,原来回溯法还可以这么玩!

以输入:[[“JFK”, “KUL”], [“JFK”, “NRT”], [“NRT”, “JFK”]为例,抽象为树形结构如下:

img

本题可以算是一道hard的题目了,关于本题的难点我在文中已经详细列出。

如果单纯的回溯搜索(深搜)并不难,难还难在容器的选择和使用上!

本题其实是一道深度优先搜索的题目,但是我完全使用回溯法的思路来讲解这道题题目,算是给大家拓展一下思维方式,其实深搜和回溯也是分不开的,毕竟最终都是用递归

#棋盘问题
#N皇后问题

回溯算法:N皇后问题 (opens new window)中终于迎来了传说中的N皇后。

下面我用一个3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:

51.N皇后

从图中,可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。

那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了

如果从来没有接触过N皇后问题的同学看着这样的题会感觉无从下手,可能知道要用回溯法,但也不知道该怎么去搜。

这里我明确给出了棋盘的宽度就是for循环的长度,递归的深度就是棋盘的高度,这样就可以套进回溯法的模板里了

相信看完本篇回溯算法:N皇后问题 (opens new window)也没那么难了,传说已经不是传说了,哈哈。

#解数独问题

回溯算法:解数独 (opens new window)中要征服回溯法的最后一道山峰。

解数独应该是棋盘很难的题目了,比N皇后还要复杂一些,但只要理解 “二维递归”这个过程,其实发现就没那么难了。

大家已经跟着「代码随想录」刷过了如下回溯法题目,例如:77.组合(组合问题) (opens new window)131.分割回文串(分割问题) (opens new window)78.子集(子集问题) (opens new window)46.全排列(排列问题) (opens new window),以及51.N皇后(N皇后问题) (opens new window),其实这些题目都是一维递归。

其中N皇后问题 (opens new window)是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来遍历列,然后一行一列确定皇后的唯一位置。

本题就不一样了,本题中棋盘的每一个位置都要放一个数字,并检查数字是否合法,解数独的树形结构要比N皇后更宽更深

因为这个树形结构太大了,我抽取一部分,如图所示:

37.解数独

解数独可以说是非常难的题目了,如果还一直停留在一维递归的逻辑中,这道题目可以让大家瞬间崩溃。

所以我在回溯算法:解数独 (opens new window)中开篇就提到了二维递归,这也是我自创词汇,希望可以帮助大家理解解数独的搜索过程。

一波分析之后,在看代码会发现其实也不难,唯一难点就是理解二维递归的思维逻辑。

这样,解数独这么难的问题也被我们攻克了

#性能分析

关于回溯算法的复杂度分析在网上的资料鱼龙混杂,一些所谓的经典面试书籍不讲回溯算法,算法书籍对这块也避而不谈,感觉就像是算法里模糊的边界

所以这块就说一说我个人理解,对内容持开放态度,集思广益,欢迎大家来讨论!

以下在计算空间复杂度的时候我都把系统栈(不是数据结构里的栈)所占空间算进去。

子集问题分析:

  • 时间复杂度:O(2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2n)
  • 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)

排列问题分析:

  • 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
  • 空间复杂度:O(n),和子集问题同理。

组合问题分析:

  • 时间复杂度:O(2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
  • 空间复杂度:O(n),和子集问题同理。

N皇后问题分析:

  • 时间复杂度:O(n!) ,其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!),n!表示n * (n-1) * … * 1。
  • 空间复杂度:O(n),和子集问题同理。

解数独问题分析:

  • 时间复杂度:O(9^m) , m是’.'的数目。
  • 空间复杂度:O(n2),递归的深度是n2

一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!

回溯专题汇聚为一张图:

img

new window)](https://programmercarl.com/0332.重新安排行程.html)其实也算是图论里深搜的题目,但是我用回溯法的套路来讲解这道题目,算是给大家拓展一下思路,原来回溯法还可以这么玩!

以输入:[[“JFK”, “KUL”], [“JFK”, “NRT”], [“NRT”, “JFK”]为例,抽象为树形结构如下:

[外链图片转存中…(img-HaaIKQKG-1680611644530)]

本题可以算是一道hard的题目了,关于本题的难点我在文中已经详细列出。

如果单纯的回溯搜索(深搜)并不难,难还难在容器的选择和使用上!

本题其实是一道深度优先搜索的题目,但是我完全使用回溯法的思路来讲解这道题题目,算是给大家拓展一下思维方式,其实深搜和回溯也是分不开的,毕竟最终都是用递归

#棋盘问题
#N皇后问题

回溯算法:N皇后问题 (opens new window)中终于迎来了传说中的N皇后。

下面我用一个3 * 3 的棋盘,将搜索过程抽象为一棵树,如图:

[外链图片转存中…(img-RN8ThG4t-1680611644530)]

从图中,可以看出,二维矩阵中矩阵的高就是这棵树的高度,矩阵的宽就是树形结构中每一个节点的宽度。

那么我们用皇后们的约束条件,来回溯搜索这棵树,只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了

如果从来没有接触过N皇后问题的同学看着这样的题会感觉无从下手,可能知道要用回溯法,但也不知道该怎么去搜。

这里我明确给出了棋盘的宽度就是for循环的长度,递归的深度就是棋盘的高度,这样就可以套进回溯法的模板里了

相信看完本篇回溯算法:N皇后问题 (opens new window)也没那么难了,传说已经不是传说了,哈哈。

#解数独问题

回溯算法:解数独 (opens new window)中要征服回溯法的最后一道山峰。

解数独应该是棋盘很难的题目了,比N皇后还要复杂一些,但只要理解 “二维递归”这个过程,其实发现就没那么难了。

大家已经跟着「代码随想录」刷过了如下回溯法题目,例如:77.组合(组合问题) (opens new window)131.分割回文串(分割问题) (opens new window)78.子集(子集问题) (opens new window)46.全排列(排列问题) (opens new window),以及51.N皇后(N皇后问题) (opens new window),其实这些题目都是一维递归。

其中N皇后问题 (opens new window)是因为每一行每一列只放一个皇后,只需要一层for循环遍历一行,递归来遍历列,然后一行一列确定皇后的唯一位置。

本题就不一样了,本题中棋盘的每一个位置都要放一个数字,并检查数字是否合法,解数独的树形结构要比N皇后更宽更深

因为这个树形结构太大了,我抽取一部分,如图所示:

[外链图片转存中…(img-NZW3jnq9-1680611644530)]

解数独可以说是非常难的题目了,如果还一直停留在一维递归的逻辑中,这道题目可以让大家瞬间崩溃。

所以我在回溯算法:解数独 (opens new window)中开篇就提到了二维递归,这也是我自创词汇,希望可以帮助大家理解解数独的搜索过程。

一波分析之后,在看代码会发现其实也不难,唯一难点就是理解二维递归的思维逻辑。

这样,解数独这么难的问题也被我们攻克了

#性能分析

关于回溯算法的复杂度分析在网上的资料鱼龙混杂,一些所谓的经典面试书籍不讲回溯算法,算法书籍对这块也避而不谈,感觉就像是算法里模糊的边界

所以这块就说一说我个人理解,对内容持开放态度,集思广益,欢迎大家来讨论!

以下在计算空间复杂度的时候我都把系统栈(不是数据结构里的栈)所占空间算进去。

子集问题分析:

  • 时间复杂度:O(2n),因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2n)
  • 空间复杂度:O(n),递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)

排列问题分析:

  • 时间复杂度:O(n!),这个可以从排列的树形图中很明显发现,每一层节点为n,第二层每一个分支都延伸了n-1个分支,再往下又是n-2个分支,所以一直到叶子节点一共就是 n * n-1 * n-2 * … 1 = n!。
  • 空间复杂度:O(n),和子集问题同理。

组合问题分析:

  • 时间复杂度:O(2^n),组合问题其实就是一种子集的问题,所以组合问题最坏的情况,也不会超过子集问题的时间复杂度。
  • 空间复杂度:O(n),和子集问题同理。

N皇后问题分析:

  • 时间复杂度:O(n!) ,其实如果看树形图的话,直觉上是O(n^n),但皇后之间不能见面所以在搜索的过程中是有剪枝的,最差也就是O(n!),n!表示n * (n-1) * … * 1。
  • 空间复杂度:O(n),和子集问题同理。

解数独问题分析:

  • 时间复杂度:O(9^m) , m是’.'的数目。
  • 空间复杂度:O(n2),递归的深度是n2

一般说道回溯算法的复杂度,都说是指数级别的时间复杂度,这也算是一个概括吧!

回溯专题汇聚为一张图:

[外链图片转存中…(img-wAOg02f4-1680611644531)]

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值