【C语言—数据结构】8种高效排序算法:从入门到实战

🔥铅笔小新z:个人主页

🎬博客专栏:数据结构

💫滴水不绝,可穿石;步履不休,能至渊。


引言

排序,是计算机科学中最经典、最基础的问题之一。它看似简单,背后却蕴藏着丰富的算法思想和智慧火花。

从简单直观的"冒泡排序"到高效莫测的"快速排序",不同的算法在时间与空间的权衡中,演绎着各自的精彩。

无论你是编程新手还是资深开发者,深入理解排序,都是锤炼算法思维不可或缺的一步。

本文将带你一览众"序",看懂它们的门道。
在这篇博客中,我们将从最基础的冒泡排序和选择排序开始,理解它们的思想;然后我们会探讨更高效的归并排序和快速排序,分析它们为何如此强大;最后,我们还会对比各种算法的性能,帮助你在实际场景中最初最佳选择。让我们开始这段奇妙的排序之旅吧!


常见排序算法


一、冒泡排序

1.1 一句话概括

冒泡排序是一种简单的排序算法,它通过反复交换相邻的、顺序错误的元素,将较大的元素逐渐"浮"到数组的末尾,就像气泡上浮一样。

1.2 核心思想

  • **比较相邻元素:**从数组的第一个元素开始,依次比较相邻的两个元素。
  • **交换位置:**如果前一个元素比后一个元素大(升序排序),就交换它们的位置。
  • **重复过程:**每一轮遍历都会将当前未排序部分的最大元素"冒泡"到正确位置。
  • **优化:**如果某一轮没有发生任何交换,说明数组已经有序,可以提前终止。

1.3 代码实现

cpp 复制代码
void BubbleSort(int* a, int n) 
{
    int exchange = 0; // 优化:记录是否发生交换
    for (int i = 0; i < n; i++)
    {
        for (int j = 0; j <n-i-1 ; j++)
        {
            if (a[j] > a[j + 1]) 
            {
                exchange = 1;
                swap(&a[j], &a[j + 1]);
            }
        }
        if (exchange == 0) // 为交换说明有序
        {
            break;
        }
    }
 }

1.4 复杂度分析

  • 时间复杂度

    • 最坏情况(完全逆序):O(n²)

    • 最好情况(已经有序):O(n)(优化后)

    • 平均情况:O(n²)

  • 空间复杂度:O(1)(原地排序)

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

1.5 优缺点

1.5.1 优点:

  • 实现简单,易于理解。

  • 原地排序,空间效率高。

  • 稳定排序。

1.5.2 缺点:

  • 效率低,不适合大规模数据。

  • 相比其他 O(n²) 算法(如选择排序),交换操作可能更频繁。

1.6 应用场景

  • 教学或理解排序算法原理。
  • 数据量极小且基本有序的情况。
  • 对稳定性有要求且数据量小。

1.7 进阶讨论

  • **与插入排序、选择排序的比较:**冒泡排序在实际中较少使用,因为其常数因子较大。
  • **优化变体:**双向排序适用于大部分元素已有序的情况。
  • **为什么叫"冒泡":**因为较大的元素像气泡一样逐渐上浮至末尾。

1.8 综合理解

冒泡排序是一种基础的比较排序算法。它的核心思想是通过多次遍历数组,每次比较相邻元素,如果顺序错误就交换它们,这样每一轮都会将当前未排序部分的最大元素"冒泡"到正确位置。它的时间复杂度在最坏和平均情况下是O(),最好情况下(已有序)优化后可以达到O(n),空间复杂度是O(1),并且是稳定的。由于效率较低,它通常用于教学或数据量很小的场景。


二、选择排序

2.1 一句话概括

选择排序是一种简单的原地比较排序算法,接下来要说到的双向选择排序是普通选择排序的一种优化。它在每一轮遍历中同时找出未排序部分的最小值和最大值,分别放到已排序部分的首尾,从而减少排序轮数。

2.2 核心思想(分布解释)

  • **双指针维护边界:**用 begin 和 end 分别指向未排序部分的起始和结束位置。
  • **同时查找最值:**在每一轮中,同时查找未排序部分的最小值和最大值。
  • **两端同时归位:**将最小值放到 begin 位置,最大值放到 end 位置。
  • **边界收缩:**begin++, end--,每次减少未排序部分的范围,直到全部有序。

2.3 代码逻辑讲解

cpp 复制代码
void SelectSort(int* arr, int n)
{
	int begin = 0, end = n - 1;
	while (begin < end) // 直到未排序部分为空
	{
		int max = begin, min = begin; // 假设当前 begin 位置既是最大值也是最小值
        // 遍历未排序部分 [begin, end] 查找真正的最小值和最大值下标
		for (int i = begin; i <= end; i++) // 注意:应该是 i <= end
		{
			if (arr[max] < arr[i])
				max = i;
			if (arr[min] > arr[i])
				min = i;
		}
        		
        // 将最小值交换到 begin 位置
        swap(&arr[begin], &arr[min]);

        // 关键处理:如果 begin 位置原本就是最大值
		if (begin == max)
			max = min; // 最大值的位置被最小值交换了,更新最大值下标

		// 将最大值交换到 end 位置
        swap(&arr[end], &arr[max]);
		
        // 收缩未排序范围
        begin++;
		end--;
	}
}

2.4 复杂度分析

  • **时间复杂度:**O()

比较次数:(n - 1) + (n - 3) + (n - 5) + ... ≈ / 2

但轮数减少到原来的一半左右。

  • **空间复杂度:**O(1)(原地排序)
  • **稳定性:**不稳定

2.5 优缺点

2.5.1 优点:

  1. 比普通选择排序快约一倍(轮数减半)

  2. 仍然保持 O(1) 的空间复杂度

  3. 实现相对简单

2.5.2 缺点:

  1. 时间复杂度仍为 O(n²)

  2. 不稳定排序

  3. 代码逻辑稍复杂(需要处理最大值的特殊情况)

2.6 综合理解

这段代码实现了双向选择排序。它使用 begin 和 end 指针标记未排序部分的边界,在每一轮中同时查找最小值和最大值。先将最小值交换到 begin 位置,如果发现 begin 位置原本就是最大值,需要更新最大值下标,然后在将最大值交换到 end 位置。这样每轮能确定两个元素的位置,排序轮数减少到原来的一半左右。时间复杂度仍然是 O(),但实际比普通选择排序更快,却保持了 O(1) 的空间复杂度。需要注意的是,这是一个不稳定的排序算法。


三、插入排序

3.1 一句话概括

插入排序是一种简单直观的排序算法,它的工作原理类似于整理扑克牌:将每个未排序元素插入到已排序部分的正确位置,从而逐步构建有序数列。

3.2 核心思想

  • **划分已排序和未排序:**初始时已排序部分只有第一个元素(下标 0 )。
  • **逐个插入:**将未排序部分的第一个元素取出,在已排序部分中从后向前找到合适的插入位置。
  • **移动元素:**在查找过程中,将比待插入元素大的元素都往后移动一位,为插入腾出空间。
  • **插入元素:**将待插入元素放到正确位置。

3.3 代码逻辑讲解

cpp 复制代码
void InsertSort(int* arr, int n)
{
	for (int i = 0; i <= n - 2; i++) // 遍历未排序部分,i 指向已排序部分的最后一个元素
	{
		int end = i;                 // 已排序部分的末尾下标
		int tmp = arr[end + 1];      // 取出待插入元素(未排序部分的第一个)
		
		// 从后向前查找插入位置
		while (end >= 0)
		{
			if (arr[end] > tmp)      // 如果当前元素比待插入元素大
			{
				arr[end + 1] = arr[end];  // 向后移动一位
				end--;                    // 继续向前比较
			}
			else
				break;                    // 找到合适位置,停止查找
		}
		arr[end + 1] = tmp;               // 将待插入元素放到正确位置
	}
}

3.4 关键细节讲解

3.4.1 为什么从后向前比较?

  1. 效率高:可以边比较边移动,避免额外的交换操作

  2. 提前终止:遇到不大于tmp的元素就可以停止,因为前面都是有序的

3.4.2 循环条件 i <= n - 2 的意义

  • i 表示已排序部分的最后一个索引

  • i = n-2 时,arr[end+1] 就是最后一个元素

  • 所以只需要遍历到倒数第二个元素

3.4.3 arr[end + 1] = tmp 的位置

  • 退出while循环时,end 指向最后一个小于等于tmp的元素

  • 或者 end = -1(tmp是最小值)

  • 所以插入位置是 end + 1

3.5 复杂度分析

3.5.1 时间复杂度:

  • 最坏情况(完全逆序):O(n²)

    • 每个元素都需要比较并移动前面所有元素

    • 比较次数:1+2+3+...+(n-1) = n(n-1)/2

  • 最好情况(已经有序):O(n)

    • 每个元素只需比较一次就确定位置

    • 比较次数:n-1

  • 平均情况:O(n²)

**3.5.2 空间复杂度:**O(1) (原地排序)

**3.5.3 稳定性:**稳定排序

  • 只有 arr[end] > tmp 时才移动,等于时不移动

  • 相等元素的相对顺序保持不变

3.6 优缺点

  • 优点:
  1. 简单直观,易于实现

  2. 对小数据集效率高(实际性能常优于其他O(n²)算法)

  3. 稳定排序

  4. 自适应:对部分有序数组效率接近O(n)

  5. 原地排序,空间效率高

  • 缺点:
  1. 大数据集效率低,不适合大规模数据

  2. 需要大量移动元素,对链表结构不友好

3.7 应用场景

  • 小规模数据(n <= 50)
  • 基本有序的数据(如日志追加时间戳排序)
  • 作为其他排序算法的子过程(如快速排序的递归小数组使用插入排序)
  • 在线算法(数据流逐个到达时的实时排序)
  • 稳定排序需求且数据量小

3.8 综合理解

插入排序的工作原理是将数组分为已排序和未排序两部分。初始时已排序部分只有一个元素,然后逐个取出来未排序部分的元素,在已排序部分中从后向前查找合适的插入位置。在查找过程中,比待插入元素大的元素都向后移动一位,为插入腾出空间。这个算法的最好时间复杂度是O(n)(已有序),最坏是O()(完全逆序),平均O()。它是稳定的原地排序算法,特别适合小规模数据或基本有序的数据集。在实际应用中,它常作为其他高级排序算法的优化子过程。


四、希尔排序

4.1 一句话概括

希尔排序是插入排序的改进版,它通过将原始数组按一定间隔(gap)分组,对每组进行插入排序,然后逐步缩小间隔直至1,最终完成整体排序。

4.2 核心思想

  • 分组插入:不是直接对整个数组排序,而是先按间隔gap将数组分成多个子序列。

  • 逐步细化 :从较大的gap开始排序,使数组宏观基本有序,然后逐渐减小gap。

  • 最终插入排序:当gap=1时,就是普通的插入排序,但此时数组已经基本有序,插入排序效率很高。

  • 克服插入排序缺陷:插入排序每次只能移动一位,希尔排序可以一次移动gap位,更快地将元素送到大致正确的位置。

4.3 代码逻辑讲解

cpp 复制代码
void ShellSort(int* arr, int n)
{
    int gap = n;                   // 初始间隔设为数组长度
    while (gap > 1)                // 当gap>1时,继续分组排序
    {
        gap = gap / 3 + 1;         // 动态计算下一个间隔(常见增量序列)
        
        // 对每个分组进行插入排序
        for (int i = 0; i < gap; i++)  // i表示第i个分组
        {
            // 对第i个分组进行插入排序
            for (int j = i; j <= n - 1 - gap; j += gap)
            {
                int end = j;                      // 已排序部分的末尾
                int tmp = arr[end + gap];         // 待插入元素
                
                // 插入排序过程
                while (end >= 0)
                {
                    if (arr[end] > tmp)           // 需要移动
                    {
                        arr[end + gap] = arr[end]; // 向后移动gap位
                        end -= gap;               // 向前移动gap位
                    }
                    else
                        break;                    // 找到插入位置
                }
                arr[end + gap] = tmp;            // 插入元素
            }
        }
    }
}

4.4 关键细节讲解

4.4.1 增量序列选择(gap = gap / 3 + 1)

  • gap / 3 + 1:这是Knuth提出的增量序列,效率较高
  • +1 的作用:确保 gap 最终能减少到 1
  • 其他常见序列:

Shell原始序列:n/2, n/4, ..., 1

Hibbard序列:1, 3, 7, 15, ..., 2^k-1

Sedgewick序列:1, 5, 19, 41, ...(更优)

4.4.2 三层循环结构

cpp 复制代码
while (gap > 1)          // 控制gap变化
    for (int i = 0; i < gap; i++)      // 遍历每个分组
        for (int j = i; j <= n-1-gap; j+=gap)  // 对当前分组插入排序

4.4.3 优化版本(常用写法)

实际中常用更简洁的写法,合并了分组循环:

cpp 复制代码
void ShellSort(int* arr, int n)
{
    int gap = n;
    while (gap > 1)
    {
        gap = gap / 3 + 1;
        // 合并分组,从gap开始遍历所有元素
        for (int i = gap; i < n; i++)
        {
            int end = i - gap;
            int tmp = arr[i];
            while (end >= 0 && arr[end] > tmp)
            {
                arr[end + gap] = arr[end];
                end -= gap;
            }
            arr[end + gap] = tmp;
        }
    }
}

4.5 复杂度分析

4.5.1 时间复杂度:

  • 最坏情况:取决于增量序列,一般为O(n²)

    • 使用Shell原始序列(n/2, n/4, ...):O(n²)

    • 使用Hibbard序列:O(n^{1.5})

    • 使用Sedgewick序列:O(n^{4/3})

  • 平均情况:优于O(n²),通常为O(n^{1.3}~O(n^{1.5}))

  • 最好情况:O(n * log n)(数组已有序)

4.5.2 空间复杂度:O(1)(原地排序)

4.5.3 稳定性不稳定排序

  • 分组插入可能改变相等元素的相对顺序

4.6 优缺点

4.6.1 优点:

  1. 比简单排序快得多:突破了O(n²)的瓶颈

  2. 原地排序:空间效率高

  3. 易于实现:代码相对简单

  4. 对中等规模数据有效:在n<5000时表现良好

  5. 不需要递归:无栈溢出风险

4.6.2 缺点:

  1. 不稳定:可能改变相等元素的顺序

  2. 时间复杂度分析复杂:依赖于增量序列

  3. 不如高级排序算法:对大数据不如快排、归并

  4. 增量序列选择影响大:不同序列性能差异明显

4.7 应用场景

  1. 中等规模数据排序(几千到几万个元素)

  2. 嵌入式系统:空间有限,需要原地排序

  3. 作为其他算法的子过程:某些特定场景的预处理

  4. 对稳定性要求不高的场景

  5. 教学用途:展示如何改进简单算法

4.8 增量序列选择的重要性

cpp 复制代码
// 不同增量序列的性能对比
1. Shell原始序列: gap = n/2, n/4, ..., 1
   - 简单但效率不高,最坏O(n²)

2. Hibbard序列: 1, 3, 7, 15, ..., 2^k-1
   - 最坏O(n^{3/2}),较好

3. Knuth序列: 1, 4, 13, 40, ..., (3^k-1)/2
   - gap = gap/3(代码中的变体)
   - O(n^{3/2})

4. Sedgewick序列: 1, 5, 19, 41, ...
   - 理论最优,O(n^{4/3})

4.9 要点

  1. 说明改进思想:"希尔排序是对插入排序的改进,通过分组排序克服插入排序只能移动一位的缺点"

  2. 强调增量序列:"核心在于gap的选择和变化,不同的增量序列性能不同"

  3. 解释代码逻辑:"外层控制gap减小,内层对每个分组进行插入排序"

  4. 分析复杂度:"时间复杂度在O(n log n)到O(n²)之间,取决于增量序列"

  5. 对比其他算法:"比插入排序快,比快排简单,但不稳定"

4.10 综合理解

希尔排序是插入排序的高效改进版本。它通过引入间隔gap的概念,先将数组按间隔分成多个子序列分别进行插入排序,然后逐步减小间隔直至1。这样做的好处是,早期的大间隔排序可以让元素大幅度移动,快速到达大致正确的位置;后期小间隔排序时,数组已经基本有序,插入排序的效率很高。代码中使用 gap = gap/3 + 1 是Knuth增量序列的变体,确保gap最终能减少到1。希尔排序的时间复杂度取决于增量序列,一般为O(n^{1.3}~O(n^{1.5})),空间复杂度O(1),是不稳定的原地排序算法。它特别适合中等规模数据的排序。

五、快速排序(hoare版本)

5.1 一句话概括

这是 Hoare 分区方案的快速排序,通过选择一个基准值(key),将数组划分为左右两部分,左边都小于等于基准值,右边都大于等于基准值,然后递归地对左右两部分排序。

5.2 核心思想

  • 选择基准值:通常选择最左边的元素作为基准

  • 双指针分区 :使用两个指针 beginend 从两端向中间扫描

  • 交换逆序对end 找小于基准值的元素,begin 找大于基准值的元素,然后交换

  • 基准值归位 :最后将基准值放到正确位置(beginend 相遇点)

  • 递归排序:对基准值左右两部分递归进行相同操作

5.3 代码逻辑讲解

cpp 复制代码
void QuickSort(int* arr, int left, int right)
{
    if (left >= right)
        return;                     // 递归终止条件
        
    int key = left;                 // 选择最左边元素作为基准值
    int begin = left, end = right;  // 初始化双指针
    
    // Hoare 分区过程
    while (begin < end)
    {
        // 从右向左找第一个小于基准值的元素
        while (begin < end && arr[end] >= arr[key])
        {
            end--;
        }
        
        // 从左向右找第一个大于基准值的元素
        while (begin < end && arr[begin] <= arr[key])
        {
            begin++;
        }
        
        // 交换这两个逆序元素
        swap(&arr[begin], &arr[end]);
    }
    
    // 将基准值放到正确位置(相遇点)
    swap(&arr[key], &arr[begin]);
    
    // 更新基准值位置
    key = begin;
    
    // 递归排序左右两部分
    QuickSort(arr, left, key - 1);   // 左子数组
    QuickSort(arr, key + 1, right);  // 右子数组
}

5.4 关键细节解析

5.4.1 为什么先移动 end 指针?

  • 关键点 :Hoare 分区必须先移动右指针(end)

  • 原因:如果先移动 begin,可能停在大于基准值的位置,最终交换会导致大的元素被换到左边

  • 正确顺序end 先找小的,begin 再找大的

5.4.2 循环条件中的等号

cpp 复制代码
while (begin < end && arr[end] >= arr[key])  // 包含等号
while (begin < end && arr[begin] <= arr[key]) // 包含等号
  • 等号的意义:允许相等元素出现在任一边

  • 避免死循环:如果没有等号,遇到相等元素会死循环

5.4.3 基准值归位的正确性

  • 最后 begin == end,这个位置是第一个小于等于基准值的位置

  • 将基准值交换到这个位置,保证左边≤基准值,右边≥基准值

5.5 复杂度分析

5.5.1 时间复杂度:

  • 最好情况:每次分区平衡,O(n log n)

    • 递归深度:log n

    • 每层比较次数:n

  • 最坏情况:完全有序或逆序,O(n²)

    • 递归深度:n

    • 每层比较次数:n

  • 平均情况:O(n * log n)

5.5.2 空间复杂度:

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

  • 最坏情况:O(n)(递归栈深度)

  • 原地排序:额外空间仅用于递归栈

5.5.3 稳定性不稳定排序

  • 分区交换会改变相等元素的相对顺序

5.6 优缺点

5.6.1 优点

  1. 平均效率高:O(n log n) 的排序算法中最快的之一

  2. 原地排序:空间效率高

  3. 内部排序:适合内存数据排序

  4. 可分治并行:左右分区可并行处理

  5. 实际应用广:很多语言标准库的排序实现

5.6.2 缺点

  1. 最坏情况差:O(n²),需优化避免

  2. 不稳定排序

  3. 递归深度:可能栈溢出

  4. 基准值选择敏感:影响性能

5.7 优化策略

5.7.1 基准值选择优化

cpp 复制代码
// 三数取中法
int GetMidIndex(int* arr, int left, int right)
{
    int mid = left + (right - left) / 2;
    if (arr[left] < arr[mid])
    {
        if (arr[mid] < arr[right]) return mid;
        else if (arr[left] < arr[right]) return right;
        else return left;
    }
    else // arr[left] >= arr[mid]
    {
        if (arr[mid] > arr[right]) return mid;
        else if (arr[left] > arr[right]) return right;
        else return left;
    }
}
// 使用:swap(&arr[left], &arr[mid]); 再执行原逻辑

5.7.2 小数组优化

cpp 复制代码
if (right - left < 10)  // 当数据量小时
{
    InsertSort(arr + left, right - left + 1);  // 使用插入排序
    return;
}

5.8 应用场景

  1. 通用排序:大多数情况下的首选排序算法

  2. 大规模数据:内存足够时效率很高

  3. 随机数据:对随机数据表现优异

  4. 需要原地排序:空间受限的情况

  5. 不稳定可接受:不要求稳定性的场景

5.9 要点

  1. 说明算法思想:"分治思想,通过分区将问题分解"

  2. 强调分区过程:"双指针从两端扫描,交换逆序元素"

  3. 解释关键细节:"必须先移动右指针,注意等号处理"

  4. 分析复杂度:"平均O(n log n),最坏O(n²),不稳定"

  5. 提及优化:"三数取中、小数组优化、尾递归优化"

5.10 综合理解

这段代码实现了 Hoare 分区的快速排序。它选择最左边的元素作为基准值,然后用双指针 begin 和 end 从两端向中间扫描。end 指针从右向左找小于基准值的元素,begin 指针从左向右找大于基准值的元素,找到后交换它们。当两个指针相遇时,将基准值交换到相遇位置,这样就完成了一次分区。然后递归地对左右两个子数组进行相同操作。这个版本必须注意先移动右指针,否则可能导致错误。快速排序的平均时间复杂度是 O(n log n),最坏 O(n²),空间复杂度 O(log n)~O(n),是不稳定的原地排序算法。实际使用中常通过三数取中等优化避免最坏情况。

六、快速排序(挖坑法)

6.1 一句话概括

挖坑法是快速排序的一种实现方式,它通过创建一个'坑位'(hole),交替从两端寻找元素填入坑中,最终将基准值放入最后一个坑位,完成一次分区。

6.2 核心思想

  • 挖第一个坑:选择基准值后,将其位置作为初始坑位(hole)

  • 填坑-挖坑交替

    1. 从右向左找小于基准值的元素,填入当前坑,该元素原位置成为新坑

    2. 从左向右找大于基准值的元素,填入当前坑,该元素原位置成为新坑

  • 基准值归位:当左右指针相遇时,将基准值放入最后一个坑位

  • 返回分界点:返回基准值的最终位置,用于递归

6.3 代码逻辑讲解

cpp 复制代码
int QuickSort(int* a, int left, int right)  // 实际上是分区函数,通常命名为Partition
{
    int mid = a[left];            // 选择最左边元素作为基准值
    int hole = left;              // 初始坑位在最左边
    int key = a[hole];            // 保存基准值(与mid相同,冗余)
    
    while (left < right)
    {
        // 第一步:从右向左找小于基准值的元素
        while (left < right && a[right] >= key)  // 跳过大于等于基准值的元素
        {
            --right;
        }
        a[hole] = a[right];       // 将找到的小元素填入当前坑
        hole = right;             // 该元素原位置成为新坑
        
        // 第二步:从左向右找大于基准值的元素
        while (left < right && a[left] <= key)   // 跳过小于等于基准值的元素
        {
            ++left;
        }
        a[hole] = a[left];        // 将找到的大元素填入当前坑
        hole = left;              // 该元素原位置成为新坑
    }
    
    a[hole] = key;                // 将基准值放入最后的坑位
    return hole;                  // 返回基准值最终位置
}

6.4 具体执行过程

数组[6, 1, 2, 7, 9, 3, 4, 5, 10, 8],left=0, right=9

初始

  • key=6, hole=0, left=0, right=9

  • 数组:[6, 1, 2, 7, 9, 3, 4, 5, 10, 8]hole=0

第一轮

  1. 从右找<6的元素:8≥6, 10≥6, 5<6 → right=7

  2. a[0]=a[7]=5,hole=7

    数组:[5, 1, 2, 7, 9, 3, 4, 5, 10, 8](两个5)

  3. 从左找>6的元素:5≤6, 1≤6, 2≤6, 7>6 → left=3

  4. a[7]=a[3]=7,hole=3

    数组:[5, 1, 2, 7, 9, 3, 4, 7, 10, 8](两个7)

第二轮

  1. 从右找<6的元素:right从7左移,4<6 → right=6

  2. a[3]=a[6]=4,hole=6

    数组:[5, 1, 2, 4, 9, 3, 4, 7, 10, 8](两个4)

  3. 从左找>6的元素:left从3右移,9>6 → left=4

  4. a[6]=a[4]=9,hole=4

    数组:[5, 1, 2, 4, 9, 3, 9, 7, 10, 8](两个9)

第三轮

  1. 从右找<6的元素:right从6左移,3<6 → right=5

  2. a[4]=a[5]=3,hole=5

    数组:[5, 1, 2, 4, 3, 3, 9, 7, 10, 8](两个3)

  3. 从左找>6的元素:left从4右移,与right相遇(left=5, right=5)

结束

  • left=right=5,hole=5

  • a[5]=key=6

    数组:[5, 1, 2, 4, 3, 6, 9, 7, 10, 8]

  • 返回hole=5

6.5 关键细节解析

循环条件的等号

cpp 复制代码
while (left < right && a[right] >= key)  // 包含等号
while (left < right && a[left] <= key)   // 包含等号
  • 等号的作用:相等元素可以跳过,不会死循环

  • 稳定性:相等元素可能移动,所以不稳定

6.6 完整快排实现

cpp 复制代码
// 挖坑法分区函数
int Partition(int* a, int left, int right)
{
    int key = a[left];      // 基准值
    int hole = left;        // 初始坑位
    
    while (left < right)
    {
        // 从右找小
        while (left < right && a[right] >= key)
            --right;
        a[hole] = a[right];
        hole = right;
        
        // 从左找大
        while (left < right && a[left] <= key)
            ++left;
        a[hole] = a[left];
        hole = left;
    }
    
    a[hole] = key;          // 基准值归位
    return hole;            // 返回分界点
}

// 快速排序主函数
void QuickSort(int* a, int left, int right)
{
    if (left >= right)
        return;
    
    int keyi = Partition(a, left, right);  // 挖坑法分区
    
    // 递归排序左右两部分
    QuickSort(a, left, keyi - 1);
    QuickSort(a, keyi + 1, right);
}

6.7 复杂度分析

与Hoare版本相同:

  • 时间复杂度

    • 平均:O(n log n)

    • 最坏:O(n²)(有序/逆序时)

  • 空间复杂度

    • 平均:O(log n)(递归栈)

    • 最坏:O(n)

  • 稳定性不稳定

6.8 优化策略

6.8.1 基准值优化

cpp 复制代码
// 三数取中法选择基准值
int GetMidIndex(int* a, int left, int right)
{
    int mid = left + (right - left) / 2;
    // 返回中间值的索引
}
// 使用:swap(&a[left], &a[mid_index]); 再开始挖坑

6.8.2 小区间优化

cpp 复制代码
if (right - left < 15)  // 小数组使用插入排序
{
    InsertSort(a + left, right - left + 1);
    return;
}

6.9 综合理解

挖坑法快速排序是快速排序的一种实现方式。它选择最左边的元素作为基准值,并将其位置作为初始'坑位'。然后从右向左寻找小于基准值的元素,将其填入当前坑位,该元素原位置成为新坑位;接着从左向右寻找大于基准值的元素,填入当前坑位,该元素原位置成为新坑位。如此交替进行,直到左右指针相遇,此时将基准值放入最后一个坑位。这样就完成了一次分区,基准值左边的元素都小于等于它,右边的元素都大于等于它。最后递归地对左右两部分进行相同操作。挖坑法比传统的交换法赋值次数更少,效率稍高,平均时间复杂度O(n log n),最坏O(n²),是不稳定的原地排序算法。

七、快速排序(前后指针法)

7.1 一句话概括

双指针法(前后指针法)是快速排序的一种实现方式,通过维护两个指针 prevcur,将小于基准值的元素逐步交换到前面,最后将基准值放到正确位置,完成分区。

7.2 核心思想

  • 双指针移动prev 指向最后一个小于基准值的位置,cur 遍历未处理部分

  • 交换策略 :当 cur 找到小于基准值的元素时,先让 prev 前进一位,然后交换 prevcur 位置的元素

  • 基准值归位 :遍历完成后,将基准值与 prev 位置的元素交换

  • 分区结果prev 左边都小于基准值,右边都大于等于基准值

7.3 代码逻辑讲解

cpp 复制代码
int QuickSort(int* a, int left, int right)  // 实际是分区函数
{
    int prev = left;          // prev指向最后一个小于基准值的位置(初始为left)
    int cur = left + 1;       // cur用于遍历数组(从left+1开始)
    int key = left;           // 基准值位置(最左边)
    
    while (cur <= right)      // 遍历[left+1, right]区间
    {
        // 关键判断:当前元素小于基准值
        if (a[cur] < a[key] && ++prev != cur)
        {
            swap(&a[cur], &a[prev]);  // 将小元素交换到前面
        }
        ++cur;  // cur指针始终前进
    }
    
    swap(&a[key], &a[prev]);  // 将基准值放到正确位置
    return prev;              // 返回基准值最终位置
}

7.4 关键细节讲解

7.4.1 双指针的含义

cpp 复制代码
int prev = left;      // 指向"小于基准值区域"的末尾
int cur = left + 1;   // 遍历指针,寻找小于基准值的元素
  • prev 区域[left+1, prev] 都是小于基准值的元素

  • cur 区域[prev+1, cur-1] 都是大于等于基准值的元素

  • 未处理区域[cur, right] 待检查

7.4.2 巧妙的判断条件

cpp 复制代码
if (a[cur] < a[key] && ++prev != cur)
  • a[cur] < a[key]:找到小于基准值的元素

  • ++prev:先让 prev 前进一位,扩展"小值区域"

  • != cur:如果 prev 和 cur 相同,说明它们同步前进,无需交换

  • 短路求值:先判断大小,再执行 ++prev

7.4.3 为什么需要 ++prev != cur 判断?

cpp 复制代码
// 当 prev 和 cur 相邻时(prev+1 == cur)
// 说明它们同步前进,不需要交换
// 交换相同位置是冗余操作

// 示例:prev=2, cur=3
// a[3] < key → ++prev=3, prev==cur → 不交换
// 如果交换,就是 a[3] 和 a[3] 交换,无意义

7.5 完整快排实现

cpp 复制代码
// 双指针法分区
int Partition(int* a, int left, int right)
{
    int keyi = left;
    int prev = left;
    
    for (int cur = left + 1; cur <= right; cur++)
    {
        if (a[cur] < a[keyi] && ++prev != cur)
        {
            swap(&a[prev], &a[cur]);
        }
    }
    
    swap(&a[keyi], &a[prev]);
    return prev;
}

// 快速排序主函数
void QuickSort(int* a, int left, int right)
{
    if (left >= right)
        return;
    
    // 可在此处添加三数取中优化
    // int mid = GetMidIndex(a, left, right);
    // swap(&a[left], &a[mid]);
    
    int keyi = Partition(a, left, right);
    
    QuickSort(a, left, keyi - 1);
    QuickSort(a, keyi + 1, right);
}

7.6 复杂度分析

与快排其他实现相同:

  • 时间复杂度

    • 平均:O(n log n)

    • 最坏:O(n²)(当数组有序时)

  • 空间复杂度

    • 平均:O(log n)(递归栈)

    • 最坏:O(n)

  • 稳定性不稳定(交换会改变相等元素顺序)

7.7 综合理解

双指针法快速排序通过两个同向移动的指针实现分区。prev指针指向小于基准值区域的末尾,cur指针遍历未处理部分。当cur找到小于基准值的元素时,先让prev前进一位扩展小值区,如果prev和cur不同(说明中间有大元素),就交换它们位置的元素。这样遍历完成后,所有小于基准值的元素都被交换到了前面。最后将基准值与prev位置的元素交换,基准值就归位到了正确位置。这种方法代码简洁,交换次数少,平均时间复杂度O(n log n),最坏O(n²),是不稳定的原地排序算法。它的优势在于实现简单且对重复元素处理较好。

八、快速排序(非递归版)

8.1 一句话概括

非递归快速排序使用栈(Stack)模拟递归过程,显式地保存待排序区间,通过循环不断取出区间进行分区操作,直到所有区间都排序完成。

8.2 核心思想

  • 栈替代递归 :用栈显式保存 [begin, end] 区间,代替递归调用的函数栈

  • 循环处理:循环从栈中取出区间,进行分区排序

  • 区间入栈:将分区产生的左右子区间压入栈中,等待后续处理

  • 迭代完成:当栈为空时,所有区间都已排序完成

8.3 代码逻辑讲解

cpp 复制代码
// 假设 ST 是栈结构,STPush 入栈,STPop 出栈,STTop 获取栈顶,STEmpty 判断栈空
while (!STEmpty(&st))          // 栈不为空时继续处理
{
    // 1. 从栈中取出一个待排序区间
    int begin = STTop(&st);    // 获取区间起始位置(栈顶)
    STPop(&st);                // 弹出
    
    int end = STTop(&st);      // 获取区间结束位置
    STPop(&st);                // 弹出
    
    // 2. 对区间 [begin, end] 进行单趟排序(双指针法)
    int keyi = begin;          // 基准值位置(最左边)
    int prev = begin;          // 双指针法分区
    int cur = begin + 1;
    
    while (cur <= end)
    {
        if (a[cur] < a[keyi] && ++prev != cur)
            Swap(&a[prev], &a[cur]);
        ++cur;
    }
    Swap(&a[keyi], &a[prev]);  // 基准值归位
    keyi = prev;               // 更新基准值位置
    
    // 3. 分区结果:区间分为三部分
    // [begin, keyi-1]  keyi  [keyi+1, end]
    // keyi 已在正确位置
    
    // 4. 将需要继续排序的子区间压入栈中
    // 注意:先处理右子区间,再处理左子区间(栈是LIFO)
    if (keyi + 1 < end)        // 右子区间有2个以上元素
    {
        STPush(&st, end);      // 先压入end
        STPush(&st, keyi + 1); // 再压入begin(右子区间的起始)
    }
    if (begin < keyi - 1)      // 左子区间有2个以上元素
    {
        STPush(&st, keyi - 1); // 先压入end
        STPush(&st, begin);    // 再压入begin(左子区间的起始)
    }
}
STDestroy(&st);                // 销毁栈,释放资源

8.4 具体执行过程示例

数组[6, 1, 2, 7, 9, 3, 4, 5, 10, 8],n=10

初始

  • 栈初始状态:push end=9, push begin=0 → 栈:[9, 0](底部是0,顶部是9)

第一轮

  • 出栈:end=9, begin=0

  • 分区:[6,1,2,7,9,3,4,5,10,8] → [5,1,2,3,4,6,9,7,10,8],keyi=5

  • 子区间:[0,4] 和 [6,9]

  • 入栈:先右后左

    1. keyi+1=6 < end=9 → push 9, push 6(右区间[6,9])

    2. begin=0 < keyi-1=4 → push 4, push 0(左区间[0,4])

  • 栈状态:[4,0,9,6](栈顶是6)

第二轮

  • 出栈:end=9, begin=6(右区间[6,9])

  • 分区:[9,7,10,8] → [8,7,9,10],keyi=8(相对于原数组索引8)

  • 子区间:[6,7] 和 [9,9]

  • 入栈:

    1. keyi+1=9 < end=9? 否(9<9不成立)→ 右区间不压栈

    2. begin=6 < keyi-1=7 → push 7, push 6(左区间[6,7])

  • 栈状态:[4,0,7,6]

继续处理...(按LIFO顺序)

最终:栈为空,数组有序

8.5 关键细节解析

8.5.1 栈中元素的存储顺序

cpp 复制代码
// 入栈顺序:先压end,再压begin
STPush(&st, end);      // 区间右边界
STPush(&st, begin);    // 区间左边界

// 出栈顺序:先出begin,再出end(LIFO)
int begin = STTop(&st); STPop(&st);
int end = STTop(&st); STPop(&st);
  • 为什么这样存储?:确保出栈时先得到begin,再得到end

  • 栈的特性:后进先出(LIFO),所以入栈顺序与出栈顺序相反

8.5.2 区间入栈的条件

cpp 复制代码
if (keyi + 1 < end)    // 右子区间至少有2个元素
if (begin < keyi - 1)  // 左子区间至少有2个元素
  • 为什么是 < 而不是 <=

    • keyi+1 < end:表示从 keyi+1 到 end 至少有2个元素

    • begin < keyi-1:表示从 begin 到 keyi-1 至少有2个元素

  • 单个元素无需排序:区间长度为1时已有序,不需要入栈

8.5.3 入栈顺序:先右后左

cpp 复制代码
// 先处理右子区间
if (keyi + 1 < end) {
    STPush(&st, end);
    STPush(&st, keyi + 1);
}
// 再处理左子区间
if (begin < keyi - 1) {
    STPush(&st, keyi - 1);
    STPush(&st, begin);
}
  • 栈的LIFO特性:后入栈的先处理

  • 先右后左的结果 :实际执行时是先处理左区间(因为左区间后入栈)

  • 模拟递归顺序:递归快排通常是先递归左子树,这刚好相反

8.5.4 模拟递归的遍历顺序

cpp 复制代码
// 递归快排:先左后右(深度优先)
QuickSort(arr, left, keyi-1);   // 先处理左
QuickSort(arr, keyi+1, right);  // 后处理右

// 非递归快排:取决于入栈顺序
// 先右后左入栈 → 实际先处理左(栈顶)
// 先左后右入栈 → 实际先处理右(栈顶)

8.5.5 栈与队列的选择

使用栈(Stack)

cpp 复制代码
// LIFO:深度优先遍历
// 模拟递归的调用顺序
// 内存使用:O(log n) ~ O(n)
  • 优点:模拟递归的自然顺序

  • 缺点:可能深度较大

使用队列(Queue)

cpp 复制代码
// FIFO:广度优先遍历
// 按层级处理所有区间
  • 优点:避免深度过大

  • 缺点:不模拟递归顺序,可能缓存不友好

8.6 复杂度分析

8.6.1 时间复杂度

  • 平均:O(n log n)(与递归版本相同)

  • 最坏:O(n²)(与递归版本相同)

8.6.2 空间复杂度

  • 栈空间:O(log n) ~ O(n)(取决于分区平衡性)

  • 总空间:O(log n) ~ O(n)(与递归版本相当)

8.6.3 优势

  1. 避免递归深度限制:可处理深度很大的排序

  2. 可控制内存:可检测栈大小,避免溢出

  3. 性能稳定:无函数调用开销

  4. 调试友好:线性执行流程

8.7 完整非递归快排实现

cpp 复制代码
// 栈结构定义(示例)
typedef struct {
    int* data;
    int top;
    int capacity;
} Stack;

void QuickSortNonR(int* a, int left, int right)
{
    Stack st;
    STInit(&st);
    
    // 初始区间入栈
    STPush(&st, right);
    STPush(&st, left);
    
    while (!STEmpty(&st))
    {
        int begin = STTop(&st); STPop(&st);
        int end = STTop(&st); STPop(&st);
        
        // 小区间优化
        if (end - begin + 1 < 16)
        {
            InsertSort(a + begin, end - begin + 1);
            continue;
        }
        
        // 三数取中优化
        int mid = GetMidIndex(a, begin, end);
        Swap(&a[begin], &a[mid]);
        
        // 双指针法分区
        int keyi = Partition(a, begin, end);
        
        // 区间入栈(优化:先处理小区间)
        int left_len = keyi - begin;
        int right_len = end - keyi;
        
        if (left_len < right_len)  // 左区间较小
        {
            // 先压右区间(较大)
            if (keyi + 1 < end)
            {
                STPush(&st, end);
                STPush(&st, keyi + 1);
            }
            // 再压左区间(较小)
            if (begin < keyi - 1)
            {
                STPush(&st, keyi - 1);
                STPush(&st, begin);
            }
        }
        else  // 右区间较小
        {
            // 先压左区间(较大)
            if (begin < keyi - 1)
            {
                STPush(&st, keyi - 1);
                STPush(&st, begin);
            }
            // 再压右区间(较小)
            if (keyi + 1 < end)
            {
                STPush(&st, end);
                STPush(&st, keyi + 1);
            }
        }
    }
    
    STDestroy(&st);
}

8.8 应用场景

  1. 深度限制环境:递归深度受限的系统

  2. 性能敏感场景:需要避免函数调用开销

  3. 调试需求:需要清晰执行流程的调试

  4. 教学演示:展示如何用循环代替递归

  5. 嵌入式系统:栈大小可控,避免溢出

8.9 综合理解

非递归快速排序通过手动维护一个栈来模拟递归过程。首先将整个数组区间入栈,然后循环从栈中取出区间进行分区排序。每次分区后,将产生的左右子区间(如果长度≥2)压入栈中继续处理。栈中存储区间边界时,采用先右边界后左边界的顺序,确保出栈时能正确重建区间。与递归版本相比,非递归版本避免了函数调用开销和递归深度限制,可以处理更深层的排序,同时调试更直观。时间复杂度仍然是平均O(n log n),最坏O(n²),空间复杂度取决于分区平衡性。实际使用中可以添加三数取中、小区间优化等策略提升性能。

九、堆排序

9.1 一句话概括

堆排序是一种基于完全二叉树-堆数据结构的比较排序算法,它通过构建最大堆(或最小堆),反复将堆顶元素(最大/最小值)与堆尾交换并调整堆,从而逐步得到有序序列。

9.2 核心思想

  • 建堆:将无序数组构建成一个堆(通常是大顶堆用于升序排序)

  • 调整堆:维护堆的性质(父节点 ≥ 子节点 或 父节点 ≤ 子节点)

  • 交换与调整:将堆顶元素(当前最大值)与堆尾交换,堆大小减1

  • 重复:对剩余元素重新调整,重复交换直到堆大小为1

9.3 代码逻辑框架

cpp 复制代码
// 堆排序主函数
void HeapSort(int* arr, int n)
{
    // 1. 建堆:从最后一个非叶子节点开始向下调整
    for (int i = n/2 - 1; i >= 0; i--)
    {
        AdjustDown(arr, n, i);
    }
    
    // 2. 排序:交换堆顶与堆尾,调整堆
    for (int end = n - 1; end > 0; end--)
    {
        Swap(&arr[0], &arr[end]);    // 堆顶最大值放到末尾
        AdjustDown(arr, end, 0);     // 对剩余元素调整堆
    }
}

// 向下调整函数
void AdjustDown(int* arr, int n, int parent)
{
    int child = parent * 2 + 1;      // 左孩子索引
    
    while (child < n)                // 孩子存在
    {
        // 选择较大的孩子(大顶堆)
        if (child + 1 < n && arr[child + 1] > arr[child])
        {
            child++;                 // 右孩子更大
        }
        
        // 如果孩子大于父亲,交换并继续向下
        if (arr[child] > arr[parent])
        {
            Swap(&arr[parent], &arr[child]);
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;                   // 堆性质已满足
        }
    }
}

9.4 关键细节解析

9.4.1 父子节点索引关系

cpp 复制代码
// 对于索引 i 的节点:
parent(i) = (i - 1) / 2;      // 父节点索引
left_child(i) = 2*i + 1;      // 左孩子索引
right_child(i) = 2*i + 2;     // 右孩子索引
  • 最后一个非叶子节点n/2 - 1(n是元素个数)

  • 数组下标从0开始的计算公式

9.4.2 建堆的两种方法

cpp 复制代码
// 方法1:向下调整法(O(n))
for (int i = n/2 - 1; i >= 0; i--)
    AdjustDown(arr, n, i);

// 方法2:向上调整法(O(n log n))
for (int i = 1; i < n; i++)
    AdjustUp(arr, i);
  • 向下调整更高效:从最后一个非叶子节点开始,复杂度O(n)

  • 向上调整:逐个插入,复杂度O(n log n)

9.4.3 向下调整算法

cpp 复制代码
void AdjustDown(int* arr, int n, int parent)
{
    int child = parent * 2 + 1;  // 左孩子
    while (child < n) {
        // 1. 选择更大的孩子(大顶堆)
        if (child + 1 < n && arr[child+1] > arr[child])
            child++;
        
        // 2. 如果孩子大于父亲,交换
        if (arr[child] > arr[parent]) {
            Swap(&arr[parent], &arr[child]);
            parent = child;      // 继续向下
            child = parent * 2 + 1;
        } else {
            break;               // 堆性质已满足
        }
    }
}

9.4.4 升序与降序的堆选择

cpp 复制代码
// 升序排序:使用大顶堆
if (arr[child] > arr[parent])  // 建大堆
if (arr[child + 1] > arr[child]) // 选大孩子

// 降序排序:使用小顶堆
if (arr[child] < arr[parent])  // 建小堆
if (arr[child + 1] < arr[child]) // 选小孩子

9.5 复杂度分析

9.5.1 时间复杂度

  • 建堆:O(n)(向下调整法)

    • 看起来是O(n log n),但通过数学证明是O(n)

    • 最后一层n/2个元素不需要调整,倒数第二层n/4个最多调整1次...

  • 排序过程:O(n log n)

    • 每次调整堆O(log n),执行n-1次
  • 总时间复杂度:O(n log n)

    • 最好、最坏、平均都是O(n log n)

9.5.2 空间复杂度:O(1)

  • 原地排序,只使用常数额外空间

9.5.3 稳定性不稳定

  • 示例:[2₁, 2₂, 1] 建大堆时,2₁和2₂可能交换顺序

9.6 优缺点

9.6.1 优点

  1. 时间复杂度稳定:始终是O(n log n),没有最坏情况退化

  2. 空间效率高:原地排序,O(1)空间复杂度

  3. 适合外排序:可以处理无法全部装入内存的大数据

  4. 并行潜力:建堆和调整可以并行化

9.6.2 缺点

  1. 缓存不友好:对数组跳跃访问,缓存命中率低

  2. 不稳定排序:相等元素可能改变相对顺序

  3. 实际性能:通常比快速排序慢(常数因子大)

  4. 实现复杂:比简单排序算法复杂

9.7 综合理解

堆排序利用堆这种完全二叉树数据结构进行排序。首先将数组构建成一个大顶堆(父节点≥子节点),这个过程从最后一个非叶子节点开始向前进行向下调整,时间复杂度O(n)。然后进入排序阶段:将堆顶元素(最大值)与堆尾交换,堆大小减1,再对堆顶进行向下调整恢复堆性质,重复这个过程直到堆大小为1。堆排序总是O(n log n)时间复杂度,没有最坏情况退化,空间复杂度O(1)是原地排序,但它是稳定排序,且由于对数组的跳跃访问,缓存不友好。它特别适合需要稳定时间复杂度或空间受限的场景,也是优先级队列和Top-K问题的基础算法。

十、归并排序

10.1 一句话概括

归并排序是一种典型的分治算法,它将数组递归地分成两半分别排序,然后将两个有序子数组合并成一个有序数组,最终完成整体排序。

10.2 核心思想

  • :将数组递归地分成两半,直到每个子数组只有一个元素(自然有序)

  • :对最小单元(单元素数组)来说,已经是有序的

  • :将两个有序子数组合并成一个更大的有序数组

  • 递归回溯:从最底层开始,层层合并,最终得到完全有序的数组

10.3 代码逻辑讲解

cpp 复制代码
// 归并排序主函数(对外接口)
void MergeSort(int* a, int n)
{
    int* tmp = new int[n];        // 创建临时数组(用于合并)
    _MergeSort(a, 0, n - 1, tmp); // 调用递归函数
    delete[] tmp;                 // 释放临时数组
}

// 递归归并排序函数
void _MergeSort(int* a, int left, int right, int* tmp)
{
    // 1. 递归终止条件:区间只有一个元素或为空
    if (left >= right)
    {
        return;
    }
    
    // 2. 分:计算中点,将数组分成两半
    int mid = (right + left) / 2;  // 等价于 left + (right - left) / 2
    
    // 3. 递归排序左右两半
    // [left, mid] 和 [mid+1, right]
    _MergeSort(a, left, mid, tmp);
    _MergeSort(a, mid + 1, right, tmp);
    
    // 4. 合:合并两个有序子数组
    int begin1 = left, end1 = mid;      // 左子数组范围
    int begin2 = mid + 1, end2 = right; // 右子数组范围
    int index = begin1;                 // 临时数组的写入位置
    
    // 5. 合并过程(双指针法)
    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] < a[begin2])
        {
            tmp[index++] = a[begin1++];  // 取左子数组元素
        }
        else
        {
            tmp[index++] = a[begin2++];  // 取右子数组元素
        }
    }
    
    // 6. 处理剩余元素(只有一个子数组还有剩余)
    while (begin1 <= end1)
    {
        tmp[index++] = a[begin1++];
    }
    while (begin2 <= end2)
    {
        tmp[index++] = a[begin2++];
    }
    
    // 7. 将临时数组的内容拷贝回原数组
    for (int i = left; i <= right; i++)
    {
        a[i] = tmp[i];
    }
}

10.4 具体执行过程示例

数组[8, 3, 1, 7, 0, 4, 6, 2],n=8

递归分解过程

初始:[8,3,1,7,0,4,6,2]

第一层分:[8,3,1,7] 和 [0,4,6,2]

第二层分:[8,3] [1,7] | [0,4] [6,2]

第三层分:[8] [3] [1] [7] | [0] [4] [6] [2] ← 单元素,自然有序

递归合并过程(从底层向上)

第三层合并:

3,8\] ← 合并 \[8\] 和 \[3

1,7\] ← 合并 \[1\] 和 \[7

0,4\] ← 合并 \[0\] 和 \[4

2,6\] ← 合并 \[6\] 和 \[2

第二层合并:

1,3,7,8\] ← 合并 \[3,8\] 和 \[1,7

0,2,4,6\] ← 合并 \[0,4\] 和 \[2,6

第一层合并:

0,1,2,3,4,6,7,8\] ← 合并 \[1,3,7,8\] 和 \[0,2,4,6

一次合并的详细步骤 (以合并 [3,8][1,7] 为例):

左数组:[3,8] (begin1=0, end1=1)

右数组:[1,7] (begin2=2, end2=3)

临时数组:tmp[0..3]

  1. 比较 a[0]=3 和 a[2]=1 → 1小 → tmp[0]=1, begin2=3

  2. 比较 a[0]=3 和 a[3]=7 → 3小 → tmp[1]=3, begin1=1

  3. 比较 a[1]=8 和 a[3]=7 → 7小 → tmp[2]=7, begin2=4

  4. 右数组用完,复制剩余左数组元素:tmp[3]=8

结果:[1,3,7,8] 复制回原数组

10.5 关键细节解析

10.5.1 分治策略

cpp 复制代码
// 分
int mid = left + (right - left) / 2;  // 防止溢出
// 递归左右
_MergeSort(a, left, mid, tmp);
_MergeSort(a, mid + 1, right, tmp);
// 合
MergeTwoArrays(...);
  • 中点计算 :使用 left + (right - left) / 2 避免整数溢出

  • 递归深度:log₂n 层

10.5.2 合并过程(双指针法)

cpp 复制代码
while (begin1 <= end1 && begin2 <= end2)
{
    if (a[begin1] <= a[begin2])  // 注意:这里用 <= 保持稳定性
    {
        tmp[index++] = a[begin1++];
    }
    else
    {
        tmp[index++] = a[begin2++];
    }
}
  • 稳定性 :当元素相等时,优先取左子数组元素(<=),保证稳定性

  • 时间复杂度:合并两个长度为m和n的数组需要O(m+n)时间

10.5.3 临时数组的使用

cpp 复制代码
// 创建
int* tmp = new int[n];
// 使用
tmp[index++] = a[begin1++];
// 拷贝回
a[i] = tmp[i];
// 释放
delete[] tmp;
  • 作用:避免在合并时覆盖原数组数据

  • 空间复杂度:O(n) 额外空间

  • 优化:可以只创建一个临时数组,全程复用

10.5.4 递归终止条件

cpp 复制代码
if (left >= right)  // 区间为空或只有一个元素
{
    return;
}
  • >= 而不是 > :当 left == right 时,区间只有一个元素,自然有序

  • 最小子问题:单元素数组已经是有序的,不需要继续分解

10.6 复杂度分析

10.6.1 时间复杂度

  • 最好情况:O(n log n)

  • 最坏情况:O(n log n)

  • 平均情况:O(n log n)

  • 推导

    • 递归树深度:log₂n

    • 每层合并总工作量:O(n)

    • 总时间:O(n) × O(log n) = O(n log n)

10.6.2 空间复杂度

  • 递归栈:O(log n)(递归深度)

  • 临时数组:O(n)

  • 总空间:O(n)(临时数组主导)

10.6.3 稳定性:稳定排序

  • 合并时使用 <=,相等元素保持原有相对顺序

10.7 优缺点

10.7.1 优点

  1. 时间复杂度稳定:总是 O(n log n),没有最坏情况

  2. 稳定排序:保持相等元素的相对顺序

  3. 适合外排序:可以处理无法全部装入内存的大数据

  4. 链表友好:对链表排序时不需要额外空间

  5. 并行化容易:分治策略天然适合并行计算

10.7.3 缺点

  1. 空间复杂度高:需要 O(n) 额外空间

  2. 非原地排序:需要复制数据

  3. 小数据效率低:递归开销较大

  4. 实现较复杂:比简单排序算法复杂

10.8 综合理解

归并排序采用分治策略,将数组递归地分成两半直到每个子数组只有一个元素(自然有序),然后通过合并操作将有序子数组合并成更大的有序数组。合并过程使用双指针法,比较两个子数组的当前元素,将较小的放入临时数组。归并排序的时间复杂度始终是O(n log n),空间复杂度O(n),是稳定排序算法。它的优势在于稳定性和对大数据、链表排序的适用性,但需要额外存储空间。归并排序也是外排序和并行计算的基础算法。


结语

掌握这些排序方法后,不妨尝试在项目中实践。如果有其他高效算法,期待你的分享!

看到这里请各位动动小手给博主点个四连吧,谢谢啦!!!

相关推荐
Yolo_TvT1 小时前
数据结构:双链表
网络·数据结构
AKDreamer_HeXY1 小时前
AtCoder Beginner Contest 434 C-E 题解
c++·算法·前缀和·图论·差分·atcoder
geekmice1 小时前
在单线程环境下,同一个 Service 中多个方法需要复用某个 List
数据结构·windows·list
roman_日积跬步-终至千里1 小时前
【模式识别与机器学习(4)】主要算法与技术(中篇:概率统计与回归方法)之线性回归模型
算法·机器学习
小李小李快乐不已1 小时前
图论理论基础(2)
java·开发语言·c++·算法·图论
点云SLAM1 小时前
四元数 (Quaternion)微分-单位四元数 q(t) 的导数详细推导(10)
算法·计算机视觉·机器人·slam·imu·四元数·单位四元数求导
秋邱1 小时前
2025 年突破性科技:大模型驱动的实时多模态数据流处理系统
人工智能·科技·算法·机器学习
sin_hielo1 小时前
leetcode 2141
数据结构·算法·leetcode
qq_433554541 小时前
C++ 最长单调子序列
c++·算法·图论