2021-05-03

第一章 数据结构与算法之qsort

数据结构与算法之美


目录

第一章 数据结构与算法之qsort

前言

一、源码分析

1.结构体定义

2. 栈定义

3.如何避免递归函数调用的栈溢出

方案一:限制递归深度

方案二:堆上(内存)模拟实现函数调用栈

4.数据交换

5. qsort函数体的实现

1. 函数定义

2. thresh阀值

3. 快速排序(quickSort)

1> 变量初始化

2> 哨兵原理设计

以下部分的代码逻辑,都是针对某一块待排序的分段[lo, hi]进行快排(分段粒度大于thresh阀值), 当所有分段快排完,也就意味着整个快排逻辑的完成(循环完成递归实现),也就是:

3> 快速排序的分区点选择

4> 双向遍历,完成当前分段的快排

这样,通过do-while双向循环遍历,完成当前分段的快排,结果变成左分段[lo, right_ptr], mid, 右分段[left_ptr, hi]

5> 下一个分段的快排处理

基本思想:

出栈:

入栈:

4、插入排序(InsertSort)

1> 变量定义

2> 找到最小元素

3> 插入排序实现

二、分析与收获

 

总结



前言

分析glibc-2.33的qsort的源码


 

一、源码分析

1.结构体定义

typedef struct
  {
    char *lo;
    char *hi;
  } stack_node;

lo表示指向低地址内存的指针

功能:排序算法过程中,从左到右的遍历指针

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、通过学习优秀代码,可以学习到很不错的一些技巧,实现思想


总结

期待大牛的指点

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值