胜者树 败者树 K-路最佳归并树 高效外部排序

    xiaoxiao2021-12-14  35

    外部排序

    外部排序和内部排序还是有非常的的不同的,我们的外部排序主要针对的优化目标也是不同的,这里我先从外部排序的物理基础开始进行讲解

    1.外存:

    外部存储设备,相对于我们的内部存储设备而言具有一些特点 1.优点:永久存储能力,便携性,存储空间大 2.缺点:访问速度相对于内存的访问速度来说极其低下(相差约5~6个数量级) 因此对于外存来说,我们要遵守的基本操作原则就是:尽可能的减少我们的对外存的访问的次数 对于外存的类型来说,我们分成了磁带和磁盘两个方面,在这里我们对磁带就不过多的赘述了,我们主要来看看磁盘 如图所示,我们可以大致的了解到磁盘的主要的操作部件,在这里我们对操作的具体不见不做过多的描述,我们主要来考虑一下我们的这些部件对我们的计算机和磁盘之间的交互的时间效率的影响

    磁盘的存取时间 磁盘访问时间主要由寻道时间,旋转延迟时间和数据传输时间组组成。 寻道时间(Seek time)tseek:是移动磁盘臂,定位到正确磁道所需的时间。 旋转延迟时间tla:是等待被存取的扇区出现在读写头下所需的时间。 传输时间twm:是传输一个字符的时间。 TI/O=tseek + tek + la + twm 我们每次都是将我们的磁盘中的数据按**块**为单位传输到我们的内存的高速缓冲区中(cache)我们每次在内存中对数据进行读取的时候,都要先从cache中开始检查,如果cache中存在数据我们就从cache中读取,如果cache为空,我们在从磁盘中进行读取 我们在外存上的数据基本都可以看作是文件,我们对外部数据进行的操作主要可以分成这么几类 文件上的操作 **检索**:在文件中寻找满足一定条件的记录 **修改**:对记录中某些数据值进行修改。若对关键字进行修改,就相当于删除加插入。 **插入**:向文件中增加一个新记录。 **删除**:从文件中删去一个记录 。 **排序**:对指定好的数据项,按其值的大小把文件中的记录排成序列。常用按关键 简称就是:增删改查排

    外部排序流程

    1.外部跑排序基本上由两个独立的过程来组成,第一个就是产生初始的有序的顺串 2.对有序的顺串进行归并操作 所以通过这样的流程,我们大致可以将我们的外部排序的实践耗费分成大致的这样几个部分 1.内部排序生成初始顺串的时间耗费 2.外存信息读写的时间耗费 3.内部的归并的时间耗费 T = m*Tis + d*Tio + s*u*Tmg 上面的就是我们的外部排序的大致的时间耗费的一个表达式 1.其中m代表我们要形成m个初始的顺串,Tis代表我们对构成每一个初始的顺串选哟的内部排序的时间耗费 2.d代表我们的依次外存的读写的次数,Tio代表我们的外存的依次都写的时间的耗费 3.s代表我们的归并的趟数,u代表我们的趟需要的归并的次数,Tmg代表依次归并的时间耗费 从上面的表达式中,我们可以大致的看出我们的需要优化的要点在哪里,首先,我们的Tio的时间耗费非常的恐怖,所以说我们呢选哟尽可能遵顼上面体积的原则,尽可能的减少我们的外存的读写的次数,也就是说,我们需要降低d的大小 在这里,我们需要知道外部排序的外部读写的次数和我们的归并的趟数有关系,我们要尽可能的减少我们的归并的趟数 h = log(m,k) k - 代表我们采取k路归并 对于减小h的大小,我们需要增大k,减少m(意味着我们构建的初始顺串要尽可能的大) 在这里的话,我们的优化思路就出来了,我们可以采用多路归并的方式从而减少外部读写的次数,降低我们的时间耗费 对于我们的m来说,这和我们的内存的规模大小有关,我们招惹里就不再多余的考虑这个问题 我们这里需要另外一个知识要点就是我们的两种选择树和一种K-Haffman树来进行对我们的依次归并的时间的耗费的优化

    选择树

    对于我们归并的操作,我们需要一些优化的数据结构来满足我们的相应的要求 首先我们先引入我们的归并操作的步骤 1.当归并序列的数目只有两组的时候 我们采用依次扫描的O(n)时间复杂度和O(n)的空间复杂度我们呢就可以实现我们的归并操作 或者我们采用另一种优化后的算法 Lantian的手摇算法讲解 手摇算法O(n)的时间复杂度以及O(1)的空间复杂度就可以完成我们的归并操作 2.但是当我们的归并序列的组数非常的多的时候,我们上面已经讨论过了,归并的路数越多可能我们的外部读写的次数会降低很多,这里我们的多路归并的思路还是非常有必要的,但是如果我们还是采用之前的朴素的方法来进行比较的话,我们会发现,我们的比较次数会变得非常的冗杂,假设我们每次都要进行k录归并的划,依次比较需要O(k)才能得到结果,我们如果需要找到最终的归并序列,需要至少O(k*n)的归并次数,在归并路数非常答的情况下,无疑非常的麻烦且并且效率底下,这里我们的优化思路就出来了 3 .选择树的优化思路 我们会发现,我们之所以朴素的方法效率底下的原因在于,我们依次只能找出一个最有数据信息,但是下一次,我们的最优信息就会选哟我们重新进行重复的操作来得到 我们的选择树构建的思路就是,依次不仅仅将我们的最优的欣喜求解出来,我们还要在依次的操作中将我们的之后的次优的信息都保存下来,下一次,我们就可以是按尽可能的高效读取了 这里的选择书我们有两种情况,下面我们一一道来

    胜者树

    我们对胜者树进行定义: 1.胜者树是一颗完全二叉树 2.胜者树的叶子结点保存我们的一个输入缓冲区(一路归并顺序表) 3.胜者树的非叶子节点保存当前比较的胜者的输入缓冲区的指针 4.胜者树的根节点保存我们的胜者树当前的的一次比较中的冠军(最优值) 现在我们来看一下胜者树的操作: 当我们将我们的胜者树的最优值输入到我们的 输出缓冲区(输出缓冲区从内存中额外开辟出来的一段,我们存储当前的归并的结果,缓冲区满写入磁盘) 之后,我们的根节点便出现了空的情况,我们需要从根节点对应的输入缓冲区中在读入一个数据来充当下一次比较的选手,然后从下到上进行维护,我们的每一次的维护都需要比较兄弟的胜者然后选出新一轮的胜者然后一直优化到我们的根的路径上(从低至上,贯穿整个树) 之后我们不断地进行上述的操作,指导我们的所有的输入缓冲区已经为空为止

    败者树

    我们通过上面的胜者树可以发现,我们的胜者数虽然相对于我们的之前的擦偶哦已经进行了很大程度上的优化,今本上已经达到了我们的O(k*logk)的实践复杂度 但是我们会注意到,我们每一次每个接待你都保存着我们的生者的信息而不是败者的信息 那么这个差别会对我们的实践效率有什么影响呢 下面这段解释非常的重要: 我们会发现,我们的胜者树维护的时候每次都需要去查找我们的根的兄弟节点的位置来进行比较,但是我们的每一次都要多一步查找兄弟的划,无论是对我们的程序的实现过程还是我们的时间效率上来看都还存在改进的余地 这里我们就要引入败者树 败者树的定义: 1.败者树是一颗完全二叉树 2.败者树的叶子结点保存的是我我们的输入缓冲区 3.败者树的非叶子结点保存我们的当前的比较中败者的对应的输入缓冲区的指针 4.败者树根保存我们的当前比较的亚军,根上面还有一个节点保存我们的冠军 如图所示,那么对于我们的调整树的过程中,我们只需要和当前的跟对应的败者的输入缓冲区的之比较就ok,减少了我们依次比较次数,那么在树庞大的时候,我们扽优化效果是非常的明显的

    败者树 VS 堆

    我们在进行我们的败者树选取的时候,读者那面都会遇到我的这种问题,如果我们的败者树进行归并排序的话,我们的堆排序的思路和败者树比较的话谁优谁劣? 这里的话,我们还真不好描述这个问题的最终结果,但是我想,败者树存在是有它的实际意义的 1.堆排序 首先,一旦存在了堆排序的划,我们就无需构建输出缓冲区,内存这个题就可以充当输出缓冲区,我们只要将我们n数据量的数据进行归并排序就好,实践复杂度是O(n*logn) 优点:     无输出缓冲区,充分利用内存资源     时间复杂度优秀O(n*logn)     相对于败者树来说,我们往往不需要从根维护到底,在维护的路径中有可能直接就中断 缺点:     树庞大,我们的logn值相对于我们的k路数来说很巨大,树的深度较大     建堆时间耗费很高,我们的缓冲区内的数据已经实现了按块基本有序     我们的堆维护的时候,每一层至少需要比较两次,败者树只需要一次就可以 2.败者树 优点:     相对于堆来说,我们的树的规模很小,似的我们的时间复杂度在在实践中可能会平均状态下更加优秀     每次维护我们每一层只需要比较一次 缺点:     我们的败者树的维护过程中必须要从底一直维护到根,这个路径不能中断,我们的堆实际中调整的次数可能会更小 最后究竟谁胜还真不好比较,我会再次问老师以求解答

    K-路最佳归并树

    上面的选择树中的败者树已经给我们的依次归并的实践效率给予了很好的优化,现在我们需要从另一个角度来考虑减少我们的外部读写的次数了 首先,我们需要了解到,我们的每个输入缓冲区的数据量都不一定是一样的,这意味着什么 这意味着我们的每一次的每个块的外部读写的次数是不一样,数据量大的外部读写次数相对高,数据量小的外部读写次数相对底 那么我们想到了什么? 没错,就是我们的最有二叉树 - Haffman树  Lantian的Haffman讲解 我们想到的方向很对,K-路最佳归并树实质上就是K-Haffman数,我们的优化的ing一需求是尽量的让我们的数据量大的块读写次数少,数据量小的读写次数多,利用我在Haffman中的反证贪心法,这样构成的K-路最佳归并树无疑可以让我们的外部读写次数降到最低值 K-路最佳归并树的思路: 1.挑选出K个权值(数据量)最小的缓冲区 2.缓冲区利用败者树进行一次归并操作,生成一个新的大的缓冲区,加入到我们的选择序列 3.重复上述的过程指导只剩下一个输入缓冲区,我们的归并操作结束,生成了有序的外部文件 上面的操作1我们为了提高时间效率通常使用堆来进行优化

    核心伪代码:

    1.堆 heap - array save the number of the data heapnum - the size of the heap siftdown(i): t while i*2<=heapnum: if heap[i]>heap[i*2]: t=i*2 else t=i if i*2+1<=heapnum and heap[i*2+1]<heap[t]: t=i*2+1 if i!=t: swap(i,t) i=t else break siftup(i): while i!=1: if heap[i]<heap[i/2]: swap(i,i/2) i=i/2 else break creat_heap(heapnum): for i=heapnum/2 to 1: siftdown(i) 2.K-路最佳归并树 m - the size of the K-Haffman data[] - the size is m,the input cache,waited to merge K_Merge(data,m): creat_heap(m) while heapnum!=1: help=[] //保存k个选出的缓冲区序列 for i=1 to k: help.append(pop()) //弹出堆顶并进行维护最小堆性质 Loser_Tree(help,k) 3.败者树: data - the size is the k,the array wait to merge k - the size ls - 非叶子节点,保存我们的输入缓冲区指针 MIN - 最小值,在我们建树的时候用来辅助维护 MAX - 我们维护的时候,为了防止出现一个缓冲区为空的情况,添加的哨兵 Loser_Tree(data,k): new_input //新的输入缓冲区,需要返回的结果 creat_Loser_Tree(data,k) while data[ls[0]].top()!=MAX: new_input.append(data[ls[0]].top) data[ls[0]].pop() Adjust(ls[0]) //调整 creat_Loser_Tree(data,k): data[0].append(MIN) //哨兵,辅助构建败者树 clear ls for i=1 to k: data[i].append(MAX) //哨兵,辅助维护败者树 for i=k down to 1: Adjust(i) Adjust(int root): father = root /2 winner = root t = root while t!=0: //0是要维护到败者树的最顶端 if win: swap(winner,loser) ls[0]=winner

    C++ Code:

    #include"iostream" #include"cstdio" #include"cstring" #include"cstdlib" #include"algorithm" #define N 1005 #define INF 0x3fffffff #define MIN -INF /* 利用OOP思路 构建cache高速缓存类 在不考虑内存容量的前提下 模拟最佳归并树,败者树 cache内利用栈模拟 */ using namespace std; class Empty_Error{}; class cache //模拟高速缓存 { public: cache() { head=1; memset(stack,0,sizeof(stack)); tail=1; } inline int top() { try { if(empty()) throw Empty_Error(); else return stack[head]; } catch(Empty_Error x) { cout<<"try to get the element from an empty cache!"<<endl; } } inline void pop() { try { if(empty()) throw Empty_Error(); else head++; } catch(Empty_Error x) { cout<<"try to pop the element from an empty cache!"<<endl; } } inline int size() { return tail-head; } inline bool empty() { if(head == tail) return true; else return false; } inline bool full() { if(tail >= N) return true; else false; } inline void append(int x) //添加数据项接口 { stack[tail++]=x; } private: int head; int tail; int stack[N]; }; cache test[1005]; int heapnum; int k; int m; void jiaohuan(int i,int t) { cache p=test[i]; test[i]=test[t]; test[t]=p; } void sift_down(int i) { int t; while(i*2<=heapnum) { if(test[i*2].size() < test[i].size()) t=i*2; else t=i; if(i*2+1 <= heapnum && test[i*2+1].size() < test[t].size()) t=i*2+1; if(i != t) { jiaohuan(i,t); i=t; } else break; } } void sift_up(int i) { int t; while(i!=1) { if(test[i].size() < test[i/2].size()) { int k=i/2; jiaohuan(i,k); i=i/2; } else break; } } void creat_heap(int num) { for(int i=(num>>1);i>=1;i--) { sift_down(i); } } void Adjust_tree(int start,int ls[],cache* queue) { int winner=start; int t=(start+k-1)/2; while(t!=0) { int a=queue[winner].top(); int b=queue[ls[t]].top(); if(a > b) { int loser=winner; winner=ls[t]; ls[t]=loser; } t=t/2; } ls[0]=winner; } void creat_Loser_tree(int ls[],cache* queue) { for(int i=1;i<N;i++) ls[i]=0; for(int i=1;i<=k;i++) queue[i].append(INF); //加入最大项辅助维护败者树 queue[0].append(MIN); //插入min项,辅助构造败者树,这里的模板性质被破坏了,再深入考虑一下 for(int i=k;i>=1;i--) { Adjust_tree(i,ls,queue); } } cache K_merge(cache* queue,int k) { cache ans; int ls[N]; //实际上只需要2*k+1的辅助空间 creat_Loser_tree(ls,queue); while(queue[ls[0]].top()!=INF) { ans.append(queue[ls[0]].top()); queue[ls[0]].pop(); Adjust_tree(ls[0],ls,queue); } return ans; } int main() //测试入口 { printf("决定K-路归并:"); scanf("%d",&k); printf("决定块数:"); scanf("%d",&m); int b=k-(m-1)%(k-1)-1; int NUM = b+m; heapnum = NUM; for(int i=1;i<=m;i++) { int x; int y; printf("初始化高速缓存%d\n",i); printf("缓存容量:\n"); scanf("%d",&x); for(int j=1;j<=x;j++) { scanf("%d",&y); test[i].append(y); } } creat_heap(NUM); cache Loser_tree[k+5]; for(int i=1;i<=NUM;i *= k) { for(int j=1;j<=k;j++) //获取归并序列 { Loser_tree[j] = test[1]; test[1]=test[heapnum--]; sift_down(1); } cache ans= K_merge(Loser_tree,k); test[heapnum+1]=ans; heapnum++; sift_up(heapnum); } //测试输出 while(!test[1].empty()) { cout<<test[1].top()<<" "; test[1].pop(); } cout<<endl; return 0; }

    遗留问题以及鸣谢:

    1.感谢屈老师的课件 2. https://my.oschina.net/liudiwu/blog/387280 3. https://segmentfault.com/q/1010000000315760 4.《数据结构》 - 严老师 1.C++代码的模板类型 - 存在毛病 2.败者树和堆的效率比较
    转载请注明原文地址: https://ju.6miu.com/read-963988.html

    最新回复(0)