数据结构 ——— 八大排序算法的思想及其实现

目录

冒泡排序(默认升序)

冒泡排序代码实现

冒泡排序算法思想

冒泡排序的逻辑与原理(结合代码)

[示例过程:对 parr = [5,3,1,2,4](升序)排序](#示例过程:对 parr = [5,3,1,2,4](升序)排序)

最终结果

核心总结

冒泡排序算法的时间复杂度和空间复杂度

空间复杂度

时间复杂度

总结
直接插入排序(默认升序)

直接插入排序代码实现

直接插入排序算法思想

直接插入排序的核心逻辑(结合代码)

[示例过程:对parr = [2,4,6,7,5,3,1,9,0,8]排序](#示例过程:对parr = [2,4,6,7,5,3,1,9,0,8]排序)

总结

直接插入排序算法的时间复杂度和空间复杂度

时间复杂度

空间复杂度

总结
希尔排序(默认升序)

希尔排序代码实现

希尔排序算法思想

代码核心逻辑解析

[示例过程:对parr = [5,3,1,2,4](升序)排序](#示例过程:对parr = [5,3,1,2,4](升序)排序)

最终结果

核心总结

希尔排序算法的空间复杂度和时间复杂度

空间复杂度

时间复杂度

总结
直接选择排序(默认升序)

直接选择排序代码实现

直接选择排序算法思想

直接选择排序的逻辑与原理(结合代码)

[示例过程:对parr = [5,3,1,2,4](升序)排序](#示例过程:对parr = [5,3,1,2,4](升序)排序)

最终结果

核心总结

直接插入排序算法的时间复杂度和空间复杂度

空间复杂度

时间复杂度

总结
堆排序(默认升序)

堆排序代码实现

堆排序算法思想

堆排序的逻辑与原理(结合代码)

[示例过程:对parr = [5,3,1,2,4](升序)排序](#示例过程:对parr = [5,3,1,2,4](升序)排序)

最终结果

核心总结

堆排序算法的时间复杂度和空间复杂度

空间复杂度

时间复杂度

总结
快速排序(huare版本)(默认升序)

快速排序(huare版本)代码实现

快速排序(huare版本)算法思想

[快速排序(Hoare 分区法)的逻辑与原理(结合代码)](#快速排序(Hoare 分区法)的逻辑与原理(结合代码))

[示例过程:对parr = [5,3,1,2,4](升序)排序](#示例过程:对parr = [5,3,1,2,4](升序)排序)

最终结果

核心总结
快速排序(挖坑法版本)

快速排序(挖坑法版本)代码实现

快速排序(挖坑法版本)算法思想

快速排序(挖坑法)的逻辑与原理(结合代码)

核心总结
快速排序(前后指针法)

快速排序(前后指针法)代码实现

快速排序(前后指针法)算法思想

快速排序(前后指针法)的逻辑与原理(结合代码)

核心总结
快速排序算法的时间复杂度和空间复杂度

时间复杂度

空间复杂度

[总结(Hoare 版本)](#总结(Hoare 版本))
三数取中(规避快速排序算法最坏的情况)

三数取中代码实现

三数取中算法思想

三数取中算法的逻辑与原理(结合代码)

为什么三数取中能规避快速排序的最坏情况?

核心总结

三数取中算法的使用
快速排序(非递归)

快速排序(非递归)代码实现

快速排序(非递归)算法思想

快速排序(非递归)的逻辑与原理(结合代码)

[示例过程:对parr = [5,3,1,2,4](升序)非递归快速排序](#示例过程:对parr = [5,3,1,2,4](升序)非递归快速排序)

最终结果

核心总结

快速排序算法(非递归)的时间复杂度和空间复杂度

时间复杂度

空间复杂度

总结
快速排序(三路划分)

快速排序(三路划分)代码实现

快速排序(三路划分)算法思想

快速排序(三路划分)的逻辑与原理(结合代码)

[示例过程:对parr = [2,3,3,5,3,1](升序)三路划分快速排序](#示例过程:对parr = [2,3,3,5,3,1](升序)三路划分快速排序)

最终结果

核心总结

[快速排序(三路划分)与 快速排序(hoare版本)时间、空间复杂度比较](#快速排序(三路划分)与 快速排序(hoare版本)时间、空间复杂度比较)

时间复杂度对比

空间复杂度对比

核心区别:实际效率的优化

总结
归并排序

归并排序代码实现

归并排序算法思想

归并排序的逻辑与原理(结合代码)

[示例过程:对parr = [5,3,1,2,4](升序)归并排序](#示例过程:对parr = [5,3,1,2,4](升序)归并排序)

最终结果

核心总结

归并排序算法的时间复杂度和空间复杂度

时间复杂度

空间复杂度

总结
小区间优化(规避归并排序递归太深的情况)

小区间优化代码实现

小区间优化算法思想

小区间优化的逻辑与原理

为什么选择直接插入排序而非其他排序

小区间优化如何解决归并排序递归太深的问题

小区间优化算法的使用
计数排序

计算排序代码实现

计数排序算法思想

计数排序的逻辑与原理(结合代码)

[示例过程:对parr = [5,3,1,2,4](升序)计数排序](#示例过程:对parr = [5,3,1,2,4](升序)计数排序)

最终结果

核心总结

计数排序的时间复杂度和空间复杂度

时间复杂度

空间复杂度

关键特性与局限性

总结


冒泡排序(默认升序)

冒泡排序代码实现

复制代码
void Swap(int* p1, int* p2)
{
    int tmp = *p1;  // 用临时变量保存p1指向的值
    *p1 = *p2;      // 将p2指向的值赋给p1指向的位置
    *p2 = tmp;      // 将临时变量保存的值(原p1的值)赋给p2指向的位置
}

void BubbleSort(int* parr, int size)
{
    // 外层循环:控制排序的轮数,每轮会确定一个最大元素的最终位置
    // i表示已排好序的元素个数(初始为0,最多需要size轮,实际可能提前结束)
    for (int i = 0; i < size; i++)
    {
        bool flag = false;  // 标记本轮是否发生过交换,初始为false(未交换)

        // 内层循环:遍历未排序部分,比较相邻元素并交换
        // j的范围是0到size-1-i-1,因为每轮会将最大元素移到末尾,已排序部分无需再比较
        for (int j = 0; j < size - 1 - i; j++)
        {
            // 若当前元素大于后一个元素,说明顺序错误,交换两者
            if (parr[j] > parr[j + 1])
            {
                Swap(&parr[j], &parr[j + 1]);  // 调用交换函数交换相邻元素
                flag = true;                   // 标记本轮发生了交换
            }
        }

        // 若本轮未发生任何交换,说明数组已经有序,提前退出循环(优化操作)
        if (flag == false)
            break;
    }
}

冒泡排序算法思想

冒泡排序的逻辑与原理(结合代码)

冒泡排序的核心思想是:重复遍历数组,每次比较相邻的两个元素,若顺序错误(前大后小)则交换它们,直到没有元素需要交换(数组有序)。因其每轮会将 "未排序部分的最大元素" 像 "气泡" 一样 "浮" 到末尾,故得名 "冒泡排序"。

代码通过以下逻辑实现:

  1. 外层循环(控制轮数)i 表示 "已排好序的元素个数"(初始为 0),每轮循环会确定一个最大元素的最终位置(数组末尾),最多需要 size 轮(但可提前结束)。
  2. 内层循环(遍历未排序部分)j 遍历 "未排序部分"(范围 0size-1-i-1),因每轮会将最大元素移到末尾,已排序的 i 个元素无需再比较。
  3. 交换与优化 :比较相邻元素 parr[j]parr[j+1],若前者大则交换;用 flag 标记本轮是否发生交换,若未交换(flag=false),说明数组已有序,可提前退出循环(减少无效遍历)。

示例过程:对 parr = [5,3,1,2,4](升序)排序

数组长度 size=5,逐步处理如下:

初始状态

数组:[5,3,1,2,4]i=0(已排序元素 0 个),flag=false

第一轮(i=0):寻找最大元素并移到末尾

  • 内层循环 j 范围:05-1-0-1=3(即 j=0,1,2,3),遍历未排序部分 [5,3,1,2,4]
    • j=0:比较 53(5>3),交换 → 数组变为 [3,5,1,2,4]flag=true
    • j=1:比较 51(5>1),交换 → 数组变为 [3,1,5,2,4]flag=true
    • j=2:比较 52(5>2),交换 → 数组变为 [3,1,2,5,4]flag=true
    • j=3:比较 54(5>4),交换 → 数组变为 [3,1,2,4,5]flag=true
  • 本轮结束:最大元素 5 已移到末尾(下标 4),flag=true(有交换),继续循环。

第二轮(i=1):寻找次大元素并移到倒数第二位

  • 已排序元素 1 个(5),未排序部分为 [3,1,2,4],内层循环 j 范围:05-1-1-1=2(即 j=0,1,2)。
    • j=0:比较 31(3>1),交换 → 数组变为 [1,3,2,4,5]flag=true
    • j=1:比较 32(3>2),交换 → 数组变为 [1,2,3,4,5]flag=true
    • j=2:比较 34(3<4),不交换。
  • 本轮结束:次大元素 4 已移到倒数第二位(下标 3),flag=true(有交换),继续循环。

第三轮(i=2):检查剩余元素是否有序

  • 已排序元素 2 个(4,5),未排序部分为 [1,2,3],内层循环 j 范围:05-1-2-1=1(即 j=0,1)。
    • j=0:比较 12(1<2),不交换;
    • j=1:比较 23(2<3),不交换。
  • 本轮结束:flag=false(无交换),说明数组已完全有序,直接跳出外层循环。

最终结果

数组经过 3 轮(实际有效 2 轮)排序后,变为升序 [1,2,3,4,5]

核心总结

冒泡排序通过 "相邻元素比较交换" 让最大元素逐步 "浮" 到末尾,每轮确定一个元素的最终位置;flag 变量的优化避免了数组已有序后的无效遍历,提升效率。其过程直观,核心是 "重复比较交换,直到无交换发生"。

冒泡排序算法的时间复杂度和空间复杂度

冒泡排序的时间复杂度和空间复杂度分析如下,其复杂度与数组的初始有序程度密切相关,且代码中的flag优化会影响最好情况的效率:

空间复杂度

冒泡排序是原地排序算法 ,排序过程中仅需要一个临时变量(用于交换相邻元素,如代码中Swap函数的临时存储),无需额外的数组或数据结构。因此:

  • 空间复杂度:O(1)(常数级)。

时间复杂度

时间复杂度主要取决于 "比较相邻元素" 和 "交换元素" 的次数,分为以下三种情况:

1. 最好情况:数组本身已完全有序(如升序数组)

  • 此时,第一轮遍历(i=0)中,内层循环比较所有相邻元素,发现均无需交换(flag保持false),直接跳出外层循环,排序结束。
  • 总比较次数为 n-1(仅一轮遍历),交换次数为 0
  • 时间复杂度:O(n) (得益于flag的优化,避免了后续无效遍历)。

2. 最坏情况:数组完全逆序(如需要升序,但原数组是降序)

  • 此时,每轮都需要交换元素,且需要进行 n-1 轮(每轮确定一个最大元素的位置):
    • 第 1 轮:比较 n-1 次,交换 n-1 次;
    • 第 2 轮:比较 n-2 次,交换 n-2 次;
    • ...
    • n-1轮:比较 1 次,交换 1 次。
  • 总比较次数和交换次数均为 1+2+...+(n-1) = n(n-1)/2,约为 O(n²)
  • 时间复杂度:O(n²)

3. 平均情况:数组元素随机排列(无序程度中等)

  • 此时,平均需要进行 n/2 轮遍历,每轮平均比较 n/2 次,总操作次数约为 O(n²/2)
  • 时间复杂度:O(n²)

总结

  • 空间复杂度:O(1)(原地排序,仅需常数级额外空间);
  • 时间复杂度:最好O(n)(有序数组,flag优化生效),最坏和平均O(n²)

冒泡排序的优势是逻辑简单、实现容易,且是稳定排序(相等元素的相对顺序不变),但效率较低,适合数据量小或接近有序的数组。


直接插入排序(默认升序)

直接插入排序代码实现

复制代码
void InsertSort(int* parr, int size)
{
    // 从数组的第二个元素开始遍历(i=1),因为第一个元素可视为已排序区间
    // i表示当前待插入元素的下标,依次处理后面的元素
    for (int i = 1; i < size; i++)
    {
        // 保存当前待插入元素的值,防止后续移动元素时被覆盖
        int tmp = parr[i];

        // end指向已排序区间的最后一个元素下标(初始为i-1,即待插入元素的前一个)
        int end = i - 1;

        // 在已排序区间中找到待插入元素的正确位置
        // 循环条件:end >= 0(未越界)
        while (end >= 0)
        {
            // 如果已排序区间的当前元素大于待插入元素,说明该元素需要后移
            if (parr[end] > tmp)
            {
                parr[end + 1] = parr[end];  // 元素后移一位
                end--;  // 继续向前比较前一个元素
            }
            else
            {
                // 找到比待插入元素小或相等的元素,说明已找到插入位置,退出循环
                break;
            }
        }

        // 将待插入元素放到正确位置(end+1是退出循环后确定的插入下标)
        parr[end + 1] = tmp;
    }
}

直接插入排序算法思想

直接插入排序的核心逻辑(结合代码)

  1. 区间划分

    • 初始时,数组的第一个元素(下标0)视为 "已排序区间"(只有一个元素,天然有序);
    • 从下标1开始的元素属于 "未排序区间",需要逐个处理。
  2. 遍历未排序区间

    • i表示 "当前待插入元素" 的下标(从1size-1),每次循环处理parr[i]
  3. 插入到已排序区间

    • tmp保存parr[i]的值(防止后续移动元素时被覆盖);
    • end指向 "已排序区间的最后一个元素"(初始为i-1),通过while循环在已排序区间中找到tmp的插入位置:
      • parr[end] > tmp:说明parr[end]需要后移(给tmp腾位置),执行parr[end+1] = parr[end]end减一继续向前比较;
      • parr[end] <= tmp:说明找到插入位置(end+1),退出循环;
    • 最后将tmp放到end+1的位置,完成一次插入。
  4. 循环结束 :当i遍历完所有未排序元素,整个数组变为有序。

示例过程:对parr = [2,4,6,7,5,3,1,9,0,8]排序

初始状态

  • 数组长度size=5,初始时:
    • 已排序区间:[5](下标0,只有第一个元素,天然有序);
    • 未排序区间:[3,1,2,4](下标1~4,需要逐个处理)。

第 1 次循环(i=1,待插入元素为3

  • 核心操作 :将未排序区间的第一个元素3插入已排序区间[5]的合适位置。
  • 步骤:
    1. 保存待插入元素:tmp = parr[1] = 3
    2. end指向已排序区间最后一个元素(end = i-1 = 0,对应值5);
    3. 比较parr[end]tmp5 > 3,因此parr[end+1] = parr[end](将5后移到下标1),end减为-1
    4. 循环结束,插入位置为end+1 = 0,将tmp=3放入parr[0]
  • 结果:已排序区间扩大为[3,5],数组变为[3,5,1,2,4]

第 2 次循环(i=2,待插入元素为1

  • 核心操作 :将未排序区间的第一个元素1插入已排序区间[3,5]的合适位置。
  • 步骤:
    1. 保存待插入元素:tmp = parr[2] = 1
    2. end = i-1 = 1(对应值5);
    3. 比较5 > 1parr[2] = 55后移到下标2),end=0
    4. 比较parr[0]=3 > 1parr[1] = 33后移到下标1),end=-1
    5. 插入位置为end+1=0,将tmp=1放入parr[0]
  • 结果:已排序区间扩大为[1,3,5],数组变为[1,3,5,2,4]

第 3 次循环(i=3,待插入元素为2

  • 核心操作 :将未排序区间的第一个元素2插入已排序区间[1,3,5]的合适位置。
  • 步骤:
    1. 保存待插入元素:tmp = parr[3] = 2
    2. end = i-1 = 2(对应值5);
    3. 比较5 > 2parr[3] = 55后移到下标3),end=1
    4. 比较parr[1]=3 > 2parr[2] = 33后移到下标2),end=0
    5. 比较parr[0]=1 <= 2:找到插入位置,退出循环;
    6. 插入位置为end+1=1,将tmp=2放入parr[1]
  • 结果:已排序区间扩大为[1,2,3,5],数组变为[1,2,3,5,4]

第 4 次循环(i=4,待插入元素为4

  • 核心操作 :将未排序区间的最后一个元素4插入已排序区间[1,2,3,5]的合适位置。
  • 步骤:
    1. 保存待插入元素:tmp = parr[4] = 4
    2. end = i-1 = 3(对应值5);
    3. 比较5 > 4parr[4] = 55后移到下标4),end=2
    4. 比较parr[2]=3 <= 4:找到插入位置,退出循环;
    5. 插入位置为end+1=3,将tmp=4放入parr[3]
  • 结果:已排序区间扩大为[1,2,3,4,5],数组最终变为[1,2,3,4,5](完全有序)。

总结

通过 4 次插入操作,每次将未排序区间的第一个元素插入已排序区间的正确位置,最终使数组[5,3,1,2,4]变为升序[1,2,3,4,5]。直接插入排序的核心是利用已排序区间的有序性,通过 "元素后移" 为待插入元素腾出位置,实现逐步排序。

直接插入排序算法的时间复杂度和空间复杂度

时间复杂度

直接插入排序的时间复杂度主要取决于 "比较元素" 和 "移动元素" 的次数,与数组的初始有序程度密切相关,分为以下三种情况:

  1. 最好情况:数组本身已完全有序(如升序数组)。

    • 此时,每个待插入元素只需与已排序区间的最后一个元素比较一次(发现无需移动),即可确定插入位置。
    • 总比较次数为 O(n) ,移动次数为 O(n)(仅需临时存储待插入元素)。
    • 时间复杂度:O(n)
  2. 最坏情况:数组完全逆序(如需要升序,但原数组是降序)。

    • 此时,第i个元素(从 1 开始计数)需要与已排序区间的i个元素依次比较,且每个元素都需要后移,才能腾出插入位置。
    • 总比较次数和移动次数均为 1+2+...+(n-1) = n(n-1)/2 ,约为 O(n²)
    • 时间复杂度:O(n²)
  3. 平均情况:数组元素随机排列(无序程度中等)。

    • 每个元素插入时,平均需要与已排序区间的一半元素比较和移动,总操作次数约为 O(n²/2)
    • 时间复杂度:O(n²)

空间复杂度

直接插入排序是原地排序算法 ,排序过程中不需要额外的存储空间(仅需一个临时变量tmp存储待插入元素,用于避免移动元素时被覆盖)。

  • 空间复杂度:O(1)(常数级)。

总结

  • 时间复杂度:最好O(n),最坏和平均O(n²)
  • 空间复杂度:O(1)

因此,直接插入排序适合数据量小初始接近有序的数组,在这种场景下效率较高。


希尔排序(默认升序)

希尔排序代码实现

复制代码
void ShellSort(int* parr, int size)
{
    // 初始化gap为数组长度,gap表示分组的步长(每组内元素间隔gap)
    int gap = size;

    // 当gap > 1时,进行分组插入排序;gap = 1时,相当于一次完整的直接插入排序,完成最终排序
    while (gap > 1)
    {
        // 更新gap值:采用gap = gap/3 + 1的方式,确保gap最终能减小到1(最后一次循环gap为1)
        // 这种方式可使分组逐渐细化,逐步减少数组的逆序对
        gap = gap / 3 + 1;

        // 对每个分组进行插入排序:共gap个分组,起始索引分别为0,1,...,gap-1
        for (int j = 0; j < gap; j++)
        {
            // 遍历当前分组中的元素,i为分组中已排序部分的最后一个元素索引
            // 每次以gap为步长向后移动,处理分组中后续待插入的元素
            for (int i = j; i < size - gap; i += gap)
            {
                // end标记当前分组中已排序部分的最后一个元素位置
                int end = i;

                // 保存当前待插入元素的值(分组中end的下一个元素,间隔为gap)
                int tmp = parr[end + gap];

                // 在当前分组的已排序部分中,找到待插入元素的正确位置
                while (end >= 0)
                {
                    // 若已排序部分的元素大于待插入元素,说明该元素需要后移(移动gap步)
                    if (parr[end] > tmp)
                    {
                        parr[end + gap] = parr[end];  // 元素后移gap步
                        end = end - gap;  // 继续向前比较分组中前一个元素(间隔gap)
                    }
                    else
                    {
                        // 找到小于或等于待插入元素的位置,退出循环(已确定插入位置)
                        break;
                    }
                }

                // 将待插入元素放到分组中正确的位置(end + gap为插入位置)
                parr[end + gap] = tmp;
            }
        }
    }
}

希尔排序算法思想

希尔排序是插入排序的改进版,核心思想是:通过设置 "步长(gap)" 将数组分为多个子序列,对每个子序列进行插入排序;逐步减小 gap(使子序列逐渐合并),最后当 gap=1 时,对整个数组进行一次直接插入排序,完成最终排序。

  • 为什么要分组? 直接插入排序对 "基本有序" 的数组效率很高(接近 O (n)),但对逆序较多的数组效率低(O (n²))。希尔排序通过大 gap 分组,让元素快速 "跳" 到大致正确的位置,减少逆序对,使数组逐渐接近有序,最后用直接插入排序收尾,大幅提升效率。

代码核心逻辑解析

  1. gap(步长)设置 :初始 gap 为数组长度size,每次更新为gap = gap / 3 + 1(确保 gap 最终会减小到 1)。
  2. 分组规则 :对于当前 gap,数组被分为gap个组,每组包含 "索引为j, j+gap, j+2*gap, ..." 的元素(j从 0 到gap-1)。
  3. 组内插入排序:对每个组,按 "直接插入排序" 的逻辑处理(将组内元素视为一个小数组,逐个插入到组内的有序部分)。
  4. 终止条件 :当gap=1时,整个数组成为一个组,执行一次直接插入排序后,数组完全有序。

示例过程:对parr = [5,3,1,2,4](升序)排序

数组长度size=5,逐步处理如下:

第一步:计算 gap 的变化

  • 初始 gap = size = 5;
  • 第一次更新 gap:gap = 5/3 + 1 = 1 + 1 = 2(整数除法);
  • 第二次更新 gap:gap = 2/3 + 1 = 0 + 1 = 1
  • 当 gap=1 时,完成最后一次排序后循环结束。

第二步:gap=2 时的分组排序(核心步骤)

此时 gap=2,数组被分为2个组(j=0 和 j=1),每组内元素间隔为 2,分别对两组进行插入排序。

组 1:j=0(元素索引:0, 0+2=2, 2+2=4)

组内元素初始为:parr[0]=5parr[2]=1parr[4]=4(即[5,1,4])。

  • 处理组内第一个待插入元素(索引 2,值 1):

    • end=0(组内已排序部分最后一个元素索引),tmp=parr[0+2]=1
    • 比较parr[0]=5 > 1parr[0+2] = 5(5 后移 2 步到索引 2),end=0-2=-2
    • 插入位置end+2=0parr[0] = 1
    • 组内元素变为:[1,5,4](数组此时:[1,3,5,2,4])。
  • 处理组内第二个待插入元素(索引 4,值 4):

    • end=2(组内已排序部分最后一个元素索引),tmp=parr[2+2]=4
    • 比较parr[2]=5 > 4parr[2+2] = 5(5 后移 2 步到索引 4),end=2-2=0
    • 比较parr[0]=1 <= 4:找到插入位置,parr[0+2] = 4
    • 组内元素变为:[1,4,5](数组此时:[1,3,4,2,5])。

组 2:j=1(元素索引:1, 1+2=3)

组内元素初始为:parr[1]=3parr[3]=2(即[3,2])。

  • 处理组内待插入元素(索引 3,值 2):
    • end=1(组内已排序部分最后一个元素索引),tmp=parr[1+2]=2
    • 比较parr[1]=3 > 2parr[1+2] = 3(3 后移 2 步到索引 3),end=1-2=-1
    • 插入位置end+2=1parr[1] = 2
    • 组内元素变为:[2,3](数组此时:[1,2,4,3,5])。

第三步:gap=1 时的最终排序(直接插入排序)

此时 gap=1,整个数组视为一个组([1,2,4,3,5]),执行直接插入排序:

  • 待插入元素依次为索引 1(2)、2(4)、3(3)、4(5)。
  • 重点处理索引 3(值 3):
    • end=2(已排序部分最后一个元素索引,值 4),tmp=3
    • 比较4 > 3parr[3] = 4(4 后移 1 步),end=1
    • 比较parr[1]=2 <= 3:插入位置end+1=2parr[2] = 3
    • 数组变为:[1,2,3,4,5]

最终结果

经过 gap=2 和 gap=1 的排序,数组[5,3,1,2,4]最终变为升序[1,2,3,4,5]

核心总结

希尔排序通过 "大 gap 分组粗调→小 gap 分组细调→gap=1 精调" 的流程,让元素快速接近目标位置,解决了直接插入排序对逆序数组效率低的问题。其关键是 gap 的设置(本例中 gap=2→1),分组排序逐步减少逆序对,最终通过一次直接插入排序完成整体有序。

希尔排序算法的空间复杂度和时间复杂度

希尔排序的时间复杂度和空间复杂度分析如下,其时间复杂度因步长(gap)序列的选择而有较大差异,空间复杂度则相对固定:

空间复杂度

希尔排序是原地排序算法 ,排序过程中仅需要一个临时变量(如代码中的tmp)存储待插入元素,无需额外的数组或数据结构。因此:

  • 空间复杂度:O(1)(常数级)。

时间复杂度

希尔排序的时间复杂度比较复杂,其核心是通过 "分组插入排序" 逐步减少数组的逆序对,最终通过一次直接插入排序完成收尾。时间复杂度的高低取决于步长(gap)序列的设计(即每次 gap 如何缩小),不同的步长序列会导致不同的时间复杂度,目前尚无精确的数学推导证明其最优值,以下是常见情况:

1. 最坏情况时间复杂度

  • 原始希尔步长(gap = n/2, n/4, ..., 1) :这种步长序列下,最坏情况时间复杂度为 O(n²)(虽然比直接插入排序的实际表现好,但理论上仍为平方级)。
  • Hibbard 步长(gap = 2ᵏ - 1,如 1, 3, 7, 15...) :最坏情况时间复杂度约为 O(n^(3/2))(优于原始步长)。
  • Sedgewick 步长(混合序列,如 94ᵏ - 92ᵏ + 1) :最坏情况时间复杂度约为 O(n^(4/3))(目前已知表现较好的步长序列之一)。

2. 平均情况时间复杂度

平均时间复杂度同样依赖步长序列,目前主流观点认为:

  • 对于合理的步长序列(如 Hibbard、Sedgewick),平均时间复杂度约为 O(n log²n)O(n^(5/4)),具体数值仍存在争议,但整体远优于直接插入排序的 O (n²)。

3. 为什么时间复杂度不固定?

希尔排序的核心是通过 "大 gap 分组" 让元素快速 "跳跃" 到接近目标位置(减少逆序对),再通过 "小 gap 分组" 细化排序,最后用 gap=1 的直接插入排序收尾。步长序列的设计直接影响 "元素跳跃的效率":

  • 若步长序列中相邻 gap 存在倍数关系(如原始希尔步长的 n/2 和 n/4),会导致分组重复(元素在不同 gap 下被分到同一组),降低效率;
  • 若步长序列中相邻 gap 互质(如 Hibbard 步长),可减少分组重复,让元素更快扩散到正确位置,提升效率。

总结

  • 空间复杂度:O (1)(原地排序,仅需常数级额外空间);
  • 时间复杂度:依赖步长序列,最坏情况在 O (n²) 到 O (n^(4/3)) 之间,平均情况约为 O (n log²n),实际应用中效率远高于直接插入排序,适合中等规模数据的排序。

直接选择排序(默认升序)

直接选择排序代码实现

复制代码
void SelectSort(int* parr, int size)
{
    int begin = 0;       // 待排序区间的起始索引(初始为数组开头)
    int end = size - 1;  // 待排序区间的结束索引(初始为数组末尾)

    // 当待排序区间不为空(begin < end)时,继续排序
    while (begin < end)
    {
        int mini = begin;  // 记录当前待排序区间中最小元素的索引(初始化为区间起始)
        int maxi = begin;  // 记录当前待排序区间中最大元素的索引(初始化为区间起始)

        // 遍历当前待排序区间[begin, end],找到最小和最大元素的索引
        for (int i = begin; i <= end; i++)
        {
            // 若当前元素大于maxi指向的元素,更新maxi为当前索引
            if (parr[i] > parr[maxi])
                maxi = i;
            
            // 若当前元素小于mini指向的元素,更新mini为当前索引
            if (parr[i] < parr[mini])
                mini = i;
        }
        
        // 将最大元素交换到当前待排序区间的末尾(end位置)
        Swap(&parr[maxi], &parr[end]);

        // 特殊情况处理:若最小元素原本在end位置(交换后被移到了maxi位置)
        // 此时需要将mini更新为maxi(因为原end位置的元素已被交换到maxi)
        if (end == mini)
            mini = maxi;

        // 将最小元素交换到当前待排序区间的开头(begin位置)
        Swap(&parr[mini], &parr[begin]);

        // 缩小待排序区间:起始位置后移一位,结束位置前移一位
        begin++;
        end--;
    }
}

直接选择排序算法思想

直接选择排序的逻辑与原理(结合代码)

直接选择排序的核心思想是:通过不断缩小 "待排序区间",每次从区间中选出最小和最大的元素,分别放到区间的起始和末尾位置,直到整个区间缩小为空(数组完全有序)。相比冒泡排序的 "相邻交换",直接选择排序通过 "一次交换确定元素最终位置" 减少了交换次数,逻辑更直接。

代码通过以下逻辑实现:

  1. 区间定义 :用begin(待排序区间起始索引)和end(待排序区间结束索引)标记当前需要排序的范围,初始为begin=0end=size-1
  2. 寻找最值 :遍历待排序区间[begin, end],找到最小元素的索引mini和最大元素的索引maxi
  3. 交换元素
    • 将最大元素(maxi指向)交换到区间末尾(end位置);
    • 特殊情况处理:若最小元素原本在end位置(交换后被移到maxi位置),需更新minimaxi(避免后续交换错误);
    • 将最小元素(mini指向)交换到区间起始(begin位置)。
  4. 缩小区间begin++(起始后移,已排好最小元素),end--(结束前移,已排好最大元素),重复上述步骤直到begin >= end(区间为空)。

示例过程:对parr = [5,3,1,2,4](升序)排序

数组长度size=5,逐步处理如下:

初始状态

待排序区间:[begin=0, end=4](元素:5,3,1,2,4)。

第一轮循环(begin=0,end=4)

  • 步骤 1:找最值索引 遍历[0,4](元素5,3,1,2,4):

    • 最大元素是5(索引0)→ maxi=0
    • 最小元素是1(索引2)→ mini=2
  • 步骤 2:交换最大元素到区间末尾 交换parr[maxi=0]parr[end=4]:数组变为[4,3,1,2,5](最大元素5已固定在末尾)。

  • 步骤 3:处理特殊情况 检查是否end=4 == mini=2?否(mini仍为2),无需更新mini

  • 步骤 4:交换最小元素到区间起始 交换parr[mini=2]parr[begin=0]:数组变为[1,3,4,2,5](最小元素1已固定在起始)。

  • 缩小区间begin=1end=3(待排序区间变为[1,3])。

第二轮循环(begin=1,end=3)

  • 步骤 1:找最值索引 遍历[1,3](元素3,4,2):

    • 最大元素是4(索引2)→ maxi=2
    • 最小元素是2(索引3)→ mini=3
  • 步骤 2:交换最大元素到区间末尾 交换parr[maxi=2]parr[end=3]:数组变为[1,3,2,4,5](最大元素4已固定在end=3位置)。

  • 步骤 3:处理特殊情况 检查是否end=3 == mini=3?是(原miniend位置,交换后2被移到maxi=2位置)→ 更新mini=2

  • 步骤 4:交换最小元素到区间起始 交换parr[mini=2]parr[begin=1]:数组变为[1,2,3,4,5](最小元素2已固定在begin=1位置)。

  • 缩小区间begin=2end=2(此时begin >= end,循环结束)。

最终结果

经过 2 轮循环,数组[5,3,1,2,4]最终变为升序[1,2,3,4,5]

核心总结

直接选择排序通过 "每次确定待排序区间的最大和最小元素,交换到两端并缩小区间" 实现排序,核心是减少无效交换(一次交换确定一个元素的最终位置)。特殊情况处理(end == mini时更新mini)是为了避免最大元素和最小元素位置重叠时的交换错误,确保排序逻辑正确。

直接插入排序算法的时间复杂度和空间复杂度

直接插入排序的时间复杂度和空间复杂度分析如下,其复杂度与数组的初始有序程度密切相关:

空间复杂度

直接插入排序是原地排序算法 ,排序过程中仅需要一个临时变量(如代码中的tmp)存储待插入元素,用于避免移动元素时被覆盖,无需额外的数组或数据结构。因此:

  • 空间复杂度:O(1)(常数级)。

时间复杂度

时间复杂度主要取决于 "比较元素" 和 "移动元素" 的次数,分为以下三种情况:

1. 最好情况:数组本身已完全有序(如升序数组)

此时,每个待插入元素只需与已排序区间的最后一个元素比较一次(发现无需移动),即可确定插入位置。

  • 总比较次数为n-1(仅需一轮遍历),移动次数为0(仅需存储临时变量)。
  • 时间复杂度:O(n)

2. 最坏情况:数组完全逆序(如需要升序,但原数组是降序)

此时,第i个元素(从 1 开始计数)需要与已排序区间的i个元素依次比较,且每个元素都需要后移以腾出位置。

  • 总比较次数和移动次数均为1+2+...+(n-1) = n(n-1)/2,约为O(n²)
  • 时间复杂度:O(n²)

3. 平均情况:数组元素随机排列(无序程度中等)

此时,每个元素插入时,平均需要与已排序区间的一半元素比较和移动,总操作次数约为O(n²/2)

  • 时间复杂度:O(n²)

总结

  • 空间复杂度:O(1)(原地排序,仅需常数级额外空间);
  • 时间复杂度:最好O(n)(有序数组),最坏和平均O(n²)(逆序或随机数组)。

直接插入排序适合数据量小初始接近有序的数组,在这类场景下效率较高。


堆排序(默认升序)

堆排序代码实现

复制代码
// 向下调整算法:将以parenti为根的子树调整为大根堆(父节点值大于子节点值)
void AdjustDown(int* parr, int size, int parenti)  
{
    // 计算parenti的左孩子索引(完全二叉树中,左孩子 = 父节点*2 + 1)
    int childi = parenti * 2 + 1;

    // 当孩子节点索引在堆范围内(未越界)时,继续调整
    while (childi < size)
    {
        // 若右孩子存在(childi+1 < size),且右孩子值大于左孩子值,更新childi为右孩子索引
        // 目的是找到当前父节点的两个孩子中值较大的那个
        if ((childi + 1 < size) && (parr[childi + 1] > parr[childi]))
            childi++;

        // 若较大的孩子值大于父节点值,说明不符合大根堆性质,需要交换
        if (parr[childi] > parr[parenti])
        {
            Swap(&parr[parenti], &parr[childi]);  // 交换父节点和较大孩子节点的值
            
            // 更新父节点索引为当前孩子节点索引,继续向下调整(因为交换后可能破坏下层堆结构)
            parenti = childi;
           
            // 重新计算新父节点的左孩子索引
            childi = parenti * 2 + 1;
        }


// 堆排序函数:基于大根堆实现数组升序排序
void HeapSort(int* parr, int size)
{
    // 第一步:将数组构建为大根堆
    // 从最后一个非叶子节点开始,依次向上进行向下调整
    // 最后一个非叶子节点索引 = (最后一个元素索引 - 1) / 2,即(size-1-1)/2
    for (int i = (size - 1 - 1) / 2; i >= 0; i--)
        AdjustDown(parr, size, i);  // 对每个非叶子节点进行向下调整

    // 第二步:利用大根堆进行排序
    // 每次将堆顶(最大值)与当前堆的最后一个元素交换,然后缩小堆的范围并调整堆
    for (int i = size - 1; i > 0; i--)
    {
        // 交换堆顶(索引0,当前最大值)和堆的最后一个元素(索引i)
        // 交换后,最大值被放到数组末尾(已排序位置)
        Swap(&parr[0], &parr[i]);
        
        // 此时堆的大小缩小为i(排除已排序的末尾元素),对新的堆顶(索引0)进行向下调整,维护大根堆性质
        AdjustDown(parr, i, 0);
    }
}

            // 若孩子值小于等于父节点值,说明当前子树已符合大根堆性质,退出调整
            break;
        }
    }
}

堆排序算法思想

堆排序的逻辑与原理(结合代码)

堆排序是基于 "堆" 这种数据结构的排序算法,核心利用大根堆(父节点值大于子节点值) 的特性:堆顶元素是当前堆中的最大值。排序过程分为两步:构建大根堆利用堆顶最大值逐步排序,最终实现数组升序。

1. 核心工具:AdjustDown(向下调整算法)

作用:将以parenti为根的子树调整为大根堆(确保父节点值大于左右子节点值),是堆排序的核心支撑。

  • 原理:
    • 对于给定的父节点parenti,找到它的左右孩子(完全二叉树中,左孩子索引=2*parenti+1,右孩子=2*parenti+2);
    • 从左右孩子中选出值较大的那个(childi);
    • childi的值大于parenti的值,交换两者(修复当前层的堆性质);
    • 交换后,parenti更新为childi,继续向下检查下层子树,直到孩子节点越界(子树已符合大根堆性质)。

2. 堆排序主流程:HeapSort函数

步骤 1:将无序数组构建为大根堆

  • 最后一个非叶子节点 开始,依次向上对每个节点执行AdjustDown
    • 最后一个非叶子节点索引:(size-1-1)/2(完全二叉树中,最后一个元素的父节点就是最后一个非叶子节点);
    • 原因:叶子节点本身已是 "单个元素的大根堆",只需调整非叶子节点即可将整个数组转为大根堆。

步骤 2:利用大根堆排序(升序)

  • 每次将堆顶(最大值)与当前堆的最后一个元素交换(最大值被放到数组末尾,成为 "已排序部分");
  • 缩小堆的范围(排除已排序的末尾元素),对新的堆顶执行AdjustDown,重建大根堆;
  • 重复上述操作,直到堆的大小为 1(数组完全有序)。

示例过程:对parr = [5,3,1,2,4](升序)排序

数组长度size=5,逐步处理如下:

第一步:构建大根堆

目标:将[5,3,1,2,4]转为大根堆(堆顶为最大值)。

  • 确定最后一个非叶子节点 :索引=(5-1-1)/2=1(对应元素3),从i=1开始向上调整。

    • 调整i=1(父节点索引 1,值 3)

      • 左孩子索引2*1+1=3(值 2),右孩子索引4(值 4);
      • 右孩子 4 > 左孩子 2 → childi=4
      • 右孩子 4 > 父节点 3 → 交换,数组变为[5,4,1,2,3]
      • 更新parenti=4,孩子索引2*4+1=9(超出size=5)→ 调整结束。
    • 调整i=0(父节点索引 0,值 5)

      • 左孩子索引1(值 4),右孩子索引2(值 1);
      • 左孩子 4 > 右孩子 1 → childi=1
      • 父节点 5 > 左孩子 4 → 无需交换,调整结束。
  • 构建完成的大根堆[5,4,1,2,3](堆顶 5 是最大值)。

第二步:利用大根堆排序(逐步提取最大值)

每次将堆顶最大值放到数组末尾,缩小堆范围并重建大根堆。

  • 第 1 轮(堆大小 = 5,i=4)

    • 交换堆顶(5)和堆尾(i=4,值 3)→ 数组变为[3,4,1,2,5](5 已排序,放到末尾);
    • 堆大小缩小为 4(排除已排序的 5),对新堆顶(索引 0,值 3)执行AdjustDown
      • 左孩子 1(4),右孩子 2(1)→ childi=1(4>1);
      • 3<4 → 交换,数组变为[4,3,1,2,5]
      • 更新parenti=1,左孩子 3(2)→ 3>2 → 无需调整,堆重建完成([4,3,1,2])。
  • 第 2 轮(堆大小 = 4,i=3)

    • 交换堆顶(4)和堆尾(i=3,值 2)→ 数组变为[2,3,1,4,5](4 已排序);
    • 堆大小缩小为 3,对新堆顶(索引 0,值 2)执行AdjustDown
      • 左孩子 1(3),右孩子 2(1)→ childi=1(3>1);
      • 2<3 → 交换,数组变为[3,2,1,4,5]
      • 更新parenti=1,左孩子 3(超出堆大小 3)→ 调整结束,堆重建完成([3,2,1])。
  • 第 3 轮(堆大小 = 3,i=2)

    • 交换堆顶(3)和堆尾(i=2,值 1)→ 数组变为[1,2,3,4,5](3 已排序);
    • 堆大小缩小为 2,对新堆顶(索引 0,值 1)执行AdjustDown
      • 左孩子 1(2)→ childi=1
      • 1<2 → 交换,数组变为[2,1,3,4,5]
      • 更新parenti=1,左孩子 3(超出堆大小 2)→ 调整结束,堆重建完成([2,1])。
  • 第 4 轮(堆大小 = 2,i=1)

    • 交换堆顶(2)和堆尾(i=1,值 1)→ 数组变为[1,2,3,4,5](2 已排序);
    • 堆大小缩小为 1 → 循环结束。

最终结果

数组[5,3,1,2,4]经过堆排序后,变为升序[1,2,3,4,5]

核心总结

堆排序通过 "构建大根堆" 将数组转为 "最大值在顶" 的结构,再通过 "交换堆顶与堆尾 + 重建堆" 的循环,逐步将最大值放到数组末尾,最终实现升序。核心是AdjustDown算法高效维护堆性质,整个过程利用堆的特性减少比较次数,效率远高于简单排序算法。

堆排序算法的时间复杂度和空间复杂度

堆排序的时间复杂度和空间复杂度分析如下,其特性与堆的结构和调整过程密切相关:

空间复杂度

堆排序是原地排序算法,排序过程中仅需要几个临时变量(如交换元素时的临时存储、索引计算变量等),无需额外的数组或数据结构来存储元素。因此:

  • 空间复杂度:O(1)(常数级)。

时间复杂度

堆排序的时间复杂度由两部分组成:构建大根堆的时间排序阶段(提取最大值并重建堆)的时间 ,整体时间复杂度为O(n log n),且在最好、最坏、平均情况下均保持一致。

1. 构建大根堆的时间复杂度:O(n)

构建大根堆时,需从 "最后一个非叶子节点" 开始,向上对每个非叶子节点执行AdjustDown(向下调整)操作。

  • 对于包含n个元素的完全二叉树,高度h ≈ log₂n(堆的高度即树的高度);
  • 每个节点的AdjustDown操作次数与其所在深度相关:深度越大(越靠近叶子)的节点,调整次数越少;
  • 所有节点的调整次数总和为O(n)(数学推导可证明:底层节点数量多但调整次数少,上层节点数量少但调整次数多,整体总和为线性级)。

2. 排序阶段的时间复杂度:O(n*log n)

排序阶段需重复以下操作n-1次:

  • 将堆顶(最大值)与当前堆的最后一个元素交换(O(1));

  • 对新的堆顶执行AdjustDown操作,重建大根堆(堆的大小每次减 1)。

  • i次重建堆时,堆的大小为n-iAdjustDown的时间复杂度为O(log(n-i))(与堆的高度成正比);

  • 总时间为log(n-1) + log(n-2) + ... + log1 ≈ O(n log n)(求和结果近似于n log n)。

3. 整体时间复杂度:O(n*log n)

构建堆的O(n)与排序阶段的O(n*log n)相加,整体时间复杂度由高阶项O(n*log n)主导,因此堆排序的时间复杂度为O(n*log n)

总结

  • 空间复杂度:O(1)(原地排序,仅需常数级额外空间);
  • 时间复杂度:O(n*log n)(最好、最坏、平均情况均为此值,稳定性优于简单排序算法)。

堆排序的优势是时间复杂度稳定(不受初始数据顺序影响),且空间效率高,适合大规模数据的排序场景。


快速排序(huare版本)(默认升序)

快速排序(huare版本)代码实现

复制代码
// Hoare版本的快速排序分区函数:
// 将数组[left, right]区间按基准值分区,左侧元素<=基准值,右侧元素>=基准值
int PartSort_Hoare(int* parr, int left, int right)  
{
    // 选择区间起始位置的元素作为基准值(key),记录其初始索引
    int keyi = left;

    // 当左右指针未相遇时,继续分区操作
    while (left < right)
    {
        // 右指针向左移动:从区间右侧寻找第一个小于基准值的元素
        // 循环条件:左右指针未相遇(left < right),且当前右指针元素>=基准值(不满足则停止移动)
        while ((left < right) && (parr[right] >= parr[keyi]))
            right--;

        // 左指针向右移动:从区间左侧寻找第一个大于基准值的元素
        // 循环条件:左右指针未相遇(left < right),且当前左指针元素<=基准值(不满足则停止移动)
        while ((left < right) && (parr[left] <= parr[keyi]))
            left++;

        // 交换左右指针找到的元素:此时左指针元素>基准值,右指针元素<基准值,交换后维持分区趋势
        Swap(&parr[right], &parr[left]);
    }

    // 当left与right相遇时,该位置即为基准值的最终位置,交换基准值(初始在keyi)与相遇位置元素
    Swap(&parr[keyi], &parr[left]);
    // 返回基准值的最终索引(作为下一次分区的边界)
    return left;
}

// 快速排序递归函数:基于Hoare分区法实现数组升序排序
void QuickSort(int* parr, int begin, int end) 
{
    // 递归终止条件1:若待排序区间为空(begin > end),则无需排序,直接返回
    // 递归终止条件2:若只有一个元素(begin == end),一个元素本身就是有序的,同样无需排序并返回
    if (begin >= end)
        return;

    // 调用Hoare分区函数,对[begin, end]区间进行分区,获取基准值最终索引keyi
    int keyi = PartSort_Hoare(parr, begin, end);

    // 递归排序基准值左侧区间[begin, keyi-1](该区间元素均<=基准值)
    QuickSort(parr, begin, keyi - 1);
    // 递归排序基准值右侧区间[keyi+1, end](该区间元素均>=基准值)
    QuickSort(parr, keyi + 1, end);
}

快速排序(huare版本)算法思想

快速排序(Hoare 分区法)的逻辑与原理(结合代码)

快速排序是典型的 "分治法" 排序算法,核心逻辑是:选择一个基准值(key),通过分区操作将数组分为两部分(左部分≤key,右部分≥key),再递归对两部分进行排序,最终使整个数组有序 。其中,Hoare 分区法通过 "双指针交替移动" 实现分区,关键是右指针(right)先移动,以保证指针相遇位置的元素一定小于基准值,确保分区正确。

1. 核心分区函数PartSort_Hoare:实现区间分区

作用:将[left, right]区间以基准值为界分为两部分,返回基准值的最终索引(用于递归分区)。

  • 基准值选择 :以区间起始位置元素为基准值(keyi = left,即parr[keyi]为基准)。
  • 双指针移动规则
    • right先向左移动:寻找第一个小于基准值 的元素(停在parr[right] < parr[keyi]的位置);
    • left再向右移动:寻找第一个大于基准值 的元素(停在parr[left] > parr[keyi]的位置);
    • 交换leftright指向的元素(确保左半部分尽量小,右半部分尽量大);
    • 重复上述步骤,直到left == right(指针相遇),此时将基准值与相遇位置元素交换,完成分区。

2. 为什么 "right 先走" 能保证相遇位置元素≤基准值?

这是 Hoare 分区法的核心细节,确保分区后基准值左侧均≤基准值,右侧均≥基准值:

  • right先移动:right始终在寻找小于基准值的元素,当left追上right时(left == right),有两种可能:
    1. right找到了小于基准值的元素后停下,left移动到right位置(此时parr[left] = parr[right] < 基准值);
    2. right未找到小于基准值的元素(整个区间都≥基准值),最终right会移动到keyi位置,left也随之移动到keyi(此时parr[left] = 基准值,满足≤基准值)。
  • 因此,相遇位置的元素一定≤基准值,与基准值交换后,基准值左侧均≤它,右侧均≥它,分区逻辑正确。

3. 递归排序函数QuickSort:分治处理子区间

  • 递归终止条件:若待排序区间为空(begin >= end),直接返回;
  • 分区操作:调用PartSort_Hoare获取基准值最终索引keyi
  • 递归处理:分别对[begin, keyi-1](左区间,元素≤基准值)和[keyi+1, end](右区间,元素≥基准值)递归排序,直到所有子区间有序。

示例过程:对parr = [5,3,1,2,4](升序)排序

数组长度为 5,初始调用QuickSort(parr, 0, 4),逐步处理如下:

第一轮分区:处理区间[0,4](元素:5,3,1,2,4

  • 基准值keyi=0parr[keyi]=5)。
  • 指针移动与交换
    • right从 4 开始向左找<5 的元素:parr[4]=4 <5right=4停下;
    • left从 0 开始向右找>5 的元素:parr[0]=5parr[1]=3parr[2]=1parr[3]=2parr[4]=4,均≤5,left移动到 4(与right相遇);
    • 交换基准值(keyi=0)与相遇位置(left=4):数组变为[4,3,1,2,5]
    • 返回keyi=4(基准值 5 的最终位置,右侧无元素,无需递归)。

递归处理左区间[0,3](元素:4,3,1,2

  • 基准值keyi=0parr[keyi]=4)。
  • 指针移动与交换
    • right从 3 向左找<4 的元素:parr[3]=2 <4right=3停下;
    • left从 0 开始向右找>4 的元素:parr[0]=4parr[1]=3parr[2]=1parr[3]=2,均≤4,left移动到 3(与right相遇);
    • 交换基准值(keyi=0)与相遇位置(left=3):数组变为[2,3,1,4,5]
    • 返回keyi=3(基准值 4 的最终位置,右侧无元素,无需递归)。

递归处理左区间[0,2](元素:2,3,1

  • 基准值keyi=0parr[keyi]=2)。
  • 指针移动与交换
    • right从 2 向左找<2 的元素:parr[2]=1 <2right=2停下;
    • left从 0 开始向右找>2 的元素:parr[0]=2parr[1]=3 >2left=1停下;
    • 交换left=1right=2:数组变为[2,1,3,4,5]
    • 继续循环(left=1 < right=2):
      • right向左移动到 1(与left相遇);
    • 交换基准值(keyi=0)与相遇位置(left=1):数组变为[1,2,3,4,5]
    • 返回keyi=1(基准值 2 的最终位置)。

递归处理剩余子区间

  • 左区间[0,0](元素 1)和右区间[2,2](元素 3)均为单个元素,递归直接返回,排序完成。

最终结果

数组[5,3,1,2,4]经过快速排序后,变为升序[1,2,3,4,5]

核心总结

快速排序通过 "分治法 + Hoare 分区" 实现高效排序,关键是 "right 指针先移动" 确保相遇位置元素≤基准值,从而正确分区。递归处理子区间使整个数组逐步有序,其核心优势是平均情况下效率极高(时间复杂度O(n log n))。


快速排序(挖坑法版本)

快速排序(挖坑法版本)代码实现

复制代码
// 挖坑法实现的快速排序分区函数:
// 将数组[left, right]区间按基准值分区,左侧<=基准值,右侧>=基准值
int PartSort_DigHole(int* parr, int left, int right)  
{
    // 选择区间起始位置的元素作为基准值(key),暂存其值
    int key = parr[left];
    // 初始化"坑位"为基准值的初始位置(left),坑位表示需要填充元素的位置
    int hole = left;

    // 当左右指针未相遇时,继续分区操作
    while (left < right)
    {
        // 右指针向左移动:从区间右侧寻找第一个小于基准值key的元素
        // 循环条件:左右指针未相遇(left < right),且当前右指针元素>=key(不满足则停止移动)
        while ((left < right) && (parr[right] >= key))
            right--;

        // 将找到的右侧元素填入当前坑位,此时原right位置变为新的坑位
        parr[hole] = parr[right];
        hole = right;

        // 左指针向右移动:从区间左侧寻找第一个大于基准值key的元素
        // 循环条件:左右指针未相遇(left < right),且当前左指针元素<=key(不满足则停止移动)
        while ((left < right) && (parr[left] <= key))
            left++;

        // 将找到的左侧元素填入当前坑位,此时原left位置变为新的坑位
        parr[hole] = parr[left];
        hole = left;
    }

    // 当left与right相遇时,将基准值key填入最后的坑位,完成分区
    parr[hole] = key; 
    // 返回基准值最终所在的索引(分区点)
    return hole;
}

// 快速排序递归函数:基于挖坑法分区实现数组升序排序
void QuickSort(int* parr, int begin, int end)  
{
    if (begin >= end)
        return;

    int keyi = PartSort_DigHole(parr, begin, end);

    QuickSort(parr, begin, keyi - 1);
    QuickSort(parr, keyi + 1, end);
}

快速排序(挖坑法版本)算法思想

快速排序(挖坑法)的逻辑与原理(结合代码)

挖坑法是快速排序中另一种经典的分区实现方式,核心逻辑仍基于 "分治法":选择基准值,通过 "挖坑 - 填坑" 的过程将区间分为 "左部分≤基准值,右部分≥基准值",再递归排序子区间。与 Hoare 版本相比,其分区过程通过 "动态更新坑位" 实现,因此无需强制右指针先移动,左指针或右指针先走均可,具体逻辑如下:

1. 核心分区函数PartSort_DigHole:"挖坑 - 填坑" 实现分区

作用:将[left, right]区间以基准值为界分区,返回基准值的最终索引(用于递归划分),核心是 "坑位" 的动态更新。

  • 初始设置

    • 选择区间起始元素为基准值(key = parr[left]),并将该位置标记为 "坑位"(hole = left)------"坑位" 表示当前需要被其他元素填充的位置。
  • 分区过程("挖坑 - 填坑" 循环)

    1. 右指针移动与填坑

      • right向左移动,寻找 ** 第一个小于基准值key** 的元素(循环条件:left < rightparr[right] ≥ key);
      • 找到后,将该元素(parr[right])填入当前坑位(parr[hole] = parr[right]),此时right的位置成为 "新的坑位"(hole = right)。
    2. 左指针移动与填坑

      • left向右移动,寻找 ** 第一个大于基准值key** 的元素(循环条件:left < rightparr[left] ≤ key);
      • 找到后,将该元素(parr[left])填入当前坑位(parr[hole] = parr[left]),此时left的位置成为 "新的坑位"(hole = left)。
    3. 循环终止与基准值就位

      • 重复上述步骤,直到left == right(左右指针相遇),此时 "坑位" 与指针相遇位置重合;
      • 将基准值key填入最后的坑位(parr[hole] = key),完成分区 ------ 此时hole左侧元素均≤key,右侧元素均≥key

2. 为什么 "左指针或右指针先走均可"?

这是挖坑法与 Hoare 法的核心区别,根源在于 "坑位" 的动态更新机制:

  • 在 Hoare 法中,指针相遇位置的元素需要直接与基准值交换,因此必须保证相遇位置元素≤基准值(依赖右指针先移动寻找小于基准值的元素);
  • 而在挖坑法中,指针的移动始终是为了 "填充当前坑位"
    • 若先移动右指针:找到小于key的元素填坑,新坑位在right,再移动左指针找大于key的元素填新坑,逻辑自洽;
    • 若先移动左指针:找到大于key的元素填初始坑(left位置),新坑位在left,再移动右指针找小于key的元素填新坑,同样逻辑自洽。
  • 无论哪种顺序,最终指针相遇时的 "坑位" 会被基准值直接填充,无需交换操作,因此无需强制某一指针先移动,只要保证 "右指针找小于key的元素,左指针找大于key的元素" 即可。

3. 递归排序函数QuickSort:分治处理子区间

与 Hoare 版本逻辑一致

核心总结

挖坑法通过 "初始坑位→右指针找小元素填坑→更新坑位→左指针找大元素填坑→更新坑位" 的循环实现分区,核心是 "坑位" 的动态迁移。由于最终基准值是直接填入最后一个坑位(而非交换),因此无需强制右指针先移动,左 / 右指针谁先移动均可保证分区正确性。其本质仍是 "分治法",通过递归将大区间分解为小区间,最终实现整体有序。


快速排序(前后指针法)

快速排序(前后指针法)代码实现

复制代码
// 前后指针法实现的快速排序分区函数:
// 将数组[left, right]区间按基准值分区,左侧元素<基准值,右侧元素>=基准值
int PartSort_BeforeAfterPointer(int* parr, int left, int right)
{
    // 选择区间起始位置的元素作为基准值,记录其初始索引(keyi)
    int keyi = left;
    
    // prev指针:标记小于基准值区域的最后一个元素索引(初始与基准值索引相同)
    int prev = left;
    
    // cur指针:用于遍历区间内的元素(从基准值的下一个元素开始)
    int cur = prev + 1;

    // 遍历区间内从cur到right的所有元素
    while (cur <= right)
    {
        // 若当前cur指向的元素小于基准值,说明该元素应划分到小于基准值的区域
        if (parr[cur] < parr[keyi])
        {
            // prev先向后移动一位(指向小于区域的下一个位置)
            // 若prev移动后与cur不重合(说明中间有大于基准值的元素),则交换prev和cur指向的元素
            // 交换后,prev仍为小于区域的最后一个位置
            if(++prev != cur)
                Swap(&parr[prev], &parr[cur]);
        }

        // cur指针向后移动,继续遍历下一个元素
        cur++;
    }

    // 遍历结束后,prev指向小于基准值区域的最后一个位置,将基准值与prev位置的元素交换
    // 交换后,基准值左侧均为小于它的元素,右侧均为大于等于它的元素
    Swap(&parr[prev], &parr[keyi]);
    // 返回基准值最终所在的索引(分区点)
    return prev;
}

// 快速排序递归函数:基于前后指针法分区实现数组升序排序
void QuickSort(int* parr, int begin, int end)  
{
    if (begin >= end)
        return;

    int keyi = PartSort_BeforeAfterPointer(parr, begin, end);

    QuickSort(parr, begin, keyi - 1);
    QuickSort(parr, keyi + 1, end);
}

快速排序(前后指针法)算法思想

快速排序(前后指针法)的逻辑与原理(结合代码)

前后指针法是快速排序中另一种直观的分区实现方式,核心思想仍是 "分治法":通过两个指针(prevcur)的配合,将区间划分为 "小于基准值的区域" 和 "大于等于基准值的区域",再递归排序子区间。其核心是用prev维护 "小于基准值区域" 的边界,用cur遍历元素并将符合条件的元素纳入该区域,具体逻辑如下:

1. 核心分区函数PartSort_BeforeAfterPointer:前后指针配合实现分区

作用:将[left, right]区间以基准值为界分区,左侧元素均小于基准值,右侧元素均大于等于基准值,返回基准值的最终索引(用于递归划分)。

  • 初始设置

    • 选择区间起始元素为基准值,keyi记录其初始索引(keyi = left);
    • prev指针:标记 "小于基准值区域" 的最后一个元素索引(初始与基准值索引相同,即prev = left,此时小于区域仅包含基准值左侧的空区域);
    • cur指针:用于遍历区间内的元素(从基准值的下一个元素开始,即cur = prev + 1),负责寻找需要纳入 "小于基准值区域" 的元素。
  • 分区过程(前后指针遍历与调整)

    1. 遍历元素curprev + 1开始,逐个遍历到right(覆盖整个待分区区间)。
    2. 判断并纳入小于区域
      • cur指向的元素(parr[cur])小于基准值(parr[keyi]),说明该元素应属于 "小于基准值区域":
        • 先将prev向后移动一位(++prev),使prev指向 "小于区域" 的下一个位置(准备扩展区域);
        • prev移动后与cur不重合(说明prevcur之间存在 "大于等于基准值" 的元素),则交换parr[prev]parr[cur]------ 交换后,cur指向的 "小于基准值" 元素被纳入prev所在的小于区域,prev仍为小于区域的最后一个位置。
    3. 继续遍历 :无论是否交换,cur都向后移动一位(cur++),继续检查下一个元素。
  • 基准值就位 :当cur遍历完所有元素(cur > right)后,prev恰好指向 "小于基准值区域" 的最后一个元素。此时将基准值(parr[keyi])与parr[prev]交换,基准值就被放到了 "小于区域" 和 "大于等于区域" 的分界处 ------ 左侧均为小于基准值的元素,右侧均为大于等于基准值的元素。最终返回prev(基准值的最终索引)。

2. 递归排序函数QuickSort:分治处理子区间

与其他快速排序版本逻辑一致,通过递归将大区间分解为小区间

核心总结

前后指针法通过prev维护 "小于基准值区域" 的边界,cur遍历寻找需纳入该区域的元素,通过 "移动prev+ 必要时交换" 的方式,高效划分区间。其逻辑直观,无需复杂的指针相遇判断,核心是通过指针配合动态扩展 "小于区域",最终将基准值放到正确位置,再递归完成整体排序。


快速排序算法的时间复杂度和空间复杂度

不论是 hoare版本 还是 挖坑法版本前后指针法版本,这些版本算法的本质都是将 [left, right] 区间分为 [左区间] key [右区间],保证左区间小于或等于 key ,保证右区间大于或等于 key,所以拿 hoare 版本举例说明:

快速排序(Hoare 版本)的时间复杂度和空间复杂度分析,与其 "分治法" 的核心逻辑及分区操作的平衡性密切相关,具体如下:

时间复杂度

快速排序的时间复杂度主要取决于分区操作的平衡性 (即每次分区后左右子区间的大小是否接近),而 Hoare 版本的分区过程(双指针交替移动)本身的时间复杂度为O(n)(需遍历整个区间)。

1. 最好情况:每次分区都能将区间均匀分为两部分

若每次分区后,基准值恰好位于区间中间,左右子区间的大小大致相等(如n/2n/2),则递归深度为log n(类似完全二叉树的高度)。

  • 每一层递归的总分区时间为O(n)(各子区间元素总和为n);
  • 总时间复杂度为 **O(n log n)**。

2. 最坏情况:每次分区都将区间分为极端不平衡的两部分

若数组本身已完全有序(或逆序),且选择区间第一个元素作为基准值,每次分区后左区间为空,右区间为n-1个元素(或反之),此时递归深度为n(类似单链表的长度)。

  • 第 1 层分区时间O(n),第 2 层O(n-1),...,第nO(1)
  • 总时间复杂度为 **O(n²)**(求和为n + (n-1) + ... + 1 = n(n+1)/2)。

3. 平均情况:大多数场景下的分区相对平衡

通过数学分析,在随机数据分布下,快速排序的平均递归深度为log n,总时间复杂度由 "各层分区时间总和" 决定,最终为 **O(n log n)**。这也是快速排序在实际应用中高效的核心原因。

空间复杂度

Hoare 版本的分区操作是原地进行 的(仅通过双指针移动和有限次元素交换,无需额外数组存储元素),因此空间复杂度主要来自递归调用的栈空间(用于保存每层递归的区间边界参数)。

1. 最好情况:递归深度为log n

当分区均匀时,递归深度为log n(与完全二叉树高度一致),栈空间仅需存储log n层的参数,因此空间复杂度为 **O(log n)**。

2. 最坏情况:递归深度为n

当分区极端不平衡时,递归深度为n(与单链表长度一致),栈空间需存储n层的参数,因此空间复杂度为 **O(n)**。

总结(Hoare 版本)

  • 时间复杂度

    • 最好情况:O(n log n)(分区均匀);
    • 最坏情况:O(n²)(分区极端不平衡,如有序数组);
    • 平均情况:O(n log n)(随机数据下的典型表现)。
  • 空间复杂度

    • 最好情况:O(log n)(递归栈深度为log n);
    • 最坏情况:O(n)(递归栈深度为n)。

三数取中(规避快速排序算法最坏的情况)

三数取中代码实现

复制代码
// 三数取中函数:从数组的左端点、右端点和中间点三个位置的元素中,选择值为中间大小的元素的索引
// 作用:用于快速排序中优化基准值的选择,避免因基准值为最值导致的效率下降

int GetMidIndex(int* parr, int left, int right) 
{
    // 1. 固定取中间位置的索引(左端点和右端点的中间)
    int mid = (left + right) / 2;

    // 2. 随机取中间位置的索引(在[left, right)范围内随机生成一个索引)
    // int mid = left + (rand() % (right - left));

    // 上述两种计算mid的方式二选一,目的是确定第三个比较位置


    // 判断左端点元素是否为三个元素中的中间值:
    // 若left位置元素大于mid位置元素且小于right位置元素,或小于mid位置元素且大于right位置元素,
    // 则left位置元素是中间值,返回left
    if ((parr[left] > parr[mid] && parr[left] < parr[right]) || (parr[left] < parr[mid] && parr[left] > parr[right]))
        return left;

    // 否则判断右端点元素是否为中间值:
    // 若right位置元素大于mid位置元素且小于left位置元素,或小于mid位置元素且大于left位置元素,
    // 则right位置元素是中间值,返回right
    else if ((parr[right] > parr[mid] && parr[right] < parr[left]) || (parr[right] < parr[mid] && parr[right] > parr[left]))
        return right;

    // 若上述两种情况都不满足,则中间位置(mid)的元素是中间值,返回mid
    else
        return mid;
}

三数取中算法思想

三数取中算法的逻辑与原理(结合代码)

三数取中是快速排序中用于优化基准值选择的策略,核心目的是从区间的三个关键位置(左端点、右端点、中间点)中,选出值为 "中间大小" 的元素作为基准值,避免因基准值是区间的最大值或最小值而导致的分区失衡。其具体逻辑如下:

1. 确定三个比较位置

函数首先确定需要比较的三个位置:

  • 左端点left(区间起始索引);
  • 右端点right(区间结束索引);
  • 中间点mid(可通过两种方式确定,二选一):
    • 固定中间位置:mid = (left + right) / 2(左端点和右端点的算术中间索引);
    • 随机中间位置:mid = left + (rand() % (right - left))(在[left, right)范围内随机生成一个索引,增加随机性)。

2. 选择中间值对应的索引

通过条件判断,从三个位置(leftmidright)的元素中,选出值为 "中间大小" 的元素的索引:

  • parr[left]的值介于parr[mid]parr[right]之间(即大于其中一个且小于另一个),则left是中间值的索引,返回left
  • 否则,若parr[right]的值介于parr[mid]parr[left]之间,返回right
  • 若前两者都不满足,则parr[mid]必然是中间值,返回mid

为什么三数取中能规避快速排序的最坏情况?

快速排序的最坏情况(时间复杂度退化为O(n²))通常发生在每次分区选择的基准值是当前区间的最大值或最小值 ,导致分区后两个子区间极端不平衡(一个子区间为空,另一个子区间仅比原区间少 1 个元素),递归深度变为O(n)(如有序数组中始终选择第一个元素作为基准值)。

三数取中通过以下方式规避这种情况:

  1. 降低选中最值的概率:三个位置的元素中,"中间值" 成为区间最大值或最小值的概率远低于随机选择一个位置(尤其是区间较大时)。例如,在有序数组中,左端点是最小值、右端点是最大值,三数取中会选择中间点的元素(非最值)作为基准值,避免了 "选最值" 的问题;
  2. 保证分区平衡性 :由于基准值是三个位置中的中间值,大概率不会是区间的最值,因此分区后左右子区间的大小会相对均衡(不会出现一个为空的极端情况),递归深度可稳定在O(log n),时间复杂度保持在O(n log n)附近。

核心总结

三数取中通过从区间的左、中、右三个位置中选择中间值作为基准值,显著降低了选中区间最值的概率,从而避免了快速排序中因分区极端不平衡导致的最坏情况,使排序效率更稳定。这是快速排序中最常用的优化手段之一,尤其适用于数据可能接近有序的场景。

三数取中算法的使用

复制代码
int PartSort_BeforeAfterPointer(int* parr, int left, int right)  //前后指针法版本
{
    // 三数取中
	int midi = GetMidIndex(parr, left, right);
	Swap(&parr[left], &parr[midi]);

    // ......
}

int PartSort_DigHole(int* parr, int left, int right)  //挖坑法版本
{
	// 三数取中
	int midi = GetMidIndex(parr, left, right);
	Swap(&parr[left], &parr[midi]);

    // ......
}

int PartSort_Hoare(int* parr, int left, int right)  //Hoare版本
{
	// 三数取中
	int midi = GetMidIndex(parr, left, right);
	Swap(&parr[left], &parr[midi]);

    // ......
}

在各个快速排序版本的分区函数中,首先要通过 GetMidIndex 函数计算出中间值的下标 midi,然后用 Swap 函数交换 parr[midi]parr[left];这样做的原因是,这些分区函数均默认选择 left 位置的元素作为基准值。


快速排序(非递归)

快速排序(非递归)代码实现

复制代码
// 快速排序的非递归实现:使用栈模拟递归过程,避免递归调用带来的栈开销
void QuickSortNonR(int* parr, int begin, int end)
{
    Stack s;                  // 定义栈,用于保存待排序区间的起始和结束索引(模拟递归调用栈)
    InitStack(&s);            // 初始化栈

    // 将初始待排序区间[begin, end]的起始和结束索引压入栈
    // 注意:栈是先进后出结构,先压起始索引,再压结束索引,后续弹出时先取结束索引
    STPush(&s, begin);
    STPush(&s, end);
    
    // 当栈不为空时,说明还有待排序的区间,继续处理
    while (!STEmpty(&s))
    {
        // 弹出栈顶的结束索引(当前待排序区间的end)
        int topEnd = STTop(&s);
        STPop(&s);
        
        // 弹出栈顶的起始索引(当前待排序区间的begin)
        int topBegin = STTop(&s);
        STPop(&s);

        // 对当前区间[topBegin, topEnd]进行分区,获取基准值的最终索引keyi
        int keyi = PartSort_Hoare(parr, topBegin, topEnd);

        // 若基准值右侧区间[keyi+1, topEnd]有效(存在至少一个元素),则将该区间压入栈
        if (keyi + 1 < topEnd)
        {
            STPush(&s, keyi + 1);  // 压入右侧区间的起始索引
            STPush(&s, topEnd);    // 压入右侧区间的结束索引
        }

        // 若基准值左侧区间[topBegin, keyi-1]有效(存在至少一个元素),则将该区间压入栈
        if (keyi - 1 > topBegin)
        {
            STPush(&s, topBegin);  // 压入左侧区间的起始索引
            STPush(&s, keyi - 1);  // 压入左侧区间的结束索引
        }
    }

    STDestroy(&s);  // 排序完成后,销毁栈释放资源
}

栈相关函数请见: 数据结构 --------- C语言实现数组栈_c语言数组实现栈-CSDN博客

快速排序(非递归)算法思想

快速排序(非递归)的逻辑与原理(结合代码)

快速排序的非递归实现核心是用 "栈" 模拟递归过程 :递归版本中,函数调用栈会自动保存每次分区后需要处理的子区间边界;而非递归版本则通过手动定义一个栈,显式存储待排序区间的[begin, end]索引,通过 "弹出区间→分区→压入有效子区间" 的循环,完成与递归版本完全一致的排序逻辑。其优势是避免了递归调用可能导致的栈溢出(尤其对大规模数据),且逻辑更可控。

核心逻辑解析

  1. 栈的作用 :存储待排序区间的起始(begin)和结束(end)索引,利用栈 "先进后出" 的特性,确保子区间的处理顺序与递归一致(先处理后压入的子区间,即深度优先)。
  2. 流程步骤
    • 初始化 :将整个数组的初始区间[begin, end]压入栈(先压begin,再压end,因栈先进后出,弹出时需先取end再取begin)。
    • 循环处理 :当栈不为空时,弹出栈顶区间[topBegin, topEnd],用 Hoare 分区法对其分区,得到基准值的最终索引keyi
    • 压入子区间 :若基准值左侧[topBegin, keyi-1]或右侧[keyi+1, topEnd]存在有效元素(区间内至少 1 个元素),则将这些子区间压入栈。
    • 终止条件:栈为空时,所有区间均已排序,数组有序。

示例过程:对parr = [5,3,1,2,4](升序)非递归快速排序

数组长度size=5,初始区间begin=0end=4,步骤如下(跟踪栈状态和数组变化):

初始状态

  • 数组:[5,3,1,2,4]
  • 栈初始化后,压入begin=0end=4,栈内元素(从上到下):4, 0(栈顶为 4)。

第 1 次循环:处理区间[0,4]

  • 弹出区间 :先弹栈顶4topEnd=4),再弹0topBegin=0),当前处理[0,4]
  • 分区(Hoare 法) :基准值为parr[0]=5right向左找 < 5 的元素(停在index=4,值 4),left向右找 > 5 的元素(未找到,与right相遇于index=4)。交换基准值(index=0)与相遇位置(index=4),数组变为[4,3,1,2,5],基准值5的最终索引keyi=4
  • 压入有效子区间
    • 右侧区间[keyi+1=5, topEnd=4]无效(5>4),不压入;
    • 左侧区间[topBegin=0, keyi-1=3]有效(0<3),压入03,栈内元素(从上到下):3, 0

第 2 次循环:处理区间[0,3]

  • 弹出区间 :弹栈顶3topEnd=3),再弹0topBegin=0),当前处理[0,3]
  • 分区(Hoare 法) :基准值为parr[0]=4right向左找 < 4 的元素(停在index=3,值 2),left向右找 > 4 的元素(未找到,与right相遇于index=3)。交换基准值(index=0)与相遇位置(index=3),数组变为[2,3,1,4,5],基准值4的最终索引keyi=3
  • 压入有效子区间
    • 右侧区间[4,3]无效,不压入;
    • 左侧区间[0,2]有效(0<2),压入02,栈内元素:2, 0

第 3 次循环:处理区间[0,2]

  • 弹出区间 :弹栈顶2topEnd=2),再弹0topBegin=0),当前处理[0,2]
  • 分区(Hoare 法) :基准值为parr[0]=2right向左找 < 2 的元素(停在index=2,值 1),left向右找 > 2 的元素(停在index=1,值 3)。交换left=1right=2,数组变为[2,1,3,4,5];继续循环,right左移至1(与left相遇)。交换基准值(index=0)与相遇位置(index=1),数组变为[1,2,3,4,5],基准值2的最终索引keyi=1
  • 压入有效子区间
    • 右侧区间[2,2]有效(2=2),压入22
    • 左侧区间[0,0]有效(0=0),压入00;栈内元素(从上到下):0,0,2,2

第 4 次循环:处理区间[0,0]

  • 弹出区间 :弹栈顶0topEnd=0),再弹0topBegin=0),区间[0,0]仅含元素1(已有序)。
  • 无有效子区间,不压入栈,栈内元素:2,2

第 5 次循环:处理区间[2,2]

  • 弹出区间 :弹栈顶2topEnd=2),再弹2topBegin=2),区间[2,2]仅含元素3(已有序)。
  • 无有效子区间,不压入栈,栈为空。

最终结果

数组经过上述循环处理后,变为升序:[1,2,3,4,5]

核心总结

非递归快速排序通过栈显式存储待排序区间,完全模拟了递归版本的 "分区→处理左子区间→处理右子区间" 流程,只是将递归调用的隐式栈替换为手动管理的显式栈。其逻辑与递归版本一致,但避免了递归可能带来的栈空间限制,更适合大规模数据排序。

快速排序算法(非递归)的时间复杂度和空间复杂度

快速排序(非递归版本)的时间复杂度和空间复杂度,与递归版本的核心逻辑密切相关(非递归仅用栈模拟递归流程,核心分区操作不变),具体分析如下:

时间复杂度

非递归版本的时间复杂度与递归版本完全一致,因为两者的核心操作(分区过程)和分区次数完全相同,仅递归调用被栈模拟替代,不影响元素的比较和交换次数。

  • 最好情况 :每次分区能将区间均匀分为两部分(左右子区间大小接近),分区层数为O(log n)(类似完全二叉树高度)。每一层的总分区时间为O(n)(所有子区间元素总和为n),因此总时间复杂度为 O(n log n)

  • 最坏情况 :每次分区后区间极端不平衡(如有序数组中基准值为最值,左区间为空,右区间为n-1个元素),分区层数为O(n)(类似单链表长度)。总时间复杂度为 O(n²) (各层分区时间总和为n + (n-1) + ... + 1 = O(n²))。

  • 平均情况 :在随机数据分布下,分区相对平衡,平均层数为O(log n),总时间复杂度为 O(n log n)

空间复杂度

非递归版本的空间复杂度主要来自显式栈存储的待排序区间索引(替代了递归版本的函数调用栈),空间复杂度取决于栈中同时存储的区间数量(即分区的深度)。

  • 最好情况 :分区均匀时,栈中最多存储O(log n)个区间(每层递归对应一个区间,深度为log n),因此空间复杂度为 O(log n)

  • 最坏情况 :分区极端不平衡时,栈中最多存储O(n)个区间(深度为n),因此空间复杂度为 O(n)

总结

  • 时间复杂度 :与递归版本一致,最好和平均情况O(n log n),最坏情况O(n²)(核心分区逻辑不变)。
  • 空间复杂度 :由显式栈的大小决定,最好情况O(log n),最坏情况O(n)(替代了递归栈的空间开销,量级相同)。

非递归版本的优势是避免了递归调用可能导致的栈溢出(尤其对大规模数据),但空间复杂度的量级与递归版本一致。


快速排序(三路划分)

快速排序(三路划分)代码实现

复制代码
// 快速排序(三路划分版本):
// 通过三数取中选择基准值,将数组划分为小于、等于、大于基准值的三部分,优化重复元素较多的场景
void QuickSort_MedianOfThree(int* parr, int begin, int end) 
{
    // 递归终止条件:若待排序区间为空(begin >= end),直接返回
    if (begin >= end)
        return;

    // 定义三个指针:
    // left:标记"小于基准值"区域的结束位置(初始为区间起始)
    // right:标记"大于基准值"区域的开始位置(初始为区间结束)
    // cur:当前遍历元素的索引(从left的下一个位置开始)
    int left = begin;
    int right = end;
    int cur = left + 1;

    // 三数取中:从区间的左、中、右三个位置选中间值作为基准值,优化基准值选择
    int midi = GetMidIndex(parr, left, right);
    // 将选中的中间值交换到left位置,此时left位置元素作为基准值
    Swap(&parr[left], &parr[midi]);
    
    // 保存基准值(key)
    int key = parr[left];

    // 遍历区间内从cur到right的元素,进行三路划分
    while (cur <= right)
    {
        // 情况1:当前元素小于基准值
        if (parr[cur] < key)
        {
            // 将当前元素交换到"小于基准值"区域的末尾(left位置)
            Swap(&parr[cur], &parr[left]);
            // "小于基准值"区域扩大(left后移),继续遍历下一个元素(cur后移)
            cur++;
            left++;
        }
        // 情况2:当前元素大于基准值
        else if (parr[cur] > key)
        {
            // 将当前元素交换到"大于基准值"区域的开头(right位置)
            Swap(&parr[cur], &parr[right]);
            // "大于基准值"区域扩大(right前移),cur不变(需重新判断交换过来的新元素)
            right--;
        }
        // 情况3:当前元素等于基准值
        else
        {
            // 直接跳过(等于基准值的元素留在中间区域),继续遍历下一个元素
            cur++;
        }
    }

    // 递归处理"小于基准值"的区间[begin, left-1]
    QuickSort_MedianOfThree(parr, begin, left - 1);

    // 递归处理"大于基准值"的区间[right+1, end]
    // (中间[left, right]区域为等于基准值的元素,已无需排序)
    QuickSort_MedianOfThree(parr, right + 1, end);
}

快速排序(三路划分)算法思想

快速排序(三路划分)的逻辑与原理(结合代码)

三路划分快速排序是对传统快速排序的优化,核心思想是将数组划分为三个区间:小于基准值、等于基准值、大于基准值,通过减少重复元素参与递归的次数,显著优化 "存在大量重复数据" 场景的排序效率。其逻辑和原理如下:

1. 核心逻辑解析

  • 分区目标 :将待排序区间[begin, end]划分为三部分:
    • [begin, left-1]:所有元素小于基准值(key);
    • [left, right]:所有元素等于基准值(key);
    • [right+1, end]:所有元素大于基准值(key)。
  • 指针作用
    • left:标记 "小于基准值" 区域的结束位置(初始为begin);
    • right:标记 "大于基准值" 区域的开始位置(初始为end);
    • cur:当前遍历元素的索引(从left+1开始,负责扫描整个区间)。
  • 基准值选择 :通过 "三数取中"(GetMidIndex)选择基准值,避免因基准值为最值导致的分区失衡。
  • 遍历与划分cur从左到右扫描元素,根据元素与基准值的关系(小于、等于、大于),通过交换调整leftrightcur的位置,最终形成三个区间。
  • 递归处理:仅对 "小于基准值" 和 "大于基准值" 的区间递归排序,"等于基准值" 的区间已无需处理(天然有序)。

2. 三路划分的作用:解决大量重复数据问题

传统快速排序(二路划分)在遇到大量重复元素时,会将 "等于基准值" 的元素全部划入某一侧(左或右),导致子区间仍包含大量重复元素,递归次数增多,甚至退化为O(n²)

三路划分通过将 "等于基准值" 的元素独立为中间区域,使其不再参与后续递归,直接减少了需要排序的元素数量:

  • 重复元素越多,中间区域越大,递归处理的子区间越小,效率提升越明显;
  • 避免了重复元素在子区间中反复被比较和交换,降低了时间复杂度(在重复元素极多时可接近O(n))。

示例过程:对parr = [2,3,3,5,3,1](升序)三路划分快速排序

数组长度size=6,初始区间begin=0end=5,步骤如下:

第一轮:处理区间[0,5](元素:[2,3,3,5,3,1]

  • 步骤 1:三数取中选基准值

    • 左(0,值 2)、中(mid=(0+5)/2=2,值 3)、右(5,值 1);
    • 三个值为2,3,1,中间值为21<2<3),故midi=0
    • 交换后基准值仍在left=0key=2)。
  • 步骤 2:三路划分(left=0right=5cur=1

    • cur=1:元素3>key=2 → 交换cur=1right=5(值 1),数组变为[2,1,3,5,3,3]right=4cur保持 1。
    • cur=1:元素1<key=2 → 交换cur=1left=0(值 2),数组变为[1,2,3,5,3,3]left=1cur=2
    • cur=2:元素3>key=2 → 交换cur=2right=4(值 3),数组不变(3=3);right=3cur=2
    • cur=2:元素3>key=2 → 交换cur=2right=3(值 5),数组变为[1,2,5,3,3,3]right=2cur=2
    • cur=2:元素5>key=2 → 交换cur=2right=2(自身),right=1;此时cur=2 > right=1,循环结束。
  • 划分结果

    • 小于区域:[begin, left-1] = [0,0](元素1);
    • 等于区域:[left, right] = [1,1](元素2);
    • 大于区域:[right+1, end] = [2,5](元素5,3,3,3)。

第二轮:递归处理大于区域[2,5](元素:[5,3,3,3]

  • 步骤 1:三数取中选基准值

    • 左(2,值 5)、中(mid=(2+5)/2=3,值 3)、右(5,值 3);
    • 三个值为5,3,3,中间值为33<3<5),故midi=3
    • 交换left=2midi=3,数组变为[1,2,3,5,3,3],基准值key=3
  • 步骤 2:三路划分(left=2right=5cur=3

    • cur=3:元素5>key=3 → 交换cur=3right=5(值 3),数组变为[1,2,3,3,3,5]right=4cur=3
    • cur=3:元素3=key=3cur=4
    • cur=4:元素3=key=3cur=5;此时cur=5 > right=4,循环结束。
  • 划分结果

    • 小于区域:[2,1](无效,因left=2 > left-1=1);
    • 等于区域:[left, right] = [2,4](元素3,3,3);
    • 大于区域:[5,5](元素5)。

第三轮:递归处理剩余区间

  • 小于区域[0,0]和大于区域[5,5]均为单个元素,递归直接返回,排序完成。

最终结果

数组经过三路划分排序后,变为升序:[1,2,3,3,3,5]

核心总结

三路划分通过将数组分为 "小于、等于、大于基准值" 三部分,使重复元素集中在中间区域且不参与递归,大幅优化了重复数据场景的效率。其核心是减少无效递归和比较,在重复元素较多时优势显著,是快速排序应对复杂数据分布的重要优化手段。

快速排序(三路划分)与 快速排序(hoare版本)时间、空间复杂度比较

快速排序的三路划分版本与 Hoare 版本(二路划分)的时间复杂度和空间复杂度在量级上是一致的,但在特定场景(如存在大量重复元素)下,三路划分的实际效率会更高。具体分析如下:

时间复杂度对比

两者的时间复杂度量级相同,核心由 "分区操作的平衡性" 和 "递归深度" 决定,但三路划分在重复元素较多时能减少无效操作:

  • 最好情况 :均为O(n log n)当分区均匀时(左右子区间大小接近),递归深度为O(log n),每层分区操作的总时间为O(n)(遍历所有元素),因此总时间复杂度为O(n log n)

  • 平均情况 :均为O(n log n)在随机数据分布下,两种版本的分区都能保持相对平衡,递归深度稳定在O(log n),总时间复杂度由 "各层分区时间总和" 决定,均为O(n log n)

  • 最坏情况 :理论上均为O(n²)

    • Hoare 版本:当数组有序(或含大量重复元素)且基准值选择不佳时,分区会极端不平衡(子区间大小为n-1),递归深度为O(n),总时间退化为O(n²)
    • 三路划分版本:若数组无重复元素且基准值选择不佳(如始终选最值),分区效果与 Hoare 版本一致,最坏时间复杂度仍为O(n²);但当存在大量重复元素时 ,三路划分会将重复元素划入中间 "等于基准值" 区域,不参与后续递归,实际最坏情况会优于 Hoare 版本(接近O(n))。

空间复杂度对比

两者的空间复杂度完全一致,均由递归调用的栈空间决定:

  • 最好情况 :均为O(log n)当分区均匀时,递归深度为O(log n),栈空间仅需存储O(log n)层的区间参数。

  • 最坏情况 :均为O(n)当分区极端不平衡时,递归深度为O(n),栈空间需存储O(n)层的参数。

核心区别:实际效率的优化

三路划分版本的优势并非改变复杂度量级,而是在存在大量重复元素时减少无效递归和比较操作

  • Hoare 版本(二路划分)会将 "等于基准值" 的元素全部划入某一侧(左或右),导致这些元素在后续递归中被反复处理;
  • 三路划分将 "等于基准值" 的元素独立为中间区域,使其不参与后续递归,直接减少了需要排序的元素数量,在重复元素较多时实际运行时间更短(但复杂度量级不变)。

总结

  • 时间复杂度 :量级相同(最好、平均O(n log n),最坏O(n²)),但三路划分在大量重复元素场景下实际效率更高。
  • 空间复杂度 :完全一致(最好O(log n),最坏O(n))。

两者的核心差异在于对重复元素的处理效率,而非复杂度量级。


归并排序

归并排序代码实现

复制代码
// 归并排序的递归辅助函数:实现归并排序的核心逻辑(分治与合并)
void _MergeSort(int* parr, int* tmp, int begin, int end)
{
    // 递归终止条件:若待排序区间为空(begin >= end),直接返回(单个元素或空区间无需排序)
    if (begin >= end)
        return;

    // 计算区间中点,将当前区间分为左右两个子区间
    int mid = (begin + end) / 2;
    
    // 递归排序左子区间 [begin, mid]
    _MergeSort(parr, tmp, begin, mid);

    // 递归排序右子区间 [mid+1, end]
    _MergeSort(parr, tmp, mid + 1, end);

    // 合并两个已排序的子区间:[begin, mid] 和 [mid+1, end]
    // 定义左子区间的起止索引
    int begin1 = begin, end1 = mid;

    // 定义右子区间的起止索引
    int begin2 = mid + 1, end2 = end;
    
    // i 用于记录临时数组 tmp 中当前待填充的位置(从区间起始位置 begin 开始)
    int i = begin;

    // 同时遍历两个子区间,将较小的元素依次放入临时数组 tmp
    while ((begin1 <= end1) && (begin2 <= end2))
    {
        // 若左子区间当前元素 <= 右子区间当前元素,取左子区间元素放入 tmp
        if (parr[begin1] <= parr[begin2])
            tmp[i++] = parr[begin1++];

        // 否则取右子区间元素放入 tmp
        else
            tmp[i++] = parr[begin2++];
    }

    // 左子区间还有剩余元素,将剩余元素依次放入 tmp
    while (begin1 <= end1)
        tmp[i++] = parr[begin1++];

    // 右子区间还有剩余元素,将剩余元素依次放入 tmp
    while (begin2 <= end2)
        tmp[i++] = parr[begin2++];

    // 将临时数组 tmp 中合并好的有序区间 [begin, end] 复制回原数组 parr 的对应位置
    memcpy(parr + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}

// 归并排序主函数(递归实现):对数组进行升序排序
void MergeSort(int* parr, int size)
{
    // 分配临时数组 tmp,用于合并过程中暂存元素(大小与原数组相同)
    int* tmp = (int*)malloc(sizeof(int) * size);

    // 检查内存分配是否成功,失败则打印错误信息并返回
    if (tmp == NULL)
    {
        perror("MergeSort.malloc");
        return;
    }

    // 调用递归辅助函数,从区间 [0, size-1] 开始排序
    _MergeSort(parr, tmp, 0, size - 1);

    // 排序完成后,释放临时数组的内存
    free(tmp);
}

归并排序算法思想

归并排序的逻辑与原理(结合代码)

归并排序是典型的 "分治法" 排序算法,核心思想是:将数组不断 "分解" 为两个等大(或接近等大)的子区间,直到每个子区间仅含一个元素(天然有序),再逐步 "合并" 这些有序子区间,最终得到完整的有序数组。其核心是 "合并" 操作 ------ 将两个已排序的子区间高效合并为一个更大的有序区间。

1. 核心逻辑解析

归并排序的流程分为 "分解" 和 "合并" 两个阶段,代码通过递归实现:

  • 分解阶段(递归拆分) :在辅助函数_MergeSort中,通过计算区间中点mid = (begin + end) / 2,将当前区间[begin, end]拆分为左子区间[begin, mid]和右子区间[mid+1, end],并递归对两个子区间排序,直到子区间为空(begin >= end,即单个元素或空区间,无需排序)。

  • 合并阶段(有序合并):当左右子区间均已排序后,通过以下步骤合并为一个有序区间:

    1. 定义左子区间边界[begin1, end1]和右子区间边界[begin2, end2]
    2. 使用临时数组tmp暂存合并结果:同时遍历两个子区间,将较小的元素依次放入tmp
    3. 将剩余未遍历完的子区间元素(左或右)依次放入tmp
    4. memcpytmp中合并好的有序区间复制回原数组parr的对应位置,完成合并。
  • 临时数组的作用 :合并时若直接在原数组操作,会覆盖未处理的元素,因此tmp用于暂存中间结果,确保合并过程正确。

示例过程:对parr = [5,3,1,2,4](升序)归并排序

数组长度size=5,初始调用MergeSort(parr, 5),分配临时数组tmp后,进入_MergeSort(parr, tmp, 0, 4),步骤如下:

阶段 1:分解(递归拆分区间)

分解过程是 "自顶向下" 的,将大区间逐步拆分为最小子区间(单个元素):

  • 初始区间[0,4] → 拆分为左[0,2]和右[3,4]
    • [0,2] → 拆分为左[0,1]和右[2,2]
      • [0,1] → 拆分为左[0,0](元素 5)和右[1,1](元素 3);
      • [2,2](元素 1)→ 无需拆分(递归终止);
    • [3,4] → 拆分为左[3,3](元素 2)和右[4,4](元素 4)→ 均无需拆分(递归终止)。

阶段 2:合并(自底向上合并有序子区间)

合并过程从最小子区间开始,逐步向上合并为更大的有序区间:

合并[0,0](5)和[1,1](3)→ 得到[3,5]

  • 左子区间begin1=0, end1=0(5),右子区间begin2=1, end2=1(3);
  • 遍历比较:3 < 5 → tmp[0] = 3begin2=2,右区间结束);
  • 剩余左区间元素:tmp[1] = 5begin1=1,左区间结束);
  • tmp[0,1][3,5],复制回parrparr变为[3,5,1,2,4]

合并[0,1](3,5)和[2,2](1)→ 得到[1,3,5]

  • 左子区间begin1=0, end1=1(3,5),右子区间begin2=2, end2=2(1);
  • 遍历比较:1 < 3 → tmp[0] = 1begin2=3,右区间结束);
  • 剩余左区间元素:tmp[1] = 3tmp[2] = 5begin1=2,左区间结束);
  • tmp[0,2][1,3,5],复制回parrparr变为[1,3,5,2,4]

合并[3,3](2)和[4,4](4)→ 得到[2,4]

  • 左子区间begin1=3, end1=3(2),右子区间begin2=4, end2=4(4);
  • 遍历比较:2 < 4 → tmp[3] = 2begin1=4,左区间结束);
  • 剩余右区间元素:tmp[4] = 4begin2=5,右区间结束);
  • tmp[3,4][2,4],复制回parrparr变为[1,3,5,2,4](原右区间位置更新)。

合并[0,2](1,3,5)和[3,4](2,4)→ 得到[1,2,3,4,5]

  • 左子区间begin1=0, end1=2(1,3,5),右子区间begin2=3, end2=4(2,4);
  • 遍历比较:
    • 1 < 2 → tmp[0] = 1begin1=1);
    • 3 > 2 → tmp[1] = 2begin2=4);
    • 3 < 4 → tmp[2] = 3begin1=2);
    • 5 > 4 → tmp[3] = 4begin2=5,右区间结束);
  • 剩余左区间元素:tmp[4] = 5begin1=3,左区间结束);
  • tmp[0,4][1,2,3,4,5],复制回parr → 最终数组变为[1,2,3,4,5]

最终结果

数组[5,3,1,2,4]经过归并排序后,变为升序[1,2,3,4,5]

核心总结

归并排序通过 "分治法" 将数组分解为最小子区间,再通过 "有序合并" 逐步构建完整有序数组,核心是合并两个有序子区间的高效操作。

归并排序算法的时间复杂度和空间复杂度

归并排序的时间复杂度和空间复杂度与其 "分治法" 的核心逻辑密切相关,具体分析如下:

时间复杂度

归并排序的时间复杂度由 "分解" 和 "合并" 两个阶段共同决定,且不受输入数据的初始顺序影响(最好、最坏、平均情况完全一致)。

  • 分解阶段 :将数组不断拆分为两个等大(或接近等大)的子区间,直到子区间仅含 1 个元素(天然有序)。对于长度为n的数组,分解的层数为log₂n(类似完全二叉树的高度),例如n=8时需要分解 3 层(8→4→2→1),分解过程本身不涉及元素比较,时间开销可忽略。

  • 合并阶段 :每一层的合并操作需要遍历当前层的所有元素(将两个有序子区间合并为一个有序区间),总操作次数为O(n)(每一层的元素总数始终是n)。

  • 总时间复杂度 :分解层数为O(log n),每层合并时间为O(n),因此总时间复杂度为 O(n log n),且在最好、最坏、平均情况下均保持一致(这是归并排序的显著优势)。

空间复杂度

归并排序的空间复杂度主要来自合并阶段所需的临时数组递归调用的栈空间,其中临时数组是主导因素。

  • 临时数组 :合并两个有序子区间时,需要一个与原数组大小相同的临时数组(tmp)暂存合并结果(避免覆盖原数组中未处理的元素),因此临时数组的空间开销为O(n)

  • 递归栈空间 :递归分解过程中,函数调用栈的深度为O(log n)(与分解层数一致),栈空间开销为O(log n)

  • 总空间复杂度 :由于临时数组的空间开销(O(n))远大于递归栈空间(O(log n)),因此归并排序的空间复杂度为 O(n)

总结

  • 时间复杂度O(n log n)(最好、最坏、平均情况完全一致,不受输入数据顺序影响);
  • 空间复杂度O(n)(主要来自合并阶段的临时数组)。

归并排序的优势是时间复杂度稳定且为稳定排序(相等元素的相对顺序不变),但缺点是需要额外的O(n)空间,不适合对内存空间敏感的场景。


小区间优化(规避归并排序递归太深的情况)

小区间优化代码实现

复制代码
// 小区间优化:当待排序区间的元素个数(end - begin + 1)小于10时
// 不再继续递归进行归并排序,而是改用直接插入排序

if (end - begin + 1 < 10)
{
    // 对当前区间[begin, end]使用直接插入排序(参数为区间起始地址和元素个数)
    InsertSort(parr + begin, end - begin + 1);
    
    // 排序完成后返回,不再继续递归分治
    return; 
}

小区间优化算法思想

小区间优化的逻辑与原理

小区间优化是归并排序中针对 "小范围数据排序效率" 的优化策略,核心逻辑是:当待排序区间的元素数量小于某个阈值(如代码中的 10)时,不再继续递归执行归并排序的 "分解 - 合并" 流程,而是改用直接插入排序对该区间进行排序。

其原理基于 "不同排序算法的效率特性差异":归并排序的O(n log n)时间复杂度是理论上的渐进复杂度(适用于大数据量),但它包含递归调用(栈开销)和合并操作(临时数组读写、遍历比较)等固定开销;而当数据量极小时,这些固定开销在总耗时中的占比会显著升高,导致归并排序的实际效率反而低于一些简单排序算法。因此,对小范围数据切换为更轻量的排序算法,可减少整体耗时。

为什么选择直接插入排序而非其他排序

在小范围数据场景下,直接插入排序相比冒泡排序、选择排序等简单排序更适合,原因如下:

  1. 操作轻量:直接插入排序的核心是 "找到插入位置并移动元素",仅涉及简单的循环和赋值,无频繁交换(相比冒泡排序)或多次遍历找最值(相比选择排序),常数因子(实际执行的指令数)更小。
  2. 适应性好:若小范围数据本身接近有序(实际场景中常见),直接插入排序的比较和移动次数会大幅减少,效率优势更明显;而冒泡、选择排序的比较 / 交换次数相对固定,适应性较差。
  3. 无额外空间 :直接插入排序是原地排序(空间复杂度O(1)),无需像归并排序那样依赖临时数组,在小范围数据上的空间开销可忽略,进一步降低了操作成本。

小区间优化如何解决归并排序递归太深的问题

归并排序的递归深度由数据量决定,对于规模为n的数组,递归深度为O(log n)(每次分解为两半)。当n极大时(如百万级以上),递归深度会显著增加(如n=10^6时,深度约 20),可能导致函数调用栈空间紧张(甚至栈溢出)。

小区间优化通过设定阈值(如 10),限制了递归的最大深度:当区间元素数量小于阈值时,停止递归,改用非递归的直接插入排序。此时,递归仅需分解到 "区间大小≈阈值" 的层级即可,递归深度变为O(log(n/阈值)),相比原深度大幅减少(如n=10^6、阈值 = 10 时,深度约log(10^5)≈17,减少了不必要的深层递归),从而避免了递归太深导致的栈空间问题。

小区间优化算法的使用

复制代码
void _MergeSort(int* parr, int* tmp, int begin, int end)
{
	// 递归终止条件:若待排序区间为空(begin >= end),直接返回(单个元素或空区间无需排序)
	if (begin >= end)
		return;

	// 小区间优化:当待排序区间的元素个数(end - begin + 1)小于10时
	// 不再继续递归进行归并排序,而是改用直接插入排序
	// 原因:直接插入排序在数据量较小时效率更高(递归和合并操作的开销相对更大)
	// 此优化可减少归并排序在小范围数据上的性能损耗,提升整体排序效率
	if (end - begin + 1 < 10)
	{
		// 对当前区间[begin, end]使用直接插入排序(参数为区间起始地址和元素个数)
		InsertSort(parr + begin, end - begin + 1);
		
		// 排序完成后返回,不再继续递归分治
		return; 
	}

	// ......
}

计数排序

计算排序代码实现

复制代码
void CountSort(int* parr, int size)
{
	// 初始化最大值和最小值为数组第一个元素,用于确定数据范围
	int max = parr[0];
	int min = parr[0];

	// 遍历数组,更新最大值和最小值,找到数据的实际范围
	for (int i = 0; i < size; i++)
	{
		if (parr[i] > max)
			max = parr[i];
		if (parr[i] < min)
			min = parr[i];
	}

	// 计算计数数组的大小(范围 = 最大值 - 最小值 + 1)
	int range = max - min + 1;
	// 动态分配计数数组,calloc初始化所有元素为0
	int* countArr = (int*)calloc(range, sizeof(int));
	// 检查内存分配是否失败
	if (countArr == NULL)
	{
		perror("CountSort.malloc"); // 打印错误信息
		return;
	}

	// 统计每个元素出现的次数:将元素值映射为计数数组的索引(减去min)
	for (int i = 0; i < size; i++)
		countArr[parr[i] - min]++;

	// 用于记录原数组中当前填充位置的索引
	int parri = 0;
	// 根据计数数组重建原数组,生成有序序列
	for (int i = 0; i < range; i++)
	{
		// 当计数数组当前位置的计数大于0时,持续填充对应元素(i + min)
		while (countArr[i]--)
		{
			parr[parri++] = i + min;
		}
	}
}

计数排序算法思想

计数排序的逻辑与原理(结合代码)

计数排序是一种非比较型排序算法 ,核心思想是通过 "统计每个元素的出现次数",再根据次数直接重建有序数组。它适用于整数类型数据数据范围(最大值与最小值的差值)相对较小 的场景,优势是时间复杂度极低(O(n + range)),但依赖于数据的分布范围。其逻辑和原理如下:

1. 核心逻辑解析

计数排序的流程可分为 4 个关键步骤:

  • 步骤 1:确定数据范围 遍历待排序数组,找到最大值(max)和最小值(min),计算数据的范围(range = max - min + 1)。这一步的目的是确定 "需要统计的数值区间",为后续创建计数数组提供大小依据。

  • 步骤 2:创建计数数组 基于数据范围range创建一个计数数组(countArr),用于记录每个元素出现的次数。数组初始化为 0(通过calloc分配内存,自动初始化为 0)。

  • 步骤 3:统计元素出现次数 遍历原数组,将每个元素值映射为计数数组的索引(映射规则:元素值 - min),并对该索引位置的计数加 1。例如,若min=1,元素值为 3,则映射到countArr[2](3-1=2),表示值为 3 的元素出现次数加 1。

  • 步骤 4:根据计数数组重建有序数组 遍历计数数组,对于每个索引i(对应元素值i + min),根据其计数countArr[i],将元素i + min重复countArr[i]次依次放入原数组,最终得到有序数组。

示例过程:对parr = [5,3,1,2,4](升序)计数排序

数组长度size=5,步骤如下:

步骤 1:确定数据范围(maxmin

  • 初始max = parr[0] = 5min = parr[0] = 5
  • 遍历数组:
    • parr[1]=3:3 < 5 → min=3
    • parr[2]=1:1 < 3 → min=1
    • parr[3]=2:2 > 1 但 < 5 → maxmin不变;
    • parr[4]=4:4 < 5 → max不变;
  • 最终max=5min=1,数据范围range = 5 - 1 + 1 = 5

步骤 2:创建计数数组

  • 分配range=5的计数数组countArr,初始化为[0,0,0,0,0](索引 0~4 分别对应元素值 1~5,因i + min = 0+1=11+1=2,...,4+1=5)。

步骤 3:统计元素出现次数

  • 遍历原数组,映射索引并计数:
    • parr[0]=55 - 1 = 4countArr[4]++countArr变为[0,0,0,0,1]
    • parr[1]=33 - 1 = 2countArr[2]++countArr变为[0,0,1,0,1]
    • parr[2]=11 - 1 = 0countArr[0]++countArr变为[1,0,1,0,1]
    • parr[3]=22 - 1 = 1countArr[1]++countArr变为[1,1,1,0,1]
    • parr[4]=44 - 1 = 3countArr[3]++countArr最终为[1,1,1,1,1]

步骤 4:重建有序数组

  • 遍历countArr,根据计数填充原数组:
    • i=0(对应元素0+1=1):countArr[0]=1 → 填充parr[0]=1parri=1
    • i=1(对应元素1+1=2):countArr[1]=1 → 填充parr[1]=2parri=2
    • i=2(对应元素2+1=3):countArr[2]=1 → 填充parr[2]=3parri=3
    • i=3(对应元素3+1=4):countArr[3]=1 → 填充parr[3]=4parri=4
    • i=4(对应元素4+1=5):countArr[4]=1 → 填充parr[4]=5parri=5

最终结果

数组[5,3,1,2,4]经过计数排序后,变为升序[1,2,3,4,5]

核心总结

计数排序通过 "统计次数→重建数组" 的方式实现排序,无需元素间的比较,因此时间效率极高。但其局限性在于:仅适用于整数,且数据范围(range)不能过大(否则计数数组会占用过多内存)。核心是利用 "元素值与计数数组索引的映射",将无序数据转化为有序的计数统计,再反向构建结果。

计数排序的时间复杂度和空间复杂度

计数排序的时间复杂度和空间复杂度与其 "统计元素次数并重建有序数组" 的核心逻辑密切相关,且高度依赖于数据的范围(即最大值与最小值的差值),具体分析如下:

时间复杂度

计数排序的时间消耗主要来自 4 个关键步骤,整体时间复杂度由 "数组长度n" 和 "数据范围range"(range = max - min + 1)共同决定:

  1. 确定最大值和最小值 :遍历整个数组(n个元素),时间复杂度为O(n)
  2. 创建计数数组 :内存分配操作的时间可忽略(不随nrange增长);
  3. 统计元素出现次数 :再次遍历整个数组(n个元素),时间复杂度为O(n)
  4. 重建有序数组 :遍历计数数组(range个索引),并根据每个索引的计数填充原数组(总填充次数为n),时间复杂度为O(range + n)

总时间复杂度为上述步骤的总和:O(n) + O(n) + O(range + n) = O(n + range)

空间复杂度

计数排序的空间消耗主要来自计数数组的内存分配 ,其大小等于数据范围rangerange = max - min + 1)。因此,额外空间复杂度为O(range)

原数组本身的空间(O(n))不计入额外空间,因为排序过程是在原数组上重建结果,无需额外存储整个原数组的副本。

关键特性与局限性

  • 当数据范围range远小于数组长度n时(如range ≈ n),时间复杂度接近O(n),空间复杂度也较低(O(n)),此时计数排序效率极高;
  • 当数据范围range远大于n时(如range = 10^6n = 100),时间和空间复杂度会退化为O(range),远高于比较型排序的O(n log n),此时计数排序不适用。

总结

  • 时间复杂度O(n + range)n为数组长度,range为数据范围max - min + 1);
  • 空间复杂度O(range)(主要来自计数数组的额外空间)。

计数排序的效率优势仅在 "数据范围较小" 的场景下体现,这是其核心局限性。

相关推荐
Hello_Embed2 小时前
FreeRTOS 入门(四):堆的核心原理
数据结构·笔记·学习·链表·freertos·
烧冻鸡翅QAQ3 小时前
考研408笔记——数据结构
数据结构·笔记·考研
异步的告白3 小时前
C语言-数据结构-2-单链表程序-增删改查
c语言·开发语言·数据结构
超级无敌大学霸3 小时前
二分查找和辗转相除法
c语言·算法
Gorgous—l4 小时前
数据结构算法学习:LeetCode热题100-图论篇(岛屿数量、腐烂的橘子、课程表、实现 Trie (前缀树))
数据结构·学习·算法
云知谷5 小时前
【软件测试】《集成测试全攻略:Mock/Stub 原理 + Postman/JUnit/TestNG 实战》
c语言·开发语言·c++·软件工程·团队开发
热心市民小刘05056 小时前
11.18二叉树中序遍历(递归)
数据结构·算法
未若君雅裁6 小时前
LeetCode 18 - 四数之和 详解笔记
java·数据结构·笔记·算法·leetcode
小欣加油7 小时前
leetcode 429 N叉树的层序遍历
数据结构·c++·算法·leetcode·职场和发展