【算法與數據結構】萬字長文總結——圖解那些讓你凌亂的七大排序!

算法和數據結構在學習中的重要性是不言而喻的,而排序算法又是面試中的最常考點。由於各個方法的既有差異又有相同以及各自的時間複雜度和空間複雜度都很容易讓人混淆,今天自己也將所有的算法總結起來整理歸納,希望能幫到大家。

一、直接插入排序

1.思路講解

直接插入排序就是每次從無序區間選擇第一個數,插入到有序區間的合適位置。可以參考平時打撲克排時,摸到牌就會插入到自己的已有的牌中去。

2.圖解示例

有待排序序列[3, 5, 9, 4, 2, 1, 7, 6, 8] 將整個空間分爲無序區間和有序區間,每次從無序區間中拿出一個數插入到有序空間中去。

  • 第一次有序區間只有第一個數 3 後面都是無序區間。其中第一次拿出無序區間的第一個數 5 來插入有序區間中
  • 第二次有序區間就變成了橙色區域的3,5,再對無需區間的第一個數 9 來插入排序得到 新的有序區間。

以此類推。
在這裏插入圖片描述
這樣,一個有9個數的序列就需要對無序區間進行8次插入操作。因此看出,當有n個數時就需要循環n-1次。
而對於每一個要插入的數來說,都要去比較有序區間中的數,來確定自己的位置,所以對於內層循環而言要循環有序區間數字個數次。
這裏也借鑑了優秀博主的動圖演示幫助大家理解:
在這裏插入圖片描述

3.代碼演示

有序區間 [0, i]
無序區間 [i + 1 ,array.length]
待插入的數據 array[i +1]插入過程在有序區間內查找
每次要插入的數據會在有序區間內從後往前依次尋找自己的位置,如果不是自己的位置,就順便進行數據的搬移。

    public static void insertSort(int[] array){
        for (int i = 0;i < array.length - 1; i++) {
            int key = array[i+1];
            int j;
            for (j = i; j >= 0 && key < array[j]; j--){
                array[j + 1] = array[j];  //數據搬移
            }
            array[j + 1] = key;
        }
    }
    

步驟:

  1. 每次把無序區間的第一個數,在有序區間內遍歷(從後往前遍歷)
  2. 找到合適的位置
  3. 搬移原有數據,騰出位置

4.性能分析

(1)時間複雜度

最好 最壞 平均
O(n) O(n2n^2) O(n2n^2)

最好情況:數組有序,只執行了外層循環
最壞情況:數組倒序,每次都要執行雙重循環

插入排序越接近有序,執行時間效率越高

(2)空間複雜度 —— O(1)

因爲沒有多餘使用的空間所以空間複雜度爲常數級別。

(3)穩定性 —— 穩定

每次插入數據時,在有序區間裏從後向前遍歷插入,可以保證維持原有的順序

二、冒泡排序

1. 思路講解

冒泡排序也是插入排序的一種,它的思想是重複遍歷無序區間,每次比較相鄰兩個數的大小,進行交換,直到最後將所有數字都排好序。這個算法的名字由來是因爲越小的元素會經由交換慢慢“浮”到數列的頂端。

2.圖解示例

3.代碼演示

代碼中添加isSorted標誌位來檢驗整個數組是否是有序的,如果外部循環一次後都沒改變標誌位的值,那麼就可以得出整個數組是有序的。

 public void bubbleSort (int[] arr) {
 		boolean isSorted = true;
        for(int i = 0; i < arr.length - 1; i ++) {
            for(int j = 0; j < arr.length - i - 1; j ++) {
                if(arr[j] > arr[j + 1]) {
                    int tmp = arr[j];
                    arr[j] = arr[j + 1];
                    arr[j + 1] = tmp;
                    isSorted = false;
                }
            }
            if(isSorted){
            	return ;
            }
        }
    }

4.性能分析

(1)時間複雜度

最好 最壞 平均
O(n) O(n2n^2) O(n2n^2)

最好情況:數組有序,只執行了外層循環
最壞情況:數組倒序,每次都要執行雙重循環

(2)空間複雜度 —— O(1)

因爲沒有多餘使用的空間所以空間複雜度爲常數級別。

(3)穩定性 —— 穩定

冒泡排序只在相鄰元素大小不符合要求時才調換他們的位置, 它並不改變相同元素之間的相對順序, 因此它是穩定的排序算法。

三、希爾排序(shell sort)

1.思路講解

由於插入排序的時間複雜度很不理想,平均的複雜度都要是O(n2n^2),所以我們就需要新的算法來對其進行優化。
希爾排序就是對插入排序的優化,那麼希爾排序是怎麼做到的呢?
希爾排序是根據插入排序的特性:數組越接近有序,則效率越高。便使得數組儘可能趨於有序。首先將所有的數每次進行分組,一組數之間的間隔我們將其稱爲分組增量。分組的增量由大變小最後逐漸減小爲0。對於每一組都進行插入排序,每排好一次序,數據就更加接近有序
那麼就有一個問題了,每次應該怎樣確定增量才合理呢?
一般情況下,如果數據的長度爲size,那麼每一次取得的增量值一般記作gap,它們之間就有如下關係:
gap = size; gap = gap / 3 + 1; 或者是gap = gap / 2;
例如:一個數組長度是10,那麼可以將增量依次確定爲4,2,1;也可以確定爲 5 ,2,1

2.圖解示例

現在有待排序序列 [3, 5, 9, 4, 2, 1, 7, 6, 8,0]
假設將 gap 分別取4, 2, 1
每次的同一個顏色的數字爲一組
經過三次排序:
在這裏插入圖片描述
每次在小組中都進行簡單的插入排序使得數據越來越接近有序化,最終當gap爲1 時進行插排也會更快

3.代碼演示

	//對每個小組進行插入排序
	private static void insertSortWithGap(int[] array, int gap) {
		for (int i = 0; i < array.length - gap; i++) {
			int key = array[i + gap];
			int j;
			for (j = i; j >= 0 && key < array[j]; j -= gap) {
				array[j + gap] = array[j];
			}
			array[j + gap] = key;
		}
	}
//確定每次的分組
	public static void shellSort(int[] array) {
	    int gap = array.length;
	    while (true) {
	        gap = gap / 3 + 1;
	        insertSortWithGap(array, gap);
	        if (gap == 1) {
	            return;
			}
		}
	}

4.性能分析

(1)時間複雜度

由於數據逐漸接近有序,因此,希爾排序的平均複雜度要優於直接插排

最好 最壞 平均
O(n) O(n2n^2) O(n^(1.3-1.4))

(2)空間複雜度 —— O(1)

沒有額外消耗空間,因此空間複雜度爲常數級

(3)穩定性 —— 不穩定

有可能相同的兩個數被分到不同的組別裏,因此無法保證排序後的結果

四、選擇排序

1.思路講解

每次都遍歷無序區間的數(這裏可以直接遍歷或者使用堆),選擇出無序區間中最大的數,再把最大的數放到無序區間最後。一直選擇n-1個數字後,數據完全有序。選擇排序對總體數據不敏感,也即無論給定的數據的順序,都不會影響複雜度。

2.圖解示例

同樣有待排序序列 [3, 5, 9, 4, 2, 1, 7, 6, 8,0]
這裏演示簡單的直接遍歷方式
其中,黃色表示無序區間,藍色表示有序區間
每次在無序區間內遍歷選擇出最大的數字與下一個要成爲有序區間的位置進行交換,紅色字體表示每次交換位置後的兩個數字。在執行 n-1 次後,數據就完全有序
在這裏插入圖片描述
也可以每次在無序區間內選擇處最小的數字進行交換n-1次,也是同樣的排好了序。這裏有動圖幫助大家更好的理解:
在這裏插入圖片描述

3.代碼演示

方式一: 大數字作爲有序區間,小數字作爲無序區間,每次找最大數字放最後。
無序區間 [0, array.length - i)
有序區間 [array.lenngth - i, array.length)
max 表示在無序區間選擇的最大數字的下標

 public void selectSort(int[] arr) {
        for(int i = 0; i < arr.length - 1; i ++) {
            int max = 0;
            //選出待排序區間內最大的值
            for(int j = 0; j < arr.length - i; j ++){
                if(arr[j] >= arr[max]) {
                    max = j;
                }
            }
            //交換
            int tmp = arr[arr.length - i - 1];
            arr[arr.length - 1 -i] = arr[max];
            arr[max] = tmp;
        }
    }

方式二: 小數字作爲有序區間,大數字作爲無序區間,每次找最小數字放最後。
有序區間 [0,i)
無序區間 [ i ,arr.length )
min 表示在無序區間選擇的最小數字的下標

    public void selectSort(int[] arr) {
        
        for(int i = 0; i < arr.length - 1; i ++) {
            int min = i;
            //選出待排序區間內最小的值
            for(int j = i; j < arr.length ; j ++){
                if(arr[j] < arr[min]) {
                    min = j;
                }
            }
            //交換
            int tmp = arr[i];
            arr[i] = arr[min];
            arr[min] = tmp;
        }
    }

4.性能分析

(1)時間複雜度

最好 最壞 平均
O(n2O(n^2) O(n2)O(n^2) O(n2)O(n^2)

(2)空間複雜度 —— O(1)

沒有額外消耗空間,因此空間複雜度爲常數級

(3)穩定性 —— 不穩定

比如【5,5,3】第一次交換就將第一個5交換到原來3的位置,導致兩個5的相對位置就發生了改變。

五、堆排序

1.思路講解

堆通常是一個可以被看做一棵完全二叉樹的數組對象,並同時滿足堆積的性質:即子結點的鍵值或索引總是小於(或者大於)它的父節點。
這樣我們就可以知道如果是一個小堆,那麼堆頂節點一定是最小的;如果是大堆,堆頂節點就是最大的。通過這個性質,就有了堆排序。

首先我們先要知道怎樣進行建堆以及堆化(以大堆爲例):

  • 如果index已經是葉子節點,則整個調整過程結束
    (1)判斷 index 位置有沒有孩子
    (2) 因爲堆是完全二叉樹,沒有左孩子就一定沒有右孩子,所以判斷是否有左孩子
    (3) 因爲堆的存儲結構是數組,所以判斷是否有左孩子即判斷左孩子下標是否越界,即 left >= size 越界
  • 確定 left 或 right,誰是 index 的最大孩子 max
    (1) 如果右孩子不存在,則 max = left
    (2) 否則,比較 array[left] 和 array[right] 值得大小,選擇大的爲 max
  • 比較 array[index] 的值 和 array[max] 的值,如果 array[index] >= array[max],則滿足堆的性質,調整結束
  • 否則,交換 array[index] 和 array[mav] 的值
  • 然後因爲 max 位置的堆的性質可能被破壞,所以把 max 視作 index,向下重複以上過程

將整個堆建好後,隨着對堆的調整後,位於堆頂的就是最大的元素,此時就可以得到最大的元素。將最後一個葉子節點與堆頂元素進行交換後,再次進行堆化操作又可以得到堆頂元素,此次得到的堆頂元素就是次大的,再次交換堆化…以此類推,多次交換以後,就可以逐步的實現排序操作。

對於排升序而言,推薦使用大根堆,排降序時,使用小根堆。這是因爲使用堆排序主要是用堆頂元素,而每次將堆頂元素與葉子節點交換相當於將大數放到了數組的後面,方便我們排序。而如果使用小根堆,當我們取出堆頂元素時,此時小根堆的性質就變了,那麼下次就找不到第二小的元素了,還要重新建堆。

2.圖解示例

在這裏插入圖片描述

3.代碼演示

無序區間 [0,i]
有序區間 (i,arr.length - 1]
每次取出無序區間一個最大數,共要取出arr.length - 1次,每一次取出後與下一個有序區間的位置進行互換。
如果知道根節點index,那麼左子樹的位置就是2 * index + 1,右子樹位置就是左子樹的下一個節點。代碼中的 j = (n - 1)/2是求得第一個非葉子節點的節點下標。
每次從第一個非葉子節點開始進行堆化找最大值,對於堆化,就像我們上面講的原理來實現。

    public void heapSort(int[] arr) {
        for(int i = arr.length - 1; i > 0; i --) {
            heapify(arr, i);
            //交換堆頂元素
            int tmp = arr[0];
            arr[0] = arr[i];
            arr[i] = tmp;
        }
    }
    public void heapify(int[] arr,int n) {
        int child;//表示左右節點中較大的節點的下標
        //j表示第一個非葉子節點的節點的下標
        for( int j = (n - 1)/2 ; j >= 0; j --) {
            child = 2 * j + 1;//左子節點位置
            //右子樹存在且大於左子樹節點,child就變成右節點
            if (child < n && arr[child] < arr[child + 1]) {
                child ++;
            }
            //根節點如果小於子節點則交換
            if(arr[j] < arr[child]) {
                int tmp = arr[child];
                arr[child] = arr[j];
                arr[j] = tmp;
            }
        }
    }

4.性能分析

(1)時間複雜度

最好 最壞 平均
O(nlognO(nlogn) O(nlognO(nlogn) O(nlognO(nlogn)

(2)空間複雜度 —— O(1)

沒有額外消耗空間,因此空間複雜度爲常數級

(3)穩定性 —— 不穩定

由於多次任意下標相互交換位置, 相同元素之間原本相對的順序被破壞了。因此, 它是不穩定的排序。

六、快速排序

1.思路講解

快排是一種分治的做法來對數據進行排序的。主要的步驟呢就是分爲三步:

  1. 在整個待排序的區間中確定一個基準值(pivot)

  2. 遍歷整個排序區間,把所有值和基準值進行比較,最終達到(partition):
    比基準值小的就放在基準值左邊
    比基準值大的就放在基準值右邊
    在這個分區結束之後,該基準就處於數列的中間位置。

  3. 這樣再用同樣的策略去處理左右的兩個小區間,直到:
    小區間中已經沒有數據了
    小區間中的數據是有序的

這樣就完成了對整個區間的排序。
那麼partition方法就需要實現以數組中的某一個數爲基準值,比這個值大的就放到左邊比這個數小的放到右邊。

2.圖解示例

在這裏插入圖片描述

3.代碼演示

這裏的partition方法的實現右很多種,我在這裏列舉幾種:
partition1: hover法
詳細的註釋都標到代碼中了,這裏的partition實現的方式就是使用兩個標記分別從數組的首尾向中間逼近,並再次在此過程中進行交換數據的元素。
partition2: 挖坑-填坑法
由於hover法導致進行很多次的交換,所以挖坑-填坑法就對其進行了改進。總體思路右類似的地方,都是通過兩個標誌來向中間靠,不同的是不需要多次交換而是採用覆蓋的方式,這是因爲一開始的pivot保存了最右邊的數字,因此該位置就可以供下次找到大於基準值的數字時放到這個位置上,這樣每覆蓋一個數就留下一個空缺供下一次覆蓋,到最後的循環結束後就講最後一個“坑”用pivot來填充。
partition3: 前後下標法
這種方式也很直接,用一個less標記從頭記錄,遍歷整個數組,小於pivot就將它與less位置的元素交換位置,再使標誌位後移。

class QuickSort{
	public void quickSort(int[] arr) {
	        quickSortInternal(arr, 0, arr.length -1);
	    }
	public void swap(int[] arr, int i,int j) {
	        int tmp = arr[i];
	        arr[i] = arr[j];
	        arr[j] = tmp;
	    }
    private void quickSortInternal(int[] arr, int left, int right) {
        if(left >= right) return;
        //1.確定基準值arr[right]作爲基準值
        //2.遍歷,小的左,大的右
        int pivotIndex = partition(arr,left,right);
        //此時pivotIndex處的元素已經找到了自己的位置
        //以pivotIndex爲基準值分成兩個小區間,它是分區的下標指引
        //[left,pivotIndex-1]
        //[pivotIndex+1,right]
        quickSortInternal(arr,left,pivotIndex - 1);
        quickSortInternal(arr,pivotIndex + 1,right);
    }

    private int partition1(int[] arr, int left, int right) {
        int pivot = arr[right]; //以最右邊的數字爲基準值
        int less = left; //左標記
        int great = right; //右標記
        while(less < great) {
            //從前往後找大於基準值的數
            while(less < great && arr[less] <= pivot) {
                less ++;
            }
            //從後往前找小於基準值的數
            while(less < great && arr[great] >= pivot) {
                great --;
            }
            //找到後進行交換
            swap(arr,less,great);
        }
        //循環結束後,基準值還沒進行交換,最後要交換
        swap(arr,less,right);
        //此時的less就是接下來繼續分區的標誌
        return less;
    }
    
	private int partition2(int[] arr, int left, int right) {
        int pivot = arr[right];
        int less = left;
        int great = right;
        while(less < great) {
            while(less < great && arr[less] <= pivot) {
                less ++;
            }
            arr[great] = arr[less];
            while(less < great && arr[great] >= pivot) {
                great --;
            }
            arr[less] = arr[great];
        }
        arr[less] = pivot;
        return less;
    }
    public int partition3(int[] arr,int left, int right){
        int pivot = arr[right];
        int less = left;
        for (int i = left; i < right; i ++) {
            if(arr[i] < pivot) {
                swap(arr,less,i);
                less ++;
            }
        }
        swap(arr, less, right);
        return less;
    }
}

以上是使用遞歸來進行排序,我們也有快排的非遞歸的方法。由於遞歸就是通過調用棧實現的,所以我們非遞歸實現的過程中,可以藉助棧來保存中間變量就可以實現非遞歸了。在這裏中間變量也就是通過Pritation函數劃分區間之後分成左右兩部分的首尾指針,只需要保存這兩部分的首尾指針即可。

public static void quickSortNoR(int[] array) {
		Stack<Integer> stack = new Stack<>();
		stack.push(array.length - 1);
		stack.push(0);

		while (!stack.isEmpty()) {
		    int left = stack.pop();
		    int right = stack.pop();
		    if (left >= right) {
		        continue;
			}
			//用到的partition1方法到遞歸方式的代碼中找
		    int pivotIndex = partition1(array, left, right);
		    // [left, pivotIndex - 1]
			// [pivotIndex + 1, right]
			stack.push(right);
			stack.push(pivotIndex + 1);

			stack.push(pivotIndex - 1);
			stack.push(left);
		}
	}

4.性能分析

(1)時間複雜度

最好 最壞 平均
O(nlognO(nlogn) O(n2O(n^2) O(nlognO(nlogn)

這裏我們來看一下怎麼計算時間複雜度,分兩個部分來看:

  1. partition 的過程是對於數組中的每一個數都進行遍歷,所以複雜度都爲O(n)
  2. 要做多少次partition 呢?
    這裏可以把分治的過程看作是一顆二叉樹,其高度就是層數,而二叉樹的高度爲O(log(n))O(log(n)) ~ O(n)O(n)

因此時間複雜度就是O(nlog(n))O(n*log(n)) ~ O(n2)O(n^2)

(2)空間複雜度

最好 最壞 平均
O(lognO(logn) O(nO(n) O(lognO(logn)

接着來看看空間複雜度的計算:我們要考慮的就是遞歸方法需要調用的棧有多少層。實際上也就是二叉樹的高度

因此就得到空間複雜度O(log(n))O(log(n)) ~ O(n)O(n)

(3)穩定性 —— 不穩定

由於也會對數組中的數據進行交換,因此也是不穩定的

我們常使用的Arrays.sort(基本數據類型的數組)就基本上使用的是快排。 快排更加適合數據量較大的應用場景,數據量小時,往往使用插入排序。在Java中默認的閾值是48.

七、歸併排序

1.思路講解

歸併排序算法是將兩個(或兩個以上)有序表合併成一個新的有序表,即把待排序序列分爲若干個子序列,每個子序列是有序的。然後再把有序子序列合併爲整體有序序列。
基本步驟:

  1. 找到中間位置,劃分左右兩個小區間,直到小區間長度爲1或者小於1
  2. 分治思想,先排序左右兩個小區間
  3. 合併有序數組

2.圖解示例

在這裏插入圖片描述

3.代碼演示

首先呢,就是要通過遞歸來進行每一次的區間劃分,當區間大小爲1或者0時就不用繼續分割。接着在merge方法中要做的就是合併兩個有序的數組,通過創建一個額外的輔助數組來進行,每次在兩個數組之中找到最小的數字放到額外數組中去,最後合併結束再將整個數組拷貝回來,這樣每一個小區間就都變成了有序區間。

 	public static void mergeSort(int[] array) {
        mergeSortInternal(array, 0, array.length);
    }
    private static void mergeSortInternal(int[] array, int low, int high) {
        if (low + 1 >= high) {
            return;
        }

        int mid = (low + high) / 2;
        // [low, mid)
        // [mid, high)
        mergeSortInternal(array, low, mid);
        mergeSortInternal(array, mid, high);
        merge(array, low, mid, high);
    }
   private static void merge(int[] array, int low, int mid, int high) {
        int length = high - low;
        int[] extra = new int[length];
        // [low, mid)
        // [mid, high)

        int iLeft = low;
        int iRight = mid;
        int iExtra = 0;
        //合併有序鏈表的循環條件
        while (iLeft < mid && iRight < high) {
            if (array[iLeft] <= array[iRight]) {
                extra[iExtra++] = array[iLeft++];
            } else {
                extra[iExtra++] = array[iRight++];
            }
        }
        //循環結束後保證將有剩餘元素的數組的剩餘元素都拷貝到extra數組
        while (iLeft < mid) {
            extra[iExtra++] = array[iLeft++];
        }

        while (iRight < high) {
            extra[iExtra++] = array[iRight++];
        }
        //將extra數組中的數重新拷貝回去
        for (int i = 0; i < length; i++) {
            array[low + i] = extra[i];
        }
    }

同樣,歸併排序也有非遞歸形式:

public static void mergeSortNoR(int[] array) {
		for (int i = 1; i < array.length; i = i * 2) {
			for (int j = 0; j < array.length; j = j + 2 * i) {
			    int low = j;
			    int mid = j + i;
			    int high = mid + i;
			    if (mid >= array.length) {
			        continue;
				}
			    if (high > array.length) {
			    	high = array.length;
				}

			    merge(array, low, mid, high); //該方法在遞歸形式實現中找
			}
		}
	}
	

4.性能分析

(1)時間複雜度

最好 最壞 平均
O(nlognO(nlogn) O(nlognO(nlogn) O(nlognO(nlogn)

說明歸併排序對數據是不敏感的,無論數據的形式,時間複雜度都不變

(2)空間複雜度 —— O(n)

在合併有序數組的過程中需要使用額外的數組

(3)穩定性 —— 穩定

整個操作不會影響數據的先後

優化點:

  1. 減少搬移次數
  2. 判斷左邊的最大數和右邊的最小數

應用 : 我們常用的Arrays.sort引用數據類型)是使用的歸併排序
歸併排序是一種常見的外部排序也即不是左右的操作都需要藉助內存的排序
如果數據量大到內存中都放不下了,就用歸併排序來處理。把大的數據量分成多路,使用多路歸併,最終合併有序。

八、七大排序的總結:

在這裏插入圖片描述
在這裏插入圖片描述

以上就是自己總結的七大排序的性質,雖然這七個排序確實很容易讓人懵但是花點時間自己總結一下思路就會很清晰了。如果有問題歡迎指正,希望可以幫到你,也歡迎小夥伴們點贊關注一起進步!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章