如果你刷过在线判题系统里的基础题,大概率见过“交换最小值和最大值”这道题。表面要求一句话就能说清:输入一串整数,把最小值与第一个数交换,最大值与最后一个数交换,再输出处理后的数组。不少同学一看就说“这不就是先找最小、再找最大、然后换一下吗”,结果一提交,测例一上,直接 WA(Wrong Answer)。我自己当年也在这道题上交过学费,后来才发现它真正考的不是“会不会找最大最小值”,而是“两次交换之间,索引会不会失效”。今天就把这个坑完整拆开讲一遍。
1. 先别急着写代码:这道题到底在考什么
1.1 题目表象与常见的错误直觉
这类题的常见描述是:第一行给一个整数 n,第二行给 n 个整数,要求把最小值与第一个数交换,最大值与最后一个数交换,最后输出整行结果。
很多人的第一反应是用语言自带函数。以 Python 为例,最自然的写法是这样:
python复制arr = [9, 2, 3, 1]
min_val = min(arr)
max_val = max(arr)
min_idx = arr.index(min_val)
max_idx = arr.index(max_val)
arr[0], arr[min_idx] = arr[min_idx], arr[0]
arr[-1], arr[max_idx] = arr[max_idx], arr[-1]
print(arr)
这个版本在部分输入下能跑对,但换一组数据就可能崩。而且就算写对了,你对它的运行过程也未必有把握——因为 arr.index 在遇到重复值时返回的是第一次出现的位置,这个行为是隐式的,很多人根本没注意到。
更常见的问题出在“先记录两个索引,再分别交换”的做法上。下面这版错误代码是我见过最多的:
python复制def swap_min_max_wrong(arr):
n = len(arr)
min_idx = 0
max_idx = 0
for i in range(1, n):
if arr[i] < arr[min_idx]:
min_idx = i
if arr[i] > arr[max_idx]:
max_idx = i
arr[0], arr[min_idx] = arr[min_idx], arr[0]
arr[n - 1], arr[max_idx] = arr[max_idx], arr[n - 1]
return arr
看起来逻辑很完整:一次遍历找到最小值和最大值的位置,然后做两次交换。问题在于:第二次交换使用的 max_idx,是在第一次交换之前记录的。当第一次交换改变了数组结构后,max_idx 指向的可能已经不再是最大值了。
1.2 核心考点其实是“索引快照”
这里有一个算法题里非常基础、也非常重要的概念:索引是位置的快照,不是元素的身份证。
你记录 max_idx = 0,意思是“最大值当前在下标 0”。可一旦你执行了一次交换,下标 0 里的内容可能变成别的值。这时候再用旧的 max_idx = 0 去做交换,拿到的就不是最大值了。
用一个生活化的类比:你搬家时想把两个柜子分别放到门口和阳台。你先搬了门口那个柜子,再去阳台拿另一个柜子时,你发现另一个柜子已经被挪到了原来的门口位置。如果你还按记忆中的“阳台”去找,自然扑空。这道题的坑就是同一个原理。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 第一版实现:先记录两个索引再交换,为什么错了
2.1 用 [9, 2, 3, 1] 手推全过程
我们拿最典型的反例 [9, 2, 3, 1] 来手动执行一遍错误代码。
初始数组:[9, 2, 3, 1]。
第一遍扫描:
arr[1] = 2不小于arr[0] = 9,min_idx保持 0。arr[1] = 2不大于arr[0] = 9,max_idx保持 0。- 后续遍历结束后,
min_idx = 3(因为 1 是最小值),max_idx = 0(因为 9 是最大值)。
第一次交换:把下标 0 和下标 3 的元素互换。
code复制交换前:[9, 2, 3, 1]
交换后:[1, 2, 3, 9]
注意,此时最大值 9 已经不在下标 0 了,它跑到了下标 3。但代码里的 max_idx 仍然是 0。
第二次交换:把下标 3 和下标 max_idx = 0 的元素互换。
code复制交换前:[1, 2, 3, 9]
交换后:[9, 2, 3, 1]
数组又变回了原样。期望结果应该是 [1, 2, 3, 9](最小值在开头、最大值在末尾),实际却是 [9, 2, 3, 1],完全错误。
2.2 为什么本地测试时容易漏掉这个 case
很多人本地自测时会用 [5, 1, 4, 9] 这种数据。在这个数组里,最小值 1 在下标 1,最大值 9 在下标 3。第一次交换把 1 换到开头后,数组变成 [1, 5, 4, 9],最大值 9 本来就在末尾,第二次交换等于自己跟自己换,所以代码表现正常。
问题就出在这里:如果最大值天然就在末尾,或者最小值天然就在开头,两次交换互不干扰,错误代码也能输出正确结果。人脑很容易复制这种“看起来没问题”的路径,于是把这个错误版本提交上去,直到评测数据里出现 [9, 2, 3, 1] 这类“最大值在开头、最小值在末尾”的输入,才暴露出问题。
另一种常见错误版本是把两次交换顺序反过来:先交换最大值与最后一个元素,再交换最小值与第一个元素。这个版本在 [1, 2, 3, 9] 这种输入上可能没问题,但在 [9, 2, 3, 1] 上同样会翻车。可以自己推一遍:先交换下标 0 和下标 3,数组变成 [1, 2, 3, 9],然后拿旧的 min_idx = 3 去和下标 0 交换,又把数组打回原形。
所以关键点从来不是“先交换最大值还是先交换最小值”,而是:第二次交换之前,必须确保你手上拿到的索引仍然指向你想要的那个元素。
3. 最大值和最小值“互相踩脚”的三种情况
3.1 情况 A:最大值在开头、最小值在末尾
这是最典型的“互相踩脚”场景。例如 [9, 2, 3, 1],最大值在开头,最小值在末尾。你一旦把最小值换到开头,原本在开头的最大值就被“挤”到了末尾;可你手里存的 max_idx 还指向开头,于是第二次交换用错位置。
这类输入是区分正确写法和错误写法最有效的测试数据。只要你的代码能通过 [9, 2, 3, 1],索引失效导致的最核心问题基本就解决了。
3.2 情况 B:最大值的位置就是最小值的位置
听起来很矛盾:一个位置怎么可能既是最大值又是最小值?答案是:数组长度为 1,或者所有元素完全相同。
比如 [5],最大最小都是 5,位置都是 0;再比如 [3, 3, 3],任何位置的值都相等。这种情况下“交换”没有实际意义,但程序必须正常返回,不能出现下标越界,也不能因为第二次交换用了错误的索引导致数组被改乱。
很多新手写这类题时压根不考虑 n=1 的情况。虽然 C 语言里 n - 1 = 0 还不会越界,但如果你的代码里写死了先处理最小值再处理最大值,逻辑上容易在索引修正时出现奇怪的分支。处理这类情况最简单的办法是:开头直接判断 n <= 1 就返回。
3.3 情况 C:最大值在开头,但开头不是唯一最大值
这是比情况 A 更隐蔽的变体。例如 [8, 2, 8, 3, 1],最小值 1 在末尾,最大值 8 同时出现在开头和中间。
如果错误代码记录 max_idx = 0(第一次出现的最大值),那么第一次交换 [0] 和 [4] 后,数组变成 [1, 2, 8, 3, 8]。此时最大值 8 仍然存在于下标 2 和下标 4。如果继续用旧的 max_idx = 0 做第二次交换,就会把下标 0 的 1 和下标 4 的 8 互换,数组又变回 [8, 2, 8, 3, 1],错误。
这个例子告诉我们:重复最大值并不会让索引失效问题消失,反而会让问题更隐蔽。因为即便你第二次交换时用的索引不是最开始的 8,只要最终数组里还有一个 8 在末尾,肉眼可能看不出毛病,但判题系统可不会跟你客气。
3.4 一句话总结三种情况
我习惯把这三类场景列成一个表,方便自己写题前快速过一遍:
| 场景 | 典型输入 | 风险点 |
|---|---|---|
| 最大值在开头、最小值在末尾 | [9, 2, 3, 1] |
第一次交换后最大值位置被覆盖 |
| 单元素或全相等 | [5]、[3, 3, 3] |
需要避免不必要的复杂逻辑和越界 |
| 重复最大值且其中一个在开头 | [8, 2, 8, 3, 1] |
未修正索引可能导致数组被打回原形 |
4. 正确的统一交换策略:先记录、再判断、后交换
4.1 策略一:修正索引法
既然问题出在第二次交换的索引可能失效,那就老老实实做一次索引修正。完整写法如下:
python复制def swap_min_max(arr):
n = len(arr)
if n <= 1:
return arr
min_idx = 0
max_idx = 0
for i in range(1, n):
if arr[i] < arr[min_idx]:
min_idx = i
if arr[i] > arr[max_idx]:
max_idx = i
# 先把最小值换到第一个位置
arr[0], arr[min_idx] = arr[min_idx], arr[0]
# 如果最大值原本就在 min_idx 位置,它已经被换到了 arr[0]
if max_idx == min_idx:
max_idx = 0
# 如果最大值原本就在 arr[0],它已经被换到了 min_idx 位置
elif max_idx == 0:
max_idx = min_idx
# 再把最大值换到最后一个位置
arr[n - 1], arr[max_idx] = arr[max_idx], arr[n - 1]
return arr
这段代码的关键在于第一次交换后的两条判断:
- 如果
max_idx == min_idx,说明最大值和最小值在同一个位置,也就是所有元素相同或只有一个元素。第一次交换后,最大值随原min_idx位置的元素一起到了下标 0,所以把max_idx改成 0。 - 如果
max_idx == 0,说明最大值原本在第一个位置,第一步交换后它被换到了min_idx位置,所以把max_idx改成min_idx。 - 其他情况,最大值没有受到第一次交换影响,索引保持不变。
我当初第一次写对时,其实不是靠背代码,而是靠推演两个 case:[9, 2, 3, 1] 和 [1, 2, 3, 9]。你会发现第一段代码必须同时满足这两个场景,于是自然补上了 elif 分支。
4.2 策略二:重新定位法
如果你不想记修正规则,还有一个更朴素的思路:先完成最小值交换,然后重新扫描一遍数组找最大值的位置,再做最大值交换。
python复制def swap_min_max_scan_twice(arr):
n = len(arr)
if n <= 1:
return arr
min_idx = 0
for i in range(1, n):
if arr[i] < arr[min_idx]:
min_idx = i
arr[0], arr[min_idx] = arr[min_idx], arr[0]
max_idx = 0
for i in range(1, n):
if arr[i] > arr[max_idx]:
max_idx = i
arr[n - 1], arr[max_idx] = arr[max_idx], arr[n - 1]
return arr
这种方法的好处是逻辑简单,不需要额外判断索引覆盖问题。因为第二次扫描发生在第一次交换之后,数组已经是真实存在的中间状态,此时找到的最大值位置必然是准确的。
时间复杂度上,修正索引法只扫描一遍,重新定位法扫描两遍,但都是 O(n),对 n 的大小没有本质差别。实际写题时,两种方法都可以用。我个人更推荐“修正索引法”,因为它训练的是对索引变化的敏感度;但如果你在考场上时间紧张,用“重新定位法”反而更不容易出错。
4.3 为什么不推荐“找值再找位置”
有些 Python 写法会依赖 arr.index(x) 或 numpy.where。这类写法在无重复元素时问题不大,但一旦出现重复值,index 默认返回第一次出现的位置,而不同语言、不同版本的行为可能不一样。
更重要的是,这种写法把“索引”完全隐藏起来,你对数组内部的变化毫无感知。如果后面遇到更复杂的交换问题,很容易栽跟头。学这道题的核心目的就是理解索引和值的关系,所以我建议使用显式索引操作。
4.4 C 语言版本参考
如果你在用 C/C++ 刷题,核心逻辑一样:
c复制void swap_min_max(int arr[], int n) {
if (n <= 1) return;
int min_idx = 0, max_idx = 0;
for (int i = 1; i < n; i++) {
if (arr[i] < arr[min_idx]) min_idx = i;
if (arr[i] > arr[max_idx]) max_idx = i;
}
int tmp = arr[0];
arr[0] = arr[min_idx];
arr[min_idx] = tmp;
if (max_idx == min_idx) {
max_idx = 0;
} else if (max_idx == 0) {
max_idx = min_idx;
}
tmp = arr[n - 1];
arr[n - 1] = arr[max_idx];
arr[max_idx] = tmp;
}
注意 C 语言里交换时一定要用临时变量,或者用异或法,但别在同一位置元素上做异或交换,否则会把那个位置清成 0。这也是一个很经典的细节坑,虽然和本题索引问题无关,但经常一起出现。
5. 边界条件与重复值:取第一个还是最后一个
5.1 判题系统最常见的约定
基础题里最常用的约定是:最小值、最大值都取第一次出现的位置。也就是说,如果数组里有多个最小值,交换的是最靠左的那个;多个最大值也一样。
为什么默认取第一次出现?因为用严格小于和严格大于去更新索引,写起来最自然,也能保证程序行为确定。很多人写遍历时用的是 < 和 >,这正好对应“第一次出现”的语义:
python复制if arr[i] < arr[min_idx]:
min_idx = i
if arr[i] > arr[max_idx]:
max_idx = i
如果你把条件改成 <= 和 >=,那么相同值会不断触发更新,最终得到的是最后一次出现的位置。某些题目可能要求取最后一次,但在没有明确说明时,最好按第一次出现处理。
5.2 重复最大值会带来什么偏差
回到场景 C 的那个例子:[8, 2, 8, 3, 1]。
用“修正索引法”(取第一次出现的最大值 8,位置 0):
- 第一次交换后:
[1, 2, 8, 3, 8] - 修正
max_idx后执行第二次交换,结果:[1, 2, 8, 3, 8]
用“重新定位法”(重新扫描后取当前数组第一个最大值 8,位置 2):
- 第一次交换后:
[1, 2, 8, 3, 8] - 重新扫描找到位置 2,与末尾交换,结果:
[1, 2, 3, 8, 8]
两个结果末尾都有 8,最小值都在开头,看起来都合法。但如果题目要求“取第一次出现的最大值”,那么第一种方法更贴合语义;如果题目只看最终数组是否满足“最小值在首、最大值在尾”,两种方法都算通过。
我在实际写题时,会先看一眼题目描述里有没有“如果存在多个最小/最大值,交换首次出现的”这类句子。如果有明确说明,就直接用第一次出现的语义;如果没有,我倾向用修正索引法,因为这个行为更好解释、也更接近大多数评测程序的默认逻辑。
5.3 单元素和全相等数组的稳健性
不管用哪种策略,都要处理 n <= 1。在正确思路下,单元素数组不需要做任何交换;全相等的数组也同理。你可以在函数开头加上:
python复制if n <= 1:
return arr
不要小看这一行。某些错误的交换逻辑在没有这行时,可能因为 max_idx == min_idx 触发修正分支后做了无意义的交换,虽然不会越界,但代码读起来很怪。提前返回是更清晰的做法。
6. 从“会做”到“做对”:本地调试与自测用例
6.1 一组能覆盖全部坑的测试用例
我把这道题建议至少跑一遍的用例整理成了表格,你可以直接复制到本地跑:
| 输入 | 期望输出 | 覆盖点 |
|---|---|---|
[5] |
[5] |
单元素 |
[3, 3, 3] |
[3, 3, 3] |
全相等 |
[1, 2, 3] |
[1, 2, 3] |
最小值已在开头,最大值已在末尾 |
[3, 2, 1] |
[1, 2, 3] |
最小值在末尾,最大值在开头 |
[9, 2, 3, 1] |
[1, 2, 3, 9] |
索引互相踩脚 |
[5, 1, 4, 9] |
[1, 5, 4, 9] |
最大值已在末尾,最小值需要前移 |
[8, 2, 8, 3, 1] |
按第一次出现语义为 [1, 2, 8, 3, 8] |
重复最大值 + 最小值在末尾 |
注意最后一行在不同语义下可能有多个合法结果。我标注的期望输出对应“第一次出现的最大值”的约定,这也是我在前文推荐的处理方式。
6.2 在代码里埋断言,让错误自己暴露
与其盯着输出看半天,不如直接在代码里检查最终状态。Python 可以写:
python复制assert arr[0] == original_min
assert arr[-1] == original_max
这里的 original_min 和 original_max 是交换前用 min(arr) 和 max(arr) 取得的值。注意如果存在重复最大值,arr[-1] == original_max 一定成立,因为只要你把任何一个最大值放到了末尾,末尾值就等于最大值;而 arr[0] == original_min 同理。这是一个非常强的不变量,只要不变量被破坏,说明交换逻辑有问题。
也可以打印中间状态:
python复制print(f"min_idx={min_idx}, max_idx={max_idx}, arr={arr}")
尤其是在第一步交换之后、第二步交换之前的打印信息,能让你直观看到 min_idx 和 max_idx 是否还指向真实的最大最小值位置。
6.3 为什么一定要把中间态打印出来
很多时候代码看着对,但脑子里的模拟和实际执行不一致。打印中间态是最快的纠错方式。比如错误代码在 [9, 2, 3, 1] 上,第二步交换前你会看到 max_idx=0,但 arr[0] 是 1,不是 9。这一眼就能发现问题出在索引过期。调试这个层面,越早暴露越好。
7. 这道题背后的思维迁移:交换类问题的通用套路
7.1 本质是“索引快照失效”
“交换最小值和最大值”看起来是一道入门题,但背后的思维模型能延伸到很多场景:链表节点交换、数组排序后的原下标追踪、数据库更新行的顺序、甚至 Excel 里先删行再插入行导致的行号变化。
只要你的操作流程是“先记住一个位置,然后修改结构,再用那个位置做后续操作”,就必须警惕位置是否还有效。这道题的训练价值就在于让你建立“快照有效性”的意识:记录一个位置后,如果数据发生了变动,你需要重新确认这个位置的内容还是不是想象中那个元素。
7.2 通用处理三板斧
从这道题里可以提炼出三个通用步骤,适用于绝大多数类似的交换或重排问题:
- 先备份关键信息。备份值也好、备份位置也好,至少要保证出错时有依据可查。
- 每次结构变动后,重新检查受影响的范围。如果只需要修正一个索引,就修正;如果无法确定,就重新扫描一次。
- 用不变量做收尾验证。比如“最小值必须在开头、最大值必须在末尾”,或者更一般的“处理后数组长度不变、元素种类不变”。
第三步在面对复杂算法题时尤其重要。很多难题不是没有思路,而是实现到一半状态混乱,最后靠不变量定位 bug。这道入门题就是练习这种调试方式的好素材。
7.3 面试或考试中的变体
这道题常见的变体包括:
- 要求保持其他元素的相对顺序不变,只把最小值移到头部、最大值移到尾部。
- 要求在同一趟遍历里同时记录最小和最大值的索引,但不允许使用额外数组。
- 升级成“把第 k 小的元素放到第 k 个位置”这类局部重排问题。
这些题的核心都是同一件事:位置记录会随修改失效,你必须设计一套更新或修正机制。能把这道基础题的原理吃透,后面遇到这些变体就不会觉得是从零开始。
我自己的体会是:一道所谓“简单题”能暴力通过并不稀奇,但能把它背后的边界条件、索引失效、重复值语义讲清楚,才说明基础真正扎实。遇到类似问题时,多花几分钟手推一个反例,比盲目提交十次都要有用。
