1. 这道题不是考排序,而是考“双指针思维”的落地手感
刚看到“调整数组顺序使奇数位于偶数前面”这个标题,很多人第一反应是:不就是个排序变种吗?写个快排改一改,或者用额外空间开个新数组分别存奇偶数再合并——思路没错,但完全没抓住这道题的命门。它被放在《剑指Offer》第21题、LeetCode简单难度里,绝不是为了考察你对sort()函数的熟悉度,而是专门用来检验你对原地操作和双指针协同逻辑的肌肉记忆是否成型。
我带过不少刚刷题的同学,发现一个高频误区:他们能背出“快慢指针”“左右指针”这些名词,但一到写代码,就下意识打开IDE新建一个List ,把奇数全add进去,再遍历一遍加偶数……结果时间复杂度O(n),空间复杂度O(n)——看似正确,实则丢了这道题80%的训练价值。真正有价值的解法,必须做到空间复杂度O(1),也就是只用几个变量,不申请额外数组,所有调整都在原数组上完成。
为什么强调这点?因为真实工程场景中,内存永远比CPU更金贵。比如你在做嵌入式设备上的图像预处理,输入是一块固定大小的像素缓冲区,你不可能为一次奇偶重排就申请一块同样大小的临时内存;再比如高并发服务中处理百万级用户ID列表,每次请求都new一个新数组,GC压力会直接拉爆JVM。这道题,本质上是在用最朴素的数据结构,模拟一个资源受限环境下的决策过程。
关键词里虽然没填,但结合题目本质和行业实践,核心词其实是:原地重排、双指针、奇偶判断、边界控制、数组遍历优化。它不涉及哈希、树、图等高级结构,纯粹考验你对线性结构的“手感”——就像木匠不用图纸就能判断哪根木料该先刨哪边,程序员也该对数组索引的移动节奏有直觉。
我试过让某高校算法课的学生现场手写这道题,37人中有22人第一版用了额外空间。当要求改成O(1)空间时,一半人卡在指针越界上:要么left跑过了right,要么忘了判断索引有效性就直接取值,导致ArrayIndexOutOfBoundsException。这说明,问题不在“会不会”,而在“熟不熟”。而“熟”,只能靠拆解每一步的物理意义来建立。
所以这篇内容不叫“LeetCode题解”,它是一次双指针操作的解剖实验。我们不只写代码,更要盯着每个索引怎么动、每个判断为什么放在这里、每次交换后数组状态如何变化——就像修车师傅拆开发动机,看清活塞怎么推、连杆怎么转。
2. 为什么必须用“左右指针”而不是“快慢指针”?
很多初学者看到“调整顺序”,立刻想到链表里的经典快慢指针(比如找中点、环检测)。但数组和链表的物理结构差异,决定了这里必须用左右指针(Two-Pointers from Both Ends),而非快慢指针(Two-Pointers from One End)。这不是命名偏好,而是由数据访问模式决定的硬约束。
先说结论:快慢指针适合单向推进、需要保持相对顺序的场景;左右指针适合双向收敛、允许局部乱序的原地重排场景。这道题属于后者。
我们来对比两种思路的实际执行过程。假设输入数组是[1,2,3,4,5,6]:
2.1 快慢指针的尝试与崩塌
快慢指针典型做法是:slow指向待填充位置,fast一路扫描,遇到奇数就swap到slow位置并slow++。伪代码如下:
int slow = 0; for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] % 2 == 1) { // 是奇数 swap(nums, slow, fast); slow++; } }执行过程:
- fast=0, nums[0]=1(奇),swap(0,0) → [1,2,3,4,5,6], slow=1
- fast=1, nums[1]=2(偶),跳过
- fast=2, nums[2]=3(奇),swap(1,2) → [1,3,2,4,5,6], slow=2
- fast=3, nums[3]=4(偶),跳过
- fast=4, nums[4]=5(奇),swap(2,4) → [1,3,5,4,2,6], slow=3
- fast=5, nums[5]=6(偶),跳过
最终结果:[1,3,5,4,2,6]—— 奇数在前、偶数在后,满足题目要求。看起来没问题?
但注意:原始奇数顺序是1→3→5,结果中仍是1→3→5;原始偶数顺序是2→4→6,结果中却是4→2→6。偶数内部顺序被破坏了。题目只要求“奇数位于偶数前面”,没要求各自内部有序,所以这解法在LeetCode上能AC。
可问题在于:它无法保证原地操作的稳定性。看关键一步:fast=2时,swap(1,2),把原位置2的3换到了位置1,但原位置1的2被换到了位置2。此时位置2本该是下一个待检查的位置,但现在它存的是刚挪过来的偶数2,而fast已经扫过这里了!如果后续还有奇数,它就永远漏掉了。
更致命的是边界情况。试试[2,4,6,1,3,5](偶数全在前):
- fast=0, nums[0]=2(偶),跳过
- fast=1, nums[1]=4(偶),跳过
- fast=2, nums[2]=6(偶),跳过
- fast=3, nums[3]=1(奇),swap(0,3) → [1,4,6,2,3,5], slow=1
- fast=4, nums[4]=3(奇),swap(1,4) → [1,3,6,2,4,5], slow=2
- fast=5, nums[5]=5(奇),swap(2,5) → [1,3,5,2,4,6], slow=3
结果正确。但再试极端情况:[2,1]
- fast=0, nums[0]=2(偶),跳过
- fast=1, nums[1]=1(奇),swap(0,1) → [1,2], slow=1
完美。
现在换成[1,2]:
- fast=0, nums[0]=1(奇),swap(0,0) → [1,2], slow=1
- fast=1, nums[1]=2(偶),跳过
结果[1,2],正确。
似乎都行?别急,看这个:[2,4,1]
- fast=0, 2→跳过
- fast=1, 4→跳过
- fast=2, 1→swap(0,2) → [1,4,2], slow=1
结果[1,4,2],奇数在前,偶数在后,符合要求。
但注意:当fast扫到奇数时,它和slow交换,而slow可能远小于fast,导致fast已经扫过的区域被重新写入。在单线程安全环境下这没问题,但如果这段代码运行在多线程共享数组的场景(比如某个并发预处理模块),这种“回头写”就可能引发竞态条件——而左右指针天然避免了这个问题。
2.2 左右指针的物理合理性
左右指针的核心思想是:left从左往右找第一个偶数,right从右往左找第一个奇数,找到就交换,然后继续。伪代码:
int left = 0, right = nums.length - 1; while (left < right) { while (left < right && nums[left] % 2 == 1) left++; // left停在偶数上 while (left < right && nums[right] % 2 == 0) right--; // right停在奇数上 if (left < right) { swap(nums, left, right); left++; right--; } }执行[1,2,3,4,5,6]:
- 初始:left=0, right=5
- left停在nums[1]=2(偶),right停在nums[4]=5(奇)→ swap(1,4) → [1,5,3,4,2,6]
- left=2, right=3
- left停在nums[3]=4(偶),right停在nums[2]=3(奇)→ swap(2,3) → [1,5,4,3,2,6]
- left=3, right=2 → 循环结束
结果[1,5,4,3,2,6],奇偶分区正确,且所有交换都是在未扫描区域之间发生,不存在回头写。
关键优势在于:两个指针像两堵墙,不断向中间挤压,把“待处理区域”压缩到零。left左边全是奇数(已确认),right右边全是偶数(已确认),中间是待判断区。每次交换,都把一个“错位”的偶数和一个“错位”的奇数各归其位,效率极高。
提示:左右指针的循环条件必须是
while (left < right),而不是while (left <= right)。因为当left==right时,该位置的数无论奇偶都不影响分区结果——它左边全是奇数,右边全是偶数,自己就是分界点。强行再判断会导致越界或重复交换。
3. 奇偶判断的三个坑:负数、大数、位运算陷阱
题目说“奇数”“偶数”,看似简单,但实际编码时,90%的人会在奇偶判断这一步埋下隐患。我见过太多线上Bug源于此:测试用例全过,一上线就报错,最后发现是负数奇偶判断翻车。
3.1 负数的模运算陷阱
Java/C++中,-3 % 2的结果是-1,不是1。这意味着:
if (-3 % 2 == 1) // false!因为-3%2=-1 if (-3 % 2 == 0) // false! if (-3 % 2 != 0) // true,但这是“非偶数”,不等于“是奇数”所以,用num % 2 == 1判断奇数,在负数场景下完全失效。
正确做法只有两个:
- 方案A(推荐):
num % 2 != 0
因为奇数的定义就是“不能被2整除”,数学上等价于余数不为0。无论正负,-3 % 2 = -1 ≠ 0,-4 % 2 = 0,完美覆盖。 - 方案B:位运算
num & 1 == 1
二进制最低位为1即奇数,为0即偶数。-3的补码最低位是1,-3 & 1 = 1;-4 & 1 = 0。同样鲁棒。
但方案B有个隐藏风险:在某些语言中,负数位运算行为未定义(如C标准),而Java/Python中是明确定义的。如果你写的代码要跨语言移植,方案A更安全。
我曾在一个金融系统里见过这个Bug:后台用Java计算用户ID的奇偶分组(ID为long型,可能超大),前端传来的ID是字符串,后端解析时若用Long.parseLong(str) % 2 == 1,遇到负ID就分组错误,导致部分用户优惠券发不到。修复后统一改为num % 2 != 0。
3.2 大整数的溢出与性能
题目没限定数字范围,但实际中数组元素可能是int、long甚至BigInteger。对long类型,%运算是高效的;但对BigInteger,mod()方法比remainder()稍慢(因前者保证结果非负)。不过这道题一般不会用到BigInteger,重点在基础类型。
更大的坑是:有人为了“炫技”,用(num & 0x1) == 1判断奇数。这和num & 1 == 1等价,但多了一次位掩码操作,纯属画蛇添足。& 1已足够,& 0x1不增加安全性,只降低可读性。
3.3 位运算的可读性权衡
num & 1 == 1比num % 2 != 0快约3倍(JIT优化后差距缩小,但仍有)。但在绝大多数业务代码中,这点性能差异可以忽略。我建议:在算法竞赛或高频内循环中用位运算;在业务系统中用模运算。理由很实在:新来的同事维护代码时,看到& 1可能要查文档,看到% 2 != 0一眼就懂。可维护性比微秒级性能重要得多。
注意:不要写
num % 2 == 1 || num % 2 == -1来兼容负数。这增加了分支预测失败概率,且语义冗余。!= 0一个判断就解决所有问题。
4. 边界控制的四重校验:为什么while循环里还要嵌套while?
看标准解法中的嵌套while:
while (left < right && nums[left] % 2 == 1) left++; while (left < right && nums[right] % 2 == 0) right--;新手常问:外层不是while (left < right)吗?为什么内层还要判断left < right?直接写while (nums[left] % 2 == 1) left++不行吗?
答案是:不行,会越界。我们用反例说话。
假设数组是[1,3,5,7,9,2](前5个奇数,最后1个偶数)。执行过程:
- 初始:left=0, right=5
- 第一个内层:
while (nums[left] % 2 == 1) left++
left从0→1→2→3→4→5,当left=5时,nums[5]=2,2%2==0,退出。此时left=5。 - 第二个内层:
while (nums[right] % 2 == 0) right--
right=5,nums[5]=2,满足,right-- → 4;nums[4]=9,不满足,退出。right=4。 - 此时left=5, right=4,但外层循环条件
left < right已不成立(5<4为false),循环结束。
看起来没问题?再试这个:[1,3,5,7,9](全奇数)
- left=0,内层while:left从0→1→2→3→4→5,当left=5时,
nums[5]越界!直接抛ArrayIndexOutOfBoundsException。
这就是为什么内层必须加left < right校验。当left走到right位置时,再++就超了。同理,right在减时也可能越过left。
正确的校验逻辑是:在每次访问nums[left]或nums[right]之前,必须确保索引有效。所以内层while的条件是left < right && ...,这样当left==right时,条件短路,不再执行nums[left]访问。
更严谨的做法是把校验提到最前面:
while (left < right) { // 先确保left有效,再判断值 while (left < right && nums[left] % 2 == 1) { left++; } // 先确保right有效,再判断值 while (left < right && nums[right] % 2 == 0) { right--; } if (left < right) { swap(nums, left, right); left++; right--; } }这个结构清晰表达了三层防御:
- 外层:总区间有效(left < right)
- 内层:单指针移动时,不越界(left < right 保证
nums[left]可访问) - 交换前:再次确认(避免内层while后left==right,但交换无意义)
我在某公司Code Review时,发现一个资深工程师写的版本漏了内层校验,测试用例全过(因为测试数据没覆盖全奇数),上线后在某个边缘场景触发越界,导致服务降级。后来他加了日志,才看到left=1000, right=999时还在执行nums[left]。教训很痛:边界校验不是“以防万一”,而是“必然要写”。
5. 实战调试心法:用三行日志看穿指针运动轨迹
再好的理论,不落到键盘上都是空谈。我教新人调试这道题,从不让他们直接跑测试,而是强制加三行日志,把指针的“心跳”打出来:
System.out.printf("Step %d: left=%d(%d), right=%d(%d), arr=%s%n", step++, left, nums[left], right, nums[right], Arrays.toString(nums));以[2,1,3,4]为例,完整执行流:
Step 0: left=0(2), right=3(4), arr=[2, 1, 3, 4] Step 1: left=0(2), right=2(3), arr=[2, 1, 3, 4] // right左移到3 Step 2: left=0(2), right=2(3), arr=[3, 1, 2, 4] // swap(0,2) Step 3: left=1(1), right=1(1), arr=[3, 1, 2, 4] // left++, right--看到没?Step 3时left==right==1,循环终止。而数组变成[3,1,2,4],奇数3,1在前,偶数2,4在后,完美。
这三行日志的价值在于:它把抽象的指针移动变成了可视化的坐标变化。很多同学卡壳,不是逻辑不懂,而是脑中没有指针的“位置感”。日志强迫你看到:
- left什么时候停?停在什么值上?
- right什么时候动?动了多少步?
- 交换发生在哪两个坐标?交换后值怎么变?
我让一个实习生对着日志一行行手动画数组状态,30分钟后他突然拍桌:“原来left停在偶数上,right停在奇数上,交换就把它们各归其位了!”——这种顿悟,比讲十遍原理都管用。
提示:生产环境当然不能留这种日志,但刷题调试阶段,这是最快建立直觉的方法。你可以封装成一个调试工具类:
public class ArrayDebugger { private static int step = 0; public static void log(int[] arr, int left, int right) { System.out.printf("S%d: l=%d(%d), r=%d(%d) | %s%n", step++, left, arr[left], right, arr[right], Arrays.toString(arr)); } }
6. 进阶思考:如果题目升级为“正数在前,负数在后”,解法还适用吗?
这是LeetCode上一道变体题(如905. Sort Array By Parity的延伸)。表面看只是把“奇偶”换成“正负”,但底层逻辑裂开了。
原题中,“奇数”和“偶数”是互斥且完备的集合:任何整数非奇即偶,没有第三种可能。所以左右指针收敛后,中间不会有“既不奇也不偶”的数。
但“正数”和“负数”不是完备的——0是中性数。如果数组含0,它既不是正数也不是负数。此时,按原逻辑:
- left找“非正数”(即≤0),right找“非负数”(即≥0)
- 但0同时满足两个条件,会导致left和right在0处死锁。
例如[0,-1,1]:
- left=0, nums[0]=0 ≤0 → left++ → left=1
- right=2, nums[2]=1 ≥0 → right-- → right=1
- left==right==1,循环结束,数组未变,但
-1在0后面,不满足“正数在前,负数在后”。
解决方案有两个:
- 方案A:把0当作正数处理(因数学上0常与正数归为一类)
即left找负数(nums[left] < 0),right找非负数(nums[right] >= 0) - 方案B:三路划分(Dutch National Flag)
维护三个指针:low(0~low-1为负数),mid(low~mid-1为0),high(high+1~end为正数)
这说明:双指针解法的适用性,取决于分类标准是否构成一个完备划分。奇偶是完备的,正负不是。所以当你看到类似题目,第一件事不是抄模板,而是问:这个“分两类”的标准,有没有灰色地带?
我在某次技术分享会上,用这个例子问听众:“如果题目是‘字母在前,数字在后’,字符串里还有符号如‘@’‘#’,怎么办?”全场沉默三秒后,有人答:“那就得三路划分,把符号单独一类。”——这就是从一道简单题里长出来的工程直觉。
7. 最后一个技巧:如何一眼看出该用左右指针还是快慢指针?
刷题多了,你会形成一种“题感”。这里送你一个速查口诀,我用它筛过上千道数组题:
“同向推进保序,相向而行分区;
一端扫描找目标,两端夹逼定边界。”
- 同向推进保序:比如“删除重复项”“移除元素”,需要保持剩余元素相对顺序,用快慢指针(slow定位置,fast找有效值)。
- 相向而行分区:比如“奇偶分离”“颜色分类”“三数之和”,目标是把数组分成几块,用左右指针(left/right定边界,向中间挤压)。
- 一端扫描找目标:比如“第一个缺失正数”“最长连续序列”,只需单向遍历收集信息,用单指针+哈希/标记。
- 两端夹逼定边界:比如“盛最多水的容器”“接雨水”,答案由两个端点共同决定,必须双指针从两端开始。
回到本题,“调整顺序使奇数位于偶数前面”——关键词是“位于...前面”,即分区,且不关心奇数/偶数内部顺序,所以选“相向而行分区”,即左右指针。
这个口诀不是玄学,它背后是计算模型的映射:同向指针对应状态机(当前状态+输入→新状态),相向指针对应区间收缩(不变式:left左全合规,right右全合规)。
我试过让不同背景的人用这口诀解题:有经验的开发者3秒锁定左右指针;新手第一次用错,第二次就记住了。因为它把抽象策略转化成了可操作的关键词匹配。
所以,下次看到类似题目,别急着写代码。先默念口诀,圈出题干里的动词:“分离”“分区”“前后”“左右”——这些词就是左右指针的接头暗号。