O(n*logn)排序算法的总结

对经典排序方法性能进行总结:

排序方法 时间复杂度 空间复杂度 稳定性
冒泡排序 O( n^2 ) O(1) 稳定
插入排序 O( n^2 ) O(1) 稳定
选择排序 O( n^2 ) O(1) 不稳定
归并排序 O( n*logn ) O(n) 稳定
堆排序 O( n*logn ) O(1) 不稳定
快速排序 O( n*logn ) O(logn) 不稳定
桶系列排序 O( n ) O(n) 稳定

今天重点讨论这三个时间复杂度为O(n*logn)的算法,桶排序只是对于特定情况下的数据进行排序,比如范围内的整数…
1、首先,说一下快排,要从三分问题说起,快排的精髓就在于三分的问题,代码:

// 三分问题思路,左边小于某值k 中间等于某值k 右边大于某值k
// 实现将一个无序序列划分成三分序列,返回值可以为三分序列的两个数,也可以啥都不返回,看需要
void swap(int arr[],int i,int j)
{
   int temp=arr[i];
   arr[i]=arr[j];
   arr[j]=temp;
}
void partition(int arr[],int length,int k)
{
   int L=0,R=n-1;  
   int i=L;    // 用于遍历arr的
   int p=L-1,q=R+1;   // 用做划分区间的,初始两边都没有
   while(i<q)       //当i没有进入q区间(即没有进入大于k的区间)
   {
      if(arr[i]==k)
      ++i;
      else if(arr[i]<k)
      {
      swap(arr,i,p+1);
      ++i; ++p;
      }
      else 
      {
      swap(arr,i,q-1);
      q--;
      }
   }
   return;    // 如果是返回p和q,需要这两个划分区间,那就放到数组中返回即可

三分过程partiotion将序列三分,就是快速排序的核心过程,如果划分值k是在序列中随机选择,并且,划分完以后继续对p范围和q范围继续划分就是O(n*logn)的快速排序,这里的划分值必须随机出来,否则按照最差时间复杂度计算,不是随机的划分值时间复杂度是O(n^2),快排如下:

// 时间复杂度为 O(n*logn)的快排算法
void quicksort(int arr[],int L,int R)
{
    if(L>=R)return;   //数量小于等于1个,直接返回
    srand(int(time(0)));
    int index=L+( rand()%(R-L+1) );
    int base=arr[index];   //这是随机的划分值
    int i=L,p=L-1,q=R+1;
    while(i<q)         // 就是partition过程
    {
      if(arr[i]==base)
      ++i;
      else if(arr[i]<base){
      swap(arr,i,p+1);
      ++i; ++p;
      }
      else {
      swap(arr,i,q-1);
      q--;
      }
    }
    quicksort(arr,L,p);
    quicksort(arr,q,R);
}

很多问题用到的都不是快排而是其中的partition过程,像找到前k大个数(找到第k大个数),就是这种类型,随机找一个数,然后进行三分,三分结束时i所在位置前一个位置看是否为k,如果是那么此位置之前全部小于k,此位置之后全部大于k,找到了第k个和前k个,如果不是,根据i大小再去p或者q范围取找,写一个题把,当作练习:

// 辅助函数
// 这个就是partition的过程,实现在L-R上进行一次三分问题,然后返回基准值base所在的下标
int partition_help(int arr[], int L, int R)
{
 if (L >= R)return L;
 srand(int(time(0)));
 int index = L + rand() % (R - L + 1);
 int base = arr[index];
 int i = L, p = L - 1, q = R + 1;
 while (i<q)
 {
  if (arr[i] == base)++i;
  else if (arr[i] < base)
  {
   swap(arr, i, p + 1);
   p++; i++;
  }
  else
  {
   swap(arr, i, q - 1);
   q--;
  }
 }
 return i - 1;   // 肯定是有i这个数,因为base就是在L-R上选的
}

// 函数实现
// 找到数组arr中第k大的数并返回这个数
int find_k(int arr[], int length,int k)
{
 if (k <= 0 || k > length)return -1;  // k不在范围区间内 同样如果k是大于0的,并且又小于等于length,说明length也是大于0
 int index = length - k;  // 其实就是找到排序好以后的下标为length-k的值
 //下面对整个arr进行partition,然后返回的值看是否为index,如果是直接返回,如果不是继续partition
 int L = 0, R = length - 1;
 while (L < R)
 {
  int x = partition_help(arr, L, R);
  if (x == index)return arr[index];
  else if (x < index)L = x + 1;
  else R = x - 1;
 }
 if (L == index)return arr[index];
 return -1;
}

2、下面说一下归并排序:归并排序的精髓是merge的过程,就是一个合并的过程,整个排序过程就是合并,从两个数开始合并,然后最后到所有数合并,同样与归并排序有关的问题都是出于对merge过程的利用,代码:

// 归并的精髓: merge
void merge(int arr[],int L,int R,int mid)
{
 if(L>=R)return;   // 当需要合并的数小于等于1个时,直接返回
 int p=L,q=mid+1;
 int *temp=new int[R-L+1];
 int i=0;
 while(p<=mid&&q<=R)  
 {
  temp[i++]=arr[p]<=arr[q]?arr[p++]:arr[q++];  
 }
 while(p<=mid)
 temp[i++]=arr[p++];
 while(q<=R)
 temp[i++]=arr[q++];
 int k=L;
 for(int i=0;i<=R-L;++i)
 {
   arr[k++]=temp[i];
 }
}

// 函数实现
void merge_sort(int arr[],int L,int R)
{
   if(L>=R)return ;
   int mid=L+(R-L)/2;
   merge_sort(arr,L,mid);
   merge_sort(arr,mid+1,R);
   merge(arr,L,R,mid);
}

可以看到函数实现非常简单,重点就是一个merge的过程,新建一个辅助空间,将两边的数按顺序排好,再赋值给 arr[ ] 数组,主函数主要是不断的递归过程,对左边同样处理,对右边同样处理,然后左右merge(合并)。
上面也说过,基本上对归并的考察就是对merge的考察,对merge按照需求去处理,比如:求逆序对问题,3、5不是逆序对,5、3是逆序对。其实很容易想到当左边的和右边的合并,如果左边的数大于右边某个数,那么这个数一定大于右边某个数的左边所有数,在merge过程中去统计这个数量就可以得到逆序对数,否则一个个去遍历,时间复杂度达到O(n^2),代码:

// 题目 : 求一个数组中的逆序对数,可以对数组进行改动,逆序对是前面一个大于后面一个
//辅助函数1
void  merge_help(int arr[],int L,int R,int mid,int &cout)
{
    if(L>=R)return;
    int *temp=new int[R-L+1];
    int p=L,q=mid+1; // 遍历arr左右两边的滑动变量
    int i=0; // 记录临时数组temp的
    while(p<=mid&&q<=R)
    {
       if(arr[p]<=arr[q]) {
       temp[i++]=arr[p++];
       }
       else {
       cout=cout+(mid-p+1);  // 当逆序对左边大于右边,说明左边的后面所有数都大于右边的当前数
       temp[i++]=arr[q++];
       }
    }
    while(p<=mid)
    temp[i++]=arr[p++];
    while(q<=R)
    temp[i++]=arr[q++];      
    // 最后还是将temp里排好的重新写到arr里
    int k=L;
    for(int i=0;i<R-L+1;++i)
      arr[k++]=temp[i];
}

//辅助函数2
void merge_help_of_nixu(int arr[],int L,int R,int &cout)
{
       if(L>=R)return;
       int mid=L+(R-L)/2;
       merge_help_of_nixu(arr,L,mid,cout); 
       merge_help_of_nixu(arr,mid+1,R,cout);
       merge_help(arr,L,R,mid,cout);
}
// 函数实现
int nixu_count(int arr[],int length)
{
    if(length<=1)return 0;  
    int cout=0;
  // 统计逆序对数
    merge_help_of_nixu(arr,0,length-1,cout);
    return cout;
}

可以看到还是对merge函数的一个应用,在merge中去统计逆序对数量,个人感觉解决一个问题首先学会将一个大的问题化为几个小问题,每个小问题解决自己的事情,大问题只需要像小问题去拿资源就可以了,比如这一题,要统计逆序对个数,要分别先统计mid左侧的逆序对个数和右侧的逆序对个数,就是所说的分冶法把,然后再将这两段统计出来,这就是一个递归,至于统计的过程去merge拿就好了。
3、最后堆排序 实际上堆经常用到,但又经常用不到,经常用到是指遇到的问题中很多用堆解决的,但是经常用不到是因为不用我们手动实现堆(除非是专门考察堆的问题),每次用STL中的priority_queue就可以了,大顶堆小顶堆都可以很方便的拿来用:

// 小顶堆,这里的vector<int> 是指底层实现的容器,然后用greater<int>代表实现小顶堆
priority_queue<int,vector<int>,greater<int> >//大顶堆,默认的优先级队列就是大顶堆
priority_queue<int,vector<int>,less<int> >;
// STL中设计好了很方便的接口,这里顺便说一下常用的适配器接口把
// stack : push() , pop() ,top() ,
// queue:  push() , pop() ,front() , back() ,
// priority_queue:  push() ,pop(), top() ,
// 还有是所有适配器都支持的接口:
// size() , empty()

首先说一下什么是堆:堆是在脑海中是一个完全二叉树的结构,实际物理实现是用数组进行实现,(完全二叉树— 要么这棵树是满的,要么是在成为满的路上),而堆中的位置对应于数组中是有确定的对应关系的!如果数组从下标为0处建立堆,那么其对应关系为:
左孩子: 2*i+1 ( i 的左孩子,i 为当前节点的下标)

右孩子: 2*i+2 ( i 的右孩子,i 为当前节点的下标);

父节点: ( i-1 ) /2 ( i 的父节点, i 为当前节点)
堆分为两种:大根堆和小根堆,大根堆——在整棵树的任何子树中,最大的值都是子树的头节点,这就是的大根堆,小根堆相反。
问题1、 不断往数组中加入数字,维持一个大根堆, 实际上就是priority_queue的push() 方法,不断往底层的vector中放入数字,并且维持大根堆。代码:

两个重要函数之一: heap_insert () 过程 ——插入的过程

// 思路: 往数组中添加元素,要维持大根堆很容易,加入的数与自己的父位置进行比较,如果大于父位置则交换,小于则不用动
//辅助函数swap
void swap(int arr[],int i,int j)
{  
   int temp=arr[i];
   arr[i]=arr[j];
   arr[j]=temp;
}
// 实现:
void insert_heap(int arr[],int index) // 将index位置的数加入大根堆
{ 
   while(arr[index]>arr[(index-1)/2])
   {
     swap(arr,index,(index-1)/2); // 与父节点比较后大于父节点的话进行进行交换
     index=(index-1)/2;
   }
}
// 用插入的方法建立大根堆
int main()
{
   int arr[]={2,4,1,6,5,7,3,9}  // 随便给一个数组
   for(int i=1;i<8;++i)
      insert_heap(arr,i); // 从1位置开始对每个数执行插入操作即可;
   return 0;
}

可以看到很简单,就是index位置的数大于其父节点就交换,直到交换到不大于为止(如果是头节点:(0-1)/2 还是0 ,即头节点父节点还是本身0位置);总结一下: 插入一个数的时间复杂度是O(logn),而且完成了一个堆的封装,可以将堆看成一个黑盒子,不断往这个黑盒子里插入,然后其内部自动实现一个堆结构,返回popmax()直接返回第一个数就可以了。
下面介绍popmax () 函数,就是返回堆中最大数并且删除,这里的做法是先将第一个位置的数(最大数)保存起来,然后与最后位置的数交换,然后让整个数组的有效区域减少一个,再将整个数组进行heapify()的过程,就是重新调整堆,让新上来的头节点与其最大的孩子节点比较,如果小于就交换,直到大于其孩子节点为止。

两个重要函数的另一个heapify() ——调整某个节点一下的堆

 // heapify()过程:将最大值与最后一个值交换然后重新调整堆的过程
 // size 作为有效区的大小,也是控制整个数组的数,代表最右边的数
void heapify(int arr[], int index,int size) // 对index位置的数做heapify
{
    int left_child=index*2+1;
    while(left_child<=size)  // 存在左孩子
    { 
      //找到两个孩子中最大的孩子的下标
      int max_num=left_child+1<=size && arr[left_child+1]>arr[left_child]?left_child+1:left_child;
      if(arr[index]>=arr[max_num]) return; //最大的孩子都超不过当前节点,直接返回了
      swap(arr,index,max_num);
      index=max_num;
      left_child=index*2+1;
    }
}

到此,堆排序的精髓部分说完了,就是一个heap_insert () 的过程,一个heapify () 的过程,堆的知识要比堆排序重要的多,堆排序知识堆的一种用法,有了上面的概念,下面介绍堆排序

// 两种方法: 1、建立大顶堆用heap_insert方法,一个个插入,
//           2、当一下子知道所有的数,就从后向前用heapify去建立大顶堆
// 法 1
void heap_sort(int arr[], int n)  // 对从零开始的n个数进行排序
{
   if(n<=1)return;
   for (int i = 1; i < n; ++i){    // 建立了大根堆
      insert_heap(arr, i);
      } 
   // 先交换头和尾一次
   int i=n-1;
   swap(arr,0,i);
   i--;
   while (i > 0)
   {
     heapify(arr, 0,i);  
     swap(arr, 0, i);
     i--;                //不断的交换和维持大顶堆的过程
   }
}

// 法 2
void heap_sort2(int arr[], int n)
{
 if (n <= 1)return;
  //从后往前建立大顶堆,只用 heapify建立,时间复杂度 O(n);
 for (int i = (n - 1) / 2; i >= 0; i--){
  heapify(arr, i, n-1);
 }                 
 int i = n - 1;
 swap(arr, 0, i);
 i--;
 while (i > 0)
 {
  heapify(arr, 0, i);
  swap(arr, 0, i);
  i--;                //不断的交换和维持大顶堆的过程
 }
}

整体来说,这个堆排序就是先建立好堆,然后不断的第一个位置和最有一个有效位置交换,然后缩小有效位置,然后对第一个位置进行heapify () 调整,直到只剩下0位置的数为止。
利用堆能解决很多问题,但前面也提到了,大部分时候我们可以直接使用STL中的priority_queue这个适配器直接完成,但同样有些时候需要在建立堆或者heapify () 同时处理一些事,现在具体想不起来什么题目用到,后面如果想到再补充吧。

补充:
最后对三种O(nlogn)的排序算法有点感触,快排空间复杂度是logn,而且还不稳定,堆排序空间复杂度是O(1),非常好,但也不是稳定的算法,归并排序是一种稳定的排序算法,但是空间复杂度为O(n),看起来每一种O(n*logn)的算法没有十全十美的,而且貌似快排什么优势没有啊,从表面上看,既不是稳定的算法,空间复杂度也不是O(1),但是快排在数据量大的情况下,是最快的,所以叫快排,这是由于常数时间的差别造成的,虽然都是O(nlogn)。但是常数时间不同,具体数据是通过大量的实验得到的结果,然后,目前没有一种稳定时间复杂度O(n*logn)空间复杂度O(1)的一种算法!

bye~

展开阅读全文

没有更多推荐了,返回首页

©️2019 CSDN 皮肤主题: 大白 设计师: CSDN官方博客
应支付0元
点击重新获取
扫码支付

支付成功即可阅读