前缀和这个技巧,本身不难,但真正到面试或者周赛里,能不能在读完题之后第一时间想到它,差别很大。力扣2055题《蜡烛之间的盘子》就是一个非常典型的例子:字符串里用 * 表示盘子,用 | 表示蜡烛,然后给你一堆查询,每次给一个子串区间 [l, r],问这个区间里“被两根蜡烛夹在中间”的盘子到底有多少个。
我第一次看这题,第一反应是“这肯定要枚举区间,然后判断每个盘子左右有没有蜡烛”,但这样肯定超时。真正上手之后才发现,这题的难点不在于前缀和本身,而是怎么把“夹在蜡烛中间”翻译成坐标、怎么处理区间左右边界的各种情况。这篇文章我会把完整思考过程拆开讲一遍:先分析题意,再做预处理设计,再讲查询阶段怎么写,最后会列几个我在提交时反复踩过的边界坑。整个方案可以在 O(n + q) 时间内通过,适合刚接触前缀和、或者想查漏补缺的人参考。
1. 题目拆解:区间内夹在蜡烛之间的盘子
1.1 原题在说什么
先还原一下题目场景。假设输入字符串 s = "*|*|**|*",这里面:
*是盘子|是蜡烛
现在给一个查询区间,比如 [1, 6],对应子串是 "|*|**|"。从视觉上看,这个子串内部有三根蜡烛,位置分别在 1、3、6。那这个查询的答案应该是多少?
不是 3,也不是 2?等等,这里要看清楚题目的定义:盘子必须“被两根蜡烛夹在中间”。所以你需要找到一对蜡烛,并且盘子位于这一对蜡烛之间。最直观的做法是把区间内第一根出现的蜡烛当作左边界,把最后一根出现的蜡烛当作右边界,然后统计这两个边界之间的盘子。
拿 [1, 6] 来说:
- 从左往右看到第一根蜡烛是下标 1
- 从右往左看到第一根蜡烛是下标 6
- 它们之间的盘子在下标 2、4、5 三个位置
去掉边界本身,位置 2、4、5 里,2 是 *,4 是 *,5 是 *,所以答案是 3。但有一个特殊点:如果区间里根本没有蜡烛,或者只有一根蜡烛,那答案就是 0。
1.2 为什么不能直接暴力
最容易想到的暴力做法是:对于每个查询 [l, r],先从 l 往后扫描找到第一根蜡烛,再从 r 往前扫描找到第一根蜡烛,然后在这两个边界之间遍历一次,数盘子数量。
假设字符串长度是 n,查询数量是 q,最坏情况下每个查询都要遍历整个区间,时间复杂度是 O(nq)。当 n 和 q 都到 10^5 的时候,这个量级是秒不动的,甚至会超时到怀疑人生。
所以这题需要做“预处理”。核心需求只有两个:
- 快速知道某个区间里有多少个盘子,这正好是前缀和最擅长的场景。
- 快速知道某个位置左边/右边最近的蜡烛在哪里,这个可以提前用两次线性扫描记录下来。
两个需求都解决之后,每个查询就能在常数时间内完成,整体复杂度降到 O(n + q)。这道题也从“看起来很奇怪”变成了“标准的区间计数题”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三个预处理数组分别解决什么问题
2.1 先用前缀和解决“任意区间有多少盘子”
所谓前缀和,简单说就是用一个数组记录“从开头到当前位置的累积量”。这里我们用前缀和来记录*的数量,蜡烛不计入。
我习惯定义成:
code复制plate_prefix[i] = 字符串前 i 个字符中 '*' 的数量
注意这个定义和不少人的习惯不一样。plate_prefix[0] = 0,plate_prefix[1] 表示只包含下标 0 的字符里有多少个 *。这么写的好处是,可以很方便地求任意连续区间的盘子数量。
建立前缀和数组的代码大概是这样的:
python复制n = len(s)
plate_prefix = [0] * (n + 1)
for i, ch in enumerate(s):
plate_prefix[i + 1] = plate_prefix[i] + (1 if ch == '*' else 0)
举个小例子:
code复制s = "*|*|**|*"
plate_prefix = [0, 1, 1, 2, 2, 3, 4, 4, 5]
这里 plate_prefix[5] = 3,表示下标 0 到 4 的 5 个字符里,* 的总数是 3。你可以验证一下:前 5 个字符是 *|*|*,其中盘子是下标 0、2、4,确实是 3 个。
如果我想知道原字符串中下标 a 到 b 之间有多少个盘子,就用:
code复制plate_prefix[b + 1] - plate_prefix[a]
这是前缀和最基本的区间减法。不过在这道题里,我们真正要算的区间并不是查询给的原区间,而是“两根蜡烛边界之间”的区间。所以查询阶段不能直接用 [l, r] 去减,得先确定左右两根蜡烛的位置。
2.2 一次性扫描出每个位置“左边最近的蜡烛”
第一个辅助数组用来记录:在每个下标 i,往左看最近的一根蜡烛在哪里。如果左边没有蜡烛,就用 -1 标记。
这个数组很多人会叫 left_candle、prev_candle 或者 nearest_left。我下面写成 prev_candle[i],表示“下标 <= i 的字符中,最靠右的那根蜡烛位置”,也就是从 i 往左找第一根蜡烛。
扫描过程非常简单,用一个变量记录上一次遇到的蜡烛下标,从左到右遍历即可:
python复制prev_candle = [-1] * n
last = -1
for i, ch in enumerate(s):
if ch == '|':
last = i
prev_candle[i] = last
还是用 s = "*|*|**|*" 来走一遍。下标 0 是 *,此时 last = -1,所以 prev_candle[0] = -1;下标 1 是 |,更新 last = 1,所以 prev_candle[1] = 1;下标 2 是 *,last 仍然是 1,所以 prev_candle[2] = 1;下标 3 是 |,last = 3,所以 prev_candle[3] = 3。最后得到:
code复制prev_candle = [-1, 1, 1, 3, 3, 3, 6, 6]
这个数组在查询时有什么用?它主要用来确定“右侧边界蜡烛”。因为每次查询给了一个右端点 r,我们需要在 s[0..r] 中找最后一根蜡烛,也就是不超过 r 的最靠右的那根蜡烛。那正好就是 prev_candle[r]。
2.3 再扫描一次得到“右边最近的蜡烛”
第二个辅助数组解决左侧边界。我们需要知道:在每个位置 i,往右看最近的一根蜡烛在哪里。如果右边没有蜡烛,就用 -1。
写成 next_candle[i],表示“下标 >= i 的字符中,最靠左的那根蜡烛位置”,也就是从 i 往右找第一根蜡烛。
这次要从右往左扫描,同样用一个变量记录已经遇到的最靠左蜡烛下标:
python复制next_candle = [-1] * n
nxt = -1
for i in range(n - 1, -1, -1):
if s[i] == '|':
nxt = i
next_candle[i] = nxt
继续用 s = "*|*|**|*" 模拟。从右边开始,下标 7 是 *,此时 nxt = -1,所以 next_candle[7] = -1;下标 6 是 |,更新 nxt = 6,所以 next_candle[6] = 6;下标 5 是 *,nxt 还是 6,所以 next_candle[5] = 6;下标 4 同样是 6;下标 3 是 |,更新 nxt = 3,所以 next_candle[3] = 3。最终:
code复制next_candle = [1, 1, 3, 3, 6, 6, 6, -1]
查询时,这个数组用来确定“左侧边界蜡烛”。给定左端点 l,我们需要在 s[l..n-1] 中找第一根蜡烛,也就是从 l 往右找最近的蜡烛,那正好就是 next_candle[l]。
到这里,三个数组都齐了:
plate_prefix:统计盘子累积数量prev_candle:找区间右侧边界蜡烛next_candle:找区间左侧边界蜡烛
3. 查询阶段:把原始区间翻译成“蜡烛坐标区间”
3.1 一次查询的完整计算逻辑
现在看一个查询 [l, r] 怎么处理。
第一步,找左侧边界蜡烛:
code复制left_pos = next_candle[l]
这个值表示:从查询左端点 l 开始,向右看第一根蜡烛。如果查询区间里没有任何蜡烛在 l 的右边,这个值就是 -1。
第二步,找右侧边界蜡烛:
code复制right_pos = prev_candle[r]
这个值表示:从查询右端点 r 开始,向左看第一根蜡烛。如果查询区间里没有任何蜡烛在 r 的左边,这个值就是 -1。
第三步,判断边界是否有效:
- 如果
left_pos == -1,说明[l, r]里没有蜡烛,答案自然是 0。 - 如果
right_pos == -1,说明[l, r]里也没有蜡烛,答案也是 0。 - 如果
left_pos >= right_pos,说明区间里虽然有蜡烛,但两根边界指向同一个位置,或者左边界在右边界的右边。这种情况下不存在“两根不同的蜡烛”,答案同样是 0。
只有在 left_pos < right_pos 时,才真正存在两根不同的蜡烛,可以把 left_pos 当作左边界,right_pos 当作右边界。
第四步,使用前缀和计算两个边界之间的盘子数量。注意这里有一个很容易犯迷糊的点:我们不需要数蜡烛,只需要数盘子。由于 left_pos 本身一定是蜡烛,right_pos 本身也一定是蜡烛,所以可以直接用:
code复制plate_prefix[right_pos] - plate_prefix[left_pos]
为什么可以这么减?因为 plate_prefix[i] 统计的是前 i 个字符里的盘子数量,plate_prefix[right_pos] 统计的是下标 0 到 right_pos - 1 的盘子数量。它已经把边界蜡烛右边界自身排除掉了。但它还包含了 left_pos 左边的盘子,以及 left_pos 这个蜡烛位置,因为蜡烛不计入盘子,所以减去 plate_prefix[left_pos] 之后,剩下的正好是区间 [left_pos+1, right_pos-1] 内的盘子。
可能有人会问:为什么不写成 plate_prefix[right_pos] - plate_prefix[left_pos + 1]?其实在这个场景里是一样的,因为 left_pos 是蜡烛,蜡烛不计入盘子,所以 plate_prefix[left_pos + 1] == plate_prefix[left_pos]。两种写法都能过,但我个人更推荐 plate_prefix[right_pos] - plate_prefix[left_pos],它表达的是“去掉左边界之前的盘子,保留从左边蜡烛开始到右边蜡烛之前这一段”。当然,如果你怕自己晕,也可以统一用严格区间写法:
code复制plate_prefix[right_pos] - plate_prefix[left_pos + 1]
这两种在数学上完全等价,只要整个代码里保持前缀和定义一致就行。
3.2 用一个完整例子走通整个流程
还是用 s = "*|*|**|*",假设查询是 [0, 7]。
先查四个关键值:
next_candle[0] = 1,所以左侧边界蜡烛在下标 1prev_candle[7] = 6,所以右侧边界蜡烛在下标 6- 因为
1 < 6,边界有效 plate_prefix[6]表示前 6 个字符中盘子的数量。前 6 个字符是*|*|**,盘子数量是 4plate_prefix[1]表示前 1 个字符中盘子的数量,也就是下标 0 这个*,数量是 1
答案就是:
code复制4 - 1 = 3
和原始肉眼观察一致:下标 2、4、5 三个盘子被夹在下标 1 和下标 6 两根蜡烛之间。
再看一个边界情况,查询 [0, 2]。此时:
next_candle[0] = 1prev_candle[2] = 1left_pos == right_pos == 1- 因为
left_pos >= right_pos,答案是 0
这个结果很合理,因为 [0, 2] 对应的子串是 *|*,只有一根蜡烛,根本没有“夹在两个蜡烛之间”的盘子。
再比如查询 [4, 7],对应子串 **|*。
next_candle[4] = 6prev_candle[7] = 6- 两根蜡烛其实指向同一个位置,所以答案也是 0
肉眼看一下也能发现,子串 **|* 里只有一根蜡烛在中间,左侧的盘子并没有被两根蜡烛包围,当然不能算。
4. 完整代码实现与逐行注释
4.1 Python 参考代码
下面这套代码可以直接在力扣 2055 题上运行。我把注释写得比较细,方便对照上面的推导看:
python复制from typing import List
class Solution:
def platesBetweenCandles(self, s: str, queries: List[List[int]]) -> List[int]:
n = len(s)
# 1. 前缀和:plate_prefix[i] 表示前 i 个字符中 '*' 的数量
plate_prefix = [0] * (n + 1)
for i, ch in enumerate(s):
plate_prefix[i + 1] = plate_prefix[i] + (1 if ch == '*' else 0)
# 2. prev_candle[i]:下标 <= i 的最近蜡烛位置,没有则 -1
prev_candle = [-1] * n
last = -1
for i, ch in enumerate(s):
if ch == '|':
last = i
prev_candle[i] = last
# 3. next_candle[i]:下标 >= i 的最近蜡烛位置,没有则 -1
next_candle = [-1] * n
nxt = -1
for i in range(n - 1, -1, -1):
if s[i] == '|':
nxt = i
next_candle[i] = nxt
ans = []
for l, r in queries:
left_pos = next_candle[l]
right_pos = prev_candle[r]
if left_pos == -1 or right_pos == -1 or left_pos >= right_pos:
ans.append(0)
else:
ans.append(plate_prefix[right_pos] - plate_prefix[left_pos])
return ans
4.2 关键位置的细节说明
代码里最需要注意的其实是那个 if 判断:
python复制if left_pos == -1 or right_pos == -1 or left_pos >= right_pos:
前面两个条件很好理解,但第三个 left_pos >= right_pos 很容易漏。漏掉的后果不是报错,而是会得到负数答案,或者把根本不算“之间”的盘子数出来。
我在第一次写的时候只判断了 left_pos == -1 和 right_pos == -1,结果在只有一根蜡烛的区间里,left_pos 和 right_pos 可能相等,然后前缀和相减成了 0,还没暴露问题。但有时候查询区间里根本没有能构成一对蜡烛的位置,强行减出来可能是一个奇怪的正数,导致答案错误。所以这个判断一定不能省。
另外,left_pos 和 right_pos 的命名也要注意。left_pos 是“从左端点向右找到的第一个蜡烛”,它不一定就是整个查询区间最左边的蜡烛,但它是真正能作为左边界的蜡烛。right_pos 同理。如果你把命名搞反,看起来代码能跑,但答案是错的。
4.3 复杂度分析
整个算法主要分两部分:
- 预处理阶段:遍历字符串三次,时间复杂度
O(n),空间复杂度O(n),因为要存前缀和和两个蜡烛位置数组。 - 查询阶段:每个查询只做常数次数组访问和一次减法,时间复杂度
O(q)。
所以总时间复杂度是:
code复制O(n + q)
空间复杂度是:
code复制O(n)
这个复杂度在 n、q 都是 10^5 甚至 10^6 级别时都能跑得动。相比暴力的 O(nq),提升非常明显。
5. 常见问题与排查实录
5.1 为什么我用前缀和减完得到的是负数?
这种情况几乎都出在边界蜡烛位置没有判断好。举个例子,如果 left_pos 在 right_pos 的右边,那么:
python复制plate_prefix[right_pos] - plate_prefix[left_pos]
结果是负数。因为前缀和是单调不减的,下标越大值越大,下标小的减去下标大的自然为负。
所以代码里一定要先判断 left_pos >= right_pos,再去做减法。这个顺序不能反过来。
5.2 查询区间内有很多根蜡烛,为什么只用最左和最右两根?
这是我最开始没有想明白的点。可能会担心:如果中间有额外的蜡烛,是不是会改变盘子归属?
其实不会。题目要统计的是“能被某两根蜡烛夹在中间”的盘子。只要盘子的位置介于整个查询区间里的第一根蜡烛和最后一根蜡烛之间,它必然也被这两根边界蜡烛夹在中间。换句话说,第一根蜡烛和最后一根蜡烛已经把合法的盘子范围框出来了,中间的蜡烛并不影响统计结果。
举个例子:
code复制s = "*|**|**|*"
query = [0, 7]
第一根蜡烛在下标 1,最后一根蜡烛在下标 6。中间还有一根蜡烛在下标 3。肉眼看一下,下标 1 和 6 之间的所有盘子,都是被至少一对蜡烛夹住的,所以答案应该数下标 2、4、5。前缀和算出来也正好是 3。中间的蜡烛不会让答案变大或变小,因为它在边界范围内,不影响“是否存在一对蜡烛包围它”这个判断。
5.3 为什么前缀和数组长度要开 n + 1?
这个问题我见过很多人问。开 n + 1 是为了让 plate_prefix[i] 能表示“前 i 个字符”的语义,也让 plate_prefix[0] 有一个安全的初始值 0。
如果数组都开到 n,那查询到边界蜡烛在下标 n - 1 时,可能需要分情况讨论,代码容易出界。统一用 n + 1,写区间减法的时候就不用担心 right_pos + 1 越界的问题。
5.4 常见问题速查表
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 答案偏小 | 只统计了相邻两根蜡烛之间的盘子 | 改用整个区间内第一根和最后一根蜡烛作为边界 |
| 答案偏大 | 把边界左边的盘子也算进去了 | 检查前缀和减法是否减掉了 left_pos 前缀部分 |
| 某些用例返回 0,实际上有答案 | prev_candle 和 next_candle 建反了 |
检查两个辅助数组的扫描方向和赋值逻辑 |
| 结果出现负数 | 没有判断 left_pos < right_pos |
在减法前增加边界判断 |
| 代码越界 | 前缀和数组长度开成 n |
使用 n + 1 长度,并用“前 i 个字符”定义 |
6. 另一种思路:蜡烛位置数组 + 二分查找
如果你不想维护两个长度为 n 的蜡烛数组,也可以先把所有蜡烛的位置收集到一个列表 candle_pos 中,然后在查询时用二分查找定位左右边界蜡烛。
思路是:
- 先遍历字符串,把所有
|的下标收集到candle_pos。 - 仍然用前缀和统计盘子数量。
- 查询
[l, r]时,在candle_pos中二分查找第一个大于等于l的位置作为左边界,最后一个小于等于r的位置作为右边界。
参考代码:
python复制from bisect import bisect_left, bisect_right
candle_pos = [i for i, ch in enumerate(s) if ch == '|']
for l, r in queries:
a = bisect_left(candle_pos, l)
b = bisect_right(candle_pos, r) - 1
if a >= b:
ans.append(0)
else:
left_pos = candle_pos[a]
right_pos = candle_pos[b]
ans.append(plate_prefix[right_pos] - plate_prefix[left_pos])
这里要注意,判断条件是 a >= b,不是 a > b。因为 a == b 代表区间里只找到了一根蜡烛,这时候不能被计数。
这种写法的优势是代码里少维护两个大数组,占用的辅助空间取决于蜡烛数量。代价是每个查询多了一次 O(log n) 的二分查找。如果蜡烛数量很少,这个方案甚至更省空间。两种方法都可以过题,我建议你至少把数组版本写熟练,因为数组版容易扩展到其他类似题目;二分版则更适合处理数据分布特别稀疏的情况。
7. 从这题延伸出的两点实战心得
这题写完之后,我自己总结了两个特别实用的点。
第一,前缀和并不只是用来“求区间和”的。当区间长度很大、查询次数很多,并且我们反复需要“数某个区间里某种元素的数量”时,就应该立刻想到前缀和。哪怕这个区间本身不直接是 [l, r],而是像本题这样要先转换成“两个蜡烛之间的区间”,本质依然是一次区间计数。你只需要把问题拆成“先找边界,再算计数”两步。
第二,处理边界时,把预处理数组定义成“前 i 个元素”比定义成“下标 i 之前所有元素”要顺手得多。我在很多区间题里都吃过这个亏:有时候用闭区间定义,写着写着就会忘记到底要不要加 1。后来我基本统一用“长度为 i 的前缀包含多少目标元素”这种方式。plate_prefix[b] - plate_prefix[a] 这种减法一旦写顺了,就不容易在边界上翻车。
最后再分享一个我自己的调试小技巧:写完代码后,不要直接拿大样例跑,先用短字符串把所有边界情况列出来,比如只有一根蜡烛、蜡烛在端点、查询区间完全不含蜡烛、查询区间首尾正好都是蜡烛,这四种情况跑通之后,再提交到力扣,基本能一次过。这题表面上是一道 2100 分左右的偏难题,但把前缀和和两个扫描数组理清楚之后,它其实特别适合用来复习区间计数类题目的通用套路。
