1

动态规划的基础知识

  1. 爬楼梯的最少成本
一个数组 cost 的所有数字都是正数,它的第 i 个数字表示在一个楼梯的第 i 级台阶往上爬的成本,在支付了成本 cost[i]之后可以从第 i 级台阶往上爬 1 级或 2 级。假设台阶至少有 2 级,既可以从第 0 级台阶出发,也可以从第 1 级台阶出发,请计算爬上该楼梯的最少成本。例如,输入数组[1,100,1,1,100,1],则爬上该楼梯的最少成本是 4,分别经过下标为 0、2、3、5 的这 4 级台阶,如图 14.1 所示。

函数f(i)表示从楼梯的第 i 级台阶再往上爬的最少成本

f(i) = min(f(i-1), f(i-2)) + cost[i]
/**
 * @param {number[]} cost
 * @return {number}
 */
var minCostClimbingStairs = function (cost) {
  let len = cost.length;
  const dp = new Array(len + 1);
  dp[0] = dp[1] = 0;
  for (let i = 2; i <= len; i++) {
    dp[i] = Math.min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);
  }

  return dp[len];
};

单序列问题

单序列问题是与动态规划相关的问题中最有可能在算法面试中遇到的题型。这类题目都有适合运用动态规划的问题的特点,如解决问题需要若干步骤,并且每个步骤都面临若干选择,需要计算解的数目或最优解。除此之外,这类题目的输入通常是一个序列,如一个一维数组或字符串。

应用动态规划解决单序列问题的关键是每一步在序列中增加一个元素,根据题目的特点找出该元素对应的最优解(或解的数目)和前面若干元素(通常是一个或两个)的最优解(或解的数目)的关系,并以此找出相应的状态转移方程。一旦找出了状态转移方程,只要注意避免不必要的重复计算,问题就能迎刃而解。

  1. 房屋偷盗
输入一个数组表示某条街道上的一排房屋内财产的数量。如果这条街道上相邻的两幢房屋被盗就会自动触发报警系统。请计算小偷在这条街道上最多能偷取到多少财产。例如,街道上 5 幢房屋内的财产用数组[2,3,4,5,3]表示,如果小偷到下标为 0、2 和 4 的房屋内盗窃,那么他能偷取到价值为 9 的财物,这是他在不触发报警系统的情况下能偷取到的最多的财物.
`f(i)`表示能偷取的最多财物

f(0) = arr[0]
f(1) = Math.max(arr[0], arr[1])
f(2) = Math.max(f(0) + arr[2], f(1))
f(3) = Math.max(f(2), f(1)+arr[3])
/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function (nums) {
    let len = nums.length;
    const dp = new Array(len);
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);
    for (let i = 2; i < len; i++) {
        dp[i] = Math.max(dp[i - 1], dp[i - 2] + nums[i]);
    }

    return dp[len - 1];
};

第一次自己写出来,不需要看题解,果然这东西需要练习。

考虑到每间房屋的最高总金额只和该房屋的前两间房屋的最高总金额相关,因此可以使用滚动数组,在每个时刻只需要存储前两间房屋的最高总金额。


/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function (nums) {
    let len = nums.length;
    if(len == 0) return 0
    if (len == 1) {
        return nums[0];
    }
    const dp = new Array(2);
    dp[0] = nums[0];
    dp[1] = Math.max(nums[0], nums[1]);
    for (let i = 2; i < len; i++) {
        dp[i % 2] = Math.max(dp[(i-1)%2], dp[(i-2)%2] + nums[i]);
    }

    return dp[(len-1)%2]
};
  1. 环形房屋偷盗
一个专业的小偷,计划偷窃一个环形街道上沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。
/**
 * @param {number[]} nums
 * @return {number}
 */
var rob = function(nums) {
    const length = nums.length;
    if (length === 1) {
        return nums[0];
    } else if (length === 2) {
        return Math.max(nums[0], nums[1]);
    }
    return Math.max(robRange(nums, 0, length - 2), robRange(nums, 1, length - 1));
};

const robRange = (nums, start, end) => {
    let first = nums[start], second = Math.max(nums[start], nums[start + 1]);
    for (let i = start + 2; i <= end; i++) {
        const temp = second;
        second = Math.max(first + nums[i], second);
        first = temp;
    }
    return second;
}

区别就在与循环导致限制

  1. 粉刷房子
假如有一排房子,共 n 个,每个房子可以被粉刷成红色、蓝色或者绿色这三种颜色中的一种,你需要粉刷所有的房子并且使其相邻的两个房子颜色不能相同。

r 17 18 21

b 2 33 10

g 17 7

/**
 * @param {number[][]} costs
 * @return {number}
 */
var minCost = function(costs) {
    let len = costs.length;
    if(costs.length == 0) {
        return 0
    }

    let dp = Array.from(new Array(3), () => {
        return new Array(len).fill(0);
    });

    for (let j = 0; j < 3; j++) {
        dp[j][0] = costs[0][j]
    }

    for(let i = 1; i < len; i++) {
        for (let j = 0; j < 3; j++) {
            let prev1 = dp[(j+2)%3][(i-1)%2];
            let prev2 = dp[(j+1)%3][(i-1)%2];
            dp[j][i%2] = Math.min(prev1, prev2) + costs[i][j] 
        }
    }

    let last = (len-1)%2
    return Math.min(dp[0][last], dp[1][last], dp[2][last])
};
  1. 翻转字符
如果一个由 '0' 和 '1' 组成的字符串,是以一些 '0'(可能没有 '0')后面跟着一些 '1'(也可能没有 '1')的形式组成的,那么该字符串是 单调递增 的。
我们给出一个由字符 '0' 和 '1' 组成的字符串 s,我们可以将任何 '0' 翻转为 '1' 或者将 '1' 翻转为 '0'。
返回使 s 单调递增 的最小翻转次数。

应用动态规划解决问题总是从分析状态转移方程开始的。如果一个只包含'0'和'1'的字符串S的长度为i+1,它的字符的下标范围为0~i。在翻转下标为i的字符时假设它的前i个字符都已经按照规则翻转完毕,所有的字符'0'都位于'1'的前面。

/**
 * @param {string} s
 * @return {number}
 */
var minFlipsMonoIncr = function(s) {
    let len = s.length;
    if(len == 0) return 0;
    let dp = new Array(2).fill().map(() => {
        return new Array(len).fill(0);
    });
    dp[0][0] = s[0] == '0' ? 0 : 1;
    dp[1][0] = s[0] == '1' ? 0 : 1;

    for(let i = 1; i < len; i++) {
        let ch = s[i]
        let prev0 = dp[0][(i-1)%2]
        let prev1 = dp[1][(i-1)%2]
        dp[0][i % 2] = prev0 + (ch == '0' ? 0 : 1)
        dp[1][i % 2] = Math.min(prev0, prev1) +  (ch == '1' ? 0 : 1)
    }

    return Math.min(dp[0][(len-1) % 2], dp[1][(len-1) % 2])

};
  1. 最长斐波那契数列
题目:输入一个没有重复数字的单调递增的数组,数组中至少有3个数字,请问数组中最长的斐波那契数列的长度是多少?例如,如果输入的数组是[1,2,3,4,5,6,7,8],由于其中最长的斐波那契数列是1、2、3、5、8,因此输出是5。
/**
 * @param {number[]} arr
 * @return {number}
 */
var lenLongestFibSubseq = function(arr) {
    const n = arr.length, map = new Map()
    const dp = new Array(n).fill(0).map(() => new Array(n).fill(0))
    for(let i = 0; i < n; i++) {
        map.set(arr[i], i)
    }

    let ans = 0
    for (let i = 1; i < n; i++) {
        for (let j = 0; j < i; j++) {
            const k = map.get(arr[i] - arr[j])
            dp[i][j] = k >= 0 && k < j ? dp[j][k] + 1 : 2
            if (ans < dp[i][j]) ans = dp[i][j]
        }
    }
    return ans > 2 ? ans : 0
};
  1. 最少回文分割
输入一个字符串,请问至少需要分割几次才可以使分割出的每个子字符串都是回文?例如,输入字符串"aaba",至少需要分割1次,从两个相邻字符'a'中间切一刀将字符串分割成两个回文子字符串"a"和"aba.
/**
 * @param {string} s
 * @return {number}
 */
var minCut = function(s) {
    const n = s.length;
    const g = new Array(n).fill(0).map(() => new Array(n).fill(true));

    for (let i = n - 1; i >= 0; --i) {
        for (let j = i + 1; j < n; ++j) {
            g[i][j] = s[i] == s[j] && g[i + 1][j - 1];
        }
    }

    const f = new Array(n).fill(Number.MAX_SAFE_INTEGER);
    for (let i = 0; i < n; ++i) {
        if (g[0][i]) {
            f[i] = 0;
        } else {
            for (let j = 0; j < i; ++j) {
                if (g[j + 1][i]) {
                    f[i] = Math.min(f[i], f[j] + 1);
                }
            }
        }
    }

    return f[n - 1];
};

构建 dp 数组,经常需要初始化二维数组

new Array(n).fill().map(() => {
  return new Array(n).fill(false);
});

Array.from(new Array(3), () => {
  return new Array(3).fill(false);
});

双序列

  1. 最长公共子序列(LCS)

输入两个字符串,请求出它们的最长公共子序列的长度。如果从字符串s1中删除若干字符之后能得到字符串s2,那么字符串s2就是字符串s1的一个子序列。例如,从字符串"abcde"中删除两个字符之后能得到字符串"ace",因此字符串"ace"是字符串"abcde"的一个子序列。但字符串"aec"不是字符串"abcde"的子序列。如果输入字符串"abcde"和"badfe",那么它们的最长公共子序列是"bde",因此输出3。

最长公共子序列问题是一个经典的计算机科学问题,
也是数据比较程序,比如 Diff工具 和 生物信息学应用的基础。
它也被广泛地应用在 版本控制,比如Git用来调和文件之间的改变

  1. 字符串交织

题目:输入3个字符串s1、s2和s3,请判断字符串s3能不能由字符串s1和s2交织而成,即字符串s3的所有字符都是字符串s1或s2中的字符,字符串s1和s2中的字符都将出现在字符串s3中且相对位置不变。例如,字符串"aadbbcbcac"可以由字符串"aabcc"和"dbbca"交织而成.


/**
 * @param {string} s1
 * @param {string} s2
 * @param {string} s3
 * @return {boolean}
 */
var isInterleave = function(s1, s2, s3) {
    let n = s1.length;
    let m = s2.length;
    let t = s3.length;
    if(n+m !== t) {
        return false
    }

    let f = new Array(n+1).fill().map(() => new Array(m+1).fill(false));

    f[0][0] = true;

    for(let i = 0; i <= n; i++) {
        for(let j = 0; j <= m; j++) {
            let p = i + j - 1;
            if (i > 0) {
                f[i][j] = f[i][j] || (f[i - 1][j] && s1.charAt(i - 1) == s3.charAt(p));
            }
            if (j > 0) {
                f[i][j] = f[i][j] || (f[i][j - 1] && s2.charAt(j - 1) == s3.charAt(p));
            }
        }
    }
    return f[n][m]
};
  1. 子序列的数目
题目:输入字符串S和T,请计算字符串S中有多少个子序列等于字符串T。例如,在字符串"appplep"中,有3个子序列等于字符串"apple"

难的是状态转移方程式。


var numDistinct = function(s, t) {
    const m = s.length, n = t.length;
    if (m < n) {
        return 0;
    }
    const dp = new Array(m + 1).fill(0).map(() => new Array(n + 1).fill(0));
    for (let i = 0; i <= m; i++) {
        dp[i][n] = 1;
    }
    for (let i = m - 1; i >= 0; i--) {
        for (let j = n - 1; j >= 0; j--) {
            if (s[i] == t[j]) {
                dp[i][j] = dp[i + 1][j + 1] + dp[i + 1][j];
            } else {
                dp[i][j] = dp[i + 1][j];
            }
        }
    }
    return dp[0][0];
};

矩阵路径问题

矩阵路径是一类常见的可以用动态规划来解决的问题。这类问题通常输入的是一个二维的格子,一个机器人按照一定的规则从格子的某个位置走到另一个位置,要求计算路径的条数或找出最优路径

矩阵路径相关问题的状态方程通常有两个参数,即f(i,j)的两个参数i、j通常是机器人当前到达的坐标。需要根据路径的特点找出到达坐标(i,j)之前的位置,通常是坐标(i-1,j-1)、(i-1,j)、(i,j-1)中的一个或多个。相应地,状态转移方程就是找出f(i,j)与f(i-1,j-1)、f(i-1,j)或f(i,j-1)的关系。

可以根据状态转移方程写出递归代码,但值得注意的是一定要将f(i,j)的计算结果用一个二维数组缓存,以避免不必要的重复计算。也可以将计算所有f(i,j)看成填充二维表格的过程,相应地,可以创建一个二维数组并逐一计算每个元素的值。通常,矩阵路径相关问题的代码都可以优化空间效率,用一个一维数组就能保存所有必需的数据。

  1. 路径的数目
一个机器人从m×n的格子的左上角出发,它每步要么向下要么向右,直到抵达格子的右下角。请计算机器人从左上角到达右下角的路径的数目。例如,如果格子的大小是3×3,那么机器人从左上角到达右下角有6条符合条件的不同路径,如图14.7所示。
/**
 * @param {number} m
 * @param {number} n
 * @return {number}
 */
var uniquePaths = function(m, n) {
    const dp = new Array(m).fill().map(() => new Array(n).fill(1))
    for(let i = 1; i < m; i++) {
        for(let j = 1; j < n; j++) {
            dp[i][j] = dp[i][j-1] + dp[i-1][j] 
        }
    }
    return dp[m-1][n-1]
};
  1. 最小路径之和
题目:在一个m×n(m、n均大于0)的格子中,每个位置都有一个数字。一个机器人每步只能向下或向右,请计算它从格子的左上角到达右下角的路径的数字之和的最小值。例如,从图14.8中3×3的格子的左上角到达右下角的路径的数字之和的最小值是8,图中数字之和最小的路径用灰色背景表示。

状态转移方程式不难,因为方向只有向下和向右


/**
 * @param {number[][]} grid
 * @return {number}
 */
var minPathSum = function(grid) {
    
    const m = grid.length
    const n = grid[0].length
    if(m==0&&n==0) {
        return 0
    }
    const dp = new Array(grid.length).fill().map(() => new Array(grid[0].length).fill(0))
    dp[0][0]=grid[0][0]
    for(let i = 1; i < n; i++) {
        dp[0][i] = dp[0][i-1] + grid[0][i]
    }
    for(let i = 1; i < m; i++) {
        dp[i][0] = dp[i-1][0] + grid[i][0]
    }
    for(let i = 1; i < m; i++) {
        for(let j = 1; j < n; j++) {
            dp[i][j] = Math.min(dp[i-1][j], dp[i][j-1])+grid[i][j]
        }
    }
    return dp[m-1][n-1]
};
  1. 三角形中最小路径之和
在一个由数字组成的三角形中,第1行有1个数字,第2行有2个数字,以此类推,第n行有n个数字。例如,图14.9是一个包含4行数字的三角形。如果每步只能前往下一行中相邻的数字,请计算从三角形顶部到底部的路径经过的数字之和的最小值。如图14.9所示,从三角形顶部到底部的路径数字之和的最小值为11,对应的路径经过的数字用阴影表示。
/**
 * @param {number[][]} triangle
 * @return {number}
 */
var minimumTotal = function(triangle) {
    if(triangle.length === 0) return 0;
    if(triangle[0].length === 0) return 0;
    const m = triangle.length // 行号
    const n = 2**(m-1) // 列号
    const dp = new Array(m).fill().map(() =>new Array(n).fill(0))
    dp[0][0] = triangle[0][0]
    for(let i = 1; i < m;i++) {
        for(let j = 0;j < 2**i; j++) {
            dp[i][j] = dp[i-1][Math.floor(j/2)] + triangle[i][Math.ceil(j/2)]
        }
    }
    return Math.min.apply(null, dp[m-1])

};

背包问题

应用动态规划的关键在于确定动态转移方程。可以用函数f(i,j)表示能否从前i个物品(物品标号分别为0,1,…,i-1)中选择若干物品放满容量为j的背包。如果总共有n个物品,背包的容量为t,那么f(n,t)就是问题的解。当判断能否从前i个物品中选择若干物品放满容量为j的背包时,对标号为i-1的物品有两个选择。一个选择是将标号为i-1的物品放入背包中,如果能从前i-1个物品(物品标号分别为0,1,…,i-2)中选择若干物品放满容量为j-nums[i-1]的背包(即f(i-1,j-nums[i-1])为true),那么f(i,j)就为true。另一个选择是不将标号为i-1的物品放入背包中,如果从前i-1个物品中选择若干物品放满容量为j的背包(即f(i-1,j)为true),那么f(i,j)也为true。当j等于0时,即背包的容量为0,不论有多少个物品,只要什么物品都不选择,就能使选中的物品的总重量为0,因此f(i,0)都为true。当i等于0时,即物品的数量为0,肯定无法用0个物品来放满容量大于0的背包,因此当j大于0时f(0,j)都为false。

0-1背包问题

  • 101 分割等和子集
题目:给定一个非空的正整数数组,请判断能否将这些数字分成和相等的两部分。例如,如果输入数组为[3,4,1],将这些数字分成[3,1]和[4]两部分,它们的和相等,因此输出true;如果输入数组为[1,2,3,5],则不能将这些数字分成和相等的两部分,因此输出false。
  • 递归
function canPartition(nums) {
  let sum = 0;
  for(let num of nums) {
      sum += num
  }
  if(sum %2 == 1) return false;
  return subsetSum(nums, sum/2)
}

function subsetSum(nums, target) {
    const dp = new Array(nums.length+1).fill().map(() => new Array(target+1).fill(false))
    return helper(nums, dp, nums.length, target)
}

function helper(nums, dp, i, j) {
    if(dp[i][j] == null) {
        if(j == 0) {
            dp[i][j] = true
        } else if (i == 0) {
            dp[i][j] = false
        } else {
            dp[i][j] = helper(nums, dp, i-1, j);
            if(!dp[i][j]&& j>= nums[i-1]) {
                dp[i][j] = helper(nums, dp, i-1, j-nums[i-1])
            }
        }
    }
    return dp[i][j]
}
  • 迭代

/**
 * @param {number[]} nums
 * @return {boolean}
 */

function canPartition(nums) {
  let sum = 0;
  for(let num of nums) {
      sum += num
  }
  if(sum %2 == 1) return false;
  return subsetSum(nums, sum/2)
}

function subsetSum(nums, target) {
    const dp = new Array(nums.length+1).fill().map(() => new Array(target+1).fill(false))
    for(let i = 0; i <= nums.length; i++) {
        dp[i][0] = true
    }   
    for(let i = 1; i <= nums.length; i++ ) {
        for(let j = 1; j <= target; j++) {
            dp[i][j] = dp[i-1][j]
            if(!dp[i][j] && j >= nums[i-1]) {
                dp[i][j] = dp[i-1][j-nums[i-1]]
            }
        }
    }
    return dp[nums.length][target]
}
  • 102 加减的目标值
给定一个非空的正整数数组和一个目标值S,如果为每个数字添加“+”或“-”运算符,请计算有多少种方法可以使这些整数的计算结果为S。例如,如果输入数组[2,2,2]并且S等于2,有3种添加“+”或“-”的方法使结果为2,它们分别是2+2-2=2、2-2+2=2及-2+2+2=2。

如果所有添加“+”的数字之和为p,所有添加“-”的数字之和为q,则 p-q = target; p+q = sum; 所以 p = (sum+target)/2。因此,这个题目等价于计算从数组中选出和为(S+sum)/2的数字的方法的数目。这是和前面的面试题非常类似的题目,是一个典型的0-1背包问题,可以用动态规划解决。

用动态规划求解问题的关键在于确定状态转移方程。可以用函数f(i,j)表示在数组的前i个数字(即nums[0..i-1])中选出若干数字使和等于j的方法的数目。如果数组的长度为n,目标和为t,那么f(n,t)就是整个问题的解。

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
var findTargetSumWays = function(nums, target) {
    let sum = 0;
    for(let num of nums) {
        sum += num
    }
    if((sum + target)%2 == 1 || sum < target) {
        return 0;
    }
    return subsetSum(nums, (sum+target)/2)
};

var subsetSum = function(nums, target) {
    let dp = new Array(target+1).fill(0);
    dp[0] = 1
    for(const num of nums) {
        for(let j = target; j >= num; --j) {
            dp[j] += dp[j - num]
        }
    }
    return dp[target]
}

完全背包问题

与0-1背包问题相比,完全背包问题是 每件物品可以放无数次,然后求背包可以装的最大价值。

  1. 最少硬币数目
给定正整数数组coins表示硬币的面额和一个目标总额t,请计算凑出总额t至少需要的硬币数目。每种硬币可以使用任意多枚。如果不能用输入的硬币凑出给定的总额,则返回-1。例如,如果硬币的面额为[1,3,9,10],总额t为15,那么至少需要3枚硬币,即2枚面额为3的硬币及1枚面额为9的硬币

如果将每种面额的硬币看成一种物品,而将目标总额看成背包的容量,那么这个问题等价于求将背包放满时物品的最少件数。值得注意的是,这里每种面额的硬币可以使用任意多次,因此这个问题不再是0-1背包问题,而是一个无界背包问题(也叫完全背包问题)。


/**
 * @param {number[]} coins
 * @param {number} amount
 * @return {number}
 */
var coinChange = function(coins, target) {

    const dp = new Array(target+1).fill(target+1)

    dp[0] = 0;

    for(const coin of coins) {
        for(let j = target; j >= 1; j--) {
            for(let k =  1; k * coin <=j; k++) {
                dp[j] = Math.min(dp[j], dp[j - k*coin]+k)
            }
        }
    }
    return dp[target] > target ? -1 : dp[target]   
};
  1. 排列的数目
给定一个非空的正整数数组nums和一个目标值t,数组中的所有数字都是唯一的,请计算数字之和等于t的所有排列的数目。数组中的数字可以在排列中出现任意次。例如,输入数组[1,2,3],目标值t为3,那么总共有4个组合的数字之和等于3,它们分别为{1,1,1}、{1,2}、{2,1}及{3}。

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number}
 */
var combinationSum4 = function(nums, target) {
    let dp = new Array(target+1).fill(0)
    dp[0] = 1;

    for(let i = 1; i <= target; i++) {
        for(const num of nums) {
            if(i >= num) {
                dp[i] += dp[i-num]
            }
        }
    }
    return dp[target]
};

总结

如果解决一个问题需要若干步骤,并且在每个步骤都面临若干选项,不要求列出问题的所有解,而只是要求计算解的数目或找出其中一个最优解,那么这个问题可以应用动态规划加以解决。本章介绍了单序列问题、双序列问题、矩阵路径问题和背包问题,这几类问题都适合运用动态规划来解决。运用动态规划解决问题的关键在于根据题目的特点推导状态转移方程。一旦确定了状态转移方程,那么问题就能迎刃而解。

另外,动态规划跟深度遍历一样,有很强的模板套路,所以多练习即可。

参考文章


看见了
876 声望16 粉丝

前端开发,略懂后台;


« 上一篇
【算法】回溯
下一篇 »
【算法】汇总