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的原因。算法的步驟如下:
- 找出待排序數組中的最大元素和最小元素。
- 統計數組中值為 i 的元素出現的次數,存入數組C的第 i 項。
- 對所有的計數累加(從C中第一個元素開始,每一項和前一項累加)。
- 反向填充目標數組:將每個元素 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)。
關鍵步驟
- 設置一個定量的陣列當作空桶子。
- 尋訪序列,並且把項目一個一個放到對應的桶子去。
- 對每個不是空的桶進行排序。可以在放入元素的時候進行插入排序,也可以在寫回的時候進行快速排序。
- 從不是空的桶子里把項目放回到原來的序列中。
算法實現
假設數據分布在[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當然可以舍棄,但是這樣就不會有穩定性了。