“数组极值交换编程挑战”这名字,听起来就是个练手的小题目:给你一个数组,把里面最大的数和最小的数换个位置。我第一次见到它是在一次模拟面试的题库里,当时觉得三分钟就能写完,结果真到白板上动手,才发现里面藏了不少坑。这个题目很适合刚学完数组、循环、函数的朋友用来巩固基础,也很适合用来自测编码习惯——比如是否记得保存索引而不是值,是否处理了重复极值,是否能从两次扫描优化成一次扫描。这篇文章就从这个挑战展开,从题目拆解、解法演进、多语言实现、边界问题一直聊到扩展玩法,希望能给你一份可以直接照着练的完整参考。
1. 题目拆解:极值交换到底在考什么
1.1 第一反应:找值、记位置、再交换
把数组极值交换拆开,本质上只有三个动作:找到最大值、找到最小值、把这两个位置上的元素对调。举个例子,数组[3, 1, 2],最大值是 3,在索引 0;最小值是 1,在索引 1。交换之后得到[1, 3, 2]。看起来就是这么简单,但很多第一次写这道题的人,动手之后会在“记录最大值和最小值”这一步跑偏。
我见过不少初学者第一版代码长这样:先定义max = arr[0],min = arr[0],遍历的时候不断更新这两个变量。等遍历结束,确实得到了最大值和最小值的数值,但接下来就卡住了——该怎么交换?直接写arr[max] = arr[min]显然是错的,因为max是数值,不是数组下标。这个瞬间的犹豫,恰恰说明了这道题真正想考察的第一个点:在做位置交换时,必须保存的是下标,而不是数值本身。
1.2 为什么保存索引比保存值更重要
假设数组是[5, 2, 9, 1],最大值是 9,最小值是 1。如果只保存数值,你依然可以“手动”知道它们的位置是索引 2 和索引 3,但程序并不知道。更麻烦的情况是数组里有重复元素,比如[5, 9, 1, 9]。最大值 9 出现了两次,那么“交换最大值和最小值”到底该交换哪一个 9?如果只保存值 9,程序无法区分两个 9 的位置;如果保存索引maxIdx = 1,含义就非常明确:我们选定的是第一次出现的那个最大值。
进一步想,在真实项目中,数组元素往往不是简单的整数,可能是对象、结构体、字符串。数值相同的两个对象,内部字段可能完全不同。这时候“保存值再交换”连基本思路都算不上,只能是错误。所以从一开始就要养成习惯:遍历过程中维护的是maxIdx和minIdx,而不是max和min。这不仅是这道题的要求,也是所有涉及数组位置操作问题的通用经验。
1.3 面试官不会明说的隐藏考点
这类题目在面试里出现频率不低,它不会直接决定你能不能拿 offer,但能帮助面试官快速判断你的编程基本功。它至少覆盖了这些隐藏考点:
- 能否想到用索引记录极值位置。
- 能否正确处理空数组、单元素数组、全相同元素数组。
- 能否在
O(n)时间内完成,而不是去排序。 - 能否在原数组上原地交换,同时不引入额外大数组。
- 能否主动沟通歧义,比如重复极值应该交换哪一个。
前三点是硬技能,最后一点是软技能。很多开发者在拿到题目后,会默认“最大值取第一个、最小值取第一个”,或者默认“可以改动原数组”,但这些问题在题面没有明说。能够把这些条件问清楚的人,通常代码反而写得更稳,因为测试用例也更容易设计。
2. 从两遍扫描到一遍扫描:解法演进与复杂度
2.1 最直接的两次遍历版本
第一版解法是顺着直觉写的:先遍历数组找到最大值的索引,再遍历数组找到最小值的索引,最后交换。
def swap_extremes(nums): if not nums: return nums max_idx = 0 for i in range(1, len(nums)): if nums[i] > nums[max_idx]: max_idx = i min_idx = 0 for i in range(1, len(nums)): if nums[i] < nums[min_idx]: min_idx = i nums[max_idx], nums[min_idx] = nums[min_idx], nums[max_idx] return nums这个版本逻辑清晰,几乎不会写错。两遍扫描的时间复杂度是O(2n),也就是常数意义上的两次遍历。对于几百万长度的数组,这多出来的一遍扫描大约会让耗时翻倍。在一些对性能敏感的场景下,能少一次遍历就少一次。不过这版代码最大的意义不在于性能,而在于它把“找最大”“找最小”“交换”三个步骤拆得清清楚楚,非常适合作为理解基线。
2.2 一次扫描同时定位两个极值
很快你会发现,找最大值和找最小值并不需要分别跑两次循环,它们可以在同一轮遍历中完成。每一次拿到当前元素,只要它比已知的最大值还要大,就更新maxIdx;只要它比已知的最小值还要小,就更新minIdx。这样一来,一轮循环结束,两个索引都定下来了。
def swap_extremes(nums): if len(nums) < 2: return nums max_idx = min_idx = 0 for i in range(1, len(nums)): if nums[i] > nums[max_idx]: max_idx = i elif nums[i] < nums[min_idx]: min_idx = i nums[max_idx], nums[min_idx] = nums[min_idx], nums[max_idx] return nums注意这里我用了elif而不是if。原因是一个元素不可能同时大于最大值又小于最小值,所以在同一个循环里,两个分支互斥,用elif可以减少一次不必要的比较。不过这属于微优化,两种写法在结果上没有任何区别。真正要留意的是,这个版本依然是从索引 1 开始遍历,因为索引 0 同时作为最大值和最小值的初始候选。
2.3 复杂度分析:少一次扫描值不值
先看复杂度表格:
| 方案 | 时间复杂度 | 空间复杂度 | 额外空间 | 代码复杂度 |
|---|---|---|---|---|
| 两次遍历 | O(2n) | O(1) | 无 | 低 |
| 一次遍历 | O(n) | O(1) | 无 | 中 |
| 排序后交换 | O(n log n) | O(1) 或 O(n) | 取决于排序 | 高 |
理论上O(2n)和O(n)是同一个量级,复杂度分析中常把常数忽略。但在实际工程里,数组长度达到千万级别时,一次完整遍历可能就是十几毫秒甚至更多,能省下一遍就省一遍。更重要的是,一次遍历版本并没有增加多少代码复杂度,相反,它让“同时维护两个候选值”的思想得到了练习,这对后续处理更复杂的问题有好处。
至于排序后交换,属于典型的“想复杂了”。排序能让你快速找到最大值和最小值,但如果要维持数组其他元素的相对顺序,排序反而破坏了这个前提。就算允许重排,排序带来的开销也远大于线性扫描。所以这道题的最优解就是线性扫描加索引交换,没有捷径。
3. 多语言实现:Python、C++、Java 的写法与坑
3.1 Python 实现:简洁背后的解构交换
Python 里交换两个变量可以直接用nums[max_idx], nums[min_idx] = nums[min_idx], nums[max_idx],这行代码本质上是一个元组打包再解包的过程,底层会先构造右侧的临时元组,再分别赋值给左侧的两个变量,所以不会出现“先覆盖后丢失”的问题。
但如果你生在 C 系语言的思维里,第一次看到这种写法会觉得像魔法。实际上它比临时变量交换要安全得多。Python 风格的实现还可以用enumerate让代码更可读:
def swap_extremes(nums): if not nums: return nums max_idx = min_idx = 0 for i, value in enumerate(nums): if value > nums[max_idx]: max_idx = i if value < nums[min_idx]: min_idx = i nums[max_idx], nums[min_idx] = nums[min_idx], nums[max_idx] return nums在写 Python 版本时最容易忽略的一点是,if value > nums[max_idx]和if value < nums[min_idx]这两个判断在单元素数组中会“同时”成立,因为此时max_idx和min_idx都是 0,比较对象是它自己。最终交换也是自己和自己交换,结果不变,不会崩溃。但如果你在交换前没有对max_idx == min_idx做判断,严格来说也算一次无意义的操作。虽然不影响正确性,但在代码 review 时可能会被问到。
3.2 C++ 实现:类型敏感最容易翻车
C++ 版本最需要小心的是索引类型。如果数组长度用size_t表示,那么size_t是无符号类型,空数组的长度减 1 会变成一个巨大的正整数,导致未定义行为。看看下面这种很自然的写法会出什么问题:
#include <vector> #include <stdexcept> void swapExtremes(std::vector<int>& nums) { if (nums.empty()) { return; } size_t maxIdx = 0; size_t minIdx = 0; for (size_t i = 1; i < nums.size(); ++i) { if (nums[i] > nums[maxIdx]) { maxIdx = i; } if (nums[i] < nums[minIdx]) { minIdx = i; } } if (maxIdx == minIdx) { return; } std::swap(nums[maxIdx], nums[minIdx]); }这段代码在绝大多数情况下是对的,但注意函数开头的if (nums.empty()) return;不能省略。一旦省略,后面的nums[0]就会越界访问。另外,std::swap是 C++ 标准库提供的交换函数,它内部使用移动语义,对于int类型来说开销和手动临时变量交换完全一样,推荐直接使用。
3.3 Java 实现:面向对象思维下的边界处理
Java 版本一般写成静态方法,传入数组,原地修改,然后返回同一个数组。也可以选择返回新数组,但那样会引入额外空间,不太符合原题“交换”的语义。
public class ArrayUtils { public static void swapExtremes(int[] nums) { if (nums == null || nums.length < 2) { return; } int maxIdx = 0; int minIdx = 0; for (int i = 1; i < nums.length; i++) { if (nums[i] > nums[maxIdx]) { maxIdx = i; } if (nums[i] < nums[minIdx]) { minIdx = i; } } int temp = nums[maxIdx]; nums[maxIdx] = nums[minIdx]; nums[minIdx] = temp; } }Java 没有 Python 的解构交换,也没有 C++ 的std::swap,只能手动定义临时变量。很多新手在写temp交换时会把顺序写反,比如先写nums[maxIdx] = nums[minIdx],再写nums[minIdx] = nums[maxIdx],结果两端都变成原来的最小值。这种情况在三个语言的实现中都要警惕,只是 Python 和 C++ 帮你封装好了,Java 必须自己写对。
3.4 交换操作的底层差异:临时变量的必要性
为什么交换操作必须依赖临时变量?核心原因是直接赋值会破坏原值。假设a = 3,b = 1,如果执行a = b,那么 a 变成 1,原来的 3 已经丢失,后续再想恢复 3 就无从谈起。只有把 3 先存到一个临时变量里,才能安全完成对调。
不同语言对这个过程的抽象程度不同:
| 语言 | 交换写法 | 底层实现 | 风险点 |
|---|---|---|---|
| Python | a, b = b, a | 元组打包解包 | 无 |
| C++ | std::swap(a, b) | 移动/复制临时对象 | 需包含<utility>或<algorithm> |
| Java | 手动temp = a; a = b; b = temp; | 临时变量赋值 | 顺序写错会覆盖数据 |
| JavaScript | [a, b] = [b, a] | 数组解构 | 无 |
在写算法题时,我不会刻意追求“一行交换”的花哨写法,更在意的是可读性和正确性。临时变量虽然多写了一行,但它永远不会产生歧义。
4. 边界情况与排错实录:重复值、空数组、相邻交换
4.1 重复极值:到底交换哪一个
这是这道题最容易引发讨论的地方。看数组[2, 2, 1],最大值是 2 且出现了两次。如果我用严格大于号>来更新maxIdx,那么maxIdx会停在第一个 2 的位置,也就是索引 0;如果我用>=来更新,maxIdx会停在最后一个 2 的位置,也就是索引 1。两种规则下,和最小值 1 交换后,数组分别变成[1, 2, 2]和[2, 1, 2]。
我个人的建议是:统一使用严格比较符号。找最大值用>,找最小值用<,这样得到的一定是第一次出现的极值位置。这么做有两个好处:一是行为确定,不依赖遍历方向;二是测试用例好写。如果你在面试中遇到这个题目,可以先主动问一句:“数组里有重复最大值时,交换第一个还是最后一个?”这句话一出口,面试官就知道你意识到了歧义,哪怕代码还没写,印象分已经到手。
4.2 空数组与单元素数组
空数组没有可交换的对象,单元素数组的极值索引相同,交换之后数组不变。这两个边界情况必须单独处理,否则会出现数组越界异常,或者发生无意义的自我赋值。
处理方式有两种:一是直接返回原数组,二是抛异常或返回错误码。具体选择取决于题目要求。在纯算法练习中,我倾向于直接返回,这样调用方不用做额外判断。在工程项目中,如果传入空数组属于非法调用,更应该抛出参数异常,因为“返回空数组”可能掩盖了上游 bug。
4.3 极值相邻时的交换危险
先说结论:相邻极值的交换本身没有任何问题,真正危险的是“交换步骤写错”导致的覆盖。
看一个错误示范,假设数组是[3, 1, 2],maxIdx = 0,minIdx = 1:
nums[max_idx] = nums[min_idx] # 现在数组变成 [1, 1, 2] nums[min_idx] = nums[max_idx] # 现在数组变成 [1, 1, 2],3 丢失了第一行把最小值 1 写到了索引 0,原来的 3 没了。第二行从索引 0 读到的值是刚刚写入的 1,再写到索引 1,结果两个位置都是 1。这就是典型的覆盖陷阱。无论是相邻还是不相邻,只要你“边改边读”,就会出错。正确做法是先把nums[max_idx]存入临时变量,或者使用语言自带的安全交换。
4.4 最大值和最小值索引相同时的全相等数组
如果数组所有元素都相等,比如[7, 7, 7, 7],那么maxIdx和minIdx都停留在初始值 0,交换自己和自己,结果不变。这虽然是“正确”的,但显然白白执行了一次无意义操作。为了性能可以在交换前加一个判断:
if max_idx != min_idx: nums[max_idx], nums[min_idx] = nums[min_idx], nums[max_idx]这个判断在数据规模大且极值相同的场景下,能省掉无意义的内存读写。但更重要的不是这点性能,而是它帮你把逻辑表达得更精确:我们只在确实存在两个不同极值位置时才执行交换。
4.5 测试用例速查表
我每次写完这类函数,都会至少跑一遍下面的测试矩阵:
| 输入数组 | 预期结果 | 覆盖点 |
|---|---|---|
[] | [] | 空数组保护 |
[5] | [5] | 单元素数组 |
[3, 1, 2] | [1, 3, 2] | 常规场景 |
[1, 2, 3] | [3, 2, 1] | 最小在头部,最大在尾部 |
[9, 9, 1] | [1, 9, 9]或[9, 1, 9] | 重复最大值,取决于比较规则 |
[7, 7, 7] | [7, 7, 7] | 全相同数组 |
[2, 1] | [1, 2] | 两个元素,极值相邻 |
[-1, 5, -3] | [-3, 5, -1] | 负数参与比较 |
[0, 100, -100] | [-100, 100, 0] | 零与正负极值 |
把这张表跑完,基本就能覆盖所有常见边界情况。实际调试时,我建议在函数入口处打印一次输入的nums和计算出的maxIdx、minIdx,确认索引是否符合预期,再观察交换结果。这种输出排查法比断点调试更快。
5. 扩展玩法:从单极值交换到更多场景
5.1 不止一对极值:交换前 K 个最大和最小
如果题目拓展成“把数组最大的 K 个数和最小的 K 个数交换位置”,难度立刻提升了一个层级。比如K = 2时,数组[1, 2, 3, 4, 5]里最小的两个是 1 和 2,最大的两个是 4 和 5,交换后变成[4, 5, 3, 2, 1]。这里需要小心元素的对应关系:最小的 1 应该和最大的 5 换,还是和最大的 4 换?通常做法是最小和最大配对,第二小和第二大配对,以此类推。
实现建议是先把数组的索引按值排序,取出前 K 个最小索引和后 K 个最大索引,然后一一对应交换。时间复杂度会变成O(n log n),但这是可以接受的,因为问题本身已经变得更复杂。如果要求保持O(n),可以用快速选择找到第 K 小和第 K 大的元素,但代码复杂度成倍上升,日常练习时先用排序方案理解思路即可。
5.2 对象数组按字段找极值
实际项目里的数组很少是原生整数,更多是对象。假设有一个用户对象列表,每个用户有score字段,现在需要把最高分用户和最低分用户的位置交换。找极值的比较逻辑就不能再直接用>和<,需要指定按哪个字段比较。
Python 里可以写key=lambda u: u.score,C++ 里可以写一个 lambda 传给std::max_element。不过要注意,max_element返回的是迭代器,如果你要拿到索引,还是需要std::distance转换一下。这类场景再一次证明了,一开始就保存索引的做法具有普适性,无论数组元素是什么类型,索引永远是定位位置的唯一可靠方式。
5.3 不修改原数组:返回新副本
有些业务场景要求原数组保持不可变,这时候我们不能原地交换,而是要先复制一份数组,再在副本上完成交换。复制操作的复杂度是O(n),整体仍然是线性复杂度,只是空间占用多了一倍。
这种做法在纯算法题里不算最优,但在工程上非常常见。比如某个对象数组被多处引用,直接改动会使其他模块看到变化后的数据,引发难以排查的问题。不可变性让代码更安全,代价是额外的内存和一次拷贝。具体取舍要看业务约束,不需要一味追求空间最优。
5.4 从这道题延伸出去的方法论
有一次我给一个刚入职的同事 review 代码,他用一个多小时写了个时间复杂度O(n log n)的排序方案来处理极值交换问题,代码洋洋洒洒七八十行。我问他为什么不用线性扫描,他说排序完取首尾最方便。这个例子很有代表性:很多开发者在面对简单问题时,第一反应是调用团委封装好的工具,而不是先思考问题的本质。数组极值交换的本质是“定位”,不是“排序”。排序解决了“极值是谁”的问题,却破坏了“极值原来在哪”的信息,所以它天然不适合这个场景。
从此我总结出一个适合自己的解题顺序:先问数据是什么、能不能改、重复怎么办,再想清楚要维护哪些信息,最后才动手写代码。这套流程在这道题上可能显得小题大做,但遇到复杂问题时,它真的能救命。
结尾
我个人的体会是,这类“简单题”恰恰是最能暴露编程习惯的试金石。后来我帮别人 review 代码时,经常故意用这个题目开场,不是想考谁,而是想看对方拿到题目之后先问什么、边界条件有没有想过。能把数组极值交换写对的人,未必是算法高手,但至少说明他编码时脑子是清楚的。如果你也正在练习,不妨按着本文的思路自己敲一遍,然后额外跑一遍那张测试用例表格,相信你会在这些边界条件里发现不少乐趣。