Python数据结构与算法分析 第四章-递归

415 阅读18分钟

Python数据结构与算法分析

第四章   递归

4.1 何谓递归

递归是解决问题的一种方法,它将问题不断地分成更小的子问题,直到子问题可以用普通的方法解决。

4.1.1 计算列数之和

 我们从一个简单的问题开始学习递归。即使不用递归,我们也知道如何解决这个问题。假设需要计算数字列表[1, 3, 5, 7, 9]的和。

def listSum(numlist):
    thesum = 0 #初始化总和为0
    for i in numlist:
        thesum = thesum + i
    return thesum

if __name__=="__main__":
    numlist = [1,2,3,4,5,6]
    s = listSum(numlist)
    print(s)

 假设暂时没有 while 循环和 for 循环。应该如何计算结果呢?加法是接受两个参数的函数,将数字列表重写成完全括号表达式。

 数字列表 numList的总和等于列表中的第一个元素(numList[0])加上其余元素(numList[1:])之和。

1663211020294.png

def listSum(numlist):
    if len(numlist) == 1:#列表只有一个元素 返回第一个元素
        return numlist[0]
    else:
        return numlist[0] + listSum(numlist[1:]) #否则返回第一个元素之外的元素
"""listSum(numlist[1:]  递归函数会调用自己"""


if __name__=="__main__":
    numlist = [1,3,5,7,9]
    s = listSum(numlist)
    print(s)

!!!递归函数会调用自己

1663212368710.png

 当问题无法再简化时,我们开始拼接所有子问题的答案,以此解决最初的问题。listsum 函数在返回一系列调用的结果时进行的加法操作。当它返回到顶层时,就有了最终答案。

1663212441628.png

4.1.2 递归三原则

  • 递归算法必须有基本情况

    • 基本情况:基本情况是指使算法停止递归的条件,

      • listsum 算法中的基本情况就是列表的长度为 1。
  • 递归算法必须改变状态向基本情况靠近

    • 改变状态是指修改算法所用的某些数据,这通常意味着代表问题的数据以某种方式变得更小。

      • listsum 算法的主数据结构是一个列表,因此必须改变该列表的状态。由于基本情况是列表的长度为 1,因此向基本情况靠近的做法自然就是缩短列表
  • 递归算法必须递归地调用自己

    • 递归的定义就是对自身进行调用,将问题分解成更小、更容易解决的子问题。

4.1.3 将整数转换成任意进制的字符串

 以十进制整数 769 为例。假设有一个字符序列对应前 10 个数,比如 convString ="0123456789"。若要将一个小于 10 的数字转换成其对应的字符串,只需在字符序列中查找对应的数字即可。例如,9 对应的字符串是 convString[9]或者"9"。将整数‘769’拆成‘7’,‘6’,‘9’。

该算法包含3个组成部分:

  • 将原来的整数分成一系列仅有单数位的数;
  • 通过查表将单数位的数转换成字符串;
  • 连接得到的字符串,从而形成结果。

设法改变状态并且逐渐向基本情况靠近:缩短整数有效的方法为减法除法

 将 769 除以 10,商是 76,余数是 9。首先,由于余数小于进制基数(9<10),因此可以通过查表直接将其转换成字符串'9'。其次,得到的商小于原整数(76<769),这使得我们离基本情况(n < base) 更近了一步。下一步是将 76 转换成对应的字符串。再一次运用除法,得到商 7 和余数 6。问题终于被简化到将 7 转换成对应的字符串,满足基本情况(7<10)

1663295348765.png

def toStr(n,base):
    convertString = "0123456789ABCDEF"
    if n < base:#基本情况 商<进制数
        return convertString[n] #如7<10 返回7
    else:
        return toStr(n//base,base)+convertString[n%base]  #调用自己进行迭代 如769//10 商为n=76 余数是 n%base = 9


if __name__=="__main__":
    s = toStr(769,10)
    print(s)

4.2 栈帧:实现递归

 当一个函数被调用的时候,系统会把调用时的现场数据压入到系统调用栈;每次调用,压入栈的现场数据称为栈帧;当函数返回时,要从调用栈的栈顶取得返回地址,恢复现场,弹出栈帧,按地址返回(后进先出)。

如何将整数 10 转换成其对应的二进制字符串"1010"。 如果将 convertString 查找和返回 toStr 调用反转,结果字符串就是反转的。但是将拼接操作推迟到递归调用返回之后,就能得到正确的结果。

 假设不拼接递归调用 toStr 的结果和 convertString 的查找结果,而是在进行递归调用之前把字符串压入栈中。

image.png


rStack  = Stack()
def toStr(n,base):
        convertString = "0123456789ABCDEF"
        while n > 0:
           if n < base:
              rStack.push(convertString[n])
           else:
              rStack.push(convertString[n % base])
           n = n // base
        res = ""
        while not rStack.isEmpty():
            res = res + str(rStack.pop())

        return res

print(toStr(10,2))

4.3 递归可视化

分形树

 我们将使用 Python 的 turtle 模块来绘制图案。Python 的各个版本都提供 turtle 模块,它用起来非常简便。顾名思义,可以用turtle 模块创建一只小乌龟(turtle)并让它向前或向后移动,或者左转、右转。小乌龟的尾巴可以抬起或放下。当尾巴放下时,移动的小乌龟会在其身后画出一条线。若要增加美观度,可以改变小乌龟尾巴的宽度以及尾尖所蘸墨水的颜色。

属性:

  • 爬行:forward(n),backward(n)
  • 转向:left(n),right(n)
  • 抬笔放笔:up(),down()
  • 颜色,尺寸:pensize(s),color(c)
from  turtle import *  #turtle 画图工具
myTurtle = Turtle()   #建立turtle对象
myWin  = myTurtle.getscreen()  #弹出画图窗口  小乌龟初始在中间(0,0)初始值朝向左边

def drawSpiral(myTurtle,lineLen): #给turtle对象 以及划线的长度
    if lineLen > 0:
        myTurtle.forward(lineLen) #先走给定的划线长度那么远
        myTurtle.right(90) #走完 右转90度
        drawSpiral(myTurtle,lineLen-10) #再调用自己,改变状态(长度减5)


drawSpiral(myTurtle,200)
myWin.exitonclick() #点击退出

1663725061699.png

 接下来绘制一棵分形树。分形是数学的一个分支,它与递归有很多共同点。分形的定义是,不论放大多少倍来观察分形图,它总是有相同的基本形状。自然界中的分形例子包括海岸线、雪花、山岭,甚至树木和灌木丛。

 对于树木来说,这意味着即使是一根小嫩枝也有和一整棵树一样的形状和特征。借助这一思想,可以把树定义为树干,其上长着一棵向左生长的子树和一棵向右生长的子树。因此,可以将树的递归定义运用到它的左右子树上。

1663745328521.png

def tree(branchLen,myTurtle): #起始点在(-300,0) 给定绘画长度和 turtle对象
    if branchLen > 5:#基本情况 树干太短
        myTurtle.forward(branchLen) #向前
        myTurtle.right(20) #右转20度
        tree(branchLen-15,myTurtle) #调用自己改变状态 长度减15
        myTurtle.left(40) #走完左转40°  因为先右转20 对称方向左转40
        tree(branchLen-10,myTurtle) #调用自己改变状态 长度减10
        myTurtle.right(20)# 右转20摆正 
        myTurtle.backward(branchLen) #回到原始位置

myTurtle.left(90)  #初始值为小乌龟头部向右  左转90度 使其朝上
myTurtle.up() #抬起笔 但不绘图
myTurtle.backward(300) # 后退300 全图大小为600*600 初始值为 (0,0),后退300到背景底部的中心位置(0,-300)
myTurtle.down() #放下笔尖
myTurtle.color('green') #更换画笔颜色
tree(110,myTurtle)
myWin.exitonclick() #点击退出

image.png

谢尔平斯三角形

 谢尔平斯基三角形展示了三路递归算法。手动绘制谢尔平斯基三角形的过程十分简单:从一个大三角形开始,通过连接每条边的中点将它分割成四个新的三角形;忽略中间的三角形,利用同样的方法分割其余三个三角形。每一次创建一个新三角形集合,都递归地分割三个三角形。

 根据自相似性,谢尔宾斯三角形是由三个尺寸减半的谢尔宾斯三角形按照品字形拼叠而成

在degree有限的情况下,degree=n的三角形,是由3个degree = n - 1 的三角形按照品字形拼叠而成。

同时,这三个degree = n - 1 de 的三角形的边长均为degree = n 的三角形的一半

degree = 0 时,则就是一个等边三角形,则为递归的基本结束条件

1663815385195.png

def drawTriangle(points,color,myTurtle): #画三角形

    """
       points[0]:左点
       points[1]: 顶边点
       points[2]: 右边点
       按照 0左-1顶-2右-0左的方向画
       points列表内 有三个点的坐标 三个(元组)
    
    """
    myTurtle.fillcolor(color)
    myTurtle.up() #抬笔
    myTurtle.goto(points[0]) #先到p0
    myTurtle.down() #落笔
    myTurtle.begin_fill() #填颜色
    myTurtle.goto(points[1])
    myTurtle.goto(points[2])
    myTurtle.goto(points[0])
    myTurtle.end_fill() #填充完毕

def getMid(p1,p2): #取中间点 两点之间中心
    return ( (p1[0]+p2[0]) /2, (p1[1] + p2[1]) / 2)

def sierpinski(points,degree,myTurtle):
    colormap =  ['blue','red','green','white','yellow','violet','orange']
    drawTriangle(points,colormap[degree],myTurtle) #colormap[degree]  degree为5 就是orange  画大三角形

    #画里面小的三个三角形
    if degree > 0:

        sierpinski([points[0],#传入新的三角形点 先画p0 第1个点是p0
                     getMid(points[0], points[1]),#第2点是 p0 和 p1 的中间点
                     getMid(points[0], points[2])],#第3点是p0 和 p2 的中间点
                     degree - 1, myTurtle)

        sierpinski([points[1],
                     getMid(points[0], points[1]),
                     getMid(points[1], points[2])],
                    degree - 1, myTurtle)

        sierpinski([points[2],
                     getMid(points[2], points[1]),
                     getMid(points[0], points[2])],
                     degree - 1, myTurtle)

setup(900,700) #设置窗口大小
bgcolor(0.5,0.5,0.5) #背景颜色
myPoints = [(-200, -100), (0, 200), (200, -100)]
sierpinski(myPoints,5, myTurtle)
myWin.exitonclick()

image.png

4.4 复杂的递归问题

汉诺塔

 传说在一个印度教寺庙里,有3根柱子,其中一根套着64由小到大的黄金盘片,憎侣们的任务就是将这一叠黄金盘从一根柱子搬到另一根,但是有两个规则:

  • 一次只能搬一个盘子
  • 大盘子不能在小盘子上

分解成递归问题

 假设我们有5个盘子,穿在1#柱,需要挪动到3#柱,如果能有办法把1#柱上面的4个盘子统统挪到2#柱上,问题就好解决:

  • 把1#柱子上剩下的最大的盘子挪到3#柱
  • 再用从1#前4个盘子挪到2#柱子的办法,把2#的4个盘子挪到3#柱子就完成了整个移动

1663899812734.png

递归思路

  • 将盘片塔从开始柱,经由中间柱,移动到目标柱:
    • 首先将上层N-1个盘片的盘片塔,从开始柱,经由目标柱子,移动中间柱;
    • 然后将第N个(最大) 的盘片,从开始柱,移动到目标柱;
    • 最后将放在中间柱的N-1个盘片,经由开始柱,移动到目标柱
  • 基本结束条件,也就是最小规模问题:
    • 1个盘片的移动
def moveTower(height,fromPole,withPole,toPole):#height几个盘子,fromPole开始柱,withPole中间柱,toPole目标柱
    if height >= 1 :
        moveTower(height-1,fromPole,toPole,withPole) #把n-1个塔 从开始柱,经由目标柱子,移动中间柱
        moveDisk(height,fromPole,toPole) #将第N个(最大) 的盘片,从开始柱,移动到目标柱
        moveTower(height-1,withPole,fromPole,toPole)#将放在中间柱的N-1个盘片,经由开始柱,移动到目标柱

def moveDisk(disk,fromPole,toPole):
    print(f"Moving disk[{disk}]from {fromPole} to {toPole}")

s = moveTower(3,"1#","2#","3#")
print(s)

1663902027717.png

4.5 探索迷宫

 本节假设小乌龟被放置在迷宫里的某个位置,我们要做的是帮助它爬出迷宫。

1664162995870.png

为简单起见,假设迷宫被分成许多格,每一格要么是空的,要么被墙堵上。小乌龟只能沿着空的格子爬行,如果遇到墙,就必须转变方向。

考虑用矩阵方式来实现迷宫数据结构:

  • 采用“数据项为字符列表的列表”的这两级列表的方式来保存方格内容
  • 采用不同字符分别代表:‘+’墙壁、‘ ’通道、‘S’乌龟投放点,从一个人文本文件逐行输入迷宫数据

1664198088575.png

  • 从起始位置开始,首先向北移动一格,然后在新的位置再递归地重复本过程。
  • 如果第一步往北行不通,就尝试向南移动一格,然后递归地重复本过程。
  • 如果向南也行不通,就尝试向西移动一格,然后递归地重复本过程。
  • 如果向北、向南和向西都不行,就尝试向东移动一格,然后递归地重复本过程。
  • 如果 4 个方向都不行,就意味着没有出路。

必须通过一个策略来记住到过的地方。本例假设小乌龟一边爬,一边丢面包屑。如果往某个方向走一格之后发现有面包屑,就知道应该立刻退回去,然后尝试递归过程的下一步。

基本情况:

  • 小乌龟遇到了墙。由于格子被墙堵上,因此无法再继续探索。
  • 小乌龟遇到了已经走过的格子。在这种情况下,我们不希望它继续探索,不然会陷入循环。
  • 小乌龟找到了出口。
  • 四个方向都行不通。

用python实现迷宫

首先绘制迷宫,由于我想保证是以方块的中心点绘制方块的,在1 * 1的方块中心中心点减-.5的操作,迷宫存储在nparray中。

1664418901189.png

#迷宫
import numpy as np


mazel = np.array([
    ['+','+','+','+','+','+','+'],
    ['+','S','+',' ','+','E','+'],
    ['+',' ','+',' ','+',' ','+'],
    ['+',' ',' ',' ','+',' ','+'],
    ['+','+',' ','+',' ',' ','+'],
    ['+',' ',' ',' ',' ','+','+'],
    ['+','+','+','+','+','+','+'] ])    #S起点 E终点

print(mazel.shape)
print(mazel[1,2]) #y=1,x=2

#坐标起点
print(np.where(mazel=='S')[0][0]) #S的坐标 [0]行索引的[0]第0位置
print(np.where(mazel=='S')[1][0])
#终点
print(np.where(mazel=='E')[0][0])
print(np.where(mazel=='E')[1][0])

class Maze:
    def __init__(self,maze,OBSTACLE = '+',TRIED = '.',PART_OF_PATH = '0',DEAD_END = '-'):
        """
        输入:迷宫数组,2d numpy arrary

        定义表示方法
        OBSTACLE墙用‘+’表示;
        TRIED:发现已经走过的位置,‘.’表示
        PART_OF_PATH:可以移动的位置,'0' 表示
        DEAD_END:死胡同, '-' 表示
        """

    # 表示迷宫信息的数组
        self.maze = maze  #迷宫名称    2维矩阵  shape[0] 一维y轴 shape[1] 二维 x轴
        self.rows = self.maze.shape[0] #shape[0] 行数
        self.cols = self.maze.shape[1] #shape[1] 列数

        self.OBSTACLE = OBSTACLE
        self.TRIED = TRIED
        self.PART_OF_PATH = PART_OF_PATH
        self.DEAD_END = DEAD_END

        #起始位置

        self.starx = np.where(mazel=='S')[1][0] #[1]列索引的[0]第0位置
        self.stary = np.where(mazel=='S')[0][0]#S的坐标 [0]行索引的[0]第0位置

        #终止位置
        self.exitx = np.where(mazel=='E')[1][0]
        self.exity = np.where(mazel=='E')[0][0]

        #创建Turtle对象
        self.t = Turtle(shape = 'turtle')

        #设置窗口大小,此时默认原点在中间
        setup(width=600,height=600)

        #定义坐标系,左下角为
        setworldcoordinates(-.5,self.rows-.5,self.cols-.5,-.5)
        """
        -.5,self.rows-.5 左下角的点 (-0.5,4.5)
        self.cols-.5,-.5 右上角的点  (4.5,-0.5)
        
        保证画每一个小方块时 是从方块中间画
        """

    def drawMaze(self): #画迷宫
        for y in range(self.rows): #遍历每一行每一列
            for x in range(self.cols):
                if self.maze[y][x] == self.OBSTACLE: #出现+ 说明为墙壁绘制方块
                    self.drawCenterBox(x,y,'tan') #调用画方块函数
        self.t.up() #抬笔
        self.t.goto(self.starx,self.stary)
        self.t.write('S',font=('Arial',14,'bold'))  #到起点坐标S 画出‘S’字符
        self.t.goto(self.exitx,self.exity)
        self.t.write('E',font=('Arial',14,'bold'))  #到终点坐标E 画出‘E’字符
        self.t.down()

        self.t.color('black','blue') #填充色,线条颜色



    def drawCenterBox(self,x,y,color): #绘制一个方块
        tracer(0) #加快作图,设置为0表示图形一次性画完

        #先移动到起点
        self.t.up() #抬笔
        self.t.goto(x-.5,y-.5)# 以坐标为中心,左上角为起点
        self.t.color('black',color)
        self.t.setheading(90) #转头向右90度
        self.t.down() #落笔

        self.t.begin_fill()#开始填充

        #右转4次,画出一个方格 形成一个封闭图形
        for i in range(4):
            self.t.forward(1) #前进1格
            self.t.right(90)

        self.t.end_fill() # 结束填充

        update() #更新

        tracer(1) #恢复正常速度

搜索迷宫

传入 迷宫地图,起始行,起始列。

  • 基本情况

      1. 遇到墙
      1. 遇到已经走过的路
      1. 已经到达出口
  • 调用自己改变状态

    • 依次向左右下上4个方向移动,遍历所有的点
    • 如果该位置的下一步可以移动,则此处标记为可以移动的位置
    • 走不通就标记为死胡同

1664419891105.png

 # moveTurtle和 dropBreadcrumb 更新屏幕信息
    def moveTurtle(self,x,y):
        self.t.up() #抬笔
        self.t.setheading(self.t.towards(x,y)) #转向前一步的方向
        self.t.goto(x,y) #走过去

    def dropBreadcrumb(self,color): #画点
        self.t.dot(color)

    def updatePosistion(self,x,y,val=None):#检查乌龟是否撞到了墙

        #乌龟移动到指定位置
        self.moveTurtle(x,y)

        #如果输入了状态,根据状态改变地图数组
        if val:
            self.maze[y][x] = val

        #根据不同情况画点
        if val == self.PART_OF_PATH:
            color = 'green'
        elif val == self.OBSTACLE:
            color = 'red'
        elif val == self.TRIED:
            color = 'black'
        elif val == self.DEAD_END:
            color = 'red'
        else:
            color = None

        #在移动到的位置留下点
        if color:
            self.dropBreadcrumb(color)

    def isExit(self,x,y):#检查乌龟当前位置是否为出口
        return (x == self.exitx and y == self.exity )

    #迷宫搜索

    def searchFrom(self,maze, x, y):  # 迷宫对象,起始行,起始列
        # 移动乌龟到指定位置
        self.updatePosistion(x,y)
        # 检查基本情况

        # 1.遇到墙
        if self.maze[y][x] == self.OBSTACLE:
            return False
        # 2.遇到已经走过的格子
        if self.maze[y][x] == self.TRIED:
            return False
        # 3. 找到出口
        if self.isExit(x, y):
            self.upatePosition(x, y, self.PART_OF_PATH)
            return True
        #如果上述三种情况都未发生,此处留下黑色足迹
        self.updatePosistion(x, y, self.TRIED)

        # 调用自己,改变状态。依次向4个方向移动 左,右 ,下, 上
        found = self.searchFrom(maze,x-1,y) or\
                self.searchFrom(maze,x+1,y) or\
                self.searchFrom(maze,x,y-1) or\
                self.searchFrom(maze,x,y+1)
        #如果该位置的下一步可以移动,则此处标记为可以移动的位置
        if found:
            self.updatePosistion(x, y, self.PART_OF_PATH)
        else:
            #走不通就标记为死胡同
            self.updatePosistion(x, y, self.DEAD_END)
        return found



mymaze = Maze(mazel)
mymaze.drawMaze()
mymaze.updatePosistion(mymaze.starx,mymaze.stary)
mymaze.searchFrom(mymaze,mymaze.starx,mymaze.stary)

4.6 分治策略

解决问题的典型策略:分而治之

 将问题分为若干个更小规模的部分

 通过解决每一个小规模部分问题,并将结果汇总得到原问题的解。

 应用:排序、查找、遍历、求值等

1664506278634.png

4.7 动态规划

  计算机科学中许多算法都是为了找到某些问题的最优解,在解决优化问题时,一个策略是动态规划

 例如,两个点之间的最短路径;能最好匹配一系列点的直线;或者满足一定条件的最小集合;

找零兑换问题

一个经典例子就是在找零时使用最少的硬币

 假设某个自动售货机制造商希望在每笔交易中给出最少的硬币

 假设顾客使用一张一美元的纸币购买了价值 37 美分的物品,最少需要找给该顾客多少硬币呢?

答案6 枚:25 美分的 2 枚,10 美分的 1 枚,1 美分的 3 枚。该如何计算呢?

贪心策略

 从面值最大的硬币(25 美分)开始,使用尽可能多的硬币,然后尽可能多地使用面值第 2 大的硬币。这种方法叫作贪婪算法——试图最大程度地解决问题

  • 从最大面值的硬币开始,用尽量多的数量有余额的,再到下一最大面值的硬币,还用尽量多的数量,直到用到¥1为止
  • 贪心策略解决货币兑换的问题表现良好,但是对于硬币面值为21分的硬币找零 63 分的情况得出最少硬币数。尽管多了 21 分的面值,贪婪算法仍然会得到 6 枚硬币的结果,而最优解是 3 枚面值为 21 分的硬币,我们可以采用递归的方法得到最优解

1664935571010.png

递归策略解决硬币找零问题

  • 基本条件:如果要找的零钱金额与硬币的面值相同,那么只需找 1枚硬币
  • 调用自身改变状态:对每种硬币尝试1次减小问题规模
    • 找零减去1分后,求兑换硬币的最少数量(递归调用自己)
    • 找零减去5分后,求兑换硬币的最少数量
    • 找零减去10分后,求兑换硬币的最少数量
    • 找零减去25分后,求兑换硬币的最少数量
    • 上述4项种选择最小的一项

1664936265743.png

def recMC(coinValueList,change):
    minCoins = change
    if change in coinValueList: #基本情况,要找的面额==硬币的值,只需要一个硬币
        return 1
    else:
        for i in [c for c in coinValueList if c <= change]: #c for c in coinValueList if c <= change 把比要找零大的面值硬币去除 例如剩下10美分 就要把>10 美分的25美分去除
            numCoins = 1+ recMC(coinValueList,change-i)  #调用自身改变自己,
            if numCoins < minCoins:
                minCoins = numCoins  #取最小值
    return  minCoins

print(recMC([1,5,10,25],63))

 递归解法虽然能解决问题,但极! 其! 低!效!

 以26分兑换硬币为例,递归调用需要377次递归,有大量的重复计算

1664938954933.png

找零兑换问题:递归解法改进

  • 消除重复计算

    • 查询表把计算过的中间结果保留,计算前先查表是否已计算
  • 中间结果就是部分找零的最优解,在递归调用的过程中已得到的最优解被记录下来

    • 在递归调用之前,先查表中是否已有了部分找零的最优解

      • 有,直接停止继续递归返回最优解
      • 没有,继续递归

以找零 11 分为例子,我们有 3 个可选方案。

(1) 1 枚 1 分的硬币加上找 10 分零钱(11–1)最少需要的硬币(1 枚)。

(2) 1 枚 5 分的硬币加上找 6 分零钱(11–5)最少需要的硬币(2 枚)。

(3) 1 枚 10 分的硬币加上找 1 分零钱(11–10)最少需要的硬币(1 枚)。

第 1 个和第 3 个方案均可得到最优解,即共需要 2 枚硬币。

def recMC(coinValueList,change,konwResults):#查询表konwResults记录中间结果需要多长,与找零的面值有关
    minCoins = change
    if change in coinValueList: #基本情况,要找的面额==硬币的值,只需要一个硬币
        konwResults[change] = 1 #记录最优解
        return 1
    elif konwResults[change] > 0:#初始化为0是没有计算过的 大于0为计算过
        return konwResults[change] #直接返回最优解
    else:
        for i in [c for c in coinValueList if c <= change]: #c for c in coinValueList if c <= change 把比要找零大的面值硬币去除 例如剩下10美分 就要把>10 美分的25美分去除
            numCoins = 1+ recMC(coinValueList,change-i,konwResults)  #调用自身改变自己,
            if numCoins < minCoins:
                minCoins = numCoins  #取最小值
                konwResults[change] = minCoins #找到最优解存入表中
    return  minCoins

print(recMC([1,5,10,25],63,[0]*64))

找零兑换:动态规划

 在解决找零问题时,动态规划算法会从 1 分找零开始,然后系统地一直计算到所需的找零金额。

(1) 1 枚 1 分的硬币加上找 10 分零钱(11–1)最少需要的硬币(1 枚)。

(2) 1 枚 5 分的硬币加上找 6 分零钱(11–5)最少需要的硬币(2 枚)。

(3) 1 枚 10 分的硬币加上找 1 分零钱(11–10)最少需要的硬币(1 枚)。 1664940281297.png

1664940382110.png

1664940709567.png

#找零:动态规划

def dpMakeChange(coinValueList,change,minCoins):#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]: #c <=cents如果大于或者等于找零的比值 就剔除 ;j 每一个硬币
            if minCoins[cents - j] +1 < coinCount: #向后减去这个硬币的比值 coinCount 候选值  minCoins[cents - j] +1 向后查找需要的硬币数
                  # j是在剔除后 大于找零所需要比值剩下的
                  #  3种情况选最小:
                  #   1 枚 1 分的硬币加上找 10 分零钱(11–1)最少需要的硬币(1 枚)。
                  #  1 枚 5 分的硬币加上找 6 分零钱(11–5)最少需要的硬币(2 枚)。
                  #   1 枚 10 分的硬币加上找 1 分零钱(11–10)最少需要的硬币(1 枚)。

                coinCount  = minCoins[cents - j] + 1
        #3.得到当前最少硬币数,记录到表中
        minCoins[cents] = coinCount

    return  minCoins[change]
print(dpMakeChange([1,5,10,21,25],63,[0]*64))

image.png

动态规划的主要思想:

  • 从最简单情况开始到达所需找零的循环
  • 其每一步都依靠以前的最优解来得到本步骤的最优解,直到得到结果

#找零:动态规划扩展

在生成最优解列表的同时跟踪记录所选择的那个硬币比值即可

在得到最后解后,减去选择的硬币币值,回溯到表格之前的部分找零,就能逐步得到每一步所选择的硬币币值


#找零:动态规划扩展
def dpMakeChange(coinValueList,change,minCoins,coinUsed):#coinValueList 币值体系,change 需要找零的数 minCoins 最优硬币数量
                                                       #从1分开始到Change逐个计算最少硬币数
    for cents in range(change+1):
        #1.初始化一个最大值,求最小值先给一个最大值
        coinCount = cents
        newCoin = 1 #初始化新加硬币
        #2.减去每个硬币,向后查最少硬币数同时记录总的最少数
        for j in [c for c in coinValueList if c <= cents]: #c <=cents如果大于或者等于找零的比值 就剔除 ;j 每一个硬币
            if minCoins[cents - j] +1 < coinCount: #向后减去这个硬币的比值 coinCount 候选值  minCoins[cents - j] +1 向后查找需要的硬币数
                  # j是在剔除后 大于找零所需要比值剩下的
                  #  3种情况选最小:
                  #   1 枚 1 分的硬币加上找 10 分零钱(11–1)最少需要的硬币(1 枚)。
                  #  1 枚 5 分的硬币加上找 6 分零钱(11–5)最少需要的硬币(2 枚)。
                  #   1 枚 10 分的硬币加上找 1 分零钱(11–10)最少需要的硬币(1 枚)。

                coinCount  = minCoins[cents - j] + 1
                newCoin = j #对应最小数量,所减的硬币
        #3.得到当前最少硬币数,记录到表中
        minCoins[cents] = coinCount
        coinUsed[cents] = newCoin
    return  minCoins[change]


def printCoins(coinUsed,change):
    coin = change
    while coin  > 0:
        thisCoin = coinUsed[coin]
        print(thisCoin)
        coin = coin - thisCoin
amt = 63
clist = [1,5,10,21,25]
coinUsed = [0]*(amt+1)
coinCount = [0]*(amt+1)
print('Making change for',amt,'requires')
print(dpMakeChange(clist,amt,coinCount,coinUsed),"coins")
print('They are:')
printCoins(coinUsed,amt)
print('The use list is as follows:')
print(coinUsed)

1665032087392.png

动态规划案例分析

 大盗潜入博物馆,面前有5件宝物,分别有重量和价值,大盗的背包仅能负重20公斤,请问如何选择宝物,总价值最高

1665108966273.png

 将该问题转换为函数问题,有一个函数m(i,W) i,W为两个参数,分别代表,宝物个数,以及对应重量

 前i(1<=i<=5) 个宝物中,组合不超过W(1<=W<=20) 的重量,得到最大价值

1665110530467.png

  • 没有宝物可装
  • 第i件宝物的重量超过总重量,只能装i-1件宝物
  • 第i件宝物可以装,对比装入i件和没装入i件(i-1)的价值谁最大

1665110935699.png

 假如计算m(5,5) 第i=5件,重量为W=5的宝物价值,题中第5个宝物对应的重量9以及超过总重量5负重,不能选择第五件。所以选择5-1=4 m(4,5) 满足题中第i=4个宝物对应的总重量W=5,对以前的价值进行比较 max(m(i-1=3,W=5),m(i-1=3,W-Wi=0)+v=8)=max(8,8)=8

#动态规划案例:背包问题
#1
#宝物的重量和价值
tr=[None,              # i=0
    {'w': 2, 'v': 3},  # i=1
    {'w': 3, 'v': 4},  # i=2
    {'w': 4, 'v': 8},  # i=3
    {'w': 5, 'v': 8},  # i=4
    {'w': 9, 'v': 10}] # i=5

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)):#len(tr)=6 rang(1,6) = 1~5
    for w in range(1,max_w+1):#1~20
        if tr[i]['w'] > w: #装不下第i个宝物
            m[(i,w)]=m[(i-1),w] # 装不下第i件宝物

        #装i宝物,和不装i宝物取最大值
        else:
            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])  #m(5,20)

#解2 :递归

tr = {(2,3),(3,4),(4,8),(5,8),(9,10)} #重量和价值

max_w = 20 #背包最大容量

#初始化记忆化表格m
#key是(宝物组合,最大重量),value是最大价值

m = {}
def thief(tr,w):#宝物集合,最大负重
    if tr == set() or w == 0: #基本情况,没有宝物可以拿,或者没有重量
        m[tuple(tr),w] = 0
        return 0
    elif (tuple(tr),w) in m:
        return m(tuple(tr),w)
    else:                     #减小规模,调用自己
        vmax = 0 #最大价值初始化为0
        for t in tr: #遍历tr 随机挑选一件宝物
            if t [0] <= w: #如果说这件宝物的重量 小于 总重量 就把这件宝物从集合中去除
              #逐个从集合中去掉某个宝物,递归调用
              #选出所有价值的最大值
              v = thief(tr - {t},w-t[0])+t[1] #总集合中减去t宝物,总重量也减去t[0]对应的重量+ t[1] t 的价值
              vmax = max(vmax,v)
        m[(tuple(tr),w)] = vmax
        return vmax
print(thief(tr,max_w))

动态规划和贪心策略的区别

  • 贪心:要求“局部最优等同于全局最优”
  • 动态规划:要求“问题最优解包括规模更小相同的若干个最优解”

动态规划适用范围

  • 最优子结构:问题的最优解包含子问题的最优解

  • 无后效性:

    • 当前阶段的状态仅由以前阶段决定;
    • 后续阶段状态变换不会影响当前阶段的状态
  • 重复子问题:解决问题过程中存在子问题的大量重复计算

  • 不适用动态规划的反例:最长路径问题

    • A-C的最长路径jinggB,但这条最长路径并不一定包含A-B的最长路径

递归总结

递归三定律

  • 基本条件
  • 减小规模,改变状态
  • 调用自己

小结

  • 某些情况下,递归可以代替迭代循环
  • 递归算法通常能够跟问题的表达自然契合
  • 递归不总是最合适的算法,有时候会产生大量的重复计算
  • 记忆化/函数值缓存可以附加存储空间记录中间计算结果有效减小重复计算
  • 如果一个问题的最优解包括规模更小相同问题的最优解,可以使用动态规划