--- tags: [技巧, 摩尔投票, Boyer-Moore, 数组] create time: 2026-05-16 18:00 --- # 97-多数元素 ## 题面 > **LeetCode 169. Majority Element** 给定一个大小为 `n` 的整数数组,返回其中的**多数元素**。多数元素是指在数组中出现次数 **大于 ⌊ n/2 ⌋** 的元素。 **要求:** - 线性时间复杂度 $O(n)$ - 常量额外空间 $O(1)$ **假设:** - 数组非空 - 给定的数组**总是存在**多数元素 **示例 1:** ``` 输入:nums = [3, 2, 3] 输出:3 ``` **示例 2:** ``` 输入:nums = [2, 2, 1, 1, 1, 2, 2] 输出:2 ``` **提示:** - `n == nums.length` - `1 <= n <= 5 * 10^4` - `-10^9 <= nums[i] <= 10^9` --- ## 思路 ### 先思考一个问题 🤔 > 如果一个候选者在选举中获得了超过半数选票,那么在"一票赞成、一票反对"互相抵消的过程中,他还能活到最后吗? > [!tip] 💡 直觉方案 > 暴力做法是用哈希表统计每个数字的出现次数——但这需要 $O(n)$ 空间。排序后取中间位置的元素可以做到 $O(1)$ 空间但代价是 $O(n \log n)$ 时间。**有没有办法在扫一遍的同时用常数空间找到答案?** ### 核心洞察:投票消除法 ⭐ 多数元素的定义是出现次数 **大于 n/2**。这是一个非常强的条件——意味着它的数量比其他所有元素加起来还多。 > [!question] 启发式提问 > 如果把多数元素想象成"支持票",其他所有元素加起来是"反对票"。每次拿一张支持票和一张反对票互相抵消,最后剩下来的会是谁? 因为多数元素的数量 **> n/2**,即使每一次抵消都恰好消耗一张多数元素的票和一张其他元素的票,多数元素的票也一定会有剩余! 这就是 **Boyer-Moore 投票算法** 的核心思想:**维护一个候选者和它的支持计数,遇到相同的就加票,遇到不同的就抵消。** ### 算法流程 ``` 维持两个变量: - candidate:当前的"候选人" - count:当前候选人的"净票数" 遍历数组: - 如果 count == 0 → 更换候选人 - 如果当前元素 == candidate → 加一票(count++) - 如果当前元素 != candidate → 抵消一票(count--) 最终 candidate 就是多数元素 ``` ### 逐步跟踪演示 以 `nums = [2, 2, 1, 1, 1, 2, 2]` 为例(n=7,多数元素需出现 > 3 次即至少 4 次): | 步骤 | 读取元素 | 操作 | candidate | count | 解读 | |------|---------|------|-----------|-------|------| | 初始 | - | 初始化 | `2` | `1` | 第一个元素自动成为候选人 | | 1 | `2` | 相同,加票 | `2` | `2` | 二号支持者到来 | | 2 | `1` | 不同,抵消 | `2` | `1` | 一敌一消 | | 3 | `1` | 不同,抵消 | `1` | `0` | 票归零,候选人更换 | | 4 | `1` | count=0,重选 | `1` | `1` | 新候选人上位 | | 5 | `2` | 不同,抵消 | `1` | `0` | 又归零了 | | 6 | `2` | count=0,重选 | `2` | `1` | 再次更换 | 等等——最后的 candidate 是 `2`,正好是正确答案! > [!info] 为什么算法保证正确? 设多数元素出现了 $m$ 次,非多数元素共出现 $n-m$ 次,且 $m > n/2$。 整个过程中,每次抵消都会同时消耗一张多数元素的票和一张非多数元素的票。最多被抵消的次数是非多数元素的数量 $(n-m)$。因此多数元素剩余的票数至少为: $$m - (n - m) = 2m - n > 0$$ 所以无论抵消的顺序如何,多数元素永远不可能被完全清除。当 `count` 归零时,更换的候选人不一定是多数元素,但后续的过程会在新的起始点重新累积——**只要后面还有足够的多数元素票就能翻盘**,而根据上面的推导,最终一定能把多数元素推到胜出位置。 > [!summary] 🎯 关键结论 > > 多数元素"数量超半"的特性保证了它在任何一对一抵消规则下都是"打不死的小强"。 ### 流程图 ```mermaid flowchart TD Start(["开始
candidate = _, count = 0"]) --> Loop{"有下一个元素?"} Loop -- 否 --> Result["返回 candidate ✅"] Loop -- 是 --> ZeroCheck{count == 0?} ZeroCheck -- 是 --> SetCand["candidate = 当前元素"] ZeroCheck -- 否 --> Match{当前元素 == candidate?} Match -- 是 --> Inc["count++"] Match -- 否 --> Dec["count--"] SetCand --> Next Inc --> Next Dec --> Next Next --> Loop Result --> End(["结束"]) ``` ### 备选方案对比 | 方法 | 时间复杂度 | 空间复杂度 | 说明 | |------|----------|----------|------| | **Boyce-Moore 投票** ⭐ | $O(n)$ | $O(1)$ | 最优解,本题标准答案 | | 哈希表计数 | $O(n)$ | $O(n)$ | 直观但空间不达标 | | 排序取中位 | $O(n \log n)$ | $O(1)$ 或 $O(\log n)$ | 多数元素一定在 `nums[n/2]` | | 随机抽样 | $O(n)$ 期望 | $O(1)$ | 随机选一个数验证,期望很快命中 | > [!note] 📊 为什么排序也能做? 既然多数元素出现超过一半,把它排序后放在任意位置,索引 `n/2` 处一定是多数元素。就像超过半数的人穿同一种颜色的衣服站成一排,从中间看去必然是那种颜色。但这不是最优解,仅作面试展示思路广度之用。 --- ## 代码提示 > [!abstract] 📝 Go 伪代码框架 ``` candidate := 不存在 count := 0 遍历 nums 中的每个 num: 如果 count == 0: candidate = num 否则如果 num == candidate: count++ 否则: count-- 返回 candidate ``` > [!step] 实现要点 1. **count 为零时的处理是最关键的细节**——此时"选举重新开始",当前元素自动成为新候选人 2. **不需要最后验证**——题目保证了多数元素一定存在,省去了反证步骤 3. **一次遍历即可**——不需要像某些变体那样分两阶段 --- ## 技巧 > [!summary] 🔑 核心模式记忆 ```go count, candidate := 0, 0 for _, v := range nums { if count == 0 { candidate = v } if v == candidate { count++ } else { count-- } } return candidate ``` 看到 **"出现次数超过 n/k"** + **"O(1) 空间"** → 优先考虑摩尔投票或其推广。 ### 1. 摩尔投票的思想本质 > [!quote] 💬 一句话总结 > > **"少数互抵消,少数扛 majority"** ——让不同的互相消灭,剩下的就是强者。 这是一种"去冲突"的思维:不需要知道每个元素的具体频次,只需要关注"相同还是不同"这个相对关系。 ### 2. 扩展:出现次数 > n/3 的元素 当条件放宽到"出现次数超过 n/3"时,最多可能有 **两个** 这样的元素。摩尔投票可以自然地推广到维护**两个候选者**: ```go func majorityElement(nums []int) []int { cand1, cand2, c1, c2 := 0, 0, 0, 0 // 第一阶段:选出两个候选人 for _, v := range nums { switch { case v == cand1: c1++ case v == cand2: c2++ case c1 == 0: cand1, c1 = v, 1 case c2 == 0: cand2, c2 = 1 default: c1-- c2-- } } // 第二阶段:验证(题目若保证存在可省略) var result []int threshold := len(nums) / 3 for _, v := range nums { if v == cand1 && c1 > threshold { result = append(result, cand1); break } if v == cand2 && c2 > threshold { result = append(result, cand2); break } } return result } ``` > [!warning] ⚠️ n/3 版本的注意事项 > > 与 n/2 版本不同,n/3 版本在第一阶段选出候选者后**必须再进行第二阶段验证**,因为可能存在两个候选者都不是真正的多数元素的情况(数组中没有任何元素真正超过 n/3)。 ### 3. 关联变体题 | 题目 | 变化点 | 核心思路 | |------|--------|---------| | **169. Majority Element** | 超过 n/2 | 摩尔投票 ✅ | | **229. Majority Element II** | 超过 n/3 | 双候选人摩尔投票 | | **GCD Sort 类问题** | 按最大出现频率选择 | 摩尔投票的变种应用 | ### 4. Go 运行细节 > [!example] 🐹 Go 特有注意事项 - `count` 和 `candidate` 的初始化值不重要,因为在第一次遍历时 `count == 0` 的条件一定会触发 `candidate = nums[0]` - 由于题目保证多数元素存在,无需第二阶段的计数验证——这比严格实现节省了 $O(n)$ 时间和 $O(n)$ 空间 - 如果想写得更紧凑,可以用 Go 的三元替代(if 表达式)来减少行数,但**可读性优先** --- ## 代码 ```go // majorityElement returns the majority element using the Boyer-Moore Voting Algorithm. // A majority element appears more than ⌊ n/2 ⌋ times. // The problem guarantees that a majority element always exists. func majorityElement(nums []int) int { // ── Step 1: 初始化 ── // count 为 0 时,下一次赋值会自动选定第一个候选人 var candidate, count int // ── Step 2: 投票阶段(一次遍历) ── for _, num := range nums { if count == 0 { // 当前没有候选人(或票数归零),新候选人上任 candidate = num } // 投给当前候选人,或者投给别人(抵消) if num == candidate { count++ // 支持 } else { count-- // 反对 } } // ── Step 3: 返回结果 ── // 题目保证多数元素一定存在,无需二次验证 return candidate } ``` ### 代码走读 > [!success] ✅ 复杂度总结 | 指标 | 结果 | 说明 | |------|------|------| | **时间复杂度** | $O(n)$ | 仅需一次线性扫描 | | **空间复杂度** | $O(1)$ | 仅使用了两个整型变量 `candidate` 和 `count` | 完美满足题目的进阶要求! > [!quote] 💬 面试建议 这道题是摩尔投票算法最经典的入门题。面试中被问到后: 1. **先阐述暴力解法**(哈希表 / 排序),展示你能想到多种方案 2. **引出"有没有 O(1) 空间的解法"**,自然过渡到摩尔投票 3. **手绘流程图**解释"抵消"过程,展现你的思路清晰程度 4. **证明正确性**——简要说明多数元素因数量过半而不可能被完全消除 5. **提及扩展到 n/3**——展示你对这个算法的理解不止于表面 > [!warning] ⚠️ 常见陷阱 > 1. **忘记处理 `count == 0` 的情况**——这是换候选人时机,漏掉会导致错误 > 2. **把 `==` 写成 `!=` 导致逻辑反转**——仔细检查条件分支 > 3. **不加验证地用于不保证存在的场景**——实际工程中应该增加第二阶段验证