(C++)排序——四种排序算法与STL sort()使用详解

冒泡排序

冒泡排序是排序原理最简单的一种排序. 在运算规模较小时非常实用,其思想也能辅助解决某些更复杂的问题.

冒泡排序(bubble sort)的思想如下:

例如,我们进行升序排序,那么,从长度为n的数组的第0位开始,将其依次与后一位比较,如果前一位大于后一位,那么就交换这两个元素. 从0开始交换到最后一个后,一次排序完成. 现在,我们已经将我们需要的有序数组排好一位了:最大的数现在被放在了数组最后面. 那么,未排序的部分长度只有n-1了.
再次排序,次大的数已经放在了整个数组的倒数第二位.

然后,我们不断重复这个过程,缩小未排序数组的长度,直到未排序数组长度为1,冒泡排序的程序就走完了.

例如,我们给一个数组:7,8,10,2,4,3,它们的位置对应下标0,1,2,3,4,5.

第一次排序,从下标为0的7开始,不交换7和8,不交换8和10,交换10和2,交换10和4,交换10和3.
第一次排序结束后,数组变为7,8,2,4,3,10.(访问的下标从0到4)
同样地,第二次排序,得到7,2,4,3,8,10.(从0到3)
然后是:
2,4,3,7,8,10;(从0到2)
2,3,4,7,8,10.(从0到1)
2,3,4,7,8,10.(只有0)(虽然已经排好序,但是按冒泡排序的程序走还得检查一次)

每次排序,未排序的数组中最大的元素总是被留在了最后面. 我们需要排的这个最大数在一次一次的交换中像泡泡一样咕噜咕噜地跑到了最后面,就像“冒泡”一样,因此,我们把这种排序称作冒泡排序——bubble sort.
要实现冒泡排序,我们需要2层循环,一层循环的长度就是数组的长度减去1——我们的未排序数组长度应从n逐步减至1,需要做n-1次操作. 二层循环的长度则是每个未排序数组的长度——从n直到1.

升序的冒泡排序的实现代码如下:

void bubble_sort(int a[]int n)//参数分别是数组和数组长度
{
	for(int i = 0; i < n - 1; ++i)
		for(int j = 0; j < n - 1 - i; ++j)
		{
			if(a[j] > a[j + 1])
			{
				int tmp = a[j];
				a[j] = a[j + 1];
				a[j + 1] = tmp;
			}
		}
}

至于降序,只需要变if中的大于号为小于号就可以了.

另外,选择排序也是一种常用的排序方法,和插入排序的执行方式十分相似. 它是从未排序数组中选择最小的数排在最前面,然后再从剩下未排序的数中选择最小的排在之前最前面的数的后面,直到排完.

插入排序

冒泡排序很好地解决了简单的排序问题. 但,我们不得不审视它的运行效率:例如上例在排4次后,已经形成了有序序列,冒泡排序会进行一次多于的判断循环,这就造成了程序对时间资源的浪费.

由于冒泡排序需要进行2层循环,我们可以知道,它的时间复杂度应为O(n2). 实际上,当我们需要排序一个非常大的数组时,冒泡排序极大可能会重复判断相当多的数对. 当数组的长度大于106时,程序平均运行时间很可能会超过10秒!这在许多编程竞赛和实际问题解决中是绝对不允许的. 于是,在此基础上,我们提出插入排序,相较于冒泡排序,它可以有效避免重复判断.

插入排序的思想是:先假设数组第一个元素已经排好序,然后将后面的数依次插入这个有序数表. 例如,再次考虑数组7,8,10,2,4,3. 一开始,7已经排好序,我们从8开始,它应该插入7的后面,然后考虑10,它应该插在8的后面,这样,有序组是7,8,10. 再轮到2,由于它比10,8,7都小,它应该插在7的前面,得到数组2,7,8,10,4,3. 再次重复操作,依次得到2,3,7,8,10,4、2,3,4,7,8,10. 这样,我们就完成了这次排序.

它比冒泡优秀在哪?

我们发现,如果这个数组的某些部分原本就是有序的,插入排序就会避免重复判断大小关系,继续插入后一个数,而冒泡排序不管数组如何排列,总是对每两个数多次比较. 当数组原本有序时,插入排序只需要依次把数插进来即可,这个时候它的时间复杂度是O(n).

但面对完全逆序数组,插入排序让每个数找到位置时,需要从有序组的后面往前面一直寻找位置,需要耗费和冒泡排序一样多的操作.

因此,我们说,插入排序的时间复杂度是O(n)~O(n2),与输入数据的特点有关.

下面是插入排序的实现代码:

void insert_sort(int a[] , int len)//插入排序
{
    /*1.从第一个元素开始,该元素可以认为已被排序;
    2.取出下一个元素,在已经排序的元素序列中从后向前扫描;
    3.如果该元素(已排序)大于新元素,将该元素移到下一个位置;
    4.重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
    5.将新元素插入到该位置后,重复2~5*/
    int b[len] = {0};
    b[0] = a[0];
    for(int i = 1; i < len; ++i)
    {
        int j = i - 1;
        for(; j >= 0; --j)
        {
            if(b[j] > a[i])
                b[j + 1] = b[j];
        }
        b[j + 1] = a[i];
    }
    for(int i = 0; i < len; i++)
    {
        a[i] = b[i];
    }
}

快速排序

前两种排序均适用于较小数据规模的排序,但数据规模大起来后,他们的运行时间是非常长的. 一个106的数组,冒泡排序需要10秒以上的时间,而插入排序的耗时非常不稳定,也极有可能花费好几秒. 而这样的效率,在许多编程竞赛和实际问题解决中是绝对不允许的.

相较于之前两种排序,我们提出一种使用递归实现的排序算法——快速排序.
快速排序.

快速排序的思想依赖于递归思想:

先确定一个基准数. 这个基准数我们任意选择.
然后,我们需要一种操作规则,来让我们完成操作后,使得排在基准数之前的数全部小于等于基准数,后面的数全部大于等于基准数.

第一种想法:取数组的第0位为基准值. 然后,从两侧遍历数组(注意从前往后遍历时要从第1位而不是第0位开始,因为0是我们需要的基准值),如果在前方遇到大于基准值的数,就暂停,存储它的下标.

同样地,在后方遇到小于基准值的数,也暂停,存储它的下标. 这时,将两个下标对应的数交换,于是左边的下标所在数变得小于基准值,而右边的大于基准值. 这时,继续从前往后、从后往前扫描.

当我们重复这一步骤至从前往后扫描的索引与从后往前扫描的索引相遇(相等)时,我们的数组已经变成这样:排在索引相遇处之前的数全部小于等于基准数,后面的数全部大于等于基准数. 于是,我们知道,索引目前所在的位置就是基准值应该存在的位置. 将索引所在的数与基准值交换,我们的目的就达到了.

同时,我们介绍第二种达到这种效果的方法:取数组的中间值为基准值,然后从基准值前面、后面遍历. 这种方法也能达到目标效果.

一次排序完成之后,我们将数组切分为两段,对前半段、后半段分别重复这段找基准值并排序的操作,再将前半段、后半段分别分为2段,……

当我们分出来的一段长度为1时,我们就知道:快速排序已经完成了!因为所有较小的值都被排到了前面,而所有较大的值都被排到了后面.

递归实现的快速排序代码如下:

void quick_sort(int a[] , int Begin , int End)//快速排序
{
    if(Begin >= End)
        return;
    else{
        int base = a[Begin];//取a[Begin]为基准数.
         int i = Begin , j = End;
         while(i < j)
         {
             //对右边,向左查找
             while(i < j && a[j] >= base)
                --j;
             //找到第一个小于等于基准值的元素a[j].
             //对左边,向右查找.
             while(i < j && a[i] <= base)
                ++i;
             //找到第一个大于等于基准值的元素a[i].
            if(i < j)
             {
                 int temp = a[j];
                a[j] = a[i];
                a[i] = temp;
             }

         }
         a[Begin] = a[i];
         a[i] = base;
         quick_sort(a , Begin , i - 1);
         quick_sort(a , i + 1 , End);
    }
}

由于只需要遍历每个元素的值并切分(log2n~n)次,所以快速排序的平均时间复杂度是O(nlogn).

当每次选择的基准值是数组的最大值或最小值时,快速排序会遇到最坏的情况,即每次切分都会切分一个长度为0和一个长度为原数组长度的数组,相当于是在进行插入排序. 为了避免基准值的不恰当选择,我们可以使用一些评判基准值的选择法——例如选择数组首位尾位以及中间位,取3数中间数为基准值的方法,等等.

归并排序

相比于快速排序,归并排序也是一种高效的排序方式,虽然效率略低于快速排序,但它不失为理解分治算法(divide-conquer)的一个好工具.

分治的思想是:当一个问题复杂的时候,我们不直接去求解这个问题,而是将问题分解为若干个子问题. 如果子问题仍然复杂,我们就继续分解,直到子问题足够清晰足够简单. 这就是“分”的思路. 然后,我们还需要“治”. 于是我们就解决这个足够简单的子问题,然后利用用子问题的结果,我们可以轻松地反推原问题,最后得到整个问题的解.

归并排序是这样应用分治思想的:
对于一个很长的数组,不断地将其二等分,直到子数组的长度为1. 这个时候,开始回溯解决原问题:

两个长度为1的数组需要放回到一个长度为2的新数组中,我们设置两个索引i、j,分别遍历两个子数组. 如果a[i] < a[j],就先放 a[i] 到新数组中,再放另一个,反之亦然. 这样,我们就得到若干个排好序的长度为2的子数组. 再接着对每对长度为2的子数组操作:设置两个索引 i、j. 分别遍历,把值小的优先放入新数组,最后组成一个长度为4的有序子数组, 不断这样重复操作,我们就能得到目标数组.

细节上,新数组是如何实现的?

我们看看下面这个例子.
对于已经排好序的子数组2,7,8和3,4,10:

由于2小于3,所以放2在第一个.
左边的子数组索引前进1位,由于7大于3,所以放左边子数组的3在第二个.

右边的子数组索引前进一位,由于4小于7,所以放右边子数组的4在第三个.

右边的子数组索引前进一位,由于10大于7,所以放7在第四个.

左边的子数组索引前进一位,由于8小于10,所以放8在第五个.

最后,放10在最后一个.

这里要注意,我们很可能出现一个数组的值放完时另一个数组的值还有很多没有放的情况. 于是我们每次需要检查索引与子数组长度的关系. 一旦索引已经遍历完一个数组的所有值,我们应当不再考虑它,直接把另一个数组没排完的元素依次加上就可以了.

归并排序代码如下:

void manage(int* a, int l, int r, int mid)//治(把已经排好序的分组放回原组)
{
    if(r - l == 1)
    {
        if(a[l] > a[r])
        {
            int tmp = a[l];
            a[l] = a[r];
            a[r] = tmp;
        }
    }
    else{
        int le[mid - l + 1], ri[r - mid];
        for(int i = l; i <= mid; ++i)
            le[i - l] = a[i];
        for(int i = mid + 1; i <= r; ++i)
            ri[i - mid - 1] = a[i];
        int i = 0, j = 0;
        int qwq = l;
        while(qwq <= r){
            if(i < mid - l + 1 && le[i] < ri[j])
            {
                a[qwq] = le[i];
                i++;
            }
            else if(j < r - mid && ri[j] <= le[i])
            {
                a[qwq] = ri[j];
                j++;
            }
            else if(i < mid - l + 1 && j >= r - mid)
            {
                a[qwq] = le[i];
                i++;
            }
            else if(j < r - mid && i >= mid - l + 1)
            {
                a[qwq] = ri[j];
                j++;
            }
            qwq++;
        }
    }
}
void divide(int* a, int l, int r)//分
{
    if(l >= r)
        return;
    int mid = (l + r) / 2;
    divide(a, l, mid);
    divide(a, mid+1, r);
    manage(a, l, r, mid);
}//调用时调用divide函数即可(别在意变量qwq的名字)

C++库 sort函数

除了以上4种排序算法,排序的方式还有堆排序,希尔排序等等. 在这里不再介绍(反正也没人看,反正网上别的资料也多得很QAQ)

然而,为了方便地解决实际问题,C++的STL库中也有一种内置的sort函数,它可以方便地为数组进行排序,并且可以进行任何可以排序的数据结构的排序,可以以任何你想要的方式对任何数据类型(包括自定义的类和结构体)排序.

sort函数的使用需要#include <algorithm>.

sort函数的格式是 sort(指针1,指针2,排序函数).

指针1/2也可以是迭代器.

对于我们需要排序的序列,我们用指针1指向序列头,用指针2指向序列尾.

那么,sort将会对指针1到指针2的所有地址所存的元素排序.

例如,对长度为n的数组a,我们可以用 sort(a, a + n)来进行排序.

而第三个参数排序函数则不是必须的,只有在我们需要降序排列而不是升序排列,或是有其他的排序方法,或是有未定义排序方式的结构,我们才需要排序函数.

如果我们对整型数据升序排序,排序函数的默认值是这样:

bool cmp(int a, int b)
{
	return a < b;
}

实际上,不管是各类竞赛,还是实际应用中,我都推荐使用sort而不是自己写的排序函数——因为sort函数更快.

(然而,自己写排序函数不仅有助于学习算法思想,而且在解决某些问题,例如计算冒泡排序的次数时也是sort不可替代的. 毕竟sort经过了封装,能进行的也只有排序而已. 所以不是说不用学排序算法啦)

“难道快速排序不是平均速度最快的排序方式吗?sort函数进行了那么多复杂的封装,难道不会因为此降低效率吗??”

平均啊,平均!

sort的第一个优点在于,它会根据数据的特点自动选择最优的排序方式. 如在数据量大时快排,在递归层数过深时改用堆排序,等等.

也就是说,sort集合了所有排序算法的优点于一身. 它的速度是最快的.

而sort函数的第二个优点在于,它是一个模板函数,可以对任何定义了比较函数的结构排序. 并可以满足多种复杂的排序要求:
例如,我们看下面这个例子:

现有若干名学生. 他们一起进行了一次测验,每个人都有考试成绩和ID两个数据,现在要将他们排名次,规定按考试成绩降序排序,但若两人考试成绩相同,则按ID升序排序. 请你写出一个程序,输入这些学生的信息,然后按排名要求依次输出它们的名次.

首先这个结构体应该是这样:

struct student
{
	int id;//ID
	int grade;//成绩
};

实际操作时,将学生们存入一个学生结构体数组.

如果自己去写排序算法,我们不仅需要写出算法框架,而且还要因为排序对象是结构体而不是整数去修改细节.

容易出错不说,即使完成,运行效率也比不上sort.

这个时候,采用sort,我们只需要定义排序的规则即可:

bool cmp(student stu1, student stu2)
{
	//如果某次排序的两个对象成绩不相等,那么按照成绩降序排列.
	if(stu1.grade != stu2.grade) return stu1.grade > stu2.grade;
	//否则,按照ID升序排列.
	else
		return stu1.id < stu2.id;
}

于是,假设有50个学生,我们建一个学生数组

struct student stu[50];

逐个输入他们的信息后,我们便可以用sort排序:

sort(stu, stu + 50, cmp);//函数cmp已经在上方定义

由此例,我们发现,即使在更复杂的排序要求中,sort也可以仅凭一个返回值为bool型,定义了排序规则的函数方便快速地对庞大的数据排序.

  • 2
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值