常用的七大排序

(叠甲:如有侵权请联系,内容都是自己学习的总结,一定不全面,仅当互相交流(轻点骂)我也只是站在巨人肩膀上的一个小卡拉米,已老实,求放过)

直接插入排序

直接插入排序(Straight Insertion Sort),它是插入排序家族里最直观、最容易理解的算法,逻辑完全贴合我们日常 “理扑克牌” 的行为。

一、核心思想

直接插入排序的核心是“逐步构建有序序列”: 把数组分成两部分:

  • 已排序区间:初始只包含数组的第 1 个元素(默认单个元素天然有序)
  • 未排序区间:剩下的所有元素

算法做的事情很简单:依次从未排序区间取出一个元素,把它插入到已排序区间的正确位置,让已排序区间始终保持有序,直到所有元素都插入完成。

生活类比:就像打扑克摸牌时,你手里的牌是整齐有序的,每摸到一张新牌,就从右往左比对,把新牌插到合适的位置。

二、算法执行步骤

以数组 {5, 2, 4, 6, 1, 3} 为例,完整走一遍流程:

  1. 初始状态:已排序区间 = [5],未排序区间 = [2, 4, 6, 1, 3]
  2. 处理第 2 个元素(2): 从已排序末尾(5)向前比:5 > 2,把 5 向后挪一位; 已遍历完所有有序元素,把 2 插到最前面 → 已排序:[2, 5]
  3. 处理第 3 个元素(4): 从后往前比:5 > 4 → 5 后移;2 < 4 → 停止; 把 4 插到 2 和 5 中间 → 已排序:[2, 4, 5]
  4. 处理第 4 个元素(6): 从后往前比:5 < 6 → 不用动,直接放在末尾 → 已排序:[2, 4, 5, 6]
  5. 处理第 5 个元素(1): 从后往前依次比较:6、5、4、2 都比 1 大,全部后移一位; 遍历到头,把 1 插到最前面 → 已排序:[1, 2, 4, 5, 6]
  6. 处理第 6 个元素(3): 从后往前比:6、5、4 都比 3 大,依次后移;2 < 3 → 停止; 把 3 插到 2 和 4 中间 → 最终有序:[1, 2, 3, 4, 5, 6]

三、C 语言代码实现

下面是标准实现:

#include <stdio.h>

// 直接插入排序:arr是待排序数组,n是数组长度
void straightInsertionSort(int arr[], int n) 
{
    int i, j, temp;
    
    // 从第2个元素(下标1)开始,逐个插入到前面的有序序列
    for (i = 1; i < n; i++) 
    {
        temp = arr[i];      // 1. 保存当前待插入的元素(防止被覆盖)
        j = i - 1;          // 2. 指针指向已排序区间的最后一个元素
        
        // 3. 向前遍历已排序区间:元素大于temp就向后挪位
        while (j >= 0 && arr[j] > temp) 
        {
            arr[j + 1] = arr[j];  // 元素后移一位,腾出空位
            j--;
        }
        
        arr[j + 1] = temp;  // 4. 把待插入元素放进正确的空位
    }
}

// 测试
int main() 
{
    int arr[] = {5, 2, 4, 6, 1, 3};
    int len = sizeof(arr) / sizeof(arr[0]);
    
    printf("排序前:");
    for (int i = 0; i < len; i++) 
    {
        printf("%d ", arr[i]);
    }
    
    straightInsertionSort(arr, len);
    
    printf("\n排序后:");
    for (int i = 0; i < len; i++) 
    {
        printf("%d ", arr[i]);
    }
    return 0;
}

四、复杂度与特性

维度结论说明
最好时间复杂度O(n)数组已经完全有序时,每个元素只比较 1 次
最坏时间复杂度O(n²)数组完全逆序时,每个元素都要移动到最前面
平均时间复杂度O(n²)
空间复杂度O(1)只用到几个临时变量,属于原地排序
稳定性稳定排序相等的元素不会交换相对顺序

五、适用场景

  1. 数据量较小的排序场景
  2. 数组基本有序的场景(越有序,效率越高)
  3. 作为高级排序算法的基础(比如希尔排序就是在它的基础上优化而来)

希尔排序

先回顾直接插入排序的短板:当数据量大、整体逆序程度高时,每个元素都要一步步向前挪动,移动次数非常多,效率很低(最坏 O (n²))。
希尔排序(Shell's Sort)是直接插入排序的优化版本,也叫缩小增量排序,核心思路是:先通过分组让数组达到「基本有序」的状态,再执行一次直接插入排序,大幅减少元素移动的总次数,从而提升效率。

一、核心思想


希尔排序的逻辑可以概括为「分组预排序 → 逐步缩增量 → 最终直接插入」:

  1. 选定一个增量 gap,把数组中间隔为 gap 的元素分为一组,每组内部执行直接插入排序。
  2. 不断缩小 gap(通常每次减半),重复分组排序的操作。
  3. 当 gap = 1 时,整个数组成为一组,此时数组已经基本有序,执行一次直接插入排序即可完成整体排序。

本质:大增量下快速调整元素的宏观位置,小增量下精细调整顺序;最后一步 gap=1 的直接插入排序,因为数组已基本有序,时间复杂度接近最优的 O (n)。

二、算法执行步骤(完整示例)


以数组 {8, 9, 1, 7, 2, 3, 5, 4, 6, 0}(长度 n=10)为例,采用入门最常用的希尔增量序列(初始 gap = n/2,每次整数减半)。
第 1 轮:gap = 5
按间隔 5 分组,共 5 组,每组 2 个元素:
组 0(下标 0、5):8、3
组 1(下标 1、6):9、5
组 2(下标 2、7):1、4
组 3(下标 3、8):7、6
组 4(下标 4、9):2、0
每组分别做直接插入排序后,数组变为:
{3, 5, 1, 6, 0, 8, 9, 4, 7, 2}
第 2 轮:gap = 2
按间隔 2 分组,共 2 组,每组 5 个元素:
偶数组(下标 0,2,4,6,8):3、1、0、9、7
奇数组(下标 1,3,5,7,9):5、6、8、4、2
每组分别做直接插入排序后,数组变为:
{0, 2, 1, 4, 3, 5, 7, 6, 9, 8}
第 3 轮:gap = 1
gap=1 时,整个数组为一组,执行一次直接插入排序。此时数组已经基本有序,仅需少量移动即可完成排序。
最终有序数组:{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
你可以验证:当 gap=1 时,希尔排序的代码逻辑和直接插入排序完全一致。

三、C 语言代码实现

#include <stdio.h>

// 希尔排序:arr为待排序数组,n为数组长度
void shellSort(int arr[], int n) 
{
    int i, j, temp;
    int gap;

    // 外层:控制增量gap,初始为n/2,每次减半,直到gap=1
    for (gap = n / 2; gap > 0; gap /= 2) 
    {
        // 中层+内层:对每一组执行直接插入排序,步长为gap
        for (i = gap; i < n; i++) 
        {
            temp = arr[i];      // 保存当前待插入元素,防止被覆盖
            j = i - gap;        // 指向同组中前一个元素的下标

            // 同组内向前遍历:元素大于temp就向后挪gap位
            while (j >= 0 && arr[j] > temp) 
            {
                arr[j + gap] = arr[j];
                j -= gap;
            }

            arr[j + gap] = temp; // 将元素插入正确位置
        }
    }
}

// 测试用例
int main() {
    int arr[] = {8, 9, 1, 7, 2, 3, 5, 4, 6, 0};
    int len = sizeof(arr) / sizeof(arr[0]);

    printf("排序前:");
    for (int i = 0; i < len; i++) 
    {
        printf("%d ", arr[i]);
    }
    shellSort(arr, len);

    printf("\n排序后:");
    for (int i = 0; i < len; i++) 
    {
        printf("%d ", arr[i]);
    }
    return 0;
}

四、复杂度与核心特性

维度结论说明
时间复杂度不固定,取决于增量序列原始希尔增量最坏 O (n²),实际平均性能远优于直接插入排序;优化增量(如 Sedgewick)可达 O (n^1.3)~O (n^4/3)
空间复杂度O(1)原地排序,仅使用少量临时变量
稳定性不稳定排序分组过程中,相等元素可能被分到不同组,相对顺序会被打乱

五、增量序列的补充


希尔排序的性能核心在于增量序列的选择,常见的有三种:

  1. 希尔增量:gap = n/2, n/4, ..., 1,实现最简单,入门首选
  2. Knuth 增量:gap = (3^k - 1)/2,反向推导至小于 n,工程中更常用
  3. Sedgewick 增量:混合增量公式,性能最优,适合大规模数据

七、适用场景

  • 中等规模数据排序(几千到几万级别)
  • 追求代码简洁、无需额外内存的场景
  • 效率显著优于直接插入排序,实现比快速排序、归并排序更简单

选择排序

一、核心思想

选择排序(Selection Sort)和插入排序一样,也把数组分成已排序区间未排序区间,但操作逻辑完全不同:

  • 插入排序:取出未排序的第一个元素,插入到已排序区间的正确位置
  • 选择排序:在未排序区间里找到最小(大)的元素,直接和未排序区间的第一个元素交换,放到已排序区间的末尾

简单概括:每一轮选一个最小的,“拎” 到前面排好

生活类比:军训排队列,教官每次从队伍里找出最矮的同学,站到已排好队伍的最后,重复直到排完。

二、算法执行步骤


我们继续用熟悉的数组 {5, 2, 4, 6, 1, 3} 演示,长度 n=6,总共需要执行 n-1 轮(最后一个元素自然有序)。
初始状态:已排序区间 = 空,未排序区间 = [5, 2, 4, 6, 1, 3]
第 1 轮:遍历整个未排序区间,找到最小值 1(下标 4)

  • 和未排序区间第 1 个元素 5(下标 0)交换
  • 数组变为:{1, 2, 4, 6, 5, 3},已排序区间 = [1]

第 2 轮:遍历未排序区间 [2, 4, 6, 5, 3],最小值是 2(下标 1)

  • 已经在正确位置,无需交换
  • 已排序区间 = [1, 2]

第 3 轮:遍历未排序区间 [4, 6, 5, 3],找到最小值 3(下标 5)

  • 和未排序第 1 个元素 4(下标 2)交换
  • 数组变为:{1, 2, 3, 6, 5, 4},已排序区间 = [1, 2, 3]

第 4 轮:遍历未排序区间 [6, 5, 4],找到最小值 4(下标 5)

  • 和未排序第 1 个元素 6(下标 3)交换
  • 数组变为:{1, 2, 3, 4, 5, 6},已排序区间 = [1, 2, 3, 4]

第 5 轮:未排序区间只剩 [5, 6],最小值 5 已在正确位置

  • 排序完成,最终结果:{1, 2, 3, 4, 5, 6}

三、C 语言代码实现


核心逻辑:外层控制轮次,内层遍历未排序区找最小值下标,最后交换一次。

#include <stdio.h>

// 选择排序:arr为待排序数组,n为数组长度
void selectionSort(int arr[], int n) 
{
    int i, j, minIndex, temp;
    
    // 外层:共n-1轮,i是未排序区间的起始下标
    for (i = 0; i < n - 1; i++) 
    {
        minIndex = i;  // 假设未排序区间第一个元素就是最小值
        
        // 内层:遍历未排序区间,找到真正的最小值下标
        for (j = i + 1; j < n; j++) 
        {
            if (arr[j] < arr[minIndex]) 
            {
                minIndex = j;  // 更新最小值下标
            }
        }
        
        // 找到最小值后,和未排序区间第一个元素交换
        if (minIndex != i) 
        {
            temp = arr[i];
            arr[i] = arr[minIndex];
            arr[minIndex] = temp;
        }
    }
}

// 测试用例
int main() 
{
    int arr[] = {5, 2, 4, 6, 1, 3};
    int len = sizeof(arr) / sizeof(arr[0]);
    
    printf("排序前:");
    for (int i = 0; i < len; i++) 
    {
    printf("%d ", arr[i]);
    }
    selectionSort(arr, len);
    
    printf("\n排序后:");
    for (int i = 0; i < len; i++)     
    {
    printf("%d ", arr[i]);
    }
    return 0;
}

关键细节:全程只记录最小值的下标,全部遍历完再做一次交换;不要边比边换,会增加不必要的交换次数。

四、复杂度与核心特性

维度结论说明
最好时间复杂度O(n²)即使数组已经完全有序,也必须遍历每一轮找最小值
最坏时间复杂度O(n²)逆序场景下,比较次数和顺序场景完全相同
平均时间复杂度O(n²)
空间复杂度O(1)原地排序,仅使用少量临时变量
稳定性不稳定排序交换操作可能打乱相等元素的相对顺序

不稳定举例:数组 {3, 2, 3, 1},第一轮最小值 1 和第一个 3 交换后,两个 3 的前后顺序就颠倒了。

五、和直接插入排序的对比


同为 O (n²) 级别的基础排序,两者有明显差异:

对比项直接插入排序选择排序
操作方式逐个插入,元素多次后移找最小值,每轮最多 1 次交换
元素移动次数较多(逆序时频繁挪位)很少(每轮最多 1 次交换)
有序性影响数组越有序效率越高(最好 O (n))有序无序效率几乎无差别
稳定性稳定不稳定

六、适用场景

  1. 数据量较小的排序场景
  2. 元素交换次数敏感的场景(选择排序的交换次数是所有基础排序里最少的)
  3. 作为进阶算法的基础(堆排序就是在选择排序的思想上优化而来)

堆排序

一、前置知识:什么是堆

堆本质是一棵满足特殊性质的完全二叉树,并且我们通常用数组来存储它,不需要指针。

1. 完全二叉树的数组下标映射

对于长度为 n 的数组,按下标 0 开始存储,对应二叉树的父子节点下标关系是核心公式:

  • 节点 i左孩子下标:2*i + 1
  • 节点 i右孩子下标:2*i + 2
  • 节点 i父节点下标:(i - 1) / 2(整数除法向下取整)
  • 最后一个非叶子节点下标:n/2 - 1(叶子节点天然满足堆性质,无需调整)

2. 大顶堆 vs 小顶堆

  • 大顶堆:每个节点的值都 ≥ 左右孩子节点的值,堆顶(根节点)是整个堆的最大值
  • 小顶堆:每个节点的值都 ≤ 左右孩子节点的值,堆顶是整个堆的最小值

堆排序做升序排序用大顶堆,做降序排序用小顶堆,我们下面以升序 + 大顶堆为例讲解。

二、核心思想

堆排序的逻辑可以拆成两大步:

  1. 建堆:把无序数组构建成一个大顶堆,此时堆顶就是全局最大值。
  2. 循环交换 + 调整
    • 把堆顶最大值和堆的最后一个元素交换,最大值就放到了数组末尾(进入已排序区间)
    • 把剩下的元素重新调整为大顶堆,得到新的堆顶最大值
    • 重复交换 + 调整,直到堆的大小缩小为 1,整个数组完全有序

本质:用堆结构高效 “选出” 当前最大值,替代了选择排序里遍历全数组找最大值的过程。

三、两个核心操作

堆排序的核心是「下沉调整堆」,建堆本质就是从下往上对每个非叶子节点做一次下沉调整。

1. 下沉调整(堆化)

作用:给定一个根节点下标,假设它的左右子树已经是大顶堆,把它向下调整到正确位置,使整个子树成为大顶堆。 步骤:

  1. 找到当前节点、左孩子、右孩子三者中的最大值下标
  2. 如果当前节点就是最大值,说明已经符合堆性质,结束
  3. 如果孩子节点更大,就和当前节点交换
  4. 交换后继续向下检查被交换的孩子节点,重复上述过程,直到满足堆性质或到达叶子节点

2. 构建初始大顶堆

最后一个非叶子节点开始,从后往前、从下往上,对每个非叶子节点执行一次下沉调整,最终整个数组就变成了大顶堆。

四、完整执行步骤示例

以数组 {5, 2, 4, 6, 1, 3}(长度 n=6)为例,完整走一遍流程。

阶段 1:构建初始大顶堆

最后一个非叶子节点下标 = 6/2 - 1 = 2

  1. 调整下标 2(元素 4):只有左孩子下标 5(元素 3),4>3,无需交换。
  2. 调整下标 1(元素 2):左孩子下标 3(6)、右孩子下标 4(1),最大值是 6;2<6,交换两者。 数组变为:{5, 6, 4, 2, 1, 3}
  3. 调整下标 0(元素 5):左孩子下标 1(6)、右孩子下标 2(4),最大值是 6;5<6,交换两者。 数组变为:{6, 5, 4, 2, 1, 3} 此时初始大顶堆构建完成,堆顶 6 是全局最大值。

阶段 2:循环交换 + 调整

共执行 n-1 = 5 轮,每轮把当前最大值放到末尾。

  1. 第 1 轮:堆顶 6 和末尾元素 3(下标 5)交换 → {3, 5, 4, 2, 1, 6} 已排序区间:[6],堆大小缩小为 5,对下标 0 做下沉调整,调整后堆:{5, 2, 4, 3, 1, 6}

  2. 第 2 轮:堆顶 5 和堆末尾元素 1(下标 4)交换 → {1, 2, 4, 3, 5, 6} 已排序区间:[5, 6],堆大小缩小为 4,调整后堆:{4, 3, 1, 2, 5, 6}

  3. 第 3 轮:堆顶 4 和堆末尾元素 2(下标 3)交换 → {2, 3, 1, 4, 5, 6} 已排序区间:[4, 5, 6],堆大小缩小为 3,调整后堆:{3, 2, 1, 4, 5, 6}

  4. 第 4 轮:堆顶 3 和堆末尾元素 1(下标 2)交换 → {1, 2, 3, 4, 5, 6} 已排序区间:[3, 4, 5, 6],堆大小缩小为 2,调整后堆:{2, 1, 3, 4, 5, 6}

  5. 第 5 轮:堆顶 2 和堆末尾元素 1(下标 1)交换 → {1, 2, 3, 4, 5, 6} 堆大小缩小为 1,排序完成。

五、C 语言代码实现

#include <stdio.h>

// 下沉调整:将以root为根的子树调整为大顶堆
// n是当前堆的大小,root是根节点下标
void adjustHeap(int arr[], int n, int root) 
{
    int largest = root;          // 假设根节点是最大值
    int left = 2 * root + 1;     // 左孩子下标
    int right = 2 * root + 2;    // 右孩子下标

    // 找出根、左孩子、右孩子三者中的最大值下标
    if (left < n && arr[left] > arr[largest]) 
    {
        largest = left;
    }
    if (right < n && arr[right] > arr[largest]) 
    {
        largest = right;
    }

    // 如果最大值不是根节点,就交换,然后继续下沉调整被交换的子树
    if (largest != root) 
    {
        int temp = arr[root];
        arr[root] = arr[largest];
        arr[largest] = temp;

        // 递归调整被影响的子树
        adjustHeap(arr, n, largest);
    }
}

// 堆排序主函数
void heapSort(int arr[], int n) 
{
    // 1. 构建初始大顶堆:从最后一个非叶子节点从后往前调整
    for (int i = n / 2 - 1; i >= 0; i--) 
    {
        adjustHeap(arr, n, i);
    }

    // 2. 循环交换堆顶与末尾元素,再调整堆
    for (int i = n - 1; i > 0; i--) 
    {
        // 堆顶(最大值)与当前堆的最后一个元素交换
        int temp = arr[0];
        arr[0] = arr[i];
        arr[i] = temp;

        // 对剩下的i个元素,重新从根节点调整为大顶堆
        adjustHeap(arr, i, 0);
    }
}

// 测试用例
int main() {
    int arr[] = {5, 2, 4, 6, 1, 3};
    int len = sizeof(arr) / sizeof(arr[0]);

    printf("排序前:");
    for (int i = 0; i < len; i++) 
    {
    printf("%d ", arr[i]);
    }
    heapSort(arr, len);

    printf("\n排序后:");
    for (int i = 0; i < len; i++) 
    {
    printf("%d ", arr[i]);
    }
    return 0;
}

六、复杂度与核心特性

维度结论说明
最好时间复杂度O(nlogn)无论数组是否有序,都需要完整建堆 + 调整
最坏时间复杂度O(nlogn)性能非常稳定,没有最坏退化
平均时间复杂度O(nlogn)
空间复杂度O(1)原地排序,仅使用少量临时变量(递归版会有函数调用栈 O (logn),迭代版为纯 O (1))
稳定性不稳定排序堆顶与末尾元素的交换会打乱相等元素的相对顺序

补充:建堆的时间复杂度是 O (n),不是 O (nlogn),因为越上层的节点调整深度越小,总和收敛为线性。

七、适用场景与对比

  1. 适用场景

    • 数据量较大的排序场景
    • 对排序时间稳定性要求高的场景(不会像快排那样最坏退化到 O (n²))
    • 经典延伸:Top K 问题(找出数组中第 K 大的数)、优先队列实现
  2. 和其他 O (nlogn) 排序对比:

    • 比归并排序:空间复杂度更优(O (1) vs O (n)),但不稳定
    • 比快速排序:最坏时间更优,但平均速度略慢于快排,且不稳定

冒泡排序

一、核心思想

冒泡排序(Bubble Sort)是最经典的交换类入门排序,核心逻辑是通过相邻元素的两两比较与交换,让较大的元素像水中的气泡一样,逐步 “冒” 到数组的末尾。

我们同样可以把数组拆成两个区间:

  • 未排序区间:数组前部,尚未排好的部分
  • 已排序区间:数组尾部,每一轮都会新增一个当前最大值

每一轮的操作:遍历整个未排序区间,相邻元素两两比较,左大右小就交换;一轮走完后,未排序区间里的最大值就会被交换到未排序区间的末尾,归入已排序区间。

生活类比:就像排队从左到右挨个比身高,高的人往右换位置,一轮下来最高的人就站到了最右边。

二、完整执行步骤

继续用数组 {5, 2, 4, 6, 1, 3}(长度 n=6)演示,共需 n-1 轮。

第 1 轮(确定最大值 6)

遍历下标 0~4,两两比较交换:

  • 5 > 2 → 交换 → [2, 5, 4, 6, 1, 3]
  • 5 > 4 → 交换 → [2, 4, 5, 6, 1, 3]
  • 5 < 6 → 不交换
  • 6 > 1 → 交换 → [2, 4, 5, 1, 6, 3]
  • 6 > 3 → 交换 → [2, 4, 5, 1, 3, 6] 本轮结束,最大值 6 到达末尾,已排序区间:[6]

第 2 轮(确定次大值 5)

遍历下标 0~3,无需再比较末尾已排序元素:

  • 2 < 4 → 不交换
  • 4 < 5 → 不交换
  • 5 > 1 → 交换 → [2, 4, 1, 5, 3, 6]
  • 5 > 3 → 交换 → [2, 4, 1, 3, 5, 6] 本轮结束,已排序区间:[5, 6]

第 3 轮(确定值 4)

遍历下标 0~2:

  • 2 < 4 → 不交换
  • 4 > 1 → 交换 → [2, 1, 4, 3, 5, 6]
  • 4 > 3 → 交换 → [2, 1, 3, 4, 5, 6] 本轮结束,已排序区间:[4, 5, 6]

第 4 轮(确定值 3)

遍历下标 0~1:

  • 2 > 1 → 交换 → [1, 2, 3, 4, 5, 6]
  • 2 < 3 → 不交换 本轮结束,已排序区间:[3, 4, 5, 6]

第 5 轮(最后校验)

仅剩余 1 个未排序元素,无需交换,排序完成。

三、C 语言代码实现

1. 基础版

完全对应上述步骤,最直观的原生实现:

#include <stdio.h>

// 基础版冒泡排序
void bubbleSort(int arr[], int n) 
{
    int i, j, temp;
    
    // 外层:共执行n-1轮
    for (i = 0; i < n - 1; i++) 
    {
        // 内层:遍历未排序区间,两两相邻比较
        // 末尾i个元素已排好,无需再比较
        for (j = 0; j < n - 1 - i; j++) 
       {
            // 左边大于右边则交换
            if (arr[j] > arr[j + 1]) 
            {
                temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

2. 优化版(提前终止)

这是冒泡排序最经典的优化:如果某一轮遍历中一次交换都没有发生,说明数组已经完全有序,可以直接结束排序,不用跑完剩余轮次。

#include <stdio.h>

// 优化版冒泡排序:增加有序标志位
void bubbleSort(int arr[], int n) 
{
    int i, j, temp;
    
    for (i = 0; i < n - 1; i++) 
    {
        int swapped = 0;  // 标记本轮是否发生过交换
        
        for (j = 0; j < n - 1 - i; j++) 
        {
            if (arr[j] > arr[j + 1]) 
            {
                temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
                swapped = 1;  // 记录发生了交换
            }
        }
        
        // 本轮无交换,数组已完全有序,直接退出
        if (swapped == 0) 
        {
            break;
        }
    }
}

// 测试用例
int main() {
    int arr[] = {5, 2, 4, 6, 1, 3};
    int len = sizeof(arr) / sizeof(arr[0]);
    
    printf("排序前:");
    for (int i = 0; i < len; i++) 
    {
    printf("%d ", arr[i]);
    }
    bubbleSort(arr, len);
    
    printf("\n排序后:");
    for (int i = 0; i < len; i++) 
    {
    printf("%d ", arr[i]);
    }
    return 0;
}

四、复杂度与核心特性

维度结论说明
最好时间复杂度O(n)优化版下,数组完全有序时,第一轮遍历无交换,直接结束
最坏时间复杂度O(n²)数组完全逆序时,每一轮都要完整比较交换
平均时间复杂度O(n²)
空间复杂度O(1)原地排序,仅使用少量临时变量
稳定性稳定排序仅相邻元素交换,相等元素不交换,相对顺序保持不变

稳定举例:数组 {3, 2, 3},冒泡排序中两个 3 的前后顺序始终保持不变。

五、三种基础 O (n²) 排序对比

对比项直接插入排序选择排序冒泡排序
核心操作元素后移 + 插入找最小值 + 单次交换两两比较 + 频繁相邻交换
元素交换次数中等最少(每轮最多 1 次)最多
最好时间复杂度O(n)O(n²)O (n)(优化后)
稳定性稳定不稳定稳定
实现难度中等中等最简单

六、适用场景


排序算法入门教学,逻辑最直观易懂
数据量很小的简单排序场景
对排序稳定性有要求、且追求实现成本极低的场景
实际工程中很少直接使用,整体性能不如插入排序,更多作为算法基础

快速排序

Lomuto 分区法(单向快慢指针法)

一、核心思想

快速排序(Quick Sort)是交换排序的分治优化版,也是目前工业界最常用的排序算法之一,核心是「基准划分 + 递归分治」。

和冒泡排序的本质区别:冒泡每轮 O (n) 操作只能确定 1 个最大值的最终位置;快排一次 O (n) 的分区操作,不仅能确定基准元素的最终有序位置,还能把数组拆成 “全小于基准、全大于基准” 两个独立子数组,递归处理后整体有序,效率从 O (n²) 提升到平均 O (nlogn)。

核心逻辑三步走:

  1. 选基准:从待排序区间选一个元素作为基准值(pivot)
  2. 分区:重排数组,所有小于基准的元素移到基准左边,所有大于基准的移到右边,基准最终落在它的固定有序位置
  3. 递归:对基准左右两个子区间,重复 “选基准 - 分区”,直到子区间长度≤1(天然有序)

生活类比:班级排队选一个同学当身高标杆,比他矮的全站左边,比他高的全站右边;再给左边、右边队伍各自选标杆拆分,直到每个人的位置都固定。

二、核心操作:分区(Partition)

分区是快排的灵魂,我们用最易理解的单向扫描分区法(Lomuto 方案)讲解:

  1. 选待排序区间的最后一个元素作为基准值
  2. 维护一个「小于基准的区域右边界」指针 i,初始在区间左边界左侧
  3. 用指针 j 从左到右遍历区间:
    • 若当前元素 ≤ 基准值,就把它换到「小于区域」的末尾,i 右移一位
    • 若当前元素 > 基准值,跳过不处理
  4. 遍历结束后,把基准元素换到「小于区域」的下一位,这就是基准的最终位置

三、完整执行示例

沿用熟悉的数组 {5, 2, 4, 6, 1, 3}(长度 n=6),完整走一遍流程。

第 1 次分区:整个数组 [0,5]
  • 基准值 pivot = 3(下标 5)
  • 初始化 i = -1(小于区域右边界)
  • j 从 0 到 4 遍历:
    1. j=0,元素 5 > 3 → 跳过
    2. j=1,元素 2 ≤ 3 → i=0,交换下标 0 和 1 → 数组变为 {2, 5, 4, 6, 1, 3}
    3. j=2,元素 4 > 3 → 跳过
    4. j=3,元素 6 > 3 → 跳过
    5. j=4,元素 1 ≤ 3 → i=1,交换下标 1 和 4 → 数组变为 {2, 1, 4, 6, 5, 3}
  • 遍历结束,交换下标 i+1=2 和基准下标 5 → 数组变为 {2, 1, 3, 6, 5, 4}
  • 基准 3 固定在下标 2,拆分出左子区间 [0,1]、右子区间 [3,5]
递归处理左子区间 [0,1]:元素 {2,1}
  • 基准值 pivot = 1(下标 1)
  • i = -1,j=0 遍历:元素 2 > 1 → 跳过
  • 交换下标 0 和 1 → 数组变为 {1, 2, 3, 6, 5, 4}
  • 基准 2 固定在下标 1,左右子区间长度均≤1,左半部分全部有序
递归处理右子区间 [3,5]:元素 {6,5,4}
  • 基准值 pivot = 4(下标 5)
  • i = 2,j 从 3 到 4 遍历:6>4、5>4 → 全部跳过
  • 交换下标 3 和 5 → 数组变为 {1, 2, 3, 4, 5, 6}
  • 基准 4 固定在下标 3,右子区间 [4,5] 继续递归,最终全部有序

最终排序结果:{1, 2, 3, 4, 5, 6}

四、C 语言代码实现

基础递归版(Lomuto 分区)

#include <stdio.h>

// 分区函数:返回基准元素的最终下标
// arr为数组,low是区间左边界,high是区间右边界
int partition(int arr[], int low, int high) 
{
    int pivot = arr[high];   // 选区间最后一个元素作为基准
    int i = low - 1;         // 小于基准区域的右边界
    int temp;

    // 遍历区间内除基准外的所有元素
    for (int j = low; j < high; j++) 
    {
        // 当前元素小于等于基准,归入小于区域
        if (arr[j] <= pivot) 
        {
            i++;
            // 交换到小于区域的末尾
            temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
        }
    }

    // 把基准元素放到小于区域的下一位,即最终正确位置
    temp = arr[i + 1];
    arr[i + 1] = arr[high];
    arr[high] = temp;

    return i + 1; // 返回基准的最终下标
}

// 快速排序递归主函数
void quickSort(int arr[], int low, int high) 
{
    // 递归终止条件:区间长度<=1
    if (low < high) 
    {
        // 1. 分区,得到基准下标
        int pivotIndex = partition(arr, low, high);
        // 2. 递归排序左半部分
        quickSort(arr, low, pivotIndex - 1);
        // 3. 递归排序右半部分
        quickSort(arr, pivotIndex + 1, high);
    }
}

// 测试用例
int main() {
    int arr[] = {5, 2, 4, 6, 1, 3};
    int len = sizeof(arr) / sizeof(arr[0]);

    printf("排序前:");
    for (int i = 0; i < len; i++) 
    {
        printf("%d ", arr[i]);
    }
    quickSort(arr, 0, len - 1);

    printf("\n排序后:");
    for (int i = 0; i < len; i++)     
    {
        printf("%d ", arr[i]);
    }
    return 0;
}

Hoare 霍尔分区法(左右双指针法)

这是快速排序发明者 C.A.R. Hoare 的原始实现,也是工业界最常用的版本,交换次数更少、执行效率更高

1. 核心思想

选取一个基准值后,用两个指针从两端向中间逼近:

  • 右指针从右往左走,找第一个小于基准的元素
  • 左指针从左往右走,找第一个大于基准的元素
  • 找到后直接交换这一对逆序元素
  • 两指针相遇时,把基准值交换到相遇位置,即为基准的最终有序位置

2. 关键规则(极易出错点)

如果选左端点作为基准,必须让右指针先移动。 原因:最终要把基准和相遇点交换,必须保证相遇点的值 ≤ 基准值;右指针先停下的位置一定满足 ≤ 基准,最终相遇位置也必然符合要求。

3. 完整执行示例

以数组 {5, 2, 4, 6, 1, 3},区间 [0,5] 为例:

  1. 选基准 pivot = arr[0] = 5,左指针 left=0,右指针 right=5
  2. 右指针左移:找小于 5 的元素,arr[5]=3 符合,停下
  3. 左指针右移:找大于 5 的元素,arr[3]=6 符合,停下
  4. 交换 arr[3]arr[5] → 数组变为 {5, 2, 4, 3, 1, 6}
  5. 继续移动:右指针左移到 arr[4]=1 停下;左指针右移到下标 4 时与右指针相遇
  6. 交换基准位置 arr[0] 和相遇位置 arr[4] → 数组变为 {1, 2, 4, 3, 5, 6}
  7. 基准 5 固定在下标 4,拆分出左区间 [0,3]、右区间 [5,5]
// Hoare 左右双指针分区法,返回基准最终下标
int hoarePartition(int arr[], int low, int high) 
{
    int pivot = arr[low];   // 选区间左端点作为基准
    int left = low;
    int right = high;

    while (left < right) 
        {
        // 1. 右指针先动:从右往左找第一个小于基准的元素
        while (left < right && arr[right] >= pivot) {
            right--;
        }
        // 2. 左指针后动:从左往右找第一个大于基准的元素
        while (left < right && arr[left] <= pivot) 
        {
            left++;
        }
        // 3. 两指针未相遇,交换逆序元素
        if (left < right) {
            int temp = arr[left];
            arr[left] = arr[right];
            arr[right] = temp;
        }
    }
    // 4. 两指针相遇,将基准值放到相遇的最终位置
    arr[low] = arr[left];
    arr[left] = pivot;
    return left;
}

// 快速排序主函数(和之前完全通用,仅替换分区函数即可)
void quickSort(int arr[], int low, int high) 
{
    if (low < high) 
    {
        int pivotIndex = hoarePartition(arr, low, high);
        quickSort(arr, low, pivotIndex - 1);  // 递归左区间
        quickSort(arr, pivotIndex + 1, high); // 递归右区间
    }
}

挖坑法

挖坑法是 Hoare 法的另一种直观表达,全程没有交换操作,只有赋值填坑,新手最容易理解和写对。

1. 核心思想

把基准元素先 “挖出来” 保存,原位置形成一个空坑;然后左右指针交替找元素填坑,每次填坑会产生新的坑位;两指针相遇时,最后一个坑位就是基准的最终位置,把基准填回去即可。

2. 完整执行示例

同样以数组 {5, 2, 4, 6, 1, 3},区间 [0,5] 为例:

  1. 挖出基准 pivot = arr[0] = 5,下标 0 形成第一个坑
  2. 右指针左移找小于 5 的元素:找到 arr[5]=3,把 3 填到下标 0 的坑里 → 下标 5 变成新坑
  3. 左指针右移找大于 5 的元素:找到 arr[3]=6,把 6 填到下标 5 的坑里 → 下标 3 变成新坑
  4. 右指针继续左移:找到 arr[4]=1,把 1 填到下标 3 的坑里 → 下标 4 变成新坑
  5. 左指针继续右移,到下标 4 时与右指针相遇,循环结束
  6. 把基准 5 填到下标 4 的坑里 → 数组变为 {3, 2, 4, 1, 5, 6},基准固定在下标 4

注:分区只保证基准位置正确、左右大小关系成立,不保证左右内部顺序,因此和 Hoare 法的中间结果不同是正常现象,不影响最终排序结果。

// 挖坑法分区,返回基准最终下标
int pitPartition(int arr[], int low, int high) 
{
    int pivot = arr[low];   // 挖出基准值,low位置形成第一个坑
    int left = low;
    int right = high;

    while (left < right) 
{
        // 1. 从右往左找小于基准的元素,填左边的坑
        while (left < right && arr[right] >= pivot) 
        {
            right--;
        }
        if (left < right) {
            arr[left] = arr[right]; // 填左坑,right位置变为新坑
            left++;
        }

        // 2. 从左往右找大于基准的元素,填右边的坑
        while (left < right && arr[left] <= pivot) 
        {
            left++;
        }
        if (left < right) {
            arr[right] = arr[left]; // 填右坑,left位置变为新坑
            right--;
        }
    }
    // 3. 两指针相遇,把基准值填到最后的坑位
    arr[left] = pivot;
    return left;
}

// 快速排序主函数(同样完全通用)
void quickSort(int arr[], int low, int high) 
{
    if (low < high) 
    {
        int pivotIndex = pitPartition(arr, low, high);
        quickSort(arr, low, pivotIndex - 1);
        quickSort(arr, pivotIndex + 1, high);
    }
}

三种分区方法对比总结

分区方法核心思路交换 / 赋值代码难度运行效率适用场景
Lomuto 单向快慢指针维护小于区域,逐个向后扩展交换次数最多最低,最易写对最低教学入门、快速实现
Hoare 左右双指针两端向中间逼近,成对交换逆序元素交换次数最少中等,指针顺序易出错最高工业级实现、追求性能
挖坑法基准挖空,交替填坑无交换,仅赋值中等,逻辑最直观和 Hoare 相当新手理解、面试手写

五、复杂度与核心特性

维度结论说明
平均时间复杂度O(nlogn)分区均匀时,递归深度 logn,每层总操作量 O (n)
最好时间复杂度O(nlogn)每次分区刚好把数组平分
最坏时间复杂度O(n²)数组完全有序 / 逆序,且选首尾元素为基准时,分区极不均匀
平均空间复杂度O(logn)递归调用栈的深度,平均为 logn
最坏空间复杂度O(n)最坏分区下,递归深度为 n
稳定性不稳定排序分区交换过程会打乱相等元素的相对顺序

关键说明:实际工程中快排几乎不会触发最坏情况,通过优化基准选择可以彻底避免 O (n²) 退化,因此它的实际运行效率通常优于堆排序和归并排序

六、常见优化方向

  1. 基准选择优化
    • 三数取中法:取区间首、中、尾三个元素的中位数作为基准,避免有序数组下的最坏情况
    • 随机基准法:随机选一个元素作为基准,概率上避免最坏情况
  2. 小区间优化 当子数组长度小于阈值(通常 10~20)时,改用直接插入排序,减少递归开销
  3. 三路快排 把数组分成「小于、等于、大于」三部分,处理存在大量重复元素的场景,效率大幅提升
  4. 尾递归优化 优先递归较短的子区间,降低递归栈深度,最坏空间复杂度可优化到 O (logn)

七、适用场景与对比


适用场景

  1. 通用大规模数据排序,是绝大多数语言内置排序函数的底层实现(如 C 标准库的 qsort)
  2. 对平均运行速度要求高,可接受不稳定排序的场景

与同复杂度排序对比

算法时间复杂度(平均 / 最坏)空间复杂度稳定性平均速度
快速排序O(nlogn) / O(n²)O(logn)不稳定最快
堆排序O(nlogn) / O(nlogn)O(1)不稳定中等
归并排序O(nlogn) / O(nlogn)O(n)稳定较快

归并排序

一、核心思想

归并排序(Merge Sort)是分治算法的经典实现,核心思路是「先拆分、后合并」:

  1. 分(拆分):将数组不断对半拆分,直到每个子数组仅含 1 个元素(单个元素天然有序)。
  2. 治(合并):将两个有序的小数组,按顺序合并成一个更大的有序数组;逐层向上合并,最终得到完整的有序数组。

和快速排序的本质区别:

  • 快排:先分区定基准,再递归处理子数组,排序工作在递归前完成
  • 归并:先递归拆分子数组,再合并有序子数组,排序工作在递归后完成

生活类比:把一摞试卷先分成两半,再继续拆到每份只有 1 张;然后把两份排好序的试卷按顺序合成一摞,不断合并直到还原整摞。

二、核心操作:合并两个有序数组

合并是归并排序的灵魂:给定两个已经有序的子数组,把它们合并成一个有序数组。 实现逻辑:

  1. 申请一块临时数组,大小等于两个子数组长度之和
  2. 用三个指针分别指向左子数组开头、右子数组开头、临时数组开头
  3. 每次比较左右指针指向的元素,把较小的元素放入临时数组,对应指针后移
  4. 当其中一个子数组遍历完,把另一个子数组剩余的元素直接全部拷贝到临时数组
  5. 最后把临时数组的结果拷贝回原数组的对应位置

三、完整执行示例

以数组 {5, 2, 4, 6, 1, 3}(长度 n=6)为例,完整走一遍拆分 + 合并流程。

阶段 1:拆分(自顶向下)

不断对半拆分,直到子数组长度为 1:

原数组:[5, 2, 4, 6, 1, 3]
第1次拆分:左 [5, 2, 4]  |  右 [6, 1, 3]
第2次拆分:左 [5,2]、[4]  |  右 [6,1]、[3]
第3次拆分:[5]、[2]、[4]  |  [6]、[1]、[3]

拆分结束,所有子数组均为单个元素,天然有序。

阶段 2:合并(自底向上)

两两合并有序子数组,逐层向上:

  1. 合并 [5][2][2, 5];合并 [6][1][1, 6]
  2. 合并 [2, 5][4][2, 4, 5];合并 [1, 6][3][1, 3, 6]
  3. 合并 [2, 4, 5][1, 3, 6][1, 2, 3, 4, 5, 6]

最终得到完整有序数组。

四、代码实现

递归版・自顶向下

这是最经典、最易理解的实现,分为 merge 合并函数和 mergeSort 递归主函数两部分。

#include <stdio.h>
#include <stdlib.h>

// 合并函数:将arr[left..mid] 和 arr[mid+1..right] 两个有序子数组合并
void merge(int arr[], int left, int mid, int right, int temp[]) {
    int i = left;       // 左子数组起始指针
    int j = mid + 1;    // 右子数组起始指针
    int k = left;       // 临时数组指针

    // 1. 同时遍历两个子数组,取较小的放入临时数组
    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) {
            temp[k++] = arr[i++];  // 优先取左边,保证稳定性
        } else {
            temp[k++] = arr[j++];
        }
    }

    // 2. 处理左子数组剩余元素
    while (i <= mid) {
        temp[k++] = arr[i++];
    }

    // 3. 处理右子数组剩余元素
    while (j <= right) {
        temp[k++] = arr[j++];
    }

    // 4. 将临时数组的结果拷贝回原数组对应区间
    for (i = left; i <= right; i++) {
        arr[i] = temp[i];
    }
}

// 归并排序递归主函数
void mergeSortRecursive(int arr[], int left, int right, int temp[]) {
    // 递归终止条件:子数组长度<=1
    if (left < right) {
        int mid = left + (right - left) / 2;  // 对半拆分,避免溢出
        
        // 先递归拆分排序左右子数组
        mergeSortRecursive(arr, left, mid, temp);
        mergeSortRecursive(arr, mid + 1, right, temp);
        
        // 再合并两个有序子数组
        merge(arr, left, mid, right, temp);
    }
}

// 封装后的对外接口:统一申请临时数组,避免递归内频繁申请释放
void mergeSort(int arr[], int n) {
    int *temp = (int *)malloc(n * sizeof(int));  // 一次性申请临时数组
    mergeSortRecursive(arr, 0, n - 1, temp);
    free(temp);
}

// 测试用例
int main() {
    int arr[] = {5, 2, 4, 6, 1, 3};
    int len = sizeof(arr) / sizeof(arr[0]);

    printf("排序前:");
    for (int i = 0; i < len; i++) printf("%d ", arr[i]);

    mergeSort(arr, len);

    printf("\n排序后:");
    for (int i = 0; i < len; i++) printf("%d ", arr[i]);
    return 0;
}

自底向上归并排序(迭代版)

递归版是「把大数组拆到最小,再逐层合并」,迭代版则反过来:直接从长度为 1 的子数组出发,两两合并,步长不断翻倍,全程没有递归调用,也没有递归栈开销。

1. 核心逻辑
  • 初始步长 size = 1,每个元素自身就是一个有序子数组
  • 每一轮按步长将数组分组,每组内左右两段有序子数组合并
  • 每轮结束后 size *= 2,直到步长 ≥ 数组长度,排序完成
2. 完整 C 语言代码

合并函数 merge 和递归版完全通用,仅主逻辑改为迭代:

#include <stdio.h>
#include <stdlib.h>

// 合并函数:与递归版完全一致
void merge(int arr[], int left, int mid, int right, int temp[]) 
{
    int i = left;
    int j = mid + 1;
    int k = left;

    while (i <= mid && j <= right) 
    {
        if (arr[i] <= arr[j]) 
        {
            temp[k++] = arr[i++];
        } else 
        {
            temp[k++] = arr[j++];
        }
    }
    while (i <= mid) temp[k++] = arr[i++];
    while (j <= right) temp[k++] = arr[j++];

    for (i = left; i <= right; i++) 
    {
        arr[i] = temp[i];
    }
}

// 自底向上迭代版归并排序
void mergeSortBottomUp(int arr[], int n) 
{
    int *temp = (int *)malloc(n * sizeof(int));
    int size;  // 当前每个有序子数组的长度

    // 步长从1开始,每次翻倍
    for (size = 1; size < n; size *= 2) 
    {
        // 按当前步长遍历所有分组
        for (int left = 0; left < n; left += 2 * size) 
        {
            int mid = left + size - 1;
            int right = left + 2 * size - 1;

            // 右边界超出数组长度时修正
            if (right >= n) right = n - 1;
            // 左子数组已到末尾,无需合并
            if (mid >= n - 1) break;

            merge(arr, left, mid, right, temp);
        }
    }
    free(temp);
}

// 测试用例
int main() {
    int arr[] = {5, 2, 4, 6, 1, 3};
    int len = sizeof(arr) / sizeof(arr[0]);

    printf("排序前:");
    for (int i = 0; i < len; i++) 
    {
        printf("%d ", arr[i]);
    }
    mergeSortBottomUp(arr, len);

    printf("\n排序后:");
    for (int i = 0; i < len; i++)     
    {
        printf("%d ", arr[i]);
    }
    return 0;
}

五、复杂度与核心特性

维度结论说明
最好时间复杂度O(nlogn)无论数组是否有序,拆分合并的总工作量固定
最坏时间复杂度O(nlogn)性能极其稳定,无退化场景
平均时间复杂度O(nlogn)
空间复杂度O(n)需要额外临时数组存放合并结果,典型的空间换时间
稳定性稳定排序合并时相等元素优先取左侧子数组,相对顺序保持不变

关键补充:归并排序是所有主流 O (nlogn) 排序中,唯一天然稳定的算法。

六、适用场景与算法对比

1. 适用场景

  • 排序稳定性有要求的场景
  • 外部排序:数据量极大、无法全部装入内存时(如磁盘大文件排序),归并排序是最优选择
  • 链表排序:无需额外数组空间,仅通过修改指针即可完成合并,效率极高

2. 与同量级排序对比

算法平均时间最坏时间空间复杂度稳定性平均速度
快速排序O(nlogn)O(n²)O(logn)不稳定最快
归并排序O(nlogn)O(nlogn)O(n)稳定较快
堆排序O(nlogn)O(nlogn)O(1)不稳定中等

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值