排序算法之快速排序與歸併排序與堆排序

快速排序

坑位法思想

快速排序是使用了分治與二分思想的算法。核心思想在於選擇一個基準值,然後將數組中大於基準值的數放置在基準值左邊,把數組中小於基準值的數放置在基準值右邊。之後對基準值左右的兩段數組重複上述操作,直至每段數組中只有一個數值,這樣每段數組都是排序的

從上述描述中可以看到分治的思想,然後這個分治的思路要使用遞歸來完成。這裏簡述下如何移動基準值左右的數值。這裏使用的是填坑法

我們選取數組的首個元素作爲基準值,讓i和j分別指向數組的首位與末尾。我們想要讓i指向的都比基準值小,讓j指向的都比基準值大。

(1)初始時,我們使用基準值與j所指向的元素比較如果基準值小於該值,則j左移,繼續比較,直至找到小於基準值的元素,或者j已經遇到i。

當找到一個元素小於基準值時,就將該值賦給nums[i],之後去比較nums[i]與基準值。(此時出現了重複值,沒關係,之後會將nums[j]的值更新掉

nums[i] = nums[j];

(2)比較nums[i]與基準值的方法類似。如果基準值大於nums[i],則i右移,繼續比較,直至找到大於基準值的元素,或者i已經遇到j。

當找到一個元素大於基準值時,就將該值賦給nums[j],之後去比較nums[j]與基準值。

nums[j] = nums[i];

具體的例子可以看參考鏈接下的文章:
參考鏈接:快速排序算法,建議學習的過程先自己手寫一次例子,[23,45,17,11,13,89,72,26,3,17,11,13]。

坑位法代碼實現

這裏要特別注意一點,元素可能存在重複,所以我們的判斷語句要帶着等於號,否則會進入死循環。

public class Main {
    static int times = 0;
    public static void main(String[] args) {
        //System.out.println("Hello World!");
        int []num = new int[]{23,45,17,11,13,89};
        quickSort(num,0,num.length-1);
        for(int i=0;i<num.length;i++)
            System.out.print(num[i]+" ");
        System.out.println();
        System.out.println(times);
    }
    public static void quickSort(int[]num,int start,int end){
        if(start<end){
            times++;
            int i = start;
            int j = end;
            int temp = num[start];
            //開始快速排序,從左到右尋找比temp大的,將其移動到右邊,從右到左尋找比temp小的,將其移動到左邊
            while(i<j){
                //首先比較 temp與num[j],這是正常情況,直到找到temp比num[j]大的情況
                while(j>i&&temp<=num[j])
                    j--;
                //此時temp比num[j]大那麼將num[j]賦值給num[i],同時比較num[i]與temp
                if(j>i)
                    num[i] = num[j];
                //可能會走到盡頭
                if(i==j)
                    break;
                //這是正常情況,直到找到temp比num[i]小的情況。
                while(i<j&&temp>=num[i])
                    i++;
                //此時找到了temp比num[i]小,將num[i]賦給num[j],同時比較num[j]與temp;或走到盡頭
                if(i<j)
                    num[j] = num[i];
                
            }
            //默認這時爲i=j
            num[i] = temp;
            quickSort(num,start,i-1);
            quickSort(num,i+1,end);
            return ;
        }
        //只剩下一個元素就可以返回了
        else{
            return ;
        }
    }
}

優化的交換法思想

上面的算法交換次數過多。我們可以簡化這個過程。我們固定讓每一次交換都是有意義的,只交換不是正常位置的元素,最後在放置基準值。

可以從代碼中看到。
(1)我們首先循環的將temp與num[end]比較,直到找到一個元素比temp小,或end走到了start。
(2)之後循環的將temp與num[start]比較,直到找到一個元素比temp大,或start走到了end。
(3)此時要麼start=end,否則就是已經找到了start和end。(大於temp的start與小於temp的end),只交換他們兩個。交換結束繼續重複(1)(2)(3)直到start=end。
(4)此時已經start=end,整個外層循環結束,此時start的位置應該去放置temp,因此將他們交換。

以上四步是一個完整的partition,我們接下來要繼續劃分其他兩段數組。持續這個過程直到每段數組中只有一個元素。

爲了便於理解,我們舉一個實際的例子,完整的走一遍 partition 的過程。
在這裏插入圖片描述

public void quickSort(int[]A ,int start ,int end)
    {
        if(start<end){
            int index = partition(A,start,end);
            quickSort(A,start,index-1);
            quickSort(A,index+1,end);
        }
        else
            return ;
    }
    //返回temp的下標
    public int partition(int[]A,int start,int end){
        int temp = A[start];
        int tempindex = start;
        while(start<end){
            while(start<end&&A[end]>=temp)
                end--;
            while(start<end&&A[start]<=temp)
                start++;
            if(start<end)
                swap(A,start,end);
        }
        swap(A,tempindex,start);
        return start;
    }
    public void swap(int[]A,int index1,int index2){
        int temp = A[index1];
        A[index1] = A[index2];
        A[index2] = temp;
    }

代碼注意(十分重要)

1.在while循環中一定不要光判斷A[end]>=temp,一定也要判斷start<end,防止越界。

2.可能有一個疑問就是既然我們已經交換了start和end,那麼start和end就沒用了,爲什麼不讓start和end移動呢。start和end的確是沒用了,但是start+1和end-1還是有用的。**如果start+1==end-1的話,在while(start<end)循環那裏就會直接退出循環,就沒有比較start+1這個位置的數值。**我們要保證的是當while循環結束時,要將所有的數都和temp這個值做好了比較。

if(start<end){
 	swap(start,end,arr);
	start++;
	end--;
}

以該例子爲例。

[2,4,4,1,3,3,2]->[2,1,4,4,3,3,2]
start = 0 -> 1
end = 6 -> 3
交換start與end,此時如果start++和end--,那麼就會退出循環,並將下標爲0的位置與start的位置(2)交換,
結果爲[4,1,2,4,3,3,2],這樣就出了問題。
而不start++和end--,會讓end--,直到end=1和start相等。這樣就比較了下標爲2的值與temp的值,
此時start=end=1,結果爲[1,2,4,4,3,3,2]

一些分析

時間複雜度:平均時間複雜度爲O(nlogn),logn是需要遞歸的次數。n是每一次需要比較的次數。

考慮最差的情況:123456。在第一次拆分時會分成1和23456,時間複雜度就變成了了O(n^2)。

爲了避免這種情況,可以隨機選取基準值。隨機選取基準值就是隨機找到一個值,並將該值與start位置的元素交換順序,

int index = (int)(Math.random()*(end-start+1)+start);
swap(A,start,index);

歸併排序

思想

分而治之,然後合併。整體思路就如下圖,首先分成兩段,之後合併。
在這裏插入圖片描述

代碼

這裏唯一值得注意的就是temp一定要在外面聲明,不要讓每一次merge裏都創建新的數組,否則內存會爆。

public class Solution {
    /**
     * @param A: an integer array
     * @return: nothing
     */
     //新建一個數組用來存放結果
    static int[]temp;
    public void sortIntegers2(int[] A) {
        // 歸併算法:分,合
        if(A==null||A.length==0)
            return ;
        temp = new int[A.length];
        mergeSort(A,0,A.length-1);
    }
    public void mergeSort(int[]num,int start,int end){
        if(start<end)
        {
            int mid = (start+end)/2;
            //分別對兩段數組繼續進行分合操作
            mergeSort(num,start,mid);
            mergeSort(num,mid+1,end);
            //將有序的兩段數組合並
            merge(num,start,mid,end);
        }
    }
    /*
    合併從[start,mid]和[mid+1,end]
    */
    public void merge(int[]num,int start,int mid,int end){
        int i = start;
        int j = mid+1;
        int time = 0;
        while (i<=mid&&j<=end){
            int left = num[i];
            int right = num[j];
            if(left<=right)
            {
                temp[time++] = left;
                i++;
            }else{
                temp[time++] = right;
                j++;
            }
        } 
        while(i<=mid)
            temp[time++] = num[i++];
        while(j<=end)
            temp[time++] = num[j++];
        time=0;
        while(start<=end)
            num[start++] = temp[time++];
    }
}

堆排序

思路

完全二叉樹性質補充

在說堆排序之前先補充一下二叉樹的知識。下圖中a爲完全二叉樹,而b爲非完全二叉樹。那麼什麼是完全二叉樹,完全二叉樹是指其下標可以與滿二叉樹完全對應,也就是說,完全二叉樹中的節點是按順序一個一個插入的。
在這裏插入圖片描述
完全二叉樹有什麼好處?我們可以通過子節點的座標找到父節點的對應座標。我們知道二叉樹一層最多有2
的i次方個節點。如果是按順序排下來的話,上層節點與下層節點的座標之差就是2倍(左子節點)或2倍+1(右子節點)
。因此已知子節點的座標,可以求得父節點的座標。但是非完全二叉樹就不可以了,比如G和D。因爲他們不滿足按順序往下排。

父節點座標 = 子節點座標/2

最大堆與最小堆概念補充

最大堆就是當前節點的值大於其左右子節點。最小堆就是當前節點的值小於其左右子節點。但對於他們的左右子節點孰大孰小沒有要求
在這裏插入圖片描述
我們的數組可以看成一個完全二叉樹。

最大堆(這裏i是從0開始的)
num[i]>num[2*i+1]&&num[i]>num[2*i+2];

在這裏插入圖片描述

堆排序完整步驟

我們以升序排序爲例。我們的數組可以看成一個完全二叉樹。核心思路是要維護一個最大堆,然後將最大堆的最大值、次大值等等交換至倒數第一個位置,倒數二個位置。

整體思路如下:
1.構造最大堆:我們首先尋找當前樹中最後一個非葉子節點A,並尋找節點A與其子節點中的最大值,並將最大值與該節點交換值(這裏注意一下交換位置後,還需要保持A節點在子節點的位置滿足最大堆的性質);持續這個過程,直到所有的非葉子節點都完成上述操作,這樣我們的最大堆構造完成。

2.交換過程:第一次時,我們當前的根節點就是整個樹中最大的值,將該值與二叉樹中的最後一個節點交換值。交換節點後,我們要繼續判斷根節點是否滿足最大堆的性質並進行調整(開始比較根節點與根節點的左子節點與右子節點,如果根節點不滿足就需要調整)。完成上述操作後,將根節點的值與倒數第二個值交換位置。持續上述操作直至已經到交換最後一個位置。

3.其實這裏最重要的一點是判斷當前節點是否滿足最大堆的性質並調整。同時,在比較節點與子節點最大值這一操作中,會對節點更改順序,當前節點換到了子節點的位置後,要要繼續判斷該節點在子節點位置是否可以滿足最大堆的性質,如果不滿足就需要調整其位置。

4.第二重要的就是如何選取最後一個非葉子節點,我們最後一個節點的父親就是最後一個非葉子節點。我們最後一個節點的下標是nums.length,我們考慮樹是從1到nums.length排序號的情況。因此它的父節點座標就是nums.length/2,但由於數組是從0到nums.length-1排序,因此最終結果爲:

最後一個非葉子節點座標 =  (nums.length/2)-1

完整代碼

整體步驟與上述思路相同。
1.構造最大堆;從最後一個非葉子節點開始,讓所有的非葉子節點滿足最大堆的性質。

2.將首位與末尾交換元素,之後讓首位的節點滿足最大堆的性質。

3.最重要的就是select函數,它的作用是判斷以index爲首的樹是否滿足最大堆性質,如果不滿足就交換元素。這裏需要注意兩點:

1.如果index所在元素被交換了位置,一定要繼續去讓該元素滿足最大堆的性質(繼續調用該函數,傳入的index變更爲交換後的位置)

2.我們在函數中會判斷index是否有左右子節點。但判斷依據是當前的左右子節點是否存在且不是已經交換位置的節點。已經交換位置的節點我們不予判斷,可以當做沒有,這也是我們傳入length的原因,就是告訴數組當前未判斷的有效長度

public class Solution {
    /**
     * @param A: an integer array
     * @return: nothing
     */
    public void sortIntegers2(int[] A) {
        // 堆排序
        if(A==null||A.length==0)
            return ;
        HeapSort(A);
    }
    public void HeapSort(int[]A)
    {
        //1.構造最大堆
        int lastIndex = A.length/2+1;
        for(int i=lastIndex;i>=0;i--)
        {
            select(A,i,A.length);
        }
        //2.交換位置與繼續尋找
        for(int i=A.length-1;i>=0;i--)
        {
            swap(A,0,i);
            select(A,0,i);
        }
    }
    /**
     * 調整索引爲 index 處的數據,使其符合堆的特性。
     * @param A 當前數組
     * @param index 需要堆化處理的數據的索引
     * @param len 未排序的堆(數組)的長度
     */
    public void select(int[]A,int index,int length)
    {
        int left = -1;
        int right = -1;
        //注意這裏的判斷不能帶上之前交換過位置的節點。如果當前節點的左子節點或右子節點是之前交換過的位置的節點,就不比較了。
        if(2*index+1<length)
            left = 2*index+1;
        if(2*index+2<length)
            right = 2*index+2;
        //都爲空不用比較
        if(left==-1&&right==-1)
            return;
        //先默認放left
        int maxindex = left;
        if(maxindex==-1)
            maxindex = right;
        else{
            if(right!=-1&&A[right]>A[left])
                maxindex = right;
        }
        //如果需要交換的話,交換之後要去判斷那個子節點位置(現在是之前的A[index]),
        //維護以其爲首的樹爲最大堆
        if(A[maxindex]>A[index])
        {
            swap(A,index,maxindex); 
            select(A,maxindex,length);
        }
    }
    /*
    普通交換
    */
    public void swap(int[]A,int index1,int index2)
    {
        int temp = A[index1];
        A[index1] = A[index2];
        A[index2] = temp;
    }
}

練習題

Lintcode

1.整數排序 II

464整數排序
純粹的練手題,可以練習快速排序,堆排序以及歸併排序。

2.

LeetCode

1.數組中的第K個最大元素

題目鏈接

思路

思路可以很簡單的使用快速排序或堆排序,然後直接返回第k個最大元素,但其實快速排序中有一種簡便算法。因爲快速排序,每次會選取一個基準值,基準值左邊是小於它的,右邊是大於它的,那麼我們就可以知道這個基準值時在數組中第幾大,同理,我們也可以知道第K大的元素就是這個基準值,或者在右邊數組還是在左邊數組。這樣就完成了剪枝的工作,可以只排序一邊數組,直到找到這個第K大的元素。

代碼

1.注意這裏能使用while(true)的原因是題目中說,k一定是滿足條件的,也就是說不會出現k超過nums的長度,也就是一定可以找到。

2.快速排序雖然快,但是有一些特殊情況時間也會很慢,所以我們的基準值選取一定要隨機選取。

3.第k大的元素對應的位置就是nums.length-k。大家可以想第一大的數,就是nums.length-1,第二大的就是nums.length-2…以此類推。

class Solution {
    int[]nums;
    int k;
    /*
    思路其實是快速排序的變種。不過可以進行有效的剪枝,我們可以通過當前partition傳回的index
    來判斷第k個最大值在左邊的數組還是右邊的數組,只排序其中的數組,
    同時判斷當前的index是否就滿足條件,滿足條件就可以直接返回。
    */
    public int findKthLargest(int[] nums, int k) {
        this.nums = nums;
        this.k = k;
        if(nums==null||nums.length==0)
            return -1;
        //第k大元素的位置
        int index = nums.length-k;
        int left = 0;
        int right = nums.length-1;
        while(true){
            int nowindex = parttion(left,right);
            if(nowindex==index)
                return nums[nowindex];
            //index在nowindex的右邊
            else if(nowindex+1<=index)
                left = nowindex+1;
            else
                right = nowindex-1;
        }
    }
   
    public int parttion(int start,int end){
        //每次都選取隨機的位置作爲temp
        int random = (int)(Math.random()*(end-start+1)+start);
        swap(random,start);
        int i = start;
        int j = end;
        int temp = nums[start];
        while(i<j){
            //先j向右
            while(i<j&&nums[j]>=temp) j--;
            //再i向右
            while(i<j&&nums[i]<=temp) i++;
            if(i<j)
                swap(i,j);
        }
        swap(i,start);
        return i;
    }
    public void swap(int index1,int index2)
    {
        int temp = nums[index1];
        nums[index1] = nums[index2];
        nums[index2] = temp; 

    }
}

2. 前 K 個高頻元素

題目鏈接

思路

其實思路比較簡單,我們一定是要遍歷一次數組的。這個O(n)時間複雜度無可避免。我們可以優化的是之後的排序過程。我們維護一個長度爲k的最小堆,當長度大於k時,想要添加進元素,就要將這個元素與堆頂元素比較,如果當前元素的出現次數更多,就將其放入堆中,並將堆頂元素出堆(之後堆內部會進行一些交換操作以維持最小堆性質)。

代碼

首先使用HashMap,讓HashMap存儲(元素,元素的個數),其次使用堆/優先隊列(PriorityQueue)來維護一個最小堆,堆頂是出現頻率最小的,維護一個長度爲k的堆,當堆的長度大於k時,就需要更新堆,將元素與堆頂比較,如果該元素比堆頂元素的出現頻率大,就將堆頂元素彈出,把該元素插入堆裏。

這裏最重要的就是要給PriorityQueue重寫比較器,以完成內部的比較。其實回看本題的時候就看看這個比較器,注意是Comparator,裏面是compare函數,同時compare函數中的形參是包裝類。

PriorityQueue<Integer> pq = new PriorityQueue<>(new Comparator<Integer>()
       {
           @Override
           public int compare(Integer a, Integer b) {
               return map.get(a)-map.get(b);
            }
       }
       );

完整代碼

class Solution {
    public List<Integer> topKFrequent(int[] nums, int k) {
        /* 
        首先使用HashMap,讓HashMap存儲(元素,元素的個數)
        使用堆/優先隊列(PriorityQueue)來維護一個最小堆,堆頂是出現頻率最小的,維護一個長度爲k的堆,當堆的長度大於k時,就需要更新堆,將元素與堆頂比較,如果該元素比堆頂元素的出現頻率大,就將堆頂元素彈出,把該元素插入堆裏。
        */
        if(nums==null||nums.length==0)
            return null;
        HashMap<Integer,Integer>map = new HashMap<>();
        LinkedList<Integer>list = new LinkedList<>();
        for(int num:nums)
            map.put(num,map.getOrDefault(num,0)+1);
        //需要爲堆寫自己的比較器。是用來對內部堆結構排序用的,始終構造成一個最小堆()
       PriorityQueue<Integer> pq = new PriorityQueue<>(new Comparator<Integer>()
       {
           @Override
           public int compare(Integer a, Integer b) {
               return map.get(a)-map.get(b);
            }
       }
       );
       for(int num:map.keySet())
       {
            if(pq.size()<k)
           {
               pq.add(num);
           }else{
               int pqHead = pq.peek();
               if(map.get(num)>map.get(pqHead))
                {
                    pq.remove();
                    pq.add(num);
                }
           }
       }
       while(!pq.isEmpty())
       {
           //System.out.println(pq.peek());
           list.addFirst(pq.remove());
       }
       return list;
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章