☰
插入排序图解与代码实现:从核心思想到希尔排序基石
2026/10/10 12:38:10 网站建设 项目流程

期末复习数据结构的时候,我盯着书上插入排序那几行代码看了好久,心里想的却是:"就这么点东西,动图还能画出什么花?"结果等到自己动手把 3,1,4,1,5,9,2,6 这个序列完整走了一遍,才意识到越简单的排序越容易在小细节上翻车。插入排序作为数据结构排序算法里最贴近日常思维的算法,恰恰是理解后面希尔排序、链表排序、甚至部分 STL 排序策略的基石。这篇文章就把我对插入排序的图解过程、动图关键帧、代码实现、复杂度推导完整整理出来,期末复习、考研刷题、面试准备都用得上。

1. 七大排序全景图:插入排序在排序家族里的定位

1.1 七种比较类排序一张表看清

提到数据结构排序算法,很多教材会把它们分成两大类:非线性时间比较类排序和线性时间非比较类排序。考研和期末最常考的七大排序,通常指的是插入排序、希尔排序、选择排序、堆排序、冒泡排序、快速排序、归并排序。

这七种算法如果按策略归类,大概是这样的:

排序算法基本策略平均时间复杂度稳定性
插入排序直接插入O(n²)稳定
希尔排序分组插入O(n^1.3~1.5)不稳定
选择排序选择最值O(n²)不稳定
冒泡排序相邻交换O(n²)稳定
快速排序分治递归O(n log n)不稳定
归并排序分治合并O(n log n)稳定
堆排序堆结构O(n log n)不稳定

很多同学背这张表时,总觉得插入排序是最没存在感的一个:先不说快速排序和归并排序这种"高级"算法,就连冒泡排序的名气都比它大。但实际做题就会发现,插入排序几乎每隔几章就会冒出来一次——要么是作为链表的排序题,要么是作为"优化后可以变成希尔排序"的铺垫,要么是作为"对基本有序数组最友好的简单排序"被拿出来和冒泡、选择对比。

我刚学的时候也犯过同样的错误:把插入排序和冒泡排序搞混。两者的循环结构看起来都像双重循环,也都涉及相邻元素的比较,但冒泡排序的核心动作是"交换",每一趟把最大值"浮"到末尾;插入排序的核心动作是"移动+插入",每一趟把无序区第一个元素"塞"进有序区。这个区别直接决定了插入排序在序列基本有序时表现远超冒泡。

1.2 为什么插入排序值得单独图解一遍

说实话,七种排序里随便挑一个都能写出一堆图解,但插入排序值得单独画图的原因有三个。

第一,它是唯一一个"人在日常生活中会自然使用"的排序算法。打扑克整理手牌、整理一叠试卷、往有序名单里加新人,本质上都在做插入排序。这种天然亲和力让它成为很多人理解算法第一步的入口。

第二,插入排序的动图和代码之间存在一个奇妙的"翻译鸿沟"。动图里 key 元素是悬空拿出来的,数组里留出一个空位,然后不断后移元素;但代码里一个arr[j + 1] = arr[j]就把这个过程压缩了。如果不看图,很难想明白到底为什么要反复覆盖同一个位置。

第三,插入排序是唯一一个能把"稳定性""哨兵优化""希尔排序基础"三个考点串起来的简单排序。把它的动图看懂了,后面学希尔排序几乎不用费力气。这也是我为什么强烈建议期末复习时,先花半小时把插入排序的每一轮手动走一遍,而不是直接背代码。

2. 插入排序的核心思想:有序区与无序区的动态推进

2.1 整理扑克牌就是迷你版插入排序

想象你手里有五张扑克牌,从左到右分别是 3、8、5、1、7,现在要把它们排成升序。正常人会怎么做?

我自己的习惯是:先看前两张,3 和 8,顺序是对的,不动。再看第三张 5,它比 8 小,比 3 大,那就把 5 插到 3 和 8 中间。再看第四张 1,它比 3 还小,那就插到最左边。整个过程里,我手里一直维持着一个"已经整理好"的前缀区,每次只需要把新牌塞进这个前缀区的合适位置,塞完前缀区变长,直到所有牌都整理完。

插入排序就是把这件事用代码表达出来。它的基础定义是:把一个待排序序列逻辑上分成两部分,左侧是有序区,右侧是无序区。每一轮从无序区取出第一个元素,在有序区里从右往左找到它该待的位置,插入进去。

用数据结构术语描述:初始时第 0 个元素自成一个有序区(一个元素天然有序),从第 1 个元素到第 n-1 个元素属于无序区。算法每一轮把有序区边界向右扩展一格,一共需要扩展 n-1 轮,排序完成。

2.2 单趟插入的三个动作:取、找、插

每一趟插入其实只做三件事,我习惯叫它"取出、找位、插入"。

取出:把当前轮次要处理的元素arr[i]先保存到临时变量 key 里。这一步极其关键,因为后面移动元素会覆盖它原来的位置。很多同学第一次写代码时忘了提前保存,结果移动完发现原值丢了。

找位:在有序区[0, i-1]范围内从右往左扫描,凡是比 key 大的元素,统统右移一位,为 key 腾出空间。看到比 key 小或者等于 key 的元素,就停下来。注意是"等于就停",这是插入排序稳定的关键,后面会细说。

插入:把 key 写入腾出来的空位,即arr[j + 1] = key。

单趟插入完成后,有序区范围从[0, i-1]变成[0, i],无序区首元素下标变成i+1。整个排序过程就好像有序区这个"窗口"从左往右一格一格推进,直到覆盖全部数组。

2.3 一个容易被忽略的点:为什么总是从右往左找

我见过不少初学者第一次自己实现插入排序,写成了"从左往右扫有序区,找到第一个比 key 大的位置就插进去"。功能上没问题,但性能差很多,代码也更复杂。

从左往右扫的问题是:你找到插入位置之后,还得把后面所有元素整体右移,才能把 key 放进去,这需要额外的循环和边界处理。而从右往左找是"边比较边移动"的合并操作——每比较一次,如果不满足条件,就直接把当前元素右移,同时指针左移。比较和移动同步进行,一趟循环结束,位置也腾好了,key 可以直接放入。

打个比方,左往右找像是先在地图上找到终点,再倒车回去;右往左找像是边开边挪路障,挪到位置正好停下。后者显然更节省操作。这个设计思想在后续很多排序和查找算法里都会反复出现:把两个独立的操作合并成同一个循环,减少无效遍历。

3. 动图视角下的逐轮图解:3,1,4,1,5,9,2,6 完整走一遍

3.1 动图里到底能看到什么关键帧

标题里说"动图展示",但静态博文里没法放动图,我换成"关键帧拆解"的方式来讲,效果一样,甚至比看动图更细致。

任何插入排序动图,本质上都在反复播放以下四个关键帧:

  • 关键帧一:指针 i 锁定无序区第一个元素,该元素高亮显示并"悬浮"出来,原位置变成空位。
  • 关键帧二:指针 j 从 i-1 开始向左移动,每指向一个比 key 大的元素,该元素就向右移动一格,填充上一个空位,同时原位置变成新的空位。
  • 关键帧三:指针 j 指向的元素比 key 小或相等,指针停止移动。
  • 关键帧四:key 从悬空状态落下,填入 j+1 位置的空位,一轮结束。

看动图最容易犯的错是只盯着"元素向右移动"的画面,却忽略了 key 在这个过程中始终保持悬空。我在下面直接用数组演示这个完整的动态过程。

3.2 第一轮到第三轮:处理重复元素时才见真功夫

演示数组我选了[3, 1, 4, 1, 5, 9, 2, 6],特意放进去两个 1,用来验证稳定性。

初始状态:[3, 1, 4, 1, 5, 9, 2, 6]。有序区只有[3],下标 1 到 7 都是无序区。

第一轮,i=1,key=1。j=0,arr[0]=3,3>1,所以 3 右移到下标 1。此时数组变成[3, 3, 4, 1, 5, 9, 2, 6],key 的下标 1 是空位。j 再左移变成 -1,循环结束。把 key=1 放入下标 0。数组变为[1, 3, 4, 1, 5, 9, 2, 6]。

这一轮,比较次数是 1 次(3 和 1 比),元素移动次数是 1 次(3 右移),key 取出和放入不算移动次数。如果实验报告要统计移动次数,记住这个口径。

第二轮,i=2,key=4。j=1,arr[1]=3,3>4 不成立,循环直接结束。key=4 放入下标 2,数组不变:[1, 3, 4, 1, 5, 9, 2, 6]。

这里有个细节值得单独说:当 key 比有序区最后一个元素还大时,它连一次移动都不需要,直接"原地落座"。这就是插入排序对基本有序数组特别友好的原因。

第三轮,i=3,key=1,这是最考验理解的一轮。

  • j=2,arr[2]=4,4>1,4 右移到下标 3。数组:[1, 3, 4, 4, 5, 9, 2, 6],下标 2 空。
  • j=1,arr[1]=3,3>1,3 右移到下标 2。数组:[1, 3, 3, 4, 5, 9, 2, 6],下标 1 空。
  • j=0,arr[0]=1,1>1 不成立,因为等于时不移动,循环停止。
  • key=1 放入下标 j+1=1。数组:[1, 1, 3, 4, 5, 9, 2, 6]。

注意看第三轮结束时,两个原本在后面的 1 保持了"先出现的在左、后出现的在右"的相对顺序。这就是插入排序稳定性的可视证据:遇到相等元素立刻停下来,不让后面的 key 越过前面相等的元素。

3.3 第四轮到第七轮:基本有序时的最优表现

第四轮,i=4,key=5。j=3 指向 4,4>5 不成立,循环立刻结束,key=5 原地落座。数组不变:[1, 1, 3, 4, 5, 9, 2, 6]。

第五轮,i=5,key=9。j=4 指向 5,5>9 不成立,key=9 原地落座。数组不变:[1, 1, 3, 4, 5, 9, 2, 6]。

第六轮,i=6,key=2,这是全场最"忙"的一轮。

  • j=5,arr[5]=9,9>2,9 右移。数组:[1, 1, 3, 4, 5, 9, 9, 6]。
  • j=4,arr[4]=5,5>2,5 右移。数组:[1, 1, 3, 4, 5, 5, 9, 6]。
  • j=3,arr[3]=4,4>2,4 右移。数组:[1, 1, 3, 4, 4, 5, 9, 6]。
  • j=2,arr[2]=3,3>2,3 右移。数组:[1, 1, 3, 3, 4, 5, 9, 6]。
  • j=1,arr[1]=1,1>2 不成立,循环停止。
  • key=2 放入下标 j+1=2。数组:[1, 1, 2, 3, 4, 5, 9, 6]。

这一轮比较了 5 次,移动了 4 次。可以发现,key 每遇到一个比自己大的元素,就要引发一次右移,所以"比较次数 = 移动次数 + 1"(最后一次失败比较不算移动)。

第七轮,i=7,key=6。

  • j=6,arr[6]=9,9>6,9 右移。数组:[1, 1, 2, 3, 4, 5, 9, 9]。
  • j=5,arr[5]=5,5>6 不成立,循环停止。
  • key=6 放入下标 6。数组:[1, 1, 2, 3, 4, 5, 6, 9]。

排序完成。把七轮比较次数和移动次数汇总一下:

轮次待插入值比较次数移动次数
1111
2410
3132
4510
5910
6254
7621
合计-148

这个表在写数据结构实验报告时可以直接用,图一画,统计表一列,再配上每轮结果输出,报告的"核心过程"部分就非常扎实了。

4. C语言实现与逐行拆解:教科书版和哨兵版

4.1 最稳妥的 while 循环写法

期末上机、考研手写代码,我最推荐的是下面这个版本,逻辑和我们的图解过程严格对应:

void insertion_sort(int arr[], int n) { for (int i = 1; i < n; i++) { int key = arr[i]; // 取出:把待插入元素保存到 key int j = i - 1; // 找位:从有序区末尾开始往前扫描 while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; // 移动:更大元素右移,腾出空位 j--; } arr[j + 1] = key; // 插入:key 落入空位 } }

逐行拆一遍:

外层for (int i = 1; i < n; i++)控制轮次,i 是每轮 key 的初始下标。i 从 1 开始而不是 0,因为第 0 个元素天然认为有序,不需要处理。

int key = arr[i]对应"取出"。这里用了临时变量 key,整个过程中 key 是悬空的,数组里 i 位置只要发生移动就会被其他元素覆盖,不提前取出来就全乱了。

内层while (j >= 0 && arr[j] > key)对应"找位"。两个条件缺一不可。j >= 0保证不越界,一旦 j 变成 -1,说明 key 比有序区所有元素都小,应插入到数组开头。arr[j] > key是移动条件,注意是严格大于,等于时不移动,这是稳定性的保证。

arr[j + 1] = arr[j]对应"移动"。每次把较大的元素右移一格。可以观察到,j+1 位置一开始是 key 原来的位置,之后每次移动填充的都是上一次移动留下的空位。

arr[j + 1] = key对应"插入"。j 停下来时,j+1 就是最后的空位。

我以前给同学讲这段代码的时候总强调一件事:把 while 循环和动图对应起来看。动图里"元素一格一格右移"的画面,在代码里就是一行arr[j + 1] = arr[j],j 每减一次,右移就发生一次。如果只看代码不看动图,大概率会困惑它为什么能连续移动。

4.2 哨兵版写法:省一次边界判断的代价

传统教材里还会介绍一种"哨兵"写法,把 arr[0] 当哨兵位,省去j >= 0的判断:

void insertion_sort_with_sentinel(int arr[], int n) { // 注意:arr[0] 作为哨兵,不存实际数据,传入数组长度需为 n+1 for (int i = 1; i <= n; i++) { arr[0] = arr[i]; // 把 key 存入哨兵位 int j = i - 1; while (arr[j] > arr[0]) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = arr[0]; } }

原理是:既然 key 已经保存在 arr[0],那么当 j 一路左移到 0 时,arr[0] > arr[0]永远不成立,循环自然会停,不需要再判断j >= 0。这相当于用 arr[0] 这个位置当做了"永远比不过的守护者"。

但我会提醒你:这个写法只在理论分析和考概念时有用,实际写排序函数几乎没人这么干,原因很简单——arr[0] 通常是真实数据,为了用哨兵还要额外留位置、挪数据,得不偿失。而且如果数组里本来就有数据,arr[0] 会被覆盖。期末考试如果要求写插入排序,直接写 4.1 那个普通版本最安全。

4.3 内层为什么用 while 而不是 for

很多教材给的是 for 写法:

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

这个写法和 while 版完全等价,只是把 j 的自减放进了 for 的更新部分。那为什么我推荐 while?

原因很简单:while 的结构更接近自然语言描述——"只要还没到数组头,并且当前元素比 key 大,就继续移动"。三个动作取、找、插在 while 版里分工更清晰,也更容易和动图对应。手写代码时 while 也不容易出现 for 循环里漏写更新语句这样的笔误。

另一个细节是关于j >= 0的位置。假如你把它写成arr[j] > key && j >= 0,在 C 语言里只要 j 先变成 -1,就会先访问 arr[-1] 造成未定义行为,可能恰好读到脏数据,也可能直接崩溃。正确写法必须让j >= 0在前,利用短路求值保护后面的数组访问。这是上机实验最容易踩中的坑之一,我亲眼见过室友因为这个连续调了半小时。

5. 复杂度与稳定性:面试和期末考常问的硬指标

5.1 最好、最坏、平均时间复杂度怎么推导

插入排序的时间复杂度分析是考研选择题的重灾区,但推导逻辑其实特别简单,只是很多人只背结果不记过程。

最好情况:数组已经有序。此时每一轮 key 只需要和有序区最后一个元素比较一次,发现不需要移动,直接插入。总共执行 n-1 轮,所以时间复杂度是 O(n)。注意,最好情况下的复杂度 O(n) 是三种简单排序(插入、选择、冒泡)里唯一能达到的。选择排序无论输入如何都要比较n(n-1)/2次,冒泡排序要加标志位才能提前退出。

最坏情况:数组完全逆序,即 [n, n-1, ..., 1]。第 i 轮(i 从 1 到 n-1)需要比较 i 次、移动 i-1 次。总比较次数:

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

总移动次数:

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

所以最坏情况时间复杂度是 O(n²)。

平均情况:一般认为一半元素需要移动,比较次数和移动次数都约为 n² 的四分之一左右量级,仍然是 O(n²)。考研题目如果问"插入排序平均时间复杂度",答案就是 O(n²)。

空间复杂度:排序过程中只用了一个临时变量 key,属于原地排序,空间复杂度 O(1)。

5.2 稳定性:为什么相等时必须停

稳定性的严格定义是:如果两个相等的元素在排序前相对顺序是 A 在 B 前,排序后仍然是 A 在 B 前,那么这个排序就是稳定的。

插入排序天然稳定,答案就在内层循环的arr[j] > key这个严格大于号上。当 key 等于某个 arr[j] 时,循环条件不成立,key 不会越过这个相等的元素,而是插入到它后面。这样就保持了原有相对顺序。

如果把严格大于改成大于等于,比如arr[j] >= key,那么相等的 key 会一路移动到所有相等元素的最前面,排序结果依然是正确的,但稳定性被破坏,后续面试官追问时会扣分。这个细节是区分"背了代码"和"真正理解"的高频题目。

5.3 三张简单排序的横向对比

插入、冒泡、选择常常被放在一起考,我习惯用一张表帮自己理清差异:

对比项插入排序冒泡排序选择排序
最好时间复杂度O(n)O(n)(加标志位)O(n²)
平均/最坏O(n²)O(n²)O(n²)
空间复杂度O(1)O(1)O(1)
稳定性稳定稳定不稳定
核心操作移动+插入相邻交换选择+交换
对基本有序数组非常友好加上标志位也友好不友好
每轮确定的位置有序区扩张当前最大值归位当前最小值归位

我重点标出最后两行。插入排序每一轮结束只是让有序区长了一个,不保证某个元素已经在最终位置上;选择排序每一轮能确定一个元素的最终位置;冒泡排序每一轮能确定当前最大值归位。

这三者里最"怕"逆序输入的是插入排序,最"无所谓"输入顺序的是选择排序,因为它的比较次数固定不变。知道这些区别,选择题考"哪个排序对近乎有序的数组最快"时就能秒选插入排序。

6. 从插入排序到希尔排序:这层递进关系是复习重点

6.1 插入排序的致命弱点:一次只能走一格

看第六轮那个 2,为了插入到正确位置,它一路把 9、5、4、3 全部挤开,自己才落座。这个过程暴露了插入排序的短板:每个元素每次最多向右移动一个位置,如果一个很小的元素在数组最右边,它要跨过前面所有元素,移动次数会非常恐怖。

举个例子,数组 [9, 8, 7, 6, 5, 4, 3, 2, 1] 在执行插入排序时,最后一个元素 1 要被比较 8 次、触发 8 次移动,才能到达第一位。整个排序的移动总量接近n²/2,完全逆序时就是最坏情况。

这个弱点不是偶然的,它来自插入排序"只能在相邻位置间搬运"的约束。理解了这一点,再看希尔排序的动机就顺理成章了。

6.2 希尔排序如何对症下药:先远距离搬家再精细调整

希尔排序的思路是:先选择一个增量 gap,把数组按间隔 gap 分成若干组,对每组内部做插入排序;然后逐步缩小 gap,重复分组插入;最后 gap=1 时做一次完整的插入排序。

这样做的意义在于,gap 较大时,原本相隔很远的元素可以在"组内"快速跨过多个位置,小元素不再需要一格一格挪,而是"跳"到属于自己的区域附近。等 gap 缩小到 1,数组已经是"基本有序"状态,此时最后一遍插入排序非常快,因为每个元素离它最终位置都不远。

希尔排序的时间复杂度无法精确给出,常见说法是依赖 gap 序列,通常在 O(n^1.3) 到 O(n²) 之间。它比插入排序快,但牺牲了稳定性——gap 分组会让相等的元素跨组移动,相对顺序无法保持。

期末复习时,我强烈建议把这两章放在一起看:先彻底掌握插入排序的图解和代码,再问自己"希尔排序把插入排序的哪个环节改进了"。这样记忆非常牢固,因为它不是两段孤立的知识,而是一条完整的演进线。

6.3 插入排序的现代应用不能被低估

有人觉得插入排序太基础,实际工作中用不到。这说法不对。很多优秀排序库在数据规模很小或局部有序时,都会退回到插入排序。最典型的是标准库中的 Introsort 策略:整体用快速排序,递归深度过深时改用堆排序,而当分块大小小于某个阈值(比如 16)时,改用插入排序完成收尾。原因很简单,快速排序在小规模数据上的递归开销远大于插入排序的简单操作开销,插入排序此时反而是最快的。

另外在链表场景下,数组版插入排序依赖下标从右往左扫描,但单向链表没法这样做,只能从头扫描找插入位置。链表插入排序的时间复杂度同样是 O(n²),但不涉及元素的物理移动,只需修改指针,实现起来也是面试高频题。很多同学把数组插入排序背得滚瓜烂熟,遇到链表版就懵,我建议动手画一下链表节点的前后指针变化,很快就通了。

7. 动图学习法:怎样把图解真正变成自己的东西

最后聊点学习层面的经验。我知道很多人收藏了一堆排序动图,结果期末一考还是不会写。问题出在"看"的方式上:动图一闪而过,只看到了元素在移动,没看进去为什么移动。

我自己的方法是"三刷动图"。

第一刷,只看,不暂停,建立整体印象:哦,有序区在慢慢变长,左边越来越整齐。

第二刷,暂停到每一个关键帧,对照数组下标画出来。我会在草稿纸上写下 i、j、key 三个变量当前的值,再写出数组此刻的状态。这一步是把动态画面翻译成静态数据结构的过程,非常关键。

第三刷,捂住动图,自己手动执行一遍刚才的数组序列,然后再放动图核对。自己哪里画错,哪里就是理解漏洞。

最后再配合一个"口头复述":闭眼,描述一次完整过程。如果能把"取出 key,j 从 i-1 开始往左扫,大于 key 就右移,等于或小于就停,插入到 j+1"顺口说出来,这轮知识才算真正装进脑子。

还有一个配套技巧是"用颜色标记空位"。动图里那个悬空的 key 和它的原位置是理解重点,很多人忽略的是:所有右移元素都在"填补空位",而新的空位又不断产生,最后 key 落下。这个过程用颜色画一遍之后,再看代码里的arr[j + 1] = arr[j],就再也不会觉得抽象了。

期末复习也好,准备面试也好,我始终认为排序算法宁可少看十个动图,也要手动走完一个序列。插入排序是最适合用来"走第一遍"的算法,等你能不看任何参考把这个完整过程画出来,再看希尔排序、快速排序,很多原理都会变得顺理成章。

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

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

立即咨询