1. 什么是插入排序

插入排序(Insertion Sort) 是我们日常生活中最不自觉就会用到的排序算法。它的核心思想可以用一句话概括打扑克牌时的理牌动作

想象一下你正在打斗地主,你左手拿着已经理好的牌(已排序部分),右手从桌上抓起一张新牌(未排序元素)。你会从右往左依次对比手里的牌,找到合适的位置,把新牌插进去。插入排序就是这么工作的!

其实重点就是,如何去组织⬇️

  • 有序区域
  • 无序区域

那么谁能保证我每一次都能前面拿到的都是有序呢?那么我们就默认你的第一张就这个独苗她就是有序的!

也就是说第一次你假定[4]就是有序区。

2. 代码实现

直接上代码实现会比较好点。下面2种写法是一模一样的,如果你能搞懂下面2种写法的不一样的地方,那么插入算法你就懂了。

# 写法1
def insertion_sort(arr):
    n = len(arr)
    for i in range(1, n):
        key = arr[i]
        j = i - 1
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key
    return arr

# 写法2
def insertion_sort(arr):
  n = len(arr)
  for i in range(1, n):
      key = arr[i]
      j = i
      while j > 0 and arr[j - 1] > key:
          arr[j] = arr[j - 1]
          j -= 1
      arr[j] = key
  return arr


# 测试
data = [29, 10, 14, 37, 13]
print(f"排序后: {insertion_sort(data)}")

代码难点必须看!

如果上面的区别你看不懂,你就很难学习接下来的概念,包括希尔排序。

# 视角 1:j 是“坑”的位置
def insertion_sort_v1(arr):
    n = len(arr)
    for i in range(1, n):
        key = arr[i]
        j = i  # 👈 j 一开始就指向我们要腾出来的“坑位”

        # 只要坑位前面还有牌 (j > 0),且前面的牌比 key 大
        while j > 0 and arr[j - 1] > key:
            arr[j] = arr[j - 1] # 前面的牌往右边(现在的坑位)挪
            j -= 1              # 坑位往左移了一格

        arr[j] = key # 循环结束,当前的 j 就是最终的坑位,直接放进去
    return arr
  • 逻辑核心:我盯着这个**“空位”**。如果前面的数比我大,我就把前面的数拉到这个空位里,然后空位就自然转移到了前面(j -= 1)。最后把 key 填进这个空位 arr[j] 里。
  • 优点:最后那一步 arr[j] = key 看起来非常自然,不需要加一。

这是你写的第二个版本(写法 1):

# 视角 2:j 是“正在比较的牌”的位置
def insertion_sort_v2(arr):
    n = len(arr)
    for i in range(1, n):
        key = arr[i]
        j = i - 1  # 👈 j 指向左手已经排好序的最后一张牌

        # 只要手里还有牌 (j >= 0),且手里的牌比 key 大
        while j >= 0 and arr[j] > key:
            arr[j + 1] = arr[j] # 把这张牌往右挪一格
            j -= 1              # 手往左边摸下一张牌

        arr[j + 1] = key # 👈 因为刚才 j 多减了 1,所以真正的空位在 j + 1
    return arr
  • 逻辑核心:我盯着**“左手里的牌”**。只要这张牌比 key 大,我就把它往右边推一格(arr[j + 1] = arr[j]),然后我的目光看向左边一张牌(j -= 1)。
  • 你的疑惑解答:你在注释里写的 # 因为这里减去了 所以要加 完全正确!当 while 循环因为 arr[j] <= key 停下来时,j 指向的是那张比 key 小的牌。所以,key 当然要插在它后面,也就是 j + 1 的位置。
特性 第一种写法 (j = i) 第二种写法 (j = i - 1)
j 的意义 坑位(空位)所在的位置 正在进行比较的元素位置
越界保护 j > 0 (因为我们要查 arr[j-1]) j >= 0 (因为我们要查 arr[j])
落子位置 arr[j] = key arr[j + 1] = key

哪个更好?

在实际工程中,这两种写法的性能是完全一模一样的,底层的机器指令也几乎没有区别。全凭程序员的个人喜好。

  • 有人喜欢第一种,因为 arr[j] = key 看着更整洁,不用烧脑去想 +1 的问题。
  • 有人(包括很多教科书)喜欢第二种,因为 j = i - 1 更符合“拿当前牌和前面所有牌比较”的直觉语言。

3. 优化写法

插入排序在处理小规模基本有序的数据时已经非常强悍了,但它依然有两个痛点:

  1. 找位置慢:需要挨个往前比较(比较次数多)。
  2. 挪位置慢:如果遇到极端的逆序(比如 [9, 8, 7, 1]),那个 1 要从最后一位一点点平移到第一位(移动次数多)。

二分法 Binary Insertion Sort

前面我们讲过,左手里的牌已经是排好序的。既然是有序数组,我们凭什么还要“从右向左挨个看”?直接上**二分查找(Binary Search)**啊!

普通插入排序是:找位置 O(n),挪位置 O(n)。

折半插入排序是:找位置 O(log),挪位置 O(n)。

def binary_insertion_sort(arr):
    n = len(arr)
    for i in range(1, n):
        key = arr[i]

        # 1. 用二分查找在 [0, i-1] 的有序区间里找插入位置
        left, right = 0, i - 1
        while left <= right:
            mid = (left + right) // 2
            if arr[mid] > key:
                right = mid - 1  # key 比较小,去左半边找
            else:
                left = mid + 1   # key 比较大(或相等),去右半边找

        # 循环结束后,left 的位置就是 key 应该插入的正确位置!

        # 2. 批量往右平移腾出坑位 (从 i-1 挪到 left)
        for j in range(i - 1, left - 1, -1):
            arr[j + 1] = arr[j]

        # 3. 填坑
        arr[left] = key

    return arr

# 测试
data = [37, 23, 0, 17, 12, 72, 31, 46, 100, 88, 54]
print(f"折半插入排序后: {binary_insertion_sort(data)}")

局限性:虽然比较的次数大幅减少了,但数组元素的平移次数并没有减少。所以它的最坏时间复杂度依然是 O(n^2)

希尔排序 Shell Sort

如果说折半插入排序是小修小补,那希尔排序就是对插入排序的降维打击

插入排序的致命伤是:元素只能一步一步地挨个换。如果最小的数在最右边,它要挪 n-1 次才能到最左边。

希尔排序的逻辑是:步子迈大点! 我们先让间隔为 gap 的元素跨越式地进行插入排序,把大数迅速丢到后面,小数迅速拎到前面。然后再缩小 gap,直到 gap = 1(此时就是标准的插入排序)。

def shell_sort(arr):
    n = len(arr)
    gap = n // 2  # 初始步长,每次砍半

    # 只要步长大于 0,就继续分组排序
    while gap > 0:
        # 下面这就是一个稍微修改了间隔的“插入排序”
        for i in range(gap, n):
            key = arr[i]
            j = i

            # 以前是 j - 1,现在是 j - gap;以前挪 1 步,现在挪 gap 步
            while j >= gap and arr[j - gap] > key:
                arr[j] = arr[j - gap]
                j -= gap

            arr[j] = key

        # 这一轮大跨步排序完后,缩小步长
        gap //= 2

    return arr

# 测试
data = [8, 9, 1, 7, 2, 3, 5, 4, 6, 0]
print(f"希尔排序后: {shell_sort(data)}")

希尔排序为什么牛?

gap = 1 的最后一轮开始时,整个数组已经宏观上基本有序了。而我们前面说过,插入排序在面对“基本有序”的数组时,时间复杂度会逼近 O(n)!希尔排序巧妙地利用了插入排序的这个隐藏天赋。它的时间复杂度打破了 O(n^2) 的魔咒,根据步长序列的不同,可以达到 O(n^{1.3}) 甚至 O(n * log^2 *n)。


总结对比

算法 找位置的方式 挪位置的步幅 时间复杂度 核心意义
普通插入排序 从右向左线性找 每次 1 步 O(n^2) 基础,对近乎有序数组极快。
折半插入排序 二分查找 每次 1 步 O(n^2) 减少了比较次数,但挪动次数没变。
希尔排序 跨分组线性找 大跨步跨越 (gap) 突破 O(n^2) 大幅减少了极端逆序情况下的挪动次数。

🔔 直接看提示词

如果你觉得上面的你都没看懂,或者太长不看。可以直接到这里。复制一下提示词。

什么是插入排序?请给我一个最常见的实现。
插入排序有什么优化写法?请浅显易懂抽丝剥茧的给我讲述一下优化写法。
xxxx这个写法能否有具体的例子给我一步步的讲解一下,包括代码的难点。
我想知道这个代码为什么不可以是xxxx