前言
今日在刷leetcode时遇见了一道很有意思的题:
这一题的解法很多,我也尝试了两种方式进行解答:Map
存储记录统计个数方案, 先排序后统计个数方案。
虽然能够满足解题,但是无法满足进阶的要求:
- 进阶:尝试设计时间复杂度为
O(n)
、空间复杂度为O(1)
的算法解决此问题。
一开始,我尝试使用快慢指针来解决问题。代码如下:
Java
private static int majorityElementV2(int[] nums) {
int left = 0, right = 0, count = 0;
while (left < nums.length) {
if (right >= nums.length) {
right = ++left;
count = 0;
continue;
}
if (nums[left] == nums[right++] && ++count > nums.length / 2) {
return nums[left];
}
}
return 0;
}
验证通过,但是leetcode耗时达到了2177ms。虽然能够满足空间复杂度O(1)
,但时间复杂度仍保持在O(n)
~O(n^2)
,不满足进阶的时间复杂度。
在翻看题解时,很多题目带有"摩尔投票"、"多数投票"、"打擂台"字样,我很好奇~
学习后,引出本篇,摩尔投票算法~!
一、摩尔投票算法-杀敌一千自损八百
博耶-摩尔多数投票算法(英语:Boyer--Moore majority vote algorithm),中文又称摩尔投票算法、多数投票算法,是一种用于寻找序列中多数元素的算法,其中多数元素需要占序列个数的一半以上。1981年由罗伯特·S·博耶和J·斯特罗瑟·摩尔发表,当下常用于处理数据流问题。
基本思想是:设计一个候选者和计数器,在一次遍历中,通过候选者之间的抵消,最终得到一个多数元素。
核心思想是:
-
设计候选者和计数器,计数器初始化为0
-
遍历序列:
- 当候选者不存在时,当前元素上位,计数器设为1;
- 当前元素与候选者相同时,计数器加1;
- 当前元素与候选者不同时,计数器减1。若计数器归0,则清除候选者。
-
遍历结束,当前候选者则为序列中个数超过一半的多数元素。
我们简单点理解:
- 诸侯争霸,百军对冲。假设我军士兵占战场总人数的一半以上,且当前进行大决战,全军出击。若每个士兵与对方一对一同归于尽,则战场上最后活下来的必定是我军。
了解之后,我们再看代码就十分清晰明了:
Java
public static int mooreVoting(int[] nums) {
// init
int count = 0, major = -1;
// loop
for (int num : nums) {
if (count == 0) {
major = num;
count++;
} else if (major == num) {
count++;
} else {
count--;
}
}
// result
return major;
}
从代码入手,我们能够分析出一下优缺点:
-
优点:
- 时间复杂度、空间复杂度低:只进行一次遍历,且不需要额外的空间存储数据。
- 代码简单,易理解。
-
缺点:
- 只适用于多数元素在序列占比超过一半的情况。
二、若多数元素不超过一半呢?
在Leetcode上还有一道同类型的题目:Leetcode《229.多数元素 Ⅱ》。
多数元素超过的次数是⌊n/3⌋
,即多数元素占比超过1/3,意味着至多出现两个多数元素。
候选者个数增加,意味着只有当无候选者空位时,才会减少候选者个数。同时,上下位情况会变得频繁,而每次上下位也属于元素抵消。因此多数元素仍会在候选者中,但候选者是否全部合法,是无法确定的。所以,我们需要新增校验当前候选者是否合法。
当下策略就变为:
- 增加候选者为2个。
- 增加校验逻辑。
代码如下:
Java
public static List<Integer> mooreVotingOneThird(int[] nums) {
// 1.check
if (nums == null || nums.length == 0) {
return Collections.emptyList();
}
if (nums.length == 1) {
return Collections.singletonList(nums[0]);
}
// 2.init
Integer major1 = null, major2 = null;
int count1 = 0, count2 = 0;
// 3.选举
for (int num : nums) {
// major之间没有高低之分。因此,先判断是否和候选者相同,再判断是否有空位,最后判断是否需要减少统计个数
if (Objects.equals(num, major1) && count1 > 0) {
count1++;
} else if (Objects.equals(num, major2) && count2 > 0) {
count2++;
} else if (Objects.isNull(major1) || count1 <= 0) {
// 作为候选者上位
major1 = num;
count1 = 1;
} else if (Objects.isNull(major2) || count2 <= 0) {
// 作为候选者上位
major2 = num;
count2 = 1;
} else {
// 不相等,且无空位,所有候选者统计个数-1
if (--count1 == 0) {
major1 = null;
}
if (--count2 == 0) {
major2 = null;
}
}
}
// 4.校验:统计候选者个数在序列是否大于1/3,大于则加入结果集
int majorNum = nums.length / 3;
int checkCount1 = 0, checkCount2 = 0;
for (int num : nums) {
if (Objects.equals(num, major1)) {
checkCount1++;
} else if (Objects.equals(num, major2)) {
checkCount2++;
}
}
List<Integer> result = new ArrayList<>();
if (checkCount1 > majorNum) {
result.add(major1);
}
if (checkCount2 > majorNum) {
result.add(major2);
}
return result;
}
可能会有惯性思维,认为候选者是有优先级的,其实不然。正如上文所说,候选者的竞争会十分激烈,上下位的情况会十分频繁,所以二者在选举过程中属于是平等状态。
分析后可知,当下时间复杂度为O(n)
,空间复杂度为O(1)
。
三、泛化摩尔投票算法
如果我们将第二部分进行泛化思考,就可以得知,摩尔投票也同样适合用于
- 查找出现次数占比超过
1/k
的元素。即,元素个数超过n/k
,n为总量个数。
思路与上文一致,增加候选者数量,以及校验是否合法。需注意的是,当前多数元素在总量的占比为变量,候选者个数也是动态的,即n/k
。无法在代码中写死变量,因此采用Map
来存储候选者及其个数。
代码如下:
Java
public static List<Integer> mooreVotingGeneralize(int[] nums, int k) {
// 1.check
if (nums == null || nums.length == 0) {
return Collections.emptyList();
}
// 2.init
int majorNum = nums.length / k; // 候选者个数
Map<Integer, Integer> majorMap = new HashMap<>(majorNum - 1); // key:候选者名单,value:候选者个数
// 3.选举
for (int num : nums) {
if (majorMap.isEmpty()) {
// 无候选者
majorMap.put(num, 1);
} else if (majorMap.containsKey(num)) {
// 与候选者相同
majorMap.put(num, majorMap.get(num) + 1);
} else if (majorMap.size() < majorNum) {
// 与候选者不同,且有空位,则将当前元素上位候选者
majorMap.put(num, 1);
} else {
// 与候选者不同,且无空位,则所有元素统计个数-1
majorMap.entrySet().forEach(entry -> {
if (entry.getValue() <= 0) {
majorMap.remove(entry.getKey());
} else {
entry.setValue(entry.getValue() - 1);
}
});
}
}
// 4.统计
// 比较value的值,选出value大于等于majorNum的key。PS:可能为多个key。
return majorMap.entrySet().stream()
.filter(entry -> entry.getValue() >= majorNum)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
代码逻辑与之前相似,只不过在候选者下位时,需要删除在Map
中的存储。