【第四天】算法图解 之 快速排序

持续更新算法图解笔记内容!

·算法图解阅读笔记之快速排序

分而治之(D&C)——一种著名的递归式问题解决方法

D&C算法——快速排序。快速排序是一种排序算法,速度比第二章介绍的选择排序快得多。

1. 分而治之

假如你是一个农场主,有一小块土地,你要将这块土地均匀地分成方块,且分出的方块要尽可能大。

用D&C方法1>找出基线条件,这种条件必须尽可能简单;2>不断将问题分解(或缩小规模),直到符合基线条件

找出基线条件,最容易处理的情况是,一条边的长度是另一条边的整数倍

根据D&C的定义,每次递归调用都必须缩小问题的规模。我们首先找出这块地可容纳的最大方块。

那么,何不对余下的那一小块地使用相同的算法呢?

余下的土地满足基线条件,因为160是80的整数倍

因此,对于最初的土地,适用的最大方块为80m*80m

D&C并非可用于解决问题的算法,而是一种解决问题的思路


再看一个例子:

给定一个数组:【2 | 4 | 6】   要将这些数字相加,并返回结果

使用循环很容易:

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

运行:10 

使用递归函数

1> 找出基线条件:如果数组不包含任何元素或只包含一个元素,计算总和将非常容易

基线条件:[ ]          不包含任何元素 ==> 总和为0

                  [7]         只包含一个元素 ==> 总和为7

因此这就是基线条件

2> 每次递归调用都必须离空数组更进一步

  

递归记录了状态

既然使用循环可以轻松完成任务,为何还要使用递归呢?

Haskell等函数式编程语言没有循环!

练习:

·请编写前述sum函数的代码

def sum(list):
    if list == []:
        return 0
    else:
        return list[0] + sum(list[1:])
print(sum([2,4,6]))

运行:12

·编写一个递归函数来计算列表包含的元素数

def count(list):
    if list == []:
        return 0
    else:
        return 1+count(list[1:])
print(count([2,4,6]))

运行:3

2.快速排序

快速排序是一种常用的排序算法,比选择排序快得多。

使用快速排序对数组进行排序。对排序算法来说,最简单的数组就是不需要排序的数组。因此,基线条件为数组为空或只包含一个元素。在这种情况下,只需原样返回数组——根本不用排序,如下

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

更长点:

两个:[1 | 7]  检查第一个元素是否比第二个元素小,如果不比第二个小,就交换他们的位置。

三个:[33 | 15 | 10]  将数组分解,直到满足基线条件。

其解释如下

快速排序工作原理

从数组中选择一个元素,这个元素被称为基准值。暂时将数组的第一个元素用作基准值,找出比基准值小的元素以及比基准值大的元素

这被称为分区,现在你有:

1> 一个由所有小于基准值的数字组成的子数组

2> 基准值

3> 一个由所有大于基准值的数字组成的数组

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

如果子数组是有序的,就像下面这样合并得到一个有序数组:左边的数组+基准值+右边的数组

在这里就是[10,15]+[33]+[ ],结果为[10,15,33]


如何对子数组进行排序?

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

quicksort([15,10]) + [33] + quicksort([ ])

-->[10, 15, 33]

不管将哪个元素用作基准值,这都管用

这个子数组都只有一个元素,而你知道如何对这些数组进行排序。

三个元素(15作基准值):1> 选择基准值; 2> 将数组分成两个子数组:小于和大于基准值的元素; 3> 对这两个子数组进行快速排序

四个元素(33作基准值):左边包含三个元素,对三个元素的数组进行排序,对其递归地调用快速排序

因此你能够对包含四个元素的数组进行排序,接着就能对包含五个元素的数组进行排序。


为什么呢?假设有一个包含五个元素的数组。

这些子数组包含的元素数都在0-4内,而你已经知道如何用快速排序对包含0-4个元素的数组进行排序!

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


* 归纳证明

这是一种证明算法行之有效的方式,他分两步:基线条件和归纳条件

递归条件:如果我站在一个横档上,就能将脚放到下一个横档上

归纳条件:如果我站在第二个横档上,就能爬到第三个横档

基线条件:我已经站在第一个横档上,通过每次爬一个横档,我就能爬到梯子最顶端

在基线条件中,我证明这种算法对空数组或包含一个元素的数组管用

在归纳条件中,我证明如果快速排序对包含一个元素管用,对包含两个元素的数组也管用;如果……两个……三个……以此类推。快速排序对任何长度的数组都管用

快速排序示例代码

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

运行:

[2, 3, 5, 10]

3. 再谈大O表示法

快速排序的独特之处在于,其速度取决于选择的基准值

上述图表中的时间是基于每秒执行十次操作计算得到的。实际上,计算机每秒执行的操作数远不止十次

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

1> 比较合并排序和快速排序

有一个打印列表中每个元素的简单函数

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

它迭代整个列表一次,因此运行时间为O(n)

现在对这个函数进行修改,使其在打印每个元素前都休眠1秒钟

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

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

[2 | 4 | 6 | 8 | 10]

print_item : 2 4 6 8 10

print_item2 : <休眠>   2<休眠>  4<休眠>  6<休眠>  8<休眠>  10

这两个函数都迭代整个列表一次,因此运行时间都为O(n)

但是print_item要快得多,因为它没有在每次打印前都暂停1秒钟

因此,虽然使用大O表示法表示时,这两个函数的速度相同,但实际上print_item的速度更快。在大O表示法中,n实际上指的是这样的:c是算法所需的固定时间量,被称为常量。例如print_item所需时间可能是10毫秒*n,而print_item2所需的时间为1秒*n

通常不考虑这个常量。因为如果两种算法的大O运行时间不同,这种常量无关紧要。举例:

简单查找:10毫秒*n            二分查找:1秒*logn

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

简单查找: 10毫秒*40亿 = 463天            二分查找: 1秒*32 = 32秒

二分查找的速度还是快得多,常量根本没有什么影响

但有时候,常量的影响可能很大,对快速查找和合并查找来说就是如此

快速查找的常量比合并查找小,因此如果他们的运行时间都为O(nlogn),快速查找的速度更快

实际上,快速查找的速度确实更快,因为相对于遇上最糟情况,它遇上平均情况的可能性要大很多。

2> 平均情况和最糟情况

快速排序的性能高度依赖于你选择的基准值

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

数组并没有被分成两半,相反,其中一个子数组始终为空,这导致调用栈非常长


现在假设你总是将中间元素用作基准值

第一个示例展示的是最糟情况,而第二个展示的是最佳情况

在最糟情况下,栈长为O(n),而在最佳情况下,栈长为O(logn)


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

在这个示例中层数为O(logn)(调用栈的高度为O(logn)),而每层需要的时间为O(n)。因此整个算法的运行时间为O(n)*O(logn)=O(nlogn),这是最佳情况。最糟情况下为O(n)*O(n)=O(n^2)

最佳情况也是平均情况

只要每次都随机地选择一个数组元素作为基准值,快速排序的平均运行时间就将为O(nlogn)。快速排序是最快的排序算法之一,也是D&C典范。

4. 小结

①D&C将问题逐步分解,使用D&C处理列表时,基线条件很可能是空数组或只包含一个元素的数组

②实现快速排序时,请随机的用作基准值的元素。快速排序的平均运行时间为O(nlogn)

③大O表示法中的常量有时候事关重大,这就是快速排序比合并排序快的原因所在

④比较简单查找和二分查找时,常量几乎无关紧要,因为列表很长时,O(logn)的速度比O(n)快得多

这次文章肝了三天,路过的点个赞吧!我终于写完了!

  • 6
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 7
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Outlier_9

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值