python数据结构与算法分析(二)

数据结构与算法分析(一)

递归

递归是解决问题的一种方法,它将问题不断地分成更小的子问题,直到子问题可以用普通的方法解决。通常情况下,递归会使用一个不停调用自己的函数。

递归三原则:
(1) 递归算法必须有基本情况;
(2) 递归算法必须改变其状态并向基本情况靠近;
(3) 递归算法必须递归地调用自己。
基本情况是指使算法停止递归的条件,这通常是小到足以直接解决的问题。
为了遵守第二条原则,必须设法改变算法的状态,从而使其向基本情况靠近。改变状态是指修改算法所用的某些数据,这通常意味着代表问题的数据以某种方式变得更小。
最后一条原则是递归算法必须对自身进行调用,这正是递归的定义。
其实,递归的逻辑并不是循环,而是将问题分解成更小、更容易解决的子问题。

栈帧:实现递归
当调用函数时,Python分配一个栈帧来处理该函数的局部变量。当函数返回时,返回值就在栈的顶端,以供调用者访问。
栈帧限定了函数所用变量的作用域。尽管反复调用相同的函数,但是每一次调用都会为函数的局部变量创建新的作用域。

4.5汉诺塔
借助一根中间柱子,将高度为height的一叠盘子从起点柱子移到终点柱子:
(1) 借助终点柱子,将高度为height -1的一叠盘子移到中间柱子;
(2) 将最后一个盘子移到终点柱子;
(3) 借助起点柱子,将高度为height -1的一叠盘子从中间柱子移到终点柱子。
只要总是遵守大盘子不能叠在小盘子之上的规则,就可以递归地执行上述步骤,就像最下面的大盘子不存在一样。上述步骤仅缺少对基本情况的判断。最简单的汉诺塔只有一个盘子。在这种情况下,只需将这个盘子移到终点柱子即可,这就是基本情况。此外,上述步骤通过逐渐减小高度height来向基本情况靠近。
在这里插入图片描述
算法如此简洁的关键在于进行两个递归调用,分别在第3行和第5行。第3行将除了最后一个盘子以外的其他所有盘子从起点柱子移到中间柱子。第4行简单地将最后一个盘子移到终点柱子。第5行将之前的塔从中间柱子移到终点柱子,并将其放置在最大的盘子之上。基本情况是高度为0。此时,不需要做任何事情,因此moveTower函数直接返回。这样处理基本情况时需要记住,从moveTower返回才能调用moveDisk。
moveDisk函数非常简单,代码如下所示。它所做的就是打印出一条消息,说明将盘子从一根柱子移到另一根柱子。不妨尝试运行moveTower程序,你会发现它是非常高效的解决方案。
在这里插入图片描述

动态规划:
递归调用动态规划

搜索

搜索是指从元素集合中找到某个特定元素的算法过程。搜索过程通常返回True或False,分别表示元素是否存在。
Python提供了运算符in,通过它可以方便地检查元素是否在列表中。

①顺序搜索:
存储于列表等集合中的数据项彼此存在线性或顺序的关系,每个数据项的位置与其他数据项相关。在Python列表中,数据项的位置就是它的下标。因为下标是有序的,所以能够顺序访问,由此可以进行顺序搜索。
顺序搜索的原理:从列表中的第一个元素开始,沿着默认的顺序逐个查看,直到找到目标元素或者查完列表。如果查完列表后仍没有找到目标元素,则说明目标元素不在列表中。
顺序搜索算法的Python实现如下代码所示。这个函数接受列表与目标元素作为参数,并返回一个表示目标元素是否存在的布尔值。布尔型变量found的初始值为False,如果找到目标元素,就将它的值改为True。
在这里插入图片描述
在分析搜索算法之前,需要定义计算的基本单元,这是解决此类问题的第一步。对于搜索来说,统计比较次数是有意义的。每一次比较只有两个结果:要么找到目标元素,要么没有找到。这里做了一个假设,即元素的排列是无序的。也就是说,目标元素位于每个位置的可能性都一样大。
要确定目标元素不在列表中,唯一的方法就是将它与列表中的每个元素都比较一次。如果列表中有n个元素,那么顺序搜索要经过n次比较后才能确定目标元素不在列表中。如果列表包含目标元素,分析起来更复杂。实际上有3种可能情况,最好情况是目标元素位于列表的第一个位置,即只需比较一次;最坏情况是目标元素位于最后一个位置,即需要比较n次。
普通情况又如何呢?我们会在列表的中间位置处找到目标元素,即需要比较n/2次。当n变大时,系数会变得无足轻重,所以顺序搜索算法的时间复杂度是O(n)。下表总结了3种可能情况的比较次数。
在这里插入图片描述
前面假设列表中的元素是无序排列的,相互之间没有关联。如果元素有序排列,顺序搜索算法的效率会提高吗?
假设列表中的元素按升序排列。如果存在目标元素,那么它出现在n个位置中任意一个位置的可能性仍然一样大,因此比较次数与在无序列表中相同。不过,如果不存在目标元素,那么搜索效率就会提高。下图展示了算法搜索目标元素50的过程。注意,顺序搜索算法一路比较列表中的元素,直到遇到54。该元素蕴含额外的信息:54不仅不是目标元素,而且其后的元素也都不是,这是因为列表是有序的。因此,算法不需要搜完整个列表,比较完54之后便可以立即停止。代码展示了有序列表的顺序搜索函数。
在这里插入图片描述
在这里插入图片描述
下表总结了在有序列表中顺序搜索时的比较次数。在最好情况下,只需比较一次就能知道目标元素不在列表中。普通情况下,需要比较n/2次,不过算法的时间复杂度仍是O(n)。总之,只有当列表中不存在目标元素时,有序排列元素才会提高顺序搜索的效率。
在这里插入图片描述

②二分搜索
还可以改进算法,进一步利用列表有序这个有利条件。在顺序搜索时,如果第一个元素不是目标元素,最多还要比较n-1次。但二分搜索不是从第一个元素开始搜索列表,而是从中间的元素着手。如果这个元素就是目标元素,那就立即停止搜索;如果不是,则可以利用列表有序的特性,排除一半的元素。如果目标元素比中间的元素大,就可以直接排除列表的左半部分和中间的元素。这是因为,如果列表包含目标元素,它必定位于右半部分。
接下来,针对右半部分重复二分过程。从中间的元素着手,将其和目标元素比较。同理,要么直接找到目标元素,要么将右半部分一分为二,再次缩小搜索范围。下图展示了二分搜索算法如何快速地找到元素54,完整的函数如代码所示。
在这里插入图片描述
在这里插入图片描述
这个算法是分治策略的好例子。分治是指将问题分解成小问题,以某种方式解决小问题,然后整合结果,以解决最初的问题。对列表进行二分搜索时,先查看中间的元素。如果目标元素小于中间的元素,就只需要对列表的左半部分进行二分搜索。同理,如果目标元素更大,则只需对右半部分进行二分搜索。两种情况下,都是针对一个更小的列表递归调用二分搜索函数,如下代码所示。
在这里插入图片描述
分析二分搜索算法:
在进行二分搜索时,每一次比较都将待考虑的元素减半。那么,要检查完整个列表,二分搜索算法最多要比较多少次呢?假设列表共有n个元素,第一次比较后剩下n/2个元素,第2次比较后剩下n/4个元素,接下来是n/8,然后是n/16,依此类推。列表能拆分多少次呢?下表给出了答案。
在这里插入图片描述
拆分足够多次后,会得到只含一个元素的列表。这个元素要么就是目标元素,要么不是。无论是哪种情况,计算工作都已完成。要走到这一步,需要比较i次,其中n/(2^i)=1。由此可得,i=logn。比较次数的最大值与列表的元素个数是对数关系。所以,二分搜索算法的时间复杂度是O(logn)。
递归调用binarySearch(alist[:midpoint], item)使用切片运算符得到列表的左半部分,并将其传给下一次调用(右半部分类似)。前面的分析假设切片操作所需的时间固定,但实际上在Python中,切片操作的时间复杂度是O(k)。这意味着若采用切片操作,那么二分搜索算法的时间复杂度不是严格的对数阶。所幸,通过在传入列表时带上头和尾的下标,可以弥补这一点。
尽管二分搜索通常优于顺序搜索,但当n较小时,排序引起的额外开销可能并不划算。实际上应该始终考虑,为了提高搜索效率,额外排序是否值得。如果排序一次后能够搜索多次,那么排序的开销不值一提。然而,对于大型列表而言,只排序一次也会有昂贵的计算成本,因此从头进行顺序搜索可能是更好的选择。

③散列
散列表是元素集合,其中的元素以一种便于查找的方式存储。散列表中的每个位置通常被称为槽,其中可以存储一个元素。槽用一个从0开始的整数标记,例如0号槽、1号槽、2号槽,等等。初始情形下,散列表中没有元素,每个槽都是空的。可以用列表来实现散列表,并将每个元素都初始化为Python中的特殊值None。下图展示了大小m为11的散列表。也就是说,表中有m个槽,编号从0到10。
在这里插入图片描述
散列函数将散列表中的元素与其所属位置对应起来。对散列表中的任一元素,散列函数返回一个介于0和m -1之间的整数。假设有一个由整数元素54、26、93、17、77和31构成的集合。首先来看第一个散列函数,它有时被称作“取余函数”,即用一个元素除以表的大小,并将得到的余数作为散列值(h(item) =item%11)。下表给出了所有示例元素的散列值。取余函数是一个很常见的散列函数,这是因为结果必须在槽编号范围内。
在这里插入图片描述
计算出散列值后,就可以将每个元素插入到相应的位置,如图所示。注意,在11个槽中,有6个被占用了。占用率被称作载荷因子,记作λ,定义如下。
在这里插入图片描述
在这里插入图片描述
在本例中,λ=6/11。
搜索目标元素时,仅需使用散列函数计算出该元素的槽编号,并查看对应的槽中是否有值。因为计算散列值并找到相应位置所需的时间是固定的,所以搜索操作的时间复杂度是O(1)。如果一切正常,那么就已经找到了常数阶的搜索算法。
只有当每个元素的散列值不同时,这个技巧才有用。如果集合中的下一个元素是44,它的散列值是0(44%11==0),而77的散列值也是0,这就有问题了。散列函数会将两个元素都放入同一个槽,这种情况被称作冲突,也叫“碰撞”。显然,冲突给散列函数带来了问题。

散列函数
给定一个元素集合,能将每个元素映射到不同的槽,这种散列函数称作完美散列函数。如果元素已知,并且集合不变,那么构建完美散列函数是可能的。不幸的是,给定任意一个元素集合,没有系统化方法来保证散列函数是完美的。所幸,不完美的散列函数也能有不错的性能。
构建完美散列函数的一个方法是增大散列表,使之能容纳每一个元素,这样就能保证每个元素都有属于自己的槽。当元素个数少时,这个方法是可行的,不过当元素很多时,就不可行了。如果元素是9位的社会保障号,这个方法需要大约10亿个槽。如果只想存储一个班上25名学生的数据,这样做就会浪费极大的内存空间。
最优的散列函数:冲突数最少,计算方便,元素均匀分布于散列表中。
有多种常见的方法来扩展取余函数,下面介绍其中的几种。
折叠法先将元素切成等长的部分(最后一部分的长度可能不同),然后将这些部分相加,得到散列值。假设元素是电话号码436-555-4601,以2位为一组进行切分,得到43、65、55、46和01。将这些数字相加后,得到210。假设散列表有11个槽,接着需要用210除以11,并保留余数1。所以,电话号码436-555-4601被映射到散列表中的1号槽。有些折叠法更进一步,在加总前每隔一个数反转一次。就本例而言,反转后的结果是:43+56+55+64+01=219,219%11=10。
另一个构建散列函数的数学技巧是平方取中法:先将元素取平方,然后提取中间几位数。如果元素是44,先计算442=1936,然后提取中间两位93,继续进行取余的步骤,得到5(93%11)。下表分别展示了取余法和平方取中法的结果。
在这里插入图片描述
处理冲突
当两个元素被分到同一个槽中时,必须通过一种系统化方法在散列表中安置第二个元素。这个过程被称为处理冲突。如果散列函数是完美的,冲突就永远不会发生。然而,这个前提往往不成立,因此处理冲突是散列计算的重点。
一种方法是在散列表中找到另一个空槽,用于放置引起冲突的元素。简单的做法是从起初的散列值开始,顺序遍历散列表,直到找到一个空槽。注意,为了遍历散列表,可能需要往回检查第一个槽。这个过程被称为开放定址法,它尝试在散列表中寻找下一个空槽或地址。由于是逐个访问槽,因此这个做法被称作线性探测
线性探测有个缺点,那就是会使散列表中的元素出现聚集现象。也就是说,如果一个槽发生太多冲突,线性探测会填满其附近的槽,而这会影响到后续插入的元素。
要避免元素聚集,一种方法是扩展线性探测,不再依次顺序查找空槽,而是跳过一些槽,这样做能使引起冲突的元素分布得更均匀。
再散列泛指在发生冲突后寻找另一个槽的过程。采用线性探测时,再散列函数是newhashvalue = rehash(oldhashvalue),并且rehash(pos) = (pos + 1)%sizeoftable。“加3”探测策略的再散列函数可以定义为rehash(pos) = (pos +3)%sizeoftable。也就是说,可以将再散列函数定义为rehash(pos) = (pos + skip)%sizeoftable。注意,“跨步”(skip)的大小要能保证表中所有的槽最终都被访问到,否则就会浪费槽资源。要保证这一点,常常建议散列表的大小为素数。
平方探测是线性探测的一个变体,它不采用固定的跨步大小,而是通过再散列函数递增散列值。如果第一个散列值是h,后续的散列值就是h+1、h+4、h+9、h+16,等等。换句话说,平方探测的跨步大小是一系列完全平方数。
另一种处理冲突的方法是让每个槽有一个指向元素集合(或链表)的引用。链接法允许散列表中的同一个位置上存在多个元素。发生冲突时,元素仍然被插入其散列值对应的槽中。不过,随着同一个位置上的元素越来越多,搜索变得越来越困难。
搜索目标元素时,用散列函数算出它对应的槽编号。由于每个槽都有一个元素集合,因此需要再搜索一次,才能得知目标元素是否存在。链接法的优点是,平均算来,每个槽的元素不多,因此搜索可能更高效。

实现映射抽象数据类型
字典是最有用的Python集合之一。字典是存储键-值对的数据类型。键用来查找关联的值,这个概念常常被称作映射。
映射抽象数据类型定义如下。它是将键和值关联起来的无序集合,其中的键是不重复的,键和值之间是一一对应的关系。映射支持以下操作。
Map()创建一个空的映射,它返回一个空的映射集合。
put(key, val)往映射中加入一个新的键-值对。如果键已经存在,就用新值替换旧值。
get(key)返回key对应的值。如果key不存在,则返回None。
del通过del map[key]这样的语句从映射中删除键-值对。
len()返回映射中存储的键-值对的数目。
in通过key in map这样的语句,在键存在时返回True,否则返回False。
使用字典的一大优势是,给定一个键,能很快找到其关联的值。为了提供这种快速查找能力,需要能支持高效搜索的实现方案。虽然可以使用列表进行顺序搜索或二分搜索,但用前面描述的散列表更好,这是因为散列搜索算法的时间复杂度可以达到O(1)。

代码使用两个列表创建HashTable类,以此实现映射抽象数据类型。其中,名为slots的列表用于存储键,名为data的列表用于存储值。两个列表中的键与值一一对应。这里,散列表的初始大小是11。尽管初始大小可以任意指定,但选用一个素数很重要,这样做可以尽可能地提高冲突处理算法的效率。
在这里插入图片描述
在代码中,hashfunction实现了简单的取余函数。处理冲突时,采用“加1”再散列函数的线性探测法。put函数假设,除非键已经在self.slots中,否则总是可以分配一个空槽。该函数计算初始的散列值,如果对应的槽中已有元素,就循环运行rehash函数,直到遇见一个空槽。如果槽中已有这个键,就用新值替换旧值。
在这里插入图片描述
同理,get函数也先计算初始散列值,如代码所示。如果值不在初始散列值对应的槽中,就使用rehash确定下一个位置。注意,第15行确保搜索最终一定能结束,因为不会回到初始槽。如果遇到初始槽,就说明已经检查完所有可能的槽,并且元素必定不存在。
HashTable类的最后两个方法提供了额外的字典功能。我们重载__getitem__和__setitem__,以通过[]进行访问。这意味着创建HashTable类之后,就可以使用熟悉的索引运算符了。
在这里插入图片描述
下面来看看运行情况。首先创建一个散列表并插入一些元素。其中,键是整数,值是字符串。
在这里插入图片描述
接下来,访问并修改散列表中的某些元素。注意,键20的值已被修改。
在这里插入图片描述

分析散列搜索算法
在最好情况下,散列搜索算法的时间复杂度是O(1),即常数阶。然而,因为可能发生冲突,所以比较次数通常不会这么简单。
在分析散列表的使用情况时,最重要的信息就是载荷因子λ。从概念上来说,如果λ很小,那么发生冲突的概率就很小,元素也就很有可能各就各位。如果λ很大,则意味着散列表很拥挤,发生冲突的概率也就很大。因此,冲突解决起来会更难,找到空槽所需的比较次数会更多。若采用链接法,冲突越多,每条链上的元素也越多。
采用线性探测策略的开放定址法,搜索成功的平均比较次数如下。
在这里插入图片描述
搜索失败的平均比较次数如下。
在这里插入图片描述
若采用链接法,则搜索成功的平均比较次数为:1+λ/2。
搜索失败时,平均比较次数就是λ。

排序

排序是指将集合中的元素按某种顺序排列的过程。
与搜索算法类似,排序算法的效率与待处理元素的数目相关。对于小型集合,采用复杂的排序算法可能得不偿失;对于大型集合,需要尽可能充分地利用各种改善措施。本节将讨论多种排序技巧,并比较它们的运行时间。
分析排序过程的步骤。
首先,排序算法要能比较大小。为了给一个集合排序,需要某种系统化的比较方法,以检查元素的排列是否违反了顺序。在衡量排序过程时,最常用的指标就是总的比较次数。
其次,当元素的排列顺序不正确时,需要交换它们的位置。交换是一个耗时的操作,总的交换次数对于衡量排序算法的总体效率来说也很重要。

①冒泡排序
冒泡排序多次遍历列表。它比较相邻的元素,将不合顺序的交换。每一轮遍历都将下一个最大值放到正确的位置上。本质上,每个元素通过“冒泡”找到自己所属的位置。
下图展示了冒泡排序的第一轮遍历过程。深色的是正在比较的元素。如果列表中有 n个元素,那么第一轮遍历要比较n-1对。注意,最大的元素会一直往前挪,直到遍历过程结束。
在这里插入图片描述
第二轮遍历开始时,最大值已经在正确位置上了。还剩n-1个元素需要排列,也就是说要比较n-2对。既然每一轮都将下一个最大的元素放到正确位置上,那么需要遍历的轮数就是n-1。完成n-1轮后,最小的元素必然在正确位置上,因此不必再做处理。代码给出了完整的bubbleSort函数。该函数以一个列表为参数,必要时会交换其中的元素。
在这里插入图片描述
Python中的交换操作和其他大部分编程语言中的略有不同。在交换两个元素的位置时,通常需要一个临时存储位置(额外的内存位置)。以下代码片段交换列表中的第i个和第j个元素的位置。如果没有临时存储位置,其中一个值就会被覆盖。
在这里插入图片描述
Python允许同时赋值。执行语句a, b = b, a,相当于同时执行两条赋值语句,如图所示。利用Python的这一特性,就可以用一条语句完成交换操作。
在这里插入图片描述
在代码中,第5~7行采用3步法交换第i个和第i+1个元素的位置。注意,也可以通过同时赋值来实现。
在分析冒泡排序算法时要注意,不管一开始元素是如何排列的,给含有n个元素的列表排序总需要遍历n-1轮。表5-6展示了每一轮的比较次数。总的比较次数是前n-1个整数之和。由于前n个整数之和是(n^2)/2+n/2,因此前n-1个整数之和就是(n ^2)/2 +n/2-n,即(n ^2)/2 -n/2。这表明,该算法的时间复杂度是O(n ^2)。在最好情况下,列表已经是有序的,不需要执行交换操作。在最坏情况下,每一次比较都将导致一次交换。
在这里插入图片描述
冒泡排序通常被认为是效率最低的排序算法,因为在确定最终的位置前必须交换元素。“多余”的交换操作代价很大。不过,由于冒泡排序要遍历列表中未排序的部分,因此它具有其他排序算法没有的用途。特别是,如果在一轮遍历中没有发生元素交换,就可以确定列表已经有序。可以修改冒泡排序函数,使其在遇到这种情况时提前终止。对于只需要遍历几次的列表,冒泡排序可能有优势,因为它能判断出有序列表并终止排序过程。代码实现了如上所述的修改,这种排序通常被称作短冒泡。
在这里插入图片描述

②选择排序
选择排序在冒泡排序的基础上做了改进,每次遍历列表时只做一次交换。要实现这一点,选择排序在每次遍历时寻找最大值,并在遍历完之后将它放到正确位置上。和冒泡排序一样,第一次遍历后,最大的元素就位;第二次遍历后,第二大的元素就位,依此类推。若给n个元素排序,需要遍历n-1轮,这是因为最后一个元素要到n-1轮遍历后才就位。
下图展示了完整的选择排序过程。每一轮遍历都选择待排序元素中最大的元素,并将其放到正确位置上。第一轮放好93,第二轮放好77,第三轮放好55,依此类推。代码给出了选择排序函数。
在这里插入图片描述
在这里插入图片描述
可以看出,选择排序算法和冒泡排序算法的比较次数相同,所以时间复杂度也是O( n ^2 )。但是,由于减少了交换次数,因此选择排序算法通常更快。就列表示例而言,冒泡排序交换了20次,而选择排序只需交换8次。

③插入排序
插入排序的时间复杂度也是O(n ^2),但原理稍有不同。它在列表较低的一端维护一个有序的子列表,并逐个将每个新元素“插入”这个子列表。下图展示了插入排序的过程。深色元素代表有序子列表中的元素。
在这里插入图片描述
首先假设位置0处的元素是只含单个元素的有序子列表。从元素1到元素n-1,每一轮都将当前元素与有序子列表中的元素进行比较。在有序子列表中,将比它大的元素右移;当遇到一个比它小的元素或抵达子列表终点时,就可以插入当前元素。
下图详细展示了第5轮遍历的情况。此刻,有序子列表包含5个元素:17、26、54、77和93。现在想插入31。第一次与93比较,结果是将93向右移;同理,77和54也向右移。遇到26时,就不移了,并且31找到了正确位置。现在,有序子列表有6个元素。
在这里插入图片描述
从代码可知,在给n个元素排序时,插入排序算法需要遍历n-1轮。循环从位置1开始,直到位置n-1结束,这些元素都需要被插入到有序子列表中。第8行实现了移动操作,将列表中的一个值挪一个位置,为待插入元素腾出空间。要记住,这不是之前的算法进行的那种完整的交换操作。
在这里插入图片描述
在最坏情况下,插入排序算法的比较次数是前n-1个整数之和,对应的时间复杂度是O(n ^2)。在最好情况下(列表已经是有序的),每一轮只需比较一次。移动操作和交换操作有一个重要的不同点。总体来说,交换操作的处理时间大约是移动操作的3倍,因为后者只需进行一次赋值。在基准测试中,插入排序算法的性能很不错。

希尔排序
希尔排序也称“递减增量排序”,它对插入排序做了改进,将列表分成数个子列表,并对每一个子列表应用插入排序。如何切分列表是希尔排序的关键——并不是连续切分,而是使用增量i(有时称作步长)选取所有间隔为i的元素组成子列表。
以图中的列表为例,这个列表有9个元素。如果增量为3,就有3个子列表,每个都可以应用插入排序,结果如图所示。尽管列表仍然不算完全有序,但通过给子列表排序,我们已经让元素离它们的最终位置更近了。
在这里插入图片描述
在这里插入图片描述
下图展示了最终的插入排序过程。由于有了之前的子列表排序,因此总移动次数已经减少了。本例只需要再移动4次。
在这里插入图片描述
如前所述,如何切分列表是希尔排序的关键。代码中的函数采用了另一组增量。先为n/2个子列表排序,接着是n/4个子列表。最终,整个列表由基本的插入排序算法排好序。下图展示了采用这种增量后的第一批子列表。
在这里插入图片描述
在这里插入图片描述

归并排序:
使用分治策略改进排序算法。
要研究的第一个算法是归并排序,它是递归算法,每次将一个列表一分为二。如果列表为空或只有一个元素,那么从定义上来说它就是有序的(基本情况)。如果列表不止一个元素,就将列表一分为二,并对两部分都递归调用归并排序。当两部分都有序后,就进行归并这一基本操作。归并是指将两个较小的有序列表归并为一个有序列表的过程。下图展示了示例列表被拆分后的情况,并给出了归并后的有序列表。
在这里插入图片描述
在代码中,mergeSort函数以处理基本情况开始。如果列表的长度小于或等于1,说明它已经是有序列表,因此不需要做额外的处理。如果长度大于1,则通过Python的切片操作得到左半部分和右半部分。要注意,列表所含元素的个数可能不是偶数。这并没有关系,因为左右子列表的长度最多相差1。
在这里插入图片描述
在第8~9行对左右子列表调用mergeSort函数后,就假设它们已经排好序了。第11~31行负责将两个小的有序列表归并为一个大的有序列表。注意,归并操作每次从有序列表中取出最小值,放回初始列表(alist)。
mergeSort函数有一条print语句(第2行),用于在每次调用开始时展示待排序列表的内容。第32行也有一条print语句,用于展示归并过程。以下脚本展示了针对示例列表执行mergeSort函数的结果。注意,列表[44, 55, 20]不会均分,第一部分是[44],第二部分是[55, 20]。很容易看出,拆分操作最终生成了能立即与其他有序列表归并的列表。
分析mergeSort函数时,要考虑它的两个独立的构成部分。首先,列表被一分为二。在学习二分搜索时已经算过,当列表的长度为n时,能切分log2n次。第二个处理过程是归并。列表中的每个元素最终都得到处理,并被放到有序列表中。所以,得到长度为n的列表需要进行n次操作。由此可知,需要进行logn次拆分,每一次需要进行n次操作,所以一共是nlogn次操作。也就是说,归并排序算法的时间复杂度是O(nlogn)。
切片操作的时间复杂度是O(k),其中k是切片的大小。为了保证mergeSort函数的时间复杂度是O(nlogn),需要去除切片运算符。在进行递归调用时,传入头和尾的下标即可做到这一点。
有一点要注意:mergeSort函数需要额外的空间来存储切片操作得到的两半部分。当列表较大时,使用额外的空间可能会使排序出现问题。

快速排序
和归并排序一样,快速排序也采用分治策略,但不使用额外的存储空间。不过,代价是列表可能不会被一分为二。出现这种情况时,算法的效率会有所下降。快速排序算法首先选出一个基准值。尽管有很多种选法,但为简单起见,本节选取列表中的第一个元素。基准值的作用是帮助切分列表。在最终的有序列表中,基准值的位置通常被称作分割点,算法在分割点切分列表,以进行对快速排序的子调用。
快速排序算法首先选出一个基准值。尽管有很多种选法,但为简单起见,本节选取列表中的第一个元素。基准值的作用是帮助切分列表。在最终的有序列表中,基准值的位置通常被称作分割点,算法在分割点切分列表,以进行对快速排序的子调用。
在图中,元素54将作为第一个基准值。从前面的例子可知,54最终应该位于31当前所在的位置。下一步是分区操作。它会找到分割点,同时将其他元素放到正确的一边——要么大于基准值,要么小于基准值。
在这里插入图片描述
分区操作首先找到两个坐标——leftmark和rightmark——它们分别位于列表剩余元素的开头和末尾,如图所示。分区的目的是根据待排序元素与基准值的相对大小将它们放到正确的一边,同时逐渐逼近分割点。下图展示了为元素54寻找正确位置的过程。
在这里插入图片描述
首先加大leftmark,直到遇到一个大于基准值的元素。然后减小rightmark,直到遇到一个小于基准值的元素。这样一来,就找到两个与最终的分割点错序的元素。本例中,这两个元素就是93和20。互换这两个元素的位置,然后重复上述过程。
当rightmark小于leftmark时,过程终止。此时,rightmark的位置就是分割点。将基准值与当前位于分割点的元素互换,即可使基准值位于正确位置,如图所示。分割点左边的所有元素都小于基准值,右边的所有元素都大于基准值。因此,可以在分割点处将列表一分为二,并针对左右两部分递归调用快速排序函数。
在这里插入图片描述
在代码中,快速排序函数quickSort调用了递归函数quickSortHelper。quickSortHelper首先处理和归并排序相同的基本情况。如果列表的长度小于或等于1,说明它已经是有序列表;如果长度大于1,则进行分区操作并递归地排序。分区函数partition实现了前面描述的过程。
在这里插入图片描述
在分析quickSort函数时要注意,对于长度为n的列表,如果分区操作总是发生在列表的中部,就会切分logn次。为了找到分割点,n 个元素都要与基准值比较。所以,时间复杂度是O(nlogn)。另外,快速排序算法不需要像归并排序算法那样使用额外的存储空间。
不幸的是,最坏情况下,分割点不在列表的中部,而是偏向某一端,这会导致切分不均匀。在这种情况下,含有n个元素的列表可能被分成一个不含元素的列表与一个含有n-1个元素的列表。然后,含有n-1个元素的列表可能会被分成不含元素的列表与一个含有n-2个元素的列表,依此类推。这会导致时间复杂度变为O( 2n ),因为还要加上递归的开销。

数据结构与算法分析(三)
数据结构与算法分析(四)

猜你喜欢

转载自blog.csdn.net/yeqing1997/article/details/112796496