Master Method 的三种情况
通过比较 f(n) 与 n^(log_b a) 的增长率,快速判断分治递归式的解:叶节点主导、均衡、或根节点主导。
CS267 · 第 23 课
从递归式的 Master Method 出发,深入探讨并行选择问题(Median of Medians)、并行归并排序以及分布式内存上的 Sample Sort,理解 Work-Span 模型下的复杂度分析。
Master Method 是求解形如 T(n) = aT(n/b) + f(n) 递归式的系统方法,其中 a ≥ 1、b > 1,且 f(n) 渐近为正。这个框架在分析分治算法时无处不在。
比较 f(n) 与 nlogba 的增长率。递归树的总工作量取决于叶节点(由 nlogba 决定)与每层合并开销(由 f(n) 决定)之间的"竞争"。
若 f(n) = O(nlogba-ε) 对某个常数 ε > 0 成立,则递归树的工作量主要集中在叶节点层,解为:
T(n) = Θ(n^(log_b a))
这意味着合并开销比叶节点工作量增长得更快,叶节点决定了总体复杂度。
若 f(n) = Θ(nlogba lgkn) 对某个常数 k ≥ 0 成立,则每一层的工作量大致相等,解为:
T(n) = Θ(n^(log_b a) lg^(k+1) n)
经典例子:归并排序中 a = 2, b = 2, k = 0,故 T(n) = Θ(n lg n)。
若 f(n) = Ω(nlogba+ε) 对某个常数 ε > 0 成立,且满足正则条件 a·f(n/b) ≤ c·f(n)(其中 c < 1),则:
T(n) = Θ(f(n))
正则条件保证了递归过程中工作量几何级数衰减,根节点的开销主导整个过程。
| 情况 | 条件 | 解 | 直觉 |
|---|---|---|---|
| Case 1 | f(n) = O(nlogba-ε) | Θ(nlogba) | 叶节点主导 |
| Case 2 | f(n) = Θ(nlogba lgkn) | Θ(nlogba lgk+1n) | 各层均衡 |
| Case 3 | f(n) = Ω(nlogba+ε) + 正则条件 | Θ(f(n)) | 根节点主导 |
选择问题是指:在包含 N 个元素的列表中找到第 k 小的元素。这是一个基础构建块,在统计学(中位数、四分位数)、图算法(MCL 聚类)和负载均衡中频繁出现。
由 Tony Hoare 设计,类似于快速排序,但只向一侧递归。选择一个 pivot 后将数组分为"小于 pivot"和"大于 pivot"两部分,然后只在包含第 k 元素的那一侧继续递归。
Quickselect 的最坏情况 O(N²) 在某些应用场景下不可接受。Blum、Floyd、Pratt、Rivest 和 Tarjan 在 1973 年提出的 Median of Medians 算法保证了最坏情况线性时间。有趣的是,六位作者中有四位是图灵奖得主!
Median of Medians 的核心思想是精心选择 pivot,保证每次递归都能丢弃一定比例的元素,从而保证线性时间复杂度。
// SELECT(S, k): 在集合 S 中找第 k 小元素
SELECT(S, k) {
M = DIVIDE_AND_SORT(S, 5); // O(N),将 S 分为 N/5 组并排序
mm = SELECT(M, |M|/2); // 递归找中位数的中位数,T(N/5)
[A, B, C] = PARTITION(S, mm); // O(N),按 mm 划分
if (k > |A| && k <= |A| + |B|)
return mm; // mm 就是答案
else if (k <= |A|)
return SELECT(A, k); // 在较小的一半递归
else
return SELECT(C, k - |A| - |B|); // 在较大的一半递归
}
关键在于确定递归调用时集合大小的上界。设 A' 是 M 中所有小于 mm 的中位数对应的完整组,C' 是大于 mm 的那些:
因此,无论递归进入 A 还是 C,最多只有 N - 3N/10 = 7N/10 个元素。
递归式为:
T(n) ≤ T(n/5) + T(7n/10) + O(n)
展开递归树:每层工作量以 9/10 的比率递减:
T(n) = c(n + 9n/10 + 81n/100 + ...)
= cn · (1 + 9/10 + (9/10)² + ...)
= cn · 1/(1 - 9/10)
= O(n)
若选择 3 个一组,递归式变为 T(n) ≤ T(n/3) + T(2n/3) + O(n),其解为 O(n lg n)。若选择 7 个或更多一组,虽然仍为 O(n),但常数因子更大。5 是保证线性时间的最小分组大小。
在分布式内存系统中,每个处理器持有一部分数据。朴素的并行 Median of Medians 存在负载不均衡问题:随着递归深入,数据分布变得不均匀,需要频繁的数据重分布和通信。
核心洞察:与其费力处理负载不均衡,不如直接忽略它!
给定 p 个不同元素 m₁, m₂, ..., mₚ 及其对应正权重 w₁, w₂, ..., wₚ(满足 Σwᵢ = 1),加权中位数 M 满足:
Σ_{mᵢ < M} wᵢ ≤ 1/2 且 Σ_{mᵢ > M} wᵢ ≤ 1/2
// 并行选择:在分布式内存中找第 k 小元素
PARALLEL_SELECT(S, k) {
lm = SELECT(S, |S|/2); // 每个处理器找本地中位数
LMS = MPI_Allgather(lm); // 交换所有本地中位数
wmm = WeightedMedian(LMS); // 计算加权中位数(冗余计算)
[A, B, C] = PARTITION(S, wmm); // 本地划分
ls = MPI_Allreduce(|A|, SUM); // 全局小于 wmm 的元素数
eq = MPI_Allreduce(|B|, SUM); // 全局等于 wmm 的元素数
if (k > ls && k <= ls + eq)
return wmm;
else if (k <= ls)
return PARALLEL_SELECT(A, k); // 递归,最多 3N/4
else
return PARALLEL_SELECT(C, k - ls - eq); // 递归,最多 3N/4
}
根据加权中位数定义,权重和 ≤ 1/2 意味着至少一半的元素在 M 的"两侧"。将权重替换为各处理器的元素数 nᵢ:
因此,每次递归至少丢弃 N/4 个元素,最多保留 3N/4。
| 指标 | 复杂度 | 说明 |
|---|---|---|
| Tcomm(n) | O(α log²p + β(p log p + n/p)) | 粗化基本情况:O(log p) 轮递归 + 最终本地排序 |
| Tcomp(n) | O((n/p) · log n) | 本地列表不保证收缩,最终需要本地排序 |
| 递归深度 | O(log n) → 粗化后 O(log p) | 每轮至少丢弃 1/4 元素 |
存在更快的随机化算法,但近年来缺乏系统的实验研究。对于课程项目而言,评估不同并行选择算法的变体是一个潜在的研究方向。相关文献包括 Bader & JáJá (1996) 和 Bader (2004) 的工作。
归并排序是基于分治范式的经典递归排序算法,其核心操作是合并(merge):将两个已排序序列合并为一个有序序列。
// 合并两个已排序数组 A[na] 和 B[nb] 到 C[]
void Merge(T *C, T *A, T *B, int na, int nb) {
while (na > 0 && nb > 0) {
if (*A <= *B) { *C++ = *A++; na--; }
else { *C++ = *B++; nb--; }
}
while (na > 0) { *C++ = *A++; na--; }
while (nb > 0) { *C++ = *B++; nb--; }
}
// 时间复杂度:Θ(n),其中 n = na + nb
// 并行归并排序:将 A[0..n-1] 排序到 B[0..n-1]
void MergeSort(T *B, T *A, int n) {
if (n == 1) {
B[0] = A[0];
} else {
T *C = new T[n]; // 临时数组
#pragma omp parallel
{
#pragma omp single
{
#pragma omp task
MergeSort(C, A, n / 2); // 排序左半
#pragma omp task
MergeSort(C + n/2, A + n/2, n - n/2); // 排序右半
}
}
Merge(B, C, C + n/2, n/2, n - n/2); // 串行合并
delete[] C;
}
}
| 指标 | 递归式 | 解 | Master Method 情况 |
|---|---|---|---|
| Work T₁(n) | 2T₁(n/2) + Θ(n) | Θ(n lg n) | Case 2:nlog₂2 = n |
| Span T∞(n) | T∞(n/2) + Θ(n) | Θ(n) | Case 3:合并步骤是串行的 |
| Parallelism | T₁ / T∞ | Θ(lg n) | 太弱了! |
朴素并行版本的并行度仅为 Θ(lg n)——非常"可怜"(PUNY!)。原因在于合并步骤 Merge() 是串行的,其 Θ(n) 的 Span 主导了整个算法。必须并行化合并操作!
P_Merge 算法的关键洞察是:通过二分搜索在两个数组之间"交替分割",使得每次递归至少丢弃 1/4 的元素。
假设 na ≥ nb(A 是较大的数组),取 A 的中点 A[mₐ],在 B 中二分搜索 A[mₐ] 的位置 mb。那么:
// 并行合并:将 A[na] 和 B[nb] 合并到 C[]
void P_Merge(T *C, T *A, T *B, int na, int nb) {
if (na < nb) { P_Merge(C, B, A, nb, na); return; } // 确保 na ≥ nb
if (na == 0) return;
int ma = na / 2;
int mb = BinarySearch(A[ma], B, nb); // 在 B 中找 A[ma] 的位置
C[ma + mb] = A[ma]; // 放置 A[ma]
#pragma omp parallel
{
#pragma omp single
{
#pragma omp task
P_Merge(C, A, B, ma, mb); // 左半部分
#pragma omp task
P_Merge(C + ma + mb + 1, A + ma + 1, B + mb,
na - ma - 1, nb - mb); // 右半部分
}
}
// 隐式 taskwait
}
递归式为 T∞(n) = T∞(3n/4) + Θ(lg n):
T_∞(n) = T_∞(3n/4) + Θ(lg n)
Case 2: n^(log_{4/3} 1) = 1, f(n) = Θ(lg n) = Θ(lg¹ n)
=> T_∞(n) = Θ(lg² n)
Work 的递归式为 T₁(n) = T₁(αn) + T₁((1-α)n) + Θ(lg n),其中 1/4 ≤ α ≤ 3/4。使用代入法证明 T₁(n) = Θ(n):
归纳假设:T₁(k) ≤ c₁k - c₂lg k,对任意 k < n 成立
T₁(n) ≤ c₁(αn) - c₂lg(αn) + c₁(1-α)n - c₂lg((1-α)n) + Θ(lg n)
= c₁n - c₂[lg(αn) + lg((1-α)n)] + Θ(lg n)
= c₁n - c₂[lg(α(1-α)) + 2lg n] + Θ(lg n)
= c₁n - c₂lg n - (c₂(lg n + lg(α(1-α))) - Θ(lg n))
≤ c₁n - c₂lg n (选取足够大的 c₂)
| 指标 | 值 |
|---|---|
| Work | Θ(n) |
| Span | Θ(lg² n) |
| Parallelism | Θ(n / lg² n) |
将 P_Merge 替换串行 Merge,得到完全并行的归并排序:
// 并行归并排序(使用并行合并)
void P_MergeSort(T *B, T *A, int n) {
if (n == 1) {
B[0] = A[0];
} else {
T C[n];
#pragma omp parallel
{
#pragma omp single
{
#pragma omp task
P_MergeSort(C, A, n / 2);
#pragma omp task
P_MergeSort(C + n/2, A + n/2, n - n/2);
}
}
P_Merge(B, C, C + n/2, n/2, n - n/2); // 并行合并!
}
}
| 指标 | 递归式 | 解 | 说明 |
|---|---|---|---|
| Work T₁(n) | 2T₁(n/2) + Θ(n) | Θ(n lg n) | 与串行相同,Case 2 |
| Span T∞(n) | T∞(n/2) + Θ(lg² n) | Θ(lg³ n) | Case 2:nlog₂1 = 1 |
| Parallelism | T₁ / T∞ | Θ(n / lg² n) | 大幅提升! |
| 版本 | Span | Parallelism |
|---|---|---|
| 朴素并行(串行合并) | Θ(n) | Θ(lg n) |
| P_MergeSort | Θ(lg³ n) | Θ(n / lg² n) |
并行化合并使并行度从对数级提升到接近线性!
桶排序假设输入数据在区间 [a, b] 上均匀分布。算法将区间划分为 m 个等宽的桶,每个元素放入对应桶中,各桶内局部排序后连接。若数据确实均匀分布,每个桶大约包含 n/m 个元素,总运行时间 Θ(n lg(n/m))。
并行化非常直接:选择 m = p(处理器数),每个处理器负责一个值域区间。每个处理器扫描本地列表,通过 all-to-all 通信将元素发送到目标处理器,最后各处理器对收到的元素排序。
桶排序的核心假设——输入在 [a, b] 上均匀分布——在实际中往往不成立。真实数据可能高度偏斜,导致某些桶过载、某些桶空闲,严重影响并行效率。
Sample Sort 是 Quicksort 的自然推广:从单个 pivot 扩展到 p 个 pivot(splitters),将数据划分为 p+1 个桶,分配给 p 个处理器并行排序。
为了保证负载平衡,采用规则采样(regular sampling)策略:
Regular sampling 保证每个桶的元素数 < 2n/p(即最坏负载不均衡为 2 倍)。证明思路:
| 步骤 | 时间复杂度 | 说明 |
|---|---|---|
| 本地排序 | Θ((n/p) lg(n/p)) | 对 n/p 个元素排序 |
| 样本收集(gather) | Θ(p²) | 收集 p(p-1) 个样本 |
| 样本排序 | Θ(p² lg p) | 对 p(p-1) 个样本排序 |
| 选择 splitters | Θ(p) | 等间距选取 p-1 个 |
| 本地二分插入 | Θ(p lg(n/p)) | p-1 次二分搜索 |
| 数据重分布 | O(n/p) | all-to-all 通信 |
虽然理论上最坏情况是 2 倍负载不均衡,但实践中 Sample Sort 的负载平衡远优于理论界。这是因为理论证明使用的是最坏情况分析,而实际数据分布通常比最坏情况好得多。
当 Sample Sort 扩展到数万核心时,样本收集阶段会遇到严重的内存问题:
在 20,000 核心上运行 Sample Sort,每个样本至少 12 字节(4 字节值 + 8 字节全局索引)。p(p-1) 个样本共需:
20,000 × 19,999 × 12 bytes ≈ 4.8 GB per core!
这甚至还未开始排序,仅样本就已占用每核心数 GB 内存。
Sample Sort 的瓶颈已从计算转移到通信和内存。在百亿亿次(Exascale)系统上,如何高效地收集和处理样本是排序算法设计的关键挑战。
Q1:Master Method 情况 2 适用于以下哪种条件的递归式?
Q2:Median of Medians 算法选择 5 个元素一组,而非 3 个,原因是?
Q3:并行归并排序 P_MergeSort 的 Span 是多少?
Q4:Sample Sort 中 regular sampling 保证每个桶的元素数少于?
Q5:在大规模系统上运行 Sample Sort 时,为什么样本收集会成为瓶颈?
通过比较 f(n) 与 n^(log_b a) 的增长率,快速判断分治递归式的解:叶节点主导、均衡、或根节点主导。
通过精心选择 pivot(中位数的中位数),保证每次递归至少丢弃 3n/10 元素,实现最坏情况 O(n) 时间选择。
归并排序的并行度瓶颈在合并操作。P_Merge 通过二分搜索分割将 Span 降至 Θ(lg² n),使整体并行度从 Θ(lg n) 提升到 Θ(n/lg² n)。
Regular sampling 以 O(p²) 的额外开销保证最坏 2 倍负载不均衡。在大规模系统上,样本收集的内存占用成为新瓶颈。