LeetCode 47中等回溯
全排列 II 图解题解
这道题到底在问什么
给一个可能含重复数字的数组 nums,返回所有不重复的排列。
- 输入
- nums=[1,1,2]
- 输出
- [[1,1,2],[1,2,1],[2,1,1]]
最优解:一步一步想明白
- 3记住这条去重判断「同层、相同值、前一个没用就跳过」,下面每一帧都在套它。
- 4开局:nums 已排好序 [1,1,2],path 是空的,三个数都没用过(白色),结果 res 也是空。每一层从头扫候选,挑一个没用过的放进 path——但碰到重复值要小心。
- 5选中没用过的 1(第 0 个):标记它为已用、放进 path。现在 path = [1],往下一层递归。
- 6这一层从头扫:第 0 个数 1 已经在 path 里了(used 标灰),打个 ✗ 跳过,不能重复用同一个位置。
- 7选中没用过的 1(第 1 个):标记它为已用、放进 path。现在 path = [1,1],往下一层递归。
- 8这一层从头扫:第 0 个数 1 已经在 path 里了(used 标灰),打个 ✗ 跳过,不能重复用同一个位置。
- 9这一层从头扫:第 1 个数 1 已经在 path 里了(used 标灰),打个 ✗ 跳过,不能重复用同一个位置。
- 10选中没用过的 2(第 2 个):标记它为已用、放进 path。现在 path = [1,1,2],往下一层递归。
- 11path 凑满了 3 个数 —— 记下这个排列 [1,1,2]。然后开始往回退(回溯),去试别的可能。
- 12回溯:把第 2 个的 2 移出 path、把它的 used 标记撤销(重新变白,后面别的分支还能用它)。回到上一层继续试下一个候选。
- 13回溯:把第 1 个的 1 移出 path、把它的 used 标记撤销(重新变白,后面别的分支还能用它)。回到上一层继续试下一个候选。
- 14选中没用过的 2(第 2 个):标记它为已用、放进 path。现在 path = [1,2],往下一层递归。
- 15这一层从头扫:第 0 个数 1 已经在 path 里了(used 标灰),打个 ✗ 跳过,不能重复用同一个位置。
- 16选中没用过的 1(第 1 个):标记它为已用、放进 path。现在 path = [1,2,1],往下一层递归。
- 17path 凑满了 3 个数 —— 记下这个排列 [1,2,1]。然后开始往回退(回溯),去试别的可能。
- 18回溯:把第 1 个的 1 移出 path、把它的 used 标记撤销(重新变白,后面别的分支还能用它)。回到上一层继续试下一个候选。
- 19这一层从头扫:第 2 个数 2 已经在 path 里了(used 标灰),打个 ✗ 跳过,不能重复用同一个位置。
- 20回溯:把第 2 个的 2 移出 path、把它的 used 标记撤销(重新变白,后面别的分支还能用它)。回到上一层继续试下一个候选。
- 21回溯:把第 0 个的 1 移出 path、把它的 used 标记撤销(重新变白,后面别的分支还能用它)。回到上一层继续试下一个候选。
- 22去重剪枝:第 1 个数 1 和它前面那个相等(都是 1),而前一个这层没用(白色)——说明前一个等值数刚在本层试过又撤销了,再选这个会排出一模一样的排列。打 ✗ 跳过。
- 23选中没用过的 2(第 2 个):标记它为已用、放进 path。现在 path = [2],往下一层递归。
- 24选中没用过的 1(第 0 个):标记它为已用、放进 path。现在 path = [2,1],往下一层递归。
- 25这一层从头扫:第 0 个数 1 已经在 path 里了(used 标灰),打个 ✗ 跳过,不能重复用同一个位置。
- 26选中没用过的 1(第 1 个):标记它为已用、放进 path。现在 path = [2,1,1],往下一层递归。
- 27path 凑满了 3 个数 —— 记下这个排列 [2,1,1]。然后开始往回退(回溯),去试别的可能。
- 28回溯:把第 1 个的 1 移出 path、把它的 used 标记撤销(重新变白,后面别的分支还能用它)。回到上一层继续试下一个候选。
- 29这一层从头扫:第 2 个数 2 已经在 path 里了(used 标灰),打个 ✗ 跳过,不能重复用同一个位置。
- 30回溯:把第 0 个的 1 移出 path、把它的 used 标记撤销(重新变白,后面别的分支还能用它)。回到上一层继续试下一个候选。
- 31去重剪枝:第 1 个数 1 和它前面那个相等(都是 1),而前一个这层没用(白色)——说明前一个等值数刚在本层试过又撤销了,再选这个会排出一模一样的排列。打 ✗ 跳过。
- 32这一层从头扫:第 2 个数 2 已经在 path 里了(used 标灰),打个 ✗ 跳过,不能重复用同一个位置。
- 33回溯:把第 2 个的 2 移出 path、把它的 used 标记撤销(重新变白,后面别的分支还能用它)。回到上一层继续试下一个候选。
⚠️ 容易写错的地方
✗ 错:不排序就去重
✓ 对:必须先 sort 让相同值相邻
去重判断靠 nums[i]==nums[i-1],相同值不相邻就拦不住
✗ 错:判 used[i-1]==true 才跳
✓ 对:应是 !used[i-1](前一个没用)才跳
前一个还在 path 里(used)说明是合法的「1 在 1 之后」分支,不能剪
✗ 错:res.add(path) 不拷贝
✓ 对:res.add(new ArrayList<>(path))
path 是同一个引用,回溯会清空它,存进去的全变样
完整代码(Python / C++ / Java)
Python
def permuteUnique(nums):
nums.sort() # 排序,让相同值相邻
res, n = [], len(nums)
used = [False] * n
path = []
def backtrack():
if len(path) == n:
res.append(path[:]) # 拷贝!
return
for i in range(n):
if used[i]: continue
if i > 0 and nums[i] == nums[i-1] and not used[i-1]:
continue # 同层去重
used[i] = True; path.append(nums[i])
backtrack()
path.pop(); used[i] = False # 撤销
backtrack()
return resC++
class Solution {
vector<vector<int>> res;
vector<int> path;
public:
void bt(vector<int>& nums, vector<bool>& used) {
if (path.size() == nums.size()) { res.push_back(path); return; }
for (int i = 0; i < nums.size(); i++) {
if (used[i]) continue;
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue; // 同层去重
used[i] = true; path.push_back(nums[i]);
bt(nums, used);
path.pop_back(); used[i] = false; // 撤销
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
sort(nums.begin(), nums.end()); // 排序
vector<bool> used(nums.size(), false);
bt(nums, used);
return res;
}
};Java
class Solution {
List<List<Integer>> res = new ArrayList<>();
List<Integer> path = new ArrayList<>();
public List<List<Integer>> permuteUnique(int[] nums) {
Arrays.sort(nums); // 排序,让相同值相邻
boolean[] used = new boolean[nums.length];
backtrack(nums, used);
return res;
}
void backtrack(int[] nums, boolean[] used) {
if (path.size() == nums.length) {
res.add(new ArrayList<>(path)); // 拷贝!
return;
}
for (int i = 0; i < nums.length; i++) {
if (used[i]) continue;
if (i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue; // 同层去重
used[i] = true; path.add(nums[i]);
backtrack(nums, used);
path.remove(path.size() - 1); used[i] = false; // 撤销
}
}
}复杂度
时间
O(n·n!)
最坏(无重复)仍 n! 个排列,每个拷贝 O(n);去重只会更少
空间
O(n)
递归深度 + used + path(不计结果)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 全排列 II 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么去重前一定要先排序?+
去重判断靠「当前值和前一个相等」(nums[i]==nums[i-1]),只有排序后相同的值才会相邻,这个判断才拦得住同层重复;不排序相同值散落各处,判断失效。
!used[i-1] 和 used[i-1] 两种写法都见过,区别?+
!used[i-1](前一个没用就跳)是同层去重,标准且高效;used[i-1] 那种是另一套「只允许相同值按从左到右顺序使用」的等价剪枝写法,但更绕,推荐前者。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 全排列 II 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。