Leetcode刷题记录-Boyer-Moore 投票算法
Boyer-Moore 投票算法
Boyer-Moore 投票算法是一种非常巧妙且高效的算法,专门用于解决“求众数”(Majority Element)及其相关问题。它的核心魅力在于仅需一次遍历和常数级别的额外空间就能找到答案。
下面我们来详细拆解这个算法。
1. 算法要解决的问题
标准的“求众数”问题定义如下:
在一个包含 n 个元素的数组中,找到那个出现次数超过
⌊ n / 2 ⌋
的元素。题目通常会保证这个众数一定存在。
例如,在 [2, 2, 1, 1, 1, 2, 2]
中,n=7,⌊ n / 2 ⌋ = 3
。众数是出现次数超过3次的元素,这里是 2
(出现了4次)。
2. 核心思想:阵地攻防与抵消
理解此算法最直观的方式是使用一个“阵地攻防”或“擂主守擂”的类比:
- 擂主 (Candidate):我们假设有一个擂主,他占据着阵地。
- 生命值 (Count):擂主有一个生命值计数器。
整个算法的遍历过程就像一场战斗:
- 规则一:如果阵地是空的 (
count == 0
),那么当前遍历到的新元素num
就会立刻占领阵地,成为新擂主 (candidate = num
),并且拥有 1 点生命值 (count = 1
)。 - 规则二:如果新来的元素
num
和当前擂主candidate
是同一个人(同伙),那么擂主的生命值加 1 (count++
),表示阵地得到了巩固。 - 规则三:如果新来的元素
num
和当前擂主candidate
是不同的人(敌人),那么敌人会与擂主的一个守卫“同归于尽”,擂主的生命值减 1 (count--
)。
最终推论:经过一整轮的战斗(遍历完整个数组),那个最终还能站在阵地上的擂主 (candidate
),就是我们要找的众数。
3. 算法步骤
基于上述思想,我们可以总结出清晰的算法步骤:
- 初始化两个变量:
candidate = None
,count = 0
。 - 遍历数组
nums
中的每一个元素num
。 - 在循环中,进行判断:
a. 如果count == 0
,将candidate
设为当前的num
,并将count
设为1
。
b. 否则,如果num == candidate
,则count
加1
。
c. 否则(num != candidate
),则count
减1
。 - 遍历结束后,变量
candidate
中存储的值就是众数。
4. 实例演练
我们用数组 nums = [2, 2, 1, 1, 1, 2, 2]
来走一遍流程:
步骤 | 当前元素 num | candidate | count | 说明 |
---|---|---|---|---|
1 | 2 | 2 | 1 | count 为0,2 成为新擂主 |
2 | 2 | 2 | 2 | num == candidate ,生命值+1 |
3 | 1 | 2 | 1 | num != candidate ,与敌人同归于尽,生命值-1 |
4 | 1 | 2 | 0 | num != candidate ,生命值-1,阵地失守 |
5 | 1 | 1 | 1 | count 为0,1 成为新擂主 |
6 | 2 | 1 | 0 | num != candidate ,生命值-1,阵地失守 |
7 | 2 | 2 | 1 | count 为0,2 成为新擂主 |
遍历结束,最终的 candidate
是 2
,这就是众数。
5. 算法正确性证明:为什么它一定有效?
因为众数的数量超过了数组长度的一半,这意味着众数的数量比所有其他元素数量的总和还要多。
- 在“同归于尽”的过程中,每一次
count--
操作都意味着一个众数和一个非众数进行了抵消。 - 由于众数的“兵力”比所有其他“敌人”的兵力总和还要强大,即使所有敌人都上来一对一兑子,最后也必然会有众数的“士兵”存活下来。
- 因此,在经历所有的抵消之后,最终能够让
count
大于 0 并留在candidate
位置的,必然是那个众数。
6. 扩展应用:寻找超过 n/3 的元素
这个算法的思想还可以扩展。例如,寻找数组中所有出现次数超过 ⌊ n / 3 ⌋
的元素(LeetCode 229. 求众数 II)。
此时,满足条件的元素最多只会有两个。我们可以用同样“抵消”的思想:
- 维护两个
candidate
和两个count
。 - 当来了一个新元素
num
:- 如果
num
和candidate1
或candidate2
相同,则对应的count
加 1。 - 如果两个
candidate
的位置都没满,则num
成为其中一个candidate
。 - 如果
num
与两个candidate
都不同,则两个count
同时减 1。这就相当于三个不同的元素凑在一起“同归于尽”了。
- 如果
最后还需要再遍历一次数组,验证留下的两个 candidate
是否真的满足次数超过 n/3
的要求,因为此时不能保证留下的就一定是答案。
总结
特性 | 描述 |
---|---|
时间复杂度 | O(n)O(n)O(n),因为只需要对数组进行一次完整的遍历。 |
空间复杂度 | O(1)O(1)O(1),因为只使用了 candidate 和 count 两个额外的变量,与输入规模无关。 |
核心思想 | 抵消/投票。利用众数数量上的绝对优势,保证其在两两抵消后仍能留存。 |
适用场景 | 寻找数组中出现次数超过 1/k 的元素,尤其是在要求线性和常数空间时。 |
Boyer-Moore 投票算法是算法面试中的常客,因为它完美地展现了如何用巧妙的思路来优化时间和空间复杂度。
169.majority element
题目描述:
给定一个大小为 n
的整数数组 nums
,请返回其中的多数元素(majority element)。
多数元素指的是在数组中出现次数超过 ⌊n / 2⌋ 次的那个元素。
可以假设数组中一定存在多数元素。
示例 1:
输入:nums = [3,2,3]
输出:3
示例 2:
输入:nums = [2,2,1,1,1,2,2]
输出:2
约束条件:
n == nums.length
1 <= n <= 5 * 10⁴
-10⁹ <= nums[i] <= 10⁹
进阶要求(Follow-up):
你能否用线性时间 O(n) 并且只使用 常数级额外空间 O(1) 来解决这个问题?
这道题是著名的 “多数投票算法(Boyer–Moore Voting Algorithm)” 经典题:
算法思想是维护一个候选元素 candidate
和一个计数器 count
。
每当遇到相同元素就加一,不同元素就减一;当计数为零时换候选。
因为多数元素的出现次数超过一半,所以最后剩下的候选就是答案。
代码实现
class Solution(object):def majorityElement(self, nums):cnt=0candidate=Nonefor num in nums:if cnt == 0:candidate = numif num == candidate:cnt+=1else:cnt-=1return candidate
其他方法
方法一:哈希表计数法 (最直观)
这是最容易想到的方法。我们可以遍历一遍数组,用一个哈希表(在 Python 中是字典)来记录每个数字出现的次数。然后再遍历一遍哈希表,找出那个出现次数超过 ⌊n / 2⌋
的数字。
思路
- 创建一个哈希表
counts
用来存储{数字: 出现次数}
。 - 遍历
nums
数组,每遇到一个数,就在哈希表中给它的计数值加 1。 - 遍历完后,再检查哈希表中的每个键值对,返回那个计数值大于
len(nums) / 2
的数字。
Python 代码示例
def majorityElement_hashmap(nums):counts = {}n = len(nums)for num in nums:counts[num] = counts.get(num, 0) + 1for num, count in counts.items():if count > n / 2:return num
- 时间复杂度: O(n)O(n)O(n),因为我们遍历数组一次,遍历哈希表一次。
- 空间复杂度: O(n)O(n)O(n),在最坏的情况下(例如,数组中一半是众数,另一半是各不相同的数),哈希表需要存储大约 n/2 个元素。
counts[num] = counts.get(num, 0) + 1
-
counts.get(num, 0)
-
.get()
是字典(哈希表)的一个方法,用来安全地获取一个键(key)对应的值(value)。 -
它接收两个参数:
key
和一个可选的default_value
(默认值)。 -
情况A:如果
num
已经是counts
字典中的一个键,那么counts.get(num, 0)
就会返回num
对应的当前计数值。例如,如果counts
是{'apple': 3}
,那么counts.get('apple', 0)
就会返回3
。 -
情况B:如果
num
还不是counts
字典中的键(也就是我们第一次遇到这个数字),直接用counts[num]
会报错(KeyError)。但.get()
方法的美妙之处就在于,它不会报错,而是会返回你提供的默认值,这里是0
。
... + 1
-
这一步很简单,就是把上一步获取到的值(无论是旧的计数值还是默认值
0
)加 1。 -
情况A:如果
num
已经存在,我们就得到了旧的计数值 + 1
。 -
情况B:如果
num
是第一次出现,我们就得到了0 + 1
,也就是1
。
-
-
counts[num] = ...
- 这是标准的字典赋值操作。它会把右边计算出的新计数值,更新(或创建)到
counts
字典中,键为num
。
- 这是标准的字典赋值操作。它会把右边计算出的新计数值,更新(或创建)到
整个流程串起来就是:
“查看字典
counts
中有没有num
这个键。如果有,就取出它现在的值;如果没有,就把它看作是0
。然后,将这个值加 1,再存回字典里,键仍然是num
。”
对比一下常规写法:
如果不使用 .get()
方法:
if num in counts:# 如果 num 已经存在,就在原来的基础上加 1counts[num] = counts[num] + 1
else:# 如果 num 是第一次出现,就创建这个键,并把值设为 1counts[num] = 1
方法二:排序法 (很巧妙)
这个方法利用了“众数”的定义。一个数如果出现的次数超过了数组长度的一半,那么当我们把数组排好序之后,这个数必然会出现在数组最中间的位置。
思路
想象一下,如果一个队伍的人数超过了总人数的一半,无论他们怎么站队,队伍中间的那个人必然是这个队伍里的人。
例如 [2, 2, 1, 1, 1, 2, 2]
,排序后是 [1, 1, 1, 2, 2, 2, 2]
。数组长度是 7,中间位置的索引是 7 // 2 = 3
。nums[3]
的值是 2
,就是众数。
Python 代码示例
def majorityElement_sort(nums):nums.sort()return nums[len(nums) // 2]
- 时间复杂度: O(nlogn)O(n \log n)O(nlogn),主要开销是排序。
- 空间复杂度: O(1)O(1)O(1) 或 O(logn)O(\log n)O(logn),取决于编程语言内置排序算法的实现。