
后台经常有同学私信问冒泡排序和选择排序都看完了还有必要学插入排序吗我的答案是——太有必要了。这一篇我们聊的是 C 语言排序系列里的插入排序法它可能是三种基础排序里最像人类直觉的一个也是后续理解希尔排序、二分插入排序甚至改进版快排的基石。这篇文章适合两类人一类是刚学完 C 语言数组、循环和函数准备进入数据结构领域的朋友另一类是已经会用冒泡、选择排序但想把排序细节、指针写法、调试技巧一并弄清楚的人。看完之后你能自己写出插入排序、能解释它的时间复杂度和稳定性还能在调试器里一步一步看它怎么把一个乱序数组理成有序。1. 插入排序的核心思路像打牌一样理牌1.1 为什么讲完冒泡和选择还要单独讲插入排序先回到系列前两篇。冒泡排序的思路是“两两比较、把大的往后冒”每一轮确定一个最大值放到末尾选择排序的思路是“每一轮找最小值、放到开头”每一轮确定一个最小值放到最前。这两个算法的共同点是每一轮都完整扫描数组排序效果是从一端逐步向另一端扩展。插入排序则完全不同。它从第二个元素开始每次只处理一个元素把它插入到前面已经排好序的子序列中。换句话说它维护的“已排序区域”不是通过完整扫描得到的而是随着每次插入逐步增长的。这种“增量式排序”的思路让插入排序在数据基本有序时表现惊艳也让它成为一种“在线算法”——你可以一边读取输入一边完成排序不需要等全部数据到齐。这个差异不是表面上的写法不同而是排序策略的根本不同。理解这一点之后再看冒泡、选择、插入三者的代码你会发现它们连循环结构都大不相同冒泡是双层 for 来回扫选择是 for 里套查找插入则是 for 套 while 的“边走边插”。1.2 用打牌理牌来理解插入排序我给学生讲插入排序从来不用书本上的定义直接问一个问题你在牌桌上摸牌的时候是怎么理牌的大多数人都是这样摸起一张新牌从右到左跟手里的牌比大小找到合适的位置把牌插进去。如果新牌比最后一张大直接放最右边如果比第一张还小就放到最左边。这个动作就是插入排序。对应到代码里“摸起一张新牌”对应int key arr[i]先把当前要处理的元素保存下来“跟手里的牌从右往左比”对应让j从i - 1开始往前遍历“把大的牌往右挪”对应arr[j 1] arr[j]给新牌腾出位置“把新牌插进空位”对应arr[j 1] key。所以插入排序的核心动作不是“交换”而是“移动 插入”。这个区别后面会专门展开。1.3 手动推演一遍完整过程光看概念容易飘建议跟着推演一遍。假设数组初始为{9, 5, 1, 4, 3}长度为 5。初始状态已排序部分只有第一个元素{9}其余{5, 1, 4, 3}是待排序部分。下面这张表展示了每一轮 key 的取值和插入后的结果轮次key比较过程数组状态初始--{9, 5, 1, 4, 3}i159 59 右移5 放到开头{5, 9, 1, 4, 3}i219 15 1两者右移1 放到开头{1, 5, 9, 4, 3}i349 45 4两者右移1 不动4 插入中间{1, 4, 5, 9, 3}i439 35 34 3三者右移1 不动3 插入{1, 3, 4, 5, 9}可以观察到两个规律每一轮结束后arr[0]到arr[i]这个区间一定是有序的。这个性质叫循环不变量后面写代码时它就是正确性的保证。新元素总是被插入到第一个比它小或等于的元素的右边这正是排序稳定性的来源。2. 代码实现从数组下标版到指针版2.1 标准版插入排序先把代码跑起来先给出最经典的下标版本完整可直接编译运行#include stdio.h void insertion_sort(int arr[], int n) { for (int i 1; i n; i) { int key arr[i]; // 取出当前要插入的元素 int j i - 1; // 从已排序部分的末尾开始往前找 // 只要前面的元素比 key 大就把它往后挪一位 while (j 0 arr[j] key) { arr[j 1] arr[j]; j--; } // 循环结束时j1 就是 key 该插入的位置 arr[j 1] key; } } void print_array(int arr[], int n) { for (int i 0; i n; i) { printf(%d , arr[i]); } printf(\n); } int main() { int arr[] {9, 5, 1, 4, 3}; int n sizeof(arr) / sizeof(arr[0]); printf(排序前: ); print_array(arr, n); insertion_sort(arr, n); printf(排序后: ); print_array(arr, n); return 0; }这段代码有几个关键点需要理解透彻。第一为什么必须先存key因为arr[j 1] arr[j]这个移动操作会把arr[i]原本的值覆盖掉。如果不先把key保存下来移动几个元素后原始值就丢了。这是初学插入排序最容易忽略的因果——我在给学弟改代码时见过太多次“排序结果里莫名其妙少了原数组的元素”基本都是栽在这里。第二外层循环为什么从1开始因为单个元素天然有序arr[0]不需要插入所以第一轮处理的应该是arr[1]。如果你从0开始虽然不会立刻报错但会做一轮无意义的操作。第三内层循环的退出位置。while循环退出的原因有两种要么j越界到了-1要么arr[j] key。无论哪种j 1这个位置就是key的最终位置。仔细想想如果是因为arr[j] key退出说明key应该排在arr[j]右边所以下标是j 1如果是因为j变成-1退出说明key比前面所有元素都小它该去的位置是下标0而j 1也正好是0。这个边界设计刚好吻合没有特殊情况。2.2 内层循环的两个条件缺一不可while (j 0 arr[j] key)里有两个条件少一个都不行而且顺序不能随意调换。先说j 0。当key是当前的最小值时j会一路减到-1此时如果条件里没有j 0代码会继续访问arr[-1]也就是访问数组开头之前的内存。在 Linux 上这可能直接触发段错误在 Windows 上则可能悄悄写坏内存等待你的是一串莫名其妙的运行时故障。很多初学者觉得“我数组里本来就是乱序的怎么会碰到最小值呢”——只要数组里有一个元素比前面所有元素都小这个分支就会被触发概率并不低。再说为什么是而不是,或者拆成两个if。C 语言的是短路求值的先判断左边如果左边为假右边根本不会执行。所以j 0写在左边可以保证arr[j]只有在下标合法时才被访问。如果你图省事写成arr[j] key j 0当j变成-1时会先访问arr[-1]再判断j 0越界已经发生了。这个“先判断下标再访问内存”的习惯在 C 语言里非常重要——它不只是写在排序算法里的任何用下标或者指针访问数组的逻辑都要守住这条底线。把这一条刻进肌肉记忆你后续写代码会少踩很多坑。arr[j] key这个严格大于号同样关键。它决定了整个排序是否稳定。如果写成arr[j] key排序完的结果看起来也是有序的但相等元素的相对顺序可能被打乱。为什么会出现这种情况我在第 3.2 节专门用一个实例讲透。2.3 指针版本换个视角操作内存热词列表里出现了很多次“c语言 指针”正好在这里补一个指针版本的插入排序帮你把数组和指针的关系打通。void insertion_sort_ptr(int *arr, int n) { for (int *p arr 1; p arr n; p) { int key *p; // 取出当前元素 int *q p - 1; // 指向已排序部分的末尾 // 从右往左扫描把大于 key 的元素右移 while (q arr *q key) { *(q 1) *q; q--; } *(q 1) key; } }这段代码里有一个初学者容易困惑的点q arr这样的指针比较合法吗答案是合法但有个前提两个指针必须指向同一个数组或者指向该数组末尾之后的下一个位置。这里的q始终在arr和arr n之间来回移动满足前提所以可以安全比较。再仔细看边界。当q递减到arr - 1时q arr为假while循环退出此时不会执行*(q 1) *q这种解引用操作。退出后执行*(q 1) key相当于*(arr) key正好把最小值放到开头。所以指针版本虽然看着吓人边界却没毛病。指针版本的加号减号运算也要理解到位q 1并不是在地址上加了 1 个字节而是加了sizeof(int)个字节。int *类型的指针加 1在 64 位系统上通常意味着地址值加 4。这个细节和数组下标的语义完全一致只是从“第几个元素”变成了“第几块内存”。我个人的建议是先用下标版本把插入排序的逻辑彻底想明白再用指针版本做思维训练。指针是一种工具不是目的。如果下标版本还写不利索就强行上指针往往会因为“数组越界”和“指针运算”叠加而彻底懵掉。3. 复杂度与稳定性为什么说它“灵活”3.1 时间复杂度推导最好、最坏与平均插入排序的时间复杂度跟数据的初始排列关系极大这也是它区别于选择排序的重要特征。选择排序无论数据长什么样比较次数都是固定的n(n-1)/2根本不存在“运气好”这一说。插入排序则完全不同。最好情况数组已经有序。外层循环从i 1走到n - 1内层循环每次只比较一次就退出因为arr[j] key不成立。总比较次数是n - 1总移动次数是 0时间复杂度是O(n)。把这段代码贴到已排序数组上跑你会发现速度快到几乎可以忽略。最坏情况数组完全逆序。第i轮需要把key和前面i个元素逐一比较并且每个元素都要右移一次。总比较次数和移动次数都是1 2 ... (n - 1) n(n-1)/2时间复杂度O(n²)。平均情况平均下来每轮大约需要比较一半的元素总比较次数大约n²/4时间复杂度也是O(n²)但常数比最坏情况小一半。从这些推导能得出一个重要结论插入排序在“基本有序”的数据上可以逼近O(n)但在“完全逆序”的数据上会退化到O(n²)。这个特性决定了它的适用场景——数据规模不大、且大体上有序时它是真正的王者。很多人会问冒泡排序在最好情况下也是O(n)啊两者有什么区别区别在于常数。冒泡即使只在最好情况下做了一次扫描它仍然要走完整个数组每一轮都在交换相邻元素。插入排序则在key比前面最后一个元素还大时直接停止连一次移动都不做。实测同样一个近乎有序的数组插入排序通常会比冒泡快 2-3 倍。3.2 空间复杂度与稳定性陷阱 和 之差先给结论插入排序是原地排序空间复杂度O(1)只用了一个key临时变量它同时也是稳定排序前提是内层循环用严格大于号。稳定性这个概念在算法教材里往往只是一行定义但在实际工程中非常重要。比如你有一个用户表先按注册时间排好序再按会员等级排序如果第二次排序是不稳定的就会把第一次排序的成果全部打乱。所以稳定排序这个性质直接决定你能不能省一次额外排序。为什么和会决定稳定性看这个例子。数组为{5a, 5b, 3}下标a、b只是用来区分两个值相等的5它们的原始顺序是5a在5b前面。如果代码写的是while (j 0 arr[j] key)i 1时key 5barr[0] 5a因为5a 5b成立所以5a被右移到arr[1]5b被插入到arr[0]。此时顺序已经变成{5b, 5a, 3}。i 2时key 35a和5b都被右移3放到开头最终结果是{3, 5b, 5a}。原来的5a在5b前面排序后却变成了5b在5a前面——相对顺序被破坏这就是不稳定。如果代码写的是while (j 0 arr[j] key)i 1时key 5barr[0] 5a因为5a 5b不成立循环退出5b被插入到5a后面顺序保持为{5a, 5b, 3}。后续无论怎么移动两个5的相对顺序始终不变最终结果是{3, 5a, 5b}。这个例子建议你自己跑一遍或者用笔推一遍。很多排序算法相关的面试题都会揪着这个细节不放面试官就等你写然后追问一句“那你的排序还稳定吗”。3.3 实际选型什么时候该用插入排序很多人学完快速排序就瞧不上O(n²)的排序但实际工程里插入排序的使用频率超乎你的想象。我用一个比喻来说明快排像搬家公司把整层楼的物品重新规划分区搬运插入排序像你在办公室新来了一份文件直接顺手插进正确的文件夹。事务量小时后者可能真的更快。具体而言有三个场景最适合插入排序数据规模小。当n在十几或者几十的量级时O(n²)和O(n log n)的绝对差距微乎其微而插入排序没有递归、没有额外数组分配、代码路径极短实际执行往往更快。很多标准库里的快排在递归切分到一定小规模后也会改用插入排序收尾就是这个原因。数据近乎有序。比如日志按时间戳排序时只有少量乱序记录或者某个排行榜每天只有零星几条分数变化。插入排序接近线性时间快排反而可能因为划分不均而退化。需要稳定排序。冒泡和插入都能做到稳定但插入排序的常数更小如果想稳定又不想写大段代码插入排序是最划算的选择。反过来如果数据规模很大、排列又完全随机那插入排序确实不适合硬扛这时候应该去学归并排序或者快速排序。有句话说得好没有万能的排序只有合适的排序。4. 常见问题排查与调试实录4.1 三个初学者最容易踩的坑我改过不少初学者的插入排序代码归纳下来有三个高频错误每个都值得单独说。坑一外层循环从 0 开始。写成for (int i 0; i n; i)不会导致立刻崩溃因为i 0时key arr[0]j -1内层循环不执行然后arr[0] key相当于白跑一轮。真正危险的是如果内层条件漏写了j 0那么第一轮就会访问arr[-1]直接越界。所以从 1 开始写既符合逻辑也是安全起点。坑二内层 while 漏写j 0。这是最经典的越界错误。只要key比当前已排序部分的所有元素都小j就会一路减到-1此时再去访问arr[j]就完蛋了。有的同学觉得“我运气好数组里的元素不是那么小”但排序算法就该处理一切输入只要有逆序数据就会踩雷。坑三把写成破坏了稳定性。排序结果看起来是对的因为数组确实变得有序了但如果你依赖稳定性做二次排序就会得到错误结果而且这种错误非常隐蔽可能跑很久才发现。排查方法就是用第 3.2 节那个{5a, 5b, 3}的例子做单元测试。还有一个不太算坑、但值得注意的点有人会在内层循环里用交换代替移动写成swap(arr[j1], arr[j])。功能上它能排序但它的代价要大得多——交换涉及到三次赋值而移动只需要一次赋值。插入排序的设计初衷本来就是“移动最少化”用交换反而弄巧成拙。看别人代码时注意分辨。4.2 用 gdb 单步观察一次插入过程热词里有“gdb 调试 c 语言程序”这里正好用插入排序实战一把。曾经我也觉得“排序代码这么短一眼就能看明白没必要开调试器”直到有次面试时被问到一个边界条件才发现自己根本没真正理解每一步的状态变化。后来我养成一个习惯新学的算法都要用 gdb 或类似的调试器单步走一遍。操作流程如下先编译再进调试器gcc -g insertion_sort.c -o insertion_sort gdb ./insertion_sort进入 gdb 后在while那一行设置断点然后运行break 8 run程序会在第一次进入内层循环前停下来。此时依次输入print i print key print j print arr[0] print arr[1]你会看到i 1key 5j 0arr[0] 9arr[1] 5。然后执行一次next程序完成一句arr[j 1] arr[j]再打印arr[1]和j你会发现arr[1]变成了9j变成了-1。再执行一次next完成arr[j 1] key此时arr[0]变成了5。这个动态过程非常直观你亲眼看着9往后挪了一位5补到了空位。gdb 的价值就在于把你脑内拼凑的逻辑变成能看见的内存状态演变。如果还想更进一步可以用watch命令监控变量变化watch key continue每当key的值改变程序就会自动暂停。对于跟踪外层循环的推进节奏这个命令很实用。4.3 VSCode 环境下的编译调试要点很多初学者用的是 VSCode热词里也有“vscode c 语言环境配置”“vscode 怎么运行 c 语言代码”。这里不展开全部配置过程只讲三个和排序调试最相关的注意点。第一编译参数一定要带-g。在tasks.json里写编译命令时如果少了-ggdb 就无法解析源代码行号你设置的断点可能会失效单步调试更是无从谈起。完整的编译命令大致是gcc -g insertion_sort.c -o insertion_sort。第二launch.json里的program路径要和编译输出的文件名完全一致。很多人编译输出是insertion_sort调试配置里却写成了insertion_sort.exe或者反过来的路径导致一按 F5 就报“找不到程序”。检查这两处的一致性能省掉大量无谓的折腾。第三如果出现“无法打开源文件”这类报错通常不是代码问题而是编译器路径或 include 路径没配好。在 Windows 上常见于 MinGW 没有正确添加到PATH环境变量。这种情况先把编译器在终端里跑通再回到 VSCode 配置问题通常就解决了。环境问题本身不是算法学习的主线但一个能单步调试的环境能让排序算法的学习效率翻倍。磨刀不误砍柴工值得花点时间把环境配好。5. 优化与延伸从插入排序走向更快的排序5.1 二分插入排序比较次数降一个量级插入排序的内层循环做了两件事比较找位置、移动腾位置。既然前面已经有序我们完全可以用二分查找来定位插入点把“比较”的复杂度从O(n)降到O(log n)。void binary_insertion_sort(int arr[], int n) { for (int i 1; i n; i) { int key arr[i]; int left 0; int right i - 1; // 二分查找“第一个大于 key 的位置”也就是插入点 while (left right) { int mid left (right - left) / 2; if (arr[mid] key) { right mid - 1; } else { left mid 1; } } // 把 [left, i-1] 区间的元素整体右移一位 for (int j i - 1; j left; j--) { arr[j 1] arr[j]; } arr[left] key; } }二分插入排序有个特别容易理解错的点二分查找的目标不是“找到等于 key 的那个元素”而是找“第一个大于 key 的位置”。当arr[mid] key时插入点可能在mid或者在它左边所以right mid - 1当arr[mid] key时为了保持稳定插入点应该在mid右边所以left mid 1。循环结束时left正好指向第一个大于key的位置也就是插入点。但也别高兴太早。二分只优化了比较次数移动元素仍然需要O(n)时间。数组右移的代价在数据量大时依旧可观所以二分插入排序的整体时间复杂度仍然是O(n²)。它真正的用武之地是“比较元素的开销远大于移动元素的开销”的场景比如数组元素是超长字符串比较一次要扫描几百个字符。这里有个小技巧mid left (right - left) / 2而不是(left right) / 2可以防止left right溢出。虽然对小型排序数组来说溢出基本不会发生但在大数组或者追求稳健的代码里这个写法是更专业的选择。5.2 哨兵优化理直气壮地删掉 j 0再来一个经典优化哨兵版插入排序。它的思路是先把全局最小值放到arr[0]这样内层循环里arr[j]永远不会小到越界因为arr[0]一定小于等于任何key于是可以理直气壮地删掉j 0这个判断。void insertion_sort_with_sentinel(int arr[], int n) { if (n 1) return; // 找到最小值放到 arr[0]作为哨兵 int min_idx 0; for (int i 1; i n; i) { if (arr[i] arr[min_idx]) { min_idx i; } } if (min_idx ! 0) { int tmp arr[0]; arr[0] arr[min_idx]; arr[min_idx] tmp; } // 从下标 2 开始插入arr[0] 永远兜底 for (int i 2; i n; i) { int key arr[i]; int j i - 1; while (arr[j] key) { arr[j 1] arr[j]; j--; } arr[j 1] key; } }注意细节交换后min_idx位置上变成了原来的arr[0]它一定大于等于最小值所以arr[0..1]这个区间天然有序。因此外层循环可以从i 2开始不会漏掉任何元素。哨兵优化的收益是省掉了每次内层循环里的j 0判断。单个判断看着不起眼但如果数组有几万或者几十万个元素逆序时内层循环要执行上百万次省掉一个比较就能省下可观的时间。工程里追求极致性能的地方经常能看到这种手法用空间或预处理换掉循环里的边界判断。不过这个版本也有代价它要先扫描一遍数组找最小值多了O(n)的额外开销而且改变了数组的初始形态。所以它更适合性能敏感且确定要原地排序的场景。作为学习材料它的价值在于展示“优化往往是在理解循环边界的基础上做减法”。5.3 冒泡、选择、插入的最终选择建议最后把三种基础排序放在同一张表里做一次全面对比排序算法最好时间复杂度平均时间复杂度最坏时间复杂度空间复杂度稳定性典型特征冒泡排序O(n)O(n²)O(n²)O(1)稳定交换频繁结构最简单选择排序O(n²)O(n²)O(n²)O(1)不稳定交换次数少比较次数恒定插入排序O(n)O(n²)O(n²)O(1)稳定近有序数据下碾压同级别根据这张表可以给出非常实用的选型建议完全随机的中等规模数据插入排序是三种里实际表现最好的因为它虽然也是O(n²)但常数最小。数据基本有序直接选插入排序它趋近线性时间其他两个都没有这个优势。交换的代价特别高比如元素是重量级结构体选择排序因为交换次数最少而占优即使不稳定也可以接受。要求稳定冒泡和插入都能写出来但插入排序更简单更快。其实这三种排序只是排序算法的开胃菜。插入排序的“增量有序”思想往后延伸一步就是希尔排序——它通过分组让数据先宏观有序再用插入排序收尾插入排序“对近有序数据特别快”这个特性也是改良版快速排序的底层依赖。把这些基础算法吃透了后面学高级算法会轻松很多。最后聊一点我的个人体会。前阵子我在处理一批日志数据时间戳字段已经是近乎有序的但中间夹着少量乱序记录。当时排序任务规模不大只有几万条我下意识用了系统库的qsort结果发现还要写比较函数、传一堆参数而数据量又没大到需要上快排的程度。后来换成插入排序几十行代码搞定跑下来时间几乎可以忽略。那之后我再也不会小看这三种最基础的排序了——它们不是教学玩具而是在数据规模小、数据近似有序、需要稳定排序时真正能打的方案。建议看完这篇文章后把标准版、对称数组版、指针版、二分版、哨兵版都亲手敲一遍再用 gdb 单步走一轮。你不光能拿下插入排序连带着数组、指针、循环边界这些 C 语言基本功都会扎实不少。