朋友前几天约我聊聊换工作的事聊到算法准备的时候他特意点了一道题LeetCode 283移动零。他说自己一眼看过去觉得简单到离谱不就是把数组里的 0 挪到尾巴上嘛结果一写代码就露怯——要么非零元素的顺序乱了要么越界要么总有几个用例跑不过。我听完一点不意外因为这道题在算法面试里的出镜率高得吓人从应届生到社招后端、客户端、测试开发几乎人人都可能被问过。它看起来是幼儿园题实际上考的是你对数组操作、双指针和原地修改这三件事的掌握程度。这篇东西不是给你背答案用的而是把这道题从读题、推导、写码、测试到面试追问的完整链路拆开揉碎讲一遍。无论你是刚准备刷 LeetCode 的零基础选手还是已经刷了百来题想查漏补缺的进阶选手都可以对照着看一看。尤其是最后面试官视角那一节很多你觉得“这么简单有什么好问”的点恰恰是面试官最想听你展开的地方。1. 题目到底在考什么先把“把零移到后面”这句人话翻译成技术需求1.1 原题描述与三个容易忽略的约束题目原文很短给定一个数组 nums编写一个函数将所有 0 移动到数组的末尾同时保持非零元素的相对顺序。这题在 LeetCode 上标的是 Easy大多数人的第一反应就是“新建一个数组把非零元素依次塞进去后面补零”但题目后面还有两行容易被忽略的补充说明必须在原数组上操作不能拷贝额外的数组尽量减少操作次数。这两条约束才是这题真正的门槛。“不能拷贝额外数组”把你最舒服的写法直接堵死了“尽量减少操作次数”意味着你连那种“先扫一遍数出零的个数再重新填一遍”的写法也可以再优化。说白了这道题考的不是你会不会遍历数组而是你在限制条件下还愿不愿意多琢磨更省的做法。我拿书柜举个例子。你面前是一排书里面夹着几本占位的空盒子当作是零要求把空盒子全部挪到最右边同时书与书的相对顺序不能变而且不许把这排书搬到另一张桌子上重新排。在桌上腾挪一下当然可以做但如果你能在这排书架内就地完成效率就完全不同。这道题就是让你在数组这个“书架”上完成同样的事。先想清楚这个场景再去看各种题解思路就顺多了。1.2 边界条件空数组、全零、零与非零交错这题挂人最容易挂在边界。我把常见情况列出来过一遍空数组函数应该什么都不做返回空数组。不少人写代码时没处理这种情况其实只要循环写得对空数组天然安全。只有一个元素如果它不是 0 就不用动如果是 0 也不用动。这个场景测试的是你有没有多余的赋值或交换逻辑。全是 0例如 [0,0,0]期望输出还是 [0,0,0]。有些解法在“补零”阶段会把已经放好位置的非零元素覆盖掉好在全 0 数组恰好不容易出问题但万一代码里出现了基于索引的固定假设就容易翻车。没有 0[1,2,3,4] 应该原样返回。这里考察的是你的交换逻辑是否在“不需要交换”时做了无效操作。零在开头、零在结尾、零分布在中间分别要确保零确实被整体搬到了末尾不残留一个在中间。我见过不少人把代码写得看起来完全正确一提交全绿但单独把 [0,0,1] 抽出来跑就懵了怎么不是 [1,0,0]其实就是细节没抠到位。所以我在做这类题的时候习惯先把六类边界用例写在纸上再开始写代码这样能少走很多回头路。题解可以背但边界意识得靠自己在每个用例上磨出来。2. 两条典型解法路线为什么推荐你优先掌握双指针2.1 路线一非零元素先排队最后统一补零先看最直观的解法维护一个指针 pos初始指向数组最左边遍历整个数组只要遇到非零元素就把它赋值到 nums[pos]然后 pos 加一遍历完之后pos 右边的位置全部赋值为 0。用 Python 写大概是这样def move_zeroes(nums): pos 0 for i in range(len(nums)): if nums[i] ! 0: nums[pos] nums[i] pos 1 for i in range(pos, len(nums)): nums[i] 0这个思路的本质是“分类整理”所有非零元素像排队一样按原来的顺序依次站到数组前面等它们都站好了剩下的空位全部填零。它的优点是逻辑非常朴素几乎不会写错时间复杂度是 O(n)空间复杂度是 O(1)已经满足题目的核心要求。但站在“尽量减少操作次数”这个角度它有冗余只要数组里存在零无论这些零原本是不是已经在末尾最后都要再遍历一遍去置零。比如输入 [1,2,3,0,0]第一遍把 1、2、3 搬到前面第二遍又去把索引 3、4 置零但这两个位置本来就是 0纯属重复劳动。如果面试官后续问你“能不能再优化”你就得往第二种方案上引了。2.2 路线二一次遍历原地交换零元素自然“滚”到末尾更优的解法是交换法。定义两个指针left 指向“当前已整理好的区间末尾”也就是下一个非零元素应该放的位置right 负责向前遍历。当 right 遇到非零元素时把 nums[left] 和 nums[right] 交换然后 left 也往前走一步如果 right 遇到的是 0就继续往前走left 不动。def move_zeroes(nums): left 0 for right in range(len(nums)): if nums[right] ! 0: nums[left], nums[right] nums[right], nums[left] left 1你可以在纸上手动跑一遍 [0,1,0,3,12]right0元素是 0不处理left 停在 0。right1元素是 1交换 nums[0] 与 nums[1]数组变成 [1,0,0,3,12]left 变 1。right2元素是 0不处理。right3元素是 3交换 nums[1] 与 nums[3]数组变成 [1,3,0,0,12]left 变 2。right4元素是 12交换 nums[2] 与 nums[4]数组变成 [1,3,12,0,0]left 变 3。最后非零元素全部按原顺序排在最前面零元素被“挤”到尾部而且整个过程中数组始终是在原地改动没有额外开空间。更妙的是它把置零操作合并进了交换里每个元素最多被移动一次从操作次数上看确实更贴合题目的“尽量减少操作次数”这句要求。2.3 两种方案的本质区别与选型逻辑其实两种方案的内核都是“把非零元素往前放”区别在于对零的处理时机第一种是最后统一补零第二种是遇到一次零就通过交换把它往右推一次。从结果看两者都能通过但面试时我更建议直接写交换法因为它把两个阶段合并成了一个循环代码更短、边界更少、操作次数也更好看。从工程角度看如果数组元素不是简单整数而是某个结构体、对象第一种方案会额外产生大量赋值操作尤其是在非零元素很多、零很少的情况下等于把每个非零元素都复制了一遍。交换法则只在“该动的地方”动能用指针交换解决的问题就不必做无意义的内存赋值。这也解释了为什么很多底层排序、分区算法里原地交换比复制赋值更受欢迎。当然第一种方案也不是一无是处它的可读性确实更高适合在不需要追求极致性能的场景里使用但这题既然明确要求“尽量减少操作次数”交换法才是标准答案。3. 手把手从零写代码循环不变量与边界用例是命门3.1 循环不变量与指针动向你要真搞懂写交换法的时候最怕的就是“照抄代码但不懂指针为什么这样动”。面试官只要追问一句“你的 left 和 right 分别代表什么”很多人就卡住了。这里我把循环不变量讲清楚在整个循环过程中nums[0:left] 这个区间内全都是按原顺序排列的非零元素nums[left:right] 这个区间内全都是已经扫描过的零nums[right:] 是尚未扫描的区域。right 每向右移动一格扫描区域就缩小一格而 left 指向的位置始终是下一个非零元素的正确归宿。这个不变量的价值在于它能让你很容易地证明代码的正确性也能帮你发现 bug。比如你写的过程中发现 left 不小心在遇到 0 时也跟着移动了那零就可能跑到非零区间里去破坏不变量发现这种情况马上就能意识到有问题。这就是为什么我一直强调刷这类基础题不要只会背代码能把循环不变量用一句话说清楚才是真正理解了这道题。再补一个很多人没注意到的细节如果数组里没有零交换法会对每个元素做一次“自我交换”也就是 left 和 right 指向同一个位置时把元素和自己交换一遍。这不会改变结果但属于无意义的操作。想看代码更利落的话可以在交换前加一句 if left ! right不过在 LeetCode 上跑下来差距微乎其微属于锦上添花不是关键优化点。3.2 一套齐活的测试用例清单光说不练不行。这里给出一组我实际在本地跑过的测试用例你们可以直接复制去验证输入数组期望输出主要验证点[][]空数组不崩溃[0][0]单元素零数组[1][1]单元素非零数组[0,0,0][0,0,0]全零数组不越界不重复交换[1,2,3,4][1,2,3,4]无零数组应完全不变[0,1,0,3,12][1,3,12,0,0]官方示例零交错分布[1,0,0,1][1,1,0,0]连续零出现在中间[0,0,1][1,0,0]零集中在头部[1,0][1,0]零在尾部已经是目标状态[4,2,4,0,0,3,0,5,1,0][4,2,4,3,5,1,0,0,0,0]较长随机数组覆盖压力场景我最想提醒的是 [1,0] 这个用例。很多人写完交换法自己脑内跑一遍觉得没问题但用 [1,0] 去测就会发现right0 时遇到非零 1left0交换后还是一样的right1 时遇到 0不处理。最后结果是 [1,0]而正确结果就是 [1,0]。这个用例看起来平平无奇实际却能暴露出“要不要在遇到 0 时也交换一次”这类错误实现。另一个值得注意的用例是 [0,0,1]。有问题的实现可能把它变成 [1,0,0] 后又变成 [0,1,0]这是因为 left 的前进逻辑和 right 的同步关系没有理清。多跑几个这种刁钻输入比自己对着代码干想高效得多。3.3 复杂度分析的几个细节时间复杂度是 O(n)因为 right 指针只会从左到右移动一遍每个元素最多被访问一次交换操作最多发生 n 次每次是常数时间。空间复杂度是 O(1)因为没有使用任何与 n 相关的辅助存储只有两个指针变量。这里要特别注意Python 里nums[left], nums[right] nums[right], nums[left]这种写法虽然看起来像是创建了临时变量但底层是栈上的临时引用不随 n 变化所以依然算 O(1)。如果你拿交换法和第一种“先搬再补零”对比赋值次数在随机数组里交换法大约有 n 次交换每个非零元素最多参与一次而第一种方法里每个非零元素都会有一次“搬运赋值”加一次末尾补零某些情况下赋值次数几乎翻倍。虽然复杂度级别一样但面试官问到“为什么说交换法操作次数更少”时你要能给出这样的对比而不只是笼统地说“感觉快一点”。4. 面试官真正想看的东西考点拆解与题目变形4.1 考点一in-place 操作意识面试官为什么会用一个 Easy 题来开场很大概率是为了先确认你有没有“原地修改”的意识。很多候选人一上来就说“我创建一个新数组把非零元素填进去”这本身不是错但在内存敏感的工程场景里这种默认复制一份数据的习惯是需要警惕的。in-place 意味着你需要在有限的额外空间下完成操作。它考察的是你能不能把“最终状态”拆解成“一系列就地转换步骤”而不是把所有转换都寄托在一份新数据上。这个能力在操作系统进程调度、嵌入式设备内存管理、大数据集清洗等场景中都非常重要。如果你在面试里不仅写出了交换法还能顺手说出“这个思路本质就是通过两个指针维护一个已整理区间”那面试官对你这部分的印象分就会完全不同。4.2 考点二相对顺序的保持——稳定性思想第二个隐藏考点是非零元素的相对顺序。这个点很容易被忽视但它其实是排序算法里“稳定性”概念的雏形。所谓稳定排序就是值相同的元素在排序前后保持原有相对顺序。移动零这题里非零元素之间的相对顺序不能乱本质上就是在要求一种“稳定分区”。为什么稳定这么重要举个实际的例子数据库里有个表先按姓名排好了序现在要按城市再分类一次如果分类算法不稳定第一轮排序建立的信息就全白费了。稳定分区在基数排序、机器学习特征分箱等场景中也是核心操作。所以当你写出交换法时如果你能主动向面试官说明“因为只有当 right 遇到非零元素并且把它和 left 交换时left 指向的要么是零、要么和 right 重合所以非零元素之间的相对顺序不会改变”这一句话就能让对方意识到你不是在背答案。4.3 变形题一把任意指定元素移动到末尾移动零只是移动“指定元素”的特例。把题目改成“给定一个整数 val将数组中所有等于 val 的元素移动到末尾并保持其余元素的相对顺序”解法几乎不用变def move_value_to_end(nums, val): left 0 for right in range(len(nums)): if nums[right] ! val: nums[left], nums[right] nums[right], nums[left] left 1整个过程就是把判断条件nums[right] ! 0换成nums[right] ! val。你可以用这道变形题检验自己是不是真的理解了交换法如果只是背代码改一个条件后大概率会出问题如果理解了“left 始终指向下一个非 val 元素应当插入的位置”那么这题就只是改一行而已。4.4 变形题二奇偶分离、颜色分类与三指针比移动指定元素再进阶一步的是“奇偶分离”把数组里的奇数放到前面偶数放到后面同时保持相对顺序。这个和移动零几乎一样只是把“非零”换成了“奇数”。但如果题目不要求保持相对顺序那就可以用首尾双指针的写法头指针找偶数尾指针找奇数找到就交换两边往中间靠这种写法在“只要求分离、不要求稳定”的场景下更快。再往后就是 LeetCode 75 的颜色分类数组中只有 0、1、2 三种值要求原地排序成 [0,0,...,1,1,...,2,2,...]这题就需要三指针维护“0 区末尾、1 区中间、2 区开头”三个边界。移动零的双指针思想就是它的精简版所以很多面试官会把 283 当作 75 题的敲门砖看你是否具备从两路分区推广到三路分区的潜力。最后说点我自己的经验。这种标着 Easy 的题刷一遍会了之后特别容易让人飘飘然但真正到面试场上最容易出问题的恰恰就是它们。我建议你把这道题当成“算法基本功测试”来做不看题解自己推一遍交换法的过程再把 4.3 的变形自己写一遍甚至可以去搜一搜 75 题看看三指针是怎么从两指针长出来的。整个过程可能也就多花一个小时但收获比盲目刷十道新题要大得多。等你在白板上能把自己的循环不变量讲清楚“移动零”才算是真正过关了。
