动态规划进阶

553 阅读7分钟

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

0-1背包问题

有一个背包,他的容量为C(Capacity)。现在有n种不同的物品,编号为0...n-1,其中每一件物品的重量为w(i),价值为v(i)。问可以向背包中存入哪些物品,使得不超过背包容量的基础上,物品的总价值最大。

暴力解法

每件物品都可以放进背包,也可以不放进背包,将所有的方式进行穷举,找到最大的。 时间复杂度:O((2^n)*n)

F(n,C) 考虑将n个物品放进容量为C的背包,使其价值最大。
对应的,当我们放置考虑是否放置第i个物品打背包中有两种策略,即,放,不放;
F(i,C) = F(i-1,C) // 不放置第i个物品,考虑前i-1个物品的放置。
              或者
         V(i) + F(i-1,C - W(i)) // 考虑放置第i个物品到背包中,V(i)代表第i个物品的价值,W(i)代表第i个物品的重量;
                                //因为已经放进背包,所以背包需要减去第i个物品的重量,即,C - W(i)

// 对应的第i个物品的是否放置进背包,即,取比较上面两种方案的放置规则取最大值者。
F(i,C) = max( F(i-1,C), V(i)+F(i-1,C - W(i)) )

递归求解

    /**
     * 0-1背包递归求解
     *
     * @param w 物品重量
     * @param v 物品价值
     * @param c 背包容量
     * @return 背包能装载的最大值
     */
    public int knapsack01(int[] w, int[] v, int c) {
        return bestValue(w, v, c, v.length-1);
    }

    /**
     * 递归求解0-1背包问题
     *
     * @param w     物品重量
     * @param v     物品价值
     * @param c     背包容量
     * @param index 考虑是否需要放置进背包的第index个物品
     * @return 当前最优值
     */
    private int bestValue(int[] w, int[] v, int c, int index) {
        if (index <= 0 || c <= 0) {
            return 0;
        }
        // 不考虑第index个物品
        int res0 = bestValue(w, v, c, index - 1);
        int res1;
        if (w[index] <= c) {
            // 考虑第index个物品
            res1 = v[index] + bestValue(w, v, c - w[index], index - 1);
        }
        return Integer.max(res0, res1);
    }

记忆化搜索

    /**
     * 带记忆化搜索的
     * 0-1背包递归求解
     *
     * @param w 物品重量
     * @param v 物品价值
     * @param c 背包容量
     * @return 背包能装载的最大值
     */
    public int knapsack01(int[] w, int[] v, int c) {
        int[][] memo = new int[w.length][c+1];
        for (int[] ints : memo) {
            Arrays.fill(ints, -1);
        }
        return bestValue1(w, v, c, v.length-1, memo);
    }

    /**
     * 带记忆化搜索的
     * 递归求解0-1背包问题
     *
     * @param w     物品重量
     * @param v     物品价值
     * @param c     背包容量
     * @param index 考虑是否需要放置进背包的第index个物品
     * @param memo  存储计算好的是否考虑将第i个物品放进背包的最优解
     * @return 当前最优值
     */
    private int bestValue1(int[] w, int[] v, int c, int index, int[][] memo) {
        if (index <= 0 || c <= 0) {
            return 0;
        }
        if (memo[index][c] != -1) {
            return memo[index][c];
        }
        // 不考虑第index个物品
        int res0 = bestValue1(w, v, c, index - 1, memo);
        // 考虑第index个物品
        int res1;
        if (w[index] <= c) {
            // 考虑第index个物品
            res1 = v[index] + bestValue1(w, v, c - w[index], index - 1, memo);
        }

        memo[index][c] = Integer.max(res0, res1);

        return memo[index][c];
    }

动态规划

假设背包的容量为5,物品信息如下面的表格

IDweightvalue
016
1210
2312

那么,可以推导出记忆化搜索的记录,如下:

ID\W012345
0066666
10610161616
20610161822
    /**
     * 动态规划
     * 0-1背包递归求解
     *
     * @param w 物品重量
     * @param v 物品价值
     * @param c 背包容量
     * @return 背包能装载的最大值
     */
    public int knapsack01(int[] w, int[] v, int c) {
        // 物品的数量n
        int n = w.length;
        // 声明一个二维数组用于记录 第[i个物品][容量为w的背包]存放物品的最大值
        int[][] memo = new int[n][c+1];
        for (int[] ints : memo) {
            Arrays.fill(ints, -1);
        }

        // 先将0号物品分别放置在容量为0,1,2...c的背包中,背包能存放物品的最大值
        for (int j = 0; j <= c; j++) {
            memo[0][j] = w[j] >= v[0] ? v[0] : 0;
        }

        // 将陆续将 1~n 号物品,放置在容量为 0~C 背包中,背包存放物品的最大值

        // i 代表第i号物品
        for (int i = 1; i < n; i++) {
            // j 代表背包的容量
            for (int j = 0; j <= c; j++) {
                // 尝试将第i号物品放置进背包,背包的最大容量
                int res0 = w[i] <= j ? v[i] + memo[i - 1][j - w[i]] : memo[i - 1][j];
                // 忽略第i好物品,背包的最大容量
                int res1 = memo[i - 1][j];
                // 比较两种放置方式,取最大值
                memo[i][j] = Integer.max(res0, res1);
            }
        }
        return memo[n - 1][c];
    }

优化

0-1背包的状态转移方程

F(i,c) = max( F((i-1) , c) , v(i) + F( i-1 , c - w(i) ) )

第 i 行的元素只依赖于第 i - 1 行元素,理论上,只需要保持两行元素即可,不需要保存 n 行元素。

   /**
    * 动态规划
    * 0-1背包递归求解
    *
    * @param w 物品重量
    * @param v 物品价值
    * @param c 背包容量
    * @return 背包能装载的最大值
    */
   public int knapsack01Plus(int[] w, int[] v, int c) {
       // 物品的数量n
       int n = w.length;
       // 声明一个二维数组用于记录 第[i个物品][容量为w的背包]存放物品的最大值
       int[][] memo = new int[2][c + 1];
       for (int[] ints : memo) {
           Arrays.fill(ints, -1);
       }

       // 先将0号物品分别放置在容量为0,1,2...c的背包中,背包能存放物品的最大值
       for (int j = 0; j <= c; j++) {
           memo[0][j] = w[j] >= v[0] ? v[0] : 0;
       }

       // 将陆续将 1~n 号物品,放置在容量为 0~C 背包中,背包存放物品的最大值

       // i 代表第i号物品
       for (int i = 1; i < n; i++) {
           // j 代表背包的容量
           for (int j = 0; j <= c; j++) {
               // 尝试将第i号物品放置进背包,背包的最大容量
               int res0 = w[i] <= j ? v[i] + memo[(i - 1) % 2][j - w[i]] : memo[(i - 1) % 2][j];
               // 忽略第i好物品,背包的最大容量
               int res1 = memo[(i - 1) % 2][j];
               // 比较两种放置方式,取最大值
               memo[i % 2][j] = Integer.max(res0, res1);
           }
       }
       return memo[(n - 1) % 2][c];
   }

优化

如果细心的小伙伴可能会看到,其实我们在计算 memo[i][j] 的时候,只依赖上一次结果中的memo[i-1][<j]的元素,所以我们可以调换计算的顺序,即,从 memo[i][0]-->memo[i][c] 改为 memo[i][c]-->memo[i][0],这样,我们就可以在一行中不断复用数组的单元格使用一维数组解决背包算法了。

    /**
     * 动态规划
     * 0-1背包递归求解
     *
     * @param w 物品重量
     * @param v 物品价值
     * @param c 背包容量
     * @return 背包能装载的最大值
     */
    public int knapsack01Plus(int[] w, int[] v, int c) {
        // 物品的数量n
        int n = w.length;
        // 声明一个二维数组用于记录 第[i个物品][容量为w的背包]存放物品的最大值
        int[] memo = new int[c + 1];
        Arrays.fill(memo, -1);
        // 先将0号物品分别放置在容量为0,1,2...c的背包中,背包能存放物品的最大值
        for (int j = 0; j <= c; j++) {
            memo[j] = w[j] >= v[0] ? v[0] : 0;
        }
        // 将陆续将 1~n 号物品,放置在容量为 0~C 背包中,背包存放物品的最大值
        // i 代表第i号物品
        for (int i = 1; i < n; i++) {
            // j 代表背包的容量
            for (int j = c; j >= w[i]; j--) {
                // 尝试将第i号物品放置进背包,背包的最大容量
                memo[j] = Integer.max(v[i] + memo[j - w[i]], memo[j]);
            }
        }
        return memo[c];
    }

0-1 背包的变种

  1. 假设每个物品可以无限使用 思路

虽然每个物品可以无限使用,但是背包的容量有限,所以,每个物品的使用还是有最大限度的,所以我们可以将无限转换成有限的背包问题。 2. 多维费用背包 背包和物品都有对应的体积和容量。要同时满足这两个约束条件达到背包能装载物品的价值最大化。

思路

三维数组 3. 物品互斥约束,物品依赖约束

Partition Equal Subset Sum

给定一个非空数组,其中所有的元素都是正整数,问,是否可以将这个数组的元素分成两部分,使得这两部分的和相等。 eg:

  • [1, 5, 11, 5],可以分成[1, 5, 5 ]和[11]两部分元素,返回true
  • [1, 2, 3, 5],无法将元素分成两部分使其相等,返回false

思路 该题为背包问题的简化版本,即背包的容量为数组中所有元素的和装满 sum/2 即可。 对应的状态转移方程为:

F(n,c)考虑将n个物品填满容量为c的背包。

F(i) = F(i-1,C)||F(i-1,C-W(i))

解释 F(i)代表考虑到第i个物品是否可以将背包填满。 W(i)代表第i个物品的重量,如果考虑将第i个物品放进背包,那么即为F(i-1,C-W(i)),如果不考虑将第i个物品放进背包,那么背包的重量即为考虑前i-1个物品的重量,即为,F(i-1,C),两者最终找到任意一个可以将背包填满的方式即可。

暴力破解

    private boolean partitionEqualSubSetSum(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return false;
        }
        int sum = 0;
        for (int i : arr) {
            sum += i;
        }
        if (sum % 2 == 1) {
            return false;
        }
        return partitionEqualSubSetSumCore(arr, sum / 2, arr.length - 1);
    }

    private boolean partitionEqualSubSetSumCore(int[] arr, int c, int index) {
        if (c == 0) {
            return true;
        }
        if (index < 0 || c < 0) {
            return false;
        }
        return partitionEqualSubSetSumCore(arr, c, index - 1) ||
                partitionEqualSubSetSumCore(arr, c - arr[index], index - 1);
    }

记忆化搜索

    private boolean partitionEqualSubSetSumWithMemo(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return false;
        }
        int sum = 0;
        for (int i : arr) {
            sum += i;
        }
        if (sum % 2 == 1) {
            return false;
        }
        // memo[i][c]代表i个物品是否可以将容量为c的背包填满
        // 0代表不能填满,1代表可以,-1代表未填充过
        int[][] memo = new int[arr.length][sum / 2+1];
        for (int[] ints : memo) {
            Arrays.fill(ints, -1);
        }
        return partitionEqualSubSetSumWithMemoCore(arr, sum / 2, arr.length - 1, memo);
    }

    private boolean partitionEqualSubSetSumWithMemoCore(int[] arr, int c, int index, int[][] memo) {
        if (c == 0) {
            return true;
        }
        if (index < 0 || c < 0) {
            return false;
        }
        if (memo[index][c] != -1) {
            return memo[index][c]==1;
        }
        boolean res = partitionEqualSubSetSumWithMemoCore(arr, c, index - 1, memo) ||
                partitionEqualSubSetSumWithMemoCore(arr, c - arr[index], index - 1, memo);
        memo[index][c] = res ? 1 : 0;
        return res;
    }

动态规划

   private boolean partitionEqualSubSetSum(int[] arr) {
        if (arr == null || arr.length <= 1) {
            return false;
        }
        int sum = 0;
        for (int i : arr) {
            sum += i;
        }
        if (sum % 2 == 1) {
            return false;
        }
        int c = sum / 2;
        boolean[] memo = new boolean[c + 1];
        // 先尝试将第0个物品放进背包
        for (int i = 0; i <= c; i++) {
            memo[i] = i == arr[0];
        }
        // 分别将第1-n个物品放进背包
        for (int i = 1; i < arr.length; i++) {
            for (int j = c; j >= arr[i]; j--) {
                memo[j] = memo[i - 1] || memo[c - arr[i]];
            }
        }
        return memo[c];
    }

Coin Change

给定不同面值的硬币,问至少需要多少硬币可以凑够指定金额,算法返回这个数,如果无法凑成,返回-1,(硬币可以无限使用) eg: 如给定硬币金额为[1,2,5] ,amount = 11,那么返回3,(1,5,5) 如给定硬币金额为[2],amount = 3,那么返回-1

Combination Sum

给定一个整数数组,其中元素没有重复,问,有多少种可能,使得数组中的数字,能凑够一个整数target, eg: 如:num[1,2,3] target = 4 可能返回的组合有[1,1,1,1], [1,1,2], [1,2,1], [1,3], [2,1,1], [2,2], [3,1] 算法返回7, 注意:顺序性

Ones and Zeros

给定一个字符串数组,数组中每个字符串都是0,1串,问,用m个0和n个1,最多可以组成多少个01串。 [约束]

  1. m和n不超过 100
  2. 数组中元素个数不超过600
  • 如:[10, 0001, 111001, 1, 0] 给定5个0和3个1最多可以组成4个元素,10,0001,1,0
  • 如:[10, 0, 1],给定1个0和1个1,最多可以组成两个元素,即,0和1

Word Break

给定一个非空字符串s和一个非空的字符串数组wordDict,问能否使用wordDict中的不同字符串首尾连接,构成非空字符串s,假定wordDict中没有重复的字符串

  • 如:s = "leetcode" wordDict = ["leet", "code"] 返回true

Target Sum

给定一个非空的数字序列,在这些数字前加上“+”或者“-”使得计算结果为给定的整数s,问一共有多少种可能。 如:nums = [1, 1, 1, 1, 1] s = 3 答案为5 -1+1+1+1+1 +1-1+1+1+1 +1+1-1+1+1 +1+1+1-1+1 +1+1+1+1-1

最长上升子序列

给定一个整数序列,求,其中最长上升子序列长度。(不要求子序列一定是连续的,只要相对位置不变即可)

  • [ 10, 9, 2, 5, 3, 7, 101, 18 ], 其中最长上升子序列长度为4 最长上升子序列为[ 2, 5, 7, 101]

解法

LIS(i)表示以第i个数字结尾的最长上升子序列的长度。 LIS(i)表示0-i范围内,选择数字nums[i]可以获得的最长上升子序列长度。

LIS(i) = max( 1 + LIS(j) if(nums[i]>nums[j]) )
         j < i

对应的第 i 个数字的上升子序列为:之前第 nums[j](nums[j]<nums[i])的上升子序列的最大长度+1

如: [ 10, 9, 2, 5, 3, 7, 101, 18]

num\i01234567
101
91
21
52
32
73
1014
84

其中i代表第i个元素的升序子序列最大长度。

  1. 第0个元素为10的最大升序序列为自己,即1
  2. 第1个元素为9,之前没有比9小的元素,所以最大升序长度为1
  3. 第2个元素为2,之前没有比2小的元素,所以最大升序长度即1
  4. 第3个元素为5,之前的元素2比5小,所以最大升序长度为2的最大升序长度+1,即1+1=2
  5. 第4个元素为3,之前的元素2比5小,所以最大升序长度为2的最大升序长度+1,即1+1=2
  6. 第5个元素为7,之前的元素2,5,3都比7小,选择最大的升序长度+1,即2+1=3
  7. 第6个元素为101,选取之前比他小的最大升序元素的值+1,即3+1=4
  8. 第7个元素为8,选取之前比他小的最大升序元素的值+1,即3+1=4

最后,得到的最大升序长度为4

编码实现

    int lis(int[] arr) {
        if (arr == null) {
            return -1;
        }
        if (arr.length <= 1) {
            return arr.length;
        }
        int len = arr.length;
        int[] memo = new int[len];
        Arrays.fill(memo, 1);
        for (int i = 1; i < len; i++) {
            for (int j = 0; j < i; j++) {
                if (arr[j] < arr[i]) {
                    memo[i] = Integer.max(memo[i], memo[j] + 1);
                }
            }
        }
        return max(memo);
    }

    private int max(int[] array) {
        if (array == null) {
            throw new IllegalArgumentException("The Array must not be null");
        } else if (array.length == 0) {
            throw new IllegalArgumentException("Array cannot be empty.");
        } else {
            int max = array[0];

            for (int j = 1; j < array.length; ++j) {
                if (array[j] > max) {
                    max = array[j];
                }
            }

            return max;
        }
    }

时间复杂度O(n^2)


Wiggle Subsequence

一个序列,他的相邻数字的大小关系是升序降序轮流交替,(最初可以是升序,也可以是降序),就称为wiggle sequence,比如:[1, 7, 4, 9, 2, 5]就是一个wiggle sequence,但是[1, 4, 7, 2, 5]和[1, 7, 4, 5, 5]就不是,给出一个数组,求出他的最长wiggle sequence的序列。