在回溯算法(Backtracking)的学习中,我们经常会听到两个概念:"输入视角"和"答案视角"。
很多时候代码写不出来,不是因为逻辑没想通,而是因为这两种视角在脑海里打架。今天我们通过两道最经典的题目------全排列(LeetCode 46)和子集(LeetCode 78),来彻底搞懂这两种思维模式的区别,并分析它们的时空复杂度。
一、 核心概念:两种视角的对决
在开始写代码之前,我们需要先建立世界观:
-
答案视角(Answer Perspective / 填坑位)
-
核心思想 :我手里有 N 个空的坑位(或者是答案需要的长度),我要考虑的是 "当前这个坑,填哪个数字?"
-
代码特征 :通常带有
for循环,枚举所有可能的候选项。 -
典型场景:全排列、N皇后。
-
-
输入视角(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],只有两条路:-
选它 :放入
vals,去下一层。 -
不选它:直接去下一层。
-
-
这种写法逻辑非常清晰,完全模拟了"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也是非常标准的解法。
掌握这两种视角的切换,是解决回溯问题的关键。做题时不妨先问自己:我是要给位置找人(排列),还是给物品找家(子集)?