【數據結構與算法分析(一)】排序

本系列博客將要總結最基本的一些數據結構與算法分析的問題,說到算法,排序顯然是最基礎的,但並不是所有人都理解的很好。本文也僅僅是個人的一些總結,歡迎指正。

排序一般分爲內排序外排序,所有待排數據都在內存的即爲內排序,反之爲外排序。而我這裏將着重討論內排序

內排序又分爲基於比較的排序和非基於比較的排序。

基於比較的排序

交換類排序

冒泡排序

作爲C語言入門的一些教材,經常會以冒泡作爲入門的例子。
全文所貼代碼的swap函數都如下:

void Swap( int& a, int& b )
{
    int c = a;
    a = b;
    b = c;
}

冒泡(也有極個別叫沉石)的原理是相鄰的數字兩兩進行比較,按照從小到大或者從大到小的順序進行交換,這樣一趟過去後,最大或最小的數字被交換到了最後一位,然後再從頭開始進行兩兩比較交換,直到倒數第二位時結束,其餘類似。

例子說明:
輸入: 5, 9, 4, 2
第一趟(外循環):
第一次比較(內循環):(5, 9), 4, 2 => 5, 9, 4, 2
第二次比較(內循環): 5, (9, 4), 2 => 5, 4, 9, 2
第三次比較(內循環): 5, 4, (9, 2 ) => 5, 4, 2, 9
第二趟(外循環):
第一次比較(內循環): (5, 4), 2, 9 => 4, 5, 2, 9
第二次比較(內循環): 4,( 5, 2), 9 => 4, 2, 5, 9
第三趟(外循環):
第一次比較(內循環): (4, 2), 5, 9 => 2, 4, 5, 9

根據上述例子可以得出最簡單的冒泡排序的寫法(代碼僅供參考):

void BubbleSort( int* pData, int n )
{
    for (int i = n - 1; i > 0; --i)
    {
        for (int j = 0; j < i; ++j)
        {
            if (pData[j + 1] < pData[j])    
            {
                Swap(pData[j + 1], pData[j]);     //小的值往上冒泡
            }
        }
    }
}

接下來我們可以思考一下怎麼優化這個算法,我們會發現,每次最後停止比較的地方,那之後的值其實已經穩定了。優化代碼如下,此處不細說,留給讀者琢磨。

\* bubble sort*\
void BubbleSort( int* pData, int n )
{
    int lastSwapPos = n - 1;
    for (int i = n - 1; i > 0; i = lastSwapPos)
    {
        lastSwapPos = 0;          
        for (int j = 0; j < i; ++j)
        {
            if (pData[j + 1] < pData[j])
            {
                Swap(pData[j + 1], pData[j]);
                lastSwapPos = j;
            }
        }
    }
}

冒泡是穩定的排序,一般沒有跳躍式的比較,也即相鄰數據的比較,都是穩定的排序,反之是不穩定的。

穩定排序: 保證排2個相等的數,在排序前後其相對位置不發生改變
比如: dict = {“1”: 5, “2”: 9, “3”:2, “4”: 4, “5”: 2 } 這樣的數據結構,根據value排序,如果出現了
dict = {“5”: 2, “3”:2, “4”: 4, “1”: 5, “2”: 9} , 即value都爲2,注意key,他們的位置發生了一次調換,我們認爲這樣的排序是不穩定的。接下來講了不穩定排序後,大家可以手動排序看看其區別。

冒泡的平均時間複雜度和最差時間複雜度都是 O(n2) , 空間複雜度是O(1) 。 適用於n 較小的情況。

快速排序

快排的原理是,通過一趟掃描將要排序的數據分割成獨立的兩部分,其中一部分的所有數據都比另外一部分的所有數據都要小,然後再按此方法對這兩部分數據分別進行快速排序,整個排序過程可以遞歸進行,以此達到整個數據變成有序序列。

此處即選一個pivot的值,讓比其小的割到其左邊,比其大的割到其右邊。一般選第一個值爲pivot。
例子說明:
輸入: 5, 9, 2, 4

step1:
取出5作爲pivot;
第一次比較: 5 > 4, 4, 9, 2, 5
第一次比較: 5 < 9, 4, 5, 2, 9
第三次比較: 5 > 2, 4, 2, 5, 9

step2: 對[4, 2] 和[9]分別再用上述方法排序

根據上述例子所寫代碼(代碼僅供參考):

void QuickSort( int* pData, int n )
{
    QSort(pData, 0, n-1);
}

void QSort( int* pData, int low, int high )
{
    if (low < high)
    {
        int pivot = Partition(pData, low, high);
        QSort(pData, low, pivot - 1);
        QSort(pData, pivot + 1, high);
    }
}

int Partition( int* pData, int low, int high )
{
    while (low < high)
    {
        while (low < high && pData[high] >= pData[low])
        {
            high--;
        }
        Swap(pData[high], pData[low]);
        while (low < high && pData[low] <= pData[high])
        {
            low++;
        }
        Swap(pData[high], pData[low]);
    }
    return low;
}

快排的平均時間複雜度是 O(nlogn) , 當爲有序數組時,退化爲O(n2) 。空間複雜度是O(1) 。 適用於n 較小的情況。是不穩定的排序。

插入類排序

插入排序

插入排序的原理是,每一步都將一個待排數據按其大小插入到已經排序的數據中的適當位置,直到全部插入完畢。

例子說明:
輸入: 5, 9, 2, 4

第一趟(外循環):
第一次比較(內循環): 9 > 5, 5, 9, 2, 4
第一趟(外循環):
第一次比較(內循環): 2 < 9, 5, 2, 9, 4
第二次比較(內循環): 2 < 5, 2, 5, 9, 4
第三趟(外循環):
第一次比較(內循環): 4 < 9, 2, 5, 4, 9
第二次比較(內循環): 4 < 5, 2, 4, 5, 9

代碼如下(僅供參考):

void InsertSort( int* pData, int n )
{
    for (int i = 1; i < n; ++i)
    {
        for (int j = i; j > 0; j--)
        {

            if (pData[j - 1] > pData[j])
            {
                Swap(pData[j - 1], pData[j]);
            }
            else
            {
                break;
            }
        }
    }
}

插排的平均時間複雜度和最差時間複雜度都是 O(n2) , 空間複雜度是O(1) 。 適用於n 較小的情況。是穩定的排序。

希爾(shell)排序

插入排序在序列基本有序的情況下可以獲得接近 O(n)的複雜度

其是直接插入排序的一種改進,增加了遞增量,分組比較。注意比較代碼,實際就是多了一層while,和一個遞增率incr,其它和快排完全一樣。
例子說明:
shell sort

void ShellSort( int* pData, int n )
{
    int incr = n;
    while (incr > 1)
    {
        incr = incr / 3 +1;
        for (int i = incr; i < n; ++i)
        {
            for (int j = i; j > incr -1; j -= incr)
            {
                if (pData[j - incr] > pData[j])
                {
                    Swap(pData[j - incr], pData[j]);
                }
                else
                {
                    break;
                }
            }
        }
    }
}

希爾排序的平均時間複雜度是 O(n2) , 最差時間複雜度是O(ns)s(1,2); 其中s是所選分組。空間複雜度是O(1) 。 是不穩定的排序。

選擇類排序

簡單選擇排序

簡單選擇排序的原理是直接從待排序數組裏選擇一個最小(或最大)的數字,每次都拿一個最小數字出來,順序放入新數組,直到全部拿完。
例子說明:
輸入: 5, 9, 4, 2

第一趟(外循環): min = 5
第一次比較(內循環): 9 > min: min 不變
第二次比較(內循環): 4 < min: min = 4
第二次比較(內循環): 2 < min: min = 2
交換: 2, 9, 4, 5
第二趟(外循環): min = 9
第一次比較(內循環): 4 < min: min = 4
第二次比較(內循環): 5 > min: min 不變
交換: 2, 4, 9, 5
第三趟(外循環): min = 9
第一次比較(內循環): 5 < min: min = 5
交換: 2, 4, 5, 9

代碼如下(僅供參考):

void SimpleSelectionSort( int* pData, int n )
{
    int lowIndex;
    for (int i = 0; i < n - 1; ++i)
    {
        lowIndex = i;
        for (int j = i + 1; j < n; ++j)
        {
            if ( pData[j] < pData[lowIndex] )  
            {
                lowIndex = j;
            }
        }
        Swap(pData[i], pData[lowIndex]);
    }
}

簡單選擇排序的平均時間複雜度和最差時間複雜度都是 O(n2) , 空間複雜度是O(1) 。適合n較小的情況。 是不穩定的排序。

堆排序

堆排序分爲建堆和調整堆兩部分。
首先有一些基礎概念:
堆:
這裏的堆(二叉堆),指得不是堆棧的那個堆,而是一種數據結構。
堆可以視爲一棵完全的二叉樹, 除了最底層之外,每一層都是滿的。堆可以利用數組來表示,每一個結點對應數組中的一個元素。

這裏寫圖片描述

最大堆(最小堆):
是優先隊列的一種。所謂最大堆,即每個父節點的元素都大於其子節點的元素。即堆頂是其最大值。
什麼是最大堆

節點與數組索引關係:
對於給定的某個結點的下標i ,可以很容易的計算出這個結點的父結點、孩子結點的下標:child(i)=2i+1

堆排序原理如下:

輸入:5, 9, 4, 2, 8, 3
step1: 初始化堆結構:
這裏寫圖片描述

step2: 構造最小值堆,先從最後一個非葉子節點開始調整堆結構,保證得到最小堆:
第一趟: 4 與 3 比較: 4 > 3, 故需調整:
這裏寫圖片描述
第二趟:9的葉子節點有兩個,其中2 < 8, 用2與9比較, 2 < 9, 故需調整:
這裏寫圖片描述
第三趟:5的葉子節點有兩個,其中2 < 3, 用2與5比較, 2 < 5, 故需調整:
至此堆構造完畢。

step3:調整堆,得到排序結果
交換堆頂的元素和最後一個元素,取出最後一個元素,重新調整堆結構,重複執行,直到將堆中元素全部取出。
這裏寫圖片描述
由根節點開始調整堆結構得到:
這裏寫圖片描述

最終得到 :
這裏寫圖片描述

代碼如下(僅供參考)【注意代碼是大頂堆】:

void HeapSort( int* pData, int n )
{
    //build heap
    for (int i = (n - 2)/2; i >= 0; i--)
    {
        SiftAdjust(pData, i, n - 1);
    }
    //sort
    for (int i = n - 1; i > 0 ; i--)
    {
        Swap(pData[i], pData[0]);  //swap heap top
        SiftAdjust(pData, 0, i - 1);   //adjust    //attention, not i
    }
}

void SiftAdjust( int* pData, int low, int high )
{
    for (int f = low, i = low * 2 + 1; i <= high; i = i*2 + 1)
    {
        if (i < high && pData[i + 1] > pData[i])
        {
            i++;
        }   //right child is greater than left child
        if (pData[f] < pData[i])
        {
            Swap(pData[f], pData[i]);
        }
        else
        {
            break;
        }
        f = i;
    }
}

堆排序的平均時間複雜度和最差時間複雜度都是 O(nlogn) , 空間複雜度是O(1) 。適合n較大的情況。 是不穩定的排序。

歸併排序

其實歸併排序是最易懂的排序。

這裏寫圖片描述

這張圖比較好的反應了整個歸併的迭代過程。
其原理是把原始數組分成若干子數組,對每一個子數組進行排序,繼續把子數組與子數組合並,合併後仍然有序,直到全部合併完,形成有序的數組。

代碼如下(僅供參考):

void MergeSort( int* pData, int n )
{
    MSort(pData, 0, n-1);
}

void MSort( int* pData, int low, int high )
{
    int mid;
    if (low < high)
    {
        mid = low + (high - low) / 2;
        MSort(pData, low, mid);
        MSort(pData, mid + 1, high);
        Merge(pData, low, mid, high);
    }
}

void Merge( int* pData, int low, int mid, int high )
{
    int* tmpArr = new int[high + 1];
    int i, j, k;
    for (i = low, j = mid + 1, k = low; i <= mid && j <= high; k++)   //attention: k = low, not k =0
    {
        if (pData[i] <= pData[j])
        {
            tmpArr[k] = pData[i];
            i++;
        }
        else
        {
            tmpArr[k] = pData[j];
            j++;
        }
    }
    for (; i <= mid; ++i)
    {
        tmpArr[k++] = pData[i];
    }
    for (; j <= high; ++j)
    {
        tmpArr[k++] = pData[j];
    }
    //
    for (int i = low; i <= high; ++i)
    {
        pData[i] = tmpArr[i];
    }
    delete []tmpArr;
}

歸併排序的平均時間複雜度和最差時間複雜度都是 O(nlogn) , 空間複雜度是O(1) 。適合n較大的情況。 是穩定的排序。

非基於比較的排序

非基於比較的排序一般都是通過空間開銷開平衡時間開銷的。需要注意它們的特定應用場景。

桶(箱)排序

桶排序是穩定的,消耗空間基本也是最多,大多數情況下也是最快的。
例子說明:
輸入:5, 19, 40, 23, 41, 35

step1: 計算桶個數:假定以10爲間隔,即每個桶可放10個元素,則需要 (41 - 5) / 10 + 1 = 4個桶
step1: 裝桶: 依次遍歷每個元素
(5 - 5 + 1) / 10 = 0 , 所以 第一個元素5 放到 0號桶中, 其它元素同理,最終得到
桶0: 5
桶1: 19, 23
桶1: 空的
桶3: 35, 40, 41
step2: 桶內排序(可採用快排之類):
桶0: 5
桶1: 19, 23
桶1: 空的
桶3:35, 40, 41,
step3: 將桶內元素按序取出排列:
5, 19, 23, 35, 40, 41

代碼如下(僅供參考):

struct Barrel
{
    int node[10];
    int count;
};

void BucketSort( int* pData, int n )
{
    int max = pData[0];
    int min = pData[0];
    for (int i = 1; i < n; ++i)
    {
        if (pData[i] > max)
        {
            max = pData[i];
        }
        if (pData[i] < min)
        {
            min = pData[i];
        }
    }
    int num = (max - min + 1) / 10 + 1;

    Barrel* pbarrels = (Barrel*)malloc(sizeof(Barrel)*num);
    memset(pbarrels, 0, sizeof(Barrel)*num);

    for (int i = 0; i < n; ++i)
    {
        int k = (pData[i] - min + 1) / 10;
        pbarrels[k].node[pbarrels[k].count] = pData[i];
        pbarrels[k].count++;
    }

    int pos = 0;
    for (int i = 0; i < num; ++i)
    {
        QSort(pbarrels[i].node, 0, pbarrels[i].count - 1);
        for (int j = 0; j < pbarrels[i].count; ++j)
        {
            pData[pos++] = pbarrels[i].node[j];
        }
    }
    free(pbarrels);    //attention: free not delete
}

複雜度分析:
假設有n個數字, 針對數字的範圍,我們分m個桶。
1. 掃描一遍裝箱: O(n),
2. 平均情況來看: 每個桶有 n/m 個元素,對每個桶排序的複雜度:(n/m) * log (n/m)
總時間複雜度: O(n + m * n/m * log n/m) = O(n + n * ( logn – logm ) )
桶排序是穩定的。當桶大小爲1時,浪費的空間最多,但是時間效率最高爲O(n)

計數排序

例子說明:
輸入: 2, 5, 3, 0, 2, 3, 0, 3
step1:
新建一個輔助數組C,大小爲 5 - 0 + 1 = 6。

step2: 統計i元素出現的個數

0 1 2 3 4 5
2 0 2 3 0 1

step3: 統計小於等於i元素出現的個數

0 1 2 3 4 5
2 2 4 7 7 8

step4: 遍歷每個元素,得到其對應有序序列的索引

0 1 2 3 4 5 6 7
- - - - - - - -

第一個元素2:根據step3的結果, 有序序列第4個位置爲2

0 1 2 3 4 5 6 7
- - - 2 - - - -

第而個元素5:根據step3的結果, 有序序列第8個位置爲5

0 1 2 3 4 5 6 7
- - - 2 - - - 5

依次類推,當第二齣現元素2的時候,位置由4往前挪一個:

0 1 2 3 4 5 6 7
- 0 2 2 - - - 5

最終結果爲:

0 1 2 3 4 5 6 7
0 0 2 2 3 3 3 5

代碼如下(僅供參考):

void SortTest::CountSort( int* pData, int n )
{
    int max = pData[0];
    int min = pData[0];
    for (int i = 1; i < n; ++i)
    {
        if (pData[i] > max)
        {
            max = pData[i];
        }
        if (pData[i] < min)
        {
            min = pData[i];
        }
    }

    int countSize = max - min + 1;

    int* countArr = new int[countSize];

    for (int i = 0; i < countSize; ++i)
    {
        countArr[i] = 0;
    }
    for (int i = 0; i < n; ++i)
    {
        countArr[pData[i] - min]++;
    }
    for (int i = 1; i < countSize; ++i)
    {
        countArr[i] += countArr[i - 1];
    }

    int* tmpData = new int[n];

    int value, pos; 
    for (int i = 0; i < n; ++i)    //attention
    //for(int i = n-1; i >= 0; i--)
    {
        value = pData[i];
        pos = countArr[value - min];
        tmpData[pos - 1] = value;
        countArr[value - min]--;
    }
    for (int i = 0; i < n; ++i)    //attention
    {
        pData[i] = tmpData[i];
    }


    delete []countArr;
    delete []tmpData;
}

時間複雜度分析:
分析代碼會發現,有關於輔助數組長度的for循環和元素總數的for循環,故爲O(n+k)。穩定排序。

基數(鴿巢)排序

在基數排序中,當k很大時,時間和空間的開銷都會增大(可以想一下對序列{8888,1234,9999}用基數排序,此時不但浪費很多空間,而且時間方面還不如比較排序。
原理類似桶排序,這裏總是需要10個桶(10進制數),多次使用。

首先以個位數的值進行裝桶,即個位數爲1則放入1號桶,爲9則放入9號桶,暫時忽視十位數。依次類推以高一位裝桶。基數排序分爲分發和收集兩部分。

例子說明:
輸入: 62, 14, 59, 88, 16
分配10個桶,桶編號爲0-9,以個位數數字爲桶編號依次入桶,變成下邊這樣
max = 88 故 總共只有 個位 和 十位

step1: 分發:按個位數大小入桶| 0 | 0 | 62 | 0 | 14 | 0 | 16 | 0 | 88 | 59 |
收集:62,14,16,88,59
step2: 分發:按十位數大小入桶 | 0 | 14,16 | 0 | 0 | 0 | 59 | 62 | 0 | 88 | 0 |
收集:14,16,59,62,88

代碼如下(僅供參考):

void SortTest::RadixSort( int* pData, int n )
{
    //detect 10 base
    int max = pData[0];
    int min = pData[0];
    //r base, d digit
    for (int i = 1; i < n; i++)
    {
        if( pData[i] > max)
        {
            max = pData[i];
        }
        if ( pData[i] < min )
        {
            min = pData[i];
        }
    }
    int r = 10;  //base
    int d = 0;

    while ( max > 0)
    {
        d++;
        max /= 10;
    }

    RSort(pData, n, r, d);
}

void SortTest::RSort( int* pData, int n, int r, int d )
{
    vector<vector<int>> linkList;
    for (int i = 0; i < r; ++i)
    {
        vector<int> list;
        linkList.push_back(list);
    }
    for (int i = 0; i < d; ++i)
    {
        Distribute(pData, n, r, i, linkList);
        Collect(pData, r, linkList);
    }
}

void SortTest::Distribute( int* pData, int n, int r, int i, vector<vector<int>>& list )
{
    int power = (int)pow(r, i);
    for (int k = 0; k < n; ++k)
    {
        int index = (pData[k] / power) % r;
        list[index].push_back(pData[k]);
    }
}

void SortTest::Collect( int* pData, int r, vector<vector<int>>& list )
{
    for (int i = 0, k = 0; i < r; ++i)
    {

        while (!list[i].empty())
        {
            pData[k++] = list[i][0];             
            list[i].erase(list[i].begin());              
            list[i].pop_back();
        }
    }
}

時間複雜度分析:平均和最差都是 O(logRB) B是(十進制0-9), R是(個十百千)
基數排序是穩定的。輔助空間同計數排序k+n。

至此,基本的一些排序算法已經全部學習完畢!

還有更多的排序,可參考 經典排序算法

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