动态规划——用二进制表示集合的状态压缩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\) 点的出发点,找到距离最近的那条。相比于上面的做法,这种做法更容易理解一些,但是代码多写几行,但是更容易理解一些。我建议如果直接理解第一段代码有困难的话,可以先搞懂第二段,然后再想明白为什么第一段代码也成立。
总结
不知道有多少人成功看到了这里,动态规划的确不简单,第一次学的话会觉得很困难难以理解是正常的。但是它是属于那种入门之前觉得特别难,但是一旦想明白了之后就特别简单的问题。而且大家从代码量上也看得出来,我用了几千字描述的算法,写出来居然只有十几行。
动态规划算法一直都是如此,代码不长,但每一行都是精髓。从这点上来说,它的性价比还真的是蛮高的。
好了,今天的文章就是这些,如果觉得有所收获,请顺手点个推荐吧,你们的举手之劳对我来说很重要。