這篇文章很長,我花了好久的時間(中間公司出了bug,加班了好幾天( ¯ ¨̯ ¯̥̥ ))進行整理,如有任何疑問,歡迎隨時留言。
關鍵字:排序算法,時間復雜度,空間復雜度
排序就是研究如何將一系列數據按照某種邏輯順序重新排列的一門算法。在計算機早期,排序要占用大量計算資源是人們的共識,而今天隨着機器性能的提高,以及排序算法的演進,排序已經非常高效,現在隨處都會提起數據的重要性,而整理數據的第一步就是排序。
引用自知乎:很多東西的難度,是隨着需求變化的。比如排序吧,10個數字,我可以給你人眼排序,
100個可以冒泡排序,學過c語言的大一學生,就能干,免費。100T的數字呢?你給我冒個泡試試?量變產生了質變,數據量的增大,讓本來可用的算法變得不可用,因為你找不到100T這么大內存,n2復雜度的冒泡排序讓排序時間變得不可接受。100T數據排序已經是各大公司炫耀技術的方式了。騰訊打破2016 Sort Benchmark 4項紀錄,98.8秒完成100TB數據排序,現在你告訴我,排序這個事兒簡不簡單?
綜上所述,排序是編程的基礎,每一名優秀的程序員都值得熟悉和掌握,今天我來總結一下。
首先介紹幾個基礎概念,曾經我們面向高考學習的時候都學過,只不過現在可能忘掉了,沒關系,下面我們重新介紹一下:
- 對數函數
在數學中用log表示,
log2^8 = 3
其中8是真數,2是對數的底,3是對數。我們都知道2的3次方等於8,對數函數相當於求2的幾次方等於8?
對數函數的表示還有幾個特殊情況,當底為10時,log可以表示為lg,同時省略底10
lg100 = 2
稱以無理數e(e=2.71828...)為底的對數稱為自然對數(natural logarithm),並記為ln。
- 時間復雜度
時間復雜度是定性的描述了一段程序的運行時間,
官方定義:算法中基本操作重復執行的次數是問題規模n的某個函數,用T(n)表示,若有某個輔助函數f(n),使得當n趨近於無窮大時,T(n)/f(n)的極限值為不等於零的常數,則稱f(n)是T(n)的同數量級函數。記作T(n)=O(f(n)),稱O(f(n))為算法的漸進時間復雜度(O是數量級的符號 ),簡稱時間復雜度。
講人話:算法中某個特定步驟的執行次數 / 對於總執行時間的估算成本,隨着「問題規模」的增大時,增長的形式。
時間復雜度使用大寫字母O來表示。
在長度為N的數組中,如果次數與N的大小無關,始終為一個固定的次數,或許是1次,或許是3次,它們都記為O(1),也叫常數階,一個遍歷就是O(N),也叫線性階,嵌套兩個遍歷就是 O(N^2),也叫平方階, 同樣的嵌套三個遍歷就是O(N^3 ),也叫立方階。
常見的時間復雜度如下圖:
若程序中包含一個嵌套兩個遍歷的函數,還有一個嵌套三個遍歷的函數,那就是O(N^2) + O(N^3) 當問題規模增大到無限大的時候,較小的分子一方可以忽略,按照數量級大的來,仍舊是O(N^3) 。
如果有二分分治,那就是O(log2^N) 。
如果一個遍歷嵌套一個二分,則是O(N*log2^N)。
- 空間復雜度
空間復雜度是指算法在執行過程中臨時占用內存的量度,空間復雜度仍舊使用大寫字母O來表示。一個算法的空間復雜度S(n)定義為該算法所耗費的存儲空間,它也是問題規模n的函數。
空間復雜度(Space Complexity)是對一個算法在運行過程中臨時占用存儲空間大小的量度,記做S(n)=O(f(n))。
在正無窮的“問題規模”時(n = +∞),時間復雜度和空間復雜度較低的程序的運行時間一定小於復雜度較高的程序。所以,時間復雜度和空間復雜度共同決定了程序的執行效率。
下面進入代碼階段。
我們先創建一個java工程sort,然后創建一個抽象類Sort。代碼如下:
package algorithms.sort;
import java.util.Random;
public abstract class Sort {
private int count;
protected int[] sort(int[] array) {
return null;
};
/**
* 互換位置的方法
*
* @param array
* 要換位置的目標數組
* @param i
* 數組位置1
* @param j
* 數組位置2
* @return 換好位置以后的數組
*/
protected int[] swap(int[] array, int i, int j) {
int t = array[i];
array[i] = array[j];
array[j] = t;
count++;
return array;
}
protected void show(int[] array) {
for (int a : array) {
System.out.println(a);
}
System.out.println("數組長度:" + array.length + ",執行交換次數:" + count);
}
public int[] getIntArrayRandom(int len, int max) {
int[] arr = new int[len];
Random r = new Random();
for (int i = 0; i < arr.length; i++) {
arr[i] = r.nextInt(max);
}
return arr;
}
/**
* 取得數組的最大值
*
* @param arr
* @return
*/
protected int max(int[] arr) {
int max = 0;
for (int a : arr) {
if (a > max)
max = a;
}
return max;
}
}
然后再創建一個客戶端Main,用來調用算法。代碼如下:
package algorithms.sort;
public class Main {
public static void main(String[] args) {
Sort s = new XXXSort();
int[] array = s.getIntArrayRandom(32, 120);
array = s.sort(array);
s.show(array);
}
}
其中XXXSort類就是我們接下來要介紹的十種排序算法。
冒泡排序
簡單來講,在一個長度為n的數組中,對每兩個相鄰的數進行比較,將數值較大的(或者比它小的)換到右側位置,遍歷一遍以后,第一個數已經換來換取換到了最適合它的位置k,同時,在[k,n]之間又可能會出現大於等於0個數被換來換去換到了最適合他們的位置。然后再從頭對每兩個相鄰的數進行比較,以此類推,直到將所有的數均換到了最適合他們的位置為止。
時間復雜度為:
T(n) = O(n^2)
空間復雜度為:
S(n) = O(1)
這里的空間復雜度只有在交換時的一個中轉臨時變量占用的內存空間,所以是O(1)。
具體實現代碼如下:
package algorithms.sort;
public class BubbleSort extends Sort {
public int[] sort(int[] array) {
for (int i = 0; i < array.length; i++) {
for (int j = 0; j < array.length - 1; j++) {
if (array[j] < array[j + 1]) {
array = swap(array, j, j + 1);
}
}
}
return array;
}
}
數組長度總共32, 執行交換次數:238
仔細想一下,會覺得這里面交換次數很多,隨着數組長度N的變大,無效交換的比重會越來越大,而且這里面的比較,有很多是重復的,例如在第一層循環第二遍執行時,很多已經找到自己在數組中合適位置的數仍舊參與比較,這些比較就是無意義的。
但是,冒泡這個思想卻是排序算法比較里程碑的,所以放在第一個進行介紹。
選擇排序
冒泡排序的優化,找到每個位置合適的數。例如,第一個位置,遍歷找出最小(或者最大)的一個數放在這,然后是第二個位置放第二小的數,以此類推,找到每個位置合適的值以后再進行交換,比起冒泡排序,降低了交換的次數,但是由於都是嵌套兩層循環,時間復雜度相同,空間復雜度也為O(1),原理同上。
package algorithms.sort;
public class SelectSort extends Sort {
public int[] sort(int[] array) {
for (int i = 0; i < array.length - 1; i++) {// 控制交換的次數,最多交換n-1次。
int maxIndex = i;
for (int j = i + 1; j < array.length; j++) {
if (array[j] > array[maxIndex]) {
maxIndex = j;
}
}
if (maxIndex != i) {// 找到當前位置后面最小值的位置,交換。
swap(array, maxIndex, i);
}
}
return array;
}
}
數組長度總共32, 執行交換次數:27
相同的數組,選擇排序的交換次數為27(這里最多不超過31次),而冒泡排序是238,可以看出,選擇排序的交換次數大大減小,如果在問題規模n為正無窮的時候,這個交換次數如果很小的話可以大大降低磁盤的I/O操作,而獲得相同的排序結果。所以選擇排序雖然在時間復雜度和空間復雜度均與冒泡排序相同,但是它在I/O的表現上非常出色。
插入排序
插入排序是首先將第一個數字當做一個已有序的新數組,(數組里面只有一個數字,肯定算有序)從第二個數字開始,將其與數組已有元素(從最右側開始比)進行比較,然后插入到該新數組中適合的位置。
例如打撲克,摸牌階段的碼牌動作,第一張摸過來,不動(已有序),第二張摸過來,跟第一張牌比較一下,如果比它大就插到第一張的前面,小則插到后面,第三張摸過來,先跟第二張比較一下,如果比它大就再跟第一張牌比較,如果比它們都大就插到第一張牌的前面(前面再也沒有牌了,不用比了,就是下面代碼中的j>0終止條件)。而如果不比第一張牌大,就保留插入到第二張的前面,而此時第二張在它與剛摸到的這張牌比較完的時候就已經成為了第三個位置的牌了。以此類推,代碼如下。
package algorithms.sort;
public class InsertSort extends Sort {
public int[] sort(int[] array) {
// 從第二張牌開始比較
for (int i = 1; i < array.length; i++) {
int target = array[i];
int j = i;
// 如果比前一個大,就把前一個放到當前目標牌的位置,把前一個的位置空出來,然后繼續跟更前一個比較,循環去找到最准確的目標位置
while (j > 0 && target > array[j - 1]) {
array[j] = array[j - 1];
j--;
}
// 在目標位置的插入操作
array[j] = target;
}
return array;
}
}
數組長度總共32, 執行交換次數:0
上面兩種排序算法都是采用交換位置的方式,而插入排序是采用插入的方式,沒有發生交換操作,所以交換次數為0。
插入排序的比較次數與選擇排序差不多,只不過選擇排序是不斷與當前位置后面的元素比較,而插入排序是不斷地與當前元素前面的比。按照最壞的打算,每一次跟前面的牌比較都是要比到第一位為止,也就是說本身數組就是一個有序數組,利用該排序更換排序方式時,會發生這種最壞情況。
假設數組的長度為N,第一層循環的第一次操作是執行1次,第二次操作是執行2次,直到第N-1次操作是執行N-1次,那么總次數為一個等差數列求和,即N*(N-1)/2,當問題規模擴大到無窮大時,小數量級的加減可以忽略,同時原分母2可以忽略不計,最終時間復雜度仍舊為O(N^2),空間復雜度也為O(1),原理同上。
快速排序
快速排序是最流行的排序算法,效率是比較高的。它是基於冒泡排序,但略比冒泡排序復雜,基本思想為二分分治,將一個數組按照一個基准數分割,比基准數小的放基准數的右邊,大的放在左邊。這就需要定義兩個數組下標變量,分別從數組的兩頭開始比較換位,最終在數組的中間位置相遇,然后在基准數的左邊和右邊再遞歸執行這個分割法即可,代碼如下。
package algorithms.sort;
public class QuickSort extends Sort {
public int[] sort(int[] array) {
return quickSort(array, 0, array.length - 1);
}
/**
* 分割的方法
*
* @param array
* @param left
* [left, right]
* @param right
* @return 兩情相悅的位置
*/
private int partition(int[] array, int left, int right) {
int pivot = array[left];// 定義基准數
int pivotIndex = left;// 保存基准數的位置
while (left < right) {// 直到中間相遇為止
while (left < right && array[right] <= pivot)// 在右側找到第一個比基准數大的
right--;
while (left < right && array[left] >= pivot)// 在左側找到第一個比基准數小的
left++;
swap(array, left, right);// 互換上面找到的第一個比基准數大的和第一個比基准數小的位置
}
swap(array, pivotIndex, left);// 最后交換基准數到兩情相悅的位置(不一定是中間)。
return left;
}
/**
* 一到遞歸別迷糊:用於遞歸的方法quickSort,而非partition
*
* @param array
* @param left
* [left,right]
* @param right
* @return
*/
private int[] quickSort(int[] array, int left, int right) {
if (left >= right)// 遞歸的終止條件,這是必要的。
return array;
int pivotIndex = partition(array, left, right);// 初次分割
quickSort(array, left, pivotIndex - 1);// 快速排序基准數左邊的數組
quickSort(array, pivotIndex + 1, right);// 快速排序基准數右邊的數組
return array;
}
}
數組長度總共32, 執行交換次數:63
交換次數為中游表現。
時間復雜度為:
T(n) = O(n*log2^n)
-
最好情況:
快速排序使用了二分法,如果恰好每一次分割都正好將基准數擺在了中央位置,也就是恰好對半分,這時它的第二層嵌套次數為log2^N ,所以完整的時間復雜度為O(n*log2^n)。
-
最壞情況:
當每一次二分操作,有一側只分到了一個元素,而另一側是N-1個元素,那就是最壞的情況,即為第二層嵌套的次數仍為N,那么時間復雜度就是O(N^2)。
快速排序的空間復雜度很高,因為要二分分治,會占用log2^N 的臨時空間去操作,同時還有快排的遞歸是與數組的長度相同,所以最終快速排序的空間復雜度為:
S(n) = O(n*log2^n)
切分要占用N個新的臨時空間,排序比較又要占用log2^N ,所以完整的空間復雜度為O(n*log2^n)。
快速排序法就是集合了冒泡、二分分治和遞歸的思想。
堆排序
-
先來介紹堆的定義
這里的堆指的是數據結構中的“二叉堆”。二叉堆一般是通過數組來表示,每個元素都要保證大於等於另兩個特定位置的元素。轉化成二叉樹的結構就是一個完全二叉樹,若每個節點都小於等於它的父節點,這種結構就叫做“大頂堆”,也叫“最大堆”,而反過來,每個節點都大於等於它的父節點,這就是“小頂堆”,也叫“最小堆”。
-
再說一下堆的特性
在一個長度為N的堆中,位置k的節點的父節點的位置為k/2,它的兩個子節點的位置分別為2k和2k+1,該堆總共有N/2個父節點。
-
修復堆有序的操作
當堆結構中出現了一個打破有序狀態的節點,若它是因為變得比他的父節點大而打破,那么就要通過將其互換到更高的位置來修復堆,這個過程叫做由下而上的堆有序化,也叫 “上浮”。反過來,就是由上至下的堆有序化,也叫“下沉”。
-
堆排序的原理
堆排序是選擇排序的延伸。根據堆的結構來看,就像一個三角形,根節點是唯一一層僅有一個數的節點,而同時它又必然是最大或者最小的一個。那么將該根節點取出來,然后修復堆,再取出修復后的根節點,以此類推,最終就會得到一個有序的數組。
-
堆排序的工作
堆排序總共分兩步:
- 無序數組 -> 使堆有序
- 取出根節點 -> 使堆有序
- 代碼如下:
package algorithms.sort;
public class HeapSort extends Sort {
/**
* 下沉操作,讓k的值array[k]下沉到合適位置,往下走就要跟子節點比較
* k位置的數值打破了大頂堆有序狀態,說明其比子節點還小,這時就要將k與較大的子節點互換位置
* (不用考慮比父節點大的問題,因為循環到檢查父節點的時候,依舊可以采用其比子節點小的邏輯)
* 7
* / \
* 6 3
* / \ / \
* 4 5 1 2
*
* @param array
* @param k
* 目標位置
* @param right
* 區間[k,right]
*/
private void sink(int[] array, int k, int right) {
// 循環終止條件1:左子並不存在,說明k目前已為葉節點,無處可沉
while (2 * k + 1 <= right) {
int bigChildIndex = 2 * k + 1;// left child index:2 * k + 1,right
// child index:2 * k + 2
// 如果有右子節點,且右子大於左子
if (2 * k + 2 <= right && array[2 * k + 1] < array[2 * k + 2])
bigChildIndex = 2 * k + 2;
if (array[k] > array[bigChildIndex])
// 循環終止條件2:k的值所處位置已堆有序,無處可沉,也就是說比他的子節點(一個或者兩個子節點)都大
break;
swap(array, k, bigChildIndex);// 下沉,交換k和bigChildIndex
k = bigChildIndex;// 位置k已經換到了bigChildIndex
}
}
/**
* 上浮操作:讓目標位置的值上浮到合適位置,往上走就要跟父節點比較
* k位置的數值打破了小頂堆有序狀態,說明其比父節點還小,這時就要將k與其父節點互換位置
* (不用考慮比子節點大的問題,因為循環到檢查子節點的時候,依舊可以采用其比父節點小的邏輯)
* 相對與下沉操作,上浮操作比較簡略的原因是k只需要與一個父節點比較大小,而下沉操作則需要跟一個或兩個子節點比較大小,多出的是這部分邏輯
* 1
* / \
* 2 5
* / \ / \
* 4 3 6 7
*
* @param array
* @param k
* 區間[0,k]
*/
private void swim(int[] array, int k) {
if (k == 0)
return;// k的位置已經是根節點了,不需要再上浮了。
// @@@@
// 終止條件:k不斷往父節點一層層地爬或許能爬到根節點(k==0),或許中途k找到了比父節點大的位置,根據小頂堆規則,它就已經堆有序。
while (k > 0 && array[k] < array[(k - 1) / 2]) {// k的父節點:(k - 1) / 2
swap(array, k, (k - 1) / 2);// 上浮
k = (k - 1) / 2;// k換到了它的父節點的位置
}
}
/**
* 堆排序:下沉堆排序 注意:通過下沉操作可以得到大頂堆也可以得到小頂堆,這里只采用一種情況來介紹。
*
* @param array
* @return 從小到大排序
*/
private int[] sinkSort(int[] array) {
int maxIndex = array.length - 1;// 數組array,區間為 [0,maxIndex]
// 構造堆
int lastParentIndex = (maxIndex - 1) / 2;// 最后一個父節點位置
// @@@@如果使用下沉操作,一定要從最后一個父節點開始往根節點倒序檢查,才能保證最大值被送到根節點@@@@
for (int i = lastParentIndex; i >= 0; i--) {// 區間[0,lastParentIndex]為當前數組的全部父節點所在
sink(array, i, maxIndex);// 區間[lastParentIndex,maxIndex],從最后一個父節點開始檢查,下沉操作,調整堆有序
}
System.out.println("the max one is " + array[0]);
// 獲得排序(注意:堆有序!=堆排序,堆有序只能保證根節點是最值,而不能保證子節點及樹枝節點同級間的大小順序)
while (maxIndex > 0) {
swap(array, 0, maxIndex--);// 取出最大值
sink(array, 0, maxIndex);// 修復堆
}
return array;
}
/**
* 堆有序:通過上浮操作,使堆有序
*
* @param array
* @param len
* 整理[0,len]區間的堆有序
*/
private void headAdjustBySwim(int[] array, int len) {
// @@@@如果使用上浮操作,一定要從最后一個葉節點開始,到根節點位置檢查,才能保證最小值被送到根節點@@@@
for (int i = len; i > 0; i--) {// i不需要檢查=0的情況,因為根節點沒有父節點了。
swim(array, i);// 區間[0,i],從最后一個葉節點開始檢查,上浮操作,調整堆有序
}
}
/**
* 堆排序:上浮堆排序 注意:通過上浮操作可以得到大頂堆也可以得到小頂堆,這里只采用一種情況來介紹。
*
* @param array
* @return 從大到小排序
*/
private int[] swimSort(int[] array) {
int maxIndex = array.length - 1;// 數組array,區間為 [0,maxIndex]
headAdjustBySwim(array, maxIndex);
System.out.println("the min one is " + array[0]);
// 獲得排序(注意:堆有序!=堆排序,堆有序只能保證根節點是最值,而不能保證子節點及樹枝節點同級間的大小順序)
while (maxIndex > 0) {
swap(array, 0, maxIndex--);// 取出最小值
headAdjustBySwim(array, maxIndex);
}
return array;
}
@Override
protected int[] sort(int[] array) {
return swimSort(array);
}
}
上浮操作:數組長度:32,執行交換次數:185
下沉操作:數組長度:32,執行交換次數:139
通過結果可以看出,下沉操作的執行交換次數是較少的,因為下沉操作的目標位置只是所有的父節點,而上浮操作要遍歷整個數組。所以,看上去,下沉操作效率會更高一些。
根據以上代碼總結一下:
我們使用的數組舉例如:{1, 12432, 47, 534, 6, 4576, 47, 56, 8}
-
將這些數組按原有順序擺成完全二叉樹的形式(注意二叉樹,完全二叉樹,滿二叉樹的定義,條件是逐漸苛刻的,完全二叉樹必須每個節點都在滿二叉樹的軌跡上,而深度為N的滿二叉樹必須擁有2N-1個節點,不多不少。)
-
將該二叉樹轉換成二叉堆,最大堆或者最小堆均可
-
取出當前二叉樹的根節點,然后用最后一個位置的元素來作為根節點,打破了二叉堆的有序狀態。
-
堆有序修復
-
重復3.4步,直到數組取完全部元素為止
堆排序的時間復雜度為:
T(n) = O(n*log2^n)
空間復雜度也為O(1),原理同上。
-
注意:
下沉和上浮均可以處理無論是大頂堆還是小頂堆,他們並沒有綁定關系。大頂堆時上浮可以是最后一個葉節點比父節點要大,所以上浮,下沉是最后一個父節點比子節點要小,所以下沉。小頂堆時就是反過來。另外,編寫代碼時要注意數組下標是從0開始,要細心處理一下。
希爾排序
希爾是個人,是希爾排序的發明者。
這是我覺得非常精巧的方案。
首先用圖來表示一下希爾排序的中心思想:
這張圖可以清晰地展示希爾排序的思路。
具體代碼如下:
package algorithms.sort;
public class ShellSort extends Sort {
@Override
protected int[] sort(int[] array) {
int lastStep = 0;// 控制循環次數,保存上一個step,避免重復
for (int d = 2; d < array.length; d++) {
int step = array.length / d;
if (lastStep != step) {
lastStep = step;
System.out.println("step: " + step);// 監控step,shellSort執行次數
shellSort(array, step);
} else {
continue;
}
}
return array;
}
private void shellSort(int[] array, int step) {
for (int i = 0; i < array.length - step; i++) {
if (array[i] < array[i + step]) {
swap(array, i, i + step);
}
}
}
}
數組長度:32,執行交換次數:56
希爾排序是精巧的,從交換次數上面來看表現也可以。
原理也是非常易懂,我很喜歡這種深入淺出的算法。
希爾排序的分析是復雜的,時間復雜度是所取增量的函數,這涉及一些數學上的難題。但是在大量實驗的基礎上推出當n在某個范圍內時,時間復雜度可以達到O(n^1.3),,空間復雜度也為O(1),原理同上。
歸並排序
歸並排序的操作有些像快速排序,只不過歸並排序每次都是強制從中間分割,遞歸分割至不可再分(即只有兩個元素),將分割后的子數組進行排序,然后相鄰的兩個子數組進行合並,新建一個數組用來存儲合並后的有序數組並重新賦值給原數組。
package algorithms.sort;
public class MergeSort extends Sort {
private int[] temp;
@Override
protected int[] sort(int[] array) {
temp = new int[array.length];// 新建一個與原數組長度相同的空的輔助數組
mergeSort(array, 0, array.length - 1);
return array;
}
/**
* 一到遞歸別迷糊:用於遞歸的方法MergeSort,而非merge
*
* @param array
* @param left
* @param right
*/
private void mergeSort(int[] array, int left, int right) {
if (left >= right)// 已經分治到最細化,說明排序已結束
return;
int mid = (right + left) / 2;// 手動安排那個兩情相悅的位置,強制為中間。ㄟ(◑‿◐ )ㄏ
mergeSort(array, left, mid);// 左半部分遞歸分治
mergeSort(array, mid + 1, right);// 右半部分遞歸分治
merge(array, left, mid, right);// 強制安排兩情相悅,就要付出代價:去插手merge他們的感情。( ͡°͜ʖ°)✧
}
/**
* 通過輔助數組,合並兩個子數組為一個數組,並排序。
*
* @param array
* 原數組
* @param left
* 左子數組 [left, mid];
* @param mid
* 那個被強制的兩情相悅的位置。(ಠ .̫.̫ ಠ)
* @param right
* 右子數組 [mid+1, right]
*/
private void merge(int[] array, int left, int mid, int right) {
for (int k = left; k <= right; k++) {// 將區間[left,right]復制到temp數組中,這是強硬合並,並沒有溫柔的捋順。
temp[k] = array[k];
}
int i = left;
int j = mid + 1;
for (int k = left; k <= right; k++) {// 通過判斷,將輔助數組temp中的值按照大小歸並回原數組array
if (i > mid)// 第三步:親戚要和藹,左半邊用盡,則取右半邊元素
array[k] = temp[j++];// 右側元素取出一個以后,要移動指針到其右側下一個元素了。
else if (j > right)// 第四步:與第三步同步,工作要順利,右半邊用盡,則取左半邊元素
array[k] = temp[i++];// 同樣的,左側元素取出一個以后,要移動指針到其右側下一個元素了。
else if (array[j] > temp[i])// 第一步:性格要和諧,右半邊當前元素大於左半邊當前元素,取右半邊元素(從大到小排序)
array[k] = temp[j++];// 右側元素取出一個以后,要移動指針到其右側下一個元素了。
else// 第二步:與第一步同步,三觀要一致,左半邊當前元素大於右半邊當前元素,取左半邊元素(從大到小排序)
array[k] = temp[i++];// 同樣的,左側元素取出一個以后,要移動指針到其右側下一個元素了。
}
}
}
數組長度:32,執行交換次數:0
由於全程代碼中並沒有涉及交換操作,所以交換次數為0。
歸並排序的空間復雜度很高,因為它建立了一個與原數組同樣長度的輔助數組,同時要對原數組進行二分分治,所以空間復雜度為
S(n) = O(n*log2^n)
時間復雜度比較低,與空間復雜度的計算方式差不多,也為O(n*log2^n) 。
歸並排序是一種漸進最優的基於比較排序的算法。但是它的空間復雜度很高,同時也是不穩定的,當遇到最壞情況,也即每次比較都發生數據移動時,效率也不高。
計數排序
直接上代碼,注釋里面說:
package sort;
public class CountingSort extends Sort {
@Override
protected int[] sort(int[] array) {
countingSort(array);
return array;
}
/*
* 計數排序
*
* @example [1,0,2,0,3,1,1,2,8] 最大值是8,建立一個計數數組a[]統計原數組中每個元素出現的次數,長度為9(因為是從0到8)
*
* @開始計數:第一個統計0的次數為2,則a[0]=2;第二個統計1的次數為3,則a[1]=3;第三個按照數組下標以此類推,最終獲得一個統計數組。
*
* @開始排序:因為按照統計數組的下標,已經是有順序的,只要循環輸出每個重復的數就可以了。
*/
private void countingSort(int[] array) {
int max = max(array);
// 開始計數
int[] count = new int[max + 1];
for (int a : array) {
count[a]++;
}
// 輸出回原數組
int k = 0;
for (int i = 0; i < count.length; i++) {
for (int j = 0; j < count[i]; j++) {
array[k++] = i;
}
}
}
}
數組長度總共32, 執行交換次數:0
這個計數排序算法也挺巧妙,他巧妙地應用了數組下標本身的順序性,將下標當做參照物去比對原數組,把與下標相同的數字出現的次數記錄到該下標的值中。
然后再遍歷計數數組,按次數循環輸出數字到原數組,即可得到一個有序數組。
時間復雜度
T(n) = O(n)
計數排序算法的最大優勢,是他的時間復雜度很小,遠小於其他基於比較的排序算法。
下面說一下這個時間復雜度是如何計算出來的,整段代碼中只有一個嵌套循環,其他的都是一層循環,也就是O(n)。觀察這個兩層的循環可以發現,如果count數組長度為1,只有一個數字,但是原數組長度為100,那么這個兩層循環只是遍歷100次,仍舊是O(n),而如果count數組長度為100,里面並沒有重復的數字出現,那么第二層循環只循環一次,仍舊是O(n),從這兩頭的極端情況可以發現,這個兩層嵌套很好理解,無論原數組是什么結構,他的時間復雜度不會變,仍舊是O(n)。
空間復雜度
S(n) = O(X)
而計數排序的空間復雜度則較高,因為他有一個輔助數組count,這個數組會根據原數組內部元素的重復情況開辟新的內存空間。輔助數組的大小完全取決於原數組的最大值,最大值如果非常大的話,輔助數組也就變得非常長,那空間復雜度會很高,原數組的最大值並不大,那么空間復雜度就不高。而原數組的最大值是無法通過長度N來衡量的,所以計數排序的空間復雜度無法給出。
桶排序
桶排序是一種高級排序算法,是比以上各種的更優化的一種排序算法。基於比較的排序算法的時間復雜度的下限是O(n*log2^n ),不會比這個更小了。但是確實存在更快的算法,這些算法不是不使用比較,而是使用某些限定條件,讓參與比較的操作盡可量減小。桶排序是這樣的,它的原理與計數排序很像,但更復雜。
桶排序的基本思想:建立一個輔助桶序列,通過某種限定關系,它是一種映射函數f(n)將原數組的元素分配到輔助桶中,每個輔助桶可以存一個數組,最終這個桶序列會按照f(n)把原數組的元素全部存入進來。然后針對每個桶內部的數組進行比較排序,可以選擇以上屬於比較排序中的任一種,這里我們選擇使用快速排序。最后,把輔助桶序列內的元素按順序輸出到原數組內即可。
用上面的計數算法來解釋:就是那個輔助數組的每個下標不再存儲單個數字的重復次數了,而是在存按照f(n)分配后的大於0個的元素,通俗來講,就是計數算法中的輔助數組的每個下標現在開始存數組了,這個下標現在就是一個桶,是一個數組,計數排序中的輔助數組現在是一個數組序列,多個數組的集合。
桶排序准備:
1.我們需要一個輔助數組集合。因為數組必須先指定長度,所以這里用自適應大小的List<Integer>來存儲數組的元素,作為一個桶,桶集合為List<List<Integer>>
2.一個映射函數f(n)
代碼如下:
package sort;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public class BucketSort extends Sort {
private List<List<Integer>> buckets = new ArrayList<List<Integer>>();
private int optimizeDivisor = 0;
private int bucketNum = 100;// 這個桶個數要提前定義
private int divisor = 1;
private int f(int n) {
return n / divisor;
}
@Override
protected int[] sort(int[] array) {
bucketSort(array);
System.out.println("divisor=" + divisor + ", 桶排序優化程度:" + optimizeDivisor);
return array;
}
private void bucketSort(int[] arr) {
divisor = max(arr) / bucketNum + 1;
for (int i = 0; i < bucketNum; i++) {
buckets.add(new LinkedList<Integer>());
}
for (int a : arr) {
buckets.get(f(a)).add(a);
}
int k = arr.length - 1;
for (int i = 0; i < bucketNum; i++) {
if (!buckets.get(i).isEmpty()) {
optimizeDivisor++;
List<Integer> list = buckets.get(i);
int[] bucket = new int[list.size()];
for (int j = 0; j < list.size(); j++) {
bucket[j] = list.get(j);
}
Sort quickSort = new QuickSort();
bucket = quickSort.sort(bucket);// 如果是從小到大排序,那就正序插入,反之從大到小則倒序插入
for (int j = bucket.length - 1; j >= 0; j--) {
arr[k--] = bucket[j];
}
}
}
}
}
divisor=1, 桶排序優化程度:29(這個值越大越好,越大說明快排參與的越少)
數組長度總共32, 執行交換次數:0
從代碼中可以看出,桶排序是上面其他的比較排序的一個優化算法。但是桶的數量要根據原數組的取值范圍去提前計算好,因為桶的數量越多,原數組的值越能平均分配到每個桶中去,相應的快速排序參與的部分就越少,時間復雜度就越低,但是空間復雜度就會越高,這是一個空間換時間的權衡。
桶的數量和每個桶的區間最好是能夠隔離開原數組出現頻率非常高的元素們,最大個數不要超過原數組最大元素的值,因為超過了將會有很多空桶,我們追求每個桶內元素盡量少的同時,又要追求整個桶集合中空桶數量盡量少。
所以使用桶排序要把握好桶個數和f(n)映射函數,將會大大提高效率。這很糾結,I know.<(▰˘◡˘▰)>
時間復雜度,最優情況就是每個桶都最多有一個元素,那么就完全不需要比較排序了,時間復雜度為O(n)。最壞情況就是只有一個桶擁有了所有原數組的元素,然后這個桶要完全使用比較排序去做,那么再趕上原數組的數值情況在那個比較排序算法里也是最壞情況,那時間復雜度可以達到O(n^2)
空間復雜度就不要說了,桶排序就是一個犧牲空間復雜度的算法。
基數排序
基數排序的中心思想:每次只比較原數組元素的一位數,將順序記錄下來,然后再比較下一位數,逐漸讓數組有序起來,比較的位數是從小到大的。
如圖所示:
准備工作:
- 我們需要一個集合,因為是動態大小,仍舊采用 List<List<Integer>>
- 需要一個可以獲得某數字的某一位數的方法
- 要獲得該數組的最大位數
- 分配方法:將數組中的元素按照某一位數的拆分分配到集合中去
- 收集方法:將集合中的元素按順序傳回數組
代碼如下:
package sort;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
public class RadixSort extends Sort {
@Override
protected int[] sort(int[] array) {
return radixSort(array);
}
private int[] radixSort(int[] arr) {
int maxBit = String.valueOf(max(arr)).length();// 獲得最大位數
for (int i = 1; i <= maxBit; i++) {// 最大位數決定再分配收集的次數
List<List<Integer>> list = new ArrayList<List<Integer>>();
// list 初始化,位數的值無外乎[0,9],因此長度為10
for (int n = 0; n < 10; n++) {
list.add(new LinkedList<Integer>());
}
// 分配
for (int a : arr) {
list.get(getBitValue(a, i)).add(a);// 將原數組的元素的位數的值作為下標,整個元素的值作為下標的值
}
// 收集
int k = 0;
for (int j = 0; j < list.size(); j++) {
if (!list.get(j).isEmpty()) {// 加一層判斷,如果list的某個元素不為空,再進入下面的元素內部循環
for (int a : list.get(j)) {
arr[k++] = a;
}
}
}
}
return arr;
}
/**
* 獲得某數字的某一個位的值,例如543的十位數為4
*
* @param target
* 待處理數字
* @param BitNum
* 從右向左第幾位數,例如142512,BitNum為3的話,對應的值為5
* @return
*/
private int getBitValue(int target, int BitNum) {
String t = String.format("%" + BitNum + "d", target).replace(" ", "0");// 如果位數不夠,則用0補位
return Integer.valueOf(String.valueOf(t.charAt(t.length() - BitNum)));
}
}
數組長度總共32, 執行交換次數:0
這是一個按照最大位數不斷分配收集的過程,並不基於比較,也不是交換,如同上面的計數排序,分配時也是將位數的值作為下標,只是不再存儲元素重復出現的次數,而是存儲該位數相同的值們,有些繞,可以結合基數排序與計數排序的代碼慢慢理解。
舉例說明:數組在第一次分配收集以后,元素按照個位數的大小被划分出來,再經歷第二次分配收集以后,元素在上一次數組的處理結果之上繼續按照十位數的大小被划分出來,以此類推,最終,會按照元素進入數組的順序獲得一個有序數組。
時間復雜度:基數排序的時間復雜度計算比較復雜,我們通過代碼進行分析,首先是按照最大位數進行循環,這個最大位數很難去定義,它不是數組的長度N,而是要找出最大值然后判斷最大值的位數,這是與N無關的,例如數組{1,100001},N為2,但是最大位數為6,這個在時間復雜度中很難表現,就記錄為O(X)吧。接着又嵌套了一層,這一層中有三個循環,第一個循環是確定次數的,就是10次,因為是從0到9,確定的;第二個循環是按照數組的長度N,所以這里是O(N);第三個循環是按照集合的大小循環,其實也是數組的長度,仍為O(n)。所以如果N>10,在問題規模的增大下,可以忽略那個10次的循環,時間復雜度就是
T(n) = O(n*X)
如果N<10的話,時間復雜度就是
T(n) = O(10*X)
空間復雜度:這些基於非比較的排序都是比較消耗空間的,因為都需要一個輔助集合,這個集合占用的空間與原數組一致。所以是
S(n) = O(n)
總結
以上十種排序算法介紹完畢,下面對於他們的思路進行一個歸納,如下圖所示:
選擇排序:
交換:
冒泡排序和快速排序都是基於比較+交換的;
選擇排序和堆排序都是基於選擇+交換的;
插入排序和希爾排序都是基於比較+插入的;
歸並排序是基於比較+合並
非選擇排序:
桶排序、計數排序、基數排序
在我們的日常編程之中,很多程序設計語言已經內置了排序方法供我們直接調用,但我們也會遇到親自去使用他們的時候,平時我們使用的快速排序的概率比較大,這並不代表其他排序算法就是無用的,基於不同的數值情況,選擇不同的排序算法,達到最優的效率,幫助整個系統更加高效的運轉,這是算法給我們帶來的,不是純靠堆積硬件配置換來的效率,而是靠知識。。。知識是第一生產力 (๑˘ ˘๑) ,而如果你是一名算法工程師,恐怕這些算法你要研究的比程序員要透徹很多,甚至要研究更復雜更適用於你們業務情況的排序算法。在研究這些里程碑的算法時,我們能夠發現他們的作者都有着非常創造性的思維,想方設法去尋找更加高效的排序算法,這些大師的作品將被記錄成經典,永遠在程序員界流傳着他們的思想。
文章所有源碼位置
參考資料
- 《數據結構》嚴蔚敏 吳偉民 編著
- 《算法 第四版》
- 網上大牛們的帖子,以及他們的圖 (●'◡'●)ノ♥