1. 别被"简单题"骗了:多数元素背后藏着算法优化的完整脉络
如果你刷过LeetCode热题100,大概率见过这道169题。它在LeetCode上被标记为"简单",但真要问你:为什么它简单?你能在几分钟内给出几种解法?每种的复杂度是多少?为什么有的做法空间省到极致?我估计很多人能答上来一半就不错了。
这道题的实际价值,恰恰不在于"通过"本身,而在于它把穷举、哈希表、排序、分治、随机化、摩尔投票这几种算法思想压缩在一个极简题目里。你从O(n²)一路优化到O(n),等于把"如何分析问题、如何选择策略、如何证明正确性"这条完整的链路重走了一遍。本文就围绕三种核心解法展开:哈希计数、排序取中位、Boyer-Moore投票算法。它们分别代表了"直观""巧妙""更巧妙"三个层次,每一层都有值得咀嚼的东西。
题目描述一句话就能说清:给定一个长度为n的数组,找到其中的多数元素。多数元素定义为"出现次数大于 n/2 的元素",而且题目保证这样的元素一定存在。
前100字自然融入核心关键词这块做到了。同时这道题的应用场景也能聊——不只是刷题,比如在分布式投票、流式数据统计、日志分析中找占比超过一半的信号,都是这个问题在真实世界的投影。理解了169题,你就理解了"多数判定"这类问题的最小模型。
2. 先看暴力解法为什么慢:两次遍历的O(n²)思路,以及它带给我们的教训
2.1 暴力解法的完整分析:双层循环到底冗余在哪里
先来最没有门槛的做法:对数组里的每个元素,遍历一遍整个数组数一数它出现了多少次;只要某个元素出现的次数大于 n/2,它就是答案。
public int majorityElement(int[] nums) { int n = nums.length; for (int i = 0; i < n; i++) { int count = 0; for (int j = 0; j < n; j++) { if (nums[j] == nums[i]) { count++; } } if (count > n / 2) { return nums[i]; } } return -1; }这段代码逻辑没有任何问题,但它背后有一个巨大的浪费:每一个元素都被重复计数了。假设数组是 [1, 2, 1, 1, 2, 1, 3, 1, 1],当i=0时数了一遍1出现的次数,当i=2时又数了一遍,当i=3、i=5、i=7、i=8时还在数。一组数据被反复遍历,时间复杂度自然是O(n²)。
这也是新手最容易踩的坑:能跑通不等于算法好。LeetCode上n的规模可能到10⁴甚至更大,O(n²)在数据量翻倍时耗时变成四倍,指数级的劣化在实际工程里不可接受。
2.2 暴力解法的隐藏价值:它是所有优化方案的"参照系"
但暴力解并不是一无是处。它至少告诉了我们两件事。
第一,这个问题本质上是个"计数问题"。我们需要知道每个元素出现的次数,然后挑选出超过阈值的那一个。所有后续优化,无论哈希还是排序还是投票,核心都是"如何更高效地完成计数或等效计数"。
第二,暴力解法是验证其他方案正确性的基准。我自己调试Boyer-Moore算法的时候,最初的参考答案就是暴力解。先用暴力跑一遍确认正确答案,再拿新解法跑一遍做比对,比自己空想正确性快得多。这个习惯我后来一直保留着,算是个实用技巧。
提示:如果你面试时说自己"先用暴力解打开思路",这是加分项;如果直接提交暴力解然后说"这是我能想到的最优解",这是减分项。两者的差别在于:你有没有继续往下想。
3. 哈希表计数:用空间换时间的经典决策,O(n)时间O(n)空间的务实选择
3.1 为什么哈希表是"计数问题"的第一反应工具
既然暴力解的时间浪费在重复计数上,那就用一个"记忆容器"把已经数过的结果存下来。这就是哈希表(HashMap)的价值:用额外空间换取一次遍历内完成所有统计。
在Java里做这道题,核心代码很简洁:
public int majorityElement(int[] nums) { Map<Integer, Integer> counts = new HashMap<>(); for (int num : nums) { counts.put(num, counts.getOrDefault(num, 0) + 1); if (counts.get(num) > nums.length / 2) { return num; } } return -1; }这里有一个很多人没注意到的细节:边统计边判断,而不是先完整统计完再寻找最大值。为什么可以这样?因为多数元素出现次数大于 n/2,它一定会在某个时刻率先突破阈值。一旦突破,就不需要再等后面的元素了。这在大多数测试用例里能省下少量无效遍历。
另一个细节是counts.getOrDefault(num, 0) + 1这种写法。在Python里对应counts[num] = counts.get(num, 0) + 1,在C++里是counts[num]++。语言不同但思路一样:默认从0开始计数。
3.2 哈希表方案的时间复杂度分析:为什么它是O(n)
这个方案只需要遍历一次数组,每次进行哈希表的插入或更新操作,均摊复杂度是O(1),所以整体是O(n)。空间上,哈希表最多存n个不同的键,空间复杂度O(n)。
单看这两项,哈希表方案已经比暴力解好了几个量级。它也是大多数人在笔试中会优先写出的方案,因为正确性显然、实现风险低。
3.3 哈希表方案的面试"减分点"与工程适用场景
但如果你在面试里止步于哈希表,面试官接下来很大概率会追问一句:"能不能不用额外的空间?"这不是故意刁难,而是想看你是否知道:空间也是成本,特别是在处理大规模数据、内存受限的嵌入式系统、或者流式数据场景下,O(n)空间可能压根不够用。
哈希表方案在工程中适合数据量可控、读写频繁但无所谓内存开销的场景。比如日志分析中统计某个key出现次数,数据量在百万级别,内存足够,哈希表就是直接有效的方案。它的问题从来不是"不能跑",而是"不能极致地跑"。
4. 排序解法:把存在性证明变成位置观察,O(nlogn)的思维转换
4.1 排序后为什么中位数一定是多数元素——一个简洁的证明
接下来这个思路稍微转个弯:如果把数组排好序,那么下标 n/2 处的元素一定是多数元素。原因很直观:多数元素因为数量超过一半,无论它最初散布在哪里,排完序后它必然会"横跨"数组的中间位置。
你甚至可以这样理解:数组排序后,相同元素聚在一起。多数元素的数量超过n/2,意味着它的"连续区间"长度超过数组一半。假设它的区间在排序后的位置为 [start, end],那么 区间长度 = end - start + 1 > n/2,必然有 start < n/2 < end,所以下标 n/2 一定落在它的区间内。
用代码实现:
def majorityElement(nums): nums.sort() return nums[len(nums) // 2]这个解法短得让人吃惊,但背后依赖的是排序和"位置不变性"。空间复杂度取决于排序算法的实现,Python的Timsort是O(n)额外空间,Java的Arrays.sort对基本类型是双轴快速排序(O(logn)栈空间),对对象是归并排序(O(n)空间)。时间上排序本身就是O(nlogn)。
4.2 排序解法的边界条件与陷阱
这里的陷阱之一是 n 为偶数时怎么办。比如 n=6,多数元素要求出现次数 > 3,也就是至少4次。排序后,下标 3 处的元素必然落在多数元素区间内吗?
答案是肯定的。多数元素出现至少4次且排序后连续,这个区间长度至少为4,而长度为6的数组中间位置是下标3(通常取 n/2 向下取整)。如果多数元素区间是[0,3],下标3正好命中;如果是[1,4],也命中;如果是[2,5],也命中。结论是:偶数长度时向下取整的下标依然一定落在多数元素区间内。
另一个陷阱是依赖了题目"保证存在多数元素"这一前提。如果这个前提不存在,排序后取中位数的做法就必须额外加一个验证步骤。这也是后面要讲的"验证逻辑"为什么重要。
4.3 排序解法的适用场景:什么时候应该选它
谈到排序解法的工程性价比,我的看法是:如果代码库刚好对数组做了排序,或者在流式数据里你本来就维护了有序结构,那么取中位数几乎零成本;否则为了找多数元素专门排序并不划算,毕竟O(nlogn)不如O(n)。
但在面试沟通中,排序解法是一个很好的"过渡答案"。你先说出O(n)的哈希方案,再抛出"排序后取中位数"这个更省空间的思路,最后引出O(n)时间O(1)空间的摩尔投票,整个思维链条非常完整,比直接甩出最优解更有说服力。面试官想听的其实是你如何一步步逼近最优解,而不是你能不能默写最优解。
5. Boyer-Moore投票算法:从O(nlogn)到O(n)时间、O(1)空间的终极方案
5.1 核心思想:打着"同归于尽"的旗号,找到最后的幸存者
Boyer-Moore投票算法(通常也叫摩尔投票法)最初由Boyer和Moore提出,用于在字符串中查找多数元素。其直觉描述极其生动:想象一场多方混战,每个元素是一个阵营的士兵,任意两个不同阵营的士兵相遇就同归于尽。如果某个阵营的士兵总数超过总数的一半,那么即使所有其他阵营联合起来攻击它,最后战场上一定还剩下这个阵营的士兵。
算法维护两个变量:候选者candidate和计数器count。遍历数组时:
- 当
count为0时,将当前元素设为候选者,count设为1。 - 否则,如果当前元素等于
candidate,count加1;如果不等于,count减1。
这个过程的本质是:用计数器来模拟"当前候选者与其他元素的抵消过程"。
Java实现:
public int majorityElement(int[] nums) { int candidate = 0; int count = 0; for (int num : nums) { if (count == 0) { candidate = num; count = 1; } else if (num == candidate) { count++; } else { count--; } } return candidate; }5.2 正确性证明:为什么"幸存者"一定是多数元素
这是整个算法里最容易让人迷惑的地方,我当初也花了些时间才彻底想通。关键结论有两层:
第一层,抵消过程不会让多数元素"消失"。也就是说,如果数组里确实存在出现次数大于 n/2 的多数元素,那么在任意时刻的抵消过程中,多数元素的净剩余数量始终大于0。为什么?因为多数元素单方数量已经超过一半,其他所有元素加起来都不如它多。即使所有非多数元素都联合起来和它对消,对消结束时多数元素依然有剩余。
第二层,count == 0时更换候选者,不等于丢弃当前候选者的所有"战绩"。实际上,count归0意味着此前扫描过的部分可以整体舍弃。因为在已经扫描的子数组中,各个元素的出现次数相互抵消,无论这段子数组里存在多少元素,它们对剩下部分的影响等于零。而多数元素在所有元素中占比超过一半,在舍弃的任意"抵消段"里,它最多被消耗掉与其相等数量的其他元素,这种消耗不会改变多数元素在剩余部分中的多数地位。
所以,当算法遍历完整个数组后,candidate一定是多数元素。空间复杂度O(1),时间复杂度O(n),一步到位。
5.3 一个必须警惕的坑:题目不保证存在多数元素时,投票算法会给出错误结果
题目明确写了"你可以假设数组是非空的,并且给定的数组总是存在多数元素",所以很多题解不会额外加验证步骤。但真实场景未必如此。如果输入数组是 [1, 2, 3],投票算法会一路换候选者,最终输出3,可3的出现次数只有1次,根本不是多数元素。
因此,稳妥的工程写法是在投票结束后,再遍历一遍数组统计candidate的真实出现次数,验证是否大于 n/2。面试时主动说出这个边界条件,通常会让面试官认为你真的理解了算法而不只是背了模板。
我见过有人在简历里写"熟悉Boyer-Moore算法",但在追问下说出"如果多数元素不存在怎么办"时沉默。这种细节往往是一票否决的关键点。
6. 从169题的思考方式延伸出去:变体、扩展与面试常见追问
6.1 变体一:找出现次数超过n/3的所有元素
LeetCode 229题正是这个扩展:给定一个大小为n的数组,找出所有出现次数大于 n/3 的元素。结论是:这样的元素最多只有两个。原因很简单,如果有3个元素出现次数都大于 n/3,总次数会超过3 * n/3 = n,矛盾。
因此可以把摩尔投票算法扩展为"双候选者"版本:维护两个候选者和两个计数器,遍历时优先处理与候选者相等的情况,否则三者一起抵消。最后再验证两个候选者的真实计数。这个变体的思路比169题本身更有挑战性,也是面试中常见的追问方向。我建议你把169吃透后直接去做一下229,会理解得更深。
6.2 变体二:如何应对"数据流中找多数元素"
真实工程里,数据可能是源源不断到达的,你没法一次性拿到完整数组。此时哈希表方案必须维护所有元素的计数,内存开销会不断增长;排序方案根本无法落地;只有摩尔投票算法能够在线处理——每来一个数更新候选者和计数器,内存占用固定。这算是投票算法在流式场景下的天然优势。流式统计异常日志、在线投票系统、实时舆情监控,都可能用到这个思路。
6.3 随机化算法:一个面试中的加分补充
这里再介绍一种不那么主流但值得知道的思路:随机选元素,然后验证它是不是多数元素。因为多数元素占比超过一半,随机挑一个元素,它就是答案的概率超过1/2。一次不行就再随机挑一次,期望次数是常数级别。实际中可以取比如20次随机尝试,每次验证遍历一次数组,如果都失败就退回其他解法。
这个办法看起来"不优雅",但它在某些数据分布下非常实用。比如在大规模数据上先随机抽样几个点验证,如果命中就提前返回,减少不必要的全量扫描。面试时作为"破坏性思维"提一句,效果很好。
7. 三种核心解法的横向对比与选型建议
| 解法 | 时间复杂度 | 空间复杂度 | 实现难度 | 适用场景 |
|---|---|---|---|---|
| 暴力双层遍历 | O(n²) | O(1) | 极低 | 几乎不用,仅作参照 |
| 哈希表计数 | O(n) | O(n) | 低 | 通用,数据量可控 |
| 排序取中位 | O(nlogn) | 视排序算法而定 | 极低 | 已排序数据或代码精简优先 |
| Boyer-Moore投票 | O(n) | O(1) | 中 | 流式数据、内存受限、面试优解 |
选择哪种方案,关键看约束条件。如果面试官限定了空间复杂度O(1),投票算法是标准答案;如果没有限定,哈希表方案实现最快、最不易出错;如果数据本身就是有序的,排序后直接取中位数当然最省事。
我在实际刷题中通常分三步走:先用HashMap把题目过一遍确认思路,然后想到排序法作对比,最后用投票算法把空间压到底。这样三种解法都亲自跑一遍,记忆远比只看题解深刻。尤其推荐在LeetCode上用自己的测试用例验证投票算法每一步candidate和count的变化,追踪几次之后,算法的直觉就内化了。
8. 写在最后:这道题真正想训练的能力
刷题不能只满足于"AC了"。拿到一道题,更值得做的是把复杂度从高到低收敛的过程想明白:暴力慢在哪里,哈希快在哪里,投票省空间省在哪里。多数元素这道题刚好把这些思考浓缩进了一个极简问题里,所以它才能遍布各家题库的热门榜单,被反复拿来考察。
我个人的体会是,算法能力的提升靠的不是记住解法模板,而是形成一条完整的思考链路:先抽象问题本质,再尝试不同策略,然后用复杂度分析筛选方案,最后用边界条件检验正确性。169题正是练习这条链路的最佳素材之一。做完这道题后,顺手去把229题也做了,然后把摩尔投票的思路讲给身边的人听——讲得清楚,才算真正掌握。