Leetcode回溯算法part1

什么是回溯算法(本质一句话版)

回溯 = 递归 + 试探 + 撤销选择

回溯算法本质是一种 深度优先搜索(DFS) 的穷举策略:

  • 先做选择

  • 继续向下搜索

  • 发现不合适就退回(回溯)

  • 换一个选择再试

👉 只要你写了递归,只要你在递归返回前"撤销状态",那你就在用回溯。


为什么回溯一定和递归绑定?

你文中这句话非常关键:

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

原因很简单:

  • 递归 = 函数调用栈

  • 回溯 = 利用调用栈 自动帮你回到上一步状态

没有递归,你就得自己维护栈和状态,非常痛苦。


回溯算法的效率认知(非常重要)

回溯 ≠ 高效算法

  • 本质是:穷举所有可能

  • 时间复杂度通常是:

    • 组合 / 子集:O(2^n)

    • 排列:O(n!)

    • 棋盘类:指数级甚至更高

👉 回溯题的核心目标不是"快",而是:

在可接受范围内,把所有合法解都找出来

剪枝只是 让死路少走一点 ,但永远改变不了穷举本质


哪些问题一看就是回溯?

可以直接记这个"回溯问题清单"👇

1️⃣ 组合问题(不强调顺序)

  • 从 N 个数中选 k 个

  • 例如:[1,2,3] 选 2 个

  • {1,2}{2,1}同一个解

2️⃣ 切割问题

  • 字符串按规则切割

  • 如:分割回文串、IP 地址复原

3️⃣ 子集问题

  • 一个集合的所有可能子集

  • 是否满足某些条件

4️⃣ 排列问题(强调顺序)

  • {1,2}{2,1}两个解

  • 通常需要 used[] 数组

5️⃣ 棋盘问题

  • N 皇后

  • 数独

  • 本质:在二维/多维空间里试探

👉 一句话判断法

"要列出所有可能解 / 所有方案" → 99% 是回溯


回溯算法 = 一棵树(这是理解的关键)

所有回溯问题都可以抽象成树形结构

树结构对应关系

回溯概念 树的含义
集合大小 树的宽度
递归深度 树的深度
一次选择 一个节点
一个解 一条从根到叶子的路径

所以:

  • 回溯 = 遍历一棵 N 叉树

  • 找到叶子节点 = 找到一个合法解


回溯三部曲(比模板更重要)

你可以忘记模板,但不能忘记这三件事

① 回溯函数参数(状态)

回溯函数的参数,本质是:

描述"当前走到哪一步了"

常见参数包括:

  • startIndex(组合 / 子集)

  • path(当前选择路径)

  • used[](排列)

  • 剩余值、目标值等


② 终止条件(叶子节点)

终止条件 = 什么时候算一个完整解

复制代码
if (满足条件) {
    保存结果;
    return;
}
  • 深度够了

  • 长度满足

  • 目标值达成

  • 棋盘填满


③ 单层搜索逻辑(for + 递归 + 回溯)

这是回溯的灵魂

复制代码
for (本层可选元素) {
    做选择;
    backtracking(...);
    撤销选择;
}

理解为一句话:

横向遍历选择,纵向递归深入


通用回溯模板(你需要"肌肉记忆")

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

    for (选择:本层集合中的元素) {
        处理节点;        // 做选择
        backtracking(...); // 递归
        回溯;            // 撤销选择
    }
}

⚠️ 注意:

  • for 循环 ≠ 递归

  • for 是"选谁"

  • 递归是"走多深"

77. 组合

整体思路讲解

这道题是:

从 1 到 n 中,选出 k 个数的所有组合

1️⃣ 为什么这是回溯问题?

因为题目要求的是:

  • 列出所有可能的组合

  • 而不是求最大值 / 最小值 / 个数

👉 "所有可能方案" = 典型回溯


2️⃣ 把问题抽象成一棵树

我们把 1 ~ n 看成一个集合,每次从中选一个数:

  • 树的深度 :已经选了多少个数(path.size()

  • 树的宽度:当前还能选哪些数

举个例子:n = 4, k = 2

复制代码
            []
       /     |     |     \
     [1]    [2]   [3]   [4]
    / | \     | \     \
 [1,2][1,3][1,4][2,3][2,4][3,4]
  • 每一条 从根到叶子的路径

  • 且路径长度为 k

  • 就是一个合法答案


3️⃣ 回溯三部曲(对应本题)

✅ ① 回溯函数的参数(状态)

我们需要知道三件事:

  • n:上限是多少

  • k:要选几个

  • startIndex下一次从哪里开始选(避免重复)


✅ ② 终止条件(什么时候收集答案)
复制代码
path.size() == k

说明:

  • 已经选够 k 个数

  • 当前路径就是一个合法组合

  • 存起来即可,不用再往下选


✅ ③ 单层搜索逻辑(for + 递归 + 回溯)

在当前这一层:

  • startIndex 开始遍历

  • 每选一个数:

    • 加入 path

    • 递归继续选下一个

    • 回溯时撤销选择

cpp 复制代码
class Solution {
public:
    vector<vector<int>>result;
    vector<int>path;
    void backtracking(int n , int k,int startIndex){
        if(path.size()==k){
            result.push_back(path);
        }

        for(int i = startIndex;i<=n;i++){
            path.push_back(i);
            backtracking(n,k,i+1);
            path.pop_back();
        }

    }


    vector<vector<int>> combine(int n, int k) {
        result.clear(); // 可以不写
        path.clear();   // 可以不写
        backtracking(n, k, 1);
        return result;
    }
};

优化后整体代码如下:

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

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

  3. 列表中剩余元素(n-i) >= 所需需要的元素个数(k - path.size())

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

为什么有个+1呢,因为包括起始位置,我们要是一个左闭的集合。

举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。

从2开始搜索都是合理的,可以是组合[2, 3, 4]。

这里大家想不懂的话,建议也举一个例子,就知道是不是要+1了。

cpp 复制代码
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 - (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;
    }
};

216. 组合总和 III

整体思路(先不看代码)

题目要求的是:

从 1~9 中选出 k 个数,使它们的和等于 n,每个数字只能用一次

和你之前写的 combine 相比,多了 两个强约束

  1. 数字范围固定:1 ~ 9

  2. 路径不仅要长度为 k而且和必须等于 n

👉 本质仍然是:
在有限集合中,找出满足条件的所有组合

标准回溯问题

把问题抽象成"树"

1️⃣ 树的含义

  • 树的深度 :当前已经选了几个数(path.size()

  • 树的宽度 :当前还能选哪些数(startIndex ~ 9

  • 一条合法路径

    • 长度 = k

    • 路径和 = n


2️⃣ 搜索目标

我们要找的是:

cpp 复制代码
path.size() == k 且 sum == n

而不是只满足其中一个。


回溯三部曲(这题非常标准)

✅ ① 回溯函数参数(状态设计)

cpp 复制代码
void backtracking(int k, int n, int sum, int startIndex)

每个参数的意义非常明确:

参数 含义
k 需要选几个数
n 目标和
sum 当前路径的和
startIndex 下一次选择的起点(防止重复)

👉 sum 是这道题新增的关键状态

✅ ② 终止条件(什么时候停)

(1)和已经超了,直接剪掉

这是非常重要的剪枝

  • 后面只会选更大的数

  • 不可能再回到 n

  • 立刻返回,避免无效搜索


(2)已经选了 k 个数

逻辑含义:

  • 数量已经固定,不能再选了

  • 只有当 sum == n 才是合法解

  • 不管成不成立,都要 return,否则会越界选

⚠️ 这一步是**"数量约束优先于数值约束"**的体现


✅ ③ 单层搜索逻辑(for + 递归 + 回溯)

剪枝后的 for 循环

这是本题的灵魂剪枝,非常重要。


cpp 复制代码
class Solution {
public:
    vector<vector<int>>result;
    vector<int>path;
    void backtracking(int k,int n,int sum, int startIndex){
        if (sum > n) return;
        if (path.size() == k) {
            if (sum == n) {
                result.push_back(path);
            }
            return;
        }

        // 剪枝:i 最大只能到 9 - (k - path.size()) + 1
        for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
            sum += i;
            path.push_back(i);
            backtracking(k, n, sum, i + 1); // 注意这里是 i + 1
            path.pop_back();
            sum -= i;
        }
    }

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

17. 电话号码的字母组合

整体思路(先不看代码)

题目要求的是:

给定一个数字字符串(2--9),返回它能表示的所有字母组合

例如:"23"

cpp 复制代码
2 -> abc
3 -> def

结果:
ad ae af
bd be bf
cd ce cf

为什么这是回溯问题?

因为题目要求的是:

  • 列出所有可能的组合

  • 每一位数字,都有多个可选字母

  • 每一位的选择都会影响后面的选择

👉 这正是:

在多个"选择集合"中,每层选一个,枚举所有路径

这类问题的特征非常明显:

  • 层次感

  • 每一层都要做选择

  • 要遍历所有可能路径


把问题抽象成一棵树(关键理解)

假设 digits = "23"

cpp 复制代码
               ""
        /        |        \
      a          b          c     ← 第 0 层(数字 2)
    / | \      / | \      / | \
   d e f      d e f      d e f     ← 第 1 层(数字 3)

对应关系非常清晰:

回溯概念 本题含义
树的深度 digits 的长度
每一层 处理一个数字
每个节点 选择一个字母
一条路径 一个字母组合

回溯三部曲(对应本题)

✅ ① 回溯函数参数(状态)

cpp 复制代码
void backtracking(const string& digits, int index)

为什么只需要 index

  • index 表示 当前处理 digits 的第几位

  • 当前路径已经通过 s 保存了

  • 不需要 startIndex,因为:

    • 每一层只能选"当前数字"的字母

    • 不存在"回头选"的问题

👉 这是和组合题最大的区别


✅ ② 终止条件(叶子节点)

cpp 复制代码
if (index == digits.size()) {
    result.push_back(s);
    return;
}

含义:

  • 所有数字都已经处理完

  • 当前 s 的长度一定等于 digits.size()

  • s 是一条完整路径(一个解)


✅ ③ 单层搜索逻辑

对于当前数字 digits[index]

  • 找到它对应的字母集合

  • 遍历每一个字母

  • 依次加入路径,递归处理下一个数字

cpp 复制代码
class Solution {
public:
    const string letterMap[10] = {
        "", // 0
        "", // 1
        "abc", // 2
        "def", // 3
        "ghi", // 4
        "jkl", // 5
        "mno", // 6
        "pqrs", // 7
        "tuv", // 8
        "wxyz", // 9
    };

    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;
    }
};

39. 组合总和

整体思路(不看代码也能懂)

题目要求的是:

给定一个无重复元素的数组 candidates,每个元素可以无限次使用 ,找出所有和为 target 的组合。

关键点有三个:

  1. 要求所有组合 → 回溯

  2. 是组合,不是排列 → 不能出现 [2,3][3,2]

  3. 元素可以重复使用 → 递归时 startIndex 不前移


把问题抽象成一棵树

candidates = [2,3,6,7],target = 7 为例:

cpp 复制代码
                 []
        /        |        |        \
      [2]       [3]      [6]       [7]
     / | \        |         |
 [2,2][2,3][2,6] [3,3]    [6,?]
    |
 [2,2,3]
    |
 [2,2,3] → sum = 7 ✓

树结构理解

回溯概念 本题含义
树的深度 当前组合长度(不固定)
树的宽度 candidates 中可选的数
一条路径 一个候选组合
叶子节点 sum == target

回溯三部曲(对应本题)

✅ ① 回溯函数参数(状态)

cpp 复制代码
void backtracking(vector<int>& candidates,
                  int target,
                  int sum,
                  int startIndex)

每个参数都很关键:

参数 含义
candidates 候选数组
target 目标和
sum 当前路径和
startIndex 本轮搜索起点(控制组合顺序)

👉 startIndex 决定"组合"而不是"排列"


✅ ② 终止条件(什么时候收集结果)

cpp 复制代码
if (sum == target) {
    result.push_back(path);
}

含义:

  • 当前路径的和刚好等于 target

  • 这是一个合法组合

  • 直接保存

⚠️ 注意:

这里没有写 return,是因为:

  • 后面的 for 循环已经通过条件
    sum + candidates[i] <= target

    保证不会继续向下扩展

  • 即使不 return,也不会产生非法解

(不过在逻辑表达上,加 return 会更清晰)


✅ ③ 单层搜索逻辑(for + 递归 + 回溯)

cpp 复制代码
for (int i = startIndex;
     i < candidates.size() && sum + candidates[i] <= target;
     i++)

这是排序 + 剪枝的核心体现:

  • candidates 已经排序

  • 一旦 sum + candidates[i] > target

  • 后面的数只会更大,直接剪掉


为什么这里递归用的是 i 而不是 i+1?(重点)

backtracking(candidates, target, sum, i);

这是这道题和你前面做的题最本质的区别

原因只有一句话:

同一个数字可以被重复选取

  • 如果写 i + 1 → 每个数只能用一次(会变成 combinationSum2)

  • i → 下一层仍然可以选当前这个数

👉 这一步直接决定了"可重复使用"

cpp 复制代码
class Solution {
public:
    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++){
                sum+=candidates[i];
                path.push_back(candidates[i]);
                backtracking(candidates,target,sum,i);
                sum-=candidates[i];
                path.pop_back();
         }
    }

    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        result.clear();
        path.clear();
        sort(candidates.begin(), candidates.end());
        backtracking(candidates,target,0,0);
        return result;
    }
};

40. 组合总和 II

组合总和II和一开始可以重复的区别就在于不可重复,所以只需要改一下递归条件,把递归传递的I改为i+1即可

cpp 复制代码
class Solution {
public:
    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);
        }
        for(int i = startIndex;i<candidates.size() && sum + candidates[i] <= target;i++){

             if (i > startIndex && candidates[i] == candidates[i - 1]) {
                continue;
            }
            sum += candidates[i];
            path.push_back(candidates[i]);
            backtracking(candidates,target,sum,i+1);
            path.pop_back();
            sum-=candidates[i];
        }
    }

    vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
        result.clear();
        path.clear();
        sort(candidates.begin(),candidates.end());
        backtracking(candidates,target,0,0);
        return result;
    }
};

131. 分割回文串

整体思路(先不看代码)

题目要求的是:

给定一个字符串 s,把它切割成若干子串,使得每一个子串都是回文串,返回所有可能的切割方案。

关键词只有两个:

  • 切割

  • 所有方案

👉 一看到"切割 + 所有方案",就要条件反射想到:

回溯 + 树形结构


把问题抽象成一棵树(这是理解的关键)

s = "aab" 为例:

cpp 复制代码
start=0
|
├── "a"    → start=1
|    ├── "a"  → start=2
|    |    └── "b"  → start=3 ✓
|    └── "ab" ✗
|
└── "aa"   → start=2
     └── "b"  → start=3 ✓

树结构映射关系

回溯概念 本题含义
树的深度 已切割的段数
每一层 从某个 startIndex 开始尝试切
每个节点 一个子串
一条路径 一种切割方案

👉 走到字符串末尾(startIndex == s.size)

👉 说明刚好完成一次完整切割


这道题的核心难点在哪里?

不是回溯本身,而是这一句判断:

当前子串是不是回文?

如果你在回溯中每次都临时判断回文

  • 时间复杂度会非常高

  • 整体会退化成三重循环

所以你的做法是非常标准、非常优秀的工程解法

先用 DP 预处理所有回文信息,再在回溯中 O(1) 查询


整体解法拆分(两步走)

✅ 第一步:预处理回文子串(DP)

构建一个二维数组:

cpp 复制代码
isPalindrome[i][j]

表示:

s[i..j](双闭区间)是不是回文串


✅ 第二步:回溯切割字符串

  • startIndex 开始

  • 枚举所有可能的切割终点 i

  • 只要 s[startIndex..i] 是回文,就可以切

  • 然后递归处理后半段


五、代码逐段讲解


① 回文 DP 预处理部分

cpp 复制代码
vector<vector<bool>> isPalindrome;

这是整道题性能的核心


初始化并填表

cpp 复制代码
isPalindrome.resize(s.size(), vector<bool>(s.size(), false));

根据字符串长度,建立 n × n 的 DP 表。


填表顺序(非常关键)

cpp 复制代码
for (int i = s.size() - 1; i >= 0; i--) {
    for (int j = i; j < s.size(); j++) {

为什么 i从后往前

因为状态转移用到了:

isPalindrome[i+1][j-1]

👉 必须先算"更短的子串"


状态转移逻辑

cpp 复制代码
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]);

对应三种情况:

1️⃣ 单个字符 → 一定是回文

2️⃣ 两个字符 → 相等就是回文

3️⃣ 多个字符 → 首尾相等 + 中间是回文


② 回溯函数设计

cpp 复制代码
void backtracking(string& s, int startIndex)

参数含义

  • startIndex下一刀从哪里开始切

👉 这是切割类回溯的"灵魂参数"


终止条件(完成一次切割)

cpp 复制代码
if (startIndex >= s.size()) {
    result.push_back(path);
    return;
}

含义:

  • 字符串已经被完整切完

  • 当前 path 就是一种合法方案


单层搜索逻辑(枚举切割位置)

复制代码
cpp 复制代码
for (int i = startIndex; i < s.size(); i++) {

表示:

  • 尝试把第一刀切在:

    • [startIndex..startIndex]

    • [startIndex..startIndex+1]

    • ...

    • [startIndex..s.size()-1]


回文判断(剪枝)

cpp 复制代码
if (isPalindrome[startIndex][i]) {
    string str = s.substr(startIndex, i - startIndex + 1);
    path.push_back(str);
} else {
    continue;
}

👉 不是回文,直接剪掉这一整条分支

这是回溯效率的关键。


递归 + 回溯

backtracking(s, i + 1); path.pop_back();

  • 递归处理剩余字符串

  • 回溯撤销本次切割


③ 主函数入口

computePalindrome(s); backtracking(s, 0);

逻辑非常清晰:

  1. 先预处理回文

  2. 再启动回溯切割

cpp 复制代码
class Solution {
public:
    vector<vector<string>>result;
    vector<string>path;
    vector<vector<bool>>isPalindrome;
     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--){
            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(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 str = s.substr(startIndex,i-startIndex+1);
                path.push_back(str);
            }else{
                continue;
            }
            backtracking(s,i+1);
            path.pop_back();
        }
    }

    vector<vector<string>> partition(string s) {
        result.clear();
        path.clear();
        computePalindrome(s);
        backtracking(s, 0);
        return result;
    }
};
相关推荐
寻寻觅觅☆9 小时前
东华OJ-基础题-106-大整数相加(C++)
开发语言·c++·算法
偷吃的耗子9 小时前
【CNN算法理解】:三、AlexNet 训练模块(附代码)
深度学习·算法·cnn
化学在逃硬闯CS10 小时前
Leetcode1382. 将二叉搜索树变平衡
数据结构·算法
ceclar12310 小时前
C++使用format
开发语言·c++·算法
Gofarlic_OMS11 小时前
科学计算领域MATLAB许可证管理工具对比推荐
运维·开发语言·算法·matlab·自动化
夏鹏今天学习了吗11 小时前
【LeetCode热题100(100/100)】数据流的中位数
算法·leetcode·职场和发展
忙什么果12 小时前
上位机、下位机、FPGA、算法放在哪层合适?
算法·fpga开发
董董灿是个攻城狮12 小时前
AI 视觉连载4:YUV 的图像表示
算法
ArturiaZ13 小时前
【day24】
c++·算法·图论
大江东去浪淘尽千古风流人物13 小时前
【SLAM】Hydra-Foundations 层次化空间感知:机器人如何像人类一样理解3D环境
深度学习·算法·3d·机器人·概率论·slam