C语言插入排序详解:原理、实现与优化 📅 发布时间:2026/9/2 18:36:37 👁 浏览次数: 插入排序是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和88 28右移{5, 8, 8, 6, 9, 3}。比较2和55 25右移{5, 5, 8, 6, 9, 3}。越界将2放在下标0{2, 5, 8, 6, 9, 3}。第3轮处理下标3的元素6保存6。比较6和88 68右移{2, 5, 8, 8, 9, 3}。比较6和55 6停止移动。将6放在原来8的位置即下标2{2, 5, 6, 8, 9, 3}。第4轮处理下标4的元素9保存9。比较9和88 9不需要移动数组保持不变{2, 5, 6, 8, 9, 3}。第5轮处理下标5的元素3保存3。比较3和99 39右移{2, 5, 6, 8, 9, 9}。比较3和88 38右移{2, 5, 6, 8, 8, 9}。比较3和66 36右移{2, 5, 6, 6, 8, 9}。比较3和55 35右移{2, 5, 5, 6, 8, 9}。比较3和22 3停止移动。将3放在下标1{2, 3, 5, 6, 8, 9}。排序完成。2.2 每一轮的状态变化表把上面的过程整理成表格能更清楚地看到“有序区”和“待处理区”的变化。轮次插入前数组被插入元素移动的元素插入后数组初始8 5 2 6 9 3无无8 5 2 6 9 318 5 2 6 9 3585 8 2 6 9 325 8 2 6 9 328、52 5 8 6 9 332 5 8 6 9 3682 5 6 8 9 342 5 6 8 9 39无2 5 6 8 9 352 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配合MinGWLinux和macOS下一般可以直接用系统自带的gcc。这里以命令行为例这也是最容易复现的方式。先检查编译器是否存在gcc --version如果提示找不到命令需要安装编译器。常见系统可参考下表但不代表所有版本和发行版行为完全一致实际安装要以自己环境的命令说明为准。环境常用安装方式安装后检查Ubuntu/Debiansudo apt install gccgcc --versionCentOS/RHELsudo yum install gccgcc --versionmacOS安装Command Line Tools后自带gcc --versionWindowsMinGW-w64或Dev-Cgcc --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 643.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]当作待插入元素而前面的有序区为空没有任何意义。所以外层循环从i1开始意味着每次假设“下标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已经变成-1arr[j]会访问数组下标为-1的位置这是未定义行为程序可能崩溃也可能返回一个随机值。正确顺序是先判断j是否有效再访问数组元素。4.4 一个容易忽略的细节j和j 1的关系内层循环结束后j指向的是“不满足移动条件的位置”。如果j等于-1说明temp比有序区所有元素都小应该放到下标0也就是j1等于0。如果j停在一个小于等于temp的元素上那么temp应该放在这个元素后面也就是j1。很多初学者会在循环结束后写成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 单元素425.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--内层循环每次都要递减j7. 从直接插入排序到二分插入排序和希尔排序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它内部使用快速排序并且需要自己提供比较函数。学习插入排序并不是建议你在生产代码里手写排序而是透过这个简单算法理解排序的本质。真正写工程代码时除非数据量很小或接近有序否则应优先选择标准库排序函数。不过插入排序的意义在算法学习中无可替代。先把它彻底搞懂后面的路会顺畅很多。