数据结构:排序(下)---进阶排序算法详解

个人主页:
wengqidaifeng

✨ 永远在路上,永远向前走

个人专栏:
数据结构
C语言
嵌入式小白启动!
重要OJ算法题详解
蓝桥杯备战

文章目录

    • 前言
    • [1. 快速排序 (Quick Sort)](#1. 快速排序 (Quick Sort))
      • [1.1 基本思想](#1.1 基本思想)
      • [1.2 算法步骤](#1.2 算法步骤)
      • [1.3 霍尔版本 (Hoare Partition Scheme)](#1.3 霍尔版本 (Hoare Partition Scheme))
        • [1.3.1 单趟排序过程](#1.3.1 单趟排序过程)
        • [1.3.2 相遇位置分析](#1.3.2 相遇位置分析)
        • [1.3.3 递归实现](#1.3.3 递归实现)
        • [1.3.4 递归深度分析](#1.3.4 递归深度分析)
      • [1.4 快速排序的优化策略](#1.4 快速排序的优化策略)
        • [1.4.1 问题:有序情况下的性能退化](#1.4.1 问题:有序情况下的性能退化)
        • [1.4.2 优化一:三数取中法](#1.4.2 优化一:三数取中法)
        • [1.4.3 优化二:小区间优化](#1.4.3 优化二:小区间优化)
      • [1.5 挖坑法 (Pit Method)](#1.5 挖坑法 (Pit Method))
        • [1.5.1 方法对比](#1.5.1 方法对比)
        • [1.5.2 核心原理](#1.5.2 核心原理)
        • [1.5.3 挖坑法优势](#1.5.3 挖坑法优势)
        • [1.5.4 代码实现](#1.5.4 代码实现)
        • [1.5.5 执行过程示例](#1.5.5 执行过程示例)
      • [1.6 前后指针法 (Two Pointers Method)](#1.6 前后指针法 (Two Pointers Method))
        • [1.6.1 基本思想](#1.6.1 基本思想)
        • [1.6.2 算法步骤](#1.6.2 算法步骤)
        • [1.6.3 代码实现](#1.6.3 代码实现)
        • [1.6.4 三种划分方法对比](#1.6.4 三种划分方法对比)
      • [1.7 快速排序的非递归实现](#1.7 快速排序的非递归实现)
        • [1.7.1 为什么需要非递归实现](#1.7.1 为什么需要非递归实现)
        • [1.7.2 用栈模拟递归](#1.7.2 用栈模拟递归)
        • [1.7.3 栈操作注意事项](#1.7.3 栈操作注意事项)
    • [2. 归并排序 (Merge Sort)](#2. 归并排序 (Merge Sort))
      • [2.1 基本思想](#2.1 基本思想)
      • [2.2 算法特点](#2.2 算法特点)
      • [2.3 递归实现](#2.3 递归实现)
      • [2.4 重要注意事项](#2.4 重要注意事项)
        • [2.4.1 区间划分问题](#2.4.1 区间划分问题)
        • [2.4.2 递归过程可视化](#2.4.2 递归过程可视化)
      • [2.5 非递归实现](#2.5 非递归实现)
        • [2.5.1 越界情况分析](#2.5.1 越界情况分析)
      • [2.6 归并排序优缺点总结](#2.6 归并排序优缺点总结)
    • [3. 快速排序 vs 归并排序 对比](#3. 快速排序 vs 归并排序 对比)
    • [4. 总结与建议](#4. 总结与建议)
      • [4.1 算法选择指南](#4.1 算法选择指南)
      • [4.2 学习建议](#4.2 学习建议)

前言

在上篇中,我们学习了四种基础排序算法:直接插入排序、希尔排序、直接选择排序和冒泡排序。这些算法的时间复杂度普遍在 O(N²) 级别,虽然在小规模数据上表现尚可,但面对大规模数据时效率较低。

本篇将介绍两种更高效的排序算法------快速排序归并排序,它们的平均时间复杂度达到了 O(N log N),是目前应用最广泛的排序算法。


1. 快速排序 (Quick Sort)

1.1 基本思想

快速排序是由图灵奖得主 Tony Hoare 于 1960 年提出的一种基于分治策略的排序算法。

核心思想

对于一段序列,先选取一个元素作为基准值(key),通过一趟排序将待排序序列分割成两部分:

  • 左子序列:所有元素都小于基准值
  • 右子序列:所有元素都大于基准值

然后对左右子序列分别递归地进行快速排序,直到整个序列有序。

1.2 算法步骤

  1. 从数列中挑出一个元素,称为"基准"(pivot)
  2. 重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面
  3. 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序

1.3 霍尔版本 (Hoare Partition Scheme)

这是快速排序最原始的划分方式,由算法发明者 Tony Hoare 提出。

1.3.1 单趟排序过程

设两个指针:

  • L (left):指向序列的第一个位置
  • R (right):指向序列的最后一个位置

执行步骤

  1. L 初始所指的值定为 key
  2. R 先向前移动,找到比 key 的位置
  3. R 找到后保持不动,L 向后移动,找比 key 的位置
  4. 交换 RL 位置的数值
  5. 重复步骤 2-4,直到 RL 相遇
  6. 将相遇位置的值与 key 的值交换

此时,key 左边的值都比 key 小,key 右边的值都比 key 大,key 已经排好了。

1.3.2 相遇位置分析

重要结论:左边做 key,右边先走,可以保证相遇位置的值比 key 小。

相遇场景分析

场景一:L 遇到 R

  • R 先走,停下来
  • R 停下来的条件是遇到比 key 小的值
  • 因此 R 停的位置一定比 key 小
  • L 没有找到比 key 大的,遇到 R 停下来

场景二:R 遇到 L

  • R 先走,找比 key 小的值,没有找到
  • 直接跟 L 相遇了
  • L 停留的位置是上一轮交换的位置
  • 上一轮交换把比 key 小的值换到了 L 的位置

注意:如果让右边的值作为 key,左边先走,可以保证相遇位置比 key 大。这对应的是降序排序的逻辑。

1.3.3 递归实现
cpp 复制代码
void QuickSort(int* a, int left, int right) {
    if (left >= right) {
        return;  // 区间只剩一个值或者不存在,递归结束
    }
    
    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]);
    keyi = begin;
    
    // 递归处理左右子区间
    // [left, keyi-1]  keyi  [keyi+1, right]
    QuickSort(a, left, keyi - 1);
    QuickSort(a, keyi + 1, right);
}
1.3.4 递归深度分析
  • 最好情况:每次划分均匀,递归树深度为 log N,每层处理 N 个元素,时间复杂度 O(N log N)
  • 最坏情况:序列已经有序,每次只划分出一个元素,递归树深度为 N,时间复杂度退化为 O(N²)

1.4 快速排序的优化策略

1.4.1 问题:有序情况下的性能退化

当序列已经有序时,如果固定选择最左边(或最右边)作为 key,会发生:

  • 每次划分极不均衡,一边为空,一边为 N-1 个元素
  • 递归深度达到 N,可能导致栈溢出
  • 时间复杂度退化为 O(N²)
1.4.2 优化一:三数取中法

通过选择合理的基准值来避免最坏情况。选取最左边、最右边和中间三个位置的元素,取其中值的大小处于中间的那个作为基准值。

cpp 复制代码
// 三数取中:返回三个数中处于中间大小的那个数的索引
int GetMidi(int* a, int left, int right) {
    int midi = (left + right) / 2;
    
    // 比较 left, midi, right 三者的大小关系
    if (a[left] < a[midi]) {
        if (a[midi] < a[right]) {
            return midi;  // left < midi < right
        } else if (a[left] < a[right]) {
            return right; // left < right < midi
        } else {
            return left;  // right < left < midi
        }
    } else {  // a[left] > a[midi]
        if (a[midi] > a[right]) {
            return midi;  // left > midi > right
        } else if (a[left] < a[right]) {
            return left;  // midi < left < right
        } else {
            return right; // midi < right < left
        }
    }
}

使用三数取中优化后的快速排序

cpp 复制代码
void QuickSort(int* a, int left, int right) {
    if (left >= right) {
        return;
    }
    
    // 三数取中,避免最坏情况
    int midi = GetMidi(a, left, right);
    Swap(&a[left], &a[midi]);  // 将选中的基准换到最左边
    
    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]);
    keyi = begin;
    
    QuickSort(a, left, keyi - 1);
    QuickSort(a, keyi + 1, right);
}
1.4.3 优化二:小区间优化

当递归到区间较小时,快速排序的递归开销相对较大。此时可以改用直接插入排序,减少递归调用次数。

cpp 复制代码
void QuickSort(int* a, int left, int right) {
    if (left >= right) {
        return;
    }
    
    // 小区间优化:区间长度小于10时,改用插入排序
    if ((right - left + 1) < 10) {
        InsertSort(a + left, right - left + 1);
        return;
    }
    
    // 三数取中
    int midi = GetMidi(a, left, right);
    Swap(&a[left], &a[midi]);
    
    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]);
    keyi = begin;
    
    QuickSort(a, left, keyi - 1);
    QuickSort(a, keyi + 1, right);
}

面试技巧:手撕代码时,可以不写三数取中和小区间优化,但在讲解思路时提到这些优化点,能体现你对算法的深入理解。

1.5 挖坑法 (Pit Method)

1.5.1 方法对比
对比维度 霍尔方法 挖坑法
交换方式 左右指针找到目标后两两交换 用"坑位"概念,找到目标后填入坑位
key 的处理 保持在原位,最后与相遇点交换 提前保存,形成初始坑位,最后填入相遇坑
理解难度 需要分析"为什么左边做 key 右边先走" 不需要分析指针顺序,逻辑更直观
1.5.2 核心原理
  1. 将第一个数据保存在临时变量 key 中,该位置形成坑位 (hole)
  2. R 向前移动,找比 key 小的值
  3. 找到后,将该值放入坑位,原位置成为新坑位
  4. L 向后移动,找比 key 大的值
  5. 找到后,将该值放入坑位,原位置成为新坑位
  6. 重复步骤 2-5,直到 LR 相遇
  7. key 的值放入最后的坑位
1.5.3 挖坑法优势
  • 物理过程直观:想象从序列中"挖走" key 形成坑,然后用符合条件的元素"填坑"
  • 无需分析指针顺序:不用纠结左右指针的先后顺序问题
  • 相遇即坑位:左右指针相遇的位置必然是当前坑位,直接填入 key 即可
1.5.4 代码实现
cpp 复制代码
// 挖坑法单趟排序
int PartSort_Hole(int* a, int left, int right) {
    // 三数取中
    int midi = GetMidi(a, left, right);
    Swap(&a[left], &a[midi]);
    
    int key = a[left];      // 保存 key 值
    int hole = left;        // 初始坑位在 left 位置
    int begin = left;
    int end = right;
    
    while (begin < end) {
        // 右边找小,填入坑位
        while (begin < end && a[end] >= key) {
            --end;
        }
        a[hole] = a[end];   // 将小的值填入坑位
        hole = end;         // end 位置成为新坑
        
        // 左边找大,填入坑位
        while (begin < end && a[begin] <= key) {
            ++begin;
        }
        a[hole] = a[begin]; // 将大的值填入坑位
        hole = begin;       // begin 位置成为新坑
    }
    
    a[hole] = key;          // 将 key 填入最终的坑位
    return hole;            // 返回 key 的最终位置
}

void QuickSort_Hole(int* a, int left, int right) {
    if (left >= right) return;
    
    int keyi = PartSort_Hole(a, left, right);
    
    QuickSort_Hole(a, left, keyi - 1);
    QuickSort_Hole(a, keyi + 1, right);
}
1.5.5 执行过程示例

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

步骤 操作 数组状态(_ 表示坑位) 坑位位置
初始 key=6 [_, 1, 2, 7, 9, 3, 4, 5, 10, 8] 0
右找小(5) 填入坑0 [5, 1, 2, 7, 9, 3, 4, _, 10, 8] 7
左找大(7) 填入坑7 [5, 1, 2, _, 9, 3, 4, 7, 10, 8] 3
右找小(4) 填入坑3 [5, 1, 2, 4, 9, 3, _, 7, 10, 8] 6
左找大(9) 填入坑6 [5, 1, 2, 4, _, 3, 9, 7, 10, 8] 4
右找小(3) 填入坑4 [5, 1, 2, 4, 3, _, 9, 7, 10, 8] 5
相遇 key填入坑5 [5, 1, 2, 4, 3, 6, 9, 7, 10, 8] -

结果:6 左边的元素 [5,1,2,4,3] 都小于 6,右边的元素 [9,7,10,8] 都大于 6。

1.6 前后指针法 (Two Pointers Method)

1.6.1 基本思想

使用两个指针 prevcur

  • prev:指向已处理区间中最后一个小于 key 的元素
  • cur:扫描指针,寻找小于 key 的元素
1.6.2 算法步骤
  1. prev 指向序列开头,cur 指向 prev 的后一个位置
  2. cur 向后遍历:
    • 如果 a[cur] < keyprev 先后移一位,然后交换 a[prev]a[cur]
    • 如果 a[cur] >= keycur 继续后移
  3. 遍历结束后,交换 a[prev]key
1.6.3 代码实现
cpp 复制代码
// 前后指针法单趟排序
int PartSort_TwoPointers(int* a, int left, int right) {
    // 三数取中
    int midi = GetMidi(a, left, right);
    Swap(&a[left], &a[midi]);
    
    int keyi = left;
    int prev = left;
    int cur = prev + 1;
    
    while (cur <= right) {
        // cur 找到小于 key 的值,且 prev 和 cur 不指向同一位置时才交换
        if (a[cur] < a[keyi] && ++prev != cur) {
            Swap(&a[prev], &a[cur]);
        }
        cur++;
    }
    
    Swap(&a[prev], &a[keyi]);
    return prev;
}

void QuickSort_TwoPointers(int* a, int left, int right) {
    if (left >= right) return;
    
    int keyi = PartSort_TwoPointers(a, left, right);
    
    QuickSort_TwoPointers(a, left, keyi - 1);
    QuickSort_TwoPointers(a, keyi + 1, right);
}
1.6.4 三种划分方法对比
方法 复杂度 代码量 理解难度 适用场景
霍尔法 相同 中等 较高 原始经典实现
挖坑法 相同 中等 较低 易于理解,教学常用
前后指针法 相同 简洁 中等 代码优雅,面试推荐

1.7 快速排序的非递归实现

1.7.1 为什么需要非递归实现

递归实现的快速排序在极端情况下可能导致栈溢出(深度达到 N)。使用非递归实现可以:

  • 避免栈溢出风险
  • 更好地控制内存使用
  • 在某些环境(如嵌入式系统)中递归受限
1.7.2 用栈模拟递归

利用栈这种数据结构来存储待排序的区间,模拟递归的过程:

  1. 将初始区间 [left, right] 入栈
  2. 循环从栈中取出区间进行单趟排序
  3. 将划分后的左右子区间分别入栈
  4. 重复步骤 2-3,直到栈为空
cpp 复制代码
#include "Stack.h"

// 用栈实现非递归快速排序
void QuickSortNonR(int* a, int left, int right) {
    ST st;
    STInit(&st);
    
    // 初始区间入栈(注意:先入右边界,后入左边界)
    STPush(&st, right);
    STPush(&st, left);
    
    // 循环每走一次相当于一次递归
    while (!STEmpty(&st)) {
        int begin = STTop(&st);
        STPop(&st);
        int end = STTop(&st);
        STPop(&st);
        
        // 单趟排序(这里使用前后指针法)
        int keyi = PartSort_TwoPointers(a, begin, end);
        
        // 处理划分出的子区间
        // [begin, keyi-1]  keyi  [keyi+1, end]
        
        // 右子区间入栈
        if (keyi + 1 < end) {
            STPush(&st, end);
            STPush(&st, keyi + 1);
        }
        
        // 左子区间入栈
        if (begin < keyi - 1) {
            STPush(&st, keyi - 1);
            STPush(&st, begin);
        }
    }
    
    STDestroy(&st);
}
1.7.3 栈操作注意事项
  • 入栈顺序:由于栈是后进先出(LIFO),如果希望先处理左区间,应该先入右区间再入左区间
  • 区间有效性:入栈前需要判断区间长度是否大于 1,避免无效操作

2. 归并排序 (Merge Sort)

2.1 基本思想

归并排序是采用分治策略的经典排序算法,由冯·诺依曼于 1945 年提出。

核心思想

  1. 分割:将数组从中间分成两部分
  2. 递归排序:对分割后的两部分分别递归排序
  3. 合并:将两个已排序的子数组合并成一个有序数组

2.2 算法特点

  • 时间复杂度:O(N log N),且非常稳定,不受输入数据影响
  • 空间复杂度:O(N),需要额外的辅助数组
  • 稳定性:稳定
  • 适用场景:大量数据的排序,特别是外部排序的基础

2.3 递归实现

cpp 复制代码
// 时间复杂度:O(N*logN)
// 空间复杂度:O(N)
void _MergeSort(int* a, int* tmp, int begin, int end) {
    if (begin >= end) {
        return;  // 区间只剩一个值或不存在,递归结束
    }
    
    int mid = (begin + end) / 2;
    
    // 递归分割并排序
    // 注意:必须是 [begin, mid] 和 [mid+1, end] 的划分方式
    _MergeSort(a, tmp, begin, mid);
    _MergeSort(a, tmp, mid + 1, end);
    
    // 归并两个有序子数组
    int begin1 = begin, end1 = mid;
    int begin2 = mid + 1, end2 = end;
    int i = begin;
    
    // 比较两个子数组,将较小的元素放入 tmp
    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 + begin, tmp + begin, (end - begin + 1) * sizeof(int));
}

void MergeSort(int* a, int n) {
    // 预先分配辅助空间,避免频繁 malloc
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL) {
        perror("malloc fail");
        return;
    }
    
    _MergeSort(a, tmp, 0, n - 1);
    
    free(tmp);
    tmp = NULL;
}

2.4 重要注意事项

2.4.1 区间划分问题

错误划分[begin, mid-1][mid, end]

  • 这种划分方式在某些情况下会导致死循环
  • 例如当 begin = 0, end = 1 时,mid = 0[0, -1][0, 1],右区间与原区间相同,陷入无限递归

正确划分[begin, mid][mid+1, end]

  • 确保每次递归区间长度严格减小
2.4.2 递归过程可视化

2.5 非递归实现

归并排序的非递归实现通常采用自底向上的方式,使用循环控制每次归并的子数组大小。

cpp 复制代码
// 归并排序非递归实现(自底向上)
void MergeSortNonR(int* a, int n) {
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL) {
        perror("malloc fail");
        return;
    }
    
    int gap = 1;  // gap 表示每组归并数据的个数
    
    while (gap < n) {
        // 每轮处理所有长度为 gap 的子数组对
        for (int i = 0; i < n; i += 2 * gap) {
            // 计算两个子数组的边界
            int begin1 = i, end1 = i + gap - 1;
            int begin2 = i + gap, end2 = i + 2 * gap - 1;
            
            // 越界处理(关键!)
            // 第二组完全不存在,不需要归并
            if (begin2 >= n) {
                break;
            }
            // 第二组部分存在,修正结束位置
            if (end2 >= n) {
                end2 = n - 1;
            }
            
            int j = i;
            // 归并两个有序子数组
            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;
    }
    
    free(tmp);
    tmp = NULL;
}
2.5.1 越界情况分析

非递归实现中,由于数组长度 n 不一定是 2 的幂次,边界计算可能出现越界:

越界情况 条件 处理方式
第二组完全越界 begin2 >= n 直接 break,不需要归并
第二组部分越界 end2 >= n 修正 end2 = n - 1,继续归并
第一组部分越界 end1 >= n 已包含在上一种情况中

2.6 归并排序优缺点总结

优点

  • 稳定的 O(N log N) 时间复杂度,不受输入数据影响
  • 稳定排序
  • 是外部排序的基础

缺点

  • 需要 O(N) 的额外空间
  • 常数因子比快速排序大,实际速度可能稍慢

3. 快速排序 vs 归并排序 对比

对比维度 快速排序 归并排序
平均时间复杂度 O(N log N) O(N log N)
最坏时间复杂度 O(N²)(可优化避免) O(N log N)
空间复杂度 O(log N)(递归栈) O(N)(辅助数组)
稳定性 不稳定 稳定
适用场景 内存排序,追求平均速度 外部排序,需要稳定性
缓存友好性 较好(原地操作) 较差(需要辅助空间)

4. 总结与建议

4.1 算法选择指南

场景 推荐算法 原因
小数据量(<50) 直接插入排序 简单高效,常数因子小
中等数据量(50~10000) 希尔排序 实现简单,性能良好
大数据量,内存充足 快速排序 平均性能最优
需要稳定性 归并排序 稳定的 O(N log N)
链表排序 归并排序 不需要随机访问
外部排序 归并排序 天然适合分组合并

4.2 学习建议

  1. 理解思想优先:先理解算法的核心思想,再关注实现细节
  2. 动手实践:每种算法至少手写 3 遍
  3. 对比记忆:将相似算法放在一起对比学习
  4. 关注边界条件:排序算法的 Bug 往往出现在边界处理上

(下篇完。至此,常见排序算法的学习已全部完成。)

相关推荐
MicroTech20252 小时前
突破单机量子计算限制:MLGO微算法科技的新型分布式量子算法模拟平台实现高效验证
科技·算法·量子计算
没有天赋那就反复2 小时前
C++里面引用参数和实参的区别
开发语言·c++·算法
wengqidaifeng2 小时前
数据结构:排序(上)---基础排序算法详解
数据结构·算法·排序算法
Zlssszls2 小时前
机器人马拉松的第二年,比的是其背后的隐形赛场:具身训练工具链
算法·机器人
shylyly_2 小时前
sizeof 和 strlen的理解与区分
c语言·算法·strlen·sizeof
m0_743106462 小时前
【浙大&南洋理工最新综述】Feed-Forward 3D Scene Modeling(五)
人工智能·算法·计算机视觉·3d·几何学
Sam_Deep_Thinking11 小时前
学数据结构到底有什么用
数据结构
kobesdu11 小时前
人形机器人SLAM:技术挑战、算法综述与开源方案
算法·机器人·人形机器人
椰羊~王小美13 小时前
随机数概念及算法
算法