20.2-动态规划(手撕算法篇)

166 阅读5分钟

1143. 最长公共子序列

解题思路

  1. **递推状态:**我们最长公共子序列的长度取决于以第i-1个字符作为结尾的A字符串与以第j-1个字符作为结尾的B字符串公共子串长度。因此,我们的递推状态应为:dp[i,j],代表:A串长度为i位,B串长度为j位的最长公共子序列的长度
  2. **递推公式(状态转移方程):**当我们A串第i-1个字符与B串第j-1个字符相等时,即两个字符串分别以i-1位和j-1位对齐时,最长公共字串的长度应该为:dp[i-1][j-1] + 1,dp[i-1][j-1]代表我们A串第i-1个字符与B串第j-1个字符作为结尾的最长公共字串的长度,再加上当前这个相同的字符串的长度1。而当我们A串第i-1个字符与B串第j-1个字符不相等时,我们公共字串的长度取决于分别以i-1位与j位作为结尾的AB串最长公共字串的长度和分别以i位与j-1位作为结尾的AB串最长公共字串的长度的最大值。综合上述两个条件,我们得到递推公式为:最后一位对齐时:dp[i][j] = dp[i-1][j-1] + 1;最后一位不对齐时:dp[i][j] = max(dp[i-1][j],dp[i][j-1])
  3. **边界条件:**当整个字符串都不匹配时,最长公共子序列的长度为0,我们初始时可以将dp数组中每个位置都初始化为0,后续操作也更加方便。

代码演示

function longestCommonSubsequence(text1: string, text2: string): number {
    let n = text1.length;
    let m = text2.length;
    // 初始化dp数组为(n+1)*(m+1)的二维数组,并在每一位初始填充为0
    const dp = new Array(n + 1).fill(0).map(() => new Array(m + 1).fill(0));
    for(let i = 1; i <= n; i++) {
        for(let j = 1; j <= m; j++) {
            // 如果a串的最后一位与b串的最后一位相等
            if(text1[i-1] === text2[j-1]) {
                // 在最后一位对齐的情况下,我们A串第i-1个字符与B串第j-1个字符作为结尾的最长公共字串的长度,再加上当前这个相同的字符串的长度1
                dp[i][j] = dp[i-1][j-1] + 1;
            } else {
                // 如果不相等,那么我们尝试让a串倒数第二位与b串最后一位对齐或a串最后一位与b串倒数第二位对齐,并在此基础上取公共字串最长的作为当前的最大公共字串长度
                dp[i][j] = Math.max(dp[i-1][j], dp[i][j-1]);
            }
        }
    }
    return dp[n][m];
};

剑指 Offer II 094. 最少回文分割

解题思路

  1. **递推状态:**题目让我们求解最好回文切割的数量,这个看起来好像无从下手。我们可以换个角度来想,如果我们求得了原字符串最少能够分隔成几个回文串,那么切割数量就等于回文串数量减一(毕竟一刀两断的道理大家还是明白的吧)。所以,我们的递推状态就定义为dp[n],代表以原字符串长度为n的字符串最少可以切割出多少个回文字符串。
  2. **递推公式(状态转义方程):**那么,我们一个长度为n的字符串,最少可以切割出几个回文子串呢?我们以n-1位的字符作为结束字符,在前面找到一个位置j,使得jn-1位的字符形成回文字符串,如果可以形成回文字符串,那么长度为n的字符串的最少回文子串的个数应为:dp[n] = min(dp[j] + 1, dp[i]),如果不能找到,那么至少第i位的字符自己可以独立成为一个回文子串:dp[i] = i
  3. **边界条件:**当原字符串长度为0时,我们能够切割出来的回文子串自然也是0
  4. **程序实现:**我们需要掌握如何判断一个字符串是否是回文串的技巧,我们直接使用双指针的方式从两端像中间扫描,一旦两端的值对不上,就说明不是回文字符串,如果直到前后指针相遇都相等,那么这个字符串就是回文字符串了。

代码演示

// 判断字符串从第i位到第j位是否是一个回文字串
function isAoA(s: string, i: number, j: number) {
    // 使用双指针方法,如果两端字符不相等,那么肯定不是回文串
    while(i <= j) if(s[i++] !== s[j--]) return false;
    // 如果循环结束还没终止,说明是一个回文串
    return true;
}
function minCut(s: string): number {
    const n: number = s.length;
    const dp: number[] = [];
    // 当字符串长度为0时,回文串的个数也为0
    dp[0] = 0;
    for(let i=1;i<=n;i++) {
        // 我们最坏的情况就是每个字符单独成为一个回文串,那么就总共能够分成i个回文串
        dp[i] = i;
        for(let j=0;j<i;j++) {
            // 如果从j到i-1是一个回文串就更新回文串的个数
            if(isAoA(s, j, i-1)) {
                dp[i] = Math.min(dp[j] + 1, dp[i]);
            }
        }
    }
    // 由于我们算的是最少能够切出几个回文串,而要切的刀数应该比回文串数量少1
    return dp[n] - 1;
};

0/1背包

0/1背包是非常经典的动态规划问题,在很多场景上都可能会使用到0/1背包的思想。一般涉及在有限的资源内,如何合理的分配资源才能达到价值最大化的问题,都属于0/1背包问题。那么,为什么我们管这类问题叫做0/1背包呢?其实0就代表最后一个物品没选,而1代表选择了最后的物品。所以0/1其实就是代表决策的两种方向。

题目描述

给一个能承重𝑉V的背包,和𝑛n件物品,我们用重量和价值的二元组来表示一个物品,第𝑖i件物品表示为(𝑉𝑖,𝑊𝑖)(Vi,Wi),问:在背包不超重的情况下,得到物品的最大价值是多少?

0-1bag


输入

第一行输入两个数 𝑉,𝑛V,n,分别代表背包的最大承重和物品数。

接下来𝑛n行,每行两个数𝑉𝑖,𝑊𝑖Vi,Wi,分别代表第i件物品的重量和价值。

(𝑉𝑖≤𝑉≤10000,𝑛≤100,𝑊𝑖≤1000000)(Vi≤V≤10000,n≤100,Wi≤1000000)

输出

输出一个整数,代表在背包不超重情况下所装物品的最大价值。

解题思路

我们可以把题目中的V即背包能装下的容积看成是资源,而W当做是收益,我们如何在资源有限的情况下获得最大收益,这就是我们今天这道题要研究的问题。

  1. **递推状态:**由于我们获得的最大收益跟物品数量和背包的最大承重有直接的关系,因此,我们定义递推状态为:dp[i][j],代表我们前i件物品,背包的承重是j的情况下,我们能获得的最大收益。
  2. **递推公式:**我们的递推状态有两种情况:
    1. **没有选择第i件物品:**由于没有选择第i件物品,那么我们的总价值就取决于i-1件物品了。dp[i][j] = dp[i-1][j]
    2. **选择了第i件物品:**如果选择了第i件物品,那么我们的背包就必须给第i件物品留下空间,因此,递推公式为:dp[i][j] = dp[i-1][j - vi] + wi,代表背包中除了最后一件物品的总价值再加上最后一件物品的总价值。
  3. **边界条件:**没有塞进去一个东西时总价值为0
  4. **程序实现:**可以使用滚动数组技巧节省空间复杂度

代码演示

function bag(v: number[], w: number[], V: number) {
  const n = v.length;
  const dp: number[][] = [];
  // 初始换一个初始填充0的滚动数组
  for(let i=0;i<2;i++) dp.push(new Array<number>(V + 5).fill(0));
  // 遍历每一个物品
  for (let i = 0; i <= n; i++) {
    // 计算滚动数组的索引
    const idx = i % 2;
    const preIdx = +!idx;
    // 遍历可能塞入背包的物品重量
    for (let j = 0; j <= V; j++) {
      // 如果当前物品不能塞进去,那么总价值取决于塞入上一个物品后的总价值
      dp[idx][j] = dp[preIdx][j];
      // 如果当前背包的容量能够塞入第i件物品,那么我们需要更新总价值
      if (j >= v[i]){
        // 由于能够塞进去第i件物品,那么总价值取决于塞入上一个物品的价值加上当前物品的价值,由于还可能存在其他的存放方案,因此,我们每次求得本次总价值还需与上一次的总价值做对比取最大值
        dp[idx][j] = Math.max(dp[idx][j], dp[preIdx][j - v[i]] + w[i]);
      }
    }
  }
  return dp[n % 2][V];
}

// 物品重量数组
const v: number[] = [4, 3, 12, 9];
// 物品价值数组
const w: number[] = [10, 7, 12, 8];
// 背包总承重
const V = 15;
console.log(bag(v, w, V));