C语言数据结构排序算法详解(上):从插入排序、希尔排序到选择排序、堆排序

C语言数据结构排序算法详解(上):从插入排序、希尔排序到选择排序、堆排序

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 《C语言基础》 《数据结构》 《机器学习导论》 《前端基础》 ✨ 数据即知识,压缩即智能


目录


前言

排序是数据结构与算法里非常基础、也非常重要的一章。

我们平时写代码时,经常会遇到这类需求:

  • 按成绩从高到低排列学生
  • 按价格从低到高展示商品
  • 按时间顺序显示消息
  • 按热度展示文章、视频或商品
  • 按字典序排列字符串
  • 在海量数据中找 Top K

这些需求背后都离不开排序。

所谓排序,简单说就是:

把一组数据按照某个关键字的大小,重新排列成递增或递减的顺序。

比如:

原数组:

  • 5,3,8,1,6

升序排序后:

  • 1,3,5,6,8

降序排序后:

  • 8,6,5,3,1

一、排序是什么?

排序就是让一组记录按照关键字有序排列。

这里有两个关键词:

  1. 记录
  2. 关键字

比如学生信息:

姓名 年龄 成绩
张三 18 92
李四 19 85
王五 18 92
赵六 20 78

每一行就是一条记录。

如果按成绩排序,那么"成绩"就是关键字。

如果按年龄排序,那么"年龄"就是关键字。

所以排序不一定只是排整数数组,它也可以排结构体、对象、字符串、文件记录等。


二、为什么排序这么重要?

排序看起来只是把数据排一下,但它的意义很大。

1. 排序可以让数据更容易查找

如果数组是无序的,查找一个元素通常要从头扫到尾。

但如果数组有序,就可以使用二分查找。

二分查找的时间复杂度是:

  • O(logN)

这比顺序查找的 O(N) 快很多。


2. 排序可以让数据更容易展示

电商网站经常会提供排序功能:

  • 综合排序
  • 销量排序
  • 价格升序
  • 价格降序
  • 好评优先
  • 上架时间排序

这些本质上都是根据不同关键字进行排序。


3. 排序经常是其他算法的前置步骤

很多算法都会先排序再处理。

比如:

  • 去重
  • 双指针
  • 区间合并
  • 贪心算法
  • Top K 问题
  • 中位数问题
  • 统计排名

排序不是孤立存在的,它经常是解决复杂问题的第一步。


三、排序算法应该从哪些维度分析?

学习排序算法时,不建议只背代码。

更重要的是理解每种排序的特点。

通常从下面几个维度分析。


1. 时间复杂度

时间复杂度关注:

数据规模变大以后,算法执行次数如何增长。

常见排序复杂度有:

复杂度 代表算法
O(N²) 插入排序、选择排序、冒泡排序
O(NlogN) 堆排序、快速排序、归并排序
O(N + 范围) 计数排序

2. 空间复杂度

空间复杂度关注:

算法执行过程中额外占用多少空间。

比如:

  • 插入排序:O(1)
  • 选择排序:O(1)
  • 堆排序:O(1)
  • 快速排序:平均 O(logN)
  • 归并排序:O(N)
  • 计数排序:O(范围)

3. 稳定性

稳定性是排序里一个非常容易被忽略,但很重要的概念。

假设有两条记录关键字相同。

排序前:

顺序 姓名 分数
1 张三 90
2 李四 90

如果排序后,张三仍然在李四前面,那么这个排序过程对这两个相同关键字的记录保持了相对顺序。

这种排序算法叫:

稳定排序

如果排序后,李四跑到张三前面了,那么就是:

不稳定排序

稳定性在结构体排序、多关键字排序中非常有用。

比如先按姓名排序,再按成绩排序,如果第二次排序是稳定的,就能在成绩相同的时候保留第一次排序的结果。


4. 是否原地排序

如果排序过程中只使用常数级额外空间,一般称为原地排序。

比如:

  • 插入排序
  • 选择排序
  • 冒泡排序
  • 堆排序

它们基本都属于原地排序。

归并排序通常需要额外数组,因此不是典型原地排序。


5. 是否基于比较

大多数排序算法都是通过比较两个元素大小来排序。

比如:

  • 插入排序
  • 希尔排序
  • 选择排序
  • 堆排序
  • 冒泡排序
  • 快速排序
  • 归并排序

这些都属于比较排序。

计数排序则不是单纯依靠比较,而是通过统计元素出现次数完成排序。


四、常见排序算法分类

常见排序算法可以按思想分成几类。

类型 代表算法 核心思想
插入排序 直接插入排序、希尔排序 把数据插入到已有序区间
选择排序 直接选择排序、堆排序 每轮选择最小值或最大值
交换排序 冒泡排序、快速排序 通过交换把元素放到正确区域
归并排序 归并排序 分治,先分解再合并
非比较排序 计数排序 统计次数再回收数据

这篇上篇先讲插入类和选择类排序。


五、插入排序:像整理扑克牌一样排序

直接插入排序是最容易理解的排序之一。

它的思想很像我们整理扑克牌。

假设你手里已经有几张排好序的牌。

现在新摸到一张牌,你要把它插入到合适位置。

比如你手里已有:

  • 3,5,9,12

新摸到:

  • 7

你会从右往左看,发现 9 和 12 都比 7 大,于是它们往后挪。

最后把 7 插到 5 和 9 中间:

  • 3,5,7,9,12

这就是插入排序的思想。


直接插入排序的基本过程

给定数组:

下标 0 1 2 3 4
数据 5 3 8 1 6

插入排序会默认第一个元素已经有序。

然后从第二个元素开始,依次插入前面的有序区间。

第一轮:把 3 插入 [5]

结果:

| 数据 | 3 | 5 | 8 | 1 | 6 |

第二轮:把 8 插入 [3, 5]

8 已经比 5 大,不需要移动。

结果:

| 数据 | 3 | 5 | 8 | 1 | 6 |

第三轮:把 1 插入 [3, 5, 8]

3、5、8 都比 1 大,全部后移。

结果:

| 数据 | 1 | 3 | 5 | 8 | 6 |

第四轮:把 6 插入 [1, 3, 5, 8]

8 后移,6 插入 5 后面。

结果:

| 数据 | 1 | 3 | 5 | 6 | 8 |


六、直接插入排序的代码实现

c 复制代码
void InsertSort(int* a, int n)
{
    for (int i = 0; i < n - 1; i++)
    {
        int end = i;
        int tmp = a[end + 1];

        while (end >= 0)
        {
            if (a[end] > tmp)
            {
                a[end + 1] = a[end];
                end--;
            }
            else
            {
                break;
            }
        }

        a[end + 1] = tmp;
    }
}

代码解读

外层循环中,i 表示当前有序区间的最后一个位置。

也就是说:

  • [0, i] 是已经排好序的区域
  • i + 1 是即将插入的元素
c 复制代码
int end = i;
int tmp = a[end + 1];

tmp 保存待插入元素。

为什么要先保存?

因为后面元素后移时,a[end + 1] 这个位置会被覆盖。


while 循环在做什么?

c 复制代码
while (end >= 0)
{
    if (a[end] > tmp)
    {
        a[end + 1] = a[end];
        end--;
    }
    else
    {
        break;
    }
}

这段逻辑表示:

  • 如果前面的元素比 tmp 大,就往后挪
  • 如果前面的元素不比 tmp 大,说明找到插入位置了

最后:

c 复制代码
a[end + 1] = tmp;

tmp 放到正确位置。


七、直接插入排序的复杂度与稳定性

1. 时间复杂度

直接插入排序的最坏情况是逆序。

比如:

数据 5 4 3 2 1

每插入一个元素,都要把前面的元素整体后移。

所以最坏时间复杂度是:

  • O(N²)

最好情况是数组本来就有序。

比如:

数据 1 2 3 4 5

每次比较一次就结束,时间复杂度接近:

  • O(N)

平均时间复杂度通常看作:

  • O(N²)

2. 空间复杂度

直接插入排序只使用了少量临时变量:

  • O(1)

3. 稳定性

直接插入排序是稳定的。

关键在于这句判断:

c 复制代码
if (a[end] > tmp)

只有当前元素严格大于 tmp 才后移。

如果两个元素相等,不会移动前面的相等元素。

因此相等元素的相对顺序不会改变。


4. 直接插入排序适合什么场景?

它适合:

  • 数据规模较小
  • 数据基本有序
  • 对稳定性有要求
  • 想要实现简单

实际开发中,很多高级排序在处理小区间时,也会切换到插入排序,因为小规模数据下插入排序常数小、实现简单、效果不错。


八、希尔排序:对插入排序的一次升级

希尔排序也叫缩小增量排序。

它是对直接插入排序的优化。

直接插入排序有一个明显问题:

如果一个很小的数在数组最后面,它只能一步一步往前挪,效率很低。

比如:

数据 9 8 7 6 5 4 3 2 1

如果用直接插入排序,1 要从最后一路往前挪到最前面。

希尔排序的想法是:

先让数据大致有序,再进行最后一次直接插入排序。

它通过 gap 分组来实现这个目标。


什么是 gap?

gap 可以理解成间隔。

比如数组:

下标 0 1 2 3 4 5 6 7 8
数据 9 1 2 5 7 4 8 6 3

如果 gap = 3,那么分组方式是:

组别 下标
第 1 组 0,3,6
第 2 组 1,4,7
第 3 组 2,5,8

每组内部做插入排序。

然后 gap 缩小,比如:

  • gap = 3
  • gap = 1

当 gap = 1 时,就是普通直接插入排序。

但是此时数组已经比较接近有序,插入排序会快很多。


九、希尔排序的代码实现

c 复制代码
void ShellSort(int* a, int n)
{
    int gap = n;

    while (gap > 1)
    {
        gap = gap / 3 + 1;

        for (int i = 0; i < n - gap; i++)
        {
            int end = i;
            int tmp = a[end + gap];

            while (end >= 0)
            {
                if (a[end] > tmp)
                {
                    a[end + gap] = a[end];
                    end -= gap;
                }
                else
                {
                    break;
                }
            }

            a[end + gap] = tmp;
        }
    }
}

代码解读

c 复制代码
gap = gap / 3 + 1;

这句用于逐步缩小 gap。

它保证最后一次 gap 一定会变成 1。

当 gap > 1 时,是预排序。

当 gap == 1 时,是直接插入排序。


希尔排序和插入排序的关系

如果把直接插入排序看作:

  • 相邻元素之间进行插入调整

那么希尔排序就是:

  • 间隔为 gap 的元素之间进行插入调整

当 gap 慢慢缩小到 1 时,数据整体已经接近有序。

所以最后一轮会很快。


十、希尔排序的复杂度与稳定性

1. 时间复杂度

希尔排序的时间复杂度和 gap 的取法有关。

不同增量序列会得到不同复杂度。

在基础学习阶段,可以记住:

  • 希尔排序通常明显优于直接插入排序
  • 复杂度不容易精确统一
  • 常见教材中通常给出介于 O(N^1.3) 到 O(N²) 之间的分析
  • 实际性能通常比 O(N²) 排序好很多

2. 空间复杂度

希尔排序仍然只使用少量临时变量:

  • O(1)

3. 稳定性

希尔排序是不稳定的。

原因是它会让相隔 gap 的元素进行交换或移动。

相等元素可能被分到不同组里,经过多轮 gap 调整后,相对顺序可能发生变化。


4. 希尔排序适合什么场景?

希尔排序适合:

  • 希望在插入排序基础上提升性能
  • 数据规模中等
  • 不强制要求稳定性
  • 想写一个代码不复杂但比 O(N²) 排序快很多的算法

十一、选择排序:每一轮选出最小值

选择排序的思想非常直接:

每一轮从未排序区间中选出最小值,放到当前区间最前面。

比如数组:

数据 5 3 8 1 6

第一轮,从所有元素中选出最小值 1,放到第 0 个位置:

数据 1 3 8 5 6

第二轮,从下标 1 到末尾选出最小值 3,放到第 1 个位置:

数据 1 3 8 5 6

第三轮,从下标 2 到末尾选出最小值 5,放到第 2 个位置:

数据 1 3 5 8 6

继续进行,最终有序。


十二、直接选择排序的代码实现

普通写法是每轮只找最小值。

这里给出一个稍微优化的写法:

每一轮同时找最小值和最大值,把它们分别放到区间两端。

这样每轮可以确定两个位置。

c 复制代码
void Swap(int* p1, int* p2)
{
    int tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}

void SelectSort(int* a, int n)
{
    int begin = 0;
    int end = n - 1;

    while (begin < end)
    {
        int mini = begin;
        int maxi = begin;

        for (int i = begin + 1; i <= end; i++)
        {
            if (a[i] < a[mini])
            {
                mini = i;
            }

            if (a[i] > a[maxi])
            {
                maxi = i;
            }
        }

        Swap(&a[begin], &a[mini]);

        if (maxi == begin)
        {
            maxi = mini;
        }

        Swap(&a[end], &a[maxi]);

        begin++;
        end--;
    }
}

为什么要处理 maxi == begin

这段代码很关键:

c 复制代码
if (maxi == begin)
{
    maxi = mini;
}

因为第一步会把最小值交换到 begin 位置。

如果最大值原本就在 begin,那么最大值会被换到 mini 的位置。

所以后面交换最大值前,需要修正 maxi

否则会把错误位置的数据换到末尾。


十三、直接选择排序的复杂度与稳定性

1. 时间复杂度

选择排序无论数组是否有序,都要完整扫描未排序区间来找最小值。

所以时间复杂度始终是:

  • O(N²)

它不像插入排序那样在接近有序时变快。


2. 空间复杂度

只使用少量临时变量:

  • O(1)

3. 稳定性

直接选择排序是不稳定的。

举个例子:

原始顺序 5a 5b 3

第一轮选择最小值 3,与第一个位置 5a 交换:

排序后局部 3 5b 5a

原来 5a 在 5b 前面,现在 5a 跑到了 5b 后面。

所以选择排序不稳定。


4. 选择排序适合什么场景?

选择排序思路简单,但效率不高。

它适合:

  • 教学理解
  • 数据规模很小
  • 不要求稳定性
  • 想理解"选择最值"的排序思想

实际开发中很少直接使用选择排序。


十四、堆排序:用堆结构优化选择过程

堆排序本质上也是选择排序的一种。

直接选择排序每一轮都要线性扫描找最大值或最小值。

堆排序则用堆结构来提高选数效率。

堆是一种特殊的完全二叉树。

它通常用数组存储。


什么是大堆和小堆?

大堆:

每个父结点都大于等于它的孩子结点。

因此堆顶是最大值。

小堆:

每个父结点都小于等于它的孩子结点。

因此堆顶是最小值。


堆的数组下标关系

如果某个结点下标是 parent,那么:

关系 下标
左孩子 parent * 2 + 1
右孩子 parent * 2 + 2
父结点 (child - 1) / 2

这组公式来自完全二叉树的顺序存储。


十五、堆排序为什么升序要建大堆?

很多初学者会问:

我要排升序,为什么不建小堆,反而要建大堆?

原因在于堆排序通常是在原数组上进行排序。

如果排升序,我们希望最大值最终放到数组末尾。

大堆的堆顶正好是最大值。

操作流程:

  1. 建大堆,让最大值在堆顶
  2. 交换堆顶和数组最后一个元素
  3. 最大值被放到最终位置
  4. 对剩余元素继续向下调整
  5. 重复这个过程

所以升序建大堆。

如果排降序,就建小堆。


堆排序过程示意

假设我们有数组:

数据 5 3 8 1 6

升序堆排序:

  • 先建大堆,堆顶是最大值 8
  • 将 8 交换到数组末尾
  • 剩余区间继续调整成大堆
  • 再把当前最大值放到倒数第二个位置
  • 重复直到有序

它的核心是:

每次把当前未排序区间中的最大值放到最终位置。


十六、堆排序的代码实现

1. 向下调整

向下调整的前提是:

左右子树已经是堆,只有根结点可能不满足堆规则。

c 复制代码
void AdjustDown(int* a, int n, int parent)
{
    int child = parent * 2 + 1;

    while (child < n)
    {
        if (child + 1 < n && a[child + 1] > a[child])
        {
            child++;
        }

        if (a[child] > a[parent])
        {
            Swap(&a[child], &a[parent]);
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

这里实现的是大堆向下调整。

关键逻辑:

  • 先找到左右孩子中较大的那个
  • 如果孩子大于父亲,就交换
  • 交换后继续向下调整

2. 堆排序完整实现

c 复制代码
void HeapSort(int* a, int n)
{
    for (int i = (n - 2) / 2; i >= 0; i--)
    {
        AdjustDown(a, n, i);
    }

    int end = n - 1;

    while (end > 0)
    {
        Swap(&a[0], &a[end]);
        AdjustDown(a, end, 0);
        end--;
    }
}

代码分两步

第一步:建堆。

c 复制代码
for (int i = (n - 2) / 2; i >= 0; i--)
{
    AdjustDown(a, n, i);
}

为什么从 (n - 2) / 2 开始?

因为它是最后一个非叶子结点。

叶子结点本身就可以看成一个堆,不需要调整。


第二步:排序。

c 复制代码
while (end > 0)
{
    Swap(&a[0], &a[end]);
    AdjustDown(a, end, 0);
    end--;
}

每次把堆顶最大值交换到末尾。

然后对剩余区间继续调整成大堆。


十七、堆排序的复杂度与稳定性

1. 时间复杂度

堆排序主要分两步:

阶段 时间复杂度
建堆 O(N)
依次选出最大值 O(NlogN)

所以整体时间复杂度是:

  • O(NlogN)

2. 空间复杂度

堆排序可以在原数组上完成,只使用少量变量:

  • O(1)

3. 稳定性

堆排序是不稳定的。

因为堆排序过程中会频繁交换堆顶和末尾元素,相等元素的相对顺序可能被打乱。


4. 堆排序适合什么场景?

堆排序适合:

  • 希望最坏时间复杂度仍然是 O(NlogN)
  • 希望空间复杂度是 O(1)
  • 不要求稳定性
  • 需要理解 Top K、优先级队列、堆结构

不过实际工程中,堆排序的常数因素和缓存局部性不一定优于快速排序,因此很多场景中快排更常见。

但堆排序在理论上非常稳,因为它没有快排那种最坏 O(N²) 的退化问题。


上篇总结

上篇主要讲了四类排序中的两大类:

  • 插入类排序
  • 选择类排序

其中包括:

  • 直接插入排序
  • 希尔排序
  • 直接选择排序
  • 堆排序

1. 直接插入排序

核心思想:

把待插入元素放入前面的有序区间。

特点:

维度 结论
时间复杂度 O(N²),最好可接近 O(N)
空间复杂度 O(1)
稳定性 稳定
适合场景 小数据、基本有序数据

2. 希尔排序

核心思想:

先按 gap 分组预排序,再进行最终插入排序。

特点:

维度 结论
时间复杂度 与 gap 取法有关,通常优于 O(N²) 排序
空间复杂度 O(1)
稳定性 不稳定
适合场景 中等规模数据、不要求稳定性

3. 直接选择排序

核心思想:

每一轮从未排序区间选择最小值或最大值。

特点:

维度 结论
时间复杂度 O(N²)
空间复杂度 O(1)
稳定性 不稳定
适合场景 教学理解、数据量很小

4. 堆排序

核心思想:

利用堆结构反复选择最大值或最小值。

特点:

维度 结论
时间复杂度 O(NlogN)
空间复杂度 O(1)
稳定性 不稳定
适合场景 需要稳定最坏复杂度、空间要求较低

相关推荐
Eric 辰东1 小时前
【C 语言程序的编译和链接】详解编译链接过程
c语言·笔记·算法·学习方法
并不喜欢吃鱼1 小时前
从零开始 C++-----十一【C++ 数据结构】红黑树全解析:从定义到工程实现(一文搞定,十分详细)
开发语言·数据结构·c++
迈巴赫车主1 小时前
蓝桥杯21247弹跳鞋java
java·开发语言·数据结构·算法·职场和发展·蓝桥杯
SoftLipaRZC2 小时前
C语言数据在内存中的存储:整型与浮点型的秘密
c语言·开发语言
疯狂打码的少年2 小时前
指令寻址方式(立即、直接、间接、变址等)
网络·笔记
社交怪人2 小时前
【2的幂】信息学奥赛一本通C语言解法(题号1037)
c语言
魔法阵维护师2 小时前
从零开发游戏需要学习的c#模块,第三十章(掉落物品 —— 血包与能量)
学习·游戏·c#
在学了加油2 小时前
Inception v1学习笔记
笔记·python·学习
Cthy_hy2 小时前
Python算法竞赛:集合去重+字典映射 核心用法一站式整理
数据结构·python·算法