【算法】搜索進階

搜索是考場騙分利器,只要你能寫出一手複雜度優秀的搜索,那至少應該不會爆0。

​ ——某位巨佬

一些玄學的東西(剪枝、小技巧、隨機化等)

神奇的剪枝

P1585 魔法陣

我們發現題目中的模數比較特別,是nm2\frac{n*m}{2}​,這暗示我們可以把搜索的過程分成兩部分(別想歪了,這題跟Meet-In-Middle一點關係都沒有)。

首先我們對於當前走的步數stepnm2step \leq \frac{n*m}{2}的情況,我們隨便搜(反正還不需要考慮對答案的貢獻),並把路徑上每一步經過的點存起來,方便後面計算貢獻時查詢。

然後對於step>nm2step > \frac{n*m}{2}的情況,我們在搜索的過程中更新當前路徑的最大值,並與全局答案比較。這裏顯然可以加入一個最優性剪枝:如果當前路徑上的最大值已經超過全局最小值,那麼直接剪掉~~(十分的顯然)~~。

然後悲慘地發現對於n,m50n,m \leq 50的數據,慢到爆炸~~(於是點擊右上角離開)~~。

那麼我們只好接着思考如何剪枝,於是我們發現下面這種情況有點奇怪。

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

我們可以找到一個點,爲了方便以最先讀入到的點舉例,我們在這個點上方畫一條向左的射線,那麼我們會發現,如果要繞這個實心圖形一週,就必須穿過這條射線。於是我們從起點開始bfsbfs,路徑要求不能穿過障礙物和我們畫的這條射線。於是我們可以得到不穿過射線每個點到起點的最短路徑。

然後我們考慮把這條射線刪除,於是我們可以把射線上下的點的路徑合併,形成一條完整的繞圖形一週的封閉路徑。

具體代碼實現有一些細節要注意:由於我們在矩陣中畫線難度較大,那麼我們考慮把這條線下方的一排點標記爲這條線,然後我們規定從下到上走的時候可以觸碰這些點(但不能穿過),但從上向下走的時候不能觸碰。這樣就能保證最後的路徑合併時不會有重疊和遺漏的問題。

#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

傳送門

題意:求矩陣左上角到右下角的xorxor和爲kk的路徑總數,只能向右或向下走。

我們發現這個問題中起點和終點都是確定的,所以可以折半搜索。我們以對角線作爲分界線,分別從起點和終點向中間搜索。從起點搜索的時候,我們記錄下到中間時的xorxor和的方案數(可以用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*算法本來是用於bfsbfs的,因爲在廣搜的時候很容易維護一個優先隊列,可以快速挑出最優的狀態。但是在深度確定的dfsdfs中,也可以用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*算法。

由於估價函數要能夠表現出當前狀態和目標狀態的差異,那麼根據上面的性質,我們可以設計估價函數gg表示當前狀態中相鄰兩數差的絕對值不爲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 騎士精神可以自行了解,大體做法沒有什麼區別,估價函數的設置纔是關鍵。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章