java实现apriori算法_七大经典、常用排序算法的原理、Java 实现以及算法分析
0. 前言
大家好,我是多選參數的程序員,一個正再 neng 操作系統、學數據結構和算法以及 Java 的硬核菜雞。數據結構和算法是我準備新開的坑,主要是因為自己再這塊確實很弱,需要大補(殘廢了一般)。這個坑以排序為開端,介紹了 7 種最經典、最常用的排序算法,分別是:冒泡排序、插入排序、選擇排序、歸并排序、快速排序、同排序、計數排序、基數排序。對應的時間復雜度如下所示:
排序算法時間復雜度是否基于比較冒泡、插入、選擇O(n^2)√快排、歸并O(nlogn)√桶、計數、基數O(n)×
整篇文章的主要知識提綱如圖所示:
編程資料獲取方式:轉發文章私信【資料】
1. 排序算法分析
學習排序算法除了學習它的算法原理、代碼實現之外,最重要的是學會如何評價、分析一個排序算法。分析一個排序算法通常從以下幾點出發。
1.1. 執行效率
而對執行效率的分析,一般從這幾個方面來衡量:
- 最好情況、最壞情況、平均情況除了需要給出這三種情況下的時間復雜度還要給出對應的要排序的原始數據是怎么樣的。
- 時間復雜度的系數、常數、低階大 O 時間復雜度反應的是算法時間隨 n 的一個增長趨勢,比如 O(n^2) 表示算法時間隨 n 的增加,呈現的是平方的增長趨勢。這種情況下往往會忽略掉系數、常數、低階等。但是實際開發過程中,排序的數據往往是 10 個、100 個、1000 個這樣規模很小的數據,所以在比較同階復雜度的排序算法時,這些系數、常數、低階不能省略。
- 比較次數和交換(或移動)次數在基于比較的算法中,會涉及到元素比較和元素交換等操作。所以分析的時候,還需要對比較次數和交換次數進行分析。
1.2. 內存消耗
內存消耗其實就是空間復雜度。針對排序算法來說,如果該排序算法的空間復雜度為 O(1),那么這個排序算法又稱為原的排序。
1.3. 穩定性
是什么
穩定性是指待排序的序列中存在值相等的元素。在排序之后,相等元素的前后順序跟排序之前的是一樣的。
為什么
我們將排序的原理和實現排序時用的大部分都是整數,但是實際開發過程中要排序的往往是一組對象,而我們只是按照對象中的某個 key 來進行排序。
比如一個對象有兩個屬性,下單時間和訂單金額。在存入到數據庫的時候,這些對象已經按照時間先后的順序存入了。但是我們現在要以訂單金額為主要 key,在訂單金額相同的時候,以下單時間為 key。那么在采用穩定的算法之后,只需要按照訂單金額進行一次排序即可。比如有這么三個數據,第一個數據是下單時間、第二數據是訂單金額:(20200515、20)、(20200516、10)、(20200517、30)、(20200518、20)。在采用穩定的算法之后,排序的情況如下:(20200516、10)、(20200515、20)、(20200518、20)、(20200517、30)可以發現在訂單金額相同的情況下是按訂單時間進行排序的。
2. 經典的常用排序算法
2.1. 冒泡排序
冒泡排序就是依次對兩個相鄰的元素進行比較,然后在不滿足大小條件的情況下進行元素交換。一趟冒泡排序下來至少會讓一個元素排好序(元素排序好的區域相當于有序區,因此冒泡排序中相當于待排序數組分成了兩個已排序區間和未排序區間)。因此為了將 n 個元素排好序,需要 n-1 趟冒泡排序(第 n 趟的時候就不需要)。
下面用冒泡排序對這么一組數據4、5、6、3、2、1,從小到大進行排序。第一次排序情況如下:
img
可以看出,經過一次冒泡操作之后,6 這個元素已經存儲在正確的位置上了,要想完成有所有數據的排序,我們其實只需要 5 次這樣的冒泡排序就行了。圖中給出的是帶第 6 次了的,但是第 6 次其實沒必要。
img
2.1.1. 優化
使用冒泡排序的過程中,如果有一趟冒泡過程中元素之間沒有發生交換,那么就說明已經排序好了,可以直接退出不再繼續執行后續的冒泡操作了。
2.1.2. 實現
下面的冒泡排序實現是優化之后的:
/** * 冒泡排序: * 以升序為例,就是比較相鄰兩個數,如果逆序就交換,類似于冒泡; * 一次冒泡確定一個數的位置,因為要確定 n-1 個數,因此需要 n-1 * 次冒泡; * 冒泡排序時,其實相當于把整個待排序序列分為未排序區和已排序區 */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; } } // 該趟排序中沒有發生,表示已經有序 if (0 == sortedFlag) { break; } }}2.1.3. 算法分析
- 冒泡排序是原地排序。因為冒泡過程中只涉及到相鄰數據的交換,相當于只需要開辟一個內存空間用來完成相鄰的數據交換即可。
- 在元素大小相等的時候,不進行交換,那么冒泡排序就是穩定的排序算法。
- 冒泡排序的時間復雜度。
- 當元素已經是排序好了的,那么最好情況的時間復雜度是 O(n)。因為只需要跑一趟,然后發現已經排好序了,那么就可以退出了。
- 當元素正好是倒序排列的,那么需要進行 n-1 趟排序,最壞情況復雜度為 O(n^2)。
- 一般情況下,平均時間復雜度是 O(n^2)。使用有序度和逆序度的方法來求時間復雜度,冒泡排序過程中主要是兩個操作:比較和交換。每交換一次,有序度就增加一,因此有序度增加的次數就是交換的次數。又因為有序度需要增加的次數等于逆序度,所以交換的次數其實就等于逆序度。因此當要對包含 n 個數據的數組進行冒泡排序時。最壞情況下,有序度為 0 ,那么需要進行 n*(n-1)/2 次交換;最好情況下,不需要進行交換。我們取中間值 n*(n-1)/4,來表示初始有序度不是很高也不是很低的平均情況。由于平均情況下需要進行 n*(n-1)/4 次交換,比較操作肯定比交換操作要多。但是時間復雜度的上限是 O(n^2),所以平均情況下的時間復雜度就是 O(n^2)。★這種方法雖然不嚴格,但是很實用。主要是因為概率的定量分析太復雜,不實用。(PS:我就喜歡這種的)”
2.2. 插入排序
**插入排序中將數組中的元素分成兩個區間:已排序區間和未排序區間(最開始的時候已排序區間的元素只有數組的第一個元素),插入排序就是將未排序區間的元素依次插入到已排序區間(需要保持已排序區間的有序)。最終整個數組都是已排序區間,即排序好了。**假設要對 n 個元素進行排序,那么未排序區間的元素個數為 n-1,因此需要 n-1 次插入。插入位置的查找可以從尾到頭遍歷已排序區間也可以從頭到尾遍歷已排序區間。
如圖所示,假設要對 4、5、6、1、3、2進行排序。左側橙紅色表示的是已排序區間,右側黃色的表示未排序區間。整個插入排序過程如下所示
img
2.2.1. 優化
- 采用希爾排序的方式。
- **使用哨兵機制。**比如要排序的數組是[2、1、3、4],為了使用哨兵機制,首先需要將數組的第 0 位空出來,然后數組元素全都往后移動一格,變成[0、2、1、3、4]。那么數組 0 的位置用來存放要插入的數據,這樣一來,判斷條件就少了一個,不用再判斷 j >= 0 這個條件了,只需要使用 arr[j] > arr[0] 的條件就可以了。因為就算遍歷到下標為 0 的位置,由于 0 處這個值跟要插入的值是一樣的,所以會退出循環,不會出現越界的問題。
2.2.2. 實現
這邊查找插入位置的方式采用從尾到頭遍歷已排序區間,也沒有使用哨兵。
/** * 插入排序: * 插入排序也相當于把待排序序列分成已排序區和未排序區; * 每趟排序都將從未排序區選擇一個元素插入到已排序合適的位置; * 假設第一個元素屬于已排序區,那么還需要插入 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. 算法分析
- 插入排序是原地算法。因為只需要開辟一個額外的存儲空間來臨時存儲元素。
- 當比較元素時發現元素相等,那么插入到相等元素的后面,此時就是穩定排序。也就是說只有當有序區間中的元素大于要插入的元素時才移到到后面的位置,不大于(小于等于)了的話直接插入。
- 插入排序的時間復雜度。
- 待排序的數據是有序的情況下,不需要搬移任何數據。那么采用從尾到頭在已排序區間中查找插入位置的方式,最好時間復雜度是 O(n)。
- 待排序的數據是倒序的情況,需要依次移動 1、2、3、...、n-1 個數據,因此最壞時間復雜度是 O(n^2)。
- 平均時間復雜度是 O(n^2)。因此將一個數據插入到一個有序數組中的平均時間度是 O(n),那么需要插入 n-1 個數據,因此平均時間復雜度是 O(n^2)★最好的情況是在這個數組中的末尾插入元素的話,不需要移動數組,時間復雜度是 O(1),假如在數組開頭插入數據的話,那么所有的數據都需要依次往后移動一位,所以時間復雜度是 O(n)。往數組第 k 個位置插入的話,那么 k~n 這部分的元素都需要往后移動一位。因此此時插入的平均時間復雜度是 O(n)”
2.2.4. VS 冒泡排序
冒泡排序和插入排序的時間復雜度都是 O(n^2),都是原地穩定排序。而且冒泡排序不管怎么優化,元素交換的次數是一個固定值,是原始數據的逆序度。插入排序是同樣的,不管怎么優化,元素移動的次數也等于原始數據的逆序度。但是,從代碼的實現上來看,冒泡排序的數據交換要比插入排序的數據移動要復雜,冒泡排序需要 3 個賦值操作,而插入排序只需要一個賦值操作。所以,雖然冒泡排序和插入排序在時間復雜度上都是 O(n^2),但是如果希望把性能做到極致,首選插入排序。其實該點分析的主要出發點就是在同階復雜度下,需要考慮系數、常數、低階等。
2.3. 選擇排序
選擇排序也分為已排序區間和未排序區間(剛開始的已排序區間沒有數據),選擇排序每趟都會從未排序區間中找到最小的值(從小到大排序的話)放到已排序區間的末尾。
img
2.3.1. 實現
/** * 選擇排序: * 選擇排序將待排序序列分成未排序區和已排序區; * 第一趟排序的時候整個待排序序列是未排序區; * 每一趟排序其實就是從未排序區選擇一個最值,放到已排序區; * 跑 len-1 趟就好 */public void switchSort(int[] arr, int len) { // len-1 趟,0-i 為已排序區 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. 算法分析
- 選擇排序是原地排序,因為只需要用來存儲最小值所處位置的額外空間和交換時所需的額外空間。
- 選擇排序不是一個穩定的算法。因為選擇排序是從未排序區間中找一個最小值,并且和前面的元素交換位置,這會破壞穩定性。比如 1、5、5、2 這樣一組數據中,使用排序算法的話。當找到 2 為 5、5、2 當前未排序區間最小的元素時,2 會與第一個 5 交換位置,那么兩個 5 的順序就變了,就破壞了穩定性。
- 時間復雜度分析。最好、最壞、平均都是 O(n^2),因為無論待排序數組情況怎么樣,就算是已經有序了,都是需要依次遍歷完未排序區間,需要比較的次數依次是 n-1、n-2,所以時間復雜度是 O(n^2)。
2.4. 歸并排序(Merge Sort)
**歸并排序的核心思想就是我要對一個數組進行排序:首先將數組分成前后兩部分,然后對兩部分分別進行排序,排序好之后再將兩部分合在一起,那整個數組就是有序的了。對于分出的兩部分可以采用相同的方式進行排序。**這個思想就是分治的思想,就是先將大問題分解成小的子問題來解決,子問題解決之后,大問題也就解決了。而對于子問題的求解也是一樣的套路。這個套路有點類似于遞歸的方式,所以分治算法一般使用遞歸來實現。分治是一種解決問題的處理思想,而遞歸是一種實現它的編程方法。
2.4.1. 實現
下面使用遞歸的方式來實現歸并排序。遞歸的遞推公式是:merge_sort(p...r) = merge(merge_sort(p...q), merge_sort(q+1...r)),終止條件是 p>=r,不再遞歸下去了。整個實現過程是先調用 __mergeSort() 函數將兩部分分別排好序,之后再使用數組合并的方式將兩個排序好的部分進行合并。
/** * 歸并排序 */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; // 合并之后數組的指針 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. 算法分析
- 歸并排序可以是穩定的排序算法,只要確保合并時,如果遇到兩個相等值的,前半部分那個相等的值是在后半部分那個相等的值的前面即可保證是穩定的排序算法。
- 歸并排序的時間復雜度為 O(nlogn),無論是最好、最壞還是平均情況都一樣。歸并的時間復雜度分析則是遞歸代碼的時間復雜度的分析。假設求解問題 a 可以分為對 b、c 兩個子問題的求解。那么問題 a 的時間是 T(a) 、求解 b、c 的時間分別是 T(b) 和 T(c),那么 T(a) = T(b) +T(c) + K。k 等于將 b、c 兩個子問題的結果合并問題 a 所消耗的時間。套用上述的套路,假設對 n 個元素進行歸并排序需要的時間是 T(n),子問題歸并排序的時間是 T(n/2),合并操作的時間復雜度是 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 時間復雜表示 T(n)=O(nlogn)。歸并排序中,無論待排數列是有序還是倒序,最終遞歸的層次都是到只有一個數組為主,所以歸并排序跟待排序列沒有什么關系,最好、最壞、平均的時間復雜度都是 O(nlogn)。 - 歸并排序并不是原地排序,因為在歸并排序的合并函數中,還需要額外的存儲空間,這個存儲空間是 O(n)。遞歸過程中,空間復雜度并不能像時間復雜度那樣累加。因為在每次遞歸下去的過程中,雖然合并操作都會申請額外的內存空間,但是合并之后,這些申請的內存空間就會被釋放掉。因此其實主要考慮最大問題合并時所需的空間復雜度即可,該空間復雜度為 O(n)。
2.5. 快速排序(Quick Sort)
快速排序利用的也是分治思想,核心思想是從待排數組中選擇一個元素,然后將待排數組劃分成兩個部分:左邊部分的元素都小于該元素的值,右邊部分的元素都大于該元素的值,中間是該元素的值。然后對左右兩個部分套用相同的處理方法,也就是將左邊部分的元素再劃分成左右兩部分,右邊部分的元素也再劃分成左右兩部分。以此類推,當遞歸到只有一個元素的時候,就說明此時數組是有序了的。
2.5.1. 實現
首先要對下標從 begin 到 end 之間的數據進行分區,可以選擇 begin 到 end 之間的任意一個數據作為 pivot(分區點),一般是最后一個數據作為分區點。之后遍歷 begin 到 end 之間的數據,將小于 pivot 的放在左邊,大于的 pivot 的放在右邊,將pivot 放在中間(位置 p)。經過這一操作之后,數組 begin 到 end 之間的數據就被分成了三個部分:begin 到 p-1、p、p+1 到 end。最后,返回 pivot 的下標。那么這個過程一般有三種方式:
- 首先說明這種方法不可取。在不考慮空間消耗的情況下,分區操作可以非常簡單。使用兩個臨時數組 X 和 Y,遍歷 begin 到 end 之間的數據,將小于 pivot 的數據都放到數組 X 中,將大于 pivot 的數據都放到數組 Y 中,最后將數組 X 拷貝到原數組中,然后再放入 pivot,最后再放入數組 Y。但是采用這種方式之后,快排就不是原地排序算法了,因此可以采用以下兩種方法在原數組的基礎之上完成分區操作。
- 第一種方法還是使用兩個指針:i 和 j,i 和 j 一開始都放置在 begin 初。之后 j 指針開始遍歷,如果 j 指針所指的元素小于等于 pivot,那么則將 j 指針的元素放到 i 指針的處,i 指針的元素放置于 j 處,然后 i 后移,j 后移。如果 j 指針所指的元素大于 pivot 那么 j 后移即可。首先個人覺得其實整個數組被分成三個區域:0-i-1 的為小于等于 pivot 的區域,i-j-1 為大于 pivot 的區域,j 之后的區域是未排序的區域。
- 第二種方法還是使用兩個指針:i 和 j,i 從 begin 處開始,j 從 end 處開始。首先 j 從 end 開始往前遍歷,當遇到小于 pivot 的時候停下來,然后此時 i 從 begin 開始往后遍歷,當遇到大于 pivot 的時候停下來,此時交換 i 和 j 處的元素。之后 j 繼續移動,重復上述過程,直至 i >= j。
在返回 pivot 的下標 q 之后,再根據分治的思想,將 begin 到 q-1 之間的數據和下標 q+1 到 end 之間的數據進行遞歸。這邊一定要 q-1 和 q+1 而不能是 q 和 q+1 是因為:考慮數據已經有序的極端情況,一開始是對 begin 到 end;當分區之后 q 的位置還是 end 的位置,那么相當于死循環了。最終,當區間縮小至 1 時,說明所有的數據都有序了。
如果用遞推公式來描述上述的過程的話,遞推公式:quick_sort(begin...end) = quick_sort(begin...q-1) + quick_sort(q+1...end),終止條件是:begin >= end。將這兩個公式轉化為代碼之后,如下所示:
/** * 快速排序 */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); // 先進行大致排序,并獲取區分點 __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 的區 // i --- j-1(含 j-1):大于 pValue 的區 // j --- end:未排序區 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. 優化
- 由于分區點很重要(為什么重要見算法分析),因此可以想方法尋找一個好的分區點來使得被分區點分開的兩個分區中,數據的數量差不多。下面介紹兩種比較常見的算法:
- **三數取中法。就是從區間的首、尾、中間分別取出一個數,然后對比大小,取這 3 個數的中間值作為分區點。**但是,如果排序的數組比較大,那“三數取中”可能不夠了,可能就要“五數取中”或者“十數取中”,也就是間隔某個固定的長度,取數據進行比較,然后選擇中間值最為分區點。
- 隨機法。隨機法就是從排序的區間中,隨機選擇一個元素作為分區點。隨機法不能保證每次分區點都是比較好的,但是從概率的角度來看,也不太可能出現每次分區點都很差的情況。所以平均情況下,隨機法取分區點還是比較好的。
- 遞歸可能會棧溢出,最好的方式是使用非遞歸的方式;
2.5.3. 算法分析
- 快排不是一個穩定的排序算法。因為分區的過程涉及到交換操作,原本在前面的元素可能會被交換到后面去。比如 6、8、7、6、3、5、9、4 這個數組中。在經過第一次分區操作之后,兩個 6 的順序就會發生改變。
- 快排是一種原地的排序算法。
- 快排的最壞時間復雜度是 O(n^2),最好時間復雜度是O(nlogn),平均時間復雜度是 O(nlogn)。快排也是使用遞歸來實現,那么遞歸代碼的時間復雜度處理方式和前面類似。快排的時間復雜度取決于 pivot 的選擇,通過合理的選擇 pivot 來使得算法的時間復雜度盡可能不出現 O(n^2) 的情況。
- 假設每次分區操作都可以把數組分成大小接近相等的兩個小區間,那么快排的時間復雜度和歸并排序一樣,都是 O(nlogn)。
- 但是分區操作不一定都能把數組分成大小接近相等的兩個小區間。極端情況如數組中的數組已經有序了,如果還是取最后一個元素作為分割點,左邊區間是 n-1 個數,右邊區間沒有任何數。此時, T(n)=T(n-1)+n,最終時間復雜度退化為 O(n^2)。大部分情況下,采用遞歸樹的方法可得到時間復雜度是 O(nlogn)。由于極端情況是少數,因此平均時間復雜度是 O(nlogn)。
2.5.4. VS 歸并排序
首先從思想上來看:歸并排序的處理過程是由下到上的,先處理子問題,然后對子問題的解再合并;而快排正好相反,處理過程是由上到下的,先分區,再處理子問題。
從性能上來看:歸并是一個穩定的、時間復雜度為 O(nlogn) 的排序算法,但是歸并并不是一個原地排序算法(所以歸并沒有快排應用廣泛)。而快速排序算法時間復雜度不一定是 O(nlogn),最壞情況下是 O(n^2),而且不是一個穩定的算法,但是通過設計可以讓快速排序成為一個原地排序算法。
2.6. 桶排序
**桶排序的核心思想就是將要排序的數據分到幾個有序的桶里,每個桶里的數據再單獨進行排序。**桶內排序完成之后,再把每個桶里的數據按照順序依次取出,組成的序列就是有序的了。一般步驟是:
- 先確定要排序的數據的范圍;
- 然后根據范圍將數據分到桶中(可以選擇桶的數量固定,也可以選擇桶的大小固定);
- 之后對每個桶進行排序;
- 之后將桶中的數據進行合并;
img
2.6.1. 實現
public void buckerSort(int[] arr, int len, int bucketCount) { // 確定數據的范圍 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]; // 數組位置記錄 // 將數據依次放入桶中 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]; } // 桶內排序 for (int i = 0; i < bucketCount; ++i) { if (indexArr[i] != 0) { quickSort(buckets[i], 0, indexArr[i] - 1); } } // 桶內數據依次取出 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();}// 對數組進行擴容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.6.2. 算法分析
- 最好時間復雜度為 O(n),最壞時間復雜度為 O(nlogn),平均時間復雜度為 O(n)。如果要排序的數據為 n 個,把這些數據均勻地分到 m 個桶內,每個桶就有 k=n/m 個元素。每個桶使用快速排序,時間復雜度為 O(k.logk)。m 個 桶的時間復雜度就是 O(m*k*logk),轉換的時間復雜度就是 O(n*log(n/m))。當桶的數量 m 接近數據個數 n 時,log(n/m) 就是一個非常小的常量,這個時候桶排序的時間復雜度接近 O(n)。如果數據經過桶的劃分之后,每個桶的數據很不平均,比如一個桶中包含了所有數據,那么桶排序就退化為 O(nlogn) 的排序算法了。這邊的平均時間復雜度為 O(n) 沒有經過嚴格運算,只是采用粗略的方式得出的。因為桶排序大部分情況下,都能將數據進行大致均分,而極少情況出現所有的數據都在一個桶里。
- 非原地算法因為桶排序的過程中,需要創建 m 個桶這個的空間復雜度就肯定不是 O(1) 了。在桶內采用快速排序的情況下,桶排序的空間復雜度應該是 O(n)。
- 桶排序的穩定與否,主要看兩塊:1.將數據放入桶中的時候是否按照順序放入;2.桶內采用的排序算法。所以將數據放入桶中是按照順序的,并且桶內也采用穩定的排序算法的話,那么整個桶排序則是穩定的。既然能穩定的話,那么一般算穩定的。
2.6.3. 總結
- 桶排序對要排序的數據的要求是非常苛刻的。
- 首先,要排序的數據需要很容易被劃分到 m 個桶。并且,桶與桶之間有著天然的大小順序,這樣子每個桶內的數據都排序完之后,桶與桶之間的數據不需要再進行排序;
- 其次,數據在各個桶中的分布是比較均勻的。如果數據經過桶的劃分之后,每個桶的數據很不平均,比如一個桶中包含了所有數據,那么桶排序就退化為 O(nlogn) 的排序算法了。
- **桶排序適合應用在外部排序中。**比如要排序的數據有 10 GB 的訂單數據,但是內存只有幾百 MB,無法一次性把 10GB 的數據全都加載到內存中。這個時候,就可以先掃描 10GB 的訂單數據,然后確定一下訂單數據的所處的范圍,比如訂單的范圍位于 1~10 萬元之間,那么可以將所有的數據劃分到 100 個桶里。再依次掃描 10GB 的訂單數據,把 1~1000 元之內的訂單存放到第一個桶中,1001~2000 元之內的訂單數據存放到第二個桶中,每個桶對應一個文件,文件的命名按照金額范圍的大小順序編號如 00、01,即第一個桶的數據輸出到文件 00 中。理想情況下,如果訂單數據是均勻分布的話,每個文件的數據大約是 100MB,依次將這些文件的數據讀取到內存中,利用快排來排序,再將排序好的數據存放回文件中。最后只要按照文件順序依次讀取文件中的數據,并將這些數據寫入到一個文件中,那么這個文件中的數據就是排序好了的。但是,訂單數據不一定是均勻分布的。劃分之后可能還會存在比較大的文件,那就繼續劃分。比如訂單金額在 1~1000 元之間的比較多,那就將這個區間繼續劃分為 10 個小區間,1~100、101~200 等等。如果劃分之后還是很大,那么繼續劃分,直到所有的文件都能讀入內存。★外部排序就是數據存儲在磁盤中,數據量比較大,內存有限,無法將數據全部加載到內存中。”
2.7. 計數排序
計數排序跟桶排序類似,可以說計數排序其實是桶排序的一種特殊情況。**當要排序的 n 個數據,所處的范圍并不大的時候,比如最大值是 K,那么就可以把數據劃分到 K 個桶,每個桶內的數據值都是相同的,**從而省掉了桶內排序的時間。可以說計數排序和桶排序的區別其實也就在于桶的大小粒度不一樣。
下面通過舉例子的方式來看一下計數排序的過程。假設數組 A 中有 8 個數據,值在 0 到 5 之間,分別是:2、5、3、0、2、3、0、3。
- 首先使用大小為 6 的數組 C[6] 來存儲每個值的個數,下標對應具體值。從而得到,C[6] 的情況為:2、0、2、3、0、1。
- 那么,值為 3 分的數據個數有 3 個,小于 3 分的數據個數有 4 個,所以值為 3 的數據在有序數組 R 中所處的位置應該是 4、5、6。為了快速計算出位置,對 C[6] 這個數組進行變化,C[k] 里存儲小于等于值 k 的數據個數。變化之后的數組為 2、2、4、7、7、8。
- 之后我們從后往前依次掃描數據 A(從后往前是為了穩定),比如掃描到 3 的時候,從數據 C 中取出下標為 3 的值,是7(也就說到目前為止,包含自己在內,值小于等于 3 的數據個數有 7 個),那么 3 就是數組 R 中第 7 個元素,也就是下標為 6。當然 3 放入到數組 R 中后,C[3] 要減 1,變成 6,表示此時未排序的數據中小于等于 3 的數據個數有 6 個。
- 以此類推,當掃描到第 2 個值為 3 的數據的時候,就會將這個數據放入到 R 中下標為 5 的位置。當掃描完整個數組 A 后,數組 R 內的數據就是按照值從小到大的有序排列了。
2.7.1. 實現
/** * 計數排序,暫時只能處理整數(包括整數和負數) * @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]; } } // 對數據進行處理 for (int i = 0; i < len; ++i) { arr[i] = arr[i] - minVal; } maxVal = maxVal - minVal; // 遍歷數據數組,求得計數數組的個數 int[] count = new int[maxVal + 1]; for (int i = 0; i < len; ++i) { count[arr[i]] ++; } printAll(count, maxVal + 1); // 對計數數組進行優化 for (int i = 1; i < maxVal + 1; ++i) { count[i] = count[i - 1] + count[i]; } printAll(count, maxVal + 1); // 進行排序,從后往前遍歷(為了穩定) 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.7.2. 算法分析
- 非原地算法計數排序相當于同排序的特例一樣。計數排序需要額外的 k 個內存空間和 n 個新的內存空間存放排序之后的數組。
- 穩定算法前面也提到了,假如采用從后往前遍歷的方式話,那么是穩定算法。
- 時間復雜度最好、最壞、平均時間復雜度都是一樣,為 O(n+k),k 為數據范圍。這個從代碼的實現可以看出,無論待排數組的情況怎么樣,都是要循環同樣的次數。
2.7.3. 總結
- 計數排序只能用在數據范圍不大的場景中,如果數據范圍 k 必要排序的數據 n 大很多,就不適合用計數排序了。
- 計數排序只能直接對非負整數進行排序,如果要排序的數據是其他類型的,需要在不改變相對大小的情況下,轉化為非負整數。比如當要排序的數是精確到小數點后一位時,就需要將所有的數據的值都先乘以 10,轉換為整數。再比如排序的數據中有負數時,數據的范圍是[-1000,1000],那么就需要先將每個數據加上 1000,轉換為非負整數。
2.8. 基數排序
桶排序和計數排序都適合范圍不是特別大的情況(請注意是范圍),但是桶排序的范圍可以比計數排序的范圍稍微大一點。假如數據的范圍很大很大,比如對手機號這種的,桶排序和技術排序顯然不適合,因為需要的桶的數量也是十分巨大的。此時,可以使用基數排序。**基數排序的思想就是將要排序的數據拆分成位,然后逐位按照先后順序進行比較。**比如手機號中就可以從后往前,先按照手機號最后一位來進行排序,之后再按照倒數第二位來進行排序,以此類推。當按照第一位重新排序之后,整個排序就算完成了。
需要注意的是**,按照每位排序的過程需要穩定的**,因為假如后一次的排序不穩定,前一次的排序結果將功虧一簣。比如,第一次對個位進行排序結果為 21、11、42、22、62,此時 21 在 22 前面;第二次對十位的排序假如是不穩定的話,22 可能跑到 21 前面去了。那么整個排序就錯了,對個位的排序也就相當于白費了。
下面舉個字符串的例子,整個基數排序的過程如下圖所示:
img
2.8.1. 實現
/** * 基數排序 * @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;}/** * 計數排序,暫時只能處理整數(包括整數和負數) * @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); } } // 遍歷數據數組,求得計數數組的個數 int[] count = new int[maxVal + 1]; for (int i = 0; i < len; ++i) { count[getBit(arr[i], exp)] ++; } // 對計數數組進行優化 for (int i = 1; i < maxVal + 1; ++i) { count[i] = count[i - 1] + count[i]; } // 進行排序,從后往前遍歷(為了穩定) 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.8.2. 算法分析
- 非原地算法是不是原地算法其實看針對每一位排序時所使用的算法。為了確保基數排序的時間復雜度以及每一位的穩定性,一般采用計數排序,計數排序是非原地算法,所以可以把基數排序當成非原地排序。
- 穩定算法因為基數排序需要確保每一位進行排序時都是穩定的,所以整個基數排序時穩定的。
- 時間復雜度是 O(kn),k 是數組的位數最好、最壞、平均的時間復雜度都是 O(n)。因為無論待排數組的情況怎么樣,基數排序其實都是遍歷每一位,對每一位進行排序。假如每一位排序的過程中使用計數排序,時間復雜度為 O(n)。假如有 k 位的話,那么則需要 k 次桶排序或者計數排序。因此總的時間復雜度是 O(kn),當 k 不大時,比如手機號是 11 位,那么基數排序的時間復雜度就近似于 O(n)。也可以從代碼中看出。
2.8.3. 總結
- 基數排序的一個要求是排序的數據要是等長的。當不等長時候可以在前面或者后面補 0,比如字符串排序的話,就可以在后面補 0,因為 ASCII 碼中所有的字母都大于 “0”,所以補 “0” 不會影響到原有的大小排序。基數排序的另一個要求就是數據可以分割出獨立的 “位” 來比較,而且位之間存在遞進關系:如果 a 數據的高位比 b 數據大,那么剩下的地位就不用比較了。除此之外,每一個位的數據范圍不能太大,要能用線性排序算法來排序,否則,基數排序時間復雜度無法達到 O(n)。
3. 排序函數
幾乎所有編程語言都會提供排序函數,比如 C 語言中 qsort()、C++ STL 中的 sort()/stable_sort()、Java 中的 Collections.sort()。這些排序函數,并不會只采用一種排序算法,而是多種排序算法的結合。當然主要使用的排序算法都是 O(nlogn) 的。
- glibc 的 qsort() 排序函數。qsort() 會優先使用歸并排序算法。當排序的數據量很大時,會使用快速排序。使用排序算法的時候也會進行優化,如使用 “三數取中法”、在堆上手動實現一個棧來模擬遞歸來解決。在快排的過程中,如果排序的區間的元素個數小于等于 4 時,則使用插入排序。而且在插入排序中還用到了哨兵機制,減少了一次判斷。★在小規模數據面前 O(n^2) 時間復雜度的算法并不一定比 O(nlogn)的算法執行時間長。主要是因為時間復雜度會將系數和低階去掉。”
- Array.sort() 排序函數,使用 TimSort 算法。TimSort 算法是一種歸并算法和插入排序算法混合的排序算法。基本工作過程就是:整個排序過程,分段選擇策略可以保證 O(nlogn) 的時間復雜度。TimSort 主要利用了待排序列中可能有些片段已經基本有序的特性。之后,對于小片段采用插入算法進行合并,合并成大片段。最后,再使用歸并排序的方式進行合并,從而完成排序工作。
- 掃描數組,確定其中的單調上升段和單調下降段,將嚴格下降段反轉;
- 定義最小基本片段長度,長度不滿足的單調片段通過插入排序的方式形成滿足長度的單調片段(就是長度大于等于所要求的最小基本片段長度)
- 反復歸并一些相鄰片段,過程中避免歸并長度相差很大的片段,直至整個排序完成。
4. 附加知識
4.1. 有序度、逆序度
在以從小到大為有序的情況中,有序度是數組中有序關系的元素對的個數,用數學公式表示如下所示。
如果 i < j,那么 a[i] < a[j]比如 2、4、3、1、5、6 這組數據的有序度是 11;倒序排列的數組,有序度是 0;一個完全有序的數組,有序度為滿有序度,為 n*(n-1)/2,比如1、2、3、4、5、6,有序度就是 15。
逆序度的定義正好跟有序度的定義相反
如果 i < j,那么 a[i] > a[j]關于逆序度、有序度、滿有序度滿足如下公式
逆序度 = 滿有序度 - 有序度排序的過程其實就是減少逆序度,增加有序度的過程,如果待排序序列達到滿有序度了,那么此時的序列就是有序了。
5. 總結
- 冒泡排序、選擇排序可能就停留在理論的層面,實際開發應用中不多,但是插入排序還是挺有用的,有些排序算法優化的時候就會用到插入排序,比如在排序數據量小的時候會先選擇插入排序。
- 冒泡、選擇、插入三者的時間復雜度一般都是按 n^2 來算。**并且這三者都有一個共同特點,那就是都會將排序數列分成已排序和未排序兩部分。**外層循環一次,其實是讓有序部分增加一個,因此外層循環相當于對有序部分和未排序部分進行分割。而外層循環次數就是待排序的數據的個數;內層循環則主要負責處理未排序部分的元素。
- 快排的分區過程和分區思想其實特別好用,在解決很多非排序的問題上都會遇到。比如如何在 O(n) 的時間復雜度內查找一個 k 最值的問題(還用到分治,更多是分區這種方式);比如將一串字符串劃分成字母和數字兩部分(其實就是分區,所以需要注意分區過程的應用)。以后看到類似分區什么的,可以想想快排分區過程的操作。
- 快排和歸并使用都是分治的思想,都可使用遞歸的方式實現。只是歸并是從下往上的處理過程,是先進行子問題處理,然后再合并;而快排是從上往下的處理過程,是先進行分區,而后再進行子問題處理。
- 桶排序、計數排序、基數排序的時間復雜度是線性的,所以這類排序算法叫做線性排序。之所以這能做到線性排序,主要是因為這三種算法都不是基于比較的排序算法,不涉及到元素之間的比較操作。但是這三種算法對排序的數據要求很苛刻。如果數據特征比較符合這些排序算法的要求,這些算法的復雜度可以達到 O(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(n)O(n)O(nlogn)×√計數排序O(n+k)O(n+k)O(n+k)×√基數排序O(kn)O(kn)O(kn)×√
巨人的肩膀
總結
以上是生活随笔為你收集整理的java实现apriori算法_七大经典、常用排序算法的原理、Java 实现以及算法分析的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 荣威D7 DMH荣获“中国心
- 下一篇: 小鹏再反击:我们吵不过余大嘴?那就直接上