目录

你打扑克牌的时候是怎么理牌的?

摸一张,拿在手里,往已有的牌里插到一个合适的位置。

再摸一张,再插进去…… 不需要扫全场,只需要跟手里已有的几张比一比就能找到位置。

这就是插入排序(Insertion Sort) 的核心思想。

把数组分成”已排序区”和”未排序区”,每次取未排序区的第一个元素,在已排序区里找到合适的位置插进去。

它是唯一一个在”数据几乎有序”时能跑到 O(n) 的简单排序算法。


它是什么

核心思想 维护一个已排序的前缀,每次取下一个新元素,通过不断与前面的元素比较并右移,腾出空位后把自己插进去。

像整理扑克牌:手里的牌永远是有序的,新来的牌只需往后找自己的位置。

插入排序的本质就是维护一个动态增长的有序序列。

伪代码

// 仅为示意
for i = 1 to n-1:
    key = arr[i]
    j = i - 1
    while j >= 0 and arr[j] > key:
        arr[j + 1] = arr[j]
        j = j - 1
    arr[j + 1] = key

注意 while 条件用的是 >(严格大于),这是稳定性的来源。

下面用流程图拆解这段伪代码。

工作原理流程图

整体骨架(对应伪代码的外层 for 循环):

flowchart LR
    A([开始]) --> B[i = 1]
    B --> C{"i < n ?"}
    C -->|否| Z([结束])
    C -->|是| D["key = arr[i], j = i-1"]
    D --> E{"j >= 0 and arr[j] > key?"}
    E -->|是| F["arr[j+1] = arr[j], --j"]
    F --> E
    E -->|否| G["arr[j+1] = key"]
    G --> H["++i"]
    H --> C

内层”寻找插入位置”的过程:

flowchart TB
    subgraph search_pos["寻找插入位置"]
        direction TB
        A1([取出 key]) --> B1[j 指向前一个元素]
        B1 --> C1{"arr[j] > key?"}
        C1 -->|是| D1["大元素右移一位<br/>j 继续左移"]
        D1 --> C1
        C1 -->|否/越界| E1[key 落位到 j+1]
    end

逐趟演示

用 [5, 3, 8, 4, 2] 跑一遍,看每张”新牌”如何插入:

初始数组: [5, 3, 8, 4, 2]

第 1 趟 (插入 3): 已排序 [3,5] | 未排序 [8,4,2]
       取出 3,5 比 3 大所以右移,3 落位到位置 0

第 2 趟 (插入 8): 已排序 [3,5,8] | 未排序 [4,2]
       取出 8,8 已经比 5 大,直接落在位置 2,不用移动

第 3 趟 (插入 4): 已排序 [3,4,5,8] | 未排序 [2]
       取出 4,5 和 8 都比 4 大依次右移,4 落位到位置 1

第 4 趟 (插入 2): 已排序 [2,3,4,5,8] | 未排序 []
       取出 2,3/4/5/8 都比 2 大全部右移,2 落位到位置 0

最终结果: [2, 3, 4, 5, 8]

注意第 2 趟:8 不需要任何移动就直接落位了。

这就是插入排序的优势所在,如果新元素已经在正确位置附近,工作量很小。


能解决什么问题

适用场景

场景为什么适合
小规模数据(n < 50)常数因子极小,实际运行往往比递归的快排还快
几乎有序的数据最佳情况 O(n),已有顺序越多越快
在线排序数据流式到达,每来一个就能插入已有序的部分
作为其他算法的子过程快排小区间优化、希尔排序的增量=1 阶段都用到它

局限性

局限说明
平均/最坏 O(n²)逆序时每个元素都要移到最前面,退化为平方级
大量数据时不适用n 超过几千就应该考虑更高效的算法

与其他算法的对比

特征冒泡排序选择排序插入排序
最好情况O(n)(带优化)O(n²)O(n)
最坏情况O(n²)O(n²)O(n²)
稳定稳定不稳定稳定
每轮操作多次相邻交换最多 1 次交换多次移动
对近有序敏感是(可提前退出)否非常敏感

稳定性证明

插入排序是稳定的。

因为内层循环的条件是 arr[j] > key(严格大于),遇到相等的元素就停止移动,不会越过相等元素。

程序输出验证:

原始: (3,a) (2,x) (2,y) (1,b)
排序后: (1,b) (2,x) (2,y) (3,a)

(2,x) 仍然在 (2,y) 前面,相对顺序保持不变。


C++ 实现与复杂度分析

通用 C++ 模板实现 插入排序只依赖比较运算符 > 和 std::move,适用于任意可比较且可移动类型:

#include <utility>   // std::move
#include <vector>

template <typename T>
void insertionSort(std::vector<T>& arr) {
    int n = static_cast<int>(arr.size());
    for (int i = 1; i < n; ++i) {
        T key = std::move(arr[i]);
        int j = i - 1;
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = std::move(arr[j]);
            --j;
        }
        arr[j + 1] = std::move(key);
    }
}

时间复杂度怎么算

基本操作是比较(arr[j] > key)和移动(赋值操作)。

按以下三种情况分析:

最好情况(已有序):

每轮只需 1 次比较(key 直接比前一个大),内层 while 不进入。共 n-1 轮,总计 n-1 次比较。

加上取 key 和落位的赋值,总工作量与 n 成正比 → O(n)。

程序验证:已有序 [1,2,3,4,5] 只需 4 次比较、8 次移动。

最坏情况(完全逆序):

第 i 轮(i 从 1 到 n-1)需要 i 次比较和 i+2 次移动(取 key + i 次右移 + 落位)。

总比较 = 1 + 2 + … + (n-1) = n(n-1)/2 → O(n²)

总移动 = 3 + 4 + … + (n+1) = n(n-1)/2 + 2(n-1) → O(n²)

程序验证:逆序 [5,4,3,2,1] 需要 10 次比较、18 次移动。

平均情况(随机排列):

期望比较次数约为 n²/4,期望移动次数约为 n²/4 → O(n²)

情况比较次数移动次数时间复杂度
最好(已有序)n-12(n-1)O(n)
最坏(逆序)n(n-1)/2~n²/2O(n²)
平均(随机)≈ n²/4*≈ n²/4*O(n²)

注:平均值为近似值,精确期望取决于输入分布,记住 O(n²) 量级即可。

空间复杂度怎么算

只用 key、j 几个标量,额外空间与 n 无关 → O(1),原地排序。

小结算法:时间 O(n²)(最好 O(n))、空间 O(1)、稳定、原地、对近有序极度友好。


总结

  • 是什么:维护已排序前缀,每次取下一个元素通过比较+右移找到合适位置插入。
  • 怎么演示:用两张分层流程图看清骨架+内层插入过程;用 [5,3,8,4,2] 的逐趟输出看到”大元素右移腾位、key 落位”的核心动作。
  • 解决什么 / 用在哪:把无序变有序;特别适合小数据和几乎有序的场景;是快排小区间优化的标配子算法。
  • 局限:逆序时退化到 O(n²);大规模数据需要更高效的算法。
  • 复杂度怎么算:最好 O(n)(已有序,每轮只比 1 次);最坏/平均 O(n²)(等差数列求和);空间 O(1)。严格大于保证稳定性。
  • 系列定位:三大简单排序中最实用的一个。下一篇希尔排序就是它的升级版,通过分组插入突破 O(n²) 的天花板。