状态机模型 dp

\(\texttt{0x00}\) 状态机介绍

百度百科

简单来说,状态机就是一个数学模型,不是一个实际的机器。它含有几个状态,还有几个函数(或者通俗一点叫“桥梁”),使得这几种状态可以在一定条件下实现相互转化。

是不是和动态规划的状态转移过程很像?

所以有一类型的动态规划题目,它的状态可以在一定的条件下相互转化,这时从状态机的角度来分析会比较好想。

\(\texttt{0x01}\) 例题

一. \(01\) 两种状态

AcWing 1049. 大盗阿福

题目描述

街道上有 \(N\) 家店铺,按照顺序从 \(1\)\(N\) 排好,
\(i\) 家店铺的财产是 \(w_i\),大盗阿福如果偷了第 \(i\) 家店铺,则他不能偷相邻的店铺,否则会被抓起来。
帮助大盗阿福找出一种偷盗方案,使得获得的总财产最大。

思路

从一般的 dp 思路来思考:

\(f(i)\) 表示偷了前 \(i\) 家店铺能获得的最大财产,对于第 \(i\) 家店铺,可以选择偷或不偷,若偷,则第 \(i - 1\) 家店铺就不能偷则应该从 \(f(i - 2)\) 转移过来;不偷,则由 \(f(i - 1)\) 转移过来。

综上,状态转移方程为:

\[f(i) = \max\{f(i - 1), f(i - 2) + w_i\} \]

从状态机的角度思考:

根据题意,可以总结出两种状态:偷了上一家店铺,没偷上一家店铺。

状态 \(1\) 可以由状态 \(2\) 转移过来,但不能由它自己转移过来(因为不能偷两家相邻的店铺)。
状态 \(2\) 既可以由状态 \(1\) 转移过来,也可以由它自己转移过来。

画个图就是这样的:

\(f(i, 0)\) 表示偷前 \(i\) 家店铺且不偷第 \(i\) 家店铺能获得的最大财产,\(f(i, 1)\) 表示偷前 \(i\) 家店铺且偷第 \(i\) 家店铺能获得的最大财产,则状态转移方程为:

\[f(i, 0) = \max\{f(i - 1, 0), f(i - 1, 1)\} \]

\[f(i, 1) = f(i - 1, 0) + w_i \]

发现每层状态只会由他的上一层状态得到,所以可以加滚动数组优化。

\(\texttt{Code:}\)


#include <iostream>
using namespace std;

int T;
int n, a;
int f[2][2];

int main() {
    scanf("%d", &T);
    while(T--) {
        scanf("%d", &n);
        f[0][0] = f[0][1] = 0;
        for(int i = 1; i <= n; i++) {
            scanf("%d", &a);
            f[i & 1][0] = max(f[i - 1 & 1][0], f[i - 1 & 1][1]);
            f[i & 1][1] = f[i - 1 & 1][0] + a;
        }
        printf("%d\n", max(f[n & 1][0], f[n & 1][1]));
    }
    return 0;
}

AcWing 1057. 股票买卖 IV

题目大意:

给定一个长度为 \(N\) 的数组,数组中的第 \(i\) 个数字表示一个给定股票在第 \(i\) 天的价格。
你最多可以完成 \(k\) 笔交易,求能获取的最大利润。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。一次买入卖出合为一笔交易

思路:

这道题用一般的方法分析就不太容易了,直接用状态机的思路分析。

不难总结出这道题有两种状态:持股 \((1)\) 和不持股 \((0)\)

操作可分为四种:

  1. 买入行为:\(k=0 \rightarrow k=1\)
  2. 卖出行为:\(k=1 \rightarrow k=0\)
  3. 持仓行为:\(k=1 \rightarrow k=1\)
  4. 空仓行为:\(k=0 \rightarrow k=0\)

画图就是这样的:

\(f(i, j, k)\) 表示:考虑前 \(i\) 天的股票,第 \(i\) 天的决策是 \(k\),且完成的完整交易数为 \(j\) 的方案。

状态转移方程为:

\[f(i, j, 0) = \max\{f(i - 1, j, 0), f(i - 1, j, 1) + a_i\} \]

\[f(i, j, 1) = \max\{f(i - 1, j, 1), dp(i - 1, j - 1, 0) - a_i\} \]

注意:因为一次买入卖出合为一笔交易,这里将买入算作开始一次交易,所以要消耗性次数,而卖出是一次交易的结束,不消耗次数。

同样可以加滚动数组优化。

\(\texttt{Code:}\)

#include <cstring>
#include <iostream>
using namespace std;
const int N = 100010, M = 110;

int n, k;
int a[N];
int dp[2][M][2];
int ans;

int main() {
    scanf("%d%d", &n, &k);
    for(int i = 1; i <= n; i++) scanf("%d", &a[i]);
    memset(dp, -0x3f, sizeof dp);
    dp[0][0][0] = dp[1][0][0] = 0;
    for(int i = 1; i <= n; i++) {
        for(int j = 1; j <= min(i, k); j++) {
            dp[i & 1][j][0] = max(dp[i - 1 & 1][j][0], dp[i - 1 & 1][j][1] + a[i]);
            dp[i & 1][j][1] = max(dp[i - 1 & 1][j][1], dp[i - 1 & 1][j - 1][0] - a[i]);
            ans = max(ans, max(dp[i & 1][j][0], dp[i & 1][j][1]));
        }
    }
    printf("%d", ans);
    return 0;
}

P1352 没有上司的舞会

题目大意

给定一棵有 \(n\) 个节点的树,每个节点有一个权值 \(w_i\),现在要从这棵树上选出一些节点。若选择了一个节点,那么它的子节点就不能被选,问能获得的最大权值和。

思路

\(f(i)\) 为以 \(i\) 为根的子树能获得的最大权值和,那么很明显,我们应该先算儿子子树的 \(f\) 值,然后才能算 \(f(i)\),更新顺序为从下往上

但是如果我们这样设计状态会发现无法转移——你不知道当前儿子节点的 \(f\) 是选儿子得到的还是没选得到的,所以你也就无法确定节点 \(i\) 选还是不选。

根据状态机的思路,本题有两个状态:选当前节点 \((1)\) 和不选当前节点 \((0)\)

选当前节点的前提是不能选儿子,\(0\rightarrow 1\)
不选当前节点那么两种都能转移,\(1\rightarrow 0,0\rightarrow 0\)

\(f(i, j)\) 表示考虑以 \(i\) 为根的子树且 \(i\) 的策略为 \(j\) 能获得的最大权值和,则状态转移方程为:

\[f(i, 0) = \max\{f(j, 0), f(j, 1)\} \]

\[f(i, 1) = \sum f(j, 0) + w_i \]

其中 \(j\)\(i\) 的子节点。

然后就可以快乐地树上递归转移了。

\(\texttt{Code:}\)

#include <cstring>
#include <iostream>

using namespace std;

const int N = 6010;
typedef long long ll;
int n, ori;
int h[N], e[N], ne[N], idx;
int w[N], f[N][2]; //0 不选,1 选
bool have_father[N];
int root;

void add(int a, int b) {
    e[idx] = b, ne[idx] = h[a], h[a] = idx++;
}

void dp(int u) {
    f[u][1] = w[u];
    for(int i = h[u]; ~i; i = ne[i]) {
        int j = e[i];
        dp(j);
        f[u][0] += max(f[j][0], f[j][1]);
        f[u][1] += f[j][0];
    }
}

int main() {
    memset(h, -1, sizeof h);
    scanf("%d", &n);
    for(int i = 1; i <= n; i++) scanf("%d", &w[i]);
    int a, b;
    for(int i = 1; i < n; i++) {
        scanf("%d%d", &a, &b);
        add(b, a);
        have_father[a] = true;
    }

    for(int i = 1; i <= n; i++)
        if(!have_father[i]) {
            root = i;
            break;
        }

    dp(root);
    printf("%d", max(f[root][0], f[root][1]));
    return 0;
}

二. 多种状态

AcWing 1058. 股票买卖 V

题目大意:

给定一个长度为 \(N\) 的数组,数组中的第 \(i\) 个数字表示一个给定股票在第 \(i\) 天的价格。

注意:

  1. 你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。一次买入卖出合为一笔交易
  2. 卖出股票后,无法在第二天买入股票(冷冻期为 \(1\) 天)。

求能获得的最大利润。

思路:

这道题的状态不再是简单的 \(01\) 状态。

但还是一样,可以归纳为 \(3\) 种状态:持股 \((0)\)、冷冻期 \((1)\)、未持股 \((2)\)

操作可分为五种:

  1. 买入行为:\(2 \rightarrow 0\)
  2. 卖出行为:\(0 \rightarrow 1\)
  3. 持仓行为:\(0 \rightarrow 0\)
  4. 空仓行为:\(2 \rightarrow 2\)
  5. 冷冻期结束:\(1 \rightarrow 2\)

画图来看就是这样:

\(f(i, j)\) 表示考虑前 \(i\) 天的股票且在第 \(i\) 天时是状态 \(j\) 的最大利润。

则状态转移方程为:

\[f(i, 0) = \max\{f(i - 1, 0), f(i - 1, 2) - a_i\} \]

\[f(i, 1) = f(i - 1, 0) + a_i \]

\[f(i, 2) = \max\{f(i - 1, 1), f(i - 1, 2)\} \]

\(\texttt{Code:}\)

#include <cstring>
#include <iostream>

using namespace std;


const int N = 100010;

int n;
int a[N];
int dp[N][3];

int main() {
    scanf("%d", &n);
    for(int i = 1; i <= n; i++) scanf("%d", &a[i]);
    memset(dp, -0x3f, sizeof dp);
    for(int i = 0; i <= n; i++) dp[i][2] = 0;
    for(int i = 1; i <= n; i++) {
        dp[i][0] = max(dp[i - 1][0], dp[i - 1][2] - a[i]);
        dp[i][1] = dp[i - 1][0] + a[i];
        dp[i][2] = max(dp[i - 1][2], dp[i - 1][1]);
    }
    printf("%d", max(dp[n][1], dp[n][2]));
    
    return 0;
}

AcWing 1052. 设计密码

题目大意

你现在需要设计一个密码 \(S\),满足:

  1. \(S\) 的长度是 \(N\)
  2. \(S\) 只包含小写英文字母;
  3. \(S\) 不包含子串 \(T\)

请问共有多少种不同的密码满足要求?

由于答案会非常大,请输出答案模 \(10^9+7\) 的余数。

思路

考虑从前向后构造密码,所以可以将目前构造到第几位作为阶段来 dp。

模板串不能作为字串出现在密码中,考虑 KMP 的过程,可以将目前匹配到了模板串的第几位作为第二维。

\(f(i, j)\) 表示目前构造到 \(i\) 位且第 \(i\) 位匹配到模板串的第 \(j\) 位的密码数。

由题可知,不能匹配到第 \(|T|\) 位,所以答案应该为 \(\sum\limits_{0 \le j < |T|}\{f(n, j)\}\)

接下来考虑状态转移。

首先需要了解一下自动机的概念

这道题考虑一个状态能更新那些状态要好想很多。

假设我们现在已经构造好了前 \(i\) 位,要去构造第 \(i + 1\) 位了。因为第 \(i + 1\) 位可以放 \(26\) 种字母,而每种字母都会有不同的匹配的结果,所以共有 \(26\) 种状态。

然后枚举第 \(i + 1\) 位的字母,对每个字母找到能匹配的最大后缀长度 \(\delta(\pi (i), ch_{i + 1})\),即构造第 \(i + 1\) 位后该密码能匹配到的模板串中的位置,也就找到了状态 \(j\) 应该更新的状态。

综上所述,状态转移方程为:

\[f(i + 1, \delta(\pi (j), ch)) \leftarrow f(i, j) \]

\(\texttt{Code:}\)

#include <cstring>
#include <iostream>

using namespace std;

const int N = 55, mod = 1e9 + 7;

int n, len;
char s[N];
int dp[N][N];
int ne[N];

int main() {
    scanf("%d%s", &n, s + 1);
    len = strlen(s + 1);
	//预处理 ne 数组
    for(int i = 2, j = 0; i <= len; i++) {
        while(j && s[j + 1] != s[i]) j = ne[j];
        if(s[j + 1] == s[i]) j++;
        ne[i] = j;
    }
    dp[0][0] = 1;
    for(int i = 0; i < n; i++)
        for(int j = 0; j < len; j++)
            for(char k = 'a'; k <= 'z'; k++) {
		//计算应该更新的状态
                int tmp = j;
                while(tmp && s[tmp + 1] != k) tmp = ne[tmp];
                if(s[tmp + 1] == k) tmp++;
                if(tmp < len) dp[i + 1][tmp] = (dp[i + 1][tmp] + dp[i][j]) % mod;
            }
    int res = 0;
    for(int i = 0; i < len; i++) res = (res + dp[n][i]) % mod;
    printf("%d", res);
    return 0;
}
posted @ 2024-07-27 11:55  Brilliant11001  阅读(4)  评论(0编辑  收藏  举报