LeetCode算法日记 - Day 56: 全排列II、话号码的字母组合
目录
1. 全排列II
1.1 题目解析
1.2 解法
1.3 代码实现
2. 电话号码的字母组合
2.1 题目解析
2.2 解法
2.3 代码实现
1. 全排列II
https://leetcode.cn/problems/permutations-ii/
给定一个可包含重复数字的序列 nums
,按任意顺序 返回所有不重复的全排列。
示例 1:
输入:nums = [1,1,2] 输出: [[1,1,2],[1,2,1],[2,1,1]]
示例 2:
输入:nums = [1,2,3] 输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
提示:
1 <= nums.length <= 8
-10 <= nums[i] <= 10
1.1 题目解析
题目本质
在长度 ≤ 8 的数组里,从左到右“选位填数”,但元素可能重复;我们需要枚举所有长度为 n 的排列,同时去掉因相同元素换位造成的重复结果。
常规解法
朴素回溯(DFS):每层从剩余元素中任选一个放入路径 path,直到放满加入答案。
问题分析
朴素回溯对含重复元素的数组会把“相同数字在同一层互换位置”的分支也枚举出来,产生大量重复结果与无用搜索;虽然可以用集合去重,但代价高(每层/全局去重会引入额外哈希开销,且实现繁琐)。
思路转折
要想不重不漏且高效,必须在搜索时剪枝,避免生成重复分支。关键做法:
i)先对 nums 排序,使相同数字相邻;
ii)在同一层的枚举里,不同层的相同元素会被标记为 true,而只有同层的才会被标记为 false 。如果当前数字 nums[i] 与前一个数字 nums[i-1] 相等,且前一个相同数字本层还没被使用(!used[i-1]),——这能保证“同层相同值只让排序后出现的第一个出场”,杜绝同构分支。
1.2 解法
算法思想(简短总结)
• 排序:nums 从小到大,使相同元素相邻。
• 回溯:path 记录当前排列;used[i] 标记下标 i 是否已被使用。
• 同层去重剪枝:当 i>0 && nums[i]==nums[i-1] && !used[i-1] 时跳过当前 i,仅允许同层的“第一个相同值”被选。
• 递归结束:当 path.size()==n 时加入答案。
i)对 nums 调用 Arrays.sort(nums)。
ii)准备结构:List<List<Integer>> res、List<Integer> path、boolean[] used。
iii)定义 dfs(depth):
-
若 depth==n:将 path 拷贝加入 res,返回。
-
循环 i=0..n-1:
-
若 used[i] 为真,跳过;
-
若 i>0 && nums[i]==nums[i-1] && !used[i-1],剪枝跳过;
-
否则选择:used[i]=true,path.add(nums[i]),递归到 depth+1;回溯撤销选择。
-
iv)返回 res。
易错点
-
忘记先排序,导致“同层去重”条件失效。
-
将剪枝条件误写成 used[i-1](应是 !used[i-1]),或把“同层”与“跨层”混淆。
-
到达叶子未 return,虽然不致错,但会多跑无用循环。
-
冗余分支:if(vis[i]) continue; 后不要再写 else continue;。
1.3 代码实现
import java.util.*;class Solution {private List<List<Integer>> res;private List<Integer> path;private boolean[] used;public List<List<Integer>> permuteUnique(int[] nums) {Arrays.sort(nums); // 1) 排序,使相同元素相邻int n = nums.length;res = new ArrayList<>();path = new ArrayList<>();used = new boolean[n];dfs(nums, 0);return res;}private void dfs(int[] nums, int depth) {if (depth == nums.length) { // 2) 叶子:收集答案res.add(new ArrayList<>(path));return;}for (int i = 0; i < nums.length; i++) {if (used[i]) continue; // 已用过,跳过// 3) 同层去重:相同元素只允许第一个进入本层分支if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) continue;// 4) 选择used[i] = true;path.add(nums[i]);// 5) 递归下一层dfs(nums, depth + 1);// 6) 回溯path.remove(path.size() - 1);used[i] = false;}}
}
复杂度分析
-
时间复杂度:最坏情况下为生成所有排列的复杂度 O(n · n!),剪枝能显著减少含重复元素时的无效分支。
-
空间复杂度:O(n) 递归栈与标记数组,答案集额外空间按输出规模计。
2. 电话号码的字母组合
https://leetcode.cn/problems/letter-combinations-of-a-phone-number/description/
给定一个仅包含数字 2-9
的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
示例 1:
输入:digits = "23" 输出:["ad","ae","af","bd","be","bf","cd","ce","cf"]
示例 2:
输入:digits = "" 输出:[]
示例 3:
输入:digits = "2" 输出:["a","b","c"]
提示:
0 <= digits.length <= 4
digits[i]
是范围['2', '9']
的一个数字。
2.1 题目解析
题目本质
把一串数字(2–9)按电话键映射成“按位选字母”的所有组合,本质是一个固定长度的笛卡尔积/按位回溯问题。
常规解法
逐位遍历,每一位从映射的字母集中任选一个,深度优先把路径补满后收集为一个字符串。
问题分析:
-
若直接用多重循环,会随着输入长度变化而改变循环层数,不可扩展;且写死层数容易出错。
-
用回溯可以自然处理任意位数(这里 ≤4),并且路径构建/撤销的代价低。
-
复杂度上,假设每位平均有 b 个字母、长度为 n,则解的规模与搜索复杂度为 O(bⁿ),这里 b≈3~4,n≤4,规模很小。
思路转折:
要写得简洁、稳定、易维护:
-
用常量表 KEYS[0..9] 存映射;
-
递归参数用“当前位置 pos”;
-
路径用可变字符串(StringBuilder/StringBuffer),到叶子一次性 toString() 收集;
-
空输入直接返回空列表,避免额外分支。
2.2 解法
算法思想
• 排列模型:第 pos 位从 KEYS[digits[pos]-'0'] 中依次选一个字母。
• 递归推进:选中一个字母 → 递归到 pos+1;当 pos==n 时加入答案。
• 回溯撤销:从路径尾部删去刚加入的字母,返回上一层继续尝试其他字母。
i)若 digits
为空,返回空列表。
ii)初始化常量映射表 KEYS
。
iii)准备结果列表 res 与路径 path(可变字符串)。
iv)调用 dfs(digits, 0)。
v)在 dfs 中:
-
若 pos == digits.length():把 path.toString() 加入结果并返回。
-
取本位数字 idx = digits.charAt(pos) - '0',得到字母串 letters = KEYS[idx]。
-
遍历 letters:依次 append 当前字母 → 递归到下一位 → 回溯 delete 最后一个字母。
vi)返回结果列表。
易错点
-
到达叶子时把同一个可变对象直接加入结果,导致后续修改污染结果;应 toString() 拷贝。
-
使用
+
拼接字符串构建路径,会产生大量中间对象;应使用 StringBuilder/StringBuffer。
2.3 代码实现
import java.util.*;class Solution {// 电话键映射private static final String[] KEYS = {"", // 0"", // 1"abc", // 2"def", // 3"ghi", // 4"jkl", // 5"mno", // 6"pqrs", // 7"tuv", // 8"wxyz" // 9};private List<String> res = new ArrayList<>();private StringBuilder path = new StringBuilder();public List<String> letterCombinations(String digits) {if (digits == null || digits.length() == 0) return res; // 空输入dfs(digits, 0);return res;}private void dfs(String digits, int pos) {if (pos == digits.length()) { // 叶子:收集结果res.add(path.toString());return;}int idx = digits.charAt(pos) - '0'; // 当前位对应的按键String letters = KEYS[idx];for (int i = 0; i < letters.length(); i++) {path.append(letters.charAt(i)); // 选择一个字母dfs(digits, pos + 1); // 递归下一位path.deleteCharAt(path.length() - 1); // 回溯撤销}}
}
复杂度分析(时间+空间)
-
时间复杂度:令输入长度为 n、每位分支数最大为 b(b≤4),则 O(bⁿ);本题 n≤4,规模很小。
-
空间复杂度:递归深度 O(n),路径临时空间 O(n),结果集按输出规模计(最多 3⁴=81 或 4⁴=256 级别)。