动态规划——用二进制表示集合的状态压缩DP
动态规划当中有非常常见的一个分支——状态压缩动态规划,很多人对于状态压缩畏惧如虎,但其实并没有那么难,希望这文章能带你们学到这个经典的应用。
二进制表示状态
在讲解多重背包问题的时候,我们曾经讲过二进制表示法来解决多重背包。利用二进制的性质,将多个物品拆分成少数个物品,转化成了简单的零一背包来解决。今天的状态压缩同样离不开二进制,不过我个人感觉今天的二进制应用更加容易理解一些。
二进制的很多应用离不开集合这个概念,我们都知道在计算机当中,所有数据都是以二进制的形式存储的。一般一个 \(int\) 整形是4个字节,也就是32位bit,我们通过这32位bit上0和1的组合可以表示多大21亿个不同的数。如果我们把这32位bit看成是一个集合,那么每一个数都应该对应集合的一种状态,并且每个数的状态都是不同的。
比如上图当中,我们列举了5个二进制位,我们把其中两个设置成了1,其余的设置成了0。我们通过计算,可以得到6这个数字,那么6也就代表了(00110)这个状态。数字和状态是一一对应的,因为每个整数转化成二进制都是唯一的。
也就是说一个整数可以转化成二进制数,它可以代表某个集合的一个状态,这两者一一对应。这一点非常重要,是后面一切推导的基础。
状态转移
整数的二进制表示可以代表一个二元集合的状态,既然是状态就可以转移。在此基础上,我们可以得出另一个非常重要的结论——我们可以用整数的加减表示状态之间的转移。
我们还用刚才的例子来举例,上面的图当中我们列举了5个二进制位,假设我们用这5个二进制位表示5个小球,这些小球的编号分别是0到4。这样一来,刚才的6可以认为表示拿取了1号和2号两个小球的状态。
如果这个时候我们又拿取了3号小球,那么集合的状态会发生变化,我们用一张图来表示:
上图当中粉丝的笔表示决策,比如我们拿取了3号球就是一个决策,在这个决策的影响下,集合的状态发生了转移。转移之后的集合代表的数是14,它是由之前的集合6加上转移带来的变化,也就是得到的。刚好就代表拿取3号球这个决策,这样我们就把整个过程串起来了。
总结一下,我们用二进制的0和1表示一个二元集合的状态。可以简单认为某个物品存在或者不存在的状态。由于二进制的0和1可以转化成一个 \(int\) 整数,也就是说我们用整数代表了一个集合的状态。这样一来,我们可以用整数的加减计算来代表集合状态的变化。
这也就是状态压缩的精髓,所谓的压缩,其实就是将一个集合压缩成了一个整数的意思,因为整数可以作为数组的下标,这样操作会方便我们的编码。
关于位运算还有很多奇技淫巧,原文链接:Here
旅行商问题
明白了状态压缩的含义之后,我们来看一道经典的例题,也就是大名鼎鼎的旅行商问题。
旅行商问题的背景很有意思,说是有一个商人想要旅行各地并进行贸易。各地之间有若干条单向的通道相连,商人从一个地方出发,想要用最短的路程把所有地区环游一遍,请问环游需要的最短路程是多少?在这题当中,我们假设商人从0位置出发,最后依然回到位置0。
我们来看下面这张图来直观地感受一下:
假设我们的商人从0位置出发,想要环游一周之后再次回到0,那么它所需要经历的最短距离是多少呢?
这个图还是比较简单的,如果在极端情况下也就是所有点之间都有连线的时候,对于每一个点来说,它可以选择的下一个位置一共有 \(n-1\) 种。那么一共可以选择的路线总共有 \(n!\) 种,这是一个非常大的值,显然是我们不能接受的。这也是为什么我们说旅行商问题是一个 \(NP-Hard\) 问题。
NP问题
既然说到了NP问题[1],简单和大家聊聊NP问题的定义。
很多算法的初学者对于这些概念非常迷糊,也的确,这些概念听起来都差不多,的确很容易搞晕。我们先从最简单的开始介绍,首先是P问题。
P问题可以认为是已经解决的问题,这个解决的定义是可以做多项式的时间复杂度内解决。所谓的多项式,也就是,这里的k是一个常数。与多项式相反的函数有很多,比如指数函数、阶乘等等。
\(NP\) 问题并不是P问题的反义,这里的N不能理解成No,就好像 \(noSQL\) 不是非 \(SQL\) 的意思一样。\(NP\) 问题指的是可以在多项式内验证解的问题。
比如给定一个排序的序列让我们判断它是不是有序的,这很简单,我们只需要遍历一下就好了。再比如大整数的因式分解,我们来做因式分解会很难,但是让我们判断一个因式分解的解法是不是正确则要简单得多,我们直接把它们乘起来和原式比较就可以了。
显然所有P问题都是NP问题,既然我们可以多项式内找到解,那么必然我们也可以在多项式内验证解是否正确。但是反过来是否成立呢,是否多项式时间内可以验证解的问题,也可以通过某种算法可以在多项式时间内被解开呢?究竟是我们暂时还没有想到算法,还是解法一开始就不存在呢?
上面的这个问题就是著名的NP=P是否成立的问题,这个问题目前仍然是一个谜,有些人相信成立,有些人不相信,这也被认为是二十一世纪的最大难题之一。
为了证明这个问题,科学家们又想出了一个办法,就是给问题做规约。举个例子,比如解方程,我们解一元一次方程非常简单,而解二元一次方程则要困难一些。如果我们想出了解二元一次方程的办法,那么必然也可以用来解一元一次方程,因为我们只需要令另一个未知数等于0就是一元一次方程了。
同理,我们也可以把NP问题做转化,将它的难度增大,增大到极限成为一个终极问题。由于这个终极问题是所有NP问题转化得到的,只要我们想出算法来解决了终极问题,那么,所有的NP问题全部都迎刃而解。就比如如果我们想出了解N元方程的算法,那么这一类解方程的问题就都搞定了。这种转化之后得到的问题称为NP完全问题,也叫做NPC问题。
下面我们来看一个经典的NPC问题,即逻辑电路问题。
下图是一个逻辑电路,假设我们知道它的输出是 \(True\) ,我们也知道了电路的结构,那么请问我们能否确定一定可以找到一个输入的组合,使得最后的输出是 \(True\) 吗?
它显然是一个 \(NP\) 问题,因为我们可以直接把解法代入电路去计算一下,就可以验证这个解是否正确,但是想要得到答案却很难。经过严谨的证明,所有NP问题都可以经过转化得到它,也就是说如果我们找到一种解法可以在多项式内解决这个问题,那么我们就解决了所有的 \(NP\) 问题。
最后,还有一个 \(NP-Hard\) 问题,\(NP-Hard\) 问题是说所有 \(NP\) 问题可以经过转化得到它,但是它本身并不是NP问题,也就是说我们无法在多项式时间内判断它的解是否正确。
比如刚才提到的旅行商问题就是一个 \(NP-Hard\) 问题,因为即使我们给定了一个解,我们也没有办法快速判断给定的解是否正确,必须要遍历完所有的情况才可以。我们验证的复杂度就已经超出了多项式的范畴,所以它不属于 \(NP\) 问题,比 \(NP\) 问题更加困难,所以是一个 \(NP-Hard\) 问题。
状态压缩解法
说完了 \(NP\) 问题,我们回到算法本身。
既然我们要用动态规划的思路来解决这个问题,就不能脱离状态和决策。前文说了我们利用二进制可以用一个整数来表示一个集合的状态,我们很容易会把这个状态当成是动态规划当中的状态,但其实这是不对的。
单纯集合之间的转移没有限制条件,比如之前的例子当中我们已经拿了1号球和2号球,后面只要是剩下的球都可以拿,但是旅行商问题不一样,假设我们去过了0和1两个地方,我们当前在位置1,我们是无法用2和5两地之间的连线来更新这个状态的,因为我们当前只能从1号位置出发。也就是说我们能采取的决策是有限制的。
所以我们不能只单纯地拿集合的状态来当做状态,为了保证地点之间的移动顺序正确,我们还需要加上一维,也就是当前所处的位置。所以真正的状态是我们之前遍历过的位置的状态,加上当前所处的地点,这两者的结合。
状态确定了,决策就很简单了,凡是当前地点能去的之前没有去过的位置,都可以构成决策。
我们之前说过,在动态规划问题当中,复杂度等于状态数乘上决策数,状态数是,决策数就是n,所以总体的复杂度是。虽然这个数字看起来仍然大得夸张,但是仍然要比n!小很多。
我们举个例子来看下,如果 \(n=10,n!=3628800\),,两者相差了三十多倍。随着n的增大,两者的差距还会更大。
最后,我们来实现以下算法:
import math
if __name__ == "__main__":
inf = 1 << 31
# 邻接矩阵存储边权重
d = [[inf for _ in range(10)] for _ in range(10)]
# 测试数据
edges = [[0, 1, 3], [1, 2, 5], [2, 3, 5], [3, 4, 3], [4, 0, 7], [4, 1, 6], [0, 3, 4], [2, 0, 4]]
for u, v, l in edges:
d[u][v] = l
# 初始化成近似无穷大的值
dp = [[inf for _ in range(5)] for _ in range((1 << 5))]
dp[0][0] = 0
# 遍历状态
for s in range(1, (1 << 5)):
for u in range(5):
# 遍历决策
for v in range(5):
# 必须要求这个点没有去过
if (s >> v) & 1 == 0:
continue
dp[s][v] = min(dp[s][v], dp[s - (1 << v)][u] + d[u][v])
print(dp[(1 << 5) - 1][0])
在ACM竞赛的代码风格当中,我们通常用u表示边的起点,v表示边的终点。所以上面的三重循环第一种是遍历了所有的状态,后面两重循环是枚举了起点和终点,也就是所有的边。我们遍历的是当前这个状态之前的最后一次移动的边,也就是说当前的点是v,之前的点是u,所以之前的状态是\(s-2^v\) ,决策带来的开销是 \(d[u][v]\),也就是从u到v的距离。
如果读过之前文章的小伙伴,会发现这是一个逆推的动态规划。我们枚举当前的状态和当前状态的所有来源,从而找到当前状态的最优解。如果对这个概念不熟悉的同学,可以查看一下之前动态规划下的其他文章。
这段代码当中有两个细节,第一个细节是我们没有做u的合法判断,有可能我们u是不合法的,比如我们的集合当中只有2和3两个点,但是我们却枚举了从4到5的策略。这样是没问题的,因为我们开始的时候把所有的状态都设置成了无穷大,只有合法的状态才不是无穷,由于我们希望最后得到的结果越小越好,不合法的状态是不会被用来更新的。
第二个细节稍微隐蔽一些,就是我们在初始化的时候设置了 \(dp[0][0] = 0\) 。这表示我们是从空集开始的,而不是从0点开始的。因为0点已经遍历过的状态对应的数字是1,当然我们也可以设置成0已经访问过了,从0点开始,这样的话由于每个点不能重复访问,所以最后我们是无法回到0点的,要得到正确结果我们还需要加上回到0点需要的消耗。
分析一下会发现第一点是第二点的基础,如果我们在枚举策略的时候都判断一下u点是否也合法,那么这个算法就没有办法执行,因为对于空集而言,所有点都是未访问过的,也都是非法状态,我们就找不到一个访问过的u作为决策的起点。
如果你看不懂上面的做法也没有关系,我再附上一种稍稍简单一些的方法:
# 我们从0点已经遍历开始
dp[1][0] = 0
for s in range(2, (1 << 5)):
for u in range(5):
# 严格限制u必须已经遍历过
if (s >> u) & 1 == 0:
continue
for v in range(5):
if (s >> v) & 1 == 0:
continue
dp[s][v] = min(dp[s][v], dp[s - (1 << v)][u] + d[u][v])
ans = inf
# 最后加上回到0点的距离
for i in range(5):
ans = min(ans, dp[(1 << 5) - 1][i] + d[i][0])
print(ans)
在这一种做法当中,我们从状态1开始,也就是说我们把0号位置看成当前所在的点,并且已经遍历过了,所以标记成了1。这样的问题是我们没有办法再回到0了,因为一个点只能走一次,所以最后的时候需要再寻找回到0点的最优路径。
\((1 << n) - 1\)的值是从 \(0\) 到 \(n-1\) 个二进制位都是 \(1\) 的值,表示这 \(n\) 个位置全部已经遍历过了。然后我们遍历所有回到 \(0\) 点的出发点,找到距离最近的那条。相比于上面的做法,这种做法更容易理解一些,但是代码多写几行,但是更容易理解一些。我建议如果直接理解第一段代码有困难的话,可以先搞懂第二段,然后再想明白为什么第一段代码也成立。
总结
不知道有多少人成功看到了这里,动态规划的确不简单,第一次学的话会觉得很困难难以理解是正常的。但是它是属于那种入门之前觉得特别难,但是一旦想明白了之后就特别简单的问题。而且大家从代码量上也看得出来,我用了几千字描述的算法,写出来居然只有十几行。
动态规划算法一直都是如此,代码不长,但每一行都是精髓。从这点上来说,它的性价比还真的是蛮高的。
好了,今天的文章就是这些,如果觉得有所收获,请顺手点个推荐吧,你们的举手之劳对我来说很重要。
参考
[TechFlow](
动态规划——用二进制表示集合的状态压缩DP的更多相关文章
- BZOJ1688|二进制枚举子集| 状态压缩DP
Disease Manangement 疾病管理 Description Alas! A set of D (1 <= D <= 15) diseases (numbered 1..D) ...
- [动态规划]状态压缩DP小结
1.小技巧 枚举集合S的子集:for(int i = S; i > 0; i=(i-1)&S) 枚举包含S的集合:for(int i = S; i < (1<<n); ...
- 旅行商问题——状态压缩DP
问题简介 有n个城市,每个城市间均有道路,一个推销员要从某个城市出发,到其余的n-1个城市一次且仅且一次,然后回到再回到出发点.问销售员应如何经过这些城市是他所走的路线最短? 用图论的语言描述就是:给 ...
- [知识点]状态压缩DP
// 此博文为迁移而来,写于2015年7月15日,不代表本人现在的观点与看法.原始地址:http://blog.sina.com.cn/s/blog_6022c4720102w6jf.html 1.前 ...
- 转 状态压缩DP
引入 首先来说说“状态压缩动态规划”这个名称,顾名思义,状态压缩动态规划这个算法包括两个特点,第一是“状态压缩”,第二是“动态规划”. 状态压缩: 从状态压缩的特点来看,这个算法适用的题目符合以下的条 ...
- Travelling(spfa+状态压缩dp)
题目连接:http://acm.hdu.edu.cn/showproblem.php?pid=3001 Travelling Time Limit: 6000/3000 MS (Java/Others ...
- Victor and World(spfa+状态压缩dp)
题目连接:http://acm.hdu.edu.cn/showproblem.php?pid=5418 Victor and World Time Limit: 4000/2000 MS (Java/ ...
- [转]状态压缩dp(状压dp)
状态压缩动态规划(简称状压dp)是另一类非常典型的动态规划,通常使用在NP问题的小规模求解中,虽然是指数级别的复杂度,但速度比搜索快,其思想非常值得借鉴. 为了更好的理解状压dp,首先介绍位运算相关的 ...
- POJ 1691 Painting a Board(状态压缩DP)
Description The CE digital company has built an Automatic Painting Machine (APM) to paint a flat boa ...
随机推荐
- Python实现的数据结构与算法之队列详解
本文实例讲述了Python实现的数据结构与算法之队列.分享给大家供大家参考.具体分析如下: 一.概述 队列(Queue)是一种先进先出(FIFO)的线性数据结构,插入操作在队尾(rear)进行,删除操 ...
- 《C++ primer plus》第5章练习题
1.输入两个整数,输出两个整数之间所有整数的和,包括两个整数. #include<iostream> using namespace std; int main() { int num1, ...
- 【题解】Tree
题目戳我 \(\text{Solution:}\) 考虑点分治.对于这个两点之间,它意味着这点对必须是不一样的. 考虑用双指针统计答案.显然,对于两个数\(a,b\),要让\(a+b=k,a\)越大则 ...
- 用composer安装captcha_src()不成功
1,要么就是TP的框架配置有问题建议重新下载 2,要么就是下载captcha_src的语句有问题 正确的语句 composer require topthink/think-captcha 1.* ...
- springboot2.2.2企业级项目整合redis与redis 工具类大全
1.springboot2.2.2整合redis教程很多,为此编写了比较完整的redis工具类,符合企业级开发使用的工具类 2.springboot与redis maven相关的依赖 <depe ...
- 苏州6617.9373(薇)xiaojie:苏州哪里有xiaomei
苏州哪里有小姐服务大保健[微信:6617.9373倩儿小妹[苏州叫小姐服务√o服务微信:6617.9373倩儿小妹[苏州叫小姐服务][十微信:6617.9373倩儿小妹][苏州叫小姐包夜服务][十微信 ...
- vue任意关系组件通信与跨组件监听状态 vue-communication
大家好!我是木瓜太香! 众所周知,组件式开发方式给我们带来了方便,不过也引入了新的问题,组件之间的数据就像被一道无形的墙隔开,如果我们希望临时让两个组件直接通信,vuex 太巨,而 $emit 又不好 ...
- linux(centos8):安装jmeter5.3
一,jmeter的用途: Apache JMeter是Apache组织开发的基于Java的压力测试工具.用于对软件做压力测试, 它最初被设计用于Web应用测试,但后来扩展到其他测试领域. Apach ...
- TCP/IP的十个问题
一.TCP/IP模型 TCP/IP协议模型(Transmission Control Protocol/Internet Protocol),包含了一系列构成互联网基础的网络协议,是Internet的 ...
- RabbitMQ消息队列总结
AMQP[高级消息队列协议] 是一个异步消息传递所使用的应用层协议规范(是线路层协议)AMQP 客户端能够无视消息的来源任意发送和接受信息 队列的使用场景: 1.与业务的主要逻辑无关,但又需要执行,就 ...