C++ 数据结构与算法(九)(回溯、组合、分割、子集、排列)

回溯法

回溯法也可以叫做回溯搜索法,它是一种搜索的方式,其本质是:走不通就回头。回溯是递归的副产品,只要有递归就会有回溯。

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

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

回溯法解决的问题都可以抽象为树形结构,因为回溯法解决的都是在集合中递归查找子集集合的大小----树的宽度递归的深度----树的深度。递归就要有终止条件,所以必然是一棵高度有限的树(N叉树)。

回溯 VS 递归

回溯法从问题本身出发,寻找可能实现的所有情况。和穷举法的思想相近,不同在于穷举法是将所有的情况都列举出来以后再一一筛选,而回溯法在列举过程如果发现当前情况根本不可能存在,就停止后续的所有工作返回上一步进行新的尝试。

回溯和递归唯一的联系就是,回溯法可以用递归思想实现。

性能分析

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

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

子集问题分析:

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

排列问题分析:

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

N皇后问题分析:

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

解数独问题分析:

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

在这里插入图片描述

回溯法模板

  • 返回值以及参数

回溯算法中函数返回值一般为void。
回溯算法一般是先写逻辑,然后需要什么参数,就填什么参数。

void backtracking(参数)
  • 回溯函数终止条件
if (终止条件) {
    存放结果;
    return;
}
  • 回溯搜索的遍历过程
    在这里插入图片描述
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
    处理节点;
    backtracking(路径,选择列表); // 递归
    回溯,撤销处理结果
}

for循环就是遍历子集合区间。
backtracking这里自己调用自己,实现递归遍历所有节点。

1、组合问题

77. 组合 ●●

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

  • 时间复杂度: O ( ( n k ) × k ) O({n \choose k} \times k) O((kn)×k)
  • 空间复杂度: O ( n + k ) = O ( n ) O(n+k)=O(n) O(n+k)=O(n),即递归使用栈空间的空间代价和临时数组的空间代价。

1. 根据搜索起点画出二叉树

在这里插入图片描述

  • 剪枝
    在这里插入图片描述
    for循环横向遍历,递归调用纵向遍历递归后回溯

剪枝优化:for循环的上限值可进行优化i <= n-(k-set.size())+1 ,去除不必要的遍历;k-set.size()为缺少的元素个数,所需的元素个数大于未遍历的元素个数时不进行遍历。

class Solution {
public:
    vector<int> set;            // 全局变量 存放当前遍历生成的数组
    vector<vector<int>> ans;    // 最终答案

    void backtrack(int n, int k, int start){
        if(set.size() == k){    
            ans.emplace_back(set);  // 满足个数要求,返回数组
            return;
        }       
        for(int i = start; i <= n-(k-set.size())+1; ++i){   // i <= n-(k-set.size())+1 剪枝操作,去除不必要的遍历;k-set.size()为缺少的元素个数,所需的元素个数大于未遍历的元素个数时不进行遍历
            set.emplace_back(i);    // 当前元素加入数组
            backtrack(n, k, i+1);   // 递归查找下一个元素
            set.pop_back();         // 回溯,执行该步时,已生成一个满足条件的数组,此时将尾部元素弹出,继续for遍历
        }
    }
    
    vector<vector<int>> combine(int n, int k) {
        backtrack(n, k, 1);
        return ans;
    }
};

2. 根据每个节点选与不选画二叉树

在这里插入图片描述

class Solution {
public:
    vector<int> set;            // 全局变量 存放当前遍历生成的数组
    vector<vector<int>> ans;    // 最终答案

    void backtrack(int n, int k, int curr){
        if(curr > n - (k-set.size()) + 1) return;	// 剪枝
        if(set.size() == k){		// 满足条件的数组
            ans.emplace_back(set);
            return;
        }
        set.emplace_back(curr);     // 选curr
        backtrack(n, k, curr+1);   
        set.pop_back();             // 不选curr
        backtrack(n, k, curr+1);   
    }
    
    vector<vector<int>> combine(int n, int k) {
        backtrack(n, k, 1);
        return ans;
    }
};

216. 组合总和 III ●●

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

  • 只使用数字1到9
  • 每个数字 最多使用一次
  • 返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

两处剪枝操作:

  1. 当前组合和比目标值大时;
  2. for 循环遍历的上限值剪枝。
  • 时间复杂度: O ( ( M k ) × k ) O({M \choose k} \times k) O((kM)×k),其中 M 为集合的大小,本题中 M 固定为 9。一共有 ( M k ) M \choose k (kM)(个组合,每次判断需要的时间代价是 O(k)。
  • 空间复杂度:O(M)。临时数组的空间代价是 O(k),递归栈空间的代价是 O(M),故空间复杂度为 O(M+k)=O(M).
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> set;

    void backtrack(int k, int start, int num){      // k为元素个数,start为for遍历起始值,num为与目标差值
        if(num < 0) return;                         // 与目标差值num<0,即和过大,停止递归,剪枝
        if(set.size() == k){                        // k个数时,结束递归
            if(num == 0) ans.emplace_back(set);     // 若组合和满足条件,即差值num=0时,保存数组
            return;
        }
        int m = 9-(k-set.size())+1;
        for(int i = start; i <= m; ++i){            // 对for循环最大值进行剪枝优化
            set.emplace_back(i);                    
            backtrack(k, i+1, num-i);               // 递归
            set.pop_back();                         // 回溯
        }

    }
    
    vector<vector<int>> combinationSum3(int k, int n) {
        backtrack(k, 1 , n);
        return ans;
    }
};

17. 电话号码的字母组合 ●●

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
在这里插入图片描述
输入:digits = “23”
输出:[“ad”,“ae”,“af”,“bd”,“be”,“bf”,“cd”,“ce”,“cf”]

特点
多个集合中求组合

1. 回溯

在这里插入图片描述

  • 时间复杂度:O(3m × 4n ),其中 m 是输入中对应 3 个字母的数字个数,n 是输入中对应 4 个字母的数字个数,m+n 是输入数字的总个数,需要遍历每一种字母组合。

  • 空间复杂度:O(m+n),其中 m 是输入中对应 3 个字母的数字个数,n 是输入中对应 4 个字母的数字个数,m+n 是输入数字的总个数。除了返回值以外,空间复杂度主要取决于哈希表以及回溯过程中的递归调用层数,哈希表的大小与输入无关,可以看成常数,递归调用层数最大为 m+n

class Solution {
public:
    const string wordMap[10] = {    // 数字与字母映射   wordMap[i][j]
        "",     // 0				// 也可以利用哈希表进行映射
        "",     // 1
        "abc",  // 2
        "def",  // 3
        "ghi",  // 4
        "jkl",  // 5
        "mno",  // 6
        "pqrs", // 7
        "tuv",  // 8
        "wxyz"  // 9
    };

    vector<string> ans; 
    string str;             

    void backtrack(string digits, int i){   // i表示字符串中下标为i的数字
        if(str.length() == digits.length()){    
            ans.emplace_back(str);          // 字符长度相等时即满足条件
            return;
        }
        int curr = (digits[i]) - '0';       // 将字符串中的数字字符转换为int
        for(int n = 0; n < wordMap[curr].length(); ++n){
            str.push_back(wordMap[curr][n]);
            backtrack(digits, i+1);         // 下一个数字的字符
            str.pop_back();                 // 回溯
        }
    }

    vector<string> letterCombinations(string digits) {
        if(!digits.length()) return ans;
        backtrack(digits, 0);
        return ans;
    }
};

也可以用哈希表进行映射:

unordered_map<char, string> phoneMap{
    {'2', "abc"},
    {'3', "def"},
    {'4', "ghi"},
    {'5', "jkl"},
    {'6', "mno"},
    {'7', "pqrs"},
    {'8', "tuv"},
    {'9', "wxyz"}
};
    
    
char digit = digits[index];
const string& letters = phoneMap.at(digit);
for (const char& letter: letters) {
    ...
}

2. 队列

在这里插入图片描述

class Solution {
public:
    const string wordMap[10] = {    // 数字与字母映射   wordMap[i][j]
        "",     // 0
        "",     // 1
        "abc",  // 2
        "def",  // 3
        "ghi",  // 4
        "jkl",  // 5
        "mno",  // 6
        "pqrs", // 7
        "tuv",  // 8
        "wxyz"  // 9
    };

    vector<string> letterCombinations(string digits) {
        vector<string> ans;                         // 字符串模仿队列操作,先进先出
        if(!digits.length()) return ans;
        ans.emplace_back("");						// 插入一个空字符
        for(int n = 0; n < digits.length(); ++n){   // 遍历处理每个数字
            int curr = digits[n] - '0';             // char转int
            int size = ans.size();                  // 处理队列前的元素个数
            for(int i = 0; i < size; ++i){          // 遍历队列
                string str = ans[0];                // 队列头元素
                ans.erase(ans.begin());             // 头元素出列
                for(char ch : wordMap[curr]){       
                    ans.emplace_back(str+ch);       // 遍历当前数字映射的所有字母,并添加进字符串队列
                }
            }       
        }
        return ans;
    }
};

39. 组合总和 ●●

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

  • 输入:candidates = [2,3,6,7], target = 7
    输出:[[2,2,3],[7]]

特点:
– 组合没有数量要求
– 元素可无限重复选取

1. 未排序回溯

在这里插入图片描述
因为本题没有组合数量要求,仅仅是总和的限制,所以递归没有层数的限制,只要选取的元素总和超过target,就返回。

  1. 参数:遍历索引、数组、目标差值
  2. 终止条件:差值等于0
  3. 单层逻辑:for 循环从 index 开始,遍历数组集合(对数组排序可以减少回溯次数)
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> set;

    void backtrack(int index, vector<int> candidates, int difference){	// 参数:遍历索引、数组、目标差值
        // 终止条件:差值小于等于0
        if(difference < 0) return;	
        if(difference == 0){
            ans.emplace_back(set);
            return;
        }
        // 允许重复数字,for循环从index开始
        for(int i = index; i < candidates.size(); ++i){
            difference -= candidates[i];
            set.emplace_back(candidates[i]);
            backtrack(i, candidates, difference);	// 递归
            set.pop_back();					// 回溯
            difference += candidates[i];	// 回溯
        }
        
    }
    
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        backtrack(0, candidates, target);
        return ans;
    }
};

2. 排序+回溯

在这里插入图片描述

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> set;

    void backtrack(int index, vector<int> candidates, int difference){	// 参数:遍历索引、数组、目标差值
        // 终止条件:差值等于0
        if(difference == 0){
            ans.emplace_back(set);
            return;
        }
        // 允许重复数字,for循环从index开始
        for(int i = index; i < candidates.size(); ++i){
            difference -= candidates[i];
            if(difference < 0){
                break;                                  // 差值小于0,不满足条件,不再往下遍历(排序后 剪枝优化)
            }else{
                set.emplace_back(candidates[i]);        // 差值大于等于0,继续递归
                backtrack(i, candidates, difference);	// 递归
                set.pop_back();					        // 回溯
                difference += candidates[i];	        // 回溯
            }
            
        }
    }
    
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end()); // 排序 剪枝
        backtrack(0, candidates, target);
        return ans;
    }
};

40. 组合总和 II ●●

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。
candidates 中的每个数字在每个组合中只能使用 一次
注意:解集不能包含重复的组合
输入: candidates = [2,5,2,1,2], target = 5,
输出:[ [1,2,2], [5] ]

此题重点在于重复组合的去重操作,所谓去重,其实就是使用过的元素不能重复选取。

组合问题可以抽象为树形结构,那么“使用过”在这个树形结构上是有两个维度的,一个维度是同一树枝上使用过(递归纵向遍历),一个维度是同一树层上使用过(for横向遍历,i > start)

本题中,元素在同一个组合内是可以重复的,但两个组合不能相同。

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

在这里插入图片描述

  • 排序,使用 vector<bool> &used 数组进行重复使用标记
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> set;

    void backtrack(int index, vector<int> candidates, int difference, vector<bool> &used){
        if(difference == 0){
            ans.emplace_back(set);
            return;
        }
        for(int i = index; i < candidates.size(); ++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;
            }
            difference -= candidates[i];
            if(difference < 0){
                break;					// 剪枝操作
            }else{
                set.emplace_back(candidates[i]);
                used[i] = true;			// 树枝上使用过
                backtrack(i+1, candidates, difference, used);	// 递归 纵向遍历
                used[i] = false;				// 树层上使用过
                set.pop_back();					// 回溯 
                difference += candidates[i];	// 回溯 
            }     
        }
    }
    
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());
        vector<bool> used(candidates.size(), false);
        backtrack(0, candidates, target, used);
        return ans;
    }
};
  • 排序,直接在for循环中判断与上一个元素是否重复进行去重判断

注意判断条件i > index 时才是横向遍历的条件,否则可能是递归调用导致的判断。

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> set;

    void backtrack(int index, vector<int> candidates, int difference){
        if(difference == 0){
            ans.emplace_back(set);
            return;
        }
        for(int i = index; i < candidates.size(); ++i){
            if(i > index && candidates[i] == candidates[i-1]){
                continue;                   // 跳过同一层使用过的元素(for 横向遍历)
            }                               // 注意判断条件, i > index 时才是横向遍历的条件,否则可能是递归调用的判断
            difference -= candidates[i];
            if(difference < 0){             // 剪枝操作
                break;
            }else{
                set.emplace_back(candidates[i]);
                backtrack(i+1, candidates, difference); // 递归 纵向遍历
                set.pop_back();                 // 回溯 
                difference += candidates[i];    // 回溯
            }      
        }
    }
    
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end()); //排序
        backtrack(0, candidates, target);
        return ans;
    }
};

2、分割问题

131. 分割回文串 ●●

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

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

切割问题类似组合问题,例如对于字符串abcdef:

组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中在选组第三个…。
分割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中在切割第三段…。

将切割问题抽象为树形结构:
在这里插入图片描述
递归用来纵向遍历,for循环用来横向遍历,切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。

  • 时间复杂度: O ( N ⋅ 2 N ) O(N \cdot 2^N) O(N2N);这里 N 为输入字符串的长度,每一个位置可拆分,也可不拆分,尝试是否可以拆分的时间复杂度为 O ( 2 N ) O(2^N) O(2N),判断每一个子串是否是回文子串,时间复杂度为 O(N);
  • 空间复杂度:
    -如果不计算保存结果的空间,空间复杂度为 O ( N ) O(N) O(N),递归调用栈的高度为 N;
    -如果计算保存答案需要空间 2 N × N 2^N \times N 2N×N,这里 2 N 2^N 2N 为保守估计,实际情况不会这么多。
class Solution {
public:
    vector<vector<string>> ans;
    vector<string> set;

    bool isPalindrome(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;
    }

    void backtrack(int start, string s){
        if(start == s.length()){            // 全部分割完毕
            ans.emplace_back(set);
            return;
        }
        for(int i = start; i < s.length(); ++i){
            if( isPalindrome(s, start, i)){     // 是回文串
                string str = s.substr(start, i-start+1);    // 截取子串
                set.emplace_back(str);          // 记录该回文串
            }else{
                continue;       // 非回文串,则跳过,进行for循环到下一个位置
            }
            backtrack(i+1, s);  // 递归,纵向遍历,切割下一段
            set.pop_back();
        }

    }
    
    vector<vector<string>> partition(string s) {
        backtrack(0, s);
        return ans;
    }
};

93. 复原 IP 地址 ●●

有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 ‘.’ 分隔。
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 ‘.’ 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。

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

在这里插入图片描述

在这里插入图片描述
在原字符串上进行添加分割点“.”操作,回溯时则删除该点。
注意判断分割子串数值的有效性(0开头的非零数,大于255的数);
for 循环横向遍历,寻找分割位置;递归纵向遍历,寻找下一个分割的位置。

class Solution {
public:
    vector<string> ans;

    void backtrack(string &s, int start, int pointNum){	// start为当前分割段的起始位,pointNum为分割点数字
        if(pointNum == 3){      // 存在三个分割点时,分割完毕
            if(isValid(s, start, s.length()-1)){    // 判断第四个数字的有效性
                ans.emplace_back(s);
            }           
            return;
        }

        for(int i = start; i < s.length(); ++i){
           if(s.length() - i - 1 > 3 * (3-pointNum)){
                continue;   // 此处分割会导致剩下的字符串过长,因此遍历跳过该点,for 横向遍历下一个数字
           }
           if(isValid(s, start, i)){
                s.insert(s.begin() + i + 1, '.');   // 当前分割有效,插入分割点
                ++pointNum;         			// 分割点+1
                backtrack(s, i+2, pointNum);    // 递归,寻找下一个分割点,start = i + 2
                s.erase(s.begin() + i + 1);     // 回溯,删除分割点
                --pointNum;                     // 分割点-1
            }else{
                break;  						// 当前横向遍历无效,退出循环
            }
        }
    }

    bool isValid(string s, int start, int end){	// 判断当前子串数字的有效性
        if(start > end) return false;  // 当分割点出现在结尾,即没有第四个数字时,start = i+2 超出字符长度
        if(s[start] == '0' && start != end) return false;   // 0开头的非零数字,无效
        int sum = 0;
        for(int i = start; i <= end; ++i){
            sum = sum * 10 + s[i] - '0';
            if(sum > 255){	
                return false;
            }
        }
        return true;    // sum <= 255
    }

    vector<string> restoreIpAddresses(string s) {
        if(s.length() > 12) return ans;
        backtrack(s, 0, 0);
        return ans;
    }
};

3、子集问题

78. 子集 ●●

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

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

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

那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始。
在这里插入图片描述
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> subset;

    void backtrack(int start, vector<int> nums){
        ans.emplace_back(subset); 		// 收集子集,要放在终止添加的上面,否则会漏掉自己
        // if(start == nums.size()){    // 终止,for循环中已限制了遍历结束条件
        //     return;
        // }
        for(int i = start; i < nums.size(); ++i){   // for 横向遍历
            subset.emplace_back(nums[i]);
            backtrack(i+1, nums);                   // 递归 纵向遍历
            subset.pop_back();                      // 回溯
        }
                      
    }

    vector<vector<int>> subsets(vector<int>& nums) {
        backtrack(0, nums);
        return ans;
    }
};

90. 子集II ●●

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的子集(幂集)。
解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

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

在这里插入图片描述
本题需要“树层去重”,先对集合排序,在for循环 横向遍历i>start)时检查是否重复元素,重复则跳过。

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> subset;

    void backtrack(int start, vector<int> nums){
        ans.emplace_back(subset);
        for(int i = start; i < nums.size(); ++i){   
            if(i > start && nums[i] == nums[i-1]){  // for循环 横向遍历时检查是否重复元素, i > start
                continue;           
            }
            subset.emplace_back(nums[i]);
            backtrack(i+1, nums);
            subset.pop_back();
        }
    }
     
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        backtrack(0, nums);
        return ans;
    }
};

491. 递增子序列 ●●

给你一个整数数组 nums ,找出并返回所有该数组中不同的递增子序列,递增子序列中 至少有两个元素 。你可以按 任意顺序 返回答案。
数组中可能含有重复元素,如出现两个整数相等,也可以视作递增序列的一种特殊情况。

输入: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]]

在这里插入图片描述

  • <unordered_set> 哈希表查找同一个for循环是否用过相同元素
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> subset;

    void backtrack(int start, vector<int> nums){
        unordered_set<int> hash;    // 重新定义hash,即对同一个for循环(即同一层)才有效
        // 遍历所有节点,不需要额外终止条件
        if(subset.size() >= 2) ans.emplace_back(subset);    // 大于等于两个元素,记录子集
        for(int i = start; i < nums.size(); ++i){
            if(!subset.empty() && nums[i] < subset.back()   // 当前元素值非递增数
                || hash.find(nums[i]) != hash.end()){       // 横向出现过重复元素
                    continue;                               // 跳出不处理
            }
            hash.insert(nums[i]);               // 记录横向遍历重复元素
            subset.emplace_back(nums[i]);       // 加入递增元素
            backtrack(i+1, nums);               // 递归
            subset.pop_back();                  // 回溯
        }
    }
    
    vector<vector<int>> findSubsequences(vector<int>& nums) {
        backtrack(0, nums);
        return ans;
    }
};
  • 数组作为哈希表去重(题目中元素值范围为[-100, 100]),优化处理速度。
class Solution {
public:
    vector<vector<int>> ans;
    vector<int> subset;

    void backtrack(int start, vector<int> nums){
        int hash[201] = {0};    // 重新定义hash,即对同一个for循环(即同一层)才有效
        // 遍历所有节点,不需要额外终止条件
        if(subset.size() >= 2) ans.emplace_back(subset);    // 大于等于两个元素,记录子集
        for(int i = start; i < nums.size(); ++i){
            if(!subset.empty() && nums[i] < subset.back()   // 当前元素值非递增数
                || hash[nums[i]+100] == 1){       // 横向出现过重复元素
                    continue;                               // 跳出不处理
            }
            hash[nums[i]+100] = 1;               // 记录横向遍历重复元素
            subset.emplace_back(nums[i]);       // 加入递增元素
            backtrack(i+1, nums);               // 递归
            subset.pop_back();                  // 回溯
        }
    }
    
    vector<vector<int>> findSubsequences(vector<int>& nums) {
        backtrack(0, nums);
        return ans;
    }
};

4、排列问题

46. 全排列 ●●

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

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

可以看出元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了,而需要一个used数组,标记已经选择的元素,如图橘黄色部分所示:
在这里插入图片描述
在这里插入图片描述

used数组标记 + 回溯

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> subset;

    void backtrack(vector<int> nums, vector<bool>&used){
        if(subset.size() == nums.size()){       // 个数相等,排列完成,终止
            ans.emplace_back(subset);   
            return;
        }

        for(int i = 0; i < nums.size(); ++i){
            if(used[i] == 1) continue;      // 元素被使用过,跳过
            subset.emplace_back(nums[i]);   
            used[i] = 1;
            backtrack(nums, used);          // 递归
            subset.pop_back();              // 回溯
            used[i] = 0;                    // 回溯
        }
    }

    vector<vector<int>> permute(vector<int>& nums) {
        vector<bool> used(nums.size(), 0);  // 逻辑数组 表示第i个元素是否已被使用
        backtrack(nums, used);
        return ans;
    }
};

47. 全排列 II ●●

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

在这里插入图片描述

1. 未排序 + used数组标记 + 数组哈希树层去重

递归内建立数组哈希表,判断同一个for横向遍历中是否使用过同一元素值,消耗相对更大。

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> subset;
    
    void backtrack(vector<int> nums, vector<bool>&used){
        if(subset.size() == nums.size()){
            ans.emplace_back(subset);
            return;
        }
        int hash[21] = {0};    // 递归内建立哈希表,判断同一个for横向遍历中是否使用过同一元素值
        for(int i = 0; i < nums.size(); ++i){
            if(hash[nums[i]+10] == 1) continue; // 同一层重复元素,跳过
            if(used[i] == 1) continue;          // 全局判断第i个元素是否使用过
            subset.emplace_back(nums[i]);
            used[i] = 1;
            hash[nums[i] + 10] = 1;             // 数组哈希
            backtrack(nums, used);
            subset.pop_back();                  // 回溯
            used[i] = 0;                        // 回溯
        }

    }
    
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        vector<bool> used(nums.size(), 0);
        backtrack(nums, used);
        return ans;
    }
};

2. 排序 + used数组标记 + 树层去重

对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用

同一树层,前一位(也就是nums[i-1])如果使用过,那么就进行去重。
在这里插入图片描述

class Solution {
public:
    vector<vector<int>> ans;
    vector<int> subset;
    
    void backtrack(vector<int> nums, vector<bool>&used){
        if(subset.size() == nums.size()){
            ans.emplace_back(subset);
            return;
        }

        for(int i = 0; i < nums.size(); ++i){
            // used[i - 1] == true,说明同一【树枝】nums[i - 1]使用过
            // used[i - 1] == false,说明同一【树层】nums[i - 1]使用过    
            if(i > 0 && nums[i] == nums[i-1] && used[i-1] == 0) continue;   // 树层去重
            if(used[i] == 1) continue;          // 全局判断第i个元素是否使用过
            subset.emplace_back(nums[i]);
            used[i] = 1;
            backtrack(nums, used);
            subset.pop_back();                  // 回溯
            used[i] = 0;                        // 回溯
        }
    }
    
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        vector<bool> used(nums.size(), 0);
        sort(nums.begin(), nums.end());
        backtrack(nums, used);
        return ans;
    }
};

3. 排序 + used数组标记 + 树枝去重

将树层去重判断的条件used[i-1] == 0 变为 树枝去重used[i-1] ==1 即可,但是树层上去重效率更高!

树层上对前一位去重非常彻底,效率很高,树枝上对前一位去重虽然最后可以得到答案,但是做了很多无用搜索。
在这里插入图片描述

5、棋盘问题

51. N 皇后 ●●●

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。
每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。

不能同行;
不能同列;
不能同斜线。

思路:
递归遍历棋盘的深度(行),for 循环遍历棋盘的宽度(列);
在判断是否符合条件时,由于每次递归切换到了新行,不存在同行的情况,所以每次判断排除同列和同斜线即可。
在这里插入图片描述

class Solution {
public:
    vector<vector<string>> ans;
    vector<string> sub;
    void backtrack(int n, int num, vector<int>& column){ // n表示皇后数量,遍历第num个皇后(行),column[i] = 1 表示已占据i列
        if(num == n){
            ans.emplace_back(sub);
            return;
        }
        string rowStr(n, '.');
        for(int i = 0; i < n; ++i){         // 列
            if(column[i] == 1) continue;    // 排除同列(columm数组)
            int flag = 1;                   // 排除斜线(判断已有字符串的Q位置)
            for(int k = num - 1; k >= 0; --k){  
                if(((i-(num-k) >= 0) && sub[k][i-(num-k)] == 'Q') || (i+num-k < n && sub[k][i+num-k] == 'Q')){
                    flag = 0;
                    break;
                }
            }
            if(flag == 0) continue;
            column[i] = 1;                  // 占据一列
            rowStr[i] = 'Q';                
            sub.emplace_back(rowStr);       
            backtrack(n, num + 1, column);  // 递归下一行
            rowStr[i] = '.';                // 回溯
            column[i] = 0;
            sub.pop_back();
        }
    }

    vector<vector<string>> solveNQueens(int n) {
        vector<int> column(n, 0);
        backtrack(n, 0, column);
        return ans;
    }
};

37. 解数独 ●●●

编写一个程序,通过填充空格来解决数独问题。
数独的解法需 遵循如下规则:
数字 1-9 在每一行只能出现一次。
数字 1-9 在每一列只能出现一次。
数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)
数独部分空格内已填入了数字,空白格用 ‘.’ 表示。
在这里插入图片描述

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

本题中棋盘的每一个位置都要放一个数字,并检查数字是否合法,解数独的树形结构要比N皇后更宽更深。
在这里插入图片描述

  1. 返回值:是否找到(唯一一个)符合的答案;
  2. 终止条件:本题递归不用终止条件,解数独是要遍历整个树形结构寻找可能的叶子节点就立刻返回。
    递归的下一层的棋盘一定比上一层的棋盘多一个数,等数填满了棋盘自然就终止(填满当然好了,说明找到结果了),所以不需要终止条件!
  3. 单层逻辑二维递归(两个for循环嵌套着递归);一个for循环遍历棋盘的行,一个for循环遍历棋盘的列,一行一列确定下来之后,递归遍历这个位置放9个数字的合法性(行、列、宫格)!

利用3个二维数组作为哈希表,判断数字合法性。

class Solution {
public:
    bool backtrack(vector<vector<int>>& rowHash, vector<vector<int>>& colHash, vector<vector<int>>& rectHash, vector<vector<char>>& board){
        for(int i = 0; i < 9; ++i){                 // 遍历行
            for(int j = 0; j < 9; ++j){             // 遍历列
                if(board[i][j] != '.') continue;    // 已填充数字,跳过
                for(int num = 1; num <= 9; ++num){  // 遍历1-9,判断可填充数字
                    if(rowHash[i][num] == 1 || colHash[j][num] == 1     // 行、列、九宫格重复判断
                        || rectHash[i / 3 * 3 + j / 3][num] == 1) continue;
                    board[i][j] = num + '0';        // 该数字不重复,占坑
                    rowHash[i][num] = 1;            // 行哈希
                    colHash[j][num] = 1;            // 列哈希
                    rectHash[i / 3 * 3 + j / 3][num] = 1;   // 宫格哈希
                    if(backtrack(rowHash, colHash, rectHash, board)) return true;
                    board[i][j] = '.';              // 回溯
                    rowHash[i][num] = 0; 
                    colHash[j][num] = 0;
                    rectHash[i / 3 * 3 + j / 3][num] = 0;
                }   
                return false;   // 9个数字遍历完都无法填充,返回false,避免无限递归
            }
        }
        return true;	// 所有行和列都遍历结束,返回true
    }

    void solveSudoku(vector<vector<char>>& board) {
        vector<vector<int>> rowHash(9, vector<int>(10, 0));
        vector<vector<int>> colHash(9, vector<int>(10, 0));
        vector<vector<int>> rectHash(9, vector<int>(10, 0));
        for(int i = 0; i < 9; i++){
            for(int j = 0; j < 9; j++){
                if(board[i][j] != '.'){
                    rowHash[i][board[i][j]-'0'] = 1;   // 第 i 行存在 board[i][j]
                    colHash[j][board[i][j]-'0'] = 1;   // 第 j 行存在 board[i][j]
                    rectHash[i / 3 * 3 + j / 3][board[i][j]-'0'] = 1;   // 0-8宫格哈希
                }
            }
        }
        backtrack(rowHash, colHash, rectHash, board);
    }
};

332. 重新安排行程 ●●●

给你一份航线列表 tickets ,其中 tickets[i] = [fromi, toi] 表示飞机出发和降落的机场地点。请你对该行程进行重新规划排序。
所有这些机票都属于一个从 JFK 出发的先生,所以该行程必须从 JFK 开始。如果存在多种有效的行程,请你按字典排序返回最小的行程组合。
例如,行程 [“JFK”, “LGA”] 与 [“JFK”, “LGB”] 相比就更小,排序更靠前。
假定所有机票至少存在一种合理的行程。且所有的机票 必须都用一次 且 只能用一次。

在这里插入图片描述

  • 映射关系(关键在于 容器、结构的选择):

一个机场映射多个机场,可以使用unordered_map,如果让多个机场之间再有顺序的话,就是用 map 或者 multimap 或者multiset

这种映射关系可以定义为:
unordered_map<string, multiset> targets 代表 unordered_map<出发机场, 到达机场的集合> targets
unordered_map<string, map<string, int>> targets 代表 unordered_map<出发机场, map<到达机场, 航班次数>> targets

而题目中出发机场和到达机场是会重复的,搜索的过程没及时处理目的机场就会死循环
所以,当选用multiset结构时,搜索的过程中要不断的删除multiset里的元素,而一旦删除元素,迭代器就失效了;

所以选用unordered_map<string, map<string, int>> targets结构,在遍历搜索时,对当前航班的航班次数进行增减操作实现次数标记,通过次数是否大于零来判断该趟航班是否有效

以上述[[“JFK”, “KUL”], [“JFK”, “NRT”], [“NRT”, “JFK”]为例,抽象为树形结构:
在这里插入图片描述

  • 递归参数:
    机票总数(用于终止条件)、行程结果(字符串数组)

  • 函数返回值:
    bool值(map容器已经过排序,我们只需要找到一个行程,就是在树形结构中唯一的一条通向叶子节点的路线,所以找到了这个叶子节点了直接返回)

  • 终止条件:
    到达叶子节点,经过的机场个数(行程数组长度) = 航班数 + 1

  • 单层逻辑:
    按照顺序遍历当前所在机场为起点的所有航班信息,并不断递归,直到到达叶子节点则结束递归。

class Solution {
public:
    unordered_map<string, map<string, int>> targets;    // targets[起点] = map<终点, 剩余次数>
    
    bool backtrack(int ticketNum, vector<string> & ans){
        if(ans.size() == ticketNum + 1){        // 终止条件:到达叶子节点
            return true;
        }
        // 遍历当前所在机场ans[ans.size()-1]为起点的航班targets[ans[ans.size()-1]]
        for(pair<const string, int> &target : targets[ans[ans.size()-1]]){  
            if(target.second > 0){              // 剩余航班次数 > 0
                --target.second;                // 行程有效,起飞
                ans.emplace_back(target.first);
                if(backtrack(ticketNum, ans)) return true;  // 到达叶子节点,返回
                ++target.second;                // 回溯
                ans.pop_back();
            }
        }
        return false;
    }

    vector<string> findItinerary(vector<vector<string>>& tickets) {
        vector<string> ans;
        ans.emplace_back("JFK");                // 固定起点
        for(vector<string> ticket : tickets){   // 初始化所有航班映射结构
            ++targets[ticket[0]][ticket[1]];    
        }
        backtrack(tickets.size(), ans);
        return ans;
    }
};

循环遍历代码中 pair 里要有const,因为map中的key不可修改的,所以是:

for (pair<const string, int>& target : targets[result[result.size() - 1]])

如果不加 const,也可以复制一份pair,例如这么写:

for (pair<string, int>target : targets[result[result.size() - 1]])
  • 0
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值