昨天帮群里一个刚开始刷洛谷的朋友调 P1114“非常男女”计划,他第一版直接暴力枚举,交上去 TLE;第二版改前缀和,但答案一直输出 0。我把代码拿过来扫了一遍,发现问题特别典型:他用来记录“某个前缀和最早出现位置”的数组默认值全是 0,而这个 0 既表示“第 0 个人位置的哨兵”,又表示“这个前缀和还没出现过”。两个含义撞在一起,前缀和 0 刚出现一次就被反复结算,自然不会得到正确答案。
这个题在洛谷的题单里不算难,位置也经常被放在“前缀和与差分”附近,但通过率一直不算高,就是因为里面有不少类似的细节坑。P1114 的核心思路其实是“前缀和 + 哈希 / 数组映射找最早出现位置”,它考察的并不是多高深的算法,而是你有没有把一个现实场景翻译成数学语言的能力。这篇文章我把完整的推导过程、代码细节、常见提交错误都梳理一遍,给正在刷洛谷算法题的朋友做一个参考。无论是准备 CSP-J、还是刚学数据结构想找入门题,这题都非常值得认真做一遍。
1. 题面理解:从“非常男女”到“区间和为 0”
1.1 题目一句话讲清楚
P1114 的原题描述读起来挺有意思,说的是有一排人站在队伍里,每个人用数字标记自己的性别,男生和女生分别对应 1 和 0 之类的一个二进制标记。现在要从队伍里找一段连续的位置,使得这一段里面男生人数和女生人数一样多,然后输出这一段最多能有多少人。
题目给定的数据规模通常是 n 不超过 100000,也就是最多十万个人站在一排。如果找不到任何一段满足条件的连续区间,就输出 0。
光看文字,这好像是一个很偏向统计的问题,但真正动手以后你会发现,它其实是一个典型的区间求和问题。这里的关键词有三个:连续、人数相等、最长。连续说明不能打乱顺序,人数相等说明要比较两类人的数量,最长说明我们希望在满足条件的所有子段里挑一个跨度最大的。这三个条件叠在一起,就决定了不能用简单的全局统计来解决。
1.2 最容易踩进去的直觉误区
很多新手第一次读完题的第一反应是先统计整个队伍里男生有多少、女生有多少,然后觉得答案和全局比例有关系。这个直觉是完全错误的。题目要看的是队伍中某一段连续区间,而不是随便把所有人分成两组。队伍里可能前 100 个人男女各一半,后面还有很多人性别比例完全失衡,但答案只跟前 100 个人这段有关,全局统计在这里起不到任何帮助。
还有人会想,既然只有两种性别,那我能不能排序后再找?这也不行。排序会破坏原有的连续顺序。题目说的“连续位置”,本质上是一种区间概念。你可以把它想象成在一串已经排好的数据上切一刀,拿出中间完整的一段,而不是允许你重新排列组合。
我见过不少初学者卡在第二步:枚举所有区间左端点和右端点,然后每个区间都重新数里面男生和女生的人数。这个做法逻辑上没错,但复杂度直接爆炸。后面我会详细算这笔账。
1.3 把“男女数量相等”翻译成等式
假设我们在考虑某一段连续区间 [l, r],这里 l 是左端点位置,r 是右端点位置。这一段里男生数量记为 man,女生数量记为 woman。题目要求 man = woman。
这里可以做一个小小但非常关键的转化:把男生看成数值 +1,把女生看成数值 -1。那么这段区间的元素和就是:
man × (+1) + woman × (-1) = man - woman
因为题目要求 man = woman,所以区间和必须等于 0。换句话说,“这段区间里男女数量相等”和“这段区间的加权和为 0”是完全等价的。
为什么要做这个转化?因为“男生数量等于女生数量”是一个由两个变量组成的条件,而把它变成“男减女等于 0”之后,就变成了单一指标。连续区间的“和”是非常适合用前缀和来维护的数据。一个二元条件被压缩成了一元数值,问题一下子简单了很多。
这里可以用一个生活化的类比来帮助理解。想象你在记录自己体重的净增长:体重增加记 +1,体重减少记 -1。如果某一天你的体重和一个月前完全一样,那么中间这段时间里增加的重量和减少的重量一定刚好抵消。性别序列也是一样,开头和结尾前缀和相等的话,中间这段正负号的数量就恰好抵消。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法的递进:从 O(n³) 到 O(n)
2.1 暴力枚举为什么注定过不了
最直观的做法是枚举所有可能的区间 [l, r],然后统计区间内男女数量,判断是否相等。伪代码如下:
text复制for l = 1 to n
for r = l+1 to n
man = 0
woman = 0
for i = l to r
如果第 i 个人是男生 man++ 否则 woman++
if man == woman:
ans = max(ans, r - l + 1)
这个三重循环的时间复杂度是 O(n³)。当 n = 100000 时,这显然是一个天文数字,根本不可能跑完。就算稍微优化一下,在内层循环移动 r 的同时动态维护 man 和 woman 的数量,能把复杂度降到 O(n²),但 n = 100000 时区间数量大约有 5 × 10⁹ 个,依然完全不可行。
我在刚开始学算法时也干过类似的事情,总觉得机器很快,十万个数据枚举所有区间应该能行。实际上 10 万的平方是 100 亿,这个数量级对普通竞赛评测环境来说是完全不可接受的。所以必须找到一种方法,把对每个区间的重复统计省掉。
2.2 前缀和让任意区间求和变成一次减法
前缀和是处理连续区间和问题最经典的工具。定义一个数组 pre,pre[0] = 0,然后从左往右扫描队列,记录下每一个位置的前缀和。第 i 个位置的前缀和等于前 i 个位置所有数字的和:
pre[i] = pre[i-1] + 性别值(i)
如果第 i 个人是男生,性别值记 +1;是女生,性别值记 -1。那么对于任意区间 [l, r],区间和可以写成:
pre[r] - pre[l-1]
如果这段区间满足男女数量相等,那么区间和是 0,于是:
pre[r] - pre[l-1] = 0
也就是:
pre[r] = pre[l-1]
这句话才是整道题的核心。我们不用真的去关心区间里面男男女女怎么排,只需要看两个前缀和相不相等。只要 pre[r] 和 pre[l-1] 相等,那么 [l, r] 必然男女数量相等。
这里有一个很自然的中间版本:先算出全部前缀和,再枚举所有可能的 l 和 r,检查 pre[l-1] 是否等于 pre[r]。这个做法的复杂度是 O(n²),比暴力 O(n³) 快了一些,但距离能通过十万数据还很远。
2.3 关键一步:只需要记录前缀和最早出现的位置
继续观察等式 pre[r] = pre[l-1]。我们在扫描到右端点 r 的时候,希望找到一个尽可能小的 l-1,使得 pre[l-1] 等于当前的 pre[r]。
对某一个固定的前缀和值,例如 3,如果它在多个位置都出现过,那么应该选哪个位置作为 l-1?答案是选“最早出现的那一个”。因为区间长度等于 r - (l-1),在 r 固定时,l-1 越小,区间越长。我们要的是最长区间,所以对于每一个前缀和值,只需要记住它第一次出现的位置,后面再遇到相同的值时直接拿当前下标减去最早位置,就能得到能形成的最大长度。
这个思想可以再换一个角度看。把前缀和画成一条上下波动的折线:读到一个男生,曲线上升一步;读到一个女生,曲线下降一步。两个位置的前缀和相等,意味着曲线在这两个位置处于同一“高度”。中间这段无论怎么上下起伏,净变化是 0,对应的就是男女数量相等的区间。如果曲线在某个高度出现过多次,想找最大跨度,自然取第一次到达这个高度和最后一次到达这个高度的位置。
这样算法就非常清晰了:
- 从第 1 个人扫到第 n 个人,同时维护当前前缀和 sum。
- 准备一个能存储“前缀和值 -> 最早出现位置”的容器。
- 如果当前 sum 之前出现过,就用当前位置减去最早出现位置,更新答案。
- 如果当前 sum 之前没出现过,就把当前位置记为这个前缀和的最早出现位置。
整个过程中每个人只被扫描一次,每次操作都是常数级别,所以时间复杂度是 O(n)。空间上只需要存储可能出现的不同前缀和,最多 O(n)。
2.4 为什么不能用滑动窗口或二分答案
有些同学学到双指针之后,看到“连续子段”就想用滑动窗口解决。这题还真不行。滑动窗口通常适用于窗口滑动的过程中,我们可以根据当前窗口状态决定左边界是否收缩。但这里的合法性判断是“区间内男女人数相等”,窗口内部的和可能为正、为负、为 0,没有单调性。你没法通过简单的移动左端点来保证下一个窗口一定更好。
还有人会想:答案长度是否满足二分性质?也就是如果能找到长度为 len 的合法区间,那么比 len 更短的区间是不是也存在?这不一定成立。举一个简单的例子,“男女男男”这个串,长度 4 的区间男女数量不相等,长度 2 的区间“男女”是合法的;反过来,如果一个长度 4 的区间合法,也不能推出长度 3 或者长度 2 的区间必定合法。所以合法区间的存在性对长度没有单调性,不能用二分答案。
这也提醒大家:并不是所有“求最值”的题都能二分。二分需要依赖单调性,这是先决条件。
3. 完整实现与代码细节:照着写就能过
3.1 处理负数前缀和:偏移量是关键
代码实现前必须想清楚一个问题:前缀和 sum 的范围是多少?
如果所有男生记 +1,那么最长可能出现连续十万人全是男生的极端数据,前缀和最大值到 +100000。反过来,连续十万人全是女生,前缀和最小到 -100000。也就是说,sum 的取值范围大约是 [-n, n]。
如果直接用 sum 作为数组下标去记录位置,sum 为负数时就会越界。所以要么使用 map / unordered_map 这种可以接受负数 key 的容器,要么给数组下标加一个足够大的偏移量。
我比较推荐新手先掌握数组偏移写法,因为它的常数更小、速度稳定,而且在很多竞赛题里数组哈希仍然是最可靠的方案之一。假设 n 最大为 100000,可以设置一个偏移量 offset = n + 2,再用一个长度为 2 * n + 5 的数组。遇到前缀和 sum 时,实际存取的 key 是 sum + offset。这样无论 sum 是最小值 -n 还是最大值 n,key 都在数组合法范围之内。
3.2 C++ 参考代码(数组偏移版)
下面这份代码是我在洛谷 P1114 上验证过的思路,核心部分很短,重点在于 firstPos 数组初始化和前缀和映射。
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
// 前缀和范围是 [-n, n],加偏移量把负下标挪到非负区间
int offset = n + 2;
vector<int> firstPos(2 * n + 5, -1);
int sum = 0;
int ans = 0;
// 哨兵:下标 0 位置的前缀和是 0
firstPos[offset] = 0;
for (int i = 1; i <= n; ++i) {
int x;
cin >> x;
if (x == 1) {
++sum; // 男生记 +1
} else {
--sum; // 女生转过成 -1,注意不能还保留 0
}
int key = sum + offset;
if (firstPos[key] != -1) {
// 之前出现过相同的前缀和,说明中间这段净变化为 0
ans = max(ans, i - firstPos[key]);
} else {
// 第一次出现,记录位置
firstPos[key] = i;
}
}
cout << ans << '\n';
return 0;
}
这段代码有几个细节值得单独说明。第一,firstPos 数组默认值是 -1,这表示“某个前缀和还没出现过”。而真实下标 0 是有含义的,它代表第 0 个人之前的空位置,也就是初始前缀和 0 出现的位置。二者必须严格区分。
第二,变量 i 从 1 开始计数,i - firstPos[key] 得到的正好是区间长度。比如 firstPos[key] = 2,当前 i = 5,那么区间是位置 3 到位置 5,长度是 3,表达式 5 - 2 = 3,没有任何偏差。
第三,代码里读取性别时,如果 x 为 0 代表女生,就直接让 sum 减 1。如果不在转换时把 0 变成 -1,前缀和遇到女生时会保持原值不动,统计就失效了。
3.3 手推一个完整样例,看看流程怎么走
假设输入是这 6 个人,1 表示男生,0 表示女生:
text复制6
1 0 1 0 0 1
这串数据里男生正好 3 个,女生正好 3 个,整段就满足男女相等,所以答案应该是 6。我们按上面代码的逻辑走一遍前缀和变化。
| 位置 | 性别 | sum 变化 | 当前 sum | firstPos 记录 | ans |
|---|---:|---:|---:|---|
| 0(哨兵) | 无 | 0 | 0 | firstPos[0] = 0 | 0 |
| 1 | 男 | +1 | 1 | firstPos[1] = 1 | 0 |
| 2 | 女 | -1 | 0 | 已出现过,最早是 0 | ans = 2 |
| 3 | 男 | +1 | 1 | 已出现过,最早是 1 | ans = 2 |
| 4 | 女 | -1 | 0 | 已出现过,最早是 0 | ans = 4 |
| 5 | 女 | -1 | -1 | firstPos[-1] = 5 | 4 |
| 6 | 男 | +1 | 0 | 已出现过,最早是 0 | ans = 6 |
从这个过程能看出来,前缀和 0 最早出现在位置 0,最后一次出现在位置 6,所以整个队伍就是合法最长段。前缀和 1 最早出现在位置 1,最后一次出现在位置 3,对应区间长度 2,也男女相等,但不是最长。
3.4 用哈希表的第一种常见替代写法
如果不喜欢手动算偏移,也可以用 unordered_map。逻辑完全一样,只是容器帮你管理任意整数 key。
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
unordered_map<int, int> firstPos;
int sum = 0, ans = 0;
firstPos[0] = 0;
for (int i = 1; i <= n; ++i) {
int x;
cin >> x;
sum += (x == 1 ? 1 : -1);
if (firstPos.count(sum)) {
ans = max(ans, i - firstPos[sum]);
} else {
firstPos[sum] = i;
}
}
cout << ans << '\n';
return 0;
}
这个写法更不容易出现数组越界问题,阅读起来也直观。缺点在于 unordered_map 的哈希有额外常数,某些极端的测试点下可能不如静态数组稳定。我的建议是两种写法都自己实现一遍。练习阶段用 map 版理解逻辑,提交时如果追求更稳的性能再用数组偏移版。
4. 实战翻车记录:这些坑我亲眼见过
4.1 答案一直为 0:哨兵没有初始化
这是最常见的问题。如果你让 firstPos 数组一开始全是 0,并且用“值是否为 -1”来判断某个前缀和是否出现过,那么当扫描过程中真的出现前缀和 0 时,程序会认为它出现过,而且最早位置是 0。这本来没错,但问题在于其他根本没有出现过的前缀和值也被默认赋成了 0,当 sum = 5 时,程序就会以为前缀和 5 在第 0 位出现过,于是 i - 0 变成一个错误的长度。
修复方式就是在初始化时把整个数组刷成 -1,然后单独把 firstPos[offset] 设为 0。如果不小心把数组设成 {0} 初始化,或者直接用了全局 int firstPos[] 而忘记清空,就会出现各种莫名其妙的错误答案。
提示:拿到这种题,第一步想清楚“0”这个值是被谁占用的。如果默认值也是 0、真实下标也是 0,必须用 -1 默认值来区分“没出现过”和“在第 0 位出现过”。
4.2 小样例能过大样例崩:数组越界 / 负下标
如果只把数组开成 n 的大小,没考虑前缀和可能是负数,那么在扫描时执行 firstPos[sum] 或 key = sum + offset 之前,sum 是负数就会越界。本地编译器不一定报错,可能刚好访问到相邻内存,小数据侥幸通过,一上大数据就 RE。
解决方法是统一使用偏移量。更安全的做法是像示例代码那样用 vector 动态分配,长度设为 2 * n + 5,并且 offset 设为 n + 2。这样就算全部是男生或全部是女生,key 也一定在数组范围内。
4.3 把女生的 0 直接加进前缀和里
我见过不少人在转化时把“男生记 1,女生记 0”直接照搬进代码,变成:
cpp复制if (x == 1) ++sum;
else sum += 0; // 这是错的
如果女生位置不改变 sum,那所有女生就相当于没有进入统计,前缀和根本反映不出女生的数量。这会导致“女女”这样的串,前缀和全程为 0,程序会认为整段都合法,这显然不对。
正确的做法是让男女产生相反的贡献:男生 +1,女生 -1。这里的核心是“性别符号异号对待”,而不是保留 0。类似的问题以后遇到很多,比如红蓝球、黑白棋、上下车人数差,本质都是把一个增量设为正、另一个增量设为负。
4.4 输出结果不是偶数,多半思路有问题
因为男女数量相等时,总人数 = 男生数量 × 2,所以答案一定是偶数。这是一个非常好用的自检技巧。如果你算出来答案是 3、5、7 这样的奇数,那一定在统计转换或者边界处理上有问题。
有同学会问,那如何保证我们找到的区间不会只有一个人?其实不用担心,单个人不可能男女数量相等,所以只要按正确规则判断,单个人形成的子段不会满足条件。对于 n = 1 或者整个序列没有任何合法区间的情况,ans 会保持 0,输出 0 是正确的。
5. 套路的价值:这类题以后会遇到很多
5.1 “找两个相等前缀和”是通用模型
把 P1114 做透之后,你会发现“前缀和 + 记录最早出现位置”这个模型可以迁移到一大批问题上。最典型的适配条件是:给一个序列,求满足某个区间性质的连续最长子段,而这个性质可以转化成“某两种数量的差为固定值”或“区间和等于某个目标值”。
比如常见的 LeetCode 525“连续数组”问题:给定一个二进制数组,找长度最长的连续子数组,使其中 0 和 1 的数量相同。这几乎就是 P1114 的英语版本。再比如“和为目标值的最长子数组”“乘积为正数的最长子数组”等,虽然表面包装不同,但核心都是先求某种前缀信息,再找前面是否出现过某个匹配值,最后更新长度。
这里我建议你把 P1114 当做一个模型题来学,而不仅仅是一个要 AC 的任务。遇到题目先尝试自己推导,能不能把条件写成 pre[r] - pre[l-1] = 0 的形式?如果可以让 0 换成 target,问题就变成了找最长子段和为 target。
5.2 为什么推荐多写几遍数组偏移版
很多刷题新手喜欢偷懒,遇到前缀和直接用 unordered_map,因为代码短、不用想负数下标。但对竞赛环境来说,静态数组往往更稳、更快。尤其这题 n 只有十万,数组偏移毫无压力。我自己的习惯是:先用 map 把思路跑通,再用数组偏移实现一次,确定两种写法都能 AC。这样做有一个额外的好处,以后再遇到需要“给状态值加大偏移”的题目,比如状态压缩 DP 或带负数的哈希,你会有更强的肌肉记忆。
做题时还应该顺手练习读题习惯。先确认 n 的范围,再看前缀和的上下界,最后才确定数组长度或者偏移量。如果一上来就把数组开成 1000000,虽然也能过,但你没有真正理解为什么需要这么大的空间。
5.3 给洛谷刷题新手的一点路线建议
如果你
