目录
前言
A.建议
1.学习算法最重要的是理解算法的每一步,而不是记住算法。
2.建议读者学习算法的时候,自己手动一步一步地运行算法。
B.简介
败者树(Loser Tree)是一种用于快速找出一组数据中当前最小或最大元素的数据结构,主要用于外部排序等场合。在败者树中,每个叶节点代表一个输入数据项,而内部节点记录的是其子树中的“败者”,即相对较大的值(如果是求最小元素时)或较小的值(如果是求最大元素时)。败者树通常实现为完全二叉树,并且可以通过数组来紧凑地表示。
一 代码实现
// 假设 `data` 是一个包含 n 个元素的数组,`loser_tree` 是一个大小为 2*n - 1 的数组用于存储败者树
#define MAX_TREE_SIZE (2*n - 1)
typedef struct {
int value; // 节点对应的数值
int index; // 数值在原始数组中的索引
} LoserTreeNode;
void initialize_loser_tree(LoserTreeNode loser_tree[MAX_TREE_SIZE], int data[], int n) {
for (int i = 0; i < n; ++i) { // 初始化叶子节点
loser_tree[n + i].value = data[i];
loser_tree[n + i].index = i;
}
// 从下往上构建败者树
for (int i = n - 1; i > 0; --i) {
update_loser_tree(loser_tree, i);
}
}
void update_loser_tree(LoserTreeNode loser_tree[MAX_TREE_SIZE], int level) {
int left = (level << 1) + 1;
int right = left + 1;
if (left >= MAX_TREE_SIZE) return; // 如果已经是叶子节点则无需比较
// 找出左右孩子中的败者
if (loser_tree[left].value <= loser_tree[right].value) {
loser_tree[level].value = loser_tree[left].value;
loser_tree[level].index = loser_tree[left].index;
} else {
loser_tree[level].value = loser_tree[right].value;
loser_tree[level].index = loser_tree[right].index;
}
// 递归向上更新直到根节点
update_loser_tree(loser_tree, (level - 1) / 2);
}
// 获取当前最小/最大值及其索引
int get_min_max_index(LoserTreeNode *root) {
return root->index;
}
// 在新数据到达时,可以调用类似 `update_loser_tree` 的函数更新相应叶节点及父节点
// 假设已经有一个已初始化的败者树 loser_tree 和当前数据大小 n,还有足够的空间存储新数据
void insert_new_data(LoserTreeNode loser_tree[MAX_TREE_SIZE], int new_value, int new_index) {
// 将新数据先放到数组最后一个位置(作为临时存放区)
loser_tree[2 * n - 1].value = new_value;
loser_tree[2 * n - 1].index = new_index;
// 更新路径从最后一个叶节点到根节点
int current = 2 * n - 1;
while (current > 0) {
int parent = (current - 1) / 2; // 计算父节点的位置
// 比较当前节点与父节点,决定谁是败者
if (loser_tree[current].value <= loser_tree[parent].value) {
// 如果当前节点值较小,则它成为新的胜者,向上更新
loser_tree[parent] = loser_tree[current];
current = parent;
} else {
// 如果当前节点值较大,则父节点维持不变,无需进一步更新
break;
}
}
// 当退出循环时,原数据位置被新数据覆盖,更新数组中的实际数据
data[loser_tree[0].index] = new_value; // 假设 data 是原始数据数组
}
// 在调用 insert_new_data 之前,需要先从输入流获取旧数据并将其在败者树中的索引取出,例如:
int old_index = loser_tree[0].index;
int old_value = data[old_index]; // 存储旧数据以便后续处理或输出
// 然后插入新数据
insert_new_data(loser_tree, new_input_value, next_input_index);
// 注意:以上代码仅适用于求最小元素的情况。如果是求最大元素,则比较逻辑应改为“>”而非“<=”。
二 时空复杂度
A.空间复杂度:
- 败者树通常实现为完全二叉树,并且可以用数组来表示。对于包含
个叶子节点的败者树,它需要的空间大小为
个元素,因为完全二叉树非叶子节点的数量等于叶子节点数量减一。因此,空间复杂度为
。
B.时间复杂度:
- 初始化败者树的时间复杂度是线性的,因为每个节点只需要进行一次比较和更新操作,所以初始化整个败者树的时间复杂度为
。 - 查询当前败者(即最小/大值)的时间复杂度是常量级的,因为在败者树的根节点上直接存储了当前的最小/大值,所以查询时间复杂度为
。 - 插入新元素并重新调整败者树以保持其正确性的时间复杂度也是
。这是因为每次插入后,从新插入的叶节点开始,最多只需要向上经过
层就可以到达根节点,每层都需要一次比较和可能的交换操作。
C.总结
总结起来,在败者树中:
- 空间复杂度:
- 查询最小/大值的时间复杂度:
- 插入新元素并更新败者树的时间复杂度:
三 优缺点
A.优点:
-
高效查找最小或最大元素:
- 败者树可以在常数时间内找到当前已知数据中的最小或最大元素,这对于实时处理大量数据流和外排序等场景非常有用。
-
动态更新:
- 当新的数据项进入时,败者树能够快速地将新数据与现有数据进行比较,并通过自底向上的局部调整迅速反映最新的最小/大值,其更新时间复杂度为
O(log n)
。
- 当新的数据项进入时,败者树能够快速地将新数据与现有数据进行比较,并通过自底向上的局部调整迅速反映最新的最小/大值,其更新时间复杂度为
-
空间效率:
- 由于败者树可以用数组实现并保持完全二叉树结构,因此空间占用相对较小,空间复杂度为
O(n)
,其中n
是原始输入序列的长度。
- 由于败者树可以用数组实现并保持完全二叉树结构,因此空间占用相对较小,空间复杂度为
-
缓存友好:
- 在实际硬件中,因为败者树的父节点与其子节点在内存中的位置通常相邻,这有助于提高缓存命中率,从而提升性能。
-
可扩展性:
- 败者树适用于多路归并排序算法,可以方便地处理多个输入流同时读取、比较和合并的过程,具有良好的可扩展性和并行性。
B.缺点:
-
初始化成本:
- 虽然单次插入的时间复杂度是
O(log n)
,但在构建初始败者树时需要对所有数据项进行比较,一次性建立整个败者树的时间复杂度为O(n log n)
。
- 虽然单次插入的时间复杂度是
-
数据特性依赖:
- 如果输入数据已经是部分有序或者有特定分布,败者树的效果可能不如其他适应这些特性的数据结构,如堆。
-
不适用静态数据集:
- 对于不需要频繁更新的数据集,如果仅为了寻找最大或最小元素而使用败者树,则显得有些“杀鸡用牛刀”,不如直接对静态数据做一次排序或使用简单的数据结构如优先队列。
-
实现复杂性:
- 相比于简单数据结构如栈、队列或堆,败者树的实现和理解要复杂一些,特别是对于包含许多细节的正确性维护以及并发场景下的应用。
四 现实中的应用
-
外部排序:
- 当待排序的数据量太大以至于无法一次性装入内存时,败者树是多路归并排序(例如2-way至k-way归并排序)中非常关键的数据结构。它能有效地管理多个输入文件或缓冲区中的最小值,以实现高效的多路合并过程。通过使用败者树,可以在读取磁盘上的数据块时立即确定当前最小记录,并决定下一次应该从哪个数据源读取,从而减少不必要的磁盘I/O操作。
-
在线比赛系统:
- 在一些在线竞赛平台或游戏中,败者树可以用来高效地维护当前未决比赛中的胜者和败者队列。当新的比赛结果产生时,可以快速更新队列,并找出下一轮比赛的参赛者。
-
实时流处理:
- 在大数据流处理和实时分析中,败者树用于持续监控数据流中的最大/小值,例如监测股票市场的最高价、最低价,或者在传感器网络中寻找异常事件的最大或最小测量值。
-
分布式系统和数据库系统:
- 在分布式计算环境中,败者树可用于跨节点进行数据比较和选择,例如在MapReduce框架中作为中间阶段的合并策略。
-
算法竞赛和教育:
- 在ACM-ICPC等编程竞赛中,败者树常被用于解决与排序、优先级调度等相关的问题,因其简单而强大的功能受到选手们的青睐。