快速排序算法_我要自学生信之数据结构与算法:快速排序

引用自算法图解,作者[美] Aditya Bhargava 译袁国忠 特别备注:本书非原创,但部分内容自己会再进行解释,以便更容易理解,重点部分会加粗

目前数据结构与算法已更新文章:

我要自学生信之数据结构与算法:算法简介

我要自学生信之数据结构与算法:选择排序

我要自学生信之数据结构与算法:递归

章节思维导图,我尽量在以后每篇文章开始前加一个思维导图,加深大家宏观的理解。不至于陷于细节。

f0489da50de7125814188222f6665a7c.png

前一章深入介绍了递归,本章的重点是使用学到的新技能来解决问题。我们将探索分而治之 (divide and conquer,D&C)——一种著名的递归式问题解决方法。 本书将深入算法的核心。只能解决一种问题的算法毕竟用处有限,而D&C提供了解决问题的 思路,是另一个可供你使用的工具。面对新问题时,你不再束手无策,而是自问:“使用分而治 之能解决吗?” 在本章末尾,你将学习第一个重要的D&C算法——快速排序。快速排序是一种排序算法,速 度比第2章介绍的选择排序快得多,实属优雅代码的典范。

4.1 分而治之

D&C并不那么容易掌握,我将通过三个示例来介绍。首先, 介绍一个直观的示例;然后,介绍一个代码示例,它不那么好看, 但可能更容易理解;最后,详细介绍快速排序——一种使用D&C 的排序算法。 假设你是农场主,有一小块土地。

0aee2dd52f001a1c88aec5d59be4f08e.png
图片引自算法图解

你要将这块地均匀地分成方块,且分出的方块要尽可能大。显然,下面的分法都不符合要求。

8100c85d4445694233c2d4d522f91b2f.png
图片引自算法图解

图片引自算法图解

如何将一块地均匀地分成方块,并确保分出的方块是最大的呢?使用D&C策略!D&C算法 是递归的。使用D&C解决问题的过程包括两个步骤。

(1) 找出基线条件,这种条件必须尽可能简单。

(2) 不断将问题分解(或者说缩小规模),直到符合基线条件。

下面就来使用D&C找出前述问题的解决方案。可你能使用的最大方块有多大呢? 首先,找出基线条件。最容易处理的情况是,一条边的长度是另一条边的整数倍。

a56a9f00b36bd9372c6e6ad4121a2330.png
图片引自算法图解

如果一边长25 m,另一边长50 m,那么可使用的最大方块为 25 m×25 m。换言之,可以将 这块地分成两个这样的方块。

现在需要找出递归条件,这正是D&C的用武之地。根据D&C的定义,每次递归调用都必须 缩小问题的规模。如何缩小前述问题的规模呢?我们首先找出这块地可容纳的最大方块。

bb2cdf43d8be1cd5782d6fe70d4b7a1f.png
图片引自算法图解

你可以从这块地中划出两个640 m×640 m的方块,同时余下一小块地。现在是顿悟时刻:何 不对余下的那一小块地使用相同的算法呢?

fafd8f41f441cd75ca61e590c9237387.png
图片引自算法图解

最初要划分的土地尺寸为1680 m×640 m,而现在要划分的土地更小,为640 m×400 m。适 用于这小块地的最大方块,也是适用于整块地的最大方块。换言之,你将均匀划分1680 m×640 m 土地的问题,简化成了均匀划分640 m×400 m土地的问题!

4aecad0a1250eb9903e0e701df9bf450.png
图片引自算法图解

下面再次使用同样的算法。对于640 m × 400 m的土地,可从中划出的最 大方块为400 m × 400 m。

这将余下一块更小的土地,其尺寸为400 m × 240 m。

385362f3bd9b3ad15e4e09d70a070e7a.png
图片引自算法图解

8cf3209ea3ff644f242c079ab3263112.png
图片引自算法图解

你可从这块土地中划出最大的方块,余下一块更小的土地,其尺寸为240 m × 160 m。

9ab15b6114ea0d44f3eaad44d1b028c8.png
图片引自算法图解

接下来,从这块土地中划出最大的方块,余下一块更小的土地。

c620b6e40542195a47244faf00237587.png
图片引自算法图解

余下的这块土地满足基线条件,因为160是80的整数倍。将这块土地分成两个方块后,将不会余下任何土地!

1add0d8d42927fef679c057d45ce3781.png
图片引自算法图解

因此,对于最初的那片土地,适用的最大方块为80 m× 80 m。

c20bce6c6a420046c7bdbd1d1bc3c4cc.png
图片引自算法图解

这里重申一下D&C的工作原理:

(1) 找出简单的基线条件;

(2) 确定如何缩小问题的规模,使其符合基线条件。

D&C并非可用于解决问题的算法,而是一种解决问题的思路。我们再来看一个例子。 给定一个数字数组。

818180e2477ff632416d870a81b687ce.png
图片引自算法图解

你需要将这些数字相加,并返回结果。使用循环很容易完成这种任务。

def sum(arr):
    total=0
    for x in arr:
        total +=x
    return[total]
print(sum([1,2,3,4]))

但如何使用递归函数来完成这种任务呢?

第一步:找出基线条件。最简单的数组什么样呢?请想想这个问题,再接着往下读。如果数 组不包含任何元素或只包含一个元素,计算总和将非常容易。

bf5db631bd3f313205e174ed895b6543.png
图片引自算法图解

因此这就是基线条件。

第二步:每次递归调用都必须离空数组更近一步。如何缩小问题的规模呢?下面是一种办法。

09d88e25c6bbf9d3f36d73c5c66a4ce8.png
图片引自算法图解

这与下面的版本等效。

2cf0902d1eb92684c2917d0e8a682248.png
图片引自算法图解

这两个版本的结果都为12,但在第二个版本中,给函数sum传递的数组更短。换言之,这缩 小了问题的规模!

cd0fdfe7b7b83186413b50ee717bb170.png
图片引自算法图解

这个函数的运行过程如下。

d7bf50fb1632316bc2235144a24ccd05.png
图片引自算法图解

别忘了,递归记录了状态。

ac23454b0e37ff631aeb0414c3ad6b5f.png
图片引自算法图解

a2c809686beed0ddadc11678ce3e4d3c.png
图片引自算法图解

01b703e7905d0c194f85287f8dafaa83.png
图片引自算法图解

4.2 快速排序

快速排序是一种常用的排序算法,比选择排序快得多。例如,C语言标准库中的函数qsort 实现的就是快速排序。快速排序也使用了D&C。

下面来使用快速排序对数组进行排序。对排序算法来说,最简单的数组什么样呢?还记得前一节的“提示”吗?就是根本不需要排序的数组。

c4cd8e26daa534901d5d228904e63e39.png
图片引自算法图解

因此,基线条件为数组为空或只包含一个元素。在这种情况下,只需原样返回数组——根本就不用排序。

def quicksort(array):
    if len(array)<2:
        return[array]

我们来看看更长的数组。对包含两个元素的数组进行排序也很容易。

43069aa9aeffc94ecf9fb6b857647ee9.png
图片引自算法图解

别忘了,你要使用D&C,因此需要将数组分解,直到满足基线条件。下面介绍快速排序的工 作原理。首先,从数组中选择一个元素,这个元素被称为基准值(pivot)。

稍后再介绍如何选择合适的基准值。我们暂时将数组的第一个元素用作基准值。

接下来,找出比基准值小的元素以及比基准值大的元素。

45c9ce2be162edec6ec3b6312996b950.png
图片引自算法图解

图片引自算法图解

这被称为分区(partitioning)。现在你有:

  • 一个由所有小于基准值的数字组成的子数组;
  • 基准值;
  • 一个由所有大于基准值的数组组成的子数组。

这里只是进行了分区,得到的两个子数组是无序的。但如果这两个数组是有序的,对整个数 组进行排序将非常容易。

954483301ebebcf4c832e9789ab1da4a.png
图片引自算法图解

如果子数组是有序的,就可以像下面这样合并得到一个有序的数组:左边的数组 + 基准值 + 右边的数组。在这里,就是[10, 15] + [33] + [],结果为有序数组[10, 15, 33]。

如何对子数组进行排序呢?对于包含两个元素的数组(左边的子数组)以及空数组(右边的 子数组),快速排序知道如何将它们排序,因此只要对这两个子数组进行快速排序,再合并结果, 就能得到一个有序数组!

86ac59a11f7f000dda5ccffd048acc69.png

不管将哪个元素用作基准值,这都管用。假设你将15用作基准值。

5bf00877fd3f038d3b3a461d01642e4b.png
图片引自算法图解

这个子数组都只有一个元素,而你知道如何对这些数组进行排序。现在你就知道如何对包含 三个元素的数组进行排序了,步骤如下。

(1) 选择基准值。

(2) 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。

(3) 对这两个子数组进行快速排序。 包含四个元素的数组呢?

f65a0948b11a0aa00b813702b7c6f1a3.png
图片引自算法图解

左边的子数组包含三个元素,而你知道如何对包含三个元素的数组进行排序:对其递归地调 用快速排序。

3aaa9886c2eca60610712a2177e93f79.png
图片引自算法图解

因此你能够对包含四个元素的数组进行排序。如果能够对包含四个元素的数组进行排序,就 能对包含五个元素的数组进行排序。为什么呢?假设有下面这样一个包含五个元素的数组。

3c0f8e01e88d15dca3505bb0c81d7e6e.png
图片引自算法图解

根据选择的基准值,对这个数组进行分区的各种可能方式如下。

2e43a7fd58882a5677a730687ce8bbb9.png
图片引自算法图解

注意,这些子数组包含的元素数都在0~4内,而你已经知道如何使用快速排序对包含0~4 个元素的数组进行排序!因此,不管如何选择基准值,你都可对划分得到的两个子数组递归地进 行快速排序。

例如,假设你将3用作基准值,可对得到的子数组进行快速排序。

4b8c976e189e9d96e416f6f1e64ab55d.png
图片引自算法图解

将子数组排序后,将它们合并,得到一个有序数组。即便你将5用作基准值,这也可行。

a8bb054d6e54528a751ee81894b59bb6.png
图片引自算法图解

将任何元素用作基准值都可行,因此你能够对包含五个元素的数组进行排序。同理,你能够 对包含六个元素的数组进行排序,以此类推。

8716a23430ef054c53b24b2e94062ebe.png
图片引自算法图解

下面是快速排序的代码。

def quicksort(array): 
    if len(array) < 2: 
        return [array]
    else: 
        pivot = array[0] 
        less = [i for i in array[1:] if i <= pivot] 
        greater = [i for i in array[1:] if i > pivot] 
        return [quicksort(less) + [pivot] + quicksort(greater)]
print (quicksort([10, 5, 2, 3]))

4.3 再谈大O表示法

快速排序的独特之处在于,其速度取决于选择的基准值。在讨论快速排序的运行时间前,我 们再来看看最常见的大O运行时间。

a7cd7b5606606943e8e57187e53d3380.png
图片引自算法图解

上述图表中的时间是基于每秒执行10次操作计算得到的。这些数据并不准确,这里提供它们 只是想让你对这些运行时间的差别有大致认识。实际上,计算机每秒执行的操作远不止10次。

对于每种运行时间,本书还列出了相关的算法。来看看第2章介绍的选择排序,其运行时间 为O(n2),速度非常慢。

还有一种名为合并排序(merge sort)的排序算法,其运行时间为O(n log n),比选择排序快 得多!快速排序的情况比较棘手,在最糟情况下,其运行时间为O(n2)。

与选择排序一样慢!但这是最糟情况。在平均情况下,快速排序的运行时间为O(n log n)。你 可能会有如下疑问。

  • 这里说的最糟情况和平均情况是什么意思呢?
  • 若快速排序在平均情况下的运行时间为O(n log n),而合并排序的运行时间总是O(n log n), 为何不使用合并排序?它不是更快吗?

4.3.1 比较合并排序和快速排序

假设有下面这样打印列表中每个元素的简单函数。

def print_items(list):
    for item in list: 
        print (item)    

这个函数遍历列表中的每个元素并将其打印出来。它迭代整个列表一次,因此运行时间为 O(n)。现在假设你对这个函数进行修改,使其在打印每个元素前都休眠1秒钟。

from time import sleep
def print_items2(list):
    for item in list: 
        sleep(1) 
        print(item)     

它在打印每个元素前都暂停1秒钟。假设你使用这两个函数来打印一个包含5个元素的列表。

cf0fe735d282605e0fc1e2c2a6ddbe04.png
图片引自算法图解

这两个函数都迭代整个列表一次,因此它们的运行时间都为O(n)。你 认为哪个函数的速度更快呢?我认为print_items要快得多,因为它没有 在每次打印元素前都暂停1秒钟。因此,虽然使用大O表示法表示时,这两 个函数的速度相同,但实际上print_items的速度更快。在大O表示法O(n) 中,n实际上指的是这样的。

c是算法所需的固定时间量,被称为常量。例如,print_ items所需的时间可能是10毫秒 * n,而print_items2所需的时间为1秒 * n。

通常不考虑这个常量,因为如果两种算法的大O运行时间不同,这种常量将无关紧要。就拿二分查找和简单查找来举例说明。假设这两种算法的运行时间包含如下常量。

81ff371406c3c44c33245c6b88a9b04c.png
图片引自算法图解

你可能认为,简单查找的常量为10毫秒,而二分查找的常量为1秒,因此简单查找的速度要快得多。现在假设你要在包含40亿个元素的列表中查找,所需时间将如下。

7d18ec1a4d624b58d03c53b3da104026.png
图片引自算法图解

正如你看到的,二分查找的速度还是快得多,常量根本没有什么影响。

但有时候,常量的影响可能很大,对快速查找和合并查找来说就是如此。快速查找的常量比 合并查找小,因此如果它们的运行时间都为O(n log n),快速查找的速度将更快。实际上,快速查 找的速度确实更快,因为相对于遇上最糟情况,它遇上平均情况的可能性要大得多。 此时你可能会问,何为平均情况,何为最糟情况呢?

4.3.2 平均情况和最糟情况

快速排序的性能高度依赖于你选择的基准值。假设你总是将第一个元素用作基准值,且要处 理的数组是有序的。由于快速排序算法不检查输入数组是否有序,因此它依然尝试对其进行排序。

0e607b13fd1e06806fc6067348383809.png
图片引自算法图解

注意,数组并没有被分成两半,相反,其中一个子数组始终为空,这导致调用栈非常长。现 在假设你总是将中间的元素用作基准值,在这种情况下,调用栈如下。

0b5f4201309c4edd2aed3fd5a04ab8d2.png
图片引自算法图解

调用栈短得多!因为你每次都将数组分成两半,所以不需要那么多递归调用。你很快就到达 了基线条件,因此调用栈短得多。

第一个示例展示的是最糟情况,而第二个示例展示的是最佳情况。在最糟情况下,栈长为 O(n),而在最佳情况下,栈长为O(log n)。

现在来看看栈的第一层。你将一个元素用作基准值,并将其他的元素划分到两个子数组中。 这涉及数组中的全部8个元素,因此该操作的时间为O(n)。在调用栈的第一层,涉及全部8个元素, 但实际上,在调用栈的每层都涉及O(n)个元素。

ad6383f89daae2f2b44fc27934ea5797.png
图片引自算法图解

即便以不同的方式划分数组,每次也将涉及O(n)个元素。

1b19272a9f21c629b84f6ffb95d90a32.png
图片引自算法图解

因此,完成每层所需的时间都为O(n)。

f67e2bbf6d46ed9ee28372372db665d0.png
图片引自算法图解

在这个示例中,层数为O(log n)(用技术术语说,调用栈的高度为O(log n)),而每层需要的 时间为O(n)。因此整个算法需要的时间为O(n) * O(log n) = O(n log n)。这就是最佳情况。

在最糟情况下,有O(n)层,因此该算法的运行时间为O(n) * O(n) = O(n2)。

知道吗?这里要告诉你的是,最佳情况也是平均情况。只要你每次都随机地选择一个数组元 素作为基准值,快速排序的平均运行时间就将为O(n log n)。快速排序是最快的排序算法之一,也是D&C典范。

4.4 小结

  • D&C将问题逐步分解。使用D&C处理列表时,基线条件很可能是空数组或只包含一个元 素的数组。
  • 实现快速排序时,请随机地选择用作基准值的元素。快速排序的平均运行时间为O(n log n)。
  • 大O表示法中的常量有时候事关重大,这就是快速排序比合并排序快的原因所在。
  • 比较简单查找和二分查找时,常量几乎无关紧要,因为列表很长时,O(log n)的速度比O(n) 快得多。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值