【数据结构】排序算法(一):插入排序——直接插入、折半插入与希尔排序

目录

    • 一、直接插入排序
      • [1.1 算法思想](#1.1 算法思想)
      • [1.2 代码实现](#1.2 代码实现)
      • [1.3 运行推演](#1.3 运行推演)
      • [1.4 复杂度分析](#1.4 复杂度分析)
    • 二、折半插入排序
      • [2.1 算法思想](#2.1 算法思想)
      • [2.2 代码实现](#2.2 代码实现)
      • [2.3 运行推演](#2.3 运行推演)
      • [2.4 复杂度分析](#2.4 复杂度分析)
    • 三、希尔排序
      • [3.1 算法思想](#3.1 算法思想)
      • [3.2 代码实现](#3.2 代码实现)
      • [3.3 运行推演](#3.3 运行推演)
      • [3.4 复杂度分析](#3.4 复杂度分析)

前言: 插入排序是最古老、最直观的排序思想之一。它的基本逻辑类似于我们整理扑克牌:手中的牌保持有序,每摸到一张新牌,就向手牌中已排序的区域插入正确的位置。本文将以直接插入排序为起点,逐步引入折半查找优化和希尔排序的分组预排序思想,结合完整可运行的C代码、控制台输出日志与逐行解析,彻底讲透插入排序家族。

一、直接插入排序

1.1 算法思想

直接插入排序的核心思维是"有序区 + 无序区"。初始状态下,将数组的第一个元素视为已排序区间,剩余部分视为无序区间。每一趟排序从无序区间取出第一个元素,在已排序区间中从后向前扫描,找到合适的位置并插入。

在寻找位置的过程中,算法采取"边比较、边后移"的策略:当已排序区间中的元素大于待插入元素时,将该元素向后移动一位,直到遇到第一个小于或等于待插入元素的位置,将待插入元素放入该位置的后方。

1.2 代码实现

c 复制代码
void InsertSort(DataType a[],int n)
{
    int i, j;
    DataType x;
    for (i = 1; i < n; i++)
    {
      //先跟a[i-1]比较,比a[i-1]小则继续向前比较
        if (a[i] < a[i - 1])          
        {
            x = a[i];
          //直到找到比x小的数即停止
            for (j = i - 1; j >= 0 && a[j] > x; --j) 
            {
                a[j + 1] = a[j];          //比其大的数向后挪动一位
            }
            a[j + 1] = x;                 //将x放在合适位置
        }
    }
}

代码解析:

  • 外层 for 循环从 i = 1 开始,意思是将第 0 个元素看作初始有序区,从第 1 个元素开始逐个插入。
  • if (a[i] < a[i - 1]) 是一个重要的短路判断:如果当前待插入元素已经比有序区的最后一个元素大,说明它本来就处于正确位置,直接跳过,这能显著优化对近乎有序数组的性能。
  • 内层 for 循环同时完成了"查找"与"挪位":从 i-1 位置向前扫描,只要 a[j] > x,就把 a[j] 后移一位;当循环退出时,j 指向了第一个不大于 x 的元素,此时将 x 放入 j+1 位置,插入完成。

1.3 运行推演

初始数据: 4 6 3 2 8 0 1 10 5 4

状态日志(每趟排序后的数组):

复制代码
4 6 3 2 8 0 1 10 5 4 
3 4 6 2 8 0 1 10 5 4 
2 3 4 6 8 0 1 10 5 4 
2 3 4 6 8 0 1 10 5 4 
0 2 3 4 6 8 1 10 5 4 
0 1 2 3 4 6 8 10 5 4 
0 1 2 3 4 6 8 10 5 4 
0 1 2 3 4 5 6 8 10 4 
0 1 2 3 4 4 5 6 8 10 
0 1 2 3 4 4 5 6 8 10 

步骤解析: 以元素 0 的插入过程为例。第四趟结束时的数组为 2 3 4 6 8 0 ...,此时 0 前面是已经有序的 2,3,4,6,8a[5]=0 明显小于 a[4]=8,触发插入逻辑。x 保存为 0,内层循环从下标 4 开始向前比较:a[4]=8 > 0 后移;a[3]=6 后移;a[2]=4 后移;a[1]=3 后移;a[0]=2 后移;当 j 减为 -1 时循环终止。最终将 0 放入下标 0 的位置。可以看到,0 一步一步被挪到了数组头部,这形象地体现了"边比较边移动"的核心机制。

1.4 复杂度分析

  • 时间复杂度:
    • 最好情况:数组完全有序,内层循环永不执行,仅需外层遍历一趟, O ( n ) O(n) O(n)。
    • 最坏情况:数组完全逆序,每一趟都需要将有序区的所有元素后移,总比较与移动次数约为 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1),为 O ( n 2 ) O(n^2) O(n2)。
    • 平均情况: O ( n 2 ) O(n^2) O(n2)。
  • 空间复杂度: 只使用了常数级辅助变量 xij, O ( 1 ) O(1) O(1)。
  • 稳定性: 稳定。当遇到相等的元素时,判断条件 a[j] > x 会阻止进一步移动,因此相同元素的前后顺序不会改变。

二、折半插入排序

2.1 算法思想

直接插入排序在查找插入位置时采用顺序查找,效率较低。折半插入排序引入二分查找法,在"已排序区间"内快速定位插入点,减少了元素比较次数。然而,元素的物理移动仍然无法避免,整体移动开销不变。

2.2 代码实现

c 复制代码
void BInsertSort(DataType a[], int n)
{
    int low, high, mid, i;
    DataType x;
    for (i = 1; i < n; i++)
    {
        x = a[i];
        low = 0; high = i - 1;
        while (low <= high)
        {
            mid = low + (high - low) / 2;  //防溢出写法

            if (a[mid] > x){
                high = mid - 1;      //从左半部分继续找
            }
            else {
                low = mid + 1;       //从右半部分继续找
            }
        }
        for (int j = i - 1; j >= high + 1; --j)
        {
            a[j + 1] = a[j];     //找到合适位置后,该位置后的元素都向后挪动一位
        }
        a[high + 1] = x;          //插入到合适位置
    }
}

代码解析:

  • 二分查找的经典写法中,mid = low + (high - low) / 2 是一种防御性编程技巧,可以避免 low + high 可能发生的整型溢出。
  • while(low <= high) 退出时,high 指针总是停留在最后一个小于等于 x 的元素的位置上(或 -1)。因此插入位置就是 high + 1
  • 后续的 for 循环将 high+1i-1 之间的所有元素统一后移一位,然后放入 x。将查找与移动两个阶段明确分离。

2.3 运行推演

状态日志:

复制代码
4 6 3 2 8 0 1 10 5 4 
3 4 6 2 8 0 1 10 5 4 
2 3 4 6 8 0 1 10 5 4 
2 3 4 6 8 0 1 10 5 4 
0 2 3 4 6 8 1 10 5 4 
0 1 2 3 4 6 8 10 5 4 
0 1 2 3 4 6 8 10 5 4 
0 1 2 3 4 5 6 8 10 4 
0 1 2 3 4 4 5 6 8 10 
0 1 2 3 4 4 5 6 8 10 

步骤解析: 以插入 1 为例:此时有序区为 0,2,3,4,6,8。二分查找过程:low=0,high=5mid=2 (a[2]=3 > 1high=1);mid=0 (a[0]=0 < 1low=1);mid=1 (a[1]=2 > 1high=0);循环结束。最终插入位置 high+1 = 1。将 a[1..5] 的元素整体后移,把 1 放入 a[1]。相比直接插入排序,比较次数明显减少,但移动次数完全相同。

2.4 复杂度分析

  • 时间复杂度: 二分查找将比较次数降为 O ( n log ⁡ n ) O(n \log n) O(nlogn),但元素移动仍需 O ( n 2 ) O(n^2) O(n2),总体时间复杂度依然是 O ( n 2 ) O(n^2) O(n2)。
  • 空间复杂度: O ( 1 ) O(1) O(1)。
  • 稳定性: 稳定。二分查找过程中,当 a[mid] <= xlow = mid + 1,保证了相等元素不会交换前后顺序。

三、希尔排序

3.1 算法思想

希尔排序是对直接插入排序在效率上的革命性改进。它基于"缩小增量"的思想:先将整个待排序列按某个增量 gap 划分为若干子序列,对每个子序列分别进行直接插入排序;然后逐步缩小 gap,重复上述过程,直至 gap = 1 时,整个序列已基本有序,最后一次直接插入排序就能以近乎线性的代价完成。

其精妙之处在于:当 gap 较大时,元素可以一次跳跃多个位置,让较小的元素快速移动到数组前端,达到宏观有序的效果;随着 gap 减小,数组的局部有序性已经建立,插入排序的移动次数大幅降低。

3.2 代码实现

c 复制代码
void ShellSort(DataType a[], int n) {
	// 1. 初始化步长 gap,通常初始设为数组长度的一半
	// 每次循环步长减半,直到步长为 1
	for (int gap = n / 2; gap > 0; gap /= 2) {

		// 2. 从第 gap 个元素开始,依次将其插入到其所在的子序列中
		// 这层循环的作用就相当于直接插入排序中的 for(i=1; i<n; i++)
		for (int i = gap; i < n; i++) {

			int temp = a[i]; // 暂存当前准备插入的元素 (不使用哨兵 A[0])
			int j = i;

			// 3. 在当前子序列中,从后往前找插入位置
			// 每次比较的距离不是 1,而是 gap
			// 注意边界条件:j >= gap,防止 arr[j - gap] 越界
			while (j >= gap && a[j - gap] > temp) {
				a[j] = a[j - gap];     // 如果前面的元素更大,则将其向后移 gap 个位置
				j -= gap;              // 继续往前跳 gap 个位置比较
			}

			// 4. 将暂存的元素放入最终找到的位置
			a[j] = temp;
		}
	}
}

代码解析:

  • 最外层循环 for (int gap = n / 2; gap > 0; gap /= 2) 采用经典的希尔增量序列,每次将步长折半。
  • 中层循环 for (int i = gap; i < n; i++) 含义深远:它不是简单的分组后单独处理每个子序列,而是使用 i++ 交替扫描各个分组,相当于把多个插入排序交错执行。这种方式代码简洁且缓存友好。
  • 最内层 while 就是经典的直接插入排序逻辑,只不过比较和移动的步长变成了 gap,边界检查也变成了 j >= gap。这种结构本质上是将直接插入排序中步长为 1 的比较扩展为步长为 gap 的比较,当 gap = 1 时完全退化回原始的插入排序。

3.3 运行推演

初始数据: 4 6 3 2 8 0 1 10 5 4

状态日志:

复制代码
gap是 5: 0 1 3 2 4 4 6 10 5 8 
gap是 2: 0 1 3 2 4 4 5 8 6 10 
gap是 1: 0 1 2 3 4 4 5 6 8 10 

步骤解析: 当 gap=5 时,数组被分为 5 组:(4,0), (6,1), (3,10), (2,5), (8,4)。对每组执行插入排序后,最小的元素 0 瞬间从索引 5 跃迁到索引 0,1 也移动到前面,首轮便实现了数组的"宏观有序"。当 gap=2 时,进一步消除中距离的逆序;最后 gap=1 时仅需少量移动即完成最终排序。这诠释了希尔排序"跨大步调整,小步精修"的核心哲学。

3.4 复杂度分析

  • 时间复杂度: 强烈依赖于增量序列的选择。使用希尔增量(n/2 不断折半)时,复杂度约在 O ( n 1.3 ) O(n^{1.3}) O(n1.3) 到 O ( n 1.5 ) O(n^{1.5}) O(n1.5) 之间,最坏情况退化到 O ( n 2 ) O(n^2) O(n2)。采用 Hibbard 等更优增量序列可稳定在 O ( n 1.3 ) O(n^{1.3}) O(n1.3) 左右。
  • 空间复杂度: O ( 1 ) O(1) O(1)。
  • 稳定性: 不稳定。相同元素可能被分入不同的增量分组,在各自排序后原有的相对顺序可能被打乱。

下一篇将继续剖析交换排序:冒泡排序与快速排序,敬请关注。