星期六爬起来打周赛的人都有一种默契:题可以不会,但一定要知道它卡在哪。第484场周赛的Q2,题号3804,标题是“中心子数组的数量(Count the Number of Centered Subarrays)”。这个题名本身就有迷惑性,我一开始差点按“回文子串”去处理,结果审完题发现完全是另一码事:它不是判断左右对称,而是在比左右两段元素和。如果你也习惯性地往中心扩展、回文匹配那边想,这篇文章值得看完。我会把题目拆成数学条件,给出可以直接抄的Python解法,再把赛场上看不见的边界情况和坑一并说清楚。
1. 从题意切入:中心子数组到底在比较什么
1.1 把“中心”翻译成数学语言
题目里的“中心”,官方定义是一个下标k,但不是随便拿个下标就能当中心。它要满足一个非常具体的条件:子数组nums[l..r]以k为中心时,左侧那一段nums[l..k-1]的元素和,必须等于右侧那一段nums[k+1..r]的元素和。写成式子更直观:
sum(nums[l..k-1]) == sum(nums[k+1..r])这里l和r分别是子数组的左端点和右端点,满足l <= k <= r。注意几个容易忽略的细节:第一,左右两段允许为空,所以单个元素组成的子数组天然满足条件,因为空集的和是0,两边都是0;第二,题目限定左右两段长度相同,也就是说,k必须是子数组的正中间那个位置,不存在“偏向左边一个元素”这种偷懒的中心;第三,这里的比较是元素和相等,不是元素值镜像相等,所以拿“回文”的思路去套会直接跑偏。
我第一次做这个题时,心里os是“这不就是中心扩展模板题吗”,结果仔细一算才发现,回文中心扩展验证的是nums[k-d]==nums[k+d],而这里验证的是两段区间的总和。一个是逐位比较,一个是区间求和,差的不是一星半点。这也提醒我,周赛Q2这类题,命名往往会引导你走向某个熟悉的套路,但真正决定难度的反而是那句看似平淡的条件描述。
1.2 一个例子把答案算明白
光说定义不够直观,我用手算一个例子。nums = [1, 2, 1],逐个数一下中心子数组:
- 中心k=0,子数组[0,0]:左右都为空,左右和都是0,成立。
- 中心k=1,子数组[1,1]:左右都为空,成立。
- 中心k=1,子数组[0,2]:左段是nums[0..0]=[1],和是1;右段是nums[2..2]=[1],和是1;相等,成立。
- 中心k=2,子数组[2,2]:左右都为空,成立。
所以答案是4。这里没有别的组合了,比如子数组[1,2],它不是奇数长度,根本不存在一个整数下标k能当正中间;子数组[0,1]同理。这个例子说明一点:按题目的“中心”定义,其实只考虑奇数长度的子数组。这个结论后面写代码时会省掉很多重复判断。
再看一个稍微复杂的数组[1, 2, 3, 2, 1]:
- k=2,d=1:左段[1]和2,右段[1]和2,相等,成立。
- k=2,d=2:左段[1,2]和3,右段[2,1]和3,相等,也成立。
同一个中心下有两层满足条件,这说明一个关键点:找到一组相等之后不能立刻break,必须继续往外扩展。这个坑我在后面会专门再讲。先把结论记住,它是本题正确率下降的元凶之一。
2. 别急着写中心扩展,先想清楚统计逻辑
2.1 为什么不能先枚举区间再找中心
很多人的第一反应是暴力枚举所有子数组,然后对每个子数组找它中间那个位置,判断左右和是否相等。这个思路不是不能用,但复杂度很难看。枚举所有子数组O(n²),再对每个子数组计算左右两段的和,哪怕用前缀和把求和降到O(1),总复杂度也是O(n²)。有人觉得O(n²)还行,但实际比赛里n往往给你放到10的五次方量级,O(n²)直接超时。
更关键的问题在于,先枚举区间再找中心,会带来重复计算。比如数组[0, 1, -1, 0]里,子数组[0,3]以0为中心成立,如果以另一个位置作中心也可能成立,一个区间被反复处理,逻辑上就容易乱。反过来,如果中心先定下来,剩下的只是向两边扩展,每个子数组只会被它的唯一中心处理一次,天然避免了重复。
所以这类题的正向思路是:别从区间出发,从中心出发。把遍历的主体从“子数组”换成“中心下标k”,然后再向外试探左右边界。这个思路的普适性很强,很多所谓“统计满足某某条件的子数组”的题,只要条件是围绕某个中心成立的,都可以优先尝试枚举中心。
2.2 固定中心之后,指针怎么走
固定中心k之后,问题就变成:从k出发,向左扩展一步,向右扩展一步,每次比较左右两段的和。这里我推荐用两个滚动变量维护左右和,而不是每次都重新求和。
具体来说,初始时左右指针分别指向k-1和k+1,左右和都是0。每扩展一轮,就把nums[l]累加到leftSum,把nums[r]累加到rightSum,然后比较这两个和。如果相等,答案加一。接着继续向两边扩展,直到左指针越界或右指针越界。
这个过程的优点是空间占用只有O(1),不需要额外数组。缺点是每个中心可能要扩展O(n)次,所以总复杂度是O(n²)。但在周赛Q2的常见数据范围里,这个复杂度恰恰就是正解。后面我会仔细算一笔账,看O(n²)在这个题里到底能不能过。
这里还有一个细节:由于每轮都同时扩展左右两边,所以子数组的长度一定是奇数。想清楚这一点后,代码里甚至不需要显式判断长度奇偶,扩展循环天然保证了这一点。
2.3 相等也不能提前停:这是最大的坑
我在第1节里已经预告了这个坑。很多第一次做这道题的人,包括我,都容易在判断到leftSum == rightSum之后,顺手写一个break,认为这一层满足条件就可以收工了。这在回文计数里通常是对的,因为回文要求逐位相等,一旦不等就不可能再相等;但这里是区间和,情况完全不同。
看这个例子,nums = [1, 2, 3, 2, 1],中心k=2:
- 向外扩一层,左和是2,右和是2,第一次相等。
- 继续向外扩一层,左和变成1+2=3,右和变成2+1=3,仍然相等。
如果第一层相等就break,第二层就被漏掉了,答案直接少1。这种“连续多层都相等”的情况在数组元素和比较随机会出现的概率并不低,尤其是元素值包含0或者正负抵消时。实际上,一旦左右和相等后继续扩展,你仍然有可能再次相等,因为新增的左右两个元素对和的贡献可能恰好相同;也可能不相等,那这一层不计入答案,但也绝不能回头。
所以循环条件只有一个:只要左右指针还在数组范围内,就一直扩下去。相等就记录,不相等就跳过,全部处理完再退出循环。这是本题实现上的核心心得。
3. 参考实现与复杂度分析
3.1 Python解题代码
下面是我在赛场上最终提交的版本,思路就是枚举中心+向外扩展。代码不长,但每一步都对应前面说的几个要点。
class Solution: def countCenteredSubarrays(self, nums: List[int]) -> int: n = len(nums) ans = 0 for k in range(n): # 长度为1的子数组,左右都为空,恒成立 ans += 1 l, r = k - 1, k + 1 left_sum = 0 right_sum = 0 while l >= 0 and r < n: left_sum += nums[l] right_sum += nums[r] if left_sum == right_sum: ans += 1 l -= 1 r += 1 return ans这个代码有几点值得解释。第一,ans初始加1是在处理单个元素子数组,而不是把k遍历时额外判断,这比写一堆if要清晰。第二,left_sum和right_sum是在循环内累加的,天然就是当前k对应的左段和右段的和,不需要每次重新算。第三,while循环里无论当前层是否相等,都要继续移动指针,直到数组边界,这避免了漏算。
空间上只用了几个整数变量,属于真正的O(1)额外空间。时间上,最外层遍历n个中心,每个中心最多扩展约n/2次,所以是O(n²)。如果你担心大数相加溢出,Python的int没有任何问题;如果用C++或Java,记得开long long,因为两边元素和可能超出int范围。
3.2 复杂度如何计算,现场怎么判断是否可行
看到O(n²),有人会本能觉得不够优。但竞赛里没有绝对的最优,只有题目约束下的可行。判断可行性的方法很简单:看数据范围。如果题目给出n <= 10^4,那么O(n²)最坏是10^8量级的循环,加上每层只有几次加法比较,C++和Java在1秒内能过,Python在优化较好的情况下也能过,如果用PyPy更稳。
如果n给到5×10^4,O(n²)就是2.5×10^9,这就基本没戏了,必须找更优的做法。不过从周赛Q2的常见分布来看,中心扩展O(n²)往往就是正解方案之一,因为第二题通常不会直接考一个需要高级数据结构才能过的算法。拿到题先花30秒看一下n的规模,再决定写什么,这个习惯比多背几个模板都管用。
我个人在现场的判断标准是:如果总操作量在5×10^7到10^8之间,就可以直接写;如果超过5×10^8,先停下来想想有没有更聪明的办法。这个数字再配合语言常数,基本能预测一个解法的命运。
3.3 如果中心允许元素间隙,怎么扩展
有读者可能会问:如果题目把中心定义放宽,允许中心落在两个元素之间的“间隙”上,那偶数长度的子数组要不要统计?这个问题看起来很合理,因为部分“中心扩展”类的题目都同时处理两种中心。
好在改造成本极低。元素中心的代码是左右指针从k-1和k+1出发;间隙中心则把左右指针从k和k+1出发,初始左右和都是0。也就是把数组当成“元素与元素的间隙也有资格当中轴”来对待。判断逻辑完全不变,只是每一轮扩展多了一种中心来源。如果题目确实要求统计所有“中心子数组”,而不仅限于以元素为中心,那你就在主循环里多跑一趟间隙中心扫描。
但回到这个题本身,我倾向于认为题目定义的中心就是下标k,不是间隙。原因很简单:题名是“中心子数组”,强调的是子数组本身有一个中心元素;如果包含间隙中心,通常会在示例里明确给出偶数长度的情况。现场不确定时,可以用第二个示例反推,一般就能判断清楚。
4. 这类题的隐藏考点:让和的单调性失效的负数
4.1 负数为什么能干扰人的第一直觉
如果你已经习惯滑动窗口、双指针这类技巧,会不自觉以为“一边增长一边比较”的题目里,和一定是单调变化的。但区间和没有任何单调性可言,因为它累加的元素是可正可负的。负数的出现会让leftSum或rightSum忽大忽小,前面的相等不代表后面永远相等,前面的不相等更不代表后面永远不相等。
这一点从题目条件就能推出来:如果数组元素全为非负数,那么扩展到相等后继续扩展,倒是大概率会变得不相等,提前break在多数情况下碰巧能过几个测试点,但仍然是错的,因为即便全是非负数,也可能出现像[1,2,3,2,1]这种连续两层相等的情况。一旦数组里有负数,错误率会成倍上升。所以负数是这道题最好的“防无脑break机制”。
4.2 带负数的裂心样例
看一个负数把直觉击碎的样例。nums = [0, 1, -1, 0],这个数组很短,但足够说明问题。
手动数一下:
- 所有长度为1的子数组都成立,共4个。
- 子数组[0,2],也就是[0,1,-1],中心1:左段[0]和0,右段[-1]和-1,不等。
- 子数组[1,3],也就是[1,-1,0],中心2:左段[1]和1,右段[0]和0,不等。
- 子数组[0,3],也就是[0,1,-1,0],长度4,没有整数中心下标,不参与。
所以答案是4。如果尝试提前break,反而不会有问题;但要验证的地方在于,负数让leftSum和rightSum经常出现“这层不等、再扩一层反而相等”的缠绕情况。这种样例就是用来卡那些把问题想得过于简单的人。
4.3 一段现场调试实录
我在周赛时第一次提交就挂在了负数样例上。当时的错误代码长这样:
if left_sum == right_sum: ans += 1 break结果返回的答案明显偏小。我一开始还以为是break只影响当前中心,后来构造了[1,2,3,2,1]这个例子,才发现同中心下两层都相等,break直接把后面的答案吞了。删掉break之后,又遇到一个新的疑惑:扩大范围之后leftSum和rightSum可能又变小,这个现象在只有正数时根本不会出现,我当时还怀疑是不是累加顺序写错。
排查方法很简单,我把自己当成一台“人工计算机”,在草稿纸上把每个中心对应的leftSum和rightSum逐层列出来。也就是从中心向外画一个两层表格,记录每一步的左右和。只要表格对得上,代码逻辑就是对的;对不上,那就是指针移动或累加方式有问题。这个调试方法比打断点快很多,尤其适合区间和类问题。
5. 周赛实战心得与其他问题的迁移
5.1 Q2的时间分配和策略
周赛一共四题,Q2往往是很多人能否稳定三题的关键分水岭。我的习惯是,第一题如果三分钟内没有一次通过,马上放掉,继续做Q2;如果Q2在15分钟内没有思路,也先放掉去扫一眼Q3,但这里有一个保留条件:像这种“中心子数组”题,核心思路几乎在标题里就暴露了。唯一需要确认的就是中心判定方式。
拿到题我一般会先干三件事:第一,圈出“中心”前后的限定语,判断是元素和相等还是值对称;第二,看数据范围,预估复杂度;第三,动手写一个n=5的临时数组,手算答案。这三步做完,Q2的撰写思路基本就清晰了。遇到这种题千万不要先想有没有O(n log n)的高级解法,先把能不能枚举中心、能不能中心扩展这个问题想清楚,多数Q2的正确答案就是这么朴素。
5.2 同一个模式还能解决哪些问题
中心扩展+区间和判断这个组合,在LeetCode上有一批近亲题。最典型的是回文子串计数,区别是那里的扩展比较的是对应元素是否相等;还有找最长有效括号、按中心统计山脉数组这类变种。它们的共同点都是“枚举可能成为中心的位置,然后向外扩展判断条件”。
如果某个题的条件是“以某个下标为中心,左右两边各自满足某个性质”,那大概率都能用这个模式套。真正需要思考的是性质是什么,以及这个性质在扩展时是否增量可维护。增量可维护就用双变量累加,不能增量维护就得配合前缀和或哈希表。我在另一道题里写过配合哈希表的版本:先固定中心,把一侧的所有可能和放进哈希表,再扫描另一侧。那种做法能把某个环节的复杂度降下来,但整体往往还是O(n²),只是常数更小。
把中心扩展这个基本功练扎实,它的价值不止于这一道3804。以后遇到任何带“中心”“对称”“中轴”关键词的题,你都会先想到它,再根据具体条件做微调。这个思考路径,才是刷周赛真正的收获。