數據結構與算法之 “排序”

一、概述

排序算法中最經典的、最常用的:冒泡排序、插入排序、選擇排序、歸併排序、快速排序、計數排序、基數排序、桶排序。
在這裏插入圖片描述

1.1、如何分析一個“排序算法”?

學習排序算法,除了學習它的算法原理、代碼實現之外,更重要的是要學會如何評價、分析一個排序算法。下面是衡量的幾個方面。

  • 排序算法的執行效率
    • 最好情況、最壞情況、平均情況時間複雜度
      在分析排序算法的時間複雜度時,要分別給出最好情況、最壞情況、平均情況下的時間複雜度。除此之外,還要說出最好、最壞時間複雜度對應的要排序的原始數據是什麼樣的。
      爲什麼要區分這三種時間複雜度呢?
      第一,有些排序算法會區分,爲了好對比,所以我們最好都做一下區分。第二,對於要排序的數據,有的接近有序,有的完全無序。有序度不同的數據,對於排序的執行時間肯定是有影響的,我們要知道排序算法在不同數據下的性能表現。
    • 時間複雜度的係數、常數 、低階
      時間複雜度反應的是數據規模 n 很大的時候的一個增長趨勢,所以它表示的時候會忽略係數、常數、低階。但是實際的軟件開發中,我們排序的可能是 10 個、100 個、1000 個這樣規模很小的數據,所以,在對同一階時間複雜度的排序算法性能對比的時候,我們就要把係數、常數、低階也考慮進來。
    • 比較次數和交換(或移動)次數
      基於比較的排序算法的執行過程,會涉及兩種操作,一種是元素比較大小,另一種是元素交換或移動。所以,如果我們在分析排序算法的執行效率的時候,應該把比較次數和交換(或移動)次數也考慮進去。
  • 排序算法的內存消耗
    算法的內存消耗可以通過空間複雜度來衡量,排序算法也不例外。不過,針對排序算法的空間複雜度,我們還引入了一個新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空間複雜度是 O(1) 的排序算法。
  • 排序算法的穩定性
    如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序不變。這種排序算法叫作穩定的排序算法;如果前後順序發生變化,那對應的排序算法就叫作不穩定的排序算法。
    爲什麼要考察排序算法的穩定性呢?
    在真正軟件開發中,我們要排序的往往不是單純的整數,而是一組對象,我們需要按照對象的某個 key 來排序。比如先對對象的某個元素排序,然後再對另一個元素排序,只有使用排序算法纔是有意義的。

二、排序算法

首先看看三種時間複雜度爲 O(n2) 的排序算法,冒泡排序、選擇排序,可能就純粹停留在理論的層面了,學習的目的也只是爲了開拓思維,實際開發中應用並不多,但是插入排序還是挺有用的。
但是實現代碼都非常簡單,對於小規模數據的排序,用起來非常高效。但是在大規模數據排序的時候,這個時間複雜度還是有點高,

2.1、冒泡排序(Bubble Sort)

冒泡這個詞用的很生動,每一輪使最值像水泡一樣冒出來,剩下的值繼續冒泡,每一輪都能得到剩下值的最值,n次後就能將所有的數據排序。

冒泡排序只會操作相鄰的兩個數據。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關係要求。如果不滿足就讓它倆互換。一次冒泡會讓至少一個元素移動到它應該在的位置,重複 n 次,就完成了 n 個數據的排序工作。

當然冒泡排序可以優化:當某次冒泡操作已經沒有數據交換時,說明已經達到完全有序,不用再繼續執行後續的冒泡操作
代碼實現:

// 冒泡排序,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(n2n^2)。

2.2、插入排序(Insertion Sort)

一個有序的數組,我們往裏面添加一個新的數據後,如何繼續保持數據有序呢?
很簡單,我們只要遍歷數組,找到數據應該插入的位置將其插入即可。
這是一個動態排序的過程,即動態地往有序集合中添加數據,我們可以通過這種方法保持集合中的數據一直有序。而對於一組靜態數據,我們也可以借鑑上面講的插入方法,來進行排序,於是就有了插入排序算法。

插入排序具體是如何藉助上面的思想來實現排序的呢?

首先,我們將數組中的數據分爲兩個區間,已排序區間和未排序區間。初始已排序區間只有一個元素,就是數組的第一個元素。插入算法的核心思想是取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入,並保證已排序區間數據一直有序。重複這個過程,直到未排序區間中元素爲空,算法結束。

比如:要排序的數據是 4,5,6,1,3,2,其中左側爲已排序區間,右側是未排序區間。
在這裏插入圖片描述
插入排序也包含兩種操作,一種是元素的比較,一種是元素的移動。當我們需要將一個數據 a 插入到已排序區間時,需要拿 a 與已排序區間的元素依次比較大小,找到合適的插入位置。找到插入點之後,我們還需要將插入點之後的元素順序往後移動一位,這樣才能騰出位置給元素 a 插入。

對於不同的查找插入點方法(從頭到尾、從尾到頭),元素的比較次數是有區別的。但對於一個給定的初始序列,移動操作的次數總是固定的,就等於逆序度。

// 插入排序,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(n2n^2)。

2.3、選擇排序(Selection Sort)

選擇排序算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。

在這裏插入圖片描述
選擇排序空間複雜度爲 O(1),是一種原地排序算法。選擇排序的最好情況時間複雜度、最壞情況和平均情況時間複雜度都爲 O(n2)。選擇排序是一種不穩定的排序算法。從前面畫的那張圖中,選擇排序每次都要找剩餘未排序元素中的最小值,並和前面的元素交換位置,這樣破壞了穩定性。正是因此,相對於冒泡排序和插入排序,選擇排序就稍微遜色了。

2.4、爲什麼插入排序要比冒泡排序更受歡迎呢?

冒泡排序和插入排序的時候講到,冒泡排序不管怎麼優化,元素交換的次數是一個固定值,是原始數據的逆序度。插入排序是同樣的,不管怎麼優化,元素移動的次數也等於原始數據的逆序度。
從代碼實現上來看,冒泡排序的數據交換要比插入排序的數據移動要複雜,冒泡排序需要 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(n2),但是如果希望把性能優化做到極致,那肯定首選插入排序。

2.4、歸併排序(Merge Sort)

歸併排序的核心思想還是蠻簡單的。如果要排序一個數組,我們先把數組從中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合並在一起,這樣整個數組就都有序了。
在這裏插入圖片描述

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