【以无克有】排序之计数、基数与桶排序

大珠小珠落玉盘,嘈嘈切切错杂弹


前言

前几次讲解的快排,归并和堆排序都是基于比较的的排序算法,他们的时间复杂度下限是O(nlogn),而今天要讲的则是基于分类的排序算法,它们的时间复杂度又有很大的不一样

计数排序

情境导入

图书馆的烦恼:
某图书馆新到2000本图书,管理员需要按ISBN编号末三位数(范围0-999)快速排序上架。若用常见的比较排序至少需要O(nlogn)时间,而使用计数排序只需O(n)时间就能完成任务。这种「非比较排序」如何突破比较排序的效率极限?

目标呈现

核心学习目标:
▢ 理解计数排序的「非比较」特性
▢ 掌握三步骤:找范围→建桶→重排列(公式:T(n)=O(n+k))
▢ 能分析其适用场景与局限性

知识建构

  1. 分层递进:从理想到算法
    生活原型:邮局分拣信件
    步骤1:确定所有地区编码(如1-100)
    步骤2:为每个编码准备格子
    步骤3:按编码投递信件到对应格子
    步骤4:按编码顺序取出信件
    算法映射:
    原数组:[3,1,2,2] → 地区编码
    计数数组:索引1:1, 2:2, 3:1 → 格子容量
    重构数组:按索引顺序展开 → 信件收集
  2. 多模态呈现
    流程图解:
        +--------------+    +-------------------+
输入数组 | 3 1 2 2      || 统计出现次数       |
        +--------------+    +-------------------+
                            ↙
        +-----------------+    +-----------------+
计数数组 | index:0 1 2 3   || 累加计数→索引位置 |
          count:0 1 2 1   |    +-----------------+
        +-----------------+           ↓
                              +-----------------+
输出数组 | 1 2 2 3         || 按计数填充元素   |
                              +-----------------+

关键代码段解析:

void count_sort(int* arr,int n)
{
    if(n <= 1) return;

    int maxx = *max_element(arr,arr+n); // *解引用,max_element方法指出来是地址
    int* count = (int*)malloc((maxx+1)*sizeof(int));
    memset(count,0,(maxx+1)*sizeof(int)); // 初始化

    for(int i=0;i<n;++i) count[arr[i]]++;

    int index = 0;
    for(int i=0;i<=maxx;++i) while(count[i]-- > 0) arr[index++] = i;

    free(count);

}
  1. 认知支架
    类比理解:
    计数数组 → 快递分拣柜(每个柜子对应一个特定物品)
    统计过程 → 扫描快递单号自动归类
    重构过程 → 从1号柜开始依次取货

    对比传统排序:

特性计数排序快速排序
比较操作需要分区比较
时间复杂度O(n+k)O(nlogn) 平均
空间复杂度O(k)O(logn) 栈空间
适用场景整数 & 范围小通用数据

易错警示

❗ 忘记初始化计数数组 → 随机值干扰统计
❗ 元素含负数 → 索引越界(需偏移处理)

即时巩固(例题)

基础练习
手动模拟排序过程:输入数组[4,2,5,2,1],写出计数数组?
[0,1,2,0,1,1]

应用练习
采用计数排序的写法完成P1271 【深基9.例1】选举学生会

#include <bits/stdc++.h>
using namespace std;

//
void count_sort(int* arr,int n)
{
    if(n <= 1) return;

    int maxx = *max_element(arr,arr+n); // *解引用,max_element方法指出来是地址
    int* count = (int*)malloc((maxx+1)*sizeof(int));

    for(int i=0;i<n;++i) count[arr[i]]++;

    int index = 0;
    for(int i=1;i<=maxx;++i) while(count[i]-- > 0) arr[index++] = i;

    free(count);

}


int main()
{
    ios::sync_with_stdio(false);
    cin.tie(0);
    int n, m;
    cin >> n >> m;
    int* xp = (int*)malloc(m*sizeof(int));
    for (int i = 0; i < m; ++i) cin >> xp[i];
    count_sort(xp,m);
    for (int i = 0; i < m; ++i) cout << xp[i] << " ";

}

变式挑战
若元素含负数(如-3,-2),如何修改代码实现排序?

void count_sort(int* arr,int n)
{
    if(n <= 1) return;

    int maxx = *max_element(arr,arr+n); // *解引用,max_element方法指出来是地址
    int minn = *min_element(arr,arr+n);
    int l = maxx - minn; // 数据偏移映射,这样处理可以处理负数
    int* count = (int*)malloc((l+1)*sizeof(int));

    for(int i=0;i<n;++i) count[arr[i] - minn]++;
	
	//重新映射
    int index = 0;
    for(int i=minn;i<=l;++i) while(count[i]-- > 0) arr[index++] = i + minn; // 数据还原

    free(count);

}

拓展思考
当数据范围k极大时(如k=10^9),如何优化空间复杂度?

方法核心思想适用场景时间复杂度空间复杂度
桶排序将数据划分为m个区间桶数据分布均匀O(n + m)O(n + m)
基数排序按位分割做多轮计数排序固定位数的数据(如ISBN)O(d(n + k))O(n + k)
外部排序磁盘分批处理 + 多路归并数据无法全部装入内存O(n logn)O(1)

迁移应用:计算机科学中的经典应用
1. 后缀数组构建(字符串处理)应用场景:DNA序列比对、全文搜索引擎
2. 图论算法优化应用案例:Kruskal算法中的边排序
3. 数据库索引优化:B+树页分裂、列式存储
4. 硬件加速排序;GPU并行排序,将计数排序的统计阶段映射到CUDA核心并行执行
5. 实时监控系统:网络流量分析,统计特定时间窗口内的IP访问次数

重点标注

✅ 优势:线性时间复杂度,稳定排序
⚠️ 局限:仅适用于整数且范围较小的场景
🔑 关键点:计数数组索引与元素值的映射关系


桶排序

核心思想

「计数排序 + 分治思想 = 桶排序」
引子:
计数排序为每个值单独建桶(空间开销大),而桶排序将大范围数据分割为多个子区间(分治),每个子区间作为一个桶,大幅降低空间消耗。

类比:计数排序 → 为每个家庭单独配送快递(精准但低效)
桶排序 → 按小区集中配送,再分发给各户(分治优化)

关键步骤

三步逻辑链:
分桶(算法核心):映射函数通用规则:保证桶间有序性(后一桶的最小值 ≥ 前一桶的最大值);设计原则:桶数量 ≈ √n(经验值,平衡桶内排序与分桶效率);数据分布越均匀,分桶效果越好
桶内排序:可自由选择排序算法(常用插入排序,因桶内数据量小);关键点:若桶内数据完全均匀分布,每个桶数据量≈1 → 退化为计数排序;若桶内数据极度倾斜,退化为单桶排序 → 时间复杂度接近O(n²)
合并结果:直接按桶编号顺序拼接(桶间已有序,无需额外操作)

核心对比

维度计数排序桶排序
桶粒度每个值一个桶数值范围一个桶
适用场景整数小范围均匀分布数据
空间开销O(max_value)O(n + bucket_num)

应用要点

优势
线性时间复杂度(理想情况 O(n + k));分治减负:将大规模问题分解为小规模子问题(降低排序难度)
陷阱
数据倾斜:若某桶数据量过大,效率急剧下降 → 需动态调整分桶策略
过度分桶:k过大时空间浪费(如k=1000但多数桶为空)
扩展设计
动态分桶:根据数据分布调整桶边界(如指数分桶应对长尾数据)
混合策略:桶排序 + 快速排序(桶内使用更高效算法)


基数排序

(十进制LSD版本)

情境导入

历史场景重现:1880年的美国人口普查办公室内,纸张堆积如山。工作人员用羽毛笔在表格上勾画,耗时8年才完成统计。当1890年人口即将突破6300万时,传统手工统计的崩溃已成定局
🔍 何乐礼的观察与顿悟:年轻的工程师赫尔曼·何乐礼,偶然发现纺织厂的提花织布机通过穿孔卡片控制图案:每张卡片的孔位代表不同指令 机器按孔位顺序自动读取指令,他灵光一闪:"如果数据也能像织布机的图案一样,分层打孔、逐位处理,是否能让统计效率飞跃?"
🛠️ 从穿孔卡片到分位排序:何乐礼设计了一种革命性的机器,
​穿孔编码:将人口数据(年龄、性别等)转为卡片上的孔位组合,例如:年龄"25岁" → 十位孔位2 + 个位孔位5
​分位整理器:
第一轮:用探针检测个位孔,将卡片分到0-9号槽(类似基数排序的个位分桶)
第二轮:保持个位顺序,检测十位孔重新分槽
最终所有卡片自动按数值从小到大排列
💡 分位思想的本质突破:我们可以从中意识到:复杂排序可分解为多次简单操作,如同乐高积木逐层拼搭:机械降维:将多维数据(如多位数)拆解为单一位处理
🌟 划时代的意义:效率爆炸:1890年人口普查仅用6个月完成,效率提升2400%
​技术遗产:穿孔卡片机成为早期计算机的输入设备,这种分位思想也催生了IBM等公司的数据处理技术
🧩 现代算法的映射:观察何乐礼机器的运作,与今日基数排序完全对应:

机器操作算法步骤代码体现
探针检测个位孔提取数字个位digit = (num / 1) % 10
卡片落入0-9号槽分桶操作bucket[digit].push_back(num)
按槽顺序收集卡片合并桶数据arr[index++] = bucket[d][i]
重复检测更高位孔直至无更高位循环处理更高位for (exp = 1; max / exp > 0; exp *= 10)

目标呈现

核心概念:
非比较排序:通过位值分配数据,避免两两比较
​稳定排序:保持相同元素原有顺序
​时间复杂度:O(d(n+k)),d为最大位数,k为基数(如十进制k=10)*

  • 学习目标:理解基数排序的 ​分位分配-收集​ 机制,掌握代码实现逻辑。

知识建构

(1)基本思想
分位处理:从最低位(LSD)开始,逐位分配数据到桶中,再按顺序收集。
动态示例:
初始数组:{329, 457, 657, 839, 436, 720, 355}
​个位排序:按0-9分桶→收集→序列变为{720, 355, 436, 457, 657, 329, 839}
​十位排序:重复分桶→收集→序列进一步有序。
(2)代码拆解

void radix_sort(int* arr, int n) {
    if(n <= 1) return;  // 边界条件:空数组或单元素无需排序

    // 1. 定位最大数 → 确定循环次数(关键!)
    int maxx = *max_element(arr, arr+n);  // 时间复杂度O(n)扫描

    // 2. 创建10个桶(对应数字0-9)
    vector<vector<int>> bucket(10);      // 空间复杂度O(n+k)

    // 3. 按位循环:exp表示当前处理位(1→个位,10→十位...)
    for(int exp=1; maxx/exp>0; exp*=10) { // 循环d次,d为最大位数
        
        // 4. 清空桶(重要!避免上一轮数据干扰)
        for(auto &b : bucket) b.clear(); 

        // 5. 分配阶段:元素按当前位入桶
        for(int i=0; i<n; ++i) {
            int digit = (arr[i]/exp) % 10;  // 核心操作:提取当前位
            bucket[digit].push_back(arr[i]); 
            // 示例:arr[i]=329,exp=100 → (329/100)=3 → 3%10=3 → 入3号桶
        }

        // 6. 收集阶段:按桶顺序覆盖原数组
        int idx = 0;
        for(int d=0; d<10; ++d) {          // 从0号桶到9号桶顺序收集
            for(int num : bucket[d]) {     // 每个桶内部保持原有顺序
                arr[idx++] = num;          // 稳定性在此体现!
            }
        }
    }
}

新手常见困惑解答
Q1:为什么用 maxx/exp > 0 作为循环终止条件?
A:

  • 数学本质:当 exp(当前处理的位权值,如1、10、100…)超过最大数的最高位时,maxx/exp 的结果会变成0。
    • 例如最大数是 999,当 exp=1000 时,999/1000=0(整数除法舍去小数),说明所有位已处理完毕。
  • 代码意义:循环次数等于 最大数的位数,确保每个数字的每一位都被处理。

Q2:短位数(如数字5)如何处理高位?是否会出错?**
A:

  • 自动补零机制:在计算高位时会自动视为前导零。
    • 示例:数字 5(个位)处理十位时,(5/10)%10=0%10=0 → 分配到0号桶。
  • 代码验证
    int num = 5;
    cout << (num/10)%10;  // 输出0,不会越界或报错
    

Q3:为什么要用二维向量 vector<vector<int>> 当桶?内存不会爆炸吗?
A:

  • 空间换时间:桶的临时存储是基数排序的核心设计,但需注意优化。
  • 内存分析
    • 每个桶最多存储 n 个元素,总空间复杂度为 O(n+k)(k=10),远优于计数排序的 O(max-min)
    • 示例:排序1亿个8位整数,仅需约400MB内存(实测每个桶平均存1000万元素)。
  • 优化技巧
    • 预分配内存:bucket[i].reserve(n/10) 减少动态扩容开销。
    • 链表替代:用 list<int> 或指针链表实现桶,避免内存碎片(但代码复杂度增加)。

Q4:为什么每次循环都要清空桶?不清空会怎样?
A:

  • 致命错误:如果不清空桶,上一轮残留数据会导致重复收集,排序完全错误。
  • 示例验证
    • 第一轮处理个位后,桶0中有 [10, 20, 30]
    • 不清空直接处理十位,这些元素会被重复加入新数据,最终数组出现重复值。
  • 底层逻辑:桶是临时容器,每轮处理独立位值,必须保证桶的纯净性。

Q5:如何直观理解“稳定性”在基数排序中的作用?
A:

  • 生活类比:想象整理扑克牌时,先按花色排序(♠️♥️♣️♦️),再按数字排序。若第二次排序不稳定,同数字不同花色的牌会乱序。
  • 代码实例
    输入:[{:21, 颜色:}, {:13, 颜色:}, {:21, 颜色:绿}]
    按个位排序后顺序不变(113)
    最后排序结果:[{:13, 颜色:},{:21, 颜色:},{:21, 颜色:绿}]
    蓝色的21仍在绿色21之前,这种稳定性在多关键字的排序中有优越的效果
    
  • 算法意义:稳定性确保高位排序时,低位的有序性不被破坏,是多轮排序正确的关键。

应用场景

何时选择基数排序?
✅ ​数据为定长整数或字符串​(如身份证、UUID)
✅ ​需要稳定排序且数据量较大​(如日志处理)
❌ ​数据长度差异大或为浮点数​ → 优先考虑其他算法

因为在算法题目的测试点中往往会给出极大位数的样例,所以对于O(d(n+k))的基数排序来说并不友好,在此也就不给出模板题,以防浪费时间学者钻研基数排序解决排序算法题


参考资料与推荐学习

【10-4:基于分类的排序计数排序、桶排序、基数排序】
【哑巴给大学生讲算法】
左程云—【算法讲解028【必备】基数排序】
【数据结构合集 - 基数排序(算法过程, 效率分析, 稳定性分析)】


总结

晨雾中的京都庭园,石灯笼上凝结的露珠悄然计数着时光的刻度,恰如计数排序在静默中丈量数据的频率;岚山竹溪载着红叶逐位漂流,若基数排序的流水将散落的数字引向归处;茶人分茶时,粗陶罐将春水分作九品,正如桶排序在粗砺中雕琢秩序。

这些算法皆在诉说着分合之道——离散的樱花需经五重塔的层阶方能成雪,无序的字符要在浮世绘的套版间寻得本位。学者的研究何尝不是这般?在0与1的枯山水间,以指尖的温度融化数据的冰河,终将在某个晨光熹微的刹那,听见算法绽放如枝垂樱裂帛的声音。

"浮世之数理,譬如朝露映千帐,且持素心向幽玄"​
愿君在计数时看见星轨,在分桶时听见落椿,让理性的算法永远流淌着物哀的诗意。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值