【算法】搜索进阶

搜索是考场骗分利器,只要你能写出一手复杂度优秀的搜索,那至少应该不会爆0。

​ ——某位巨佬

一些玄学的东西(剪枝、小技巧、随机化等)

神奇的剪枝

P1585 魔法阵

我们发现题目中的模数比较特别,是 n ∗ m 2 ​ \frac{n*m}{2}​ 2nm,这暗示我们可以把搜索的过程分成两部分(别想歪了,这题跟Meet-In-Middle一点关系都没有)。

首先我们对于当前走的步数 s t e p ≤ n ∗ m 2 step \leq \frac{n*m}{2} step2nm的情况,我们随便搜(反正还不需要考虑对答案的贡献),并把路径上每一步经过的点存起来,方便后面计算贡献时查询。

然后对于 s t e p > n ∗ m 2 step > \frac{n*m}{2} step>2nm的情况,我们在搜索的过程中更新当前路径的最大值,并与全局答案比较。这里显然可以加入一个最优性剪枝:如果当前路径上的最大值已经超过全局最小值,那么直接剪掉~~(十分的显然)~~。

然后悲惨地发现对于 n , m ≤ 50 n,m \leq 50 n,m50的数据,慢到爆炸~~(于是点击右上角离开)~~。

那么我们只好接着思考如何剪枝,于是我们发现下面这种情况有点奇怪。

EonvVg.png

我们发现,当现在走到红色格子时,我的左右都已经不能再访问了,但不管我向上或者向下,总有一段会被封闭起来,再也无法访问。比如我向下,那么就会被困在下方,向上的话就无法回到下方。于是我们大胆猜想,当一个点左右都不能走,即只能上下走的时候,这种方案是无法遍历整个矩形的。当只能左右走的时候同理也直接剪枝。

于是你发现好像能过了,事实证明跑得飞快。

#include <bits/stdc++.h>
#define MAX 55
#define INF 0x3f3f3f3f
using namespace std;

const int mx[] = {0, 1, 0, -1}, my[] = {1, 0, -1, 0};

int n, m, k1, k2, P;
int vis[MAX][MAX], px[MAX], py[MAX];
int ans = INF;

inline bool chk(int x, int y){		//检查一个点是否可以访问
    return (x>0 && x<=n && y>0 && y<=m) && (!vis[x][y]);
}

void dfs(int x, int y, int step, int maxx){
    //只能在一条直线上移动(上下或左右)
    if(chk(x+1,y) && chk(x-1,y) && !chk(x,y+1) && !chk(x,y-1)) return;
    if(!chk(x+1,y) && !chk(x-1,y) && chk(x,y+1) && chk(x,y-1)) return;
    
    //前半部分搜索记录路径,后半部分搜索统计答案
    if(step > P){
        maxx = max(maxx, k1*abs(px[step-P]-x)+k2*abs(py[step-P]-y));	//计算贡献
    }
    else px[step] = x, py[step] = y;
    
    if(step >= n*m){
        ans = min(ans, maxx);	//记录答案
        return;
    }
    if(maxx >= ans) return;		//最优性剪枝
    for(int i = 0; i < 4; i++){
        int u = x + mx[i], v = y + my[i];
        if(chk(u, v)) {
            vis[u][v] = 1;
            dfs(u, v, step+1, maxx);
            vis[u][v] = 0;
        }
    }
}

int main()
{
    cin >> n >> m >> k1 >> k2;
    P = (n*m)>>1;
    vis[1][1] = 1;
    dfs(1, 1, 1, 0);
    cout << ans << endl;
    
    return 0;
}

小技巧——射线法

P2864 树林

题意很简单,在矩阵中求从出发点绕一个实心区域一周的最短距离。每一步可以向周围8个方向扩展。

这题如果用朴素的搜索应该会很烦,而且码量巨大,于是我们搬出一种套路——射线法。

EoM68s.png

我们可以找到一个点,为了方便以最先读入到的点举例,我们在这个点上方画一条向左的射线,那么我们会发现,如果要绕这个实心图形一周,就必须穿过这条射线。于是我们从起点开始 b f s bfs bfs,路径要求不能穿过障碍物和我们画的这条射线。于是我们可以得到不穿过射线每个点到起点的最短路径。

然后我们考虑把这条射线删除,于是我们可以把射线上下的点的路径合并,形成一条完整的绕图形一周的封闭路径。

具体代码实现有一些细节要注意:由于我们在矩阵中画线难度较大,那么我们考虑把这条线下方的一排点标记为这条线,然后我们规定从下到上走的时候可以触碰这些点(但不能穿过),但从上向下走的时候不能触碰。这样就能保证最后的路径合并时不会有重叠和遗漏的问题。

#include <bits/stdc++.h>
#define MAX 55
using namespace std;

struct pt{
    int x, y;
}st;

const int mx[] = {-1, 0, 1, 1, 1, 0, -1, -1}, my[] = {1, 1, 1, 0, -1, -1, -1, 0};
int n, m, lx, ly, dis[MAX][MAX];
char a[MAX][MAX];

queue<pt> q;
void bfs(){
    q.push(st);
    dis[st.x][st.y] = 1;
    while(!q.empty()){
        pt t = q.front();
        q.pop();
        for(int i = 0; i < 8; ++i){
            int u = t.x+mx[i], v = t.y+my[i];
            if(u<=0 || u>n || v<=0 || v>m || dis[u][v] || a[u][v] == 'X') continue;
            if(t.y <= ly && t.x == lx && u == lx-1) continue;		//从下到上不能穿过但可以触碰
            if(t.y <= ly && t.x == lx-1 && u == lx) continue;		//从上到下不能触碰
            dis[u][v] = dis[t.x][t.y]+1;
            q.push((pt){u, v});
        }
    }
}

int main()
{
    cin >> n >> m;
    for(int i = 1; i <= n; ++i){
        scanf("%s", a[i]+1);
        for(int j = 1; j <= m; ++j){
            if(a[i][j] == '*'){
                st = (pt){i, j};
            }
            if(a[i][j] == 'X' && !lx){
                lx = i, ly = j;
            }
        }
    }
    bfs();
    int ans = 0x3f3f3f3f;
    for(int i = 1; i <= ly; ++i){
        if(!dis[lx][i]) continue;
        //对于每个线上能到达起点的点,枚举线下方三个方向的点,更新答案
        if(dis[lx-1][i-1]){
            ans = min(ans, dis[lx][i]+dis[lx-1][i-1]);
        }
        if(dis[lx-1][i]){
            ans = min(ans, dis[lx][i]+dis[lx-1][i]);
        }
        if(dis[lx-1][i+1]){
            ans = min(ans, dis[lx][i]+dis[lx-1][i+1]);
        }
    }
    cout << ans-1 << endl;

    return 0;
}

乱搞随机化

其实这并不是真正的随机化算法,只是一种乱搞。

P4212 外太空旅行

先说一下团:如果一张图中的节点两两之间都有边相连,那么称其为团(跟完全图很像)。

本题要求一张图中的最大团。

当你苦思冥想了很久很久~~(10min)~~之后,点开题解却发现:很不幸,最大团是NPC问题,也就是说在多项式复杂度内找不到靠谱的解法。 (那做个鬼啊!!)

于是我们考虑爆搜。我们可以考虑暴力枚举原序列的排列,从头开始贪心取,判断加入到当前集合中是否满足团的要求,如果满足,直接贪心选取,否则扔掉。这样只要枚举完所有的排列,就能保证算法正确性。

然后你发现虽然数据小,但也不可能暴力枚举完每一种排列,于是考虑乱搞。

这时候就轮到乱搞中的重要人物~~(机惨好帮手)~~出场了——大名鼎鼎的random_shuffle!!!

我们随机打乱序列,然后贪心选取,更新答案。由于数据范围小,我们可以随机成千上万次,使我们错误的几率大大降低(本题中随机1000次就能过)。

#include <bits/stdc++.h>
#define MAX 105
using namespace std;

int n, ans;
int a[MAX], g[MAX][MAX], deg[MAX];
int v[MAX], cnt;

bool check(int x){		//判断当前点加入能否满足团的性质
    if(deg[x] < cnt) return 0;
    for(int i = 1; i <= cnt; i++){
        if(!g[v[i]][x]) return 0;
    }
    return 1;
}

int main()
{
    srand(time(NULL));
    cin >> n;
    int x, y;
    for(int i = 1; i <= n; i++){
        a[i] = i;
    }
    while(scanf("%d%d", &x, &y) != EOF){
        g[x][y] = g[y][x] = 1;
        deg[x]++, deg[y]++;
    }
    for(int t = 1; t <= 10000; t++){
        cnt = 0;
        random_shuffle(a+1, a+n+1);		//随机打乱
        for(int i = 1; i <= n; i++){
            if(check(a[i])){		//能取就取
                v[++cnt] = a[i];
            }
        }
        ans = max(ans, cnt);		//更新答案
    }
    cout << ans << endl;
    
    return 0;
}

Meet-In-Middle(折半搜索)

当我们搜索的起点与终点都是确定的时候,如果我们盲目搜索的话,其实很多路径最后并没有到达要求的终点。针对这种情况,我们可以采用从起点和终点向中间搜索的方法,也就是Meet-In-Middle(折半搜索),这样就保证了路径一定是从起点走向重点的,剪去了无用的状态。

CF1006F Xor-Paths

传送门

题意:求矩阵左上角到右下角的 x o r xor xor和为 k k k的路径总数,只能向右或向下走。

我们发现这个问题中起点和终点都是确定的,所以可以折半搜索。我们以对角线作为分界线,分别从起点和终点向中间搜索。从起点搜索的时候,我们记录下到中间时的 x o r xor xor和的方案数(可以用 m a p , u n o r d e r e d _ m a p ( c + + 11 ) map,unordered\_map(c++11) map,unordered_map(c++11)等记录)。然后从终点向中间搜索,当到达中间的时候,在容器中查找方案数,并累计答案。

代码实现的时候需要注意中间部分是否会出现重复计算。

#include <bits/stdc++.h>
#define ll long long
using namespace std;

map<ll, ll> f[25];		//f[i][j]表示从起点开始搜到中间,横坐标为i,异或和为j的方案数
ll a[25][25], ans, k;
int n, m;

void dfs(int x, int y, ll sum){		//从起点向中间搜
    if(x+y == (n+m)/2+1) {
        f[x][sum^a[x][y]]++;
        return;
    }
    if(x < n) dfs(x+1, y, sum^a[x][y]);
    if(y < m) dfs(x, y+1, sum^a[x][y]);
}

void solve(int x, int y, ll sum){		//从终点向中间搜
    if(x+y == (n+m)/2+1) {
        ans += f[x][sum^k];		//不需要再次^a[x][y],否则会算重 
        return;
    }
    if(x > 1) solve(x-1, y, sum^a[x][y]);
    if(y > 1) solve(x, y-1, sum^a[x][y]);
}

int main()
{
    cin >> n >> m >> k;
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            scanf("%lld", &a[i][j]);
        }
    }
    
    dfs(1, 1, 0);
    solve(n, m, 0);
    cout << ans << endl;
    
    return 0;
}

类似的题还有CF525E Anya and Cubes。这道题并不容易一眼看出来是折半搜索,但是想到之后实现非常模板。


迭代加深和A*算法

很多时候,我们的搜索是盲目的,会做出一些SB的行为。那么这个时候就需要一种方式让他尽可能往正解那个方向搜索。于是出现了迭代加深和A*算法。

迭代加深用于搜索深度不确定或者深度上限较大的场景,我们枚举一个搜索深度,在深度范围内搜索,防止搜到一些奇奇怪怪的对答案毫无贡献的东西。

但实际上虽然我们限定了深度,但搜索依然是相对盲目的,于是有人想出了A*算法。我们在搜索的时候计算一个估价函数,通过这个函数挑出一个最有可能到达答案的状态进行扩展。A*算法本来是用于 b f s bfs bfs的,因为在广搜的时候很容易维护一个优先队列,可以快速挑出最优的状态。但是在深度确定的 d f s dfs dfs中,也可以用A*来确定搜索的顺序和方向。所以它可以跟迭代加深组合,形成IDA*算法。(废话贼多)

迭代加深——埃及分数

传送门

先不考虑限制(其实差不多),我们发现这个问题没有明确的上限:我们无法知道有多少个分数(搜索层数不确定),也无法知道每个分数的分母范围(每层中的状态个数不确定)。显然迭代加深搜索。

由于我们限制了深度,那么我们就可以进行可行性剪枝,我们从小到大搜索分母,如果在当前状态后所有的分母都取到最大,但答案还是比要求的值小的话,剪枝。同时我们可以通过一些计算确定下一层的分母范围。

然后考虑不能选的数,其实就是特判一下。


inline ll get_first(ll x, ll y){
    ll res = y / x;
    return res*x >= y ? res : res + 1;
}

bool dfs(int d, ll last, ll a, ll b){	//当前层数,当前层分母范围的左端点,a/b表示剩下分数需要满足的和
    if(d == maxd){
        //注意判断a!=0
        if(a && b%a != 0)
            return false;
        //最后一层也要考虑能不能选
        //由于b/a可能很大,但是不能选的数字都在1000以内,所以直接特判
        if(a && b/a <= 1000 && cant[b/a]) return false;
        
        if(a) v.push_back(b/a);
        if(better()){		//比较答案
            ans = v;
        }
        if(a) v.pop_back();
        return true;
    }
    bool flag = false;
    last = max((ll)last, get_first(a,b));
    for(ll i = last; ;i++){
        if(i<=1000 && cant[i]) continue;		//同样要判在1000以内
        if(b*((ll)maxd-d+1) <= a*i){		//可行性剪枝
            break;
        }
        ll aa = a*i-b, bb = b*i;
        ll g = gcd(aa, bb);
        aa /= g, bb /= g;
        v.push_back(i);
        if(dfs(d+1, i+1, aa, bb))
            flag = true;		//由于要求最优解而不是任意一组解,所以不能直接return
        v.pop_back();
    }
    return flag;
}

void solve(int T){
    //这里初始化(代码被我吃掉了)
    //这里读入和标记不能选
    for(maxd = 2; ; maxd++){		//枚举深度
        if(dfs(1, 1, a, b)){
            break;
        }
    }
    //这里输出答案
}

A*——铁盘整理

传送门

看到数据范围,就发现爆搜好像要爆炸。接着我们发现如果我们把盘子的半径离散化,那么最后的状态就是公差为1的上升等差数列。于是我们可以根据这个性质设计出估价函数,执行A*算法。

由于估价函数要能够表现出当前状态和目标状态的差异,那么根据上面的性质,我们可以设计估价函数 g g g表示当前状态中相邻两数差的绝对值不为1的个数。显然到达目标状态时,函数值为0。

接着我们套上迭代加深,于是我们可以根据这个函数进行剪枝。如果当前状态的步数加上估价函数的值大于枚举的深度,那么直接退出。因为这个估价函数同时代表了当前状态到目标状态理论上的最小步数。

#include <bits/stdc++.h>
#define MAX 55
using namespace std;

int n, ans;
int st[MAX], a[MAX];

bool check(){
    for(int i = 1; i < n; i++){
        if(st[i] > st[i+1]) return false;
    }
    return true;
}

int g(){		//估价函数,钦定st[n+1]=n+1,方便统计
    int res = 0;
    for(int i = 1; i <= n; i++) res += (abs(st[i+1]-st[i]) != 1);
    return res;
}

void dfs(int step, int mx){
    if(check()){
        ans = step;
        return;
    }
    if(ans || step+g() > mx || step == mx) return;
    for(int i = 2; i <= n; i++){
        if(abs(st[i]-st[i+1]) == 1) continue;		//小剪枝:当前区间已经有元素满足顺序
        reverse(st+1, st+i+1);
        dfs(step+1, mx);
        reverse(st+1, st+i+1);
    }
}

int main()
{
    cin >> n;
    for(int i = 1; i <= n; i++){
        scanf("%d", &st[i]);
        a[i] = st[i];
    }
    sort(a+1, a+n+1);
    for(int i = 1; i <= n; i++){
        st[i] = lower_bound(a+1, a+n+1, st[i])-a;
    }
    st[n+1] = n+1;
    int maxd = 1;
    while(!ans){		//迭代加深
        dfs(0, maxd++);
    }
    cout << ans << endl;
    
    return 0;
}

剩下一道IDA*的题P2324 骑士精神可以自行了解,大体做法没有什么区别,估价函数的设置才是关键。

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值