七大排序(一)

1.1排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序
1.2常见的排序算法
插入排序:直接插入排序,希尔排序
选择排序:选择排序,堆排序
交换排序:冒泡排序,快速排序
归并排序:归并排序
2.1插入排序
直接插入排序是一种简单的插入排序法。其基本思想: 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。比如打扑克牌时,就经常用到这种算法。
void InsertSort(int* a, int n) //对一组数据进行插入排序,n是有效数据的个数
{
        int i = 0;
        int j = 0;
        for (j = 0; j < n - 1; j++)
        {
               int end = j;
               int tmp = a[end + 1];
               for (i = j; i >= 0; i--)
               {
                       if (a[i] > tmp)
                       {
                              a[i + 1] = a[i];
                       }
                       else
                       {
                              break;
                       }
               }
               a[i + 1] = tmp; //如果tmp比第一个数还要小,那么end已经等于-1,结束循环了,所以要在循环外赋值
        }
}
插入排序至少要包含两个数,一个为已经排好的有序数列,一个为将要插入的数据。当end为0时,代表有序数的尾下标为0。
直接插入排序的特性总结:
1. 元素集合越接近有序,直接插入排序算法的时间效率越高
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1),它是一种稳定的排序算法
4. 稳定性:稳定
比较一下插入排序和冒泡排序:
冒泡排序和插入排序的时间复杂度是一样的,但插入排序优于冒泡排序。原因在于,如果一组数接近于有序,那么插入排序只需要修改成有序即可,而冒泡排序还要再循环一次来判断是否有序。
2.2希尔排序
希尔排序是在插入排序的基础上优化得出的排序,比较难理解,但十分优秀。
希尔排序的基本思路是先进行一次预排序,是数据接近有序,然后再进行插入排序。
预排序就是让大的数尽快到后面,小的数尽快到前面。具体操作是:将数据分成几组(gap),每次一组进行插入排序,几组进行完后,数据就相对有序。可以自己选择分为几组(gap):图示gap为3
分组后插入排序挪动的次数就减少了。当把逆序用插入排序改为顺序时,这样的优化效果最大。
gap越大,大的数据越快到后面(是分别在几个循环中跳的,不是一次跳到合适位置),预排序后的数据越接近无序;gap越小,预排序后的数据越接近有序。
预排序代码:
void ShellSort(int* a, int n)
{
        int gap = 3;
        for (int j = 0; j < gap; j++)
        {
               for (int i = j; i < n - gap; i += gap) //n-gap是防止越界的,当end为一组数据的倒数第二个时,就是最后一次插入排序了。最后一次是比较前面的数,插入合适的位置,插入后,该组数就有序了。所以控制的边界就是,n-1位置所在组数的倒数第二个数据。
               {
                       int end = i;
                       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 gap = 3;
for (int i = 0; i < n - gap; i ++)
{
        int end = i;
        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;
}
这种方法主要是在排第一组数据的第一个后,不排第一组的第二个,而是排第二组的第一个,每组的第一个排完后,再排第一组的第二个。
gap具体的值不适合用固定的,官方只给了确定gap的建议。
实现方式是这样的:
void ShellSort(int* a, int n)
{
        int gap = n;
        while (gap > 1)
        {
               gap = gap / 3 + 1; //保证最后一次是插入排序
               for (int j = 0; j < gap; j++)
               {
                       for (int i = j; i < n - gap; i += gap)
                       {
                              int end = i;
                              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;
                       }
               }
        }       
}
希尔排序:
gap>1:预排序
gap==1:插入排序
看似希尔排序没有优化,最后都要用插入排序。实际上希尔排序是有时间复杂度上的的优化的。
插入排序在确定待插入的数比比较的数大,会立刻break,预排序就是将大的数快速挪到后面。保证break的更早更多。
希尔排序的缺点:对一个接近有序的数列,预排序没有太大效果。
这里介绍一个计算运算时间的方法:
clock()函数,会记录计算机在运行到这个函数时的时间(单位:毫秒)
int begin  = clock();
执行函数;
int end = clock();
end-begin就是这个函数运行的时间。
在使用这个方法的时候,要在release版本下测试,debug版本要建立支持调试的函数栈帧,需要很多额外信息。
利用随机数进行排序,发现冒泡排序虽然和插入排序是一样的时间复杂度,但运行的时间差了大约十倍。希尔排序是和堆排序一个级别的。
希尔排序的时间复杂度十分难算。
所以采用估算法:当gap大时,时间复杂度为O(N),因为跳到步数大;当gap小时,时间复杂度为O(N),因为此时数列趋近于有序。gap次数的计算是N/(3*3*3……*3) = 1,共x次乘3。所以x=log以3为底的N。时间复杂度可以近似为O(N*log(3)N),有些教科书上有计算平均时间复杂度为O(N^1.3),有兴趣可以了解一下。
3.1直接选择排序
直接选择排序是最直观的排序,也是效率最低的排序。
已知排序的效率的高低顺序为:堆排序=希尔排序>插入排序>冒泡排序>选择排序
选择排序就是将数据遍历一遍,将最小数选出来,然后和合适位置的数交换;堆排序则是建好堆后,迅速找出最小数(最大数)然后放到合适的位置处。
同样选择排序也可以优化,比如一次选两个数,最小的和最大的。
代码如下:
void swap(int* a, int* b)
{
        int tmp = *a;
        *a = *b;
        *b = tmp;
}
void SelectSort(int* a, int n)
{
        int left = 0;
        int right = n - 1;
        int i = 0;
        while (left < right)
        {
               int maxi = left;
               int mini = left;
               for (i = left + 1; i < right; i++)
               {
                       if (a[i] < a[mini])
                              mini = i;
                       if (a[i] > a[maxi])
                              maxi = i;
               }
               swap(&a[left], &a[mini]);
               swap(&a[right], &a[maxi]);
               left++;
               right--;
        }
}
int main()
{
        int a[10] = { 9,1,2,5,7,4,8,6,3,5 };
        SelectSort(a, 10);
        for (int i = 0; i < 10; i++)
               printf("%d ", a[i]);
        return 0;
}
但这样的优化也会存在问题,在排下组数据时,没有完成顺序排序,原因在于在极限状态下,当left == max时,在经过交换后,max下标所指向的数据已经发生了改变,再交换就不是最大值移到最右边了。
这种情况只需要修改一下,如果把交换的顺序修改,也会有right == mini的情况。
               swap(&a[left], &a[mini]);
               if (left == maxi)
                       maxi = mini;
               swap(&a[right], &a[maxi]);
优化后的选择排序是比冒泡排序更优的,但在已经有序的情况下,冒泡排序更优。选择排序不能进行是否有序的识别,插入排序和冒泡排序则可以识别有序的情况。
4.1快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,后来又有人对快速排序进行了优化,由此快速排序演生了几种版本。
最开始的版本是Hoare版本:
单趟排序:选出一个key,一般是第一个数或者最后一个数,key称为基准值。要求排完后左边的值都比key小,右边的值都比key大。
一般是建立左位置变量,右位置变量;左位置找比key大的值,找到后停下,右位置找比key小的值,找到后停下;当两个位置都停下后,值交换,继续找下一组符合条件的值;在没有找到符合的值时,不会停止寻找。是按照一个找到后另一个找,而不是左边找一次,右边找一次的找。当左位置和右位置相等时,将该位置的值与key位置的值互换。
这样要保证相遇位置的值比key位置的值小,如何保证?
右位置先走,直到找到第一个比key小的数,左边再走就一定会相遇在比key位置值小的地方。如果key的位置是在最右边,那么,左位置先走。可以保证相遇位置的值一定比key大。
以key在左边为例:相遇只有两种情况,一种是右位置遇到比key小的值停下了,左位置找到右位置;一种情况是交换后右位置先走,已经没有比key位置还小的数,右位置一直找到左位置的地方。
代码如下:
void swap(int* a, int* b)
{
        int tmp = *a;
        *a = *b;
        *b = tmp;
}
void PartSort(int* a, int n)
{
        int key = 0;
        int left = 1;
        int right = n - 1;
        while (left < right)
        {
               while (left < right && a[right] > a[key]) //在找值时,要确定和key位置相等的值归左还是归右,如果仅仅判断是否大于小于,会出现死循环。如果判断大于等于,小于等于也可以。只是要解决存在与key相同的值出现的死循环。
               {
                       right--;
               }
               while ((left < right && a[left] <= a[key]) //如果key位置的数为最小值,要加上left<right的条件防止越界
               {
                       left++;
               }
               swap(&a[left], &a[right]);
        }
        swap(&a[left], &a[key]);      
}
单趟排序完成后,key位置的值,已经在它正确的位置,如果左边有序,右边有序,整体就有序。
如何解决左边右边有序的问题?
用分治思想解决。思想上还是二叉树:key位置有序后,看key左边整体,不停递归,直到待排序部分整体的长度小于等于1。
图示如下(是二叉树,又不是二叉树):
相对的,要对函数参数进行修改,方便控制区间。
void swap(int* a, int* b)
{
        int tmp = *a;
        *a = *b;
        *b = tmp;
}
int PartSort(int* a, int left, int right)
{
        int key = left; //key表示比较值的下标
        while (left < right)
        {
               while (left < right && a[right] > a[key]) //如果这里和下面while的条件都只是大于小于,出现和key值相等的值时,就会发生下列情况:找到的值不大于a[key],right停下,左边也找到与key值相等的值,停下,左右交换,交换后两边的值不变,再判断,还是不符合大于a[key]的情况,陷入死循环。
               {
                       right--;
               }
               while (left < right && a[left] <= a[key])
               {
                       left++;
               }
               swap(&a[left], &a[right]);
        }
        swap(&a[left], &a[key]);      
        return left; //返回的left是key位置的值
}
void QuickSort(int* a, int begin, int end) //begin和end是闭区间
{
        if (end <= begin) //要考虑子区间只有一个值或者不存在的情况
               return;
        int keyi = PartSort(a, begin, end);
        QuickSort(a, begin, keyi - 1); //key位置的数在到达正确的位置后前面的区间
        QuickSort(a, keyi + 1, end); //key位置的数在到达正确的位置后后面的区间
}
下面介绍一个,对Hoare版本优化的方法,本质上效率没有明显提升,只是方便理解。Hoare版本也是要掌握的,因为公司面试可能会出不定项选择题,考察三种单趟排序,这是第二种,名为挖坑法
首先,选择一个坑位,将坑位的值保存(key),定为比较值,然后从右边边开始找,找到比比较值小的值放到坑位去,这时,坑位发生改变,改变为找到用来填坑值的位置,再从左边开始找比比较值大的位置,找到后值放入坑位中,坑位再次发生改变,以此类推,直到左边找到右边(左边等于右边)。最后将比较值放入坑位中。
如图所示(key应该是比较值,坑位是另外的变量):
相比原始版本,完坑法不需要保证相遇位置的值小于key,也不需要理解右边先走(从右边找值填坑是自然而然的)。
代码如下:
int PartSort(int* a, int left, int right)
{
    int key = a[left]; //这里的key不表示下标,而是表示比较的值
    int pit = left;
    while(left < right)
    {
            while(left < right && a[right] >= key)
            {
                right--;
            }
            a[pit] = a[right];
            pit = right;
            while(left < right && a[left] <= key) //即使找到后left或者right停下了,因为此时left,right,pit值相等,所以交换也不会有影响
            {
                left++;
            }
            a[pit] = a[left];
            pit = left;
    }
    a[pit] = key;
    return pit;
}
比较挖坑法和hoare法的单趟排序,所得结果不同。
然后是第三的方法:前后指针法
这是最新的快排优化方法,还没有具体的名字,但是这是细节处理最优的写法。
指使用一前一后两个指针,prev和cur,prev开始指向第一个值,cur开始指向第二个值,cur负责找比key值小的数,每次找到后prev++,然后交换。最后找完的那一步prev不需要++,直接与key值交换,然后改变key的位置,返回key。因为prev指向的值比prev小,而++后指向的值就比prev大了。prev前面的值比key小,perv和cur之间的值比key大,与key相等的值可能在任意一边,不会影响结果。
具体过程如图所示:
注意点:cur和prev不能赋确定的值,,代码是通过递归来确定各个区间的值的,利用left,key,key+1,right来确定每个值,用确定的值初始化,比如0和1,会有问题,因为每次单趟排序不一定是从0和1开始,有可能从6和7开始。
如图所示:
第一层是从0,1开始,第二层的第二部分就是从6,7开始,当取出key为5时,由left(0),key(5),key+1(6),right(9)来控制区间,递归的子区间不一定从0开始。
代码如下:
int PartSort3(int* a, int left, int right) //前后指针法
{
        int keyi = left; //如果key保存的是变量,那么要注意,最后交换的时候不能和key交换,因为key是局部变量,交换不会影响数组中的值
        int prev = left;
        for (int cur= left + 1; cur <= right; cur++) //选择闭区间来控制区间,right是最后一个,也要比较
        {
               if (a[cur] < a[keyi])
               {
                       prev++;
                       swap(&a[prev], &a[cur]); //这里会出现cur和prev相同情况,想要不重复交换,将prev++去掉,将if中的条件改为 ( a [cur] <  a [keyi] && a[prev++] != a[cur])即可。每次出现比a[keyi]小的值就后置++,判断与a[cur]是否相同,不相同再交换
               }
        }
        swap(&a[keyi], &a[prev]);
        keyi = prev;
        return key;
}
再来比较三种方法的结果:
三者确实不一样,在做不定项选择的时候,三种情况都要考虑。顺便说一下,大厂的面试题都是在线oj,没有选择题。
hoare和挖坑法选左边或者右边作key没有太大区别。前后指针法选后面作key,情况则如图所示:
cur给left,prev给left-1。其余逻辑与key在前相同,就是在最后与key交换时,prev同样需要++,key左边要比key小,prev不++的话,下标prev所指向的值会比key小,就不符合单趟排序的目的了。
快速排序的特性总结:
1. 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2. 时间复杂度:O(N*logN)
最好情况:每次选到的key都是中位数(排成有序最中间的数),第一层排正确1个数,第二层排正确2个数,第三层排正确4个数。直到排完。情况如图所示:N=1+2+4+8+……2^x  
这样排序的层数是logN,每层近似看成O(N),时间复杂度:O(N*logN)
但是按照这样快速排序的最坏情况来看,时间复杂度根本不是O(N*logN),最坏情况是每次取的key都是最大(最小),最终会出现这样的情况:
第一次选取1作key,1的左边为空,1的右边都是比1大的数,完成单趟排序;第二次选取2作key,情况类似,直到选6作key。每次这样单趟排序的话层数是O(N),每层是O(N),时间复杂度为O(N^2)。
除了时间复杂度,还有一个问题,快速排序是使用递归实现的,调用太多次会出现一个问题:爆栈。
函数的调用是会建立函数栈帧的,调用太多次就会出现栈空间不够的情况。
不懂函数栈帧的可以看看我前面写的文章:函数栈帧的创建与销毁。
即使这样,在c/c++的库中,也是使用快排作库的,而不是使用希尔排序。每次都选用最大(最小)作key,只有在有序或者接近有序的情况会发生,那么可以做针对性的优化。即针对选key进行优化。经过专业测试的人测试,希尔的稳定性不如快排。虽然在整数部分希尔可能优于快排,但在其他变量,其他量级中,快排更具有稳定性。
有两种方案:1.随机选key。2.三数取中,即选取最前面,最后面,中间的数,选择不是最大,也不是最小的那个数。
随机选key并不推荐,虽然随机选不容易选出最值,但也不容易选出最合适的值。
三数取中,则具备不会选出最值和更容易选出合适值两种特性。
进行优化后的代码:
int GetMidIndex(int* a, int left, int right) //逻辑很简单,结构较复杂
{
    int mid = (left+right)/2; //防溢出写法:mid = left + (right - left) / 2;
    if(a[left] < a[mid])
    {
        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 right;
            else
                return left;
        }
    }
}
int PartSort3(int* a, int left, int right) //前后指针法
{
        int midi = GetMidIndex(a, left, right);
        swap(&a[midi], &a[left]); //将三数中的中间值与第一个值交换,后面的代码就不需要改变了
        int keyi = left;
        int prev = left;
        for (int cur= left + 1; cur <= right; cur++)
        {
               if (a[cur] < a[keyi])
               {
                       prev++;
                       swap(&a[prev], &a[cur]);
               }
        }
        swap(&a[keyi], &a[prev]);
        keyi = prev;
        return keyi;
}
还有一种优化方式,叫小区间优化,虽然提升效果不是很明显,但是标准c++库中是有这个优化的,所以也提一下。
如图所示,快排的递归展开简化图就是一棵二叉树。
若有1000个数,则有10层。由图示可以推出在只有7个数进行排序时,就排序7个数,要进行14次递归(粗略计算),仅仅7个数要进行14次递归,有点低效率(总体排序的数越多,在进行小数据量排序时要递归的次数越多)。所以要小区间优化,即在小区间不使用递归调用进行排序。对数据量小的数据进行排序,可以使用选择,冒泡,插入。其中插入最优。
第一层调用1次,第二层2次,第十层就要调用500+次,一共才调用1000次,去掉最后一层的调用直接少了近一半的调用次数。不过由于编译器优化后(release版本),函数调用消耗很低,所以优化的效果也不是很明显。
考虑到正常计算机处理的数据量,采用插入排序的门槛数量为10左右是比较合适的。
代码如下:
void QuickSort(int* a, int begin, int end) //begin和end是闭区间
{
        if (end <= begin)
               return;
        if(end - begin + 1 <= 10) //采用闭区间,区间中数实际的个数是end-begin+1
        {
            InsertSort(a + begin, end-begin+1);
            //a指向数组首地址,插入排序需要知道排序数据的个数,a要指向将要排序数据的第一个。如上图
        }
        else
        {
            int keyi = PartSort(a, begin, end);
            QuickSort(a, begin, keyi - 1);
            QuickSort(a, keyi + 1, end);
        }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值