0. 前言
評價一個算法的好壞,除了其是否具有較低的時間複雜度之外,還有其穩定性以及空間複雜度。
穩定性的判斷標註是數組元素的值相同時,進行元素交換後,相對位置如果發生變化,變化則不具有穩定性。
1. 冒泡算法
冒泡排序可以說是每個程序員接觸到的第一個排序算法,其算法思想較爲簡單。
在每一輪的排序中,對待排序子數組中的相鄰元素進行比較,如果逆序,則交換位置。當一輪結束後,待排序子數組中最大的元素便出現了子數組最後一個位置。
具體如下圖所示:
冒泡排序的代碼如下所示:
package sort;
import java.util.Arrays;
/**
* @author wangzhao
* @date 2020/6/15 20:48
*/
public class BubbleSort {
public static void sort(int[] array){
if (array == null){
return;
}
for (int i = 0; i <array.length; i++){
// 不斷縮小子數組的範圍
for (int j = 1; j < array.length - i; j++){
// 如果逆序,則交換位置
if (array[j] < array[j - 1]){
swap(array, j, j-1);
}
}
}
}
private static void swap(int[] array, int j, int i) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
一個長度爲n
的數組,上述代碼需要進行n-1
趟的排序。但是我們可以看上圖,在第一趟排序後,整個數字便完全有序了,無需進行後續操作。
所以上述代碼可進行優化,優化後的代碼如下:
public static void sort(int[] array){
if (array == null){
return;
}
for (int i = 0; i <array.length; i++){
// flag 用於標識該趟有沒有進行元素交換,沒有交換的話,則說明已經完全沒有了
boolean flag = false;
for (int j = 1; j < array.length - i; j++){
flag = true;
if (array[j] < array[j - 1]){
swap(array, j, j-1);
}
}
if(!flag){
break;
}
}
}
接下來分析冒泡算法的效率。
時間複雜度O(n)=(n^2)
,最壞的情況下,數組爲一個完全逆序的數組,此時不管是否優化,依然需要O(n)=(n^2)
,如果是一個有序的數組,那麼在優化後的代碼,只需要O(n)=(n)
。
空間複雜度O(1)
,因爲我們只引用到了固定的常量而已。
穩定性:穩定。
2. 插入算法
插入排序有點像我們打撲克牌時,把小的牌插入到左邊,以此達到這個牌有序。
將每一個元素插入到其他已經有序的元素中的適當位置。當前索引左邊的元素都是有序的,但他們的最終位置還不確定,爲了給更小的元素騰出空間,它們可能會向右移動。
package sort;
import java.util.Arrays;
/**
* @author wangzhao
* @date 2020/6/15 21:24
*/
public class InsertSort {
public static void sort(int[] array){
if (array == null){
return;
}
for(int i = 1; i < array.length; i++){
for (int j = i; j > 0 && array[j] < array[j-1]; j--){
swap(array, j, j-1);
}
}
}
private static void swap(int[] array, int j, int i) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
接下來分析插入算法的效率。
時間複雜度O(n)=(n^2)
,最壞的情況下,數組爲一個完全逆序的數組,需要O(n)=(n^2)
,如果是一個有序的數組,只需要O(n)=(n)
。
空間複雜度O(1)
,因爲我們只引用到了固定的常量而已。
穩定性:穩定。
3. 希爾排序
對於插入排序來說,它只會交換相鄰的元素,因此它只能一點一點地從數組地一端移動到另一端,如果最小元素在最右邊,需要n-1
次交換才能移動最左端。
希爾排序算法思想:使數組中任意間隔爲h
的元素都是有序地,這樣的數組稱爲h
有序數組。然後不斷縮小h的值,直到h
爲1
,此時可看作是插入排序。相比於插入排序,這樣可以上述的極端情況下,更快的將元素交換到它的正確位置。
package sort;
import java.util.Arrays;
/**
* @author wangzhao
* @date 2020/6/15 21:46
*/
public class ShellSort {
public static void sort(int[] array){
if (array == null){
return;
}
// 通常 h 並不是如此計算,有更好的方法,這裏只說明算法思想,暫如此採用
int h = array.length / 2;
while (h >= 1){
for (int i = h; i < array.length; i++){
for (int j = i; j > h && array[j] < array[j-h]; j -= h){
swap(array, j, j-h);
}
}
// 不斷縮小 h 的值,直到爲 1
h /= 2;
}
}
}
接下來分析希爾算法的效率。
時間複雜度O(n^(1.3—2))
,最後h
會退化到1
,但如果前期的排序中,能夠將小元素直接放到前面,一定程度上可以較低時間複雜的。
空間複雜度O(1)
,因爲我們只引用到了固定的常量而已。
穩定性:不穩定。
4. 歸併排序
算法思想:要將一個數組排序,可以先(遞歸地)將它分成兩半分別排序,然後將結果歸併起來。
歸併排序,分爲兩部分,一部分爲遞歸,另一部分則爲合併。
既然遞歸,則一定存在遞歸的終止條件,終止條件爲只存在一個元素,如何判斷只存在一個元素,左右下標相等。
接着是進行合併,合併則是對兩個有序數組進行合併,藉助一個輔助數組很容易實現。
package sort;
import java.util.Arrays;
/**
* @author wangzhao
* @date 2020/6/15 22:13
*/
public class MergeSort {
// 輔助數組
private static int[] aux_array = null;
public static void sort(int[] array){
if (array == null){
return;
}
aux_array = new int[array.length];
int left = 0;
int right = array.length - 1;
recursive(array, left, right);
}
public static void recursive(int[] array, int left, int right){
// 只存在一個元素,遞歸結束
if (left == right)
return;
int mid = ((left - right) >> 1) + right;
recursive(array, left, mid);
recursive(array, mid+1, right);
merge(array, left, mid, right);
}
private static void merge(int[] array, int left, int mid, int right) {
int index = left;
int i, j = 0;
for (i = left, j = mid + 1; i <= mid && j <= right;){
if(array[i] < array[j]){
aux_array[index++] = array[i++];
}else{
aux_array[index++] = array[j++];
}
}
while (i <= mid){
aux_array[index++] = array[i++];
}
while (j <= right) {
aux_array[index++] = array[j++];
}
// 此時,輔助數組已經將子數組合並完成,將輔助數組中的元素拷貝回原數組中
for (index = left; index <= right; index++){
array[index] = aux_array[index];
}
}
}
接下來分析歸併算法的效率。
時間複雜度O(nlog(n))
。
空間複雜度O(n)
,有相關論文表示可以做到O(1)
,如感興趣,可自行查閱。
穩定性:穩定。
5. 快速排序
算法思想:每次在數組中,選擇一個哨兵,比哨兵大的元素放到其右邊,小的則放到其左邊,這樣每一次就將哨兵的元素給排好了,然後對哨兵左右的子數組進行遞歸上述操作。
將大於哨兵的元素放到其右邊,小於哨兵的放到其左邊,並且返回哨兵的下標可以才用如下算法:
將數組劃分爲兩個區域,分別爲大於和小於等於的區域,具體代碼如下:
public static int partition(int[] array, int left, int right){
// 將數組劃分爲兩部分,low 區爲小於等於哨兵array[right]的區域,high 區爲大於哨兵array[right]的區域
int low = left - 1;
int high = right;
for(int i = left; i < high; i++){
if(array[i] <= array[right]){
// low 區擴大
swap(array, i, ++low);
}else{
// high 區擴大
swap(array, i, --high);
// 注意,這裏要進行 i--,因爲換來的元素不知道其大小
i--;
}
}
// 最後,將哨兵放到其正確的位置
swap(array, right, high);
return high;
}
完整代碼如下:
package sort;
import java.util.Arrays;
/**
* @author wangzhao
* @date 2020/6/15 23:14
*/
public class QuickSort {
public static void sort(int[] array){
if (array == null){
return;
}
int left = 0;
int right = array.length - 1;
quickSort(array, left, right);
}
public static void quickSort(int[] array, int left, int right){
if (right == left || right <= 0 || left >= array.length - 1)
return;
int index = partition(array, left, right);
quickSort(array, left, index-1);
quickSort(array, index + 1, right);
}
public static int partition(int[] array, int left, int right){
// 將數組劃分爲兩部分,low 區爲小於等於哨兵array[right]的區域,high 區爲大於哨兵array[right]的區域
int low = left - 1;
int high = right;
for(int i = left; i < high; i++){
if(array[i] <= array[right]){
// low 區擴大
swap(array, i, ++low);
}else{
// high 區擴大
swap(array, i, --high);
// 注意,這裏要進行 i--,因爲換來的元素不知道其大小
i--;
}
}
// 最後,將哨兵放到其正確的位置
swap(array, right, high);
return high;
}
}
快排優化,在上面的代碼中,我們每次只能返回一個下標。將數組劃分爲3塊,小於目標值區域,等於目標值區域,大於目標值區域。
這樣當出現相同的元素時,每次返回相等元素的左右邊界,這樣可以對子數組的劃分變小,同時也可以避免無意義的排序。
// 三向切分
private static int[] partition_2(int[] array, int left, int right) {
int lower = left-1; // 小於目標值的區域
int high = right; // 大於等於目標值的區域
for(int i = left; i < high; i++){
if (array[i] < array[right]){
swap(array, i, ++lower);
}else if (array[i] > array[right]){
swap(array, i, --high);
i--;
}else{
continue;
}
}
swap (array, high, right);
return new int[]{lower,high+1};
}
接下來分析快排算法的效率。
時間複雜度O(n)=(nlog(n))
。
空間複雜度O(1)
,因爲我們只引用到了固定的常量而已。
穩定性:不穩定。
6. 堆排序
我們首先介紹一下大頂堆,大頂堆指堆頂的元素大於等於其左右兒子節點的元素,小頂堆則相反。
如下圖,所示一個大頂堆。
[5, 4, 1, 3, 2, 6, 7]
形成大頂堆的形成過程如下:
如上所示,便是大頂堆形成的過程。
堆排序的思路則是將堆頂元素與最後一個元素進行交換,此時堆中的最後一個元素,便是數組最大的元素。交換後,由於不符合大頂堆的定義,所以我們需要對堆進行調整,使之保持大頂堆的形態。
現在我們倒着看每次從堆尾剝離的元素,可以發現其是一個遞增的有序序列。
整個堆排序涉及兩個操作,上浮及下沉。
上浮
當某個節點的大於其父節點(或是在堆底加入了一個新的元素)時,我們需要由下而上恢復堆的順序。
如果當前節點比其父節點大,則交換,直到我們遇到更大的父節點。
/**
*
* @param array 可以視作堆
* @param insertIndex 堆中待插入的下標,需要注意的是,這裏的下標並不是數組最後一個下標,堆中每次插入一個元素後,insertIndex + 1
* @param target 待插入元素
*/
public static void swim(int[] array, int insertIndex, int target){
// 將元素插入堆中
array[insertIndex] = target;
// 判斷是否符合大頂堆
while (insertIndex > 0 && array[insertIndex] > array[insertIndex/2]){
swap(array, insertIndex, insertIndex/2);
insertIndex /= 2;
}
}
下沉
當某個節點的小於其子節點(例如將根節點替換爲一個較小的元素)時,我們需要由上而下恢復堆的順序。
如果某個節點變得比它的兩個子節點或是其中之一更小了,那麼我們可以通過將它和它的兩個子節點中的較大者交換,直到沒有比它更大的子節點或者到達底部爲止。
/**
*
* @param array 可以視作堆
* @param index 需要下沉節點的下標,如果是堆排序,則其默認爲 0
* @param heapLastIndex 堆中最後一個元素的下標,需要注意的是,這裏的下標並不是數組最後一個下標,每次移除堆尾的元素,heapLastIndex - 1
*/
public static void sink(int[] array,int index, int heapLastIndex){
// 不能超過堆中最後一個元素
while (index * 2 < heapLastIndex){
int leftIndex = 2 * index + 1;
int rightIndex = 2 * index + 2;
// 該變量用來記錄兩個節點中較大的下標
int maxIndex = leftIndex;
// 此時,說明一定存在兩個兒子節點
if (rightIndex <= heapLastIndex){
maxIndex = array[leftIndex] > array[rightIndex] ? leftIndex : rightIndex;
}
// 如果父節點,大於最大的兒子節點,符合大頂堆,退出
if (array[index] > array[maxIndex]){
break;
}
swap(array, index, maxIndex);
index = maxIndex;
}
}
完整代碼:
package sort;
import java.util.Arrays;
/**
* @author wangzhao
* @date 2020/6/16 1:08
*/
public class HeapSort {
public static void sort(int[] array){
if (array == null){
return;
}
// 生成大頂堆
for (int i=0; i < array.length; i++){
swim(array, i, array[i]);
}
for (int i=array.length - 1; i > 0; i--){
swap(array, 0, i);
sink(array, 0, i-1);
}
}
/**
*
* @param array 可以視作堆
* @param insertIndex 堆中待插入的下標,需要注意的是,這裏的下標並不是數組最後一個下標,堆中每次插入一個元素後,insertIndex + 1
* @param target 待插入元素
*/
public static void swim(int[] array, int insertIndex, int target){
// 將元素插入堆中
array[insertIndex] = target;
// 判斷是否符合大頂堆
while (insertIndex > 0 && array[insertIndex] > array[insertIndex/2]){
swap(array, insertIndex, insertIndex/2);
insertIndex /= 2;
}
}
/**
*
* @param array 可以視作堆
* @param index 需要下沉節點的下標,如果是堆排序,則其默認爲 0
* @param heapLastIndex 堆中最後一個元素的下標,需要注意的是,這裏的下標並不是數組最後一個下標,每次移除堆尾的元素,heapLastIndex - 1
*/
public static void sink(int[] array,int index, int heapLastIndex){
// 不能超過堆中最後一個元素
while (index * 2 < heapLastIndex){
int leftIndex = 2 * index + 1;
int rightIndex = 2 * index + 2;
// 該變量用來記錄兩個節點中較大的下標
int maxIndex = leftIndex;
// 此時,說明一定存在兩個兒子節點
if (rightIndex <= heapLastIndex){
maxIndex = array[leftIndex] > array[rightIndex] ? leftIndex : rightIndex;
}
// 如果父節點,大於最大的兒子節點,符合大頂堆,退出
if (array[index] > array[maxIndex]){
break;
}
swap(array, index, maxIndex);
index = maxIndex;
}
}
private static void swap(int[] array, int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
}
接下來分析堆排序的效率。
時間複雜度O(n)=(nlog(n))
。
空間複雜度O(1)
,因爲我們只引用到了固定的常量而已。
穩定性:不穩定。
7. 基數排序
基數排序可以參考之前寫的一篇博客。基數排序
8. 總結
至此,所有的主流排序算法都已介紹加Coding
完成,有很多有意思還未介紹的地方。如堆排序中可以被擴展到的優先隊列,這個挺有趣,面試中可能會有這樣一個問題,如何在100
萬的數字中,選擇最大or
最小的10
個數。
雖然上面既有時間複雜度爲O(n^2)
,也有O(nlong)
複雜度的排序算法,但並不意味着時間複雜度越低,表現就越優秀,還和其數據量相同。
對於工程排序,往往是採用多種排序算法的結合,這裏我們以Arrays.sort
爲例,觀察其代碼:
static void sort(int[] a, int left, int right,
int[] work, int workBase, int workLen) {
// Use Quicksort on small arrays
if (right - left < QUICKSORT_THRESHOLD) {
sort(a, left, right, true);
return;
}
/*
* Index run[i] is the start of i-th run
* (ascending or descending sequence).
*/
int[] run = new int[MAX_RUN_COUNT + 1];
int count = 0; run[0] = left;
// Check if the array is nearly sorted
for (int k = left; k < right; run[count] = k) {
if (a[k] < a[k + 1]) { // ascending
while (++k <= right && a[k - 1] <= a[k]);
} else if (a[k] > a[k + 1]) { // descending
while (++k <= right && a[k - 1] >= a[k]);
for (int lo = run[count] - 1, hi = k; ++lo < --hi; ) {
int t = a[lo]; a[lo] = a[hi]; a[hi] = t;
}
} else { // equal
for (int m = MAX_RUN_LENGTH; ++k <= right && a[k - 1] == a[k]; ) {
if (--m == 0) {
sort(a, left, right, true);
return;
}
}
}
/*
* The array is not highly structured,
* use Quicksort instead of merge sort.
*/
if (++count == MAX_RUN_COUNT) {
sort(a, left, right, true);
return;
}
}
// Check special cases
// Implementation note: variable "right" is increased by 1.
if (run[count] == right++) { // The last run contains one element
run[++count] = right;
} else if (count == 1) { // The array is already sorted
return;
}
// Determine alternation base for merge
byte odd = 0;
for (int n = 1; (n <<= 1) < count; odd ^= 1);
// Use or create temporary array b for merging
int[] b; // temp array; alternates with a
int ao, bo; // array offsets from 'left'
int blen = right - left; // space needed for b
if (work == null || workLen < blen || workBase + blen > work.length) {
work = new int[blen];
workBase = 0;
}
if (odd == 0) {
System.arraycopy(a, left, work, workBase, blen);
b = a;
bo = 0;
a = work;
ao = workBase - left;
} else {
b = work;
ao = 0;
bo = workBase - left;
}
// Merging
for (int last; count > 1; count = last) {
for (int k = (last = 0) + 2; k <= count; k += 2) {
int hi = run[k], mi = run[k - 1];
for (int i = run[k - 2], p = i, q = mi; i < hi; ++i) {
if (q >= hi || p < mi && a[p + ao] <= a[q + ao]) {
b[i + bo] = a[p++ + ao];
} else {
b[i + bo] = a[q++ + ao];
}
}
run[++last] = hi;
}
if ((count & 1) != 0) {
for (int i = right, lo = run[count - 1]; --i >= lo;
b[i + bo] = a[i + ao]
);
run[++last] = right;
}
int[] t = a; a = b; b = t;
int o = ao; ao = bo; bo = o;
}
}
根據數據量的多少,採用了插入排序,快速排序,歸併排序三種排序的組合。