程式設計師進階-八大演算法攻略
常見的八大排序演算法,以及它們之間的關係如下所示:
一、插入排序-直接插入排序
1.演算法思想:直接插入排序是一種簡單插入排序,基本思想是:把n個待排序的元素看成為一個有序表和一個無序表。開始時有序表中只包含1個元素,無序表中包含有n-1個元素,排序過程中每次從無序表中取出第一個元素,將它插入到有序表中的適當位置,使之成為新的有序表,重複n-1次可完成排序過程 。
很簡單吧,接下來,我們要將這個演算法轉化為程式語言。
假設有一組無序序列 R0, R1, ... , RN-1。
(1) 我們先將這個序列中下標為 0 的元素視為元素個數為 1 的有序序列。
(2) 然後,我們要依次把 R1, R2, ... , RN-1 插入到這個有序序列中。所以,我們需要一個外部迴圈,從下標 1 掃描到 N-1 。
(3) 接下來描述插入過程。假設這是要將 Ri 插入到前面有序的序列中。由前面所述,我們可知,插入Ri時,前 i-1 個數肯定已經是有序了。
所以我們需要將Ri 和R0 ~ Ri-1 進行比較,確定要插入的合適位置。這就需要一個內部迴圈,我們一般是從後往前比較,即從下標 i-1 開始向 0 進行掃描。
2.核心程式碼
public void insertSort(int[] list) { // 列印第一個元素 System.out.format("i = %d:\t", 0); printPart(list, 0, 0); // 第1個數肯定是有序的,從第2個數開始遍歷,依次插入有序序列 for (int i = 1; i < list.length; i++) { int j = 0; int temp = list[i]; // 取出第i個數,和前i-1個數比較後,插入合適位置 // 因為前i-1個數都是從小到大的有序序列,所以只要當前比較的數(list[j])比temp大,就把這個數後移一位 for (j = i - 1; j >= 0 && temp < list[j]; j--) { list[j + 1] = list[j]; } list[j + 1] = temp; System.out.format("i = %d:\t", i); printPart(list, 0, i); } }
3.演算法時間、空間複雜度、穩定性分析
二、插入排序-希爾排序
1.演算法思想:把記錄按步長 gap分組,對每組記錄採用直接插入排序方法進行排序。
隨著步長逐漸減小,所分成的組包含的記錄越來越多,當步長的值減小到1時,整個資料合成為一組,構成一組有序記錄,則完成排序。
在上面這幅圖中:
初始時,有一個大小為 10 的無序序列。
在第一趟排序中,我們不妨設 gap1 = N / 2 = 5,即相隔距離為 5 的元素組成一組,可以分為 5 組。
接下來,按照直接插入排序的方法對每個組進行排序。
在第二趟排序中,我們把上次的 gap 縮小一半,即 gap2 = gap1 / 2 = 2 (取整數)。這樣每相隔距離為 2 的元素組成一組,可以分為 2 組。
按照直接插入排序的方法對每個組進行排序。
在第三趟排序中,再次把 gap 縮小一半,即gap3 = gap2 / 2 = 1。 這樣相隔距離為 1 的元素組成一組,即只有一組。
按照直接插入排序的方法對每個組進行排序。此時,排序已經結束。
2.核心程式碼
public void shellSort(int[] list) { int gap = list.length / 2; while (1 <= gap) { // 把距離為 gap 的元素編為一個組,掃描所有組 for (int i = gap; i < list.length; i++) { int j = 0; int temp = list[i]; // 對距離為 gap 的元素組進行排序 for (j = i - gap; j >= 0 && temp < list[j]; j = j - gap) { list[j + gap] = list[j]; } list[j + gap] = temp; } System.out.format("gap = %d:\t", gap); printAll(list); gap = gap / 2; // 減小增量 } }
3.演算法時間、空間複雜度、穩定性分析
三、選擇排序-簡單選擇排序
1.演算法思想:
(1)從待排序序列中,找到關鍵字最小的元素;
(2)如果最小元素不是待排序序列的第一個元素,將其和第一個元素互換;
(3)從餘下的 N - 1 個元素中,找出關鍵字最小的元素,重複(1)、(2)步,直到排序結束。
2.核心程式碼
public void selectionSort(int[] list) {
// 需要遍歷獲得最小值的次數
// 要注意一點,當要排序 N 個數,已經經過 N-1 次遍歷後,已經是有序數列
for (int i = 0; i < list.length - 1; i++) {
int temp = 0;
int index = i; // 用來儲存最小值得索引
// 尋找第i個小的數值
for (int j = i + 1; j < list.length; j++) {
if (list[index] > list[j]) {
index = j;
}
}
// 將找到的第i個小的數值放在第i個位置上
temp = list[index];
list[index] = list[i];
list[i] = temp;
System.out.format("第 %d 趟:\t", i + 1);
printAll(list);
}
}
3.演算法時間、空間複雜度、穩定性分析
四、選擇排序-堆排序
1.演算法思想:
首先,按堆的定義將陣列R[0..n]調整為堆(這個過程稱為建立初始堆),交換R[0]和R[n];
然後,將R[0..n-1]調整為堆,交換R[0]和R[n-1];
如此反覆,直到交換了R[0]和R[1]為止。
以上思想可歸納為兩個操作:
(1)根據初始陣列去構造初始堆(構建一個完全二叉樹,保證所有的父結點都比它的孩子結點數值大)。
(2)每次交換第一個和最後一個元素,輸出最後一個元素(最大值),然後把剩下元素重新調整為大根堆。
當輸出完最後一個元素後,這個陣列已經是按照從小到大的順序排列了。
2.核心程式碼
public void HeapAdjust(int[] array, int parent, int length) {
int temp = array[parent]; // temp儲存當前父節點
int child = 2 * parent + 1; // 先獲得左孩子
while (child < length) {
// 如果有右孩子結點,並且右孩子結點的值大於左孩子結點,則選取右孩子結點
if (child + 1 < length && array[child] < array[child + 1]) {
child++;
}
// 如果父結點的值已經大於孩子結點的值,則直接結束
if (temp >= array[child])
break;
// 把孩子結點的值賦給父結點
array[parent] = array[child];
// 選取孩子結點的左孩子結點,繼續向下篩選
parent = child;
child = 2 * child + 1;
}
array[parent] = temp;
}
public void heapSort(int[] list) {
// 迴圈建立初始堆
for (int i = list.length / 2; i >= 0; i--) {
HeapAdjust(list, i, list.length);
}
// 進行n-1次迴圈,完成排序
for (int i = list.length - 1; i > 0; i--) {
// 最後一個元素和第一元素進行交換
int temp = list[i];
list[i] = list[0];
list[0] = temp;
// 篩選 R[0] 結點,得到i-1個結點的堆
HeapAdjust(list, 0, i);
System.out.format("第 %d 趟: \t", list.length - i);
printPart(list, 0, list.length - 1);
}
}
3.演算法時間、空間複雜度、穩定性分析
五、交換排序-氣泡排序
1.演算法思想:假設有一個大小為 N 的無序序列。氣泡排序就是要每趟排序過程中通過兩兩比較,找到第 i 個小(大)的元素,將其往上排。
以上圖為例,演示一下氣泡排序的實際流程:
假設有一個無序序列 { 4. 3. 1. 2, 5 }
第一趟排序:通過兩兩比較,找到第一小的數值 1 ,將其放在序列的第一位。
第二趟排序:通過兩兩比較,找到第二小的數值 2 ,將其放在序列的第二位。
第三趟排序:通過兩兩比較,找到第三小的數值 3 ,將其放在序列的第三位。
至此,所有元素已經有序,排序結束。
要將以上流程轉化為程式碼,我們需要像機器一樣去思考,不然編譯器可看不懂。
假設要對一個大小為 N 的無序序列進行升序排序(即從小到大)。
(1) 每趟排序過程中需要通過比較找到第 i 個小的元素。
所以,我們需要一個外部迴圈,從陣列首端(下標 0) 開始,一直掃描到倒數第二個元素(即下標 N - 2) ,剩下最後一個元素,必然為最大。
(2) 假設是第 i 趟排序,可知,前 i-1 個元素已經有序。現在要找第 i 個元素,只需從陣列末端開始,掃描到第 i 個元素,將它們兩兩比較即可。
所以,需要一個內部迴圈,從陣列末端開始(下標 N - 1),掃描到 (下標 i + 1)。
2.核心程式碼
public void bubbleSort(int[] list) {
int temp = 0; // 用來交換的臨時數
// 要遍歷的次數
for (int i = 0; i < list.length - 1; i++) {
// 從後向前依次的比較相鄰兩個數的大小,遍歷一次後,把陣列中第i小的數放在第i個位置上
for (int j = list.length - 1; j > i; j--) {
// 比較相鄰的元素,如果前面的數大於後面的數,則交換
if (list[j - 1] > list[j]) {
temp = list[j - 1];
list[j - 1] = list[j];
list[j] = temp;
}
}
System.out.format("第 %d 趟:\t", i);
printAll(list);
}
}
3.演算法時間、空間複雜度、穩定性分析
六、交換排序-快速排序
1.演算法思想:
通過一趟排序將要排序的資料分割成獨立的兩部分:分割點左邊都是比它小的數,右邊都是比它大的數。
然後再按此方法對這兩部分資料分別進行快速排序,整個排序過程可以遞迴進行,以此達到整個資料變成有序序列。
詳細的圖解往往比大堆的文字更有說明力,所以直接上圖:
上圖中,演示了快速排序的處理過程:
初始狀態為一組無序的陣列:2、4、5、1、3。
經過以上操作步驟後,完成了第一次的排序,得到新的陣列:1、2、5、4、3。
新的陣列中,以2為分割點,左邊都是比2小的數,右邊都是比2大的數。
因為2已經在陣列中找到了合適的位置,所以不用再動。
2左邊的陣列只有一個元素1,所以顯然不用再排序,位置也被確定。(注:這種情況時,left指標和right指標顯然是重合的。因此在程式碼中,我們可以通過設定判定條件left必須小於right,如果不滿足,則不用排序了)。
而對於2右邊的陣列5、4、3,設定left指向5,right指向3,開始繼續重複圖中的一、二、三、四步驟,對新的陣列進行排序。
2.核心程式碼public int division(int[] list, int left, int right) {
// 以最左邊的數(left)為基準
int base = list[left];
while (left < right) {
// 從序列右端開始,向左遍歷,直到找到小於base的數
while (left < right && list[right] >= base)
right--;
// 找到了比base小的元素,將這個元素放到最左邊的位置
list[left] = list[right];
// 從序列左端開始,向右遍歷,直到找到大於base的數
while (left < right && list[left] <= base)
left++;
// 找到了比base大的元素,將這個元素放到最右邊的位置
list[right] = list[left];
}
// 最後將base放到left位置。此時,left位置的左側數值應該都比left小;
// 而left位置的右側數值應該都比left大。
list[left] = base;
return left;
}
private void quickSort(int[] list, int left, int right) {
// 左下標一定小於右下標,否則就越界了
if (left < right) {
// 對陣列進行分割,取出下次分割的基準標號
int base = division(list, left, right);
System.out.format("base = %d:\t", list[base]);
printPart(list, left, right);
// 對“基準標號“左側的一組數值進行遞迴的切割,以至於將這些數值完整的排序
quickSort(list, left, base - 1);
// 對“基準標號“右側的一組數值進行遞迴的切割,以至於將這些數值完整的排序
quickSort(list, base + 1, right);
}
}
3.演算法時間、空間複雜度、穩定性分析
七、歸併排序
1.演算法思想:
將待排序序列R[0...n-1]看成是n個長度為1的有序序列,將相鄰的有序表成對歸併,得到n/2個長度為2的有序表;將這些有序序列再次歸併,得到n/4個長度為4的有序序列;如此反覆進行下去,最後得到一個長度為n的有序序列。
綜上可知:
歸併排序其實要做兩件事:
(1)“分解”——將序列每次折半劃分。
(2)“合併”——將劃分後的序列段兩兩合併後排序。
2.核心程式碼
(1)如何合併
在每次合併過程中,都是對兩個有序的序列段進行合併,然後排序。
這兩個有序序列段分別為 R[low, mid] 和 R[mid+1, high]。
先將他們合併到一個區域性的暫存陣列R2中,帶合併完成後再將R2複製回R中。
為了方便描述,我們稱 R[low, mid] 第一段,R[mid+1, high] 為第二段。
每次從兩個段中取出一個記錄進行關鍵字的比較,將較小者放入R2中。最後將各段中餘下的部分直接複製到R2中。
經過這樣的過程,R2已經是一個有序的序列,再將其複製回R中,一次合併排序就完成了。
public void Merge(int[] array, int low, int mid, int high) {
int i = low; // i是第一段序列的下標
int j = mid + 1; // j是第二段序列的下標
int k = 0; // k是臨時存放合併序列的下標
int[] array2 = new int[high - low + 1]; // array2是臨時合併序列
// 掃描第一段和第二段序列,直到有一個掃描結束
while (i <= mid && j <= high) {
// 判斷第一段和第二段取出的數哪個更小,將其存入合併序列,並繼續向下掃描
if (array[i] <= array[j]) {
array2[k] = array[i];
i++;
k++;
} else {
array2[k] = array[j];
j++;
k++;
}
}
// 若第一段序列還沒掃描完,將其全部複製到合併序列
while (i <= mid) {
array2[k] = array[i];
i++;
k++;
}
// 若第二段序列還沒掃描完,將其全部複製到合併序列
while (j <= high) {
array2[k] = array[j];
j++;
k++;
}
// 將合併序列複製到原始序列中
for (k = 0, i = low; i <= high; i++, k++) {
array[i] = array2[k];
}
}
(2)如何分解
在某趟歸併中,設各子表的長度為gap,則歸併前R[0...n-1]中共有n/gap個有序的子表:R[0...gap-1], R[gap...2*gap-1], ... , R[(n/gap)*gap ... n-1]。
呼叫Merge將相鄰的子表歸併時,必須對錶的特殊情況進行特殊處理。
若子表個數為奇數,則最後一個子表無須和其他子表歸併(即本趟處理輪空):若子表個數為偶數,則要注意到最後一對子表中後一個子表區間的上限為n-1。
public void MergePass(int[] array, int gap, int length) {
int i = 0;
// 歸併gap長度的兩個相鄰子表
for (i = 0; i + 2 * gap - 1 < length; i = i + 2 * gap) {
Merge(array, i, i + gap - 1, i + 2 * gap - 1);
}
// 餘下兩個子表,後者長度小於gap
if (i + gap - 1 < length) {
Merge(array, i, i + gap - 1, length - 1);
}
}
public int[] sort(int[] list) {
for (int gap = 1; gap < list.length; gap = 2 * gap) {
MergePass(list, gap, list.length);
System.out.print("gap = " + gap + ":\t");
this.printAll(list);
}
return list;
}
3.演算法時間、空間複雜度、穩定性分析
八、基數排序
1.演算法思想:
不妨通過一個具體的例項來展示一下,基數排序是如何進行的。
設有一個初始序列為: R {50, 123, 543, 187, 49, 30, 0, 2, 11, 100}。
我們知道,任何一個阿拉伯數,它的各個位數上的基數都是以0~9來表示的。
所以我們不妨把0~9視為10個桶。
我們先根據序列的個位數的數字來進行分類,將其分到指定的桶中。例如:R[0] = 50,個位數上是0,將這個數存入編號為0的桶中。
分類後,我們在從各個桶中,將這些數按照從編號0到編號9的順序依次將所有數取出來。
這時,得到的序列就是個位數上呈遞增趨勢的序列。
按照個位數排序: {50, 30, 0, 100, 11, 2, 123, 543, 187, 49}。
接下來,可以對十位數、百位數也按照這種方法進行排序,最後就能得到排序完成的序列。