程序員那些必須掌握的排序算法(上)
現在的IT行業並不像以前那么好混了,從業人員過多,導致初級程序員過剩,這也間接導致了公司的招聘門檻越來越高,要求程序員掌握的知識也越來越多。
算法也是一個爭論了很久的話題,程序員到底該不該掌握算法?不同的人有不同的答案,而事實上,很多公司都對算法有一定的要求,有些公司直接在面試的時候便會要求面試者手寫算法題。這就對程序員的技術要求產生了很大的考驗,所以面對如今的大環境,我們必須掌握算法,才能在今后的工作中占據一席之地。
那么接下來,我就簡單介紹一下幾個排序算法,希望對你們有所幫助。
1.冒泡排序
冒泡排序(Bubble Sort),是一種較簡單的排序算法。
它重復地走訪過要排序的元素列,依次比較兩個相鄰的元素,如果他們的順序(如從大到小、首字母從A到Z)錯誤就把他們交換過來。走訪元素的工作是重復地進行直到沒有相鄰元素需要交換,也就是說該元素列已經排序完成。
這個算法的名字由來是因為越大的元素會經由交換慢慢“浮”到數列的頂端(升序或降序排列),就如同碳酸飲料中二氧化碳的氣泡最終會上浮到頂端一樣,故名“冒泡排序”。
演示:
代碼如下:
@Test public void bubbleSort() { int[] arr = { 3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48 }; // 統計比較次數 int count = 0; // 第一輪比較 for (int i = 0; i < arr.length - 1; i++) { // 第二輪比較 for (int j = 0; j < arr.length - 1 - i; j++) { if (arr[j] > arr[j + 1]) { // 交換位置 int temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; } count++; } } System.out.println(Arrays.toString(arr)); System.out.println("一共比較了:" + count + "次"); }
運行結果:
[2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
一共比較了:95次
我們首先在開始循環時定義了一個boolean變量為true,然后如果元素之間進行了交換,就將值置為false。所以,我們就可以通過這個boolean變量來判斷是否有元素進行了交換。如果boolean變量為true,則證明沒有元素進行交換,那么久說明此時的數組元素已經完成排序,那么跳出外層循環即可,否則就繼續排序。通過結果也可以看出,比較次數確實是減少了很多。
2.選擇排序
選擇排序(Selection sort)是一種簡單直觀的排序算法。它的工作原理是:第一次從待排序的數據元素中選出最小(或最大)的一個元素,存放在序列的起始位置,然后再從剩余的未排序元素中尋找到最小(大)元素,然后放到已排序的序列的末尾。以此類推,直到全部待排序的數據元素的個數為零。選擇排序是不穩定的排序方法。
演示:
代碼如下:
@Test public void SelectionSort() { int[] arr = { 3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48 }; for (int i = 0; i < arr.length - 1; i++) { int index = i; for (int j = 1 + i; j < arr.length; j++) { if (arr[j] < arr[index]) { index = j;// 保存最小元素的下標 } } // 此時已經找到最小元素的下標 // 將最小元素與前面的元素交換 int temp = arr[index]; arr[index] = arr[i]; arr[i] = temp; } System.out.println(Arrays.toString(arr)); }
運行結果:
[2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
- 1
實現也非常的簡單,首先在外循環里定義了一個index變量存儲i的值,這是為了避免重復地比較,因為在每一輪的比較結束后,前i個元素是已經排好序的,所以無需再次比較,只需從i開始即可。后面的比較都是基於index位置的元素進行比較,倘若比較完后index位置的元素是最小值,那就無需交換,不動即可。而如果找到了比index位置的元素更小的元素,那就將該元素的索引賦值給index,然后繼續比較,直到比較完成,比較完成之后得到的index即為數組中的最小值,那此時只需要將index位置的元素和i位置的元素交換即可。
3.插入排序
插入排序(Insertion sort)是一種簡單直觀且穩定的排序算法。如果有一個已經有序的數據序列,要求在這個已經排好的數據序列中插入一個數,但要求插入后此數據序列仍然有序,這個時候就要用到一種新的排序方法——插入排序法,插入排序的基本操作就是將一個數據插入到已經排好序的有序數據中,從而得到一個新的、個數加一的有序數據,算法適用於少量數據的排序,時間復雜度為O(n^2)。是穩定的排序方法。插入算法把要排序的數組分成兩部分:第一部分包含了這個數組的所有元素,但將最后一個元素除外(讓數組多一個空間才有插入的位置),而第二部分就只包含這一個元素(即待插入元素)。在第一部分排序完成后,再將這個最后元素插入到已排好序的第一部分中。
插入排序的基本思想是:每步將一個待排序的記錄,按其關鍵碼值的大小插入到前面已經排序的數組中的適當位置上,直到全部插入完為止。
演示:
代碼如下:
@Test public void InsertionSort() { int[] arr = { 3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48 }; for (int i = 1; i < arr.length; i++) { // 定義待插入的數 int insertValue = arr[i]; // 找到待插入數的前一個數的下標 int insertIndex = i - 1; while (insertIndex >= 0 && insertValue < arr[insertIndex]) { arr[insertIndex + 1] = arr[insertIndex]; insertIndex--; } arr[insertIndex + 1] = insertValue; } System.out.println(Arrays.toString(arr)); }
運行結果:
[2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
- 1
那么在這里,因為數組元素我們並不確定,所以只能將數組的第一個元素看成是一個有序的序列,所以從數組的第二個元素開始才是我們需要去尋找插入位置的元素。所以外層循環從1開始,然后將arr[i],也就是當前的第二個元素先保存起來,然后找到待插入元素的前一個元素下標,也就是i-1,此時通過一個while循環去比較。
當insertIndex小於0時應該退出循環,因為此時已經與前面的所有元素比較完畢。在比較的過程中,如果待插入元素小於前一個元素,就將前一個元素后移,也就是將前一個元素的值直接賦值給待插入元素位置。因為在最開始已經將待插入元素進行了保存,所以只需將待插入元素的值賦值給它的前一個元素即可。因為在while循環中insertIndex執行了自減操作,所以它的前一個元素下標應為insertIndex + 1。而如果待插入的元素值大於前一個元素,那么就不會進入while循環,這樣insertIndex + 1之后的位置仍然是自己所在的位置,所以賦值后值不改變,后面的操作以此類推。
4.希爾排序
傳統的插入排序算法在某些場景中存在着一些問題,例如[2,3,4,5,1]這樣的一個數組,當我們對其進行插入排序的時候,發現要插入的數字是1,而要想將1插入到最前面,需要經過四個步驟,分別將5、4、3、2后移。所以得出結論:如果較小的數是我們需要進行插入的數,那效率就會比較低。鑒於這種場景的缺陷,希爾排序誕生了,它是插入排序的一種更高效的版本。
先看看希爾排序的概念:
希爾排序(Shell’s Sort)是插入排序的一種又稱“縮小增量排序”(Diminishing Increment Sort),是直接插入排序算法的一種更高效的改進版本。希爾排序是非穩定排序算法。該方法因D.L.Shell於1959年提出而得名。
希爾排序是把記錄按下標的一定增量分組,對每組使用直接插入排序算法排序;隨着增量逐漸減少,每組包含的關鍵詞越來越多,當增量減至1時,整個文件恰被分成一組,算法便終止。
演示:
動畫如果沒有看懂,我這里再貼幾張靜態圖:
代碼實現:
@Test public void ShellSort() { int[] arr = { 3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48 }; for (int gap = arr.length / 2; gap > 0; gap /= 2) { // 對數組元素進行分組 for (int i = gap; i < arr.length; i++) { // 遍歷各組中的元素 for (int j = i - gap; j >= 0; j -= gap) { // 交換元素 if (arr[j] > arr[j + gap]) { int temp = arr[j]; arr[j] = arr[j + gap]; arr[j + gap] = temp; } } } } System.out.println(Arrays.toString(arr)); }
運行結果:
[2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
- 1
那么在上面的程序段中,數組長度為15,所以在第一輪,數組被分為了15 / 2 = 7個小組,然后分別對每個小組的元素進行遍歷。在第一輪中小組之間的元素間隔都為7,所以分成的小組數其實也就是元素之間的間隔。接着就可以對每個小組的元素進行比較,然后進行交換,接下來以此類推。
推薦閱讀
程序員那些必須掌握的排序算法(下)
接着上一篇的排序算法,我們廢話不多說,直接進入主題。
1.快速排序
快速排序(Quicksort)是對冒泡排序的一種改進。
快速排序由C. A. R. Hoare在1960年提出。它的基本思想是:通過一趟排序將要排序的數據分割成獨立的兩部分,其中一部分的所有數據都比另外一部分的所有數據都要小,然后再按此方法對這兩部分數據分別進行快速排序,整個排序過程可以遞歸進行,以此達到整個數據變成有序序列。
演示:
代碼如下:
public static void quickSort(int[] arr, int left, int right) { int l = left;// 左下標 int r = right;// 右下標 int pivot = arr[(left + right) / 2];// 找到中間的值 // 將比pivot小的值放在其左邊,比pivot大的值放在其右邊 while (l < r) { // 在pivot左邊尋找,直至找到大於等於pivot的值才退出 while (arr[l] < pivot) { l += 1;// 將l右移一位 } // 在pivot右邊尋找,直至找到小於等於pivot的值才退出 while (arr[r] > pivot) { r -= 1;// 將r左移一位 } if (l >= r) { // 左右下標重合,尋找完畢,退出循環 break; } // 交換元素 int temp = arr[l]; arr[l] = arr[r]; arr[r] = temp; //倘若發現值相等的情況,則沒有比較的必要,直接移動下標即可 // 如果交換完后,發現arr[l]==pivot,此時應將r左移一位 if (arr[l] == pivot) { r -= 1; } // 如果交換完后,發現arr[r]==pivot,此時應將l右移一位 if (arr[r] == pivot) { l += 1; } } // 如果l==r,要把這兩個下標錯開,否則會出現無限遞歸,導致棧溢出的情況 if (l == r) { l += 1; r -= 1; } // 向左遞歸 if (left < r) { quickSort(arr, left, r); } // 向右遞歸 if (right > l) { quickSort(arr, l, right); } }
測試代碼:
public static void main(String[] args) { int[] arr = { 3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48 }; quickSort(arr, 0, arr.length - 1); System.out.println(Arrays.toString(arr)); }
- 1
- 2
- 3
- 4
- 5
運行結果:
[2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
- 1
快速排序的實現原理很簡單,就是將原數組分成兩部分,然后以中間值為標准,比它小的就放其左邊,比它大的就放其右邊,然后在左右兩邊又以相同的方式繼續排序。
所以在代碼實現過程中,首先要創建兩個移動的變量,一個從最左邊開始往右移動,一個從最右邊開始往左移動,通過這兩個變量來遍歷左右兩部分的元素。當發現左邊有大於中間數的元素,右邊有小於中間數的元素,此時就進行交換。當兩個變量重合也就是相等的時候遍歷結束,然后左右兩部分作遞歸處理。
2.歸並排序
歸並排序(MERGE-SORT)是建立在歸並操作上的一種有效的排序算法,該算法是采用分治法(Divide and Conquer)的一個非常典型的應用。將已有序的子序列合並,得到完全有序的序列;即先使每個子序列有序,再使子序列段間有序。若將兩個有序表合並成一個有序表,稱為二路歸並。
演示:
歸並排序使用了一種分治思想,分治思想的意思就是’分而治之",就是把一個復雜的問題分成兩個或更多的相同或相似的子問題,再把子問題分成更小的子問題……直到最后子問題可以簡單地直接求解。
通過這個動圖來看的話,相信很多人都一臉懵,沒關系,我們通過靜態圖來分析一下:
假設現在有一個待排序的序列,[5,2,4,7,1,3,2,2],那么我們就需要將該序列進行分治,先將其分成兩份:[5,2,4,7]和[1,3,2,2],再將這兩份分別分成兩份:[5,2]和[4,7];[1,3]和[2,2],最后將這四部分再次分別分為兩份,最后就將整個序列分為了八份。需要注意的是,在分的過程中,不需要遵循任何規則,關鍵在於歸並,歸並的過程中便實現了元素的排序。
代碼如下:
public static void mergeSort(int[] arr, int left, int right, int[] temp) { // 分解 if (left < right) { int mid = (left + right) / 2;// 中間索引 // 向左遞歸進行分解 mergeSort(arr, left, mid, temp); // 向右遞歸進行分解 mergeSort(arr, mid + 1, right, temp);// mid + 1,中間位置的后一個位置才是右邊序列的開始位置 // 每分解一輪便合並一輪 merge(arr, left, right, mid, temp); } } /** * 合並的方法 * * @param arr 待排序的數組 * @param left 左邊有序序列的初始索引 * @param right 中間索引 * @param mid 右邊有序序列的初始索引 * @param temp 做中轉的數組 */ public static void merge(int[] arr, int left, int right, int mid, int[] temp) { int i = left; // 初始化i,左邊有序序列的初始索引 int j = mid + 1;// 初始化j,右邊有序序列的初始索引(右邊有序序列的初始位置即為中間位置的后一個位置) int t = 0;// 指向temp數組的當前索引,初始為0 // 先把左右兩邊的數據(已經有序)按規則填充到temp數組 // 直到左右兩邊的有序序列,有一邊處理完成為止 while (i <= mid && j <= right) { // 如果左邊有序序列的當前元素小於或等於右邊有序序列的當前元素,就將左邊的元素填充到temp數組中 if (arr[i] <= arr[j]) { temp[t] = arr[i]; t++;// 索引后移 i++;// i后移 } else { // 反之,將右邊有序序列的當前元素填充到temp數組中 temp[t] = arr[j]; t++;// 索引后移 j++;// j后移 } } // 把有剩余數據的一邊的元素填充到temp中 while (i <= mid) { // 此時說明左邊序列還有剩余元素 // 全部填充到temp數組 temp[t] = arr[i]; t++; i++; } while (j <= right) { // 此時說明左邊序列還有剩余元素 // 全部填充到temp數組 temp[t] = arr[j]; t++; j++; } // 將temp數組的元素復制到原數組 t = 0; int tempLeft = left; while (tempLeft <= right) { arr[tempLeft] = temp[t]; t++; tempLeft++; }
關於歸並排序的算法思想確實比較繞,所以我也在代碼中寫了很多注釋。
我們先來測試一下:
public static void main(String[] args) { int[] arr = { 3, 44, 38, 5, 47, 15, 36, 26, 27, 2, 46, 4, 19, 50, 48 }; int[] temp = new int[arr.length]; mergeSort(arr, 0, arr.length - 1, temp); System.out.println(Arrays.toString(arr)); }
運行結果:
[2, 3, 4, 5, 15, 19, 26, 27, 36, 38, 44, 46, 47, 48, 50]
- 1
來分析一下吧,對於該排序算法,有兩個部分組成,分解和合並。首先講講分解,在前面也說到了,我們需要將待排序的序列不停地進行分解,通過兩個索引變量控制,一個初始索引,一個結尾索引。只有當兩索引重合才結束分解。此時序列被分解成了十五個小份,這樣分解工作就完成了。接下來是合並,合並操作也是最麻煩的,也是通過兩個索引變量i,j。開始i在左邊序列的第一個位置,j在右邊序列的第一個位置,然后就是尋找左右兩個序列中的最小值,放到新序列中,這時可能會出現一邊的元素都放置完畢了,而另外一邊還存在元素,此時只需將剩余的元素按順序放進新序列即可,因為這時左右兩邊的序列已經是有序的了,最后將新序列復制到舊序列。這里也特別需要注意,因為合並的過程是分步的,而並非一次合並完成,所以數組的索引是在不斷變化的。
自己手動畫了個圖,左右兩個箭頭就是索引變量i,j,當i所指的元素也就是1和j所指的元素也就是2進行比較,發現1小,就將1放到新數組的第一個位置,此時應該將i和新數組的索引都右移一位,然后繼續比較,以此類推,相信這樣大家應該能理解了吧。
3.基數排序
基數排序(radix sort)屬於“分配式排序”(distribution sort),又稱“桶子法”(bucket sort)或bin sort,顧名思義,它是透過鍵值的部份資訊,將要排序的元素分配至某些“桶”中,藉以達到排序的作用,基數排序法是屬於穩定性的排序,其時間復雜度為O(nlog( r )m),其中r為所采取的基數,而m為堆數,在某些時候,基數排序法的效率高於其它的穩定性排序法。基數排序是用空間換時間的經典算法。
演示:
基數排序的基本思想是:
將所有待比較的數值統一為同樣的數位長度,數位較短的數前面補零。然后,從最低位開始,依次進行一次排序,這樣從最低位排序一直到最高位排序完成以后,數列就變為了一個有序序列。
這樣說可能過於抽象,我們通過詳細步驟來分析一下:
我們假設有一個待排序數組[53,3,542,748,14,214],那么如何使用基數排序對其進行排序呢?
首先我們有這樣的十個一維數組,在基數排序中也叫桶。
那么第一輪排序開始,我們依次遍歷每個元素,並得到元素的個位數。拿到的第一個元素為53,其個位數為3,所以將53放入編號為3的桶中,第二個元素3的個位數也是3,所以也放在編號為3的桶中,而第三個元素542的個位數為2,所以將542放入編號為2的桶中,以此類推。
所以結果為:
將元素全部放入桶中之后,我們需要按照桶的順序(也就是一維數組的下標)依次取出數據,並放回原來的數組。
那么很簡單,按順序取出數據並放回原數組之后,原數組將變為[542,53,3,14,214,748]。
這樣第一輪就完成了,接下來開始第二輪。
第二輪排序和第一輪類似,也要去遍歷數組元素,但不同的是第二輪的存放順序取決於十位數。
取出數據的第一個元素為542,十位數為4,所以放入編號為4的桶;第二個元素53,十位數為5,所以放入編號為5的桶;第三個元素3,十位數為0,所以放入編號為0的桶,以此類推。
所以結果為:
然后同樣按照桶的順序將數據從中取出並放入原數組,此時原數組變為[3,14,214,542,748,53]。
接下來又進行第三輪排序,以元素的百位數進行區分,結果為:
按順序取出數據后,原數組變為[3,14,53,214,542,748]。這時的數組已經完成排序。
從中我們也可以知道,基數排序的排序輪數取決於數組元素中最大位數的元素。
代碼如下:
public static void raixSort(int[] arr) { // 第一輪(針對每個元素的個位進行排序處理) // 定義一個二維數組,模擬桶,每個桶就是一個一維數組 // 為了防止放入數據的時候桶溢出,我們應該盡量將桶的容量設置得大一些 int[][] bucket = new int[10][arr.length]; // 記錄每個桶中實際存放的元素個數 // 定義一個一維數組來記錄每個桶中每次放入的元素個數 int[] bucketElementCounts = new int[10]; for (int j = 0; j < arr.length; j++) { // 取出每個元素的個位 int digitOfElement = arr[j] % 10; // 將元素放入對應的桶中 // bucketElementCounts[digitOfElement]就是桶中的元素個數,初始為0,放在第一位 bucket[digitOfElement][bucketElementCounts[digitOfElement]] = arr[j]; // 將桶中的元素個數++ // 這樣接下來的元素就可以排在前面的元素后面 bucketElementCounts[digitOfElement]++; } // 按照桶的順序取出數據並放回原數組 int index = 0; for (int k = 0; k < bucket.length; k++) { // 如果桶中有數據,才取出放回原數組 if (bucketElementCounts[k] != 0) { // 說明桶中有數據,對該桶進行遍歷 for (int l = 0; l < bucketElementCounts[k]; l++) { // 取出元素放回原數組 arr[index++] = bucket[k][l]; } } // 第一輪處理后,需要將每個bucketElementCounts[k]置0 bucketElementCounts[k] = 0; } System.out.println("第一輪:" + Arrays.toString(arr)); // ---------------------------- // 第二輪(針對每個元素的十位進行排序處理) for (int j = 0; j < arr.length; j++) { // 取出每個元素的十位 int digitOfElement = arr[j] / 10 % 10; // 將元素放入對應的桶中 // bucketElementCounts[digitOfElement]就是桶中的元素個數,初始為0,放在第一位 bucket[digitOfElement][bucketElementCounts[digitOfElement]] = arr[j]; // 將桶中的元素個數++ // 這樣接下來的元素就可以排在前面的元素后面 bucketElementCounts[digitOfElement]++; } // 按照桶的順序取出數據並放回原數組 index = 0; for (int k = 0; k < bucket.length; k++) { // 如果桶中有數據,才取出放回原數組 if (bucketElementCounts[k] != 0) { // 說明桶中有數據,對該桶進行遍歷 for (int l = 0; l < bucketElementCounts[k]; l++) { // 取出元素放回原數組 arr[index++] = bucket[k][l]; }