算法基础知识——动态规划(三)

一、采用动态规划处理待解决问题的特征

  • 简单的子问题(Simple problem):有某种方式将全局优化问题分解为子问题,每个子问题都与原问题类似。有一种定义子问题的简单方式,如几个下标i,j,k等。
  • 子问题最优性(Subproblem Optimality):问题的最优解 = 子问题最优解的组合。
  • 子问题重叠(Subproblem Overlap):不相关子问题的最优解可以包含公共子问题。因此可以通过存储中间过程值避免重复递归调用。

二、动态规划常见应用

  • 矩阵连乘(Matrix Chain-Product):在多个矩阵相乘的表达式A中,如何插入括号使得乘积个数最小。
  • 望远镜调度(Telescope Scheduling):对于给定观察请求列表,如何在不冲突的情况下安排这些观察请求,使得被列入观察日程的观测的总效益达到最大。
  • 博弈策略:每个博弈方在进行决策时可以选择多种做法的一类问题。
  • 最长公共子序列(Longest Common Subsequence,LCS):每个字符串中按照字符出现的相对顺序选出的最长子串(这里的子序列不一定连续,子串(substring)一定连续)。
  • 0-1背包问题(0-1 Knapsack Problem):如何在总重量不超过重量限制的前提下,最大化所携带物品的总价值。
  • 最大子数组和(Max SubArray):给定一个整数数组,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
  • 传递闭包:给定一个有向图/无向图,如果结点ij连通但不直接相连,就建立一个边\left \langle i,j \right \rangle
  • 所有结点对之间的最短路径(All-Paris Shortest Paths)。
  • ……

三、矩阵连乘问题

  • A_{0}\cdot A_{1}...A_{n-1}的乘积,其中A_{i}d_{i}\times d_{i+1}阶矩阵,i=0,1,2...,n-1
    • 定义子问题:子问题为A_{i}\cdot A_{i+1}...A_{j}N_{i,j}表示计算这个表达式所需的最小乘积数,原矩阵连乘问题=计算N_{0,n-1}的值。
    • 刻画最优解:A_{i}\cdot A_{i+1}...A_{j}=\left ( A_{i}... A_{k} \right ) \cdot \left ( A_{k+1} ... A_{j} \right ),k\in \left \{ i,i+1,...,j-1 \right \}
    • 设计动态规划算法:N_{i,j}=min\left \{ N_{i,k} +N_{k+1,j}+d_{i}\cdot d_{k+1}\cdot d_{j+1}\right \}N_{i,i}=0
    • 算法效率:O\left ( n^{3} \right ),表示A_{i}矩阵为d\times e阶时、A_{j}矩阵为e\times f阶时,任意的单个元素需要e个数量乘积,所有项的计算需要d\times e\times f个数量乘积。

四、望远镜调度问题

  • 观察请求列表L,列表中每个申请记为i,申请包含了请求观察开始时间s_{i},完成观察时间f_{i},完成观察后的科学效益指标b_{i}。对于一个观察请求i,必须用望远镜完成从s_{i}f_{i}的整个观察,才能得到效益b_{i}。对于两个观察请求ij,如果时间区间\left [ s_{i},f_{i} \right ]\left [ s_{j},f_{j} \right ]相交,则称两个请求有冲突。
    • 定义子问题:观察请求的次序问题,按照结束时间排序和按照效益排序。定义前驱(predecessor)为pred\left ( i \right ),表示与i不冲突的最大j\left ( j< i \right ),如果不存在这样的下标,则定义i的前驱为0。排序后的第i个观察请求的效益为b_{i}。定义最优值为B_{i}
    • 刻画最优解:分两种情况,达到最优值时包含观察请求i,有B_{i}=B_{pred\left ( i \right )}+b_{i}; 达到最优值B_{i}不包含观察请求i,有B_{i}=B_{i-1}
    • 设计动态规划算法:B_{i}=max\left \{ B_{i-1},B_{pred\left ( i \right )}+b_{i} \right \}B_{0}=0
    • 算法效率:O\left ( nlogn \right ),包括为了求前驱对完成观察时间f_{i}排序需要O\left ( nlogn \right ),遍历观察请求列表L需要O\left ( n \right )

五、博弈策略

  • 硬币行游戏(Coins-in-a-line):偶数个硬币排成一行,两名玩家轮流从剩余的硬币行的两端取走一个硬币。最后收集到总面值最大的玩家胜利。
    • 定义子问题:硬币编号为\left [ 1,n \right ],面值编号为V\left [ i \right ]\left ( 1\leq i\leq n \right )M_{i,j}为在编号ij硬币行中当前玩家与另一位玩家面值之差的最大值。
    • 刻画最优解:给定编号从ij的硬币行,当j=i,有M_{i,i}=V\left [ i \right ];当i<j,当前玩家可取走V\left [ i \right ]V\left [ j \right ],有M_{i,j}=max\left \{ V\left [ i \right ]-M_{i+1,j}, V\left [ j \right ]-M_{i,j-1} \right \},最后判断M_{1,n},如果大于0,则第一位玩家胜利;等于0,平局,否则,第二位玩家胜利。
    • 设计动态规划算法:M_{i,j}=\left\{\begin{matrix} max\left \{ V\left [ i \right ]-M_{i+1,j}, V\left [ j \right ]-M_{i,j-1} \right \},i<j \\ V\left [ i \right ],i=j \end{matrix}\right.
    • 算法效率:O\left ( n^{2} \right ),需要计算每个子数组对应的M的值,包括i从1到n,共计算n\left ( n+1 \right )/2个子数组。

实例5-1 【LeetCode】877. 石子游戏

  • Alice 和 Bob 用几堆石子在做游戏。一共有偶数堆石子,排成一行;每堆都有 正 整数颗石子,数目为 piles[i] 。游戏以谁手中的石子最多来决出胜负。石子的 总数 是 奇数 ,所以没有平局。Alice 和 Bob 轮流进行,Alice 先开始 。 每回合,玩家从行的 开始 或 结束 处取走整堆石头。这种情况一直持续到没有更多的石子堆为止,此时手中 石子最多 的玩家 获胜 。假设 Alice 和 Bob 都发挥出最佳水平,当 Alice 赢得比赛时返回 true ,当 Bob 赢得比赛时返回 false 。
  • 2 <= piles.length <= 500
  • piles.length 是 偶数
  • 1 <= piles[i] <= 500
  • sum(piles[i]) 是 奇数
    • 输入:piles = [5,3,4,5]
    • 输出:true
    • 解释:Alice 先开始,只能拿前 5 颗或后 5 颗石子 。假设他取了前 5 颗,这一行就变成了 [3,4,5] 。如果 Bob 拿走前 3 颗,那么剩下的是 [4,5],Alice 拿走后 5 颗赢得 10 分。如果 Bob 拿走后 5 颗,那么剩下的是 [3,4],Alice 拿走后 4 颗赢得 9 分。这表明,取前 5 颗石子对 Alice 来说是一个胜利的举动,所以返回 true 。

示例代码:

class Solution {
public:
    bool stoneGame(vector<int>& piles) {
        int dp[piles.size()][piles.size()];
        memset(dp, 0, sizeof(dp));
        for(int i = 0; i < piles.size() - 1; i++){
            dp[i][i] = piles[i];
        }
        for(int i = piles.size() - 2; i >= 0; i--){
            for(int j = i + 1; j < piles.size(); j++){
                dp[i][j] = max(piles[i] - dp[i+1][j], piles[j] - dp[i][j-1]);
            }
        }
        return dp[0][piles.size() - 1] > 0;
    }
};

六、最长公共子序列

  • 给定长度为nm的字符串XY,求既是X的子序列又是Y的子序列的最长子序列S
    • 定义子问题:L\left [ i,j \right ]X\left [ 0...i \right ]Y\left [ 0...j \right ]的最长公共子序列。
    • 刻画最优解:如果X\left [ i \right ]=Y\left [ j \right ],可以断定公共子序列以X\left [ i \right ]结尾,有L\left [ i,j \right ]=L\left [ i-1,j-1 \right ]+1;如果X\left [ i \right ]\neq Y\left [ j \right ],公共子序列的结尾可能是X\left [ i \right ]或者Y\left [ j \right ],或者两者都不是,有L\left [ i,j \right ]=max\left \{ L\left [ i-1,j \right ],L\left [ i,j-1 \right ] \right \}
    • 设计动态规划算法:定义一个长度为\left ( n+1 \right )\times \left ( m+1 \right )的数组L,初始化L[i,0]=0L[0,j]=0,从头迭代,直到求出L\left [ n \right ]\left [ m \right ]
    • 算法效率:O\left ( nm \right ),分别需要遍历俩字符串,nm的长度
    • 如何输出最长公共子序列:从后往前重建最长公共子序列,如果X\left [ i \right ]=Y\left [ j \right ],则存储该字符,然后转到L\left [ i-1 \right ]\left [ j-1 \right ];如果X\left [ i \right ]\neq Y\left [ j \right ],则转到L\left [ i-1,j \right ]L\left [ i,j-1 \right ]中的较大者;当遇到边界值i=0j=0时算法终止。

实例6-1:【LeetCode】1143. 最长公共子序列

  • 给定两个字符串 text1 和 text2,返回这两个字符串的最长 公共子序列的长度。如果不存在公共子序列,返回0。
  • 1 <= text1.length, text2.length <= 1000
  • text1 和 text2 仅由小写英文字符组成。
    • 输入:text1 = "abcde", text2 = "ace"
    • 输出:3
    • 解释:最长公共子序列是 "ace" ,它的长度为 3 。
    • 输入:text1 = "abc", text2 = "def"
    • 输出:0
    • 解释:两个字符串没有公共子序列,返回 0 。

示例代码:

#include <cstring>
class Solution {
public:
    int longestCommonSubsequence(string text1, string text2) {
        int len1 = text1.length(), len2 = text2.length();
        //dp[i][j]表示以i结尾的text1和以j结尾的text2的最长公共子序列
		int dp[len1 + 1][len2 + 1]; 
        memset(dp, 0, sizeof(dp));
        for(int i = 0; i < len1; i++){
            for(int j = 0; j < len2; j++){
                if(text1[i] == text2[j]){
                    dp[i + 1][j + 1] = dp[i][j] + 1;
                }else{
                    dp[i + 1][j + 1] = max(dp[i][j + 1], dp[i + 1][j]);
                }
            }
        }
        return dp[len1][len2];
    }
};

实例6-2:【LeetCode】300. 最长递增子序列

  • 给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。
  • 1 <= nums.length <= 2500
  • -10^4 <= nums[i] <= 10^4
    • 输入:nums = [10,9,2,5,3,7,101,18]
    • 输出:4
    • 解释:最长递增子序列是 [2,3,7,101],因此长度为 4 。
    • 输入:nums = [7,7,7,7,7,7,7]
    • 输出:1

示例代码:

方法一:动态规划法,dp\left [ i \right ]=max\left \{ dp[i],dp\left [ j+1 \right ] \right \},for~j~in~[0,i),时间复杂度:O\left ( n^{2} \right )

int lengthOfLIS(vector<int>& nums) {
	//dp[i]表示以i结尾的串的最长递增子序列的长度
	int dp[nums.size()];
	int result = 1;
	for(int i = 0; i < nums.size(); i++){
		dp[i] = 1;
		for(int j = 0; j < i; j++){
			if(nums[i] > nums[j])
				dp[i] = max(dp[i], dp[j] + 1);
		}
		result = max(dp[i], result);
	}
	return result;
}

方法二:贪心法,希望每次在上升子序列中最后加上的那个数尽可能小,lcs\left [ i \right ]表示长度为i的最长递增子序列末尾元素的最小值,如果nums[i]>lcs\left [ lcs.size()-1)) \right ],直接加入到数组末尾;否则,找到第一个nums\left [ i \right ]小的数lcs\left [ k \right ],更新lcs\left [ k+1 \right ]=nums\left [ i \right ],时间复杂度:O\left ( n^{2} \right )

int lengthOfLIS(vector<int>& nums) {
	int index;
	vector<int> lcs;
	lcs.push_back(nums[0]);
	for(int i = 0; i < nums.size(); i++){
		if(nums[i] > lcs[lcs.size() - 1]){
			lcs.push_back(nums[i]);
		}else{
			for(index = lcs.size() - 1; index >= 0; index--){
				if(nums[i] > lcs[index]){
					lcs[index + 1] = nums[i];
					break;
				}
			}
			if(index == -1){
				lcs[0] = nums[i];
			}
		}
	}
	return lcs.size();
} 

方法三:贪心法 + 二分查找,由于lcs数组的单调性,引入二分查找,优化时间复杂度,时间复杂度:O\left ( nlogn \right )

int lengthOfLIS(vector<int>& nums) {
	vector<int> lcs;
	lcs.push_back(nums[0]);
	int left = 0, right = 0, middle = 0;
	for(int i = 0; i < nums.size(); i++){
		if(nums[i] > lcs[lcs.size() - 1]){
			lcs.push_back(nums[i]);
		}
		left = 0, right = lcs.size();
		while(left < right){
			middle = (right - left) / 2 + left;
			// 找到第一个nums[i]大于的数
			if(nums[i] > lcs[middle]){
				left = middle + 1;
			}else{
				right = middle;
			}
		}
		lcs[left] = nums[i];
	}
	return lcs.size();
}

七、0-1背包问题

  • 伪多项式时间算法(Pseudo-Polynomial-Time):运行时间是输入中某个量的多项式(依赖于参数W),而不是整个输入规模的多项式。
  • 设背包可承受最大总重量为W,可能携带的物品来自n件不同的有用物品构成的集合S,每件物品i有一个整数重量w_{i}和实用价值b_{i}。如何在不超过重量限制的情况下携带最大价值的物品。
    • 定义子问题:物品编号\left [ 1...n \right ]S_{k}表示编号为\left [ 1...k \right ]的物品集合,B\left [ k,w \right ]表示在S_{k}的所有子集中,总重量不超过w的子集的最大价值
    • 刻画最优解:当w_{k}\leq w时,S_{k-1}的总重量不超过w-w_{k}的最优子集再加上k号物品,或者S_{k-1}的总重量不超过w的最优子集,有B\left [ k,w \right ]=max\left \{ B\left [ k-1,w \right ], B\left [ k-1,w -w_{k}\right ]+b_{k} \right \};当w_{k}> w时,B\left [ k,w \right ]= B\left [ k-1,w \right ]
    • 设计动态规划算法:B\left [ k,w \right ]= \left\{\begin{matrix}max\left \{ B\left [ k-1,w \right ], B\left [ k-1,w -w_{k}\right ]+b_{k} \right \},w_{k}\leq w \\ B\left [ k-1,w \right ],w_{k}> w \end{matrix}\right.
    • 算法效率:O\left ( nW \right )
  • 引申:完全背包问题
    • B\left [ k,w \right ]= \left\{\begin{matrix}max\left \{ B\left [ k-1,w \right ], B\left [ k,w -w_{k}\right ]+b_{k} \right \},w_{k}\leq w \\ B\left [ k-1,w \right ],w_{k}> w \end{matrix}\right.

八、其他问题示例

实例8-1 【LeetCode】剑指 Offer 47. 礼物的最大价值

  • 在一个 m*n 的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于 0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格、直到到达棋盘的右下角。给定一个棋盘及其上面的礼物的价值,请计算你最多能拿到多少价值的礼物?
  • 0 < grid.length <= 200
  • 0 < grid[0].length <= 200
  • 输入: 
    [
      [1,3,1],
      [1,5,1],
      [4,2,1]
    ]
  • 输出: 12
  • 解释: 路径 1→3→5→2→1 可以拿到最多价值的礼物

方法:动态规划,dp(i, j)=\left\{\begin{array}{ll} \operatorname{grid}(i, j) & , i=0, j=0 \\ \operatorname{grid}(i, j)+d p(i, j-1) & , i=0, j \neq 0 \\ \operatorname{grid}(i, j)+d p(i-1, j) & , i \neq 0, j=0 \\ \operatorname{grid}(i, j)+\max [d p(i-1, j), d p(i, j-1)] & , i \neq 0, j \neq 0 \end{array}\right.

示例代码:

#include <cstring>
class Solution {
public:
    int maxValue(vector<vector<int>>& grid) {
        int m = grid.size(), n = grid[0].size();
        int dp[m][n];
        memset(dp, 0, sizeof(dp));
        dp[0][0]=grid[0][0];
        for(int i = 1; i < n; i++){
            dp[0][i] = dp[0][i-1] + grid[0][i];
        }
        for(int i = 1; i < m; i++){
            dp[i][0] = dp[i - 1][0] + grid[i][0];
        }
        for(int i = 1; i < m; i++){
            for(int j = 1; j < n; j++){
                dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + grid[i][j];
            }
        }
        return dp[m-1][n-1];
    }
};

实例8-2 【LeetCode】198. 打家劫舍

  • 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
  • 1 <= nums.length <= 100
  • 0 <= nums[i] <= 400
    • 输入:[2,7,9,3,1]
    • 输出:12
    • 解释:偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。

方法:动态规划,dp[i]=\left\{\begin{array}{l} \text { nums }[0],i=0 \\ \max \{\text { nums }[0], \text { nums }[1]\},i=1 \\ \max \{\text { dp }[i-2]+\text { nums }[i], \text { dp }[i-1]\},i>1 \end{array}\right.

示例代码:

class Solution {
public:
    int rob(vector<int>& nums) {
        //dp[i]表示以i房屋为结尾的最高金额
        //dp[0] = nums[0], dp[1] = max{nums[0], nums[1]}
        //dp[i] = max(dp[i-2] + nums[i], dp[i-1])
        if(nums.size() == 1){
            return nums[0];
        }
        int dp[nums.size()];
        dp[0] = nums[0], dp[1] = max(nums[0], nums[1]);
        for(int i = 2; i < nums.size(); i++){
            dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
        }
        return dp[nums.size() - 1];
    }
};

参考文献

[1]Michael T.Goodrich, Roberto Tamassia. 算法设计与应用. [M]北京:机械工业出版社,2018.01;

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值