本文將介紹鍵索引計數法、LSD基數排序、MSD基數排序。
1. 字符串(String)
我們來簡單回顧一下字符串。
眾所周知,字符串是編程語言中表示文本的數據類型。它是一堆字符的組合,如 String S="String"。
我們可以知道字符串的長度:S.length()=6;
可以知道某個位置的字符是什么:S[0]="S"; S[5]="g";
可以提取S中的一部分;
可以把兩個字符串合並起來形成新字符串等等。
2. 字符串排序
如果我們要對一堆字符串像字典一樣排序,怎么排?例如:
字典是怎么排序的呢?
按照英文字母表順序a,b,c,d,...,y,w,我們得到了字母的大小排序:a<b<c<d<...<y<w。
sea和she相比,第一個字母相同,第二個字母e<h,故sea<she;
sea和seashells相比,前三個字母相同,但seashells比sea長,故sea<seashells;
seashells和sells相比,前兩個字母相同,第三個字母a<l,故seashells<sells。
說到排序,我們自然想起了插入排序、歸並排序、快速排序、堆排序,我們來回顧一下它們對N個對象進行排序的效率:
圖中的CompareTo()就是對象之間比較大小的方法。
要想使用上述4種排序算法,必須提供一種對象之間比較大小的方法。即程序需要知道對象a與對象b誰大誰小(或相等)。
如果對象是數字,那比較方法容易實現。但如果對象是字符串,比較方法就復雜多了。
接下來,我們將介紹擁有比上述方法更高效率的字符串排序算法。
3. 鍵索引計數法(Key-indexed counting )
講算法之前,我們來先了解一下這些算法的基礎:鍵索引計數法。
從例子入手:a[]是擁有一堆只有一個字符的string數組。
a[]中不同的字符分別有6個:a,b,c,d,e,f。我們需要給這些字符配對一些int類型的鍵索(Key-indexed):令a=0;b=1;c=2;d=3;e=4;f=5。
創建一個int類型的數組count,擁有7個元素:
然后我們數這六個字符分別重復出現了多少次,並把次數記錄在count[x+1]中。x為某個字符對應的鍵索。
a重復出現了2次,a對應的鍵索為0,故count[1]=2;
b重復出現了3次,b對應的鍵索為1,故count[2]=3;
c重復出現了1次,c對應的鍵索為2,故count[3]=1;
d重復出現了2次,d對應的鍵索為3,故count[4]=2;
e重復出現了1次,e對應的鍵索為4,故count[5]=1;
f重復出現了3次,f對應的鍵索為5,故count[6]=3;
然后我們計算比某個字符小的字符有多少個,計算方法為count[x+1] += count[x]。x為某個字符對應的鍵索。(x從0開始逐漸遞增)
例如:count[1]=count[1]+count[0]=2; count[2]=count[1]+count[2]=2+3=5; count[3]=count[3]+count[2]=1+5=6;
為了方便理解,我們把鍵索對應的字符顯示出來:
然后就是排序了,構建一個輔助數組aux[]。
從a[0]逐一排序:
因為a[0]=d,d對應的鍵索為3,count[3]=6,故aux[6]=d, count[3]+=1;
因為a[1]=a,a對應的鍵索為0,count[0]=0,故aux[0]=a, count[0]+=1;
因為a[2]=c,c對應的鍵索為2,count[2]=5,故aux[5]=c, count[2]+=1;
因為a[3]=f,f對應的鍵索為5,count[5]=9,故aux[9]=f, count[5]+=1;
如此類推,直到aux[]填滿了。
aux[]就是排序完畢后的數組,最后只需把aux[]復制給a[]即可,算法結束。
總結一下通用思路就是:
令a[]是擁有一堆只有一個字符的string數組,且有R個不同的字符。
1. 創建一個int類型的擁有R+1個元素的數組count和一個與a[]同等大小的string數組aux[];
2. 數這些不同的字符分別重復出現了多少次,並把次數記錄在count[x+1]中。x為某個字符對應的鍵索;
3. 計算比某個字符小的字符有多少個,計算方法為count[x+1] += count[x]。x為某個字符對應的鍵索。(x從0開始逐漸遞增)
4. 通過利用count[]把a[]的元素逐一添加到aux[]中
5. 把aux[]復制給a[]
鍵索引計數法是穩定的(Stable),如果不了解穩定是什么意思,繼續往下看,稍后將介紹。
代碼實現:
4. LSD基數排序(LSD radix sort )
現在開始講String排序算法。
LSD全稱:Least-significant-digit-first 低位有效數字優先。
如果要排序的那堆字符串長度相同,我們可以用LSD基數排序。
從例子入手:a[]是擁有一堆只有三個字符的string數組。
為了方便理解,我把這些字符都特意分開標示出來了。a[0]=dab;a[1]=add;a[2]=cab; ...
首先,我們先對第三個字符那一列通過鍵索引計數法把這堆字符串排一次序:
理所當然的,因為這些字符串每個都是一個個體,排序的時候,要整個字符串一起移動而不是只移動第三個字符。
標紅的那一列已經排好序了。
接下來對第二個字符那一列通過鍵索引計數法把這堆字符串排一次序:
接下來對第一個字符那一列通過鍵索引計數法把這堆字符串排一次序:
排序完成,算法結束。
穩定性問題:
在這里,我們可以討論一下穩定性的問題了(stable)。我們經常說這個算法是穩定的(stable),那個算法是不穩定的(not stable)。
這里的穩定不是指可不可靠,而是指這個算法會不會破壞原有的順序。
例如,我們看一下這個例子,經歷了對第二個字符那一列通過鍵索引計數法把這堆字符串排一次序后,a[1]=cab; a[2]=fad。
它們在對第三個字符那一列通過鍵索引計數法把這堆字符串排一次序的那時,對應的位置在哪?a[1]=cab; a[4]=fad。
第一次排序時,cab在fad前面,因為它們的第三個字符b<d; 但第二次排序時,這兩個字符串的第二個字符都是a,不穩定的算法有可能會把fad排到cab前面,但穩定的算法肯定不會!
總結一下LSD基數排序通用思路就是:
如果需要排序的那堆字符串擁有一樣多的字符,那么我們可以從它們的最后一個字符進行鍵索引計數法排序,然后對它們的倒數第二個字符進行鍵索引計數法排序,如此類推,直到對它們的第一個字符進行鍵索引計數法排序后,排序結束。
鍵索問題:
但是,鍵索引計數法是需要知道要排序的字符串有多少個不同的字符的,從而給那些字符匹配鍵索,難道我們每次都要去數一下有多少個不同的字符?
其實並不需要,這樣做太耗費時間了。看下表:
根據每個不同類型的字符串,我們可以選擇不同的R值(鍵索引計數法構建count數組需要用到的R值)。
如果我們知道要排序的字符串都是由小寫字母組成,則R=26(畢竟只有26個字母);
如果要排序的字符串還有一些符號,那就用R=256吧。
總之,按照需求選擇R值。
LSD基數排序的代碼實現:
5. MSD基數排序(MSD radix sort )
那么如果要排序的那堆字符串長度不同,怎么辦?那就用MSD基數排序吧!
MSD全稱:Most-significant-digit-first 高位有效數字優先。
LSD是從最后一位字符開始往前排序的,而MSD是從第一位字符開始往后排序的。
從例子入手:a[]是擁有一堆字符串的字符串數組。
為了方便理解,我把這些字符都特意分開標示出來了。a[0]=she;a[1]=sells;a[2]=seashells; ...
我們設定每個字符串的最后一位字符的下一個位置的空字符的鍵索為-1,即:(為了方便觀察,這里將暫時標紅它們)
每次進行鍵索引計數法排序時,我們都按照排序結果,把所有字符串分成數個區。如下:
首先,我們先對第一個字符那一列通過鍵索引計數法把這堆字符串排一次序:
第一個字符那一列只有4個不同的字母,故把所有字符串分為4個區:
第一個區有a[0];第二個區有a[1];第三個區有a[2]~a[11];第四個區有a[12]、a[13]。
然后從上往下看:
第一個區只有一個字符串,此區排序完畢;
第二個區只有一個字符串,此區排序完畢;
第三個區有很多個字符串,對此區的第二個字符那一列通過鍵索引計數法把這堆字符串排一次序:(為了方便觀察,已排序完畢的字符串用綠色表示)
繼續分區:
第一個區有a[2]~a[6];第二個區有a[7]~a[10];第三個區有a[11]。
然后從上往下看:
第一個區有很多個字符串,對此區的第三個字符那一列通過鍵索引計數法把這堆字符串排一次序:(為了方便觀察,待排序的字符串用黑色表示)
繼續分區:
第一個區有a[2]~a[4];第二個區有a[5]、a[6]。
然后從上往下看:
第一個區有很多個字符串,對此區的第四個字符那一列通過鍵索引計數法把這堆字符串排一次序:
在這次排序中,-1的效果就能表現出來了:因為分配鍵索時,字符分到的鍵索都是正數,這里的-1是最小的,故這樣可以保證最短的字符在最前面。
繼續分區:只有一個區,此區有a[3]、a[4]。(-1不是字符,不參與分區)
此區有很多個字符串,對此區的第五個字符那一列通過鍵索引計數法把這堆字符串排一次序:
繼續分區:只有一個區,此區有a[3]、a[4]。
此區有很多個字符串,對此區的第六個字符那一列通過鍵索引計數法把這堆字符串排一次序:
(由於這兩個字符串是相同的,故每次排序后的結果都一樣,這里省略第7、8個字符的排序)
繼續分區:只有一個區,此區有a[3]、a[4]。
此區有很多個字符串,對此區的第九個字符那一列通過鍵索引計數法把這堆字符串排一次序:
繼續分區:只有一個區,此區有a[3]、a[4]。
此區有很多個字符串,對此區的第十個字符那一列通過鍵索引計數法把這堆字符串排一次序:
繼續分區:沒有區(因為都是-1,-1不是字符,不參與分區)
此區排序完畢,開始下一個區的排序:
此區有很多個字符串,對此區的第四個字符那一列通過鍵索引計數法把這堆字符串排一次序:
繼續分區:只有一個區,此區有a[5]、a[6]。
此區有很多個字符串,對此區的第五個字符那一列通過鍵索引計數法把這堆字符串排一次序:
繼續分區:只有一個區,此區有a[5]、a[6]。
此區有很多個字符串,對此區的第六個字符那一列通過鍵索引計數法把這堆字符串排一次序:
繼續分區:沒有區(因為都是-1,-1不是字符,不參與分區)
此區排序完畢,開始下一個區的排序:
此區有很多個字符串,對此區的第三個字符那一列通過鍵索引計數法把這堆字符串排一次序:
繼續分區:
第一個區有a[7]~a[9];第二個區有a[10]。
然后從上往下看:
第一個區有很多個字符串,對此區的第四個字符那一列通過鍵索引計數法把這堆字符串排一次序:
繼續分區:只有一個區,此區有a[8]。(-1不是字符,不參與分區)
此區只有一個字符串,此區排序完畢。
開始下一個區的排序:
此區只有一個字符串,此區排序完畢。
開始下一個區的排序:
此區只有一個字符串,此區排序完畢。
開始下一個區的排序:
此區有兩個字符串,但由於它們是一樣的,故這里省略對此區的第二、第三、第四個字符那一列分別通過鍵索引計數法排序:
沒有下一個區了,全部排序完畢,算法結束。
這個算法演示過程本身就是一個遞歸的過程。
總結一下:
1. 對所有字符串的第一個字符那一列通過鍵索引計數法把這堆字符串排一次序,根據排序結果進行分區;
2. 從上往下處理各個分區:如果分區只有一個字符串,則此區處理完畢;如果有多個字符串,則對此區的字符串的下一個字符進行排序、分區、處理分區。
3. 所有分區處理完后,排序完畢,算法結束。
實現代碼:
6. LSD和MSD的算法效率
注釋:
N為要排序的元素個數。
LSD的W: 由於LSD只針對於要排序的那堆字符串長度相同,故W為字符串的長度。
MSD的W: 要排序的那堆字符串的長度平均值。
guarantee: 算法保證能在多少次操作后完成。
random: 如果要排序的數組里的元素順序是隨機的,則算法可以在多少次操作后完成。
extra space: 算法需要的額外空間。
7. MSD算法的缺陷
1. MSD算法需要額外空間(鍵索引計數法每次排序都需要使用額外空間),這意味着浪費內存。
2. 遞歸循環里的每次循環都需要進行很多操作。
在字符串算法—字符串排序(下篇)中,將把MSD算法和快速排序法結合起來,形成更高效率的算法: 3區基數快速排序(3-way radix quicksort)。
另外,如果我們需要在一篇文章中搜索關鍵詞,如何高效地操作?
在字符串算法—字符串排序(下篇)中,我們將介紹后綴排序法( suffix sort )來解決搜索關鍵詞的問題。