qinghuan
← 回到知识探索

计算机趣闻 / 动手实验

先排个位再排十位,为什么同一个桶里的顺序不能打乱?

用四张数字卡片看清稳定排序的用途:后一轮只负责新的一位,必须保住前一轮已经排好的部分。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 第一轮只看个位,还没有完成整个排序
  4. 第二轮按十位分组,同组内沿用刚才的顺序
  5. 稳定指的是当前排序键相同时,保留进入这一轮的先后
  6. 本例只处理两位非负整数,不偷偷扩展成通用字符串排序
  7. 完整程序:手算之外,再让独立方法核对
  8. 自己造一组第二轮不能乱序的卡片
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
稳定排序
只看本轮的排序键时,键相同的两项保留进入本轮的先后。
低位优先基数排序
从较不重要的位开始,一轮只处理一位,并保留同组已有顺序。
打开完整术语小抄 ↗

有四张卡片,顺序是21、12、11、22。你打算先只看个位排一轮,再只看十位排一轮。第二轮把十位相同的卡片随便摆放,会不会反正都一样?

答案藏在一个细节里:21和22虽然十位相同,个位却不相同。第一轮的工作需要被第二轮保留下来。下面不背排序名称,先按两轮卡片移动看出哪里会坏。

把例子带到自己的电脑上

下载完整练习包

需要 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。

下载 ZIP · 5 个文件
包里有什么
  • demo.py:完整可运行源码
  • README.md:运行步骤与练习
  • observed.stdout.txt:本次实际输出
  • verification.json:源码和运行验证记录
  • SHA256SUMS.txt:文件摘要

在这个文件夹打开 PowerShell,逐行运行

python --version
python -B demo.py

解压后,在含 demo.py 的目录打开终端。命令分别回车执行。用四张数字卡片看清稳定排序的用途:后一轮只负责新的一位,必须保住前一轮已经排好的部分。

找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。

先排个位,再排十位:同桶里的顺序能随便改吗?

正在准备实验控件…

原始顺序:21〔原下标 0〕 → 12〔原下标 1〕 → 11〔原下标 2〕 → 22〔原下标 3〕

观察同一个十位桶中的个位先后;点击下一轮继续。

仅处理 0 到 99 的十进制非负整数,较短数字看作前面补零。下标区分相同数值的两张卡片;错误模式只在十位这一轮倒转同桶顺序。

第一轮只看个位,还没有完成整个排序

把21、12、11、22依次放进个位对应的桶。个位为1的桶收到21、11,个位为2的桶收到12、22。每个桶保持进入时的先后,再按桶号从小到大取出,得到21、11、12、22。

这时21仍在11前面,并不算失败:本轮只承诺个位有序,没有承诺整个数有序。下一轮才处理更重要的十位。互动区给每张卡片保留原下标,两个相同数值也不会被看成同一张卡。

第二轮按十位分组,同组内沿用刚才的顺序

从第一轮的21、11、12、22开始。十位为1的桶依次收到11、12,十位为2的桶依次收到21、22。按桶号取出后得到11、12、21、22,数值顺序正确。

若第二轮故意把每个桶倒过来,结果就变成12、11、22、21。十位仍然从小到大,但同一十位中的个位顺序已经被破坏。不能只看当前这一位是否有序,就宣布整个数排好了。

稳定指的是当前排序键相同时,保留进入这一轮的先后

稳定排序不是“程序不报错”,也不是“每次结果都相同”。它指当前排序键相同的两项,排序后保留原来的相对次序。在十位这一轮,11与12的键都为1,所以稳定性会保住第一轮建立的11在12前面的关系。

把数字扩展到更多位也一样:假设低k位已经有序,接下来按第k+1位分桶。不同桶按新的一位排序;同一桶内保留低k位的顺序。两部分合起来,低k+1位就有序。这里每轮都从较不重要的位走向较重要的位。

重复数值也有可观察的顺序。输入21、12、11、12中,两张12原下标为1和3;正常两轮排序后,原下标1仍在3之前。只比较最终数值数组,会漏掉这种身份顺序变化。

本例只处理两位非负整数,不偷偷扩展成通用字符串排序

代码接受0到99,个位数可以理解成前面补零,例如7按07处理。两个桶分配循环是本文自己写的示范,不是对外部库的性能替代。负数、小数、布尔值和字符串均拒绝,空数组允许。

一般地,处理d位、每位b个桶、n条记录,每轮分配和收集需要O(n+b),总工作量为O(d(n+b))。不保留历史时工作空间为O(n+b);教学程序额外保存每轮顺序,需要O(dn+b)空间。本例固定d=2、b=10。

作者仓库的LSD.java展示了固定宽度字符串从末位往前处理,并按原顺序分配到辅助数组。本文借用的是这个机制,使用自己的十进制小例子;没有声称该字符串方法直接解决负整数、变长文本或语言地区排序。

完整程序:手算之外,再让独立方法核对

保存下面全部代码为 demo.py,执行 python demo.py。本次在 Windows / CPython 3.14.0 实跑,只使用标准库;同时通过 Python 3.10 语法解析,未在 Python 3.10 解释器中运行。

把0、1、10、11组成长度0到5的所有1365个数组,逐一对照按完整数值及原下标排序的独立参考;另检查重复数字、错误模式反例与7种无效输入。

输出来自本次真实执行。检查使用显式 AssertionError,python -O 不会把检查删掉。小范围穷举支持实现核对,算法的一般理由仍由前面的依赖关系说明。

展开收起代码 · 小实验python · 67 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""Two decimal passes, with identities preserved to expose stability."""
from itertools import product


def require(ok, message):
    if not ok:
        raise AssertionError(message)


def radix(values, break_last=False):
    if not isinstance(values, (list, tuple)) or len(values) > 1000:
        raise ValueError("at most 1000 integers required")
    if any(type(x) is not int or not 0 <= x <= 99 for x in values):
        raise ValueError("integers in 0..99 required; bool excluded")
    if type(break_last) is not bool:
        raise ValueError("break_last must be bool")
    records = [(value, index) for index, value in enumerate(values)]
    trace = [list(records)]
    for place in (1, 10):
        buckets = [[] for _ in range(10)]
        for record in records:
            buckets[record[0] // place % 10].append(record)
        # Deliberate fault: reverse ties in the MOST significant pass only.
        if break_last and place == 10:
            for bucket in buckets:
                bucket.reverse()
        records = [record for bucket in buckets for record in bucket]
        trace.append(list(records))
    return records, trace


def show(records):
    return [value for value, _ in records]


def main():
    values = [21, 12, 11, 22]
    good, trace = radix(values)
    broken, _ = radix(values, True)
    print(f"input={values}")
    print(f"after_units={show(trace[1])}")
    print(f"stable_tens={show(good)}")
    print(f"reverse_ties_in_tens={show(broken)}")
    require(show(broken) != sorted(values), "fault must be visible")
    duplicate, _ = radix([21, 12, 11, 12])
    print(f"duplicates_as_value_original_index={duplicate}")
    count = 0
    for size in range(6):
        for values in product((0, 1, 10, 11), repeat=size):
            actual, _ = radix(values)
            # Independent reference: sort the whole number, then identity.
            expected = sorted((value, i) for i, value in enumerate(values))
            require(actual == expected, "numeric order and equal-value identity")
            count += 1
    for invalid in ([True], [-1], [100], [1.5], ["11"], "11", [0] * 1001):
        try:
            radix(invalid)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid input accepted")
    print(f"exhaustive_arrays={count}; invalid_inputs=7")
    print("PASS: numeric order, duplicate stability, empty input and fault witness")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。用四张数字卡片看清稳定排序的用途:后一轮只负责新的一位,必须保住前一轮已经排好的部分。

运行后对照结果
input=[21, 12, 11, 22]
after_units=[21, 11, 12, 22]
stable_tens=[11, 12, 21, 22]
reverse_ties_in_tens=[12, 11, 22, 21]
duplicates_as_value_original_index=[(11, 2), (12, 1), (12, 3), (21, 0)]
exhaustive_arrays=1365; invalid_inputs=7
PASS: numeric order, duplicate stability, empty input and fault witness

自己造一组第二轮不能乱序的卡片

练习不预填答案:把输入改为31、20、32、21,先写出个位一轮,再写出保持桶内顺序的十位一轮。最后只倒转十位桶,指出第一对违反整体升序的相邻卡片。

再额外放入一张31,用原下标标记它。分别检查“数值是否有序”和“相等数值是否保持原顺序”,不要把两个检查合并成一个。

记住这些要点

  • 低位先排好,高位这一轮必须保留同组内部的已有顺序。
  • 稳定性针对当前键相同的项,并不是泛指结果可靠。
  • 完整数字有序与相同数字的身份顺序稳定,是两个检查。
  • 基数、位数和输入编码决定算法适用范围。

别把例子用过头

  • 第二轮只确认十位有序,却忽略桶内的个位被打乱。
  • 把每个桶当作无序集合,丢失前一轮建立的顺序。
  • 直接把两位非负整数的程序用于负数或任意文本。

自己试一小步

对31、20、32、21,再加入一个31,怎样同时检查数值排序与相同值的稳定性?

  1. 给每张卡片编号,手写两轮分桶。
  2. 比较保持桶内顺序和倒转十位桶的结果。
  3. 为数值顺序与原下标顺序各写一个检查。

做到这些,就算完成

  • 每一轮都标明只按哪一位分桶。
  • 两张31始终保留独立身份。
  • 练习不给最终排列,先预测再运行。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。