原創公眾號:bigsai 轉載需聯絡筆者
前言
在排序演算法中,大家可能對桶排序、計數排序、基數排序不太瞭解,不太清楚其演算法的思想和流程,也可能看過會過但是很快就忘記了,但是不要緊,幸運的是你看到了本篇文章。本文將通俗易懂的給你講解基數排序。
基數排序,是一種原理簡單,但實現複雜的排序。很多人在學習基數排序的時候可能會遇到以下兩種情況而淺嘗輒止:
- 一看原理,這麼簡單,懂了懂了(順便溜了)
- 再一看程式碼,這啥啥啥啊?這些的肯定有問題(不看溜了)
要想深入理解基數排序,必須搞懂基數排序各種形式(數字型別、等長字元型別、不等長字元)各自實現方法,瞭解其中的聯絡和區別,並且也要掌握空間優化的方法(非二維陣列而僅用一維陣列)。下面跟著我詳細學習基數排序吧!
基數排序原理
首先百度百科看看基數排序的定義:
基數排序(radix sort)屬於“分配式排序”(distribution sort),又稱“桶子法”(bucket sort)或bin sort,顧名思義,它是透過鍵值的部份資訊,將要排序的元素分配至某些“桶”中,藉以達到排序的作用,基數排序法是屬於穩定性的排序,基數排序法的效率高於其它的穩定性排序法。
基數排序也稱為卡片排序,簡而言之,基數排序的原理就是多次利用計數排序(計數排序是一種特殊的桶排序),但是和前面的普通桶排序和計數排序有所區別的是,基數排序並不是將一個整體分配到一個桶中,而是將自身拆分成一個個組成的元素,每個元素分別順序分配放入桶中、順序收集,當從前往後或者從後往前每個位置都進行過這樣順序的分配、收集後,就獲得了一個有序的數列。
在具體實現上如果從左往右那就是最高位優先(Most Significant Digit first)法,簡稱MSD法;如果從右往左那就是最低位優先(Least Significant Digit first)法,簡稱LSD法。但是不管從最高位開始還是從最低位開始要保證和相同位進行比較,你需要注意的是如果是int等數字型別需要保證從右往左(從低位到高位)保證對齊,如果是字元型別的話需要從左往右(從高位到低位)保證對齊。
你可能會問為啥不直接將這個數或者這個數按照區間範圍放到對應的桶中,一方面基數排序可能很多時候處理的是字元型的資料,不方便放入某個桶中,另一方面如果數字很大,不方便直接放入桶中。並且基數排序並不需要交換,也不需要比較,就是多次分配、收集得到結果。
所以遇到這種情況我們基數排序思想很簡單,就拿 934,241,3366,4399這幾個數字進行基數排序的一趟過程來看,第一次會根據各位進行分配、收集:
分配和收集都是有序的,第二次會根據十位進行分配、收集,此次是在第一次個位分配、收集基礎上進行的,所以所有數字單看個位十位是有序的。
而第三次就是對百位進行分配收集,此次完成之後百位及其以下是有序的。
而最後一次的時候進行處理的時候,千位有的數字需要補零,這次完畢後後千位及以後都有序,即整個序列排序完成。
想必看到這裡基數排序的思想你也已經懂了吧,但是雖然懂你不一定能夠寫出程式碼來,繼續看看下面的分析和實現。
數字型別基數排序
有很多時候也有很多時候對基數排序的講解也是基於數字型別的,而數字型別這裡就用int來實現,對於數字型別的基數排序你需要注意的有以下幾點:
- 無論是最高位優先法還是最低位優先法進行遍歷需要保證數字各位、十位、百位等對齊,這裡我使用最低位優先法從個位開始向上。
- 數字型別的基數排序需要十個桶(0-9),你可以使用二維陣列,第一維度長度為10表示十個數字,第二個維度為陣列長度,用來儲存數字(因為最壞情況可能當前位數字一樣)。但這樣無疑太浪費記憶體空間了,你可以使用List或者Queue替代,這裡就用List了。
- 具體實現要先找到最大值確定最高多少位,用來進行遍歷時候確認。
- 收集的時候藉助一個自增引數遍歷收集。
- 每次收集完畢十個桶(bucket)需要清空待下次收集。
實現的程式碼為:
static void radixSort(int[] arr)//int 型別 從右往左
{
List<Integer>bucket[]=new ArrayList[10];
for(int i=0;i<10;i++)
{
bucket[i]=new ArrayList<Integer>();
}
//找到最大值
int max=0;//假設都是正數
for(int i=0;i<arr.length;i++)
{
if(arr[i]>max)
max=arr[i];
}
int divideNum=1;//1 10 100 100……用來求對應位的數字
while (max>0) {//max 和num 控制
for(int num:arr)
{
bucket[(num/divideNum)%10].add(num);//分配 將對應位置的數字放到對應bucket中
}
divideNum*=10;
max/=10;
int idx=0;
//收集 重新撿起資料
for(List<Integer>list:bucket)
{
for(int num:list)
{
arr[idx++]=num;
}
list.clear();//收集完需要清空留下次繼續使用
}
}
}
等長字串基數排序
除了數字之外,等長字串也是常常遇到的方式,其主要方法和數字型別差不多,這裡也看不出策略上的不同。低位優先法或者高位優先法都可使用(這裡依舊低位從右向左)。
在實現細節方面,和前面的數字型別區別不是很大,但是因為字串是等長的遍歷更加方便容易。但需要額外注意的是:
- 字元型別的桶bucket不是10個而是ASCII字元的個數(根據實際需要檢視ASCII表)。其實就是利用char和int之間關係可以直接按照每個字元進行順序儲存。
具體實現程式碼為:
static void radixSort(String arr[],int len)//等長字元排序情況 長度為len
{
List<String>buckets[]=new ArrayList[128];
for(int i=0;i<128;i++)
{
buckets[i]=new ArrayList<String>();
}
for(int i=len-1;i>=0;i--)//每一位上進行操作
{
for(String str:arr)
{
buckets[str.charAt(i)].add(str);//分配
}
int idx=0;
for(List<String>list:buckets)
{
for(String str:list)
{
arr[idx++]=str;//收集
}
list.clear();//收集完該bucket清空
}
}
}
非等長字串基數排序
等長的字串進行基數排序時候很好遍歷,那麼非等長的時候該如何考慮呢?這種非等長不能像處理數字那樣粗暴的計算當成0即可。字串的大小是從前往後進行排列的(和長度沒關係)。例如看下面字串,“d”這個字串即使很短但是在排序依然放在最後面。你知道該怎麼處理嗎?
"abigsai"
"bigsai"
"bigsaisix"
"d"
如果高位優先,前面一旦比較過各個字元的桶(bucket)就要固定下來,也就是在進行右面下一個字元分配、收集的時候要標記空間,即下次進行分配收集的前面是‘a’字元的一組,‘b’字元一組,並且不能越界,實現起來很麻煩這裡就不詳細講解了有興趣的可以自行研究一下。
而本篇實現的是低位優先。低位優先採用什麼思路呢?很簡單,跟我看圖解。
第一步,先將字元按照長度進行分配到一個桶(bucket)中,宣告一個List<String>wordLen[maxlen+1]
;在遍歷字元時候,以字元長度為下表index,將字串順序加入進去。其中maxlen為最長字串長度,之所以要maxlen+1是因為需要使用maxlen下標(0-maxlen)。
第二步,分配完成遍歷收集到原陣列中,這樣原陣列在長度上相對有序。
這樣就可以進行基數排序啦,當然,在開始的時候並不是全部都進行分配收集,而是根據長度慢慢遞減,長度可以到達6位分配、收集,長度到達5的分配、收集……長度為1的進行分配、收集。這樣進行一遭就很完美的進行完基數排序,因為我們藉助根據長度收集的桶可以很容易知道當前長度開始的index在哪裡。
具體實現的程式碼為:
static void radixSort(String arr[])//字元不等長的情況進行排序
{
//找到最長的那個
int maxlen=0;
for(String team:arr)
{
if(team.length()>maxlen)
maxlen=team.length();
}
//一個對長度分 一個對具體字元分,先用長度來找到
List<String>wordLen[]=new ArrayList[maxlen+1];//用長度先統計各個長度的單詞
List<String>bucket[]=new ArrayList[128];//根據字元來劃分
for(int i=0;i<wordLen.length;i++)
wordLen[i]=new ArrayList<String>();
for(int i=0;i<bucket.length;i++)
bucket[i]=new ArrayList<String>();
//先根據長度來一下
for(String team:arr)
{
wordLen[team.length()].add(team);
}
int index=0;//先進行一次(按照長度分)的桶排序使得陣列長度初步有序
for(List<String>list:wordLen)
{
for(String team:list)
{
arr[index++]=team;
}
}
//然後 先進行長的 從後往前進行
int startIndex=arr.length;
for(int len=maxlen;len>0;len--)//每次長度相同的要進行基數一次
{
int preIndex=startIndex;
startIndex-=wordLen[len].size();
for(int i=startIndex;i<arr.length;i++)
{
bucket[arr[i].charAt(len-1)].add(arr[i]);//利用字元桶重新裝
}
//重新收集
index=startIndex;
for(List<String>list:bucket)
{
for(String str:list)
{
arr[index++]=str;
}
list.clear();
}
}
}
空間優化(等長字元)基數排序
上面無論是等長還是不等長,使用的空間其實都是跟二維相關的,我們能不能使用一維的空間去解決這個問題呢?當然能啊。
在使用空間的整個思路是差不多的,但是這裡為了讓你能夠理解我們在講解的時候講解等長字串的情況。
先回憶剛剛講的等長字串,就是從個位進行遍歷,在遍歷的時候將資料放到對應的桶裡面,然後在進行收集的時候放回原陣列。
你能否發現什麼規律?
- 一個字串收集的時候放的位置其實它只需要知道它前面有多少個就可以確定!
- 並且當前位置字元如果相同那麼就是根據arr中相對順序來進行當前輪。
所以我們可以嘗試來動態維護這個int bucket[]。第一次進行只記錄次數,第二次進行疊加表示比當前位置+1編號小的元素的個數。
但是這樣處理不太好知道比當前位置小的有多少,所以我們在分配的時候向下挪一位,這樣bucket[i]就可以表示比當前位置小的元素的個數。
我們在進行收集的時候需要再次遍歷arr,但我們需要一個臨時陣列String value[]儲存結果(因為arr沒遍歷完後面不能使用),而進行遍歷的規則就是:遍歷arr時候對應字串str,該位字元對應bucket[str.charAt(i)]
桶中數字就是要放到arr中的編號(多少個比它小的就放到第多少位),放置之後要對bucket當前位自增(因為下一個這個位置字串要把這個str考慮進去)。這樣到最後即可完成排序。
第一趟遍歷arr前兩個字串部分過程如下:
第一趟中間兩個字串處理情況:
第一趟最後兩個字串處理情況:
就這樣即可完成一趟操作,一趟完成記得將value的值賦值到arr中,當然有方法使用指標引用可以避免交換資料帶來的時間影響,但這裡為了使大家更加簡單理解就直接複製過去。這樣完成若干次,整個基數排序即可完成。
具體實現的程式碼為:
static void radixSortByArr(String arr[],int len)//固定長度的使用陣列進行優化
{
int charLen=129;//多用一個
String value[]=new String[arr.length];
for(int index=len-1;index>=0;index--)//不同的位置
{
int bucket[]=new int[charLen];//儲存character的桶
for(int i=0;i<arr.length;i++)//分配
{
bucket[(int)(arr[i].charAt(index)+1)]++;
}
for(int i=1;i<bucket.length;i++)//疊加 當前i位置表示比自己小的個數
{
bucket[i]+=bucket[i-1];
}
for(int i=0;i<arr.length;i++)
{
value[bucket[arr[i].charAt(index)]++]=arr[i];//中間的++因為當前位置填充了一個,下次再來同元素就要後移
}
System.arraycopy(value,0,arr,0,arr.length);//copy陣列
}
}
至於不定長的,思路也差不多,這裡就留給你優秀的你自己去思考啦。
結語
至於基數排序的演算法分析,以定長的情況分析,假設有n數字(字串),每個有k位,那麼根據基數就要每一位都遍歷就是K次,每次都是O(n)級別。所以差不多是O(n*k)級別,當然k遠遠小於n,可能有成千上萬個數,但是每個數或者字元正常可沒成千上萬那麼長。
本次基數排序就全講完啦,那麼多張圖我想你也應該懂了。
最後我請你們兩連事幫忙一下:
-
點贊、關注一下支援, 您的肯定是我在平臺創作的源源動力。
-
微信搜尋「bigsai」,關注我的公眾號,不僅免費送你電子書,我還會第一時間在公眾號分享知識技術。加我還可拉你進力扣打卡群一起打卡LeetCode。
記得關注、我們們下次再見!