题目描述:
给定一个大小为 n 的数组 nums ,返回其中的多数元素。多数元素是指在数组中出现次数 大于 ⌊ n/2 ⌋ 的元素。
你可以假设数组是非空的,并且给定的数组总是存在多数元素。
输入输出示例
输入:nums = [2,2,1,1,1,2,2]
输出:2
解题方案
方式一:暴力枚举(不推荐)
**算法思路:**最简单的暴力方法是,枚举数组中的每个元素,再遍历一遍数组统计其出现次数。
复杂度分析
该方法的时间复杂度是 O(n2),会超出时间限制,因此我们需要找出时间复杂度小于 O(n2) 的优秀做法。
方式二:哈希表
算法思想: 我们知道出现次数最多的元素大于 ⌊n/2⌋ 次,所以可以用哈希表来快速统计每个元素出现的次数。我们使用哈希映射(HashMap)来存储每个元素以及出现的次数。对于哈希映射中的每个键值对,键表示一个元素,值表示该元素出现的次数。
实现步骤:
- 我们用一个循环遍历数组 nums 并将数组中的每个元素加入哈希映射中。
- 在这之后,我们遍历哈希映射中的所有键值对,在遍历数组 nums 时候使用打擂台的方法,维护最大的值,返回值最大的键。
实现代码:
class Solution {
//方法的作用:遍历数组中的元素,将元素以及其出现次数加入到Map集合当中
private Map<Integer, Integer> countNums(int[] nums) {
Map<Integer, Integer> counts = new HashMap<Integer, Integer>();
for (int num : nums) {
if (!counts.containsKey(num)) {
//如果是第一次添加进Map集合的情况
counts.put(num, 1);
} else {
//如果不是第一次添加,需要根据KEY获取对应的value进行添加
counts.put(num, counts.get(num) + 1);
}
}
return counts;
}
public int majorityElement(int[] nums) {
//1.首先将数组中的元素放入到Map集合当中
Map<Integer, Integer> counts = countNums(nums);
//存储出现次数最多的元素
Map.Entry<Integer, Integer> majorityEntry = null;
for (Map.Entry<Integer, Integer> entry : counts.entrySet()) {
if (majorityEntry == null || entry.getValue() > majorityEntry.getValue()) {
majorityEntry = entry;
}
}
return majorityEntry.getKey();
}
}
Map.Entry
1.所属的类和接口体系
Map.Entry是一个接口,它定义在java.util包下,位于 Java 的集合框架中。这个接口是Map接口的一个内部接口,用于描述Map中的一个键值对元素(也就是一个映射关系,包含了对应的键以及与该键关联的值)。
Map接口常见的实现类有HashMap、TreeMap、LinkedHashMap等,而不管是哪种具体的Map实现类,它们内部存储的都是一个个的键值对,这些键值对在代码层面就可以用Map.Entry接口类型来进行操作和表示。
2.主要的方法和作用
Map.Entry接口提供了几个常用方法来获取和操作对应的键值对信息:
- **getKey()方法:**用于获取该键值对中的键。例如,对于一个Map.Entry类型的变量entry,通过entry.getKey()就能得到对应的String类型的键。
- **getValue()方法:**用于获取该键值对中的值。沿用上面的例子,通过entry.getValue()就能获取到对应的Integer类型的值。
- setValue(V value)方法(这里的V是对应值的泛型类型):可以用于修改当前键值对中的值,不过要注意,并不是所有实现了Map.Entry接口的类都支持修改值操作,像HashMap中对应的Entry是支持的,而对于不可变的Map实现(如Collections.unmodifiableMap返回的Map)其内部的Entry就不支持修改值操作。
在代码中,经常会使用迭代器或者for-each循环遍历Map的entrySet()(这个方法返回一个包含所有Map.Entry元素的集合)来依次获取每个键值对,进而利用上述这些方法来操作其中的键和值信息,
复杂度分析
时间复杂度:O(n),
其中 n 是数组 nums 的长度。我们遍历数组 nums 一次,对于 nums 中的每一个元素,将其插入哈希表都只需要常数时间。如果在遍历时没有维护最大值,在遍历结束后还需要对哈希表进行遍历,因为哈希表中占用的空间为 O(n)(可参考下文的空间复杂度分析),那么遍历的时间不会超过 O(n)。因此总时间复杂度为 O(n)。
空间复杂度:O(n)。
哈希表最多包含 n−⌊n/2⌋ 个键值对,所以占用的空间为 O(n)。这是因为任意一个长度为 n 的数组最多只能包含 n 个不同的值,但题中保证 nums 一定有一个众数,会占用(最少) ⌊n/2⌋+1 个数字。因此最多有 n−(⌊n/2⌋+1) 个不同的其他数字,所以最多有 n−⌊n/2⌋ 个不同的元素。
方法三:排序
算法思想
如果将数组 nums 中的所有元素按照单调递增或单调递减的顺序排序,那么下标为 ⌊n/2⌋ 的元素(下标从 0 开始)一定是众数。
实现步骤
对于这种算法,我们先将 nums 数组排序,然后返回上文所说的下标对应的元素。
下面的图中解释了为什么这种策略是有效的。在下图中,第一个例子是 n 为奇数的情况,第二个例子是 n 为偶数的情况。
对于每种情况,
数组上面的线表示如果众数是数组中的最小值时覆盖的下标,
数组下面的线表示如果众数是数组中的最大值时覆盖的下标。
对于其他的情况,这条线会在这两种极端情况的中间。
对于这两种极端情况,它们会在下标为 ⌊n/2⌋ 的地方有重叠。因此,无论众数是多少,返回 ⌊n/2⌋ 下标对应的值都是正确的。
class Solution {
public int majorityElement(int[] nums) {
Arrays.sort(nums);
return nums[nums.length / 2];
}
}
复杂度分析
时间复杂度:O(nlogn)。
将数组排序的时间复杂度为 O(nlogn)。
空间复杂度:O(logn)。
如果使用语言自带的排序算法,需要使用 O(logn) 的栈空间。如果自己编写堆排序,则只需要使用 O(1) 的额外空间。
方法四:随机化
算法思想
因为超过 ⌊n/2⌋ 的数组下标被众数占据了,这样我们随机挑选一个下标对应的元素并验证,有很大的概率能找到众数。
实现思路
由于一个给定的下标对应的数字很有可能是众数,我们随机挑选一个下标,检查它是否是众数,如果是就返回,否则继续随机挑选。
实现代码:
class Solution {
//生成一个再区间[min,max)之间的随机数
private int randRange(Random rand, int min, int max) {
return rand.nextInt(max - min) + min;
}
//统计在数组nums当中,值为num的元素出现次数
private int countOccurences(int[] nums, int num) {
int count = 0;
for (int i = 0; i < nums.length; i++) {
if (nums[i] == num) {
count++;
}
}
return count;
}
public int majorityElement(int[] nums) {
//1.定义一个随机数
Random rand = new Random();
int majorityCount = nums.length / 2;
while (true) {
//2.nums[randRange(rand, 0, nums.length)]为数组中的任意一个元素,并将值赋给candidate
//randRange(rand, 0, nums.length)返回随机的一个数组下标
int candidate = nums[randRange(rand, 0, nums.length)];
//3.如果candidate的出现次数大于数组长度的1/2,那么该元素为多数元素
if (countOccurences(nums, candidate) > majorityCount) {
return candidate;
}
}
}
}
复杂度分析
时间复杂度:无法推算
理论上最坏情况下的时间复杂度为 O(∞),因为如果我们的运气很差,这个算法会一直找不到众数,随机挑选无穷多次,所以最坏时间复杂度是没有上限的。
然而,运行的期望时间是线性的。为了更简单地分析,先说服你自己:由于众数占据 超过 数组一半的位置,期望的随机次数会小于众数占据数组恰好一半的情况。因此,我们可以计算随机的期望次数(下标为 prob 为原问题,mod 为众数恰好占据数组一半数目的问题):
计算方法为:
当众数恰好占据数组的一半时,第一次随机我们有1/2的概率找到众数,
如果没有找到,则第二次随机时,包含上一次我们有1/4的概率找到众数,
以此类推。因此期望的次数为 i∗1/2^i的和,可以计算出这个和为 2,说明期望的随机次数是常数。
每一次随机后,我们需要 O(n) 的时间判断这个数是否为众数,因此期望的时间复杂度为 O(n)。
空间复杂度:O(1)。
随机方法只需要常数级别的额外空间。
方式五:分治
算法思想
如果数 a 是数组 nums 的众数,如果我们将 nums 分成两部分,那么 a 必定是至少一部分的众数。
我们可以使用反证法来证明这个结论。
假设 a 既不是左半部分的众数,也不是右半部分的众数,那么 a 出现的次数少于 l / 2 + r / 2 次,其中 l 和 r 分别是左半部分和右半部分的长度。
由于 l / 2 + r / 2
实现思路
- 将数组分成左右两部分
- 分别求出左半部分的众数 a1 以及右半部分的众数 a2
- 在 a1 和 a2 中选出正确的众数。
我们使用经典的分治算法递归求解,直到所有的子问题都是长度为 1 的数组。
长度为 1 的子数组中唯一的数显然是众数,直接返回即可。
如果回溯后某区间的长度大于 1,我们必须将左右子区间的值合并。
如果它们的众数相同,那么显然这一段区间的众数是它们相同的值。
否则,我们需要比较两个众数在整个区间内出现的次数来决定该区间的众数。
实现代码
class Solution {
//统计元素num在区间[lo,hi]中出现的次数
private int countInRange(int[] nums, int num, int lo, int hi) {
int count = 0;
for (int i = lo; i <= hi; i++) {
if (nums[i] == num) {
count++;
}
}
return count;
}
private int majorityElementRec(int[] nums, int lo, int hi) {
//在大小为 1 的数组中,唯一的那个元素就是多数元素
if (lo == hi) {
return nums[lo];
}
// 分成左右两部分,做递归
int mid = (hi - lo) / 2 + lo;
int left = majorityElementRec(nums, lo, mid);
int right = majorityElementRec(nums, mid + 1, hi);
//如果左右两部分返回的多数元素相同,则直接返回即可
if (left == right) {
return left;
}
//否则,统计两个元素在数组中的出现次数,返回真正的多数元素
int leftCount = countInRange(nums, left, lo, hi);
int rightCount = countInRange(nums, right, lo, hi);
return leftCount > rightCount ? left : right;
}
public int majorityElement(int[] nums) {
return majorityElementRec(nums, 0, nums.length - 1);
}
}
复杂度分析
时间复杂度:O(nlogn)。
函数 majority_element_rec() 会求解 2 个长度为n/2的子问题,并做两遍长度为 n 的线性扫描。因此,分治算法的时间复杂度可以表示为:
T(n)=2T(n/2)+2n
根据 主定理,本题满足第二种情况,所以时间复杂度可以表示为:
空间复杂度:O(logn)。
尽管分治算法没有直接分配额外的数组空间,但在递归的过程中使用了额外的栈空间。算法每次将数组从中间分成两部分,所以数组长度变为 1 之前需要进行 O(logn) 次递归,即空间复杂度为 O(logn)。
方法五:Boyer-Moore 投票算法 最优解法
算法思想
如果我们把众数记为 +1,把其他数记为 −1,将它们全部加起来,由于多数元素的出现次数超过数组元素的半数,显然和大于 0,从结果本身我们可以看出众数比其他数多。
实现步骤
Boyer-Moore 算法的本质和方法四中的分治十分类似。我们首先给出 Boyer-Moore 算法的详细步骤:
我们维护一个候选众数 candidate 和它出现的次数 count。初始时 candidate 可以为任意值,count 为 0;
我们遍历数组 nums 中的所有元素,对于每个元素 x,在判断 x 之前,如果 count 的值为 0,我们先将 x 的值赋予 candidate,随后我们判断 x:
如果 x 与 candidate 相等,那么计数器 count 的值增加 1;
如果 x 与 candidate 不等,那么计数器 count 的值减少 1
在遍历完成后,candidate 即为整个数组的众数。
我们举一个具体的例子,例如下面的这个数组:
[7, 7, 5, 7, 5, 1 | 5, 7 | 5, 5, 7, 7 | 7, 7, 7, 7]
在遍历到数组中的第一个元素以及每个在 | 之后的元素时,candidate 都会因为 count 的值变为 0 而发生改变。最后一次 candidate 的值从 5 变为 7,也就是这个数组中的众数。
Boyer-Moore 算法的正确性较难证明,这里给出一种较为详细的用例子辅助证明的思路,供读者参考:
首先我们根据算法步骤中对 count 的定义,可以发现:在对整个数组进行遍历的过程中,count 的值一定非负。这是因为如果 count 的值为 0,那么在这一轮遍历的开始时刻,我们会将 x 的值赋予 candidate 并在接下来的一步中将 count 的值增加 1。因此 count 的值在遍历的过程中一直保持非负。
那么 count 本身除了计数器之外,还有什么更深层次的意义呢?我们还是以数组
[7, 7, 5, 7, 5, 1 | 5, 7 | 5, 5, 7, 7 | 7, 7, 7, 7]
作为例子,首先写下它在每一步遍历时 candidate 和 count 的值:
nums: [7, 7, 5, 7, 5, 1 | 5, 7 | 5, 5, 7, 7 | 7, 7, 7, 7]
candidate: 7 7 7 7 7 7 5 5 5 5 5 5 7 7 7 7
count: 1 2 1 2 1 0 1 0 1 2 1 0 1 2 3 4
我们再定义一个变量 value,它和真正的众数 maj 绑定。在每一步遍历时,如果当前的数 x 和 maj 相等,那么 value 的值加 1,否则减 1。value 的实际意义即为:到当前的这一步遍历为止,众数出现的次数比非众数多出了多少次。我们将 value 的值也写在下方:
nums: [7, 7, 5, 7, 5, 1 | 5, 7 | 5, 5, 7, 7 | 7, 7, 7, 7] value: 1 2 1 2 1 0 -1 0 -1 -2 -1 0 1 2 3 4
我们将 count 和 value 放在一起:
nums: [7, 7, 5, 7, 5, 1 | 5, 7 | 5, 5, 7, 7 | 7, 7, 7, 7] count: 1 2 1 2 1 0 1 0 1 2 1 0 1 2 3 4 value: 1 2 1 2 1 0 -1 0 -1 -2 -1 0 1 2 3 4
发现在每一步遍历中,count 和 value 要么相等,要么互为相反数!并且在候选众数 candidate 就是 maj 时,它们相等,candidate 是其它的数时,它们互为相反数!
**为什么会有这么奇妙的性质呢?**这并不难证明:我们将候选众数 candidate 保持不变的连续的遍历称为「一段」。
在同一段中,count 的值是根据 candidate == x 的判断进行加减的。
那么如果 candidate 恰好为 maj,那么在这一段中,count 和 value 的变化是同步的;
如果 candidate 不为 maj,那么在这一段中 count 和 value 的变化是相反的。因此就有了这样一个奇妙的性质。
这样以来,由于:
我们证明了 count 的值一直为非负,在最后一步遍历结束后也是如此;
由于 value 的值与真正的众数 maj 绑定,并且它表示「众数出现的次数比非众数多出了多少次」,那么在最后一步遍历结束后,value 的值为正数;
在最后一步遍历结束后,count 非负,value 为正数,所以它们不可能互为相反数,只可能相等,即 count == value。因此在最后「一段」中,count 的 value 的变化是同步的,也就是说,candidate 中存储的候选众数就是真正的众数 maj。
实现代码:
class Solution {
public int majorityElement(int[] nums) {
int count = 0;
Integer candidate = null;
for (int num : nums) {//遍历数组
if (count == 0) {
candidate = num;
}
count += (num == candidate) ? 1 : -1;
}
return candidate;
}
}
复杂度分析
时间复杂度:O(n)。
Boyer-Moore 算法只对数组进行了一次遍历。
空间复杂度:O(1)。
Boyer-Moore 算法只需要常数级别的额外空间。
通过率100% 的解法
class Solution {
public int majorityElement(int[] nums) {
return f(nums, 0);//从数组的第一个元素查找多数元素
}
public int f(int[] nums, int m){
int c = nums[m], count = 1, i = m;
//c记录当前位置的值 count当前元素比其他元素多出现的次数 i循环遍历索引
int n = nums.length;//n数组长度
while(i < n - 1 && count > 0){
i++;
if(nums[i] == c){
count++;
}else{
count--;
}
}
if(i == n - 1) return c;//到数组末尾时,count还是大于0,说明该元素出现的次数比其他元素都多
else return f(nums, i + 1);//否则,看下一个元素
}
}
欢迎大家点赞,评论加关注呦