一文带你吃透八大排序!(C语言超详解版 + 快排三路划分优化加餐 + 工业级自省排序加餐解析!)

欢迎来到我的频道 点击转跳【数据结构与算法专栏】
码云连接 【点击转跳】在这里插入图片描述

0.时间复杂度

排名量级名称数学表示典型算法/场景评价
1常数阶 O ( 1 ) O(1) O(1)哈希表查找、数组下标访问🚀 极速(无论数据多少,时间不变)
2对数阶 O ( log ⁡ n ) O(\log n) O(logn)二分查找⚡ 极快(数据翻倍,只多算一步)
3线性阶 O ( n ) O(n) O(n)简单遍历、查找最大值✅ 快(数据翻倍,时间翻倍)
4线性对数阶 O ( n log ⁡ n ) O(n \log n) O(nlogn)快速排序、归并排序、堆排序🌟 优秀(排序算法的天花板)
5平方阶 O ( n 2 ) O(n^2) O(n2)冒泡排序、插入排序、选择排序🐢 慢(数据翻倍,时间翻4倍)
6立方阶 O ( n 3 ) O(n^3) O(n3)普通矩阵乘法、Floyd算法🐌 很慢(通常只用于极小数据)
7指数阶 O ( 2 n ) O(2^n) O(2n)斐波那契数列(递归版)、汉诺塔💥 极慢(数据量稍大即崩溃)
8阶乘阶 O ( n ! ) O(n!) O(n!)全排列生成、旅行商问题(暴力解)☠️ 灾难(几乎不可用)

1. 插入排序

直接插⼊排序是⼀种简单的插⼊排序法,其基本思想是:把待排序的记录按其关键码值的⼤⼩逐个插⼊到⼀个已经排好序的有序序列中,直到所有的记录插⼊完为⽌,得到⼀个新的有序序列 。

1.1 直接插入排序

当插入第 i ( i > = 1 ) i(i>=1) i(i>=1) 个元素时,前面的 array [ 0 ] , array [ 1 ] , … , array [ i − 1 ] \text{array}[0],\text{array}[1],\dots,\text{array}[i-1] array[0],array[1],,array[i1] 已经排好序,此时用 array [ i ] \text{array}[i] array[i] 的排序码与 array [ i − 1 ] , array [ i − 2 ] , … \text{array}[i-1],\text{array}[i-2],\dots array[i1],array[i2], 的排序码顺序进行比较,找到插入位置即将 array [ i ] \text{array}[i] array[i] 插入,原来位置上的元素顺序后移。
在这里插入图片描述


实际中我们玩扑克牌时,就⽤了插⼊排序的思想,假设我们有一个数组:{ 5, 2, 4, 6, 1 }
排序的过程就是把数组分为 “有序区间”“无序区间”

  • 初始状态[5] (有序) | 2, 4, 6, 1 (无序)
  • 第 1 趟:拿出 2,插入到 [5] 中。2 < 5,所以 5 后移,2 插在 5 前面。
    • 结果:[2, 5] | 4, 6, 1
  • 第 2 趟:拿出 4,插入到 [2, 5] 中。4 < 5 (5后移),4 > 2 (停在2后面)。
    • 结果:[2, 4, 5] | 6, 1
  • 第 3 趟:拿出 6,插入到 [2, 4, 5] 中。6 > 5,直接放最后。
    • 结果:[2, 4, 5, 6] | 1
  • 第 4 趟:拿出 1,插入到 [2, 4, 5, 6] 中。比 1 大的都往后挪,1 插在最前面。
    • 结果:[1, 2, 4, 5, 6] (完成)

代码与分析:

//n是元素个数
void InsertSort(int* a, int n)
{
    // 外层循环:控制“无序区间”的第一个元素,也就是我们要拿去插入的那张“新牌”
    // i 从 0 开始,到 n-2 结束(因为 i+1 是最后一个元素)
        for (int i = 0; i < n-1; i++)
    {
        int end = i;         // end 指向“有序区间”的最后一个元素 也就第一个元素(此时是有序的!)
        int tmp = a[end + 1]; // tmp 保存我们要插入的那个数(新牌)

        // 内层循环:在“有序区间”里从后往前找位置
        while (end >= 0)
        {
            // 如果有序区间的数比 tmp 大,说明 tmp 应该插在这个数的前面
            if (a[end] > tmp)
            {
                a[end + 1] = a[end]; // 把这个大的数往后挪一个位置
                end--;               // 继续向前比较
            }
            else
            {
                break; // 如果 a[end] <= tmp,说明找到了合适的位置,停止比较
            }
        }
        
        // 循环结束时,end + 1 就是 tmp 应该插入的位置
        // 注意:这里既处理了“插在中间”,也处理了“插在开头”和“插在末尾”
        a[end + 1] = tmp;
    }
}

为什么要用 tmp 保存 a[end + 1]

因为当我们执行 a[end + 1] = a[end] 时,a[end + 1] 原来的值会被覆盖。如果不先存起来,这个数就丢了。tmp 就像是把这张牌先拿在手里,腾出空位来让别的牌移动。


while (end >= 0) 里的 end-- 在做什么?

  • 如果前面的牌比手里的牌大,前面的牌就往后挪一位(填后面的坑),同时自己原来的位置变成了新坑(end--)。
  • 这个过程一直持续,直到遇到一个比手里的牌小的数,或者已经到了数组最开头。

为什么最后是 a[end + 1] = tmp

while 循环结束时,有两种情况:

  • 情况 A(找到了位置):遇到了 a[end] <= tmp,执行了 break。此时 end 指向的是比 tmp 小的那个数,所以 tmp 应该插在 end 的后面,即 end + 1
  • 情况 B(插在最前)end 减到了 -1,循环条件不满足退出。说明前面所有数都比 tmp 大,tmp 应该插在索引 0 的位置。此时 end-1end + 1 正好是 0

复杂度与特性

  • 时间复杂度
    • 最好情况:数组已经是正序(如 1, 2, 3, 4, 5)。内层 while 循环每次只比较一次就 break。复杂度为 O(N)
    • 最坏情况:数组是逆序(如 5, 4, 3, 2, 1)。每次插入都要把前面所有数挪一遍。复杂度为 O(N²)
    • 平均情况O(N²)
  • 空间复杂度O(1)。只需要 tmpend 等几个变量,不需要额外开辟数组空间。
  • 稳定性稳定。因为我们在 a[end] > tmp 时才挪动,如果 a[end] == tmp,我们会 break,把 tmp 插在相等的元素后面,保持了相对顺序。

1.2 希尔排序

希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数(通常是 g a p = n / 3 + 1 \boldsymbol{gap = n/3+1} gap=n/3+1),把待排序文件所有记录分成各组,所有的距离相等的记录分在同一组内,并对每一组内的记录进行排序,然后 g a p = g a p / 3 + 1 \boldsymbol{gap=gap/3+1} gap=gap/3+1得到下一个整数,再将数组分成各组,进行插入排序,当 g a p = 1 \boldsymbol{gap=1} gap=1时,就相当于直接插入排序

它是在直接插入排序算法的基础上进行改进而来的,综合来说它的效率肯定是要高于直接插入排序算法的。

gap>1的时候相当于预排序 此时存在大量数据在前,小的数据在后,但是每组的比较次数就很小!
在这里插入图片描述
gap==1的时候,此时就相当于直接插入排序,此时虽然数量大,但是比较的次数少,很多小的数据已经在前面分组排序的过程中已经放到前面去了,大的数据已经放到后面了!

代码和具体案例:

void ShellSort(int* a, int n)
{
    // 1. 初始化 gap (步长/间隔)
    // gap 代表分组中两个元素之间的距离
    int gap = n;

    // 2. 外层循环:控制 gap 的缩小
    // 当 gap 缩小到 1 时,就是最后一轮直接插入排序
    while (gap > 1)
    {
        // 3. 更新 gap
        // 比如 n=10, gap 会变成 4 -> 2 -> 1
        gap = gap / 3 + 1;

        // 4. 中层循环:遍历每一个分组
        // 我们要对每一组进行“预排序”
        // i 代表每一组的第一个元素的下标
        //  i < n - gap 是为了保证 i + gap < n(避免数组越界)。
        for (int i = 0; i < n - gap; i++)
        {
            // ---------------------------------------------------------
            // 下面是【直接插入排序】的逻辑,只是步长变成了 gap
            // ---------------------------------------------------------
            
            int end = i;           // end 指向当前组已排序区间的最后一个元素
            int tmp = a[end + gap]; // tmp 保存当前组要插入的那个元素 (未排序区的第一个)

            // 5. 内层循环:在当前组内进行插入排序
            // 只要 end 还没越界,就继续往前找位置
            while (end >= 0)
            {
                // 如果前面的元素比要插入的元素大,说明位置不对,需要后移
                if (a[end] > tmp)
                {
                    // 把大的元素向后移动 gap 个位置 (腾出空位)
                    a[end + gap] = a[end];
                    
                    // 继续向前找 (注意:这里是减去 gap,而不是减 1)
                    end -= gap;
                }
                else
                {
                    // 如果前面的元素比 tmp 小(或相等),说明找到了合适的插入位置
                    break;
                }
            }
            
            // 6. 将 tmp 插入到正确的位置
            // 此时 end 指向的是比 tmp 小的那个元素,所以插在 end + gap 处
            a[end + gap] = tmp;
        }
    }
}

假设我们要排序的数组是:
{ 9, 8, 7, 6, 5, 4, 3, 2, 1, 0 } (这是一个最坏情况,完全逆序)
数组长度 n = 10

代码执行:

  1. gap = 10
  2. 进入 while 循环:gap = 10 / 3 + 1 = 4
    • 注意:这里用的是 gap/3 + 1 的算法,这是为了最后能收敛到 1。
  3. 进入 for 循环:i0n - gap - 1 (即 05)。

发生了什么? 此时 gap = 4。代码把数组分成了 4 个“虚拟组”:

  • 第1组 (下标 0, 4, 8): { 9, 5, 1 }
  • 第2组 (下标 1, 5, 9): { 8, 4, 0 }
  • 第3组 (下标 2, 6): { 7, 3 }
  • 第4组 (下标 3, 7): { 6, 2 }

代码里的 while 循环在做什么? 它实际上是在对每一组单独做插入排序

  • 比如第1组 { 9, 5, 1 },经过排序会变成 { 1, 5, 9 }
  • 比如第2组 { 8, 4, 0 },经过排序会变成 { 0, 4, 8 }

第一阶段结束后,数组变成了: { 1, 0, 3, 2, 5, 4, 7, 6, 9, 8 }

观察: 虽然数组还没完全有序,但小的数(0, 1, 2)已经跑到了前面,大的数(9, 8)跑到了后面。整体已经接近有序了!


第二阶段:缩小跨度 (gap 变小)

代码执行:

  1. 回到 whilegap = 4 / 3 + 1 = 2
  2. 进入 for 循环。

发生了什么? 此时 gap = 2。数组被分成 2 组:

  • 第1组 (下标 0, 2, 4, 6, 8): { 1, 3, 5, 7, 9 } -> 排序后还是它自己(已经有序)。
  • 第2组 (下标 1, 3, 5, 7, 9): { 0, 2, 4, 6, 8 } -> 排序后还是它自己。

第二阶段结束后,数组变成了: { 1, 0, 3, 2, 5, 4, 7, 6, 9, 8 } (在这个例子里没变,但在其他乱序数组中,这一步会进一步修正顺序)。


第三阶段:最后的“临门一脚” (gap = 1)

代码执行:

  1. 回到 whilegap = 2 / 3 + 1 = 1
  2. 进入 for 循环。

发生了什么? 此时 gap = 1

  • 这意味着所有元素都在同一个组里。
  • 代码中的 while (end >= 0)end -= gap 就变成了标准的插入排序逻辑。
int end = i;
int tmp = a[end + gap]; // 1. 取出要插入的数
while (end >= 0)
{
    if (a[end] > tmp)
    {
        a[end + gap] = a[end]; // 2. 如果前面的数大,就往后挪 gap 个位置
        end -= gap;            // 3. 继续向前找 (步长为 gap)
    }
    else
    {
        break; // 4. 找到位置了
    }
}
a[end + gap] = tmp; // 5. 填入
  • end -= gap:这是希尔排序和插入排序唯一的区别。插入排序是 end--(每次挪1个),希尔排序是 end -= gap(每次跨越 gap 个)。
  • a[end + gap] = a[end]:这是在给 tmp 腾位置。

时间复杂度

外层循环:
外层循环的时间复杂度可以直接给出为: O ( log ⁡ 2 n ) O(\log_2 n) O(log2n) 或者 O ( log ⁡ 3 n ) O(\log_3 n) O(log3n),即 O ( log ⁡ n ) O(\log n) O(logn)

内层循环:
请添加图片描述
假设一共有n个数据,合计gap组,则每组为n/gap个;在每组中,插入移动的次数最坏的情况下为
1 + 2 + 3 + ⋯ + ( n g a p − 1 ) 1+2+3+\dots+(\frac{n}{gap}-1) 1+2+3++(gapn1),一共是gap组,因此:
总计最坏情况下移动总数为: g a p ∗ [ 1 + 2 + 3 + ⋯ + ( n g a p − 1 ) ] gap * [1 + 2 + 3 + \dots + (\frac{n}{gap} - 1)] gap[1+2+3++(gapn1)]
gap取值有(以除3为例): n / 3 ,   n / 9 ,   n / 27 , … ,   2 ,   1 n/3,\ n/9,\ n/27,\dots,\ 2,\ 1 n/3, n/9, n/27,, 2, 1

  • 当gap为 n / 3 n/3 n/3时,移动总数为: n 3 ∗ ( 1 + 2 ) = n \frac{n}{3} * (1 + 2) = n 3n(1+2)=n
  • 当gap为 n / 9 n/9 n/9时,移动总数为: n 9 ∗ ( 1 + 2 + 3 + ⋯ + 8 ) = n 9 ∗ 8 ( 1 + 8 ) 2 = 4 n \frac{n}{9} * (1 + 2 + 3 + \dots + 8) = \frac{n}{9} * \frac{8(1 + 8)}{2} = 4n 9n(1+2+3++8)=9n28(1+8)=4n
  • 最后一趟,gap=1即直接插入排序,内层循环排序消耗为n

通过以上的分析,可以画出这样的曲线图:
请添加图片描述
因此,希尔排序在最初和最后的排序的次数都为n,即前⼀阶段排序次数是逐渐上升的状态,当到达
某⼀顶点时,排序次数逐渐下降⾄n,⽽该顶点的计算暂时⽆法给出具体的计算过程。

希尔排序时间复杂度不好计算,因为 gap 的取值很多,导致很难去计算,因此很多书中给出的希尔排序的时间复杂度都不固定。《数据结构(C语⾔版)》--- 严蔚敏书中给出的时间复杂度为:
在这里插入图片描述

2.选择排序

选择排序的基本思想:
每⼀次从待排序的数据元素中选出最⼩(或最⼤)的⼀个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。

2.1 直接选择排序

在这里插入图片描述

代码与算法解析

步骤作用说明
1每一轮从当前未排序区间中,找到最大(或最小)的元素
2将找到的最值元素,交换到未排序区间的末尾(或开头),这个位置就成了已排序区间的一部分
3缩小未排序区间,重复上述过程,直到只剩一个元素(天然有序)

示例 :以数组 [5, 3, 4, 1, 2] 为例:

  1. 第1轮:在 [0,4] 区间找最小值 1[1, 3, 4, 5, 2]
  2. 第2轮:在 [1,4] 区间找最小值 2[1, 2, 4, 5, 3]
  3. 第3轮:在 [2,4] 区间找最小值 3[1, 2, 3, 5, 4]
  4. 第4轮:在 [3,4] 区间找最小值 4[1, 2, 3, 4, 5]

我们发现此时最后一个数字天然就有序!


代码思路:

  1. 在元素集合 array[i] -- array[n-1] 中选择关键码最大(小)的数据元素
  2. 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
  3. 在剩余的 array[i] -- array[n-2]array[i+1] -- array[n-1])集合中,重复上述步骤,直到集合剩余 1 个元素

// 选择排序(升序)
// arr: 待排序数组,n: 数组元素个数
void selectSort(int arr[], int n) 
{
    // 外层循环:控制未排序区间的起点,只需循环 n-1 轮(最后1个元素天然有序)
    for (int i = 0; i < n - 1; i++)
     {
        // 步骤1:在 [i, n-1] 区间中找到最小值的索引
        int min_idx = i; // 初始假设当前起点是最小值位置
        for (int j = i + 1; j < n; j++)
         {
            if (arr[j] < arr[min_idx]) 
            {
                min_idx = j; // 更新最小值索引
            }
        }

        // 步骤2:如果最小值不是当前区间起点,则交换(避免无意义的自身交换)
        if (min_idx != i) 
        {
            swap(&arr[i], &arr[min_idx]);
        }

        // 步骤3:下一轮未排序区间变为 [i+1, n-1],重复上述过程
    }
}

双路选择排序

其实在原本基础上 我们可以再对直接选择排序优化成双路选择排序
它的核心思路是:每一轮循环同时找出当前范围内的最小值和最大值,分别放到数组的开头和结尾。
相比传统的只选最小值的单路选择排序,它的效率大约提升了一倍!

//选择排序(升序)
void SelectSort(int* a, int n)
{
    // 定义当前未排序区间的左右边界
    int begin = 0, end = n - 1;

    // 当左边界小于右边界时,说明区间内还有元素需要排序
    while (begin < end)
    {
        // 初始化最小值和最大值的下标
        // 假设当前区间的第一个元素既是最小也是最大
        int mini = begin, maxi = begin;

        // 遍历当前区间 [begin, end],寻找真正的最小值和最大值下标
        for (int i = begin; i <= end; i++)
        {
            // 如果当前元素比记录的最大值还大,更新最大值下标
            if (a[i] > a[maxi])
            {
                maxi = i;
            }
            // 如果当前元素比记录的最小值还小,更新最小值下标
            if (a[i] < a[mini])
            {
                mini = i;
            }
        }

        // ---------------------------------------------------------
        // 【关键修正步骤】防止“最大值就在begin位置”的特殊情况
        // ---------------------------------------------------------
        // 如果最大值原本就在 begin 位置,而刚才我们把 a[mini] 和 a[begin] 交换了,
        // 那么最大值的位置其实已经被换走了(换到了 mini 的位置)。
        // 所以这里必须把 maxi 修正为 mini,否则后面会把错误的数换到后面去。
        if (begin == maxi)
        {
            maxi = mini;
        }

        // 1. 把找到的最小值交换到区间的头部 (begin)
        swap(&a[mini], &a[begin]);

        // 2. 把找到的最大值交换到区间的尾部 (end)
        swap(&a[maxi], &a[end]);

        // 缩小范围:头部往后移,尾部往前移
        ++begin;
        --end;
    }
}

核心难点解析:为什么要修正 maxi

这是这段代码中最容易出错、也是最精彩的地方。

场景假设: 数组是 { 9, 1, 2, 3, 4 }

  • begin = 0, end = 4
  • 遍历后找到:最小值是 1 (下标 mini=1),最大值是 9 (下标 maxi=0)。

如果不修正:

  1. 执行 swap(&a[mini], &a[begin]),即交换 a[1]a[0]
    • 数组变成了 { 1, 9, 2, 3, 4 }
    • 注意: 此时最大值 9 已经被换到了下标 1 的位置。
  2. 执行 swap(&a[maxi], &a[end]),即交换 a[0]a[4]
    • 因为 maxi 还是 0,所以把 1 换到了最后。
    • 数组变成 { 4, 9, 2, 3, 1 }
    • 结果错误! 最大值 9 没去它该去的地方。

修正后:

  • 检测到 begin == maxi (0 == 0),说明最大值就在开头,会被第一步交换影响。
  • 执行 maxi = mini,即 maxi 变为 1
  • 第二步交换 a[1]a[4],正确地把 9 换到了最后。

时间复杂度

  • 时间复杂度:无论数据是否有序,都需要遍历每一轮找最值,所以最好/最坏/平均复杂度都是 O ( n 2 ) O(n^2) O(n2)
  • 空间复杂度 O ( 1 ) O(1) O(1),原地排序,仅需常数额外空间
  • 稳定性:不稳定排序(交换操作可能破坏相同元素的相对顺序[如找最大值插入到最后面的时候 或者 全部只剩下相同元素的时候都会如此 可以自己举几个例子试试!])

2.2 堆排序

这个在之前的堆章节 其实我就写过了 有需要的 可以【点击转跳】

3.交换排序

交换排序基本思想:
所谓交换,就是根据序列中两个记录键值的⽐较结果来对换这两个记录在序列中的位置
交换排序的特点是:将键值较⼤的记录向序列的尾部移动,键值较⼩的记录向序列的前部移动!

3.1 冒泡排序

我不想解析 说实话 最基础的排序!之所以叫做冒泡排序,因为每⼀个元素都可以像⼩⽓泡⼀样,根据⾃⾝⼤⼩⼀点⼀点向数组的⼀侧移动。
在这里插入图片描述

// 冒泡排序函数:对数组 a 进行升序排序,n 为数组长度
void BubbleSort(int* a, int n)
{
    // exchange 标志位:用于优化,记录本轮是否发生过交换
    // 0 表示未交换(已有序),1 表示已交换(可能无序)
    int exchange = 0;

    // 外层循环:控制排序的轮数,每一轮确定一个最大值放到末尾
    for (int i = 0; i < n; i++)
    {
        // 重置标志位,假设本轮数组已经有序
        exchange = 0;

        // 内层循环:进行相邻元素的比较和交换
        // j < n-i-1 是因为每一轮结束后,最后 i 个元素已经是最大的且有序,无需再比较
        for (int j = 0; j < n - i - 1; j++)
        {
            // 如果前一个元素大于后一个元素,则交换它们(升序)
            if (a[j] > a[j + 1])
            {
                // 发生了交换,说明数组可能还未完全有序
                exchange = 1;
                // 交换相邻两个元素
                swap(&a[j], &a[j + 1]);
            }
        }

        // 优化判断:如果一轮比较下来,exchange 仍为 0
        // 说明没有任何元素需要交换,数组已经完全有序,可以直接提前结束排序
        if (exchange == 0)
        {
            break;
        }
    }
}

时间复杂度: O ( N 2 ) O(N^2) O(N2)
稳定性:稳定!

3.2 快速排序

快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。(这是一种不稳定的排序!)

我们可以用递归 来实现 快速排序的基本框架!

//快速排序
void QuickSort(int* a, int left, int right)
{
if (left >= right) {
return;
}
//_QuickSort⽤于按照基准值将区间[left,right)中的元素进⾏划分
int meet = _QuickSort(a, left, right);
QuickSort(a, left, meet - 1);
QuickSort(a, meet + 1, right);
}

//⚠️:不断递归 因为是二叉树的结构 所以递归的时间复杂度是 logn 级别

将区间中的元素进⾏划分的 _QuickSort ⽅法主要有以下⼏种实现⽅式:

3.2.1 hoare版本

算法思路
1)创建左右指针,确定基准值

  • right:从右往前走,找比基准值要小的数据
  • left:从左往后走,找比基准值要大的数据

2)从右向左找出比基准值小的数据,从左向右找出比基准值大的数据,左右指针数据交换,进入下次循环

  • 前题:left<=right,找到之后 让left和right所指数据交换!
  • left>right,keyiright数据交换!
// 快速排序的核心分区函数(Hoare 版本变体)
// 功能:选取基准值,将数组分为两部分,左边比基准小,右边比基准大
// 返回值:基准值最终所在的索引位置
int _QuickSort(int* a, int left, int right)
{
    // 保存初始的左右边界,用于后续递归
    int begin = left;
    int end = right;
    
    // 选取最左边的元素作为基准值(Key)
    int keyi = left;
    
    // 【关键步骤】先让 left 指针向右移动一位
    // 因为 keyi 在 left 位置,如果不先移动,第一次比较就是自己和自己比
    ++left;
    
    // 主循环:当左右指针没有相遇时继续
    while (left <= right)
    {
        // 1. 右边找小:从右向左找第一个小于等于基准值的元素
        while (left <= right && a[right] > a[keyi])
        {
            --right;
        }
        
        // 2. 左边找大:从左向右找第一个大于等于基准值的元素
                while (left <= right && a[left] < a[keyi])
        {
            ++left;
        }
        
        // 3. 如果指针没有交错,交换找到的两个元素
        if (left <= right)
        {
            // 交换后,左右指针各向中间移动一步
            swap(&a[left++], &a[right--]);
        }
    }
    
    // 4. 将基准值归位
    // 此时 right 指针指向的是“小于基准值”的区域的最后一个元素
    // 所以将基准值与 right 位置的元素交换
    swap(&a[keyi], &a[right]);
    
    // 返回基准值的最终位置,用于后续划分左右区间
    return right;
}

问题1:为什么跳出循环后right位置的值一定不大于key?

当 left > right 时,即right走到left的左侧,而left扫描过的数据均不大于key,因此right此时指向的数据一定不大于key!
请添加图片描述
这个自己画一下就很明显啦 这里就不演示了!

问题2:为什么left 和 right指定的数据和key值相等时也要交换?

相等的值参与交换确实有一些额外消耗。实际还有各种复杂的场景,假设数组中的数据大量重复时,无法进行有效的分割排序会死循环。
请添加图片描述
在标准的 Hoare 分区法中,我们设置的条件是:

  • right 指针找小于等于 key 的值
  • left 指针找大于等于 key 的值
    即使元素和 key 相等,也会被停下来并参与交换。

这样做的核心目的是:

  • 保证分区有效:如果遇到相等值不移动,在大量重复元素的数组里,分区会变得极不均衡(比如所有元素都相等时,分区每次只能把数组分成 1n-1,时间复杂度直接退化到 O(n²))。
    在这里插入图片描述

时间复杂度:正常情况下 是 O ( l o g N ∗ N ) O(logN*N) O(logNN)
但是在极端情况下 比如 数组有序的时候
如:[1,2,3,4] 时间复杂度变成 O ( N 2 ) O(N^2) O(N2)!
在这里插入图片描述

3.2.2 挖坑法

思路: 创建左右指针。⾸先从右向左找出⽐基准⼩的数据,找到后⽴即放⼊左边坑中,当前位置变为新的"坑",然后从左向右找出⽐基准⼤的数据,找到后⽴即放⼊右边坑中,当前位置变为新的"坑",结束循环后将最开始存储的分界值放⼊当前的"坑"中,返回当前"坑"下标(即分界值下标)

// 快速排序核心分区函数 - 挖坑法
// 功能:选取基准值,将数组分为两部分,左边比基准小,右边比基准大
// 返回值:基准值最终所在的索引位置
int _QuickSort(int* a, int left, int right)
{
    // 1. 初始化
    // 选取最左边的值作为基准值(Key)
    int key = a[left];
    
    // hole 代表“坑”的位置
    // 因为 a[left] 的值已经被取出来赋给 key 了,
    // 所以 a[left] 这个位置就是一个空的“坑”,等待被填充
    int hole = left;

    // 2. 主循环:左右指针向中间逼近
    while (left < right)
    {
        // --- 第一步:右边找小,填左坑 ---
        
        // 从右向左找第一个小于基准值 key 的数
        // 注意:这里必须包含等于号 (>=),否则遇到相等元素会死循环【6,6,6,6】自己可以试试
        while (left < right && a[right] >= key)
        {
            --right;
        }
        
        // 找到了比 key 小的数 a[right]
        // 把它填到左边的坑 hole 里
        a[hole] = a[right];
        
        // a[right] 被移走了,所以 right 位置变成了新的坑
        hole = right;

        // --- 第二步:左边找大,填右坑 ---
        
        // 从左向右找第一个大于基准值 key 的数
        // 注意:这里必须包含等于号 (<=)
        while (left < right && a[left] <= key)
        {
            ++left;
        }
        
        // 找到了比 key 大的数 a[left]
        // 把它填到右边的坑 hole 里
        a[hole] = a[left];
        
        // a[left] 被移走了,所以 left 位置变成了新的坑
        hole = left;
    }

    // 3. 基准值归位
    // 当 left 和 right 相遇时,循环结束
    // 此时 hole 就是相遇点,也是基准值应该在的最终位置
    // 把保存的基准值 key 填进这个最后的坑里
    a[hole] = key;

    // 返回基准值的索引,用于后续划分左右区间
    return hole;
}

我们用数组 [6, 1, 2, 7, 9,3,4,5,10,8] 来演示挖坑法的第一个过程。

静态模拟:在这里插入图片描述
变成动图就是:
在这里插入图片描述

步骤动作数组状态 (坑用 _ 表示)指针与坑位变化
初始挖走 a[0],形成初始坑[_, 1, 2, 7, 9, 3, 4, 5, 10, 8]hole=0, left=0, right=9
1右找小: right 找到 5 (a[7]),填入坑 0[5, 1, 2, 7, 9, 3, 4, _, 10, 8]hole=7, left=0, right=7
2左找大: left 找到 7 (a[3]),填入坑 7[5, 1, 2, *7, 9, 3, 4, 7, 10, 8]
(注: 此时 a[3] 还是 7,逻辑上视为坑)
hole=3, left=3, right=7
3右找小: right 找到 4 (a[6]),填入坑 3[5, 1, 2, 4, 9, 3, _, 7, 10, 8]hole=6, left=3, right=6
4左找大: left 找到 9 (a[4]),填入坑 6[5, 1, 2, 4, *9, 3, 9, 7, 10, 8]
(注: 此时 a[4] 还是 9,逻辑上视为坑)
hole=4, left=4, right=6
5右找小: right 找到 3 (a[5]),填入坑 4[5, 1, 2, 4, 3, _, 9, 7, 10, 8]hole=5, left=4, right=5
6左找大: left 向右移动,与 right5 相遇[5, 1, 2, 4, 3, _, 9, 7, 10, 8]hole=5, left=5, right=5
结束基准值归位: 将 key=6 填入最后的坑 5[5, 1, 2, 4, 3, 6, 9, 7, 10, 8]分区完成:hole左边的值小于等于keyhole右边的值大于等于key

和上一段代码一样,这段代码也是固定选取最左边的元素作为基准。
因此,如果数组已经有序,这段代码的时间复杂度依然会退化到 O ( N 2 ) O(N^2) O(N2)

3.2.3 lomuto 前后指针法

创建前后指针,从左往右找⽐基准值⼩的进⾏交换,使得⼩的都排在基准值的左边。
在这里插入图片描述

创建两个变量prev、curcur从左往右照比基准值要小的数据,prevcur交换!

  1. cur 找到了比基准值小的数据,++prevprevcur交换,cur++
  2. cur未找到比基准值小的数据,cur++
// 快速排序核心分区函数 - 前后指针法
// 功能:选取基准值,将数组分为两部分,左边比基准小,右边比基准大
// 返回值:基准值最终所在的索引位置
int _QuickSort(int* a, int left, int right)
{
    // prev (前指针):初始指向最左边
    // 它的含义是:[left, prev] 区间内的元素都是小于基准值的
    int prev = left;
    
    // cur (后指针):初始指向 prev 的下一位
    // 它负责向后遍历,寻找比基准值小的元素
    int cur = left + 1;
    
    // 选取最左边的元素作为基准值
    int key = left;

    // 主循环:cur 一直向右遍历直到数组末尾
    while (cur <= right)
    {
        // 核心逻辑:
        // 1. a[cur] < a[key]:如果当前元素比基准值小
        // 2. ++prev != cur:先让 prev 自增,如果 prev 和 cur 不重合
        //    (如果重合说明 prev 紧挨着 cur,中间没有比基准值大的数,无需交换)
        if (a[cur] < a[key] && ++prev != cur)
        {
            // 交换:将比基准值小的数 a[cur] 换到 prev 位置
            // 效果:把小的数扔到了前面,把大的数挤到了后面
            swap(&a[cur], &a[prev]);
        }
        
        // cur 继续向后扫描
        ++cur;
    }

    // 循环结束后,prev 指向的是“小于基准值区域”的最后一个位置
    // 将基准值与 prev 位置的元素交换,让基准值归位
    swap(&a[key], &a[prev]);

    // 返回基准值的最终位置
    return prev;
}

图解:
在这里插入图片描述

步骤动作描述数组状态指针与逻辑
初始设定基准值与指针[6, 1, 2, 7, 9, 3, 4]key=6, prev=0, cur=1
1cur 指向 11 < 6
++prev 变为 1,prev == cur,不交换。
[6, 1, 2, 7, 9, 3, 4]prev=1, cur 变为 2
2cur 指向 22 < 6
++prev 变为 2,prev == cur,不交换。
[6, 1, 2, 7, 9, 3, 4]prev=2, cur 变为 3
3cur 指向 77 > 6
不满足条件,不操作。
[6, 1, 2, 7, 9, 3, 4]prev=2 (不动), cur 变为 4
4cur 指向 99 > 6
不满足条件,不操作。
[6, 1, 2, 7, 9, 3, 4]prev=2 (不动), cur 变为 5
5cur 指向 33 < 6
++prev 变为 3 (指向7)。
prev != cur,交换 a[3]a[5]
[6, 1, 2, 3, 9, 7, 4]prev=3, cur 变为 6
6cur 指向 44 < 6
++prev 变为 4 (指向9)。
prev != cur,交换 a[4]a[6]
[6, 1, 2, 3, 4, 7, 9]prev=4, cur 变为 7
7cur 越界,循环结束。
交换基准值 a[0]a[prev] (即 a[4])。
[4, 1, 2, 3, 6, 7, 9]分区完成

前后指针法作为快速排序的一种实现方式,其时间复杂度与快速排序的整体性能表现一致。
本质就是双指针的一种,属于算法中的 数组分块思想 是一个非常典型的算法 其目的就是最后让[0,left)的值小于key让[left,right]的值大于等于key
可以参考我写的练习题(题目名:移动0)题解 就是专门对这个算法进行过练习 【点击传送】

⏱️ 时间复杂度

  • 平均时间复杂度:O(n log n)
    在数据随机分布的理想情况下,每次分区都能将数组大致平分为两半,递归深度为 log n,每一层分区操作需要遍历 n 个元素,因此总时间复杂度为 O(n log n)。
  • 最坏时间复杂度:O(n²)
    当每次分区都极不平衡时(例如,每次选择的基准值都是当前区间的最大值或最小值),递归会退化成一个链,深度达到 n 层。这种情况通常发生在数组本身已经有序(正序或逆序) 时。

3.2.4 ⾮递归版本

利用栈 实现非递归版本

它的核心思想是:把原本由系统递归调用处理的“待排序区间”,改为手动存入栈中,然后循环处理。

// 快速排序非递归版本
void QuickSortNonR(int* a, int left, int right)
{
    // 1. 初始化栈
    ST st;
    STInit(&st);
    
    // 将初始的排序区间 [left, right] 压入栈中
    // 注意:先压 right,再压 left,这样出栈时才是先取到 left
    STPush(&st, right);
    STPush(&st, left);
    
    // 2. 主循环:只要栈不为空,说明还有区间需要排序
    while (!STEmpty(&st))
    {
        // --- 取出一个待排序区间 ---
        // 先弹出的作为 begin (左边界)
        int begin = STTop(&st);
        STPop(&st);
        // 后弹出的作为 end (右边界)
        int end = STTop(&st);
        STPop(&st);
        
        // --- 单趟排序 (分区操作) ---
        // 这里使用的是“前后指针法”进行分区
        int keyi = begin;      // 基准值索引,选最左边
        int prev = begin;      // 前指针
        int cur = begin + 1;   // 后指针
        
        while (cur <= end)
        {
            // 如果当前元素小于基准值,且 prev 和 cur 不重合
            if (a[cur] < a[keyi] && ++prev != cur)
                Swap(&a[prev], &a[cur]);
            ++cur;
        }
        // 将基准值归位
        Swap(&a[keyi], &a[prev]);
        // keyi 更新为基准值归位后的最终位置
        keyi = prev;
        
        // 此时数组被分为三部分:
        // [begin, keyi-1] | keyi | [keyi+1, end]
        // 左边都比 key 小,右边都比 key 大
        
        // --- 将左右子区间压入栈 ---
        // 注意:为了保持和递归类似的顺序(先处理左边),
        // 入栈顺序应该是“先右后左”,这样出栈时就是“先左后右”。
        
        // 如果右子区间存在(区间长度 > 1),则入栈
        if (keyi + 1 < end)
        {
            STPush(&st, end);       // 右区间右边界
            STPush(&st, keyi + 1);  // 右区间左边界
        }
        
        // 如果左子区间存在(区间长度 > 1),则入栈
        if (begin < keyi - 1)
        {
            STPush(&st, keyi - 1);  // 左区间右边界
            STPush(&st, begin);     // 左区间左边界
        }
    }
    
    // 3. 销毁栈
    STDestroy(&st);
}
  1. 栈的作用
    递归的本质是系统栈在保存“现场”(即下一次要处理的区间)。非递归版本就是手动创建一个栈 ST,把 [left, right] 这样的区间边界成对地存进去。
  2. 入栈顺序
    代码中先压入 right 再压入 left。因为栈是后进先出 (LIFO) 的,所以弹出时先得到 left,再得到 right,这样才构成一个合法的区间 [left, right]
  3. 子区间入栈顺序
    在分区完成后,我们需要处理左右两个子区间。代码选择先压右区间,再压左区间。这样在下一次循环中,会优先处理左区间。这虽然不是强制的(先处理右区间也能排好序),但通常符合人们从左到右的思维习惯。
  4. 边界条件
    if (keyi + 1 < end)if (begin < keyi - 1) 确保了只有当子区间内至少有 2 个元素时才入栈。如果区间只有一个元素或为空,它本身就是有序的,不需要再处理。

3.3 快排加餐之 三路划分(随机选key)!

三路划分思想:

  1. key默认取left位置的值。
  2. left指向区间最左边,right指向区间最右边,cur指向left+1位置
  3. cur遇到比key小的值后跟left位置交换,换到左边,left++cur++
  4. cur遇到比key大的值后跟right位置交换,换到右边,right--,cur++
  5. cur遇到跟key相等的值后,cur++
  6. 直到cur>right结束!

ps:发现没有其实这个就是lomuto的升级版!本质还是数组分块思想的延伸,只不过是把分成两块变成 分成三块!
[0,left)的值小于key,让[left,right]的值等于key,让(right,结束]的值大于key(一句话总结就是:left左边的值小于key,right右边的值大于key!

⚠️:而三路划分的目的就是 让相同的值在一块,这样就可以大大减少时间复杂度了!!


而对于如何优化key的选值 防止出现有序数组导致的 O ( N 2 ) O(N^2) O(N2)惨状!

这里给出的解答是可以使用随机数种子!当然这里的随机数是个虚随机数,是跟据一定公式生成的 底层式子为:
请添加图片描述
综上:最后的三路划分代码是:

//srand((unsigned int)time(NULL));定义在main中
//写在递归里,每次递归都重置种子,1 秒内种子一样 → 随机数失效
void QuickSort(int* a, int left, int right)
{
  int end=right; 
  int begin=left;
  if(left>=right)
  {
    return;
  }
//随机选key
  int randi=left+(rand()%(right-left+1));
  swap(&a[left],&a[randi]);
  int key = a[left];
  int cur=left+1;
   while(cur<=right)
   {
   if(a[cur]<key)
   {
    swap(&a[cur],&a[left]);
    ++left;
    ++cur;
   }
   else if(a[cur]>key)
   {
   swap(&a[cur],&a[right]);
   --right;
   }
   else
   {
   ++cur;
   }
   }
   QuickSort( a,  begin,  left-1);
   QuickSort(a,  right+1, end);
}

4. 归并排序

归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序核心步骤:
请添加图片描述
⚠️:这涉及到递归的本质和分治 可以参考我写的算法基础章节 里面的逆序数就是用这种思想来写的!(请期待)

/**
 * 归并排序
 * a:原数组
 * left:当前排序区间的左边界
 * right:当前排序区间的右边界
 * tmp:用于辅助合并的临时数组(避免在递归中重复开辟空间)
 */
void _MergeSort(int* a, int left, int right, int* tmp)
{
    // 1. 递归终止条件
    // 如果区间内只有一个元素或为空,说明已经有序,无需再分解
    if (left >= right)
    {
        return;
    }

    // 2. 分解:计算中点,将区间一分为二
    int mid = (right + left) / 2;
    // 区间划分:[left, mid] 和 [mid+1, right]

    // 3. 递归排序左半部分
    _MergeSort(a, left, mid, tmp);
    // 4. 递归排序右半部分
    _MergeSort(a, mid + 1, right, tmp);

    // 5. 合并:将两个有序的子区间合并为一个有序区间
    // 此时 a[left...mid] 和 a[mid+1...right] 已经是有序的

    int begin1 = left, end1 = mid;       // 左半部分的起止指针
    int begin2 = mid + 1, end2 = right;  // 右半部分的起止指针
    int index = begin1;                  // 临时数组的填充位置指针

    // 核心合并逻辑:双指针比较
    while (begin1 <= end1 && begin2 <= end2)
    {
        // 比较两个子数组当前的元素,将较小的放入临时数组
        if (a[begin1] < a[begin2])
        {
            tmp[index++] = a[begin1++];
        }
        else
        {
            tmp[index++] = a[begin2++];
        }
    }

    // 处理剩余元素
    // 如果左半部分还有剩余(右半部分先排完了),直接拷贝
    while (begin1 <= end1)
    {
        tmp[index++] = a[begin1++];
    }
    // 如果右半部分还有剩余(左半部分先排完了),直接拷贝
    while (begin2 <= end2)
    {
        tmp[index++] = a[begin2++];
    }

    // 6. 回拷:将排好序的临时数组内容拷贝回原数组
    // 注意:只拷贝当前处理的区间 [left, right]
    for (int i = left; i <= right; i++)
    {
        a[i] = tmp[i];
    }
}

复杂度与稳定性

  1. 时间复杂度:(O(n\log n))
  2. 空间复杂度:(O(n))

稳定性:稳定

5. 非比较排序

5.1 计数排序

计数排序又称 鸽巢原理,是对哈希直接定址法的变形应用。

有关哈希表的详细概念可以参考我之前写的博客!【点击转跳】

它的核心思想非常直观:不通过比较元素大小来排序,而是统计每个元素出现的次数,然后根据统计结果直接还原出有序的数组。这种方法在处理数据范围相对集中的整数时,效率极高,时间复杂度可以达到 O(N)。

操作步骤为:

  1. 统计相同元素出现次数。
  2. 根据统计的结果将序列回收到原来的序列中
    请添加图片描述

虽然在数据范围相对集中的时候,计数排序的效率很高,但是该排序的适用范围和场景十分有限!因为一旦数据分散起来就会和哈希表一样造成空间浪费,当然 很多人会想为什么不可以用离散化进行优化?因为离散化的前提就是 有序

请添加图片描述

// 计数排序
// a: 待排序数组
// n: 数组长度
void CountSort(int* a, int n)
{
    // 1. 寻找数组中的最大值和最小值
    // 目的:确定数据的范围,以便决定开辟多大的计数数组
    int min = a[0], max = a[0];
    for (int i = 1; i < n; i++)
    {
        if (a[i] > max)
            max = a[i];
        if (a[i] < min)
            min = a[i];
    }

    // 2. 计算数据范围大小
    // 例如:min=90, max=100,则 range = 11 (包含90到100共11个数)
    int range = max - min + 1;

    // 3. 开辟计数数组并初始化
    int* count = (int*)malloc(sizeof(int) * range);
    if (count == NULL)
    {
        perror("malloc fail");
        return;
    }
    // 将计数数组所有位置清零
    memset(count, 0, sizeof(int) * range);

    // 4. 统计次数 (核心步骤)
    for (int i = 0; i < n; i++)
    {
        // 相对映射:a[i] - min
        // 例如:min=90,数字95应该存放在 count[5] 的位置
        count[a[i] - min]++;
    }

    // 5. 排序(根据统计结果重写原数组)
    int j = 0; // 原数组的下标
    for (int i = 0; i < range; i++) // 遍历计数数组
    {
        // 如果 count[i] > 0,说明数字 (i + min) 存在
        // while 循环负责把该数字重复写回原数组
        while (count[i]--)
        {
            a[j++] = i + min; // 还原数值:索引 i 对应的实际数值是 i + min
        }
    }
    
    // 注意:通常这里应该 free(count),但代码中未展示,实际使用建议释放
}
  1. 相对映射 (a[i] - min)
    这是计数排序处理非零起点数据的关键。
    • 如果数据是 [90, 95, 100],我们不需要开辟大小为 101 的数组。
    • 通过减去 min (90),我们将数据映射为 [0, 5, 10],只需要开辟大小为 11 的数组即可,大大节省空间。
  2. 时间复杂度:O(N + Range)
    • 寻找最大最小值的时候遍历了一次数组 :O(N)
    • 统计次数遍历了一次数组:O(N)
    • 排序回写遍历了范围:O(Range)
    • 当数据范围 Range 和 数据量 N 差不多大时,效率非常高,是线性阶的。
  3. 空间复杂度:O(Range)
    • 需要额外开辟一个大小为 max - min + 1 的数组。如果数据跨度极大(例如 11000000),这种方法会非常浪费内存,不适合使用。
  4. 稳定性
    • 这种写法是稳定的。因为我们在回写时,是按照数据的顺序依次写入的(虽然这里看不出顺序,因为相同数值没有区别),但在涉及复杂结构体排序时,计数排序可以保持相同元素的相对顺序(如(85,张三)、(85,李四)这样的pair结构,只需要倒序遍历原数组就可以做到稳定了!)。

6. 排序算法复杂度及稳定性分析

排序方法平均情况最好情况最坏情况辅助空间稳定性
冒泡排序 O ( n 2 ) O(n^2) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)稳定
直接选择排序 O ( n 2 ) O(n^2) O(n2) O ( n 2 ) O(n^2) O(n2)$ O(n^2) $ O ( 1 ) O(1) O(1)不稳定
直接插入排序 O ( n 2 ) O(n^2) O(n2) O ( n ) O(n) O(n) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)稳定
希尔排序 O ( n log ⁡ n ) ∼ O ( n 2 ) O(n \log n) \sim O(n^2) O(nlogn)O(n2) O ( n 1.3 ) O(n^{1.3}) O(n1.3) O ( n 2 ) O(n^2) O(n2) O ( 1 ) O(1) O(1)不稳定
堆排序 O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( 1 ) O(1) O(1)不稳定
归并排序 O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n ) O(n) O(n)稳定
快速排序 O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n log ⁡ n ) O(n \log n) O(nlogn) O ( n 2 ) O(n^2) O(n2) O ( log ⁡ n ) ∼ O ( n ) O(\log n) \sim O(n) O(logn)O(n)不稳定

7. 自省排序(加餐)

思路:进行自我侦测和反省,当快排递归深度太深的时候,那就说明在这种数据序列下,选key出现了问题,性能在快速退化,那么就不要再进行快速排序分割递归了,改为堆排序进行排序!

/**
 * 内省排序核心递归函数
 * a:待排序数组
 * left:当前排序区间的左边界
 * right:当前排序区间的右边界
 * depth:当前递归深度
 * defaultDepth:最大允许递归深度 (通常为 2*logN)
 */
void Introsort(int* a, int left, int right, int depth, int defaultDepth)
{
    // 1. 递归终止条件:区间为空或只有一个元素
    if (left >= right)
        return;

    // 2. 小区间优化:
    // 当区间长度小于 16 时,改用插入排序。
    // 原因:对于极小的数据集,插入排序的常数项更小,且能避免深层递归的函数调用开销。
    if (right - left + 1 < 16)
    {
        Insertsort(a + left, right - left + 1); // 对当前子区间进行插入排序
        return;
    }

    // 3. 递归深度限制:
    // 如果递归深度超过阈值 (2*logN),说明快排划分极度不平衡,有退化风险。
    // 此时强制切换为堆排序,以保证最坏时间复杂度仍为 O(NlogN)。
    if (depth > defaultDepth)
    {
        Heapsort(a + left, right - left + 1); // 对当前子区间进行堆排序
        return;
    }

    // 4. 快速排序分区 (三路划分)
    // 目的:处理重复元素。将数组分为 <key, =key, >key 三部分。
    int key = a[left];    // 选取基准值 (这里选最左边)
    int begin = left;     // 保存原始左边界,用于后续递归
    int end = right;      // 保存原始右边界,用于后续递归
    int cur = begin + 1;  // 当前扫描指针

    while (cur <= end)
    {
        if (a[cur] < key)
        {
            // 当前元素小于基准值:交换到左侧,left和cur都前进
            swap(&a[cur], &a[left]);
            ++left;
            ++cur;
        }
        else if (a[cur] > key)
        {
            // 当前元素大于基准值:交换到右侧,right后退,cur不动(因为交换回来的元素还没检查)
            swap(&a[cur], &a[right]);
            --right;
        }
        else
        {
            // 当前元素等于基准值:cur前进
            ++cur;
        }
    }
    // 循环结束后,区间被划分为:
    // [begin, left-1] < key
    // [left, right] == key (中间这一段已经有序且相等,无需再排)
    // [right+1, end] > key

    // 5. 递归处理左右子区间
    // 注意:中间 [left, right] 区间不需要处理
    // 深度 +1 传入下一层
    Introsort(a, begin, left - 1, depth + 1, defaultDepth); // 递归处理小于区
    Introsort(a, right + 1, end, depth + 1, defaultDepth);  // 递归处理大于区
}

/**
 * 内省排序接口函数
 * a:待排序数组
 * left:数组左边界
 * right:数组右边界
 */
void Quicksort(int* a, int left, int right)
{
    if (left >= right) return;

    int depth = 0; // 初始深度为 0
    int logn = 0;
    int N = right - left + 1;

    // 计算 log2(N)
    // 循环结束后,logn 约为 log2(N)
    for (int i = 1; i < N; i *= 2)
    {
        logn++;
    }

    // 启动内省排序
    // 限制最大深度为 2 * log2(N),这是经验值,超过这个深度说明快排效果不好
    Introsort(a, left, right, depth, logn * 2);
}

这段代码是一个标准的、工业级的内省排序实现。它结合了三种排序算法的优点:

  • 快速排序:平均情况下的速度最快。
  • 堆排序:保证了最坏情况下的时间复杂度依然是 O(NlogN)。
  • 插入排序:优化了小区间的性能,可以避免在小区间内递归栈帧开辟,减少每次递归都要计算基准值(Pivot)、移动指针、判断边界、压栈出栈等。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值