写在前面
这篇题解参考了大佬 Enucai 的这篇题解的思路和代码,但我这种蒟蒻第一次很难理解大佬的状态设计和转移方法,题解中没有给出详细的做题思路,我又太弱了,没法向大佬一样很快把 dp 状态表示出来,于是我花了较长时间完全搞懂这题,然后写了这篇题解来详细谈谈这题的思维过程和做题时 dp 状态的逐步建立过程,同时稍稍改良了一下大佬的做法,少用了一个状态,希望这篇题解能帮到大家 qwq。
简要题意
这里给出容易理解但不是很严谨的简要题意:
合法括号序列:每个左括号都能唯一地和它右边的一个右括号匹配且没有多余的括号(后文给出了更严谨的定义)。
超级括号序列是向合法括号序列中(不包括最左和最右)插入任意数量的 *
且满足任意连续的 *
长度不超过
k
k
k 形成的序列。
现在给出一个仅由 (
、)
、*
、?
构成的字符串
S
S
S,其中 ?
可以填入 (
、)
、*
中任意一个字符,求使得填后字符串为超级括号序列的填法方案数。
思路
首先拿到题最容易想到的暴力是直接对每个 ?
进行 dfs,枚举可以填三种字符,并判断可行性。显然不加剪枝搜索的状态数会达到
3
500
3^{500}
3500,而观察题目发现答案要对
1
0
9
+
7
10^9 +7
109+7 取模,就说明合法情况数本身都会超过这个值,所以只要是 朴素的 dfs,无论如何剪枝都不可能通过(可以换一种方式记搜,题解里有大佬写了),这种时候就可以换一种思路,考虑 dp,使其一次能转移更多的情况数,不必枚举到每一个情况。
首先考虑线性 dp,从前到后,对于序列的每一个位置,我们需要使用前面的状态来转移答案,看似很好转移,但实际上很难判断每个转移是否合法,最主要的困难是题目要求最外层的括号两边都不能有 *
,而内层的括号则只要求左右两边不能都是 *
,这意味着某些前面位置结尾的不合法情况会转移成后面的合法情况,而某些后面位置结尾的合法情况又会转移成后面的不合法情况,所以每次转移我们必须知道前面每一个“比较具体”的情况对应的方案数,判断转移哪些,但我们无法把这些“比较具体”的情况表示成较少的状态,需要占很大的空间,这在空间上就已经不可接受了,仔细想想,这只是相当于把朴素的 dfs 转化成更麻烦的 dp 了,实际效果与朴素 dfs 相差无几。
所以目前能考虑的办法几乎只有区间 dp 了,显然直接用二维的状态 d p i , j dp_{i,j} dpi,j 直接表示区间 [ i , j ] [i,j] [i,j] 内的方案数无法进行状态转移,但区间的状态不同于线性 dp 所用的状态,可以更灵活地使用区间端点表示,所以我们接下来思考如何更好地进行状态表示。
设计状态
本题最难的地方在于状态转移的不重不漏,这就需要我们在设计状态时多花心思,使状态数量尽可能少并易于转移。
由于左右两边都有 *
的非超级括号序列的区间也能转移成是括号序列的区间(即区间左右套一对括号和编程合法的,就是前文分析线性 dp 提到的“不合法情况转移到合法情况”),所以我们需要扩大状态表示的范围。于是对于这类区间,最直接地根据区间端点分类,这类区间的 所有情况 很容易分成以下 不重复的 四类:
-
左端点为
(
,右端点为)
。 -
左端点为
(
,右端点为*
。 -
左端点为
*
,右端点为)
。 -
左端点为
*
,右端点为*
。
思考一下,这四种状态是不是每种都能在转移时被其他状态表示出来,对于第 1 种状态,容易想到,可以由子区间的两种方式合并:两个状态 1 的区间,或状态 2 和状态 3 的区间。但这样直接把情况数相加就会重复枚举,因为枚举了所有断点,同一个情况可以被多次累加,如 ()*()*()
会在断点下标为 2 和断点下标为 5 时两次被计入情况,而显然这个序列是确定的,只表示同一种情况。
同样地,对于第 2、3 种状态(这两种情况对称),若只拿子区间的状态 1 的区间与第 4 种状态的区间合并来累加答案,也会重复计数,如 ()*()*()*
这种情况会在断点下标为 2、5、8 时被累加 3 次。
状态 4 同样如此,都不能依靠已有状态表示,所以考虑增加状态。
表示和转移第 1 种状态
注:下文的字符串之间的 + 表示字符串拼接。
我们发现第 1 种状态的重复是由于形如 (...)*(...)*(...)*(...)
的序列以几种不同的组合方式计入答案:(...)*(...)*(...)*
+ (...)
、 (...)*(...)*
+ (...)*(...)
、(...)*
+ (...)*(...)*(...)
,可以发现,后 2 种组合方式的后半部分都由多组 最外层的括号 和中间的一些 *
拼接而成,于是我们考虑去除这类情况,只保留形如上面第 1 种组合方式的情况。为此,可以增加一个比状态 1 范围更小的状态以去重:
状态 A:左端点为
(
,右端点为)
,且最外层为且仅为这一组括号组成。
于是每个区间就可以唯一地表示为左子区间为状态 1 或 2 的区间、右子区间为状态 A 的区间的组合,以这种方式枚举每个断点的左右子区间,就可以做到不重不漏了。
如何转移?枚举每个断点,简单分析可以得到左边由于分状态 1、2 共两类,使用加法原理,左右子区间的所有方案可以随意组合,再使用乘法原理乘起来则为该断点分的左右区间的方案数。又由于每个断点考虑的情况都计算的是该区间至少由 2 2 2 组最外层的括号组成的情况,没有考虑本身就是 1 1 1 组最外层的括号(即状态 A)的情况,所以求和之后再加上状态 A 的方案数即可。
表示和转移第 2、3 种状态
这里以状态 2 为例,与表示第 1 种状态的思路一样,考虑重复的原因:形如 (...)*(...)*(...)**
的序列被以 (...)*(...)*(...)
+ **
、(...)*(...)
+ *(...)**
、(...)
+ *(...)*(...)**
三种组合方式计入答案。同样考虑保留只第一种枚举到的情况,去除后
2
2
2 种。可以发现第一种组合方式的这类情况的右子区间都是一些连续的 *
,我们以它表示枚举右子区间的状态,也相当于是增加了一个比状态 4 范围更小的状态以去重:
状态 B:整个区间全都由
*
组成。
这样每个区间就可以唯一地表示为左子区间为状态 1 的区间、右子区间为状态 B 的区间的组合,保证了枚举断点时累积不重不漏。
转移与状态 1 类似:枚举断点,左子区间状态 1 的方案数与右子区间状态 B 的方案数相乘,再对于每一个断点求和。由于状态 B 一定不包含于状态 2 或状态 3,所以最后不加状态 B 的方案数。
状态 3 的转移就是状态 2 左右子区间的状态反过来,即:左子区间状态 B 的方案数与右子区间状态 1 的方案数相乘,其他完全一样。
表示和转移状态 A
注意:别忘了新加的状态也要被已有状态表示,不然没法转移。
由于状态 A 只需要满足最外层套了一对括号,里面是任意状态都行,所以直接去掉最外层的括号,其方案数就是最外层括号内部是这个大区间的状态 1、2、3 的方案数之和(注意内部区间的状态 A 属于状态 1,不必再加,而状态 4 由于左右两边都是 *
,为题中所述的 (SAS)
形式,不计入答案 )。
表示和转移状态 B
可以直接暴力判断区间是否全为 *
,这层循环会和转移其他状态枚举区间断点的循环并列,不会影响整体复杂度。
当然,可以直接用子区间的的状态 B 答案更新,取去掉区间右边得到的子区间的状态 B 的答案,若为
1
1
1 且区间右端点为 *
就可以知道这个区间全是 *
,最后别忘了还要保证状态 B 的区间长度不超过
k
k
k,同时满足这三个条件,该状态的方案数就是
1
1
1,否则是
0
0
0。不过这样更新要注意边界的初始化。
状态 4 4 4
可以发现,现在其他状态的更新都已经用不到状态 4 4 4 了,而状态 4 4 4 也不是我们要求的答案,每次更新它没有意义,直接去掉这个状态即可。
总述
根据上面的分析过程,更形式化地给出本题 dp 的过程。
为了便于较严谨地描述,又由于本人语言表达能力欠佳,下面给出一些会用到的词语在这篇题解中的定义。
在下文中, S S S 表示题中所给的用来确定字符超级括号序列方案数的字符串,默认第一个字符的下标从 1 开始。 i i i 表示左区间下标, j j j 表示右区间下标, t t t 表示枚举的区间断点下标。
注意,在
S
S
S 中的 ?
可以为 (
、)
、*
中的任意一个,所以 ?
在对应的状态都应该算进去。
约定
- 合法括号序列(这与很多和括号有关的思维题里的定义相同):
-
空串是合法括号序列。
-
若
A
和B
均是合法括号序列,则A
和B
连接而成的序列是合法括号序列。 -
若
A
是合法括号序列,则(A)
是合法括号序列。
- 合法状态序列:
-
空串是合法状态序列。
-
若
A
是合法状态序列且A
左侧的连续*
数量严格小于 k k k,则*A
是合法状态序列;若A
是合法状态序列序列且A
右侧的连续*
数量严格小于 k k k,则A*
是合法状态序列。 -
若
A
和B
均是合法状态序列,则A
和B
连接而成的序列是合法状态序列。 -
若
A
是合法状态序列,则(A)
是合法状态序列。
-
称 在合法序列中 位置为 i i i 和 j j j 的一组括号 “匹配” 当且仅当区间 [ i , j ] [i,j] [i,j] 为合法序列且 S j S_j Sj 为
(
, S j S_j Sj 为)
。 -
称 在合法序列中 位置为 i i i 和 j j j 的一组括号 “单区间匹配” 当且仅当这组括号匹配且区间 [ i + 1 , j − 1 ] [i+1,j-1] [i+1,j−1] 为合法序列。
状态定义
定义
d
p
i
,
j
,
p
dp_{i,j,p}
dpi,j,p 为在
S
S
S 的区间
[
i
,
j
]
[i,j]
[i,j] 中确定 ?
字符,使 S
成为 合法状态序列,且状态为
p
p
p 时的方案数(注意这里的状态编号不是前文的编号,为方便表示重新编了号)。
我们对 所有合法状态序列 的状态进行讨论。
-
p = 0 p=0 p=0:区间内字符全为
*
。 -
p = 1 p=1 p=1:左右端点是一组 单区间匹配 的括号。
-
p = 2 p=2 p=2:左端点为
(
,右端点为*
。 -
p = 3 p=3 p=3:左右端点是一组 匹配 的括号。
-
p = 4 p=4 p=4:左端点为
*
,右端点为)
。
其中, p = 1 p=1 p=1 的状态包含于 p = 3 p=3 p=3 的状态。
显然,对于 S S S 的整个区间 [ 1 , n ] [1,n] [1,n], p = 3 p=3 p=3 的状态即为答案。
初始化边界:对于 ∀ i \forall i ∀i ,有 d p i , i − 1 , 0 = 1 dp_{i,i-1,0}=1 dpi,i−1,0=1。
状态转移
-
d p i , j , 0 = 1 dp_{i,j,0}=1 dpi,j,0=1 当且仅当 d p i , j − 1 , 0 = 1 dp_{i,j-1,0}=1 dpi,j−1,0=1 且 S j S_j Sj 可以为
*
且 j − i + 1 < k j-i+1<k j−i+1<k,否则 d p i , j , 0 = 0 dp_{i,j,0}=0 dpi,j,0=0。 -
d p i , j , 1 = d p i + 1 , j − 1 , 0 + d p i + 1 , j − 1 , 2 + d p i + 1 , j − 1 , 3 + d p i + 1 , j − 1 , 4 dp_{i,j,1} = dp_{i+1,j-1,0}+dp_{i+1,j-1,2}+dp_{i+1,j-1,3}+dp_{i+1,j-1,4} dpi,j,1=dpi+1,j−1,0+dpi+1,j−1,2+dpi+1,j−1,3+dpi+1,j−1,4 当且仅当 S i S_i Si 和 S j S_j Sj 为一组匹配的括号,否则 d p i , j , 1 = 0 dp_{i,j,1}=0 dpi,j,1=0。
-
d p i , j , 2 = ∑ t = i j − 1 d p i , t , 3 × d p t + 1 , j , 0 dp_{i,j,2} = \sum_{t=i}^{j-1} dp_{i,t,3} \times dp_{t+1,j,0} dpi,j,2=∑t=ij−1dpi,t,3×dpt+1,j,0 。
-
d p i , j , 3 = d p i , j , 1 + ∑ t = i j − 1 ( d p i , t , 2 + d p i , t , 3 ) × d p t + 1 , j , 1 dp_{i,j,3} = dp_{i,j,1} + \sum_{t=i}^{j-1} (dp_{i,t,2}+dp_{i,t,3}) \times dp_{t+1,j,1} dpi,j,3=dpi,j,1+∑t=ij−1(dpi,t,2+dpi,t,3)×dpt+1,j,1 。
-
d p i , j , 4 = ∑ t = i j − 1 d p i , t , 0 × d p t + 1 , j , 3 dp_{i,j,4} = \sum_{t=i}^{j-1} dp_{i,t,0} \times dp_{t+1,j,3} dpi,j,4=∑t=ij−1dpi,t,0×dpt+1,j,3
复杂度
时间复杂度和标准的区间 dp 相同,为 O ( n 3 ) O(n^3) O(n3)。空间上,使用了大小为 N × N × 5 N \times N \times 5 N×N×5 的 long long 数组作为 dp 数组( N N N 为 n n n 的最大值)。都可以通过此题数据范围。
Code
明白了以上所述的状态定义和转移,直接照着转移写代码就很简单了,不过注意边界的初始化和同一个区间不同状态转移的顺序,每次更新需要保证用到的所有状态都被正确地更新了。
#include<bits/stdc++.h>
#define LL long long
using namespace std;
const int N=550,mo=1e9+7;
LL dp[N][N][5];
int n,k;
char S[N];
signed main()
{
scanf("%d%d",&n,&k);
scanf("%s",S+1);
for(int i=1;i<=n;++i)
dp[i][i-1][0]=1;//初始化边界
for(int len=1;len<=n;++len)//枚举区间长度
for(int i=1,j=i+len-1;j<=n;++i,++j)//枚举左右端点
{
if(len<=k&&dp[i][j-1][0]&&(S[j]=='*'||S[j]=='?')) dp[i][j][0]=1;
if(len>=2)//用小于2的子区间转移没有意义
{
if((S[i]=='('||S[i]=='?')&&(S[j]==')'||S[j]=='?'))//判断左右端点是否为匹配的括号
dp[i][j][1]=(dp[i+1][j-1][0]+dp[i+1][j-1][2]+dp[i+1][j-1][3]+dp[i+1][j-1][4])%mo;
for(int t=i;t<=j-1;++t)//枚举子区间的断点
{
dp[i][j][2]=(dp[i][j][2]+dp[i][t][3]*dp[t+1][j][0])%mo;
dp[i][j][3]=(dp[i][j][3]+(dp[i][t][2]+dp[i][t][3])*dp[t+1][j][1])%mo;
dp[i][j][4]=(dp[i][j][4]+dp[i][t][0]*dp[t+1][j][3])%mo;
}
}
dp[i][j][3]=(dp[i][j][3]+dp[i][j][1])%mo;
}
printf("%lld",dp[1][n][3]);
return 0;
}