快速排序你真的會了嗎?

来源:https://www.cnblogs.com/bianchengzhuji/archive/2019/02/22/10420290.html
-Advertisement-
Play Games

快速排序可能沒你想的那麼簡單!如何選擇基準?如何快速分區?如何減少數據交換次數?如何使用非遞歸方法? ...


原文地址:快速排序優化詳解

正如它的名字所體現,快速排序是在實踐中最快的已知排序演算法,平均運行時間為O(NlogN),最壞的運行時間為O(N^2)。演算法的基本思想很簡單,然而想要寫出一個高效的快速排序演算法並不是那麼簡單。基準的選擇,元素的分割等都至關重要,如果你不清楚如何優化快速排序演算法,本文你不該錯過。

演算法思想

快速排序利用了分治的策略。而分治的基本基本思想是:將原問題劃分為若幹與原問題類似子問題,解決這些子問題,將子問題的解組成原問題的解。

那麼如何利用分治的思想對數據進行排序呢?假如有一個元素集合A:

  • 選擇A中的任意一個元素pivot,該元素作為基準
  • 將小於基準的元素移到左邊,大於基準的元素移到右邊(分區操作)
  • A被pivot分為兩部分,繼續對剩下的兩部分做同樣的處理
  • 直到所有子集元素不再需要進行上述步驟

可以看到演算法思想比較簡單,然而上述步驟實際又該如何處理呢?

如何選擇基準

實際上無論怎麼選擇基準,都不會影響排序結果,但是不同的選擇卻可能影響整體排序時間,因為基準選擇不同,會導致分割的兩個集合大小不同,如果分割之後,兩個集合大小是幾乎相等的,那麼我們整體分割的次數顯然也會減少,這樣整體耗費的時間也相應降低。我們來看一下有哪些可選擇策略。

選擇第一個或者最後一個

如果待排序數是隨機的,那麼選擇第一個或者最後一個作基準是沒有什麼問題的,這也是我們最常見到的選擇方案。但如果待排序數據已經排好序的,就會產生一個很糟糕的分割。幾乎所有的數據都被分割到一個集合中,而另一個集合沒有數據。這樣的情況下,時間花費了,卻沒有做太多實事。而它的時間複雜度就是最差的情況O(N^2)。因此這種策略是絕對不推薦的

隨機選擇

隨機選擇基準是一種比較安全的做法。因為它不會總是產生劣質的分割。
C語言實現參考:

ElementType randomPivot(ElementType A[],int start,int end)
{
    srand(time(NULL))
    int randIndex = rand()%(end -start)+start;
    return A[randIndex];
}

選擇三數中值

從前面的描述我們知道,如果能夠選擇到數據的中值,那是最好的,因為它能夠將集合近乎等分為二。但是很多時候很難算出中值,並且會耗費計算時間。因此我們隨機選取三個元素,並用它們的中值作為整個數據中值的估計值。在這裡,我們選擇最左端,最右端和中間位置的三個元素的中值作為基準。

假如有以下數組:

1 9 10 3 8 7 6 2 4

左端元素為1,位置為0,右端元素為4,位置為8,則中間位置為[0+8]/2=4,中間元素為8。那麼三數中值就為4(1,4,8的中值)。

如何將元素移動到基準兩側

選好基準之後,如何將元素移動到基準兩側呢?通常的做法如下:

  • 將基準元素與最後的元素交換,使得基準元素不在被分割的數據範圍
  • i和j分別從第一個元素和倒數第二個元素開始。i在j的左邊時,將i右移,直到發現大於等於基準的元素,然後將j左移,直到發現小於等於基準的元素。i和j停止時,元素互換。這樣就把大於等於基準的移到了右邊,小於等於基準的移到了左邊
  • 重覆上面的步驟,直到i和j交錯
  • 將基準元素與i所指向的元素交換,使得基準元素將整個元素集合分割為小於基準和大於基準的元素集合

在們採用三數中值得方法選擇基準的情況下,既然基準是中值,實際上只要保證左端,右端,中間值是從小到大即可。還是以前面提到的數組為例,我們找到三者後,對三者進行排序如下:
排序前

      
1 9 10 3 8 7 6 2 4

排序後

      
1 9 10 3 4 7 6 2 8

如果是這樣的情況,那麼實際上不需要把基準元素和最後一個元素交換,而只需要和倒數第二個元素交換即可,因為最後一個元素肯定大於基準,這樣可以減少交換次數

如果前面的描述還不清楚,我們看一看實際中一趟完整的流程是什麼樣的。

第一步,將左端,右端和中間值排序,中值作為基準:

      
1 9 10 3 4 7 6 2 8
        基準        

第二步,將中值與倒數第二個數交換位置:

     
             
1 9 10 3 2 7 6 4 8
              基準  

第三步,i向右移動,直到發現大於等於基準的元素9:

         
1 9 10 3 2 7 6 4 8
           
  i         j 基準  

第四步,j向左移動,直到發現小於等於基準的元素2:

         
1 9 10 3 2 7 6 4 8
           
  i     j     基準  

第五步,交換i和j:

         
1 2 10 3 9 7 6 4 8
           
  i j     基準  

第六步,重覆上述步驟,i右移,j左移:

         
1 2 10 3 9 7 6 4 8
           
    i j       基準  

第七步,交換i和j指向的值:

         
1 2 3 10 9 7 6 4 8
           
    i j       基準  

第八步,重覆上述步驟,i右移,j左移,此時i和j已經交錯:

         
1 2 3 10 9 7 6 4 8
           
    j i       基準  

第九步,i和j已經交錯,因此最後將基準元素與i所指元素交換:

         
1 2 3 4 9 7 6 10 8
               
      i          

到這一步的時候,我們發現i的左邊都是小於i指向的元素,而右邊都是大於i的元素。最後在對子集進行同樣的操作即可。

如何對子集進行排序

遞歸法

最常見的便是遞歸法了。遞歸的好處是代碼簡潔易懂,但是不可忽略的是,當遞歸嵌套過深時,它的效率問題以及棧溢出的風險可能會迫使你選擇非遞歸法。在前面對整個集合一分為二之後,對剩下的兩個集合遞歸調用,直到完成排序。簡單描述如下(非可運行代碼):

void Qsort(int A[],int left,int right)
{
    /*分區操作*/
    int i = partition(A,left,right);
    /*對子集遞歸調用*/
    Qsort(A,left,i-1);
    Qsort(A,i+1,right);
}

遞歸最需要註意的便是遞歸結束調用,否則會產生無限遞歸,從而發生棧溢出。

後面我們會看到,遞歸法的代碼非常簡潔。(相關閱讀《重新看遞歸》)

尾遞歸

在遞歸版本中,Qsort分別遞歸調用計算左右兩個子集合,而第二個遞歸其實並非必須,完全可以用迴圈來替代,以下代碼模擬實現了尾遞歸,(並非是真的尾遞歸):

void Qsort(ElementType A[],int left,int right)
{
        int i = 0;
    while(left < right)
    {
            /*分割操作*/
            i = partition(A,left,right);
            /*遞歸調用*/
            Qsort(A,left,i-1);
            /*右半部分通過迴圈實現*/
            left = i + 1;
    }
}

非遞歸法

那麼有沒有方法可以不用遞歸呢?既然遞歸每次都進行壓棧操作,那麼我們能不能分區後僅僅將區間信息存儲到棧里,然後從棧中取出區間再繼續分區呢?顯然是可以的。實際上我們每次分區時,只需要知道區間即可,那麼將這些區間信息存儲起來,就可以不用遞歸了,按照分好的區間不斷分區即可。

例如對於前面提到的數組,首先對區間[0,8]進行分區操作,之後得到兩個新的分區,1,2,3和9,7,6,10,8,假設兩個區間仍然可以使用快速排序,那麼需要將區間[0,2]和[5,8]的其中一個壓棧,另一個繼續分區操作。

按照這種思路,代碼簡單描述如下(非可運行代碼):

void Qsort(A,left,right)
{    
    while(STACK_IS_NOT_EMPTY)
    {
        /*分區操作*/
        POP(lo,hi);
        int mid = partition(A,lo,hi);
        /*存儲新的邊界*/
        PUSH(lo,mid-1);
        PUSH(mid+1,hi);
    }
}

當然這裡面沒有體現分區終止條件。我們需要在數據量小於一定值的時候,就不再繼續進行分區操作了,而是選擇插入排序(為什麼?)。

那麼問題來了,如何選擇棧的大小呢?查看qsort.c的源碼發現,它選擇瞭如下的值:

#define STACK_SIZE (8* sizeof(unsigned long int));

為什麼會是這個值呢?設想一下,假設待排序數組長度使用unsigned long int來表示,並且假設每次都將集合分為二等分。那麼即便數組長度達到最大值,實際上最多只需要分割8 *(sizeof(unsigned long int))次,也就將它分割完了。然而由於以下幾個原因,需要存儲在棧中的區間信息很難超出棧空間,因為:

  • 數組長度不會接近unsigned long int,否則記憶體也撐不住了
  • 區間足夠小時,不採用快速排序
  • 每做一個分區,只會增加一個區間PUSH到棧中,增長速度慢

註意事項

至此,快速排序所有的主要步驟已經介紹完畢。但是有以下註意事項:

  • 有大量重覆元素時避免產生糟糕分區,因此在發現大於等於基準或者小於等於基準時,便停止掃描。
  • 通常會將基準一開始移動到最後位置或倒數第二個位置,避免基準在待分區區間。
  • 對於很小的數組(N<=20),插入排序要比快速排序更好。因為快速排序有遞歸開銷,並且插入排序是穩定排序。
  • 如果函數本身的局部變數很少,那麼遞歸帶來的開銷也就越小;如果遞歸發生棧溢出了,首先需要排除代碼設計問題。因此如果你設計的非遞歸版本效率低於遞歸版本,也不要驚訝。

註:假定在待排序的記錄序列中,存在多個具有相同的關鍵字的記錄,若經過排序,這些記錄的相對次序保持不變,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序後的序列中,r[i]仍在r[j]之前,則稱這種排序演算法是穩定的;否則稱為不穩定的。--來自百科

遞歸版代碼實現

C語言代碼實現如下:

#include <stdlib.h>
#include <stdio.h>
#include <time.h>
/*使用快速排序的區間大小臨界值,可根據實際情況選擇*/
#define MAX_THRESH 4
typedef int ElementType;
/*元素交換*/
void swap(ElementType *a,ElementType *b)
{
    ElementType temp = *a;
    *a = *b;
    *b = temp;
}
/*插入排序*/
void insertSort(ElementType A[],int N)
{
    /*優化後的插入排序*/
    int j = 0;
    int p = 0;
    int temp = 0;
    for(p = 1;p<N;p++)
    {
        temp = A[p];
        for(j = p;j>0 && A[j-1] > temp;j--)
        {
            A[j] = A[j-1];
        }
        A[j] = temp;
    }

}
/*三數中值法選擇基準*/
ElementType medianPivot(ElementType A[],int left,int right)
{
    int center = (left + right)/2 ;
    /*對三數進行排序*/
    if(A[left] > A[center])
        swap(&A[left],&A[center]);
    if(A[left] > A[right])
        swap(&A[left],&A[right]);
    if(A[center] > A[right])
        swap(&A[center],&A[right]);

    /*交換中值和倒數第二個元素*/    
    swap(&A[center],&A[right-1]);
    return A[right-1];
}

/*分區操作*/
int partition(ElementType A[],int left,int right)
{

    int i = left;
    int j = right-1;
    /*獲取基準值*/
    ElementType pivot = medianPivot(A,left,right);
    for(;;)
    {
        /*i j分別向右和向左移動,為什麼不直接先i++?*/
        while(A[++i] < pivot)
        {}
        while(A[--j] > pivot)
        {}

        if(i < j)
        {
            swap(&A[i],&A[j]);
        }
        /*交錯時停止*/
        else
        {
            break;
        }

    }
    /*交換基準元素和i指向的元素*/
    swap(&A[i],&A[right-1]);
    return i;

}


void Qsort(ElementType A[],int left,int right)
{
    int i = 0;
    register ElementType *arr = A;
    if(right-left >= MAX_THRESH)
    {
        /*分割操作*/
        i = partition(arr,left,right);
        /*遞歸*/
        Qsort(arr,left,i-1);
        Qsort(arr,i+1,right);
    }
    else
    {
        /*數據量較小時,使用插入排序*/
        insertSort(arr+left,right-left+1);
    }
}
/*列印數組內容*/
void printArray(ElementType A[],int n)
{
    if(n > 100)
    {
        printf("too much,will not print\n");
        return;
    }
    int i = 0;
    while(i < n)
    {
        printf("%d ",A[i]);
        i++;
    }
    printf("\n");
}
int main(int argc,char *argv[])
{
    if(argc < 2)
    {
        printf("usage:qsort num\n");
        return -1;
    }
    int len = atoi(argv[1]);
    printf("sort for %d numbers\n",len);
    /*隨機產生輸入數量的數據*/
    int *A = malloc(sizeof(int)*len);
    int i = 0;
    srand(time(NULL));
    while(i < len)
    {
       A[i] = rand();
       i++;
    }
    printf("before sort:");
    printArray(A,len);
    /*對數據進行排序*/
    Qsort(A,0,len-1);
    printf("after  sort:");
    printArray(A,len);
    return 0;
}

尾遞歸版代碼實現

略。

非遞歸版代碼實現

非遞歸版與遞歸版大部分代碼相同,Qsort函數有所不同,並且增加棧相關內容定義:

/*存儲區間信息*/
typedef struct stack_node_t
{
    int lo;
    int hi;
}struct_node;
/*最大棧長度*/
#define STACK_SIZE 8 * sizeof(unsigned int)

/*入棧,出棧*/
#define STACK_PUSH( low, hig )    ( (top->lo = low), (top->hi = hig), top++)
#define STACK_POP( low, hig )    (top--, (low = top->lo), (hig = top->hi) )

/*快速排序*/
void Qsort( ElementType A[], int left, int right )
{
    if(NULL == A)
        return;
    /*使用寄存器指針*/
    register ElementType *arr = A;
    if ( right - left >= MAX_THRESH )
    {
        struct_node    stack[STACK_SIZE]   = { { 0 } };
        register struct_node    *top            = stack;

        /*最大區間壓棧*/
        int lo = left;
        int hi = right;
        STACK_PUSH( 0, 0);
        int mid = 0;
        while ( stack < top )
        {
            /*出棧,取出一個區間進行分區操作*/

            mid = partition( arr, lo, hi );

            /*分情況處理,左邊小於閾值*/
            if ( (mid - 1 - lo) <= MAX_THRESH)
            {
                /* 左右兩個數據段的元素都小於閾值,取出棧中數據段進行劃分*/
                if ( (hi - (mid+1)) <= MAX_THRESH)
                    /* 都小於閾值,從棧中取出數據段 */
                    STACK_POP (lo, hi);
                else
                    /* 只有右邊大於閾值,右邊繼續分區*/
                    lo =  mid -1 ;
            }
            /*右邊小於閾值,繼續計算左邊*/
            else if ((hi - (mid+1)) <= MAX_THRESH)
                hi = mid - 1;
            /*左右兩邊都大於閾值,且左邊大於右邊,左邊入棧,右邊繼續分區*/
            else if ((mid -1 - lo) > (hi - (mid + 1)))
            {
                STACK_PUSH (lo, mid - 1);
                lo = mid + 1;
            }
            /*左右兩邊都大於閾值,且右邊大於左邊,右邊入棧,左邊繼續分區*/
            else
            {
                STACK_PUSH (mid + 1, hi);
                hi = mid  -1;
            }
        }

    }

    /*最後再使用插入排序,對於接近有序狀態的數據,插入排序速度很快*/
    insertSort(arr,right-left+1);

}

運行結果

我們隨機產生1億個整數,並對其進行排序:

$ gcc -o qsort qsort.c
$ time ./qsort 100000000

遞歸版運行結果:

sort for 100000000 numbers
before sort:too much,will not print
after  sort:too much,will not print

real    0m16.753s
user    0m16.623s
sys    0m0.132s

非遞歸版結果:

sort for 100000000 numbers
before sort:too much,will not print
after  sort:too much,will not print

real    0m16.556s
user    0m16.421s
sys    0m0.137s

可以看到,實際上兩種方法的效率差距並不是很大。至於原因,前面我們已經說過了。

總結

本文所寫的示例實現與glibc的實現相比,還有很多可優化的地方,例如,本文實現僅對int類型實現了排序或交換值,如果待排序內容是其他類型,就顯得力不從心,讀者可參考《高級指針話題函數指針》思考如何實現對任意數據類型進行排序,。但快速排序的優化主要從以下幾個方面考慮:

  • 優化基準選擇
  • 優化小數組排序效率
  • 優化交換次數
  • 優化遞歸
  • 優化最差情況,避免糟糕分區
  • 元素聚合

有興趣地也可以進一步閱讀qsort源碼,進一步瞭解其中喪心病狂的優化。

思考

  • 為什麼要在遇到相同元素時就進行掃描?
  • 插入排序最好的情況時間複雜度是多少,在什麼情況下出現?
  • 文中實現的代碼還有哪些可以優化的地方?

練習

  • 採用第一種基準選擇策略實現快速排序,並測試對有序數組的排序性能
  • 實現通用快速排序演算法,參考《C語言函數指針

參考

  • 《數據結構與演算法分析》
  • 《演算法導論》
  • glibc qsort.c源碼

微信公眾號【編程珠璣】:專註但不限於分享電腦編程基礎,Linux,C語言,C++,演算法,資料庫等編程相關[原創]技術文章,號內包含大量經典電子書和視頻學習資源。歡迎一起交流學習,一起修煉電腦“內功”,知其然,更知其所以然。


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 第一次接觸大發的時候,我還只有二十歲。當時跟同村的幾個人在工地上,晚上無聊的時候就會聚在一起打牌,有一天晚上正好多了我一個人插不上手。我自己又無聊,就問他們有沒有別的好玩的,他們就跟我說起了北京賽車。說起來,玩堵這種事真的是不需要天賦,也不需要有人教的,比如我就是無師自通,跟我說了一下規則我玩了一會 ...
  • [toc] ECB模式介紹 電碼本模式(Electronic Codebook Book (ECB) 這種模式是將整個明文分成若幹段相同的小段,然後對每一小段進行加密。 pkcs5padding和pkcs7padding的區別 pkcs5padding和pkcs7padding都是用來填充數據的一種 ...
  • leetcode初級演算法 問題描述 給定一個整數數組,判斷是否存在重覆元素。 如果任何值在數組中出現至少兩次,函數返回 true。如果數組中每個元素都不相同,則返回 false。 該問題表述非常簡單 查看數組中是否有相同元素 解法一:(未通過-超出時間限制) 思路:利用list的內置函數count計 ...
  • Java程式的入口 main()方法的簽名為:public static void main(String[] args) {...} ,其中, ♦ public修飾符:Java類由JVM調用,為了讓JVM可以自由調用這個main()方法,所以使用public修飾符把這個方法暴露出來。 ♦ stat ...
  • https://www.luogu.org/blog/Nvwang/p1969-ji-mu-tai-sai ...
  • 作為介面的函數頭 C++函數可被其他函數激活或調用,函數頭描述了函數與調用它的函數之間的介面。 在C語言中,省略返回類型相當於說函數的類型為int,然而,C++逐步淘汰了這種用法 也可以使用下麵的變體: int main(void) 在括弧中使用關鍵字void明確地指出,函數不接受任何參數,在C++ ...
  • 原文同步發表至個人博客【夜月歸途】 原文鏈接:http://www.guitu18.com/se/java/2019-02-22/29.html 作者:夜月歸途 出處:http://www.guitu18.com/ 本博客中未標明轉載的文章歸作者夜月歸途和博客園所有。 歡迎轉載,但未經作者同意必須保 ...
  • 73、反向輸出一個鏈表。 74、列表排序及連接。 75、算一道簡單的題目。 76、編寫一個函數,當輸入n為偶數時,調用函數求1/2+1/4+...+1/n,當輸入n為奇數時,調用函數1/1+1/3+...+1/n。 77、迴圈輸出列表。 78、找到年齡最大的人並輸出。 參考資料: Python 10 ...
一周排行
    -Advertisement-
    Play Games
  • 前言 本文介紹一款使用 C# 與 WPF 開發的音頻播放器,其界面簡潔大方,操作體驗流暢。該播放器支持多種音頻格式(如 MP4、WMA、OGG、FLAC 等),並具備標記、實時歌詞顯示等功能。 另外,還支持換膚及多語言(中英文)切換。核心音頻處理採用 FFmpeg 組件,獲得了廣泛認可,目前 Git ...
  • OAuth2.0授權驗證-gitee授權碼模式 本文主要介紹如何筆者自己是如何使用gitee提供的OAuth2.0協議完成授權驗證並登錄到自己的系統,完整模式如圖 1、創建應用 打開gitee個人中心->第三方應用->創建應用 創建應用後在我的應用界面,查看已創建應用的Client ID和Clien ...
  • 解決了這個問題:《winForm下,fastReport.net 從.net framework 升級到.net5遇到的錯誤“Operation is not supported on this platform.”》 本文內容轉載自:https://www.fcnsoft.com/Home/Sho ...
  • 國內文章 WPF 從裸 Win 32 的 WM_Pointer 消息獲取觸摸點繪製筆跡 https://www.cnblogs.com/lindexi/p/18390983 本文將告訴大家如何在 WPF 裡面,接收裸 Win 32 的 WM_Pointer 消息,從消息裡面獲取觸摸點信息,使用觸摸點 ...
  • 前言 給大家推薦一個專為新零售快消行業打造了一套高效的進銷存管理系統。 系統不僅具備強大的庫存管理功能,還集成了高性能的輕量級 POS 解決方案,確保頁面載入速度極快,提供良好的用戶體驗。 項目介紹 Dorisoy.POS 是一款基於 .NET 7 和 Angular 4 開發的新零售快消進銷存管理 ...
  • ABP CLI常用的代碼分享 一、確保環境配置正確 安裝.NET CLI: ABP CLI是基於.NET Core或.NET 5/6/7等更高版本構建的,因此首先需要在你的開發環境中安裝.NET CLI。這可以通過訪問Microsoft官網下載並安裝相應版本的.NET SDK來實現。 安裝ABP ...
  • 問題 問題是這樣的:第三方的webapi,需要先調用登陸介面獲取Cookie,訪問其它介面時攜帶Cookie信息。 但使用HttpClient類調用登陸介面,返回的Headers中沒有找到Cookie信息。 分析 首先,使用Postman測試該登陸介面,正常返回Cookie信息,說明是HttpCli ...
  • 國內文章 關於.NET在中國為什麼工資低的分析 https://www.cnblogs.com/thinkingmore/p/18406244 .NET在中國開發者的薪資偏低,主要因市場需求、技術棧選擇和企業文化等因素所致。歷史上,.NET曾因微軟的閉源策略發展受限,儘管後來推出了跨平臺的.NET ...
  • 在WPF開發應用中,動畫不僅可以引起用戶的註意與興趣,而且還使軟體更加便於使用。前面幾篇文章講解了畫筆(Brush),形狀(Shape),幾何圖形(Geometry),變換(Transform)等相關內容,今天繼續講解動畫相關內容和知識點,僅供學習分享使用,如有不足之處,還請指正。 ...
  • 什麼是委托? 委托可以說是把一個方法代入另一個方法執行,相當於指向函數的指針;事件就相當於保存委托的數組; 1.實例化委托的方式: 方式1:通過new創建實例: public delegate void ShowDelegate(); 或者 public delegate string ShowDe ...