深蓝黑背景封面。左侧是一条自上而下的投票流程链,四张圆角卡片以向下箭头串联:第一张「第一遍扫描:维护 3 个候选槽与计数器」;第二张「命中候选:计数 +1;新元素占空槽」;第三张紫色描边「槽满遇第四种元素:三槽各 −1,来者丢弃(核销)」;第四张青色高亮「第二遍验证:统计真实频次,留下超过 n/4 的」。链条下方注「超过 n/4 的数至多 3 个——3 个槽刚好够」。右侧大标题「数组里三个数各自出现超过 n/4 次,怎么找出来」,副标题「摩尔投票法:从单候选到三候选推广」。左上标注「面试经典 · 算法」,右上圆角胶囊标注「Day 05 · 11-22」。 图 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,全部命中。随机数组与暴力统计对拍十万组完全一致。

深蓝黑背景概念图。左侧是三帧槽位快照,以箭头连成时间顺序:快照一「扫描前段」,三个槽位为 A:3、B:2、C:1;快照二紫色描边「新元素 X 到来但槽已满」,触发核销——三槽各减 1 变成 A:2、B:1、C:0,X 被丢弃;快照三青色描边「扫描结束」,剩余候选 A:1、B:1、C:0,进入二次验证。右侧三张规则卡:青色卡「入槽:命中已有候选计数 +1,空槽直接入座」;紫色卡「核销:四种不同元素碰头,互相抵消各减一票」;灰色卡「验证:第一遍只保证不漏,第二遍数真实频次」。底部总结条:核销掉的元素不可能超过 n/4——每核销一次,四种互异元素同归于尽,超过 n/4 的数最多被核销 n/4 次,必然幸存。 图 2:核销瞬间的三帧快照与三条规则。

收尾把复杂度说死。

两遍线性扫描,时间 O(n);候选槽固定 3 个,空间 O(1)。这正是它相对哈希计数(O(n) 空间)和排序(O(n log n) 时间)的价值:数据流场景下只能扫一遍、内存受限时,这个方案几乎是唯一选择。推广结论也一并记住:超过 n/k 的元素至多 k−1 个,槽位开 k−1 个,核销时 k−1 个槽各减一票。

面试现场建议先反问确认题意(「三个数各自超过 n/4」而不是「合计超过 3/4」),再从单候选版本讲起,推出槽数结论,给出两遍扫描,最后补对拍级的正确性论证。你第一次遇到这题时,是卡在「开几个槽」还是卡在「忘了二次验证」?欢迎讲讲。