資料結構與演算法分析筆記與總結(java實現)--排序5:快速排序練習題
題目:對於一個int陣列,請編寫一個快速排序演算法,對陣列元素排序。給定一個int陣列A及陣列的大小n,請返回排序後的陣列。測試樣例:[1,2,3,5,2,3],6 [1,2,2,3,3,5]
思路:
快速排序是使用二分思想,通過遞迴來實現排序的。對於一個數組,它先隨機選擇一個元素A將陣列分成兩個部分,將小於元素A的元素放到陣列的A的左邊,將大於A的元素放到A的右邊,然後再對左右兩側的子陣列分別進行遞迴的分割排序,遞迴的邊界條件是當最終分割得到的陣列只有一個元素,即被元素A分割得到的某個陣列大小是1,那麼這個大小為1的陣列就直接就是結果,不用再進行遞迴了,對於元素數目多於1的陣列繼續進行分割遞迴排序。快速排序的關鍵①隨機元素的選取,直接決定了排序的複雜度,但是通常選取陣列的中間元素作為分界元素;②已知陣列array和其中的分界元素array[k],如何將陣列中小於等於array[k]的元素放在其左邊而將大於array[k]的元素放在其右邊?這裡採取的策略是,建立一個抽象的小於等於區間{}。先將array[k]與陣列的最後一個元素array[length-1]進行交換,使得分界元素位於最後面,然後假設初始的小於等於array[k]的陣列smallArray[]在array[0]位置的左側,初始為空,即為{};然後使用兩個指標p1,p2對陣列array[]和smallArray[]進行遍歷,p1初始值是0,p2初始值是-1,將array[p1]與array[k]=A進行比較,如果array[p1]>A,那麼p2保持不變,p1++,表示這個元素大於A,不能放入到左側的{}中;如果array[p1]<A,那麼說明A元素小於分界值A,可以放入到左側的{}中,此時將p2++,表示{}要開始容納一個元素,然後將array[p2]與array[p1]進行交換,此時得到{0}5641723;然後再p1++即開始遍歷下一個元素,即當遇到一個元素array[p1]<A時總是先對p2進行p2++,再將其array[p2]與array[p1]進行交換,再對p1進行p1++。即快速排序也是基於交換的,需要進行nlogn次交換。
快速排序的時間複雜度O(nlogn),空間複雜度O(1),不穩定。
採用該思路失敗的程式碼:
import java.util.*;
//快速排序:藉助二分法的思想,使用遞迴,選定中間元素,將小於該值的元素放到左邊,大於等於的放到右邊
public class QuickSort {
public int[]quickSort(int[] A, int n) {
//特殊輸入
if(A==null||A.length<=0)return A;
//呼叫遞迴方法進行二分和元素的移動,完成排序
this.adjust(A,0,A.length-1);
return A;
}
//寫一個遞迴的分割方法,將陣列二分,然後以此元素為界將陣列進行移動,小於等於的位於左邊,大於等於的位於右邊
/*public voiddivide(int[] array,int start,int end){
//遞迴一定要寫終止的邊界條件
if(start==end)return;
//找任意一個元素作為分界點,但是通常選擇中間元素作為分界點
int middle=(start+end)/2;
//對當前的陣列根據中間點進行分界,使得小於等於的位於左邊,大於的位於右邊
this.adjust(array,start,end);
//繼續對2個子陣列進行二分
this.divide(array,start,middle);
this.divide(array,middle+1,end);
//呼叫adjust()方法將[start,end]範圍內的陣列以middle為界進行移動使之按照大小位於2邊
this.adjust(array,start,end);
}
*/
//寫一個方法adjust(),將[start,end]範圍內的元素按照中間值分成2個部分並調整大小,小於等於的在左,大的在右
public voidadjust(int[] array,int start,int end){
//遞迴一定要寫終止的邊界條件,這裡的邊界條件是需要分界的陣列只有一個元素
if(start==end)return;
//顯然分界元素是middle
intmiddle=(start+end)/2;
//核心邏輯,將小於middle的元素放到左邊,大於middle的元素放到右邊
//①先將分界元素與最後的元素交換使得分界元素位於最後面
this.swap(array,middle,end);
//②設定指標p指向抽象的小於等於middle的陣列smallArray{}456123;指標i用來遍歷原始陣列array,每次調整都是從0開始進行遍歷調整
int p=start-1;
for(inti=start;i<=end;i++){
if(array[i]<=array[end]){
//如果元素i小於分界元素,那麼就與小於等於陣列區間{}的下一個元素進行交換
swap(array,++p,i);
}
}
/*
③本次調整結束,小於等於array[middle]值的元素全部位於陣列的前列,大於的位於後面,注意,middle只是位置上面的中間,並不是大小的中間值(中位數),於是調整後的陣列原來的分界值並不在中間位置,只是確保該值前面的都是小於該值的,該值後面的都是大於該值的而已。於是在此之後要繼續對分界後的2個子陣列進行分界排序,即對2個子陣列呼叫遞迴過程。注意此時的2個子陣列的分界位置是調整後的分界值所在的新位置,即上面迴圈過後的p的位置。於是對[start,p]和[p+1,end]進行調整
*///假設執行adjust()方法後就完成了調整功能,即將[start,end]範圍的元素進行了分界,不要考慮遞迴細節
this.adjust(array,start,p);
this.adjust(array,p+1,end);
}
//專門寫一個輔助函式用來交換陣列中的2個元素
public voidswap(int[] array,int p1,int p2){
inttemp=array[p1];
array[p1]=array[p2];
array[p2]=temp;
}
}
對於快速排序,換成這種思路:
快速排序和歸併排序一樣,都採用分治的思想,先分再合,寫一個divide(array,start,end)方法來對陣列array中從start到end範圍的陣列進行分界;顯然需要遞迴的劃分陣列,要想遞迴的劃分陣列需要求得分界元素,於是在遞迴呼叫divide()之前,寫一個分界函式partition()來確定[start,end]範圍的分界值—將陣列元素進行分界—返回分界後分界元素位於的新的位置p1。即在divide()方法中通過先呼叫partitiom()方法返回分界後的新的分界元素下標mid然後遞迴的呼叫divide(start,mid);divide(mid+1,end)來進行新的分界。
即核心的邏輯是寫一個partition(A,start,end)方法,選擇以中間位置的元素作為分界點,middle=(start+end)/2,先將這個元素與最後的元素array[end]交換位置,使得該分界元素位於陣列的末尾,然後將小於等於分界值的元素移動到陣列的前面,將大於等於分界值的元素移動到陣列的後面部分,使用的交換策略是設定2個指標p1,p2分別從陣列的start和end-1開始進行遍歷,p1逐步向後面移動,將元素逐個與分界值進行比較,如果小於分界值就不交換,直接p1++,直到遇到array[p1]>=分界值為止;p2從陣列的end-1開始向前遍歷,如果array[p2]>分界值則不交換,p2--,直到array[p2]<=分界值為止,此時array[p1]<=分界值;array[p2]>=分界值;此時將array[p1]與array[p2]進行交換,依次進行,直到p1>=p2,即p1和p2交錯時停止,此時p1所在位置是第一個大於等於分界值的元素,將array[p1]與分界值array[end]進行交換,此時完成一輪分割,於是小於等於分界值的元素都在前面部分,大於等於分界值的元素都在後面部分,分界值的新的下標是p1,即此時[start,end]陣列的分界值在p1位置(注意,這裡採取的交換策略中,對於左邊的指標p1,認為大於等於分界值的元素都應該移到右邊;對於右邊的指標p2,認為小於等於分界值的元素都應該移動到左邊,即都包含等於的情況,這樣可以使得結果均衡,避免出現最壞情況。)在完成了這一輪的分界之後,應該對分界後的2個子陣列進行遞迴的分界,即已經得到了分界值p1,於是對於[start,p1]和[p1+1,end]要分別呼叫partition()方法進行分界。
與歸併排序不同的是,歸併排序是先遞迴呼叫divide(),再呼叫非遞迴方法merge()方法進行合併;
this.divide(array,tempArray,start,middle);
this.divide(array,tempArray,middle+1,end);
this.merge(array,tempArray,start,end);
快速排序是先呼叫非遞迴方法partition()確定分界值,再遞迴呼叫divide()進行進一步分界。
int mid = partition(A, start, end);
quick(A, start , mid);
quick(A, mid+1, end);
注意對於遞迴方法,一定要有遞迴結束的邊界條件。
注意:快速排序非常容易出錯,不僅要理解,對於易出錯的點還要記住解決方案,直接按照規範的操作來,不要隨便寫,直接避免出錯就行了。
①例如如果對於區間[6,7]進行快排,那麼(6+7)/2=6;p1=6,p2=6,將44與44進行交換,之後p1=7,p2=5,結束迴圈,將array[p1]與array[end]進行交換,即array[end]與array[end]自身進行交換。相當於沒有交換,於是程式陷入死迴圈,死遞迴最終出現棧溢位的錯誤。
這裡快速排序採用的分組方式其實很簡單,不需要找到之間元素後與最後的元素進行交換,在對i、j進行遍歷交換最後再將最後的元素更換回來並記錄分界點新的位置。採用的分組策略是這樣的:partition(array,start,end)方法用於對陣列array中[start,end]區間內的元素進行分界,注意partition()方法不是遞迴方法,它先找到[start,end]陣列中間位置的元素值,注意時值middleValue,不需要將其與最後的元素交換,然後使用2個指標從頭和尾開始向後和向前進行遍歷,這裡指標可以直接使用start和end,比較的邏輯還是一樣的,如果array[start]<middleValue則start繼續向後移動,如果array[end]>middleValue則end繼續向前移動,當遇到array[end]<=middleValue,array[start]>=middleValue時將array[start]與array[end]交換然後start++,end--;直到start>=end即交錯時結束交換並返回此時的start值到quick()方法中進行繼續的分界,此時這個返回的start作為待分界陣列的分界點,之後分別對2個子陣列進行分界即可,但是這裡千萬千萬注意,有一個麻煩的細節,在得到int middle=this.partition(array,start,end)即陣列的分界點後,通過遞迴呼叫quick()方法對兩個子陣列進行分界,此時採取的分界方式是[start,middle-1]和[middle,end即為:
this.quick(array, start, middle-1);
this.quick(array, middle , end);
為什麼不是用:
this.quick(array, start, middle);
this.quick(array, middle+1 , end);
進行分界:如果使用[start,middle]和[middle+1,end]進行分界,那麼存在一種情況:對於quick(0,2)即
對於[0][1][2]3個元素,是順序排列的,交換時在start=end=1之後start=2,end=0;此時返回的middle=2,即以[2]作為陣列[0,2]的分界,相當於沒有進行分界,於是遞迴呼叫quick(array,0,2)一直陷入死迴圈,死遞迴,最終導致棧溢位。而採用[start,middle-1]和[middle,end]可以避免這個問題。記住這個問題直接避免即可。
quick()是一個遞迴方法,它的結束的邊界條件還是if(start>=end) return;
總結:快速排序方法邏輯還是很清楚直接的,和歸併排序一樣,需要寫2個方法,quickSort(array,n)是呼叫者方法;寫一個quick(array,start,end)方法,這是一個遞迴方法,用來計算intmiddle=partition(array,start,end);即陣列[start,end]的分界點;然後遞迴呼叫quick(start,middle-1)方法和quick(array,middle,end)方法來對子陣列進行分界;關鍵是寫一個partition(array,start,end)方法,用來先找位置中間值middleVlaue,然後將陣列元素分界到middleValue的2邊,然後返回新的分界點位置,即start的位置,將其返回到quick()方法中作為int middle即陣列分界的分界點。
import java.util.*;
//快速排序,使用分治思想,通過遞迴來分割地解決問題,關鍵是返回分界之後分界點的位置,以便進行下一次的遞迴分界
public class QuickSort {
public int[] quickSort(int[] A, int n) {
//特殊輸入
if(A==null||A.length<=0) return A;
//呼叫一個遞迴的quick()方法來實現快速排序
this.quick(A,0,A.length-1);
return A;
}
//寫一個遞迴方法quick()通過遞迴呼叫自己來不斷分割給定的區間,假設執行quick(array,start,end)方法後陣列就完成排序
public voidquick(int[] array,int start,int end){
//遞迴方法一定要有遞迴結束的邊界條件,本題結束的邊界條件是要分割的區域只有一個元素
if(start>=end)return;
//呼叫partition()方法來對[start,end]範圍的陣列進行分界,並返回分界元素的位置下標
intmiddle=this.partition(array,start,end);
//遞迴呼叫divide()方法對已經得到的2個子陣列進行分界,假設呼叫divide()方法後陣列就可以對該範圍完成分界
//if (middle > start + 1) {不需要寫,遞迴終止條件已經可以終止遞迴
this.quick(array,start, middle-1);
//}
//if(middle<end) {不需要寫,遞迴終止條件已經可以終止遞迴
this.quick(array,middle, end);
//}
}
//核心方法partition(),用來對[start,end]範圍的陣列進行分界並且返回分界值的新下標
public intpartition(int[] array,int start,int end){
//先找出分界值
int middleValue=array[(start+end)/2];
//以start,end作為2個指標,分別從陣列的開頭和結尾向後和向前遍歷陣列,符合交換條件時就進行交換,不符合就繼續移動,直到2個指標交錯或者重合(重合時交換與不交換等價,於是是否包含=號不影響結果)
while(start<=end){
//當陣列有序排列時是start和end移動可能導致越界,但可以在後面交換條件時再進行判斷
while(array[start]<middleValue){
start++;
}
while(array[end]>middleValue){
end--;
}
//可以防止越界的情況
if(start<=end){
//交換2個元素的位置
this.swap(array,start,end);
start++;
end--;
}
}
//start是大於等於分界值的第一個元素,下一次就在以此分界點形成的2個子陣列中進行遞迴分界
return start;
}
//輔助函式,專門用來交換陣列中的2個元素
public voidswap(int[] array,int p1,int p2){
inttemp=array[p1];
array[p1]=array[p2];
array[p2]=temp;
}
}
3.堆排序
所謂堆就是優先佇列,就是先進先出的佇列,即兩端開口的序列。先將陣列建立成為大小為n的大根堆,堆頂是最大的元素,將堆頂元素與堆末尾的元素進行交換,並讓這個最大元素脫離陣列,再對剩下的堆進行排序,通過對堆進行調整,使得最大元素調整到堆頂的位置,然後再將堆頂元素與最後的元素進行交換。
4.希爾排序(shell排序)
希爾排序是插入排序的改良版本,插入排序中前面是有序序列,每次將元素array[i]插入到前面有序序列中的合適位置,直接插入排序在插入時是逐個與前面的元素進行比較,即比較的步長為1,而希爾排序中,步長是一個逐漸變小的過程,對於陣列6 5 3 1 8 7 2 4。例如第一次插入時步長為3,於是對於下標為0,1,2的元素不需要排序,從i=3即第4個元素開始進行插入,此時比較array[3]與array[0]的大小,如果array[3]<array[0],那麼就將array[3]與array[0]進行交換,說明array[3]應該插入到前面去,然後i++在比較array[4]與array[1]的大小,進行相同的交換……例如對於i=7的最後一個元素,它先與array[4]=8進行交換,此時array[4]=4,然後再與array[4-3=1]=5進行比較交換,於是array[1]=4,再往前就越界了。即對於某一個步長k,在遍歷元素時總是與array[i-k],array[i-2*k],array[i-3*k]進行比較和交換,即在步長k的情況下,對於一個元素array[i],只需要考慮array[i-k],array[i-2*3],array[i-3*k],array[i-4*k]……(直到向前越界)
這個序列即可,即抽取這幾個元素組成一個新的當前待排序的子序列陣列。比較大小決定是否進行交換,注意,一個元素array[i]要與之前的所有元素進行比較和交換,直到再往前跳躍時越界,不能僅僅比較和交換1次。對於步長k=3遍歷比較交換完成後對k進行調整,通常是k--;按照相同的過程進行遍歷比較交換,此時從元素i=k=2進行向前的比較,前面的2個元素不用考慮順序,比較array[i-2],array[i-2*2],array[i-3*2]……直到向前越界。不管步長的大小如何調整,最終步長k一定要調整為k=1,即對所有元素進行逐一比較交換,使得整個陣列完全有序。當步長k=1時的排序就是一個直接插入排序,直接插入排序其實和任意步長k的插入排序思想都是一樣的,就是逐個比較array[i-k],array[i-2*k],array[i-3*k],進行比較交換,只是當步長為k=1時的交換就是兩個相鄰元素之間的交換,前面插入排序中所將的將array[i]插入到前面有序序列中的j位置,其實就是通過對array[i-1],array[i-2],array[i-3]……逐一進行比較交換得到的,並不是找到位置後再將目標位置後面的元素統一向後移動一位,即還是基於比較交換的。
希爾排序進行了好幾輪的插入排序,看似麻煩,但是當k值較大時,排序的粒度較粗,交換的元素較少,當k逐漸減小時,當前陣列已經排序的程度逐漸提高,需要進行交換的次數變少,當最後k=1時只需要對很少的幾個元素進行交換即可。根據統計規律可以得出結論,當步長k選擇恰當時可以使得時間複雜度減少,最優時間複雜度為O(n),最劣時間複雜度為O(n^2),平均的時間複雜度為O(n^1.5),空間複雜度為O(1).
其實對於氣泡排序、插入排序、選擇排序、歸併排序、希爾排序、堆排序、快速排序,都是基於元素交換來實現的。
在寫程式碼時,步長總是從int feet=length/2開始,逐步減小為一半(常識,除以2用>>來實現),直到feet>0不再滿足,即最後一次遍歷的步長總是1;對於每一個步長feet,從i=feet(注意對於步長為1時就從第2個元素即i=1,因為總是與前面的元素進行比較,開始遍歷陣列)開始遍歷陣列,對於每個i,比較array[i]與array[i-feet]、array[i-feet-feet](直到向前越界)進行比較。如果array[i]<前面某個元素就與它進行交換,直到找到在該步長陣列中的合理的位置,即希爾排序是步長為feet的插入排序,插入的原理是不變的。希爾排序程式中有3層迴圈,最內層是對於元素array[i]遍歷前面的元素找到合適的插入位置;中間層迴圈時對每個元素進行遍歷和向前插入,外層迴圈時feet的遍歷,由於feet是有限的,所以外層迴圈複雜度是常數C而不是n,對於內層的2層迴圈,最壞情形複雜度為O(n^2),即等於直接插入排序,但是一般複雜度為O(n^1.5)。在寫程式碼時對於不同步長feet的遍歷可以使用for迴圈、while迴圈也可以使用遞迴,顯然這裡使用的是尾遞迴,很容易的,就是while迴圈的遞迴形式而已。
importjava.util.*;
//希爾排序,對步長feet進行迴圈或者遞迴地縮短,直到收斂為步長為1的直接插入排序
publicclass ShellSort{
public int[] shellSort(int[] A, int n){
//特殊輸入
if (A == null || A.length<= 0)
return A;
//呼叫遞迴方法(尾遞迴)sort()來完成希爾排序
//注意習慣,題目中的陣列通常以A給出,而自己喜歡用array表示陣列,因此在呼叫函式時要記住傳入的是A
sort(A, A.length >> 1);
//記得要返回排序後的結果
return A;
}
//寫一個排序方法sort(array,feet)用來使用步長feet對陣列進行插入排序,內部呼叫的方法最好寫成private方法
private void sort(int[] array, intfeet) {
//遞迴一定要有停止遞迴的邊間條件
if (feet <= 0)
return;
//按照步長feet對陣列array進行插入排序
//初始位置為i=feet;初始的比較位置是index=index-feet
for (int i = feet; i <array.length; i++) {
// 要與前一個元素進行比較需要設定一個指標index,總是比較2個相鄰的元素array[index]和array[index-feet],第一個元素是array[i]
int index = i;
//如果index-feet<0說明index不要再往前交換了,本元素已經找到了合適的位置,停止迴圈
while (index - feet>= 0) {
if(array[index] < array[index - feet]) {
//如果後一個元素比前一個元素要小,應該交換元素
this.swap(array,index, index - feet);
index-= feet;
} else {
// 注意,還要有else,如果後面的元素大於等於前面的元素,不需要交換,說明元素array[index]之前已經找到合適的位置於是不需要再往前遍歷了,結束本元素array[i]的插入,開始下一個i的向前插入
break;
}
}
}
// 本步長的插入結束,此時需要更換步長feet,再次進行插入,於是遞迴呼叫sort()傳入新的步長即可
this.sort(array, feet>> 1);
}
//寫一個輔助函式用來交換2個元素
private void swap(int[] array, int p1,int p2) {
int temp = array[p1];
array[p1] = array[p2];
array[p2] = temp;
}
}