
1. 题目到底想考什么先看清需求再动手“剑指offer-68、调整数组顺序使奇数位于偶数前面二”别看题目不长它在面试题里算是很典型的“看起来简单、做起来容易翻车”的题目。核心场景是这样的给你一个整数数组要求把所有奇数放到前面偶数放到后面并且相对顺序要保持稳定——也就是说原本在前面的奇数调整后依然在前面原本在前面的偶数调整后依然在前面。这里括号里的“二”其实暗示了它和基础版本的区别基础版只要求“前奇后偶”的区间分布不要求相对顺序而进阶版多了一个稳定性的约束难度一下就上来了。这个问题本身的价值在哪它考的绝不只是“你会不会写两次遍历”而是三件事第一你有没有理解稳定排序的意义第二你手上有没有不止一种解法第三你写代码时对边界条件的敏感度怎么样。很多人在面试中五分钟搞定一个双指针交换版本结果面试官追问“如果要求保持相对顺序怎么办”当场发懵。所以这篇文章我想把这个题从暴力、优化、稳定版本到泛化扩展一层层拆开讲清楚每个方案背后的取舍和适用场景。适合谁看准备算法面试的同学、复习数组操作的朋友以及工作中遇到“按条件重新分区但又不能打乱原顺序”这类需求的开发人员。不管你是刚接触算法题的初学者还是刷题已经有一段时间的进阶选手这篇文章都能给你提供可直接复用的思路和代码。2. 解题思路拆解为什么这个问题不简单2.1 先删掉最笨的办法额外数组遍历最容易想到的办法当然是创建两个临时数组一个装奇数一个装偶数然后依次遍历原数组判断每个元素的奇偶性分到对应数组里最后把“奇数数组 偶数数组”合并回来。这个方案的正确性没有任何问题时间复杂度和空间复杂度都是O(n)逻辑几乎是直白的。我见过很多人第一次上手就是这种写法面试时也不会被判错。它能保证相对顺序对于“二”这个带稳定性要求的版本这其实是一个完全可用的答案。但问题是这个解法太“无脑”了。面试官后续一定会追问“能不能在O(1)额外空间下完成”这时候如果只会这一种解法场面就会比较尴尬。从学习角度说这种解法真正价值在于验证你对题意的理解——它把问题拆成了“分区 合并”两个子问题。先跑一遍实现确保自己理解没有偏差再往下深挖更好的解法这样的学习路径反而是效率最高的。为什么我会先提这个简单版本因为在实战中最快的解法不一定是最好的但最稳的解法往往能帮你先拿下一道题的基础分。尤其在笔试环节时间紧张的情况下一个O(n)空间、O(n)时间的解法已经能解决大多数判题用例剩下的优化属于锦上添花而非雪中送炭。2.2 双指针头尾交换快但是不稳定再看经典的“头尾指针交换法”。定义两个指针left从数组头部开始right从尾部开始left向右移动直到遇到偶数停下right向左移动直到遇到奇数停下然后交换这两个位置的元素继续循环直到left right。这个方案的时间复杂度是O(n)空间复杂度是O(1)而且思路非常经典在很多教材里都被当作例题讲。关键在于这种方案是不稳定的。举个例子假设数组是[1, 2, 4, 3, 5, 6]。left先指向索引1值为2right从尾部左移到索引4值为5交换后数组变成[1, 5, 4, 3, 2, 6]。然后left继续右移到索引2值为4right继续左移到索引3值为3再交换数组变成[1, 5, 3, 4, 2, 6]。你看原本在索引1位置的偶数2被交换到了索引4位置原本在索引4位置的奇数5被换到了索引1。奇数和偶数的“块”是分开了但奇数之间的相对顺序已经乱了原本3在5后面现在3在5前面。偶数之间也一样原本2在4前面现在2在4后面。所以这个经典解法只适用于基础版不能直接拿来做“二”。如果你面试时只写出这个版本却没有主动提稳定性问题面试官一旦追问就会很被动。我建议的思路是先讲双指针版本能解决“不要求稳定”的版本然后主动指出这个方案的稳定性缺陷再引出稳定版本的实现这样整个回答的层次感就出来了。2.3 直接找稳定版本的核心矛盾稳定版本的核心矛盾在于既要O(1)额外空间又要保持相对顺序。数组的“原地”操作本身就很容易破坏顺序因为你在交换元素时跨越了距离。要在不借助额外数组的情况下保持稳定性最直观的思路是“把偶数往后挪把奇数往前插”。具体怎么做维护一个变量oddTail表示已经处理好的奇数区间的末尾位置。然后从左到右遍历数组遇到奇数时把它前面的所有偶数整体向后移动一个位置然后把当前奇数放到oddTail的位置oddTail加1。这个过程类似于“插入排序”的局部挪移最坏时间复杂度为O(n^2)空间复杂度仍为O(1)。但要注意题目要求“二”版本的复杂度如果没有额外说明通常希望你能给出更优策略。好在稳定的O(n)版本也有那就是借用额外数组的那个方案——它用O(n)空间换来了稳定性。所以这个问题的本质其实是时间、空间、稳定性三者之间的取舍没有绝对最优只有根据约束条件选一个平衡点。3. 手写实现一步一步把稳定版代码写出来3.1 Python 实现冒泡思想原地稳定版先上代码。这个实现适合在 O(1) 空间要求下保持稳定性我把它叫“局部腾挪法”。def reorder_odd_even_atable(nums): if not nums or len(nums) 1: return nums n len(nums) # odd_tail 表示当前已放置好的奇数的下一个位置 odd_tail 0 for i in range(n): if nums[i] % 2 1: # 把当前奇数从位置 i 移动到 odd_tail 位置 # 先把 nums[i] 保存下来 cur nums[i] # 将 [odd_tail, i-1] 区间整体右移一位 for j in range(i, odd_tail, -1): nums[j] nums[j - 1] nums[odd_tail] cur odd_tail 1 return nums过程很好理解从左往右扫描遇到奇数时它前面如果有一串偶数就整体往后挪一位给这个奇数腾出一个位置来。这样每次插入奇数时都不会跨过其他奇数所以相对顺序一定保持稳定。试着跑一下[2, 4, 1, 3]。初始化 odd_tail 0。i0nums[0]2偶数跳过。i1nums[1]4偶数跳过。i2nums[2]1奇数保存cur1然后将位置1和0的元素都往后挪挪完后数组变成[2, 4, 4]把cur放到位置0数组变成[1, 2, 4]odd_tail变成1。i3nums[3]3奇数保存cur3将位置2和位置1的元素4和2依次后移数组变成[1, 2, 2, 4]把cur放到位置1得到[1, 3, 2, 4]。可以看到奇数1和3的相对顺序没变偶数2和4的相对顺序也没变——稳定达成。这个方案最坏情况下逆序数组所有奇数都在所有偶数后面会触发显著挪移时间复杂度接近O(n^2)。如果面试官明确要求O(n)时间那这个方案就不够看了。3.2 Python 实现额外数组稳定版时间优先需要O(n)时间时用额外数组。代码非常干净def reorder_odd_even_stable(nums): if not nums or len(nums) 1: return nums odds [] evens [] for x in nums: if x % 2 1: odds.append(x) else: evens.append(x) return odds evens这段代码虽然简单但它的关键在于遍历一次数组奇数依次放入odds列表偶数依次放入evens列表天然保留了稳定特性。最后拼接两个列表就完成了。这种方式在工程上非常常见因为很多真实业务里数组规模不大空间充裕稳定性和代码可读性比那一点额外内存更值钱。3.3 C 实现原地版与标准库风格如果你用C打比赛或者面试原地挪移版可以这么写void reorderArray(vectorint nums) { int n nums.size(); int oddTail 0; for (int i 0; i n; i) { if (nums[i] 1) { // 位运算判断奇数效率更高 int cur nums[i]; for (int j i; j oddTail; j--) { nums[j] nums[j - 1]; } nums[oddTail] cur; } } }这里额外想提一个位运算的小知识判断一个整数是否为奇数用x 1比x % 2更快。因为取模运算符在底层涉及除法运算而按位与直接对最低比特位做判断性能更好。在算法题里这种微优化可能影响不大在真正的大循环高频调用场景里差距会比较明显。这也是热词里提到“奇数字节”相关问题的观察角度之一——字节是8位最低位为1就是奇数为0就是偶数和整数判断本质是一致的。4. 泛化扩展从奇偶判定到任意划分函数4.1 用高阶函数封装判奇偶逻辑好的代码不止要解决一道题还要能复用。也许明天需求就变成“把所有负数放前面”、“把所有能被3整除的放前面”、“把所有质数放前面”——如果每次重写整个排序逻辑那就是重复劳动了。所以更好的方式是把这个判断条件抽象成一个函数参数我们把核心排序逻辑和高层判断逻辑解耦。先定义一个基础接口from typing import List, Callable def reorder_with_condition(nums: List[int], should_move_front: Callable[[int], bool]) - List[int]: if not nums or len(nums) 1: return nums result [] # 第一轮收集满足条件的元素 for x in nums: if should_move_front(x): result.append(x) # 第二轮收集其余元素 for x in nums: if not should_move_front(x): result.append(x) return result def is_odd(x: int) - bool: return x % 2 1调用方式reorder_with_condition(nums, is_odd)。以后想改规则比如要负数在前就可以写lambda x: x 0再传给同一个函数。这个设计的好处是排序逻辑本身的稳定性由函数内部保证无论判断条件怎么换都不会影响正确的相对顺序。如果你追求更严谨的工程化写法还可以把它写得和 C 的std::stable_partition对齐。实际上 C 标准库就有这个函数专门做稳定分区内部实现就是类似思路只是它针对的是任意迭代器区间。面试时如果提到自己熟悉std::stable_partition并和这道题联系起来会是个加分项。4.2 变体负数在前、被3整除的在前等我看到很多资料里把这道题当作“奇偶划分”但它的本质其实是“二分分区”。我们只需改变判定函数就能派生出一整个题目家族。比如“把负数放在非负数前面且保持相对顺序稳定”。测试数据 [-3, 4, -1, 0, 5, -2]。期望结果应该是 [-3, -1, -2, 4, 0, 5]。写法就是把should_move_front换成x 0。没有任何其他变化。再比如“把能被3整除的放在不能被3整除的前面保持稳定”。输入 [6, 2, 9, 4, 3, 1]期望输出 [6, 9, 3, 2, 4, 1]。同理。这种变体题在面试中出现频率极高因为它能从基础题延伸出大量子问题侧面考察代码的可扩展性。我在实际面试候选人时经常先让候选人解决奇偶问题然后立刻追加“如果换成负数在前呢”如果候选人答“再写一个函数”那我会继续问“那如果每个星期换一个规则你怎么设计”这时候能说出“把判断条件抽成参数”的候选人代码能力明显强一档。4.3 从数组扩展到字符串或字节流的奇偶处理热词里有个有趣的点“为什么socket接收到奇数字节后面会补一个随机数”。这个问题虽然不是剑指offer原题但它背后的思维方式完全一致——我们不仅要对数组里的整数做奇偶判断还会对字节流、数据包、文件内容做各种基于奇偶性的处理。我简单解释一下socket通信中如果应用层协议要求数据按固定长度对齐比如偶数长度封包而底层传来的数据刚好是奇数字节可能需要在末尾补填充字节这个填充值有时候是零有时候是随机数——这取决于协议定义。如果你把每个字节看成一个小整数那么“判断奇偶”就是用byte 1 1来判断。所以这也算是奇偶判断在工程领域的实际落地能让我们把算法题和真实业务场景串起来。热词里还有“如何在一列excel数据中提取奇数列数据”。这个问题本质上也是奇偶判定把列索引拿出来判断列号除以2的余数是否为1。可以用Excel的MOD(COLUMN(), 2)1也可以写个简单脚本按列遍历。同样的判定逻辑用在数组索引、列号、字节上抽象层级不同思维模型相同。5. 常见问题与排查技巧实录5.1 边界条件吃大亏空数组、单元素、全是奇数、全是偶数不管哪种解法边界条件都是最容易扣分的地方。先说空数组和单元素数组很多代码在遍历前没判空直接就用下标访问轻则报错重则越界。我的习惯是任何数组类算法题开头三行先处理空和长度小于等于1的情况这样后续逻辑可以假设数组至少有两个元素思维负担小一些。再说全奇数和全偶数的情况。测试时要专门验证如果全是奇数odd_tail会一路增长到n最终数组不变如果全是偶数odd_tail一直是0数组也不变。这两种情况都不该报错且结果要和原数组完全一致。书写时要注意odd_tail只在遇到奇数时才增加所以全奇数时会正确递增不会越界全偶数时odd_tail保持0也不会异常。这里最容易出错的其实是“腾挪”版本当odd_tail和i相等时内层循环不需要执行直接把奇数放到当前位置这就是天然的正确行为。我曾经见过有人为了省掉这个判断添加额外的复杂度反而弄出了Bug。5.2 稳定性验证交换法写完后拿小样本逐条核对关于稳定性我建议写完代码后不要急着提交先拿一个小的反例数组在纸上走一遍。比如[1, 4, 3, 2, 5]如果目标结果是[1, 3, 5, 4, 2]那就说明奇数之间相对顺序未变偶数之间也未变。双指针交换法跑出来的可能是[1, 5, 3, 4, 2]奇数5和3的顺序反了一下就露馅了。这个验证过程很值钱因为面试官不一定每次都会提醒你验证稳定性但一旦结果和预期不一致一眼就能看出问题。我在实际刷题时也常常碰到一种现象在网上搜题解发现有些博客给出的“指针交换法”被误称为稳定算法。这是不对的。要分清楚“快排分区”和“stable_partition”的区别。快排自身是不稳定的而stable_partition专门保证稳定性。所以看到任何说“双指针法稳定”的文章都要留个心眼。5.3 面试的坑别在没审清要求时用错方案面试中最大的坑是没听清楚题目的限制条件就开写。基础版如果只要求“前奇后偶”那双指针交换法是最优解进阶版加了“相对顺序稳定”双指针交换法直接不合格如果再加“时间复杂度O(n)”那么原地腾挪版也出局了只能选额外数组版。所以拿到题目先复述一遍“您是说需要奇数都在偶数前面同时保持它们原有相对顺序对吗是否有空间复杂度限制”这种确认看起来琐碎实际上反而是专业度的体现。顺便说一句我在面试中见过不少候选人在这道题上栽跟头不是因为代码写得有问题而是因为对整个题目的变体没有体系化认知。如果你能把“基础版、稳定版、O(n)时间版、泛化函数版”全部讲一遍基本上这道题就拿下了。我在实际面试候选人时经常先让候选人解奇偶问题然后立刻追加“如果换成负数在前呢”如果候选人答“再写一个函数”那我会继续追问“那如果每个星期换一个规则你怎么设计”这时候能说出“把判断条件抽成参数”的候选人代码能力明显强一档。5.4 常见问题与排查速查表问题场景典型原因排查与修复方案输出中奇偶相对顺序被打乱使用了双指针交换法且未做稳定处理改用局部腾挪法或额外数组法空数组/单元素报错未在开头判空函数开头加if not nums or len(nums) 1: return nums全奇数或全偶数组结果出错odd_tail 逻辑在边界下的处理不当检查 odd_tail 是否只在遇到奇数时递增步进不能越过数组长度时间超限原地腾挪法在逆序数组上的最坏O(n²)改用额外数组法把时间压到O(n)判断负数/整除等变体时错误判断规则写死在代码里将判断抽象为should_move_front函数参数对x % 2和x 1结果不一致有疑惑C/Java中负数取模的特性Java用(x 1) 1判断奇数Python用x % 2 1即可关于“负数取模”那一条我要特别提醒一下。Java里-3 % 2的结果是 -1 而不是 1如果你用x % 2 1来判断奇数负数奇数会被误判为偶数。这就解释了为什么很多 C/Java 代码里判断奇数都用x 1而不是x % 2 1。我在实际面试中见过不止一次候选人在这个问题上踩坑。Python 的取模行为是向负无穷取整-3 % 2 1成立所以用 1没问题。写代码时一定要先明确语言特性再用合适的写法。这个细节看似很小但足以让测试样例直接翻车。6. 扩展与延伸奇偶判断在真实场景中的那些变体6.1 奇偶排列组合比如“求07所能组成的奇数个数”怎么想热词里有一个挺有意思的问题“求07所能组成的奇数个数”。我猜这里的意思是给定数字0和7用它们组成若干位数可能每位可重复也可能需要排列求能组成多少个奇数。这个问题的核心依然是奇偶判断——奇数必须有1、3、5、7、9等奇数做末位。在这个给定集合里只有7是奇数所以能组成的奇数个数取决于末位固定为7时前面位数的排列方式有多少种。如果每位可从0和7中选且末位为7那么前面每一位都有2种选择总数为2^(n-1)。和数组调整问题相比同样用到“末位奇偶决定整体奇偶”这一直觉只是从“数组位置分区”变成了“数字组合计数”。这种交叉联想对面试特别有帮助因为面试官一旦考察“奇数”相关话题可能从完全不同的角度出题数组分区、组合计数、字符串数字验证……所有题目背后都是奇偶性这一基础数学性质。如果我们能熟练地从一个题目迁移到另一个题目的判断逻辑举一反三的能力就体现出来了。6.2 奇偶判断用于ASCII、字节补位和数据筛选热词里还提到了“任意输入一个字符判断其ascii是否是奇数若是输出yes否则输出no”。本质上就是ord(char) % 2 1或ord(char) 1 1。字符的ASCII码是个整数最低比特位是1时它就是奇数。这个题的思路和剑指offer这道数组题其实共用同一个“奇偶判定”引擎。“如何在一列excel数据中提取奇数列数据”同样是把列索引做奇偶判断。用Excel公式可以做比如用辅助列MOD(COLUMN(), 2)筛选输出结果为1的列或者用Python的pandas按df.columns[::2]来选奇数列注意索引从0开始时的偏移。这让我想到很多人以为算法题只存在于面试题集里其实奇偶判定在后端数据处理、报表生成、字段对齐等业务中非常常见。6.3 稳定性需求在工程场景中的真实案例最后补充一个工程场景。假设你在维护一个订单列表每个订单有一个状态码需要把所有“已支付”状态的订单排在“未支付”之前且每个状态内部依然按时间先后排序。如果直接使用不稳定的快排分区用户会看到订单顺序被随机打乱体验极差。而使用稳定分区状态调整后原有的时间顺序依然保留这就是stable_partition的意义。因此剑指offer这道题并不是单纯的刷题游戏它在真实业务中的映射比比皆是。7. 总结与自己的心得这道“调整数组顺序使奇数位于偶数前面二”我刷过很多遍每次都有新的体会。当初我刚开始刷题时只会写额外数组版觉得这题实在简单后来面试时被问到“能否原地且稳定”才意识到自己远远没吃透。我自己在实际项目中最常用的是“判断条件函数化 两次遍历”的组合方案。原因很简单稳定性有保障时间复杂度O(n)可控而且代码可读性非常高团队成员接手时没有理解成本。只有在硬性要求“O(1)额外空间”时我才会用局部腾挪版并接受O(n²)的最坏时间。最后再分享一个小技巧平时刷题时可以把同一道题的多个变体整理在一个文档里每个变体只改判定函数那一行。比如奇偶版、正负版、整除版、质数版全部用同一个框架去套。这样练习一个月后你再看到任何“把满足X条件的元素放前面”的题目基本不用想直接写因为你的代码结构已经把判断逻辑抽象好了。这种抽象能力比会背一道题的解法重要得多。