【初阶数据结构与算法】八大排序之交换排序(冒泡、快速),一次性讲清!

一.冒泡排序 Bubblesort

(升序为例)理解冒泡排序,就要明白,每次从头到尾进行相邻元素两两之间的比较交换,最后都会让本轮数组中最大的数走到数组的最后(有隐形收束边界的意味),当待排数组只剩一个数时,冒泡排序就完成了。(很多人说冒泡排序很好理解,但我觉得,其实理解挺有难度的)

(1)代码实现

复制代码
//升序为例
void Bubblesort(int* arr,int n)
{
  for(int j =0;j<n-1;j++)
 {
  int i = 0;
  int flag = 1;
  for(i = 0;i<n-1-j;i++)
  {
     if(arr[i]>arr[i+1])
    {
       Swap(&arr[i],&arr[i+1];
       flag = 0;
    }
  }
  if(flag)
    break;
 }
}

(2)细节理解

内外层循环各自代表了什么?循环条件又有什么样的含义?

红色循环条件:相邻两两元素之间的比较,由于我写的是i和i+1位置上的元素之间的比较,为了不让i+1越界,所以i首先要小于n-1,其次,由于没进行完一次从头至尾的相邻两两元素之间的比较,就会有一个数据走到了数组"后方"的正确位置上(以升序为例),所以不需要把正确排序位置上的数据再拉进排序的比较行列中,体现在代码上,就是i<n-1-j。

绿色循环条件:总共要进行n-1次的从头至尾的相邻两两元素之间的比较,这样才能确定n-1个数据的正确位置,当然,也可以排n趟,但是没有必要,当n-1个数据都已经到了它们的正确位置之后,最后一个数据自然就到了它的正确位置。

二.快速排序 Quicksort

(一)递归版快速排序

(1)hoare法

以升序为例,取数组第一个数据下标为key,定义begin为数组第一个数据的下标,end为数组最后一个数据的下标,先判断end对应数据和key对应数据的大小关系,如果是key对应数据更大,就不要动end,如果end对应数据更大,就让end--(向前走一步),继续与key对应数据进行大小比较,出现key对应数据更大的情况后,就去看begin。

同样,也要判断begin对应数据的下标和key对应数据的大小,如果是begin对应数据更小,那就不要动begin,如果是key对应数据更小,那就begin++(向后走一步),继续与key对应数据进行大小比较,直到出现key对应数据更小的情况,再将对应end和begin位置的数据进行交换,则比key大的数据走到了数组的后方,比key小的数据走到了数组的前方,重复上述操作,等begin和end相遇之后,再将相遇位置的数据和key位置的数据进行交换,就能实现数组的升序。这就是hoare法的快速排序。

(注意:可能存在begin、end相遇前并没有进行过一次交换的情况,那这就是直接让相遇位置的数据和key位置的数据进行交换就好。)

1.朴素版
复制代码
void Quicksort(int* arr,int left,int right)
{
  //递归结束的条件
  if(left<=right)
    return;

  int begin = left;
  int end = right;
  int key = left;

  while(begin<end)
  {
    while(begin<end && arr[key]<=arr[end])
      end--;
    while(begin<end && arr[key]>= arr[begin])
      begin++;
    //都找到了要交换的数据
    Swap(&arr[begin],&arr[end]);
  }
  //相遇后,要和key位置上的数据交换
  Swap(&arr[begin],&arr[key]);
  key = begin;
  //完成了第一次排序,确定了key位置上的数据的正确位置
  
  //递归
  Quicksort(arr,left,key-1);
  Quicksort(arr,key+1,right);

}
2.三数取中

有时候取得key位置上的值太大了或者太小了,就会导致递归时栈太深,有栈溢出的风险,为了优化这种情况,我们可以在头尾和中间的数据中取一个中间值作为arr[key],尽量避免选到太大或太小的数,这就是三数取中。

复制代码
int GetMid(int* arr,int left,int right)
{
   int mid = (left+right)/2;
   if(arr[left]>arr[mid])
   {
      if(arr[mid]>arr[right])
      //left>mid>right
         return mid;
      else
      {
        if(arr[left]>arr[right])
        //left>right>mid
            return right;
        else
        //right>left>mid
            return left;
      }
   }
   else
   {
      if(arr[left]>arr[right])
      //mid>left>right
          return left;
      else
      {
        if(arr[mid]>arr[right]
        //mid>right>left
          return right;
        else
        //right>mid>left
          return mid;
      }
   }
}

void Quicksort(int* arr,int left,int right)
{
  //递归结束的条件
  if(left<=right)
    return;

  int begin = left;
  int end = right;
  //三数取中
  int key = GetMid(arr,left,right);
  Swap(&arr[begin],&arr[key]);
  key = begin;

  while(begin<end)
  {
    while(begin<end && arr[key]<=arr[end])
      end--;
    while(begin<end && arr[key]>= arr[begin])
      begin++;
    //都找到了要交换的数据
    Swap(&arr[begin],&arr[end]);
  }
  //相遇后,要和key位置上的数据交换
  Swap(&arr[begin],&arr[key]);
  key = begin;
  //完成了第一次排序,确定了key位置上的数据的正确位置
  
  //递归
  Quicksort(arr,left,key-1);
  Quicksort(arr,key+1,right);

}

将key位置上的数据和begin位置上的数据交换,使key位置还是数组的首位置,这样while循环的逻辑就不用改变,注意,除了要交换数据外,还要将begin的值赋给key。

3.小区间优化

快排的递归过程很像二叉树的前序遍历,由此我们能大概知道,处理最后的一些小区间(叶子)会占到程序执行时间的一半左右,如果我们让小区间不再走递归那一套,而是直接进行现有的排序(堆排序、插入排序、希尔排序等等),时不时就能实现快排的优化呢?实际上确实是这样的。

复制代码
void Quicksort(int* arr,int left,int right)
{
  //递归结束的条件
  if(left<=right)
    return;
 
  //小区间优化
  if((right-left-1) < 10)
     Insertsort(arr+left,right-left+1);
  else
 {
  int begin = left;
  int end = right;

  //三数取中
  int key = GetMid(arr,left,right);
  Swap(&arr[begin],&arr[key]);
  key = begin;

  while(begin<end)
  {
    while(begin<end && arr[key]<=arr[end])
      end--;
    while(begin<end && arr[key]>= arr[begin])
      begin++;
    //都找到了要交换的数据
    Swap(&arr[begin],&arr[end]);
  }
  //相遇后,要和key位置上的数据交换
  Swap(&arr[begin],&arr[key]);
  key = begin;
  //完成了第一次排序,确定了key位置上的数据的正确位置
  
  //递归
  Quicksort(arr,left,key-1);
  Quicksort(arr,key+1,right);
 }
}
细节理解:
①为什么刚好begin和end相遇的位置是小于arr[key]的?

(先不解释为啥升序是end先走,后面自会有详细解释)

相遇无非就两种情况,要么begin遇到end,要么end遇到begin。

a.begin遇到end:

end先走,begin遇到end,那说明end肯定是停在了某个需要交换数据的位置上,也就是end找到了比key位置上的数据要小的数据,begin和end的相遇位置就是end停着的那个位置,也就是说相遇位置的数据是比key位置数据更小的数据。

b.end遇到begin:

end先走,直到遇见begin都没有找到需要交换的数据,由于每次都是end先走begin后走,等begin停下了了,就说明begin和end位置上的数据要交换了,则新一轮end遇到的begin,是上一轮已经交换过数据的的begin,begin位置上是比key位置更小的数据,所以在end遇到begin的情况下,相遇的位置的数据还是比key位置的数据要小。

②为什么升序需要end先走(先进行判断)?降序呢?

先明确一下,什么叫先走(先判断):即先判断end位置上的数据和key位置上的数据的大小关系。

让end先走先判断,就是为了让end掌握主动权在每一轮比较的开始,这样,无论是begin遇到找到小数据需要交换的end,还是end遇到上一轮已经变成小数据的begin,都能保证begin和end相遇位置的数据比key位置数据要小,交换后,才符合升序的题目要求。

如果是降序,就应该让begin先走先判断,这样begin掌握主动权在每一轮比较的开始,当begin遇到end时,就是begin遇到上一轮已经换成大数据的end,当end遇到begin时,就是end遇到找到大数据需要交换的begin了,这样才能保证begin和end相遇位置的数据是比key位置的数据大,再交换后,才能满足题目降序的要求。

③为什么内层循环的两个循环条件都带有等号?begin从key位置出发,会出现key位置的数据在begin和end相遇前被提前换走的情况吗(如何避免?)?

可能乍一想两边的判断都加=很奇怪,当两边的判断都加上=时,key位置的数据还能被交换到正确位置上吗?其实是可以的。

数组现在的构成是[小于等于arr[key] arr[key] 大于等于arr[key]],arr[key]所处的位置是合适的,等排好[小于等于arr[key]]和[大于等于arr[key]]两部分后,数组就能成为升序或降序。

那可以两边都不加=吗?

不行。

有一个特殊的情况,当数组的每个值都一样时,如果两边的判断都没有加上=,那么就会陷入死循环。

譬如数组[3,3,3,3],begin=0,key=0,end=3,在判断的时候,arr[end]=3不大于arr[key]需要交换,则end停在下标为3的位置,arr[begin]=3不小于arr[key]需要交换,则begin停在下标为0的位置,Swap之后,arr[end]=3和arr[begin]=3依旧,那么还是不用end--、begin++,还是继续交换,可是交换后只会是无穷的交换,这就是陷入了死循环。

那可以一边有=一边没有=吗?

理论是可以的,一边有等号,遇到和arr[key]相等的数据时不会停下,但另一边没有等号,遇到和arr[key]相等的数据时会停下,等交换后,和arr[key]相等的数据被换到了有等号的那边,进入循环,进行自减或自增,就跳过了与arr[key]相等的数据,不会陷入死循环。

但这里还有一个问题:begin从key位置出发,会出现key位置的数据在begin和end相遇前被提前换走的情况吗?我们是通过什么来避免的?

先说结论:这个问题确实是会发生的,可我们能通过下面的红色语句来避免,也就是在比较时加上等号。

在begin比较时加上等号,这样从下标为0位置开始的begin遇到和arr[key]数据相等的数据时,就可以进入循环begin++跳过第一个值,这样就不会存在key位置的数据在begin和end相遇前被提前换走的情况了。

所以结论是:不管end如何,begin这一端一定要加上等号

④为什么内层循环的循环条件还有与一个begin<end?

如果不与上begin<end,那么到执行交换的语句的条件是end和begin都找到了要交换的数据,那么就不能处理begin和end相遇的情况,当begin遇到end时,end停在了大于arr[key]的位置,可begin不会停下,而是会直接路过end向后走。所以需要与begin<end。

⑤为什么是定义数组首元素的下标为key,而不是数组首元素的值为key呢?

需要能真正交换数组内的数据,就不能是对临时变量进行交换,所以是下标而不是拷贝了数组值的临时变量,有点传值调用传址调用的意味。

⑥为什么递归的结束条件是left<=right?

等于比较好想,就是区间内只有一个数据,此时直接return,但left<right,其实可以假定key=right,在进入新的函数后,left=key+1=right原+1,right=right原,那么就有left<right了,这种情况也是不用再排序了递归了,直接return就好。

(2)前后指针法

(升序为例)如上图的初始情况,判断arr[cur]和arr[key]的大小,如果arr[cur]大于arr[key],那就直接让cur向前走一步,如果arr[cur]小于arr[key],那就让prev向前走一步,然后交换pcur和prev位置的数据,再让cur向前走一步,直到pcur走出边界外,最后,再将prev位置的数据和key位置的数据进行交换就行。

2-1 代码实现
复制代码
void Quicksort(int* arr,int left,int right)
{
  //递归的结束条件
  if(begin<=end)
    return;
  
  //小区间优化
  if((right-left+1) < 10)
    Insertsort(arr+left,right-left+1);
  else
  {
   //三数取中
   int mid = GetMid(arr,left,right);
   Swap(&arr[left],&arr[mid]);
   int key = left;
 
   //前后指针法
   int prev = left;
   int pcur = prev+1;
   while(pcur<=right)
   {
     if(arr[pcur]<=arr[key])
    {
       prev = prev+1;
       Swap(&arr[prev],&arr[pcur]);
       pcur = pcur+1;
    }
     else
       pcur = pcur+1;
   }
   Swap(&arr[key],&arr[prev]);
   key = prev;
   //确定好了key位置对应的数据的位置

   //递归
   Quicksort(arr,left,key-1);
   Quicksort(arr,key+1,right);
  }
}
2-2 细节分析
①前后指针是怎么实现升序的?

过程是prev从数组第一个位置开始,pcur从数组的第二个位置开始,判断arr[pcur]和arr[key]的大小,如果arr[pcur]更大,则只让pcur向下走一步,这样大于arr[key]的数也被夹在prev和pcur之间,如果arr[pcur]更小,则prev先向下走一步(prev走到了prev和pcur之间夹的大于arr[key]的数据),再交换prev和pcur对应数据,这样小于arr[key]的数据就被换到了prev的位置上,大于arr[key]的数据就被换到了pcur的位置上,再让pcur走一步,这样大于arr[key]的数又被夹在了prev和pcur之间。

按照这个过程进行下去,当pcur走出边界时,prev和pcur之间夹的是大于arr[key]的数据,prev位置的数据是小于arr[key]的,最后再交换prev和key位置的数据,就能让key走到它的正确位置。此时数组的构成就是[小于等于arr[key] arr[key] 大于等于arr[key]]。等排好各个区间的顺序之后,就能实现升序。

②代码还能实现优化吗?

当pcur和prev之间没有夹数据时,pcur遇到比arr[key]小的数据时,prev向后走一步,则prev和pcur指向了数组的同一位置,那么prev和pcur的数据交换,就是pcur自个之间的交换,这步其实是可以省略的;两个分支下都需要进行pcur=pcur+1,那就不用单独写else了,直接把pcur=pcur+1提出来。

最终的优化代码如下:

复制代码
 while(pcur<=right)
   {
     if(arr[pcur]<=arr[key] && ++prev != prur)
     //前置++,++prev后与pcur进行大小比较
     //如果不相等,才进入if语句进行交换
       Swap(&arr[prev],&arr[pcur]);    
     pcur = pcur+1;
   }

(3)挖坑法

(升序为例)取arr[key]=arr[0],数组的第一个位置形成坑位,end从数组最后先往前走找比arr[key]小的数据,找到后填入数组的首位置(也就是坑位),那end停下的位置就是新的坑位,begin从数组前方向后找比arr[key]大的数据,找到后填入坑位,然后begin停下的位置就是新的坑位了,直到begin、end相遇,再将arr[key]填入begin、end坑位里。

(二)非递归版快速排序

快速排序最关键的是每次排序时的边界left、right(begin、end),只要每次都知道边界,其实排序并不难,要能存储边界值,又要能模拟体现递归的过程,我们可以利用实现快速排序的非递归。

(1)代码实现

复制代码
void QuicksortNonR(int* arr,int n,int left,int right)
{
   ST st;
   STInit(&st);
   STPush(&st,right);
   STPush(&st,left);
   while(!STEmpty(&st))
  {
   int begin = STTop(&st);
   int begin0 = begin;
   STPop(&st);
   int end = STPop(&st);
   int end0 = end;
   STPop(&st);

   //三数取中
   int mid = GetMid(arr,begin,left);
   Swap(&arr[begin],&arr[mid]);
   int key = begin;
   
   //三法取一
   while(begin<end)
   {
    while(begin<end && arr[key]<=arr[end])
      end--;
    while(begin<end && arr[key]>= arr[begin])
      begin++;
    //都找到了要交换的数据
    Swap(&arr[begin],&arr[end]);
   }
   //相遇后,要和key位置上的数据交换
   Swap(&arr[begin],&arr[key]);
   key = begin;
   
   //右区间[key+1,end0]
   //左区间[begin0,key-1]
   if(key+1<end0)
   {
    STPush(&st,end0);
    STPush(&st,key+1);
   }
   if(begin0<key-1)
   {
    STPush(&st,key-1);
    STPush(&st,begin0);
   }
  }
 STDestroy(&st);
}

也可以小区间优化。

(2)细节理解

栈是如何模拟实现递归的过程的?

栈是先进后出,则我们先存右边界再存左边界,这样取出来就是先得到左边界再得到右边界,为了模拟递归的过程,那排序也是要先处理好左区间再看右区间,那么入栈应该是是入右区间的右左边界,再入左区间的右左边界,这样才能先取出左边界,如果左边界还能细分,就继续入栈小边界的边界值,就不会立刻取出右边界处理右区间的排序,而是先深入处理完左区间,这和递归的过程是相同的。

------end------

相关推荐
水木流年追梦5 小时前
大模型入门-大模型分布式训练2
开发语言·分布式·python·算法·正则表达式·prompt
sali-tec5 小时前
C# 基于OpenCv的视觉工作流-章78-KRT测量
图像处理·人工智能·数码相机·opencv·算法·计算机视觉
菜菜的顾清寒5 小时前
力扣HOT100(32)二叉树的中序遍历
数据结构·算法·leetcode
x2c5 小时前
数据结构:线性表中链表的建立和基本操作(C)
算法
DolphinDB5 小时前
基于 DolphinDB 搭建微服务的 SpringBoot 项目
后端·算法
珊瑚里的鱼6 小时前
【动态规划】第N个泰波那契数
算法·动态规划
ComputerInBook6 小时前
C++ 23 相比 C++ 20 新增之特征
开发语言·算法·c++23
吴可可1236 小时前
样条曲线转多段线技巧
算法·c#
csdn_aspnet6 小时前
java 算法 LeetCode 编号 70 - 爬楼梯
java·开发语言·算法·leetcode
pengyi8710156 小时前
动态 IP 池调度算法核心原理:从随机分配到智能调度演进
网络·tcp/ip·算法