1. 程式人生 > >演算法淺談——分治演算法與歸併、快速排序(附程式碼和動圖演示)

演算法淺談——分治演算法與歸併、快速排序(附程式碼和動圖演示)

在之前的文章當中,我們通過海盜分金幣問題詳細講解了遞迴方法。


我們可以認為在遞迴的過程當中,我們通過函式自己呼叫自己,將大問題轉化成了小問題,因此簡化了編碼以及建模。今天這篇文章呢,就正式和大家聊一聊將大問題簡化成小問題的分治演算法的經典使用場景——排序。


排序演算法


排序演算法有很多,很多博文都有總結,號稱有十大經典的排序演算法。我們信手拈來就可以說上來很多,比如插入排序、選擇排序、桶排序、希爾排序、快速排序、歸併排序等等。老實講這麼多排序演算法,但我們實際工作中並不會用到那麼多,凡是高階語言都有自帶的排序工具,我們直接呼叫就好。為了應付面試以及提升自己演算法能力呢,用到的也就那麼幾種。今天我們來介紹一下利用分治思想實現的兩種經典排序演算法——歸併排序與快速排序。


歸併排序


我們先來講歸併排序,歸併排序的思路其實很簡單,說白了只有一句話:兩個有序陣列歸併的複雜度是\(O(n)\)。

我們舉個例子:

a = [1, 4, 6]
b = [2, 4, 5]
c = []


我們用i和j分別表示a和b兩個陣列的下標,c表示歸併之後的陣列,顯然一開始的時候i, j = 0, 0。我們不停地比較a和b陣列i和j位置大小關係,將小的那個數填入c。

填入一個數之後:


i = 1
j = 0
a = [1, 4, 6]
b = [2, 4, 5]
c = [1]


填入兩個數之後:


i = 1
j = 1
a = [1, 4, 6]
b = [2, 4, 5]
c = [1, 2]


我們重複以上步驟,直到a和b陣列當中所有的數都填入c陣列為止,我們可以很方便地寫出以上操作的程式碼:


def merge(a, b):
  i, j = 0, 0
  c = []
  while i < len(a) or j < len(b):
    # 判斷a陣列是否已經全部放入
    if i == len(a):
      c.append(b[j])
      j += 1
      continue
    elif j == len(b):
      c.append(a[i])
      i += 1
      continue
    # 判斷大小
    if a[i] <= b[j]:
      c.append(a[i])
      i += 1
    else:
      c.append(b[j])
      j += 1
  return c


從上面的程式碼我們也能看出來,這個過程雖然簡單,但是寫成程式碼非常麻煩,因為我們需要判斷陣列是否已經全部填入的情況。這裡有一個簡化程式碼的優化,就是在a和b兩個陣列當中插入一個”標兵“,這個標兵設定成正無窮大的數,這樣當a陣列當中其他元素都彈出之後。由於標兵大於b陣列當中除了標兵之外其他所有的數,就可以保證a陣列永遠不會越界,如此就可以簡化很多程式碼了(前提,a和b陣列當中不存在和標兵一樣大的數)。


我們來看程式碼:

def merge(a, b):
  i, j = 0, 0
  # 插入標兵
  a.append(MAXINT)
  b.append(MAXINT)
  c = []
  # 由於插入了標兵,所以長度判斷的時候需要-1
  while i < len(a)-1 or j < len(b)-1:
    if a[i] <= b[j]:
      c.append(a[i])
      i += 1
    else:
      c.append(b[j])
      j += 1
  return c

這裡應該都沒有問題,接下來的問題是我們怎麼利用歸併陣列的操作來排序呢?

其實很簡單,這也是歸併排序的精髓。


我們每次將一個數組一分為二,顯然,這個劃分出來的陣列不一定是有序的。但如果我們繼續切分呢?直到陣列當中只有一個元素的時候,是不是就天然有序了呢?

我們舉個例子:

          [4, 1, 3, 2]
           /        \
        [4, 1]    [3, 2]
        /    \    /    \
      [4]   [1]  [3]   [2]
        \    /     \   /
        [1, 4]     [2, 3]
           \        /
          [1, 2, 3, 4]

通過上面的這個過程我們可以發現,在歸併排序的時候,我們先一直往下遞迴切分陣列,直到所有的切片當中只有一個元素天然有序。接著一層一層地歸併回來,當所有元素歸併結束的時候,陣列就完成了排序。這也就是歸併排序的全部過程。


如果還不理解,還可以參考一下下面的動圖。

我們來試著用程式碼來實現。之前我曾經在面試的時候被要求在白板上寫過歸併排序,當時我用的C++覺得編碼還有一定的難度。現在,當我用習慣了Python之後,我感覺編碼難度降低了很多。因為Python支援許多陣列相關的高階操作,比如切片,變長等等。整個歸併排序的程式碼不超過20行,我們一起來看下程式碼:


def merge_sort(arr):
    n = len(arr)
    # 當長度小於等於1,說明天然有序
    if n <= 1:
        return arr
    mid = n // 2
    # 通過切片將陣列一分為二,遞迴排序左邊以及右邊部分
    L, R = merge_sort(arr[: mid]), merge_sort(arr[mid: ])
    n_l, n_r = len(L), len(R)

    # 陣列當中插入標兵
    L.append(sys.maxsize)
    R.append(sys.maxsize)
    new_arr = []
    i, j = 0, 0

    # 歸併已經排好序的L和R
    while i < n_l or j < n_r:
        if L[i] <= R[j]:
            new_arr.append(L[i])
            i += 1
        else:
            new_arr.append(R[j])
            j += 1
    return new_arr


你看,無論是思想還是程式碼實現,歸併排序並不難,就算一開始不熟悉,寫個兩遍也一定沒問題了。

理解了歸併排序之後,再來學快速排序就不難了,我們一起來看快速排序的演算法原理。


快速排序


快速排序同樣利用了分治的思想,我們每次做一個小的操作,讓陣列的一部分變得有序,之後我們通過遞迴,將這些有序的部分組合在一起,達到整體有序。


在歸併排序當中,我們劃分問題的方法是橫向切分,我們直接將陣列一分為二,針對這兩個部分分別排序。快排稍稍不同,它並不是針對陣列的橫向切分,而是從問題本身出發的”縱向“切分。在快速排序當中,我們解決的子問題不是對陣列的一部分排序,而是提升陣列的有序程度。怎麼提升呢?我們在陣列當中尋找一個數,作為標杆,我們利用這個標杆調整陣列當中元素的順序。將小於它的放到它的左側,大於它的放到它的右側。這麼一個操作結束之後,可以肯定的是,這個標杆所在的位置就是排序完成之後,它應該在的位置。


我們來看個例子:

a = [8, 4, 3, 9, 10, 2, 7]


我們選擇7作為標杆,一輪操作之後可以得到:

a = [2, 4, 3, 7, 9, 10, 8]


接著我們怎麼做呢?很簡單,我們只需要針對標杆前面以及標杆後面的部分重複上述操作即可。如果還不明白的同學可以看一下下面這張動圖:

如果用C++寫過快排的同學肯定對於快排的程式碼印象深刻,它是屬於典型的原理不難,但是寫起來很麻煩的演算法。因為快速排序需要用到兩個下標,寫的時候一不小心很容易寫出bug。同樣,由於Python當中動態陣列的支援非常好,我們可以避免使用下標來實現快排,這樣程式碼的可讀性以及編碼難度都要降低很多。


多說無益,我們來看程式碼:

def quick_sort(arr):
    n = len(arr)
    # 長度小於等於1說明天然有序
    if n <= 1:
        return arr
  
    # pop出最後一個元素作為標杆
    mark = arr.pop()
    # 用less和greater分別儲存比mark小或者大的數
    less, greater = [], []
    for x in arr:
        if x <= mark:
            less.append(x)
        else:
            greater.append(x)
    arr.append(mark)
    return quick_sort(less) + [mark] + quick_sort(greater)

整個程式碼出去註釋,不到15行,我想大家應該都非常容易理解。

最後,我們來分析一下這兩個演算法的複雜度,為什麼說這兩個演算法都是\(nlogn\)的演算法呢?(不考慮快速排序最差情況)這個證明非常簡單,我們放一張圖大家一看就明白了:

我們在遞迴的過程當中,我們只遍歷了一遍陣列,雖然我們每一層都會講陣列拆分。但是在遞迴樹上同一層的遞迴函式遍歷的總數加起來應該是等於陣列的總長也就是n的。


而且遞迴的層數是有限制的,因為我們每次都將陣列一分為二。而一個數組的最小長度是1,也就是說極端情況下我們一共能有\(\log_2^n\)層,每一層的複雜度總和是n,所以整體的複雜度是\(nlogn\)。


當然對於快速排序演算法來說,如果陣列是倒序的,我們預設取最後一個元素作為標杆的話,我們是無法切分陣列的,因為除它之外所有的元素都比它大。在這種情況下演算法的複雜度會退化到\(n^2\)。所以我們說快速排序演算法最差複雜度是\(O(n^2)\)。


到這裡,關於歸併排序與快速排序的演算法就講完了。這兩個演算法並不難,我想學過演算法和資料結構的同學應該都有印象,但是在實際面試當中,真正能把程式碼寫出來並且沒有明顯bug的實在是不多。我想,不論之前是否已經學會了,回顧一下都是很有必要的吧。


今天的文章就到這裡,希望大家有所收穫。如果喜歡本文,請順手點個關注吧。

相關推薦

演算法——分治演算法歸併快速排序(程式碼演示)

在之前的文章當中,我們通過海盜分金幣問題詳細講解了遞迴方法。 我們可以認為在遞迴的過程當中,我們通過函式自己呼叫自己,將大問題轉化成了小問題,因此簡化了編碼以及建模。今天這篇文章呢,就正式和大家聊一聊將大問題簡化成小問題的分治演算法的經典使用場景——排序。 排序演算法 排序演算法有很多,很多博文都有總結

資料結構演算法--排序(冒泡選擇歸併快速排序排序

/** * 氣泡排序 * @param arr */ function bubbleSort(arr) { let len = arr.length; for (let i =0; i < arr.len; i++) { for (l

java--線程(二線程的方法狀態)

println block not 問題: inter 方法 pre 源碼 單個 1.線程的狀態介紹: 說明:線程共包括以下5種狀態。1. 新建狀態(New) : 線程對象被創建後,就進入了新建狀態。例如,Thread thread = new Thr

十二種排序包你滿意(冒泡插入歸併快速排序等包含希爾計數排序

前言 排序演算法在電腦科學入門課程中很普遍,在學習排序演算法的時候,涉及到大量的各種核心演算法概念,例如大O表示法,分治法,堆和二叉樹之類的資料結構,隨機演算法,最佳、最差和平均情況分析,時空權衡以及上限和下限,本文就介紹了十二種排序演算法供大家學習。 簡介 排序演算法是用來根據元素對應的比較運算子重新排列給

Java常用的八種排序演算法程式碼實現(二):歸併排序快速排序

注:這裡給出的程式碼方案都是通過遞迴完成的 --- 歸併排序(Merge Sort):   分而治之,遞迴實現   如果需要排序一個數組,我們先把陣列從中間分成前後兩部分,然後對前後兩部分進行分別排序,再將排好序的數組合並在一起,這樣整個陣列就有序了   歸併排序是穩定的排序演算法,時間

ICA演算法的概念本質流程

本文轉自http://m.elecfans.com/article/699564.html ICA獨立成分分析是近年來出現的一種強有力的資料分析工具(Hyvarinen A, Karhunen J, Oja E, 2001; Roberts S J, Everson R, 2001)。1994年

資料結構演算法

一個優秀的程式  = 優秀的資料結構   +   一個優秀的演算法(包含企業級開發,人工智慧開發), 所以一個程式猿這個是必須要做的,必須會的,否則不是一個合格的程式猿; 資料結構:個人理解,就是對

排序演算法的C++實現效能分析(插入排序歸併排序快速排序STOOGE排序排序

選擇排序、快速排序、希爾排序、堆排序不是穩定的排序演算法 氣泡排序、插入排序、歸併排序和基數排序都是穩定的排序演算法。 總結: (1)如果資料量非常小,那麼適合用簡單的排序演算法:氣泡排序,選擇排序和插入排序。因為他們雖然比較次數多,但是移動次數少。比如,如果記錄的關鍵

資料結構演算法分析學習及如何進行演算法分析

一、前言 都說資料結構與演算法分析是程式設計師的內功,想要理解計算機世界就不能不懂點資料結構與演算法,然而這也備受爭議,因為大多數的業務需求都用不上資料結構與演算法,又或者說已經有封裝好的庫可以直接呼叫,例如Java中的ArrayList與LinkedList

排序演算法——分治思想歸併排序

1、分治法思想     分治思想主要通過遞迴來實現,每層遞迴中主要包括三個步驟: 分解:即將原問題

JavaScript 資料結構演算法之美 - 歸併排序快速排序希爾排序排序

1. 前言 演算法為王。 想學好前端,先練好內功,只有內功深厚者,前端之路才會走得更遠。 筆者寫的 JavaScript 資料結構與演算法之美 系列用的語言是 JavaScript ,旨在入門資料結構與演算法和方便以後複習。 之所以把歸併排序、快速排序、希爾排序、堆排序放在一起比較,是因為它們的平均時

演算法——遞迴演算法海盜分金問題

本文始發於個人公眾號:TechFlow 最近看到一道很有意思的問題,分享給大家。 還是老規矩,在我們聊演算法問題之前,先來看一個故事。 傳說中,有5個海盜組成了一支無敵的海盜艦隊,他們在最後一次的尋寶當中找尋到了100枚價值連城的金幣。於是,很自然的,這群海盜面臨分贓的問題。為了防止海盜內訌,殘忍的海盜們

演算法——走迷宮問題廣度優先搜尋

本文始發於個人公眾號:**TechFlow**,原創不易,求個關注 在之前週末LeetCode專欄當中,我們詳細描述了深度優先搜尋和回溯法,所以今天我們繼續這個話題,來和大家聊聊搜尋演算法的另一個分支,廣度優先搜尋。 廣度優先搜尋的英文是Breadth First Search,簡寫為bfs。與它相對的深

Oracle12c 數據庫用戶CDBPDB之間的關系

所有 bing 名詞 1.0 容器 ner 們的 roo val 名詞介紹: 數據庫:數據庫(Database)是按照數據結構來組織、存儲和管理數據的倉庫,它產生 於距今六十多年前,隨著信息技術和市場的發展,特別是二十世紀九十年代以 後,數據管理不再僅僅是存儲和管理數據,而

查詢演算法 演算法資料結構: 七 二叉查詢樹 演算法資料結構: 十一 雜湊表

閱讀目錄 1. 順序查詢 2. 二分查詢 3. 插值查詢 4. 斐波那契查詢 5. 樹表查詢 6. 分塊查詢 7. 雜湊查詢   查詢是在大量的資訊中尋找一個特定的資訊元素,在計算機應用中,查詢是常用的基本運算,例如編譯程式中符號表的查詢。本文

排序演算法(直接插入氣泡排序選擇排序快速排序希爾排序排序歸併排序

main函式 int main() { int data[] = {1,2,6,3,4,7,7,9,8,5}; //bubble_sort(data,10); //select_sort(data,10); Insert_Sort(data,10); fo

【python資料結構演算法】幾種排序演算法:氣泡排序快速排序

以下排序演算法,預設的排序結果是從小到大。 一.氣泡排序: 1.氣泡排序思想:越大的元素,就像越大的氣泡。最大的氣泡才能夠浮到最高的位置。 具體來說,即,氣泡排序有兩層迴圈,外層迴圈控制每一輪排序中操作元素的個數——氣泡排序每一輪都會找到遍歷到的元素的最大值,並把它放在最後,下一輪排序時

選擇排序氣泡排序合併排序快速排序歸併排序演算法原理

實驗目的: 掌握選擇排序、氣泡排序、合併排序、快速排序、歸併排序的演算法原理 分析不同排序演算法的時間效率和時間複雜度,以及理論值與實測資料的對比分析。 一、氣泡排序 演算法虛擬碼: for i=1 to n     

C#中的委託事件非同步

從剛接觸c#程式設計到現在,差不多快有一年的時間了。在學習過程中,有很多地方始終似是而非,直到最近才弄明白。 本文將先介紹用法,後評斷功能。 一、委託 基本用法: 1.宣告一個委託型別。委託就像是‘類'一樣,聲明瞭一種委託之後就可以建立多個具有此種特徵的委託。(特徵,指的是返回值、引數型

3. 排序通常有多種演算法,如氣泡排序插入排序選擇排序希爾排序歸併排序快速排序,請選擇任意2種用java實現 [分值:20] 您的回答:(空) (簡答題需要人工評分)

3. 排序通常有多種演算法,如氣泡排序、插入排序、選擇排序、希爾排序、歸併排序、快速排序,請選擇任意2種用java實現  [分值:20] 您的回答:(空)  (簡答題需要人工評分) package com.interview; /** * 各種排序演算法 */