2018年第七週-基礎的排序方法(三種基礎排序演算法簡介)

黃小數發表於2019-05-14

新年伊始,永珍更新

這周是狗年春節,祝大家狗年旺旺旺。

第六章

基礎的排序方法

為了我們的第一次在排序演算法領域的征途,我們需學習一些基礎的方法,這些基礎方法適合一些小檔案,或者適合一些特殊結構的檔案。以下列出一些原因為什麼我們需要詳細地學習這些簡單的排序演算法。第一,它們提供環境,能夠讓我們理解一些排序演算法的術語和基本原理,從而達到讓我們擁有一個充足的背景知識去學習更復雜的演算法。第二,在很多排序應用裡,這些簡單的方法實際上比那些強大通用的方法更加高效。第三,這些簡單方法讓那些強大通用的方法變得更好,也還可以給那些複雜方法提高效率。
這一章的目的不僅僅是為了介紹基礎的排序演算法,而且還開發一個排序框架可以讓我們在後續的章節使用。我們會看看適合應用排序演算法的不同情況,而且使用不同的輸入檔案,和尋找其他方式來對比排序方法並學習它們的屬性。

遊戲規則

在考慮具體的演算法之前,我們先討論通用術語和排序演算法的一些假設,這會對我們有幫助。我們假設排序檔案的方法裡,都是含有鍵。這些概念都是現代程式設計環境中很自然的抽象。鍵(items),是項(items)的一部分,被用於控制排序。排序方法的目的時重新整理這些項,讓它們的鍵根據一些定義好的排序規則來排序(通常是數字從大到小或字母順序)。在不同的應用中,鍵和項的特性有很大的不同,但將鍵和與之關聯的資訊放入排序中是排序問題的特徵。
如果要被排序的檔案能都在記憶體裡,這種排序方法被稱為內部排序(internal)。而如果檔案來自硬碟等外部設施,稱為外部排序(external sorting)。這兩者主要的區別是內部排序可以容易地訪問任何項,而外部排序必須順序的訪問(access items sequentially)或者至少是一個大塊(in large blocks)。我們會在第11章看一些外部排序,但我們更多考慮的演算法是內部排序。
我們會考慮陣列和鏈式列表。陣列排序問題和鏈式列表排序問題都很有趣:在開發我們的演算法的時候,我們會遇到一些基礎任務非常適合序列配置,而一些基礎任務適合鏈式列表的配置。一些經典方法因足夠抽象,所以可以被實現在陣列或鏈式列表。而其他同樣帶限制式訪問的資料型別,一樣有趣(Other types of access restrictions are also sometimes of interest)。
一開始,我們將專注於陣列排序。程式6.1 展示許多我們實現的風格。一個驅動程式的構成包括:通過讀取標準輸入或生成隨機的整型來填充陣列,然後呼叫一個排序函式將這些整型進行排序,然後輸出排序後的結果。

/**
程式6.1
這個程式展示約定,用於實現基礎陣列排序。main方法是一個驅動,用於初始化整型(隨機生成或來自標準輸入)。,然後呼叫一個排序函式將這些整型進行排序,然後輸出排序後的結果。   
這個程式的排序函式是插入排序的一個版本(具體可看章節8.3),定義了要被排序的東西所對應的資料型別為項(Item),和為了項定義了些操作:操作less(比較兩個key),操作exch(交換兩個項),和操作compexch(比較兩個項並如果後一項大於前一項時交換) 。我們在程式碼裡通過typeof和簡單巨集實現Item是整型。其他資料型別是章節8.7主題,但這些並不影響排序。
**/
#include <stdio.h>
#include <stdlib.h>

//考慮移植性的問題,才把型別都定義為Item,這就可以隨時改為浮點float都可以
typedef int Item;

#define key(A) (A)
//使用巨集的一個好處,不用考慮引數的型別
#define less(A, B) (key(A) < key(B))
#define exch(A, B)  
    {               
    Item t = A; 
    A = B;      
    B = t;      
    }
#define compexch(A, B) 
    if(less(B, A))     
    exch(A, B)
void sort(Item a[], int l, int r)
{
    int i, j;
    for(i = l + 1; i <= r; i++) {
    for(j = i; j > l; j--) {
        compexch(a[j - 1], a[j]);
    }
    }
}

main(int argc, char* argv[])
{
    int i, N = atoi(argv[1]), sw = atoi(argv[2]),j=0;
    int* a = malloc(N * sizeof(int));
    printf("%d items
",N);
    if(sw)
    for(i = 0; i < N; i++) {
        a[i] = 1000 * (1.0 * rand() / RAND_MAX);
        printf("%d item is %d
",i,a[i]);
    }
    else
/** 不知道是不是我的知識不夠,書本上的例子這段程式碼執行不起來
    while(scanf("%d", &a[N]) == 1)
        N++; 
         */
    while(j<N){
        scanf("%d",&a[j++]);
    }
    //2.呼叫插入排序的函式對這些整型進行排序
    sort(a, 0, N - 1);
    //3.輸出排序後的結果
    for(i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("
");
}

我們從第3章和第4章可以知道,有很多機制可以讓我們使得這些排序實現也能適用於其他資料型別。我們會在章節6.7進行討論。程式6.1使用了像我們章節4.1討論那樣的簡單的行內資料,排序僅僅通過傳入的引數和一些簡單的資料操作。通常,這些方法允許我們使用同樣的程式碼去排序其他資料型別。舉個例子,如果將程式6.1程式碼中的生成資料、儲存資料、和輸出隨機keys中的整型替換為浮點,僅僅做出的改變就是將main方法外面的typedef的int改為float即可。為了提供如此靈活性,我們保留項中的資料型別,以不指定的資料型別的項進行排序。暫時,我們可以認為項就是int或float;在章節6.7中,我們考慮詳細的資料型別的實現,允許我們使用我們的 排序實現 來排序任意項,包括浮點,數值,字串,和其他型別的keys,我們可以用第3章和第4章說的來實現。
通常,我們最初的感興趣的效能引數是我們演算法的執行耗時。選擇排序、插入排序、和氣泡排序會在章節6.2至6.4討論,章節6.5討論它們的時間複雜度,都是N^2。在第7章我們討論的更高階的方法,通過IO排序N項只需NlogN,但這個方法不是一直都是很好的方法,需考慮較小的N值和特定的情況。在章節6.6,我們看一下更高階的方法(希爾排序shellsort),時間複雜度為N3/2 甚至更少。在章節6.10,我們看到一種特定的方法(索引鍵排序key-indexed排序),時間複雜度僅僅是N
額外記憶體的使用量也是排序演算法中我們該考慮的第二重要的因素。基本上,這些方法可以分為三類:一些是不需要額外記憶體,除了也許用了一些小棧(a small stack)或表(table); 一些鏈式列表或通過指標陣列座標關聯的資料,僅僅需要額外的記憶體給N個指標或陣列座標;一些需要足夠的額外記憶體來儲存需要排序的陣列的副本。
我們經常使用排序方法來排序多個鍵的項——我們可能甚至排序一個集合的項通過多次訪問不同keys。在這種情況,對於我們很重要的是,需意識到我們用的排序方法是否有一下屬性:

定義6.1 一個排序方法是穩定的,則表明如果存在多個keys的情況下,保留了之前的順序。 (我感覺就是通過項關聯多個key)

6.2 選擇排序(Selection Sort)

一個最簡單排序演算法如下。
第一,從陣列中找到最小的值,與第一個位置的元素交換。
然後,從陣列中找到第二小的值,與第二個位置的元素交換。
然後一直持續,直到整個陣列都排好序。
這個方法被稱為選擇排序,是因為它的工作原理是重複在剩餘的元素中選擇最小的元素。程式6.2是該演算法的實現。

//程式6.2
#include <stdio.h>
#include <stdlib.h>

//考慮移植性的問題,才把型別都定義為Item,這就可以隨時改為浮點float都可以
typedef int Item;

#define key(A) (A)
//使用巨集的一個好處,不用考慮引數的型別
#define less(A, B) (key(A) < key(B))
#define exch(A, B)  
    {               
    Item t = A; 
    A = B;      
    B = t;      
    }
#define compexch(A, B) 
    if(less(B, A))     
    exch(A, B)
 

void selection(Item a[],int l,int r){
    int i,j;
    for(i=l;i<r;i++){
        int min = i;
        for(j=i+1;j<=r;j++)
            if(less(a[j],a[min])) min=j;
        exch(a[i],a[min]);
    }
}

main(int argc, char* argv[])
{
    int i, N = atoi(argv[1]), sw = atoi(argv[2]),j=0;
    int* a = malloc(N * sizeof(int));
    printf("%d items
",N);
    if(sw)
    for(i = 0; i < N; i++) {
        a[i] = 1000 * (1.0 * rand() / RAND_MAX);
        printf("%d item is %d
",i,a[i]);
    }
    else
/** 不知道是不是我的知識不夠,書本上的例子這段程式碼執行不起來
    while(scanf("%d", &a[N]) == 1)
        N++; 
         */
    while(j<N){
        scanf("%d",&a[j++]);
    }
    //2.呼叫排序的函式對這些整型進行排序 
    printf("use selection sort method
");
    selection(a,0,N - 1);
    //3.輸出排序後的結果
    for(i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("
");
}

程式6.2 是一個實現選擇排序。內迴圈僅僅是比較當前元素與最小值。它非常簡單。在內迴圈之後,交換元素,將此元素放入它最終的位置。所以交換次數是N-1(最後元素是不用交換,在呼叫此方法時傳入的就是N-1),而外部迴圈的N-2次,因為第。執行耗時主要在逐個元素的比較上。在章節6.5,我們會展示選擇排序的時間複雜度為N2,然後使用更加準確地預測總的耗時,和把選擇排序與其他基本排序方法比較比較。 依次類推,共需要進行的比較次數是∑ =(n-1)+(n-2)+…+2+1=n(n-1)/2,即進行比較操作的時間複雜度為O(n2)。

A disadvantage of selection sort is that its running time depends only slightly on the amount of order already in the file.
此選擇排序演算法不好之處在於,它的執行時間依賴於陣列是否已經排好序。在一輪找到最小值的過程中並沒有給什麼資訊給下一輪去找新的最小值。例如,無聊是已經有順序的陣列,還是都全都相等的陣列,或隨機生成但已經排好序的陣列,它們的耗時都一樣。後面我們會看到,其他方法在這種排好序的情況下會更好。
儘管選擇排序簡單且暴力的方法,但它在一種很重要的應用中是好過那些複雜的方法:就是要排序的檔案是巨大的items和小的keys。在這種應用裡,比較成本(遍歷成本)比移動的資料成本少,沒有什麼演算法比排序演算法移動更少的資料。for such application, the cost of moving the data dominates the cost of making comparisons, and no algorithm can sort a file with substantially less data movement than selection sort
選擇排序在排序方法中的重要引數:
時間複雜度 N^2
記憶體使用量:不需要額外的記憶體
穩定性:穩定

Java實現版本:

package com.jc.algortithms.sortMethods;

import java.util.Random;


/**
 * 選擇排序
 * <p>
 * 1. 從陣列中找到最小的元素,與第一個位置的元素交換
 * <p>
 * 2. 然後從第二個位置開始找,找到最小的,與第二個位置的元素交換
 * <p>
 * 3. 然後一直持續,外圍的i只需遍歷到倒數第二個元素,內迴圈就要從i開始一直遍歷到最後一個元素
 * <p>
 * 時間複雜度為N^2
 */
public class SelectionSort {


    public static void sort(int a[]) {
        System.out.println("after selection sort");
        for (int i = 0; i < a.length - 1; i++) {
            int min = i;
            for (int j = i + 1; j < a.length; j++) {
                if (a[j] < a[min])
                    min = j;
            }
            Sort.exch(a, i, min);
        }

    }

    public static void main(String[] args) {
        Random random = new Random();
        int a[] = new int[10];

        System.out.println(a.length + " items");
        for (int i = 0; i < a.length; i++) {
            a[i] = random.nextInt(1000);
            System.out.println(i + " item is " + a[i]);
        }

        sort(a);

        for (int e : a) {
            System.out.printf("%5d", e);
        }

    }
}

6.3 插入排序 Insertion Sort

bridge hands 一手牌
人們常常使用該方法來給自己一手牌排序,每次只考慮一個元素,並將它插入進已經排好序的元素之間(要保持排好的順序)。在計算機實現這種方法,我們需要將大一點的元素都往右移一個位置空出位置來插入一個元素。前面程式6.1實現的就是這種方法,叫插入排序insertion sort。
就像選擇排序,在排序的時候,當前座標(索引、下標)的左邊都是已經排好序的元素,但跟選擇排序不同的是,插入排序的這些排好序的元素所在的位置並不是它們最終的位置,可能後續遇到更小的元素,還是需要騰出位置來讓給最小的元素。只有當座標(索引、下標)到達最右邊時,才是完整的排序。
程式6.1實現的插入排序,很直接而且也不高效。我們現在考慮三個途徑去優化,這也是我們實現的重複出現的主題:我們的目標是簡潔、明瞭、而且還高效的程式碼,但這些目標往往是衝突的,所以我們必須從中找到個平衡。首先我們先自然地實現,然後再尋找優化(變形)方式,然後再校驗每個優化(變形)方式。
第一,因為(當前座標的)左邊的子陣列都是有序的,所以我們可以一旦遇到要被插入的key大於遇到的key則可以停止執行compexch方法。所以在程式6.1,如果less(a[j-1],a[j])返回true,我們就可以中斷內部迴圈。這個修改將6.1改為了適應式排序,並且讓程式更快,快速因子是2,具體可看程式6.2。(This modification changes the implementation into an adaptive sort, and speeds up the program by about a factor of 2 for randomly ordered keys)
根據前面一段落描述優化方法,我們有兩種情況可以中斷內部迴圈——我們重寫程式碼,利用while迴圈來相應這個實際變化。一個更為微妙的改進是,我們不需要再判斷j<l:這是因為第一步已將最小的放在第一個位置。一個常用的選擇是保持a[1]到a[N]是有序的,和將哨兵鍵(sentinel key)在a[0]位置上,讓a[0]上的值時最小的。然後判斷是否遇到更小的key,這樣把內部迴圈變得更小而且更快。程式6.3是該改進的實現。

/**
 * 程式6.3
 * 此程式碼是程式6.1的改進,改進的第一點就將最小的元素放入陣列中的第一個位置,將此元素作為哨兵。  
 * 第二點,在內迴圈中, 只是簡單的賦值語句,而不是交換。  
 * 第三點,結束內迴圈的時候,才把元素插入進內迴圈後的位置。  
 * 每次迴圈i,它都是在排好序的且大於a[i]的a[l],...,a[i-1]裡移動一個位置,然後再將a[i]放入合適的位置,a[l]...a[i]的排序就完成
 */

#include <stdio.h>
#include <stdlib.h>

//考慮移植性的問題,才把型別都定義為Item,這就可以隨時改為浮點float都可以
typedef int Item;

#define key(A) (A)
//使用巨集的一個好處,不用考慮引數的型別
#define less(A, B) (key(A) < key(B))
#define exch(A, B)  
    {               
    Item t = A; 
    A = B;      
    B = t;      
    }
#define compexch(A, B) 
    if(less(B, A))     
    exch(A, B)
void insertionSort(Item a[], int l, int r)
{
    int i;
    for(i=l+1;i<=r;i++)
        compexch(a[l],a[i]);
    
    for(i = l + 2; i <= r; i++) {
        int j = i;
        Item v = a[i];
        while(less(v,a[j-1])){
            a[j] = a[j-1];
            j--;
        }
        a[j] = v;
    }
}

main(int argc, char* argv[])
{
    int i, N = atoi(argv[1]), sw = atoi(argv[2]),j=0;
    int* a = malloc(N * sizeof(int));
    printf("%d items
",N);
    if(sw)
    for(i = 0; i < N; i++) {
        a[i] = 1000 * (1.0 * rand() / RAND_MAX);
        printf("%d item is %d
",i,a[i]);
    }
    else
/** 不知道是不是我的知識不夠,書本上的例子這段程式碼執行不起來
    while(scanf("%d", &a[N]) == 1)
        N++; 
         */
    while(j<N){
        scanf("%d",&a[j++]);
    }
    //2.呼叫插入排序的函式對這些整型進行排序
    insertionSort(a, 0, N - 1);
    //3.輸出排序後的結果
    for(i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("
");
}

哨兵(sentinel)有時候不太好使用:可能很小的值不太好定義,或者可能程式裡沒有額外的位置去存放此額外的鍵(perhaps the calling routine has no room to include an extra key)。程式6.3展示了一個途徑來解決插入排序的兩個問題:做一次而且也是第一次遍歷陣列,將最小的值與第一個位置交換,然後再排序剩下的陣列,而第一項就是擔當哨兵的角色。我們通常應該避免程式碼中的哨兵,因為通過顯式的測試來理解程式碼通常更容易,但是我們應該注意到,在使程式更簡單和更高效的情況下,哨兵可能是有用的(we generally shall avoid sentinels in our code, because it is often easier to understand code with explicit tests, but we shall note situations where sentinels might be useful in making programs both simpler and more efficient.)。
第三個改進就是,我們考慮在內迴圈中移除掉無關的指令。我們考慮到連續交換同樣的元素是不高效的。例如我們有兩個及以上交換,就會有

t=a[j]; a[j]=a[j-1]; a[j-1]=t;

然後再就

t=a[j-1]; a[j-1]=a[j-2]; a[j-2]=t;

如此來推。在這兩次交換中,t其實是沒有變的,但我們卻花了時間去儲存(交換)它,然後再下次交換中讀取它。程式6.3移動了大量的元素往右移一個位置,而不是用交換,從而減少了浪費的時間。
同樣是插入排序,程式6.3比程式6.1更高效9(在章節6.5,我們將會看到這個改進是差不多兩倍的速度)。在這本書裡,我們對簡介高效的演算法和簡潔高效的演算法實現都很感興趣。在這個例子中,底層演算法都不太一樣——我們應該把程式6.1的排序函式稱為非適應式插入排序(nonadaptive insertion sort)會更合適一些。對演算法的屬性有一個很好的理解是開發一個可以在應用程式中有效使用的實現的最佳指南(a good understanding of the properties of an algorithm is the best guide to developing an implementation that can be used effectively in an application)。
與選擇排序不同,插入排序的時間取決於所輸入的值的排序情況。例如,一個檔案很大和keys都已經排序好(或者說接近於排好序),這時插入排序會很快,選擇排序會很慢。在章節6.5我們會更詳細地對比這兩種演算法。
插入排序在排序方法中的重要引數:
時間複雜度 N^2
記憶體使用量:不需要額外的記憶體
穩定性:穩定

6.4 氣泡排序(Bubble Sort)

氣泡排序可能是大家學的第一個排序,因為它夠簡單:不停的遍歷陣列,交換相鄰的元素(沒有排好序相鄰的元素),一直持續到陣列被排序好。氣泡排序的主要功能很容易實現,但實際是否比插入排序或選擇排序更容易,這就存在爭議了。氣泡排序比其他兩個方法(插入和選擇)更慢,但為了完整性,我們簡單地說一下氣泡排序。
我們總是從右到左的遍歷陣列。第一輪,我們不關什麼時候會遇到最小的元素,但我們總是把最小的元素與它左邊的元素交換,最終將最小的元素放入陣列的最左邊。在第二輪,第二小的元素會放到最終的位置,如此類推。因此,幾輪過後,氣泡排序的操作就像一種選擇排序,儘管氣泡排序做了很多操作才把每一個元素放入最終的位置。程式6.4是該演算法的實現。

/**
* 程式6.4
* 對於從l到r-1的每個i,內迴圈inner(j)將a[i],....,a[r]中最小的值放入a[i]中,持續地比較交換元素。
* 最小值從右往左一直在比較,就好像“冒泡”到最右邊。  
* 就像在選擇排序中,座標i從左到右的遍歷。在座標的左邊的元素都是在最終的位置
* /
#include <stdio.h>
#include <stdlib.h>

//考慮移植性的問題,才把型別都定義為Item,這就可以隨時改為浮點float都可以
typedef int Item;

#define key(A) (A)
//使用巨集的一個好處,不用考慮引數的型別
#define less(A, B) (key(A) < key(B))
#define exch(A, B)  
    {               
    Item t = A; 
    A = B;      
    B = t;      
    }
#define compexch(A, B) 
    if(less(B, A))     
    exch(A, B)
void bubbleSort(Item a[], int l, int r)
{
    int i, j;
    for(i=l;i<r;i++){
        for(j=r;j>i;j--)
            compexch(a[j-1],a[j]);
    }
}

main(int argc, char* argv[])
{
    int i, N = atoi(argv[1]), sw = atoi(argv[2]),j=0;
    int* a = malloc(N * sizeof(int));
    printf("%d items
",N);
    if(sw)
    for(i = 0; i < N; i++) {
        a[i] = 1000 * (1.0 * rand() / RAND_MAX);
        printf("%d item is %d
",i,a[i]);
    }
    else
/** 不知道是不是我的知識不夠,書本上的例子這段程式碼執行不起來
    while(scanf("%d", &a[N]) == 1)
        N++; 
         */
    while(j<N){
        scanf("%d",&a[j++]);
    }
    //2.呼叫氣泡排序的函式對這些整型進行排序
    bubbleSort(a, 0, N - 1);
    //3.輸出排序後的結果
    for(i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("
");
}

我們可以類似章節6.3那樣優化插入排序地去優化程式6.4。對比程式碼,程式6.4出現了非適應式插入排序(nonadaptive insertion sort)。不過不同的是,程式6.1的插入排序是移動左邊已經排好序的元素,而氣泡排序是移動右邊未被排序的元素。

程式6.4只使用compexch指令和非適應式插入排序(nonadaptive insertion sort),當我們還是可以改進它,讓它更高效,當陣列是已經接近排好序,通過一輪遍歷即可知道不用交換(所以如果陣列時已經排好序的,我們就可以中斷外部迴圈)。加上這些改進,會讓氣泡排序在一些資料會快一些,但一般它不會像插入排序那樣中斷內迴圈後那麼塊,這一點會在章節6.5討論。
氣泡排序在排序方法中的重要引數:
時間複雜度 N^2
記憶體使用量:不需要額外的記憶體
穩定性:穩定

參考:
Algorithms in C, Parts 1-4_ Fundamentals, Data Structures, Sorting, Searching (3rd Edition) (Pts. 1-4).pdf

相關文章