算法基础之递归与动态规划


参考https://labuladong.gitbook.io/algo/dong-tai-gui-hua-xi-lie/dong-tai-gui-hua-xiang-jie-jin-jie
注:图片来源见水印

1. 递归算法的适用场景

当一个大规模的问题与小规模的问题有着相同的形式,解决大规模的问题和解决小问题的方法是同一个方法时,就可能可以用递归的算法来解决。递归算法的特点为:一个函数递归调用本身(由后向前),通过递归调用来缩小问题的规模,直到最终遇到退出条件(递归基),再由前向后组装出目标解。

2. 递归的三要素

  1. 明确函数的目的,确定函数的输入参数和返回值.
  2. 寻找递归的结束条件(递归基)。
  3. 找出函数的等价关系,也即大规模问题转换为小规模问题的表现形式

3. 递归的缺点

操作系统会给每个进程分配一个最大上限的堆栈空间,如果超过了这个内存空间大小程序就会崩溃(core dump).函数调用的参数是通过栈空间来传递的,在调用过程中会占用线程的栈资源(用来存储函数的参数、局部变量等。但是如果编译器会优化的话好像局部变量不会存在这里)。栈空间一般比较小,往往只有几M。而递归调用,只有走到最后的结束点后函数才能依次退出,而未到达最后的结束点之前,占用的栈空间一直没有释放,如果递归调用次数过多,就可能导致占用的栈资源超过线程的最大值,从而导致栈溢出,导致程序的异常退出。

此外,递归可能会进行大量重复运算,时间复杂度指数级,导致耗时严重。具体例子见后文。

4. 递归算法的例子

4.1. Fibonacci数列

leetcode-剑指 Offer 10- I. 斐波那契数列
写一个函数,输入 n ,求斐波那契(Fibonacci)数列的第 n 项。斐波那契数列的定义如下:

F(0) = 0, F(1) = 1
F(N) = F(N - 1) + F(N - 2), 其中 N > 1.

斐波那契数列由 0 和 1 开始,之后的斐波那契数就是由之前的两数相加而得出。
答案需要取模 1e9+7(1000000007),如计算初始结果为:1000000008,请返回 1。

4.1.1. 解法一:原始的递归不做任何优化

java代码:

class Solution {
    public int fib(int n) {
        if(n<2){
            return n;
        }
        return (fib(n-1)+fib(n-2))%1000000007;
    }
}

python代码:

class Solution:
    def fib(self, n: int) -> int:
        if n<2:
            return n
        return (self.fib(n-1)+self.fib(n-2))%1000000007

这种算法在n>=43以后就超出时间限制,这是因为进行了大量重复运算,解释如下:
在这里插入图片描述

图4.1 Fibonacci递归算法求解示意图

由上图可见,大量子问题都进行了重复运算。

4.1.1.1. 时间复杂度分析

T ( n ) T(n) T(n)为求 f ( n ) f(n) f(n)的时间复杂度,根据 f ( n ) f(n) f(n)的递归表达式容易得到 T ( n ) T(n) T(n)的递归表达式:
T ( n ) = T ( n − 1 ) + T ( n − 2 ) + 1 (4-1) T(n)=T(n-1)+T(n-2)+1\tag{4-1} T(n)=T(n1)+T(n2)+1(4-1)
且有 T ( 0 ) = T ( 1 ) = 1 T(0)=T(1)=1 T(0)=T(1)=1.
S ( n ) = T ( n ) + 1 2 S(n)=\frac{T(n)+1}{2} S(n)=2T(n)+1,则有:
S ( n ) = S ( n − 1 ) + S ( n − 2 ) (4-2) S(n)=S(n-1)+S(n-2)\tag{4-2} S(n)=S(n1)+S(n2)(4-2)
且有 S ( 0 ) = 1 , S ( 1 ) = 1 S(0)=1,S(1)=1 S(0)=1,S(1)=1
也即 S ( n ) S(n) S(n)是Fibonacci数列的第n+1项,容易得到其通项(具体推导见Fibonacci数列的一般形式——二阶常系数齐次差分方程的解法 )为:
S ( n ) = 1 5 ( 1 + 5 2 ) n + 1 − 1 5 ( 1 − 5 2 ) n + 1 (4-3) S(n)=\frac{1}{\sqrt{5}}\left(\frac{1+\sqrt{5}}{2}\right)^{n+1}-\frac{1}{\sqrt{5}}\left(\frac{1-\sqrt{5}}{2}\right)^{n+1}\tag{4-3} S(n)=5 1(21+5 )n+15 1(215 )n+1(4-3)
T ( n ) T(n) T(n)的通项为:
T ( n ) = 2 f i b ( n + 1 ) − 1 = 2 5 ( 1 + 5 2 ) n + 1 − 2 5 ( 1 − 5 2 ) n + 1 − 1 \begin{align*} T(n)&=2fib(n+1)-1\\ &=\frac{2}{\sqrt{5}}\left(\frac{1+\sqrt{5}}{2}\right)^{n+1}-\frac{2}{\sqrt{5}}\left(\frac{1-\sqrt{5}}{2}\right)^{n+1}-1\\ \tag{4-4} \end{align*} T(n)=2fib(n+1)1=5 2(21+5 )n+15 2(215 )n+11

4.1.1.2. 空间复杂度

显然,空间复杂度是图4.1中递归二叉树的最大深度,因此为 O ( n ) O(n) O(n)

4.1.2. 带备忘录的递归算法

为了避免大量重复运算,我们可以用一个数组将一些中间计算结果存起来。

class Solution {
    public int fib(int n) {
        if(n<2){
            return n;
        }
        long[] memo=new long[n];
        return (int)((helper(n-1,memo)+helper(n-2,memo))%1000000007);
    }
    private long helper(int n, long[] memo){
        if(n<2) return (long)n;
        if(memo[n]!=0) return memo[n];
        memo[n]=(helper( n-1, memo)+helper( n-2, memo))%1000000007;
        return memo[n];
    }
}

上述算法其实是对递归生成的二叉树进行了大量的剪枝,如下图所示:
在这里插入图片描述

图4.2 利用数组对递归生成的二叉树剪枝

时间复杂度和空间复杂度都变成了 O ( n ) O(n) O(n)。此时的递归算法和动态规划已经很像了,不过递归是先从后往前分解问题规模,再从前往后返回答案。而动态规划一般是直接从前往后根据递推式计算答案。
一般动态规划的场景中往往还有其他限制条件,例如求最优方案,后文将详细介绍。

4.1.3. 利用动态规划解决Fibonacci

从前往后递推,并且可以优化存储空间,只存储最近的两次结果,每次递推迭代时记得更新最近的两次结果变量。

class Solution {
    public int fib(int n) {
        if(n<2) return n;
        int result=0;
        int a=0,b=1;
        for(int i=2;i<=n;i++){
            result=(a+b)%1000000007;
            a=b;
            b=result;
        }
        return result;
    }
}

5. 动态规划

在动态规划里面,递推式一般叫做状态转移方程

5.1. 动态规划例子

5.1.1. 凑零钱问题

leetcode-322. 零钱兑换
给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

5.1.1.1. 递归法

状态转移方程为:
f ( v ) = { − 1 , v < 0 0 , , v = 0 m i n f ( v − c i ) + 1 ∣ c i ∈ c o i n s , n > 0 (5-1) f(v)=\left\{ \begin{aligned} -1,v&<0 \\ 0,,v&=0 \\ min{f(v-c_i)+1|c_i\in coins},n&>0 \end{aligned} \right. \tag{5-1} f(v)= 1,v0,,vminf(vci)+1∣cicoins,n<0=0>0(5-1)
上述递推式的含义为,要求组成价值为 v v v的最少硬币数,只需分别算出组成价值 v − c i v-c_i vci的最少硬币数再+1,然后看减去哪种 c i c_i ci下是硬币数量最少的情况。这样将能减少问题规模,并且在小规模的问题上可以复用上述策略

class Solution {
    public int coinChange(int[] coins, int amount) {
        return dp(coins,amount);
    }
    private int dp(int[] coins, int amount){
        if(amount==0) return 0;
        if(amount<0) return -1;
        int result=Integer.MAX_VALUE;
        for(int coin : coins){
            int subResult=dp(coins, amount-coin);
            if(subResult<0) continue;
            result=Math.min(result,1+subResult);
        }
        return result!=Integer.MAX_VALUE?result:-1;
    }
}

上面的暴力递归法很容易超出时间限制,这是因为存在大量重复计算,如下图所示:
在这里插入图片描述

图5.1 零钱兑换递归示意图
5.1.1.2. 带备忘录的递归(从后往前的动态规划)
  1. 用一个HashMap memo存下中间结果,key是amount,value是result
public class Solution1 {
    Map<Integer, Integer> memo = new HashMap<Integer, Integer>();

    public int coinChange(int[] coins, int amount) {
        return dp(coins, amount);
    }

    private int dp(int[] coins, int amount) {
        if (amount == 0) {
            return 0;
        }
        if (amount < 0) {
            return -1;
        }
        if (memo.containsKey(amount)) {
            return memo.get(amount);
        }
        int result = Integer.MAX_VALUE;
        for (int coin : coins) {
            int subResult = dp(coins, amount - coin);
            if (subResult < 0) {
                continue;
            }
            result = Math.min(result, 1 + subResult);
        }
        if (result == Integer.MAX_VALUE) {
            memo.put(amount, -1);
        } else {
            memo.put(amount, result);
        }
        return memo.get(amount);
    }
}
  1. 用一个数组存储中间结果,index是amount,value是amount
public class Solution2 {
    int[] memo;

    public int coinChange(int[] coins, int amount) {
        memo = new int[amount + 1];// 默认初始化0
        return dp(coins, amount);
    }

    private int dp(int[] coins, int amount) {
        if (amount == 0) {
            return 0;
        }
        if (amount < 0) {
            return -1;
        }
        if (memo[amount] != 0) {
            return memo[amount];
        }

        int result = Integer.MAX_VALUE;
        for (int coin : coins) {
            int subResult = dp(coins, amount - coin);
            if (subResult < 0) {
                continue;
            }
            result = Math.min(result, 1 + subResult);
        }
        if (result == Integer.MAX_VALUE) {
            memo[amount] = -1;
        } else {
            memo[amount] = result;
        }
        return memo[amount];
    }
}
5.1.1.3 从前往后的动态规划

前面的动态规划解法本质上还是递归,时间消耗和空间消耗都比从前往后的动态规划要大。这里我们采用从前往后的动态规划,注意每次递推的时候,要判断前面的项下标是否大于0,以及值是否大于等于0。同时在每次遍历完coins后要将对memo[i]进行判断,如果仍为0则应该置为-1

class Solution3 {
    public int coinChange(int[] coins, int amount) {
        int[] memo = new int[amount + 1];// 默认初始化0
        memo[0] = 0;
        for (int i = 1; i <= amount; i++) {
            for (int coin : coins) {
                if (i - coin >= 0 && memo[i - coin] >= 0) {
                    if (memo[i] > 0) {
                        memo[i] = Math.min(memo[i], memo[i - coin] + 1);
                    } else {
                        memo[i] = memo[i - coin] + 1;
                    }
                }
            }
            if (memo[i] == 0) {
                memo[i] = -1;
            }
        }
        return memo[amount];
    }
}

当然,官方给的解答更巧妙一些,提前将memo初始化为amount,这样就可以减少一些操作。

public class Solution {
public int coinChange(int[] coins, int amount) {
        int[] memo = new int[amount + 1];// 默认初始化0
        Arrays.fill(memo, amount + 1);
        memo[0] = 0;
        for (int i = 1; i <= amount; i++) {
            for (int coin : coins) {
                if (i - coin >= 0) {
                    memo[i] = Math.min(memo[i], memo[i - coin] + 1);
                }
            }
        }
        return memo[amount] > amount ? -1 : memo[amount];
    }
}
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值