前面的章节证明了只使用比较的任意一般排序算法在最坏情形下都需要Ω(N log N)时间,但在某些特殊情形下,以线性时间进行排序仍然是可能的。
桶式排序
一个简单例子就是桶式排序(bucket sort)。要使桶式排序能够正常运行,必须要有一些附加的信息。输入A1, A2, … , AN 必须只由小于 M 的正整数构成。这种情况下算法很简单:使用一个大小为 M 的叫作 count 的数组,它被初始化为全0。于是,count有M个单元或称桶(bucket),初始时这些桶均为空。当读入 Ai 时,count[Ai] 增1。在所有的输入数据读入后,扫描数组count,打印出排序后的数据。该算法用时O(M+N),如果M为O(N),那么总量就是O(N)。
基数排序
基数排序有时候也称为卡片排序(card sort),因为在现代计算机出现之前它一直用于分类老式穿孔卡片。
算法描述
假设有10个 0~999 范围内的数,要对它们排序。一般说来,这是在 0~bp-1 范围内的 N 个数,其中 p 是某个常数。显然不能用桶式排序,因为那样桶就太多了。诀窍在于使用几趟桶式排序。自然的算法是通过最高有效位 (基底为b) 进行的桶式排序,然后再对下一个最高位进行,等等。不过,更简单的做法是以反序进行桶式排序,首先从最低有效位开始。当然,可能不止一个数会落入同一个桶中,而且不像原始的桶,这些数可能是不同的,因此我们把它放到一个表中。每一趟都是稳定的:与当前数字一致的那些项保持着前面各趟中确定的排序。
一个简单例子
我们对 64, 8, 216, 512, 27, 729, 0, 1, 343, 125 进行排序,这是随机排列的前 10 个立方数 (我们用0补位使得十位数字和百位数字更清楚)。
在第一趟排序之后,各项按最低有效位被排序,一般说来,在第 k 趟排序后,各项按第 k 最低有效位被排序。于是最后各项被彻底排完序。事实上,该算法是正确有效的。注意到,只有两个数以错误的顺序出自同一个桶的情况下才有可能发生失败。但是前一趟排序保证,当多个数进入一个桶的时候,他们是按照第 k-1 位有效数字排序的顺序进行的。
初 始的项: 064 ,008, 216, 512, 027, 729, 000, 001, 343, 125
个位数字的排序:000, 001, 512, 343, 064, 125, 216, 027, 008, 729
十位数字的排序:000, 001, 008, 512, 216, 125, 027, 729, 343, 064
百位数字的排序:000, 001, 008, 027, 064, 125, 216, 343, 512, 729
运行时间
算法的运行时间为O(p(N+b)),其中,p是排序进行的趟数,N 是被排序的元素的个数,而 b 则为桶的个数。
基数排序的应用
对于字符串排序,如果所有的字符串长度相同,均为 L,那么通过对每个字符使用桶式排序,我们可以以 O(NL) 时间实现基数排序。进行这种排序最直接的方法如下面的程序所示。该程序中,假设所有字符均为 ASCII 码,位于Unicode 字符集的前256个位置。在每一趟排序中,我们添加一项到相应的桶中,当所有的桶被填入相应的字符串之后,我们走遍各桶,将桶中的内容全部倒回数组中。注意,当一个桶被填入相应的项并在下一趟被清空时,当前趟的序是被保留的。
//字符串基数排序的简单实现,用到桶的ArrayList数组
/**
* 对string类对象的数组(array of strings)进行的基数排序
* 假设全部为ASCII码
* 并设所有字符串都有相同的长度
*/
void radixSortA(vector<string>& arr, int stringLen)
{
const int BUCKETS = 256;
vector<vector<string>> buckets(BUCKETS);
for (int pos = stringLen - 1; pos >= 0; --pos) //从最低有效位开始
{
for (string& s : arr)
buckets[s[pos]].push_back(std::move(s));
int idx = 0;
for (auto& thisBucket : buckets)
{
for (string& s : thisBucket)
arr[idx++] = std::move(s);
thisBucket.clear();
}
}
}
计数基数排序
计数基数排序 (counting radix sort) 是基数排序的另一种实现,它避免使用一些vector对象来表示桶,而是保留进入每一个桶有多少项的计数。可以把这些信息放到一个数组 count 中,使得 count [k+1] 就是位于第 k 个桶内的项数,在这个信息被计算之后,从最小下标到最大下标扫描 count 数组,并将 count [k] 增加 count [k-1],在这次扫描之后,计数数组count [k] 代表其值严格小于 k 的项数。
固定长度字符串的计数基数排序
在下面代码中,假设这些项存储在数组 in 中,而单趟的结果被放在数组 out 中。初始时,in 代表 arr 而 out 代表临时数组 buffer。每趟排序之后交换 in 和 out 的角色。如果有偶数趟,则最后的 out 引用的是 arr,因此排序完成。否则,我们必须从 buffer 移回到arr。
//固定长度字符串的计数基数排序
/**
* 对string类对象的数组(array of strings)进行的基数排序
* 假设全部为ASCII码
* 并设所有字符串都有相同的长度
*/
void countingRadixSort(vector<string>& arr, int stringLen)
{
const int BUCKETS = 256;
int N = arr.size();
vector<string> buffer(N);
vector<string>* in = &arr;
vector<string>* out = &buffer;
for (int pos = stringLen - 1; pos >= 0; --pos)
{
vector<int> count(BUCKETS + 1);
for (int i = 0; i < N; ++i)
++count[(*in)[i][pos] + 1];
for (int b = 1; b <= BUCKETS; ++b)
count[b] += count[b - 1];
for (int i = 0; i < N; ++i)
(*out)[count[(*in)[i][pos]]++] = std::move((*in)[i]);
//交换in和out的角色
std::swap(in, out);
}
//如果是奇数趟,in为buffer,而out则是arr;从而用move再将*in传给*out
if (stringLen % 2 == 1)
for (int i = 0; i < arr.size(); ++i)
(*out)[i] = std::move((*in)[i]);
}
可变长字符串的基数排序
基本算法是,首先按照长度将字符串排序,然后只看那些我们知道足够长的字符串,而根本不去查看所有的字符串。由于字符串长度是些小的整数,因此初始按照长度排序可以由桶式排序进行。
//可变长字符串的基数排序
/**
* 对string类对象的数组(array of strings)进行的基数排序
* 假设全部为ASCII码
* 并设所有字符串的长度均以maxLen为界
*/
void radixSort(vector<string>& arr, int maxLen)
{
const int BUCKETS = 256;
vector<vector<string>> wordsByLength(maxLen + 1);
vector<vector<string>>buckets(BUCKETS);
for (string& s : arr)
wordsByLength[s.length()].push_back(std::move(s));
int idx = 0;
for (auto& wordList : wordsByLength)
for (string& s : wordList)
arr[idx++] = std::move(s);
int startingIndex = arr.size();
for (int pos = maxLen - 1; pos >= 0; --pos)
{
startingIndex -= wordsByLength[pos + 1].size();
for (int i = startingIndex; i < arr.size(); ++i)
buckets[arr[i][pos]].push_back(std::move(arr[i]));
idx = startingIndex;
for (auto& thisBucket : buckets)
{
for (string& s : thisBucket)
arr[idx++] = std::move(s);
thisBucket.clear();
}
}
}
该版本基数排序的运行时间对于所有字符串中的总字符数而言是线性的。基于比较的 O(N log N) 排序算法一般在每次字符串比较中将只看少数的字符,所以,一旦平均字符串长度开始变大,则基数排序的优点会急剧减缩甚至完全消失。
排序小结
大部分内部排序都用到内存可直接寻址的事实。希尔排序用一个时间单位比较元素 a[ i ] 和 a[ i-hk ];堆排序用一个时间单位比较元素 a[ i ] 和 a[ i*2+1 ];使用三数中值分割法的快速排序以常数个时间单位比较 a[ left ]、a[ center ] 和 a[ right ]。
对于一般的内部排序应用,插入排序、希尔排序、归并排序或快速排序是经常选用的方法,究竟使用哪个方法依赖于输入的大小和底层的环境。插入排序适用于非常少量的输入;对于适量输入的排序,希尔排序是上佳选择,对于适当的增量序列,它表现出极好的性能且只有几行代码;归并排序具有 O(N logN) 最坏情形性能,但是需要额外的空间,但其使用的比较次数几乎是最优的,因为任何只使用元素比较的排序算法必然至少要用到对输入序列的 ⌈ log(N!) ⌉ 次比较;快速排序几乎是必然的 O(N logN) 性能,并且能够与堆排序结合而给出 O(N logN) 的最坏情形性能保证。另外,字符串可以通过使用基数排序而以线性时间完成排序。