在解决"169. 多数元素"问题时,我们首先需要明确题目要求:给定一个大小为n的数组,找出出现次数超过⌊n/2⌋的元素。这个看似简单的问题背后,其实隐藏着多种解题思路,每种方法在时间复杂度和空间复杂度上都有显著差异。
多数元素问题有几个关键特性值得我们注意:
这些特性意味着:
在实际编码中,我们通常会考虑以下几种解法:
| 解法类型 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 哈希统计法 | O(n) | O(n) | 通用场景,不依赖特定条件 |
| 排序取中法 | O(nlogn) | O(1)或O(n) | 数据可修改且排序开销可接受 |
| 摩尔投票法 | O(n) | O(1) | 明确存在多数元素的场景 |
从表中可以看出,摩尔投票法在时间和空间复杂度上都达到了最优,但它高度依赖"多数元素必然存在"这一前提条件。这也是为什么在实际工程中,我们往往会先确认问题特性再选择算法。
哈希统计法是最直观的解决方案:
java复制public int majorityElement(int[] nums) {
Map<Integer, Integer> countMap = new HashMap<>();
int majorityThreshold = nums.length / 2;
for (int num : nums) {
int count = countMap.getOrDefault(num, 0) + 1;
if (count > majorityThreshold) {
return num;
}
countMap.put(num, count);
}
// 题目保证存在多数元素,此处不会执行到
return -1;
}
虽然哈希表解法的时间复杂度是O(n),但实际性能表现却不尽如人意:
在我的实际测试中,这个解法在LeetCode上耗时约18ms,仅击败5%的提交。内存消耗约51MB,表现尚可。
提示:在Java中使用HashMap时,如果能预估元素数量,最好在初始化时指定容量,避免扩容开销。例如本例中可以设置初始容量为nums.length。
排序取中法利用了多数元素的数学特性:
java复制public int majorityElement(int[] nums) {
Arrays.sort(nums);
return nums[nums.length / 2];
}
这个解法虽然代码简洁,但有几点需要注意:
在实际测试中,这个解法耗时约5ms,击败约40%的提交。内存消耗约55MB,表现一般。
注意:如果题目不保证存在多数元素,排序取中法就不适用了。例如[1,2,3]返回2显然是错误的。
摩尔投票法(Boyer-Moore算法)是解决多数元素问题的最优解。其核心思想是"对抗消除":
java复制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;
}
为什么这个算法能正确找到多数元素?我们可以这样理解:
假设多数元素为m,出现次数为k(k > n/2),其他元素总出现次数为n-k。
在最坏情况下,m以外的所有元素都用来抵消m:
因此最终剩下的候选必然是m。
在实际测试中,摩尔投票法表现出色:
在实际工程中应用这些算法时,需要考虑更多因素:
摩尔投票法可以扩展解决更一般的问题:
例如,找出所有出现次数超过n/3的元素:
java复制public List<Integer> majorityElement(int[] nums) {
// 初始化两个候选和计数器
Integer candidate1 = null, candidate2 = null;
int count1 = 0, count2 = 0;
for (int num : nums) {
if (candidate1 != null && candidate1 == num) {
count1++;
} else if (candidate2 != null && candidate2 == num) {
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 (candidate1 != null && num == candidate1) count1++;
if (candidate2 != null && num == candidate2) count2++;
}
if (count1 > nums.length / 3) result.add(candidate1);
if (count2 > nums.length / 3) result.add(candidate2);
return result;
}
在实现这些算法时,容易犯以下错误:
哈希表法:
排序法:
摩尔投票法:
为了更直观地理解各算法的性能差异,我进行了本地测试(JDK 17,i7-11800H):
| 算法类型 | 时间复杂度 | 空间复杂度 | 10^4元素耗时(ms) | 内存消耗(MB) |
|---|---|---|---|---|
| 哈希统计法 | O(n) | O(n) | 15.2 | ~50 |
| 排序取中法 | O(nlogn) | O(1) | 8.7 | ~55 |
| 摩尔投票法 | O(n) | O(1) | 1.5 | <1 |
测试数据为随机生成的包含多数元素的数组,结果与LeetCode评测趋势一致。
实际工程中选择算法时,除了复杂度分析,还应该考虑:
- 数据是否允许被修改
- 是否需要保持原始数据顺序
- 后续是否还需要使用元素的频率信息
在不同编程语言中实现这些算法时,需要注意语言特性的差异:
python复制def majorityElement(nums):
count = 0
candidate = None
for num in nums:
if count == 0:
candidate = num
count += (1 if num == candidate else -1)
return candidate
Python版本需要注意:
cpp复制int majorityElement(vector<int>& nums) {
int count = 0;
int candidate = 0;
for (int num : nums) {
if (count == 0) {
candidate = num;
}
count += (num == candidate) ? 1 : -1;
}
return candidate;
}
C++版本的优势:
经过对各种解法的分析和实践,可以得出以下结论:
在实际编码面试中,建议:
最后分享一个实用技巧:当遇到"出现次数超过半数"这类问题时,摩尔投票法应该成为你的第一反应。这种算法不仅效率高,而且可以扩展到更一般的频率统计问题中。