详解状态压缩动态规划算法

本文始发于我的公众号:TechFlow,原创不易,求个关注web


今天是算法与数据结构专题的第16篇,也是动态规划系列的第5篇。算法

今天文章的内容是动态规划当中很是常见的一个分支——状态压缩动态规划,不少人对于状态压缩畏惧如虎,但其实并无那么难,但愿我今天的文章能带大家学到这个经典的应用。数组

二进制表示状态

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

二进制的不少应用离不开集合这个概念,咱们都知道在计算机当中,全部数据都是以二进制的形式存储的。通常一个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号两个小球的状态。spa

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

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

总结一下,咱们用二进制的0和1表示一个二元集合的状态。能够简单认为某个物品存在或者不存在的状态。因为二进制的0和1能够转化成一个int整数,也就是说咱们用整数表明了一个集合的状态。这样一来,咱们能够用整数的加减计算来表明集合状态的变化

这也就是状态压缩的精髓,所谓的压缩,其实就是将一个集合压缩成了一个整数的意思,由于整数能够做为数组的下标,这样操做会方便咱们的编码。

旅行商问题

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

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

咱们来看下面这张图来直观地感觉一下:

假设咱们的商人从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问题,即逻辑电路问题。

下图是一个逻辑电路,假设咱们知道它的输出是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 = [[013], [125], [235], [343], [407], [416], [034], [204]]
    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 - ,决策带来的开销是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点的出发点,找到距离最近的那条。相比于上面的作法,这种作法更容易理解一些,可是代码多写几行,可是更容易理解一些。我建议若是直接理解第一段代码有困难的话,能够先搞懂第二段,而后再想明白为何第一段代码也成立。

总结

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

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

好了,今天的文章就是这些,若是以为有所收获,请顺手点个关注或者转发吧,大家的举手之劳对我来讲很重要。

相关文章
相关标签/搜索