上一篇介绍了计数排序,本文详解另一种线性复杂度的排序——基数排序(radix sort)。基数排序是也是一种非比较整数排序算法。可以用于给字符串集合的“按字典序”排序。
问题定义:给定n个d位数据(不足d为的字符串在尾部用""补齐以达到长度为d),每位上是有k个可能取值的一位数值,如果用到的稳定排序在O(n+k)的时间内完成,则基数排序可以在O(d(n+k))时间内完成排序。当每一位的数值在0~k-1取值时,并且k不是很大,计数排序是一个好的稳定排序的选择,每次遍历n个d位数字花费时间为O(n+k),一共要执行d轮,所以总共时间复杂度为O(d(n+k))。因为d和k都是确定的数字,所以认为计数排序是线性复杂度的。当然在将key映射为整数数字还是很具有灵活性的。
基数排序的具体实现方式有两种:Most Significant digit和Least Significant digit,这两个属于不太好翻译,可以这样理解Significant,就是说对排序后元素先后顺序有影响(带有先后意义)的位,而Most和Least表示从最高影响的位和最低影响的位开始排序。
比如数字的比较大小是从最右边位开始比较,而字符串的比较则应该从最左边那一位开始,后面较短的字符串的位用空字符串补齐。
举个整数排序的例子,显然整数排序需要从最右(低)位开始进行排序,图示如下:
从最右位开始循环,每个循环里:按照该位上的数组放到属于0-9的桶中,然后将按顺序复制桶里的元素;直到最高位复制结束即为排序结果。
代码如下:
public class Radix {
public static int getMax(int arr[], int n)
{
int mx = arr[0];
for (int i = 1; i < n; i++)
if (arr[i] > mx)
mx = arr[i];
return mx;
}
public static void countSort(int arr[], int n, int exp)
{
int output[] = new int[n];
int i;
int count[] = new int[10];
Arrays.fill(count,0);
for (i = 0; i < n; i++)
count[ (arr[i]/exp)%10 ]++;
for (i = 1; i < 10; i++)
count[i] += count[i - 1];
//(arr[i]/exp)%10
for (i = n - 1; i >= 0; i--)
{
output[count[ (arr[i]/exp)%10 ] - 1] = arr[i];
count[ (arr[i]/exp)%10 ]--;
}
for (i = 0; i < n; i++)
arr[i] = output[i];
}
public static void radixsort(int arr[], int n)
{
int m = getMax(arr, n);
//exp控制整数数位
for (int exp = 1; m/exp > 0; exp *= 10)
countSort(arr, n, exp);
}
public static void main (String[] args)
{
int arr[] = {170, 45, 75, 90,02, 802, 24, 2, 66};
radixsort(arr,arr.length);
}
}
上面是基于十进制表达的数字排序,下面看一种基于二进制表达的数字排序,w为十进制整数的二进制表达的总位数(这里用Java的int型32位举例),d表示每次比较的位数,同样是从最右位开始:
public static int[] radixSort(int[] a) {
int w=32,d=2;
int[] b = null;
for (int p = 0; p < w/d; p++) {
int c[] = new int[1<<d];
b = new int[a.length];
for (int i = 0; i < a.length; i++)
c[(a[i] >> d*p)&((1<<d)-1)]++;
for (int i = 1; i < 1<<d; i++)
c[i] += c[i-1];
for (int i = a.length-1; i >= 0; i--)
b[--c[(a[i] >> d*p)&((1<<d)-1)]] = a[i];
a = b;
}
return b;
}
上述代码中每次循环取两位的技巧:(a[i] >> d*p)&((1<<d)-1),关于位运算技巧的可以看这篇博客https://blog.youkuaiyun.com/To_be_to_thought/article/details/85015853,里面有详细介绍。
再看一个变长字符串排序的例子,这次使用ArrayList泛型数组(每个数组元素为一个ArrayList结构)来实现,以跟上面的示意图一一对应起来,在这种意义上基数排序跟桶排序是一样的,同时也类似与散列哈希算法。
先看算法导论习题的例子,我加了变长字符串进去给如单词按字典序排序:
为了让算法写的更加自然流畅,我们先用基数排序(桶排序)的思维进行图示演示:
首先对每个单词的第一个字母,进行基数排序。
这里采用ASCII码128个字符作为“桶”,但因为作图空间有限,只用A-Z和空字符来图示,这注意本次装桶操作是所有元素(strs[0:strs.length-1]):
然后,用桶中顺序的元素给原字符串数组进行回代复制得到一个相对有序的顺序:
下一步是不是就每个字符串的第二个字符进行桶排序了呢?不能直接进行,因为如果单纯这样排而不顾及第一个字符决定的先后顺序,排序结果就是错误的,我就是先这样编程的,代码里基数排序的内部排序算法用的是计数排序,错误代码展示一下:
public static void countSort(String[] strs,int j)
{
int n=strs.length;
String[] output=new String[n];
int[] count=new int[128];
for(String str: strs)
{
char c=0;
if(j<str.length())
c=str.charAt(j);
count[c]++;
}
for (int i = 1; i < count.length; i++)
count[i] += count[i - 1];
for (int i = n - 1; i >= 0; i--)
{
char c=0;
if(j<strs[i].length())
c=strs[i].charAt(j);
output[count[c] - 1] = strs[i];
count[c]--;
}
for (int i = 0; i < n; i++)
strs[i] = output[i];
}
//Most significant digit radix sorts
public static void radixsort(String[] strs)
{
int maxLength=Integer.MIN_VALUE;
for(int n=0;n<strs.length;n++)
maxLength=Math.max(maxLength, strs[n].length());
for(int i=0;i<maxLength;i++)
countSort(strs,i);
}
有一个小技巧值得注意一下就是不够长的字符串的后面的位可以用空字符来替代,因为ASCII码中空字符就排在第一个,就为0。
错误在哪里呢?因为上一步排序后就相对有序了,只有局部可能是混乱的,也就是说要对每个混乱的局部进行局部的排序,而这个局部范围的确定就是靠在整个数组中局部的起始索引和终止索引来确定。那么对于局部子数组该怎么排序呢?这里有两种选择,一种是递归地对局部子数组也进行基数排序,或者对子数组进行其他稳定排序,这里我们只展示前者的图示,因为递归的算法表达更具抽象性,虽然性能可能差一些。
继续进行第二步:
对如下红框标出的子数组的元素按第二个字符进行基数排序,注意这时的局部数组的索引范围为0-1:
该子数组排序后进行收集,回代复制
本次操作后结果:
因为待排序元素很少,所以这里的桶里的元素很稀疏。
再来(第三组)三个元素的局部子数组(红框标出)的按第二个字符基数排序:
本次操作后结果为:
到了这一步,思路很明了,关键就是如何确定局部子数组的范围,那么起始索引和终止索引该如何确定呢?回忆收集那一步,每个桶里装的就是一个局部数组,可以在收集时记录下每个存在元素的桶里的元素数量,这样就可以对应到原数组的位置了,比如第一步基数排序后的收集记录为:2,2,3,1,1,1,1,2,1,3,也就是说第一个子数组索引范围0-1,第二个子数组索引范围2-3,第三个子数组索引范围4-6.......但我们发现这个记录不直观的对应到索引范围,受到线段树的启发可以进行累积再减一转化:2,4,7,8,9,10,11,12,13,16,还可以在前面加一个0:0,1,3,6,7,8,9,10,11,12,15,这样就一一对应每个局部子数组的右边界,而左边界由上一个记录确定,注意记录的起点是当前子数组的起点lo。
这里的递归条件就是子数组的大小为1时即可返回。当然这里也可在在数组达到一定大小时改用插入排序。
自此,大功告成!下面上代码:
public static void radixSort1(String[] strs)
{
recursive(strs,0,strs.length-1,0);
}
public static char getMyChar(String str,int d)
{
if(d<str.length())
return str.charAt(d);
else
return 0;
}
@SuppressWarnings("unchecked")
public static void recursive(String[] strs,int lo,int hi,int d)
{
if(lo>=hi)
return ;
ArrayList<String>[] ArrayListBuckets=new ArrayList[128];
ArrayList<Integer> record=new ArrayList<Integer>();
record.add(0);
for(int i=0;i<ArrayListBuckets.length;i++)
ArrayListBuckets[i]=new ArrayList<String>();
for(int i=lo;i<=hi;i++)
ArrayListBuckets[getMyChar(strs[i],d)].add(new String(strs[i]));
int idx=0;
for(ArrayList<String> al:ArrayListBuckets)
{
if(al.size()!=0)
{
record.add(record.get(record.size()-1)+al.size());
for(int i=0;i<al.size();i++)
{
strs[lo+idx]=al.get(i);
idx++;
}
}
}
for(int i=1;i<record.size();i++)
recursive(strs,lo+record.get(i-1),lo+record.get(i)-1,d+1);
}
public static void main (String[] args)
{
String[] strs1={"b", "c", "bd","ba"};
radixSort1(strs1);
print(strs1);
System.out.println();
String[] strs2={"COW","DOG","SEA","RUG","ROW","MOB","BOX","TAB","BAR","EAR","TAR","DIG","TEA","NOW","FOX","DO","CORN"};
radixSort1(strs2);
print(strs2);
}
简直完美!!!