《数据结构与算法(Python版)》
北京大学地球与空间科学学院|陈斌|2019年授课
Bilibili课程链接:【北京大学】数据结构与算法Python版(完整版)_哔哩哔哩_bilibili
《数据结构与算法(Python版)》
目录
1.引子
1.1图灵机模型
-
图灵机不是机器,而是一种模型。
-
图灵机模拟器软件Visual Turing。
1.2突破计算的极限
1.3如何评判程序的好坏
计算资源指标(Lee Code也采用了这两种指标)
(1)存储空间或内存(受问题自身数据规模影响,哪些是描述问题本身占用、哪些是算法占用,不容易判别)
(2)算法的执行时间(算法的实际运行时间)
程序开始之前和结束之后分别进行计时,python有time模块获取计算机系统当前时刻
import time
start_time = time.time() #time.time是浮点数,从1970年1月1日0分0秒开始计时
end_time = time.time()
run_time = end_time - start_time
#避免随机性可运行多次,以五次为例
for i in range(5):
print('Time required %10.7 seconds',%f(10000)) #f为某一函数,返回run_time
但是关于运行时间检测的问题实际上是有问题的,用不同语言编写或在不同机器(超级计算机、普通计算机、单片机)上运行,是有差异的。
1.4大O表示法
算法时间度量指标
-
一个算法所实施的操作数量或步骤数可作为独立于具体程序的度量指标。
-
程序设计语言包含三种控制流语句和赋值语句,赋值语句是个合适的选择。一条赋值语句同时包含了(表达式)计算和(变量)存储两个基本资源。而控制流语句仅仅起到了组织语句的作用。
1.5“变位词”判断问题
-
解法一:逐个对比
-
解法二:排序一一比对
-
解法三:暴力求解-全排列
-
解法四:计数比较
ord()
它以一个字符(长度为1的字符串)作为参数,返回对应的 ASCII 数值,或者 Unicode 数值,字母的编码也是连续的。如果所给的 Unicode 字符超出了你的 Python 定义范围,则会引发一个 Type Error 的异常。
注:解法四时间复杂度最优,但是占用内存大,需要释放26个字符,如果是汉字变位词更加复杂,因此解法四是以空间换时间的做法。
2. Python数据类型
2.1列表和字典性能对比
-
列表
list
和字典dict
是两种重要的数据结构。
1.1 列表
-
list类型各种操作(interface)的实现方法有很多,总的方案是让最常用的操作性能最好,牺牲不太常用的操作。80%的功能使用率只有20%。
-
最常用的是按索引取值和赋值(
v=a[i],a[i]=v
):由于列表随机访问特性,这两个操作执行时间与列表大小无关,均为O(1)。 -
列表增长可以选择
append()
和+
。
# 添加一个元素
lst.append(v) #执行时间是O(1)
# 增长一个列表
lst = lst+[v] #执行时间是O(n+k),n是lst的长度,k是v的长度
注:使用timeit
模块对函数计时,timeit
中的Timer对象有两个参数,第一个参数是反复执行的语句,是字符串的形式;第二个参数是“安装语句”。
from timeit import Timer
t1 = Timer("test1()","from__main__import test1")
print("concat %f seconds\n"%t1.timeit(number=1000))
-
list.pop
的计时实验
# 长度200万的列表,执行1000次
import timeit
x = list(range(2000000))
popzero = timeit.Timer("x.pop(0)","from __main__ import x") # O(n)
popend = timeit.Timer("x.pop()","from __main__ import x") #(1)
print(popzreo.timeit(number = 1000)) #3.8991717999999977
print(popend.timeit(number = 1000)) #0.00041329999999817346
1.2 字典
-
字典与列表不同,根据关键码(key)找到数据项,而列表根据位置(index)
-
常用的操作:get(取值)、set(赋值)、contains(in)(判断字典中是否存在某个关键码(key),上述三种操作的性能均为O(1).
-
list
和dict
的in
操作对比:
利用in和not in操作符,可以确定一个值是否在列表中。像其他操作符一样,in和not in可以用在表达式中。
import timeit
import random
for i in range(10000,1000001,20000):
t = timeit.Timer("random.randrange(%d) in x"%i,"from __main__ import random,x")
# 列表
x = list(range(i))
lst_time = t.timeit(number = 1000)
x = {j:None for j in range(i)}
d_time = t.timeit(number = 1000)
print("%d,%10.3f,%10.3f"%(i,lst_time,d_time)
原因分析:python中list
对象的存储结构采用的是线性表,因此其查询复杂度为O(n),而dict
对象的存储结构采用的是散列表,其在最优情况下查询复杂度为O(1)。
Python时间复杂度官网:
2.2线性结构
-
线性结构是一种有序数据项的集合,其中每个数据项都有唯一的前驱和后继。
-
线性结构总有两端,在不同情况下称呼不一样(前后、左右、顶底)。
-
两端的称呼并不是关键,不同线性结构的两端区别在于数据增减的方式。有的结构只允许数据项从一端添加,有的结构则允许 数据项从两端移除。
-
从4个最简单但功能强大的结构入手,开始研究数据结构:栈Stack、队列Queue、双端队列Deque、列表List。这些数据结构的共同点在于,数据项之间只存在先后的次序关系,都是线性结构。
2.1 栈Stack
-
一种有次序的数据项集合,在栈中数据的加入和移除都仅发生在同一端,这一端叫栈“顶top”,另一端叫栈“底base”。
-
距离栈底越近的数据项,留在栈中的时间就越长,而最新加入栈的数据项会被最先移除,这种次序通常称为“后进先出LIFO”。
-
栈的特性:反转次序,如计算机网页访问、Word里的撤销按钮。
说明:ADT(Abstract Data Type 抽象数据结构)
,在计算机科学中除了基本类型如 int、long、char 等,大多数的程序还需要用到其他集成更多细节的抽象数据结构,如栈(Stack)、队列(Queue)、树(Tree)、图(Graph) 等,许多复杂算法的实现必须借助特定的数据结构才能够实现。
-
用列表实现
ADT Stack
list左端还是右端作为栈顶,性能有所区别。栈顶为左端时push/pop的复杂度为O(n),而栈顶为右端的实现push/pop的复杂度为O(1)。
2.2 栈的应用(1):简单括号匹配
-
LISP语言大量使用括号。
-
括号的使用遵循“平衡”规则,对括号的正确匹配是很多语言编译器的基础算法。
2.3 栈的应用(2):通用括号匹配
class Stack():
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def push(self,item):
return self.items.append(item)
def pop(self):
return self.items.pop()
def peek(self):
return self.items[len(self.items)-1]
def size(self):
return len(self.items)
def parChecker(symbolString):
s = Stack()
balanced = True
index = 0
while index < len(symbolString) and balanced:
symbol = symbolString[index]
if symbol == '(':
s.push(symbol)
else:
if s.isEmpty():
balanced = False
else:
s.pop()
index += 1
if s.isEmpty() and balanced:
return True
else:
return False
# 测试
print(parChecker('('))
print(parChecker(')'))
print(parChecker(''))
print(parChecker(')('))
print(parChecker('()'))
def matches(open,close):
opens = '{[('
closes = '}])'
return opens.index(open) == closes.index(close)
def parChecker(symbolString):
s = Stack()
balanced = True
index = 0
while index < len(symbolString) and balanced:
symbol = symbolString[index]
if symbol in '{[(': # 只判断三种括号
s.push(symbol)
else:
if s.isEmpty():
balanced = False
else:
top = s.pop()
if not matches(top,symbol): #top是open符号,symbol是close符号
balanced = False
index += 1
if s.isEmpty() and balanced:
return True
else:
return False
2.4 栈的应用(3):进制转换
十进制转二进制是“除以二”的过程,得到的余数是从低到高的次序,而输出则是从高到低,所以需要一个栈来次序反转。
def divideBy2(decnumber):
remstack = Stack()
while decnumber > 0:
rem = decnumber % 2
remstack.push(rem)
decnumber = decnumber // 2
binstring = ""
while not remstack.isEmpty():
binstring = binstring + str(remstack.pop())
return binstring
print(divideBy2(42))
十进制转二进制的算法很容拓展到N进制,计算机另外两种常用的进制就是八进制和十六进制。日常生活中还常用六十进制。
def baseConverter(decnumber,base):
digits = "0123456789ABCDEF"
remstack = Stack()
while decnumber > 0:
rem = decnumber % base
remstack.push(rem)
decnumber = decnumber // base
binstring = ""
while not remstack.isEmpty():
binstring = binstring + digits[remstack.pop()]
return binstring
print(baseConverter(42,16))
2.5 栈的应用(4):表达式转换
-
中缀表达式如B*C,操作符(operator)介于操作数(operand)中间的表示法,称为“中缀”表示法。中缀表示法会引起混淆。
-
引入操作符“优先级”的概念来消除混淆,嵌套在括号内的优先级更高。
-
计算机处理最好能明确所有的计算顺序,引入全括号表达式:在所有表达式两边都加上括号。
-
前缀和后缀表达式:“+AB”、“AB+”。操作符的次序完全决定了运算的次序,不再有混淆。
-
通用的中缀转后缀算法具有反转特性,操作数按次序扫描,而操作符次序反转,算法示例:
def InfixToPostfix(infixexpr):
# 定义字典记录操作符优先级
prec = {}
prec["*"] = 3
prec["/"] = 3
prec["+"] = 2
prec["-"] = 2
prec["("] = 1
opStack = Stack() # 记录操作符优先级的栈
postfixList = [] # 存放后缀表达式
tokenList = infixexpr.split() # 解析表达式到单词列表
for token in tokenList:
# 操作数
if token in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" or token in "0123456789":
postfixList.append(token)
elif token == '(':
opStack.push(token)
elif token == ')':
topToken = opStack.pop()
while topToken != '(':
postfixList.append(topToken)
topToken = opStack.pop()
# 操作符
else:
while (not opStack.isEmpty()) and \
(prec[opStack.peek()] >= prec[token]):
postfixList.append(opStack.pop())
opStack.push(token)
while not opStack.isEmpty():
postfixList.append(opStack.pop())
return " ".join(postfixList) # 合成后缀表达式字符串
# 检验
infixexpr = 'A + B * ( 1 + 2 )'
print(InfixToPostfix(infixexpr)) # 输出:A B 1 2 + * +
2.6 栈的应用(5):后缀表达式求值
-
仍然满足栈的特性,操作符只作用于离它最近的两个操作数。
def postfixEval(postfixExpr):
operandStack = Stack()
tokenList = postfixExpr.split()
for token in tokenList:
if token in "0123456789":
operandStack.push(int(token))
else:
operand2 = operandStack.pop()
operand1 = operandStack.pop()
result = doMath(token,operand1,operand2)
operandStack.push(result)
return operandStack.pop()
def doMath(token,op1,op2):
if token == '+':
return op1 + op2
elif token == '-':
return op1 - op2
elif token == '*':
return op1 * op2
else:
return op1 / op2
# 检查
infixexpr = '3 + 2 * ( 1 + 2 )'
print(InfixToPostfix(infixexpr)) # 输出:3 2 1 2 + * +
print(postfixEval(InfixToPostfix(infixexpr))) # 输出:9
2.7 队列(Queue)
-
队列是一种有次序的数据集合,其特征是:新数据项的添加总发生在一端(通常称为“尾端(rear)),而现存数据项的移除总发生在另一端(通常称为”首端(front)“)。
-
当数据项加入队列,首先出现在队尾,随着队首数据项的移除,它逐渐接近队首。
-
这种次序安排的原则称为FIFO,队列仅有一个入口和一个出口,不允许中间移除。
-
利用List来容纳Queue的数据项:将List首端作为队列尾端,List末端作为队列首端。
class Queue():
def __init__ (self):
self.item = []
def isEmpty(self):
return self.items == []
def enqueue(self,item): # 复杂度为O(n)
return self.item.insert(0,item)
def dequeue(self):
return self.item.pop()
def size(self):
return len(self.item)
2.8 队列的应用(1):热土豆问题
-
“击鼓传花”的土豆版本,传烫手的热土豆,鼓声停,手里有土豆的出列。
def hotPotato(namelist,num):
simqueue = Queue()
for name in namelist:
simqueue.enqueue(name)
while simqueue.size()>1:
for i in range(num):
simqueue.enqueue(simqueue.dequeue())
simqueue.dequeue()
return simqueue.dequeue()
print(hotPotato(['Bill','David','Mary','Ted','Fransio','Cute'],5))
2.9 队列的应用(2):打印任务问题
-
模拟算法:打印任务。怎么设定打印机的模式,让大家都不会等太久的前提下尽量提高打印质量。这是一个典型的决策支持问题,但无法通过规则直接计算。
-
对问题进行建模,对问题进行抽象,抛弃对问题实质没有关系的变量如学生年龄、性别等。最后得出3个关键对象:打印任务、打印队列、打印机。
(1)打印任务的属性:提交时间、打印页数
(2)打印队列的属性:具有FIFO性质的打印任务队列
(3)打印机属性:打印速度、是否忙
import random
class Printer:
def __init__(self,ppm):
self.pagerate = ppm # 打印速度page per minute
self.currentTask = None # 打印任务
self.timeRemaining = 0 # 任务倒计时
def tick(self): # 打印1秒
if self.currentTask != None:
self.timeRemaining = self.timeRemaining - 1
if self.timeRemaining <= 0:
self.currentTask = None
def busy(self): # 打印机是否在忙
if self.currentTask != None:
return True
else:
return False
def startNext(self,newtask): # 打印新作业
self.currentTask = newtask
self.timeRemaining = newtask.getPages()*60/self.pagerate
class Task:
def __init__(self,time):
self.timestamp = time # 生成时间戳
self.pages = random.randrange(1,21) # 生成打印页数
def getStamp(self):
return self.timestamp
def getPages(self):
return self.pages
def waitTime(self,currenttime):
return currenttime - self.timestamp
def newPrintTask(): # 以1/180概率生成作业
num = random.randrange(1,181)
if num == 180:
return True
else:
return False
def simulation(numSeconds,pagesPerMinute): # 模拟时间,打印机打印模式
labprinter = Printer(pagesPerMinute)
printQueue = Queue()
waitingtimes = [] # 记录每个任务的等待时间
#程序主体:时间流逝
for currentSecond in range(numSeconds):
if newPrintTask():
task = Task(currentSecond)
printQueue.enqueue(task)
if(not labprinter.busy()) and (not printQueue.isEmpty()):
nexttask = printQueue.dequeue()
waitingtimes.append(nexttask.waitTime(currentSecond))
labprinter.startNext(nexttask)
labprinter.tick()
averageWait = sum(waitingtimes)/len(waitingtimes)
print("Average Wait %6.2f secs %3d tasks remaining." %(averageWait,printQueue.size()))
for i in range(10):
simulation(3600,5)
运行结果:
2.10 双端队列
-
双端队列(Deque)是一种有次序的数据集,跟队列相似,其两端可以称作“首”、“尾”端,但deque中的数据项既可以从队首加入,也可以从队尾加入,数据项也可以从两端移除。从某种意义上说,双端队列集成了栈和队列的能力。
-
但双端队列并不具有内在的LIFO或者FIFO特性,如果用双端队列来模拟栈或者队列,需要由使用者自行维护操作的一致性。
-
List实现ADT Deque:
(1)采用List实现:List下标为0作为deque的尾端,List下标为-1作为deque的首端。
(2)操作复杂度:addFront / removeFront为O(1),addRear / removeRear为O(n)。
class Deque:
def __init__(self):
self.items = []
def isEmpty(self):
return self.items == []
def addFront(self,item):
self.items.append(item)
def addRear(self,item):
self.items.insert(0,item)
def removeFront(self):
return self.items.pop()
def removeRear(self):
return self.items.pop(0)
def size(self):
return len(self.items)
2.11 双端队列的应用:“回文词”判定
def palchecker(aString):
chardeque = Deque()
for ch in aString:
chardeque.addRear(ch)
stillEqual = True
while chardeque.size()>1 and stillEqual:
first = chardeque.removeFront()
last = chardeque.removeRear()
if first != last:
stillEqual = False
return stillEqual
print(palchecker("lshaigids"))
print(palchecker("radar"))
2.12 无序表抽象数据类型
-
在前面基本数据结构的讨论中,我们采用Python List实现了多种线性数据结构。
-
列表是一种简单强大的数据集结构,提供了丰富的操作接口。但不是所有的编程语言都提供了List数据类型,有时候需要程序员自己实现。
-
列表是一种数据项按照相对位置存放的数据集,特别的被称为“无序表Unordered list”。
-
链表实现无序表
链表实现节点Node:
class Node:
def __init__(self,initdata):
self.data = initdata
self.next = None
def getData(self):
return self.data
def getNext(self):
return self.next
def setData(self,newdata):
self.data = newdata
def setNext(self,newnext):
self.next = newnext
-
无序表必须要有对第一个节点的引用信息:设立一个属性head,保存第一个节点的引用空表的head为None。
class UnorderedList:
def __init__(self):
self.head = None
-
链表从head指向的首位最容易插入新数据项,插入首位复杂度O(1),而列表为O(n),因此链表和列表的底层是不一样的。
class UnorderedList:
def __init__(self):
self.head = None
def add(self,item):
temp = Node(item)
temp.setNext(self.head) # Step1
self.head = temp # Step2
# Step1和Step2的顺序不能反,否则原来列表执行Step2后源列表丢失,再执行Step1会报错。
-
链表size的复杂度为
O(n)
,而有序表只需返回地址减去第一个地址计算,复杂度为O(1)。
class UnorderedList:
def __init__(self):
self.head = None
def size(self):
current = self.head
count = 0
while current != None:
count = count + 1
current = current.getNext()
return count
def search(self,item):
current = self.head
found = False
while current != None and not found:
if current.getData() == item:
found = True
else:
current = current.getNext()
return found
-
链表实现remove(item)方法首先要找到item,这个过程和search一样,但是删除节点时需要特别的技巧。current指向的是当前匹配数据项的节点,而删除需要把前一个节点的next指向current下一个节点。
class UnorderedList:
def __init__(self):
self.head = None
def remove(self,item):
current = self.head
previous = None
found = False
while not found:
if current.getData() == item: # 如果remove的对象不在unorderedList中,这句会报错
found = True
else:
previous = current
current = current.getNext()
# 分两种情况:previous为空和不为空
if previous == None:
self.head = current.getNext()
else:
previous.setNext(current.getNext())
2.13 有序表抽象数据类型
-
有序表是一种数据项依照其某可比性质(如整数大小、字母表先后)来决定在列表中的位置。
-
越“小”的数据项越靠近列表的头,越靠“前”。
-
用链表方法实现有序表:Node定义相同,也设置一个head来保存链表表头的应用。
class OrderedList():
def __init__(self):
self.head = None
# 利用有序表特性可以为search方法节省内存
def search(self,item):
current = self.head
found = False
stop = False
while current != None and not found and not stop
if current.getData() == item:
found = True
else:
if current.getData()>item:
stop = True
else:
current = current.getNext()
return found
def add(self,item):
current = self.head
previous = None
stop = False
while current != None and not stop:
if current.getData()>item: # 发现插入位置
stop = True
else:
previous = current # 维护previous
current = current.getNext()
temp = Node(item)
# 分两种情况
if previous == None: # 插入在表头
temp.setNext(self.head)
self.head = temp
else:
temp.setNext(current)
previous.setNext(temp)
-
对于链表复杂度的分析,主要是看相应的方法是否涉及到链表的遍历。
-
对于一个包含节点数为n的链表,
isEmpty
是O(1),因为需要检查head是否为None。size
/search
/remove
/add
涉及到链表的遍历,复杂度O(n)。无序表的add方法是O(1),因为仅需要插入到表头。
3.递归
3.1 什么是递归
-
递归(recursion)是一种解决问题的方法,其精髓在于将问题分解为规模更小的相同问题,持续分解直到问题规模小到可以用非常简单直接的方式来解决。
-
递归的问题分解方式非常独特,其算法方面的明显特征就是在算法流程中调用自身。
-
举例:给定一个列表,返回所有数之和,不能用循环语句。
可以把求和问题归纳为“数列的和=首个数+余下数列”,如果数列包含的数少到只有一个的话,它的和就是这个数了。
def listsum(numList):
if len(numList) == 1: # 递归出口
return numList[0]
else:
return numList[0] + listsum(numList[1:]) # 调用自身
print(listsum([1,3,5,7,9]))
上述程序要点:
(1)问题分解为更小规模的相同问题,并表现为“调用自身”。
(2)对“最小规模”问题的解决简单直接。
递归三定律:
(1)数列求和问题具备了基本结束条件:当列表长度为1时,直接输出所包含的唯一数。
(2)递归算法就是改变列表并向长度为1的状态演进。
(3)调用自身是递归算法中最难理解的部分,实际上我们理解为“问题分解成了规模更小的相同问题”就可以了。
3.2 递归的应用(1):任意进制转换
-
十进制有十个不同的符号:
convString = “0123456789”
比十小的整数转换成十进制,直接查表就可以。想办法把比十大的整数,拆成一系列比十小的整数,逐个查表。 -
在递归三定律里,我们找到了“基本结束条件”,就是小于十的整数,拆解整数的过程就是向“基本结束条件”演进的过程。
-
可以用整数除和求余数两个计算来将整数一步步拆开。除以“进制基base”(//base),对“进制基”求余数(%base)。
-
余数总小于“进制基base”,是基本结束条件,直接查表转换。整数商成为“更小规模”问题,通过递归调用自身解决。
def toStr(n,base):
convertString = "0123456789ABCDEF"
if n < base:
return convertString[n]
else:
return toStr(n//base,base) + convertString[n%base]
print(toStr(1453,16))
3.3 递归调用的实现:递归深度的限制
(1) 没有结束条件
(2) 向基本结束条件演进太慢,导致递归层数太多,调用栈溢出。
-
在Python内置的sys模块可以获取和调整最大递归深度。
import sys
sys.getrecursionlimit() # 返回1000
sys.setrecursionlimit(3000)
sys.getrecursionlimit() # 返回3000
3.4 递归的可视化:分形树
-
Python的海龟做图系统turtle module是Python内置的,随时可用,以LOGO语言的创意为基础,其意象为模拟海龟在沙滩上爬行而留下的足迹。
(1)爬行:forward(n)
; backward(n)
(2)转向:left(a)
; right(a)
(3)抬笔放笔:penup()
; pendown()
(4)笔属性:pensize(s)
; pencolor(c)
import turtle
t = turtle.Turtle()
def drawSpiral(t,linelen):
# 最小规模,0直接退出
if linelen > 0:
t.forward(linelen)
t.right(90)
# 减小规模,边长减去5
drawSpiral(t,linelen-5) # 调用自身
drawSpiral(t,100)
turtle.done()
import turtle
def tree(branch_len):
if branch_len > 5: # 树干太短不画,即递归结束条件
t.forward(branch_len) # 画树干
t.right(20) # 向右倾斜
tree(branch_len - 5) # 递归调用,画右边的小树
t.left(40) # 向左回40°,向左倾斜
tree(branch_len - 5) # 递归调用,画左边的小树
t.right(20) # 向右回20°,即回正
t.backward(branch_len) # 海归退回原位置
t = turtle.Turtle()
t.left(90)
t.penup()
t.backward(100)
t.pendown()
t.pencolor('green')
t.pensize(2)
tree(75) # 画树干长度为75的二叉树
t.hideturtle()
turtle.done()
3.5 递归的可视化:谢尔宾斯基三角形
import turtle
def sierpinski(degree,points):
colormap = ['blue','red','green','yellow','orange','pink']
# 绘制等边三角形
drawTriangle(points,colormap[degree])
# 最小规模,0直接退出
if degree > 0:
# 减小规模,getMid边长减半,调用自身,左上右的次序
sierpinski(degree-1,{'left':points['left'],
'top':getMid(points['left'],points['top']),
'right':getMid(points['left'],points['right'])})
sierpinski(degree-1,{'left':getMid(points['left'],points['top']),
'top':points['top'],
'right':getMid(points['top'],points['right'])})
sierpinski(degree-1,{'left':getMid(points['left'],points['right'])
'top':getMid(points['right'],points['top'])
'right':points['right']})
def drawTriangle(points,color):
t.fillcolor(color)
t.penup()
t.goto(points['top'])
t.pendown()
t.begin_fill()
t.goto(points['left'])
t.goto(points['right'])
t.goto(points['top'])
t.end_fill()
def getMid(p1,p2):
return((p1[0]+p2[0])/2,(p1[1]+p2[1])/2)
t = turtle.Turtle()
points={'left':(-200,-100),
'top':(0,200),
'right':(200,-100)}
sierpinski(5,points)
turtle.done()
4.分治策略
将问题分为若干更小规模的部分,通过解决每一个小规模部分问题,并将结果汇总得到原问题。
4.1优化问题和贪心策略
优化问题的一个案例是找零兑换问题:
假设为自动售货机厂家编写程序,自动售货机每次要找给顾客数量最少的硬币。
假设顾客投入$1硬币,买了¢37的东西,要找¢63。那么最少数量就是2个quarter(¢25)、1个dime(¢10)和三个penny(¢1),共6个硬币。
贪心策略依赖币值的分布,如果币值设置特殊,则贪心策略就会实效。
4.2找零兑换问题的递归解法
我们需要找一种肯定能得到最优解的方法,使其不依赖于具体的货币体系。
首先是确定基本结束条件,兑换硬币这个问题最简单直接的情况就是,需要兑换的找零,其面值正好等于某种硬币。
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)) #返回6,耗时51s
def recMC(coinValueList,change,knownResults):
minCoins = change
if change in coinValueList: #递归基本结束条件
knownResults[change] = 1 #记录最优解
return 1 #最小规模直接返回
elif knownResults[change]>0:
return knownResults[change] #查表成功,直接用最优解
else:
for i in [c for c in coinValueList if c <= change]:
numCoins = 1 + recMC(coinValueList,change-i,knownResults) #调用自身,减小规模
if numCoins < minCoins:
minCoins = numCoins #每次减去一种硬币面值,挑选最小数量
knownResults[change] = minCoins #找到最优解,记录在表中
return minCoins
print(recMC([1,5,10,25],63,[0]*64)) #返回6,耗时18ms
中间结果记录可以很好的解决找零兑换问题。实际上,这种方法还不能称为动态规划,而是叫做”memoization(记忆化/函数值缓存)“的技术提高了递归解法的性能。
4.3找零兑换问题的动态规划解法
动态规划算法采用了一种更有条理的方式来得到问题的解。找零兑换的动态规划算法从最简单的“1分钱找零”的最优解开始,逐步递加上去,直到我们需要的找零钱数。在找零递加的过程中,设法保持每一分钱的递加都是最优解,一直加到求解找零钱数,自然得到最优解。
递加过程能保持最优解的关键是,其依赖于更少钱数最优解的简单计算,而更少钱数的最优解已经得到了。问题的最优解包含了更小规模子问题的最优解,这是一个最优化问题能够用动态规划策略解决的必要条件。
def dpMakeChange(coinValueList,change,minCoins):
# 从1分钱到change逐个计算最少硬币数
for cents in range(1,change+1):
# 1.初始化一个最大值
coinCount = cents
# 2.减去每个硬币,向后查最少硬币数,同时记录总的最少数。
for j in [c for c in coinValueList if c <= cents]:
if minCoins[cents-j]+1<coinCount:
coinCount = minCoins[cents-j]+1
# 3.得到当前最少硬币数,记录到表中
minCoins[cents] = coinCount
# 返回最后一个结果
return minCoins[change]
print(dpMakeChange([1,5,10,21,25],63,[0]*64)) # 3,23ms
动态规划算法的扩展:前面的算法已经得到了最少硬币的数量,但没有返回硬币如何组合。扩展算法的思路很简单,只需要在生成最优解列表的同时跟踪记录所选择的那个硬币币值即可。在得到最后的解后,减去选择的硬币币值,回溯到表格之前的部分找零,就能逐步得到每一步所选择的硬币币值。
def dpMakeChange(coinValueList,change,minCoins,coinsUsed):
for cents in range(change+1):
coinCount = cents
newCoin = 1 # 初始化一下新加硬币
for j in [c for c in coinValueList if c <= cents]:
if minCoins[cents-j]+1<coinCount:
coinCount = minCoins[cents-j]+1
newCoin = j # 对应最小数量所减的硬币
minCoins[cents] = coinCount
coinsUsed[cents] = newCoin # 记录本步骤增加的1个硬币
return minCoins[change]
def printCoins(coinsUsed,change):
coin = change
while coin > 0:
thisCoin = coinUsed[coin]
print(thisCoin)
coin = coin - thisCoin
amnt = 63
clist = [1,5,10,21,25]
coinUsed = [0]*(amnt+1)
coinCount = [0]*(amnt+1)
print("Making change for",amnt,"requires") # 63
print(dpMakeChange(clist,amnt,coinCount,coinUsed),"coins") # 3 coins
printCoins(coinUsed,amnt) # 21,21,21
4.4博物馆大盗问题的动态规划解法
大盗潜入博物馆,面前有5件宝物,分别有重量和价值,大盗的背包仅能负重20公斤,请问如何选择宝物,总价值最高?
item | weight | value |
---|---|---|
1 | 2 | 3 |
2 | 3 | 4 |
3 | 4 | 8 |
4 | 5 | 8 |
5 | 9 | 10 |
# 宝物的重量和价值
tr = [None,{'w':4,'v':8},{'w':2,'v':3},{'w':3,'v':4},{'w':5,'v':8},{'w':9,'v':10}]
# 大盗最大承重
max_w = 20
# 初始化二维表格m[(i,w)]
# 表示前i个宝物中,最大重量w的组合,所得到的最大价值
# 当i什么都不取,或w上限为0,价值均为0
m = {(i,w):0 for i in range(len(tr))
for w in range(max_w+1)}
# 逐个填写二维表格
for i in range(1,len(tr)):
for w in range(1,max_w+1):
if tr[i]['w']>w: # 装不下第i个宝物
m[(i,w)]=m[(i-1,w)] # 不装第i个宝物
else:
# 不装第i个宝物,装第i个宝物,两种情况下最大价值
m[(i,w)]=max(m[(i-1,w)],m[(i-1,w-tr[i]['w'])]+tr[i]['v'])
# 输出结果
print(m[(len(tr)-1),max_w])
# 输出二维表
for i in range(len(tr)-1):
for j in range(max_w):
print(m[i,j])
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 | 8 |
0 | 0 | 3 | 3 | 8 | 8 | 11 | 11 | 11 | 11 | 11 | 11 | 11 | 11 | 11 | 11 | 11 | 11 | 11 | 11 | 11 |
0 | 0 | 3 | 4 | 8 | 8 | 11 | 12 | 12 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 | 15 |
0 | 0 | 3 | 4 | 8 | 8 | 11 | 12 | 12 | 16 | 16 | 19 | 20 | 20 | 23 | 23 | 23 | 23 | 23 | 23 | 23 |
0 | 0 | 3 | 4 | 8 | 8 | 11 | 12 | 12 | 16 | 16 | 19 | 20 | 20 | 23 | 23 | 23 | 23 | 26 | 26 | 29 |
4.5递归小结
-
递归是解决某些具有自相似的复杂问题的有效技术。
-
递归算法“三定律”:
(1)具备基本结束条件
(2)减小规模、改变状态、向基本结束条件演进
(3)递归必须调用自身
-
如果没有缩小规模会使得调用栈溢出,使得递归失败。
-
某些情况下,递归可以代替迭代循环。
-
递归算法通常能够跟问题的表达自然契合。
-
递归不总是最合适的算法,有时候递归算法会引发巨量的重复计算。
-
“记忆化/函数值缓存”可以通过附加存储空间中间计算结果来有效减少重复计算。
-
如果一个问题最优解包括规模更小相同问题的最优解,就可以用动态规划来解决。