洛谷题单指南-状态压缩动态规划-P4484 [BJWC2018] 最长上升子序列
原题链接:https://www.luogu.com.cn/problem/P4484
题意解读:求n个数所有排列的LIS的数学期望,说人话就是,求1-n的数字的所有排列的LIS之和的平均值。
解题思路:
1、暴力法
暴力法没什么好说的,对于n,枚举数字1~n的全排列,对于每一个排列求LIS,最后加总,除以n!。
但是全排列的时间复杂度n!,LIS的时间复杂度最小nlogn,整体nn!logn,显然是不可接受的。
2、动态规划
动态规划的想法不错,问题是太难想,这里就有一种奇技淫巧!
对于数字1~n的一个排列a,设a[i]表示第i个数,设pre[i]表示前i个数中能形成LIS的最大值,设diff[i]=pre[i]-pre[i-1]也就是pre的差分;
举例:
a:2 1 3
pre: 1 1 2
diff 1 0 1
根据pre的定义,一定有pre[i] <= pre[i+1] <= pre[i] + 1,相邻两个pre要么相等要么相差1,于是diff必然是0和1的组合!!!
基于以上分析,任何一个排列都可以表示为diff这样一组0/1的状态,可以压缩成二进制整数,我们只需要统计所有这样的状态对应的排列的数量,
而这些diff状态相同的排列,都一定有相同的LIS,等于diff中1的数量。
因此,我们定义f[i][j]表示前i个数组成的排列中diff状态为j的方案数。
有了状态定义,再来看怎么进行状态转移?
对于n=1,排列为:1。
对于n=2,可以基于n=1的所有排列,将2插入到各个地方,2插到1前面就是:2 1,2插到1后面就是:1 2,有2种排列。
对于n=3,可以基于n-2是所有排列,将3插入到各个地方,
3插入2 1有三种方式,对应:3 2 1,2 3 1,2 1 3;
3插入1 2也有三种方式:对应3 1 2,1 3 2,1 2 3。
一共有6种排列。
这就给了我们一个启示,可以实现n从小到大的递推,将一个最大的数n插入到1~n-1的所有排列各个位置,可以通过diff状态枚举来遍历所有排列,然后插入各个位置也可以更新新的diff状态,从而实现状态转移。
在一个排列插入一个最大的数时,假设插入位置为k,则当前diff[k]一定变成1,因为以k结尾的LIS一定增加1个,k后面的第一个1如果存在,设其位置为pos,diff[pos]应该变成0,因为插入的最大数改变了pos前面的pre的大小。
举例:
a:2 1 3 pre:1 1 2 diff:1 0 1
现将4插入2 1之间,得到
a:2 4 1 3 pre:1 2 2 2 diff:1 1 0 0
可以看出,对于diff来说,就是在1 0之间插入了1,变成1 1 0 1,然后将最后的1置0,变成1 1 0 0
3、优化技巧
技巧1:由于diff数组的第一位永远是1,状态可以省一位,只需要n - 1位表示状态。
技巧2:如果直接定义f[N][1 << N - 1],空间也是吃不消的,由于f[i][j]值依赖于f[i-1][…],可以通过滚动数组优化第一维。
技巧3:设状态为j,要在第k位插入一个1,并将k后面第一个1置0,这样的操作可以这样实现。
int t = (j >> k) << (k + 1) | (1 << k) | j & ((1 << k) - 1)
以上代码在第k位插入一个1,其中(j >> k) << (k + 1)获取第k位以上的状态并空出第k位,(1 << k) 就是第k位的1,j & ((1 << k) - 1)是状态j在第k位以下的状态。
int up=lowbit((j>>k) << (k+1));
以上代码第k位插入1之后,找到k后面第一个1的位置。
t^=up;
以上代码将第k位之后第一个1置0。
技巧4:由于状态省了第一位,枚举插入位置时,会缺少在开头插入最新值的操作,需要单独处理,在一个排列开头插入最大数,diff的开头还是1,其余的数整体左移但是原来第一位的1变成0,因此状态从j转移到j << 1。
4、打表
基于以上代码写出来之后,可以将n = 1 ~ n = 28都运行一遍,得到所有的结果,实现打表。
打表代码:
#include <bits/stdc++.h>
using namespace std;
const int N = 28, M = 1 << (N - 1), MOD = 998244353;
int f[2][M]; //f[i][j]表示前i个数,排列后diff数组状态是j的方案数,j不包含最低位的1
int ones[M]; //ones[i]表示二进制i中1的个数
int n;
int lowbit(int x)
{
return x & -x;
}
int qmi(int a, int b)
{
int res = 1;
while(b)
{
if(b & 1) res = 1ll * res * a % MOD;
a = 1ll * a * a % MOD;
b = b >> 1;
}
return res;
}
int main()
{
for(int i = 1; i < M; i++) ones[i] = ones[i - lowbit(i)] + 1; //预计算
for(int n = 1; n <= 28; n++) //打表
{
memset(f, 0, sizeof(f));
f[1 % 2][0] = 1; //初始化,前1个数排列后diff状态为0的方案数是1
for(int i = 2; i <= n; i++) //枚举所有数字个数
{
fill(f[i % 2], f[i % 2] + (1 << (i - 2)), 0); //因为是滚动数组,需清空当前行
for(int j = 0; j < (1 << (i - 2)); j++) //枚举i-1个数字时排列的状态
{
f[i % 2][j << 1] = (f[i % 2][j << 1] + f[(i - 1) % 2][j]) % MOD; //当将最新的数字插入到排列的头部时,单独处理
for(int k = 0; k <= i - 2; k++) //枚举将最新数字插入到已排列的位置,对应到diff中
{
int t = (j >> k) << (k + 1) | (1 << k) | j & ((1 << k) - 1); //在第k位插入一个1
int up = lowbit((j >> k) << (k + 1)); //第k位插入1之后,找到k后面第一个1的位置
t ^= up; //将第k位之后第一个1置0
f[i % 2][t] = (f[i % 2][t] + f[(i - 1) % 2][j]) % MOD; //状态转移,累加方案数
}
}
}
int ans = 0;
for(int i = 0; i < (1 << (n - 1)); i++)
{
ans = (ans + 1ll * f[n % 2][i] * (ones[i] + 1)) % MOD; //累加所有排列的LIS之和
}
int fac = 1;
for(int i = 1; i <= n; i++) fac = 1ll * fac * i % MOD; //求n!
ans = 1ll * ans * qmi(fac, MOD - 2) % MOD; //除以n!模MOD,就是乘上n!模MOD的逆元,用费马小定理计算逆元
cout << ans << ","; //输出打表内容
}
return 0;
}
100分代码:
#include <bits/stdc++.h>
using namespace std;
int a[] = {1,499122178,2,915057326,540715694,946945688,422867403,451091574,317868537,200489273,976705134,705376344,662845575,331522185,228644314,262819964,686801362,495111839,947040129,414835038,696340671,749077581,301075008,314644758,102117126,819818153,273498600,267588741};
int main()
{
int n;
cin >> n;
cout << a[n - 1];
return 0;
}