【算法】計數排序、桶排序和基數排序詳解


01.計數排序、桶排序與基數排序

並不是所有的排序 都是基於比較的,計數排序和基數排序就不是。基於比較排序的排序方法,其復雜度無法突破\(n\log{n}\) 的下限,但是 計數排序 桶排序 和基數排序是分布排序,他們是可以突破這個下限達到O(n)的的復雜度的

1. 計數排序

概念

計數排序是一種穩定的線性時間排序算法。計數排序使用一個額外的數組C,使用 C[i] 來計算 i 出現的次數。然后根據數C來將原數組A中的元素排到正確的位置。

復雜度

計數排序的最壞時間復雜度、最好時間復雜度、平均時間復雜度、最壞空間復雜度都是O(n+k) 。n為元素個數,k為待排序數的最大值。

優缺點

計數排序不是比較排序,排序的速度優於任何比較排序算法。由於用來計數的數組C的長度取決於待排序數組中數據的范圍(等於待排序數組的最大值與最小值的差加1),這使得對於數組中數據范圍很大的數組,需要大量的時間和內存。(簡言之,不適於大范圍數組)

通俗地理解,例如有10個年齡不同的人,統計出有8個人的年齡比A小,那A的年齡就排在第9位,用這個方法可以得到其他每個人的位置,也就排好了序。當然,年齡有重復時需要特殊處理(保證穩定性),這就是為什么最后要反向填充目標數組,以及將每個數字的統計減去1的原因。算法的步驟如下:

  1. 找出待排序數組中的最大元素和最小元素。
  2. 統計數組中值為 i 的元素出現的次數,存入數組C的第 i 項。
  3. 對所有的計數累加(從C中第一個元素開始,每一項和前一項累加)。
  4. 反向填充目標數組:將每個元素 i 放在新數組的第 C[i] 項, 每放一個元素就將 C[i] 減去1.

C語言實現

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

void print_arr(int *arr, int n)
{
    int i;
    printf("%d", arr[0]);
    for (i = 1; i < n; i++)
    printf("%d", arr[i]);
    priintf("\n");
}

void counting_sort(int *ini_arr, int *sortrd_arr, int n)
{
    int *count_arr = (int *)malloc(sizeof(int) * 100);
    int  i, j, k;

    // 初始計數化數組
    for (k = 0; k < 100; k++)
        count_arr[k] = 0;
    
    // 步驟二, 計數
    for (i = 0; i < 100; i++)
        count_arr[i]++;
    
    // 步驟三, 對所有的計數累加(計算每個數的實際順序)
    for (k = 1; k < 100; k++)
        count_arr[k] += count_arr[k-1];
    
    // 步驟4, 反向填充數組
    for (j = n; j > 0; j--)
    {
        int elem = ini_arr[j - 1];          // 取待排序元素
        int index = count_arr[elem] - 1;    // 取待排序元素在有序數組中的序號
        sortrd_arr[index] = elem;           // 將待排序數組存入結果數組中
        count_arr[elem]--;                  // 修正排序結果,保證sorted_arr數組中元素的穩定性
    }
    /*
     * 上述句子也可以寫為: 
     *      sortrd_arr[--count_arr[ini_arr[j -1]]] = ini_arr[j -1];
     *
     */
        free(count_arr);
}

int main(int argc, char **argv) 
{
    int n =10;
    int i;
    int *arr = (int *)malloc(sizeof(int) * n);
    int *sorted_arr = (int *)malloc(sizeof(int) * n);
    srand(time(0));
    for (i = 0; i< n; i++)
    arr[i] = rand()%100;
    printf("Init array:");
    print_arr(arr, n);
    counting_sort(arr, sorted_arr, n);
    printf("Sorted_arr;");
    free(arr);
    free(sorted_arr);
    return 0;
}


2. 桶排序

概念

桶排序(Bucket sort)或所謂的箱排序,其工作原理是將陣列分到有限數量的桶里。每個桶再分別排序。桶排序是分布排序,不是比較排序,因而不受到比較排序 O(\(n\log{n}\))下限的影響。

復雜度

最壞時間復雜度是O(n^2), 平均復雜度是O(n+k),最壞空間復雜度是O(n*k)。

關鍵步驟

  1. 設置一個定量的陣列當作空桶子。
  2. 尋訪序列,並且把項目一個一個放到對應的桶子去。
  3. 對每個不是空的桶進行排序。可以在放入元素的時候進行插入排序,也可以在寫回的時候進行快速排序。
  4. 從不是空的桶子里把項目放回到原來的序列中。

算法實現

假設數據分布在[0, 100]之間,每個桶內部用鏈表表示,在數據入桶的同時插入排序。然后把各個桶中的數據合並。


#include <iterator>
#include <iostream>
#include <vector>

using namespace std;

const int BUCKET_NUM = 10;

// 數據結構的定義,explicit表示不允許構造函數發生隱式轉換
struct ListNode {
    explicit ListNode(int i = 0): mData(i), mNext(NULL){};
    ListNode *mNext;
    int mData;
};

ListNode *insert(ListNode *head, int val)
{
    ListNode dummyNode;                         // 節點指針
    ListNode *newNode = new ListNode(val);      //待插入的新節點
    ListNode *pre , *curr;
    dummyNode.mNext = head;                     // 將指針指向鏈表頭部
    pre = &dummyNode;                           // 設置臨時指針。pre是當前檢查元素的上一個元素
    curr = head;

    while (NULL != curr && curr->mData <= val)  // 末尾檢測
    {
        pre = curr;                             // 不斷向前循環,直到末尾或者找到不小於val的元素
        curr = curr -> mNext;       
    }

    newNode->mNext = curr;                      // 改變指針指向
    pre ->mNext =  newNode;
    return dummyNode.mNext;                     // 返回鏈表頭節點
}

ListNode *Merge(ListNode *head1, ListNode *head2)   // 將head2合並到head1
{
    ListNode dummyNode;
    ListNode *dummy = &dummyNode;               // 臨時指針
    while(NULL != head1 && NULL != head2)       // 循環直到末尾
    {
        if (head1->mData <= head2 ->mData)      // 類似於歸並排序
        {
            dummy->mNext = head1;
            head1 = head1 -> mNext;
        }else{
            dummy->mNext = head2;
            head2 = head2->mNext;
        }
        dummy = dummy->mNext;
    }
    if (NULL != head1) dummy->mNext = head1;
    if(NULL!=head2) dummy->mNext = head2;
    return dummyNode.mNext;
}

void BucketSort(int n,int arr[]){
	vector<ListNode*> buckets(BUCKET_NUM,(ListNode*)(0));

    // 將元素分配到桶
	for(int i=0;i<n;++i){
		int index = arr[i]/BUCKET_NUM;
		ListNode *head = buckets.at(index);
		buckets.at(index) = insert(head,arr[i]);
	}

    // 逐一合並各個桶
	ListNode *head = buckets.at(0);
	for(int i=1;i<BUCKET_NUM;++i){
		head = Merge(head,buckets.at(i));
	}
    //將排好序的元素寫回原數組
	for(int i=0;i<n;++i){
		arr[i] = head->mData;
		head = head->mNext;
	}
}

3. 基數排序

定義

基數排序是桶排序的擴充,也是一種分布排序。其原理是將整數按位數切割為不同的數字,然后按每個數分別比較。根據比較的方向,基數排序又可以分為MSD(從左到右)和LSD(從右向左)

LSD原理 將所有帶比較數值統一為同樣的數位長度,數位較短的前面補0,。然后,從最低位開始,進行一次排序,一直到最高位排序完成以后,數列就變成一個有序數列。其思想就是,將待排序數據中的每組關鍵字依次進行桶分配。

MSD原理
msd算法從左向右遍歷字符。其核心思想是分治,我們采用遞歸的方法來實現。原理如下:

  • 首先,使用鍵索引排序的方法對首字母進行排序,此時排好序的數組已經是首字母有序的數組,並且已經按照首字字母分好了組。
  • 按照分好的組,遞歸的對每個首字母對應的子數組進行排序。
  • 重復步驟二。

復雜度

最壞時間復雜度是O(kN),最壞空間復雜度是O(k+N);

LSD實現

/**
 * 基數排序:C 語言
 *
 * 
 */

#include <stdio.h>

// 數組長度
#define LENGTH(array) ( (sizeof(array)) / (sizeof(array[0])) )

/*
 * 獲取數組a中最大值
 *
 * 參數說明:
 *     a -- 數組
 *     n -- 數組長度
 */
int get_max(int a[], int n)
{
    int i, max;

    max = a[0];
    for (i = 1; i < n; i++)
        if (a[i] > max)
            max = a[i];
    return max;
}

/*
 * 對數組按照"某個位數"進行排序(桶排序)
 *
 * 參數說明:
 *     a -- 數組
 *     n -- 數組長度
x*     exp -- 指數。對數組a按照該指數進行排序。
 *
 * 例如,對於數組a={50, 3, 542, 745, 2014, 154, 63, 616};
 *    (01) 當exp=1表示按照"個位"對數組a進行排序
 *    (02) 當exp=10表示按照"十位"對數組a進行排序
 *    (03) 當exp=100表示按照"百位"對數組a進行排序
 *    ...
 */
void count_sort(int a[], int n, int exp)
{
    // 存儲"被排序數據"的臨時數組
    int *output = (int *)malloc(sizeof(int)*n);            
    int i, buckets[10] = {0};

    // 將數據出現的次數存儲在buckets[]中
    for (i = 0; i < n; i++)
        buckets[ (a[i]/exp)%10 ]++;

    // 更改buckets[i]。目的是讓更改后的buckets[i]的值,是該數據在output[]中的位置。
    for (i = 1; i < 10; i++)
        buckets[i] += buckets[i - 1];

    // 將數據存儲到臨時數組output[]中
    for (i = n - 1; i >= 0; i--)
    {
        output[buckets[ (a[i]/exp)%10 ] - 1] = a[i];
        buckets[ (a[i]/exp)%10 ]--;
    }

    // 將排序好的數據賦值給a[]
    for (i = 0; i < n; i++)
        a[i] = output[i];
}

/*
 * 基數排序 
 *
 * 參數說明:
 *     a -- 數組
 *     n -- 數組長度
 */
void radix_sort(int a[], int n)
{
    int exp;    // 指數。當對數組按各位進行排序時,exp=1;按十位進行排序時,exp=10;...
    int max = get_max(a, n);    // 數組a中的最大值

    // 從個位開始,對數組a按"指數"進行排序
    for (exp = 1; max/exp > 0; exp *= 10)
        count_sort(a, n, exp);
}

void main()
{
    int i;
    int a[] = {53, 3, 542, 748, 14, 214, 154, 63, 616};
    int ilen = LENGTH(a);

    printf("before sort:");
    for (i=0; i<ilen; i++)
        printf("%d ", a[i]);
    printf("\n");

    radix_sort(a, ilen);

    printf("after  sort:");
    for (i=0; i<ilen; i++)
        printf("%d ", a[i]);
    printf("\n");
}

msd實現


  /*
  MSD(Most Significant Digit First) 高位優先的字符串排序
 
  該算法基於鍵索引計數法的思想,進行了擴展,使得該算法可以
  處理不等長的字符串排序,其中涉及兩個關鍵點。
  1、采用分治法,從高位向低位的方向,依次選取關鍵字做為排序
     的鍵字進行排序,每一輪排序后,將字符串組進行拆分,對拆
     分后的每個子組分別進行排序,這里子組拆分的依據就是本輪
     鍵索引計數法排序之后的分類組。
  
     如下示例,最初只有一個字符串組,其中組成員數為5個字符串
     首先,選擇第0列做為排序的鏈字進行鍵索引計數法排序,排序
     完成后,按分類組划分,此時分為了兩組(見第一次后的情況),
     這時候對這兩組分別進行鏈索引計數法排序,注意這時每組第
     0列已經為相同字符,所以此時選擇第1做為排序的鏈字進行鍵
     索引計數法排序,在第二次排序后,此時已經分為了4組lp字符串
     組,依次類推,直到所有子組僅含有一個成員,所有子組排序
     處理完后,即整個字符串排序算法完成。
  
     原始:    第一次后:    第二次后:
     abcd      abcd          abcd
     ddba      acca              
     daca                    acca 
     acca      ddba              
     daab      daca          daca
               daab          daab
                                 
                             ddba
  
  2、剛才提到了該算法可以處理不等長的字符串排序,該算法采用一
     種比較巧妙的方法,將短字符串長度已經滿足不了排序的處理也
     做為鍵值比較處理了,同時如果短字符串長度滿足不了排序處理
     時,該鍵值優先級最高,所以就會出現排在最上方。
  
     如下示例,當第2列(字符c的位置)處理完后,開始進行第3列比較
     處理,此時第一個條目abcd的第3列鍵值為d、第二個條目abc的第
     3列鍵值已經不存在,長度已經滿足不了排序,但此時鍵值優先級
     為最高,第三個條目abcde的第3列鍵值為d,所以本輪最終將第二
     個條目abc排在了最上面,相同原理,abcd條目就會比abcde條目的
     優先級高。
  
     原始:    排序后:
     abcd      abc
     abc       abcd
     abcde     abcde
  */
 

#include <IOSTREAM>
#include <FSTREAM>
#include <STRING>
#include <VECTOR>


const int R = 256;  // 基數
const int M = 15;   // 小數組使用插入排序的閾值
using namespace std;

int charAt(const string& str, int d)
{
    if ( d < str.size() )
        return str[d];
    else 
        return -1;
}

//參數分別表示字符串容器,排序字符串起始位置,排序字符串結束位置,鍵的位數,輔助存儲容器
void MSD_sort(vector<string>& sVec, int lo, int hi, int d, vector<string>& aux)
{
    int i,r;

    /*
     * 這里存在一個優化:當數組數量較少的時候,我們可以使用插入排序優化算法
     *  
     * 故而下列return語句可以改寫為:
     *      if (hi <= low + M)
     *              {
     *                  insert_sort(svec, low, hi, d, aux );
     *                  return;                 
     *              }
     */
    if (hi <= lo)
        return;

    /*
     * R+2 的原因是:
     *      在charAt函數中,但索引值大於字符串本身的大小時,我們返回了-1,在下面,我么們將會看到,
     *      我們在所有的返回值上都加了1,然后將它作為count數組的索引,這意味着對於每個字符,都有
     *      可能是R+1種分組結果,因為鍵索引排序的方法本身就需要多一個額外的位置,故而是R+2.
     * 
     * 返回-1的原因:
     *      返回-1是為了處理具有相同首字母的字符串中長度稍短的那個字符。舉例來說,
     *      對於三個順序字符abcfg,abc,abcds來說,abc是三者中最短的,我們很容易知道,
     *      abc應該排在最前面,那么我如何讓計算機知道這件事呢。我們是這樣處理的,在charAt
     *      方法中可以看到,對於索引超出長度的字符串,我們返回的是-1.在count中我們對返回值加了1,
     *      從而讓count的索引都是非負整數。這樣,我們就將所有字符都被檢查過的字符串所在的子數組
     *      排在其他子數組的前面,這樣就不需要遞歸的將該子數組排序。
     *  
     */
    int count[R+2]={0};
    
    //計算頻率, 注意此處的 R+2
    for (i=lo; i<=hi; i++)
        count[charAt(sVec[i], d) + 2]++;
    
    //頻率轉化為索引
    for (r=0; r<R+1; r++)
        count[r+1] += count[r];
    
    //分類
    for (i=lo; i<=hi; i++)
        aux[count[charAt(sVec[i], d) + 1]++] = sVec[i];
    
    //回寫
    for (i=lo; i<=hi; i++)
        sVec[i] = aux[i-lo];//注意aux下標

    //以從左到右的每個字符為鍵進行排序
    for (r=0; r<R; r++)//count[R+1]為0,不對應任何字符
        MSD_sort(sVec, lo+count[r], lo+count[r+1]-1, d+1, aux);
}

int main(int argc, char* argv[])
{
    string str;
    vector<string> sVec;
    ifstream infile("data.txt");
    cout<<"------Before sort:"<<endl;
    while (infile>>str)
    {
        cout<<str<<endl;
        sVec.push_back(str);
    }

    int n = sVec.size();
    vector<string> aux(n);
    MSD_sort(sVec, 0, n-1, 0, aux);

    cout<<"------After sort:"<<endl;
    for (int i=0; i<n; i++)
        cout<<sVec[i]<<endl;

    return 0;
}

msd算法的性能及其改進

我們從三個方面衡量算法的性能:需要檢查的字符數量,統計字符出現頻率時所需的時間和空間,將頻率轉化為索引所需的時間和空間。

msd算法的性能取決於數據,因為他並非是比較排序,鍵的順序並不重要,所需關注的只是鍵所對應的值:

  • 對於隨機輸入,msd只檢查足以區分字符串的(字符),因為其運行時間是亞線性的。

  • 對於非隨機輸入,他的運行時間仍然可能是亞線性的。特別是當存在大量等值鍵的情況下,msd會檢查比隨機輸入更多的鍵,因而可能耗費更多的時間,其時間接近線性。

  • 最壞情況下,即為所有字符都相同並且所有字符長度都一致,那么其運行時間是線性的。

用msd算法對基於大型字母表的字符串排序時,msd算法可能消耗大量的時間和空間,特別是在有大量重復字符串的情況下。

msd算法可以注意的地方:

  • 小數組時可以使用改進后的插入排序。
  • 對於含有大量等值鍵的子數組排序會比較慢。msd的最壞情況就是所有的鍵均相同。
  • 額外空間的占用。為了進行切分,msd算法使用了兩個輔助數組,一個是aux, 一個是count。本質上,使用aux只是為了保證穩定性。aux當然可以舍棄,但是這樣就不會有穩定性了。

4.參考鏈接

  1. https://segmentfault.com/a/1190000012923917
  2. https://zh.wikipedia.org/wiki/%E5%9F%BA%E6%95%B0%E6%8E%92%E5%BA%8F
  3. https://zh.wikipedia.org/wiki/%E8%AE%A1%E6%95%B0%E6%8E%92%E5%BA%8F
  4. https://blog.csdn.net/liujianfeng1984/article/details/48488597
  5. https://blog.csdn.net/xuelabizp/article/details/50781616


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM