1. 程式人生 > 其它 >外排序演算法介紹

外排序演算法介紹

外排序演算法介紹

1.背景

        在大資料場景,待排序的資料檔案可能很大,計算機記憶體不能容納巨大的資料檔案,這時候對大資料檔案就不能單純的使用快速排序、堆排序等內部排序演算法了,得采取一些其他的排序策略。         針對大資料場景的排序,目前最普遍的做法是對待排序的大檔案進行分割,讓大檔案變成一個個很小的、記憶體足夠容納的資料片段檔案,然後對這些分片檔案分別排序,最後通過歸併等演算法將這些片段檔案合併,最終輸出有序的結果。其中,將多個有序片段合併的排序演算法,被稱為外部排序演算法。

2.外部排序演算法基本思想

外部排序常採用的排序方法是歸併排序,這種歸併方法由兩個不同的階段組成:

        (1)採用適當的內部排序方法對輸入檔案的每個片段進行排序,將排好序的片段(成為歸併段)寫到外部儲存器中(通常由一個可用的磁碟作為臨時緩衝區),這樣臨時緩衝區中的每個歸併段的內容是有序的。

(2)利用歸併演算法,歸併第一階段生成的歸併段,直到只剩下一個歸併段為止。

2.1 兩路歸併

        對於已採用內排序演算法完成排序的有序片段,外排序歸併最簡單使用的是2路歸併。每次讀入2路有序片段的前m個元素進行歸併,若輸出緩衝區已滿,則將已歸併好的元素寫入檔案;若其中一路m個元素歸併完成,讀入該路剩下的前m個元素。重複交替執行,直到所有元素都歸併完成為止,則當前檔案的元素為有序的。

2.2 k路歸併

        由於2路歸併需要所有元素反覆進行比較,比較的次數過多,導致歸併的效率很低,因此有人提出了改進演算法,採用多路歸併來提高效率,即k路歸併。         LeetCode的23. Merge k Sorted Lists這道題本質上就是一個多路歸併的問題。k路歸併的演算法主要有三種:堆排序、勝者樹、敗者樹。其中敗者樹更適用於大資料場景下的外排序。

2.2.1 敗者樹原理

        敗者樹,顧名思義,即記錄勝敗者的樹形結構。實際上,這種資料結構的最初靈感來源就是來自於比賽中記錄勝敗得分的。只不過在敗者樹中,父節點記錄的是敗者節點,而勝者節點繼續上浮比較。

        典型的4-路敗者樹結構如下圖所示:

(1)多路平衡歸併演算法具體實現

a.初始化操作

        b[0..k],其中0~k-1為k個葉節點,存放k路歸併片段的首地址,k為虛擬記錄,該關鍵字取可能的最小值minkey

        ls[0..k-1],其中1~k-1存放不含葉節點的敗者樹的敗者編號,0存放最後勝出的編號

b.處理步驟

        a)建敗者樹ls[0..k-1]

        b)重複下列操作直至k路歸併完畢:

        將b[ls[0]]寫至輸出歸併段;

        補充記錄(某歸併段變空時,補充資料),調整敗者樹。

2.2.2 堆排序、勝者樹及敗者樹的聯絡

        k路歸併一般採用堆進行排序,利用完全二叉樹的性質,可以很快更新,保持堆的性質。然而堆操作次數還是不夠精簡,因此有人進一步提出了勝者樹和敗者樹的資料結構來進行多路歸併。         勝者樹與敗者樹的葉子節點記錄的都是資料,勝者樹中間節點記錄的是勝者對應的標號,而敗者樹中間節點記錄的是敗者對應的標號。同時敗者樹需要一個額外節點來記錄最終勝者。         由於敗者樹的更新只需將子節點與父節點比較,而勝者樹的更新需要與父節點和子節點比較,因此在實際應用中採用敗者樹更好。 

2.2.3 三種演算法的相同點 

        這三種演算法的空間和時間複雜度都是一樣的,調整一次的時間複雜度都是O(logN)的,都利用了二叉樹的性質。

2.2.4 三種演算法的不同點

         最早只有用堆來完成k路歸併,但是堆每次取出最小值之後,把最後一個數放到堆頂,調整堆的時候,每次都要選出父節點的兩個孩子節點的最小值,然後再用孩子節點的最小值和父節點進行比較,所以每調整一層需要比較兩次。
        為了簡化比較過程,就有了勝者樹,如圖:

         在勝者樹中,每層比較只用跟自己的兄弟節點進行比較行,所以用勝者樹可以比堆少一半的比較次數。
         但不足的是,勝者樹在節點上升的時候首先需要獲得父節點,然後再獲得兄弟節點後,再比較。                 為了進一步減少比較次數,於是就有了敗者樹,如下圖:

        在使用敗者樹的時候,每個新元素上升時,只需要獲得父節點並比較即可。

        所以總的來說,敗者樹減少了訪存的時間。現在程式的主要瓶頸在於訪存,計算倒可以忽略不計了。 

3. 敗者樹演算法實現

        例:合併k路有序連結串列

        測試樣例:

1 輸入:[
2   1->4->5,
3   1->3->4,
4   2->6
5 ]
6 輸出:1->1->2->3->4->4->5->6

        程式碼實現:

  1 import sys
  2 
  3 # Definition for singly-linked list.
  4 class ListNode:
  5     def __init__(self, x):
  6         self.val = x
  7         self.next = None
  8 
  9 class Solution:
 10     def mergeTwoLists(self, l1, l2):
 11         """
 12         :type l1: ListNode
 13         :type l2: ListNode
 14         :rtype: ListNode
 15         """
 16 
 17         if l1 == None:
 18             return l2
 19         if l2 == None:
 20             return l1
 21 
 22         dummy = ListNode(0)
 23         head = dummy
 24         while l1 != None and l2 != None:
 25             if l1.val <= l2.val:
 26                 head.next = l1
 27                 l1 = l1.next
 28                 head = head.next
 29             else:
 30                 head.next = l2
 31                 l2 = l2.next
 32                 head = head.next
 33 
 34         while l1 != None:
 35             head.next = l1
 36             l1 = l1.next
 37             head = head.next
 38         while l2 != None:
 39             head.next = l2
 40             l2 = l2.next
 41             head = head.next
 42 
 43         return dummy.next
 44 
 45     def adjust(self, s, listsLen, lists, loserTree):
 46         #構成完全二叉樹,按完全二叉樹索引
 47         t = (s + listsLen) // 2
 48         # 比較當前節點和父節點的大小,若大於,則更新,並將勝者儲存在索引0位置
 49         while t > 0:
 50             if lists[s].val > lists[loserTree[t]].val:
 51                 s, loserTree[t] = loserTree[t], s
 52             t = t // 2
 53         loserTree[0] = s
 54 
 55     def mergeKLists(self, lists):
 56         """
 57         :type lists: List[ListNode]
 58         :rtype: ListNode
 59         """
 60         # 去掉list中的None
 61         while None in lists:
 62             lists.remove(None)
 63         # 若當前序列小於等於1,則返回結果
 64         listsLen = len(lists)
 65         if listsLen < 1:
 66             return None
 67 
 68         if listsLen == 1:
 69             return lists[0]
 70         # 使用內排序演算法,歸併路數不超過16,然後使用外排序進一步歸併
 71         while len(lists) > 16:
 72             i, j = 0, len(lists) - 1
 73             while i < j:
 74                 lists[i] = self.mergeTwoLists(lists[i], lists[j])
 75                 lists.pop()
 76                 i += 1
 77                 j -= 1
 78 
 79         # 初始化新節點,儲存歸併結果
 80         dummy = ListNode(-sys.maxsize)
 81         head = dummy
 82         listsLen = len(lists)
 83 
 84         # 使用外排序進行歸併,若其中某路已歸併完,則構造新的敗者樹
 85         while listsLen > 0 and listsLen :
 86             # 初始化敗者樹
 87             loserTree = [listsLen] * listsLen
 88             lists.append(ListNode(-sys.maxsize))
 89             for i in range(listsLen):
 90                 self.adjust(i, listsLen, lists, loserTree)
 91 
 92             # k-歸併,將每次勝者新增到連結串列的尾部,並讀取下一個數,並更新敗者樹
 93             while lists[loserTree[0]] != None:
 94                 pos = loserTree[0]
 95                 dummy.next = lists[pos]
 96                 dummy = dummy.next
 97                 lists[pos] = lists[pos].next
 98 
 99                 # 如果某一路歸併完畢,則需要移除這一路
100                 if lists[pos] == None:
101                     break
102                 # 更新敗者樹
103                 self.adjust(pos, listsLen, lists, loserTree)
104 
105             # 去掉歸併完的路
106             while None in lists:
107                 lists.remove(None)
108                 listsLen -= 1
109 
110         return head.next

4.演算法分析

        每次從k個組中的首元素中選一個最小的數,加入到新組,這樣每次都要比較k-1次,故演算法複雜度為O((n-1)(k-1))。

        而如果使用敗者樹,可以在O(logk)的複雜度下得到最小的數,演算法複雜度將為O((n-1)logk), 對於大資料場景的外部排序來說,這是一個不小的提高。

 

參考:

 https://leetcode.com/problems/merge-k-sorted-lists/

https://blog.csdn.net/haolexiao/article/details/53488314

https://zhuanlan.zhihu.com/p/36618960

https://www.zhihu.com/question/35144290/answer/148681658