数据结构与算法(一)(Python版)
文章目录
- 递归动规
- 初识递归:数列求和
- 递归三定律
- 递归的应用:任意进制转换
- 递归的应用:斐波那契数列
- 递归调用的实现
- 分治策略与递归
- 优化问题和贪心策略
- 找零兑换问题
- 贪心算法和动态规划的区别
- 贪心策略解决找零兑换问题
- 找零兑换问题的递归解法
- 找零兑换问题的动态规划解法
- 排序与查找
- 顺序查找算法及分析
- 顺序查找:无序表查找
- 顺序查找:有序表查找
- 二分查找算法及分析
- 递归算法实现二分查找
- 二分查找与顺序查找的对比
- 冒泡排序算法及分析
- 冒泡排序优化
- 冒泡排序与其他排序算法对比
- 选择排序算法及分析
- 插入排序算法及分析
- 谢尔排序算法及分析
- 归并排序算法及分析
- 快速排序算法及分析
递归动规
递归是一种解决问题的方法,其精髓在于将问题分解为规模更小的相同问题,持续分解,直到问题规模小到可以用非常简单直接的方式来解决。递归的问题分解方式非常独特,其算法方面的明显特征就是:在算法流程中调用自身。
初识递归:数列求和
问题: 给定一个列表,返回所有数的和。列表中数的个数不定,需要一个循环和一个累加变量来迭代求和。
循环:
def listnum(numlist):
thesum = 0
for i in numlist:
thesum = thesum + i
return thesum
print(listnum([1,2,3,4,5]))
递归:
数列的和=“首个数”+“余下数列”的和
def digui(numlist):
if len(numlist) == 1:
return numlist[0]
else:
return numlist[0] + digui(numlist[1:])
print(digui([1,2,3,4,5]))
递归三定律
递归的应用:任意进制转换
def jinzhi(n,base):
convertstring = "0123456789ABCDEF" ## 查表
if n < base:
return convertstring[n]
else:
return jinzhi(n//base,base) + convertstring[n%base]
print(jinzhi(14,2))
递归的应用:斐波那契数列
def feibo(n):
if n == 1 or n ==2:
return 1
else:
return feibo(n-1) + feibo(n-2)
return feibo[n]
print(feibo(3))
递归调用的实现
在调试递归算法程序的时候经常会碰到这样的错误:RecursionError
,是因为递归的层数太多,系统调用栈容量有限。这时候要检查程序中是否忘记设置基本结束条件,导致无限递归,或者向基本结束条件演进太慢,导致递归层数太多,调用栈溢出。
在Python
内置的sys
模块可以获取和调整最大递归深度。
分治策略与递归
优化问题和贪心策略
找零兑换问题
贪心算法和动态规划的区别
- 贪心算法每一步的最优解一定包含上一步的最优解,上一步之前的最优解则不作保留;动态规划全局最优解中不一定包含前一个局部最优解,因此需要记录之前的所有的局部最优解。
- 贪心不能保证最后解是最佳的,一般复杂度低;而动态规划本质是穷举法,可以保证结果是最佳的,复杂度高。
贪心策略解决找零兑换问题
因为我们每次都试图解决问题的尽量大的一部分对应到兑换硬币问题,就是每次以最多数量的最大面值硬币来迅速减少找零面值。
找零兑换问题的递归解法
def recMc(coinvaluelist,change):
mincoins = change
if change in coinvaluelist:
return 1 ##最小规模:直接返回
else:
for i in [c for c in coinvaluelist if c <= change]:
numcoins = 1 + recMc(coinvaluelist,change - i) ##减小规模:每次减去一种硬币面值,挑选最小数量
if numcoins < mincoins:
mincoins = numcoins
return mincoins
print(recMc([1,5,10,25],63))
递归解法虽然能解决问题,但其最大的问题是:极其低效。
对这个递归解法进行改进的关键就在于消除重复计算。
我们可以用一个表将计算过的中间结果保存起来,在计算之前查表看看是否已经计算过,这个算法的中间结果就是部分找零的最优解,在递归调用过程中已经得到的最优解被记录下来。在递归调用之前,先查找表中是否已有部分找零的最优解,如果有,直接返回最优解而不进行递归调用。如果没有,才进行递归调用。
改进后的解法,极大减少了递归调用次数对63分兑换硬币问题,仅仅需要221次递归调用是改进前的三十万分之一,瞬间返回。
找零兑换问题的动态规划解法
中间结果记录可以很好解决找零兑换问题实际上,这种方法还不能称为动态规划,而是叫做“memoization
(记忆化/函数值缓存)”的技术提高了递归解法的性能。
动态规划算法采用了一种更有条理的方式来得到问题的解,找零兑换的动态规划算法从最简单的“1分钱找零”的最优解开始,逐步递加上去,直到我们需要的找零钱数。在找零递加的过程中,设法保持每一分钱的递加都是最优解,一直加到求解找零钱数,自然得到最优解。
递加的过程能保持最优解的关键是,其依赖于更少钱数最优解的简单计算,而更少钱数的最优解已经得到了。
问题的最优解包含了更小规模子问题的最优解,这是一个最优化问题能够用动态规划策略解决的必要条件。
排序与查找
顺序查找算法及分析
如果数据项保存在如列表这样的集合中,我们会称这些数据项具有线性或者顺序关系。在Python List中,这些数据项的存储位置称为下标【index】,这些下标都是有序的整数。通过下标,我们就可以按照顺序来访问和查找数据项,这种技术称为“顺序查找"。
要确定列表中是否存在需要查找的数据项,首先从列表的第1个数据项开始,按照下标增长的顺序,逐个比对数据项,如果到最后一个都未发现要查找的项,那么查找失败。
顺序查找:无序表查找
def sequentialsearch(alist,num):
pos = 0
found = False ##注意found初始值为False
while pos < len(alist) and not found:
if alist[pos] == num:
found = True
else:
pos += 1
return found
testlist = [11,22,33,44,55]
print(sequentialsearch(testlist,33))
时间复杂度为O(n)
。
顺序查找:有序表查找
当数据项存在时,比对过程与无序表完全相同。
不同之处在于,如果数据项不存在,比对可以提前结束。
def ordsequentialsearch(alist,num):
pos = 0
found = False
stop = False
while pos < len(alist) and not found and not stop:
if alist[pos] == num:
found = True
else:
if alist[pos] > num:
stop = True
else:
pos += 1
return found
testlist = [1,2,3,4,6]
print(ordsequentialsearch(testlist,5))
实际上,就算法复杂度而言,仍然是O(n)
。只是在数据项不存在的时候,有序表的查找能节省一些比对次数,但并不改变其数量级。
二分查找算法及分析
def binarysearch(alist,num):
first = 0
last = len(alist) - 1
found = False
while first <= last and not found:
mid = (last + first) // 2
if num == alist[mid]:
found = True
else:
if num > alist[mid]:
first = mid + 1
else:
last = mid - 1
return found
alist = [1,2,3,13,45,67]
print(binarysearch(alist,4))
print(binarysearch(alist,13))
二分查找算法实际上体现了解决问题的典型策略:分而治之,将问题分为若干更小规模的部分,通过解决每一个小规模部分问题,并将结果汇总得到原问题的解。
递归算法实现二分查找
def binarysearch(alist,num):
if len(alist) == 0:
return False #递归结束条件
else:
mid = len(alist) // 2
if alist[mid] == num:
return True
else:
if alist[mid] < num:
return binarysearch(alist[mid+1:],num)
else:
return binarysearch(alist[:mid-1],num)
alist = [1,2,3,13,45,67]
print(binarysearch(alist,4))
print(binarysearch(alist,13))
所以二分法查找的算法复杂度是O(log n)
,实际代码中切片操作O(k)
会使算法时间复杂度增加。
二分查找与顺序查找的对比
另外,虽然二分查找在时间复杂度上优于顺序查找,但也要考虑到对数据项进行排序的开销。如果一次排序后可以进行多次查找,那么排序的开销就可以摊薄。但如果数据集经常变动,查找次数相对较少,那么可能还是直接用无序表加上顺序查找来得经济。所以,在算法选择的问题上,光看时间复杂度的优劣是不够的,还需要考虑到实际应用的情况。
冒泡排序算法及分析
冒泡排序的算法思路在于对无序表进行多趟比较交换,每趟包括了多次两两相邻比较,并将逆序的数据项互换位置,最终能将本趟的最大项就位。经过n-1趟比较交换,实现整表排序【相当于一趟才能保证第一个数字最大项或者最小项就位】每趟的过程类似于“气泡”在水中不断上浮到水面的经过。
第1趟比较交换,共有n-1对相邻数据进行比较。一旦经过最大项,则最大项会一路交换到达最后一项。第2趟比较交换时,最大项已经就位,需要排序的数据减少为n-1,共有n-2对相邻数据进行比较,直到第n-1趟完成后,最小项一定在列表首位,就无需再处理了。
def bubblesort(alist):
for passnum in range(len(alist)-1,0,-1):
for i in range(passnum):
if alist[i] > alist[i+1]:
# temp = alist[i]
# alist[i] = alist[i+1]
# alist[i+1] = temp
alist[i],alist[i+1] = alist[i+1],alist[i]
alist = [54,26,93,17,77,31,44,55,20]
bubblesort(alist)
print(alist)
无序表初始数据项的排列状况对冒泡排序没有影响。算法过程总需要n-1
趟,随着趟数的增加,比对次数逐步从n-1
减少到1
,并包括可能发生的数据项交换。比对的时间复杂度是O(n2)
。
最好的情况是列表在排序前已经有序,交换次数为0。最差的情况是每次比对都要进行交换,交换次数等于比对次数,平均情况则是最差情况的一半。交换次数也是O(n2)
冒泡排序优化
通过监测每趟比对是否发生过交换,可以提前确定排序是否完成,这也是其它多数排序算法无法做到的。
def bubblesort(alist):
exchange = True #注意exchange值的多次改变
passnum = len(alist) - 1
while passnum and exchange:
exchange = False
for i in range(passnum):
if alist[i] > alist[i+1]:
exchange = True
alist[i],alist[i+1] = alist[i+1],alist[i]
passnum = passnum - 1
alist = [54,26,93,17,77,31,44,55,20]
bubblesort(alist)
print(alist)
冒泡排序与其他排序算法对比
冒泡排序通常作为时间效率较差的排序算法,来作为其它算法的对比基准。其效率主要差在每个数据项在找到其最终位置之前,必须要经过多次比对和交换,其中大部分的操作是无效的。但有一点优势,就是无需任何额外的存储空间开销,适应性比较广,如链式也可以进行操作。
另外,通过监测每趟比对是否发生过交换,可以提前确定排序是否完成,这也是其它多数排序算法无法做到的。如果某趟比对没有发生任何交换,说明列表已经排好序,可以提前结束算法。
选择排序算法及分析
选择排序对冒泡排序进行了改进,保留了其基本的多趟比对思路,每趟都使当前最大项就位。但选择排序对交换进行了削减,相比起冒泡排序进行多次交换,每趟仅进行1次交换,记录最大项的所在位置,最后再跟本趟最后一项交换,选择排序的时间复杂度比冒泡排序稍优比对次数不变,还是o(n2)
,交换次数则减少为o(n)
。
def selectionsort(alist):
for passnum in range(len(alist)-1,0,-1):
positionmax = 0
for i in range(1,passnum+1):
#这里因为设置最大位置初始值索引为0,所以需要从列表索引为1开始比较
if alist[i] > alist[positionmax]:
positionmax = i
alist[positionmax],alist[passnum] = alist[passnum],alist[positionmax]
alist = [54,26,93,17,77,31,44,55,20]
selectionsort(alist)
print(alist)
插入排序算法及分析
时间复杂度还是o(n2)
,插入排序的思想类似于整理扑克牌。第1趟,子列表仅包含第1个数据项,将第2个数据项作为“新项”插入到子列表的合适位置中,这样已排序的子列表就包含了2个数据项。第2趟,再继续将第3个数据项跟前2个数据项比对,并移动比自身大的数据项,空出位置来,以便加入到子列表中,经过n-1趟比对和插入,子列表扩展到全表,排序完成。
def insert(alist):
for index in range(1,len(alist)):
currentvalue = alist[index] #插入项
position = index
while position > 0 and alist[position] > currentvalue :
alist[position] = alist[position - 1]
position = position -1
alist[position] = currentvalue
alist = [11,23,31,24,56]
insert(alist)
print(alist)
谢尔排序算法及分析
谢尔排序以插入排序为基础,对无序表进行“间隔”划分子列表,每个子列表都执行插入排序。随着子列表的数量越来越少,无序表的排序越来越接近有序,从而减少整体排序的比对次数。
最后一趟是标准的插入排序,但由于前面几趟已经将列表处理到接近有序,这一趟仅需少数几次移动即可完成。
归并排序算法及分析
归并排序是递归算法,思路是将数据表持续分裂为两半,对两半分别进行归并排序。
递归的基本结束条件是:数据表仅有1个数据项,自然是排好序的;缩小规模:将数据表分裂为相等的两半,规模减为原来的二分之一;调用自身:将两半分别调用自身排序,然后将分别排好序的两半进行归并,得到排好序的数据表。
def mergesort1(alist):
if len(alist) > 1: #基本结束条件
mid = len(alist) // 2
lefthalf = alist[:mid]
righthalf = alist[mid:]
mergesort1(lefthalf) #递归调用
mergesort1(righthalf)
i = j = k = 0
while i < len(lefthalf) and j < len(righthalf): ## 拉链式交错把左右半部从小到大归并到结果列表
if lefthalf[i] < righthalf[j]:
alist[k] = lefthalf[i]
i = i + 1
else:
alist[k] = righthalf[j]
j = j + 1
k = k + 1
def mergesort2(alist):
if len(alist) > 1: #基本结束条件
mid = len(alist) // 2
lefthalf = alist[:mid]
righthalf = alist[mid:]
mergesort2(lefthalf) #递归调用
mergesort2(righthalf)
i = j = k = 0
while i < len(lefthalf): #归并左半部剩余项
alist[k] = lefthalf[i]
i = i + 1
k = k + 1
while j < len(righthalf): ##归并右边半部剩余项
alist[k] = righthalf[i]
j = j + 1
k = k + 1
def mergesort3(alist):
# 递归结束条件
if len(alist) <= 1:
return alist
# 分解问题,并递归调用
mid = len(alist) // 2
left = mergesort3(alist[:mid])
right = mergesort3(alist[mid:])
#合并左右半部,完成排序
merged = []
while left and right:
if left[0] <= right[0]:
merged.append(left.pop(0))
else:
merged.append(right.pop(0))
merged.extend(right if right else left)
return merged
alist = [54,26,93,17,77,31,44,55,20]
mergesort1(alist)
print(alist)
将归并排序分为两个过程来分析:分裂和归并。
分裂的过程,借鉴二分查找中的分析结果,是对数复杂度,时间复杂度为O(log n)
。归并的过程,相对于分裂的每个部分,其所有数据项都会被比较和放置一次,所以是线性复杂度,其时间复杂度是O(n)
。
综合考虑,每次分裂的部分都进行一次o(n)
的数据项归并,总的时间复杂度为
o(nlog n)
最后,我们还是注意到两个切片操作。为了时间复杂度分析精确起见,可以通过取消切片操作,改为传递两个分裂部分的起始点和终止点,也是没问题的,只是算法可读性稍微牺牲一点点。我们注意到归并排序算法使用了额外1倍的存储空间用于归并,这个特性在对特大数据集进行排序的时候要考虑进去。
快速排序算法及分析
快速排序的思路是依据一个“中值”数据项来把数据表分为两半:小于中值的一半和大于中值的一半,然后每部分分别进行快速排序(递归)。如果希望这两半拥有相等数量的数据项,则应该找到数据表的“中位数”,但找中位数需要计算开销!要想没有开销,只能随意找一个数来充当“中值”。
快速排序的递归算法“递归三要素”如下基本结束条件:
数据表仅有1个数据项,自然是排好序的。
缩小规模:根据“中值”,将数据表分为两半,最好情况是相等规模的两半。
调用自身:将两半分别调用自身进行排序(排序基本操作在分裂过程中)。