7.6 迭代加深搜索

迭代加深搜索是一个应用范围很广的算法,不仅可以像回溯法那样找一个解,也可以像状态空间搜索那样找一条路径。下面先举一个经典的例子。

埃及分数问题。在古埃及,人们使用单位分数的和(即1/aa是自然数)表示一切有理数。例如,2/3=1/2+1/6,但不允许2/3=1/3+1/3,因为在加数中不允许有相同的。

对于一个分数a/b,表示方法有很多种,其中加数少的比加数多的好,如果加数个数相同,则最小的分数越大越好。例如,19/45=1/5+1/6+1/18是最优方案。

输入整数a,b(0<a<b<500),试编程计算最佳表达式。

样例输入:

495 499

样例输出:

Case 1: 495/499=1/2+1/5+1/6+1/8+1/3992+1/14970

【分析】

这道题目理论上可以用回溯法求解,但是解答树非常“恐怖”——不仅深度没有明显的上界,而且加数的选择在理论上也是无限的。换句话说,如果用宽度优先遍历,连一层都扩展不完(因为每一层都是无限大的)。

解决方案是采用迭代加深搜索(iterative deepening):从小到大枚举深度上限maxd,每次执行只考虑深度不超过maxd的结点。这样,只要解的深度有限,则一定可以在有限时间内枚举到。

提示7-17:对于可以用回溯法求解但解答树的深度没有明显上限的题目,可以考虑使用迭代加深搜索(iterative deepening)。

深度上限maxd还可以用来“剪枝”。按照分母递增的顺序来进行扩展,如果扩展到i层时,前i个分数之和为c/d,而第i个分数为1/e,则接下来至少还需要(a/b-c/d)/(1/e)个分数,总和才能达到a/b。例如,当前搜索到19/45=1/5+1/100+…,则后面的分数每个最大为1/101,至少需要(19/45-1/5) / (1/101) =23项总和才能达到19/45,因此前22次迭代是根本不会考虑这棵子树的。这里的关键在于:可以估计至少还要多少步才能出解。

注意,这里的估计都是乐观的,因为用了“至少”这个词。说得学术一点,设深度上限为maxd,当前结点n的深度为g(n),乐观估价函数为h(n),则当g(n)+h(n)>maxd时应该剪枝。这样的算法就是IDA*。当然,在实战中不需要严格地在代码里写出g(n)和h(n),只需要像刚才那样设计出乐观估价函数,想清楚在什么情况下不可能在当前的深度限制下出解即可。

提示7-18:如果可以设计出一个乐观估价函数,预测从当前结点至少还需要扩展几层结点才有可能得到解,则迭代加深搜索变成了IDA*算法。

本题的主框架就是一个简单循环:


int ok = 0;
for(maxd = 1; ; maxd++) {
  memset(ans, -1, sizeof(ans));
  if(dfs(0, get_first(a, b), a, b)) { ok = 1; break; }
}

其中get_first(a, b)是满足1/c≤a/b的最小c。迭代加深搜索过程如下(约分的原理详见第10章):


//如果当前解v比目前最优解ans更优,更新ans
bool better(int d) {
  for(int i = d; i >= 0; i--) if(v[i] != ans[i]) {
    return ans[i] == -1 || v[i] < ans[i];
  }
  return false;
}

//当前深度为d,分母不能小于from,分数之和恰好为aa/bb
bool dfs(int d, int from, LL aa, LL bb) {
  if(d == maxd) {
    if(bb % aa) return false; //aa/bb必须是埃及分数
    v[d] = bb/aa;
    if(better(d)) memcpy(ans, v, sizeof(LL) * (d+1));
    return true;
  }
  bool ok = false;
  from = max(from, get_first(aa, bb)); //枚举的起点
  for(int i = from; ; i++) {
    //剪枝:如果剩下的maxd+1-d个分数全部都是1/i,加起来仍然不超过aa/bb,则无解
    if(bb * (maxd+1-d) <= i * aa) break;
    v[d] = i;
    //计算aa/bb - 1/i,设结果为a2/b2
    LL b2 = bb*i;
    LL a2 = aa*i - bb;
    LL g = gcd(a2, b2); //以便约分
    if(dfs(d+1, i+1, a2/g, b2/g)) ok = true;
  }
  return ok;
}

例题7-10 编辑书稿(Editing a Book, UVa 11212)

你有一篇由n(2≤n≤9)个自然段组成的文章,希望将它们排列成1, 2,…, n。可以用Ctrl+X(剪切)和Ctrl+V(粘贴)快捷键来完成任务。每次可以剪切一段连续的自然段,粘贴时按照顺序粘贴。注意,剪贴板只有一个,所以不能连续剪切两次,只能剪切和粘贴交替。

例如,为了将{2,4,1,5,3,6}变为升序,可以剪切1将其放到2前,然后剪切3将其放到4前。再如,对于排列{3,4,5,1,2},只需一次剪切和一次粘贴即可——将{3,4,5}放在{1,2}后,或者将{1,2}放在{3,4,5}前。

【分析】

本题是典型的状态空间搜索问题,“状态”就是1~n的排列,初始状态是输入,终止状态是1, 2, 3,…, n。因为n≤9,排列最多有9!=362880个。虽然这个数字不算大,但是每个状态的后继状态也比较多(有很多剪切和粘贴的方式),所以仍有超时的危险。比赛时很多选手使用了一些“加速策略”。

策略1:每次只剪切一段连续的数字。例如,不要剪切2 4这样数字不连续的片段。

策略2:假设剪切片段的第一个数字为a,最后一个数字为b,要么把这个片段粘贴到a-1的下一个位置,要么粘贴到b+1的前一个位置。

策略3:永远不要“破坏”一个已经连续排列的数字片段。例如,不能把1 2 3 4中的2 3剪切出来。

3种策略都能缩小状态空间,但它们并不都是正确的。很多程序都无法得到“5 4 3 2 1”的正确结果(答案是3步而不是4步:5 4 3 2 1——>3 2 5 4 1→3 4 1 2 5→1 2 3 4 5),读者不妨自行验证上面的3种策略是否可以得到这组数据的正确答案。

本题可以用IDA*算法求解。不难发现n≤9时最多只需要8步,因此深度上限为8。IDA*的关键在于启发函数。考虑后继不正确的数字个数h,可以证明每次剪切时h最多减少3,因此当3d+h>3maxd时可以剪枝,其中d为当前深度,maxd为深度限制(3)

如何证明每次剪切时h最多减少3呢?如图7-19所示,因为最多只有3个数字的后继数字发生了改变(即图中的a, b, c),h自然最多减少3。

 

图7-19 h最多减少3