关于动态规划

首先,什么是动态规划?

基本定义

动态规划(Dynamic Programming,DP) 是运筹学的一个分支,是求解决策过程最优化的过程。20世纪50年代初,美国数学家贝尔曼 (R.Bellman)等人在研究多阶段决策过程的优化问题时,提出了著名的最优化原理,从而创立了动态规划。动态规划的应用极其广泛,包括工程技术、经济、工业生产、军事以及自动化控制等领域,并在背包问题、生产经营问题、资金管理问题、资源分配问题、最短路径问题和复杂系统可靠性问题等中取得了显著的效果。

算法本身

是利用一些简单,好算的子问题去更新难一点的大问题,最后得到整体最优解。

其中,动态规划有三个步骤:
step 1 :定义状态,及我们常用的 d p dp dp 数组表示什么意义。
step 2 :初始化,就是把题目给出的,或者显而易见的子问题答案初始化。
step 3 :状态转移,我们需要找到一个状态转移方程去转移状态。或者也可以理解为如何利用已知子问题与下一个阶段的问题之间的联系去更新下一个阶段的问题的答案。


有点懵?那我们来看看一道例题

T1 斐波那契数列

题目描述

斐波那契数列 1 , 1 , 2 , 3 , 5 , 8 , 13 , 21 , 34 , 55 , . . . 1,1,2,3,5,8,13,21,34,55,... 1,1,2,3,5,8,13,21,34,55...,从第三项起,每一项都是紧挨着的前两项的和。现在,请求出斐波那契的任意一项。

分析

我们按照动态规划的三步一步一步来。

首先,我们定义一个 d p [ i ] dp[i] dp[i] 表示斐波那契数列的第 i i i 项(一般定义状态就是题目要求什么我们就定义什么。

然后来思考如何初始化。你会发现题目已经给出了斐波那契数列的前几项,所以我们不妨按以下方式初始化。

dp[1] = 1; // 表示斐波那契数列的第一项为1
dp[2] = 1; 

这时候再看,题目告诉我们“从第三项起,每一项都是紧挨着的前两项的和”。所以显然,这道题中的 d p dp dp 状态转移方程就是

dp[i] = dp[i - 1] + dp[i - 2]; // i >= 3

确定了这些,我们就可以写代码了嘛

不过 d p dp dp 的实现不止一种,我在这里介绍这道题的三种写法

AC代码1

常见的形式,本质是“别人更新自己”

#include 

const int MAXN = 10005;
int dp[MAXN];

int main() {
	int n;
	scanf ("%d", &n);
	dp[1] = 1;
	dp[2] = 1;
	for(int i = 3; i <= n; i++)
		dp[i] = dp[i - 1] + dp[i - 2];
	printf("%d\n", dp[n]);
	return 0;
} 
AC代码2

一般不用,本质是“自己更新别人”

#include 

const int MAXN = 10005;
int dp[MAXN];

int main() {
	int n;
	scanf ("%d", &n);
	dp[1] = 1;
	dp[2] = 1;
	// 因为我们的转移方程是 dp[i] = dp[i - 1] + dp[i - 2]
	// 所以也可以看成 dp[i + 2] = dp[i + 1] + dp[i]
	// 所以每次遇到 dp[i] 就让它的后两个更新即可
	// 注意在 i = 1 的时候特判一下,不然就会重复更新 dp[2]
	for(int i = 1; i <= n; i++) {
		if(i != 1) dp[i + 1] += dp[i];
		dp[i + 2] += dp[i];
	}
	printf("%d\n", dp[n]);
	return 0;
} 
AC代码3

人气王,记忆化搜索,虽然好用,但是常数大

#include 

const int MAXN = 10005;
int dp[MAXN];

int dfs(int i) { // 搜索
	if(i <= 2) return 1;
	if(dp[i]) return dp[i];
	// 如果 dp[i] 现在已经有值了就可以直接返回,减少递归次数
	return dp[i] = dfs(i - 1) + dfs(i - 2); // 否则更新
}

int main() {
	int n;
	scanf ("%d", &n);
	printf("%d", dfs(n));
	return 0;
} 

经过一道简单题,有没有那么一点点懂什么是 d p dp dp 了?

接下来我们来看一些 d p dp dp 经典模型。


LIS

命题描述

给定一个整数序列 A 1 A 2 A 3 … . A n A1A2A3….An A1A2A3.An。求它的一个递增子序列(序列不要求元素连续),使子序列的元素个数尽量多。并输出其中一组递增子序列。

分析

这很简单嘛,按照 d p dp dp 三部曲。

我们先定义出 d p [ i ] dp[i] dp[i] 的含义,不妨假设 d p [ i ] dp[i] dp[i] 表示以 i i i 结尾的最长递增子序列的长度。那么一定有 d p [ 1 ] = 1 dp[1] = 1 dp[1]=1,这很显然吧。

接下来的问题就是如何转移了,根据我们 d p dp dp 数组的定义,我们会发现 d p [ i ] dp[i] dp[i] 应该是前面的某一个最长递增子序列加上 a [ i ] a[i] a[i] 得到的。而如果可以加上 a [ i ] a[i] a[i] 就代表上一个一定小于 a [ i ] a[i] a[i]。那所以我们就可以直接在 1 1 1 i − 1 i - 1 i1 中找 a [ j ] < a [ i ] a[j] < a[i] a[j]<a[i] 并更新 d p [ i ] dp[i] dp[i]

那么,如何输出这组递增子序列呢?我们可以使用输出路径的技巧,利用 p r e pre pre 数组保存前驱,然后递归输出即可。

具体实现
#include  

const int MAXN = 5005;
int a[MAXN], dp[MAXN], prev[MAXN];
// dp[i]表示以i元素结尾的最长上升子序列
// prev[i]表示i号元素在最长上升子序列中的上一个元素的下标

void print(int i) { // 递归输出
	if(prev[i] == i) {
		printf("%d ", a[i]);
		return ;
	}
	print(prev[i]);
	printf("%d ", a[i]);
}

int main() {
	int n;
	scanf ("%d", &n);
	int ans = 0, v = 0;
	for(int i = 1; i <= n; i++) {
		scanf ("%d", &a[i]);
		dp[i] = 1; // 以i结尾的最长子序列最短就是只有i一个元素,所以初始化长度为1
		prev[i] = i; // 前驱初始化为自己
		for(int j = 1; j < i; j++) {
			if(a[i] > a[j] && dp[j] + 1 > dp[i]) { // 在前面比当前元素小的元素处更新 dp[i]
				dp[i] = dp[j] + 1; // 长度加一
				prev[i] = j; // 保存前驱,即当前元素在这组最长上升子序列中的前面那一个元素
			}
 		}	
		if(dp[i] > ans) {
			ans = dp[i];
			// 看以哪一个点结尾的最长子序列的长度最长
			v = i; // 保存这个点的下标
		}	
	}
	printf("%d\n", ans);
	print(v);	
	return 0;
} 

LCS

我谔谔,这就玄学不会打了?于是笔者回去翻了翻以前的代码……

命题描述

给定两个字符串,求出这两个字符串共同拥有的最长子序列。

分析

依然严格按照 d p dp dp 的三部曲慢慢来

首先我们定义 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示在 a a a 字符串中在 i i i 号元素前且在 b b b 字符串中在 j j j 号元素前的最长公共子序列。显然边界为 d p [ 0 ] [ 0 ] = 0 dp[0][0] = 0 dp[0][0]=0

那么如何转移呢?显然,根据定义,如果当前枚举到的 a [ i ] a[i] a[i] b [ j ] b[j] b[j] 相等,那么这两个元素一定可以和之前的最长公共子序列构成一个新的最长公共子序列。如果 a [ i ] a[i] a[i] b [ j ] b[j] b[j] 不相等,那么就用这两个元素之前的最长公共子序列进行更新即可。(如果觉得玄学就一会儿看代码吧)

那么如何输出序列呢?也很简单,我们用一个 f l a g flag flag 记录当前更新是怎么更新的,然后在递归输出里特判一下就可以啦。

具体实现
#include  
#include  
#include  
using namespace std;

const int MAXN = 1005;
char a[MAXN], b[MAXN];
int dp[MAXN][MAXN], flag[MAXN][MAXN];
// dp[i][j]表示在a[i]之前,b[j]之前的最长公共子序列,注意不是以a[i],b[j]结尾的最长公共子序列
// flag保存更新方式

void print(int i, int j) {
	if(i == 0 || j == 0) return ; // 边界
	if(flag[i][j] == 0) { // 如果是相等转移而来?递归两个字符串
        print(i - 1, j - 1);
        printf("%c", a[i - 1]); // 输出
    }
    else if(flag[i][j] == 1) print(i - 1, j); // 否则递归访问单个
    else print(i, j - 1);
}

int main() {
	scanf ("%s", a);
	scanf ("%s", b);
	int lena = strlen(a);
	int lenb = strlen(b);
	for(int i = 1; i <= lena; i++) 
		for(int j = 1; j <= lenb; j++) { // 遍历两个字符串
			if(a[i - 1] == b[j - 1]) { // 如果枚举到当前元素相等
				dp[i][j] = dp[i - 1][j - 1] + 1; // 那么直接就是之前的最大公共子序列的长度+1
				flag[i][j] = 0; // 保存更新方式
			}
			else if(dp[i - 1][j] >= dp[i][j - 1]) { 
			// 如果当前元素不相等,且a数组目前的更长,那么就用a这边的更新
				dp[i][j] = dp[i - 1][j];
				flag[i][j] = 1;
			}
			else {
			// 反之亦然
				dp[i][j] = dp[i][j - 1];
				flag[i][j] = -1;
			}
		}
	printf("%d\n", dp[lena][lenb]);
	print(lena, lenb);	
	return 0;
}

背包

在考虑了一下后……最终选择把套娃背包提前了

1.0/1背包

有一个最多能装 m m m 千克的背包,有 n n n 件物品,它们的重量分别是 W 1 , W 2 , . . . , W n W_1,W_2,...,W_n W1W2...Wn,它们的价值分别是 C 1 , C 2 , . . . , C n C_1,C_2,...,C_n C1C2...Cn。若每种物品只有一件,问能装入的最大总价值。

分析
显然,贪心很容易举出反例,毕竟你无法满足重量价值同时最优(啥,你说性价比最优?我谔谔

所以我们祭出动态规划。

定义一个 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示前 i i i 件物品放入容量为 j j j 的背包中能获得的最大价值。好了为什么这种背包叫0/1背包呢?因为它对于每件物品其实就是选与不选两种状态。因而我们可以很快得到转移方程:

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + c[i]); 
// 前者表示当前物品不取,其最大价值就是同样容量的背包下装入前i-1个物品的最大价值
// 后者表示当前物品要取。
// 其最大价值就是当前物品的价值加上去掉当前物品的重量的背包装前i-1件物品的最大价值

然后你会惊讶的发现,出现的 d p dp dp 数组的第一维均为 i − 1 i - 1 i1,也就是说枚举到这件物品的时候, d p dp dp 一定是从上一件那里转移过来的,所以可以直接用当前物品覆盖上一件物品算出的答案,即删去第一维。没搞懂的可以用草稿纸画图推一下。。

具体实现

#include 
#include 
using namespace std;

const int MAXN = 1005;
int dp[MAXN]; // 删去一维后的dp数组
struct node { // 定义一个结构体表示物品的信息
	int w, c;
} a[MAXN];

int main() {
	int n, v;
	scanf ("%d %d", &n, &v);
	for(int i = 1; i <= n; i++) scanf ("%d %d", &a[i].w, &a[i].c);
	for(int i = 1; i <= n; i++) { // 枚举每件物品
		for(int j = v; j >= a[i].w; j--) 
		// 枚举背包容量,并让背包容量严格大于当前物品的重量
		// 按背包容量从大到小进行更新,不然更新顺序会错(不过二维好像就不存在这个问题了
		// 可以自己推一下
			dp[j] = max(dp[j], dp[j - a[i].w] + a[i].c);	
	}
	printf("%d", dp[v]);
	return 0;
}

2.完全背包

有一个最多能装 m m m 千克的背包,有 n n n 种物品,它们的重量分别是 W 1 , W 2 , . . . , W n W_1,W_2,...,W_n W1W2...Wn,它们的价值分别是 C 1 , C 2 , . . . , C n C_1,C_2,...,C_n C1C2...Cn。若每种物品有无限件,问能装入的最大总价值。

分析
如果把它看成 0/1 的话。

定义 d p [ i ] [ j ] dp[i][j] dp[i][j] 表示表示前 i i i 种物品放入容量为 j j j 的背包中能获得的最大价值。那么,显然可以通过 0/1 来推出这样一个式子

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - k * w[i]] + k * c[i]); 
// k表示第i种物品放k个进背包

不过这就变成了一个及其低效的算法了。于是我们考虑优化。

显然,一种物品其实最多取 v / w [ i ] v / w[i] v/w[i] 件,所以我们可以直接将每种物品拆成 v / w [ i ] v / w[i] v/w[i] 件,转化为 0/1背包求解,但这不是最快的方法。

最快最玄学的完全背包的代码是这样的:

for(int i = 1; i <= n; i++) { // 枚举每件物品
		for(int j = a[i].w; j <= v; j++) 
			dp[j] = max(dp[j], dp[j - a[i].w] + a[i].c);	
	}

哈?好像就是改了一下第二层循环的顺序?嗯。事实证明这是对的。首先,我们发现0/1背包如此使用循环顺序的原因是要满足即将更新的背包大小是没有加入任何一件第 i i i 件物品。而完全背包中,每种物品可以选很多件,就相当于我们需要在每次更新的时候即将更新的背包大小可能已经装进了几件第 i i i 种物品!所以把循环顺序反过来就好了。

第一维可以删掉的原因同上。

具体实现

#include 
#include 
using namespace std;
const int MAXN = 10005;
int dp[MAXN];

struct data {
	int c, w;
} a[MAXN];

int main() {
	int m, n;
	scanf ("%d %d", &m, &n);
	for(int i = 1; i <= n; i++) 
		scanf ("%d %d", &a[i].w, &a[i].c);
	for(int i = 1; i <= n; i++) {
		for(int j = a[i].w; j <= m; j++) 
			dp[j] = max(dp[j], dp[j - a[i].w] + a[i].c);
	}
	printf("%d\n", dp[m]);
    return 0;
}

3.多重背包

有一个最多能装 m m m 千克的背包,有 n n n 种物品,它们的重量分别是 W 1 , W 2 , . . . , W n W_1,W_2,...,W_n W1W2...Wn,它们的价值分别是 C 1 , C 2 , . . . , C n C_1,C_2,...,C_n C1C2...Cn 。若第 i i i 种物品有 T i T_i Ti 件,问能装入的最大总价值。

分析

这种背包的解决方式其实在完全背包中已经提到了。我们把每种物品的每一件拆开来看,利用 0/1 背包求解。显然每一件都拆不是最优的方法。

于是,我们引入二进制拆分。首先,每个数都能被拆成 2 2 2 的乘法形式的和对吧(有能力有关系的同学可以尝试证明一下可以尝试去问问学数竞的同学

那么,这和这道题有什么关系呢?很简单,我们可以把每种物品拆成这样的几件:重量为 W i W_i Wi, 价值为 C i C_i Ci;重量为 W i × 2 W_i \times 2 Wi×2, 价值为 C i × 2 C_i \times 2 Ci×2;重量为 W i × 4 W_i \times 4 Wi×4, 价值为 C i × 4 C_i \times 4 Ci×4……以此类推,相当于就是把每一种的件数二进制拆分而组合起来。

然后0/1即可。

具体实现

#include 
#include 
using namespace std;

const int MAXN = 100005;
struct node {
	int w, v; // 这里的v就是上文的c,即价值
} a[MAXN]; 
int dp[MAXN];

int main() {
	int m, n, cnt = 0;
	scanf ("%d %d", &m, &n);
	for(int i = 1; i <= n; i++) {
		int x, y, z;
		scanf ("%d %d %d", &x, &y, &z);
		for(int j = 1; j <= z; j <<= 1) { // 二进制拆分
			a[++cnt].w = x * j;
			a[cnt].v = y * j;
			z -= j;
		}
		if(z) { // 最后遗留下来的全部合起来作为下一组
			a[++cnt].w = x * z;
			a[cnt].v = y * z;			
		}
	}
	for(int i = 1; i <= cnt; i++)
		for(int j = m; j >= a[i].w; j--)
			dp[j] = max(dp[j], dp[j - a[i].w] + a[i].v); // 01背包
	printf("%d", dp[m]);
	return 0;
}

好了基础的背包就到这里了,剩下的其实就是这三种背包的反复运用。可以自己去分析一下(在这里就直接口胡一下命题……

比较出名的有:

4.混合背包

顾名思义,将几种背包混合起来。

#include 
#include 
#include 
using namespace std;

const int MAXN = 10005;
int w[MAXN], v[MAXN], m[MAXN];
int dp[MAXN * 100];

int main() {
	int v_, n;	
    scanf("%d %d", &n, &v_);
    for(int i = 1; i <= n; i++)
        scanf("%d %d %d", &w[i], &v[i], &m[i]);
    for(int i = 1; i <= n; i++) {
        if(m[i] == 0) {
            for(int j = w[i]; j <= v_; j++)
                dp[j] = max(dp[j], dp[j - w[i]] + v[i]);        	
		}
        else {
            int x = m[i];
            if(m[i] == -1) x = 1;
            for(int k = 1; k <= x; k <<= 1) {
                for(int j = v_; j >= w[i] * k; j--)
                    dp[j] = max(dp[j], dp[j - w[i] * k] + v[i] * k);
                x -= k;
            }
            if(x) {
                for(int j = v_; j >= w[i] * x; j--)
                    dp[j] = max(dp[j], dp[j - w[i] * x] + v[i] * x);            	
			}
        }
    }
    printf("%d", dp[v_]);
    return 0;
}
5.分组背包

每个物品有它的组,且每一组只能选一件物品。

#include 
#include 
using namespace std;

const int MAXN = 105;
int dp[MAXN], v[MAXN], w[MAXN];
int n, m;

int main() {
    scanf ("%d %d", &n, &m);
    for(int i = 1; i <= n; i++) {
    	int t;
        scanf ("%d", &t);
        for(int j = 1; j <= t; j++)
            scanf ("%d %d", &v[j], &w[j]);
        for(int j = m; j >= 0; j--)
            for(int k = 1; k <= t; k++)
                if(j >= v[k])
                    dp[j] = max(dp[j], dp[j - v[k]] + w[k]);           
    }
    printf("%d", dp[m]);
    return 0;
}
6.二位费用背包

一件物品拥有两种价值。

#include  
#include 
#include 
using namespace std;

const int MAXN = 105;
const int MAXM = 1205;
int dp[MAXM][MAXM];
struct node {
    int a, b, c;
} s[MAXN];
 
int main() {
    int u, v, n;
    scanf ("%d %d %d", &u, &v, &n);
    for(int i = 1; i <= n; i++)
        scanf ("%d %d %d", &s[i].a, &s[i].b, &s[i].c);
    memset(dp, 0x3F, sizeof dp);
    
    dp[0][0] = 0;
    for(int i = 1; i <= n; i++)
        for(int j = u + 100; j >= s[i].a; j--)
            for(int k = v + 100; k >= s[i].b; k--)
                dp[j][k] = min(dp[j][k], dp[j - s[i].a][k - s[i].b] + s[i].c);
                
    for(int i = u; i <= u + 100; i++)
        for(int j = v; j <= v + 100; j++)
            if(dp[i][j] != 0x3F3F3F3F) {
                printf("%d\n", dp[i][j]);
                return 0;
            }
    printf("-1");
    return 0;
}

区间dp

顾名思义,每次转移一个区间的答案到另一个区间。

玄学?那我们来看一道经典题目

T2 石子合并

题目描述

n n n 堆石子摆成一条线。现要将石子有次序地合并成一堆。规定每次只能选相邻的 2 2 2 堆石子合并成新的一堆,并将新的一堆石子数记为该次合并的代价。计算将 n n n 堆石子合并成一堆的最小代价。

  • 输入格式:
    第1行: n n n
    第2行:有 n n n 个空格分开的整数,表示 n n n 堆石子的数量

  • 输出格式:
    输出最小合并代价

样例输入
4
1 2 3 4
样例输出
19
分析

首先我们来定义状态, d p [ i ] [ j ] dp[i][j] dp[i][j] 表示将 i i i j j j 堆石子合并成一堆最少需要多少的代价。且 d p [ i ] [ i ] = 0 dp[i][i] = 0 dp[i][i]=0

然后我们会发现,如果要将所有的石子合成一堆,那一定最后会先合成两堆,再合成一堆。仔细一想将两堆合成一堆的代价就是,分别合成这两堆的代价加上这两堆石子数总和。以此就得到了状态转移方程。

不过这一题的更新的循环有点不同,区间 d p dp dp 是先枚举要更新的区间的长度(即阶段,当然阶段最开始长度为1),再依靠阶段和我们枚举的第二层循环 i i i 去找到 j j j 的位置

具体实现
#include 
#include 
#include 
using namespace std;

const int MAXN = 1005;
int a[MAXN], dp[MAXN][MAXN];
// dp[i][j]表示将第i堆石子到第j堆石子全部合并需要的最小代价
int sum[MAXN];

int main() {
	int n;
	memset(dp, 0x3F3F3F3F, sizeof(dp));
	// 因为求最小嘛,所以需要初始化为最大值
	scanf ("%d", &n);
	for(int i = 1; i <= n; i++) {
		scanf ("%d", &a[i]);
		dp[i][i] = 0;
		sum[i] = sum[i - 1] + a[i];
		// 保存前缀和,因为我们的状态转移方程涉及到区间和
	}
	for(int len = 2; len <= n; len++) // 枚举阶段
		for(int i = 1; i + len - 1 <= n; i++)  {
			int j = i + len - 1; // 相当于i是区间左端点,我们需要算出右端点j
			for(int k = i; k < j; k++) 
				dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + sum[j] - sum[i - 1]);
				// 分成两部分求最小
		}
	printf("%d", dp[1][n]); // 输出答案即可
	return 0;
} 

树形dp

顾名思义就是在树上做 d p dp dp

一般是在叶结点进行初始化,答案存在根节点上,而根节点的答案通过它的子结点来更新。其实牵强一点说,线段树就是一种树形 d p dp dp (逃。

接下来我们来看到题目:

T3 没有上司的舞会

题目描述

某大学有 n n n个职员,编号为 1 … n 1\ldots n 1n。他们之间有从属关系,也就是说他们的关系就像一棵以校长为根的树,父结点就是子结点的直接上司。

现在有个周年庆宴会,宴会每邀请来一个职员都会增加一定的快乐指数 r i r_i ri ,但是呢,如果某个职员的直接上司来参加舞会了,那么这个职员就无论如何也不肯来参加舞会了。

所以,请你计算,邀请哪些职员可以使快乐指数最大,求最大的快乐指数。

  • 输入格式:
    输入的第一行是一个整数 n n n
    2 2 2 到第 ( n + 1 ) (n + 1) (n+1) 行,每行一个整数,第 ( i + 1 ) (i+1) (i+1) 行的整数表示 i i i 号职员的快乐指数 r i r_i ri
    ( n + 2 ) (n + 2) (n+2) 到第 2 × n 2 \times n 2×n 行,每行输入一对整数 l , k l, k l,k,代表 k k k l l l 的直接上司。

  • 输出格式
    输出一行一个整数代表最大的快乐指数。

样例输入
7
1
1
1
1
1
1
1
1 3
2 3
6 4
7 4
4 5
3 5
样例输出
5
分析

首先这样的元素与元素之间的关系很容易让人想到树形结构,例如并查集。于是我们也可以来建造这样一棵关系树,保存职员之间的关系。

然后定义 d p dp dp 数组,这还是比较好像到的, d p [ i ] [ j ] dp[i][j] dp[i][j] 表示第 i i i 名职员在 j j j 状态下能产生的最大快乐指数。其中 j = 1 j = 1 j=1表示这名职员来, j = 0 j = 0 j=0 表示这名职员不来。

然后该怎么做呢?我们可以从根开始,分别遍历它的每一个儿子,去递归找到答案。而如果当前结点没有儿子就说明到叶结点了,直接返回即可

具体实现

#include 
#include 
#include 
using namespace std;

const int MAXN = 6005;
int dp[MAXN][2], r[MAXN];
// r[i]表示第i名职员来能产生的快乐值
vector<int> s[MAXN]; 
// s[i]表示第i名职员的员工结点的集合
bool vis[MAXN]; // vis[i]表示第i名职员是否是另一位职员的员工

void Tree_Dp(int i) { // 树形dp
	dp[i][1] = r[i]; // 如果当前职员要来,初始化为这个职员本身能产生的快乐值
	dp[i][0] = 0; // 不来就为0
	for(int j = 0; j < s[i].size(); j++) {
		Tree_Dp(s[i][j]); // 递归找他的所有员工
		dp[i][1] += dp[s[i][j]][0]; 
		// dp[i][1]表示这个上司来了,那么他的员工肯定来不了
		// 所以j状态一定为0
		dp[i][0] += (max(dp[s[i][j]][0], dp[s[i][j]][1])); 
		// 如果不来取两种状态最大值即可
	}
	return ;
}

int main() {
	int n, ans = 0;
	scanf("%d", &n);
	for(int i = 1; i <= n; i++) 
		scanf ("%d", &r[i]);
	for(int i = 1; i <= n - 1; i++) {
		int u, v;
		scanf ("%d %d", &u, &v);
		s[v].push_back(u); // 建树
		vis[u] = true; // 职员u标记为是另一个职员的员工
	}
	int root; // 根节点一定只能是最上面的上司,即没有父亲结点
	for(int i = 1; i <= n; i++)
		if(vis[i] == false) { // 如果一个职员不是任何一个其他职员的员工,说明它就是根节点
			root = i;
			break;
		}
	Tree_Dp(root); 
	printf("%d", max(dp[root][1], dp[root][0])); // 取根节点来或不来两种状态的最大值
	return 0; 
}

排列dp

这是一个非常玄学的 d p dp dp,它主要研究的是关于数列排列的问题。给个例子叭。

命题描述

给定一个数列 1 1 1 n n n,请问有多少种排列使得排列后的数列所含逆序对为偶数。

分析

首先,这道题的答案应该是 n ! / 2 n! / 2 n!/2(我也不知道为什么)。不过我们在这里不讲这个方法的证明,而是引入排列 d p dp dp

我们定义 d p [ i ] [ j ] dp[i][j] dp[i][j] 为排好前 i i i 个数能产生 j j j 个逆序对的总方案数。显然, d p [ 1 ] [ 0 ] = 1 dp[1][0] = 1 dp[1][0]=1

那么,该如何转移呢?其实我们可以把 i + 1 i + 1 i+1 看作是插入前 i i i 个数排好的序列。那么我们考虑插入到某个位置会带来多少逆序对即可。

会发现 i + 1 i + 1 i+1 一定大于前面的每一个数,所以当 i + 1 i + 1 i+1 插入到第 k k k 个位置时,将会新产生 i − k i - k ik 个逆序对。所以我们只需要遍历一遍 i i i j j j。在每次枚举一个 k k k,转移即可。

具体实现
#include 

const int MAXN = 1005;
int dp[MAXN][MAXN];
// dp[i][j]表示前i个数能存在j个逆序对的排列方案总数

int main () {
	int n;
	scanf ("%d", &n);
	dp[1][0] = 1; // 初始化
	for(int i = 1; i < n; i++) /
		for(int j = 0; j <= n * (n - 1) / 2; j++) 
			for(int k = 0; k <= i; k++) // 分别枚举i,j,k
				dp[i + 1][j + i - k] += dp[i][j];	
				// 把i+1插入到k会产生i-k个新的逆序对
				// 更新新的总方案数答案		
	int ans = 0;
	for(int i = 0; i <= n * (n - 1) / 2; i += 2) // 统计能得到偶数个逆序对的总方案数
		ans += dp[n][i]; // 累加所有数的合法排列方案总数
	printf("%d", ans);
	return 0; 
} 

不过……这时间复杂度也太高了?

于是我们考虑优化。会发现题目要求的是求可以得到偶数个逆序对的方案数,而不需要求出具体每个方案可以得到多少逆序对。所以我们可以优化第二维,改为取模。具体看代码叭。。

优化
#include 

const int MAXN = 1005;
int dp[MAXN][2];
// 这时的dp[i][j]表示前i个数能得到偶数或奇数个逆序对的方案总数
// j=1表示奇数个逆序对,j=0表示偶数个

int main () {
	int n;
	scanf ("%d", &n);
	dp[1][0] = 1;
	for(int i = 1; i < n; i++)
		for(int j = 0; j <= 1; j++) 
			for(int k = 0; k <= i; k++) 
				dp[i + 1][(j + i - k) % 2] += dp[i][j];	
	printf("%d", dp[n][0]); // 这里就不用累加了,直接输出即可
	return 0; 
} 

嘛,这就很快了。

我也许会鸽,但一定不会弃稿(逃

一维不行就二维,二维不行就三维……总有一年你能把状态转移方程想出来的(长者

你可能感兴趣的:(动态规划,动态规划,算法,c++)