排序4-归并排序与快速排序
排序3-插入选择排序
排序2-冒泡排序
排序1-几大经典排序算法
这篇就算是排序系列的最后一篇了,本想着再搞一个总结式的通用排序函数,实际上也实现了一多半了,但是发现要优化到与 C 库里面的 qsort 一样的速度还是蛮困难的,于是就作罢了,时间紧迫,暂时以先掌握排序的思想为主,优化的工作留给以后的闲暇时间来搞。
所谓线性排序就是时间复杂度在 O(N) 的排序算法,它们大多不是基于比较的,或者说最核心的地方不是基于比较的。本文里面涉及到的线性排序算法有「桶排序」、「计数排序」、「基数排序」。
除此之外,还加入了另外一种排序,就是「堆排序」,这个按道理来讲不是线性排序,但是先放进本文一并说啦,然后后续的数据结构与算法里面还会涉及到堆的使用,到时再加以补充说明。
桶排序的代码我没有去写,因为两个原因:一个是它真的很简单,很容易理解并实现;另一个是一个合格的桶排序会需要用到链表什么的,并且优化起来也是会费那么一点时间的。说这么多废话就是我嫌麻烦,不想写,并且觉得写了价值不是很大。
之前有写过一个「V4L2框架-control的数据结构」,这篇文章里面有涉及到对桶排序的介绍,以及相关的代码介绍,可以参考这里:「V4L2框架-control的数据结构」。
桶排序的基本原理是数据的分类,对于一个待排序数组来说,它是有一定的范围要求的,假设说现在有一个范围是 1~100 的百万数量级别的待排序数组,那么我们可以先创建 100 个桶,然后遍历这个大数组,把值为 N 的数据放在第 N 个桶里面,数组遍历完成之后再按照顺序依次取出,结果就是有序的了。
这个时候有几个问题,一个是桶里面到底存放的是什么数据,是不是把原来的数组数据全部拷贝一份存放?还是只需要存放数据的索引即可。如果待排序数据的单个个体非常大,并且数量多,那么显然拷贝不是一个非常有效的做法,此时可以选择虚拟一个链表型数据结构,例如:
struct bucket_elem {
void *data;
void *val; //int val;
struct bucket_elem *next;
//struct bucket_elem *prev;
};
上面的 @data
可以指向实际的数据位置,@val
可以完全拷贝一份原始数据的排序值或者仅让其指向待排序数据的排序值位置即可,后面就是单链表或者双链表的结构,用于组织每一个桶内的数据。如果数据量巨大的话,我们最终排序出来的结果就是一个子元素是上述结构体的单向链表或者双向链表,然后索引的时候就去索引这个链表结构,通过它间接找到最终的原始数据,而不是直接找原始数据。
可以看到,上面的方式其实对内存 cache 命中率是不是十分友好的,因为排序的最终结果肯定使得链表指向的具体数据十分分散,但是对于个体数据量巨大的数据来说,这个是可以忍受的。而对于另外一些数据量比较小的原始数据来讲,就可以完全拷贝一份到桶里面了。
还有一个就是内存的分配,如何预先分配每一个桶里面的数据个数呢?总不能来一个分配一个吧,那样效率显得太低了,可以选择为每一个桶预先分配一定数量的数据,然后等到空间用完之后再去分配,反正是以链表的形式组织,新分配的空间很容易方便的加入到原来的序列里面。
对于大多数数据来讲,每一个桶里面最终存放的数据肯定不是非常均匀的,极端情况下会出现大量的数据全部挤在其中几个桶里面,这样排序的效果就会稍稍的退化,但是可以控制,比如我们的桶分的很细,以 1 个排序单位为粒度的这种,那么不管你数据怎么集中,我都能保证绝对的 O(N) 时间复杂度。但是如果粒度过大,那如果出现过于集中的情况下,时间复杂度会极端退化为 O(N*N) 的情况(取决于桶内排序的算法最坏时间复杂度)。
可以看到,同排序适合原始排序数据的值范围有限,并且在不能保证桶粒度的情况下,数据要尽量的均匀分布,如果能够保证桶的粒度,那就不必关注数据是否均匀分布。除去这些约束之外,使用桶排序并不能很好的发挥出其优势。
数据结构 | 数组或者链表 |
最坏时间复杂度 | O(N*N) |
平均时间复杂度 | O(N+K) |
最坏空间复杂度 | O(N*K) |
基本原理就是:
代码如下:
void classic_counting_sort(int *sort_butt, int size)
{
int i = 1;
int radix_butt[SORT_RANGE];
int *radix_arry = malloc(sizeof(int)*size);
/* Should check the maxmum and minimum value of the sorted array. */
memcpy(radix_arry, sort_butt, sizeof(int)*size);
memset(&radix_butt[0], 0, sizeof(radix_butt));
for (i = 0 ; i < size; i++) {
radix_butt[radix_arry[i]]++;
}
int last_tmp = 0;
i = 0;
while (radix_butt[i] == 0 && i < 10)
i++;
last_tmp = radix_butt[i];
i++;
for (; i < SORT_RANGE; i++) {
if (radix_butt[i] > 0) {
radix_butt[i] = radix_butt[i] + last_tmp;
last_tmp = radix_butt[i];
}
}
int radix_idx = 0;
for (i = size-1; i >= 0; i--) {
radix_idx = radix_arry[i];
radix_butt[radix_idx]--;
sort_butt[radix_butt[radix_idx]] = radix_arry[i];
}
free(radix_arry);
}
其中 @SORT_RANGE
就是上面步骤里面说的 N+1,这个代表了计数数组的长度,代码很简单,就是按照上面说的步骤来的,多了一个步骤就是申请一个暂存数组,并且拷贝原始数据到暂存数组里面,因为后续需要对原始数据进行扫描与重排,一个原始数组搞不定。
数据结构 | 数组 |
最坏时间复杂度 | O(N+K) |
平均时间复杂度 | O(N+K) |
最坏空间复杂度 | O(N+K) |
计数排序时间真的是很快很快。从上面的数据来看,计数排序不适合用于排序数据范围特别大的数据,想想如果范围是千万、亿级别的,岂不是要创建那么多个数组项来存放,如果范围大,数据量又少,那用这种排序方式简直是自寻死路,所以有了「基数排序」。
基数排序可以看作是「计数排序」的扩展用法,它把一个很大的数据按照数据位来切分,每次都先排一个位,从后往前遍历数据位,就比如:217384638584756387 这么长的定长数据,我们就可以使用基数排序,从后往前依次取一个位,把这个位使用计数排序先排好,然后往前遍历。
void classic_counting_sort(int *sort_butt, int *radix_arry, int size, int len_idx)
{
int i = 1;
int radix_butt[10];
memset(&radix_butt[0], 0, sizeof(radix_butt));
for (i = 0 ; i < size; i++) {
radix_butt[(radix_arry[i]/(int)pow(10,len_idx))%10]++;
}
int last_tmp = 0;
i = 0;
while (radix_butt[i] == 0 && i < 10)
i++;
last_tmp = radix_butt[i];
i++;
for (; i < 10; i++) {
if (radix_butt[i] > 0) {
radix_butt[i] = radix_butt[i] + last_tmp;
last_tmp = radix_butt[i];
}
}
int radix_idx = 0;
for (i = size-1; i >= 0; i--) {
radix_idx = radix_arry[i]/(int)pow(10,len_idx)%10;
radix_butt[radix_idx]--;
sort_butt[radix_butt[radix_idx]] = radix_arry[i];
}
}
void classic_radix_sort(int *sort_butt, int size)
{
int *radix_arry = malloc(sizeof(int)*size);
int len_idx = 0;
memcpy(radix_arry, sort_butt, sizeof(int)*size);
for (len_idx = 0; len_idx < MAX_LEN; len_idx++) {
if (len_idx%2 == 0)
classic_counting_sort(sort_butt, radix_arry, size, len_idx);
else
classic_counting_sort(radix_arry, sort_butt, size, len_idx);
}
free(radix_arry);
}
我改写了下基数排序的代码,加了一个位索引的参数,这样每次排序的时候都使用指定的位来进行排序,上面的代码我默认了 @MAX_LEN
是奇数,最终排出来原始数组里面的数据就是有序的了。
可以猜想到,基数排序适合于很长的夹杂了字母、数字、符号等数据的排序,因为那种使用普通的比较排序是根本无法做到的,因为你就没法整体的去做「比较」这个动作。
数据结构 | 数组 |
最坏时间复杂度 | O(K*N) |
最坏空间复杂度 | O(N+K) |
void classic_heap_sort(int *sort_butt, int size)
{
int heap_cnt = size;
int i = 0;
/* Parent: i, left_cld: 2*i+1, right_cld: 2*i+2 */
int non_leaf_node = 0;
int max_val_pos = 0;
int swap_tmp = 0;
for (i = 0; i < size-1; i++) {
/* Find the first unsorted non-leaf node, and heap the list. */
non_leaf_node = heap_cnt/2-1;
while (non_leaf_node >= 0) {
max_val_pos = non_leaf_node;
if (sort_butt[max_val_pos] < sort_butt[2*non_leaf_node+1])
max_val_pos = 2*non_leaf_node+1;
if (2*non_leaf_node+2 < heap_cnt && sort_butt[max_val_pos] < sort_butt[2*non_leaf_node+2])
max_val_pos = 2*non_leaf_node+2;
if (max_val_pos == non_leaf_node) {
non_leaf_node--;
continue;
}
swap_tmp = sort_butt[max_val_pos];
sort_butt[max_val_pos] = sort_butt[non_leaf_node];
sort_butt[non_leaf_node] = swap_tmp;
non_leaf_node--;
}
heap_cnt--;
swap_tmp = sort_butt[0];
sort_butt[0] = sort_butt[heap_cnt];
sort_butt[heap_cnt] = swap_tmp;
}
}
这个只贴了代码,如果要介绍堆的话还是需要点篇幅的,准备放在后面搞,简单说下堆。它是一种完全二叉树结构,完全二叉树的特点使得它能够按照数组的形式去存储,而不必使用链表,它具有以下性质(我默认数组是从下标0处开始存放数据):
从上面的性质来看,堆排序对缓存命中率是十分地不友好,所以虽然它的时间复杂度与快排类似,但是却慢很多,因为交换次数以及缓存命中率的缘故(堆排序可能会导致很多的无效交换操作,严重拉低排序效率),堆排序涉及到建堆、堆化两个操作,具体的就先点到为止了,放到之后的文里面再说。其实堆更适合用于查找指定位置的数据以及最 Top N 的数据,而不是排序。
好久没有更新有关于工作啊之类的感悟了,下次就写下如何刷存在感吧,存在感是个不能没有,但是也不能过的东西,过了很容易遭至厌烦,没有就会处于很被动的状况。
Github 代码链接:链接
代码阅读原文可以看到。