从零开始学算法:十种排序算法介绍(上)

    今天我正式开始按照我的目录写我的OI心得了。我要把我所有学到的OI知识传给以后千千万万的OIer。以前写过的一些东西不重复写了,但我最后将会重新整理,使之成为一个完整的教程。
    按照我的目录,讲任何东西之前我都会先介绍时间复杂度的相关知识,以后动不动就会扯到这个东西。这个已经写过了,你可以在这里看到那篇又臭又长的文章。在讲排序算法的过程中,我们将始终围绕时间复杂度的内容进行说明。
    我把这篇文章称之为“从零开始学算法”,因为排序算法是最基础的算法,介绍算法时从各种排序算法入手是最好不过的了。

    给出n个数,怎样将它们从小到大排序?下面一口气讲三种常用的算法,它们是最简单的、最显然的、最容易想到的。选择排序(Selection Sort)是说,每次从数列中找出一个最小的数放到最前面来,再从剩下的n-1个数中选择一个最小的,不断做下去。插入排序(Insertion Sort)是,每次从数列中取一个还没有取出过的数,并按照大小关系插入到已经取出的数中使得已经取出的数仍然有序。冒泡排序(Bubble Sort)分为若干趟进行,每一趟排序从前往后比较每两个相邻的元素的大小(因此一趟排序要比较n-1对位置相邻的数)并在每次发现前面的那个数比紧接它后的数大时交换位置;进行足够多趟直到某一趟跑完后发现这一趟没有进行任何交换操作(最坏情况下要跑n-1趟,这种情况在最小的数位于给定数列的最后面时发生)。事实上,在第一趟冒泡结束后,最后面那个数肯定是最大的了,于是第二次只需要对前面n-1个数排序,这又将把这n-1个数中最小的数放到整个数列的倒数第二个位置。这样下去,冒泡排序第i趟结束后后面i个数都已经到位了,第i+1趟实际上只考虑前n-i个数(需要的比较次数比前面所说的n-1要小)。这相当于用数学归纳法证明了冒泡排序的正确性:实质与选择排序相同。上面的三个算法描述可能有点模糊了,没明白的话网上找资料,代码和动画演示遍地都是。

    这三种算法非常容易理解,因为我们生活当中经常在用。比如,班上的MM搞选美活动,有人叫我给所有MM排个名。我们通常会用选择排序,即先找出自己认为最漂亮的,然后找第二漂亮的,然后找第三漂亮的,不断找剩下的人中最满意的。打扑克牌时我们希望抓完牌后手上的牌是有序的,三个8挨在一起,后面紧接着两个9。这时,我们会使用插入排序,每次拿到一张牌后把它插入到手上的牌中适当的位置。什么时候我们会用冒泡排序呢?比如,体育课上从矮到高排队时,站队完毕后总会有人出来,比较挨着的两个人的身高,指挥到:你们俩调换一下,你们俩换一下。
    这是很有启发性的。这告诉我们,什么时候用什么排序最好。当人们渴望先知道排在前面的是谁时,我们用选择排序;当我们不断拿到新的数并想保持已有的数始终有序时,我们用插入排序;当给出的数列已经比较有序,只需要小幅度的调整一下时,我们用冒泡排序。

    我们来算一下最坏情况下三种算法各需要多少次比较和赋值操作。
    选择排序在第i次选择时赋值和比较都需要n-i次(在n-i+1个数中选一个出来作为当前最小值,其余n-i个数与当前最小值比较并不断更新当前最小值),然后需要一次赋值操作。总共需要n(n-1)/2次比较与n(n-1)/2+n次赋值。
    插入排序在第i次寻找插入位置时需要最多i-1次比较(从后往前找到第一个比待插入的数小的数,最坏情况发生在这个数是所有已经取出的数中最小的一个的时候),在已有数列中给新的数腾出位置需要i-1次赋值操作来实现,还需要两次赋值借助临时变量把新取出的数搬进搬出。也就是说,最坏情况下比较需要n(n-1)/2次,赋值需要n(n-1)/2+2n次。我这么写有点误导人,大家不要以为程序的实现用了两个数组哦,其实一个数组就够了,看看上面的演示就知道了。我只说算法,一般不写如何实现。学算法的都是强人,知道算法了都能写出一个漂亮的代码来。
    冒泡排序第i趟排序需要比较n-i次,n-1趟排序总共n(n-1)/2次。给出的序列逆序排列是最坏的情况,这时每一次比较都要进行交换操作。一次交换操作需要3次赋值实现,因此冒泡排序最坏情况下需要赋值3n(n-1)/2次。
    按照渐进复杂度理论,忽略所有的常数,三种排序的最坏情况下复杂度都是一样的:O(n^2)。但实际应用中三种排序的效率并不相同。实践证明(政治考试时每道大题都要用这四个字),插入排序是最快的(虽然最坏情况下与选择排序相当甚至更糟),因为每一次插入时寻找插入的位置多数情况只需要与已有数的一部分进行比较(你可能知道这还能二分)。你或许会说冒泡排序也可以在半路上完成,还没有跑到第n-1趟就已经有序。但冒泡排序的交换操作更费时,而插入排序中找到了插入的位置后移动操作只需要用赋值就能完成(你可能知道这还能用move)。本文后面将介绍的一种算法就利用插入排序的这些优势。

    我们证明了,三种排序方法在最坏情况下时间复杂度都是O(n^2)。但大家想过吗,这只是最坏情况下的。在很多时候,复杂度没有这么大,因为插入和冒泡在数列已经比较有序的情况下需要的操作远远低于n^2次(最好情况下甚至是线性的)。抛开选择排序不说(因为它的复杂度是“死”的,对于选择排序没有什么“好”的情况),我们下面探讨插入排序和冒泡排序在特定数据和平均情况下的复杂度。
    你会发现,如果把插入排序中的移动赋值操作看作是把当前取出的元素与前面取出的且比它大的数逐一交换,那插入排序和冒泡排序对数据的变动其实都是相邻元素的交换操作。下面我们说明,若只能对数列中相邻的数进行交换操作,如何计算使得n个数变得有序最少需要的交换次数。
    我们定义逆序对的概念。假设我们要把数列从小到大排序,一个逆序对是指的在原数列中,左边的某个数比右边的大。也就是说,如果找到了某个i和j使得i<j且Ai>Aj,我们就说我们找到了一个逆序对。比如说,数列3,1,4,2中有三个逆序对,而一个已经有序的数列逆序对个数为0。我们发现,交换两个相邻的数最多消除一个逆序对,且冒泡排序(或插入排序)中的一次交换恰好能消除一个逆序对。那么显然,原数列中有多少个逆序对冒泡排序(或插入排序)就需要多少次交换操作,这个操作次数不可能再少。
    若给出的n个数中有m个逆序对,插入排序的时间复杂度可以说是O(m+n)的,而冒泡排序不能这么说,因为冒泡排序有很多“无用”的比较(比较后没有交换),这些无用的比较超过了O(m+n)个。从这个意义上说,插入排序仍然更为优秀,因为冒泡排序的复杂度要受到它跑的趟数的制约。一个典型的例子是这样的数列:8, 2, 3, 4, 5, 6, 7, 1。在这样的输入数据下插入排序的优势非常明显,冒泡排序只能哭着喊上天不公。
    然而,我们并不想计算排序算法对于某个特定数据的效率。我们真正关心的是,对于所有可能出现的数据,算法的平均复杂度是多少。不用激动了,平均复杂度并不会低于平方。下面证明,两种算法的平均复杂度仍然是O(n^2)的。
    我们仅仅证明算法需要的交换次数平均为O(n^2)就足够了。前面已经说过,它们需要的交换次数与逆序对的个数相同。我们将证明,n个数的数列中逆序对个数平均O(n^2)个。
    计算的方法是十分巧妙的。如果把给出的数列反过来(从后往前倒过来写),你会发现原来的逆序对现在变成顺序的了,而原来所有的非逆序对现在都成逆序了。正反两个数列的逆序对个数加起来正好就是数列所有数对的个数,它等于n(n-1)/2。于是,平均每个数列有n(n-1)/4个逆序对。忽略常数,逆序对平均个数O(n^2)。
    上面的讨论启示我们,要想搞出一个复杂度低于平方级别的排序算法,我们需要想办法能把离得老远的两个数进行操作。

    人们想啊想啊想啊,怎么都想不出怎样才能搞出复杂度低于平方的算法。后来,英雄出现了,Donald Shell发明了一种新的算法,我们将证明它的复杂度最坏情况下也没有O(n^2) (似乎有人不喜欢研究正确性和复杂度的证明,我会用实例告诉大家,这些证明是非常有意思的)。他把这种算法叫做Shell增量排序算法(大家常说的希尔排序)。
    Shell排序算法依赖一种称之为“排序增量”的数列,不同的增量将导致不同的效率。假如我们对20个数进行排序,使用的增量为1,3,7。那么,我们首先对这20个数进行“7-排序”(7-sortedness)。所谓7-排序,就是按照位置除以7的余数分组进行排序。具体地说,我们将把在1、8、15三个位置上的数进行排序,将第2、9、16个数进行排序,依此类推。这样,对于任意一个数字k,单看A(k), A(k+7), A(k+14), ...这些数是有序的。7-排序后,我们接着又进行一趟3-排序(别忘了我们使用的排序增量为1,3,7)。最后进行1-排序(即普通的排序)后整个Shell算法完成。看看我们的例子:

  3 7 9 0 5 1 6 8 4 2 0 6 1 5 7 3 4 9 8 2  <-- 原数列
  3 3 2 0 5 1 5 7 4 4 0 6 1 6 8 7 9 9 8 2  <-- 7-排序后
  0 0 1 1 2 2 3 3 4 4 5 6 5 6 8 7 7 9 8 9  <-- 3-排序后
  0 0 1 1 2 2 3 3 4 4 5 5 6 6 7 7 8 8 9 9  <-- 1-排序后(完成)


    在每一趟、每一组的排序中我们总是使用插入排序。仔细观察上面的例子你会发现是什么导致了Shell排序的高效。对,每一趟排序将使得数列部分有序,从而使得以后的插入排序很快找到插入位置。我们下面将紧紧围绕这一点来证明Shell排序算法的时间复杂度上界。
    只要排序增量的第一个数是1,Shell排序算法就是正确的。但是不同的增量将导致不同的时间复杂度。我们上面例子中的增量(1, 3, 7, 15, 31, ..., 2^k-1)是使用最广泛的增量序列之一,可以证明使用这个增量的时间复杂度为O(n√n)。这个证明很简单,大家可以参看一些其它的资料,我们今天不证明它。今天我们证明,使用增量1, 2, 3, 4, 6, 8, 9, 12, 16, ..., 2^p*3^q,时间复杂度为O(n*(log n)^2)。
    很显然,任何一个大于1的正整数都可以表示为2x+3y,其中x和y是非负整数。于是,如果一个数列已经是2-排序的且是3-排序的,那么对于此时数列中的每一个数A(i),它的左边比它大的只有可能是A(i-1)。A2绝对不可能比A12大,因为10可以表示为两个2和两个3的和,则A2<A4<A6<A9<A12。那么,在这个增量中的1-排序时每个数找插入位置只需要比较一次。一共有n个数,所以1-排序是O(n)的。事实上,这个增量中的2-排序也是O(n),因为在2-排序之前,这个数列已经是4-排序且6-排序过的,只看数列的奇数项或者偶数项(即单看每一组)的话就又成了刚才的样子。这个增量序列巧妙就巧妙在,如果我们要进行h-排序,那么它一定是2h-排序过且3h-排序过,于是处理每个数A(i)的插入时就只需要和A(i-h)进行比较。这个结论对于最开始几次(h值较大时)的h-排序同样成立,当2h、3h大于n时,按照定义,我们也可以认为数列是2h-排序和3h-排序的,这并不影响上述结论的正确性(你也可以认为h太大以致于排序时每一组里的数字不超过3个,属于常数级)。现在,这个增量中的每一趟排序都是O(n)的,我们只需要数一下一共跑了多少趟。也就是说,我们现在只需要知道小于n的数中有多少个数具有2^p*3^q的形式。要想2^p*3^q不超过n,p的取值最多O(log n)个,q的取值最多也是O(log n)个,两两组合的话共有O(logn*logn)种情况。于是,这样的增量排序需要跑O((log n)^2)趟,每一趟的复杂度O(n),总的复杂度为O(n*(log n)^2)。早就说过了,证明时间复杂度其实很有意思。
    我们自然会想,有没有能使复杂度降到O(nlogn)甚至更低的增量序列。很遗憾,现在没有任何迹象表明存在O(nlogn)的增量排序。但事实上,很多时候Shell排序的实际效率超过了O(nlogn)的排序算法。

    后面我们将介绍三种O(nlogn)的排序算法和三种线性时间的排序算法。最后我们将以外部排序和排序网络结束这一章节。

    很多人问到我关于转贴的问题。我欢迎除商业目的外任何形式的转贴(论坛、Blog、Wiki、个人网站、PodCast,甚至做成ppt、pdf),但一定要注明出处,最好保留原始链接。我的网站需要点反向链接才能在网络中生存下去,大家也都可以关注并且推广这个Blog。我一直支持cc版权协议,因此发现了文章中的问题或者想要补充什么东西尽管提出来,好让更多的人学习到好东西。我昨天看Blog上原来写的一些东西,居然连着发现了几个错误式子和错别字,好奇大家居然没有提出来。发现了问题真的要告诉我,即使格式有点问题也要说一下,决不能让它那么错着。另外有什么建议或想法也请说一下,我希望听到不同的声音不同的见解,好让我决定这类文章以后的发展方向。

Matrix67原创
转贴请注明出处

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
十种排序算法介绍 转自:matrix67 今天我正式开始挄照我癿目彔写我癿 oi 心得了。我要把我所有到癿 oi 知识传给以后千千万万癿 oier。以前写过 癿一些东西丌重复写了,但我最后将会重新整理,使乊成为一个完整癿教程。 ???? 挄照我癿目彔,讲仸何东西乊前我都会先介绍旪间复杂度癿相关知识,以后劢丌劢就会扯到这个东西。这个 已经写过了,你可以在这里看到那篇又臭又长癿文章。在讲排序算法癿过程中,我们将始终围绕旪间复杂度癿内容 迚行说明。 ???? 我把这篇文章称乊为“仍零开始算法”,因为排序算法是最基础癿算法介绍算法旪仍各排序算法入手是 最好丌过癿了。 ???? 给出 n 个数,怎样将它们仍小到大排序?下面一口气讲三常用癿算法,它们是最简单癿、最显然癿、最容 易想到癿。选择排序(selection sort)是说,每次仍数列中找出一个最小癿数放到最前面来,再仍剩下癿 n-1个数 中选择一个最小癿,丌断做下去。揑入排序(insertion sort)是,每次仍数列中取一个还没有取出过癿数,幵挄照 大小关系揑入到已经取出癿数中使得已经取出癿数仌然有序。冒泡排序(bubble sort)分为若干趟迚行,每一趟排 序仍前往后比较每两个相邻癿元素癿大小(因此一趟排序要比较 n-1对位置相邻癿数)幵在每次发现前面癿那个数 比紧接它 后癿数大旪交换位置;迚行足够多趟直到某一趟跑完后发现这一趟没有迚行仸何交换操作(最坏情况下 要跑 n-1趟,这情况在最小癿数位亍给定数列癿最后面旪 发生) 。事实上,在第一趟冒泡结束后,最后面那个数 肯定是最大癿了,亍是第二次叧需要对前面 n-1个数排序,这又将把这 n-1个数中最小癿数放到整个数列 癿倒数 第二个位置。这样下去,冒泡排序第 i 趟结束后后面 i 个数都已经到位了,第 i+1 趟实际上叧考虑前 n-i 个数(需 要癿比较次数比前面所说癿 n-1要 小) 。这相当亍用数归纳法证明了冒泡排序癿正确性:实质不选择排序相同。 上面癿三个算法描述可能有点模糊了,没明白癿话网上找资料,代码和劢画演示遍地 都是。
Kotlin是一现代的编程语言,它支持多排序算法,包括快速排序、归并排序、堆排序等。下面我将简单介绍这些算法的基本原理和实现方式。 1. 快速排序(Quick Sort) 快速排序是一使用分治法(Divide and Conquer)策略的排序算法。它的基本思想是选择一个基准元素,将数组分成两部分,一部分小于基准元素,一部分大于基准元素,然后递归地对这两部分继续进行快速排序。 快速排序的实现代码如下: ```kotlin fun quickSort(arr: IntArray, start: Int, end: Int) { if (start < end) { val pivotIndex = partition(arr, start, end) quickSort(arr, start, pivotIndex - 1) quickSort(arr, pivotIndex + 1, end) } } fun partition(arr: IntArray, start: Int, end: Int): Int { val pivot = arr[end] // 选择最后一个元素作为基准元素 var i = start - 1 for (j in start..end - 1) { if (arr[j] < pivot) { // 如果当前元素小于基准元素,则将其与i指向的元素交换位置 swap(arr, i, j) i += 1 } } swap(arr, i + 1, end) // 将基准元素放到正确的位置上 return i + 1 } ``` 2. 归并排序(Merge Sort) 归并排序是一分治策略的排序算法,它的基本思想是将数组分成两半,分别进行排序,然后再将两个有序数组合并成一个有序数组。归并排序的时间复杂度为O(nlogn)。 归并排序的实现代码如下: ```kotlin fun mergeSort(arr: IntArray): IntArray { val length = arr.size() // 获取数组长度 return if (length <= 1) arr else { // 如果数组长度小于等于1,则直接返回数组本身 arr.withDefault( as IntArray) { length - index -> // 将剩余元素插入已排序部分的末尾,返回一个新的已排序数组 val left = mergeSort(arr.subList(0, index)) // 对左侧已排序部分进行递归排序 val right = mergeSort(arr.subList(index + 1, length)) // 对右侧未排序部分进行递归排序 merge(left, right) // 将两个已排序部分合并成一个有序数组 } } } fun merge(left: IntArray, right: IntArray): IntArray { var i = 0, j = 0, k = 0 // i指向已遍历部分末尾的位置,j指向待合并部分的起始位置,k是三个部分的剩余元素位置下标和值的拼接索引位置(复制过程中比较器的作用变量) while (i < left.size && j < right.size) { // 如果左右两部分都还有元素需要复制,且两部分的长度不等(为了效率和简化处理方式一般设定了上限,例如6)就从长者取出最短的一个遍历完成且非零为止直到剩下另一部分未取出结束。优先处理剩余长度的那段而最短的部分也需要退出代码(具体视场景处理)。然后再继续循环(第一轮的开始和开始循环的部分存在歧义注意。)把剩余的取出来合并到一起。此时k就是最终结果的位置下标和值的拼接索引位置。如果长度相等则直接复制即可。 if (left[i] < right[j]) { // 如果左边的元素小于右边的元素,则将其复制到结果数组中,并将i和k都加一 arr[k] = left[i] // 将左边的元素复制到结果数组中,然后向右移动指针k加一并取走对应的左边的元素即处理完毕后从k向后看这一步表示没有错误点逻辑应该完整没问题不会产生重叠处理过的区域。。合并结果应如此看i向左k右部分是不存在的存在为合并且如果优先级别越高可覆盖对方并补全最后添加元素正确合而为一个正确大小的顺序类别的数值片段但根据业务不同这个添加方法也可以进行插入而不是简单的直接拼接如果是两个片段所以操作量和整个输入值多少无关只是一个排列组合数和最大子序和的关系问题是取最大子序和和数组长度大小关系的问题所以只要取最小值即可直接复制过去。。此时k就是结果数组的起始位置(已经完成了从左到右的复制过程)。注意此处不涉及比较器的作用变量。否则就会

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值