从零开始写算法——回溯篇2:电话号码的字母组合 + 组合总和

在之前的文章中,我们讨论了"全排列"和"子集"的区别。今天我们把难度稍微提升一点,看看在更复杂的场景下,如何灵活运用 "答案视角""输入视角" 来解题。

我们将通过两道 LeetCode 经典题目:电话号码的字母组合 (LeetCode 17)和 组合总和(LeetCode 39),来深刻体会这两种思维模式的差异。


一、 答案视角的经典:电话号码的字母组合

题目:给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

1. 视角选择:为什么是"答案视角"?

这道题是非常典型的"填坑"问题。

  • 假设输入是 "23",那么答案的长度一定是 2。

  • 我们手里有两个坑位:[坑1, 坑2]

  • 坑1 必须从数字 '2' 对应的 "abc" 里选一个。

  • 坑2 必须从数字 '3' 对应的 "def" 里选一个。

因为每个坑位的候选集是固定且相互独立 的(第1个坑选什么不影响第2个坑),所以我们采用 答案视角 ,即递归函数中的索引 i 代表**"当前正在填第几个坑"**。

2. 代码实现

这里通过直接覆盖 vals[i] 的方式,比传统的 push_back / pop_back 更加简洁,因为长度是固定的。(只要长度是固定的,就可以考虑用覆盖去替代恢复现场)。

C++代码实现:

cpp 复制代码
class Solution {
    // 答案视角
    // 思路: 先把所有的这个电话数字对应的字母映射到好, 遍历每一个组(也就是abc)拿到了a就去堆第二个位置的def去遍历,因此可以用dfs来结果
    string MAPPING[10] = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};
    vector<string> ans;
    void dfs(string digits, string vals, int i, int n) {
        // i 代表当前填到了第几个位置
        if (i == n) {
            ans.push_back(vals);
            return;
        }
        // 这里的循环是在遍历当前这个坑位 i 有哪些字母可以选择
        for (char c : MAPPING[digits[i] - '0']) {
            vals[i] = c; // 直接覆盖,相当于填空
            dfs(digits, vals, i + 1, n); // 填下一个空
            // 注意:因为是直接覆盖 vals[i],下一次循环会再次覆盖,
            // 所以这里不需要显式的"撤销"操作 (pop_back),这是固定长度填空的技巧
        }

    }
public:
    vector<string> letterCombinations(string digits) {
        int n = digits.size();
        if (n == 0) return {};
        // 预先分配好 vals 的长度,全部初始化为 0
        string vals(n, 0);
        dfs(digits, vals, 0, n);
        return ans;
    }
};

3. 时空复杂度分析

  • 时间复杂度:O(4^N * N)

    • 其中 N 是输入数字字符串的长度。

    • 在最坏情况下(比如输入全都是 7 或 9,对应 4 个字母),每个坑位有 4 种选择,总组合数为 4^N。

    • 递归树的叶子节点个数为 4^N。

    • 在每个叶子节点,我们将 vals 复制到 ans 中,字符串复制耗时 O(N)。

    • 总耗时:节点数 * 单节点操作 = O(4^N * N)。

  • 空间复杂度:O(N)

    • 主要消耗在递归调用栈上,深度为 N。

    • vals 字符串的空间也是 O(N)。

    • (输出答案的 O(4^N * N) 空间通常不计入算法辅助空间)。


二、 输入视角的变体:组合总和(可重复选)

题目 :给你一个无重复元素的整数数组 candidates 和一个目标整数 target,找出 candidates 中可以使数字和为目标数 target 的所有不同组合。数字可以无限制重复被选取。

1. 视角选择:为什么是"输入视角"?

这道题的难点在于**"无限制重复选取"**。

如果用答案视角(填坑),我们不知道坑有多少个(比如 target=10, candidates=[2],答案是 [2,2,2,2,2],有5个坑),这就很难定义循环的边界。

因此,我们切换到 输入视角(选或不选),逻辑会变得异常清晰:

对于当前数字 candidates[i],我有两种选择:

  1. 选它 :而且选了之后,下一次还可以继续选它(不换人)。

  2. 不选它 :彻底放弃这个数字,以后也不能再回头选它(换下一个人)。

2. 代码实现

C++代码实现:

cpp 复制代码
class Solution {

    // 思路: 看到目标taget,肯定是要让他-去值后得到0就是答案,因此用输入视角更合适,采用选或不选的策略
    vector<vector<int>> ans;
    void dfs(vector<int>& candidates, vector<int>& vals, int target, int i, int n) {
        // 成功出口:正好减到 0
        if (target == 0) {
            ans.push_back(vals);
            return;
        }
        // 失败出口:考察完所有数字,或者 target 减超了
        if (i == n || target < 0) {
            return;
        }
        
        // --- 核心逻辑:输入视角 ---

        // 选择 1:选当前数字 candidates[i]
        vals.push_back(candidates[i]);
        // 关键点:参数依然是 i,而不是 i+1
        // 这表示:虽然我选了你,但我还没考察完你,下一层递归还可以接着选你(实现无限重复)
        dfs(candidates, vals, target - candidates[i], i, n);
        vals.pop_back(); // 回溯

        // 选择 2:不选当前数字(或者选够了,不想再选了)
        // 参数变成 i + 1,表示彻底跳过 candidates[i],以后再也不看它了
        dfs(candidates, vals, target, i + 1, n);

    }

public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        vector<int> vals;
        int n = candidates.size();
        if (n == 0) return {};
        dfs(candidates, vals, target, 0, n);
        return ans;
    }
};

3. 时空复杂度分析

这里的复杂度分析比普通组合要复杂,因为树的深度不固定。

  • 时间复杂度:O(S) (粗略估计)(可行解的每一个长度,代表了它找路一次,所以是长度和),举例如下:

    cpp 复制代码
    假如 target = 5,candidates = [1, 2, 5]。找到的答案 ans 如下:
    [1, 1, 1, 1, 1] (长度 5)
    [1, 1, 1, 2]    (长度 4)
    [1, 2, 2]       (长度 3)
    [5]             (长度 1)
    
    那么 S = 5 + 4 + 3 + 1 = 13。
    • 严格来说是指数级的。最坏情况取决于 target 和数组中最小元素的值。

    • 递归树的最大深度(Height)大约是 target / min(candidates)

    • 因为每个节点分两个叉(选或不选),所以节点总数大概是 2 的 Height 次方级别。

    • 准确来说,时间复杂度通常表示为 O(S),其中 S 是所有可行解的总长度。因为我们需要把每个解都构造出来。

  • 空间复杂度:O(target / min(candidates))

    • 空间主要取决于递归栈的深度。

    • 在最坏情况下(比如 target=100,最小元素是 1),我们可能需要递归 100 层(一直选 1)。

    • 所以空间复杂度是 O(target / min_val)。


三、 总结与对比

通过这两道题,我们可以总结出回溯算法对索引 i 的不同处理方式:

题目类型 视角 i 的含义 下一层递归传参
电话号码组合 答案视角 当前正在填第几个坑 i + 1 (去填下一个坑)
组合总和 输入视角 当前正在考察第几个候选人 选:传 i (还能接着选) 不选:传 i + 1 (跳过看下一个)
  • 什么时候用"选"与"不选"?

    当题目允许重复选择同一个元素,或者无法确定答案的长度时,使用输入视角(选或不选)配合参数控制(传 i 还是 i+1)通常是逻辑最清晰的解法。

  • 什么时候用"For 循环填坑"?

    当题目明确了答案的结构固定(比如每个位置必须对应一个映射,或者全排列)时,使用答案视角配合 For 循环效率更高。

相关推荐
持梦远方2 小时前
算法剖析1:摩尔投票算法 ——寻找出现次数超过一半的数
c++·算法·摩尔投票算法
程序员-King.2 小时前
链表——算法总结与新手教学指南
数据结构·算法·链表
Ulyanov3 小时前
战场地形生成与多源数据集成
开发语言·python·算法·tkinter·pyside·pyvista·gui开发
FMRbpm3 小时前
树的练习6--------938.二叉搜索树的范围和
数据结构·c++·算法·leetcode·职场和发展·新手入门
wubba lubba dub dub7503 小时前
第三十三周 学习周报
学习·算法·机器学习
C+-C资深大佬3 小时前
C++数据类型
开发语言·c++·算法
多米Domi0114 小时前
0x3f 第35天 电脑硬盘坏了 +二叉树直径,将有序数组转换为二叉搜索树
java·数据结构·python·算法·leetcode·链表
想逃离铁厂的老铁4 小时前
Day45 >> 115、不同的子序列 + 583. 两个字符串的删除操作 + 72. 编辑距离
算法·leetcode
cyyt4 小时前
深度学习周报(1.12~1.18)
人工智能·算法·机器学习