排序之插入与希尔:直接插入排序、折半插入排序、希尔排序

排序算法,是数据结构里的基本功。我个人觉得,插入排序这一族算法,特别能体现「从简单到优化」的思维过程。今天咱们就聊聊直接插入排序、折半插入排序,还有那个让人又爱又恨的希尔排序。

一、直接插入排序——最朴素的思路

直接插入排序,说白了就是打扑克牌时理牌的手法。你摸到一张新牌,从右往左看,找到合适的位置插进去。这个过程,就是直接插入排序的核心。

它的工作原理是这样的:把待排序的数组分成两个区域——已排序区和未排序区。每次从未排序区取出第一个元素,在已排序区从右向左扫描,找到合适的位置插入。

核心要点:直接插入排序在基本有序的序列上表现极好,时间复杂度可接近 O(n)。但在完全逆序时,会退化到 O(n²)。

// 直接插入排序——C语言实现
void insertSort(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;  // 插入
    }
}

这段代码我写过无数遍。嗯,这里要注意:内层循环的条件是 arr[j] > key,这意味着相等的元素不会交换位置。所以直接插入排序是稳定的排序算法。

我的经验:我在项目中遇到过需要排序的数据量很小(比如几十个元素),而且数据本身已经接近有序的场景。直接插入排序跑起来,比快排还快。因为快排的递归开销在这种场景下反而成了负担。

二、折半插入排序——减少比较次数

直接插入排序有个明显的缺点:每次都要从右向左逐个比较。如果已排序区很大,比较次数就很多。那能不能用二分查找来加速呢?当然可以。这就是折半插入排序。

折半插入排序的思路是:在已排序区用二分查找找到插入位置,然后统一移动元素。比较次数从 O(n) 降到了 O(log n),但移动次数仍然是 O(n)。

// 折半插入排序——C语言实现
void binaryInsertSort(int arr[], int n) {
    int i, j, key, left, right, mid;
    for (i = 1; i < n; i++) {
        key = arr[i];
        left = 0;
        right = i - 1;
        // 二分查找插入位置
        while (left <= right) {
            mid = (left + right) / 2;
            if (arr[mid] > key)
                right = mid - 1;
            else
                left = mid + 1;
        }
        // 移动元素
        for (j = i - 1; j >= left; j--)
            arr[j + 1] = arr[j];
        arr[left] = key;
    }
}

为什么二分查找的条件是 arr[mid] > key 时往左走,否则往右走?因为我们要保证稳定性。当 arr[mid] == key 时,我们把新元素插到右边,这样相等元素的相对顺序就不会改变。

避坑指南:我曾经在实现折半插入排序时,把二分查找的边界条件写错了。left 和 right 的更新逻辑搞反了,结果排序结果完全不对。后来我养成了一个习惯:每次写完二分查找,先用小数据手动跑一遍边界情况。

折半插入排序的时间复杂度仍然是 O(n²),因为移动元素的开销没变。它只是减少了比较次数,对于比较操作代价高昂的场景(比如字符串排序)很有价值。

三、希尔排序——跨越式的改进

希尔排序,是插入排序的「升级版」。它的核心思想是:先让序列「基本有序」,然后再用插入排序收尾。怎么做到基本有序?通过增量分组。

希尔排序的工作原理:选择一个增量序列,比如 {5, 3, 1}。先按增量 5 分组,对每组进行插入排序;然后按增量 3 分组,再排序;最后按增量 1(就是普通的插入排序)收尾。

// 希尔排序——C语言实现
void shellSort(int arr[], int n) {
    int gap, i, j, key;
    // 增量序列:n/2, n/4, ..., 1
    for (gap = n / 2; gap > 0; gap /= 2) {
        for (i = gap; i < n; i++) {
            key = arr[i];
            j = i - gap;
            while (j >= 0 && arr[j] > key) {
                arr[j + gap] = arr[j];
                j -= gap;
            }
            arr[j + gap] = key;
        }
    }
}

你想想看,为什么希尔排序比直接插入排序快?因为经过前面几轮「粗调」,序列已经基本有序了。最后一轮插入排序时,元素移动的距离大大缩短。

增量序列的选择

增量序列的选择,直接决定了希尔排序的性能。我整理了几种常见的增量序列:

增量序列 递推公式 时间复杂度 特点
Shell 原始序列 gap = n/2, n/4, ..., 1 O(n²) 简单,但最坏情况差
Hibbard 序列 2^k - 1 O(n^(3/2)) 相邻增量互质,性能好
Sedgewick 序列 9*4^k - 9*2^k + 1 或 4^k - 3*2^k + 1 O(n^(4/3)) 实际表现优秀
Knuth 序列 (3^k - 1) / 2 O(n^(3/2)) 工程中常用

重要提醒:希尔排序是不稳定的排序算法。为什么?因为分组排序时,相等的元素可能被分到不同的组,它们的相对顺序就无法保证了。我在项目中用过希尔排序,但前提是我不关心稳定性。

四、排序的稳定性分析

稳定性,说白了就是:两个相等的元素,排序后谁在前谁在后?如果原来的相对顺序保持不变,就是稳定的;否则就是不稳定的。

咱们来总结一下今天讲的三种排序的稳定性:

  • 直接插入排序:稳定。因为遇到相等元素时,我们把它插到右边,不改变相对顺序。
  • 折半插入排序:稳定。二分查找时,相等时往右找,保证了稳定性。
  • 希尔排序:不稳定。分组排序时,相等的元素可能被分到不同组,顺序就乱了。

我的建议:如果你需要稳定的排序,优先考虑归并排序或插入排序。希尔排序虽然快,但不稳定。我曾经在一个数据库项目中,因为用了不稳定的排序导致数据对不上,排查了半天才发现是稳定性问题。

五、知识体系总览

下面这张图,把今天讲的内容串起来了。你可以看到三种排序之间的关系,以及增量序列的选择路径。

插入排序家族知识体系 插入排序 直接插入排序 折半插入排序 希尔排序 稳定 | O(n²) | 适合小规模 稳定 | O(n²) | 减少比较次数 不稳定 | O(n^(3/2)) | 跨越式改进 增量序列选择 Shell: n/2, n/4... Hibbard: 2^k-1 Sedgewick: 复杂公式 Knuth: (3^k-1)/2 核心:插入排序是基础,折半优化比较,希尔优化移动

从这张图可以看得很清楚:直接插入排序是基础,折半插入排序在比较次数上做了优化,而希尔排序则在移动次数上做了跨越式的改进。三种排序各有适用场景,没有银弹。

总结一下:

  • 数据量小且基本有序 → 直接插入排序
  • 比较操作代价高 → 折半插入排序
  • 数据量大且不要求稳定 → 希尔排序
  • 需要稳定排序 → 别用希尔,用直接插入或归并

好了,今天的内容就到这里。排序算法是基本功,多写几遍代码,多跑几个测试用例,自然就熟了。

公众号:蓝海资料掘金营,微信deep3321