动态规划

211 阅读3分钟

动态规划是一种算法的设计技巧,通常用来解决最优化问题。动态规划将主问题拆分为子问题,通过求解子问题的最优解,组合出原问题的一个最优解。

拆分成子问题这一点和分治法相似,但是使用动态规划的时候,拆分的子问题是有重叠的,而分治法拆分的子问题没有重叠。

以归并排序 和 计算斐波那契数列 为例,归并排序的子问题没有重叠的部分,而斐波那契数列的计算中,相同的子问题存在多次计算(图中深色的节点就是有重复计算的子问题)。

image-20220320172540336.png

斐波那契数列:f(n)={f(0)=0f(1)=1f(n)=f(n1)+f(n2)其中 n>1斐波那契数列: f(n) = \begin{cases} f(0) = 0\\ f(1) = 1\\ f(n) = f(n - 1) + f(n - 2)& 其中~n > 1\\ \end{cases}

image-20220320172609448.png

求最优解这一点和贪心算法相似,但贪心算法只是在每一步做出当时最优的选择,一直做贪心选择,最终得到最优解。而动态规划是要得出子问题的最优解。

动态规划有两种实现方式:

  • 带备忘的自顶向下方法:将子问题的解保存下来,进行递归计算的过程中,如果遇到需要计算这个子问题的地方,先检查有没有这个子问题的解,如果已经存在这个解了,直接使用,没有这个子问题的解,就进行计算得到该解并保存下来。
  • 自底向上方法:将子问题按照规模从小到大的顺序进行计算,当遇到某个子问题的时候,它需要的所有子子问题都已经计算过了,直接使用即可。每个子问题只求解一次。

斐波那契数列

斐波那契数列可以用动态规划处理,但它其实不是典型的使用动态规划解决的问题,因为它并不是求最优解,而是简单地进行计算。但是因为它比较简单,所以就用它来说明下带备忘的自顶向下方法和自底向上方法。(Python实现)

斐波那契数列:f(n)={f(0)=0f(1)=1f(n)=f(n1)+f(n2)其中 n>1斐波那契数列: f(n) = \begin{cases} f(0) = 0\\ f(1) = 1\\ f(n) = f(n - 1) + f(n - 2)& 其中~n > 1\\ \end{cases}

  1. 带备忘的自顶向下方法:

还是使用了递归,但是将计算过程中子问题的结果存下来了,遇到时直接使用,所以实际上每个fib(n) 只计算了一次,时间复杂度为O(n)O(n),因为额外使用了一个n + 1的数组来保存结果,所以空间复杂度为O(n)O(n)。(细致一点把其他变量算进来,空间复杂度也为O(n)O(n)

class Solution(object):
    def fib(self, n):
        fn = [None] * (n + 1) # 初始化一个长度为n + 1 的数组,存放子问题的计算结果
        return self.fib_handler(n, fn)
    
    def fib_handler(self, n, fn):
        fn_solution = fn[n]
        if fn_solution:
            return fn_solution
        else:
            if n <= 1:
                fn[n] = n
                return n
            solution = self.fib_handler(n - 1, fn) + self.fib_handler(n - 2, fn)
            fn[n] = solution
            return solution
  1. 自底向上方法:

n从小到大进行计算,比如,当计算fib(5) 的时候,fib(4)fib(3)一定已经计算过了,直接用fib(4) + fib(3) 就能得到fib(5)的结果。每个fib(n)只计算了一次,时间复杂度为O(n)O(n),使用三个变量solutionfn2fn1 来保存中间结果,空间复杂度为O(1)O(1)

class Solution(object):
    def fib(self, n):
        if n <= 1:
            return n
        
        fn1 = 1 # 表示f(n - 1)
        fn2 = 0 # 表示f(n - 2)
        
        for _ in range(2, n + 1):
            solution = fn1 + fn2
            fn2 = fn1
            fn1 = solution
        
        return solution

打家劫舍

题目:

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

分析

  1. 总而言之,就是不能偷相邻房屋,并且要偷到最高金额。

  2. 通常情况下,这时候要先把大问题分为小问题,并且整理出一个从小问题的解到大问题的解之间的方程,就比如f(n)=f(n1)+f(n2)f(n) = f(n - 1) + f(n - 2)。这种方程称为状态转移方程

    (1)找子问题:用 f(i)f(i) 表示从0号房屋到ii号房屋能偷到的最大金额(这样表示的话,我们所求的最终答案就是f(n1) f(n-1)) 。

    (2)找状态转移方程:

    ​ 当到了第 ii 个房屋的时候,小偷有两种选择,偷 或者 不偷。

    ​ 如果偷,因为不能相邻,所以最多能偷:f(i2)+nums[i]f(i - 2) + nums[i],也就是在隔了一个房屋的前一个房屋能偷到的最大金额加上 偷的当前房屋的金额。

    ​ 如果不偷,最多能偷:f(i1)f(i - 1),也就是在相邻的前一个屋能偷到的最大金额。

    ​ 所以小偷在第 ii 个房屋能偷到的最大金额是两种选择中金额较大的那一个:max(f(i2)+nums[i],f(i1))max(f(i - 2) + nums[i], f(i -1)),这个方程中ii 必须大于等于2,否则就会得到负的索引。

    ​ 当只有一个房屋的时候没得选,有两个房屋的时候选择金额较大的那个。

    ​ 状态转移方程就找到了:

            f(i)={f(0)=nums[0]f(1)=max(nums[0],nums[1])f(i)=max(f(i2)+nums[i],f(i1))其中 i>1~~~~~~~~f(i) = \begin{cases} f(0) = nums[0]\\ f(1) = max(nums[0], nums[1])\\ f(i) = max(f(i - 2) + nums[i], f(i -1))& 其中~i > 1\\ \end{cases}

  3. 找到方程之后,用代码实现这个方程就可以了。

class Solution(object):
    def rob(self, nums):
        n = len(nums)
        
        if n == 0:
            return 0

        fi = [0] * n # 存放每一个房屋偷窃的最大金额

        fi[0] = nums[0]
        
        if n > 1:
            fi[1] = max(nums[0], nums[1])

        for i in range(2, n):
            fi[i] = max(fi[i - 2] + nums[i], fi[i - 1])
        
        return fi[n - 1]

每一个房屋计算一次金额,时间复杂度为O(n)O(n),因为用了fi数组来存放每个房屋偷窃的最大金额,空间复杂度为O(n)O(n)

以上两个算法例子的LeetCode地址:

  1. 斐波那契数
  2. 打家劫舍