精读笔记:Cormen, Leiserson, Rivest, Stein《Introduction to Algorithms》(第 3 版,MIT Press 2009)|负责 PDF 第 1–248 页(前言、Part I Foundations、Part II Sorting and Order Statistics)
说明:本块从封面开始,到 Part II(第 9 章及章末)结束、Part III 开篇之前。原书的伪代码统一改写为 Python 风格伪代码(数组下标沿用原书的 1 起始约定,除非另行说明),并给出时间/空间复杂度。原书带 ★(星号)的节为研究生难度,笔记中照样标注。
封面、版权页与目录(PDF p.1–13)
- PDF p.1–4:封面与扉页。书名 Introduction to Algorithms,第 3 版(Third Edition),作者 Thomas H. Cormen、Charles E. Leiserson、Ronald L. Rivest、Clifford Stein,MIT Press(Cambridge, Massachusetts / London, England)。业内常按作者首字母简称 CLRS。
- PDF p.5:版权页,© 2009 MIT;排版字体 Times Roman + MathTime Pro 2;ISBN 978-0-262-03384-8(精装)/ 978-0-262-53305-8(平装)。
- PDF p.6–12:总目录。全书八个部分:
- Part I 基础(Foundations):第 1 章 算法在计算中的作用;第 2 章 入门(插入排序、算法分析、算法设计/归并排序);第 3 章 函数的增长(渐近记号、标准记号与常用函数);第 4 章 分治(最大子数组、Strassen 矩阵乘法、代入法、递归树法、主方法、★主定理证明);第 5 章 概率分析与随机算法(雇佣问题、指示器随机变量、随机算法、★概率分析的进一步应用)。
- Part II 排序与顺序统计量(Sorting and Order Statistics):第 6 章 堆排序;第 7 章 快速排序;第 8 章 线性时间排序;第 9 章 中位数与顺序统计量。
- Part III 数据结构(栈/队列/链表、散列表、二叉搜索树、红黑树、数据结构扩张);Part IV 高级设计与分析技术(动态规划、贪心、摊还分析);Part V 高级数据结构(B 树、斐波那契堆、van Emde Boas 树、不相交集合);Part VI 图算法(BFS/DFS、最小生成树、单源/全源最短路径、最大流);Part VII 精选主题(多线程算法、矩阵运算、线性规划、多项式与 FFT、数论算法、字符串匹配、计算几何、NP 完全性、近似算法);Part VIII 附录:数学背景(A 求和、B 集合等、C 计数与概率、D 矩阵);参考文献、索引。
- PDF p.13:空白页。
前言(Preface,PDF p.14–20)
- 定位:全面介绍现代算法研究,覆盖面广且有深度,但力求讲解浅显而不牺牲数学严谨性。每章讲一个算法、一种设计技术、一个应用领域或相关主题。算法用英文描述并配可读性高的伪代码;全书 244 幅图;强调效率,对所有算法做运行时间分析。适合本科/研究生算法或数据结构课程,也适合工程师自学。
- 致教师:全书像“自助餐”(smorgasbord),各章相对自成一体;每章先易后难,节的边界是自然的停顿点。共有 957 道练习(exercises,节末,短小,检验基本掌握)和 158 道思考题(problems,章末,较长的案例研究,常引入新内容并分步引导)。网站 http://mitpress.mit.edu/algorithms/ 提供部分题解。带星号(★)的节和习题更适合研究生,未必更难,但可能需要更高深的数学。
- 致学生:逐步讲解每个算法,并仔细解释分析所需数学;希望本书既是教材也是日后可查的数学参考与工程手册。
- 先修要求:有编程经验(理解递归过程、数组、链表等简单数据结构);熟悉数学证明,尤其数学归纳法;少量内容用到初等微积分。其余数学由 Part I 和 Part VIII(附录)提供。
- 致专业人员:可作算法手册;讨论实现与工程问题,对主要具有理论意义的算法常给出实用替代方案;伪代码不处理错误处理等软件工程细节。
- 致同行:每章章末注记(chapter notes)给出历史与参考文献;出于政策不为习题提供出处,以免学生直接查答案。
- 第 3 版主要变化:新增 van Emde Boas 树与多线程算法两章,矩阵基础单独成附录 D;递归式一章改写为更广的“分治”章,前两节用分治解两个问题(最大子数组、Strassen 算法,后者从矩阵运算章移来);删去二项堆与排序网络两章(排序网络中的 0-1 原理改为思考题 8-7 的 0-1 排序引理);斐波那契堆不再依赖二项堆;动态规划改以钢条切割(rod cutting)开篇,更强调备忘录(memoization)并引入子问题图;贪心以活动选择问题更直接地引出;二叉搜索树删除改为保证真正删除的是被请求的那个结点(避免外部指针悬空);流网络改为完全基于边上的流;KMP 处理方式修改;伪代码语法改为用
=赋值、==判等(原书用特殊箭头,现与 C/C++/Java/Python 一致),去掉 do、then 关键字,用//作行注释,用点号表示对象属性;伪代码保持过程式而非面向对象。新增 100 道练习、28 道思考题。 - 网站提供勘误、部分题解与“教授冷笑话”解释。排版用 LaTeX2ε,插图用 MacDraw Pro(作者为此保留老式 Mac),索引用自写的 C 程序 Windex,参考文献用 BibTeX。
- 致谢:MIT Press 编辑;技术文字编辑 Julie Sussman;van Emde Boas 树取材于 Erik Demaine 讲义(受 Michael Bender 影响);多线程一章源自与 Harald Prokop 合写的讲义,受 MIT Cilk 项目(Bradley Kuszmaul、Matteo Frigo)影响,伪代码设计受 Cilk/Cilk++ 启发。落款 2009 年 2 月。
- PDF p.21–22:空白页与书名页。
Part I 基础(Foundations)
Part I 导言(PDF p.23–25)
本部分温和地引入如何描述算法、全书反复使用的设计策略和算法分析的基本思想。第 1 章概述算法及其在现代计算系统中的地位,主张把算法视为一种技术(technology),与快速硬件、图形界面、面向对象系统、网络并列。第 2 章给出第一批算法:用增量法(incremental approach)的插入排序和用分治(divide-and-conquer)的归并排序,二者运行时间都随 \(n\) 增长但增长速率不同。第 3 章精确定义渐近记号(asymptotic notation),其余部分主要统一数学记号。第 4 章深入分治,给出 Strassen 矩阵乘法等例子以及求解递归式的方法,尤其是“主方法”(master method);主方法的正确性证明可以跳过而不影响使用。第 5 章介绍概率分析(probabilistic analysis)与随机算法(randomized algorithm):概率分析用于同规模不同输入运行时间不同的情形——要么假设输入服从已知分布从而对所有输入求平均,要么概率来自算法内部的随机选择。随机算法的行为由输入和随机数发生器共同决定,可用来“强加”输入分布,使得没有哪个特定输入总导致坏性能,或者为允许少量出错的算法界定错误率。附录 A–D 是参考材料。
第 1 章 算法在计算中的作用(The Role of Algorithms in Computing)(PDF p.26–36)
1.1 算法(PDF p.26–32)
定义。 非形式地说,算法(algorithm)是任何良定义的计算过程,它取某个值或值的集合作为输入(input),产生某个值或值的集合作为输出(output);即把输入转换为输出的一系列计算步骤。也可把算法看作求解良说明的计算问题(computational problem)的工具:问题陈述以一般术语规定期望的输入/输出关系,算法描述实现该关系的具体计算过程。
排序问题(sorting problem)的形式定义:
- 输入:\(n\) 个数的序列 \(\langle a_1, a_2, \dots, a_n\rangle\)。
- 输出:输入序列的一个排列(permutation,重排)\(\langle a'_1, a'_2, \dots, a'_n\rangle\),满足 \(a'_1 \le a'_2 \le \cdots \le a'_n\)。
例:输入 \(\langle 31, 41, 59, 26, 41, 58\rangle\),输出 \(\langle 26, 31, 41, 41, 58, 59\rangle\)。这样一个具体输入称为问题的一个实例(instance):满足问题约束、计算解所需的输入。
排序是许多程序的中间步骤,因而是计算机科学的基本操作。哪种排序算法最好取决于:待排序项数、数据已部分有序的程度、项值可能的限制、计算机体系结构、存储设备(内存、磁盘甚至磁带)。
正确性。 若对每个输入实例算法都停机并给出正确输出,则称算法正确(correct),称它求解(solves)了该问题。不正确的算法可能不停机,或停机给出错误答案。错误率可控的不正确算法有时也有用(如第 31 章寻找大素数的算法),但本书通常只关注正确算法。算法可以用英文、程序甚至硬件设计来描述,唯一要求是精确描述计算过程。
算法解决哪些问题(举例): 人类基因组计划(识别约 10 万个基因、30 亿碱基对的序列、存储和分析);互联网(数据路由——第 24 章;搜索引擎——第 11、32 章);电子商务(公钥密码与数字签名——第 31 章,基于数值算法与数论);制造与商业中的稀缺资源分配(石油公司钻井选址以最大化期望利润、竞选广告投放、航空公司排班、ISP 资源配置——均可用第 29 章的线性规划求解)。
本书还解决的具体问题:
- 最短路径:路网建模为图,求两顶点间最短路径(第 24 章)。可能路径数量巨大。
- 最长公共子序列(LCS):给定 \(X=\langle x_1,\dots,x_m\rangle\)、\(Y=\langle y_1,\dots,y_n\rangle\),子序列是从 \(X\) 中删去一些(可以全部或不删)元素得到的序列,如 \(\langle A,B,C,D,E,F,G\rangle\) 的子序列 \(\langle B,C,E,G\rangle\)。LCS 长度衡量相似度(如 DNA 链)。\(X\)、\(Y\) 分别有 \(2^m\)、\(2^n\) 个子序列,穷举不可行;第 15 章用动态规划(dynamic programming)高效求解。
- 拓扑排序:机械设计由零件库组成,零件可包含其他零件,要把零件排成每个零件出现在使用它的零件之前。\(n\) 个零件有 \(n!\) 种顺序,阶乘比指数增长还快,不能枚举;第 22 章高效求解。
- 凸包:平面上 \(n\) 个点的凸包(convex hull)是包含所有点的最小凸多边形,直观上是绷在钉子外的橡皮筋。\(2^n\) 个子集都可能是顶点集,且还需知道顶点顺序;第 33 章给两种方法。
这些问题的两个共同特征:(1) 候选解很多,绝大多数不是解,找到一个解或“最好”的解很有挑战;(2) 有实际应用(运输公司找最短路以降低人工和燃料成本、网络路由、驾车导航/GPS)。
并非所有问题都有易识别的候选解集合,例如对信号样本做离散傅里叶变换(DFT),把时域转换为频域,得到各频率强度的系数;DFT 用于信号处理、数据压缩、大整数/多项式乘法。第 30 章给出快速傅里叶变换(FFT)并勾勒硬件电路设计。
数据结构(data structure): 存储和组织数据以便访问和修改的方式。没有一种数据结构适合所有用途,必须了解多种结构的优点和局限。
技术(technique): 本书可当算法“食谱”,但更要教会设计与分析技术,以便自己设计算法、证明正确并理解效率。有些章讲具体问题(第 9 章中位数与顺序统计量、第 23 章最小生成树、第 26 章最大流),有些章讲技术(第 4 章分治、第 15 章动态规划、第 17 章摊还分析)。
难解问题(hard problems): 效率通常以速度衡量。有些问题没有已知高效解,第 34 章研究其中的 NP 完全(NP-complete)问题。它们有趣的原因:(1) 没人找到高效算法,也没人证明不存在——未知;(2) 若其中任何一个有高效算法,则全部都有;(3) 一些 NP 完全问题与已有高效算法的问题非常相似,问题陈述的小改动可导致最佳已知算法效率的巨变。实际意义:若能证明问题是 NP 完全的,就不必徒劳寻找高效精确算法,转而设计给出“好但非最优”解的高效算法。例:配送公司卡车从中心仓库出发、送完货回仓库,要最小化总路程——旅行商问题(traveling-salesman problem),NP 完全;在某些假设下有总距离不比最小值大太多的高效近似算法(approximation algorithms,第 35 章)。
并行(parallelism): 物理限制使时钟频率无法持续提高(功率密度随时钟频率超线性增长,芯片会“熔化”),因此芯片设计为多个处理“核”(core)。多核计算机可视为单芯片上的若干台顺序计算机,即一种并行计算机。要发挥性能需在设计算法时考虑并行。第 27 章给出“多线程”(multithreaded)算法模型,它在理论上有优势,并是若干成功程序(包括一个冠军国际象棋程序)的基础。
练习 1.1(PDF p.32): 1.1-1 举一个需要排序或计算凸包的现实例子;1.1-2 除速度外现实中还有哪些效率度量;1.1-3 选一个见过的数据结构讨论其优缺点;1.1-4 最短路径与旅行商问题的异同;1.1-5 构造一个必须最优解的现实问题和一个近似最优即可的问题。
1.2 作为一种技术的算法(Algorithms as a technology)(PDF p.32–35)
假如计算机无限快、内存免费,还需要研究算法吗?需要——至少要证明方法会终止并给出正确答案。若计算机无限快,任何正确方法都可以,选最容易实现的即可。但计算时间和内存空间都是有限资源,时间或空间高效的算法帮助我们明智使用资源。
效率(efficiency)。 解同一问题的不同算法效率差异巨大,常比硬件/软件差异更显著。插入排序耗时约 \(c_1 n^2\),归并排序约 \(c_2 n\lg n\)(\(\lg n = \log_2 n\)),通常 \(c_1 < c_2\)。把二者写成 \(c_1 n\cdot n\) 与 \(c_2 n\cdot\lg n\),差别在 \(n\) 与 \(\lg n\) 这一因子:\(n=1000\) 时 \(\lg n\approx 10\),\(n=10^6\) 时 \(\lg n\approx 20\)。小规模输入插入排序常更快,但无论 \(c_1\) 比 \(c_2\) 小多少,总存在一个交叉点(crossover point),超过后归并排序更快。
经典数值例子: 计算机 A 每秒执行 \(10^{10}\) 条指令,计算机 B 每秒 \(10^7\) 条(A 比 B 快 1000 倍)。最好的程序员用机器语言在 A 上写插入排序,需 \(2n^2\) 条指令;普通程序员用低效编译器在 B 上写归并排序,需 \(50 n\lg n\) 条指令。排序 \(n=10^7\) 个数(8 字节整数约 80 MB):
B 比 A 快 17 倍以上。若排序 1 亿个数,插入排序要超过 23 天,归并排序不到 4 小时。问题规模越大,增长较慢的算法相对优势越大。
算法与其他技术。 应把算法与硬件一样视为技术:系统总性能既取决于快速硬件,也取决于高效算法。即使应用层不显式含算法(如简单 Web 应用),它依赖的硬件设计、GUI、网络路由、编译器/解释器/汇编器都大量使用算法。例如地图出行服务要用最短路径、地图渲染、地址插值等算法。计算机能力增长让我们求解更大规模问题,而恰恰在大规模时算法效率差异最突出。扎实的算法功底是区分熟练程序员与新手的特征之一。
练习 1.2: 1.2-1 举一个应用层需要算法的应用并讨论其中算法的作用;1.2-2 同一机器上插入排序 \(8n^2\) 步、归并排序 \(64 n\lg n\) 步,\(n\) 取何值时插入排序更快(解 \(8n^2 < 64n\lg n\),即 \(n < 8\lg n\),得 \(2\le n\le 43\));1.2-3 求最小的 \(n\) 使 \(100n^2\) 快于 \(2^n\)(答案 \(n=15\))。
思考题 1-1 运行时间比较: 对 \(f(n)\in\{\lg n, \sqrt n, n, n\lg n, n^2, n^3, 2^n, n!\}\) 与时间 \(t\in\{\text{1 秒、1 分、1 小时、1 天、1 月、1 年、1 世纪}\}\),假设算法耗时 \(f(n)\) 微秒,求时间 \(t\) 内能解的最大规模 \(n\)。考查对增长率量级的直觉(如 1 秒 \(=10^6\) 微秒时 \(\lg n\) 可达 \(2^{10^6}\),\(n!\) 只到 9)。
章末注记(PDF p.36): 列出算法通论教材(Aho–Hopcroft–Ullman、Baase–Van Gelder、Brassard–Bratley、Dasgupta–Papadimitriou–Vazirani、Goodrich–Tamassia、Kleinberg–Tardos、Knuth、Sedgewick、Skiena 等),实践方面 Bentley、Gonnet,综述性手册,以及计算生物学算法教材(Gusfield、Pevzner 等)。
第 1 章 本章要点
- 算法是把输入变为输出的良定义计算过程;问题由输入/输出关系规定,实例是具体输入;正确算法对每个实例都停机并输出正确结果。
- 有价值的算法问题通常候选解极多且有实际用途;某些问题(NP 完全)至今无高效精确算法,应转向近似算法。
- 算法是一种技术:增长率的差异(\(n^2\) vs \(n\lg n\))在大规模时压倒常数因子和硬件差距,存在交叉点。
- 硬件趋势使并行(多核)算法设计变得重要。
第 1 章 与量化交易的关联
- 规模直觉直接适用:全市场逐笔数据、多年分钟线、上千因子 × 数千股票的截面计算,\(O(n^2)\) 与 \(O(n\log n)\) 的差别会决定一次回测是几分钟还是几天;做系统设计时先估算 \(n\) 与增长率,再谈硬件和语言优化。
- 书中点名的问题在量化中都有对应:线性规划(第 29 章)用于组合优化与资金分配;最短路径/图算法可用于多资产路径、套利环检测(汇率图中的负环);FFT 用于信号频谱分析与卷积加速;NP 完全性提示带基数约束、整数手数的组合构建问题通常只能求近似/启发式解。
- “不正确但错误率可控”的算法思想对应量化中的近似分位数、采样估计等。
第 1 章 推荐习题
- 1.2-2、1.2-3:体会常数因子与增长率的交叉点。
- 思考题 1-1:建立不同复杂度在实际时间预算下可处理规模的量级感。
- 1.1-4、1.1-5:区分精确最优与近似可接受的问题,培养判断问题难度的意识。
第 2 章 入门(Getting Started)(PDF p.37–63)
本章建立全书设计与分析算法的框架:用插入排序引入伪代码、循环不变式(loop invariant)证明正确性、运行时间分析;再引入分治法并设计归并排序,最后分析其运行时间。
2.1 插入排序(Insertion sort)(PDF p.37–44)
问题与术语。 输入/输出同第 1 章排序问题。待排序的数也称关键字(keys);输入以含 \(n\) 个元素的数组形式给出。伪代码类似 C/C++/Java/Python/Pascal,但可嵌入英文、忽略数据抽象/模块化/错误处理等软件工程问题。
直观。 插入排序适合排序少量元素,像整理手中扑克牌:左手起初为空,牌面朝下放在桌上;每次从桌上取一张牌,从右到左与手中牌比较,插入正确位置。任何时刻左手的牌都已排好序,且是原先牌堆顶部的那些牌(图 2.1)。
原地排序(in place): 在数组 \(A\) 内重排,任何时刻至多常数个元素存放在数组之外。
伪代码(INSERTION-SORT,下标 1 起始,A.length = n):
def insertion_sort(A): # A[1..n]
for j in range(2, n + 1): # 第1行:j = 2 .. n
key = A[j] # 第2行
# 把 A[j] 插入已排好序的 A[1..j-1]
i = j - 1 # 第4行
while i > 0 and A[i] > key: # 第5行
A[i + 1] = A[i] # 第6行:右移一位
i = i - 1 # 第7行
A[i + 1] = key # 第8行
复杂度:最好 \(\Theta(n)\)(已排序),最坏/平均 \(\Theta(n^2)\);额外空间 \(O(1)\)(原地);稳定(相等元素不越过彼此,因为判断条件为严格 >)。
例(图 2.2): \(A=\langle 5,2,4,6,1,3\rangle\),依次得到 \(\langle 2,5,4,6,1,3\rangle\to\langle 2,4,5,6,1,3\rangle\to\langle 2,4,5,6,1,3\rangle\to\langle 1,2,4,5,6,3\rangle\to\langle 1,2,3,4,5,6\rangle\)。
循环不变式与正确性。 不变式:在第 1–8 行 for 循环每次迭代开始时,子数组 \(A[1..j-1]\) 由原来在 \(A[1..j-1]\) 中的元素组成,且已按序排列。证明循环不变式需三点:
- 初始化(Initialization):第一次迭代前为真;
- 保持(Maintenance):某次迭代前为真,则下次迭代前仍为真;
- 终止(Termination):循环终止时,不变式给出一个有助于证明算法正确的有用性质。
前两条类似数学归纳法的基础步和归纳步;第三条最重要,通常把不变式与导致循环终止的条件结合使用,且“归纳”在循环终止时停止,而不是无限进行。对插入排序:初始化时 \(j=2\),\(A[1..1]\) 只有原元素 \(A[1]\),平凡有序;保持:循环体把 \(A[j-1],A[j-2],\dots\) 右移直到找到 \(A[j]\) 的正确位置并插入,于是 \(A[1..j]\) 由原 \(A[1..j]\) 的元素有序组成,\(j\) 加一后不变式保持(严格证明还需为内层 while 循环另立不变式,此处从略);终止:for 循环在 \(j>n\) 时终止,此时 \(j=n+1\),代入得 \(A[1..n]\) 即整个数组已排序。脚注:对 for 循环,“第一次迭代前”指给循环计数器赋初值之后、首次检查循环头条件之前。
伪代码约定(第 3 版):
- 缩进表示块结构(含 if-else;else 与对应 if 同缩进;多路测试用 elseif;省略 then,但称 if 为真时执行的部分为 then 子句)。每个伪代码过程都放在一页内。
- while、for、repeat-until、if-else 含义同 C/Java/Python/Pascal;循环退出后计数器保留其值——for 循环结束后计数器值为第一个超过上界的值(插入排序正确性证明用到了 \(j=n+1\))。for 用
to表示递增、downto表示递减,步长大于 1 时用by。 //为行注释;多重赋值i = j = e等价于先j = e再i = j。- 变量是过程局部的,未经明示不用全局变量。
- 数组用 \(A[i]\) 访问,\(A[1..j]\) 表示子数组。
- 复合数据组织为对象(object),对象有属性(attribute),用点号访问,如
A.length。表示数组或对象的变量被视为指向其数据的指针:y = x后x与y指向同一对象,修改x.f对y.f可见。属性可级联:x.f.g即(x.f).g。不指向任何对象的指针取特殊值 NIL。 - 参数按值传递(by value):被调过程得到参数副本,对参数本身赋值对调用者不可见;传对象时复制的是指针,故
x = y不可见,x.f = 3可见;数组按指针传递,修改元素对调用者可见。 - return 立即返回调用点,可一次返回多个值。
- 布尔运算 and/or 是短路的(short circuiting),因此可写
x != NIL and x.f == y。 - 关键字 error 表示调用条件不满足,由调用者处理。
练习 2.1: 2.1-1 仿图 2.2 演示对 \(\langle 31,41,59,26,41,58\rangle\) 的插入排序;2.1-2 改写为非升序排序(把第 5 行比较改为 A[i] < key);2.1-3 线性查找问题:输入序列 \(A\) 与值 \(v\),输出满足 \(v=A[i]\) 的下标 \(i\),若不存在返回 NIL;写线性查找伪代码并用满足三性质的循环不变式证明正确(不变式:迭代开始时 \(A[1..i-1]\) 中没有 \(v\));2.1-4 两个 \(n\) 位二进制整数存于数组 \(A\)、\(B\),求和存入 \(n+1\) 元数组 \(C\),形式化问题并写伪代码(逐位相加带进位,\(\Theta(n)\))。
2.2 分析算法(Analyzing algorithms)(PDF p.44–50)
分析的含义: 预测算法所需资源——有时是内存、通信带宽、硬件,但最常见的是计算时间。分析多个候选算法通常可以找出最有效的一个,或至少淘汰较差的。
RAM 模型(random-access machine): 通用单处理器随机访问机,指令逐条执行、无并发操作。不严格定义指令集,但以真实计算机为准,不能滥用(例如不能假设有一条“排序”指令)。包含常见指令:算术(加减乘除、取余、floor、ceiling)、数据移动(load、store、copy)、控制(条件/无条件转移、子程序调用与返回),每条耗时常数。数据类型为整数和浮点数(本书通常不关心精度,但某些应用中精度关键)。字长有限:输入规模为 \(n\) 时,整数用 \(c\lg n\) 位表示,\(c\ge 1\) 为常数——\(c\ge1\) 保证一个字能存下 \(n\) 从而能索引各元素,\(c\) 为常数防止字长无限增长(否则可在一个字中存大量数据并常数时间处理,不现实)。
灰色地带: 求幂 \(x^y\) 一般不是常数时间;但左移指令可在常数时间内把整数左移 \(k\) 位,相当于乘以 \(2^k\),因此当 \(k\) 不超过字长时把计算 \(2^k\) 视为常数时间。RAM 模型不模拟存储层次(cache、虚拟内存);有些模型考虑存储层次但复杂得多,而 RAM 分析通常已能很好地预测真实机器性能(少数习题考察存储层次效应)。分析即使简单算法也可能需要组合数学、概率论、代数技巧以及识别公式中最重要项的能力。
输入规模(input size): 依问题而定。排序、DFT 用元素个数 \(n\);整数乘法等用表示输入所需的总位数;图用顶点数和边数两个参数。
运行时间(running time): 在特定输入上执行的基本操作数或“步数”。约定伪代码第 \(i\) 行每次执行耗时常数 \(c_i\)(与 RAM 模型一致)。脚注:用英文描述的步骤(如“按 x 坐标排序”)可能不是常数时间;调用子程序这条语句本身是常数时间,被调子程序的执行另计。
插入排序的精确分析。 对 \(j=2,\dots,n\),令 \(t_j\) 为第 5 行 while 测试对该 \(j\) 执行的次数(循环正常退出时,测试比循环体多执行一次)。各行代价与次数:
| 行 | 代价 | 次数 |
|---|---|---|
1 for j |
\(c_1\) | \(n\) |
2 key = A[j] |
\(c_2\) | \(n-1\) |
| 3 注释 | 0 | \(n-1\) |
4 i = j-1 |
\(c_4\) | \(n-1\) |
| 5 while 测试 | \(c_5\) | \(\sum_{j=2}^n t_j\) |
| 6 右移 | \(c_6\) | \(\sum_{j=2}^n (t_j-1)\) |
7 i = i-1 |
\(c_7\) | \(\sum_{j=2}^n (t_j-1)\) |
| 8 插入 | \(c_8\) | \(n-1\) |
(脚注:这种“代价×次数”对内存等资源不一定成立——执行 \(n\) 次、每次引用 \(m\) 个字的语句未必引用 \(mn\) 个不同的字。)
- 最好情况(已排序):\(t_j=1\),\(T(n)=(c_1+c_2+c_4+c_5+c_8)n-(c_2+c_4+c_5+c_8)\),形如 \(an+b\),线性。
- 最坏情况(逆序):\(t_j=j\)。利用 \(\sum_{j=2}^n j=\frac{n(n+1)}{2}-1\),\(\sum_{j=2}^n (j-1)=\frac{n(n-1)}{2}\),
形如 \(an^2+bn+c\),二次函数。通常对固定输入运行时间是确定的;后面会看到随机算法即使输入固定,行为也会变化。
最坏情况与平均情况分析。 本书通常只求最坏情况运行时间(规模 \(n\) 的任何输入上的最长运行时间),理由:(1) 它是任何输入运行时间的上界,提供保证;(2) 某些算法最坏情况经常出现,如数据库查找不存在的信息;(3) 平均情况往往与最坏情况大致一样差——随机选 \(n\) 个数做插入排序,平均而言 \(A[1..j-1]\) 中一半元素比 \(A[j]\) 小,\(t_j\approx j/2\),平均运行时间仍是 \(n\) 的二次函数。平均情况分析的局限在于“平均输入”不明确,通常假设同规模所有输入等可能;实际中该假设可能不成立,但可以用随机算法做概率分析得到期望运行时间(第 5 章)。
增长量级(order of growth / rate of growth)。 逐步抽象:先忽略各语句实际代价用 \(c_i\) 表示,再把最坏情况写成 \(an^2+bn+c\);进一步只保留最高阶项(低阶项在大 \(n\) 时不重要),并忽略其常系数(常数因子在大输入时不如增长率重要),得插入排序最坏情况运行时间 \(\Theta(n^2)\)(读作 theta of n-squared)。若一个算法最坏情况运行时间的增长量级更低,则通常认为它更有效;小输入时增长量级高的算法可能更快,但输入足够大时 \(\Theta(n^2)\) 算法在最坏情况下总快于 \(\Theta(n^3)\) 算法。
练习 2.2: 2.2-1 用 \(\Theta\) 表示 \(n^3/1000-100n^2-100n+3\)(答:\(\Theta(n^3)\));2.2-2 选择排序(selection sort):每次找出剩余最小元素与 \(A[i]\) 交换,写伪代码、给循环不变式(\(A[1..i-1]\) 是全数组最小的 \(i-1\) 个元素且有序),说明为何只需对前 \(n-1\) 个元素进行(最后一个自然最大),最好与最坏均为 \(\Theta(n^2)\);2.2-3 线性查找平均检查约 \((n+1)/2\) 个元素,最坏 \(n\) 个,平均与最坏都是 \(\Theta(n)\);2.2-4 几乎任何算法都可通过对某个特殊输入直接输出预先算好的答案来获得好的最好情况运行时间(说明最好情况意义不大)。
2.3 设计算法(Designing algorithms)(PDF p.50–60)
插入排序用的是增量法(incremental approach):已排好 \(A[1..j-1]\) 后插入 \(A[j]\) 得到有序 \(A[1..j]\)。本节介绍分治法,用它设计最坏情况运行时间远小于插入排序的排序算法;分治算法的运行时间常可用第 4 章技术方便求出。
2.3.1 分治法(The divide-and-conquer approach)
许多有用算法在结构上是递归的(recursive):为解问题而一次或多次递归调用自身处理紧密相关的子问题。分治法在每层递归上有三步:
- 分解(Divide):把问题分成若干个规模更小的同类子问题;
- 解决(Conquer):递归地求解子问题;子问题足够小时直接求解;
- 合并(Combine):把子问题的解合并成原问题的解。
归并排序(merge sort):分解——把 \(n\) 元序列分成各 \(n/2\) 个元素的两个子序列;解决——递归地用归并排序排序两个子序列;合并——归并两个已排序子序列得到答案。序列长度为 1 时递归“触底”(bottoms out),长度为 1 的序列天然有序。
MERGE 过程。 关键操作是合并步骤中的归并。辅助过程 MERGE\((A,p,q,r)\),其中 \(p\le q<r\),假定 \(A[p..q]\) 与 \(A[q+1..r]\) 已排序,把它们合并成一个有序子数组替换 \(A[p..r]\)。耗时 \(\Theta(n)\),\(n=r-p+1\)。扑克牌类比:桌上两堆牌面朝上、各自有序、最小牌在顶上;每个基本步选出两堆顶上较小的一张,移出并面朝下放到输出堆;某堆空了就把另一堆剩余的整体放到输出堆。每个基本步常数时间,至多 \(n\) 步,故 \(\Theta(n)\)。
哨兵(sentinel)技巧: 在每堆底部放一张值为 \(\infty\) 的哨兵牌,这样无需在每步检查是否有堆为空;露出 \(\infty\) 的牌不可能是较小者,除非两堆都只剩哨兵——此时所有非哨兵牌已全部输出。因为已知恰好输出 \(r-p+1\) 张牌,执行那么多步后停止。
def merge(A, p, q, r):
n1 = q - p + 1
n2 = r - q
L = new_array(1..n1+1); R = new_array(1..n2+1)
for i in 1..n1: L[i] = A[p + i - 1]
for j in 1..n2: R[j] = A[q + j]
L[n1 + 1] = INF # 哨兵
R[n2 + 1] = INF
i = 1; j = 1
for k in p..r:
if L[i] <= R[j]: # 用 <= 保证稳定性
A[k] = L[i]; i += 1
else:
A[k] = R[j]; j += 1
时间 \(\Theta(n)\),额外空间 \(\Theta(n)\)(数组 L、R)。
例(图 2.3): MERGE\((A,9,12,16)\),\(A[9..16]=\langle 2,4,5,7,1,2,3,6\rangle\),复制后 \(L=\langle 2,4,5,7,\infty\rangle\),\(R=\langle 1,2,3,6,\infty\rangle\),逐步输出 \(1,2,2,3,4,5,6,7\),终止时 L、R 中只剩两个哨兵未复制。
MERGE 的循环不变式(第 12–17 行 for 循环): 每次迭代开始时,\(A[p..k-1]\) 按序包含 \(L[1..n_1+1]\) 和 \(R[1..n_2+1]\) 中最小的 \(k-p\) 个元素;并且 \(L[i]\)、\(R[j]\) 分别是各自数组中尚未被复制回 \(A\) 的最小元素。
- 初始化:\(k=p\),\(A[p..k-1]\) 为空,含 0 个最小元素;\(i=j=1\),\(L[1]\)、\(R[1]\) 是各自最小的未复制元素。
- 保持:若 \(L[i]\le R[j]\),则 \(L[i]\) 是尚未复制的最小元素;复制到 \(A[k]\) 后 \(A[p..k]\) 含 \(k-p+1\) 个最小元素;\(k\) 与 \(i\) 增 1 后不变式恢复。\(L[i]>R[j]\) 时对称处理。
- 终止:\(k=r+1\),\(A[p..r]\) 按序包含 \(L\)、\(R\) 中最小的 \(r-p+1\) 个元素;\(L\)、\(R\) 共 \(n_1+n_2+2=r-p+3\) 个元素,除两个最大的(即两个哨兵)外都已复制回 \(A\)。
运行时间:第 1–3、8–11 行常数时间;第 4–7 行 for 循环 \(\Theta(n_1+n_2)=\Theta(n)\);第 12–17 行 \(n\) 次迭代各常数时间。
MERGE-SORT:
def merge_sort(A, p, r):
if p < r: # 至少两个元素
q = (p + r) // 2 # floor
merge_sort(A, p, q) # 左半含 ceil(n/2) 个
merge_sort(A, q + 1, r) # 右半含 floor(n/2) 个
merge(A, p, q, r)
# 初始调用 merge_sort(A, 1, A.length)
复杂度:时间 \(\Theta(n\lg n)\)(最好、最坏、平均相同);空间 \(\Theta(n)\) 辅助数组 + \(\Theta(\lg n)\) 递归栈;稳定;非原地。\(p\ge r\) 时子数组至多一个元素,已有序。脚注:取 \(q=\lfloor (p+r)/2\rfloor\) 使两段大小分别为 \(\lceil n/2\rceil\) 与 \(\lfloor n/2\rfloor\),可按 \(p\)、\(r\) 奇偶分四种情况验证。
例(图 2.4,\(n\) 为 2 的幂时自底向上看): \(A=\langle 5,2,4,7,1,3,2,6\rangle\) → 两两归并得 \(\langle 2,5\rangle,\langle 4,7\rangle,\langle 1,3\rangle,\langle 2,6\rangle\) → \(\langle 2,4,5,7\rangle,\langle 1,2,3,6\rangle\) → \(\langle 1,2,2,3,4,5,6,7\rangle\)。
2.3.2 分析分治算法(Analyzing divide-and-conquer algorithms)
当算法包含对自身的递归调用时,常用递归方程(recurrence equation)或递归式(recurrence)描述其运行时间:用较小输入上的运行时间表示规模 \(n\) 上的总运行时间,再用数学工具求解。
一般分治递归式:设 \(T(n)\) 为规模 \(n\) 的运行时间;若 \(n\le c\)(常数)则直接求解需 \(\Theta(1)\);分解为 \(a\) 个子问题,每个规模为原来的 \(1/b\)(归并排序 \(a=b=2\),但很多分治算法 \(a\ne b\)),分解耗时 \(D(n)\),合并耗时 \(C(n)\):
归并排序分析。 为简化假设 \(n\) 为 2 的幂(第 4 章说明这不影响增长量级)。分解:计算中点,\(D(n)=\Theta(1)\);解决:递归解两个规模 \(n/2\) 的子问题,贡献 \(2T(n/2)\);合并:MERGE 为 \(C(n)=\Theta(n)\)。\(\Theta(n)+\Theta(1)\) 仍是 \(\Theta(n)\),得
第 4 章主定理可证 \(T(n)=\Theta(n\lg n)\)。对数函数比任何线性函数增长都慢,因此大输入下归并排序在最坏情况下胜过 \(\Theta(n^2)\) 的插入排序。
递归树直观解法。 改写为
\(c\) 同时表示解规模 1 问题的时间和分解、合并时每个元素的时间(脚注:若二者不同,取较大者得上界、取较小者得下界,二者都是 \(n\lg n\) 量级,合起来得 \(\Theta(n\lg n)\))。图 2.5:根代价 \(cn\),两棵子树为 \(T(n/2)\);继续展开,第 \(i\) 层(根为第 0 层)有 \(2^i\) 个结点,每个代价 \(c(n/2^i)\),该层总代价 \(2^i\cdot c(n/2^i)=cn\);最底层 \(n\) 个叶子各代价 \(c\),合计 \(cn\)。层数为 \(\lg n+1\)(归纳:\(n=1\) 时 1 层,\(\lg1+1=1\);\(2^i\) 个叶子时有 \(i+1\) 层,\(2^{i+1}\) 个叶子的树多一层,为 \((i+1)+1=\lg 2^{i+1}+1\))。总代价
练习 2.3: 2.3-1 仿图 2.4 演示对 \(\langle 3,41,52,26,38,57,9,49\rangle\) 的归并排序;2.3-2 改写 MERGE 不用哨兵:当 L 或 R 之一全部复制回 A 后,把另一个数组剩余部分直接复制回去;2.3-3 用数学归纳法证明 \(n\) 为 2 的幂时 \(T(2)=2\)、\(T(n)=2T(n/2)+n\) 的解为 \(T(n)=n\lg n\)(归纳步:\(T(2^{k+1})=2\cdot 2^k k+2^{k+1}=2^{k+1}(k+1)\));2.3-4 递归版插入排序(先递归排序 \(A[1..n-1]\) 再插入 \(A[n]\))的最坏情况递归式:\(T(n)=T(n-1)+\Theta(n)\),解为 \(\Theta(n^2)\);2.3-5 二分查找(binary search):有序序列中把中点与 \(v\) 比较,每次排除一半,写伪代码并论证最坏 \(\Theta(\lg n)\);2.3-6 在插入排序内层用二分查找找插入位置能否把最坏时间降到 \(\Theta(n\lg n)\)?(不能:比较次数降为 \(O(n\lg n)\),但元素移动仍需 \(\Theta(n^2)\));2.3-7★ 给定 \(n\) 个整数的集合 \(S\) 与整数 \(x\),在 \(\Theta(n\lg n)\) 时间判断 \(S\) 中是否存在两元素之和恰为 \(x\)(先排序,再用双指针从两端向中间扫描,或对每个元素二分查找 \(x-s\))。
思考题(PDF p.60–63):
-
2-1 在归并排序中对小数组使用插入排序:先用插入排序分别排序 \(n/k\) 个长度为 \(k\) 的子表,再用标准归并机制合并。(a) 证明插入排序可在 \(\Theta(nk)\) 最坏时间内排序这 \(n/k\) 个子表;(b) 说明如何在 \(\Theta(n\lg(n/k))\) 时间内合并这些子表(归并树有 \(\lg(n/k)\) 层,每层 \(\Theta(n)\));(c) 修改后算法最坏时间 \(\Theta(nk+n\lg(n/k))\),要与标准归并排序同为 \(\Theta(n\lg n)\),\(k\) 作为 \(n\) 的函数最大可取 \(\Theta(\lg n)\);(d) 实践中如何选 \(k\)(实测插入排序快于归并排序的最大规模)。这是“粗化递归叶子”(coarsen the leaves)的经典思想。
-
2-2 冒泡排序(bubblesort)的正确性:
def bubblesort(A): for i in 1..n-1: for j in n downto i+1: if A[j] < A[j-1]: swap(A[j], A[j-1])(a) 除证明终止且输出 \(A'[1]\le\cdots\le A'[n]\) 外,还需证明 \(A'\) 是 \(A\) 的一个排列;(b) 给出内层循环(第 2–4 行)的精确不变式并证明(如:每次迭代开始时 \(A[j]\) 是 \(A[j..n]\) 中的最小元素,且 \(A[j..n]\) 是原元素的排列);(c) 由此给出外层循环不变式(\(A[1..i-1]\) 由全数组最小的 \(i-1\) 个元素有序组成)并证明不等式 (2.3);(d) 冒泡排序最坏运行时间 \(\Theta(n^2)\),与插入排序相同,但其最好情况也是 \(\Theta(n^2)\)(比较次数固定为 \(n(n-1)/2\))。
-
2-3 霍纳规则(Horner's rule)的正确性:多项式 \(P(x)=\sum_{k=0}^n a_kx^k=a_0+x(a_1+x(a_2+\cdots+x(a_{n-1}+xa_n)\cdots))\),代码:
y = 0; for i = n downto 0: y = a_i + x*y。(a) 运行时间 \(\Theta(n)\);(b) 朴素算法从头计算每一项(每项 \(x^k\) 需 \(k\) 次乘法)为 \(\Theta(n^2)\),远慢于霍纳规则;(c) 循环不变式:第 2–3 行每次迭代开始时 \(y=\sum_{k=0}^{n-(i+1)} a_{k+i+1}x^k\)(空和为 0),用它证明终止时(\(i=-1\))\(y=\sum_{k=0}^n a_kx^k\);(d) 由此论证代码正确。 -
2-4 逆序对(inversions):\(A[1..n]\) 为 \(n\) 个不同数,若 \(i<j\) 且 \(A[i]>A[j]\),则 \((i,j)\) 称为一个逆序对。(a) \(\langle 2,3,8,6,1\rangle\) 的五个逆序对:\((1,5),(2,5),(3,4),(3,5),(4,5)\);(b) 元素取自 \(\{1,\dots,n\}\) 时逆序排列 \(\langle n,n-1,\dots,1\rangle\) 逆序对最多,为 \(\binom n2=n(n-1)/2\) 个;(c) 插入排序运行时间与逆序对数 \(I\) 的关系:第 6 行右移的执行次数恰好等于 \(I\),运行时间为 \(\Theta(n+I)\);(d) 修改归并排序在 \(\Theta(n\lg n)\) 最坏时间内统计逆序对:归并时每当从 R 取出元素,就把 L 中剩余元素个数 \(n_1-i+1\) 累加到计数。
章末注记(PDF p.63): Knuth 1968 年出版《The Art of Computer Programming》第一卷,开创以运行时间分析为中心的现代算法研究;“algorithm”一词源自 9 世纪波斯数学家 al-Khowârizmî。Aho、Hopcroft、Ullman 倡导用渐近分析比较算法性能,并普及用递归关系描述递归算法运行时间。Knuth 第 3 卷对排序算法有百科全书式论述,包括精确的步数分析;插入排序最重要的变体是 D. L. Shell 提出的 Shell 排序(对输入的周期子序列做插入排序)。归并排序:1938 年发明了能一遍归并两叠穿孔卡片的机械整理机;冯·诺依曼 1945 年可能在 EDVAC 上编写了归并排序程序。程序正确性证明的早期历史见 Gries,首篇论文归功于 P. Naur,循环不变式归功于 R. W. Floyd。
第 2 章 本章要点
- 用循环不变式的“初始化—保持—终止”三步证明迭代算法正确,本质是在循环处停止的数学归纳法。
- RAM 模型:基本指令常数时间、字长 \(c\lg n\) 位、不考虑存储层次;运行时间以基本步数计,输入规模依问题而定。
- 插入排序:原地、稳定,最好 \(\Theta(n)\),最坏/平均 \(\Theta(n^2)\),运行时间为 \(\Theta(n+\text{逆序对数})\)。
- 关注最坏情况:提供保证、常发生、平均情况常同样差。只保留最高阶项并去掉常系数得到增长量级。
- 分治三步:分解、解决、合并;归并排序 \(T(n)=2T(n/2)+\Theta(n)=\Theta(n\lg n)\),递归树每层 \(cn\)、共 \(\lg n+1\) 层;MERGE 用哨兵简化边界,时间 \(\Theta(n)\)、额外空间 \(\Theta(n)\)。
第 2 章 与量化交易的关联
- 回测与因子计算中最常见的操作是排序与有序序列合并:截面排名(rank)因子、分位数分组、多交易所行情按时间戳归并(多路归并就是 MERGE 的推广,逐笔数据合流天然是 \(O(n\log k)\) 的多路归并)。
- 插入排序“对近乎有序数据为 \(\Theta(n+I)\)”的性质在实盘中很实用:订单簿价位、按时间到达但轻微乱序的成交记录,插入式维护比整体重排更快。
- 逆序对计数(思考题 2-4)直接对应 Kendall's tau 秩相关系数:\(\tau=1-\frac{4I}{n(n-1)}\),可用归并排序在 \(O(n\log n)\) 内计算,用于因子 IC 的稳健版本、两个排序的一致性检验。
- 霍纳规则用于高效计算多项式(如多项式拟合的收益率曲线、近似函数),循环不变式思维有助于验证回测引擎中的状态更新(如持仓、现金在每个 bar 后满足的约束)。
- 二分查找用于在有序时间戳中定位(as-of join、按时间切片)。
第 2 章 推荐习题
- 2.1-3:写线性查找并用循环不变式证明,练习正确性证明格式。
- 2.3-5、2.3-7:二分查找与“两数之和”,排序 + 双指针的基本套路。
- 思考题 2-1:理解混合排序中的阈值 \(k\) 选择(工程实践中 Timsort、introsort 的思想来源)。
- 思考题 2-4:逆序对计数,与 Kendall tau 直接相关,强烈推荐。
- 思考题 2-3:霍纳规则及其循环不变式。
第 3 章 函数的增长(Growth of Functions)(PDF p.64–85)
当输入规模足够大、只有运行时间的增长量级起作用时,我们研究的是算法的渐近效率(asymptotic efficiency):输入规模无限增大时运行时间如何随之增长。除了极小输入,渐近更有效的算法通常是最佳选择。精确运行时间中的常数因子和低阶项在大输入下被规模本身的影响所支配,计算它们通常不值得。
3.1 渐近记号(Asymptotic notation)(PDF p.64–74)
渐近记号定义在定义域为自然数集 \(\mathbb N=\{0,1,2,\dots\}\) 的函数上,便于描述只在整数规模上定义的最坏运行时间 \(T(n)\);有时会“滥用”到实数域或自然数子集上,但必须清楚精确含义,“滥用而不误用”。渐近记号本质上作用于函数,不只是运行时间——可以刻画空间,甚至与算法无关的函数。还需分清所指是哪种运行时间:最坏情况,或者“无论什么输入”的总体陈述。
\(\Theta\) 记号(渐近紧确界,asymptotically tight bound):
\(f(n)\) 对足够大的 \(n\) 被“夹”在 \(c_1g(n)\) 与 \(c_2g(n)\) 之间。\(\Theta(g(n))\) 是集合,严格应写 \(f(n)\in\Theta(g(n))\),但习惯写 \(f(n)=\Theta(g(n))\)(这种对等号的滥用有好处,见下)。定义要求每个成员渐近非负(足够大的 \(n\) 时非负;渐近正函数指足够大的 \(n\) 时为正),因此 \(g(n)\) 本身也须渐近非负,否则集合为空。本章所有渐近记号都假设所涉函数渐近非负。
- 例 1:证明 \(\frac12 n^2-3n=\Theta(n^2)\)。需 \(c_1n^2\le \frac12n^2-3n\le c_2n^2\),除以 \(n^2\) 得 \(c_1\le \frac12-\frac3n\le c_2\)。取 \(c_2\ge \frac12\) 时右不等式对 \(n\ge1\) 成立;取 \(c_1\le\frac1{14}\) 时左不等式对 \(n\ge7\) 成立。故 \(c_1=\frac1{14}\)、\(c_2=\frac12\)、\(n_0=7\) 即可。常数选择不唯一且依赖于具体函数。
- 例 2:\(6n^3\ne\Theta(n^2)\)。若 \(6n^3\le c_2n^2\) 对 \(n\ge n_0\) 成立,则 \(n\le c_2/6\),对任意大的 \(n\) 不可能。
- 直观:低阶项可忽略(大 \(n\) 时最高阶项的一小部分就能压过低阶项),取 \(c_1\) 略小于、\(c_2\) 略大于最高阶系数即可;最高阶系数也可忽略,它只按常数因子改变 \(c_1,c_2\)。
- 二次函数 \(f(n)=an^2+bn+c\)(\(a>0\)):取 \(c_1=a/4\),\(c_2=7a/4\),\(n_0=2\max(|b|/a,\sqrt{|c|/a})\),可验证 \(0\le c_1n^2\le an^2+bn+c\le c_2n^2\)。一般地,\(p(n)=\sum_{i=0}^d a_in^i\),\(a_d>0\),则 \(p(n)=\Theta(n^d)\)(思考题 3-1)。
- 常数是 0 次多项式,记为 \(\Theta(n^0)\) 或 \(\Theta(1)\)(小滥用:没有指明哪个变量趋于无穷;脚注说明根本问题是普通函数记号不区分函数与值,λ 演算可写 \(\lambda n.n^2\),但会使代数运算复杂)。本书用 \(\Theta(1)\) 表示常数或关于某变量的常函数。
\(O\) 记号(渐近上界,asymptotic upper bound):
读作 big-oh of g of n。\(\Theta\) 比 \(O\) 强:\(\Theta(g(n))\subseteq O(g(n))\)。当 \(a>0\) 时任何线性函数 \(an+b\in O(n^2)\)(取 \(c=a+|b|\),\(n_0=\max(1,-b/a)\))。本书中 \(f(n)=O(g(n))\) 仅声称 \(g\) 的某常数倍是 \(f\) 的渐近上界,不声称上界有多紧;文献中有时非正式地用 \(O\) 表示紧确界,算法文献中区分二者是标准做法。
- 只看算法整体结构常可得 \(O\) 上界:插入排序双重循环,内层每次迭代 \(O(1)\),\(i,j\) 均至多 \(n\),内层对 \(n^2\) 对 \((i,j)\) 各至多执行一次,故最坏 \(O(n^2)\)。
- 用 \(O\) 界定最坏运行时间,就得到对每个输入的运行时间上界(总体陈述):插入排序最坏 \(O(n^2)\) 适用于所有输入。但最坏 \(\Theta(n^2)\) 并不意味着每个输入都是 \(\Theta(n^2)\)——已排序输入只需 \(\Theta(n)\)。
- 严格说“插入排序的运行时间是 \(O(n^2)\)”是滥用,因为同一 \(n\) 下运行时间随输入变化;其含义是:存在 \(f(n)\in O(n^2)\),使对任意 \(n\) 和任意规模为 \(n\) 的输入,运行时间都不超过 \(f(n)\);等价于最坏运行时间为 \(O(n^2)\)。
\(\Omega\) 记号(渐近下界,asymptotic lower bound):
定理 3.1: 对任意两个函数 \(f(n),g(n)\),\(f(n)=\Theta(g(n))\) 当且仅当 \(f(n)=O(g(n))\) 且 \(f(n)=\Omega(g(n))\)。实践中通常用它由上、下界推出紧确界。
- 说“运行时间(不加修饰)是 \(\Omega(g(n))\)”,意为对每个 \(n\) 无论选哪个规模为 \(n\) 的输入,运行时间至少是 \(g(n)\) 的常数倍,等价于给出最好情况运行时间的下界。插入排序最好情况为 \(\Omega(n)\),故其运行时间为 \(\Omega(n)\);运行时间属于 \(\Omega(n)\) 和 \(O(n^2)\),且这两个界都是尽可能紧的:运行时间不是 \(\Omega(n^2)\)(有输入只需 \(\Theta(n)\)),但说“最坏情况运行时间是 \(\Omega(n^2)\)”并不矛盾。
等式与不等式中的渐近记号。
- 单独出现在等号右边(如 \(n=O(n^2)\)):等号表示集合成员关系。
- 出现在公式中:表示某个不愿命名的匿名函数(anonymous function)。如 \(2n^2+3n+1=2n^2+\Theta(n)\) 表示存在 \(f(n)\in\Theta(n)\)(这里 \(f(n)=3n+1\))使等式成立。用于消除无关细节,如归并排序递归式 \(T(n)=2T(n/2)+\Theta(n)\)。
- 匿名函数个数等于记号出现次数:\(\sum_{i=1}^n O(i)\) 只有一个匿名函数(关于 \(i\) 的函数),不同于 \(O(1)+O(2)+\cdots+O(n)\)(后者没有清晰解释)。
- 出现在等号左边(如 \(2n^2+\Theta(n)=\Theta(n^2)\)):规则是无论左边匿名函数如何选取,都存在右边匿名函数的选法使等式成立,即右边比左边更粗糙。可链式使用:\(2n^2+3n+1=2n^2+\Theta(n)=\Theta(n^2)\),逐个等式解释,合起来得 \(2n^2+3n+1=\Theta(n^2)\)。
\(o\) 记号(非渐近紧确的上界):
例:\(2n=o(n^2)\),但 \(2n^2\ne o(n^2)\)。与 \(O\) 的区别:\(O\) 要求“存在某个” \(c\),\(o\) 要求“对所有” \(c>0\)。直观上 \(f\) 相对 \(g\) 变得可以忽略:
有些作者把该极限当作定义;本书定义还要求匿名函数渐近非负。
\(\omega\) 记号(非渐近紧确的下界): \(f(n)\in\omega(g(n))\iff g(n)\in o(f(n))\)。形式定义:
例:\(n^2/2=\omega(n)\),但 \(n^2/2\ne\omega(n^2)\)。若极限存在则 \(\lim f(n)/g(n)=\infty\)。
函数比较的性质(设 \(f,g\) 渐近正):
- 传递性(transitivity):对 \(\Theta,O,\Omega,o,\omega\) 均成立,如 \(f=O(g)\) 且 \(g=O(h)\Rightarrow f=O(h)\)。
- 自反性(reflexivity):\(f=\Theta(f)\),\(f=O(f)\),\(f=\Omega(f)\)。
- 对称性(symmetry):\(f=\Theta(g)\iff g=\Theta(f)\)。
- 转置对称性(transpose symmetry):\(f=O(g)\iff g=\Omega(f)\);\(f=o(g)\iff g=\omega(f)\)。
- 与实数比较类比:\(O\sim\le\),\(\Omega\sim\ge\),\(\Theta\sim=\),\(o\sim<\),\(\omega\sim>\)。\(f=o(g)\) 称 \(f\) 渐近小于 \(g\),\(f=\omega(g)\) 称 \(f\) 渐近大于 \(g\)。
- 但三分性(trichotomy)不成立:并非任意两个函数都渐近可比。例:\(n\) 与 \(n^{1+\sin n}\),后者指数在 0 到 2 之间振荡,既非 \(O\) 也非 \(\Omega\)。
练习 3.1: 3.1-1 用 \(\Theta\) 定义证明 \(\max(f(n),g(n))=\Theta(f(n)+g(n))\)(\(\frac12(f+g)\le\max\le f+g\));3.1-2 证明对实常数 \(a\) 和 \(b>0\),\((n+a)^b=\Theta(n^b)\) (3.2)(\(n\ge2|a|\) 时 \(n/2\le n+a\le 2n\));3.1-3 解释“算法 A 的运行时间至少是 \(O(n^2)\)”为何无意义(\(O\) 是上界,“至少一个上界”不提供任何信息);3.1-4 \(2^{n+1}=O(2^n)\) 成立(\(=2\cdot2^n\)),\(2^{2n}=O(2^n)\) 不成立(\(=4^n\),比值 \(2^n\to\infty\));3.1-5 证明定理 3.1;3.1-6 证明算法运行时间为 \(\Theta(g(n))\) 当且仅当最坏运行时间为 \(O(g(n))\) 且最好运行时间为 \(\Omega(g(n))\);3.1-7 证明 \(o(g(n))\cap\omega(g(n))=\varnothing\);3.1-8 推广到两个参数 \(n,m\) 以不同速率趋于无穷:\(O(g(n,m))=\{f:\exists c,n_0,m_0>0,\ 0\le f(n,m)\le cg(n,m)\) 对所有 \(n\ge n_0\) 或 \(m\ge m_0\}\),给出 \(\Omega\)、\(\Theta\) 的相应定义。
3.2 标准记号与常用函数(Standard notations and common functions)(PDF p.74–81)
单调性(monotonicity): \(m\le n\Rightarrow f(m)\le f(n)\) 为单调递增(monotonically increasing);\(m\le n\Rightarrow f(m)\ge f(n)\) 为单调递减;\(m<n\Rightarrow f(m)<f(n)\) 为严格递增(strictly increasing);\(m<n\Rightarrow f(m)>f(n)\) 为严格递减。
向下取整与向上取整(floors and ceilings): \(\lfloor x\rfloor\) 为不超过 \(x\) 的最大整数,\(\lceil x\rceil\) 为不小于 \(x\) 的最小整数。
对任意整数 \(n\):\(\lceil n/2\rceil+\lfloor n/2\rfloor=n\)。对实数 \(x\ge0\)、整数 \(a,b>0\):
floor 与 ceiling 函数都是单调递增的。
模运算(modular arithmetic): 对整数 \(a\) 和正整数 \(n\),\(a\bmod n\) 是 \(a/n\) 的余数(remainder / residue):
若 \((a\bmod n)=(b\bmod n)\),记 \(a\equiv b\pmod n\),称 \(a\) 与 \(b\) 模 \(n\) 等价;等价于 \(n\) 整除 \(b-a\)。不等价记 \(a\not\equiv b\pmod n\)。
多项式(polynomials): \(d\) 次多项式 \(p(n)=\sum_{i=0}^d a_in^i\),\(a_d\ne0\)。\(p\) 渐近正当且仅当 \(a_d>0\),此时 \(p(n)=\Theta(n^d)\)。实常数 \(a\ge0\) 时 \(n^a\) 单调递增,\(a\le0\) 时单调递减。若存在常数 \(k\) 使 \(f(n)=O(n^k)\),称 \(f\) 多项式有界(polynomially bounded)。
指数(exponentials): 对实数 \(a>0\) 及 \(m,n\):\(a^0=1\),\(a^1=a\),\(a^{-1}=1/a\),\((a^m)^n=a^{mn}=(a^n)^m\),\(a^ma^n=a^{m+n}\)。\(a\ge1\) 时 \(a^n\) 关于 \(n\) 单调递增;约定 \(0^0=1\)。多项式与指数增长率关系:对实常数 \(a>1\) 与任意 \(b\),
即底数大于 1 的指数函数比任何多项式增长都快。自然对数底 \(e=2.71828\ldots\):
\(x\to0\) 时 \(e^x=1+x+\Theta(x^2)\)(此处渐近记号描述 \(x\to0\) 的极限行为)。对所有 \(x\):\(\lim_{n\to\infty}(1+x/n)^n=e^x\) (3.14)。
对数(logarithms): \(\lg n=\log_2 n\)(二进制对数),\(\ln n=\log_e n\)(自然对数),\(\lg^k n=(\lg n)^k\)(幂),\(\lg\lg n=\lg(\lg n)\)(复合)。约定对数只作用于紧跟的项:\(\lg n+k\) 表示 \((\lg n)+k\)。\(b>1\) 固定时 \(\log_b n\) 对 \(n>0\) 严格递增。对实数 \(a,b,c>0\)(底数不为 1):
由 (3.15),换底只改变常数因子,因此不关心常数因子时(如 \(O\) 记号中)一律写 \(\lg n\)。计算机科学家偏爱以 2 为底,因为许多算法把问题一分为二。\(|x|<1\) 时 \(\ln(1+x)=x-\frac{x^2}{2}+\frac{x^3}{3}-\frac{x^4}{4}+\cdots\);对 \(x>-1\):
若 \(f(n)=O(\lg^k n)\),称 \(f\) 多对数有界(polylogarithmically bounded)。在 (3.10) 中以 \(\lg n\) 代 \(n\)、\(2^a\) 代 \(a\) 得 \(\lim \frac{\lg^b n}{(2^a)^{\lg n}}=\lim\frac{\lg^b n}{n^a}=0\),故对任意常数 \(a>0\),\(\lg^b n=o(n^a)\):任何正幂多项式都比任何多对数函数增长快。
阶乘(factorials): \(0!=1\),\(n!=n\cdot(n-1)!\),即 \(n!=1\cdot2\cdots n\)。弱上界 \(n!\le n^n\)。Stirling 近似:
由此(练习 3.2-3):\(n!=o(n^n)\),\(n!=\omega(2^n)\),
对所有 \(n\ge1\) 还有(Robbins)\(n!=\sqrt{2\pi n}(n/e)^ne^{\alpha_n}\),其中 \(\frac{1}{12n+1}<\alpha_n<\frac{1}{12n}\) (3.20, 3.21)。
函数迭代(functional iteration): \(f^{(i)}(n)\) 表示把 \(f\) 对初值 \(n\) 迭代 \(i\) 次:\(f^{(0)}(n)=n\),\(f^{(i)}(n)=f(f^{(i-1)}(n))\)。例:\(f(n)=2n\) 时 \(f^{(i)}(n)=2^in\)。
迭代对数(iterated logarithm)\(\lg^* n\)(读作 log star of n): 设 \(\lg^{(i)}n\) 为上述迭代(仅当 \(\lg^{(i-1)}n>0\) 时有定义);注意区分 \(\lg^{(i)}n\)(连续取 \(i\) 次对数)与 \(\lg^i n\)(对数的 \(i\) 次幂)。
增长极慢:\(\lg^*2=1\),\(\lg^*4=2\),\(\lg^*16=3\),\(\lg^*65536=4\),\(\lg^*(2^{65536})=5\)。可观测宇宙中的原子数约 \(10^{80}\),远小于 \(2^{65536}\),因此实际中几乎不会遇到 \(\lg^*n>5\) 的输入规模。
斐波那契数(Fibonacci numbers):
序列 \(0,1,1,2,3,5,8,13,21,34,55,\ldots\)。与黄金分割率 \(\phi\) 及其共轭 \(\hat\phi\) 相关,二者是 \(x^2=x+1\) (3.23) 的两个根:
且 \(F_i=\dfrac{\phi^i-\hat\phi^i}{\sqrt5}\)(可归纳证明)。由于 \(|\hat\phi|<1\),\(|\hat\phi^i|/\sqrt5<1/\sqrt5<1/2\),所以
即 \(F_i\) 等于 \(\phi^i/\sqrt5\) 四舍五入到最近整数,斐波那契数呈指数增长。
练习 3.2: 3.2-1 证明单调递增函数之和与复合仍单调递增,若还非负则乘积也单调递增;3.2-2 证明 (3.16);3.2-3 证明 (3.19),并证 \(n!=\omega(2^n)\)、\(n!=o(n^n)\);3.2-4★ \(\lceil\lg n\rceil!\) 是否多项式有界(否:\(\lg(\lceil\lg n\rceil!)=\Theta(\lg n\lg\lg n)\),不是 \(O(\lg n)\));\(\lceil\lg\lg n\rceil!\) 是否多项式有界(是:\(\Theta(\lg\lg n\cdot\lg\lg\lg n)=o(\lg n)\));3.2-5★ \(\lg(\lg^*n)\) 与 \(\lg^*(\lg n)\) 哪个渐近更大(后者:\(\lg^*(\lg n)=\lg^*n-1\),而 \(\lg(\lg^* n)\) 更小);3.2-6 证明 \(\phi\)、\(\hat\phi\) 满足 \(x^2=x+1\);3.2-7 归纳证明 \(F_i=(\phi^i-\hat\phi^i)/\sqrt5\);3.2-8 证明 \(k\ln k=\Theta(n)\) 蕴含 \(k=\Theta(n/\ln n)\)。
思考题(PDF p.82–84):
- 3-1 多项式的渐近性质:\(p(n)=\sum_{i=0}^d a_in^i\),\(a_d>0\),\(k\) 为常数。用定义证明:\(k\ge d\Rightarrow p=O(n^k)\);\(k\le d\Rightarrow p=\Omega(n^k)\);\(k=d\Rightarrow p=\Theta(n^k)\);\(k>d\Rightarrow p=o(n^k)\);\(k<d\Rightarrow p=\omega(n^k)\)。
- 3-2 相对渐近增长:对每对 \((A,B)\) 判断 \(A\) 是否为 \(B\) 的 \(O,o,\Omega,\omega,\Theta\)(\(k\ge1,\epsilon>0,c>1\)):(a) \(\lg^k n\) vs \(n^\epsilon\)(\(o\),故也 \(O\));(b) \(n^k\) vs \(c^n\)(\(o\));(c) \(\sqrt n\) vs \(n^{\sin n}\)(不可比,全否);(d) \(2^n\) vs \(2^{n/2}\)(\(\omega\),\(\Omega\));(e) \(n^{\lg c}\) vs \(c^{\lg n}\)(相等,\(\Theta\)、\(O\)、\(\Omega\));(f) \(\lg(n!)\) vs \(\lg(n^n)\)(\(\Theta\))。
- 3-3 按增长量级给 30 个函数排序并划分 \(\Theta\) 等价类:\(\lg(\lg^*n)\)、\(2^{\lg^*n}\)、\((\sqrt2)^{\lg n}\)、\(n^2\)、\(n!\)、\((\lg n)!\)、\((3/2)^n\)、\(n^3\)、\(\lg^2n\)、\(\lg(n!)\)、\(2^{2^n}\)、\(n^{1/\lg n}\)、\(\ln\ln n\)、\(\lg^*n\)、\(n\cdot2^n\)、\(n^{\lg\lg n}\)、\(\ln n\)、\(1\)、\(2^{\lg n}\)、\((\lg n)^{\lg n}\)、\(e^n\)、\(4^{\lg n}\)、\((n+1)!\)、\(\sqrt{\lg n}\)、\(\lg^*(\lg n)\)、\(2^{\sqrt{2\lg n}}\)、\(n\)、\(2^n\)、\(n\lg n\)、\(2^{2^{n+1}}\)。(b) 构造一个非负函数 \(f\),使其对 (a) 中每个 \(g_i\) 都既非 \(O(g_i)\) 也非 \(\Omega(g_i)\)(如在 0 与 \(2^{2^{n+2}}\) 间振荡的函数)。考点:熟练运用 \(n^{1/\lg n}=2\)、\(4^{\lg n}=n^2\)、\(2^{\lg n}=n\)、\((\lg n)^{\lg n}=n^{\lg\lg n}\) 等恒等式与 Stirling 公式。
- 3-4 渐近记号性质的证明或反驳(\(f,g\) 渐近正):(a) \(f=O(g)\Rightarrow g=O(f)\)(假);(b) \(f+g=\Theta(\min(f,g))\)(假);(c) \(f=O(g)\Rightarrow\lg f=O(\lg g)\),条件 \(\lg g\ge1\)、\(f\ge1\)(真);(d) \(f=O(g)\Rightarrow 2^f=O(2^g)\)(假,如 \(f=2n\),\(g=n\));(e) \(f=O(f^2)\)(假,若 \(f<1\) 如 \(1/n\));(f) \(f=O(g)\Rightarrow g=\Omega(f)\)(真);(g) \(f=\Theta(f(n/2))\)(假,如 \(4^n\));(h) \(f+o(f)=\Theta(f)\)(真)。
- 3-5 \(O\) 与 \(\Omega\) 的变体:\(\overset{\infty}{\Omega}\)(omega infinity):若存在正常数 \(c\) 使 \(f(n)\ge cg(n)\ge0\) 对无穷多个整数 \(n\) 成立,则 \(f=\overset{\infty}{\Omega}(g)\)。(a) 证明对任意两个渐近非负函数,\(f=O(g)\) 或 \(f=\overset{\infty}{\Omega}(g)\) 至少一个成立,而用 \(\Omega\) 时不成立;(b) 讨论用 \(\overset{\infty}\Omega\) 刻画程序运行时间的利弊;\(O'\):\(f=O'(g)\) 当且仅当 \(|f|=O(g)\);(c) 用 \(O'\) 代 \(O\) 时定理 3.1 两个方向的变化;软 O(soft-oh)\(\tilde O(g(n))=\{f:\exists c,k,n_0>0,\ 0\le f(n)\le cg(n)\lg^k n,\ \forall n\ge n_0\}\),即忽略对数因子的 \(O\);(d) 类似定义 \(\tilde\Omega\)、\(\tilde\Theta\) 并证明定理 3.1 的类比。
- 3-6 迭代函数:对单调递增 \(f\) 和常数 \(c\),定义 \(f^*_c(n)=\min\{i\ge0:f^{(i)}(n)\le c\}\)(把参数降到 \(c\) 以下所需的迭代次数)。求尽可能紧的界:(a) \(f=n-1,c=0\):\(n\);(b) \(\lg n,c=1\):\(\lg^*n\);(c) \(n/2,c=1\):\(\lceil\lg n\rceil\);(d) \(n/2,c=2\):\(\lceil\lg n\rceil-1\);(e) \(\sqrt n,c=2\):\(\lg\lg n\);(f) \(\sqrt n,c=1\):不收敛到 1(\(n>1\) 时未定义);(g) \(n^{1/3},c=2\):\(\log_3\lg n\);(h) \(n/\lg n,c=2\):\(\Theta(\lg n/\lg\lg n)\)。
章末注记(PDF p.85): Knuth 将 \(O\) 记号溯源到 P. Bachmann 1892 年的数论著作;\(o\) 记号由 E. Landau 1909 年在讨论素数分布时发明;Knuth 倡导 \(\Omega\)、\(\Theta\) 以纠正文献中用 \(O\) 同时表示上下界的不严谨做法,但很多人仍在应该用 \(\Theta\) 的地方用 \(O\)。不同作者的渐近记号定义在常见情形下一致,有些定义允许非渐近非负的函数(只要绝对值有界)。(3.20) 归功于 Robbins;初等函数性质可参考 Abramowitz–Stegun、Zwillinger 或微积分教材;计算机科学中的离散数学见 Knuth、Graham–Knuth–Patashnik《Concrete Mathematics》。
第 3 章 本章要点
- 五种渐近记号:\(\Theta\)(紧确)、\(O\)(上界)、\(\Omega\)(下界)、\(o\)(严格上界,\(\lim f/g=0\))、\(\omega\)(严格下界,\(\lim f/g=\infty\))。定理 3.1:\(\Theta\iff O\wedge\Omega\)。
- “运行时间是 \(O(g)\)”等价于最坏运行时间 \(O(g)\);“运行时间是 \(\Omega(g)\)”等价于最好运行时间 \(\Omega(g)\);“最坏运行时间 \(\Theta(g)\)”不意味着每个输入都 \(\Theta(g)\)。
- 公式中的渐近记号代表匿名函数;等号左边出现时右边更粗糙。
- 增长量级阶梯:\(1\ll\lg^*n\ll\lg\lg n\ll\lg n\ll\lg^k n\ll n^\epsilon\ll n\ll n\lg n\ll n^2\ll n^k\ll c^n\ll n!\ll n^n\)。换底只差常数,\(\lg(n!)=\Theta(n\lg n)\)。
- 常用恒等式与不等式:\(a^{\log_b c}=c^{\log_b a}\)、\(e^x\ge1+x\)、\(\frac{x}{1+x}\le\ln(1+x)\le x\)、Stirling 公式、斐波那契数闭式。
第 3 章 与量化交易的关联
- 渐近记号是评估量化系统可扩展性的通用语言:估算“股票数 \(N\) × 时间点 \(T\) × 因子数 \(K\)”下各环节的复杂度(如协方差矩阵 \(O(N^2T)\)、逐日截面排序 \(O(TN\log N)\)、协方差求逆 \(O(N^3)\)),据此决定是否需要因子模型降维(\(N\times K\) 结构)。
- 指数函数与不等式在金融中直接出现:\(\lim(1+x/n)^n=e^x\) 是连续复利的数学基础;\(\ln(1+x)\approx x\)(及 (3.17) 的界)给出对数收益率与简单收益率之间的近似误差界;\(e^x\approx1+x+\Theta(x^2)\) 用于小收益率展开。
- 模运算用于循环缓冲区(滚动窗口指标的环形数组索引)、哈希分片。
- \(\lg^*\)、\(\lg\lg n\) 等极慢函数在实际中可视为常数,有助于判断某些“理论上非常数”的结构(如并查集、van Emde Boas 树)在实盘规模下的实际成本。
第 3 章 推荐习题
- 3.1-4、3.1-3:辨析 \(O\) 的含义与常见误用。
- 3.2-3:用 Stirling 公式证明 \(\lg(n!)=\Theta(n\lg n)\)(第 8 章排序下界要用)。
- 思考题 3-2、3-3:系统训练比较函数增长率,3-3 是经典难题。
- 思考题 3-4:渐近记号性质的真假判断,帮助避免推导误区。
第 4 章 分治策略(Divide-and-Conquer)(PDF p.86–134)
第 4 章导言(PDF p.86–88)
回顾分治三步(分解、解决、合并)。子问题足够大需要递归求解时称为递归情况(recursive case);子问题小到不再递归时称递归“触底”,进入基本情况(base case)。有时除了与原问题同类的更小子问题,还要解决与原问题不完全相同的子问题,这部分算作合并步骤。本章新算法:最大子数组问题;两个 \(n\times n\) 矩阵乘法的分治算法——朴素分治 \(\Theta(n^3)\),不优于直接法;Strassen 算法 \(O(n^{2.81})\),渐近更优。
递归式(recurrence): 用较小输入上的函数值描述函数自身的方程或不等式,是刻画分治算法运行时间的自然工具。例:归并排序 \(T(n)=\Theta(1)\)(\(n=1\)),\(2T(n/2)+\Theta(n)\)(\(n>1\))(4.1),解为 \(\Theta(n\lg n)\)。递归式形式多样:按 2/3 与 1/3 不等分割、分解与合并为线性时间时 \(T(n)=T(2n/3)+T(n/3)+\Theta(n)\);子问题不必是原规模的常数比例,例如递归线性查找 \(T(n)=T(n-1)+\Theta(1)\)。
三种求解方法(得到 \(\Theta\) 或 \(O\) 界):
- 代入法(substitution method):猜测一个界,用数学归纳法证明。
- 递归树法(recursion-tree method):把递归式转换为树,结点表示各层递归的代价,用求和界定技术求解。
- 主方法(master method):求解形如 \(T(n)=aT(n/b)+f(n)\) (4.2)(\(a\ge1\),\(b>1\))的递归式,对应生成 \(a\) 个规模为 \(n/b\) 的子问题、分解与合并共花 \(f(n)\) 的分治算法。需记住三种情况。
递归式也可能是不等式:\(T(n)\le 2T(n/2)+\Theta(n)\) 只给上界,解用 \(O\) 表示;\(T(n)\ge 2T(n/2)+\Theta(n)\) 只给下界,解用 \(\Omega\) 表示。
技术细节(technicalities): \(n\) 为奇数时归并排序子问题规模为 \(\lfloor n/2\rfloor\) 与 \(\lceil n/2\rceil\),严格的递归式是
边界条件也常被忽略:常数规模输入的运行时间为常数,故足够小的 \(n\) 有 \(T(n)=\Theta(1)\),通常直接写 \(T(n)=2T(n/2)+\Theta(n)\) (4.4)。改变 \(T(1)\) 会改变精确解,但通常只差常数因子,不改变增长量级。一般先忽略取整与边界条件,事后再判断它们是否重要——通常不重要,但要知道何时重要;定理 4.1 表明对许多分治递归式这些细节不影响渐近界。
4.1 最大子数组问题(The maximum-subarray problem)(PDF p.89–96)
动机(股票买卖): 你可以投资 Volatile Chemical Corporation 的股票,只允许买入一股一次、之后某天卖出,买卖都在当日收盘后进行;作为补偿,你可以预知未来股价。目标是最大化利润。图 4.1 给出 17 天(第 0–16 天)价格:
| 天 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 价格 | 100 | 113 | 110 | 85 | 105 | 102 | 86 | 63 | 81 | 101 | 94 | 106 | 101 | 79 | 94 | 90 | 97 |
| 变化 | 13 | −3 | −25 | 20 | −3 | −16 | −23 | 18 | 20 | −7 | 12 | −5 | −22 | 15 | −4 | 7 |
最低价出现在第 7 天之后(63),晚于第 1 天之后的最高价(113),无法“最低买、最高卖”。也不能总是“要么在最低点买、要么在最高点卖”:图 4.2 反例,价格 \(10,11,7,10,6\)(第 0–4 天),最大利润 3 来自第 2 天后以 7 买入、第 3 天后以 10 卖出,而 7 不是全局最低(6),10 不是全局最高(11)。
暴力解: 尝试所有买入早于卖出的日期对,共 \(\binom n2=\Theta(n^2)\) 对,每对至少常数时间,故 \(\Omega(n^2)\)。
变换: 考虑每日价格变化(第 \(i\) 天变化 = 第 \(i\) 天后价格 − 第 \(i-1\) 天后价格),得到数组 \(A\)(图 4.3,\(A[1..16]=\langle 13,-3,-25,20,-3,-16,-23,18,20,-7,12,-5,-22,15,-4,7\rangle\))。问题变为寻找和最大的非空连续子数组,称为最大子数组(maximum subarray)。本例最大子数组为 \(A[8..11]\),和为 \(18+20-7+12=43\),即第 7 天后买入、第 11 天后卖出,每股赚 43 美元。变换本身不直接降低复杂度:\(n\) 天仍需检查 \(\binom{n-1}{2}=\Theta(n^2)\) 个子数组;练习 4.1-2 说明若利用已算出的子数组和,每个子数组和可 \(O(1)\) 得到,暴力法为 \(\Theta(n^2)\)。可能有多个最大子数组,故说“一个”最大子数组。只有当数组含负数时问题才有意义;若全为非负数,整个数组就是答案。
分治解法。 求 \(A[low..high]\) 的最大子数组:取中点 \(mid\),任何连续子数组 \(A[i..j]\) 必恰好位于以下三处之一(图 4.4(a)):
- 完全在 \(A[low..mid]\) 中:\(low\le i\le j\le mid\);
- 完全在 \(A[mid+1..high]\) 中:\(mid<i\le j\le high\);
- 跨越中点:\(low\le i\le mid<j\le high\)。
前两种是规模更小的同类子问题,递归求解;跨越中点的最大子数组不是原问题的更小实例(多了必须跨中点的限制),归入合并步骤,可在线性时间求出:任何跨中点子数组由 \(A[i..mid]\) 与 \(A[mid+1..j]\) 组成(图 4.4(b)),分别求两部分的最大值再相加。
def find_max_crossing_subarray(A, low, mid, high):
left_sum = -INF; s = 0
for i in range(mid, low - 1, -1): # mid downto low
s += A[i]
if s > left_sum:
left_sum = s; max_left = i
right_sum = -INF; s = 0
for j in range(mid + 1, high + 1): # mid+1 to high
s += A[j]
if s > right_sum:
right_sum = s; max_right = j
return (max_left, max_right, left_sum + right_sum)
左半循环从 \(mid\) 往下到 \(low\),考虑的每个子数组都形如 \(A[i..mid]\)(必须包含 \(A[mid]\));右半从 \(mid+1\) 往上到 \(high\),都形如 \(A[mid+1..j]\)。迭代次数共 \((mid-low+1)+(high-mid)=n\),每次 \(\Theta(1)\),故 \(\Theta(n)\)。
def find_maximum_subarray(A, low, high):
if high == low:
return (low, high, A[low]) # 基本情况:一个元素
mid = (low + high) // 2
(ll, lh, ls) = find_maximum_subarray(A, low, mid)
(rl, rh, rs) = find_maximum_subarray(A, mid + 1, high)
(cl, ch, cs) = find_max_crossing_subarray(A, low, mid, high)
if ls >= rs and ls >= cs:
return (ll, lh, ls)
elif rs >= ls and rs >= cs:
return (rl, rh, rs)
else:
return (cl, ch, cs)
# 初始调用 find_maximum_subarray(A, 1, A.length)
分析: 设 \(n\) 为 2 的幂。基本情况 \(T(1)=\Theta(1)\) (4.5);递归情况:第 1、3 行常数,两个规模 \(n/2\) 的子问题 \(2T(n/2)\),跨中点 \(\Theta(n)\),第 7–11 行 \(\Theta(1)\),故 \(T(n)=2T(n/2)+\Theta(n)\) (4.6),与归并排序递归式 (4.1) 相同,解为 \(\Theta(n\lg n)\)(时间),空间为 \(\Theta(\lg n)\) 递归栈。分治法因此渐近快于暴力法;但它并非最优——练习 4.1-5 给出不用分治的线性时间算法。
练习 4.1: 4.1-1 全为负数时返回最大的那个单元素(值最大的负数);4.1-2 写 \(\Theta(n^2)\) 暴力伪代码(固定起点 \(i\),累加 \(j\) 得 \(A[i..j]\) 和);4.1-3 实现两种算法求交叉点 \(n_0\),再把递归算法的基本情况改为在 \(n<n_0\) 时用暴力法,观察交叉点是否改变;4.1-4 若允许结果为空子数组(和为 0),如何修改不允许空子数组的算法(如:结果和为负时返回空子数组);4.1-5 线性时间非递归算法(Kadane 算法)的思路:从左向右扫描,记录目前为止的最大子数组;已知 \(A[1..j]\) 的最大子数组,则 \(A[1..j+1]\) 的最大子数组要么是 \(A[1..j]\) 的最大子数组,要么是某个 \(A[i..j+1]\);以 \(j+1\) 结尾的最大子数组可由以 \(j\) 结尾的最大子数组在常数时间得到:
def max_subarray_linear(A): # Kadane, 时间 Θ(n), 空间 O(1)
best = -INF; cur = 0; cur_start = 1
for j in 1..n:
if cur <= 0: # 以 j-1 结尾的最佳和非正,则从 j 重新开始
cur = A[j]; cur_start = j
else:
cur += A[j]
if cur > best:
best = cur; best_l = cur_start; best_r = j
return (best_l, best_r, best)
4.2 矩阵乘法的 Strassen 算法(Strassen's algorithm for matrix multiplication)(PDF p.96–104)
\(A=(a_{ij})\)、\(B=(b_{ij})\) 为 \(n\times n\) 方阵,\(C=A\cdot B\) 的元素
要计算 \(n^2\) 个元素,每个是 \(n\) 个值的和。
def square_matrix_multiply(A, B): # 时间 Θ(n^3),额外空间 Θ(n^2)(输出)
n = A.rows
C = new n x n matrix
for i in 1..n:
for j in 1..n:
C[i][j] = 0
for k in 1..n:
C[i][j] += A[i][k] * B[k][j]
return C
三重循环各 \(n\) 次,第 7 行常数时间,故 \(\Theta(n^3)\)。直觉上似乎任何矩阵乘法都需 \(\Omega(n^3)\),但这是错的:Strassen 递归算法运行时间 \(\Theta(n^{\lg7})\),\(\lg 7\in(2.80,2.81)\),即 \(O(n^{2.81})\)。
简单分治算法。 设 \(n\) 为 2 的幂(保证 \(n\ge2\) 时 \(n/2\) 为整数)。把 \(A,B,C\) 各分成四个 \(n/2\times n/2\) 子矩阵 (4.9):
def square_matrix_multiply_recursive(A, B):
n = A.rows
C = new n x n matrix
if n == 1:
C[1][1] = A[1][1] * B[1][1]
else:
partition A, B, C as in (4.9) # 用下标计算,Θ(1)
C11 = SMMR(A11, B11) + SMMR(A12, B21)
C12 = SMMR(A11, B12) + SMMR(A12, B22)
C21 = SMMR(A21, B11) + SMMR(A22, B21)
C22 = SMMR(A21, B12) + SMMR(A22, B22)
return C
实现细节:若创建 12 个新的 \(n/2\times n/2\) 矩阵,复制要 \(\Theta(n^2)\);用下标计算(以原矩阵的行下标范围和列下标范围表示子矩阵)可在 \(\Theta(1)\) 内完成划分(不过渐近上复制与否没有差别)。递归式:\(T(1)=\Theta(1)\) (4.15);\(n>1\) 时划分 \(\Theta(1)\),8 次递归调用 \(8T(n/2)\),4 次 \(n/2\times n/2\) 矩阵加法各 \(\Theta(n^2)\)(每个矩阵 \(n^2/4\) 个元素),
解为 \(\Theta(n^3)\),不比直接法快。若用复制实现划分(\(\Theta(n^2)\)),递归式不变。
关键提醒: \(\Theta\) 记号会吸收常数乘子(划分两个矩阵的 \(\Theta(2)\)、加法的 \(\Theta(n^2/4)\)、四次加法的 \(\Theta(4n^2)\) 都写成 \(\Theta(1)\) 或 \(\Theta(n^2)\)),但递归记号 \(T(n/2)\) 前的系数 8 不能吸收:它决定递归树每个结点的孩子个数,从而决定每层有多少项;忽略系数,递归树就从“茂密”(bushy)变成线性的。
Strassen 方法。 关键是让递归树稍微不那么茂密:只做 7 次而非 8 次 \(n/2\times n/2\) 矩阵的递归乘法,代价是常数次额外矩阵加法。四步:
- 按 (4.9) 把 \(A\)、\(B\)、\(C\) 分解为 \(n/2\times n/2\) 子矩阵,下标计算 \(\Theta(1)\)。
- 构造 10 个矩阵 \(S_1,\dots,S_{10}\),每个是第 1 步两个子矩阵的和或差,共 \(\Theta(n^2)\):
- 递归计算 7 个乘积:
只有中间一列的乘法需要真正执行。 4. 用 \(P_i\) 的加减组合得到 \(C\) 的四块(共 8 次加减,\(\Theta(n^2)\)):
逐项展开可验证:\(C_{11}\) 中 \(A_{11}B_{22}\)、\(A_{22}B_{11}\)、\(A_{22}B_{22}\)、\(A_{22}B_{21}\)、\(A_{12}B_{22}\) 等项两两抵消,剩 \(A_{11}B_{11}+A_{12}B_{21}\);\(C_{12}=A_{11}B_{12}+A_{12}B_{22}\);\(C_{21}=A_{21}B_{11}+A_{22}B_{21}\);\(C_{22}=A_{22}B_{22}+A_{21}B_{12}\),分别对应 (4.11)–(4.14)。
递归式(\(n=1\) 时做标量乘法):
用一次矩阵乘法换来常数次矩阵加法,主方法得 \(T(n)=\Theta(n^{\lg 7})\approx\Theta(n^{2.807})\)。空间:每层需 \(\Theta(n^2)\) 临时矩阵,总额外空间 \(\Theta(n^2)\)(几何级数求和)。Strassen 的方法“一点也不显然”(作者称这可能是全书最大的轻描淡写)。实用性见章末注记。
练习 4.2(4.2-3 至 4.2-5 需先读 4.5 节): 4.2-1 用 Strassen 算法计算 \(\begin{pmatrix}1&3\\7&5\end{pmatrix}\begin{pmatrix}6&8\\4&2\end{pmatrix}\)(结果 \(\begin{pmatrix}18&14\\62&66\end{pmatrix}\));4.2-2 写 Strassen 伪代码;4.2-3 \(n\) 不是 2 的幂时如何修改(补零扩充到不小于 \(n\) 的 2 的幂,至多翻倍,仍为 \(\Theta(n^{\lg7})\));4.2-4 若能用 \(k\) 次乘法(不假设乘法交换律)计算 \(3\times3\) 矩阵乘积,使 \(n\times n\) 乘法为 \(o(n^{\lg 7})\) 的最大 \(k\) 是多少(需 \(\log_3 k<\lg 7\),\(k\le21\),运行时间 \(\Theta(n^{\log_3 21})\approx n^{2.77}\));4.2-5 V. Pan 的方法:\(68\times68\) 用 132,464 次乘法、\(70\times70\) 用 143,640 次、\(72\times72\) 用 155,424 次,比较 \(\log_{68}132464\approx2.795\)、\(\log_{70}143640\approx2.7951\)、\(\log_{72}155424\approx2.7954\),以 \(70\times70\) 最佳,均优于 Strassen 的 2.807;4.2-6 用 Strassen 作子程序,\(kn\times n\) 乘 \(n\times kn\) 需 \(k^2\) 次 \(n\times n\) 乘法,\(\Theta(k^2n^{\lg7})\);反序 \(n\times kn\) 乘 \(kn\times n\) 需 \(k\) 次乘法加求和,\(\Theta(kn^{\lg7})\);4.2-7 只用三次实数乘法计算复数乘积 \((a+bi)(c+di)\) 的实部 \(ac-bd\) 与虚部 \(ad+bc\)(如计算 \(ac\)、\(bd\)、\((a+b)(c+d)\),虚部 \(=(a+b)(c+d)-ac-bd\))。
4.3 用代入法求解递归式(The substitution method for solving recurrences)(PDF p.104–109)
代入法两步:(1) 猜测解的形式;(2) 用数学归纳法求出常数并证明解正确。对较小值应用归纳假设时把猜测的解代入函数,故名“代入法”。方法强大,但必须能猜出答案形式;可用于证明上界或下界。
例: 求 \(T(n)=2T(\lfloor n/2\rfloor)+n\) (4.19) 的上界。猜 \(T(n)=O(n\lg n)\),需证 \(T(n)\le cn\lg n\)。假设对所有正整数 \(m<n\) 成立,特别是 \(m=\lfloor n/2\rfloor\):\(T(\lfloor n/2\rfloor)\le c\lfloor n/2\rfloor\lg(\lfloor n/2\rfloor)\)。代入:
只要 \(c\ge1\)。
边界条件: 还需证明解对边界条件成立。若 \(T(1)=1\) 是唯一边界条件,\(n=1\) 时 \(T(1)\le c\cdot1\cdot\lg1=0\) 与 \(T(1)=1\) 矛盾,归纳基础失败。解决:渐近记号只要求对 \(n\ge n_0\) 成立,\(n_0\) 可自选。\(n>3\) 时递归式不直接依赖 \(T(1)\),因此在归纳证明中用 \(T(2)\)、\(T(3)\) 代替 \(T(1)\) 作基础情况,令 \(n_0=2\)。区分递归式的基本情况(\(n=1\))与归纳证明的基础情况(\(n=2,3\))。由 \(T(1)=1\) 得 \(T(2)=4\)、\(T(3)=5\),取 \(c\ge2\) 即可使 \(T(2)\le c\cdot2\lg2\)、\(T(3)\le c\cdot3\lg3\)。对多数递归式,扩展边界条件使归纳假设对小 \(n\) 成立是直接的,以后不总是写出细节。
做出好的猜测: 没有通用方法,需要经验和创造力;启发式包括:
- 与见过的递归式相似则猜相似解:\(T(n)=2T(\lfloor n/2\rfloor+17)+n\) 看似困难,但 \(n\) 大时 \(\lfloor n/2\rfloor\) 与 \(\lfloor n/2\rfloor+17\) 差别不大,都把 \(n\) 近似对半分,猜 \(O(n\lg n)\)(练习 4.3-6 验证)。
- 先证宽松的上下界再缩小不确定范围:对 (4.19),由 \(n\) 项得下界 \(\Omega(n)\),可证初始上界 \(O(n^2)\),逐步降低上界、提高下界,收敛到 \(\Theta(n\lg n)\)。
- 用递归树(4.4 节)生成猜测。
微妙之处(减去低阶项): 有时猜对了渐近界,但归纳证明算不通,常因归纳假设不够强。例:\(T(n)=T(\lfloor n/2\rfloor)+T(\lceil n/2\rceil)+1\),猜 \(O(n)\),试证 \(T(n)\le cn\):\(T(n)\le c\lfloor n/2\rfloor+c\lceil n/2\rceil+1=cn+1\),无法推出 \(T(n)\le cn\)。不要去猜更大的 \(O(n^2)\)(虽然能证),原猜测是对的,只差一个低阶常数 1;归纳法必须证明归纳假设的精确形式。改为更强的假设 \(T(n)\le cn-d\)(\(d\ge0\)):
反直觉之处:用归纳法证上界时,证明较弱的上界可能更难,因为归纳中也只能用那个较弱的界。当递归式有多个递归项时,每个递归项都能减掉一次低阶项(此例减掉了两次 \(d\))。
避免陷阱: 对 (4.19) 错误地“证明” \(T(n)=O(n)\):猜 \(T(n)\le cn\),然后 \(T(n)\le2(c\lfloor n/2\rfloor)+n\le cn+n=O(n)\) ——错误!因为没有证明归纳假设的精确形式 \(T(n)\le cn\)。要证 \(T(n)=O(n)\) 必须明确证明 \(T(n)\le cn\)。
变量替换(changing variables): 例 \(T(n)=2T(\lfloor\sqrt n\rfloor)+\lg n\)。忽略取整,令 \(m=\lg n\) 得 \(T(2^m)=2T(2^{m/2})+m\);令 \(S(m)=T(2^m)\),得 \(S(m)=2S(m/2)+m\),与 (4.19) 相同,\(S(m)=O(m\lg m)\)。换回:\(T(n)=T(2^m)=S(m)=O(m\lg m)=O(\lg n\lg\lg n)\)。
练习 4.3: 4.3-1 证 \(T(n)=T(n-1)+n\) 的解为 \(O(n^2)\);4.3-2 证 \(T(n)=T(\lceil n/2\rceil)+1\) 的解为 \(O(\lg n)\)(需减低阶项,如证 \(T(n)\le c\lg(n-2)\) 或类似形式);4.3-3 证 \(T(n)=2T(\lfloor n/2\rfloor)+n\) 也是 \(\Omega(n\lg n)\),从而 \(\Theta(n\lg n)\);4.3-4 用不同的归纳假设(如 \(T(n)\le cn\lg n+n\))克服 \(T(1)=1\) 的边界困难而不调整基础情况;4.3-5 证明归并排序的“精确”递归式 (4.3) 的解为 \(\Theta(n\lg n)\);4.3-6 证 \(T(n)=2T(\lfloor n/2\rfloor+17)+n\) 为 \(O(n\lg n)\);4.3-7 \(T(n)=4T(n/3)+n\) 的解为 \(\Theta(n^{\log_34})\),证明直接假设 \(T(n)\le cn^{\log_34}\) 失败,再减去低阶项(如 \(cn^{\log_34}-dn\))使之成功;4.3-8 同理处理 \(T(n)=4T(n/2)+n=\Theta(n^2)\)(假设 \(cn^2-dn\));4.3-9 用变量替换解 \(T(n)=3T(\sqrt n)+\log n\)(令 \(m=\lg n\),\(S(m)=3S(m/2)+m\),得 \(\Theta(m^{\lg3})\),即 \(T(n)=\Theta((\lg n)^{\lg 3})\))。
4.4 用递归树方法求解递归式(The recursion-tree method for solving recurrences)(PDF p.109–114)
代入法给出简洁的正确性证明,但难以想出好猜测。画递归树是设计好猜测的直接方式:每个结点表示某个子问题的代价,先对每层求和得到每层代价,再把所有层代价相加得到总代价。递归树最适合生成猜测,再用代入法验证;生成猜测时可以容忍一点“不严谨”(sloppiness);若非常仔细地画树和求和,递归树本身也可以作为直接证明(4.6 节就用它直接证明主定理)。
例 1: \(T(n)=3T(\lfloor n/4\rfloor)+\Theta(n^2)\)。容忍的不严谨:忽略取整,写成 \(T(n)=3T(n/4)+cn^2\)(显式写出常数 \(c>0\)),并假设 \(n\) 是 4 的幂。图 4.5:根代价 \(cn^2\),三个孩子各 \(c(n/4)^2\),再下一层九个结点各 \(c(n/16)^2\)……深度 \(i\) 处子问题规模 \(n/4^i\),当 \(i=\log_4n\) 时规模为 1,故树有 \(\log_4 n+1\) 层(深度 \(0,1,\dots,\log_4n\))。深度 \(i\) 有 \(3^i\) 个结点,对 \(i=0,\dots,\log_4n-1\),每个结点代价 \(c(n/4^i)^2\),该层总代价 \(3^ic(n/4^i)^2=(3/16)^icn^2\)。最底层有 \(3^{\log_4 n}=n^{\log_43}\) 个结点,每个代价 \(T(1)\),合计 \(\Theta(n^{\log_43})\)。总代价:
再次容忍不严谨,用无穷递减几何级数作上界 (A.6):
\(cn^2\) 的系数构成递减几何级数,和不超过常数 \(16/13\);根贡献 \(cn^2\),占总代价常数比例——根的代价主导总代价。若 \(O(n^2)\) 确为上界则必为紧确界,因为第一次递归调用就贡献 \(\Theta(n^2)\),故 \(\Omega(n^2)\) 是下界。
代入法验证:证 \(T(n)\le dn^2\):
只要 \(d\ge(16/13)c\)。
例 2(不等分割): \(T(n)=T(n/3)+T(2n/3)+O(n)\),\(c\) 为 \(O(n)\) 中的常数(图 4.6)。顶部各层代价都是 \(cn\)。从根到叶的最长简单路径 \(n\to(2/3)n\to(2/3)^2n\to\cdots\to1\),\((2/3)^kn=1\) 时 \(k=\log_{3/2}n\),树高 \(\log_{3/2}n\)。直观上解至多为层数 × 每层代价 \(=O(cn\log_{3/2}n)=O(n\lg n)\)。但并非每层都贡献 \(cn\):若它是高 \(\log_{3/2}n\) 的完全二叉树,叶子数为 \(2^{\log_{3/2}n}=n^{\log_{3/2}2}\),叶子总代价 \(\Theta(n^{\log_{3/2}2})\),由于 \(\log_{3/2}2>1\),这是 \(\omega(n\lg n)\);然而树不是完全二叉树,叶子更少,越往下缺失的内部结点越多,底部各层贡献少于 \(cn\)。不必精确核算,容忍不严谨,直接用代入法验证 \(O(n\lg n)\):证 \(T(n)\le dn\lg n\),
只要 \(d\ge c/(\lg3-2/3)\)。
练习 4.4: 4.4-1 \(T(n)=3T(\lfloor n/2\rfloor)+n\)(答 \(\Theta(n^{\lg3})\));4.4-2 \(T(n)=T(n/2)+n^2\)(\(\Theta(n^2)\));4.4-3 \(T(n)=4T(n/2+2)+n\)(\(\Theta(n^2)\));4.4-4 \(T(n)=2T(n-1)+1\)(\(\Theta(2^n)\));4.4-5 \(T(n)=T(n-1)+T(n/2)+n\)(上界可取 \(O(2^n)\),实际增长是超多项式、次指数级,练习考查画树与代入验证);4.4-6 用递归树论证 \(T(n)=T(n/3)+T(2n/3)+cn\) 为 \(\Omega(n\lg n)\)(最短路径深度 \(\log_3n\),之前各层都满 \(cn\));4.4-7 画 \(T(n)=4T(\lfloor n/2\rfloor)+cn\) 的递归树并给紧确界(\(\Theta(n^2)\)),用代入法验证;4.4-8 \(T(n)=T(n-a)+T(a)+cn\)(\(a\ge1\)、\(c>0\) 为常数),解为 \(\Theta(n^2)\);4.4-9 \(T(n)=T(\alpha n)+T((1-\alpha)n)+cn\)(\(0<\alpha<1\)),解为 \(\Theta(n\lg n)\)——任何常数比例的分割都给出 \(n\lg n\)(这正是第 7 章快速排序分析的关键直觉)。
4.5 用主方法求解递归式(The master method for solving recurrences)(PDF p.114–118)
主方法是求解
(\(a\ge1\)、\(b>1\) 为常数,\(f(n)\) 渐近正)的“食谱”式方法:记住三种情况后,很多递归式无需纸笔即可解出。含义:把规模 \(n\) 的问题分成 \(a\) 个规模 \(n/b\) 的子问题递归求解,\(f(n)\) 涵盖分解与合并的代价。例:Strassen 算法 \(a=7,b=2,f(n)=\Theta(n^2)\)。技术上 \(n/b\) 可能非整数,把 \(a\) 个 \(T(n/b)\) 换成 \(T(\lfloor n/b\rfloor)\) 或 \(T(\lceil n/b\rceil)\) 不影响渐近行为(4.6 节证明),故通常省略取整。
定理 4.1(主定理,master theorem): 令 \(a\ge1\)、\(b>1\) 为常数,\(f(n)\) 为函数,\(T(n)\) 在非负整数上由 \(T(n)=aT(n/b)+f(n)\) 定义(\(n/b\) 解释为 \(\lfloor n/b\rfloor\) 或 \(\lceil n/b\rceil\))。则:
- 若对某常数 \(\epsilon>0\) 有 \(f(n)=O(n^{\log_ba-\epsilon})\),则 \(T(n)=\Theta(n^{\log_ba})\)。
- 若 \(f(n)=\Theta(n^{\log_ba})\),则 \(T(n)=\Theta(n^{\log_ba}\lg n)\)。
- 若对某常数 \(\epsilon>0\) 有 \(f(n)=\Omega(n^{\log_ba+\epsilon})\),且对某常数 \(c<1\) 和所有足够大的 \(n\) 有 \(af(n/b)\le cf(n)\)(正则条件,regularity condition),则 \(T(n)=\Theta(f(n))\)。
直观: 比较 \(f(n)\) 与 \(n^{\log_ba}\),较大者决定解。情况 1 \(n^{\log_ba}\) 更大,解为 \(\Theta(n^{\log_ba})\);情况 3 \(f(n)\) 更大,解为 \(\Theta(f(n))\);情况 2 两者同阶,乘一个对数因子,\(\Theta(n^{\log_ba}\lg n)=\Theta(f(n)\lg n)\)。
技术要点: 情况 1 中 \(f\) 必须“多项式地”小于 \(n^{\log_ba}\),即小一个 \(n^\epsilon\) 因子;情况 3 中 \(f\) 必须多项式地大于 \(n^{\log_ba}\),且满足正则条件(我们遇到的多项式有界函数大多满足)。三种情况没有覆盖所有可能:情况 1 与 2 之间有空隙(\(f\) 小于但非多项式地小于 \(n^{\log_ba}\)),情况 2 与 3 之间也有空隙(大于但非多项式地大于);\(f\) 落入空隙或情况 3 正则条件不成立时,主方法不适用。
例子:
- \(T(n)=9T(n/3)+n\):\(a=9,b=3\),\(n^{\log_39}=\Theta(n^2)\),\(f(n)=O(n^{2-\epsilon})\)(\(\epsilon=1\)),情况 1,\(T(n)=\Theta(n^2)\)。
- \(T(n)=T(2n/3)+1\):\(a=1,b=3/2\),\(n^{\log_{3/2}1}=n^0=1\),\(f(n)=\Theta(1)\),情况 2,\(T(n)=\Theta(\lg n)\)。
- \(T(n)=3T(n/4)+n\lg n\):\(n^{\log_43}=O(n^{0.793})\),\(f(n)=\Omega(n^{\log_43+\epsilon})\)(\(\epsilon\approx0.2\));正则条件:\(af(n/b)=3(n/4)\lg(n/4)\le(3/4)n\lg n=cf(n)\),\(c=3/4\)。情况 3,\(T(n)=\Theta(n\lg n)\)。
- 主方法不适用:\(T(n)=2T(n/2)+n\lg n\)。\(n^{\log_ba}=n\),\(f(n)=n\lg n\) 渐近大于 \(n\),但不是多项式地大于:比值 \(\lg n\) 对任何 \(\epsilon>0\) 都渐近小于 \(n^\epsilon\),落入情况 2 与 3 之间的空隙(解见练习 4.6-2:\(\Theta(n\lg^2n)\))。
- (4.7) \(T(n)=2T(n/2)+\Theta(n)\)(最大子数组、归并排序):\(n^{\log_22}=n\),情况 2,\(\Theta(n\lg n)\)。
- (4.17) \(T(n)=8T(n/2)+\Theta(n^2)\):\(n^{\log_28}=n^3\),\(f(n)=O(n^{3-\epsilon})\)(\(\epsilon=1\)),情况 1,\(\Theta(n^3)\)。
- (4.18) \(T(n)=7T(n/2)+\Theta(n^2)\):\(n^{\lg7}\),\(2.80<\lg7<2.81\),\(f(n)=O(n^{\lg7-\epsilon})\)(\(\epsilon=0.8\)),情况 1,\(\Theta(n^{\lg7})\)。
练习 4.5: 4.5-1 用主方法给出紧确界:(a) \(2T(n/4)+1\):\(\Theta(\sqrt n)\);(b) \(2T(n/4)+\sqrt n\):\(\Theta(\sqrt n\lg n)\);(c) \(2T(n/4)+n\):\(\Theta(n)\);(d) \(2T(n/4)+n^2\):\(\Theta(n^2)\)。4.5-2 Caesar 教授想设计比 Strassen 渐近更快的矩阵乘法:分成 \(n/4\times n/4\) 块,分解与合并 \(\Theta(n^2)\),\(T(n)=aT(n/4)+\Theta(n^2)\),求使其快于 Strassen 的最大整数 \(a\)(需 \(\log_4a<\lg7\),即 \(a<49\),答 48)。4.5-3 用主方法证明二分查找 \(T(n)=T(n/2)+\Theta(1)\) 的解为 \(\Theta(\lg n)\)。4.5-4 \(T(n)=4T(n/2)+n^2\lg n\) 能否用主方法(不能,落入空隙;上界 \(O(n^2\lg^2n)\))。4.5-5★ 给出满足情况 3 除正则条件外所有条件的 \(a,b,f\)(如 \(a=1,b=2,f(n)=n(2-\cos n)\))。
4.6 ★ 主定理的证明(Proof of the master theorem)(PDF p.118–128)
应用主定理不需要理解证明。证明分两部分:第一部分假设 \(T(n)\) 只在 \(b\) 的精确幂(\(n=1,b,b^2,\dots\))上定义,给出全部直觉;第二部分把分析推广到所有正整数,处理取整。
有限定义域上的渐近记号警告: 本节有时对只在 \(b\) 的幂上定义的函数使用渐近记号,这是小滥用,但必须警惕得出不当结论。例:证明 \(n\) 为 2 的幂时 \(T(n)=O(n)\) 并不保证 \(T(n)=O(n)\)——若 \(T(n)=n\)(\(n=1,2,4,8,\ldots\))而其余 \(T(n)=n^2\),则对所有 \(n\) 成立的最好上界是 \(O(n^2)\)。因此本书只在上下文明确时才在受限定义域上使用渐近记号。
4.6.1 精确幂情形的证明
引理 4.2: 设 \(a\ge1\)、\(b>1\) 为常数,\(f(n)\) 是定义在 \(b\) 的精确幂上的非负函数,\(T(1)=\Theta(1)\),\(T(n)=aT(n/b)+f(n)\)(\(n=b^i\),\(i\) 为正整数)。则
证明(递归树,图 4.7):根代价 \(f(n)\),有 \(a\) 个孩子各代价 \(f(n/b)\)(可设想 \(a\) 为整数,但数学上不需要);深度 \(j\) 有 \(a^j\) 个结点,各代价 \(f(n/b^j)\)。叶子代价 \(T(1)=\Theta(1)\),都在深度 \(\log_bn\)(因 \(n/b^{\log_bn}=1\)),共 \(a^{\log_bn}=n^{\log_ba}\) 个叶子。深度 \(j\) 所有内部结点代价 \(a^jf(n/b^j)\),内部结点总代价为求和项,代表分解与合并的代价;叶子总代价 \(\Theta(n^{\log_ba})\),即求解所有 \(n^{\log_ba}\) 个规模为 1 的子问题的代价。递归树视角下三种情况分别对应:总代价 (1) 由叶子主导;(2) 均匀分布在各层;(3) 由根主导。
引理 4.3: 同样条件下,定义在 \(b\) 的精确幂上的
满足:
- 若 \(f(n)=O(n^{\log_ba-\epsilon})\),则 \(g(n)=O(n^{\log_ba})\)。
- 若 \(f(n)=\Theta(n^{\log_ba})\),则 \(g(n)=\Theta(n^{\log_ba}\lg n)\)。
- 若对某常数 \(c<1\) 及足够大的 \(n\) 有 \(af(n/b)\le cf(n)\),则 \(g(n)=\Theta(f(n))\)。
证明:
- 情况 1:\(f(n/b^j)=O((n/b^j)^{\log_ba-\epsilon})\),代入得 \(g(n)=O\big(\sum_j a^j(n/b^j)^{\log_ba-\epsilon}\big)\) (4.23)。提出因子化简为递增几何级数:
\(b,\epsilon\) 为常数,故为 \(n^{\log_ba-\epsilon}O(n^\epsilon)=O(n^{\log_ba})\)。
- 情况 2:\(f(n/b^j)=\Theta((n/b^j)^{\log_ba})\),代入 (4.24),每项相同:\(\sum_j a^j(n/b^j)^{\log_ba}=n^{\log_ba}\sum_j(a/b^{\log_ba})^j=n^{\log_ba}\sum_j1=n^{\log_ba}\log_bn\),故 \(g(n)=\Theta(n^{\log_ba}\log_bn)=\Theta(n^{\log_ba}\lg n)\)。
- 情况 3:\(f(n)\) 出现在 \(g(n)\) 中且各项非负,故 \(g(n)=\Omega(f(n))\)。把正则条件改写为 \(f(n/b)\le(c/a)f(n)\),迭代 \(j\) 次得 \(f(n/b^j)\le(c/a)^jf(n)\),即 \(a^jf(n/b^j)\le c^jf(n)\)(对足够大的参数成立;至多常数个参数最小的项不满足,它们 \(a^jf(n/b^j)=O(1)\))。于是递减几何级数:
故 \(g(n)=\Theta(f(n))\)。
引理 4.4(精确幂版本的主定理): 结论与定理 4.1 三种情况相同。证明:用引理 4.3 估计 (4.21) 的求和:情况 1 \(T(n)=\Theta(n^{\log_ba})+O(n^{\log_ba})=\Theta(n^{\log_ba})\);情况 2 \(T(n)=\Theta(n^{\log_ba})+\Theta(n^{\log_ba}\lg n)=\Theta(n^{\log_ba}\lg n)\);情况 3 \(T(n)=\Theta(n^{\log_ba})+\Theta(f(n))=\Theta(f(n))\),因为 \(f(n)=\Omega(n^{\log_ba+\epsilon})\)。
4.6.2 向下取整与向上取整
需把分析推广到含取整、对所有整数定义的递归式。对 \(T(n)=aT(\lceil n/b\rceil)+f(n)\) (4.25) 求下界、对 \(T(n)=aT(\lfloor n/b\rfloor)+f(n)\) (4.26) 求上界是常规的(分别用 \(\lceil n/b\rceil\ge n/b\)、\(\lfloor n/b\rfloor\le n/b\) 推下去)。(4.26) 的下界与 (4.25) 的上界方法相同,只介绍后者。
递归树(图 4.8)中递归调用的参数序列为 \(n,\lceil n/b\rceil,\lceil\lceil n/b\rceil/b\rceil,\ldots\),记
目标是找到使 \(n_k\) 为常数的深度 \(k\)。由 \(\lceil x\rceil\le x+1\):\(n_0\le n\),\(n_1\le n/b+1\),\(n_2\le n/b^2+1/b+1\),\(n_3\le n/b^3+1/b^2+1/b+1\),一般地
取 \(j=\lfloor\log_bn\rfloor\):\(n_{\lfloor\log_bn\rfloor}<\frac{n}{b^{\lfloor\log_bn\rfloor}}+\frac{b}{b-1}<\frac{n}{b^{\log_bn-1}}+\frac{b}{b-1}=\frac{n}{n/b}+\frac{b}{b-1}=b+\frac{b}{b-1}=O(1)\),即深度 \(\lfloor\log_bn\rfloor\) 处问题规模至多为常数。于是
与 (4.21) 几乎相同,只是 \(n\) 为任意整数。按引理 4.3 方式估计 \(g(n)=\sum_{j=0}^{\lfloor\log_bn\rfloor-1}a^jf(n_j)\) (4.29):
- 情况 3:若 \(af(\lceil n/b\rceil)\le cf(n)\) 对 \(n>b+b/(b-1)\) 成立(\(c<1\)),则 \(a^jf(n_j)\le c^jf(n)\),同引理 4.3 处理。
- 情况 2:\(f(n)=\Theta(n^{\log_ba})\)。只要证明 \(f(n_j)=O(n^{\log_ba}/a^j)=O((n/b^j)^{\log_ba})\),引理 4.3 情况 2 的证明即可照搬。\(j\le\lfloor\log_bn\rfloor\) 蕴含 \(b^j/n\le1\)。由 \(f(n)=O(n^{\log_ba})\),存在常数 \(c>0\) 使足够大的 \(n_j\) 满足
因为 \(c(1+b/(b-1))^{\log_ba}\) 为常数。
- 情况 1 几乎相同,关键是证明 \(f(n_j)=O((n/b^j)^{\log_ba-\epsilon})\),代数更复杂。
由此对所有整数 \(n\) 证明了主定理的上界;下界证明类似。
练习 4.6(均为 ★): 4.6-1 当 \(b\) 为正整数时给出 \(n_j\) 的简单精确表达式(\(n_j=\lceil n/b^j\rceil\),利用 (3.4));4.6-2 证明若 \(f(n)=\Theta(n^{\log_ba}\lg^kn)\)(\(k\ge0\)),则主递归式解为 \(\Theta(n^{\log_ba}\lg^{k+1}n)\)(仅考虑 \(b\) 的精确幂)——这是情况 2 的推广,可解 \(2T(n/2)+n\lg n=\Theta(n\lg^2n)\);4.6-3 证明情况 3 的陈述是“过度”的:正则条件 \(af(n/b)\le cf(n)\)(\(c<1\))本身就蕴含存在 \(\epsilon>0\) 使 \(f(n)=\Omega(n^{\log_ba+\epsilon})\)。
第 4 章 思考题(PDF p.128–132)
- 4-1 递归式例子(\(n\le2\) 时 \(T(n)\) 为常数,给出尽可能紧的上下界):(a) \(2T(n/2)+n^4=\Theta(n^4)\);(b) \(T(7n/10)+n=\Theta(n)\);(c) \(16T(n/4)+n^2=\Theta(n^2\lg n)\);(d) \(7T(n/3)+n^2=\Theta(n^2)\);(e) \(7T(n/2)+n^2=\Theta(n^{\lg7})\);(f) \(2T(n/4)+\sqrt n=\Theta(\sqrt n\lg n)\);(g) \(T(n-2)+n^2=\Theta(n^3)\)。
- 4-2 参数传递代价: 本书假设过程调用传参为常数时间,即使传的是 \(N\) 元数组(大多数系统传指针)。考察三种策略:(1) 按指针传数组,\(\Theta(1)\);(2) 复制整个数组,\(\Theta(N)\);(3) 只复制被调过程可能访问的子范围 \(A[p..q]\),\(\Theta(q-p+1)\)。(a) 递归二分查找三种情况的递归式与上界:\(T(n)=T(n/2)+c\) 得 \(\Theta(\lg N)\);\(T(n)=T(n/2)+cN\) 得 \(\Theta(N\lg N)\);\(T(n)=T(n/2)+cn\) 得 \(\Theta(N)\)。(b) 归并排序:\(\Theta(N\lg N)\);\(T(n)=2T(n/2)+cn+2N\) 得 \(\Theta(N^2)\);\(\Theta(N\lg N)\)。考点:实现细节(拷贝 vs 引用)如何改变复杂度。
- 4-3 更多递归式例子: (a) \(4T(n/3)+n\lg n=\Theta(n^{\log_34})\);(b) \(3T(n/3)+n/\lg n=\Theta(n\lg\lg n)\)(主方法不适用,需直接求和调和级数);(c) \(4T(n/2)+n^2\sqrt n=\Theta(n^{2.5})\);(d) \(3T(n/3-2)+n/2=\Theta(n\lg n)\);(e) \(2T(n/2)+n/\lg n=\Theta(n\lg\lg n)\);(f) \(T(n/2)+T(n/4)+T(n/8)+n=\Theta(n)\)(\(1/2+1/4+1/8<1\));(g) \(T(n-1)+1/n=\Theta(\lg n)\);(h) \(T(n-1)+\lg n=\Theta(n\lg n)\);(i) \(T(n-2)+1/\lg n=\Theta(n/\lg n)\);(j) \(\sqrt nT(\sqrt n)+n=\Theta(n\lg\lg n)\)。
- 4-4 斐波那契数(生成函数法): 定义生成函数(形式幂级数)\(\mathcal F(z)=\sum_{i\ge0}F_iz^i=z+z^2+2z^3+3z^4+5z^5+8z^6+13z^7+21z^8+\cdots\)。(a) 证 \(\mathcal F(z)=z+z\mathcal F(z)+z^2\mathcal F(z)\);(b) 证 \(\mathcal F(z)=\dfrac{z}{1-z-z^2}=\dfrac{z}{(1-\phi z)(1-\hat\phi z)}=\dfrac{1}{\sqrt5}\Big(\dfrac{1}{1-\phi z}-\dfrac{1}{1-\hat\phi z}\Big)\),\(\phi=\frac{1+\sqrt5}{2}=1.61803\ldots\),\(\hat\phi=\frac{1-\sqrt5}{2}=-0.61803\ldots\);(c) 证 \(\mathcal F(z)=\sum_{i\ge0}\frac{1}{\sqrt5}(\phi^i-\hat\phi^i)z^i\);(d) 由 (c) 及 \(|\hat\phi|<1\) 证明 \(i>0\) 时 \(F_i\) 等于 \(\phi^i/\sqrt5\) 四舍五入到最近整数。
- 4-5 芯片检测: Diogenes 教授有 \(n\) 片据称相同的芯片,可两两互测:测试台一次放两片,各自报告对方好坏。好芯片总是如实报告,坏芯片的报告不可信。四种结果:A 说 B 好、B 说 A 好 → 两片都好或都坏;其余三种(至少一方说对方坏)→ 至少一片坏。(a) 证明若至少 \(n/2\) 片是坏的,则任何基于两两测试的策略都不一定能确定哪些芯片是好的(坏芯片可以合谋);(b) 假设超过 \(n/2\) 片是好的,证明 \(\lfloor n/2\rfloor\) 次两两测试足以把“找到一个好芯片”的问题规模减少到接近一半(两两配对,凡报告“都好”的对只留一片,其余丢弃;奇数时特殊处理);(c) 证明在好芯片超过半数时可用 \(\Theta(n)\) 次测试识别出所有好芯片,递归式 \(T(n)\le T(\lceil n/2\rceil)+\lfloor n/2\rfloor\),解为 \(\Theta(n)\),再用找到的好芯片测试其余 \(n-1\) 片。
- 4-6 Monge 阵列: \(m\times n\) 实数阵列 \(A\) 若对所有 \(1\le i<k\le m\)、\(1\le j<l\le n\) 满足 \(A[i,j]+A[k,l]\le A[i,l]+A[k,j]\),称为 Monge 阵列——任取两行两列,左上加右下不超过左下加右上。例(7 行 5 列 Monge 阵列,逐行):\([10,17,13,28,23]\)、\([17,22,16,29,23]\)、\([24,28,22,34,24]\)、\([11,13,6,17,7]\)、\([45,44,32,37,23]\)、\([36,33,19,21,6]\)、\([75,66,51,53,34]\)。 (a) 证明阵列是 Monge 的当且仅当对所有相邻行列 \(A[i,j]+A[i+1,j+1]\le A[i,j+1]+A[i+1,j]\)(“若”方向对行、列分别归纳);(b) 给定一个非 Monge 阵列(5 行 4 列,逐行 \([37,23,22,32]\)、\([21,6,7,10]\)、\([53,34,30,31]\)、\([32,13,9,6]\)、\([43,21,15,8]\)),只改一个元素使之成为 Monge(用 (a) 检查相邻 2×2;问题出在第 1–2 行、第 2–3 列:\(23+7>22+6\),如把 \(A[1,3]=22\) 改为 24 即可);(c) 令 \(f(i)\) 为第 \(i\) 行最左最小元素所在列,证明 \(f(1)\le f(2)\le\cdots\le f(m)\);(d) 分治算法:取偶数行构成子阵 \(A'\),递归求其每行最左最小值,再在 \(O(m+n)\) 时间内求奇数行的最左最小值(利用 (c) 的单调性,第 \(2k+1\) 行只需在 \(f(2k)\) 与 \(f(2k+2)\) 之间搜索);(e) 递归式 \(T(m)=T(m/2)+O(m+n)\),解为 \(O(m+n\log m)\)。
章末注记(PDF p.132–134):
- 分治作为算法设计技术至少可追溯到 1962 年 Karatsuba 与 Ofman 的论文;据 Heideman 等,高斯在 1805 年已提出第一个 FFT 算法,其表述就是把问题分解为子问题再合并。最大子数组问题是 Bentley《Programming Pearls》第 7 章问题的小变体。
- Strassen 算法 1969 年发表时引起轰动,此前很少有人想到存在渐近快于基本方法的算法。此后上界不断改进,当时最好的是 Coppersmith–Winograd 的 \(O(n^{2.376})\);已知最好的下界只是显然的 \(\Omega(n^2)\)(必须填满 \(n^2\) 个元素)。
- 实践中 Strassen 常不是首选,四个原因:(1) \(\Theta(n^{\lg7})\) 中隐藏的常数因子比 \(\Theta(n^3)\) 的直接法大;(2) 稀疏矩阵有更快的专用方法;(3) 数值稳定性不如直接法——浮点有限精度下误差积累更大;(4) 各层递归形成的子矩阵占用空间。后两点在 1990 年前后被缓解:Higham 指出数值稳定性差异被夸大,对某些应用不可接受但对另一些可接受;Bailey、Lee、Simon 讨论了降低内存需求的技术。实际的稠密矩阵快速乘法实现在规模高于“交叉点”时用 Strassen,低于时切换回简单方法。交叉点高度依赖系统:只计操作数、忽略 cache 与流水线的分析给出 \(n=8\)(Higham)或 \(n=12\)(Huss-Lederman 等);D'Alberto 与 Nicolau 的自适应方案在安装时基准测试,在不同系统上测得交叉点 \(n=400\) 到 \(2150\),在某些系统上甚至找不到交叉点。
- 递归式早在 1202 年就由斐波那契研究;De Moivre 引入生成函数法求解递归式。主方法改编自 Bentley、Haken、Saxe,其扩展形式即练习 4.6-2。
- Akra–Bazzi 方法(Leighton 修改版)可解更一般的分治递归式:
其中 \(x\ge1\) 为实数;\(x_0\) 为常数,满足 \(x_0\ge1/b_i\) 与 \(x_0\ge1/(1-b_i)\);\(a_i>0\) 为常数;\(0<b_i<1\) 为常数;\(k\ge1\) 为整数常数;\(f(x)\) 非负且满足多项式增长条件(存在正常数 \(c_1,c_2\),使对所有 \(x\ge1\)、所有 \(i\) 及所有 \(b_ix\le u\le x\) 有 \(c_1f(x)\le f(u)\le c_2f(x)\);若 \(|f'(x)|\) 有多项式上界则满足,如 \(f(x)=x^\alpha\lg^\beta x\))。求解:先找唯一实数 \(p\) 使 \(\sum_{i=1}^ka_ib_i^p=1\)(总存在),则
主方法不适用于 \(T(n)=T(\lfloor n/3\rfloor)+T(\lfloor2n/3\rfloor)+O(n)\),但 Akra–Bazzi 适用(\(p=1\),得 \(\Theta(n\lg n)\))。它较难使用,但能处理子问题规模差别很大的分割;主方法简单,但只适用于子问题规模相等的情况。
第 4 章 本章要点
- 分治设计:最大子数组 \(\Theta(n\lg n)\)(另有 Kadane 线性算法);矩阵乘法朴素分治 \(8T(n/2)+\Theta(n^2)=\Theta(n^3)\),Strassen \(7T(n/2)+\Theta(n^2)=\Theta(n^{\lg7})\approx n^{2.81}\)。
- 递归式中 \(\Theta\) 可吸收常数,但 \(T(n/b)\) 前的系数 \(a\) 不可吸收,它决定递归树分支数。
- 代入法:猜测 + 归纳;注意必须证明精确形式,必要时减去低阶项强化假设;边界条件可通过选 \(n_0\) 处理;变量替换可化归熟悉形式。
- 递归树:逐层求和产生猜测(几何级数递减→根主导,递增→叶主导,相等→乘层数)。
- 主定理三种情况及空隙、正则条件;扩展:\(f=\Theta(n^{\log_ba}\lg^kn)\Rightarrow\Theta(n^{\log_ba}\lg^{k+1}n)\);更一般的不等分割用 Akra–Bazzi。
第 4 章 与量化交易的关联
- 最大子数组问题本身就是金融问题:给定价格序列求“事后最优单次买卖”的最大利润,即价格变化序列的最大子数组和;其对偶——最大回撤(maximum drawdown)——是收益序列的“最小子数组和”,可用同样的线性扫描(Kadane 思路:维护历史最高点与当前回撤)在 \(O(n)\) 计算,是回测报告的标准指标。需注意这是事后(look-ahead)概念,只能用于评估,不能作为可交易信号。
- 矩阵乘法复杂度直接决定协方差估计、因子暴露计算、组合优化的成本。实务中用 BLAS/LAPACK 的分块 \(\Theta(n^3)\) 实现而非 Strassen(常数、数值稳定性、内存原因与章末注记一致);但理解 \(O(n^3)\) 让你判断几千只股票的协方差求逆是否可接受、何时改用因子模型或低秩近似。
- 主定理用于估计自研分治/递归算法的成本(例如多尺度分析、分层聚类风险平价 HRP 中的递归二分配权、区间树/线段树查询)。
- 思考题 4-2 提醒:Python/NumPy 中切片复制与视图的区别会把 \(\Theta(N\lg N)\) 变成 \(\Theta(N^2)\),回测引擎中按值传递大 DataFrame 是常见性能陷阱。
- Monge 性质(4-6)在最优执行/动态规划加速(如 SMAWK、四边形不等式优化)中出现,可用于加速某些交易成本相关的 DP。
第 4 章 推荐习题
- 4.1-5:线性时间最大子数组(Kadane),并改写为最大回撤计算。
- 4.2-7:三次乘法计算复数乘积(Gauss 技巧,与 Karatsuba 相通)。
- 4.3-7、4.3-8:练习“减去低阶项”的代入法技巧。
- 4.4-9:任意常数比例分割仍为 \(\Theta(n\lg n)\),为快速排序分析打基础。
- 4.5-1、思考题 4-1、4-3:熟练使用主方法并识别不适用情形。
- 4.6-2:主定理情况 2 的扩展,实用。
- 思考题 4-2:参数传递代价对复杂度的影响。
第 5 章 概率分析与随机算法(Probabilistic Analysis and Randomized Algorithms)(PDF p.135–166)
本章介绍概率分析和随机算法;概率论基础见附录 C。后续章节会多次回到这两个主题。
5.1 雇用问题(The hiring problem)(PDF p.135–139)
情境: 你要雇一名办公助理,通过职业介绍所,每天来一位候选人。面试每人需付介绍所少量费用;真正雇用代价高——要解雇现任助理并付介绍所一大笔雇用费。你承诺任何时候都要有最好的人选:面试后若该候选人优于现任助理,就解雇现任、雇用新人。想估计这一策略的总费用。
def hire_assistant(n):
best = 0 # 0 号为虚拟的最差候选人
for i in 1..n:
interview(i)
if i is better than best:
best = i
hire(i)
代价模型: 关注的不是运行时间而是面试和雇用的费用,但分析技术相同——都是统计某些基本操作的执行次数。面试代价 \(c_i\) 低,雇用代价 \(c_h\) 高,雇用 \(m\) 人则总代价 \(O(c_in+c_hm)\)。面试费 \(c_in\) 固定,所以重点分析随每次运行变化的雇用费 \(c_hm\)。这一情境是常见计算范式的模型:扫描序列找最大值/最小值并维护当前“胜者”,雇用问题刻画的是“当前胜者”被更新的频率。
最坏情况: 候选人按质量严格递增出现,每人都被雇用,雇用费 \(O(c_hn)\)。但我们既不知道也无法控制到达顺序,因此自然要问典型或平均情况。
概率分析(probabilistic analysis): 在问题分析中使用概率,最常用于分析运行时间,也可分析其他量(如雇用费)。需要了解或假设输入分布,然后对所有可能输入的分布取平均,得到平均情况运行时间(average-case running time)。必须谨慎选择输入分布:有些问题可以合理假设,从而把概率分析作为设计高效算法和洞察问题的手段;有些问题无法描述合理的输入分布,则不能用概率分析。
对雇用问题,假设候选人以随机顺序到达:任意两人可比较优劣,即候选人之间存在全序(total order);用 \(rank(i)\in\{1,\dots,n\}\) 表示第 \(i\) 人的唯一名次,名次越高越优。\(\langle rank(1),\dots,rank(n)\rangle\) 是 \(\langle1,\dots,n\rangle\) 的一个排列;“随机顺序到达”等价于该名次列表等可能地是 \(n!\) 个排列中的任一个,称名次构成均匀随机排列(uniform random permutation)。
随机算法(randomized algorithms): 很多情况下我们对输入分布所知甚少,或即使知道也无法用计算方式建模;但可以让算法部分行为随机化,把概率和随机性作为设计与分析工具。雇用问题中,我们无法知道候选人是否真的随机到达,因此改变模型:介绍所预先发来 \(n\) 位候选人名单,我们每天随机选择面试谁。这样就不依赖“候选人随机到达”的猜测,而是控制了过程并强制了随机顺序。
一般地,若算法的行为不仅由输入决定,还由随机数发生器产生的值决定,则称其为随机的(randomized)。假设有随机数发生器 RANDOM:RANDOM(a, b) 等可能地返回 \(a\) 到 \(b\)(含)之间的整数,如 RANDOM(0,1) 以 1/2 概率返回 0 或 1,RANDOM(3,7) 以各 1/5 概率返回 3–7;每次返回值与之前独立,可想象成掷一个 \(b-a+1\) 面的骰子。(实践中多数编程环境提供伪随机数发生器(pseudorandom-number generator):确定性算法,产生“看起来”统计随机的数。)
术语区分: 分析随机算法时,对随机数发生器返回值的分布取期望,称为期望运行时间(expected running time);概率分布在输入上时称平均情况运行时间,算法自身做随机选择时称期望运行时间。
练习 5.1: 5.1-1 说明“第 4 行总能判断哪位候选人最好”这一假设蕴含了候选人名次上的全序;5.1-2★ 只用 RANDOM(0,1) 实现 RANDOM(a,b),给出期望运行时间(生成 \(\lceil\lg(b-a+1)\rceil\) 位随机数,超出范围则重试,期望 \(O(\lg(b-a))\));5.1-3★ 有一个以未知概率 \(p\) 输出 1 的 BIASED-RANDOM,构造输出无偏 0/1 的算法并求期望时间(von Neumann 技巧:连续调用两次,结果为 01 输出 0、10 输出 1,相同则重来;期望调用次数 \(1/(p(1-p))\))。
5.2 指示器随机变量(Indicator random variables)(PDF p.139–143)
指示器随机变量提供了概率与期望之间转换的便捷方法。给定样本空间 \(S\) 与事件 \(A\),
例:抛一次均匀硬币,\(S=\{H,T\}\),\(\Pr\{H\}=\Pr\{T\}=1/2\),\(X_H=I\{H\}\) 统计正面次数,\(E[X_H]=1\cdot\Pr\{H\}+0\cdot\Pr\{T\}=1/2\)。
引理 5.1: 给定样本空间 \(S\) 和事件 \(A\),令 \(X_A=I\{A\}\),则 \(E[X_A]=\Pr\{A\}\)。证明:\(E[X_A]=1\cdot\Pr\{A\}+0\cdot\Pr\{\bar A\}=\Pr\{A\}\),\(\bar A=S-A\)。
指示器随机变量在单次抛硬币中显得繁琐,但在重复随机试验中很有用。例:\(n\) 次抛硬币的正面数,令 \(X_i=I\{\text{第 }i\text{ 次正面}\}\),\(X=\sum_{i=1}^nX_i\),由期望的线性性(linearity of expectation,(C.21))
比 (C.37) 逐一计算恰有 0、1、2… 次正面的概率简单得多。期望的线性性即使随机变量之间相关也成立,这使指示器随机变量成为强大的分析技术,全书将反复使用。
用指示器随机变量分析雇用问题。 假设候选人随机顺序到达(5.3 节会去掉此假设)。\(X\) 为雇用次数。直接用定义 \(E[X]=\sum_{x=1}^nx\Pr\{X=x\}\) 计算繁琐。改为对每个候选人定义 \(X_i=I\{\text{候选人 }i\text{ 被雇用}\}\),\(X=X_1+\cdots+X_n\) (5.2)。由引理 5.1,\(E[X_i]=\Pr\{\text{候选人 }i\text{ 被雇用}\}\)。候选人 \(i\) 被雇用当且仅当他优于候选人 \(1,\dots,i-1\);由于随机到达,前 \(i\) 人也以随机顺序出现,其中任一人都等可能是目前最好的,故概率为 \(1/i\):
虽然面试 \(n\) 人,平均只雇用约 \(\ln n\) 人。
引理 5.2: 假设候选人以随机顺序出现,HIRE-ASSISTANT 的平均情况总雇用费为 \(O(c_h\ln n)\)。这比最坏情况的 \(O(c_hn)\) 有显著改进。
练习 5.2: 5.2-1 随机顺序下恰好雇用一次的概率为 \(1/n\)(最好的人第一个来),恰好雇用 \(n\) 次的概率为 \(1/n!\)(严格递增);5.2-2 恰好雇用两次的概率(第一个人名次为 \(k\) 且比他好的人中最好的那位在其他比他好的人之前出现:\(\frac1n\sum_{k=1}^{n-1}\frac{1}{n-k}=\frac{H_{n-1}}{n}\));5.2-3 用指示器随机变量求 \(n\) 个骰子点数和的期望(\(3.5n\));5.2-4 帽子保管问题(hat-check problem):\(n\) 位顾客的帽子被随机归还,期望拿回自己帽子的人数为 \(n\cdot\frac1n=1\);5.2-5 \(A\) 为 \(\langle1,\dots,n\rangle\) 的均匀随机排列,用指示器随机变量求期望逆序对数:每对 \((i,j)\) 逆序的概率为 1/2,期望 \(\binom n2/2=n(n-1)/4\)。
5.3 随机算法(Randomized algorithms)(PDF p.143–151)
上一节说明了知道输入分布如何帮助分析平均情况行为;很多时候缺乏此知识,就无法做平均情况分析,但可以用随机算法。对雇用问题这类“假设所有输入排列等可能”有帮助的问题,概率分析能指导随机算法设计:不是假设输入分布,而是强加(impose)一个分布——运行算法前先随机排列候选人,从而强制每个排列等可能。修改后的算法仍期望雇用约 \(\ln n\) 次,但这对任何输入都成立,而不只对来自特定分布的输入。
概率分析与随机算法的区别: 5.2 节的算法是确定性的,对特定输入雇用次数总是相同,不同输入雇用次数不同,只依赖名次序列。例:名次列表 \(A_1=\langle1,2,\dots,10\rangle\) 总是雇 10 次;\(A_2=\langle10,9,\dots,1\rangle\) 只雇 1 次;\(A_3=\langle5,2,1,8,4,7,10,9,3,6\rangle\) 雇 3 次(名次 5、8、10 的人)。存在昂贵输入 \(A_1\)、便宜输入 \(A_2\)、中等输入 \(A_3\)。随机化算法先排列再找最好者,随机性在算法中而非输入分布中:对给定输入(如 \(A_3\))无法说出更新最大值的次数,它每次运行都不同——第一次可能产生排列 \(A_1\) 更新 10 次,第二次可能产生 \(A_2\) 只更新 1 次。对这类随机算法,没有哪个特定输入会引发最坏行为;即使你的“死敌”也无法构造坏输入,因为随机排列使输入顺序无关紧要。只有随机数发生器产生“不走运”的排列时才表现差。
def randomized_hire_assistant(n):
randomly permute the list of candidates
best = 0
for i in 1..n:
interview(i)
if i is better than best:
best = i; hire(i)
引理 5.3: RANDOMIZED-HIRE-ASSISTANT 的期望雇用费为 \(O(c_h\ln n)\)。证明:排列输入后的情形与 HIRE-ASSISTANT 的概率分析完全相同。比较引理 5.2 与 5.3:前者对输入做了假设(称平均情况费用),后者不做假设(称期望费用),但随机化输入需要额外时间。
随机排列数组。 许多随机算法通过排列输入数组实现随机化(也有其他使用随机化的方式)。设数组 \(A\) 含元素 \(1..n\),目标是产生均匀随机排列。
方法一:PERMUTE-BY-SORTING。 给每个元素 \(A[i]\) 赋随机优先级 \(P[i]\),再按优先级排序。例:\(A=\langle1,2,3,4\rangle\),\(P=\langle36,3,62,19\rangle\),得 \(B=\langle2,4,1,3\rangle\)。
def permute_by_sorting(A):
n = A.length
P = new array[1..n]
for i in 1..n:
P[i] = RANDOM(1, n**3) # 取 1..n^3 使优先级大概率互不相同
sort A using P as sort keys # 比较排序 Θ(n lg n)
时间 \(\Theta(n\lg n)\)(瓶颈是排序;比较排序下界 \(\Omega(n\lg n)\),归并排序可达到;练习 8.3-4 说明 \(0..n^3-1\) 范围的数可 \(O(n)\) 排序),空间 \(\Theta(n)\)。取 \(1..n^3\) 是为了让优先级大概率唯一(练习 5.3-5:全部唯一的概率至少 \(1-1/n\);练习 5.3-6:有相同优先级时如何处理)。排序后若 \(P[i]\) 是第 \(j\) 小的优先级,则 \(A[i]\) 位于输出的第 \(j\) 个位置。
引理 5.4: 假设所有优先级互不相同,PERMUTE-BY-SORTING 产生输入的均匀随机排列。证明:先考虑每个元素 \(A[i]\) 恰好得到第 \(i\) 小优先级的特定排列(恒等排列),令 \(E_i\) 为 \(A[i]\) 得到第 \(i\) 小优先级的事件,所求概率 \(\Pr\{E_1\cap\cdots\cap E_n\}=\Pr\{E_1\}\Pr\{E_2\mid E_1\}\Pr\{E_3\mid E_2\cap E_1\}\cdots\Pr\{E_n\mid E_{n-1}\cap\cdots\cap E_1\}\)(练习 C.2-5)。\(\Pr\{E_1\}=1/n\)(从 \(n\) 个中随机选的优先级是最小的);给定 \(A[1..i-1]\) 已依次得到最小的 \(i-1\) 个优先级,剩下 \(n-i+1\) 个元素等可能得到第 \(i\) 小,故 \(\Pr\{E_i\mid\cdots\}=1/(n-i+1)\)。相乘得 \(\frac1n\cdot\frac1{n-1}\cdots\frac12\cdot\frac11=\frac1{n!}\)。对任意固定排列 \(\sigma=\langle\sigma(1),\dots,\sigma(n)\rangle\),令 \(r_i\) 为 \(A[i]\) 所得优先级的名次,把 \(E_i\) 定义为 \(r_i=\sigma(i)\),同样的证明得概率也是 \(1/n!\)。
常见误区: 以为只要证明每个元素 \(A[i]\) 落在每个位置 \(j\) 的概率都是 \(1/n\) 就证明了均匀随机排列——这个较弱条件不充分(练习 5.3-4 的反例)。
方法二:RANDOMIZE-IN-PLACE(即 Fisher–Yates / Knuth 洗牌)。 原地、\(O(n)\) 时间。第 \(i\) 次迭代从 \(A[i..n]\) 中随机选一个放到 \(A[i]\),之后 \(A[i]\) 不再改变。
def randomize_in_place(A): # 时间 Θ(n),额外空间 O(1)
n = A.length
for i in 1..n:
swap(A[i], A[RANDOM(i, n)])
\(k\) 排列(\(k\)-permutation):从 \(n\) 个元素中取 \(k\) 个、无重复的序列,共 \(n!/(n-k)!\) 种。
引理 5.5: RANDOMIZE-IN-PLACE 计算出一个均匀随机排列。证明用循环不变式:第 2–3 行 for 循环第 \(i\) 次迭代之前,对每个可能的 \((i-1)\) 排列,子数组 \(A[1..i-1]\) 包含该 \((i-1)\) 排列的概率为 \((n-i+1)!/n!\)。
- 初始化:\(i=1\),对每个 0 排列,概率 \((n-1+1)!/n!=1\);\(A[1..0]\) 为空,0 排列不含元素,空子数组以概率 1 包含任何 0 排列,成立。
- 保持:设第 \(i\) 次迭代前每个 \((i-1)\) 排列以概率 \((n-i+1)!/n!\) 出现在 \(A[1..i-1]\),证明迭代后每个 \(i\) 排列以概率 \((n-i)!/n!\) 出现在 \(A[1..i]\)。考虑特定 \(i\) 排列 \(\langle x_1,\dots,x_i\rangle\),它由 \((i-1)\) 排列 \(\langle x_1,\dots,x_{i-1}\rangle\) 后接 \(x_i\) 组成。\(E_1\):前 \(i-1\) 次迭代在 \(A[1..i-1]\) 产生该 \((i-1)\) 排列,\(\Pr\{E_1\}=(n-i+1)!/n!\);\(E_2\):第 \(i\) 次迭代把 \(x_i\) 放到 \(A[i]\)。由 (C.14),\(\Pr\{E_2\cap E_1\}=\Pr\{E_2\mid E_1\}\Pr\{E_1\}=\frac{1}{n-i+1}\cdot\frac{(n-i+1)!}{n!}=\frac{(n-i)!}{n!}\)(第 3 行从 \(A[i..n]\) 的 \(n-i+1\) 个值中随机选 \(x_i\))。
- 终止:\(i=n+1\),\(A[1..n]\) 是给定 \(n\) 排列的概率为 \(0!/n!=1/n!\),即均匀随机排列。
随机算法往往是解决问题最简单、最高效的方法。
练习 5.3: 5.3-1 Marceau 教授质疑初始化时空子数组含 0 排列的概率应为 0;改写算法使不变式在第一次迭代前针对非空子数组(如先把 \(A[1]\) 与 \(A[\text{RANDOM}(1,n)]\) 交换,再从 \(i=2\) 循环),并修改证明。5.3-2 Kelp 教授想随机产生除恒等排列外的任意排列,提出 for i = 1 to n-1: swap A[i] with A[RANDOM(i+1, n)]——不行,它只能产生 \((n-1)!\) 种排列(如 \(n=3\) 时只能产生两个循环排列),并非所有非恒等排列。5.3-3 PERMUTE-WITH-ALL:每次与 A[RANDOM(1,n)] 交换——不均匀,因为 \(n^n\) 种等可能的执行路径不能被 \(n!\) 整除地分配(如 \(n=3\):27 条路径分给 6 个排列)。5.3-4 Armstrong 教授的 PERMUTE-BY-CYCLIC:随机取偏移量 offset,把 \(A[i]\) 放到 \(B[(i+\text{offset})\bmod n]\);每个元素落在每个位置的概率都是 \(1/n\),但只能产生 \(n\) 种循环移位,不是均匀随机排列。5.3-5★ 证明 PERMUTE-BY-SORTING 中 \(P\) 全部唯一的概率至少 \(1-1/n\)(联合界:\(\binom n2/n^3<1/n\))。5.3-6 有相同优先级时如何保证均匀(对相同优先级的元素重新抽取优先级并递归排序)。5.3-7 随机抽样:要得到 \(\{1,\dots,n\}\) 的均匀随机 \(m\) 元子集,可以洗牌后取前 \(m\) 个(调用 \(n\) 次 RANDOM);\(n\gg m\) 时证明下面的递归过程只调用 \(m\) 次 RANDOM 且每个 \(m\) 子集等可能(Floyd 抽样算法):
def random_sample(m, n):
if m == 0:
return set()
S = random_sample(m - 1, n - 1)
i = RANDOM(1, n)
if i in S:
S = S | {n}
else:
S = S | {i}
return S
5.4 ★ 概率分析与指示器随机变量的进一步应用(PDF p.151–166)
四个例子:生日悖论、球与箱子、连续正面序列(streaks)、在线雇用问题。
5.4.1 生日悖论(The birthday paradox)
房间里至少要有多少人,才有 50% 的机会有两人生日相同?答案出奇地少——远少于一年天数,甚至少于其一半。
设房间有 \(k\) 人,编号 \(1..k\);忽略闰年,一年 \(n=365\) 天;\(b_i\) 为第 \(i\) 人生日(\(1\le b_i\le n\)),均匀分布 \(\Pr\{b_i=r\}=1/n\),且相互独立。则 \(\Pr\{b_i=r\text{ 且 }b_j=r\}=1/n^2\),
直观:\(b_i\) 选定后,\(b_j\) 与之相同的概率为 \(1/n\)(依赖独立性假设)。
精确分析(补事件): 至少两人生日相同的概率 = 1 − 所有生日都不同的概率。令 \(B_k=\bigcap_{i=1}^kA_i\),\(A_i\) 为第 \(i\) 人生日与所有 \(j<i\) 的人都不同的事件。\(B_k=A_k\cap B_{k-1}\),由 (C.16):
初值 \(\Pr\{B_1\}=\Pr\{A_1\}=1\)。若 \(b_1..b_{k-1}\) 互不相同,\(\Pr\{A_k\mid B_{k-1}\}=(n-k+1)/n\)(\(n\) 天中还有 \(n-(k-1)\) 天没被占)。迭代:
由 \(1+x\le e^x\) (3.12):
当 \(-k(k-1)/2n\le\ln(1/2)\),即 \(k(k-1)\ge2n\ln2\),解二次方程得 \(k\ge(1+\sqrt{1+(8\ln2)n})/2\)。\(n=365\) 时 \(k\ge23\):至少 23 人时,至少两人生日相同的概率至少 1/2。火星一年 669 火星日,需要 31 个火星人。
指示器随机变量的近似分析: 对每对 \(1\le i<j\le k\) 定义 \(X_{ij}=I\{i\text{ 与 }j\text{ 同生日}\}\),\(E[X_{ij}]=1/n\)。同生日的对数 \(X=\sum_{i=1}^k\sum_{j=i+1}^kX_{ij}\),
\(k(k-1)\ge2n\) 时期望同生日对数至少 1,即至少 \(\sqrt{2n}+1\) 人时可期望至少一对同生日。\(n=365\)、\(k=28\) 时 \(E[X]=(28\cdot27)/(2\cdot365)\approx1.0356\);火星需 38 人。两种分析回答的问题不同(概率超过 1/2 vs 期望对数达到 1),具体人数不同,但渐近相同:都是 \(\Theta(\sqrt n)\)。
5.4.2 球与箱子(Balls and bins)
把相同的球随机独立地投入 \(b\) 个箱子(编号 \(1..b\)),每次落入任一箱子的概率为 \(1/b\)。投球过程是成功概率 \(1/b\) 的伯努利试验序列(附录 C.4),对分析散列(第 11 章)尤其有用(思考题 C-1 有更多问题)。
- 给定箱子中有多少球?服从二项分布 \(b(k;n,1/b)\),投 \(n\) 个球时期望 \(n/b\) (C.37)。
- 平均要投多少球,给定箱子才有一个球?服从成功概率 \(1/b\) 的几何分布,期望 \(1/(1/b)=b\) 次 (C.32)。
- 投多少球才能使每个箱子至少一个球?把落入空箱称为“命中”(hit),求得到 \(b\) 次命中的期望投掷次数 \(n\)。按命中把投掷划分为阶段:第 \(i\) 阶段为第 \(i-1\) 次命中之后到第 \(i\) 次命中为止(第 1 阶段就是第一次投掷)。第 \(i\) 阶段每次投掷时有 \(i-1\) 个箱子有球、\(b-i+1\) 个空,命中概率 \((b-i+1)/b\)。第 \(i\) 阶段投掷数 \(n_i\) 服从几何分布,\(E[n_i]=\frac{b}{b-i+1}\)。由线性性:
约需 \(b\ln b\) 次投掷。这也称为集券问题(coupon collector's problem):收集 \(b\) 种不同的优惠券,期望要随机获得约 \(b\ln b\) 张才能集齐。
5.4.3 连续正面序列(Streaks)
抛均匀硬币 \(n\) 次,期望最长连续正面长度是多少?答案 \(\Theta(\lg n)\)。
上界 \(O(\lg n)\): 令 \(A_{ik}\) 为从第 \(i\) 次开始至少 \(k\) 次连续正面的事件(第 \(i..i+k-1\) 次都是正面),\(1\le k\le n\),\(1\le i\le n-k+1\)。由独立性
取 \(k=2\lceil\lg n\rceil\):\(\Pr\{A_{i,2\lceil\lg n\rceil}\}=1/2^{2\lceil\lg n\rceil}\le1/2^{2\lg n}=1/n^2\)。这样的序列至多有 \(n-2\lceil\lg n\rceil+1\) 个起点,由 Boole 不等式(并集概率不超过各事件概率之和,对非独立事件也成立,(C.19)):
令 \(L_j\) 为最长序列恰为 \(j\) 的事件(\(j=0..n\)),\(L\) 为最长序列长度,\(E[L]=\sum_{j=0}^nj\Pr\{L_j\}\) (5.10)。若逐项用类似 (5.9) 的上界会得到很弱的界。直觉:没有哪一项的 \(j\) 与 \(\Pr\{L_j\}\) 同时都大——\(j\ge2\lceil\lg n\rceil\) 时 \(\Pr\{L_j\}\) 很小,\(j<2\lceil\lg n\rceil\) 时 \(j\) 较小。形式化:事件 \(L_j\) 互不相交,故 \(\sum_{j\ge2\lceil\lg n\rceil}\Pr\{L_j\}<1/n\);又 \(\sum_{j=0}^{2\lceil\lg n\rceil-1}\Pr\{L_j\}\le1\)。因此
尾概率衰减很快:\(r\ge1\) 时,从位置 \(i\) 开始至少 \(r\lceil\lg n\rceil\) 个正面的概率 \(1/2^{r\lceil\lg n\rceil}\le1/n^r\),所以最长序列至少 \(r\lceil\lg n\rceil\) 的概率至多 \(n/n^r=1/n^{r-1}\)。例:\(n=1000\) 次抛掷,出现至少 \(2\lceil\lg n\rceil=20\) 个连续正面的概率至多 \(1/1000\);超过 \(3\lceil\lg n\rceil=30\) 的概率至多 \(1/10^6\)。
下界 \(\Omega(\lg n)\): 把 \(n\) 次抛掷划分为至少 \(\lfloor n/\lfloor(\lg n)/2\rfloor\rfloor\) 组、每组 \(s=\lfloor(\lg n)/2\rfloor\) 次连续抛掷。某组全正面的概率 \(\Pr\{A_{i,\lfloor(\lg n)/2\rfloor}\}=1/2^{\lfloor(\lg n)/2\rfloor}\ge1/\sqrt n\),不全正面的概率至多 \(1-1/\sqrt n\)。各组由互斥且独立的抛掷组成,所有组都失败的概率至多
用到 \(1+x\le e^x\) 以及足够大 \(n\) 时 \((2n/\lg n-1)/\sqrt n\ge\lg n\)。因此最长序列至少 \(\lfloor(\lg n)/2\rfloor\) 的概率
指示器随机变量的近似分析: \(X_{ik}=I\{A_{ik}\}\),长度至少 \(k\) 的序列总数 \(X=\sum_{i=1}^{n-k+1}X_{ik}\),\(E[X]=\sum_{i=1}^{n-k+1}1/2^k=\frac{n-k+1}{2^k}\)。若此期望远大于 1,则很可能出现长度 \(k\) 的序列;远小于 1 则不太可能。取 \(k=c\lg n\):
\(c\) 大时长度 \(c\lg n\) 的序列不太可能出现;\(c=1/2\) 时 \(E[X]=\Theta(n^{1/2})\),很可能出现长度 \((1/2)\lg n\) 的序列。仅凭这些粗略估计即可得出期望最长序列为 \(\Theta(\lg n)\)。
5.4.4 在线雇用问题(The on-line hiring problem)
变体:不想面试所有人,也不想反复雇用和解雇;愿意接受接近最好的人选以换取恰好雇用一次。公司规定:每次面试后必须立即决定录用或拒绝。如何在减少面试量与提高录用质量之间权衡?
模型与策略: 面试后给每人打分 \(score(i)\),分数互不相同。看过 \(j\) 人后知道其中最高分,但不知道后面 \(n-j\) 人是否有更高分。策略:选正整数 \(k<n\),面试并拒绝前 \(k\) 人,此后录用第一个分数高于之前所有人的应聘者;若最好的人在前 \(k\) 个中,则录用第 \(n\) 人。
def on_line_maximum(k, n):
bestscore = -INF
for i in 1..k:
if score(i) > bestscore:
bestscore = score(i)
for i in k+1..n:
if score(i) > bestscore:
return i
return n
分析: 固定 \(k\),令 \(M(j)=\max_{1\le i\le j}score(i)\);\(S\) 为成功选中最佳者的事件,\(S_i\) 为最佳者是第 \(i\) 个面试者时成功的事件。\(S_i\) 互不相交,且最佳者在前 \(k\) 个时不可能成功,\(\Pr\{S_i\}=0\)(\(i\le k\)),故
最佳者在位置 \(i\) 时成功需要两件事:\(B_i\)——最佳者在位置 \(i\);\(O_i\)——位置 \(k+1..i-1\) 的人都没被选中,即 \(score(k+1),\dots,score(i-1)\) 都小于 \(M(k)\)。\(B_i\) 与 \(O_i\) 独立:\(O_i\) 只依赖位置 \(1..i-1\) 的相对顺序,\(B_i\) 只依赖位置 \(i\) 的值是否大于其余所有位置。故 \(\Pr\{S_i\}=\Pr\{B_i\}\Pr\{O_i\}\) (C.15)。\(\Pr\{B_i\}=1/n\);\(O_i\) 发生当且仅当位置 \(1..i-1\) 中的最大值(等可能在这 \(i-1\) 个位置中任一个)落在前 \(k\) 个位置,\(\Pr\{O_i\}=k/(i-1)\)。于是 \(\Pr\{S_i\}=\frac{k}{n(i-1)}\),
用积分界 (A.12):\(\int_k^n\frac1x\,dx\le\sum_{i=k}^{n-1}\frac1i\le\int_{k-1}^{n-1}\frac1x\,dx\),得
最大化下界(更易处理):对 \(k\) 求导得 \(\frac1n(\ln n-\ln k-1)\),令其为 0 得 \(\ln k=\ln n-1=\ln(n/e)\),即 \(k=n/e\)。用 \(k=n/e\) 实施策略,至少以概率 \(1/e\approx0.368\) 录用到最佳者。(这就是著名的“秘书问题” 1/e 法则:先观察约 37% 的候选人只看不选,之后选第一个超过此前所有人的。)
练习 5.4: 5.4-1 至少多少人才能使有人与你同生日的概率至少 1/2(\(1-(364/365)^k\ge1/2\),\(k\ge253\));至少多少人才能使 7 月 4 日至少有两人生日的概率大于 1/2(\(k=613\) 左右)。5.4-2 向 \(b\) 个箱子投球直到某箱有两个球,期望投掷次数(\(\Theta(\sqrt b)\),约 \(\sqrt{\pi b/2}+\) 常数)。5.4-3★ 生日悖论分析需要相互独立还是两两独立即可(指示器变量分析只需两两独立)。5.4-4★ 邀请多少人能使很可能有三人同生日(\(\Theta(n^{2/3})\) 量级,期望三元组数 \(\binom k3/n^2\ge1\) 时 \(k\approx94\))。5.4-5★ 大小为 \(n\) 的集合上长度 \(k\) 的串构成 \(k\) 排列的概率:\(\frac{n!}{(n-k)!n^k}\),即生日都不同的概率。5.4-6★ \(n\) 个球投入 \(n\) 个箱子,期望空箱数 \(n(1-1/n)^n\approx n/e\),恰有一个球的箱子期望数 \(n\cdot(1-1/n)^{n-1}\approx n/e\)。5.4-7★ 强化下界:证明 \(n\) 次抛掷中不出现长于 \(\lg n-2\lg\lg n\) 的连续正面的概率小于 \(1/n\)。
第 5 章 思考题(PDF p.164–166)
- 5-1 概率计数(probabilistic counting,R. Morris): \(b\) 位计数器通常只能计到 \(2^b-1\);概率计数以损失精度为代价计到大得多的值。计数器值 \(i\) 表示计数 \(n_i\)(\(i=0..2^b-1\)),\(n_i\) 递增非负,初值 0 表示 \(n_0=0\)。INCREMENT:若 \(i=2^b-1\) 报溢出;否则以概率 \(1/(n_{i+1}-n_i)\) 加 1,否则不变。\(n_i=i\) 时为普通计数器;更有趣的是 \(n_i=2^{i-1}\)(\(i>0\))或 \(n_i=F_i\)。设溢出概率可忽略。(a) 证明 \(n\) 次 INCREMENT 后计数器表示值的期望恰为 \(n\)(每次增量的期望恰为 1);(b) 取 \(n_i=100i\),估计 \(n\) 次后表示值的方差(每次增量是以 \(1/100\) 概率取 100 的随机变量,方差 \(99\),总方差 \(99n\))。考点:近似计数思想(对应流数据 sketch)。
- 5-2 在无序数组中查找: 在 \(n\) 元无序数组 \(A\) 中找 \(x\)。随机策略 RANDOM-SEARCH:每次随机选一个下标(可重复),\(A[i]=x\) 则停止,直到找到或所有下标都被选过。(a) 写伪代码,确保所有下标都被选过后终止(用计数器与标记数组);(b) 恰有一个 \(x\) 时期望选取次数为 \(n\)(几何分布);(c) 有 \(k\ge1\) 个时为 \(n/k\);(d) 没有时期望要选 \(n(\ln n+O(1))\) 次才检查完所有元素(集券问题)。确定性线性查找 DETERMINISTIC-SEARCH(按顺序检查,假设所有输入排列等可能):(e) 恰有一个 \(x\):平均 \((n+1)/2\),最坏 \(n\);(f) \(k\ge1\) 个:平均 \((n+1)/(k+1)\),最坏 \(n-k+1\);(g) 没有:平均和最坏都是 \(n\)。SCRAMBLE-SEARCH(先随机排列再线性查找):(h) \(k=0\) 与 \(k=1\) 以及一般 \(k\ge1\) 的最坏与期望运行时间(期望与 DETERMINISTIC-SEARCH 的平均情况相同,但排列需额外 \(\Theta(n)\));(i) 选择哪个算法——通常确定性线性查找,简单且 \(O(n)\) 有保证,随机查找在无目标时更差。
章末注记(PDF p.166): 高级概率技术参见 Bollobás、Hofri、Spencer;随机算法的优点见 Karp、Rabin 的综述;Motwani 与 Raghavan 的教材系统论述随机算法。雇用问题的变体通常称为“秘书问题”(secretary problems),如 Ajtai、Megiddo、Waarts 的工作。
第 5 章 本章要点
- 概率分析假设输入分布并求平均情况;随机算法自身做随机选择,求期望运行时间,对任何输入都成立,没有固定的坏输入。
- 指示器随机变量 \(E[I\{A\}]=\Pr\{A\}\) 与期望线性性(不要求独立)是计算期望的核心工具。
- 雇用问题:随机顺序下期望更新最大值 \(H_n=\ln n+O(1)\) 次。
- 均匀随机排列:PERMUTE-BY-SORTING(\(\Theta(n\lg n)\))与 RANDOMIZE-IN-PLACE(Fisher–Yates,\(\Theta(n)\),原地);“每个元素等可能落在每个位置”不足以证明均匀。
- 经典结论:生日悖论 \(\Theta(\sqrt n)\)(365 天时 23 人概率过半)、集券问题 \(b\ln b\)、最长连续正面 \(\Theta(\lg n)\)、秘书问题 \(k=n/e\) 时成功概率 \(\ge1/e\)。
第 5 章 与量化交易的关联
- 回测与统计显著性: 最长连续正面 \(\Theta(\lg n)\) 的结论直接用于判断“连胜/连亏”是否异常:1000 个交易日里出现约 10 连涨在纯随机下就很正常(\(\lg1000\approx10\)),不应当作策略有效的证据;20 连涨的概率才低于 \(1/1000\)。
- 多重检验与数据挖掘偏差: 生日悖论说明在大量候选中“巧合配对”出现得比直觉早得多——在数百个因子/参数组合中找到“显著”相关几乎必然;这是回测过拟合(p-hacking)的数学根源之一。
- 随机化方法: Fisher–Yates 洗牌是置换检验(permutation test)、随机打乱标签检验因子是否有效、bootstrap 重抽样、随机划分交叉验证的基础;必须用正确的洗牌算法,否则零假设分布有偏(练习 5.3-2、5.3-3 的错误实现正是常见 bug)。Floyd 抽样(5.3-7)用于从大股票池中高效无放回抽样。
- 雇用问题/秘书问题: 最优停止的思想对应交易执行中的择时(如在一段时间内选择成交价格的阈值策略)和“何时止盈”的决策;\(H_n\approx\ln n\) 告诉你在随机游走价格中“创历史新高”的期望次数,可作为趋势/突破信号统计的零假设基准(如 \(n\) 天中创新高天数期望约 \(\ln n\))。
- 概率计数(5-1): 对应流式行情中的近似计数/基数估计(HyperLogLog 一类)以节约内存。
- 集券问题、球与箱子: 估计散列分桶(行情按代码分片)、随机采样覆盖所有股票所需次数。
第 5 章 推荐习题
- 5.2-4(帽子问题)、5.2-5(随机排列的期望逆序对数 \(n(n-1)/4\)):指示器变量的基本功。
- 5.3-2、5.3-3、5.3-4:辨析错误的洗牌算法,实务中极易犯的错误。
- 5.3-7:Floyd 随机抽样。
- 5.1-3:von Neumann 去偏技巧。
- 5.4-6:\(n\) 球 \(n\) 箱期望空箱 \(n/e\)。
- 思考题 5-1(概率计数)、5-2(随机 vs 确定性查找的期望分析)。
Part II 排序与顺序统计量(Sorting and Order Statistics)
Part II 导言(PDF p.167–171)
本部分给出若干求解排序问题的算法(输入 \(n\) 个数 \(\langle a_1,\dots,a_n\rangle\),输出满足 \(a'_1\le\cdots\le a'_n\) 的排列)。输入通常是 \(n\) 元数组,也可用链表等表示。
数据的结构: 实践中待排序的数很少是孤立值,通常是称为记录(record)的数据集合的一部分。每个记录含一个关键字(key),即排序依据的值;其余部分为卫星数据(satellite data),随关键字一起移动。排序算法置换关键字时也要置换卫星数据;若卫星数据很大,常置换指向记录的指针数组而非记录本身,以减少数据移动。这些实现细节正是算法与完整程序的区别:排序算法描述确定有序顺序的方法,与排的是单个数还是大记录无关;因此讨论排序时通常假设输入只是数。把排序数的算法翻译成排序记录的程序概念上直接,但工程上可能有微妙之处。
为什么研究排序: 许多计算机科学家认为排序是算法研究中最基本的问题:
- 有些应用天然需要排序(如银行按支票号排序以准备客户对账单);
- 算法常把排序作为关键子程序(如图形渲染按“在上方”关系排序对象,从底向上绘制);
- 排序算法种类繁多,运用了丰富的技术,算法设计中许多重要技术都出现在排序算法中,具有历史意义;
- 可以证明排序的非平凡下界(第 8 章),最好的上界在渐近意义上与下界匹配,因此知道我们的排序算法是渐近最优的;排序下界还可用来证明其他问题的下界;
- 实现排序时会遇到许多工程问题:最快的程序依赖于对关键字与卫星数据的先验知识、存储层次(cache、虚拟内存)、软件环境等,很多问题最好在算法层面而不是靠“调代码”解决。
排序算法概览: 第 2 章插入排序最坏 \(\Theta(n^2)\),但内循环紧凑,小规模时是很快的原地排序;归并排序 \(\Theta(n\lg n)\),但 MERGE 不是原地的。本部分新增两种排序任意实数的算法:堆排序(第 6 章),原地、\(O(n\lg n)\),使用称为堆(heap)的重要数据结构,也可实现优先队列;快速排序(第 7 章),原地,最坏 \(\Theta(n^2)\),但期望 \(\Theta(n\lg n)\),实践中通常优于堆排序,代码紧凑、隐藏常数小,是排序大数组的流行算法。
插入、归并、堆、快速排序都是比较排序(comparison sorts):通过比较元素确定顺序。第 8 章用决策树模型证明任何比较排序最坏情况需 \(\Omega(n\lg n)\),从而堆排序与归并排序是渐近最优的比较排序;若能通过比较以外的方式获取有序信息,就能突破此下界:计数排序(counting sort)假设输入在 \(\{0,1,\dots,k\}\) 中,用数组下标确定相对顺序,\(\Theta(k+n)\),\(k=O(n)\) 时线性;基数排序(radix sort)扩展计数排序的范围,\(n\) 个 \(d\) 位数、每位 \(k\) 种取值,\(\Theta(d(n+k))\),\(d\) 为常数且 \(k=O(n)\) 时线性;桶排序(bucket sort)需要知道输入的概率分布,对 \([0,1)\) 上均匀分布的 \(n\) 个实数平均 \(O(n)\)。
| 算法 | 最坏情况运行时间 | 平均/期望运行时间 |
|---|---|---|
| 插入排序 | \(\Theta(n^2)\) | \(\Theta(n^2)\) |
| 归并排序 | \(\Theta(n\lg n)\) | \(\Theta(n\lg n)\) |
| 堆排序 | \(O(n\lg n)\) | —(本书不分析) |
| 快速排序 | \(\Theta(n^2)\) | \(\Theta(n\lg n)\)(期望) |
| 计数排序 | \(\Theta(k+n)\) | \(\Theta(k+n)\) |
| 基数排序 | \(\Theta(d(n+k))\) | \(\Theta(d(n+k))\) |
| 桶排序 | \(\Theta(n^2)\) | \(\Theta(n)\)(平均情况) |
顺序统计量(order statistics): \(n\) 个数集合的第 \(i\) 个顺序统计量是集合中第 \(i\) 小的数。可以排序后取第 \(i\) 个,不对输入分布做假设时需 \(\Omega(n\lg n)\)。第 9 章证明即使元素是任意实数,也能在 \(O(n)\) 时间找到第 \(i\) 小元素:一个代码紧凑的随机算法,最坏 \(\Theta(n^2)\)、期望 \(O(n)\);以及一个更复杂的最坏 \(O(n)\) 算法。
背景: 多数内容不依赖难的数学,但快速排序、桶排序和顺序统计量算法的分析用到概率(附录 C 与第 5 章);最坏线性时间选择算法的分析比本部分其他最坏情况分析所需数学更复杂。
第 6 章 堆排序(Heapsort)(PDF p.172–190)
堆排序与归并排序一样运行时间为 \(O(n\lg n)\),又与插入排序一样是原地排序(任何时刻只有常数个元素存放在输入数组之外),兼具二者优点。它还引入另一种设计技术:用数据结构(堆)管理信息。堆不仅用于堆排序,也是高效的优先队列,在后续章节反复出现。注意:“堆”一词最初来自堆排序,后来也指 Java、Lisp 等语言中的垃圾回收存储;本书中的堆一律指数据结构。
6.1 堆(Heaps)(PDF p.172–175)
定义。 (二叉)堆(binary heap)是一个数组对象,可视为一棵近似完全二叉树(nearly complete binary tree,附录 B.5.3):除最底层外每层都填满,最底层从左往右填到某处为止。树的每个结点对应数组一个元素。表示堆的数组 \(A\) 有两个属性:A.length(数组元素个数)与 A.heap-size(数组中有多少个元素属于堆),\(0\le\) A.heap-size \(\le\) A.length,只有 \(A[1..\text{heap-size}]\) 是堆的有效元素。根为 \(A[1]\)。下标计算:
def PARENT(i): return i // 2 # i 右移一位
def LEFT(i): return 2 * i # i 左移一位
def RIGHT(i): return 2 * i + 1 # 左移一位再把最低位置 1
大多数计算机上可用移位指令一条完成,好的堆排序实现常把它们写成宏或内联过程。
例(图 6.1): 最大堆数组 \(A=\langle16,14,10,8,7,9,3,2,4,1\rangle\)(下标 1–10)。树高为 3,下标 4(值 8)的结点高度为 1。数组中父结点总在孩子左边。
堆性质(heap property):
- 最大堆(max-heap):对除根外的每个结点 \(i\),\(A[\text{PARENT}(i)]\ge A[i]\)——结点值至多是其父结点值。最大元素在根,任一结点为根的子树中所有值都不大于该结点值。
- 最小堆(min-heap):\(A[\text{PARENT}(i)]\le A[i]\),最小元素在根。
堆排序用最大堆;最小堆常用于实现优先队列(6.5 节)。
高度: 结点的高度(height)是从该结点到叶子最长简单向下路径的边数;堆的高度为根的高度。\(n\) 元堆基于完全二叉树,高度 \(\Theta(\lg n)\)(准确为 \(\lfloor\lg n\rfloor\),练习 6.1-2)。堆的基本操作运行时间至多与树高成正比,即 \(O(\lg n)\)。本章过程概览:
- MAX-HEAPIFY:\(O(\lg n)\),维护最大堆性质的关键;
- BUILD-MAX-HEAP:线性时间,从无序数组构造最大堆;
- HEAPSORT:\(O(n\lg n)\),原地排序;
- MAX-HEAP-INSERT、HEAP-EXTRACT-MAX、HEAP-INCREASE-KEY、HEAP-MAXIMUM:\(O(\lg n)\),用堆实现优先队列。
练习 6.1: 6.1-1 高为 \(h\) 的堆中元素最少 \(2^h\)、最多 \(2^{h+1}-1\);6.1-2 证明 \(n\) 元堆高度为 \(\lfloor\lg n\rfloor\)(由 \(2^h\le n\le2^{h+1}-1\));6.1-3 证明最大堆任一子树的根包含该子树中的最大值;6.1-4 元素互不相同时,最大堆的最小元素一定在某个叶子上;6.1-5 已排序(升序)数组是最小堆;6.1-6 \(\langle23,17,14,6,13,10,1,5,7,12\rangle\) 不是最大堆(\(A[4]=6<A[9]=7\));6.1-7 证明用数组存储 \(n\) 元堆时,叶子是下标 \(\lfloor n/2\rfloor+1,\lfloor n/2\rfloor+2,\dots,n\) 的结点。
6.2 维护堆的性质(Maintaining the heap property)(PDF p.175–177)
MAX-HEAPIFY\((A,i)\):调用时假定以 LEFT\((i)\) 和 RIGHT\((i)\) 为根的二叉树都是最大堆,但 \(A[i]\) 可能小于其孩子,违反最大堆性质;让 \(A[i]\) 的值在堆中“逐级下降”(float down),使以 \(i\) 为根的子树满足最大堆性质。
def max_heapify(A, i):
l = LEFT(i); r = RIGHT(i)
if l <= A.heap_size and A[l] > A[i]:
largest = l
else:
largest = i
if r <= A.heap_size and A[r] > A[largest]:
largest = r
if largest != i:
A[i], A[largest] = A[largest], A[i]
max_heapify(A, largest)
每一步确定 \(A[i]\)、\(A[\text{LEFT}(i)]\)、\(A[\text{RIGHT}(i)]\) 中最大者的下标存入 largest。若 \(A[i]\) 最大,以 \(i\) 为根的子树已是最大堆,过程结束;否则较大的孩子与 \(A[i]\) 交换,使结点 \(i\) 及其孩子满足性质,但 largest 结点现在持有原 \(A[i]\) 的值,其子树可能违反性质,于是对它递归调用。
例(图 6.2): heap-size = 10,\(A=\langle16,4,10,14,7,9,3,2,8,1\rangle\),调用 MAX-HEAPIFY\((A,2)\):\(A[2]=4\) 小于孩子 14,与 \(A[4]\) 交换,得 \(\langle16,14,10,4,7,9,3,2,8,1\rangle\);递归 \(i=4\),4 与孩子 8(\(A[9]\))交换,得 \(\langle16,14,10,8,7,9,3,2,4,1\rangle\);递归 \(i=9\) 无变化。
运行时间: 规模 \(n\)、以 \(i\) 为根的子树上,调整 \(A[i]\) 与两孩子关系 \(\Theta(1)\),加上在某个孩子子树上递归的时间。孩子子树规模至多 \(2n/3\)(最坏情况是最底层恰好半满),故
由主定理情况 2 得 \(T(n)=O(\lg n)\)。也可以说,在高度为 \(h\) 的结点上运行时间为 \(O(h)\)。空间:递归版 \(O(\lg n)\) 栈,迭代版 \(O(1)\)。
练习 6.2: 6.2-1 演示 MAX-HEAPIFY\((A,3)\) 作用于 \(\langle27,17,3,16,13,10,1,5,7,12,4,8,9,0\rangle\)(3 与 10 交换,再与 9 交换);6.2-2 写 MIN-HEAPIFY,运行时间相同;6.2-3 \(A[i]\) 已大于其孩子时调用无效果;6.2-4 \(i>\text{heap-size}/2\) 时结点是叶子,无效果;6.2-5 用循环代替第 10 行递归,写高效迭代版(避免某些编译器对尾递归生成低效代码);6.2-6 证明 MAX-HEAPIFY 在规模 \(n\) 的堆上最坏运行时间 \(\Omega(\lg n)\)(构造使其沿根到叶路径每个结点都递归调用的值,如根为最小值)。
6.3 建堆(Building a heap)(PDF p.177–180)
自底向上调用 MAX-HEAPIFY 可把数组 \(A[1..n]\) 转为最大堆。由练习 6.1-7,\(A[\lfloor n/2\rfloor+1..n]\) 都是叶子,各自是 1 元堆;BUILD-MAX-HEAP 对其余结点逐个运行 MAX-HEAPIFY。
def build_max_heap(A):
A.heap_size = A.length
for i in range(A.length // 2, 0, -1): # floor(n/2) downto 1
max_heapify(A, i)
例(图 6.3): \(A=\langle4,1,3,2,16,9,10,14,8,7\rangle\),依次对 \(i=5,4,3,2,1\) 调用 MAX-HEAPIFY:\(i=5\)(16)不变;\(i=4\):2 与 14 交换;\(i=3\):3 与 10 交换;\(i=2\):1 下沉(与 16 交换、再与 7 交换);\(i=1\):4 下沉(与 16、14、8 依次交换)。最终 \(\langle16,14,10,8,7,9,3,2,4,1\rangle\)。每当对某结点调用 MAX-HEAPIFY 时,它的两棵子树都已是最大堆。
正确性(循环不变式): 第 2–3 行 for 循环每次迭代开始时,结点 \(i+1,i+2,\dots,n\) 都是一个最大堆的根。
- 初始化:\(i=\lfloor n/2\rfloor\),结点 \(\lfloor n/2\rfloor+1..n\) 都是叶子,是平凡最大堆的根。
- 保持:结点 \(i\) 的孩子编号都大于 \(i\),由不变式它们都是最大堆的根——这正是调用 MAX-HEAPIFY\((A,i)\) 使 \(i\) 成为最大堆根所需的条件;且该调用保持 \(i+1..n\) 都是最大堆根。\(i\) 减 1 后不变式重新成立。
- 终止:\(i=0\),结点 \(1..n\) 都是最大堆的根,特别是结点 1。
运行时间: 简单上界:每次 MAX-HEAPIFY \(O(\lg n)\),共 \(O(n)\) 次调用,总 \(O(n\lg n)\)——正确但不紧。更紧的分析:MAX-HEAPIFY 的代价随结点高度变化,而大多数结点高度很小。利用 \(n\) 元堆高度为 \(\lfloor\lg n\rfloor\),且高度为 \(h\) 的结点至多 \(\lceil n/2^{h+1}\rceil\) 个(练习 6.3-3):
在公式 (A.8) \(\sum_{k\ge0}kx^k=x/(1-x)^2\) 中代入 \(x=1/2\):\(\sum_{h=0}^\infty\frac{h}{2^h}=\frac{1/2}{(1-1/2)^2}=2\)。因此
即可在线性时间内把无序数组建成最大堆。BUILD-MIN-HEAP 把第 3 行换成 MIN-HEAPIFY,同样线性时间。
练习 6.3: 6.3-1 演示 BUILD-MAX-HEAP 作用于 \(\langle5,3,17,10,84,19,6,22,9\rangle\)(结果 \(\langle84,22,19,10,3,17,6,5,9\rangle\));6.3-2 为什么循环下标要从 \(\lfloor n/2\rfloor\) 递减到 1 而非递增(MAX-HEAPIFY 要求孩子子树已是最大堆,递增顺序无法保证);6.3-3 证明 \(n\) 元堆中高度为 \(h\) 的结点至多 \(\lceil n/2^{h+1}\rceil\) 个。
6.4 堆排序算法(The heapsort algorithm)(PDF p.180–183)
先用 BUILD-MAX-HEAP 把 \(A[1..n]\) 建成最大堆。最大元素在根 \(A[1]\),与 \(A[n]\) 交换即放到最终位置;把结点 \(n\) 从堆中去掉(heap-size 减 1)后,根的孩子仍是最大堆,但新根可能违反性质,调用 MAX-HEAPIFY\((A,1)\) 即可在 \(A[1..n-1]\) 上恢复最大堆。对规模 \(n-1\) 直到 2 的堆重复此过程。
def heapsort(A): # 时间 O(n lg n),额外空间 O(1)(迭代版 heapify),不稳定
build_max_heap(A)
for i in range(A.length, 1, -1): # n downto 2
A[1], A[i] = A[i], A[1]
A.heap_size -= 1
max_heapify(A, 1)
例(图 6.4): 从最大堆 \(\langle16,14,10,8,7,9,3,2,4,1\rangle\) 开始,每轮把根移到末尾并下沉新根,依次得到 \(14,10,9,8,7,4,3,2,1\) 为堆顶……最终 \(A=\langle1,2,3,4,7,8,9,10,14,16\rangle\)。
运行时间: BUILD-MAX-HEAP \(O(n)\),\(n-1\) 次 MAX-HEAPIFY 各 \(O(\lg n)\),总 \(O(n\lg n)\)。
练习 6.4: 6.4-1 演示对 \(\langle5,13,2,25,7,17,20,8,4\rangle\) 堆排序;6.4-2 用循环不变式论证正确性:第 2–5 行 for 循环每次迭代开始时,\(A[1..i]\) 是包含 \(A[1..n]\) 中最小的 \(i\) 个元素的最大堆,\(A[i+1..n]\) 按序包含 \(A[1..n]\) 中最大的 \(n-i\) 个元素;6.4-3 已升序或降序的数组上堆排序都是 \(\Theta(n\lg n)\);6.4-4 证明堆排序最坏运行时间为 \(\Omega(n\lg n)\);6.4-5★ 证明元素互不相同时堆排序最好运行时间也是 \(\Omega(n\lg n)\)。
6.5 优先队列(Priority queues)(PDF p.183–187)
堆排序是优秀算法,但实践中好的快速排序实现通常更快。不过堆本身用途广泛,最常见的是作为高效优先队列。优先队列分最大优先队列与最小优先队列;这里实现基于最大堆的最大优先队列(练习 6.5-3 写最小优先队列)。
定义: 优先队列(priority queue)维护元素集合 \(S\),每个元素有一个称为关键字(key)的值。最大优先队列支持:
- INSERT\((S,x)\):把 \(x\) 插入 \(S\),即 \(S=S\cup\{x\}\);
- MAXIMUM\((S)\):返回关键字最大的元素;
- EXTRACT-MAX\((S)\):删除并返回关键字最大的元素;
- INCREASE-KEY\((S,x,k)\):把 \(x\) 的关键字增大到 \(k\)(假设 \(k\) 不小于当前关键字)。
应用:共享计算机上的作业调度——队列记录待执行作业及其相对优先级,作业完成或中断时调度器用 EXTRACT-MAX 选出优先级最高的待执行作业,随时可用 INSERT 加入新作业。最小优先队列支持 INSERT、MINIMUM、EXTRACT-MIN、DECREASE-KEY,可用于事件驱动模拟:队列元素是待模拟事件,以发生时间为关键字;事件必须按时间顺序模拟(模拟一个事件可能产生未来的新事件),每步用 EXTRACT-MIN 取下一个事件,新事件用 INSERT 插入。第 23、24 章会用到强调 DECREASE-KEY 的最小优先队列。
句柄(handle): 应用中优先队列元素对应应用对象(作业、事件),常需双向查找:在每个堆元素中存一个指向应用对象的句柄(指针或整数,视应用而定),在每个应用对象中存一个指向堆元素的句柄(通常是数组下标)。由于堆操作中元素在数组里移动位置,实际实现每次移动堆元素时都要更新应用对象中的下标。本书不展开,但实践中必须正确维护。
def heap_maximum(A): # Θ(1)
return A[1]
def heap_extract_max(A): # O(lg n)
if A.heap_size < 1:
error("heap underflow")
mx = A[1]
A[1] = A[A.heap_size]
A.heap_size -= 1
max_heapify(A, 1)
return mx
def heap_increase_key(A, i, key): # O(lg n)
if key < A[i]:
error("new key is smaller than current key")
A[i] = key
while i > 1 and A[PARENT(i)] < A[i]: # 类似插入排序的内循环,沿路径上浮
A[i], A[PARENT(i)] = A[PARENT(i)], A[i]
i = PARENT(i)
def max_heap_insert(A, key): # O(lg n)
A.heap_size += 1
A[A.heap_size] = -INF # 新叶子先设为 -∞
heap_increase_key(A, A.heap_size, key)
HEAP-EXTRACT-MAX 类似堆排序 for 循环体(第 3–5 行),在 MAX-HEAPIFY 的 \(O(\lg n)\) 之外只多常数工作。HEAP-INCREASE-KEY 用数组下标 \(i\) 标识要增大关键字的元素;增大后可能违反最大堆性质,于是像插入排序内循环一样沿着到根的简单路径前进,反复与父结点比较,若更大则交换并继续,否则终止(此时最大堆性质成立),路径长 \(O(\lg n)\)。例(图 6.5):在图 6.4(a) 的最大堆中把下标 9 的结点(值 4)增大到 15,先与父结点 8 交换,再与父结点 14 交换,此时父结点 16 ≥ 15,终止。MAX-HEAP-INSERT 先给树添加一个关键字为 \(-\infty\) 的新叶子,再调用 HEAP-INCREASE-KEY 设成正确值并维护性质。
结论: 在规模为 \(n\) 的集合上,堆可以在 \(O(\lg n)\) 时间内支持任何优先队列操作。
练习 6.5: 6.5-1 演示 HEAP-EXTRACT-MAX 作用于 \(\langle15,13,9,5,12,8,7,4,0,6,2,1\rangle\);6.5-2 演示 MAX-HEAP-INSERT\((A,10)\) 作用于同一堆;6.5-3 写最小优先队列的 HEAP-MINIMUM、HEAP-EXTRACT-MIN、HEAP-DECREASE-KEY、MIN-HEAP-INSERT;6.5-4 为何先把新结点关键字设为 \(-\infty\)(保证随后的 HEAP-INCREASE-KEY 的前置条件 key ≥ 当前值成立);6.5-5 用循环不变式论证 HEAP-INCREASE-KEY 正确:第 4–6 行 while 循环每次迭代开始时,若相应结点存在则 \(A[\text{PARENT}(i)]\ge A[\text{LEFT}(i)]\) 且 \(A[\text{PARENT}(i)]\ge A[\text{RIGHT}(i)]\),且 \(A[1..\text{heap-size}]\) 满足最大堆性质,唯一可能的违反是 \(A[i]\) 大于 \(A[\text{PARENT}(i)]\);6.5-6 交换通常要三次赋值,用插入排序内循环的思路(父结点下移、最后一次性放入 key)减到一次赋值;6.5-7 用优先队列实现先进先出队列(关键字为递增的时间戳,用最小优先队列)和栈(关键字递增,用最大优先队列);6.5-8 HEAP-DELETE\((A,i)\) 在 \(O(\lg n)\) 删除结点 \(i\)(用最后一个元素替换,再视情况上浮或下沉);6.5-9 用最小堆在 \(O(n\lg k)\) 时间合并 \(k\) 个有序表(\(n\) 为元素总数,堆中保存各表当前首元素):
def k_way_merge(lists): # 时间 O(n lg k),额外空间 O(k)
H = min-heap of (lists[j][0], j, 0) for each nonempty list j
out = []
while H not empty:
(v, j, t) = extract_min(H)
out.append(v)
if t + 1 < len(lists[j]):
insert(H, (lists[j][t+1], j, t+1))
return out
第 6 章 思考题(PDF p.187–189)
- 6-1 用插入的方法建堆: BUILD-MAX-HEAP′:
heap-size = 1; for i = 2 to n: MAX-HEAP-INSERT(A, A[i])。(a) 与 BUILD-MAX-HEAP 在相同输入上不一定产生相同的堆(反例 \(\langle1,2,3\rangle\):BUILD-MAX-HEAP′ 得 \(\langle3,1,2\rangle\),BUILD-MAX-HEAP 得 \(\langle3,2,1\rangle\));(b) 证明最坏情况下 BUILD-MAX-HEAP′ 需 \(\Theta(n\lg n)\)(升序输入时每次插入都上浮到根)。 - 6-2 \(d\) 叉堆分析: \(d\) 叉堆(\(d\)-ary heap)中非叶结点(至多一个例外)有 \(d\) 个孩子。(a) 数组表示:第 \(j\) 个孩子 \(d(i-1)+j+1\)(\(j=1..d\)),父结点 \(\lfloor(i-2)/d\rfloor+1\);(b) 高度 \(\Theta(\log_dn)\);(c) EXTRACT-MAX:下沉时每层比较 \(d\) 个孩子,\(O(d\log_dn)\);(d) INSERT:上浮 \(O(\log_dn)\);(e) INCREASE-KEY(\(k<A[i]\) 报错,否则设 \(A[i]=k\) 并上浮):\(O(\log_dn)\)。考点:\(d\) 的取舍——插入/增键多时大 \(d\) 有利,提取多时小 \(d\) 有利(Dijkstra 算法中常用)。
- 6-3 Young 氏矩阵(Young tableau): \(m\times n\) 矩阵,每行从左到右有序、每列从上到下有序;某些元素可为 \(\infty\),视为不存在,因此可容纳 \(r\le mn\) 个有限数。(a) 画出含 \(\{9,16,3,2,4,8,5,14,12\}\) 的 \(4\times4\) Young 氏矩阵;(b) 论证 \(Y[1,1]=\infty\) 时为空,\(Y[m,n]<\infty\) 时为满;(c) 在非空矩阵上实现 \(O(m+n)\) 的 EXTRACT-MIN:取出 \(Y[1,1]\),置为 \(\infty\),然后像 MAX-HEAPIFY 那样与右边和下边较小者交换并递归,把 \(m\times n\) 问题化为 \((m-1)\times n\) 或 \(m\times(n-1)\) 子问题;令 \(p=m+n\),\(T(p)=T(p-1)+O(1)=O(m+n)\);(d) \(O(m+n)\) 插入到非满矩阵(放到 \(Y[m,n]\) 后向左/向上交换);(e) 不用其他排序方法,用 \(n\times n\) Young 氏矩阵在 \(O(n^3)\) 时间排序 \(n^2\) 个数(\(n^2\) 次插入与提取各 \(O(n)\));(f) \(O(m+n)\) 判断某数是否在矩阵中(从右上角出发:大于目标则左移,小于则下移)。
章末注记(PDF p.189–190): 堆排序由 Williams 发明,他也描述了用堆实现优先队列;BUILD-MAX-HEAP 由 Floyd 提出。第 16、23、24 章用最小堆实现最小优先队列;第 19 章(斐波那契堆)给出某些操作时间界更好的实现,第 20 章(van Emde Boas 树)在关键字取自有界非负整数集时更快。若数据为 \(b\) 位整数且内存由可寻址的 \(b\) 位字组成,Fredman 与 Willard 实现了 \(O(1)\) 的 MINIMUM 与 \(O(\sqrt{\lg n})\) 的 INSERT、EXTRACT-MIN;Thorup 把 \(O(\sqrt{\lg n})\) 改进到 \(O(\lg\lg n)\)(空间与 \(n\) 无界,但用随机散列可在线性空间实现)。重要特例:EXTRACT-MIN 的返回值随时间单调递增(单调优先队列),出现在 Dijkstra 单源最短路径(第 24 章,DECREASE-KEY 的效率尤其重要)和离散事件模拟中。对单调情形、整数范围 \(1..C\),Ahuja、Mehlhorn、Orlin、Tarjan 用基数堆(radix heap)实现 \(O(\lg C)\) 摊还时间的 EXTRACT-MIN 与 INSERT、\(O(1)\) 的 DECREASE-KEY;结合斐波那契堆可改进到 \(O(\sqrt{\lg C})\);Cherkassky、Goldberg、Silverstein 结合 Denardo–Fox 多级桶结构与 Thorup 的堆得到 \(O(\lg^{1/3+\epsilon}C)\) 期望时间;Raman 进一步得到 \(O(\min(\lg^{1/4+\epsilon}C,\lg^{1/3+\epsilon}n))\)。
第 6 章 本章要点
- 二叉堆是用数组表示的近似完全二叉树,PARENT/LEFT/RIGHT 由移位计算;最大堆性质 \(A[\text{PARENT}(i)]\ge A[i]\),高度 \(\lfloor\lg n\rfloor\)。
- MAX-HEAPIFY:\(T(n)\le T(2n/3)+\Theta(1)=O(\lg n)\);BUILD-MAX-HEAP 用 \(\sum h/2^h=2\) 证明为 \(O(n)\)(不是 \(O(n\lg n)\))。
- HEAPSORT:\(O(n\lg n)\) 最坏、原地、不稳定;实践中通常慢于快速排序。
- 优先队列:MAXIMUM \(\Theta(1)\),EXTRACT-MAX、INCREASE-KEY、INSERT 都是 \(O(\lg n)\);需维护句柄以便对象与堆元素双向定位。
- 用最小堆可在 \(O(n\lg k)\) 内多路归并 \(k\) 个有序表。
第 6 章 与量化交易的关联
- 订单簿与撮合: 限价订单簿的买方需要“最高价优先”、卖方“最低价优先”,最基本的实现就是最大堆/最小堆(按价格,再按时间戳打破平局实现价格-时间优先);撤单需要 HEAP-DELETE(练习 6.5-8)与句柄维护,改价对应 INCREASE/DECREASE-KEY。生产级撮合引擎多用按价位的平衡树或数组+链表,但堆是理解与原型实现的起点。
- 事件驱动回测: 事件驱动回测引擎本质上就是 6.5 节的“事件驱动模拟器”:以时间戳为关键字的最小优先队列,按时间顺序弹出行情、订单、成交、定时器事件;处理一个事件会插入未来事件(如订单延迟回报)。保证严格按时间顺序处理是避免前视偏差(look-ahead bias)的结构性手段。
- 多路行情归并: 练习 6.5-9 的 \(k\) 路归并正是把多个交易所/多个标的的按时间排序的 tick 文件合并成统一时间线的标准方法,\(O(n\lg k)\)。
- Top-K 选股: 用大小为 \(k\) 的最小堆在 \(O(n\lg k)\) 内选出因子得分最高的 \(k\) 只股票,流式数据下尤其适用(如实时维护涨幅榜、成交额榜)。
- 滚动分位数/中位数: 用一个最大堆+一个最小堆维护流式中位数,是滚动中位数、稳健标准化的常用实现。
第 6 章 推荐习题
- 6.1-7、6.3-3:叶子下标与各高度结点数,BUILD-MAX-HEAP 线性时间分析的基础。
- 6.2-5:迭代版 MAX-HEAPIFY(实现必备)。
- 6.5-8、6.5-9:堆删除与 \(k\) 路归并,量化工程中直接可用。
- 6.5-6:减少赋值次数的常数优化技巧。
- 思考题 6-2:\(d\) 叉堆的权衡;思考题 6-3:Young 氏矩阵(二维有序矩阵查找是经典面试题)。
第 7 章 快速排序(Quicksort)(PDF p.191–211)
快速排序对 \(n\) 个数的输入最坏运行时间为 \(\Theta(n^2)\),但通常是实践中最好的选择:平均性能极好,期望运行时间 \(\Theta(n\lg n)\),且隐藏的常数因子很小;它是原地排序,在虚拟内存环境中也表现良好。7.1 节描述算法及其划分子程序;7.2 节直观讨论性能;7.3 节给出使用随机抽样的随机化版本,期望时间好且没有特定输入引发最坏行为;7.4 节证明其最坏 \(\Theta(n^2)\),并在元素互异时期望 \(O(n\lg n)\)。
7.1 快速排序的描述(Description of quicksort)(PDF p.191–195)
快速排序同样采用分治:对子数组 \(A[p..r]\):
- 分解:把 \(A[p..r]\) 划分(重排)为两个(可能为空的)子数组 \(A[p..q-1]\) 和 \(A[q+1..r]\),使前者每个元素 \(\le A[q]\),\(A[q]\le\) 后者每个元素;计算下标 \(q\) 是划分过程的一部分。
- 解决:递归调用快速排序对两个子数组排序。
- 合并:子数组已有序,无需合并工作,\(A[p..r]\) 已排好序。
def quicksort(A, p, r):
if p < r:
q = partition(A, p, r)
quicksort(A, p, q - 1)
quicksort(A, q + 1, r)
# 初始调用 quicksort(A, 1, A.length)
def partition(A, p, r): # Lomuto 划分,时间 Θ(n),n = r-p+1,原地
x = A[r] # 主元(pivot)
i = p - 1
for j in range(p, r): # j = p .. r-1
if A[j] <= x:
i += 1
A[i], A[j] = A[j], A[i]
A[i + 1], A[r] = A[r], A[i + 1]
return i + 1
PARTITION 总选 \(x=A[r]\) 作为主元(pivot element),围绕它划分 \(A[p..r]\)。运行中数组被分成四个(可能为空的)区域(图 7.2)。循环不变式: 第 3–6 行循环每次迭代开始时,对任意下标 \(k\):
- 若 \(p\le k\le i\),则 \(A[k]\le x\);
- 若 \(i+1\le k\le j-1\),则 \(A[k]>x\);
- 若 \(k=r\),则 \(A[k]=x\)。
\(j..r-1\) 之间的元素不属于任何情况,与主元无特定关系。
- 初始化:\(i=p-1\),\(j=p\),\(p..i\) 与 \(i+1..j-1\) 之间都没有元素,前两条平凡成立;第 1 行赋值满足第 3 条。
- 保持(图 7.3):若 \(A[j]>x\),只把 \(j\) 加 1,条件 2 对 \(A[j-1]\) 成立,其他不变;若 \(A[j]\le x\),\(i\) 加 1,交换 \(A[i]\) 与 \(A[j]\),再 \(j\) 加 1——交换后 \(A[i]\le x\),条件 1 满足;换到 \(A[j-1]\) 的元素由不变式知 \(>x\),条件 2 满足。
- 终止:\(j=r\),数组每个元素都在三个集合之一:\(\le x\)、\(>x\)、以及只含 \(x\) 的单元素集合。
最后两行把主元与最左边的大于 \(x\) 的元素交换,把主元移到划分后的正确位置并返回新下标。输出满足分解步骤的要求,实际还满足更强的条件:QUICKSORT 第 2 行后 \(A[q]\) 严格小于 \(A[q+1..r]\) 的每个元素。PARTITION 在 \(n=r-p+1\) 规模子数组上运行时间 \(\Theta(n)\)(练习 7.1-3)。
例(图 7.1): \(A=\langle2,8,7,1,3,5,6,4\rangle\),\(x=4\):2 与自身交换进入小区;8、7 进入大区;1 与 8 交换;3 与 7 交换;5、6 进入大区;最后主元 4 与 \(A[i+1]=7\) 交换,得 \(\langle2,1,3,4,7,5,6,8\rangle\),返回 \(q=4\)。
练习 7.1: 7.1-1 演示 PARTITION 作用于 \(\langle13,19,9,5,12,8,7,4,21,2,6,11\rangle\);7.1-2 所有元素相同时 PARTITION 返回 \(q=r\)(导致最坏划分);修改使其此时返回 \(\lfloor(p+r)/2\rfloor\)(如对等于主元的元素交替放入两侧);7.1-3 论证 PARTITION 为 \(\Theta(n)\)(for 循环 \(n-1\) 次,每次常数);7.1-4 改为非升序排序(第 4 行改为 A[j] >= x)。
7.2 快速排序的性能(Performance of quicksort)(PDF p.195–200)
运行时间取决于划分是否平衡,而这取决于用哪些元素作主元。划分平衡时与归并排序一样快;不平衡时可能与插入排序一样慢。
最坏情况划分: 划分产生一个 \(n-1\) 元子问题和一个 0 元子问题(7.4.1 节证明这是最坏情况)。设每次递归都如此,划分代价 \(\Theta(n)\),\(T(0)=\Theta(1)\):
各层代价相加是等差级数 (A.2),得 \(\Theta(n^2)\)(练习 7.2-1 用代入法证明)。因此快速排序最坏运行时间不比插入排序好;更糟的是 \(\Theta(n^2)\) 恰好发生在输入已完全有序时——这是常见情形,而插入排序此时只需 \(O(n)\)。
最好情况划分: 最均匀的划分得到两个规模都不超过 \(n/2\) 的子问题(\(\lfloor n/2\rfloor\) 与 \(\lceil n/2\rceil-1\)),\(T(n)=2T(n/2)+\Theta(n)\)(容忍忽略取整和减 1),主定理情况 2 得 \(\Theta(n\lg n)\)。
平衡的划分: 平均情况更接近最好情况。关键是理解划分的平衡性如何反映在递归式中。设划分总是产生 9:1 的比例划分(乍看很不平衡):
递归树(图 7.4):在深度 \(\log_{10}n=\Theta(\lg n)\) 达到边界条件之前每层代价都是 \(cn\),此后各层代价至多 \(cn\);递归在深度 \(\log_{10/9}n=\Theta(\lg n)\) 终止。总代价 \(O(n\lg n)\)——与正中间划分渐近相同。即使 99:1 划分也是 \(O(n\lg n)\)。事实上任何常数比例的划分都产生深度 \(\Theta(\lg n)\)、每层代价 \(O(n)\) 的递归树,运行时间 \(O(n\lg n)\)。
平均情况的直观: 需要对各种输入出现频率做假设。快速排序的行为取决于输入元素的相对顺序而非具体值;如雇用问题,暂时假设输入数的所有排列等可能。随机输入上每层划分不太可能相同,有些划分会比较平衡、有些会很不平衡;练习 7.2-6 表明约 80% 的时间 PARTITION 产生比 9:1 更平衡的划分,约 20% 更不平衡。平均情况下好坏划分混合并随机分布在树中。为直观起见,设好坏划分在树中逐层交替,好的是最好划分、坏的是最坏划分(图 7.5(a)):根处划分代价 \(n\),产生 \(n-1\) 与 0 两个子数组(最坏);下一层 \(n-1\) 规模子数组做最好划分,得 \((n-1)/2-1\) 与 \((n-1)/2\)(设 0 规模子数组边界代价为 1)。坏划分后接好划分得到三个子数组,规模 0、\((n-1)/2-1\)、\((n-1)/2\),总划分代价 \(\Theta(n)+\Theta(n-1)=\Theta(n)\);这并不比图 7.5(b) 一次划分得到两个 \((n-1)/2\) 子数组、代价 \(\Theta(n)\) 的情况差,而后者是平衡的。直观上坏划分的 \(\Theta(n-1)\) 代价被好划分的 \(\Theta(n)\) 代价“吸收”,结果仍是好划分。因此好坏交替时运行时间仍为 \(O(n\lg n)\),只是常数略大。7.4.2 节给出严格分析。
练习 7.2: 7.2-1 用代入法证明 \(T(n)=T(n-1)+\Theta(n)\) 的解为 \(\Theta(n^2)\);7.2-2 所有元素都相同时 QUICKSORT 运行时间 \(\Theta(n^2)\)(每次划分得 \(n-1\) 与 0);7.2-3 元素互异且降序时为 \(\Theta(n^2)\);7.2-4 银行按交易时间记录支票,但客户希望按支票号排列;人们通常按支票号顺序开支票、商户也较快兑现,因此这是对“几乎有序”输入的排序,论证插入排序会胜过快速排序(插入排序 \(\Theta(n+I)\),而快速排序在近乎有序输入上趋向最坏情况);7.2-5 若每层划分比例为 \(1-\alpha\) 比 \(\alpha\)(\(0<\alpha\le1/2\) 为常数),证明递归树叶子最小深度约 \(-\lg n/\lg\alpha\),最大深度约 \(-\lg n/\lg(1-\alpha)\);7.2-6★ 论证对任意常数 \(0<\alpha\le1/2\),随机输入上 PARTITION 产生比 \(1-\alpha\) 比 \(\alpha\) 更平衡划分的概率约为 \(1-2\alpha\)(主元名次落在 \([\alpha n,(1-\alpha)n]\) 之间)。
7.3 快速排序的随机化版本(A randomized version of quicksort)(PDF p.200–201)
平均情况分析假设所有输入排列等可能,但工程中不能总期望如此(练习 7.2-4)。如 5.3 节,可加入随机化使所有输入都有好的期望性能;许多人认为随机化快速排序是足够大输入的首选排序算法。可以像 5.3 节那样显式排列输入,但另一种随机化技术——随机抽样(random sampling)——分析更简单:不总用 \(A[r]\) 作主元,而是从 \(A[p..r]\) 中随机选一个元素与 \(A[r]\) 交换,保证主元等可能是 \(r-p+1\) 个元素中的任一个,因此期望划分平均而言较平衡。
def randomized_partition(A, p, r):
i = RANDOM(p, r)
A[r], A[i] = A[i], A[r]
return partition(A, p, r)
def randomized_quicksort(A, p, r):
# 期望时间 Θ(n lg n),最坏 Θ(n^2);期望栈深 Θ(lg n),最坏 Θ(n);原地、不稳定
if p < r:
q = randomized_partition(A, p, r)
randomized_quicksort(A, p, q - 1)
randomized_quicksort(A, q + 1, r)
练习 7.3: 7.3-1 为何分析随机算法的期望运行时间而非最坏运行时间(最坏情况由随机选择而非输入决定,极不可能发生,期望时间更能反映性能且对所有输入成立);7.3-2 RANDOMIZED-QUICKSORT 最坏和最好情况下调用 RANDOM 的次数都是 \(\Theta(n)\)(每次 PARTITION 调用一次,且每次至少固定一个主元,调用次数在 \(n/2\) 到 \(n-1\) 之间)。
7.4 快速排序分析(Analysis of quicksort)(PDF p.201–206)
7.4.1 最坏情况分析
用代入法证明运行时间为 \(O(n^2)\)。令 \(T(n)\) 为 QUICKSORT 在规模 \(n\) 输入上的最坏时间:
\(q\) 取 \(0..n-1\) 是因为 PARTITION 产生总规模 \(n-1\) 的两个子问题。猜 \(T(n)\le cn^2\),代入:
\(q^2+(n-q-1)^2\) 关于 \(q\) 的二阶导数为正,故在区间端点取最大(练习 7.4-3),\(\max\le(n-1)^2=n^2-2n+1\)。于是
只需取 \(c\) 足够大使 \(c(2n-1)\) 压过 \(\Theta(n)\)。故 \(T(n)=O(n^2)\);7.2 节的不平衡划分给出了 \(\Omega(n^2)\) 的情形(或练习 7.4-1 直接证明 (7.1) 的解为 \(\Omega(n^2)\))。因此快速排序(最坏)运行时间为 \(\Theta(n^2)\)。
7.4.2 期望运行时间
直观:若每层递归中 RANDOMIZED-PARTITION 把任意常数比例的元素放到划分的一侧,递归树深度 \(\Theta(\lg n)\),每层 \(O(n)\) 工作;即使在这些层之间插入几层最不平衡的划分,总时间仍是 \(O(n\lg n)\)。严格分析:先理解划分如何运作,推出期望时间上界 \(O(n\lg n)\),与 7.2 节最好情况 \(\Theta(n\lg n)\) 结合得期望 \(\Theta(n\lg n)\)。全程假设待排序元素值互不相同。
运行时间与比较次数。 QUICKSORT 与 RANDOMIZED-QUICKSORT 只在选主元方式上不同,可以就 QUICKSORT 和 PARTITION 讨论,但假设主元从传给 RANDOMIZED-PARTITION 的子数组中随机选取。运行时间由 PARTITION 主导。每次调用 PARTITION 选一个主元,该元素此后不会出现在任何递归调用中,因此整个执行过程至多调用 \(n\) 次 PARTITION。一次调用耗时 \(O(1)\) 加上与第 3–6 行 for 循环迭代次数成正比的时间,每次迭代在第 4 行做一次主元与另一元素的比较。
引理 7.1: 令 \(X\) 为 QUICKSORT 在 \(n\) 元数组上整个执行过程中 PARTITION 第 4 行执行比较的总次数,则 QUICKSORT 运行时间为 \(O(n+X)\)。证明:至多 \(n\) 次调用 PARTITION,每次常数工作加若干次 for 循环迭代,每次迭代执行一次第 4 行。
计算 \(E[X]\)。 不分析每次调用的比较次数,而是推导总比较次数的整体界。把数组元素重命名为 \(z_1,z_2,\dots,z_n\),\(z_i\) 为第 \(i\) 小元素;定义 \(Z_{ij}=\{z_i,z_{i+1},\dots,z_j\}\)。
- 每对元素至多比较一次:元素只与主元比较,一次 PARTITION 结束后该主元不再与其他元素比较。
- 定义 \(X_{ij}=I\{z_i\text{ 与 }z_j\text{ 被比较}\}\)(指整个执行过程中任一时刻),则 \(X=\sum_{i=1}^{n-1}\sum_{j=i+1}^nX_{ij}\),
- 何时不比较:例如输入 1..10(任意顺序),第一个主元为 7,第一次 PARTITION 把数分成 \(\{1,\dots,6\}\) 和 \(\{8,9,10\}\),7 与所有其他元素比较,但第一组的数(如 2)永远不会与第二组的数(如 9)比较。一般地(元素互异),一旦选了满足 \(z_i<x<z_j\) 的主元 \(x\),\(z_i\) 与 \(z_j\) 以后就不可能再比较;若 \(z_i\) 在 \(Z_{ij}\) 中其他元素之前被选为主元,则 \(z_i\) 与 \(Z_{ij}\) 中除自身外的每个元素比较;\(z_j\) 同理。例中 7 与 9 被比较,因为 7 是 \(Z_{7,9}\) 中第一个被选为主元的;2 与 9 永不比较,因为 \(Z_{2,9}\) 中第一个主元是 7。所以 \(z_i\) 与 \(z_j\) 被比较当且仅当 \(Z_{ij}\) 中第一个被选为主元的元素是 \(z_i\) 或 \(z_j\)。
- 在 \(Z_{ij}\) 中有元素被选为主元之前,整个 \(Z_{ij}\) 都在同一划分中,因此 \(Z_{ij}\) 的每个元素等可能第一个被选为主元。\(|Z_{ij}|=j-i+1\),主元随机独立选取,故任一给定元素第一个被选中的概率为 \(1/(j-i+1)\)。两事件互斥:
- 合并并令 \(k=j-i\),用调和级数界 (A.7):
结论:元素互异时,使用 RANDOMIZED-PARTITION 的快速排序期望运行时间为 \(O(n\lg n)\)。(精确地 \(E[X]\approx2n\ln n\approx1.39n\lg n\)。)
练习 7.4: 7.4-1 证明 (7.1) 中 \(T(n)=\Omega(n^2)\);7.4-2 证明快速排序最好情况运行时间为 \(\Omega(n\lg n)\);7.4-3 证明 \(q^2+(n-q-1)^2\) 在 \(q=0\) 或 \(q=n-1\) 时取最大;7.4-4 证明 RANDOMIZED-QUICKSORT 期望运行时间为 \(\Omega(n\lg n)\);7.4-5 实践改进:子数组少于 \(k\) 个元素时直接返回不排序,顶层快速排序返回后对整个数组运行一次插入排序;论证期望时间 \(O(nk+n\lg(n/k))\)(递归树只到深度 \(\lg(n/k)\);插入排序时每个元素只需在其所在的长度 \(<k\) 的块内移动),理论上 \(k\) 取 \(O(\lg n)\) 量级,实践中通过实测选择;7.4-6★ 随机选三个元素、以其中位数为主元划分,求最坏为 \(\alpha\) 比 \(1-\alpha\) 划分的概率近似(主元名次落在 \([\alpha n,(1-\alpha)n]\) 的概率约 \(1-2(3\alpha^2-2\alpha^3)\))。
第 7 章 思考题(PDF p.206–211)
-
7-1 Hoare 划分的正确性: 本章的 PARTITION(Lomuto)不是原始划分算法,原始算法由 C. A. R. Hoare 提出:
def hoare_partition(A, p, r): x = A[p] i = p - 1; j = r + 1 while True: j -= 1 while A[j] > x: j -= 1 # repeat j-- until A[j] <= x i += 1 while A[i] < x: i += 1 # repeat i++ until A[i] >= x if i < j: A[i], A[j] = A[j], A[i] else: return j(a) 演示其作用于 \(\langle13,19,9,5,12,8,7,4,11,2,6,21\rangle\);设子数组至少两个元素,证明:(b) 下标 \(i,j\) 不会越出 \(A[p..r]\);(c) 终止时返回的 \(j\) 满足 \(p\le j<r\);(d) 终止时 \(A[p..j]\) 的每个元素都小于等于 \(A[j+1..r]\) 的每个元素。Lomuto 划分把主元从两个划分中分离出来;Hoare 划分总把主元(原 \(A[p]\))放进 \(A[p..j]\) 或 \(A[j+1..r]\) 之一,由于 \(p\le j<r\),划分总是非平凡的。(e) 改写 QUICKSORT 使用 HOARE-PARTITION(递归 \(A[p..j]\) 与 \(A[j+1..r]\))。Hoare 划分交换次数更少,且对大量相等元素更稳健。
-
7-2 有相等元素的快速排序: 7.4.2 节假设元素互异。(a) 所有元素都相等时随机化快速排序运行时间为 \(\Theta(n^2)\);(b) 修改 PARTITION 得到 PARTITION′\((A,p,r)\),重排 \(A[p..r]\) 并返回两个下标 \(q\le t\),使 \(A[q..t]\) 全部相等、\(A[p..q-1]\) 每个元素小于 \(A[q]\)、\(A[t+1..r]\) 每个元素大于 \(A[q]\),时间 \(\Theta(r-p)\)(三路划分 / 荷兰国旗问题);(c) 改 RANDOMIZED-PARTITION 调用 PARTITION′ 得 RANDOMIZED-PARTITION′,改 QUICKSORT 得 QUICKSORT′,只对不知是否相等的元素划分递归;(d) 用 QUICKSORT′ 时如何调整 7.4.2 节分析以去掉元素互异的假设(等于主元的元素不再参与后续比较,比较概率仍不超过 \(2/(j-i+1)\))。
-
7-3 另一种快速排序分析: 着眼于每次递归调用的期望运行时间而非比较次数。(a) 规模 \(n\) 的数组中任一元素被选为主元的概率为 \(1/n\);定义 \(X_i=I\{\text{第 }i\text{ 小元素被选为主元}\}\),\(E[X_i]=1/n\);(b) 令 \(T(n)\) 为随机变量,论证
\[E[T(n)]=E\Big[\sum_{q=1}^nX_q\big(T(q-1)+T(n-q)+\Theta(n)\big)\Big];\tag{7.5}\](c) 改写为 \(E[T(n)]=\frac2n\sum_{q=2}^{n-1}E[T(q)]+\Theta(n)\) (7.6);(d) 证明 \(\sum_{k=2}^{n-1}k\lg k\le\frac12n^2\lg n-\frac18n^2\) (7.7)(把求和分为 \(k=2..\lceil n/2\rceil-1\) 与 \(k=\lceil n/2\rceil..n-1\) 两部分,前一部分用 \(\lg k\le\lg n-1\));(e) 用 (7.7) 以代入法证明 \(E[T(n)]\le an\lg n\),从而 \(E[T(n)]=\Theta(n\lg n)\)。
-
7-4 快速排序的栈深度: 第二个递归调用可以用迭代结构消除,这称为尾递归(tail recursion),好的编译器会自动做:
def tail_recursive_quicksort(A, p, r): while p < r: q = partition(A, p, r) # 划分并排序左子数组 tail_recursive_quicksort(A, p, q - 1) p = q + 1(a) 论证它能正确排序;编译器用栈执行递归过程,每次调用压入参数等信息(数组以指针表示,每次 \(O(1)\) 栈空间),栈深度指计算中任何时刻使用的最大栈空间。(b) 描述栈深度为 \(\Theta(n)\) 的情形(如输入已有序,每次左子数组规模 \(n-1\));(c) 修改代码使最坏栈深度为 \(\Theta(\lg n)\),同时保持期望 \(O(n\lg n)\)——总是对较小的那一侧递归,对较大的一侧用循环:
def quicksort_logstack(A, p, r): # 最坏栈深 Θ(lg n) while p < r: q = randomized_partition(A, p, r) if q - p < r - q: quicksort_logstack(A, p, q - 1); p = q + 1 else: quicksort_logstack(A, q + 1, r); r = q - 1 -
7-5 三数取中划分(median-of-3): 从子数组随机选 3 个元素,取其中位数为主元(参见练习 7.4-6)。设元素互异、\(n\ge3\),有序输出为 \(A'[1..n]\),\(p_i=\Pr\{x=A'[i]\}\)。(a) 对 \(i=2..n-1\),\(p_i=\dfrac{6(i-1)(n-i)}{n(n-1)(n-2)}\)(\(p_1=p_n=0\));(b) 与普通实现相比,选中中位数 \(A'[\lfloor(n+1)/2\rfloor]\) 的概率提高多少:\(n\to\infty\) 时比值趋于 \(3/2\);(c) 若“好划分”指主元 \(x=A'[i]\) 满足 \(n/3\le i\le2n/3\),概率从 \(1/3\) 提高到约 \(\int_{1/3}^{2/3}6t(1-t)\,dt=13/27\)(用积分近似);(d) 论证在 \(\Omega(n\lg n)\) 运行时间中三数取中只影响常数因子。
-
7-6 区间的模糊排序(fuzzy sorting): 不确切知道数值,只知道每个数所属的闭区间 \([a_i,b_i]\)。模糊排序是求区间的排列 \(\langle i_1,\dots,i_n\rangle\),使存在 \(c_j\in[a_{i_j},b_{i_j}]\) 满足 \(c_1\le c_2\le\cdots\le c_n\)。(a) 设计随机算法:整体结构是对左端点 \(a_i\) 做快速排序,但要利用区间重叠改进运行时间(重叠越多问题越容易)——选主元区间后,把与主元区间有公共部分的区间归为“相等”中间组(求所有重叠区间的公共交集作为主元区间),只对两侧递归;(b) 论证一般情况下期望 \(\Theta(n\lg n)\),所有区间都重叠(存在 \(x\) 属于所有区间)时期望 \(\Theta(n)\);算法不应显式检查这种情况,性能应随重叠增加自然改善。
章末注记(PDF p.211): 快速排序由 Hoare 发明(其版本见思考题 7-1);7.1 节的 PARTITION 归功于 N. Lomuto;7.4 节的分析归功于 Avrim Blum。Sedgewick 与 Bentley 讨论了实现细节及其重要性。McIlroy 展示了如何构造“杀手对手”(killer adversary),产生使几乎任何快速排序实现都花 \(\Theta(n^2)\) 的数组;若实现是随机化的,对手在看到快速排序的随机选择之后再产生数组。
第 7 章 本章要点
- 快速排序:分解靠 PARTITION(\(\Theta(n)\),原地),合并无需工作。Lomuto 划分的四区域循环不变式。
- 最坏 \(\Theta(n^2)\)(每次 \(n-1\) 与 0 划分,如已有序输入或全相等元素);最好 \(\Theta(n\lg n)\);任何常数比例划分都是 \(O(n\lg n)\)。
- 随机化(随机抽样选主元)后期望 \(\Theta(n\lg n)\),对任何输入成立;证明核心:\(z_i,z_j\) 被比较当且仅当 \(Z_{ij}\) 中第一个主元是二者之一,概率 \(2/(j-i+1)\),求和得 \(O(n\lg n)\)。
- 工程要点:小子数组切换插入排序、三数取中、三路划分处理重复元素、对较小侧递归以把栈深控制在 \(O(\lg n)\);快速排序不稳定。
第 7 章 与量化交易的关联
- 快速排序(及其变体 introsort)是 C++
std::sort、NumPy 默认np.sort(kind='quicksort')的基础;需要稳定排序(如按时间戳排序后再按价格排序、保持同价位订单的时间优先)时要显式选择稳定算法(kind='stable',归并/Timsort)。 - 金融数据常有大量重复值(价格按最小变动单位离散、大量零收益、停牌日相同价格),7-2 的三路划分是避免退化到 \(\Theta(n^2)\) 的关键;已排序或近乎有序的时间序列(7.2-4)正是朴素快速排序的最坏情况,应使用随机主元或插入排序。
- 7.4.2 节的“指示器变量 + 两两比较概率”方法是分析随机化算法的通用模板,同样用于快速选择(第 9 章)求分位数。
- 7-6 模糊排序对应“带置信区间的排名”:因子打分有估计误差时,只能确定区间而非点值的排序;重叠越多,可区分的排序信息越少,这一思想可用于避免对噪声排名过度交易。
- 7-4 的栈深度问题提醒:在 Python 中递归实现快速排序处理百万级数据会触发递归深度上限,应采用“小侧递归、大侧循环”。
第 7 章 推荐习题
- 7.1-2、7.2-2、思考题 7-2:重复元素问题与三路划分(金融数据重复值很多)。
- 7.2-4:近乎有序数据上插入排序胜过快速排序。
- 7.4-5:快速排序 + 插入排序收尾的混合策略。
- 思考题 7-1:Hoare 划分的正确性证明(边界条件极易写错)。
- 思考题 7-3:另一种期望分析,练习递归式期望的求解。
- 思考题 7-4:栈深度控制。
- 思考题 7-5:三数取中的概率分析。
第 8 章 线性时间排序(Sorting in Linear Time)(PDF p.212–233)
前面的归并排序、堆排序在最坏情况下、快速排序在平均情况下达到 \(O(n\lg n)\);对每种算法都能构造使其运行 \(\Omega(n\lg n)\) 的输入。它们的共同点是:排序结果只依赖输入元素之间的比较,称为比较排序(comparison sorts)。8.1 节证明任何比较排序在最坏情况下必须做 \(\Omega(n\lg n)\) 次比较,因此归并排序和堆排序渐近最优,不存在比它们快超过常数因子的比较排序。8.2–8.4 节介绍三种线性时间排序:计数排序、基数排序、桶排序,它们用比较以外的操作确定顺序,不受该下界约束。
8.1 排序的下界(Lower bounds for sorting)(PDF p.212–215)
比较排序只能通过元素间比较获取输入 \(\langle a_1,\dots,a_n\rangle\) 的顺序信息:对 \(a_i,a_j\) 做 \(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 model): 决策树是一棵满二叉树(full binary tree),表示某个排序算法在给定规模输入上执行的所有比较,忽略控制、数据移动等其他方面。内部结点标注 \(i:j\)(\(1\le i,j\le n\)),表示比较 \(a_i\le a_j\);左子树对应 \(a_i\le a_j\) 之后的比较,右子树对应 \(a_i>a_j\) 之后的比较。叶子标注排列 \(\langle\pi(1),\dots,\pi(n)\rangle\),表示排序结果 \(a_{\pi(1)}\le a_{\pi(2)}\le\cdots\le a_{\pi(n)}\)。算法的一次执行对应从根到某个叶子的一条简单路径。正确的排序算法必须能产生输入的每一种排列,因此 \(n!\) 个排列都必须作为叶子出现,且都必须可从根经某次实际执行到达(“可达”叶子)。例(图 8.1):插入排序作用于 3 个元素的决策树:根 1:2;若 \(\le\) 走 2:3,若再 \(\le\) 得 \(\langle1,2,3\rangle\),否则比较 1:3 得 \(\langle1,3,2\rangle\) 或 \(\langle3,1,2\rangle\);根若 \(>\) 走 1:3,若 \(\le\) 得 \(\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\)。\(3!=6\) 个排列,至少 6 个叶子。
最坏情况下界: 从根到可达叶子的最长简单路径长度即对应算法的最坏比较次数,等于决策树高度。所有“每个排列都作为可达叶子出现”的决策树高度的下界,就是任何比较排序运行时间的下界。
定理 8.1: 任何比较排序算法在最坏情况下都需要 \(\Omega(n\lg n)\) 次比较。证明:考虑高度 \(h\)、有 \(l\) 个可达叶子、对 \(n\) 个元素排序的决策树。\(n!\) 个排列都出现为叶子,故 \(n!\le l\);高度为 \(h\) 的二叉树叶子不超过 \(2^h\),故
推论 8.2: 堆排序和归并排序是渐近最优的比较排序(\(O(n\lg n)\) 上界与 \(\Omega(n\lg n)\) 下界匹配)。
练习 8.1: 8.1-1 比较排序决策树中叶子的最小可能深度为 \(n-1\)(至少需要 \(n-1\) 次比较才能确认已有序);8.1-2 不用 Stirling 公式,用 A.2 节技术(如把 \(\sum\lg k\) 拆半估计)求 \(\lg(n!)=\sum_{k=1}^n\lg k\) 的渐近紧确界 \(\Theta(n\lg n)\);8.1-3 证明不存在对至少一半的 \(n!\) 个输入运行时间为线性的比较排序,对 \(1/n\) 比例、\(1/2^n\) 比例的输入也不存在(高度为 \(h\) 的树中至多 \(2^h\) 个叶子深度 \(\le h\),\(\lg(n!/2)\)、\(\lg(n!/n)\)、\(\lg(n!/2^n)\) 都仍是 \(\Theta(n\lg n)\));8.1-4 输入由 \(n/k\) 个子序列组成,每个含 \(k\) 个元素,后一子序列的元素都大于前一子序列,只需各子序列内部排序;证明此变体需要 \(\Omega(n\lg k)\) 次比较(提示:直接合并各子序列下界不严格;应数可能输出数 \((k!)^{n/k}\),取对数得 \((n/k)\lg(k!)=\Omega(n\lg k)\))。
8.2 计数排序(Counting sort)(PDF p.215–218)
假设 \(n\) 个输入元素都是 \(0..k\) 之间的整数;\(k=O(n)\) 时运行时间 \(\Theta(n)\)。思想:对每个输入元素 \(x\),确定小于 \(x\) 的元素个数,据此把 \(x\) 直接放到输出数组中的位置(如有 17 个元素小于 \(x\),则 \(x\) 属于第 18 位);元素值相同时要稍作修改,避免放到同一位置。输入 \(A[1..n]\),输出 \(B[1..n]\),临时存储 \(C[0..k]\)。
def counting_sort(A, B, k): # 时间 Θ(n + k),额外空间 Θ(n + k),稳定,非原地
C = [0] * (k + 1) # C[0..k]
for j in 1..n:
C[A[j]] += 1 # C[i] = 等于 i 的元素个数
for i in 1..k:
C[i] += C[i - 1] # C[i] = 小于等于 i 的元素个数(前缀和)
for j in n downto 1: # 从后往前扫描保证稳定
B[C[A[j]]] = A[j]
C[A[j]] -= 1
例(图 8.2): \(A=\langle2,5,3,0,2,3,0,3\rangle\),\(k=5\)。第 5 行后 \(C=\langle2,0,2,3,0,1\rangle\);第 8 行后(前缀和)\(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\)。
正确性要点: 若元素互异,首次进入第 10 行时对每个 \(A[j]\),\(C[A[j]]\) 恰是其在输出中的最终位置(有 \(C[A[j]]\) 个元素 \(\le A[j]\))。元素可能重复,因此每放一次就把 \(C[A[j]]\) 减 1,使下一个值相同的输入元素放到紧靠其前的位置。
运行时间: 第 2–3 行 \(\Theta(k)\),第 4–5 行 \(\Theta(n)\),第 7–8 行 \(\Theta(k)\),第 10–12 行 \(\Theta(n)\),总计 \(\Theta(k+n)\);实践中通常在 \(k=O(n)\) 时使用,此时为 \(\Theta(n)\)。计数排序突破 \(\Omega(n\lg n)\) 下界,因为它不是比较排序——代码中完全没有输入元素之间的比较,而是用元素的实际值作为数组下标。离开比较排序模型,该下界就不适用。
稳定性(stable): 值相同的数在输出中的相对次序与输入中相同,即平局时先出现在输入中的先出现在输出中。通常只有带卫星数据时稳定性才重要;但计数排序的稳定性还有另一重要原因:它常被用作基数排序的子程序,而基数排序要求子排序稳定。
练习 8.2: 8.2-1 演示计数排序作用于 \(\langle6,0,2,0,1,3,4,6,1,3,2\rangle\);8.2-2 证明计数排序稳定;8.2-3 把第 10 行改为 for j = 1 to A.length,算法仍正确,但不再稳定(相同值的元素次序被反转);8.2-4 预处理 \(\Theta(n+k)\) 后以 \(O(1)\) 回答“有多少个整数落在 \([a..b]\) 中”:计算前缀计数数组 \(C\),答案为 \(C[b]-C[a-1]\)。
8.3 基数排序(Radix sort)(PDF p.218–221)
基数排序是如今只能在计算机博物馆里见到的卡片排序机所用的算法。卡片有 80 列,每列可在 12 个位置之一打孔;排序机可被机械地“编程”检查每张卡片的某一列,按打孔位置把卡片分到 12 个箱子之一,操作员再按箱子顺序收集卡片。十进制数字每列只用 10 个位置(其余 2 个编码非数字字符),\(d\) 位数占 \(d\) 列。排序机一次只能看一列,因此按 \(d\) 位数排序 \(n\) 张卡片需要一个排序算法。
直觉做法是先按最高有效位排序,再递归地对各箱子排序后合并,但为排序每个箱子要把其余 9 个箱子的卡片放在一边,产生大量需要跟踪的中间卡片堆(练习 8.3-5)。基数排序违反直觉地先按最低有效位(least significant digit)排序,把卡片合成一叠(0 号箱在 1 号箱之前……),再按次低位排序并合并,如此直到所有 \(d\) 位都排过,此时卡片按 \(d\) 位数完全有序,只需 \(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\)。
关键: 按位排序必须稳定。卡片排序机的排序是稳定的,但操作员从箱子中取出卡片时要注意不改变顺序。在顺序随机访问计算机上,基数排序有时用于按多个字段排序记录,如按年、月、日排序日期:可以用比较函数先比年、平局比月、再平局比日;也可以用稳定排序排三遍:先按日,再按月,最后按年。
def radix_sort(A, d): # 第 1 位最低,第 d 位最高
for i in 1..d:
use a stable sort to sort array A on digit i
引理 8.3: 给定 \(n\) 个 \(d\) 位数,每位可取至多 \(k\) 个值,若所用稳定排序耗时 \(\Theta(n+k)\),则 RADIX-SORT 在 \(\Theta(d(n+k))\) 时间内正确排序。证明:正确性对正在排序的列做归纳(练习 8.3-3);每位在 \(0..k-1\) 且 \(k\) 不太大时,计数排序是显然选择,每遍 \(\Theta(n+k)\),共 \(d\) 遍。\(d\) 为常数且 \(k=O(n)\) 时为线性时间。
引理 8.4: 给定 \(n\) 个 \(b\) 位数和任意正整数 \(r\le b\),若所用稳定排序对 \(0..k\) 的输入耗时 \(\Theta(n+k)\),则 RADIX-SORT 在 \(\Theta((b/r)(n+2^r))\) 时间内正确排序。证明:把每个关键字看作 \(d=\lceil b/r\rceil\) 个 \(r\) 位“数字”,每个数字在 \(0..2^r-1\),用 \(k=2^r-1\) 的计数排序(如 32 位字看成 4 个 8 位数字:\(b=32\),\(r=8\),\(k=255\),\(d=4\))。每遍 \(\Theta(n+2^r)\),共 \(d\) 遍。
\(r\) 的选择: 给定 \(n,b\),选 \(r\le b\) 最小化 \((b/r)(n+2^r)\):
- 若 \(b<\lfloor\lg n\rfloor\),对任意 \(r\le b\) 都有 \(n+2^r=\Theta(n)\),取 \(r=b\) 得 \((b/b)(n+2^b)=\Theta(n)\),渐近最优。
- 若 \(b\ge\lfloor\lg n\rfloor\),取 \(r=\lfloor\lg n\rfloor\) 在常数因子内最优:此时为 \(\Theta(bn/\lg n)\);\(r\) 增大超过 \(\lfloor\lg n\rfloor\) 时分子的 \(2^r\) 比分母的 \(r\) 增长快,得 \(\Omega(bn/\lg n)\);\(r\) 减小时 \(b/r\) 变大而 \(n+2^r\) 仍为 \(\Theta(n)\)。
基数排序 vs 快速排序: 若 \(b=O(\lg n)\)(常见)且取 \(r\approx\lg n\),基数排序为 \(\Theta(n)\),看似优于快速排序的期望 \(\Theta(n\lg n)\);但 \(\Theta\) 隐藏的常数不同:基数排序遍数可能更少,但每遍可能慢得多。选哪个取决于实现特性、机器(快速排序通常更有效利用硬件 cache)以及输入数据。此外以计数排序为中间稳定排序的基数排序不是原地的,而许多 \(\Theta(n\lg n)\) 比较排序是原地的;内存紧张时可能更倾向快速排序。
练习 8.3: 8.3-1 演示 RADIX-SORT 作用于单词表 COW, DOG, SEA, RUG, ROW, MOB, BOX, TAB, BAR, EAR, TAR, DIG, BIG, TEA, NOW, FOX;8.3-2 插入排序、归并排序稳定,堆排序、快速排序不稳定;让任何排序算法稳定的简单方案:把原下标作为次关键字附加到每个元素上(平局按下标比较),额外空间 \(\Theta(n)\)(每个下标 \(\lg n\) 位),时间只增加常数因子;8.3-3 用归纳法证明基数排序正确,并指出何处需要中间排序稳定(第 \(i\) 位相同的两数,必须保持按前 \(i-1\) 位已排好的相对次序);8.3-4 在 \(O(n)\) 时间内排序 \(0..n^3-1\) 的 \(n\) 个整数(视为 3 位 \(n\) 进制数,基数排序 3 遍,每遍 \(\Theta(n)\));8.3-5★ 先按最高位的卡片排序算法,最坏需要多少遍、要跟踪多少堆卡片(最坏 \(\frac{10^d-1}{9}\) 遍,需同时跟踪约 \(9d\) 堆)。
8.4 桶排序(Bucket sort)(PDF p.221–226)
桶排序假设输入服从均匀分布,平均情况运行时间 \(O(n)\)。与计数排序一样,它快是因为对输入做了假设:计数排序假设输入是小范围整数,桶排序假设输入由随机过程产生、在 \([0,1)\) 上均匀独立分布(附录 C.2)。把 \([0,1)\) 分成 \(n\) 个相等的子区间(桶,bucket),把 \(n\) 个输入分配到桶中;由于输入均匀独立,预期每个桶中不会有太多数。对每个桶排序,再按桶顺序依次列出元素。输入 \(A[1..n]\),\(0\le A[i]<1\);辅助数组 \(B[0..n-1]\) 存放链表(桶)。
def bucket_sort(A): # 平均 Θ(n),最坏 Θ(n^2)(桶内用插入排序),额外空间 Θ(n)
n = A.length
B = [empty list for _ in range(n)] # B[0..n-1]
for i in 1..n:
insert A[i] into list B[floor(n * A[i])]
for i in 0..n-1:
sort list B[i] with insertion sort
return concatenate B[0], B[1], ..., B[n-1] 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]\) 同桶,要么在下标更小的桶中。同桶时第 7–8 行把它们排好序;不同桶时第 9 行把它们按正确顺序排列。
运行时间分析: 除第 8 行外各行最坏 \(O(n)\)。令 \(n_i\) 为落入桶 \(B[i]\) 的元素个数(随机变量),插入排序为二次时间:
对输入分布取期望(平均情况),由线性性及 (C.22):
断言: 对 \(i=0,\dots,n-1\),
每个桶的 \(E[n_i^2]\) 相同不足为奇,因为每个输入值等可能落入任一桶。证明:定义 \(X_{ij}=I\{A[j]\text{ 落入桶 }i\}\),\(n_i=\sum_{j=1}^nX_{ij}\),展开平方:
\(X_{ij}\) 以 \(1/n\) 概率为 1,\(E[X_{ij}^2]=1^2\cdot\frac1n+0^2\cdot(1-\frac1n)=\frac1n\);\(k\ne j\) 时 \(X_{ij}\) 与 \(X_{ik}\) 独立,\(E[X_{ij}X_{ik}]=E[X_{ij}]E[X_{ik}]=\frac1{n^2}\)。代入:
代回 (8.1),平均运行时间 \(\Theta(n)+n\cdot O(2-1/n)=\Theta(n)\)。
推广: 即使输入不服从均匀分布,只要各桶大小的平方和与元素总数成线性关系,由 (8.1) 桶排序仍是线性时间。
练习 8.4: 8.4-1 演示 BUCKET-SORT 作用于 \(\langle.79,.13,.16,.64,.39,.20,.89,.53,.71,.42\rangle\);8.4-2 解释最坏情况为何是 \(\Theta(n^2)\)(所有元素落入同一桶),以及保持线性平均时间同时使最坏为 \(O(n\lg n)\) 的简单改动(桶内改用归并排序或堆排序);8.4-3 \(X\) 为抛两次均匀硬币的正面数,\(E[X^2]=0\cdot\frac14+1\cdot\frac12+4\cdot\frac14=\frac32\),\(E^2[X]=1\)(说明 \(E[X^2]\ne E^2[X]\));8.4-4★ 单位圆内均匀分布的 \(n\) 个点 \(p_i=(x_i,y_i)\)(\(0<x_i^2+y_i^2\le1\)),按到原点距离 \(d_i=\sqrt{x_i^2+y_i^2}\) 平均 \(\Theta(n)\) 排序:桶按等面积划分,第 \(i\) 个桶为 \(\sqrt{i/n}\le d<\sqrt{(i+1)/n}\)(或对 \(d_i^2\) 做桶排序,\(d^2\) 在 \([0,1]\) 上均匀);8.4-5★ 随机变量 \(X\) 的概率分布函数 \(P(x)=\Pr\{X\le x\}\),从 \(O(1)\) 可计算的连续分布 \(P\) 中抽取 \(X_1..X_n\),给出平均线性时间的排序算法(概率积分变换:\(P(X_i)\) 在 \([0,1]\) 上均匀,对 \(P(X_i)\) 做桶排序,\(P\) 单调保序)。
第 8 章 思考题(PDF p.226–233)
- 8-1 比较排序的概率下界: 证明任何确定性或随机化比较排序在 \(n\) 个互异元素上运行时间的概率性 \(\Omega(n\lg n)\) 下界。确定性比较排序 \(A\) 的决策树 \(T_A\),假设输入的所有排列等可能。(a) 每个叶子标上随机输入到达它的概率,证明恰有 \(n!\) 个叶子标为 \(1/n!\),其余为 0;(b) 令 \(D(T)\) 为决策树 \(T\) 的外部路径长度(所有叶子深度之和),\(T\) 有 \(k>1\) 个叶子,\(LT\)、\(RT\) 为左右子树,证明 \(D(T)=D(LT)+D(RT)+k\);(c) 令 \(d(k)\) 为所有 \(k>1\) 个叶子的决策树中 \(D(T)\) 的最小值,证明 \(d(k)=\min_{1\le i\le k-1}\{d(i)+d(k-i)+k\}\);(d) 证明对给定 \(k>1\),\(i\lg i+(k-i)\lg(k-i)\) 在 \(i=k/2\) 处取最小,从而 \(d(k)=\Omega(k\lg k)\);(e) 证明 \(D(T_A)=\Omega(n!\lg(n!))\),从而排序 \(n\) 个元素的平均时间为 \(\Omega(n\lg n)\);随机化比较排序 \(B\):把决策树扩展为两类结点——普通比较结点和“随机化”结点(模拟 RANDOM\((1,r)\),有 \(r\) 个等可能的孩子)。(f) 证明对任意随机化比较排序 \(B\),存在一个确定性比较排序 \(A\),其期望比较次数不超过 \(B\)(固定随机选择中最好的那一条)。
- 8-2 线性时间原地排序: \(n\) 个记录的关键字为 0 或 1,三个期望特性:(1) \(O(n)\) 时间;(2) 稳定;(3) 原地(除原数组外常数存储)。(a) 满足 1、2:计数排序;(b) 满足 1、3:类似 PARTITION 的双指针交换;(c) 满足 2、3:插入排序(或稳定的原地归并);(d) 能否把 (a)–(c) 中某个用作 RADIX-SORT 第 2 行的排序,使 \(b\) 位关键字的 \(n\) 个记录在 \(O(bn)\) 时间排好:可以用 (a)(稳定且线性),(b) 不稳定不行,(c) 不是线性时间;(e) 关键字在 \(1..k\) 时修改计数排序使其原地、\(O(n+k)\)(可用 \(O(k)\) 额外存储):计算各值的目标区间后循环交换把每个元素放到所属区间(类似 American flag sort),不稳定。
- 8-3 排序不同长度的数据项: (a) 整数数组,不同整数位数不同,所有整数总位数为 \(n\),在 \(O(n)\) 时间排序(先按位数分组——位数多的整数更大——再对每组用基数排序);(b) 字符串数组,总字符数 \(n\),在 \(O(n)\) 时间按字典序排序(如 \(a<ab<b\)):按首字符计数排序分组,再对每组递归处理剩余字符,长度用完的串排在组首。
- 8-4 水壶: \(n\) 个红壶与 \(n\) 个蓝壶,形状大小各异;红壶容量互不相同,蓝壶也是;每个红壶都有一个容量相同的蓝壶。唯一操作:取一红一蓝,把红壶灌满倒入蓝壶,得知哪个更大或相等,耗时 1 单位;不能直接比较两个红壶或两个蓝壶。(a) 确定性 \(\Theta(n^2)\) 算法:每个红壶与所有蓝壶比较找到配对;(b) 证明任何算法需要 \(\Omega(n\lg n)\) 次比较(决策树:\(n!\) 种可能配对,每次比较三种结果,高度 \(\ge\log_3n!\));(c) 期望 \(O(n\lg n)\) 的随机算法:随机选一个红壶,用它划分蓝壶,找到配对的蓝壶后用该蓝壶划分红壶,再对两侧递归——同快速排序分析,期望 \(O(n\lg n)\),最坏 \(\Theta(n^2)\)。(即“螺母与螺栓”问题。)
- 8-5 平均排序(average sorting): 不要求排序,只要求元素“平均递增”:若对所有 \(i=1..n-k\) 有 \(\frac{\sum_{j=i}^{i+k-1}A[j]}{k}\le\frac{\sum_{j=i+1}^{i+k}A[j]}{k}\),称 \(A\) 是 \(k\) 有序的(\(k\)-sorted)。(a) 1 有序即完全有序;(b) 给出 \(1..10\) 的一个 2 有序但未排序的排列(如 \(\langle1,6,2,7,3,8,4,9,5,10\rangle\));(c) 证明 \(n\) 元数组 \(k\) 有序当且仅当对所有 \(i=1..n-k\) 有 \(A[i]\le A[i+k]\)(滑动平均之差为 \((A[i+k]-A[i])/k\));(d) 在 \(O(n\lg(n/k))\) 时间内对 \(n\) 元数组 \(k\) 排序(把下标模 \(k\) 同余的 \(k\) 个子序列各自排序,每个长 \(n/k\));(e) 在 \(O(n\lg k)\) 时间内对 \(k\) 有序数组完全排序(用练习 6.5-9 的 \(k\) 路归并,\(k\) 个子序列各自已有序);(f) 证明 \(k\) 为常数时 \(k\) 排序 \(n\) 元数组需 \(\Omega(n\lg n)\) 时间(否则结合 (e) 可在 \(o(n\lg n)\) 完全排序,与比较排序下界矛盾)。这里的“\(k\) 有序 ⟺ 滑动均值单调”是滑动平均的有趣性质。
- 8-6 合并有序表的下界: 证明合并两个各含 \(n\) 项的有序表最坏需要 \(2n-1\) 次比较。(a) 把 \(2n\) 个数分成两个各 \(n\) 个数的有序表共有 \(\binom{2n}{n}\) 种方式;(b) 用决策树与 (a) 证明任何正确的合并算法至少 \(\lg\binom{2n}n=2n-o(n)\) 次比较;(c) 证明若两个元素在有序顺序中相邻且来自不同的表,则必须比较它们;(d) 由 (c) 推出 \(2n-1\) 的下界(构造两表交错的输入,相邻跨表对共 \(2n-1\) 对)。
- 8-7 0-1 排序引理与列排序(columnsort): 比较交换操作 COMPARE-EXCHANGE\((A,i,j)\)(\(i<j\)):若 \(A[i]>A[j]\) 则交换,之后 \(A[i]\le A[j]\)。无关比较交换算法(oblivious compare-exchange algorithm)只通过一个预先指定的比较交换操作序列工作:比较的位置必须事先确定,可依赖于元素个数,但不能依赖于待排序的值或之前比较交换的结果。例如插入排序可写成:
for j = 2 to n: for i = j-1 downto 1: COMPARE-EXCHANGE(A, i, i+1)。0-1 排序引理: 若无关比较交换算法能正确排序所有只含 0 和 1 的输入,则它能正确排序任意值的输入。用逆否命题证明:设算法 \(X\) 不能正确排序 \(A[1..n]\),令 \(A[p]\) 为被 \(X\) 放到错误位置的最小值,\(A[q]\) 为被 \(X\) 移到 \(A[p]\) 应在位置上的值;定义 0-1 数组 \(B[i]=0\)(若 \(A[i]\le A[p]\)),\(B[i]=1\)(若 \(A[i]>A[p]\))。(a) 论证 \(A[q]>A[p]\),从而 \(B[p]=0\)、\(B[q]=1\);(b) 证明 \(X\) 不能正确排序 \(B\)(比较交换与单调函数可交换)。列排序: 作用于 \(r\) 行 \(s\) 列(\(n=rs\))的矩阵,要求 \(r\) 为偶数、\(s\) 整除 \(r\)、\(r\ge2s^2\);结束时按列主序(自上而下、自左而右)递增。共 8 步,奇数步相同(各列分别排序),偶数步是固定置换:1 各列排序;2 转置并重塑为 \(r\) 行 \(s\) 列(最左列变成前 \(r/s\) 行,依次类推);3 各列排序;4 执行第 2 步的逆置换;5 各列排序;6 把每列上半移到同列下半、下半移到右邻列的上半(最左列上半空出,最后一列下半移到新增最右列的上半,其下半空);7 各列排序;8 执行第 6 步的逆置换。图 8.5 给出 \(r=6\)、\(s=3\) 的例子(虽违反 \(r\ge2s^2\) 但恰好成功),输入 \(\langle10,14,5;\ 8,7,17;\ 12,1,6;\ 16,9,11;\ 4,15,2;\ 18,3,13\rangle\)(逐行),最终按列主序得 \(1..18\)。(c) 论证即使不知道奇数步用什么排序方法,也可把列排序视为无关比较交换算法;定义:确知全为 0 或全为 1 的区域为“干净的”(clean),否则为“脏的”(dirty);设输入只含 0 和 1。(d) 证明步骤 1–3 后矩阵由顶部若干干净的全 0 行、底部若干干净的全 1 行、以及中间至多 \(s\) 个脏行组成;(e) 证明步骤 4 后按列主序读,开头是干净的 0 区、结尾是干净的 1 区,中间脏区至多 \(s^2\) 个元素;(f) 证明步骤 5–8 产生完全有序的 0-1 输出,从而列排序能正确排序任意值的输入;(g) 若 \(s\) 不整除 \(r\),证明步骤 1–3 后至多有 \(2s-1\) 个脏行,\(r\) 相对 \(s\) 需要多大(\(r\ge2(2s-1)s\) 量级);(h) 提出对步骤 1 的简单修改,使 \(s\) 不整除 \(r\) 时仍只需 \(r\ge2s^2\)(如用哑元素补齐),并证明正确。
章末注记(PDF p.232–233): 研究比较排序的决策树模型由 Ford 与 Johnson 引入;Knuth 关于排序的论著涵盖排序问题的许多变体,包括这里的信息论下界;Ben-Or 用推广的决策树模型研究排序下界。Knuth 认为 H. H. Seward 于 1954 年发明计数排序,并提出把计数排序与基数排序结合的思想;从最低有效位开始的基数排序似乎是机械卡片排序机操作员广泛使用的民间算法,据 Knuth 第一份公开文献是 L. J. Comrie 1929 年描述穿孔卡片设备的文件。桶排序自 1956 年起使用,基本思想由 E. J. Isaac 与 R. C. Singleton 提出。Munro 与 Raman 给出最坏情况 \(O(n^{1+\epsilon})\) 次比较的稳定排序(\(0<\epsilon\le1\) 为任意固定常数),虽比较次数多于 \(O(n\lg n)\) 算法,但只移动数据 \(O(n)\) 次且原地。对 \(n\) 个 \(b\) 位整数在 \(o(n\lg n)\) 时间排序有许多研究(假设内存分为可寻址的 \(b\) 位字):Fredman 与 Willard 的融合树(fusion tree)\(O(n\lg n/\lg\lg n)\);Andersson 改进到 \(O(n\sqrt{\lg n})\)(需乘法与预计算常数);Andersson、Hagerup、Nilsson、Raman 不用乘法 \(O(n\lg\lg n)\)(存储可能对 \(n\) 无界,用乘法散列可降到 \(O(n)\) 但变为期望时间);Thorup 不用乘法与随机化、线性空间 \(O(n(\lg\lg n)^2)\);Han 改进到 \(O(n\lg\lg n\lg\lg\lg n)\)。这些算法都是重要理论突破,但都相当复杂,目前看来不太可能在实践中与现有排序算法竞争。思考题 8-7 的列排序归功于 Leighton。
第 8 章 本章要点
- 决策树模型:比较排序 = 叶子数 \(\ge n!\) 的二叉树,高度 \(\ge\lg(n!)=\Omega(n\lg n)\);归并排序、堆排序渐近最优;平均情况与随机化比较排序同样有 \(\Omega(n\lg n)\) 下界(思考题 8-1)。
- 计数排序:\(\Theta(n+k)\),稳定(从后往前放置),非原地,用值作下标。
- 基数排序:从最低位开始,每位用稳定排序,\(\Theta(d(n+k))\);\(b\) 位整数取 \(r\approx\lg n\) 位一组得 \(\Theta(bn/\lg n)\)。
- 桶排序:均匀分布输入平均 \(\Theta(n)\),核心计算 \(E[n_i^2]=2-1/n\);只要桶大小平方和为线性即可。
- 线性排序以对输入的假设换取速度;实际选择需考虑常数、cache、是否原地。
第 8 章 与量化交易的关联
- 截面排名与分组: 因子值常需转为截面排名(rank)或分位数组(如十分组回测)。若已知值域有限(如离散化的评分、价格以最小变动价位为单位的整数刻度),可用计数排序/基数排序在线性时间完成;按概率积分变换(练习 8.4-5)把因子值映射为经验 CDF 后再分桶,正是“分位数标准化”的算法本质。
- 稳定排序的重要性: 多关键字排序(先按日期、再按代码,或订单按价格-时间优先)可用“从次要键到主要键依次稳定排序”实现(基数排序的思想,8.3 节日期例子),这正是 pandas
sort_values(kind='stable')多列排序的原理。 - 桶排序与直方图: 均匀分布假设下的分桶思想用于构造收益分布直方图、价格分档的订单簿数组实现(按价格档位直接寻址,相当于计数排序的“值作下标”),撮合引擎常用这种 \(O(1)\) 价位寻址。
- 思考题 8-5 的 \(k\) 有序: “滑动 \(k\) 期均值单调递增 ⟺ \(A[i]\le A[i+k]\)”是移动平均的一个精确刻画,可用于理解均线趋势判断的含义(\(k\) 日均线上行等价于今日价格高于 \(k\) 日前价格)。
- 合并下界(8-6): 多数据源行情合并的比较次数不可能少于 \(2n-1\),说明合并成本有硬下限,优化应着眼于 I/O 与内存布局。
第 8 章 推荐习题
- 8.1-3、8.1-4:深入理解决策树下界的应用方式。
- 8.2-3、8.2-4:计数排序稳定性与前缀计数的区间查询(与前缀和技巧相通)。
- 8.3-2、8.3-4:稳定化方案;\(n^3\) 范围整数的线性排序。
- 8.4-2、8.4-5:桶排序的最坏情况改进与任意已知分布的线性排序(概率积分变换)。
- 思考题 8-1:平均情况与随机化下界;8-4:水壶(螺母与螺栓)问题;8-5:\(k\) 有序与滑动均值;8-6:合并下界。
第 9 章 中位数与顺序统计量(Medians and Order Statistics)(PDF p.234–248)
\(n\) 元集合的第 \(i\) 个顺序统计量(order statistic)是第 \(i\) 小的元素:最小值是第 1 个(\(i=1\)),最大值是第 \(n\) 个(\(i=n\))。中位数(median)非形式地说是集合的“中点”:\(n\) 为奇数时唯一,\(i=(n+1)/2\);\(n\) 为偶数时有两个,\(i=n/2\) 与 \(i=n/2+1\)。不论奇偶,中位数出现在 \(i=\lfloor(n+1)/2\rfloor\)(下中位数,lower median)与 \(i=\lceil(n+1)/2\rceil\)(上中位数,upper median);本书为简便,“中位数”一律指下中位数。
选择问题(selection problem): 为方便假设元素互异(几乎所有内容都可推广到有重复值的情形)。
- 输入:\(n\) 个(互异的)数的集合 \(A\) 和整数 \(i\),\(1\le i\le n\)。
- 输出:\(A\) 中恰好大于其他 \(i-1\) 个元素的元素 \(x\)。
可用堆排序或归并排序排序后取第 \(i\) 个,\(O(n\lg n)\)。本章给出更快的算法:9.1 节最小值与最大值;9.2 节期望 \(O(n)\) 的实用随机算法(假设元素互异);9.3 节最坏 \(O(n)\)、更具理论意义的算法。
9.1 最小值和最大值(Minimum and maximum)(PDF p.235–236)
求 \(n\) 元集合最小值需要多少次比较?上界 \(n-1\):依次检查每个元素并记录目前最小者。
def minimum(A): # n-1 次比较,Θ(n) 时间,O(1) 空间
mn = A[1]
for i in 2..A.length:
if mn > A[i]:
mn = A[i]
return mn
最大值同理 \(n-1\) 次。这是最优的:下界也是 \(n-1\)。把求最小值的算法看作元素之间的锦标赛,每次比较是一场比赛,较小者获胜;除最终胜者外每个元素至少输一场,故至少需要 \(n-1\) 次比较。MINIMUM 在比较次数意义下最优。
同时求最小值和最大值: 应用如图形程序要把 \((x,y)\) 数据缩放到矩形屏幕上,需先求每个坐标的最小值和最大值。分别求各用 \(n-1\) 次,共 \(2n-2\) 次,渐近最优 \(\Theta(n)\)。但实际上至多 \(3\lfloor n/2\rfloor\) 次比较即可:维护目前的最小值和最大值,不是每个元素都与二者比较(每元素 2 次),而是成对处理:先把一对元素互相比较,再把较小者与当前最小值比较、较大者与当前最大值比较,每 2 个元素 3 次比较。初值设置:\(n\) 为奇数时最小值和最大值都设为第一个元素,其余成对处理;\(n\) 为偶数时先比较前两个元素确定初值,其余成对处理。比较次数:\(n\) 奇数时 \(3\lfloor n/2\rfloor\);\(n\) 偶数时 \(1+3(n-2)/2=3n/2-2\)。两种情况都至多 \(3\lfloor n/2\rfloor\)。
def min_and_max(A): # 至多 3*floor(n/2) 次比较
n = A.length
if n % 2 == 1:
mn = mx = A[1]; start = 2
else:
mn, mx = (A[1], A[2]) if A[1] < A[2] else (A[2], A[1]); start = 3
for i in range(start, n, 2): # 成对处理 A[i], A[i+1]
a, b = (A[i], A[i+1]) if A[i] < A[i+1] else (A[i+1], A[i])
if a < mn: mn = a
if b > mx: mx = b
return mn, mx
练习 9.1: 9.1-1 证明最坏情况下可用 \(n+\lceil\lg n\rceil-2\) 次比较求第二小元素(先用锦标赛 \(n-1\) 次求最小值;第二小元素必定只输给过最小值,与最小值比赛过的元素至多 \(\lceil\lg n\rceil\) 个,在其中求最小值再用 \(\lceil\lg n\rceil-1\) 次);9.1-2★ 证明同时求最大值与最小值最坏需要 \(\lceil3n/2\rceil-2\) 次比较的下界(考虑可能成为最大值或最小值的元素数量,以及一次比较如何影响这些计数——对手论证)。
9.2 期望为线性时间的选择算法(Selection in expected linear time)(PDF p.236–241)
一般选择问题看似比求最小值难,但渐近运行时间相同,都是 \(\Theta(n)\)。RANDOMIZED-SELECT 是以快速排序为模型的分治算法:像快速排序一样递归划分输入数组,但快速排序递归处理划分的两侧,而 RANDOMIZED-SELECT 只处理一侧。这一差别体现在分析中:快速排序期望 \(\Theta(n\lg n)\),RANDOMIZED-SELECT 期望 \(\Theta(n)\)(假设元素互异)。它使用 7.3 节的 RANDOMIZED-PARTITION,因此也是随机算法。返回 \(A[p..r]\) 中第 \(i\) 小的元素:
def randomized_select(A, p, r, i):
# 期望 Θ(n),最坏 Θ(n^2);原地;递归版期望栈深 O(lg n),可改为迭代 O(1)
if p == r:
return A[p] # 此时必有 i == 1
q = randomized_partition(A, p, r)
k = q - p + 1 # 低区元素个数 + 主元
if i == k: # 主元就是答案
return A[q]
elif i < k:
return randomized_select(A, p, q - 1, i)
else:
return randomized_select(A, q + 1, r, i - k)
说明:第 1 行检查基本情况(子数组只有一个元素,此时 \(i\) 必为 1)。否则 RANDOMIZED-PARTITION 把 \(A[p..r]\) 划分为 \(A[p..q-1]\)(每个元素 \(\le A[q]\))和 \(A[q+1..r]\)(每个元素 \(>A[q]\)),\(A[q]\) 称为主元。第 4 行计算低区元素个数加 1(主元)为 \(k\)。若 \(i=k\) 则 \(A[q]\) 就是第 \(i\) 小元素;若 \(i<k\),目标在低区,递归在低区选第 \(i\) 小;若 \(i>k\),目标在高区,由于已知 \(A[p..q]\) 这 \(k\) 个值比它小,目标是 \(A[q+1..r]\) 中第 \(i-k\) 小的元素。代码看似允许对 0 元素子数组递归,但练习 9.2-1 证明不会发生。
最坏情况: \(\Theta(n^2)\),即使求最小值也如此——若极其不走运,总是围绕剩余最大元素划分,每次划分 \(\Theta(n)\)。但期望运行时间是线性的,且因为随机化,没有特定输入会引发最坏行为。
期望运行时间分析: 设规模 \(n\) 的输入 \(A[p..r]\) 上运行时间为随机变量 \(T(n)\)。RANDOMIZED-PARTITION 等可能返回任一元素为主元,因此对每个 \(1\le k\le n\),子数组 \(A[p..q]\) 恰有 \(k\) 个元素(都 \(\le\) 主元)的概率为 \(1/n\)。定义指示器随机变量 \(X_k=I\{A[p..q]\text{ 恰有 }k\text{ 个元素}\}\),元素互异时
选定 \(A[q]\) 为主元时事先不知道是立即终止、在 \(A[p..q-1]\) 上递归还是在 \(A[q+1..r]\) 上递归,取决于第 \(i\) 小元素相对 \(A[q]\) 的位置。假设 \(T(n)\) 单调递增,可以用最大可能输入上的递归时间作为上界,即假设第 \(i\) 小元素总在元素较多的一侧。对给定调用,\(X_k\) 恰对一个 \(k\) 为 1;\(X_k=1\) 时可能递归的两个子数组规模为 \(k-1\) 与 \(n-k\):
取期望,用线性性和 (C.24)(\(X_k\) 与 \(T(\max(k-1,n-k))\) 独立,练习 9.2-2):
\(\max(k-1,n-k)=k-1\)(\(k>\lceil n/2\rceil\)),\(=n-k\)(\(k\le\lceil n/2\rceil\))。\(n\) 为偶数时 \(T(\lceil n/2\rceil)\) 到 \(T(n-1)\) 各项恰好出现两次;\(n\) 为奇数时这些项都出现两次,\(T(\lfloor n/2\rfloor)\) 出现一次。故
代入法证明 \(E[T(n)]=O(n)\): 假设 \(E[T(n)]\le cn\)(\(c\) 满足初始条件),\(n\) 小于某常数时 \(T(n)=O(1)\)(常数稍后选);取常数 \(a\) 使上式 \(O(n)\) 项(非递归部分)对所有 \(n>0\) 不超过 \(an\)。
需证足够大的 \(n\) 时 \(cn/4-c/2-an\ge0\),即 \(n(c/4-a)\ge c/2\)。只要 \(c>4a\),就有 \(n\ge\frac{c/2}{c/4-a}=\frac{2c}{c-4a}\)。因此若假设 \(n<2c/(c-4a)\) 时 \(T(n)=O(1)\),则 \(E[T(n)]=O(n)\)。结论:元素互异时可在期望线性时间内找到任意顺序统计量,特别是中位数。
练习 9.2: 9.2-1 证明 RANDOMIZED-SELECT 不会对 0 长度数组递归(\(i<k\) 时低区至少有 \(k-1\ge i\ge1\) 个元素;\(i>k\) 时高区有 \(n-k\ge i-k\ge1\) 个);9.2-2 论证 \(X_k\) 与 \(T(\max(k-1,n-k))\) 独立(后续递归中的随机选择与本次主元选择独立);9.2-3 写 RANDOMIZED-SELECT 的迭代版(用 while 循环更新 \(p,r,i\));9.2-4 用 RANDOMIZED-SELECT 选 \(A=\langle3,2,9,0,7,5,4,8,6,1\rangle\) 的最小值,描述导致最坏性能的划分序列(每次都选剩余最大元素为主元:9、8、7、…、1)。
9.3 最坏情况为线性时间的选择算法(Selection in worst-case linear time)(PDF p.241–245)
SELECT 同样递归划分,但保证划分是好的。它使用快速排序的确定性 PARTITION,修改为以划分元素为输入参数。对 \(n>1\) 个互异元素求第 \(i\) 小(\(n=1\) 时直接返回唯一元素):
- 把输入的 \(n\) 个元素分成 \(\lfloor n/5\rfloor\) 组、每组 5 个,以及至多一组由剩余 \(n\bmod5\) 个元素组成。
- 对 \(\lceil n/5\rceil\) 组中的每一组先用插入排序排序(至多 5 个元素),再取出中位数。
- 递归调用 SELECT 求第 2 步找出的 \(\lceil n/5\rceil\) 个中位数的中位数 \(x\)(偶数个时按约定取下中位数)。
- 用修改后的 PARTITION 围绕“中位数的中位数” \(x\) 划分输入数组。令 \(k\) 为低区元素个数加 1,于是 \(x\) 是第 \(k\) 小元素,高区有 \(n-k\) 个元素。
- 若 \(i=k\) 返回 \(x\);否则若 \(i<k\) 在低区递归找第 \(i\) 小,若 \(i>k\) 在高区递归找第 \(i-k\) 小。
def select(A, i): # 最坏 Θ(n) 时间;递归栈 O(lg n),分组中位数需 O(n) 额外空间(或原地交换)
n = len(A)
if n < 140: # 小规模直接排序(常数时间)
return sorted(A)[i - 1]
groups = [A[j:j+5] for j in range(0, n, 5)]
medians = [sorted(g)[(len(g) - 1) // 2] for g in groups] # 各组下中位数
x = select(medians, (len(medians) + 1) // 2) # 中位数的中位数
low = [a for a in A if a < x]
high = [a for a in A if a > x]
k = len(low) + 1
if i == k: return x
elif i < k: return select(low, i)
else: return select(high, i - k)
分析: 先求大于划分元素 \(x\) 的元素个数的下界(图 9.1:每组 5 个元素占一列,组中位数为白色,箭头从大元素指向小元素)。第 2 步找到的中位数中至少一半大于等于 \(x\)(脚注:由于元素互异,除 \(x\) 外所有中位数都严格大于或小于 \(x\))。因此 \(\lceil n/5\rceil\) 组中至少一半各贡献至少 3 个大于 \(x\) 的元素,但要排除两组:元素不足 5 个的那组(若 5 不整除 \(n\))和包含 \(x\) 本身的那组。故大于 \(x\) 的元素个数至少
同理至少 \(3n/10-6\) 个元素小于 \(x\)。因此第 5 步最坏情况下递归调用作用于至多 \(7n/10+6\) 个元素。
递归式: 第 1、2、4 步 \(O(n)\)(第 2 步是 \(O(n)\) 次对 \(O(1)\) 规模集合的插入排序);第 3 步 \(T(\lceil n/5\rceil)\);第 5 步至多 \(T(7n/10+6)\)(设 \(T\) 单调递增)。先做一个看似无来由的假设:少于 140 个元素的输入需 \(O(1)\) 时间(常数 140 的来历稍后可见):
代入法证明线性: 证明对某个足够大的常数 \(c\) 和所有 \(n>0\) 有 \(T(n)\le cn\)。先假设 \(c\) 足够大使 \(T(n)\le cn\) 对所有 \(n<140\) 成立;取常数 \(a\) 使 \(O(n)\) 项(非递归部分)对所有 \(n>0\) 不超过 \(an\)。代入:
当
时至多为 \(cn\)。\(n>70\) 时 (9.2) 等价于 \(c\ge10a\cdot\frac{n}{n-70}\)。由于 \(n\ge140\),\(\frac{n}{n-70}\le2\),故取 \(c\ge20a\) 即满足 (9.2)。(140 并无特别之处,可换成任何严格大于 70 的整数,再相应选 \(c\)。)因此 SELECT 最坏运行时间为线性。
与排序下界的关系: 与比较排序一样,SELECT 和 RANDOMIZED-SELECT 也只通过比较获取元素相对顺序信息。第 8 章表明比较模型下排序即使平均也需 \(\Omega(n\lg n)\)(思考题 8-1);第 8 章的线性时间排序对输入做了假设,而本章的线性时间选择算法不需要任何关于输入的假设。它们不受 \(\Omega(n\lg n)\) 下界约束,因为它们不排序就解决了选择问题。所以本章开头“排序后索引”的方法在渐近意义上是低效的。
练习 9.3: 9.3-1 分组改为每组 7 个时仍是线性时间(\(T(n)\le T(n/7)+T(5n/7+8)+O(n)\),\(1/7+5/7<1\));每组 3 个时不是线性(\(T(n)=T(n/3)+T(2n/3)+O(n)=\Theta(n\lg n)\));9.3-2 证明 \(n\ge140\) 时至少 \(\lceil n/4\rceil\) 个元素大于中位数的中位数 \(x\),至少 \(\lceil n/4\rceil\) 个元素小于 \(x\);9.3-3 让快速排序最坏 \(O(n\lg n)\):用 SELECT 找中位数作主元,\(T(n)=2T(n/2)+O(n)\);9.3-4★ 只用比较找到第 \(i\) 小元素的算法,无需额外比较即可找出比它小的 \(i-1\) 个元素和比它大的 \(n-i\) 个元素(比较记录构成的偏序已确定这些关系);9.3-5 给定一个最坏线性时间的“黑盒”中位数子程序,设计求任意顺序统计量的简单线性算法(用中位数划分,只在一侧递归,\(T(n)=T(n/2)+O(n)=O(n)\));9.3-6 \(n\) 元集合的 \(k\) 分位数(\(k\)th quantiles)是把有序集合分成 \(k\) 个等大小集合(误差 1 以内)的 \(k-1\) 个顺序统计量,给出 \(O(n\lg k)\) 算法(找中间那个分位数并划分,两侧递归,递归深度 \(\lg k\),每层 \(O(n)\));9.3-7 \(O(n)\) 找出 \(S\) 中最接近中位数的 \(k\) 个数(求中位数,计算各元素与中位数的距离,再用 SELECT 找第 \(k\) 小的距离并划分);9.3-8 \(X[1..n]\)、\(Y[1..n]\) 已排序,\(O(\lg n)\) 求全部 \(2n\) 个元素的中位数(比较两数组中位数,丢弃不可能包含答案的一半,二分);9.3-9 Olay 教授为石油公司咨询:东西走向的主输油管穿过 \(n\) 口油井的油田,每口井用南北走向的最短支线连到主管(图 9.2);已知各井 \(x\)、\(y\) 坐标,主管最优位置(最小化支线总长)是各井 \(y\) 坐标的中位数,可线性时间求出(\(\sum|y_i-y|\) 由中位数最小化)。
第 9 章 思考题(PDF p.245–247)
-
9-1 有序输出最大的 \(i\) 个数: 给定 \(n\) 个数,用基于比较的算法按序找出最大的 \(i\) 个,分析三种方法的最好最坏时间:(a) 排序后列出最大的 \(i\) 个:\(O(n\lg n+i)\);(b) 建最大优先队列,调用 EXTRACT-MAX \(i\) 次:\(O(n+i\lg n)\);(c) 用顺序统计量算法找第 \(i\) 大的数,围绕它划分,再对最大的 \(i\) 个数排序:\(O(n+i\lg i)\)(最好)。
-
9-2 带权中位数(weighted median): \(n\) 个互异元素 \(x_1..x_n\),正权重 \(w_1..w_n\),\(\sum w_i=1\);带权(下)中位数是满足
\[\sum_{x_i<x_k}w_i<\frac12\quad\text{且}\quad\sum_{x_i>x_k}w_i\le\frac12\]的元素 \(x_k\)。例:元素 \(0.1,0.35,0.05,0.1,0.15,0.05,0.2\),且每个元素的权等于其值(\(w_i=x_i\)),则中位数是 0.1,而带权中位数是 0.2。(a) 论证 \(w_i=1/n\) 时普通中位数就是带权中位数;(b) 用排序在 \(O(n\lg n)\) 最坏时间求带权中位数(排序后累加权重直到超过 1/2);(c) 用 SELECT 这类线性时间中位数算法在 \(\Theta(n)\) 最坏时间求带权中位数(求中位数并划分,比较低区权重和,只在一侧递归,\(T(n)=T(n/2)+\Theta(n)\))。邮局选址问题(post-office location problem):给定带权 \(w_i\) 的点 \(p_1..p_n\),求点 \(p\)(不必是输入点)最小化 \(\sum_{i=1}^nw_id(p,p_i)\)。(d) 论证一维情形(\(d(a,b)=|a-b|\))带权中位数是最优解;(e) 二维曼哈顿距离 \(d(a,b)=|x_1-x_2|+|y_1-y_2|\) 的最优解:\(x\) 坐标与 \(y\) 坐标分别取带权中位数(目标函数可分离)。
-
9-3 小顺序统计量: SELECT 求第 \(i\) 个顺序统计量的最坏比较次数 \(T(n)=\Theta(n)\),但常数很大;\(i\) 相对 \(n\) 较小时可用 SELECT 作子程序但最坏比较更少。(a) 设计用 \(U_i(n)\) 次比较求第 \(i\) 小元素的算法:\(U_i(n)=T(n)\)(\(i\ge n/2\)),否则 \(U_i(n)=\lfloor n/2\rfloor+U_i(\lceil n/2\rceil)+T(2i)\)(提示:先做 \(\lfloor n/2\rfloor\) 次不相交的两两比较,在由每对较小者组成的集合上递归;找到其中最小的 \(i\) 个后,答案必在这 \(i\) 个及其配对的较大者共 \(2i\) 个元素中);(b) 证明 \(i<n/2\) 时 \(U_i(n)=n+O(T(2i)\lg(n/i))\);(c) \(i\) 为小于 \(n/2\) 的常数时 \(U_i(n)=n+O(\lg n)\);(d) \(i=n/k\)(\(k\ge2\))时 \(U_i(n)=n+O(T(2n/k)\lg k)\)。
-
9-4 随机选择的另一种分析: 仿照 7.4.2 节快速排序分析,用指示器随机变量分析 RANDOMIZED-SELECT。元素互异,重命名为 \(z_1..z_n\)(\(z_i\) 第 \(i\) 小),RANDOMIZED-SELECT\((A,1,n,k)\) 返回 \(z_k\)。对 \(1\le i<j\le n\),\(X_{ijk}=I\{\text{求 }z_k\text{ 的执行过程中 }z_i\text{ 与 }z_j\text{ 被比较}\}\)。(a) 给出 \(E[X_{ijk}]\) 的精确表达式(依 \(i,j,k\) 的相对位置而不同:\(z_i,z_j\) 被比较当且仅当 \(\{z_{\min(i,k)},\dots,z_{\max(j,k)}\}\) 中第一个被选为主元的是 \(z_i\) 或 \(z_j\),概率为 \(2/(\max(j,k)-\min(i,k)+1)\));(b) 令 \(X_k\) 为求 \(z_k\) 时元素之间的总比较次数,证明
\[E[X_k]\le2\Big(\sum_{i=1}^k\sum_{j=k}^n\frac{1}{j-i+1}+\sum_{j=k+1}^n\frac{j-k-1}{j-k+1}+\sum_{i=1}^{k-2}\frac{k-i-1}{k-i+1}\Big);\](c) 证明 \(E[X_k]\le4n\);(d) 得出结论:元素互异时 RANDOMIZED-SELECT 期望运行时间 \(O(n)\)。
章末注记(PDF p.248): 最坏线性时间的中位数算法由 Blum、Floyd、Pratt、Rivest、Tarjan 设计(俗称 BFPRT 或 median-of-medians);快速的随机化版本归功于 Hoare(即 quickselect)。Floyd 与 Rivest 提出改进的随机化版本,围绕从元素小样本中递归选出的元素进行划分。确定中位数到底需要多少次比较仍未知:Bent 与 John 给出 \(2n\) 的下界,Schönhage、Paterson、Pippenger 给出 \(3n\) 的上界;Dor 与 Zwick 改进了二者——上界略小于 \(2.95n\),下界为 \((2+\epsilon)n\)(\(\epsilon\) 为小正常数),略微改进了 Dor 等人的相关工作。Paterson 综述了这些结果及相关工作。
第 9 章 本章要点
- 求最小值需且只需 \(n-1\) 次比较(锦标赛论证);同时求最小与最大值成对处理至多 \(3\lfloor n/2\rfloor\) 次。
- RANDOMIZED-SELECT(quickselect):只在一侧递归,期望 \(\Theta(n)\)、最坏 \(\Theta(n^2)\);分析用 \(E[T(n)]\le\frac2n\sum_{k=\lfloor n/2\rfloor}^{n-1}E[T(k)]+O(n)\) 与代入法。
- SELECT(中位数的中位数):5 个一组,保证至少 \(3n/10-6\) 个元素在每侧,\(T(n)\le T(\lceil n/5\rceil)+T(7n/10+6)+O(n)=O(n)\) 最坏;关键在于 \(1/5+7/10<1\)。
- 选择问题不需排序即可线性求解,不受 \(\Omega(n\lg n)\) 排序下界约束;中位数可用于快速排序保证最坏 \(O(n\lg n)\)。
- 带权中位数最小化带权绝对偏差和(邮局选址),可线性时间求出。
第 9 章 与量化交易的关联
- 分位数与截面统计: 因子研究中的截面中位数、分位数(如去极值 winsorize 用 1%/99% 分位、MAD 稳健标准化用中位数绝对偏差、分位数分组回测)都是选择问题;用 quickselect(NumPy 的
np.partition、np.percentile底层用 introselect)可在 \(O(n)\) 而非 \(O(n\lg n)\) 完成,对“每日 × 全市场 × 多因子”的批量计算能显著提速。练习 9.3-6 的 \(O(n\lg k)\) 求 \(k\) 分位数正适用于十分组/五分组。 - Top-K 选股(思考题 9-1): 只需最强的 \(i\) 只股票并排好序时,方法 (c) \(O(n+i\lg i)\) 最优——先
argpartition再对前 \(i\) 个排序,比全排序快。 - 带权中位数(思考题 9-2): 最小化 \(\sum w_i|x-x_i|\) 的解是带权中位数,这与 L1 回归/分位数回归、成交量加权中位价(VWMP)、按成交量加权的“公允价格”估计直接相关;在执行算法中,若冲击成本与偏离距离成线性,最优参考价就是带权中位数。邮局选址的“二维分离为一维”思想也用于多维 L1 优化问题。
- 稳健统计: 中位数对异常值不敏感,在处理脏数据(错价、闪崩 tick)时优于均值;练习 9.3-8 的两个有序数组求中位数可用于合并两个已排序数据源后的快速中位数查询。
- 最小最大值(9.1): 成对比较的 \(3n/2\) 技巧用于高频场景下对每个 bar 同时更新最高价与最低价,减少分支与比较。
第 9 章 推荐习题
- 9.1-1:\(n+\lceil\lg n\rceil-2\) 次比较求第二小(锦标赛思想)。
- 9.2-3:迭代版 quickselect(工程实现)。
- 9.3-1:分组大小 3、5、7 对线性性的影响,深刻理解 \(1/5+7/10<1\)。
- 9.3-3、9.3-5、9.3-6:用线性选择改进快速排序、求任意顺序统计量和 \(k\) 分位数。
- 9.3-8:两有序数组的中位数(经典面试题);9.3-9:输油管选址(中位数最小化绝对偏差和)。
- 思考题 9-1(Top-K)、9-2(带权中位数与邮局选址)、9-4(随机选择的指示器变量分析)。
(本块到 PDF 第 248 页、第 9 章章末注记为止;Part III《数据结构》开篇(第 10 章)续见下一块。)