刷LeetCode Hot100有个习惯,前期按题号硬推,前90道基本都是动规、回溯、树、图这些硬骨头,等推进到96-100这最后五题的时候,会有一种突然从泥潭里上岸的感觉。这五题堪称整个Hot100里最典型的“技巧题”组合:只出现一次的数字、多数元素、颜色分类、下一个排列、寻找重复数。它们没有复杂的模板,也没有绕来绕去的状态转移,解题核心全在几个关键的观察和指针设计上,一旦想通,代码量少得惊人。
这篇记录就把我刷这五题时真正想明白的东西写下来,不只是贴代码,而是说清楚每个技巧为什么是这样设计的,以及实际练习中容易栽进去的坑。无论你是刚开始刷LeetCode的初学者,还是准备面试前想快速过一遍经典题型的选手,这五道题都值得认真吃透。
1. 只出现一次的数字:异或是天然的“消消乐”
题目本身不长:给你一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次,找出那个只出现一次的元素。要求算法具有线性时间复杂度,且不使用额外空间。
第一次看到这道题,大多数人会想到用哈希表计数,遍历一遍,记录每个数字出现的次数,最后再找那个次数为1的数。这个解法当然对,时间复杂度O(n),空间复杂度也是O(n)。但题目明确要求不使用额外空间,这就把哈希表这条路堵死了。
1.1 异或运算的三个性质,就是整道题的钥匙
异或(XOR)有三个性质值得反复记:
- 任何数和0做异或,结果还是它本身:a ^ 0 = a。
- 任何数和它自己做异或,结果是0:a ^ a = 0。
- 异或满足交换律和结合律:a ^ b ^ c 等于 a ^ c ^ b,也等于 (a ^ b) ^ c。
利用这三条性质,把数组里所有数字依次做异或运算,成对出现的数字会互相抵消成0,最后剩下的那个数字就是只出现一次的元素。
这个思路本质上就是一个“消消乐”的过程。你可以把每个成对的数字想象成两个相同的块,它们一碰就消失,只有那个落单的数字会留到最后面。整个过程不需要任何额外存储,只需要一个变量从头滚到尾。
1.2 代码极短,但背后的思维值得琢磨
python复制def singleNumber(nums: list[int]) -> int:
res = 0
for num in nums:
res ^= num
return res
代码就这几行,但为什么是异或而不是加减?因为异或能处理“成对抵消”这个场景,而且它对负数、大整数都天然适用。如果你用累加的方式,只能靠“总和减去成对元素的和”来算,但那样需要先知道成对元素的值,多一层操作。
这里有个细节容易被忽视:异或操作不依赖数字的具体大小,也不依赖它们在数组里的位置。哪怕数组是乱序的,只要成对出现的数字是两两相同,交换律和结合律就能保证它们最终会两两抵消。这一点是哈希表方案没有的优雅之处。
1.3 实际练习中的两个误区
第一个误区:有人觉得先排序再找相邻不同的元素也能过。排序的时间复杂度是O(n log n),不满足线性时间要求,面试时这么答会被追问。
第二个误区:有人会想用集合去重然后乘2减总和。比如把数组去重后所有元素之和乘以2,再减去原数组的总和,剩下的就是落单的数。这个思路正确,但需要一个set,额外空间还是O(n),同样不满足要求。
我刷这道题的体会是,它真正的价值不在这道题本身,而是让你以后看到“成对出现”“找唯一落单”这类字眼时,第一反应就是异或。这个反应在后续很多位运算题里都能用上。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多数元素:摩尔投票的本质是“把对拼掉”
题目说,给定一个大小为n的数组,找到其中的多数元素。多数元素是指在数组中出现次数大于n/2的元素。你可以假设数组是非空的,并且给定的数组总是存在多数元素。
这道题的常规解法很多:哈希表计数、排序后取中间值、分治。但最经典的技巧是摩尔投票法,时间复杂度O(n),空间复杂度O(1)。
2.1 投票抵消的直觉:多数元素扛得住消耗
摩尔投票的核心思想是维护一个候选人和一个票数,然后遍历数组。
- 如果票数为0,就把当前元素设为候选人,票数置为1。
- 如果当前元素等于候选人,票数加1。
- 如果当前元素不等于候选人,票数减1。
遍历结束后,候选人就是多数元素。
用生活化的场景来理解:假设有一群人在投票,多数派的人数超过一半。每当一个多数派和一个少数派碰在一起,两个人都“同归于尽”,多数派依然能剩下人;但少数派之间如果互相抵消,那多半是多数派得利。最终能站到最后的,一定是最初人数过半的那一派。
我最初学这个方法时有个疑问:如果少数派内部也互相抵消,会不会把多数派的票数也消耗掉?不会。因为多数派数量超过一半,无论怎么配对抵消,多数派总能剩下至少一个。这个结论的严格证明可以用归纳法,但直觉上记住“过半的人扛得住全员消耗”就够了。
2.2 代码和边界情况
python复制def majorityElement(nums: list[int]) -> int:
candidate = None
count = 0
for num in nums:
if count == 0:
candidate = num
if num == candidate:
count += 1
else:
count -= 1
return candidate
注意,这个写法依赖题目“一定存在多数元素”的前提。如果题目不保证存在多数元素,就不能直接返回候选人,而应该再遍历一遍数组,统计候选人的真实出现次数,确认它确实超过n/2再返回;否则返回-1。
很多人在面试时会被追问:“如果不保证存在多数元素,你的代码还成立吗?”这就是在考察你对前提条件的敏感度。养成习惯:拿到题目先确认输入约束,再决定要不要做验证步骤。
2.3 与其它解法的取舍
哈希表计数是最直观的思路,但空间O(n)。排序取中位数也很简单,因为如果某个数出现超过一半,排序后它一定会占据中间位置,但时间复杂度是O(n log n)。分治是另一个思路,把数组分成两半,分别找多数元素,再合并判断,时间O(n)、空间O(log n),但代码量比摩尔投票大。
实际面试中,除非面试官故意限制,用摩尔投票是性价比最高的方案。它既展示了位运算之外另一种空间优化思路,也考察你对“超过半数”这个特殊条件的利用能力。这道题告诉我们:当题目给了一个看起来不起眼的数量条件,比如“大于n/2”,这个条件本身就是解题的核心线索。
3. 颜色分类:三指针把数组切成了“三个世界”
题目叫“颜色分类”,实际上是一个排序题:给定一个包含红色、白色、蓝色,一共n个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。在这个问题中,使用整数0、1和2分别表示红色、白色和蓝色。
最容易想到的方案是直接调用排序算法,或者统计0、1、2的个数然后回填。这两种做法都能过,但题目要求“原地排序”,并且最好只遍历一次,这就引出了经典的三指针解法,也叫荷兰国旗问题。
3.1 从单指针到三指针:为什么需要三个指针
先看一个相对容易理解的方案:两次遍历。
第一次遍历统计0、1、2各自出现的次数,然后按顺序回填。这需要额外记三个数,不是严格意义上的“原地”,但能通过。如果面试官继续追问能不能一次遍历搞定,就要上三指针了。
三指针的思路很清晰:用左指针left维护0区域的右边界,用右指针right维护2区域的左边界,再用当前指针i扫描整个数组。开始时left指向数组开头,right指向数组末尾,i从开头出发。
不变式是:left左边的所有元素都是0,right右边的所有元素都是2,中间是未扫描和1组成的区域。i遍历到每个元素时,根据它的值做不同处理。
3.2 交换规则为什么不对称,这是最大的坑
- 当nums[i]等于0时,把它和nums[left]交换,然后left加1,i加1。
- 当nums[i]等于1时,什么都不做,i加1。
- 当nums[i]等于2时,把它和nums[right]交换,然后right减1,但i不加1。
核心问题在于:为什么交换0时i可以前进,交换2时i不能前进?
原因是,从left位置换过来的元素,只可能是0或1,因为left左边的元素都是0,left位置本身也是0或1,把它换到i位置后,i位置的值要么是0要么是1,已经属于“处理过”的区域,所以i可以放心前进。
但从right位置换过来的元素就不一定了。right右边的元素都是2,但right位置的元素可能是0、1、2中的任意一个。把未知元素换到i位置后,i还需要重新判断一次当前值,所以i不能前进,必须留在原地再处理一轮。
这个不对称很容易被忽略。我第一次写的时候,交换2之后顺手加了i,结果把某个0漏判,最终结果就乱了。后来发现所有题解里交换2后都不动i,才意识到这里别有玄机。
3.3 代码与手推实例
python复制def sortColors(nums: list[int]) -> None:
left = 0
right = len(nums) - 1
i = 0
while i <= right:
if nums[i] == 0:
nums[i], nums[left] = nums[left], nums[i]
left += 1
i += 1
elif nums[i] == 1:
i += 1
else:
nums[i], nums[right] = nums[right], nums[i]
right -= 1
拿数组[2, 0, 2, 1, 1, 0]手动走一遍:
- 初始:left=0, right=5, i=0,nums[0]=2。交换nums[0]和nums[5],数组变[0, 0, 2, 1, 1, 2],right减为4,i仍为0。
- nums[0]=0,交换nums[0]和nums[0](原地自己换自己),left变1,i变1,数组还是[0, 0, 2, 1, 1, 2]。
- nums[1]=0,交换nums[1]和nums[1],left变2,i变2,数组还是[0, 0, 2, 1, 1, 2]。
- nums[2]=2,交换nums[2]和nums[4],数组变[0, 0, 1, 1, 2, 2],right变3,i仍为2。
- nums[2]=1,i变3。
- nums[3]=1,i变4。
- i=4, right=3,循环结束。
最终数组[0, 0, 1, 1, 2, 2],正确。
这道题给我最大的启发是,指针变量不只是用来“定位”的,它们还承担着“区域边界”的语义。每次交换其实是在维护三个区域的分界线,想清楚这一点,代码就不会写错。
4. 下一个排列:从右往左找“拐点”是一套固定剧本
“下一个排列”是整个Hot100里最容易被低估的题。它不像位运算那样有很强的直觉推导,也不像三指针那样有明显的指针移动规则,它更像一个“找规律”的题。但只要掌握字典序的本质,解法非常固定。
题目要求:实现获取下一个排列的函数。给定一个整数数组表示的数字,需要重新排列成数字序列,使得得到的数字序列在字典序中比当前排列大的最小排列。如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。
4.1 字典序的直觉:越靠左的数字,权重越大
字典序可以理解成数字排列在英文词典中的顺序。比如[1, 2, 3]后面是[1, 3, 2],再后面是[2, 1, 3]。[1, 3, 2]不可能是[1, 2, 3]的前一个,因为数字序列要按照“从左到右逐位比较”的规则排序。
一个关键的观察是:为了让新的排列比当前排列更大,我们只需要把某个位置上的数字变大,同时保持这个位置左边的所有数字不变。由于越靠左的位置权重越大,所以我们应该尽可能找到最靠右的、还能变大的位置来操作。
这个“最靠右的、还能变大的位置”,就是所谓的“拐点”。
4.2 算法的三个关键步骤
标准解法分三步:
- 从右向左扫描,找到第一个满足nums[i] < nums[i+1]的下标i。这个i就是需要变大的位置。如果找不到,说明整个数组是降序排列,不存在下一个更大的排列,直接反转整个数组。
- 从右向左扫描,找到第一个大于nums[i]的元素nums[j],交换nums[i]和nums[j]。
- 将i+1到数组末尾的部分反转,让它变成升序。
为什么第二步要从右往左找?因为我们需要找到的是比nums[i]大的最小数字,从右往左遇到的第一个大于nums[i]的数字,就是所有大于nums[i]的数字中处于最右侧的那个,也就是“最接近nums[i]”的较大数。交换后,i位置变得尽可能小地增大了。
为什么第三步要反转而不是排序?因为i+1到末尾原本是降序的,反转之后会变成升序,这是字典序最小的排列。这一步保证了新的排列是所有比当前排列大的排列中最小的那个。
4.3 手工推演一个完整例子
以[1, 5, 8, 4, 7, 6, 5, 3, 1]为例:
- 从右往左找第一个nums[i] < nums[i+1]:1 < 3不成立,3 < 5不成立,5 < 6不成立,6 < 7不成立,7 < 4不成立,4 < 6成立,所以i=3,对应数字4。
- 从右往左找第一个大于nums[3]即4的数字:从右往左依次是1、3、5、6、7,第一个大于4的是5(下标6)。
- 交换4和5,数组变成[1, 5, 8, 5, 7, 6, 4, 3, 1]。
- 反转下标4到末尾的部分,也就是[7, 6, 4, 3, 1]反转为[1, 3, 4, 6, 7],最终结果是[1, 5, 8, 5, 1, 3, 4, 6, 7]。
python复制def nextPermutation(nums: list[int]) -> None:
i = len(nums) - 2
while i >= 0 and nums[i] >= nums[i+1]:
i -= 1
if i >= 0:
j = len(nums) - 1
while j >= 0 and nums[j] <= nums[i]:
j -= 1
nums[i], nums[j] = nums[j], nums[i]
left, right = i + 1, len(nums) - 1
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
注意循环里用的是nums[i] >= nums[i+1]和nums[j] <= nums[i]这两个比较,带等号。原因是为了跳过重复数字。如果不带等号,遇到[1, 5, 1]这种数组时,最终结果会出错。这是很多人在实现时忽略的细节。
这道题可以当作C++里next_permutation的手写版来记。一旦理解这套“找拐点、换大数、反转后缀”的固定剧本,以后再遇到求排列组合的问题,很多都能顺着这个思路展开。
5. 寻找重复数:数组里藏了一个环,快慢指针直接抓
最后一题是“寻找重复数”:给定一个包含n+1个整数的数组nums,其数字都在[1, n]范围内,假设只有一个重复的整数,返回这个重复的数。要求:不能修改原数组,只能使用O(1)的额外空间,时间复杂度小于O(n^2)。
题目限制非常严格,不能修改数组意味着不能用排序,也不能用原地交换。O(1)空间意味着不能开哈希表。看起来几乎无解,但它其实有一个极其优美的转化:把数组看成链表,用快慢指针找环入口。
5.1 把数组下标和值“串”成一个链表
关键观察是,数组的每个元素值都在1到n之间,完全可以当作“下一个下标”来使用。以[1, 3, 4, 2, 2]为例:
- 从下标0出发,nums[0]=1,于是走到下标1。
- nums[1]=3,走到下标3。
- nums[3]=2,走到下标2。
- nums[2]=4,走到下标4。
- nums[4]=2,走到下标2。
- nums[2]=4,走到下标4……循环。
这个后一半的行走过程形成了一个环:2 -> 4 -> 2。环入口处的值就是重复的数。
为什么一定会成环?因为数组长度是n+1,每个元素值都在1到n之间,所以下标从0出发,每次跳转到的位置一定在1到n内。总共只有n个可跳转的位置,但数组里有n+1个元素,跳转次数足够多时必然产生重复访问,也就是成环。
5.2 快慢指针两步走:先找相遇点,再找环入口
先用快慢指针找到环内的相遇点:慢指针每次走一步,快指针每次走两步,在循环链表里它们一定会相遇。
然后用一个额外的指针从起点出发,与慢指针以相同速度前进,两者的相遇点就是环入口。
为什么第二个步骤能找到环入口?这里涉及Floyd判圈算法的一个经典结论。设起点到环入口的距离为d,环入口到相遇点的距离为s,环长为L。慢指针走了d+s步,快指针走了2(d+s)步,快指针比慢指针多走了d+s步,这个多走的部分一定是一个整圈,所以d+s是L的整数倍。
从相遇点再继续走L-s步就能回到环入口,而从起点走d步也能到环入口。由于d+s是L的整数倍,那么从相遇点出发,去走d步,等价于先走(L-s)步回到环入口,再继续走,最终也能到达环入口。所以起点指针和相遇点指针同步前进,相遇点就是环入口。
5.3 代码实现和易错点
python复制def findDuplicate(nums: list[int]) -> int:
slow = nums[0]
fast = nums[nums[0]]
while slow != fast:
slow = nums[slow]
fast = nums[nums[fast]]
slow = 0
while slow != fast:
slow = nums[slow]
fast = nums[fast]
return slow
第一个while里fast每次走两步的方式是nums[nums[fast]],这个写法对应链表里fast.next.next。第二个while里两个指针都走一步,相遇时就是重复数字。
注意这道题不能用nums[0]的初始值来直接找环入口,因为入口可能不是重复数字本身所在的节点。我第一次做的时候把环入口理解成了下标,结果搞错了半天。其实环入口是“下标”,这个下标对应的nums值就是重复的数。看起来有点绕,多画几遍图就清楚了。
还有一个常见疑问:这个解法会不会陷入死循环?不会。因为数组可跳转的位置有限,从下标0出发必然进入一个环,快慢指针一定会在环里相遇。
对比哈希表和排序方案,快慢指针方案的时间复杂度是O(n),空间复杂度O(1),是这道题所有解法中最“优雅”的一个。它把“数组中找重复”完全转化为“链表中找环”,这种跨领域的思维转换,正是技巧题最有魅力的地方。
最后说一点我刷完这五道题的真实感受。Hot100把这几道题放在末尾,并不是因为它们简单,而是因为它们代表着算法训练中很重要的一层能力:对约束条件的敏感度。只出现一次的数字逼你放弃哈希表,多数元素逼你利用数量过半的信息,颜色分类逼你原地一次扫描,下一个排列逼你找字典序规律,寻找重复数逼你不能改数组。每个限制条件都在暗示一个隐藏的思路。如果平时刷题只背模板,遇到这些限制就会手足无措;反过来,当你习惯了先从约束条件反推解法,这些题反而会成为你面试时的加分项。
建议把这五道题放在一起连刷,刷完后合上题解,自己在纸上把每一题的核心观察写一遍:异或抵消、投票消耗、三指针分区、找拐点反转、链表找环。能清晰地把这些讲明白,才算真正吃透了Hot100里这最后的五道技巧题。
