qinghuan
← 回到知识探索

计算机趣闻 / 动手实验

找到第3小的数,为什么左边两个数仍然没有排好?

Quickselect只缩小目标名次所在的一侧;用一次分区看清选出某个名次与完成整个排序的区别。

展开本文目录
  1. 下载完整练习包
  2. 先动手看看
  3. 名次先约定:重复值也占位置
  4. 一轮分区只把枢轴送到正确的名次位置
  5. 枢轴没到目标位置,就只继续目标所在的一侧
  6. 固定拿最后一个数,已经有序时可能每轮只减少一个
  7. 核心分区可以原地进行,演示为了保留输入与步骤会复制
  8. 完整程序:用独立方法核对结果
  9. 写出你需要保证的关系,别顺手要求完整有序
  10. 记住这些要点
  11. 别把例子用过头
  12. 自己试一小步
  13. 查原始资料
开始前,会这些就够了
遇到不熟悉的词,先看这 2 个解释
顺序统计量
把所有值升序排列后,指定名次上的值。
分区
围绕一个枢轴,按大小关系把元素安排到不同区域。
打开完整术语小抄 ↗

数组是4、2、5、1、3。要找第3小的数,程序只做一轮分区,就返回3;可数组此时是2、1、3、4、5,左边2和1仍然倒着。结果是不是只做了一半?

如果任务是完整排序,确实还没完成;但如果任务只是找第3小,已经足够。关键在于分区能证明哪些关系,以及它从未承诺哪些关系。

把例子带到自己的电脑上

下载完整练习包

需要 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 的目录打开终端。命令分别回车执行。Python 标准库的独立 Quickselect 实验;固定末尾枢轴,加 1092 个数组的 6015 个名次对照。未实现随机枢轴,未测量大规模性能。

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

选出第 k 小之后,左边的数字已经排好了吗?

正在准备实验控件…

已做分区轮数
0
累计元素与枢轴比较
0
目标值
尚未确定

数组:4 | 2 | 5 | 1 | 3

初始数组还没有执行分区。先预测左边是否必须有序。

名次下标从 0 开始,重复值分别占位置。演示每轮固定选当前区间最后一个值作为枢轴,因此可能退化到平方次数;不是随机版平均性能保证。快照展示分区,不承诺区间内部有序或相等记录稳定。

名次先约定:重复值也占位置

把数组完整升序后,下标k上的值叫第k+1小的值。本文代码用从0开始的rank,因此第3小传rank=2。重复值按出现次数占据位置,例如1、1、4的第2小仍是1,而不是第2个不同值4。

本文只返回这个名次的数值,不负责相同值记录的稳定身份。如果要返回原始记录或保持同值先后,必须额外约定;不能从数值正确推导出身份顺序也保留。

一轮分区只把枢轴送到正确的名次位置

示例选当前区间的最后一个数3作为枢轴。从左到右扫描4、2、5、1,把小于3的项移到前面,最后把3放在它们之后。得到2、1、3、4、5,枢轴位置正好是下标2。

这时左侧每项都小于3,右侧每项都大于或等于3;但左侧内部和右侧内部都没有排好序的承诺。这里2、1就是最直接的反例。因为刚好有两个更小的数在前面,枢轴3已经占据目标名次。

本文采用最后元素作枢轴、严格小于移到左边的简单分区代码。外部作者源码使用另一种分区细节并先随机打乱;我们引用的是按枢轴位置选一侧的原则,没有把两份实现称为同一个程序。

枢轴没到目标位置,就只继续目标所在的一侧

若枢轴最终下标p小于目标rank,目标只可能在右侧;左侧和枢轴已经提供了更靠前的那些位置。若p大于rank,就只继续左侧。若p等于rank,立即结束。

快速排序需要把两侧都继续整理,因为它要得到所有名次。Quickselect只追踪一个名次,所以每轮只保留一侧。缩小的区间可能内部很乱,只要目标仍包含在其中就可以继续。

全相等的数组仍然能求出正确的数值名次,但本例严格小于的分区会把枢轴放在区间开头。若目标在后面,就可能每次只缩短一个元素;重复值并不会自动带来良好的效率。

固定拿最后一个数,已经有序时可能每轮只减少一个

把1到n的升序数组交给本文程序,要求最小值。最后元素每次都是当前最大值,分区比较n−1次后只排除这一个最大值;接下来又比较n−2次,直到剩下最小值。

总比较次数是(n−1)+(n−2)+…+1=n(n−1)/2。实跑n为5、10、20时,分别记录10、45、190次比较,展示了这个确定枢轴策略的最坏情况O(n²)。一次演示只比较4次,不足以说明所有输入都快。

元素互异且每轮等概率选取当前区间的枢轴时,随机 Quickselect 的期望时间为 O(n)。含重复值时,还需让分区正确处理相等项,例如一次分出小于、等于、大于枢轴的三段;仅给本文严格小于的二路分区随机换枢轴,仍无法避免全相等输入每次只缩短一个元素。

期望是在随机选择上求平均,不是每次都线性的保证。本文没有实现随机枢轴,也没有用这三个输入测出那个理论结论;CMU讲义使用能单独处理相等项的随机版本。

核心分区可以原地进行,演示为了保留输入与步骤会复制

如果直接修改传入数组且不留步骤,迭代选择核心只需要常数级额外变量;本文先复制输入,避免改动调用方的列表,因此至少用O(n)额外空间。

每轮分区后又保存完整数组快照,最坏有O(n)轮,轨迹会用O(n²)空间和复制时间。输入被限制为1到100个−999到999的整数,rank必须在有效范围内;布尔值、空数组和越界名次都会被拒绝。

完整程序:用独立方法核对结果

保存下方全部代码为 demo.py,执行 python demo.py。程序只使用 Python 标准库,本次实际运行环境是 Windows / CPython 3.14.0;另通过 Python 3.10 语法解析,未在 3.10 解释器中执行。

枚举由0、1、2构成的长度1到6数组,共1092个;对每个有效名次运行,共6015组。独立用完整排序核对选中值,再检查左右分区关系与元素多重集合没有改变,另拒绝7类无效输入。

检查使用显式 AssertionError,正常执行和 python -O 执行都通过。输出直接取自本次运行;小范围穷举核对实现,不能代替前文对一般情况的解释。

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

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

小实验 · python
"""Selecting a rank partitions values but does not sort both partitions."""
from itertools import product


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


def select(values, rank):
    if not isinstance(values, (list, tuple)) or not 1 <= len(values) <= 100:
        raise ValueError("1..100 integer values required")
    if any(type(value) is not int or not -999 <= value <= 999 for value in values):
        raise ValueError("integers in -999..999 required; bool excluded")
    if type(rank) is not int or not 0 <= rank < len(values):
        raise ValueError("zero-based rank outside array")
    values = list(values)
    low, high, comparisons = 0, len(values) - 1, 0
    trace = []
    while low <= high:
        pivot = values[high]
        boundary = low
        for scan in range(low, high):
            comparisons += 1
            if values[scan] < pivot:
                values[scan], values[boundary] = values[boundary], values[scan]
                boundary += 1
        values[boundary], values[high] = values[high], values[boundary]
        trace.append((low, high, pivot, boundary, list(values)))
        if boundary == rank:
            return values[rank], values, comparisons, trace
        if boundary < rank:
            low = boundary + 1
        else:
            high = boundary - 1
    raise AssertionError("nonempty candidate interval expected")


def main():
    values, rank = [4, 2, 5, 1, 3], 2
    chosen, partial, comparisons, _ = select(values, rank)
    print(f"input={values}; zero_based_rank={rank}")
    print(f"selected={chosen}; partitioned={partial}; fully_sorted={partial == sorted(values)}")
    print(f"left_of_rank={partial[:rank]}; comparisons={comparisons}")
    require(chosen == 3 and partial[:rank] == [2, 1], "partition is not full sorting")
    counts = []
    for size in (5, 10, 20):
        chosen, _, comparisons, _ = select(list(range(size)), 0)
        require(chosen == 0 and comparisons == size * (size - 1) // 2, "deterministic pivot worst case")
        counts.append((size, comparisons))
    print(f"sorted_input_select_min_comparisons={counts}")
    arrays = cases = 0
    for size in range(1, 7):
        for values in product((0, 1, 2), repeat=size):
            arrays += 1
            for rank in range(size):
                chosen, partial, _, _ = select(values, rank)
                expected = sorted(values)
                require(chosen == expected[rank], "independent full sort")
                require(sorted(partial) == expected, "multiset preserved")
                require(all(x <= chosen for x in partial[:rank]), "left rank boundary")
                require(all(x >= chosen for x in partial[rank+1:]), "right rank boundary")
                cases += 1
    invalid = [([], 0), ([1], -1), ([1], 1), ([1], True), ([True], 0), ([1.5], 0), ([1000], 0)]
    for args in invalid:
        try:
            select(*args)
        except ValueError:
            pass
        else:
            raise AssertionError("invalid input accepted")
    print(f"arrays={arrays}; rank_cases={cases}; invalid_inputs=7")
    print("PASS: selected rank, partition boundaries, duplicates and preserved multiset")


if __name__ == "__main__":
    main()

怎样运行:在独立练习文件夹中,依次执行下载区的命令。2026-10-26 使用 Python 3.10 或更新版本,无第三方依赖;实测 Windows / Python 3.14.0,Python 3.10 仅做语法兼容检查 复跑这份源码并核对完整输出。Python 标准库的独立 Quickselect 实验;固定末尾枢轴,加 1092 个数组的 6015 个名次对照。未实现随机枢轴,未测量大规模性能。

运行后对照结果
input=[4, 2, 5, 1, 3]; zero_based_rank=2
selected=3; partitioned=[2, 1, 3, 4, 5]; fully_sorted=False
left_of_rank=[2, 1]; comparisons=4
sorted_input_select_min_comparisons=[(5, 10), (10, 45), (20, 190)]
arrays=1092; rank_cases=6015; invalid_inputs=7
PASS: selected rank, partition boundaries, duplicates and preserved multiset

写出你需要保证的关系,别顺手要求完整有序

练习不预填答案:数组5、2、4、2、1、3,分别求第2小和第5小。每轮标记当前区间、最后元素枢轴、分区后枢轴位置,再决定下一轮只保留哪一侧。

对最终数组写三个独立检查:目标值是否等于完整排序的对应名次;左右两侧是否满足与目标的大小关系;元素和重复次数是否保持。最后检查整个数组是否有序,说明它不是选择任务必须满足的条件。

记住这些要点

  • 选出一个名次只需足够的分区关系,不要求两侧内部有序。
  • rank从0开始,重复值按出现次数占位置。
  • Quickselect每轮继续一侧,快速排序要继续两侧。
  • 固定枢轴最坏可平方级;随机版本的期望界不能直接贴到本例实现上。

别把例子用过头

  • 找到目标名次后,误以为整个数组已经排序。
  • 把第k小与第k个不同值混为一谈。
  • 测试一次很快就宣称所有输入都是线性时间。
  • 演示保存了所有快照,却仍把整体额外空间写成O(1)。

自己试一小步

数组5、2、4、2、1、3求第5小时,每轮枢轴位置如何决定下一侧?

  1. 先转换成从0开始的目标rank。
  2. 逐轮记录区间、枢轴和最终位置。
  3. 分别核对名次、分区关系与元素多重集合。

做到这些,就算完成

  • 重复值没有被去重。
  • 每轮只保留目标所在的一侧。
  • 完整有序被单独检查,不被当作必须条件。

查原始资料

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

  • Quick.java:select与partition (新标签页)↗Sedgewick / Wayne《Algorithms, 4th Edition》作者仓库 · 英文

    按分区后枢轴位置缩小到目标所在一侧,以及选中名次两侧的大小关系。

    已阅读作者仓库原始Java源码中的partition与select方法及注释;源码先shuffle且使用不同分区,本文为独立编写的最后枢轴示例,没有复制其实现。

  • CMU 15-210:Quickselect (新标签页)↗Carnegie Mellon University / 15-210 · 英文

    从0开始的名次、随机枢轴选择、重复值处理与期望线性工作量的分析。

    已阅读25页PDF的文本提取,重点第13页名次定义、第17至19页随机分区与重复值、第23页期望工作量。未声称观看课程或执行其并行算法。