數據結構與算法分析(五)--- 遞推與遞歸 + 減治排序

人有人的思維,計算機有計算機的思維,它們很不相同。如果你要問其中最大的不同是什麼,那就是一種被稱爲遞歸(recursive)的逆向思維。相比之下,人的正向思維被稱爲遞推(iterative)。要了解什麼是遞歸,我們先了解什麼是遞推。

一、遞推

遞推是人本能的正向思維,比如我們在學習解方程時,先學習解一元方程,再學習解二元方程,之後才學解三元方程,最後推廣到有任意未知數的方程,就是所謂的線性方程組,這種循序漸進、由易到難、由小到大、由局部到整體等正向思維方式就是遞推。

如果用遞推的方法計算一個整數的階乘,比如5! = 12345,那麼做法是從小到大一個個乘起來,如果算n!,那麼要從1乘到n,使用遞推計算n!的函數實現代碼如下所示:

// algorithm\recursive.c

#include <stdio.h>

int factorial_iterative(int n)
{
    if(n < 0)
        return 0;
    
    int i, res = 1;
    
    for(i = 1; i <= n; i++)
        res *= i;
    
    return res;
}

上面的代碼邏輯符合我們從小到大、自底向上的遞推思維方式,我們從來不覺得它有什麼問題。事實上,我們在中學裏學的數學歸納法就是遞推方法的典型應用。

計算機思維正相反,它是自頂向下,從整體到局部的遞歸思維。什麼是遞歸呢?直接解釋概念很難講清楚,下面以一個面試題爲例來說明:

我們倆來做一個遊戲,第一個人先從1和2中挑一個數字,第二個人可以在對方的基礎上選擇加1,或者加2。然後又輪到了第一個人,他可以再次選擇加1,或者加2,之後把選擇權交給對方。就這樣雙方交替地選擇加1或者加2,誰要是正好加到20,誰就贏了。用什麼策略可以保證一定能贏?

我們先簡化上面的問題,把加到20改爲加到10。按照遞推思維,假如讓你先選,你選2,我加2到4,你加1到5,我再加2到7,接下來你不論選加1到8還是加2到9,我都贏定了。再假如我先選,我選1,你選加2到3,我選加1到4,你選加2到6,我選加1到7,又回到第一次最後的狀態,我還是贏定了。

可能你已經從上面的例子中想清楚這道題裏面的技巧了,如果僅僅搶到10,情況並不複雜,你即使想不清楚它的道理,試幾次也能找到規律,但是如果是搶20,情況就複雜多了,如果是搶30甚至50呢?就不能通過窮舉法這種笨辦法解決問題了,就必須找到它的規律。

可能你已經看出來了,要想搶到20,就需要搶到17,因爲搶到了17,無論對方加1還是加2,你都可以加到20。而要想搶到17,就要搶到14,依此類推,就必須搶到11、8、5、2,因此對於這道題,只要第一個人搶到了2,他就贏定了。這裏面的核心在於看清楚,無論對方選擇1還是2,你都可以讓第一輪兩個人加起來的數值等於3,於是你就可以牢牢控制整個過程了。

這道看似是智力題的面試題是要考察候選人的什麼技能呢?就是對計算機遞歸思想的理解。對於一般人,讓他們數到20,他們會從小到大數,也就是正向的遞推思維。但是這道題的解題思想正好相反,它是要尋找20,就要先尋找17,至於怎麼從17到20,方法你是知道的,接下來要尋找17,就要尋找14,依此類推,這就是遞歸思想。

二、遞歸

上面這道面試題,可能有點過於簡單,但是面試官其實還留有後手。比如他會問面試者,按照上述方法,從1開始加到20,一個有多少種不同的遞加過程?

解這道題的技巧也在於使用遞歸,如果你從1、2、3開始找規律就難了。我們假定數到20有F(20)種不同的路徑,那麼到達20這個數字,前一步只有兩個可能的情況,即從18直接蹦到20,或者從19數到20,由於這兩種情況彼此是不同的,因此走到20的路徑數量,其實就是走到18的路徑數量,加上走到19的路徑數量,也就是說F(20) = F(19) + F(18),類似的,F(19) = F(18) + F(17),這就是遞推公式。

最後,F(1)只有一個可能性,就是F(1) = 1,F(2)有兩個可能性,要麼直接蹦到2,要麼從1走到2,所以F(2) = 2。知道了F(1)和F(2),就可以知道F(3),然後再倒着推導回去,一直到F(20)即可。

數學比較好的朋友可能已經看出來了,這就是著名的斐波那契數列,如果我們認爲F(0) 也等於1,那麼這個數列就是這樣的1(=F(0))、1、2、3、5、8、13、21…,這個數列幾乎按照幾何級數的速度遞增,到了F(20),就已經是10946了。

斐波那契數列其實反映出一個物種自然繁衍,或者一個組織自然發展過程中成員的變化規律。斐波那契數列最初是這樣描述的:有一對兔子,它們生下了一對小兔子,前面的成爲兔一代,後面的稱爲兔二代,然後這兩代兔子各生出一對兒兔子,這樣就有了第三代。這時第一代兔子老了,就生不了小兔子了,但是第二、第三代還能生,於是它們生出了第四代,然後它們不斷繁衍下去,請問第N代兔子有多少對兒?

斐波那契數列增長有多快呢?我們假設F(n)表示數列中的第n個數,F(n+1)表示數列中的第n+1個數,我們用Rn = F(n+1)/F(n)表示數列增長的相對速率,簡單計算下即可得知,Rn很快趨近於1.618,這恰好是黃金分割的比例。黃金分割比例是個神奇的數字,或許反映了宇宙自身的一個常數,比如自然界中的蝸牛殼、龍捲風、星系的形狀都符合等角螺旋線,也被稱爲自然生長螺旋線,就是由黃金分割的幾何相似性繪出的。

上面這個比率(Rn = 1.618)幾乎也是一個企業擴張時能夠接受的最高的員工數量增長速率,如果超過這個速率,企業的文化就很難維持了。企業在招入新員工時,通常要由一個老員工帶一個新員工,缺了這個環節,企業的人一多就各自爲戰了。而當老員工帶過兩三個新員工後,他們會追求更高的職業發展道路,不會花太多時間繼續帶新人了,因此帶新員工的人基本也就是職級中等偏下的人,這很像上面的兔子繁殖,只有那些已經性成熟而且還年輕的在生育。

上面那道面試題,將數到20的不同路徑擴展到數到n的不同路徑,實際上就是求斐波那契數列的第N個數,根據上面的遞推公式可以擴展得到F(n) = F(n-1) + F(n-2)。有了遞推公式,還需要遞歸邊界,也即前面提到的F(0) = F(1) = 1。遞推公式可以將求解F(n)的未知解自頂向下轉換爲求解F(n-1)與F(n-2)的未知解,直到達到遞歸邊界的已知解,再通過遞歸邊界的已知解自底向上遞推(或迴歸),求得F(n)的已知解。

使用遞歸計算斐波那契數列第N個數的函數實現代碼如下:

// algorithm\recursive.c

int fibonacci_recursive(int n)
{
    if(n < 0)
        return 0;
    else if(n == 0 || n == 1)
        return 1;
    else
        return (fibonacci_recursive(n-1) + fibonacci_recursive(n-2));
}

從上面的解題過程可以總結:遞歸就在於使用計算機自頂向下、從整體到局部的思維方式分析問題,找到把原問題自頂向下層層展開(或分解)的遞推公式,通過不斷重複使用遞推公式,把原問題展開(或分解)到有已知解的遞歸邊界處,再從遞歸邊界的已知解,自底向上遞推(或迴歸)求得原問題的解。

遞歸可以說是計算機科學的精髓,包含自頂向下和自底向上兩個遞推過程(可以把其中一個稱爲迴歸或回溯過程),遞歸的實現需要找到遞推公式與遞歸邊界兩個部分:

  • 遞歸邊界:子問題展開或分解的盡頭;
  • 遞推公式:將原問題分解爲若干個子問題的方式。

爲了更直觀的瞭解遞歸過程,我們看下遞歸調用示意圖,先從最開始簡單的求n!爲例,先給出使用遞歸方法求解n!的函數實現代碼如下:

// algorithm\recursive.c

int factorial_recursive(int n)
{
    if(n < 0)
        return 0;
    else if(n == 0)
        return 1;
    else
        return n*factorial_recursive(n-1);
}

跟遞推方式求解的函數代碼相比更簡潔些,以求解3!爲例,給出遞歸求解階乘的過程示意圖如下:
遞歸求解階乘的過程示意圖
從上圖可以明顯看出遞歸求解自頂向下與自底向上兩個遞推過程,這個過程有點類似於堆棧的後進先出結構。計算機實際執行遞歸時,確實使用了計算機的系統堆棧,由此可以看出,如果遞歸層級過深,就會導致系統堆棧不夠用而出現錯誤。

我們再看斐波那契數列的遞歸求解過程,對此應該更有體會,以F(4)爲例,斐波那契數列遞歸求解示意圖如下:
斐波那契數列遞歸求解過程
由上圖可以看出斐波那契數列求解的遞歸調用過程比階乘求解的遞歸調用複雜得多,如果階乘求解的遞歸調用複雜度爲O(n),斐波那契數列的遞歸調用複雜度就是O(2^n),後者的遞歸調用複雜度呈指數增長,採用上面的函數實現方式只能求解比較小的斐波那契數列,比如前40項。

斐波那契數列遞歸求解爲何會有這麼大的複雜度呢?再仔細看其遞歸求解過程示意圖,發現再遞歸求解過程中有大量的重複計算。要想提高算法的運行效率自然要讓計算機儘可能少做事,現在計算機做了大量重複計算,效率自然大大降低。如何避免這種大量重複計算呢?

最簡單的就是將中間計算結果保存起來,在下次使用時直接取用,不用再重新計算,比如在外部建一個數組專門用來保存中間結果,按這種方式實現的斐波那契數列遞歸求解函數代碼如下:

// algorithm\recursive.c

#define MAXN    1000

int fibonacci[MAXN] = {0};

int fibonacci_recursive_memory(int n)
{
    if(n < 0)
        return 0;
    else if(n == 0 || n == 1)
        return 1;
    else if(fibonacci[n] != 0)
        return fibonacci[n];
    else
    {
        fibonacci[n] = fibonacci_recursive_memory(n-1) + fibonacci_recursive_memory(n-2);
        return fibonacci[n];
    }
}

這種保存中間結果,避免重複計算的過程相當於讓遞歸函數有了記憶功能,可以稱爲帶記憶功能的遞歸,優化前後的斐波那契數列遞歸求解過程對比示意圖如下:
遞歸求解的記憶優化
從上圖可以看出,通過採用保存中間結果,避免重複計算的優化,將斐波那契數列的遞歸求解調用複雜度從O(2^n)降低到了O(n),相當於藉助外部數組O(n)的空間將遞歸計算時間複雜度從指數級別降低到了線性級別,這是非常有效的以空間換時間的方法。保存中間結果避免重複計算的技巧也是計算這類重疊子問題的非常有效的方法。

三、遞歸設計—遞推實現

遞歸的原理在計算機科學中更多的體現在邏輯的做事方法或解決問題的思維方式,在具體的實現上,如果直接採用遞歸的方法,邏輯簡單,代碼很短,非常簡潔漂亮。但是,由於要不斷的把中間狀態放到堆棧中(壓棧或入棧),然後再出來(彈出棧或出棧),佔用空間較多,當遞歸層級很深時,可能會因系統棧空間不夠用而出現錯誤。

因此,在具體實現時,很多情況是使用遞歸的原則設計,遞推的原則實現,比如斐波那契數列求解第N個數的問題,採用遞推方式的函數實現代碼如下:

// algorithm\recursive.c

int fibonacci_iterative(int n)
{
    if(n < 0)
        return 0;

    int i, temp, res1 = 1, res2 = 1;
    for(i = 2; i <= n; i++)
    {
        temp = res1;
        res1 = res2;
        res2 += temp;
    }

    return res2;
}

遞推的實現方式是自底向上,從遞歸邊界出發,向上根據遞推公式求得原問題的解,實際上相當於遞歸過程的後半部分,但省去了大量中間狀態入棧與出棧的操作,節省了大量系統棧的空間佔用。

3.1 尾遞歸

有些問題很難採用遞推方式實現,或者我們既想省去對系統棧的大量佔用,又想使用遞歸這種簡潔漂亮的實現方式,有沒有什麼辦法可以兩者兼顧呢?

再回顧下前面介紹的通過外部數組保存中間結果,避免大量重複計算來提高算法效率的方法,中間結果既然可以保存在外部數組中,當然也可以保存在函數參數中。如果把遞歸調用過程的中間結果或中間狀態保存在函數參數中,使其不依賴上一次的調用結果,當達到遞歸邊界時,可以直接從參數中獲得原問題的解,而省去了迴歸過程,這種省去迴歸過程的遞歸就獲得了遞推的優點。

如果要省去遞歸調用的迴歸過程,除了要將遞歸調用的中間結果或狀態保存到函數參數中,還需要保證遞歸調用後面沒有任何計算,也即遞歸調用只出現在函數末尾,而且函數末尾只返回遞歸函數本身,這種特殊的遞歸稱爲尾遞歸。

先看簡單的n!求解,採用尾遞歸方式的函數實現代碼:

// algorithm\recursive.c

int factorial_Tailrecursive(int n, int res)
{
    if(n < 0)
        return 0;
    else if(n == 0)
        return res;
    else
        return factorial_Tailrecursive(n-1, n*res);
}

仔細看尾遞歸調用的參數變化過程,也是符合遞推思維的,遞歸調用被省去的迴歸求解過程實際上轉移到遞歸函數的參數中了。從n!求解的遞歸邊界:0!= 1可知,調用該函數時res初值傳入1,比如求5!,函數調用格式爲factorial_Tailrecursive(5, 1)。

再看斐波那契數列求解第N個數的問題,採用尾遞歸方式如何實現?由於斐波那契數列的遞推公式中需要前面兩級的解,所以該問題的尾遞歸函數需要兩個參數分別保存前面兩級的解。按照遞推思維,尾調用的兩個參數初值分別爲遞歸邊界F(0) =1與F(1) = 1,在參數中實現的遞歸公式則體現爲前兩級解的和,按照這種方式,採用尾遞歸方式求解該問題的函數實現代碼如下:

// algorithm\recursive.c

int fibonacci_Tailrecursive(int n, int res1, int res2)
{
    if(n < 0)
        return 0;
    else if(n == 0)
        return res1;
    else if(n == 1)
        return res2;
    else
        return fibonacci_Tailrecursive(n-1, res2, res2 + res1);
}

需要注意的是尾遞歸調用本身並不能節省大量的系統棧開銷,現在的編譯器對C/C++都實現了尾遞歸優化(比如gcc / g++要開啓尾遞歸優化需要設置優化等級-O2或-O3),由於尾遞歸調用已經把本層級計算得到的所有結果全部傳給下一層級了,本層級無需再保存任何數據,編譯器判斷下一層級的尾遞歸調用不需要再去創建一個函數棧空間,可以直接複用當前的函數棧空間,把原先的數據覆蓋即可,通過函數棧空間的複用,達到尾遞歸對棧空間的使用達到遞推方式實現的效果。但是,編譯器並沒有對所有語言實現尾遞歸調用的優化,比如編譯器對java、python這種更高級的語言就沒有實現尾遞歸優化,沒辦法通過尾遞歸調用節省大量的系統棧空間佔用。

四、遞歸應用示例

4.1 求解最大公約數

正整數a與b的最大公約數是指a與b的所有公約數中最大的那個公約數,一般用gcd(a, b)來表示。求解最大公約數最常使用的是輾轉相除法(即歐幾里得算法),該算法基於下面這個定理:

  • 設a、b均爲正整數,則gcd(a, b) = gcd(b, a%b)

證明:
設a = kb + r(a,b,k,r皆爲正整數,且r<b),其中k和r分別爲a除以b得到的商和餘數,則有r = a - kb成立。
設d爲a和b的一個公約數,即a和b都可以被d整除。而r = a - kb,兩邊同時除以d,r/d=a/d-kb/d=m,由等式右邊可知m爲整數,因此d也是b與r(也即a % b)的公約數。因此d既是a與b的公約數,也是b與a%b的公約數。由d的任意性,得a和b的公約數都是b和a%b的公約數。
設d爲b和a%b的公約數,由於a = kb + r = kb + a%b,由於k是正整數,則d也是a和b的公約數,同樣由d的任意性,得a和a%b的公約數都是a和b的公約數。
因此a和b的公約數與b和a%b的公約數全部相等,故其最大公約數也必然相等,得證。

上面這個定理可以直接作爲遞歸算法的遞推公式使用,有了遞推公式,還需要找到遞歸邊界,即數據規模減小到什麼程度可以直接計算出結果。很簡單,0和任意一個整數a的最大公約數都是a(也即gcd(a, 0) = a),這個結論可以作爲遞歸邊界。有了遞推公式和遞歸邊界,就可以編寫出求解最大公約數的遞歸函數實現代碼:

// algorithm\recursive.c

int gcd(int a, int b)
{
    if(b == 0)
        return a;
    else
        return gcd(b, a % b);
}

上面的遞歸函數以尾遞歸的形式實現,便於C/C++編譯器優化。如果還要求解最小公倍數,則直接用a和b的乘積除以其最大公約數即可得到。

4.2 插入排序算法

排序算法可以算是基礎算法中最常用的了,基礎的排序算法主要有插入排序、冒泡排序、選擇排序等(高級點的排序算法後面再介紹),這三種基礎排序算法都是相鄰元素比較,每個元素都需要跟其它N-1個元素進行比較,要完成N個元素分別跟其它N-1個元素的比較,需要的最壞與平均時間複雜度都是O(n2)(可以通過逆序數證明:通過交換相鄰元素來完成排序的算法,其最壞與平均時間複雜度爲O(n2))。

雖然三種排序算法的時間複雜度一致,但在工程中插入排序算法平均消耗的時間最少,在小規模數據排序中,插入排序算法比另外兩種排序算法更常用。比如在快速排序算法的優化中,當數據規模很小時(比如10個數以內),就可以使用插入排序算法代替快速排序算法節約時間。

插入排序算法是指,對序列A的n個元素A[0]–A[n-1],令i從1到n-1枚舉,進行n-1趟操作,假設某一趟,序列A的前k-1個元素A[0]–A[k-1]已經有序,而範圍[k, n-1]還未有序,那麼該趟從範圍[0, k-1]中尋找某個位置i,使得將A[k]插入位置i後(此時A[i]–A[k-1]會後移一位至A[i+1]–A[k]),則範圍[0, K]變得有序。下面給出一個動畫展示該過程:
插入排序動畫演示
插入排序這種在序列A的前k-1個有序元素A[0]–A[k-1]中插入A[k],使得序列A[0]–A[k]有序的操作很符合遞推公式的特點,如果使用遞歸方法實現插入排序算法,這個過程就可以作爲遞推公式。有了遞推公式,還需要遞歸邊界,再回顧插入排序的過程,k從1開始不斷遞增,直到k = n-1,便實現了A[0]–A[n-1]有序的目的,也就完成了插入排序的過程,因此可以把k = n作爲遞歸邊界。

我們要以尾遞歸方式實現,還需要考慮使用哪些參數來保存遞歸調用某層的全部中間結果。在遞歸調用中,k從1開始逐漸遞增到n-1,因此k需要作爲一個參數,序列A的首地址和元素個數也需要作爲參數傳入,這三個參數就可以保存遞歸調用的全部中間結果了。

按照上面的分析,編寫插入排序算法的尾遞歸實現代碼如下:

// algorithm\sort.c

void insert_sort(int *data, int n, int k)
{
    if(k >= n)
        return;
    
    int i = k-1, temp = data[k];
    while (i >= 0 && data[i] > temp)
    {
        data[i+1] = data[i];
        i--;
    }
    data[i+1] = temp;

    insert_sort(data, n, k+1);
}

對於排序算法的原始無序數據,我採用了隨機數生成,使用隨機數生成n個無序數據的實現代碼如下:

// algorithm\sort.c

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

#define MAX_COUNT      10000

int *data = NULL;

int * data_init(int *data, int n)
{
    int i;

    data = malloc(n * sizeof(int));

    srand(time(NULL));

    for(i = 0; i < n; i++)
        data[i] = rand() % n;

    return data;
}

bool validate_data(int *data, int n)
{
    bool res = true;
    int i;
    for (i = 1; i < n; i++)
    {
        if(data[i] < data[i - 1])
            res = false;
    }

    if(res == true)
        printf("The data sequence has been ordered.\n\n");
    else
        printf("The data sequence is unordered.\n\n");

    return res;
}

int main(void)
{
    data = data_init(data, MAX_COUNT);

    insert_sort(data, MAX_COUNT, 1);
  
    validate_data(data, MAX_COUNT);

    free(data);
    return 0;
}

插入排序爲何比冒泡排序與選擇排序速度更快呢?主要是因爲插入排序算法更充分利用了序列中已存在的順序信息,數據序列在部分有序時,插入排序可以減少比較次數,也即逆序數越少,插入排序越快。當數據序列接近有序,也即逆序數很少時,插入排序幾乎是最快的排序算法(逆序數很少時,插入排序算法的時間複雜度接近O(N))。

4.3 希爾排序算法

前面我們已經提到插入排序算法的時間複雜度爲O(n2),我們想對排序算法進行優化,以獲得更高的計算效率。要想提高效率,就需要讓計算機少做事情,放到排序算法中就是要減少數據之間的相互比較次數。

對於插入排序算法,每個待排序元素A[k]都要和前面的A[0]–A[k-1]個元素進行比較,效率自然較低。如果我們把序列A分爲多組,每組分別排序後,再放到一起排序,就能減少數據之間的相互比較次數。舉個例子,假如一個學校有20000名學生,如果直接相互比較排出名次,每個人都要和所有人去比較,效率自然比較低,如果我們將這20000名學生放到10個班級中,每個班級內先相互比較排出名次,再把這2萬名學生放到一起,只跟各班級名次相近的學生相互比較即可,每個人需要比較的對手少了很多,效率自然比前面的方法高不少。

希爾排序和後面要介紹的歸併排序、快速排序等都是基於上述邏輯,也即將序列A分解爲多個小序列,對小序列分別排序,比直接進行大序列A的排序要節省不少時間。

希爾排序對序列A的分組是以增量序列(h1, h2, …, ht)的形式進行的,按增量序列個數k,對序列A進行k趟排序。比如選擇增量hk,對應的一組增量序列就是A[i]、A[i + hk]、A[i + 2 * hk]…A[i + k * hk](其中i + k * hk < n),對這組增量序列進行插入排序。在使用增量序列hk的一趟排序之後,對於每一個i,有A[i] <= A[i + hk],所有相隔hk的元素都被排序。下面給出一個動畫展示該過程:
希爾排序動畫演示
對於希爾排序增量序列的選取,我們採用比較簡單且常用的增量序列:ht = n / 2和hk = h(k+1) / 2,也即不斷將序列增量除以2。這種增量序列並不是最好的,還存在一些更好的增量序列(比如Hibbard增量序列:hk = 2k-1)能帶來更高的計算效率,但比這種增量序列更復雜,這裏主要介紹算法思想與原理,故選取最簡單常用也是希爾推薦的增量序列實現希爾排序算法。

要以遞歸方式實現希爾排序算法,首先找到遞推公式,按照增量序列中的某個增量hk,對被該增量分割出的多組小序列分別進行插入排序(比如增量hk分割出的多組小序列的首個元素分別爲A[0], A[1],…, A[hk-1])。使用增量hk完成排序後,選擇增量序列中的下一個增量h(k+1)進行同樣的排序操作,這個過程就可以作爲遞推公式。遞歸邊界如何確定呢?增量序列的界限即是遞歸邊界,對於我們選擇的增量序列,不斷除以2,最小增量爲1,我們可以把增量1作爲遞歸邊界。

如果我們以尾遞歸形式實現希爾排序算法,還需要使用合適的參數保存遞歸調用過程的中間狀態,對比前面介紹的插入排序,很容易想到增量hk要作爲一個參數,序列A的首地址和元素個數也需要作爲參數傳入,這三個參數就可以保存遞歸調用過程的全部中間狀態。

在實現希爾排序算法前,還需要對前面的插入排序算法進行修改,前面的插入排序算法默認增量爲1,我們需要增加一個增量參數hk,以便對希爾排序中被增量hk分割出的多組小序列分別進行插入排序。修改後的插入排序算法如下:

void insert_sort(int *data, int n, int k, int step)
{
    if(k >= n)
        return;
    
    int i = k - step, temp = data[k];
    while (i >= 0 && data[i] > temp)
    {
        data[i + step] = data[i];
        i -= step;
    }
    data[i + step] = temp; 

    insert_sort(data, n, k+ step, step);
}

在此基礎上實現的希爾排序代碼如下:

// algorithm\sort.c

void shell_sort(int *data, int n, int step)
{
    if(step < 1)
        return;

    int i;
    for(i = 0; i < step; i++)
        insert_sort(data, n, i, step);
    
    shell_sort(data, n, step / 2);
}

int main(void)
{
    data = data_init(data, MAX_COUNT);

    shell_sort(data, MAX_COUNT, MAX_COUNT / 2);

    validate_data(data, MAX_COUNT);

    free(data);
    return 0;
}

爲了便於比較插入排序與希爾排序的時間複雜度,我們使用C語言的一個函數clock()分別記錄排序開始時間與結束時間,獲得兩種排序算法消耗的時間,在main函數中增加函數執行時間計算的代碼如下:

// algorithm\sort.c

#define MAX_COUNT      10000

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

int main(void)
{
    int k, n;
    clock_t start, end;
    double time;

    printf("insert sort: 1\n");
    printf("shell  sort: 2\n");
    printf("Select method: ");
    scanf("%d", &k);

    data = data_init(data, MAX_COUNT);

    start = clock();
    switch (k)
    {
    case 1:
        insert_sort(data, MAX_COUNT, 1, 1);
        break;

    case 2:
        shell_sort(data, MAX_COUNT, MAX_COUNT / 2);
        break;
    
    default:
        break;
    }
    end = clock();
    time = (double)(end - start) / (CLOCKS_PER_SEC / 1000);

    validate_data(data, MAX_COUNT);
    printf("execution time: %.3lf ms.\n", time);

    free(data);
    return 0;
}

使用插入排序和希爾排序分別對1000個隨機數進行排序,執行時間分別如下:
插入排序與希爾排序時間對比
從上面的結果可以看出,希爾排序比插入排序的效率高了很多,還是挺明顯的。希爾排序的時間複雜度根據選取增量序列的不同而不同,對於我們選擇的增量序列,其時間複雜度也是O(n2)。對於Hibbard增量序列,其時間複雜度爲O(n(3/2)),由此也可以看出,通過增量序列將原序列進行分組,讓序列元素執行遠距離交換,可以讓排序算法的平均時間複雜度低於O(n2)。

對於我們選擇的增量序列,雖然希爾排序與插入排序的時間複雜度都是O(n2),但衡量時間複雜度一般省略了常數,主要是看計算時間隨數據量增大的相對增長速率。在工程應用中,對相同數據量的計算,雖然兩種算法的時間複雜度一致,但可能一種算法的工程時間複雜度是O(k * n2),另一種算法的工程時間複雜度是O( n2 / t),其中t與m均爲大於1的常數,那麼這兩種算法在工程應用中,後者的效率是前者的 k * t 倍,希爾排序與插入排序就類似這種情況。

本章算法實現源碼下載地址:https://github.com/StreamAI/ADT-and-Algorithm-in-C/tree/master/algorithm

本章使用的代碼編輯器爲VS Code,編譯器爲gcc,VS Code插件爲Code Runner,該插件的配置界面如下(此插件默認調用gcc / g++並沒有設置優化選項,所以不能對尾遞歸進行優化,可以通過手動輸入gcc / g++編譯命令開啓-O2或-O3優化,以便編譯器對尾遞歸的函數棧使用進行優化):
Code Runner配置

更多文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章