八大排序算法(C语言实现)

文章目录

    • [0. 排序算法总览](#0. 排序算法总览)
    • [1. 直接插入排序](#1. 直接插入排序)
      • [1.1 核心思想](#1.1 核心思想)
      • [1.2 为什么要用 `end`](#1.2 为什么要用 end)
      • [1.3 推荐代码](#1.3 推荐代码)
      • [1.4 代码理解](#1.4 代码理解)
      • [1.5 特点](#1.5 特点)
    • [2. 希尔排序](#2. 希尔排序)
      • [2.1 核心思想](#2.1 核心思想)
      • [2.2 为什么 gap 要从大到小](#2.2 为什么 gap 要从大到小)
      • [2.3 为什么是 `end -= gap`](#2.3 为什么是 end -= gap)
      • [2.4 推荐代码](#2.4 推荐代码)
      • [2.5 代码理解](#2.5 代码理解)
      • [2.6 特点](#2.6 特点)
    • [3. 选择排序](#3. 选择排序)
      • [3.1 一次选一个数](#3.1 一次选一个数)
      • [3.2 一次选两个数](#3.2 一次选两个数)
      • [3.3 `maxIndex == left` 为什么要修正](#3.3 maxIndex == left 为什么要修正)
      • [3.4 特点](#3.4 特点)
    • [4. 堆排序](#4. 堆排序)
      • [4.1 堆的基本概念](#4.1 堆的基本概念)
      • [4.2 为什么升序要建大堆](#4.2 为什么升序要建大堆)
      • [4.3 向下调整 AdjustDown](#4.3 向下调整 AdjustDown)
      • [4.4 建堆](#4.4 建堆)
      • [4.5 堆排序完整代码](#4.5 堆排序完整代码)
      • [4.6 特点](#4.6 特点)
    • [5. 冒泡排序](#5. 冒泡排序)
      • [5.1 核心思想](#5.1 核心思想)
      • [5.2 代码](#5.2 代码)
      • [5.3 为什么外层写 `end > 0`](#5.3 为什么外层写 end > 0)
      • [5.4 特点](#5.4 特点)
    • [6. 快速排序](#6. 快速排序)
    • [7. 归并排序](#7. 归并排序)
      • [7.1 归并排序思想](#7.1 归并排序思想)
      • [7.2 合并两个有序区间](#7.2 合并两个有序区间)
      • [7.1 递归实现](#7.1 递归实现)
      • [7.2 非递归实现](#7.2 非递归实现)
      • [7.3 外排序](#7.3 外排序)
      • [7.4 归并排序特点](#7.4 归并排序特点)
    • [8. 计数排序](#8. 计数排序)
      • [8.1 核心思想](#8.1 核心思想)
      • [8.2 代码](#8.2 代码)
      • [8.3 相对映射](#8.3 相对映射)
      • [8.4 特点](#8.4 特点)
    • [9. 复杂度与稳定性总结](#9. 复杂度与稳定性总结)
    • [10. 最终记忆版](#10. 最终记忆版)

0. 排序算法总览

排序算法 核心思想 平均时间复杂度 最坏时间复杂度 空间复杂度 稳定性
直接插入排序 将当前元素插入前面的有序区间 O(N²) O(N²) O(1) 稳定
希尔排序 按 gap 分组做插入排序,逐步缩小 gap 与 gap 序列有关 通常可到 O(N²) O(1) 不稳定
选择排序 每趟选择最小值,放到未排序区间开头 O(N²) O(N²) O(1) 不稳定
堆排序 建大堆,反复把堆顶最大值放到末尾 O(NlogN) O(NlogN) O(1) 不稳定
冒泡排序 相邻元素比较交换,每趟把最大值冒到末尾 O(N²) O(N²) O(1) 稳定
快速排序 一趟分区让 key 到最终位置,再处理左右区间 O(NlogN) O(N²) O(logN) 不稳定
归并排序 先让左右区间有序,再合并两个有序区间 O(NlogN) O(NlogN) O(N) 稳定
计数排序 统计每个值出现次数,再按次数回填 O(N + range) O(N + range) O(range) 可稳定

说明:希尔排序的复杂度和 gap 序列有关,不能简单固定写成 O(NlogN)。常见 gap /= 2 写法实际表现通常优于直接插入排序,但理论复杂度并不稳定。

公共辅助函数

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void Swap(int* x, int* y)
{
    int tmp = *x;
    *x = *y;
    *y = tmp;
}

void PrintArray(int* a, int n)
{
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
    printf("\n");
}

1. 直接插入排序

1.1 核心思想

直接插入排序可以类比整理扑克牌:前面一段牌已经排好,后面新拿到一张牌,把它插入到前面合适的位置。

对于数组来说,可以认为:

text 复制代码
[0, i - 1]:已经有序
[i]:当前要插入的元素
[i + 1, n - 1]:还没有处理

过程图:

text 复制代码
原数组:   5   2   4   6   1

第 1 趟: [5]  2   4   6   1
          tmp = 2,把 5 后移,2 插到前面
结果:    [2   5]  4   6   1

第 2 趟: [2   5]  4   6   1
          tmp = 4,把 5 后移,4 插入 2 后面
结果:    [2   4   5]  6   1

1.2 为什么要用 end

在插入排序中,end 不是多余变量,它表示当前有序区间的最后一个下标 。它从右往左扫描,负责给 tmp 找插入位置。

可以这样理解:

text 复制代码
tmp 保存当前要插入的元素
end 从有序区间末尾开始往前走
如果 a[end] 比 tmp 大,就把 a[end] 往后挪
直到找到 <= tmp 的位置,或者 end 走到 -1
最后把 tmp 放到 end + 1

为什么最后是 end + 1?因为循环结束时,end 停在"不需要后移"的位置,真正插入点在它后面。

1.3 推荐代码

c 复制代码
void InsertSort(int* a, int n)
{
    for (int i = 1; i < n; i++)
    {
        int tmp = a[i];       // 待插入元素
        int end = i - 1;      // 有序区间的最后一个位置

        while (end >= 0 && tmp < a[end])
        {
            a[end + 1] = a[end];
            end--;
        }

        a[end + 1] = tmp;
    }
}

1.4 代码理解

这段代码不是相邻元素一直交换,而是采用"挪动"的方式:

text 复制代码
5  7  9  3
      ↑  ↑
    end tmp

tmp = 3,因为 9 > 3,所以把 9 后移:

text 复制代码
5  7  9  9
   ↑
 end

继续比较 7 > 3,再后移:

text 复制代码
5  7  7  9
↑
end

继续比较 5 > 3,再后移:

text 复制代码
5  5  7  9
end = -1

最后把 tmp 放到 end + 1 = 0

text 复制代码
3  5  7  9

1.5 特点

  • 时间复杂度:最好 O(N),最坏 O(N²)。
  • 空间复杂度:O(1)。
  • 稳定性:稳定。因为相等时不往前插,原有相对顺序不变。
  • 适用场景:数据量小,或者数据基本有序。

2. 希尔排序

2.1 核心思想

希尔排序可以理解为带 gap 的插入排序

普通插入排序一次只能挪一个位置,如果一个很小的数在数组末尾,它要一步一步挪到前面,代价很大。希尔排序先用较大的 gap 分组,让元素可以"大步移动",使数组接近有序;最后 gap 变成 1,再做一次普通插入排序。

过程图:

text 复制代码
原数组:  9   1   2   5   7   4   8   6   3   5
下标:    0   1   2   3   4   5   6   7   8   9

gap = 5 时,同组元素下标相差 5:
组1:a[0], a[5]  -> 9, 4
组2:a[1], a[6]  -> 1, 8
组3:a[2], a[7]  -> 2, 6
组4:a[3], a[8]  -> 5, 3
组5:a[4], a[9]  -> 7, 5

每一组内部做插入排序。

2.2 为什么 gap 要从大到小

gap 越大,元素移动跨度越大,适合在前期快速调整大致位置;gap 越小,调整越精细。最后 gap = 1 时,整个数组作为一组,完成最终排序。

text 复制代码
gap 大:快速接近有序
     ↓
gap 小:局部精细调整
     ↓
gap = 1:普通插入排序收尾

2.3 为什么是 end -= gap

普通插入排序中,同组相邻元素的下标差是 1,所以向前找位置时是:

c 复制代码
end--;

希尔排序中,同组相邻元素的下标差是 gap,所以要写:

c 复制代码
end -= gap;

这也是希尔排序最关键的一点:

把直接插入排序里的"1"换成"gap"。

2.4 推荐代码

c 复制代码
void ShellSort(int* a, int n)
{
    int gap = n;
    while (gap > 1)
    {
        gap /= 2;

        // 对所有 gap 分组进行插入排序
        for (int i = gap; i < n; i++)
        {
            int tmp = a[i];
            int end = i - gap;

            while (end >= 0 && tmp < a[end])
            {
                a[end + gap] = a[end];
                end -= gap;
            }

            a[end + gap] = tmp;
        }
    }
}

2.5 代码理解

对于 i 位置的元素,前一个同组元素是:

c 复制代码
end = i - gap;

如果 tmp < a[end],说明前一个同组元素太大,需要后移到:

c 复制代码
a[end + gap]

然后继续看同组更前面的元素:

c 复制代码
end -= gap;

2.6 特点

  • 时间复杂度:与 gap 序列有关,常见写法无法严格固定为 O(NlogN)。
  • 空间复杂度:O(1)。
  • 稳定性:不稳定。因为相同元素可能在不同 gap 分组中跨越移动。
  • 本质:预排序 + 最后一趟直接插入排序。

3. 选择排序

3.1 一次选一个数

选择排序的基本思想:每一趟从未排序区间中找最小值,把它放到未排序区间的开头。

过程图:

text 复制代码
原数组:  6   3   8   2   9

第 1 趟:在 [6 3 8 2 9] 中找最小值 2,和开头 6 交换
结果:   [2]  3   8   6   9

第 2 趟:在 [3 8 6 9] 中找最小值 3,位置不变
结果:   [2 3]  8   6   9

代码:

c 复制代码
void SelectSortOne(int* a, int n)
{
    for (int i = 0; i < n - 1; i++)
    {
        int minIndex = i;
        for (int j = i + 1; j < n; j++)
        {
            if (a[j] < a[minIndex])
            {
                minIndex = j;
            }
        }

        if (minIndex != i)
        {
            Swap(&a[i], &a[minIndex]);
        }
    }
}

3.2 一次选两个数

可以在一趟中同时找最小值和最大值:

text 复制代码
最小值放 left
最大值放 right
然后 left++,right--

代码:

c 复制代码
void SelectSort(int* a, int n)
{
    int left = 0;
    int right = n - 1;

    while (left < right)
    {
        int minIndex = left;
        int maxIndex = left;

        for (int i = left; i <= right; i++)
        {
            if (a[i] < a[minIndex])
                minIndex = i;

            if (a[i] > a[maxIndex])
                maxIndex = i;
        }

        Swap(&a[minIndex], &a[left]);

        // 如果最大值原来就在 left,那么上一步交换后,最大值被换到了 minIndex
        if (maxIndex == left)
        {
            maxIndex = minIndex;
        }

        Swap(&a[maxIndex], &a[right]);

        left++;
        right--;
    }
}

3.3 maxIndex == left 为什么要修正

例如:

text 复制代码
9  1  3  2

当前区间中:

text 复制代码
最小值 1 的下标 minIndex = 1
最大值 9 的下标 maxIndex = 0,也就是 left

先把最小值换到 left:

text 复制代码
1  9  3  2

此时最大值 9 已经不在原来的 maxIndex = 0,而是到了 minIndex = 1

所以必须更新:

c 复制代码
maxIndex = minIndex;

否则第二次交换会把错误位置的数据换到末尾。

3.4 特点

  • 时间复杂度始终 O(N²),即使数组已经有序,也要找最小值。
  • 空间复杂度 O(1)。
  • 不稳定。因为交换可能改变相同元素的相对顺序。

4. 堆排序

4.1 堆的基本概念

堆是一棵完全二叉树,常用数组存储。

对于下标为 i 的节点:

text 复制代码
左孩子:2 * i + 1
右孩子:2 * i + 2
父节点:(i - 1) / 2

大堆:每个父节点都大于等于左右孩子,堆顶是最大值。

text 复制代码
          9
       /     \
      7       8
    /  \     / \
   3    5   2   6

数组表示:

text 复制代码
9  7  8  3  5  2  6
0  1  2  3  4  5  6

4.2 为什么升序要建大堆

升序排序需要把最大值放到数组最后。大堆堆顶正好是最大值,所以每次把堆顶和当前堆的最后一个元素交换,最大值就被放到了最终位置。

text 复制代码
建大堆:最大值在 a[0]
交换:  a[0] <-> a[end]
调整:  剩余 [0, end - 1] 继续保持大堆

4.3 向下调整 AdjustDown

向下调整的前提:根节点的左右子树已经是堆,只是根节点可能破坏堆结构。

过程:

text 复制代码
1. parent 指向根节点
2. child 先指向左孩子
3. 如果右孩子存在且更大,child 改为右孩子
4. 如果 a[child] > a[parent],交换,然后 parent 继续往下走
5. 否则调整结束

代码:

c 复制代码
void AdjustDown(int* a, int n, int root)
{
    int parent = root;
    int child = 2 * parent + 1;

    while (child < n)
    {
        // 选出左右孩子中较大的那个
        if (child + 1 < n && a[child + 1] > a[child])
        {
            child++;
        }

        if (a[child] > a[parent])
        {
            Swap(&a[child], &a[parent]);
            parent = child;
            child = 2 * parent + 1;
        }
        else
        {
            break;
        }
    }
}

这里的 n 表示当前堆的有效元素个数 ,不一定是数组总长度。堆排序过程中,数组末尾会逐渐变成有序区间,这部分不再参与堆调整,所以 n 会逐渐变小。

4.4 建堆

建堆时,从最后一个非叶子节点开始,向前依次做向下调整。

最后一个元素下标是 n - 1,它的父节点就是最后一个非叶子节点:

c 复制代码
(n - 2) / 2

代码:

c 复制代码
void BuildHeap(int* a, int n)
{
    for (int i = (n - 2) / 2; i >= 0; i--)
    {
        AdjustDown(a, n, i);
    }
}

为什么从后往前?因为向下调整要求左右子树已经是堆,从最后一个非叶子节点往前调整,可以保证每次调整时下面的子树已经处理过。

4.5 堆排序完整代码

c 复制代码
void HeapSort(int* a, int n)
{
    // 1. 建大堆
    for (int i = (n - 2) / 2; i >= 0; i--)
    {
        AdjustDown(a, n, i);
    }

    // 2. 反复把堆顶最大值放到末尾
    int end = n - 1;
    while (end > 0)
    {
        Swap(&a[0], &a[end]);
        AdjustDown(a, end, 0);
        end--;
    }
}

4.6 特点

  • 建堆复杂度:O(N)。
  • 每次删除堆顶后调整:O(logN)。
  • 堆排序总复杂度:O(NlogN)。
  • 空间复杂度:O(1)。
  • 稳定性:不稳定。

5. 冒泡排序

5.1 核心思想

冒泡排序每一趟比较相邻元素,如果前一个比后一个大,就交换。这样一趟下来,当前未排序区间的最大值会被"冒"到末尾。

过程图:

text 复制代码
原数组:  5  3  8  2

第 1 趟:
5 和 3 比,交换 -> 3 5 8 2
5 和 8 比,不换 -> 3 5 8 2
8 和 2 比,交换 -> 3 5 2 8
                       ↑
                    最大值到位

5.2 代码

c 复制代码
void BubbleSort(int* a, int n)
{
    for (int end = n - 1; end > 0; end--)
    {
        int exchange = 0;

        for (int i = 0; i < end; i++)
        {
            if (a[i] > a[i + 1])
            {
                Swap(&a[i], &a[i + 1]);
                exchange = 1;
            }
        }

        if (exchange == 0)
        {
            break;
        }
    }
}

5.3 为什么外层写 end > 0

end 表示当前这一趟冒泡的最后一个比较位置。最后只剩两个元素时,还需要比较 a[0]a[1],此时 end = 1

end = 0 时,内层循环不会执行,所以没有必要继续。

5.4 特点

  • 时间复杂度:最好 O(N),最坏 O(N²)。
  • 空间复杂度:O(1)。
  • 稳定性:稳定。相等元素不交换。
  • exchange 是优化点:如果某一趟没有交换,说明数组已经有序。

6. 快速排序

6.1 快排整体思想

快速排序的核心是分区

一趟排序选一个基准值 key,把小于 key 的放左边,大于 key 的放右边。此时 key 到达最终位置,然后递归处理左右区间。

text 复制代码
原区间:       [ left ............... right ]
一趟分区后:   [ 小于 key ] key [ 大于 key ]
                         ↑
                    key 已经到最终位置

递归框架:

c 复制代码
void QuickSort(int* a, int left, int right)
{
    if (left >= right)
        return;

    int keyi = PartSort(a, left, right);

    QuickSort(a, left, keyi - 1);
    QuickSort(a, keyi + 1, right);
}

快排传 beginend,是因为递归时排序的是数组的某个子区间,而不是每次都排序整个数组。

6.2 Hoare 版本

思想

Hoare 版本使用左右指针:

text 复制代码
key 选最左边时:right 先走,找小;left 后走,找大。
找到后交换,直到 left 和 right 相遇。
最后把 key 和相遇位置交换。

过程图:

text 复制代码
key = 6

6  1  2  7  9  3  4  5  8
↑                       ↑
key                    right 找小

right 找到 5,left 再找大,left 找到 7,交换:
6  1  2  5  9  3  4  7  8
key 在左,为什么 right 先走

如果 key 选最左边,最后需要把 key 和相遇点交换。right 先走可以保证相遇点更适合放 key:相遇位置上的值通常是小于等于 key 的。若 left 先走,可能让 key 和一个大于 key 的值交换,导致分区错误。

记忆:

text 复制代码
key 在左,右边先走。
key 在右,左边先走。
单趟代码
c 复制代码
int PartSortHoare(int* a, int left, int right)
{
    int keyi = left;

    while (left < right)
    {
        while (left < right && a[right] >= a[keyi])
        {
            right--;
        }

        while (left < right && a[left] <= a[keyi])
        {
            left++;
        }

        if (left < right)
        {
            Swap(&a[left], &a[right]);
        }
    }

    Swap(&a[keyi], &a[left]);
    return left;
}

递归代码:

c 复制代码
void QuickSortHoare(int* a, int begin, int end)
{
    if (begin >= end)
        return;

    int keyi = PartSortHoare(a, begin, end);
    QuickSortHoare(a, begin, keyi - 1);
    QuickSortHoare(a, keyi + 1, end);
}

6.3 挖坑法

思想

挖坑法不是交换,而是"填坑"。

text 复制代码
1. 保存 key,key 原来的位置形成坑。
2. right 从右往左找小值,填到左边坑里。
3. left 从左往右找大值,填到右边坑里。
4. 坑不断移动,最后 left 和 right 相遇。
5. 把 key 放到最终坑位。

过程图:

text 复制代码
key = 6,左边形成坑
[坑] 1  2  7  9  3  4  5  8

right 找到 5,填左坑:
5  1  2  7  9  3  4  [坑] 8

left 找到 7,填右坑:
5  1  2  [坑] 9  3  4  7  8
代码
c 复制代码
int PartSortHole(int* a, int left, int right)
{
    int key = a[left];

    while (left < right)
    {
        while (left < right && a[right] >= key)
        {
            right--;
        }
        a[left] = a[right];

        while (left < right && a[left] <= key)
        {
            left++;
        }
        a[right] = a[left];
    }

    a[left] = key;
    return left;
}

void QuickSortHole(int* a, int begin, int end)
{
    if (begin >= end)
        return;

    int keyi = PartSortHole(a, begin, end);
    QuickSortHole(a, begin, keyi - 1);
    QuickSortHole(a, keyi + 1, end);
}
细节

挖坑法里不需要额外判断:

c 复制代码
if (a[right] < key)

因为内层循环结束后,要么找到了小值,要么 left == right,此时赋值相当于自赋值,不影响结果。

6.4 前后指针法

思想

前后指针法使用两个指针:

text 复制代码
cur:负责从左往右扫描
prev:维护"小于 key 区间"的最后一个位置

数组被分成四部分:

text 复制代码
[key] [小于 key 的区域] [大于等于 key 的区域] [未扫描区域]
      begin+1..prev    prev+1..cur-1       cur..right

cur 遇到小于 key 的值时:

text 复制代码
1. prev++,小区间扩大一格
2. 如果 prev != cur,把 a[cur] 换到 a[prev]
3. cur 继续往后扫描
单趟代码
c 复制代码
int PartSortPrevCur(int* a, int left, int right)
{
    int keyi = left;
    int prev = left;
    int cur = left + 1;

    while (cur <= right)
    {
        if (a[cur] < a[keyi])
        {
            prev++;
            if (prev != cur)
            {
                Swap(&a[prev], &a[cur]);
            }
        }

        cur++;
    }

    Swap(&a[keyi], &a[prev]);
    return prev;
}

原笔记里常见的压缩写法:

c 复制代码
if (a[cur] < a[keyi] && ++prev != cur)
{
    Swap(&a[prev], &a[cur]);
}

它等价于:

c 复制代码
if (a[cur] < a[keyi])
{
    ++prev;
    if (prev != cur)
    {
        Swap(&a[prev], &a[cur]);
    }
}

6.5 快速排序非递归

思想

递归快排是系统调用栈帮我们保存"还没有处理的区间"。非递归快排就是自己创建一个栈,把待排序区间 [left, right] 存进去。

text 复制代码
递归:QuickSort(left, keyi - 1),QuickSort(keyi + 1, right)
非递归:把 [left, keyi - 1] 和 [keyi + 1, right] 压栈

过程图:

text 复制代码
初始栈: [0, 8]

弹出 [0, 8],单趟排序后 keyi = 5
压入: [0, 4] 和 [6, 8]

之后继续弹出一个区间处理,直到栈为空。
简单栈实现
c 复制代码
typedef struct Stack
{
    int* data;
    int top;
    int capacity;
} Stack;

void StackInit(Stack* st)
{
    st->capacity = 16;
    st->top = 0;
    st->data = (int*)malloc(sizeof(int) * st->capacity);
    if (st->data == NULL)
    {
        perror("malloc fail");
        exit(1);
    }
}

void StackPush(Stack* st, int x)
{
    if (st->top == st->capacity)
    {
        st->capacity *= 2;
        int* tmp = (int*)realloc(st->data, sizeof(int) * st->capacity);
        if (tmp == NULL)
        {
            perror("realloc fail");
            exit(1);
        }
        st->data = tmp;
    }

    st->data[st->top++] = x;
}

void StackPop(Stack* st)
{
    if (st->top > 0)
        st->top--;
}

int StackTop(Stack* st)
{
    return st->data[st->top - 1];
}

int StackEmpty(Stack* st)
{
    return st->top == 0;
}

void StackDestroy(Stack* st)
{
    free(st->data);
    st->data = NULL;
    st->top = st->capacity = 0;
}
非递归快排代码
c 复制代码
void QuickSortNonR(int* a, int begin, int end)
{
    if (begin >= end)
        return;

    Stack st;
    StackInit(&st);

    StackPush(&st, begin);
    StackPush(&st, end);

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

        int left = StackTop(&st);
        StackPop(&st);

        int keyi = PartSortHoare(a, left, right);

        if (left < keyi - 1)
        {
            StackPush(&st, left);
            StackPush(&st, keyi - 1);
        }

        if (keyi + 1 < right)
        {
            StackPush(&st, keyi + 1);
            StackPush(&st, right);
        }
    }

    StackDestroy(&st);
}

压栈顺序是先左边界再右边界:

c 复制代码
StackPush(&st, left);
StackPush(&st, right);

出栈时先取到的是 right,再取到的是 left,因为栈是后进先出。

6.6 快速排序优化:三数取中和小区间优化

三数取中

如果每次都选最左边作为 key,遇到有序数组时容易退化成 O(N²)。三数取中是在 leftmidright 三个位置里选一个大小居中的数作为 key,尽量避免选到极大值或极小值。

text 复制代码
left     mid      right
 1        5         9

选中间大小的 5 作为 key

代码:

c 复制代码
int GetMidIndex(int* a, int left, int right)
{
    int mid = left + (right - left) / 2;

    if (a[left] < a[mid])
    {
        if (a[mid] < a[right])
            return mid;
        else if (a[left] < a[right])
            return right;
        else
            return left;
    }
    else
    {
        if (a[left] < a[right])
            return left;
        else if (a[mid] < a[right])
            return right;
        else
            return mid;
    }
}

使用时通常把选中的 key 换到 begin 位置,因为后面的分区代码默认 key 在最左边:

c 复制代码
int midIndex = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[midIndex]);
带三数取中的前后指针法
c 复制代码
int PartSortPrevCurWithMid(int* a, int left, int right)
{
    int midIndex = GetMidIndex(a, left, right);
    Swap(&a[left], &a[midIndex]);

    int keyi = left;
    int prev = left;
    int cur = left + 1;

    while (cur <= right)
    {
        if (a[cur] < a[keyi])
        {
            prev++;
            if (prev != cur)
                Swap(&a[prev], &a[cur]);
        }
        cur++;
    }

    Swap(&a[keyi], &a[prev]);
    return prev;
}
小区间优化

快速排序递归到后期,会产生大量很小的区间。小区间继续快排,递归调用成本反而不划算。因此可以在区间长度较小时,改用插入排序或希尔排序。

c 复制代码
void InsertSortRange(int* a, int left, int right)
{
    for (int i = left + 1; i <= right; i++)
    {
        int tmp = a[i];
        int end = i - 1;

        while (end >= left && tmp < a[end])
        {
            a[end + 1] = a[end];
            end--;
        }

        a[end + 1] = tmp;
    }
}

void QuickSortOptimized(int* a, int begin, int end)
{
    if (begin >= end)
        return;

    if (end - begin + 1 <= 16)
    {
        InsertSortRange(a, begin, end);
        return;
    }

    int keyi = PartSortPrevCurWithMid(a, begin, end);
    QuickSortOptimized(a, begin, keyi - 1);
    QuickSortOptimized(a, keyi + 1, end);
}

6.7 快排特点

  • 平均时间复杂度:O(NlogN)。
  • 最坏时间复杂度:O(N²)。
  • 空间复杂度:平均 O(logN),最坏 O(N)。
  • 稳定性:不稳定。
  • 常见优化:三数取中、随机选 key、小区间优化、非递归实现。

7. 归并排序

7.1 归并排序思想

归并排序使用分治思想:

text 复制代码
先拆分,再合并。

但真正完成排序的是"合并"过程,而不是拆分过程。

text 复制代码
原数组: 10  6  7  1  3  9  4  2

拆分:
10 6 7 1        3 9 4 2
10 6   7 1      3 9   4 2
10  6  7  1     3  9  4  2

合并:
[6 10] [1 7] [3 9] [2 4]
[1 6 7 10] [2 3 4 9]
[1 2 3 4 6 7 9 10]

为什么要先递归再合并?因为归并排序的合并操作要求左右两个区间已经有序。如果左右区间本身无序,就不能通过"比较两个区间开头元素"的方式正确合并。

7.2 合并两个有序区间

例如:

text 复制代码
左区间:1  6  7  10
右区间:2  3  4  9

用两个指针分别指向两个区间开头,谁小就放入临时数组 tmp

text 复制代码
比较 1 和 2,放 1
比较 6 和 2,放 2
比较 6 和 3,放 3
比较 6 和 4,放 4
比较 6 和 9,放 6
...

7.1 递归实现

c 复制代码
void _MergeSort(int* a, int left, int right, int* tmp)
{
    if (left >= right)
        return;

    int mid = left + (right - left) / 2;

    _MergeSort(a, left, mid, tmp);
    _MergeSort(a, mid + 1, right, tmp);

    int begin1 = left, end1 = mid;
    int begin2 = mid + 1, end2 = right;
    int index = left;

    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] <= a[begin2])
            tmp[index++] = a[begin1++];
        else
            tmp[index++] = a[begin2++];
    }

    while (begin1 <= end1)
        tmp[index++] = a[begin1++];

    while (begin2 <= end2)
        tmp[index++] = a[begin2++];

    for (int i = left; i <= right; i++)
        a[i] = tmp[i];
}

void MergeSort(int* a, int n)
{
    if (a == NULL || n <= 1)
        return;

    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail");
        exit(1);
    }

    _MergeSort(a, 0, n - 1, tmp);

    free(tmp);
}
为什么 tmpint*
c 复制代码
int* tmp = (int*)malloc(sizeof(int) * n);

malloc 申请的是一整块连续空间,返回这块空间的首地址。因为这块空间要按 int 数组使用,所以用 int* 保存。

text 复制代码
tmp
 ↓
[ ][ ][ ][ ][ ][ ]

tmp[0]tmp[1]tmp[2] 本质上都是通过指针访问连续空间。

稳定性细节

归并排序要稳定,比较时应写:

c 复制代码
if (a[begin1] <= a[begin2])

当左右区间元素相等时,优先取左区间元素,可以保持原来的相对顺序。如果写成 <,相等时可能先取右区间元素,稳定性会被破坏。

7.2 非递归实现

非递归归并排序也叫自底向上归并排序。

它不递归拆分,而是直接认为每个单独元素都是有序区间,然后两两合并。

text 复制代码
gap = 1:一个元素一组,有序
[10] [6] [7] [1] [3] [9] [4] [2]

合并后:
[6 10] [1 7] [3 9] [2 4]

gap = 2:两个元素一组,有序
[6 10] 和 [1 7] 合并
[3 9] 和 [2 4] 合并

合并后:
[1 6 7 10] [2 3 4 9]

gap = 4:四个元素一组,有序
[1 6 7 10] 和 [2 3 4 9] 合并
gap 为什么指数增长

gap 表示当前有序小区间的长度。每一轮都把两个长度为 gap 的有序区间合并成一个长度约为 2 * gap 的有序区间,所以每轮结束后:

c 复制代码
gap *= 2;

它不是普通下标,不应该 gap++

非递归代码
c 复制代码
void Merge(int* a, int* tmp, int left, int mid, int right)
{
    int begin1 = left, end1 = mid;
    int begin2 = mid + 1, end2 = right;
    int index = left;

    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] <= a[begin2])
            tmp[index++] = a[begin1++];
        else
            tmp[index++] = a[begin2++];
    }

    while (begin1 <= end1)
        tmp[index++] = a[begin1++];

    while (begin2 <= end2)
        tmp[index++] = a[begin2++];

    for (int i = left; i <= right; i++)
        a[i] = tmp[i];
}

void MergeSortNonR(int* a, int n)
{
    if (a == NULL || n <= 1)
        return;

    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail");
        exit(1);
    }

    for (int gap = 1; gap < n; gap *= 2)
    {
        for (int left = 0; left < n; left += 2 * gap)
        {
            int mid = left + gap - 1;
            int right = left + 2 * gap - 1;

            // 右区间不存在,不需要合并
            if (mid >= n - 1)
                break;

            // 右区间不完整,修正右边界
            if (right >= n)
                right = n - 1;

            Merge(a, tmp, left, mid, right);
        }
    }

    free(tmp);
}
边界理解

对于每一组,需要合并:

text 复制代码
[left, left + gap - 1]
[left + gap, left + 2 * gap - 1]

所以:

c 复制代码
mid = left + gap - 1;
right = left + 2 * gap - 1;

如果 mid >= n - 1,说明右区间不存在,不用合并。

如果 right >= n,说明右区间存在但不完整,需要把 right 修正为 n - 1

7.3 外排序

当数据量非常大,内存放不下整个数组时,普通内部排序无法一次完成。这时可以使用外排序。

外排序的典型思路是:

text 复制代码
大文件
  ↓
切分成多个小文件
  ↓
每个小文件读入内存排序
  ↓
得到多个有序小文件
  ↓
多路归并,合并成一个整体有序的大文件

流程图:

text 复制代码
big.txt
  │
  ├── part1.txt  排序后 -> part1_sorted.txt
  ├── part2.txt  排序后 -> part2_sorted.txt
  ├── part3.txt  排序后 -> part3_sorted.txt
  │
  └── 多路归并 -> result.txt

下面代码演示两个有序文件的归并。真实大数据场景通常会使用多路归并和缓冲区优化。

c 复制代码
void MergeTwoSortedFiles(const char* file1, const char* file2, const char* outFile)
{
    FILE* f1 = fopen(file1, "r");
    FILE* f2 = fopen(file2, "r");
    FILE* fout = fopen(outFile, "w");

    if (f1 == NULL || f2 == NULL || fout == NULL)
    {
        perror("open file fail");
        if (f1) fclose(f1);
        if (f2) fclose(f2);
        if (fout) fclose(fout);
        exit(1);
    }

    int x, y;
    int ret1 = fscanf(f1, "%d", &x);
    int ret2 = fscanf(f2, "%d", &y);

    while (ret1 != EOF && ret2 != EOF)
    {
        if (x <= y)
        {
            fprintf(fout, "%d\n", x);
            ret1 = fscanf(f1, "%d", &x);
        }
        else
        {
            fprintf(fout, "%d\n", y);
            ret2 = fscanf(f2, "%d", &y);
        }
    }

    while (ret1 != EOF)
    {
        fprintf(fout, "%d\n", x);
        ret1 = fscanf(f1, "%d", &x);
    }

    while (ret2 != EOF)
    {
        fprintf(fout, "%d\n", y);
        ret2 = fscanf(f2, "%d", &y);
    }

    fclose(f1);
    fclose(f2);
    fclose(fout);
}

7.4 归并排序特点

  • 时间复杂度:O(NlogN)。
  • 空间复杂度:O(N)。
  • 稳定性:稳定。
  • 适合链表排序,也适合外排序思想。
  • 缺点:数组版本需要额外临时空间。

8. 计数排序

8.1 核心思想

计数排序不是基于比较的排序。它适合整数范围比较集中的场景。

思路:

text 复制代码
1. 找到数组最小值 min 和最大值 max
2. 开一个大小为 range = max - min + 1 的计数数组
3. count[a[i] - min]++
4. 根据 count 回填原数组

例如:

text 复制代码
原数组: 3  1  2  3  2  1  3
min = 1, max = 3, range = 3

count[0] 记录数字 1 出现次数 -> 2
count[1] 记录数字 2 出现次数 -> 2
count[2] 记录数字 3 出现次数 -> 3

回填:1 1 2 2 3 3 3

8.2 代码

c 复制代码
void CountSort(int* a, int n)
{
    if (a == NULL || n <= 1)
        return;

    int min = a[0];
    int max = a[0];

    for (int i = 1; i < n; i++)
    {
        if (a[i] < min)
            min = a[i];
        if (a[i] > max)
            max = a[i];
    }

    int range = max - min + 1;
    int* count = (int*)calloc(range, sizeof(int));
    if (count == NULL)
    {
        perror("calloc fail");
        exit(1);
    }

    for (int i = 0; i < n; i++)
    {
        count[a[i] - min]++;
    }

    int index = 0;
    for (int j = 0; j < range; j++)
    {
        while (count[j] > 0)
        {
            a[index++] = j + min;
            count[j]--;
        }
    }

    free(count);
}

8.3 相对映射

如果数组最小值不是 0,例如:

text 复制代码
90  95  92  90

没有必要开 0~95 的空间。可以用相对映射:

text 复制代码
count[a[i] - min]

如果 min = 90,那么:

text 复制代码
90 -> count[0]
92 -> count[2]
95 -> count[5]

8.4 特点

  • 时间复杂度:O(N + range)。
  • 空间复杂度:O(range)。
  • 适用条件:整数,且数据范围不能太离散。
  • 对于 1, 100000000 这种范围跨度很大的数据,不适合使用计数排序。
  • 稳定性:简单回填整数版本不体现稳定性;如果用于对象排序,需要通过前缀和从后往前放置,才能保证稳定。

9. 复杂度与稳定性总结

排序 最好 平均 最坏 空间 稳定性 备注
插入排序 O(N) O(N²) O(N²) O(1) 稳定 越接近有序越快
希尔排序 与 gap 有关 与 gap 有关 通常可到 O(N²) O(1) 不稳定 插入排序优化版
选择排序 O(N²) O(N²) O(N²) O(1) 不稳定 交换次数少,但比较次数固定
堆排序 O(NlogN) O(NlogN) O(NlogN) O(1) 不稳定 升序建大堆
冒泡排序 O(N) O(N²) O(N²) O(1) 稳定 可用 exchange 提前结束
快速排序 O(NlogN) O(NlogN) O(N²) O(logN) 不稳定 实际常用,需优化 key
归并排序 O(NlogN) O(NlogN) O(NlogN) O(N) 稳定 适合外排序
计数排序 O(N+range) O(N+range) O(N+range) O(range) 可稳定 适合范围集中的整数

10. 最终记忆版

插入排序:

前面有序,后面取一个元素,向前找位置插入。end 负责从有序区间末尾往前扫描。

希尔排序:

gap 分组的插入排序。gap 大时快速预排序,gap 小时精细调整,最后 gap = 1 完成排序。

选择排序:

每趟选最小值放前面,也可以同时选最大值放后面。一次选两个数时要注意 maxIndex == left 的修正。

堆排序:

升序建大堆。每次把堆顶最大值放到末尾,再对剩余堆做向下调整。

冒泡排序:

相邻比较,大的往后冒。某一趟没有交换,说明已经有序。

快速排序:

一趟分区让 key 到最终位置,再处理左右区间。Hoare、挖坑、前后指针只是分区方式不同。三数取中用于避免 key 太偏。

归并排序:

先让左右子区间分别有序,再合并两个有序区间。真正排序发生在合并阶段。

计数排序:

用计数数组统计每个整数出现次数,再按次数回填。适合整数范围集中的场景。

相关推荐
爱睡懒觉的焦糖玛奇朵1 小时前
【从视频到数据集:焦糖玛奇朵的魔法工具Dataset Cleaner】
人工智能·python·学习·算法·yolo·音视频
xjxijd1 小时前
行为感知算法赋能运维,提前预判硬件故障与异常访问
运维·算法
江屿风1 小时前
C++图论基础拓扑排序经典OJ题流食般投喂
开发语言·c++·笔记·算法·图论
C+-C资深大佬1 小时前
C++ 数字与字符串互转
java·c++·算法
满怀冰雪1 小时前
第12篇-二分答案法-当答案不好求时如何反向搜索
java·算法
KaMeidebaby1 小时前
卡梅德生物技术快报|兔单克隆抗体应用实战:禽源病原 IFA 检测全流程拆解
前端·人工智能·物联网·算法·百度
CC数学建模1 小时前
2026年第十六届APMCM 亚太地区大学生数学建模竞赛(中文赛项)赛题A题:自来水厂水质预测与评估完整思路、代码、模型、文章,全网首发高质量分享!
python·算法·数学建模
SoftLipaRZC3 小时前
单链表的应用:经典OJ题与通讯录项目实战
数据结构
SoftLipaRZC3 小时前
单链表专题:从概念到实现
数据结构