您的位置:首页 > 其它

有关状压DP

2022-05-13 22:08 387 查看

【以下内容仅为本人在学习中的所感所想,本人水平有限目前尚处学习阶段,如有错误及不妥之处还请各位大佬指正,请谅解,谢谢!】

引言

动态规划虽然已经是对暴力算法的优化,但在某些比较特别的情况下,可以通过一些小技巧进一步对其优化,通产我们会在时间与空间中做权衡,在时间可以接受度范围内,适当的以时间为代价换取更小空间的占用;在不爆空间的情况下,适当的以空间换时间。在此,本人将以目前总结的经验详细介绍状态压缩与状压DP。

状态压缩

(一)状态

状态指某个事物表现出来的形态(百度百科)。联系前面的文章(有关动态规划 - PaperHammer - 博客园 (cnblogs.com)),我们在分析解决动态规划的问题时,其重点是在“分情况定变量”这一步,即有多少种选择,如何选择。不妨把这每一个选择视为一个事物,则它表现出来的形态就是所做出的选择,更进一步就是选择的结果。所以,动态规划中的状态实际上就是每一种情况

(二)压缩

压缩是一种通过特定的算法来减小计算机文件大小的机制,其目的是减少所占空间的同时提高运算速度(百度百科)。压缩的本质是减小,但不可否认虽然减小了文件体积提高了传输速度,但会存在文件质量的下降。

(三)状态压缩

一般地,我们规定利用计算机的二进制性质来描述所做出的选择,即将所有情况统一分为:行或不行、放或不放等两种情况(将很多情况归于两类情况)。由此可以发现,对于状态压缩,我们是针对有多少种选择进行了压缩,即对空间进行优化,那么根据互斥性原则(自己编的名字),对空间上的优化势必会带来时间上的复杂。所以,状态压缩在时间上其实是一种很暴力的思想,它需要遍历每一种情况,每种情况有两种选择(0或1),最高会达到2n的时间复杂度,但针对一些题目,可以依靠某些限定条件,大幅降低时间复杂度,从而变相达到既优化了空间也优化了时间的目的。

【注:相关位运算内容在此不作说明】

举例

(一)吃奶酪

链接:P1433 吃奶酪 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

题意:房间里放着n块奶酪。一只小老鼠要把它们都吃掉,问至少要跑多少距离?老鼠一开始在(0,0)点处。

分析:据题意可将其抽象为,从原点出发,返回走过所有点的最小距离。最值问题容易想到搜索。搜索时跳过重复路径,最后进行比较即可。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ConsoleApp1
{
internal class Program
{
static int n;
static double res = double.MaxValue;
static NodeToNode[][] ntn;
static int[] vis;
static void Main(string[] args)
{
Program p = new Program();
n = int.Parse(Console.ReadLine());
n += 1;
double[] x = new double
, y = new double
;
x[0] = 0;
y[0] = 0;
for (int i = 1; i < n; i++)
{
string[] inp = Console.ReadLine().Split(' ');
x[i] = double.Parse(inp[0]);
y[i] = double.Parse(inp[1]);
}//录入所有点,包括(0,0)
ntn = new NodeToNode
[];
for (int i = 0; i < n; i++) ntn[i] = new NodeToNode
;
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
ntn[i][j].dis = Dis(x[i], y[i], x[j], y[j]);//计算每两个点间的距离并编号
ntn[i][j].next = j;
}
ntn[i] = ntn[i].OrderBy(k => k.dis).ThenBy(k => k.next).ToArray();//一定要注意这个排序方式!!!!!
}

//for (int i = 0; i < n; i++)
//    for (int j = 0; j < n; j++)
//        Console.WriteLine(ntn[i][j].dis + " " + ntn[i][j].next);

vis = new int
;
vis[0] = 1;
p.Dfs(0, 0.0, 1);
Console.WriteLine(res.ToString("0.00"));
//Console.ReadLine();
}
public void Dfs(int cur, double sum, int cnt)
{
if(cnt == n)
{
res = res <= sum ? res : sum;
return;
}
if (sum >= res) return;
for(int i = 0; i < n; i++)
{
if(vis[ntn[cur][i].next] == 0 && cur != ntn[cur][i].next)
{
vis[ntn[cur][i].next] = 1;
Dfs(ntn[cur][i].next, sum + ntn[cur][i].dis, cnt + 1);
vis[ntn[cur][i].next] = 0;
}
}
}
public struct NodeToNode
{
public double dis;
public int next;
}
private static double Dis(double x1, double y1, double x2, double y2)
{
return Math.Sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
}
}
}
View Code

这种思路本质上是对除原点以外的所有点,进行全排列,比较每一种排列结果,其时间复杂度为O(N*N!),(应该是),在比赛中一般仅能承受N<=10的数据范围。

 

 

再分析:因为在深度搜索中会出现大量重复访问的数据,所以优化搜索算法,最先想到的一般是记忆化处理,记住到达某一个点的最小距离。但这种方法不能保证最坏的情况,有一部分情况和之前无异。本题除了距离因素,就只剩奶酪因素,既然记住距离还不够,那就只能在“奶酪”上下手了。

对于每个奶酪,只有两种情况:吃过/没吃过;抽象出来即,对于每个点:走过/没走过。发现对于每个事物(奶酪/点),仅有两种状态(访问过/没访问过),满足基本状态压缩的前提,所以我们考虑使用二进制来表示每个状态。

  a.  大化小:最终问题是让总距离最小,最终距离是基于每一次选择得来的,那么就把从一个点到一个新的点,这一选择视为一个子问题。并且每次选择总以最小最小距离为基础进行操作,每次选择处理方式相同。

  b.  分情况定变量:对于每个点,只有两种情况(走或不走);我们需要知道当前这个点是否走过,所以要记录当前所在点;还需要知道已经走过了几个奶酪,所以要记录对点(奶酪)的访问情况,共两个变量。用f[i][j]表示老鼠当前走到第i个点(奶酪)处,且走过的点的二进制状态为j时,最短的距离。

:可以使用二进制10100110来表示已经走过第2、3、6、8个奶酪(定义此处索引从1开始),此时j的值为166。需要注意的是,第i个状态是从低位向高位的第i位,即从低位向高位进行转移

  c.  推方程:如果要走这个点(保证该点可访问)那么f[i][j] = f[j][k - (1 << (i − 1)] + dis(i, j)其中f[i][j]表示以i为起点走成状态j的最小距离,dis表示两点间距离。

 

解释 k - (1 << i − 1) 】

(1)  符号‘<<’:表示某十进制数对应的二进制数向右移,等价于对十进制数*2。

如:1 << 3表示将1对应的二进制编码向右移动4位  (1)10 = (0001)2 右移5位变为(1000)2 = (8)10 = 1 * 23

(2)式子:最终目标的状态 – 当前位置的状态 = 中间状态

      如:(5)10 =(0101)2 = (0111)10 – (0010)2 = (7)10 - (2)10

                    上一个中间状态 = 目标状态 – 当前位置状态

 

【难点】为什么可以用 k & (1 << (i – 1)判断合法位置

我们设一个状态 k = 01101,表示第一、三、四列(从低位开始)中的某个点已经访问过;

由于我们是一行一行访问的,所以在k状态我们应该访问第三行的点了,那第三行我们应该访问哪个点,或者说状态k由哪些状态转移而来呢?

状态k(01101)由三种状态(必然是前两行的状态)来的:

  前两行在三、四列已访问,第三行只好访问第一列;(01100)

  前两行在一、四列已访问,第三行只好访问第三列;(01001)

  前两行在一、三列已访问,第三行只好访问第四列;(00101)

无非就是这三种情况,现在我们来考虑怎么来表示状态s由这三种状态来的,k & (1 << (i - 1))就是用来实现这个功能的,即判断当前情况是否为其上一个情况转移而来。

for (int i = 1; i <= 4; i++)

  01101 & (1<<0) = 01101 & 1 = 00001

  01101 & (1<<1) = 01101 & 10 = 00000

  01101 & (1<<2) = 01101 & 100 = 00100

  01101 & (1<<3) = 01101 & 1000 = 01000

  01101 & (1<<4) = 01101 & 10000 = 00000

由此得出,只有和k=01101有1重合结果才大于0,根据这个特性判断此列是否可以访问。

  d.  定边界:本题在搜索过程中不存在索引非法而导致的无法访问点,但需要对初值进行设定,因为其默认值为0,我们需要存储最小距离,所以应对初值设定为一个较大的值。

【注:文末代码中已附有更加详细的注释】

 

(二)01背包

【注:题目解析请转至该文章有关动态规划 - PaperHammer - 博客园 (cnblogs.com)

在该问题中,对于每种物品只有两种选择,不放,故也可以使用状态压缩进行优化。

如:有5件物品,

  如果这5件物品都不放的话,那就是00000;

  如果这5件物品都放的话,那就是11111;

观察可知,在上面的例子中00000 ~ 11111可以代表所有的情况,转化为十进制就是0到(1 << (5 – 1));

其中,所以f[10000]只能从f[00000] + W[1] 转移过来;f[11000]可以从f[01000] + W[1]或者f[10000] + W[2]转移过来,以此类推

总结

  1.  注意位运算的运算等级顺序,括号很重要

  2.  状压dp的特点一般是规模比较小,一般小于15,最多不超过20;而且一般只有两种决策

状态压缩比较难理解,本篇文章也花了快两周时间,虽然写成了笔记,但本人理解程度依旧不深,在此希望与各位大佬相互交流一起学习

【感谢您可以抽出时间阅读到这里;受限于水平,内容可能会有许多不妥之处,许多地方可能存在错误,还请各位大佬留言指正,请见谅,谢谢!】

 

#附文中所提到的第一题的代码 及 状压模板

(1)吃奶酪

 

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace ConsoleApp1
{
internal class Program
{
static int n,sum;
static double res = double.MaxValue;
static double[][] f;
static double[] x, y;
static void Main(string[] arg)
{
n = int.Parse(Console.ReadLine());
x = new double[n + 1];
y = new double[n + 1];

for (int i = 1; i <= n; i++)
{
string[] inp = Console.ReadLine().Split(' ');
x[i] = double.Parse(inp[0]);
y[i] = double.Parse(inp[1]);
}

//初始化,因为之后的比较是取较小的一个,所以将所有值预设为最大
f = new double[n + 1][];
for(int i = 1; i <= n; i++)
{
f[i] = new double[(1 << n)];
Array.Fill(f[i], double.MaxValue);
}

DP(f, n);
Console.WriteLine(res.ToString("0.00"));
//Console.ReadLine();
}
static public double DP(double[][] f, int n)
{
//分别枚举所有可能的二进制状态、当前点所在的位置和能在当前状态下到达当前点的位置。

//【枚举状态】
for (int k = 1; k <= (1 << n) - 1; k++)// k表示下一个位置所代表的状态【减1是因为长度为(1 << n),索引是从0到(1 << n) - 1]
{
//【枚举起点】
for (int i = 1; i <= n; i++)// i表示当前位置
{
if ((k & (1 << (i - 1))) == 0)// 此情况下非合法的中间情况【减1是因为索引上限】
continue;
if (k == (1 << (i - 1)))// 两状态相同, 即为同一个点
{
f[i][k] = 0;// 同一个点,距离为0
continue;
}
//【枚举中间点】注:中间点点不等于起点,中间点指从i到终点之间的点
for (int j = 1; j <= n; j++)// j   =>   k - (1 << (i - 1))
{
if ((k & (1 << (j - 1))) == 0 || i == j)// 如果   不合法   或   中间点等于起点   则跳过
continue;
f[i][k] = Math.Min(f[i][k], f[j][k - (1 << (i - 1))] + Dis(i, j));
}
sum += k ^ (1 << (i - 1));
Console.Write(sum + " ");
}
sum = 0;
Console.WriteLine();
}
for (int i = 1; i <= n; i++)
{
double cur = f[i][(1 << n) - 1] + Dis(i, 0);
res = Math.Min(res, cur);
}
return res;
}
private static double Dis(int a,int b)
{
return Math.Sqrt((x[a] - x[b]) * (x[a] - x[b]) + (y[a] - y[b]) * (y[a] - y[b]));
}
}
}

 

 

 

(2)一般状压模板

int n;
int maxn = 1 << n;//总状态数。
//枚举已有的集合数。按照状态转移的顺序,一般从小编号到大编号。
for(int i = 1; i <= m; ++ i){
//枚举当前集合中的状态。
for(int j = 0; j < maxn; ++ j){
//判断当前集合是否处于合法状态,通常我们需用一个数组提前处理好。如g数组;
if(当前状态是否合格){
for(int k = 0; k < maxn; ++ k){
//枚举上一个集合的状态。
if(上一个集合的状态是否合格 + 上一个集合的状态和当前状态的集合是否产生了冲突){
列写状态转移方程。
}
}
}
}
}
}
TRANSLATE with x English
Arabic Hebrew Polish
Bulgarian Hindi Portuguese
Catalan Hmong Daw Romanian
Chinese Simplified Hungarian Russian
Chinese Traditional Indonesian Slovak
Czech Italian Slovenian
Danish Japanese Spanish
Dutch Klingon Swedish
English Korean Thai
Estonian Latvian Turkish
Finnish Lithuanian Ukrainian
French Malay Urdu
German Maltese Vietnamese
Greek Norwegian Welsh
Haitian Creole Persian  
  TRANSLATE with COPY THE URL BELOW Back EMBED THE SNIPPET BELOW IN YOUR SITE Enable collaborative features and customize widget: Bing Webmaster Portal Back
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: