【算法/训练】:动态规划(线性DP)

一、路径类

1. 字母收集

思路:

1、预处理

对输入的字符矩阵我们按照要求将其转换为数字分数由于只能往下和往右走,因此走到(i,j)的位置要就是从(i - 1, j)往下走,或者是从(i,j - 1)的位置往右走 ,由于我们要使得路程遍历积分最多,则应该从积分多的位置过来,

2、状态表示 dpij 表示:0, 0出发,到底i, j位置,一共有多少分

3、状态转移方程

故(i,j)位置的积分应该为dp** i j = max(dp i - 1 j , dp i j - 1 ) + dp i j ;**

4、初始化

但是上面仅对于(i >= 1 && j >= 1)成立, 对于第一行和第一列我们应该特殊处理,利用前缀和的知识 可以求得,走到第一列的第i个位置最多能拿的积分以及走到第一行的第j个位置最多能拿的积分 ,然后我们就可以按照dp** i j = max(dp i - 1 j , dp i j - 1 ) + dp i j 的方法遍历每个节点即可**

AC代码如下:

cpp 复制代码
#include <iostream>
using namespace std;

const int N = 1005;
int dp[N][N];

int main() {
	int n, m;
	cin >> n >> m;
	char ch;
	for (int i = 0; i < n; i++){
		for (int j = 0; j < m; j++){
			cin >> ch;
			if (ch == 'l') dp[i][j] = 4;
			else if (ch == 'o') dp[i][j] = 3;
			else if (ch == 'v') dp[i][j] = 2;
			else if (ch == 'e') dp[i][j] = 1;
			else a[i][j] = 0;
		}
	}

for (int i = 1; i < n; i++) dp[i][0] = dp[i - 1][0] + dp[i][0]; 
for (int j = 1; j < m; j++) dp[0][j] = dp[0][j - 1] + dp[0][j]; 

	for (int i = 1; i < n; i++){
		for (int j = 1; j < m; j++){
			dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) + dp[i][j];
		}
	}

	cout << dp[n - 1][m - 1] << endl;
	return 0;
}

2、NOIP2002 普及组 过河卒

分析:

思路:
1、状态表示

dpij 表示:从0, 0出发,到底i, j位置,一共有多少种方法
2、状态转移方程

dp i j = dp i - 1 j + dpij - 1 (i > 0 && j > 0)

当走到马可以走的地方,dp i j = 0;
3、初始化

先创建一个 dp n + 2 m + 2 ,然后让dp 0 1 = 1 或者 dp 1 0 = 1。注意这样初始化的时候,x需要+1,y也需要+1.和dp表位置一一对应

cpp 复制代码
#include <iostream>
#include <vector>
using namespace std;

//int dp[1005][1005];
int main()
{
    int n, m, x, y;
    cin >> n >> m >> x >> y;
    
    vector<vector<long long>> dp(n + 2, vector<long long>(m + 2));
    dp[0][1] = 1;
    x += 1, y += 1;和dp表位置一一对应

    for (int i = 1; i <= n + 1; i++)
    {
        for (int j = 1; j <= m + 1; j++) { //马所在位置
            if (i != x && j != y && abs(i - x) + abs(j - y) == 3 || (i == x && j == y))
            {
                dp[i][j] == 0;
            }
            else dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
        }
    }
    cout << dp[n + 1][m + 1] << endl;

    return 0;
}

二、子序列和连续序列类

1. mari和shiny

🌈线性 dp

在维护 i 位置之前,⼀共有多少个 "s" "sh" ,然后更新 "shy" 的个数。

(1)状态表示

  • si:字符串 str 中 0, i 区间内有多少个 "s"。

  • hi:字符串 str 中 0, i 区间内有多少个 "sh"。

  • yi:字符串 str 中 0, i 区间内有多少个 "shy。


(2)状态转移方程


(3)空间优化

用三个变量来表示即可

s:(字符串 str 中 0, n-1 区间内有多少个 "s")

h:(字符串 str 中 0, n-1 区间内有多少个 "sh")

y:(字符串 str 中 0, n-1 区间内有多少个 "shy")

最后的遍历结束后,y即我们需要的结果

AC代码如下:

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;
typedef long long ll;
int main()
{
	int n;
	string str;
	cin >> n >> str;
	ll s = 0, h = 0, y = 0;
	for (int i = 0; i < n; i++) {
		if (str[i] == 's') s++;
		else if (str[i] == 'h') h += s;
		else if (str[i] == 'y') y += h;
	}
	cout << y << endl;
	return 0;
}
🌈二维 dp

这道题目如果不是子序列,而是要求连续序列的,那就可以考虑用 KMP。

(1)dpij 含义

  • string t="shy"

dpij:以 i-1 为结尾的 str 子序列中出现以 j-1 为结尾的 t 的个数为 dpij


(2)递推关系

  • stri - 1 与 tj - 1相等
  • stri - 1 与 tj - 1 不相等

当 stri - 1 与 tj - 1相等时,dpij 可以有两部分组成:

  1. 一部分是用 stri - 1 来匹配,那么个数为 dpi - 1j - 1。即不需要考虑当前 str 子串和 t 子串的最后一位字母,所以只需要 dpi-1j-1
  2. 一部分是不用 stri - 1 来匹配,个数为 dpi - 1j

为什么还要考虑不用 stri - 1 来匹配,都相同了指定要匹配?

🧩例如: str:shyy 和 t:shy ,str3 和 t2 是相同的,但是字符串 str 也可以不用 str3 来匹配,即用 str0str1str2 组成的 "shy"。当然也可以用 str3 来匹配,即:str0str1str3 组成的 "shy"。

所以,当 stri - 1 与 tj - 1 相等时,dp i j = dp i - 1 j - 1 + dp i - 1 j ;

当 stri - 1 与 tj - 1 不相等时,dpij 只有一部分组成,不用 stri - 1 来匹配(就是模拟在 str 中删除这个元素),即:dpi - 1j,所以递推公式为:dp i j = dp i - 1 j ;

为什么只考虑 "不用 stri - 1 来匹配" 这种情况, 不考虑 "不用 tj - 1 来匹配" 的情况呢?

🧩这里要明确,我们求的是str 中有多少个 t,而不是求 t 中有多少个 str ,所以只考虑 str 中删除元素的情况,即不用 stri - 1 来匹配 的情况。


(3)状态转移方程

  • **dpij显然要从dpi-1?递推而来。**立即思考dpi-1j, dpi-1j-1分别与dpij的关系。因为少一个字符,自然而然从当前字符着手。考察si的第i个字符(表为si)和tj的第j个字符(表为tj)的关系。

若si ≠ tj:那么s_i中的所有t_j子序列,必不包含si,即s_i-1和s_i中tj的数量是一样的,得到该情形的转移方程: dp i j = dp i -1 j
*

若si = tj:假设s_i中的所有t_j子序列中,包含si的有a个,不包含的有b个。s_i中包含si的子序列个数相当于s_i-1中t_j-1的个数,不包含si的子序列个数与上一种情况一样,于是得到该情形的转移方程:

a = dp i -1 j -1 b = dp i-1 j dp i j = a + b = dpi-1j-1 + dpi-1j


(4)遍历顺序

从上到下,从左到右。

AC代码如下:

cpp 复制代码
#include <iostream>
#include <vector>
 
using namespace std;
 
int main()
{
    int n;
    cin >> n;
    string str;
    cin >> str;
    string t="shy";
    int m=t.size();
 
    vector<vector<long long>> dp(n+1, vector<long long>(m+1));
    for(int i=0; i<=n; i++) dp[i][0]=1;
 
    for(int i=1; i<=n; i++)
    {
        for(int j=1; j<=m; j++)
        {
            if(str[i-1]==t[j-1])
                dp[i][j]=dp[i-1][j-1]+dp[i-1][j];
            else
                dp[i][j]=dp[i-1][j];
        }
    }
 
    cout << dp[n][m] << endl;
 
    return 0;
}
  1. 不同的子序列

该题于上题不一样的是,上面给定了t的具体字符串,而这里没有给定,但是我们也需要用二维dp的方法来写。

(1)dpij 含义

s i 的子序列中在t j 出现的次数

s i 表示s从下标 i 到末尾的子字符串。

t j 表示t从下标 j 到末尾的子字符串。


(2)递推关系

  1. 分别令两个维度为0,推测边界。

  2. dp0j表示s_0中t_j的个数。s_0是空字符串,只有当j=0时,才有dp0j = 1,表示s子空串中有一个t子空串,否则dp0j = 0,因为一个空串不可能包含一个非空串。

  3. dpi0表示s_i中t0的个数。t_0是空字符串,显然任何串(包括空串)都含有一个空子串。因此dpi0 = 1。

注意到,dpi0 = 1实际上已经包含了dp0j = 1的情形。


(3)初始化

  • dpi0 表示:以 i-1 为结尾的 str 可以随便删除元素,出现空字符串的个数。所以,dpi0 一定都是 1,因为也就是把以 i-1 为结尾的 str,删除所有元素,出现空字符串的个数就是 1。
  • dp0j 表示:空字符串 str 可以随便删除元素,出现以 j-1 为结尾的字符串 t 的个数。所以,dp0j 一定都是 0,因为 str 如论如何也变成不了 t。
  • dp00 表示:空字符串 str 可以删除 0 个元素,变成空字符串 t。所以,dp00 = 1。

(4)遍历顺序

从上到下,从左到右。

cpp 复制代码
​
int numDistinct(string s, string t) {
	int n = s.size(), m = t.size();
	if (n < m) return 0;
	vector<vector<unsigned int>> dp(n + 1, vector<unsigned int>(m + 1)); //注意是unsigned int
	for (int i = 0; i <= n; i++) dp[i][0] = 1;
	for (int i = 1; i <= n; i++) {
		for (int j = 1; j <= m; j++) {
			dp[i][j] = dp[i - 1][j] +(s[i - 1] == t[j - 1] ? dp[i - 1][j - 1] : 0);
		}
	}
	return dp[n][m];
}

​

三、偷盗问题

1. 打家劫舍

思路:

首先考虑最简单的情况。如果只有一间房屋,则偷窃该房屋,可以偷窃到最高总金额。如果只有两间房屋,则由于两间房屋相邻,不能同时偷窃,只能偷窃其中的一间房屋,因此选择其中金额较高的房屋进行偷窃,可以偷窃到最高总金额。

如果房屋数量大于两间,应该如何计算能够偷窃到的最高总金额呢?对于 k( k > 2)的房屋数量,有两个选项:

  • 偷窃第k间房屋,那么就不能偷窃第 k−1 间房屋,偷窃总金额为前 k−2 间房屋的最高总金额与第 k 间房屋的金额之和。

  • 不偷窃第 k 间房屋,偷窃总金额为前 k−1 间房屋的最高总金额。

    在两个选项中选择偷窃总金额较大的选项,该选项对应的偷窃总金额即为前 k 间房屋能偷窃到的最高总金额。

1、状态表示

dp i :表示前i间房屋能偷盗的最多钱数。
2、状态转移方程

dp i = max(dp i - 2 + nums i , dpi - 1 )

提示里说了nums.length>=1;

对于nums.length==1,需特殊处理,return nums0;

AC代码如下:

cpp 复制代码
int rob(vector<int>& nums) {
    int n = nums.size();
    //方法一
    /*if (n == 1) return nums[0];
    vector<int> dp(n + 1);
    dp[0] = nums[0];
    dp[1] = max(nums[0], nums[1]);
    for (int i = 2; i < n; ++i) {
        dp[i] = max(dp[i - 2] + nums[i], dp[i - 1]);
    }
    return dp[n - 1];*/

    //方法二
    int f1 = 0, f0 = 0;
    for (int i = 0; i < n; i++) {
        int tmp = max(f1, f0 + nums[i]);
        f0 = f1;
        f1 = tmp;
    }
    return f1;
}

2. 打家劫舍 II

思路:

这道题中的房屋是首尾相连的,第一间房屋和最后一间房屋相邻,因此第一间房屋和最后一间房屋不能在同一晚上偷窃。和上题相似,这道题也可以使用动态规划解决。

考虑是否偷 nums0

  • 如果偷 nums0,那么 nums1 和 numsn−1 不能偷,问题变成从 nums2 到 numsn−2 的非环形版本。
  • 如果不偷 nums0,那么问题变成从 nums1 到 numsn−1 的非环形版本。
  • 分别取**start,end)=\[2,n−2) 和 \[start,end)=\[1,n−1) 进行计算,取两个 dp\[end 中的最大值,即可得到最终结果**

假设偷窃房屋的下标范围是 start,end,用 dpi 表示在下标范围 start,i 内可以偷窃到的最高总金额:

1、状态表示
dp i :表示前i间房屋能偷盗的最多钱数。
2、状态转移方程

dpi=max(dpi−2+numsi,dpi−1)

注意:这题我们用 tmp, f1, f0 来分别表示dpi,dpi - 1,dpi -2

cpp 复制代码
int robRange(vector<int>& nums, int start, int end) { // [start, end) 左闭右开
    int f1 = 0 , f0 = 0;
    for (int i = start; i < end; i++) {
        int tmp = max(f1, f0 + nums[i]);
        f0 = f1;
        f1 = tmp;
    }
    return f1;
}

int rob(vector<int>& nums) {
    int n = nums.size();
    return max(nums[0] + robRange(nums, 2, n - 1), robRange(nums, 1, n));
}

3. 打家劫舍 III

思路:

对于当前节点,就两个选择, 或者放弃然后我们用 f(o) 表示抢 o 节点的情况下,o 节点的子树上被选择的节点的最大权值和;g(o) 表示不抢 o 节点的情况下,o 节点的子树上被选择的节点的最大权值和;l 和 r 代表 o 的左右孩子。

🧩1、当 o 被选中时,o 的左右孩子都不能被选中 ,故 o 被选中情况下子树上被选中点的最大权值和为 l 和 r 不被选中的最大权值和相加,即 f(o)=g(l)+g(r)。

🧩2、当 o 不被选中时,o 的左右孩子可以被选中 ,也可以不被选中。对于 o 的某个具体的孩子 x,它对 o 的贡献是 x 被选中和不被选中情况下权值和的较大值。故 g(o)=max{f(l),g(l)}+max{f(r),g(r)}。

🧩至此,我们可以用哈希表来存 f 和 g 的函数值,用深度优先搜索的办法后序遍历这棵二叉树,我们就可以得到每一个节点的 f 和 g。根节点的 f 和 g 的最大值就是我们要找的答案。

cpp 复制代码
unordered_map<TreeNode*, int>f, g;
void dfs(TreeNode* node)
{
	if (node == nullptr) return;
	dfs(node->left), dfs(node->right);
	f[node] = node->val + g[node->left] + g[node->right]; //如果抢该节点
	g[node] = max(f[node->left], g[node->left]) + max(f[node->right], g[node->right]);
}


int rob(TreeNode* root) {
	dfs(root);
	return max(f[root], g[root]);
}

4. 打家劫舍 IV

思路:

通过观察可以发现,当偷盗能力越大时,小偷最多能偷的房屋数就越多;如果偷盗能力越小时,小偷最多能偷的房屋数就越少。

由题意我们可知,这是求最小的最大值,因此我们可以想到用二分的方法来找到合适的 capacity。对于每个通过二分列举出来的 capacity,因为碍于偷盗能力,小偷只能偷价值不超过 capacity 的房子,而且不能连续偷。因此,我们可以通过动态规划的方法算出:当小偷的偷盗能力为 capacity 时,小偷可以最多可以偷多少间房(设为 y),如果 y >= k,那么就代表 capacity 偏大,要把 capacity 调小一点;如果 y < k,那么就代表 capacity 偏小,把 capacity 调大一点。且由于要求的是最小的最大值,因此属于二分里面的区间左端点问题。

处。

通过二分枚举 capacity,对每个 capacity 进行动态规划,求出在该 capacity 的情况下最多偷到的房屋数,然后再根据这个房屋数调整 capacity 的查找区间。

cpp 复制代码
int minCapability(vector<int>& nums, int k) {
    vector<int> capacity = nums;
    sort(capacity.begin(), capacity.end());

    int l = 0, r = capacity.size() - 1;
    while (l < r)
    {
        int m = (r - l >> 1) + l; //注意:+, -的优先级高于>>
        if (check(nums, capacity[m]) >= k)r = m;
        else l = m + 1;
    }
    return capacity[l];
}

int check(vector<int>& nums, int capacity)
{
    int n = nums.size();
    vector<vector<int>> dp(n + 1, vector<int>(2));

    for (int i = 1; i <= n; i++)
    {
        if (nums[i - 1] <= capacity) dp[i][1] = dp[i - 1][0] + 1;
        dp[i][0] = max(dp[i - 1][1], dp[i - 1][0]);
    }
    return max(dp[n][1], dp[n][0]);
}
相关推荐
隔窗听雨眠5 小时前
C语言函数递归从入门到精通(下):性能优化与工程实践
c语言·算法·性能优化
退休倒计时5 小时前
【每日一题】LeetCode 146. LRU 缓存 TypeScript
算法·leetcode·缓存·typescript
珊瑚里的鱼5 小时前
【递归】汉诺塔
算法·深度优先
c++之路5 小时前
备忘录模式(Memento Pattern)
c++·microsoft
天恩软件5 小时前
一分钟学会 C++ 标准模板库智能指针
c++·智能指针
MrZhao4005 小时前
一个最小 Agent 是怎么跑起来的:Agent Loop 与工具使用全链路
算法
j7~6 小时前
【C++】STL--Vector容器--拆析解剖Vector的实现以及Vector的底层详解(1)
开发语言·c++·vector·迭代器失效·迭代器的使用
Keven_116 小时前
算法札记:二分
算法·二分
森G6 小时前
76、仿ASIO实现的Linux c++服务器------服务器源码解析----云视频服务项目
c++·qt
TCW11216 小时前
AI底层系列:用C++实现线性代数的公式推导与算法设计-6.线性方程组的解集
c++·人工智能·算法