【排序】排序算法介紹

■  快速排序面試

  我的感受快速排序相對還好理解一些。大的框架上來講,快速排序使用的是遞歸的思想。算法

  具體描述: 首先獲取數組的第0元素做爲一個基準(pivot),而後從第1元素開始向右遍歷,將全部小於基準的值都儘可能往左擺放。具體來講那就是在遍歷過程當中創建兩個遊標,一個遊標i用來作全遍歷,另外一個遊標d_index用來標明到那個元素爲止,前面的全部元素都已是被斷定爲小於基準所以被放到左邊的邊界。比較第i元素和基準大小,對於每比較到一次第i元素小於基準的狀況,就交換當前i和d_index兩個元素,而且將d_index自增1。遍歷完一趟後此時應該交換d_index-1(此時的第d_index元素是屬於大於基準的哦),這樣基準元素就到了它正確的位置,剩餘的事情就是再快速排序一下整個序列中[0:d_index-1]以及[d_index:]這兩個子序列便可。shell

  另外因爲用了遞歸,因此得斟酌下遞歸返回的條件。能夠想象,當遞歸不斷深刻,對於右半區的序列而言返回的d_index是愈來愈大的,總有一天+1後會大於right,同時左半區愈來愈小,會在某一天等於0。這二者都是跳出遞歸的條件。總的來講,能夠設置一個left<right的條件,控制跳出。api

  用代碼來講:數組

def quick_sort(lst,left=0,right=None):
    if right is None:
        right = len(lst) - 1
    if left < right:
        partitionIndex = partition(lst,left,right)
        # 遞歸處理左半區和右半區(雖然說是半區,但並不必定是對半開,根據partitionIndex大小不一樣可能會有偏頗
        quick_sort(lst,left,partitionIndex-1)
        quick_sort(lst,partitionIndex+1,right)

def partition(lst,left,right):
    '''
    對lst的[left:right+1]序列進行分區,最終返回分區元素的下標d_index-1
    函數返回時數組所處的狀態應該是d_index-1左邊的都比它小,右邊都比它大
    :param lst:
    :param left:
    :param right:
    :return:
    '''
    pivot = left
    i = d_index = pivot + 1
    while i <= right:
        if lst[i] < lst[pivot]:
            lst[d_index],lst[i] = lst[i],lst[d_index]
            d_index += 1
        i += 1
    lst[d_index-1],lst[pivot] = lst[pivot],lst[d_index-1]
    return d_index - 1

 

  (上面的"將小於基準的元素儘可能放到左邊"的邏輯是從左到右依次循環遍歷,碰到小於基準的放到當前未遍歷區的最左端。另外還可能有多種實現邏輯,好比兩個遊標分別從左右開始,右邊遊標遇到小於基準的值就將其值賦到左邊,左邊遊標則是遇到大雨基準值的放到右邊。須要注意要靈活一些。)數據結構

  若是要求quick_sort只能接受一個lst參數,那麼能夠考慮將遞歸放在partition中進行:app

def partition(lst,left,right):
    if left >= right:
        return
    pivot = left
    d = i = left + 1
    while i <= right:
        if lst[pivot] < lst[i]:
            lst[d],lst[i] = lst[i],lst[d]
            d += 1
        i += 1
    lst[d-1],lst[pivot] = lst[pivot],lst[d-1]
    pivot = d - 1    # 別忘了這步,不然pivot是0
    partition(lst,left,pivot-1)
    partition(lst,pivot+1,right)

def quick_sort(lst):
    left = 0
    right = len(lst) - 1
    partition(lst,left,right)
View Code

 

  ●  分析框架

  快速排序的時間複雜度,關鍵在於看要進行多少次比較和換位。能夠想象,最壞的狀況下,每次選出的基準都是當前待排序內容中最大值,這樣會致使整個遞歸過程當中的右半區始終是空,全部排序負擔都在左半區進行。至關於就是作了一個普通的冒泡排序,因此是O(n^2)的。在理想狀況下,每次選擇的基準都差很少是待排序區域的中間值,這樣的話比如是二叉樹的搜索,最終的時間複雜度是O(nlogn)的。整體而言,平均狀況下更接近O(nlogn),是衆多基於關鍵字排序算法中比較快的,因此被叫作了快速排序。ide

  空間上,因爲使用了遞歸,系統自動開闢了一個棧空間用來記錄所有出現過的partitionIndex。易知這個棧最壞狀況下將是O(n),平均的是O(logn)函數

 

■  歸併排序

  歸併的思想其實也不難,其核心想法是將待排序的數組當作先後兩個(差很少)等長的數組的組合,只要保證兩個數組各自都有序,而後經過必定方法將兩個有序的數組再有序地合併成一個大數組就完成了排序。至於兩個子數組怎麼排序,那麼可使用遞歸的歸併排序便可。這個遞歸在子數組的長度只有1的時候中止並返回子數組自己用於上一級的歸併。

  代碼:

  拜Python中方便的數組切片所賜,歸併的代碼能夠寫得很清晰

def merge_sort(lst):
    leng = len(lst)
    if leng <= 1:
        return lst  # 這裏記得要返回子數組,若是返回None那麼這個子數組的信息就丟失了!
    lc = lst[:leng//2]
    rc = lst[leng//2:]
    return list(merge(merge_sort(lc),merge_sort(rc)))

def merge(left,right):
    # res = []  借用了Python的生成器機制,沒有單獨開一個數組來保存結果
    i,j = 0,0
    m,n = len(left),len(right)
    while i < m and j < n:
        if left[i] <= right[j]:
            # res.append(left[i])
            yield left[i]
            i += 1
        else:
            # res.append(right[j])
            yield right[j]
            j += 1

    # 一個遍歷完了另外一個還沒完時的補處理
    while i < m:
        # res.append(left[i])
        yield left[i]
        i += 1
    while j < n:
        # res.append(right[j])
        yield right[j]
        j += 1

    # return res

   歸併排序是一種穩定的排序方法,當多個元素同值時原先就放在左邊的元素最終排序出來以後也放在左邊。時間複雜度上來講歸併排序是O(nlogn)的,而且不論原數組是否處於比較理想的狀態都差很少是這個。空間上來講,由於須要額外維護一個數組用來進行子數組的合併(雖然Python用了yield,空間上可能複雜度更小一些),這個數組最大的時候也就是n個元素,所以是O(n)的空間複雜度。

■  希爾排序

  希爾排序能夠看作是一個插入排序的改進型。相比於插入排序不斷地遍歷數組中的全部位置,希爾排序經過必定間隔將數組分紅若干組,優先排列距離較遠的元素,這樣萬一較遠的元素是可能須要交互位置的元素時能夠比較快的完成操做。希爾排序又叫減少增量排序。

  希爾排序的想法是這樣的,首先指定一個gap值,一般能夠指定爲數組長度//2。將數組中全部0,0+gap,0+2*gap...這些下標的元素做爲一個組進行簡單的插入排序,保證位於這些隔着gap格的位置上的各個元素處於有序的狀態。處理完這一組後繼續處理1,1+gap...這一組,以此類推。將全部組都處理完成後,將gap減少1,重複上述操做。最終當gap==1的時候,在進行逐個元素之間的微調便可,最終得到有序序列。

  代碼:

def shell_sort(lst):
    gap = len(lst) // 2  # 初始指定gap是長度的一半
    while gap > 0:  # 不斷縮小gap
        i = gap
        while i < len(lst):  # 通過這個循環能夠保證以當前gap爲間隔造成的各個組都有序了
            j = i
            while j-gap >= 0:  # 每趟遍歷保證最小的放在最左邊
                if lst[j-gap] > lst[j]:
                    lst[j-gap],lst[j] = lst[j],lst[j-gap]
                j -= gap
            i += 1

        gap /= 2

  希爾排序的複雜度取決於增量gap的具體設置。像上面這個例子中使用了gap = len(lst) // 2開始計算,這樣整個排序的時間複雜度可能在O(n^2)左右。通常希爾排序最低的時間複雜度能夠下降到O(nlog2n),比直接插入排序要快一點,可是仍是慢於快速排序的O(nlogn)。

  至於空間複雜度,因爲不涉及任何遞歸以及額外的數據結構,因此是O(1)的。

  ●  通常模式說明

  通常說到希爾排序,算法結構就是如上面代碼那樣的。可是在本身實現的時候,常常會把第二層循環寫成for(i=0; i<gap; i++)。而後把第三層循環寫成for(j=i; j+gap<len(lst); j+=gap)。這樣寫其實從功能實現的角度來講沒什麼毛病,無非是將最裏層的循環不變式改爲了最大的放在最右邊。

  可是須要注意的是公認的希爾排序,仍是以上面代碼中的模式爲準。爲了考試面試等,仍是須要把本身的思惟扭轉過來。

 

■  堆排序

  在寫二叉樹和堆的數據結構的時候已經提到過了堆排序如何構建以及使用。就再也不寫了。

  不過世面上面經常使用的堆排序的「模板」在細節上和我看的那本書裏描述的堆排序還有些不一樣(好比約定俗成的函數名等等),下面特意寫一下符合世間通常標準的堆排序代碼:

def buildMaxHeap(lst):
    for i in range((len(lst)-1)//2,-1,-1):
        heapify(lst,i)

def heapify(lst,i):    # heapify至關於那邊書上的shiftdown,不過參數要少一些
    lc = 2 * i + 1
    rc = 2 * i + 2
    while rc < len(lst) or lc < len(lst):
        if rc < len(lst) and lst[rc] > lst[lc]:
            t = rc
        else:
            t = lc
        if lst[t] > lst[i]:
            lst[i],lst[t] = lst[t],lst[i]
        i = t
        lc,rc = 2*i+1,2*i+2

def heapSort(lst):
    buildMaxHeap(lst)
    i = len(lst)-1
    res = []    # 其實若是經過堆自己結構來存儲已排序的部分,能夠不用這個額外的數據結構,只是heapify中沒有end參數,致使Python中很難指定某一次向下篩選的下邊界
    while lst:  # 條件也能夠寫是while i >= 0之類的
        lst[0],lst[i] = lst[i],lst[0]
        res.insert(0,lst.pop())
        heapify(lst,0)
        i -= 1
    return res

 

  無非就是把向下篩選的shiftdown叫作了heapify,而且是經過大頂堆來排序的。

   ●  分析

  正如書上所說,構造堆的過程是一個O(n)的過程(詳細證實目前我還作不出來…,憑感受看應該是O(nlogn)的,但並非),排序時每一個元素都會去進行一次向下篩選,因此總的時間複雜度是O(nlogn)。通常來講若是充分利用堆結構自己的空間那麼能夠不用外部數據結構記錄結果,因此空間複雜度能夠作到O(1)。

 

■  計數排序

  * 基數排序和計數排序是兩個不一樣的東西…

  上述全部排序算法,通常狀況下時間複雜度是O(nlgn)的,對於某些算法如快速排序,若是狀況很糟糕的話反而會更差。

  另外一方面,從總體的角度出發看待排序操做,咱們必然是要訪問數組中每一個元素的值的,否則沒辦法作出完整正確的排序。所以排序算法通常複雜度的下界就是O(n)了。那麼有沒有O(n)的排序算法實現呢?答案是確定的。並且爲了讓時間複雜度下降到O(n),咱們必然要採起的策略就是以空間換時間,計數排序就是這樣一種線性時間複雜度,可是空間消耗可能比較大的排序算法。

 

  ●  描述與實現

  對於數組中的某個數,排序完成(假設是升序排序)以後這個數在有序數組中的位置如何肯定?針對這個問題,顯然,答案是其下標是i-1,i是數組中全部小於等於它值的數的個數(因爲是小於等於,包括其自身因此要減去1,暫時不考慮有相同值的元素)。

  基於這個顯而易見的事實,計數排序的基本想法就是,針對待排序數組lst,額外維護一個數組B。B的長度是max(lst)+1,而B[i]元素的值是lst中全部小於等於i的元素的個數(注意是i,下標值)。

  示例: 若是lst是[2,1,5,3,2,3],那麼維護的這個B是[0, 1, 3, 5, 5, 6]。其中B中值的意義就是,lst中小於等於0的值是0個,小於等於1的值的個數是1個,小於等於2的值的個數是3個……

  得出B以後,接下來要作的是逆序遍歷原數組lst,對於元素lst[j],訪問B[lst[j]],得到到的數字 - 1(減去1是爲了調和下標和個數之間相差1的問題)就是這個lst[j]值該在排序後數組安排的下標的位置。安排完成後,別忘了B[lst[j]] -= 1,代表若是後面又遇到和lst[j]等值的元素,那麼這些元素應該往前一格安排。因爲排序直接針對下標進行賦值,咱們不能直接用原lst做爲結果的容器,所以能夠另新開一個和lst等長的數組C,用C做爲結果容器來記錄排序結果。

  計數排序的一個特色就是它是穩定的,而穩定的依據在於咱們遍歷原數組的時候使用的是逆序遍歷。因爲B中的值是逐漸遞減的,因此等值元素安排入新數組的順序是從後往前的,爲了保證穩定性,遍歷的時候也從後往前才行。

  實現代碼也不難:

def count_sort(lst):
    leng = len(lst)
    B = [0] * (max(lst)+1)    # 這裏也務必要+1,若不加1的話max(lst)沒法在B中獲得維護
    C = [None] * leng

    for i in lst:    # 首先計數每種元素的出現次數
        B[i] += 1
    tmp = 0
    for i,count in enumerate(B):    # 將B[i]從新賦值成sum(B[:i])
        B[i] = count + tmp
        tmp += count

    for j in range(leng-1, -1, -1):    # 逆序遍歷
        val = lst[j]
        C[B[val]-1] = val    # B[val] - 1的減去1別忘了
        B[val] -= 1
    return C

 

  該說的上面基本都說了,略微值得一提的是求B的過程,分紅兩步走。第一步是統計每一個值在lst中出現的次數,第二步是將某個下標爲i的值B[i]從新賦值成B[0]到B[i-1]全部值的和,如此遞歸到B最後一個值。這樣獲得的B就是符合要求的,「小於等於個人元素有多少個」的列表了。

  ●  分析

  回到算法總體上來,上面說了計數算法是個穩定的排序算法。再來看看時間複雜度,組成程序主體的是三個循環,第一個和第三個循環分別是O(n)的,而第二個循環是O(k)的。n,k分別表示原數組lst長度和數組中最大數值。因此整個算法的複雜度是O(n + k)。當k << n的時候,這個算法的排序基本上屬於O(n)的。而空間複雜度,咱們建立了一個長度爲n的結果數組和一個長度爲k的輔助數組,所以總空間複雜度也是O(n+k)。

  技術排序的弱點也很明顯,若是n不大可是k很大的話,其複雜度仍是比較使人恐懼的。

  另外上述具體代碼適用的具體條件還有一個就是默認lst中元素都含有正整數的接口供排序使用。若是元素的數值類型不是正整數,或者沒有這個接口的話,恐怕要另尋他路。我一開始嘗試想使用HashMap來記錄元素出現次數,可是發現爲了要統計「小於等於個人元素個數」,HashMap自己又要作一次排序…

相關文章
相關標籤/搜索