C++ 常见的排序算法详解

今天来系统地梳理一下常见的排序算法。包括冒泡、选择、插入、希尔、归并、快排、堆排,还有非比较类的如计数、桶排、基数。排序是算法的基础,深入理解它们对编程思维和性能优化至关重要。

接下来将从简单到复杂,逐一剖析它们的核心思想、步骤、复杂度、优缺点,以及C++的实现代码以及适用场景。


1. 概述

排序算法可以分为两大类:

  • 比较类排序:通过比较元素间的相对次序来进行排序。其平均时间复杂度不可能突破 O(n log n)。
  • 非比较类排序:不通过比较来决定元素次序,而是利用额外的信息(如整数的数值范围)。它可以突破基于比较排序的时间下界,达到线性时间复杂度,但通常有特定的适用条件。

2. 常见排序算法详解

一、 冒泡排序 (Bubble Sort)
  • 核心思想:重复地遍历待排序序列,一次比较两个相邻元素,如果它们的顺序错误就把它们交换过来。遍历序列的工作是重复地进行直到没有再需要交换,也就是说该序列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端。

  • 过程

    1. 比较相邻的元素。如果第一个比第二个大,就交换它们两个。
    2. 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数
    3. 针对所有的元素重复以上的步骤,除了最后一个(以及后续已经排序好的)。
    4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
  • C++实现

    cpp 复制代码
    void bubbleSort(vector<int>& arr) {
        int n = arr.size();
        for (int i = 0; i < n - 1; ++i) {
            bool swapped = false; // 优化:如果某一轮没有交换,说明已有序,可提前结束
            for (int j = 0; j < n - 1 - i; ++j) {
                if (arr[j] > arr[j + 1]) {
                    swap(arr[j], arr[j + 1]);
                    swapped = true;
                }
            }
            if (!swapped) break; // 本轮无交换,提前结束
        }
    }
  • 复杂度分析

    • 时间复杂度:
      • 最好情况(已有序):O(n)(优化后)。
      • 最坏/平均情况:O(n²)。
    • 空间复杂度:O(1),是原地排序
    • 稳定性稳定(相等元素不会交换)。
  • 评价:效率很低,除了教学和极小的数据集,基本不会在实际中使用。


二、 选择排序 (Selection Sort)
  • 核心思想:在未排序序列中找到最小(或最大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。

  • 过程

    1. 在序列中找到最小元素。
    2. 将其与序列第一个元素交换(如果第一个元素就是最小元素则和自己交换)。
    3. 在剩余未排序序列中重复上述过程。
  • C++实现

    cpp 复制代码
    void selectionSort(vector<int>& arr) {
        int n = arr.size();
        for (int i = 0; i < n - 1; ++i) {
            int minIndex = i;
            for (int j = i + 1; j < n; ++j) { // 寻找未排序部分的最小值下标
                if (arr[j] < arr[minIndex]) {
                    minIndex = j;
                }
            }
            swap(arr[i], arr[minIndex]); // 将找到的最小值放到已排序序列的末尾
        }
    }
  • 复杂度分析

    • 时间复杂度:无论什么数据输入都是 O(n²)。因为寻找最小值的循环必须执行完。
    • 空间复杂度:O(1),是原地排序
    • 稳定性不稳定 。例如序列 [5, 8, 5, 2, 9],第一个5会和2交换,导致两个5的相对顺序改变。
  • 评价:相比冒泡排序,它的交换次数更少(最多交换 n-1 次),但时间复杂度依然很高,适用于数据量小且对交换开销敏感的场景。


三、 插入排序 (Insertion Sort)
  • 核心思想:通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

  • 过程

    1. 将第一个元素视为已排序序列。
    2. 取出下一个元素,在已经排序的元素序列中从后向前扫描。
    3. 如果该元素(已排序)大于新元素,将该元素移到下一位置。
    4. 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置。
    5. 将新元素插入到该位置后。
    6. 重复步骤2~5。
  • C++实现

    cpp 复制代码
    void insertionSort(vector<int>& arr) {
        int n = arr.size();
        for (int i = 1; i < n; ++i) { // i从1开始,认为arr[0]是已排序的
            int key = arr[i]; // 待插入的元素
            int j = i - 1;
            // 将arr[0..i-1]中比key大的元素都向后移动一位
            while (j >= 0 && arr[j] > key) {
                arr[j + 1] = arr[j];
                j--;
            }
            arr[j + 1] = key; // 插入到正确位置
        }
    }
  • 复杂度分析

    • 时间复杂度:
      • 最好情况(已有序):O(n)。
      • 最坏/平均情况:O(n²)。
    • 空间复杂度:O(1),是原地排序
    • 稳定性稳定
  • 评价 :对于小规模或基本有序的数据集非常高效。STL中的 std::sort 在递归到小数组时也会转而使用插入排序。它是高级排序算法优化的重要组成部分。


四、 希尔排序 (Shell's Sort) - 插入排序的改进
  • 核心思想:是插入排序的一种更高效的改进版本。它通过将原始数组"分组"并进行插入排序,随着增量序列的减小,数组变得越来越"部分有序",最后当增量为1时进行一次标准的插入排序,此时效率很高。

  • 过程

    1. 选择一个增量序列 gap,例如 n/2, n/4, ..., 1
    2. 按增量序列个数 k,对序列进行 k 趟排序。
    3. 每趟排序,根据对应的增量 gap,将待排序列分割成若干长度为 gap 的子序列,分别对各子序列进行直接插入排序。
    4. 当增量因子为1时,整个序列作为一个表来处理,进行最后一次插入排序。
  • C++实现 (使用 gap = n/2, gap /= 2 序列):

    cpp 复制代码
    void shellSort(vector<int>& arr) {
        int n = arr.size();
        for (int gap = n / 2; gap > 0; gap /= 2) { // 增量序列
            for (int i = gap; i < n; ++i) { // 从第gap个元素开始,对各个分组进行插入排序
                int temp = arr[i];
                int j;
                for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
                    arr[j] = arr[j - gap];
                }
                arr[j] = temp;
            }
        }
    }
  • 复杂度分析 :复杂度依赖于增量序列的选择,分析非常复杂。

    • 时间复杂度:使用上述增量序列最坏是 O(n²),一些更好的增量序列(如Hibbard)可以达到 O(n^(3/2)),甚至 O(n log² n)。
    • 空间复杂度:O(1)。
    • 稳定性不稳定。相同的元素可能在各自的分组排序中被打乱顺序。
  • 评价:是第一批突破 O(n²) 复杂度的算法之一,实现简单,在中等规模数据上表现良好。


五、 归并排序 (Merge Sort) - 分治法
  • 核心思想 :采用分治法 。将已有序的子序列合并,得到完全有序的序列。

    1. 分割:递归地把当前序列平均分割成两半。
    2. 治理:在不能再分割后,对子序列进行排序(通常子序列长度为1时自然有序)。
    3. 合并:将两个已经排序的子序列合并成一个有序序列。
  • 过程 :关键是 merge 函数。

  • C++实现

    cpp 复制代码
    // 合并两个有序子数组 arr[l..m] 和 arr[m+1..r]
    void merge(vector<int>& arr, int l, int m, int r) {
        vector<int> left(arr.begin() + l, arr.begin() + m + 1);
        vector<int> right(arr.begin() + m + 1, arr.begin() + r + 1);
    
        int i = 0, j = 0, k = l;
        while (i < left.size() && j < right.size()) {
            if (left[i] <= right[j]) {
                arr[k++] = left[i++];
            } else {
                arr[k++] = right[j++];
            }
        }
        // 拷贝剩余元素
        while (i < left.size()) arr[k++] = left[i++];
        while (j < right.size()) arr[k++] = right[j++];
    }
    
    void mergeSort(vector<int>& arr, int l, int r) {
        if (l >= r) return; // 终止递归条件
        int m = l + (r - l) / 2; // 防止溢出,等效于 (l+r)/2
        mergeSort(arr, l, m);
        mergeSort(arr, m + 1, r);
        merge(arr, l, m, r);
    }
    
    // 包装函数
    void mergeSort(vector<int>& arr) {
        mergeSort(arr, 0, arr.size() - 1);
    }
  • 复杂度分析

    • 时间复杂度:最好、最坏、平均都是 O(n log n)。非常稳定。
    • 空间复杂度:O(n) 。因为合并操作需要额外的空间。不是原地排序
    • 稳定性稳定merge时判断用 <= 即可保证)。
  • 评价:效率高且稳定,是外部排序(数据量大到在磁盘中)的基础。缺点是空间复杂度高。Java、Python等语言的通用排序算法之一(对于对象排序,稳定性很重要)。


六、 快速排序 (Quick Sort) - 分治法
  • 核心思想 :同样采用分治法。选择一个"基准"元素,通过一趟排序将待排记录分割成独立的两部分,其中一部分的所有元素均比基准小,另一部分均比基准大。然后递归地对这两部分数据进行排序。

  • 过程 :关键是 partition 操作。

    1. 从数列中挑出一个元素,称为 "基准"。
    2. 分区:重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。
    3. 递归地把小于基准值元素的子数列和大于基准值元素的子数列排序。
  • C++实现 ( Lomuto partition scheme ):

    cpp 复制代码
    // 分区函数,选择最右元素为基准
    int partition(vector<int>& arr, int low, int high) {
        int pivot = arr[high]; // 选择最右元素作为基准
        int i = (low - 1);     // 指向小于基准的区域的最后一个元素
    
        for (int j = low; j <= high - 1; j++) {
            if (arr[j] < pivot) { // 当前元素小于基准
                i++; // 扩大小于基准的区域
                swap(arr[i], arr[j]);
            }
        }
        swap(arr[i + 1], arr[high]); // 将基准放到正确位置
        return (i + 1);
    }
    
    void quickSort(vector<int>& arr, int low, int high) {
        if (low < high) {
            int pi = partition(arr, low, high); // pi是基准的最终位置
            quickSort(arr, low, pi - 1);
            quickSort(arr, pi + 1, high);
        }
    }
    
    // 包装函数
    void quickSort(vector<int>& arr) {
        quickSort(arr, 0, arr.size() - 1);
    }

    (注:Hoare partition scheme 是另一种更高效的选择,但逻辑稍复杂)

  • 复杂度分析

    • 时间复杂度:
      • 平均情况:O(n log n)。
      • 最坏情况(数组已有序或逆序):O(n²)。但通过随机选择基准或中位数基准可以极大避免。
    • 空间复杂度:平均 O(log n)(递归调用栈的深度),最坏 O(n)。是原地排序
    • 稳定性不稳定。分区操作会打乱顺序。
  • 优化

    • 随机化快排:随机选择一个元素作为基准,避免在有序数组上出现最坏情况。
    • 三数取中:选择头、中、尾三个元素的中位数作为基准。
    • 对于小数组切换到插入排序。
  • 评价 :在平均情况下是内部排序中最快的算法,std::sort 的核心就是基于快排的IntroSort。


七、 堆排序 (Heap Sort)
  • 核心思想 :利用这种数据结构的特性进行排序。堆是一个近似完全二叉树,且满足父节点的值总是大于等于(或小于等于)其子节点的值。

  • 过程

    1. 建堆:将待排序序列构建成一个大顶堆(升序排序用大顶堆)。
    2. 此时,整个序列的最大值就是堆顶的根节点。
    3. 将其与末尾元素进行交换,此时末尾就为最大值。
    4. 然后将剩余 n-1 个元素重新构造成一个大顶堆,这样会得到次大值。
    5. 如此反复执行,便能得到一个有序序列了。
  • C++实现

    cpp 复制代码
    // 调整以节点i为根的子树为大顶堆,n是堆的大小
    void heapify(vector<int>& arr, int n, int i) {
        int largest = i;       // 初始化最大值为根
        int left = 2 * i + 1;  // 左子节点
        int right = 2 * i + 2; // 右子节点
    
        if (left < n && arr[left] > arr[largest]) largest = left;
        if (right < n && arr[right] > arr[largest]) largest = right;
    
        if (largest != i) {
            swap(arr[i], arr[largest]);
            heapify(arr, n, largest); // 递归地调整受影响的子树
        }
    }
    
    void heapSort(vector<int>& arr) {
        int n = arr.size();
        // 构建大顶堆(从最后一个非叶子节点开始调整)
        for (int i = n / 2 - 1; i >= 0; i--) {
            heapify(arr, n, i);
        }
        // 一个个从堆顶取出元素
        for (int i = n - 1; i > 0; i--) {
            swap(arr[0], arr[i]); // 将当前最大值移到数组末尾
            heapify(arr, i, 0);   // 在缩小的堆上重新调整
        }
    }
  • 复杂度分析

    • 时间复杂度:建堆 O(n),每次调整 O(log n) ,总复杂度为 O(n log n)最好、最坏、平均都是 O(n log n)
    • 空间复杂度:O(1),是原地排序
    • 稳定性不稳定 。例如 [5, 5, 5],建堆和交换过程可能打乱顺序。
  • 评价:时间复杂度稳定且是原地排序,常用于嵌入式系统等内存受限的场景。但由于数据交换是跳跃式的,缓存不友好,实际性能常不如好的快排实现。


八、 非比较排序 (计数排序、桶排序、基数排序)

这类排序用于特定情况,可以达到线性时间复杂度 O(n)

  • 计数排序 (Counting Sort)

    • 思想 :将输入的数据值转化为键存储在额外开辟的数组空间中。要求输入的数据必须是有确定范围的整数
    • 过程:统计每个元素出现的次数,然后根据计数结果直接输出到有序序列中。
    • 复杂度:O(n + k),k是整数的范围。k过大时效率低下。
  • 桶排序 (Bucket Sort)

    • 思想:将数据分到有限数量的桶里,每个桶再分别排序(可能使用别的排序算法或递归方式继续使用桶排序)。
    • 复杂度:O(n),取决于桶的数量和桶内使用的排序算法。
  • 基数排序 (Radix Sort)

    • 思想:按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。
    • 复杂度:O(n * k),k是最大数字的位数。

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 log n) ~ O(n²) O(n log² n) O(n²) O(1) 不稳定 分组插入
归并排序 O(n log n) O(n log n) O(n log n) O(n) 稳定 分治、合并
快速排序 O(n log n) O(n log n) O(n²) O(log n) 不稳定 分治、分区
堆排序 O(n log n) O(n log n) O(n log n) O(1) 不稳定 堆结构

4. C++中的排序:std::sort

在C++实际开发中,你几乎不需要自己实现排序算法。标准库提供了极其高效的 std::sort 函数(位于 <algorithm> 头文件中)。

  • 实现原理 :它并非单一的排序算法,而是一种混合算法 IntroSort

    1. 快速排序:作为主要排序方法。
    2. 堆排序 :当递归深度过深(超过 log(n) 层)时,转为堆排序,避免快排的最坏O(n²)情况。
    3. 插入排序:当分区的元素数量少于一定阈值(如16)时,转为插入排序,因为对小数组插入排序常数项更小。
  • 用法

    cpp 复制代码
    #include <algorithm>
    #include <vector>
    
    int main() {
        std::vector<int> arr = {5, 2, 8, 1, 9};
        // 默认升序排序
        std::sort(arr.begin(), arr.end()); // arr becomes {1, 2, 5, 8, 9}
    
        // 降序排序:使用lambda表达式或greater<>()
        std::sort(arr.begin(), arr.end(), std::greater<int>());
        // 或 std::sort(arr.begin(), arr.end(), [](int a, int b) { return a > b; });
    
        return 0;
    }

    它是绝大多数情况下你的最佳选择。