选择排序算法详解:C语言实现与复杂度分析
2026/9/17 4:17:46 网站建设 项目流程

1. 选择排序到底在干什么——先把思路彻底吃透

排序算法是数据结构绕不开的基本功,而选择排序又是所有排序算法里最容易上手、也最能体现“C语言基础”功底的一个。很多人学C语言数组那一章的时候,第一次接触到的排序可能是冒泡,但翁恺老师的练习题里,选择排序的出现频率一点都不低。它名字听着简单,但真要写出满分代码,还是有几个细节值得抠。

选择排序的核心逻辑用一句话就能说清:每一趟从未排序的部分里挑出最小的元素,放到已排序部分的末尾,反复执行,直到全部排完。听起来和冒泡很像,都是两两比较、循环嵌套,但两者的实际行为差别很大。冒泡排序是“相邻元素不断交换,把大值像气泡一样顶到后面”,而选择排序是“先扫描一遍,记下最小元素的坑位,一趟结束才交换一次”。

这么说太抽象,打个比方。你面前有十张乱序的扑克牌,只允许从左往右看。选择排序的做法是:第一轮,从左往右全部扫一遍,找到最小的那张,把它拿到最左边;第二轮,从第二张开始再扫,找到剩余里的最小,放到第二位;以此类推。整个过程像不像“每次挑一个最小的出来排队”?所以叫“选择排序”——每一次都在“选择”剩余元素里的最优解。

这个思路决定了它的一个天然特性:交换次数非常少。无论数据多乱,n个元素最多交换n-1次。这一点和冒泡有本质区别——冒泡在极端情况下,每一趟都要做多次交换。如果你在嵌入式或者内存受限的设备上写排序代码,交换操作的成本往往比比较操作高得多,这时候选择排序的优势就体现出来了。

适合看这篇文章的人,我大致分三类:一是刚学完C语言数组和循环、想去啃排序算法的初学者;二是准备计算机二级C语言考试,或者正在刷翁恺C语言练习题的学生;三是平时写C代码、偶尔需要一个小巧排序工具的工程党。选择排序作为十大排序算法里的第一梯队,是理解后续堆排序、锦标赛排序的思想地基,花半小时把它吃透,后面学再复杂的排序都会轻松不少。

2. C语言实现:从第一版能跑的代码,到像样的封装

2.1 标准双层循环写法与边界参数

先把最标准的实现在这里摆出来,这一段代码可以直接抄进你的编辑器跑:

#include <stdio.h> void selection_sort(int arr[], int n) { for (int i = 0; i < n - 1; i++) { int min_idx = i; // 假设当前位置就是最小值所在位置 for (int j = i + 1; j < n; j++) { if (arr[j] < arr[min_idx]) { min_idx = j; // 找到更小的,更新下标 } } if (min_idx != i) { // 最小值不是当前位置才交换 int temp = arr[i]; arr[i] = arr[min_idx]; arr[min_idx] = temp; } } } int main() { int a[] = {5, 3, 8, 1, 9, 2}; int n = sizeof(a) / sizeof(a[0]); selection_sort(a, n); for (int i = 0; i < n; i++) { printf("%d ", a[i]); } printf("\n"); return 0; }

外层循环for (int i = 0; i < n - 1; i++),为什么是n - 1?因为当i走到倒数第二个元素时,剩下最后一个元素必然是最大值,不需要再选了。说白了,n个数只需排好n-1个,最后一个自动归位。

内层循环for (int j = i + 1; j < n; j++),从i + 1开始,避免和自己比较,这是最容易被初学者写成j = 0或者j = i的地方。虽然那样也能跑出正确结果,但无意义的比较浪费了CPU,更重要的是让逻辑变得不清晰。每次扫描后,min_idx记录的是整个未排序区间里最小元素的下标,而不是最小值本身。这里有一个C语言初学者经常犯迷糊的点:我们为什么不直接记录最小值,而是记录下标?因为交换的时候需要知道“最小值在哪个位置”,只有值没有位置,你还是得再遍历一遍去找下标,白白浪费时间。

if (min_idx != i)这个判断可加可不加,加上去的好处在于:如果当前元素已经是未排序区间的最小值,就不做无意义的交换,可以省掉一次赋值开销。虽然影响微乎其微,但这是一个良好的编码习惯。

我用一组数据{5, 3, 8, 1, 9, 2}走一遍流程,大家感受一下:

  • 第一轮:i=0,扫描 3,8,1,9,2,最小是 1,下标3。交换 arr[0] 和 arr[3],数组变成{1, 3, 8, 5, 9, 2}
  • 第二轮:i=1,扫描 8,5,9,2,最小是 2,下标5。交换后变成{1, 2, 8, 5, 9, 3}
  • 第三轮:i=2,扫描 5,9,3,最小是 3,下标5。交换后{1, 2, 3, 5, 9, 8}
  • 第四轮:i=3,扫描 9,8,最小是 8,下标5。交换后{1, 2, 3, 5, 8, 9}
  • 第五轮:i=4,最后一个元素 9 已经是最大的,不用管了

每一轮我们都能确定一个元素的最终位置,这个位置一旦确定就不会再被改变。这是选择排序的一个重要特征,做题画流程图的时候尤其有用。

2.2 交换操作的细节:临时变量与宏封装

交换两个变量的值,这是C语言里最常见的操作,但越基础越容易在细节上栽跟头。常规写法:

int temp = arr[i]; arr[i] = arr[min_idx]; arr[min_idx] = temp;

有些同学会想用“不用临时变量”的异或交换技巧:

arr[i] ^= arr[min_idx]; arr[min_idx] ^= arr[i]; arr[i] ^= arr[min_idx];

我负责任地告诉你:在排序算法里不要用。原因有三:

第一,异或交换只对整型有效,碰到floatdouble、结构体数组直接报错或产生未定义行为。第二,如果arr[i]arr[min_idx]指向同一个内存地址,异或之后会把这个数变成0。第三,可读性差,别人看代码要多想几秒才知道你要干什么。排序算法要求的就是稳、清晰、不容易出错,用临时变量是最老实也最妥当的做法。

如果你经常写排序,可以封装一个交换宏:

#define SWAP(x, y) do { \ typeof(x) temp = (x); \ (x) = (temp); \ (y) = (old); \ } while (0)

不过说句实在话,typeof是GNU C的扩展,不是标准C,跨平台编译(比如用MSVC)的时候会报错。我自己的习惯是写一个交换函数会更通用一些。如果你只是刷题,不涉及跨平台,直接用临时变量就好,完全不需要花里胡哨。

2.3 用指针还是用下标:C语言实现的两种风格

学C语言的人迟早要面对一个问题:能用下标,为什么还要折腾指针?对于选择排序,下标版本确实已经足够清晰。但如果你想检验自己指针到底学得怎么样,可以试试用指针重构一版:

void selection_sort_ptr(int *arr, int n) { for (int *p = arr; p < arr + n - 1; p++) { int *min_p = p; for (int *q = p + 1; q < arr + n; q++) { if (*q < *min_p) { min_p = q; } } if (min_p != p) { int temp = *p; *p = *min_p; *min_p = temp; } } }

这段代码和下标版在逻辑上完全等价,但有几个小细节值得琢磨:

  • p < arr + n - 1是外层循环的终止条件,对应下标版的i < n - 1。这里用指针比较大小,C语言标准允许同一数组内的指针比较,不能越界。
  • int *min_p = p,这里记录的是指针,对应下标版里的min_idx。本质上记录的都是“当前位置”。
  • q < arr + n对应j < narr + n是一个指向“最后一个元素之后”的指针,合法但不可以解引用,只用于比较。

我见过不少人写指针版时会把内层循环写成for (int *q = p + 1; q <= arr + n; q++),这是典型的边界错误。因为arr + n已经越界了,用<=会把最后一个元素之后的内存地址也解引用读过一次,虽然大概率不会立刻报错,但那已经是未定义行为了。刷OJ的时候这种错误很难查,尤其是在小数据量测试下一切正常,换一组数据就随机崩溃。

指针版本在实际工程里有什么优势?数组名作为函数参数传入时,本来就会退化为指针,所以在函数内部你用下标和用指针的操作效率是一样的。没有谁比谁快这一说。选哪个纯粹看个人习惯和代码的可读性。如果你想借这个机会把C语言指针练熟,写一版指针版选择排序是个特别好的练习项目。

3. 复杂度与性能:选择排序的真实面孔

3.1 时间复杂度为什么是 O(n²)——比较次数与交换次数分开算

很多人会把冒泡和选择的复杂度都记成 O(n²),然后以为这俩性能一样。其实这只说对了一半。时间复杂度这个东西,要拆开看比较次数和交换次数。

选择排序的比较次数非常固定:第一轮比较 n-1 次,第二轮比较 n-2 次,第三轮 n-3 次……最后一轮比较 1 次。总次数是:

(n-1) + (n-2) + ... + 1 = n(n-1)/2

这个值不管数据原本是正序、逆序还是乱序,都一样。所以选择排序的时间复杂度“最好情况”和“最坏情况”都是 O(n²),不存在像插入排序那样的“几乎有序时接近 O(n)”的优势。

但交换次数就完全不同了。选择排序每一轮最多交换一次,n 个元素最多交换 n-1 次。最少呢?如果数据本来就有序,每一轮最小值都在当前位置,交换 0 次。平均下来大概是 n/2 次交换。冒泡排序在最坏情况下要做 n(n-1)/2 次交换,所以同样是 O(n²),选择排序在“交换成本高”的场景下实际速度会明显优于冒泡。

有一个很经典的类比:比较操作像翻阅资料,交换操作像搬动实物。翻100份资料可能只要几秒钟,但搬动100个箱子可能要累得半死。C语言里的赋值、数组元素搬运,如果数据不是简单的int而是大结构体,交换成本会急剧上升。这一点在实际工程中特别重要。

3.2 空间复杂度与“原地排序”的含义

选择排序的空间复杂度是 O(1),因为除了一个临时变量存储交换的值,不需要额外的辅助数组。这种只需要常数级额外空间的排序方式,叫“原地排序”。每一轮交换直接在原数组上完成,不需要像归并排序那样开一块临时内存去合并结果。

在嵌入式开发中,这个特性很值钱。RAM只有几KB的单片机上,你不可能给几千个采样值做排序时再复制一份数组。选择排序这类原地排序就成了首选方案。我印象很深,之前做过一个温控项目,单片机里要维护一组最近的温度采样点排序,用的就是选择排序。原因很简单:内存紧张、数据量不大、对实时性要求一般,但绝对不能额外占用内存。

3.3 稳定性问题:选择排序为什么不稳定,一个典型反例

稳定性是排序算法的一个重要指标,含义是:如果两个值相等的元素,排序前A在B前面,排序后A仍然在B前面,那么这个排序算法就是稳定的。C语言里对整数数组排序,稳定性无所谓,因为同样的数谁前谁后没区别。但如果你排的是结构体数组,每个结构体有多个字段,按主键排序后希望次键的顺序保持不变,稳定性就很重要。

选择排序是不稳定的排序算法。为什么?看这个例子:

数组:[5a, 5b, 2] (5a和5b值相同,5a在5b前面)

第一轮扫描整个数组,发现最小值是 2,下标2,和下标0的 5a 交换,数组变成:

[2, 5b, 5a]

这时候 5a 和 5b 的相对顺序已经变了——5a 原本在 5b 前面,现在跑到后面去了。

原因在于,选择排序每轮扫描的是未排序区间,最小值可能出现在未排序区间的后半部分,而我们要把它挪到当前的最前面——也就是“跨过”若干元素。这个跨过的动作会把相等的元素顺序打乱。所以如果你需要稳定排序,直接绕开选择排序,老老实实写插入排序或者归并排序。这也是为什么C语言标准库的qsort不保证稳定,而stable_sort之类的高级接口才专门处理稳定性问题。

3.4 什么时候选择排序反而合理

聊了这么多缺点,选择排序是不是就该被淘汰了?也不是。以下三种场景,选择排序反而是不错的选择:

第一种就是前文说的,交换成本远高于比较成本,且数据不是很大。比如你排的是字符串指针数组,比较两个字符串可能很快,但交换两个指针也就两个赋值,而若排的是大结构体,交换成本高就会拖慢整体,选择排序能把交换次数压到最低。

第二种是数据规模小,比如n小于50。在这种数量级下,O(n²)和O(n log n)的差距非常小,反而选择排序的代码简单、不容易出错、调试方便。很多嵌入式场景就是这种数据量。

第三种是你需要一个“稳定的、可预测的”执行时间。选择排序的比较次数固定,不管数据长什么样,每一趟扫描的次数都一样。有些实时性要求高的场合,宁可要一个最坏情况也可控的算法,也不要一个平均快但最坏情况可能卡顿的算法。这一点可能超出初学者认知,但做过程序的人会懂——有时候确定性比绝对速度更重要。

4. 代码优化与变种:从能跑,到跑得好

4.1 每轮同时找最大和最小的双向选择排序

标准选择排序每轮只确定一个元素的位置,要走 n-1 轮。但如果我们每轮同时找最大值和最小值,一个放最前面,一个放最后面,那循环次数就能减半。这就是“双向选择排序”,也叫“二元选择排序”。

核心思路:每轮扫描未排序区间,同时记录最小值和最大值的位置,扫描完成后,最小值换到区间头部,最大值换到区间尾部。然后缩小未排序区间,继续下一轮。

参考实现:

void selection_sort_improved(int arr[], int n) { int left = 0, right = n - 1; while (left < right) { int min_idx = left; int max_idx = left; for (int i = left; i <= right; i++) { if (arr[i] < arr[min_idx]) { min_idx = i; } if (arr[i] > arr[max_idx]) { max_idx = i; } } // 最小值放到左端 int temp = arr[left]; arr[left] = arr[min_idx]; arr[min_idx] = temp; // 如果最大值的位置在 left,上面交换后最大值被换到 min_idx 处了 if (max_idx == left) { max_idx = min_idx; } // 最大值放到右端 temp = arr[right]; arr[right] = arr[max_idx]; arr[max_idx] = temp; left++; right--; } }

这里最大的坑就是我注释里写的:如果最大值原本就在left位置,先交换最小值到left时,会把最大值也交换走,最大值跑到了min_idx那里。此时max_idx需要更新,否则第二次交换就会把错位的数据摆上去。这个bug非常隐蔽,我见过不少人在面试手撕代码时栽在这里。

4.2 选择排序思想的进阶:堆排序和锦标赛排序

选择排序的“每轮扫描找最小值”有一个可以优化的点:扫描一遍找最小值是 O(n),如果我们能维护一个“最小值候选集”,每次取最小值更快,整体复杂度就能降下来。堆排序就是沿着这个思路走的:用二叉堆维护最小值,每次取堆顶 O(log n),n次操作总共 O(n log n)。

所以严格来说,堆排序也是选择排序类算法的延伸。理解了选择排序的“每轮选择”思想,再去啃堆排序,你会有一种豁然开朗的感觉。类似的还有锦标赛排序,它把两两比较的结果保存下来,形成一个树状结构,根节点就是最小值,省去了重复扫描的浪费。这些进阶内容大家可以后续去学,但基石就是今天这简单的双层循环。

4.3 与冒泡排序、插入排序的横向对比

很多初学者搞不清楚这三种 O(n²) 排序的区别,我用一张表说清楚:

算法比较次数(最坏)交换/移动次数(最坏)稳定性特点
冒泡排序n(n-1)/2n(n-1)/2稳定实现简单,常数小
选择排序n(n-1)/2n-1不稳定交换次数最少
插入排序n(n-1)/2n(n-1)/2稳定数据基本有序时接近O(n)

比较次数上,选择排序和冒泡一样,都是要全部比一遍;但交换次数选择排序只有冒泡的 1/n 左右。插入排序则是个奇葩,数据越有序,它越快,最好情况 O(n)。所以如果你拿到的数据大概率接近有序,插入排序吊打其他两个;如果交换代价高、数据乱序,选择排序的表现更稳。

这里也顺带回应很多人的疑问:既然冒泡排序和选择排序实现难度差不多,为什么我推荐学选择排序?因为选择排序引入了“下标记录”的思想,养成“存下标”而不是“存值”的习惯,对后面学习二分查找、链表操作都很有帮助。另一个原因是它的交换次数少,在C语言里交换意味着多次内存读写,写嵌入式程序时这个优势会被放大。

5. 实打实的踩坑记录与调试技巧

5.1 边界条件写错:小于还是小于等于,真的会出事

选择排序代码短,但该出错的地方一步都不会少。最常见的一类错误是边界条件。

错误示范一:

for (int i = 0; i < n; i++) { // 错:跑到最后一个元素还在继续选择 int min_idx = i; for (int j = i + 1; j < n; j++) { // ... } }

外层循环如果写成i < n,最后一遍i = n-1时,内层循环j = n不会执行,min_idx = n-1,然后执行if (min_idx != i)发现相等,不交换。虽然结果没错,但这是一种逻辑上的浪费,而且代码语义不清晰。更危险的是,如果一个人顺手把j < n写成j <= n,内层循环会访问arr[n],这就是数组越界。

错误示范二:

for (int i = 0; i < n - 1; i++) { for (int j = i; j < n - 1; j++) { // 错:丢掉了最后一个元素的比较 // ... } }

内层循环把j < n写成j < n - 1,会导致每一轮扫描都比标准版少看一个元素。比如数组{4, 3, 2, 1},第一轮扫描只看到 3, 2,没看到 1,最小值记录成了 arr[1]=3,交换后变成{3, 4, 2, 1},后面越排越乱。这种错误在小数组上非常难发现,因为有些数组碰巧最后一位恰好不是最小值,而换一组数据就翻车。

我建议所有初学排序的人,写完代码后用逆序数组{n, n-1, ..., 1}和全相同数组来测试。逆序数组是最坏情况,如果它都能排对,普通情况基本没问题;全相同数组能检验你的交换条件写得对不对。

5.2 交换写错导致的数据丢失

第二个高频bug是交换逻辑。新手在写交换时经常犯这种错误:

arr[i] = arr[min_idx]; // 错:arr[i] 的原始值还没存下来就被覆盖 arr[min_idx] = temp; // temp 在这之前根本没赋值或者赋值时机不对

或者是:

arr[i] = arr[min_idx]; arr[min_idx] = arr[i]; // 错:此时 arr[i] 已经是原来 arr[min_idx] 的值了

这两个写法本质都是“没有先保存被覆盖变量的值”,导致数据丢失。C语言里赋值运算符是右结合的,a = b意味着“把 b 的值覆盖到 a”,此刻 a 原有的值就没了。所以在交换之前必须把其中一个值先存进临时变量。

还有一个容易忽略的场景:arr[i]arr[min_idx]是同一个位置时,交换会“自己和自己换”,虽然结果不变,但如果你用的不是临时变量而是异或,就会变成0。所以我前面强调标准交换用临时变量就好,别整花活。

5.3 大数组下的性能实测:直觉有时候不靠谱

有同学问我,选择排序在10万元素下到底有多慢?我自己在普通PC上拿随机 int 数组跑过,大概感受是这样:

  • 1万元素:瞬间完成,肉眼几乎感觉不到延迟,耗时尚可
  • 10万元素:耗时在几十毫秒级,能感觉到“卡了一下”
  • 100万元素:耗时到了秒级,具体在几秒到十几秒之间浮动

作为对比,快速排序在相同量级下,100万元素耗时通常不到0.1秒。差距非常明显。所以如果你刷LeetCode或者OJ,题目数据量在 10^5 以上,千万不要写选择排序,一定超时。选择题型的判断标准很简单:n小于1000随便用,n到5000以上就要小心,n到10000以上基本要换高级算法。

反过来,C语言二级考试的题量一般都很小,用选择排序完全没问题。考试目的不是看你优化多厉害,而是考察你会不会写循环、懂不懂边界条件。这时候用最朴素的选择排序反而不容易出错。

5.4 翁恺老师练习题风格:如何把选择排序写成“满分答案”

不知道大家刷过翁恺老师的C语言练习题没有,他的题目风格经常是:写一个函数,接收数组和长度,对数组排序,然后在主函数里调用并打印。看起来简单,但很多人在函数接口上丢分。

常见的做法是先维护一个print_array函数:

void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { if (i > 0) printf(" "); printf("%d", arr[i]); } printf("\n"); }

这样在每次循环后调用,就能看到每一轮排序的结果。很多练习题甚至会要求你“每轮排序后输出一次数组”,这时候你的selection_sort就不能只是排序,还要在每轮交换后打印。这种题考的就是你对选择排序过程的熟悉程度。我建议初学者在草稿纸上先画几轮排序过程,把每一轮的结果写出来,再对着代码验证,比你空想一百遍都管用。

还有一类题会要求记录排序过程中交换的次数或者比较的次数。要做这类题,你就需要把选择排序的内部步骤拆得很清楚:比较次数是n(n-1)/2固定,交换次数和初始数据有关——这正好测试你对算法的理解到底是不是表面的。

5.5 一个容易被忽略的问题:数组作为函数参数时的退化

C语言函数传数组,实际上传的是指针。在函数内部做sizeof(arr) / sizeof(arr[0])得到的不是数组长度,而是指针的大小除以元素大小。在32位系统上,这个结果通常是1;在64位系统上,对int数组也常常得到2。所以排序函数必须显式传入 n,不能指望在函数内部自己算长度。

这是一个特别基础、特别容易踩的知识点,面试官也特别喜欢让新手解释。选择排序正好是检验这个知识点的绝佳场景——你很难找到一个比它更简单的、必须用“数组+长度”两个参数才能完整实现的功能。

6. 最后再分享一点我的个人体会

选择排序的代码我写了不下几十遍,每次写都有新的感受。最开始学它,我只觉得它是冒泡排序的“改良版”,无非是少交换几次。后来在嵌入式项目里真的拿它排采样数据,才理解“交换次数少”在资源受限环境里有多重要。我自己现在的习惯是,写任何排序代码之前,先问三个问题:数据量多大?内存紧不紧张?稳定性有没有要求?想清楚这三个问题,选择排序该不该用、怎么用,答案就很清楚了。

如果你刚接触排序算法,我建议把选择排序当成一个“暖身题”,不要纠结于它快不快,而是老老实实把下标、边界、交换、指针这四个点都吃透,尤其是用指针重写一遍。这个过程等于把C语言里最核心的数组、循环、指针、函数参数传递全部复习一遍,性价比极高。等你熟练了,再去挑战快速排序、归并排序这类分治算法,会发现基础越扎实,进阶越轻松。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询