1. 先把这个题目的真实考点说清楚
“交换最小值和最大值”这句题目,我见过太多人把它当成一道送分题来做。但实际写代码的时候,能一次写对并扛住所有边界测试的人,真没那么多。
先从最朴素的直觉说起。给你一个数组[2, 5, 1, 9, 3],要求交换最小值和最大值。多数人第一反应是:找出最小值 1,找出最大值 9,把这两个数对调,得到[2, 5, 9, 1, 3]。这个思路本身没错,但问题在于:在数组里,你能直接操作的并不是值,而是格子——也就是下标。要交换两个元素,必须知道它们在数组里的具体位置,否则你只是把两个局部变量值换了换,数组本身纹丝不动。
所以这道题真正想考察的核心只有一件事:定位。你不是在交换两个数字,而是在交换“最小值所在的位置”和“最大值所在的位置”上站着的两个元素。很多新手卡住,不是不会找最大最小值,而是没有建立起“值”和“位置(下标)”之间的映射关系。
这个题目常见于三个场景:
- C/C++ 课程中数组章节的典型练习,教材里经常用这道题讲“数组下标”和“元素值”的区别。
- 各类刷题网站的基础题,通常作为数组操作模块的第一道热身题。
- 面试手写代码环节的暖场题,用来考察候选人是否细心、会不会考虑边界条件。
它适合刚接触数组语法的新手,也适合准备校招笔试的人,甚至适合那些自我感觉基础扎实、但好久没碰数组细节的老手。因为这道题的变体和边界条件,是真的能筛人的。
说句题外话,我带人的时候喜欢先用这道题去判断对方的代码习惯。不是因为题目本身有多难,而是因为它能在极短的时间内暴露三个问题:你会不会考虑空数组和单元素数组?你知道不知道重复值怎么处理?你写交换的时候是交换了数组元素还是交换了两个临时变量?这三个问题答利索了,基础基本靠谱。
2. 解题思路拆解:找“位置”才是题眼
2.1 标准解法就三步
正确解法是标准的“查找-交换”流程:
- 遍历数组,记录最小值第一次出现的下标,记为
min_idx。 - 遍历数组,记录最大值第一次出现的下标,记为
max_idx。 - 交换
arr[min_idx]与arr[max_idx]。
把这个流程翻译成代码,核心其实只有两个变量。注意,这里刻意强调“下标”,不是“值”。因为一旦只记录值,你后续还得用index()或者再遍历一次去反查位置,既慢又容易出错。
我举个反例。假如你写了类似这样的代码:
min_val = min(arr) max_val = max(arr) min_val, max_val = max_val, min_val然后输出arr,你会发现数组没有任何变化。原因一目了然:这个交换只是把两个临时变量手里的数值互相递了一下,但数组里的元素压根没动过。这就是典型的“交换了两个值,而不是交换两个位置”。
2.2 一遍遍历还是两遍遍历?
查找最小值和最大值,有两种写法。第一种是两遍独立遍历,先找min_idx,再找max_idx。第二种是一遍遍历同时更新两者:
min_idx = 0 max_idx = 0 for i in range(1, len(arr)): if arr[i] < arr[min_idx]: min_idx = i if arr[i] > arr[max_idx]: max_idx = i两遍也是一样属于 O(n) 时间复杂度,但一遍遍历有个额外好处:只扫了一次,逻辑上更紧凑,而且min_idx和max_idx各自独立更新,先判断哪个都不影响结果,不容易出错。
不过有一类写法要特别小心:把两个判断写成if...elif...结构。
for i in range(1, len(arr)): if arr[i] < arr[min_idx]: min_idx = i elif arr[i] > arr[max_idx]: max_idx = i这段代码在大部分数组上是正常的,因为某个位置上的元素不太会同时满足“比当前最小值小”和“比当前最大值大”。但在复杂逻辑里,elif很容易造成条件覆盖不完整,比如你后续还要同时更新其他状态时,就会漏掉一种分支。所以我个人统一建议:两个独立的if,别用elif,表达意图更清晰,也更好扩展。
提示:无论一遍还是两遍遍历,时间都是 O(n),额外空间 O(1)。这已经是最优解,因为你要挑出一筐苹果里最大和最小的,不每个看一眼不可能知道。
2.3 边界条件才是拉开差距的地方
任何一道简单题,拉开差距的都在边界。这道题的边界条件我整理成了表格:
| 场景 | 例子 | 正确行为 |
|---|---|---|
| 空数组 | [] | 不报错,原样返回 |
| 单元素数组 | [5] | 无需交换,原样返回 |
| 所有元素相等 | [7, 7, 7] | min_idx等于max_idx,交换白交换,数组不变 |
| 存在多个最小值 | [3, 1, 4, 1] | 通常交换第一个最小值,即下标 1 |
| 存在多个最大值 | [2, 9, 3, 9] | 通常交换第一个最大值,即下标 1 |
| 最小值与最大值相邻 | [1, 9, 2] | 正常交换,结果是[9, 1, 2] |
关于重复值,“通常交换第一个”只是一个默认约定,具体要看你面对的是哪一版题目。有些题库会明确写“交换最先出现的最小值和最大值”,有些则无所谓。你在代码里注释一行“重复值取首次出现”,是很加分的习惯,说明你思考过这个歧义。
还有个容易搞混的场景:如果题目说的是“把最小值放到数组开头,最大值放到数组末尾”,那是另一个变体,不是单纯的交换。此时如果最小值已经在末尾、最大值已经在开头,直接连续两次交换就会出错。这个变体我后面专门再讲。
3. 各语言实现与实操笔记
3.1 Python:最简洁,但也有坑
Python 版本可以写得很短:
def swap_min_max(arr): if len(arr) < 2: return arr min_idx = 0 max_idx = 0 for i in range(1, len(arr)): if arr[i] < arr[min_idx]: min_idx = i if arr[i] > arr[max_idx]: max_idx = i arr[min_idx], arr[max_idx] = arr[max_idx], arr[min_idx] return arr也可以借助内建函数:
def swap_min_max_builtin(arr): if len(arr) < 2: return arr min_idx = arr.index(min(arr)) max_idx = arr.index(max(arr)) arr[min_idx], arr[max_idx] = arr[max_idx], arr[min_idx] return arr两段代码功能一样,但第二段有个细节:arr.index()返回的是第一次出现的下标,正好符合“重复值取首次出现”的约定。性能上,min()、max()和index()各扫一遍数组,相当于三次 O(n) 遍历,但因为这些内建函数跑在底层,实际速度对于普通规模的数据完全够用。
一个容易翻车的所谓“Python 技巧”:
arr[min_idx], arr[max_idx] = arr[max_idx], arr[min_idx]当min_idx和max_idx相等时,这行元组交换天然安全。但如果你图省事写成:
arr[min_idx] = arr[max_idx] arr[max_idx] = arr[min_idx]那就会把最大值复制到最小值的位置,再用同一个值覆盖最大值的位置,数组直接坏掉。这就是典型的三步交换法手误,尤其当两个下标相邻或相等时,后果很隐蔽。
3.2 C 语言:教科书级的写法
C 语言版本是各类教材里的常客。完整写法:
#include <stdio.h> void swap_min_max(int arr[], int n) { if (n < 2) { return; } int min_idx = 0; int 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 temp = arr[min_idx]; arr[min_idx] = arr[max_idx]; arr[max_idx] = temp; } int main() { int arr[] = {2, 5, 1, 9, 3}; int n = sizeof(arr) / sizeof(arr[0]); swap_min_max(arr, n); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); return 0; }这里必须留个心:C 里数组作为函数参数传递时,会退化成指针,函数内部直接修改arr[i]是能作用到原数组的。所以不需要返回一个“新数组”,原数组已经被改了。新手常犯的错是把数组复制一份、在副本上交换,然后期望主函数里的数组发生变化——在 C 里数组不是传值类型,这种做法只会让修改无声消失。
3.3 Java 与 C++:标准库帮你省一半功夫
Java 版本,手写循环版:
public static void swapMinMax(int[] arr) { if (arr.length < 2) { return; } int minIdx = 0; int maxIdx = 0; for (int i = 1; i < arr.length; i++) { if (arr[i] < arr[minIdx]) { minIdx = i; } if (arr[i] > arr[maxIdx]) { maxIdx = i; } } int temp = arr[minIdx]; arr[minIdx] = arr[maxIdx]; arr[maxIdx] = temp; }Java 的数组对象是引用类型,方法里修改数组元素,调用方是能感知的。但注意,如果你在方法里写的是arr = new int[]{...}这样的重新赋值操作,那就只是把局部引用指向了新对象,原数组照样不动。这两者的区别,面试时经常被拿出来当追问点。
C++ 版可以用标准库一步到位:
#include <algorithm> #include <vector> void swapMinMax(std::vector<int>& v) { if (v.size() < 2) { return; } auto minIt = std::min_element(v.begin(), v.end()); auto maxIt = std::max_element(v.begin(), v.end()); std::iter_swap(minIt, maxIt); }std::min_element和std::max_element返回的是迭代器,本质上也是一种“位置”,正好印证了前面说的:找位置,而不是找值。如果数组里所有元素相等,两个迭代器指向同一个元素,std::iter_swap对同位置操作是安全的,不会出问题。
C++ 里还有一个坑:别把std::swap(*minIt, *maxIt)写成std::swap(minIt, maxIt)。前者交换两个元素的值,后者交换的是迭代器本身,完全是两码事,编译能过,结果全错。
3.4 变体题:从“交换”到“移动”
这个题目有一大类变体,最常见的是:
将数组中的最小值移到第一个位置,最大值移到最后一个位置,其余元素保持相对顺序不变。
如果直接用“交换”的思维去解,很容易翻车。举个例子:
输入: [9, 2, 5, 1] 最小值 1 在末尾,最大值 9 在开头如果先让最小值 1 与第一个元素 9 交换,得到[1, 2, 5, 9]。此时你会发现,最大值 9 已经从开头跑到了别的位置。如果你还用交换前记录下来的max_idx = 0去和最后一个位置交换,就会把原来的 1 又换回到开头,数组变回[9, 2, 5, 1],功亏一篑。
正确做法是:先记录初始的min_idx和max_idx,执行“最小值换到开头”后,检查一次max_idx是否被这次交换波及。具体规则是:如果max_idx正好等于这次交换的目标位置(也就是下标 0),那最大值已经被挪到了min_idx的位置,需要把max_idx更新成原来的min_idx,再执行第二次交换。如果最大值不在目标位置上,就不用更新。
这个细节极其容易忽略,因为普通样例很少撞上这个组合。我在实际测试中遇到这个坑时,整个人愣了一下才反应过来——从“交换”升级到“移动”,本质是把定位思想加深了一层:你必须在动手修改数组之前,把所有目标位置都记牢,就像搬家前先在箱子上贴好标签,而不是搬一半才想起来哪个箱子里有什么。
4. 常见问题与排查技巧实录
4.1 为什么我交换后数组没有任何变化
这是出现频率最高的现象。代码看起来操作了数组,实际却纹丝不动。常见原因有二:
第一,交换的是两个变量,不是数组元素。比如前面提到的min_val, max_val = max_val, min_val,这就是典型的“只换了纸上的数字,没动抽屉里的东西”。
第二,在传值语义的语言或写法中,你在函数内部修改的是数组副本。比如 Java 里方法参数是基本类型时天然传值,C++ 函数参数忘了写引用符号&,对std::vector的修改就不会传出去。排查思路很简单:在交换后立刻打印数组每一个元素,看看是“完全没变”还是“变了但变错”。前者是传值/引用问题,后者是下标或边界问题。
4.2 重复值出现时,交换结果和预期不一致
比如[1, 2, 3, 1],你可能发现交换后变成[1, 2, 3, 1]——没变化。原因是代码用max(arr)找到最大值 3(下标 2),用min(arr)找到最小值 1,但index(1)返回第一次出现的下标 0,所以最小值本来就在开头,交换了个寂寞。
更隐蔽的一种错误是在一次遍历里同时更新两个下标时手滑:
if arr[i] < arr[min_idx]: min_idx = i max_idx = i # 手滑这种错误在样例数据上不太容易暴露,因为样例通常能给出正确结果;一旦数据里最小和最大位置比较偏,结果就错了。建议写完后用刁钻数据自测,比如[1, 1, 1]、[5, 1]、[9, 2, 5, 1]。
4.3 自测用例清单(可直接抄作业)
我每次写完这类数组操作题,都会带着下面这组用例过一遍:
| 用例 | 期望结果 |
|---|---|
[] | [],不报错 |
[5] | [5] |
[1, 1, 1] | [1, 1, 1] |
[1, 2] | [2, 1] |
[2, 1] | [1, 2] |
[3, 1, 4, 1, 5] | [3, 5, 4, 1, 1] |
[1, 2, 1] | 选第一个 1 交换得[2, 1, 1],选第二个得[1, 1, 2],两种都是合法的“第一次出现”约定 |
第七个用例的重点在于,最小值重复时选择哪个下标,结果可能不同。它比[3, 1, 4, 1, 5]更能检验你的约定是否一致,因为后者两个 1 交换后数组布局恰好相同,容易让错误约定蒙混过关。
注意:判断重复值处理是否正确,别只看输出结果,要看你的实现到底选的是哪个下标。写一行注释说明规则,会让你的代码更可信。
4.4 从这道题延伸到选择排序
写对了这道题,其实你就已经掌握了选择排序里最关键的一个环节。选择排序的每一轮,就是“找到剩余区间的最小值下标,然后与区间第一个位置交换”。把这个动作不断重复,就是完整的选择排序。
所以这道题表面看只有 O(n) 的数组遍历,但它背后连接的是一整套基础能力:通过下标操作元素、通过临时变量完成交换、通过遍历维护极值状态。这些能力在后续的排序算法、滑动窗口、双指针等话题里会反复出现。
我在带人时发现,能流畅写出这道题变体的人,多半已经形成了一个肌肉记忆:凡是数组内交换,第一步永远是明确下标;凡是遍历维护极值,第一步永远是确定初始值。这两个习惯,比这道题本身值钱得多。
最后分享一个实操中的小技巧:如果你在写 Python,并且函数需要原地修改数组,建议在函数第一行就把输入检查写全,包括空数组、单元素、全部相等三种情况。这不仅是防御性编程,更是在向看你代码的人传达一个信息——这些边界我都想到了。面试手写代码时,就凭这一点,你的评价往往就能上一个档次。