递归、分治

238 阅读3分钟

递归

递归的本质与基本实现形式

递归是一种解决问题的方法,与我们日常处理事情不同,日常我们处理一件事情可能会分为第一阶段、第二阶段...,而递归其精髓在于将问题分解为规模更小的相同问题,持续分解,直到问题规模小到可以用非常简单直接的方式来解决。递归的问题分解方式非常独特,其算法方面的明显特征就是:在算法流程中调用自身

递归为我们提供了一种对复杂问题的优雅解决方案,精妙的递归算法常会出奇简单令人赞叹。

简而言之,如何理解递归?

  • 函数自身调用自身
  • 通过函数体来进行的循环
  • 以自相似的方法重复进行的过程

典型应用

假设,我们给定一个列表[1,3,5,7,9],返回所有数的和

由于列表中数的个数不定,需要一个循环和一个累加变量来迭代求和

def listsum(numList):
    theSum = 0
    for i in numList:
        theSum += i
    return theSum
print(listsum([1,3,5,7,9]))

程序很简单,但假如不使用循环语句,既不能用for,也不能用while还能对不确定长度的列表求和么?

我们认识到求和实际上最终是由一次次的加法实现的,而加法恰有2个操作数,这个是确定的。

思考:怎么将问题规模较大的列表求和,分解为规模较小而且固定的2个数求和(加法)?

答案是:同样是求和问题,但规模发生了变化,符合递归解决问题的特征!这时候我们就可以使用递归

换个方式来表达数列求和:全括号表达式

(1+(3+(5+(7+9))))

上面这个式子,最内层的括号(7+9) ,这是无需循环即可计算的,实际,上整个求和的过程是这样:

total= (1 + (3+(5+(7 + 9))))

total= (1 +(3+ (5+ 16)))

total= (1 +(3 + 21))

total= (1 + 24)

total = 25

观察上述过程中所包含的重复模式,可以把求和问题归纳成:

数列的和=“首个数”+“余下数列”的和数列的和=“首个数”+“余下数列”的和

如果数列包含的数少到只有1个的话,它的和就是这个数了,这是规模小到可以做最简单的处理

image.png

所以我们可以对上面的程序进行修改

def listsum(numList):
    if len(numList) == 1: # 递归结束条件
        return numList[0]
    else:
        return numList[0] + listsum(numList[1:])
print(listsum([1,3,5,7,9]))

再比如,我们计算nn!

def factorial(n):
    if n <= 1:
        return 1
    return n*factorial(n-1)

为什么不选择使用n!递推????

递推不太容易找到推导路径的问题,比如下面的问题,一棵树,在根节点的时候,我们是不知道下边长什么样的......

递归的三个关键

  • 定义需要递归的问题(重叠子问题)——数学归纳法思维
  • 确定递归边界
  • 保护与还原现场

为了向阿西莫夫的“机器人三定律”致敬,递归算法也总结出 “三定律”

  1. 递归算法必须有一个基本结束条件(最小规模问题的直接解决)
  2. 递归算法必须能改变状态向基本结束条件演进(减小问题规模)
  3. 递归算法必须调用自身(解决减小了规模的相同问题)

python代码模板

def recursion( level, param1, param2,...):
#recursion terminator
if level > MAX_LEVEL:
#process resultreturn
# process Logic in current levelprocess( level, data. . . )
#drill down
self.recursion( level + 1, new_param1,...)
#restore the current level status if needed

深度限制问题

在调试递归算法程序的时候经常会碰到这样的错误: RecursionError---出错原因:递归的层数太多,系统调用栈容量有限。因为系统对栈的调用是需要一定的内存的,所以出现RecursionError时,我们需要:

  1. 检查程序是否忘记了结束条件以至于导致无限递归,如:
def tell_story():
    print('从前有座山,山上有座庙,庙里有个老和尚,他在讲: ')
    tell_story()
print('我给你讲个故事:')
tell_story()
  1. 或者向基本结束条件演进太慢,导致递归层数太多,调用栈溢出,比如我们上面所讲的数列求和的问题,假设栈的深度是1000,我们需要对2000个数进行求和时就会引发这个错误。

那么,怎么了解递归的深度限制并且按照实际情况对其进行修改呢?

在Python内置的sys模块可以获取和调整最大递归深度

import sys
sys.getrecursionlimit()
sys.setrecursionlimit(2000)
sys.getrecursionlimit()

递归的使用

应用

进制转换

我们用最熟悉的十进制分析下这个问题,十进制有十个不同符号: convString ="0123456789"

  • 比十小的整数,转换成十进制,可以直接查表获取: convString[n]
  • 想办法把比十大的整数,拆成一系列比十小的整数,逐个查表

比如九百二十一,拆成九、二、一,查表得到921就可以了

回想我们上面提及到的递归三定律,我们发现“基本结束条件”就是小于10的整数,拆解整数的过程就是向“基本结束条件”演进的过程。我们用整数除,和求余数两个计算来将整数一步步拆开:

  • 除以“进制基base”(// base) # (base为几进制)
  • 对“进制基”求余数(% base)

余数总小于“进制基base”,是“基本结束条件”,可直接进行查表转换,整数商成为、“更小规模”问题,通过递归调用自身解决

image.png

同样的,二进制,八进制,十六进制也可以通过上面的方法实现,下面附上实现的代码

def toStr(n, base):
    convertString = '0123456789ABCDEF'
    if n < base:
        return convertString[n]
    else:
        return toStr(n//base, base) + convertString[n%base]

递归调用的实现:

当一个函数被调用的时候,系统会把调用时的现场数据压入到系统调用栈, 每次调用,压入栈的现场数据称为栈帧,当函数返回时,要从调用栈的栈顶取得返回地址恢复现场,弹出栈帧,按地址返回。下面我们以将10转换为二进制为例进行说明:

image.png

汉诺塔

图片来源:xxx.ilovefishc.com/forum/20171…

汉诺塔问题是法国数学家Edouard Lucas于1883年,根据传说提出来的。传说在一个印度教寺庙里,有3根柱子,其中一根套着64个由小到大的黄金盘片,僧侣们的任务就是要把这一叠黄金盘从一根柱子搬到另一根,但有两个规则:

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

image.png

神的旨意说一旦这些盘子完成迁移寺庙将会坍塌,世界将会毁灭......神的旨意是千真万确的!

下面我们以3个盘片进行演示:

image.png

那么为什么说神的旨意是千真万确的呢?

虽然这些黄金盘片跟世界末日有着神秘的联系,但我们却不必太担心,,据计算,要搬完这64个盘片:

需要的移动次数为2641=18,446,744,073,709,551,6152^{64}-1 =18,446,744,073,709,551,615

如果每秒钟搬动一次,则需要584,942,417,355(五千亿)年!

我们还是从递归三定律来分析河内塔问题基本结束条件(最小规模问题),如何减小规模,调用自身。

假设我们有5个盘子,穿在1#柱,需要挪到3#柱

image.png

如果能有办法把最上面的一摞4个盘子统统挪到2#柱(如下图),那问题就好解决了:

image.png

把剩下的最大号盘子直接从1#柱挪到3#柱,再用同样的办法把2#柱上的那一摞4个盘子挪到3#柱,就完成了整个移动。这样我们的问题就分解成了解决4个盘子如何从1#挪到2#,问题的规模已经减小了,同样是想办法把上面的一摞3个盘子挪到3#柱,把剩下最大号盘子从1#挪到2#柱,再用同样的办法把一摞3个盘子从3#挪到2#柱。一摞3个盘子的挪动也照此:分为上面一摞2个,和下面最大号盘子那么2个盘子怎么移动?不行,就再分解为1个盘子的移动

那么,我们总结一下问题解决的思路:

将盘片塔从开始柱,经由中间柱,移动到目标柱:

  • 首先将上层N-1个盘片的盘片塔,从开始柱,经由目标柱,移动到中间柱;
  • 然后将第N个(最大的)盘片,从开始柱,移动到目标柱;
  • 最后将放置在中间柱的N-1个盘片的盘片塔,经由开始柱,移动到目标柱。

基本结束条件,也就是最小规模问题是:1个盘片的移动问题

def moveDisk(disk, fromPoke, toPole):
    print(f'Moving disk[{disk}] form {fromPoke} to {toPole}')

def moveTower(height, fromPole, withPole, toPole):
    """
    :param height: 盘片的高度
    :param fromPole: 开始柱
    :param withPole:中间柱
    :param toPole:目标柱
    :return:
    """
    if height >= 1:
        moveTower(height - 1, fromPole, toPole, withPole )
        moveDisk(height,fromPole, toPole) 
        moveTower(height - 1, withPole, fromPole, toPole)

moveTower(3, '1#', '2#', '3#')

我们可以看见运行的结果和我们上面3个盘片的演示是一致的。

可视化

前面的种种递归算法展现了其简单而强大的一面,但还是难有个直观的概念,下面我们通过递归作图来展现递归调用的视觉影像, 下面内容需要使用turtle模块

具体介绍:docs.python.org/3/library/t…

螺旋线

我们可以把螺旋线抽象成一次又一次绘制框的问题,当绘制的框小到一定程度时,停止绘制

import turtle

t = turtle.Turtle()
def drawSpiral(t, lineLen):
    if lineLen > 0:
        t.forward(lineLen)
        t.right(90)
        drawSpiral(t, lineLen-5)

drawSpiral(t, 100)
turtle.done()

分形树

图片来源:

ts1.cn.mm.bing.net/th/id/R-C.5…

分形(Fractal)是1975年由Mandelbrot开创的新学科“一个粗糙或零碎的几何形状,可以分成数个部分,且每一部分都(至少近似地)是整体缩小后的形状”,即具有自相似的性质。而分形树就是自相似递归图形。

自然界中的自相似例子也很多,海岸线,山脉,闪电,云朵......自然现象中所具备的分形特性,使得计算机可以通过分形算法生成非常逼真的自然场景。分形是在不同尺度上都具有相似性的事物我们能看出一片叶子上的每个子叶,实际上都具有整片叶子的外形特征。

图像来源:uploadfile.bizhizu.cn/up/e1/b6/0e…

这样,我们想要绘制一棵树时,可以把树分解为三个部分:树干、左边的小树、右边的小树。分解后,正好符合递归的定义:对自身的调用

import turtle
def tree(branch_len):
    if branch_len > 5:  # 树干太短不画,即递归结束条件
        t.forward(branch_len) # 画树干
        t.right(20) # 右倾斜20tree(branch_len - 15) # 递归调用,画右边的小树,树干减15
        t.left(40) # 向左回40度,即左倾斜20tree(branch_len - 15) # 递归调用,画左边的小树,树干减15
        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()

谢尔宾斯基三角

分形构造,平面称谢尔宾斯基三角形,立体称谢尔宾斯基金字塔

实际上,真正的谢尔宾斯基三角形是完全不可见的,其面积为0,但周长无穷,是介于一维和二维之间的分数维(约1.585维)构造。

图片来源:p1.ssl.qhmsg.com/t013113e379…

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

由于我们无法真正做出谢尔宾斯基三角形(degree->∞),只能做degree有限的近似图形。

在degree有限的情况下,degree=n的三角形,是由3个degree=n-1的三角形按照品字形拼叠而成,同时,这3个degree=n-1的三角形边长均为degree=n的三角形的一半(规模减小)。当degree=0,则就是一个等边三角形,这是递归基本结束条件。代码如下:

import turtle
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 )



def sierpinski(degree, points):   # degree是阶数,points是等边三角形的坐标
    colormap = ['blue', 'red', 'green', 'white', 'yellow', 'orange']
    drawTriangle(points, colormap[degree])
    if degree > 0:  # 最小规模为0
        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['top'], points['right']),'right':points['right']})   # 对右边分区绘制

t = turtle.Turtle()
# 最外面的轮廓的三点
points = {'left':(-200, -100),'top':(0, 200),'right' :(200, -100)}
# 调用函数绘制五阶的三角形
sierpinski(5, points)
turtle . done()

谈到了递归就离不开树,树是什么?树其实就是递归产生的

树上面有一个树根,树根我们可以看作是子问题。它跟递归是天然的一个很搭配的一个数据结构。假设树根叫root,剩余的每一个点都有一个父亲结点(比如H的父节点是D),以及D结点的子结点是H和I,树还可以有子树sub-tree(比如B有DHI和EJ两个子树),子结点也分为左节点和右节点(比如C的左节点是F,右节点是G),以及兄弟结点(比如FG互为兄弟)。一棵树通常有N层,我们可以把根叫作第0层,也可以叫第1层,不同的教程定义不一样,但是从根往后,每一层层数加1

详细介绍后面再讲

分治

分治,即“分而治之”,就是把原问题划分成若干个同类子问题,分别解决后,再把结果合并起来

关键点

递归是实现形式,分治是一个算法

  • 原问题和各个子问题都是重复的(同类的)------递归定义
  • 除了向下递归“问题”,还要向上合并“结果”
  • 分治算法一般用递归实现

分治算法的“递归状态树”

递归三定律体现了分治策略,问题解决依赖于若干缩小了规模的问题,汇总得到原问题的解应用相当广泛:排序、查找、遍历、求值等等

优化问题和贪心策略

计算机科学中许多算法都是为了找到某些问题的最优解

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

找零兑换问题

优化问题中一个经典案例是兑换最少个数的硬币问题。

假设你为一家自动售货机厂家编程序,自动售货机要每次找给顾客最少数量硬币;假设某次顾客投进$1\$1 纸币,买了c37\bcancel{c}37 的东西,要找c63\bcancel{c}63 ,那么最少数量就是: 2个quarter (c25\bcancel{c}25 )1个dime (c10\bcancel{c}10 ) 和3个penny (c1\bcancel{c}1 )共6个

人们会采用各种策略来解决这些问题,例如最直观的 “贪心策略”, 一般我们这么做:

从最大面值的硬币开始,用尽量多的数量有余额的,再到下一最大面值的硬币,还用尽量多的数量,一直到penny(c1\bcancel{c}1 )为止

因为我们每次都试图解决问题的尽量大的一部分对应到兑换硬币问题,就是每次以最多数量最大面值硬币来迅速减少找零面值

注意:贪心算法几乎只在乎局部的最优解,具有一定的局限性,会出现贪心策略失效的问题

递归解法

我们来找一种肯定能找到最优解的方法

思考:贪心策略是否有效依赖于具体的硬币体系???

  • 首先是确定基本结束条件,兑换硬币这个问题最简单直接的情况就是,需要兑换的找零,其面值正好等于某种硬币,如找零25分,答案就是1个硬币!

  • 其次是减小问题的规模,我们要对每种硬币尝试1次,例如美元硬币体系:

    • 找零减去1分(penny)后,求兑换硬币最少数量(递归调用自身) ;
    • 找零减去5分(nikel)后,求兑换硬币最少数量
    • 找零减去10分(dime)后,求兑换硬币最少数量
    • 找零减去25分(quarter)后,求兑换硬币最少数量
def recMC(coinValusList, change):
    minCoins = change
    if change in coinValusList:
        return 1
    else:
        for i in [c for c in coinValusList if c <= change]:
            numCoins = 1 + recMC(coinValusList, change-i)
            if numCoins < minCoins:
                minCoins = numCoins
    return minCoins

for循环使所有的硬币面值组合都被计算了一遍,c <= change筛选了面值比目标面值小的硬币面值,通过change-i减小规模,选择硬币数最少的numCoins或minCoins

虽然上面的程序很简洁,但是效率极低,运行时间长,原因是我们把所有的可能性都计算了一遍,重复计算的次数很多。

思考:怎么对递归算法进行改进呢?

答案是:我们要消除重复计算。我们可以用一个表将计算过的中间结果保存,计算前先检查目标值是否已经被计算过。

这个算法的中间结果就是部分找零的最优解,在递归调用过程中已经得到的最优解被记录下来:在递归调用之前,先查找表中是否已有部分找零的最优解,如果有,直接返回最优解而不进行递归调用;如果没有,才进行递归调用

def recDC(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 + recDC( coinValueList,change - i, knownResults)
            if numCoins < minCoins:
                minCoins = numCoins
                #找到最优解,记录到表中
                knownResults[change] = minCoins
    return minCoins
print(recDC([1,5,10,25],63, [0]*64))

我们比较一下两个程序之间的效率

start = time.time()
end = time.time()
print('Running time:%s Seconds'%(end-start))

动态规划

动态规划化解决找零问题

递归解法里,中间结果记录可以很好解决找零兑换问题,实际上,这种方法还不能称为动态规划,而是叫做"memoization (记忆化/函数值缓存)”的技术提高了递归解法的性能。

  • 动态规划算法采用了一种更有条理的方式来得到问题的解
  • 找零兑换的动态规划算法从最简单的“1分钱找零”的最优解开始,逐步递加上去,直到我们需要的找零钱数
  • 在找零递加的过程中,设法保持每一分钱的递加都是最优解,一直加到求解找零钱数,自然得到最优解

递加的过程能保持最优解的关键是,其依赖于更少钱数最优解的简单计算,而更少钱数的最优解已经得到了。问题的最优解包含了更小规模子问题的最优解,这是一个最优化问题能够用动态规划策略解决的必要条件。

image.png

计算11分钱的兑换法,我们做如下几步:

  • 首先减去1分硬币,剩下10分钱查表最优解是1
  • 然后减去5分硬币,剩下6分钱查表最优解是2
  • 最后减去10分硬币,剩下1分钱查表最优解是1

通过上述最小值得到最优解:2个硬币

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]

我们注意到动态规划算法的dpMakeChange并不是递归函数,虽然这个问题是从递归算法开始解决,但最终我

们得到一个更有条理的高效非递归算法。

动态规划中最主要的思想是:

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

算法拓展:

前面的算法已经得到了最少硬币的数量,但没有返回硬币如何组合,扩展算法的思路很简单,只需要在生成最优解列表同时跟踪记录所选择的那个硬币币值即可:在得到最后的解后,减去选择的硬币币值回溯到表格之前的部分找零,就能逐步得到每一步所选择的硬币币值

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 = coinsUsed[coin]
        print(thisCoin)
        coin = coin - thisCoin


amnt = 63
clist = [1,5,10,25]
coinsUsed = [0] * (amnt + 1)
coinCount = [0] * (amnt + 1)
print("Making change for",amnt,"requires" )
print(dpMakeChange(clist,amnt,coinCount,coinsUsed),"coins")
print("They are:")
printCoins(coinsUsed,amnt)
# print("The used list is as follows:")
# print(coinsUsed)

博物馆大盗问题

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

itemweightvalue
123
234
348
458
5910

这时候,贪心策略是会失效的,因为不一定先带走item=5那件宝物的解就是最优解,我们使用动态规划对该问题进行分析。

我们把m(i,W)m(i, W)记为:前i(1i  5)i(1\le i \le 5) 个宝物中,组合不超过W(1W 20)W(1\le W \le 20) 重量,得到的最大价值m(i,W)m(i, W)应该是m(i1,W)m(i-1, W)m(i1,WWi)+vim(i-1, W-W_i)+v_i 两者最大值我们从m(1,1)m(1, 1)开始计算到m(5,20)m(5, 20)

image.png

#宝物的重量和价值
tr = [None, {'w':2,'v':3}, {'w':3, 'v':4},{'w':4,'v':8},{'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'])

image.png