力扣2055这道题,我用前缀和做第一版的时候反而错了两次。原因很简单:题目里的“盘子”不是普通区间求和里那种每个元素都算数的元素,它要求盘子必须被两根蜡烛包住。换句话说,不能一上来就对查询区间做盘子数量的前缀和相减,得先把区间“裁剪”到有效蜡烛之间,再做统计。
这道题很适合当前缀和从“会写一维模板”进阶到“能处理边界条件”的过渡题,面试也比较常见。它能帮你把三件事一次练透:预处理数组怎么设计、边界值为什么不能漏判、区间查询怎么从O(n)降到O(1)。下文我会从题意拆解、前缀和与左右蜡烛数组的配合、完整代码实现、常见易错点排查这几个方面展开,最后把前缀和与差分这对概念的用途也捋一遍。
1. 题目核心拆解:为什么不能直接数区间里的盘子
1.1 先理解“被蜡烛夹住的盘子”到底是指什么
题目输入只有一个字符串s,里面的*代表盘子,|代表蜡烛,外加若干组查询。每个查询给一个下标区间[l, r],让我返回这个区间里满足条件的盘子数,条件是:盘子的左边和右边都至少有一根蜡烛,而且蜡烛也得在同一个查询区间内部。
我一开始理解错了,以为只要区间内出现两根蜡烛,中间不管有多少盘子都直接算进去,后来发现关键在“区间最外侧的蜡烛之外不能算”。举个例子:s = "*|*",查询[0,2]。肉眼扫过去,索引1是唯一的蜡烛,这个区间根本没构成“两根蜡烛中间夹着盘子”的结构,所以答案是0。但如果我用普通区间盘子数量前缀和去做,会得到区间内有1个盘子,直接返回1,这就错了。
再比如s = "|**|*",查询[0,4]。索引0和3各有一根蜡烛,中间索引1和2是两个盘子,索引4虽然也是盘子,但它在最右侧蜡烛3的右面,不能算作被夹住的盘子。所以正确答案是2,而不是区间总盘子数3。这道题真正要统计的,是区间内最左蜡烛和最右蜡烛之间的盘子。
1.2 暴力扫每个区间的做法为什么不够用
看到这种区间题,直觉是先对每个查询遍历子串,找到第一根蜡烛和最后一根蜡烛,再统计中间*的数量。思路没问题,问题是复杂度。如果字符串长度n和查询数量m都达到10^5,每个查询扫一次子串就是O(n*m),最坏情况到10^10次操作,基本不可能通过。
这时候自然会想到“预处理 + 查表”的思路。静态数组、字符串,配上一大堆区间查询,最典型的手段就是前缀和。可是前缀和能快速回答“某段区间里有多少个盘子”,不能直接回答“这段区间的有效蜡烛边界在哪里”,所以还需要辅助数组来解决边界定位问题。
1.3 题目真正想考察的预处理思路
把需求拆开看,会发现有两个独立问题:
- 如何快速知道某个位置右边最近的一根蜡烛在哪。
- 如何快速知道某个位置左边最近的一根蜡烛在哪。
只要预处理出这两个答案,再配合盘子数量的前缀和,每次查询就只需要做几次数组取值和一次减法,整体复杂度是线性的。这种“一次遍历生成辅助数组 + 前缀和做区间统计”的组合,刚好是前缀和类题目的进阶考法,力扣2055被归到前缀和标签下也就是这个原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 算法设计关键:左右蜡烛数组和盘子前缀和如何配合
2.1 一个数组不够,必须同时知道左右两个方向的最近蜡烛
有的解法会用“蜡烛下标数组+二分查找”,也就是把所有|的位置存到一个数组,查询时二分找到第一个不小于l的蜡烛下标,以及最后一个不大于r的蜡烛下标。这当然能过,而且代码也清晰。不过如果题目要求不依赖二分,或者想强化前缀和思想,用两个线性扫描数组更合适。
为什么一个数组不够?因为查询边界l和r的“身份”不一样。l是区间左端点,我需要从l向右找到第一根蜡烛,确保它不会被排除在区间外;r是区间右端点,我需要从r向左找到最后一根蜡烛。这是两个方向的问题,只存“左边最近的蜡烛”或只存“右边最近的蜡烛”都无法同时回答两端的需求。
2.2 预处理数组的构建过程
先定义两个数组:
leftCandle[i]:在下标[0, i]范围内,最靠右的蜡烛下标;如果不存在,则为-1。rightCandle[i]:在下标[i, n-1]范围内,最靠左的蜡烛下标;如果不存在,则为-1。
构建leftCandle时从左往右扫,用变量cur记录最后一次遇到的蜡烛下标。遇到|就更新cur,然后leftCandle[i] = cur。这样对于任意查询右端点r,leftCandle[r]直接告诉我区间内最右侧的蜡烛在哪。
构建rightCandle时从右往左扫,用变量cur记录从右往左最后一次遇到的蜡烛下标。遇到|就更新cur,然后rightCandle[i] = cur。这样对于任意查询左端点l,rightCandle[l]直接告诉我区间内最左侧的蜡烛在哪。
这两个数组的构建花费O(n),查询时是O(1),空间也是O(n)。整个过程其实很像“记录上一个更大的数”这类单调栈题的简化版,只是这里不需要栈,只需要一个滚动变量。
2.3 区间内盘子数量的最终公式
拿到一个查询[l, r]后,先找出:
leftBound = rightCandle[l],表示从l向右找,第一根蜡烛的位置。rightBound = leftCandle[r],表示从r向左找,最后一根蜡烛的位置。
这里有个容易混淆的地方:rightCandle[l]虽然名字带“right”,但它表示的是“在l右侧的蜡烛”,所以是区间左边界;leftCandle[r]表示的是“在r左侧的蜡烛”,所以是区间右边界。别把变量名和边界方向搞反。
接下来分情况:
- 如果
leftBound == -1,说明l到字符串末尾都没有蜡烛,答案必然是0。 - 如果
rightBound == -1,说明字符串开头到r都没有蜡烛,答案也是0。 - 如果
leftBound >= rightBound,说明区间里不存在一对有效的左右蜡烛。最典型的情况是区间内只有一根蜡烛,或者根本没有蜡烛,答案同样是0。
只有当leftBound < rightBound时,才说明区间内至少存在两根蜡烛。此时可以把问题看成:查询区间[l, r]的有效统计范围被压缩到了[leftBound, rightBound],因为leftBound左侧的盘子没有被左边蜡烛夹住,rightBound右侧的盘子没有被右边蜡烛夹住,都不应该算。
再使用盘子前缀和计算[leftBound, rightBound]之间的盘子数,就得到答案。
2.4 为什么中间被其他蜡烛再分段也不会漏算
有人可能会担心:[leftBound, rightBound]之间如果还有更多蜡烛,比如||**|中间的盘子是不是要按蜡烛切分成多段分别统计?其实不用。对于[leftBound, rightBound]内任意一个盘子位置x,它左边一定有leftBound这根蜡烛,右边一定有rightBound这根蜡烛,所以它天然满足“被两根蜡烛夹住”的条件。就算中间插入了其他蜡烛,也不影响这个盘子符合题目要求。
题目没有要求盘子必须和蜡烛相邻,也没有要求只能统计“同一段”里的盘子,只是说“两支蜡烛之间”的盘子都算。在左右边界都确定的情况下,边界之间的所有盘子都满足要求。这就是为什么可以直接拿整个[leftBound, rightBound]区间做盘子前缀和相减。
3. 代码实现与样例手推:从Python到C++
3.1 先看Python完整解法
直接上代码,注释我写得比较细,方便对照前面的思路:
python复制class Solution:
def platesBetweenCandles(self, s: str, queries: List[List[int]]) -> List[int]:
n = len(s)
# left_candle[i]: [0, i]范围内最靠右的蜡烛下标,不存在则为-1
left_candle = [-1] * n
cur = -1
for i in range(n):
if s[i] == '|':
cur = i
left_candle[i] = cur
# right_candle[i]: [i, n-1]范围内最靠左的蜡烛下标,不存在则为-1
right_candle = [-1] * n
cur = -1
for i in range(n - 1, -1, -1):
if s[i] == '|':
cur = i
right_candle[i] = cur
# pre[i+1] 表示 s[0..i] 中的盘子总数,使用n+1长度方便做差
pre = [0] * (n + 1)
for i in range(n):
pre[i + 1] = pre[i] + (1 if s[i] == '*' else 0)
ans = []
for l, r in queries:
left_bound = right_candle[l] # 从左端点向右看的第一根蜡烛
right_bound = left_candle[r] # 从右端点向左看的最后一根蜡烛
if left_bound == -1 or right_bound == -1 or left_bound >= right_bound:
ans.append(0)
else:
# [left_bound, right_bound]内的盘子数
ans.append(pre[right_bound + 1] - pre[left_bound])
return ans
需要注意,这里pre[i + 1] = pre[i] + (1 if s[i] == '*' else 0)的意思是:pre[k]代表前k个字符中盘子总数。于是区间[left_bound, right_bound]内的盘子数就是pre[right_bound + 1] - pre[left_bound]。如果换一种前缀数组写法,pre[i]代表[0..i]范围内的总数,那么减法时要小心下标偏移,这也是最容易写错的地方之一。
3.2 C++实现与STL写法
C++版本质上没有任何区别,只是注意vector的初始化和查询结构体的类型:
cpp复制class Solution {
public:
vector<int> platesBetweenCandles(string s, vector<vector<int>>& queries) {
int n = s.size();
vector<int> leftCandle(n, -1), rightCandle(n, -1);
int cur = -1;
for (int i = 0; i < n; ++i) {
if (s[i] == '|') cur = i;
leftCandle[i] = cur;
}
cur = -1;
for (int i = n - 1; i >= 0; --i) {
if (s[i] == '|') cur = i;
rightCandle[i] = cur;
}
vector<int> pre(n + 1, 0);
for (int i = 0; i < n; ++i) {
pre[i + 1] = pre[i] + (s[i] == '*' ? 1 : 0);
}
vector<int> ans;
for (auto& q : queries) {
int l = q[0], r = q[1];
int leftBound = rightCandle[l];
int rightBound = leftCandle[r];
if (leftBound == -1 || rightBound == -1 || leftBound >= rightBound) {
ans.push_back(0);
} else {
ans.push_back(pre[rightBound + 1] - pre[leftBound]);
}
}
return ans;
}
};
Java写法和C++几乎一样,只是把vector换成数组或ArrayList,这里不再重复。需要提醒的是,如果是在面试手写,一定要自己把leftCandle和rightCandle的语义随口说清楚,边说边写,不然容易把两个数组搞混。
3.3 官方样例完整手推
拿题目自带的示例:s = "**|**|***|",查询queries = [[2,5],[5,9]]。
先把字符下标列出来:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| 字符 | * | * | * | * | * | * | ||||
| 预处理pre | 0 | 1 | 2 | 2 | 3 | 4 | 4 | 5 | 6 | 7 |
注意上表的pre从下标0开始,pre[0]=0,pre[i]表示前i个字符里的盘子总数。因为n+1长度的前缀数组,最后一格pre[10]=7表示整个字符串有7个盘子。
再看leftCandle数组,从左往右记录“当前位置左边最近的蜡烛”:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| leftCandle | -1 | -1 | 2 | 2 | 2 | 5 | 5 | 5 | 5 | 9 |
再看rightCandle数组,从右往左记录“当前位置右边最近的蜡烛”:
| 下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
|---|---|---|---|---|---|---|---|---|---|---|
| rightCandle | 2 | 2 | 2 | 5 | 5 | 5 | 9 | 9 | 9 | 9 |
第一个查询[2,5]:
leftBound = rightCandle[2] = 2。rightBound = leftCandle[5] = 5。- 因为
2 < 5,所以需要统计[2,5]之间的盘子。 pre[5+1] - pre[2] = pre[6] - pre[2] = 4 - 2 = 2。- 结果2,符合预期。
第二个查询[5,9]:
leftBound = rightCandle[5] = 5。rightBound = leftCandle[9] = 9。- 因为
5 < 9,统计[5,9]之间的盘子。 pre[10] - pre[5] = 7 - 4 = 3。- 结果3,符合预期。
手推一遍之后会发现,核心逻辑其实就三句话:查左侧最近蜡烛,查右侧最近蜡烛,做一次前缀差。真正要小心的全是边界条件。
4. 常见易错点与提交前实测建议
4.1 复杂度与在线表现
这个解法的时间是O(n + q),空间是O(n)。n是字符串长度,q是查询数量。实际跑的时候,即便n和q都到10万级别,也只是几十万量级的操作,属于非常轻松的水平。相比暴力法每次查询都重新扫区间,优势非常明显。
我自己的习惯是:先在本地写好一个暴力版本,用随机小数据对拍,确认优化版本正确后再提交。因为这类题目代码量不大,但边界条件很暗坑,直接提交容易红名。
4.2 最容易翻车的五种情况速查
下面这张表是我刷这道题时亲自踩过或围观别人踩过的坑,强烈建议提交前逐条检查:
| 场景 | 容易出现的错误 | 正确做法 |
|---|---|---|
| 查询区间内没有任何蜡烛 | 直接对原区间做前缀差,返回一堆盘子数 | 检查到leftBound或rightBound为-1时返回0 |
| 查询区间内只有一根蜡烛 | 认为有一个边界就算有效 | 必须保证leftBound < rightBound |
| 把leftCandle[r]写成rightCandle[r] | 拿到的蜡烛可能在r右侧,不在查询区间里 | 右端点应该找它左边最近的蜡烛,用leftCandle[r] |
| 前缀数组下标混乱 | 计算差值时越界或漏算端点 | 固定成pre[i]表示前i个字符,区间差用pre[R+1]-pre[L] |
统计盘子时把*判断成` |
` | 前缀数组全算成蜡烛,输出结果对不上 |
第四个问题尤其值得展开。很多写前缀和的新手会纠结:pre[i]到底表示前i个字符还是到i为止?我的方法是“写成n+1长度,pre[0]=0,pre[i]代表前i个字符的总量”,这样区间[L,R]的统计永远是pre[R+1] - pre[L],不需要考虑L=0时的负下标。
4.3 用暴力代码做本地对拍
对拍能帮你快速暴露问题。下面这段暴力代码可以作为参考答案,随机生成字符串和查询,再和前缀和方案的结果对比:
python复制import random
def brute_force(s, queries):
res = []
for l, r in queries:
cnt = 0
for i in range(l, r + 1):
if s[i] == '*':
has_left = any(s[j] == '|' for j in range(l, i))
has_right = any(s[j] == '|' for j in range(i + 1, r + 1))
if has_left and has_right:
cnt += 1
res.append(cnt)
return res
def random_check(times=1000):
for _ in range(times):
n = random.randint(1, 15)
s = ''.join(random.choice('*|') for _ in range(n))
q = random.randint(1, 10)
queries = []
for _ in range(q):
l = random.randint(0, n - 1)
r = random.randint(l, n - 1)
queries.append([l, r])
sol = Solution()
if sol.platesBetweenCandles(s, queries) != brute_force(s, queries):
print("mismatch:", s, queries)
return
print("all ok")
这个对拍会随机生成大量小规模用例,暴力解复杂度不高,足够完成验证。我自己刷前缀和类题目时,只要一次对拍通过,基本上提交就不会再错。
4.4 另外两个容易忽略的测试用例
写完代码后,我建议除了官方的两个查询,再手动补上这几组:
s = "***",任意查询都应该返回全0,因为根本没有蜡烛。s = "|*",查询[0,1],区间里只有一根蜡烛,结果应为0。s = "*|*|*",查询[1,3],区间是|*|,中间盘子数为1。s = "||",查询[0,1],两根蜡烛中间没有盘子,结果是0。
这几个用例能把大部分边界条件都覆盖到。尤其最后一个例子,虽然leftBound=0、rightBound=1满足L<R,但因为中间没有盘子,前缀差自然算出0,很多实现也能通过。
5. 从2055题延伸开:前缀和、差分与后续题目怎么练
5.1 前缀和与“前缀表达式”不是一回事
有时候会看到这样的热搜描述:“算术表达式有前缀表示法、中缀表示法和后缀表示法等形式。日常使用的算术表达式是……”这是编译原理或数据结构里的表达式概念,和算法里的前缀和并不相同。前缀表示法是指运算符写在操作数前面的波兰表示法,比如+ 1 2;而前缀和是把数组的累积结果存起来,用于快速回答区间和。两者只是都带了“前缀”两个字,实际解决的问题毫无关系。
力扣2055用到的是后者,它的核心是“把任意区间盘子数变成两个前缀值的差”。理解清楚这一点,刷题时才不会被网上的名词干扰。
5.2 前缀和与差分为什么会经常一起出现
如果说前缀和解决的是“静态区间求和”,那么差分解决的是“多次区间增量后求最终数组”。差分数组可以理解为前缀和的逆运算:对原数组做差分,再对差分数组求前缀和,能还原出原数组。经典题目如“航班预订统计”“拼车”等,都是先构造差分数组完成区间加减,最后求一次前缀和得到结果。
力扣2055没有任何增量更新,输入字符串从头到尾不变,所以它不需要差分,只需要只读的前缀和。这也提示我们:遇到题先判断数据是否“静态”。数据静态、查询很多,优先前缀和、哈希表、排序预处理;数据动态且有频繁区间修改,才考虑树状数组、线段树或者差分。
5.3 刷完这道题之后推荐的扩展练习
如果这题已经能一遍过,可以顺着这个思路刷下一组题目:
303题:区域和检索-数组不可变。最基础的一维前缀和,适合校验前缀数组写法。
304题:二维区域和检索-矩阵不可变。练习二维前缀和容斥公式。
1109题:航班预订统计。典型的差分题,区间增量后再求前缀和。
1442题:形成两个异或相等数组的三元组数目。用前缀异或做哈希优化,能加深对“前缀思想”的理解。
这几道题做完,你会发现自己看到“子数组”“子区间”“区域查询”这些词时,第一反应不再是暴力遍历,而是先想能不能通过预处理把查询时间压下来。这个思维转变,比单纯记住某道题的答案重要得多。
5.4 我个人刷这题的一个小习惯
最后分享一个实际工程里很有用的习惯:凡是做区间统计的题,先明确“区间端点本身算不算在结果里”。这题的坑就是端点盘子不能随便算,必须被蜡烛夹住。我后来养成了把所有下标区间的含义写成左闭右闭还是左闭右开、前缀数组长度是n还是n+1、缺失值用什么表示,这些“约定”先写下来,再动手写代码。约定固定后,很多边界问题都会自然消失。做题是这样,写业务代码的区间计算也是这样,先定规矩,再谈逻辑。
