零基础数据结构与算法——第四章:基础算法-排序(下)

发布于:2025-07-11 ⋅ 阅读:(21) ⋅ 点赞:(0)

排序上(冒泡/选择/插入)

排序中(归并/堆排/快排)

4.1.7 计数排序(Counting Sort)

基本概念

计数排序是一种非比较排序算法,它不通过比较元素来排序,而是通过统计每个元素出现的次数来实现排序。计数排序适用于已知范围的整数排序,其时间复杂度为O(n+k),其中k是整数的范围。

计数排序的核心思想是:将输入的数据值转化为键存储在额外开辟的数组空间中,然后依次把计数大于0的元素按顺序填充到结果数组中

生活中的例子

想象一个班级的考试成绩统计:

  1. 假设成绩范围是0-100分
  2. 老师拿到一叠试卷,想按分数从低到高排序
  3. 老师可以准备101个格子(对应0-100分),每个格子放对应分数的试卷
  4. 最后从0分开始,依次取出每个格子里的试卷,就得到了排好序的结果

这就是计数排序的基本思想 - 不需要比较元素大小,只需要"数数"。

算法步骤
  1. 找出范围:确定待排序数组中的最大值和最小值
  2. 计数:统计数组中每个值出现的次数,存入计数数组
  3. 排序:根据计数数组,将元素按顺序放回原数组
图解过程

假设我们有数组:[4, 2, 2, 8, 3, 3, 1]

步骤1:找出范围

  • 最小值:1
  • 最大值:8
  • 范围:8-1+1=8(需要8个计数格子)

步骤2:计数

  • 创建计数数组count[8],初始值都是0
  • 遍历原数组,对应位置计数加1

原数组:[4, 2, 2, 8, 3, 3, 1]

计数数组(索引是原数组元素值减去最小值):

count[0] = 1 (对应值1,出现1次)
count[1] = 2 (对应值2,出现2次)
count[2] = 2 (对应值3,出现2次)
count[3] = 1 (对应值4,出现1次)
count[4] = 0 (对应值5,出现0次)
count[5] = 0 (对应值6,出现0次)
count[6] = 0 (对应值7,出现0次)
count[7] = 1 (对应值8,出现1次)

步骤3:排序

  • 遍历计数数组,按照计数将元素放回原数组

排序后数组:[1, 2, 2, 3, 3, 4, 8]

优化版本(稳定排序)

为了保证排序的稳定性(相同元素的相对位置不变),可以对计数排序进行优化:

  1. 累加计数数组,使count[i]表示小于等于i的元素个数
  2. 从后向前遍历原数组,根据计数数组确定每个元素在排序后数组中的位置
代码实现
public static void countingSort(int[] arr) {
    if (arr == null || arr.length <= 1) {
        return; // 数组为空或只有一个元素,无需排序
    }
    
    // 步骤1:找出最大值和最小值
    int max = arr[0], min = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] > max) {
            max = arr[i];
        }
        if (arr[i] < min) {
            min = arr[i];
        }
    }
    
    // 计算范围
    int range = max - min + 1;
    
    // 步骤2:创建并填充计数数组
    int[] count = new int[range];
    for (int i = 0; i < arr.length; i++) {
        count[arr[i] - min]++;
    }
    
    // 步骤3:根据计数数组排序原数组
    int index = 0;
    for (int i = 0; i < range; i++) {
        // 将计数大于0的元素依次填充到原数组
        while (count[i] > 0) {
            arr[index++] = i + min;
            count[i]--;
        }
    }
}

// 稳定版本的计数排序
public static void stableCountingSort(int[] arr) {
    if (arr == null || arr.length <= 1) {
        return;
    }
    
    // 找出最大值和最小值
    int max = arr[0], min = arr[0];
    for (int i = 1; i < arr.length; i++) {
        max = Math.max(max, arr[i]);
        min = Math.min(min, arr[i]);
    }
    
    int range = max - min + 1;
    int[] count = new int[range];
    int[] output = new int[arr.length];
    
    // 统计每个元素出现的次数
    for (int i = 0; i < arr.length; i++) {
        count[arr[i] - min]++;
    }
    
    // 累加计数数组
    for (int i = 1; i < range; i++) {
        count[i] += count[i - 1];
    }
    
    // 从后向前遍历原数组,确保稳定性
    for (int i = arr.length - 1; i >= 0; i--) {
        output[count[arr[i] - min] - 1] = arr[i];
        count[arr[i] - min]--;
    }
    
    // 将结果复制回原数组
    for (int i = 0; i < arr.length; i++) {
        arr[i] = output[i];
    }
}
性能分析
  • 时间复杂度:O(n + k),其中n是数组长度,k是整数范围(max-min+1)

    • 当k很小时,计数排序的效率非常高
    • 当k很大时,计数排序可能不如其他排序算法
  • 空间复杂度:O(k) - 需要额外的计数数组和输出数组(稳定版本)

  • 稳定性

    • 基本版本:稳定
    • 优化版本:稳定(通过从后向前遍历确保稳定性)
适用场景
  • 排序的数据是有确定范围的整数
  • 数据量大但范围小的情况(如对0-100的分数排序)
  • 对排序稳定性有要求的场景
计数排序的优缺点

优点

  • 时间复杂度为O(n+k),优于比较排序的O(n log n)
  • 排序稳定
  • 适合数据量大但范围小的情况

缺点

  • 只适用于整数排序
  • 当数据范围k很大时,空间复杂度高
  • 不适合排序范围未知或范围很大的数据

4.1.8 基数排序(Radix Sort)

基本概念

基数排序是一种非比较型整数排序算法,它的核心思想是按照数字的每一个位(个位、十位、百位…)来进行排序。基数排序不是直接比较元素的大小,而是通过分配和收集的过程来实现排序。

基数排序有两种实现方式:

  • 最低位优先(LSD - Least Significant Digit):从最低位(个位)开始,逐位向高位排序
  • 最高位优先(MSD - Most Significant Digit):从最高位开始,逐位向低位排序

通常使用LSD方式,因为它更容易实现且稳定性好。

生活中的例子

想象一下邮局分拣信件的场景:

  1. 邮局收到大量的信件,需要按照邮政编码排序
  2. 工作人员先按照邮编的最后一位数字(个位)将信件分成10堆(0-9)
  3. 然后将这10堆信件合并,保持相对顺序不变
  4. 接着按照邮编的倒数第二位(十位)再次分成10堆
  5. 重复这个过程,直到处理完邮编的所有位数
  6. 最终,所有信件就按照邮编顺序排好了

这就是基数排序的基本思想 - 逐位排序,最终得到完全有序的结果。

算法步骤
  1. 确定最大位数:找出数组中的最大值,确定它有多少位数
  2. 逐位排序:从最低位(个位)开始,对每一位进行计数排序
  3. 重复过程:对十位、百位…依次进行同样的排序,直到处理完所有位数
图解过程

假设我们有数组:[170, 45, 75, 90, 802, 24, 2, 66]

步骤1:确定最大位数

  • 最大值是802,有3位数

步骤2:按个位数排序

  • 分组:[170, 90, 802, 2] (个位是0或2),[45, 75, 24] (个位是4或5),[66] (个位是6)
  • 合并:[170, 90, 802, 2, 45, 75, 24, 66]

步骤3:按十位数排序

  • 分组:[802, 2] (十位是0),[24] (十位是2),[45] (十位是4),[66] (十位是6),[170, 75] (十位是7),[90] (十位是9)
  • 合并:[802, 2, 24, 45, 66, 170, 75, 90]

步骤4:按百位数排序

  • 分组:[2, 24, 45, 66, 75, 90] (百位是0),[170] (百位是1),[802] (百位是8)
  • 合并:[2, 24, 45, 66, 75, 90, 170, 802]

排序完成!

代码实现
public static void radixSort(int[] arr) {
    if (arr == null || arr.length <= 1) {
        return; // 数组为空或只有一个元素,无需排序
    }
    
    // 步骤1:找出最大值
    int max = arr[0];
    for (int i = 1; i < arr.length; i++) {
        max = Math.max(max, arr[i]);
    }
    
    // 步骤2:计算最大值的位数
    int maxDigit = 0;
    while (max > 0) {
        max /= 10;
        maxDigit++;
    }
    
    // 步骤3:按照每一位进行排序(从个位开始)
    for (int digit = 0; digit < maxDigit; digit++) {
        countingSortByDigit(arr, digit);
    }
}

// 对数组按照指定的位数进行计数排序
private static void countingSortByDigit(int[] arr, int digit) {
    int n = arr.length;
    int[] output = new int[n]; // 输出数组
    int[] count = new int[10]; // 计数数组(0-9十个数字)
    
    // 统计每个数字出现的次数
    for (int i = 0; i < n; i++) {
        int digitValue = getDigit(arr[i], digit);
        count[digitValue]++;
    }
    
    // 累加计数数组,计算每个数字应该放置的位置
    for (int i = 1; i < 10; i++) {
        count[i] += count[i - 1];
    }
    
    // 从后向前遍历原数组,构建输出数组(保证稳定性)
    for (int i = n - 1; i >= 0; i--) {
        int digitValue = getDigit(arr[i], digit);
        output[count[digitValue] - 1] = arr[i];
        count[digitValue]--;
    }
    
    // 将输出数组复制回原数组
    for (int i = 0; i < n; i++) {
        arr[i] = output[i];
    }
}

// 获取数字num的第digit位的值(从个位开始,个位是第0位)
private static int getDigit(int num, int digit) {
    // 使用整数除法和取模运算获取指定位的数字
    return (num / (int) Math.pow(10, digit)) % 10;
}
性能分析
  • 时间复杂度:O(d * (n + k))

    • d是最大数的位数
    • n是元素个数
    • k是每位数字的范围(十进制数是10)
    • 当d很小时,基数排序可以接近O(n)的时间复杂度
  • 空间复杂度:O(n + k)

    • 需要额外的输出数组和计数数组
  • 稳定性:稳定

    • 基数排序是稳定的排序算法,相同元素的相对位置不会改变
适用场景
  • 排序的数据是整数或可以转化为整数的数据
  • 数据位数较少,但数据量较大的情况
  • 数据取值范围很大,但是位数不多的情况
基数排序的优缺点

优点

  • 可以达到线性时间复杂度O(n),当位数d很小时
  • 排序稳定
  • 适合长度相近的数据排序(如电话号码、字符串等)

缺点

  • 只适用于可以分割出独立位的数据,如整数
  • 对于负数,需要特殊处理
  • 当数据位数很多时,可能不如比较排序算法
  • 需要额外的空间来存储中间结果
基数排序与计数排序、桶排序的关系

基数排序、计数排序和桶排序都是非比较排序算法,它们之间有密切的关系:

  • 基数排序使用计数排序作为内部排序算法(对每一位进行排序)
  • 基数排序可以看作是桶排序的一种特殊实现(每一位有10个桶)
  • 这三种排序算法都可以在特定条件下达到线性时间复杂度

4.1.9 桶排序(Bucket Sort)

基本概念

桶排序是一种分治策略的排序算法,其核心思想是将数据分散到有限数量的桶中,每个桶再单独排序。当待排序数据分布均匀时,桶排序可以达到线性时间复杂度。

桶排序的工作原理是将数据范围分成n个大小相同的区间,这些区间被称为桶。然后将数据放入对应的桶中,再对每个桶中的数据进行排序,最后将所有桶中的数据按照顺序合并起来。

生活中的例子

想象一个图书馆整理书籍的场景:

  1. 图书管理员需要整理一大堆不同类别的书籍
  2. 首先,管理员准备了几个书架(桶),分别标记为"文学"、“科学”、“历史”、"艺术"等
  3. 然后,管理员将每本书放入对应类别的书架上
  4. 接着,管理员对每个书架上的书按照书名字母顺序排序
  5. 最后,管理员按照书架的顺序(如先文学,再科学…)将所有书籍重新排列

这就是桶排序的基本思想 - 分类、局部排序、合并。

算法步骤
  1. 创建桶:根据数据范围创建n个空桶
  2. 分配数据:遍历原始数组,将每个元素放入对应的桶中
  3. 桶内排序:对每个非空桶内的元素进行排序(可以使用任何排序算法)
  4. 合并桶:按照桶的顺序,将所有桶中的元素合并成有序数组
图解过程

假设我们有数组:[0.42, 0.32, 0.33, 0.52, 0.37, 0.47, 0.51]

步骤1:创建桶

  • 创建5个桶,每个桶覆盖范围0.2(0-0.2, 0.2-0.4, 0.4-0.6, 0.6-0.8, 0.8-1.0)

步骤2:分配数据

  • 桶0(0-0.2):空
  • 桶1(0.2-0.4):[0.32, 0.33, 0.37]
  • 桶2(0.4-0.6):[0.42, 0.47, 0.52, 0.51]
  • 桶3(0.6-0.8):空
  • 桶4(0.8-1.0):空

步骤3:桶内排序

  • 桶1:[0.32, 0.33, 0.37]
  • 桶2:[0.42, 0.47, 0.51, 0.52]

步骤4:合并桶

  • 最终排序结果:[0.32, 0.33, 0.37, 0.42, 0.47, 0.51, 0.52]
代码实现
public static void bucketSort(double[] arr) {
    if (arr == null || arr.length <= 1) {
        return; // 数组为空或只有一个元素,无需排序
    }
    
    int n = arr.length;
    
    // 步骤1:创建桶(这里创建n个桶)
    List<List<Double>> buckets = new ArrayList<>(n);
    for (int i = 0; i < n; i++) {
        buckets.add(new ArrayList<>());
    }
    
    // 找出数组中的最大值和最小值
    double maxVal = arr[0];
    double minVal = arr[0];
    for (int i = 1; i < n; i++) {
        maxVal = Math.max(maxVal, arr[i]);
        minVal = Math.min(minVal, arr[i]);
    }
    
    // 计算桶的范围大小
    double range = (maxVal - minVal) / n;
    if (range == 0) range = 1; // 防止除以零错误
    
    // 步骤2:将数据分配到桶中
    for (int i = 0; i < n; i++) {
        // 计算元素应该放入哪个桶
        int bucketIndex = (int) ((arr[i] - minVal) / range);
        
        // 处理边界情况
        if (bucketIndex >= n) {
            bucketIndex = n - 1;
        }
        
        // 将元素添加到对应的桶中
        buckets.get(bucketIndex).add(arr[i]);
    }
    
    // 步骤3:对每个桶内部进行排序
    for (int i = 0; i < n; i++) {
        if (!buckets.get(i).isEmpty()) {
            Collections.sort(buckets.get(i)); // 使用Java内置排序
        }
    }
    
    // 步骤4:将桶中的元素合并回原数组
    int index = 0;
    for (int i = 0; i < n; i++) {
        for (double value : buckets.get(i)) {
            arr[index++] = value;
        }
    }
}

// 针对整数数组的桶排序
public static void bucketSortForIntegers(int[] arr, int bucketSize) {
    if (arr == null || arr.length <= 1) {
        return;
    }
    
    // 找出最大值和最小值
    int minValue = arr[0];
    int maxValue = arr[0];
    for (int i = 1; i < arr.length; i++) {
        if (arr[i] < minValue) {
            minValue = arr[i];
        } else if (arr[i] > maxValue) {
            maxValue = arr[i];
        }
    }
    
    // 计算桶的数量
    int bucketCount = (maxValue - minValue) / bucketSize + 1;
    List<List<Integer>> buckets = new ArrayList<>(bucketCount);
    for (int i = 0; i < bucketCount; i++) {
        buckets.add(new ArrayList<>());
    }
    
    // 将数据分配到桶中
    for (int i = 0; i < arr.length; i++) {
        int bucketIndex = (arr[i] - minValue) / bucketSize;
        buckets.get(bucketIndex).add(arr[i]);
    }
    
    // 对每个桶内部进行排序
    int currentIndex = 0;
    for (int i = 0; i < buckets.size(); i++) {
        Integer[] bucketArray = buckets.get(i).toArray(new Integer[0]);
        Arrays.sort(bucketArray);
        for (int j = 0; j < bucketArray.length; j++) {
            arr[currentIndex++] = bucketArray[j];
        }
    }
}
性能分析
  • 时间复杂度

    • 平均情况:O(n + k),其中k是桶的数量
    • 最坏情况:O(n²),当所有元素都被分配到同一个桶中时
    • 最好情况:O(n),当元素均匀分布在各个桶中时
  • 空间复杂度:O(n + k)

    • 需要额外的空间来存储桶
  • 稳定性:稳定

    • 如果桶内排序使用的算法是稳定的,则桶排序也是稳定的
适用场景
  • 待排序数据分布比较均匀
  • 数据量大但是数据范围有限
  • 外部排序(数据太大,无法一次性加载到内存)
桶排序的优缺点

优点

  • 当数据分布均匀时,时间复杂度接近O(n)
  • 适合外部排序(数据量大,内存有限)
  • 可以并行化处理(不同的桶可以在不同的处理器上排序)

缺点

  • 当数据分布不均匀时,效率降低
  • 需要额外的空间
  • 桶的数量和大小需要提前确定,不容易把握
桶排序的优化
  1. 动态调整桶的数量:根据数据分布特点,动态调整桶的数量和大小
  2. 结合其他排序算法:对于小数据量的桶,可以使用插入排序;对于大数据量的桶,可以使用快速排序
  3. 并行处理:不同的桶可以并行排序,提高效率

网站公告

今日签到

点亮在社区的每一天
去签到