LeetCode算法日记 - Day 57: 括号生成、组合
目录
1. 括号生成
1.1 题目解析
1.2 解法
1.3 代码实现
2. 组合
2.1 题目解析
2.2 解法
2.3 代码实现
1. 括号生成
https://leetcode.cn/problems/generate-parentheses/description/
数字 n
代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
示例 1:
输入:n = 3 输出:["((()))","(()())","(())()","()(())","()()()"]
示例 2:
输入:n = 1 输出:["()"]
提示:
1 <= n <= 8
1.1 题目解析
题目本质
生成所有长度为 2n 的“合法括号串”。“合法”可等价为:任意前缀内 ) 的数量不超过 (,且总计 ( 与 ) 各 n 个。
常规解法
暴力枚举所有由 ( 与 ) 组成的 2n 长串(共2^{2n} 个),再逐个校验是否合法。
问题分析:
暴力会产生大量明显不合法的前缀(例如一上来就放 )
),校验再滤掉,时间在生成阶段就浪费了,复杂度近似O(2^{2n})量级,不可取。
思路转折:
要高效 → 必须在生成阶段就“剪枝”。
核心约束只有两条:
i)左括号总数 ≤ n;
ii)任意时刻右括号数 ≤ 左括号数。
只在满足约束时继续向下搜索,就能只生成合法分支,避免无效枚举。这正是回溯 + 约束剪枝的用武之地,最终解的规模是第 n 个卡特兰数 Cn,复杂度与结果数量同阶,比暴力好很多。剪枝目的不一定是优化性能,也能防止产出错误数据。
1.2 解法
算法思想
• 回溯构造路径 path;
• 若 left < n 放 ( 继续;
• 若 right < left 放 ) 继续;
• 当 path.length() == 2 * n 收集答案。
i)维护成员变量:ret 结果集、path(StringBuffer)、left/right 当前已放数量、n 目标对数。
ii)终终止条件:path.length() == 2 * n 时加入结果返回。
iii)分支一:若 left < n,append('('),left++,递归,回溯时对称撤销(left-- 与删末字符)。
iv)分支二:若 right < left,append(')'),right++,递归,回溯时对称撤销(right-- 与删末字符)。
v)返回 ret。
易错点
-
终止条件不能用 right == n,应以长度到 2n 为准。
-
放左括号条件必须 left < n(而非 <=)。
-
回溯要“计数器+字符”成对撤销,避免状态泄漏。
-
right < left 是前缀合法性的关键约束。
1.3 代码实现
import java.util.*;class Solution {List<String> ret;StringBuffer path;int right;int left;int n;public List<String> generateParenthesis(int length) {ret = new ArrayList<>();path = new StringBuffer();right = 0;left = 0;n = length;dfs();return ret;}// 回溯 + 约束剪枝public void dfs() {// 长度到达 2n,说明一定 left == right == nif (path.length() == 2 * n) {ret.add(path.toString());return;}// 还能放左括号if (left < n) {path.append('(');left++;dfs();left--; // 回溯:计数器恢复path.deleteCharAt(path.length() - 1); // 回溯:删除最后一个字符}// 右括号必须比左括号少才能放if (right < left) {path.append(')');right++;dfs();right--; // 回溯:计数器恢复path.deleteCharAt(path.length() - 1); // 回溯:删除最后一个字符}}
}
复杂度分析
-
时间复杂度:O(C_n)(第 n 个卡特兰数,等于结果数量同阶);
-
空间复杂度: O(n)(递归栈与构造路径,不含结果集)。
2. 组合
https://leetcode.cn/problems/combinations/
给定两个整数 n
和 k
,返回范围 [1, n]
中所有可能的 k
个数的组合。
你可以按 任何顺序 返回答案。
示例 1:
输入:n = 4, k = 2 输出: [[2,4],[3,4],[2,3],[1,2],[1,3],[1,4], ]
示例 2:
输入:n = 1, k = 1 输出:[[1]]
提示:
1 <= n <= 20
1 <= k <= n
2.1 题目解析
题目本质
在区间 [1, n] 内选择恰好 k 个数的所有组合(无序、无重复)。本质是“从 n 个里选 k 个”的枚举问题。
常规解法
多层 for 循环硬写枚举。适合固定很小的 kkk,但代码不可扩展。
问题分析
固定层数不适配通用 n,k。通用枚举需“逐层选择+回退”的回溯框架。若无剪枝,会在“已经选满 kkk 个后仍继续递归”或“剩余元素不足以凑满”两类分支上做无用功。
思路转折
要高效 → 用回溯 + 两类剪枝:
i)长度剪枝:当 path.size()==k 立刻收集并 return,阻断后续无意义扩展;
ii)容量剪枝:当前起点 key 到 n 剩余数量必须 ≥ k - path.size(),否则当前分支不可能凑满,直接停本层循环。
2.2 解法
算法思想
• 回溯构造递增路径 path,保证不重不漏;
• 当 path.size()==k:加入答案并返回;;
• 容量剪枝:循环上界设为 n - (k - path.size()) + 1
i)设全局 ret、path、n、k,从 dfs(1) 启动。
ii)命中基:path.size()==k → ret.add(copy) → return。
iii)设本层最大起点 maxStart = n - (k - path.size()) + 1。
iv)循环 i 从 key 到 maxStart:选 i,递归 dfs(i+1),回溯移除 i。
v)结束返回 ret。
易错点
-
命中基只 add 不 return 会导致超配递归(path 继续被塞到 k+1、k+2… 虽不入库但浪费栈帧)。
-
忽略容量剪枝,导致在“剩余可选元素数 < 需要补齐数”时仍然空转。
-
结果收集必须拷贝新列表,避免被后续回溯污染。
2.3 代码实现
import java.util.*;class Solution {List<List<Integer>> ret;List<Integer> path;int n;int k;public List<List<Integer>> combine(int _n, int _k) {ret = new ArrayList<>();path = new ArrayList<>();k = _k;n = _n;dfs(1);return ret;}public void dfs(int key) {// 长度剪枝:选满即收集并返回,阻断无意义扩展if (path.size() == k) {ret.add(new ArrayList<>(path));return;}// 容量剪枝:当前层起点至多到 n - (还需数量) + 1int maxStart = n - (k - path.size()) + 1;for (int i = key; i <= maxStart; i++) {path.add(i);dfs(i + 1);path.remove(path.size() - 1);}}
}
复杂度分析
-
时间复杂度:Θ(C(n, k)),与答案规模同阶(剪枝降低常数因子)。
-
空间复杂度:O(k),递归深度与路径长度(不含结果集)。