☰
C语言插入排序详解:原理、实现与优化
2026/10/14 19:52:02 网站建设 项目流程

插入排序是C语言学习中最适合用来理解算法思维的排序算法之一。它的核心做法和外行人手动整理扑克牌完全一致:把当前牌插入到已经排好序的牌堆中合适的位置,让有序区不断扩展,最终整副牌有序。这个思路不需要复杂的数学推导,也不需要递归和分治,只要把比较和移动两个动作弄清楚,就能把代码写出来。这篇文章从零开始讲直接插入排序,先解释执行过程,再给出完整C语言代码,然后逐段分析内层循环为什么这样写,最后补充常见错误、优化变体和学习建议。学完后你不仅可以直接上机运行这段代码,还可以独立处理数组越界、重复元素、逆序输入等边界情况,并能理解插入排序在什么场景下值得用,在什么场景下应该换算法。

1. 先理解插入排序:它不是“小技巧”,而是一种有序区扩展机制

1.1 插入排序解决什么问题

在计算机里,把一组无序数据变成有序数据是最基本的问题。C语言没有内置的sort函数可以直接作用于普通数组,因此排序算法是每个C语言学习者绕不开的内容。插入排序是其中最容易建立直观印象的一种。

直接插入排序的输入是一个数组,例如:

int a[9] = {34, 8, 64, 51, 32, 21, 11, 5, 45};

输出是一个从小到大排列的数组:

{5, 8, 11, 21, 32, 34, 45, 51, 64}

在这个转换过程中,算法并不需要额外的大块内存,只需要一个临时变量保存“当前要插入的元素”,其余工作都是在原数组内完成移动和赋值。

1.2 为什么说它像整理扑克牌

假设你手里已经有三张牌,从左到右按从小到大排列:3、7、9。这时你摸到一张5,你会怎么处理?你不会把整副牌打乱重排,而是先找到5应该放的位置:它小于7、大于3,所以应该插在3和7之间。然后把7和9向右移动一个位置,空出一个坑位,把5放进去,得到3、5、7、9。

插入排序就是这个过程的机械化表达。程序任何时候都维护一个“已经有序的前缀区间”和一个“尚未处理的后续区间”。每次从后续区间取出第一个元素,在前面有序区间找到正确位置并插入。随着循环推进,有序区从1个元素扩展到n个元素。

1.3 插入排序的适用场景和复杂度底线

插入排序在数据量不大、数据本身接近有序时表现很好。最坏情况下,数组完全逆序,例如{5,4,3,2,1},每一轮都需要把所有前面的元素向右移动,比较次数和移动次数接近n的平方量级,时间复杂度是O(n²)。最好情况下,数组已经有序,每一轮只需要比较一次就能确定位置,整体时间复杂度是O(n)。平均情况同样是O(n²)。

因为只需要常量级额外空间,空间复杂度是O(1)。并且当两个相同元素的相对位置不改变时,插入排序是稳定的。这一点在很多实际项目里很重要,尤其是需要对多字段记录按某一字段排序时,稳定性决定了排序结果是否保持上一次排序的相对顺序。

2. 用“动画式”执行过程理解直接插入排序

2.1 用无序数组走一遍完整流程

很多初学者直接看代码会卡在内层循环上,因为比较和移动是同时在数组上发生的。先把数据跑一遍流程,再对照代码,理解成本会低很多。

假设数组为:

int a[] = {8, 5, 2, 6, 9, 3};

初始状态认为下标0位置只有一个元素8,所以有序区是{8},待处理区是{5, 2, 6, 9, 3}。

第1轮处理下标1的元素5:

  • 用临时变量保存5。
  • 比较5和8,因为8 > 5,所以把8向右移动一位,数组变成{8, 8, 2, 6, 9, 3}。
  • 下标已经移动到-1,说明5应该放在下标0。
  • 把5放回,数组变成{5, 8, 2, 6, 9, 3}。

第2轮处理下标2的元素2:

  • 保存2。
  • 比较2和8,8 > 2,8右移:{5, 8, 8, 6, 9, 3}。
  • 比较2和5,5 > 2,5右移:{5, 5, 8, 6, 9, 3}。
  • 越界,将2放在下标0:{2, 5, 8, 6, 9, 3}。

第3轮处理下标3的元素6:

  • 保存6。
  • 比较6和8,8 > 6,8右移:{2, 5, 8, 8, 9, 3}。
  • 比较6和5,5 < 6,停止移动。
  • 将6放在原来8的位置,即下标2:{2, 5, 6, 8, 9, 3}。

第4轮处理下标4的元素9:

  • 保存9。
  • 比较9和8,8 < 9,不需要移动,数组保持不变:{2, 5, 6, 8, 9, 3}。

第5轮处理下标5的元素3:

  • 保存3。
  • 比较3和9,9 > 3,9右移:{2, 5, 6, 8, 9, 9}。
  • 比较3和8,8 > 3,8右移:{2, 5, 6, 8, 8, 9}。
  • 比较3和6,6 > 3,6右移:{2, 5, 6, 6, 8, 9}。
  • 比较3和5,5 > 3,5右移:{2, 5, 5, 6, 8, 9}。
  • 比较3和2,2 < 3,停止移动。
  • 将3放在下标1:{2, 3, 5, 6, 8, 9}。

排序完成。

2.2 每一轮的状态变化表

把上面的过程整理成表格,能更清楚地看到“有序区”和“待处理区”的变化。

轮次插入前数组被插入元素移动的元素插入后数组
初始8 5 2 6 9 3无无8 5 2 6 9 3
18 5 2 6 9 3585 8 2 6 9 3
25 8 2 6 9 328、52 5 8 6 9 3
32 5 8 6 9 3682 5 6 8 9 3
42 5 6 8 9 39无2 5 6 8 9 3
52 5 6 8 9 339、8、6、52 3 5 6 8 9

从表中可以看到,每一轮的核心动作是“从右向左比较”和“逐个右移”。右移并不是简单交换,而是在有序区中“挤出一个插入位置”。这也是插入排序和冒泡排序最明显的区别:冒泡排序主要做相邻交换,插入排序主要做平移和定位。

2.3 动画中强调的“哨兵”概念

在常见教学动画里,会把当前待插入元素用一个单独的格子高亮显示。这个高亮的格子对应C语言代码里的临时变量temp。不过教学中还有一种“哨兵”优化:在数组下标0处预留一个存储位置,把待插入元素存在下标0位置,循环结束条件从j >= 0变为a[j] > temp,因为当j等于0时,a[0]等于temp本身,条件不成立,循环自然停止,从而省去每次都判断j >= 0。

哨兵写法比较适合学习数组越界控制,但在普通工程里不建议为了省一次判断而破坏数组的直观性。更好的做法是先写出没有哨兵的常规版本,理解边界,再去看哨兵版本。

3. 环境准备和最小C语言实现

3.1 学习环境怎么准备

C语言的学习和编译并不挑平台。Windows下可以用Dev-C++、Code::Blocks或VS Code配合MinGW;Linux和macOS下一般可以直接用系统自带的gcc。这里以命令行为例,这也是最容易复现的方式。

先检查编译器是否存在:

gcc --version

如果提示找不到命令,需要安装编译器。常见系统可参考下表,但不代表所有版本和发行版行为完全一致,实际安装要以自己环境的命令说明为准。

环境常用安装方式安装后检查
Ubuntu/Debiansudo apt install gccgcc --version
CentOS/RHELsudo yum install gccgcc --version
macOS安装Command Line Tools后自带gcc --version
WindowsMinGW-w64或Dev-C++gcc --version

如果你用的是集成开发环境,只需要能新建C源文件并运行即可。核心目标是把下面这一份完整代码编译通过。

3.2 完整可运行代码

下面代码包含四个部分:排序函数、数组打印函数、测试主函数。

#include <stdio.h> void insertion_sort(int arr[], int n) { int i, j; int temp; for (i = 1; i < n; i++) { temp = arr[i]; j = i - 1; while (j >= 0 && arr[j] > temp) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = temp; } } void print_array(int arr[], int n) { int i; for (i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } int main(void) { int a[] = {34, 8, 64, 51, 32, 21, 11, 5, 45}; int n = sizeof(a) / sizeof(a[0]); printf("排序前:"); print_array(a, n); insertion_sort(a, n); printf("排序后:"); print_array(a, n); return 0; }

把代码保存为insertion_sort_demo.c,然后在终端执行:

gcc -o insertion_sort_demo insertion_sort_demo.c ./insertion_sort_demo

正常运行结果应该是:

排序前:34 8 64 51 32 21 11 5 45 排序后:5 8 11 21 32 34 45 51 64

3.3 代码中最需要先记住的三行

排序函数里真正的核心只有三行逻辑:

temp = arr[i]; // 保存当前待插入元素 while (j >= 0 && arr[j] > temp) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = temp; // 把元素放回正确位置

第一行保存的是“当前值”。如果在比较过程中直接用arr[i],前面元素右移时可能会把arr[i]覆盖,导致数据丢失。

第二行是“从右向左找位置并移动”。arr[j] > temp表示只要前一个元素比当前值大,就把它向右移动一位。移动之后j减1,继续往前比较。

第三行把保存下来的当前值放到j + 1位置。循环结束时的j要么是-1,要么指向一个不大于temp的元素。无论哪种情况,正确插入位置都是j + 1。

4. 关键代码剖析:数组下标、临时变量和循环条件为什么不缺一不可

4.1 外层循环为什么从1开始,而不是0

如果外层循环从0开始,第一次就会把arr[0]当作待插入元素,而前面的有序区为空,没有任何意义。所以外层循环从i=1开始,意味着每次假设“下标0到i-1已经是排好序的”。

这个假设在循环过程中是自洽的。i等于1时,有序区只有arr[0],当然有序。i等于2时,前一轮已经把arr[1]插入到了合适位置,所以arr[0]到arr[1]有序。依次类推。

如果外层循环写成i <= n,就会访问arr[n],造成数组越界。正确写法是i < n。

4.2 内层循环为什么要右移而不是交换

如果使用交换,每比一次就交换一次,也能完成排序,但会多出很多赋值操作。插入排序希望先在有序区中确定位置,最后只做一次落位,所以中间过程是“把比temp大的元素向右平移”。

右移后数组会出现重复元素,这是算法正常现象。例如{5, 8, 2}在插入2时先变成{5, 8, 8},再用保存的2覆盖到下标0。代码里的肉眼观察可能觉得数组“坏掉了”,其实这是因为数据还没有落位,最终结果仍然是正确的。

4.3 为什么内层条件里必须先判断j >= 0

内层循环条件写成:

while (j >= 0 && arr[j] > temp)

这里的顺序不能反过来写成:

while (arr[j] > temp && j >= 0)

因为C语言中&&会从左到右求值,如果j已经变成-1,arr[j]会访问数组下标为-1的位置,这是未定义行为,程序可能崩溃,也可能返回一个随机值。正确顺序是先判断j是否有效,再访问数组元素。

4.4 一个容易忽略的细节:j和j + 1的关系

内层循环结束后,j指向的是“不满足移动条件的位置”。如果j等于-1,说明temp比有序区所有元素都小,应该放到下标0,也就是j+1等于0。如果j停在一个小于等于temp的元素上,那么temp应该放在这个元素后面,也就是j+1。

很多初学者会在循环结束后写成arr[j] = temp,这时如果j等于-1,数组就会越界。正确写法必须是arr[j + 1] = temp。

5. 运行验证:用更多测试用例确认排序结果和稳定性

5.1 验证标准测试用例

上面数组的排序结果只是第一步。算法要可靠,还需要测试不同输入。这里写一个简单的验证主函数,测试乱序、正序、逆序、有重复、只有一个元素五种情况。

#include <stdio.h> void insertion_sort(int arr[], int n); void print_array(int arr[], int n); int main(void) { int a1[] = {34, 8, 64, 51, 32, 21, 11, 5, 45}; int a2[] = {1, 2, 3, 4, 5}; int a3[] = {5, 4, 3, 2, 1}; int a4[] = {7, 7, 3, 9, 3, 1}; int a5[] = {42}; int n; n = sizeof(a1) / sizeof(a1[0]); printf("乱序:"); insertion_sort(a1, n); print_array(a1, n); n = sizeof(a2) / sizeof(a2[0]); printf("正序:"); insertion_sort(a2, n); print_array(a2, n); n = sizeof(a3) / sizeof(a3[0]); printf("逆序:"); insertion_sort(a3, n); print_array(a3, n); n = sizeof(a4) / sizeof(a4[0]); printf("重复:"); insertion_sort(a4, n); print_array(a4, n); n = sizeof(a5) / sizeof(a5[0]); printf("单元素:"); insertion_sort(a5, n); print_array(a5, n); return 0; }

预期输出:

乱序:5 8 11 21 32 34 45 51 64 正序:1 2 3 4 5 逆序:1 2 3 4 5 重复:1 3 3 7 7 9 单元素:42

5.2 稳定性如何验证

如果排序的数据是一组结构体,通过某一字段排序,我们希望在关键字相同时,原始顺序不改变。例如下面这种结构:

typedef struct { int id; int score; } Student;

用score排序,如果两个学生分数都是90,那么id较小的那个在排序后仍然在前面,就说明算法稳定。插入排序因为只把严格大于temp的元素右移,相等元素不会越过,所以稳定性成立。

如果想把插入排序修改成不稳定算法,也很容易:把内层条件改成arr[j] >= temp。这样相等元素也会右移,temp会插到相等元素前面,相对顺序被破坏。实际项目中应尽量避免这种无谓修改。

5.3 如何打印中间结果辅助调试

调试排序算法最常见的需求是观察每一轮结果。可以在排序函数里加上中间打印,但建议通过#ifdef DEBUG控制,避免污染正常输出:

#include <stdio.h> void insertion_sort(int arr[], int n) { int i, j, temp; for (i = 1; i < n; i++) { temp = arr[i]; j = i - 1; while (j >= 0 && arr[j] > temp) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = temp; #ifdef DEBUG printf("第%d轮:", i); for (int k = 0; k < n; k++) { printf("%d ", arr[k]); } printf("\n"); #endif } }

编译时使用-DDEBUG开启调试输出:

gcc -DDEBUG -o insertion_sort_debug insertion_sort_debug.c

这样能直观看到每一轮数组变化,和前面的动画式执行过程一一对应。

6. 常见错误和排查方法:从崩溃到结果错误

6.1 数组越界导致的段错误

现象:程序在插入逆序数组时直接崩溃,或者输出错误后再崩溃。

常见原因是在循环结束后错误写成arr[j] = temp,而j此时可能是-1。

排查方式:

  • 使用printf在循环开始和结束打印i、j的值。
  • 使用gdb运行程序,崩溃时执行bt查看调用栈。
  • 检查所有数组下标是否都在0到n-1范围内。

修复方式是把落位语句改成arr[j + 1] = temp。

6.2 用arr[i]而不是temp导致数据丢失

现象:数组出现多个重复元素,或者某个元素消失。

原因:前面元素右移时,会覆盖arr[i]。如果不先保存,后续比较到的值已经不是原始待插入值。

排查方式:在进入内层循环前保存arr[i]到temp,整个循环过程中arr[i]可以被覆盖,但temp必须保持不变。

6.3 外层循环多循环一次导致访问越界

现象:排序正常,但程序最后卡住或打印垃圾值。

原因:外层循环写成i <= n,在i等于n时访问arr[n]。

排查方式:检查循环条件,正确写法是i < n。可以增加一个断言:

#include <assert.h> assert(i < n);

6.4 比较符号写反导致排序顺序错误

现象:结果是从大到小排列,而不是从小到大。

原因:内层条件写成了arr[j] < temp,导致把较小的元素向右移动,违背了插入排序的“把大元素右移”逻辑。

排查方式:先确认目标排序方向。升序排序必须用arr[j] > temp。

6.5 常见错误速查表

错误现象可能原因检查方法处理建议
程序崩溃循环结束后使用arr[j]gdb打印j值改为arr[j + 1]
数组元素丢失没有用temp保存检查是否先赋值temp在移动前保存当前值
输出多一个垃圾值外层循环越界检查i的取值范围写成i < n
排序方向相反比较符号错误打印中间数组升序用>
死循环j没有递减检查是否遗漏j--内层循环每次都要递减j

7. 从直接插入排序到二分插入排序和希尔排序

7.1 二分插入排序:减少比较次数,不减少移动次数

直接插入排序的一个缺点是:在已经有序的前缀中查找插入位置时,是从右往左逐个比较。如果数组很长,比较次数会很可观。既然前缀是有序的,可以用二分查找快速定位插入位置,然后再统一移动元素。

C语言实现思路如下:

void binary_insertion_sort(int arr[], int n) { int i, j, left, right, mid, temp; for (i = 1; i < n; i++) { temp = arr[i]; left = 0; right = i - 1; while (left <= right) { mid = (left + right) / 2; if (arr[mid] > temp) right = mid - 1; else left = mid + 1; } for (j = i - 1; j >= left; j--) arr[j + 1] = arr[j]; arr[left] = temp; } }

这种写法把比较次数从O(n²)降低到O(n log n),但移动次数仍然是O(n²),所以最坏时间复杂度依然是O(n²)。它适合比较操作代价很高的场景,比如字符串排序,但这种优化对C语言基础学习者来说属于进阶内容。

7.2 希尔排序:插入排序的“间隔化”扩展

希尔排序利用插入排序在数据接近有序时效率高的特点,先按一定间隔分组,对每组做插入排序,然后逐步缩小间隔,最后间隔为1时做一次完整插入排序。由于前面几轮已经让数组基本有序,最后一轮移动次数明显减少。希尔排序的实际平均时间复杂度比直接插入排序好,但它是不稳定的。

理解直接插入排序是理解希尔排序的前提,因为希尔排序的每个分组内部用的就是插入排序思路。

7.3 插入排序和冒泡排序的对比

C语言初学者经常把插入排序和冒泡排序放在一起比较。它们的时间复杂度都是O(n²),但插入排序在数据接近有序时表现更好,冒泡排序在优化后可提前退出循环。冒泡排序主要做相邻交换,插入排序主要做元素平移。由于插入排序的移动通常比交换的赋值次数更少,在实际运行中,插入排序往往比冒泡排序稍快。

两种算法都可以作为简单排序入门。如果目标是理解“比较-交换”模式,选冒泡;如果目标是理解“有序区扩展”的算法思想,选插入。

8. 学习建议和可复用检查清单

8.1 建议按下面顺序完成练习

第一步,不看参考代码,把数组{12, 11, 13, 5, 6}的每一轮执行过程手写出来,重点写清楚每一轮数组变成什么样子,以及j值的变化。

第二步,在电脑上完成最小代码编译运行,自己修改输入数组,观察输出。

第三步,用-DDEBUG开启中间结果打印,对照手写过程检查是否一致。

第四步,尝试把函数修改为降序排序,注意只需要修改内层比较符号。

第五步,尝试给结构体数组按成绩排序,验证稳定性。第六步,再看二分插入排序和希尔排序,理解优化方向。

8.2 插入排序实现检查清单

在写完或审查一段插入排序代码时,可以按以下清单逐项检查:

  • 外层循环从1开始,到n-1结束。
  • 待插入元素已经用临时变量保存。
  • 内层循环先判断j >= 0,再访问数组元素。
  • 右移操作只针对大于temp的元素。
  • 每轮结束j都有递减,避免死循环。
  • 最后落位到arr[j + 1],不是arr[j]。
  • 函数没有使用额外数组,没有明显内存泄漏。
  • 测试过空数组?如果n为0,外层循环不会执行,需要保证函数不崩溃。
  • 测试过只有一个元素的数组。
  • 测试过完全逆序的数组。
  • 对结构体排序时,确认相等元素的相对顺序保持稳定。

8.3 入门之后怎么进一步深入

插入排序学完之后,可以继续观察归并排序如何采用分治策略,以及快速排序如何通过划分把大问题变小。它们虽然和插入排序思想不同,但都依赖“比较”和“移动”这两个基础操作。理解了插入排序以后,再去读那些算法的动图或代码,会更容易抓住关键点。

对于工程实践,如果数据规模很大,C语言标准库自带qsort,它内部使用快速排序,并且需要自己提供比较函数。学习插入排序并不是建议你在生产代码里手写排序,而是透过这个简单算法理解排序的本质。真正写工程代码时,除非数据量很小或接近有序,否则应优先选择标准库排序函数。不过,插入排序的意义在算法学习中无可替代。先把它彻底搞懂,后面的路会顺畅很多。

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

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

立即咨询