開始之前
假設最長字串的長度是L,以L作為輸入的長度, 然後假定所有的字串都"補齊"到此長度,這個補齊只是邏輯上的,我們可以假想有一種"Null 字元", 它小於任何其它字元,用此字元補齊所有長度不足的字串。例如:最長的字串長度為9,有一個字串A長度為6, 那麼當比較第7位字元的時候,我們讓A[7]為"Null 字元"。
如果要包含所有的字元似乎並不容易,我們先定義一個字元集, 待排序字串中的所有字元都包含在這個字元集裡
//字元集private string _myCharSet = "0123456789qwertyuiopasdfghjklzxcvbnm";
再來一個產生隨機字串的方法(C#實現):
private Random _random = new Random(); string[] GetRandStrings(int size, int minLength, int maxLength){ string[] strs = new string[size]; int len = 0; StringBuilder sb = new StringBuilder(maxLength); for (int i = 0; i < strs.Length; i++) { //先隨機確定一個長度 len = _random.Next(minLength, maxLength); for (int j = 0; j < len; j++) { //隨機選取一個字元 sb.Append(_myCharSet[_random.Next(_myCharSet.Length)]); } strs[i] = sb.ToString(); sb.Clear(); } return strs;}
這裡按照字元的整數表示來確定桶的範圍,再為"Null 字元"準備一個桶。 為了表示"Null 字元"這個特例,這裡用default(char),即'\0'表示它, 因為當調用string.ElementAtOrDefault(int)方法時,如果超出索引會返回'\0'。
初級版本(C#)
void StringRadixSort(string[] strArray){ if (strArray == null || strArray.Length == 0 || strArray.Contains(null)) { return; } //獲得字串的最大長度 int maxLength = 0; foreach (string s in strArray) { if (s.Length > maxLength) { maxLength = s.Length; } } //確定字元的整數範圍 int rangeStart = _myCharSet[0]; int rangeEnd = _myCharSet[0]; foreach (char ch in _myCharSet) { if (ch < rangeStart) rangeStart = ch; if (ch >= rangeEnd) rangeEnd = ch + 1; } //也要為"Null 字元"分配一個桶,其索引為0 int bucketCount = rangeEnd - rangeStart + 1; LinkedList<string>[] buckets = new LinkedList<string>[bucketCount]; //初始化所有的桶 for (int i = 0; i < buckets.Length; i++) { buckets[i] = new LinkedList<string>(); } //從最後一個字元開始排序 int currentIndex = maxLength - 1; while (currentIndex >= 0) { foreach (string theString in strArray) { //如果超出索引,返回'\0'字元(default(char)) char ch = theString.ElementAtOrDefault(currentIndex); if (ch == default(char)) { //"Null 字元"的處理 buckets[0].AddLast(theString); } else { //將字元對應表到桶 int index = ch - rangeStart + 1; buckets[index].AddLast(theString); } } //從桶裡依次取回字串,完成一趟排序 int i = 0; foreach (LinkedList<string> bucket in buckets) { while (bucket.Count > 0) { strArray[i++] = bucket.First(); bucket.RemoveFirst(); } } currentIndex--; }}
稍作"改良"
用作確定字元的整數範圍的代碼略顯蛋疼,而且根據字元集來看, 並不是區間內所有的整數對應的字元都可能出現,因此會有這樣的情況: 我們給某些根本不會出現的字元分配了桶,這純屬浪費。 我們可以用一個字典(散列)來記錄字元和它的桶之間的映射。於是有了下面的代碼。
private Dictionary<char, int> _charOrderDict = new Dictionary<char, int>(_myCharSet.Length);void BuildCharOrderDict(){ char[] sortedCharSet = _myCharSet.ToArray(); //使用預設的比較子排序 Array.Sort(sortedCharSet); //為"Null 字元"單獨建立映射 _charOrderDict.Add(default(char), 0); for (int i = 0; i < sortedCharSet.Length; i++) { // 儲存的是字元及其對應的桶的索引 _charOrderDict.Add(sortedCharSet[i], i + 1); }}
也可以不用預設的字元排序來作為映射,而完全自己定義字元之間的大小關係。 下面是調整後的代碼:
void StringRadixSort(string[] strArray){ if (strArray == null || strArray.Length == 0 || strArray.Contains(null)) { return; } //獲得字串的最大長度 int maxLength = 0; foreach (string s in strArray) { if (s.Length > maxLength) { maxLength = s.Length; } } //為每一個字元(包括Null 字元'\0')分配一個桶 //"Null 字元"索引應為0 int bucketCount = _myCharSet.Length + 1; LinkedList<string>[] buckets = new LinkedList<string>[bucketCount]; //初始化所有的桶 for (int i = 0; i < buckets.Length; i++) { buckets[i] = new LinkedList<string>(); } //從最後一個字元開始排序 int currentIndex = maxLength - 1; while (currentIndex >= 0) { foreach (string theString in strArray) { //如果超出索引,返回'\0'字元(default(char)) char ch = theString.ElementAtOrDefault(currentIndex); //根據字元順序的定義查詢字元 int index = _charOrderDict[ch]; buckets[index].AddLast(theString); } //從桶裡依次取回字串,完成一趟排序 int i = 0; foreach (LinkedList<string> bucket in buckets) { while (bucket.Count > 0) { strArray[i++] = bucket.First(); bucket.RemoveFirst(); } } currentIndex--; }}
Now, it works! 如果採用的快速排序來做, 其時間複雜度為O(n∗logn)O(n∗logn)。表面上看,基數排序更好,不過嚴格來說, 基數排序的時間複雜度應該是O(k∗n)O(k∗n),其中k和字串長度正相關。 此時兩種演算法的比較可以通過比較k和lognlogn的比較結果近似得出。 如果字串的長度很長,即k很大,而輸入規模n不大的時候, 就會有k>lognlogn,此時快速排序反而更有優勢。反之,則基數排序可能更優。
最後...
杯具的是,當我擴大字元集,將鍵盤上所有字元都加進去後, 發現基數排序的結果和Array.Sort(string[]方法的排序結果並不一樣。 仔細觀察資源管理員對檔案名稱的排序,才發現其字串排序的規則要複雜的多,並非簡單的比較字元。 查詢相關資料後發現,字串的排序甚至還要考慮地區文化的影響,即使都是拉丁字母, 不同地區的定序都可能不一樣,因此, 使用基數排序實現的字串排序演算法好像並無多大實用價值<T-T>。