如果有人甩给你一个一亿长度的整数数组,让你找出其中出现次数超过一半的那个数,而且内存不让你额外开一个Map,你第一反应是什么?排序?哈希?这两招面试时确实能过,但等面试官追问“空间复杂度能不能压到O(1)”时,很多人就卡住了。摩尔投票法(Boyer-Moore Voting Algorithm)就是为这种场景准备的:一次遍历,两个变量,O(n)时间加O(1)空间,干净利落地把多数元素揪出来。
我第一次看到这个算法时,第一反应是“这也太赖皮了吧”,十几行代码就把一个看起来必须统计所有频次的问题解决了。后来踩过几次忘做二次验证的坑,又研究了它扩展到“出现超过n/3的元素”这类变体,才真正理解它为什么能成立、什么时候会失效。这篇文章不打算只贴一段代码,我会把这个算法的原理、标准模板、边界处理、扩展思路以及我在刷题和项目里踩过的坑一次性讲透,不管你是准备面试还是想在流式场景里快速找热点,都应该能从这里直接抄作业。
1. 摩尔投票法到底在解决什么问题
1.1 一个看似简单的众数问题
先定义清楚问题。有一个长度为n的数组,我们想找一个值,它的出现次数大于n/2。LeetCode上叫它Majority Element,中文通常翻译成“多数元素”或“众数”。注意一个细节:这里说的是“大于”一半,不是“大于等于”,如果数组长度为4,出现次数必须大于2才算,恰好2次不合格。
有一个很容易混淆的点:日常口语里说的“众数”可能指出现次数最多的数,哪怕只有1次。但摩尔投票法针对的是严格超过一半的多数元素,不是普通的“最高频”。这个区别非常关键,因为一旦没有“超过一半”这个前提,成对抵消的核心性质就不成立了。
能解决什么问题?在算法题里,最常见的就是LeetCode 169。在工程里,类似场景也有不少:比如从大量访问日志里判断某个IP是否占了超过总流量一半的份额、在传感器数据流里识别异常占比较高的状态值,甚至在一些投票类业务里快速核对某选项是否过半。这类问题的共同特征是:数据规模很大,或者数据是持续流入的,我们不想把所有元素都存下来再做统计。
1.2 为什么排序和哈希表不是最优解
我见过很多人拿到题之后先写HashMap统计频次,这也是最直觉的思路。遍历一遍数组,每个元素出现次数累加,最后找次数大于n/2的键。时间确实是O(n),但空间是O(n),万一数组有5000万个元素,这个Map会非常吃内存。如果元素本身还是对象而不是int,内存翻倍增长会更难受。
排序法也很常见:排完序后,如果存在多数元素,它一定会出现在下标n/2的位置。这是正确的,因为超过一半相同的数无论怎么分布,排完序后中间位置必然被它占据。问题在于排序本身通常要O(n log n)时间,而且如果数据是实时流入的,你根本没法把所有数据排完再处理。
分治法也能做:每次分成左右两半,分别找到两边候选众数,再合并比较。时间能到O(n log n)甚至O(n),但实现复杂度明显高一些,而且对新人很不友好。
所以,当面试官或者实际场景要求“只能遍历一次、内存最多O(1)”时,摩尔投票法基本是唯一简单优雅的选择。它不排序、不建哈希表,而是用一种“抵消”的思路,把全局多数元素的问题化简成一个状态机,这是算法最漂亮的地方。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理:众人拾柴,也众人互耗
2.1 不同元素成对抵消,候选人最后才揭晓
想象有一群人,每个人身上贴着一个数字。多数派的人数超过总人数一半。现在让任意两个贴不同数字的人互相抵消离场,会发生什么?
假设总人数是100,多数派有51人。就算极端情况下每次抵消都消耗掉1个多数派和1个非多数派,在非多数派的49个人全部被消耗完之后,多数派还剩下2个人。而且因为非多数派互相之间也可能被抵消掉,多数派的人被消耗得更少。所以无论抵消顺序如何,最后剩下的一定是多数派的人。
这个直觉就是摩尔投票法的核心。但算法不能真的去枚举“任意一对不同元素”,那太慢了。Boyer和Moore在1981年提出的巧妙设计是只维护一个“候选人candidate”和一个“计数器count”:
- 遇到一个数与当前候选人相同,count加1,相当于候选人阵营多了一个人。
- 遇到不同的数,count减1,相当于有一个不同阵营的人把候选人阵营的一个人抵消掉了。
- 当count减到0,说明候选人阵营已经被完全抵消,此时把下一个遇到的数设为新候选人,count置为1。
这个过程本质上是在不断寻找“还没有被完全抵消掉的队伍”。不同数字之间的抵消是抽象的,并不是真的要删除数组元素,而是用一种累积状态模拟抵消后的剩余情况。
我举个例子手动跑一遍,数组是[2, 2, 1, 1, 1, 2, 2],我们从count=0开始:
- 遍历第1个数字2:count为0,候选设为2,count变为1。
- 第2个数字2:等于候选,count变为2。
- 第3个数字1:不等于候选,count变为1。
- 第4个数字1:不等于候选,count变为0。
- 第5个数字1:count刚变为0,候选更新为1,count变为1。
- 第6个数字2:不等于候选,count变为0。
- 第7个数字2:count为0,候选更新为2,count变为1。
最终候选人是2。统计整个数组,2确实出现了4次,大于7/2,所以2就是多数元素。中间你会发现候选人从2切到1又切回2,这个过程非常像两个人轮流上台打擂台,谁的支持者多,谁就能在最后留在台上。
2.2 一定非要第二趟扫描吗
很多精简版代码在第一步选出候选人后就直接return了,没有二次验证。这在LeetCode 169里成立,因为题目保证了一定存在多数元素。但在真实场景和很多变形题里,输入数组可能根本没有多数元素,比如[1, 2, 3, 4],跑完第一趟会得到一个候选人,但实际没有一个数出现超过一半。
所以完整算法分两趟:
- 第一趟:用抵消逻辑筛出一个可能候选人。
- 第二趟:重新遍历数组,统计这个候选人的真实出现次数,只有次数大于n/2时才返回它,否则返回“不存在”。
为什么第一趟筛出来的不一定是真多数?因为抵消过程只保证“如果存在多数元素,它一定会成为最后的候选人”,但不保证“最后留下来的候选人一定是多数元素”。当数组中各元素数量比较均匀,没有任何一个元素超过一半时,最后一轮幸存者可能只是一个在局部抵消中没有被杀掉的普通元素。验证这一步正是算法的安全网。
我自己的习惯是:哪怕题目已经保证存在多数元素,我也会在工程代码里多写一遍验证。多一次O(n)遍历通常不是什么大事,但能省掉很多隐蔽的bug,这个习惯在数据来源不干净、约束条件不可靠的时候特别值钱。
3. 代码落地与边界情况:从模板到线上
3.1 标准实现,以及一个手动推演
写Java版本时,我推荐用从count等于0开始的写法,而不是先拿nums[0]初始化候选人。好处是天然处理空数组,不会越界。
java复制public int majorityElement(int[] nums) {
int candidate = 0;
int count = 0;
for (int num : nums) {
if (count == 0) {
candidate = num;
}
count += (num == candidate) ? 1 : -1;
}
// 第二趟:验证
int times = 0;
for (int num : nums) {
if (num == candidate) {
times++;
}
}
return times > nums.length / 2 ? candidate : -1;
}
这段代码值得逐行解释一下。count等于0是个重置信号,意味着当前擂台已经没有人能站住了,于是把新来的数字推上台。紧接着count做累加,如果当前数字正好等于候选人,加1;否则减1。很多人会在这里写错成先判断相等再决定是否替换候选,逻辑顺序乱了之后,候选人更新时机就会出问题。
手动推演一个更极端的例子:[1, 1, 1, 2, 2, 2, 2, 3]。直觉上多数元素是2,因为它出现4次,数组长度8,4并不大于8/2,所以其实没有多数元素。第一趟会得到什么?跑下来候选人是2,但第二趟统计2的次数等于4,4 > 4不成立,于是返回-1。这就是验证阶段的价值。
我见过有人把验证条件写成大于等于nums.length / 2。在这个例子中就会错误地返回2。必须强调:多数元素的定义是严格超过一半,所以验证条件只能用大于。这一点在面试里问边界条件时经常被单独拎出来考查。
3.2 多语言模板和验证阶段的取舍
Python版本同样简洁,我一般这样写:
python复制def majority_element(nums: list[int]) -> int:
candidate = None
count = 0
for num in nums:
if count == 0:
candidate = num
count += 1 if num == candidate else -1
times = sum(1 for num in nums if num == candidate)
return candidate if times > len(nums) // 2 else -1
Go版本也不复杂:
go复制func majorityElement(nums []int) int {
candidate := 0
count := 0
for _, num := range nums {
if count == 0 {
candidate = num
}
if num == candidate {
count++
} else {
count--
}
}
times := 0
for _, num := range nums {
if num == candidate {
times++
}
}
if times > len(nums)/2 {
return candidate
}
return -1
}
很多语言的基础类型都能套这个模板,因为算法核心只需要两个操作:判等和计数。如果数组元素不是整数而是对象,只需要把candidate类型换成对应的对象类型,比较逻辑从==换成equals即可。空间复杂度依然是O(1),因为不管数组多大,始终只存一个候选值和一个计数器。
关于验证阶段的取舍,我的建议分场景判断:
- 如果题目和线上接口都明确“保证有答案”,可以省略第二趟。
- 如果输入来自用户、文件、外部API,任何一条数据不满足约束都可能让结果错得离谱,这时保留第二趟更稳妥。
- 如果是数据流且无法二次回放,只能在第一趟结束后直接输出候选人,那就要接受“如果根本不存在多数元素,返回的候选无意义”这个事实,通常会在业务层再想别的办法。
复杂度方面,第一趟O(n),第二趟O(n),合起来还是O(n)。空间上只有固定几个局部变量,所以是O(1)。这里有个有趣的论调:有些人觉得两趟加起来扫了两遍数组,能不能叫“一次遍历”?严格说,单趟筛选确实是只扫一遍,但验证阶段还需要第二遍。在数据不可回放的流式场景里,你可以先执行筛选,等流结束后如果还保留着全量备份数据,再补一次验证;如果没法备份,那就只能信第一趟的结果。
4. 进阶玩法:找超过 n/3 的数,甚至推广到 n/k
4.1 最多只有两个候选人
LeetCode 229要求找出所有出现次数大于n/3的元素。先想一个数学事实:如果数组里有3个不同的数都大于n/3,那么它们的总次数一定大于n,这显然不可能。所以满足条件的元素最多只有2个。
顺着前面的抵消思路,找多于n/3的元素,相当于需要三股力量互相对抗。维护一个候选不够了,因为可能存在两个候选。很多初学者以为要做三层嵌套循环或者干脆退化成哈希表,其实不用。标准做法是维护两个候选人和两个计数器,遍历时按特定顺序更新。
这里有一个值得反复强调的思维转变:找超过n/2的数,是两派互相对抗;找超过n/3的数,最多可以有两派存活。每一次出现三个互不相同的数时,它们可以“同归于尽”一波。因为如果某个数出现超过n/3,就算它被成组抵消,最后也会因为数量优势留存下来。
4.2 双计数器代码这样写才不会翻车
Java实现如下:
java复制public List<Integer> majorityElement(int[] nums) {
int candidate1 = 0;
int candidate2 = 1;
int count1 = 0;
int count2 = 0;
for (int num : nums) {
if (num == candidate1) {
count1++;
} else if (num == candidate2) {
count2++;
} else if (count1 == 0) {
candidate1 = num;
count1 = 1;
} else if (count2 == 0) {
candidate2 = num;
count2 = 1;
} else {
count1--;
count2--;
}
}
List<Integer> result = new ArrayList<>();
int times1 = 0;
int times2 = 0;
for (int num : nums) {
if (num == candidate1) times1++;
else if (num == candidate2) times2++;
}
if (times1 > nums.length / 3) result.add(candidate1);
if (times2 > nums.length / 3) result.add(candidate2);
return result;
}
注意几个关键细节。第一,两个候选人的初始值不能相同,否则在数组中所有值都不等于这两个起点时,两个候选的计数都会一直为0,最后可能出现同一个候选人被重复返回。我这里让candidate1等于0,candidate2等于1,就是为了让它们一开始就是不同的哨兵值,即使数组中真的存在0或1也没关系,它们会通过后续的计数和替换被纠正。
第二,分支顺序必须是“先匹配已有候选人,再找空位,最后集体减一”。如果反过来,当一个候选人的计数器为0但当前值恰好等于另一个非零候选人时,可能把本该匹配的场景误当成空位替换,导致计数状态错误。这属于实现顺序问题,跑几个特殊用例就能测出来。
Python版本换汤不换药:
python复制def majority_element_ii(nums: list[int]) -> list[int]:
candidate1, candidate2 = 0, 1
count1 = count2 = 0
for num in nums:
if num == candidate1:
count1 += 1
elif num == candidate2:
count2 += 1
elif count1 == 0:
candidate1, count1 = num, 1
elif count2 == 0:
candidate2, count2 = num, 1
else:
count1 -= 1
count2 -= 1
ans = []
times1 = sum(1 for v in nums if v == candidate1)
times2 = sum(1 for v in nums if v == candidate2)
if times1 > len(nums) // 3:
ans.append(candidate1)
if times2 > len(nums) // 3:
ans.append(candidate2)
return ans
用手动模拟来验证,数组[1, 1, 1, 2, 2, 2, 3, 3],长度8,超过8/3的数量至少要3个。1出现3次,2出现3次,3出现2次,所以结果应该是[1, 2]。按上面代码跑,第一趟选出的两个候选最终会被验证为1和2,正确。
4.3 通用摩尔投票:维护 k-1 个候选
如果你进一步追问:找出现次数大于n/4甚至n/k的元素呢?数学上,满足次数大于n/k的元素最多有k-1个。因此通用的摩尔投票就需要维护k-1个候选人以及对应的k-1个计数器。
通用算法核心逻辑可以概括为:遍历每个元素,先看它是不是某个已有候选人,如果是,对应计数器加1;如果不是,再看有没有候选人的计数器为0,有则用当前元素替换这个候选人并把计数器置为1;如果候选人全满且当前元素不属于任何候选人,就把所有候选人的计数器都减1,相当于让当前元素作为“第三方”参与了一次抵消。第一趟结束后,还要对最多k-1个候选人逐一验证出现次数是否超过n/k。
时间和空间复杂度分别是O(nk)和O(k)。当k是一个很小的常数时,算法依然非常轻量。实际面试和竞赛里遇到n/3级别就够了,但理解n/k的版本能让你把这类抵消思想完全串联起来,面试官追问时你会明显比只会背模板的人更有底气。
我个人并不推荐真的用一个list去维护很多候选人,除非k很大且场景特殊。因为通用版本里每次遍历都要在当前候选列表里做线性查找,k一大时间开销会上升。但在k为2或3的场景里,这几乎是除哈希表外最实用的方案。
5. 常见错误与面试实战记录
5.1 五个最容易被忽视的坑
先说第一个坑,也是最容易踩的:忘记第二趟验证。LeetCode 169因为题目保证了答案存在,所以返回第一趟候选就能过。但如果把这段代码直接搬到“判断数组是否存在多数元素”的接口里,一旦输入是[1, 2, 3, 4],你会返回一个根本不达标的数,线上就会出事故。永远把“第一趟筛候选人,第二趟验证”当成完整模板,是最稳的习惯。
第二个坑是Count归零后的处理顺序。正确写法是count为0时先将当前数设为候选人,再统一做加1或减1判断。但如果当前数恰好是一个与新候选人不同的数,先设候选人再减1会让count变成-1,导致状态错乱。所以一个统一的写法是:先处理count为0的情况,然后无条件执行count += (当前值等于候选人) ? 1 : -1。这样可以避免在分支中手动把count重置为1,减少出错机会。
第三个坑是把验证条件写成大于等于。多数元素要求严格大于n/2,验证条件必须写>。打个比方,数组[1, 1, 2, 2]中1和2都出现2次,长度4的一半是2,2并没有大于2,所以严格多数元素不存在。如果验证用了>=,就会错误判定某个候选通过。
第四个坑是数组元素不是基本类型时误用==比较。在Java中,如果候选人是Integer对象且值大于127,用==比的是引用地址,不是值本身,结果会变得飘忽不定。工程代码里建议直接用equals比较,或者把候选人定义成基本类型int,避免这类语言细节坑。
第五个坑是混淆Boyer-Moore投票算法和Boyer-Moore字符串搜索算法。这两个算法都出自Robert S. Boyer和J Strother Moore两位大佬,但解决的是完全不同的问题:一个在线性时间和常数空间里找多数元素,一个是在长文本里高效搜索子串。面试时如果你把两者混为一谈,会显得理解很浅,而且很可能被面试官抓住细节追问,场面会非常尴尬。
5.2 一道经典题的现场答题脉络
假设面试官现在问:给定一个大小为n的数组,找出其中出现次数大于n/2的元素,要求线性时间、常数空间。我一般建议按这个节奏回答:
先承认最简单的做法是哈希表统计,时间O(n),空间O(n)。然后自然过渡到进阶方案:因为题目保证或者实际场景可能存在多数元素,所以可以尝试摩尔投票法。接着解释核心思想不是统计频率,而是用不同元素互相抵消,让数量过半的元素天然胜出。最后给代码,并说明为什么空间是O(1)。
面试官如果追问“为什么候选人一定就是多数元素”,不要只背结论。你可以这样说:如果多数元素存在,任意两个不同元素抵消不会改变它仍然占剩余元素多数这一事实,所以不断抵消到最后,剩下的唯一元素只能来自多数阵营。如果多数元素不存在,候选就没有这种保证,所以需要二次验证。这个回答能同时展示原理理解和边界意识。
如果他还问“扩展到n/3怎么做”,就把前面维护两个候选人的思路抛出来,同时说明最多两个候选人的数学原因。这套组合拳打下来,面试官通常就会让你继续做下一题了。
5.3 问题自查速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 返回了一个数,但数组里没有多数元素 | 少了第二趟验证 | 第二趟统计候选人真实次数,不满足>n/2就返回不存在 |
| 偶数长度数组验证结果不对 | 验证条件用了>= | 改成严格大于,比如times > nums.length / 2 |
| count归零后状态混乱 | 分支顺序写错,新候选还没赋值就执行加减 | 先处理候选赋值,再统一用count += (相等 ? 1 : -1) |
| 数组为空时程序崩溃 | 直接把nums[0]当初始候选 | 初始candidate不依赖首元素,从count=0开始 |
| 两个候选人的初始值相同 | 使用默认0和默认0 | 初始值分别设成0和1,保证不同 |
| 混淆字符串搜索与多数元素算法 | 对Boyer-Moore家族理解不够 | 记住投票算法用于多数元素,字符串搜索算法用于模式匹配 |
这套速查表是我用血泪换来的。特别是第二趟验证和验证条件这两个问题,我在很长时间里都没意识到少了一步会导致线上返回脏数据。建议大家把这页存下来,或者直接贴在刷题笔记里,每次调用模板前扫一眼。
再分享一个工程里的小技巧:如果你需要在流式数据上实时维护多数元素候选,但又担心流结束后没法验证,可以封装一个简单的类,内部只保存candidate和count,每次新数据进来就调用一次add方法。等一个批次的流结束后,再决定是否要回放验证。这个小类在上亿级别数据的日志分析里跑过,内存占用几乎可以忽略不计,非常稳。
