目录
学习目标:
在完成本章的学习之后,能够:
● 根据问题的规模确定算法工作量的增长率;
● 使用大O表示法来描述算法的运行时和内存使用情况;
● 认识常见的工作量增长率或复杂度的类别(常数、对数、线性、平方和指数);
● 描述顺序查找和二分查找的工作方式;
● 描述选择排序算法和快速排序算法的工作方式。
程序 = 算法+数据结构+编程语言
算法是一种解决问题的方法和思想,它描述了一个随着问题被解决而停止的计算过程。
算法执行过程中会消耗两个资源:处理对象所需的时间和存储数据所需的空间(也就是内存)。
对于算法来说,尽可能地追求消耗更短的时间和占用更少的空间。
2.1 衡量算法的效率
2.1.1 衡量算法的运行时
衡量算法时间成本的一种方法是:用计算机时钟得到算法实际的运行时。这个过程被称为基准测试(benchmarking)或性能分析(profiling)。
编制测试程序如下:
import time
problem_size = 10000000
print("%12s%16s" % ("问题规模", "用时(S)"))
for count in range(5):
start_time = time.time()
# The start of the algorithm
work = 1
for x in range(problem_size):
work += 1
work -= 1
# The end of the algorithm
elapsed = time.time() - start_time
print("%12d%16.3f" % (problem_size, elapsed))
problem_size *= 2
测试程序的输出结果如下:
图2-1 测试程序的输出结果
从结果中可以很轻易地看出:当问题规模翻倍时,运行时也差不多翻了一番。
再举一个例子,把测试程序算法语句改成下面这样:
for x in range(problem_size):
for x in range(problem_size):
work += 1
work -= 1
两个赋值语句被放在了嵌套循环里。用problem_size = 1000测试显示:当问题规模翻倍时,运行时差不多翻了两番。按照这种增长速度,若要处理先前那个最大的数据集,大概需要175天!
这种方法可以准确地预测很多算法的运行时,但存在两个主要问题:
●算法的运行时会因各机器软硬件及工作环境的不同而存在差异。
● 用非常大的数据集确定算法的运行时是非常不切实际的。
2.1.2 统计指令数
另一种计量算法时间成本的方法是:在不同问题规模下,统计需要执行的指令数。这样无论在什么平台上运行算法,都能够很好地预测算法执行的抽象工作量。
通过这种方式对算法进行分析时,可以把它分成两个部分:
● 无论问题的规模如何变化,指令执行的次数总是相同的;
● 执行的指令数随着问题规模的变化而变化。
修改前面的测试程序,并跟踪显示内部循环迭代次数:
problem_size = 1000
print("%12s%15s" % ("问题规模", "迭代次数"))
for count in range(5):
number = 0
work = 1
for j in range(problem_size):
for k in range(problem_size):
number += 1
work += 1
work -= 1
print("%12d%15d" % (problem_size, number))
problem_size *= 2
可见,随着问题规模的翻倍,指令数(递归调用的次数)在一开始的时候缓慢增长,随后迅速加快。
以统计执行指令数的方式进行跟踪计数问题在于,对于某些算法来说,计算机仍然无法以足够快的运行速度在一定时间内得到非常大的问题规模的结果。
2.1.3 衡量算法使用的内存
对于算法所用资源的完整分析需要包含它所需的内存量。同样地,要会关注它潜在的增长率,在后续内容会对其中的几种算法进行探讨。
2.2 复杂度分析
学习一种不用关心与平台相关的时间,也不必使用统计指令数量来评估算法效率的方法。
复杂度分析(complexity analysis)方法,所需要的就是阅读算法,进行一些简单的代数计算。
2.2.1 复杂度的阶
考虑前面讨论过的两个循环计数算法。对于问题规模为n的情况,第一个循环算法会执行n次;第二个循环算法包含一个迭代n^2次的嵌套循环。在n还比较小的时候,这两种算法完成的工作量是差不多的,但是随着n的逐渐增大,它们完成的工作量也越来越不同。图2-2和表2-1展示了这种差异。
图2-2 测试程序所完成工作量的示例图
第一个算法的工作量与问题规模成正比(问题规模为10则工作量为10;问题规模为20则工作量为20;以此类推),其复杂度的阶是线性的(linear)。
第二种算法的工作量随问题规模的平方(问题规模为10时,工作量为100)而增长,其复杂度的阶是平方(quadratic)的。
可见,这两个算法的性能在复杂度的阶(order of complexity)上是不一样的,随着问题规模的增大,具有较高复杂度阶的算法性能会更快地变差。
表2-1 测试程序所完成的工作量
在算法分析中通常还有其他几个复杂度的阶:常数(constant)阶、对数(logarithmic)阶、指数(exponential)阶。
图2-3和表2-2总结了算法分析中常见的复杂度的阶。
图2-3 常见复杂度的阶示例图
表2-2 常见复杂度的阶
2.2.2 大O表示法
在算法中用来表示算法的效率或计算复杂度的一种方法被称为大O表示法(big-O notation)。在这里,“O”代表“在……阶”,指的是算法工作的复杂度的阶。因此,线性算法复杂度记为O(n)。
2.2.3 比例常数
比例常数(constant of proportionality)包含在大O分析中被忽略的项和系数。比如,线性时间算法所执行的指令工作量:work = 2 *size,则比例常数就是work/size,也就是2。
现在,看看下面算法的代码比例常数:
work = 1
for x in range(n):
work += 1
work -= 1
这个算法执行的抽象工作量就是1+2n。尽管它会大于n,但这其工作量2n的运行时会以线性速率增加,也就是说,它的比例常数1、2,运行时是O(n)。
2.3 查找算法
2.3.1 最小值查找
先看下面代码:
def indexOfMin(lsta):
"""查找最小值的下标."""
min_index = 0
current_index = 1
while current_index < len(lsta):
if lsta[current_index] < lsta[min_index]:
min_index = current_index
current_index += 1
return min_index
要保证算法能够找到最小元素的位置,就必须要访问列表里的每个元素,这个工作是在while循环内的if语句比较后完成的。因此,这个算法是对大小为n的列表进行n-1次比较,也就是说,它的复杂度为O(n)。
2.3.2 顺序查找列表
在任意的元素列表里,从位于第一个位置的元素开始,按顺序查找特定的目标元素,直到最后一个位置,这种搜索称为顺序搜索(sequential search)或线性搜索(linear search)。在找到目标元素时返回元素的索引,否则返回−1。
下面是顺序搜索函数的Python实现:
def sequentialSearch(target,lsty):
position = 0
while position < len(lsty):
if target == lsty[position]:
return position
position += 1
return -1
lsa = [3,5,7,0,3,1,9,6]
print(sequentialSearch(0,lsa))
要注意比较顺序搜索的分析和最小值搜索的有哪些不同。
2.3.3 最好情况、最坏情况以及平均情况下的性能
有些算法的性能取决于需要处理的数据所在列表中的位置。比如,对顺序搜索的分析需要考虑下面3种情况:
● 在最坏情况下,目标元素位于列表的末尾或者根本就不在列表里。所以,最坏情况的复杂度为O(n)。
● 在最好情况下,在第一个位置就找到目标元素,只需要O(1)的复杂度。
● 要确定平均情况,就需要把每个可能位置找到目标所需要的迭代次数相加,然后再将它们的总和除以n。因此,平均情况的复杂度是O(n)。
2.3.4 基于有序列表的二分查找
在数据有序的情况下,可以使用二分查找。先看代码:
def binarySearch(target,lsty):
left = 0
right = len(lsty) -1
while left <= right:
midpoint = (left + right) // 2
if target == lsty[midpoint]:
return midpoint
elif target < lsty[midpoint]:
right = midpoint - 1
else:
left = midpoint + 1
return -1
lsa = [0,1,3,5,6,7,9,10,12]
print(binarySearch(7,lsa))
在开始查找之前,要确保列表里的元素是有序的(此处为升序排序):
● 首先查找列表的中间位置上的元素,并把中间位置元素与目标元素进行比较,如果匹配,那么就返回当前位置。
● 如果目标元素小于当前元素,则在中间位置之前部分的中间位置继续查找;
● 如果目标元素大于当前元素,则在中间位置之后部分的中间位置继续查找。
●在找到了目标元素或者当前开始位置大于当前结束位置时,停止查找过程。
算法里只有一个循环,并且没有嵌套或隐藏的循环,如果目标不在列表里,就会得到最坏情况。最坏情况下的复杂度为O()
图2-4展示了在包含9个元素的列表里,通过二分算法查找并不在列表里的目标元素10时,对列表进行的分解。(可以看到,原始列表左半部分中的任何元素都不会被访问。)
图2-4 二分搜索10时所访问的列表元素
对目标元素10的二分查找,对于包含9个元素的列表来说,最多需要4次比较,而对于包含1000000个元素的列表最多需要20次比较就能完成查找。
当然,为了让列表能够有序,二分查找需要付出额外的排序成本。
2.3.5 比较数据元素
二分查找和最小值查找都有一个假设,那就是“列表里的元素彼此之间是可以比较的”。在Python里,这也意味着这些元素属于同一个类型,并且它们可以识别比较运算符==、<和>。几种Python内置的类型对象,如数字、字符串和列表,均支持使用这些运算符进行比较。
为了能够让算法对新的类对象使用比较运算符==、<和>,程序员应在这个新的类里定义__eq__、__lt__和__gt__方法。在定义了这些方法之后,其他比较运算符的方法将自动生成。
比如,SavingsAccount对象可能包含3个数据字段:名称、PIN(密码)以及余额。假定这个账户对象应该按照名称的字母顺序对它进行排序,那么就需要按照下面的方式来实现__lt__方法。代码如下:
class SavingsAccount:
def __init__(self, name, pin, balance = 0.0):
self.name = name
self.pin = pin
self.balance = balance
def __lt__(self, other):
return self.name < other.name
# Other methods, including __eq__
s1 = SavingsAccount("Ken", "1001", 0)
s2 = SavingsAccount("Bill", "1001", 30)
print(s1 < s2)
这样,就可以把账户放在列表中,并按照名称对它进行排序了。
2.4 基本的排序算法
Python排序函数都将被编写为可以在整数列表上运行,并且都会用swap函数交换列表中两个元素的位置。该函数的代码如下:
def swap(lsty, i, j):
temp = lsty[i]
lsty[i] = lsty[j]
lsty[j] = temp
2.4.1 选择排序
选择排序(selection sort)策略是:
●首先,在未排序列表中查找到最小的元素,如果它不在第一个位置,那么将它和第一个位置上的元素交换,第一个位置就是有序的了;
●接下来,从未排序的第二个位置开始向后重复上述查找过程,找到后面最小的元素并与第二个位置上的元素进行交换;
●继续重复以上步骤,当从列表最后位置开始和结束的时候(可以抽象理解为:最后位置上的元素与自已交换位置),这个列表就已经是有序的了。
代码如下:
def selectionSort(lsty):
for i in range(len(lsty)-1):
mid_indx = i
for j in range(i,len(lsty)):
if lsty[mid_indx] > lsty[j]:
mid_indx = j
lsty[i],lsty[mid_indx] = lsty[mid_indx],lsty[i]
print(lsty)
nums = [5,3,6,7,4,1,2,9]
selectionSort(nums)
图2-5 选择排序期间数据的踪迹
选择排序的复杂度都是O()。
对于大型数据集来说,交换元素的成本可能会很高。所以在外部循环里对数据元素进行交换,额外成本是线性的。
2.4.2 冒泡排序
冒泡排序(bubble sort)策略是:
●从第一个数开始,依次比较相邻的两个数,将小数放在前面,大数放在后面,经过一轮比较后,最大的数将位于最后一个位置。
●然后继续从第一个数开始,依次比较相邻数,直到将倒数第二大的数置于倒数第二位置。
●如此重复,直到所有数都完成排序。
●这个过程就好像一个个气泡逐渐从水底浮到了水面上。
见下图:
图2-6 冒泡排序期间数据的踪迹
如果在主循环的一次遍历中没有进行任何交换,那么说明整个列表已经是有序的了。可以使用标志来追踪有没有发生交换。
def bubbleSort(lsty):
for i in range(len(nums),0,-1):
flag = 0
for j in range(i-1):
if nums[j] > nums[j+1]:
nums[j],nums[j+1] = nums[j+1],nums[j]
flag = 1
if not flag:
break
print((lsty)
nums = [3,38,5,44,15,7]
bubbleSort(nums)
冒泡排序的复杂度也是O()。
2.4.3 插入排序
插入排序法(nsertion sort)是将列表中的元素逐一与已排序好的数据进行比较,进而找到合适的位置并插入。打扑克牌时的抓牌过程就是典型的插入排序。
●将待排序列表第一个元素看做一个有序列表,把第二个元素到最后一个元素当成是未排序列表。
●从头到尾依次扫描未排序列表,将扫描到的每个元素插入有序列表的适当位置(如果待插入的元素与有序列表中的某个元素相等,则将待插入元素插入到相等元素的后面)。
见下图:
#代码一:
def insertSort(data):
for i in range(1,len(data)): #第一个元素为有序
j = i -1
#如果当前修士小于前一个元素,临时保留当前值,将前一个元素后移一位
if data[i] < data[j]:
temp = data[i]
data[j+1] =data[j]
#继续往前找,如果有比临时大的数字,则后移一位,直到找到比临时小的
#或到达列表的第一个元素
j -= 1
while j >= 0 and temp < data[j]:
data[j+1] = data[j]
j -= 1
#将临时插入合适位置
data[j+1] = temp
print(data)
nums = [58,29,86,69,10]
insertSort(nums)
# 代码二:
def insertSort(data):
for i in range(len(data)):
temp = data[i]
j = i -1
while j >= 0 and temp < data[j]:
data[j+1] = data[j]
j -= 1
data[j+1] = temp
print(data)
nums = [58,29,86,69,10]
insertSort(nums)
插入排序在最坏情况下复杂度是O()。
列表里有序元素越多,插入排序的性能就会越好,在有序列表的最好情况下,排序复杂度是线性的。但是,在平均情况下,插入排序仍然有平方阶的复杂度。
2.4.4 再论最好情况、最坏情况以及平均情况下的性能
要对算法复杂度进行详细分析,应把它的行为分为3种情况:
● 最好情况(best case)——算法在什么情况下可以以最少的工作量完成工作?在最好情况下,算法的复杂度是多少?
● 最坏情况(worst case)——算法在什么情况下需要完成最多的工作量?在最坏情况下,算法的复杂度是多少?
● 平均情况(average case)——算法在什么情况下用适量的工作量就能完成工作?在平均情况下,算法的复杂度是多少?
无论是选择一个算法还是开发一个新算法,都需要意识到这些区别。
2.5 更快的排序
2.5.1 快速排序
快速排序法(quickly Sort)又称为分割交换法,是对冒泡排序法的一种改进。基策略是:
● 取列表中的一个数作为基准值(pivot),把所有小于基准值的数都放在它的一侧,再把所有大于基准值的数都放在它的另一侧。
● 随后,对基准值左右两侧的列表再分别进行快速排序(即再用同样的方式处理左右两边的数据,直到排序完成为止)。
快速排序是通过分治思想来排序的。
例如,有n项数据,数据值用K1, K2, …, Kn来表示。其快速排序法操作步骤如下:
(1)先在数据中假设一个虚拟中间值K(为了方便,一般取第一个位置上的数)。
(2)从左向右查找数据Ki,使得Ki>K,Ki的位置数记为i。
(3)从右向左查找数据Kj,使得Kj<K,Kj的位置数记为j。
(4)若i<j,数据Ki与Kj交换,并回到步骤(2)。
(5)若i≥j,数据K与Kj交换,并以j为基准点分割成左右两部分,然后针对左右两部分再进行步骤(1)~步骤(5),直到左半边数据等于右半边数据为止。
例如,有这样一组数据:6, 1, 2, 7, 9, 3, 4, 5, 10, 8,如图4.41所示。采用快速排序法递增排序,步骤如下:
重复上述步骤,对基准元素左边数据时行排序:
重复上述步骤,对基准元素右边数据时行排序:
结合左半边排序和右半边排序,c:
代码如下:
def quickSort(data,start,end):
if start > end :#data为空列表
return
i,j = start,end #初始化左右指针
pivot = data[start] #取第1个位置数为基准元素
while i < j: #边界条件
while j > i and data[j] >= pivot:#从右往左找小于基准的数
j -= 1
while i < j and data[i] <= pivot:#从左往右找大于基准的数
i += 1
if i < j : #判断停止的i、j,看是否分别找到相应的(大于基准、小于基准)数
data[i],data[j] = data[j],data[i] #如果是,相互交换
elif i >= j:
data[start],data[j] = data[j],data[start] #交换基准元素与J位置数
quickSort(data,start,i-1) #对基准元素的左半边进行排序
quickSort(data,i+1,end) #对基准元素的右半边进行排序
data1 = [6,1,2,7,9,3,4,5,10,8]
quickSort(data1,0,(len(data1)-1))
print(data1)
快速排序算法,在最好情况下的性能为O(nlogn);在最坏情况下,快速排序算法的性能为O()。