第一次看到洛谷 P1114 这个题目名,我以为是道模拟排队、统计人数的生活场景题。点进去才发现,题目真正要你做的是:给出一串由 0 和 1 组成的队伍,找出最长的一段连续队伍,让其中的 0 和 1 数量一样多。题面披着“非常男女”的外衣,内核其实是个标准的前缀和问题。
这道题在洛谷的分类里经常被当成“前缀和入门必刷”的题目。但如果你只会两层循环硬枚举区间,大概率会超时;如果直接套滑动窗口,又会发现两个指针根本不知道怎么移动。我当年第一次做的时候也被卡了好一会儿,绕了一圈才想明白,这类“找最长连续子段满足某个和差条件”的问题,核心套路就是:把原数组改写成 1 和 -1,然后借助前缀和相等来判断区间合法性。
如果你正在准备 CSP-J/S,或者在刷蓝桥杯、找算法实习,这篇文章可以当作 P1114 的完整解题笔记,也可以当作“前缀和 + 哈希桶”这一类题型的通用模板。我会从最暴力的思路开始拆,一步一步讲到最优解,再把提交时会踩的坑全部翻出来,希望能帮你省下一点调试时间。
1. 把题意读透:为什么“非常男女”是一道前缀和题
1.1 题目到底要我们求什么
题目描述通常长这样:有一排同学,每个位置要么是男生,要么是女生,分别用 1 和 0 表示。你需要找出一段连续的同学,使得这一段里面男生人数等于女生人数,并输出这一段的最大长度。
举个简单例子:队伍是 1 0 1 0,那么整段 4 个人里恰好 2 男 2 女,答案是 4;队伍是 1 1 0,前两个人都是男生,后一个是女生,单独看任何长度为 2 的区间都不满足男女相等,但看整个区间也是 2 男 1 女,不满足,所以答案不是 2 就是 0,需要程序自己算。
我一开始做题容易陷入一个误区:老想着怎么模拟“选男生选女生”的过程。实际上题目根本不需要你输出选中的方案,只要求最长长度,这提醒我们可以从区间计数入手。如果把男生数量记为 cnt1,女生数量记为 cnt0,合法区间就满足 cnt1 - cnt0 = 0。所以问题本质上是个数学条件,不是模拟题。
这种题对新手友好的一点是,它不要求你写复杂的搜索或贪心,只要你把模型转化对,代码量其实非常短。真正难的,是怎样在数据范围变大之后,还能在合理时间内找出答案。
1.2 暴力思路和两个“死胡同”
最直接的想法是枚举所有左右端点 [l, r],然后数一数里面有多少个 1 和多少个 0。三重循环肯定能过样例,但复杂度是 O(n^3),一旦数据量到几千就完全跑不动。
稍微聪明一点的做法,是先用一个普通前缀和数组 cnt1[i] 记录前 i 个人中男生数量,然后枚举左右端点,用 cnt1[r] - cnt1[l-1] 算出区间男生数,再对比区间长度的一半。这样的复杂度是 O(n^2),看起来像优化了,但本质还是枚举所有区间,面对一万级别的数据依然脆弱。
很多人这时会想:能不能用滑动窗口?我试过,答案是很难直接套。滑动窗口要求窗口在扩展和收缩时有明确的单调性,比如“窗口和小于 k 就扩大右端,大于 k 就收缩左端”。但这题判断条件是人数相等,当你发现当前窗口男多女少时,你并不知道下一个位置是男还是女;继续向右扩展可能让差值更大,也可能把差值拉回零;收缩左端也会面临同样的问题。窗口的伸缩方向没法用一条简单规则确定下来,所以双指针在这里不是最优解。
还有一个容易想到的思路是排序或二分答案。二分最长长度似乎可行,但如果要验证“是否存在长度为 len 的合法区间”,还是得枚举起点并快速求区间内男女数量差,依然跳不出前缀和。问题的瓶颈在于“如何快速判断前面是否出现过某个前缀和”,而这恰恰是哈希表擅长的活。
1.3 从数据范围反推目标复杂度
做算法题,第一步不是写代码,而是先看范围。洛谷 P1114 这类题的数据量,通常不会让 O(n^2) 舒服通过,尤其是题目被划到普及组提高组交界处时,求解目标基本是线性的 O(n),或者最多 O(n log n)。
如果目标是 O(n),你就能确定自己大概率只需要把数组扫一遍。边扫描边记录某些信息,通常就是“前缀和”或“动态规划状态”。再结合合法区间需要满足的条件,我们基本可以判断,这道题不是让用线段树去维护什么复杂信息,而是需要我们在一次遍历中快速找到两次相同前缀和之间的最大距离。
于是思路就清晰了:把男生和女生用相反数表示,让“人数相等”变成“区间和为 0”。然后用前缀和数组把任意区间和变成两个前缀和相减。只要两个前缀和相等,中间这一段就一定合法。接下来要想的,就是怎样在一次扫描中高效完成这个判断。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从暴力到 O(n):前缀和推导全过程
2.1 把性别改写成 1 和 -1 的好处
一个很关键的技巧:不要把男生和女生记成 0 和 1,而是男生记 1,女生记 -1。为什么?因为这样数组的连续区间和,恰好等于“男生人数 - 女生人数”。
举个例子:队伍是 男 女 男 女,改写成数字就是 1, -1, 1, -1。前两个数的和是 0,对应这一段确实一男一女;整个数组的和也是 0,对应两男两女。如果队伍是 男 男 女,改写成 1, 1, -1,总和是 1,代表男生比女生多 1 个。这样一来,“男生人数等于女生人数”这个条件,就等价于“区间和等于 0”。
很多教程把这步说得太轻巧,好像只是换个记号。其实这个记号的改变非常本质。它把一个“比较两个统计量大小关系”的问题,转化成“计算一段数的和”的问题。后者可以用前缀和快速计算,前者就很难快速批量处理。
你可能会问:那我用 0 表示女生、1 表示男生,区间内 1 的数量等于区间长度一半,不也能判断吗?也能,但算起来要同时维护区间长度和男生数量两个变量,代码明显更啰嗦。用 1 和 -1 之后,只需要维护一个累加值,一切都能合并成一个数,这是最简的状态表示。
2.2 区间合法等价于两个前缀和相等
先回忆一下前缀和的定义:pre[i] 表示原数组前 i 个数的和,特别地,pre[0] = 0。那么从第 l+1 个位置到第 r 个位置这一段的区间和,就是 pre[r] - pre[l]。
放到这道题里,设区间为 (l, r],它合法当且仅当区间和为 0,也就是:
pre[r] - pre[l] = 0
即:
pre[r] = pre[l]
所以问题变成:给定一个前缀和数组,找到一对下标 i < j,满足 pre[i] == pre[j],并且让 j - i 尽量大。
注意这里的下标从 0 开始。i 可以是 0,因为一段区间可以从第 1 个人开始,对应区间 (0, r]。这一步是最容易忽略的:pre[0] = 0 必须被考虑进去。举个直观例子,数组是 1, -1, 1, -1,前缀和依次是 0、1、0、1、0。pre[0] = 0,pre[2] = 0,pre[4] = 0,这三个位置两两之间都对应一个合法区间:前两个位置差是 2,代表第 1 到第 2 个人;第 0 到第 4 个位置差是 4,代表整段队伍。答案就是 4。
如果你漏掉了 pre[0],只看位置 2 和位置 4,得到的最大长度只有 2,比真实答案少一半。很多人第一次写这题就挂在初始化上。
2.3 为什么只需要记录第一次出现的位置
现在我们要找“相等的两个前缀和之间最远距离”。如果把所有前缀和都存下来再两两比较,复杂度又回到 O(n^2)。
换个角度:我们从左往右扫描到当前位置 i 时,如果之前某个位置 j 的前缀和等于当前 pre[i],那么 (j, i] 就是一个合法区间。要让区间最长,当然希望 i - j 越大越好。对固定的 i 来说,j 越小,区间越长。所以对于每一种前缀和值,只需要记录它第一次出现的下标,后面每次遇到相同值,就用当前下标减去第一次出现的下标,然后更新答案。
反过来想,如果你记录的是最后一次出现位置,那么当后面再遇到相同的值,两次之间的距离可能不如前面那次远,但你已经丢失了最早的位置。因此,“只保留最早位置”这个决策不是随意选择,而是由“求最大距离”这个目标直接决定的。
这个过程用一个哈希表就能完成,键是前缀和值,值是最早出现下标。如果前缀和的范围不大且是整数,直接用数组做桶比用 unordered_map 更快。因为这里的值可能为负,所以需要整体平移下标,后面我会讲具体写法。
3. 代码实现与几个必须写对的关键细节
3.1 C++ AC 参考代码
按照常见的输入格式,第一行是一个整数 n,第二行有 n 个由空格隔开的 0/1 数字。参考代码可以这样写:
cpp复制#include <bits/stdc++.h>
using namespace std;
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n;
// 前缀和的范围是 [-n, n],加上偏移量 n 之后映射到 [0, 2n]
vector<int> first(2 * n + 5, -1);
int pre = 0;
int ans = 0;
// 关键:下标 0 位置的前缀和是 0
first[0 + n] = 0;
for (int i = 1; i <= n; ++i) {
int x;
cin >> x;
// 假设输入中 1 代表男生,0 代表女生
pre += (x == 1 ? 1 : -1);
int idx = pre + n;
if (first[idx] == -1) {
// 这个前缀和第一次出现,记录下标
first[idx] = i;
} else {
// 之前出现过相同前缀和,说明中间这一段人数相等
ans = max(ans, i - first[idx]);
}
}
cout << ans << '\n';
return 0;
}
如果题目的输入是一整行字符串,比如直接给 "110010",代码只需要改读入部分:
cpp复制string s;
cin >> s;
for (int i = 0; i < (int)s.size(); ++i) {
int x = s[i] - '0';
// 后面处理逻辑完全一样
}
变量 i 的起点是 1,表示队伍中第几个人;first[n] = 0 表示在前缀和数组中下标 0 的位置已经有值 0。这里 pre 就是 pre[i],当我们扫到第 i 个人时,它正好表示前 i 个人的总和。
3.2 初始化与边界条件最容易忽略
这段代码里最值得反复强调的,就是 first[0 + n] = 0 这一行。
它代表的是“一个都没有选”的状态。什么意思?如果你选了队伍从第 1 个人到第 r 个人作为候选区间,那么它对应的前缀和是 pre[r] - pre[0]。pre[0] 必须作为一个可选起点存在。如果不初始化,程序里 first 全是 -1,当某个前缀和第一次出现时会被记下来,但永远不会作为答案的开头位置去匹配后面的值。对于像 1 0 1 0 这样的样例,不初始化会导致整段合法区间被漏掉,最终输出 2 而不是 4。
另一个容易错的地方是 first 数组初始值的选择。必须用 -1 表示“之前没见过”,不能用 0 表示。因为位置 0 本身是合法下标,和“没出现过”完全不是一回事。如果初始化为 0,你无法区分“前缀和值等于 x 的位置出现过,第一次出现在 0”和“这个前缀和值还没出现过”两种情况。这会导致重复更新答案,甚至把区间长度算错。
代码里的 vector<int> first(2 * n + 5, -1) 使用了堆空间,而不是开一个可能越界的栈上大数组。这个细节在数据量大的时候也值得注意。如果你非要写 int first[200005] 这类固定数组,必须确保数组大小能覆盖 2 * n + 2,否则边界上可能越界。
3.3 复杂度对比:从 O(n²) 到 O(n)
我整理了一张复杂度对照表,可以更直观看到每类做法的差距:
| 方法 | 时间复杂度 | 空间复杂度 | 适用范围 |
|---|---|---|---|
| 三重循环暴力 | O(n^3) | O(1) | 仅用于理解题意 |
| 前缀和 + 枚举左右端点 | O(n^2) | O(n) | n 在几千以内 |
| 前缀和 + unordered_map | O(n) 平均 | O(n) | 各种情况都能用 |
| 前缀和 + 数组桶 | O(n) | O(n) | 前缀和范围较小时最快 |
用 vector 当桶,本质上是把哈希表换成了数组。因为这道题的前缀和只可能是 [-n, n] 之间的整数,范围可以提前确定,所以开一个长度为 2n+5 的数组是最省事的方案。如果题目改成前缀和值域很大且分散,才需要改用 unordered_map。
unordered_map 也不是不行,但常数比数组大不少。我在洛谷上测过,同样一份逻辑,用数组桶提交明显更稳,尤其是在输入规模达到几十万以上时。对竞赛来说,能少一点不确定的常数就少一点。
4. 我踩过的坑:初始化、负下标与自测样例
4.1 负数前缀和带来的下标越界问题
很多人第一次写数组桶版本时,会直接拿 pre 当下标用,比如写成 first[pre]。问题在于,前缀和完全可能是负数。队伍是 0 1 0,如果 0 对应 -1,那么前缀和依次是 -1、0、-1,pre 会出现 -1。拿 -1 当数组下标直接越界。
解决办法是给所有前缀和加一个偏移量 n,把取值范围整体平移到非负区间。原本范围是 [-n, n],加上 n 之后变成 [0, 2n]。这也是为什么数组要开 2 * n + 5,而不是 n + 1。
写代码时容易搞混的是偏移量到底加多少。有人习惯写 n + 1,也没问题,只要所有用到下标的地方都用同一个偏移量,并且数组长度足够即可。我自己的习惯是统一用 pre + n,初始化时也写 first[n] = 0,整个逻辑一条线,不容易乱。
4.2 first 数组初值设计的常见翻车现场
我看见过不少同学把 first 初始化为 0,因为他们想着“下标 0 就代表没出现过”。这种做法对多数查找类题目没问题,但在这道题里会出大事,因为 first[0 + n] = 0 本身就有意义,它表示前缀和 0 第一次出现在位置 0。如果你用 0 表示“没出现过”,就无法同时表达“前缀和 0 已经出现过”这件事。
我推荐一套固定的写法:
- 读入
n; - 创建长度为
2 * n + 5的数组,全部赋初值 -1; - 单独设置
first[n] = 0; - 遍历时,如果对应值是 -1,就记录当前位置;否则更新答案。
这套写法的优点是语义清晰:-1 只代表“从未出现”,0 和正数都代表具体下标。你不需要额外添加一堆 if 特判。
4.3 提交前先用小样例自测
我每次写完这类题,都会额外造几个边界样例,而不是只跑题目样例。几个适合这道题的用例:
n = 1,输入1,输出应为 0。因为单个人无法做到男女相等。n = 2,输入1 0,输出应为 2。两个位置里恰好一男一女。n = 4,输入1 0 1 0,输出应为 4。整段正好平衡。n = 3,输入1 1 0,输出应为 0 或 2?其实这里要小心:区间[2, 3]是1 0,恰好一男一女,所以输出应为 2。n = 6,输入1 1 0 1 0 1,输出应为 4。因为中间某一段可以做到 2 男 2 女。
如果一个一个手工验证太慢,可以直接在循环里输出每个位置的前缀和,以及每次更新答案时的区间信息。这样做虽然提交前要删掉调试代码,但排查逻辑错误非常高效。
注意:如果某个位置的前缀和之前从未出现过,那么它不是终点,只是起点。只有遇到重复前缀和时,才说明从上一个记录点到当前位置能形成合法区间。理解这一点,调试时会少走很多弯路。
5. 思路延伸:用同一模型刷更多类似题
5.1 同类题原型:区间差值为 0 的经典模型
P1114 的模型绝不是洛谷专属。力扣上有一道非常像的题,LeetCode 525,题目给一个二进制数组,要找含相同数量 0 和 1 的最长连续子数组。解法几乎一样,也是把 0 看成 -1,然后求相同前缀和之间的最远距离。
还有一类常见变体,是“和为 k 的最长连续子数组”。比如给定一个整数数组,找出和为某个固定值 k 的最长子数组。思路也类似:遍历过程中维护 pre,然后查找前面是否出现过 pre - k。这里要做的不再是找相同的 pre,而是找与当前目标值相等的最早位置。
如果你能把 P1114 的代码原理吃透,再去做这几类题会非常快,因为它们共享同一个骨架:
- 维护一个前缀状态;
- 用哈希桶记录状态最早出现的位置;
- 每次遇到能组成目标关系的状态,就尝试更新答案;
- 状态值需要根据题目条件做数学变形。
5.2 如果题目条件改成“男比女多 k 人”
题目不可能永远只考相等,最常见的改动是:找到最长的一段连续队伍,使得男生人数比女生人数多 k 人。
这个条件翻译过来就是区间和等于 k,不是 0。用前缀和表示:
pre[r] - pre[l] = k
所以 pre[l] = pre[r] - k。在扫描到位置 r 时,你应该去查“值为 pre[r] - k 的前缀和最早出现在哪里”。一旦找到,那么从那个位置到当前位置的区间就满足条件。
代码只需要把最后一段更新逻辑改成:
cpp复制int target = pre - k;
if (first[target + n] != -1) {
ans = max(ans, i - first[target + n]);
}
这个改动说明了一个很重要的点:解题时不要死记模板,要理解模板内部在查什么。P1114 查的是“相同的 pre”,因为目标是差值为 0;如果目标是差值为 k,就查“pre - k”。
5.3 给刷题人的三条经验
第一,看到“连续子数组满足某条件”的题,优先想前缀和。如果条件里同时涉及两个统计量的相等或差值,再想想能不能用相反数把它们合并成一个标量。合并之后,许多看似复杂的题目都会瞬间变成“找相同值”或“找目标值”。
第二,数组桶和哈希表的选择,要结合值域来判断。P1114 的 pre 范围是 [-n, n],所以数组桶最合适。如果值域不确定,或者状态是二元组、字符串,再切换成 unordered_map。不要一上来就抱着一棵线段树不放,很多线性可解的问题根本用不着。
第三,多花时间研究 pre[0] = 0、首次出现位置记录、偏移量处理这三个细节。它们单独看都不难,但凑在一起就是这类题的命门。只要任何一个地方出错,样例可能都过不了。把这些细节固化成自己的代码习惯之后,写前缀和题目基本可以一次通过。
我个人在实际刷题中的体会是,P1114 是一道性价比很高的练习题。它不考冷门数据结构,也不考复杂证明,却把“前缀和 + 哈希桶”这个高频组合讲得非常清楚。做熟这道题之后,再去碰那些和子数组、子串有关的算法题,你会明显感觉到自己对“如何把区间条件转换成前缀状态”更有手感。遇到类似题目时,脑子里会自然冒出这条路径:先尝试把条件数学化,再考虑一次遍历能记录什么状态,最后看看能不能用哈希桶优化到 O(n)。这套思考方式,才是这道“非常男女”计划真正值得你带走的东西。
