排序(下)

排序(下): 如何用快排思想在O(n)內查找第K大元素?

冒泡排序,插入排序選擇排序的時間複雜度都是O(n2 ),比較高,適合小規模數據排序,而歸併排序和快速排序,時間複雜度都是O(nlogn)的排序,適合大規模的數據排序

歸併排序

如果要排序一個數組,先把數組從 中間分成前後兩部分,然後對前後兩部分分別排序,再將排好序的兩部分合併在一起,這樣整個數組就是有序的

 

歸併排序使用的 是分治思想,將一個大問題分解成小的子問題來解決.可以使用遞歸的的編程技巧來實現

遞推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))
​
終止條件:
p >= r 不用再繼續分解
​

merge_sort(p…r),給下標從p到r之間的數組排序,將這個排序的問題轉化爲兩個子問題,merge_sort(p…q)和merge_sort(q+1…r),其中下標q等於p和r的中間位置,也就是(p+r)/2,當從下標p到q和q+1到r這兩個子數組都排好序之後,再將兩個有序的子數組合併到一起,這樣從下標p到r之間的數據就是有序的

僞代碼實現:

// 歸併排序算法, A 是數組,n 表示數組大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}
​
// 遞歸調用函數
merge_sort_c(A, p, r) {
  // 遞歸終止條件
  if p >= r  then return
​
  // 取 p 到 r 之間的中間位置 q
  q = (p+r) / 2
  // 分治遞歸
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 將 A[p...q] 和 A[q+1...r] 合併爲 A[p...r]
  merge(A[p...r], A[p...q], A[q+1...r])
}
​

合併的過程是如何實現的?

申請一個臨時數組tmp,大小與A[p…r]相同,用兩個遊標i和j,分別指向A[p…q]和A[q+1…r]的第一個元素,比較這兩個元素A[i]和A[j],就把A[i]放入到臨時數組tmp,並且i後移一位,否則將A[j]放入到數組tmp,j後移一位;繼續上述比較過程,直到其中一個子數組中所有的數據都放入臨時數組中,再把另一個數組中的數據加入到臨時數組的末尾,這時臨時數組中存儲的就是這兩個子數組合並之後的結果,最後把臨時數組tmp中的數據拷貝到原數組A[p…r]中

 

代碼實現;

package com.zach.algorithm;
​
import java.util.Arrays;
​
public class MergeSort {
    public static void main(String[] args) {
        int[] arr = {1, 3, 8, 10, 20, 0, 9};
        mergeSort(arr, 7);
        System.out.println(Arrays.toString(arr));
    }
​
    public static void mergeSort(int[] a, int n) {
        mergeSortInternally(a, 0, n - 1);
    }
​
    private static void mergeSortInternally(int[] a, int p, int r) {
        if (p >= r)
            return;
        //取p到r之間的位置q,防止(p+r)的和超過int類型最大值
        int q = p + (r - p) / 2;
        //分治遞歸
        mergeSortInternally(a, p, q);
        mergeSortInternally(a, q + 1, r);
​
        //將A[p...q]和A[q+1...r]合併爲A[p...r]
        merge(a, p, q, r);
    }
​
    private static void merge(int[] a, int p, int q, int r) {
        int i = p;
        int j = q + 1;
        int k = 0;//初始化變量i,j,k
        int[] tmp = new int[r - p + 1];//申請一個臨時數組
        while (i <= q && j <= r) {
            if (a[i] <= a[j]) {
                tmp[k++] = a[i++];
            } else {
                tmp[k++] = a[j++];
            }
        }
​
        //判斷哪個子數組中有剩餘的數據
        int start = i;
        int end = q;
        if (j <= r) {
            start = j;
            end = r;
        }
        //將剩餘的數據拷貝到臨時數組tmp中
        while (start <= end) {
            tmp[k++] = a[start++];
        }
        //將tmp中的數組拷貝回a[p...r]
        for (i = 0; i <= r - p; ++i) {
            a[p + i] = tmp[i];
        }
    }
​
}
​

歸併排序在合併的過程中,如果A[p…q]和A[q+1…r]之間有值相同的元素,可先把A[p..q]中元素放入tmp數組,這樣就保證了值相同的元素,在合併前後的 先後順序不變,故是一個穩定的排序算法

遞歸代碼的時間複雜度分析:

遞歸的適用場景是,一個問題a可以分解爲多個子問題b,c,那求解問題a就可以分解爲求解問題b,c,然後將b,c的求解結果合併爲a的結果;定義求問題a 的時間是T(a),求解問題b,c的時間分別T(b)和T(c),則得到公式:

T(a) = T(b) + T(c) + K
//k爲將兩個子問題b,c的結果合併成問題a的結果所消耗的時間

假設對n個元素進行歸併排序需要的時間是T(n),分解成兩個子數組排序的時間都是T(n/2),merge()函數合併兩個有序子數組的時間複雜度是O(n),歸併排序的 時間複雜度計算公式:

T(1) = C;   n=1 時,只需要常量級的執行時間,所以表示爲 C。
T(n) = 2*T(n/2) + n; n>1
​

根據公式求解T(n):

T(n) = 2*T(n/2) + n
     = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
     = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
     = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
     ......
     = 2^k * T(n/2^k) + k * n
     ......
​

得到T(n) = 2k T(n/2k )+Kn,當T(n/2k )=T(1)時,即n/2k =1. k = log2 n,將k值代入公式既得T(n) = Cn+nlog2 n,如果用大O標記法來表示: T(n)=O(nlogn) 所以歸併排序時間複雜度就是O(nlogn),歸併排序的執行效率與排序的原始數組的有序程度無關,時間複雜度非常穩定,不管最好情況,最壞情況,還是平均情況,時間複雜度都是O(nlogn)

遞歸代碼的空間複雜度不能像時間複雜度那樣累加,儘管每次合併操作都需要申請額外的內存空間,但在合併完成之後,臨時開闢的內存空間就被釋放掉了,在任意時刻,CPU只會有一個函數在執行,也就只會有一個臨時的 內存空愛你在使用,臨時內存空間最大也不會超過n個數據的大小,故空間複雜度是O(n)

快速排序的原理

如果排序的數組中下標從p到r之間的 一組數據,選擇p到r之間的 任意一個數據作爲pivot(分區點)

遍歷p到r之間的 數據,將小於pivot的放到左邊,將大於pivot的放到右邊,將pivot放到中間,數組p到r之間的數據就被分成了三個部分,前面p到q-1之間都是小於pivot的,中間是pivot,後面是大於pivot

 

根據分治遞歸的處理思想,遞歸排序下標p到q–1之間的數據和下標從q+1到r之間的數據,直到區間縮小爲1,就說明所有的數據都有序了

partition()分區函數,就是隨機選擇一個元素pivot(一般情況下,選擇p到r區間的最後一個元素),然後對A[p…r]分區,函數返回pivot的下標,簡單實現分區函數,可以申請兩個臨時數組X和Y,遍歷A[p…r],將小於pivot的元素都拷貝到臨時數組X,將大於pivot的 元素都拷貝到臨時數組Y,最後將X和Y中數據順序拷貝到A[p…r],這種實現方式需要很多額外的內存空間,就不是原地排序算法了;

原地分區函數的實現思路: 通過遊標把A[p…r-1]分成兩部分,A[p..i-1]的元素都是小於pivot的,暫時成爲已處理區間,A[i…r-1]是未處理區間,每次都從未處理區間中取一個 元素A[j],與pivot對比,如果小於pivot,則將其加入到已處理區間的尾部,即A[i]的位置

代碼實現:

//實現方案一
//對順序表elem進行快速排序
    public void quickSort(int[] elem) {
        QSort_2(elem, 1, elem.length - 1);
    }
    
    //對順序表elem中的子序列elem[start...end]做快速排序
    public void QSort(int[] elem, int start, int end) {
        
        int pivot;
        if(start < end) {
            pivot = Partition(elem ,start, end);
            
            QSort(elem, start, pivot - 1);
            QSort(elem, pivot + 1, end);
        }
    } 
    
    /**
     * 交換順序表elem中字表記錄,使基數記錄到位,並返回其所在位置
     * 此時在它之前(後)的記錄均不大(小)於它
     * @param elem
     * @param low
     * @param high
     * @return
     */
    public int Partition(int[] elem, int low, int high) {
        
        int pivotkey = elem[low];
​
        while(low < high) {
            
            while((low < high) && (elem[high] >= pivotkey)) {
                high--;             
            }
            swap(elem, low, high);
            
            while((low < high) && (elem[low] <= pivotkey)) {
                low++;
            }
            swap(elem, low, high);
        }
        return low;
    }
​
//實現方案二
public class QuickSort {
​
    public static void main(String[] args) {
        int[] a = {2,1,5,4,7,6,3};
        quickSort(a,7);
        System.out.println(Arrays.toString(a));
    }
​
    public static void quickSort(int[] a, int n) {
        quickSortInternally(a, 0, n - 1);
    }
​
    private static void quickSortInternally(int[] a, int p, int r) {
        if (p >= r)
            return;
        //獲取分區點
        int q = partition(a, p, r);
        quickSortInternally(a, p, q - 1);
        quickSortInternally(a, q + 1, r);
    }
​
    private static int partition(int[] a, int p, int r) {
        int pivot = a[r];
        int i = p;
        for (int j = p; j < r; ++j) {
            if (a[j] < pivot) {
                if (i == j) {
                    ++i;
                } else {
                    int tmp = a[i];
                    a[i++] = a[j];
                    a[j] = tmp;
                }
            }
        }
        int tmp = a[i];
        a[i] = a[r];
        a[r] = tmp;
        System.out.println("i=" + i);
        return i;
    }
}

快排和歸併用的都是分治思想,遞歸的實現方式,區別在哪裏

歸併排序的處理過程是由下到上的 ,先處理子問題,然後再合併,而快排正好相反,它的處理過程是由上到下,先分區,然後再處理子問題,歸併排序雖然是穩定的,時間複雜度爲O(nlongn)的排序算法,但是它是非原地排序算法,主要原因是合併函數無法在 原地執行,快速排序通過巧妙設計原地分區函數,可實現原地排序,解決了歸併排序佔用太多內存的問題;

 

快速排序的性能分析:

如果每次分區操作,正好把數組分成大小接近相等的兩個小區間,極端情況下快排的時間複雜度就退化成O(n2 )

如何在O(n)的 時間複雜度內求無序數組中的第K大元素?

如[4,2,5,12,3]這樣一組數據,第3大元素就是4,選擇數組區間A[0...n-1]的最後一個元素A[n-1]作爲pivot,對數組A[0...n-1]原地分區,數組分成了A[0...p-1], A[p], A[p+1…n-1]; 若p+1=K,則A[p]就是要找的元素,若K>p+1則說明第K大的元素出現在A[p+1…n-1]區間,同理,K<p+1,那麼就在A[0...p-1]區間查找

第一次分區查找,需要對n執行分區操作,遍歷n個元素,第二次則遍歷n/2個元素,以此類推,分區遍歷元素的個數分別爲n/4,n/8,n/16...直到區間縮小爲1,然後累加求和: n+n/2+n/4+n/8+n/16+...+1,和等於2n-1故時間複雜度爲O(n)

思考題:

現在有10個接口訪問日誌文件,每個日誌文件大約300MB,每個文件裏的日誌都是按照時間戳從小到大排序的,希望將這10個較小的日誌文件,合併爲1個日誌文件,合併之後的日誌仍然按照時間戳從小到大排列,希望將這10個較小的日誌文件,合併爲1個日誌文件,合併之後的日誌仍然按照時間戳從小到大排列,如果處理上述排序任務的 機器內存只有1GB,用一種方法快速的將10個日誌文件合併;

方案一:

先構建十條io流,分別指向十個文件,每條io流讀取對應文件的第一條數據,然後比較時間戳,選擇出時間戳最小的那條數據,將其寫入一個新的文件,然後指向該時間戳的io流讀取下一行數據,然後繼續剛纔的操作,比較選出最小的時間戳數據,寫入新文件,io流讀取下一行數據,以此類推,完成文件的合併, 這種處理方式,日誌文件有n個數據就要比較n次,每次比較選出一條數據來寫入,時間複雜度是O(n),空間複雜度是O(1),幾乎不佔用內存

方案二:

先取得十個文件時間戳的最小值數組的最小值a,和最大值數組的最大值b。然後取mid=(a+b)/2,然後把每個文件按照mid分割,取所有前面部分之和,如果小於1g就可以讀入內存快排生成中間文件,否則繼續取時間戳的中間值分割文件,直到區間內文件之和小於1g。同理對所有區間都做同樣處理。最終把生成的中間文件按照分割的時間區間的次序直接連起來即可。

方案三:

1.申請10個40M的數組和一個400M的數組。 2.每個文件都讀40M,取各數組中最大時間戳中的最小值。 3.然後利用二分查找,在其他數組中快速定位到小於/等於該時間戳的位置,並做標記。 4.再把各數組中標記位置之前的數據全部放在申請的400M內存中, 5.在原來的40M數組中清除已參加排序的數據。[可優化成不挪動數據,只是用兩個索引標記有效數據的起始和截止位置] 6.對400M內存中的有效數據[沒裝滿]做快排。 將排好序的直接寫文件。 7.再把每個數組儘量填充滿。從第2步開始繼續,知道各個文件都讀區完畢。

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