数据结构(c语言版)-5


排序的概念及其运用

 排序:所谓排序,就是使一串记录,按照其中的某个或者某些关键字的大小,递增或递减的排列起来的操作。

稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,就是在原序列中,r[i] = r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍然在r[j]之前,则称这种排序算法是稳定的;否则就是不稳定的。

内部排序:数据元素全部放在内存中的排序。

外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内存之间移动数据的排序。

常见的排序算法:

插入排序

直接插入排序:

这个排序就是,在一组数据中,我们先拿第一个数据把他当做是一个有序区间,然后让第二个数据按照左小右大的排序插入到这个有序区间,也就是说,那第二个数据和第一个数据相比较,比第一个小,就插入左边,比第一个大就插入右边。然后再让第三个数据插入这个有序区间,同样和这两个数比较。在插入第四个数据,我们一个个进行比较,当找到的第一个数据比他大,找到的第二个数据比他小,就插入到他们的中间。如果比区间里面的数据都小,就插入到左边,反之。

其实就像完扑克,理牌一样。

 一直不断这样排序,直到把不是有序区间的数据插入完。

例如:假设[0,end]是有序的,那就把end+1位置的值插入继续,让[0,end+1]有序

void Insertsort(int* a,int n )
{  

  //[0,end]有序    end+1位置的值插入[0,end],让[0,end+1]有序
  for(int i,i < n - 1;i++)
 {
  
  int end;
  int tmp =a[end + 1];
  while(end >= 0)
  {
    if(a[end] > tmp)
    {
    a[end+1] = a[end];
    --end;
    }
    else
    { 
     break;
    }
  }
  
  a[end+1] = tmp;
 }
  for(int i = 0;i<n;i++)
   {
    printf("%d",a[i]);
   }
   printf("\n");
}

int main()
{
   int a[] = {3,5,2,7,8,6,1,9,4,0}
   Insertsort(a,sizeof(a)/sizeof(int));
 
   return 0;
}
  

 对于这个直接排序,他的最坏时间复杂度是O(n^2),这个虽然思想简单,打算时间复杂度不小,所以在此基础之上,创建了一种更优算法--希尔排序。

希尔排序(缩小增量排序)

是插入排序的基础上的优化,

1.先进行预排序,让数组接近有序。

2.再直接插入排序

预排序:也就是分组排,我们假设间隔为gap的为一组,我们假设gap == 3。(gap为多少,就会分多少组)

 然后就是一组一组排序,先排红色那一组,一样是使用类似于之前直接插入排序的思想,先把9看做是有序区间,然后把后面一个数据按照顺序插入到这个有序区间里面,只不过这个后面的这个数据,不是8了,而是6,也就是这个小组里面的后面一个数据。

红色这一组排完序之后:0  3  6  9

蓝色这一组排完序之后:2  5  8  

橙色这一组排完序之后:1  4  7

 也就是这样一个顺序,然后在这个基础之上在使用直接插入排序,就非常简单了。

 但是这个例子是刚好是逆序的情况,比较特殊,当我们给定数组里面的数据比较混乱的时候,我们会不仅仅使用一次分组排,gap的值也会改变。

 就像这个例子:gap由大变小,

gap越大,大的数就可以越快到达后面去,小的数就越有可能代打前面去。gap越大,预排序排完就越不可能接近有序。gap越小,越接近有序。

其实最后gap = 1的排序也就是基本插入排序(gap>1时都是预排序,gap=0就是直接插入排序)

代码实现:

void shellSort(int* a,int n)
{ 
   int gap = n;
 
  while(gap > 1)
{
  gap = gap / 2;    //除3除2都可以,

 //把间隔为gap的多组数据同时排
  for(int i = 0;i<=n-gap;++i)
 { 
  
   int end;
   int tmp = a[end + gap];
 
   while(end >= 0)
  {
    if(a[end] > tmp)
    {
      a[end + gap] = a[end];
      end -= gap;
    }
     else
    {
      break;
    }
  }
   a[end+gap] = tmp;

  }
 }
}

int main()
{ 
  TestInserSort();

  return 0;
}

这里for循环中如果是要一个一个组来算的话,应该是“i += gap",但是这里是"i++”,这里巧妙的使用了“i++”让每一组都一起进行排序。也就是第一组的第一个数据排完序,因为是i++,加到第二组的第一个数据,也就是给第二组的第二个数据排序·······

需要注意的是:在第一个while循环下面,有一个“gap = gap / 2;"其实除2除3都可以,但是除2的话,最后一个gap的值就是1,就可以直接进行直接插入排序,但是除3的话就不一定可以除到1。如果用除3的话,除的最后一项的值是0,我们“gap = gap/3 +1",就保证最后一个的gap值为值。

希尔排序相对于直接插入排序的效率要高很多,我们上面给定数据都很小,如果这个数组里面有很多的数据需要我们去排列,那么这两种算法差距就会非常的明显。

 希尔排序的时间复杂度是O(N*logN),相比于直接插入排序的O(n^2)要优化很多。

选择排序

 堆排序:

堆的逻辑结构是一种完全二叉树,我们知道树的实现可以使链式结构实现,也可以是数组的方式实现,堆就是用数组实现的完全二叉树。堆的物理结构是一个数组,其实他不是完全二叉树,只是我们把他想象出来的完全二叉树。

 如这样一个数组,我们把他想象成下面这样一个完全二叉树:

 那么我们如何看出这个树呢?他呢给了我们几个公式用来算出这些结点的父子关系:

leftchild = parent*2 +1    左孩子的下标 = 父亲结点的下标*2 +1
rightchild = parent*2 +2   右孩子的下标 = 父亲结点的下标*2 +2

parent = (child-1)/2       其中的child左右孩子均可

堆的两个特性:

结构性:用数组表示的完全二叉树;

有序性:任一结点的关键字是其子树所有结点的最大值(或者最小值)

                “最大堆(MaxHeap)”也称“大顶堆”:最大值

                “最小堆(MaxHeap)”也称“小顶堆”:最小值

大顶堆:树中所有的父亲都>=孩子                小顶堆:树中所有的父亲都<=孩子

根最大                                                             根最小

选择排序

直接选择排序

 直接选择排序:在数组里面遍历一遍之后选出最小的数据,然后再把它放到这个数组的最左边。放完之后,在再之后的数据里面找到这些数据里面最小的数据,把他放到这个刚刚数组的右边一个位置。然后再在后面的数据里面找出后面数据中最小的数据,放到第二次放到位置的右边一个位置。如此反复就实现了对这个数据的排序。

如这个图一样,我们先找出这个数组中的最小的数据“1”,把他放到这个数据的最左边。然后再在“9”“4”“8”这三个数据里找到里面最小的数据再把它放到最左边。如此反复,直到这个数组被排序完。

 更优算法:

 这样的直接选择排序来排列虽然想法很好理解,但是算法复杂度不太好,我们这里用代码实现一个优化的直接选择排序。上述排序只是选择了一个最小的数,然后进行排序,我们这里用两个变量(begin和end),找到这个数组里面最大的数据和最小的数据。然后再把最小的数和数组中的第一个数换位置,把最大的数和数组中的最后一个数换位置,然后再让begin++,end--,就是在除了第一位置和最后位置的数据里面找到最大的数和最小的数。因为是换位置,所以begin和end两个变量应该找到的是这个最大数和最小数的下标数据。

 在实现代码之前,我们需要注意一种特殊情况:

 当我们先把mini指向的最小的数和第一个位置的maxi指向的最大的数位置以交换,因为maxi指向的是下标,位置是不会变的,这个时候maxi指向位置的数的值是-1而不是之前指向的9。所以对于这种情况我们要进行特殊处理,我们在找到最大的数和最小的数之后,因为我们是先换的mini指向的数,这时候如果begin指向的数和maxi指向的数是同一个数,就把maxi的值改成mini的值。

void Swap(int* p1,int* p2)
{

  int tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}

void SelectSort(int* a,int* n)
{
   int begin = 0,end = n - 1;
   while(begin<end)
   {
       int mini = begin;maxi = begin;
       for(int i = begin;li <= end;i++)
       {
         if(a[i] < a[mini])
               mini = i;
         if(a[i] > a[maxi])
               maxi = i;
       }
   
       if(begin == maxi)     //
       {
         maxi = mini;
       }   

      Swap(&a[begin] , &a[mini]);
      Swap(&a[end] , &a[maxi]);
      begin++;
      end--;
   }
}

 直接选择排序的时间复杂度是O(N^2),很差,因为最好的情况也是O(N^2),而之前说过的直接插入排序,他在有些情况下非常的好,在这个数组里面的数本来就差不多有序的情况下,这个直接插入排序就显得非常的方便。

堆排序 

 堆排序也是一种选择排序,他只是借用了堆来实现这个效果。我们之前说过,堆其实是把一个一维数组把他想象成一个完全二叉树,而堆排序也就是在这个数组的基础之上,我们建立一个小堆或者是大堆。当我们的这个堆的根结点的左右子树都是小堆或者是大堆的时候,我们可以使用向下调整算法来快速的建立一个大堆或者是小堆。

向下调整算法:假设我们要实现小堆,我们知道,如果一个堆他是小堆的话,他的充要条件就是,这个树里面的每一个结点的左右子树都是小堆。由此,我们可以从根结点开始,选出左右孩子中小的那个,根父亲结点比较,如果孩子比父亲小,那么就让他们两个做交换;如果这个父亲比孩子小,就不交换,继续比较这个结点的另一个孩子。如果这个结点的孩子都比较完了,就继续比较这个根节点的左子树和右子树的结点中父亲与孩子关系。

 如上图,把他进行这样的换数据操作,把这个“27”一直与他的左右孩子进行比较,如果孩子比他小,就把这个“27”往下面换,一直换到这个“27”的左右孩子都不比他小,或者是这个”27“的左右孩子都为NULL的时候(调到叶子结点),停止这个”27“的操作。

最后得到这个小堆:

 代码实现:
 

void Swap(int* p1,int* p2)
{

  int tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}

void AdhustDwon(int* a,int n,int root)
{
    int parent = root;
    int child = parent*2 + 1;     //指向根结点的孩子的下标变量先默认指向左孩子;
    while(child < n)        //当这个child结点变量超过了数组,就跳出循环     
    {  
     if(child + 1 < n  && a[child] > a[child+1])   //判断这个左孩子和右孩子的大小,并且这个我的 
                                                 // child下标变量不越界
     {
       child += 1;      //如果右孩子比左孩子小,就让下标变量+1,让他指向右孩子
     }
     if(a[chlid] < a[parent])
     {
       Swap(&a[child],&a[parent]);
       parent = child;
       child = parent*2 + 1;
     }
     else               //当我们最小的那个孩子都比你大,说明这个堆已经是小堆了
        break;
}

需要注意的是:第一个if判断里面,加了一个(child+1<n)这个条件,例如下面这个数组,“28“这个根结点是没有右孩子的,如果我们进去在判断左右孩子大小的时候访问了”28“这个根节点不存在的右孩子,就会超出数组的大小,造成数组的访问越界。

 我们这样就实现了向下调整算法,但是我们之前也说过,要使用这个算法,是有前提的,左右子树都是小堆,这种情况是很巧的情况,如果我们左右子树不是小堆,就不能使用这个算法了。那么,当我们的左右子树不是小堆,该怎么实现堆排序呢?

这个时候,我们就把左右子树变成小堆,就可以使用这个算法。比如这个树:

我们从最后一个树开始调,如果我们把“7”和“8”为结点的这两棵树调成小堆,那么我们就可以对“5”这棵树使用“向下调整算法”这个算法

。所以,我们到这从最后一颗子树开始调,比如这棵树,我们发现叶子结点不需要调,因为他们没有孩子结点,我们只需要从倒数最后一个非叶子结点开始调就行了,也就是“8”。从“8”开始依次“7”“2”·····来调。

那么接下来又出现了一个问题,我们需要找到这个“8”,也就是倒数最后一个非叶子结点。我们只需要找到最后一个结点,也就是上图中的“0”,假设这时一个未知大小的数组,那么他的下标应该是n-1,知道了这个孩子的下标,用(n-1-1)/2来计算出它的父亲结点,这个父亲结点就是倒数最后一个非叶子结点。

代码实现:

void HeapSort(int* a ,int n)
{
      //建堆
      for(int i = (n-2)/2;i>=0;i--)
      {
        AdjustDwon(a,n,i);
      }
}

这个代码的意思就是:我们先找到倒数最后一个非叶子结点的位置,然后利用向下调整算法,把以“8”为根结点的这个树调整为小堆,紧接着会在“7”这个树调整,按照数组的逆序一个树一个树来调整为小堆,直到最后一个根(数组里的第一个元素)调整,这个调整也就是我们之前的,在左右子树为小堆的情况下使用向下调整算法。

同样的,我们也可以这样实现大堆的建立。

 问题

那么,我们来考虑一个问题,假设我们要把这个数组里面的数据排升序,我们应该建大堆还是小堆呢?

答案是建大堆,我们拿下面这个堆来解释:

 我们可以看到这是一个小堆,但是他的数组形式的排列非常的怪异,他虽然把最小的那个数(“0”)选了出来,但是后面的数据的顺序完全是打乱的,如果我们要继续排列的话,要把后面的那些数据重新组成一个新的小堆,然后再选出里面最小的那个数,再把后面的数据组成一个新的堆·······是不是很麻烦。

他这个就是把每一个堆里面最小的那个树选出来,在排序,和直接选择排序很像,但是他每找一个数据,就要建一个堆。我们这样不好对比········

我们来从时间复杂度的角度看,我们建立一个堆的时间复杂度是O(n),那么这样一算,虽然我们能用这个方法来实现拍升序,但是这个效率太低了。

我们用建小堆的方式来说排序,还不如用直接选择排序的方式来排序。

所以,我们应该建大堆,如下图:

 我们先把数组中第一个数,也就是这个堆中最大的数,把他与这个数组里面的最后一个数交换位置,如上面这个例子,就把“9”和“0”的位置进行交换。交换之后,如下图所示:

交换位置之后,这个最大的数“9”就不在看做这个堆里面的数据了,也就是说,假设这个堆里面有n个数据,交换之后,不再把最后一个数据看做是堆里面的数据,把前n-1个数据看做是一个新的堆。

交换之前的堆

 交换完之后,并不再把最后一个数据看做是堆里面的数据。

 之后,我们在像之前实现向下调整算法实现小堆一样,小堆是把大的数向下面移动,交换;大堆就是把大的数往上面移动,交换。我们用这个调整算法把这个新的堆里面最大的数筛选出来到这个堆的堆顶,然后再像之前一样,把这个新筛选出来的最大的数和这个新的堆的最后一个数(也就是这个数组里面的倒数第二个数据)进行位置交换,如此反复实现排序。

这个排序和之前用小堆的方式类似,但是他的时间复杂度是O(N*logN)。相对于上面的小堆的时间复杂度就好了很多。

void Swap(int* p1,int* p2)
{

  int tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}

void AdhustDwon(int* a,int n,int root)
{
    int parent = root;
    int child = parent*2 + 1;     //指向根结点的孩子的下标变量先默认指向左孩子;
    while(child < n)        //当这个child结点变量超过了数组,就跳出循环     
    {  
     if(child + 1 < n  && a[child] < a[child+1])   //判断这个左孩子和右孩子的大小,并且这个我的 
                                                 // child下标变量不越界
     {
       child += 1;      //如果右孩子比左孩子大,就让下标变量+1,让他指向右孩子
     }
     if(a[chlid] > a[parent])
     {
       Swap(&a[child],&a[parent]);
       parent = child;
       child = parent*2 + 1;
     }
     else               //当我们最大的那个孩子都比你小,说明这个堆已经是大堆了
        break;
}

void test_Sort(int* a ,int n)
{
      //建堆
      for(int i = (n-2)/2;i>=0;i--)
      {
        AdjustDwon(a,n,i);
      }


      //排升序
      int end = n - 1;

      while(end > 0)
      {
       Swap(&a[0] , &a[end]);
       AdjustDwon(a,end, 0);
       --end;
      }
}

交换排序

冒泡排序

 冒泡排序:基本思想就是,在有N个元素的数组中,从第一个数开始比较,如果前一个数的值大于后一个数,就交换位置;然后再比交换位置之后的第二个数和第三个数;一直比较到第N-1个元素和第N个数的比较,这称为第一趟。第二趟就只需要比较到倒数第二个位置,因为第一趟已经把最大的数调到最后去了。这个数组有N个数据,一共就要排序N-1趟。

void Swap(int* p1,int* p2)
{

  int tmp = *p1;
  *p1 = *p2;
  *p2 = tmp;
}


void BUbbleSort(int* a,int n)
{
     int exchange = 0;
     for(int j = 0;j<n,j++)
     {
        for(int i = 1;i<n-j;i++)
        {
           Swap(&a[i-1],&a[i]);
           exchange = 1;
        }
     }
      if(exchange == 0)    //如果这个exchange没有被赋值为1,说明这个数组的元素已经是有序的了
     {
       break;
     }
}


int main()
{
   int a[i] = {9,3,5,2,7,8,-1,9,4,0};
   BubbleSort(a,sizeof(a)/sizeof(int));

   return 0;
}

最坏时间复杂度O(N*N),最好时间复杂度O(N)。

 快速排序

 挖坑法

假设在一组数据中,我们使用挖坑法来实现排序(排升序),我们先从这组数中随机选择一个数,作为“key”,这个数所在位置最好是在这个数组的最左边或者最右边。然后把他的位置进行更改,把小于它的数放在它的左边,大于它的数放在它的右边。这些被放置的顺序没有要求,左边的数只要求比第一次随机选择的数要小,右边反之。

那么他的单趟是如何实现这样的排序呢?如下图所示,我们选择的key是“6”,那么我们就把“6”这个数据放到key这个内存里,然后把数组里“6”的位置删掉,这时“6”所在位置是空的,这个就是我们挖的坑。然后,我们用指向数组末端的下标的变量end来往数组的前面来寻找数组中比这个“key”小的数,把他放到我们之前挖的坑里(原来“6”的位置)。这时这个坑的位置就变在数组中间被拿走的拿走的数据那里。坑的位置在排好数据之后,是在比“key”值大的那一边。

此时我们再用指向数组首位下标的begin变量来往数组后面寻找比“key”值大的数,找到就把他放到新的坑里,此时坑的位置有发生了改变,坑的位置在排好数据之后,是在比“key”值小的那一边。接着再用end来寻找比key值小的数。找到之后再用begin来找大的数。

如此反复,直到end变量指向的下标和begin指向的下标重合的时候,就把key里面的值给到end与begin指向的数组位置。如此就实现了挖坑法的第一趟。

上述图是第一趟开始之前和结束之后的对比,下图是过程列举。 

 

 

 

 

这是单趟所实现的目的,如上描述的例子,我们是选择“6”来实现第一趟,当“6”的位置排好之后,在之后的趟次中,“6”的位置就不需要在动了(因为左边都比他小,右边都比他大,当这组数组排好顺序之后他的位置就是我们现在排的这个位置)。

//单趟挖坑法排序
int QuickSort(int* a,int n)  
{
   int begin = 0;end = n - 1;
   int pivot = begin;
   int key = a[begin];

   while(begin < end)
   {
        //右边往左找小放到左边
        while(begin < end && a[end] >= key) //往左找小--的过程中end与begin重合就退出循环
        {
          --end;
        }
      
        //小的放到左边的坑里,自己形成了新的坑位
        a[pivot] = a[end];
        pivot = end;

        //左边往右找大放到右边
        while(begin < end && a[begin] <= key)
        {
         ++begin;
        }
   
        //大的放到左边的坑里,自己形成新的坑位
        a[pivot] = a[begin];
        pivot = begin;
   }
   //当while循环结束代表已经排序完了,这时再把key的值放到end和begin指向的位置
   pivot = begin;
   a[pivot] = key;

  return pivot;

}

 我们在实现单趟之后,我们把我们第一次选好的key放好了位置,之后,我们以这个位置作为分界线,把这个数组重新分为两个数组。然后如果这两个数组都是有序的那么我们的这整个数组就是有序的。如果不是有序的,我们在不是有序的这个一边数组,重新按照之前选择key的方式来在这个新的数组里选择出一个key,再把这个key放到这个新数组的指定位置。这时候,这个新的数组都被分为了两个新数组。

这分数组的方式优点类似于二分法,我们每一次选出一个key,分出两个新的数组,每一次被分出的新的数组大小肯定会比之前的数组小,我们一直分到这个key的左右都只有一个数据的时候,我们这个小数组相当于是只有三个元素,key的左边比他大,右边比他小,那么这个三个数据的数组就是有序的。

当然也有极限情况,当我们选出的这个key是这个数组的最大值或者是最小值,也就是这个key的位置是这个数组的最左边和最右边的情况,这时候就不是有序了,假设我们这一次key是数组的最小值,指向的是数组的最左边,那么key右边的元素还剩下两个,这两个数据一样要进行之前分数组的操作。我们分出的这个数组,再次选出key在分出一个只有一个元素的数组。

我们把全部的数据都分完之后,这个数组就有序了,他这个分数组的方法画出来就像一个二叉树一样,但是这个不是二叉树,他是把这个数组分出了很多的小数组来实现排序的。

int QuickSort(int* a,int left,int right)  
{
   if(left >= right)
       return 0;

   int begin = left;end = right;
   int pivot = begin;
   int key = a[begin];

   while(begin < end)
   {
        //右边往左找小放到左边
        while(begin < end && a[end] >= key) //往左找小--的过程中end与begin重合就退出循环
        {
          --end;
        }
      
        //小的放到左边的坑里,自己形成了新的坑位
        a[pivot] = a[end];
        pivot = end;

        //左边往右找大放到右边
        while(begin < end && a[begin] <= key)
        {
         ++begin;
        }
   
        //大的放到左边的坑里,自己形成新的坑位
        a[pivot] = a[begin];
        pivot = begin;
   }
   //当while循环结束代表已经排序完了,这时再把key的值放到end和begin指向的位置
   pivot = begin;
   a[pivot] = key;

   //区间的分割是这样的(left,pivot-1) pivot (pivot+1,right)  ;pivot 也就是key的位置
   QuickSort(a,left,pivot-1);
   QuickSort(a,pivot+1,right);
}

int main()
{
   int a[] = {6,3,5,2,7,8,9,4,1}
   QuickSort(a,0,sizeof(a) / sizeof(int)-1);
}

在数组中实现就是这么一个效果。

时间复杂度:

他其实就是begin和end都在往中间走,那么单趟排序的话他们一共走了n个数,这个假象的树的高度是logN,所以这个算法的时间复杂度是O(logN*N)。虽然快排的时间复杂度和堆排序是一样的,但是我们用大量的数据进行对比之后,发现快排还是比堆排序效率高一些。

 虽然快排在无序情况下效率很高,但是在有序不管是顺序还是逆序的情况下,快排的效率甚至达到了O(N^2)。

假如这个树是顺序的,我们从开头或者结尾开始找,我们找到的都是最大或者最小的数,假设我们找的是最小的那个数,key就是最小的那个数,那么右边都是比他大的数,那么接着从最左边开始找,我们发现每一次找key,分数组,都会把开头最小的数或者是结尾最大的数给分出来,那么我们单趟排序,每一次都要让begin和end往中间走, 如图,一共有N次,所以时间复杂度是O(N^2)。

 也就是说,我们用开头和结尾的数据去选key的方式来实现快排排序是很快,但是是有缺陷的,如果这个数据是有序的,那么就会变得很麻烦,我们取中间的数据可解决这个问题,但是取中间的数据由不好实现乱序快排能实现的功能了。

所以我们又创造出了“三数取中”这种新的方式来解决这个问题。

所谓三数取中就是在三个数里面取即不是最大也不是最小的那个数,这样就保证了我们取的key不是最大的数也不是最小的数,如此就避免了在有序的情况下我们快排算法的缺陷。

我们为了实现之前实现的逻辑,从开头或者结尾取key,我们在中间找到这个key之后,就把这个key的位置与开头left的位置换一下,这样就保证了之前实现的算法和三数取中相结合了。

//三数取中
GetMidIndex(int* a,int left,int right)
{
   int mid = (right + left) >> 1;
   if(a[left]<a[index])
   {
     if(a[mid]<a[right])
      {
         return mid;
      }
     else if(a[left]>a[right])
     {
      return left;
     }
      else
      {
      return right;
      }
    }
    else  //a[left] > a[mid]
   {
      if(a[mid]>a[right])
      {
         return mid;
      }
      else if(a[left] < a[right])
      {
         return left;
      }
      else
      {
         return right;
      }
   }
}


void QuickSort(int* a,int left,int right)  
{
   if(left >= right)
       return 0;
 
   int index = GetMidIndex(a,left,right);
   Swap(&a[left],&a[index]);

   int begin = left;end = right;
   int pivot = begin;
   int key = a[begin];

   while(begin < end)
   {
        //右边往左找小放到左边
        while(begin < end && a[end] >= key) //往左找小--的过程中end与begin重合就退出循环
        {
          --end;
        }
      
        //小的放到左边的坑里,自己形成了新的坑位
        a[pivot] = a[end];
        pivot = end;

        //左边往右找大放到右边
        while(begin < end && a[begin] <= key)
        {
         ++begin;
        }
   
        //大的放到左边的坑里,自己形成新的坑位
        a[pivot] = a[begin];
        pivot = begin;
   }
   //当while循环结束代表已经排序完了,这时再把key的值放到end和begin指向的位置
   pivot = begin;
   a[pivot] = key;


   //区间的分割是这样的(left,pivot-1) pivot (pivot+1,right)  ;pivot 也就是key的位置
   QuickSort(a,left,pivot-1);
   QuickSort(a,pivot+1,right);
}

int main()
{
   int a[] = {6,3,5,2,7,8,9,4,1}
   QuickSort(a,0,sizeof(a) / sizeof(int)-1);
}

但是我们在快排中使用了三数取中的方式来选key的话,几乎不会出现最快的情况,但没有三数取中在解决最坏情况的时候就很麻烦。

快排的小区间优化

我们这个快排的实现方式实现出来是一个类似于二叉树的结构,而二叉树结构的结点个数我们知道是向下快速递增的。假设这个数组的个数是100w个,那么这个二叉树的高度应该差不多是20,2^19 约等于 50W个数,2^18 约等于 25w个数,由此我们可以看出,这100w个数里面,有很大一部分数据是在这个二叉树的下面几层的,而对于最后需要排序的个数不多情况下,比如分出来只有是个数据的小数组,我们需要对它进行排序,但是使用快排的话,每一次都要分出小数组,也就是调用函数(使用递归)。

我们知道调用函数是有消耗的,那么我们之前使用递归实现快速排序,排序100w个数据不是调用了很多次函数吗。

由此我们可以使用小区间优化来对此进行优化,我们用if条件判断一下,如果到了最后几层二叉树就是用别的排序方法进行排序,不使用快速排序了,假如我们使用直接插入排序来排序最后的几层数据,就避免了函数调用的消耗。

我们之前也说过,直接插入排序是挺复杂的一个算法,那么为什么在这里就可以对比他更快的快排进行优化呢?其实在最后几层里面,这个数组已经被分成了若干个小数组,而这若干个小数组里面根本就没有向之前100w个数据一样夸张的数据量,他们的数据量都相对而言很少,而很少的数据中使用快排和直接插入其实真正的时间并不会差距很多,但是快排中还有函数调用的消耗,所以理论上是会有优化的,但是优化并不明显。

代码实现:
这个代码的实现是在上述快速排序的基础之上实现的,就是把函数递归的地方加了两个if判断,当这个区间里面的个数>10就进行递归,<10就进行插入排序。

if(pivot - 1 - left > 10)
{
   QuickSort(a,left,pivot - 1);
}
else
{
    InsertSort(a+left,pivot-1-left+1);   //插入排序
}

if(right - (pivot + 1)>10)
{
    QuickSort(a,pivot+1,right);      //插入排序
}
else
{
    //a是数组的开头元素,+pivot+1是他的左区间
    InserSort(a+pivot+1,right-(pivot+1)+1); //假设是[0,9]区间,他的个数是9-0+1。
}

左右指针法

 是挖坑法的类似实现方法,我们先来实现单趟排序,先在首或者尾元素找key,同样的我们使用两个指针---一个指向数组首元素的指针(begin),一个指向数组尾元素的指针(end)。begin指针负责从左往右找比key大的数据,end从右往左找比key小的数据,两个数据都找到对应的值后把这两个数的位置进行交换,如此反复,直到这两个指针相遇,也就是重合。重合之后就代表这个这一趟的数据已经把大的放到右边,小的放到左边了,这时候就把key和begin与end指向的数据交换位置,把key指向的数据移到规定的位置,而这个位置和之前挖坑法一样,是数组完全排好之后这个数据应该在的位置,因为它的左边都比他小,右边都比他大。

那么我们同样是要分为如同二分法一样把这个数组分为几个数组的,同样用到了递归,和之前挖坑法的递归方式一样,他们俩的区别其实就在于每趟中交换数据的方式不一样。

 

//三数取中
GetMidIndex(int* a,int left,int right)
{
   int mid = (right + left) >> 1;
   if(a[left]<a[index])
   {
     if(a[mid]<a[right])
      {
         return mid;
      }
     else if(a[left]>a[right])
     {
      return left;
     }
      else
      {
      return right;
      }
    }
    else  //a[left] > a[mid]
   {
      if(a[mid]>a[right])
      {
         return mid;
      }
      else if(a[left] < a[right])
      {
         return left;
      }
      else
      {
         return right;
      }
   }
}

int PartSort2(int* a,int left,int right)
{
    int index = GetMidIndex(a,left,right);
   Swap(&a[left],&a[index]);

   int begin = left;end = right;
   int keyi = begin;

   while(begin < end)
   {
        //找小
        while(begin < end && a[end] >= a[keyi]) //往左找小--的过程中end与begin重合就退出循环
        {
          --end;
        }

        //找大
        while(begin < end && a[begin] <= a[keyi])
        {
         ++begin;
        }
        Swap(&a[begin],&a[end]);
   }
    Swap(&a[begin],&a[keyi]);
    return begin;
}

void QuickSort(int* a,int left,int right)  
{
   if(left >= right)
       return 0;
  
   int keyIndex = ParSort2(a,left,right);

   if(keyIndex - 1 - left > 10)
   {
     QuickSort(a,left,keyIndex - 1);
   }
   else
   {
     InsertSort(a + left,keyIndex - 1 - left + 1);
   }

   if(right - (keyIndex + 1) > 10)
   {
     QuickSort(a,keyIndex + 1,right);
   }
   else
   {
     InserSort(a + keyIndex + 1,right - (keyIndex + 1) + 1);
   }
}

   //区间的分割是这样的(left,pivot-1) pivot (pivot+1,right)  ;pivot 也就是key的位置
}

int main()
{
   int a[] = {6,3,5,2,7,8,9,4,1}
   QuickSort(a,0,sizeof(a) / sizeof(int)-1);
}

前后指针法

 前后指针法同样的需要选出一个key来(一般是选首元素为key),然后就是利用两个指针(cur和prev),一前一后来把比选择的key小的值往左边放,大的值往右边放。cur和prev指针都从首元素上,cur先从左往右找比key值小的数(这时候prev是不动的),找到之后就停下来,++prev让prev指向它之前指向位置的下一个位置,然后就交换prev和cur指向位置的值。

这是一趟,如此反复,每一趟都是把小的数往左边放,大的数往右边推。

最后,当cur指针指向这个数组最后一个元素的后面一个位置的时候,就停下来,再交换此时prev指向位置的值和key指向的首元素的值,此时之前key指向的值就被放在了prev的位置,而这个位置就是这个数组中全部数据排列好之后,这个数应该在的位置,因为左边多比他小,右边都比他大。

如下图所示例子的演示:

 

 当然这是单趟的,我们要实现排序需要很多趟,我们同样使用递归来实现多趟,这个递归思想和前两种快排思想一样,所以直接套用。

 代码实现:
 

//三数取中
GetMidIndex(int* a,int left,int right)
{
   int mid = (right + left) >> 1;
   if(a[left]<a[index])
   {
     if(a[mid]<a[right])
      {
         return mid;
      }
     else if(a[left]>a[right])
     {
      return left;
     }
      else
      {
      return right;
      }
    }
    else  //a[left] > a[mid]
   {
      if(a[mid]>a[right])
      {
         return mid;
      }
      else if(a[left] < a[right])
      {
         return left;
      }
      else
      {
         return right;
      }
   }
}

int PratSort3(int* a,int left,int right)
{
   int index = GetMidIndex(a,left,right);
   Swap(&a[left],&a[index]);

   int keyi = left;
   int prev = left;
   int cur = left + 1;
  
   while(cur <= right)
   {
      if(a[cur] < a[keyi])
      {
        ++prev;
        Swap(&a[prev],&a[cur]);
      }
      ++cur;
   }
   Swap(&a[keyi],&a[prev]);
}

void QuickSort(int* a,int left,int right)  
{
   if(left >= right)
       return 0;
  
   int keyIndex = ParSort3(a,left,right);

   if(keyIndex - 1 - left > 10)
   {
     QuickSort(a,left,keyIndex - 1);
   }
   else
   {
     InsertSort(a + left,keyIndex - 1 - left + 1);
   }

   if(right - (keyIndex + 1) > 10)
   {
     QuickSort(a,keyIndex + 1,right);
   }
   else
   {
     InserSort(a + keyIndex + 1,right - (keyIndex + 1) + 1);
   }
}

   //区间的分割是这样的(left,pivot-1) pivot (pivot+1,right)  ;pivot 也就是key的位置
}

int main()
{
   int a[] = {6,3,5,2,7,8,9,4,1}
   QuickSort(a,0,sizeof(a) / sizeof(int)-1);
}

归并排序

 假设有一组数据(一个数组里),我们把这个数组的数据从中间划开,对半分成两个区间,左区间和右区间。如果(前提)这个两个区间都是有序的,那么我们就可以使用归并思想了,我们先创建一个新的数组空间(足够存下这两个数组的数据),然后拿两个指针,一个指向左区间的首元素,一个指向右区间的首元素,然后比较这两个元素,把小的那个元素放到新数组空间的第一位,然后把指向小的那个元素的区间的指针++一下,指向下一个元素。同样的,后面选出来的数据放到新数据空间时,是放到之前放置数据的后面位置。然后一直如此反复,知道其中有一个区间的数据都被放完了,如果另一个区间里的数据没有放完的话,不管剩下几个数据,都按照原来的元素的存储顺序把他们放到新数组空间里。

我们使用归并思想的都是在这两个左右区间都有序的情况下,那么如果这两个区间不有序怎么办呢?

我们使用的类似之前快排递归的思想,如果一个区间不有序,那么就把这个区间对半分为两个左右区间,一直这样分,总会分到区间只有一个元素的时候,当分到只有一个元素的时候,我们可以认为他是有序的了,就可以对这个小区间实现归并思想了。

如下图:假设我们一直分到只有一个元素的时候才有序,我们就把就把这两个数据按照顺序归并在一个新数组里面,就如下面的[10,6]这数组,被分成了[10]和[6]这两个数组,然后把这两个数组归并为[6,10]这个新的数组,然后在和旁边的[1,7]数组归并为[1,6,7,10]这个数组。知道把这个数组排序完。

 上面是画的抽象图,我们在物理存储空间的角度来看的话就是,假设我们把这个数组分出了[10,6]这个小数组,我们要对这个数组进行排序就是如下图所示操作:

 其他归并操作都如此操作。

 代码实现:

void _MergeSort(int* a,int left,int right,int* tmp)   
{
   if(left>=right)    //这个数组已经被分得只剩下一个值了就不用再分出数组归并了,直接返回
   {
     return;
   }

   int mid = (left + right) >> 1;  //  >> 1  相当于  / 2
   //假设[left,mid] [mid + 1,right]这两个分出来的区间有序,就可以归并了
   //先递归分出小区间(小数组)在去归并排序
   _MergeSort(a,left,mid,tmp);
   _MergeSort(a,mid + 1,right,tmp);

   //归并
   int begin1 = left,end1 =mid;
   int begin2 = mid + 1,end2 = right;
   int index = left;
   while(begin1<=end1 && begin2<=end2)    //归并操作
  {
    if(a[begin1]<a[begin2])         //判断左右区间中第一个元素的大小
    {
      tmp[index++] = a[begin1++];    //左区间的数小,就把这个数放到新开辟的数组中
    }
    else
    {
     tmp[index++] = a[begin2++];    //右区间的数小,就把这个数放到新开辟的数组中
    }
  }
  

//因为我不知道两个区间在实现归并的时候,哪一个区间先排完最后我该把哪一个区间的数直接放到新数组中
//所以我直接把两个区间的数据都放到新的数组中,两个区间存放的顺序不用管,因为如果是空的区间存放
//和不存放都一样。
  while(begin1 <= end1)       //存放剩余的左区间剩余的数据
  {
    tmp = [index++] = a[begin1++];
  }
  while(begin2 <= end2)       //存放剩余的左区间剩余的数据
  {
    tmp = [index++] = a[begin2++];
  } 
 
  //拷贝回去
  for(int i = left;i <= right;i++)  //把新开辟的数组中的数据拷贝到原来的数组中去
  {
     a[i]  = tmp[i];
  }
}
void MergeSort(int* a,int n)
{
  int* tmp = (int*)malloc(sizeof(int)*n);
  _MergeSort(a,0,n-1,tmp);

  free(tmp);
}

 归并排序的另一种方式

 我们之前要使用归并排序就要先把这个两个区间给做到有序,我们解决办法是,如果这个区间没有序,就把这个区间对半分,分到只有一个数据认为他是有序;但是这样做有些麻烦,要像二叉树结构一样一直从根结点分到叶子结点(如上图所示)。

我们现在直接把这组数据,以相邻的两个数据为一个小区间,然后这个小区间分出来就只有一个数据,可以认为是有序的,就可以使用归并思想。如下图:

 然后再把下面单趟归并之后的数据拷贝到原来的数据里:

 此时,原来的我们以两个数据为一个区间的这个小区间就是有序的,然后再把这些区间按照归并思想来排列:

 

注意:我们上面的数据都是偶数个,所以我们在分区间的时候都可以把他全部分完,但是有一种情况我们上面的算法会出现问题,就是当这组数据的个数是奇数个的时候,和我们分出的区间的个数是奇数个的时候,就会发生数组越界的问题导致我们的程序奔溃。如下面这里例子:

 对于上面这种情况是第一次分区间的时候发生的数据访问越界,还有一种情况是在分过区间之后,这个区间的个数是奇数个,那么在下一次分区间的时候也会发现数组访问越界,如下所示:

 此时已经被分成了5个区间:

 我们发现此时也出现了数组的访问越界,所以每一次归并之前的分区间操作都有可能会有右区间访问越界的情况,这种情况下右区间可能会算多,比如说我左区间里有8个数,而整个数据就只有9个,但是此时我所计算的右区间个数也应该是8个,就是说如果左区间是[0,7],右区间应该是[8,15]但是实际上我只有整个数据只有9个,此时右区间的个数就多算了。

因此我们在解决这个数组访问越界问题的时候就有两种情况

  • 一种是右区间的开始数据访问越界
  • 另一种是右区间里有数据,但是数据没有填满。

解决方法:针对第一种情况,右区间直接不存在,左区间有数据,那么当分区间分到我们右区间空的时候,也就是指向右区间首元素的变量(begin2)指向的下标位置大于等于这组数据的元素个数时,就停止分区间,直接进行下面的归并操作。

针对第二种情况,当右区间的数没有填满的时候,就修改一下右区间指向末尾元素变量(end2),修正到这组数据的末尾就行了(下标是n-1)。

整个的代码实现:

void MergeSOrtNonR(int* a,int n)
{
  int* tmp = (int*)malloc(sizeof(int)*n);

  int gap = 1;   //每一组数据的个数

  while(gap<n)
 {
  
  for(int i = 0;i < n; i += 2*gap)
  {
    //区间是[i,i+gap-1] [i+gap,i*gap-1]

   int begin1 = i,end1 = i+gap-1;      //取左区间首尾元素的下标
   int begin2 = i+gap,end2 = i+2*gap-1;  //取右区间首尾元素的下标

   //归并过程中右区间可能不存在
   if(begin >= n)
       break;

   //归并过程中右区间有数据,但是可能算多了,把右区间的右范围修正一下
   if(end2 >= n)
   {
     end2 = n - 1;
   }   

   int index = i;                       //用于指向用于归并的新数组中下一个空位置下标
   while(begin1 <= end1 && begin2 <= end2)
   {
    if(a[begin1] < a[begin2])      判断左右区间的第一元素的大小
    {
      tmp[index++] = a[begin1++];     //把左区间的第一个元素放到新开辟的数组里面
    }
    else
    {
      tmp[index++] = a[begin2++];    //把右区间的第一个元素放到新开辟的数组里面
    }
   }

    while (begin1 <= end1)
    {
      tmp[index++] = a[begin1++];
    }
    while (begin2 <= end2)
    {
      tmp[index++] = a[begin2++];
    }

   //每一次归并完就把数据拷回去,但是新创建的数组空间里的数据不变
    for(int j = i; j <= end2;j++)
    { 
      a[j] = tmp[i];
    }
 }

   
    gap *= 2;
}

    free(tmp);    //释放掉开辟的新数组的空间
}

非递归实现快速排序

 我们知道,递归是有缺陷的,首先,递归的效率相比于其他算法效率是有所降低的,但是现在计算机的性能已经非彼从前了,编译器的优化也比之前要好,所以效率这一块已经差的不太多了。递归的缺陷主要在于在极端情况下,会导致栈溢出。

要了解递归的消耗,就要知道一个东西叫做栈帧,我们在调用函数的时候,都要开辟一个栈帧,这个栈帧是为函数运行而开辟的一个空间,在栈帧中要存放的是函数的必要信息,比如:函数的返回值,局部变量,函数传参等等信息,在函数结束之后,这个栈帧会被释放。

也就是说,每调用一个函数,就要开辟一个栈帧空间,那么当在函数递归里面我们知道,他会调用很多次函数,而且在调用下一个函数之前前面一个函数是没有结束的,也就是说上一个函数的栈帧没有释放。那么当我们递归过深之后,这个栈帧空间的开辟就会超出这个栈的承受范围,发生栈溢出。

//递归实现1加到n
int f(int n)
{
   retrun n<=1 ? 1 : f(n-1)+n;
}

比如这个函数,用递归的方式来实现从1加到n的操作,当n的值很大很大的时候,这个函数递归会递归得很深,可能会导致栈溢出。

假设我们的n=10000,我们知道这个函数递归是要建立10000个栈帧,就有很大可能会栈溢出。所以用非递归实现的快排就比递归快排有好处。

用非递归方式实现主要有两种方法:

  • 直接把递归的地方改成循环实现。--简单算法
  • 借助数据结构的栈来模拟实现递归过程。--复杂算法

我们之前用的是递归调用函数的方式来实现快排,是用的调用函数创建的栈帧来实现的,现在我们不用递归了,那么我们要模拟实现一个栈来实现递归的话,就要考虑之前栈帧里面到底存了啥,然后我们自己创建一个数据结构的栈来存储这些东西就行了。

 比如这个例子:

假设这有8个数据,我们先把这0-7压入我们创建的栈里面,当我们需要排列0-7这个区间的数据的时候,就把这个区间的数拿出来排列,这里是选出下标为3的数作为key,之后再把key分开的两个区间压入栈里面,因为栈是后进先出的,那么我们就先把4-7区间的数压入栈,再把0-2区间的数压入栈里面;现在我们要先排序0-2这个区间的数,同样的先把这个区间的数拿出来,然后对于0-2区间的数,我们选出的是下标为1的数为这个区间的key,那么接下来再分的话左右区间都只有一个数了,那么这两个区间就是有序的,就不再压入栈里面。

也就是说,当这个区间有序了,就不在压入栈了,就像之前递归一样,有序了就不在递归调用函数了。

 

 

也就是说栈里面的数是要被单趟分割排序的。

//单趟挖坑法排序
int PartSort1(int* a,int n)  
{
   int begin = 0;end = n - 1;
   int pivot = begin;
   int key = a[begin];

   while(begin < end)
   {
        //右边往左找小放到左边
        while(begin < end && a[end] >= key) //往左找小--的过程中end与begin重合就退出循环
        {
          --end;
        }
      
        //小的放到左边的坑里,自己形成了新的坑位
        a[pivot] = a[end];
        pivot = end;

        //左边往右找大放到右边
        while(begin < end && a[begin] <= key)
        {
         ++begin;
        }
   
        //大的放到左边的坑里,自己形成新的坑位
        a[pivot] = a[begin];
        pivot = begin;
   }
   //当while循环结束代表已经排序完了,这时再把key的值放到end和begin指向的位置
   pivot = begin;
   a[pivot] = key;

  return pivot;

}

我们通过代码来理解,因为是c语言实现,所以我们要先实现一下数据结构的栈,和栈的基本操作。先把栈初始化,把这整个数组放进去,因为这个数据使用数组来储存的,我们要放入这整个数组就只需要把首元素和尾元素放进去就可以了。

StackInit(&st);    //栈的初始化函数
StackPush(&st,n - 1); //把数组的最右边数据放进去
StachPush(&st,0);   //把数组的最左边数据放进去

 这个里面的每一次循环都是对一个小区间的排序,基本思想是假设我们先排列左边的小区间,就先把右边的区间放到栈里面,再把左边的区间放到栈里面,排序之后找key分区间也是先把右边的小区间放入栈再把左边的小区间放到栈里面。这样我们每一次先拿出来排序的区间都是从左区间开始的。

while(!StackEmpty(&st))     //这个栈为空就跳出循环
{

int left = StackTop(&st);   //取栈顶的数据给到left
StackPop(&st);             //把栈顶的数组给删除掉

int righ = StackTop(&st);   //取栈顶的数据给到right
StackPop(&st);             //把栈顶的数组给删除掉

int keyIndex = PartSort1(a,left,right);    //利用单趟堆排序实现小区间的排序
//此时的区间为   [left,keyIndex-1] keyIndex [keyIndex+1,right]

//上述key的右区间
if(keyIndex + 1 < right)    //满足条件说明这个区间有大于1个数,继续把这个区间入栈到栈里面
{
   StackPush(&st,right);    //把这个区间的右边的数放到栈里面
   StackPush(&st,keyIndex + 1); //把这个区间左边的数放到栈里面
}

//左区间
if(left < keyIndex - 1)     //基本思想一样
{
   StackPush(&st,keyIndex-1); 
   StackPush(&st,left);
}

}

最后在把整个数组的数据都排好之后,我们要把这个栈的给释放掉:

StackDestory(&st);

 对于上述中对栈的基本操作函数实现:
 

void StackDestroy(ST* ps)
{
       assert(ps);
       free(ps->a);
       ps->a = NULL;
       ps->capacity = ps->top = 0;
}


void StackPop(ST* ps)
{
     assert(ps);
     assert(ps->top > 0);       //判断栈是否为空
 
     ps->top--;
}

STDataType StackTop(ST* ps)
{
       assert(ps); 
       assert(ps->top > 0);         //判断这个栈是否为空,为空就报错
     
       return ps-a[ps->top - 1];
}

void StackPush(ST* ps,STDataType x)
{
       assert(ps);
        
       if(ps->top == ps->capacity)
    {
        int newCapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;    //判断这个栈是否为空
        STDataType* tmp = realloc(ps->a,sizeof(STDataType)*newCapacity);
        if (tmp == NULL)             //判断realloc函数是否使用成功
       {
          printf("realloc fail\n")
       } 
 
    ps->a = tmp;                         //对开辟的空间进行分布
    ps->capacity = newCapacity;       
    }
 
       ps->a[ps->top] = x;             
       ps->top++;
}

 关于栈和堆的解释:

我们知道,在计算机存储里有栈和堆,像我们调用函数创建的栈帧就存储在栈里面,但是栈空间很小,而相比之下堆的空间相对大一些,堆的话一般是我们malloc函数申请空间所存储位置。在我们学习的数据结构里面也有栈和堆,这两个地方的栈和堆没有一点关系;计算机存储里面的栈和堆是操作系统这门学科堆内存的划分,他表示有一段区域被划分为栈,一段区域被划分为堆。

我们上面不用递归实现快速排序是用的我们自己实现的栈来实现类似调用函数中创建栈帧一样存储每一次排列一个小区间的数据,因为栈是先进后出,我们用栈来实现就比较像递归思想,比如下面这个例子:
我们之前实现的递归思想和用数据结构的栈来实现栈帧和递归的效果;假设我们先排列左区间,我们就是先把右区间入栈,在入左区间,最终效果都是先把左区间的数排列好在排列右区间的数。

 其实用队列也可以实现快速排列,只不过它排列的方式不太像递归的实现思想了,队列实现是一层一层来排序处理的

我们现在来简单的说一下一些算法思想

桶排序(基数排序)

 桶排序的基本思想是,依次取每一数的个位,十位,百位···········,每一次的取位,都按照这个位的数值大小进行排序,一直取出然后排列到这组数据的数据的最大的位次之后停止。如下面这个例子:
我们发现,当我们取位,排序,排到最大的位次的时候,这组数据就已经被我们排序好了。

 但是这个算法有一个致命缺点,就是这个算法只排序整型的数据,所以在实际中,这个排序基本没有人用。

计数排序

 计数排序采用了映射的思想来类比实现:

 计数排序顾名思义就是对这组数据中出现过的数据的次数进行统计,就是我创建一个数组,这个数组的大小是我要排序的这组数据中最大数据的值+1,而且数组中每一个元素的值都是0。开辟完数组之后,数组的下标我们就理解为数据的值,然后我们就遍历我们要排序的这组数据,每找到一个数据就在新开辟的数据中找到这个数据对于的下标位置,让这个位置的值+1。如下例子:

 当我们统计出每一个数出现的次数之后,就可以直接排序了,依次把先创建数组中的每一个元素对应下标大小按照次数进行排列即可,如下所示:

 但是这个排序也有缺陷,就是当这组数据中的数据的值相差太大之后,我们开辟的数组就会过大,造成空间浪费,如下所示:

 我们所用到的数组中的空间根本没有这么多,但是我们却开辟了很大一块数组空间,当然对于特殊情况,我们也可以采用特殊的计算方法来缩小新开辟数组空间,比如这组数据:

100   101   102   101   109   105

 我们发现前面100的数组空间都没用,而且发现100之后也只用了10个空间,那么我就考虑这个数组只开辟10个空间,当我们要统计一个数(num)的时候,只需要让num这个数据的值,减去最小的数据的值,如上就是减去100。

代码实现:

// 非比较排序
// 计数排序:思想很巧,适用范围具有局限性
// 时间复杂度:O(N+range),说明他适用于范围集中一组整形数据排序
// 空间复杂度:O(range)
void CountSort(int* a, int n)
{ 
   int max = a[0], min = a[0]; 
   for (int i = 0; i < n; ++i) 
   {  

    if (a[i] > max)  
    {   
      max = a[i];  
    }  

    if (a[i] < min)  
    {   
      min = a[i]; 
    } 
   } 
  
   int range = max - min + 1; 
   int* count = (int*)malloc(sizeof(int)*range); 
   memset(count, 0, sizeof(int)*range); 

// 统计次数 
   
   for (int i = 0; i < n; ++i) 
   {  
     count[a[i]-min]++; 
   } 
   
   int j = 0; 
   
   for (int i = 0; i < range; ++i) 
   {
  
     while (count[i]--)  
    {   
       a[j++] = i+min;  
    } 
   }  

     free(count);
}


void TestCountSort()
{ 
     int a[] = { 10, 6, 7, 3, 9, 4, 2, 8,9,10,12,12,3,7,5 }; 
     CountSort(a, sizeof(a) / sizeof(int)); 
}

int main()
{
   TestCountSort();
}

各种排序算法稳定性

稳定性:假定在待飘絮的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且在r[i]再r[j]之前,而在排序后的排序中,r[i]仍在r[j]之前,则就称这种排序算法是稳定的,否则就是不稳定的。

比如这个例子:

 我们排序之后这个两个49应该是排列在一起的,但是虽然这两个数据是一样的,但是他们应该也是有数据的,黑色的49应该在绿色的49之前,如果满足这个条件那么这个排序就是稳定的,否则就是不稳定的。

对于稳定性的作用,在这里举一个例子,上述是我们的考试成绩,那么从左到右的成绩是先后考出来的,那么左边黑色的49应该比右边绿色的49先考出来,那么黑色这个49应该排在绿色49的前面,如果这个排列稳定,那么排列出来的结应该满足上面的条件;反之。

冒泡排序:假如前面数比后面数大就交换,但是相等的时候是不交换的,所以是稳定的

 选择排序:选择排序在一般情况下选出来是稳定的,但是有特殊情况如下图:

 假设我第一趟遍历排序:

 交换之后,我们发现虽然两个9的位置没有变换,但是最后一个5的位置和第二个5的位置发生了改变,那么这个算法就是不稳定的。

插入排序:插入排序是稳定的,因为他是把前面有序区间的后面一个值放到前面让前面区间再次有序,如果相等就不放,所以是稳定的。

希尔排序:因为希尔是先预排序也就是先分组排序的,如果相同的值分到了不同的组,那么我一交换他就不可控了,不确定交换之后还是否保持原有的相同数的顺序。

堆排序:堆排序也是不稳定的,我们在建好堆,然后把这个堆排列成小堆或者是大堆的时候,就会乱:

我们简单考虑,这种情况下两个9的位置就变了。

 归并排序:归并排序我们可以让他是稳定的,如这一趟排序:

 我们只要满足左区间的3先下来,再让右区间的3在下来,那么这个归并排序就是有序的;如果我们让右区间的3先下来,那么这个归并排序就是不稳定的。我们之前的希尔排序,堆排序的不稳定都不是我们能控制住的,但是对于归并排序的稳定性我们是能控制的。

快速排序:快速排序是不稳定的,假设这一趟排序:

 我们已经选了第一个5作为我们的key,那么我们需要把这个5放到中间去,是的这个5的左边比他小右边比他大,这时候我们发现左右两边的5的顺序我们是不好控制的,就算这一趟是满足稳定的,但是后面的几趟排序也有可能把他搞乱,关于快排我们是不好控制的。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

chihiro1122

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值