多数元素问题:从暴力美学到摩尔投票神仙解法
朋友们!今天咱们来盘一盘 LeetCode 里那个看似简单却暗藏玄机的多数元素问题。想象你参加班级选举,有个家伙得票超过一半
怎么快速揪出这个"人气王"?且看我花式解题!
不懂摩尔投票?没关系,我来给你讲!文中会有详细的讲解过程哦
🔍【程序员专属升级打怪路线图 🗺️ 已加载完毕】
当前副本:多数元素 BOSS 战 🎮
初始装备:暴力破解三板斧 ⚔️
终极奥义:摩尔投票神技 ✨
长按 F 键准备------3️⃣ 2️⃣ 1️⃣
gogogo,出发喽,黑咖啡品味有多浓 (串台了不是)🚀
核心问题:多数元素
题目:给定一个大小为
n
的数组nums
,返回其中的多数元素。多数元素是指在数组中出现次数 大于⌊ n/2 ⌋
的元素。假设数组是非空的,并且给定的数组总是存在多数元素。进阶:尝试设计时间复杂度为 O(n)、空间复杂度为 O(1) 的算法解决此问题。
🚀 心路历程:从菜鸟到大佬的进化史
1. 暴力开搞:点名点到地老天荒 🤺📝🚁
口号:"管他什么优雅什么进阶,能跑就是好代码!"
刚看到题目时,我嘴角疯狂上扬 😏:双重循环直接莽!
每个元素当候选人都让全班举手投票 👋,数完票数超半就返回!
java
for (候选人A : 全班同学) {
int 票数 = 0;
for (同学 : 全班) {
if (同学 == 候选人A) 票数++; // 点名点到手抽筋 📝
}
if (票数 > 半数) return 候选人A; // 恭喜当选!
}
真相时刻:
- ✅ 优点:逻辑直白,适合算法婴儿班 👶
- ❌ 致命伤 :
- 数据量>1000?电脑风扇秒变直升机 🚁(嗡嗡嗡------!)
- 时间复杂度 O(n²) → 全班 50 人时要点名 2500 次!
- 空间复杂度 O(1) → 唯一优点:不费脑子 🤯
经典场景:面试官冷笑:"就这?"。
2. 哈希土豪:空间换时间的氪金玩家 💸
口号:"内存?老子多的是!"
被暴击后灵机一动 ✨:掏出氪金神卡 HashMap,开启专属投票包厢!
投票流程说明书 📜:
- 进场领卡 → 新人发银卡,老客升金卡
- 刷卡积分 → 每投一票 +10086 积分
- 年终结算 → 积分超半直接保送 C 位!
java
// == 氪金玩家专属代码块 ==
Map<Integer, Integer> VIP包厢 = new HashMap<>(); // 💳 会员卡发放处
for (同学 : 全班) {
// 老会员刷脸入场,新会员现场制卡(内存:这波血亏!)
VIP包厢.put(同学, VIP包厢.getOrDefault(同学, 0) + 1); // 💰 氪金+1
}
// 年终颁奖典礼 🏆
for (Map.Entry<Integer, Integer> entry : VIP包厢.entrySet()) {
if (entry.getValue() > 半数) {
System.out.println("🎉 恭喜【VIP-" + entry.getKey() + "】登上宝座!");
return entry.getKey(); // 💥 内存爆炸警告!
}
}
氪金玩家生存指南:
操作 | 特效 | 程序员表情三连 |
---|---|---|
开包厢 | 金色传说特效 💫 | 💰→💳→💥 |
刷积分 | 紫色进度条疯狂上涨 📈 | ⌨️→🤯→😭 |
查榜单 | 内存红色预警灯闪烁 🚨 | 👛→🕳️→💸 |
面试官冷笑:"这 VIP 包厢...是用我内存条开的?" 😤
面试官拍桌:"内存 O(1)呢?!"
3. 排序投机:数学课代表的直觉闪现 🎲
口号:"排序中位数,闭眼出答案!"
灵光乍现 💡:既然超过半数,排序后中间位置必是多数派!
5 行代码结束战斗:
java
Arrays.sort(全班); // 按身高排队 👫👫👫
return 全班[全班.length/2]; // 中间那位就是天选之子!👑
数学奥义:
- ✅ 极致简洁:代码比薯片还脆 🥨
- ❌ 暗坑 :排序破坏原数组,且耗时 O(n log n)
灵魂拷问 :"如果数组不能进行任何修改?" 面试官微笑掏枪 🔫 面试官冷笑 :"这排序...是用我 CPU 寿命换的?" 🔫 你 😎:"这叫福报共享,双赢!(打开风扇散热)💻💨"
4. 欧皇检测器:程序员の抽卡玄学 🎮
口号:"信东哥,得永生!祖传代码保平安,996 福报换欧气!" 💻✨
核心机制:
- 每次抽卡消耗 1 点「福报值」🧧
- 抽中 SSR(多数元素)自动触发保底机制
- 非酋强制触发大悲咒 BGM 🔔
java
// == 程序员の欧皇模拟器 ==
Random 抽卡机 = new Random();
int 保底计数器 = 0;
final int 最大非酋值 = (int) (n * 0.618); // 黄金分割保底线
while (保底计数器 < 最大非酋值) { // 防止无限轮回
int SSR候选 = 全班[抽卡机.nextInt(n)];
// 祖传代码の神秘力量加持
if (验票(SSR候选) > 半数) {
System.out.println("🎉 恭喜主公抽中天命SSR!");
return SSR候选; // 一发入魂!🍀
}
保底计数器++;
System.out.println("💔 第" + 保底计数器 + "抽:又是蓝天白云...");
}
// 保底机制强制触发
System.out.println("🚨 非洲大酋长警报!启用祖传保底算法!");
return 摩尔投票神抽(); // 调用保底算法
程序员抽卡指南 📜:
状态 | 特效 | 时间复杂度 | 程序员梗指数 |
---|---|---|---|
欧皇附体 | 金色传说 💫 | O(1) | 👑 |
常规抽卡 | 紫色史诗 💜 | O(n) | 💼 |
保底触发 | 血色警告 █████▒▒ | O(n) | 💣 |
保底机制数学证明 📐:
txt
∵ 多数元素出现次数 m > n/2
∴ 随机抽取命中概率 p > 1/2
根据伯努利试验模型:
P(连续k次不中) = (1-p)^k < (1/2)^k
当k=10时,P < 0.1% → 玄不救非,氪能改命!
面试官の凝视 👀:"你这保底算法...是抄米哈游的还是暴雪的?" 你 😎:"祖传代码的事,能叫抄吗?这叫编程艺术!"
5. 分治修仙:宗门分裂大法 🧙♂️⚔️
渡劫口诀 :"一分为二炼金丹,合二为一破虚空!" 修真派作风:宗门分裂 为左右两派,各自推举掌门,最终宗门大比决胜负!
java
// == 宗门分裂代码块 ==
int 左派掌门 = 闭关修炼(左宗门);// 🧘♂️ 左派闭关参悟
int 右派掌门 = 闭关修炼(右宗门);// 🧘♂️ 右派闭关参悟
if (左派掌门 == 右派掌门)
return 左派掌门; // ⚡ 双修大成!
else
return 宗门大比(左派掌门, 右派掌门);// ⚔️ 以武会友
修仙代价:
- ✅ 思想深刻:递归分治逼格拉满 💎
- ❌ 杀鸡用牛刀 :时间 O(n log n) ,内存 O(log n)
面试官吐槽:"我只要 O(1)空间,你递归栈都 O(log n)了?" 😤
6. 摩尔投票:位面之子的神技 ✨🔥
口号:"异阵营抵消,剩者为王!"
终极顿悟 ❗:不同阵营相互厮杀,活到最后的必是多数派!
神级代码:
java
int 候选人 = 全班[0], 血量 = 1;
for (同学 : 全班) {
if (同学 == 候选人) 血量++; // 同胞!回血!💚
else if (血量 == 0) {
候选人 = 同学; // 光杆司令叛变!🏳️
血量 = 1;
} else 血量--; // 遭遇敌人!掉血!💔
}
return 候选人; // 笑到最后の赢家 🏆
封神理由:
- ✅ 时间 O(n):一次遍历秒全场 ⚡
- ✅ 空间 O(1):只用 2 变量,内存感动哭 😭
- ✅ 流处理适用:数据量 1TB 也不虚 💾
面试官反应:"你...你居然会这招?" 🤯(默默掏出 offer)
最终奥义:
🌟 多数元素 = 摩尔投票! 🌟
三行代码版(装逼专用):
javaint c=nums[0], cnt=1; for (int i=1; i<nums.length; i++) cnt = (nums[i]==c) ? cnt+1 : (cnt==0 ? (c=nums[i],1) : cnt-1); return c;
具体的代码实现如下
1.暴力二重循环:莽夫式排查
- 思路:就像挨个问全班同学"你投了谁",再数票数。简单粗暴但累死人!
- ✅ 优势:代码比泡面说明书还好懂
- 时间复杂度:O(n²),每个元素都要遍历一次数组
- 空间复杂度:O(1),只使用了常数级别的额外空间
java
public class Solution {
public int majorityElement(int[] nums) {
int majorityCount = nums.length / 2;
for (int num : nums) {
int count = 0;
for (int elem : nums) {
if (elem == num) {
count += 1;
}
}
if (count > majorityCount) {
return num;
}
}
return -1; // 根据题意,数组中总是存在多数元素,所以这行代码理论上不会执行
}
}
2.哈希法:空间换时间
- 思路:搞个记票本 📒 遇到相同名字就画"正"字,最后翻本子找赢家!
- ⚡ 闪电速度:O(n)碾压暴力解法
- 🚨 内存警告:空间 O(n),数组过大时内存哭晕在厕所
- 时间复杂度:O(n),每个元素都要遍历一次数组
- 空间复杂度:O(n),哈希表最多存储 n 个元素
java
class Solution {
public int majorityElement(int[] nums) {
/**
直接开始哈希,空间换时间
*/
Map<Integer, Integer> countMap = new HashMap<>();
for(int num : nums) {
// getOrDefault:有名字就+1,没有就新建
countMap.put(num,countMap.getOrDefault(num,0) + 1);
}
// 翻记票本核对!
for (Map.Entry<Integer, Integer> entry : countMap.entrySet()) {
if (entry.getValue() > (nums.length / 2)) {
return entry.getKey();
}
}
return -1;
}
}
3.排序法:数学鬼才
- 思路:由于多数元素出现次数超过一半,排序后它必然位于数组的中间位置。就像是全班按身高排队,超过半数的家伙必定占领 C 位!👑
- 时间复杂度:O(n log n),排序的时间复杂度
- 空间复杂度:O(1) 或 O(n),取决于排序算法
假设现在有一个数组
[2, 2, 1, 1, 1, 2, 2]
,长度为 7。对其进行排序,排序后的结果为
[1, 1, 1, 2, 2, 2, 2]
,此时中间位数为 3,而下标为 3 的数为 2 ,所以返回 2 。还有一个数组
[3, 2, 4, 2, 2, 4, 2, 2, 4]
,长度为 9。对其进行排序,排序后的结果为
[2, 2, 2, 2, 2, 3, 4, 4, 4]
,此时中间位数为 4,而下标为 4 的数为 2 ,所以返回 2 。综上所述,超越半数的数字一定在中间位,所以直接返回中间位的数字即可。
🔢 数学证明(瞟一眼就懂)
位置 | 最多非多数元素 | 多数元素最少占领区 | 结论 |
---|---|---|---|
前半段 | ≤ n/2 个位置 | ≥ (m - n/2) > 0 个 | 必跨过中点 ✅ |
后半段 | ≤ n/2 个位置 | ≥ (m - n/2) > 0 个 | 必跨过中点 ✅ |
💡 翻译:就算非多数元素全挤在前后半段,多数元素仍有"兵力"占领中线!
java
class Solution {
public int majorityElement(int[] nums) {
Arrays.sort(nums);
return nums[nums.length / 2];
}
}
4.随机化:让你的算法更有优势
- 思路:随机化的核心是随机选择一个元素作为候选人,然后验证它是否是多数元素。如果不是,就重新随机选择。这个过程会重复多次,直到找到多数元素。 本题可以用随机化解决的根本原因在于,多数元素出现的次数超过一半,随机选择一个元素作判断,它被选中的概率是非常高的。此方法只是拓展一个思路。
- 时间复杂度:O(n),每个元素都要遍历一次数组
- 空间复杂度:O(1),只使用了常数级别的额外空间
java
class Solution {
public int majorityElement(int[] nums) {
int n = nums.length;
Random rand = new Random();
while (true) {
int candidate = nums[rand.nextInt(n)]; // 随机抓人
int count = 0;
for (int num : nums) {
if (num == candidate) {
count++;
}
}
if (count > n / 2) {
return candidate;
}
}
}
}
5.分治:将问题分解为子问题
- 思路:分治的核心是将数组分成两半,分别找出每一半的多数元素,然后合并结果。合并时需要计算两个子数组中各自的多数元素出现的次数,最终返回出现次数较多的那个。
- 时间复杂度:O(n log n),每次分治都需要遍历数组
- 空间复杂度:O(log n),递归调用栈的空间
java
class Solution {
public int majorityElement(int[] nums) {
return majorityElementRec(nums, 0, nums.length - 1);
}
private int majorityElementRec(int[] nums, int left, int right) {
// 基本情况:只有一个元素
if (left == right) {
return nums[left];
}
// 分治:将数组分成两半
int mid = left + (right - left) / 2;
int leftMajor = majorityElementRec(nums, left, mid);
int rightMajor = majorityElementRec(nums, mid + 1, right);
// 合并结果:计算两个子数组中各自的多数元素
if (leftMajor == rightMajor) {
return leftMajor;
}
int leftCount = countInRange(nums, leftMajor, left, right);
int rightCount = countInRange(nums, rightMajor, left, right);
return leftCount > rightCount ? leftMajor : rightMajor;
}
private int countInRange(int[] nums, int num, int left, int right) {
int count = 0;
for (int i = left; i <= right; i++) {
if (nums[i] == num) {
count++;
}
}
return count;
}
}
6.摩尔投票法:神级投票机制
你以为这是普通投票?不!这是一场数字版《饥饿游戏》 !候选人互相厮杀,弱者淘汰,最终只剩一个王者!👑 原理简单粗暴:多数元素得票必须超过半壁江山 (即 > n/2
),利用阵营抵消的生存法则,让其他元素互相残杀,最后活下来的就是天选之子!
6.1 算法原理
摩尔投票法(Boyer-Moore Majority Vote Algorithm)是一种高效的寻找数组中多数元素的算法,其核心思想基于多数元素出现的次数超过数组长度的一半这一特性。通过模拟投票过程,让不同元素相互抵消,最终剩下的元素必然是多数元素。
6.2 核心思路
- 候选人机制 :维护一个候选人变量和一个计数器
- 投票规则 :
- 遇到与候选人相同的元素,计数器+1
- 遇到不同元素,计数器-1
- 当计数器为 0 时,更换候选人为当前元素并重置计数器为 1
- 最终结果 :遍历结束后,候选人即为多数元素
6.3 关键设计点
6.3.1 初始值设置 :
- 候选人初始化为数组第一个元素
- 计数器初始化为 1(代表已获得一票)
6.3.2 遍历策略 :
- 从索引 1 开始遍历(已处理第一个元素)
- 一次遍历完成 O(n)时间复杂度
6.3.3 抵消机制 :
- 不同元素相互抵消(计数器减 1)
- 计数器归零意味着当前候选人失去竞争力
策略 | 操作说明 | 举个栗子 🌰 |
---|---|---|
初始值 | 首元素自动当选候选人,血条设为 1(首票保送!) | [2,?,?,?] → 候选人=2 |
遍历顺序 | 从第二个元素开始 PK(首元素已占位) | i=1开始,跳过首元素 |
血条机制 | 血条归零=立刻换人!绝不拖延!(防止诈尸) | 血条=0时秒换新候选人 |
最终胜出 | 由于多数元素出现次数超过一半,即使与其他所有元素抵消后仍会剩余 | [2,2,2] → 候选人=2 |
6.4 正确性证明
假设数组长度为 n,多数元素出现次数为 m(m > n/2):
- 其他元素总出现次数为 n-m < m
- 即使所有非多数元素都与多数元素抵消,仍会剩余 m-(n-m) = 2m-n > 0 个多数元素
java
class Solution {
public int majorityElement(int[] nums) {
// 初始化候选人(第一个元素)和计数器
int candidate = nums[0], count = 1;
// 从第二个元素开始遍历
for (int i = 1; i < nums.length; i++) {
if (nums[i] == candidate) {
// 遇到相同元素,计数器增加
count++;
} else {
// 遇到不同元素,计数器减少
count--;
// 计数器为0时,更换候选人
if (count == 0) {
candidate = nums[i];
count = 1;
}
}
}
return candidate;
}
}
- 时间复杂度:O(n),每个元素都要遍历一次数组
- 空间复杂度:O(1),只使用了常数级别的额外空间
🌟 终极大总结
解法 | 时间复杂度 | 空间复杂度 | 适用场景 | 梗指数 |
---|---|---|---|---|
暴力循环 | O(n²) | O(1) | 数据量<50 | 🤺 |
哈希土豪 | O(n) | O(n) | 内存充足时 | 💰 |
排序法 | O(n log n) | O(1) | 允许修改数组 | 🔢 |
随机玄学 | 期望 O(n) | O(1) | 欧皇限定 | 🍀 |
分治修仙 | O(n log n) | O(log n) | 学术装 X | 🧘♂️ |
摩尔投票 | O(n) | O(1) | 封神首选! | 👑 |
最后寄语:
_"下次面试官再问多数元素,把摩尔投票拍他桌上说:
'这一招,值多少薪资你看着办!'"_ 😎
拓展:多数元素 II
题目描述:给定一个大小为 n 的整数数组,找出其中所有出现超过 ⌊ n/3 ⌋ 次的元素。
发现问题没有,和第一题的思路是一样的,只是需要注意的是,这里的多数元素是指出现次数超过 ⌊ n/3 ⌋ 次的元素,而不是超过 ⌊ n/2 ⌋ 次的元素。
由此的话,我们之前的 数学排序法、随机法、分治法解决这个问题的代价和难度就会增大。
但是其他方法思路依旧可以进行实现,只需略微修改。
1.暴力解题!
- 思路:双重循环,外层循环遍历每个元素,内层循环统计该元素出现的次数。如果次数超过 n/3 且结果集中不包含该元素,则将其添加到结果集中。
- 时间复杂度:O(n²),每个元素都要遍历一次数组
- 空间复杂度:O(1),只使用了常数级别的额外空间
java
class Solution {
public List<Integer> majorityElement(int[] nums) {
int majory = nums.length / 3;
List<Integer> result = new ArrayList<>();
for (int num : nums) {
int count = 0;
for (int elem : nums) {
if (num == elem) count++;
}
if (count > majory && !result.contains(num))
result.add(num);
}
return result;
}
}
2.哈希法
- 思路:哈希表的键是数组中的元素,值是该元素出现的次数。遍历数组,将每个元素的出现次数记录在哈希表中。最后遍历哈希表,将出现次数超过 n/3 的元素添加到结果集中。
- 时间复杂度:O(n),每个元素都要遍历一次数组
- 空间复杂度:O(n),哈希表的空间
java
class Solution {
public List<Integer> majorityElement(int[] nums) {
Map<Integer, Integer> countMap = new HashMap<>();
int majority = nums.length / 3;
for (int num : nums) {
countMap.put(num, countMap.getOrDefault(num,0) + 1);
}
List<Integer> result = new ArrayList<>();
for(Map.Entry<Integer, Integer> entry : countMap.entrySet()) {
if (entry.getValue() > majority) {
result.add(entry.getKey());
}
}
return result;
}
}
3.摩尔投票法
- 思路:摩尔投票法的核心是维护两个候选人和它们的计数器。遍历数组,根据当前元素更新候选人和计数器。最后验证两个候选人是否符合条件。
- 时间复杂度:O(n),每个元素都要遍历一次数组
- 空间复杂度:O(1),只使用了常数级别的额外空间
java
class Solution {
public List<Integer> majorityElement(int[] nums) {
int candidate1 = 0, candidate2 = 0;
int count1 = 0, count2 = 0;
for (int num : nums) {
if (num == candidate1) {
count1++;
} else if (num == candidate2) {
count2++;
} else if (count1 == 0) {
candidate1 = num;
count1 = 1;
} else if (count2 == 0) {
candidate2 = num;
count2 = 1;
} else {
count1--;
count2--;
}
}
List<Integer> result = new ArrayList<>();
count1 = 0;
count2 = 0;
for (int num : nums) {
if (num == candidate1) count1++;
else if (num == candidate2) count2++;
}
if (count1 > nums.length / 3) result.add(candidate1);
if (count2 > nums.length / 3) result.add(candidate2);
return result;
}
}
无限大的思考
你好,我是无限大。
我是一位非常有想法的人,我喜欢思考,喜欢解决问题,喜欢挑战自我。
我认为很多时候,我们会被一些问题所困扰,无法一步实现这些问题的最优解法。
但是,我们可以从不同的角度出发,慢慢的拆分问题,一步一步的,先将题解写出来,把问题解决后,再去想办法进行一个进阶的优化和拓展。
其实工作、学习以及人生也是这样,面对繁琐的业务、复杂的问题、不断的挑战,我们不要去一开始就想着去一步到位,一步登天,而是要从最简单的开始,脚踏实地的去解决问题,然后再去思考如何去继续改进。
从 0 到 1 是一个非常漫长的过程,但是我们可以先实现从 0 到 0.5,从 0.5 到 0.75,从 0.75 到 0.9,从 0.9 到 1。
在这个过程中,我们可以学习到很多知识,也可以得到很多启发。
就像代码需要持续集成,人生也需要持续重构。从暴力解法的青铜段位,到摩尔投票的王者段位,每一次 commit 都是新的起点!✨
感谢你阅读我的文章,希望我们可以一起进步!