数据结构:排序(上)---基础排序算法详解

个人主页:
wengqidaifeng

✨ 永远在路上,永远向前走

个人专栏:
数据结构
C语言
嵌入式小白启动!
重要OJ算法题详解
蓝桥杯备战

文章目录

    • [1. 排序的基本概念](#1. 排序的基本概念)
      • [1.1 什么是排序](#1.1 什么是排序)
      • [1.2 排序的分类](#1.2 排序的分类)
      • [1.3 排序算法的评价指标](#1.3 排序算法的评价指标)
      • [1.4 排序算法总览](#1.4 排序算法总览)
    • [2. 测试环境搭建](#2. 测试环境搭建)
    • [3. 基础排序算法详解](#3. 基础排序算法详解)
      • [3.1 直接插入排序 (Insertion Sort)](#3.1 直接插入排序 (Insertion Sort))
        • [3.1.1 基本思想](#3.1.1 基本思想)
        • [3.1.2 算法步骤](#3.1.2 算法步骤)
        • [3.1.3 代码实现](#3.1.3 代码实现)
        • [3.1.4 执行过程示例](#3.1.4 执行过程示例)
        • [3.1.5 算法特点分析](#3.1.5 算法特点分析)
      • [3.2 希尔排序 (Shell Sort)](#3.2 希尔排序 (Shell Sort))
      • [3.3 直接选择排序 (Selection Sort)](#3.3 直接选择排序 (Selection Sort))
        • [3.3.1 基本思想](#3.3.1 基本思想)
        • [3.3.2 算法步骤](#3.3.2 算法步骤)
        • [3.3.3 代码实现(优化版:同时选择最大和最小)](#3.3.3 代码实现(优化版:同时选择最大和最小))
        • [3.3.4 重要 Bug 说明](#3.3.4 重要 Bug 说明)
        • [3.3.5 算法特点分析](#3.3.5 算法特点分析)
      • [3.4 冒泡排序 (Bubble Sort)](#3.4 冒泡排序 (Bubble Sort))
        • [3.4.1 基本思想](#3.4.1 基本思想)
        • [3.4.2 算法步骤](#3.4.2 算法步骤)
        • [3.4.3 代码实现](#3.4.3 代码实现)
        • [3.4.4 执行过程示例](#3.4.4 执行过程示例)
        • [3.4.5 算法特点分析](#3.4.5 算法特点分析)
    • [4. 四种基础排序算法对比](#4. 四种基础排序算法对比)

1. 排序的基本概念

1.1 什么是排序

排序是计算机程序设计中的一种重要操作,其功能是将一个数据元素(或记录)的任意序列,重新排列成一个按关键字有序的序列。

1.2 排序的分类

  • 内部排序:数据元素全部放在内存中的排序。

    • 适用于数据量较小,能够完全加载到内存中的场景。
    • 内部排序算法是排序学习的基础和核心。
  • 外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。

    • 适用于海量数据,如大型文件、数据库记录的排序。
    • 通常需要借助外部存储器(如磁盘)进行数据交换。

1.3 排序算法的评价指标

在学习和比较排序算法时,我们通常关注以下几个维度:

评价指标 说明
时间复杂度 算法执行所需的时间,通常关注最好、最坏和平均情况
空间复杂度 算法执行所需的额外内存空间
稳定性 相同关键字的记录在排序后相对位置是否保持不变
适应性 算法是否能够利用输入数据已有的有序性来提高效率

1.4 排序算法总览

从图中可以看出,排序算法家族庞大,各有特点。本文将从最基础、最直观的排序算法开始讲解。


2. 测试环境搭建

为了更好地理解和验证各种排序算法,我们先搭建一个通用的测试框架。

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

// 交换两个整数的值
void Swap(int* a, int* b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

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

// 生成随机数组用于测试
void GenerateRandomArray(int* a, int n, int maxValue) {
    srand((unsigned)time(NULL));
    for (int i = 0; i < n; i++) {
        a[i] = rand() % maxValue;
    }
}

// 验证数组是否已排序
int IsSorted(int* a, int n) {
    for (int i = 0; i < n - 1; i++) {
        if (a[i] > a[i + 1]) {
            return 0;  // 未排序
        }
    }
    return 1;  // 已排序
}

// 插入排序测试函数
void TestInsertSort() {
    int a[] = { 2, 4, 1, 7, 8, 3, 9, 2 };
    int n = sizeof(a) / sizeof(int);
    
    printf("排序前: ");
    PrintArray(a, n);
    
    InsertSort(a, n);
    
    printf("排序后: ");
    PrintArray(a, n);
    printf("排序结果: %s\n", IsSorted(a, n) ? "正确" : "错误");
}

int main() {
    TestInsertSort();
    return 0;
}

3. 基础排序算法详解

3.1 直接插入排序 (Insertion Sort)

3.1.1 基本思想

把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。

直观理解:就像打扑克牌时整理手牌,每摸到一张新牌,就把它插入到手中已有牌的合适位置。

3.1.2 算法步骤
  1. 将第一个元素视为已排序序列
  2. 取下一个元素,在已排序序列中从后向前扫描
  3. 如果该元素(已排序)大于新元素,将该元素移到下一位置
  4. 重复步骤3,直到找到已排序元素小于或等于新元素的位置
  5. 将新元素插入该位置
  6. 重复步骤2~5
3.1.3 代码实现
cpp 复制代码
#include "stdio.h"
#include "stdlib.h"

// 插入排序
// 时间复杂度:O(N^2) 最坏情况:逆序
// 最好情况:顺序有序,O(N)
// 空间复杂度:O(1)
// 稳定性:稳定
// 有实践意义,但用得比较少
void InsertSort(int* a, int n) {
    // 外层循环:控制待插入的元素
    for (int i = 0; i < n - 1; i++) {
        int end = i;                    // 已排序序列的最后一个位置
        int tmp = a[end + 1];           // 待插入的元素(提前保存,防止被覆盖)
        
        // 内层循环:在已排序序列中寻找插入位置
        while (end >= 0) {
            if (tmp < a[end]) {
                a[end + 1] = a[end];    // 元素后移
                --end;                   // 继续向前比较
            } else {
                break;                   // 找到插入位置
            }
        }
        a[end + 1] = tmp;               // 插入元素
    }
}
3.1.4 执行过程示例

以数组 [2, 4, 1, 7, 8, 3, 9, 2] 为例,演示直接插入排序的过程:

轮次 已排序部分 待插入元素 操作过程 结果
初始 [2] 4 4>2,直接放在2后面 [2,4,1,7,8,3,9,2]
第1轮 [2,4] 1 1<4,4后移;1<2,2后移;插入1 [1,2,4,7,8,3,9,2]
第2轮 [1,2,4] 7 7>4,直接放在4后面 [1,2,4,7,8,3,9,2]
第3轮 [1,2,4,7] 8 8>7,直接放在7后面 [1,2,4,7,8,3,9,2]
第4轮 [1,2,4,7,8] 3 3<8,8后移;3<7,7后移;3<4,4后移;3>2,插入3 [1,2,3,4,7,8,9,2]
第5轮 [1,2,3,4,7,8] 9 9>8,直接放在8后面 [1,2,3,4,7,8,9,2]
第6轮 [1,2,3,4,7,8,9] 2 2<9,9后移;...;2≥2,插入2 [1,2,2,3,4,7,8,9]
3.1.5 算法特点分析

优点

  • 实现简单,代码量少
  • 对于小规模数据或基本有序的数据效率较高
  • 是稳定的排序算法
  • 可以边接收数据边排序(在线算法)

缺点

  • 平均时间复杂度为 O(N²),数据量大时效率较低
  • 大量数据移动操作

适用场景

  • 数据量较小(一般小于50个元素)
  • 数据基本有序的情况
  • 作为其他复杂排序算法的子过程(如快速排序的小区间优化)

3.2 希尔排序 (Shell Sort)

3.2.1 基本思想

希尔排序是直接插入排序的改进版本,由Donald Shell于1959年提出。它通过引入"增量"(gap)的概念,使得元素可以跨距离交换,从而解决直接插入排序每次只能移动一个位置的局限性。

核心策略

  1. 预排序:将数组分成多个子序列,对每个子序列进行插入排序,让数组接近有序
  2. 最终排序:当增量减小到1时,进行一次标准的插入排序
3.2.2 分组原理

将数组分成 gap 组,gap 即为间隔。将第 n 个数据和第 n+gap 个数据归为一组,这样从前到后不断分,最后共分成 gap 组,然后单独对每一组进行插入排序。

以下图为例,gap = 3,将数组分成了3组:

  • 第一组:索引 0, 3, 6, 9(颜色相同)
  • 第二组:索引 1, 4, 7
  • 第三组:索引 2, 5, 8

虽然这样操作一次后,数组并没有完全有序,但大的元素更快地向后移动,小的元素更快地向前移动,整体比原数组更接近有序。

3.2.3 代码实现
方式一:逐组排序(三层循环)
cpp 复制代码
// 插入排序改良 - 逐组排序版本
void ShellSort_GroupByGroup(int* a, int n) {
    int gap = 3;
    // 外层循环:控制每一组
    for (int j = 0; j < gap; j++) {
        // 中层循环:对当前组进行插入排序
        for (int i = j; i < n - gap; i += gap) {
            int end = i;
            int tmp = a[end + gap];
            
            // 内层循环:在当前组内寻找插入位置
            while (end >= 0) {
                if (tmp < a[end]) {
                    a[end + gap] = a[end];
                    end -= gap;
                } else {
                    break;
                }
            }
            a[end + gap] = tmp;
        }
    }
}
方式二:多组并排(两层循环,效率相同但代码更简洁)
cpp 复制代码
// 多组并着走,两层循环
void ShellSort_MultiGroup(int* a, int n) {
    int gap = 3;
    // 所有组一起处理
    for (int i = 0; i < n - gap; ++i) {
        int end = i;
        int tmp = a[end + gap];
        
        while (end >= 0) {
            if (tmp < a[end]) {
                a[end + gap] = a[end];
                end -= gap;
            } else {
                break;
            }
        }
        a[end + gap] = tmp;
    }
}

注意:以上两种实现方法效率之间没有区别,完成的是同一件事情。第二种写法更简洁,也是常用的写法。

3.2.4 增量序列的选择

gap 的选择对希尔排序的效率至关重要:

  • gap 越大:大的数可以更快跳到后面,小的数可以越快跳到前面,但是越不接近有序
  • gap 越小 :跳得越慢,但是越接近有序。当 gap == 1 时,就是标准的插入排序

因此,gap 既不能一直太大,也不能一直太小。最优策略是让 gap 逐渐减小,进行多轮预排序。

cpp 复制代码
// 时间复杂度:O(N^1.3) 
// 空间复杂度:O(1)
// 稳定性:不稳定(相同元素可能被分到不同组)
void ShellSort(int* a, int n) {
    int gap = n;
    while (gap > 1) {
        // gap = gap / 2;     // 一种简单的 gap 给法
        gap = gap / 3 + 1;     // 较优的增量序列,+1 保证 gap 最后一定是 1
        
        // gap > 1 时是预排序
        // gap = 1 时是插入排序
        for (int i = 0; i < n - gap; ++i) {
            int end = i;
            int tmp = a[end + gap];
            
            while (end >= 0) {
                if (tmp < a[end]) {
                    a[end + gap] = a[end];
                    end -= gap;
                } else {
                    break;
                }
            }
            a[end + gap] = tmp;
        }
    }
}
3.2.5 时间复杂度分析

希尔排序的时间复杂度分析较为复杂,与增量序列的选择密切相关。

假设 gap 按除以3递减,忽略 +1 的影响:

  • 初始 gap = n/3,每组约3个数据
  • 第一趟预排序最坏消耗:(1+2) × n/3 ≈ n
  • 第二趟:gap = n/9,每组约9个数据,但由于第一趟已经使数组部分有序,实际消耗远小于理论最坏值 (1+2+...+8) × n/9 ≈ 4n

实际上,随着排序的进行,数组越来越有序,后续每趟的消耗逐渐减小。

从图中可以看出:

  • 早期:gap 大,每组数据少,排序快
  • 中期:gap 适中,每组数据增多,但数组已部分有序
  • 后期:gap 小,数组已基本有序,插入排序效率高

总体时间复杂度 :约为 O(N¹·³)O(N¹·⁵) 之间,优于普通的 O(N²) 算法。

3.2.6 算法特点分析

优点

  • 在中等规模数据上表现良好
  • 代码实现相对简单
  • 不需要额外的辅助空间

缺点

  • 不稳定排序
  • 时间复杂度分析困难
  • 对大规模数据不如快速排序、归并排序

适用场景

  • 数据量中等(几千到几万)
  • 对稳定性没有要求
  • 嵌入式系统等内存受限环境

3.3 直接选择排序 (Selection Sort)

3.3.1 基本思想

每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。

3.3.2 算法步骤
  1. 在未排序序列中找到最小(大)元素
  2. 将其存放到已排序序列的末尾
  3. 重复步骤1~2,直到所有元素排序完毕
3.3.3 代码实现(优化版:同时选择最大和最小)
cpp 复制代码
// 时间复杂度:O(N^2),最好最坏都是
// 空间复杂度:O(1)
// 稳定性:不稳定
// 注意:该排序效率较低,主要用于教学
void SelectSort(int* a, int n) {
    int begin = 0, end = n - 1;
    
    while (begin < end) {
        int mini = begin;  // 最小值的索引
        int maxi = begin;  // 最大值的索引
        
        // 一轮遍历同时找出最大值和最小值的位置
        for (int i = begin + 1; i <= end; ++i) {
            if (a[i] > a[maxi]) {
                maxi = i;
            }
            if (a[i] < a[mini]) {
                mini = i;
            }
        }
        
        // 将最小值交换到 begin 位置
        Swap(&a[begin], &a[mini]);
        
        // 关键 Bug 修复:
        // 如果最大值原本在 begin 位置,经过上一步交换,
        // 它被换到了 mini 位置,需要更新 maxi
        if (begin == maxi) {
            maxi = mini;
        }
        
        // 将最大值交换到 end 位置
        Swap(&a[end], &a[maxi]);
        
        ++begin;
        --end;
    }
}
3.3.4 重要 Bug 说明

在同时选择最大和最小的优化版本中,存在一个容易被忽视的 Bug:

Bug 场景

beginmaxi 指向同一个位置时,执行 Swap(&a[begin], &a[mini]) 会将最大值移动到 mini 位置,而 maxi 变量仍然记录着原来的 begin 位置,导致后续交换使用了错误的索引。

示例

复制代码
初始数组: [9, 3, 5, 1, 7]
begin=0, end=4
mini=3 (值为1), maxi=0 (值为9)

执行 Swap(&a[0], &a[3]) 后:
数组变为: [1, 3, 5, 9, 7]
此时 maxi 仍然等于 0,但 a[0] 现在是 1
如果继续用 maxi=0 去和 end 交换,就会把 1 换到最后,出错!

解决方案: 添加 if (begin == maxi) maxi = mini; 来更新 maxi
3.3.5 算法特点分析

优点

  • 实现简单,逻辑直观
  • 交换次数少(最多 n-1 次)
  • 不占用额外空间

缺点

  • 无论数据是否有序,时间复杂度恒为 O(N²)
  • 是所有 O(N²) 排序算法中最慢的之一
  • 不稳定

适用场景

  • 数据量很小
  • 对交换操作有严格限制的场景
  • 教学演示

3.4 冒泡排序 (Bubble Sort)

3.4.1 基本思想

重复地走访要排序的数列,一次比较两个相邻的元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行,直到没有再需要交换的元素为止。

名字由来:因为越小(或越大)的元素会经由交换慢慢"浮"到数列的顶端,就像水中的气泡一样。

3.4.2 算法步骤
  1. 比较相邻的元素,如果逆序则交换
  2. 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对。这一轮结束后,最后的元素会是最大(小)的数
  3. 针对所有的元素重复以上的步骤,除了已排序好的最后一个
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较
3.4.3 代码实现
cpp 复制代码
// 时间复杂度:O(N^2) / 最好 O(N)(已有序的情况)
// 空间复杂度:O(1)
// 稳定性:稳定
// 适用于教学场景,真实应用中较少使用
void BubbleSort(int* a, int n) {
    // 外层循环:控制排序的轮数
    for (int j = 0; j < n; j++) {
        int flag = 0;  // 优化标志位:标记本轮是否发生交换
        
        // 内层循环:进行相邻元素的比较和交换
        // 注意:每轮比较的次数递减,因为末尾的元素已经排好
        for (int i = 1; i < n - j; i++) {
            if (a[i - 1] > a[i]) {
                Swap(&a[i - 1], &a[i]);
                flag = 1;  // 发生了交换
            }
        }
        
        // 如果一轮下来没有发生任何交换,说明数组已经有序
        if (flag == 0) {
            break;
        }
    }
}
3.4.4 执行过程示例

以数组 [5, 1, 4, 2, 8] 为例:

轮次 比较过程 本轮结果 说明
第1轮 5>1交换→1<4不换→4>2交换→4<8不换 [1,4,2,5,8] 最大值8"浮"到最后
第2轮 1<4不换→4>2交换→4<5不换 [1,2,4,5,8] 次大值5就位
第3轮 1<2不换→2<4不换 [1,2,4,5,8] 无交换,flag=0,提前结束
3.4.5 算法特点分析

优点

  • 实现极其简单
  • 稳定排序
  • 可检测输入数据是否有序(通过flag)

缺点

  • 效率低下,时间复杂度 O(N²)
  • 大量不必要的比较和交换

适用场景

  • 教学演示排序算法原理
  • 数据量极小(少于10个元素)
  • 需要稳定排序且不想写复杂代码时

4. 四种基础排序算法对比

算法 最好时间复杂度 最坏时间复杂度 平均时间复杂度 空间复杂度 稳定性
直接插入排序 O(N) O(N²) O(N²) O(1) 稳定
希尔排序 O(N) O(N²) O(N¹·³)~O(N¹·⁵) O(1) 不稳定
直接选择排序 O(N²) O(N²) O(N²) O(1) 不稳定
冒泡排序 O(N) O(N²) O(N²) O(1) 稳定

综合建议

  1. 数据量小(<50)且基本有序:使用直接插入排序
  2. 数据量中等(几千~几万):使用希尔排序
  3. 教学演示或极简代码:使用冒泡排序或选择排序
  4. 需要稳定性:在 O(N²) 算法中只能选直接插入排序或冒泡排序

(上篇完。下篇将介绍快速排序、归并排序等更高效的排序算法,敬请期待。)

相关推荐
Zlssszls2 小时前
机器人马拉松的第二年,比的是其背后的隐形赛场:具身训练工具链
算法·机器人
shylyly_2 小时前
sizeof 和 strlen的理解与区分
c语言·算法·strlen·sizeof
m0_743106462 小时前
【浙大&南洋理工最新综述】Feed-Forward 3D Scene Modeling(五)
人工智能·算法·计算机视觉·3d·几何学
Sam_Deep_Thinking11 小时前
学数据结构到底有什么用
数据结构
kobesdu11 小时前
人形机器人SLAM:技术挑战、算法综述与开源方案
算法·机器人·人形机器人
椰羊~王小美13 小时前
随机数概念及算法
算法
阿Y加油吧13 小时前
算法实战笔记:LeetCode 169 多数元素 & 75 颜色分类
笔记·算法·leetcode
不要秃头的小孩13 小时前
力扣刷题——509. 斐波那契数
python·算法·leetcode·动态规划