☰
插入排序详解:从打牌理牌到C语言实现
2026/10/4 11:34:32 网站建设 项目流程

有一次我在给一位刚学 C 语言的朋友讲排序。他皱着眉头问我:冒泡排序我勉强能看懂,插入排序到底在干嘛?为什么要把元素一个一个往前挪?我当时没有急着翻课本,而是从桌上拿了一副扑克牌,抽了六张,像发牌那样摆成一排,反问他:你平时打牌理牌,是怎么理的?他愣了一下说:起了牌之后,顺手插到该放的位置啊。我说:对,这就是插入排序。

插入排序是所有排序算法里最接近人类直觉的一个。它不搞什么花哨策略,就是重复一个动作:拿起一个元素,插进前面已经排好序的序列里,让它待在正确的位置。这个动作重复 n-1 次,整组数据就排好了。如果你准备零基础学排序,我真正建议你先把插入排序彻底搞懂,而不是急着去啃快速排序或者归并排序。原因不复杂:插入排序的价值不在“快”,而在把“排序”这个抽象问题,还原成了“整理”这个具体动作。它是最好的第一块跳板。

1. 为什么说插入排序是最接近人类直觉的排序算法

1.1 打牌理牌:你其实一直在手动执行插入排序

想象一下你正在玩扑克。摸完一轮牌,你手里已经有几张牌,比如是 5、3、7。这时候你又摸到一张 4。正常人不会把整手牌重新排一遍,而是很自然地看一圈,发现 4 应该放在 3 和 5 之间,于是把 5 和 7 往旁边挪一挪,腾出位置,把 4 放进去。

这个动作分解到计算机里,恰好就是插入排序的三个子步骤:

  1. 取牌:把当前要整理的元素拿出来。
  2. 腾位:从右往左依次比较,把比它大的牌往后挪。
  3. 落位:把牌放进腾出来的空位。

同样的动作重复若干次,整手牌就整整齐齐了。很多人觉得算法是课本里才有的东西,但插入排序恰恰是少数几种“你早就用过,只是不知道它叫这个名字”的算法。

我经常用这个类比去说服初学者:如果你的程序需要维护一个“始终保持有序”的数组,比如排行榜、成绩表、库存列表,每次新来一个数据都要插到合适位置,那么你其实是在重复使用插入排序的思想。

1.2 先建立一个正确的心理模型:它不是在“整体重排”

初学者最容易搞混的一点,是觉得插入排序像冒泡排序那样,从第一轮开始就在全局反复交换。不是的。插入排序每一轮只做一件事:把当前元素插到它前面那个“已经有序的序列”里。

所以在任意一轮进行中,数组都分成两段:

  • 左边一段:已经排好序。
  • 右边一段:还没处理,顺序保持原样。

随着轮次推进,左边这段不断变长,右边这段不断变短。最终右边消失,整个数组有序。

理解这个“半边有序、半边待处理”的心理模型,比记住代码本身更重要。因为后面你学二分插入排序、希尔排序,甚至归并排序,都离不开“局部有序”的概念。插入排序把这个概念展示得最直白。

2. 动画背后:一次完整的插入排序过程拆解

2.1 拿六个数字把整个过程走一遍

动画看的时候总是很快,容易一晃而过。我建议你拿笔在纸上,跟着下面这个例子手动推演一遍。用数组{5, 2, 4, 6, 1, 3}来演示。

先约定:我们把第一个元素 5 看作“已经排好序的部分”。从第二个元素开始,每一轮取出一个元素往前插。

轮次取出的 key插入前已排序部分操作摘要插入后的数组
初始-[5]把第一个元素视为已排序5, 2, 4, 6, 1, 3
12[5]5 后移,2 放到开头2, 5, 4, 6, 1, 3
24[2, 5]5 后移,2 前停止,4 插入中间2, 4, 5, 6, 1, 3
36[2, 4, 5]5 < 6,不需要移动2, 4, 5, 6, 1, 3
41[2, 4, 5, 6]6、5、4、2 依次后移,1 放到开头1, 2, 4, 5, 6, 3
53[1, 2, 4, 5, 6]6、5、4 后移,遇到 2 停止,3 插入1, 2, 3, 4, 5, 6

这六轮结束,数组变成有序。注意第 3 轮,key 是 6,它比前面已排序部分的最后一个元素 5 还大,所以一个都不用挪,直接原地不动。这是插入排序在“数据已经比较有序”时效率高的原因之一。

2.2 每一轮内部其实只有三步

很多人看动画被带偏,以为插入排序是在“交换元素”。实际上它更准确地说是在“移动元素”,并且只在最后做一次真正的插入。

每一轮循环内发生的事情是:

  1. 把arr[i]的值存到变量key里,此时原位置相当于被“掏空”了。
  2. 用一个下标j从i-1开始向左移动,凡是比key大的元素,都往右复制一位。
  3. 直到遇到一个不大于key的元素,或者已经遍历到数组最左边,循环停止。此时把key放进arr[j+1]。

注意第二步里“复制”这个词。插入排序内部大量操作是把arr[j]赋值给arr[j+1],这本质上是元素的后移,而不是交换。这一点在阅读代码时非常关键,很多初学者会疑惑:为什么我没写swap,数组却在变化?因为后移本身就是一种移动。

2.3 动画里真正值得盯住的三个细节

看插入排序动画的时候,我建议你刻意去盯三件事:

  • key 那个被抽出来的元素,它在每一轮开始时是“悬空”的,动画里通常会高亮。
  • 比较方向永远是“从右往左”,也就是从已排序部分的末尾往开头走。这个方向决定了你写 while 循环时j--的原因。
  • 元素不是交换过去的,而是一个一个“挤”过去的,视觉上像多米诺骨牌往右倒。

把这三个细节在脑子里和后面的代码对应起来,你就能做到“看动画能想到代码,看代码能想到动画”。

注意:插入排序每一轮只处理一个元素,千万不要一次性想把整个数组都排好。算法最忌讳“贪多”,一轮只解决一个元素的归属,是插入排序最朴素的智慧。

3. C 语言代码实现:先跑通,再讲优化

3.1 一个能直接运行的最小版本

先不用考虑各种花哨写法,下面这段是插入排序最经典、最容易理解的 C 语言实现:

#include <stdio.h> void insertion_sort(int arr[], int n) { int i, j, key; for (i = 1; i < n; i++) { key = arr[i]; // 取出当前要插入的元素 j = i - 1; // 从它前面一个位置开始向前找 // 只要前一个元素比 key 大,就把它往后挪 while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; // 空出来的位置放入 key } } int main() { int arr[] = {5, 2, 4, 6, 1, 3}; int n = sizeof(arr) / sizeof(arr[0]); insertion_sort(arr, n); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); return 0; }

这段代码在常见编译器环境下可以直接编译运行,输出结果是1 2 3 4 5 6。

3.2 逐行拆解核心循环

很多初学者拿到代码就背,背完就忘。我建议你换一种方式:一行一行地问自己“这一行在干什么,为什么在这里”。

第一行核心代码是:

for (i = 1; i < n; i++)

为什么不从 0 开始?因为第 0 个元素单独看就已经是“长度为 1 的有序序列”了,不需要插入。我们从第 1 个元素开始,把它插到前面长度为 1 的序列里;下一轮处理第 2 个元素,把它插到前面长度为 2 的序列里。i 的含义是“当前要处理的下标”。

第二句:

key = arr[i];

这句的意义是把当前元素备份出来。为什么要备份?因为后面的 while 循环会把前面的元素往右复制,有可能覆盖掉arr[i]的位置。如果不提前存到 key 里,等你想放回去的时候,原值已经丢了。

第三句:

j = i - 1;

j 代表“当前正在和 key 比较的那个位置”,从已排序部分的最后一个位置开始。

第四句是整个算法的灵魂:

while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; }

这里有两个条件,缺一不可:

  • j >= 0:防止访问数组下标变成负数。
  • arr[j] > key:只要前面的元素比 key 大,就需要往后挪。

循环内部做的事情,就是把arr[j]复制到它右边一位。注意,这里每一轮都会覆盖掉arr[j+1]原来的值,但那个值要么已经在上一轮被备份走了,要么就是被掏空的位置,所以不会丢失数据。

循环结束后,j 指向的是“最后一个不大于 key 的元素”的位置。因为循环退出前 j 又执行了一次j--,所以 key 的正确落点是j + 1。最后一句:

arr[j + 1] = key;

就是完成插入。

3.3 给排序加上过程输出,自己验证一遍

只看代码还是不够直观。我强烈建议你在排序函数里加几行打印,亲眼看看每一轮数组怎么变化:

#include <stdio.h> void print_array(int arr[], int n) { for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); } void insertion_sort(int arr[], int n) { int i, j, key; for (i = 1; i < n; i++) { key = arr[i]; j = i - 1; while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; printf("第 %d 轮插入后: ", i); print_array(arr, n); } } int main() { int arr[] = {5, 2, 4, 6, 1, 3}; int n = sizeof(arr) / sizeof(arr[0]); printf("初始数组: "); print_array(arr, n); insertion_sort(arr, n); return 0; }

运行后你会看到类似这样的输出:

初始数组: 5 2 4 6 1 3 第 1 轮插入后: 2 5 4 6 1 3 第 2 轮插入后: 2 4 5 6 1 3 第 3 轮插入后: 2 4 5 6 1 3 第 4 轮插入后: 1 2 4 5 6 3 第 5 轮插入后: 1 2 3 4 5 6

这个输出和第 2 节的手工推演完全一致。能对上,说明你对算法的理解没有偏差。

3.4 教科书版本:用 for 循环压缩写法

很多教材和源码会把 while 改写成 for 循环,看起来更紧凑:

void insertion_sort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; int j; for (j = i - 1; j >= 0 && arr[j] > key; j--) { arr[j + 1] = arr[j]; } arr[j + 1] = key; } }

这个版本和 while 版本逻辑完全一样,只是把“初始化 j、判断条件、j--”合并到了 for 语句里。我建议你两个版本都写一遍,理解它们是等价的。考试和面试时,遇到哪种写法都能马上反应过来。

4. 复杂度、稳定性与适用边界

4.1 时间复杂度要看三种情况

初学者容易把插入排序直接定性成“O(n²) 的慢排序”,这个说法太粗糙了。它的时间复杂度应该分三种情况看:

情况条件比较/移动次数时间复杂度
最好数据已经从小到大有序每轮只比较 1 次,不移动O(n)
平均数据随机排列每轮大约移动一半已排序元素O(n²)
最坏数据完全逆序每轮都要把前面所有元素往后挪O(n²)

最好情况很好理解:如果数组本来就是有序的,arr[j] > key第一次比较就失败,while 循环直接不进去,每一轮只做一次比较和一次赋值。所以插入排序在“处理基本有序的数据”时,效率其实很高,能达到线性级别。

最坏情况是逆序数组{6,5,4,3,2,1},每一轮的 key 都小于前面所有元素,所有已排序元素都要后移。比较次数和移动次数加起来大约是n(n-1)/2,也就是 O(n²)。

空间复杂度则是 O(1)。它只用了 i、j、key 这几个额外变量,在原地完成排序,不申请额外的数组。这一点在嵌入式环境或者内存受限的场景里是很大的优势。

4.2 稳定性:一个细节决定排序的“品格”

稳定性的定义是:如果数组里有两个相等的元素,排序之后它们的相对顺序不能变。

插入排序的稳定性,取决于 while 循环里比较条件用的是>还是>=。

  • 写成arr[j] > key:遇到相等的元素时,while 循环停止,key 被插到相等元素后面。相等元素的原始顺序不变,算法是稳定的。
  • 写成arr[j] >= key:遇到相等的元素时,仍然会把它往后挪,key 被插到相等元素前面。相等元素的顺序被反转,算法变得不稳定。

在只做单字段排序时,这个差别看不出来,结果都是有序的。但真实业务里经常有多字段排序,比如学生成绩表先按总分排,总分相同再按学号排。如果你在第二次排序时用了不稳定的算法,前一次按学号排好的顺序可能被破坏。所以稳定性不是理论洁癖,而是真实工程需求。

4.3 什么时候该用它,什么时候别用它

适合插入排序的场景,我总结为四个:

  1. 数据量小,比如几十个到几百个元素。
  2. 数据已经基本有序,只有少数元素位置不对。
  3. 数据是动态到来的,比如实时流式数据,每来一条就插入到已排序列表里。
  4. 内存紧张,不能申请额外的大数组。

不适合的场景也很明确:

  1. 数据量大且无序,比如几万、几十万个随机数,这时候快速排序、归并排序明显更合适。
  2. 对排序耗时极敏感的服务端场景,插入排序的 O(n²) 会成为瓶颈。
  3. 数据本身已经是稳定有序的结构,但你需要频繁大量调整顺序时,应该考虑更高效的数据结构,比如平衡树。

注意:插入排序是“小数据友好型”算法。把它用在大规模随机数据上,等于拿着一把水果刀去砍树,不是刀不行,是场景选错了。

5. 新手最容易栽的坑,以及一套排查顺序

5.1 三个高频 bug

我自己见过初学者写插入排序,最容易出现三个问题。

第一个:while 条件漏写j >= 0。直接写成while (arr[j] > key),当 j 减到 -1 时,会去访问arr[-1]。这在 C 语言里是未定义行为,运气好读到垃圾值,运气不好直接段错误崩溃。

第二个:把arr[j] > key写成arr[j] >= key。排序结果依然有序,所以很难发现,但算法从稳定变成了不稳定。如果后面你拿它做多字段排序,就会埋下隐患。

第三个:循环结束后插入位置写错。很多人想当然写成arr[j] = key,却忘了 while 循环退出前 j 已经多减了一次。应该是arr[j + 1] = key。这个 bug 的典型症状是:数组里某个元素丢失,或者某两个位置出现重复值。

还有一个不是循环本身的坑,而是数组大小的坑。在main里用sizeof(arr) / sizeof(arr[0])计算数组长度是对的,但一旦数组作为参数传进函数,它就退化成指针,sizeof(arr)不再是整个数组的大小,而是指针的大小。所以不要在函数内部重新用 sizeof 算长度,应该在调用前算好传进去。

5.2 一套从现象到根因的排查顺序

如果你写完代码发现结果不对,不要慌,按下面这个顺序排查:

  1. 先看现象是什么:是完全没排序?还是只有局部有序?还是最后一位不合法?还是直接崩溃?
  2. 把数组缩小到 3 到 5 个元素,用手推一遍中间结果,确定算法逻辑本身对不对。
  3. 在 while 循环里加打印,输出每一轮的 i、key、j,以及当前数组状态,看看卡在哪一步。
  4. 检查边界输入:空数组、单元素、重复元素、已经有序、完全逆序、含负数。
  5. 检查 n 的传递:是不是在函数里误用了sizeof。
  6. 检查比较方向:>还是>=,升序还是降序,j--还是j++。

这个排查顺序的核心是:先确认“算法逻辑”对不对,再确认“代码实现”对不对,最后才考虑“边界情况”对不对。很多初学者一上来就在网上问为什么崩溃,其实只要加两行打印,自己就能发现是j >= 0漏了。

5.3 正确性验证:不要只看一次输出

只跑一个样例得到正确结果,不代表代码没问题。我建议你准备一组测试数据,至少覆盖这些情况:

// 逆序数组,最坏情况 int arr1[] = {6, 5, 4, 3, 2, 1}; // 正序数组,最好情况 int arr2[] = {1, 2, 3, 4, 5, 6}; // 重复元素,验证稳定性隐患 int arr3[] = {3, 1, 3, 2, 3}; // 含负数 int arr4[] = {0, -2, 7, -1, 5}; // 单元素 int arr5[] = {1};

每次跑完,都写一个小的检查函数,确认数组确实是从小到大排列,而不是肉眼看一眼就完事。养成这个习惯之后,你以后学任何排序算法都会更快。

6. 从插入排序出发,建立算法学习的可复用框架

6.1 学任何一个排序算法的四个步骤

插入排序不只是教你一个算法,它还能帮你建立一套学习排序算法的方法论。以后你学冒泡排序、选择排序、快速排序、归并排序,都可以走同一个流程:

第一步,找一个生活场景。插入排序对应打牌理牌,冒泡排序对应气泡上浮,选择排序对应每轮挑最小的放最前面。没有生活场景,你对算法的记忆就是死记硬背。

第二步,手工推演。拿一个 6 个元素左右的小数组,把每一轮的中间状态写出来,至少走 3 个例子。

第三步,写最小可运行代码,加调试输出。先保证正确,再考虑优化或压缩写法。过程性打印是你最好的老师。

第四步,分析三个维度:时间复杂度的三种情况、空间复杂度、稳定性。最后明确它适合什么场景、不适合什么场景。

这套流程走完,你对一个算法的理解就不是“会用”,而是“懂它”。

6.2 两个顺势就能理解的进阶方向

理解了插入排序之后,有两个方向是你立刻就能往前走的。

第一个是二分插入排序。因为插入排序每一轮要插入的前半段已经有序,所以完全可以用二分查找快速定位插入位置。这样比较次数可以从 O(n²) 降到 O(n log n),但元素移动的次数仍然是 O(n²)。它的意义在于,让你看到“比较”和“移动”是两个可以分别优化的环节。

第二个是希尔排序。希尔排序的本质就是“多次插入排序”:先把数组按一定间隔分成若干组,对每组做插入排序,然后缩小间隔,直到间隔为 1。它的核心思想是让元素先进行大步移动,减少小步移动的总次数。如果你插入了插入排序的原理,希尔排序的代码你基本能看懂一半。

另外还有一个不那么直观但很重要的事情:很多现代混合排序算法,在处理小规模数组片段时,会退化到插入排序。比如 TimSort 在处理长度小于某个阈值的子数组时,就会调用插入排序,因为在小规模数据上,插入排序的常数开销相对更低。所以插入排序不是“被淘汰的算法”,它仍然藏在很多高性能排序的实现细节里。

6.3 回到一个更底层的经验

折腾完这一整条链路,我最想留给你的一句话是:学算法,先别急着追求最短的代码、最快的性能,先把“这个算法到底在重复做什么动作”想清楚。

插入排序重复的动作是“拿起一张牌,插进正确的位置”。你理解了这一点,代码怎么写都只是表达方式的问题。而当你理解了“为什么每轮要找右往左找、为什么挪完要插回 j+1、为什么相等元素不要越过”,你其实已经在用工程师的思维看算法了。

下一步,你可以试着把这份代码改成降序排序,或者改成二分插入排序,或者加上一个“如果本轮没有移动元素就直接结束”的提前退出。改着改着,你会发现自己不知不觉已经能独立折腾算法了。

先从这一份代码开始,把它跑通,把它打印出来,把它丢掉再重新默写一遍。搞定插入排序,30 分钟够用了。

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

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

立即咨询