程序員需要會寫的幾種排序算法

我一直覺得寫代碼也可以寫出藝術,在不懂畫的人的眼里,《向日葵》不過是小孩子的涂鴉,在懂代碼的人眼里,那看似混亂的字符,確是邏輯藝術的完美體現。

排序算法基礎

排序算法,是一種能將一串數據按照特定的排序方式進行排列的一種算法,一個排序算法的好壞,主要從時間復雜度,空間復雜度,穩定性來衡量。

時間復雜度

時間復雜度是一個函數,它描述了該算法的運行時間,考察的是當輸入值大小趨近無窮時的情況。數學和計算機科學中使用這個大 O 符號用來標記不同”階“的無窮大。這里的無窮被認為是一個超越邊界而增加的概念,而不是一個數。

想了解時間復雜度,我想講講常見的 O(1),O(log n),O(n),O(n log n),O(n^2) ,計算時間復雜度的過程,常常需要分析一個算法運行過程中需要的基本操作,計量所有操作的數量。

O(1)常數時間

O(1)中的 1 并不是指時間為 1,也不是操作數量為 1,而是表示操作次數為一個常數,不因為輸入 n 的大小而改變,比如哈希表里存放 1000 個數據或者 10000 個數據,通過哈希碼查找數據時所需要的操作次數都是一樣的,而操作次數和時間是成線性關系的,所以時間復雜度為 O(1)的算法所消耗的時間為常數時間。

O(log n)對數時間

O(log n)中的 log n 是一種簡寫,loga n 稱作為以 a 為底 n 的對數,log n 省略掉了 a,所以 log n 可能是 log2 n,也可能是 log10 n。但不論對數的底是多少,O(log n)是對數時間算法的標準記法,對數時間是非常有效率的,例如有序數組中的二分查找,假設 1000 個數據查找需要 1 單位的時間, 1000,000 個數據查找則只需要 2 個單位的時間,數據量平方了但時間只不過是翻倍了。如果一個算法他實際的得操作數是 log2 n + 1000, 那它的時間復雜度依舊是 log n, 而不是 log n + 1000,時間復雜度可被稱為是漸近時間復雜度,在 n 極大的情況,1000 相對 與 log2 n 是極小的,所以 log2 n + 1000 與 log2 n 漸進等價。

O(n)線性時間

如果一個算法的時間復雜度為 O(n),則稱這個算法具有線性時間,或 O(n) 時間。這意味著對于足夠大的輸入,運行時間增加的大小與輸入成線性關系。例如,一個計算列表所有元素的和的程序,需要的時間與列表的長度成正比。遍歷無序數組尋最大數,所需要的時間也與列表的長度成正比。

O(n log n)線性對數時間

排序算法中的快速排序的時間復雜度即 O(n log n),它通過遞歸 log2n 次,每次遍歷所有元素,所以總的時間復雜度則為二者之積, 復雜度既 O(n log n)。

O(n^2)二次時間

冒泡排序的時間復雜度既為 O(n^2),它通過平均時間復雜度為 O(n)的算法找到數組中最小的數放置在爭取的位置,而它需要尋找 n 次,不難理解它的時間復雜度為 O(n^2)。時間復雜度為 O(n^2)的算法在處理大數據時,是非常耗時的算法,例如處理 1000 個數據的時間為 1 個單位的時間,那么 1000,000 數據的處理時間既大約 1000,000 個單位的時間。

時間復雜度又有最優時間復雜度,最差時間復雜度,平均時間復雜度。部分算法在對不同的數據進行操作的時候,會有不同的時間消耗,如快速排序,最好的情況是 O(n log n),最差的情況是 O(n^2),而平均復雜度就是所有情況的平均值,例如快速排序計算平均復雜度的公式為

![Uploading image-2_542306.png . . .]


image-1.png

最后的結果就是 1.39n * log2 n,與 n * log2 n 漸進等價,是的,1.3 倍在無窮大級別都不算什么,只要不和無窮大的 n 相關的乘數都可以通過漸進等價省略掉。

空間復雜度

和時間復雜度一樣,有 O(1),O(log n),O(n),O(n log n),O(n^2),等等,但談論算法的空間復雜度,往往講它的額外空間復雜度,例如冒泡排序算法只需要額外的常數空間,放置交換兩個相鄰數時產生的中間變量,及循環時候用來記錄循環次數的變量。所以冒泡排序的額外空間復雜度為 O(1)。如果算法所需的額外空間為 O(n),則操作數據的數目和所需的空間成線性關系。

穩定性

當相等的元素是無法分辨的,比如像是整數,穩定性并不是一個問題。然而,假設以下的數對將要以他們的第一個數字來排序。

(4, 1)  (3, 1)  (3, 7) (5, 6)

在這個狀況下,有可能產生兩種不同的結果,一個是讓相等鍵值的紀錄維持相對的次序,而另外一個則沒有:

(3, 1)  (3, 7)  (4, 1)  (5, 6)  (維持次序)
(3, 7)  (3, 1)  (4, 1)  (5, 6)  (次序被改變)

不穩定排序算法可能會在相等的鍵值中改變紀錄的相對次序,這導致我們無法準確預料排序結果(除非你把數據在你的大腦里用該算法跑一遍),但是穩定排序算法從來不會如此。例如冒泡排序即穩定的存在,相等不交換則不打亂原有順序。而快速排序有時候則是不穩定的。(不穩定原因會在講快速排序時說明。)

常見排序算法

冒泡排序

冒泡排序是一種非常簡單的排序算法,4,5 行代碼就能實現,過程分為 4 個步驟:

  • 比較相鄰的元素。如果第一個比第二個大,就交換他們兩個。
  • 對每一對相鄰元素作同樣的工作,從開始第一對到結尾的最后一對。這步做完后,最后的元素會是最大的數。
  • 針對所有的元素重復以上的步驟,除了最后一個。
  • 持續每次對越來越少的元素重復上面的步驟,直到沒有任何一對數字需要比較。

這個算法的名字由來是因為越大的元素,會經由交換慢慢的“浮”到數列的尾端。冒泡排序對 n 個項目需要 O(n^2) 的比較次數,且是在原地排序,所以額外空間復雜度為 O(1) 。盡管這個算法是最容易了解和實現的排序算法之一,但它相當于其它數列排序來說是很沒有效率的排序,如果元素不多,對性能也沒有太大要求,倒是可以快速寫出冒泡排序來使用。博客中出現的代碼都由 C++ 編寫。

void bubbleSort(int array[], int length) {
    int i, j;
    for (i = 0; i < length - 1 ;i++)
        for (j = 0; j < length - 1 - i; j++)
            if (array[j] > array[j + 1])
                swap(array[j], array[j+1]);
}

插入排序

插入排序簡單直觀,通過構建有序序列,對于未排序的元素,在已排序序列中從后向前掃描,找到相應位置并插入。時間復雜度為 O(n^2) ,原地排序,額外空間復雜度為 O(1)。

過程分為 6 個步驟:

  • 從第一個元素開始,該元素可以認為已經被排序
  • 取出下一個元素,在已經排序的元素序列中從后向前掃描
  • 如果該元素(已排序)大于新元素,將該元素移到下一位置
  • 重復步驟3,直到找到已排序的元素小于或者等于新元素的位置
  • 將新元素插入到該位置后
  • 重復步驟2~5
void insertSort(int array[], int length) {
    int i, j;
    int temporary;
    //從第二個元素開始,將元素插入到已排好序的元素里。
    for (i = 1; i < length; i++) {
        //需要插入的新元素
        temporary = array[i];
        //從已排序的元素序列中從后向前掃描,找到已排序的元素小于或者等于新元素的位置,將新元素
        //插入到該位置后
        for (j = i - 1; j >= 0 && array[j] > temporary; j--)
            array[j+1] = array[j];
        array[j+1] = temporary;
    }
}

選擇排序

選擇排序也是非常簡單的排序算法,選擇最小先排序,首先在未排序序列中找到最小元素,存放到排序序列的起始位置,然后,再從剩余未排序元素中繼續尋找最小元素,然后放到已排序序列的末尾。以此類推,直到所有元素均排序完畢。時間復雜度為 O(n^2),額外空間復雜度為 O(1)。

過程分為 5 個步驟:

  • 從第一個元素開始,聲明一個變量儲存最小元素的位置,初始為第一個元素的位置。
  • 取出下一個元素,與當前最小元素進行比較。如果元素比當前最小元素小,則變量儲存這個元素的位置。
  • 重復步驟 2,直到沒有下一個元素,變量里儲存的既最小元素的位置。
  • 將最小元素放在排序序列的起始位置。
  • 重復 1~3,從剩余未排序元素中繼續尋找最小元素,然后放到已排序序列的末尾。
//選擇排序  平均時間復雜度O(n^2) 額外空間復雜度O(1)
void selectionSort(int array[], int length) {
    int i, j, min;
    for (i = 0; i < length; i++) {
        //找到最小元素存放到起始位置。
        min = i;
        for (j = i + 1; j < length; j++)
            if (array[j] < array[min])
                min = j;
        swap(array[i], array[min]);
    }
}

快速排序

快速排序從名字上來說并不能直觀的記憶它的實現思路,但它和它的名字一樣,很快速,快速排序是一個非常不錯的排序算法,時間復雜度 O(n log n),且通常明顯比其他 Ο(n log n) 算法更快,這是最應該記憶,并能熟練寫出的排序算法。

步驟為:

  • 從數列中挑出一個元素,稱為"基準",
  • 重新排序數列,所有元素比基準值小的擺放在基準前面,所有元素比基準值大的擺在基準的后面(相同的數可以到任一邊)。在這個分區結束之后,該基準就處于數列的中間位置。這個稱為分區操作。遞歸地把小于基準值元素的子數列和大于基準值元素的子數列排序。

為了減少數組中不必要的移動,挑最后一個元素為基準,在剩下的元素的左右兩端開始尋找,左邊找到比它大的,右邊找到比它小的,交換這個數的位置,繼續尋找,只需要很少的交換步驟,即可將比基準大的和比基準小的數分開,最后左右兩端匯集在一起,匯集在一起有兩種情況。

  • 第一種,左端匯集到右端身上,說明匯集之前左端的值比基準小,所以它需要向右移動去尋找,如果右端的值已經交換過了,則右端比基準大,左右兩端已匯集,所以只要交換左端和基準的值就可以了。如果右端的值還沒交換過,則與基準值進行比較,大于的話交換左端和基準的值,小于的話,則說明左邊的值都比基準值小,去掉基準值,剩下的數繼續快排。
  • 第二種,右端匯集到左端身上,說明左端找到了比基準大的值,而匯集之前右端的值也比基準大,所以也只要交換左端和基準的值就可以了。

邏輯看起來很復雜,只是對遞歸到最深的地方對各種情況做處理。

void quickSortRecursive(int array[], int start, int end) {
    if (start >= end)
        return;
    //從數列中挑出一個元素,稱為"基準"。
    int mid = array[end];
    int left = start;
    int right = end - 1;
    while (left < right) {
        //從左開始找,找到大于等于 mid 的數停止。
        while (array[left] < mid && left < right) left++;
        //從右開始找,找到小于 mid 的數停止。
        while (array[right] >= mid && right > left) right--;
        //交換left和right位置的數
        swap(array[left], array[right]);
    }
    //使 left 位置數小于它左邊的數,大于它右邊的數。
    if (array[left] >= array[end])
        swap(array[left], array[end]);
    else
        left++;
    //遞歸地把小于基準值元素的子數列和大于基準值元素的子數列排序
    quickSortRecursive(array, start, left - 1);
    quickSortRecursive(array, left + 1, end);
}

為什么說快速排序有時候是不穩定的呢,如上面代碼所寫,相等的都按比基準小做處理,因為基準在最右端,所以順序不會變,這是穩定的,但有時候快速排序為了防止某些極端情況,(比如本身就是順序排序,這個時候時間復雜度就是 O(n^2)),往往挑選中間的數移至末尾作為基準,這個時候就會打亂與基準相等數的順序,就是不穩定的。(所以這些排序算法重要的是思路,代碼是可以根據情況進行改變的)

遞歸的時候由于函數調用是有時間和空間的消耗的,所以快速排序的空間復雜度并不是 O(1),因為最差情況,遞歸調用 n 次,所以最差空間復雜度為 O(n),最好情況,遞歸調用 log n 次,所以最優空間復雜度為 O(log n),因為額外空間復雜度一般看最差情況,因為時間可以平均,但空間一定得滿足,所以它的額外空間復雜度為 O(n)。

堆排序

堆排序比其它排序更難理解一點,但堆排序很有意思,它需要利用堆這種數據結構,堆是一個近似完全二叉樹的結構,并同時滿足堆積的性質:即子結點的鍵值或索引總是小于(或者大于)它的父節點。小于則是最小堆,根結點為堆的最小值,大于則是最大堆,根節點為堆得最大值。而堆排序則利用最大堆的性質,一個一個找出最大數的值。堆可以通過數組來實現。下圖是一個一維數組,第一個元素是根節點,每一個父節點都有兩個子節點,可以從圖中得出這樣的規律,

  • 父節點 i 的左子節點在位置 (2 * i + 1);
  • 父節點 i 的右子節點在位置 (2 * i + 2);
  • 子節點 i 的父節點在位置 floor((i - 1) / 2);
image-3.png

floor 函數的作用是向下取整,所以左子節點右子節點都能通過這個公式找到正確的父節點。

先上代碼。

//堆排序  平均時間復雜度O(n log n) 額外空間復雜度O(1)
void maxHeap(int array[], int start, int end) {
    int dad = start;
    int son = dad * 2 + 1;
    while (son < end) {
        //比較兩個子節點的大小。
        if (son + 1 < end && array[son] < array[son + 1])
            son++;
        //如果父節點大于子節點,直接返回。
        if (array[dad] > array[son])
            return;
        //如果父節點小于子節點,交換父子節點,因為子節點變了,所以子節點可能比孫節點小,需繼續
        //比較。
        swap(array[dad], array[son]);
        dad = son;
        son = dad * 2 + 1;
    }
}

void heapSort(int array[], int length) {
    int i;
    //i從最后一個父節點開始調整
    for (i = length / 2 - 1; i >= 0; i--) {
        //形成最大堆,第一個元素為最大數。
        maxHeap(array, i, length);
    }
    //將第一個元素放置到最后,再將前面的元素重新調整,得到最大堆,將此時最大的數放置到倒數第二
    //位置,如此反復。
    for (int i = length - 1; i > 0; i--) {
        swap(array[0], array[i]);
        maxHeap(array, 0, i);
    }
}

maxHeap 函數是用來使以此父節點作為根節點的堆為最大堆,先比較兩個子節點的大小,找到最大的子節點,再與根做比較,如果根大則已經是最大堆,如果根小,則交換子節點和根節點的數據,此時子節點還得保證以它為根節點的堆為最大堆,所以還需要與孫節點進行比較。函數結束既調整完畢。

heapSort 函數里先從最后一個父節點開始調整,調整完的數與有序數列前一位交換,形成新的有序數列,此時再對剩下來的數進行堆調整,因為兩個子節點已經是最大堆了,所以這個時候是直接以第一個元素為根調整,只需要操作 log2 n 次,所以排好一個數據的平均時間漸進等價于 log2 n,所以堆排序的時間復雜度為 O(n log n)。堆排序是原地排序,所以額外空間復雜度為 O(1)。堆排序和快速排序一樣,是一個不穩定的排序,因為在根的位置左子樹和右子樹的數據,你并不知道哪個元素在原數組處于前面的位置。

總結

我最喜歡堆排序,它最差的時間復雜度也是 O(n log n),而快速排序雖然比它更快點,但最差的時間復雜度為 O(n^2),且堆排序的空間復雜度只有 O(1)。還有很多很有意思的排序方法,我稍微了解了一下思路,并未都寫一遍。建議不管是哪個方向的程序員,都將這些常見的排序算法寫寫,體驗一下編程之美。

生活不應該只有 API 的調用,還應該有邏輯與優雅。

PS:算法雖然很有意思,但也一定要剎住,別一不小心被勾搭的轉方向了。- -!

最后留個項目鏈接:JMSort,這是我看完堆排序之后得到的靈感嘗試寫的排序算法,大概思路就是兩兩比較之后每四個進行一次比較,最后將得到的最大的數放置在數組末尾,剩下的繼續比較。因為上次比較的數據是可以復用的,所以應該效率也不低,不過暫時只寫了個沒復用版本(因為復用版本被我寫亂了),時間復雜度 O(n^2),實際運行效率就比冒泡快一點 TAT,等著我以后來優化,目標優化到 O(n log n) 的時間復雜度。

下圖是1W條隨機數據所需的排序時間。

排序方法 時間(微秒)
冒泡排序 316526
快速排序 1345
插入排序 74718
選擇排序 127416
堆排序 2076
JM排序 205141

參考資料

維基百科-排序算法

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,316評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,481評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,241評論 0 374
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,939評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,697評論 6 409
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,182評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,247評論 3 441
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,406評論 0 288
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,933評論 1 334
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,772評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,973評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,516評論 5 359
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,209評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,638評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,866評論 1 285
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,644評論 3 391
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,953評論 2 373

推薦閱讀更多精彩內容

  • 該篇文章主要介紹了算法基礎以及幾種常見的排序算法:選擇排序、插入排序、冒泡排序、快速排序、堆排序。 一、算法基礎 ...
    ZhengYaWei閱讀 1,263評論 0 12
  • 概述:排序有內部排序和外部排序,內部排序是數據記錄在內存中進行排序,而外部排序是因排序的數據很大,一次不能容納全部...
    每天刷兩次牙閱讀 3,738評論 0 15
  • 概述 排序有內部排序和外部排序,內部排序是數據記錄在內存中進行排序,而外部排序是因排序的數據很大,一次不能容納全部...
    蟻前閱讀 5,209評論 0 52
  • 不安的夜晚,我睡不著,因為抄襲,因為光陰,因為爭論,因為很多。睡吧,我累了。
    停停走走_8de3閱讀 175評論 0 0
  • 尊敬的老師,親愛的同學們,大家好! 我是報關與國際貨運專業的馮謙,今天我就要以“讀書”這一話題進行演講。 一說到閱...
    1f434727e2f7閱讀 250評論 0 5