第一章 数据结构与算法之qsort
数据结构与算法之美
目录
以下部分的代码逻辑,都是针对某一块待排序的分段[lo, hi]进行快排(分段粒度大于thresh阀值), 当所有分段快排完,也就意味着整个快排逻辑的完成(循环完成递归实现),也就是:
这样,通过do-while双向循环遍历,完成当前分段的快排,结果变成左分段[lo, right_ptr], mid, 右分段[left_ptr, hi]
前言
分析glibc-2.33的qsort的源码
一、源码分析
1.结构体定义
typedef struct
{
char *lo;
char *hi;
} stack_node;
功能:排序算法过程中,从左到右的遍历指针
hi表示指向高地址内存的指针
功能:排序算法过程中,从右到左的遍历指针
stack_node名称可以看出来,是为“栈”设计的数据结构体
功能:储存待排序的分段的首尾地址(压入栈中),获取下一个待排序的分段的首尾地址(出栈)
2. 栈定义
#define STACK_SIZE (CHAR_BIT * sizeof (size_t))
#define PUSH(low, high) ((void) ((top->lo = (low)), (top->hi = (high)), ++top))
#define POP(low, high) ((void) (--top, (low = top->lo), (high = top->hi)))
#define STACK_NOT_EMPTY (stack < top)
STACK_SIZE 栈大小
功能:stack栈变量的空间分配大小(数组,一片连续的内存地址块)
PUSH 入栈
功能:保存待排序的分段的首尾地址,然后入栈(++top), top初始值指向stack首地址
POP 出栈
功能:获取下一个待排序的分段的首尾地址,出栈(--top)
STACK_NOT_EMPTY 栈数据不为空
功能:排序过程中,循环判断是否还存在下一个待排序的分段
3.如何避免递归函数调用的栈溢出
方案一:限制递归深度
缺陷比较多,为了保护系统不崩溃而牺牲问题的真正解决
方案二:堆上(内存)模拟实现函数调用栈
qsort排序就是用了这个方案。
也就是前面提到的stack_node数据结构及PUSH, POP的实现定义
4.数据交换
/* Byte-wise swap two items of size SIZE. */
#define SWAP(a, b, size) \
do \
{ \
size_t __size = (size); \
char *__a = (a), *__b = (b); \
do \
{ \
char __tmp = *__a; \
*__a++ = *__b; \
*__b++ = __tmp; \
} while (--__size > 0); \
} while (0)
基于字节宽度的数据交换(其他高级语言比如java都是底层实现,不用这么麻烦)
数据块a和数据块b,数据块大小是size
数据交换的原理:
1、取数据块a的首地址__a, 取数据块b的首地址__b
2、循环交换一个字节宽度的数据
char __tmp = *__a; \
*__a++ = *__b; \
*__b++ = __tmp;
3、__a++, __b++, 交换完数据,两个数据块头指针递增一个字节宽度
4、--__size,按字节大小递减,最终循环完整个size大小,也就意味着a, b两个数据块的数据交换完成
注意:这个数据交换,并不改变原始的a, b值(a, b也是指针)
5. qsort函数体的实现
1. 函数定义
void
_quicksort (void *const pbase, size_t total_elems, size_t size,
__compar_d_fn_t cmp, void *arg)
pbase 待排序数据块的首地址
total_elems 待排序的数据个数
size 待排序数据的元素数据大小
cmp 数据的比较函数(具体由使用者提供函数实现)
arg 其他参数,主要用于cmp比较函数,使用者传递的参数
2. thresh阀值
char *base_ptr = (char *) pbase;
const size_t max_thresh = MAX_THRESH * size;
if (total_elems == 0)
/* Avoid lossage with unsigned arithmetic below. */
return;
if (total_elems > MAX_THRESH)
{
....
}
....
这里定义了一个非常关键的thresh:
MAX_THRESH设计为4(这个是可以根据算法的性能优化,适配的阀值),也就是以4个size为基准
if (total_elems > MAX_THRESH)
{
....快速排序
}
....插入排序
如果数据个数大于MAX_THRESH阀值,则优先处理(进入快速排序)
如果数据个数小于或者等于MAX_THRESH阀值,则直接处理(进入插入排序)
3. 快速排序(quickSort)
1> 变量初始化
char *lo = base_ptr;
char *hi = &lo[size * (total_elems - 1)];
stack_node stack[STACK_SIZE];
stack_node *top = stack;
lo指向首地址,也是指向第一个数据的内存地址
hi指向最后一个数据元素的内存地址
&: 取址,&x,表示指向x的内存地址,这里是char*指针类型
total_elems - 1: 数据总个数为total_elems,-1是为了获取最后一个数据的内存地址
size:因为是char*字节指针类型,需要乘以size大小(size可以是任意个字节大小,通用性的设计)
&lo[size * (total_elems - 1)]:C语言可以对指针按[]数组下标操作原理,这个表达式的意思就是从lo(首地址)开始,地址偏移total_elems - 1个元素,也就是最后一个数据地址,然后取址
stack 分配栈空间
top 栈指针指向stack首地址
2> 哨兵原理设计
PUSH (NULL, NULL);
while (STACK_NOT_EMPTY)
{
....
}
PUSH(NULL,NULL); 哨兵原理设计
特别巧妙,简化代码设计
技巧:首次 while (STACK_NOT_EMPTY)循环肯定可以进去处理,当后续有PUSH操作的时候,也就是有待排序分段入栈,当循环操作完,POP出栈后,如果是最后一次POP(其实POP的是NULL数据),这个时候直接退出循环了,也就是不需要去处理最后一次POP的数据
以下部分的代码逻辑,都是针对某一块待排序的分段[lo, hi]进行快排(分段粒度大于thresh阀值), 当所有分段快排完,也就意味着整个快排逻辑的完成(循环完成递归实现),也就是:
while (STACK_NOT_EMPTY)
{
分段的快排处理代码块
}
3> 快速排序的分区点选择
char *left_ptr;
char *right_ptr;
char *mid = lo + size * ((hi - lo) / size >> 1);
if ((*cmp) ((void *) mid, (void *) lo, arg) < 0)
SWAP (mid, lo, size);
if ((*cmp) ((void *) hi, (void *) mid, arg) < 0)
SWAP (mid, hi, size);
else
goto jump_over;
if ((*cmp) ((void *) mid, (void *) lo, arg) < 0)
SWAP (mid, lo, size);
jump_over:;
left_ptr = lo + size;
right_ptr = hi - size;
pivot值用的是三数取中法(头,中间,尾)三个数据取中间值
left_ptr = lo + size;
right_ptr = hi - size;
在三数取中法后,做了如上操作
left_ptr指向的是头方向的第二个数据
right_ptr指向的是尾方向的倒数第二个数据
因为在三数取中法的时候,头尾数据已经排序过了,不需要再次排序
4> 双向遍历,完成当前分段的快排
do
{
while ((*cmp) ((void *) left_ptr, (void *) mid, arg) < 0)
left_ptr += size;
while ((*cmp) ((void *) mid, (void *) right_ptr, arg) < 0)
right_ptr -= size;
if (left_ptr < right_ptr)
{
SWAP (left_ptr, right_ptr, size);
if (mid == left_ptr)
mid = right_ptr;
else if (mid == right_ptr)
mid = left_ptr;
left_ptr += size;
right_ptr -= size;
}
else if (left_ptr == right_ptr)
{
left_ptr += size;
right_ptr -= size;
break;
}
}
while (left_ptr <= right_ptr);
遍历思路:
1、头部第二个数据开始遍历,直到数据块大于或者等于pivot值,也就是mid数据块,这个时候left_ptr指向的就是不小于pivot值的数据块
2、尾部倒数第二个数据开始遍历,直到数据库小于或者等于pivot值,也就是mid数据块,这个时候right_ptr指向的就是不大于pivot值的数据块
3、left_ptr只可能是<=right_ptr, 因为left_prt <=mid, right_ptr >= mid。
当left_ptr < right_ptr,就意味着:left_ptr指向的值是>=pivot, 而right_ptr指向的是<=pivot,并且不是同一个位置数据,这个时候需要swap两个指针指向的数据块,也就是:
if (left_ptr < right_ptr)
{
SWAP (left_ptr, right_ptr, size);....
}
同时,需要更新mid位置:
如果mid==left_ptr,就意味着pivot值的位置由mid(left_ptr)交换到了right_ptr位置,所以,mid = right_ptr;
如果mid==right_prt, 就意味着pivot值的位置由mid(right_ptr)交换到了left_ptr位置,所以,mid = left_ptr;
if (left_ptr < right_ptr)
{
....
if (mid == left_ptr)
mid = right_ptr;
else if (mid == right_ptr)
mid = left_ptr;...
}
最后,
left_ptr往右移一个数据:因为left_ptr指向数据已经快排完成
right_ptr往左移一个数据,因为right_ptr指向数据已经快排完成
if (left_ptr < right_ptr)
{
....
left_ptr += size;
right_ptr -= size;
}
当left_ptr = right_prt,就意味着两个指针都指向mid(pivot值位置),也就意味着完成了该分段的快排,退出while循环
这里同样对left_ptr和right_prt做了指针往前移动的处理,与上面的指针移动思路是否一样?
虽然作用是一样的,不过,思维的角度是不一样的,具体看后面的解释
else if (left_ptr == right_ptr)
{
left_ptr += size;
right_ptr -= size;
break;
}
4、浓缩看下do-while循环处理:
do
{....
if (left_ptr < right_ptr)
{
SWAP (left_ptr, right_ptr, size);
....
left_ptr += size;
right_ptr -= size;
}
else if (left_ptr == right_ptr)
{
left_ptr += size;
right_ptr -= size;
break;
}
}
while (left_ptr <= right_ptr);
这个双向遍历循环代码块,就是为了完成当前待排序数据分段的快排,快排完成的临界点就是left_ptr == right_ptr, 也就是都指向的是mid(pivot)位置
那如下代码作用:
1、如果left_ptr < right_ptr的情况,就是往mid方向偏移,然后do-while循环处理
2、如果left_ptr == right_ptr的情况,其实该待排序数据分段的快排已经完成了[lo,hi] 变成了左分段[lo,left_ptr(mid)]和右分段[right_ptr(mid),hi],
这个时候为了代码实现的统一,通过两个指针的向前偏移,变成左分段[lo,right_ptr]和右分段[left_ptr,hi]
left_ptr和right_ptr不再指向mid,而是指向的mid两侧数据块,mid位置数据块是不需要再快排的
left_ptr += size;
right_ptr -= size;
这样,通过do-while双向循环遍历,完成当前分段的快排,结果变成左分段[lo, right_ptr], mid, 右分段[left_ptr, hi]
5> 下一个分段的快排处理
if ((size_t) (right_ptr - lo) <= max_thresh)
{
if ((size_t) (hi - left_ptr) <= max_thresh)
/* Ignore both small partitions. */
POP (lo, hi);
else
/* Ignore small left partition. */
lo = left_ptr;
}
else if ((size_t) (hi - left_ptr) <= max_thresh)
/* Ignore small right partition. */
hi = right_ptr;
else if ((right_ptr - lo) > (hi - left_ptr))
{
/* Push larger left partition indices. */
PUSH (lo, right_ptr);
lo = left_ptr;
}
else
{
/* Push larger right partition indices. */
PUSH (left_ptr, hi);
hi = right_ptr;
}
基本思想:
1、如果分段的数据块个数小鱼或者等于thresh阀值,则不再进行快排(在 while (STACK_NOT_EMPTY))循环中不再处理)
2、两个分段,分段1和分段2,数据块少的分段优先处理,数据块多的分段入栈stack,稍后处理
出栈:
当两个分段的数据块的个数都小于或者等于thresh阀值的时候,也就意味着这两个分段不再使用快排,也就是不再继续分段,POP处理
POP处理:
取得下一个需要快排的分段(这个分段是之前入栈保存的分段)
通过while (STACK_NOT_EMPTY)循环,重新三数取中值,双向遍历快排,是否继续分段还是快排完成(这个就是模拟递归函数调用的过程)
while (STACK_NOT_EMPTY)循环退出的临界:
POP出栈最后一个数据(也就是函数开始时的PUSH(NULL,NULL)数据), 获得的是NULL数据,wihle循环条件不满足(栈为空)退出循环
这样,就完成了所有的分段的快排
剩下的就是一个个小于thresh数据块长度的小分段还没有完成排序(后面直接使用插入排序算法)
if ((size_t) (right_ptr - lo) <= max_thresh)
{
if ((size_t) (hi - left_ptr) <= max_thresh)
/* Ignore both small partitions. */
POP (lo, hi);....
}
入栈:
两种情况:
左分段[lo, right_ptr],右分段[left_ptr, hi]
前提是两个分段的数据个数都是大于thresh阀值的
1、左分段数据块比较多
左分段入栈:PUSH (lo, right_ptr);
lo指针指向右分段的left_ptr(右分段的首地址)
进入while (STACK_NOT_EMPTY)循环开始快排右分段
2、右分段数据块比较多
右分段入栈:PUSH (left_ptr, hi);
hi指针指向左分段的right_ptr(尾倒数第一个数据内存地址)
进入while (STACK_NOT_EMPTY)循环开始快排左分段
else if ((right_ptr - lo) > (hi - left_ptr))
{
/* Push larger left partition indices. */
PUSH (lo, right_ptr);
lo = left_ptr;
}
else
{
/* Push larger right partition indices. */
PUSH (left_ptr, hi);
hi = right_ptr;
}
4、插入排序(InsertSort)
qsort使用到的第二个排序算法
前面快排的分段粒度是大于thresh阀值,也就意味着所有小于或者等于thresh阀值的分段还没有真正完成最后的排序
这里使用了插入排序算法,一次遍历所有的元素,完成最终的排序
1> 变量定义
char *const end_ptr = &base_ptr[size * (total_elems - 1)];
char *tmp_ptr = base_ptr;
char *thresh = min(end_ptr, base_ptr + max_thresh);
char *run_ptr;
end_ptr: 指向最后个元素数据的内存地址
tmp_ptr: 指向首地址,插入算法,分段思想,[已排序,待排序]两个分段,不断从待排序拿到元素,遍历已排序,插入到对应位置。这里的tmp_ptr指向的就是已排序分段的尾地址,遍历已排序区间从tmp_ptr往前遍历
thresh:如果所有元素个数本身就小于thresh阀值的特殊情况处理,避免越界溢出
run_ptr: 指向从插入算法的待排序区间获得的数据(用来插入的数据)的内存地址,遍历待排序区间使用的是run_ptr往后遍历
2> 找到最小元素
for (run_ptr = tmp_ptr + size; run_ptr <= thresh; run_ptr += size)
if ((*cmp) ((void *) run_ptr, (void *) tmp_ptr, arg) < 0)
tmp_ptr = run_ptr;
if (tmp_ptr != base_ptr)
SWAP (tmp_ptr, base_ptr, size);
基本思想:
第一个thresh分段粒度的分段里的最小元素,就是所有元素的最小元素
理由:因为通过快排算法,左分段的元素一定是小于右分段的元素,而最左边的分段里的最小元素肯定就是所有元素的最小元素
1、遍历该分段所有数据
2、找出最小值,同时把tmp_ptr指向最小值
3、tmp_ptr最开始指向的是base_ptr(第一个元素),当tmp_ptr != base_ptr时,也就意味着最小元素位置不是在第一个位置,进行数据交换SWAP
SWAP宏定义的数据交换并不改变指针
这个时候tmp_ptr指针已经没有实际意义,base_ptr指向第一个元素,也是最小的元素,也是插入算法的已排序的区间
3> 插入排序实现
run_ptr = base_ptr + size;
while ((run_ptr += size) <= end_ptr)
{
tmp_ptr = run_ptr - size;
while ((*cmp) ((void *) run_ptr, (void *) tmp_ptr, arg) < 0)
tmp_ptr -= size;
tmp_ptr += size;
if (tmp_ptr != run_ptr)
{
char *trav;
trav = run_ptr + size;
while (--trav >= run_ptr)
{
char c = *trav;
char *hi, *lo;
for (hi = lo = trav; (lo -= size) >= tmp_ptr; hi = lo)
*hi = *lo;
*hi = c;
}
}
}
定位待排序区间的首个元素
run_ptr = base_ptr + size;
run_ptr指向base_ptr的下一个元素(base_ptr由上面可知,指向的是最小元素),也就是插入算法的待排序区间的第一个元素
循环遍历所有元素
while ((run_ptr += size) <= end_ptr)
{
tmp_ptr = run_ptr - size;
while ((*cmp) ((void *) run_ptr, (void *) tmp_ptr, arg) < 0)
tmp_ptr -= size;....
}
最外层循环,run_ptr遍历所有元素,步进长度是size,也就是每个元素的大小
a. 遍历已排序区间,找到插入位置
tmp_ptr指向的是run_ptr前一个元素(也就是插入算法的已排序区间的最后一个元素)
while ((*cmp) ((void *) run_ptr, (void *) tmp_ptr, arg) < 0)
tmp_ptr -= size;tmp_ptr += size;
该循环是从tmp_ptr往前遍历元素,直到tmp_ptr指向的值不大于run_ptr指向的值(插入算法的已排序区间的遍历)
其实,也就是找到run_ptr需要插入的位置,也就是遍历后的tmp_ptr指向的位置的下一个位置
因为tmp_ptr指向值比run_ptr指向值更小,退出循环的条件
所以,需要tmp_ptr+=size;操作
b. 插入数据
if (tmp_ptr != run_ptr)
{
char *trav;
trav = run_ptr + size;
while (--trav >= run_ptr)
{
char c = *trav;
char *hi, *lo;
for (hi = lo = trav; (lo -= size) >= tmp_ptr; hi = lo)
*hi = *lo;
*hi = c;
}
}
情况一:
tmp_ptr == run_ptr。这意味着run_ptr的值是大于目前所有已排序区间的数据,不需要数据交换插入,直接进入下个循环:
(前面的run_ptr指向后一个数据,tmp_ptr同样指向下一个位置,其实,就是run_ptr位置)
情况二:
temp_ptr != run_ptr,那肯定是小于run_ptr。这意味着run_ptr需要插入的tmp_ptr位置是在已排序区间里的某一个位置,需要进行数据交换
完成后进入下个循环
c. 数据搬移:
trav = run_ptr + size;
while (--trav >= run_ptr)
{
char c = *trav;
char *hi, *lo;
for (hi = lo = trav; (lo -= size) >= tmp_ptr; hi = lo)
*hi = *lo;
*hi = c;
}
这个数据搬移也是基于字节宽度的搬移
这里有个注意点:
SWAP宏定义是a,b两个size大小数据块的数据交换
这里的数据搬移是,[tmp_ptr, run_ptr)区间数据往后挪一个size大小,然后run_ptr指向的数据保存到tmp_ptr指向的位置数据
所以,这里不是简单是数据互换,不能直接用SWAP
trav指向的是run_ptr的size数据块的首字节地址,通过while (--trav >= run_ptr)循环遍历run_ptr指向的size数据块的所有字节数据
for (hi = lo = trav; (lo -= size) >= tmp_ptr; hi = lo)
*hi = *lo;
这个循环的思路:
1、lo指向的是trav位置,然后往左偏移,每次偏移size大小
2、每次循环后,lo = lo - size, 而hi是循环上一次的lo的值,也就是lo = hi - size,也就是hi就是lo字节往右偏移size大小的字节位置数据
*hi = *lo操作就是把字节数据往右偏移
3、当lo指向的位置小于tmp_ptr(tmp_ptr是run_ptr数据要插入的位置)的时候,循环就退出了
这个时候,lo指向的是tmp_ptr前面的元素位置,而hi指向的就是tmp_ptr里的位置,而tmp_ptr需要存储的是run_ptr的数据
其实就是trav的数据,也就是char c = *trav;
*hi = c;
所以,for循环结束的时候,需要如上操作。
while (--trav >= run_ptr)
最终,通过上面的while循环,循环遍历run_ptr里的所有字节数据,然后通过上面的for循环,基于字节大小往右偏移size大小,while循环size次数(一个字节循环一次)
d.排序完成
while ((run_ptr += size) <= end_ptr)循环结束,也就是所有数据排序完成
二、分析与收获
1、排序算法是可以结合使用的
1> qsort对于大量数据,优先使用快排算法(非稳定性算法,牺牲掉稳定性),如果需要稳定性,要如何实现呢?
2> 快排的pivot选择
2>qsort的 thresh阀值选择
2、对于不同的数据形式,选择的排序算法并不完全依照时间复杂度来选择(复杂度只是一种理论的趋势性的表示,不同于实际的执行效率)
3、巧用哨兵原理,可以让代码设计更简洁
4、通过学习优秀代码,可以学习到很不错的一些技巧,实现思想
总结
期待大牛的指点