第488场周赛Q1——题号100985《统计主导元素下标数》,题目本身不复杂,但它刚好踩中了几个特别容易被新手忽略的点:如何理解“主导元素”的精确定义、统计“下标数”是不是一定要真的去收集下标、以及“严格大于一半”在代码里怎么写才不会翻车。这篇文章我按第488场周赛的实际场景来复盘,把从最直觉的哈希解到最优的摩尔投票解都走一遍,再重点说说边界条件那些坑。
先说明一下,本文按LeetCode中“主导元素”最经典的定义来讲:一个数在数组中出现的次数严格大于数组长度的一半,这个数就是主导元素。题目要求返回该主导元素在数组中出现的下标总数;如果不存在主导元素,则返回0。如果个别版本里定义略有不同,核心思路依然适用,替换掉判断条件即可。
1. 先看懂题名:主导元素到底指什么
1.1 题名里藏着的两个关键词
“主导元素”这四个字是整道题的题眼。在LeetCode里,这个概念最经典的出处是第169题 Majority Element,中文翻译有很多种:多数元素、主元素、主导元素。不同的OJ和题解平台翻译不一样,但背后定义是一致的:出现次数超过数组长度一半。
如果你刷过169题,看到“主导元素”应该立刻反应到这个定义,而不是去猜“出现次数最多的元素”。这两者有本质区别。出现次数最多的元素(众数)不一定是主导元素,比如数组 [1, 2, 3, 4],每个元素都只出现一次,出现次数最多的是任意一个,但哪个都没有超过4的一半,所以不存在主导元素。
题目里第二个关键词是“下标数”。这个表述容易让人产生一个误解:是不是要把所有满足条件的下标都收集到一个列表里,然后返回列表长度?其实不用。主导元素一旦确定,它出现的每一个位置都是合法下标,下标总数就等于它在数组中的出现次数。意识到这一点,代码能省掉一整次遍历。
1.2 先跑两个小例子
例子一:nums = [3, 2, 3]。n = 3,数字3出现了2次,2 > 1.5,所以3是主导元素,它的下标是0和2,答案就是2。
例子二:nums = [2, 2, 1, 1, 1, 2, 2]。n = 7,数字2出现了4次,4 > 3.5,所以2是主导元素,答案就是4。
这两个例子说明一个很关键的点:答案就是主导元素的频数,和它出现在哪些位置没有关系。很多同学在这个地方多写了一层循环,白白增加代码量。
1.3 三个和题意强相关的边界问题
第一,不存在主导元素返回什么?按题目最合理的设定是返回0。这要求我们写代码时对候选元素做“是否真的超过一半”的验证,不能只找到候选就直接返回。
第二,恰好等于一半算不算?不算。“严格大于”这个词意味着频数等于n/2时,不能判定为有主导元素。比如 [1, 2],1出现1次,1 > 1为假,所以没有主导元素。
第三,数组中可能出现多个主导元素吗?不可能。两个不同的数不可能同时出现超过一半。比如 n = 10,一个数要超过5次才符合条件,如果两个数都超过5次,总数就超过10了,矛盾。这个性质看起来 trivial,但它保证了后面摩尔投票算法的正确性基础。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一反应:哈希计数,思路短平快
2.1 最直观的写法
看到统计频次的题,第一反应肯定是哈希表,这也是周赛Q1的标准解法。思路分三步:
- 用哈希表统计每个数字的出现次数。
- 找到出现次数最多的那个数字,记为bestKey,以及它的频次bestFreq。
- 判断bestFreq是否严格大于n的一半。是,返回bestFreq;否,返回0。
这里有个隐藏的简化:既然主导元素如果存在,一定是出现次数最多的元素,那么我们只要维护最大的频次就可以了,不需要等哈希表全部构建完再另外找一遍。边遍历边比较,时间复杂度仍然是O(n),但代码更紧凑。
2.2 哈希解法参考代码
C++版本:
cpp复制class Solution {
public:
int countDominantIndices(vector<int>& nums) {
int n = nums.size();
unordered_map<int, int> freq;
int bestKey = 0, bestFreq = 0;
for (int x : nums) {
freq[x]++;
if (freq[x] > bestFreq) {
bestFreq = freq[x];
bestKey = x;
}
}
if (bestFreq * 2 > n) return bestFreq;
return 0;
}
};
Python版本:
python复制class Solution:
def countDominantIndices(self, nums: List[int]) -> int:
n = len(nums)
freq = {}
best_key, best_freq = 0, 0
for x in nums:
freq[x] = freq.get(x, 0) + 1
if freq[x] > best_freq:
best_freq = freq[x]
best_key = x
return best_freq if best_freq * 2 > n else 0
注意看,哈希解法的返回就是bestFreq,根本不需要再收集一遍下标。这就是我们前面说的“下标数等于出现次数”在代码层面的体现。
2.3 复杂度与遗憾
时间复杂度O(n),只需要一趟遍历;空间复杂度O(k),k是数组中不同数字的个数,最坏情况下k=n,比如数组里所有数都不相同。
这个解法的优点是直观、正确、不容易出错,非常适合周赛开局抢时间。但它有一个明显的弱点:空间占用是线性的。如果题目加一个附加条件——只能使用O(1)的额外空间,哈希解法立刻失效。这也是为什么面试官非常喜欢在“多数元素”类型题目后面追问一句“能不能优化空间复杂度”。
从竞赛角度看,Q1用哈希完全合理,但从“吃透这道题”的角度看,摩尔投票必须掌握,因为它才是这道题真正想考察的算法内核。
3. 进阶:摩尔投票,O(1)空间找出唯一候选
3.1 一句话理解摩尔投票
把这个过程想象成一群人站成一排,每一次操作挑两个不同的人一起离场。如果有一个人的数量超过了总人数的一半,那么不管怎么配对离场,最后剩下来的一定是他。
这就是Boyer-Moore Majority Vote Algorithm的基本思想。它不是去统计每个元素的真实次数,而是让不同元素两两抵消,最后剩下“最有可能超过一半”的那个候选者。整个抵消过程只需要一个计数器和一个候选变量,空间复杂度O(1)。
3.2 算法流程
维护两个变量:
- candidate:当前的候选元素。
- count:candidate相对其他元素的“净剩余”数量。
遍历数组的每一个元素x:
- 如果count为0,把candidate设为x。
- 然后判断:如果x等于candidate,count加1;否则count减1。
遍历结束后,candidate就是可能的主导元素。但注意,只是“可能”。
3.3 为什么第一遍扫完只是“候选”
很多第一次学摩尔投票的人会有一个疑问:为什么最后留下来的candidate不直接返回?原因是抵消过程只保证了“如果存在主导元素,那它一定是candidate”,但并没有保证candidate真的超过一半。
举一个很经典的例子:nums = [1, 2, 3]。n = 3,任何数字都只出现1次,不存在主导元素。但跑一遍摩尔投票:
- 遍历到1,count为0,candidate设为1,count变为1。
- 遍历到2,2不等于1,count减为0。
- 遍历到3,count为0,candidate设为3,count变为1。
最终candidate是3,count是1。如果我们不看后面的验证,直接返回3,这就错了。3根本没有超过数组长度的一半。
所以摩尔投票必须接一个第二遍扫描,验证候选元素的真实出现次数是否超过n/2。这一步缺了,整个算法就是错的。
3.4 正确性证明
我们来简洁地证明一下,为什么如果存在主导元素,它一定能在抵消中活到最后。
设主导元素为m,它在数组中共出现c次,且c > n / 2,也就是c > n - c。整个抵消过程可以看作每次消去两个不同元素。
考虑m最不利的情况:每一次抵消都带走一个m和一个非m。非m元素一共有n - c个,所以最多只能有n - c次抵消会带走m。因为c > n - c,所以这些抵消全部发生之后,m的数量仍然大于0,最终candidate必然是m。
这个证明只需要三句话,但它是整个算法的基石。理解了它,你才敢放心写出那个看起来有点反直觉的count自增自减逻辑。
4. 本题真正的实现技巧:两次扫描即可,别写三遍遍历
4.1 大多数人的过度实现
我见过不少同学做这道题时写三遍循环:
- 第一遍摩尔投票找candidate。
- 第二遍统计candidate出现次数。
- 第三遍遍历数组,把等于candidate的下标加入vector,最后返回vector.size()。
第三遍完全是多余的。我们用第二遍统计出来的total,已经就是“主导元素的下标总数”。如果验证通过,直接返回total;验证不通过,返回0。不需要再构建任何下标集合。
这个细节看起来很小,但它反映了一个思考习惯:写代码前先想明白最终要的是什么。题目要的是数量,不是位置,所以中间不需要存储位置。
4.2 C++ 完整代码
cpp复制class Solution {
public:
int countDominantIndices(vector<int>& nums) {
int n = nums.size();
// 第一遍扫描:摩尔投票,找出唯一可能的候选元素
int candidate = 0;
int count = 0;
for (int x : nums) {
if (count == 0) {
candidate = x;
}
if (x == candidate) {
count++;
} else {
count--;
}
}
// 第二遍扫描:验证candidate是否真的超过一半
int total = 0;
for (int x : nums) {
if (x == candidate) {
total++;
}
}
// 主导元素的下标总数等于它出现的次数total
return (total * 2 > n) ? total : 0;
}
};
4.3 Python 完整代码
python复制class Solution:
def countDominantIndices(self, nums: List[int]) -> int:
n = len(nums)
# 第一遍扫描:摩尔投票
candidate = 0
count = 0
for x in nums:
if count == 0:
candidate = x
if x == candidate:
count += 1
else:
count -= 1
# 第二遍扫描:验证
total = sum(1 for x in nums if x == candidate)
return total if total * 2 > n else 0
这两段代码是本题最精简的形态。摩尔投票部分保持了O(1)空间,第二遍验证统计出真实频数,最后用乘法判断避免整数除法的边界问题。
4.4 关于count的一个常见误解
循环结束后的count,它不是candidate的真实出现次数,而是candidate在被抵消过程中“净剩”的次数。一个常见错误是直接用这个count做判断,比如判断count > n / 2。
举例说明:nums = [1, 1, 2, 2, 3]。n = 5,没有主导元素。按顺序跑摩尔投票:
- 遇到1,count为0,candidate=1,count=1。
- 遇到1,count=2。
- 遇到2,2不等于1,count=1。
- 遇到2,2不等于1,count=0。
- 遇到3,count为0,candidate=3,count=1。
循环结束时candidate=3,count=1。如果拿count当真实频数,会以为3出现了1次,1 * 2 = 2不大于5,所以返回0,结果碰巧是对的。但换个例子,nums = [1, 2, 1, 2, 2],n = 5,2出现了3次,是主导元素。摩尔投票结束时candidate可能是2,count=1,这个count明显不等于2的真实频数3。
所以一定要明白:count只是“净剩票数”,不是“真实票数”。真实票数必须通过第二遍扫描重新统计。
5. 边界用例与翻车点实测:哪些地方容易丢分
5.1 一组覆盖典型情况的用例
| 输入 | 说明 | 预期输出 |
|---|---|---|
| [] | 空数组 | 0 |
| [5] | 单元素,本身过半 | 1 |
| [1, 2] | 每个元素恰好一半 | 0 |
| [1, 2, 1, 1] | 常规情况,1过半 | 3 |
| [1, 2, 1, 2] | 双元素交替,都不过半 | 0 |
| [1, 1, 2, 2, 3] | 无主导元素 | 0 |
| [1, 2, 3, 1, 2, 2, 2, 2] | 主导元素集中在尾部 | 5 |
最后一行验证一下:n = 8,数字2出现了5次,5 > 4,所以2是主导元素,返回它的出现次数5。
5.2 整数判断的严谨写法
判断total是否超过n的一半,最稳妥的写法是:
cpp复制if (total * 2 > n)
而不是:
cpp复制if (total > n / 2)
虽然在大多数情况下两者结果一致,但使用整数除法时,n / 2会向下取整,可读性上容易让读者误以为“等于n/2也算”。另外,如果total和n都是整数,用乘法判断也能避免某语言中整数除法截断带来的不确定性。
极端情况下要留意total * 2是否会溢出。在本题数据范围内,int足够容纳,但如果面对超大规模数据,更稳的写法是:
cpp复制if (total > n - total)
这个写法完全避开了乘法和除法,同时和“严格大于一半”的定义严格对应:total大于其他元素数量之和。
5.3 摩尔投票遇上“恰好一半”输入
数组 [1, 2, 1, 2],n = 4,没有主导元素。它的特殊性在于,1和2都出现2次,恰好等于n/2。跑摩尔投票时,最终candidate取决于遍历顺序和配对抵消的具体过程,可能是1也可能是2。无论candidate是谁,第二遍扫描统计出的total都是2,2 * 2 = 4不大于4,所以正确返回0。
这说明第二遍验证真正不可缺。如果不验证,这道题一半的整数长度用例都会翻车。
5.4 空数组带来的隐患
如果题目允许n = 0,摩尔投票的第一遍循环不会执行,candidate保持初始值0,count保持0。第二遍扫描不执行,total = 0,0 * 2 > 0为假,返回0。逻辑上没问题。
但要注意,在C++中访问vector的size()返回的是size_t类型,转换为int后要小心。我自己习惯在一开始就写int n = (int)nums.size(),把所有比较统一在int域内进行,避免unsigned类型把负数变成一个很大的正数。
5.5 返回值到底是数量还是下标集合
这是这道题最容易产生过度实现的地方。如果题面是“返回下标数”,那必然是一个整数;如果题面是“返回所有下标”,那才需要返回一个vector。从《统计主导元素下标数》这个题名看,关键词是“统计”和“数”,所以就是整数。
很多同学刷题时被“下标”二字带偏,非要把所有下标存下来:
cpp复制vector<int> indices;
for (int i = 0; i < n; i++) if (nums[i] == candidate) indices.push_back(i);
return indices.size();
这段代码功能没错,但多占了一份O(n)空间,还多遍历了一次。真正的竞赛选手会一眼看出:下标数等于出现次数,所以第二遍统计频数后直接返回,代码干净利落。
6. 复盘:从这道Q1带走什么
6.1 Q1题型的特点
周赛Q1很多是把经典题换个问法,本质还是那些最基础的数据结构和算法套路。这道题就是“多数元素”加“统计出现次数”的组合。你如果熟悉169题,这道题的核心部分秒懂;你如果不熟悉,即使现场能靠哈希做出来,遇到“O(1)空间”的追问就会卡壳。
所以平时刷题还是要做题型归类。看到“过半数”“主导”“majority”这些词,第一时间在脑子里拉出三个候选方案:哈希计数、排序取中位数、摩尔投票。哈希最通用,排序O(n log n)通常不是最优,摩尔投票是空间最优解。
6.2 对做题节奏的建议
如果这是周赛现场,我会建议你直接用哈希解秒掉这道题。原因是Q1的价值在于用最少的时间拿到分,把时间留给后面的题目。哈希解法的代码量最小,出错概率最低,十分钟内可以完成从读题到提交。
但这不意味着摩尔投票不用学。周赛结束后的复盘,或者应对面试时,摩尔投票才是更值得展示的方案。它体现的是对空间复杂度的敏感度,以及对一个经典算法的熟练度。
我个人的习惯是:现场写一个最快能过的版本,提交通过后再花两分钟想一遍有没有更优解,作为复盘笔记记录下来。长期坚持,简单题也能筛出不少知识点盲区。
6.3 可以迁移到其他题目的三个思路
第一个思路:题目问“统计数量”时,先想想是否等于“统计频数”。很多题目表面上问下标、问个数、问子数组数量,深层其实在考察某个元素的频次。想通这一点,能避免大量无效操作。
第二个思路:“严格大于一半”的判断写成total > n - total,语义最清晰,也最不容易出错。这个写法从布尔逻辑上就杜绝了“等于一半算不算”的歧义。
第三个思路:摩尔投票的第一遍扫描只负责候选,第二遍扫描只负责验证,两遍扫描职责分离。这个模式还可以推广到找出现次数超过n/3的元素,只是候选数量变成两个,逻辑会更复杂,但思想一脉相承。
6.4 一点个人体会
第488场周赛的这道Q1,说难不难,说简单也不简单。它把“阅读题面”和“算法选择”这两个能力放在一起考了一次。很多人在“下标数”三个字上多绕了一圈,或者在“严格大于”边界上翻车,这都不是算法本身的问题,而是对题目信息的提取不够精确。
我自己的经验是,刷题时遇到定义类的词,先停下来确认精确含义,再动手写代码。周赛环境里,多花30秒读题,往往能省下后面调试的5分钟。这道题的最佳解法只需要两遍扫描、三个变量,代码写出来不超过二十行,但它包含的判断和取舍,值得反复体会。
