资讯动态

LeetCode 283 移动零:双指针原地算法详解与多语言实现

发布时间:2026/9/18 3:13:41 来源:尧图企业网站定制
1. 项目题干与考点拆解1.1 题目到底在说什么LeetCode hot100 第4题“移动零”原题编号其实是283题目描述非常短给定一个数组 nums编写一个函数将所有 0 移动到数组的末尾同时保持非零元素的相对顺序。举个例子输入[0, 1, 0, 3, 12]输出应该是[1, 3, 12, 0, 0]。注意两个要求必须在原数组上操作不能拷贝额外的数组非零元素的相对顺序不能变。这道题看着简单但它几乎是所有刷题人入双指针的门槛。hot100 把它排在这么靠前的位置不是因为它难而是因为它能一次性考察你对数组操作、指针思维、复杂度分析这三个基本功的掌握程度。网上说的“指针解法”核心就是快慢双指针这也是面试官最希望看到的答案。1.2 为什么这道题“简单但不轻易满分”我见过很多人上来就写两层循环冒泡式地把 0 往后挪或者新建一个数组把非零挑出来再补 0。这两种做法都能跑通但都不符合出题人的预期。原因在于第一种时间复杂度 O(n²)虽然数据量小的时候看不出问题但面试官会追问“能不能一次遍历”第二种直接开辟了新数组空间复杂度 O(n)违背了题目里“原地操作”的潜台词。这道题真正要考察的其实就两件事能不能想到用指针把“非零元素”和“零元素”分区以及在交换或覆写的时候能不能保证稳定性。后面我会把三条路线全部写出来对比完你自然就明白为什么双指针是正解。2. 从最笨的解到最优解思路是怎么长出来的2.1 方案一额外数组 两次遍历最直觉但最“贵”如果第一次刷题没读过题里的“原地”限制最容易想到的思路是开一个新数组先把所有非零元素按顺序塞进去再把剩下的位置补 0最后把新数组内容拷回原数组。def move_zeroes_extra(nums): n len(nums) result [0] * n idx 0 for num in nums: if num ! 0: result[idx] num idx 1 # 将结果写回原数组 for i in range(n): nums[i] result[i]时间复杂度 O(n)空间复杂度 O(n)。这个方案能 ACAccepted但它有两个明显问题空间浪费和没必要的一趟写回。如果面试官追问“能不能不用额外空间”那就得回到原地操作上了。这里有个容易忽略的小点题目说的是“函数将所有 0 移动到数组末尾”并没有允许你返回新数组。所以哪怕你在函数内部构造了额外数组最后也必须拷贝回原数组。这一点想清楚就理解了为什么“原地”是硬约束。2.2 方案二前后双指针交换一种直觉上的补丁既然要把 0 往后放那最自然的想法是“前后各一个指针”左指针找 0右指针找非零找到就交换。这很像快排的 partition 思路。def move_zeroes_swap(nums): left, right 0, len(nums) - 1 while left right: # 左指针找0 while left right and nums[left] ! 0: left 1 # 右指针找非0 while left right and nums[right] 0: right - 1 # 交换 nums[left], nums[right] right, left等等我故意写了一个 bug 在上面你能看出来吗nums[left], nums[right] right, left把值写错了应该写成nums[left], nums[right] nums[right], nums[left]。这个笔误其实就是前后指针方案的第一个坑交换的是数组元素的值不是指针本身。更严重的坑是这种前后交换会把非零元素的相对顺序打乱。比如[1, 0, 0, 2]左指针在 index 1 找到 0右指针从末尾找到 index 3 的非零 2交换后变成[1, 2, 0, 0]看起来没问题。但如果数组是[1, 0, 2, 0, 3]左指针 index 1 和右指针 index 4 交换得[1, 3, 2, 0, 0]非零元素的相对顺序从 1、2、3 变成了 1、3、2直接违背题意。所以前后交换方案虽然时间复杂度是 O(n)但它破坏了稳定性。除非题目明确说“不要求保持相对顺序”否则不要用。2.3 方案三快慢指针原地覆写稳定且零额外空间正解是快慢双指针网上更多叫“快慢指针 覆写”思路分成两步第一步用快指针 fast 遍历数组每遇到一个非零元素就把它写到慢指针 slow 指向的位置然后 slow 加一。这相当于把所有非零元素“挤”到数组前面且因为 fast 是从头到尾顺序遍历所以非零元素的相对顺序天然不变。第二步从 slow 开始到数组末尾全部填充 0。def move_zeroes(nums): slow 0 n len(nums) # 第一次遍历把所有非零元素往前覆写 for fast in range(n): if nums[fast] ! 0: nums[slow] nums[fast] slow 1 # 第二次遍历末尾补0 while slow n: nums[slow] 0 slow 1时间复杂度 O(n)空间复杂度 O(1)一次遍历完成覆写不改变非零元素的相对顺序而且完全原地操作。这就是那道题的标准答案。有人可能会问既然第二步还要再遍历一次能不能优化成一次遍历可以那就是“遍历时直接交换”的写法我在下一节单独讲因为细节更值得花篇幅。3. 核心代码实现与指针细节实战3.1 C 实现以及数组下标与指针的辨析先给出 LeetCode 上最常见的 C 写法也就是单指针赋值的版本class Solution { public: void moveZeroes(vectorint nums) { int slow 0; int n nums.size(); for (int fast 0; fast n; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; } } while (slow n) { nums[slow] 0; } } };很多初学者对nums[slow] nums[fast]这句很困惑。它其实等价于nums[slow] nums[fast]; slow slow 1;这个写法背后的逻辑是把slow当成“下一个非零元素应该放的位置”。在 C 语境下vectorint的下标访问本质上走的是迭代器或指针运算nums[slow]等价于*(nums.begin() slow)所以在逻辑上slow就是一个指针的概念——它指向当前待写入的位置。你要是非要用真正的指针来写在 C 风格数组下可以这样void moveZeroes(int* nums, int numsSize) { int* slow nums; int* fast nums; for (; fast nums numsSize; fast) { if (*fast ! 0) { *slow *fast; slow; } } while (slow nums numsSize) { *slow 0; slow; } }注意这里slow和fast都是真正的指针变量fast nums numsSize是尾后指针比较*slow *fast是指针解引用赋值。这个版本能帮你更直观地理解“指针移动”到底移动的是什么不是数组元素而是指向数组某个位置的地址。我在实际教别人的时候发现很多人分不清“指针变量”和“指针的值”。在这个例子中slow这个指针变量的值是一个地址*slow才是这个地址上存放的值。移动指针是改地址赋值是改值两码事。3.2 一次遍历的交换版本可读性与性能的平衡LeetCode 热评区和题解里还有另一种一次遍历写法核心思想是快指针遇到非零元素就和慢指针交换class Solution { public: void moveZeroes(vectorint nums) { int slow 0; for (int fast 0; fast nums.size(); fast) { if (nums[fast] ! 0) { swap(nums[slow], nums[fast]); slow; } } } };这样确实只需要一次遍历而且不需要补 0 的那一轮循环。我实测下来性能差异可以忽略不计因为第二次循环本身就是 O(n) 里的一次普通遍历两个版本时间复杂度完全一样都是 O(n)。但交换版本有个隐藏问题当slow fast时交换的是同一个元素做了无用功。更优雅的写法是加一个判断if (nums[fast] ! 0) { if (slow ! fast) { swap(nums[slow], nums[fast]); } slow; }这个判断能减少大量自我交换。数据量小的时候无感但数组有几万个元素且绝大多数非零时能省下不少无意义的写操作。我个人更推荐初学阶段用“先覆写再补零”的两段式版本逻辑更清晰面试时如果追求代码简洁再写交换版本也不迟。3.3 三种语言的快速对照方便直接抄作业Java 版本class Solution { public void moveZeroes(int[] nums) { int slow 0; for (int fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; slow; } } while (slow nums.length) { nums[slow] 0; slow; } } }Python 版本class Solution: def moveZeroes(self, nums: List[int]) - None: slow 0 for fast in range(len(nums)): if nums[fast] ! 0: nums[slow] nums[fast] slow 1 for i in range(slow, len(nums)): nums[i] 0JavaScript 版本var moveZeroes function(nums) { let slow 0; for (let fast 0; fast nums.length; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; slow; } } while (slow nums.length) { nums[slow] 0; slow; } };提醒一点Python 里如果你写成nums[:] sorted(nums, keylambda x: x 0)也能过但那是在炫技面试官不一定会认可。老老实实双指针才是正道。4. 边界条件、测试用例与常见 BUG 排查实录4.1 边界情况速查表这道题虽然简单但边界条件一旦漏掉很容易写出“看起来对跑起来错”的代码。我整理了一张测试用例表建议刷题时逐条过测试用例期望输出容易犯的错空数组[][]无但要注意慢指针循环条件别越界单元素[0][0]如果写交换逻辑可能出现自己和自己交换单元素[1][1]同上全零[0,0,0][0,0,0]非零遍历不执行补零循环要把整个数组填满无零[1,2,3][1,2,3]覆写版本中 slow 跑到末尾补零循环不执行零在前[0,0,1,2][1,2,0,0]覆写版本没问题但交换版本容易把顺序弄乱零在中间[1,0,2,0,3][1,2,3,0,0]前后双指针交换会破坏 1,2,3 的顺序交替出现[0,1,0,1,0][1,1,0,0,0]相对顺序要保持两个 1 的顺序不能变我自己刷题时最讨厌全零和无零这两种极端输入因为它们最容易暴露“慢指针没有推进”或“补零循环没执行”的问题。4.2 三个我实际踩过的坑第一个坑把 slow 写成局部变量却在函数外面用。C 版本里如果写成int slow;而不初始化slow 的值是未定义的遍历时直接数组越界。很多刷题平台不给太多调试信息报错还是“AddressSanitizer: heap-buffer-overflow”特别难排查。解决办法是每次声明指针下标变量都要初始化int slow 0写死。第二个坑交换时把下标和值搞混。我见过有人写出swap(slow, fast)结果数组元素根本没变因为交换的是两个局部整数变量不是数组里的值。这实际上是 C 里最常见的“传值不传引用”困惑swap的两个参数必须是可以修改的左值nums[slow]是可以修改的而裸的slow只是一个局部变量副本。如果非要自己写交换请这样写int tmp nums[slow]; nums[slow] nums[fast]; nums[fast] tmp;第三个坑对nums.size()反复调用导致的边界判断混乱。vectorint::size()返回的是无符号整数size_t如果你用for (int i 0; i nums.size() - 1; i)当nums为空时nums.size() - 1会变成很大的正数无符号溢出循环直接飞出去。这道题一般不会踩到但如果你把代码改成从末尾倒序遍历时就要非常小心。4.3 如何用测试用例快速定位“不稳定”的写法如果你用的是“前后双指针交换”那种不稳定方案我建议你专门准备一个用例[1, 2, 0, 3, 0, 4]。跑完后如果输出是[1, 2, 4, 3, 0, 0]说明 3 和 4 的相对顺序被破坏正确输出应该是[1, 2, 3, 4, 0, 0]。判断“顺序是否被破坏”有个直觉方法把所有非零元素按原顺序单独抄出来看和最终结果里的非零部分是否一致。如果一致说明你的算法是稳定的。数组移动零是否稳定往往决定了算法能不能通过面试官后续的追问。5. 从移动零延伸到更广的指针思维5.1 快慢指针模式的一鱼多吃只做这一道题其实“吃不饱”因为快慢指针是一整套方法论。LeetCode 27 题“移除元素”几乎就是把移动零的判定条件从nums[fast] ! 0改成nums[fast] ! val代码结构一模一样。LeetCode 26 题“删除有序数组中的重复项”也是快慢指针只不过赋值条件变成了nums[fast] ! nums[slow - 1]。我建议你把这个模式总结成四步口诀快指针负责“找”慢指针负责“存”找到符合条件的元素就往前存最后慢指针的位置就是新数组的长度或末尾。只要看透了这一点后面做“移动石头”“移动负数到数组末尾”“把奇偶数分区”一类题目都是同一套模板。5.2 面试时怎么讲才能把 30 行代码讲出亮点这道题在面试里被视为“热身题”但正是热身题最容易看出候选人的表达功底。我比较推荐的讲解顺序是先说明白题目两个硬约束原地操作 保持相对顺序。然后说“我可以用快慢指针解决”不要直接甩代码。先画一个例子[0, 1, 0, 3, 12]slow 指向 0fast 从 0 往后扫遇到第一个非零元素 1 时把它写到 slow 的位置slow 后移。然后再解释为什么这样不会乱序因为 fast 是按顺序遍历的先遇到谁就先写谁天然有序。最后说复杂度 O(n)、O(1)。面试官通常会追问两个问题如果元素不是 0 而是“负数”你怎么办如果要求把所有 0 移到开头呢这两个追问都不难只要把判断条件从! 0改成 0或者 val把补零改成补目标值思路完全一样。真正想加分的话可以主动提一句“这个解法是稳定的这一点对保持相对顺序很关键”几乎所有面试官都会眼睛一亮。5.3 C 指针细节的后续延伸如果你是因为这道题被“指针”两个字吸引进来的那我要说明一下LeetCode 刷题里的“指针解法”更多是思想层面的指针也就是用下标模拟指针的思路但理解真正的 C/C 指针反过来会强化你对这个算法的理解。比如nums[fast] ! 0这句话在 C/C 里等价于*(nums fast) ! 0。这里的nums在传递时会退化成指向首元素的指针nums fast是指针运算*(nums fast)才是数组元素。所以“移动零”的每一步操作本质都是在说通过指针运算找到某个位置的元素然后把它挪到另一个指针指向的位置。我见过很多同学看完这道题去啃“指针数组”“函数指针”“指向指针的指针”结果越啃越乱。我的建议是先把双指针的“思想版”练熟再回头把 C 语言的指针语法补上。你会有一种“原来当时的内层机制是这回事”的顿悟感。等你能轻松区分“指针的值”和“指针指向的值”之后再看智能指针的实现也会顺手很多。5.4 这道题的更多变体值得自己动手练一遍移动零不止 LeetCode 283 这一道题它作为套路题有很多变形把 0 移到数组开头其他保持顺序。做法是把快慢指针从末尾往前遍历或者先逆序再处理。把负数移到左边正数移到右边且各自保持相对顺序。这时候需要两次移动一次负数到前一次正数归位。在链表里“移动零”用指针去删除和插入节点考察点和数组完全不同。把某个特征值x全部移除这就是 LeetCode 27 的变体。我自己刷这些变体时有一个习惯每做完一题就在原题下面写一行“变换条件”比如“把 0 改成 val”“把数组改链表”“把顺序改成不要求稳定”。下次再遇到新题先扫一眼注释就能快速匹配模板。6. 最后的经验总结这道题本身不复杂但我带过不少人刷它发现一个共同现象代码写对很容易但讲清楚为什么用双指针却很难。如果你也是第一次接触这类题我建议你哪怕已经 AC 了也再花十分钟做一件事把快慢指针每一步的 slow 和 fast 位置画在一张纸上。画完你会真正理解什么叫“慢指针记录结果区间的末尾快指针遍历整个输入区间”。我个人的体会是移动零这道题最大的价值不在于“会不会”而在于它把“指针”从 C 语言语法层面上升到了“算法设计思想”层面。以前你写int *p a可能只是背语法做完这道题你会发现很多数组的原地操作都可以用指针移动来建模。这种思维上的转变比多背二十道题的答案有用得多。如果你正要刷 LeetCode hot100建议按顺序刷因为第 4 题之后很快会遇到更多双指针题比如“盛最多水的容器”“三数之和”“接雨水”移动零就是最开始那个最容易上手的引子。把它彻底吃透后面能省不少力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价