每日算法 — 使用java实现计数排序:线性时间非比较排序与频率统计应用

算法概述

计数排序(Counting Sort)是一种非比较型的线性时间排序算法,由 Harold H. Seward 于1954年提出。与传统的基于比较的排序算法(如快速排序、归并排序)不同,计数排序的核心思想是利用待排序元素本身的值作为数组下标,通过统计每个元素出现的次数来实现排序。当待排序数据的取值范围相对集中时,计数排序可以达到 O(n + k) 的时间复杂度,其中 n 为元素个数,k 为数据取值范围的大小。

计数排序属于稳定排序,这意味着相等元素的原始相对顺序在排序后会被保留。这一特性使其成为基数排序(Radix Sort)等高级排序算法的重要子过程。本文将深入讲解计数排序的原理,并用 Java 实现基础版与稳定版两种变体,同时展示其在频率统计中的实战应用。

核心原理

计数排序的工作流程可分为三个关键步骤:

第一步:统计频率

遍历原始数组,使用一个辅助数组 count[] 记录每个数值出现的次数。count[i] 表示数值 i 在原始数组中出现了多少次。

第二步:计算前缀和

将 count[] 转换为前缀和数组。转换后,count[i] 表示小于等于 i 的元素总共有多少个。这一步是确定每个元素在最终输出数组中位置的关键。

第三步:反向填充输出数组

从原始数组的末尾开始遍历(保证稳定性),根据 count[] 数组确定每个元素在输出数组中的确切位置,将其放入对应位置后,将 count[] 中对应值减一。

为什么需要反向填充?

反向填充是保证排序稳定性的关键。假设原始数组中有两个值为 5 的元素,后出现的那个 5 在 count[] 中对应的位置更靠前。从后向前遍历,后出现的 5 会先被放置到输出数组中更靠后的位置,从而保持了原始相对顺序。

基础版计数排序 Java 实现

基础版计数排序适用于非负整数场景,直接根据元素值作为数组下标进行统计。

/**
 * 基础版计数排序实现
 * 适用于非负整数数组,不保证稳定性
 */
public class CountingSortBasic {

    /**
     * 对非负整数数组进行排序
     * @param arr 待排序数组
     * @return 排序后的新数组(原数组不变)
     */
    public static int[] sort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return arr == null ? null : arr.clone();
        }

        // 第一步:找出数组中的最大值,确定计数数组的大小
        int max = arr[0];
        for (int num : arr) {
            if (num > max) {
                max = num;
            }
        }

        // 创建计数数组,下标范围 0 ~ max
        int[] count = new int[max + 1];

        // 第二步:统计每个元素出现的频率
        for (int num : arr) {
            count[num]++;
        }

        // 第三步:根据计数数组重构排序结果
        int[] result = new int[arr.length];
        int index = 0;
        for (int i = 0; i < count.length; i++) {
            // 数值 i 出现了 count[i] 次,依次写入结果数组
            while (count[i] > 0) {
                result[index++] = i;
                count[i]--;
            }
        }

        return result;
    }

    public static void main(String[] args) {
        int[] data = {4, 2, 2, 8, 3, 3, 1, 0, 4, 2};
        System.out.println("原始数组: " + java.util.Arrays.toString(data));
        int[] sorted = sort(data);
        System.out.println("排序结果: " + java.util.Arrays.toString(sorted));
        // 输出: [0, 1, 2, 2, 2, 3, 3, 4, 4, 8]
    }
}

基础版的优点是代码简洁、空间占用相对较小;缺点是无法处理负数,且不保证排序稳定性(相同元素的原始顺序可能被改变)。

稳定版计数排序 Java 实现

稳定版计数排序通过引入偏移量处理负数,并采用反向填充策略保证稳定性。

/**
 * 稳定版计数排序实现
 * 支持负数,保证排序稳定性
 */
public class CountingSortStable {

    /**
     * 对整数数组进行稳定排序
     * @param arr 待排序数组
     * @return 排序后的新数组(原数组不变)
     */
    public static int[] sort(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return arr == null ? null : arr.clone();
        }

        // 第一步:找出最大值和最小值,确定取值范围
        int max = arr[0];
        int min = arr[0];
        for (int num : arr) {
            if (num > max) max = num;
            if (num < min) min = num;
        }

        // 计算偏移量:将所有数值映射到非负下标
        int offset = -min;
        int range = max - min + 1;

        // 创建计数数组,下标范围 0 ~ (max - min)
        int[] count = new int[range];

        // 第二步:统计频率(使用偏移后的下标)
        for (int num : arr) {
            count[num + offset]++;
        }

        // 第三步:计算前缀和,count[i] 表示小于等于该值的元素个数
        for (int i = 1; i < count.length; i++) {
            count[i] += count[i - 1];
        }

        // 第四步:反向遍历原数组,根据前缀和确定位置,填充输出数组
        int[] result = new int[arr.length];
        for (int i = arr.length - 1; i >= 0; i--) {
            int value = arr[i];
            // 该值在输出数组中的位置 = count[value + offset] - 1
            int position = count[value + offset] - 1;
            result[position] = value;
            // 位置占用后减一,保证下一个相同值放在前面
            count[value + offset]--;
        }

        return result;
    }

    public static void main(String[] args) {
        int[] data = {-3, 1, -5, 0, 2, -3, 1, 4, -1, 2};
        System.out.println("原始数组: " + java.util.Arrays.toString(data));
        int[] sorted = sort(data);
        System.out.println("稳定排序: " + java.util.Arrays.toString(sorted));
        // 输出: [-5, -3, -3, -1, 0, 1, 1, 2, 2, 4]
    }
}

稳定版通过 offset 偏移量巧妙解决了负数处理问题,同时利用前缀和 + 反向填充的经典范式,确保了相等元素的原始相对顺序不变。

频率统计实战应用

计数排序的核心数据结构——频率数组,本身就是最强大的频率统计工具。以下展示如何利用计数排序的思想,高效完成成绩分段统计、字符频率分析等常见任务。

import java.util.*;

/**
 * 基于计数排序思想的频率统计工具类
 */
public class FrequencyAnalyzer {

    /**
     * 统计整数数组中每个值的出现频率
     * @param arr 输入数组
     * @return Map<数值, 出现次数>
     */
    public static Map<Integer, Integer> frequencyMap(int[] arr) {
        Map<Integer, Integer> freq = new LinkedHashMap<>();
        if (arr == null || arr.length == 0) return freq;

        // 找出范围
        int max = arr[0], min = arr[0];
        for (int v : arr) {
            if (v > max) max = v;
            if (v < min) min = v;
        }

        int offset = -min;
        int[] count = new int[max - min + 1];

        // 统计频率
        for (int v : arr) {
            count[v + offset]++;
        }

        // 按数值大小顺序输出频率(天然有序)
        for (int i = 0; i < count.length; i++) {
            if (count[i] > 0) {
                freq.put(i - offset, count[i]);
            }
        }
        return freq;
    }

    /**
     * 学生成绩分段统计
     * 将 0~100 分的成绩按 10 分为一段进行统计
     */
    public static void gradeStatistics(int[] scores) {
        // 10 个分数段:0-9, 10-19, ..., 90-100
        int[] segments = new int[11];

        for (int score : scores) {
            int seg = Math.min(score / 10, 10);
            segments[seg]++;
        }

        System.out.println("===== 成绩分段统计 =====");
        for (int i = 0; i < 10; i++) {
            System.out.printf("%2d-%2d 分: %d 人%n", i * 10, i * 10 + 9, segments[i]);
        }
        System.out.printf("90-100分: %d 人%n", segments[10]);
    }

    /**
     * 字符频率统计(支持 ASCII 字符)
     */
    public static void charFrequency(String text) {
        int[] freq = new int[128]; // ASCII 范围

        for (char c : text.toCharArray()) {
            if (c < 128) {
                freq[c]++;
            }
        }

        System.out.println("===== 字符频率统计 =====");
        for (int i = 0; i < freq.length; i++) {
            if (freq[i] > 0) {
                System.out.printf("'%c' : %d 次%n", (char) i, freq[i]);
            }
        }
    }

    public static void main(String[] args) {
        int[] scores = {85, 92, 78, 65, 88, 92, 71, 85, 99, 55, 68, 82};
        System.out.println("频率统计: " + frequencyMap(scores));
        gradeStatistics(scores);

        String text = "Hello, Counting Sort!";
        charFrequency(text);
    }
}

复杂度分析

指标 时间复杂度 空间复杂度 说明
最优情况 O(n + k) O(k) k 为数据取值范围
最坏情况 O(n + k) O(k) 与数据分布无关
平均情况 O(n + k) O(k) 始终线性
稳定性 稳定 — 反向填充保证稳定性

其中 n 为待排序元素个数,k 为 max - min + 1(数据取值范围大小)。

适用场景

  • 数据范围集中:当 k ≈ n 或 k < n 时,计数排序效率极高。
  • 整数或离散数据:元素必须是可映射为数组下标的离散值。
  • 作为子过程:稳定版计数排序是基数排序(Radix Sort)的必备基础。
  • 频率统计需求:需要同时获取排序结果和每个值的出现次数。

不适用场景

  • 数据范围极大:若 k >> n(如稀疏的大范围数据),空间开销会过大。
  • 浮点数排序:需要通过缩放因子映射为整数,可能引入精度问题。
  • 对象排序:对象本身不能直接作为数组下标,需提取可排序的键值。

算法扩展:基数排序的基石

计数排序最重要的应用之一是作为基数排序的底层排序过程。基数排序通过从低位到高位(或反之)逐位排序,将时间复杂度优化到 O(d × (n + k)),其中 d 为数字的位数。由于每一位的排序需要保证稳定性,稳定版计数排序恰好满足这一要求。

/**
 * 基数排序简化示意:利用稳定计数排序对每一位进行排序
 */
public class RadixSortDemo {

    public static void radixSort(int[] arr) {
        if (arr == null || arr.length <= 1) return;

        // 找到最大值,确定最大位数
        int max = arr[0];
        for (int v : arr) if (v > max) max = v;

        // 从个位开始,对每一位进行稳定计数排序
        for (int exp = 1; max / exp > 0; exp *= 10) {
            countingSortByDigit(arr, exp);
        }
    }

    private static void countingSortByDigit(int[] arr, int exp) {
        int n = arr.length;
        int[] output = new int[n];
        int[] count = new int[10]; // 每一位只有 0~9

        // 统计当前位上的数字频率
        for (int value : arr) {
            int digit = (value / exp) % 10;
            count[digit]++;
        }

        // 前缀和
        for (int i = 1; i < 10; i++) {
            count[i] += count[i - 1];
        }

        // 反向填充(保证稳定性)
        for (int i = n - 1; i >= 0; i--) {
            int digit = (arr[i] / exp) % 10;
            output[count[digit] - 1] = arr[i];
            count[digit]--;
        }

        System.arraycopy(output, 0, arr, 0, n);
    }
}

总结

计数排序以其 O(n + k) 的线性时间复杂度,在排序算法家族中占据独特地位。它打破了基于比较排序的 Ω(n log n) 下界,通过空间换时间的策略,在数据范围集中时展现出无与伦比的效率。

本文实现了基础版与稳定版两种计数排序,并展示了其在频率统计中的实战价值。理解计数排序的关键在于掌握频率统计 → 前缀和 → 反向定位的三段式思维框架,这一框架不仅适用于排序本身,也是解决各类离散数据统计问题的通用利器。