排序算法之:锦标赛排序的败者树优化策略
题目描述
我们已知经典的锦标赛排序(Tournament Sort,也称为树形选择排序)的基本原理:它通过构建一棵完全二叉树(锦标赛树)来模拟锦标赛过程,每次从n个元素中选出胜者(最小/最大值),将其输出后,用一个新的候选者替换其位置,并重新进行从该叶节点到根节点的“比赛”,以选出下一个胜者。这个过程重复n次即可完成排序。其时间复杂度为O(n log n)。
然而,基本实现中每次替换胜者后,都需要重新进行从该叶节点到根节点的log n次比较。虽然避免了堆排序中类似的下沉(sift-down)操作,但每次重新比赛的开销依然存在。败者树(Loser Tree)是锦标赛排序的一种重要优化数据结构。它不再在树节点中保存“胜者”,而是保存“败者”,而将最终的“冠军”(全局胜者)保存在一个额外的节点中。这样,当胜者被输出并被新元素替换后,重构整条路径的效率会更高,同时能更清晰地组织多路归并(虽然我们这里先聚焦于单列表排序)。
本题要求:详细解释败者树优化在锦标赛排序中的应用。包括:
- 败者树的结构定义与构建。
- 如何使用败者树优化“替换胜者并重构”的过程。
- 与基本锦标赛排序相比,它在比较次数和常数因子上的优化点。
- 给出其排序过程的伪代码或详细步骤,并分析时间复杂度。
解题过程循序渐进讲解
步骤1:回顾基本锦标赛排序的流程与潜在问题
假设我们要对数组 arr = [5, 3, 8, 1, 2] 进行升序排序(找最小值)。
- 建树:将元素放到叶节点(通常用数组表示完全二叉树)。内部节点存储其两个子节点的较小者。
叶节点: [5, 3, 8, 1, 2] (假设用-∞或极大值填充到2的幂,这里n=5,我们简化为5个叶子) 构建过程:比较相邻叶子,胜者上升。 最终树结构(只画逻辑关系): 1 / \ 3 1 / \ / \ 5 3 8 1 实际在数组中存储内部节点时,需要计算偏移。 - 输出胜者:根节点1是当前最小,输出。
- 替换与重构:将胜者1所在的叶节点(原始数组位置)用正无穷(
∞)替代,表示该选手已被淘汰。然后,从这个叶节点开始,重新和它的兄弟比较,胜者上升到父节点,如此向上直到根,选出新的胜者。- 这个过程需要从叶到根遍历,每层1次比较,总共约
log n次。
- 这个过程需要从叶到根遍历,每层1次比较,总共约
潜在问题:每次重构都需要“赢家”重新比赛。在重构路径上,节点需要知道它的两个子节点中谁是“之前的赢家”(即当前节点存储的值),以与新的候选者比较。这需要访问兄弟节点并判断,逻辑稍显复杂。
步骤2:引入败者树的核心思想
败者树的核心优化在于:每个内部节点记录的是“这场比赛中的败者”,而“胜者”则继续向上参赛。整棵树最终在根节点之上(或一个额外节点)记录“总冠军”。
优势:
- 当某个叶节点的值被更新后,在从该叶节点到根节点的路径上,我们只需要将新值与路径上节点记录的败者进行比较,胜者继续向上,败者留在该节点。这个过程更规整,避免了“谁是当前赢家”的判断。
- 特别适合多路归并(多个有序流的归并),因为每个叶节点对应一个归并段,更新时只需从该段取下个元素。这里我们聚焦于用败者树实现单个无序数组的排序。
步骤3:败者树的结构与存储
对于一个有 n 个叶节点的败者树(对应待排序的 n 个元素):
- 我们需要一个数组
tree来存储内部节点,长度为n-1(如果n是2的幂,方便实现;否则可以补虚节点,值设为极大值)。 - 叶节点值本身存储在另一个数组
leaves中(初始为待排序数组)。 - 我们还需要一个变量
winner来存储当前全局最小值的叶节点索引(或值)。在构建和调整后,winner指示了当前最小的元素所在叶位置。
关键:tree[k] 存储的是以k为根的子树中,比赛到该节点时的败者(的叶节点索引)。而胜者传递到上层。
步骤4:败者树的构建(初始化)
我们以 leaves = [5, 3, 8, 1, 2] 为例,为简单,假设我们将其扩展为8个叶(n=8,补3个∞),内部节点 tree 长度=7。
构建过程(自底向上):
- 从最底层的内部节点开始,每个节点对应两个叶节点。
- 比较两个叶节点的值,较大者(败者)的索引存入该内部节点,较小者(胜者)的索引继续向上层传递。
- 向上层传递时,再与来自另一子树的胜者比较,败者存入当前节点,胜者继续向上。
- 最终,到达根节点时,最后一次比赛的败者存入
tree[0],而最终的胜者(全局最小)记录在winner变量中。
例:简化版,假设叶索引0~4对应值[5,3,8,1,2],索引5~7为∞。
- 第一层比较(叶层):
- 比较索引0(5)和1(3):败者索引0(值5)存入某节点,胜者索引1(值3)向上。
- 比较索引2(8)和3(1):败者索引2(值8)存入,胜者索引3(值1)向上。
- 比较索引4(2)和5(∞):败者索引5(∞)存入,胜者索引4(2)向上。
- 索引6、7的∞比较,败者索引6(∞),胜者索引7(∞)向上(但值∞不影响)。
- 向上层比较胜者:
- 比较上层传递来的胜者索引1(3)和索引3(1):败者索引1(值3)存入,胜者索引3(值1)向上。
- 比较胜者索引4(2)和索引7(∞):败者索引7(∞),胜者索引4(2)向上。
- 最后比较胜者索引3(1)和索引4(2):败者索引4(值2)存入根节点
tree[0],胜者索引3(值1)成为全局winner。
此时,winner=3(值1),tree 中记录了各层败者。
步骤5:取出胜者并调整败者树
- 输出当前
winner对应的叶节点值leaves[winner](即最小值)。 - 从该叶节点所属的归并段(此处是单个数组)取下一个元素。由于我们是对单个数组排序,且该位置元素已被输出,我们将其替换为
∞(表示该选手已淘汰)。 - 调整败者树:从该叶节点(索引
winner)开始,沿着父节点路径向上直到根。- 在每个父节点,将新到来的值(即当前叶节点的新值)与该节点记录的败者(存储在
tree中)进行比较。 - 比赛的胜者(较小值)继续向上传递,败者(较大值)的索引更新存储在该父节点。
- 在每个父节点,将新到来的值(即当前叶节点的新值)与该节点记录的败者(存储在
- 当到达根节点后,最后一次比较的胜者成为新的全局
winner。
举例:初始后,winner=3(值1),输出1。将 leaves[3] 设为 ∞。
调整路径:从叶索引3向上。
- 父节点存储的败者是索引1(值3)。比较新值 ∞ 与 值3,胜者是索引1(值3),败者是索引3(∞)。更新该父节点为败者索引3,胜者索引1向上。
- 上层父节点(根)存储的败者是索引4(值2)。比较胜者索引1(值3)与 值2,胜者是索引4(值2),败者是索引1(值3)。更新根节点为败者索引1,胜者索引4成为新
winner。
现在 winner=4(值2),输出2,继续。
优化体现:在调整过程中,我们只需要与父节点中已存储的败者比较一次,就可以决定新的胜者和败者。而不需要像原始锦标赛排序那样,每次都要访问兄弟节点并判断哪个是当前赢家。这减少了数据读取和比较的逻辑分支,常数更优。
步骤6:排序全过程与伪代码
输入:数组 arr[0..n-1]
输出:升序排序结果
1. 初始化:
- 将 arr 复制到 leaves[0..n-1]。
- 将 leaves[n..2^k-1] 填充为 ∞(k是满足2^k >= n的最小整数,叶节点数=2^k)。
- 创建 tree[0..2^k-2] 存储内部节点(败者索引)。
- winner = 未定义。
2. 构建败者树 buildLoserTree():
- 从最后一个内部节点往前遍历:
* 对每个内部节点,其两个子节点(可能是叶或下层内部节点)的胜者进行比较,败者索引存入该节点,胜者索引向上传递。
- 最终 winner 设为最终胜者索引。
3. 循环 n 次:
a. 输出 leaves[winner]。
b. leaves[winner] = ∞。
c. adjust(winner):从 winner 叶节点开始,沿父节点路径调整到根。
对于当前节点 p(父节点):
- 比较 leaves[winner] 和 leaves[tree[p]](当前节点记录的败者)。
- 较大值的索引作为新败者存入 tree[p]。
- 较小值的索引作为新胜者,继续向上(赋值给 winner,用于上层比较)。
d. 循环结束后,winner 自动更新为下一个最小值的索引。
4. 结束。
步骤7:时间复杂度与优化分析
- 建树:需要初始化并比较所有内部节点。对于 m 个叶节点(m 是2的幂,m ≈ 2n),有 m-1 个内部节点,每个节点一次比较,建树 O(m) = O(n)。
- 调整:每次输出一个元素后,调整路径长度 = 树高 = O(log m) = O(log n)。每次调整在每一层只需要一次比较。
- 总比较次数:建树 O(n) + n 次调整 * O(log n) = O(n log n)。
与基本锦标赛排序对比:
- 基本锦标赛排序每次调整也需要 O(log n) 次比较,但每次比较可能需要先确定兄弟中的胜者(需两次读取和比较判断),实际常数因子更大。
- 败者树的优势在于结构清晰,每次调整只需一次确定性的比较(新值与存储的败者),减少了数据访问和指令分支,在实现上更高效。尤其是在硬件层面或需要稳定排序的多路归并中,这种优势更明显。
空间复杂度:需要额外 O(n) 空间存储树结构和扩充的叶节点。
总结
败者树优化将锦标赛排序中内部节点存储“胜者”改为存储“败者”,使得在更新元素时,重构路径的逻辑更简单、比较更规整。虽然渐近时间复杂度仍为 O(n log n),但降低了常数因子,并为扩展为高效的多路归并(如外部排序)奠定了基础。理解败者树的关键在于掌握“败者留节点,胜者向上走”的调整规则,以及如何用数组来高效表示这棵完全二叉树。