回溯算法本质上是一种暴力搜索的优化方法,通过"尝试-回退"的机制系统地遍历所有可能的解空间。在解决组合、排列、子集等问题时,回溯算法展现出独特的优势。
回溯算法的核心框架可以概括为以下三步:
这种"前进-后退"的机制使得算法能够探索所有可能的解,同时通过剪枝操作避免无效搜索。
给定一个整数数组nums,找出所有不同的递增子序列,要求子序列长度至少为2,且保持原数组中的相对顺序。例如:
输入:[4,6,7,7]
输出:[[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]]
解决这个问题的关键在于:
我们使用回溯算法配合哈希表去重:
cpp复制class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIndex) {
if(path.size() > 1) { // 满足长度要求
result.push_back(path);
}
unordered_set<int> uset; // 本层去重
for(int i = startIndex; i < nums.size(); i++) {
// 剪枝条件:不递增或已使用
if ((!path.empty() && nums[i] < path.back()) ||
uset.find(nums[i]) != uset.end()) {
continue;
}
uset.insert(nums[i]); // 记录本层使用
path.push_back(nums[i]);
backtracking(nums, i + 1); // 不可重复使用元素
path.pop_back(); // 回溯
}
}
vector<vector<int>> findSubsequences(vector<int>& nums) {
backtracking(nums, 0);
return result;
}
};
时间复杂度:O(n * 2^n) - 最坏情况下需要遍历所有子序列
空间复杂度:O(n) - 递归栈深度和临时存储空间
优化点:
给定一个不含重复数字的数组nums,返回其所有可能的全排列。例如:
输入:[1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
与组合问题不同,排列问题需要考虑元素的顺序,因此每次都需要从第一个元素开始考虑。
使用used数组标记元素使用状态:
cpp复制class Solution {
public:
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> result;
vector<int> path;
vector<bool> used(nums.size(), false);
backtracking(nums, used, path, result);
return result;
}
void backtracking(vector<int>& nums, vector<bool>& used,
vector<int>& path, vector<vector<int>>& result) {
if(path.size() == nums.size()) {
result.push_back(path);
return;
}
for(int i = 0; i < nums.size(); i++) {
if(used[i]) continue; // 跳过已使用元素
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used, path, result);
path.pop_back();
used[i] = false; // 回溯
}
}
};
当数组中包含重复元素时,直接使用46题的方法会产生重复排列。例如:
输入:[1,1,2]
错误输出会包含多个[1,1,2]
关键挑战在于如何有效去重,同时保证算法效率。
我们采用"树层去重"策略:
这种策略确保同一树层不会使用相同元素,而同一树枝可以重复使用。
cpp复制class Solution {
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<vector<int>> result;
vector<int> path;
vector<bool> used(nums.size(), false);
sort(nums.begin(), nums.end()); // 必须排序
backtracking(nums, used, path, result);
return result;
}
void backtracking(vector<int>& nums, vector<bool>& used,
vector<int>& path, vector<vector<int>>& result) {
if(path.size() == nums.size()) {
result.push_back(path);
return;
}
for(int i = 0; i < nums.size(); i++) {
// 树层去重条件
if(i > 0 && nums[i] == nums[i-1] && !used[i-1]) {
continue;
}
if(!used[i]) {
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used, path, result);
path.pop_back();
used[i] = false;
}
}
}
};
结果中出现空列表:
出现重复结果:
栈溢出错误:
参数传递优化:
剪枝策略强化:
数据结构选择:
打印日志调试:
小数据测试:
可视化递归树:
| 特征 | 组合问题 | 排列问题 |
|---|---|---|
| 元素顺序 | 不考虑 | 考虑 |
| 起始索引 | 需要startIndex | 总是从0开始 |
| 去重方式 | 同层去重 | 全范围去重 |
| 典型问题 | 子集、组合总和 | 全排列、N皇后 |
python复制def backtrack(路径, 选择列表):
if 满足终止条件:
结果.append(路径)
return
for 选择 in 选择列表:
if 不满足选择条件: # 剪枝
continue
做选择
backtrack(新路径, 新选择列表)
撤销选择
回溯算法特别适合解决以下类型问题:
在实际编码面试中,当遇到"所有可能"、"全部解"等关键词时,应优先考虑回溯算法。