本平台分享内容尽量保证与其他账号同步更新。
引入
“All problems in computer science can be solved by another level of indirection.”
“计算机科学中的所有问题都可以通过增加一个间接层来解决。”
——剑桥大学计算机科学教授:David Wheeler
将计算机应用于现实生活中时,需要解决的问题往往是具体的。作为代码的编辑者,我们的任务就是将生活中的问题抽象化,利用计算机的知识将其解决。
根据刚才引用的话,我们可以知道,在解决实际问题时候,我们往往需要进行以下几个抽象步骤:
- 将现实问题抽象为计算机语言问题,这往往是树和图的问题;
- 使用算法解决问题;
- 用API封装算法;
- 将整体包裹成类和对象,进一步提高代码的人文性(方便复用,方便扩展,方便维护)。
本文涉及的知识:剪枝,是算法的一种优化手段,主要出现在枚举和搜索问题中。由于枚举应用性不够强,不具有代表性,故本文主要依赖搜索算法编写。
剪枝的必要性
在实际问题中,搜索是最容易想到、应用最广的算法之一。但搜索树的规模往往是庞大的,由于搜索算法的时间复杂度是指数型,最终代码的执行效率可以说是极低。我们只需要尽可能减少搜索树的规模,也就是尽量减少搜索的节点数量和路径数量,就可以实现程序的轻量化和高效化。这一过程即可称之为剪枝。
每次剪枝相当于剪去了搜索树上的一条边,减少的节点数量相当于此边连接的子树大小。
基础剪枝
基础剪枝可以分为几类:
- 记忆化搜索
- 可行性剪枝
- 最优性剪枝
- 排除等效冗余
记忆化搜索
对于搜索树上的重复节点,其代表的是相同的状态。我们可以将这个状态保存在集合中避免重复计算。这一方法理论上并没有减少搜索树的规模,但是减少了每个搜索节点的期望时间,提高了程序的执行效率。
一般情况下,状态可以用一个数字表示,将状态保存在数组中即可。
伪代码:
int memo[SIZE];
void dfs(int xxx)
{
if(memo[xxx])return memo[xxx];
else
略
}
在更加复杂的情况下,可能没法直接用一串数字表示当前的搜索状态,那么我们可以尝试将当前状态包装起来(类、结构体或者不包装),使用哈希算法实现记忆化搜索。(请自行了解)
可行性剪枝
在搜索过程中,如果当前解已经不可用了,及时停止搜索。这一方法可以减少搜索树中的无效分支,一定程度上优化程序执行。
伪代码:
int ans = 最坏情况下的解;
void dfs(传入状态){
if(当前状态已经不可用)return;
if(达到目标状态)ans = 已有和现有解较优者
for(遍历当前节点的所有分支)
{
操作
dfs(传入新状态,保证规模缩小);
撤回操作
}
}
最优性剪枝
如果在搜索的过程中发现,当前搜索的解已经不可能在后续的搜索过程中变得比已经得到的解更优,停止搜索。可以类推的是,如果在搜索中只需要判断结果是否可行,我们只需要在第一次得到肯定答案后无条件回退搜索即可,此举对搜索的优化是巨大的。
伪代码:
int ans = 已经得到的暂时最优解;
void dfs(传入状态){
if(当前解比ans更差)return;
if(达到目标状态)ans = 当前解和ans更优者;
for(遍历所有分支节点)
{
操作;
dfs(传入新状态,保证规模缩小);
撤回操作;
}
}
排除等效冗余
对于同一前后效性且状态相同的节点,没有必要重复搜索。因为在第一次搜索时得到的答案,后续的等效节点得到的是相同的效果。可以直接跳过此类节点(通过next数组或链表实现)。
伪代码:
int next[SIZE];
bool dfs(int y)
{
略
for(int i=1;i<=n;)
{
if(dfs(x[i]))
{
略
}
i = next[i];
}
}
其他剪枝
以上提到的基础剪枝方法几乎可以做到极大程度上的优化,但是秉持着没有最好,只有更好的原则,此处简单介绍一下更加深度的剪枝方法。
- 极端情况剪枝:若在最理想情况下没法得到最优解,停止当前分支搜索。
- 启发式剪枝:利用代价函数评估状态,决定节点的搜索次序,并辅助剪枝。
- 限制性剪枝:通过设置搜索在时间和空间上的限制,当此搜索路径超限时停止搜索。
- 数学剪枝:借助图论和数论的知识辅助剪枝。
- α-β剪枝:一种博弈树的剪枝方法。
其他提高搜索效率的方法
回归我们剪枝的本意,就是为了尽可能的提高搜索的效率。除了剪枝外,我们还可以在其他方面改良程序,提高效率。
- 优化搜索方式:对核心算法框架的优化。主要体现在搜索方式上,例如对状态的选取、对搜索树的定义等等。
- 优化子结构:每个搜索节点内包含的内容作为一个子结构,每次执行的效率会直接影响到程序整体的效率。可以通过循环展开、哈希、二分等方法尽可能减小子结构的规模。
- 优化搜索顺序:根据后续子树的分支数、规模,调整搜索顺序。
- 预处理:对于全局不变的数据,在搜索前首先处理,后续搜索时就只需要提取而无需计算。
- 快读快输+宏定义:在编译层面优化程序的执行。
- 玄学方法:在竞赛时如果实在解决不了也没办法进一步优化,试试调参后双手合十,心中默念rp++,或许可以侥幸AC。
以上的优化方法效果依次递减,在使用时请尽可能顺序尝试。后两种方法对于程序的优化是常数级的,非必要不使用。(最后这个方法这辈子都用不上)
总结
以上本文对于剪枝以及搜索算法优化的理论分享内容。由于本人能力有限,可能有部分内容的描述非专业化,请读者海涵。另外,如果发现本文内容有纰漏、错误,敬请指出,我会及时修改。同时也欢迎读者提问、交流,我们共勉。
理论上的知识是抽象且生涩难懂的,接下来我会对选取的部分较为典型的题目结合刚才的内容进行讲解。请注意,本文给出的代码并不代表最优解,使用到的优化技巧也未必是必需的,读者可以自行尝试更优方案。
题目分享
A.选数
首先是最基础的题目,我们可以通过本题对搜索问题进行一定的解决办法进行初步熟悉。本题的剪枝是非必要的,注意码风的规范即可。
本题可以剪枝的部分为素数判断,由于题目中需要判断多个数字是否是素数,可以通过预处理的方式,通过素数筛极大程度提高程序效率。
无剪枝优化的代码如下:
#include<bits/stdc++.h>
using namespace std;
int n, k;
int a[30];
long long ans;
bool isprime(int n)
{
for (int i = 2; i <= sqrt(n); i++)
{
if (n % i == 0)return 0;
}
return 1;
}
void dfs(int now, int sum, int st)
{
if (now == k)
{
if (isprime(sum))++ans;
return;
}
for (int i = st; i < n; i++)
{
dfs(now + 1, sum + a[i], i + 1);
}
return;
}
int main()
{
cin >> n >> k;
for (int i = 0; i < n; i++)cin >> a[i];
dfs(0, 0, 0);
cout << ans;
return 0;
}
加上剪枝优化也并不复杂,但由于涉及知识与本文没有太大关系,故请读者自行了解。关键字:素数筛法。
B.Undoubtedly Lucky Numbers
本题的要点在于优化搜索方法。可以很容易想到,我们可以枚举所有1-n之间的数字,并且依次判断其各位数字是否满足幸运数的条件。可是结合数据来看,这样的算法规模无疑是难以承受的。更优的方法是基于数字x和y来构造幸运数,这一方法的时间复杂度较前者可以减少的是数量级,极大程度优化程序执行。
搜索构造幸运数代码如下:
#include<bits/stdc++.h>
using namespace std;
#define int long long
int N;
set<int> s;
void dfs(int, int, int);
signed main() {
cin >> N;
for (int x = 0; x <= 9; x++)
for (int y = 0; y <= x; y++)
if (x != y || y != 0)
dfs(x, y, 0);
cout << s.size() << "\n";
}
void dfs(int n, int m, int num) {
if (num > N)
return;
if (num) {
s.insert(num);
dfs(n, m, num * 10 + n);
dfs(n, m, num * 10 + m);
}
else {
if (n)
dfs(n, m, num * 10 + n);
if (m)
dfs(n, m, num * 10 + m);
}
}
为了避免xy的交换顺序而导致重复搜索,我们在枚举时就规定了y<x,并且默认x!=y!=0。同时我们使用STL内置的集合set保存已经搜索到的数字,防止相同数字重复计数。
C.狗哥玩木棒
如果说前两个题只是对搜索算法的练习热身,对剪枝并没有硬性要求。那么接下来的两个题目就是非剪枝不可了。(如果不使用搜索的话当我没说)
分析本题,我们需要将给出的所有木棒全部用完,那么即使最后可以拼成正方形,边长也是固定的sum/4。sum即是给出的所有木棒长度总和。
对于剪枝,我们需要利用刚才所讲到的最优性剪枝:若当前解比已有解更差,停止;可行性剪枝:当已经得到了肯定答案,无条件回退;优化搜索顺序:优先选择灵活性差的大木棍而后选择小木棍,减小搜索树规模。
代码如下:
#include<bits/stdc++.h>
using namespace std;
int n, m, sum, check, flag;
int a[1100],b[1100];
void dfs(int now,int tes)
{
//借助flag标记,实现无条件回退
if (flag)return;
//最优性剪枝
if (now > sum)return;
if (now == sum)now = 0, tes = tes + 1;
if (tes == 4) {
cout << "yes" << endl;
flag = 1; return;
}
for (int i = 1; i <= m; ++i)
{
if (b[i])continue;
b[i] = 1;
dfs(now + a[i], tes);
b[i] = 0;
//可行性剪枝
if (now == 0 || now + a[i] == 0)return;
}
}
int main()
{
cin >> n;
while (n--)
{
memset(b, 0, sizeof b);
cin >> m;
sum = 0;
flag = 0;
for (int j = 1; j <= m; ++j)
{
cin >> a[j];
sum += a[j];
}
//提前判定,减少搜索期望
if (sum % 4)
{
cout << "no" << endl; continue;
}
sum /= 4;
//优化搜索顺序
sort(a + 1, a + 1 + n, greater<>());
dfs(0, 0);
if (!flag)
{
cout << "no" << endl;
}
}
return 0;
}
D.小木棍
个人认为本题可以算是基础剪枝的模板题,我们需要尽可能使用所有剪枝技巧来优化程序才能通过题目。本题我们需要使用到的技巧有:可行性剪枝:搜到答案立刻回退,若得到当前解不可行的信息立刻回退;排除等效冗余:若无解,不重复搜索相同的木棍而直接跳到下一个;优化子结构:在每个节点中使用二分而非顺序查找;优化搜索顺序:优先搜索灵活性差的大木棍而后搜索小木棍;快读快输:提高编译效率。
const int M = 100;
typedef long long ll;
//快读快输
inline int read()
{
int res = 0, f = 1;
char c = getchar();
while (c < '0' || c>'9')
{
if (c == '-')f = -1;
c = getchar();
}
while (c >= '0' && c <= '9')
{
res = res * 10 + c - '0';
c = getchar();
}
return res * f;
}
int n, len;
int a[M], x, cnt, nxt[M];
int sum, num;
bool used[M], flag;
void dfs(int k, int last, int rest)
{
int j;
if (!rest) {
//可行性剪枝 搜到答案立刻回退
if (k == num) {
flag = 1; return;
}
for (j = 1; j <= cnt; ++j)
{
if (!used[j])break;
}
used[j] = 1;
dfs(k + 1, j, len - a[j]);
used[j] = 0;
if (flag)return;
}
//优化子结构 使用二分而非顺序查找
j = lower_bound(a + last + 1, a + 1 + cnt, rest, greater<>()) - a;
for (; j <= cnt; ++j)
{
if (used[j])continue;
used[j] = 1;
dfs(k, j, rest - a[j]);
used[j] = 0;
if (flag)return;
//可行性剪枝 如果发现此情况下刚才搜索没能得到答案,直接回退
if (rest == a[j] || rest == len)return;
//排除等效冗余 对于等长的小木棍没有重复搜索的必要
j = nxt[j];
if (j == cnt)return;
}
}
int main()
{
n = read();
for (int i = 1; i <= n; ++i)
{
x = read();
if (x > 50)continue;
a[++cnt] = x;
sum += x;
}
sort(a + 1, a + 1 + cnt, greater<>());
nxt[cnt] = cnt;
for (int i = cnt - 1; i > 0; --i)
{
if (a[i] == a[i + 1])nxt[i] = nxt[i + 1];
else nxt[i] = i;
}
for (len = a[1]; len <= sum / 2; ++len)
{
if (sum % len)continue;
num = sum / len;
flag = 0;
used[1] = 1;
dfs(1, 1, len - a[1]);
used[1] = 0;
if (flag)
{
cout << len; break;
}
}
if (!flag)cout << sum;
return 0;
}
以上是我对一些比较基础而有代表性的搜索剪枝题目的分享内容。再次说明:文中出现的代码均由作者本人编写,不代表标准最优题解。笔者坚信算法没有最优只有更优,希望读者勤读力耕,终有一日成为神犇。