图 1:两遍扫描的全流程,核销与验证缺一不可。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料(原题面写作「三个数字出现超过 3/4」,按惯例理解为三个数各自出现次数超过 n/4),与任何公司的当前招聘无关。这道题是摩尔投票法的推广版,单候选版本人人会背,一推广到多候选就卡住一大片人。
先回顾单候选版本:核销思想。
经典摩尔投票找「出现次数超过一半」的元素:维护一个候选和一张票。扫到一个数,等于候选则票数加一,不等则票数减一,票数清零就换候选。背后的直觉是「配对核销」——把两个不同的元素捆在一起扔掉,多数派元素因为超过半数,怎么配对都扔不光,必然活到最后。
推广的关键:超过 n/4 的数至多有几个?
这是全题的题眼。假设有四个数各自都超过 n/4 次,加起来就超过 n 次,矛盾。所以答案至多 3 个——候选槽开 3 个刚好够。更一般地,超过 n/k 的数至多 k−1 个,槽就开 k−1 个。
三槽投票的规则。
第一遍扫描维护 3 个「候选 + 计数」槽位,对每个新元素分三种情况:
- 命中已有候选:该槽计数加一;
- 有空槽:直接入座,计数置一;
- 槽满且来者是新面孔:触发核销——三个槽各减一票,新元素丢弃。计数减到零的槽立即清空,可以接待后面的元素。
核销这一步的语义要想清楚:相当于四个互异的元素(三个槽里各一个加上来者)碰头同归于尽。每核销一次,恰好消灭四种不同的元素各一个。
为什么超过 n/4 的元素必然幸存?
反证。假设某个真超过 n/4 次的元素 x 最后不在候选里,那它每次出现要么被核销、要么压根没入槽(没入槽的那次也伴随着一次核销)。每次核销最多消耗一个 x,同时必须陪葬另外三个与 x 不同的元素。x 被核销的次数至多与「非 x 元素」中被拉来陪葬的次数相当,但总核销次数不可能超过 n/4——每次核销消灭 4 个元素,n 个元素最多核销 n/4 次。所以 x 至少还剩 n/4 以上减 n/4 的余额,必然留在槽里。
第二遍验证:候选不等于答案。
第一遍只保证「不漏」:真正的答案一定在幸存候选里;但不保证「不多」:没超过 n/4 的元素也可能赖在槽里。所以必须第二遍扫描,统计每个幸存候选的真实频次,只保留确实超过 n/4 的。忘掉这一步是这题最常见的失分点。
代码实测,并和暴力法对拍十万次。
用 Node.js 22.15.0 运行(node vote.js):
function majorityQuarter(nums) {
const cand = new Map(); // 候选 -> 票数
for (const x of nums) {
if (cand.has(x)) cand.set(x, cand.get(x) + 1);
else if (cand.size < 3) cand.set(x, 1);
else for (const [k, v] of cand) { // 核销:三槽各减一票
if (v - 1 === 0) cand.delete(k); else cand.set(k, v - 1);
}
}
const cnt = new Map([...cand.keys()].map(k => [k, 0]));
for (const x of nums) if (cnt.has(x)) cnt.set(x, cnt.get(x) + 1);
return [...cnt].filter(([, c]) => c > nums.length / 4).map(([k]) => k);
}
实测输出(节选):
t4 (n=24, n/4=6) -> []
t5 (n=25, n/4=6.25) -> [ 1, 2, 3 ]
对拍通过: 100000 / 100000
t4 里 1、2、3 各出现恰好 6 次、等于 n/4 而不超过,答案为空——说明第二遍验证确实在干活;t5 里三数各 7 次超过 6.25,全部命中。随机数组与暴力统计对拍十万组完全一致。
图 2:核销瞬间的三帧快照与三条规则。
收尾把复杂度说死。
两遍线性扫描,时间 O(n);候选槽固定 3 个,空间 O(1)。这正是它相对哈希计数(O(n) 空间)和排序(O(n log n) 时间)的价值:数据流场景下只能扫一遍、内存受限时,这个方案几乎是唯一选择。推广结论也一并记住:超过 n/k 的元素至多 k−1 个,槽位开 k−1 个,核销时 k−1 个槽各减一票。
面试现场建议先反问确认题意(「三个数各自超过 n/4」而不是「合计超过 3/4」),再从单候选版本讲起,推出槽数结论,给出两遍扫描,最后补对拍级的正确性论证。你第一次遇到这题时,是卡在「开几个槽」还是卡在「忘了二次验证」?欢迎讲讲。