全天动态规划入门到入坑。。。
一、总概:
动态规划是指解最优化问题的一类算法,考察方式灵活,也常是NOIP难题级别。先明确动态规划里的一些概念:
状态:可看做用动态规划求解问题时操作的对象。
边界条件:不需要、或不能由别的状态推出,且已知、或可算出的状态。递推时就用边界条件推出所有状态。
状态转移方程:由已知状态推出未知状态所用的方式、或原则等,依照它可用已知状态推出未知状态。
动态规划(DP)主要有:线性DP,数位DP,区间DP,树形DP,状压DP和其他DP(难度较高的还有数据结构优化DP,博弈论DP,概率(期望)DP),NOIP考察概率见下图:
(长者的销魂字迹~~~)
************************
动态规划的实现方式一般有三种:“顺着推”,“倒着推”,和记忆化搜索。
顺着推即算出某一状态后,考虑该状态会影响那些状态,并更新一下它影响的状态;
倒着推即知道该状态是由受哪些状态影响的,并由那些状态来推出该状态;
记忆化搜索即运用搜索与递归的思想,算某状态时,就递归算所有影响它的状态,同时为了防止一模一样的状态被计算多次,可以把每次计算出来的状态都存储下来,下次要算它的时候直接返回值就行了。
核心代码(斐波那契数列的倒着推与顺着推)
int main() { cin >> n; f[0]=0; f[1]=1; for (int a=2;a<=n;a++) { f[a]=f[a-1]+f[a-2]; } cout << f[n] << endl; cin >> n; f[0]=0; f[1]=1; for (int a=0;a<n;a++) { f[a+1] += f[a]; f[a+2] += f[a]; } cout << f[n] << endl; }
记忆化搜索:
1 int dfs(int n) 2 { 3 if (n==0) return 0; 4 if (n==1) return 1; 5 if (suan_le_mei[n]) return f[n]; 6 7 suan_le_mei[n] = true; 8 f[n] = dfs(n-1) + dfs(n-2); 9 10 return f[n]; 11 }// O(n) 12 13 int main() 14 { 15 cin >> n; 16 cout << dfs(n) << endl; 17 18 return 0; 19 }
二、数位DP:
一般按照数字的位数划分状态,状态一般由多维数组(条件数与维数成正比)表示。由高位开始向低位填数,枚举下一位数字填什么转移状态。
核心:下一位填什么
例题:
1、用DP的方式求解区间[l,r]中数的个数
题解:
显然答案为l-r+1,但这样就太没意思了。考虑用DP的方法做。题目等价于l<=v<=r,求v的个数。v的位数一定小于等于r的位数、大于等于l的位数。因此只考虑从v的最高位开始向下填数就好了。众所周知,我们比较数的大小都是将2数整数部分最低位对其后,再从最高位(位数不足的当做该位为0)开始依次比较,除非相等,则第一次得到两数位的数的大小关系就是我们要比较的两数的大小关系。故知当我们填到v的第i位时(从高往低数),v的前i-1位一定小于等于r的前i-1位、大于等于l的前i-1位。考虑v与r的大小关系,知当前i-1位一样时与不同时填第i位的方式也有差异,这是又要考虑v与l的大小关系。要考虑好多,怎么优化呢?
其实可用前缀和的思想。区间[l,r]中的数的个数等于[0,r]的数的个数-[0,l-1]的数的个数。便可建函数work(0,x)来算出区间[0,x]中数的个数。这样就只需考虑v与上限x的大小关系了。由上面思考得到主要知道两个量(当前所填位、二数前面位数的大小关系),可设数组f[i][2]表示状态,其中i表示当前已填到(这里为从低往高数)第i位,二维下标1表示高位v==x,0表示当前位与高位上v<x,f表示相应时刻时的填数方案数,最后结果为f[1][0]+f[1][1];因为是从第n(x的最高位数)位开始填,边界条件就是f[n+1][1]=1;填第i位数受到高位数的两种大小情况的影响,得状态转移方程:f[i][0]=f[i+1][1]*z[n]+f[i+1][0]*10;f[i][1]=f[i+1][1];(z[n]为x的第n位数。)
最后输出work(0,r)-work(0,l-1)即得最终答案。(代码提示:因为要用2次work函数,因此别忘清零)
2、求区间[l,r]中相邻二数位的数的差至少为2的数的个数。
题解:多了一个条件,多一个维度就完事了。设状态f[i][2][k],k表示当前填了数k。其余思想跟上题无大区别。只有填数时在加一个是否与上个k差2的特判就行。
3、求区间[l,r]中所有数的各个数位的数之和。
题解:设二维数组f和g,f的意义同第一题,g[i]表示已填到第i位(i是从低位向高位数,填数时是从高位向低位填数)时所有数的数位之和。当在第i位填下一个数c时,相当于前面f[i+1]个数的每个数后面都填上了一个c,所以总共加了(f[i+1][0]+f[i+1][1])*c。故知g的状态转移方程:g[i][0]=g[i+1][0]*10+f[i+1][0]*(0+1+…+9)+g[i+1][1]*z[n]+f[i+1][1]*(0+1+…+(z[n]-1));g[i][1]=g[i+1][1]+f[i+1][i]*z[n];
核心代码:
1 //提前说明一下该代码中状态的二维下标0与1的意义与题解是相反的。老师的代码还是抱着敬畏之心看吧(滑稽) 2 int solve(int x) 3 { 4 int n=0; 5 while (x) 6 { 7 z[n] = x%10; 8 x/=10; 9 n++; 10 } 11 n--; 12 13 memset(f,0,sizeof(f)); 14 memset(g,0,sizeof(g)); 15 16 f[n+1][1] = 1; 17 g[n+1][1] = 0; 18 19 for (int a=n;a>=0;a--) 20 for (int b=0;b<=1;b++) 21 { 22 if (b==0) 23 { 24 for (int c=0;c<=9;c++) 25 { 26 f[a][0] += f[a+1][b]; 27 g[a][0] += g[a+1][b] + f[a+1][b] * c; 28 } 29 } 30 else 31 { 32 for (int c=0;c<=z[a];c++) 33 { 34 if (c==z[a]) 35 { 36 f[a][1] += f[a+1][b]; 37 g[a][1] += g[a+1][b] + f[a+1][b] * c; 38 } 39 else 40 { 41 f[a][0] += f[a+1][b]; 42 g[a][0] += g[a+1][b] + f[a+1][b] * c; 43 } 44 } 45 } 46 } 47 48 return g[0][0] + g[0][1]; 49 }
三、树形DP
在树形结构上进行动态规划。明显看出每个节点可由其儿子节点推出,边界条件即叶节点,状态转移方程看情况写。
例题:
1、
给出一个树和若干询问,询问以某个节点为根的子树有多少个节点。
题解:以一个点为根的子树节点个数等于这个点左、右子树的节点数的个数+1(它本身)。故知状态为以某个节点为根的子树的节点数,边界条件为叶节点(1),状态转移方程即为当前状态=所有儿子状态的和+1;
代码:
1 #include<iostream> 2 3 using namespace std; 4 5 int f[233]; 6 7 void dfs(int p) 8 { 9 for (x is p's son) //教学计划还没到存图,这里写伪代码知道意思就好 10 { 11 dfs(x); 12 f[p] += f[x]; 13 } 14 f[p] ++; 15 } 16 17 int main() 18 { 19 cin >> n; 20 read_tree(); 21 22 dfs(1); 23 cout << f[1] << endl; 24 25 return 0; 26 }
2、
树的任意两点都是连通的,树的直径即整个树中所有连通两点的不经过重复边的路径的最大长度。给出一个数,求它的直径。
题解:找找路径的特点,发现连接两点的路径都是先从一点向上走走到两点的最近公共祖先然后再向下折连到终点。路径可被该祖先分成有不严格上升关系(大于等于)的两部分。若将此路径看做是由该祖先发出连向两点的路径,则这两条路径的长度和等于一开始的路径。故求树的直径,可看做求某点发出的两条向下路径的总长度最大值。
如何求一点向下发出的两条总长度最大的路径呢?一点向下发出路径,必定经过它的儿子。因此我们可以从下往上推,设f[i][0]为i节点向下发出的最长的一条路径,f[i][1]为次长的一条路径。边界条件为叶节点的f都为0。当我们推到i时,i的儿子必已经推完。易由父子关系得f[i][0]=max(f[son1][0],f[son2][0],...,f[sonk][0])+1(假设有k个儿子),设f[u][0]是其中最大的,则f[i][1]=max(f[sona1][0],f[sona2][0],...,f[sonak-1][0])(a取遍1到k中不为u的所有数)。至于怎么从下往上推,可利用DFS的回溯。最后答案即枚举所有点i,找到最大的f[i][0]+f[i][1]。
四、区间DP
当一个问题具有如下性质:1.合并相邻的两个元素;2.最终合并成一堆,求最优化的代价。便可以用区间DP。区间DP一般设状态为二维的数组f[i][j],其中i为区间左端点,j为区间右端点。
普通思路:转移状态是枚举两区间中断部分。
例题:P1880 [NOI1995]石子合并https://www.luogu.org/problemnew/show/P1880
题解:以求最小值为例:
先看不为环的情况。设石子为a1,a2,...,an,每次合并石子只会改变石子整体性,不改变石子顺序。只要求将若干相邻石子合并,而没有其他条件,可设状态f[i][j],表示把原第i堆石子到原第j堆石子全部合并最小的得分,边界条件为a[i][i]=0(i=1,2,...,n)。考虑得到f[i][j]的方式,最近一次合并前石子ai+ai+1,...,aj一定是两堆石子ai+ai+1,...,ap和ap+1,ap+2,...,aj。枚举每一个p,得到的最小得分即为f[i][j],这就是状态转移方程。(易错警告)枚举的顺序必须保证用已经推出来的量去推出没有推出来的量,所以应先把所有区间长度为2的f求出来,再求长度为3,4,...,n。不能只是简单双双从1到n枚举i和j。因为是要求最小值,所以应有初始化成大数的操作(这里用0x3f初始化是因为首先0x3f3f3f3f很大,而且两个加起来不会爆int,但如果2个0x7ffffff加起来的话就爆int,随后自然大多爆零了)。那么最后的答案即为f[1][n]。
如果为环呢?发现即使为环,在合并的过程中也有一个石子的分界一直没有被破坏,或换个角度,将环割成一条线,只要枚举分割的地方,最后在取所有分割得到的答案的最小值就行了。
对于最大值,把最小改成最大就行了。
时间复杂度:O(N3),空间:二倍的数组空间。
代码:
1 #include<iostream> 2 3 using namespace std; 4 5 const int INF=0x3f3f3f3f; 6 7 int main() 8 { 9 cin >>n; 10 for (int a=1;a<=n;a++) 11 cin >> z[a]; 12 memset(f,0x3f,sizeof(f));//求最小,注意往大里初始化。用Ox7f初始化后二元素相加会 13 //int溢出——爆零,而用0x3f初始化后二个元素相加也没事。 14 for (int a=1;a<=n;a++) 15 f[a][a] = 0; 16 17 for (int len=2;len<=n;len++)//(易错警告)注意枚举的顺序,要确保用已经知道的状态推出来现状态 18 for (int l=1,r=len; r<=n; l++,r++) 19 for (int p=l;p<r;p++) 20 f[l][r] = min(f[l][r],f[l][p]+f[p+1][r]+sum[l][r]); 21 22 cout << f[1][n] << endl; 23 24 return 0; 25 }
五、状压DP
例题:已知平面上有一些点及它们的坐标,求从第一个点开始经过其他所有点的路径的最短长度。(英文TSP问题,中文旅行商问题,也是个NP-hard问题,即最小的时间复杂度为O(2n))
题解:第i个元素是否在已经到过的集合里用一个二进制数第i位上的0或1表示。设f[i][j],i为表示集合的十进制数,j为当前走到的节点,f为最短长度。
见代码:
1 #include<cstdio> 2 #include<iostream> 3 4 using namespace std; 5 6 const int maxn=20; 7 8 int n; 9 10 double f[1<<maxn][maxn];//表示集合的二进制数最多有n位,最高位为第n-1位(从0开始),转换成十进制后是2^n-1,但数组也是从0开始。 11 12 double dis(int i,int j)//算距离 13 { 14 return sqrt((x[i]-x[j])*(x[i]-x[j])+(y[i]-y[j])*(y[i]-y[j])); 15 } 16 17 int main() 18 { 19 cin >> n; 20 for (int a=0;a<n;a++) 21 cin >> x[a] >> y[a]; 22 23 for (int a=0;a<(1<<n);a++)//手打初始化 24 for (int b=0;b<n;b++) 25 f[a][b] = 1e+20; 26 27 f[1][0] = 0;//二进制下最低位从零开始。 28 29 for (int s=1;s<(1<<n);s++) 30 for (int i=0;i<n;i++) 31 if ( ((s>>i) & 1) == 1)//i已经到过 32 for (int j=0;j<n;j++) 33 if ( ((s>>j) & 1) == 0 )//j没有到过 34 f[s|(1<<j)][j] = min(f[s|(1<<j)][j] , f[s][i] + dis(i,j)); 35 36 double ans = 1e+20; 37 for (int a=1;a<n;a++) 38 ans = min (ans , f[(1<<n)-1][a]); 39 40 cout << ans <<endl; 41 42 }
时间复杂度:空间复杂度:
n<=20还是能接受的。
总结一下各数据规模一般对应的时间复杂度:
n<=12:O(n!) 大暴力
n<=20:O(2n*n2) NP-hard 问题
n<=32 n<=50 高深算法优化,还是算了吧
n<=100 O(n3)小暴力、Floyd