一、问题描述
题目要求
给定一个长度为 n 的整数数组 numbers,请找出数组中出现的次数超过数组长度一半的数字。如果不存在则返回 0。
题目约束
-
1 ≤ n ≤ 10000
-
数组中的值 0 ≤ val ≤ 10000
-
要求时间复杂度 O(n),空间复杂度 O(1)
示例
text
输入: [1,2,3,2,2,2,5,4,2]
输出: 2
输入: [1,2,3,4,5]
输出: 0
二、三种解法对比分析
| 方法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|---|
| 暴力统计法 | O(n²) | O(1) | 简单直观 | 效率极低 | 仅学习用 |
| 排序法 | O(nlogn) | O(1)或O(n) | 实现简单 | 不符合要求,修改原数组 | 简单场景 |
| 摩尔投票法 | O(n) | O(1) | 最优解 | 需要理解原理 | 面试推荐 |
三、解法一:暴力统计法
代码实现
cpp
class Solution {
public:
int MoreThanHalfNum_Solution(vector<int>& numbers) {
for(size_t i = 0; i < numbers.size(); i++) {
int count = 1;
int it = numbers[i];
for(size_t j = 0; j < numbers.size(); j++) {
if(j != i && numbers[j] == it) {
++count;
}
}
if(count > numbers.size() / 2) {
return it;
}
}
return 0;
}
};
算法分析
1. 核心思路
-
遍历数组中的每个元素
-
对于每个元素,再次遍历整个数组统计其出现次数
-
如果某个元素的出现次数超过数组长度一半,返回该元素
-
如果遍历完没有找到,返回 0
2. 时间复杂度
-
外层循环: n 次
-
内层循环: n 次
-
总复杂度: O(n²)
3. 示例演示
text
输入: [1,2,2,2,3]
数组长度: 5, 需要超过 2.5,即至少 3 次
i=0, num=1, count=1 → 不满足
i=1, num=2, count=3 → 满足,返回2
4. 存在的问题
cpp
// 问题1:重复统计
// 对于相同的元素会重复统计多次
// 例如有两个相同的数字2,每个2都会被单独统计
// 问题2:效率极低
// 10000个元素的数组需要约1亿次比较
// 问题3:边界情况处理不完善
// 没有考虑空数组的情况
5. 优化建议
cpp
// 可以添加一些优化,但时间复杂度仍然是O(n²)
class Solution {
public:
int MoreThanHalfNum_Solution(vector<int>& numbers) {
if (numbers.empty()) return 0;
// 使用哈希集合记录已统计过的元素
unordered_set<int> visited;
for (size_t i = 0; i < numbers.size(); i++) {
// 如果当前元素已经统计过,跳过
if (visited.find(numbers[i]) != visited.end()) {
continue;
}
int count = 1;
for (size_t j = i + 1; j < numbers.size(); j++) {
if (numbers[j] == numbers[i]) {
++count;
}
}
visited.insert(numbers[i]); // 标记为已统计
if (count > numbers.size() / 2) {
return numbers[i];
}
}
return 0;
}
};
四、解法二:排序法
代码实现
cpp
class Solution {
public:
int MoreThanHalfNum_Solution(vector<int>& numbers) {
sort(numbers.begin(), numbers.end());
return numbers[numbers.size() / 2];
}
};
算法分析
1. 核心思想
-
将数组排序
-
如果存在出现次数超过一半的数字,那么排序后这个数字一定在中间位置
-
检查中间位置的数字是否真的出现超过一半
2. 完整实现(需要验证)
cpp
class Solution {
public:
int MoreThanHalfNum_Solution(vector<int>& numbers) {
if (numbers.empty()) return 0;
sort(numbers.begin(), numbers.end());
int candidate = numbers[numbers.size() / 2];
int count = 0;
// 验证候选数字是否真的超过一半
for (int num : numbers) {
if (num == candidate) {
++count;
}
}
return count > numbers.size() / 2 ? candidate : 0;
}
};
3. 时间复杂度
-
排序: O(nlogn)
-
验证: O(n)
-
总复杂度: O(nlogn)
4. 数学原理
假设数组中存在一个数字 x 出现超过一半:
-
排序后,数组被分为三部分:小于
x的部分,等于x的部分,大于x的部分 -
因为
x的数量超过一半,所以等于x的部分至少占据数组中间位置
text
示例: [1,2,2,2,3,4,2] → 排序后: [1,2,2,2,2,3,4]
长度7,中间位置是索引3,值为2
5. 优缺点分析
优点:
-
代码极其简洁
-
容易理解和实现
缺点:
-
改变了原数组(可能需要备份)
-
时间复杂度不是最优的
-
需要额外验证步骤(不能直接返回中间值)
五、解法三:摩尔投票法(最优解)
代码实现
cpp
class Solution {
public:
int MoreThanHalfNum_Solution(vector<int>& numbers) {
if (numbers.empty()) return 0;
int candidate = 0; // 候选数字
int votes = 0; // 票数
// 第一轮:找出候选数字
for (int num : numbers) {
if (votes == 0) {
candidate = num;
votes = 1;
} else if (num == candidate) {
++votes;
} else {
--votes;
}
}
// 第二轮:验证候选数字是否超过一半
int count = 0;
for (int num : numbers) {
if (num == candidate) {
++count;
}
}
return count > numbers.size() / 2 ? candidate : 0;
}
};
算法分析
1. 核心思想
-
将数组中的数字看作不同候选人的选票
-
遍历数组,维护一个候选数字和票数
-
遇到相同数字则票数加1,遇到不同数字则票数减1
-
票数为0时更换候选数字
2. 详细过程
text
示例: [1,2,3,2,2,2,5,4,2]
初始化: candidate = 0, votes = 0
步骤1: num=1, votes=0 → candidate=1, votes=1
步骤2: num=2, votes=1, num≠candidate → votes=0
步骤3: num=3, votes=0 → candidate=3, votes=1
步骤4: num=2, votes=1, num≠candidate → votes=0
步骤5: num=2, votes=0 → candidate=2, votes=1
步骤6: num=2, votes=1, num=candidate → votes=2
步骤7: num=5, votes=2, num≠candidate → votes=1
步骤8: num=4, votes=1, num≠candidate → votes=0
步骤9: num=2, votes=0 → candidate=2, votes=1
候选数字: 2
验证: 2出现5次 > 9/2=4.5 → 返回2
3. 数学证明
原理:
-
如果存在一个数字出现次数超过一半
-
那么它与其他数字一一抵消后,最后剩下的必然是它
证明 :
设数组长度为 n,目标数字 x 出现次数为 m > n/2
-
每次抵消消除一个 x 和一个非 x
-
最多可以消除 n/2 对(因为 m > n/2,所以 x 不会被完全消除)
-
最终剩下的必然是 x
4. 时间和空间复杂度
-
第一轮遍历: O(n)
-
第二轮验证: O(n)
-
总时间复杂度: O(n)
-
空间复杂度: O(1)(只使用了常数个变量)
六、算法可视化对比
性能对比图表
text
n = 10000 时的操作次数对比:
暴力法: n² = 100,000,000 次操作
排序法: n log n ≈ 10000 × 13.3 ≈ 133,000 次操作
摩尔投票法: 2n = 20,000 次操作
效率提升: 暴力法 : 排序法 : 摩尔投票法 ≈ 5000 : 6.7 : 1
内存使用对比
cpp
// 暴力法: 无额外内存
int count; // 4字节
// 排序法:
// 如果使用快速排序,递归调用栈 O(logn)
// 如果使用堆排序,额外空间 O(1)
// 摩尔投票法:
int candidate; // 4字节
int votes; // 4字节
int count; // 4字节
七、边界情况与错误处理
1. 空数组处理
cpp
if (numbers.empty()) return 0;
2. 只有一个元素的数组
cpp
// 直接返回该元素
if (numbers.size() == 1) return numbers[0];
3. 不存在超过一半的数字
cpp
// 必须进行第二轮验证
// 摩尔投票法找到的候选数字不一定真的超过一半
int count = 0;
for (int num : numbers) {
if (num == candidate) ++count;
}
return count > numbers.size() / 2 ? candidate : 0;
4. 大数测试
cpp
// 测试用例
vector<int> test1(10000, 5); // 全是5,应该返回5
vector<int> test2; // 空数组,应该返回0
vector<int> test3 = {1,2,3}; // 没有超过一半的,应该返回0
vector<int> test4 = {1,2,2}; // 2出现2次,3个元素需要>1.5,返回2
八、实际应用场景
1. 选举系统
-
快速找出得票过半的候选人
-
实时票数统计
2. 数据分析
-
寻找主要影响因素
-
异常值检测
3. 系统监控
-
识别频繁出现的错误类型
-
故障根因分析
4. 网络协议
-
数据包验证
-
一致性检查
九、扩展思考
1. 如果要求返回所有出现次数超过 n/k 的元素?
cpp
// 扩展摩尔投票法,维护 k-1 个候选
vector<int> majorityElement(vector<int>& nums, int k) {
unordered_map<int, int> candidates;
for (int num : nums) {
if (candidates.count(num)) {
++candidates[num];
} else if (candidates.size() < k - 1) {
candidates[num] = 1;
} else {
// 所有候选者票数减1
for (auto it = candidates.begin(); it != candidates.end(); ) {
if (--(it->second) == 0) {
it = candidates.erase(it);
} else {
++it;
}
}
}
}
// 验证阶段
vector<int> result;
for (auto& p : candidates) {
int count = 0;
for (int num : nums) {
if (num == p.first) ++count;
}
if (count > nums.size() / k) {
result.push_back(p.first);
}
}
return result;
}
2. 如果数组特别大,无法一次性加载到内存?
cpp
// 使用分治法或流式处理
// 只能使用摩尔投票法,因为它只需要常数空间
int streamMajorityElement(istream& data_stream) {
int candidate = 0, votes = 0;
int num;
while (data_stream >> num) {
if (votes == 0) {
candidate = num;
votes = 1;
} else if (num == candidate) {
++votes;
} else {
--votes;
}
}
return candidate; // 需要再次遍历验证
}
3. 并行计算版本
cpp
// 使用MapReduce思想
// Map阶段:每个线程统计局部候选
// Reduce阶段:合并局部候选
十、面试技巧
1. 如何向面试官解释摩尔投票法?
text
"可以把这个算法想象成选举:
- 每个数字就是一个候选人
- 我们遍历数组进行投票
- 遇到相同候选人就加一票
- 遇到不同候选人就被抵消一票
- 因为目标候选人票数超过一半,所以最后剩下的必然是他"
2. 解题步骤建议
-
首先提出暴力解法(体现思考过程)
-
分析其缺点(时间复杂度高)
-
提出排序解法(简单但不符合要求)
-
最后给出摩尔投票法(最优解)
-
分析时间空间复杂度
-
讨论边界情况和验证步骤
3. 常见面试问题
-
为什么需要第二轮验证?
-
如果没有超过一半的数字会怎样?
-
算法的时间复杂度是多少?
-
能否进一步优化?
十一、总结
关键点回顾
-
暴力统计法:简单但效率低,仅用于理解问题
-
排序法:实现简单,但改变了原数组且时间复杂度非最优
-
摩尔投票法:O(n)时间,O(1)空间,是最优解法
选择建议
-
学习时:理解三种方法的演进过程
-
面试时:直接给出摩尔投票法,并解释原理
-
工程实践:使用摩尔投票法,注意添加验证步骤
算法思想扩展
摩尔投票法体现了"抵消"的思想,这种思想可以应用于:
-
寻找主要元素
-
数据压缩
-
异常检测
-
负载均衡
掌握这种算法思想,能够帮助我们解决更多类似的问题,培养高效的问题解决能力。
记住:优秀的算法不仅在于解决问题,更在于以最优的方式解决问题。摩尔投票法正是这样一个既优美又高效的算法典范。