开始前,会这些就够了
- 能区分第3小与从0开始的下标2
- 可先读 有序数组中的二分查找
- Python 3.10+;只使用标准库
数组是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 开始,重复值分别占位置。演示每轮固定选当前区间最后一个值作为枢轴,因此可能退化到平方次数;不是随机版平均性能保证。快照展示分区,不承诺区间内部有序或相等记录稳定。
名次先约定:重复值也占位置
把数组完整升序后,下标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 执行都通过。输出直接取自本次运行;小范围穷举核对实现,不能代替前文对一般情况的解释。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""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小时,每轮枢轴位置如何决定下一侧?
- 先转换成从0开始的目标rank。
- 逐轮记录区间、枢轴和最终位置。
- 分别核对名次、分区关系与元素多重集合。
做到这些,就算完成
- 重复值没有被去重。
- 每轮只保留目标所在的一侧。
- 完整有序被单独检查,不被当作必须条件。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- Quick.java:select与partition (新标签页)↗
按分区后枢轴位置缩小到目标所在一侧,以及选中名次两侧的大小关系。
- CMU 15-210:Quickselect (新标签页)↗
从0开始的名次、随机枢轴选择、重复值处理与期望线性工作量的分析。