dp 研究
\(\sf Attention\)
注意此篇博客已经荒废。
才发现有人比我整理的详细多了/jk/jk/jk \(\rm Link\)
感觉他的方法论是正确的,而且有非常多的细节小技巧。
\(\textsf{Dynamic Programming}\)
- 动态规划
很多算法都是利用了问题的课划分性以及子问题的相似性来进行归纳,降低复杂度。动态规划将整个问题划分为若干重叠的子问题,逐层递进,最终推导出整个问题的解。
概述
状态、阶段、决策是动态规划算法的三要素,而子问题重叠性、无后效性、最优子结构则是能动态规划算法使用的条件。
一个动态规划算法(朴素)的时间复杂度一般是枚举状态用时 \(\times\) 转移耗时。对应称为 \(xD/yD\) 的 \(dp\) 算法(时间复杂度 \(\mathcal O(n^{x + y})\))。比如最长上升子序列就是 \(1D/1D\) 的问题,状态为 \(\mathcal O(n)\) 个,转移要枚举前面的所有状态,为 \(\mathcal O(n)\) 级别,共 \(\mathcal O(n^2)\)。
动态规划的题目很多,不可能刷的完,所以要有意识的总结一些方法。
\(dp\) 和 \(dfs\) 是由相同之处的,中间隔了一个记忆化。这两者都是对状态空间的遍历,只不过搜索一次枚举一个状态,而动态规划一次枚举一堆具有类似特点的状态,所以前者是指数级的复杂度,而后者通常是多项式级别复杂度。两者“转移”的过程是很相似的。
这也是为什么有时候我们面对动态规划的题目可以将最简单的搜索作为出发点。
组成 \(dp\) 的,有以下三种东西。其一,\(dp\) 模型(套路),其二,\(dp\) 思考的基本方向和基本方法策略,其三,题目本身的性质。第三点因题目而异,也是最重要的东西。拿到任何题目第一步一定是分析性质,动态规划也不例外。然而分析题目性质这方面具体的套路很多而且千变万化,这里先挖个坑。
\(dp\) 模型
\(dp\) 模型(套路)是最基本,也是最容易掌握的,具体的有线性 \(dp\) 区间预处理、区间划分、排列插入、各种背包、区间 \(dp\)、树形 \(dp\) 的基本框架、状压 \(dp\)、数位 \(dp\) 等等。熟练掌握这些是必要的,借助这些套路化的东西可以跳过很多思考步骤。
\(dp\) 思考方向
看到一道 \(dp\) 题后绝对不能瞎想一气,而是有方法、有步骤、有套路的进行思考。我们分析性质,分块,再分析,再分块,直到分出能维护的东西。
首先我们从设计 \(dfs\) 入手,这个过程着重观察状态的表示以及多个状态之间的相似性。接下来可以考虑记忆化。搜索递归过程中的转移很可能就是最终 \(dp\) 的转移,这也是为什么个人习惯从“转移”入手。当然不是所有转移方式都是显而易见的,这一点因题目而异。
不是所有题目都是爆搜的“决策”就是最后的转移,有时第一步想到的 \(dfs\) 并不能跟后面的 \(dp\) 产生桥梁关系,不过有时可以起较好的参考作用。
然后我们尝试设计状态。我们去发掘搜索中重复的信息,思考哪些状态是类似的、相同的、重复的,可以一并转移的(所谓记忆化的过程)。这些可以一并转移的状态所有的共通的特点就将被记录为状态的表示。在这个过程中,对于最优性 \(dp\) 我们要注意最优子结构,对于计数 \(dp\) 我们要注意不重不漏。我们用 \(dp\) 数组来描述一个状态,这个数组的特征是若干个下标以及这些下标共同映射到的一个值。通常而言下标描述的是状态,即当前决策集合的共通特征,而这个映射到的值就是我们的答案。通常而言对于朴素的 \(dp\),一个“转移”需要什么因素,需要记录哪些变量因素那么我们的状态就记录什么。
时刻注意这些变量之间的联系。
同时,一个状态设计的好与否也会影响到是否能顺利推出转移方程,以及后续阶段的优化(如果需要)、最终实现的复杂度。
然后,我们在完成设计状态以及转移方法的基础上,就可以推出转移方程。一般来说,如果你有足够优秀的状态和正确的转移,状态转移方程是不难列出的,按题意列出关系即可。如果这一步遇到了阻碍,可以想想是不是前面的步骤出了问题。
接下来是一些细节的完善以及代码实现环节。有关的有 \(dp\) 边界的处理方法以及特殊情况、特殊转移的特判,\(dp\) 顺序的确定以及具体的实现方式(直接递推、记忆化或者是套上别的东西,比如 \(AC\) 自动机)。
综上,拿到了一道困难的 \(dp\) 题,我们可以尝试这样想:
- 先思考转移是什么(即所谓「决策」,有时候可以发掘 \(dp\) 和 \(dfs\) 的关联,有时候就直接借助常见套路,比如树上背包子树合并的思路,等等)
- 例如 \(dfs\):先想想 \(dfs\) 是怎么解决这个问题的,为后面 \(dp\) 做铺垫。
- 思考 \(dfs\) 式的转移怎么变成 \(dp\) 式转移(有时候就是一样的),怎么将一个个零件拼凑出整体的答案。
- 然后就是借助套路。感觉这个用的还是多一点,比如看到树形结构就套树形 \(dp\) 的方法,看到全排列就想一个一个从小到大插入数字。很多一开始的奇技淫巧不知不觉就变成了用起来轻车熟路的套路。
- 提炼出这种转移所需要的变量和因素(就是说想要实现这个转移你需要知道哪些东西的值)。这一步需要紧贴上一步的“转移”思考,结合性质看他到底是怎么决策的。
- 根据上一条设计出合理的 \(dp\) 状态。
- 根据设计出的状态将上文的“转移”(有可能还不一样)具体化为状态转移方程(推式子)。
- 完善具体的实现细节、边界处理、特判等问题。
- (一定要以上在想清楚之后!)写代码调代码。
- 如果实在是卡在哪一步很久,考虑一下有没有一种可能,还有关键性质没有发掘。
例题研究
问题
- 万一这个题第一眼不是搜索或者朴素解法没有明显的“转移”性质的东西呢?
- 感觉上面 \(dfs\to dp\) 以及设计状态的那一步说的很不清楚,有待发掘方法。
- 貌似不是所有题目的最终转移都是一开始的“转移”。
一些杂项
关于这些有很多的技巧。束手无策的时候可以先想想转移的顺序是什么(比如是线性递推,还是区间合并、区间插入等等),期望计数类的 \(dp\) 要特别注意不重不漏。
有待填充。
\(dp\) 优化
我们思考 \(dp\) 第一步都是推出朴素的方程,但是有可能需要优化。\(dp\) 的优化可以从多个方面进行。基本的有卷前缀和、贪心结论、离散化等等。后面常见的有状态设计的优化(例如倍增优化 \(dp\)、定义域值域互换)和转移优化(数据结构维护、单调队列、斜率优化、决策单调性等等)。
\(dp\) 优化方面的方法技巧和朴素 \(dp\) 的处理方式并不一样。后者在前者的基础上进行而且相对偏结构化、套路化,可以参考的模板也相对多一些。熟练掌握这些模型是必要的。
除了以上的优化方法,还有一些经典思想,例如费用提前计算、贡献均摊(自己取的名字)等等。
综合应用
\(dp\) 的综合应用太多太多了,比如说矩阵加速,结合 kmp、\(\rm AC\) 自动机等等。