第一次在洛谷题库里看到P1114这个“非常男女”计划,我下意识以为又是一个搜索剪枝题,结果读完题发现核心就一句话:有一个长度为n的0/1串,1代表男生,0代表女生,找一个最长的连续子串,使得子串里1和0的个数一样多,输出这个长度。n一旦给到10^5甚至更大,枚举所有子段的做法就一定超时。可是当你把男生编码成+1、女生编码成-1,再看前缀和,事情突然变简单了:两个位置的前缀和相等,中间那段就是男女平衡的合法区间。这篇文章就把这道题为什么能用前缀和秒掉,数组偏移和哈希表两种实现怎么选,以及新手最容易写错的那几个边界点讲明白。无论你刚入门洛谷,还是刷题一段时间想找点理论支撑,这篇都适合当做一个“前缀和+哈希”的标准模板收藏。
1. 本能思路与它们各自的死穴:O(n^3)、O(n^2)、双指针都不行
1.1 题目本质一句话读懂
P1114的题面其实很短:一个长度为n的0/1序列,1和0分别代表两种性别,要求找出最长的连续子串,让子串内两种性别的人数一样多。输出的是最长长度,不需要输出具体是哪一段。两个容易忽略的点:一是“连续”,意味着不能跳过人,也不能调整顺序;二是只要长度。有了这两个点,问题的候选解就是O(n^2)个连续区间,不需要去枚举排列组合。明白这一点后,复杂度能不能降下来,就成了下一步的核心问题。
很多人初读这道题会以为要回溯枚举所有男生女生的组合,这完全是误解。组合数会爆炸,但连续区间的数量只有n(n+1)/2个,这已经是一个完全不同的量级。我们接下来的优化,就是在这些连续区间里找规律,而不是真的一个个去看。
1.2 直接枚举的时间账:从O(n^3)到O(n^2)
最直接的暴力是三层循环:左端点l、右端点r,然后再把l到r之间重新数一遍。复杂度O(n^3),n只要到1000,操作量就在10^9级别,基本不可能通过。稍微优化一下,提前维护两个前缀数量数组cnt0和cnt1,区间内的0/1个数用O(1)算出来,复杂度降到O(n^2)。很多新手以为O(n^2)在n=10^5时只是“常数有点大”,其实不是:n=10^5时区间数大约是5×10^9,就算每一次判断只需要几条CPU指令,整体也要几十秒甚至更久,妥妥超时。
| 做法 | 统计区间0/1个数的方式 | 时间复杂度 | n=10^5时的大致操作量 |
|---|---|---|---|
| 三层循环 | 每次重新数 | O(n^3) | 约10^15 |
| 枚举区间+前缀数量数组 | O(1)查询 | O(n^2) | 约5×10^9 |
| 前缀和+哈希 | 一次遍历 | O(n) | 约10^5 |
所以这道题的难点不是“会不会写暴力”,而是能不能识别出第三种做法的来由。看到这张表你就应该明白,不是机器跑得不够快,而是算法级别就输了。
1.3 双指针为什么在这里失灵
和很多最长区间题一样,有人会想到滑动窗口:右指针往前挪,左边根据平衡条件调整。但这个题里窗口的平衡程度没有单调性。右指针加一个人,窗口可能从不平衡变成平衡,也可能从平衡变成不平衡;左指针移动时也没有任何保证能恢复到平衡。滑动窗口能work,前提通常是“窗口越大越容易满足条件”这类单调性,比如最多K个不同字符的最长子串。而这里要的是0和1个数完全相等,长度增加并不能保证平衡,所以双指针思路从根上就不成立。
我第一次做这道题时也试图用双指针,后来发现没有单调性才转向前缀和。这个试错过程其实很有价值:它逼着你接受一个事实,如果区间端点之间没有直接关系,就必须找一个能把两端联系起来的新变量。
2. 前缀和视角:把“男女人数相等”翻译成“两个前缀和相等”
2.1 用+1和-1给两种性别编码
把每个男生记为+1,每个女生记为-1。为什么不是用1和0?因为男女人数相等就等价于每个位置的权值加起来等于0。女生-1,男生+1,一个总和就把“两堆数量一样多”压缩成了一个数。你可以把+1想象成一次收入,-1想象成一次支出,某一段男女相等,就相当于这一段收支刚好抵平,净变化为0。
这个编码是整个题目的一个关键选择。如果还用1和0,判断“数量相等”需要同时比较两个数;而改成+1和-1之后,判断条件就变成了“总和是否为0”,变量少了一个,问题结构立刻清晰起来。
2.2 从“区间内和为零”推出“前后缀和相同”
定义前缀和s[i]表示前i个人的净和,s[0]=0。于是区间[l+1, r]的总和就是s[r] - s[l]。如果这个区间男女相等,那这个差值必须为0,也就是s[r]等于s[l]。反过来也一样成立。这样一来,问题被完全改写成:在s[0], s[1], ..., s[n]这n+1个数里,找到两个相等且下标距离最远的数。
这一步就是把连续子段和的问题,变成两个前缀和之间的关系。你可能见过很多题解直接写“前缀和相同”,背后的逻辑就是这条式子。别看它简单,一旦在纸上写出s[r] - s[l] = 0,整个优化方向就被锁死了:我们不需要枚举区间,只需要枚举每个前缀和的“最早出现位置”。
2.3 一个完整例子把转化过程看清楚
假设队列是男、女、男、男、女,编码成[1, -1, 1, 1, -1]。前缀和从s[0]开始依次是0, 1, 0, 1, 2, 1。肉眼观察:s[0]=s[2]=0,对应原序列第1到第2个人,男女各一个,长度2;s[1]=s[3]=s[5]=1,其中s[1]和s[5]相距4,对应第2到第5个人-1, 1, 1, -1,男生2个女生2个,长度4;s[4]=2只出现一次,无法配对。最大长度就是4。
如果直接看原序列,要人工枚举所有区间才能发现[女, 男, 男, 女]这一整段平衡;但把前缀和列出来,哪个值出现两次、距离多远,一目了然。这个例子很好地说明了“重新描述问题”的价值:原问题和转化后的问题在数学上是同一个,但在人的视野里难度完全不同。
2.4 这一小步背后其实藏着一个“映射压缩”思想
可能有人觉得这不就是转化了一下吗,有什么了不起?它的本质是:把“区间内部怎么分布”压缩成“端点处的累计值”。判断某个区间是否合法,不再关心中间每一个人,只要两端前缀和一致,中间的累计变化就是0。前缀和像是一本流水账,你不需要一笔一笔回顾中间消费,只看首尾余额就能知道这段时间是否净收支为0。
这种压缩思想在区间题里特别常见。很多看起来要枚举内部的题目,最后都是靠“两端点的某个值相等”来一锤定音。P1114的难度不高,但它把这种思想体现得极其直白,适合用来建立条件反射。
3. 一遍扫描求最远:哈希表与数组偏移的具体实现
3.1 为什么只留“第一次出现”的位置
现在目标是从左到右扫描s数组,找出每个前缀和值最早出现的下标。假设某个值x在位置p1 < p2 < ... < pk出现,能构成的最大长度是pk - p1。从左往右遍历时,当前下标i就是最新的pk,所以只需要知道p1是什么,就能用i - p1更新答案。因此,第一次遇见某个前缀和值时把它记下来;后面再次遇见,只更新答案,绝不覆盖p1。
这段逻辑是代码里最核心的地方,也是很多人写错的根源。记下了最早位置,后面的所有相同前缀和都能拿它当左端点,保证距离最大。它有点像“等老朋友”的思路:只要知道第一个到达的人站在哪,后来者就能自动算出距离。
3.2 用数组代替哈希表:负数下标整体平移
前缀和s[i]的取值范围是[-n, n]。为什么?因为每个位置贡献+1或-1,n个位置的累计和最多是n,最少是-n。数组下标不允许负数,所以把每个前缀和加一个偏移量n,得到pos = sum + n,范围变成0到2n。这样直接开一个长度2n+5的数组就能当哈希表用,不需要map,也不需要unordered_map。
这种写法的好处是快且稳,下标连续,不会碰到哈希冲突。很多竞赛题的整数范围如果已知,数组偏移都是首选。你不需要担心哈希函数、扩容问题,开好数组直接访问,性能上基本没有额外开销。
3.3 完整C++代码与逐段解释
#include <bits/stdc++.h> using namespace std; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin >> n; vector<int> first(2 * n + 5, -1); int sum = 0; // 当前前缀和 int ans = 0; // 最长长度 first[0 + n] = 0; // 前缀和0在第0个位置就出现过 for (int i = 1; i <= n; ++i) { int x; cin >> x; // 这里假设读入1表示男、0表示女;如果题面相反,把三元表达式反过来即可 sum += (x == 1 ? 1 : -1); int pos = sum + n; // 下标偏移 if (first[pos] == -1) { first[pos] = i; // 第一次出现,记录下标 } else { ans = max(ans, i - first[pos]); // 之前出现过,用当前下标减去最早下标 } } cout << ans << '\n'; return 0; }逐段解释一下。first数组长度写成2*n+5,就是为了容纳pos的所有可能取值。sum从0开始,对应s[0]。first[n] = 0是整个算法的起点,后面会专门讲它有多重要。每次读入一个人,更新sum,计算出pos = sum + n。如果这个前缀和值从未出现过,说明这是第一次遇到,把它对应的下标i存进first;如果已经出现过,说明从最早那个位置到当前这一段是男女平衡的,用i - first[pos]更新答案。整个循环下来,ans里存的就是最大长度。
3.4 用unordered_map实现时的注意点
如果不喜欢数组偏移,也可以用unordered_map<int, int> first,逻辑完全一样。但有两个地方要注意:第一,first[0]要提前设为0(在1-indexed写法里),不然同样会丢失起点;第二,查找和写入时最好用find判断,不要用中括号[]随意访问,因为[]在键不存在时会插入默认值,干扰计数。unordered_map在绝大多数情况下够用,但常数比数组大,极端情况下哈希可能退化,竞赛里能数组就数组。
如果题目给的是字符而不是数字,代码也很容易改:读入ch后,判断ch是男生还是女生,分别映射成+1和-1。核心逻辑完全不变。
4. 边界、长度定义与初始化:那些改错后让人抓狂的细节
4.1 first数组的尺寸:n还是2n?到底开多大
先确认pos = sum + n的范围:sum最小是-n,最大是n,所以pos最小0,最大2n。数组长度只要超过2n就行,写成2n+1都已经够了。不过为了少想边界,普遍直接写2*n+5。最怕的是写成n+5,那当sum为正且接近n时,下标直接越界,报错或者运行到未定义行为。这个问题在本地小数据测试时往往看不出来,因为小n下sum+n不会超过n+5,等数据一大就炸。
顺便提醒一下vector的初始化方式。vector first(2 * n + 5, -1);会把每个位置都初始化成-1,省去手动memset的麻烦。如果你用静态数组,记得用fill或者memset,否则默认值可能是0,后面的判断逻辑就全乱套了。
4.2 千万别漏掉 first[n] = 0 这个初始化
这是一个非常隐蔽的坑。前缀和s[0]=0是逻辑起点,必须在遍历前记录。如果漏了,第一个遇到前缀和0的实际位置会被当成“最早出现”,导致左边界整体右移。举例:序列男女男女,即[1, -1, 1, -1],前缀和是[0, 1, 0, 1, 0]。正确结果是4,整段平衡。漏初始化时,第一次出现前缀和0在i=2,之后i=4再遇0,答案变成4-2=2,明明整段都是平衡的,却丢掉了一半长度。
所以这道题的初始化不是可选项,而是算法的一部分。不写这一行,整个思路都是残缺的。这也是为什么很多看起来“逻辑没错”的代码,样例过了,交上去却WA。
4.3 区间长度是 i - first,不是 i - first + 1
还有人在算长度时犹豫。前缀和s[i]表示前i个人,如果s[j]=s[i]且j<i,那么满足条件的区间是第j+1到第i个人,人数等于i-j。比如前面例子里s[1]=s[5]=1,对应第2到第5个人,一共4个,公式就是5-1=4。如果写成5-1+1=5,就会把第1个人也错误算进去。之所以会在这里纠结,是因为大家常把“数组下标”和“第几个元素”混在一起。只要记住s[i]是前i个人,逻辑就清楚了。
这个错误在本地测试也可能很难发现,因为答案常常只差1,而题目只要求输出长度,不要求输出区间,肉眼很难看出是边界加错了还是判断写错了。我建议遇到这种题,心里默念一句:前缀和之差,隔的是中间那些元素,不包含左端点本身。
4.4 first记录“最早出现”,不能随手改成“最近出现”
再看一个常见错误。有人写成first[pos] = i; ans = max(ans, i - first[pos]);,结果答案永远是0,因为刚写入又拿它减自己。还有人写成ans = max(ans, i - first[pos]); first[pos] = i;,第一轮碰巧正确,但后面再把first更新成最近位置,后续遇到相同前缀和时距离就会偏短。这两种写法都违背了同一件事:first应该存“最早出现的位置”,而不是“最近出现的位置”。
正确写法必须把“首次出现”和“后续更新答案”分成两种情况:第一次遇到才写first,已经遇到过只更新ans。想清楚这一点,代码就不会越改越错。如果你发现自己写的版本在某种数据上答案总比暴力小,多半就是这里出了问题。
4.5 边界测试与输出
如果序列全为同一个性别,前缀和一直单向变化,没有两个位置相等,ans保持初始值0,正确输出0。如果存在相邻一比一,至少会有长度2的答案。做题时可以拿全0、全1、0101、1010、单个元素等小样例测一遍,这几个用例基本能覆盖初始化、长度计算和答案更新三处关键点。尤其是n=1时,输出必须为0,很多人在这个小用例上就能暴露出first数组初始化的错误。
5. 跳出P1114:前缀和差值模型的通用模板
5.1 通用问题:任意数组中最长的和为k的连续子段
P1114本质是“和为0的最长连续子段”。把0换成任意整数k,就得到一道更通用的问题:给定整数数组a,找最长的连续子段,使子段和等于k。解法是维护前缀和sum,并希望在当前位置i之前存在某个位置j,满足sum[j] = sum[i] - k。因为从j+1到i这段的和就是sum[i] - sum[j],正好等于k。如果k=0,需要的就是sum[j]=sum[i],和P1114完全一致。所以P1114不是孤立题,它背后是一个“前缀和差值模型”的最小样本。
这个推广很有意义。很多题表面上是“01串平衡”,换个包装就变成“整数数组和为0”或者“股票净值回到原点”,但解法骨架完全没变。能够识别出这一点,比多刷十道模板题更管用。
5.2 通用模板代码(0-indexed写法)
#include <bits/stdc++.h> using namespace std; int maxLenSubarraySumK(const vector<int>& a, int k) { unordered_map<int, int> first; first[0] = -1; // 数组开始之前的位置,前缀和0已经出现 int sum = 0; int ans = 0; for (int i = 0; i < (int)a.size(); ++i) { sum += a[i]; int need = sum - k; if (first.count(need)) { ans = max(ans, i - first[need]); } if (!first.count(sum)) { first[sum] = i; // 只记录第一次出现 } } return ans; }这段代码把核心逻辑讲得更通用。first[0] = -1是因为这里用的是0-indexed下标,-1表示“数组开始之前的那个虚拟位置”。每次遍历先算need = sum - k,如果need曾经出现过,就能用当前位置i减去最早出现位置得到长度。然后只有当前前缀和sum从未出现过,才把它写进first。这个模板和P1114的代码放在一起对比,能看到下标体系不同但思想完全一致。
5.3 还能套用到哪些场景
这类“前缀和+首次出现位置”模型能解决的问题很多。比如最长01平衡子串,就是P1114本身;找和为0的最长子数组,是常见的面试题;找和为k的最长子数组,是上面这个通用模板;带权匹配问题里,用+1/-1分别表示两种状态,求状态回到原点的最长段,也一样。二维平面上“两个维度差相等”的问题,有时候要把两个维度合拼成一个key,本质上还是同一个模型。
遇到这些题,第一步都是建立“某个位置的前缀和能代表什么”,第二步再想“两个端点前缀和满足什么关系”,之后就是查表的事。一旦养成了这个思维习惯,你会发现很多区间题都不再需要靠感觉硬猜。
5.4 关于对拍验证经验的一个小建议
最后说一个我自己的习惯:每次写完这类前缀和题目,我会顺手写一个O(n^2)的暴力版本,再写一个随机数据生成器,把两个程序跑在同一组随机01串上。如果答案不一致,按第4节那几个常见坑逐个排查。这个小流程多做几次,比对着题解改半天更有效。对拍有个好处,它能把错误样本缩得很小,通常长度在10以内就能暴露问题,定位起来非常快。
P1114本身的难度并不高,但它把“连续子段和”这个抽象概念讲得非常具体。只要你动手把数组偏移版本实现一遍,再看一眼sum-k模板,最后用暴力对拍验证一轮,前缀和的套路基本就能长在肌肉记忆里。以后遇到类似什么“最长平衡子串”“和为0的最长子数组”,你就知道它们其实都共享同一个解法骨架。