排序算法之:锦标赛排序(Tournament Sort)的“胜者树”优化策略
题目描述
锦标赛排序(Tournament Sort)是一种基于“淘汰赛”(Tournament)思想的排序算法。在经典的实现中,我们通常通过构建一个“败者树”(Loser Tree)来高效地选出最小(或最大)元素,以进行多路归并。然而,锦标赛排序也可以使用“胜者树”(Winner Tree)来直观地实现。
你的任务是:设计并实现一个基于“胜者树”的锦标赛排序算法,对给定的整数数组进行升序排序。请详细解释胜者树的构建过程、从树中提取最小元素并更新树的过程,并分析其时间复杂度与空间复杂度。
要求:
- 算法应能处理任意长度的整数数组。
- 重点阐述胜者树相较于直接选择排序的优势,以及其内部运作机制。
- 解释“重建”或“更新”胜者树的过程,这是该算法效率的关键。
解题过程循序渐进讲解
第一步:理解问题与直观思路
我们先回顾一个简单的选择排序:每次从未排序部分选择最小元素,放到已排序部分的末尾。选择最小元素需要比较 n-1, n-2, ... 次,总比较次数为 O(n²)。
锦标赛排序改进了“选择最小元素”这一过程。它的核心思想是模拟体育比赛的淘汰赛:
- 初赛:所有选手(数组元素)两两比赛(比较),胜者(较小值)晋级。
- 多轮比赛:晋级的选手继续两两比赛,直到决出冠军(全局最小值)。
- 后续比赛:当冠军被选出后,我们让冠军原来的“替补”(即当初被冠军淘汰的选手)重新进行比赛,来决出新的冠军(第二小的值),以此类推。
这个“比赛记录”我们用一棵胜者树来维护。胜者树是一棵完全二叉树,其中:
- 叶子节点:存储待排序的元素。
- 内部节点:存储其两个子节点比赛的“胜者”(即较小的值)。
第二步:胜者树的结构与初始化构建
假设我们有数组 arr = [5, 3, 8, 1, 4, 9, 2]。
-
确定树的大小:
- 叶子节点数需要是 2 的幂,以便构成一棵满二叉树。如果元素个数不是 2 的幂,我们用“无穷大”(
INT_MAX)来填充。这里 7 个元素,我们补充 1 个∞,使叶子节点数为 8。 - 一棵有
n_leaf个叶子节点的满二叉树,总节点数size = 2 * n_leaf。我们通常用数组tree来表示这棵树,tree[1]为根节点(冠军位置),tree[n_leaf]到tree[2*n_leaf - 1]为叶子节点。
- 叶子节点数需要是 2 的幂,以便构成一棵满二叉树。如果元素个数不是 2 的幂,我们用“无穷大”(
-
初始化叶子节点:
- 将原始元素和填充的
∞放入叶子节点位置。tree[7]=5, tree[8]=3, tree[9]=8, tree[10]=1, tree[11]=4, tree[12]=9, tree[13]=2, tree[14]=∞。(索引从1开始方便计算父子关系)
- 将原始元素和填充的
-
自底向上构建胜者树:
- 从最后一个内部节点开始(索引为
n_leaf - 1,即6),向前遍历到根节点(索引1)。 - 对于每个内部节点
i,其左孩子索引为2*i,右孩子索引为2*i+1。比较两个孩子的值,将较小的值(胜者)赋给tree[i]。 - 示例构建过程(比较并设置父节点):
tree[6] = min(tree[12], tree[13]) = min(9, 2) = 2tree[5] = min(tree[10], tree[11]) = min(1, 4) = 1tree[4] = min(tree[8], tree[9]) = min(3, 8) = 3tree[3] = min(tree[6], tree[7]) = min(2, 5) = 2(注意:tree[7]是叶子5,tree[6]是内部节点2)tree[2] = min(tree[4], tree[5]) = min(3, 1) = 1tree[1] = min(tree[2], tree[3]) = min(1, 2) = 1
- 最终,根节点
tree[1]的值为1,就是全局最小值。
- 从最后一个内部节点开始(索引为
此时,我们拥有了一个初始化的胜者树,根节点就是当前的最小元素。
第三步:提取冠军与更新胜者树
这是锦标赛排序的核心。当我们从树根取出冠军(当前最小值)后,需要找到下一个最小值。
-
提取冠军:冠军值就是
tree[1]。我们将其输出到结果数组的第一个位置。 -
更新树以寻找下一个冠军:
- 将冠军所在的叶子节点的值替换为
∞(因为它已经被“淘汰”出后续比赛)。 - 然后,从该叶子节点开始,向上回溯到根节点,重新进行从该叶子到根路径上的每一场比赛。
- 具体步骤:
a. 找到冠军值1对应的叶子节点索引。我们知道它来自tree[10]。
b. 将tree[10]的值修改为∞。
c. 更新其父节点 (parent = 10 / 2 = 5)。重新比较tree[10](∞) 和tree[11](4),胜者是4,所以tree[5] = 4。
d. 更新父节点的父节点 (parent = 5 / 2 = 2)。重新比较tree[4](3) 和tree[5](4),胜者是3,所以tree[2] = 3。
e. 更新到根节点 (parent = 2 / 2 = 1)。重新比较tree[2](3) 和tree[3](2),胜者是2,所以tree[1] = 2。 - 新的冠军(第二小的值)2 又出现在了根节点。
- 将冠军所在的叶子节点的值替换为
-
重复过程:
- 取出新的冠军2,输出。
- 找到值2对应的叶子节点(
tree[13]),将其置为∞。 - 沿着
13 -> 6 -> 3 -> 1的路径重新比赛。 - 重复此“提取-更新”过程,直到所有有效元素(非∞)都被取出。取出的顺序就是升序。
第四步:算法步骤总结与实现要点
-
初始化:
- 计算大于等于
n的最小2的幂leaf_size。构建大小为2 * leaf_size的数组tree。 - 将原数组元素拷贝到
tree的[leaf_size, leaf_size + n - 1]位置,其余叶子填充INT_MAX。 - 自底向上 (
i从leaf_size-1递减到1) 构建胜者树:tree[i] = min(tree[2i], tree[2i+1])`。
- 计算大于等于
-
排序(主循环):
- 创建一个结果数组
sorted。 - 循环
n次(n为原数组长度):
a. 提取冠军:sorted[i] = tree[1]。
b. 找到冠军叶子:从根节点开始向下查找,每次比较左右子节点,与冠军值相同的方向向下,直到叶子节点。或者,在每次更新时,我们可以在内部节点额外记录冠军来自左子树还是右子树(胜者树的一种变体,胜者树通常只存值,这里我们用向下搜索)。
c. 更新叶子:将该叶子节点值改为INT_MAX。
d. 向上调整:从该叶子节点开始,向父节点迭代,更新路径上的每个内部节点:tree[parent] = min(tree[2*parent], tree[2*parent+1])。
- 创建一个结果数组
-
返回结果:
sorted数组即为排序结果。
第五步:复杂度分析
- 时间复杂度:
- 建树:有
leaf_size个叶子,约leaf_size个内部节点。每个内部节点进行一次比较。建树复杂度为 O(leaf_size)。由于leaf_size < 2n,所以是 O(n)。 - 每次提取和更新:提取冠军 O(1)(直接取根)。找到冠军叶子需要从根向下遍历树高 O(log n)。向上更新路径上的节点,也是树高 O(log n)。所以每次操作是 O(log n)。
- 总复杂度:建树 O(n) + n 次提取更新 * O(log n) = O(n log n)。
- 建树:有
- 空间复杂度:
- 胜者树数组
tree的大小为2 * leaf_size,其中leaf_size是大于等于n的最小的2的幂,小于2n。因此,额外空间复杂度为 O(n)。
- 胜者树数组
第六步:与败者树的对比与总结
- 胜者树:内部节点存储的是“胜者”(子节点中的较小值)。在更新时,需要知道冠军来自哪个叶子,这通常需要从根向下搜索(或存储额外信息),然后自底向上重新比较。
- 败者树:内部节点存储的是“败者”(子节点中的较大值),而冠军(全局胜者)单独记录。更新时,只需要将新的进入者(替换冠军的选手)与父节点存储的败者比较,就能决定新的胜者和败者,更新路径更加直接高效。因此,在多路归并的实际应用中,败者树更为常用。
核心收获:锦标赛排序(胜者树版)利用树形结构维护了元素间的比较关系,将“选择最小元”这一操作的代价从 O(n) 降低到了 O(log n),从而将整体时间复杂度从选择排序的 O(n²) 提升到了 O(n log n)。它生动地体现了“用空间换时间”以及“利用数据结构优化重复比较”的思想。