1. 这题在 LeetCode 和面试里的位置:一个“简单题”的含金量
1.1 题目原貌与第一眼判断
LeetCode 283 移动零这道题,我每次面试别人都愿意现场手写考一遍,也在各种刷题群的讨论里看到它反复出现。题面很短:给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序,要求原地修改,并且尽量减少操作次数。比如输入[0,1,0,3,12],输出应该是[1,3,12,0,0]。
LeetCode 上它的难度标的是 Easy,但在热门 100 题清单里常年挂名,面试出现频率也高得离谱。我见过不少候选人在这题上翻车:有人直接开新数组,把非零元素按顺序拷贝进去再填零,虽然能跑通用例,但一问“原地”两个字就露馅;有人用稳定排序把零排到后面,思路没错但复杂度不理想;还有人记得要用双指针,却讲不清楚两个指针各自维护的是什么,代码写出来也是磕磕绊绊。能和我说清楚“慢指针指向的是当前已经整理好的区间末尾”“快指针负责探索未知区域”这两个点的候选人,一只手数得过来。
这题之所以值得反复琢磨,是因为它几乎把数组类题目的基本功全部串起来了:循环边界的把握、原地修改的意识、对“相对顺序”的理解、以及能否把一次遍历和两次遍历的差别讲明白。对用 Golang 刷题的人来说,它还额外牵扯到切片是引用类型、函数内修改切片底层数组是否影响外部变量、range 循环里取到的是元素副本这类语言层面的细节。所以别看它简单,它能挖的点一点都不少。
1.2 为什么选择用 Golang 来刷这题
如果你正在学 Go,又想保持刷题手感,283 是个非常好的起点。Go 语法的收敛度很高,没有继承、没有泛型层面的复杂约束,写算法题时不会把时间浪费在语言特性上。切片在函数间传递时,底层数组是共享的,这意味着你不需要像 C++ 那样传指针,也不像 Python 那样纠结返回值,直接修改切片内容,调用方就能观察到变化。这个特性对“原地修改数组”这一类题目特别友好。
另外,Go 自带的go test和基准测试工具在刷题时非常好用。LeetCode 页面上的在线编辑环境虽然能跑测试,但调试能力有限;我在本地一般会建一个目录,每个题一个_test.go文件,写完直接go test -v跑所有自定义用例,比在网页上一次一次提交高效太多。环境装好之后,刷题对我来说就变成了一件很顺手的事:打开终端,建文件,写实现,跑表驱动测试,全绿了再粘到提交框里。
很多刚接触 Go 的人会纠结该用哪个版本。我的建议是:安装当前官方仍在维护的稳定版,不要追新也不要停在太老的版本,LeetCode 评测机的 Go 版本会定期更新,本地版本太老可能会踩到语法不支持的问题。Windows 上装 Go 也没那么复杂,把GOPATH或者现在的模块缓存路径配好,避免放在带中文的路径下,基本不会有什么幺蛾子。
2. 从暴力解到双指针:一条完整的推导路径
2.1 最无脑的解法:新数组拷贝
先来聊聊正常人看到这道题的第一反应。把零移到末尾,那我不如重新开一个数组,遍历原数组,遇到非零就放进新数组,最后再把新数组剩余的位置填上零,完事。这个思路在逻辑上是完全正确的,代码也极其简单。
func moveZeroesWithCopy(nums []int) []int { n := len(nums) res := make([]int, n) idx := 0 for _, v := range nums { if v != 0 { res[idx] = v idx++ } } return res }这里我故意写成返回一个新切片。但题目明确要求原地修改,也就是不能额外开辟和原数组等长的空间,所以在 LeetCode 上这个解法是不合规的。不过别急着否定它,这个思路里藏着一个重要的结构:非零元素保持原有的相对顺序,一个一个往前摆放。任何后续的优化,本质上都在做同一件事——把非零元素按顺序挪到前面,只是挪的方式和存放位置不同罢了。
我为什么还是建议你把这段代码写一遍?因为它是后面所有递进分析的地基。如果连“非零元素保持相对顺序”这个要求都没想到,后续的优化方向根本无从谈起。写完之后,你可以问自己三个问题:这段代码的时间复杂度是多少?空间复杂度是多少?如果强制要求不能返回新数组,我应该在哪一步做修改?这三个问题问完,双指针的推导就顺理成章了。
2.2 第一版可提交的原地解法:计数后覆盖
常见的可提交思路之一,是先统计出数组里有多少个零,然后做一次遍历把非零元素集中到前面,最后从后面补零。这个过程只需要 O(1) 的额外空间,满足“原地修改”的要求。
func moveZeroes(nums []int) { n := len(nums) zeroCount := 0 for _, v := range nums { if v == 0 { zeroCount++ } } idx := 0 for _, v := range nums { if v != 0 { nums[idx] = v idx++ } } for i := n - zeroCount; i < n; i++ { nums[i] = 0 } }这个解法的时间复杂度是 O(n),需要扫描数组两遍,额外空间是 O(1)。它的思路非常直白:先把非零元素全部“压实”到前面,idx记录的就是非零元素的总数,那么剩下的位置有多少个,自然就是零的数量,把它们挨个填成 0 就行。
这段代码能通过测试,但有一个地方值得琢磨:我真的需要提前统计零的数量吗?看我写的第三段循环,其实只依赖idx的值,而idx在第二段循环结束后就已经等于非零元素的个数了。既然如此,统计零的那次遍历就显得有些多余。能不能在一次遍历里同时完成“找非零”和“确定位置”这两件事?这就是双指针出现的原因。
2.3 双指针解法是怎么被“逼”出来的
把计数法的两次遍历压缩成一次,关键在于看穿一个事实:非零元素应该放在哪个位置,只取决于已经找到了多少个非零元素,和后面还剩多少个零没有关系。
这时候可以让两个指针分工:快指针fast负责从头到尾扫描整个数组,它的职责是寻找非零元素;慢指针slow负责记录下一个非零元素应该存放的位置。每当fast找到一个非零元素,就把它写到slow指向的位置,然后slow向右移动一格。等到fast扫描完整个数组,所有非零元素就已经按原有顺序排列在数组前部,剩下的位置补零即可。
如果不用生活化的比喻,这个流程听起来有点抽象。可以这么理解:你面前有一条传送带,上面混着零和其他物品,你的任务是把物品按顺序码放到货架上,货架位置有限。快指针是传送带的读取头,扫到物品就拿出来;慢指针是你手里的“下一个空货架”的标签。每次放下一件物品,标签就往后贴一格。扫描结束,货架前半段按顺序摆满了物品,后半段空位放上空箱子,也就是零。
后面要写的三种解法,不管代码长什么样,核心都是这个“快慢指针”的骨架。理解到这一层,你在面试里被问到“为什么双指针能保持相对顺序”时,就不会卡壳:因为所有非零元素都是按fast扫描到的先后顺序被放到slow位置上的,扫描顺序天然保持了它们在原数组中的相对次序。
3. Golang 实现细节:切片、指针与那些容易翻车的角落
3.1 非零元素前移法:最容易被接受的写法
理解了上面的推导之后,最自然的 Go 实现就是“非零元素前移 + 末尾补零”。我平时在本地和提交里用的主要是这个版本,因为它的意图最清晰,别人 review 代码时一眼就能看懂。
func moveZeroes(nums []int) { n := len(nums) j := 0 for i := 0; i < n; i++ { if nums[i] != 0 { nums[j] = nums[i] j++ } } for i := j; i < n; i++ { nums[i] = 0 } }运行流程用示例[0,1,0,3,12]走一遍:i=0时遇到 0,跳过;i=1时遇到 1,nums[0]=1,j变 1;i=2遇到 0;i=3遇到 3,nums[1]=3,j变 2;i=4遇到 12,nums[2]=12,j变 3。扫描结束后,前三个位置依次是 1、3、12,最后把位置 3 和 4 填成 0,得到[1,3,12,0,0]。
这个写法有一个小细节容易被忽略:当j和i指向同一个位置时,nums[j] = nums[i]是一次自我赋值,没有实际作用。比如数组里根本没有 0 时,每个元素都会做一次多余的赋值操作。不过在刷题场景下,这个多余的赋值不算什么问题,时间复杂度的量级没有变化。但如果你在面试里被追问“能不能减少操作次数”,这就是一个可以拿出来讲的优化点。
3.2 交换法:代码最短但需要讲明白“为什么对”
另一种常见写法是交换法,它的代码更短,也更有“算法感”:
func moveZeroes(nums []int) { slow := 0 for fast := 0; fast < len(nums); fast++ { if nums[fast] != 0 { nums[slow], nums[fast] = nums[fast], nums[slow] slow++ } } }用一个例子模拟一下:数组是[0,1,0,3,12]。初始slow=0。fast=0时元素是 0,跳过。fast=1时元素是 1,交换nums[0]和nums[1],数组变成[1,0,0,3,12],slow变 1。fast=2遇到 0。fast=3遇到 3,交换nums[1]和nums[3],数组变成[1,3,0,0,12],slow变 2。fast=4遇到 12,交换nums[2]和nums[4],得到[1,3,12,0,0]。
为什么这样交换不会破坏非零元素的相对顺序?关键在于slow指针的语义。它在任何时候都指向“当前最靠左的那个零”,更准确地说,它指向的是已经整理好的非零区间的下一个位置。如果fast扫过的路径上出现过零,slow就会停在第一个零的位置;当后面来一个非零元素,把它和这个零交换,零被换到后面,非零元素则放到了正确的位置。由于每次交换都是把当前fast指向的非零元素往前挪,因此非零元素之间的相对顺序是不会乱的。
交换法也有一个可以优化的点:如果数组本身就是全非零,比如[1,2,3],每个元素都会和自己交换一次,纯属浪费。加上一个判断就能避免:
func moveZeroes(nums []int) { slow := 0 for fast := 0; fast < len(nums); fast++ { if nums[fast] != 0 { if slow != fast { nums[slow], nums[fast] = nums[fast], nums[slow] } slow++ } } }不过说实话,这个优化对 LeetCode 的判题性能影响非常微小,更大的意义在于体现你对“操作次数”这个要求的理解。面试中能主动说出这一点,绝对是加分项。
3.3 range 循环与索引陷阱
用 Go 刷题,最容易翻车的不是算法本身,而是语言细节。这里我专门讲一个坑:range 循环中的值变量是副本。
很多新手会写出这样的代码:
for _, v := range nums { if v != 0 { v = 0 // 试图把非零元素清零?这是无效的 } }原因在于v是切片中元素的拷贝,你对它赋值不会影响原切片。这也是为什么我在这道题里更推荐使用传统的索引循环for i := 0; i < len(nums); i++,或者使用for i := range nums。不是说 range 不能用,而是你必须清楚:range拿到的值只能用于判断,不能用来直接修改原数组;想修改就必须通过索引nums[i]。
另一个 Go 刷题特有的问题是切片作为函数参数时的表现。LeetCode 给的方法签名是func moveZeroes(nums []int),没有返回值。有人会疑惑:函数里修改nums,外面的数组真的会变吗?答案是会,但只限于修改“切片底层数组元素”这个层面。切片本身是一个包含指向底层数组的指针、长度、容量的结构体,函数内可以修改nums[i]影响底层数组;但如果你在函数里执行nums = append(nums, 100),这个操作可能改变切片头的长度甚至底层数组指针,外部变量却感知不到。所以在刷这种要求原地修改的题目时,老老实实通过索引修改元素,不要依赖append来改变切片长度,这是从无数报错里总结出来的经验。
4. 边界情况、性能表现与 LeetCode 判题机制
4.1 容易漏掉的一批边界输入
LeetCode 的用例通常不会太刁难人,但你自己写测试时不能只盯着示例。就这道题而言,我强烈建议把下面这组输入全部跑一遍:
| 输入 | 期望输出 | 需要验证的点 |
|---|---|---|
[] | [] | 空数组不能 panic |
[0] | [0] | 只有一个零 |
[1] | [1] | 只有一个非零元素 |
[0,0,0] | [0,0,0] | 全零数组 |
[1,2,3] | [1,2,3] | 全非零数组,确保顺序不变 |
[0,0,1] | [1,0,0] | 多个零在开头 |
[1,0,0,0] | [1,0,0,0] | 多个零在结尾 |
[0,1,0,2,0,3] | [1,2,3,0,0,0] | 零与非零交替出现 |
为什么要专门提这些边界?因为它们分别对应了代码里的不同风险点。空数组考验你有没有在开头加多余的特殊判断,其实不加也能过,因为循环天然不会执行;全零数组考验补零逻辑会不会越界;全非零数组考验会不会因为交换逻辑导致元素丢失。用 Go 写测试时,最好用表驱动方式把这些用例全部覆盖,跑完所有用例再提交,能省去很多网页端试错的来回。
4.2 时间和空间复杂度的严谨分析
这一节是面试里一定会被问到的部分,我整理成一张表,方便对比:
| 解法 | 时间复杂度 | 空间复杂度 | 是否符合原地要求 |
|---|---|---|---|
| 新数组拷贝 | O(n) | O(n) | 否 |
| 计数后覆盖 | O(n) | O(1) | 是 |
| 非零元素前移法 | O(n) | O(1) | 是 |
| 双指针交换法 | O(n) | O(1) | 是 |
时间上,几个解法都是 O(n),但常数因子不同。计数后覆盖和非零前移法都是两遍遍历,双指针交换法只有一遍遍历。空间上,所有原地解法都只用了常数级别的额外变量。这里有个很容易被忽略的点:Go 中的nums[i], nums[j] = nums[j], nums[i]交换是 O(1) 的操作,不会因为元素是 int 类型而多消耗额外空间,所以整个算法可以放心声称是 O(1) 额外空间。
如果你面试时被问到“双指针交换法的最坏情况交换次数”,可以这样回答:假设数组前 k 个位置全是 0,后面有 m 个非零元素,那么每个非零元素都可能触发一次交换,最多交换 m 次,m ≤ n,所以整体仍然是 O(n)。而前移法的最坏情况是每个元素都做一次搬移,同样 O(n)。两者在大 O 层面没有区别,但在常数上有微小差异,这也是面试官可能继续追问的方向。
4.3 针对“尽量减少操作次数”的进一步优化
题目描述里有一句话“尽量减少操作次数”,很多同学会把它理解成“必须用最少的遍历次数”,其实更准确的解读是“不要做无意义的工作”。比如用 sort 稳定排序,理论上能过,但复杂度是 O(n log n),对这道题来说就是浪费;又比如每次都把非零元素后面的所有元素整体往后挪,复杂度会退化到 O(n²),那显然也不是题目的本意。
在双指针交换法的基础上,加一个slow != fast的保护能减少无零数组上的自我赋值;在前移法的基础上,你可以在循环里判断if i != j再赋值,同样能减少部分自搬移。这些优化的收益虽然小,但能提现你对“操作次数”的敏感度。
还有一个更偏理论层面的点:如果允许打乱非零元素的相对顺序,可以用类似快速排序分区的方式,从数组头和尾同时出发交换,那样在零元素很少的场景下能进一步减少交换次数。但题目明确要求保持相对顺序,所以快慢指针才是正解。类似的分区思想在后面的 75 题“颜色分类”里还会再遇到,到时候这个基础就打好了。
5. 从 283 出发:一组值得顺带刷掉的同源题目
5.1 LeetCode 27 移除元素:模板题的源头
LeetCode 27 是“移除元素”:给定一个数组 nums 和一个值 val,要求原地移除所有数值等于 val 的元素,返回移除后数组的新长度。写完 283 再看这题,会发现它们的骨架几乎一模一样:一个快指针负责遍历,一个慢指针负责记录新数组的末尾。
func removeElement(nums []int, val int) int { slow := 0 for fast := 0; fast < len(nums); fast++ { if nums[fast] != val { nums[slow] = nums[fast] slow++ } } return slow }回到 283,其实它可以被看作 27 的一个特例:先调用removeElement移除所有的 0,得到非零元素的数量,然后从那个位置开始把剩余元素填成 0。理解了这一点,283 的覆盖法就不是需要死记的代码,而是 27 的自然延伸。所以我常说,别孤立地刷题,很多题是同一套模板换了个壳。
5.2 LeetCode 26 删除排序数组中的重复项:同样是快慢指针
LeetCode 26 是“删除排序数组中的重复项”:给定一个已排序数组,原地删除重复元素,使每个元素只出现一次,返回新长度。这题的快慢指针思路也是一脉相承,只是判断条件变成了当前元素与已保留区间中最后一个元素是否相同。
func removeDuplicates(nums []int) int { if len(nums) == 0 { return 0 } slow := 1 for fast := 1; fast < len(nums); fast++ { if nums[fast] != nums[slow-1] { nums[slow] = nums[fast] slow++ } } return slow }这里的slow从 1 开始,是因为第一个元素一定被保留;fast从 1 开始扫描,遇到和已保留区间末尾不同的元素,就把它搬到slow位置。如果把这三道题放在一起对比着刷,你会发现它们全都是“快指针读,慢指针写”的同一个故事,只是判断条件不同,边界细节略有差异。这是刷数组类题最划算的一种做法:一题三吃,把模板固化到肌肉记忆里。
5.3 面试中的组合拳:283 常被怎么追问
我在面试环节比较喜欢在候选人写完 283 后再追加几个问题,考察他是不是真的理解了。一个常见的追问是:“如果我不想保持非零元素的相对顺序,能不能用更少的交换次数完成?”这就是快速排序分区思路的变体,双指针从两端夹逼,把零往右赶,非零往左赶,交换次数有可能更少,但会破坏相对顺序。
另一个追问是:“如果数组里不只是 0,而是要把所有等于某个 target 的元素移到末尾,代码怎么改?”这其实就是把nums[fast] != 0换成nums[fast] != target,一行的事。如果面试官再进一步,问“分成奇数和偶数两组,奇数在前偶数在后,同时要求保持各自相对顺序”,那就是稳定的 partition 问题,数组在 O(1) 额外空间下几乎无法同时做到稳定和线性时间,这时候反而需要往链表或者额外数组方向想。
我见过不少候选人,算法题刷了上百道,但被追问一次就露馅,原因就是只会背代码,没想清楚指针语义。283 这样的小题反而是检验“真懂还是假懂”的好工具,因为它足够简单,简单到无法靠复杂的技巧掩盖理解的空缺。
6. 我常用的一套 Go 刷题工作流与习惯
6.1 本地测试模板与基准测试
刷题时我很少直接在网页上写,而是在本地建一个小工程,用 Go 自带的测试框架跑用例。以 283 为例,我的目录结构大概是这样的:main.go里放解法,main_test.go里放测试用例。测试文件用表驱动的方式写,既方便阅读又方便扩展。
package main import ( "reflect" "testing" ) func TestMoveZeroes(t *testing.T) { tests := []struct { name string nums []int want []int }{ {"示例", []int{0, 1, 0, 3, 12}, []int{1, 3, 12, 0, 0}}, {"空数组", []int{}, []int{}}, {"全零", []int{0, 0, 0}, []int{0, 0, 0}}, {"无零", []int{1, 2, 3}, []int{1, 2, 3}}, {"交替", []int{0, 1, 0, 2, 0, 3}, []int{1, 2, 3, 0, 0, 0}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { moveZeroes(tt.nums) if !reflect.DeepEqual(tt.nums, tt.want) { t.Fatalf("moveZeroes() 得到 %v, 期望 %v", tt.nums, tt.want) } }) } }这里有一点要注意:由于切片是引用类型,moveZeroes(tt.nums)会直接修改tt.nums的底层数组,所以在断言里比较的是修改后的tt.nums和期望值tt.want。每个测试用例结构体里的nums是独立切片,用例之间互不影响,表驱动的方式天然避免了数据污染。
如果你想进一步验证两种写法的性能差异,可以写一个基准测试:
func BenchmarkMoveZeroes(b *testing.B) { nums := []int{0, 1, 0, 3, 12, 0, 0, 2, 4, 0, 6, 8} for i := 0; i < b.N; i++ { tmp := make([]int, len(nums)) copy(tmp, nums) moveZeroes(tmp) } }基准测试里我每次重新拷贝一份数据,是为了避免第一次调用把数组改好之后,后续基准测试直接拿排好序的数组跑,导致结果失真。这个细节我一开始也忽略过,后来发现输出结果异常,才意识到基准测试要保证输入的一致性。
6.2 提交前必跑的几个用例
坚持在本地测完再提交,能省很多时间。除了前面边界表里列出的那几类,我还会留一组“压测数据”,比如长度比较大的数组,以及在末尾补一批随机数,观察算法会不会出现意外的越界。Go 的切片访问越界会直接 panic,一旦出现,说明循环边界有 bug,这类问题在 LeetCode 网页上也能测出来,但本地 panic 的提示信息更直观,定位更快。
提交之前,我习惯把代码再读一遍,重点检查两件事:第一,是否用索引访问切片而不是依赖 range 的值变量去做修改;第二,是否需要返回值,283 的方法签名是func moveZeroes(nums []int),没有返回值,如果你写成了返回切片,LeetCode 会报编译错。这类签名问题看起来低级,但每次周赛都有人因为函数签名不匹配被判错,提醒自己多看一眼准没错。
6.3 关于刷题这件事的一点个人体会
刷题刷到后面,我越来越觉得真正重要的不是记住某道题的答案,而是形成一套稳定的分析流程:先明确题目的约束条件,再想清楚能不能用暴力解打底,然后在暴力解的基础上找出冗余操作,最后用指针或者更高效的数据结构消掉这些冗余。283 就是这条流程的绝佳训练样本:从新数组拷贝到计数法,再从计数法到双指针,每一步的优化动机都非常清晰,不存在“为什么突然想到双指针”的跳跃感。
我每次面试考这道题,都会在候选人用 Golang 写出最终版本后,追问一句:“你的慢指针指向的到底是什么?”如果能回答“指向的是当前已整理区间的末尾”,我就知道这个人对这道题是真的理解了。如果你读到这篇文章,也建议你合上代码,自己把这句话讲一遍:快慢指针各自维护的语义是什么,为什么交换不会破坏相对顺序,为什么空间复杂度是 O(1)。能把这三个问题讲清楚,283 就算真正吃透了,再去刷 27、26、75 这些同源题目,会轻松很多。