一、剪枝
1.可行性剪枝
若当前情况已经不符合题意则可以直接返回,而不需要走到头。
例如,若搜索的第二个数已经不满足和第一个数匹配,那就没有必要继续搜索第三个数了。
2.最优性剪枝
若当前情况已经劣于已经找到的最优解时,直接返回。
例如,若当前搜到的路径已经比 ans 大了,就不用继续搜了。
3.搜索顺序剪枝
虽然从某种意义上说,这仅仅是“骗过了数据”。
但是不可否认的是大多情况下,改变搜索顺序可以优化搜索效率。
4.Alpha-Beta
为一种固定化的剪枝手段。
可自行了解。
略
5.人类智慧
以上剪枝策略只是形式化的剪枝策略。在具体实现中还需要利用人类智慧来进行剪枝:
• 极端法:考虑极端情况,如果最极端的情况都不满足,那一定不满足。
• 调整法:通过对子树的对比剪掉重复子树和明显不是最优的子树。
• 数学方法:比如在图论中借助连通分量,数论中借助模方程的分析,借助不等式的放缩来估计下界等等。
在实际应用中,往往需要选手自行根据题目发扬剪枝艺术。并没有一个固定的写法。
二、搜索状态的设计
虽然搜索是遍历所有的解空间。但对于不同的状态设计,会呈现出不同的解空间。
同时,在实际应用中,根据题目需要,还可以去掉一些无用的状态(剪枝)。
不同的状态定义所形成的搜索树也不尽相同。
通过巧妙的状态定义,也可更好地解决问题。
同时,虽然搜索是遍历所有解空间,但是时间复杂度并不是解空间大小。也不是状态数大小。因为在的搜索过程中,访问到搜索树上同一个节点时,会继续往下考虑,将搜过的内容再搜一遍。
仍以例 1 为例。看这样一组数据:
2 3 3 2 1 4
对于 (3, 5) 这样的状态,能被多少个状态转移而来:
(2, 2),(2, 3),(2, 1),(2, 4)。
而 (3, 5) 接下去的状态,(2, 2),(2, 3),(2, 1),(2, 4) 都会再考虑一遍。
三、记忆化搜索
记忆化搜索是一种通过记录已经遍历过的状态的信息,从而避免对同一状态重复遍历的搜索实现方式。
与其说是记忆化搜索,笔者更愿称之为记忆化递归。通过递归将一个大的问题变成一个个小问题,然后拼接起来。
例 3
给定 10 个数,选出 3 个数的和小于 1000 且最大。
容易发现仅仅是三个数要满足的限制变了,所以按例 1 的DFS 写法把 check 函数变一变即可。但是这样无法记忆化的。因为它每一个状态维护的是到起点的结果,而不是到终点。既然是到终点,那么 sum 就不能直接维护,而是要通过函数递归返回值求得:
int dfs(int x)
{
if (x > 3) return 0;
int res = 0;
for (int i = 1; i <= n; i++)
{
if (!vis[i])
{
vis[i] = 1;
if (dfs(x + 1) + a[i] < 1000)
res = max(res, dfs(x + 1) + a[i]);
vis[i] = 0;
}
}
return res;
}
记忆化搜索因为使用以往搜到是结果,那么就要保证现在到这一步时确确实实能按以往的结果做选择。如果到这一步之前的选择的不同影响了后续的可选择性,那自然是不能用之前的结果的,也就是满足“子问题”。
所以上述代码中仅仅用 (x) 描述状态并不是一个子问题。所以需要对状态设计进行修改,使得一个状态描述确确实实是一个子问题。
状态可以修改为 (i, x) 最后选的一个数是第 i 个数,已经选了 x 个数。因为限制死了后续只能选择 i + 1, i + 2, ...n 中的数,那么就保证了遇到 (i, x) 这个状态时,后续的可选择性是一致的。
因为只需要在一般搜索的基础上进行记录状态结果的行为,所以记忆化搜索算是一种优化思想。体现在代码上也并不复杂。
核心变为如何存储状态信息:
一般而言,若状态信息并不复杂(主要是数值不大)那么便可以直接用数组进行存储,将状态信息记录为数组下标。
int dfs(int _i,int x)
{
if (_i > n) return;
if (x > 3) return 0;
if (vis[_i][x]) return mem[_i][x];
int res = 0;
for (int i = _i + 1; i <= n; i++)
if (dfs(i + 1, x + 1) + a[i] < 1000)
res = max(res, dfs(i + 1, x + 1) + a[i]);
vis[_i][x] = 1;//标记已使用
mem[_i][x] = res;//储存结果
}
BFS 因为不搜到底,并不能利用已经到过的状态(就算状态是到过的,但是没有它到终点的结果)。所以 BFS 不存在记忆化搜索的写法。
事实上,问题的 sum 一般都会比较大,不能作为数组下标存储。但是我们可以使用 map 将状态和状态的结果视作一个映射,存起来。
因为记忆化搜索确保了每个状态只访问一次,它也是一种常见的动态规划实现方式
为什么要用例 3 记忆化而不是例 1?
因为例 1 根本无法简单地记忆化,因为例 1 的后续的选择是和前面所有选的具体数字是有关的,而不是仅通过和就可以判断是否能选的(例 3 中的 check 只关心和的大小,不关心具体选
的数;例 1 中的 check 需要关心所有选择的数)。同一个状态描述对应的不同实际状态的后续操作不尽相同。比如例 1 中,选择2, 3 或 1, 4 都对应 (3, 5) 的状态描述,但在选择第三个数时,他
们的选择是不一样的。所以例 1 中的状态不足以支撑记忆化的过程。
但是观察会发现,其实不是状态不行,而是状态描述不行。选择的数在例 1 中是要考虑到状态描述中去的,那么在状态描述中加上选择的数即可。但是这样产生的问题就是状态不易存储。我们需要更巧妙的状态描述。本文不再赘述。
四、搜索方式
1.双向搜索
1.1 同时双向搜索
同时从起点和终点开始搜索。往往采用 BFS 实现。
把起点和终点都加入初始队列。
在扩展过程中,把由起点扩展的一系列点标记为 1,把由终点扩展的一系列点标记为 2。当 1 能扩展到 2,或 2 能扩展到 1时,表示找到解。
由于起点和终点交题前进,所以对于起点和终点,各自的深度只有原规模的一半,那么状态规模的增长也只有原来的一半。
1.2 meet in middle
将原问题划分成两部分分别处理,后将两部分的结果合并。meet in middle 往往用于处理序列问题,把序列划分成左右两部分。
例 4:给定 40 个数,求选出若干个数的和不超过 109 的方案数。
直接对 40 个数搜索所有解的时间复杂度为:O()。
若将序列划分成两个长度为 20 的序列,首先单独处理的时间复杂度为:O()。
之后,对于左半部分的某一个解,在右半部分中找到之和不超过 109 的方案数。即为在若干个数中求不超过某值的数的个数,可以使用二分解决(upper_bound)。
总时间复杂度:O(20 × )。
一般而言,决定是否能双向搜索的均为两部分信息是否能合并。
若原问题状态规模为 O(),双向搜索状态规模降为 O(
)
2.特殊DFS
2.1 迭代加深搜索
每次限制递归层数的 DFS。
每进行一次 DFS,递归深度限制增大 1。
避免了 DFS 的递归层数过多;也避免了 BFS 在同一时刻状态数过多。
但存在重复搜索前缀的缺点,不过由于搜索树的规模增长一般为指数,深度增大一层的数据规模可能会超过前面所有层的数据规模之和。所以影响并不是那么大。
2.2 IDA*
A* 和迭代加深的结合。
3.特殊BFS
3.1 01 BFS
在前文中,提到 BFS 主要用于解决边权为 1 的最短路径问题。但是注意到 BFS 过程中队列内是有两个距离值的,队尾比队首大 1,边权为 1 时每次扩展加在队尾。如果边权还存在 0 的话,则加在队首。其它部分与 BFS 完全相同。
3.2 优先队列 BFS
在前文中,提到 BFS 如果处理边权不为 1 的问题,那便没有第一次出队即为最小值的性质了。究其本质,是因为队列内元素不呈现单调性了。但是可以“强行”让队列内元素呈现单调性。即:采用优先队列替换队列实现。
但若是边权存在负值,那仍然不满足第一次出队时为最小值(不能使用 vis 数组)
原因显然。
3.3 A*
A* 是基于 BFS 的改进。
定义从 x 到起点的距离为 g(x),从 x 到终点的距离为 h(x), x 的估价函数为 f (x)。初始起点入队,每次从队列中取出估价函数最小的一个元素。h(x) 称为启发函数,因为无法直接获取从 x
到终点的距离,所以对 h(x) 采用的往往是一个估价量,要求不超过真实距离,且越接近真实距离,越优。
3.4 Dancing Links
解决精确覆盖问题和重复覆盖问题的高效搜索算法。
采用十字链表加上优秀的剪枝实现。
略。
五、启发式搜索
1.贪婪最佳优先搜索
h(x) = 0 的 A*,也就是优先队列 BFS,也可以说是Dijkstra。
2.模拟灭火
它是一种基于概率的搜索算法,模拟固体物质的退火过程。它以一定的概率接受比当前解更差的解,以避免陷入局部最优。它的估价函数是 f (n) = e− TE ,其中 E 表示新解和旧解的能量差,T 表示温度参数。它的优点是能够跳出局部最优,缺点是参数的选择比较难,收敛速度慢。
算法竞赛中存在部分模拟退火的应用(相较其它启发式搜索而言),本身为一个算法框架,了解即可。
3.遗传算法
略
4.蚁群算法
5.粒子群优化算法
六、番外
1.对抗搜索
这里的对抗搜索是指双人进行对抗博弈。
博弈原理:
• 存在先手必胜策略,当且仅当存在一种策略使得不存在后手必胜策略。
• 存在后手必胜策略,当且仅当无论先手怎么操作,都有后手必胜策略。
一般状态设计为 dfs(x, ...y) x = 1/0 表示先手/后手;
y = 1/0 表示必胜/必败;返回值为 1/0 表示是否存在。参数其它部分用于描述局面信息。
• dfs(x, ..., 1) =dfs(x ⊕ 1, ...0) 与之和。
• dfs(x, ..., 0) =dfs(x ⊕ 1, ...1) 或之和。
注意:
因为搜索其本身时间复杂度上界很高,以及剪枝没有严格时间复杂度证明,所以近年来的算法竞赛中搜索相关问题的出现次数大大减少,除非题目本身明确上界也能通过,否则不会是搜索问题。