动态规划算法

简单来说,就是把问题分成多个阶段,每个阶段都符合一个式子(状态转移),后面阶段的状态(结果)一般可以由前面阶段的状态(结果)转移而来。

找出状态转移方程,动态规划已经成功了 90%。使用动态规划求解时,最关键的是找出状态转移方程,而想要找出状态转移方程,首先要对问题状态有清晰的定义。一般来说,动态规划求解主要包括以下步骤:

  • 划分阶段:把问题划分成子问题,类似于分治法,每个阶段一般是有序的。
  • 定义状态:每个阶段,都有属于自己的最优状态,每一个阶段的最优状态,可以从前面阶段的最优状态中转化获得。
  • 状态转化:不同阶段之间的转化关系,就是状态转移方程,定义好它们之间的递推关系,就是极其重要的一步。
  • 逆向求解:一般来说我们要求解一个状态,需要先把前面的状态先求解。那么逆向就是自底向上,也就是先挨个把前面的- 状态求解,再使用前面的状态求解后面的状态。(注意最初的状态定义必须准确,边界值需要处理好)

斐波那契数列

public class FibonacciTest {

  public static int Fibonacci(int n) {
    if (n == 0) {
      return 0;
    }
    if (n == 1) {
      return 1;
    }
    int[] nums = new int[n + 1];
    nums[0] = 0;
    nums[1] = 1;
    for (int i = 2; i <= n; i++) {
      nums[i] = nums[i - 1] + nums[i - 2];
    }
    return nums[n];
  }
}

优化之后

public class FibonacciTest {

  public int Fibonacci(int n) {
    if (n <= 0) return 0;
    if (n == 1) return 1;
    // 初始化状态
    int fibonacci1 = 0;
    int fibonacci2 = 1;
    int result = 0;
    for (int i = 2; i <= n; i++) {
      result = fibonacci1 + fibonacci2;
      fibonacci1 = fibonacci2;
      fibonacci2 = result;
    }
    // 返回最后的状态
    return result;
  }
}

跳台阶
小时候,我们会在阶梯上跳,现在我们玩一个游戏,一次可以跳上 1 级台阶,也可以跳上 2 级…… 假设也可以跳上 n 级。求我们跳上一个 n 级的台阶总共有多少种跳法。

public class JumpFloorTest {

  public int jumpFloor(int target) {
    if (target <= 0) {
      return 0;
    }
    int[] nums = new int[target];
    nums[0] = 1;
    for (int i = 0; i < target; i++) {
      int sum = 1;
      for (int j = 0; j < i; j++) {
        sum += nums[j];
      }
      nums[i] = sum;
    }
    return nums[target - 1];
  }
}

上面的做法明显复杂度过高,那么我们改用动态规划的方式:

  • 定义状态:f(n) 表示跳上 n 级台阶的方法。
  • 初始化状态:跳上第一级只有一种方法,f(1) = 1。
    在这里插入图片描述
public class JumpFloorTest {

  public static void main(String[] args) {
    System.out.println("F(4) = " + jumpFloor(4));
  }

  public static int jumpFloor(int target) {
    if (target <= 0) return 0;
    // 初始化
    int total = 1;
    for (int i = 1; i < target; i++) {
      // 状态转移
      total = 2 * total;
    }
    return total;
  }
}

动态规划求解回文串

“上海自来水来自海上”,这句话不管是顺着读还是逆着读,都是一样的,这就是回文串。相信如何判断一个字符串是回文串,这个问题大家都会(根据中心对称来判断即可),现在问题升级了,给出一个字符串 s,找到 s 里面包含的最长的回文串。

输入:s = abddbc
输出:bddb

在这里插入图片描述

public class Palindrome {

  public static void main(String[] args) {
    System.out.println(longestPalindrome("abddbc"));
  }

  public static String longestPalindrome(String str) {
    int size = str.length();
    if (size <= 1) return str;
    boolean[][] results = new boolean[size][size];
    // 起始索引位置
    int startIndex = 0;
    // 保存回文串最大长度
    int max = 0;
    // 初始化状态,长度为 1 和 2 的子串
    for (int i = 0; i < size; i++) {
      results[i][i] = true;
      if (i < size - 1 && str.charAt(i) == str.charAt(i + 1)) {
        results[i][i + 1] = true;
        startIndex = i;
        max = 2;
      }
    }
    // 回文串的长度从 3 开始计算
    for (int length = 3; length <= size; length++) {
      for (int i = 0; i <= size - length; i++) {
        int j = i + length - 1;
        // 去掉当前字符的前面长度为 length 的子串是回文串,且当前字符与对折字符相等
        if (results[i + 1][j - 1] && str.charAt(i) == str.charAt(j)) {
          results[i][j] = true;
          startIndex = i;
          max = length;
        }
      }
    }
    print(results);
    if (startIndex == 0 && max == 0) return String.valueOf(str.charAt(0));
    // 返回回文串
    return str.substring(startIndex, startIndex + max);
  }

  // 打印矩阵
  public static void print(boolean[][] results) {
    for (int i = 0; i < results.length; i++) {
      for (int j = 0; j < results[0].length; j++) {
        System.out.print(results[i][j] ? "T " : "F ");
      }
      System.out.println("");
    }
    System.out.println("========================== ");
  }
}

连续子数组的最大和

假设有一个整型数组,数组里有正数也有负数。数组中的一个或连续多个整数组成一个子数组。现在要求所有子数组的和的最大值,当然我们还希望时间复杂度为 O(n)。

倘若没有时间复杂度的要求,我们马上就能想到暴力破解的方法,那就是两层循环,求每一个区间的子数组的和,不断和最大值比较,最后得到最大值。

public class LargeSumOfSubArray {

  public static void main(String[] args) {
    System.out.println("sum: " + simpleSolution(new int[] { 1, -3, 4, 2, -8 }));
  }

  public static int simpleSolution(int[] array) {
    if (array == null || array.length == 0) {
      return 0;
    }
    int result = Integer.MIN_VALUE;
    // 从每一个字符开始
    for (int i = 0; i < array.length; i++) {
      int tempSum = 0;
      // 每一种长度的子串(合法的索引范围)
      for (int j = i; j < array.length; j++) {
        tempSum = tempSum + array[j];
        // 当前最大值和保存的最大值相比较
        if (tempSum > result) {
          result = tempSum;
        }
      }
    }
    return result;
  }
}

暴力破解并非我们目的所在,我们需要尝试使用动态规划来解决它。动态规划三把斧,状态定义,初始化状态以及状态转换。动态规划只要找出状态转移方程,就意味着成功了一大半。

我们求解的是字符串里面的最大连续子数组之和,假设我们得知一个数组里面的最大子数组之和,还得知了这个数组后面的元素,可以知道这个数组加上这个元素之后的最大子数组之和么?

答案是否定的,因为我们不知道之前的数组的最大连续子数组之和,是否是以最后一个元素为结尾的,如果不是,我们加上新的元素,就不能算是连续子数组。因此这里面的状态除了最大的子数组之外,还有以最后一个元素为结尾的最大子数组之和。

定义状态:dp[i] 表示下标以 i 结尾的连续子数组的最大和,假设数组大小为 n,那么最终求解的就是 dp[n-1]。

  • 以 num[i-1] 结尾的连续子数组最大和 max + 当前数 nums[i]。
  • 连续子数组最大和还是之前的 dp[i-1]。
  • 当前数 nums[i]。

状态转移方程为:

dp[i] = Max{ Max { max + nums[i] , nums[i] } , dp[i-1] }
public class LargeSumOfSubArray {

  public static void main(String[] args) {
    System.out.println(
      "sum: " + findLargeSumOfSubArray(new int[] { 1, -3, 4, 2, -8 })
    );
  }

  public static int findLargeSumOfSubArray(int[] array) {
    // 记录当前所有子数组的和的最大值
    int res = array[0];
    // 包含 array[i] 的连续数组最大值
    int max = array[0];
    for (int i = 1; i < array.length; i++) {
      // 当前元素加上以上一个元素结尾的连续子数组的和,与当前元素比较,去较大值
      max = Math.max(max + array[i], array[i]);
      // 更新保存的最大值
      res = Math.max(max, res);
    }
    return res;
  }
}

捡最大价值的装备包

你有一个背包,只能装 m 公斤,你现在刚刚降落,走进一个房子,房子里面有 n 个物品,假设每件物品的重量取值为 W1、W2、W3… Wn,每件物品对你的成长价值是 C1、C2、C3… Cn,所以这个时候,你要选择把最大价值的装备放到自己的背包里,那我们怎么选择呢?

  • 定义状态:如果前面 i 件装备,放进最大重量为 w 的背包,获得的最大价值为 v[i][w]。
  • 初始化状态:0 件装备或者背包的最大重量为 0 的背包,获得的最大的价值都是 0。
  • 状态转移:面临第 i 件装备放不放进去这个问题,有两种情况:
    • 放进去的第 i 件装备,超过了最大的背包数量,放不进去,那么 v[i][w] = v[i-1][w],也就是在最大的重量是 w 的背包里面,从前面 i 件挑选放进去最优解,和从前面 i-1 件中挑选的最优解是一样的。
    • 剩下的空间可以放下第 i 件,选择放进去,那么假设第 i 件物品的重量是 w[i],此时的最优解,应该是 v - w[i] 大小的背包,选择放进去 i-1 件的最优解,再加上现在放进去的背包 c[i]。

总结一下:i 件物品放进容量为 V 的背包中的最优选择是取上面步骤 1 和 2 中最大的,也就是 Max{v[i-1][v],c[i-1][v-w[i]]+c[i]}。

public class BagValue {

  public static void main(String[] args) {
    int maxWeight = 10;
    int bagNum = 5;
    int[] weights = { 3, 4, 5, 7, 2 };
    int[] values = { 3, 5, 4, 6, 1 };
    int[][] results = bagValue(maxWeight, bagNum, weights, values);
    print(results);
  }

  public static int[][] bagValue(
    int maxWeight,
    int bagNum,
    int weights[],
    int values[]
  ) {
    // c[i][m] 表示前 i 件物品恰好放入重量为 m 的背包时的最大价值
    int v[][] = new int[bagNum + 1][maxWeight + 1];
    // 初始化状态,重量最大为 0,结果肯定为 0
    for (int i = 0; i < bagNum + 1; i++) {
      v[i][0] = 0;
    }
    // 初始化状态,包裹数量最大为 0,结果肯定为 0
    for (int j = 0; j < maxWeight + 1; j++) {
      v[0][j] = 0;
    }
    // 循环
    for (int i = 1; i < bagNum + 1; i++) {
      for (int j = 1; j < maxWeight + 1; j++) {
        // 规划到物品为 i 件最大重量为 j 时,如果第 i 件的重量 (weights[i-1]) 小于最大重量 j:
        if (weights[i - 1] <= j) {
          // 如果放进去的价值比不放进去的价值更大
          if (v[i - 1][j] < v[i - 1][j - weights[i - 1]] + values[i - 1]) {
            // 物品 i 放入背包中,则背包剩余重量为 j-weights[i-1]
            // 则 v[i][j] 为 v[i-1][j-weights[i-1]] 的值加上当前物品 i 的价值
            v[i][j] = v[i - 1][j - weights[i - 1]] + values[i - 1];
          } else {
            // 第 i 件不放入背包中,所以 v[i][j] 为 v[i-1][j] 的值
            v[i][j] = v[i - 1][j];
          }
        } else {
          v[i][j] = v[i - 1][j];
        }
      }
    }
    return v;
  }

  // 打印矩阵
  public static void print(int[][] results) {
    for (int i = 0; i < results.length; i++) {
      for (int j = 0; j < results[0].length; j++) {
        System.out.print(results[i][j] + " ");
      }
      System.out.println("");
    }
    System.out.println(
      "最大价值为: " + results[results.length - 1][results[0].length - 1]
    );
  }
}

最长公共子序列

字符串一般会涉及子序列和子串两个概念,子序列和子串可不是同样的概念,子串要求是连续的,而子序列只要求顺序并不要求连续。

在这里插入图片描述

我们可以知道这个问题可以分解为更小的问题,思考一下,比如长度为 n 的字符串 str1 和长度为 m 的字符串 str2,两者的最长公共子序列,是不是可以从 str1 前长度为 n-1 的子串与 str2 前长度为 m-1 的子串的最长公共子序列中得知呢?答案是肯定的。

public class LCS {

  public static void main(String[] args) {
    findLCS("abbcdd", "abdbcabd");
  }

  public static int findLCS(String str1, String str2) {
    int n = str1.length();
    int m = str2.length();
    // 状态定义
    int[][] dp = new int[n + 1][m + 1];
    // 初始化状态
    for (int i = 0; i <= n; i++) {
      for (int j = 0; j <= m; j++) {
        dp[i][j] = 0;
      }
    }
    for (int i = 1; i <= n; i++) {
      for (int j = 1; j <= m; j++) {
        // 如果两个字符相等
        if (str1.charAt(i - 1) == str2.charAt(j - 1)) {
          dp[i][j] = dp[i - 1][j - 1] + 1;
        } else {
          // 不相等的时候,取 dp[i - 1][j] 和 dp[i][j - 1] 的最大
          dp[i][j] = dp[i - 1][j] > dp[i][j - 1] ? dp[i - 1][j] : dp[i][j - 1];
        }
      }
    }
    print(dp);
    return dp[n][m];
  }

  // 打印矩阵
  public static void print(int[][] results) {
    for (int i = 0; i < results.length; i++) {
      for (int j = 0; j < results[0].length; j++) {
        System.out.print(results[i][j] + " ");
      }
      System.out.println("");
    }
    System.out.println(
      "最长公共子串的长度为: " +
      results[results.length - 1][results[0].length - 1]
    );
  }
}

寻找数字塔的路径

数字塔是一座由数字组成的塔,第 i 层由 i 个数字组成,比如下面的塔中,1 + 3 + 7 + 9 + 11 = 31 就是最大的路径值。最上面的 1 可以往下走到 3 或者 5,上面第 2 层的 3 又可以走到第三层的 7 或者 5,但是不能走到 2。

  • 定义状态:dp[i][j] 表示走到第 i+1 层,第 j+1 个位置的最大路径值。
  • 初始化状态:第一层的第一个位置的最大路径只有一种,就是塔的顶点位置的值,dp[0][0]=data[0][0]。
  • 状态转换:每一个位置索引合法的情况下,只能通过自己的左上角或者右上角的位置走下来。那么就是 dp[i][j] = Math.max(dp[i-1][j-1], dp[i-1][j]) + data[i][j],如果在左边界,那么只能是右上角走下来,也就是 dp[i][j] = dp[i-1][j] + data[i][j],在求解的过程中,用变量保存最大值,不断更新即可。
    在这里插入图片描述
public class Tower {

  public static void main(String[] args) {
    int[][] datas = {
      { 1 },
      { 3, 5 },
      { 7, 5, 2 },
      { 9, 3, 4, 11 },
      { 11, 1, 2, 5, 7 },
    };
    System.out.println("最大路径为:" + maxRoute(datas));
  }

  public static int maxRoute(int data[][]) {
    int max = 0;
    int dp[][] = new int[data.length][data.length];
    dp[0][0] = data[0][0];
    for (int i = 1; i < data.length; i++) {
      for (int j = 0; j <= i; j++) {
        if (j == 0) {
          // 左边界,直接是右上角的数字相加
          dp[i][j] = dp[i - 1][j] + data[i][j];
        } else {
          // 不是左边界,取左上角也右上角的最大值
          dp[i][j] = Math.max(dp[i - 1][j - 1], dp[i - 1][j]) + data[i][j];
        }
        max = Math.max(dp[i][j], max);
      }
    }
    print(dp);
    return max;
  }

  // 打印矩阵
  public static void print(int[][] results) {
    for (int i = 0; i < results.length; i++) {
      for (int j = 0; j < results[0].length; j++) {
        System.out.print(results[i][j] + " ");
      }
      System.out.println("");
    }
  }
}

版权声明:本文为tianynnb原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。