1. 程式人生 > >《資料結構與演算法之美》專欄閱讀筆記3——排序演算法

《資料結構與演算法之美》專欄閱讀筆記3——排序演算法

上週排計劃,說花個一天的時間看完好了(藐視臉)~然後每天回家看一會,看了一個星期……做人,要多照鏡子好嘛

文章目錄

1、簡單排序

1.1 如何分析排序演算法

從以下幾個方方面入手。

執行效率
  • 最好情況、最壞情況、平均情況時間複雜度
  • 時間複雜度的係數、常數、低階
    時間複雜度反映的時資料規模較大的時候的增長趨勢。但實際開發中,也存在很多小規模的資料,此事稀疏、常數和低階的佔比較大,需要進行考慮。
  • 比較次數和交換次數
記憶體消耗

可以通過空間複雜度來衡量。新概念:原地排序。特指空間複雜度為O(1)的排序演算法。

穩定性

對於同一序列,排序的結果相同。
因為實際比較中,更多的是對物件進行排序。

2、排序演算法

2.1、冒泡

原理:重複地走訪過要排序的元素列,依次比較兩個相鄰的元素,如果他們的順序錯誤就把他們交換過來。重複地進行直到沒有相鄰元素需要交換,直到排序完成。

要點:

  • 對N個待排序元素,要排序的序列是0 ~ (N-已排序個數)。
  • 如果一次排序中沒有進行任何元素交換,說明序列已經是有序的,可以停止比較。

分析:
順便複習下均攤時間複雜度的使用場景:

  • 大部分情況下,時間複雜度都很低,只有個別情況下,時間複雜度較高。
  • 操作之間存在前後連貫的時序關係
    對排序演算法的平均複雜度分析,可以使用有序度逆序度來進行分析。
  • 有序度:陣列中具有有序關係的元素對的個數。
  • 滿序度:完全有序的大小為n的陣列的有序度,為n*(n-1)/2
  • 逆序度:與有序度相反。

關鍵公式:逆序度 = 滿有序度 - 有序度。

排序的過程就是達到滿有序度的過程

結論:交換的次數等於逆序度。
平均交換次數 = [0(最好) + n*(n-1)/2(最壞)]/2 = n*(n-1)/4

在作者給的基礎上再來一點優化:

public static <T extends Comparable<T>> void BubbleSort(T[] values, int length) {
        if (length <= 1)
            return;
        int flag = length;
        while (flag > 0) {
            int end = flag - 1;
            flag = 0;
            for (int j = 0; j < end; j++) {
                if (values[j].compareTo(values[j+1]) > 0) {
                    T tmp = values[j];
                    values[j] = values[j+1];
                    values[j+1] = tmp;
                    flag = j + 1;
                }
            }
        }
    }

通過flag來縮短要排序序列

2.2、插入

原理:取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入,並保證已排序區間資料一直有序。

要點:
插入是通過移動來達到滿序度的,所以移動的次數等於逆序度。

public static <T extends Comparable<T>> void InsSort(T[] values, int length) {
        if (length <= 1)
            return;
        for (int i = 0; i < length; i++) {
            T v = values[i];
            int j = i - 1;
            for (; j >= 0; --j) {
                if (values[j].compareTo(v) > 0) {
                    values[j+1] = values[j];
                } else {
                    break;
                }
            }
            values[j+1] = v;
        }
    }

直接插入排序,因為是要在已排序的序列中找到插入位置,所以需要移動是主節奏。

2.3、選擇排序

原理:每次選取未排序區間的最小值,放到已排序區間的末尾。

要點:相較於插入排序,不需要移動元素,交換次數為n,消耗在遍歷比較上。

實現

public static <T extends Comparable<T>> void SelSort(T[] values, int length) {
        if (length <= 1)
            return;

        for (int i = 0; i < length; i++) {
            T min = values[i];
            int minIdx = i;
            for (int j = i + 1; j < length; j++) {
                if (values[j].compareTo(min) < 0) {
                    min = values[j];
                    minIdx = j;
                }
            }
            if (minIdx != i) {
                T tmp = values[i];
                values[i] = values[minIdx];
                values[minIdx] = tmp;
            }
        }
    }

2.4、小結

適合小規模資料的排序。

3、分治思路的排序演算法

3.1、歸併排序

原理:將大問題分解成小問題來進行排序,然後對排序後的結果進行合併。(妥妥地遞迴哦)

圖片連結

時間複雜度分析

T(1) = C;
T(n) = 2*T(n/2) + n;
		= 2^k * T(n/2^k) + k*n

另外一個分析思路就是:
拆分和合並的結構可以看作一棵二叉樹,拆分部分和合並部分的深度都是logn,每個元素要找到自己的最終位置都要在樹裡跑一遍呢~所以複雜度為n*logn

實現:

private static <T extends Comparable<T>> void SortC(T[] values, int left, int right, T[] tmp) {
        if (left >= right)
            return;

        int mid = (left + right)/2;
        for (int i = left; i <= right; i++) {
            if (i == mid + 1)
                System.out.print("     +      ");
            System.out.print(values[i] + " ");
        }
        System.out.println("");

        SortC(values, left, mid, tmp);
        SortC(values, mid + 1, right, tmp);
        MergeC(values, left, mid, right, tmp);
    }
    private static <T extends Comparable<T>> void MergeC(T[] values, int left, int mid, int right, T[] tmp) {
        int i = left;
        int j = mid + 1;
        int tmpIdx = 0;
        while (i <= mid && j <= right) {
            if (values[i].compareTo(values[j]) < 1) {
                tmp[tmpIdx++] = values[i++];
            } else {
                tmp[tmpIdx++] = values[j++];
            }
        }

        while (i <= mid) {
            tmp[tmpIdx++] = values[i++];
        }
        while (j <= right) {
            tmp[tmpIdx++] = values[j++];
        }

        tmpIdx = 0;
        for (i = left; i <= right; i++) {
            values[i] = tmp[tmpIdx++];
        }
    }

利用哨兵簡化程式設計的思路是:

MERGE(A, p, q, r)
	n1 ← q-p+1;                                 //計算左半部分已排序序列的長度
	n2 2 ← r-q;                                 //計算右半部分已排序序列的長度
	create arrays L[1..n1+1] and R[1..n2+1]     //新建兩個陣列臨時儲存兩個已排序序列,長度+1是因為最後有一個標誌位
	for i ← 1 to n1
	do L[i] ← A[p + i-1]    					//copy左半部分已排序序列到L中
	for j ← 1 to n2
	do R[j] ← A[q + j]      					//copy右半部分已排序序列到R中
	L[n1+1] ← ∞                             //L、R最後一位設定一個極大值作為標誌位                       
	R[n2+1] ← ∞ 
	i ← 1
	j ← 1
	for k ← p to r         //進行合併
		do if L[i] < R[j]
		then A[k] ← L[i]
			i ← i + 1
		else A[k] ← R[j]
			j ← j + 1

使用極大值作為哨兵。
程式碼實現:

void Merge(int A[],int p,int q,int r)  
{  
    int i,j,k;  
    int n1=q-p+1;  
    int n2=r-q;  
    int *L=new int[n1+1]; //開闢臨時儲存空間  
    int *R=new int[n2+1];  
    for(i=0;i<n1;i++)  
        L[i]=A[i+p];      //陣列下標從0開始時,這裡為i+p  
    for(j=0;j<n2;j++)  
        R[j]=A[j+q+1];    //陣列下標從0開始時,這裡為就j+q+1  
    L[n1]=INT_MAX;        //"哨兵"設定為整數的最大值,INT_MAX包含在limits.h標頭檔案中  
    R[n2]=INT_MAX;  
    i=0;  
    j=0;  
    for(k=p;k<=r;k++)     //開始合併  
    {  
        if(L[i]<=R[j])  
            A[k]=L[i++];  
        else  
            A[k]=R[j++];  
    }  
}  

3.2、快速排序

原理:也是利用分治的思想。先找到待排序序列中任意一點作為基準點,將小於基準點的放到基準點左邊,大於的放到右邊,並以基準點進行分割槽,遞迴到最小區間為1則所有資料完成排序。

小技巧:為了實現原地排序,在分割槽函式中使用資料交換而不是搬移。

效能分析

  • 分割槽極其均衡時,比如兩個大小相同的區間,則跟合併排序效能差不多,O(nlogn)。
  • 分割槽極其不均衡時,取決於分割槽函式的實現,類似於冒泡+選擇混合體了,退化到O(n^2)。

雖然均攤分析並不適用於此場景,分析思路可以借鑑,極其不均衡是要求每次分割槽都達到最不均衡的情況,概率比較小,所以平均時間複雜度還是O(nlogn)。
(作者沒有繼續分析,我只好揮發聰明才智瞎猜咯~)

實現

private static <T extends Comparable<T>> int partition(T[] values, int left, int right) {
        T pivot = values[right];
        int i = left;
        for (int j = left; j < right; j++) {
            if (values[j].compareTo(pivot) < 0) {
                T tmp = values[i];
                values[i] = values[j];
                values[j] = tmp;
                i++;
            }
        }

        T tmp = values[right];
        values[right] = values[i];
        values[i] = tmp;

        return i;
    }

    private static <T extends Comparable<T>> void QuickSortC(T[] values, int left, int right) {
        if (left >= right)
            return;

        int partIdx = partition(values, left, right);
        QuickSortC(values, left, partIdx - 1);
        QuickSortC(values, partIdx + 1, right);

    }
    public static <T extends Comparable<T>> void QuickSort(T[] values, int length) {
        QuickSortC(values, 0, length - 1);
    }

4、線性排序

桶排序、計數排序和基數排序都不是基於比較的排序演算法,都不涉及元素之間的比較操作,時間複雜度可以達到O(n),也就是線性的。

4.1、桶排序

原理:將要排序的資料分到幾個有序的桶裡,每個桶裡的資料再單獨進行排序,排序完之後按照順序依次取出。

桶排序對資料的要求比較嚴格:

  • 要排序的資料需要很容易就能劃分成m個桶
  • 桶與桶之間有著天然的大小順序(桶內排序完成後,桶之間不需要進行排序)
  • 資料在桶之間的分佈比較均勻。

適用場景:外部排序。資料儲存在外部磁碟中,因為記憶體有限。

4.2、計數排序

看了一下作者給的示意圖,然後還讓集中精神,就一臉懵逼。
其實好像可以小小總結下更簡單呢:

  • step1:根據數值範圍,按數值單位劃分成K個桶。(這樣可以不用進行桶內排序)
  • step2:每個桶記錄的是下表對應數值的個數。(啥也不做的話我們已經知道每個數值有多少個重複的啦)
  • step3:從左到右累加。(因為要知道排序後的位置,就需要知道前面有多少個數據,累加完就曉得啦)
    資料來源:2,5,3,0,2,3,0,3。
  • step4:遍歷原始陣列,根據“桶”組可以算出資料在排序後的陣列中的下標。注意:因為重複的資料是挨著存的,對一個數據排序完畢後,需要對桶儲存的值減一。

適用場景:資料範圍不大的非負整數。

實現

public static void CountingSort(Integer[] values, int length) {
        if (length <= 1)
            return;
        int max = values[0];
        for (int i = 0; i < length; i++) {
            if (max < values[i]) {
                max = values[i];
            }
        }

        // 構造索引陣列並初始化
        int[] c = new int[max + 1];
        for (int i = 0; i <= max; i++) {
            c[i] = 0;
        }

        // 統計
        for (int i = 0; i < length; i++) {
            c[values[i]]++;
        }

        // 累加得到索引值
        for (int i = 1; i <= max; i++) {
            c[i] += c[i-1];
        }

        int r[] = new int[length];
        for (int i = length - 1; i >= 0; --i) {
            int index = c[values[i]] - 1;
            r[index] = values[i];
            c[values[i]]--;
        }

        for (int i = 0; i < length; i++) {
            values[i] = r[i];
        }
    }

4.3、基數排序

原理:將待比較的數值分割成位,如果低位能夠確定大小則無須繼續比較高位。
按照低位優先比較和高位優先比較有兩種寫法,思路都是一致的。

適用場景:需要可以分割出獨立的“位”來比較,而且位之間有遞進的關係。

注意:用來比較位的演算法必須是穩定的,否則低位的比較結果沒有意義。對於非等長的情況可以使用0來補齊。

5、排序演算法的優化

一個通用的排序演算法需要兼顧效能和適用的資料規模。

5.1、快速排序中分割槽點選取的優化方向

最壞情況下的快速排序的時間複雜度是O(n^2),主要是分割槽點的選擇影響的。
最理想的分割槽點是可以對半分。關於取樣點的選取,作者給了兩條思路(網友給了千千萬萬個<可能不太靠譜>的思路)

三數取中法

從區間的首、中、尾各取一個元素,選大小為中間值的那個作為分割槽點。
看上去就是取樣的嘛(取樣啥的當然是訊號處理專業的強項丫~對不起!我給本專業丟臉了!)

隨機法

從待排序區間中隨機選取一個元素作為分割槽點。
看上去就像是擲色子(相信科學喵~)
引入隨機化快速排序的作用,就是當該序列趨於有序時,能夠讓效率提高,大量的測試結果證明,該方法確實能夠提高效率。但在整個序列數全部相等的時候,隨機快排的效率依然很低,它的時間複雜度為O(N^2)。

5.2、遞迴優化

快速排序跟合併排序最大的不同就是它是先分割槽排序再進行遞迴。如果待排序的序列劃分極端不平衡,遞迴的深度將趨近於n,而棧的大小是很有限的,每次遞迴呼叫都會耗費一定的棧空間,函式的引數越多,每次遞迴耗費的空間也越多。優化後,可以縮減堆疊深度。倆思路:

  • 限制遞迴深度
  • 通過再堆上模擬一個函式呼叫棧,手動模擬遞迴壓棧、出棧的過程,來消除系統棧大小的限制。(啥意思?黑人問號)

大概說的是下面這個意思?

private static <T extends Comparable<T>> void QuickSortC(T[] values, int left, int right) {
        if (left >= right)
            return;

//        int partIdx = partition(values, left, right);
//        QuickSortC(values, left, partIdx - 1);
//        QuickSortC(values, partIdx + 1, right);
        LinkedListStack<Integer> stack = new LinkedListStack<>();
        int partIdx = partition(values, left, right);
        if (left < partIdx - 1) {
            stack.push(left);
            stack.push(partIdx);
        }
        if (partIdx + 1 < right) {
            stack.push(partIdx + 1);
            stack.push(right);
        }

        while(!stack.empty()) {
            right = stack.pop();
            left = stack.pop();

            partIdx = partition(values, left, right);

            if (partIdx == left || partIdx == right)
                continue;

            if (left < partIdx - 1) {
                stack.push(left);
                stack.push(partIdx);
            }
            if (partIdx + 1 < right) {
                stack.push(partIdx + 1);
                stack.push(right);
            }
        }
    }

6、小結

沒有小結,謝謝。