万字详解|手撕 9大排序算法!
0. 前言
大家好,我是多選參數(shù)的程序鍋,一個正在搗鼓操作系統(tǒng)、學(xué)數(shù)據(jù)結(jié)構(gòu)和算法以及 Java 的失業(yè)人員。數(shù)據(jù)結(jié)構(gòu)和算法我已經(jīng)學(xué)了有一段日子了,最近也開始在刷 LeetCode 上面的題目了,但是自己感覺在算法上還是 0 ,還得猛補啊。
今天這篇基于之前的 8 大排序算法基礎(chǔ)之上,增加一個堆排序,也就是這么 9 個排序算法:冒泡排序、插入排序、選擇排序、歸并排序、快速排序、堆排序、桶排序、計數(shù)排序、基數(shù)排序。它們對應(yīng)的時間復(fù)雜度如下所示:
| 冒泡、插入、選擇 | O(n^2) | √ |
| 快排、歸并、堆排序 | O(nlogn) | √ |
| 桶、計數(shù)、基數(shù) | O(n) | × |
整篇文章的主要知識提綱如圖所示,本篇相關(guān)的代碼都可以從 https://github.com/DawnGuoDev/algorithm 獲取,另外,該倉庫除了包含了基礎(chǔ)的數(shù)據(jù)結(jié)構(gòu)和算法實現(xiàn)之外,還會有數(shù)據(jù)結(jié)構(gòu)和算法的筆記、LeetCode 刷題記錄(多種解法、Java 實現(xiàn)) 、一些優(yōu)質(zhì)書籍整理。
★本文的圖很多都是從極客時間王爭老師專欄那邊拷貝過來或者截圖過來的,少部分圖是自己重新畫的。為什么不全都換成自己畫的圖?主要是我比較懶,我覺得圖能將自己要闡述的點解釋清楚,或者說和自己整理過后的文字結(jié)合的不錯,我覺得這個圖就沒必要重新畫了,人家的畫已經(jīng)很好看了,也很清晰了,你將它重新畫,其實也是差不多,可能就是換個樣式而已,核心的東西還是沒有變。但是,在有些地方,我覺得別人的圖跟我闡述的內(nèi)容不符合,或者不能很好地闡述我想表達的東西,又或者這個地方需要一個圖,那么我會畫一個。
”1. 排序算法分析
學(xué)習(xí)排序算法除了學(xué)習(xí)它的算法原理、代碼實現(xiàn)之外,最重要的是學(xué)會如何評價、分析一個排序算法。分析一個排序算法通常從以下幾點出發(fā)。
1.1. 執(zhí)行效率
而對執(zhí)行效率的分析,一般從這幾個方面來衡量:
最好情況、最壞情況、平均情況
除了需要給出這三種情況下的時間復(fù)雜度還要給出對應(yīng)的要排序的原始數(shù)據(jù)是怎么樣的。
時間復(fù)雜度的系數(shù)、常數(shù)、低階
大 O 時間復(fù)雜度反應(yīng)的是算法時間隨 n 的一個增長趨勢,比如 O(n^2) 表示算法時間隨 n 的增加,呈現(xiàn)的是平方的增長趨勢。這種情況下往往會忽略掉系數(shù)、常數(shù)、低階等。但是實際開發(fā)過程中,排序的數(shù)據(jù)往往是 10 個、100 個、1000 個這樣規(guī)模很小的數(shù)據(jù),所以在比較同階復(fù)雜度的排序算法時,這些系數(shù)、常數(shù)、低階不能省略。
比較次數(shù)和交換(或移動)次數(shù)
在基于比較的算法中,會涉及到元素比較和元素交換等操作。所以分析的時候,還需要對比較次數(shù)和交換次數(shù)進行分析。
1.2. 內(nèi)存消耗
內(nèi)存消耗其實就是空間復(fù)雜度。針對排序算法來說,如果該排序算法的空間復(fù)雜度為 O(1),那么這個排序算法又稱為原地排序。
1.3. 穩(wěn)定性
是什么
穩(wěn)定性是指待排序的序列中存在值相等的元素。在排序之后,相等元素的前后順序跟排序之前的是一樣的。
為什么
我們將排序的原理和實現(xiàn)排序時用的大部分都是整數(shù),但是實際開發(fā)過程中要排序的往往是一組對象,而我們只是按照對象中的某個 key 來進行排序。
比如一個對象有兩個屬性,下單時間和訂單金額。在存入到數(shù)據(jù)庫的時候,這些對象已經(jīng)按照時間先后的順序存入了。但是我們現(xiàn)在要以訂單金額為主要 key,在訂單金額相同的時候,以下單時間為 key。那么在采用穩(wěn)定的算法之后,只需要按照訂單金額進行一次排序即可。比如有這么三個數(shù)據(jù),第一個數(shù)據(jù)是下單時間、第二數(shù)據(jù)是訂單金額:(20200515、20)、(20200516、10)、(20200517、30)、(20200518、20)。在采用穩(wěn)定的算法之后,排序的情況如下:(20200516、10)、(20200515、20)、(20200518、20)、(20200517、30)可以發(fā)現(xiàn)在訂單金額相同的情況下是按訂單時間進行排序的。
2. 經(jīng)典的常用排序算法
2.1. 冒泡排序
冒泡排序就是依次對兩個相鄰的元素進行比較,然后在不滿足大小條件的情況下進行元素交換。一趟冒泡排序下來至少會讓一個元素排好序(元素排序好的區(qū)域相當(dāng)于有序區(qū),因此冒泡排序中相當(dāng)于待排序數(shù)組分成了兩個已排序區(qū)間和未排序區(qū)間)。因此為了將 n 個元素排好序,需要 n-1 趟冒泡排序(第 n 趟的時候就不需要)。
下面用冒泡排序?qū)@么一組數(shù)據(jù)4、5、6、3、2、1,從小到大進行排序。第一次排序情況如下:
可以看出,經(jīng)過一次冒泡操作之后,6 這個元素已經(jīng)存儲在正確的位置上了,要想完成有所有數(shù)據(jù)的排序,我們其實只需要 5 次這樣的冒泡排序就行了。圖中給出的是帶第 6 次了的,但是第 6 次其實沒必要。
2.1.1. 優(yōu)化
使用冒泡排序的過程中,如果有一趟冒泡過程中元素之間沒有發(fā)生交換,那么就說明已經(jīng)排序好了,可以直接退出不再繼續(xù)執(zhí)行后續(xù)的冒泡操作了。
2.1.2. 實現(xiàn)
下面的冒泡排序?qū)崿F(xiàn)是優(yōu)化之后的:
/*** 冒泡排序:* 以升序為例,就是比較相鄰兩個數(shù),如果逆序就交換,類似于冒泡;* 一次冒泡確定一個數(shù)的位置,因為要確定 n-1 個數(shù),因此需要 n-1* 次冒泡;* 冒泡排序時,其實相當(dāng)于把整個待排序序列分為未排序區(qū)和已排序區(qū)*/ public void bubbleSort(int[] arr, int len) {// len-1 趟for (int j = 0; j < len-1; j++) {int sortedFlag = 0;// 一趟冒泡for (int i = 0; i < len-1-j; i++) {if (arr[i] > arr[i+1]) {int temp = arr[i];arr[i] = arr[i+1];arr[i+1] = temp;sortedFlag = 1;}}// 該趟排序中沒有發(fā)生,表示已經(jīng)有序if (0 == sortedFlag) {break;}} }2.1.3. 算法分析
冒泡排序是原地排序。因為冒泡過程中只涉及到相鄰數(shù)據(jù)的交換,相當(dāng)于只需要開辟一個內(nèi)存空間用來完成相鄰的數(shù)據(jù)交換即可。
在元素大小相等的時候,不進行交換,那么冒泡排序就是穩(wěn)定的排序算法。
冒泡排序的時間復(fù)雜度。
當(dāng)元素已經(jīng)是排序好了的,那么最好情況的時間復(fù)雜度是 O(n)。因為只需要跑一趟,然后發(fā)現(xiàn)已經(jīng)排好序了,那么就可以退出了。
當(dāng)元素正好是倒序排列的,那么需要進行 n-1 趟排序,最壞情況復(fù)雜度為 O(n^2)。
一般情況下,平均時間復(fù)雜度是 O(n^2)。使用有序度和逆序度的方法來求時間復(fù)雜度,冒泡排序過程中主要是兩個操作:比較和交換。每交換一次,有序度就增加一,因此有序度增加的次數(shù)就是交換的次數(shù)。又因為有序度需要增加的次數(shù)等于逆序度,所以交換的次數(shù)其實就等于逆序度。
因此當(dāng)要對包含 n 個數(shù)據(jù)的數(shù)組進行冒泡排序時。最壞情況下,有序度為 0 ,那么需要進行 n*(n-1)/2 次交換;最好情況下,不需要進行交換。我們?nèi)≈虚g值 n*(n-1)/4,來表示初始有序度不是很高也不是很低的平均情況。由于平均情況下需要進行 n*(n-1)/4 次交換,比較操作肯定比交換操作要多。但是時間復(fù)雜度的上限是 O(n^2),所以平均情況下的時間復(fù)雜度就是 O(n^2)。
★這種方法雖然不嚴格,但是很實用。主要是因為概率的定量分析太復(fù)雜,不實用。(PS:我就喜歡這種的)
”
2.2. 插入排序
**插入排序中將數(shù)組中的元素分成兩個區(qū)間:已排序區(qū)間和未排序區(qū)間(最開始的時候已排序區(qū)間的元素只有數(shù)組的第一個元素),插入排序就是將未排序區(qū)間的元素依次插入到已排序區(qū)間(需要保持已排序區(qū)間的有序)。最終整個數(shù)組都是已排序區(qū)間,即排序好了。**假設(shè)要對 n 個元素進行排序,那么未排序區(qū)間的元素個數(shù)為 n-1,因此需要 n-1 次插入。插入位置的查找可以從尾到頭遍歷已排序區(qū)間也可以從頭到尾遍歷已排序區(qū)間。
如圖所示,假設(shè)要對 4、5、6、1、3、2進行排序。左側(cè)橙紅色表示的是已排序區(qū)間,右側(cè)黃色的表示未排序區(qū)間。整個插入排序過程如下所示
2.2.1. 優(yōu)化
采用希爾排序的方式。
**使用哨兵機制。**比如要排序的數(shù)組是[2、1、3、4],為了使用哨兵機制,首先需要將數(shù)組的第 0 位空出來,然后數(shù)組元素全都往后移動一格,變成[0、2、1、3、4]。那么數(shù)組 0 的位置用來存放要插入的數(shù)據(jù),這樣一來,判斷條件就少了一個,不用再判斷 j >= 0 這個條件了,只需要使用 arr[j] > arr[0] 的條件就可以了。因為就算遍歷到下標(biāo)為 0 的位置,由于 0 處這個值跟要插入的值是一樣的,所以會退出循環(huán),不會出現(xiàn)越界的問題。
2.2.2. 實現(xiàn)
這邊查找插入位置的方式采用從尾到頭遍歷已排序區(qū)間,也沒有使用哨兵。
/*** 插入排序:* 插入排序也相當(dāng)于把待排序序列分成已排序區(qū)和未排序區(qū);* 每趟排序都將從未排序區(qū)選擇一個元素插入到已排序合適的位置;* 假設(shè)第一個元素屬于已排序區(qū),那么還需要插入 len-1 趟;*/ public void insertSort(int[] arr, int len) {// len-1 趟for (int i = 1; i < len; i++) {// 一趟排序int temp = arr[i];int j;for (j = i-1; j >= 0; j--) {if (arr[j] > temp) {arr[j+1] = arr[j];} else {break;}}arr[j+1] = temp;} }2.2.3. 算法分析
插入排序是原地算法。因為只需要開辟一個額外的存儲空間來臨時存儲元素。
當(dāng)比較元素時發(fā)現(xiàn)元素相等,那么插入到相等元素的后面,此時就是穩(wěn)定排序。也就是說只有當(dāng)有序區(qū)間中的元素大于要插入的元素時才移到到后面的位置,不大于(小于等于)了的話直接插入。
插入排序的時間復(fù)雜度。
待排序的數(shù)據(jù)是有序的情況下,不需要搬移任何數(shù)據(jù)。那么采用從尾到頭在已排序區(qū)間中查找插入位置的方式,最好時間復(fù)雜度是 O(n)。
待排序的數(shù)據(jù)是倒序的情況,需要依次移動 1、2、3、...、n-1 個數(shù)據(jù),因此最壞時間復(fù)雜度是 O(n^2)。
平均時間復(fù)雜度是 O(n^2)。因此將一個數(shù)據(jù)插入到一個有序數(shù)組中的平均時間度是 O(n),那么需要插入 n-1 個數(shù)據(jù),因此平均時間復(fù)雜度是 O(n^2)
★最好的情況是在這個數(shù)組中的末尾插入元素的話,不需要移動數(shù)組,時間復(fù)雜度是 O(1),假如在數(shù)組開頭插入數(shù)據(jù)的話,那么所有的數(shù)據(jù)都需要依次往后移動一位,所以時間復(fù)雜度是 O(n)。往數(shù)組第 k 個位置插入的話,那么 k~n 這部分的元素都需要往后移動一位。因此此時插入的平均時間復(fù)雜度是 O(n)
”
2.2.4. VS 冒泡排序
冒泡排序和插入排序的時間復(fù)雜度都是 O(n^2),都是原地穩(wěn)定排序。而且冒泡排序不管怎么優(yōu)化,元素交換的次數(shù)是一個固定值,是原始數(shù)據(jù)的逆序度。插入排序是同樣的,不管怎么優(yōu)化,元素移動的次數(shù)也等于原始數(shù)據(jù)的逆序度。但是,從代碼的實現(xiàn)上來看,冒泡排序的數(shù)據(jù)交換要比插入排序的數(shù)據(jù)移動要復(fù)雜,冒泡排序需要 3 個賦值操作,而插入排序只需要一個賦值操作。所以,雖然冒泡排序和插入排序在時間復(fù)雜度上都是 O(n^2),但是如果希望把性能做到極致,首選插入排序。其實該點分析的主要出發(fā)點就是在同階復(fù)雜度下,需要考慮系數(shù)、常數(shù)、低階等。
2.3. 選擇排序
選擇排序也分為已排序區(qū)間和未排序區(qū)間(剛開始的已排序區(qū)間沒有數(shù)據(jù)),選擇排序每趟都會從未排序區(qū)間中找到最小的值(從小到大排序的話)放到已排序區(qū)間的末尾。
2.3.1. 實現(xiàn)
/*** 選擇排序:* 選擇排序?qū)⒋判蛐蛄蟹殖晌磁判騾^(qū)和已排序區(qū);* 第一趟排序的時候整個待排序序列是未排序區(qū);* 每一趟排序其實就是從未排序區(qū)選擇一個最值,放到已排序區(qū);* 跑 len-1 趟就好*/ public void switchSort(int[] arr, int len) {// len-1 趟,0-i 為已排序區(qū)for (int i = 0; i < len-1; i++) {int minIndex = i;for (int j = i+1; j < len; j++) {if (arr[j] < arr[minIndex]) {minIndex = j;}}if (minIndex != i) {int temp = arr[i];arr[i] = arr[minIndex];arr[minIndex] = temp;}} }2.3.2. 算法分析
選擇排序是原地排序,因為只需要用來存儲最小值所處位置的額外空間和交換時所需的額外空間。
選擇排序不是一個穩(wěn)定的算法。因為選擇排序是從未排序區(qū)間中找一個最小值,并且和前面的元素交換位置,這會破壞穩(wěn)定性。比如 1、5、5、2 這樣一組數(shù)據(jù)中,使用排序算法的話。當(dāng)找到 2 為 5、5、2 當(dāng)前未排序區(qū)間最小的元素時,2 會與第一個 5 交換位置,那么兩個 5 的順序就變了,就破壞了穩(wěn)定性。
時間復(fù)雜度分析。最好、最壞、平均都是 O(n^2),因為無論待排序數(shù)組情況怎么樣,就算是已經(jīng)有序了,都是需要依次遍歷完未排序區(qū)間,需要比較的次數(shù)依次是 n-1、n-2,所以時間復(fù)雜度是 O(n^2)。
2.4. 歸并排序(Merge Sort)
**歸并排序的核心思想就是我要對一個數(shù)組進行排序:首先將數(shù)組分成前后兩部分,然后對兩部分分別進行排序,排序好之后再將兩部分合在一起,那整個數(shù)組就是有序的了。對于分出的兩部分可以采用相同的方式進行排序。**這個思想就是分治的思想,就是先將大問題分解成小的子問題來解決,子問題解決之后,大問題也就解決了。而對于子問題的求解也是一樣的套路。這個套路有點類似于遞歸的方式,所以分治算法一般使用遞歸來實現(xiàn)。分治是一種解決問題的處理思想,而遞歸是一種實現(xiàn)它的編程方法。
2.4.1. 實現(xiàn)
下面使用遞歸的方式來實現(xiàn)歸并排序。遞歸的遞推公式是:merge_sort(p...r) = merge(merge_sort(p...q), merge_sort(q+1...r)),終止條件是 p>=r,不再遞歸下去了。整個實現(xiàn)過程是先調(diào)用 __mergeSort() 函數(shù)將兩部分分別排好序,之后再使用數(shù)組合并的方式將兩個排序好的部分進行合并。
/*** 歸并排序*/ public void mergeSort(int[] arr, int len) {__mergerSort(arr, 0, len-1); }private void __mergerSort(int[] arr, int begin, int end) {if (begin == end){return;}__mergerSort(arr, begin, (begin+end)/2);__mergerSort(arr, (begin+end)/2 + 1, end);merge(arr, begin, end);return; }private void merge(int[] arr, int begin, int end) {int[] copyArr = new int[end-begin+1];System.arraycopy(arr, begin, copyArr, 0, end-begin+1);int mid = (end - begin + 1)/2;int i = 0; // begin - mid 的指針int j = mid; // mid - end 的指針int count = begin; // 合并之后數(shù)組的指針while (i <= mid-1 && j <= end - begin) {arr[count++] = copyArr[i] < copyArr[j] ? copyArr[i++] : copyArr[j++];}while (i <= mid-1) {arr[count++] = copyArr[i++];}while (j <= end - begin) {arr[count++] = copyArr[j++];} }2.4.2. 算法分析
歸并排序可以是穩(wěn)定的排序算法,只要確保合并時,如果遇到兩個相等值的,前半部分那個相等的值是在后半部分那個相等的值的前面即可保證是穩(wěn)定的排序算法。
歸并排序的時間復(fù)雜度為 O(nlogn),無論是最好、最壞還是平均情況都一樣。
歸并的時間復(fù)雜度分析則是遞歸代碼的時間復(fù)雜度的分析。假設(shè)求解問題 a 可以分為對 b、c 兩個子問題的求解。那么問題 a 的時間是 T(a) 、求解 b、c 的時間分別是 T(b) 和 T(c),那么 T(a) = T(b) +T(c) + K。k 等于將 b、c 兩個子問題的結(jié)果合并問題 a 所消耗的時間。
套用上述的套路,假設(shè)對 n 個元素進行歸并排序需要的時間是 T(n),子問題歸并排序的時間是 T(n/2),合并操作的時間復(fù)雜度是 O(n)。所以,T(n) =2 * T(n/2) +O(n),T(1) = C。最終得到:
T(n)= 2*T(n/2) + n= 2*(2*T(n/4)+ n/2)+n = 2^2*T(n/4) + 2*n= 2^2*(2*T(n/8)+n/4) + 2*n = 2^3*T(n/8) + 3*n= ....= 2^k*T(n/2^K) + k*n= ....= 2^(log_2^n)*T(1) + log_2^n*n最終得到?,使用大 O 時間復(fù)雜表示 T(n)=O(nlogn)。
歸并排序中,無論待排數(shù)列是有序還是倒序,最終遞歸的層次都是到只有一個數(shù)組為主,所以歸并排序跟待排序列沒有什么關(guān)系,最好、最壞、平均的時間復(fù)雜度都是 O(nlogn)。
歸并排序并不是原地排序,因為在歸并排序的合并函數(shù)中,還需要額外的存儲空間,這個存儲空間是 O(n)。遞歸過程中,空間復(fù)雜度并不能像時間復(fù)雜度那樣累加。因為在每次遞歸下去的過程中,雖然合并操作都會申請額外的內(nèi)存空間,但是合并之后,這些申請的內(nèi)存空間就會被釋放掉。因此其實主要考慮最大問題合并時所需的空間復(fù)雜度即可,該空間復(fù)雜度為 O(n)。
2.5. 快速排序(Quick Sort)
快速排序利用的也是分治思想,核心思想是從待排數(shù)組中選擇一個元素,然后將待排數(shù)組劃分成兩個部分:左邊部分的元素都小于該元素的值,右邊部分的元素都大于該元素的值,中間是該元素的值。然后對左右兩個部分套用相同的處理方法,也就是將左邊部分的元素再劃分成左右兩部分,右邊部分的元素也再劃分成左右兩部分。以此類推,當(dāng)遞歸到只有一個元素的時候,就說明此時數(shù)組是有序了的。
2.5.1. 實現(xiàn)
首先要對下標(biāo)從 begin 到 end 之間的數(shù)據(jù)進行分區(qū),可以選擇 begin 到 end 之間的任意一個數(shù)據(jù)作為 pivot(分區(qū)點),一般是最后一個數(shù)據(jù)作為分區(qū)點。之后遍歷 begin 到 end 之間的數(shù)據(jù),將小于 pivot 的放在左邊,大于的 pivot 的放在右邊,將pivot 放在中間(位置 p)。經(jīng)過這一操作之后,數(shù)組 begin 到 end 之間的數(shù)據(jù)就被分成了三個部分:begin 到 p-1、p、p+1 到 end。最后,返回 pivot 的下標(biāo)。那么這個過程一般有三種方式:
首先說明這種方法不可取。在不考慮空間消耗的情況下,分區(qū)操作可以非常簡單。使用兩個臨時數(shù)組 X 和 Y,遍歷 begin 到 end 之間的數(shù)據(jù),將小于 pivot 的數(shù)據(jù)都放到數(shù)組 X 中,將大于 pivot 的數(shù)據(jù)都放到數(shù)組 Y 中,最后將數(shù)組 X 拷貝到原數(shù)組中,然后再放入 pivot,最后再放入數(shù)組 Y。但是采用這種方式之后,快排就不是原地排序算法了,因此可以采用以下兩種方法在原數(shù)組的基礎(chǔ)之上完成分區(qū)操作。
第一種方法還是使用兩個指針:i 和 j,i 和 j 一開始都放置在 begin 初。之后 j 指針開始遍歷,如果 j 指針?biāo)傅脑匦∮诘扔?pivot,那么則將 j 指針的元素放到 i 指針的處,i ?指針的元素放置于 j 處,然后 i 后移,j 后移。如果 j 指針?biāo)傅脑卮笥?pivot 那么 j 后移即可。首先個人覺得其實整個數(shù)組被分成三個區(qū)域:0-i-1 的為小于等于 pivot 的區(qū)域,i-j-1 為大于 pivot 的區(qū)域,j 之后的區(qū)域是未排序的區(qū)域。
第二種方法還是使用兩個指針:i 和 j,i 從 begin 處開始,j 從 end 處開始。首先 j 從 end 開始往前遍歷,當(dāng)遇到小于 pivot 的時候停下來,然后此時 i 從 begin 開始往后遍歷,當(dāng)遇到大于 pivot 的時候停下來,此時交換 i 和 j 處的元素。之后 j 繼續(xù)移動,重復(fù)上述過程,直至 i >= j。
在返回 pivot 的下標(biāo) q 之后,再根據(jù)分治的思想,將 begin 到 q-1 之間的數(shù)據(jù)和下標(biāo) q+1 到 end 之間的數(shù)據(jù)進行遞歸。這邊一定要 q-1 和 q+1 而不能是 q 和 q+1 是因為:考慮數(shù)據(jù)已經(jīng)有序的極端情況,一開始是對 begin 到 end;當(dāng)分區(qū)之后 q 的位置還是 end 的位置,那么相當(dāng)于死循環(huán)了。最終,當(dāng)區(qū)間縮小至 1 時,說明所有的數(shù)據(jù)都有序了。
如果用遞推公式來描述上述的過程的話,遞推公式:quick_sort(begin...end) = quick_sort(begin...q-1) + quick_sort(q+1...end),終止條件是:begin >= end。將這兩個公式轉(zhuǎn)化為代碼之后,如下所示:
/*** 快速排序*/ public void quickSort(int[] arr, int len) {__quickSort(arr, 0, len-1); }// 注意邊界條件 private void __quickSort(int[] arr, int begin, int end) {if (begin >= end) {return;}// 一定要是 p-1!int p = partition(arr, begin, end); // 先進行大致排序,并獲取區(qū)分點__quickSort(arr, begin, p-1);__quickSort(arr, p+1, end); }private int partition(int[] arr, int begin, int end) {int pValue = arr[end];// 整兩個指針,兩個指針都從頭開始// begin --- i-1(含 i-1):小于 pValue 的區(qū)// i --- j-1(含 j-1):大于 pValue 的區(qū)// j --- end:未排序區(qū)int i = begin;int j = begin;while (j <= end) {if (arr[j] <= pValue) {int temp = arr[j];arr[j] = arr[i];arr[i] = temp;i++;j++;} else {j++;}}return i-1; }2.5.2. 優(yōu)化
由于分區(qū)點很重要(為什么重要見算法分析),因此可以想方法尋找一個好的分區(qū)點來使得被分區(qū)點分開的兩個分區(qū)中,數(shù)據(jù)的數(shù)量差不多。下面介紹兩種比較常見的算法:
**三數(shù)取中法。就是從區(qū)間的首、尾、中間分別取出一個數(shù),然后對比大小,取這 3 個數(shù)的中間值作為分區(qū)點。**但是,如果排序的數(shù)組比較大,那“三數(shù)取中”可能不夠了,可能就要“五數(shù)取中”或者“十?dāng)?shù)取中”,也就是間隔某個固定的長度,取數(shù)據(jù)進行比較,然后選擇中間值最為分區(qū)點。
隨機法。隨機法就是從排序的區(qū)間中,隨機選擇一個元素作為分區(qū)點。隨機法不能保證每次分區(qū)點都是比較好的,但是從概率的角度來看,也不太可能出現(xiàn)每次分區(qū)點都很差的情況。所以平均情況下,隨機法取分區(qū)點還是比較好的。
遞歸可能會棧溢出,最好的方式是使用非遞歸的方式;
2.5.3. 算法分析
快排不是一個穩(wěn)定的排序算法。因為分區(qū)的過程涉及到交換操作,原本在前面的元素可能會被交換到后面去。比如 6、8、7、6、3、5、9、4 這個數(shù)組中。在經(jīng)過第一次分區(qū)操作之后,兩個 6 的順序就會發(fā)生改變。
快排是一種原地的排序算法。
快排的最壞時間復(fù)雜度是 O(n^2),最好時間復(fù)雜度是O(nlogn),平均時間復(fù)雜度是 O(nlogn)。
快排也是使用遞歸來實現(xiàn),那么遞歸代碼的時間復(fù)雜度處理方式和前面類似。
快排的時間復(fù)雜度取決于 pivot 的選擇,通過合理地選擇 pivot 來使得算法的時間復(fù)雜度盡可能不出現(xiàn) O(n^2) 的情況。
假設(shè)每次分區(qū)操作都可以把數(shù)組分成大小接近相等的兩個小區(qū)間,那么快排的時間復(fù)雜度和歸并排序一樣,都是 O(nlogn)。
但是分區(qū)操作不一定都能把數(shù)組分成大小接近相等的兩個小區(qū)間。極端情況如數(shù)組中的數(shù)組已經(jīng)有序了,如果還是取最后一個元素作為分割點,左邊區(qū)間是 n-1 個數(shù),右邊區(qū)間沒有任何數(shù)。此時, T(n)=T(n-1)+n,最終時間復(fù)雜度退化為 O(n^2)。大部分情況下,采用遞歸樹的方法可得到時間復(fù)雜度是 O(nlogn)。由于極端情況是少數(shù),因此平均時間復(fù)雜度是 O(nlogn)。
2.5.4. VS 歸并排序
首先從思想上來看:歸并排序的處理過程是由下到上的,先處理子問題,然后對子問題的解再合并;而快排正好相反,處理過程是由上到下的,先分區(qū),再處理子問題。
從性能上來看:歸并是一個穩(wěn)定的、時間復(fù)雜度為 O(nlogn) 的排序算法,但是歸并并不是一個原地排序算法(所以歸并沒有快排應(yīng)用廣泛)。而快速排序算法時間復(fù)雜度不一定是 O(nlogn),最壞情況下是 O(n^2),而且不是一個穩(wěn)定的算法,但是通過設(shè)計可以讓快速排序成為一個原地排序算法。
2.6. 堆排序
堆是一種特殊的樹。只要滿足以下兩個條件就是一個堆。
堆是一個完全二叉樹。既然是完全二叉樹,那么使用數(shù)組存儲的方式將會很方便。
堆中的每個節(jié)點的值都必須大于等于(或小于等于)其子節(jié)點的值。對于大于等于子節(jié)點的堆又被稱為“大頂堆”;小于等于子節(jié)點的堆又被稱為“小頂堆”。
由于”堆是一個完全二叉樹“,因此一般堆使用數(shù)組來存儲,一是節(jié)省空間,二是通過數(shù)組的下標(biāo)就可以找到父節(jié)點、左右子節(jié)點(數(shù)組下標(biāo)最好從 1 開始,該節(jié)的代碼實現(xiàn)都將從數(shù)組下標(biāo)為 1 的地方開始)。那么,借助堆這種數(shù)據(jù)結(jié)構(gòu)實現(xiàn)的排序被稱為堆排序。堆排序是一種原地的、時間復(fù)雜度為 O(nlogn) 且不穩(wěn)定的排序算法。
2.6.1. 實現(xiàn)
整個堆排序的實現(xiàn)分為建堆和排序兩個步驟。
建堆
首先是將待排序數(shù)組建立成一個堆,秉著能不借助額外數(shù)組則不借助的原則,我們可以直接在原數(shù)組上直接操作。這樣,建堆有兩個方法:
第一種方法類似于上述堆的操作中“往堆中插入一個元素”的思想。剛開始的時候假設(shè)堆中只有一個元素,也就是下標(biāo)為 1 的元素。然后,將下標(biāo)為 2 的元素插入堆中,并對堆進行調(diào)整。以此類推,將下標(biāo)從 2 到 n 的元素依次插入到堆中。這種建堆方式相當(dāng)于將待排序數(shù)組分成“堆區(qū)”和“待插入堆區(qū)”。
如圖所示,我們將對待排序數(shù)據(jù) 7、5、19、8、4 進行建堆(大頂堆)??梢钥吹匠跏蓟丫鸵粋€元素 7。之后將指針移到下標(biāo)為 2 的位置,將 5 這個元素添加到堆中并從下往上進行堆化。之后,再將指針移到下標(biāo)為 3 的位置,將 19 這個元素添加到堆中并從下往上進行堆化。依次類推。
第二種方法是將整個待排序數(shù)組都當(dāng)成一個“堆”,但是這個“堆”不一定滿足堆的兩個條件,因此我們需要對其進行整體調(diào)整。那么,調(diào)整的時候是從數(shù)組的最后開始,依次往前調(diào)整。調(diào)整的時候,只需要調(diào)整該節(jié)點及其子樹滿足堆的要求,并且是從上往下的方式進行調(diào)整。由于,葉子節(jié)點沒有子樹,所以葉子節(jié)點沒必要調(diào)整,我們只需要從第一個非葉子節(jié)點開始調(diào)整(這邊的第一是從后往前數(shù)的第一個)。那么第一個非葉子節(jié)點的下標(biāo)為 n/2,因此我們只需要對 n/2 到 1 的數(shù)組元素進行從上往下堆化即可(下標(biāo)從 n/2 到 1 的數(shù)組元素所在節(jié)點都是非葉子節(jié)點,下標(biāo)從 n/2+1 到 n 的數(shù)組元素所在節(jié)點都是葉子節(jié)點)。
如圖所示,我們將對待排序數(shù)據(jù) 7、5、19、8、4、1、20、13、16 進行建堆(大頂堆)??梢钥吹秸麄€過程是從 8 這個元素開始進行堆化。在對 8 進行堆化的時候,僅對 8 及其子樹進行堆化。在對 5 進行堆化的時候,僅對 5 及其子樹進行堆化。
我們將第二種思路實現(xiàn)成如下代碼段所示:
public void buildHeap(int[] datas, int len) {this.heap = datas;this.capacity = len - 1;this.count = len - 1;for (int i = this.count/2; i >=1; i--) {heapifyFromTop(i);} }public void heapifyFromTop(int begin) {while (true) {int i = begin; // i 是節(jié)點及其左右子節(jié)點中較大值的那個節(jié)點的下標(biāo)/* 就是在節(jié)點及其左右子節(jié)點中選擇一個最大的值,與節(jié)點所處的位置進行;但是,需要注意的是假如這個值正好是節(jié)點本身,那么直接退出循環(huán);否則需要進行交換,然后從交換之后的節(jié)點開始繼續(xù)堆化 */if (begin * 2 <= this.count && this.heap[begin] < this.heap[2 * begin]) {i = 2 * begin;}if ((2 * begin + 1) <= this.count && this.heap[i] < this.heap[2 * begin + 1]) {i = 2 * begin + 1;}if (i == begin) {break;}swap(begin, i);begin = i;} } ★為什么下標(biāo)從 n/2 到 1 的數(shù)組元素所在節(jié)點都是非葉子節(jié)點,下標(biāo)從 n/2+1 到 n 的數(shù)組元素所在節(jié)點都是葉子節(jié)點?這個算是完全二叉樹的一個特性。嚴格的證明暫時沒有,不嚴謹?shù)淖C明還是有的。這里采用反證法,假如 n/2 + 1 不是葉子節(jié)點,那么它的左子節(jié)點下標(biāo)應(yīng)該為 n+2,但是整個完全二叉樹最大的節(jié)點的下標(biāo)為 n。所以 n/2 + 1 不是葉子節(jié)點不成立,即 n/2 + 1 是葉子節(jié)點。那么同理可證 n/2 + 1 到 n 也是如此。而對于下標(biāo)為 n/2 的節(jié)點來說,它的左子節(jié)點有的話下標(biāo)應(yīng)該為 n,n 在數(shù)組中有元素,因此 n/2 有左子節(jié)點,即 n/2 不是葉子節(jié)點。同理可證 1 到 n/2 都不是葉子節(jié)點。
”排序
建完堆(大頂堆)之后,接下去的步驟是排序。那么具體該怎么實現(xiàn)排序呢?
此時,我們可以發(fā)現(xiàn),堆頂?shù)脑厥亲畲蟮?#xff0c;即數(shù)組中的第一個元素是最大的。實現(xiàn)排序的過程大致如下:我們把它跟最后一個元素交換,那最大元素就放到了下標(biāo)為 n 的位置。此時堆頂元素不是最大,因此需要進行堆化。采用從上而下的堆化方法(參考刪除堆頂元素的堆化方法),將剩下的 n-1 個數(shù)據(jù)構(gòu)建成堆。最后一個數(shù)據(jù)因為已知是最大了,所以不用參與堆化了。n-1 個數(shù)據(jù)構(gòu)建成堆之后,再將堆頂?shù)脑?#xff08;下標(biāo)為 1 的元素)和下標(biāo)為 n-1 的元素進行交換。一直重復(fù)這個過程,直至堆中只剩一個元素,此時排序工作完成。如圖所示,這是整個過程的示意圖。
下面將排序的過程使用 Java 實現(xiàn),如下所示。那么講建堆和排序的過程結(jié)合在一起之后就是完整的堆排序了。
public void heapSort() {while (this.count > 1) {swap(this.count, 1);this.count--;heapifyFromTop(1);} } ★詳細的代碼看文章開頭給出的 Github 地址。
”2.6.2. 算法分析
時間復(fù)雜度
堆排序的時間復(fù)雜度是由建堆和排序兩個步驟的時間復(fù)雜度疊加而成。
建堆的時間復(fù)雜度
在采用第二方式建堆時,從粗略的角度來看,每個節(jié)點進行堆化的時間復(fù)雜度是 O(logn),那么 n/2 個節(jié)點堆化的總時間復(fù)雜度為 O(nlogn)。但是這此時粗略的計算,更加精確的計算結(jié)果不是 O(nlogn),而是 O(n)。
因為葉子節(jié)點不需要進行堆化,所以需要堆化的節(jié)點從倒數(shù)第二層開始。每個節(jié)點需要堆化的過程中,需要比較和交換的次數(shù),跟這個節(jié)點的高度 k 成正比。那么所有節(jié)點的高度之和,就是所有節(jié)點堆化的時間復(fù)雜度。假設(shè)堆頂節(jié)點對應(yīng)的高度為 h ,那么整個節(jié)點對應(yīng)的高度如圖所示(以滿二叉樹為例,最后一層葉子節(jié)點不考慮)。
那么將每個非葉子節(jié)點的高度求和為
求解這個公式可將兩邊同時乘以 2 得到 S2,
然后再減去 S1,從而就得到 S1
由于
所以最終時間復(fù)雜度為 O(2n-logn),也就是 O(n)。
排序的時間復(fù)雜度
排序過程中,我們需要進行 (n-1) 次堆化,每次堆化的時間復(fù)雜度是 O(logn),那么排序階段的時間復(fù)雜度為 O(nlogn)。
總的時間復(fù)雜度
那么,整個總的時間復(fù)雜度為 O(nlogn)。
★不對建堆過程的時間復(fù)雜度進行精確計算,也就是建堆以 O(nlogn) 的時間復(fù)雜度算的話,那么總的時間復(fù)雜度還是 O(nlogn)。
”穩(wěn)定與否
堆排序不是穩(wěn)定的排序算法。因為在排序階段,存在將堆的最后一個節(jié)點跟堆頂點進行互換的操作,所以有可能會改變相同數(shù)據(jù)的原始相對順序。比如下面這樣一組待排序 20、16、13、13 ,在排序時,第二個 13 會跟 20 交換,從而更換了兩個 13 的相對順序。
是否原地
堆排序是原地排序算法,因為堆排序的過程中的兩個步驟中都只需要極個別臨時存儲空間。
2.6.3. 總結(jié)
在實際開發(fā)中,為什么快速排序要比堆排序性能要好?
對于同樣的數(shù)據(jù),在排序過程中,堆排序算法的數(shù)據(jù)交換次數(shù)要多于快速排序
對于基于比較的排序算法來說,整個排序過程就是由比較和交換這兩個操作組成??焖倥判蛑?#xff0c;交換的次數(shù)不會比逆序度多。但是堆排序的過程,第一步是建堆,這個過程存在大量的比較交換操作,并且很有可能會打亂數(shù)據(jù)原有的相對先后順序,導(dǎo)致原數(shù)據(jù)的有序度降低。比如,在對一組已經(jīng)按從小到大的順序排列的數(shù)據(jù)進行堆排序時,那么建堆過程會將這組數(shù)據(jù)構(gòu)建成大頂堆,而這一操作將會讓數(shù)據(jù)變得更加無序。而采用快速排序的方法時,只需要比較而不需要交換。
★最直接的方式就是做個試驗看一下,對交換次數(shù)進行統(tǒng)計。
”堆排序的訪問方式?jīng)]有快速排序友好
快速排序來說,數(shù)據(jù)是順序訪問的。而堆排序,數(shù)據(jù)是跳著訪問的。訪問的數(shù)據(jù)量如何很大的話,那么堆排序可能對 CPU 緩存不太友好。
2.7. 桶排序
**桶排序的核心思想就是將要排序的數(shù)據(jù)分到幾個有序的桶里,每個桶里的數(shù)據(jù)再單獨進行排序。**桶內(nèi)排序完成之后,再把每個桶里的數(shù)據(jù)按照順序依次取出,組成的序列就是有序的了。一般步驟是:
先確定要排序的數(shù)據(jù)的范圍;
然后根據(jù)范圍將數(shù)據(jù)分到桶中(可以選擇桶的數(shù)量固定,也可以選擇桶的大小固定);
之后對每個桶進行排序;
之后將桶中的數(shù)據(jù)進行合并;
2.7.1. 實現(xiàn)
public void buckerSort(int[] arr, int len, int bucketCount) {// 確定數(shù)據(jù)的范圍int minVal = arr[0];int maxVal = arr[0];for (int i = 1; i < len; ++i) {if (arr[i] < minVal) {minVal = arr[i];} else if (arr[i] > maxVal){maxVal = arr[i];}}// 確認每個桶的所表示的范圍bucketCount = (maxVal - minVal + 1) < bucketCount ? (maxVal - minVal + 1) : bucketCount;int bucketSize = (maxVal - minVal + 1) / bucketCount;bucketCount = (maxVal - minVal + 1) % bucketCount == 0 ? bucketCount : bucketCount + 1;int[][] buckets = new int[bucketCount][bucketSize];int[] indexArr = new int[bucketCount]; // 數(shù)組位置記錄// 將數(shù)據(jù)依次放入桶中for (int i = 0; i < len; i++) {int bucketIndex = (arr[i] - minVal) / bucketSize;if (indexArr[bucketIndex] == buckets[bucketIndex].length) {expandCapacity(buckets, bucketIndex);}buckets[bucketIndex][indexArr[bucketIndex]++] = arr[i];}// 桶內(nèi)排序for (int i = 0; i < bucketCount; ++i) {if (indexArr[i] != 0) {quickSort(buckets[i], 0, indexArr[i] - 1);}}// 桶內(nèi)數(shù)據(jù)依次取出int index = 0;for (int i = 0; i < bucketCount; ++i) {for (int j = 0; j < indexArr[i]; ++j) {arr[index++] = buckets[i][j];}}// 打印for (int i = 0; i < len; ++i) {System.out.print(arr[i] + " ");}System.out.println(); }// 對數(shù)組進行擴容 public void expandCapacity(int[][] buckets, int bucketIndex) {int[] newArr = new int[buckets[bucketIndex].length * 2];System.arraycopy(buckets[bucketIndex], 0, newArr, 0, buckets[bucketIndex].length);buckets[bucketIndex] = newArr; }2.7.2. 算法分析
最好時間復(fù)雜度為 O(n),最壞時間復(fù)雜度為 O(nlogn),平均時間復(fù)雜度為 O(n)。
如果要排序的數(shù)據(jù)為 n 個,把這些數(shù)據(jù)均勻地分到 m 個桶內(nèi),每個桶就有 k=n/m 個元素。每個桶使用快速排序,時間復(fù)雜度為 O(k.logk)。m 個 桶的時間復(fù)雜度就是 O(m*k*logk),轉(zhuǎn)換的時間復(fù)雜度就是 O(n*log(n/m))。當(dāng)桶的數(shù)量 m 接近數(shù)據(jù)個數(shù) n 時,log(n/m) 就是一個非常小的常量,這個時候桶排序的時間復(fù)雜度接近 O(n)。
如果數(shù)據(jù)經(jīng)過桶的劃分之后,每個桶的數(shù)據(jù)很不平均,比如一個桶中包含了所有數(shù)據(jù),那么桶排序就退化為 O(nlogn) 的排序算法了。
這邊的平均時間復(fù)雜度為 O(n) 沒有經(jīng)過嚴格運算,只是采用粗略的方式得出的。因為桶排序大部分情況下,都能將數(shù)據(jù)進行大致均分,而極少情況出現(xiàn)所有的數(shù)據(jù)都在一個桶里。
非原地算法
因為桶排序的過程中,需要創(chuàng)建 m 個桶這個的空間復(fù)雜度就肯定不是 O(1) 了。在桶內(nèi)采用快速排序的情況下,桶排序的空間復(fù)雜度應(yīng)該是 O(n)。
桶排序的穩(wěn)定與否,主要看兩塊:1.將數(shù)據(jù)放入桶中的時候是否按照順序放入;2.桶內(nèi)采用的排序算法。所以將數(shù)據(jù)放入桶中是按照順序的,并且桶內(nèi)也采用穩(wěn)定的排序算法的話,那么整個桶排序則是穩(wěn)定的。既然能穩(wěn)定的話,那么一般算穩(wěn)定的。
2.7.3. 總結(jié)
桶排序?qū)σ判虻臄?shù)據(jù)的要求是非??量痰?。
首先,要排序的數(shù)據(jù)需要很容易被劃分到 m 個桶。并且,桶與桶之間有著天然的大小順序,這樣子每個桶內(nèi)的數(shù)據(jù)都排序完之后,桶與桶之間的數(shù)據(jù)不需要再進行排序;
其次,數(shù)據(jù)在各個桶中的分布是比較均勻的。如果數(shù)據(jù)經(jīng)過桶的劃分之后,每個桶的數(shù)據(jù)很不平均,比如一個桶中包含了所有數(shù)據(jù),那么桶排序就退化為 O(nlogn) 的排序算法了。
**桶排序適合應(yīng)用在外部排序中。**比如要排序的數(shù)據(jù)有 10 GB 的訂單數(shù)據(jù),但是內(nèi)存只有幾百 MB,無法一次性把 ?10GB 的數(shù)據(jù)全都加載到內(nèi)存中。這個時候,就可以先掃描 10GB 的訂單數(shù)據(jù),然后確定一下訂單數(shù)據(jù)的所處的范圍,比如訂單的范圍位于 1~10 萬元之間,那么可以將所有的數(shù)據(jù)劃分到 100 個桶里。再依次掃描 10GB 的訂單數(shù)據(jù),把 1~1000 元之內(nèi)的訂單存放到第一個桶中,1001~2000 元之內(nèi)的訂單數(shù)據(jù)存放到第二個桶中,每個桶對應(yīng)一個文件,文件的命名按照金額范圍的大小順序編號如 00、01,即第一個桶的數(shù)據(jù)輸出到文件 00 中。
理想情況下,如果訂單數(shù)據(jù)是均勻分布的話,每個文件的數(shù)據(jù)大約是 100MB,依次將這些文件的數(shù)據(jù)讀取到內(nèi)存中,利用快排來排序,再將排序好的數(shù)據(jù)存放回文件中。最后只要按照文件順序依次讀取文件中的數(shù)據(jù),并將這些數(shù)據(jù)寫入到一個文件中,那么這個文件中的數(shù)據(jù)就是排序好了的。
但是,訂單數(shù)據(jù)不一定是均勻分布的。劃分之后可能還會存在比較大的文件,那就繼續(xù)劃分。比如訂單金額在 1~1000 元之間的比較多,那就將這個區(qū)間繼續(xù)劃分為 10 個小區(qū)間,1~100、101~200 等等。如果劃分之后還是很大,那么繼續(xù)劃分,直到所有的文件都能讀入內(nèi)存。
★外部排序就是數(shù)據(jù)存儲在磁盤中,數(shù)據(jù)量比較大,內(nèi)存有限,無法將數(shù)據(jù)全部加載到內(nèi)存中。
”
2.8. 計數(shù)排序
計數(shù)排序跟桶排序類似,可以說計數(shù)排序其實是桶排序的一種特殊情況。**當(dāng)要排序的 n 個數(shù)據(jù),所處的范圍并不大的時候,比如最大值是 K,那么就可以把數(shù)據(jù)劃分到 K 個桶,每個桶內(nèi)的數(shù)據(jù)值都是相同的,**從而省掉了桶內(nèi)排序的時間??梢哉f計數(shù)排序和桶排序的區(qū)別其實也就在于桶的大小粒度不一樣。
下面通過舉例子的方式來看一下計數(shù)排序的過程。假設(shè)數(shù)組 A 中有 8 個數(shù)據(jù),值在 0 到 5 之間,分別是:2、5、3、0、2、3、0、3。
首先使用大小為 6 的數(shù)組 C[6] 來存儲每個值的個數(shù),下標(biāo)對應(yīng)具體值。從而得到,C[6] 的情況為:2、0、2、3、0、1。
那么,值為 3 分的數(shù)據(jù)個數(shù)有 3 個,小于 3 分的數(shù)據(jù)個數(shù)有 4 個,所以值為 3 的數(shù)據(jù)在有序數(shù)組 R 中所處的位置應(yīng)該是 4、5、6。為了快速計算出位置,對 C[6] 這個數(shù)組進行變化,C[k] 里存儲小于等于值 k 的數(shù)據(jù)個數(shù)。變化之后的數(shù)組為 2、2、4、7、7、8。
之后我們從后往前依次掃描數(shù)據(jù) A(從后往前是為了穩(wěn)定),比如掃描到 3 的時候,從數(shù)據(jù) C 中取出下標(biāo)為 3 的值,是7(也就說到目前為止,包含自己在內(nèi),值小于等于 3 的數(shù)據(jù)個數(shù)有 7 個),那么 3 就是數(shù)組 R 中第 7 個元素,也就是下標(biāo)為 6。當(dāng)然 3 放入到數(shù)組 R 中后,C[3] 要減 1,變成 6,表示此時未排序的數(shù)據(jù)中小于等于 3 的數(shù)據(jù)個數(shù)有 6 個。
以此類推,當(dāng)掃描到第 2 個值為 3 的數(shù)據(jù)的時候,就會將這個數(shù)據(jù)放入到 R 中下標(biāo)為 5 的位置。當(dāng)掃描完整個數(shù)組 A 后,數(shù)組 R 內(nèi)的數(shù)據(jù)就是按照值從小到大的有序排列了。
2.8.1. 實現(xiàn)
/*** 計數(shù)排序,暫時只能處理整數(shù)(包括整數(shù)和負數(shù))* @param arr* @param len*/ public void countingSort(int[] arr, int len) {// 確定范圍int minVal = arr[0];int maxVal = arr[0];for (int i = 1; i < len; ++i) {if (maxVal < arr[i]) {maxVal = arr[i];} else if (arr[i] < minVal) {minVal = arr[i];}}// 對數(shù)據(jù)進行處理for (int i = 0; i < len; ++i) {arr[i] = arr[i] - minVal;}maxVal = maxVal - minVal;// 遍歷數(shù)據(jù)數(shù)組,求得計數(shù)數(shù)組的個數(shù)int[] count = new int[maxVal + 1];for (int i = 0; i < len; ++i) {count[arr[i]] ++;}printAll(count, maxVal + 1);// 對計數(shù)數(shù)組進行優(yōu)化for (int i = 1; i < maxVal + 1; ++i) {count[i] = count[i - 1] + count[i];}printAll(count, maxVal + 1);// 進行排序,從后往前遍歷(為了穩(wěn)定)int[] sort = new int[len];for (int i = len - 1; i >= 0; --i) {sort[count[arr[i]] - 1] = arr[i] + minVal;count[arr[i]]--;}printAll(sort, len); }2.8.2. 算法分析
非原地算法
計數(shù)排序相當(dāng)于桶排序的特例一樣。計數(shù)排序需要額外的 k 個內(nèi)存空間和 n 個新的內(nèi)存空間存放排序之后的數(shù)組。
穩(wěn)定算法
前面也提到了,假如采用從后往前遍歷的方式話,那么是穩(wěn)定算法。
時間復(fù)雜度
最好、最壞、平均時間復(fù)雜度都是一樣,為 O(n+k),k 為數(shù)據(jù)范圍。這個從代碼的實現(xiàn)可以看出,無論待排數(shù)組的情況怎么樣,都是要循環(huán)同樣的次數(shù)。
2.8.3. 總結(jié)
計數(shù)排序只能用在數(shù)據(jù)范圍不大的場景中,如果數(shù)據(jù)范圍 k 比要排序的數(shù)據(jù) n 大很多,就不適合用計數(shù)排序了。
計數(shù)排序只能直接對非負整數(shù)進行排序,如果要排序的數(shù)據(jù)是其他類型的,需要在不改變相對大小的情況下,轉(zhuǎn)化為非負整數(shù)。比如當(dāng)要排序的數(shù)是精確到小數(shù)點后一位時,就需要將所有的數(shù)據(jù)的值都先乘以 10,轉(zhuǎn)換為整數(shù)。再比如排序的數(shù)據(jù)中有負數(shù)時,數(shù)據(jù)的范圍是[-1000,1000],那么就需要先將每個數(shù)據(jù)加上 1000,轉(zhuǎn)換為非負整數(shù)。
2.9. 基數(shù)排序
桶排序和計數(shù)排序都適合范圍不是特別大的情況(請注意是范圍),但是桶排序的范圍可以比計數(shù)排序的范圍稍微大一點。假如數(shù)據(jù)的范圍很大很大,比如對手機號這種的,桶排序和技術(shù)排序顯然不適合,因為需要的桶的數(shù)量也是十分巨大的。此時,可以使用基數(shù)排序。**基數(shù)排序的思想就是將要排序的數(shù)據(jù)拆分成位,然后逐位按照先后順序進行比較。**比如手機號中就可以從后往前,先按照手機號最后一位來進行排序,之后再按照倒數(shù)第二位來進行排序,以此類推。當(dāng)按照第一位重新排序之后,整個排序就算完成了。
需要注意的是**,按照每位排序的過程需要穩(wěn)定的**,因為假如后一次的排序不穩(wěn)定,前一次的排序結(jié)果將功虧一簣。比如,第一次對個位進行排序結(jié)果為 21、11、42、22、62,此時 21 在 22 前面;第二次對十位的排序假如是不穩(wěn)定的話,22 可能跑到 21 前面去了。那么整個排序就錯了,對個位的排序也就相當(dāng)于白費了。
下面舉個字符串的例子,整個基數(shù)排序的過程如下圖所示:
2.9.1. 實現(xiàn)
/*** 基數(shù)排序* @param arr* @param len*/ public void radixSort(int[] arr, int len, int bitCount) {int exp = 1;for (int i = 0; i < bitCount; ++i) {countingSort(arr, len, exp);exp = exp * 10;} }public int getBit(int value, int exp) {return (value / exp) % 10; } /*** 計數(shù)排序,暫時只能處理整數(shù)(包括整數(shù)和負數(shù))* @param arr* @param len*/ public void countingSort(int[] arr, int len, int exp) {// 確定范圍int maxVal = getBit(arr[0], exp);for (int i = 1; i < len; ++i) {if (maxVal < getBit(arr[i], exp)) {maxVal = getBit(arr[i], exp);}}// 遍歷數(shù)據(jù)數(shù)組,求得計數(shù)數(shù)組的個數(shù)int[] count = new int[maxVal + 1];for (int i = 0; i < len; ++i) {count[getBit(arr[i], exp)] ++;}// 對計數(shù)數(shù)組進行優(yōu)化for (int i = 1; i < maxVal + 1; ++i) {count[i] = count[i - 1] + count[i];}// 進行排序,從后往前遍歷(為了穩(wěn)定)int[] sort = new int[len];for (int i = len - 1; i >= 0; --i) {sort[count[getBit(arr[i], exp)] - 1] = arr[i];count[getBit(arr[i], exp)]--;}System.arraycopy(sort, 0, arr, 0, len);printAll(sort, len); }2.9.2. 算法分析
非原地算法
是不是原地算法其實看針對每一位排序時所使用的算法。為了確保基數(shù)排序的時間復(fù)雜度以及每一位的穩(wěn)定性,一般采用計數(shù)排序,計數(shù)排序是非原地算法,所以可以把基數(shù)排序當(dāng)成非原地排序。
穩(wěn)定算法
因為基數(shù)排序需要確保每一位進行排序時都是穩(wěn)定的,所以整個基數(shù)排序時穩(wěn)定的。
時間復(fù)雜度是 O(kn),k 是數(shù)組的位數(shù)
最好、最壞、平均的時間復(fù)雜度都是 O(n)。因為無論待排數(shù)組的情況怎么樣,基數(shù)排序其實都是遍歷每一位,對每一位進行排序。假如每一位排序的過程中使用計數(shù)排序,時間復(fù)雜度為 O(n)。假如有 k 位的話,那么則需要 k 次桶排序或者計數(shù)排序。因此總的時間復(fù)雜度是 O(kn),當(dāng) k 不大時,比如手機號是 11 位,那么基數(shù)排序的時間復(fù)雜度就近似于 O(n)。也可以從代碼中看出。
2.9.3. 總結(jié)
基數(shù)排序的一個要求是排序的數(shù)據(jù)要是等長的。當(dāng)不等長時候可以在前面或者后面補 0,比如字符串排序的話,就可以在后面補 0,因為 ASCII 碼中所有的字母都大于 “0”,所以補 “0” 不會影響到原有的大小排序。
基數(shù)排序的另一個要求就是數(shù)據(jù)可以分割出獨立的 “位” 來比較,而且位之間存在遞進關(guān)系:如果 a 數(shù)據(jù)的高位比 b 數(shù)據(jù)大,那么剩下的低位就不用比較了。
除此之外,每一個位的數(shù)據(jù)范圍不能太大,要能用線性排序算法來排序,否則,基數(shù)排序時間復(fù)雜度無法達到 O(n)。
3. 排序函數(shù)
幾乎所有編程語言都會提供排序函數(shù),比如 C 語言中 qsort()、C++ STL 中的 sort()/stable_sort()、Java 中的 Collections.sort()。這些排序函數(shù),并不會只采用一種排序算法,而是多種排序算法的結(jié)合。當(dāng)然主要使用的排序算法都是 O(nlogn) 的。
glibc 的 qsort() 排序函數(shù)。qsort() 會優(yōu)先使用歸并排序算法。當(dāng)排序的數(shù)據(jù)量很大時,會使用快速排序。使用排序算法的時候也會進行優(yōu)化,如使用 “三數(shù)取中法”、在堆上手動實現(xiàn)一個棧來模擬遞歸來解決。在快排的過程中,如果排序的區(qū)間的元素個數(shù)小于等于 4 時,則使用插入排序。而且在插入排序中還用到了哨兵機制,減少了一次判斷。
★在小規(guī)模數(shù)據(jù)面前 O(n^2) 時間復(fù)雜度的算法并不一定比 O(nlogn)的算法執(zhí)行時間長。主要是因為時間復(fù)雜度會將系數(shù)和低階去掉。
”Array.sort() 排序函數(shù),使用 TimSort 算法。TimSort 算法是一種歸并算法和插入排序算法混合的排序算法?;竟ぷ鬟^程就是:
整個排序過程,分段選擇策略可以保證 O(nlogn) 的時間復(fù)雜度。TimSort 主要利用了待排序列中可能有些片段已經(jīng)基本有序的特性。之后,對于小片段采用插入算法進行合并,合并成大片段。最后,再使用歸并排序的方式進行合并,從而完成排序工作。
掃描數(shù)組,確定其中的單調(diào)上升段和單調(diào)下降段,將嚴格下降段反轉(zhuǎn);
定義最小基本片段長度,長度不滿足的單調(diào)片段通過插入排序的方式形成滿足長度的單調(diào)片段(就是長度大于等于所要求的最小基本片段長度)
反復(fù)歸并一些相鄰片段,過程中避免歸并長度相差很大的片段,直至整個排序完成。
4. 附加知識
4.1. 有序度、逆序度
在以從小到大為有序的情況中,有序度是數(shù)組中有序關(guān)系的元素對的個數(shù),用數(shù)學(xué)公式表示如下所示。
如果 i < j,那么 a[i] < a[j]比如 2、4、3、1、5、6 這組數(shù)據(jù)的有序度是 11;倒序排列的數(shù)組,有序度是 0;一個完全有序的數(shù)組,有序度為滿有序度,為 n*(n-1)/2,比如1、2、3、4、5、6,有序度就是 15。
逆序度的定義正好跟有序度的定義相反
如果 i < j,那么 a[i] > a[j]關(guān)于逆序度、有序度、滿有序度滿足如下公式
逆序度 = 滿有序度 - 有序度排序的過程其實就是減少逆序度,增加有序度的過程,如果待排序序列達到滿有序度了,那么此時的序列就是有序了。
5. 總結(jié)
冒泡排序、選擇排序可能就停留在理論的層面,實際開發(fā)應(yīng)用中不多,但是插入排序還是挺有用的,有些排序算法優(yōu)化的時候就會用到插入排序,比如在排序數(shù)據(jù)量小的時候會先選擇插入排序。
冒泡、選擇、插入三者的時間復(fù)雜度一般都是按 n^2 來算。**并且這三者都有一個共同特點,那就是都會將排序數(shù)列分成已排序和未排序兩部分。**外層循環(huán)一次,其實是讓有序部分增加一個,因此外層循環(huán)相當(dāng)于對有序部分和未排序部分進行分割。而外層循環(huán)次數(shù)就是待排序的數(shù)據(jù)的個數(shù);內(nèi)層循環(huán)則主要負責(zé)處理未排序部分的元素。
快排的分區(qū)過程和分區(qū)思想其實特別好用,在解決很多非排序的問題上都會遇到。比如如何在 O(n) 的時間復(fù)雜度內(nèi)查找一個 k 最值的問題(還用到分治,更多是分區(qū)這種方式);比如將一串字符串劃分成字母和數(shù)字兩部分(其實就是分區(qū),所以需要注意分區(qū)過程的應(yīng)用)。以后看到類似分區(qū)什么的,可以想想快排分區(qū)過程的操作。
快排和歸并使用都是分治的思想,都可使用遞歸的方式實現(xiàn)。只是歸并是從下往上的處理過程,是先進行子問題處理,然后再合并;而快排是從上往下的處理過程,是先進行分區(qū),而后再進行子問題處理。
桶排序、計數(shù)排序、基數(shù)排序的時間復(fù)雜度是線性的,所以這類排序算法叫做線性排序。之所以這能做到線性排序,主要是因為這三種算法都不是基于比較的排序算法,不涉及到元素之間的比較操作。但是這三種算法對排序的數(shù)據(jù)要求很苛刻。如果數(shù)據(jù)特征比較符合這些排序算法的要求,這些算法的復(fù)雜度可以達到 O(n)。
桶排序、計數(shù)排序針對范圍不大的數(shù)據(jù)是可行的,它們的基本思想都是將數(shù)據(jù)劃分為不同的桶來實現(xiàn)排序。
各種算法比較
排序算法平均時間復(fù)雜度最好時間復(fù)雜度最壞時間復(fù)雜度是否是原地排序是否穩(wěn)定 冒泡 O(n^2) O(n) O(n^2) √ √ 插入 O(n^2) O(n) O(n^2) √ √ 選擇 O(n^2) O(n^2) O(n^2) √ × 歸并 O(nlogn) O(nlogn) O(nlogn) × ?O(n) √ 快排 O(nlogn) O(nlogn) O(n^2) √ × 堆排序 O(nlogn) O(nlogn) O(nlogn) √ × 桶排序 O(n) O(n) O(nlogn) × √ 計數(shù)排序 O(n+k) O(n+k) O(n+k) × √ 基數(shù)排序 O(kn) O(kn) O(kn) × √
6. 巨人的肩膀
極客時間,《數(shù)據(jù)結(jié)構(gòu)與算法之美》,王爭
《算法圖解》
URL 去重的 6 種方案!(附詳細代碼)
多圖證明,Java到底是值傳遞還是引用傳遞?
阿里為什么推薦使用LongAdder,而不是volatile?
關(guān)注下方二維碼,收獲更多干貨!
創(chuàng)作挑戰(zhàn)賽新人創(chuàng)作獎勵來咯,堅持創(chuàng)作打卡瓜分現(xiàn)金大獎總結(jié)
以上是生活随笔為你收集整理的万字详解|手撕 9大排序算法!的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 3种时间格式化的方法,SpringBoo
- 下一篇: 阿里《Java手册》做一个有技术情怀的人