grokking algorithms Quicksort 第四章 快速排序法 中文翻译

第四章 快速排序

····················································
在本章中
你会学到分治法,有时候你可能会遇到用任何算法都无法解决的问题,当一个好的算法工程师遇到此类问题时,他不会放弃。他有一个万能工具箱,来解决这个问题。分治法是第一个被尝试的方法;
你会学到快速排序,这是个在实践中经常被使用的优雅算法,快速排序会用到分治法。
····················································
在上一章中你学到了递归。这一章我们新技能来解决问题。我们用分治法(D&C),这是一个众所周知的递归工具。
这一章我们真正进入算法世界了,毕竟,若只是解决某一个类型的问题,算法并不是很好用,相反,分治法会提供一个新的解决问题的思路给你。
分治法是你的工具箱中的另一个工具,当你遇到一个新问题时,你不会被它绊倒,相反,你可以问,“我用分治法可以解决它吗?”
在本章的最后,你会学到分治法的最具代表性的算法:快速排序(简称快排法),快排是一个排序算法,它比选择排序(第二章中学到的)要快很多。快排的代码实现也很优美。

分治法

这里写图片描述
图4-1
分治法我们会花时间来讲述,现在我们先看一个例子。首先,我们有个直观的例子,我会用一个代码的例子,可能不是很美观,但是非常简单。最后,我们学习快排法,一个使用分治法的算法。

假设你是一个有大块土地的农场主。你的土地长1680米,宽640米。

这里写图片描述
图4-2

你想将土地平均的分成小的正方形块,下面的这三幅图都不满足要求

这里写图片描述
图4-3
你怎么得到这块土地上的最大的正方形块呢?利用分治策略。分治算法属于递归算法,要用分治法解决这个问题,分两步:
1.找基础场景。这必须是最简单的。
2.分解或者减小你的问题直到它变成基础场景。
让我们利用分治法来解决问题,你能使用的最大正方形尺寸是多少?
首先,找基础场景,最简单的基础场景是长是宽的整数倍。

这里写图片描述
图4-4
假设宽为25米,长是50米,那么最大的正方形是25 X 25米,你需要这样分割土地。
现在你需要找到递归场景。这是分治法的基础。对于分治算法,若是每个递归问题解决了,那么大的问题也就解决了。但是怎样减小问题呢?哦们先找可以使用的最大正方形。

这里写图片描述
图4-5
我们可以得到2个640 X 640的正方形。然后剩下的需要再分割。现在到了“啊哈”的时刻了,还剩一块土地需要分割!为沈么对这一小块不试试相同的算法呢?
这里写图片描述
图4-6
你开始时需要分割1680 X 640的土地,但是现在你需要分割640 X 400的土地,若是你找到了这块土地的最大正方形,那么它可能就是整个土地的最大正方形,你现在将大的问题变成小的问题了。(1680 X 640 —-> 640 X 400)

欧几里得算法
“若是你找到了这个尺寸的最大正方形,那么你就找到了整个农场的最大正方形”。你可能不太明白这句话,没有关系,这不是很明显,不幸的是,这句话解释有些长,不适合放在本书中,所以你把它作为一个定理记住吧,若是你想深入理解,可汗学院有对这个算法的很好的解释。https:www.khanacademy.org/computing/computer-science/cryptography/modarithmetic/a/the-educlidean-algorithm

这里写图片描述
图4-7
让我们继续使用这个算法,从640 X 400米开始,最大的正方形是400 X 400米。
现在,剩下更小的部分,400 X 240米。

这里写图片描述
图4-8
然后,我们可以得到更小的部分。240 X 160米

这里写图片描述
图4-9
然后,得到更小的部分,160 X 80米
这里写图片描述
图4-10
现在,我们得到基础场景了。160是80的2倍(80是160的因子)。若是对它分割成正方形,就没有剩余的小块了。

这里写图片描述
图4-11
现在,对于原来的土地来说,我们得到的最大的正方形是80 X 80米

这里写图片描述
图4-12
我们来回顾下分治法是怎么奏效的:
1.找到基础场景
2.分割问题直到它变成基础场景。
分治法不是非常简单的解决问题的方法。相反,它是思考问题的一种方式,我们来看另一个例子。
这里写图片描述
图4-13
你有一个数组,里面存储了数字。
你需要返回他们的和,用循环来实现

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

但是,若是用递归功能怎么实现呢?
步骤1:找到基础场景。最简单的数组是什么?想想最简单的场景,然后继续阅读。若是数组中只有0个或1个元素,那么求和会非常简单。

这里写图片描述
图4-14
所以这是基础场景

步骤2:每次递归,我们需要将它向基础场景靠拢。你怎样才能减小问题的规模呢?这是方法。
这里写图片描述
图4-15
这个和下面的是相同的。

这里写图片描述
图4-16
另一个场景的结果也是12.但是第二个版本,你用了更小的数组来求和。这就是说,你将问题的规模变小了。
你的求和功能是这样的:

这里写图片描述
图4-17
这是实际实现。
这里写图片描述
图4-18
记住,递归记录状态
这里写图片描述
图4-19

小提示
当你用递归解决问题时,基础场景经常是空数组或含有一个元素的数组,若你不相信,可以试一下。

功能代码中的潜峰
“为什么我用循环就可以实现的事情要用递归呢?”你可能会想,这在编程语言中是个备用武器(潜峰),因为有些编程语言如Hashell没有循环。所以,类似的功能必须用递归来实现。如果你递归掌握的很好的话,那么学这种语言就会很快,例如,用Hashell写一个求和函数。

sum [] = 0
sum [X:xs] = x + (sum xs)

注意到了吗,你在这个函数中有两个定义,一个是基准场景,第二个是递归场景。你也可以使用if语句来实现此功能。

sum arr = if arr == []
            then 0
      else (head arr) + (sum (tail arr))

但是第一种方式很容易理解,递归中有很多类似的地方可以帮助我们理解。若是你对递归感兴趣或者喜欢学习新语言,可以尝试一下Haskell。

练习
4.1 用代码实现求和功能
4.2 用递归实现求列表长度的功能。
4.3 找到列表中最大的项。
4.4 还记得第一章中的二分查找吗?它是一个分治算法,你能找到二分查找的基准场景和递归场景吗?
这里写图片描述
图4-20

快速排序(简称为快排)
快排法是一个排序算法,它比选择排序快而且在实际场景中使用很广泛。比如,C语言的基础库中有一个方法qsort,它的内部实现就是快速排序,快排用到了分治法。

这里写图片描述
图4-21
让我们用快排法来对数组进行排序。什么样的数组对排序算法来说最简单呢?(还记得我们上一部分的提示吗?),对了,有的数组是不需要排序的。

这里写图片描述
图4-22
空数组和只有一项的数组是基准场景,我们可以原样返回它—因为这没有什么好排的:

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

我们来看大一点的数组,有两项的数组,排序也非常简单。
这里写图片描述
图4-23
若是数组中含有三项呢?
这里写图片描述
图4-24
记住,使用分治法,我们可以将这个数组一直分解直到找到基准场景。这正是快排法是如何工作的。首先,从数组中拿出一项,这被称为支点。我们稍后会讲怎样选择一个好的支点。现在我们把第一项作为一个支点。
这里写图片描述
图4-25
现在找到比支点小和比支点大的项。

这里写图片描述
图4-26
这被叫做划分。现在我们有:
一个由比支点小的项组成的子数组
支点
一个由比支点大的项组成的字数组
这两个子数组是没有排序的。它们刚被划分完成。但是若是我们假定它们是排序好的,那么这个数组排序就会很简单。

这里写图片描述
图4-27
若子数组是排序好的,那么就可以将左边的数组+支点+右边的数组拼起来,这样就得到了排序好的数组了。在这个例子中,[10,15] + [33] + [ ] = [10, 15, 33].这是个排序好的数组。
怎样来排序子数组呢?我们在快排法的基础场景中已经知道如何对含有两项的数组进行排序了,(左边子数组)和空数组(右边子数组)。所以,若是我们用快排法将数组的两个子数组拼接起来,我们就会得到一个新数组。
用这个作为支点是有效的。假设我们选择15来作为支点呢?

这里写图片描述
图4-28
每一个子数组都有1项,我们知道如何排序子数组,所以,当数组含有3项时,我们知道如何排序了,这是步骤:
找支点
将数组进行划分:比支点小的项组成的子数组、比支点大的项组成一个子数组
对这两个子数组递归的使用快排。
若是数组中含有四项呢?
这里写图片描述

图4-29
我们将33选为支点

这里写图片描述
图4-30
左边的子数组有3项,我们知道有3项的子数组怎么排序:递归的调用快排。

这里写图片描述
图4-31
现在你会排序含有4项的数组了。你会排序含有4项的数组,那么就会排序含有5项的数组,为什么呢?假设下面的数组含有5项。

这里写图片描述
图4-32
下面的是你怎样来划分数组。这根据你选的支点会有所不同。

这里写图片描述
图4-33
你发现了吗?任何一个子数组包含的项在0~4之间。你用快排法已经知道如何对它们进行排序了。所以不管你选的支点是什么,你都可以对这两个子数组递归的调用快排法。
例如,假设你选择了3作为支点。

这里写图片描述
图4-34
子数组排序好了,你可以将它们连接起来,这样整个数组就排序好了。假设你选择5作为支点。

这里写图片描述
图4-35
选择任意一个值作为支点都可以排序。利用这个相同的逻辑,你可以对含有6项,7项。。的数组进行排序。

归纳证明
我们用到了归纳证明!归纳证明是论证算法正确的一种方法,归纳证明分两步:基准场景和归纳场景。是不是很相似?例如,假设我要证明我可以爬到梯子顶部,在归纳证明中,若是我们爬到梯子的第一级,那么我就可以爬到第二级,我就可以爬到第三级,这就是归纳场景。我可以说,因为我可以爬上梯子第一级,因此,在一定时间内,我可以爬上整个梯子。

这里写图片描述
图4-36
快排和它类似,基础场景下,该算法是有效的:含有0项或1项的数组。在这个归纳场景下,若是快排对含有1项的数组起作用,那么它对含有2项、3项等的项也会起作用。这样,我们就可以说快排对任何大小的数组都是有效的。我不会对归纳论证做深入的介绍了,但是我们会发现它非常有趣而且和分治法很像。
这是快排的代码描述:

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])

大O标记法
快排的速度依赖于我们选取的支点。在讨论快排前,我们先看下大多数情况下,大O的运行时间。

这里写图片描述
图4-37
假定我们的计算机1秒只能运行10次,这幅图表示的不是很精确—只能给你一个不同算法运行时间的大概印象。在现实生活中,你的计算机比这个快多了。
每个运行时间对应一个示例算法,我们在第二章中学习了选择排序,它是O(n2),这是个非常慢的算法。
还有另一个排序算法叫冒泡法,它的时间复杂度是O(nlogn) ,非常快!快排是一个狡猾的算法,最差的情况下,需要O(n2).
这和选择排序一样慢!但这是最差情形,平均情况下,快排的时间复杂度为O(nlogn)。所以你会考虑:
最差情形和平均情形是什么意思!
若是快排平均是O(nlogn),冒泡一直是O(nlogn),那为什么不用冒泡法呢?这不是更快吗?

冒泡法 VS 快排法
假设下面的这个例子。将列表中每项打印出来.

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

这个方法遍历列表中的每一项。然后将它们打印出来,因为只循环一次,时间复杂度为O(n)。假设打印每项前,你都让它睡1秒。

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

打印每项前,我们休眠1秒。假设你需要打印5项。假设打印这5项,两个方法你都用。

这里写图片描述
图4-38
这两个方法都遍历列表,都花费O(n)时间,在现实中,哪个更快呢?应该是print_items,因为打印前,它不需要休眠1秒。就算他们都是用O(n)表示的,现实中,print_items也会更快一点。当我们这样写时,它意味着:

这里写图片描述
图4-39
C是你的算法需要的时间常量,它被叫做常数。例如,10 milliseconds * n (print_items函数)对比 1second * n(print_items2)。
你可以将常量忽略。因为若是两个算法的表达式不同时,常量几乎没有影响 。以二分查找和简单查找为例,他们都有以下常数。

这里写图片描述
图4-40
你可能会说,“Wow!简单查找的常量是10毫秒,二分查找的常量是1秒钟,简单查找更快!”假设你需要查找含有4亿个项的列表,这就是它们花费的时间

这里写图片描述
图4-41
你看到了,二分查找快多了。常量几乎没有什么影响。

但是,有时常量也有很大的作用。对比快排法和冒泡法。快排的常量比冒泡的小,若他们都是O(nlogn),那么快排的速度快!因为快排遇到平均场景的概率比遇到最差场景的概率大。
所以你会好奇:平均情形(场景)和最差情形(场景)是什么意思呢?

平均情形 VS 最差情形
快排的性能取决于我们选择的pivot(支点),假设你总是选择第一项作为支点,你用快排排序一个已经排序好的列表,但是快排不知道它是排序好的,所以它还是要给这一个数组排序。

这里写图片描述

图4-42
现在,你不是将数组拆成两半了,相反,其中一个子数组老是空的。所以,这个递归非常长。但是,假设你老是取中间的值作为支点,看下这个递归过程。

这里写图片描述
图4-43
这个非常短!每次你都将数组分成两个子数组,你不需要递归很多次,就可以得到基础场景,这个调用栈就会短一些。
第一种场景叫做最差场景,第二种场景叫做最好场景。最差情形下,时间复杂度为O(n),最好的情形下,时间复杂度为O(nlogn)。
现在来看第一层,首先我选择一个支点,剩下的项被分成两个子数组。我们遍历了这8项,因此,第一次操作需要O(n),这次调用我们遍历了所有项。事实上,每一次调用,我们都需要O(n)时间。

这里写图片描述
这里写图片描述
图4-44
就算我们拆分数组不同(选择的支点不同),我们每次也都需要O(n)时间。

这里写图片描述
图4-45
因此,每一层我们都需要O(n)时间来完成。

这里写图片描述
图4-46
在这个例子中,有O(logn)层,也就是说,这个调用栈高为O(logn),每层需要O(n)时间,因此这个算法总共需要O(n)*O(logn)=O(nlogn)时间。这是最好的场景。
最差情况下,每层都需要O(n)时间,总共有O(n)层,因此需要O(n)*O(n)=O(n2)时间。
猜猜看?我告诉你最好的场景也就是平均情形,若是你每次都是随机的选取一项作为支点,快排平均情况下,时间复杂度是O(nlogn),快排是快速排序算法之一,也是分治法的一个好的示例。

练习
下面这些操作的时间复杂度用大O表示法如何表示?
4.5 将数组中的每项打印出来
4.6 对数组中的每项的值都乘以2;
4.7 只对数组中的第一项乘以2;
4.8 对书中的每项做乘法运算,比如你的数组是[2, 3, 7, 8, 10].首先将每项的值乘以2,然后再将每项的值乘以3,再乘以4,等等。

复习
分治法是将问题逐渐分解,变得越来越小,若你在使用分治法,那基础场景是空数组或只含有一项的数组;
若你在用快排算法,随机选取一项作为支点,快排的平均运行时间是O(nlogn);
大O表示法中的常量有时候也起作用,这也是为什么快排法比冒泡法快的原因;
比较简单查找和二分查找,常量是不起作用的。因为当数组很大时,O(logn)比O(n)快多了。
这里写图片描述

图4-47

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值