一些简单的算法和数据结构(排序算法)

1,099 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第21天,点击查看活动详情

排序算法

我们刚刚看到,如果我们碰巧知道列表已排序,我们可以利用该信息来大大减少搜索列表所需的时间。这是否意味着当被要求搜索列表时,我们应该首先对其进行排序,然后执行搜索?

设 θ(排序复杂度(L)) 是排序列表的复杂性的紧密限制。既然我们知道我们可以在θ(1en(L))时间内搜索未排序的列表,那么我们是否应该先排序然后搜索的问题归结为问题,排序复杂度(L)+log(1en(L))是否小于len(L)?可悲的是,答案是否定的。如果不至少查看列表中的每个元素一次,就不可能对列表进行排序,因此不可能在亚线性时间内对列表进行排序。

这是否意味着二进制搜索是一种没有实际意义的求知欲?很高兴,没有。假设我们希望多次搜索同一列表。支付对列表进行排序一次的开销,然后在许多搜索中摊销排序成本可能是有意义的。如果我们希望搜索列表 k 次,相关的问题就变成了,排序复杂度(L) + klog(len(L)) 是否小于 k1en(L)?

随着 k 变大,对列表进行排序所需的时间变得越来越无关紧要。k需要多大取决于对列表进行排序所需的时间。例如,如果排序在列表大小上呈指数级增长,则 k 必须非常大。

幸运的是,排序可以相当有效地完成。例如,大多数Python实现中的排序标准实现大约在o(n * 1og(n))时间内运行,其中n是列表的长度。在实践中,您很少需要实现自己的排序函数。在大多数情况下,正确的做法是使用Python的内置排序方法(L.sort()对列表L进行排序)或其内置函数排序(排序(L)返回与L具有相同元素的列表,但不改变L)。我们在这里介绍排序算法主要是为了提供一些思考算法设计和复杂性分析的实践。

我们从一个简单但低效的算法开始,选择排序。选择排序(图 12-4)的工作原理是保持循环不变,即给定将列表划分为前缀 (L[0:i]) 和后缀 (L[i+1:1en (L) 1),对前缀进行排序,并且前缀中没有元素大于后缀中的最小元素。

我们使用归纳来推理循环不变量。

基本情况:在第一次迭代开始时,前缀为空,即后缀是整个列表。因此,不变量(平凡地)为真。

归纳步骤:在算法的每个步骤中,我们将一个元素从后缀移动到前缀。为此,我们将后缀的最小元素附加到前缀的末尾。因为在我们移动元素之前持有的不变量,我们知道在附加元素之后,前缀仍然被排序。我们还知道,由于我们删除了后缀中的最小元素,因此前缀中没有元素大于后缀中的最小元素。

终止:当循环退出时,前缀包括整个列表,后缀为空。因此,整个列表现在按升序排序。

image.png

很难想象一个更简单或更明显的正确排序算法。不幸的是,它相当低效.74 内循环的复杂性是θ (1en (L) )。外环的复杂度也是 θ(1en (L))。因此,整个函数的复杂性为 θ (1en (L) 2)。即,它是L.75长度的二次型

合并排序

幸运的是,我们可以比使用分而治之算法的二次时间做得更好。基本思想是将原始问题的更简单实例的解结合起来。通常,分而治之的算法的特点是

·阈值输入大小,低于该值不会细分问题

实例拆分为的子实例的大小和数量

·用于组合子解决方案的算法。

阈值有时称为递归基数。对于第 2 项,通常考虑初始问题大小与子实例大小的比率。在到目前为止我们看到的大多数例子中,这个比率是2。

合并排序是一种典型的分而治之算法。它由约翰·冯·诺依曼于1945年发明,至今仍被广泛使用。像许多分而治之的算法一样,它最容易以递归方式描述:

  1. 如果列表的长度为 o 或 1,则表示已排序。

如果列表有多个元素,请将列表拆分为两个列表,并使用合并排序对每个列表进行排序。

3.合并结果。

冯·诺依曼所做的关键观察是,两个排序列表可以有效地合并为一个排序列表。我们的想法是查看每个列表的第一个元素,并将两个元素中较小的元素移动到结果列表的末尾。当其中一个列表为空时,所有保留是从其他列表中复制其余项目。例如,考虑合并两个列表L_1 = [1,5,12,18,19,20] 和 L_2 =[2,3,4,17]:

image.png

合并过程的复杂性是什么?它涉及两个常量时间操作,比较元素的值并将元素从一个列表复制到另一个列表。比较的数目是阶数 θ (1en (L)),其中 L 是两个列表中较长的一个。复制操作的次数是顺序 θ(1en(L1) + len(L2)),因为每个元素只复制一次。(复制元素的时间取决于元素的大小。但是,这不会影响排序增长的顺序,因为它是列表中元素数量的函数。因此,合并两个排序列表在列表的长度上是线性的。

image.png

请注意,我们已将比较运算符设置为 merge_sort 函数的参数,并编写了一个 lambda 表达式来提供默认值。因此,例如,代码

image.png

打印

[1,2, 3, 4, 5][5, 4, 3, 2,1]

让我们分析一下merge_sort的复杂性。我们已经知道合并的时间复杂度是阶θ(1en(L))。在每个递归级别,要合并的元素总数为 len(L)。因此,merge_sort的时间复杂度是阶 θ(1en(L)) 乘以递归的级别数。由于merge_sort每次都将列表一分为二,因此我们知道递归的级别数是阶 θ(log(1en(L) )。因此,merge_sort的时间复杂度为 θ(n*1og (n)),其中 n 为 len (L).76

这比选择排序的θ(1en (L) 2)要好得多。例如,如果L 有 10,000元素1英寸(长)2为 100百万,但伦(L)* 1og2(1en(L))约为130,000。这种时间复杂性的提高是有代价的。选择排序是就地排序算法的一个示例。因为它通过交换列表中元素的位置来工作,所以它只使用恒定数量的额外存储(在我们的实现中为一个元素)。相比之下,合并排序算法涉及创建列表的副本。这意味着它的空间复杂度是阶θ(1en(L))。对于大型列表,这可能是一个问题。

假设我们想要对写为名字后跟姓氏的名字列表进行排序,例如,列表[“克里斯·特曼”,“汤姆·布雷迪”,“埃里克·格里姆森”,“吉赛尔·邦德臣”)。图 12-6 定义了两个排序函数,然后使用这些函数以两种不同的方式对列表进行排序。每个函数都使用 str 类型的拆分方法。

image.png

运行图 12-6 中的代码时,将打印

image.png