Java版LeetCode热题100之多数元素:从暴力解法到Boyer-Moore投票算法的全面解析
本文约9500字,系统性地讲解了LeetCode第169题“多数元素”的多种解法、原理分析、复杂度评估及面试实战技巧。适合准备算法面试或深入理解数据结构与算法的同学阅读。
一、原题回顾
题目名称:多数元素(Majority Element)
LeetCode编号:169
难度等级:简单(但蕴含深刻思想)
题目描述
给定一个大小为n的数组nums,返回其中的多数元素。
多数元素是指在数组中出现次数大于 ⌊ n/2 ⌋的元素。
你可以假设:
- 数组是非空的;
- 给定的数组总是存在多数元素。
示例
示例 1: 输入:nums = [3,2,3] 输出:3 示例 2: 输入:nums = [2,2,1,1,1,2,2] 输出:2约束条件
n == nums.length1 <= n <= 5 * 10^4-10^9 <= nums[i] <= 10^9- 输入保证数组中一定有一个多数元素。
进阶要求
尝试设计时间复杂度为O(n)、空间复杂度为O(1)的算法解决此问题。
二、原题分析
这道题的核心在于:如何高效地找出出现次数超过一半的元素?
关键观察点:
- 多数元素唯一存在(因为若有两个元素都超过 n/2,则总和 > n,矛盾);
- 出现次数 > n/2,意味着它在数量上绝对占优;
- 不需要知道具体出现多少次,只需识别出该元素即可。
常见的错误思路:
- 直接排序后取中间值(可行,但非最优);
- 暴力双重循环统计(时间复杂度过高);
- 忽略“存在多数元素”这一强前提,导致算法冗余。
本题的价值在于:引导我们从不同角度思考“计数”与“比较”的本质,并引出经典的Boyer-Moore 投票算法。
三、答案构思:五种主流解法概览
我们将从暴力 → 哈希 → 排序 → 随机化 → 分治 → 投票算法逐步优化,最终达到 O(n) 时间 + O(1) 空间的理想解。
| 方法 | 时间复杂度 | 空间复杂度 | 是否满足进阶要求 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | ❌ |
| 哈希表 | O(n) | O(n) | ❌ |
| 排序 | O(n log n) | O(log n) | ❌ |
| 随机化 | 期望 O(n) | O(1) | ✅(概率性) |
| 分治 | O(n log n) | O(log n) | ❌ |
| Boyer-Moore 投票 | O(n) | O(1) | ✅✅✅ |
下面逐一详解。
四、完整答案与代码实现
方法一:哈希表(HashMap)
思路
用哈希表统计每个元素的出现次数,遍历过程中维护最大频次的元素。
代码实现
importjava.util.*;classSolution{publicintmajorityElement(int[]nums){Map<Integer,Integer>counts=newHashMap<>();intmajorityCount=nums.length/2;for(intnum:nums){counts.put(num,counts.getOrDefault(num,0)+1);if(counts.get(num)>majorityCount){returnnum;// 提前返回,优化常数时间}}// 理论上不会执行到这里,因题目保证存在多数元素return-1;}}优化点:在插入时立即判断是否超过 n/2,可提前终止,避免二次遍历。
代码分析
- 使用
getOrDefault简化逻辑; - 利用“多数元素必然存在”的前提,可在计数超过阈值时立即返回;
- 无需存储所有计数后再遍历。
复杂度分析
- 时间复杂度:O(n),仅一次遍历;
- 空间复杂度:O(n),最坏情况下所有元素都不同(但题目保证有众数,实际空间 < n)。
方法二:排序法
思路
由于多数元素出现 > n/2 次,无论其值大小,排序后必定占据中间位置。
例如:
[2,2,1,1,1,2,2]→ 排序后[1,1,1,2,2,2,2],索引 3(即 7/2=3)为 2;[3,2,3]→[2,3,3],索引 1 为 3。
代码实现
importjava.util.Arrays;classSolution{publicintmajorityElement(int[]nums){Arrays.sort(nums);returnnums[nums.length/2];}}代码分析
- 极简代码,依赖语言内置排序;
- 利用数学性质:中位数必为众数。
复杂度分析
- 时间复杂度:O(n log n),由排序决定;
- 空间复杂度:O(log n),Java 的
Arrays.sort()对基本类型使用双轴快排,递归栈深度为 O(log n)。
⚠️ 注意:若手写堆排序,可将空间降至 O(1),但时间仍为 O(n log n)。
方法三:随机化算法
思路
由于多数元素占比 > 50%,随机选一个元素,它是众数的概率 > 50%。重复几次几乎必然命中。
代码实现
importjava.util.Random;classSolution{privateintcountOccurrences(int[]nums,inttarget){intcount=0;for(intnum:nums){if(num==target)count++;}returncount;}publicintmajorityElement(int[]nums){Randomrand=newRandom();intn=nums.length;intmajorityThreshold=n/2;while(true){intcandidate=nums[rand.nextInt(n)];if(countOccurrences(nums,candidate)>majorityThreshold){returncandidate;}}}}代码分析
- 每次随机选一个下标,验证是否为众数;
- 虽然理论上可能无限循环,但期望尝试次数仅为 2 次(几何分布)。
复杂度分析
- 时间复杂度:期望 O(n),最坏 O(∞)(实际不可能);
- 空间复杂度:O(1)。
✅ 满足进阶要求(概率意义上)。
方法四:分治法(Divide and Conquer)
思路
关键性质:若 a 是整个数组的众数,则 a 至少是左半或右半的众数。
证明(反证法):
- 假设 a 在左半 ≤ l/2 次,右半 ≤ r/2 次;
- 则总次数 ≤ (l + r)/2 = n/2,与“> n/2”矛盾。
因此可递归求解左右子数组的众数,再合并比较。
代码实现
classSolution{// 统计 [lo, hi] 区间内 num 的出现次数privateintcountInRange(int[]nums,intnum,intlo,inthi){intcount=0;for(inti=lo;i<=hi;i++){if(nums[i]==num)count++;}returncount;}privateintmajorityElementRec(int[]nums,intlo,inthi){// 基线条件:单个元素if(lo==hi)returnnums[lo];intmid=lo+(hi-lo)/2;intleftCandidate=majorityElementRec(nums,lo,mid);intrightCandidate=majorityElementRec(nums,mid+1,hi);// 若左右候选相同,直接返回if(leftCandidate==rightCandidate){returnleftCandidate;}// 否则统计两者在整个区间中的出现次数intleftCount=countInRange(nums,leftCandidate,lo,hi);intrightCount=countInRange(nums,rightCandidate,lo,hi);returnleftCount>rightCount?leftCandidate:rightCandidate;}publicintmajorityElement(int[]nums){returnmajorityElementRec(nums,0,nums.length-1);}}代码分析
- 递归分割数组;
- 合并时需 O(n) 时间统计两个候选的全局频次;
- 利用“众数必在子区间中占优”的性质。
复杂度分析
- 时间复杂度:T(n) = 2T(n/2) + O(n) →O(n log n)(主定理);
- 空间复杂度:O(log n),递归栈深度。
方法五:Boyer-Moore 投票算法(最优解)
思路
核心思想:多数元素“抵消”其他元素后仍有剩余。
- 维护一个候选
candidate和计数器count; - 遍历数组:
- 若
count == 0,则更新candidate = current; - 若当前元素 ==
candidate,count++;否则count--;
- 若
- 最终
candidate即为众数。
为什么有效?
因为众数数量 > 其他所有元素之和,所以“一对一抵消”后,众数必胜。
代码实现(简洁版)
classSolution{publicintmajorityElement(int[]nums){intcount=0;Integercandidate=null;for(intnum:nums){if(count==0){candidate=num;}count+=(num==candidate)?1:-1;}returncandidate;// 题目保证存在,无需验证}}正确性证明(直观理解)
以[7,7,5,7,5,1,5,7,5,5,7,7,7,7,7,7]为例:
| 元素 | 7 | 7 | 5 | 7 | 5 | 1 | 5 | 7 | 5 | 5 | 7 | 7 | 7 | 7 | 7 | 7 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| cand | 7 | 7 | 7 | 7 | 7 | 7 | 5 | 5 | 5 | 5 | 5 | 5 | 7 | 7 | 7 | 7 |
| cnt | 1 | 2 | 1 | 2 | 1 | 0 | 1 | 0 | 1 | 2 | 1 | 0 | 1 | 2 | 3 | 4 |
- 每当
count=0,意味着此前的子数组中无绝对优势元素; - 新一轮从当前位置开始“重新计票”;
- 由于众数整体占优,最后一轮的
candidate必为众数。
📌注意:若题目不保证存在多数元素,需在最后加一步验证(遍历统计
candidate是否 > n/2)。
复杂度分析
- 时间复杂度:O(n),仅一次遍历;
- 空间复杂度:O(1),仅用两个变量。
✅完美满足进阶要求!
五、时间复杂度与空间复杂度对比总结
| 方法 | 时间复杂度 | 空间复杂度 | 是否稳定 | 是否满足进阶 |
|---|---|---|---|---|
| 暴力 | O(n²) | O(1) | 是 | ❌ |
| 哈希表 | O(n) | O(n) | 是 | ❌ |
| 排序 | O(n log n) | O(log n) | 是 | ❌ |
| 随机化 | 期望 O(n) | O(1) | 否(概率) | ✅(弱) |
| 分治 | O(n log n) | O(log n) | 是 | ❌ |
| Boyer-Moore | O(n) | O(1) | 是 | ✅✅✅ |
结论:在确定存在多数元素的前提下,Boyer-Moore 投票算法是最优解。
六、常见问题解答(FAQ)
Q1:为什么排序后中间元素一定是众数?
因为众数出现 > n/2 次,即使全部集中在一端,也会“覆盖”中位数位置。例如 n=7,众数至少出现 4 次,无论怎么排,第 4 个位置(索引 3)必为其值。
Q2:Boyer-Moore 算法能用于“找出现次数最多的元素”吗?
不能!它仅适用于“存在出现次数 > n/2 的元素”的场景。若只是找频率最高的(如 top-1),需用哈希表。
Q3:如果数组中没有多数元素,怎么办?
Boyer-Moore 仍会返回一个
candidate,但需二次验证:// 验证 candidate 是否真的 > n/2intcount=0;for(intnum:nums)if(num==candidate)count++;returncount>nums.length/2?candidate:-1;
Q4:随机化算法会不会超时?
几乎不会。期望尝试 2 次,每次 O(n),总期望时间 2n。即使尝试 10 次,概率也极低(< 0.1%)。
七、优化思路与工程实践建议
1.优先选择 Boyer-Moore
- 代码简洁、效率高、空间省;
- 适用于流式数据(无法存储全部元素时)。
2.哈希表 vs 投票算法
- 若需同时获取所有元素频次,用哈希表;
- 若仅需众数,用投票算法。
3.排序法的适用场景
- 当数组已部分有序,或后续还需其他顺序操作时;
- 代码最短,适合快速原型。
4.避免暴力解法
- O(n²) 在 n=5e4 时操作次数达 2.5e9,必然超时。
八、数据结构与算法基础知识点回顾
1.哈希表(HashMap)
- 平均 O(1) 插入/查询;
- 底层:数组 + 链表/红黑树(Java 8+);
- 适用于“计数”、“去重”、“映射”场景。
2.分治思想
- 将大问题分解为相似子问题;
- 典型应用:归并排序、快速排序、线段树;
- 时间复杂度常用主定理分析。
3.随机化算法
- 利用概率降低期望复杂度;
- 常见于:快速选择、蒙特卡洛方法;
- 需注意最坏情况(虽概率极低)。
4.Boyer-Moore 投票算法
- 属于在线算法(online algorithm);
- 核心:利用“多数”与“少数”的数量差;
- 可扩展至“找出现 > n/k 的元素”(需 k-1 个计数器)。
九、面试官提问环节(模拟)
Q:你能解释一下 Boyer-Moore 算法为什么正确吗?
答:因为多数元素出现次数超过一半,所以在“一对一抵消”过程中,其他所有元素合起来也无法完全抵消它。算法通过维护一个候选和计数器,模拟这一抵消过程。当计数器归零时,说明此前的子数组中无绝对优势元素,于是从当前位置重新开始。由于整体上众数占优,最后一轮的候选必为众数。
Q:如果要找出现次数超过 n/3 的元素(可能有0、1或2个),怎么做?
答:可扩展 Boyer-Moore 思想,维护两个候选和两个计数器。遍历时:
- 若当前元素等于任一候选,对应计数器+1;
- 若有计数器为0,替换该候选;
- 否则两个计数器都-1。
最后需验证两个候选是否真的 > n/3。
这就是LeetCode 229. 求众数 II的解法。
Q:这个算法能用于数据流(streaming data)吗?
答:完全可以!Boyer-Moore 是典型的单遍扫描、常数空间算法,非常适合处理无法全部加载到内存的大数据流。每来一个新元素,更新
candidate和count即可,无需回溯。
十、这道算法题在实际开发中的应用
1.分布式系统中的共识机制
- 在 Paxos、Raft 等协议中,“多数派”决策是核心;
- 类似思想:只要超过半数节点同意,即可达成一致。
2.实时数据分析
- 监控系统中识别“高频异常事件”;
- 如:某 IP 在短时间内发起 >50% 的请求,可能是 DDoS 攻击。
3.投票系统
- 选举中得票过半者胜出;
- 算法可快速识别领先者(假设数据按时间流式到达)。
4.缓存淘汰策略
- LRU 等策略需统计访问频次;
- 若某资源访问量突增(>50%),可优先缓存。
十一、相关题目推荐
| 题号 | 题目 | 难度 | 关联点 |
|---|---|---|---|
| 229 | 求众数 II | 中等 | 扩展:找 > n/3 的元素 |
| 1207 | 独一无二的出现次数 | 简单 | 哈希表计数 |
| 387 | 字符串中的第一个唯一字符 | 简单 | 计数 + 遍历 |
| 509 | 斐波那契数 | 简单 | 对比:非计数类问题 |
| 217 | 存在重复元素 | 简单 | 哈希表去重 |
重点练习 229 题,它是本题的自然延伸。
十二、总结与延伸
核心收获
- 多数元素问题的本质是“数量优势”,而非“值的大小”;
- Boyer-Moore 投票算法是解决此类问题的最优方案,体现了“抵消思想”的巧妙;
- 算法选择需结合问题约束(如“保证存在众数”);
- 从暴力到最优,体现了算法优化的典型路径:空间换时间 → 利用数学性质 → 在线处理。
延伸思考
能否在 O(1) 空间内找到 top-k 频繁元素?
→ 一般不能,但若 k 固定(如 k=2),可用扩展投票法。如果数组是只读的,且不能修改,还能用投票算法吗?
→ 可以!投票算法不要求修改原数组。在 GPU 或并行计算中如何加速?
→ 分治法天然适合并行:各子区间独立计算候选,最后合并。
最后的话
LeetCode 169 题看似简单,却蕴含了哈希、排序、分治、随机化、在线算法等多种思想。掌握它,不仅是为了通过面试,更是为了培养从问题本质出发设计算法的能力。
记住:最优解往往来自于对问题约束的深刻理解,而非盲目套用模板。