把169题真正吃透,是我一直认为检验算法基本功是否扎实最有效的方式之一。这道题就是LeetCode上大名鼎鼎的“多数元素”(Majority Element),题目描述只有一句话:给定一个大小为 n 的数组,返回其中的多数元素,多数元素指在数组中出现次数大于 ⌊n/2⌋ 的元素。很多人刷过它,也能背出摩尔投票的模板,但当你追问一句“为什么抵消完之后剩下的就一定是多数元素”,很多人就卡住了。这篇文章准备把169题彻底拆透——从最暴力的解法一路优化到最优解,把每种思路的复杂度、适用场景、代码模板、面试追问一层层剥开,适合正在准备算法面试的开发者,也适合想系统整理LeetCode知识点、建立解题思维框架的朋友。
1. 169题的题面设定:一个“超过一半”带来的数学优势
1.1 “大于 ⌊n/2⌋”到底意味着什么
多数元素定义里最关键的一个词是“大于”,不是“大于等于”。数组长度 n=7 时,⌊7/2⌋=3,元素出现3次不满足条件,必须至少出现4次;n=8 时,⌊8/2⌋=4,必须至少出现5次。这个“严格超过一半”的设定非常讲究,它是后面所有巧妙解法的根基。
如果阈值换成“恰好一半”,事情就变得非常棘手。比如 [1,2,1,2] 里没有出现次数等于2且唯一的元素吗?其实1和2都出现了2次,你会面临“选哪个”的歧义问题。可一旦要求“超过一半”,在任意数组中,满足条件的元素最多只有一个。这个唯一性直接让我们可以把问题从“找多个可能答案”缩小成“锁定唯一目标”。
从数学角度还能推出一个直观结论:把多数元素出现次数记为 k,k > n/2,那么 k - (n - k) > 0。换句话说,多数元素的数量减去所有非多数元素的数量总和,结果一定是个正数。这个“差额为正”才是摩尔投票算法成立的底层原因,也是排序法“取中间位必然命中”的数学依据。很多刷题笔记会直接抛结论,但我建议你自己拿张草稿纸把 n=5、n=6、n=7 三种情况画一画,把元素排成排,会发现多数元素无论怎么分布,都像一根承重墙一样“压”住了数组的中间位置。
1.2 “题目总是保证存在多数元素”这句话有多重要
LeetCode 169 的题干里有一句非常容易被忽略的前提:可以假设数组非空,并且给定的数组总是存在多数元素。这句话直接决定了很多解法的代码可以写得非常短。
以摩尔投票为例,如果题目不保证多数元素存在,那算法跑完后得到的 candidate 可能只是一个“伪多数元素”,必须再遍历一遍数组,统计 candidate 的真实出现次数,确认大于 n/2 后才能返回。但正因为 169 题给了这个强保证,代码里可以直接 return candidate,省掉验证环节。
很多人在面试里翻车,就是因为把别的高频题里“可能不存在多数元素”的条件混进了 169 的解答里,或者反过来在 229 题(多数元素 II)里忘记做第二遍验证。我的建议是:读题时第一件事就在草稿纸上圈出“总是存在”四个字,它直接决定你要不要多写一个 for 循环。后文第4章我会专门展开这个问题的边界处理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 解法进化的完整链路:从暴力哈希到排序分治
2.1 暴力枚举:只作为思考起点,不写进代码
最直观的思路肯定是暴力枚举:外层循环选一个元素,内层循环统计它在整个数组里出现了多少次,第一次遇到计数大于 n/2 的元素直接返回。时间复杂度是 O(n²),因为 n 个元素各数一遍要 O(n),两层循环就是 O(n²);空间复杂度 O(1)。
python复制def majority_element_brutal(nums):
n = len(nums)
for i in range(n):
count = 0
for j in range(n):
if nums[j] == nums[i]:
count += 1
if count > n // 2:
return nums[i]
return -1
为什么不推荐?当 n 到 10^5 级别时,n² 意味着 10^10 次操作,在 LeetCode 上几乎稳定超时。但暴力法的价值在于帮我们确认问题定义:既然我们要找的是出现次数超过一半的元素,那核心工作就是“计数”。任何后续优化,本质上都是在思考如何减少“计数”的开销。
2.2 哈希表计数:最通用也最容易想到的解法
用哈希表(Python 里的 dict / Counter、Java 里的 HashMap)统计所有元素的出现次数,遍历完后再扫一遍哈希表,找到计数大于 n/2 的那个键。
python复制from collections import Counter
class Solution:
def majorityElement(self, nums: List[int]) -> int:
counter = Counter(nums)
n = len(nums)
for num, cnt in counter.items():
if cnt > n // 2:
return num
时间复杂度 O(n),空间复杂度 O(n)。
哈希表解法最大的优点是通用性极强。不管题目问的是“出现次数大于 n/2”“出现次数大于 n/3”,还是“出现次数最多的元素”,哈希表都能一套打天下,逻辑简单、不容易写错。缺点是需要 O(n) 的额外空间,以及两次遍历(一次统计、一次查找)。
在面试中,我通常建议把哈希表作为第一个说出来的解法,因为它是“下限高、风险低”的代表。然后顺着面试官“能不能省掉额外空间”的追问,自然过渡到排序法或者摩尔投票法。但要注意,哈希表虽然能解出 169,但它没有用到“超过一半”这个特殊条件,属于一种“杀鸡用牛刀”的通用解。真正体现算法功底的,是后面几种利用了“超过一半”特性的优化方案。
2.3 排序取中:一个反直觉但极其简单的结论
把数组排序之后,直接返回下标 n//2 的元素。原因是:如果某个元素出现次数超过一半,那么排序后它一定会落在数组的中间位置。
这个结论很多人会觉得反直觉,其实用反证法想一下:假设多数元素 m 排序后没有覆盖到中间位置 nums[n//2],那么 m 要么全部排在中间位置的左边,要么全部排在右边。如果全部排在左边,说明 m 的数量最多只有 n//2 个(因为从下标0到 n//2-1 一共 n//2 个位置),这和不小于 n//2+1 矛盾;如果全部排在右边同理。所以 m 必然占据中间位置。
python复制class Solution:
def majorityElement(self, nums: List[int]) -> int:
nums.sort()
return nums[len(nums) // 2]
排序法的时间复杂度取决于语言内置排序算法,通常是 O(n log n);空间复杂度取决于是否原地排序,Python 的 sort() 是原地排序,空间复杂度 O(log n) 甚至 O(1)(取决于实现)。
排序法最值得学习的点在于:它把“统计频次”转化为“位置性质”,思路完全不同,但答案依然正确。我在实际面试中会把它作为“第二种解法”讲,因为代码极短,还能顺便展示对排序特性的理解。不过要注意,排序法有一个潜在问题:它依赖排序的稳定性吗?答案是不依赖,因为我们根本不在乎相同元素的相对顺序,只在乎中间位置的值。这也意味着不管你用快排、归并还是堆排序,结论都成立。
2.4 随机化:让概率帮你做题的冷门加分项
随机化解法在 169 题中算是一个小众思路:随机从数组里挑一个元素,然后遍历数组验证它是不是多数元素。因为多数元素出现次数超过 1/2,所以随机选一次,选中的概率大于 1/2;如果没选中,再随机选一次。每次验证需要 O(n),期望尝试次数小于 2 次,所以期望时间复杂度 O(n),空间 O(1)。
python复制import random
class Solution:
def majorityElement(self, nums: List[int]) -> int:
n = len(nums)
while True:
candidate = random.choice(nums)
cnt = sum(1 for num in nums if num == candidate)
if cnt > n // 2:
return candidate
随机法的实战意义不在于平时刷题,而在于面试中展示“概率思维”。当面试官追问“有没有办法用 O(1) 空间解决”时,除了摩尔投票,你还可以提一句“理论上随机化也能达到期望 O(n),但最坏情况可能无限循环,所以实际工程中不会这么写”。这一句话就能让对方感觉到你不仅会背模板,还理解算法在不同场景下的取舍。
2.5 分治合并:面试官最爱追问的递归思路
分治的思路是把数组从中间切成两半,递归地求出左半部分的多数元素和右半部分的多数元素。然后合并:如果两边求出的多数元素相同,直接返回;如果不同,分别统计这两个元素在当前整个区间内的出现次数,次数多的那个就是整个区间的多数元素。
python复制class Solution:
def majorityElement(self, nums: List[int]) -> int:
def conquer(left: int, right: int) -> int:
if left == right:
return nums[left]
mid = (left + right) // 2
left_major = conquer(left, mid)
right_major = conquer(mid + 1, right)
if left_major == right_major:
return left_major
left_cnt = sum(1 for i in range(left, right + 1) if nums[i] == left_major)
right_cnt = sum(1 for i in range(left, right + 1) if nums[i] == right_major)
return left_major if left_cnt > right_cnt else right_major
return conquer(0, len(nums) - 1)
时间复杂度递推式为 T(n) = 2T(n/2) + O(n),由主定理可得整体复杂度 O(n log n);递归调用栈深度 O(log n),所以空间复杂度 O(log n)。
我为什么说面试官爱追问分治?因为它能顺理成章地引出“怎么把区间多数查询扩展到任意子数组”的问题,也就是 LeetCode 1157 这类线段树变种题。如果你在面试中能主动从 169 的分治写法延伸到“线段树每个节点维护区间候选者”的思路,那绝对是个非常大的加分项。不过那是后话,本节先把分治代码本身吃透即可。
下面是六种解法的复杂度对比,建议直接收藏:
| 解法 | 时间复杂度 | 空间复杂度 | 核心思想 |
|---|---|---|---|
| 暴力枚举 | O(n²) | O(1) | 直接计数 |
| 哈希表 | O(n) | O(n) | 空间换时间 |
| 排序取中 | O(n log n) | O(1)或O(log n) | 中间位置性质 |
| 随机化 | 期望O(n) | O(1) | 概率验证 |
| 分治合并 | O(n log n) | O(log n) | 递归合并 |
| 摩尔投票 | O(n) | O(1) | 抵消思想 |
3. 摩尔投票法:为什么它是这道题的最优解
3.1 算法流程与多语言模板
摩尔投票(Boyer-Moore Majority Vote Algorithm)的核心是维护一个候选者 candidate 和一个计数器 count。遍历数组时:
- 如果 count == 0,将当前元素设为 candidate,count 置为 1。
- 如果当前元素等于 candidate,count 加 1。
- 如果当前元素不等于 candidate,count 减 1。
遍历结束后,candidate 就是多数元素。由于 169 题保证存在多数元素,直接返回即可。
Python 版本:
python复制class Solution:
def majorityElement(self, nums: List[int]) -> int:
candidate = None
count = 0
for num in nums:
if count == 0:
candidate = num
if num == candidate:
count += 1
else:
count -= 1
return candidate
Java 版本:
java复制class Solution {
public int majorityElement(int[] nums) {
int candidate = 0;
int count = 0;
for (int num : nums) {
if (count == 0) {
candidate = num;
}
if (num == candidate) {
count++;
} else {
count--;
}
}
return candidate;
}
}
C++ 版本:
cpp复制class Solution {
public:
int majorityElement(vector<int>& nums) {
int candidate = 0;
int count = 0;
for (int num : nums) {
if (count == 0) {
candidate = num;
}
count += (num == candidate) ? 1 : -1;
}
return candidate;
}
};
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--
}
}
return candidate
}
实现细节上,candidate 初始化成什么值不重要,因为 count == 0 时立刻会被当前元素覆盖。但为了可读性,Python 里用 None,其它语言里用 0 或 nums[0] 都可以。比较时要注意必须先判断 count == 0 再更新 candidate,再比较计数,逻辑顺序不要写反。
3.2 用“正负抵消”理解为什么剩下的就是答案
摩尔投票的直观解释其实很形象:把数组看作一场“投票对抗”,每个元素就是一个选民,多数元素是真正的“主角”。candidate 是当前擂台上的守卫者,count 就是守卫者手里的“命牌”。
遍历到与 candidate 相同的元素时,命牌 +1;遍历到不同的元素时,命牌 -1。当命牌归零时,说明当前守卫者已经被反对者全部抵消掉了,它退场,换成新的元素来守卫。
为什么最后剩下的守卫者一定是多数元素?关键在于多数元素的数量优势。假设多数元素为 m,出现次数为 k,k > n/2,那么 m 的总票数比所有非 m 的票数加起来还要多。无论对手怎么排列组合,m 在每一轮“一对一抵消”中都不会被彻底消灭;反而其它任何元素都可能因为内部分裂或者被 m 抵消而提前出局。最后留在擂台上的一定是具有“净正票数”的元素,也就是 m。
严格一点的证明可以这样写:算法维护的 count 本质上记录的是“从当前候选元素开始,净胜出的票数差”。当 count 清零,说明从上次清零位置到如今这段子数组里,各元素票数恰好一样多,可以整段丢弃。而被丢弃的这些子数组中,多数元素和非多数元素的数量相等,所以丢弃它们不会影响剩余部分中多数元素的主导地位。如此反复丢弃抵消段,最后剩下的部分里,多数元素仍然占据多数,那个元素就是 candidate。
用一个例子跑一遍:
code复制nums = [2, 2, 1, 1, 1, 2, 2]
- 遍历到第1个元素 2:count 为 0,candidate=2,count=1。
- 遍历到第2个元素 2:与 candidate 相同,count=2。
- 遍历到第3个元素 1:不同,count=1。
- 遍历到第4个元素 1:不同,count=0。
- 遍历到第5个元素 1:count 为 0,candidate=1,count=1。
- 遍历到第6个元素 2:不同,count=0。
- 遍历到第7个元素 2:count 为 0,candidate=2,count=1。
最终 candidate=2,而 2 出现 4 次,确实是多数元素。过程中 candidate 从 2 换成 1 又换回 2,但最终没有被换掉,这就是“净胜票差永远为正”的体现。
3.3 摩尔投票最常见的三个认知误区
误区一:认为摩尔投票统计的是“出现次数最多的元素”。不对。它只保证在存在多数元素时返回多数元素。如果数组是 [1,2,3],不存在超过一半的元素,摩尔投票可能返回 3,但出现次数最多的其实是 1、2、3 并列。所以它不能当“众数统计器”用。
误区二:认为 count 归零时替换 candidate 会丢失之前的多数信息。实际上不会。count 归零意味着从上一个归零点到当前位置之间,候选者和反对者刚好数量相等,这段可以整体丢弃。数学上可以证明,丢弃这些“打平段”后,剩余数组中多数元素的性质不变,所以 candidate 的替换是安全的。
误区三:在题目不保证存在多数元素时,忘记第二遍验证。这是最典型的坑。如果数组里根本没有多数元素,摩尔投票返回的 candidate 很可能是错的,必须在遍历结束后再统计 candidate 的出现次数,确认是否大于 n/2。
4. 实际刷题与面试中的经典坑位
4.1 没有“总是存在多数元素”保证时,第二遍验证怎么写
很多变式题会把 169 的条件改掉,比如“给定一个数组,找出出现次数大于数组长度一半的元素,如果不存在返回 -1”。这时候摩尔投票必须加验证环节:
python复制def majority_element_with_check(nums):
candidate = None
count = 0
for num in nums:
if count == 0:
candidate = num
count += 1 if num == candidate else -1
# 第二遍统计验证
total = nums.count(candidate) if candidate is not None else 0
return candidate if total > len(nums) // 2 else -1
注意 Python 列表的 count 方法是 O(n) 的,所以整体时间复杂度仍是 O(n);空间 O(1)。如果要更严谨,就自己写循环统计,不要额外分配容器。
4.2 手写摩尔投票时最容易写错的三个细节
细节一:candidate 的初始化。最保险的写法是 candidate = None(Python)或 candidate = 0(Java/C++),因为 count == 0 时会被立即覆盖。但如果你把 candidate 初始化为 nums[0],那么空数组会越界,这点在 LeetCode 上虽然不会触发(题目保证非空),但面试手写时面试官可能会拿空数组来刁难。
细节二:count 的判断顺序。先判断 count == 0,再比较 num == candidate,最后更新 count。如果顺序颠倒,可能出现 candidate 还没更新,就把它和 num 比较的 bug。尤其是 count 减到 0 后,下一次遍历要先换候选人,再决定计数方向。
细节三:变量命名。不要写 x、y、a、b 这种无意义命名,面试官会默认你缺乏工程素养。用 candidate / count 或 cand / cnt 都行,代码一眼就能看懂。
4.3 如何组织一场“让面试官满意”的递进式回答
我在模拟面试时经常看到有人一上来就背摩尔投票,结果面试官追问一句就崩了。正确的回答节奏应该是这样:
第一步,先说:“这题最简单也最稳妥的方法是哈希表统计,时间 O(n)、空间 O(n)。”然后快速说清思路,别急着写代码。
第二步,补充:“如果要求 O(1) 空间,有一个基于抵消思想的解法叫摩尔投票,它利用多数元素超过一半这个性质,线性扫描一遍就能找到候选者。”然后画一个抵消过程的示意图。
第三步,在写代码前主动说:“因为题目保证存在多数元素,遍历完可以直接返回;如果题目不保证,我会再加一遍统计验证。”这句话能直接堵住面试官最想追问的那个点。
第四步,写完代码后,主动举一个计数归零、换候选人的例子走一遍,证明代码逻辑闭环。
这样一套下来,基本就展示了对这道题从思路上到工程实践上的完整理解。刷题不能只刷“会写模板”,更要刷“会讲逻辑”。
4.4 这道题在流式数据场景下的扩展
面试官如果继续深挖,很可能问:“如果数组不是一次性给我们,而是一个接一个地流式输入,你还能求多数元素吗?”
哈希表可以,因为它只需要每次对 incoming 元素计数,更新表里的值,但空间仍是 O(n)。排序法和分治法就失效了,因为它们需要拿到完整数组才能操作。摩尔投票依然可以,因为它是真正的“在线算法”,每次只维护 candidate 和 count 两个变量,新元素来了就更新一次,永远不回头。这个特性让摩尔投票在流式计算、大数去重统计、近似众数检测等场景里有实际价值。
我建议你在回答流式数据问题时,顺带点出“在线算法”这个词,再举一个业务场景,比如网关日志里实时监测某个错误码是否占比超过一半,这就是摩尔投票的落地场景。能说出这层,面试官基本就会觉得你真的理解了。
5. 从169延伸到更多变式:多数元素问题的完整家族
5.1 LeetCode 229:出现次数大于 n/3 的元素
169 的阈值是超过一半,229 的阈值是超过三分之一。超过 n/3 的元素最多只能有 2 个,因为如果有 3 个元素都超过 n/3,总数就超过 n 了。利用这个性质,摩尔投票可以扩展成“维护两个候选者、两个计数器”的版本。
第一轮扫描:维护 cand1、cnt1、cand2、cnt2。遍历每个元素:
- 如果 num == cand1,cnt1++;
- 否则如果 num == cand2,cnt2++;
- 否则如果 cnt1 == 0,cand1 = num,cnt1 = 1;
- 否则如果 cnt2 == 0,cand2 = num,cnt2 = 1;
- 否则,cnt1--,cnt2--。
第二轮扫描:分别统计 cand1 和 cand2 的真实出现次数,大于 n/3 才加入答案。
python复制class Solution:
def majorityElement(self, nums: List[int]) -> List[int]:
cand1 = cand2 = None
cnt1 = cnt2 = 0
for num in nums:
if num == cand1:
cnt1 += 1
elif num == cand2:
cnt2 += 1
elif cnt1 == 0:
cand1 = num
cnt1 = 1
elif cnt2 == 0:
cand2 = num
cnt2 = 1
else:
cnt1 -= 1
cnt2 -= 1
return [x for x in (cand1, cand2) if x is not None and nums.count(x) > len(nums) // 3]
这段代码的坑在于:cand1 和 cand2 可能相同,需要后续去重;还有第二轮验证千万别省,因为第一轮结束后的候选者并不保证出现次数超过 n/3。
5.2 更一般的“出现次数大于 n/k”问题
把阈值推广到 n/k,那么满足条件的元素最多有 k-1 个,可以用维护 k-1 个候选者的通用模板。思路和 k=3 完全一致:k-1 个候选者、k-1 个计数器;遍历时先匹配已有候选者,再找空位,最后对不匹配任何候选者的元素做整体减一。时间复杂度 O(nk),空间 O(k)。当 k 比较小(比如 3、4)时非常实用。
这个通用模板在面试里属于“可迁移知识”。一旦你说清了 169,再主动提到 229 和 n/k 扩展,面试官很容易就会认可你的算法抽象能力。但注意不要为了炫技把代码写得过长,实际面试中先写对再优化,才是最重要的。
5.3 从“数组全体多数”到“任意子区间多数”
LeetCode 1157 是另一个方向的扩展:给定一个数组,反复查询某个子区间 [l, r] 内是否存在出现次数超过区间长度一半的元素,并返回这个元素。暴力做法是每次查询都扫一遍区间,复杂度 O(n)。更优的做法是线段树:每个节点保存当前区间内的“候选多数元素”,合并左右子节点时复用 169 题分治版的合并逻辑。查询时把区间分解成 O(log n) 个线段树节点,再两两合并候选者。
这道题偏难,通常不是新手必须刷的内容。但我建议你在理解 169 的分治版本之后,去翻一翻 1157 的题解,因为它的核心就是“169 分治思想的工业化落地”,能看出很多大厂面试题是怎么从一道经典题延伸出复杂模型的。
5.4 这道题和“众数”“平均值”等概念的区别
刷题时很多人会把“多数元素”和“众数”混为一谈。严格来说,众数是出现次数最多的元素,可能有多个;而多数元素是出现次数超过一半的元素,最多一个,且可以用摩尔投票精确定位。LeetCode 169 里的英文名 Majority Element,翻译成“主要元素”更准确,它不是一个统计意义上的众数概念。
这个区别在题目扩展时非常关键。例如问“找到出现次数最多的元素”,哈希表是标准解法,摩尔投票就不适用。而问“是否存在占比超过一半的元素”,摩尔投票就是最优解。做题前先分清概念,才能选对工具。
从 169 这道题能延伸出的内容实在太多。我在实际刷题过程中养成了一个习惯:把每道经典题背后的算法思想整理成一张“知识网”,比如 169 连接着哈希、排序、分治、随机化、流式处理、线段树、计数问题等多个方向。当你把一道题从暴力到最优,从不适用场景到扩展变式都走了一遍,这道题才真正成为了你的知识,而不是短暂的记忆。刷题最忌讳的,就是看到一道题能 AC(Accepted)就立刻滑到下一题,那只是在训练手指,不是在训练大脑。
