量化交易中文教材

第 08 章 排序的下界与线性时间排序

学习目标

  1. 能用决策树模型证明:任何比较排序在最坏情况下至少需要 \(\Omega(n\lg n)\) 次比较,并理解这个下界"管什么、不管什么"。
  2. 掌握计数排序的三趟扫描,理解它为什么从后往前放置、为什么必须稳定。
  3. 掌握基数排序的正确性条件(每位排序必须稳定),会用 \(\Theta((b/r)(n+2^r))\) 选择每遍的位数 \(r\)。
  4. 掌握桶排序的平均线性时间分析(\(E[n_i^2]=2-1/n\)),以及用概率积分变换把任意已知分布化为均匀分布。
  5. 能把这些"用值作下标"的思想用到量化场景:价格档位直方图、成交量分布、区间计数、分位数分组、多键排序。

读前导读

这一章在解决什么问题。 前几章的排序算法(归并、堆、快速排序)最好也只能做到大约 \(n\lg n\) 次操作。本章先回答一个"天花板"问题:只靠"两两比大小"来排序,能不能更快?答案是不能——这是一个关于所有可能算法的结论,不是某个算法写得不好。然后本章给出绕过天花板的办法:不比大小,而是直接把数值当作位置。最贴近你工作的例子是成交量分布:一天几万笔成交,价格却只落在几百个价位上,你只需准备几百个"格子",每笔成交往对应价位的格子里一扔,扫一遍就排好了,根本不需要比较两笔成交谁的价格高。

可以把本章和你熟悉的东西这样对应:决策树下界的思路是"信息量"——你要从 \(n!\) 种可能的排列里认出真实的那一种,每问一个"是/否"问题最多把可能性砍掉一半,这和二分法估算债券到期收益率(YTM)时每试一次就把区间减半是同一个道理。桶排序的平均分析则完全是 CFA 级别的概率:二项分布的均值和方差。最后的概率积分变换,就是你熟悉的"把 z 值换成百分位"(\(\Phi(z)\))。

需要先想起来的数学。

  • 对数 \(\lg\) 与指数的互逆关系。 本书 \(\lg\) 表示以 2 为底的对数:\(\lg 8=3\) 因为 \(2^3=8\)。直观含义:"要把 \(n\) 个东西每次对半分,分几次才剩一个"。不等式 \(l\le 2^h\) 两边取 \(\lg\) 得 \(\lg l\le h\),因为 \(\lg\) 是递增函数,取对数不改变不等号方向。\(\lg(ab)=\lg a+\lg b\),所以 \(\lg(n!)=\lg1+\lg2+\cdots+\lg n\)。见 第 00 册第 04 章 级数与收敛。
  • 渐近记号 \(\Theta\)、\(O\)、\(\Omega\)。 粗略说:\(O(f)\) 是"不超过 \(f\) 的常数倍"(上界),\(\Omega(f)\) 是"至少是 \(f\) 的常数倍"(下界),\(\Theta(f)\) 是"上下都卡住,增长速度和 \(f\) 一样"。例:\(3n^2+5n=\Theta(n^2)\)。本章的下界定理用的是 \(\Omega\)。详见本册第 03 章。
  • 示性随机变量与期望的线性性。 \(I\{\text{事件}\}\) 在事件发生时取 1,否则取 0,所以 \(E[I]=\Pr\{\text{事件}\}\)。期望的线性性:\(E[X+Y]=E[X]+E[Y]\),不论 \(X,Y\) 是否独立;但 \(E[XY]=E[X]E[Y]\) 需要独立。例:掷 10 枚硬币,正面数 \(=\sum I_j\),期望 \(=10\times0.5=5\)。见 第 00 册第 07 章 概率中的分析工具。
  • 求和记号与前缀和。 \(\sum_{k=1}^{n}a_k=a_1+\cdots+a_n\)。"前缀和" \(C[i]=a_0+\cdots+a_i\) 的好处是区间和 \(a_a+\cdots+a_b=C[b]-C[a-1]\),一次减法搞定——就像用累计净值相除得到任意区间收益。
  • 分布函数(CDF)。 \(P(x)=\Pr\{X\le x\}\),单调不减,取值在 \([0,1]\)。标准正态的 CDF 就是你查表用的 \(\Phi\),\(\Phi(1.645)\approx0.95\)。

怎么读这一章。 8.1 节的定理 8.1 和它后面"一次比较最多 1 比特"那段是核心,务必读懂;"下界的推广"一节第一次可以只看结论。8.2 计数排序和 8.3 基数排序是全章最实用的部分,建议拿纸笔把图 8.2 的例子手算一遍。8.4 节的 \(E[n_i^2]\) 证明是一道标准的期望计算题,CFA 统计基础够用。8.5 节量化实战值得精读,尤其是价位直方图和十分组的对比;8.6 节可以跳过。


8.1 比较排序的下界

比较模型

插入、归并、堆、快速排序有一个共同点:它们只通过比较两个元素来获得顺序信息,称为比较排序(comparison sorts)。在这个模型里,算法可以做 \(a_i<a_j\)、\(a_i\le a_j\)、\(a_i=a_j\)、\(a_i\ge a_j\)、\(a_i>a_j\) 这类测试,但不能查看元素的值本身,也不能用其他方式获取顺序信息。为了下界证明,不妨设元素互异:此时 \(a_i=a_j\) 的测试没有用,其余四种测试提供的信息等价,所以只考虑 \(a_i\le a_j\)。

决策树

决策树(decision tree)是一棵满二叉树,表示某个比较排序算法在给定规模 \(n\) 上执行的全部比较,忽略数据移动和控制逻辑。内部结点标 \(i:j\),表示比较 \(a_i\le a_j\);左子树是"成立"之后的比较,右子树是"不成立"之后的比较。叶子标一个排列 \(\langle\pi(1),\dots,\pi(n)\rangle\),表示算法认定 \(a_{\pi(1)}\le a_{\pi(2)}\le\cdots\le a_{\pi(n)}\)。算法在一个具体输入上的一次运行,就是从根到某片叶子的一条路径。

例(原书图 8.1):插入排序在 3 个元素上的决策树。根比较 1:2;若 \(a_1\le a_2\),再比较 2:3,成立则输出 \(\langle1,2,3\rangle\),否则再比较 1:3,得 \(\langle1,3,2\rangle\) 或 \(\langle3,1,2\rangle\);若 \(a_1>a_2\),比较 1:3,成立得 \(\langle2,1,3\rangle\),否则比较 2:3,得 \(\langle2,3,1\rangle\) 或 \(\langle3,2,1\rangle\)。输入 \(\langle6,8,5\rangle\) 走到叶子 \(\langle3,1,2\rangle\),即 \(a_3=5\le a_1=6\le a_2=8\)。

一个正确的排序算法必须能输出所有 \(n!\) 种排列,所以每种排列都必须作为可达叶子出现。

白话解释:决策树就是"猜谜游戏"的完整剧本。你手里有 3 张写了数字的牌,对手只允许你问"第 \(i\) 张是否不大于第 \(j\) 张"这类是非题。每问一题,剧本就往左或往右分叉;问到能确定 3 张牌的大小顺序时停下,这个停下的位置就是叶子,叶子上写着答案(一个排列)。3 张牌的顺序共有 \(3!=6\) 种,所以剧本至少要有 6 个结局。 "满二叉树"指每个分叉点恰好有两个分支(是/否)。"排列 \(\langle3,1,2\rangle\)"的意思是"第 3 个元素最小、第 1 个次之、第 2 个最大",记的是下标,不是数值本身。

定理与证明

算法的最坏比较次数就是从根到可达叶子的最长路径,即决策树的高度。

定理 8.1:任何比较排序算法在最坏情况下都需要 \(\Omega(n\lg n)\) 次比较。

证明:设决策树高度为 \(h\)、有 \(l\) 片可达叶子。每种排列都要出现,故 \(n!\le l\);高度为 \(h\) 的二叉树至多 \(2^h\) 片叶子,故

\[ n!\le l\le 2^h\quad\Longrightarrow\quad h\ge\lg(n!)=\Omega(n\lg n). \]

最后一步用斯特林公式,或者不用它:\(\lg n!=\sum_{k=1}^{n}\lg k\ge\sum_{k=\lceil n/2\rceil}^{n}\lg\frac n2\ge\frac n2\lg\frac n2\)(练习 8.1-2)。\(\blacksquare\)

推导拆解:这个证明只有三步,每步用的东西都很朴素。 第一步 \(n!\le l\):每种排列都得有一片叶子对应它(否则这种输入会被排错),所以叶子数至少是排列数。 第二步 \(l\le 2^h\):每往下一层,结点数最多翻一倍;第 0 层 1 个,第 1 层至多 2 个,……第 \(h\) 层至多 \(2^h\) 个。 第三步取对数:\(n!\le2^h\) 两边取 \(\lg\),得 \(h\ge\lg(n!)\)。 最后估计 \(\lg(n!)\) 的大小:\(\lg(n!)=\lg1+\lg2+\cdots+\lg n\)(对数把乘积变成和)。扔掉前一半较小的项(扔掉非负项只会让和变小,不等号方向正确),后一半至少有 \(n/2\) 项,每项 \(\lg k\ge\lg(n/2)\),所以和 \(\ge\frac n2\lg\frac n2\)。而 \(\frac n2\lg\frac n2=\frac n2(\lg n-1)\),\(n\) 大时就是 \(n\lg n\) 的常数倍,这正是 \(\Omega(n\lg n)\) 的意思。 数值感受:\(n=1000\) 时 \(\lg(1000!)\approx8530\),即任何比较排序最坏都要 8530 次以上比较;而 \(n\lg n\approx9966\)。

推论 8.2:堆排序和归并排序是渐近最优的比较排序。

把这个证明换个角度理解会更有用:**一次比较最多提供 1 比特信息,而要从 \(n!\) 种可能中确定一种,至少需要 \(\lg n!\) 比特。**这就是"信息论下界"。按这个角度,第 07 章的随机化快速排序平均用 \(1.39n\lg n\) 次比较,只比下界多 39%。

金融直觉:这个下界说的是"信息不够就不可能做到",不是"目前没人做到"。类比:要从 1024 只候选债券里通过"是/否"问题锁定一只,最少要问 \(\lg1024=10\) 个问题,再聪明的提问方式也省不掉。排序要锁定的是 \(n!\) 种顺序之一,所以至少要问 \(\lg(n!)\) 次。想突破,就只能换一种"每次能得到更多信息"的提问方式——比如直接看数值,这就是后面三种算法的出发点。

下界的推广

  • 几乎所有输入都难(练习 8.1-3):不存在一种比较排序,能对 \(n!\) 个输入中的一半、\(1/n\) 甚至 \(1/2^n\) 在线性时间内完成。因为深度不超过 \(h\) 的叶子至多 \(2^h\) 个,而 \(\lg(n!/2)\)、\(\lg(n!/n)\)、\(\lg(n!/2^n)\) 都还是 \(\Theta(n\lg n)\)。
  • 平均情况与随机化(思考题 8-1):即使按平均情况计,或允许算法使用随机数,比较排序的期望时间仍是 \(\Omega(n\lg n)\)。思路是:所有叶子深度之和(外部路径长度)在 \(k\) 片叶子的二叉树中至少是 \(\Omega(k\lg k)\),取 \(k=n!\) 再平均;随机化算法可以看成一族确定性算法的混合,其中最好的那个不会比平均差。
  • 分块排序(练习 8.1-4):输入由 \(n/k\) 个块组成,块与块之间已经有序,只需块内排序。可能的输出有 \((k!)^{n/k}\) 种,所以下界是 \(\lg\big((k!)^{n/k}\big)=\frac nk\lg k!=\Omega(n\lg k)\)。
  • 最少比较(练习 8.1-1):决策树里叶子的最小深度是 \(n-1\)——要确认输入已经有序,至少得把相邻元素都比一遍。
  • 合并的下界(思考题 8-6):合并两个各含 \(n\) 个元素的有序表,有 \(\binom{2n}{n}\) 种可能结果,信息论下界是 \(\lg\binom{2n}{n}=2n-o(n)\);更精细的论证(来自不同表、在结果中相邻的两个元素必须直接比较)给出 \(2n-1\) 的精确下界。

这个下界不管什么

下界只约束"只能比较"的算法。如果算法能直接利用元素的值(比如把值当数组下标),就跳出了这个模型。下面三种算法都是这样做的,代价是对输入做了假设:整数范围有限、位数有限、或分布已知。第 09 章还会看到另一种绕开方式:不排序,只做选择。


8.2 计数排序

思想与伪代码

计数排序(counting sort)假设 \(n\) 个输入都是 \(0..k\) 之间的整数。对每个元素 \(x\),如果知道有多少个元素小于等于它,就能把它直接放到输出的正确位置——例如有 17 个元素小于 \(x\),\(x\) 就属于第 18 位。有重复值时,每放一个就把计数减 1,让下一个同值元素放到它前面。

COUNTING-SORT(A, B, k)
1   let C[0..k] be a new array
2   for i = 0 to k
3       C[i] = 0
4   for j = 1 to A.length
5       C[A[j]] = C[A[j]] + 1          // C[i] = 等于 i 的元素个数
6   for i = 1 to k
7       C[i] = C[i] + C[i - 1]         // C[i] = 小于等于 i 的元素个数
8   for j = A.length downto 1
9       B[C[A[j]]] = A[j]
10      C[A[j]] = C[A[j]] - 1

例(原书图 8.2):\(A=\langle2,5,3,0,2,3,0,3\rangle\),\(k=5\)。第一趟计数后 \(C=\langle2,0,2,3,0,1\rangle\);求前缀和后 \(C=\langle2,2,4,7,7,8\rangle\)。从后往前放:\(A[8]=3\) 放到 \(B[7]\),\(C[3]\) 变为 6;\(A[7]=0\) 放到 \(B[2]\),\(C[0]\) 变为 1;\(A[6]=3\) 放到 \(B[6]\),\(C[3]\) 变为 5;……最终 \(B=\langle0,0,2,2,3,3,3,5\rangle\)。

白话解释:计数排序像给一场考试排座位。第一趟点名:数出 0 分的有 2 人、2 分的 2 人、3 分的 3 人、5 分的 1 人。第二趟累加:0 分及以下 2 人,所以 0 分的人坐 1–2 号;2 分及以下 4 人,所以 2 分的人坐 3–4 号;3 分及以下 7 人,所以 3 分的人坐 5–7 号。第三趟入座:每来一个人,按他的分数直接走到"本分数段的最后一个空座",坐下后把这个分数段的空座指针往前挪一格。 这里的 \(C[i]\) 先后有两层意思:第 5 行之后是"等于 \(i\) 的个数",第 7 行之后变成"小于等于 \(i\) 的个数",也就是"值为 \(i\) 的元素中最后一个该坐的位置"。整个过程没有拿任何两个人的分数互相比较,只用了"分数本身就是格子编号"这一点。

运行时间

第 2–3 行 \(\Theta(k)\),第 4–5 行 \(\Theta(n)\),第 6–7 行 \(\Theta(k)\),第 8–10 行 \(\Theta(n)\),合计 \(\Theta(n+k)\)。当 \(k=O(n)\) 时就是 \(\Theta(n)\)。代码里没有任何两个输入元素之间的比较,所以不受 \(\Omega(n\lg n)\) 约束。

稳定性

排序是稳定的(stable),如果值相同的元素在输出中保持输入中的相对次序。第 8 行从后往前扫描保证了这一点:同值元素中最后出现的那个被放到同值区段的最后。如果改成从前往后扫(练习 8.2-3),排序仍然正确,但同值元素的次序会被反转,不再稳定。

推导拆解:为什么从后往前扫就稳定?以图 8.2 中的三个 3 为例,它们在 \(A\) 中的位置是 3、6、8。前缀和后 \(C[3]=7\),即值为 3 的区段占 \(B[5..7]\)。从后往前扫:先遇到 \(A[8]\),放进 \(B[7]\)(区段最后);再遇到 \(A[6]\),放进 \(B[6]\);最后 \(A[3]\) 放进 \(B[5]\)。原来靠后的仍然靠后,次序保住了。若从前往后扫,\(A[3]\) 会先拿到 \(B[7]\),次序就整个倒过来。

当只排孤立的数时,稳定不稳定看不出来;带卫星数据时它很重要。计数排序的稳定性还有一个更关键的用途:它是基数排序的子程序,而基数排序的正确性依赖子排序稳定。

区间计数

练习 8.2-4 给出一个副产品:做完前 7 行(\(\Theta(n+k)\) 预处理)后,"有多少个元素落在 \([a,b]\)"可以 \(O(1)\) 回答:\(C[b]-C[a-1]\)。这就是前缀和技巧,量化里用它做价位区间成交量、收益区间计数等查询。

原地版本(思考题 8-2,了解即可)

计数排序需要输出数组 \(B\),不是原地的。思考题 8-2 讨论关键字只有 0 和 1 时三个性质——\(O(n)\) 时间、稳定、原地——只能三选二:计数排序满足前两个;类似 PARTITION 的双指针交换满足第一和第三个;插入排序满足后两个。关键字在 \(1..k\) 时,可以先算出每个值的目标区间,再循环交换把元素放进所属区间,得到原地、\(O(n+k)\) 但不稳定的版本(与 American flag sort 同一思路)。


8.3 基数排序

从卡片排序机说起

基数排序(radix sort)源自穿孔卡片排序机:机器一次只能按一列把卡片分到 10 个箱子里。直觉的做法是先按最高位分箱,再对每个箱子递归,但这要保管大量中间卡片堆(练习 8.3-5★:最坏需要 \((10^d-1)/9\) 遍)。反直觉但高效的做法是先按最低有效位(least significant digit)排序,合成一叠,再按次低位排序……直到最高位,只需 \(d\) 遍。

例(原书图 8.3):\(\langle329,457,657,839,436,720,355\rangle\)。按个位排序得 \(\langle720,355,436,457,657,329,839\rangle\);按十位排序得 \(\langle720,329,436,839,355,457,657\rangle\);按百位排序得 \(\langle329,355,436,457,657,720,839\rangle\)。

RADIX-SORT(A, d)
1  for i = 1 to d                      // 第 1 位最低,第 d 位最高
2      use a stable sort to sort array A on digit i

为什么必须稳定

用归纳法证明(练习 8.3-3):设前 \(i-1\) 位已排好,现在按第 \(i\) 位排序。第 \(i\) 位不同的两个数,按第 \(i\) 位排好就对了;第 \(i\) 位相同的两个数,它们的先后应该由低 \(i-1\) 位决定,而这个顺序上一遍已经排好——只有稳定排序才会保留它。

同样的道理用于多字段记录:按年、月、日排序日期,可以写一个先比年、再比月、再比日的比较函数,也可以用稳定排序排三遍:先按日,再按月,最后按年。pandas 的 sort_values([...], kind='stable') 和"先按次要键、再按主要键各做一次稳定排序"结果相同(第 07 章 7.6.2 节验证过)。

金融直觉:成交回报要按"交易日 → 账户 → 成交时间"排序。用稳定排序的做法是倒着来:先按成交时间排,再按账户排,最后按交易日排。最后一遍按交易日排时,同一天的记录之间不会被打乱,它们仍保持上一遍排好的"账户、时间"顺序。如果最后一遍不稳定,同一天内的记录会被随意重排,前两遍就白做了。"卫星数据"指跟着关键字一起移动的其他字段,比如按评级排序时跟着走的股票代码。

运行时间

引理 8.3:\(n\) 个 \(d\) 位数,每位取 \(k\) 种值,若每遍用 \(\Theta(n+k)\) 的稳定排序(计数排序),则基数排序用时 \(\Theta(d(n+k))\)。\(d\) 为常数且 \(k=O(n)\) 时为线性。

引理 8.4:\(n\) 个 \(b\) 位二进制数,对任意 \(r\le b\),把每个数看成 \(\lceil b/r\rceil\) 个 \(r\) 位"数字",每个数字在 \(0..2^r-1\) 之间,则基数排序用时

\[ \Theta\Big(\frac{b}{r}\,(n+2^r)\Big). \]

例如 32 位整数取 \(r=8\):4 遍,每遍计数数组长 256。

怎样选 \(r\)

目标是最小化 \((b/r)(n+2^r)\):

  • 若 \(b<\lfloor\lg n\rfloor\):任何 \(r\le b\) 都有 \(n+2^r=\Theta(n)\),取 \(r=b\),一遍完成,\(\Theta(n)\)。
  • 若 \(b\ge\lfloor\lg n\rfloor\):取 \(r=\lfloor\lg n\rfloor\),代价 \(\Theta(bn/\lg n)\),在常数因子内最优。\(r\) 再大,分子里的 \(2^r\) 涨得比分母里的 \(r\) 快;\(r\) 再小,遍数 \(b/r\) 变多而每遍仍是 \(\Theta(n)\)。

一句话:**每遍处理 \(\lg n\) 位,让计数数组和输入一样大。**下面的实验里 \(n=20000\),\(\lg n\approx14.3\),理论代价在 \(r=11\) 附近最小。

推导拆解:代价 \((b/r)(n+2^r)\) 是"遍数 × 每遍工作量"。遍数 \(b/r\):32 位整数每遍处理 8 位,要 4 遍。每遍工作量 \(n+2^r\):扫一遍 \(n\) 个数,加上清零和累加长度为 \(2^r\) 的计数数组。这是一个典型的此消彼长:\(r\) 大则遍数少,但计数数组按 \(2^r\) 指数膨胀。 取 \(r=\lg n\) 时 \(2^r=n\),每遍工作量 \(2n\),遍数 \(b/\lg n\),总代价约 \(2bn/\lg n\)。若 \(r\) 比 \(\lg n\) 大 1,计数数组翻倍;大 5,计数数组变成 \(32n\),远超遍数减少带来的节省。所以平衡点就在"计数数组和输入差不多大"处。这和久期管理里"在两个反向成本之间找平衡"是同一种权衡思路,只是这里没有用导数,而是直接比较数量级。 注意实验里的 \(r=11\) 并不等于 \(\lg n\approx14\):因为遍数要向上取整,\(r=11\) 恰好 3 遍,\(r=14\) 也是 3 遍但计数数组大 8 倍,所以 11 更划算。

基数排序 vs 快速排序

若 \(b=O(\lg n)\)、取 \(r\approx\lg n\),基数排序是 \(\Theta(n)\),渐近上优于快速排序的 \(\Theta(n\lg n)\)。但 \(\Theta\) 里藏着不同的常数:基数排序遍数少,每遍却可能慢得多(随机写入计数数组对 cache 不友好);快速排序顺序扫描数组,更善于利用 cache。再者,以计数排序为子程序的基数排序不是原地的。实践中怎么选取决于实现、机器和数据,需要实测。NumPy 的做法可作参考:kind='stable' 对 16 位及以下整数类型用基数排序,其他类型用 Timsort。

变长数据(思考题 8-3)

  • 整数位数不同、总位数为 \(n\):先按位数分组(没有前导零时,位数多的数更大),每组内部做基数排序,总时间 \(O(n)\)。
  • 字符串总字符数为 \(n\),按字典序排序(\(a<ab<b\)):按首字符用计数排序分组,组内对剩余字符递归,已经用完的短串排在组首,总时间 \(O(n)\)。这是 MSD(最高位优先)基数排序的思路,股票代码、合约代码这类定长短字符串都可以这样排。

8.4 桶排序

思想

桶排序(bucket sort)假设输入由随机过程产生,在 \([0,1)\) 上独立均匀分布。把 \([0,1)\) 等分成 \(n\) 个桶(bucket),把 \(n\) 个数分进去;因为分布均匀,每个桶里预期只有很少几个数。每个桶内部排序,再按桶的顺序首尾相接。

BUCKET-SORT(A)
1  n = A.length
2  let B[0..n-1] be a new array of empty lists
3  for i = 1 to n
4      insert A[i] into list B[floor(n * A[i])]
5  for i = 0 to n - 1
6      sort list B[i] with insertion sort
7  concatenate the lists B[0], B[1], ..., B[n-1] together in order

例(原书图 8.4):\(n=10\),\(A=\langle.78,.17,.39,.26,.72,.94,.21,.12,.23,.68\rangle\)。桶 \(i\) 存放 \([i/10,(i+1)/10)\) 中的值:\(B[1]=\{.12,.17\}\),\(B[2]=\{.21,.23,.26\}\),\(B[3]=\{.39\}\),\(B[6]=\{.68\}\),\(B[7]=\{.72,.78\}\),\(B[9]=\{.94\}\),依次连接即有序。

正确性:若 \(A[i]\le A[j]\),则 \(\lfloor nA[i]\rfloor\le\lfloor nA[j]\rfloor\),\(A[i]\) 要么与 \(A[j]\) 同桶(桶内排序负责),要么在更靠前的桶(连接顺序负责)。

平均情况分析

除第 6 行外,各行最坏都是 \(O(n)\)。设 \(n_i\) 是落入桶 \(i\) 的元素个数(随机变量),插入排序是二次的,所以

\[ T(n)=\Theta(n)+\sum_{i=0}^{n-1}O(n_i^2),\qquad E[T(n)]=\Theta(n)+\sum_{i=0}^{n-1}O\big(E[n_i^2]\big). \tag{8.1} \]

断言:对每个 \(i\),

\[ E[n_i^2]=2-\frac1n. \tag{8.2} \]

证明:令 \(X_{ij}=I\{A[j]\text{ 落入桶 }i\}\),则 \(n_i=\sum_{j=1}^nX_{ij}\)。展开平方:

\[ E[n_i^2]=\sum_{j=1}^{n}E[X_{ij}^2]+\sum_{j}\sum_{k\ne j}E[X_{ij}X_{ik}]. \]

\(X_{ij}\) 以概率 \(1/n\) 取 1,所以 \(E[X_{ij}^2]=1/n\);\(k\ne j\) 时 \(X_{ij}\) 与 \(X_{ik}\) 独立,\(E[X_{ij}X_{ik}]=1/n^2\)。代入:

\[ E[n_i^2]=n\cdot\frac1n+n(n-1)\cdot\frac1{n^2}=1+\frac{n-1}{n}=2-\frac1n.\qquad\blacksquare \]

推导拆解:逐步看清每一步用了什么。 (a) \(n_i=\sum_j X_{ij}\):桶 \(i\) 里的个数,就是"第 1 个数是否落入桶 \(i\)"+"第 2 个数是否落入"+……,每项是 0 或 1。 (b) 展开平方:\((X_{i1}+\cdots+X_{in})^2\) 展开后有 \(n^2\) 项,其中 \(j=k\) 的 \(n\) 项是 \(X_{ij}^2\),其余 \(n(n-1)\) 项是交叉项 \(X_{ij}X_{ik}\)。再对整体取期望,用期望的线性性把期望分配到每一项上。 (c) \(X_{ij}^2=X_{ij}\),因为 0 和 1 的平方还是自己,所以 \(E[X_{ij}^2]=\Pr\{\text{落入桶 }i\}=1/n\)(\(n\) 个等宽桶,均匀分布落入每个的概率相同)。 (d) 交叉项要用独立性:不同输入独立抽取,所以 \(E[X_{ij}X_{ik}]=E[X_{ij}]E[X_{ik}]=\frac1n\cdot\frac1n\)。 直觉:平均每桶 1 个数,但"平方的平均"是 2 左右,因为有的桶 0 个、有的 2–3 个,平方会放大不均匀。插入排序的成本是 \(n_i^2\) 量级,所以要看 \(E[n_i^2]\) 而不是 \(E[n_i]\);好在它是常数,\(n\) 个桶加起来仍是线性。

另一种看法:\(n_i\sim\text{Binomial}(n,1/n)\),\(E[n_i^2]=\text{Var}(n_i)+(E[n_i])^2=(1-1/n)+1\)。注意 \(E[n_i^2]\ne(E[n_i])^2=1\)(练习 8.4-3 用抛两次硬币的例子说明 \(E[X^2]=3/2\ne E^2[X]=1\))。

代回 (8.1),平均运行时间是 \(\Theta(n)+n\cdot O(2-1/n)=\Theta(n)\)。

推广与局限

  • 不一定要均匀:只要各桶大小的平方和的期望与 \(n\) 成线性,桶排序就是线性时间。
  • 最坏情况是所有元素落进同一个桶,\(\Theta(n^2)\);把桶内排序换成归并排序或堆排序,最坏降到 \(O(n\lg n)\),平均仍为线性(练习 8.4-2)。
  • 按等概率而不是等宽分桶:单位圆内均匀分布的点按到原点距离排序(练习 8.4-4★),距离 \(d\) 不是均匀的,但面积是:第 \(i\) 个桶取 \(\sqrt{i/n}\le d<\sqrt{(i+1)/n}\),或者直接对 \(d^2\) 做桶排序。
  • 任意已知连续分布(练习 8.4-5★):若 \(X_1,\dots,X_n\) 独立同分布,分布函数 \(P(x)=\Pr\{X\le x\}\) 连续且可 \(O(1)\) 计算,则 \(P(X_i)\) 在 \([0,1]\) 上均匀,且 \(P\) 单调保序。对 \(P(X_i)\) 做桶排序即可平均线性时间排序。这就是统计学里的概率积分变换。

金融直觉:概率积分变换就是你熟悉的"z 值换百分位"。若某因子得分服从标准正态,得分 1.28 对应 \(\Phi(1.28)\approx0.90\),即"第 90 百分位"。把所有得分都换成百分位后,它们就均匀铺在 \([0,1]\) 上,等宽分桶自然每桶人数差不多。为什么 \(P(X)\) 是均匀的:\(\Pr\{P(X)\le u\}=\Pr\{X\le P^{-1}(u)\}=P(P^{-1}(u))=u\),这正是 \([0,1]\) 上均匀分布的分布函数。代价是你必须知道真实分布;分布假设错了(如收益率有厚尾却按正态处理),百分位就不准,桶就不均匀,8.5.2 节的实验会演示这一点。


8.5 量化实战

8.5.1 计数排序与基数排序的实现

import numpy as np

def counting_sort(A, k, key=lambda v: v):
    """稳定计数排序:key(v) 取值于 0..k。时间 Θ(n+k),额外空间 Θ(n+k)。"""
    C = [0] * (k + 1)
    for v in A:
        C[key(v)] += 1                 # C[i] = 等于 i 的个数
    for i in range(1, k + 1):
        C[i] += C[i - 1]               # C[i] = 小于等于 i 的个数
    B = [None] * len(A)
    for v in reversed(A):              # 从后往前放,保证稳定
        C[key(v)] -= 1
        B[C[key(v)]] = v
    return B

def radix_sort(A, b=32, r=8):
    """LSD 基数排序:把 b 位非负整数看成 ceil(b/r) 个 r 位“数字”,每位用稳定计数排序。"""
    mask = (1 << r) - 1
    for shift in range(0, b, r):
        A = counting_sort(A, mask, key=lambda v: (v >> shift) & mask)
    return A

# 原书图 8.2 与图 8.3 的例子
print("图8.2:", counting_sort([2, 5, 3, 0, 2, 3, 0, 3], 5))
A = [329, 457, 657, 839, 436, 720, 355]
for d in range(3):                                      # 十进制逐位,打印每一遍的结果
    A = counting_sort(A, 9, key=lambda v: v // 10**d % 10)
    print(f"图8.3 第{d+1}遍(按第{d+1}位):", A)

# 稳定性演示:按“评级”排序,同评级保持原有(代码)顺序
recs = [("600519", 2), ("000001", 0), ("600036", 2), ("000858", 1), ("601318", 0)]
print("按评级稳定排序:", counting_sort(recs, 2, key=lambda t: t[1]))

# 随机测试:32 位整数,r 取 4/8/11/16 都应正确
rng = np.random.default_rng(3)
x = rng.integers(0, 2**32, 20000).tolist()
for r in (4, 8, 11, 16):
    assert radix_sort(x, 32, r) == sorted(x)
n = len(x)
for r in (4, 8, 11, 16):
    passes = -(-32 // r)
    print(f"r={r:>2d}: 遍数 d={passes},理论代价 (b/r)(n+2^r) ≈ {passes * (n + 2**r):>7d}")
print("lg n ≈", round(np.log2(n), 1))

# 练习 8.2-4:预处理前缀计数后,O(1) 回答“有多少数落在 [a, b]”
vals = rng.integers(0, 100, 1000)
C = np.cumsum(np.bincount(vals, minlength=100))
a, b = 20, 35
print("落在 [20,35] 的个数:", C[b] - C[a - 1], "  直接数:", int(((vals >= a) & (vals <= b)).sum()))

输出:

图8.2: [0, 0, 2, 2, 3, 3, 3, 5]
图8.3 第1遍(按第1位): [720, 355, 436, 457, 657, 329, 839]
图8.3 第2遍(按第2位): [720, 329, 436, 839, 355, 457, 657]
图8.3 第3遍(按第3位): [329, 355, 436, 457, 657, 720, 839]
按评级稳定排序: [('000001', 0), ('601318', 0), ('000858', 1), ('600519', 2), ('600036', 2)]
r= 4: 遍数 d=8,理论代价 (b/r)(n+2^r) ≈  160128
r= 8: 遍数 d=4,理论代价 (b/r)(n+2^r) ≈   81024
r=11: 遍数 d=3,理论代价 (b/r)(n+2^r) ≈   66144
r=16: 遍数 d=2,理论代价 (b/r)(n+2^r) ≈  171072
lg n ≈ 14.3
落在 [20,35] 的个数: 146   直接数: 146

图 8.2、图 8.3 的结果与原书一致。"按评级稳定排序"一行中,评级同为 0 的 000001 与 601318、同为 2 的 600519 与 600036 都保持了原来的先后。基数排序对 \(r=4,8,11,16\) 都正确;理论代价在 \(r=11\)(接近 \(\lg n\approx14\),且 \(32/11\) 向上取整只要 3 遍)时最小,\(r=16\) 时计数数组长 65536,比 \(n\) 还大,代价反而上升。

8.5.2 价格档位、成交量分布、分位数分组与均线

import numpy as np, pandas as pd
from scipy.stats import norm

rng = np.random.default_rng(8)

# ---------- (1) 价格离散化为整数 tick:计数排序 = 成交量分布(volume profile) ----------
tick = 0.01
px = np.round(10 + np.cumsum(rng.normal(0, 0.01, 50_000)), 2)     # 一天的逐笔成交价
vol = rng.integers(1, 50, px.size) * 100
lo = px.min()
idx = np.rint((px - lo) / tick).astype(int)          # 价格 -> 0..k 的整数下标
k = idx.max()
profile = np.bincount(idx, weights=vol, minlength=k + 1)      # 每个价位的成交量,Θ(n+k)
cum = np.concatenate([[0], np.cumsum(profile)])               # 前缀和,用于 O(1) 区间查询
def volume_between(p1, p2):                                    # [p1, p2] 价位区间内的总成交量
    i = int(round((p1 - lo) / tick)); j = int(round((p2 - lo) / tick))
    return cum[j + 1] - cum[i]
poc = lo + profile.argmax() * tick                            # 成交量最大的价位(POC)
print(f"价位数 k+1 = {k+1},逐笔数 n = {px.size},成交量最大价位 = {poc:.2f}")
print("区间 [poc-0.05, poc+0.05] 成交量占比 = %.3f" % (volume_between(poc - 0.05, poc + 0.05) / vol.sum()))
order = np.argsort(idx, kind="stable")                        # 按价位稳定排序,用作核对
assert np.array_equal(np.sort(px), px[order])

# ---------- (2) 桶排序 + 概率积分变换(练习 8.4-5):已知分布时线性时间分组 ----------
def bucket_sort_known_cdf(x, cdf):
    n = len(x)
    B = [[] for _ in range(n)]
    for v in x:
        B[min(int(n * cdf(v)), n - 1)].append(v)       # P(X) ~ U[0,1),均匀落入 n 个桶
    sizes = np.array([len(b) for b in B])
    out = [v for b in B for v in sorted(b)]             # 桶内元素很少,任何排序都行
    return out, sizes

n = 2000
z = rng.standard_normal(n)                              # 已标准化的因子得分,近似 N(0,1)
out, sizes = bucket_sort_known_cdf(z.tolist(), norm.cdf)
assert out == sorted(z.tolist())
print(f"桶大小平方均值 = {np.mean(sizes**2):.3f},理论 E[n_i^2] = 2 - 1/n = {2 - 1/n:.4f}")

# 十分组:用 CDF 直接分桶 vs 按截面排名分组
dec_cdf = np.minimum((norm.cdf(z) * 10).astype(int), 9)
dec_rank = pd.qcut(pd.Series(z).rank(method="first"), 10, labels=False).to_numpy()
print("CDF 分组各组人数:", np.bincount(dec_cdf, minlength=10).tolist())
print("排名分组各组人数:", np.bincount(dec_rank, minlength=10).tolist())
print("两种分组一致的比例 = %.3f" % (dec_cdf == dec_rank).mean())

# 分布假设不对时(厚尾 t(3) 得分却按正态 CDF 分桶),桶就不均匀了
zt = rng.standard_t(3, n) / np.sqrt(3)                  # 方差为 1 的 t(3)
_, sizes_t = bucket_sort_known_cdf(zt.tolist(), norm.cdf)
print(f"厚尾数据按正态分桶: 桶大小平方均值 = {np.mean(sizes_t**2):.3f},最大桶 = {sizes_t.max()}")

# ---------- (3) 思考题 8-5:k 有序 <=> 滑动 k 期均值单调不减 <=> A[i] <= A[i+k] ----------
p = 100 + np.cumsum(rng.normal(0.05, 1, 300))
kk = 20
ma = pd.Series(p).rolling(kk).mean().to_numpy()
up_ma = np.diff(ma[kk - 1:]) >= 0                      # 均线上行的日子
up_kk = p[kk:] >= p[:-kk]                              # 今日价格 >= k 日前价格
print("“均线上行”与“今日价 >= k 日前价”逐日一致:", bool(np.array_equal(up_ma, up_kk)))

输出:

价位数 k+1 = 326,逐笔数 n = 50000,成交量最大价位 = 10.67
区间 [poc-0.05, poc+0.05] 成交量占比 = 0.075
桶大小平方均值 = 2.003,理论 E[n_i^2] = 2 - 1/n = 1.9995
CDF 分组各组人数: [222, 212, 199, 178, 193, 194, 180, 200, 204, 218]
排名分组各组人数: [200, 200, 200, 200, 200, 200, 200, 200, 200, 200]
两种分组一致的比例 = 0.916
厚尾数据按正态分桶: 桶大小平方均值 = 2.238,最大桶 = 13
“均线上行”与“今日价 >= k 日前价”逐日一致: True

(1)价格档位就是天然的计数排序输入。 股票、期货价格只能落在最小变动价位(tick size)的整数倍上,一天之内的价格范围通常只有几百个价位,\(k\ll n\)。把价格换成整数价位下标后:np.bincount 一趟得到每个价位的成交量(成交量分布,volume profile),前缀和让"某价位区间成交了多少"变成 \(O(1)\) 查询,找成交最密集的价位(POC)也只要一次 argmax。同样的"值作下标"思想用于订单簿:在价格范围有限时,用一个按价位下标直接寻址的数组存放各档挂单量,读写某一档都是 \(O(1)\),这是许多撮合引擎和行情重建程序的实际做法(比第 06 章的堆更快,代价是要预知价格范围)。

(2)桶排序与分位数分组。 因子研究中常把截面得分分成十组(十分组回测)。如果得分已经标准化且近似正态,用正态分布函数 \(\Phi\) 把它映射到 \([0,1)\) 后乘 10 取整,就能在 \(O(n)\) 内直接得到组号;把桶数取成 \(n\),就是练习 8.4-5 的线性排序,实验中桶大小平方的均值 2.003 与理论值 \(2-1/n\) 吻合。但要清楚两者的区别:

  • 按排名分组,每组恰好 \(n/10\) 只股票,与分布无关;排名需要排序(或第 09 章的选择算法)。
  • 按假定分布的 CDF 分组,只在分布假设正确时近似等人数(实验中各组 178–222 只,与排名分组的一致率约 92%);分布假设错了,桶就不均匀——厚尾数据按正态分桶时,最大的桶有 13 个元素,平方和也偏离了理论值。

实际研究中,截面分组一般用排名或经验分位数(pd.qcut),因为每期样本的分布并不稳定;按理论 CDF 分桶适合分布已知且稳定的场合(例如对已经做过排名-正态化的得分再分组,或对 p 值、均匀化后的分数分桶)。

(3)思考题 8-5 与均线。 若对所有 \(i\) 都有

\[ \frac{1}{k}\sum_{j=i}^{i+k-1}A[j]\le\frac1k\sum_{j=i+1}^{i+k}A[j], \]

称数组 \(A\) 是 \(k\) 有序的(\(k\)-sorted)。两边相减,左右两个和只差首尾两项,所以 \(k\) 有序当且仅当 \(A[i]\le A[i+k]\) 对所有 \(i\) 成立。翻译成交易语言:**\(k\) 日均线今天上行,等价于今天的价格不低于 \(k\) 日前的价格。**均线方向其实只是一个 \(k\) 期动量信号的符号,并不包含更多信息。实验逐日验证了这一点。原书在这道题里还给出:把下标模 \(k\) 同余的 \(k\) 个子序列各自排序,就能在 \(O(n\lg(n/k))\) 时间把数组变成 \(k\) 有序;反过来,把 \(k\) 有序的数组完全排序,可以用第 06 章的 \(k\) 路归并在 \(O(n\lg k)\) 内完成;而 \(k\) 为常数时,"\(k\) 排序"本身也需要 \(\Omega(n\lg n)\)。


8.6 其他思考题简介

以下内容与量化实践关系较弱,了解它们讲什么即可,需要时回原书。

  • 水壶问题(思考题 8-4):\(n\) 个红壶与 \(n\) 个蓝壶容量一一配对,只能拿一红一蓝比较。暴力配对 \(\Theta(n^2)\);决策树给出 \(\Omega(n\lg n)\) 下界(\(n!\) 种配对、每次比较三种结果,高度至少 \(\log_3 n!\));随机选一个红壶划分蓝壶、找到配对的蓝壶再划分红壶,按快速排序的方式递归,期望 \(O(n\lg n)\)。这就是著名的"螺母与螺栓"问题。
  • 0-1 排序引理与列排序(思考题 8-7):无关比较交换算法(oblivious compare-exchange algorithm)的比较位置事先固定,与数据无关。0-1 排序引理说:这样的算法只要能排好所有 0-1 输入,就能排好任意输入。原书用它证明 Leighton 的列排序(columnsort):在 \(r\times s\) 矩阵(\(r\) 偶数、\(s\) 整除 \(r\)、\(r\ge2s^2\))上,交替做"各列排序"和固定置换,共 8 步完成排序。它适合并行和外存场景:每一步只需对能装进内存的一列排序。

本章小结

比较排序的决策树至少要有 \(n!\) 片叶子,所以高度至少 \(\lg n!=\Omega(n\lg n)\);这个下界对最坏、平均、随机化情形都成立,归并排序和堆排序因此渐近最优。要更快,就必须利用比较以外的信息。计数排序把小范围整数当数组下标,三趟扫描完成,\(\Theta(n+k)\),从后往前放置保证稳定。基数排序从最低位开始逐位稳定排序,\(b\) 位整数每遍取 \(r\approx\lg n\) 位时为 \(\Theta(bn/\lg n)\)。桶排序假设输入在 \([0,1)\) 上均匀,平均 \(\Theta(n)\),关键是 \(E[n_i^2]=2-1/n\);已知分布时用概率积分变换先化为均匀。量化里最常用的是"值作下标":价格档位直方图、成交量分布、区间计数和按档位寻址的订单簿。

概念/公式 内容
比较排序下界 \(n!\le l\le2^h\Rightarrow h\ge\lg n!=\Omega(n\lg n)\)
分块排序下界 \(\frac nk\lg k!=\Omega(n\lg k)\)
合并下界 \(2n-1\) 次比较
计数排序 \(\Theta(n+k)\),稳定,非原地
区间计数 前缀计数 \(C\),答案 \(C[b]-C[a-1]\)
基数排序 \(\Theta(d(n+k))\);\(b\) 位数 \(\Theta((b/r)(n+2^r))\),取 \(r=\lfloor\lg n\rfloor\)
桶排序 \(E[T(n)]=\Theta(n)+\sum O(E[n_i^2])\),\(E[n_i^2]=2-1/n\)
概率积分变换 \(X\sim P\Rightarrow P(X)\sim U[0,1]\)
\(k\) 有序 滑动 \(k\) 期均值不减 \(\iff A[i]\le A[i+k]\)

练习

基础

  1. 演示计数排序作用于 \(\langle6,0,2,0,1,3,4,6,1,3,2\rangle\),写出前缀和之后的 \(C\)。(练习 8.2-1) 答案要点:计数 \(C=\langle2,2,2,2,1,0,2\rangle\),前缀和 \(\langle2,4,6,8,9,9,11\rangle\),输出 \(\langle0,0,1,1,2,2,3,3,4,6,6\rangle\)。
  2. 把计数排序第 8 行改成 for j = 1 to A.length,算法还正确吗?还稳定吗?(练习 8.2-3) 提示:正确但不稳定,同值元素次序反转。
  3. 哪些排序算法是稳定的:插入、归并、堆、快速?给出让任意排序算法稳定的通用办法及其代价。(练习 8.3-2) 提示:插入、归并稳定;把原下标作为次关键字,额外 \(\Theta(n)\) 空间(每个下标 \(\lg n\) 位),时间只多常数因子。
  4. 如何在 \(O(n)\) 时间内排序 \(n\) 个取值于 \(0..n^3-1\) 的整数?(练习 8.3-4) 提示:看成 3 位 \(n\) 进制数,基数排序 3 遍,每遍计数排序 \(\Theta(n)\)。
  5. 设 A 股某日全市场约 5000 只股票的收益率已四舍五入到 0.01%,涨跌幅限制在 \(\pm20\%\) 内。说明如何在线性时间内得到全市场收益率的排序和任意分位数。 提示:映射为 \(0..4000\) 的整数,\(k=4001=O(n)\),计数排序;前缀计数直接给出分位数位置。

进阶

  1. 证明 \(\lg(n!)=\Theta(n\lg n)\),不使用斯特林公式。(练习 8.1-2) 提示:上界 \(\lg n!\le n\lg n\);下界只保留后一半项,每项至少 \(\lg(n/2)\)。
  2. 用归纳法证明基数排序正确,并指出证明中哪一步用到了稳定性。(练习 8.3-3) 提示:见 8.3 节"为什么必须稳定"。
  3. 给定 \(n\) 个 32 位整数,\(n=10^6\)。按 \((b/r)(n+2^r)\) 计算 \(r=8,16,20\) 时的代价,哪个最好?若考虑 cache(计数数组超过 L2 缓存会变慢),你会怎么选? 提示:\(r=16\) 理论最好(2 遍,计数数组 65536);\(r=20\) 要 2 遍且计数数组达百万级,cache 未命中多;实践中常取 8 或 11。
  4. 练习 8.4-5★:从已知连续分布 \(P\) 抽样的 \(n\) 个数,给出平均线性时间的排序算法,并说明在因子十分组中用它的前提条件。 提示:对 \(P(X_i)\) 桶排序;前提是截面得分确实服从 \(P\) 且各股独立。
  5. (思考题 8-5)证明把 \(k\) 有序数组完全排序需要 \(O(n\lg k)\) 时间,并说明为什么当 \(k\) 为常数时,把任意数组变成 \(k\) 有序仍需 \(\Omega(n\lg n)\)。 提示:\(k\) 个同余子序列各自有序,用 \(k\) 路归并;若 \(k\) 排序能 \(o(n\lg n)\),再加 \(O(n\lg k)\) 的归并就能 \(o(n\lg n)\) 完全排序,与定理 8.1 矛盾。

原书推荐习题:8.1-3、8.1-4、8.2-3、8.2-4、8.3-2、8.3-4、8.4-2、8.4-5;思考题 8-1、8-4、8-5、8-6。


原书对照

本章小节 原书章节 PDF 页码
8.1 比较排序的下界 8.1 Lower bounds for sorting p.212–215
8.2 计数排序 8.2 Counting sort p.215–218
8.3 基数排序 8.3 Radix sort p.218–221
8.4 桶排序 8.4 Bucket sort p.221–226
8.6 思考题简介 Chapter 8 Problems(8-1 至 8-7)与 Notes p.226–233