





第三部分编程题第二题:《括号序列》
这道题第一次看,很多同学会觉得:
"老师,这不就是数括号吗?"
其实它真正考查的是一个非常重要的思想:
动态规划 + 子序列 + 括号匹配
而且这道题特别适合用"小机器人走楼梯"的故事来理解。
题目的要求是:给定一个只包含 ( 和 ) 的字符串,从中选择一些字符,保持原来的下标顺序组成子序列,问其中有多少个是合法括号序列。答案对 10^9 取模。题目数据范围为 n ≤ 2000。
一、先搞懂:什么叫"子序列"?
这是第一关。
比如:
sequence
我们可以选择其中一些字符,而且:
可以删除字符,但是不能改变剩下字符的先后顺序。
例如:
s u n
就是 sequence 的一个子序列。
但是如果把顺序改变成:
n u s
就不是了。
特别重要:不同下标也算不同子序列
题目特别强调:
两个子序列不同,当且仅当所选取的下标不同。
例如:
e e e
里面有三个 e。
虽然最后得到的字符串都是:
e
但是:
选第1个e
选第2个e
选第3个e
这是 3个不同的子序列。
所以这道题不是简单地问:
"有多少种不同的括号字符串?"
而是:
"有多少种不同的选取方法,最终形成合法括号序列?"
这点一定要记住。
二、什么叫合法括号序列?
我们平时见过:
()
是合法的。
()()
是合法的。
(())
是合法的。
(()())
也是合法的。
但是:
(()
不合法。
)(
也不合法。
三、用"小机器人"理解括号
这是理解这道题最重要的办法。
我们规定:
( → 上楼1层
) → 下楼1层
也可以理解成一个"括号能量值"。
例如:
(
走到:
1楼
再来一个:
(
走到:
2楼
然后:
)
下降:
1楼
再:
)
下降:
0楼
所以:
(())
就是:
0 → 1 → 2 → 1 → 0
最终回到0楼,而且中间没有掉到地下室。
所以它是合法的。
四、合法括号序列有两个关键条件
把括号换成"楼层"以后,就特别容易理解了。
条件1:任何时候都不能掉到负数
比如:
)(
一开始就是:
0 → -1
掉进地下室了!
所以不合法。
条件2:最后必须回到0
例如:
(()
楼层变化:
0 → 1 → 2 → 1
虽然没有掉到地下室,但是最后停在1楼。
所以也不合法。
因此合法括号序列就是:
过程中余额不能小于0,最后余额必须等于0。
这个思想非常重要。
五、来看样例1
题目给:
6
))(()(
也就是:
位置: 1 2 3 4 5 6
字符: ) ) ( ( ) (
题目答案是:
3
为什么?
我们找合法括号子序列。
第一种:空字符串
什么都不选:
""
题目规定:
空字符串也是合法括号序列。
所以:
1种
第二种:选择位置3和5
位置:
3 5
( )
得到:
()
合法。
第三种:选择位置4和5
位置:
4 5
( )
也得到:
()
虽然最后的字符串和刚才一样:
()
但是选取下标不同:
(3,5)
(4,5)
所以是两个不同的子序列。
因此:
空字符串 1个
位置3、5 1个
位置4、5 1个
----------------
总共 3个
所以答案:
3
这体现了题目"不同下标算不同子序列"的规定。也就是说,我们统计的不是"能拼出多少种不同的括号字符串",而是"有多少种不同的选取下标组合,最终都能形成合法括号序列"。
哪怕两个子序列最后得到的字符串一模一样,只要它们来自不同的下标位置,就要被分别计数。
这一点贯穿整道题的始终,也是后面动态规划状态设计时"为什么同一个余额 j 会有多种选法"的根本原因。
六、能不能直接枚举?
有同学可能会想到:
"那我把所有子序列都找出来,然后一个个判断不就行了吗?"
这是一个非常自然的想法。
但是问题来了。
长度为 n 的字符串,一共有:
2^n
个子序列。
因为每个字符都有两种选择:
选
不选
例如长度3:
2 × 2 × 2 = 8
长度10:
2^10=1024
还不算太可怕。
但是本题:
n ≤ 2000
那么:
2^2000
这是一个超级超级大的数字。
所以:
不能枚举所有子序列。
我们必须想办法"聪明地数"。
这就是我们的动态规划DP登场的时候。
七、动态规划到底要记录什么?
这是整道题最核心的问题:
状态怎么设计?
我们定义:
f[i][j]
表示:
只考虑前
i个字符,选择其中一些字符组成子序列,并且这个子序列当前的括号余额为j,这样的选法有多少种。
这里的"余额"就是:
左括号数量−右括号数量
比如:
(
余额:
1
比如:
((
余额:
2
比如:
(()
余额:
1
八、为什么 j 不能小于0?
因为:
j < 0
意味着:
右括号比左括号多。
也就是我们的"小机器人"掉进地下室了。
例如:
)
一开始:
0
选这个 ):
0 → -1
这是不合法的。
所以我们只记录:
j = 0, 1, 2, ...
不会记录负数。
这一个小技巧非常漂亮:
只要动态规划状态永远不允许出现负余额,就自动保证了"前缀不能非法"。
九、为什么答案是 f[n][0]?
最后我们考虑完整个字符串:
f[n][j]
表示:
考虑全部n个字符以后,余额是j的合法前缀子序列数量。
真正的合法括号序列必须:
最后回到0。
所以我们需要:
f[n][0]
因此最终答案就是:
fn0
十、最关键的问题:第i个字符选不选?
这是动态规划最经典的地方。
对于第 i 个字符:
要么不选
要么选
这就是动态规划的"两条路"。
十一、情况一:不选第i个字符
无论第 i 个字符是什么:
我们都可以选择不使用它。
那么:
f[i][j] = f[i - 1][j];
什么意思?
比如:
前面i-1个字符
已经有100种方法可以得到余额3。
现在第i个字符我不要。
那么:
仍然有100种
所以:
fij ← fi−1j
十二、情况二:第i个字符是左括号 (
假设:
s[i] == '('
左括号会让余额:
+1
比如原来:
余额 = 2
选一个 (:
2 → 3
所以:
如果现在希望得到余额
j,那么在选择第i个(之前,余额必须是j-1。
因此:
f[i][j] += f[i - 1][j - 1];
但是必须满足:
j > 0
因为不能访问:
f[i-1][-1]
程序中这样设定:
if (s[i] == '(' && j)
f[i][j] =
(f[i][j] + f[i - 1][j - 1]) % mod;
十三、举例帮助理解
假设我们现在希望:
最终余额 = 3
最后一个字符是:
(
那么这个 ( 会让余额增加1。
所以在加入它之前:
必须是2
也就是:
2 + 1 = 3
因此:
f[i][3]
可以从:
f[i-1][2]
转移过来。
十四、情况三:第i个字符是右括号 )
右括号会让余额:
−1
例如:
3 → 2
所以:
如果现在最终余额是j,那么加入这个
)之前,余额应该是j+1。
因此:
f[i][j] += f[i - 1][j + 1];
程序中这样设定:
if (s[i] == ')' && j < n)
f[i][j] =
(f[i][j] + f[i - 1][j + 1]) % mod;
十五、为什么右括号不能直接让余额变成负数?
这就是一个需要注意的地方。
假设:
当前余额 = 0
现在来了:
)
如果选它:
0 → -1
非法!
我们的数组只记录:
j = 0,1,2,...,n
所以:
负数状态根本不存在。
例如想计算:
f[i][-1]
我们根本不会计算它。
于是非法情况就自动被过滤掉了。
十六、把三个转移总结起来
对于:
f[i][j]
先做:
① 不选
f[i][j] = f[i-1][j];
② 如果当前是 (
f[i][j] += f[i-1][j-1];
因为:
j-1 + 1 = j
③ 如果当前是 )
f[i][j] += f[i-1][j+1];
因为:
j+1 - 1 = j
十七、画成"小机器人路线图"
可以把DP想象成机器人在楼梯上走:
余额
↑
│
3 ● ← (
↑
2 ●
↑
1 ●
↑
0 ●
│
└────────→ 字符位置
遇到:
(
只能:
向上走一格
遇到:
)
只能:
向下走一格
但是:
不能走到0层以下。
最后:
必须停在0层。
这就是整道题的动态规划本质。
十八、用样例手动走一遍
我们用样例:
))(()(
位置:
1 2 3 4 5 6
) ) ( ( ) (
开始:
f[0][0] = 1
意思是:
什么字符都没看,选择空字符串,有1种方法。
看第1个字符 )
如果什么都不选:
余额0
仍然有:
1种
如果选 ):
0 → -1
非法。
所以:
f[1][0] = 1
看第2个字符 )
还是一样。
不选:
余额0
有1种。
选:
0 → -1
非法。
所以:
f[2][0] = 1
看第3个字符 (
现在有两种选择:
不选
空字符串:
1种
选择
(
余额:
0 → 1
所以:
f[3][0] = 1
f[3][1] = 1
看第4个字符 (
可以得到:
f[4][0] = 1
以及:
f[4][1] = 2
f[4][2] = 1
为什么余额1有2种?
因为可以:
选择第3个(
选择第4个(
两种不同的下标选择。
看第5个字符 )
这是非常关键的一步。
我们现在可以把前面的 ( 和这个 ) 配起来。
比如:
第3个( + 第5个)
得到:
()
还有:
第4个( + 第5个)
也是:
()
所以最后:
f[5][0] = 3
这3种正是:
空字符串
(3,5)
(4,5)
看第6个字符 (
虽然还能形成更多余额大于0的子序列,但是我们最后只关心:
f[6][0]
答案仍然是:
3
所以样例输出:
3
与题目一致。
十九、完整参考程序
题目的官方参考程序如下:
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 2005;
const int mod = 1e9;
int n;
char s[N];
int f[N][N];
int main()
{
scanf("%d", &n);
scanf("%s", s + 1);
f[0][0] = 1;
for (int i = 1; i <= n; i++)
{
for (int j = 0; j <= n; j++)
{
// 不选择第i个字符
f[i][j] = f[i - 1][j];
// 选择第i个字符,并且它是左括号
if (s[i] == '(' && j)
{
f[i][j] =
(f[i][j] + f[i - 1][j - 1]) % mod;
}
// 选择第i个字符,并且它是右括号
if (s[i] == ')' && j < n)
{
f[i][j] =
(f[i][j] + f[i - 1][j + 1]) % mod;
}
}
}
printf("%d\n", f[n][0]);
return 0;
}
这就是题目给出的参考程序。
二十、逐行解析程序
① 定义字符串
char s[N];
存放:
((()))...
这些括号。
② 定义DP数组
int f[N][N];
这里:
第一个下标i = 看了多少个字符
第二个下标j = 当前余额
所以:
f[i][j]
就是:
看前i个字符,余额为j的合法前缀子序列有多少种。
二十一、为什么 N=2005?
题目规定:
n ≤ 2000
所以开:
const int N = 2005;
留一点余量。
DP数组大约是:
2005 × 2005
大约400万整数。
每个 int 4字节。
大约需要:
4000000×4 ≈ 16MB
题目内存限制是512 MB,所以完全够用。
二十二、为什么要取模?
题目要求:
答案对 10^9 取模。
所以代码:
const int mod = 1e9;
每次加法以后:
% mod
例如:
f[i][j] =
(f[i][j] + f[i-1][j-1]) % mod;
防止数字越来越大。
二十三、这道题最容易错的三个地方
错误1:把"子序列"理解成"连续子串"
例如:
( ( ) (
可以选择:
第1个(
第3个)
得到:
()
它们中间虽然隔着字符,但依然是子序列。
所以:
子序列不要求连续。
错误2:觉得两个 () 只有一个
例如:
( ( )
1 2 3
可以选择:
1,3
也可以:
2,3
虽然得到的字符串都是:
()
但下标不同。
所以是:
2种
题目对此有明确规定。
错误3:只检查最后是不是0
这是非常重要的坑。
例如:
)(
余额变化:
0 → -1 → 0
最后确实是:
0
但是它依然不合法。
为什么?
因为中间已经:
掉到地下室
所以必须保证:
任何时刻余额都不能小于0。
我们的DP恰好通过只保存 j>=0 的状态解决了这个问题。
二十四、这道题为什么是二维DP?
因为我们需要同时记录两个东西:
第一个维度
i
表示:
已经处理到第几个字符。
第二个维度
j
表示:
当前括号余额是多少。
所以:
f[i][j]
就是一个非常标准的:
"位置 + 状态"二维动态规划。
这和以前学过的很多DP其实是同一家族。
二十五、和以前学过的DP联系起来
比如以前学:
背包
dp[i][j]
表示:
前i个物品,容量j时的最大价值。
网格DP
dp[i][j]
表示:
走到第i行第j列的方法数。
本题
f[i][j]
表示:
看前i个括号,当前余额为j的方法数。
所以看到二维数组:
dp[i][j]
第一件事情永远问自己:
"i代表什么?j代表什么?"
只要这两个问题回答清楚,DP通常就已经成功一半了。
二十六、这道题的"万能思考模板"
以后遇到类似问题,可以按照下面五步走。
第1步:问"我到底在数什么?"
这里数的是:
合法括号子序列的数量。
第2步:不能暴力怎么办?
所有子序列有:
2^n
太多。
所以考虑:
动态规划。
第3步:定义状态
f[i][j]
前 i 个字符,余额 j。
第4步:考虑第 i 个字符
只有两种:
不选
或者:
选
如果选:
( → 余额+1
) → 余额-1
第5步:确定最终答案
合法括号序列:
余额不能 < 0
最后 = 0
因此:
f[n][0]
就是答案。
二十七、给大家的最终记忆口诀
左括号上楼,右括号下楼;
地下室不能去,最后必须回到0楼。
第i个字符,要么不选,要么选;
(从j-1来,)从j+1来;
最终答案看f[n][0]!
本题最核心的公式:
f[i][j]
= 不选第i个
+ 选择第i个
如果:
s[i] == '('
那么:
f[i][j] += f[i-1][j-1]
如果:
s[i] == ')'
那么:
f[i][j] += f[i-1][j+1]
最后:
答案 = f[n][0];
这道题真正值得大家学会的,并不是背代码,而是学会这个思维:
面对"数量非常巨大、不能一个个枚举"的问题,就寻找一个小小的状态,把大量情况"压缩"到
dp[i][j]里面。
这就是动态规划最迷人的地方。