首页/数据结构/07-sorting/基数排序 🔗 在 Obsidian 中打开
数据结构 · 07-sorting

基数排序

重要度 ★★ 数据结构/排序排序分配排序408考研
速查
基数排序 = 分配 + 收集,按位排序、不比较关键字。时间 $O(d\times(n+r))$、空间 $O(n+r)$、稳定,与初始序列无关

速查表

项目
主题基数排序
核心概念非比较的排序算法,属于分配排序。不通过比较关键字排序,而是通过分配收集按位处理。
时间复杂度$O(d\times(n+r))$(最好 / 平均 / 最坏均相同)
空间复杂度$O(n+r)$
稳定性✅ 稳定(详见「稳定性分析」)
难度⭐⭐⭐

核心概念

基数排序(Radix Sort)是一种非比较的排序算法,是分配排序的一种。它不通过比较关键字来排序,而是通过分配收集的方式,按位对关键字进行排序。

核心思想

  • 将关键字拆分为若干个(个位、十位、百位……)
  • 按照从低位到高位(或从高位到低位)的顺序,依次对每一位进行排序
  • 每一位的排序使用稳定的排序算法(如计数排序)
  • 最终得到有序序列

基数的定义

  • 假设关键字由 $d$ 元组 $(k^{(d-1)}, k^{(d-2)}, \ldots, k^{1}, k^{0})$ 组成
  • 其中 $0 \leq k^{i} \leq r-1$
  • $r$ 称为基数(如十进制的基数为 10)

两种方法

  • 最低位优先(LSD):从个位开始,逐位向高位排序(常用
  • 最高位优先(MSD):从最高位开始,逐位向低位排序

关键特性

  • 基数排序不是基于比较的排序算法
  • 元素的移动次数与关键字的初始排列次序无关
  • 基数排序是稳定
  • 通常使用链式存储(而不是顺序存储)
  • 只能对整数进行排序
一趟 = 分配(Distribute)→ 收集(Collect) 原序列n 个元素 r 个队列按第 i 位入队 新序列Q₀→Q_{r-1} 出队
图:一趟只看一位;重复 $d$ 趟,序列即整体有序。

算法步骤

LSD(最低位优先)基数排序

  1. 初始化:设置 $r$ 个空辅助队列 $Q_0, Q_1, \ldots, Q_{r-1}$
  2. 分配:按照关键字位权重递增的次序(个、十、百……),对 $d$ 个关键字位分别做分配和收集
    • 顺序扫描各个元素
    • 若当前处理的关键字位为 $x$,就将元素插入 $Q_x$ 队尾
  3. 收集:把 $Q_0, Q_1, \ldots, Q_{r-1}$ 各个队列的结点依次出队并链接在一起
  4. 重复步骤 2-3,共处理 $d$ 趟
  5. 最终得到有序序列

MSD(最高位优先)基数排序

  1. 按照最高位进行分配和收集
  2. 对每个队列中的元素,按照次高位递归进行分配和收集
  3. 重复直到最低位
为什么 LSD 更常用LSD 只需 $d$ 趟迭代,无需递归、无需拆分子问题;MSD 必须对每个桶递归下去,实现更复杂。

代码实现

// 基数排序(LSD,链式实现)
#define MAX_DIGIT 10  // 最大位数
#define RADIX 10      // 基数(十进制)

// 链表结点
typedef struct Node {
    int data;
    struct Node *next;
} Node;

// 获取整数 x 的第 d 位数字(从个位开始,d=0 表示个位)
int GetDigit(int x, int d) {
    for (int i = 0; i < d; i++)
        x /= 10;
    return x % 10;
}

// 基数排序
void RadixSort(int A[], int n) {
    // 创建 r 个队列(用链表实现)
    Node *bucket[RADIX];  // 桶数组
    Node *tail[RADIX];    // 每个桶的尾指针

    // 计算最大位数 d
    int maxVal = A[0];
    for (int i = 1; i < n; i++)
        if (A[i] > maxVal) maxVal = A[i];
    int d = 0;
    while (maxVal > 0) { d++; maxVal /= 10; }

    // 创建链表
    Node *head = (Node *)malloc(sizeof(Node));
    head->next = NULL;
    Node *p = head;
    for (int i = 0; i < n; i++) {
        p->next = (Node *)malloc(sizeof(Node));
        p = p->next;
        p->data = A[i];
        p->next = NULL;
    }

    // LSD 基数排序
    for (int i = 0; i < d; i++) {
        // 初始化桶
        for (int j = 0; j < RADIX; j++) {
            bucket[j] = (Node *)malloc(sizeof(Node));
            bucket[j]->next = NULL;
            tail[j] = bucket[j];
        }
        // 分配
        p = head->next;
        while (p != NULL) {
            int digit = GetDigit(p->data, i);
            tail[digit]->next = p;
            tail[digit] = p;
            p = p->next;
        }
        tail[digit]->next = NULL;  // 最后一个桶的尾部置空

        // 收集
        head->next = NULL;
        p = head;
        for (int j = 0; j < RADIX; j++) {
            if (bucket[j]->next != NULL) {
                p->next = bucket[j]->next;
                p = tail[j];
            }
            free(bucket[j]);
        }
        p->next = NULL;
    }

    // 将排序结果写回数组
    p = head->next;
    for (int i = 0; i < n; i++) {
        A[i] = p->data;
        Node *temp = p;
        p = p->next;
        free(temp);
    }
    free(head);
}
阅读提示上面是笔记原文代码,重在体现"桶 = 队列尾插保序"的思路。手写时注意每趟结束前应把每个桶的尾指针 next 置空,而不是只置空最后一次用到的桶。

手算示例

对序列 {329, 457, 657, 839, 436, 720, 355} 进行 LSD 基数排序。

初始:329, 457, 657, 839, 436, 720, 355

第一趟(按个位分配和收集)

Q_0: 720
Q_1:
Q_2:
Q_3:
Q_4:
Q_5: 355
Q_6: 436
Q_7: 457, 657
Q_8:
Q_9: 329, 839

收集:720, 355, 436, 457, 657, 329, 839

第二趟(按十位分配和收集)

Q_0:
Q_1:
Q_2: 720, 329
Q_3: 436, 839
Q_4:
Q_5: 355, 457, 657
Q_6:
Q_7:
Q_8:
Q_9:

收集:720, 329, 436, 839, 355, 457, 657

第三趟(按百位分配和收集)

Q_0:
Q_1:
Q_2:
Q_3: 329, 355
Q_4: 436, 457
Q_5:
Q_6: 657
Q_7: 720
Q_8: 839
Q_9:

收集:329, 355, 436, 457, 657, 720, 839

最终结果:329, 355, 436, 457, 657, 720, 839

时间复杂度分析

情况时间复杂度说明
最好情况$O(d\times(n+r))$与初始序列无关
平均情况$O(d\times(n+r))$与初始序列无关
最坏情况$O(d\times(n+r))$与初始序列无关

详细分析

  • 一趟分配:$O(n)$,扫描所有 $n$ 个元素
  • 一趟收集:$O(r)$,连接 $r$ 个队列
  • 共 $d$ 趟:$d$ 趟分配和收集
  • 总时间复杂度 $= d \times (O(n) + O(r)) = O(d\times(n+r))$
  • 与序列初始状态无关

适用场景

  • 数据元素的关键字可以方便地拆分为 $d$ 组,且 $d$ 较小
  • 每组关键字的取值范围不大,即 $r$ 较小
  • 数据元素个数 $n$ 较大

空间复杂度

空间复杂度为 $O(n+r)$

  • 需要 $r$ 个辅助队列(桶)
  • 如果使用链式存储,还需要 $n$ 个结点空间
  • 总空间复杂度 $= O(r) + O(n) = O(n + r)$

稳定性分析

结论基数排序是稳定的排序算法。

原因:

  • 分配时,按照原始序列的顺序依次将元素放入桶中
  • 收集时,按照桶的顺序依次取出元素
  • 当两个元素的关键字位相同时,它们会进入同一个桶,且保持原有的相对顺序
连锁要求正因为 LSD 依赖"低位排好的结果在高位相等时不被打乱",所以每一趟内部使用的排序必须稳定,否则整体结果错误。

常见考法

高频设问
  1. 基数排序的过程模拟:写出每一趟分配和收集后的结果
  2. 基数排序的稳定性:稳定的
  3. 基数排序的时间复杂度:$O(d\times(n+r))$
  4. 基数排序不是基于比较的排序:无需比较关键字
  5. 基数排序的适用场景:$d$ 小、$r$ 小、$n$ 大
  6. LSD 和 MSD 的区别:LSD 从低位开始,MSD 从高位开始
  7. 基数排序的空间复杂度:$O(n+r)$
  8. 与计数排序的关系:基数排序内部使用计数排序

易错点

必记
  1. ❌ 认为基数排序是基于比较的排序 → 不是,不需要比较关键字
  2. ❌ 认为基数排序是不稳定的 → 稳定的
  3. ❌ 混淆 LSD 和 MSD → LSD 从个位开始(常用),MSD 从最高位开始
  4. ❌ 忘记基数排序只能对整数排序 → 只能对整数
  5. ❌ 认为基数排序的时间复杂度与初始序列有关 → 无关
  6. ❌ 使用顺序存储而不是链式存储 → 通常使用链式存储
  7. ❌ 分配时从前往后,收集时也从前往后 → 收集时从 $Q_0$ 到 $Q_{r-1}$

核心结论

  1. 基数排序不是基于比较的排序算法
  2. 时间复杂度为 $O(d\times(n+r))$,与初始序列无关
  3. 空间复杂度为 $O(n+r)$
  4. 基数排序是稳定
  5. 通常使用链式存储
  6. 只能对整数进行排序
  7. 适合 $d$ 小、$r$ 小、$n$ 大 的场景
  8. 内部使用计数排序作为子排序算法

记忆卡片

基数排序的时间复杂度?
$O(d\times(n+r))$,$d$ 为位数、$n$ 为元素个数、$r$ 为基数。与初始序列无关。
为什么是稳定的?
分配按原始顺序入桶,收集按桶序出桶。相等元素进同一桶并保持原相对顺序。
LSD 与 MSD 的区别?
LSD 从个位起逐位向高位;MSD 从最高位起逐位向低位。LSD 更常用。
适合什么数据?
① 关键字可拆为 $d$ 组且 $d$ 小;② 每组取值范围 $r$ 小;③ 元素个数 $n$ 大。
与其他排序的本质区别?
不基于比较,靠分配 + 收集完成;冒泡 / 快排 / 归并都要比较关键字。
一趟的开销构成?
分配 $O(n)$ + 收集 $O(r)$,共 $d$ 趟。

交互动画 · LSD 分配与收集

本趟输入序列(高亮位 = 当前排序位) 分配:按当前位入队 Q₀ ~ Q₉(尾插,保序) 收集:Q₀ → Q₉ 依次出队链接
点击「第 1 趟 · 个位」开始,或用「下一趟」逐步推进
初始序列:329, 457, 657, 839, 436, 720, 355
示意图:橙色桶表示本趟被用到的队列;同一桶内元素保持入队先后——这正是基数排序稳定性的来源。

相关知识点

(暂无关联知识点)

↑ 本页右上「在 Obsidian 中打开」可跳回源笔记。