極客時間課程《數據結構與算法之美》筆記05 - 排序

排序

冒泡

原地排序:就是特指空間複雜度是 O (1) 的排序算法。如下三個都是原地排序。java

穩定性:若是待排序的序列中存在值相等的元素,通過排序以後,相等元素之間原有的前後順序不變。算法

5a257a87a8539e2affcb74375032bc70.png

// 冒泡排序,a 表示數組,n 表示數組大小
public void bubbleSort(int[] a, int n) {
  if (n <= 1) return;
 
 for (int i = 0; i < n; ++i) {
    // 提早退出冒泡循環的標誌位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; ++j) {
      if (a[j] > a[j+1]) { // 交換
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示有數據交換      
      }
    }
    if (!flag) break;  // 沒有數據交換,提早退出
  }
}

冒泡特點:編程

  • 原地排序,空間複雜度O(1)。
  • 穩定的排序算法。
  • 最好狀況,一次冒泡操做,時間複雜度O(n),最壞O(exp(n) 。

分析排序複雜度的兩個指標:
有序度:是數組中具備有序關係的元素對的個數。數組

有序元素對:a[i] <= a[j], 若是 i < j。

對於一個倒序排列的數組,好比 6,5,4,3,2,1,有序度是 0;對於一個徹底有序的數組,好比 1,2,3,4,5,6,有序度就是 n*(n-1)/2,也就是 15。咱們把這種徹底有序的數組的有序度叫做滿有序度函數

逆序度:與有序度相反,逆序度 = 滿有序度 - 有序度。優化

插排

將數組中的數據分爲兩個區間,已排序區間和未排序區間。
b60f61ec487358ac037bf2b6974d2de1.jpeg
須要將數據a插入到已排序區間時,須要拿a與已排序區間的元素比較找到合適的插入位置。code

// 插入排序,a 表示數組,n 表示數組大小
public void insertionSort(int[] a, int n) {
  if (n <= 1) return;

  for (int i = 1; i < n; ++i) {
    int value = a[i];
    int j = i - 1;
    // 查找插入的位置
    for (; j >= 0; --j) {
      if (a[j] > value) {
        a[j+1] = a[j];  // 數據移動
      } else {
        break;
      }
    }
    a[j+1] = value; // 插入數據
  }
}

插排特色:排序

  • 原地排序
  • 穩定排序
  • 同冒泡,最好O(n),最壞O(exp(n)),平均O(exp(n))。

選擇排序

和插排有點相似,也區分已排序區間和未排序區間。選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。
32371475a0b08f0db9861d102474181d.jpeg遞歸

選擇排序特色:內存

  • 原地排序
  • 不穩定排序
  • 同冒泡,最好,最壞,平均都是O(exp(n))。

插排比冒泡好的緣由

從代碼實現上來看,冒泡排序的數據交換要比插入排序的數據移動要複雜,冒泡排序須要 3 個賦值操做,而插入排序只須要 1 個。

冒泡排序中數據的交換操做:
if (a[j] > a[j+1]) { // 交換
   int tmp = a[j];
   a[j] = a[j+1];
   a[j+1] = tmp;
   flag = true;
}

插入排序中數據的移動操做:
if (a[j] > value) {
  a[j+1] = a[j];  // 數據移動
} else {
  break;
}

接下來是兩種時間複雜度O (nlogn)的排序算法:歸併排序快速排序。這兩種適合大規模數據排序,均用到了分治思想。

分治是一種解決問題的處理思想,遞歸是一種編程技巧,這二者並不衝突。

歸併排序

db7f892d3355ef74da9cd64aa926dc2b.jpeg

遞推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

終止條件:
p >= r 不用再繼續分解
(其實是分解成爲單個元素而後有序合併)

僞代碼:

// 歸併排序算法, A 是數組,n 表示數組大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}

// 遞歸調用函數
merge_sort_c(A, p, r) {
  // 遞歸終止條件
  if p >= r  then return

  // 取 p 到 r 之間的中間位置 q
  q = (p+r) / 2
  // 分治遞歸
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 將 A[p...q] 和 A[q+1...r] 合併爲 A[p...r]
  merge(A[p...r], A[p...q], A[q+1...r])
}

其中merge()函數的僞代碼:

merge(A[p...r], A[p...q], A[q+1...r]) {
  var i := p,j := q+1,k := 0 // 初始化變量 i, j, k
  var tmp := new array[0...r-p] // 申請一個大小跟 A[p...r] 同樣的臨時數組
  while i<=q AND j<=r do {
    if A[i] <= A[j] {
      tmp[k++] = A[i++] // i++ 等於 i:=i+1
    } else {
      tmp[k++] = A[j++]
    }
  }
  
  // 判斷哪一個子數組中有剩餘的數據
  var start := i,end := q
  if j<=r then start := j, end:=r
  
  // 將剩餘的數據拷貝到臨時數組 tmp
  while start <= end do {
    tmp[k++] = A[start++]
  }
  
  // 將 tmp 中的數組拷貝回 A[p...r]
  for i:=0 to r-p do {
    A[p+i] = tmp[i]
  }
}

若是咱們定義求解問題 a 的時間是 T (a),求解問題 b、c 的時間分別是 T (b) 和 T ( c),那咱們就可獲得遞推關係式:
T(a) = T(b) + T(c) + K
K是講子問題b、c的結果合併成爲問題a的結果所消耗的時間。

歸併排序特色:

  • 穩定的排序算法
  • 時間複雜度O (nlogn),最好最壞平均都是此複雜度。
  • 非原地排序,空間複雜度O (n)

快速排序

4d892c3a2e08a17f16097d07ea088a81.jpeg

分區:
6643bc3cef766f5b3e4526c332c60adc.jpeg

原地分區函數僞代碼(空間複雜度O(1)):

partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i

快排特色:

  • 非穩定排序
  • 能夠原地排序
  • 遞歸實現,時間複雜度,最好O(nlogn),最壞O(exp(n))

快排與歸併的區別:
aa03ae570dace416127c9ccf9db8ac05.jpeg
歸併處理過程是由下到上,先子問題,再合併。
快排處理過程是由上到下,先分區,再子問題。

O(n)時間複雜度內求無序數組中的第K大元素
借鑑快排的分治和分區思想。遞歸分區,而後比較pivot的下標p+1與K。


線性排序

接下來三種時間複雜度爲O(n)的排序算法:桶排序、計數排序、基數排序。由於複雜度是線性的,因此叫作線性排序。主要緣由是:不涉及元素間的比較操做
對排序數據要求比較高,好比給100萬用戶基於年齡排序。

桶排序

核心思想:將數據分到幾個有序的桶裏,每一個桶裏的數據再單獨進行排序。桶內排完序以後,再把每一個桶裏的數據按照順序依次取出,可得有序序列。
987564607b864255f81686829503abae.jpeg

數據有n個,均勻劃分到m個桶內。每一個桶k=n/m個元素,桶內快排O(k*logk)。桶排序的時間複雜度爲
O (n*log (n/m))。當桶的個數m接近數據個數n時,log(n/m)就是一個很是小的常量,桶排序的時間複雜度接近O(n)。空間複雜度爲O(1)。

桶排序比較適合用在外部排序中:數據比較大,沒法所有加載到內存中。

計數排序

一個理解:計數排序能夠做爲桶排序的一種特殊狀況
舉例說明:8個考生,成績在0到5分之間,放在數組A[8]中,分別是:2,5,3,0,2,3,0,3。
考生的成繢從0到5分,咱們使用大小爲6的數組C[6]表示桶,其中下標對應分數。不過, C[6]內存儲的並非考生,而是對應的考生個數。像我剛剛舉的那個例子,咱們只須要遍歷一遍考生分數,就能夠獲得C[6]的值。
從圖中能夠看出,分數爲3的考生有3個,小於3分的考生有4個,因此,成績爲3分的考生在排序以後的有序數組R[8]中,會保存下標4,5,6的位置。

361f4d781d2a2d144dcbbbb0b9e6db29.jpeg

思路以下:對C[6]數組順序求和,C[k]存儲小於等於分數k的考生個數。

dd6c62b12b0dc1b3a294af0fa1ce371f.jpeg

咱們從後到前依次掃描數組A。好比,當掃描到3時,咱們能夠從數組C中取出下標爲3的值7,也就是說,到目前爲止,包括本身在內,分數小於等於3的考生有7個,也就是說3是數組R中的第7個元素(也就是數組R中下標爲6的位置)。當3放入到數組R中後,小於等於3的元素就只剩下了6個 了,因此相應的C[3]要減1,變成6。

以此類推,當咱們掃描到第2個分數爲3的考生的時候,就會把它放入數組R中的第6個元素的位置(也就是下標爲5的位置)。當咱們掃描完整個數組A後,數組R內的數據就是按照分數從小到大有序排列的了。

1d730cb17249f8e92ef5cab53ae65784.jpeg

// 計數排序,a 是數組,n 是數組大小。假設數組中存儲的都是非負整數。
public void countingSort(int[] a, int n) {
  if (n <= 1) return;

  // 查找數組中數據的範圍
  int max = a[0];
  for (int i = 1; i < n; ++i) {
    if (max < a[i]) {
      max = a[i];
    }
  }

  int[] c = new int[max + 1]; // 申請一個計數數組 c,下標大小 [0,max]
  for (int i = 0; i <= max; ++i) {
    c[i] = 0;
  }

  // 計算每一個元素的個數,放入 c 中
  for (int i = 0; i < n; ++i) {
    c[a[i]]++;
  }

  // 依次累加
  for (int i = 1; i <= max; ++i) {
    c[i] = c[i-1] + c[i];
  }

  // 臨時數組 r,存儲排序以後的結果
  int[] r = new int[n];
  // 計算排序的關鍵步驟,有點難理解
  for (int i = n - 1; i >= 0; --i) {
    int index = c[a[i]]-1;
    r[index] = a[i];
    c[a[i]]--;
  }

  // 將結果拷貝給 a 數組
  for (int i = 0; i < n; ++i) {
    a[i] = r[i];
  }
}

計數排序特色

  • 數據範圍不大的場景
  • 只能給非負整數排序,其餘類型要轉化爲非負整數
  • 只涉及掃描遍歷操做,時間複雜度是O(n)

基數排序

十萬個手機號排序,桶排序和計數排序不適用。
一個思路:假設比較a,b兩個手機號碼的大小,若是前面幾位a比b大,則後面幾位不用看了。
先排最後一位,而後倒數第二位排序。這樣11次排序以後,手機號碼有序。
字符串舉例:

df0cdbb73bd19a2d69a52c54d8b9fc0c.jpeg

這是穩定排序算法思路。這樣,排序的數據有k位,就須要k次桶排序或者計數排序,總的時間複雜度是O(k*n)。
因此基數排序的時間複雜度近似於O(n)

實際上有些時候數據不是等長的,好比單詞排序,有長有短。能夠把全部單詞補齊到相同長度,位數不夠的在後面補0,根據ASCII值,全部字母大於「0」,因此不會影響大小順序。

總結:
基數排序對要排序的數據是有要求的,須要能夠分割出獨立的"位"來比較,並且位之間有遞進的關係,若是a數據的高位比b數據大,那剰下的低位就不用比較了。除此以外,每一位的數據範圍不能太大,要能夠用線性排序算法來排序(基數排序須要藉助桶排序或者計數排序來完成每一位的排序工做),不然,基數排序的時間複雜度就沒法作到〇(n) 了。

解答開篇:如何給100萬用戶基於年齡排序。使用桶排序。

排序優化

常見幾種排序算法的比較:
1f6ef7e0a5365d6e9d68f0ccc71755fd.jpeg

小規模數據能夠選擇時間複雜度爲O(exp(n))的算法,若是對大規模數據排序,時間複雜度O(nlogn)更高效。因此,爲了兼顧更多狀況,通常都會選擇O(nlogn)排序算法實現排序。

快速排序優化方式:

  • 三數取中法
    從區間的首、尾、中間,分別取一個數,而後找中間值做爲分區點。
  • 隨機法
    隨機選擇一個元素做爲分區點。
  • 避免遞歸過深而堆棧太小
    限制遞歸深度,過線則中止遞歸。
    在堆上模擬實現一個函數調用棧,沒有了系統棧大小的限制。

由於有係數和常數因素,小規模數據的排序,O(exp(n))的排序算法並不必定比O(nlogn)排序算法執行的時間長。因此會選擇簡單、不須要遞歸的插入排序算法。

相關文章
相關標籤/搜索