【排序】C语言实现八大排序算法(含完整源码与性能测试)

【排序】C语言实现八大排序算法(含完整源码与性能测试)

❤️感谢支持,点赞关注不迷路❤️

排序,是计算机程序设计中最为基础且重要的算法之一。无论是面试题还是实际工程,排序算法总是高频出现。本文从 冒泡排序计数排序,逐一分析每种排序的核心思路、代码实现、时间与空间复杂度,并给出 10 万级数据的实测对比,帮你建立完整的排序知识体系。


排序讲解

排序,笼统来说就是将一串记录按照关键字的大小,递增或递减地排列起来。生活中处处都有排序的影子------购物按价格筛选、成绩排行榜、考试成绩排名等。

本文基于 C 语言,实现以下八大排序算法(全部以递增为例):

序号 排序名称 类别
1 冒泡排序 交换排序
2 堆排序 选择排序
3 直接插入排序 插入排序
4 希尔排序 插入排序
5 直接选择排序 选择排序
6 快速排序 交换排序
7 归并排序 归并排序
8 计数排序 非比较排序

一、冒泡排序

冒泡排序是我们接触的第一个排序,虽然效率不高,但教学意义重大,是打开排序算法世界大门的第一把钥匙。

图解过程

初始数组:[5, 3, 8, 4, 2]

第一趟(i=0):

复制代码
[5, 3, 8, 4, 2]
  ↓比较
[3, 5, 8, 4, 2]  交换 5>3
  ↓比较
[3, 5, 8, 4, 2]  不交换 5<8
  ↓比较
[3, 4, 8, 5, 2]  交换 8>4
  ↓比较
[3, 4, 2, 8, 5]  交换 8>2
第一趟结束,最大值8沉到最右

第二趟(i=1):

复制代码
[3, 4, 2, 8, 5]
  ↓比较
[3, 4, 2, 8, 5]  不交换 3<4
  ↓比较
[2, 4, 3, 8, 5]  交换 4>2
  ↓比较
[2, 3, 4, 8, 5]  交换 4>3
第二趟结束,次大值5在8左边

第三趟(i=2):

复制代码
[2, 3, 4, 8, 5]
  ↓比较
[2, 3, 4, 8, 5]  不交换 2<3
  ↓比较
[2, 3, 4, 8, 5]  不交换 3<4
第三趟结束,无需交换,数组已有序

代码实现

c 复制代码
void Swap(int* a, int* b)
{
    int c = *a;
    *a = *b;
    *b = c;
}

void maopao(int* arr, int r)
{
    for (int i = 0; i < r - 1; i++)           // 趟数
    {
        int flag = 1;
        for (int j = 0; j < r - 1 - i; j++)   // 两两比较
        {
            if (arr[j] > arr[j + 1])
            {
                flag = 0;
                Swap(&arr[j], &arr[j + 1]);
            }
        }
        if (flag == 1) return;               // 提前结束优化
    }
}

过程分析

  • 外层循环控制总的趟数,对于 n 个元素,只需要 n-1 趟,因为最后一趟只剩一个元素无需比较。
  • 内层循环负责两两比较,将大的元素逐步"冒泡"到右侧。
  • 每经过一趟排序,未排序部分就会少一个元素,因此内层 j 的上限要减去 i。
  • flag 优化:当某一趟没有任何交换时,说明数组已经有序,直接 return。

时空复杂度

  • 空间复杂度:O(1),仅使用了常量级辅助变量
  • 时间复杂度:O(N²),最差情况为逆序

性能验证

10 万个随机数,冒泡排序耗时约 3000+ ms,效率最低,但逻辑最为简单。


二、堆排序

堆排序利用 这一完全二叉树结构的特点------堆顶元素要么最大(大堆),要么最小(小堆),不断交换堆顶与末尾元素并重新调整堆,最终得到有序序列。

图解过程

以数组 [4, 10, 3, 5, 1] 为例,构建大堆:

复制代码
原始完全二叉树:
        4
      /   \
    10      3
   /  \
  5    1

建堆过程(从最后一个非叶子节点开始向下调整):
节点(10)是最后一个非叶子节点,比较10与孩子5、1,10最大无需交换
节点(4)与孩子10、3比较,4<10,交换
        10
      /    \
     4      3
    / \
   5   1

再调整节点(4),与孩子5比较,4<5,交换
        10
      /    \
     5      3
    / \
   4   1

代码实现

c 复制代码
void Swap(int* a, int* b)
{
    int c = *a;
    *a = *b;
    *b = c;
}

// 向下调整算法 ------ 构建大堆
void AdjustDown(int* arr, int r, int parent)
{
    int child = parent * 2 + 1;
    while (child < r)
    {
        if (child + 1 < r && arr[child + 1] > arr[child])
        {
            child++;
        }
        if (arr[parent] < arr[child])
        {
            Swap(&arr[parent], &arr[child]);
            parent = child;
            child = parent * 2 + 1;
        }
        else
        {
            break;
        }
    }
}

void Heappai(int* arr, int r)
{
    // 建堆 ------ 从第一个非叶子节点开始向下调整
    for (int i = (r - 1 - 1) / 2; i >= 0; i--)
    {
        AdjustDown(arr, r, i);
    }

    // 排序:不断将堆顶(最大值)与末尾交换,再调整堆
    int end = r;
    while (end)
    {
        Swap(&arr[0], &arr[--end]);
        AdjustDown(arr, end, 0);
    }
}

过程分析

  • 建堆 :从最后一个非叶子节点 (n-1-1)/2 开始往前,对每个节点执行向下调整,最终得到一个大堆。
  • 排序:将堆顶最大值与数组末尾交换,此时末尾就是最大元素;然后对剩余的 n-1 个元素重新调整为堆,循环直至堆为空。
  • 核心就在于 向下调整算法 ------ 让父节点与孩子节点比较,若孩子比父大(大堆),则交换,并继续向下调整。

时空复杂度

  • 空间复杂度:O(1),原地排序
  • 时间复杂度:O(N log N),建堆 O(N),每次调整 O(log N),共 N 次

性能验证

10 万数据仅需 6 ms 左右,远超冒泡排序。


三、直接插入排序

想象一下打扑克牌时,每摸一张牌都会按顺序插入到手牌中------直接插入排序就是这个思路。

图解过程

数组 [4, 5, 2, 7, 1],逐步将元素插入已排序部分:

复制代码
初始:已排序[4],待插入[5, 2, 7, 1]

插入5:tep=5,end=0,4<5 不挪,插入到位置1
已排序[4, 5],待插入[2, 7, 1]

插入2:tep=2,end=1,5>2 挪到位置2,end=0
              4>2 挪到位置1,end=-1
              插入到位置0
已排序[2, 4, 5],待插入[7, 1]

插入7:tep=7,end=2,5<7 不挪,插入到位置3
已排序[2, 4, 5, 7],待插入[1]

插入1:tep=1,end=3,7>1 挪,end=2
              5>1 挪,end=1
              4>1 挪,end=0
              2>1 挪,end=-1
              插入到位置0
最终:[1, 2, 4, 5, 7]

代码实现

c 复制代码
void insertsort(int a[], int n)
{
    for (int i = 0; i < n - 1; i++)
    {
        int end = i;
        int tep = a[end + 1];          // 保存待插入元素
        while (end >= 0)
        {
            if (a[end] > tep)          // 比待插入元素大,往后挪
            {
                a[end + 1] = a[end];
                end--;
            }
            else
            {
                break;
            }
        }
        a[end + 1] = tep;              // 插入到正确位置
    }
}

过程分析

  • 外层循环控制要插入的元素,用 end 指向已排序部分的最后一个位置,tep 保存待插入元素。
  • 内层 while 循环中,如果已排序元素比 tep 大,就把它往后挪一位;否则找到插入位置。
  • 简单说:移动排序,像整理扑克牌一样,比新牌大的牌就往前挪。

时空复杂度

  • 空间复杂度:O(1)
  • 时间复杂度 :O(N²),最差情况为逆序;但实际很难遇到最差情况,所以实际效率比冒泡高不少

性能验证

10 万数据约 几十毫秒,明显优于冒泡排序。


四、希尔排序

希尔排序是直接插入排序的升级版 ,核心思想是预排序------先让数据基本有序,最后再做一次直接插入排序。

希尔排序法又称缩小增量法。先选定一个整数(通常是 gap = n/3+1),把待排序记录分成各组,所有距离相等的记录分在同一组内,对每一组进行排序,然后 gap = gap/3+1 得到下一个整数,再次分组排序......当 gap=1 时,就相当于直接插入排序。

图解过程

数组 [9, 5, 1, 7, 3, 6, 4, 8],gap 从 3 递减到 1:

复制代码
gap = 3 时,分组情况:
索引:  0  1  2  3  4  5  6  7
数据: 9  5  1  7  3  6  4  8
组别:  A  B  A  B  A  B  A  B

A组 [9, 1, 3, 4] 排序后:[1, 3, 4, 9]
B组 [5, 7, 6, 8] 排序后:[5, 6, 7, 8]

gap = 1 时,整体插入排序,此时数组已接近有序,效率极高

代码实现

c 复制代码
void shellsort(int a[], int n)
{
    int gap = n;
    while (gap > 1)
    {
        gap = gap / 3 + 1;            // 保证最后一次 gap=1
        for (int i = 0; i < n - gap; i++)
        {
            int end = i;
            int tep = a[end + gap];
            while (end >= 0)
            {
                if (a[end] > tep)
                {
                    a[end + gap] = a[end];
                    end -= gap;
                }
                else
                {
                    break;
                }
            }
            a[end + gap] = tep;
        }
    }
}

过程分析

  • gap 就是分组间隔,gap/3+1 使得 gap 逐步缩小,最终必为 1。
  • 每组内进行插入排序,当 gap=1 时,整个数组已经基本有序,直接插入排序效率最高。
  • 外层 for 循环用 i 来控制遍历,而不是每组单独排------优化点在于 i 到了哪一组就排哪一组。

时空复杂度

  • 空间复杂度:O(1)
  • 时间复杂度:O(N^1.3) 左右,《数据结构(C语言版)》--- 严蔚敏

性能验证

10 万数据约 6 ms,相比直接插入排序提升显著。


五、直接选择排序

直接选择排序的思路非常直接:每次在未排序部分选出最小值放到开头,选出最大值放到末尾,依次收缩边界。

图解过程

数组 [3, 5, 1, 4, 2],begin=0,end=4:

复制代码
初始:[3, 5, 1, 4, 2]
       ↑          ↑
      mini       maxi

第一轮找最小最大:
遍历 [3,5,1,4,2],发现 mini=2(在索引4),maxi=5(在索引1)
交换 min 到开头,max 到末尾:
[2, 5, 1, 4, 3] ←→ [2, 3, 1, 4, 5]
  begin=1, end=3

第二轮:
遍历 [5,1,4,3],发现 mini=1(在索引2),maxi=5(在索引0,但开头已处理)
因为 maxi==begin,需要将 maxi 修正为 mini(索引2)
交换:
[1, 3, 5, 4, 2]
  begin=2, end=2,结束

代码实现

c 复制代码
void SelectSort(int* arr, int n)
{
    int begin = 0, end = n - 1;
    while (begin < end)
    {
        int mini = begin, maxi = begin;
        for (int i = begin + 1; i <= end; i++)
        {
            if (arr[i] < arr[mini]) mini = i;
            if (arr[i] > arr[maxi]) maxi = i;
        }
        // 注意:若最大值在开头,先交换会覆盖 mini 的位置,需要修正
        if (maxi == begin) maxi = mini;
        Swap(&arr[mini], &arr[begin]);
        Swap(&arr[maxi], &arr[end]);
        begin++;
        end--;
    }
}

过程分析

  • 遍历未排序区间 [begin, end],找出最小值下标 mini 和最大值下标 maxi。
  • 交换到两端后,begin++,end--,缩小区间。
  • 特别注意:如果最大值恰好在开头,先交换 mini 和 begin 后,最大值的位置会被覆盖,此时需要将 maxi 修正为 mini。

时空复杂度

  • 空间复杂度:O(1)
  • 时间复杂度:O(N²)

性能验证

与冒泡排序大差不差,10 万数据约 2000-3000 ms


六、快速排序

快速排序简称"快排",相信即便没学过也听过它的大名,是面试和工程中的高频明星

快速排序的基本思想:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列所有元素均小于基准值,右子序列所有元素均大于基准值,然后递归左右子序列,直至所有元素排列在相应位置上。

图解过程 ------ Lomuto 前后指针法

数组 [4, 2, 7, 1, 5, 3],选基准值 key=4(左端):

复制代码
初始:
[4, 2, 7, 1, 5, 3]
 ↑key
prev=0, cur=1

cur=1:a[1]=2 < 4,++prev=1,交换(自己和自己,换了等于没换)
[4, 2, 7, 1, 5, 3]

cur=2:a[2]=7 > 4,cur++,不交换
cur=3:a[3]=1 < 4,++prev=3,交换 a[3]↔a[3]
[4, 2, 1, 7, 5, 3]

cur=4:a[4]=5 > 4,cur++,不交换
cur=5:a[5]=3 < 4,++prev=4,交换 a[5]↔a[4]
[4, 2, 1, 3, 5, 7]
           ↑
          prev

最后交换 a[prev] 和 a[key]:
[3, 2, 1, 4, 5, 7]
             ↑
           基准值位置(已归位)

左区间 [3,2,1],右区间 [5,7],递归继续...

1. hoare 版本

c 复制代码
int GetMid(int* a, int left, int right)
{
    int mid = (left + right) / 2;
    if (a[left] > a[right])
    {
        if (a[right] > a[mid]) return right;
        else if (a[mid] > a[left]) return left;
        else return mid;
    }
    else
    {
        if (a[mid] < a[left]) return left;
        else if (a[mid] > a[right]) return right;
        else return mid;
    }
}

int Quicksort(int* a, int left, int right)
{
    int mid = GetMid(a, left, right);
    Swap(&a[left], &a[mid]);           // 三数取中优化
    int key = left;
    int begin = left, end = right;
    while (begin < end)
    {
        while (begin < end && a[end] >= a[key]) end--;
        while (begin < end && a[begin] <= a[key]) begin++;
        Swap(&a[begin], &a[end]);
    }
    Swap(&a[key], &a[begin]);
    return begin;
}

void QuickSort(int* a, int left, int right)
{
    if (left >= right) return;
    int key = Quicksort(a, left, right);
    QuickSort(a, left, key - 1);
    QuickSort(a, key + 1, right);
}

2. Lomuto 前后指针法

c 复制代码
int partQuickSort(int* a, int left, int right)
{
    int mid = GetMid(a, left, right);
    Swap(&a[left], &a[mid]);
    int key = left;
    int prev = left, cur = left + 1;
    while (cur <= right)
    {
        if (a[cur] < a[key] && ++prev != cur)
            Swap(&a[prev], &a[cur]);
        cur++;
    }
    Swap(&a[prev], &a[key]);
    return prev;
}

3. 小区间优化 + 三数取中

c 复制代码
void Quicksort2(int* a, int left, int right)
{
    if (left >= right) return;
    if (right - left + 1 < 10)         // 小区间优化
    {
        insertsort(a + left, right - left + 1);
        return;
    }
    int key = Quicksort(a, left, right);
    Quicksort2(a, left, key - 1);
    Quicksort2(a, key + 1, right);
}

4. 非递归版本(借助栈)

c 复制代码
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 key = partQuickSort(a, begin, end);
        if (key + 1 < end) { STPush(&st, end); STPush(&st, key + 1); }
        if (begin < key - 1) { STPush(&st, key - 1); STPush(&st, begin); }
    }
    STDestory(&st);
}

时空复杂度

  • 空间复杂度:O(log N)(递归栈帧)
  • 时间复杂度:O(N log N),最差 O(N²)(有序数组可通过三数取中避免)

性能验证

10 万数据约 4 ms,综合性能最强。


七、归并排序

归并排序采用 分治法 的思想,先递归拆分数组至单个元素,再有序合并两个子数组,最终得到完全有序的序列。

图解过程

数组 [6, 5, 3, 1, 8, 7, 2, 4] 的递归拆分与合并:

复制代码
递归拆分:
[6, 5, 3, 1, 8, 7, 2, 4]
[6, 5, 3, 1]    [8, 7, 2, 4]
[6, 5]  [3, 1]  [8, 7]  [2, 4]
[6] [5] [3] [1] [8] [7] [2] [4]   ← 单个元素,递归终止

两两合并(归并):
[5, 6]  [1, 3]  [7, 8]  [2, 4]
[1, 3, 5, 6]    [2, 4, 7, 8]
[1, 2, 3, 4, 5, 6, 7, 8]   ← 完全有序

递归版本

c 复制代码
void _MergeSort(int* a, int* tmp, int begin, int end)
{
    if (begin == end) return;
    int mid = (begin + end) / 2;
    _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;
    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)
{
    int* tmp = (int*)malloc(sizeof(int) * n);
    _MergeSort(a, tmp, 0, n - 1);
    free(tmp);
}

这里时间复杂度未来介绍一下为什么是O(logN)

这里拿N等于8举例

因为这里用的是递归思想第一次是处理一个数组长度为的8进行归并排序所以是O(N);

第二个进入递归,是处理二个长度为【4】【4】的二个数组分别进行归并排序

第三次是【2】【2】【2】【2】进行归并。

第四次是【1】【1】【1】【1】【1】【1】【1】【1】归并

我们可以看见每一层都是O(N)

那一共有几层呢

我们可以算一下

  • 初始数组长度:n

  • 第1次拆分:得到2个长度为n/2的子数组

  • 第2次拆分:得到4个长度为n/4的子数组

  • ...

  • 第k次拆分:得到2^k个长度为n/2的k次方的子数组

当子数组长度为1时,停止拆分:

n/z^k= 1

两边取以2为底的对数:

k = log2 n

所以总层数约为log2 n层(向上取整,因为数组长度不一定是2的整数次幂)。

所以他的时间复杂度是O(NlogN)

非递归版本(循环实现)

c 复制代码
void _MergeSortNonR(int* a, int n)
{
    int* tmp = (int*)malloc(sizeof(int) * n);
    int gap = 1;
    while (gap < n)
    {
        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 = begin1;
            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 - begin1 + 1) * sizeof(int));
        }
        gap *= 2;
    }
    free(tmp);
}

这里我们可以通过图看见里面的循环执行次数每一次都是O(N)

外面的次数是logN

所以他的时间复杂度也是O(NlogN)

时空复杂度

  • 空间复杂度:O(N),需要额外辅助数组
  • 时间复杂度:O(N log N)

性能验证

10 万数据约 4 ms,与快排持平,且性能稳定。


八、计数排序

前面七种排序都需要两两比较元素大小,计数排序则另辟蹊径,通过统计每个元素出现的次数,按下标天然有序的特性来完成排序

图解过程

数组 [4, 2, 4, 1, 3]

复制代码
Step 1:找最小最大值
min=1, max=4,range = 4-1+1 = 4

Step 2:创建计数数组(长度4,全0)
count: [0, 0, 0, 0]
        ↓
       index

Step 3:遍历原数组,统计并映射
a[0]=4 → count[4-1]=count[3]++
a[1]=2 → count[2-1]=count[1]++
a[2]=4 → count[3]++
a[3]=1 → count[1-1]=count[0]++
a[4]=3 → count[3-1]=count[2]++

count: [1, 1, 1, 2]
         ↑  ↑  ↑  ↑
        1   2   3   4  (+min还原)

Step 4:遍历计数数组,回写原数组
count[0]=1 → a[0]=1+1=2
count[1]=1 → a[1]=2+1=3
count[2]=1 → a[2]=3+1=4
count[3]=2 → a[3]=4+1=5, a[4]=5+1=6
最终:[2, 3, 4, 5, 6]

代码实现

c 复制代码
void contsort(int* a, int n)
{
    int min = a[0], 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* cout = (int*)calloc(range, sizeof(int));

    // 统计次数
    for (int i = 0; i < n; i++)
    {
        cout[a[i] - min]++;           // 映射到计数数组下标
    }

    // 回写
    int j = 0;
    for (int i = 0; i < range; i++)
    {
        while (cout[i]--)
        {
            a[j++] = i + min;
        }
    }
    free(cout);
}

过程分析

  1. 先遍历数组找出最小值和最大值,确定计数数组的大小 range = max - min + 1
  2. 创建计数数组 cout,用 calloc 初始化为 0。
  3. 遍历原数组,通过 a[i] - min 映射到计数数组下标并累加计数。
  4. 最后遍历计数数组,按下标(加上最小值还原原值)依次填回原数组。

特性分析

  • 计数排序不是比较排序,利用下标天然有序的特性完成排序。
  • 适用场景:数据范围集中时效率极高;数据分散时空间浪费严重。

时空复杂度

  • 空间复杂度:O(range)
  • 时间复杂度:O(N + range)

性能验证

10 万数据 0 ms,在适用场景下堪称恐怖,但适用范围有限。


九、完整测试代码

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

int main()
{
    srand((unsigned int)time(NULL));
    const int N = 100000;
    int* a1 = (int*)malloc(sizeof(int) * N);
    int* a2 = (int*)malloc(sizeof(int) * N);
    int* a3 = (int*)malloc(sizeof(int) * N);
    int* a4 = (int*)malloc(sizeof(int) * N);
    int* a5 = (int*)malloc(sizeof(int) * N);
    int* a6 = (int*)malloc(sizeof(int) * N);
    int* a7 = (int*)malloc(sizeof(int) * N);

    for (int i = 0; i < N; ++i)
    {
        a1[i] = rand() + i;
        a2[i] = a1[i];
        a3[i] = a1[i];
        a4[i] = a1[i];
        a5[i] = a1[i];
        a6[i] = a1[i];
        a7[i] = a1[i];
    }

    int begin7 = clock(); MergeSort(a7, N);        int end7 = clock();
    int begin6 = clock(); QuickSortNonR(a6, 0, N - 1); int end6 = clock();
    int begin5 = clock(); shellsort(a5, N);         int end5 = clock();
    int begin4 = clock(); Heappai(a4, N);          int end4 = clock();
    int begin3 = clock(); maopao(a3, N);           int end3 = clock();
    int begin2 = clock(); qsort(a2, N, sizeof(int), paixu); int end2 = clock();
    int begin1 = clock(); Quicksort2(a1, 0, N - 1); int end1 = clock();

    printf("排序 %d 个随机数,各算法用时(毫秒):\n", N);
    printf("MergeSort:       %d\n", end7 - begin7);
    printf("QuickSortNonR:   %d\n", end6 - begin6);
    printf("shellsort:       %d\n", end5 - begin5);
    printf("Heapsort:        %d\n", end4 - begin4);
    printf("bubblesort:      %d\n", end3 - begin3);
    printf("qsort:           %d\n", end2 - begin2);
    printf("Quicksort2:      %d\n", end1 - begin1);

    free(a1); free(a2); free(a3); free(a4); free(a5); free(a6); free(a7);
    return 0;
}

测试结果

复制代码
排序 100000 个随机数,各算法用时(毫秒):
MergeSort:       4
QuickSortNonR:   4
shellsort:       6
Heapsort:        6
bubblesort:      3000+
qsort:           8
Quicksort2:      4

十、排序稳定性详解

什么是稳定性?

稳定性 是指:待排序序列中存在值相等的元素 ,排序后这些相等元素的相对前后顺序保持不变 ,则称该排序算法是稳定 的;否则称为不稳定的。

举例说明

有一个数组,每个元素不仅有值,还有原始下标(用于区分相同值):

复制代码
初始状态:  [5a, 3, 5b, 1, 3]
          a在b前,都是5

排升序后:

  • 稳定排序结果 : [1, 3a, 3, 5a, 5b] → 两个 3 的相对顺序没变,两个 5 的相对顺序也没变
  • 不稳定排序结果 : [1, 3, 3, 5b, 5a] → 两个 5 的相对顺序被颠倒

为什么有的稳定,有的不稳定?

稳定排序 :只在必须交换时才交换 ,相同值之间靠比较判断 (> / <),相等时保持原位置不动。

排序 稳定原因
冒泡排序 if(arr[j] > arr[j+1])> 而非 ,相等时不交换 ✅
直接插入排序 从后往前找,遇到 a[end] > tep 才挪,等于时 break,相同值保持原有顺序 ✅
归并排序 合并时 a[begin1] <= a[begin2],用 <= 保证相等时左区间优先 ✅
计数排序 用下标映射,下标天然有序,相同值不会被调换位置 ✅

不稳定排序 :排序过程中,相同值的元素可能被交换到另一个相同值的前面或后面,破坏了原始相对顺序。

排序 不稳定原因
直接选择排序 每次同时交换 min 和 max 到两端,相同值的两个元素可能被换位 ✗
希尔排序 gap > 1 预排序阶段,跨组比较时相同值可能产生相对位移 ✗
堆排序 建堆和调整过程中,堆顶与末尾交换时,相同值可能被打乱 ✗
快速排序 hoare/挖坑法中,right 找小 left 找大交换,相同值可能在两区间之间被换位 ✗

稳定性的意义

如果排序对象是多字段结构体(比如先按成绩排序,相同成绩时保持姓名先后顺序),稳定性就有重要价值。

简单说:稳定排序保护"相等"元素的相对位置,不稳定排序不保证这一点


十一、排序总结

排序名称 最好时间 平均时间 最坏时间 空间复杂度 稳定性
冒泡排序 O(N) O(N²) O(N²) O(1) ✅ 稳定
堆排序 O(N log N) O(N log N) O(N log N) O(1) ❌ 不稳定
直接插入排序 O(N) O(N²) O(N²) O(1) ✅ 稳定
希尔排序 O(N log N) O(N^1.3) O(N²) O(1) ❌ 不稳定
直接选择排序 O(N²) O(N²) O(N²) O(1) ❌ 不稳定
快速排序 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(N) ✅ 稳定
计数排序 O(N + range) O(N + range) O(N + range) O(range) ✅ 稳定

十二、内部排序与外部排序

前面讲的八大排序算法,全部属于内部排序,因为它们假设数据在内存中,可以随机访问。但实际工程中,数据量往往远大于内存容量,这就需要外部排序。

什么是内部排序?什么是外部排序?

类型 定义 适用场景
内部排序(内排) 数据全部加载到内存中,可以随机访问任意元素 数据量小,能完整装进内存
外部排序(外排) 数据太大无法一次性装进内存,需要分块读写磁盘/文件 超大文件、百万/千万级数据量

什么时候用外排?

当数据量达到内存装不下的程度时,就必须用外排:

  • 排序 10 亿个整数(~40GB),机器只有 16GB 内存
  • 排序一个 100GB 的日志文件
  • 数据库对超大型表进行排序输出

外排的核心思想:分而治之 + 多路归并

外排分为两个阶段

阶段一:分段内排

复制代码
原始大文件(太大,无法一次读入内存)
        ↓
每次读一块数据进内存(比如 1GB)
        ↓
用内排算法(快排/归并排)排好这一块
        ↓
写回磁盘,得到若干有序的小文件(称为"归并段")

阶段二:多路归并

复制代码
多个有序小文件
        ↓
每次从各文件读一个数(或一小批),选最小/最大的输出
        ↓
继续读、继续选,直到所有文件处理完毕
        ↓
最终得到完全有序的大文件

图解过程

假设内存每次最多容纳 3 个整数,待排序数据为 [8, 3, 9, 2, 7, 1, 5, 4, 6]

复制代码
【阶段一:分段内排】
内存每次最多3个数,分3次读入:

读入 [8, 3, 9] → 内排 → [3, 8, 9] → 写回 temp1
读入 [2, 7, 1] → 内排 → [1, 2, 7] → 写回 temp2
读入 [5, 4, 6] → 内排 → [4, 5, 6] → 写回 temp3

得到三个有序文件:temp1=[3,8,9]  temp2=[1,2,7]  temp3=[4,5,6]

【阶段二:多路归并】
三路归并:每次从 temp1/temp2/temp3 各读一个数出来比较

第一轮:
比较 3(t1)、1(t2)、4(t3) → 选 1 → 输出 [1],从 temp2 补一个数
比较 3(t1)、2(t2)、4(t3) → 选 2 → 输出 [1,2],从 temp2 补一个数
比较 3(t1)、7(t2)、4(t3) → 选 3 → 输出 [1,2,3],从 temp1 补一个数
比较 8(t1)、7(t2)、4(t3) → 选 4 → 输出 [1,2,3,4],从 temp3 补一个数
比较 8(t1)、7(t2)、5(t3) → 选 5 → 输出 [1,2,3,4,5],从 temp3 补一个数
比较 8(t1)、7(t2)、6(t3) → 选 6 → 输出 [1,2,3,4,5,6],从 temp3 补一个数
...继续,最终输出 [1,2,3,4,5,6,7,8,9]

关键点:多路归并的效率

多路归并的复杂度为 O(N logK),其中:

  • N 为总数据量
  • K 为归并路数(文件数量)

路数 K 越大,层数越少,读写磁盘次数越少。理想情况下 K 越大越好,但受限于内存中同时打开的文件描述符数量

实际优化手段:

  • 增加归并路数(K 从 2 增大到 8、16......)
  • 败者树/胜者树:减少比较次数
  • 置换-选择排序:生成长度更大的有序归并段,减少归并段数量

内排 vs 外排的选择

数据量 内存够用? 推荐方案
几千~几万 直接内排,快排/归并排随便选
几十万~几百万 内排,可用优化快排或归并排
数千万~数亿 外排,分块内排 + 多路归并
TB 级数据 外排 + 加大归并路数/多线程/分布式

一句话总结

  • 内排:数据装得下内存,所有元素随意访问,快排/归并排/堆排随便用
  • 外排 :数据太大装不下,分块读进内存排好序,再通过归并思想把各有序块合并成整体有序

这里举一个例子说一下

假如我们有10亿个整数,那他就要10亿乘以4个比特位,而内存就只有1G,这里我们见一下换算单位
1G=1024MB
1MB=1024KB
1KB=1024byte

所以1G大约等于10的9次方byte也就是1亿

所以这时候我们用内存,直接直接排序根本排不了,这时候我们就要使用外排序了。

这里我们可以先把4G的大文件存在磁盘里面,在分别取1G存进内存里面进行排序,内存里面现在也不能用归并排序,因为他还要开辟一个O(N)的空间,所以我们就用快排,排内存的,然后依次排4个文件,在内存里面排好了再取出来,进行归并排序,下面我用一个图来说一下。

结语

以上就是我们完整实现和分析的八大排序算法。感谢大家的支持!

相关推荐
承渊政道1 小时前
【贪心算法】(经典实战应用解析(一):柠檬水找零、将数组和减半的最少操作次数、最大数、摆动序列)
数据结构·c++·学习·算法·leetcode·贪心算法·排序算法
初心未改HD1 小时前
机器学习之支持向量机SVM详解
算法·机器学习·支持向量机
少司府1 小时前
C++基础入门:vector深度解析(七千字深度剖析)
c语言·开发语言·数据结构·c++·容器·vector·顺序表
he___H1 小时前
子串----
java·数据结构·算法·leetcode
05候补工程师2 小时前
【ROS 2 避坑指南】从 SLAM 实时建图到 Nav2 导航算法深度调优全过程
算法·ubuntu·机器人
Dlrb12112 小时前
C语言-函数传参
c语言·数据结构·算法
洛水水10 小时前
【力扣100题】18.随机链表的复制
算法·leetcode·链表
南宫萧幕10 小时前
规则基 EMS 仿真实战:SOC 区间划分与 Simulink 闭环建模全解
算法·matlab·控制
多加点辣也没关系10 小时前
数据结构与算法|第二十三章:高级数据结构
数据结构·算法