排序(下): 如何用快排思想在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步開始繼續,知道各個文件都讀區完畢。