前言
此文仅记录学习过程,详细讲解了败者树的构建过程以及构建完毕之后的调整。
背景:在leetcode刷题时,遇到23.合并K个升序链表。第一时间就想到了外部排序用到的败者树,但是其数据结构让我一时半会难以想到如何构造,看来如果只是看一遍算法的话还是无法深刻理解其本质。(虽然可以用优先队列的小根堆可以做题目来,但是学习不是固地自封)
反省:学习途中遇到的算法应该自己实现一遍,知道其中核心细节以及其主要用途,转化为自己的知识。
败者树的运作过程
败者树其实是树形选择排序的一种变形,其是一个完全二叉树。顾名思义败者树中的非叶子节点保留的是败者,ls[0]记录的是最终胜者,也就是没有被保留在败者节点的人。
算法:就是从叶子节点出发,假设一开始的叶子节点是胜利者,与父节点比较
下图就是一个5路-归并排序败者树数据结构例子:
bi是一个抽象数据结构,其数据有序排列。第一个数据被读取后将会使得第二个数据为第一个数据,可以用栈实现,也可以用数组加指针。
栈:pop第一个数据,第二数据就可以变成第一个
顺序表加指针:第一个数据被读取后,指针向后移动。数组与链表都可以这样实现。
int ls[5]; //ls[t]记录的数据如果是i,bi的第一个元素曾经在这个节点比赛被打败。
不纠结如何实现,看一下算法核心应该是运作的。
显示每个bi(i=0~4)的第一个元素参与败者树。顺序也可以逆序也可以,我们选择逆序从b[4]开始参与败者树的调整。
败者树构建运作过程(规定小元素获胜):
一开始还未比赛的时候,b4查看ls[4],发现没有对手(可以设置特殊值-1),那就必须留在原地,等待对手,不然败者树就无法构成,此时ls[4]=4。
b3参与败者树,查看ls[4]=4,知道对手是b4的第一个元素,那么就开始比较。b3的第一个元素6小于b4的第一个元素12,所以ls[4]记录4。b3继续向上比较,查看ls[2],发现没有对手,那么就留在原地,ls[2]=3。
b2参与败者树,查看ls[3],发现没有对手,那就必须留在原地,等待对手,此时ls[3]=2。
b1参与败者树,查看ls[3],发现ls[3]=2,与b2的第一个元素进行比较,b1的第一个元素9小于b2的第一个元素20,那么ls[3]=2。b1继续向上比较,查看ls[1],发现没有对手,那么就留在原地,ls[1]=1。
b0参与败者树,查看ls[2],发现ls[2]=3,与b3第一个元素进行比较,b3的第一个元素6小于b0的第一个元素10,所以ls[2]=0,让之前ls[2]记录的3向上继续比较,那么b3查看ls[1],发现ls[1]=1,与b1第一个元素进行比较,b3的6<b1的9,所以ls[1]=1,ls[0]=3。
显然胜者为b3的第一个元素6。且败者树的每个节点(ls[i](i=1~4)都有败者的编号记录在上面。
比赛至多在ls[1]上比赛,然后决出最终胜者。
构建完成后的败者树调整
取出胜者,ls[0]编号的b中的第一个元素被取出,那么就将b中下一个元素变成第一个元素参与败者树的调整,与上方调整一样,不过要分类讨论。
1.ls[0]编号的b有下一个元素
那么直接就让下一个元素变成第一个元素参与败者树。
2.ls[0]编号的b没有下一个元素
那么就添加一个元素使得这个元素为无穷大inf,因为小的胜利所以如果b中没有元素了,那么其父节点的比赛就直接让等待的人向上比赛。
比如上图中ls[0]就是3编号的b,b3如果只有一个元素6,下一个元素没有,那么添加一个元素无穷大,与父节点比较的时候,b3肯定会输,所以ls[4]=3,而原先的ls[4]=4编号的b向上比赛。所以,在b4来到ls[4]这个节点比赛的时候,肯定会赢而向上比赛。
败者树代码
curPoint定位到第一个元素。
lists里面存放的是一条条有序的链表。
#include<iostream>
#include<string>
#include<vector>
using namespace std;
struct ListNode{
int val;
ListNode* next;
ListNode() :val(0), next(nullptr) {};
ListNode(int x) :val(x), next(nullptr) {};
ListNode(int x, ListNode* p) :val(x), next(p) {};
};
//分析败者树数据结构
//输入的是链表数组,每个链表相当于归并块
//败者树上记录数组下标以表示败者的归并块在链表数组中的位置,只需要记录下标即可。
//当胜者被读取后,需要将胜者节点从链表中弹出,定位到下一个节点,
//由于链表只能前向迭代,所以将记录指针定位到下一个节点即可。
const int k = 5;//5路-归并排序, 多少有序的线性表多少路归并 ,可自定义或者根据lists中有多少归并块设置。
vector<int> my_ls(k);//非叶子节点除根节点,记录败者归并块位置,根节点记录胜者。
vector<ListNode*> curPoint(k);//记录与数组下标相同的归并块(链表)的当前元素,当胜者节点被读取时,需要将curPoint数组对应指针向后迭代。
//存储归并块(链表)的数组。
//vector<ListNode*> lists(k,new ListNode());//错误,初始化调用fill()引用了new ListNode()指针,在赋值给数组中的所有指针,指针数组都定位到同一个内存地址
vector<ListNode*> lists;
ListNode* K_Merge(vector<int>& ls, vector<ListNode*>& curPoint);
void init(const vector<ListNode*>& lists);
void adjust(vector<int>& ls, vector<ListNode*> curPoint, int i);
void init(const vector<ListNode*> &lists)
{
curPoint.resize(k, nullptr);
for (int i = 0; i < k; i++)
{
my_ls[i] = k;//没有k编号的lists,这里表示没有人在比赛
}
for (int i = 0; i < k; i++)
{
curPoint[i] = lists[i];
}
for (int i = 0; i < k; i++)
{
adjust(my_ls, curPoint, i);
}
}
void adjust(vector<int>& ls, vector<ListNode*> curPoint, int i)
{
int t = (i + k) / 2;
while (t > 0)
{
if (i!=k && (ls[t]==k || curPoint[i]->val > curPoint[ls[t]]->val))
{
int temp = i;
i=ls[t];
ls[t] = temp;
}
t /= 2;
}
ls[0] = i;
}
ListNode* K_Merge(vector<int>& ls, vector<ListNode*>& curPoint)
{
ListNode head, * tail = &head;
ListNode* maxNode = new ListNode(INT_MAX);
while (curPoint[ls[0]]->val != INT_MAX)
{
tail->next = curPoint[ls[0]];
tail = tail->next;
curPoint[ls[0]] = curPoint[ls[0]]->next;
if (curPoint[ls[0]] == nullptr)
{
curPoint[ls[0]] = maxNode;
}
adjust(ls, curPoint, ls[0]);
}
return head.next;
}
int main()
{
for (int i = 0; i < k; i++)
{
lists.push_back(new ListNode());
}
for (int i = 0; i < k; i++)
{
ListNode* tmp = lists[i];
for (int j = 0; j < k; j++)
{
tmp->val = i + j;
if (j == k - 1) break;
ListNode* p = new ListNode();
tmp->next = p;
tmp = tmp->next;
}
}
init(lists);
ListNode* ans = K_Merge(my_ls, curPoint);
ListNode* tmp = ans;
while (tmp != nullptr)
{
cout << tmp->val << " ";
tmp = tmp->next;
}
system("pause");
return 0;
}
//
//0 1 1 2 2 2 3 3 3 3 4 4 4 4 4 5 5 5 5 6 6 6 7 7 8
败者树用途
主要用于大型文件在小内存的情况下如何进行排序。
我们一般的排序算法都是在小数据下进行的排序,将数据放在内存上就基本足够了,但是数据如果大于内存的话,那么就需要将数据一部分一部分放到内存进行排序。
例如上文有5个b,那么等量的取得每个b中前部分数据放入内存,然后进行败者树的建立与调整。
当存放bi的前部分数据全部参与败者树的时候,就会回到磁盘看是否bi部分是否还有数据,如果有就读入到内存中存放bi的位置。例如刚开始,内存分配了一个b的容量与一个物理块(称wx),那么每个bi读入1/5的b的容量(称1/5bi的数据放在xi的内存区),然后进行败者树的构建与调整,胜者放到空的物理块中。如果某个xi中的数据为空,那么就回去磁盘上找bi看还有没有数据,有就继续写入min(1/5bi容量,bi真实容量)。
当数据排序好的数据足够一个物理块的时候,将其写回到磁盘。
微观上内存内在进行败者树构建与调整,其实宏观上内存与磁盘也在进行一种败者树的构建与调整。