本文正在参加「金石计划」
括号问题
对于「括号问题」往往是 笔试/面试 中的常见题型。本文将对常见的关于「括号问题」的题型进行整理总结,并归纳出一种能够应对绝大多数「括号问题」的方法论。掌握该方法论可轻松做出如下 道「括号问题」相关题目。
678. 有效的括号字符串
基本题意
给定一个只包含三种字符的字符串:(
,)
和 *
,写一个函数来检验这个字符串是否为有效字符串。
有效字符串具有如下规则:
- 任何左括号
(
必须有相应的右括号)
。 - 任何右括号
)
必须有相应的左括号(
。 - 左括号
(
必须在对应的右括号之前)
。 - 可以被视为单个右括号
)
,或单个左括号(
,或一个空字符串。 - 一个空字符串也被视为有效字符串。
示例 1:
输入: "()"
输出: True
示例 2:
输入: "(*)"
输出: True
示例 3:
输入: "(*))"
输出: True
注意:
- 字符串大小将在 [1,100] 范围内。
动态规划
定义 为考虑前 个字符(字符下标从 开始),能否与 个右括号形成合法括号序列。
起始时只有 为 ,最终答案为 。
不失一般性的考虑 该如何转移:
- 当前字符为
(
: 如果 为 ,必然有 为 ,反之亦然。即有 ; - 当前字符为
)
: 如果 为 ,必然有 为 ,反之亦然。即有 ; - 当前字符为
*
: 根据*
代指的符号不同,分为三种情况,只有有一种情况为 即可。即有 。
代码:
class Solution {
public boolean checkValidString(String s) {
int n = s.length();
boolean[][] f = new boolean[n + 1][n + 1];
f[0][0] = true;
for (int i = 1; i <= n; i++) {
char c = s.charAt(i - 1);
for (int j = 0; j <= i; j++) {
if (c == '(') {
if (j - 1 >= 0) f[i][j] = f[i - 1][j - 1];
} else if (c == ')') {
if (j + 1 <= i) f[i][j] = f[i - 1][j + 1];
} else {
f[i][j] = f[i - 1][j];
if (j - 1 >= 0) f[i][j] |= f[i - 1][j - 1];
if (j + 1 <= i) f[i][j] |= f[i - 1][j + 1];
}
}
}
return f[n][0];
}
}
- 时间复杂度:
- 空间复杂度:
模拟
通过解法一,我们进一步发现,对于某个 而言(即动规数组中的某一行),值为 的必然为连续一段。
即 由于存在可变化的 *
符号,因此考虑在考虑前 个字符,其能与消耗的左括号的数量具有明确的「上界与下界」。且当前上界与下界的变化,仅取决于「当前为何种字符」,以及「处理上一个字符时上界与下界为多少」。
但直接记录所能消耗的左括号上限和下限需要处理较多的边界问题。
我们可以使用与(题解)301. 删除无效的括号 类似的思路:
令左括号的得分为 ;右括号的得分为 。那么对于合法的方案而言,必定满足最终得分为 。
同时由于本题存在 *
,因此我们需要记录得分的区间区间是多少,而不仅是一个具体的得分。
具体的,使用两个变量 l
和 r
分别表示「最低得分」和「最高得分」。
根据当前处理到的字符进行分情况讨论:
- 当前字符为
(
:l
和r
同时加一; - 当前字符为
)
:l
和r
同时减一; - 当前字符为
*
: 如果*
代指成(
的话,l
和r
都进行加一;如果*
代指成)
的话,l
和r
都进行减一;如果*
不变的话,l
和r
均不发生变化。因此总的l
的变化为减一,总的r
的变化为加一。
需要注意的是,在匹配过程中如果 l
为负数,需要重置为 ,因为如果当前序列本身为不合法括号序列的话,增加 (
必然还是不合法。同时,当出现 l > r
说明上界为负数,即右括号过多,必然为非合法方案,返回 。
代码:
class Solution {
public boolean checkValidString(String s) {
int l = 0, r = 0;
for (char c : s.toCharArray()) {
if (c == '(') {
l++; r++;
} else if (c == ')') {
l--; r--;
} else {
l--; r++;
}
l = Math.max(l, 0);
if (l > r) return false;
}
return l == 0;
}
}
- 时间复杂度:
- 空间复杂度:
32. 最长有效括号
基本题意
给你一个只包含 '('
和 ')'
的字符串,找出最长有效(格式正确且连续)括号子串的长度。
示例 1:
输入:s = "(()"
输出:2
解释:最长有效括号子串是 "()"
示例 2:
输入:s = ")()())"
输出:4
解释:最长有效括号子串是 "()()"
示例 3:
输入:s = ""
输出:0
提示:
- 0 <= s.length <= 3 *
- s[i] 为
'('
或')'
栈
从前往后扫描字符串 s
。
使用 i
来记录当前遍历到的位置,使用 j
来记录最近的最长有效括号的开始位置的「前一个位置」。
只对 '('
进行入栈(入栈的是对应的下标),当遍历到 ')'
的时候,由于栈中只有 '('
,所以可以直接弹出一个 '('
与之匹配(如果有的话)。
再检查栈中是否还有 '('
,如果有使用栈顶元素的下标来计算长度,否则使用 j
下标来计算长度。
代码:
class Solution {
public int longestValidParentheses(String s) {
int n = s.length();
char[] cs = s.toCharArray();
Deque<Integer> d = new ArrayDeque<>();
int ans = 0;
for (int i = 0, j = -1; i < n; i++) {
if (cs[i] == '(') {
d.addLast(i);
} else {
if (!d.isEmpty()) {
d.pollLast();
int top = j;
if (!d.isEmpty()) top = d.peekLast();
ans = Math.max(ans, i - top);
} else {
j = i;
}
}
}
return ans;
}
}
- 时间复杂度:每个字符最多进栈和出栈一次。复杂度为
- 空间复杂度:
301. 删除无效的括号
基本题意
给你一个由若干括号和字母组成的字符串 s ,删除最小数量的无效括号,使得输入的字符串有效。
返回所有可能的结果。答案可以按 任意顺序 返回。
示例 1:
输入: "()())()"
输出: ["()()()", "(())()"]
示例 2:
输入: "(a)())()"
输出: ["(a)()()", "(a())()"]
示例 3:
输入: ")("
输出: [""]
提示:
- 1 <= s.length <= 25
- s 由小写英文字母以及括号 '(' 和 ')' 组成
- s 中至多含 20 个括号
搜索 + 剪枝
由于题目要求我们将所有(最长)合法方案输出,因此不可能有别的优化,只能进行「爆搜」。
我们可以使用 DFS
实现回溯搜索。
基本思路:
我们知道所有的合法方案,必然有左括号的数量与右括号数量相等。
首先我们令左括号的得分为 ;右括号的得分为 。则会有如下性质:
- 对于一个合法的方案而言,必然有最终得分为 ;
- 搜索过程中不会出现得分值为 负数 的情况(当且仅当子串中某个前缀中「右括号的数量」大于「左括号的数量」时,会出现负数,此时不是合法方案)。
同时我们可以预处理出「爆搜」过程的最大得分: max = min(左括号的数量, 右括号的数量)
PS.「爆搜」过程的最大得分必然是:合法左括号先全部出现在左边,之后使用最多的合法右括号进行匹配。
枚举过程中出现字符分三种情况:
- 左括号:如果增加当前
(
后,仍为合法子串(即 ) 时,我们可以选择添加该左括号,也能选择不添加; - 右括号:如果增加当前
)
后,仍为合法子串(即 ) 时,我们可以选择添加该右括号,也能选择不添加; - 普通字符:直接添加。
使用 Set
进行方案去重, 记录「爆搜」过程中的最大子串,然后只保留长度等于 的子串。
代码:
class Solution {
Set<String> set = new HashSet<>();
int n, max, len;
String s;
public List<String> removeInvalidParentheses(String _s) {
s = _s;
n = s.length();
int l = 0, r = 0;
for (char c : s.toCharArray()) {
if (c == '(') l++;
else if (c == ')') r++;
}
max = Math.min(l, r);
dfs(0, "", 0);
return new ArrayList<>(set);
}
void dfs(int u, String cur, int score) {
if (score < 0 || score > max) return ;
if (u == n) {
if (score == 0 && cur.length() >= len) {
if (cur.length() > len) set.clear();
len = cur.length();
set.add(cur);
}
return ;
}
char c = s.charAt(u);
if (c == '(') {
dfs(u + 1, cur + String.valueOf(c), score + 1);
dfs(u + 1, cur, score);
} else if (c == ')') {
dfs(u + 1, cur + String.valueOf(c), score - 1);
dfs(u + 1, cur, score);
} else {
dfs(u + 1, cur + String.valueOf(c), score);
}
}
}
- 时间复杂度:预处理 的复杂度为 ;不考虑 带来的剪枝效果,最坏情况下,每个位置都有两种选择,搜索所有方案的复杂度为 ;同时搜索过程中会产生的新字符串(最终递归树中叶子节点的字符串长度最大为 ,使用
StringBuilder
也是同理),复杂度为 。整体复杂度为 - 空间复杂度:最大合法方案数与字符串长度呈线性关系。复杂度为
搜索 + 剪枝
在解法一,我们是在搜索过程中去更新最后的 。
但事实上,我们可以通过预处理,得到最后的「应该删除的左括号数量」和「应该删掉的右括号数量」,来直接得到最终的 。
因此在此基础上,我们可以考虑多增加一层剪枝。
代码:
class Solution {
Set<String> set = new HashSet<>();
int n, max, len;
String s;
public List<String> removeInvalidParentheses(String _s) {
s = _s;
n = s.length();
int l = 0, r = 0;
for (char c : s.toCharArray()) {
if (c == '(') {
l++;
} else if (c == ')') {
if (l != 0) l--;
else r++;
}
}
len = n - l - r;
int c1 = 0, c2 = 0;
for (char c : s.toCharArray()) {
if (c == '(') c1++;
else if (c == ')') c2++;
}
max = Math.min(c1, c2);
dfs(0, "", l, r, 0);
return new ArrayList<>(set);
}
void dfs(int u, String cur, int l, int r, int score) {
if (l < 0 || r < 0 || score < 0 || score > max) return ;
if (l == 0 && r == 0) {
if (cur.length() == len) set.add(cur);
}
if (u == n) return ;
char c = s.charAt(u);
if (c == '(') {
dfs(u + 1, cur + String.valueOf(c), l, r, score + 1);
dfs(u + 1, cur, l - 1, r, score);
} else if (c == ')') {
dfs(u + 1, cur + String.valueOf(c), l, r, score - 1);
dfs(u + 1, cur, l, r - 1, score);
} else {
dfs(u + 1, cur + String.valueOf(c), l, r, score);
}
}
}
- 时间复杂度:预处理 和 的复杂度为 ;不考虑 带来的剪枝效果,最坏情况下,每个位置都有两种选择,搜索所有方案的复杂度为 ;同时搜索过程中会产生的新字符串(最终递归树中叶子节点的字符串长度最大为 ,使用
StringBuilder
也是同理),复杂度为 。整体复杂度为 - 空间复杂度:最大合法方案数与字符串长度呈线性关系。复杂度为
20. 有效的括号
基本题意
给定一个只包括 '(',')','{','}','[',']'
的字符串 s
,判断字符串是否有效。
有效字符串需满足:
-
左括号必须用相同类型的右括号闭合。
-
左括号必须以正确的顺序闭合。
示例 1:
输入:s = "()"
输出:true
示例 2:
输入:s = "()[]{}"
输出:true
示例 3:
输入:s = "(]"
输出:false
示例 4:
输入:s = "([)]"
输出:false
示例 5:
输入:s = "{[]}"
输出:true
提示:
s
仅由括号'()[]{}'
组成
栈 + 哈希表
这是道模拟题,同一类型的括号,一个右括号要对应一个左括号。
不难发现可以直接使用 栈
来解决:
代码:
class Solution {
HashMap<Character, Character> map = new HashMap<Character, Character>(){{
put(']', '[');
put('}', '{');
put(')', '(');
}};
public boolean isValid(String s) {
Deque<Character> d = new ArrayDeque<>();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '(' || c == '{' || c == '[') {
d.addLast(c);
} else {
if (!d.isEmpty() && d.peekLast() == map.get(c)) {
d.pollLast();
} else {
return false;
}
}
}
return d.isEmpty();
}
}
- 时间复杂度:对字符串
s
扫描一遍。复杂度为 - 空间复杂度:使用的哈希表空间固定,不随着样本数量变大而变大。复杂度为
注意:三叶使用了 Deque
双端队列来充当栈,而不是 Stack
,这也是 JDK 推荐的做法。建议所有的 Java 同学都采用 Deque
作为栈。
不使用 Stack
的原因是 Stack
继承自 Vector
,拥有了动态数组的所有公共 API,并不安全,而且 Stack
还犯了面向对象设计的错误:将组合关系当成了继承关系。
栈 + ASCII 差值
我们也可以利用 "()"
、"{}"
和 "[]"
的左右部分在 ASCII 值上比较接近的事实。
(
和 )
分别对应 -7 和 -8;[
和 ]
分别对应 43 和 45;{
和 }
分别对应 75 和 77。
也就是同类型的左右括号,相差不超过 2 ,同时不同类型的左右括号,相差大于 2。
利用此特性,我们可以节省一个哈希表:
代码:
class Solution {
public boolean isValid(String s) {
Deque<Integer> d = new ArrayDeque<>();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
int u = c - '0';
if (c == '(' || c == '{' || c == '[') {
d.addLast(u);
} else {
if (!d.isEmpty() && Math.abs(d.peekLast() - u) <= 2) {
d.pollLast();
} else {
return false;
}
}
}
return d.isEmpty();
}
}
- 时间复杂度:对字符串
s
扫描一遍。复杂度为 - 空间复杂度:
22. 括号生成
基本题意
数字 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。
示例 1:
输入:n = 3
输出:["((()))","(()())","(())()","()(())","()()()"]
示例 2:
输入:n = 1
输出:["()"]
提示:
DFS
既然题目是求所有的方案,那只能爆搜了,爆搜可以使用 DFS
来做。
从数据范围 1 <= n <= 8
来说,DFS
应该是稳稳的 AC。
这题的关键是我们要从题目中发掘一些性质:
-
括号数为
n
,那么一个合法的括号组合,应该包含n
个左括号和n
个右括号,组合总长度为2n
-
一对合法的括号,应该是先出现左括号,再出现右括号。那么意味着任意一个右括号的左边,至少有一个左括号
其中性质 2 是比较难想到的,我们可以用反证法来证明性质 2 总是成立:
假设某个右括号不满足「其左边至少有一个左括号」,即其左边没有左括号,那么这个右括号就找不到一个与之对应的左括号进行匹配。
这样的组合必然不是有效的括号组合。
使用我们「20. 有效的括号(简单)」的思路(栈)去验证的话,必然验证不通过。
掌握了这两个性质之后,我们可以设定一个初始值为 0 的得分值,令往组合添加一个 (
得分值 + 1,往组合添加一个 )
得分值 -1。
这样就有:
-
一个合法的括号组合,最终得分必然为 0 (左括号和右括号的数量相等,对应了性质 1)
-
整个
DFS
过程中,得分值范围在[0, n]
(得分不可能超过n
意味着不可能添加数量超过n
的左括号,对应了性质 1;得分不可能为负数,意味着每一个右括号必然有一个左括号进行匹配,对应性质 2)
代码:
class Solution {
public List<String> generateParenthesis(int n) {
List<String> ans = new ArrayList<>();
dfs(0, n * 2, 0, n, "", ans);
return ans;
}
/**
* i: 当前遍历到位置
* n: 字符总长度
* score: 当前得分,令 '(' 为 1, ')' 为 -1
* max: 最大得分值
* path: 当前的拼接结果
* ans: 最终结果集
*/
void dfs(int i, int n, int score, int max, String path, List<String> ans) {
if (i == n) {
if (score == 0) ans.add(path);
} else {
if (score + 1 <= max) dfs(i + 1, n, score + 1, max, path + "(", ans);
if (score - 1 >= 0) dfs(i + 1, n, score - 1, max, path + ")", ans);
}
}
}
- 时间复杂度:放置的左括号数量为
n
,右括号的个数总是小于等于左括号的个数,典型的卡特兰数问题。复杂度为 - 空间复杂度:
总结
综上所述,将成对括号的左右部分看做数值,可将问题转化为数学判定。
再结合「前缀和」或是「简单遍历并维护变量」等常见做法,可轻松将原问题等价为简单问题。