从零开始写算法——回溯篇1:全排列 + 子集

在回溯算法(Backtracking)的学习中,我们经常会听到两个概念:"输入视角"和"答案视角"

很多时候代码写不出来,不是因为逻辑没想通,而是因为这两种视角在脑海里打架。今天我们通过两道最经典的题目------全排列(LeetCode 46)和子集(LeetCode 78),来彻底搞懂这两种思维模式的区别,并分析它们的时空复杂度。

一、 核心概念:两种视角的对决

在开始写代码之前,我们需要先建立世界观:

  1. 答案视角(Answer Perspective / 填坑位)

    • 核心思想 :我手里有 N 个空的坑位(或者是答案需要的长度),我要考虑的是 "当前这个坑,填哪个数字?"

    • 代码特征 :通常带有 for 循环,枚举所有可能的候选项。

    • 典型场景:全排列、N皇后。

  2. 输入视角(Input Perspective / 选或不选)

    • 核心思想 :我面前站着一排数字(输入数组),我要遍历这排数字,对于每一个数字,我只考虑 "要不要把它放进篮子里?"

    • 代码特征 :通常没有 for 循环(或者循环仅仅是用来推进索引),主要逻辑是"选"和"不选"的两次递归调用。

    • 典型场景:子集、0-1背包。


二、 全排列:答案视角的极致应用

题目 :给定一个不含重复数字的数组 nums,返回其所有可能的全排列。

1. 思路解析

全排列是一个典型的**"讲究顺序"**的问题。[1, 2][2, 1] 是两个不同的答案。

这里我们必须采用 答案视角(填坑位)

  • 假设我们需要填满长度为 N 的坑。

  • 站在第 i 个坑面前,我们可以从 nums 里随便选一个还没被用过的数字。

  • 为了知道谁没被用过,我们需要一个 exist_nums 标记数组。

  • 选定一个数后,去填第 i+1 个坑;回来的时候,要把这个数拿出来(回溯),标记为没用过,以便给别人用。

2. 代码实现

C++代码实现:

cpp 复制代码
class Solution {
    // 输入视角和答案视角的区别, 输入视角是看选还是不选, 答案视角是填坑位
    // 思路:站在答案视角去看问题(也就是填坑位) 全局ans记录答案,同时vals记录每一个组合, 针对每个位置i需要for遍历他的各个可能的数
    vector<vector<int>> ans;
    void dfs(vector<int>& nums, vector<int>& vals, vector<int>& exist_nums, int i, int n) {
        if (i == n) {
            ans.push_back(vals);
            return;
        };
        for (int j = 0; j < nums.size(); ++j) {
            if (!exist_nums[j]) {
                vals.push_back(nums[j]);
                exist_nums[j] = 1;
                dfs(nums, vals, exist_nums, i + 1, n);
                exist_nums[j] = 0;
                vals.pop_back();
            }

        }
        return;

        
    }
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<int> vals, exist_nums(nums.size(), 0);
        dfs(nums, vals, exist_nums, 0, nums.size());
        return ans;
    }
};

3. 时空复杂度分析

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

    • 全排列的总数是 N! (N的阶乘)。

    • 递归树的节点总数大约是 N! 级别。

    • 在叶子节点,我们需要将 vals 复制到 ans 中,这个操作需要 O(N)。

    • 所以总时间是 O(N * N!)。

  • 空间复杂度:O(N)

    • 递归调用栈的深度最大为 N。

    • exist_nums 标记数组的大小为 N。

    • vals 数组的大小为 N。

    • (注意:返回值的空间通常不计入算法的空间复杂度,如果计入则是 O(N * N!))。


三、 子集:视角的自由切换

题目 :给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集。

1. 思路解析

子集问题是一个**"不讲究顺序"**的问题。[1, 2][2, 1] 是同一个集合,不能重复出现。

针对这个问题,我们有两种经典的解法,刚好对应了两种视角:

  • 解法一:答案视角(dfs1)

    • 类似于全排列,我们用 for 循环来决定下一个放入集合的数字是谁。

    • 关键区别 :为了去重(避免 [2, 1]),我们规定**"只能往后选"**。即传入一个 i (startIndex),循环从 i 开始。

    • 收集答案的时机:进入节点就收集,因为子集长度不固定。

  • 解法二:输入视角(dfs2)

    • 这是子集问题特有的"二叉树"解法。

    • 我们不需要循环,只需要遍历输入数组 nums

    • 对于当前的 nums[i],只有两条路:

      1. 选它 :放入 vals,去下一层。

      2. 不选它:直接去下一层。

    • 这种写法逻辑非常清晰,完全模拟了"0-1 背包"的决策过程。

2. 代码实现

C++代码实现:

cpp 复制代码
class Solution {
    // 思路:但是没有顺序我么可以定一个顺序, 然后就是少了i == n的判断, 注意这里for结束之后其实有一个隐性的return, 这是递归的出口
    // 这个题目站在输入视角其实更好理解。
    vector<vector<int>> ans;
    // 答案视角
    void dfs1(vector<int>& nums, vector<int>& vals, int i,int n) {
        ans.push_back(vals);
        for (int j = i; j < n; ++j) {
            vals.push_back(nums[j]);
            dfs1(nums, vals, j + 1, n);
            vals.pop_back();
        }
    }
    // 输入视角
    void dfs2(vector<int>& nums, vector<int>& vals, int i, int n) {
        if (i == n) {
            ans.push_back(vals);
            return;
        }
        // 选
        vals.push_back(nums[i]);
        dfs2(nums, vals, i + 1, n);

        // 选结束回来后要恢复现场
        vals.pop_back();

        // 不选
        dfs2(nums, vals, i + 1, n);

    }
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        int n = nums.size();
        vector<int> vals;
        // 这里演示调用输入视角的方法,如果想用答案视角,调用 dfs1(nums, vals, 0, n) 即可
        dfs2(nums, vals, 0, n);
        return ans;
    }
};

3. 时空复杂度分析

无论是 dfs1 还是 dfs2,它们的本质都是遍历所有可能的子集,因此复杂度是一样的。

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

    • 一个长度为 N 的数组,子集个数共有 2^N 个。

    • 这意味着递归树大概有 2^N 个节点。

    • 在每个节点(或叶子节点),我们需要把 vals 复制进 ans,平均耗时 O(N)。

    • 所以总时间是 O(N * 2^N)。

  • 空间复杂度:O(N)

    • 递归栈的深度最大为 N。

    • vals 数组临时存储的长度最大为 N。


四、 总结与对比

维度 答案视角 (dfs / dfs1) 输入视角 (dfs2)
思维模型 填坑位:当前这个位置填谁? 过安检:当前这个物品要不要带?
代码结构 For 循环 (横向遍历候选人) 两次递归 (选 vs 不选)
适用场景 全排列、组合、需要字典序的情况 子集、0-1背包、分割等和子集
去重方式 需要 visited 数组或 startIndex 天然无顺序问题,无需特殊去重
  • 全排列 :只能用答案视角 ,因为顺序重要,且需要回头看之前的元素(配合 exist_nums)。

  • 子集 :推荐用输入视角 ,逻辑最简单,代码最简洁(无需 For 循环);当然答案视角 配合 startIndex 也是非常标准的解法。

掌握这两种视角的切换,是解决回溯问题的关键。做题时不妨先问自己:我是要给位置找人(排列),还是给物品找家(子集)?

相关推荐
Yupureki1 小时前
《算法竞赛从入门到国奖》算法基础:入门篇-贪心算法(下)
c语言·c++·学习·算法·贪心算法
zzz海羊2 小时前
【CS336】Transformer|2-BPE算法 -> Tokenizer封装
深度学习·算法·语言模型·transformer
_OP_CHEN2 小时前
【算法基础篇】(四十七)乘法逆元终极宝典:从模除困境到三种解法全解析
c++·算法·蓝桥杯·数论·算法竞赛·乘法逆元·acm/icpc
杭州杭州杭州2 小时前
pta考试
数据结构·c++·算法
YuTaoShao2 小时前
【LeetCode 每日一题】2975. 移除栅栏得到的正方形田地的最大面积
算法·leetcode·职场和发展
少许极端2 小时前
算法奇妙屋(二十五)-递归问题
算法·递归·汉诺塔
Remember_9932 小时前
【数据结构】初识 Java 集合框架:概念、价值与底层原理
java·c语言·开发语言·数据结构·c++·算法·游戏
:mnong2 小时前
通过交互式的LLM算法可视化工具学习大语言模型原理
学习·算法·语言模型
Remember_9932 小时前
【数据结构】Java集合核心:线性表、List接口、ArrayList与LinkedList深度解析
java·开发语言·数据结构·算法·leetcode·list