首页 > 算法 数据结构 > 字符串排序新探索——使用基数排序

字符串排序新探索——使用基数排序

2012年7月7日 发表评论 阅读评论
文章作者:Yx.Ac   文章来源:勇幸|Thinking (http://www.ahathinking.com)   转载请注明,谢谢合作。

---

一个偶然的机会发现字符串排序也可以使用基数排序来实现,而且是一个很有意思的问题,因为这其中有一个渐进优化的过程,本文先考虑字符串排序的几种实现方法,然后从理论上分析使用基数排序的复杂度,最后将其与快排进行定性比较,从理论和实现两方面验证在字符串排序问题中,基数排序何时超越快排。

那么字符串排序有哪些方法呢?这里给出一个简洁列表,欢迎补充

  1. 快速排序:最简单最直接的方法,但是规模稍大,字符串大量移动和复制的开销会特别大。
  2. 先做地址索引,然后快排:目前来讲,这似乎应该是最优的方法,有效避免了字符串的移动和复制开销,时间复杂度为O(Lcmp*N*lgN)。Lcmp是字符串比较时的平均比较长度,这个我们稍后做定量计算。
  3. Trie树:这是我写本文草稿时忽然想到的,这种方法也不错,如果串有重复可以在节点设置一个计数域;优点是能有效节省空间,大规模字符串真能节省不少空间呢,内存有限情况下可以考虑,而且理论上复杂度是线性的O(Lave*N),Lave为字符串平均长度,N为串个数;缺点是什么呢?缺点是建树过程要不断的申请开辟空间,大规模情况下这个开销不可忽视;不过我觉得仍然可以一试,改天我再做一个测试,看看百万级规模情况下(硬件条件有限,千万级就做不来了)时间与快排比起来怎么样,本节稍后我们可以从理论上做一个大致的分析。
  4. 基数排序:以上三种方法应该说比较容易想到,也比较常规;偶然发现基数排序在这方面也是有用武之地的,而且某些情况下,它的性能是超越第二种快排方法的,同时在实现上,我们也可以将基数排序分配和收集过程中空间的申请和释放优化掉,甚至可以通过数据结构的有效设计来优化掉桶元素的添加和删除操作时的开销。基数排序的复杂度为O(Lmax*N),Lmax是待排字符串的最大长度,这也是限制基数排序的地方。
  5. Summary:个人认为在实际应用时,后三种方法都是可以考虑的,优先考虑索引快排和Trie树,在了解本文之后,可以知道某些特定情况下基数排序也是不错的选择。

下面说一下,基数排序字符串实现中的几个问题,几个关键词:

基数:在字符串排序中,基数为256,为了简化问题(主要因为考虑256,随机生成的字符串都太难看了:)),暂且只考虑英文字母,故考虑基数为26,程序随机生成的字符串文件全部为小写英文字母。

静态链:在基数排序实现时,一般使用数组,且将计数排序作为基数排序的一个子过程(见《算法导论》且注意计数排序的稳定性对于基数排序正确性的重要)。但是对于字符串排序问题,我们最好使用基数排序特有的“分配”与“收集”过程,为了方便后续的优化过程我们使用静态链表来存储待排序的字符串。

静态队列:这也是程序优化的一个结构,静态队列只有head与tail两个域;因为处理字符串的规模有些大,如果使用动态队列,在基数排序的分配与收集过程中,频繁的增加和删除操作,这种空间的不断申请与释放太耗时间了,使用静态队列的好处是与静态链相配合,只记录队列子链的始末位置,省去频繁入队出队操作的开销。下面会画图举个例子说明这个问题。

例如,我们在使用基数排序排序如下字符串时:abc,def,bbc,zaf,gfc,(为了简单起见,例子中所有字符串等长),以字符串的长度L为最低有效位,正常情况下在分配时,如下图,桶队列是要入队添加元素的,每个桶对应分配后的一个子链,c桶中存储的是末字符为c的字符串,f桶存储的是末字符为f的串;而在基数排序收集过程时每个桶队列要出队删除元素,对于大规模字符串来讲,这样的操作开销不容忽视,

简单更改数据结构就会避免这样的问题,字符串数组改为静态链,桶队列改为静态队列,如下图所示:

原来的入队出队操作由更改静态链的指针域所代替,同时更新静态队列中队列tail即可,例如在第一次分配时,桶队列中只记录每个子链的head和tail,实际的中间元素是在静态链中体现的,红色部分为第一个子链,蓝色部分是第二个子链,最后一个子链的末节点的next域为-1,指示着静态链的结尾。

为什么这样优化?

这样尽可能地避免基数排序过程中空间的分配和释放,使得理论分析和代码验证的结果尽可能吻合(刚开始忽略了这个问题,所以理论分析和程序验证很大差距),既然要与快排做对比,下面我们就从理论上分析基数排序的复杂度和快排的复杂度。

复杂度分析:

快排:由于字符串排序时,字符串内部是需要进行比较的,所以复杂度为O(Lcmp*N*lgN)。Lcmp是字符串比较时的平均比较长度,这个我们稍后做定量计算。

基数排序:用字符串的最大长度做基数排序的最低有效位,复杂度为O(Lmax*N),Lmax是待排字符串的最大长度,咋一看起来似乎是基数排序占优,几近线性,其实并非这样,它的优势是有条件的。

我们注意到,基数排序与快排的差别在于:Lmax  vs  Lcmp*lgN,这两个L是不同的:对于基数排序,由于是按“位”进行分配收集,故一定是字符串的最大长度,以我们的测试为准,随机生成100w的字符串,长度在[0-100]的随机数,那么基数排序的Lmax=100,快排的是多少呢?我们做个简单计算:考虑到字符串只有小写英文字母,那么快排比较两个字符串时,一次就命中的概率是很大的,为25/26,比较2次的概率为1/26*25/26,比较3次的概率为(1/26)^2*25/26,所以期望的比较次数设为L’’,如下图计算(其中用到了高中学的那个数列求和)

我们可以看到快排中比较的次数不超过2,所以这个还是很小的,关键就在于lgN了,百万字符串,lgN=20,所以谁快谁慢,一目了然,那如果字符串的长度比较短呢?例如20-30之间,再如果字符串的规模更大呢?千万级?十亿?这样的话基数排序的优势就出来了,这样我们就知道了,是选择快排还是基数排序,关键看字符串的长度和字符串的规模。

总结

基数排序:字符串的规模超过了亿级(lg 1亿=20-30),而字符串的长度没有超过20,那么选择基数排序;如果字符串的规模稍小,例如百万级,字符串的长度很短(例如介于10-20),那么选用基数排序,总之看Lmax与lgN的差别了,Lcmp的影响基本可以忽略

快排:如果字符串很长,规模又不大,那么就用快排

分析了这么多,程序结果与分析会吻合么?下面附上实现代码和对比结果:

  1. 百万随机字符串,字符串长度在[1,20]随机,基数排序优于快排,虽是毫秒级,规模大就不一样了
  2. 百万随机字符串,字符串长度在[1,100]随机,基数排序时间约是快排的5倍
  3. 关键看字符串的最大长度与规模的对数级比较了

总代码如下(包括随机生成字符串,输出文件,基数排序与快排比较):

#include <iostream>
#include <fstream>
#include <string>
#include <ctime>
#include <windows.h>
using namespace std;

/* 字符串排序 使用基数排序 */
#define STRING_N 1000000  // 字符串最大数量
#define MAXCHAR 21  // 字符串最大字符数
#define RADIX 256    // 基数

int maxStrlen;      // 字符串最大长度作为最低有效位
int strNum;         // 待排字符串数量

/* 静态链 */
struct Node
{
	char str[MAXCHAR];
	int len;
	int next;
}strArr[STRING_N];

char *strArrcopy[STRING_N]; // 快排使用

/* 桶,每个桶是一个链队列,记录各桶子链的始末元素下标 */
struct Bucket
{
	int head;
	int tail;
}bucketQueue[RADIX];

/* 基数排序 返回排序后静态链第一个元素的下标 */
void Allocation(Node * arr, int first_index, int i_th);
void Collection(Node * arr, int & first_index);

int Radix_sort(Node * arr, int digits)
{
	int first_index = 0;
	for(int i = digits - 1; i >= 0; --i)
	{
		Allocation(arr, first_index, i);
		Collection(arr, first_index);
	}
	return first_index;
}

/* 分配过程 first_index 静态链第一个节点下标  i_th 为第i_th([0-n-1])个字符(排序码)
 * 分配之后,静态链中实际是分成了radix个子链,对应的每个桶中存储子链的始末下标
 */
void Allocation(Node * arr, int first_index, int i_th)
{
	for(int i = 0; i < RADIX; ++i)     // 置桶为空
	{
		bucketQueue[i].head = -1;
	}
	int index = first_index, key_ith;  // key_ith 串第i_th个字符
	while(index != -1)                 // 分配
	{
		key_ith = 0;                   // 默认串的第i_th字符为空
		if(arr[index].len > i_th)
		{
			key_ith = arr[index].str[i_th]; // 取第i_th个排序码
		}
		if(bucketQueue[key_ith].head == -1)    // 桶为空
		{
			bucketQueue[key_ith].head = index;
		}else
		{
			arr[bucketQueue[key_ith].tail].next = index;  // 更新子链
		}
		bucketQueue[key_ith].tail = index;     // 更新子链的末指针
		index = arr[index].next;
	}
}

/* 收集 first_index 引用记录收集后的静态链第一个元素下标 */
void Collection(Node * arr, int & first_index)
{
	int q_index = 0;  // 桶队列下标
	int last_index;   // 记录已收集子链的末指针
	while(bucketQueue[q_index].head == -1)
	{
		++q_index;
	}
	first_index = bucketQueue[q_index].head;
	last_index = bucketQueue[q_index].tail;

	while(++q_index < RADIX)
	{
		while(q_index < RADIX && bucketQueue[q_index].head == -1)
		{
			++q_index;
		}
		if(q_index < RADIX && bucketQueue[q_index].head != -1)
		{
			arr[last_index].next = bucketQueue[q_index].head;
			last_index = bucketQueue[q_index].tail;
		}
	}
	arr[last_index].next = -1;
}

/* 输出函数*/
void output(Node * arr, int first_index)
{
	if(!arr)
	{
		printf("Error");
		return ;
	}
	FILE * outFile = fopen("result.txt","wt");
	if(!outFile)
	{
		printf("Open file Error\n");
		exit(1);
	}
	while(first_index != -1)
	{
		fputs(arr[first_index].str, outFile);
		first_index = arr[first_index].next;
	}
	fclose(outFile);
}

/* 快排使用的比较函数 */
int pstrcmp(const void *p, const void *q)
{
	return strcmp(*(char**)p, *(char**)q);
}
/*快排输出函数*/
void output_qsort(char ** arr)
{
	if(!arr)
	{
		printf("Error");
		return ;
	}
	FILE * outFile = fopen("qsort_result.txt","wt");
	if(!outFile)
	{
		printf("Open file Error\n");
		exit(1);
	}
	for(int i = 0; i < STRING_N; ++i)
	{
		fputs(arr[i], outFile);
	}
	fclose(outFile);
}

void main()
{
	/* 随机生成1000000行字符串到文件strTest.txt */
	////去掉注释生成字符串/*
	int strLen = 1;
	char strtmp[MAXCHAR] = {0};
	FILE * toFile = fopen("strTest.txt","wt");
	if(!toFile)
	{
		printf("Open file Error\n");
		exit(1);
	}

	srand((unsigned)time(0));
	for(int i = 0; i < STRING_N; ++i) // 生成1000000行字符串
	{
		int l = rand() % (MAXCHAR-1); // 生成一个字符串的长度
		strLen = l > 1 ? l : 1;
		for(int j = 0; j < strLen; ++j)
		{
			strtmp[j] = 'a' + rand() % 26;
		}
		strtmp[strLen] = '\0';
		fputs(strtmp, toFile);
		fputs("\n", toFile);
	}
	fclose(toFile);
	//*/
	//-------------------------------------

	FILE * infile = fopen("strTest.txt","rt");
	if(!infile)
	{
		printf("Open Error\n");
		exit(1);
	}
	char input[MAXCHAR] = {0};
	int i = 0;

	DWORD take = GetTickCount();

	while(fgets(input,MAXCHAR,infile))   // 初始化静态链
	{
		strcpy(strArr[i].str, input);
		strArrcopy[i] = strArr[i].str; // 初始化字符指针数组
		strArr[i].len = strlen(input);
		strArr[i].next = i + 1;
		++strNum;
		if(strArr[i].len > maxStrlen)
		{
			maxStrlen = strArr[i].len;
		}
		++i;
	}
	strArr[i-1].next = -1;  // 链末
	fclose(infile);         // 关闭文件

	printf("\nReading time: %ld\n", GetTickCount() - take);

	DWORD take_q = GetTickCount();  // 快排时间
	qsort(strArrcopy,STRING_N,sizeof(char *),pstrcmp);
	printf("\nQsort time: %ld\n", GetTickCount() - take_q);

	DWORD take_r = GetTickCount();  // 基数排序时间
	int first_index = Radix_sort(strArr, maxStrlen);
	printf("\nRadix_sort time: %ld\n", GetTickCount() - take_r);

	output(strArr,first_index);  // 输出到文件

	output_qsort(strArrcopy);
}

附上输出结果如下:均是百万字符串,左边为长度在[1,20]随机,右边为长度在[1,100]随机,输出时间为毫秒。

注:这里并不是要较真基数排序与快排谁厉害,一师兄说的好,一般来讲,还是基于比较的排序为王道,那些非比较的排序虽然看起来可能为线性,但一般都是适用于特定情况下,而且常数因子也不一样,更不说还需要额外空间了,至少快排还是原地排序;故这里只是为字符串排序问题提供另外一种思路,而且本身这个问题在实现上也比较有趣,所以就啰嗦了点。

此外在实现这个问题时,我顺便写了计数排序,基数排序的数组实现,然后是基数排序链表实现的代码,一并附在这里,方便自己以后查找回顾,免得后来还要重拾。

计数排序

基数排序数组实现

基数排序静态链表实现

本文相关代码可以到这里下载。

(全文完)

参考资料:

《算法导论》计数排序、基数排序

  • http://www.heykings.com/ KING

    好文!有理有据,可惜不够详细.
    PS:有个错误.插图"待排字符串静态链"中,gfc下一个指针应该是-1.