【算法】排序算法小結(下)

       接上篇繼續彙總排序算法。

  五、歸併排序

5.1 原地歸併

算法描述:實現歸併最直接的方法就是將兩個不同的有序數組歸併到第三個數組中,常用的方法是創建適當大小的數組,將兩個輸入數組中的元素一個個從小到大放入這個數組當中。但隨着歸併次數的增加,需要創建的數組很多,空間複雜度很高。我們希望能有一種原地歸併的方法,先將前半部分排序,再將後半部分排序,然後在數組中移動而不需要使用額外的空間。

        原地歸併的核心就是覆蓋原數組,從而不需要每次返回新的數組。這裏介紹兩種方法,算法第四版中的方法,以及手搖算法,並進行適當的分析。

        A. 它將涉及的所有元素複製到一個輔助數組中,再把歸併的結果放回原數組中。

複雜度分析:由於返回的是原數組,空間複雜度 O(1),時間複雜度和歸併排序相同

示例

                                                     

import copy
def merge(nums,lo,mid,hi):
    n= len(nums)
    i,j = lo,mid+1
    aux = copy.deepcopy(nums)
    k = lo
    while k<=hi:
        if j<=hi and (i>mid or aux[j]<aux[i]):
            nums[k]=aux[j]
            j+=1
        else:
            nums[k]=aux[i]
            i+=1
        k += 1
    return nums

        B.手搖算法。(參考 https://blog.csdn.net/qq_36771269/article/details/80397186 )。通過三次逆序,實現位置的變換。A算法其實不能算是嚴格原地歸併算法,它仍舊藉助了輔助數組。手搖算法真正實現了“原地”“這個概念。首先舉例說明一下什麼是手搖算法:想要將 EFABCD轉換爲ABCDEF。分爲三步

               E F   A B C D 

第一步:   F E   A B C D
第二步:   F E   D C B A

第三步:   A B  C  D E F

        首先將左邊逆序,再將右邊逆序,最後全部逆序。

        理解手搖算法之後,如何將其與merge函數融合?用 i,j 指代起點和中點。i指針不斷向後移動,直到找到第一個比j指向的元素大的元素或者到達中點, index指針先代替j指向右端的第一個元素 ,j指針不斷向後移動,直道找到第一個比i指向元素大的元素或者直到遇到數組的末尾,最後將i~index-1段和index~j-1段進行手搖,之後將 i 移動j-index+1空位,然後繼續上述操作。

複雜度分析: 整體空間複雜度僅爲交換操作,爲O(1)。但是時間複雜度在最好的情況,左子段和右子段直接全部交換,複雜度還是O(n*logn),但最壞的情況,一段一段的緩慢前進的情況;此時算法的時間複雜度就是n*n,原地歸併的複雜度就是O(n*n*logn)。 綜合起來原地歸併的時間複雜度在O(n*logn)–O(n*n*logn)之間。

示例:

                                                          

def merge(nums,lo,mid,hi):
    i,j = lo,mid+1
    while i<=mid and j<=hi:
        if nums[i]<=nums[j]:
            i+= 1
        else:
            index=j
            j += 1
            while j<=hi and nums[j]<nums[i]:
                j += 1
            nums[i:index]=nums[i:index][::-1]
            nums[index:j]=nums[index:j][::-1]
            nums[i:j] = nums[i:j][::-1]
    return nums

 

5.2 自頂向下的歸併排序(遞歸方法)

        自頂向下實際上就是遞歸的一個過程,分解(sort)到底部之後再進行合併(merge),合併主要使用前述merge。可見樹狀圖(下圖,N=16)來理解整個問題。每個結點都表示一個sort() 方法通過merge() 方法歸併而成的子數組。這棵樹有n層,0~n-1之間的任意k有 2^k 個子數組,每個數組長度2^(n-k),每次歸併需要2^(n-k)次比較,因此每層比較次數2^n,總共n2^n, 由於2^n記爲N,總共爲NlgN。

                       

def sort(nums,lo,hi):
    if lo>=hi:
        return
    mid = (hi+lo)//2
    sort(nums,lo,mid)
    sort(nums,mid+1,hi)
    return merge(nums,lo,mid,hi)

拓展思考

1. 用不同的方法處理小規模問題能改進大多數遞歸算法的性能,遞歸會使小規模問題中方法的調用過於頻繁,因而改進可以改善性能。如果在小數組(一般長度小於15)上使用插入排序,會比歸併排序更快,可以將歸併排序的運行時間縮短10%到15%。

def sort(nums,lo,hi):
    if lo >= hi:
        return
    if hi-lo<=15:
                for i in range(lo+1,hi+1):
            for j in range(i,lo,-1):
                if nums[j] < nums[j-1]:
                    nums[j],nums[j-1]=nums[j-1],nums[j]
        return nums
    else:
        mid = (hi+lo)//2
        sort(nums,lo,mid)
        sort(nums,mid+1,hi)
    return merge(nums, lo, mid, hi)

2.如果添加一個判斷條件,如果a[mid]小於a[mid+1],我們就認爲數組已經是有序的,就可以跳過merge()方法,這個過程不影響排序的遞歸調用,但是任意有序的子數組算法的運行時間就變成線性的了。

def sort(nums,lo,hi):
    # print lo,hi
    if lo>=hi:
        return
    mid = (hi+lo)//2
    sort(nums,lo,mid)
    sort(nums,mid+1,hi)
    # return merge(nums, lo, mid, hi)
    if nums[mid]<=nums[mid+1]:
        return nums
    else:
        return merge(nums,lo,mid,hi)

3. 通過切換輔助數組和輸入數組,縮短數組元素的複製時間。通過標籤,切換兩種排序方法,一種將數據從輸入數組排序到輔助數組;一種將數據從輔助數組排序到輸入數組。

 

5.3 自底向上的歸併排序(循環)

        自頂向下是算法設計中”分治“的思想。將大問題分割成小問題分別解決,然後用所有小問題的答案來解決整個大問題。在這裏,自底向上剛好相反。先歸併微型數組,然後再成對歸併並得到子數組。直至將所有數組歸併在一起。這種方法,代碼量更小,兩兩歸併,四四歸併,八八歸併,一直下去。

def sort(nums):
    n = len(nums)
    sz = 1
    while sz<n:
        j = 0
        while j<n:
            nums = merge(nums,j,j+sz-1,min(j+sz*2-1,n-1))
            j += sz*2
        sz *= 2
    return nums

5.4 小結與分析

        自底向上的歸併排序比較適合用鏈表組織的數據(leetcode上有一題,可以去實踐一下)。這種方法只需要重新組織鏈表鏈接就能將鏈表原地排序,不需要新的鏈接節點。

        歸併排序是一種漸近最優的基於比較排序的算法。歸併排序在最壞情況下的比較次數和任意基於比較的排序算法所需的最少比較次數都是~NlgN。

 

六、快速排序

        快速排序也是一種分治的排序算法,它將一個數組分成兩個子數組,將兩部分獨立地排序。中心思想是:當兩個子數組都有序時,整個數組就有序了。和歸併排序排序不同,歸併排序的遞歸調用發生在處理整個數組之前,找到最小子元素,但對於快速排序來說,遞歸調用發生在處理整個數組之後,切分的位置取決於數組的內容。

        整個算法的關鍵在partition 切分部分。數組需要滿足以下三個條件:

1)對於某個j,a[j] 已經確定

2)a[lo] 到 a[j-1] 中的所有元素不大於 a[j]

3)a[j+1] 到 a[hi] 中的所有元素不小於 a[j]

複雜度分析:將長度爲N的無重複數組排序,平均需要 ~2NlgN次比較;最多需要N^2/2次比較,但是隨機打亂數組能夠預防這種情況。快速排序具有兩個明顯的優勢:第一、快速排序切分方法的內循環會使用一個遞增的索引將數組元素和一個定值比較,十分簡潔;第二,它需要的比較次數很少

def partition(nums,lo,hi):
    k = nums[lo]
    i,j=lo+1,hi
    while True:
        while nums[i]<=k:
            if i == hi:
                break
            i += 1
        while nums[j]>k:
            if j == lo:
                break
            j -= 1
        if i >= j:
            break
        nums[i],nums[j]=nums[j],nums[i]
    nums[lo],nums[j]=nums[j],nums[lo]
    return j,nums
def sort(nums,lo,hi):
    if hi<=lo:
        return
    j,nums = partition(nums,lo,hi)
    sort(nums,lo,j-1)
    sort(nums,j+1,hi)
    return nums

快速排序的改進

1.  切換到插入排序。和前面類似,對於小數組,快速排序比插入排序要慢,因此最簡單的改動就是當長度小於例如5~15時,切換成插入排序:

if (hi <= lo + M):
    return insertion(nums,lo,hi)

2. 三取樣切分。使用子數組的一小部分元素的中位數來切分數組。這樣使得切分更好。將取樣大小設爲3, 用大小居中的元素切分效果最好。同時可以將取樣元素放在數組末尾作爲“哨兵”,去掉partition()中的數組邊界測試。

3. 熵最優排序。適用於有大量重複元素的情況。介紹三向切分的快速排序。

       

        維護一個指針 lt 使得 a[lo..lt-1] 中的元素都小於v,一個指針 gt 使得 a[gt+1..hi] 中的元素都大於v,一個指針 i,使得 a[lt...i-1]中的元素都等於v,a[i..gt]中的元素還未確定,按照快排方法進行交換,直至所有元素均被處理。

def sort(nums,lo,hi):
    if hi<=lo:
        return
    lt,i,gt=lo,lo+1,hi
    v = nums[lo]
    while i <=gt:
        if nums[i]<v:
            nums[lt],nums[i]=nums[i],nums[lt]
            i += 1
            lt += 1
        elif nums[i]>v:
            nums[gt],nums[i] = nums[i],nums[gt]
            gt -= 1
        else:
            i +=1
    sort(nums,lo,lt-1)
    sort(nums,gt+1,hi)
    return nums

        補充一下幾個結論,三向切分的最壞情況是所有的主鍵均不相同,當存在主鍵相同時,就會比歸併排序的性能好很多。通過香農定理有以下兩個結論(證明過程並沒有看懂):不存在任何基於比較的排序算法能夠保證在NH-N次比較之內將N個元素排序,其中H爲由主鍵值出現的頻率定義的香農信息量;對於大小爲N的數組,三向切分的快速排序需要~(2ln2)NH次比較。

        反正記住,三向切分的快速排序的運行時間和輸入的信息量的N倍是成正比的。對於包含大量重複元素的數組,它可以將排序時間從對數降低到線性級別。記得在排序前將數組打亂以避免最壞情況。 

七、堆排序

7.1 堆的定義

        當一顆二叉樹的每個結點都大於等於它的兩個子結點時,它被稱爲堆有序。其中,根結點是堆有序的二叉樹中的最大結點。接下來,對於堆的表示,具有特別好性能的就是完全二叉樹,它只需要數組而不需要指針就可以表示。它將二叉樹的結點按層序放入數組,位置 k 的結點的父節點位置爲 \left \lfloor k/2 \right \rfloor,它的兩個子結點的位置分別爲 2k 和 2k+1。這個關係非常非常重要!

        另外,值得注意的是,這裏堆的數組從序號1 開始, nums[0]不放任何數據,可取“/”。

7.2 重要操作

       基本操作--上浮(swim):當某個結點的優先級上升(或是在堆底加入一個新的元素)時,我們需要由下至上恢復堆的順序。具體來說,如果堆的有序狀態因爲某個結點比它的父節點更大而被打破,我們需要通過交換它和它的父結點來修復堆。同樣可以向上依次恢復秩序。這個過程,實現起來也比較簡單:

def swim(nums,k):
    while k>1 and nums[k]>nums[k/2]:
        nums[k],nums[k/2]=nums[k/2],nums[k]
        k = k/2
    return nums

        基本操作--下沉(sink):如果堆的有序狀態因爲某個結點變得比其他兩個結點或者其中之一個結點更小而被打破,那麼可以通過與子結點中較大的進行交換來恢復,同樣不斷重複至完全修復。

def sink(nums,k):
    while 2*k <len(nums):
        j = 2*k
        if j+1<len(nums) and nums[j]<nums[j+1]:
            j += 1
        if nums[k]<nums[j]:
            nums[j],nums[k]= nums[k],nums[j]
        k = j

         插入:將新元素加入到數組末尾,增加堆的大小,並讓這個新元素上浮到合適位置。

def insert(nums,v):
    nums.append(v)
    return swim(nums,len(nums)-1)

        刪除最大元素:從數組頂端刪去最大的元素,並將數組的最後一個元素放到頂端,減小堆的大小並讓這個元素下沉到合適的位置。

def delmax(nums):
    nums[-1],nums[1]=nums[1],nums[-1]
    maxnum = nums.pop(-1)
    return  maxnum,sink(nums, 1)

       對於一個含有N個元素的基於堆的優先隊列,插入元素操作只需要不超過 lgN+1 次比較,刪除最大元素的操作需要不超過 2lgN 次比較。

7.3  堆排序

         思路很簡單,將所有元素插入一個查找最小元素的優先隊列,然後再重複調用刪除最小元素的操作來將它們按順序刪去。分爲兩個階段,第一,在堆的構造中,將原始數據重新組織安排進一個堆。第二下沉排序,從堆中按遞減順序取出所有元素並得到排序結果。

        堆的構造:從右至左掃描數組,跳過大小爲1的堆。從上往下下沉。(和我們常規思維,從左往右掃描數組不同,從右往左下沉效率更高。因爲我們的目標是構造一個堆有序的數組並使最大元素位於數組的開頭,次大元素在附近,而非構造函數結束的末尾。下沉操作由 N 個元素構造堆只需少於2N次比較以及少於N次交換。

        下沉排序:將堆中最大元素刪除,然後放入堆縮小後空出的位置。

        有區別的是,這裏對sink函數進行了改動,引入了變量 n, 這樣將最大元素放至末尾,不需要額外的空間。寫代碼時,需要注意一下邊界條件。

def sink(nums,n,k):
    while 2*k < n:
        j = 2*k
        if j+1<n and nums[j]<nums[j+1]:
            j = j+1
        if nums[k]<nums[j]:
            nums[j],nums[k]= nums[k],nums[j]
        k = j
    return nums
def sort(nums):
    nums = ["/"]+ nums
    n = len(nums)
    for i in range(n/2,0,-1):
        nums = sink(nums,n,i)
    while n>1:
        n -= 1
        nums[1],nums[n] = nums[n],nums[1]
        nums = sink(nums,n,1)
    return nums[1:]

        最後簡單說一下三種其他的排序。分別是基數排序、桶排序和計數排序。

八、基數排序

        直接用一個例子理解一下:分別從個位、十位、百位依次對數組進行排序。

Radix Sort

Radix Sort

複雜度分析:設待排序列爲n個記錄,d爲執行回合數,基數爲 r,則進行鏈式基數排序的時間複雜度爲O(d(n+r)),最好、最壞、平均均爲此,其中,一趟分配時間複雜度爲O(n),一趟收集時間複雜度爲O(r),共進行d趟分配和收集。

        空間複雜度來說:使用二維矩陣來當桶子:Ο(n × r),需要r個桶子,每個桶子需可放n個資料 ⇒ Ο( n × r);使用鏈表來當桶子,需要O(n).

import math
def sort(nums,r=10):
    k = int(math.ceil(math.log(max(nums),r)))
    for i in range(1,k+1):
        buckets = [[] for _ in range(r)]
        for item in nums:
            buckets[item%(r**i)/(r**(i-1))].append(item)
        nums=[]
        for each in buckets:
            nums +=each
    return nums

九、桶排序

        桶排序的基本思想是將一個數據表分割成許多buckets,然後每個bucket各自排序,或用不同的排序算法,或者遞歸的使用bucket sort算法。也是典型的divide-and-conquer分而治之的策略。

        每個桶內的排序算法,根據情況限定。

圖來源:https://blog.csdn.net/developer1024/article/details/79770240

è¿éåå¾çæè¿°

        複雜度分析:對N個關鍵字進行桶排序的時間複雜度分爲:循環計算每個關鍵字的桶映射函數 O(N); 利用其他排序算法對每個桶內的所有數據進行排序,爲 ∑ O(Ni*logNi) 。其中Ni 爲第i個桶的數據量。

        第(2)部分是桶排序性能好壞的決定因素。所以我們要儘量減少桶內數據的數量。有以下兩點:(1) 映射函數f(k)能夠將N個數據平均的分配到M個桶中,這樣每個桶就有[N/M]個數據量。(2) 儘量的增大桶的數量。對於N個待排數據,M個桶,平均每個桶[N/M]個數據的桶排序平均時間複雜度爲:O(N)+O(M*(N/M)*log(N/M))=O(N+N*(logN-logM))=O(N+N*logN-N*logM)  當N=M時,即極限情況下每個桶只有一個數據時。桶排序的最好效率能夠達到O(N)。

        桶排序的空間複雜度 爲O(N+M)。

 

十、計數排序

        總體概括來說,主要思想是根據獲得的數據表的範圍,分割成不同的buckets,然後直接統計數據在buckets上的頻次,然後順序遍歷buckets就可以得到已經排好序的數據表。如下圖例子,

        算法的步驟如下:

  1. 找出待排序的數組中最大和最小的元素
  2. 統計數組中每個值爲i的元素出現的次數,存入數組C的第i
  3. 對所有的計數累加(從C中的第一個元素開始,每一項和前一項相加)
  4. 反向填充目標數組:將每個元素i放在新數組的第C(i)項,每放一個元素就將C(i)減去1

        計數排序算法沒有用到元素間的比較,它利用元素的實際值來確定它們在輸出數組中的位置。因此,計數排序算法不是一個基於比較的排序算法,從而它的計算時間下界不再是O(nlogn)。算法中的循環時間代價都是線性的,還有一個常數k,因此時間複雜度是O(n+k)。另一方面,計數排序算法之所以能取得線性計算時間的上界是因爲對元素的取值範圍作了一定限制,即k=O(n)。當k=O(n)時,我們採用計數排序就很好,總的時間複雜度爲O(n)。

        總體是一個空間換時間的方法。比較穩定。

 

        最後的最後, 一定記住 排序算法小結(上) 的那張小結的圖。然後對於較爲常見的算法要做到熟悉。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章