1. 问题描述与理解
今天我们来聊聊力扣第169题"多数元素"这道经典算法题。题目描述很简单:给定一个大小为n的数组,找出其中的多数元素。多数元素是指在数组中出现次数大于⌊n/2⌋的元素。你可以假设数组是非空的,并且给定的数组总是存在多数元素。
举个例子,对于数组[3,2,3],多数元素是3,因为它出现了2次,而数组长度是3,2 > ⌊3/2⌋=1。再比如[2,2,1,1,1,2,2],多数元素是2,它出现了4次,而4 > ⌊7/2⌋=3。
注意题目中的两个关键假设:数组非空,且总是存在多数元素。这意味着我们不需要处理边界情况,可以专注于算法本身。
2. 常见解法分析
2.1 暴力解法
最直观的想法是暴力枚举:对于每个元素,统计它在数组中出现的次数,然后找出出现次数超过n/2的那个。这种方法的时间复杂度是O(n²),因为对于n个元素,每个都需要遍历整个数组来计数。
python复制def majorityElement(nums):
for num in nums:
count = 0
for n in nums:
if n == num:
count += 1
if count > len(nums)//2:
return num
虽然这种方法简单直接,但在力扣上提交时会因为超时无法通过,因为当n很大时(比如10^5),O(n²)的复杂度太高了。
2.2 哈希表统计法
我们可以用哈希表(字典)来优化统计过程,将时间复杂度降到O(n),但需要O(n)的额外空间。
python复制def majorityElement(nums):
counts = {}
for num in nums:
counts[num] = counts.get(num, 0) + 1
if counts[num] > len(nums)//2:
return num
这种方法利用了哈希表O(1)时间复杂度的查找特性,只需要遍历一次数组即可。在实际面试中,这通常是可以接受的解法,但有没有可能在不使用额外空间的情况下解决问题呢?
2.3 排序法
另一种思路是先对数组排序,因为多数元素出现次数超过一半,所以排序后中间位置的元素一定是多数元素。
python复制def majorityElement(nums):
nums.sort()
return nums[len(nums)//2]
这种方法的时间复杂度取决于排序算法,Python内置的sort()方法是O(n log n)时间复杂度,空间复杂度如果是原地排序则是O(1)。虽然比哈希表法慢一些,但代码极其简洁。
3. 最优解法:摩尔投票算法
3.1 算法原理
摩尔投票算法(Boyer-Moore Voting Algorithm)可以在O(n)时间复杂度和O(1)空间复杂度内解决这个问题。它的核心思想是对拼消耗:在数组中,多数元素的个数至少比非多数元素多一个,所以我们可以用计数的方式来找出多数元素。
算法步骤:
- 初始化候选元素candidate和计数器count=0
- 遍历数组:
- 如果count==0,将当前元素设为candidate
- 如果当前元素==candidate,count加1;否则count减1
- 最后剩下的candidate就是多数元素
3.2 算法实现
python复制def majorityElement(nums):
count = 0
candidate = None
for num in nums:
if count == 0:
candidate = num
count += (1 if num == candidate else -1)
return candidate
这个算法为什么有效?因为多数元素的数量比其他所有元素加起来还多,所以即使count被其他元素抵消,最终多数元素还是会剩下。
3.3 算法正确性证明
让我们用数学归纳法来证明这个算法的正确性:
- 基本情况:当数组只有一个元素时,算法直接返回该元素,显然正确。
- 归纳假设:假设算法对长度为n的数组正确。
- 归纳步骤:考虑长度为n+1的数组。
- 如果前n个元素的count为0,那么第n+1个元素成为候选,且由于多数元素存在,它必须是多数元素。
- 如果前n个元素的count不为0,那么多数元素在前n个中出现的次数减去其他元素出现的次数等于当前count。加入第n+1个元素后:
- 如果是多数元素,count增加1
- 如果不是,count减少1
- 由于多数元素总数超过一半,最终count会保持正数。
4. 算法复杂度分析
让我们比较一下各种解法的时间和空间复杂度:
| 方法 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 暴力枚举 | O(n²) | O(1) |
| 哈希表统计 | O(n) | O(n) |
| 排序法 | O(n log n) | O(1)或O(n) |
| 摩尔投票算法 | O(n) | O(1) |
显然,摩尔投票算法在时间和空间复杂度上都是最优的。这也是为什么它被广泛认为是这个问题的最佳解法。
5. 实际应用与变种
5.1 实际应用场景
多数元素算法在实际中有很多应用,比如:
- 选举计票系统中快速找出得票过半的候选人
- 数据流中找出高频元素
- 系统监控中检测占主导地位的异常模式
5.2 算法变种
摩尔投票算法可以扩展解决更一般的问题,比如找出所有出现次数超过n/k的元素。对于k=3的情况,算法可以这样实现:
python复制def majorityElement2(nums):
if not nums:
return []
# 初始化两个候选和计数器
cand1, cand2, count1, count2 = None, None, 0, 0
# 第一遍遍历找出两个候选
for num in nums:
if num == cand1:
count1 += 1
elif num == cand2:
count2 += 1
elif count1 == 0:
cand1, count1 = num, 1
elif count2 == 0:
cand2, count2 = num, 1
else:
count1 -= 1
count2 -= 1
# 第二遍遍历验证这两个候选是否真的满足条件
result = []
for cand in [cand1, cand2]:
if nums.count(cand) > len(nums)//3:
result.append(cand)
return result
这个变种展示了摩尔投票算法的灵活性,它可以被扩展来解决更复杂的问题。
6. 边界条件与测试用例
虽然题目假设了多数元素一定存在,但为了算法的健壮性,我们可以考虑如何处理不存在多数元素的情况:
python复制def majorityElement(nums):
count = 0
candidate = None
# 第一遍找出候选
for num in nums:
if count == 0:
candidate = num
count += (1 if num == candidate else -1)
# 第二遍验证候选是否真的是多数元素
count = 0
for num in nums:
if num == candidate:
count += 1
return candidate if count > len(nums)//2 else None
好的测试用例应该包括:
- 只有一个元素的数组
- 多数元素在数组开头或结尾
- 数组中有负数和零
- 多数元素刚好满足n/2+1次出现
例如:
python复制test_cases = [
([3,2,3], 3),
([2,2,1,1,1,2,2], 2),
([1], 1),
([0,0,1,1,1], 1),
([-1,-1,2], -1)
]
7. 不同语言的实现
摩尔投票算法在不同语言中的实现大同小异,但各有特点:
Java实现:
java复制public int majorityElement(int[] nums) {
int count = 0;
Integer candidate = null;
for (int num : nums) {
if (count == 0) {
candidate = num;
}
count += (num == candidate) ? 1 : -1;
}
return candidate;
}
JavaScript实现:
javascript复制function majorityElement(nums) {
let count = 0;
let candidate = null;
for (const num of nums) {
if (count === 0) {
candidate = num;
}
count += (num === candidate) ? 1 : -1;
}
return candidate;
}
C++实现:
cpp复制int majorityElement(vector<int>& nums) {
int count = 0;
int candidate = 0;
for (int num : nums) {
if (count == 0) {
candidate = num;
}
count += (num == candidate) ? 1 : -1;
}
return candidate;
}
8. 算法优化与注意事项
虽然摩尔投票算法已经很高效,但在实际实现时还是有一些优化点和注意事项:
-
初始值选择:有些实现会将candidate初始化为nums[0],count初始化为1,然后从第二个元素开始遍历。两种方式都正确,但前者代码更统一。
-
元素比较:在比较当前元素和candidate时,要注意处理candidate为None/null的情况,特别是在静态类型语言中。
-
并行计算:对于非常大的数组,摩尔投票算法可以很容易地并行化处理,因为计数操作是可交换和可结合的。
-
内存访问模式:算法是顺序访问数组元素,对CPU缓存友好,这也是它高效的原因之一。
实际编码时,建议先写出基本实现,再考虑这些优化。过早优化往往是浪费时间。
9. 相关题目推荐
掌握了多数元素问题后,可以尝试解决以下类似题目:
-
- 求众数 II(找出所有出现次数超过n/3的元素)
-
- 子数组中占绝大多数的元素(扩展到子数组查询)
- 面试题17.10. 主要元素(与本题几乎相同)
这些题目都可以用摩尔投票算法的变种来解决,是很好的练习材料。