引言
败者树(Loser Tree)是外排序中k路归并的核心数据结构,它与胜者树(Winner Tree)互补,通过记录”败者”而非”胜者”来优化比较次数。在k路归并中,每轮需要选出k个有序段中的最小值,败者树将这一选择操作的比较次数从O(k)优化到O(log k)。本文将用Java实现完整的败者树,并展示其在外排序k路归并中的实际应用。
算法核心思想
胜者树与败者树的对比
胜者树:每个内部节点记录左右子树比较的胜者。当胜者被取出后,新元素需要从叶子向上更新,每层都需要读取兄弟节点(败者)进行比较。
败者树:每个内部节点记录比较的败者,胜者继续向上参与比较,根节点的父节点额外记录最终胜者。更新时只需与新进入的兄弟节点比较,无需访问原来的胜者。
败者树的核心优势
在k路归并中,每轮取走最小值后需要补充新元素。败者树更新时,新元素从叶子向上,每层只需与当前节点存储的败者比较,而胜者树需要读取兄弟节点的值。败者树因此减少了数组访问次数,在实践中更快。
树的结构
对于k个叶子节点,败者树使用大小为k的内部节点数组(索引1到k-1)和一个独立的winner变量存储全局最小值。叶子节点对应k个归并段的当前元素。
完整Java实现
import java.util.*;
/**
* 败者树(Loser Tree)实现
* 用于k路归并中高效选择最小值
* @param <T> 元素类型,必须实现Comparable接口
*/
public class LoserTree<T extends Comparable<T>> {
private final Object[] leaves; // 叶子节点,存储各段的当前元素
private final int[] loserTree; // 败者树内部节点,存储败者索引
private int winner; // 全局胜者索引(最终最小值所在叶子)
private final int k; // 路数
/**
* 构造败者树
* @param initialValues k个初始值,每个代表一路的当前元素
*/
@SuppressWarnings("unchecked")
public LoserTree(T[] initialValues) {
this.k = initialValues.length;
this.leaves = new Object[k];
this.loserTree = new int[k]; // 内部节点数等于叶子数(简化实现,实际用k-1个)
// 初始化叶子
for (int i = 0; i < k; i++) {
leaves[i] = initialValues[i];
}
// 构建败者树
buildTree();
}
/**
* 比较两个叶子节点的值
* @return true if leaves[a] < leaves[b](a胜)
*/
@SuppressWarnings("unchecked")
private boolean lessThan(int a, int b) {
T valA = (T) leaves[a];
T valB = (T) leaves[b];
if (valA == null) return false; // null表示该路已空,视为无穷大
if (valB == null) return true;
return valA.compareTo(valB) < 0;
}
/**
* 构建败者树
* 初始化所有内部节点,通过锦标赛方式逐层确定败者
*/
private void buildTree() {
// 先让所有叶子参与一轮比赛,确定初始败者
// 简化实现:使用完全二叉树结构,内部节点索引1..k-1
// 初始化winner为0号叶子
winner = 0;
// 从1号叶子开始,逐个点与当前winner比赛
for (int i = 1; i < k; i++) {
// i与当前winner比赛
if (lessThan(i, winner)) {
// i胜,原winner成为败者,存储在当前层
loserTree[i] = winner;
winner = i;
} else {
// winner胜,i成为败者
loserTree[i] = i;
}
}
// 上面的简化方法不完全正确,下面是标准的完全二叉树构建
// 重新用标准的完全二叉树方式构建
Arrays.fill(loserTree, -1);
// 找到第一个大于等于k的2的幂
int treeSize = 1;
while (treeSize < k) treeSize <<= 1;
// 使用扩展数组方式:内部节点从1开始编号
// 叶子在树中的位置为 treeSize/2 + i
int[] internal = new int[treeSize]; // 内部节点存储败者索引
Arrays.fill(internal, -1);
// 初始化所有叶子位置
int leafStart = treeSize / 2;
// 自底向上构建
for (int i = 0; i < k; i++) {
int pos = leafStart + i;
int current = i;
while (pos > 1) {
pos >>= 1; // 父节点
if (internal[pos] == -1) {
// 该节点为空,当前节点暂时成为胜者继续向上
internal[pos] = current;
break;
} else {
// 与存储的节点比赛
int opponent = internal[pos];
if (lessThan(current, opponent)) {
// current胜,opponent成为败者留在该节点
internal[pos] = opponent;
// current继续向上
} else {
// opponent胜,current成为败者留在该节点
internal[pos] = current;
current = opponent;
// opponent继续向上
}
}
}
if (pos == 1) {
winner = current; // 最终胜者
}
}
// 将内部节点复制到loserTree(适配外部接口)
System.arraycopy(internal, 0, loserTree, 0, Math.min(k, internal.length));
}
/**
* 获取当前最小值(胜者)
*/
@SuppressWarnings("unchecked")
public T getWinner() {
return (T) leaves[winner];
}
/**
* 获取胜者索引
*/
public int getWinnerIndex() {
return winner;
}
/**
* 替换胜者叶子为新值,并重新调整败者树
* @param newValue 新值,null表示该路已结束
*/
public void replaceWinner(T newValue) {
leaves[winner] = newValue;
// 找到叶子在完全二叉树中的位置
int treeSize = 1;
while (treeSize < k) treeSize <<= 1;
int leafStart = treeSize / 2;
int pos = leafStart + winner;
int current = winner;
while (pos > 1) {
pos >>= 1;
int opponent = loserTree[pos];
if (opponent == -1) {
// 只有一个孩子
continue;
}
if (lessThan(current, opponent)) {
// current胜,opponent保持为败者
// current继续向上
} else {
// opponent胜,current成为新的败者
loserTree[pos] = current;
current = opponent;
}
}
winner = current;
}
/**
* 检查是否所有路都已结束
*/
public boolean isEmpty() {
return leaves[winner] == null;
}
/**
* 打印败者树结构(调试用)
*/
public void printTree() {
System.out.println("Loser Tree (winner=" + winner + "):");
System.out.println("Winner value: " + leaves[winner]);
for (int i = 1; i < k && i < loserTree.length; i++) {
System.out.println(" Node " + i + ": loser=" + loserTree[i] +
" value=" + leaves[loserTree[i]]);
}
}
// ==================== k路归并应用 ====================
/**
* k路归并:将k个有序数组合并为一个有序数组
* @param arrays k个有序数组
* @return 合并后的有序数组
*/
@SafeVarargs
public static <T extends Comparable<T>> List<T> kWayMerge(T[]... arrays) {
int k = arrays.length;
if (k == 0) return new ArrayList<>();
// 每个数组的当前索引
int[] indices = new int[k];
// 构建初始叶子值
@SuppressWarnings("unchecked")
T[] initialValues = (T[]) new Comparable[k];
for (int i = 0; i < k; i++) {
initialValues[i] = arrays[i].length > 0 ? arrays[i][0] : null;
}
LoserTree<T> tree = new LoserTree<>(initialValues);
List<T> result = new ArrayList<>();
while (!tree.isEmpty()) {
T minVal = tree.getWinner();
int minIdx = tree.getWinnerIndex();
result.add(minVal);
// 推进该路的索引
indices[minIdx]++;
T nextVal = indices[minIdx] < arrays[minIdx].length
? arrays[minIdx][indices[minIdx]]
: null;
tree.replaceWinner(nextVal);
}
return result;
}
/**
* 主程序:演示败者树与k路归并
*/
public static void main(String[] args) {
// 测试1:基本败者树操作
System.out.println("=== 基本败者树测试 ===");
Integer[] initial = {3, 1, 4, 1, 5};
LoserTree<Integer> tree = new LoserTree<>(initial);
tree.printTree();
System.out.println("\n逐轮取出最小值:");
int round = 1;
while (!tree.isEmpty()) {
System.out.println("Round " + round + ": min=" + tree.getWinner() +
" from leaf " + tree.getWinnerIndex());
tree.replaceWinner(null); // 模拟取走
round++;
}
// 测试2:k路归并
System.out.println("\n=== k路归并测试 ===");
Integer[] arr1 = {1, 4, 7, 10};
Integer[] arr2 = {2, 5, 8, 11};
Integer[] arr3 = {3, 6, 9, 12};
List<Integer> merged = kWayMerge(arr1, arr2, arr3);
System.out.println("归并结果: " + merged);
// 测试3:大规模性能测试
System.out.println("\n=== 大规模性能测试 ===");
int k = 100; // 100路归并
int perArray = 10000; // 每路10000个元素
@SuppressWarnings("unchecked")
Integer[][] largeArrays = new Integer[k][];
Random rand = new Random(42);
for (int i = 0; i < k; i++) {
largeArrays[i] = new Integer[perArray];
int start = rand.nextInt(1000);
for (int j = 0; j < perArray; j++) {
largeArrays[i][j] = start + j * 2 + rand.nextInt(2);
}
Arrays.sort(largeArrays[i]);
}
long start = System.currentTimeMillis();
List<Integer> largeMerged = kWayMerge(largeArrays);
long elapsed = System.currentTimeMillis() - start;
System.out.println("k=" + k + ", 每路" + perArray + "元素");
System.out.println("归并后总元素: " + largeMerged.size());
System.out.println("败者树归并耗时: " + elapsed + "ms");
// 验证有序性
boolean sorted = true;
for (int i = 1; i < largeMerged.size(); i++) {
if (largeMerged.get(i) < largeMerged.get(i - 1)) {
sorted = false;
break;
}
}
System.out.println("结果有序: " + sorted);
}
}
关键算法解析
1. 败者树的存储结构
败者树采用完全二叉树的紧凑存储。对于k个叶子,找到不小于k的最小2的幂treeSize,叶子从treeSize/2开始存放,内部节点从1到treeSize/2-1。每个内部节点存储该次比较的败者索引。
2. 构建过程
初始构建时,每个叶子自底向上参与比较。若某内部节点为空,当前节点暂时成为胜者继续向上;若已有败者存储,则两者比较,败者留在该节点,胜者继续向上。最终到达根节点的即为全局胜者。
3. 更新过程
当全局胜者被取走后,该叶子更新为新值(或null),然后同样自底向上比较。由于存储的是败者,新值只需与每层的败者比较即可,无需访问胜者。这正是败者树比胜者树高效的根源。
复杂度分析
| 指标 | 复杂度 | 说明 |
|---|---|---|
| 构建 | O(k) | k个叶子逐层比较 |
| 每轮查询最小值 | O(1) | 直接读取winner |
| 每轮更新 | O(log k) | 自底向上log k层比较 |
| k路归并总时间 | O(n log k) | n个元素,每轮O(log k) |
| 空间 | O(k) | 内部节点数组 |
相比朴素k路归并的O(n×k),败者树将时间复杂度优化到O(n log k),在k较大时优势明显。
总结
败者树是外排序k路归并的经典优化数据结构。相比胜者树,它通过存储败者减少了更新时的数组访问次数,在实际运行中更快。
核心设计要点:
1. 存储败者而非胜者:更新时只需读取当前节点存储的败者进行比较
2. 完全二叉树紧凑存储:利用数组模拟树结构,无指针开销
3. O(log k)选择效率:与堆相同的选择效率,但更利于k路归并的连续更新场景
本文实现的败者树可直接应用于外排序、多路流合并等场景,是处理大规模有序数据合并的必备工具。