代码随想录:回溯篇

77. 组合

核心思路

组合问题无法通过多层for循环暴力实现(k层嵌套不现实),需用回溯法模拟多层循环:将问题抽象为「n为树宽、k为树深」的N叉树,横向遍历(for循环)选当前层元素,纵向遍历(递归)选下一层元素,遍历到叶子节点(path长度=k)时收集结果。

一、基础回溯解法

核心逻辑
  1. 全局变量:result 存最终组合集,path 存单次组合;
  2. 递归参数:startIndex 控制每层遍历起始位置(避免重复组合);
  3. 终止条件:path.size() == k 时,将path存入result;
  4. 单层逻辑:for循环横向遍历(从startIndex开始),递归纵向遍历(i+1避免重复),回溯撤销当前选择。
class Solution {
private:
    vector<vector<int>> result; // 最终结果集
    vector<int> path;           // 单次组合路径
    void backtracking(int n, int k, int startIndex) {
        // 终止条件:路径长度达到k,收集结果
        if (path.size() == k) {
            result.push_back(path);
            return;
        }
        // 横向遍历:从startIndex开始,避免重复组合
        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); // 初始起始位置为1
        return result;
    }
};

二、剪枝优化

核心逻辑

若当前起始位置后剩余元素数 < 所需元素数(k - path.size()),无需继续遍历,直接剪枝:

  • 已选元素数:path.size()
  • 还需元素数:k - path.size()
  • 遍历终止位置:n - (k - path.size()) + 1(+1因左闭区间)。
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;
        }
        // 剪枝:i <= 终止位置,避免无效遍历
        for (int i = startIndex; i <= n - (k - path.size()) + 1; 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;
    }
};

三、核心总结

  1. 回溯本质:用「递归+for循环」模拟多层嵌套循环,解决k层for循环无法实现的问题;
  2. 去重关键:startIndex 控制每层遍历起始位置,避免出现[1,2]和[2,1]这类重复组合;
  3. 剪枝逻辑:通过计算剩余元素数,减少无效遍历,提升效率(如n=4、k=3时,起始位置无需从3/4开始)。

基础解法直观易懂,剪枝优化是面试加分项,核心是理解「树形结构+循环不变量」的回溯逻辑。


216. 组合总和III

核心思路

在固定集合 [1,2,...,9] 中找 k个不重复数的组合,且组合和为n。核心逻辑与77.组合一致(回溯法+树形遍历),仅新增「和的限制」与「固定集合范围(1-9)」两个约束。

一、基础回溯解法

核心逻辑
  1. 全局变量:result 存最终结果集,path 存单次组合;
  2. 递归参数:targetSum(目标和n)、k(组合长度)、sum(当前组合和)、startIndex(遍历起始位置);
  3. 终止条件:path.size() == k 时,若sum == targetSum则收集结果,否则直接返回;
  4. 单层逻辑:for循环遍历1-9(从startIndex开始),累加元素和、记录路径,递归后回溯撤销选择。
class Solution {
private:
    vector<vector<int>> result; // 最终结果集
    vector<int> path;           // 单次组合路径
    void backtracking(int targetSum, int k, int sum, int startIndex) {
        // 终止条件:组合长度达k,判断和是否符合
        if (path.size() == k) {
            if (sum == targetSum) result.push_back(path);
            return;
        }
        // 横向遍历:固定范围1-9,startIndex避免重复
        for (int i = startIndex; i <= 9; i++) {
            sum += i;          // 累加当前元素和
            path.push_back(i); // 记录当前元素
            backtracking(targetSum, k, sum, i + 1); // 递归下一层
            sum -= i;          // 回溯:撤销和
            path.pop_back();   // 回溯:撤销元素
        }
    }
public:
    vector<vector<int>> combinationSum3(int k, int n) {
        backtracking(n, k, 0, 1); // 初始和为0,起始位置1
        return result;
    }
};

二、剪枝优化(关键优化)

核心逻辑
  1. 提前终止:若当前组合和sum > targetSum,直接返回(后续元素更大,无需遍历);
  2. 遍历范围剪枝:i <= 9 - (k - path.size()) + 1(剩余元素数不足时,停止遍历)。
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(int targetSum, int k, int sum, int startIndex) {
        if (sum > targetSum) return; // 剪枝:和超目标,直接返回
        if (path.size() == k) {
            if (sum == targetSum) result.push_back(path);
            return;
        }
        // 剪枝:缩小遍历范围,避免无效循环
        for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
            sum += i;
            path.push_back(i);
            backtracking(targetSum, k, sum, i + 1);
            sum -= i;
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combinationSum3(int k, int n) {
        backtracking(n, k, 0, 1);
        return result;
    }
};

三、核心总结

  1. 与77.组合的区别:新增「和为n」的约束、集合固定为1-9(无需传n,遍历范围直接限制为9);
  2. 回溯核心:startIndex 避免重复组合,「处理+递归+回溯」三步缺一不可;
  3. 剪枝技巧:和超目标时提前终止、遍历范围按剩余所需元素数收缩,大幅减少无效遍历。

17. 电话号码的字母组合(精简版)

核心思路

将数字字符串映射为字母集合,通过回溯法解决多层for循环嵌套问题:把数字串长度作为树的深度,每个数字对应的字母集作为树的宽度,遍历到叶子节点(遍历完所有数字)时收集字母组合结果。

版本一:直观回溯

关键准备

定义数字到字母的映射数组(匹配电话按键):

const string letterMap[10] = {
    "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
};
完整代码
class Solution {
private:
    const string letterMap[10] = {
        "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
    };
    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'; // 字符转数字
        string letters = letterMap[digit];
        // 遍历当前数字的所有字母
        for (int i = 0; i < letters.size(); i++) {
            s.push_back(letters[i]);          // 选择当前字母
            backtracking(digits, index + 1);  // 递归处理下一个数字
            s.pop_back();                     // 回溯:撤销选择
        }
    }
public:
    vector<string> letterCombinations(string digits) {
        if (digits.empty()) return {}; // 空输入直接返回空
        backtracking(digits, 0);
        return result;
    }
};

版本二:回溯藏于参数

class Solution {
private:
    const string letterMap[10] = {
        "", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
    };
    vector<string> result;
    
    void getCombinations(const string& digits, int index, const string& s) {
        if (index == digits.size()) {
            result.push_back(s);
            return;
        }
        int digit = digits[index] - '0';
        string letters = letterMap[digit];
        // 回溯隐藏在参数拼接中(s + letters[i] 生成新字符串,无需手动pop)
        for (int i = 0; i < letters.size(); i++) {
            getCombinations(digits, index + 1, s + letters[i]);
        }
    }
public:
    vector<string> letterCombinations(string digits) {
        if (digits.empty()) return {};
        getCombinations(digits, 0, "");
        return result;
    }
};

三、核心总结

  1. 与组合问题的区别:本题是多集合求组合(每个数字对应独立字母集),77/216是单集合求组合;
  2. 关键参数:index 记录当前处理的数字下标(树的深度),而非startIndex;
  3. 边界处理:空输入直接返回空结果,避免无效递归;
  4. 效率说明:时间复杂度 O(3^m * 4^n)(m为对应3个字母的数字数,n为对应4个字母的数字数)。

版本一逻辑直观(手动处理回溯),是面试首选;版本二通过参数拼接隐藏回溯,代码更短但可读性稍差。


39. 组合总和

核心思路

在无重复元素的数组中找和为target的组合,元素可无限重复选取。核心逻辑:用回溯法遍历所有可能的组合,通过startIndex控制遍历起始位置(避免重复组合),递归时允许重复选取当前元素(i而非i+1),并通过排序+剪枝优化减少无效遍历。

一、基础回溯解法

核心逻辑
  1. 全局变量:result 存最终组合集,path 存单次组合;
  2. 递归参数:candidates(候选数组)、target(目标和)、sum(当前组合和)、startIndex(遍历起始位置);
  3. 终止条件:sum > target 直接返回,sum == target 收集结果;
  4. 单层逻辑:for循环从startIndex开始,累加元素和、记录路径,递归时传入i(允许重复选当前元素),最后回溯撤销选择。
class Solution {
private:
    vector<vector<int>> result; // 最终结果集
    vector<int> path;           // 单次组合路径
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum > target) return; // 和超目标,直接返回
        if (sum == target) {      // 和符合目标,收集结果
            result.push_back(path);
            return;
        }
        // 遍历候选数组,startIndex避免重复组合
        for (int i = startIndex; i < candidates.size(); i++) {
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i); // 传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;
    }
};

二、剪枝优化

核心逻辑
  1. 排序:先对候选数组排序,使元素从小到大排列;
  2. 遍历剪枝:sum + candidates[i] <= target 时才遍历(提前终止无效循环)。
class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum == target) {
            result.push_back(path);
            return;
        }
        // 剪枝:sum + 当前元素 > 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);
            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;
    }
};

三、核心总结

  1. 与经典组合问题的区别:
    • 无组合数量限制,仅限制和为target;
    • 元素可无限重复选取(递归传i而非i+1);
  2. startIndex的作用:单集合求组合需用(避免[2,3][3,2]这类重复),多集合求组合无需用(如17.电话号码字母组合);
  3. 剪枝技巧:求和问题中,排序后通过sum + candidates[i] <= target剪枝,大幅减少无效递归。

基础解法逻辑直观,剪枝优化是面试加分项,核心是理解「允许重复选元素」和「排序剪枝」的底层逻辑。


40. 组合总和II(精简版)

核心思路

有重复元素的数组中找和为target的组合,要求:① 每个元素在组合中仅用一次;② 解集无重复组合。核心难点是树层去重(避免同一层重复选相同元素),需先排序数组,再通过used数组startIndex实现去重,同时保留树枝上的合法重复(如示例中的[1,1,6])。

版本一:used数组去重,直观易理解

核心逻辑
  1. 排序:先对数组排序,让相同元素相邻,为去重做准备;
  2. used数组:标记元素是否被使用,used[i-1]==false 表示同一树层的相同元素已使用(需跳过),used[i-1]==true 表示同一树枝的相同元素已使用(合法);
  3. 递归参数:新增used数组,递归时i+1(元素仅用一次);
  4. 剪枝:sum + candidates[i] <= target 提前终止无效遍历。
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;
        }
        // 遍历+剪枝:sum + 当前元素 > target 时终止
        for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
            // 树层去重:相同元素且前一个未使用(同一层已选过)
            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); // 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);
        sort(candidates.begin(), candidates.end()); // 排序为去重做准备
        backtracking(candidates, target, 0, 0, used);
        return result;
    }
};

版本二:无需used数组,startIndex去重

核心逻辑

直接通过i > startIndex判断是否为同一树层的重复元素(无需额外数组,更简洁):

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;
    void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
        if (sum == target) {
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
            // 树层去重:i > startIndex 说明是同一层的重复元素
            if (i > startIndex && candidates[i] == candidates[i-1]) {
                continue;
            }
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates, target, sum, i+1);
            sum -= candidates[i];
            path.pop_back();
        }
    }
public:
    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        sort(candidates.begin(), candidates.end());
        backtracking(candidates, target, 0, 0);
        return result;
    }
};

三、核心总结

  1. 与39.组合总和的关键区别:
    • 元素仅用一次(递归传i+1而非i);
    • 数组有重复元素,需树层去重(避免重复组合);
  2. 去重核心:
    • 先排序,让相同元素相邻;
    • 树层去重(跳过同一层重复元素),树枝去重(允许同一组合内的合法重复);
  3. 两种去重方式:
    • used数组:直观区分树层/树枝去重,适合理解;
    • startIndex:更简洁,无需额外数组,面试首选。

本题的核心是理解「树层去重」和「树枝去重」的区别,排序+去重是解决重复数组组合问题的通用套路。


131. 分割回文串

核心思路

将字符串分割为若干回文子串,本质是组合问题的变形:把「切割」抽象为树形遍历(递归纵向遍历、for循环横向遍历),切割线遍历到字符串末尾时收集结果,核心需解决「切割模拟」和「回文判断」两个问题。

一、基础回溯解法

核心逻辑
  1. 全局变量:result 存最终分割方案,path 存单次分割的回文子串;
  2. 递归参数:startIndex 表示当前切割起始位置(即切割线);
  3. 终止条件:startIndex >= s.size() 时,说明切割到字符串末尾,收集当前分割方案;
  4. 单层逻辑:for循环从startIndex开始截取子串[startIndex, i],判断是否为回文,是则加入path,递归处理下一段(i+1),最后回溯撤销选择。
class Solution {
private:
    vector<vector<string>> result; // 最终分割方案
    vector<string> path;           // 单次分割的回文子串
    
    // 判断子串s[start..end]是否为回文(双指针法)
    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;
    }

    void backtracking(const string& s, int startIndex) {
        // 终止条件:切割线到字符串末尾,收集结果
        if (startIndex >= s.size()) {
            result.push_back(path);
            return;
        }
        // 横向遍历:截取[startIndex, i]的子串
        for (int i = startIndex; i < s.size(); i++) {
            if (isPalindrome(s, startIndex, i)) { // 是回文才切割
                string sub = s.substr(startIndex, i - startIndex + 1);
                path.push_back(sub);
            } else {
                continue; // 非回文跳过
            }
            backtracking(s, i + 1); // 递归:切割下一段(i+1为新切割线)
            path.pop_back();        // 回溯:撤销当前切割
        }
    }
public:
    vector<vector<string>> partition(string s) {
        backtracking(s, 0);
        return result;
    }
};

二、优化解法(动态规划预存回文判断)

核心逻辑

提前用动态规划计算所有子串是否为回文,避免回溯中重复判断,提升效率:

  • isPalindrome[i][j] 表示s[i..j]是否为回文;
  • 状态转移:s[i]==s[j]s[i+1..j-1] 是回文 → s[i..j] 是回文。
class Solution {
private:
    vector<vector<string>> result;
    vector<string> path;
    vector<vector<bool>> isPalindrome; // 预存子串是否为回文

    // 预处理所有子串的回文状态
    void computePalindrome(const string& s) {
        isPalindrome.resize(s.size(), vector<bool>(s.size(), false));
        // 倒序遍历,保证计算i行时i+1行已完成
        for (int i = s.size()-1; i >= 0; i--) {
            for (int j = i; j < s.size(); j++) {
                if (i == j) 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]);
            }
        }
    }

    void backtracking(const string& s, int startIndex) {
        if (startIndex >= s.size()) {
            result.push_back(path);
            return;
        }
        for (int i = startIndex; i < s.size(); i++) {
            if (isPalindrome[startIndex][i]) { // 直接查预处理结果
                string sub = s.substr(startIndex, i - startIndex + 1);
                path.push_back(sub);
            } else {
                continue;
            }
            backtracking(s, i + 1);
            path.pop_back();
        }
    }
public:
    vector<vector<string>> partition(string s) {
        computePalindrome(s); // 预处理回文状态
        backtracking(s, 0);
        return result;
    }
};

三、核心总结

  1. 切割问题本质:等价于组合问题,startIndex 是切割线,i 是当前尝试的新切割线,递归传i+1避免重复切割;
  2. 回文判断:基础版用双指针(简单直观),优化版用动态规划(预存结果,减少重复计算);
  3. 关键逻辑:
    • 终止条件:切割线遍历到字符串末尾;
    • 单层遍历:仅截取回文子串,非回文直接跳过。

本题难点在于将「切割」抽象为树形遍历,核心是复用组合问题的回溯框架,结合回文判断即可解决。


93. 复原IP地址

核心思路

将纯数字字符串分割为4段合法IP段(每段0-255、无前置0、仅数字),本质是带约束的切割问题:用回溯法遍历所有切割可能,通过「逗点数量」控制分割段数,通过合法性校验过滤无效分割。

一、完整解法

核心逻辑
  1. 递归参数:startIndex(切割起始位置)、pointNum(已添加逗点数量,最多3个);
  2. 终止条件:pointNum == 3 时,校验最后一段是否合法,合法则收集结果;
  3. 单层逻辑:遍历截取[startIndex, i]子串,校验合法后插入.,递归处理下一段,最后回溯撤销.
  4. 合法性校验:① 无前置0(单0合法);② 纯数字;③ 数值≤255。
class Solution {
private:
    vector<string> result; // 最终合法IP集合

    // 校验[start, end]子串是否为合法IP段
    bool isValid(const string& s, int start, int end) {
        if (start > end) return false;
        // 前置0不合法(单0除外)
        if (s[start] == '0' && start != end) return false;
        int num = 0;
        for (int i = start; i <= end; i++) {
            if (s[i] < '0' || s[i] > '9') return false; // 非数字
            num = num * 10 + (s[i] - '0');
            if (num > 255) return false; // 超过255
        }
        return true;
    }

    // startIndex:当前切割起始位,pointNum:已添加逗点数
    void backtracking(string& s, int startIndex, int pointNum) {
        // 终止条件:逗点数为3,校验最后一段
        if (pointNum == 3) {
            if (isValid(s, startIndex, s.size()-1)) {
                result.push_back(s);
            }
            return;
        }
        // 横向遍历:截取[startIndex, i]子串
        for (int i = startIndex; i < s.size(); i++) {
            if (isValid(s, startIndex, i)) {
                s.insert(s.begin() + i + 1, '.'); // 插入逗点
                backtracking(s, i + 2, pointNum + 1); // 下一段从i+2开始(跳过逗点)
                s.erase(s.begin() + i + 1); // 回溯:删除逗点
            } else break; // 子串非法,直接终止本层遍历
        }
    }

public:
    vector<string> restoreIpAddresses(string s) {
        // 剪枝:IP长度需4-12位(4段,每段1-3位)
        if (s.size() < 4 || s.size() > 12) return {};
        backtracking(s, 0, 0);
        return result;
    }
};

二、核心总结

  1. 切割逻辑:与131.分割回文串一致,startIndex为切割线,区别在于用pointNum限制分割段数(最多3个逗点=4段);
  2. 合法性校验(关键):
    • 段不能以0开头(单0除外);
    • 段必须是纯数字且数值≤255;
  3. 回溯操作:插入.后递归,回溯时删除.,保证字符串还原;
  4. 剪枝优化:提前过滤长度<4或>12的字符串(IP段数/位数约束)。

78. 子集

核心思路

子集问题是回溯法的经典应用,与组合/分割问题的核心区别:组合/分割只收集叶子节点,子集需收集所有节点。将数组抽象为树形结构,递归纵向遍历、for循环横向遍历,遍历过程中收集每一步的路径(即所有子集),通过startIndex控制元素不重复选取。

一、完整解法

核心逻辑
  1. 全局变量:result 存所有子集,path 存当前子集路径;
  2. 递归参数:startIndex 表示当前遍历起始位置(避免重复选取元素);
  3. 关键操作:进入递归后先收集当前路径(所有节点都是子集),再通过for循环遍历剩余元素;
  4. 终止条件:startIndex >= nums.size() 时自然终止(可省略,因for循环会结束)。
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; // 终止条件(可省略)
        
        // 横向遍历:从startIndex开始,避免重复选取
        for (int i = startIndex; i < nums.size(); i++) {
            path.push_back(nums[i]);    // 选择当前元素
            backtracking(nums, i + 1);  // 递归:下一层从i+1开始
            path.pop_back();            // 回溯:撤销选择
        }
    }

public:
    vector<vector<int>> subsets(vector<int>& nums) {
        backtracking(nums, 0);
        return result;
    }
};

二、核心总结

  1. 核心区别:
    • 组合/分割问题:仅收集树形结构的叶子节点
    • 子集问题:收集树形结构的所有节点(每一步路径都是子集);
  2. 关键逻辑:
    • 进入递归后先收集当前路径(必做,否则漏掉空集/中间子集);
    • startIndex 控制遍历起始位置,保证元素不重复选取(子集无序);
  3. 简化点:终止条件可省略,因startIndex >= nums.size()时for循环直接结束,无递归操作。

本题是回溯模板题,核心是理解「收集所有节点」的逻辑,其余与组合问题的回溯框架完全一致。


90. 子集II

核心思路

在含重复元素的数组中求所有不重复子集,核心是子集问题+树层去重:先排序让重复元素相邻,再通过used数组startIndex跳过同一树层的重复元素(保留树枝上的合法重复),其余逻辑与78.子集一致(收集所有节点)。

一、解法1:used数组去重(直观易理解)

核心逻辑
  1. 排序:让重复元素相邻,为去重做准备;
  2. used数组:used[i-1]==false 表示同一树层的重复元素已使用(需跳过),used[i-1]==true 表示同一树枝的重复元素(合法);
  3. 收集子集:进入递归后先收集当前路径(所有节点都是子集)。
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++) {
            // 树层去重:相同元素且前一个未使用(同一层已选过)
            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); // i+1:元素不重复选取
            // 回溯
            used[i] = false;
            path.pop_back();
        }
    }

public:
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        sort(nums.begin(), nums.end()); // 排序为去重做准备
        vector<bool> used(nums.size(), false);
        backtracking(nums, 0, used);
        return result;
    }
};

二、解法2:无需used数组(简洁版)

核心逻辑

直接通过i > startIndex判断同一树层的重复元素(无需额外数组):

class Solution {
private:
    vector<vector<int>> result;
    vector<int> path;

    void backtracking(vector<int>& nums, int startIndex) {
        result.push_back(path);
        for (int i = startIndex; i < nums.size(); i++) {
            // 树层去重:i > startIndex 说明是同一层的重复元素
            if (i > startIndex && nums[i] == nums[i-1]) {
                continue;
            }
            path.push_back(nums[i]);
            backtracking(nums, i+1);
            path.pop_back();
        }
    }

public:
    vector<vector<int>> subsetsWithDup(vector<int>& nums) {
        sort(nums.begin(), nums.end());
        backtracking(nums, 0);
        return result;
    }
};

三、核心总结

  1. 与78.子集的核心区别:
    • 数组含重复元素,需树层去重(避免重复子集);
    • 先排序,让重复元素相邻,是去重的前提;
  2. 去重逻辑(二选一):
    • used数组:i>0 && nums[i]==nums[i-1] && used[i-1]==false 跳过树层重复;
    • startIndex:i>startIndex && nums[i]==nums[i-1] 跳过树层重复(更简洁);
  3. 子集核心:进入递归后先收集当前路径(所有节点都是子集),这是与组合/分割问题的关键区别。

本题是78.子集+40.组合总和II的结合体,掌握「排序+树层去重」即可解决,是回溯去重的经典应用。


491. 递增子序列(精简版)

核心思路

在不排序的前提下找所有长度≥2的递增子序列,核心是「递增约束+树层去重」:

  1. 递增约束:当前选的数必须≥路径最后一个数(保证子序列递增);
  2. 树层去重:同一层不能选重复数(避免重复子序列),因不能排序,需用哈希(数组/set)记录本层已选数;
  3. 子集逻辑:收集所有长度≥2的路径(无需终止条件,startIndex自然限制递归)。

一、基础解法(unordered_set去重)

class Solution {
private:
    vector<vector<int>> result; // 所有递增子序列
    vector<int> path;           // 当前子序列路径

    void backtracking(vector<int>& nums, int startIndex) {
        if (path.size() > 1) {  // 收集长度≥2的子序列
            result.push_back(path);
        }
        unordered_set<int> uset; // 记录本层已选数(去重)
        for (int i = startIndex; i < nums.size(); i++) {
            // 跳过:1. 破坏递增 2. 本层已选过该数
            if ((!path.empty() && nums[i] < path.back()) || uset.count(nums[i])) {
                continue;
            }
            uset.insert(nums[i]); // 标记本层已选
            path.push_back(nums[i]);
            backtracking(nums, i + 1); // 下一层从i+1开始
            path.pop_back(); // 回溯
        }
    }

public:
    vector<vector<int>> findSubsequences(vector<int>& nums) {
        backtracking(nums, 0);
        return result;
    }
};

二、核心总结

  1. 与90.子集II的关键区别:
    • 本题不能排序(排序会破坏原数组的递增子序列逻辑);
    • 去重方式:用哈希(set/数组)记录本层已选数,而非排序+startIndex;
  2. 核心约束:
    • 递增:nums[i] ≥ path.back()(path非空时);
    • 去重:同一层不选重复数(哈希表标记);
  3. 效率优化:
    • 数值范围小([-100,100])时,用数组哈希比unordered_set更快,避免哈希映射开销。

本题是子集去重的进阶版,核心是「不排序场景下的树层去重」,掌握哈希表记录本层已选数是关键。


46. 全排列(精简版)

核心思路

全排列是有序组合([1,2]和[2,1]是不同结果),核心区别于组合/子集:

  1. 无需startIndex,每层遍历从0开始(允许重复选元素,但同一排列内元素仅用一次);
  2. used数组标记已选元素,避免同一排列内重复使用;
  3. 终止条件:路径长度等于数组长度(收集完整排列)。

一、完整解法

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;
        }
        // 每层从0开始遍历(排列有序)
        for (int i = 0; i < nums.size(); i++) {
            if (used[i]) continue; // 跳过已选元素(同一排列内)
            used[i] = true;        // 标记已选
            path.push_back(nums[i]);
            backtracking(nums, used);
            // 回溯
            path.pop_back();
            used[i] = false;
        }
    }

    vector<vector<int>> permute(vector<int>& nums) {
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return result;
    }
};

二、核心总结

  1. 与组合/子集的关键区别:
    • 排列有序:每层遍历从0开始,而非startIndex
    • 去重约束:用used数组标记已选元素,保证同一排列内元素不重复;
  2. 终止条件:路径长度等于数组长度(收集完整排列);
  3. 核心逻辑:
    • 遍历:0到nums.size()-1,跳过已标记元素;
    • 回溯:标记/取消标记used,添加/删除路径元素。

本题是排列问题的基础模板,核心是理解「有序遍历+used数组标记」的逻辑,区别于组合/子集的无序遍历。


47. 全排列 II(精简版)

核心思路

在含重复元素的数组中求不重复全排列,核心是「全排列基础逻辑+树层去重」:

  1. 先排序:让重复元素相邻,为去重做准备;
  2. used数组双重作用:
    • 标记同一排列内已选元素(避免重复使用);
    • 区分「树层重复」和「树枝重复」,优先树层去重(效率更高);
  3. 终止条件:路径长度等于数组长度(收集完整排列)。

一、最优解法(树层去重,效率更高)

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 (i > 0 && nums[i] == nums[i-1] && used[i-1] == false) {
                continue;
            }
            if (!used[i]) { // 跳过同一排列内已选元素
                used[i] = true;
                path.push_back(nums[i]);
                backtracking(nums, used);
                // 回溯
                path.pop_back();
                used[i] = false;
            }
        }
    }

    vector<vector<int>> permuteUnique(vector<int>& nums) {
        sort(nums.begin(), nums.end()); // 排序为去重做准备
        vector<bool> used(nums.size(), false);
        backtracking(nums, used);
        return result;
    }
};

二、关键拓展(两种去重方式对比)

去重写法逻辑含义效率
used[i-1] == false树层去重(优先选)高(少无效搜索)
used[i-1] == true树枝去重低(多无效搜索)

核心原则:两种写法都能去重,但树层去重更高效;必须加used[i-1]判断,不能仅用nums[i]==nums[i-1](否则无法区分层/枝)。

三、核心总结

  1. 与46.全排列的区别:
    • 数组含重复元素,需排序+树层去重;
    • used数组新增「去重」功能(原仅标记已选元素);
  2. 核心逻辑:
    • 排序:让重复元素相邻,是去重前提;
    • 树层去重:跳过同一层重复元素,避免生成重复排列;
    • 排列遍历:每层从0开始,用used数组避免同一排列内重复选元素。

本题是全排列+去重的综合应用,核心是理解「used数组区分树层/树枝重复」的逻辑,优先选择树层去重提升效率。


后面两道二维的等我考上研再来。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值