引用自算法图解,作者[美] Aditya Bhargava 译袁国忠 特别备注:本书非原创,但部分内容自己会再进行解释,以便更容易理解,重点部分会加粗
目前数据结构与算法已更新文章:
我要自学生信之数据结构与算法:算法简介
我要自学生信之数据结构与算法:选择排序
我要自学生信之数据结构与算法:递归
章节思维导图,我尽量在以后每篇文章开始前加一个思维导图,加深大家宏观的理解。不至于陷于细节。
![f0489da50de7125814188222f6665a7c.png](https://i-blog.csdnimg.cn/blog_migrate/b4021d93bb3e0b28e3e7c4cdb3384423.jpeg)
前一章深入介绍了递归,本章的重点是使用学到的新技能来解决问题。我们将探索分而治之 (divide and conquer,D&C)——一种著名的递归式问题解决方法。 本书将深入算法的核心。只能解决一种问题的算法毕竟用处有限,而D&C提供了解决问题的 思路,是另一个可供你使用的工具。面对新问题时,你不再束手无策,而是自问:“使用分而治 之能解决吗?” 在本章末尾,你将学习第一个重要的D&C算法——快速排序。快速排序是一种排序算法,速 度比第2章介绍的选择排序快得多,实属优雅代码的典范。
4.1 分而治之
D&C并不那么容易掌握,我将通过三个示例来介绍。首先, 介绍一个直观的示例;然后,介绍一个代码示例,它不那么好看, 但可能更容易理解;最后,详细介绍快速排序——一种使用D&C 的排序算法。 假设你是农场主,有一小块土地。
![0aee2dd52f001a1c88aec5d59be4f08e.png](https://i-blog.csdnimg.cn/blog_migrate/9c22b60fa0ccd99fbb5410ccbc6391cf.jpeg)
你要将这块地均匀地分成方块,且分出的方块要尽可能大。显然,下面的分法都不符合要求。
![8100c85d4445694233c2d4d522f91b2f.png](https://i-blog.csdnimg.cn/blog_migrate/614b70672a14183753ac481a68cf772e.png)
图片引自算法图解
如何将一块地均匀地分成方块,并确保分出的方块是最大的呢?使用D&C策略!D&C算法 是递归的。使用D&C解决问题的过程包括两个步骤。
(1) 找出基线条件,这种条件必须尽可能简单。
(2) 不断将问题分解(或者说缩小规模),直到符合基线条件。
下面就来使用D&C找出前述问题的解决方案。可你能使用的最大方块有多大呢? 首先,找出基线条件。最容易处理的情况是,一条边的长度是另一条边的整数倍。
![a56a9f00b36bd9372c6e6ad4121a2330.png](https://i-blog.csdnimg.cn/blog_migrate/f590f08665eb3c7b673c925c1076692b.png)
如果一边长25 m,另一边长50 m,那么可使用的最大方块为 25 m×25 m。换言之,可以将 这块地分成两个这样的方块。
现在需要找出递归条件,这正是D&C的用武之地。根据D&C的定义,每次递归调用都必须 缩小问题的规模。如何缩小前述问题的规模呢?我们首先找出这块地可容纳的最大方块。
![bb2cdf43d8be1cd5782d6fe70d4b7a1f.png](https://i-blog.csdnimg.cn/blog_migrate/c66f4d0f9cbc3c5b37e1cb287155bac0.png)
你可以从这块地中划出两个640 m×640 m的方块,同时余下一小块地。现在是顿悟时刻:何 不对余下的那一小块地使用相同的算法呢?
![fafd8f41f441cd75ca61e590c9237387.png](https://i-blog.csdnimg.cn/blog_migrate/ab6d4434b0c315f2a6b4c3953075a39d.jpeg)
最初要划分的土地尺寸为1680 m×640 m,而现在要划分的土地更小,为640 m×400 m。适 用于这小块地的最大方块,也是适用于整块地的最大方块。换言之,你将均匀划分1680 m×640 m 土地的问题,简化成了均匀划分640 m×400 m土地的问题!
![4aecad0a1250eb9903e0e701df9bf450.png](https://i-blog.csdnimg.cn/blog_migrate/4f98e8af00db76169e414985b124dd78.jpeg)
下面再次使用同样的算法。对于640 m × 400 m的土地,可从中划出的最 大方块为400 m × 400 m。
这将余下一块更小的土地,其尺寸为400 m × 240 m。
![385362f3bd9b3ad15e4e09d70a070e7a.png](https://i-blog.csdnimg.cn/blog_migrate/39010d98f5ee00220ed0079f6ca8c643.png)
![8cf3209ea3ff644f242c079ab3263112.png](https://i-blog.csdnimg.cn/blog_migrate/5bea1a668cc6e2f4163cc94c3d0865c8.png)
你可从这块土地中划出最大的方块,余下一块更小的土地,其尺寸为240 m × 160 m。
![9ab15b6114ea0d44f3eaad44d1b028c8.png](https://i-blog.csdnimg.cn/blog_migrate/32984da76641e0573f1f5943422cf986.png)
接下来,从这块土地中划出最大的方块,余下一块更小的土地。
![c620b6e40542195a47244faf00237587.png](https://i-blog.csdnimg.cn/blog_migrate/51a29f9bb6180620e2b063cfa040da73.png)
余下的这块土地满足基线条件,因为160是80的整数倍。将这块土地分成两个方块后,将不会余下任何土地!
![1add0d8d42927fef679c057d45ce3781.png](https://i-blog.csdnimg.cn/blog_migrate/6dd18b9e4c10c3dea67baa1893572f59.png)
因此,对于最初的那片土地,适用的最大方块为80 m× 80 m。
![c20bce6c6a420046c7bdbd1d1bc3c4cc.png](https://i-blog.csdnimg.cn/blog_migrate/4cec3d73e961b839483b1e0a16c2f6a8.png)
这里重申一下D&C的工作原理:
(1) 找出简单的基线条件;
(2) 确定如何缩小问题的规模,使其符合基线条件。
D&C并非可用于解决问题的算法,而是一种解决问题的思路。我们再来看一个例子。 给定一个数字数组。
![818180e2477ff632416d870a81b687ce.png](https://i-blog.csdnimg.cn/blog_migrate/ca27c3c6cbf5e1a595129f537f52d753.png)
你需要将这些数字相加,并返回结果。使用循环很容易完成这种任务。
def sum(arr):
total=0
for x in arr:
total +=x
return[total]
print(sum([1,2,3,4]))
但如何使用递归函数来完成这种任务呢?
第一步:找出基线条件。最简单的数组什么样呢?请想想这个问题,再接着往下读。如果数 组不包含任何元素或只包含一个元素,计算总和将非常容易。
![bf5db631bd3f313205e174ed895b6543.png](https://i-blog.csdnimg.cn/blog_migrate/60a7d3d62d775728a4e4bb0af358cb82.png)
因此这就是基线条件。
第二步:每次递归调用都必须离空数组更近一步。如何缩小问题的规模呢?下面是一种办法。
![09d88e25c6bbf9d3f36d73c5c66a4ce8.png](https://i-blog.csdnimg.cn/blog_migrate/5adb47f3ceb53de11f2c04547f847c3a.png)
这与下面的版本等效。
![2cf0902d1eb92684c2917d0e8a682248.png](https://i-blog.csdnimg.cn/blog_migrate/d346b3f53f49d19afd04f4bca39841e1.png)
这两个版本的结果都为12,但在第二个版本中,给函数sum传递的数组更短。换言之,这缩 小了问题的规模!
![cd0fdfe7b7b83186413b50ee717bb170.png](https://i-blog.csdnimg.cn/blog_migrate/d051f67414aa3aa5a586ab20fc271292.jpeg)
这个函数的运行过程如下。
![d7bf50fb1632316bc2235144a24ccd05.png](https://i-blog.csdnimg.cn/blog_migrate/9ae3cee832d7d8c0f11c01fe76a3448f.png)
别忘了,递归记录了状态。
![ac23454b0e37ff631aeb0414c3ad6b5f.png](https://i-blog.csdnimg.cn/blog_migrate/4a62edd1593cd52d444a7931fc31352e.jpeg)
![a2c809686beed0ddadc11678ce3e4d3c.png](https://i-blog.csdnimg.cn/blog_migrate/4d97050747701c1c637c71bbe5f05219.png)
![01b703e7905d0c194f85287f8dafaa83.png](https://i-blog.csdnimg.cn/blog_migrate/b996180f710064bac4364e5486e3e974.jpeg)
4.2 快速排序
快速排序是一种常用的排序算法,比选择排序快得多。例如,C语言标准库中的函数qsort 实现的就是快速排序。快速排序也使用了D&C。
下面来使用快速排序对数组进行排序。对排序算法来说,最简单的数组什么样呢?还记得前一节的“提示”吗?就是根本不需要排序的数组。
![c4cd8e26daa534901d5d228904e63e39.png](https://i-blog.csdnimg.cn/blog_migrate/29fd5d409f46bca1790641e5d44a8bc8.png)
因此,基线条件为数组为空或只包含一个元素。在这种情况下,只需原样返回数组——根本就不用排序。
def quicksort(array):
if len(array)<2:
return[array]
我们来看看更长的数组。对包含两个元素的数组进行排序也很容易。
![43069aa9aeffc94ecf9fb6b857647ee9.png](https://i-blog.csdnimg.cn/blog_migrate/463b403a418afbeb9fde8a7607b4694a.png)
别忘了,你要使用D&C,因此需要将数组分解,直到满足基线条件。下面介绍快速排序的工 作原理。首先,从数组中选择一个元素,这个元素被称为基准值(pivot)。
稍后再介绍如何选择合适的基准值。我们暂时将数组的第一个元素用作基准值。
接下来,找出比基准值小的元素以及比基准值大的元素。
![45c9ce2be162edec6ec3b6312996b950.png](https://i-blog.csdnimg.cn/blog_migrate/6cc9dc900405007265a48df996824d03.png)
图片引自算法图解
这被称为分区(partitioning)。现在你有:
- 一个由所有小于基准值的数字组成的子数组;
- 基准值;
- 一个由所有大于基准值的数组组成的子数组。
这里只是进行了分区,得到的两个子数组是无序的。但如果这两个数组是有序的,对整个数 组进行排序将非常容易。
![954483301ebebcf4c832e9789ab1da4a.png](https://i-blog.csdnimg.cn/blog_migrate/a450e92b290f0386d28b3c2ef19671ae.png)
如果子数组是有序的,就可以像下面这样合并得到一个有序的数组:左边的数组 + 基准值 + 右边的数组。在这里,就是[10, 15] + [33] + [],结果为有序数组[10, 15, 33]。
如何对子数组进行排序呢?对于包含两个元素的数组(左边的子数组)以及空数组(右边的 子数组),快速排序知道如何将它们排序,因此只要对这两个子数组进行快速排序,再合并结果, 就能得到一个有序数组!
![86ac59a11f7f000dda5ccffd048acc69.png](https://i-blog.csdnimg.cn/blog_migrate/15c841af17d09a58b9046724fce5c625.png)
不管将哪个元素用作基准值,这都管用。假设你将15用作基准值。
![5bf00877fd3f038d3b3a461d01642e4b.png](https://i-blog.csdnimg.cn/blog_migrate/a8226db288400383cf4227a45c624af3.png)
这个子数组都只有一个元素,而你知道如何对这些数组进行排序。现在你就知道如何对包含 三个元素的数组进行排序了,步骤如下。
(1) 选择基准值。
(2) 将数组分成两个子数组:小于基准值的元素和大于基准值的元素。
(3) 对这两个子数组进行快速排序。 包含四个元素的数组呢?
![f65a0948b11a0aa00b813702b7c6f1a3.png](https://i-blog.csdnimg.cn/blog_migrate/ccefcdc2a35f4de292a99ef1c18ac3fc.png)
左边的子数组包含三个元素,而你知道如何对包含三个元素的数组进行排序:对其递归地调 用快速排序。
![3aaa9886c2eca60610712a2177e93f79.png](https://i-blog.csdnimg.cn/blog_migrate/f0bab2cf1ccaeebaf69e2e29a654d801.png)
因此你能够对包含四个元素的数组进行排序。如果能够对包含四个元素的数组进行排序,就 能对包含五个元素的数组进行排序。为什么呢?假设有下面这样一个包含五个元素的数组。
![3c0f8e01e88d15dca3505bb0c81d7e6e.png](https://i-blog.csdnimg.cn/blog_migrate/cb01ee838ef23c9ff61e51c2aa25b4b4.png)
根据选择的基准值,对这个数组进行分区的各种可能方式如下。
![2e43a7fd58882a5677a730687ce8bbb9.png](https://i-blog.csdnimg.cn/blog_migrate/ce5886fbc698260842e6fe20e65b925c.png)
注意,这些子数组包含的元素数都在0~4内,而你已经知道如何使用快速排序对包含0~4 个元素的数组进行排序!因此,不管如何选择基准值,你都可对划分得到的两个子数组递归地进 行快速排序。
例如,假设你将3用作基准值,可对得到的子数组进行快速排序。
![4b8c976e189e9d96e416f6f1e64ab55d.png](https://i-blog.csdnimg.cn/blog_migrate/98bbe42df5edeeb752a1c9eb45c256af.png)
将子数组排序后,将它们合并,得到一个有序数组。即便你将5用作基准值,这也可行。
![a8bb054d6e54528a751ee81894b59bb6.png](https://i-blog.csdnimg.cn/blog_migrate/0916809e0cad34197bdc3ba6479b8511.png)
将任何元素用作基准值都可行,因此你能够对包含五个元素的数组进行排序。同理,你能够 对包含六个元素的数组进行排序,以此类推。
![8716a23430ef054c53b24b2e94062ebe.png](https://i-blog.csdnimg.cn/blog_migrate/c6a8d163476a8d2222950cee2444c9e5.jpeg)
下面是快速排序的代码。
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](https://i-blog.csdnimg.cn/blog_migrate/55516d439ac4679ed5d63f52462c53b1.jpeg)
上述图表中的时间是基于每秒执行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](https://i-blog.csdnimg.cn/blog_migrate/d5b37e092280b8e1caf950654cd7f770.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](https://i-blog.csdnimg.cn/blog_migrate/409639480fd9e597ef3d4810d12511f8.png)
你可能认为,简单查找的常量为10毫秒,而二分查找的常量为1秒,因此简单查找的速度要快得多。现在假设你要在包含40亿个元素的列表中查找,所需时间将如下。
![7d18ec1a4d624b58d03c53b3da104026.png](https://i-blog.csdnimg.cn/blog_migrate/0f73dff6d64b81a6b99149917c73443b.png)
正如你看到的,二分查找的速度还是快得多,常量根本没有什么影响。
但有时候,常量的影响可能很大,对快速查找和合并查找来说就是如此。快速查找的常量比 合并查找小,因此如果它们的运行时间都为O(n log n),快速查找的速度将更快。实际上,快速查 找的速度确实更快,因为相对于遇上最糟情况,它遇上平均情况的可能性要大得多。 此时你可能会问,何为平均情况,何为最糟情况呢?
4.3.2 平均情况和最糟情况
快速排序的性能高度依赖于你选择的基准值。假设你总是将第一个元素用作基准值,且要处 理的数组是有序的。由于快速排序算法不检查输入数组是否有序,因此它依然尝试对其进行排序。
![0e607b13fd1e06806fc6067348383809.png](https://i-blog.csdnimg.cn/blog_migrate/13f1e6018c7e2cfcf2a596483ef04830.jpeg)
注意,数组并没有被分成两半,相反,其中一个子数组始终为空,这导致调用栈非常长。现 在假设你总是将中间的元素用作基准值,在这种情况下,调用栈如下。
![0b5f4201309c4edd2aed3fd5a04ab8d2.png](https://i-blog.csdnimg.cn/blog_migrate/a7db159db19146068341080943080465.png)
调用栈短得多!因为你每次都将数组分成两半,所以不需要那么多递归调用。你很快就到达 了基线条件,因此调用栈短得多。
第一个示例展示的是最糟情况,而第二个示例展示的是最佳情况。在最糟情况下,栈长为 O(n),而在最佳情况下,栈长为O(log n)。
现在来看看栈的第一层。你将一个元素用作基准值,并将其他的元素划分到两个子数组中。 这涉及数组中的全部8个元素,因此该操作的时间为O(n)。在调用栈的第一层,涉及全部8个元素, 但实际上,在调用栈的每层都涉及O(n)个元素。
![ad6383f89daae2f2b44fc27934ea5797.png](https://i-blog.csdnimg.cn/blog_migrate/2a080bed6ccb57309664158951d35178.jpeg)
即便以不同的方式划分数组,每次也将涉及O(n)个元素。
![1b19272a9f21c629b84f6ffb95d90a32.png](https://i-blog.csdnimg.cn/blog_migrate/cea810930fe1926764d4b8e235f8d497.jpeg)
因此,完成每层所需的时间都为O(n)。
![f67e2bbf6d46ed9ee28372372db665d0.png](https://i-blog.csdnimg.cn/blog_migrate/b53e6d0bc3ec8dd0ffee9ef65d31d21f.jpeg)
在这个示例中,层数为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) 快得多。