Chapter3——常用的排序和查找算法

一.排序


1.冒泡排序

    對於冒泡排序的規範性定義可以參考維基百科:冒泡排序,下面冒泡排序的算法過程引自維基百科:

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

代碼實現:

#include <iostream>
using namespace std;
const int MAXSIZE = 10;
void bubbleSort(int a[]);

int main() {
    int a[] = {1, 3, 5, 7, 9, 2, 4, 10, 8, 6};
    bubbleSort(a);
    for (int i = 0; i < MAXSIZE; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
    return 0;
}

void bubbleSort(int a[]) {
    int temp;
    for (int i = 0; i < MAXSIZE; i++) {         //比較的趟數
        for (int j = 0; j < MAXSIZE - i - 1;j++) {  //每趟比較的次數
            if (a[j + 1] < a[j]) {
                temp = a[j];
                a[j] = a[j + 1];
                a[j + 1] = temp;
            }
        }
    }
}

    時間複雜度爲O(n2) ,空間複雜度爲O(1) ,是穩定排序。由於冒泡排序比較簡單,故這裏不再累述,更多可參考維基百科或者網絡上的博客。下面是維基百科上的冒泡排序示意圖:
冒泡排序


2.快速排序

    對於快速排序的規範性定義可以參考維基百科:快速排序快速排序採用“分而治之、各個擊破”的觀念。下面快速排序的算法過程引自維基百科:

1.從數列中挑出一個元素,稱爲”基準”(pivot)(一般情況下選擇第一個數作爲基準);
2.重新排序數列,所有比基準值小的元素擺放在基準前面,所有比基準值大的元素擺在基準後面(相同的數可以到任何一邊)。在這個分區結束之後,該基準就處於數列的中間位置。這個稱爲分區(partition)操作。
3.遞歸地(recursively)把小於基準值元素的子數列和大於基準值元素的子數列排序。

    從以上快速排序的過程可知,快速排序的核心在於分治法 + “基準”數歸位,關於分治法的定義可參考維基百科:對於快速排序的規範性定義可以參考維基百科:分治法

在計算機科學中,分治法是建基於多項分支遞歸的一種很重要的算法範式。字面上的解釋是“分而治之”,就是把一個複雜的問題分成兩個或更多的相同或相似的子問題,直到最後子問題可以簡單的直接求解,原問題的解即子問題的解的合併。

    而對於第二步,一次分區操作(一趟快速排序)該如何實現呢?下面是嚴蔚敏《數據結構》的做法,也是快速排序的經典做法:

1.設置兩個指針low和high,它們的初值分別爲low和high(實參傳來的參數)。
2.設”基準”記錄的關鍵字爲key,首先從high所指位置起向前搜索找到第一個關鍵字小於key的記錄和key互相交換,然後從low所指位置起向後搜索,找到第一個關鍵字大於key的記錄和key互相交換。
3.重複1、2,直至low=high。

具體實現上述算法時,每交換一對記錄需進行3次記錄移動(賦值)的操作,但實際上對key賦值的操作是多餘的,原因是只有在一趟排序結束時,即low=high的位置纔是”基準”的最終位置。因此可暫存”基準”值,排序過程中只做low或high指向元素的單向移動,直到一趟排序結束後再將”基準”值移到正確的位置上。

以一個例子爲例:

序列 2 3 5 4 8 1 6 7 共8個元素
初始狀態: 2 3 5 4 8 1 6 7     2(選擇 2 作爲基準值),low = 0, high = 7
1 3 5 4 8 __ 6 7     2 high向左掃描,1 < 2,將1填入low所指的位置。
此時low = 0, high = 5
1 __ 5 4 8 3 6 7     2 low向右掃描,3 > 2,將3填入high所指的位置。
此時low = 1, high = 5
1 __ 5 4 8 3 6 7     2 high向左掃描,掃至high==low,退出循環。
此時low = 1,high = 1
最後,將基準元素填入low==high處,得到一趟排序後的結果
1 2 5 4 8 3 6 7

完整代碼實現:

#include <iostream>
using namespace std;
const int MAXSIZE = 8;
void quickSort(int low, int high, int a[]);
int Partition(int low, int high, int a[]);

int main() {
    int a[] = {2, 3, 5, 4, 8, 1, 6, 7};
    quickSort(0, MAXSIZE - 1, a);
    for (int i = 0; i < MAXSIZE; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
    return 0;
}

void quickSort(int low, int high, int a[]) {
    int keyPos;
    if (low < high) {
        keyPos = Partition(low, high, a);
        quickSort(low, keyPos - 1, a);              //遞歸的排序左邊
        quickSort(keyPos + 1, high, a);             //遞歸的排序右邊
    }   
}

//一趟快速排序過程
int Partition(int low, int high, int a[]) {
    int key = a[low];                         //基準元素
    while (low < high) {
        while (low < high && a[high] >= key) {
            high--;
        }
        a[low] = a[high];
        while (low < high && a[low] <= key) {
            low++;
        }
        a[high] = a[low];
    }
    a[low] = key;
    return low;
}

    平均複雜度爲O(nlog2n) ,最壞複雜度爲O(n2) ,以上實現方式最壞空間複雜度爲O(n) ,是不穩定的排序。由於快速排序的實現方式有很多種,上面介紹的是比較權威也比較常用的一種樸素實現方式(未優化),更多可參考維基百科或者網絡上的博客。下面是維基百科上的快速排序示意圖:
快速排序
關於快速排序的兩點小結:
1.爲什麼快速排序會比較快?(相對於冒泡排序而言)
    從直觀上理解,快速排序每趟排序確定一個元素的最終位置(“基準”元素),快速排序每趟排序後,把比基準元素小的元素都放在了基準元素左邊,比基準元素大的元素都放在了基準元素右邊,並且快速排序的元素比較是”跳躍”式的,相對於冒泡排序能避免不少無效比較,而冒泡排序雖每趟排序也確定一個元素的最終位置,但是該元素的前面均是比該元素小的元素,而後面爲空,或者是已確定位置的元素,直觀說來,冒泡排序明顯是”太偏”了。更多理解可參考:
快速排序爲什麼快?
快速排序的運行時間並不穩定,憑什麼被命名作「快速」排序?
2.什麼時候快速排序會退化到O(n2) 的時間複雜度?
    當元素基本有序或基本逆序時,快速排序會退化到O(n2) 的時間複雜度。原因在於快速排序的核心在於分治策略。而當元素基本有序或基本逆序時,快速排序每趟排序的分區操作使得左子區間和右子區間的長度爲0,這樣的話分治策略的表現最差。相對而言,當每趟排序的分區操作使得左子區間和右子區間的長度越相近,快速排序的表現越佳。


3.桶排序

    對於桶排序的規範性定義可以參考維基百科:桶排序,下面桶排序的算法過程引自維基百科:

1.設置一個定量的數組當作空桶子。
2.尋訪序列,並且把項目一個一個放到對應的桶子去。
3.對每個不是空的桶子進行排序。
4.從不是空的桶子裏把項目再放回原來的序列中。

    由於桶排序不是基於比較的排序,本質上就是以時間換空間。並且桶排序實現並不難,故完整代碼實現如下:

#include <iostream>
using namespace std;
const int MAXSIZE = 100;
void bucketSort(int a[], int arraySize);
int bucket[MAXSIZE];
int main() {
    int a[] = {89, 98, 12, 23, 45, 55, 89, 19};
    bucketSort(a, 8);
    return 0;
}

void bucketSort(int a[], int arraySize) {
    for (int i = 0; i < arraySize; i++) {
        bucket[a[i]]++;
    }
    for (int i = 0; i < MAXSIZE; i++) {
        if (bucket[i]) {
            cout << i << " ";
        }
    }
    cout << endl;
}

    時間複雜度爲O(n+k) ,空間複雜度爲O(n) (n爲元素上限值,k爲元素個數)。
關於桶排序的兩點小結:
1.桶排序適用於數據範圍不大,但數據規模較大的數據排序,如成績排序。但這也是桶排序的缺點,如果數據量不大或者數據範圍很大的話,那麼桶排序則不適用。
2.桶排序空間換時間的思想很重要,很多時候會以一定空間上的犧牲以達到效率上的提升。


4.快速排序的一個簡單應用

應用:查找大量無序元素中第k 大的數
具體問題描述:N 個整數Xi ,然後指定一個整數k ,找出裏面第k 大的數字。
輸入格式:

第1行 兩個整數n,k 以空格隔開;
第2行 有N 個整數(可出現相同數字,均爲隨機生成),同樣以空格隔開。
數據規模:0<n5106,0<kn,1Xi108

樣例說明:

Sample1:
5 2
5 4 1 3 1
輸出:4
Sample2:
5 2
5 5 4 4 4
輸出:5
Sample3:
5 3
5 5 4 4 3
輸出:4

思路分析:
    由於是找出無序元素中第k 大的數,那麼很容易想到先逆序排序,然後直接取第k1 個元素即可。這樣做的話,時間複雜度是O(nlog2n) ,但是仔細想想,題目只是要求找出無序元素中第k 大的數,需要”大動干戈”的對全部元素排序,然後僅僅只取一個元素嗎?
    回顧一下快速排序的過程,快速排序的每一趟先確定一個基準數,然後再將這個基準數歸位。試想,如何確定該基準數位置的呢?通過以上快速排序過程可知,當low指針和high指針相遇時,low指針與high指針相遇的位置,即是基準數的最終位置。那麼這個位置代表什麼呢?

對於升序序列,例如一趟快速排序後的結果爲:
3 2 4 5 8 7 9
那麼5位於a[3]的位置,也正說明5是數組中第4小的數(下標從0開始)
對於降序序列,例如一趟快速排序後的結果爲:
8 9 7 6 3 2 5
那麼6位於a[3]的位置,也正說明6是數組中第4大的數(下標從0開始)

    那麼有了以上的分析思路可知,每趟快速排序確定一個第(low+1)大的數(以降序爲例),在這個數左邊都是比它大的數,右邊都是比它小的數。而後快速排序的過程是什麼?

遞歸地(recursively)把小於基準值元素的子數列和大於基準值元素的子數列排序。

    但是,是否需要把左右兩邊的子數列都排序?從以上分析可知,不必,若我要找的是第k 大的數,如果一趟快速排序後確定基準數的位置low<k1 ,說明要找的數在low的右邊,因此再遞歸的把右子數列排序即可,若low>k1 ,同理遞歸的把左子數列排序即可。而如果low==k1 ,說明已找到要找的數,直接輸出即可。因此總結以上思路分析如下:

1.每次選取第一個元素(實際應用可採用隨機算法隨機選擇基準元素以提高程序的運行效率)爲基準元素,然後通過Partition() 的分區操作確定該基準數在數列中的位置low

2.

  • low<k1 ,遞歸的將low的右子數列排序
  • low>k1 ,遞歸的將low的左子數列排序
  • low==k1 ,直接輸出a[low]

故完整代碼實現如下:

#include <iostream>
using namespace std;
const int MAXSIZE = 5e6 + 100;
void quickSort(int low, int high, int a[], int k);
int Partition(int low, int high, int a[]);
int a[MAXSIZE];

int main() {
    int n, k;
    std::ios::sync_with_stdio(false);
    while (cin >> n >> k) {
        for (int i = 0;i < n;i++) {
            cin >> a[i];
        }
        quickSort(0, n - 1, a, k - 1);
    }
    return 0;
}

void quickSort(int low, int high, int a[], int k) {
    int keyPos;
    if (low < high) {
        keyPos = Partition(low, high, a);
        if (keyPos < k) {
            quickSort(keyPos + 1, high, a, k);
        } else if (keyPos > k) {
            quickSort(low, keyPos - 1, a, k);
        } else {
            cout << a[keyPos] << endl;
        }
    }   
}

//一趟快速排序過程
int Partition(int low, int high, int a[]) {
    int key = a[low];                         //基準元素
    while (low < high) {
        while (low < high && a[high] <= key) {
            high--;
        }
        a[low] = a[high];
        while (low < high && a[low] >= key) {
            low++;
        }
        a[high] = a[low];
    }
    a[low] = key;
    return low;
}

    期望的時間複雜度爲O(n) ,爲了得到期望的時間複雜度爲線性時間複雜度,快速排序中的基準元素的選擇一般使用隨機數。
    當然,解決這個問題不止上面介紹的這一種方法,更多理解可參考:
尋找無序數組中第k大的數


二.查找


1.二分查找

    二分查找也稱二分搜索算法,對於二分搜索算法的規範性定義可參考維基百科:二分搜索算法,二分搜索算法是一種在有序數組中查找某一特定元素的搜索算法。下面二分搜索算法的過程引自維基百科:

1.搜索過程從數組的中間元素開始,如果中間元素正好是要查找的元素,則搜索過程結束;
2.如果某一特定元素大於或者小於中間元素,則在數組大於或小於中間元素的那一半中查找,而且跟開始一樣從中間元素開始比較。
3.如果在某一步驟數組爲空,則代表找不到。這種搜索算法每一次比較都使搜索範圍縮小一半。

算法步驟(僞代碼輔以文字描述,來自維基百科):
給予一個包含n個帶值元素的數組A 或是記錄A0,,An1 ,使A0An1 ,以及目標值T,還有下列用來搜索TA 中位置的子程序。

1.令L0 ,R爲n1
2.如果L>R ,則搜索以失敗告終。
3.令m(中間值元素)爲(L+R)/2
4.如果Am<T ,令Lm+1 並回到步驟二。
5.如果Am>T ,令Rm1 並回到步驟二。
6.當Am=T ,搜索結束;回傳值m

圖示說明:
二分查找
故完整代碼實現如下:

#include <iostream>

using namespace std;
int binarySearch(int a[], int low, int high, int key);

int main(int argc, char *argv[]) {
    int key;
    int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
    cout << "Please input your search key: " << endl;
    cin >> key;
    int keyPos = binarySearch(a, 0, 8, key);
    if (keyPos == -1) {
        cout << key << " is not in this array" << endl;
    } else {
        cout << key << " is in the index of " << keyPos << endl;
    }
}

int binarySearch(int a[], int low, int high, int key) {
    while (low <= high) {
        int mid = (low + high) / 2;
        if (a[mid] == key) {
            return mid;
        } else if(a[mid] > key) {
            high = mid - 1;
        } else {
            low = mid + 1;
        }
    }
    return -1;
}

遞歸過程實現:

#include <iostream>

using namespace std;
int binarySearch(int a[], int low, int high, int key);

int main(int argc, char *argv[]) {
    int key;
    int a[] = {1, 2, 3, 4, 5, 6, 7, 8, 9};
    cout << "Please input your search key: " << endl;
    cin >> key;
    int keyPos = binarySearch(a, 0, 8, key);
    if (keyPos == -1) {
        cout << key << " is not in this array" << endl;
    } else {
        cout << key << " is in the index of " << keyPos << endl;
    }
}

int binarySearch(int a[], int low, int high, int key) {
    if (low > high) {    //遞歸終止條件
        return -1;
    }
    int mid = (low + high) / 2;
    if (a[mid] == key) {
        return mid;
    } else if (a[mid] < key) {
        return binarySearch(a, mid + 1, high, key);
    } else {
        return binarySearch(a, low, mid - 1, key);
    }
}

    時間複雜度爲O(log2n) ,空間複雜度:迭代爲O(1) ,遞歸爲: O(log2n)
總結:
1.二分查找的過程可用二叉樹來描述,把當前查找區間的中間位置上的結點作爲根,左子表和右子表中的結點分別作爲根的左子樹和右子樹。由此得到的二叉樹,稱爲描述二分查找的判定樹(Decision Tree)或比較樹(Comparison Tree)。
注意:
判定樹的形態只與表結點個數n相關,而與輸入實例中R[1..n].keys的取值無關。
【例】具有11個結點的有序表可用下圖所示的判定樹來表示。
判定樹

  • 圓結點即樹中的內部結點。樹中圓結點內的數字表示該結點在有序表中的位置。
  • 外部結點:圓結點中的所有空指針均用一個虛擬的方形結點來取代,即外部結點。
  • 樹中某結點i與其左(右)孩子連接的左(右)分支上的標記”<”、”(“、”>”、”)”表示:當待查關鍵字K
int mid = (low + high) / 2;   //可能存在加法溢出的問題

妥當寫法應該是:

int mid = low + (high - low) / 2;

更多可參考:
二分查找有幾種寫法?它們的區別是什麼?


2.哈希查找

(尚未截稿)

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