9.4 更多经典模型
本节介绍一些常见结构中的动态规划,序列、表达式、凸多边形和树。尽管它们的形式和解法千差万别,但都用到了动态规划的思想:从复杂的题目背景中抽象出状态表示,然后设计它们之间的转移。
9.4.1 线性结构上的动态规划
最长上升子序列问题(LIS)。给定n个整数A1, A2,…, An,按从左到右的顺序选出尽量多的整数,组成一个上升子序列(子序列可以理解为:删除0个或多个数,其他数的顺序不变)。例如序列1, 6, 2, 3, 7, 5,可以选出上升子序列1, 2, 3, 5,也可以选出1, 6, 7,但前者更长。选出的上升子序列中相邻元素不能相等。
【分析】
设d(i)为以i结尾的最长上升子序列的长度,则
,最终答案是max{d(i)}。如果LIS中的相邻元素可以相等,把小于号改成小于等于号即可。上述算法的时间复杂度为O(n2)。《算法竞赛入门经典》中介绍了一种方法把它优化到O(nlogn),有兴趣的读者可以自行阅读。
最长公共子序列问题(LCS)。给两个子序列A和B,如图9-7所示。求长度最大的公共子序列。例如1, 5, 2, 6, 8, 7和2, 3, 5, 6, 9, 8, 4的最长公共子序列为5, 6, 8(另一个解是2, 6, 8)。
图9-7 子序列A和B
【分析】
设d(i,j)为A1,A2,…,Ai和B1,B2,…,Bj的LCS长度,则当A[i]=A[j]时d(i,j)=d(i-1,j-1)+1,否则d(i,j)=max{d(i-1,j),d(i,j-1)},时间复杂度为O(nm),其中n和m分别是序列A和B的长度。
例题9-6 照明系统设计(Lighting System Design, UVa 11400)
你的任务是设计一个照明系统。一共有n(n≤1000)种灯泡可供选择,不同种类的灯泡必须用不同的电源,但同一种灯泡可以共用一个电源。每种灯泡用4个数值表示:电压值V(V≤132000),电源费用K(K≤1000),每个灯泡的费用C(C≤10)和所需灯泡的数量L(1≤L≤100)。
假定通过所有灯泡的电流都相同,因此电压高的灯泡功率也更大。为了省钱,可以把一些灯泡换成电压更高的另一种灯泡以节省电源的钱(但不能换成电压更低的灯泡)。你的任务是计算出最优方案的费用。
【分析】
首先可以得到一个结论:每种电压的灯泡要么全换,要么全不换。因为如果只换部分灯泡,如V=100有两个灯泡,把其中一个换成V=200的,另一个不变,则V=100和V=200两种电源都需要,不划算(若一个都不换则只需要V=100一种电源)。
先把灯泡按照电压从小到大排序。设s[i]为前i种灯泡的总数量(即L值之和),d[i]为灯泡1~i的最小开销,则d[i] = min{d[j] + (s[i]-s[j])*c[i] + k[i])},表示前j个先用最优方案买,然后第j+1~i个都用第i号的电源。答案为d[n]。
例题9-7 划分成回文串(Partitioning by Palindromes, UVa 11584)
输入一个由小写字母组成的字符串,你的任务是把它划分成尽量少的回文串。例如,racecar本身就是回文串;fastcar只能分成7个单字母的回文串,aaadbccb最少分成3个回文串:aaa, d, b ccb。字符串长度不超过1000。
【分析】
d[i]为字符0~i划分成的最小回文串的个数,则d[i] = min{d[j] + 1 | s[j+1~i]是回文串}。注意频繁的要判断回文串。状态O(n)个,决策O(n)个,如果每次转移都需要O(n)时间判断,总时间复杂度会达到O(n3)。
可以先用O(n2)时间预处理s[i..j]是否为回文串。方法是枚举中心,然后不断向左右延伸并且标记当前子串是回文串,直到延伸的左右字符不同为止(7)。这样一来,每次转移的时间降为了O(1),总时间复杂度为O(n2)。
例题9-8 颜色的长度(Color Length, ACM/ICPC Daejeon 2011, UVa1625)
输入两个长度分别为n和m(n,m≤5000)的颜色序列,要求按顺序合并成同一个序列,即每次可以把一个序列开头的颜色放到新序列的尾部。
例如,两个颜色序列GBBY和YRRGB,至少有两种合并结果:GBYBRYRGB和YRRGGBBYB。对于每个颜色c来说,其跨度L(c)等于最大位置和最小位置之差。例如,对于上面两种合并结果,每个颜色的L(c)和所有L(c)的总和如图9-8所示。
图9-8 每个颜色的L(c)和L(c)的总和
你的任务是找一种合并方式,使得所有L(c)的总和最小(8)。
【分析】
根据前面的经验,可以设d(i,j)表示两个序列已经分别移走了i和j个元素,还需要多少费用。等一下!什么叫“还需要多少费用”呢?本题的指标函数(即需要最小化的函数)比较复杂。当某颜色第一次出现在最终序列中时,并不知道它什么时候会结束;而某个颜色的最后一个元素已经移到最终序列里时,又“忘记”了它是什么时候第一次出现的。
怎么办呢?如果记录每个颜色的第一次出现位置,状态会变得很复杂,时间也无法承受,所以只能把在指标函数的“计算方式”上想办法:不是等到一个颜色全部移完之后再算,而是每次累加。换句话说,当把一个颜色移到最终序列前,需要把所有“已经出现但还没结束”的颜色的L(c)值加1。更进一步地,因为并不关心每个颜色的L(c),所以只需要知道有多少种颜色已经开始但尚未结束。
例如,序列GBBY和YRRGB,分别已经移走了1个和3个元素(例如,已经合并成了YRRG)。下次再从序列2移走一个元素(即G)时,Y和G需要加1。下次再从序列1移走一个元素(它是B)时,只有Y需要加1(因为G已经结束)。
这样,可以事先算出每个颜色在两个序列中的开始和结束位置,就可以在动态规划时在O(1)时间内计算出状态d(i,j)中“有多少个颜色已经出现但尚未结束”,从而在O(1)时间内完成状态转移。状态总是为O(nm)个,总时间复杂度也是O(nm)。
最优矩阵链乘。一个n×m矩阵由n行m列共个数排列而成。两个矩阵A和B可以相乘当且仅当A的列数等于B的行数。一个n×m的矩阵乘以一个m×p的矩阵等于一个的矩阵,运算量为mnp。
矩阵乘法不满足分配律,但满足结合律,因此A×B×C既可以按顺序(A×B)×C进行,也可以按A×(B×C)进行。假设A、B、C分别是2×3,3×4和4×5的,则(A×B)×C的运算量为2×3×4+2×4×5=64,A×(B×C)的运算量为3×4×5+2×3×5=90。显然第一种顺序节省运算量。
给出n个矩阵组成的序列,设计一种方法把它们依次乘起来,使得总的运算量尽量小。假设第i个矩阵Ai是
的。
【分析】
本题任务是设计一个表达式。在整个表达式中,一定有一个“最后一次乘法”。假设它是第k个乘号,则在此之前已经算出了
和
。由于P和Q的计算过程互不相干,而且无论按照怎样的顺序,P和Q的值都不会发生改变,因此只需分别让P和Q按照最优方案计算(最优子结构!)即可。为了计算P的最优方案,还需要继续枚举的“最后一次乘法”,把它分成两部分。不难发现,无论怎么分,在任意时候,需要处理的子问题都形如“把Ai,Ai+1,…,Aj乘起来需要多少次乘法?”如果用状态f(i, j)表示这个子问题的值,不难列出如下的状态转移方程:
边界为f(i, i)=0。上述方程有些特殊:记忆化搜索固然没问题,但如果要写成递推,无论按照i还是j的递增或递减顺序均不正确。正确的方法是按照j-i递增的顺序递推,因为长区间的值依赖于短区间的值。
最优三角剖分。对于一个n个顶点的凸多边形,有很多种方法可以对它进行三角剖分(triangulation),即用n-3条互不相交的对角线把凸多边形分成n-2个三角形。为每个三角形规定一个权函数w(i, j, k)(如三角形的周长或3个顶点的权和),求让所有三角形权和最大的方案。
【分析】
本题和最优矩阵链乘问题十分相似,但存在一个显著不同:链乘表达式反映了决策过程,而剖分不反映决策过程。举例来说,在链乘问题中,方案((A1A2)(A3(A4A5)))只能是先把序列分成A1A2和A3A4A5两部分,而对于一个三角剖分,“第一刀”可以是任何一条对角线,如图9-9所示。
如果允许随意切割,则“半成品”多边形的各个顶点是可以在原多边形中随意选取的,很难简洁定义成状态,而“矩阵链乘”就不存在这个问题——无论怎样决策,面临的子问题一定可以用区间表示。在这样的情况下,有必要把决策的顺序规范化,使得在规范的决策顺序下,任意状态都能用区间表示。
定义d(i, j)为子多边形i, i+1,…, j-1, j(i<j)的最优值,则边i-j在最优解中一定对应一个三角形i-j-k(i<k<j),如图9-10所示(注意顶点是按照逆时针编号的)。
因此,状态转移方程为:
时间复杂度为O(n3),边界为d(i,i+1)=0,原问题的解为d(0,n-1)。
| ![]() |
| 图9-9 难以简洁表示的状态 | 图9-10 定义的子多边形 |
例题9-9 切木棍(Cutting Sticks, UVa 10003)
有一根长度为L(L<1000)的棍子,还有n(n<50)个切割点的位置(按照从小到大排列)。你的任务是在这些切割点的位置处把棍子切成n+1部分,使得总切割费用最小。每次切割的费用等于被切割的木棍长度。例如,L=10,切割点为2, 4, 7。如果按照2, 4, 7的顺序,费用为10+8+6=24,如果按照4, 2, 7的顺序,费用为10+4+6=20。
【分析】
设d(i,j)为切割小木棍i~j的最优费用,则
,其中最后一项a[j]-a[i]代表第一刀的费用。切完之后,小木棍变成i~k和k~j两部分,状态转移方程由此可得。把切割点编号为1~n,左边界编号为0,右边界编号为n+1,则答案为d(0,n+1)。
状态有O(n2)个,每个状态的决策有O(n)个,时间复杂度为O(n3)。值得一提的是,本题可以用四边形不等式优化到O(n2),有兴趣的读者请参见本书的配套《算法竞赛入门经典——训练指南》或其他参考资料。
例题9-10 括号序列(Brackets Sequence, NEERC 2001, UVa1626)
定义如下正规括号序列(字符串):
例如,下面的字符串都是正规括号序列:(),[],(()),([]),()[],()[()],而如下字符串则不是正规括号序列:(,[,],)(,([()。
输入一个长度不超过100的,由“(”、“)”、“[”、“]”构成的序列,添加尽量少的括号,得到一个规则序列。如有多解,输出任意一个序列即可。
【分析】
设串S至少需要增加d(S)个括号,转移如下:
边界是:S为空时d(S)=0,S为单字符时d(S)=1。注意(S′, [S′, ) S′之类全部属于第二种转移,不需要单独处理。
注意:不管S是否满足第一条,都要尝试第二种转移,否则“[][]”会转移到“][”,然后就只能加两个括号了。
当然,上述“方程”只是概念上的,落实到程序时要改成子串在原串中的起始点下标,即用d(i,j)表示子串S[i~j]至少需要添加几个括号。下面是递推写法,比记忆化写法要快好几倍,而且代码更短。请读者注意状态的枚举顺序:
void dp() {
for(int i = 0; i < n; i++) {
d[i+1][i] = 0;
d[i][i] = 1;
}
for(int i = n-2; i >= 0; i——)
for(int j = i+1; j < n; j++) {
d[i][j] = n;
if(match(S[i], S[j])) d[i][j] = min(d[i][j], d[i+1][j-1]);
for(int k = i; k < j; k++)
d[i][j] = min(d[i][j], d[i][k] + d[k+1][j]);
}
}
本题需要打印解,但是上面的代码只计算了d数组,如何打印解呢?可以在打印时重新检查一下哪个决策最好。这样做的好处是节约空间,坏处是打印时代码较复杂,速度稍慢,但是基本上可以忽略不计(因为只有少数状态需要打印)。
void print(int i, int j) {
if(i > j) return ;
if(i == j) {
if(S[i] == '(' || S[i] == ')') printf("()");
else printf("[]");
return;
}
int ans = d[i][j];
if(match(S[i], S[j]) && ans == d[i+1][j-1]) {
printf("%c", S[i]); print(i+1, j-1); printf("%c", S[j]);
return;
}
for(int k = i; k < j; k++)
if(ans == d[i][k] + d[k+1][j]) {
print(i, k); print(k+1, j);
return;
}
}
本题唯一的陷阱是:输入串可能是空串,因此不能用scanf("%s", s)的方式输入,只能用getchar、fgets或者getline。
例题9-11 最大面积最小的三角剖分(Minimax Triangulation, ACM/ICPC NWERC 2004, UVa1331)
三角剖分是指用不相交的对角线把一个多边形分成若干个三角形。如图9-11所示是一个六边形的几种不同的三角剖分。
图9-11 六边形的不同三角部分
输入一个简单m(2<m<50)边形,找一个最大三角形面积最小的三角剖分。输出最大三角形的面积。在图9-11的5个方案中,最左边(即左下角)的方案最优。
【分析】
本题的程序实现要用到一些计算几何的知识,不过基本思想是清晰的:首先考虑凸多边形的简单情况。和“最优三角剖分”一样,设d(i,j)为子多边形i,i+1,…,j-1,j(i<j)的最优解,则状态转移方程为d(i,j)= min{S(i,j,k), d(i,k), d(k,j) | i<k<j},其中S(i,j,k)为三角形i-j-k的面积。
回到原题。需要保证边i-j是对角线(9)(唯一的例外是i=0且j=n-1),具体方法是当边i-j不满足条件时直接设d(i,j)为无穷大,其他部分和凸多边形的情形完全一样。
9.4.2 树上的动态规划
树的最大独立集。对于一棵n个结点的无根树,选出尽量多的结点,使得任何两个结点均不相邻(称为最大独立集),然后输入n-1条无向边,输出一个最大独立集(如果有多解,则任意输出一组)。
【分析】
用d(i)表示以i为根结点的子树的最大独立集大小。此时需要注意的是,本题的树是无根的:没有所谓的“父子”关系,而只有一些无向边。没关系,只要任选一个根r,无根树就变成了有根树,上述状态定义也就有意义了。
结点i只有两种决策:选和不选。如果不选i,则问题转化为了求出i的所有儿子的d值再相加;如果选i,则它的儿子全部不能选,问题转化为了求出i的所有孙子的d值之和。换句话说,状态转移方程为:

其中,gs(i)和s(i)分别为i的孙子集合与儿子集合,如图9-12所示。
代码应如何编写呢?上面的方程涉及“枚举结点i的所有儿子和所有孙子”,颇为不便。其实可以换一个角度来看:不从i找s(i)和gs(i)的元素,而从s(i)和gs(i)的元素找i。换句话说,当计算出一个d(i)后,用它去更新i的父亲和祖父结点的累加值
和
。这样一来,每个结点甚至不必记录其子结点有哪些,只需记录父结点即可。这就是前面提过的“刷表法”。不过这个问题还有另外一种解法,在实践中更加常用,将在例题部分介绍。
树的重心(质心)。对于一棵n个结点的无根树,找到一个点,使得把树变成以该点为根的有根树时,最大子树的结点数最小。换句话说,删除这个点后最大连通块(一定是树)的结点数最小。
【分析】
和树的最大独立集问题类似,先任选一个结点作为根,把无根树变成有根树,然后设d(i)表示以i为根的子树的结点个数。不难发现
。程序实现也很简单:只需要一次DFS,在无根树转有根树的同时计算即可,连记忆化都不需要——因为本来就没有重复计算。
那么,删除结点i后,最大的连通块有多少个结点呢?结点i的子树中最大的有max{d(j)}个结点,i的“上方子树”中有n-d(i)个结点,如图9-13所示。这样,在动态规划的过程中就可以顺便找出树的重心了。
| ![]() |
| 图9-12 结点i的gs(i)(浅灰色)和s(i )(深灰色) | 图9-13 树中的结点分布 |
树的最长路径(最远点对)。对于一棵n个结点的无根树,找到一条最长路径。换句话说,要找到两个点,使得它们的距离最远。
【分析】
和树的重心问题一样,先把无根树转成有根树。对于任意结点i,经过i的最长路就是连接i的两棵不同子树u和v的最深叶子的路径,如图9-14所示。
图9-14 子树u和v的最深叶子路径
设d(i)表示根为结点i的子树中根到叶子的最大距离,不难写出状态转移方程:d(i)=max{d(j)+1}。对于每个结点i,把所有子结点的d(j)都求出来之后,设d值前两大的结点为u和v,则d(u)+d(v)+2就是所求。
本题还有一个不用动态规划的解法:随便找一个结点u,用DFS求出u的最远结点v,然后再用一次DFS求出v的最远结点w,则v-w就是最长路径。
结合上述两个问题的解法,可以解决下面的问题:对于一棵n个结点的无根树,求出每个结点的最远点,要求时间复杂度为O(n)。这个问题留给读者思考。
例题9-12 工人的请愿书(Another Crisis, UVa 12186)
某公司里有一个老板和n(n≤105)个员工组成树状结构,除了老板之外每个员工都有唯一的直属上司。老板的编号为0,员工编号为1~n。工人们(即没有直接下属的员工)打算签署一项请愿书递给老板,但是不能跨级递,只能递给直属上司。当一个中级员工(不是工人的员工)的直属下属中不小于T%的人签字时,他也会签字并且递给他的直属上司。问:要让公司老板收到请愿书,至少需要多少个工人签字?
【分析】
设d(u)表示让u给上级发信最少需要多少个工人。假设u有k个子结点,则至少需要c=(kT-1)/100+1个直接下属发信才行。把所有子结点的d值从小到大排序,前c个加起来即可。最终答案是d(0)。因为要排序,算法的时间复杂度为O(nlogn)。动态规划部分代码如下:
vector<int> sons[maxn]; //sons[i]为结点i的子列表
int dp(int u) {
if(sons[u].empty()) return 1;
int k = sons[u].size();
vector<int> d;
for(int i = 0; i < k; i++)
d.push_back(dp(sons[u][i]));
sort(d.begin(), d.end());
int c = (k*T - 1) / 100 + 1;
int ans = 0;
for(int i = 0; i < c; i++) ans += d[i];
return ans;
}
例题9-13 Hali-Bula的晚会(Party at Hali-Bula, ACM/ICPC Tehran 2006, UVa1220)
公司里有n(n≤200)个人形成一个树状结构,即除了老板之外每个员工都有唯一的直属上司。要求选尽量多的人,但不能同时选择一个人和他的直属上司。问:最多能选多少人,以及在人数最多的前提下方案是否唯一。
【分析】
本题几乎就是树的最大独立集问题,不过多了一个要求:判断唯一性。设:
例题9-14 完美的服务(Perfect Service, ACM/ICPC Kaoshiung 2006, UVa1218)
有n(n≤10000)台机器形成树状结构。要求在其中一些机器上安装服务器,使得每台不是服务器的计算机恰好和一台服务器计算机相邻。求服务器的最少数量。如图9-15所示,图9-15(a)是非法的,因为4同时和两台服务器相邻,而6不与任何一台服务器相邻。而图9-15(b)是合法的。
| ![]() |
| (a) | (b) |
图9-15 非法与合法的树状结构
【分析】
有了前面的经验,这次仍然按照每个结点的情况进行分类。
状态转移比前面复杂一些,但也不困难。首先可以写出:
d(u,0) = sum{min(d(v,0), d(v,1))} + 1
d(u,1) = sum(d(v,2))
而d(u,2)稍微复杂一点,需要枚举当服务器的子结点编号v,然后把其他所有子结点v'的d(v',2)加起来,再和d(v,0)相加。不过如果这样做,每次枚举v都需要O(k)时间(其中k是u的子结点数目),而v本身要枚举k次,因此计算d(u,2)需要花O(k2)时间。
刚才的做法有很多重复计算,其实可以利用已经算出的d(u,1)写出一个新的状态转移方程:
d(u,2) = min(d(u,1) – d(v,2) + d(v,0))
这样一来,计算d(u,2)的时间复杂度变为了O(k)。因为每个结点只有在计算父亲时被用了3次,总时间复杂度为O(n)。
9.4.3 复杂状态的动态规划
最优配对问题。空间里有n个点P0, P1,…, Pn-1,你的任务是把它们配成n/2对(n是偶数),使得每个点恰好在一个点对中。所有点对中两点的距离之和应尽量小。n≤20, |xi|,|yi|,|zi|≤10000。
【分析】
既然每个点都要配对,很容易把问题看成如下的多阶段决策过程:先确定P0和谁配对,然后是P1,接下来是P2,……,最后是Pn-1。按照前面的思路,设d(i)表示把前i个点两两配对的最小距离和,然后考虑第i个点的决策——它和谁配对呢?假设它和点j配对(j<i),那么接下来的问题应是“把前i-1个点中除了j之外的其他点两两配对”,它显然无法用任何一个d值来刻画——此处的状态定义无法体现出“除了一些点之外”这样的限制。
当发现状态无法转移后,常见的方法是增加维度,即增加新的因素,更细致地描述状态。既然刚才提到了“除了某些元素之外”,不妨把它作为状态的一部分,设d(i,S)表示把前i个点中,位于集合S中的元素两两配对的最小距离和,则状态转移方程为:

其中,|PiPj|表示点Pi和Pj之间的距离。方程看上去很不错,但实现起来有问题:如何表示集合S呢?由于它要作为数组d中的第二维下标,所以需要用整数来表示集合,确切地说,是{0, 1, 2,…,n-1}的任意子集(subset)。
在第7章的“子集枚举”部分,曾介绍过子集的二进制表示,现在再次用到此知识:
for(int i = 0; i < n; i++)
for(int S = 0; S < (1<<n); S++) {
d[i][S] = INF;
for(int j = 0; j < i; j++) if(S & (1<<j))
d[i][S] = max(d[i][S], dist(i, j) + d[i-1][S^(1<<i)^(1<<j)]);
}
上述程序中故意用了很多括号,传达给读者的信息是:位运算的优先级低,初学者很容易弄错。例如,“1<<n-1”的正确解释是“1<<(n-1)”,因为减法的优先级比左移要高。为了保险起见,应多用括号。另一个技巧是利用C语言中“0为假,非0为真”的规定简化表达式:“if(S & (1<<j))”的实际含义是“if((S & (1<<j)) != 0)”。
提示9-19:位运算的优先级往往比较低。如果不确定表达式的计算顺序,应多用括号。
由于大量使用了形如1<<n的表达式,此类表达式中,左移运算符“<<”的含义是“把各个位往左移动,右边补0”。根据二进制运算法则,每次左移一位就相当于乘以2,因此a<<b相当于a*2b,而在集合表示法中,1<lt;i代表单元素集合{i}。由于0表示空集,“S & (1<<j)”不等于0就意味着“S和{j}的交集不为空”。
上面的方程可以进一步简化。事实上,阶段i根本不用保存,它已经隐含在S中了——S中的最大元素就是i。这样,可直接用d(S)表示“把S中的元素两两配对的最小距离和”,则状态转移方程为:
d(S)=min{|PiPj|+d(S-{i}-{j})|j∈S, i=max{S}}
状态有2n个,每个状态有O(n)种转移方式,总时间复杂度为O(n2n)。
提示9-20:如果用二进制表示子集并进行动态规划,集合中的元素就隐含了阶段信息。例如,可以把集合中的最大元素想象成“阶段”。
值得一提的是,不少用户一直在用这样的状态转移方程:
d(S)=min{|PiPj|+d(S-{i}-{j})|i, j∈S, }
它和刚才的方程很类似,唯一的不同是:i和j都是需要枚举的。这样做虽然也没错,但每个状态的转移次数高达O(n2),总时间复杂度为O(n22n),比刚才的方法慢。这个例子再次说明:即使用相同的状态描述,减少决策也是很重要的。
提示9-21:即使状态定义相同,过多地考虑不必要的决策仍可能会导致时间复杂度上升。
接下来出现了一个新问题:如何求出S中的最大元素呢?用一个循环判断即可。当S取遍{0, 1, 2,…, n-1}的所有子集时,平均判断次数仅为2(想一想,为什么)。
for(int S = 0; S < (1<<n); S++) {
int i, j;
d[S] = INF;
for(i = 0; i < n; i++)
if(S & (1<<i)) break;
for(j = i+1; j < n; j++)
if(S & (1<<j)) d[S] = max(d[S], dist(i, j) + d[S^(1>>i)^(1>>j)]);
}
注意,在上述的程序中求出的i是S中的最小元素,而不是最大元素,但这并不影响答案。另外,j的枚举只需从i+1开始——既然i是S中的最小元素,则说明其他元素自然均比i大。最后需要说明的是S的枚举顺序。不难发现:如果S'是S的真子集,则一定有S'<S,因此若以S递增的顺序计算,需要用到某个d值时,它一定已经计算出来了。
提示9-22:如果S'是S的真子集,则一定有S'<S。在用递推法实现子集的动态规划时,该规则往往可以确定计算顺序。
货郎担问题(TSP)。有n个城市,两两之间均有道路直接相连。给出每两个城市i和j之间的道路长度Li,j,求一条经过每个城市一次且仅一次,最后回到起点的路线,使得经过的道路总长度最短。N≤15,城市编号为0~n-1。
【分析】
TSP是一道经典的NPC难题(10),不过因为本题规模小,可以用动态规划求解。首先注意到可以直接规定起点和终点为城市0(想一想,为什么),然后设d(i,S)表示当前在城市i,还需访问集合S中的城市各一次后回到城市0的最短长度,则
d(i, S)=min{d(j, S-{j}+dist(i, j))|j∈S}
边界为d(i,{})=dist(0,i)。最终答案是d(0,{1,2,3,…,n-1}),时间复杂度为O(n22n)。
图的色数。图论有一个经典问题是这样的:给一个无向图G,把图中的结点染成尽量少的颜色,使得相邻结点颜色不同。
【分析】
设d(S)表示把结点集S染色,所需要颜色数的最小值,则d(S)=d(S-S')+1,其中S'是S的子集,并且内部没有边(即不存在S'内的两个结点u和v使得u和v相邻)。换句话说,S'是一个“可以染成同一种颜色”的结点集。
首先通过预处理保存每个结点集是否可以染成同一种颜色(即“内部没有边”),则算法的主要时间取决于“高效的枚举一个集合S的所有子集”。
如何枚举S的子集呢?详见下面的代码(代码中的S0就是上面的S'):
d[0] = 0;
for(int S = 1; S < (1<<n); S++) {
d[S] = INF;
for(int S0 = S; S0; S0 = (S0-1)&S)
if(no_edges_inside[S0]) d[S] = min(d[S], d[S-S0]+1);
}
如何分析上述算法的时间复杂度?它等于全集{1, 2,…, n}的所有子集的“子集个数”之和。如果不好理解,可以令c(S)表示集S的子集的个数(它等于2|S|),则本题的时间复杂度为sum{c(S0) | S0是{1,2,3,…,n}的子集}。元素个数相同的集合,其子集个数也相同,可以按照元素个数“合并同类项”。元素个数为k的集合有C(n,k)个,其中每个集合有2k个子集,因此本题的时间复杂度为sum{C(n,k)2k}=(2+1)n=3n,其中第一个等号用到了第10章即将学到的二项式定理(不过是“反着”用的)。
提示9-23:枚举1~n的每个集合S的所有子集的总时间复杂度为O(3n)。
例题9-15 校长的烦恼(Headmaster's Headache, UVa 10817)
某校有m个教师和n个求职者,需讲授s个课程(1≤s≤8,1≤m≤20,1≤n≤100)。已知每人的工资c(10000≤c≤50000)和能教的课程集合,要求支付最少的工资使得每门课都至少有两名教师能教。在职教师不能辞退。
【分析】
本题的做法有很多。一种相对容易实现的方法是:用两个集合s1表示恰好有一个人教的科目集合,s2表示至少有两个人教的科目集合,而d(i,s1,s2)表示已经考虑了前i个人时的最小花费。注意,把所有人一起从0编号,则编号0~m-1是在职教师,m~n+m-1是应聘者。状态转移方程为d(i,s1,s2) = min{d(i+1, s1', s2')+c[i], d(i+1, s1, s2)},其中第一项表示“聘用”,第二项表示“不聘用”。当i≥m时状态转移方程才出现第二项。这里s1'和s2'分别表示“招聘第i个人之后s1和s2的新值”,具体计算方法见代码。
下面代码中的st[i]表示第i个人能教的科目集合(注意输入中科目从1开始编号,而代码的其他部分中科目从0开始编号,因此输入时要转换一下)。下面的代码用到了一个技巧:记忆化搜索中有一个参数s0,表示没有任何人能教的科目集合。这个参数并不需要记忆(因为有了s1和s2就能算出s0),仅是为了编程的方便(详见s1'和s2'的计算方式)。最终结果是dp(0, (1<s)-1, 0, 0),因为初始时所有科目都没有人教。
int m, n, s, c[maxn], st[maxn], d[maxn][1<<maxs][1<<maxs];
int dp(int i, int s0, int s1, int s2) {
if(i == m+n) return s2 == (1<<s) - 1 ? 0 : INF;
int& ans = d[i][s1][s2];
if(ans >= 0) return ans;
ans = INF;
if(i >= m) ans = dp(i+1, s0, s1, s2); //不选
int m0 = st[i] & s0, m1 = st[i] & s1;
s0 ^= m0; s1 = (s1 ^ m1) | m0; s2 |= m1;
ans = min(ans, c[i] + dp(i+1, s0, s1, s2)); //选
return ans;
}
本题还有其他解法,例如,分别用0,1,2表示每个科目是没人教、恰好一个人教和至少两个人教,这样就可以用一个三进制数来保存状态,而不是两个集合。不过这样做编程稍微麻烦一些,而且时间效率差不多(在上面的代码中,虽然d数组有4s个元素,但因为记忆化的关系,只用到了3s个)。
例题9-16 20个问题(Twenty Questions, ACM/ICPC Tokyo 2009, UVa1252)
有n(n≤128)个物体,m(m≤11)个特征。每个物体用一个m位01串表示,表示每个特征是具备还是不具备。我在心里想一个物体(一定是这n个物体之一),由你来猜。
你每次可以询问一个特征,然后我会告诉你:我心里的物体是否具备这个特征。当你确定答案之后,就把答案告诉我(告知答案不算“询问”)。如果你采用最优策略,最少需要询问几次能保证猜到?
例如,有两个物体:1100和0110,只要询问特征1或者特征3,就能保证猜到。
【分析】
为了叙述方便,设“心里想的物体”为W。首先在读入时把每个物体转化为一个二进制整数。不难发现,同一个特征不需要问两遍,所以可以用一个集合s表示已经询问的特征集。在这个集合s中,有些特征是W所具备的,剩下的特征是W不具备的。用集合a来表示“已确认物体W具备的特征集”,则a一定是s的子集。
设d(s,a)表示已经问了特征集s,其中已确认W所具备的特征集为a时,还需要询问的最小次数。如果下一次提问的对象是特征k(这就是“决策”),则询问次数为:
max{d(s+{k},a+{k}),d(s+{k}, a)}+1
考虑所有的k,取最小值即可。边界条件为:如果只有一个物体满足“具备集合a中的所有特征,但不具备集合s-a中的所有特征”这一条件,则d(s,a)=0,因为无须进一步询问,已经可以得到答案。
因为a为s的子集,所以状态总数为3m,时间复杂度为O(m*3m)。对于每个s和a,可以先把满足该条件的物体个数统计出来,保存在cnt[s][a],避免状态转移的时候重复计算。统计cnt[s][a]的方法是枚举s和物体,时间复杂度为O(n*2m),所以总时间复杂度为O(n*2m + m*3m)。对于本题的规模来说O(n*2m)可以忽略不计。
例题9-17 基金管理(Fund Management, ACM/ICPC NEERC 2007, UVa1412)
你有c(0.01≤c≤108)美元现金,但没有股票。给你m(1≤m≤100)天时间和n(1≤n≤8)支股票供你买卖,要求最后一天结束后不持有任何股票,且剩余的钱最多。买股票不能赊账,只能用现金买。
已知每只股票每天的价格(0.01~999.99。单位是美元/股)与参数si和ki,表示一手股票是si(1≤si≤106)股,且每天持有的手数不能超过ki(1≤ki≤k),其中k为每天持有的总手数上限。每天要么不操作,要么选一只股票,买或卖它的一手股票。c和股价均最多包含两位小数(即美分)。最优解保证不超过109。要求输出每一天的决策(HOLD表示不变,SELL表示卖,BUY表示买)。
【分析】
根据前面的经验,可以用d(i,p)表示经过i天之后,资产组合为p时的现金的最大值。其中p是一个n元组,pi≤ki表示第i只股票有pi手。根据题目规定,p1+…+pn≤k。因为0≤pi≤8,理论上最多只有98<5*107种可能,所以可以用一个九进制整数来表示p。
一共有3种决策:HOLD、BUY和SELL,分别进行转移即可。注意在考虑购买股票时不要忘记判断当前拥有的现金是否足够。细心的读者可能已经发现:正因为如此,本题并不是一个标准的DAG最长/短路问题,因为某些边u->v的存在性依赖于起点到u的最短路值。也就是说,本题的状态不能像之前的DAG问题一样“反着定义”:如果用d(i,p)表示资产组合为p,从第i天开始到最后能拥有的现金的最大值,就没法转移了(想一想,为什么)。
这样的做法虽然不错(11),但是效率却不够高,因为九进制整数无法直接进行“买卖股票”的操作,需要解码成n元组才行。因为几乎每次状态转移都会涉及编码、解码操作,状态转移的时间大幅度提升,最终导致超时。
解决方法是事先计算出所有可能的状态并且编号(还记得第5章中的“集合栈计算机”吗?),代码如下:
vector<vector<int> > states;
map<vector<int>, int> ID;
void dfs(int stock, vector<int>& lots, int totlot) {
if(stock == n) {
ID[lots] = states.size();
states.push_back(lots);
}
else for(int i = 0; i <= k[stock] && totlot + i <= kk; i++) {
lots[stock] = i;
dfs(stock+1, lots, totlot + i);
}
}
然后构造一个状态转移表,用buy_next[s][i]和sell_next[s][i]分别表示状态s进行“买股票i”和“卖股票i”之后转移到的状态编号,代码如下:
int buy_next[maxstate][maxn], sell_next[maxstate][maxn];
void init() {
vector<int> lots(n);
states.clear();
ID.clear();
dfs(0, lots, 0);
for(int s = 0; s < states.size(); s++) {
int totlot = 0;
for(int i = 0; i < n; i++) totlot += states[s][i];
for(int i = 0; i < n; i++) {
buy_next[s][i] = sell_next[s][i] = -1;
if(states[s][i] < k[i] && totlot < kk) {
vector<int> newstate = states[s];
newstate[i]++;
buy_next[s][i] = ID[newstate];
}
if(states[s][i] > 0) {
vector<int> newstate = states[s];
newstate[i]——;
sell_next[s][i] = ID[newstate];
}
}
}
}
动态规划主程序采用刷表法(读者也可以试着改成倒推的填表法),为了方便起见,另外编写了“更新状态”的函数update,读者可以自行体会它的好处。为了打印解,在更新解d时还要更新最优策略opt和“上一个状态”prev。注意下面的price[i][day]表示第day天时一手股票i的价格,而不是输入中的“每股价格”。
double d[maxm][maxstate];
int opt[maxm][maxstate], prev[maxm][maxstate];
void update(int day, int s, int s2, double v, int o) {
if(v > d[day+1][s2]) {
d[day+1][s2] = v;
opt[day+1][s2] = o;
prev[day+1][s2] = s;
}
}
double dp() {
for(int day = 0; day <= m; day++)
for(int s = 0; s < states.size(); s++) d[day][s] = -INF;
d[0][0] = c;
for(int day = 0; day < m; day++)
for(int s = 0; s < states.size(); s++) {
double v = d[day][s];
if(v < -1) continue;
update(day, s, s, v, 0); //HOLD
for(int i = 0; i < n; i++) {
if(buy_next[s][i] >= 0 && v >= price[i][day] - 1e-3)
update(day, s, buy_next[s][i], v - price[i][day], i+1); //BUY
if(sell_next[s][i] >= 0)
update(day, s, sell_next[s][i], v + price[i][day], -i-1); //SELL
}
}
return d[m][0];
}
最后是打印解的部分。因为状态从前到后定义,因此打印解时需要从后到前打印,用递归比较方便。
void print_ans(int day, int s) {
if(day == 0) return;
print_ans(day-1, prev[day][s]);
if(opt[day][s] == 0) printf("HOLD\n");
else if(opt[day][s] > 0) printf("BUY %s\n", name[opt[day][s]-1]);
else printf("SELL %s\n", name[-opt[day][s]-1]);
}