P12271 [蓝桥杯 2024 国 Python B] 括号与字母 解题报告
P12271 [蓝桥杯 2024 国 Python B] 括号与字母 解题报告
这是一份通俗易懂的解题报告,希望能帮助你理解这道题的巧妙解法。
1. 问题分析:我们要解决什么?
首先,我们快速回顾一下题目要求:给定一个带括号和字母的字符串,有多次询问。每次询问一个字母 c
和一个数量 x
,问我们:有多少对匹配的括号,它们之间的 c
字母数量大于或等于 x
?
2. 初步思考:暴力解法行不行?
一个最直观的想法是:
- 找到所有的括号对。
- 对于每次询问
(c, x)
,我们遍历每一个括号对。 - 在每个括号对内部,再一个一个地数字母
c
的数量。 - 如果数量大于等于
x
,就把最终答案加一。
这个方法思路简单,但问题是效率太低。字符串长度 |S|
和询问次数 Q
都可能很大(达到 10^6 和 10^5)。如果我们每次询问都去重新数一遍,一定会超时。因此,我们需要更高效的“预处理”方法,争取在询问时能够“秒出”答案。
3. 核心思路:化繁为简,三步走战略
这道题的精髓在于将一个复杂的问题拆解成几个简单的小问题,然后用高效的算法逐一击破。整个解法可以分为三步:
第一步:快速找到所有匹配的括号对。
第二步:快速计算任意一对括号之间的字母数量。
第三步:巧妙地处理“不少于 x 个”这种询问。
下面我们来逐一分解这三步。
4. 算法详解
第一步:用“栈”找到所有括号对
这是一个非常经典的算法问题。我们可以从左到右遍历字符串:
- 遇到左括号
(
,我们就把它所在的位置(下标) 压入一个栈中。 - 遇到右括号
)
,说明我们找到了一个括号对的右半部分。此时,栈顶的那个左括号就是和它匹配的左半部分。我们取出栈顶的左括号位置,这样就得到了一个完整的括号对(左括号位置 L, 右括号位置 R)
。然后将这个左括号位置从栈中弹出。
遍历完整个字符串后,我们就找到了所有的括号对。
例子:((a)())
i=0
, 遇到(
, 栈:[0]
i=1
, 遇到(
, 栈:[0, 1]
i=2
, 遇到a
, 忽略i=3
, 遇到)
, 栈顶是1
。找到一对(1, 3)
。弹出1
,栈:[0]
i=4
, 遇到(
, 栈:[0, 4]
i=5
, 遇到)
, 栈顶是4
。找到一对(4, 5)
。弹出4
,栈:[0]
i=6
, 遇到)
, 栈顶是0
。找到一对(0, 6)
。弹出0
,栈:[]
这样,我们就用 O(|S|) 的时间找到了所有括号对。
第二步:用“前缀和”快速计算区间内字母数
现在我们有了很多括号对 (L, R)
,需要快速知道它们中间(即 L+1
到 R-1
的范围)某个字母 c
的数量。如果每次都去数,还是太慢。
这里就要用到另一个经典技巧——前缀和。
我们可以对 'a' 到 'z' 这 26 个字母,分别建立一个前缀和数组。例如,对于字母 c
,我们创建一个数组 count_c
,其中 count_c[i]
记录了在字符串前 i
个字符中,c
总共出现了多少次。
这个数组可以在一次遍历中轻松建好:count_c[i] = count_c[i-1] + (当前字符是否为 c ? 1 : 0)
。
有了前缀和数组,计算任意区间 [start, end]
中 c
的数量就变成了 O(1) 的操作:count_c[end] - count_c[start-1]
。
小结: 结合第一步和第二步,我们现在可以这样做:
- 预处理所有字母的前缀和数组。
- 用栈遍历字符串,每找到一对括号
(L, R)
: - 对于从 'a' 到 'z' 的每一个字母,利用前缀和计算出它在
(L, R)
之间的数量k
。
第三步:用“后缀和”思想高效回答询问
我们已经能算出每个括号对里,每种字母分别有多少个了。现在的问题是,如何应对“不少于 x 个”的询问?
让我们引入一个中间数组,题解中叫 F
,我们可以把它理解成一个“计数器”或者“桶”。F[c][k]
的含义是:内部含有 k 个字母 c 的括号对,一共有多少个?
我们可以这样填充 F
数组:
在上面第二步的基础上,当我们算出一个括号对里有 k
个字母 c
时,我们就让 F[c][k]
的值加 1。
等我们遍历完所有括号对,F
数组就统计好了。例如,F['b'][3] = 5
就表示,有 5 个括号对,它们内部不多不少,正好有 3 个字母 'b'。
现在,当一个询问 ('b', 2)
进来时,它问的是有多少对括号,b
的数量不少于 2 个。这其实就是 b
的数量为 2 的对数 + b
的数量为 3 的对数 + b
的数量为 4 的对数 + ...
也就是求和:F['b'][2] + F['b'][3] + F['b'][4] + ...
如果每次询问都这么加一遍,还是不够快。这里是本题解法的最后一步,也是最精妙的一步:再次利用预处理,把 F
数组变成后缀和形式!
我们可以从后往前遍历 F
数组,更新它的含义。
令 new_F[c][k] = F[c][k] + F[c][k+1] + F[c][k+2] + ...
这个 new_F
可以通过一个简单的递推计算出来:
new_F[c][k] = new_F[c][k+1] + F[c][k]
(注意 F
是旧值)
我们可以直接在原 F
数组上操作,从后往前更新:
for k from n-1 down to 0: F[c][k] = F[c][k] + F[c][k+1]
经过这个操作,F[c][k]
的含义就从“正好有 k 个 c 的对数”变成了“至少有 k 个 c 的对数”。
至此,所有准备工作完成!
当一个询问 (c, x)
进来时,我们只需要直接查询 F[c][x]
的值,这就是最终答案!查询时间是 O(1)。
5. 整体流程总结与代码解析
1. 预处理阶段:
a. 创建前缀和数组 S[26][|S|+1]
:遍历字符串,计算每个字母到每个位置为止的出现次数。
b. 创建计数器数组 F[26][|S|+1]
,并初始化为 0。
c. 遍历字符串找括号对:
- 使用一个栈。
- 遇到 (
,下标入栈。
- 遇到 )
,栈顶下标 L
出栈,当前下标为 R
。我们找到了括号对 (L, R)
。
- 对于这个括号对,为每个字母 j
('a' to 'z') 做如下操作:
- 计算它在 (L,R)
间的数量 k = S[j][R] - S[j][L]
。
- 将 F[j][k]
的值加 1。
d. 将 F
数组转化为后缀和:
- 对每个字母 j
('a' to 'z'):
- for i from |S|-1 down to 0: F[j][i] += F[j][i+1]
。
2. 询问阶段:
a. 读入询问 (c, x)
。
b. 直接输出预处理好的 F[c - 'a'][x]
。
代码(题解中的C++代码)简析:
// ... 包含头文件和定义常量 ...
int n,Q,S[M][N],F[M][N]; // S是前缀和数组,F是计数器/答案数组
string C;
stack<int>St;
// ... main函数 ...
cin>>C>>Q;
n=C.size(),C=" "+C; // 字符串前面加个空格,方便1-based索引
// --- 预处理阶段 1 & 2 ---
for(int i=1;i<=n;i++)
{
// 计算前缀和 S
for(int j=0;j<M;j++) S[j][i]=S[j][i-1]+(C[i]==j+'a');
// 用栈找括号对并填充 F 数组
if(C[i]=='(') St.push(i);
if(C[i]==')')
{
int L=St.top(); St.pop(); // 找到一对 (L, i)
// 对每个字母 j
for(int j=0;j<M;j++)
// 计算括号内j的数量,并给对应的F[j][数量]加一
F[j][S[j][i]-S[j][L-1]]++;
}
}
// --- 预处理阶段 3 ---
// 将F数组转为后缀和形式
for(int j=0;j<M;j++)
for(int i=n;i>=0;i--) F[j][i]+=F[j][i+1];
// --- 询问阶段 ---
while(Q--)
{
char c; int x;
cin>>c>>x;
// O(1) 查询并输出答案
cout<<F[c-'a'][x]<<endl;
}
6. 复杂度分析
- 所有预处理步骤(前缀和、找括号对、后缀和)的时间复杂度都是
O(|S| * 26)
,因为我们基本上是对字符串进行常数次遍历,每次遍历中的一些操作会循环26次。 - 每次询问的时间复杂度是
O(1)
。 - 总复杂度为
O(|S| * 26 + Q)
,其中 26 是一个很小的常数。这个效率足以通过本题的所有数据。
希望这份详细的报告能让你明白这道题的解法!这是一个组合了多种经典算法技巧的好题。