1. 程式人生 > >演算法基礎_排序演算法

演算法基礎_排序演算法

一.O(n^2)的排序演算法

O(n^2)的排序演算法

  • 基礎
  • 編碼簡單,易於實現,是一些簡單情景的首選
  • 在一些特殊情況下,簡單的排序演算法更有效
  • 簡單的排序演算法思想衍生出複雜的排序演算法
  • 作為子過程,改進更復雜的排序演算法

1. 選擇排序   時間複雜度為O(n^2),空間複雜度O(1)

//選擇排序演算法,時間複雜度為O(n^2),空間複雜度為O(1).
    public int[] selectionSort(int[] arr){

        for (int i=0; i<arr.length; i++){

            //尋找[i,n)區間裡的最小值(合法值)
            int currentIndex=i;
            for (int j=i+1; j<arr.length; j++){
                if(arr[j]<arr[currentIndex]){
                    currentIndex=j;
                }
            }
            
            //交換陣列中索引為 i , cuurentIndex 的元素
            Utils.swap(arr, i, currentIndex);
        }

        return arr;
    }

2.插入排序法, 時間複雜度為O(n^2),空間複雜度O(1)

//插入排序演算法, 時間複雜度為O(n^2),空間複雜度為O(1).
    public int[] insertionSort(int[] arr){

        for(int i=1; i<arr.length; i++){

            //尋找元素arr[i]合適的插入位置
           for(int j=i; j>0; j--){
               if(arr[j]<arr[j-1]){
                   Utils.swap(arr, j, j-1);
               }else{
                   break;
               }
           }
        }
        return arr;
    }

2.1 改進的插入排序法,時間複雜度為O(n^2),空間複雜度O(1).

      由於元素的交換操作比元素的賦值操作更費時(一次交換需要3次賦值操作),所以優化思路是將比較中的元素交換操作改進為了賦值操作。

 //改進的插入排序演算法, 時間複雜度為O(n^2),空間複雜度為O(1).
    //將頻繁的交換元素操作改為了賦值操作,提高了效率
    public int[] insertionSort(int[] arr){
        for (int i=1; i<arr.length; i++){

            //尋找元素arr[i]合適的插入位置
            int e = arr[i];
            int j;   //j儲存元素e應該插入的位置, 生存週期在for迴圈外
            //for迴圈寫法1
//            for(j=i; j>0; j--){
//                if(e<arr[j-1]){
//                    arr[j]=arr[j-1];
//                } else{
//                    break;   //此處的break不可省略,不然for迴圈退出時,j=1.
//                }
//            }

            //上述for迴圈更優雅的寫法  條件 e<arr[j-1] 不滿足時,無法進入迴圈
            for(j=i; j>0 && e<arr[j-1]; j--){
                arr[j]=arr[j-1];
            }

            arr[j]=e;
        }
        return arr;
    }
  • 相比於選擇排序法,改進的插入排序法的內層迴圈在元素找到正確位置後就可以直接跳出,所以時間上要更快一些
  • 插入排序法雖然是一個O(n^2)的排序演算法,但它有自己的特點,在近乎有序的陣列中排序時,它幾乎可以看作是O(n)級別的,比O(nlogn)的演算法還要有效,所以具有實際意義的
  • 希爾排序法就是插入排序法的一種延伸,希爾排序法的時間複雜度在O(n^2)與O(nlogn)之間,但相比於O(nlogn)的演算法來說,實現較為簡單,具有一定的實際意義

2.2 希爾排序 

 排序思想:

  • 希爾排序是對插入排序的優化,基於以下兩個認識:1. 資料量較小時插入排序速度較快,因為n和n^2差距很小;2. 資料基本有序時插入排序效率很高,因為比較和移動的資料量少。
  • 希爾排序的基本思想是把需要排序的序列按下標的一定增量分組,對每組使用直接插入排序演算法排序;隨著增量逐漸減少,每組包含的資料越來越多,當增量減至1時,整個陣列恰被分成一組,演算法便終止。這樣通過對較小的序列進行插入排序,然後對基本有序的數列進行插入排序,能夠提高插入排序演算法的效率。
  • 簡單插入排序很循規蹈矩,不管陣列分佈是怎麼樣的,依然一步一步的對元素進行比較,移動,插入,比如[5,4,3,2,1,0]這種倒序序列,陣列末端的0要回到首位置很是費勁,比較和移動元素均需n-1次。而希爾排序在陣列中採用跳躍式分組的策略,通過某個增量將陣列元素劃分為若干組,然後分組進行插入排序,隨後逐步縮小增量,繼續按組進行插入排序操作,直至增量為1。希爾排序通過這種策略使得整個陣列在初始階段達到從巨集觀上看基本有序,小的基本在前,大的基本在後。然後縮小增量,到增量為1時,其實多數情況下只需微調即可,不會涉及過多的資料移動。

實現方法:

  • 希爾排序的基本步驟,首先,選擇增量gap=length/2,縮小增量的方式為:gap = gap/2,這種增量選擇我們可以用一個序列來表示,{n/2,(n/2)/2...1},稱為增量序列。希爾排序的增量序列的選擇與證明是個數學難題,我們選擇的這個增量序列是比較常用的,也是希爾建議的增量,稱為希爾增量,但其實這個增量序列不是最優的。
  • 在希爾排序的理解時,我們傾向於對於每一個分組,逐組進行處理,但在程式碼實現中,我們可以不用這麼按部就班地處理完一組再調轉回來處理下一組(這樣還得加個for迴圈去處理分組)比如[5,4,3,2,1,0] ,首次增量設gap=length/2=3,則為3組[5,2] [4,1] [3,0],實現時不用迴圈按組處理,我們可以從第gap個元素開始,逐個跨組處理。同時,在插入資料時,可以採用元素交換法尋找最終位置,也可以採用陣列元素移動法尋覓。
  • 希爾排序的時間複雜度和增量的選擇策略有關,上述增量方法造成希爾排序的不穩定性。

特點:

  • Shell Sort雖然慢於高階的排序方式, 但仍然是非常有競爭力的一種排序演算法 ,其所花費的時間完全在可以容忍的範圍內, 遠不像O(n^2)的排序演算法, 在資料量較大的時候無法忍受 。
  • 同時, Shell Sort實現簡單, 只使用迴圈的方式解決排序問題, 不需要實現遞迴, 不佔用系統佔空間, 也不依賴隨機數 。
  • 如果演算法實現所使用的環境不利於實現複雜的排序演算法, 或者在專案工程的測試階段, 完全可以暫時使用Shell Sort來進行排序任務

參考: 圖解排序演算法(二)之希爾排序

1>. 增量序列為 {n/2, (n/2)/2,...1} 的希爾排序

 //增量序列為 {n/2, (n/2)/2,...1} 的希爾排序
    public int[] shellSort(int[] arr){
        //第一重迴圈控制增量的變化
        for(int gap = arr.length/2; gap>=1; gap/=2){

            //對每個小組進行插入排序,排序是跨組處理的
            //arr[i], arr[i-gap], arr[i-2*gap]...屬於一組
            for (int i=gap; i<arr.length; i++){
                int j;
                int e=arr[i];
                for(j=i; j>=gap && e<arr[j-gap]; j-=gap){  //j>=gap,最終保證j-gap在[0,gap)內
                     arr[j]=arr[j-gap];
                }
                arr[j]=e;
            }
        }

        return arr;
    }

2>. 根據資料量大小不同自動調整增量序列為 {1, 4, 13, 40, 121, 364, 1093...} 的希爾排序

//增量序列為 {1, 4, 13, 40, 121, 364, 1093...} 的希爾排序
    public int[] shellSortII(int[] arr){

        //計算增量序列,根據資料量大小自動初始化
        int gap = 1;
        while (gap<arr.length/3){
            gap=3*gap+1;
        }

        //while迴圈控制增量的變化
        while(gap>=1){

            //對每個小組進行插入排序,排序是跨組處理的
            //arr[i], arr[i-gap], arr[i-2*gap]...屬於一組
            for(int i=gap; i<arr.length; i++){
                int j;
                int e=arr[i];
                for(j=i; j>=gap && e<arr[j-gap]; j-=gap){
                    arr[j]=arr[j-gap];
                }
                arr[j]=e;
            }

            //while迴圈退出條件控制
            gap/=3;
        }

        return arr;
    }

 

3.氣泡排序, 時間複雜度為O(n^2),空間複雜度O(1).

//基本的氣泡排序,對n個數據冒泡,則需要經過n-1輪比較,每一輪比較都將最大的元素放到末端
    public int[] bubbleSort(int[] arr){
        for (int i=arr.length-1; i>0; i--){  //i從 arr.length-1 ~ 1, 一共arr.length-1次 
            for(int j=0; j<i; j++){
                if(arr[j]>arr[j+1]){
                    Utils.swap(arr, j, j+1);
                }
            }
        }
        return arr;
    }

3.1 優化的氣泡排序, 時間複雜度為O(n^2),空間複雜度O(1).

氣泡排序的優化思路:

  • 如果在某一趟的冒泡途中沒有出現數據交換,那就只能是資料已經被排好序了,這樣就可以提前得知資料排好序從而中斷迴圈,消除掉不必要的比較
  • 如果在某一趟的冒泡途中最後的交換出現在pos的位置,那麼表示pos位置以後都已經排好序,這樣相比於基本冒泡每一次縮小遍歷範圍1而言有可能一次縮小的遍歷範圍>=1,所以這樣也可以提高排序的效率
//優化氣泡排序
    public int[] bubbleSortII(int[] arr){
        int lastswap=0;  //記錄最後一次發生交換的位置,下輪交換直接跳到此位置
        for(int i=arr.length-1; i>0; i=lastswap){
            lastswap=0;  //在每輪冒泡比較前,將lastswap置0,若冒泡比較時由元素的交換,則重新記錄lastswap的值,
                         // 若沒有元素的交換,則說明排序已完成,此時lastswap保持為0,可以直接跳出迴圈,提前結束排序過程
            for(int j=0; j<i; j++){
                if(arr[j]>arr[j+1]){
                    Utils.swap(arr, j, j+1);
                    lastswap=i;
                }
            }
        }
        return arr;
    }

 

二. O(nlogn)的排序演算法

1.歸併排序,遞迴實現,時間複雜度為O(nlogn),空間複雜度O(n)

//歸併排序, 時間複雜度為O(nlogn), 空間複雜度為O(n).
    public void mergeSort(int[] arr, int l, int r){
        //遞迴退出條件
        if(l>=r){
            return;
        }

        int middle=(r-l)/2+l;
        mergeSort(arr, l, middle);
        mergeSort(arr, middle+1, r);
        merge(arr,l, middle, r);
    }

    //歸併過程,即對arr[l, middle], arr[middle+1, r]兩個分別有序的部分做有序歸併
    private void merge(int[] arr, int l,int middle, int r){
        //輔助空間
        int[] temp = new int[r-l+1];
        for(int i=l; i<=r; i++){
            temp[i-l]=arr[i];  //i-l是為了保證temp的下標起始為0
        }

        //將temp中的資料重新歸併回arr
        int k=l;
        int m=l, n=middle+1;
        for(k=l; k<=r; k++){
            if(m>middle){
                arr[k]=temp[n-l];
                n++;
            }else if(n>r){
                arr[k]=temp[m-l];
                m++;
            }else{
                if(temp[m-l]>temp[n-l]){
                    arr[k]=temp[n-l];
                    n++;
                }else{
                    arr[k]=temp[m-l];
                    m++;
                }
            }
        }
    }

歸併排序的優化思路:

  • 由於merge是對兩個有序的部分進行合併,所以,在進行merge之前, 可以先進行一下判斷,如果arr[middle]<arr[middle+1],則不需要merge過程,這樣在資料量較大時,可以提高效率。(注,if判斷語句也會耗費時間)
  • 在資料量很小時(~15), 可以使用插入排序來代替後續的歸併排序。

1.2 自底向上的遞迴排序,迭代實現 

 //自下向上的歸併排序,通過迭代實現
    public void mergeSortII(int[] arr){
        //size表示每次歸併的長度,即對多少個元素歸併
        for (int size=1; size<arr.length; size+=size){
            
            //對arr[i, i+size-1], arr[i+size, i+size+size-1]進行歸併
            for (int i=0; i+size<arr.length; i+=size+size){  // 注意邊界限制,防止越界
                merge(arr, i, i+size-1, Math.min(i+size+size-1, arr.length-1));
            }
        }
    }

    private void merge(int[] arr, int l, int middle, int r){
        int[] temp = new int[r-l+1];
        for (int i=l; i<=r; i++){
            temp[i-l]=arr[i];
        }

        int k=l;
        int m=l, n=middle+1;
        for(k=l; k<=r; k++){
            if(m>middle){
                arr[k]=temp[n-l];
                n++;
            }else if(n>r){
                arr[k]=temp[m-l];
                m++;
            }else{
                if(temp[m-l]>temp[n-l]){
                    arr[k]=temp[n-l];
                    n++;
                }else{
                    arr[k]=temp[m-l];
                    m++;
                }
            }
        }
    }

 迭代實現的自底向上的歸併排序特點:

         不需要通過陣列索引獲取陣列元素,可以使用O(nlogn)的時間對連結串列這樣的資料結構進行排序,即對連結串列進行時間複雜度為O(nlogn)的排序,可以用迭代實現的自底向上的歸併排序。

2.快速排序 

 //基礎版快速排序
    public void quickSort(int[] arr,int l, int r){
        //遞迴退出條件
        if(l>=r){
            return;
        }

        int p = partition(arr, l, r);
        quickSort(arr, l, p-1);
        quickSort(arr, p+1, r);
    }

    //對arr[l,r]部分進行partition操作
    //返回p,使得arr[l,p-1]<arr[p],arr[p+1,r]>=arr[p]
    private int partition(int[] arr, int l, int r){
        //標定點選擇第一個元素,即arr[l]
        int v=arr[l];

        //i,j指標,使得arr[l+1,j]<v, arr[j+1,i)>=v
        int j=l;
        for(int i=l+1; i<=r; i++){
            if(arr[i]<v){
                j++;
                Utils.swap(arr,j,i);
            }
        }
        Utils.swap(arr, l, j);
        return j;
    }

基礎版單路排序的缺陷:(遞迴樹的不平衡導致遞迴深度的加深進而導致遞迴過程時間複雜度的惡化)         

  •  近乎有序的陣列:基礎版的快速排序在對近乎有序的陣列排序時,速度很慢,在對完全有序的資料排序時,達到了最差情況O(n^2),遠落後於遞迴排序。 原因是在對近乎有序的資料排序時,總是將第一個元素作為標定點,會導致快速排序生成的遞迴樹是極其不平衡的,遞迴深度遠不止logn,即遞迴的時間複雜度遠不止logn級別,最差情況 (完全有序的資料) 達到n的級別,降低排序速率。相反,歸併排序卻可以嚴格的保持遞迴樹的平衡,遞迴的時間複雜度仍為logn級別。
  • 具有大量重複資料的陣列:在對具有大量重複資料陣列排序時,單路的快排總是會將重複資料都劃分到標定點的一側,導致遞迴樹的不平衡,降低排序速度。

2.1 改進的雙路快排 

改進點:

  • 標定點通過隨機選取,而不是一味取第一個元素,這樣可以平衡有序陣列的遞迴樹,使其遞迴深度的期望為logn,從而提升對近乎有序陣列的排序的速度。
  • 通過雙指標從兩端同時遍歷的雙路排序,可以提升具有大量重複資料的陣列的排序速度。因為單路快排中會將所有重複資料都劃分到標定點一側,這樣會導致遞迴樹的不平衡,而雙路快排從兩側進行劃分,可以避免以上問題,從而提升對具有大量重複資料陣列的排序速度。
 //改進的快速排序
    public void quickSortII(int[] arr, int l, int r){
        //遞迴退出條件
        if(l>=r){
            return;
        }

        int p = positition(arr, l, r);
        quickSortII(arr, l, p-1);
        quickSortII(arr, p+1, r);
    }

    private int positition(int[] arr, int l, int r){
        //改進1 隨機選取標定點
        int index = (int)(Math.random()*(r-l+1)+l); // index為[l, r]之間的一個隨機數
        Utils.swap(arr, l, index);
       
        //改進2 雙路快排
        int i=l+1, j=r;   //i為l+1, 而不是l
        int v=arr[l];

        //arr[l+1, i)<=v, arr(j, r]>=v
        while (true){   //while判斷為真, 可以保證即使只有兩個元素時,即i=l+1=j 時,也可以進入迴圈

            while (i<=r && arr[i]<v){     //i停下的位置一定是大於或等於v的位置
                i++;
            }
            while (j>=l+1 && arr[j]>v){  //j停下的位置一定是小於或等於v的位置
                j--;
            }

            if(i>j){   //只能取大於
                break;
            }
            Utils.swap(arr, i, j);
            i++;    //防止交換操作後,arr[i]=v(或arr[j]=v) 而導致i(或j)指標無法移動,陷入死迴圈
            j--;
        }
        Utils.swap(arr, l, j);   // 最終,j落在最後一個小於等於v的位置, i落在第一個一個大於等於v的位置,最終i不等於j,要看l處需要的是大於還是小於v的值。
        return j;
    }

 2.2 為處理大量重複資料而生的三路快排

 //三路快排,用於優化陣列中有大量重複資料的情況
    //由於三路快排的每次position操作需要返回兩個索引,這裡的positition操作直接寫在了quickSortIII中
    //三路快排中,將arr[l, r]分為 <v, ==v, >v 三部分,之後遞迴對 <v, >v 的部分繼續進行三路快排,省去了對 ==v部分的操作,特別適合於存在大量重複資料陣列的排序
    public void quickSortIII(int[] arr, int l, int r){

        //遞迴退出條件
        if(l>=r){
            return;
        }

        //positition操作  在v交換前使得:arr[l+1, m]<v, arr[m+1, n-1]==v, arr[n, r]>v
        int index=(int)(Math.random()*(r-l+1)+l);
        Utils.swap(arr, l ,index);

        int v=arr[l];
        int m=l, n=r+1;
        for(int i=l+1; i<n; ){
            if(arr[i]==v){
                i++;
            }else if(arr[i]<v){
                m++;
                Utils.swap(arr, i, m);
                i++;
            }else{
                n--;
                Utils.swap(arr, i, n);
            }
        }
        Utils.swap(arr,l,m);  //交換後 arr[l,m-1]<v, arr[m, n-1]=v, arr[n, r]>v

        //遞迴
        quickSortIII(arr, l, m-1);  //注意,這裡的r=m-1, 因為最後一步的v與arr[m]進行了交換,此時 arr[l,m-1]<v, arr[m, n-1]=v, arr[n, r]>v
        quickSortIII(arr, n, r);
    }

歸併排序與快速排序的延伸: 

  • 分治思想:顧名思義,分而治之,就是將原問題分割成同等結構的子問題,之後將子問題逐一解決後,原問題也就得到了解決。
  • 歸併排序與快速排序都使用了分治思想,歸併排序中“分”的操作很簡單,其主要側重於“分”後的歸併處理,而快速排序則側重於如何進行“分”的操作

三.排序思想的應用 

1. 求陣列的逆序對

方式1.暴力求解,雙重for迴圈實現,時間複雜度度為O(n^2).

方式2.利用歸併排序思想,歸併過程中是對兩個有序的部分做處理,可以不用一組一組的作比較。從而降低時間複雜度為O(nlogn).

// 對於一個大小為N的陣列, 其最大的逆序數對個數為 N*(N-1)/2, 非常容易產生整型溢位
    //計算陣列中逆序對的個數
    public int reverseOrders(int[] arr, int l, int r){
        //遞迴退出條件
        if(l>=r){
            return 0;
        }

        int mid = (r-l)/2+l;
        int countl = reverseOrders(arr, l, mid);        // 求出 arr[l...mid] 範圍的逆序數
        int countr = reverseOrders(arr, mid+1, r);   // 求出 arr[mid+1...r] 範圍的逆序數

        return countl+countr+merge(arr, l, mid, r);
    }


    // merge函式求出在arr[l...mid]和arr[mid+1...r]有序的基礎上, arr[l...r]的逆序數對個數
    private int merge(int[] arr, int l, int mid, int r){
        //輔助陣列
        int[] temp = new int[r-l+1];
        for(int i = l; i<=r; i++){
            temp[i-l]=arr[i];
        }

        int count=0;    //記錄逆序對個數 初始化為0.
        
        int m=l, n=mid+1;
        for(int k=l; k<=r; k++){
            if(m>mid){
                arr[k]=temp[n-l];
                n++;
            }else if (n>r){
                arr[k]=temp[m-l];
                m++;
            }else {
                if(temp[m-l]<=temp[n-l]){
                    arr[k] = temp[m-l];
                    m++;
                }else{
                    // 此時, 因為右半部分k所指的元素小
                    // 這個元素和左半部分的所有未處理的元素都構成了逆序數對
                    // 左半部分此時未處理的元素個數為 mid - m + 1
                    arr[k] = temp[n-l];
                    n++;
                    count+=mid-m+1;
                }
            }
        }
        return count;
    }

2.求陣列中第k大的值  (leetcode 215)

方式1. 將陣列進行一次完全的排序,在從中找出第k大的值,排序需要的時間複雜度為O(nlogn),也是總的時間複雜度。

方式2.利用快排的思想,可以降低時間複雜度為O(n).