从直接插入排序到希尔排序:深入理解两类经典排序算法
在数据排序的众多算法中,有一类算法被称为"简单排序"。它们通常易于理解、代码实现简单,非常适合作为学习排序算法的入门选择。然而,这些简单排序算法(如冒泡排序、选择排序、直接插入排序)的时间复杂度普遍为 O(n²),在处理大规模数据时效率较低。其中,直接插入排序 因其在数据基本有序时表现优异而备受关注。希尔排序 则是在直接插入排序基础上进行改进的一种更高效的排序算法,它通过"分组插入"的方式,显著提升了排序的整体性能。本文将详细且深入地介绍这两种排序算法的原理、步骤、代码实现以及复杂度分析,并通过丰富的示例帮助你彻底掌握它们。
一、直接插入排序(Insertion Sort)
算法思想
直接插入排序是一种简单直观的排序算法,它的思想与我们平时整理手牌或整理书架的过程高度相似:每次将一个新元素"插入"到已经排好序的序列中的正确位置,从而不断扩展已排序部分,直到整个数组有序。
具体来说,我们把数组看作两个区域:左侧的已排序区域 和右侧的未排序区域 。初始时,已排序区域只包含第一个元素(因为单个元素自然有序)。然后,我们依次从未排序区域取出第一个元素(称为"待插入元素"或 key),将它插入到已排序区域中的合适位置,同时将大于它的元素依次向后移动一位,为它腾出空间。重复这个过程,直到未排序区域为空。
算法步骤(以升序为例)
- 从数组的第二个元素开始(下标
i = 1),依次遍历每个元素,将其视为待插入元素key。 - 将
key与它前面的元素(下标j = i-1, i-2, ..., 0)从右向左逐一比较。 - 如果前面的元素
arr[j]大于key,则将该元素向后移动一位(arr[j+1] = arr[j]),然后继续向左比较(j--)。 - 当遇到一个小于等于
key的元素或者已经比较到数组开头(j < 0)时,停止移动。 - 将
key放入空出来的位置,即arr[j+1] = key。 - 继续处理下一个
i,直到所有元素都被插入。
详细示例演示
我们以数组 [5, 2, 4, 6, 1, 3] 为例,逐步展示每一轮插入的过程。为了方便理解,我们用 | 分隔已排序部分和未排序部分。
-
初始状态 :
[5] | [2, 4, 6, 1, 3]已排序部分只有第一个元素
5。 -
第1轮(i=1,key=2)
比较
key=2与它前面的元素5:由于5 > 2,将5后移一位。此时数组变为[5, 5, 4, 6, 1, 3](注意两个5是临时状态)。j减为-1,退出循环。将key放入j+1 = 0位置,得到[2, 5] | [4, 6, 1, 3]。 -
第2轮(i=2,key=4)
比较
key=4与前面元素:先与5比较,5 > 4,将5后移一位,数组变为[2, 5, 5, 6, 1, 3]。然后j减为0,比较arr[0]=2与4:2 <= 4,停止。将key放入j+1 = 1位置,得到[2, 4, 5] | [6, 1, 3]。 -
第3轮(i=3,key=6)
比较
key=6与前面元素:先与5比较,5 < 6,无需移动。直接停止,将6放在原位置(实际上没有变化)。此时数组为[2, 4, 5, 6] | [1, 3]。 -
第4轮(i=4,key=1)
比较
key=1与前面元素:从右向左依次与6, 5, 4, 2比较。6 > 1→ 将6后移一位,数组[2, 4, 5, 6, 6, 3]5 > 1→ 将5后移一位,数组[2, 4, 5, 5, 6, 3]4 > 1→ 将4后移一位,数组[2, 4, 4, 5, 6, 3]2 > 1→ 将2后移一位,数组[2, 2, 4, 5, 6, 3]- 到达开头,
j = -1,退出。将key放入j+1 = 0位置,得到[1, 2, 4, 5, 6] | [3]。
-
第5轮(i=5,key=3)
比较
key=3与前面元素:从右向左依次与6, 5, 4, 2比较。6 > 3→ 将6后移一位,数组[1, 2, 4, 5, 6, 6]5 > 3→ 将5后移一位,数组[1, 2, 4, 5, 5, 6]4 > 3→ 将4后移一位,数组[1, 2, 4, 4, 5, 6]2 <= 3→ 停止。将key放入j+1 = 2位置,得到[1, 2, 3, 4, 5, 6]。
排序完成。
代码实现(Python)
python
def insertion_sort(arr):
"""
直接插入排序(升序)
参数 arr: 待排序列表
返回: 排序后的列表(原地修改)
"""
n = len(arr)
# 从第二个元素开始遍历(下标1)
for i in range(1, n):
key = arr[i] # 当前待插入元素
j = i - 1 # 已排序部分的最后一个元素下标
# 将比 key 大的元素向右移动一位
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
# 插入 key 到正确位置
arr[j + 1] = key
return arr
# 测试
test = [5, 2, 4, 6, 1, 3]
print("排序前:", test)
sorted_arr = insertion_sort(test.copy()) # 使用副本,避免修改原列表
print("直接插入排序后:", sorted_arr)
代码执行输出:
排序前: [5, 2, 4, 6, 1, 3]
直接插入排序后: [1, 2, 3, 4, 5, 6]
复杂度与稳定性分析
时间复杂度
- 最坏情况 O(n²) :当输入数组完全逆序时(例如
[6,5,4,3,2,1]),每一轮插入都需要将key移动到最前面,比较和移动的次数分别为1+2+...+(n-1) = n(n-1)/2,因此时间复杂度为 O(n²)。 - 最好情况 O(n):当输入数组已经有序时,每一轮只需要比较一次(发现前一个元素 ≤ key),不需要移动,所以总共只需要 n-1 次比较,时间复杂度为 O(n)。
- 平均情况 O(n²):对于随机排列的数组,平均比较和移动次数约为 n²/4,仍为 O(n²)。
空间复杂度
- O(1) :直接插入排序是原地排序 算法,只需要常数级别的额外空间(一个
key变量和循环变量)。
稳定性
- 稳定 :因为在比较时,只有当
arr[j] > key时才移动元素(严格大于)。如果遇到相等的元素,不会移动它,因此相等元素的相对顺序保持不变。例如:对[2, ①, ②](两个2用编号区分),排序后[2, ①, ②]仍保持原顺序。
优缺点
- 优点:实现简单,对于小规模数据或基本有序的数据效率很高(接近线性),且稳定。
- 缺点:对于大规模乱序数据,复杂度 O(n²) 不可接受。
二、希尔排序(Shell Sort)
算法背景与思想
直接插入排序有一个重要特性:当数组基本有序时,插入排序的效率非常高(接近 O(n)) 。然而,对于完全逆序的数组,它需要移动大量元素。那么,有没有一种方法可以先让数组变得"基本有序",然后再用插入排序完成最终整理呢?
1959年,计算机科学家唐纳德·希尔(Donald Shell)提出了希尔排序。其核心思想是:将整个数组按照某个增量(gap)分成若干个子序列,对每个子序列分别进行直接插入排序。随着增量逐渐缩小,每个子序列包含的元素越来越多,但整个数组也越来越接近全局有序。当增量缩小到 1 时,整个数组实际上只进行一次全局插入排序,而此时数组已经基本有序,因此排序速度很快。
希尔排序也称为"缩小增量排序"。
增量序列的选择
增量序列的设计对希尔排序的性能至关重要。最经典的是希尔提出的原始序列:n/2, n/4, ..., 1(每次减半)。但后来人们发现其他增量序列可以获得更优的时间复杂度,例如:
- Hibbard 增量序列 :
1, 3, 7, 15, ..., 2^k - 1,最坏复杂度 O(n^(3/2))。 - Sedgewick 增量序列 :
1, 5, 19, 41, 109, ...,最坏复杂度 O(n^(4/3)),实践中表现很好。 - Knuth 增量序列 :
1, 4, 13, 40, 121, ...(即(3^k - 1)/2)。
本文以最简单的原始增量(每次折半)为例进行讲解。
算法步骤(以升序为例)
- 初始化增量
gap = len(arr) // 2。 - 当
gap > 0时,执行以下操作:- 从下标
i = gap开始,依次遍历每个元素arr[i]。 - 对于当前元素,将其视为待插入元素,对它所在的子序列 (该子序列由索引
i, i-gap, i-2*gap, ...组成)执行一次插入排序。 - 遍历完所有
i后,将增量缩小:gap = gap // 2。
- 从下标
- 重复步骤2,直到
gap == 0,排序完成。
注意 :希尔排序并不是先完整地排好每个子序列,再处理下一个子序列,而是采用交替比较 的方式:从 gap 开始,每个元素都与其前面相距 gap 的元素进行比较和插入。这种方式使得代码更简洁,且不需要显式地拆分子序列。
希尔排序的详细解析
初始数组与第一轮排序(gap=4)
原始数组为 [9, 8, 3, 7, 5, 6, 4, 1],初始增量 gap=4。将数组分为4个子序列:
- 子序列1(索引0,4):
[9,5]→ 排序后[5,9] - 子序列2(索引1,5):
[8,6]→ 排序后[6,8] - 子序列3(索引2,6):
[3,4]→ 保持[3,4] - 子序列4(索引3,7):
[7,1]→ 排序后[1,7]
合并后数组更新为 [5,6,3,1,9,8,4,7],此时较大元素(如9、8)向后移动,较小元素(如1、3)向前移动。
第二轮排序(gap=2)
增量缩小为 gap=2,分为2个子序列:
- 子序列1(索引0,2,4,6):
[5,3,9,4]→ 插入排序后为[3,4,5,9] - 子序列2(索引1,3,5,7):
[6,1,8,7]→ 插入排序后为[1,6,7,8]
合并后数组更新为 [3,1,4,6,5,7,9,8],此时数组已接近有序。
第三轮排序(gap=1)
当 gap=1 时,退化为标准插入排序:
- 对
[3,1,4,6,5,7,9,8]逐元素插入排序,最终得到完全有序的[1,3,4,5,6,7,8,9]。
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
# 测试
test = [9, 8, 3, 7, 5, 6, 4, 1]
print("排序前:", test)
print("希尔排序后:", shell_sort(test.copy()))
输出结果
排序前: [9, 8, 3, 7, 5, 6, 4, 1]
希尔排序后: [1, 3, 4, 5, 6, 7, 8, 9]
复杂度与稳定性分析
时间复杂度
- 最坏情况(原始增量序列):O(n²)。
- 优化增量序列(如Hibbard或Sedgewick):可降至 O(n^(3/2)) 或 O(n^(4/3))。
- 平均情况(经验值):O(n^(1.3~1.5))。
空间复杂度
O(1),原地排序算法。
稳定性
不稳定。分组插入可能导致相等元素的相对顺序改变(例如 [5,3,3,4] 排序后可能变为 [3,3,4,5],两个3的顺序可能交换)。
直接插入排序 vs 希尔排序
| 特性 | 直接插入排序 | 希尔排序 |
|---|---|---|
| 核心思想 | 逐个插入已排序序列 | 分组插入,逐步缩小增量 |
| 最坏时间复杂度 | O(n²) | O(n²)(原始增量)或更低 |
| 平均时间复杂度 | O(n²) | O(n^(1.3~1.5))(经验值) |
| 稳定性 | 稳定 | 不稳定 |
| 适用场景 | 小规模或基本有序数据 | 中等规模数据,无需稳定性 |
适用建议
- 直接插入排序:适合小规模数据(如n ≤ 1000)或已基本有序的序列,实现简单且稳定。
- 希尔排序:适合中等规模数据(几千到几万),性能显著优于直接插入排序,但需注意不稳定性。
实际开发中,Python内置的 sorted() 使用Timsort(混合归并和插入排序),通常无需手动实现希尔排序,但理解其分治思想对算法学习很有帮助。