代码随想录算法训练营 Day19 | 回溯算法 part01

77. 组合

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

你可以按 任何顺序 返回答案。

cpp 复制代码
class Solution {
public:
    vector<int> path;           // 单层路径,存储当前组合
    vector<vector<int>> ans;    // 结果集,存储所有符合条件的组合
    // 回溯函数
    // n: 总数
    // k: 需要选取的个数
    // startIndex: 当前搜索的起始位置(防止重复选取,保证组合有序)
    void backtracking(int n, int k, int startIndex) {
        // 1. 终止条件:当前组合长度等于 k,收集结果
        if (path.size() == k) {
            ans.push_back(path);
            return;
        }
        // 2. 单层搜索逻辑
        // 剪枝优化:i <= n - (k - path.size()) + 1
        // 解释:还需要选取 (k - path.size()) 个元素
        // 如果 i 超过了这个范围,剩下的元素就不够凑齐 k 个了
        for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
            path.push_back(i);             // 处理节点
            backtracking(n, k, i + 1);     // 递归:下一层从 i+1 开始
            path.pop_back();               // 回溯:撤销处理结果
        }
    }
    vector<vector<int>> combine(int n, int k) {
        backtracking(n, k, 1);
        return ans;
    }
};

总结

1. 回溯三部曲
  1. 确定返回值和参数:返回值通常为 void,参数根据题目需求定(这里是范围 n、目标数量 k、起始位置 startIndex)。
  2. 确定终止条件:当路径长度满足要求(path.size() == k)时,保存结果并返回。
  3. 确定单层搜索逻辑:
    • for 循环横向遍历(从 startIndexn)。
    • backtracking 递归纵向遍历。
    • 核心: path.push_backpath.pop_back 构成了回溯的"做选择"与"撤销选择"。
2. 剪枝优化

代码中 i <= n - (k - path.size()) + 1 是一个极其重要的优化。

  • 场景:假设 n=4, k=3,当前 path 为空。
  • 无剪枝:i 会遍历 1 到 4。当 i=4 时,path={4},递归下一层发现没有元素可选了,最终只能得到一个长度为 1 的无效路径。
  • 有剪枝:
    • 还需要选 k - path.size() = 3 个数。
    • 为了至少能有 3 个数,起始位置 i 最大只能是 4 - 3 + 1 = 2
    • 所以 i 只需遍历 1 和 2,直接跳过了 3 和 4,大幅减少了无效递归。
3. 复杂度分析
  • 时间复杂度:O(C(n, k) * k)。
    • 共有 C(n, k) 个组合,每个组合需要 O(k) 的时间存入结果集。
  • 空间复杂度:O(k)。
    • 递归深度为 k,path 最大长度也为 k(不考虑结果集占用的空间)。

216. 组合总和 III

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

  • 只使用数字1到9
  • 每个数字 最多使用一次

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

cpp 复制代码
class Solution {
public:
    vector<int> path;           // 单层路径
    vector<vector<int>> ans;    // 结果集
    // 回溯函数
    // k: 目标个数
    // n: 目标和
    // startIndex: 起始位置
    // sum: 当前路径累加和
    void backtracking(int k, int n, int startIndex, int sum) {
        // 1. 终止条件:已选够 k 个数
        if (path.size() == k) {
            if (sum == n) ans.push_back(path); // 满足和的条件才收集
            return;
        }
        // 2. 单层搜索逻辑
        // 剪枝优化1:i <= 9 - (k - path.size()) + 1
        // 保证剩余数字足够凑齐 k 个,避免无效递归
        for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
            // 剪枝优化2:如果当前和已超标,直接返回
            if (sum + i > n) return;
            path.push_back(i);                  // 处理节点
            backtracking(k, n, i + 1, sum + i); // 递归
            path.pop_back();                    // 回溯
        }
    }
    vector<vector<int>> combinationSum3(int k, int n) {
        backtracking(k, n, 1, 0);
        return ans;
    }
};

总结

1. 本题与上一题的区别

这道题是 216. 组合总和 III,可以看作是 77. 组合 的变体:

  • 组合问题:依然是从集合中选 k 个数(这里是 1-9)。
  • 增加约束:不仅要选够 k 个,还要满足 和 == n
  • 参数变化:回溯函数多了一个 sum 参数,用于实时记录当前路径的和,避免了每次遍历数组求和的开销。
2. 剪枝优化(双重剪枝)
  1. 总和剪枝 (sum + i > n):

    • 因为数字是正数且递增,如果加上当前的 i 已经爆了,后面的 i+1, i+2...更大,肯定也会爆,所以直接 return 结束当前层循环。
  2. 数量剪枝 :

    • 如果剩下的数字不够凑齐 k 个,就没必要遍历了,可以优化为 i <= 9 - (k - path.size()) + 1
3. 为什么要传递 sum

在递归参数中直接维护 sum 是回溯常用的技巧:

  • 对比:如果在终止条件里写 if (path.size() == k && accumulate(path.begin(), path.end(), 0) == n),每次都要遍历 path 计算总和,时间复杂度较高。
  • 优化:通过传参 sum + i,我们在 O(1) 时间内完成了和的更新与判断。
4. 复杂度分析
  • 时间复杂度:O(C(9, k) * k)。
    • 最多有 C(9, k) 种组合,每个组合需要 O(k) 时间复制到结果。
  • 空间复杂度:O(k)。
    • 递归深度和 path 长度均为 k。

17. 电话号码的字母组合

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

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

cpp 复制代码
class Solution {
public:
    // 数字到字母的映射表,索引 0 和 1 为空
    vector<string> mp {"", "", "abc", "def", "ghi", "jkl",
                       "mno", "pqrs", "tuv", "wxyz"};
    string path;        // 当前拼接的字符串路径
    vector<string> ans; // 结果集
    // 回溯函数
    // digits: 输入的数字字符串
    // index: 当前处理到 digits 的第几个数字(树的深度)
    void backtracking(string digits, int index) {
        // 1. 终止条件:路径长度等于数字串长度,说明处理完了所有数字
        if (path.size() == digits.size()) {
            ans.push_back(path);
            return;
        }
        // 边界检查:防止越界(虽然上面的判断已经隐含了这个逻辑,作为一个好习惯保留)
        if (index >= digits.size()) return;
        // 2. 单层搜索逻辑
        // 获取当前数字对应的字母字符串
        string s = mp[digits[index] - '0'];
        // 遍历当前数字对应的所有字母
        for (char c : s) {
            path.push_back(c);              // 处理节点
            backtracking(digits, index + 1);// 递归:处理下一个数字
            path.pop_back();                // 回溯:撤销处理结果
        }
    }
    vector<string> letterCombinations(string digits) {
        // 特殊情况处理:如果输入为空,直接返回空结果
        if (digits.empty()) return ans;
        backtracking(digits, 0);
        return ans;
    }
};

总结

1. 树形结构的理解
  • 树的宽度:由当前数字对应的字母个数决定(例如 "2" 对应 "abc",宽度为 3)。
  • 树的深度:由输入数字字符串 digits 的长度决定。
  • 区别:之前的"组合问题"是通过 startIndex 控制不再选取之前的元素(纵向遍历),而本题是两个不同集合之间的组合(横向遍历),每一层代表一个数字位,所以用 index 来控制层级即可。
2. 细节处理
  • 字符转数字:digits[index] - '0' 是经典写法,将字符型数字转为整型索引。
3. 复杂度分析
  • 时间复杂度:O(3^m * 4^n)。
    • 其中 m 是对应 3 个字母的数字个数,n 是对应 4 个字母的数字个数(7 和 9)。
    • 因为每个数字对应的字母个数不同,这是一个指数级的复杂度。
  • 空间复杂度:O(m + n)。
    • 主要是递归调用的栈空间消耗。
相关推荐
汉克老师1 小时前
GESP5级C++考试语法知识(十、二分算法(二))
c++·算法·二分算法·gesp5级·gesp五级·找答案
cheems95272 小时前
[数据结构]栈和队列的互相模拟实现
数据结构·算法
计算机安禾2 小时前
【数据结构与算法】第6篇:线性表(二):单链表的实现(头插法、尾插法)
c语言·数据结构·学习·算法·链表·visual studio code·visual studio
2401_873204652 小时前
C++与Node.js集成
开发语言·c++·算法
☆5662 小时前
基于C++的区块链实现
开发语言·c++·算法
ysa0510302 小时前
迷宫传送[最短路径]
c++·笔记·算法·深度优先
左左右右左右摇晃2 小时前
数据结构——链表
数据结构·链表
计算机安禾2 小时前
【数据结构与算法】第5篇:线性表(一):顺序表(ArrayList)的实现与应用
c语言·开发语言·数据结构·c++·算法·visual studio code·visual studio
仰泳的熊猫2 小时前
题目2584:蓝桥杯2020年第十一届省赛真题-数字三角形
数据结构·c++·算法·蓝桥杯