1. 一道看似人畜无害的入门题,面试官到底想看你什么
提到“算法题”里的“按奇偶排序数组”,不少人的第一反应是:这题不是有手就行吗,双指针扫一遍就结束了。可就是这么一道看起来基础到不能再基础的题,字节跳动在面试实战中问过,考研408的算法题备考群里也反复出现,甚至不少工作三五年的开发者现场手写时,依然会在边界条件上翻车。
我把这道题的原貌先放在这里,方便没刷过的人对上号:给定一个非负整数数组 nums,把数组重新排列,让所有偶数排在前面,所有奇数跟在后面,返回任意一个满足条件的数组即可。也就是说,题目明确说了“任意顺序”,奇数内部、偶数内部原本的相对顺序可以被打乱。
输入 [3,1,2,4],输出 [4,2,1,3]、[2,4,1,3]、[4,2,3,1] 都算正确。
为什么这道题能同时出现在大厂面试和考研复习范围里?因为它的“解法难度”和“考察深度”是错位的:代码可以写得很短,但背后连着数组的原地修改、双指针思想、快速排序 partition、稳定性判断、以及语言边界行为,随便往哪个方向深挖都能挖出东西。
1.1 先别急着写代码,搞清楚题目到底限制了什么
这个题最容易被人忽略的地方恰恰写在题干最后一句:“返回满足条件的任何数组均可”。这句话一出,题目难度直接降了一个档次——它明确允许我破坏原数组中奇数和偶数的相对顺序。如果去掉这句话,把题目改成必须保持偶数之间、奇数之间的原有先后顺序,那解法就要重新考虑了,这个区别相当关键,我后面专门用一节来讲。
另外注意“原地”这个词。虽然原题没有强制要求,但面试官通常会追加一句“最好别用额外空间”,这时候如果你只会开两个新数组装偶数和奇数,思路大体没错,但少了算法层面的思考。能不能在 O(1) 额外空间内完成?这才是这道题真正的考点。
还有一个小细节,数组元素是“非负整数”。出题人把负数排除掉,可能是有意降低取模运算的歧义。因为在某些语言里 x % 2 == 1 并不能正确判断一个负数是不是奇数,关于这个坑后面我也会专门说。
1.2 它真正想考察的三层能力
第一层是编码准确性。双指针的 while 嵌套很容易因为漏掉边界判断而出错,很多人在本地一跑出错,不是思路错了,而是两个内层 while 在极端输入下越界。
第二层是复杂度分析。这题要求你立刻说出时间 O(n)、空间 O(1) 的实现,你要是只答出用辅助数组的 O(n) 空间版本,面试官会默认你对算法复杂度还不够敏感。
第三层是抽象能力。快速排序里的 partition 就是“把满足某种条件的元素挪到数组一端”,按奇偶排序只是把“小于基准值”换成“是偶数”。能把这层抽象关系想清楚的人,写快排、写荷兰国旗问题、写移动零都会顺手很多;想不清楚的人,大概率是背题型,换个条件就懵了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一直觉的辅助数组方案,其实没那么“丢人”
先写暴力版本不是耻辱。在真实的面试场景里,我反而建议你先说出一个能正确工作的方案,再去优化。毕竟写代码第一步是“能跑”,第二步才是“跑得快、省内存”。
2.1 两遍扫描的实现和复杂度
最直接的办法是开两个临时数组:遍历一遍 nums,把偶数放进 even,把奇数放进 odd,最后把两个数组拼回去。
python复制def sort_by_parity(nums):
even = []
odd = []
for x in nums:
if x % 2 == 0:
even.append(x)
else:
odd.append(x)
nums[:] = even + odd
return nums
这段代码的时间复杂度是 O(n),空间复杂度也是 O(n),因为额外创建了两个列表。用 Python 写还可以更简短:
python复制nums[:] = [x for x in nums if x % 2 == 0] + [x for x in nums if x % 2 == 1]
这个版本的优点是绝对正确、逻辑清晰,而且它还意外地满足一个原题没有要求的性质:偶数之间的相对顺序、奇数之间的相对顺序都能保持。也就是说,它是一个“稳定分区”。如果面试官后续问你“能不能保持相对顺序”,你把这个版本甩出来就是一个现成的正确解。
2.2 一个在 OJ 上能过、在面试里必挂的低级失误
很多人写最后一步时会写成:
python复制nums = even + odd
return nums
在函数内部,这行代码只是把局部变量 nums 重新指向了一个新列表,函数外部的原数组一点儿没变。如果你的面试题目是“返回一个新数组”,这样倒没问题;但原题要求的是“把原数组重排”,LeetCode 这类 OJ 会直接比较你返回的列表,所以即使你写错了也能过,可白板面试不是这样的。
正确写法是用切片赋值:
python复制nums[:] = even + odd
nums[:] 会修改原列表对象的内容,而不是让 nums 指向新对象。这个区别我用一句话总结:nums = 是换了个盒子,nums[:] = 是把盒子里的东西换掉。面试时函数需要原地修改数组,写切片赋值最稳。
2.3 辅助数组方案的真正局限
辅助数组最大的问题不是慢,而是“没有体现算法思维”。如果你能写出双指针方案,这段辅助数组代码只是你确认题意的过程;如果只能写出辅助数组,面试官可能会觉得你对“原地修改”的练习还不够。
不过我必须强调一点,笔试场景里辅助数组方案不一定吃亏。时间 O(n)、空间 O(n) 本身并不差,而且代码出错率远低于双指针。除非题目明确要求 O(1) 空间,否则我建议你把两个版本都准备好,开场先写辅助数组,再顺势提出“我可以原地优化成 O(1) 空间”,这反而是一个不错的表达节奏。
3. 双指针写法不少,本质都是快排里的 partition
双指针解法有好几种,左右对撞、快慢交替、单指针游标。别看它们在代码层面长得不太一样,核心思想只有一个:把数组分成左边一段和右边一段,左段满足“是偶数”,右段满足“是奇数”,两段中间用一个指针记录分界位置。这其实就是快速排序 partition 的降维版本,只是基准值变成了“偶数为真”这个条件。
3.1 左右对撞双指针:最容易理解的原地写法
维护两个指针,left 从数组头部向右走,right 从尾部向左走。left 停下来时说明它指到了奇数,right 停下来时说明它指到了偶数,然后交换这两个位置上错位的元素。不断重复直到两个指针相遇。
python复制def sort_by_parity(nums):
left, right = 0, len(nums) - 1
while left < right:
# 左指针停在奇数上
while left < right and nums[left] % 2 == 0:
left += 1
# 右指针停在偶数上
while left < right and nums[right] % 2 == 1:
right -= 1
if left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
这里每一步操作我都写了 left < right 作为内层条件,没写就会越界,这块下一节详细说。看一个实际过程,输入 [3,1,2,4]:
left=0停在奇数 3,right=3停在偶数 4,交换,数组变成[4,1,2,3]left=1停在奇数 1,right=2停在偶数 2,交换,数组变成[4,2,1,3]left=2,right=1,循环结束
最终得到 [4,2,1,3],正好是原题示例中可接受的答案之一。
3.2 快慢交替写法:我第一次写就是在做“移动零”时养成的
另一种很常见的写法是单指针游标配合一次遍历,我习惯叫它快慢指针。它维护一个 slow,表示“下一个偶数应该放到的位置”,然后让 fast 从头到尾扫描,碰到偶数就把它和 slow 位置的元素交换,然后 slow += 1。
python复制def sort_by_parity(nums):
slow = 0
for fast in range(len(nums)):
if nums[fast] % 2 == 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
return nums
这个写法本质上是“稳定地把所有偶数往前搬,奇数自然被挤到后面”,代码比左右对撞更短,也不会遇到两个内层 while 边界错误的问题。用 [3,1,2,4] 跑一遍:
fast=0,3 是奇数,跳过fast=1,1 是奇数,跳过fast=2,2 是偶数,和slow=0交换,数组变成[2,1,3,4]fast=3,4 是偶数,和slow=1交换,数组变成[2,4,3,1]
3.3 三种方案的取舍对比
写到这里,你可能想知道面试时到底用哪种。我给一张对比表,结合我的实际经验来说:
| 方案 | 时间复杂度 | 额外空间 | 相对顺序 | 适用场景 |
|---|---|---|---|---|
| 辅助数组两遍扫描 | O(n) | O(n) | 稳定 | 第一版保底、要求稳定 |
| 左右对撞双指针 | O(n) | O(1) | 不稳定 | 原题标准答案,内存要求严格 |
| 快慢指针单游标 | O(n) | O(1) | 偶数顺序保留,奇数顺序不一定 | 代码短,适合笔试快速提交 |
大部分标准答案都会用左右对撞双指针,因为它最直观地展示了“一头找错的、另一头找另一个错的、交换”的模板,这个模板后续迁移到三路快排、颜色分类问题时基本不用改。快慢指针写法短,但如果你正在面试,被追问到“为什么这个写法会改变同一组内部的顺序”时,回答难度会略高一点。
4. 最容易写错的边界:内层到底要不要加 left < right
“按奇偶排序数组”的讨论区里,最常见的一类错误不是思路不对,而是边界判断漏了。这个问题我在帮人 review 代码时见过太多次了,值得单独拿出来讲。
4.1 一段看着没问题、跑起来就崩的代码
很多人第一次写的版本是这样的:
python复制def sort_by_parity(nums):
left, right = 0, len(nums) - 1
while left < right:
while nums[left] % 2 == 0:
left += 1
while nums[right] % 2 == 1:
right -= 1
if left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
从代码逻辑上看,这个结构很合理:外层有 left < right 保护,先找需要交换的两个错位元素,再交换。问题出在内层两个 while 没有同步判断 left < right,极端情况下会把指针推出数组边界。
4.2 用最极端的输入复现一遍
假如输入 [2,4,6,8],也就是全偶数。外层 while 进入时 left=0、right=3。内层第一个 while 检查 nums[0] % 2 == 0,成立,left 加 1,变成 1;继续检查 nums[1],也是偶数,left 再加 1;一直加到 left=4,此时已经越过数组末尾,下一步访问 nums[4] 就会抛索引越界。
反过来,如果输入 [1,3,5,7],全奇数。内层第二个 while 会不断让 right 减 1,一直减到 right=-1 才停,同样越界。
还有一种稍隐蔽的问题:没有内层边界检查,但外层判断还没失效,这时 left 和 right 可能已经错位了,仍然执行交换,把已经排好的元素又打乱。比如 [2,4,1,3],它本来就满足偶前奇后,但因为内层循环不加 left < right,指针会一路冲过头,最后在错位的位置上做一次多余的交换,反而把数组改错了。
4.3 靠“循环不变式”一次写对,而不是靠背代码
要彻底避免上面这些边界问题,靠死记“内层要加 left < right”是不够的,因为你换个题型可能又忘了。我建议在写这类双指针时,先在草稿纸上写出两条不变式:
- 循环开始时,位置
[0, left-1]里面的元素已经全部是偶数; - 位置
[right+1, n-1]里面的元素已经全部是奇数。
每次循环结束都要保证这两条仍然成立。基于这个不变式推演:
左指针 left 负责扩展第一条,即从左往右找到一个“不属于左边区域的奇数”。如果它不满足“偶数”条件,就停下,准备交换。右指针 right 负责扩展第二条,即从右往左找到一个“不属于右边区域的偶数”。如果它不满足“奇数”条件,就停下。
但指针自己不能跑到对方的区域外面去。由于外层条件是 left < right,一旦 left 追上了 right,说明中间已经不存在待交换元素,此时所有元素都已归位。所以内层两个 while 在移动时也要受 left < right 约束,这既是为了防越界,也是为了保证不越过已经整理好的区域。
我把这个完整版本再贴一次,你可以直接当模板记:
python复制def sort_by_parity(nums):
left, right = 0, len(nums) - 1
while left < right:
while left < right and nums[left] % 2 == 0:
left += 1
while left < right and nums[right] % 2 == 1:
right -= 1
if left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1
return nums
这套“不变式”思维比代码本身值钱得多。以后你写快速排序 partition、写数组去重、写颜色分类,只要遇到“双指针维护两段性质”,都可以先把这两句话写出来再动手。
5. 稳定性的分水岭,很多题解一句话带过的地方
如果面试题真的只有“偶数在前奇数在后 + 任意顺序”,那上面双指针就是终点。但优秀的面试官不会停在原题,他会追问一句:“如果要求保持偶数之间、奇数之间原来的相对顺序,怎么写?”你如果只会双指针,大概率会卡住,因为双指针本身就是不稳定分区。
5.1 双指针到底在哪里破坏了稳定性
拿左右对撞版本跑一个输入 [3,1,2,4]:
- 原始奇数顺序是 3、1,原始偶数顺序是 2、4
- 第一次交换把 3 和 4 交换,数组变成
[4,1,2,3] - 第二次交换把 1 和 2 交换,数组变成
[4,2,1,3]
最后奇数在结果里出现的顺序是 1、3,和原来的 3、1 完全反了。原因很简单:交换会把一个横跨很远的元素挪到前面,这个元素会“跨过”一堆原本排在自己前面的同类元素,使同类元素之间的先后关系被打乱。
快慢指针版本稍微特殊一点,它会把偶数按原顺序放到前面,但奇数块的顺序不一定能保住。比如 [3,1,2,4] 跑快慢指针得到 [2,4,1,3],偶数的 2、4 保持原顺序,但奇数的 1、3 却相对原来的 3、1 反了。
5.2 稳定的解法其实有好几个层次
如果离开“原地”,最简单的稳定解法是辅助数组两遍收集,第一遍把偶数按序放进新数组,第二遍把奇数按序追加进去,之后 nums[:] = result,时间 O(n),空间 O(n)。这个解法我在第 2 节已经写过,它天然就是稳定的。
如果面试官要求“尽量原地”,那事情就变得麻烦起来。对数组做稳定分区且做到 O(1) 额外空间,其实是一个比较复杂的问题,普通的做法是用类似插入排序的思想:从左往右扫描,遇到偶数时,把它往前挪,一直挪到前面连续奇数段的最前面,也就是紧挨着前一个偶数的位置。
python复制def sort_by_parity_stable(nums):
n = len(nums)
for i in range(1, n):
if nums[i] % 2 == 0:
j = i
tmp = nums[i]
while j > 0 and nums[j - 1] % 2 == 1:
nums[j] = nums[j - 1]
j -= 1
nums[j] = tmp
return nums
这个实现的时间复杂度最坏是 O(n^2)。拿 [1,3,5,2] 来说,2 要跨过前面三个奇数才能到头部;如果数组是 [1,3,5,2,4,6,8],后面的每个偶数都要跨过很长一段奇数,整体会很慢。这个方案在面试中用来展示“我理解稳定分区与原地操作之间的代价”,比你只会辅助数组方案要加分不少。
5.3 Python 内置 sort 反而给出了一个简短答案
在 Python 里,你甚至可以一行写掉稳定版本的按奇偶排序:
python复制nums[:] = sorted(nums, key=lambda x: x % 2)
Python 的排序算法 Timsort 是稳定排序,key=lambda x: x % 2 会把偶数映射成 0,奇数映射成 1,所有偶数排前面,奇数排后面;又因为排序稳定,相同 key 的元素保持原来的相对顺序。时间 O(n log n),空间 O(n),虽然不是最优复杂度,但在工程上足够优雅。
不过提醒一下,如果你正在参加算法面试,我不建议拿这行代码当主要答案,因为这道题期望的是你写 O(n) 的 partition。你可以把它作为追加知识提一句:“如果语言内置稳定排序,那么用一行 key 函数也能稳定完成,只是复杂度不如双指针”,这样能体现出你对语言特性和算法复杂度的双重敏感。
6. 从这道题延伸出去的几个高频变体
“把满足某种条件的元素放到前面”这个操作太常见了,以至于算法题稍微改一下条件就能变成新题。这块我整理几个最常出现的变体,它们和你刚写的按奇偶排序是同一个套路。
6.1 升级版:按奇偶排序数组 II
LeetCode 922 给的条件更硬核:不仅偶数在半边、奇数在半边,而且偶数必须落在偶数下标(0、2、4…)上,奇数必须落在奇数下标(1、3、5…)上。输入保证数组里偶数和奇数的数量相等,或者只差一个。
思路其实还是双指针,只是步长从 1 变成了 2。一个 even 指针专门检查偶数下标,另一个 odd 指针专门检查奇数下标,发现放错位置的两个元素就交换。
python复制def sort_array_by_parity_ii(nums):
n = len(nums)
odd = 1
for even in range(0, n, 2):
if nums[even] % 2 == 1:
while nums[odd] % 2 == 1:
odd += 2
nums[even], nums[odd] = nums[odd], nums[even]
return nums
这里 odd 指针跳到奇数下标并检查当前位置是不是偶数,如果是偶数就说明它应该被换到偶数下标去;因为题目保证了奇偶数个数匹配,所以 odd 一定能找到一个合适的位置。
6.2 降级版:移动零问题
LeetCode 283 的“移动零”也是这个套路。把数组里的 0 移到末尾,同时保持非零元素的相对顺序。它可以看成按奇偶排序的变体:谓词从“是偶数”变成“不是 0”。
python复制def move_zeroes(nums):
slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
return nums
如果你换过一种问题来理解,比如“把所有负数放在前面,正数放在后面”“把所有能被 3 整除的数放在前面”,你甚至可以抽出一个通用函数:
python复制def partition_by(nums, predicate):
slow = 0
for fast in range(len(nums)):
if predicate(nums[fast]):
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
return nums
只要给 predicate 传一个 lambda x: x % 2 == 0,就又变回了按奇偶排序。这个抽象是我想重点强调的“算法思维”。
6.3 再往上一步就是三路快排
颜色分类问题把元素分成红色、白色、蓝色三类,要求按照红、白、蓝的顺序排列。它需要三个指针,思路从“满足条件放左边、不满足放右边”升级成“小于基准的放左边、等于基准的放中间、大于基准的放右边”。这就是三路快速排序的核心 partition。
如果你能把按奇偶排序理解成二路 partition 的一个特例,那么你在理解三路 partition 时会非常顺:双指针变三指针,边界条件从两个 while 的越界问题变成三个 while 的越界问题,但只要还守着“某一侧已经处理完”的不变式,写出来不会歪到哪里去。
6.4 工程里我反而更推荐稳定分区
在刷题环境下,我们当然更关心时间和空间复杂度;但在真实业务里,“稳定性”常常比“原地 O(1)”更重要。举个例子,订单列表已经按付款时间排好,现在产品要求线上订单在前、线下订单在后,但同一渠道的订单仍要按付款时间排序。这时候你不能随便交换位置,只能做稳定分区。于是,工程上最实用的反而不是双指针,而是一次遍历生成两个列表再合并——简单、好读、不容易错。
这就是为什么我反复提到稳定性,它在算法题和业务代码之间划出了一条分界线:不要求稳定时,双指针是漂亮的答案;要求稳定时,最简单的辅助数组方案反而常常是更负责任的答案。
7. 过了这道题之后,我留下的几条实战经验
最后说几个我在实际刷题和面试里积累的小经验。它们不全是这道题本身的内容,但每一条都来自踩坑,你可以直接拿去用。
第一,看到题先问一句话:“奇数之间、偶数之间的相对顺序有没有要求?”如果是面试,这能展示你考虑问题周全;如果是自己刷题,也能
