从0到1学会动态规划

495 阅读7分钟

这是我参与8月更文挑战的第30天,活动详情查看:8月更文挑战

动态规划是数据结构和算法板块中最难的一部分内容,很多人只要一听到题目是动态规划,瞬间心中会产生恐惧心理,同时动态规划也是大厂面试官比较青睐的一个模块,无论是社招还是校招都是大厂面试时的一部分,说实话,最近我也在学习动态规划,我将从一个小白的角度,从0到1整理动态规划相关内容,从暴力破解记忆化搜索最后到动态规划,循序渐进,一举攻克动态规划。希望能对正在阅读该文章的你提供一些帮助。

第一部分

斐波那契数列

F(0)=0,F(1)=1,F(n)=F(n-1)+F(n-2)

很容易就可以写出如下的代码:

    int fib(int n) {
        if (n == 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        } else {
            return fib(n - 1) + fib(n - 2);
        }
    }

如果我们计算fib(5)的时候,他的递归树如下图所示:

image.png 仅仅计算第5个值的时候,函数就要递归如此多次,而且,有很多次都是重复计算的。 如果我们计算fib(100)的话,哪会有很多很多次重复的计算。

记忆化搜索

为了避免重复的计算,我们可以计算好的结果存到一个容器中,如果之前已经计算了我们需要的值,我们直接返回就可以了,不需要重复的计算。

    int fib(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("n value must be > 0");
        }
        int[] memo = new int[n + 1];
        // 初始化数组中的值都为-1
        // 初始化为-1的原因是因为fib计算的结果不可能是-1,都是大于等于0的
        for (int i = 0; i < memo.length; i++) {
            memo[i] = -1;
        }
        return fibCore(n, memo);
    }

    int fibCore(int n, int[] memo) {
        if (n == 0) {
            return 0;
        }
        if (n == 1) {
            return 1;
        }
        if (memo[n] == -1) {
            // 之前没有计算过该值,需要计算
            memo[n] = fibCore(n - 1, memo) + fibCore(n - 2, memo);
        }
        return memo[n];
    }

上面这种解决问题的方式自上而下的解决,即我们先不算fib(n),而是算fib(n-1)fib(n-2),一直这样 递推下去,知道出现f(0)或者f(1)才终止。

动态规划

我们可以反过来,自上而下的解决这个问题,即先计算fib(0),fib(1),fib(2)...fib(n),代码如下:

    int fib(int n) {
        if (n < 0) {
            throw new IllegalArgumentException("n value must be > 0");
        }
        int[] memo = new int[n + 1];
        for (int i = 0; i <= n; i++) {
            if (i <= 1) {
                memo[i] = i;
            } else {
                memo[i] = memo[i - 1] + memo[i - 2];
            }
        }
        return memo[n];
    }

动态规划 将原问题拆解成若干个子问题,同时保存子问题的答案,使得每个子问题的求解只求一次,最终获取原问题的答案。

image.png

爬楼梯

题目

假设有一个楼梯,一次可以上一个,或者上两个,问,上n阶台阶,有多少种解法?

image.png

我们上第n阶台阶我们可能是在n-1阶台阶或者n-2阶台阶,而我们上n-1阶台阶可能是n-1-1或者n-1-2阶台阶。依次类推,直到我们上第2阶台阶或者第1阶台阶时结束,上第1阶台阶时,只有一种,而上第二阶台阶时可以是1+1或者2,所以有两种。这样我们的代码可以是这样的。

暴力破解

int climb(int n) {
        if (n == 1) {
            return 1;
        }
        if (n == 2) {
            // 直接爬2阶或者一阶一阶爬
            return 2;
        }
        return climb(n - 1) + climb(n - 2);
    }

我们设想爬第0阶台阶,我们只有一种,就是没有嘛~,这样我们爬第二阶台阶也可以通过climb(1)+climb(0)计算出来,于是,我们的代码就变成了这样:

    int climb(int n) {
        if (n <= 1) {
            return 1;
        }
        return climb(n - 1) + climb(n - 2);
    }

细心的小伙伴可以看出,这个就是我们刚刚写的斐波那契数列。

Triangle

给定一个三角形的数字阵列,选择一条自顶向下的路径,使得沿途的所有数字之和最小(每一步只能移动到相邻的格子中) eg:

最小路径为:2+3+5+1=11

image.png

MiniMum Path Sum

给出一个m*n矩阵,其中每一个格子包含一个非负的整数,寻找一条从左上角到右下角的路径,使得沿路的路径之和最小。 【注意】每次只能向下或者向右移动。

第二部分

给定一个正整数n,可以将其分割成多个数字的和,若要让这些数字的乘机最大,求分割的方法,(至少分成两个数)。返回这个最大的乘机。 如: n=2,则返回1(2=1+1) n=10,则返回36(10=3+3+4)

暴力解法

回溯遍历将一个数做分割,时间复杂度O(2^n)

  1. 此处以分割4为例,如下图:

image.png

  1. 对应分割n,如下图:

image.png

对应的代码如下:

    int breakInteger(int n) {
        if (n == 1) {
            return 1;
        }
        int res = -1;
        for (int i = 1; i < n - 1; i++) {
            // i*(n-i)加入比较的原因
            // 因为函数breakInteger一定要将一个数字分割成两部分,
            // 所以,如果我们只将n分成两部分不想在往下分割了,那就是 i*(n-i)
            res = max(res, i * (n - i), i * breakInteger(n - i));
        }
        return res;
    }

    int max(int a, int b, int c) {
        return Integer.max(a, Integer.max(b, c));
    }

记忆化搜索

上面分割n的图中,我们可以看到该题也会出现”重叠子问题“的情况,所以可以进一步进行”记忆化搜索“,将算好的结果记录下来,避免重复运算。

    int breakInteger(int n) {
        int[] memo = new int[n + 1];
        Arrays.fill(memo, -1);
        return breakIntegerCore(n, memo);
    }

    int breakIntegerCore(int n, int[] memo) {
        if (n == 1) {
            return 1;
        }
        if (memo[n] != -1) {
            return memo[n];
        }
        int res = -1;
        for (int i = 1; i < n - 1; i++) {
            // i*(n-i)加入比较的原因
            // 因为函数breakInteger一定要将一个数字分割成两部分,
            // 所以,如果我们只将n分成两部分不想在往下分割了,那就是 i*(n-i)
            res = max(res, i * (n - i), i * breakIntegerCore(n - i, memo));
        }
        memo[n] = res;
        return res;
    }

    int max(int a, int b, int c) {
        return Integer.max(a, Integer.max(b, c));
    }

动态规划

自底向上解决

    int integerBreak(int n) {
        // memo[i]用于存储将数字i进行分割(至少分割成两部分)后,最大的乘积
        int[] memo = new int[n + 1];
        Arrays.fill(memo, -1);

        memo[1] = 1;

        for (int i = 2; i <= n; i++) {
            // 计算memo[i]
            for (int j = 1; j < i - 1; j++) {
                // i*(i-j)
                memo[i] = max(memo[i], j * (i - j), j * memo[i - j]);
            }
        }
        return memo[n];
    }

Perfect Squares

给定一个正整数,寻找最少的完全平方数,使他们的和为n eg: 12=4+4+4 13=4+9

Decode Ways

一个字符串,包含A-Z的字母,每个字符可以和1-26的数字对应,如:A-1,B-2...Z-26。给出一个数字字符串,问,我们有多少种方法可以解析这个数字字符串? eg: AB 可以解析成(1,2)或者12,最终返回2

Unique path

image.png 有一个机器人,从一个m*n的矩阵的左上角出发,要达到这个矩阵的有下角,机器人每次只能向右或者向下移动,问一共有多少种不同的路径。

第三部分

House Robber

假设你是一个专业的小偷,打算洗劫一条街所有的房子,每个房子都有价值不同的宝物,但是如果你连续偷了两栋房子,就会触发报警系统,编程求出你最多可以偷窃价值多少的宝物 eg: [3,4,1,2] -->6 [3,(4),1,(2)] [4,3,1,2] -->6 [(4),3,1,(2)]

暴力解法

检查所有房子的组合,对每一个组合检查是否有相邻的房子,如果没有,记录其价值,找到最大值。 时间复杂度:O((2^n)*n)

递归树如下

image.png

状态转移方程

  1. 我们称对 ”考虑偷取[x...n-1]范围里的房子“ 的递归函数的定义,我们称之为**"状态"**
f(0)=max{ v(0)+f(2), v(1)+f(3), v(2)+f(4), ... v(n-3)+f(n-1), v(n-2), v(n-1) }

上面的函数f(x)代表从第x个房子偷取获取宝物的最大值,v(y)代表去除y号房子代表的价值。本题的是考虑从 "0号房子考虑,获取偷取宝物的最大值", 即,

  • 可以是 偷取0号房子的宝物,加上从2号房子考虑偷取宝物的最大值;
  • 可以是 偷取1号房子的宝物,加上从3号房子考虑偷取宝物的最大值;
  • 可以是 偷取2号房子的宝物,加上从4号房子考虑偷取宝物的最大值; ...
  • 可以是 偷取n-3号房子的宝物,加上从n-1号房子考虑偷取宝物的最大值;
  • 可以是偷取n-2号房子的宝物,加上从n号房子考虑偷取宝物的最大值,以为n号房子不存在,所以忽略。
  • 可以是偷取n-1号房子的宝物...

这些方案中选择最大值即为本题的解。也就是上面的方程。该方程我们称之为**"状态转移方程"**。

暴力破解

    /**
     * 获取宝物的最大值
     *
     * @param house 房子
     * @return 获取宝物的最大值
     */
    public int robs(int[] house) {
        return tryRobs(0, house);
    }

    /**
     * 尝试抢劫获取宝物的最大值
     *
     * @param index 开始考虑抢劫房子的编号
     * @param house 房子
     * @return 尝试抢劫获取宝物的最大值
     */
    private int tryRobs(int index, int[] house) {
        if (index >= house.length) {
            // 尝试抢劫房子的编号超出房子编号的最大值
            return 0;
        }
        // 用于记录本次抢劫房子的最大值
        int res = -1;
        for (int i = index; i < house.length; i++) {
            res = Integer.max(res, house[i] + tryRobs(i + 2, house));
        }
        return res;
    }

记忆化搜索

    /**
     * 获取宝物的最大值
     *
     * @param house 房子
     * @return 获取宝物的最大值
     */
    public int robs(int[] house) {
        int[] memo = new int[house.length];
        Arrays.fill(memo, -1);
        return tryRobsWithMemo(0, house, memo);
    }

    /**
     * 带记忆化搜索的尝试抢劫获取宝物的最大值
     *
     * @param index 开始考虑抢劫房子的编号
     * @param house 房子
     * @param memo  memo[x]开始考虑抢劫x号房子获取宝物的最大值
     * @return 尝试抢劫获取宝物的最大值
     */
    private int tryRobsWithMemo(int index, int[] house, int[] memo) {
        if (index >= house.length) {
            // 尝试抢劫房子的编号超出房子编号的最大值
            return 0;
        }
        if (memo[index] != -1) {
            return memo[index];
        }
        // 用于记录本次抢劫房子的最大值
        int res = -1;
        for (int i = index; i < house.length; i++) {
            res = Integer.max(res, house[i] + tryRobsWithMemo(i + 2, house,memo));
        }
        memo[index] = res;
        return res;
    }

动态规划

    int rob(int[] house) {
        int length = house.length;
        if (length == 0) {
            return 0;
        }
        int[] memo = new int[length];
        Arrays.fill(memo, -1);

        memo[length - 1] = house[length - 1];

        for (int i = length - 2; i >= 0; i--) {
            // 计算memo[i]的最大值
            for (int j = i; j < length; j++) {
                memo[i] = Integer.max(memo[i], house[j] + (j + 2 < length ? memo[j + 2] : 0));
            }
        }
        return memo[0];
    }

拓展

  1. 考虑偷取从[x...n-1]范围里的房子的宝物
  2. 考虑偷取从[0...x]范围里房子的宝物

House Robber II

和Hourse Robber 一样,不过这次实在环形的街道,也就是给定的数组中,第一个元素和最后一个元素为邻居,在不触碰警报的情况下能够窃取财产的最大值是多少?

Best Time To Buy And Sell Stock with Cooldown

给定一个数组,表示一只股票在每一天的价格。设计一个交易算法,在这些天进行自动交易,要求:每天只能进行一次操作,在买完股票后,在下一天是不能购买的。问如何交易,能让利益最大化。 eg: price=[1,2,3,0,2] 最佳交易方式:[buy, sell, colldown, buy, sell],利润为3,算法返回3