數據結構與算法分析(六)--- 分治與減治 + 分治排序與二分查找

一、分治算法

分治(divide and conquer)的全稱爲“分而治之”,從名稱上看,分治算法主要由兩部分構成:

  • 分(divide):遞歸求解所有從原問題分解出來的相似子問題;
  • 治(conquer):從子問題的解構建原問題的解。

也就是說,分治算法將原問題劃分成若干個規模較小而結構與原問題相同或相似的子問題,然後遞歸求解所有子問題(如果存在子問題的規模小到可以直接解決,就直接解決它),最後合併所有子問題的解,即可得到原問題的解。

1.1 分治與減治

傳統上,在函數正文中至少含有兩個遞歸調用的例程叫做分治算法,而函數正文中只含一個遞歸調用的例程不是分治算法(可以稱爲減治算法)。對於函數中只包含一個遞歸調用的減治算法,在前篇博客:遞推與遞歸中已經做過介紹,插入排序與希爾排序算法就可以稱爲減治算法。

減治算法一般只包含一個子問題(或者說只選取一部分子問題,而裁剪掉另一部分子問題),遞歸求解該子問題的解即可得到原問題的解(遞推公式的作用)。

分治算法則先將原問題遞歸分解爲多個子問題,再在遞歸中求解所有子問題的解,最後合併所有子問題的解即可得到原問題的解。需要指出的是,分治算法分解出的子問題應當是相互獨立、沒有交叉重疊的,如果存在兩個子問題有交叉重疊部分,那麼不應當使用分治算法解決。可以看出,分治算法是對遞歸算法的組合應用。

1.2 歸併排序

說起分治算法,最先想到的一般是歸併排序,“歸併”可以理解爲遞歸分解與合併兩部分,正好對應分治算法的分與治兩個過程。

遞歸分解數據序列,最常見的就是一分二、二分四、四分八…,直至分解到數據序列只剩下一個元素,不可再分,就到了遞歸邊界。歸併排序的遞歸分解過程也很清晰,遞推公式就是前面說的一分二、二分四、四分八…這個過程,實際上就是左邊界或右邊界減半的過程,遞歸邊界就是數據序列只剩下一個元素的情形,也即左右邊界相等的情形。

遞歸分解過程中,跟前篇介紹希爾排序的遞歸分組不同的是,希爾排序對於增量序列中的每個增量只分解出一個子問題,而歸併排序的遞歸分解,每次遞歸都會將原問題分解爲兩個子問題,所以在函數正文中需要兩次遞歸調用,這也是前面介紹的分治算法與減治算法的區別。

歸併排序的遞歸分解過程,主要是通過左邊界或右邊界減半實現的,那麼參數就需要包含數據序列的首地址,左邊界下標和右邊界下標,下面給出遞歸分解過程的實現代碼(數據合併部分暫略):

// algorithm\sort.c

void recursive_merge(int *data, int left, int right)
{
    if(left >= right)
        return;

    int mid = left + (right - left) / 2;
    recursive_merge(data, left, mid);
    recursive_merge(data, mid + 1, right);

    merge_data(data, left, mid, right);
}

接下來看被遞歸分解的數據序列如何合併?遞歸分解到只剩一個元素時,我們可以認爲該數據序列是有序的。每次自頂向下遞歸調用時,原序列都被遞歸分解爲兩個子序列,在到達遞歸邊界後,開始自底向上迴歸,每次迴歸都需要將兩個有序子序列合併爲一個有序子序列。所以,問題就轉換爲我們如何將兩個有序子序列合併爲一個有序序列?

兩個有序子序列合併爲一個有序序列,最簡單的就是藉助一個空數組,將兩個有序子序列的首元素相互比較,較小的元素放入空數組,並將其所在子序列的指針移到下一個元素處,繼續剛纔的比較過程。直到其中一個子序列的元素比較完畢,將另一個子序列剩餘的元素全部放到空數組中。最後,我們將存放在空數組中的有序序列依次放入原序列即可。將該過程舉例圖示如下:
兩個有序子序列合併爲一個有序序列示意圖
按照上面的邏輯編寫數據合併實現代碼如下(兩個子序列分別爲data[left] – data[mid]與data[mid+1] – data[right]):

// algorithm\sort.c

void merge_data(int *data, int left, int mid, int right)
{
    int *temp = malloc((right - left + 1) * sizeof(int));

    int i = left, j = mid + 1, k = 0;

    while(i <= mid && j <= right)
    {
        if(data[i] <= data[j])
            temp[k++] = data[i++];
        else
            temp[k++] = data[j++];
    }

    while(i <= mid)
        temp[k++] = data[i++];

    while(j <= right)
        temp[k++] = data[j++];

    for(i = left, k = 0; i <= right; i++, k++)
        data[i] = temp[k];

    free(temp);
}

到這裏遞歸分解與子序列合併兩個過程都通過函數實現了,我們用一張圖來說明歸併排序的兩個過程:
歸併排序示意圖
上面的函數還有點改進空間,內存的分配釋放比較佔用時間,上面子序列合併的實現函數merge_data中進行了空數組的分配與釋放,該函數被多次調用就會降低歸併排序的效率。我們可以先爲其分配一個與原序列相同大小的空數組,在子序列合併函數merge_data中就可以省去內存的分配與釋放操作過程,但需要增加一個參數用於傳入臨時數組的指針。優化後的歸併排序實現代碼如下:

// algorithm\sort.c

void merge_data(int *data, int *temp, int left, int mid, int right)
{
    int i = left, j = mid + 1, k = 0;

    while(i <= mid && j <= right)
    {
        if(data[i] <= data[j])
            temp[k++] = data[i++];
        else
            temp[k++] = data[j++];
    }

    while(i <= mid)
        temp[k++] = data[i++];

    while(j <= right)
        temp[k++] = data[j++];

    for(i = left, k = 0; i <= right; i++, k++)
        data[i] = temp[k];
}

void recursive_merge(int *data, int *temp, int left, int right)
{
    if(left >= right)
        return;

    int mid = left + (right - left) / 2;
    recursive_merge(data, temp, left, mid);
    recursive_merge(data, temp, mid + 1, right);

    merge_data(data, temp, left, mid, right);
}

爲了方便調用,我們可以再對recursive_merge進行一層封裝,只需要傳入數據序列首地址及元素個數兩個參數即可,封裝函數實現代碼如下:

// algorithm\sort.c

void merge_sort(int *data, int n)
{
    int *temp = malloc(n * sizeof(int));

    recursive_merge(data, temp, 0, n - 1);

    free(temp);
}

從歸併排序的實現過程看,遞歸分解過程N分N/2、再分N/4、…最後分到1,一共分割了log2N次(由2x = N得x = log2N);子序列合併過程比較排序加上數據拷貝,大概需要耗費2N時間,兩者相乘得歸併排序的時間複雜度爲O(N * logN)(省略常數)。很顯然,歸併排序的時間複雜度比插入排序與希爾排序的時間複雜度更低,也即效率更高。

從歸併排序算法的實現過程,可以更深的瞭解分治算法的原理與應用,下面再介紹一種更常用的分治算法 — 快速排序算法。

二、快速排序

快速排序也是一種分治算法,自然也可以分爲自頂向下的遞歸分解與自底向上的子問題合併兩部分。與歸併排序不同的是,歸併排序是在數據合併過程中由我們完成有序子序列的合併,快速排序則是在序列分解過程中由我們完成子序列分組。

在介紹希爾排序時,我們舉了兩萬名學生先分班級,在各班級內先排出名次,再在不同班級相應名次間互相比較,比兩萬名學生放一塊兒進行兩兩比較,效率高得多。這個例子同樣適用於歸併排序,先分解爲有序子序列,再將多個有序子序列合併爲一個有序序列,能大幅減少數據間的比較與交換次數,因此可以獲得超越插入排序的執行效率。

快速排序與歸併排序不同的是,快速排序按分數線分班,比如A班的所有學生能力都高於分數線level,B班的所有學生能力都低於分數線level,待A班與B班內的學生排序後,兩班組合就是所有學生的排序,兩班之間的學生不用再相互比較,這就能讓計算機做更少的事兒,在某些情況下比歸併排序獲得更高的執行效率,這也是快速排序比歸併排序更常用的原因。

從上面的分析可以看出,快速排序的關鍵是如何選擇分界線並把數據按分界線分爲小於分界線的子序列和大於分界線的子序列?理想情況下是取數據序列的中位數,能夠通過分界線把數據序列分爲元素個數相等的兩個子序列,這種情況相比歸併排序省去了額外數組的空間開銷與數據拷貝的時間開銷,應該能獲得比歸併排序更高的效率。糟糕情況下,分界線並沒有把數據序列分隔開,此時退化爲類似插入排序這種未分組的基礎排序算法,效率甚至不如插入排序。快速排序分界線的選擇,跟希爾排序增量序列的選擇類似,都會對排序算法的運行效率或者時間複雜度產生很大的影響。

快速排序選擇分界線比較常用的有以下三種方式:

  1. 選一個固定位置,比如a[left]:這種方法是最不可取的,原因非常簡單,假設數組已經接近有序,那麼選取a[left]作爲分界線就很容易導致分治變得“無效”,因爲a[left]很可能就是最小的元素;
  2. 隨機選擇一個位置,比如a[left + rand()%(right-left+1)]:這種方法可取,隨機選擇的位置雖然不是很好,但也不至於太糟,計算隨機數耗費的時間稍微多一些;
  3. 三數取中法,比如選擇a[left]、a[(left+right)/2]、a[right]大小排中間的那個:這種方法一般相比前兩種更好,選擇的分界線更接近中位數,而且省去了計算隨機數的開銷。

我們使用更優的三數取中法來選擇分界線,要從三個元素中選出值處於中間的元素,最簡單的方法是先將這三個元素排序,而且排序工作本身就能減少原數據序列的逆序數。三個元素排序後,選擇中間元素作爲分界線,爲了方便後續分組,我們需要先把中間元素移到邊上(比如倒數第二個元素,因排序後中間元素肯定不大於末尾元素),待原數據序列按分界線分爲小於分界線與不小於分界線兩組,再把中間元素移回到應該在的位置,便完成了元素分組,且以中間元素爲分界線。該過程圖示如下:
三數取中選擇樞紐
選擇好分界線後,就要以樞紐值爲分界,將數據序列分爲小於樞紐值和不小於樞紐值兩組,怎麼劃分呢?首先想到的是每個元素逐個與樞紐值相比較,小的放左邊、大於等於的放右邊,這種方法雖然簡單,但效率有點低且需佔用額外的空間。有沒有更高效的序列劃分方法呢?

回想下希爾排序比插入排序效率高的原因,序列元素執行遠距離交換比執行相鄰交換更高效(也即遠距離元素交換一次能減少超過一個逆序數,相鄰元素交換一次只能減少一個逆序數),我們利用這個技巧,可以很容易想到,使用兩個遊標(或理解爲哨兵)分別從序列兩端向中間進發,左邊的遊標停在第一個大於等於分界線的元素處,右邊的遊標停在第一個小於分界線的元素處,如果左右兩個遊標沒有相遇,則交換兩個遊標指向的元素,直到兩個遊標相遇,便完成了元素分組。最後再把樞紐值放到左右遊標相遇處,便完成了以樞紐值爲分界線,左邊的元素全部小於樞紐值,右邊的元素全部大於等於樞紐值。該過程圖示如下:
序列以樞紐值劃分圖示
按照上面的邏輯編寫選擇樞紐值,並以樞紐值作爲分界線將數據序列劃分爲左右兩組的函數實現代碼如下:

// algorithm\sort.c

int partition(int *data, int left, int right)
{
    int mid = left + (right - left) / 2;
    if(data[left] > data[mid])
        swap_data(&data[left], &data[mid]);
    if(data[left] > data[right])
        swap_data(&data[left], &data[right]);
    if(data[mid] > data[right])
        swap_data(&data[mid], &data[right]);

    swap_data(&data[mid], &data[right-1]);
    int i = left + 1, j = right - 2, pivot = right - 1;
    
    while (true)
    {
        while (data[i] < data[pivot])
            i++;

        while (j > left && data[j] >= data[pivot])
            j--;
        
        if(i < j)
            swap_data(&data[i], &data[j]);
        else
            break;
    }
    
    if(i < right)
        swap_data(&data[i], &data[pivot]);

    return i;
}

void swap_data(int *a, int *b)
{
    if(*a != *b)
    {
        int temp = *a;
        *a = *b;
        *b = temp;
    }
}

上面的函數實現代碼中需要提醒的是,左右兩個遊標i和j要注意不能訪問越界,i初值爲left+1,條件data[i] < data[pivot]保證了i的值不會大於right-1;j初值爲right-2,條件data[j] > data[pivot]可以保證j的值不會小於left(因爲data[left] <=data[pivot] ),但我們不能把data[j] > data[pivot]作爲判斷條件,假如左右遊標指向的元素都等於樞紐值且左右遊標未相遇,就會導致左右遊標不能前進而陷入死循環。解決方案之一是把條件判斷式改爲data[j] >= data[pivot],就可以解決該問題,但就不能保證遊標訪問不越界了(假如data[left] ==data[pivot]),需要再加上游標邊界j > left。

實現了數據序列按分界線分割處理後,接下來就可以交給遞歸完成序列的後續處理了。遞推公式類似於歸併排序,將序列左右邊界分割,跟歸併排序的二等分不同的是,我們需要按照分界線劃分,分界線的遊標就是前面分割函數partition()的返回值i,以i爲分界線將數據序列分割爲左右兩個子序列,這就是快速排序的遞推公式。遞歸邊界跟歸併排序只剩一個元素的做法也略有不同,不管是三數取中還是按中位數分組,一般至少包含三個元素,我們就以少於三個元素的情形作爲遞歸邊界,假如剩下兩個元素逆序則對其交換,否則直接返回即可。按照這個邏輯編寫快速排序函數的實現代碼如下:

// algorithm\sort.c

void quick_sort(int *data, int left, int right)
{
    if(right - left <= 1)
    {
        if(right - left == 1 && data[left] > data[right])
            swap_data(&data[left], &data[right]);
  
        return;
    }

    int divide = partition(data, left, right);
    quick_sort(data, left, divide - 1);
    quick_sort(data, divide + 1, right);
}

可以看出,快速排序也有尾調用的特點,可以省去自底向上的迴歸過程,這也是快速排序比歸併排序在工程中更常用、平均效率更高的原因之一。

快速排序的時間複雜度跟分界線或樞紐值的選擇有很大關係,前面分析過了,最壞情況下分界線或樞紐值總是選成了該數據序列的最大值或最小值,此時快速排序蛻化成了選擇排序,需要分割N-1次,所以最壞情況時間複雜度爲O(N2);最好情況下分界線或樞紐值總是選成了該數據序列的中位數,每次都分爲大小相等的兩個子序列,需要分割logN次,所以最好情況時間複雜度爲O(N * logN)。

快速排序平均情況下,分界線或樞紐值不會總選到該數據序列的中位數,但被分隔開的兩個子序列,左邊元素更多與右邊元素更多的概率基本相等,多次分割平均起來可以接近最好情況,即平均情況時間複雜度也是O(N * logN)。雖然跟歸併排序的平均時間複雜度相同,因爲節省了額外的空間開銷和數組元素複製開銷,且省去了迴歸過程,在數據量較大時,快速排序平均比歸併排序快兩到三倍,因此快速排序比歸併排序更常用。

在數據量較小或接近有序時,快速排序的效率還是比不上插入排序,因此在工程中,常把快速排序與插入排序的優點結合起來,避免快速排序陷入比較壞的情形,比如可以對快速排序再次進行如下封裝:

// algorithm\sort.c

void quicksort(int *data, int n)
{
    if(n < 15)
        insert_sort(data, n, 1, 1);
    else
        quick_sort(data, 0, n - 1);
}

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);
}

以一百萬隨機數序列排序爲例,對比歸併排序與快速排序的運行時間如下圖示:
歸併排序與快速排序運行時間

2.1 C標準庫函數qsort

C語言標準庫爲排序算法只提供了一個接口函數,該排序函數在數據規模比較大時便是以快速排序算法實現的,由此可見快速排序的重要性。下面給出該接口函數的聲明:

// <stdlib.h>

/* quick sort function api
 *
 * ptr: A pointer to an array to be sorted
 * count: The number of elements in an array
 * size: The byte size of each element of the array
 * comp: Compare functions. 
 		 If the first parameter is less than the second, a negative integer value is returned; 
		 if the first parameter is greater than the second, a positive integer value is returned; 
		 if the two parameters are equal, zero is returned.
 		 The signature of the comparison function should be equivalent to the following:
 		 	int CMP (const void *a, const void *b);
 		 The function must not modify the objects passed to it, and must return consistent results 
 		 when comparing the same objects, regardless of their position in the array.
 * return: Zero on success and non-zero if a run-time constraint violation is detected
 */
void qsort( void *ptr, size_t count, size_t size,
            int (*comp)(const void *, const void *) );

C語言排序函數qsort的使用示例如下:

// algorithm\sort.c

#include <stdlib.h>

int compfunc(const void *a, const void *b)
{
    const int arg1 = *(const int *)a;
    const int arg2 = *(const int *)b;
    
    if(arg1 < arg2)
        return -1;
    if(arg1 > arg2)
        return 1;      
    return 0;
}

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

    qsort(data, MAX_COUNT, sizeof(int), compfunc);
  
    validate_data(data, MAX_COUNT);

    free(data);
    return 0;
}

三、二分查找

我們之所以對序列元素進行排序,其中一個重要原因是爲了方便日後的查找,如果序列是無序的,查找某個元素是否存在就需要遍歷整個序列,也即時間複雜度爲O(N)。假如序列已經排好了順序,從中間隨機選擇一個元素與我們要查找的目標元素值相比較,根據比較結果我們就可以知道應該向前查找還是向後查找,我們查閱字典就是使用類似的邏輯。

從歸併算法可以瞭解,每次從序列中選擇一個元素將序列分爲左右兩部分,如果每次將序列二等分,將原序列分割爲只剩一個元素的序列需要的分割次數最少。二分查找算法也是使用類似的邏輯,每次選擇序列中間的元素跟要查找的目標元素比較,假如原序列按非降序排列,中間元素值小於要查找的目標元素值,則從後一半序列中再去中間元素去比較,直到中間元素值與目標元素值相等,或者序列分割到盡頭仍不相等,則返回結果。

二分查找雖然也是將原問題不斷分解爲小問題,但每次只選擇其中一個小問題求解,也即原問題的解等價於被分解的其中一個小問題的解,不涉及所有子問題解的合併,所以算是減治算法。按照這個邏輯編寫二分查找的實現代碼如下:

// algorithm\search.c

int binary_search(int *data, int left, int right, int target)
{
    if(left > right || target < data[left] || target > data[right])
        return -1;

    int mid = left + (right - left) / 2;
    if(target < data[mid])
        return binary_search(data, left, mid - 1, target);
    else if(target > data[mid])
        return binary_search(data, mid + 1, right, target);
    else
    	return mid;
}

上面的函數查找序列中是否存在元素target,若不存在則返回-1,若存在則返回與元素target相等的元素下標(即在序列中的位置)。

假如序列中有重複的元素,也即等於要查找的目標元素值target的元素不止一個,上面的函數就返回第一個等於target的元素下標,該下標可能既不是序列中第一個等於target的元素下標,也不是最後一個等於target的元素下標。如果我們想返回序列中第一個等於target的元素下標該怎麼辦呢?

二分查找要返回第一個符合條件的元素下標,就不能在查找到符合條件的元素後直接返回了,而需要繼續向前查找,直到分割到子序列的盡頭爲止。原先data[mid]等於目標值直接返回的分支就需要與大於目標值的分支合併(假如data[mid]等於目標值,則第一個符合條件的元素必然在[left, mid]區間內);增加遞歸邊界即序列左右邊界相等時,根據與目標值的比較結果返回。按照這個邏輯,修改二分查找的實現代碼如下:

// algorithm\search.c

int binary_search(int *data, int left, int right, int target)
{
    if(left > right || target < data[left] || target > data[right])
        return -1;
    if(left == right)
    {
        if(target == data[left])
            return left;
        else
            return -1;
    }

    int mid = left + (right - left) / 2;
    if(target <= data[mid])
        return binary_search(data, left, mid, target);
    else if(target > data[mid])
        return binary_search(data, mid + 1, right, target);
}

上面的二分查找函數不僅可以判斷序列中是否存在等於目標值的元素(不存在則返回-1),當遇到序列中多個元素均等於目標值時,可以返回序列中第一個等於目標值的元素下標。假如要返回序列中最後一個等於目標值的元素下標,只需要把原先data[mid]等於目標值直接返回的分支合併到小於目標值的分支(假如data[mid]等於目標值,則最後一個符合條件的元素必然在[mid, right]區間內)。

前面介紹的二分查找依賴的是順序表結構,需要藉助數組O(1)的隨機訪問效率才能實現,那麼能否依賴鏈式表結構實現呢?不管是單向鏈表還是雙向鏈表,隨機訪問的時間複雜度都是O(n),都無法實現O(logn)的二分查找效率。

還記得前面介紹的跳躍鏈表嗎?通過構建多級索引層,也是可以實現O(logn)時間的二分查找的,只不過需要額外佔用O(n)的內存空間,這是一個典型的以空間換時間的技巧。

跳錶比數組實現的二分查找有什麼優勢呢?再回顧下順序表與鏈式表的優缺點對比,很容易想到,跳錶並不需要連續的內存地址空間,而且支持O(1)時間插入、刪除一個數據,是一種高效的動態數據結構;數組則需要連續的內存地址空間,插入、刪除一個數據需要O(n)時間,並不適合數據的動態更新,算是一種高效的靜態數據結構。

3.1 查找第K大的元素值

二分查找借鑑歸併排序的等分技巧可以快速完成在有序數據序列中查找某個目標元素的任務。很多時候,對大規模數據排序的成本比較高,如果只想知道數據的大概分佈,比如該組數據的中位數是多少?前20%元素與後80%元素的分界線是多少?有沒有可以不用排序就能達成任務的算法呢?

再回顧下快速排序的劃分技巧,partition()正好可以實現將一組數據分割爲兩部分,並返回分界線的功能,我們可以藉助快速排序的partition()函數,再結合二分查找的技巧,直接獲得無序數據序列的第K個大的元素值。按照上述邏輯,在一個無序數據序列中查找第K大的元素值的實現代碼如下:

// algorithm\search.c

int searth_Kth(int *data, int left, int right, int k)
{
    if(right - left <= 1)
    {
        if(right - left == 1 && data[left] > data[right])
            swap_data(&data[left], &data[right]);
        
        if(left == k - 1)
            return data[left];
        else if(right == k - 1)
            return data[right];
        else
            return -1;
    }

    int divide = partition(data, left, right);

    if(divide > k - 1)
        return searth_Kth(data, left, divide - 1, k);
    else if(divide < k - 1)
        return searth_Kth(data, divide + 1, right, k);
    else
        return data[k - 1];
}

由於前面快速排序是按照三數取中法進行的,對數據序列劃分到只剩下兩個元素時,需要在查找函數中特別處理。如果函數partition()中選擇樞紐值使用了隨機選擇法,就可以在序列劃分函數中處理只有一個元素的情況,可以省去在查找函數中的特別處理。

3.2 C標準庫函數bsearch

C語言標準庫也爲二分查找提供了一個函數接口,該函數接口的聲明如下:

// <stdlib.h>

/* binary search function api
 *
 * key: A pointer to the element to be searched
 * ptr: A pointer to the array to verify
 * count: The number of elements in an array
 * size: The byte size of each element of the array
 * comp: Compare functions. 
 		 If the first parameter is less than the second, a negative integer value is returned; 
		 if the first parameter is greater than the second, a positive integer value is returned; 
		 if the two parameters are equal, zero is returned.
 		 The signature of the comparison function should be equivalent to the following:
 		 	int CMP (const void *a, const void *b);
 		 The function must not modify the objects passed to it, and must return consistent results 
 		 when comparing the same objects, regardless of their position in the array.
 * return: Points to a pointer that is equivalent to the *key, 
 			and returns a null pointer when an element is not found.
 */
void* bsearch( const void *key, const void *ptr, size_t count, size_t size, 
				int (*comp)(const void*, const void*) );

需要指出的是,C標準庫提供的函數bsearch只是返回查找到的元素指針,如果想獲得元素下標,只需要將該指針所指地址減去序列首地址即可。

由於bsearch提供了供我們自定義的比較函數comp,我們還可以使用結構體作爲序列元素,以結構體中的某一成員作爲排序依據和查找目標,待查找到並返回與*key比較相等的指針後,就可以獲得該元素的其它成員值。舉例如下:

// algorithm\search.c

#include <stdlib.h>
#include <stdio.h>
 
struct data {
    int nr;
    char *value;
} dat[] = {
    {1, "Foo"}, {2, "Bar"}, {3, "Hello"}, {4, "World"}
};

 
int data_cmp(const void *lhs, const void *rhs) 
{
    const struct data *const l = (const struct data *)lhs;
    const struct data *const r = (const struct data *)rhs;
 
    if (l->nr < r->nr)
        return -1;
    else if (l->nr > r->nr)
        return 1;
    else
        return 0;
}
 
int main(void) 
{
    struct data key = { .nr = 3 };
    struct data const *res = bsearch(&key, dat, sizeof(dat) / sizeof(dat[0]),
                                     sizeof(dat[0]), data_cmp);
    if(res)
        printf("\nNo %d: %s\n", res->nr, res->value);
    else
        printf("\nNo %d not found\n", key.nr);
    
    return 0;
}

上述自定義比較函數,用來進行結構體排序的方法,也可以用在前面介紹的qsort函數中,來實現結構體按某成員排序的目的(也可以在主排序成員相等時,再選擇第二成員作爲輔助排序依據)。

上面代碼中const修飾符主要用於保護被修飾的變量,防止其被意外修改,也即被const修飾的變量就變成了只讀變量。假如被const修飾的變量包含取值運算符*,需要區分const保護的是指針指向的值還是指針變量本身。如果const在取值運算符左側則修飾的是指針指向的值,如果const在取值運算符右側則修飾的是指針變量本身,如果想要同時保護指針指向的值和指針變量本身,則需要在取值運算符兩側都使用const修飾。

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

更多文章:

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