数据结构之排序

一、先搞懂排序的「稳定性」:为啥它很重要?

排序的核心目标是将序列按指定规则(如升序 / 降序)重新排列,但「稳定性」这个容易被忽略的属性,直接决定了排序结果是否符合业务预期。

官方定义:若序列中两个等值数据(记为 k [i] 和 k [j],排序前 i<j),排序后依然保持 k [i] 在 k [j] 前面,则称这种排序是稳定的;反之则不稳定。

生活化例子:给 "学生成绩表" 按分数升序排序时,若两个同学分数相同 ------ 稳定排序会保留两人原本的提交时间 / 排名顺序,不稳定排序则可能打乱这个顺序。如果业务要求 "分数相同按提交时间排序",稳定性就直接决定了结果的正确性。

二、常用排序算法:性能 + 稳定性对比表

直接上干货!这张表覆盖面试 / 开发高频排序算法,核心指标一目了然:

排序方法 平均时间复杂度 最好情况 最坏情况 辅助空间 稳定性 适用场景
冒泡排序 \(O(n^2)\) \(O(n)\) \(O(n^2)\) \(O(1)\) 稳定 小数据量(n<100)、代码极简
简单选择排序 \(O(n^2)\) \(O(n^2)\) \(O(n^2)\) \(O(1)\) 不稳定 交换成本高的场景(交换次数少)
直接插入排序 \(O(n^2)\) \(O(n)\) \(O(n^2)\) \(O(1)\) 稳定 数据基本有序、动态插入场景
折半插入排序 \(O(n^2)\) \(O(nlogn)\) \(O(n^2)\) \(O(1)\) 稳定 优化查找,比直接插入更高效
希尔排序 \(O(nlogn)~O(n^2)\) \(O(n^{1.3})\) \(O(n^2)\) \(O(1)\) 不稳定 中等规模数据,插入排序优化版
堆排序 \(O(nlogn)\) \(O(nlogn)\) \(O(nlogn)\) \(O(1)\) 不稳定 大数据量、内存受限场景
归并排序 \(O(nlogn)\) \(O(nlogn)\) \(O(nlogn)\) \(O(n)\) 稳定 对稳定性有要求的大数据场景
快速排序(递归) \(O(nlogn)\) \(O(nlogn)\) \(O(n^2)\) \(O(logn)~O(n)\) 不稳定 大数据量、平均效率最优
快速排序(非递归) \(O(nlogn)\) \(O(nlogn)\) \(O(n^2)\) \(O(logn)\) 不稳定 生产环境大数据量,避免递归栈溢出

划重点

  • 小数据量(n<100):冒泡 / 插入 / 选择排序够用(代码简单、空间占用少);
  • 大数据量:优先选快排 / 归并(时间复杂度低);
  • 对稳定性有要求:归并排序 / 插入排序是优选(快排 / 堆排序不稳定);
  • 数据基本有序:直接插入排序效率接近\(O(n)\),性价比极高;
  • 内存受限场景:堆排序是唯一选择(原地排序,无额外空间开销)。

三、9 大经典排序算法:

以下均为可直接运行的 C 语言实现,重点标注核心逻辑和关键细节。

1. *冒泡排序:最易理解的 "相邻交换"

核心原理:重复遍历数组,每次比较相邻元素,将较大的元素逐步 "冒泡" 到数组尾部;每轮遍历后,尾部已排序的部分无需再比较,内层循环范围可逐步缩小。

复制代码
void bubble_sort(int *arr, int len)
{
    // i:外层控制排序轮次;j:内层遍历未排序区间;k:临时变量用于交换
    int i, j, k;
    for(i=0; i<len; i++)
    {
        // len-i:每轮结束后,末尾i个元素已排好序,无需重复比较
        for(j=1; j< len-i; j++)
        {
            // 前一项大于当前项,交换位置(保证升序)
            if( arr[j-1] > arr[j] )
            {
                k = arr[j];
                arr[j] = arr[j-1];
                arr[j-1] = k;
            }
        }
    }
}

2. *直接插入排序:"逐个归位" 的高效排序

核心原理:将数组分为 "有序区" 和 "无序区",初始有序区仅包含第一个元素;每次从无序区取一个元素,插入到有序区的合适位置,逐步扩大有序区直至整个数组有序。

复制代码
// arr:待排序数组;len:数组长度
void insert_order(int *arr, int len)
{
    int i, j, k;
    // i:指向无序区第一个元素(有序区初始为arr[0])
    for(i=1; i<len; i++)
    {
        k = arr[i]; // 保存待插入元素(避免移动时被覆盖)
        j = i-1;    // j:指向有序区最后一个元素
        // 有序区元素大于待插入值,依次后移,腾出插入位置
        while(j>=0 && arr[j] > k)
        {
            arr[j+1] = arr[j];
            j--;
        }
        arr[j+1] = k; // 将待插入元素放入最终位置
    }
}

3. *简单选择排序:"找最小值" 的交换优化

核心原理:每轮遍历无序区,找到最小值的下标,将其与无序区第一个元素交换;每轮仅需一次交换,相比冒泡排序大幅减少交换次数,适合交换成本高的场景。

复制代码
void select_order(int *arr, int len)
{
    // i:外层控制有序区边界;j:遍历无序区找最小值;min:最小值下标;k:交换临时变量
    int i, j, k, min;
    for(i=0; i<len; i++)
    {
        min = i; // 假设当前无序区第一个元素是最小值
        // 遍历无序区,找到真正的最小值下标
        for(j=i+1; j<len; j++)
        {
            if(arr[min] > arr[j])
                min = j;
        }
        // 最小值不在当前位置,交换(避免无意义的自我交换)
        if(min != i)
        {
            k = arr[min];
            arr[min] = arr[i];
            arr[i] = k;
        }
    }
}

4. *折半插入排序:二分查找优化插入效率

核心原理:在直接插入排序的基础上,用 "二分查找" 替代顺序查找,快速定位插入位置,减少比较次数(但元素移动次数不变,时间复杂度仍为\(O(n^2)\),但实际效率更高)。

复制代码
void bin_sort(int *arr, int len)
{
    int i, j, k, left, mid, right;
    for(i=1; i<len; i++)
    {
        left = 0;          // 有序区左边界
        right = i-1;       // 有序区右边界
        k = arr[i];        // 待插入元素
        // 二分查找插入位置
        while(left <= right)
        {
            mid = (left + right)/2; // 中间位置
            if(arr[mid] > k)        // 待插入值更小,去左半区找
                right = mid - 1;
            else                    // 待插入值更大,去右半区找
                left = mid + 1;
        }
        // 找到插入位置left后,有序区元素从后往前逐步后移
        for(j=i; j>=left; j--)
        {
            arr[j] = arr[j-1];
        }
        arr[left] = k; // 插入待排序元素
    }
}

5. *快速排序(递归版):大数据量的 "效率之王"

核心原理:采用 "分治思想",选一个基准值将数组分为两部分(左小右大),再递归处理左右子数组,直至子数组长度为 1。

复制代码
// arr:待排序数组;left:左边界下标;right:右边界下标
void quick_sort(int *arr, int left, int right)
{
    int i, j, k;
    i = left;    // 左边界指针
    j = right;   // 右边界指针
    k = arr[i];  // 选左边界元素作为基准值(可优化为随机选基准)

    // 左右指针未相遇时,进行分区
    while(i < j) 
    {
        // 从右往左找:比基准值小的元素
        while(k <= arr[j] && i < j)
            j--;
        // 找到后,将该元素放到左指针位置
        if(i < j)
            arr[i] = arr[j];

        // 从左往右找:比基准值大的元素
        while(k >= arr[i] && i < j)
            i++;
        // 找到后,将该元素放到右指针位置
        if(i < j)
            arr[j] = arr[i];
    }
    // 基准值放到最终的中间位置(i=j)
    arr[i] = k;

    // 递归排序左半部分(左边界到基准值前一位)
    if(left < i-1)
        quick_sort(arr, left, i-1);
    // 递归排序右半部分(基准值后一位到右边界)
    if(right > i+1)
        quick_sort(arr, i+1, right);
}
快排关键注意点
  • 最坏情况(数组已完全有序):时间复杂度退化到\(O(n^2)\),可通过 "随机选基准值""三数取中法" 优化;
  • 稳定性:快排是不稳定排序,等值元素的相对位置可能被打乱;
  • 递归风险:递归深度过深可能导致栈溢出,实际开发中可改用 "尾递归优化" 或 "栈模拟非递归实现"。

6. 希尔排序:插入排序的 "步长优化版"

核心原理 :将数组按步长(gap) 划分为多个子序列,对每个子序列做直接插入排序;逐步缩小步长(通常减半),当步长为 1 时,数组已基本有序,最后一次直接插入排序即可完成整体排序。相比直接插入排序,希尔排序通过大跨度移动元素,大幅减少后续插入的移动次数,效率显著提升。

复制代码
// arr:待排序数组;len:数组长度
void shell_sort(int *arr, int len)
{
    int i, j, k, gap;
    // gap:步长,初始为数组长度的一半,逐步减半至1
    for (gap = len / 2; gap > 0; gap /= 2)
    {
        // 对每个步长对应的子序列执行插入排序
        // i:指向每个子序列的无序区第一个元素
        for (i = gap; i < len; i++)
        {
            k = arr[i]; // 保存待插入元素(避免移动覆盖)
            j = i - gap; // j:指向当前子序列有序区最后一个元素
            // 子序列内元素大于待插入值,按步长后移
            while (j >= 0 && arr[j] > k)
            {
                arr[j + gap] = arr[j];
                j -= gap;
            }
            arr[j + gap] = k; // 插入到子序列的合适位置
        }
    }
}
关键注意点
  • 步长选择影响效率:常用 len/2 递减(简单易实现),最优步长(如 Knuth 序列:gap = gap*3 +1)可进一步优化;
  • 稳定性:不稳定(相同值的元素可能因步长分组被打乱相对位置);
  • 适用场景:中等规模数据(n≈1000~10000),比直接插入 / 冒泡快一个量级。

7. 堆排序:基于完全二叉树的高效排序

核心原理 :利用大顶堆(父节点值≥子节点值)的特性:

  1. 构建大顶堆:将无序数组调整为堆结构,保证根节点是最大值;

  2. 堆顶交换:将堆顶(最大值)与堆尾元素交换,缩小堆范围(已排序区 + 1);

  3. 堆化调整:对新堆顶执行 "下沉" 操作,恢复大顶堆特性;

  4. 重复步骤 2~3,直至堆范围为 1。

    // 堆化调整:对以i为根的子树进行大顶堆调整
    // arr:数组;len:数组总长度;i:待调整的根节点下标
    void heap_adjust(int arr, int len, int i)
    {
    int max_idx = i; // 假设当前根节点是最大值
    int left = 2
    i + 1; // 左子节点下标
    int right = 2*i + 2;// 右子节点下标
    int temp;

    复制代码
     // 左子节点更大,更新最大值下标
     if (left < len && arr[left] > arr[max_idx])
         max_idx = left;
     // 右子节点更大,更新最大值下标
     if (right < len && arr[right] > arr[max_idx])
         max_idx = right;
    
     // 最大值不是根节点,交换并递归调整受影响的子树
     if (max_idx != i)
     {
         temp = arr[i];
         arr[i] = arr[max_idx];
         arr[max_idx] = temp;
         // 递归调整交换后的子节点
         heap_adjust(arr, len, max_idx);
     }

    }

    // 堆排序主函数
    void heap_sort(int *arr, int len)
    {
    int i, temp;
    // 1. 构建大顶堆(从最后一个非叶子节点开始倒序调整)
    // 最后一个非叶子节点下标:len/2 - 1
    for (i = len/2 - 1; i >= 0; i--)
    heap_adjust(arr, len, i);

    复制代码
     // 2. 逐个取出堆顶(最大值)放到数组尾部
     for (i = len - 1; i > 0; i--)
     {
         // 堆顶(arr[0])与当前堆尾交换
         temp = arr[0];
         arr[0] = arr[i];
         arr[i] = temp;
    
         // 3. 调整剩余堆(范围缩小为0~i-1)
         heap_adjust(arr, i, 0);
     }

    }

关键注意点
  • 稳定性:不稳定(堆化交换会打乱等值元素的相对位置);
  • 空间复杂度:O (1)(原地排序),适合内存受限的大数据场景;
  • 时间复杂度:始终 O (nlogn)(无最坏情况退化),但常数项比快排大,实际效率略低。

8. 归并排序:稳定的分治排序

核心原理 :采用分治思想

  1. 分:将数组递归拆分为左右两个子数组,直到子数组长度为 1(天然有序);

  2. 治:将两个有序子数组合并为一个有序数组;

  3. 合:逐层合并子数组,最终得到完整的有序数组。

    // 合并两个有序子数组:arr[left...mid] 和 arr[mid+1...right]
    // temp:临时数组,用于存储合并结果(避免频繁申请内存)
    void merge(int *arr, int left, int mid, int right, int *temp)
    {
    int i = left; // 左子数组起始下标
    int j = mid + 1;// 右子数组起始下标
    int k = 0; // temp数组的下标

    复制代码
     // 合并两个有序子数组到temp
     while (i <= mid && j <= right)
     {
         // 稳定合并:左子数组元素≤右子数组时优先取左(保证稳定性)
         if (arr[i] <= arr[j])
             temp[k++] = arr[i++];
         else
             temp[k++] = arr[j++];
     }
    
     // 左子数组剩余元素拷贝到temp
     while (i <= mid)
         temp[k++] = arr[i++];
     // 右子数组剩余元素拷贝到temp
     while (j <= right)
         temp[k++] = arr[j++];
    
     // 将temp中的有序数据拷贝回原数组
     k = 0;
     while (left <= right)
         arr[left++] = temp[k++];

    }

    // 归并排序递归函数
    void merge_sort_recur(int *arr, int left, int right, int *temp)
    {
    if (left >= right) // 子数组长度为1,递归终止
    return;

    复制代码
     int mid = (left + right) / 2;
     // 递归拆分左子数组
     merge_sort_recur(arr, left, mid, temp);
     // 递归拆分右子数组
     merge_sort_recur(arr, mid+1, right, temp);
     // 合并左右有序子数组
     merge(arr, left, mid, right, temp);

    }

    // 归并排序入口函数(封装递归,简化调用)
    void merge_sort(int *arr, int len)
    {
    // 申请临时数组(仅一次,避免递归中频繁申请)
    int *temp = (int *)malloc(len * sizeof(int));
    if (temp == NULL) // 内存申请失败处理
    {
    printf("内存申请失败!\n");
    return;
    }
    merge_sort_recur(arr, 0, len-1, temp);
    free(temp); // 释放临时内存
    }

关键注意点
  • 稳定性:稳定(合并时优先取左子数组的等值元素);
  • 空间复杂度:O (n)(需临时数组存储合并结果);
  • 适用场景:对稳定性有要求的大规模数据(如电商订单排序:价格相同按下单时间)。

9. 快速排序(非递归版):避免栈溢出的生产级实现

前文递归版快排存在栈溢出风险,非递归版用栈模拟递归过程,适合生产环境大规模数据排序。

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

// 分区函数:返回基准值最终位置(和递归版逻辑一致)
int partition(int *arr, int left, int right)
{
    int i = left, j = right;
    int pivot = arr[left]; // 基准值(可优化为三数取中)
    while (i < j)
    {
        // 从右往左找小于基准值的元素
        while (arr[j] >= pivot && i < j)
            j--;
        if (i < j)
            arr[i++] = arr[j];
        // 从左往右找大于基准值的元素
        while (arr[i] <= pivot && i < j)
            i++;
        if (i < j)
            arr[j--] = arr[i];
    }
    arr[i] = pivot; // 基准值归位
    return i;
}

// 非递归快速排序(用栈模拟递归)
void quick_sort_non_recur(int *arr, int len)
{
    if (len <= 1)
        return;

    // 用数组模拟栈,存储待排序区间的左右下标
    int *stack = (int *)malloc(2 * len * sizeof(int));
    if (stack == NULL)
    {
        printf("内存申请失败!\n");
        return;
    }
    int top = -1; // 栈顶指针(初始为-1,空栈)

    // 初始入栈:整个数组的左右边界
    stack[++top] = 0;
    stack[++top] = len - 1;

    // 栈非空时循环
    while (top >= 0)
    {
        // 出栈:先取右边界,再取左边界
        int right = stack[top--];
        int left = stack[top--];

        // 分区,得到基准值位置
        int pivot_idx = partition(arr, left, right);

        // 左子区间(left ~ pivot_idx-1)入栈(长度>1才入栈)
        if (pivot_idx - 1 > left)
        {
            stack[++top] = left;
            stack[++top] = pivot_idx - 1;
        }
        // 右子区间(pivot_idx+1 ~ right)入栈(长度>1才入栈)
        if (pivot_idx + 1 < right)
        {
            stack[++top] = pivot_idx + 1;
            stack[++top] = right;
        }
    }
    free(stack); // 释放栈内存
}

四、全场景排序算法选型指南

结合算法特性与业务需求,以下是精准的选型方案:

业务场景 最优算法 核心原因
小数据量(n<100) 冒泡 / 直接插入 代码简单、调试成本低,无需复杂优化
数据基本有序 直接插入排序 效率接近\(O(n)\),无额外空间开销
大结构体数组排序(交换成本高) 简单选择排序 交换次数最少,降低结构体拷贝成本
中等规模数据(n≈1000~10000) 希尔排序 平衡效率与实现复杂度,比插入排序快一个量级
大规模无稳定性要求(常规场景) 非递归优化版快速排序 避免栈溢出,平均时间复杂度最优
大规模且要求稳定性(如订单排序) 归并排序 稳定 + O (nlogn),牺牲空间换业务正确性
内存受限的大数据场景(如嵌入式) 堆排序 原地排序(O (1) 空间),时间复杂度稳定
相关推荐
Zhixiong Sun2 小时前
【算法训练营】【day1】数组part01
算法·力扣
Pluchon2 小时前
硅基计划4.0 算法 BFS最短路问题&多源BFS&拓扑排序
java·算法·哈希算法·近邻算法·广度优先·宽度优先·迭代加深
小尧嵌入式3 小时前
音视频入门基础知识
开发语言·c++·qt·算法·音视频
小股虫3 小时前
Redis数据结构底层深度解析:写入与读取的高效逻辑
数据结构·redis·bootstrap
CoderYanger3 小时前
C.滑动窗口-求子数组个数-越短越合法——3134. 找出唯一性数组的中位数
java·开发语言·数据结构·算法·leetcode
_OP_CHEN3 小时前
【算法基础篇】(二十八)线性动态规划之基础 DP 超详解:从入门到实战,覆盖 4 道经典例题 + 优化技巧
算法·蓝桥杯·动态规划·运筹学·算法竞赛·acm/icpc·线性动态规划
ndzson3 小时前
从前序与中序遍历序列构造二叉树 与
数据结构·算法
今天你TLE了吗3 小时前
LeeCode Hot100随机链表的复制 java易懂题解
java·数据结构·链表
山峰哥3 小时前
现代 C++ 的最佳实践:从语法糖到工程化思维的全维度探索
java·大数据·开发语言·数据结构·c++