15

动态规划——用二进制表示集合的状态压缩DP

 3 years ago
source link: http://www.cnblogs.com/RioTian/p/13941529.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

动态规划当中有非常常见的一个分支—— 状态压缩动态规划 ,很多人对于状态压缩畏惧如虎,但其实并没有那么难,希望这文章能带你们学到这个经典的应用。

二进制表示状态

在讲解多重背包问题的时候,我们曾经讲过二进制表示法来解决多重背包。利用二进制的性质,将多个物品拆分成少数个物品,转化成了简单的零一背包来解决。今天的状态压缩同样离不开二进制,不过我个人感觉今天的二进制应用更加容易理解一些。

二进制的很多应用离不开 集合 这个概念,我们都知道在计算机当中,所有数据都是以二进制的形式存储的。一般一个 \(int\) 整形是4个字节,也就是32位bit,我们通过这32位bit上0和1的组合可以表示多大21亿个不同的数。如果我们把这32位bit看成是一个集合,那么 每一个数都应该对应集合的一种状态 ,并且每个数的状态都是不同的。

EnEJZb.jpg!mobile

比如上图当中,我们列举了5个二进制位,我们把其中两个设置成了1,其余的设置成了0。我们通过计算,可以得到6这个数字,那么6也就代表了(00110)这个状态。 数字和状态是一一对应的 ,因为每个整数转化成二进制都是唯一的。

也就是说一个整数可以转化成二进制数,它可以代表某个集合的一个状态,这两者一一对应。这一点非常重要,是后面一切推导的基础。

状态转移

整数的二进制表示可以代表一个二元集合的状态,既然是状态就可以转移。在此基础上,我们可以得出另一个非常重要的结论—— 我们可以用整数的加减表示状态之间的转移

我们还用刚才的例子来举例,上面的图当中我们列举了5个二进制位,假设我们用这5个二进制位表示5个小球,这些小球的编号分别是0到4。这样一来,刚才的6可以认为表示拿取了1号和2号两个小球的状态。

如果这个时候我们又拿取了3号小球,那么集合的状态会发生变化,我们用一张图来表示:

3m6bQr7.jpg!mobile

上图当中粉丝的笔表示决策,比如我们拿取了3号球就是一个决策,在这个决策的影响下,集合的状态发生了转移。转移之后的集合代表的数是14,它是由之前的集合6加上转移带来的变化,也就是得到的。刚好就代表拿取3号球这个决策,这样我们就把整个过程串起来了。

总结一下,我们用二进制的0和1表示一个二元集合的状态。可以简单认为某个物品存在或者不存在的状态。由于二进制的0和1可以转化成一个 \(int\) 整数,也就是说我们用整数代表了一个集合的状态。这样一来,我们可以 用整数的加减计算来代表集合状态的变化

这也就是状态压缩的精髓,所谓的压缩,其实就是将一个集合压缩成了一个整数的意思,因为整数可以作为数组的下标,这样操作会方便我们的编码。

关于位运算还有很多奇技淫巧,原文链接:Here

旅行商问题

明白了状态压缩的含义之后,我们来看一道经典的例题,也就是大名鼎鼎的旅行商问题。

旅行商问题的背景很有意思,说是有一个商人想要 旅行各地 并进行贸易。各地之间有若干条 单向的通道 相连,商人从一个地方出发,想要用最短的路程把所有地区环游一遍,请问环游需要的最短路程是多少?在这题当中,我们假设商人从0位置出发,最后依然回到位置0。

我们来看下面这张图来直观地感受一下:

UVrMb23.jpg!mobile

假设我们的商人从0位置出发,想要 环游一周之后再次回到0 ,那么它所需要经历的最短距离是多少呢?

这个图还是比较简单的,如果在 极端情况下也就是所有点之间都有连线 的时候,对于每一个点来说,它可以选择的下一个位置一共有 \(n-1\) 种。那么一共可以选择的路线总共有 \(n!\) 种,这是一个非常大的值,显然是我们不能接受的。这也是为什么我们说旅行商问题是一个 \(NP-Hard\) 问题。

NP问题

既然说到了NP问题,简单和大家聊聊NP问题的定义。

很多算法的初学者对于这些概念非常迷糊,也的确,这些概念听起来都差不多,的确很容易搞晕。我们先从最简单的开始介绍,首先是P问题。

P问题可以认为是已经解决的问题,这个解决的定义是可以做 多项式的时间复杂度内 解决。所谓的多项式,也就是,这里的k是一个常数。与多项式相反的函数有很多,比如指数函数、阶乘等等。

\(NP\) 问题并不是P问题的反义,这里的N不能理解成No,就好像 \(noSQL\) 不是非 \(SQL\) 的意思一样。 \(NP\) 问题指的是可以 在多项式内验证解的问题

比如给定一个排序的序列让我们判断它是不是有序的,这很简单,我们只需要遍历一下就好了。再比如大整数的因式分解,我们来做因式分解会很难,但是让我们判断一个因式分解的解法是不是正确则要简单得多,我们直接把它们乘起来和原式比较就可以了。

显然 所有P问题都是NP问题 ,既然我们可以多项式内找到解,那么必然我们也可以在多项式内验证解是否正确。但是反过来是否成立呢,是否多项式时间内可以验证解的问题,也可以通过某种算法可以在多项式时间内被解开呢? 究竟是我们暂时还没有想到算法,还是解法一开始就不存在呢?

上面的这个问题就是著名的NP=P是否成立的问题,这个问题目前仍然是一个谜,有些人相信成立,有些人不相信,这也被认为是二十一世纪的最大难题之一。

为了证明这个问题,科学家们又想出了一个办法,就是给问题做规约。举个例子,比如解方程,我们解一元一次方程非常简单,而解二元一次方程则要困难一些。如果我们想出了解二元一次方程的办法,那么必然也可以用来解一元一次方程,因为我们只需要令另一个未知数等于0就是一元一次方程了。

同理,我们也可以把NP问题做转化,将它的难度增大, 增大到极限成为一个终极问题 。由于这个终极问题是所有NP问题转化得到的,只要我们想出算法来解决了终极问题,那么,所有的NP问题全部都迎刃而解。就比如如果我们想出了解N元方程的算法,那么这一类解方程的问题就都搞定了。这种转化之后得到的问题称为 NP完全问题,也叫做NPC问题

下面我们来看一个经典的NPC问题,即逻辑电路问题。

7fA3Q3E.jpg!mobile

下图是一个逻辑电路,假设我们知道它的输出是 \(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\) 点的出发点,找到距离最近的那条。相比于上面的做法,这种做法更容易理解一些,但是代码多写几行,但是更容易理解一些。我建议如果直接理解第一段代码有困难的话,可以先搞懂第二段,然后再想明白为什么第一段代码也成立。

总结

不知道有多少人成功看到了这里,动态规划的确不简单,第一次学的话会觉得很困难难以理解是正常的。但是它是属于那种入门之前觉得特别难,但是 一旦想明白了之后就特别简单的问题 。而且大家从代码量上也看得出来,我用了几千字描述的算法,写出来居然只有十几行。

动态规划算法一直都是如此,代码不长,但每一行都是精髓。从这点上来说,它的性价比还真的是蛮高的。

好了,今天的文章就是这些,如果觉得有所收获,请顺手点个 推荐 吧,你们的举手之劳对我来说很重要。

参考

状态压缩技巧:动态规划的降维打击

OI wiki

TechFlow


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK