
熟悉LeetCode hot100的朋友一定有这种感觉里面很多题看着简单真动手写却容易在细节上翻车。“移动零”就是很典型的一道。它排在hot100第4题的位置是不少公司的面试暖场题也是个很容易让人在“保持非零元素相对顺序”这个条件上栽跟头的题目。今天我想借着这道题把双指针的思维模型和C/C指针的几种实际用法放在一起聊透。如果你刚开始刷题看到“移动零”可能会想这不就是把所有0挑出来放到数组末尾吗对方向没错但怎么放、能不能原地放、怎么保证非零元素的相对顺序不乱这些细节才是这道题的全部价值所在。我的建议是先别看答案自己上手写一版再回来对照下面的几种实现你的体会会深很多。1. 移动零这道题到底在考什么1.1 题目需求的三层意思题目本身不长给定一个数组nums编写一个函数将所有0移动到数组的末尾同时保持非零元素的相对顺序。注意三个隐藏条件。第一“原地操作”意味着不能新建一个数组把非零元素装进去再拷贝回来空间复杂度被限制在O(1)级别。第二“保持非零元素的相对顺序”这一点最容易被忽略一上来就用双端指针往中间缩的人很容易把顺序搞乱。第三“尽量减少操作次数”虽然leetcode没有硬性要求最优解的赋值次数但面试时如果你能说明白为什么你的方案赋值次数少是明显的加分项。1.2 为什么这题是hot100的常青树这题入选hot100不是因为算法难度大它考的是基础中的基础数组的连续存储特性、元素覆盖与搬移的理解、双指针的抽象能力。换句话说它是一道“筛选题”能快速看出一个人是好学生还是刷题机器。而且它衍生出的快慢指针模型可以无缝迁移到很多其他题上比如“删除排序数组中的重复项”“移除元素”“三数之和”等。把这道题的原理吃透等于给那一类题目都打了一遍地基。这也是为什么它在hot100里的排位这么靠前。1.3 面试现场最常踩的三个坑第一个坑是想到用sort或partition把0和非0分成两堆。问题是只要用了双端交换的思路非零元素的相对顺序就会变化。比如 [0,1,0,3,12]如果从两头同时扫很可能得到 [12,3,1,0,0]这就不满足题目要求。第二个坑是新建辅助数组思路没问题但空间复杂度变成了O(n)直接违反原地操作的限制。第三个坑更隐蔽有的人用覆盖法把非零元素前移后忘了把慢指针之后的残留位置置0结果输出成了 [1,3,12,3,12]半天查不出原因。2. 双指针的核心思路谁探路谁收坑2.1 先理解算法题语境里的“指针”在LeetCode语境里说“指针”不一定指C语言的地址指针它更抽象指的是“记录当前位置的变量”。你可以用int型的下标去实现也可以用C/C里真正的指针去实现效果一样。理解这一点很关键因为你看题解时会看到作者一会儿说“指针”一会儿说“下标”其实说的是同一个东西。这道题用到的双指针准确叫法是“快慢指针”。慢指针指向下一个可以放置非零元素的坑位快指针负责往前探路把所有非零元素找出来交给慢指针。2.2 覆盖法的完整推演过程先看最直接、赋值次数最少的“覆盖法”。维护一个慢指针slow初始指向数组开头。快指针fast从0开始遍历整个数组如果nums[fast] ! 0就把nums[fast]赋值给nums[slow]然后slow向后移动一位如果nums[fast] 0什么都不做fast继续往前走。遍历结束后所有非零元素都已经按顺序搬到了数组前部slow之前的位置都是非零元素。最后只要把slow到数组末尾的位置全部赋值为0就完成了。拿示例 [0,1,0,3,12] 推演一遍fast0, slow0nums[0]0跳过 fast1, slow0nums[1]1nums[0]1slow1数组变为 [1,1,0,3,12] fast2, nums[2]0跳过 fast3, slow1nums[3]3nums[1]3slow2数组变为 [1,3,0,3,12] fast4, slow2nums[4]12nums[2]12slow3数组变为 [1,3,12,3,12] 最后把 nums[3] 和 nums[4] 置0得到 [1,3,12,0,0]你注意看覆盖法的过程中那些“残留”的旧值并不影响正确性因为最后一步会用0把它们清掉。这也是这个方法最反直觉的地方中间过程数组是“脏”的但结果是对的。2.3 覆盖法的代码实现C语言索引版void moveZeroes(int* nums, int numsSize) { int slow 0; for (int fast 0; fast numsSize; fast) { if (nums[fast] ! 0) { nums[slow] nums[fast]; slow; } } for (int i slow; i numsSize; i) { nums[i] 0; } }如果让你点出这个方法最核心的“为什么”就是慢指针slow前面的元素一定都是非零元素快指针fast前面的元素一定都已经被检查过。fast碰到非0就交给slow碰到0就跳过slow永远指向下一个空闲坑位。你只要记住了这句话代码根本不会写错。2.4 交换法的思路与对比交换法的思路略有不同快指针fast遇到非零元素时把nums[slow]和nums[fast]交换然后slow移动一位。这样不需要最后补零因为0会在交换过程中自然“漂移”到后面。void moveZeroes(int* nums, int numsSize) { int slow 0; for (int fast 0; fast numsSize; fast) { if (nums[fast] ! 0) { int tmp nums[slow]; nums[slow] nums[fast]; nums[fast] tmp; slow; } } }这两种方式各有什么优劣从赋值次数上分析覆盖法每个位置最多被赋值一次最后补零又要赋值numsSize - slow次总共不超2n次赋值。交换法每次交换要3次赋值虽然零最终会自己和自己在后面交换但如果遇到极端情况比如数组本身全非零slow和fast几乎总是在同一位置此时就是自己和自己交换白白浪费了3n次赋值。所以如果你追求“尽量减少操作次数”覆盖法理论上是更优的。不过交换法在理解上更直观很多面试官看到交换版会更放心因为逻辑自洽且不需要“清理残留”。怎么取舍看你的习惯如果是我在代码完整性和简洁性之间我会选择覆盖法因为它省掉了一个swap函数代码量更小。3. 指针版实现用C语言把双指针写到极致3.1 真正的指针怎么遍历数组既然标题里带了一个“指针”那C语言的指针写法必须得上。用指针遍历数组的核心思想是把数组名当成指向首元素的指针然后靠指针的算术运算往前走。void moveZeroes(int* nums, int numsSize) { int* slow nums; int* fast nums; while (fast nums numsSize) { if (*fast ! 0) { *slow *fast; slow; } fast; } while (slow nums numsSize) { *slow 0; slow; } }这段代码和索引版逻辑一模一样只是用指针替代了整数下标。这里有三个细节值得展开说说。第一int* slow nums;这一行。nums是一个数组名在表达式中会自动退化为指向首元素的指针所以这个初始化的意思是让slow指向nums[0]的地址。注意数组名本身不是指针变量它是常量不能做nums这样的操作但可以让另一个指针变量指向它。第二fast nums numsSize这一步。nums numsSize计算的是数组末尾之后一个位置的地址也就是常说的“one-past-end”指针。指针比较只有在同一个数组范围内才有意义在这里就是用来判断fast是否越界。第三*slow *fast;是解引用赋值。*fast取出fast所指位置的值*slow表示slow所指的位置二者类型相同可以直接赋值。这比用下标访问更接近“底层”一点也能帮助你理解数组访问的本质。3.2 交换法指针版与swap函数再来看交换法怎么用指针写。既然要交换两个位置的值那就专门写一个swap函数参数用指针void swap(int* a, int* b) { int tmp *a; *a *b; *b tmp; } void moveZeroes(int* nums, int numsSize) { int* slow nums; int* fast nums; while (fast nums numsSize) { if (*fast ! 0) { swap(slow, fast); slow; } fast; } }这里swap(slow, fast)传入的是两个指针。函数内部通过*a和*b修改了指针所指位置的内容从而真正改变了数组里的值。这正是C语言里“用指针作为函数参数才能修改外部变量”的精髓。你可以对比一下如果用值传递写void swap(int a, int b)就是在函数里拷贝了一份值外部的数组根本无法被修改。这个点很多初学者容易懵借这道题自己跑一遍就明白了。3.3 指针运算与类型的紧密关系指针自增fast到底向后移动了几个字节答案是sizeof(指针所指类型)个字节。如果fast是int*这里通常就是4个字节如果换成char*就是1个字节如果是struct Node*就是整个结构体的大小。C语言通过这种方式让指针运算天然适配数据类型你不必手动计算地址偏移。这也是为什么这么简单的代码里指针的类型声明不能乱写。如果写成void* fast那么fast根本编译不过去因为void指针不知道步长是多少。理解了这一层你会真正明白为什么C指针要“带着类型走”。3.4 一个容易被忽略的UB空指针自增写指针时最容易犯的低级错误是初始化成NULL之后直接自增。例如int* fast NULL; while (fast nums numsSize) { ... fast; // 未定义行为 }对空指针做算术运算本质上是C标准里的未定义行为不同编译器、不同平台的表现都不一样。刷题时你基本不会这么干但万一你自己造轮子时埋了这种雷调试起来会异常痛苦。建议养成一个习惯指针在使用前一定确保指向合法内存区域的地址。4. 绕不开的指针基础从这道题向外延展4.1 指针与数组名的区别很多人在写代码时会把“数组名”和“指向数组首元素的指针”划等号。严格来说数组名是一个标识符它标识一块连续的内存区域而指针是存放地址的变量。在绝大多数表达式中数组名会隐式转换成指向首元素的指针所以int* p nums;能通过编译。但两者不是一回事数组名不能自增自减因为它的值首元素地址不可被修改sizeof(nums) 返回整个数组占用的字节数而 sizeof(p) 只返回一个指针的字节数。在“移动零”这道题里我们只用了数组名的退化特性没有去修改它所以一切顺利。如果你试图在函数里对参数 nums 做nums ...能不能行注意函数形参int* nums本质上是一个指针变量不是真正的数组名因此它可以被重新赋值。这个细小的区别在写大型项目时经常让人栽跟头。4.2 指针数组与数组指针顺着热词里的“指针数组”“数组指针”我把这两个概念也理一遍。指针数组本质是一个数组数组的每个元素是指针。比如char* lines[10]lines里有10个指针每个指针可以指向一个字符串。LeetCode里的int* nums其实也可以看成一种指针只是它指向一个内存块而指针数组更常见于处理字符串列表的C程序中。数组指针本质是一个指针它指向整个数组。比如int (*p)[10]p指向的是一个包含10个int的数组。这在二维数组传参时会出现比如void foo(int (*arr)[10])意思是你传进来一行10个int的二维数组的一维切片。遇到这种写法不要慌它的优先级规则很简单先看括号括号里的p先被声明为指针然后(*p)[10]中的[10]说明这个指针指向长度10的数组。在移动零这种一维数组题里你基本用不到数组指针但面试官如果顺着指针话题追问你能答上来就是加分项。4.3 const与指针底层const和顶层const热词里有一条“顶层指针和底层指针可以相互赋值吗”问的其实是const int* p和int* const p的区别。int* const pp本身是const不能改变p的指向但可以通过p修改所指的值。这叫顶层const因为它修饰指针本身。const int* pp指向的值是const不能通过p修改所指的值但p可以指向其他位置。这叫底层const因为它修饰指针指向的对象。回到移动零如果你想把fast定义为“不能改变指向”的指针应该写int* const fast nums;但这样做是错的因为循环里要做fast修改fast的指向。所以这道题里的fast和slow应该用普通的int*不做const限制。面试时如果被问“这里能加const吗”你能答出上面这一层说明你真的理解const和指针的关系。底层const和顶层const可以相互赋值吗结论是顶层const可以直接忽略比如把int* const p赋值给int* p2是允许的因为只是把“不能改指向”的限制去掉拷贝的仍然是一个可用的地址。底层const则不能随便忽略你不能把const int*赋值给int*否则就破坏了只读承诺。但反过来把int*赋值给const int*是允许的属于权限收缩安全。4.4 函数指针与指针函数继续顺着热词走一个容易混的点函数指针和指针函数。函数指针指“指向函数的指针”声明形式是int (*funcPtr)(int, int)。它最常出现在排序、回调、动态加载场景。比如C库的qsort的第四个参数就是函数指针。指针函数指“返回值是指针的函数”声明形式是int* func(int a)。在LeetCode里你写的很多代码本身就是指针函数比如int* twoSum(...)返回一个数组指针。这两者怎么区分看*和函数名谁结合。如果*和函数名先结合就是指针函数如果函数名先和(结合外面再加*就是函数指针。说一句题外话在C里越来越多的场景会用std::function和lambda替代传统函数指针但底层原理仍然是这一套。4.5 C里的智能指针与这道题的关系在C中如果不用原始指针还可以用智能指针来管理动态内存比如std::unique_ptr、std::shared_ptr。智能指针的核心思想是RAII资源生命周期绑定到对象生命周期自动释放内存避免内存泄漏。在移动零这类算法题里我们用不到动态内存所以不该用智能指针更不该用什么new int[]。因为算法题要的是极致的空间控制智能指针反而会增加开销。但如果面试官从指针延伸出C内存管理问题你能说清楚智能指针和原始指针的取舍会显得项目经验很扎实。5. 常见错误、边界条件与调试技巧实录5.1 我在实际调试中遇到的典型错误第一个错误覆盖法忘记补零。这个太常见了我第一次写这道题时就栽在这上面。写完第一个循环看着非零元素按顺序挪到了前面就以为完事了结果输出里全是残留的旧值。排查方法很简单打印数组每次关键步骤后的状态如果发现数组尾部有非零残值立刻就能定位到“补零循环丢了”。第二个错误在循环体内修改了数组长度。有人想用“看到零就从后面找非零”的思路于是一边遍历一边numsSize--或len--结果导致循环退出条件混乱要么跳过了元素要么数组越界。正确的双指针思路里数组长度从始至终都是固定的谁也不要动它。第三个错误交换法里发生了自己与自己的交换。当数组本来就没有0的时候fast和slow始终指向同一个位置交换自己和自己没有意义。加判断可以减少无意义的赋值操作但要注意不能破坏逻辑。我自己做过一个简单的性能对比在十万级数组上加 if 的分支不一定比不加更快因为分支预测的开销可能抵消掉节省的赋值时间。所以这道题里我更喜欢覆盖法它天然没有这种烦恼。第四个错误用int *fast NULL;后直接fast。这个属于未定义行为前面已经讲过这里只提醒一句——在刷题系统里它可能正常运行但进入真实项目就是隐患。5.2 边界条件检查清单每次写完代码和提交前我都会习惯性地跑一组边界测试空数组[]应直接返回不崩溃单个元素[0]或[1]结果应保持不变全零数组[0,0,0]结果不变全非零数组[1,2,3,4]应保持不变零在开头、零在中间、零在结尾的混合场景例如[0,1,0,3,12]、[1,0,2]、[1,2,0]。你可以在本地为每个用例写一个简单的assert比如#include assert.h #include stdio.h void check(int* nums, int size, int* expected) { moveZeroes(nums, size); for (int i 0; i size; i) { assert(nums[i] expected[i]); } } int main() { int arr1[] {0, 1, 0, 3, 12}; int exp1[] {1, 3, 12, 0, 0}; check(arr1, 5, exp1); int arr2[] {0, 0, 0}; int exp2[] {0, 0, 0}; check(arr2, 3, exp2); int arr3[] {1, 2, 3, 4}; int exp3[] {1, 2, 3, 4}; check(arr3, 4, exp3); printf(all tests passed\n); return 0; }5.3 调试技巧打印中间状态算法出问题时最快的定位方式就是把数组在关键步骤打印出来。C语言里可以写一个辅助函数void printArray(int* nums, int size) { for (int i 0; i size; i) { printf(%d , nums[i]); } printf(\n); }然后在每次if (*fast ! 0)分支和循环结束时调用一下你就能看到数组是“怎么一步步变脏又变干净”的。说实话动态观察数组变化远比看代码推演来得直观尤其是刚接触双指针的同学强烈建议自己加打印调试一遍。5.4 一道题吃透一整个模型移动零解决后你可以顺手把同模型的其他题刷一遍巩固一下“移除元素”给定一个值val把数组中所有等于val的元素移除也是快慢双指针思路几乎一模一样“删除有序数组中的重复项”快指针找下一个不同的元素慢指针记录唯一元素的位置“排序数组中的平方数排序”虽然用的是双端指针但思考方式同样是利用数组的有序性进行位置调整。这些题我都刷过做移动零时形成的肌肉记忆碰到后面这几道能帮你直接省掉一半的思考时间。这也就是为什么我一直强调吃透一道hot100的基础题比题海战术有价值。6. 写在最后的个人体会这道题我刷了不止五遍每次给新人讲的时候都有新的感悟。最核心的一点是写代码前先花十秒确认题目里的隐藏条件尤其是“原地”“保持相对顺序”“减少操作次数”这三个词。这三个词几乎决定了你用什么算法模型。双指针里的慢指针和快指针其实很像生活中“流水线装箱”的分工快指针负责扫描传送带上的货物慢指针负责告诉装箱员下一个箱子该放哪儿。扫描器扫到非零货物就放进当前箱子装箱位置往后挪一格扫到空箱子就跳过到终点后把剩下的空箱子补齐。脑子里有了这个画面代码就不容易写错。我个人在实际操作中最常用的还是覆盖法因为它代码最短、无冗余交换、清晰直接。但如果你遇到面试官要求“必须交换”或者面试官想考察你swap函数的写法那交换法也要熟练到能闭眼写。两种方法都写一遍你的收获远比只记一个解法大得多。最后再分享一个小技巧刷题时把这道题的指针版本和索引版本都写出来然后在心里大声问自己一句“这两个版本哪一个更快”能答出“几乎一样快因为编译器会把下标访问优化成指针偏移”说明你是真的理解数组访问的本质了。