校招必看:八大排序算法原理、复杂度与高频面试题

排序算法详解:从入门到精通


🌈 say-fall:个人主页 🚀 专栏:《手把手教你学会C++》 | 《系统深入Linux操作系统》 | 《数据结构与算法》 | 《小游戏与项目》 💪 格言:做好你自己,才能吸引更多人,与他们共赢,这才是最好的成长方式。

📝 前言

提到排序,很多人的第一反应就是调库------C++的sort、Python的sorted、Java的Arrays.sort,一行代码搞定。但是,你是否真正理解这些排序函数背后的原理?如果你在面试中被问到"快速排序的最坏时间复杂度是多少",或者"为什么归并排序是稳定的",你能回答上来吗?

排序算法是数据结构中最基础、最重要的内容之一。无论是考研、求职面试,还是实际开发中的性能优化,排序算法都是必考知识点。更重要的是,排序算法蕴含了许多经典的算法思想:分治、递归、贪心、堆等,掌握排序算法能让你对算法设计有更深刻的理解。

不过不用担心------本文将系统地讲解八大经典排序算法,从最简单的冒泡排序到高效的快速排序、归并排序,每种算法都会给出详细的图解、代码实现和复杂度分析,让你真正理解排序的本质。

通过本文,你将掌握:

技能 应用场景
冒泡排序、选择排序、插入排序 理解排序基本思想,小规模数据排序
希尔排序 插入排序的优化版本,理解增量序列的作用
快速排序(递归与非递归实现) 面试必考,理解分治思想,掌握三种partition方法
归并排序(递归与非递归实现) 理解分治思想,掌握外部排序的核心
堆排序 理解堆结构,掌握优先队列的基础
计数排序 理解非比较排序的思想,处理范围固定的整数排序

📌 前置知识: 需要掌握C语言基础语法、数组操作、函数调用、递归的基本概念。

文章目录

  • 排序算法详解:从入门到精通
    • [📝 前言](#📝 前言)
    • [一、🔥 排序算法概览](#一、🔥 排序算法概览)
      • [1.1 按比较方式分类](#1.1 按比较方式分类)
      • [1.2 按稳定性分类](#1.2 按稳定性分类)
      • [1.3 时间复杂度对比](#1.3 时间复杂度对比)
    • [二、🔷 冒泡排序](#二、🔷 冒泡排序)
      • [2.1 算法思想](#2.1 算法思想)
      • [2.2 动图演示](#2.2 动图演示)
      • [2.3 代码实现](#2.3 代码实现)
      • [2.4 复杂度分析](#2.4 复杂度分析)
      • [2.5 优化版本](#2.5 优化版本)
    • [三、🔷 选择排序](#三、🔷 选择排序)
      • [3.1 算法思想](#3.1 算法思想)
      • [3.2 动图演示](#3.2 动图演示)
      • [3.3 代码实现](#3.3 代码实现)
      • [3.4 复杂度分析](#3.4 复杂度分析)
    • [四、🔷 插入排序](#四、🔷 插入排序)
      • [4.1 算法思想](#4.1 算法思想)
      • [4.2 动图演示](#4.2 动图演示)
      • [4.3 代码实现](#4.3 代码实现)
      • [4.4 复杂度分析](#4.4 复杂度分析)
    • [五、🔷 希尔排序](#五、🔷 希尔排序)
      • [5.1 算法思想](#5.1 算法思想)
      • [5.2 增量序列的作用](#5.2 增量序列的作用)
      • [5.3 代码实现](#5.3 代码实现)
      • [5.4 复杂度分析](#5.4 复杂度分析)
    • [六、🔷 堆排序](#六、🔷 堆排序)
      • [6.1 堆的基本概念](#6.1 堆的基本概念)
      • [6.2 算法思想(升序使用大根堆)](#6.2 算法思想(升序使用大根堆))
      • [6.3 向下调整算法](#6.3 向下调整算法)
      • [6.4 堆排序实现](#6.4 堆排序实现)
      • [6.5 复杂度分析](#6.5 复杂度分析)
    • [七、🔷 快速排序](#七、🔷 快速排序)
      • [7.1 算法思想](#7.1 算法思想)
      • [7.2 三种 Partition 方法](#7.2 三种 Partition 方法)
        • [7.2.1 Hoare 法(左右指针法)](#7.2.1 Hoare 法(左右指针法))
        • [7.2.2 挖坑法](#7.2.2 挖坑法)
        • [7.2.3 双指针法(前后指针法)](#7.2.3 双指针法(前后指针法))
      • [7.3 快速排序主体](#7.3 快速排序主体)
      • [7.4 三数取中优化](#7.4 三数取中优化)
      • [7.5 非递归实现](#7.5 非递归实现)
      • [7.6 复杂度分析](#7.6 复杂度分析)
    • [八、🔷 归并排序](#八、🔷 归并排序)
      • [8.1 算法思想](#8.1 算法思想)
      • [8.2 递归实现](#8.2 递归实现)
      • [8.3 非递归实现](#8.3 非递归实现)
      • [8.4 复杂度分析](#8.4 复杂度分析)
    • [九、🔷 计数排序](#九、🔷 计数排序)
      • [9.1 算法思想](#9.1 算法思想)
      • [9.2 代码实现](#9.2 代码实现)
      • [9.3 复杂度分析](#9.3 复杂度分析)
    • [十、🔷 排序算法对比与选择](#十、🔷 排序算法对比与选择)
      • [10.1 性能对比(100万元素测试)](#10.1 性能对比(100万元素测试))
      • [10.2 选择建议](#10.2 选择建议)
    • [十一、🔷 排序算法的稳定性分析](#十一、🔷 排序算法的稳定性分析)
      • [11.1 什么是稳定性?](#11.1 什么是稳定性?)
      • [11.2 为什么稳定性重要?](#11.2 为什么稳定性重要?)
      • [11.3 各排序算法的稳定性](#11.3 各排序算法的稳定性)
    • [十二、🤔 几个思考题](#十二、🤔 几个思考题)
      • [1️⃣ 为什么快速排序的平均时间复杂度是 O(nlogn),而最坏是 O(n²)?](#1️⃣ 为什么快速排序的平均时间复杂度是 O(nlogn),而最坏是 O(n²)?)
      • [2️⃣ 为什么归并排序需要额外的 O(n) 空间?](#2️⃣ 为什么归并排序需要额外的 O(n) 空间?)
      • [3️⃣ 希尔排序为什么比插入排序快?](#3️⃣ 希尔排序为什么比插入排序快?)

一、🔥 排序算法概览

排序算法可以根据不同的维度进行分类:

1.1 按比较方式分类

  • 比较排序:通过比较元素之间的大小关系来确定相对位置。如冒泡排序、快速排序、归并排序等。
  • 非比较排序:不通过比较,而是利用元素的特性(如值的范围)进行排序。如计数排序、基数排序、桶排序。

1.2 按稳定性分类

  • 稳定排序:排序后,相等元素的相对顺序保持不变。如冒泡排序、插入排序、归并排序。
  • 不稳定排序:排序后,相等元素的相对顺序可能改变。如选择排序、快速排序、希尔排序。

1.3 时间复杂度对比

排序算法 平均时间复杂度 最坏时间复杂度 最好时间复杂度 空间复杂度 稳定性
冒泡排序 O(n²) O(n²) O(n) O(1) 稳定
选择排序 O(n²) O(n²) O(n²) O(1) 不稳定
插入排序 O(n²) O(n²) O(n) O(1) 稳定
希尔排序 O(n^1.3) O(n²) O(n) O(1) 不稳定
快速排序 O(nlogn) O(n²) O(nlogn) O(logn) 不稳定
归并排序 O(nlogn) O(nlogn) O(nlogn) O(n) 稳定
堆排序 O(nlogn) O(nlogn) O(nlogn) O(1) 不稳定
计数排序 O(n+k) O(n+k) O(n+k) O(k) 稳定

💡 可以看到,没有哪种排序算法在所有情况下都是最优的,需要根据实际场景选择合适的排序算法。


二、🔷 冒泡排序

冒泡排序是最简单的排序算法之一。它的基本思想是:通过相邻元素的比较和交换,将最大的元素"冒泡"到数组的末尾。

2.1 算法思想

  1. 比较相邻的两个元素,如果前者大于后者,则交换它们。
  2. 对每一对相邻元素做同样的比较,从数组开头到结尾。这样,最大的元素就会"冒泡"到最后。
  3. 重复上述过程,每次比较的范围缩小一个元素(因为最后的元素已经有序)。
  4. 直到整个数组有序。

2.2 动图演示

以数组 {9, 1, 2, 5, 7, 4, 6, 3} 为例:

第一轮:

  • 比较 9 和 1,9 > 1,交换 → {1, 9, 2, 5, 7, 4, 6, 3}
  • 比较 9 和 2,9 > 2,交换 → {1, 2, 9, 5, 7, 4, 6, 3}
  • 比较 9 和 5,9 > 5,交换 → {1, 2, 5, 9, 7, 4, 6, 3}
  • 比较 9 和 7,9 > 7,交换 → {1, 2, 5, 7, 9, 4, 6, 3}
  • 比较 9 和 4,9 > 4,交换 → {1, 2, 5, 7, 4, 9, 6, 3}
  • 比较 9 和 6,9 > 6,交换 → {1, 2, 5, 7, 4, 6, 9, 3}
  • 比较 9 和 3,9 > 3,交换 → {1, 2, 5, 7, 4, 6, 3, 9}

第一轮结束,最大的元素 9 已经"冒泡"到末尾。

第二轮到第七轮类似,最终得到有序数组 {1, 2, 3, 4, 5, 6, 7, 9}

2.3 代码实现

c 复制代码
// 冒泡排序
void BubbleSort(int* a, int sz)
{
    for (int j = 0; j < sz - 1; j++)      // 外层循环控制轮数
    {
        // 单趟:从0开始,每次排好一个最大的元素到末尾
        for (int i = 0; i < sz - j - 1; i++)  // 内层循环控制比较范围
        {
            if (a[i] > a[i + 1])          // 相邻元素比较
            {
                Swap(&a[i], &a[i + 1]);   // 前者大于后者,交换
            }
        }
    }
}

2.4 复杂度分析

  • 时间复杂度

    • 最好情况(数组已有序):O(n),但需要优化(加标志位)才能达到。
    • 最坏情况(数组逆序):O(n²),需要 n-1 轮,每轮比较 n-j-1 次。
    • 平均情况:O(n²)。
  • 空间复杂度:O(1),只需要常数个额外空间。

  • 稳定性:稳定。相等元素不会交换,相对顺序保持不变。

2.5 优化版本

可以增加一个标志位,如果某一轮没有发生交换,说明数组已经有序,可以提前结束:

c 复制代码
// 冒泡排序优化版
void BubbleSortOpt(int* a, int sz)
{
    for (int j = 0; j < sz - 1; j++)
    {
        int flag = 0;  // 标志位:本轮是否发生交换
        for (int i = 0; i < sz - j - 1; i++)
        {
            if (a[i] > a[i + 1])
            {
                Swap(&a[i], &a[i + 1]);
                flag = 1;  // 发生了交换
            }
        }
        if (flag == 0)    // 本轮没有交换,数组已有序
            break;
    }
}

优化后,最好情况下时间复杂度为 O(n)。

⚠️ 冒泡排序虽然简单,但效率较低,仅适用于小规模数据或教学演示。


三、🔷 选择排序

选择排序的思想也很直观:每次从未排序部分选择最小的元素,放到已排序部分的末尾。

3.1 算法思想

  1. 在未排序部分找到最小元素的下标。
  2. 将最小元素与未排序部分的第一个元素交换。
  3. 已排序部分扩大一个元素。
  4. 重复上述过程,直到所有元素有序。

3.2 动图演示

以数组 {9, 1, 2, 5, 7, 4, 6, 3} 为例:

第一轮: 找到最小元素 1(下标 1),与第一个元素 9 交换 → {1, 9, 2, 5, 7, 4, 6, 3}

第二轮: 在剩余元素中找到最小元素 2(下标 2),与第二个元素 9 交换 → {1, 2, 9, 5, 7, 4, 6, 3}

以此类推,最终得到有序数组。

3.3 代码实现

这里给出一个优化版本,每次同时找最大和最小值:

c 复制代码
// 选择排序(优化版:每次找最大和最小)
void SelectSort(int* a, int sz)
{
    int begin = 0, end = sz - 1;
    while (begin < end)
    {
        int maxi = begin, mini = begin;
        // 单趟:找最大和最小元素的下标
        for (int i = begin + 1; i <= end; i++)
        {
            if (a[i] < a[mini])
                mini = i;
            if (a[i] > a[maxi])
                maxi = i;
        }
        // 处理特殊情况:最大值在开头或最小值在末尾
        if (maxi == begin || mini == end)
        {
            Swap(&a[mini], &a[maxi]);
            int tmp = mini;
            mini = maxi;
            maxi = tmp;
        }
        // 交换:最大值放末尾,最小值放开头
        Swap(&a[maxi], &a[end]);
        Swap(&a[mini], &a[begin]);
        end--;
        begin++;
    }
}

3.4 复杂度分析

  • 时间复杂度

    • 最好/最坏/平均:都是 O(n²)。因为无论数组是否有序,都需要比较所有元素。
  • 空间复杂度:O(1)。

  • 稳定性 :不稳定。例如数组 {5, 5, 3},第一轮会将第一个 5 和 3 交换,导致两个 5 的相对顺序改变。

💡 选择排序的交换次数最少(最多 n-1 次),适合交换成本高的场景。


四、🔷 插入排序

插入排序类似于我们打扑克牌时整理手牌的方式:每次将一张牌插入到已排序部分的适当位置。

4.1 算法思想

  1. 将数组的第一个元素视为已排序部分。
  2. 取出下一个元素,在已排序部分从后向前扫描。
  3. 如果已排序元素大于新元素,则将该元素后移一位。
  4. 重复步骤3,直到找到已排序元素小于等于新元素的位置。
  5. 将新元素插入到该位置。
  6. 重复步骤2-5,直到所有元素有序。

4.2 动图演示

以数组 {9, 1, 2, 5, 7, 4, 6, 3} 为例:

第一步: 已排序部分 {9},待插入元素 1

  • 9 > 1,9 后移 → {_, 9}_ 表示空位)
  • 到达开头,插入 1 → {1, 9}

第二步: 已排序部分 {1, 9},待插入元素 2

  • 9 > 2,9 后移 → {1, _, 9}
  • 1 < 2,停止,插入 2 → {1, 2, 9}

以此类推。

4.3 代码实现

c 复制代码
// 插入排序
void InsertSort(int* a, int sz)
{
    for (int i = 1; i < sz; i++)    // 从第二个元素开始,每次插入一个元素
    {
        // 单趟:将 a[i] 插入到 [0, i-1] 的有序区间
        int end = i;
        int tmp = a[end];           // 保存待插入元素
        while (end > 0)
        {
            if (tmp < a[end - 1])   // 前面的元素比待插入元素大
            {
                a[end] = a[end - 1]; // 后移
                end--;
            }
            else
            {
                break;              // 找到合适位置
            }
        }
        a[end] = tmp;               // 插入
    }
}

4.4 复杂度分析

  • 时间复杂度

    • 最好情况(数组已有序):O(n),每个元素只需比较一次。
    • 最坏情况(数组逆序):O(n²),每个元素都要比较和移动多次。
    • 平均情况:O(n²)。
  • 空间复杂度:O(1)。

  • 稳定性:稳定。相等元素不会交换。

⚠️ 插入排序对于基本有序的数组效率很高,这也是希尔排序的基础。


五、🔷 希尔排序

希尔排序是插入排序的改进版本,又称"缩小增量排序"。它通过设置增量(gap),将数组分成若干子序列,分别进行插入排序。

5.1 算法思想

  1. 选择一个增量序列,通常取 gap = n/3 + 1(n为数组长度)。
  2. 按增量将数组分成若干子序列,每个子序列进行插入排序。
  3. 逐步缩小增量,重复步骤2。
  4. 当 gap = 1 时,进行一次标准插入排序,完成排序。

5.2 增量序列的作用

希尔排序的核心在于增量序列的选择:

  • gap > 1 时:预排序,让大元素快速后移,小元素快速前移。
  • gap = 1 时:进行一次标准插入排序。由于数组已经基本有序,这次排序会非常快。

5.3 代码实现

c 复制代码
// 希尔排序
void ShellSort(int* a, int sz)
{
    int gap = sz;
    while (gap > 1)
    {
        // gap > 1 时是预排序
        // gap == 1 时是插入排序
        gap = gap / 3 + 1;  // +1 保证最后一次 gap 一定是 1

        // 对每个子序列进行插入排序
        for (int i = 0; i < sz - gap; i++)
        {
            int end = i;
            int tmp = a[end + gap];
            while (end >= 0)
            {
                if (tmp < a[end])
                {
                    a[end + gap] = a[end];
                    end -= gap;
                }
                else
                {
                    break;
                }
            }
            a[end + gap] = tmp;
        }
    }
}

5.4 复杂度分析

  • 时间复杂度

    • 平均情况:约 O(n^1.3),具体取决于增量序列。
    • 最坏情况:O(n²)。
  • 空间复杂度:O(1)。

  • 稳定性:不稳定。相同元素可能被分到不同的子序列。

💡 希尔排序打破了 O(n²) 的限制,是第一个突破这个时间复杂度的排序算法。


六、🔷 堆排序

堆排序利用堆这种数据结构进行排序。堆是一棵完全二叉树,分为大根堆和小根堆。

6.1 堆的基本概念

  • 大根堆:每个节点的值都大于等于其子节点的值。根节点是最大值。
  • 小根堆:每个节点的值都小于等于其子节点的值。根节点是最小值。

6.2 算法思想(升序使用大根堆)

  1. 建堆:从最后一个非叶子节点开始,向上调整,构建大根堆。
  2. 排序
    • 将堆顶(最大值)与堆末尾元素交换。
    • 堆大小减1,对堆顶进行向下调整,恢复大根堆性质。
    • 重复上述过程,直到堆大小为1。

6.3 向下调整算法

c 复制代码
// 向下调整建堆
void Adjustdown(int* a, int n, size_t parent)
{
    size_t child = 2 * parent + 1;  // 左孩子下标

    while (child < n)
    {
        // 选出较大的孩子
        if (child + 1 < n && a[child + 1] > a[child])
        {
            child++;
        }
        // 如果孩子比父亲大,交换
        if (a[parent] < a[child])
        {
            Swap(&a[parent], &a[child]);
            parent = child;
            child = 2 * parent + 1;
        }
        else
        {
            return;
        }
    }
}

6.4 堆排序实现

c 复制代码
// 堆排序
void HeapSort(int* a, int sz)
{
    // 向下调整建大根堆
    int end = sz - 1;
    for (int i = (end - 1) / 2; i >= 0; i--)  // 从最后一个非叶子节点开始
    {
        Adjustdown(a, sz, i);
    }
    
    // 排序:每次将堆顶(最大值)换到末尾
    for (size_t i = 0; i < sz; i++)
    {
        end = sz - i - 1;
        Swap(&a[0], &a[end]);      // 堆顶与末尾交换
        Adjustdown(a, end, 0);     // 向下调整恢复堆性质
    }
}

6.5 复杂度分析

  • 时间复杂度

    • 建堆:O(n)
    • 排序:n 次调整,每次调整 O(logn),共 O(nlogn)
    • 总时间复杂度:O(nlogn)
  • 空间复杂度:O(1),原地排序。

  • 稳定性:不稳定。

⚠️ 堆排序是原地排序,不需要额外空间,但实际应用中由于缓存不友好,性能往往不如快速排序。


七、🔷 快速排序

快速排序是最常用的排序算法之一,采用分治思想。

7.1 算法思想

  1. 选择基准值(pivot):从数组中选择一个元素作为基准。
  2. 分区(Partition) :将数组分为两部分,使得:
    • 左边部分的元素都小于等于基准值。
    • 右边部分的元素都大于等于基准值。
    • 基准值位于最终位置。
  3. 递归排序:对左右两部分分别递归进行快速排序。

7.2 三种 Partition 方法

7.2.1 Hoare 法(左右指针法)
c 复制代码
// Hoare 法快排
int hoareSort(int* a, int left, int right)
{
    int keyi = left;  // 选择最左边的元素作为基准
    int begin = left, end = right;
    
    while (begin < end)
    {
        // 右边先走找小
        while (begin < end && a[end] >= a[keyi])
            end--;
        // 左边再走找大
        while (begin < end && a[begin] <= a[keyi])
            begin++;
        // 交换左边大值和右边小值
        Swap(&a[begin], &a[end]);
    }
    // 基准值放到正确位置
    Swap(&a[keyi], &a[begin]);
    return begin;  // 返回基准值的最终位置
}
7.2.2 挖坑法
c 复制代码
// 挖坑法快排
int pitSort(int* a, int left, int right)
{
    int piti = left;  // 坑的位置
    int pit = a[piti]; // 基准值
    int begin = left, end = right;
    
    while (begin < end)
    {
        // 右边找小,填到左边的坑
        while (begin < end && a[end] >= pit)
            end--;
        a[piti] = a[end];
        piti = end;
        
        // 左边找大,填到右边的坑
        while (begin < end && a[begin] <= pit)
            begin++;
        a[piti] = a[begin];
        piti = begin;
    }
    // 基准值填到最后的坑
    a[begin] = pit;
    return begin;
}
7.2.3 双指针法(前后指针法)
c 复制代码
// 双指针法快排
int ptrSort(int* a, int left, int right)
{
    int keyi = left;
    int prev = left, cur = left + 1;
    
    while (cur <= right)
    {
        if (a[cur] < a[keyi])
        {
            prev++;
            if (a[cur] != a[prev])
            {
                Swap(&a[cur], &a[prev]);
            }
        }
        cur++;
    }
    Swap(&a[keyi], &a[prev]);
    return prev;
}

7.3 快速排序主体

c 复制代码
// 快速排序
void QuickSort(int* a, int left, int right)
{
    if (left >= right)
        return;
    
    // 小区间优化:元素较少时使用插入排序
    if (right - left + 1 < 10)
    {
        InsertSort(a + left, right - left + 1);
    }
    else
    {
        // 三数取中:避免最坏情况
        int midi = GetMidi(a, left, right);
        Swap(&a[left], &a[midi]);
        
        int keyi = hoareSort(a, left, right);  // 分区
        QuickSort(a, left, keyi - 1);          // 排左边
        QuickSort(a, keyi + 1, right);         // 排右边
    }
}

7.4 三数取中优化

c 复制代码
// 三数取中:选择 left、right、midi 中间大小的元素
int GetMidi(int* a, int left, int right)
{
    int midi = (left + right) / 2;
    if (a[left] < a[right])
    {
        if (a[midi] < a[left])
            return left;
        else if (a[right] < a[midi])
            return right;
        else
            return midi;
    }
    else
    {
        if (a[midi] < a[right])
            return right;
        else if (a[left] < a[midi])
            return left;
        else
            return midi;
    }
}

7.5 非递归实现

使用栈模拟递归过程:

c 复制代码
// 快排非递归
void QuickSortNonR(int* a, int left, int right)
{
    Stack st;
    StackInit(&st);
    StackPush(&st, right);
    StackPush(&st, left);

    while (!StackEmpty(&st))
    {
        int begin = StackTop(&st);
        StackPop(&st);
        int end = StackTop(&st);
        StackPop(&st);

        int midi = GetMidi(a, begin, end);
        Swap(&a[begin], &a[midi]);
        
        int keyi = hoareSort(a, begin, end);
        
        // 将子区间入栈
        if (keyi + 1 < end)
        {
            StackPush(&st, end);
            StackPush(&st, keyi + 1);
        }
        if (begin < keyi - 1)
        {
            StackPush(&st, keyi - 1);
            StackPush(&st, begin);
        }
    }
    StackDestroy(&st);
}

7.6 复杂度分析

  • 时间复杂度

    • 平均情况:O(nlogn)
    • 最坏情况:O(n²),当数组已有序且每次选择最左或最右元素作为基准时。通过三数取中可以避免。
  • 空间复杂度

    • 递归版:O(logn),递归栈深度。
    • 非递归版:O(logn),显式栈深度。
  • 稳定性:不稳定。

💡 快速排序是实践中最快的排序算法之一,C标准库的 qsort 就是快速排序的实现。


八、🔷 归并排序

归并排序采用分治思想,将数组分成两半,分别排序后再合并。

8.1 算法思想

  1. 分解:将数组从中间分成两半,递归处理。
  2. 合并:将两个有序子数组合并成一个有序数组。

8.2 递归实现

c 复制代码
// 归并排序子函数
void _MergeSort(int* a, int* tmp, int left, int right)
{
    if (left >= right)
        return;

    int midi = (left + right) / 2;
    _MergeSort(a, tmp, left, midi);      // 排左半边
    _MergeSort(a, tmp, midi + 1, right); // 排右半边

    // 合并两个有序区间
    int i = left;
    int begin1 = left, end1 = midi;
    int begin2 = midi + 1, end2 = right;
    
    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] < a[begin2])
            tmp[i++] = a[begin1++];
        else
            tmp[i++] = a[begin2++];
    }
    while (begin1 <= end1)
        tmp[i++] = a[begin1++];
    while (begin2 <= end2)
        tmp[i++] = a[begin2++];
    
    // 拷贝回原数组
    memcpy(a + left, tmp + left, (right - left + 1) * sizeof(int));
}

// 归并排序
void MergeSort(int* a, int sz)
{
    int* tmp = (int*)malloc(sizeof(int) * sz);
    if (tmp == NULL)
    {
        perror("tmp malloc fail");
        exit(1);
    }
    _MergeSort(a, tmp, 0, sz - 1);
    free(tmp);
}

8.3 非递归实现

c 复制代码
// 归并排序非递归子函数
void _MergeSortNonR(int* a, int* tmp, int left, int right)
{
    int gap = 1;
    while (gap < right - left + 1)
    {
        for (int i = 0; i < right - left + 1; i += 2 * gap)
        {
            int j = i;
            int begin1 = i, end1 = i + gap - 1;
            int begin2 = i + gap, end2 = i + 2 * gap - 1;

            // 处理边界情况
            if (begin2 >= right - left + 1)
                break;
            if (end2 >= right - left + 1)
                end2 = right - left + 1 - 1;

            // 合并
            while (begin1 <= end1 && begin2 <= end2)
            {
                if (a[begin1] <= a[begin2])
                    tmp[j++] = a[begin1++];
                else
                    tmp[j++] = a[begin2++];
            }
            while (begin1 <= end1)
                tmp[j++] = a[begin1++];
            while (begin2 <= end2)
                tmp[j++] = a[begin2++];
            
            memcpy(a + i, tmp + i, (end2 - i + 1) * sizeof(int));
        }
        gap *= 2;
    }
}

8.4 复杂度分析

  • 时间复杂度

    • 所有情况:O(nlogn),非常稳定。
  • 空间复杂度:O(n),需要额外的临时数组。

  • 稳定性:稳定。

⚠️ 归并排序需要额外的 O(n) 空间,但它是稳定排序中效率最高的,常用于外部排序。


九、🔷 计数排序

计数排序是一种非比较排序,适用于范围固定的整数排序。

9.1 算法思想

  1. 找出数组中的最大值和最小值,确定元素范围。
  2. 创建计数数组,统计每个元素出现的次数。
  3. 根据统计结果,将元素放回原数组。

9.2 代码实现

c 复制代码
// 计数排序
void CountSort(int* a, int sz)
{
    // 找出最大值和最小值
    int max = a[0], min = a[0];
    for (int i = 0; i < sz; i++)
    {
        if (a[i] > max)
            max = a[i];
        if (a[i] < min)
            min = a[i];
    }
    
    // 建立计数数组
    int range = max - min + 1;
    int* count = (int*)calloc(range, sizeof(int));
    
    // 统计次数
    for (int i = 0; i < sz; i++)
        count[a[i] - min]++;
    
    // 填入原数组
    int j = 0;
    for (int i = 0; i < range; i++)
    {
        while (count[i]--)
            a[j++] = i + min;
    }
    
    free(count);
}

9.3 复杂度分析

  • 时间复杂度:O(n + k),其中 k 是元素范围(max - min + 1)。
  • 空间复杂度:O(k),计数数组的大小。
  • 稳定性:稳定(需要额外处理)。

⚠️ 计数排序适用于元素范围不大的整数排序,不适合浮点数或范围很大的整数。


十、🔷 排序算法对比与选择

10.1 性能对比(100万元素测试)

排序算法 耗时(毫秒)
插入排序 很慢(O(n²))
希尔排序 较快
选择排序 很慢(O(n²))
堆排序 较快
快速排序 最快
归并排序 较快
冒泡排序 很慢(O(n²))

10.2 选择建议

场景 推荐排序算法
小规模数据(n < 50) 插入排序
大规模数据,内存充足 快速排序
大规模数据,需要稳定排序 归并排序
数据范围固定的整数 计数排序
数据基本有序 插入排序
外部排序(数据在磁盘) 归并排序

十一、🔷 排序算法的稳定性分析

11.1 什么是稳定性?

稳定性是指排序后,相等元素的相对顺序是否保持不变。

  • 稳定排序:排序后,相等元素的相对顺序不变。
  • 不稳定排序:排序后,相等元素的相对顺序可能改变。

11.2 为什么稳定性重要?

在实际应用中,我们可能需要对多个字段进行排序。例如,先按年龄排序,再按姓名排序。如果排序不稳定,第一次排序的结果可能会被第二次排序打乱。

11.3 各排序算法的稳定性

排序算法 稳定性 原因
冒泡排序 稳定 相等元素不交换
插入排序 稳定 相等元素不移动
归并排序 稳定 合并时相等元素先取左边
选择排序 不稳定 交换可能改变相对顺序
希尔排序 不稳定 相同元素可能分到不同组
快速排序 不稳定 交换可能改变相对顺序
堆排序 不稳定 堆调整可能改变相对顺序
计数排序 稳定 按顺序填入

十二、🤔 几个思考题

学完本文,来试试回答这些问题:

1️⃣ 为什么快速排序的平均时间复杂度是 O(nlogn),而最坏是 O(n²)?

答: 快速排序的时间复杂度取决于分区是否均匀。

  • 平均情况:每次分区都大致将数组分成两半,递归深度为 logn,每层处理 n 个元素,总时间复杂度为 O(nlogn)。

  • 最坏情况:当数组已经有序(升序或降序),且每次选择最左或最右元素作为基准时,每次分区只能减少一个元素,递归深度变为 n,总时间复杂度为 O(n²)。

💡 通过三数取中法选择基准,可以有效避免最坏情况的发生。

2️⃣ 为什么归并排序需要额外的 O(n) 空间?

答: 归并排序在合并两个有序子数组时,需要一个临时数组来存储合并结果。如果直接在原数组上合并,可能会覆盖尚未处理的元素。

💡 这也是归并排序的一个缺点,但在外部排序中,归并排序是唯一可行的选择。

3️⃣ 希尔排序为什么比插入排序快?

答: 希尔排序通过增量序列将数组分成若干子序列,分别进行插入排序。当 gap 较大时,大元素可以快速后移,小元素可以快速前移,避免了插入排序中元素只能逐位移动的低效问题。

当 gap 减小到 1 时,数组已经基本有序,此时进行插入排序非常高效。

💡 希尔排序的时间复杂度取决于增量序列的选择,最佳增量序列可以使时间复杂度接近 O(nlogn)。


✅ 本节完...

📝 作者:say-fall | 编辑:say-fall | 🌟 原创不易,如果对你有帮助,记得 👍 点赞 + ⭐ 收藏 哦!

相关推荐
Hanniel1 小时前
C++枚举新手入门教程
c++
贾斯汀玛尔斯10 小时前
每天学一个算法--LSM-Tree(Log-Structured Merge Tree)
java·算法·lsm-tree
许长安11 小时前
RPC 同步调用基本使用方法:基于官方 RouteGuide 示例
c++·经验分享·笔记·rpc
kyriewen1111 小时前
WebAssembly:前端界的“外挂”,让C++代码在浏览器里跑起来
开发语言·前端·javascript·c++·单元测试·ecmascript
浅念-14 小时前
刷穿LeetCode:BFS 解决 Flood Fill 算法
数据结构·c++·算法·leetcode·职场和发展·bfs·宽度优先
做cv的小昊15 小时前
【TJU】研究生应用统计学课程笔记(8)——第四章 线性模型(4.1 一元线性回归分析)
笔记·线性代数·算法·数学建模·回归·线性回归·概率论
贾斯汀玛尔斯15 小时前
每天学一个算法--倒排索引(Inverted Index)
算法·inverted-index
楼田莉子15 小时前
Linux网络:NAT_代理
linux·运维·服务器·开发语言·c++·后端