多数元素问题:从暴力美学到摩尔投票神仙解法

多数元素问题:从暴力美学到摩尔投票神仙解法

朋友们!今天咱们来盘一盘 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,开启专属投票包厢!

投票流程说明书 📜:

  1. 进场领卡 → 新人发银卡,老客升金卡
  2. 刷卡积分 → 每投一票 +10086 积分
  3. 年终结算 → 积分超半直接保送 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. 每次抽卡消耗 1 点「福报值」🧧
  2. 抽中 SSR(多数元素)自动触发保底机制
  3. 非酋强制触发大悲咒 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)

最终奥义

🌟 多数元素 = 摩尔投票! 🌟

三行代码版(装逼专用):

java 复制代码
int 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. 候选人机制 :维护一个候选人变量和一个计数器
  2. 投票规则
  • 遇到与候选人相同的元素,计数器+1
  • 遇到不同元素,计数器-1
  • 当计数器为 0 时,更换候选人为当前元素并重置计数器为 1
  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 都是新的起点!✨

感谢你阅读我的文章,希望我们可以一起进步!

相关推荐
你的人类朋友1 小时前
✨什么是SaaS?什么是多租户?
后端·架构·设计
M1A12 小时前
全球语言无障碍:Unicode标准解读与技术演进史
后端
无限大62 小时前
《计算机“十万个为什么”》之 面向对象 vs 面向过程:编程世界的积木与流水线
后端
洛可可白2 小时前
Spring Boot 应用结合 Knife4j 进行 API 分组授权管理配置
java·spring boot·后端
Livingbody2 小时前
基于ERNIE-4.5-0.3B医疗领域大模型一站式分布式训练部署
后端
程序员爱钓鱼3 小时前
Go语言实战案例:使用sync.Mutex实现资源加锁
后端·go·trae
程序员爱钓鱼3 小时前
Go语言实战案例:使用context控制协程取消
后端·google·trae
Moment3 小时前
Node.js 这么多后端框架,我到底该用哪个?🫠🫠🫠
前端·后端·node.js
22:30Plane-Moon3 小时前
初识SpringBoot
java·spring boot·后端