数据结构之排序算法(上):从评价指标到插入、希尔、选择与堆排序

本篇目标:

1.认识排序的基础概念

2.学会插入排序,希尔排序。选择排序,堆排序的算法思路

一. 什么是排序

排序是数据结构中最基础、也最能体现"用数据特征换取效率"的主题之一。表面看,排序只是把一组数字按升序或降序排列;实际工程中,元素往往是一条完整记录,真正参与比较的只是记录中的某个关键字。

设有记录序列

cpp 复制代码
R₁, R₂, ..., Rₙ

每条记录分别对应一个关键字:

cpp 复制代码
k₁, k₂, ..., kₙ

排序的目标是找到原记录序列的一种排列

cpp 复制代码
Rₚ₁, Rₚ₂, ..., Rₚₙ

使其对应的关键字满足:

cpp 复制代码
kₚ₁ ≤ kₚ₂ ≤ ... ≤ kₚₙ

此时,记录序列便按照关键字完成了升序排列

最终得到按关键字非递减排列的记录序列。若把不等号反过来,就是非递增排序。

例如,学生记录可能包含学号、姓名、年龄和成绩:

cpp 复制代码
typedef struct Student 
{
    int stuNo;
    char name[20];
    int age;
    double score;
} Student;

如果按成绩升序排列 ,score 是排序关键字,而整条 Student 记录必须随着关键字一起移动。理解这一点后,"稳定性"为什么重要也就自然清楚了:两个学生成绩相同时,稳定排序能够保留他们原来的先后关系。

排序在实际生活中也随处可见,如购物软件 中通过综合评分、评价、价格等排序商品方便筛选。再或者大学综合实力排行榜,再或者华润富豪排行榜等等,如图:

常见内部排序可以从两个角度分类:

按基本操作分 :插入排序、选择排序、交换排序、归并排序等。

按是否比较关键字分:比较排序与非比较排序。计数排序、基数排序属于后者。

如图:

所谓"内部排序",是指待排序数据能够整体放入内存。若数据大到必须借助磁盘分批处理,则属于外部排序,典型方法是外部归并。

二. 评价排序算法的三个核心指标

1. 时间复杂度

排序不只要看一个笼统的复杂度,还应区分最好、平均和最坏情况。例如,直接插入排序在数组已经有序时只需线性扫描,时间复杂度是 O(n);但在完全逆序时需要大量移动,最坏达到 O(n²)。

分析时通常关注两类操作

cpp 复制代码
关键字比较次数;
记录移动或交换次数。

这两个指标并不总是同步变化。折半插入排序能显著减少比较次数,却不能减少为了空出插入位置而进行的元素移动,因此其总体最坏时间复杂度仍是 O(n²)。

2. 空间复杂度

如果算法只使用若干临时变量,额外空间是 O(1),通常称为原地排序。直接插入、希尔、简单选择和堆排序都可以原地完成。

注意:

cpp 复制代码
"原地"并不等于"不使用任何额外内存",而是额外空间不会随着输入规模 `n` 增长。

3. 稳定性

设两个元素 Ri、Rj 的关键字相等,并且排序前 Ri 位于 Rj 前面。如果排序后仍能保证 `Ri` 在 `Rj` 前面,则该排序算法是稳定的。

稳定性只对"相等关键字的记录"有意义。纯整数数组里的两个 `5` 看起来没有区别,但如果它们分别是:

cpp 复制代码
text
(成绩=90, 提交时间=09:00)
(成绩=90, 提交时间=09:05)

稳定排序就能在按成绩排序后继续保留提交时间的先后关系。多关键字排序中,稳定性尤其重要:可以先按次关键字稳定排序,再按主关键字稳定排序。

例如如图所示:

4. 性能测试代码

后面要用的代码:

cpp 复制代码
void TestPerformance()
{
    srand(static_cast<unsigned int>(time(nullptr)));

    const int N = 10'000'000;

    int* a1 = static_cast<int*>(malloc(sizeof(int) * N));
    int* a2 = static_cast<int*>(malloc(sizeof(int) * N));
    int* a3 = static_cast<int*>(malloc(sizeof(int) * N));
    int* a4 = static_cast<int*>(malloc(sizeof(int) * N));
    int* a5 = static_cast<int*>(malloc(sizeof(int) * N));
    int* a6 = static_cast<int*>(malloc(sizeof(int) * N));
    int* a7 = static_cast<int*>(malloc(sizeof(int) * N));
    int* a8 = static_cast<int*>(malloc(sizeof(int) * N));
    int* a9 = static_cast<int*>(malloc(sizeof(int) * N));

    // 生成相同的测试数据
    for (int i = 0; i < N; ++i)
    {
        a1[i] = rand() + i;

        // 如果只使用 rand(),重复数据会比较多
        // a1[i] = rand();

        a2[i] = a1[i];
        a3[i] = a1[i];
        a4[i] = a1[i];
        a5[i] = a1[i];
        a6[i] = a1[i];
        a7[i] = a1[i];
        a8[i] = a1[i];
        a9[i] = a1[i];
    }

    // 插入排序
    clock_t begin1 = clock();
    // InsertSort(a1, N);
    clock_t end1 = clock();

    // 希尔排序
    clock_t begin2 = clock();
    // ShellSort(a2, N);
    clock_t end2 = clock();

    // 选择排序
    clock_t begin3 = clock();
    // SelectSort(a3, N);
    clock_t end3 = clock();

    // 堆排序
    clock_t begin4 = clock();
    // HeapSort(a4, N);
    clock_t end4 = clock();

    // 冒泡排序
    clock_t begin5 = clock();
    // BubbleSort(a5, N);
    clock_t end5 = clock();

    // 快速排序
    clock_t begin6 = clock();
    // QuickSort(a6, 0, N - 1);
    clock_t end6 = clock();

    // 归并排序
    clock_t begin7 = clock();
    // MergeSort(a7, N);
    clock_t end7 = clock();

    // 计数排序
    clock_t begin8 = clock();
    // CountSort(a8, N);
    clock_t end8 = clock();

    // 基数排序
    clock_t begin9 = clock();
    // RadixSortLSD(a9, N);
    clock_t end9 = clock();

    printf("InsertSort   : %lld ms\n",
           static_cast<long long>(end1 - begin1));
    printf("ShellSort    : %lld ms\n",
           static_cast<long long>(end2 - begin2));
    printf("SelectSort   : %lld ms\n",
           static_cast<long long>(end3 - begin3));
    printf("HeapSort     : %lld ms\n",
           static_cast<long long>(end4 - begin4));
    printf("BubbleSort   : %lld ms\n",
           static_cast<long long>(end5 - begin5));
    printf("QuickSort    : %lld ms\n",
           static_cast<long long>(end6 - begin6));
    printf("MergeSort    : %lld ms\n",
           static_cast<long long>(end7 - begin7));
    printf("CountSort    : %lld ms\n",
           static_cast<long long>(end8 - begin8));
    printf("RadixSortLSD : %lld ms\n",
           static_cast<long long>(end9 - begin9));

    free(a1);
    free(a2);
    free(a3);
    free(a4);
    free(a5);
    free(a6);
    free(a7);
    free(a8);
    free(a9);
}

排序算法可视化网站

<1>.VisuAlgo由新加坡国立大学(NUS)计算机学院的 Steven Halim 博士牵头创建。

https://visualgo.net/en/sortinghttps://visualgo.net/en/sorting

<2>.Comparison Sorting由美国旧金山大学(University of San Francisco)计算机科学系提供。

Comparison Sorting Visualizationhttps://www.cs.usfca.edu/~galles/visualization/ComparisonSort.html

三. 直接插入排序

1. 思路及过程

直接插入排序与打牌时整理手牌非常相似:左侧牌已经有序,每次从右侧取出一张新牌,在左侧找到合适位置,将较大的牌依次右移,再把新牌插进去。

如图:

单次插入示意图:

0, end 已经有序,那么一次插入要完成的任务是:把 aend + 1 插入 0, end,使 0, end + 1 有序。

流程如图:

代码块:

cpp 复制代码
int end=6;
int tmp = a[end + 1];

while (end >= 0)
{
    if (tmp < a[end])
    {
        // 依次往后挪动
        a[end + 1] = a[end];
        --end;
    }
    else
    {
        // 挪动结束,跳出循环
        break;
    }
}

a[end + 1] = tmp;

那么整体流程思路呢?

整体插入思想:

cpp 复制代码
<1>.直接插入排序最开始将 [0, 0] 作为第一个有序区间,把下标为 1 的元素插入 [0, 0],此时 [0, 1] 区间有序。

<2>.再把下标为 2 的元素插入 [0, 1],此时 [0, 2] 区间有序。

<3>.以此类推,不断重复上述过程,直至 [0, n - 2] 区间有序。

<4>.最后把下标为 n - 1 的元素插入 [0, n - 2],此时 [0, n - 1] 整体有序。

流程图:

代码块:

cpp 复制代码
void InsertSort(int* a, int n)
{
    // 将 [1, n-1] 位置的值依次插入前面的有序区间
    for (int i = 0; i < n - 1; i++)
    {
        // [0, end] 有序,将 end+1 位置的值插入 [0, end],保持有序
        int end = i;
        int tmp = a[end + 1];

        while (end >= 0)
        {
            if (tmp < a[end])
            {
                // 依次往后挪动
                a[end + 1] = a[end];
                --end;
            }
            else
            {
                // 挪动结束,跳出循环
                break;
            }
        }

        a[end + 1] = tmp;
    }
}

这里故意使用 aend > key,而不是 aend >= key。遇到相等元素时停止移动,新元素会放在已有相等元素之后,从而保持稳定性。

测试代码:

cpp 复制代码
void PrintArray(int* a, int n)
{
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }

    printf("\n");
}

void TestInsertSort()
{
    int a[] = {2, 4, 1, 7, 8, 3, 9, 2};

    InsertSort(a, sizeof(a) / sizeof(int));
    PrintArray(a, sizeof(a) / sizeof(int));
}

2. 复杂度与稳定性

时间复杂度:最好情况:原序列已经升序,每个元素只比较一次,时间复杂度 O(n);最坏情况:原序列完全逆序,移动次数约为 1 + 2 + ... + (n - 1),时间复杂度 O(n²);平均时间复杂度:O(n²)。

空间复杂度:O(1)。

稳定性:稳定。

直接插入排序很"挑数据":当序列规模较小,或者大部分元素已经有序时,它往往非常高效。这也是很多混合排序在处理小区间时切换到插入排序的原因。

四. 希尔排序

1. 思路及过程

直接插入排序在逆序数组上很慢,是因为一个很小的元素每轮只能通过连续移动逐步靠近前端。希尔排序的改进思路是:先允许元素以较大步长移动,让远距离逆序快速减少;最后再用步长为 1 的直接插入排序收尾。

希尔排序又称"缩小增量排序"。给定增量 gap,把下标相差 gap 的元素看成一组,并对每组进行直接插入排序。例如 gap = 3 时:

cpp 复制代码
text:
第 0 组:a[0], a[3], a[6], ...
第 1 组:a[1], a[4], a[7], ...
第 2 组:a[2], a[5], a[8], ...

例如d==3时随机数组进行预排序过程展示:

通过上面的过程我们就可以得出间距为d的单趟插入代码实现

cpp 复制代码
// [0, end] 区间中,间距为 d 的数据已经有序,
// 将 end+d 位置的元素插入对应的 d 间距分组中
int d = 3; // 增量
for (size_t i = 0; i < n - d; ++i)
{
    int end = i;
    int tmp = a[end + d];

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

    a[end + d] = tmp;
}

当 gap > 1 时,可以把这些轮次理解为预排序当 gap == 1 时,它就是一次普通的直接插入排序。由于前面的预排序已经让数组接近有序,最后一轮通常很快。

可是这个d应该如何变化呢?那么就要讲到下面的增量序列了。

2. 增量序列

希尔排序的效率高度依赖增量序列。常见方案包括:

Shell:不断令 gap = gap / 2;

Knuth:1, 4, 13, 40, ...,满足 gap = 3 * gap + 1;

Sedgewick:由若干公式交错生成。

而 Knuth 方案高效、简洁且应用广泛。下面采用 Knuth 增量。

希尔排序:

cpp 复制代码
void ShellSort(int* a, int n)
{
    int d = n;
    while (d > 1)
    {
        // +1 保证最后一个 d 一定是 1
        // d > 1 时是预排序
        // d == 1 时是直接插入排序
        d = d / 3 + 1; // 缩小增量
        // 关键点:
        // ++i 控制多个间隔为 d 的组串联并行插入排序
        for (size_t i = 0; i < n - d; ++i)
        {
            int end = i;
            int tmp = a[end + d];

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

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

测试代码:

cpp 复制代码
void TestShellSort()
{
    int a[] = {9, 1, 2, 5, 7, 4, 6, 3, 5};
    /*
    int a[] = {
        9, 1, 2, 5, 7, 4, 6, 3, 5,
        9, 1, 2, 5, 7, 4, 6, 3, 5,
        9, 1, 2, 5, 7, 4, 6, 3, 5,
        9, 1, 2, 5, 7, 4, 6, 3, 5,
        9, 1, 2, 5, 7, 4, 6, 3, 5
    };
    */
    PrintArray(a, sizeof(a) / sizeof(int));
    ShellSort(a, sizeof(a) / sizeof(int));
    PrintArray(a, sizeof(a) / sizeof(int));
}

3. 复杂度与稳定性

时间复杂度:希尔排序的复杂度不能脱离增量序列单独讨论,平均性能约为 O(n^1.3),最坏可到 O(n²);有些精心设计的增量序列可以得到更好的理论界。

空间复杂度:O(1)。

稳定性:不稳定。

它不稳定的根本原因,是相同关键字可能被分到不同增量组,在远距离移动中改变相对次序。即使每个组内的插入过程是稳定的,整体仍不一定稳定。

判断某一趟希尔排序可能采用什么增量时,可以把结果按候选 gap 分组:如果每个组内都已经有序,该增量才可能成立。

六. 简单选择排序

1. 思路及过程

第 i 趟从未排序区间 i, n - 1找出最小元素,把它交换到位置 i。前 i 个元素构成有序区间,后面的元素继续参与选择。

流程图:

代码块:

cpp 复制代码
void SelectSort(int* a, int n)
{
    for (int i = 0; i < n - 1; ++i)
    {
        // 在区间 [i + 1, n - 1] 中寻找最小元素的位置
        int min = i;
        for (int j = i + 1; j < n; ++j)
        {
            if (a[j] < a[min])
            {
                min = j;
            }
        }
        // 如果最小元素不在当前位置,则进行交换
        if (min != i)
        {
            int tmp = a[i];
            a[i] = a[min];
            a[min] = tmp;
        }
    }
}

测试代码:

cpp 复制代码
void TestSelectSort()
{
    int a[] = {9, 1, 2, 5, 7, 4, 6, 3};
    int n = sizeof(a) / sizeof(int);

    PrintArray(a, n);
    SelectSort(a, n);
    PrintArray(a, n);
}

2. 复杂度与稳定性

不论数组原来是升序、降序还是随机,简单选择排序每一趟都必须扫描未排序区间:

cpp 复制代码
(n - 1) + (n - 2) + ... + 1 = n(n - 1) / 2

因此:

cpp 复制代码
最好、平均、最坏时间复杂度均为 O(n²);
交换次数至多 n - 1,比冒泡排序少;
额外空间复杂度为 O(1);
不稳定。

**为什么不稳定?**考虑带身份标记的序列:

简单选择排序⽆法保持稳定性,如{5,6,5,0,2,0,3}我们可以遍历⼀遍选出第⼀个0交换到最左边的位 置,保持0的相对顺序,但是在交换过程中5的相对顺序就被破坏了,所以简单选择排序⽆法保持稳 定性。

七. 堆排序

堆排序在堆的那一篇文章里面就已经提到过了,这里就简单的说一下。

1. 思路及过程

简单选择排序每一趟靠线性扫描找最值,找一次需要 O(n)。堆排序把数组逻辑上看作完全二叉树,利用堆顶始终为最值的性质完成选择:

排升序时建立大堆,堆顶是当前最大值;

将堆顶与当前未排序区间的末尾交换;

缩小堆的有效范围;

对新的堆顶执行向下调整,恢复大堆。

数组下标为 `parent` 的结点,其孩子下标是:

cpp 复制代码
left  = 2 * parent + 1
right = 2 * parent + 2

向下调整有一个前提:当前结点的左右子树已经是堆 ,只有该结点可能破坏堆序。每次选出更大的孩子与父结点比较;若孩子更大,就交换并继续向下,否则调整完成。

代码块:

cpp 复制代码
void Swap(int* x, int* y)
{
    int tmp = *x;
    *x = *y;
    *y = tmp;
}
// 向下调整算法
void AdjustDown(int* a, int n, int parent)
{
    // 假设 parent 的左右子树已经是大堆,
    // child 先指向左孩子
    int child = parent * 2 + 1;
    // child >= n 时,说明孩子不存在,
    // parent 已经指向叶子结点,调整结束
    while (child < n)
    {
        // 比较左右孩子,让 child 指向较大的孩子
        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;
        }
    }
}
void HeapSort(int* a, int n)
{
    /* 建堆:O(N),升序排序:建立大堆,降序排序:建立小堆 */
    for (int i = (n - 2) / 2; i >= 0; --i)
    {
        AdjustDown(a, n, i);
    }
    // 依次将堆顶元素交换到数组末尾
    int j = 1;

    while (j < n)
    {
        // 将第 j 大的元素放到倒数第 j 个位置
        Swap(&a[0], &a[n - j]);
        // 排除已经有序的末尾部分,重新调整为大堆
        AdjustDown(a, n - j, 0);
        ++j;
    }
}

测试代码:

cpp 复制代码
void TestHeapSort()
{
    int a[] = {9, 1, 2, 5, 7, 4, 6, 3};
    int n = sizeof(a) / sizeof(int);

    PrintArray(a, n);
    HeapSort(a, n);
    PrintArray(a, n);
}

建堆不是 O(n log n),而是 O(n):越靠近底层,结点越多但可下沉的高度越小;把所有结点的调整代价累加,结果为线性级。之后要执行约 n - 1次堆顶交换与向下调整,每次最多 O(log n),所以排序阶段为 O(n log n)。

2. 复杂度与稳定性

时间复杂度:最好、平均、最坏均为 O(n log n);

空间复杂度:O(1);

稳定性:不稳定。

不稳定的原因仍是远距离交换。堆顶记录与末尾记录交换时,可能跨过多个相同关键字,破坏它们原有的相对次序。

堆排序的优势是最坏情况也有 O(n log n) 保证,而且原地;缺点是访问不连续、缓存局部性通常不如快速排序,常数开销也不算小。

总结:

数据量小或数组接近有序:优先直接插入排序。

希望保持原地、代码不复杂,又想明显快于普通 O(n²) 排序:可考虑希尔排序。

关键字比较昂贵,但元素交换非常昂贵且数据规模不大:简单选择排序交换次数少,可能有价值。

需要最坏 O(n log n)、只能使用常数额外空间:堆排序是可靠选择。

必须稳定:本篇算法中优先直接插入;希尔、简单选择和堆排序都不稳定。

相关推荐
syagain_zsx1 小时前
算法基础篇 · 02 高精度(C++ 题解)
开发语言·c++·学习·高精度
6Hzlia1 小时前
【Classic 150 刷题计划】 LeetCode 242. 有效的字母异位词 | C++ 哈希计数与严密防线
c++·算法·leetcode
wabs6661 小时前
关于二叉树【力扣101.对称二叉树的思考】
数据结构·c++·算法·leetcode·二叉树
6Hzlia1 小时前
【Classic 150 刷题计划】 LeetCode 228. 汇总区间 | C++ 双游标区间扫描与 to_string 规范
c++·算法·leetcode
SuperByteMaster2 小时前
configUSE_TIME_SLICING和configUSE_PREEMPTION
c语言
周杰偷奶茶2 小时前
【C 语言】分支、循环和 goto 语句(附实战)
c语言
LuminousCPP2 小时前
数据结构-排序(五):计数排序与排序专题阶段总结|从外部归并到线性排序,再看算法边界与选型
c语言·数据结构·笔记·算法·排序算法
山下梅子酒2252 小时前
洛谷-入门-B2046
c语言
纪念 2292 小时前
C++类和对象(二)
开发语言·c++