线性状态动态规划是最基础的一类动态规划,它是具有线性阶段划分的动态规划。
个人认为熟练掌握动态规划算法没有什么捷径,就是多做题,多练习,多思考,多理解吧。
另外,解决一些问题的一种手段就是将自己已经会的经典问题进行变形转化,以解决该问题。(这好像叫化归思想来着)所以我们就要看一看一些经典的线性状态DP问题:
LIS问题,LCS问题和背包问题
我知道背包问题理论上应该不算线性状态动态规划的范畴,但是这篇文章不打算写太多内容,所以为了各篇篇幅的统一性就把背包问题放进这里来了。
有关这些经典问题的好博客和好文章太多了,比我写得好的一抓一大把。
推荐阅读:
- 崔添翼《背包问题九讲》。各版本可在这里查看;
- 《背包问题 (附单调队列优化多重背包)》
- Junior Dynamic Programming——动态规划初步·各种子序列问题
我们看一道例题,感受下经典问题的变形:
例题:NOIP2004提高组 合唱队形
题目就是叫我们求一个最长的子序列,满足存在一个分界点,前半部分上升,后半部分下降。
我们会求最长上升子序列和最长下降子序列,那么该怎么求最长的先上升后下降的子序列呢?
于是我们想到枚举分界点 i i i,那么设 f i f_i fi 表示以 i i i 结尾的从前往后的最长上升子序列长度, g i g_i gi 表示以 i i i 结尾的从后往前的最长上升子序列长度,则这个序列的长度为 f i + g i − 1 f_i+g_i-1 fi+gi−1(因为位于分界点的同学被重复算了一次),取 max \max max,再用总数减去最大值即可。
附代码:
#include <iostream>
using namespace std;
int f[105], g[105], t[105];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> t[i];
for (int i = 1; i <= n; i++) {
f[i] = 1;
for (int j = 1; j < i; j++)
if (t[j] < t[i])
f[i] = max(f[i], f[j] + 1);
}
for (int i = n; i > 0; i--) {
g[i] = 1;
for (int j = n; j > i; j--)
if (t[j] < t[i])
g[i] = max(g[i], g[j] + 1);
}
int ans = 0;
for (int i = 1; i <= n; i++) ans = max(ans, f[i] + g[i] - 1);
cout << n - ans << endl;
return 0;
}
再来变式 (怎么感觉我在出练习册)
例题:一本通 2.3 例 3 Nikitosh 和异或
一个子段的异或和可以差分为两段前缀异或和的异或和。
然后我们仿照“合唱队形”一题的思路,算出每个点前面最大的子段异或和,后面的最大子段异或和。然后枚举分界点即可。注意选的子段不一定非要包含该点。
设
f
i
f_i
fi 表示该点及之前的最大异或子段和,则
f
i
=
max
{
f
i
−
1
,
max
1
≤
j
<
i
{
a
i
⊕
a
j
}
}
f_i = \max\{f_{i-1},\max_{1 \le j \lt i}\{a_i \oplus a_j\}\}
fi=max{fi−1,1≤j<imax{ai⊕aj}}
该点及之后的最大异或子段和 g i g_i gi 同理。
第二部分的计算可以使用01Trie字典树优化时间复杂度。代码见下:
#include<cstdio>
#include<cctype>
#include<algorithm>
using namespace std;
const int maxn = 4e5 + 10;
int a[maxn], n;
int fixxor, two[maxn][32];
int root, tot;
struct Trie
{
int ch[2];
void clear()
{
ch[0] = ch[1] = 0;
return;
}
} t[maxn * 32];
int f[maxn], g[maxn];
inline int read()
{
int x = 0;
bool f = true;
char ch = getchar();
while(!isdigit(ch))
{
if(ch == '-')
f = false;
ch = getchar();
}
while(isdigit(ch))
{
x = (x << 1) + (x << 3) + (ch ^ 48);
ch = getchar();
}
return f ? x : -x;
}
inline void jinzhi(int x, int v)
{
for(register int i = 31; i >= 0; --i)
{
two[x][i] = v & 1;
v >>= 1;
}
return;
}
inline void insert(int x, int root)
{
int p = root;
for(register int i = 0; i < 32; ++i)
{
if(t[p].ch[two[x][i]] == 0)
{
t[++tot].clear();
t[p].ch[two[x][i]] = tot;
}
p = t[p].ch[two[x][i]];
}
return;
}
inline int match(int x, int root)
{
int p = root;
int res = 0;
for(register int i = 0; i < 32; ++i)
{
res <<= 1;
if(t[p].ch[two[x][i] ^ 1])
{
res |= 1;
p = t[p].ch[two[x][i] ^ 1];
}
else
p = t[p].ch[two[x][i]];
}
return res;
}
int main()
{
n = read();
for(register int i = 1; i <= n; ++i)
a[i] = read();
fixxor = 0;
for(register int i = 1; i <= n; ++i)
{
fixxor ^= a[i];
jinzhi(i, fixxor);
}
tot = root = 1;
t[root].clear();
insert(0, root);
for(register int i = 1; i <= n; ++i)
{
f[i] = max(f[i - 1], match(i, root));
insert(i, root);
}
fixxor = 0;
for(register int i = n; i > 0; --i)
{
fixxor ^= a[i];
jinzhi(i, fixxor);
}
tot = root = 1;
t[root].clear();
g[n] = a[n];
insert(n + 1, root);
for(register int i = n; i > 0; --i)
{
g[i] = max(g[i + 1], match(i, root));
insert(i, root);
}
int ans = 0;
for(register int i = 1; i < n; ++i)
ans = max(ans, f[i] + g[i + 1]);
printf("%d\n", ans);
return 0;
}
Dilworth Theorem
这里顺便介绍一下一个冷门的知识点:Dilworth定理。这一定理在特殊子序列问题中有一些应用:
一个序列的最少不上升子序列划分段数即为最长上升子序列长度。
当然这个定理得到的子序列结论不止这一个,其它的也可以用与这个结论的证明相同的方法。
我们证明一下这个结论。
设这个序列的最长上升子序列长度为 l e n len len,最少不上升子序列划分段数为 m m m。
Observation1:
m
≤
l
e
n
m \le len
m≤len.
Proof:我们从划分的每一个不上升子序列中各抽出一个元素,则这些元素一定能构成一个上升子序列。如果有两个元素相等,则它们必然可以共存于一个不上升子序列中,那么答案就不是最优了,矛盾。而
l
e
n
len
len 是最长上升子序列的长度,所以必有
m
≤
l
e
n
m \le len
m≤len。
Observation2:
m
≥
l
e
n
m \ge len
m≥len.
Proof:对于最长上升子序列中的任意两个元素,它们不能在同一个不上升子序列里。如果它们在同一个不上升子序列里,那么不满足上升,矛盾。
∴ m = l e n \therefore m = len ∴m=len
推荐练习题: