NOI C++ 课程体系 / 算法篇 / 第12讲
CSP-J 入门级 · 排序算法 · m4-12

插入排序 与 计数排序

从扑克牌整理到计数统计 —— 掌握两种截然不同但各有用武之地的排序算法,理解"比较排序"与"非比较排序"的本质区别。

6 大模块 2 种排序算法 5 种排序对比 5 大易错点 32 道分层练习
本讲 · 第 36/56 讲插入排序与计数排序
模块 01

情景导航

从打牌的习惯动作出发,直觉理解插入排序和计数排序的思想。

故事整理扑克牌

小明打扑克时,习惯把牌一张张拿起来插入手中已排好的牌中。比如手上的牌是 [3, 7, 9],新拿到一张 5,他就把 5 插到 3 和 7 之间 → [3, 5, 7, 9]。

这就是"插入排序"——你打牌时一直在用的算法!

而计数排序的思路完全不同。如果老师要对全班成绩排序,她可以这样:

第一步:统计每个分数(0~100)有几个人

第二步:从低分往高分,按统计数依次输出

不需要比较任何两个学生谁高谁低,纯粹靠统计计数就排好了!

计数排序 = 不比较大小,靠"数数"来排序!

💡 两种排序的本质区别

插入排序(比较排序):通过比较元素大小来决定位置,逐个插入到已排序部分。

计数排序(非比较排序):不比较元素,通过统计每个值出现次数来确定位置。

比较排序的下界是 O(n log n),而计数排序可以突破这个下界,达到 O(n+k)!

①生活类比
🃏

打牌 = 插入排序

每摸一张牌,插入手中已排序的牌中。

→ 插入排序
📊

点名统计 = 计数排序

老师按学号点名,记录每人到没到,最后输出到齐的名单。

→ 计数排序
📚

书架插书 = 插入

新书按照字母序插入到已有书籍的对应位置。

→ 有序插入
🗳️

投票计票 = 计数

不比较候选人,统计每人的票数,按票数排名。

→ 频数统计
②核心定义

插入排序(Insertion Sort):将数组分为"已排序"和"未排序"两部分。每次取出未排序部分的第一个元素,在已排序部分中从右往左找到合适位置插入。

计数排序(Counting Sort):适用于值域有限的整数排序。用一个计数数组统计每个值出现的次数,然后根据计数重建有序序列。不需要比较元素大小。

📌 本讲核心知识地图

板块核心内容时间复杂度
插入排序逐个插入到已排序部分(比较排序)最优O(n),最差O(n²)
插入排序优化二分查找插入位置 / 希尔排序二分插入 O(n log n) 比较
计数排序统计频数 → 前缀和 → 重建(非比较)O(n+k),k为值域
稳定版计数排序保持相同元素的原始相对顺序O(n+k),稳定
五种排序对比冒泡/选择/插入/计数/std::sort各有适用场景
③核心口诀
🔑 插入排序

已排未排分左右,取出一张往左看;
比它大的往后挪,找到位置插进去!
近乎有序特别快,O(n)就能搞定!

🔑 计数排序

值域不大用计数,开个桶来数个数;
前缀和定位置,按序输出稳又快!
O(n+k) 突破下界,非比较排序之王!

模块 02

知识探究

深入掌握插入排序和计数排序的原理、代码实现和优化技巧。

1插入排序详解 核心

核心思想:维护一个有序前缀 [0..i-1],将 a[i] 插入到合适位置。逐步扩大有序部分直到整个数组有序。

📊 排序过程可视化:[5, 2, 4, 6, 1, 3]

像整理扑克牌:绿色区域是手里已排好的牌,每次拿起一张(粉色),往左找到位置插进去

C++insertion_sort.cpp
#include <iostream>
using namespace std;

void insertionSort(int a[], int n) {
    for (int i = 1; i < n; i++) {
        int key = a[i];   // 取出待插入元素
        int j = i - 1;

// 从右往左扫描已排序部分
        while (j >= 0 && a[j] > key) {
            a[j + 1] = a[j];  // 大的元素右移
            j--;
        }

a[j + 1] = key;  // 插入到正确位置
    }
}

int main() {
    int a[] = {5, 2, 4, 6, 1, 3};
    int n = 6;
    insertionSort(a, n);
    for (int i = 0; i < n; i++)
        cout << a[i] << " ";
    return 0;
}
运行结果
1 2 3 4 5 6
复杂度最好情况最坏情况平均空间稳定性
插入排序O(n)(已有序)O(n²)(逆序)O(n²)O(1)✅ 稳定
🔑 插入排序的优势

• 对近乎有序的数组特别快:如果数组已经基本有序,内层 while 几乎不执行 → 接近 O(n)
• 稳定排序:相等元素不会交换位置
• 在线排序:可以边接收数据边排序(每来一个就插入)
• 小数组首选:n < 20 时比快速排序还快(常数因子小)

2插入排序的优化 提效
🔍 优化1:二分查找插入位置
  • 内层循环本质上是在有序数组中找位置
  • 用二分查找代替线性扫描
  • 比较次数从 O(n) 降为 O(log n)
  • 但移动次数仍是 O(n),总复杂度不变
  • 实际常数因子更小
🚀 优化2:希尔排序(Shell Sort)
  • 先用大间距分组,组内插入排序
  • 逐渐缩小间距直到 1
  • 大间距让数组快速"基本有序"
  • 最后间距为1时几乎不需移动
  • 复杂度约 O(n^1.3) ~ O(n^1.5)
C++二分插入排序
void binaryInsertionSort(int a[], int n) {
    for (int i = 1; i < n; i++) {
        int key = a[i];
        // 二分查找插入位置
        int lo = 0, hi = i;
        while (lo < hi) {
            int mid = (lo + hi) / 2;
            if (a[mid] <= key) lo = mid + 1;
            else hi = mid;
        }
        // 移动元素腾出位置
        for (int j = i; j > lo; j--)
            a[j] = a[j - 1];
        a[lo] = key;
    }
}
3计数排序详解 非比较排序

核心思想:当数据值域 k 不大时,用一个大小为 k+1 的数组 count[] 统计每个值出现的次数,然后按计数重建有序数组。

📊 排序过程可视化:[4, 2, 2, 8, 3, 3, 1]

动画演示简单版计数排序(统计频数 → 按值输出);下方静态图展示的是稳定版三步走(含前缀和)

计数排序三步走
原始数组:
4
2
2
8
3
3
1

Step 1:统计频数 count[]

值:
0
1
2
3
4
5
6
7
8
次数:
0
1
2
2
1
0
0
0
1

Step 2:计算前缀和(确定每个值在输出中的末尾位置)

值:
0
1
2
3
4
5
6
7
8
前缀和:
0
1
3
5
6
6
6
6
7

Step 3:从后往前遍历原数组,放入输出数组

输出结果:
1
2
2
3
3
4
8
C++counting_sort.cpp — 简单版
#include <iostream>
using namespace std;

void countingSort(int a[], int n, int maxVal) {
    int count[101] = {0};  // 计数数组,大小根据值域确定

// Step 1:统计每个值出现的次数
    for (int i = 0; i < n; i++)
        count[a[i]]++;

// Step 2:根据计数重建有序数组
    int idx = 0;
    for (int v = 0; v <= maxVal; v++)
        while (count[v] > 0) {
            a[idx++] = v;
            count[v]--;
        }
}

int main() {
    int a[] = {4, 2, 2, 8, 3, 3, 1};
    int n = 7;
    countingSort(a, n, 8);
    for (int i = 0; i < n; i++) cout << a[i] << " ";
    return 0;
}
运行结果
1 2 2 3 3 4 8
复杂度时间空间稳定性适用条件
简单计数排序O(n + k)O(k)❌ 不稳定整数、值域 k 不太大
4稳定版计数排序 重要

简单版计数排序不保持相同元素的原始顺序。要做到稳定,需要结合前缀和,从后往前遍历原数组放置元素。

C++stable_counting_sort.cpp
void stableCountingSort(int a[], int n, int maxVal) {
    int count[101] = {0};
    int output[100005];

// Step 1:统计频数
    for (int i = 0; i < n; i++) count[a[i]]++;

// Step 2:计算前缀和(确定每个值的最后位置)
    for (int i = 1; i <= maxVal; i++)
        count[i] += count[i - 1];

// Step 3:从后往前遍历(保证稳定性!)
    for (int i = n - 1; i >= 0; i--) {
        output[count[a[i]] - 1] = a[i];
        count[a[i]]--;
    }

// Step 4:复制回原数组
    for (int i = 0; i < n; i++) a[i] = output[i];
}

🔍 为什么从后往前遍历能保证稳定?

假设原数组有两个值为 3 的元素:a[2]=3ₐ 和 a[4]=3ᵦ(3ₐ 在 3ᵦ 前面)。

前缀和 count[3] = 5,表示值 ≤ 3 的元素共 5 个。

从后往前遍历时:先遇到 3ᵦ → 放到 output[4],count[3]变成4;再遇到 3ₐ → 放到 output[3]。

结果:output[3]=3ₐ, output[4]=3ᵦ → 3ₐ 仍在 3ᵦ 前面,保持了原始顺序 ✅

💡 为什么稳定性重要?

当需要对结构体按多个关键字排序时,稳定性至关重要。

例如:先按成绩排序,再按姓名排序。如果第二次排序是稳定的,成绩相同的同学仍然保持成绩排序的顺序。

基数排序(Radix Sort)就是利用稳定计数排序来实现的 —— 从个位到百位逐位排序。

案例案例:成绩单排序 实战

全班 10 名同学的数学成绩 [0~100],需要从小到大排序并输出排名。

📖 数据:[78, 92, 65, 88, 73, 95, 82, 70, 90, 85]

方法选择:成绩范围 [0, 100],k=100,n=10。计数排序和插入排序都可以。

计数排序:开 count[101] 数组,统计每个分数的出现次数,从 0 到 100 扫描输出。

插入排序:从第2个成绩开始逐个插入已排序部分。

当 n 很小(如10)时两者差异不大。当 n=100万 且值域仍为 [0,100] 时,计数排序 O(n+k) 远快于插入排序 O(n²)。

C++grade_sort.cpp — 计数排序实现
#include <iostream>
using namespace std;

int main() {
    int scores[] = {78,92,65,88,73,95,82,70,90,85};
    int n = 10;
    int count[101] = {0};

for (int i = 0; i < n; i++) count[scores[i]]++;

cout << "排名  成绩" << endl;
    int rank = 1;
    for (int v = 100; v >= 0; v--)  // 从大到小输出
        while (count[v]-- > 0)
            cout << rank++ << "     " << v << endl;
    return 0;
}
运行结果
排名  成绩
1     95
2     92
3     90
4     88
5     85
6     82
7     78
8     73
9     70
10    65
📊五种排序算法全面对比
算法最好最坏平均空间稳定类型
冒泡排序O(n)O(n²)O(n²)O(1)✅比较
选择排序O(n²)O(n²)O(n²)O(1)❌比较
插入排序O(n)O(n²)O(n²)O(1)✅比较
计数排序O(n+k)O(n+k)O(n+k)O(k)✅非比较
std::sortO(n log n)O(n log n)O(n log n)O(log n)❌比较
✅ 插入排序适用
  • 小数组 (n < 50)
  • 近乎有序的数组
  • 在线排序(边接收边排)
  • 需要稳定性
✅ 计数排序适用
  • 整数数据
  • 值域 k 不太大(k < 10⁶)
  • 大量数据 n >> k
  • 需要稳定性
✅ std::sort 适用
  • 通用排序(首选)
  • 大数据量
  • 不需要稳定性
  • 各种数据类型
🔑 排序算法选择口诀

通用排序用 sort,一行代码最省心;
小数组用插入,常数因子最小;
值域小用计数,线性时间最快;
需要稳定用 stable_sort,归并保序不担心!

模块 03

实践应用

通过详细分析题,巩固插入排序和计数排序的理解。

题1插入排序过程追踪

题目:对数组 [3, 1, 4, 1, 5, 9, 2, 6] 进行插入排序,写出每一轮结束后的数组状态,并统计总的元素移动次数。

查看参考答案

逐步执行过程:

轮次取出比较过程移动次数数组状态
初始——0[3, 1, 4, 1, 5, 9, 2, 6]
i=1key=11<3 → 3右移1[1, 3, 4, 1, 5, 9, 2, 6]
i=2key=44>3 → 停止0[1, 3, 4, 1, 5, 9, 2, 6]
i=3key=11<4→4右移, 1<3→3右移, 1=1→停2[1, 1, 3, 4, 5, 9, 2, 6]
i=4key=55>4 → 停止0[1, 1, 3, 4, 5, 9, 2, 6]
i=5key=99>5 → 停止0[1, 1, 3, 4, 5, 9, 2, 6]
i=6key=22<9→右移, 2<5→右移, 2<4→右移, 2<3→右移, 2>1→停4[1, 1, 2, 3, 4, 5, 9, 6]
i=7key=66<9→右移, 6>5→停1[1, 1, 2, 3, 4, 5, 6, 9]

总移动次数:1+0+2+0+0+4+1 = 8 次

注意稳定性:原数组中第一个 1 在 a[1],第二个 1 在 a[3]。排序后 a[0]=1ₐ, a[1]=1ᵦ,原始相对顺序保持不变 → 插入排序是稳定的 ✅

题2计数排序分析

题目:对数组 [5, 3, 5, 1, 3, 5, 2, 1] 进行计数排序。写出 count 数组和前缀和数组的完整状态。

查看参考答案

Step 1:统计频数 count[]

值 v012345
count[v]021203

含义:值1出现2次,值2出现1次,值3出现2次,值5出现3次

Step 2:计算前缀和 prefix[]

值 v012345
prefix[v]023558

含义:值≤1的元素共2个,值≤2的共3个,值≤3的共5个,值≤5的共8个(=n)

Step 3:从后往前放置(稳定版)

i=7: a[7]=1, prefix[1]=2 → output[1]=1, prefix[1]=1

i=6: a[6]=2, prefix[2]=3 → output[2]=2, prefix[2]=2

i=5: a[5]=5, prefix[5]=8 → output[7]=5, prefix[5]=7

i=4: a[4]=3, prefix[3]=5 → output[4]=3, prefix[3]=4

i=3: a[3]=1, prefix[1]=1 → output[0]=1, prefix[1]=0

i=2: a[2]=5, prefix[5]=7 → output[6]=5, prefix[5]=6

i=1: a[1]=3, prefix[3]=4 → output[3]=3, prefix[3]=3

i=0: a[0]=5, prefix[5]=6 → output[5]=5, prefix[5]=5

最终输出:[1, 1, 2, 3, 3, 5, 5, 5]

稳定性验证:原数组 a[3]=1ₐ 和 a[7]=1ᵦ → output[0]=1ₐ, output[1]=1ᵦ,原始顺序保持 ✅

题3算法选择分析

题目:以下三种场景分别最适合哪种排序算法?说明理由。

场景A:10万个学生成绩(0~100分),需要排序

场景B:15个元素的数组,已经几乎有序(只有2个位置不对)

场景C:100万个10位手机号,需要排序

查看参考答案

场景A:计数排序 ✅

理由:n=100000 很大,值域 k=100 很小。计数排序 O(n+k) = O(100100),远快于 O(n log n) = O(1700000)。而且成绩是整数,满足计数排序的前提条件。

场景B:插入排序 ✅

理由:n=15 很小,且数组几乎有序。插入排序对几乎有序的数组接近 O(n),因为大部分元素不需要移动。总移动次数极少(约2~3次)。std::sort 虽然复杂度低但常数因子大,在小数组上不如插入排序。

场景C:基数排序(基于计数排序)✅

理由:手机号是10位数字,值域太大 (10¹⁰) 不能直接用计数排序。但可以用基数排序:从个位到十亿位逐位排序,每位用稳定计数排序(0~9)。10轮 × O(n+10) = O(10n),远快于 O(n log n)。

模块 04

总结提升

1核心要点总结
知识板块必须记住的核心
插入排序思想取一个元素,在已排序部分从右往左找位置插入
插入排序优势近乎有序时 O(n);小数组首选;稳定
二分插入优化用二分查找确定插入位置,比较次数 O(log n)
计数排序思想统计每个值的出现次数,按计数重建有序数组
计数排序复杂度O(n+k),突破比较排序下界 O(n log n)
计数排序限制只适用于整数,且值域 k 不能太大
稳定计数排序前缀和 + 从后往前遍历,保证相同元素不交换顺序
比较排序下界任何基于比较的排序至少需要 O(n log n) 次比较
2拓展:希尔排序与桶排序
🚀 希尔排序(Shell Sort)
  • 插入排序的升级版
  • 先按大间距分组,组内插入排序
  • 间距序列:n/2, n/4, ..., 1
  • 大间距让数组快速"基本有序"
  • 最后间距=1时插入排序非常快
  • 复杂度约 O(n^1.3),不稳定
🪣 桶排序(Bucket Sort)
  • 计数排序的推广版
  • 将数据分到若干个"桶"中
  • 每个桶内部排序(插入/快排)
  • 最后合并所有桶
  • 适合均匀分布的浮点数
  • 平均 O(n+k),k=桶数

💡 排序算法的演进

冒泡/选择/插入 → O(n²) 基础排序

希尔排序 → O(n^1.3) 插入排序的分组升级

归并/快排/堆排 → O(n log n) 分治/分治/选择

计数/桶/基数排序 → O(n+k) 非比较排序

竞赛中直接使用 std::sort(内省排序=快排+堆排+插入排序混合),无需手写。

3思考与启发

💡 学完本讲,你应该掌握的6项能力

1. 能手推插入排序的每一步过程(取元素→比较→移动→插入)。

2. 能说出插入排序在近乎有序时的高效性(接近 O(n))。

3. 能手推计数排序的三步过程(统计→前缀和→放置)。

4. 能判断一个问题是否适合用计数排序(整数 + 值域有限)。

5. 能解释为什么从后往前遍历保证了计数排序的稳定性。

6. 能根据数据特征选择最合适的排序算法。

模块 05

学习检测

5 个初学者最常犯的错误。

易错1

插入排序内层 while 条件写错

❌ 错误
while(a[j] > key) 缺少 j >= 0 的判断,当 key 是最小值时 j 变成 -1,访问 a[-1] 导致越界。
✅ 正确
while(j >= 0 && a[j] > key) 先判断 j >= 0 再比较,短路求值保证安全。
口诀:while 先判边界再比值,j >= 0 不能漏!
易错2

计数排序的 count 数组大小不够

❌ 错误
数据最大值为 100,但 count 只开了 50,导致 count[100] 越界。
✅ 正确
count 数组大小至少为 maxVal + 1。先遍历找最大值,或根据题意确定值域。
口诀:计数数组开多大?最大值加一不能少!
易错3

计数排序用于浮点数

❌ 错误
对 double 类型数据用计数排序,用浮点数做数组下标。
✅ 正确
计数排序只适用于整数。浮点数应使用比较排序(如 std::sort)或先映射为整数。
口诀:计数排序只认整数,浮点数请找 sort!
易错4

稳定版计数排序从前向后遍历

❌ 错误
从 i=0 到 n-1 遍历原数组放置元素,导致相同元素的顺序反转。
✅ 正确
必须从 i=n-1 到 0 遍历,先放后面的元素到后面位置,保证稳定性。
口诀:稳定排序从后放,先来后到不乱套!
易错5

以为插入排序比选择排序慢

❌ 错误
"插入排序和选择排序都是 O(n²),没有区别。"
✅ 正确
插入排序最好 O(n)(近乎有序时极快),选择排序始终 O(n²)。插入排序还是稳定的,选择排序不是。
口诀:插入最好 O(n),选择始终平方!
速查排序算法速查表
算法时间(最好/最坏/平均)空间稳定特点
冒泡O(n) / O(n²) / O(n²)O(1)✅简单但慢
选择O(n²) / O(n²) / O(n²)O(1)❌交换次数最少
插入O(n) / O(n²) / O(n²)O(1)✅近乎有序极快
计数O(n+k)O(k)✅非比较,整数专用
std::sortO(n log n)O(log n)❌通用首选
stable_sortO(n log n) / O(n log²n)O(n)✅稳定通用
📌知识点笔记卡

🃏 插入排序

逐个插入到已排序部分

近乎有序时 O(n)

稳定排序

🔢 计数排序

统计频数→前缀和→放置

O(n+k) 线性时间

只适用整数

🔒 稳定性

相同元素保持原顺序

从后往前放置

多关键字排序必备

⚡ 比较排序下界

任何比较排序

至少 O(n log n)

计数排序突破此限制

🎯 算法选择

通用 → std::sort

小数组 → 插入排序

值域小整数 → 计数排序

🚀 进阶算法

希尔排序 → 分组插入

桶排序 → 分桶+排序

基数排序 → 逐位计数

课后练习

分层练习作业

A基础 / B提高 / C挑战,难度递增,点击展开答案与解析。

🟢 A组 · 基础(4题)

A1选择题

插入排序在以下哪种情况下效率最高?

A. 数组完全逆序   B. 数组已完全有序   C. 数组元素随机排列   D. 数组所有元素相同

查看参考答案

答案:B

数组已有序时,每次插入只需比较1次且不需要移动 → 总复杂度 O(n)。D 选项虽然比较后也不需移动,但B是最佳答案。

💡 解析思路:插入排序的工作量 = 逆序对个数。已有序数组逆序对为 0,每个 key 只比较 1 次、移动 0 次 → O(n)。
⚠️ 易错:误选 D。全相同元素虽然也快,但那是退化输入;“效率最高”的标准答案是已完全有序。
A2选择题

计数排序的时间复杂度为 O(n+k),其中 k 表示()

A. 数组长度   B. 排序的轮数   C. 数据的值域范围   D. 比较的次数

查看参考答案

答案:C. 数据的值域范围

k 是数据中最大值与最小值之差(值域大小)。计数数组的大小取决于 k,遍历计数数组需要 O(k) 时间。

💡 解析思路:count[] 数组要为「每个可能的值」开一个格子,格子个数由值域决定;最后遍历 count[] 输出需要 O(k)。
⚠️ 易错:误选 A。数组长度是 n,它出现在 O(n+k) 的「n」里,别混淆两个变量。
💡 解析思路:两段相互独立的扫描:统计频数 O(n) + 按值输出 O(k),两项相加 = O(n+k)。当 k = O(n) 时整体就是线性 O(n)。
⚠️ 易错:误选 D。n 和 k 是先后两段独立过程,相加不相乘——乘法只出现在「内外嵌套」的循环里。
A3选择题

以下哪种排序算法不是基于比较的排序?

A. 冒泡排序   B. 插入排序   C. 计数排序   D. 选择排序

查看参考答案

答案:C. 计数排序

计数排序通过统计频数来确定元素位置,不需要比较元素大小。冒泡、插入、选择排序都是通过比较来确定元素顺序的。

易错:把“不是基于比较”理解成“复杂度一定更低”——计数排序是 O(n+k),k 很大时反而比比较排序更慢更费内存。

💡 解析思路:计数排序用「值当下标」统计频数,元素之间从头到尾没有比较过大小。
⚠️ 延伸:正因为不做比较,计数排序不受 O(n log n) 比较排序下界的限制——这是它能做到线性的根本原因,也是选择题高频考点。
A4编程题

用插入排序对输入的 n 个整数进行升序排列(n≤1000)。

查看参考答案
#include <iostream>
using namespace std;
int main() {
    int n; cin >> n;
    int a[1005];
    for(int i=0;i<n;i++) cin >> a[i];
    for(int i=1;i<n;i++){
        int key=a[i], j=i-1;
        while(j>=0 && a[j]>key){
            a[j+1]=a[j]; j--;
        }
        a[j+1]=key;
    }
    for(int i=0;i<n;i++) cout<<a[i]<< " ";
    return 0;
}

思路:把 a[i] 暂存为 key,向前寻找第一个不大于 key 的位置,边找边把元素逐个后移,最后把 key 放进空出来的位置。

易错:后移时写成 a[j]=a[j-1] 却忘记写 j--,会陷入死循环;或没有先暂存 key,导致原值被后续移动覆盖。

🟡 B组 · 提高(4题)

B1编程题

用计数排序对 n 个成绩(0~100)排序,并统计每个分数段(0~59不及格,60~79及格,80~100优秀)的人数。

查看参考答案
#include <iostream>
using namespace std;
int main() {
    int n; cin >> n;
    int count[101] = {};
    for(int i=0;i<n;i++){
        int x; cin >> x;
        count[x]++;
    }
    // 统计分数段
    int fail=0, pass=0, excellent=0;
    for(int v=0;v<=59;v++) fail+=count[v];
    for(int v=60;v<=79;v++) pass+=count[v];
    for(int v=80;v<=100;v++) excellent+=count[v];
    cout << "不及格: " << fail << endl;
    cout << "及格: " << pass << endl;
    cout << "优秀: " << excellent << endl;
    // 输出排序结果
    for(int v=0;v<=100;v++)
        while(count[v]-- > 0) cout << v << " ";
    return 0;
}

思路:用 count[0..100] 统计每个分数出现的次数,再按分数从小到大输出即完成排序,顺带按区间累加得到各分数段人数。

易错:count 数组只开到 100,遇到分数正好是 100 就越界(必须开到 101);或分数段边界处理不当,把 60 或 80 重复统计。

B2分析题

对数组 [9, 8, 7, 6, 5, 4, 3, 2, 1] 进行插入排序,计算总的比较次数和移动次数。与对 [1, 2, 3, 4, 5, 6, 7, 8, 9] 排序的开销做对比。

查看参考答案

逆序数组 [9,8,7,6,5,4,3,2,1]:

i=1: key=8, 比较1次, 移动1次 → [8,9,...]

i=2: key=7, 比较2次, 移动2次 → [7,8,9,...]

i=k: key, 比较k次, 移动k次

总比较 = 1+2+3+4+5+6+7+8 = 36 次

总移动 = 1+2+3+4+5+6+7+8 = 36 次

已排序数组 [1,2,3,4,5,6,7,8,9]:

每个 key 只需比较1次(key >= a[j]),移动0次

总比较 = 8 次,总移动 = 0 次

差距:逆序是插入排序的最坏情况 O(n²),有序是最好情况 O(n)。差距 = 36/8 = 4.5倍。

💡 解析思路:逆序数组逆序对最多:第 i 个元素要和前面 i 个都比一遍,总比较 1+2+…+8 = 36 次,移动 36 次;而已有序数组只比较 8 次、移动 0 次——相差 36 倍,这正是「近乎有序的数据用插入排序」的依据。
⚠️ 易错:移动次数不要把 key 的「取出」和「放回」也算进去,只数 a[j+1]=a[j] 的右移次数。
B3编程题

实现二分插入排序,对 n 个整数排序。用 n=10000 测试运行时间。

查看参考答案
#include <iostream>
#include <algorithm>
#include <chrono>
using namespace std;
void binaryInsertionSort(int a[], int n) {
    for(int i=1;i<n;i++){
        int key=a[i];
        int lo=0, hi=i;
        while(lo<hi){
            int mid=(lo+hi)/2;
            if(a[mid]<=key) lo=mid+1;
            else hi=mid;
        }
        for(int j=i;j>lo;j--) a[j]=a[j-1];
        a[lo]=key;
    }
}
int main() {
    int n=10000;
    int a[10005];
    for(int i=0;i<n;i++) a[i]=rand()%10000;
    auto t1=chrono::high_resolution_clock::now();
    binaryInsertionSort(a,n);
    auto t2=chrono::high_resolution_clock::now();
    cout << chrono::duration_cast<chrono::milliseconds>(t2-t1).count() << " ms" << endl;
    return 0;
}

思路:插入位置改用二分查找(lo

易错:以为二分插入排序的时间复杂度变成了 O(n log n)——元素移动的次数仍然是 O(n²),只是比较次数减少了。

B4编程题

实现稳定版计数排序,对结构体数组按 score 字段排序,保持 score 相同时的原始顺序。验证稳定性。

查看参考答案
#include <iostream>
#include <cstring>
using namespace std;
struct Student {
    char name[20];
    int score;
};
void stableCountSort(Student a[], int n, int maxScore) {
    int count[101] = {};
    Student output[1005];
    for(int i=0;i<n;i++) count[a[i].score]++;
    for(int i=1;i<=maxScore;i++) count[i]+=count[i-1];
    for(int i=n-1;i>=0;i--){  // 从后往前!
        output[count[a[i].score]-1]=a[i];
        count[a[i].score]--;
    }
    for(int i=0;i<n;i++) a[i]=output[i];
}
int main() {
    Student a[]={
        {"Alice",85},{"Bob",92},{"Carol",85},
        {"Dave",78},{"Eve",92}
    };
    stableCountSort(a, 5, 100);
    for(int i=0;i<5;i++)
        cout << a[i].name << " " << a[i].score << endl;
    return 0;
}
运行结果
Dave 78
Alice 85    ← Alice 在 Carol 前(保持原序)✅
Carol 85
Bob 92      ← Bob 在 Eve 前(保持原序)✅
Eve 92

思路:先统计各分数的出现次数,对 count 求前缀和得到每个分数的结束位置,再从后往前扫描原数组填入 output 以保持稳定。

易错:从前向后扫描并直接输出,会破坏稳定性(相同 score 的元素顺序被颠倒)。

🔴 C组 · 挑战(4题)

C1初赛真题

关于插入排序和计数排序,下列说法正确的有()个

① 插入排序是稳定排序
② 计数排序是稳定排序(稳定版实现)
③ 插入排序的最坏时间复杂度是 O(n²)
④ 计数排序可以对任意实数排序
⑤ 插入排序对近乎有序的数组特别高效

A. 2   B. 3   C. 4   D. 5

查看参考答案

答案:C(4个正确)

① ✅ 插入排序稳定   ② ✅ 从后往前放置保证稳定   ③ ✅ 逆序时 O(n²)   ④ ❌ 计数排序只适用于整数   ⑤ ✅ 接近有序时 O(n)

💡 解析思路:④ 是陷阱:计数排序要求值必须是非负整数(要当数组下标用),实数、字符串都不行(除非先离散化,但那已经不是裸的计数排序)。①②③⑤ 均正确,共 4 个。
⚠️ 易错:多选判断题先划掉 100% 确定的,再用反例验证拿不准的——比如想证明 ④ 错,只要问一句「3.14 放在 count 数组的第几格?」。
C2初赛真题

对 n 个 [0, k) 范围内的整数排序,计数排序的时间复杂度为()

A. O(n log n)   B. O(n²)   C. O(n + k)   D. O(nk)

查看参考答案

答案:C. O(n + k)

统计频数 O(n) + 遍历计数数组 O(k) = O(n+k)。当 k = O(n) 时,排序时间为 O(n),这是线性时间。

考点:计数排序需要 O(n) 遍历统计频数,再 O(k) 扫描计数数组输出结果,总复杂度与数值范围 k 有关。

易错:把复杂度记成 O(n log n) 或直接答 O(n),忽略了 k 的影响(k=10⁹ 时时间和内存都不可行)。

C3综合编程

实现希尔排序(Shell Sort),使用 Knuth 间距序列(1, 4, 13, 40, 121, ...),对 n 个整数排序。对比与普通插入排序的运行时间。

查看参考答案
#include <iostream>
using namespace std;

void shellSort(int a[], int n) {
    // 计算初始间距(Knuth序列: 1, 4, 13, 40, 121, ...)
    int gap = 1;
    while (gap < n / 3) gap = gap * 3 + 1;

while (gap >= 1) {
        // 对每个间距执行插入排序
        for (int i = gap; i < n; i++) {
            int key = a[i];
            int j = i - gap;
            while (j >= 0 && a[j] > key) {
                a[j + gap] = a[j];
                j -= gap;
            }
            a[j + gap] = key;
        }
        gap /= 3;  // 缩小间距
    }
}

int main() {
    int n = 100000;
    int a[100005];
    for (int i = 0; i < n; i++) a[i] = rand() % 100000;
    shellSort(a, n);
    // 验证有序
    bool ok = true;
    for (int i = 1; i < n; i++)
        if (a[i] < a[i-1]) { ok = false; break; }
    cout << (ok ? "排序正确" : "排序错误") << endl;
    return 0;
}

Knuth 间距序列:h = 3h + 1 → 1, 4, 13, 40, 121, 364, ...

复杂度:约 O(n^1.25),远优于普通插入排序的 O(n²)。在 n=100000 时,希尔排序约 0.05 秒,普通插入排序约 10 秒。

思路:按 Knuth 序列从大到小取间距 gap,对每个 gap 做一次“跨 gap 的插入排序”,gap=1 时就是普通插入排序。

易错:gap 递减时漏掉最后一轮 gap=1;或内层循环仍只比较相邻元素,gap 没有参与下标计算,排序不完整。

C4综合编程

实现基数排序(Radix Sort),对 n 个非负整数(≤10⁹)排序。从个位到最高位,每位用稳定计数排序。

查看参考答案
#include <iostream>
using namespace std;

void radixSort(int a[], int n) {
    // 找最大值确定最大位数
    int maxVal = a[0];
    for (int i = 1; i < n; i++)
        if (a[i] > maxVal) maxVal = a[i];

int output[100005];

// 从个位到最高位,逐位计数排序
    for (long long exp = 1; maxVal / exp > 0; exp *= 10) {
        int count[10] = {};  // 0~9 十个数字

// 统计当前位的频数
        for (int i = 0; i < n; i++)
            count[(a[i] / exp) % 10]++;

// 前缀和
        for (int i = 1; i < 10; i++)
            count[i] += count[i - 1];

// 从后往前放置(保证稳定)
        for (int i = n - 1; i >= 0; i--) {
            int digit = (a[i] / exp) % 10;
            output[count[digit] - 1] = a[i];
            count[digit]--;
        }

// 复制回原数组
        for (int i = 0; i < n; i++)
            a[i] = output[i];
    }
}

int main() {
    int a[] = {170, 45, 75, 90, 802, 24, 2, 66};
    int n = 8;
    radixSort(a, n);
    for (int i = 0; i < n; i++)
        cout << a[i] << " ";
    return 0;
}
运行结果
2 24 45 66 75 90 170 802

原理:从个位开始逐位排序。每位用稳定计数排序(0~9)。经过 d 轮(d=位数)后整体有序。

复杂度:O(d × (n + 10)),d ≤ 10(int最大10位),约 O(10n) = O(n)!

思路:从低位到高位依次对每一位做稳定的计数排序(exp=1,10,100,… 取出该位数字),低位有序保证高位排序后低位顺序不被打乱。

易错:用不稳定的排序处理每一位,低位已经排好的相对顺序会丢失;或 exp 提前越界结束循环,高位没有处理完。

🏆 洛谷原题精选(20道)

以下题目涵盖排序算法的各种应用场景,按难度递增排列。

1
排序
P1177
入门
2
明明的随机数
P1059
入门
3
宇宙总统
P1781
入门
4
分数线划定
P1068
入门
5
奖学金
P1093
入门
6
谁拿了最多奖学金
P1051
入门
7
车厢重组
P1116
入门
8
排队接水
P1223
入门
9
第k小整数
P1138
入门
10
拼数
P1012
普及
11
纪念品分组
P1094
普及
12
凌乱的yyy/线段覆盖
P1803
普及
13
逆序对
P1908
普及
14
合并果子
P1090
普及
15
瑞士轮
P1309
普及
16
合唱队形
P1091
普及
17
国王游戏
P1080
提高
18
火柴排队
P1966
提高
19
逛公园
P3953
提高
20
普通平衡树
P3369
提高