刷LeetCode的数组和排序题,可以说是每一个准备面试、打算法基础的人绕不开的起点。我当年就是从这两块开始,一点点把“看到题目脑子里一片空白”的状态磨成了“先想到暴力解,再想到优化点”的思维习惯。数组题看似简单,但它几乎覆盖了双指针、哈希表、原地操作、前缀和这些后续高频算法的雏形;排序则是理解复杂度、递归和分治思想最直接的入口。这篇内容适合刚刷题不久、想系统过一遍初级题型的读者,也适合那些刷了几十题但总觉得没有章法、想回头补基础的朋友。我会按题型拆思路、给模板、讲坑点,用你能直接抄的代码和步骤说话。
1. 初级数组题到底在考什么:核心模式与破题思路
先说一个最大的感受:LeetCode的初级数组题,表面上是考察“你会不会写循环”,实际上考的是两件事——能不能用最小的额外空间去改数组,以及能不能把双重循环优化成单次遍历。如果你带着这个意识去刷题,很多题其实是同一套模板的不同外衣。
1.1 双指针是数组题的半壁江山
双指针不是某个具体算法,而是一种“用两个下标协作遍历数组”的套路。初级数组题里,删除重复项、移动零、移除元素、合并有序数组,全是双指针的变体。入门的时候我总以为双指针只能处理有序数组,后来才明白,它的核心价值是用两个下标表示不同状态,从而省掉额外数组。
举个例子,删除排序数组中的重复项,题目要求原地修改,不能开新数组。这时候一个指针负责“找新元素”,另一个指针负责“记录放哪”,两个指针走一遍,问题就结束了。所以你不需要记住每一道题的代码,而是记住这个分工模式:快指针负责探索,慢指针负责维护结果区。后面遇到移动零、移除元素,你自然会发现是同一套语言。
1.2 原地操作与时间空间权衡
数组题有个潜规则:能原地操作就不要开新数组。很多题目明确写了“原地修改”,即使没写,面试官也更喜欢空间复杂度O(1)的方案。但这里有个容易误判的地方——有些人为了原地而原地,写出一堆复杂的交换逻辑,反而漏洞百出。
我的经验是:先想清楚允许的空间复杂度,再选实现方式。如果题目没有禁止额外数组,而使用额外数组能让代码简洁到一眼看对,那在笔试阶段完全可以用。但如果是面试,你最好在给出简洁解法之后,主动提一句“这个可以用双指针优化到O(1)空间”。这体现的不是你会背题,而是你理解取舍。
1.3 哈希表辅助的经典套路
数组题里有一类问题靠遍历本身很难解决,典型就是两数之和、存在重复元素、两个数组的交集。这时候哈希表是天然的加速器。初级阶段的哈希表用法其实很单调:一边遍历,一边把“已经见过的值”存起来,然后用O(1)时间判断“目标值是否在之前出现过”。
这个思想延伸出去,就是后续很多中等题的基础。比如连续子数组和、滑动窗口计数,底层都是“用哈希表记录状态”。所以不要觉得两数之和只是简单题,它背后的“边遍历边查询”模式,值得你反复咀嚼。
2. 手把手拆解五道必刷数组题
选这五道题,因为它们覆盖了初级数组题大部分核心手法:双指针、原地交换、哈希表、反转技巧、位运算。每一道我都会给出完整思路、代码和复杂度,再说明坑在哪里。
2.1 删除排序数组中的重复项:双指针的入门模板
题目要求:给你一个非严格递增排列的数组,原地删除重复出现的元素,使每个元素只出现一次,返回删除后数组的新长度。
思路一句话:由于数组有序,重复元素一定连续。用慢指针记录“下一个不重复元素应该放的位置”,用快指针遍历数组。当快指针遇到和慢指针指向的值不同的元素时,说明发现了新元素,把它放到慢指针的下一个位置即可。
def removeDuplicates(nums): if not nums: return 0 slow = 0 for fast in range(1, len(nums)): if nums[fast] != nums[slow]: slow += 1 nums[slow] = nums[fast] return slow + 1注意返回值是长度,而nums的前几个位置已经被修改成了去重后的序列。我第一次写的时候,把slow的初始值设成1,结果数组长度是1的时候直接越界。正确做法是先把空数组这种情况单独处理,慢指针从0开始,快指针从1开始。
这个模板能直接迁移到“移除指定值”的题目:把判断条件从“不相等”改成“不等于目标值”,逻辑几乎一模一样。所以这道题值得背,但更值得理解的是慢指针的语义——它始终指向“最后一个被保留元素的位置”。
2.2 移动零:快慢指针的变体
题目要求:把数组里所有0移动到末尾,同时保持非零元素的相对顺序。
这道题我第一次想做的是“遇到0就把它和后面的非0交换”,但这样写很容易陷入局部交换的混乱。正确的做法是:还是用快慢指针,快指针找非零元素,慢指针指向当前应该填入非零元素的位置。把所有非零元素按顺序往前放,放完后剩余位置补0。
def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1这里交换而不是直接赋值,是因为直接赋值会把后面的非零元素复制到前面,但原来位置的值不会自动变成0。用交换就能保证非零元素相对顺序不变,同时0被一步步推到后面。如果你在纸上模拟一遍[0,1,0,3,12]这个过程,会发现交换操作特别优雅,每个元素最多被交换一次。
坑点在于:交换操作本身比赋值耗时,但LeetCode上这个复杂度是O(n),完全够用。真正需要避免的是每次遇到0都去移动后面所有元素,那样最坏情况是O(n²),大数据量会超时。
2.3 两数之和:哈希表补集法
题目要求:返回数组中两个数的下标,使这两个数之和等于目标值。你可以假设每组输入只对应一个答案,且不能重复使用同一个元素。
暴力解法是双重循环,O(n²)。优化思路很直接:遍历到某个数nums[i]时,我们希望立刻知道“target - nums[i]”之前有没有出现过。用哈希表记录“值 -> 下标”,就能把查询时间降到O(1)。
def twoSum(nums, target): seen = {} for i, num in enumerate(nums): complement = target - num if complement in seen: return [seen[complement], i] seen[num] = i注意顺序:先查补集,再把自己放入哈希表。我之前写反过,导致同一个元素被用了两次,比如target是4,数组是[2,2]时没问题,但如果数组是[2,3],先放自己再查,就会在遇到3时发现补集1不存在,这倒是没影响;真正的坑在于如果数组里只有一个2,target是4,你先把自己放进去,然后遍历到另一个元素时恰好补集也是2,就可能错误返回同一个下标。所以“先查后存”是这道题的铁律。
这道题还有一个变体是要求返回排序后的下标,或者要求输出所有组合,那种情况下双指针更适合。但初级题里,哈希表是通用解法。
2.4 旋转数组:三次反转的数学原理
题目要求:给定一个数组,将数组中的元素向右移动k个位置,其中k是非负数。
最容易想到的办法是开一个额外数组,把每个元素放到新位置,然后拷贝回来。空间O(n)。但更经典的解法是三次反转,空间O(1),而且代码短得惊人。
思路是这样的:假设数组是[1,2,3,4,5,6,7],k=3。先整体反转得到[7,6,5,4,3,2,1];然后反转前k个元素,得到[5,6,7,4,3,2,1];再反转剩余部分,得到[5,6,7,1,2,3,4]。你会发现结果恰好是原数组右移3位的结果。
为什么?因为“右移k位”的本质是:最后k个元素跑到最前面,其余元素往后顺移。而三次反转分别做了“整体逆序”“前段逆序”“后段逆序”,逆序本就是可逆操作,组合起来刚好实现循环移动。
def rotate(nums, k): n = len(nums) k %= n if k == 0: return reverse(nums, 0, n - 1) reverse(nums, 0, k - 1) reverse(nums, k, n - 1) def reverse(nums, left, right): while left < right: nums[left], nums[right] = nums[right], nums[left] left += 1 right -= 1这里必须特别注意:k可能大于数组长度,所以先取模。我踩过的坑是忘记处理k=0的情况,导致反转范围出错。另外,反转函数里的while条件是left < right,写成left <= right虽然也能跑,但等于多交换了一次中间元素,虽然不影响结果,但没必要。
2.5 只出现一次的数字:异或运算的妙用
题目要求:非空整数数组,除了某个元素只出现一次,其余元素都出现两次。找出那个只出现一次的元素。
这道题如果没接触过位运算,第一反应是哈希表计数。但题目通常会有进阶要求:线性时间复杂度,且不使用额外空间。这时候异或就派上用场了。
异或的核心性质:任何数和0异或等于它本身;任何数和自身异或等于0;异或满足交换律和结合律。所以把数组里所有数字全部异或一遍,成对出现的数字会变成0,剩下的就是那个单次出现的数字。
def singleNumber(nums): result = 0 for num in nums: result ^= num return result代码短到不像是一道题,但理解背后的数学性质才是关键。我建议你亲手算一下[4,1,2,1,2]的异或过程:4^1 = 5,5^2 = 7,7^1 = 6,6^2 = 4,最后得到4。中间过程很乱,但最终结果是对的,这就是逻辑运算的威力。
坑点在于:这个解法只适用于“重复次数是偶数”且“只有一个落单数字”的特定场景。如果你碰到“每个数字出现3次”的题,异或就不管用了,那是进阶题,需要逐位统计。
3. 排序类初级题怎么切入:从合并到原地排序
LeetCode的初级排序题,很少让你去手写一个完整的快速排序,更多是考察“有序数组合并”以及“对部分排序算法的理解”。但如果你想真正通过刷题建立体系,手写一次常见排序算法是绕不开的基本功。
3.1 合并两个有序数组:逆向双指针的经典场景
题目要求:两个非递减顺序排列的整数数组nums1和nums2,nums1的长度为m+n,其中前m个元素是待合并的元素,后n个位置是0占位。要求将所有元素合并到nums1中,且保持有序。
这道题看起来简单,但如果从前往后合并,会把nums1里还没比较的元素覆盖掉。所以正解是从后往前填:用两个指针分别指向nums1的有效末尾和nums2的末尾,再一个指针指向nums1的实际末尾(m+n-1)。每次比较两个指针指向的值,较大的放到末尾,然后指针前移。
def merge(nums1, m, nums2, n): p1 = m - 1 p2 = n - 1 p = m + n - 1 while p1 >= 0 and p2 >= 0: if nums1[p1] > nums2[p2]: nums1[p] = nums1[p1] p1 -= 1 else: nums1[p] = nums2[p2] p2 -= 1 p -= 1 # 如果nums2还有剩余,直接拷贝到前面 nums1[:p2 + 1] = nums2[:p2 + 1]这里有个细节:如果p1先小于0了,说明nums1的有效部分已经用完,剩下应该全是nums2的元素;如果p2先小于0,则nums2已经全部合入,不需要额外操作。很多人的困惑是最后那个切片赋值为什么不是写“如果p2 >= 0”,因为切片本身就包含了空切片的情况,所以直接赋值不会出错。
我从这道题里学到的通用技巧是:合并有序数组时,如果题目允许覆盖补位区域,优先考虑逆向操作。这个思路在“移除元素”里同样有效,因为从后往前操作可以避免元素移动造成的数据丢失。
3.2 常考排序算法的复杂度对照
就算LeetCode初级题不直接让你写排序算法,面试中也经常会问复杂度、稳定性、适用场景。我用一张表把这些整理清楚,方便你对照记忆。
| 排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 |
|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
| 插入排序 | O(n²) | O(n) | O(n²) | O(1) | 稳定 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
| 快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n) | 不稳定 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
这里说一句:初级题里如果遇到排序,优先考虑内置的sort()函数就完了,没必要手写。但理解这些算法的差异,是为了应对“Top K问题”“寻找第K大的数”这类进阶题目。比如快速排序的partition过程,就是很多中等题的直接模板。
3.3 手写快速排序的边界处理
虽然LeetCode初级题不直接考,但手写快速排序是很多笔试的第一道编程题。我在这里给出一个不容易出错的版本,并解释边界。
def quick_sort(nums, left, right): if left >= right: return pivot = nums[left] i = left j = right while i < j: while i < j and nums[j] >= pivot: j -= 1 nums[i] = nums[j] while i < j and nums[i] <= pivot: i += 1 nums[j] = nums[i] nums[i] = pivot quick_sort(nums, left, i - 1) quick_sort(nums, i + 1, right)这个写法叫“挖坑填数法”,基准值选了最左边的元素。注意两个while里面都要加i < j条件,否则当nums[j]一直大于等于pivot时,j会一路减到小于left。另一个值得提示的点是:如果你改选中间的数为基准,边界会更复杂,建议新手先从固定左端点开始,熟练后再改。
我实际面试中见过有人把快速排序写成了死循环,问题大多出在“等于pivot”的时候,两个while用了严格大于或严格小于,导致左右指针永远不动。所以记住:处理等于pivot的元素时,要保持一侧等于也继续移动,具体取决于你选择的策略,但必须保证指针能前进。
此外,如果数组包含大量重复元素,快速排序会退化到O(n²),这时候可以想想为什么很多语言的排序库不用纯快排,而是混入插入排序和堆排序,这就是三路快排、内省排序存在的意义。
4. 刷题避坑与实战经验:我踩过的几个坑
这部分我想分享一些代码之外的细节。很多题你看着答案能懂,自己一写就错,原因往往不是思路不对,而是对边界和语言特性的把握不够。
4.1 注意数组越界与指针结束条件
我最早刷题时,最常见的错误是数组越界。比如删除重复项那道题,如果数组为空,直接访问nums[0]就会崩溃;比如旋转数组里反转时,k一次性取模可以防止右边界为负。指针循环的结束条件一定要在纸上画出来。
我有个习惯:每次写循环前,先确认“当指针到达数组末尾时,循环是否还能正确退出”。比如合并有序数组,主循环条件是p1 >= 0 and p2 >= 0,一旦有一个指针小于0就退出,剩下的另一个数组用单独的逻辑处理。这样就不容易出现“有一个数组先耗尽,但循环还在访问无效下标”的情况。
4.2 测试用例与边界值自查
LeetCode提交代码前,最好自己先跑几个边界用例。我总结了一套“初级数组题通用测试清单”:
- 空数组或零长度
- 数组长度是1
- 所有元素都相同
- 目标值不在数组中
- 数组中包含负数或0
- k为0或k等于数组长度
这些用例能帮你快速暴露问题。比如两数之和,如果答案不存在,题目通常保证有解,但如果你在练习时自己扩展了题目,就要想好返回什么;比如移动零,如果数组全零,你的交换逻辑是否依然能结束循环。
4.3 笔试面试时的代码规范
刷题时你可能只追求“能过”,但面试时代码规范同样重要。我见过很多候选人代码逻辑对但变量名全是a、b、c,或者没有任何注释,面试官很难快速理解你的思路。我的建议是:变量名用语义化缩略词,比如slow、fast、p1、p2、seen;关键步骤写一行注释说明当前思路;函数入口处理空数组边界。
另外,如果面试官让你用某种语言写题,尽量用语言内置的高效工具。比如Python里列表的切片、enumerate、字典的in运算符,都是很自然的表达方式。不要为了炫技去写一些奇技淫巧的位运算,除非题目明确要求或者你很清楚底层的动机。
5. 常见问题速查表与后续学习路线
在这里我把实际刷题中容易被卡住的问题集中整理一下,可以作为自查清单。
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 删除重复项后返回值正确,但输出数组不对 | 忘记题目要求原地修改,打印了原数组 | 检查你是否只修改前k位,而后续元素无关紧要 |
| 两数之和返回了同一个元素的下标 | 先存入哈希表再查补集 | 改成先查补集,后存当前值 |
| 旋转数组答案错乱 | 没有对k取模,或反转范围计算错误 | 先k %= n,再在纸上画出反转边界 |
| 快速排序超时或栈溢出 | 最坏情况退化成O(n²) | 考虑随机选基准或改用三路快排 |
| 合并有序数组时覆盖了未比较元素 | 从前往后合并 | 改成从后往前合并 |
5.1 常见问题及排查
上述表格里的问题,我很推荐你遇到后再回来对照。这里补充一个排查通用思路:如果结果不对,先用小数组在纸上手动模拟一次,把每个指针的变化列出来;再和代码对比,一般就能发现是某个边界条件写错了。
还有人会问:为什么我的代码和题解一样,但就是超时?这个通常不是逻辑问题,而是语言层面的低效操作,比如在Python里频繁删除列表中间元素、用加法拼接大量字符串等。数组题的操作尽量用下标交换或赋值,少用list.pop(0)、list.insert、list.remove这类方法,因为它们都是O(n)操作,叠加在循环里会变成O(n²)。
5.2 从初级到进阶的路线建议
刷完数组和排序的初级题,下一步建议按三条线走:
- 双指针进阶:三数之和、四数之和、接雨水、盛最多水的容器,这些题在初级双指针上加了更多状态判断。
- 哈希表进阶:最长连续序列、字母异位词分组、和为K的子数组,你会用到更复杂的哈希表结构。
- 排序进阶:数组中的第K个最大元素、颜色分类、排序链表,这些题会用到快速选择、三路划分、归并排序等技巧。
学习路线不需要把LeetCode所有标签题都刷完,而是按模式刷。每次遇到新题,先尝试判断它属于哪种模式,再用你熟悉的模板去套。如果套不上,说明这个模式新的点在哪里,记下来。
最后说一点我自己的体会:刷题这事儿,最怕的就是“看题解秒懂,自己做卡壳”。我后来强制自己先写暴力解,再优化,哪怕暴力解超时也要写出来。因为暴力解能帮你理清题目在做什么,优化时你才知道哪些计算是重复的。初级数组和排序题正好适合练这个流程:题目足够简单,让你有余力去思考“为什么能更优”,而不是直接被复杂的逻辑淹没。
把这些基础打牢之后,你会发现后面刷中等题时,很多“新题”不过是旧模板换了一层包装。祝你能在一次次的提交错误和Accepted之间,建立起属于自己的解题直觉。