GESP2026年9月认证C++七级( 第三部分编程题(2、括号序列))精讲


第三部分编程题第二题:《括号序列》

这道题第一次看,很多同学会觉得:

"老师,这不就是数括号吗?"

其实它真正考查的是一个非常重要的思想:

动态规划 + 子序列 + 括号匹配

而且这道题特别适合用"小机器人走楼梯"的故事来理解。

题目的要求是:给定一个只包含 ( 和 ) 的字符串,从中选择一些字符,保持原来的下标顺序组成子序列,问其中有多少个是合法括号序列。答案对 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 &lt;= n; i++)
{
    for (int j = 0; j &lt;= n; j++)
    {
        // 不选择第i个字符
        f[i][j] = f[i - 1][j];

        // 选择第i个字符,并且它是左括号
        if (s[i] == '(' &amp;&amp; j)
        {
            f[i][j] =
                (f[i][j] + f[i - 1][j - 1]) % mod;
        }

        // 选择第i个字符,并且它是右括号
        if (s[i] == ')' &amp;&amp; j &lt; 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] 里面。

这就是动态规划最迷人的地方。


相关推荐
jimy13 小时前
虚函数vtable,链接时看到构造函数,linker把符号地址写入vtable(三)
开发语言·c++
潼心1412o4 小时前
C++初阶(长期更新)第9讲:string(上)
开发语言·c++
朝朝辞暮i4 小时前
C++ 第 39 章: 阶段性总复习——类、对象、构造、继承、this、指针与智能指针
开发语言·c++·算法·ros2
wuminyu4 小时前
Virtual Thread重投递至ForkJoinPool任务队列过程解析
java·linux·c语言·jvm·c++
All for pursuit.4 小时前
【回溯-6】79.单词搜索
数据结构·c++·算法·leetcode
Benny_Tang5 小时前
AT_arc180_d [ARC180D] Division into 3 题解
数据结构·c++·算法
殷色玫瑰5 小时前
C++ STL:stack、queue、priority_queue 与容器适配器详解
java·开发语言·数据结构·c++·算法·visualstudio
努力努力再努力wz6 小时前
【CUDA入门系列】CUDA编程实战:设备选择、显存管理、Kernel、Stream 与 Event 全流程梳理
开发语言·c++
沫璃染墨6 小时前
《从零入门Linux系统篇(五十八):线程篇·十一——线程安全与死锁详解:从可重入到多锁管理》
linux·服务器·开发语言·c++·驱动开发·安全·架构