刷题这件事,很多人开局就卡在“从哪入手”这步。如果让我给一个肯定不会错的答案,那就是数组和排序。这个组合几乎是所有算法题的基石——你后面遇到的字符串处理、二分查找、动态规划、区间合并,拆到底层基本都是数组操作加上某种顺序关系的利用。而LeetCode题库里的初级算法题,数组和排序又是出现频率最高的入门分区。这篇文章就结合我刷题带人的实际经验,把这批题背后真正值得掌握的套路拆开讲透,不搞那些“一行题解背完就忘”的东西,而是让你看完能自己推出来。
如果你现在处于“题刷了不少,但换个问法就不会”的阶段,或者刚开始刷还没建立自己的解题框架,这篇文章比较适合你。我会先把数组题里最常见的几种底层思维抽出来,再把排序算法中真正高频的选型和写法拎清楚,最后用几道典型的题完整走一遍从读题到码出答案的推导过程。
1. 数组题到底在考什么:四种底层思维模型
数组题表面上花样百出,什么两数之和、盛最多水的容器、三数之和、下一个排列,但剥开外壳之后,核心就四种思维模型。我建议你刷题到一定量之后,主动按这四类去归类题目,而不是按题号去记。这样才能做到“换个问法也能上手”。
1.1 双指针:有序前提下最暴力的优化
双指针是数组题里性价比最高的技巧,没有之一。它的核心逻辑其实非常简单:如果数组有序,那么很多 O(n²) 的暴力枚举可以被优化到 O(n)。
举一个最经典的例子,判断数组中是否存在两个数和为 target。暴力做法是两层循环,每个元素都去和后面的元素相加判断一次,时间复杂度 O(n²)。但如果你先给数组排个序,然后一个指针指向开头,一个指针指向结尾,每次比较两数之和与 target 的大小关系——大了就右指针左移,小了就左指针右移——这样每个元素最多被访问一次,复杂度直接降到 O(n)。
这个技巧为什么成立?背后的道理是单调性。数组有序之后,左指针右移让和变大,右指针左移让和变小,这种单调的调整方向让搜索路径变成了一条直线,而不是一张网。我看到很多新手理解不了双指针,就是因为他们总是想着“我两个指针到底该移动哪个”,而没有建立“当前状态和 target 比较之后,移动方向是唯一确定的”这个认知。
双指针还有几个变种,刷题时经常会遇到:
- 对撞指针:一个从头,一个从尾,相向而行。典型应用是两数之和已排序版本、回文串判断的数组版、反转数组。
- 快慢指针:一个走得快一个走得慢,典型应用是链表环检测,但数组题里也能见到变体,比如原地删除重复元素时,快指针负责探查,慢指针负责写入。
- 滑动窗口:左右指针同向移动,维护一个“窗口”。典型应用是最大连续子数组、最小覆盖子串这类子数组/子串问题。
这三种变体本质上都是“通过两个指针的不同移动策略,把需要对整个数组重复扫描的暴力解法,压缩成一次或常数次扫描”。
我自己的体会是,凡是题目里出现“连续子数组”“两两配对”“和为目标值”这类字眼,第一反应就应该想到双指针。哪怕最后不是最优解,双指针也往往能提供一个比暴力好得多的保底方案,面试时就算没写出来最优解,能给出一个双指针的 O(n) 或 O(n log n) 思路,也已经比卡死强多了。
1.2 前缀和:把“区域求和”变成一次减法
如果说双指针解决的是“两个点之间的关系”,前缀和解决的就是“一段区间之间的关系”。
前缀和数组的定义很简单:prefix[i] = nums[0] + nums[1] + ... + nums[i]。也就是从开头加到当前位置的累计和。有了这个数组之后,要求区间[l, r]的和,不需要再循环遍历,直接用prefix[r] - prefix[l-1]一步得到。
这个优化在多次查询的场景下非常关键。比如题目要求你多次计算不同区间的和,如果每次遍历,总复杂度是 O(m×n),m 是查询次数,n 是数组长度。用前缀和预处理一遍 O(n),之后每次查询 O(1),总复杂度 O(n+m)。这个提升在数据量大的时候是数量级的差别。
刷初级题的时候,前缀和经常不会单独出现,而是和哈希表组合。比如寻找和为 k 的子数组个数,这是 LeetCode 上非常经典的一道题。暴力解是枚举所有子数组,O(n²)。但如果你在遍历的过程中,边算前缀和边把“这个前缀和出现了多少次”记在哈希表里,那么对于当前位置 i,只要查一下prefix[i] - k在之前出现过几次,就知道有多少个以 i 结尾的子数组和为 k。这又是 O(n) 的解法。
我当时理解这个思路的时候卡了很久,后来找到一个还算直观的解释:a到b的和是 k,等价于prefix[b] - prefix[a-1] = k,移项之后就是prefix[a-1] = prefix[b] - k。所以我们遍历到 b 的时候,只需要问一句:之前有没有出现过prefix[b] - k这个前缀和?出现过几次?这就是哈希表存在的意义——它替你记住了之前所有前缀和的出现次数。
1.3 原地修改:空间复杂度的隐形考点
数组题里很多要求“原地”操作的题目,比如把数组中的零移动到末尾、移除指定元素、删除排序数组中的重复项。这类题的共同点是:要求不额外使用数组空间,只能在原数组上做修改。
这里最常见的思维陷阱是“删除元素就想用新数组存结果”。但原地修改的思路换了一下:我不是真的删除,而是用一个“写入指针”管理“哪些位置才算有效数据”。有效数据往前写,覆盖掉那些不要的元素。
举移除指定元素的例子。一个指针 i 遍历整个数组,另一个指针 index 指向当前“有效数组”的末尾位置。遍历的时候,如果当前元素不是要移除的值,就把它写到 index 位置,index 加一;如果是,直接跳过。遍历结束,index 就是新数组的长度,数组前 index 个位置就是所有有效元素。
这个写法看起来简单,但它背后其实是一种“逻辑删除”的思想——不追求物理删除,只需要让有效数据连续地排在前面,然后用长度标记出有效范围。之后的操作都只认这个范围和标记。
我觉得原地修改类的题目是做“空间复杂度”训练最好的素材。很多新手在写代码的时候,变量用得顺手就多开一个数组、多开一个哈希表,完全不考虑空间。但到了真刀真枪的环境里,内存是有限的,尤其嵌入式、移动端这类场景,一个额外的大数组可能就是致命的。原地操作的训练就是在帮你建立这个意识:能用几个变量解决的,就别开新数组;能在原数组上挪的,就别想着复制一份。
1.4 模拟遍历:初级题里最大的坑
模拟遍历在初级数组题里占比其实不小,比如螺旋矩阵、旋转数组、加一。这类题算法没什么特别高深的技巧,就是“照着题目要求一步一步走”,但恰恰是这种题最容易翻车——因为你对“过程的边界条件”缺乏敏感。
螺旋矩阵就是一个最典型的例子。它的思路一句话就能说完:一圈一圈从外往内打印,每一圈按上、右、下、左的顺序走。但写起来到处是边界:什么时候该停止?遍历到最后一列之后,列坐标要不要回退?只有一行的矩阵,下边那一圈还要不要走?
这类题的正确打开方式,我个人觉得是先写伪代码把“一圈”的过程理清楚,再考虑有多圈,最后用几组极端输入去试:空数组、单元素、单行、单列、矩形。很多边界问题不是想出来的,是试出来的。网上那些“代码看起来很完美但提交就是错”的讨论,十有八九都出在这种模拟题的边界case上。
这里想给大家一个建议:模拟类题目不要追求一遍写对,追求一遍逻辑清晰。把过程拆成分步骤的、可读性强的代码,出了问题能快速定位到哪一步,比那种“全挤在一个循环里、靠几个临时变量硬撑”的写法重要得多。等你把清晰的版本写顺了,再考虑合并步骤优化代码长度。
2. 排序算法不是背代码:选型比实现重要
排序在初级刷题里是个比较微妙的存在。说它不重要,但几乎所有需要有序数组的题都要依赖它;说它重要,可实际刷题时我们又不太会手写排序——调用语言自带的排序函数,一行代码就完事了。所以这里我想谈的既不是每个排序算法的具体代码实现,也不是“背下来八大排序”这种话,而是怎样的排序选型意识能帮你更快更稳地解题。
2.1 语言自带排序函数的正确打开方式
首先说一个足以让你少走很多弯路的事实:刷题和绝大多数工程场景下,你不需要自己写排序算法,语言自带的排序函数已经是被优化到极致的结果。
C++ 的std::sort是内省排序,综合了快速排序、堆排序和插入排序的优点,最坏情况也是 O(n log n);Python 的sorted()和list.sort()是 Timsort,对现实中大量存在的“部分有序”数据有极好的表现;Java 的Arrays.sort()对基本类型用双轴快排,对对象类型用 Timsort。
很多面试准备者会问:既然自带排序这么好,那我还需要学排序算法吗?
我的答案是:排序算法学习的是过程,而不是结果。最有价值的三个收获是——
- 知道什么场景该用什么排序(选型能力)
- 知道排序的稳定性在什么场景下会坑你
- 知道某些题目让你排序其实不是为了排完就完,而是排序为后续操作创造条件
自带排序函数能不能用?能,而且该用就用。面试时你说“我可以调用Arrays.sort()吗”,得到的回答通常是“可以”。但你如果能多解释一句“这里排序以后就可以用双指针把复杂度降下来了”,这个人的水平和只说“我这就排序”的人,在面试官心里的评价是完全不一样的。
2.2 那你还应该懂得哪些排序底层逻辑
不过初级刷题阶段,你还是至少要深入理解三种排序的原理,因为它们是后续很多算法的基础,而不是因为面试会考手写。这三种是:快速排序、归并排序、堆排序。它们在很多非排序题目里会以“思想”的形式出现,你需要认识它们。
- 快速排序的分区思想:快排的核心操作是 partition——选一个基准值,把数组分成“小于等于基准”和“大于等于基准”两部分。这个分区操作单独拎出来,就是快速选择算法的基础,可以用来解决“找第 K 大的数”这类问题,时间复杂度期望 O(n),比“完全排序后取第 K 个”的 O(n log n) 更快。
- 归并排序的分治思想:归并排序把数组一次次对半切开,排序后再合并。这种二分式的分治处理思路,在解决“逆序对数量”“合并 K 个有序数组”等问题时会被直接复用。
- 堆排序的堆思想:堆排序里的堆不是一个具体的代码片段,而是“用完全二叉树的逻辑维护最大/最小值”的数据结构。在很多场景下,你不需要把数组完整排序,只需要知道最大的几个元素是什么,那用堆是更自然的选择。比如找数据流中的中位数、求数组里最大的 K 个数。
所以我一直强调,别去死背排序代码。你要做的是理解三种排序各自的“关键动作”:
| 排序 | 关键动作 | 你能带走的思想 |
|---|---|---|
| 快排 | 分区(partition) | 把某个元素放到它最终的位置上,并能区分左右区间 |
| 归并 | 合并(merge) | 两个有序数组的线性合并,可以边合并边统计信息 |
| 堆排 | 堆化(heapify) | 只维护部分有序结构,就能高效取极值 |
有了这三个“零件”,很多排序相关题目的解法就可以快速拆出来了。
2.3 稳定性的坑:什么时候你不能随手 sort
排序的稳定性是个容易被忽略的细节:稳定的排序能保持相等元素的原始相对顺序,不稳定的排序不能。
就刷题而言,它影响的是那些“先按一个字段排,再按另一个字段排”的场景。如果你要对任务列表先按优先级排序,再按创建时间排序,而第二轮的排序是不稳定的,那创建时间相等的一组任务,内部顺序就乱了,你在第一轮建立的那个顺序就被破坏了。
正确做法有两种:一是用稳定的排序算法去处理第二轮排序;二是直接把两个字段合并成一个排序键,比如用元组(优先级, 创建时间)作为排序依据,一轮排序搞定。第二种做法在实际刷题和工程里都更常用。
这个例子看着小,但它解释了一个规律:写排序代码之前,先想清楚你的排序键是什么,以及相同时保持什么顺序。这是排序题里最容易出错的地方,比忘记写cmp回调函数还要致命。
3. “排序后数组的利用”:三类高频解题套路
在初级题里,排序本身很少是目的,排序之后做什么才是。最常见的三种“排序后利用”分别是:排序+双指针、排序+区间合并、排序+按规则构造。下面挨个说。
3.1 排序 + 双指针:两数之和的通用升级版
如果你遇到一道题目,要求从数组中找两个元素,满足某个条件(和等于目标、面积大于阈值),而且你第一反应是两层循环——这时候试着考虑一下:能不能先排序,再用双指针?
“两数之和”是这类的根本模板。无序版本用哈希表解,O(n);如果你看到的是有序数组版本,双指针能省掉哈希表的额外空间。而它的变形题“三数之和”,本质是固定一个元素,剩下两个元素用双指针去找,总体复杂度从 O(n³) 降到 O(n²)。这个降低是完全靠“排序”铺路的。
三数之和里还有一个去重的细节,也是依赖排序的:排序之后,相同的元素会挨在一起,所以当你在循环里发现“这个元素和前一个元素相同”,就可以直接跳过。没有排序的话,去重就要靠哈希集合,代码会麻烦不少。
所以我建议大家把“排序+双指针”当成一个组合技来记。它适用于:子集构造、配对问题、区间问题,只要是同一个数组里的多个元素需要“彼此找关系”,不妨先排队,再讨论。
3.2 排序 + 区间合并:让重叠关系一目了然
区间合并类题目的标准场景是:给你一堆区间,需要合并所有重叠的区间,或者求不相交区间的最大数量。
这类题不排序的时候,区间之间的重叠关系是乱的,你很难从当前区间确定“哪些区间可能和它重叠”。但一旦按起点排序,所有可能重叠的区间就会连续排列在一起。这时候你只需要从左到右扫一遍,每次判断“下一个区间的起点是否在当前区间终点以内”,是就合并,否则把当前区间收起来,开启新区间。
这个过程的时间复杂度是排序的 O(n log n) 加上扫描的 O(n),可以算是这类题的通用框架。我见过不少人在这个题上纠结“怎么判断重叠”,其实排序之后,重叠的判断就一句话:next.start <= current.end。排序的作用就是把你从“判断所有区间”变成“只判断相邻区间”,问题的维度直接降了一维。
区间合并这个套路还能外延到很多场景——比如会议室安排(求最多同时有几个会议)、服务器资源重叠检查、CPU 任务时间线合并。它们的底层都是一样的:给区间排队,然后线性扫描重叠关系。
3.3 排序 + 按规则构造:字典序、自定义比较器
有些题目表面上和排序没有关系,但它要求你“按某个自定义的规则排列元素”。这时候排序加自定义比较器就成了解题核心。
最典型的一道题是“把数组排成最小的数”:给定一组非负整数,重新排列它们的顺序使之组成一个最小的整数。比如[3, 30, 34, 5, 9],正确的输出是3033459。这个题的关键就是定义一种新的比较规则——两个数字 a 和 b,比较ab和ba哪个拼接结果更小,a 就排在 b 前面。
这种自定义比较器的思路,在涉及“字典序”的题目里特别常见。字典序本身就是一种排序方式,它和自然排序(按数字大小)经常不一样,所以你不能直接套默认排序,必须明确你的排列目标后再设计比较器。
这类的价值在于训练读者的抽象能力:把“排列方案”翻译成“比较规则”,然后把实现交给排序函数。一旦你完成了这个翻译,后面就是调 API 的事了,难度大头在前面。
4. 实战推导:三道典型初级题从读题到落码全流程
这个部分用三道题完整走一遍分析推导的过程,重点不在于背答案,而在于展示一套“从题目文字到最终代码”的思维链。这三道题我故意挑了不同思维模型的:加一(模拟)、移动零(原地修改)、合并区间(排序+区间合并)。
4.1 “加一”:从末位开始,把过程拆成两步
题目是这样的:给定一个由整数组成的非空数组所表示的非负整数,在该数的基础上加一。最高位数字存放在数组首位,数组中每个元素只存储单个数字。比如[1,2,3]表示 123,加一之后是 124,返回[1,2,4]。
这题的坑在于进位。[1,9,9]加一之后不是[1,9,10],因为每个位置只能存单个数字,所以 9+1 = 10,当前位写 0,向前进一位。而如果一直进位到最高位,比如[9,9,9]加一变成[1,0,0,0],数组长度还要增加一位。
我的思考顺序是这样:
第一步:加一这件事,只有最低位受影响。如果最低位加一之后不超过 9,那就直接返回,问题解决。比如[1,2,3],末位 3 变 4,返回[1,2,4]。这个分支覆盖了大多数情况。
第二步:如果末位加一变成了 10,当前位写 0,同时把“进位”传递到下一位。实际上这道题有一个非常简练的循环写法:从末位开始遍历,如果当前位加一后小于 10,直接返回;否则当前位归零,继续向前。如果循环结束都没返回,说明所有位都是 9,那么数组首位变成 1,后面补零,长度加一。
这个写法之所以优雅,是因为它把“加一”的操作和“判断是否继续进位”的操作合并成了同一步。代码只需要几行:
def plusOne(digits): for i in range(len(digits) - 1, -1, -1): if digits[i] < 9: digits[i] += 1 return digits digits[i] = 0 return [1] + digits注意这里的关键判断:digits[i] < 9意味着这个位置加一之后不会产生进位,所以直接改完返回;等于 9 的话,加一变 10,写 0,继续循环。最后一行的[1] + digits处理了全部是 9 的情况,因为循环走完所有位都归零了,前面补一个 1 即可。
这个题本身简单,但它是很好的模拟题入门。它的“过程”极其清楚:从后往前处理,只处理需要进位的部分。很多人一上来就在数组前面插入一个 1,把后面的数字全部重新算一遍,代码写复杂了,边界还有 bug。而循环版本思路清晰,不留死角。
4.2 “移动零”:原地操作的最佳练习
题目:给定一个数组,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。要求在原数组上操作。
这个题的经典思路就是我在 1.3 里讲的“写入指针”模型。一个指针 i 负责遍历,另一个指针 pos 指向“下一个非零元素应该放的位置”。遍历时遇到非零元素就放到 pos,pos 加一;遇到零直接跳过。遍历结束后,pos 之后的所有位置补零。
def moveZeroes(nums): pos = 0 for i in range(len(nums)): if nums[i] != 0: nums[pos] = nums[i] pos += 1 while pos < len(nums): nums[pos] = 0 pos += 1这个写法的正确性基于一个事实:pos 总是小于等于 i。所以把nums[i]写到nums[pos]的时,不会覆盖掉还没遍历到的元素。这个性质是这个解法的灵魂,也是很多人写不出来或者写出来有 bug 的根源——他们没有意识到 pos 和 i 之间的相对关系。
我每次讲这个题都会强调:原地操作的问题,最重要的就是“读写不冲突”的证明。如果你用一个写入指针和一个遍历指针,只要写入指针始终不越过遍历指针,安全就是可以保证的。这个证明虽然简单,但建议自己动手推一遍,以后遇到各种“原地”要求都能先人一步想到这个模型。
有人可能会问,能不能遇到非零元素就和“第一个零的位置”交换?可以,但那样多个额外的判断,还涉及“怎么维护第一个零的位置”,代码就没这么干净。我为啥推荐这个版本?因为它把“挑出有效元素”和“末尾补零”分成两个阶段,每个阶段都是简单重复的操作,不容易出错,也方便把逻辑讲清楚。
顺带一提,如果面试官问“你能让这题的赋值操作次数更少吗”,那可以换成交换的方式。但作为初级题解法,上面的版本已经足够,重点在于思路清晰。
4.3 “合并区间”:从暴力到排序扫描的思维跳跃
题目:给出一组区间,合并所有重叠的区间。区间用[start, end]表示,比如[[1,3],[2,6],[8,10],[15,18]],合并后是[[1,6],[8,10],[15,18]],因为[1,3]和[2,6]重叠。
这题我分两步讲我的思考链路。
第一步:不排序,你能怎么做?我的第一反应是:对每个区间,都去和其他区间比较有没有重叠,有就合并。但问题来了:合并之后的新区间可能又和其他区间重叠了,你得再比一轮。这个过程会反复迭代,而且复杂度很容易失控。这个思路我第一次想的时候就觉得不对劲——合并是一个具有传递性的连锁操作,用暴力方式很难处理这种连锁性。
第二步:排序会带来什么变化?如果把所有区间按起点排序,那么能合并的区间一定是连续相邻的。这是一条非常强的性质。有了它,你只需要线性地从左扫到右,维护一个“当前合并中的区间”,每遇到一个新区间就判断:
- 新区间的起点大于当前区间的终点:没有重叠,把当前区间收进结果,用新区间开启下一轮合并;
- 新区间的起点小于等于当前区间的终点:有重叠,把它们合并。合并后的终点取两者的最大值(因为新区间的终点可能比当前区间的终点更长)。
写成代码就是:
def merge(intervals): intervals.sort(key=lambda x: x[0]) result = [] for interval in intervals: if not result or result[-1][1] < interval[0]: result.append(interval) else: result[-1][1] = max(result[-1][1], interval[1]) return result这里result[-1]就是“当前合并中的区间”,因为它是按顺序处理的,所以它一定是所有已处理区间里最靠右的那个。
这里我想强调一个细节:为什么合并后的终点取最大值而不是直接沿用新的终点或者旧的终点?原因很简单,重叠合并后的区间需要覆盖所有被合并的区间,终点必须是这些区间终点的最大值。很多人写的时候会习惯性写成result[-1][1] = interval[1],这在某些 case 下就会漏掉一部分。比如[[1,4],[2,3]],第二个区间虽然和第一个重叠,但它的终点更小,如果直接把终点改成 3,那就把 4 这个正确的终点弄丢了。这类小细节在排序+扫描的框架里是最容易出的 bug,刷题时宁可多想一步也不要嫌麻烦。
5. 刷题习惯和排错思路:我踩过的那些坑
最后这部分写点实操层面的东西。数组和排序题的坑相对集中,踩过一遍之后能帮你节省大量时间,但如果能在踩坑之前就有意识地避开,更划算。
5.1 边界条件是数组题的第一大敌
数组题里最常见的错误就是下标越界和边界条件没处理干净。尤其是在写循环时,“<=还是<” “len还是len-1”这种细节,几乎决定了你提交是 100% 还是直接 Runtime Error。
我的经验是,不要靠眼睛检查边界,靠极端的用例去测。每次写完数组相关的题,至少用这五组输入自测一遍:
- 空数组(如果题目允许)
- 只有一个元素的数组
- 全部元素相同(比如全是零、全是 9)
- 已经是顺序排列的数组
- 倒序排列的数组
这几组输入覆盖了绝大多数边界情况。比如前面说的“移动零”,如果数组全是零,你的逻辑能不能正确地不做任何交换?如果数组只有一个零和一个非零,pos 的移动是否正确?这些用眼睛看很难看出来,但跑一下立刻见分晓。
还有个不错的小技巧:写 while 循环时,把所有涉及下标增减的地方都单独写在一行,不要跟nums[i+1] = nums[i]--这种揉在一起,既难读又难查。可读性差是 bug 的温床,这点在数组题里尤其明显。
5.2 不要卡在一个思路上:换个角度重新审题
我在数组和排序上见过最多的一种情况,是新手在同一个思路上较劲几小时。比如“两数之和”这道题,有人非要用两层循环优化,而没有转过弯来考虑哈希表。
我的建议是给自己定个规则:如果一种思路写了 30 分钟还没有进展,果断跳出来重新读题。这里的关键不是换一道题,而是换一种角度看待同一道题。数组题的角度很少有唯一解,你能想到的通常不止一种。
角度可以是空间换时间(哈希表)、可以是以排序为预处理、可以是换一个方向遍历(从后往前)、也可以是定义一个新数组来存储信息。当你卡住的时候,主动问自己一句:这道题的操作对象能不能不按原样来?存不存在一种预处理让后续处理变得简单?
这种“主动换角度”的能力,不是天赋,是练出来的。它靠的是你脑子里积累的“题型原型”足够多——看到两数之和能想到哈希表,看到有序数组能想到双指针,看到区间能想到排序。这就是为什么我一直强调要多做归类,而不只是刷题号。
5.3 初级题阶段不建议背题解:把过程和理由写下来
最后想聊一个习惯问题。
我看到很多人刷题会直接看题解,然后照着把代码敲一遍,标记为“已刷”。这种打卡式刷题对自己的提升非常有限。初级阶段的题,一道题的重点往往不是代码本身,而是选择这个思路的理由。
我的建议是,每做完一道题,在笔记里写三行话:
- 这道题我用的核心思路是什么(一句话)
- 为什么这个思路能降低复杂度(因为什么特性)
- 换一个条件我可能用什么备选方案
比如“移动零”的笔记我会写:核心思路是双指针,一个遍历一个写入,利用 pos 不越过 i 的性质保证原地安全;如果不要求保持非零元素相对顺序,可以改成两头交换的双指针法;这题的空间复杂度是 O(1),完全靠原地操作省空间。
写这么三行话花不了两分钟,但它能强制你必须理解这道题,而不是仅仅“见过答案”。坚持下来,你积累的不是题数,而是题型的抽象模板。等到面试场上临场遇到新题,你脑子里能够快速调出的,正是这些模板。
数组和排序是算法刷题的第一块敲门砖,但它敲开的门绝不仅仅是“会做几道题”。双指针的单调性思想、前缀和的预处理思想、原地操作的空间意识、排序后有序性带来的化简能力,这些东西会跟着你走到后面的二叉树、图论、动态规划,甚至走到真实的工程代码里。把这块地基打牢了,后面的大楼才能盖得稳。