希尔排序(Shell Sort)

插入排序的高效改进 —— 递减增量排序算法

一、算法概述

希尔排序(Shell Sort),又称递减增量排序算法(Diminishing Increment Sort), 是直接插入排序算法的一种更高效的改进版本。它由计算机科学家 Donald Shell1959 年 提出, 是第一批突破 O(n²) 时间复杂度的排序算法之一,在排序算法的发展史上具有里程碑式的意义。

希尔排序的核心思想是:将相距一定间隔(gap)的元素分组,先对各组进行插入排序,然后逐步缩小间隔, 最终当间隔为 1 时做一次完整的插入排序。由于前期的大间隔排序使数组部分有序,最后一次插入排序的 移动次数大幅减少,整体性能显著优于普通插入排序。

算法特征

  • 发明时间: 1959 年,Donald Shell
  • 类别: 比较排序、内部排序、不稳定排序
  • 核心思想: 递减增量(先粗排后细排)
  • 空间复杂度: O(1)(原地排序)
  • 时间复杂度: 依赖增量序列,最好 O(n log n),最坏 O(n^(4/3)) 至 O(n²)
  • 稳定性: 不稳定
  • 适应性: 对中等规模数据(几千到几万)表现优异

二、算法思想

2.1 从插入排序出发

回顾直接插入排序:在有序序列中插入一个新元素时,需要逐个比较和移动元素,最好情况 O(n)(已有序), 最坏情况 O(n²)(逆序)。当数据量较大且逆序程度较高时,插入排序的效率非常低下。

希尔排序的改进思路是:与其让元素一次只移动一个位置,不如让元素一次可以跨越多步移动。 通过设置间隔 gap,将数组分割成若干子序列,先对这些子序列分别进行插入排序, 使得整个数组"基本有序",最后再对全体进行一次插入排序。

"希尔排序的本质就是:利用插入排序在数据基本有序时效率极高的特性,先通过大间隔分组排序让数据快速趋于有序,再用小间隔精细调整。"

2.2 分组插入 —— 图解示例

以数组 [9, 8, 3, 7, 5, 6, 4, 1] 为例,初始 gap = 4:

第一轮(gap = 4):

原数组: [9, 8, 3, 7, 5, 6, 4, 1] 分组: 9 --- 5 (索引 0 和 4) 8 --- 6 (索引 1 和 5) 3 --- 4 (索引 2 和 6) 7 --- 1 (索引 3 和 7) 组内排序后: 5 --- 9 6 --- 8 3 --- 4 1 --- 7 结果: [5, 6, 3, 1, 9, 8, 4, 7]

第二轮(gap = 2):

数组: [5, 6, 3, 1, 9, 8, 4, 7] 分组: 5 - 3 - 9 - 4 (偶数索引) 6 - 1 - 8 - 7 (奇数索引) 组内插入排序后: 偶数索引组: 3, 4, 5, 9 奇数索引组: 1, 6, 7, 8 结果: [3, 1, 4, 6, 5, 7, 9, 8]

第三轮(gap = 1 —— 标准插入排序):

数组: [3, 1, 4, 6, 5, 7, 9, 8] 此时数组已经"基本有序",只需少量比较和移动即可完成: 结果: [1, 3, 4, 5, 6, 7, 8, 9]

2.3 递减增量过程

增量序列的选择直接影响希尔排序的性能。递减增量的含义是:每轮排序所使用的间隔 gap 从大到小递减,最终 gap = 1 时做完整排序。整个过程可以形象地理解为"先粗调,后微调"。

  1. 选择增量序列: 确定一组递减的增量值,最后一个增量必须为 1。例如初始 gap = n/2,之后每次 gap = gap / 2(向下取整)。
  2. 按间隔分组: 对于当前增量 gap,将数组分成若干组,每组元素索引相差 gap 的倍数。
  3. 组内插入排序: 对每一组分别进行直接插入排序。
  4. 缩小增量: 按增量序列取下一个更小的 gap,重复步骤 2 和 3。
  5. 最终排序: 当 gap = 1 时,做一次完整的插入排序,数组完全有序。

三、增量序列的选择

增量序列的选择是希尔排序设计的核心问题。不同的增量序列会导致截然不同的时间性能表现。 事实上,希尔排序的时间复杂度至今仍然是一个开放的研究问题,尚未完全解决。

3.1 Shell 原始增量序列(最常用)

公式: gap = ⌊n / 2⌋, ⌊n / 4⌋, ..., 1,即每次将 gap 除以 2。

// Shell 原始增量序列(最常用) function shellSort(arr) { let n = arr.length; for (let gap = Math.floor(n / 2); gap > 0; gap = Math.floor(gap / 2)) { // 对间隔为 gap 的元素进行插入排序 for (let i = gap; i < n; i++) { let temp = arr[i]; let j; for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) { arr[j] = arr[j - gap]; } arr[j] = temp; } } return arr; }

最坏时间复杂度: O(n²)。当 n 为 2 的幂时,偶数位置的元素始终不会与奇数位置的元素进行比较,导致性能退化。

3.2 Hibbard 增量序列

公式: 2^k - 1,即 ..., 15, 7, 3, 1。

// Hibbard 增量序列生成 function hibbardGaps(n) { let gaps = []; let k = 1; while (Math.pow(2, k) - 1 < n) { gaps.unshift(Math.pow(2, k) - 1); k++; } return gaps; } // 例如 n = 1000: [511, 255, 127, 63, 31, 15, 7, 3, 1]

最坏时间复杂度: O(n^(3/2))。由于 Hibbard 增量序列的相邻增量互质,避免了 Shell 原始序列的奇偶分离问题。

3.3 Sedgewick 增量序列

公式(两种定义之一): 9 * 4^k - 9 * 2^k + 1(k ≥ 0),或 4^(k+1) + 3 * 2^k + 1。 得到序列:..., 109, 41, 19, 5, 1。

// Sedgewick 增量序列生成 function sedgewickGaps(n) { let gaps = []; let k = 0; while (true) { let gap = 9 * Math.pow(4, k) - 9 * Math.pow(2, k) + 1; if (gap >= n) break; gaps.unshift(gap); k++; } return gaps; } // 例如 n = 1000: [593, 209, 109, 41, 19, 5, 1]

最坏时间复杂度: O(n^(4/3))。Sedgewick 序列是目前已知的综合性能最好的增量序列之一,Robert Sedgewick 在《算法》一书中详细论证了其优越性。

3.4 增量序列对比表

增量序列 生成公式 最坏时间复杂度 特点
Shell 原始 ⌊n / 2^k⌋ O(n²) 实现简单,大数组有退化风险
Hibbard 2^k - 1 O(n^(3/2)) 相邻增量互质,性能优于 Shell
Sedgewick 9·4^k - 9·2^k + 1 O(n^(4/3)) 综合性能最优,推荐使用
Knuth (3^k - 1) / 2 O(n^(3/2)) 实践中表现良好,易于计算
Ciura 经验序列 O(n^(4/3)) 基于实验得出的最优序列

增量序列选择建议

  • 通用场景: 使用 Sedgewick 序列或 Knuth 序列,性能稳定可靠。
  • 简单实现: 使用 Shell 原始增量(gap = n/2, n/4, ...),代码简洁且对中小规模数据已足够。
  • 极致性能: 使用 Ciura 经验序列(...701, 301, 132, 57, 23, 10, 4, 1),已被实验证明是目前最优的增量序列之一。
  • 增量互质性: 好的增量序列要求相邻增量之间互质,以避免同余模式导致性能退化。

四、算法实现

以下是希尔排序在多种主流编程语言中的实现,均采用 Shell 原始增量序列(gap = n/2, n/4, ..., 1)。

4.1 Python 实现

# Python 希尔排序实现 def shell_sort(arr): n = len(arr) gap = n // 2 # 递减增量循环 while gap > 0: # 对每个分组进行插入排序 for i in range(gap, n): temp = arr[i] j = i # 组内元素后移 while j >= gap and arr[j - gap] > temp: arr[j] = arr[j - gap] j -= gap arr[j] = temp gap //= 2 return arr # 测试 arr = [9, 8, 3, 7, 5, 6, 4, 1, 2] print("排序前:", arr) shell_sort(arr) print("排序后:", arr)

4.2 Java 实现

// Java 希尔排序实现 public class ShellSort { public static void shellSort(int[] arr) { int n = arr.length; // 递减增量循环 for (int gap = n / 2; gap > 0; gap /= 2) { // 对每个分组进行插入排序 for (int i = gap; i < n; i++) { int temp = arr[i]; int j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } } } public static void main(String[] args) { int[] arr = {9, 8, 3, 7, 5, 6, 4, 1, 2}; System.out.println("排序前: " + Arrays.toString(arr)); shellSort(arr); System.out.println("排序后: " + Arrays.toString(arr)); } }

4.3 C++ 实现(模板泛型)

// C++ 希尔排序(泛型实现) template<typename T> void shellSort(T arr[], int n) { // 递减增量循环 for (int gap = n / 2; gap > 0; gap /= 2) { // 对每个分组进行插入排序 for (int i = gap; i < n; i++) { T temp = arr[i]; int j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } } } // 使用示例 int main() { int arr[] = {9, 8, 3, 7, 5, 6, 4, 1, 2}; int n = sizeof(arr) / sizeof(arr[0]); shellSort(arr, n); for (int i = 0; i < n; i++) { std::cout << arr[i] << " "; } return 0; }

4.4 JavaScript 实现(使用 Sedgewick 增量序列)

// JavaScript 希尔排序(Sedgewick 增量序列) function shellSortSedgewick(arr) { const n = arr.length; // 生成 Sedgewick 增量序列 const gaps = []; let k = 0; while (true) { let gap = 9 * Math.pow(4, k) - 9 * Math.pow(2, k) + 1; if (gap >= n) break; gaps.unshift(Math.floor(gap)); k++; } if (gaps.length === 0 || gaps[gaps.length - 1] !== 1) { gaps.push(1); } // 使用增量序列进行排序 for (let gi = 0; gi < gaps.length; gi++) { const gap = gaps[gi]; for (let i = gap; i < n; i++) { const temp = arr[i]; let j = i; while (j >= gap && arr[j - gap] > temp) { arr[j] = arr[j - gap]; j -= gap; } arr[j] = temp; } } return arr; } // 测试 const arr = [9, 8, 3, 7, 5, 6, 4, 1, 2]; console.log("排序前:", arr); shellSortSedgewick(arr); console.log("排序后:", arr);

五、时间复杂度分析

5.1 时间复杂度的一般规律

希尔排序的时间复杂度是增量序列的函数,这是它与 O(n log n) 排序算法最大的不同之处。 不同的增量序列会导致不同的时间复杂度上界。

增量序列 最坏时间复杂度 平均时间复杂度(实验值)
Shell 原始(n/2^k) O(n²) O(n^(3/2))
Hibbard(2^k - 1) O(n^(3/2)) 约 O(n^(5/4))
Sedgewick(9·4^k - 9·2^k + 1) O(n^(4/3)) 略优于 O(n^(4/3))
Knuth((3^k - 1)/2) O(n^(3/2)) 约 O(n^(5/4))
Ciura 经验序列 未知(实验最优) 接近 O(n log n)

时间复杂度核心要点

  • 最佳情况: O(n log n) —— 当数组已经基本有序时,希尔排序退化为近似插入排序的最佳情况。
  • 最坏情况: 使用 Shell 原始增量且数组大小为 2 的幂时,可能退化到 O(n²)。这个问题可以通过使用更优的增量序列来避免。
  • 平均情况: 使用 Sedgewick 序列时约为 O(n^(4/3)),虽然比 O(n log n) 略差,但由于常数因子很小,在中等规模数据上实际性能往往超过快速排序。
  • 空间复杂度: O(1),希尔排序是原地排序算法,不需要额外的存储空间。

5.2 为什么希尔排序这么快?

希尔排序的性能优势来源于它巧妙地利用了插入排序的两个核心特性:

  1. 插入排序在数据基本有序时效率极高(接近 O(n)): 希尔排序通过大间隔排序让数据快速"大致有序",为最终的插入排序创造了理想条件。
  2. 大间隔排序减少了大量远距离逆序对: 在 gap 较大时,元素可以一次跨越多个位置,迅速消除远距离的逆序对,这比插入排序的一次只移一位高效得多。

性能对比:希尔排序 vs 插入排序

以对 10,000 个随机整数排序为例的基准测试(实验数据):

  • 插入排序: 约 0.12 秒(逆序数据约 0.25 秒)
  • 希尔排序(Shell 增量): 约 0.003 秒
  • 希尔排序(Sedgewick 增量): 约 0.002 秒

两者性能差距达到 40-60 倍。数据量越大,差距越显著。

六、稳定性分析

希尔排序是不稳定的排序算法

稳定性是指:如果两个相等的元素在排序前的相对顺序与排序后保持一致,则排序算法是稳定的。 希尔排序破坏了稳定性,原因如下:

在希尔排序的不同轮次中,同一组内的元素进行插入排序时,会改变它们的相对位置。 更关键的是,不同轮次间,元素被分入不同的组,分组方式是按照 gap 取模确定的。 一个元素可能在早期被移到了大于它的相等元素的后面,而在后续轮次中再也无法回到前面。

示例说明: 假设数组 [5a, 5b, 3](5a 和 5b 都是 5,但用字母区分顺序)。

原始: [5a, 5b, 3] gap = 2: 分组:(5a, 3) 和 (5b) 排序后: [3, 5b, 5a] ← 5a 移到了 5b 后面,相对顺序改变 gap = 1: 整体插入排序 结果: [3, 5a, 5b] 或 [3, 5b, 5a] 但无论哪种,原始顺序 5a→5b 都已无法保证

因此,如果需要稳定的排序(例如对多个关键字依次排序),不能使用希尔排序, 而应该选择归并排序、冒泡排序或插入排序等稳定排序算法。

七、希尔排序 vs 插入排序 —— 详细对比

对比维度 插入排序 希尔排序
时间复杂度(平均) O(n²) O(n^(4/3)) ~ O(n^(3/2))
时间复杂度(最好) O(n) O(n log n)
时间复杂度(最坏) O(n²) O(n^(4/3)) ~ O(n²)(依赖增量)
空间复杂度 O(1) O(1)
稳定性 稳定 不稳定
元素移动方式 一次移动一位 一次跨越多位
对逆序数据的表现 极差(O(n²)) 良好(O(n^(4/3)))
对已排序数据的表现 优秀(O(n)) 优秀(O(n log n))
代码复杂度 极简 简单
适合数据规模 小规模(n < 1000) 中等规模(n < 50000)
缓存友好性 顺序访问,较好 跳跃访问,略差

核心结论

希尔排序可以看作是对插入排序的多阶段加速优化。它保留了插入排序的原地排序和简单性, 同时通过分组预排序大幅减少了所需的比较和移动次数。在 n = 10000 的随机数据上, 希尔排序通常比插入排序快 两个数量级。但希尔排序付出的代价是丧失了稳定性, 以及引入了增量序列的选择问题。

八、应用场景与实践建议

8.1 适用场景

8.2 不适用场景

8.3 实践建议

  • 优先选择 Sedgewick 或 Knuth 增量序列作为通用方案,避免使用 Shell 原始增量处理大数组。
  • 对小数组(n < 50)无需使用希尔排序,直接使用插入排序即可,希尔排序的增量初始化开销在小数据上无法体现优势。
  • 注意最坏情况防御: 即使使用 Sedgewick 序列,也不要假设希尔排序总是 O(n^(4/3)),某些特定的数据分布仍可能导致性能退化。
  • 结合语言特性: 在 Python 中,希尔排序的纯 Python 实现可能慢于内置的 Timsort(C 实现),但在 C/C++/Java 中希尔排序的竞争力很强。

九、核心要点总结