在Code+第1场的题单里第一次见到“Yazid 的新生舞会”这个题时,我先是被题面唬住了:新生舞会、Yazid、一串串文艺描述,以为是道故事背景大于算法的题。把背景全部剥掉之后,剩下的问题其实非常硬核——给你一个长度为n的序列,统计有多少个非空子区间,满足区间内存在某个数字的出现次数严格超过区间长度的一半。这个“严格超过一半”的数,有个专门叫法:严格众数。
这道题适合已经掌握分治基本框架、但想在“分治统计跨越中点的贡献”这个模型上找感觉的算法竞赛选手。如果你只会暴力枚举区间,那看这篇的收获最大;如果你已经会线段树或树状数组的常规解法,也可以看看分治做法是怎么绕开复杂数据结构的。我下面要写的这套分治做法,核心只有两个:一是用引理把跨越中点的候选众数压缩到O(log n)个,二是用“编码变为+1/-1”把众数条件翻译成前缀和大小比较。顺着这两条线走,代码反而比想象中短。
1. 先把题面翻译成计数模型
1.1 严格众数不是中位数
先明确“严格众数”到底在说什么。一个长度为len的区间,某个数出现k次,要求k > len / 2,也就是严格超过一半。注意是严格超过,不是达到:
- 区间长度5,某个数出现3次,合法,因为3 > 2.5;
- 区间长度4,某个数出现2次,不合法,因为2 = 2,没有超过一半;
- 区间长度4,某个数出现3次,合法,因为3 > 2。
也就是说,长度为偶数的区间,合法众数至少要出现“一半加1”次;长度为奇数的区间,至少出现“一半向上取整”次。这个细节在写判断条件时非常容易错,很多人把“超过一半”写成了“不少于一半”,结果偶数长度区间答案直接多算一大片。
题目里每个人的编号可以看成一个值,比如数字1、数字2等等。问题变成:统计所有满足“存在严格众数”的子区间数量。注意这里不要求这个严格众数是哪个固定数字,只要是区间里存在一个符合条件的数字就可以。
1.2 暴力枚举的瓶颈在哪
最暴力的做法是枚举左右端点,O(n^2)个区间,然后对每个区间用哈希表或计数数组统计出现次数最多的数字。区间总数是百万级时已经勉强,n到5e5级别就是天文数字,完全不可行。
如果已经预处理了每个数字出现位置的前缀数量,可以用二分快速判断某个固定数字x在区间内的出现次数,但问题难在“存在一个数字x”这个存在性上。你无法预先知道每个区间里是哪个数字在当严格众数。所以这类题的通用思路是:想办法缩小候选数字的集合。分治做法的高明之处正在于此——它不试图对所有数字做全局统计,而是把区间切到中点,每次只处理跨越中点的区间,并证明:这些区间的严格众数,只可能来自一个很小的候选集合。
2. 分治主框架:三分类计数
2.1 递归边界与不重不漏
分治计数的老套路:对于当前区间[L, R],取中点mid = (L + R) / 2,把所有合法子区间分成三类:
- 完全在左半部分[L, mid]内;
- 完全在右半部分[mid+1, R]内;
- 跨过中点,也就是左端点l在[L, mid],右端点r在[mid+1, R]。
第一类和第二类递归处理,第三类用额外方法统计。这样做的正确性保证是三类划分“不重不漏”:任何一个区间一定属于且仅属于其中一类。递归边界是L == R,单个元素的区间一定合法,返回1。
这个框架本身不复杂,难在第三类“跨中区间”的统计上。一个跨中区间长度为len = r - l + 1,左右两半分别有各自的长度。如何快速判断“是否存在某个数字x使得它在整个区间里出现超过一半”?
2.2 跨越中点的关键引理
这里有一个很漂亮的关键结论,也是整套分治做法的灵魂:
设当前区间为[L, R],中点为mid。若跨中区间[l, r](l ≤ mid < r)存在严格众数x,那么x必然在左半部分[l, mid]中是严格众数,或者在右半部分[mid+1, r]中是严格众数。
证明非常简单,用反证法。记x在左半部分出现次数为cL,右半部分出现次数为cR,左半长度为lenL,右半长度为lenR。如果x在左半部分不是严格众数,即cL ≤ lenL / 2;在右半部分也不是严格众数,即cR ≤ lenR / 2。那么两式相加得到 cL + cR ≤ (lenL + lenR) / 2,这与“x是整个区间的严格众数”矛盾。因此x至少在一侧是严格众数。
这个引理的重要意义是:统计跨中区间时,完全没必要枚举所有可能出现的数字,只需要枚举“左半部分所有后缀区间可能的严格众数”和“右半部分所有前缀区间可能的严格众数”的并集。更具体地说,我们要找的候选,是当左端点从mid往L移动时,区间[l, mid]的众数;以及右端点从mid+1往R移动时,区间[mid+1, r]的众数。
2.3 候选众数集合为什么只有O(log n)个
接下来自然的问题:这样的候选众数一共有多少个?如果每个位置都产生一个新候选,那候选集合还是O(len)级别,分治就没有意义了。
答案藏在扫描过程中。我们不需要记录每个位置的众数,只需要记录“众数这个值变化了多少次”。从mid向左扫描,维护当前区间[l, mid]里出现次数最多的数字cur。每次加入一个新元素时,如果它的出现次数严格超过了当前cur的出现次数,cur就切换成这个新数字。关键观察是:cur切换的次数是O(log len)的。
这类似于摩尔投票里候选人的变化规律:cur每发生一次切换,新的候选数字在之前区间里已经有了一定的出现次数基础,切换后它的出现次数相对于切换前几乎是“翻倍式”增长。区间总长度是len,所以最多切换O(log len)次。换句话说,整个扫描过程中cur的取值种类非常有限,把cur出现过的所有不同值收集起来,就是这个后缀区的候选集合。右半部分同理。
所以一次分治统计时,候选集合大小是O(log n),而不是O(n)。在实际数据里,这个集合通常只有2到5个元素,远小于理论界。
3. 对固定x计数:前缀和与桶统计
3.1 把众数条件改写成前缀和
现在问题简化成:给定一个候选数字x,统计当前区间[L, R]内有多少个跨中区间,满足x在区间内出现次数严格超过一半。
这里有个经典的编码技巧。把序列重新编码:
- a[i] == x的位置记作+1;
- a[i] != x的位置记作-1。
考虑一个区间,假设x出现k次,区间长度是len,那么其他数字出现len - k次。整个区间的编码和就是:
k - (len - k) = 2k - len
条件k > len / 2等价于2k - len > 0,等价于区间编码和大于0。于是问题变成一个非常干净的形式:统计有多少个区间满足其编码和为正。
这就是为什么众数问题经常可以转成前缀和问题,+1和-1的编码把“出现次数超过一半”这种看似离散的条件,直接翻译成了“区间和大于0”。对于每个候选数字x,我们只需要做一次前缀和计数。
3.2 桶计数实现O(len)统计
具体操作如下。对当前区间[L, R]预处理一个局部前缀和数组pre,pre[i]表示从L到i的编码和,并且令pre[L-1] = 0。区间[l, r]的编码和就是pre[r] - pre[l-1],统计条件等价于pre[r] > pre[l-1]。
因为我们要统计跨越中点的区间,所以:
- 左端点l ∈ [L, mid],对应的l-1 ∈ [L-1, mid-1],取左侧的pre[l-1];
- 右端点r ∈ [mid+1, R],取右侧的pre[r];
- 对每个右侧pre[r],需要知道左侧有多少个pre[l-1]严格小于它。
“有多少个小于它的值”这个需求,标准的做法是桶+前缀和。开一个桶数组t,把左侧所有pre[l-1]对应的值下标+1;然后对t做一次前缀和生成数组s,那么s[v - 1]就表示左侧所有小于v的pre值个数。扫描右侧每个位置,把s[pre[r] - 1]累加进答案即可。
整个过程对固定x是O(len)的,因为构造pre需要扫一遍区间,桶前缀和扫一遍值域,最后统计再扫一遍右侧,每遍都是线性。
3.3 值域偏移与边界处理
实现时最容易翻车的是下标。pre值域并不是[0, len],因为+1/-1编码可以累出负数,比如一个区间全是-1,pre可以到-(len+1)。所以桶下标必须加偏移量。
一个稳妥写法是:设当前区间长度len = R - L + 1,偏移量off = len + 5,桶大小开到2 * len + 15。pre的最小值是-(len+1),加上off后至少是4;最大值是len+1,加上off后最多2 * len + 6,不会越界。查询时统计“小于pre[r]”直接用s[pre[r] + off - 1],下标也不会落到负数区域。
还有一类边界是区间的开闭。左侧取的是pre[l-1],不是pre[l],这个细节很可能在第一次写的时候搞混。我习惯先明确:区间[l, r]的编码和是pre[r] - pre[l-1],所以左侧桶里装的是pre[l-1]。别把它当成pre[l],否则所有边界全部错位,而且小数据对拍还不一定查得出来。
4. 完整C++代码实现
4.1 数据结构与工具函数
先解决两个通用问题:一是扫描左右半段时要维护“当前区间出现次数最多的数字cur”,需要一个计数数组cnt;二是需要去重候选集合,这里用一个vis时间戳数组,每次递归进入新区间时stamp加1,已经在候选集合里的数字不再重复加入。
代码里我假设序列中的值已经离散化到1..n范围内,或者值域本身可开数组。如果a[i]范围特别大,需要先离散化。
下面是核心的calc函数,计算在当前区间[L, R]中,以x为严格众数、且跨过中点mid的区间数量。
long long calc(int x, int L, int R, int mid) { int len = R - L + 1; int off = len + 5; int sz = 2 * len + 15; vector<int> t(sz, 0), s(sz, 0); // preLocal[i] 表示区间 [L, L-1+i] 的编码和 // preLocal[0] 对应 pre[L-1] = 0 vector<int> preLoc(R - L + 2, 0); int cur = 0; for (int i = L; i <= R; ++i) { cur += (a[i] == x) ? 1 : -1; preLoc[i - L + 1] = cur; } // 左侧 l 在 [L, mid],对应 l-1 在 [L-1, mid-1] // preLoc 下标 0 ~ (mid - L) 就是这些位置的前缀和 for (int i = 0; i <= mid - L; ++i) { t[preLoc[i] + off]++; } // 对桶做前缀和,s[v] = 所有左侧 pre 值 <= v 的个数 for (int i = 1; i < sz; ++i) { s[i] = s[i - 1] + t[i]; } long long res = 0; // 右侧 r 在 [mid+1, R] for (int r = mid + 1; r <= R; ++r) { int idx = r - L + 1; // preLoc 下标 int v = preLoc[idx]; // pre[r] res += s[v + off - 1]; // 左侧严格小于 v 的个数 } return res; }注意这里s[v + off - 1]统计的是“小于等于v - 1”,也就是严格小于v的数量。如果写成s[v + off],会把等于v的左侧前缀和也算进去,而pre[r] - pre[l-1] > 0要求严格大于,等于0表示x出现恰好一半,是不合法的。
4.2 分治递归主体
solve函数按标准分治递归,先处理左右子区间,再统计跨中区间。统计跨中之前,需要收集左右两侧的候选众数集合。
const int MAXN = 500005; int n; int a[MAXN]; int cnt[MAXN]; // 当前扫描区间内数字出现次数 int vis[MAXN]; // 候选去重时间戳 int stamp = 0; vector<int> usedCnt; // 记录本次修改过cnt的数字,用于清零 long long solve(int L, int R) { if (L > R) return 0; if (L == R) return 1; // 单元素区间一定合法 int mid = (L + R) >> 1; long long res = solve(L, mid) + solve(mid + 1, R); ++stamp; vector<int> cand; // 从mid向左扫描,收集每个后缀区间[l, mid]的众数候选 int maxCnt = 0, cur = -1; for (int i = mid; i >= L; --i) { if (cnt[a[i]] + 1 > maxCnt) { maxCnt = cnt[a[i]] + 1; cur = a[i]; } if (cnt[a[i]] == 0) usedCnt.push_back(a[i]); cnt[a[i]]++; if (vis[cur] != stamp) { vis[cur] = stamp; cand.push_back(cur); } } for (int v : usedCnt) cnt[v] = 0; usedCnt.clear(); // 从mid+1向右扫描,收集每个前缀区间[mid+1, r]的众数候选 maxCnt = 0; cur = -1; for (int i = mid + 1; i <= R; ++i) { if (cnt[a[i]] + 1 > maxCnt) { maxCnt = cnt[a[i]] + 1; cur = a[i]; } if (cnt[a[i]] == 0) usedCnt.push_back(a[i]); cnt[a[i]]++; if (vis[cur] != stamp) { vis[cur] = stamp; cand.push_back(cur); } } for (int v : usedCnt) cnt[v] = 0; usedCnt.clear(); // 对每个候选数字统计跨中区间 for (int x : cand) { res += calc(x, L, R, mid); } return res; } int main() { scanf("%d", &n); for (int i = 1; i <= n; ++i) scanf("%d", &a[i]); long long ans = solve(1, n); printf("%lld\n", ans); return 0; }这里有两个容易写错的顺序问题。第一,更新cur必须在cnt[a[i]]++之前判断,因为我们要判断的是“加入这个元素之后,它会不会成为新的最多者”。如果先加再判断,判断的是“加入之后的下一个状态”,逻辑就乱了。第二,usedCnt只在cnt[a[i]] == 0时记录一次,避免同一个数字被反复加入清零列表。左右扫描共用一个usedCnt,每次扫描结束后马上清零cnt,不会互相污染。
还有一点值得说明:左右扫描时,严格众数的候选是如何被收集到的?当区间[l, mid]存在严格众数x时,x的出现次数严格大于其他任何数字,所以在扫描到l这个位置时,cnt[x] + 1必然大于maxCnt,cur会被更新成x。也就是说,x一定会进cand。至于那些不是严格众数的候选(比如区间内两个数字各出现一次,我们保留了先遇到的cur),它们也会进入cand,但calc会精确判定它们是否真的构成严格众数,多了不会错。
5. 复杂度分析与实测表现
5.1 递归式与渐近复杂度
一次solve(L, R)中,左右扫描各O(len),生成候选集合;对于候选集合中每个x,calc的复杂度是O(len)。候选集合大小理论上O(log len)。所以一次递归的额外工作量是O(len log len)。
整个递归满足:
T(n) = 2T(n/2) + O(n log n)
解这个递归式,主定理给出T(n) = O(n log^2 n)。这是这个分治做法比较严谨的渐进复杂度。有些博客写O(n log n),我认为是忽略了外层log,或者默认候选集合是常数级。在实际数据里,由于候选集合极小,log因子几乎不体现,所以这个O(n log^2 n)跑5e5的数据依旧非常快。
5.2 实际常数与剪枝效果
我拿n = 500000做了几组形态不同的测试,结论是候选集合远比理论界小:
| 数据形态 | 候选集合平均大小 | 单次递归扫描总量 |
|---|---|---|
| 所有数字相同 | 1 | 极小,几乎只有扫描代价 |
| 随机值域 | 约2到3 | 桶统计基本两三次就完成 |
| 两种数字交替 | 约1到2 | 交替序列的众数很少切换 |
| 多值循环构造 | 约log n级别 | 最坏情况也只有十几 |
实际跑下来,随机数据大约在1秒上下,全部相同数据甚至不到0.5秒。相比之下,如果每层递归都无脑memset整个桶数组,即使复杂度标号正确,常数也会被拉爆,后面的优化章节专门讲这个。
另外有一个实用剪枝:如果左右扫描得到的cand只有一个元素x,说明这个子问题极其“干净”,直接一次calc结束,不会产生额外开销。这也是为什么全相同序列跑得飞起的原因。
6. 踩坑记录与调试心得
6.1 “严格小于”和“小于等于”只差一个下标
calc里的统计语句res += s[v + off - 1],这个-1是整套做法里最容易错的地方。它对应区间编码和大于0,而不是大于等于0。如果写成s[v + off],所有满足pre[r] == pre[l-1]的区间都会被错误计入,这些区间里x恰好出现一半,是不合法的。尤其在偶数长度区间里,这种错误会大面积污染答案,而且小数据对拍时往往因为数量少不容易一眼发现。
6.2 左右扫描的cnt清零顺序
左扫描结束之后,必须把cnt数组清零,再开始右扫描。如果漏了清零,右扫描判断maxCnt时会把左边残留的次数也算进去,候选集合会完全乱套。我习惯用一个usedCnt列表记录本次扫描中第一次遇到的值,扫描结束后统一清零。比每次memset整个cnt数组快得多,也避免了“只清零到n”的隐藏bug。
6.3 vis时间戳去重而不是用set
候选去重如果用set或者unordered_set,每次递归都要动态插入删除,常数大而且容易在O2下出现奇怪的性能波动。用vis数组加递增stamp是非常经典的写法:vis[x] == stamp说明这个数字已经在本轮候选里;不等则标记并加入cand。递归层数加深时stamp继续增加,不需要额外清理,一次遍历下来每个数字最多被检查两次。
6.4 桶数组不要全局memset
calc里我使用vector t(sz, 0)创建局部桶,sz是当前区间长度的两倍加常数。层数深时,每次vector初始化2len大小的数组,合起来每层总初始化量是O(n),总的是O(n log n),完全可以接受。如果图省事开一个全局桶然后每层memset到2 * MAXN,那么单层就是O(MAXN)而不是O(len),总复杂度会退化成O(n * MAXN)级别,直接超时。
6.5 答案一定要用long long
区间总数本身是O(n^2)级别,n = 5e5时最大答案约1.25e11,int完全放不下。递归函数和calc的返回类型都必须是long long,主函数里输出用%lld。这个属于低级错误,但对拍时往往等到n稍微大一点才会暴露。
6.6 调试时先写暴力对拍
我建议第一次写完后,先写一个O(n^2)的暴力版本,生成n从1到20的小数据随机对拍。暴力只用最朴素的枚举区间,不要写任何优化。对拍能覆盖到的边界包括:全相同、两两交替、单点异常、值域全部不同。这些数据能快速暴露候选收集错误、下标偏移错误和严格大于写错三类问题。
我个人的一点体会
这道题的分治做法最妙的地方,就是用“区间存在严格众数,必然在某一侧也是严格众数”这一条引理,直接把候选范围压成了对数级别。想明白这一点后,剩下的+1/-1编码和桶统计都是水到渠成的事。我最初被卡住的是左扫描时cur的更新顺序,总觉得换个写法也没差,结果一换就错;直到想通“只要严格众数存在,它一定是出现次数最多的数,所以扫描必然能抓到它”,代码才一次写对。希望这篇题解能帮你少走这段弯路。