淺談狀壓DP

開放下載:下載地址
注:未經允許不得進行轉載、參考,本文歸屬權爲作者所有,如有需求請先聯繫作者申請。

淺談狀壓DP

作者:筱柒_Littleseven

地址:http://littleseven.top/

QQ/微信:3364445435 / yuqihao2003

目錄

  • 一、位運算及狀壓DP中常用的位運算技巧
  • 二、狀壓DP概念及涉及到的一些鋪墊知識
  • 三、經典狀壓DP和簡單例題
  • 四、TSP(旅行商)問題的狀壓DP解法
  • 五、狀壓DP的衍生
  • 六、總結

一、位運算及狀壓DP中常用的位運算技巧

簡單位運算介紹

運算符號:[PS.其中按位取反操作應用較少]

含義 表達式(Pascal語言) C/C++語言
按位與 a and b a & b
按位或 a or b a | b
按位異或 a xor b a ^ b
按位取反 not a ~ a
左移 a shi b a << b
右移 a shr b a >>b

1. and(&)運算 按位與運算

​ 在二進制下,相同位的數字都爲 \(1\) ,則爲 \(1\) ;若有一個不爲 \(1\) ,則爲 \(0\)

MFJWvQ.png

2. or(|)運算 按位或運算

​ 在二進制下,相同位的數字只要一個爲 \(1\) ,即爲 \(1\) ;反之,則爲 \(0\)

MFJRgg.png

3. xor(^)運算 按位異或運算

​ 在二進制下,相同位的數字相同則爲 \(0\) ,不同則爲 \(1\) 。也可以理解 \(Xor\) 運算爲不進位的二進制加法運算。

MFJ28S.png

4. not(~)運算 按位取反運算

​ 在二進制下,把內存當中的 \(0\)\(1\) 全部取反。這裏要注意整數是否有符號等等。

5. shi(<<)運算 左移運算

​ 在二進制下,把所有位數字向左移動,並在後邊補上 \(0\)

MFJgC8.png

6. shr(>>)運算 右移運算

​ 在二進制下,把所有數字向右移動,並去掉末尾位數。

MFJ64f.png

常用位運算技巧

  • 取出二進制數 \(x\) 的第 \(k\) 位:y = 1 << k & xy = x >> k & 1
  • 取出二進制數 \(x\) 的最後一個 \(1\)\(lowbit\)):y = x & (-x)
  • 將二進制數 \(x\) 的第 \(k\) 位變成 \(1\)x = x | (1 << k)
  • 將二進制數 \(x\) 的第 \(k\) 位變成 \(0\)x = x & (~ (1 << k))
  • 將二進制數 \(x\) 的第 \(k\) 位取反:x = x ^ (1 << k)
  • 取出二進制數 \(x\) 的最後 \(k\) 位:y = x & ((1 << k) - 1)

位運算優先級

優先級 運算符(C/C++)
1 ~
2 <<,>>
3 &
4 ^
5 |
6 &=,^=,|=,<<=,>>=

二、狀壓DP概念及涉及到的一些鋪墊知識

狀壓DP

​ 狀壓 dp 是動態規劃的一種,通過將狀態壓縮爲整數來達到優化轉移的目的。 [——來自 OI Wiki]

對於概念的一些補充解釋

狀態壓縮,指的是我們需要將動態規劃的一部分狀態壓縮起來處理。這些狀態應該保證一定的相似性、狀態的可能性許多、適合集中處理且便於壓縮處理。

​ 動態規劃是解決多階段決策最優化問題的一種方法,狀壓優化也不能對此有影響。所以由此來看,狀壓DP只是動態規劃的一種優化方法,或者說是一種實現方法,並沒有改變動態規劃的本質。

​ 而對於較簡單且常見的狀壓DP,實現的方式通常是將一行的決策情況(狀態)壓成一個數。而這一個數所有二進制位,則代表這一行的情況,這似乎與部分題當中的 \(bitset\) 有異曲同工之妙。而在這個實現過程中就需要很多涉及位運算的問題。這樣更體現出狀壓DP適合解決選不選的問題,將一個 \(bool\) 數組變成一個 \(int\) 。並且狀壓DP不適合情況數過多的情況,假如我們在這一行中有 \(60\) 個點,那麼我們在狀態的這一維需要開一個 \(2^{60}\) 的數組,顯然這不太現實。

​ 並且對於動態規劃來說,需要保證無後效性和最優子結構兩個性質,特別是無後效性限制了這個DP的狀態與實現方式。在狀壓DP中當然是也要保證這兩個最基本的性質,所以狀態壓縮的狀態、壓縮狀態的方式、狀態轉移的實現方式對於保證這個DP的正確性來說都是至關重要的。

對於狀壓DP的例子

問題:

\(n\) 件物品和一個容量爲 \(v\) 的揹包。放入第 \(i\) 件物品會佔用 \(c_i\) 的空間,同時也會得到 \(w_i\) 的價值。求解對於每種方法這個揹包的價值。

分析:

  • 狀態:
    • 由於問題是每一種揹包的價值。顯然我們需要拿到每一種揹包的放置情況,那麼不妨就設狀態是:對於這 \(n\) 件物品放與不放的情況,答案是多少。
    • 這時我們會發現,如果開一個 \(n\) 維數組,第 \(i\) 維用 \(0/1\) 表示這個物品選還是不選,這樣的話這個問題將會迎刃而解。但是顯然空間不允許我們這種做法,同樣很難實現這個 \(n\) 維數組。所以我們考慮狀壓。
    • 如果當前有 \(8\) 個物品(\(n = 8\)),根據多維數組我們要開一個 \(f[s][s][s][s][s][s][s][s]\) 數組(其中 \(s\) 代表 $0/1 $)。由於每種物品只有選或者不選兩種情況,那麼我們考慮把這 \(8\) 個維度壓在一起變成 \(f[0000000]\sim f[11111111]\) 這麼一個數組。這樣我們只需要開一個 \(f[256]\) 就可以了。
  • 轉移:
    • 由於對於一個物品,放的狀態能且僅能從不放的狀態轉移而來。對於 \(f[10000]~~(f[16])\) 則從 \(f[00000]~~(f[0])+w[1]\) 轉移而來;而對於 \(f[11000]~~(f[24])\) 則可以由 \(f[10000]~~(f[26])+w[2]\) 轉移而來,也可以由 \(f[01000]~~(f[8])+w[1]\) 轉移而來。
  • 答案
    • \(f[S]\) 則表示在狀態 \(S\) 下這個揹包的價值。

狀壓DP的特點

  1. DP規模較小(\(n<= 20\)),適合多維壓縮計算,並且被壓縮的維度的性質應相似。
  2. 一般情況下只有兩種決策(也可能會有多種出現)。

三、經典狀壓DP和簡單例題

洛谷 P1879 [USACO06NOV]玉米田Corn Fields

題目鏈接

題目大意:

​ 現在又一個 \(n \times m\) 的牧場,對於每一個格子,有的可以種上牧草,有的則不能。但是無論如何不可以選擇相鄰的兩塊草地種植牧草,也就是任意的兩塊草地都沒有公共邊。現在想知道一共可以有多少種種植方案(不種植牧草也是一種方案)。(\(1\le n,m \le 12\)

分析:

​ 通過這道題的數據範圍我們會發現,每一塊草地只有種或不種的兩種決策,並且對於每一塊草地都是相同性質的決策。同樣這個範圍是可以壓縮的 \(2^{12} = 4096\)

​ 所以我們把每一行的狀態壓在一個狀態當中。例如 \(00000\) 表示這一行不種牧草,而 \(00100\) 表示在這一行的第三個位置種牧草。

​ 之後考慮DP過程:

  • 狀態:我們設 \(f[i][s]\) 表示當前處理到第 \(i\) 行,在這行的狀態爲 \(s\) 的情況下的方案數。

  • 轉移:

    這道題的轉移其實很值得推敲。首先我們會明確其中有一些格子是本身就不能種植,而有一些格子是因爲有相鄰的格子種植了而不能種植。所以對於一個狀態的合法性(也就是轉移過程的條件)是要從很多方面進行考慮的。

    • 首先我們考慮對於這個格子本質就不能種草的情況,我們可以在讀入的時候直接把每一行壓成一個狀態,這樣相當於直接拿出來了把這一行種滿的情況(暫時不考慮相鄰),這個狀態中 \(1\) 的位置則表示這個位置可種, \(0\) 的位置則表示這個位置不可種。那麼顯然如果滿足 j & s[i] == j,那麼這個狀態就是合法的。
    • 其次我們來考慮橫向相鄰格子的情況,我們可以對於每一個狀態進行一下預處理。通過 ! i & (i << 1) 來判斷是否和右邊草地相連,通過 ! i & (i >> 1) 判斷是否和左邊草地相連。
    • 再來考慮縱向相鄰格子的情況,在轉移的過程中,我們可以找到所有與當前 \(f[i][s]\) 沒有縱向相鄰方格的 \(f[i - 1][k]\) 進行轉移。這時候需要保證 k & j == 0 ,即兩行之間沒有縱向的相鄰格子。
  • 答案:由於並不知道最後的答案是在什麼,也沒有具體的答案。對於這道題,他的答案就是 $\sum_{i=1}^n f[n][i] $。

AC代碼:

#include <bits/stdc++.h>

using namespace std;

const int N = 15;

const int S = (1 << 12) + 10;

const int mod = 1e8;

int n, m, mp[N][N];

bool is[S]; // 判斷當前狀態是否合法

int s[N], f[N][S]; // s[]存放每一行的初始狀態,f[][]是DP數組

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i ++ ) {
        for (int j = 1; j <= m; j ++ ) {
            scanf("%d", &mp[i][j]);
            s[i] = (s[i] << 1) | mp[i][j];
            // 更新每一行的初始狀態
        }
    }
    for (int i = 0; i <= (1 << m) - 1; i ++ ) {
        if ((! (i & (i << 1))) && (! (i & (i >> 1)))) {
            is[i] = 1;
            // 判斷一個狀態是否合法
            // ! (i & (i << 1)) 判斷是否和右邊草地相連
            // ! (i & (i >> 1)) 判斷是否和左邊草地相連
        }
    }
    f[0][0] = 1; // 初值,表示在第 0 行選出來 0 個位置的方案數
    for (int i = 1; i <= n; i ++ ) {  // 枚舉當前是第i行
        for (int j = 0; j <= (1 << m) - 1; j ++ ) {  // 枚舉狀態
            if (is[j] && (j & s[i]) == j) {  // 當前狀態合法,並且也符合這一行的地形
                for (int k = 0; k <= (1 << m) - 1; k ++ ) {  // 枚舉上一行的狀態
                    if ((k & j) == 0) {  // 如果上一行和這一行沒有在縱向方向上相鄰的
                        f[i][j] = ((f[i][j] + f[i - 1][k])) % mod;  // 記錄答案
                    }
                }
            }
        }
    }
    int ans = 0;
    for (int i = 0; i <= (1 << m) - 1; i ++ ) {
        ans = (ans + f[n][i]) % mod;  // 循環統計方案數
    }
    printf("%d\n", ans);
    return 0;
}

洛谷 P1896 [SCOI2005]互不侵犯 BZOJ 1087: [SCOI2005]互不侵犯King

題目鏈接

題目大意:

​ 在 \(N\times N\) 的棋盤裏面放K個國王,使他們互不攻擊,共有多少種擺放方案。國王能攻擊到它上下左右,以及左上
左下右上右下八個方向上附近的各一個格子,共 \(8\) 個格子。

分析:

​ 首先這道題看起來和上道題有一些相似處,那麼我們對比一下這兩道題:

  • 第一題只限制了四個方向,而這道題是限制了八個方向
  • 第一題中只有方向上的限制,而這道題有人數上的限制

​ 對於上一道題,我們需要開一個二維的數組來實現DP的過程,\(f[i][s]\) 表示到 \(i\) 行且第 \(i\) 行的狀態爲 \(s\) 的方案數。而顯然對於這道題,人數的限制十分令人頭疼,我們不得不用三維數組 \(f[i][s][k]\) ,雖然數組開的下(開不下可以優化),但是這道題當中的人數和方案數有關聯,或者說一種當前行狀態會對應當前狀態下一個特定的人數。

​ 而解決方案則是選擇使用DFS來找到所有合法的情況,單獨使用 \(s[]\) 數組存每個合法狀態的狀態, \(num[]\) 存達到這個狀態需要這一行放多少個國王。

void dfs(int pos, int ss, int tot) {
    if (pos >= n) {
        s[ ++ cnt] = ss;
        num[cnt] = tot;
        return ;
    }
    dfs(pos + 1, ss, tot);
    dfs(pos + 2, ss + (1 << pos), tot + 1);
}

​ 這個時候在考慮整個DP的情況:

  • 狀態:\(dp[i][j][k]\) 表示當前處理到第 \(i\) 行,在這一行選擇編號爲 \(j\) 的擺放方案,在當前的狀態下已經選擇了 \(k\) 個國王的方案數。
  • 轉移:
    • 對於同一行之間國王的衝突,在在於處理的過程中直接避免
    • 對於上下兩行之間國王的衝突,if(s[j] & s[k]) continue;
    • 對於左上方和右下方之間國王的衝突, if(s[j] & (s[k] >> 1)) continue ;
    • 對於右上方和左下方之間國王的衝突, if(s[j] & (s[k] << 1)) continue ;
    • 最終按照揹包過程進行轉移即可

AC代碼:

#include <bits/stdc++.h>

using namespace std;

typedef long long ll;

const int N = 12;

const int S = 2010;

int n, m;

int cnt, s[N * N], num[N * N];

// cnt計數,s數組存狀態,num數組存達到當前狀態需要用多少個國王。

ll dp[N][S][N * N];

void dfs(int pos, int ss, int tot) {
    // DFS預處理出所有的狀態
    // 由於在這道題需要統計國王的數量,所以一個狀態是存不下的,而且狀態又與國王的數量有關,所以採用這種方式
    if (pos >= n) {
        s[ ++ cnt] = ss;
        num[cnt] = tot;
        return ;
        // 當pos>=n的時候說明已經找到一個滿足一行的狀態了
    }
    dfs(pos + 1, ss, tot);
    dfs(pos + 2, ss + (1 << pos), tot + 1);
}

int main() {
    scanf("%d%d", &n, &m);
    dfs(0, 0, 0);
    for (int i = 1; i <= cnt; i ++ ) {
        dp[1][i][num[i]] = 1ll;
    }
    // 預處理出來所有第一行的答案都=1
    for (int i = 2; i <= n; i ++ ) { // 枚舉行數
        for (int j = 1; j <= cnt; j ++ ) {  // 枚舉當前方案
            for (int k = 1; k <= cnt; k ++ ) {  // 枚舉上一行方案
                if (s[j] & s[k]) {
                    // 這裏說明這一行存在某個國王在上一行的下邊
                    continue ;
                }
                if (s[j] & (s[k] >> 1)) {
                    // 這裏說明這一行存在某個國王在上一行的右下角
                    continue ;
                }
                if (s[j] & (s[k] << 1)) {
                    // 這裏說明這一行存在某個國王在上一行的左下角
                    continue ;
                }
                for (int l = num[j]; l <= m; l ++ ) {
                    dp[i][j][l] += dp[i - 1][k][l - num[j]];
                }
            }
        }
    }
    ll ans = 0;
    for (int i = 1; i <= cnt; i ++ ) {
        ans += dp[n][i][m];
    }
    printf("%lld\n", ans);
    return 0;
}

洛谷 P2704 [NOI2001]炮兵陣地

題目鏈接

題目大意:

​ 司令部的將軍們打算在NM的網格地圖上部署他們的炮兵部隊。一個NM的地圖由N行M列組成,地圖的每一格可能是山地(用“H” 表示),也可能是平原(用“P”表示),如下圖。在每一格平原地形上最多可以佈置一支炮兵部隊(山地上不能夠部署炮兵部隊);一支炮兵部隊在地圖上的攻擊範圍如圖中黑色區域所示:

如果在地圖中的灰色所標識的平原上部署一支炮兵部隊,則圖中的黑色的網格表示它能夠攻擊到的區域:沿橫向左右各兩格,沿縱向上下各兩格。圖上其它白色網格均攻擊不到。從圖上可見炮兵的攻擊範圍不受地形的影響。 現在,將軍們規劃如何部署炮兵部隊,在防止誤傷的前提下(保證任何兩支炮兵部隊之間不能互相攻擊,即任何一支炮兵部隊都不在其他支炮兵部隊的攻擊範圍內),在整個地圖區域內最多能夠擺放多少我軍的炮兵部隊。

分析:

​ 我們會發現 \(m \le 10\) ,並且對於每一個位置只有放與不放的兩種決策,並且是一道最優化,那麼考慮狀壓DP 。

​ 首先我們發現,這道題與前兩題不同的就是前兩道題是方案數,而這道題是最大值。而且前兩道題當前一行的狀態只和前一行有關,而顯然這道題當中當前行的狀態需要從前兩行共同決定。

​ 對於這個問題,我們採用以下DP:

  • 狀態:既然與兩行都有關,不妨就把兩個狀態都枚舉出來。設 $dp[i][j][k] $ 表示當前在第 \(i\) 行,當前行的狀態爲 \(j\) ,前一行的狀態爲 \(k\) 時最多可以排放的炮兵部隊數量。
  • 轉移:這道題難點還是在於轉移。首先我們發現第一行和第二行是必須預處理出來的。而從第三行開始,我們需要枚舉當前行的狀態,上一行的狀態,上兩行的狀態。只有三個滿足合法條件,纔可以通過上邊兩個轉移到當前這個。(在地圖上是前兩行轉移到當前行,而在DP過程上是上一個DP狀態轉移到當前的DP狀態,這裏需要理解)
  • 答案:答案顯然就是 \(max(dp[i][j][k])~~~(1\le j \le cnt,~1\le k \le cnt)\)

這道題屬於狀壓DP非常經典的一道題,從這三道題當中應該可以發現狀壓DP的一些小規律。

AC代碼:

#include <bits/stdc++.h>

using namespace std;

const int N = 110;

const int M = 12;

char str[20];

int n, m, mp[N][M], ori[N];

// mp來存整個地圖的山地還是平地的情況。
// ori來存每一行壓縮後的狀態  1爲山地0爲平地

int cnt, s[N * M], num[N * M];

// s來存每一個狀態,num來存這個狀態有多少個炮

int dp[N][(1 << 10)][(1 << 10)];

int getbit(int x) {
    // 用來取出對於一個數 x 在二進制下一共有幾個 1
    int ret = 0;
    while (x) {
        ret ++ ;
        x -= x & (-x);
    }
    return ret;
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 1; i <= n; i ++ ) {
        scanf("%s", str + 1);
        for (int j = 1; j <= m; j ++ ) {
            if (str[j] == 'H') {
                mp[i][j] = 1;
            }
        }
    }
    for (int i = 1; i <= n; i ++ ) {
        for (int j = 1; j <= m; j ++) {
            ori[i] = (ori[i] << 1) + mp[i][j];
        }
    }
    s[ ++ cnt] = 0; // 由於一行什麼都不放也是一種狀態,所以s[1] = 0,num[1] = 0
    for (int i = 1; i <= (1 << m) - 1; i ++ ) {
        if (i & (i << 1)) { // 是否與右邊第一個衝突
            continue ;
        }
        if (i & (i << 2)) { // 是否與右邊第二個衝突
            continue ;
        }
        if (i & (i >> 1)) { // 是否與左邊第一個衝突
            continue ;
        }
        if (i & (i >> 2)) { // 是否與左邊第二個衝突
            continue ;
        }
        s[ ++ cnt] = i;
        num[cnt] = getbit(i);
    }
    // 對於第一行預處理:
    for (int i = 1; i <= cnt; i ++ ) {
        if ((ori[1] & s[i]) == 0) {
            dp[1][i][0] = num[i];
        }
    }
    // 對於第二行預處理:
    for (int i = 1; i <= cnt; i ++ ) {
        if ((ori[2] & s[i]) == 0) {
            for (int j = 1; j <= cnt; j ++ ) {
                if (((s[i] & s[j]) == 0) && (((ori[1] & s[j]) == 0))) {
                    dp[2][i][j] = num[i] + num[j];
                }
            }
        }
    }
    // DP過程:
    for (int i = 3; i <= n; i ++ ) {
        for (int j = 1; j <= cnt; j ++ ) {
            if ((s[j] & ori[i]) == 0) {
                for (int k1 = 1; k1 <= cnt; k1 ++ ) {
                    if (((s[k1] & ori[i - 1]) == 0) && (((s[k1] & s[j]) == 0))) {
                        for (int k2 = 1; k2 <= cnt; k2 ++ ) { 
                            if (((ori[i - 2] & s[k2]) == 0) && ((s[k1] & s[k2]) == 0) && ((s[j] & s[k2]) == 0)) {
                                dp[i][j][k1] = max(dp[i][j][k1], dp[i - 1][k1][k2] + num[j]);
                            }
                        }
                    }
                }
            }
        }
    }
    int ans = 0;
    for (int i = 1; i <= cnt; i ++ ) {
        for (int j = 1; j <= cnt; j ++ ) {
            ans = max(ans, dp[n][i][j]);
        }
    }
    printf("%d\n", ans);
    return 0;
}

洛谷 P4460 [CQOI2018]解鎖屏幕 BZOJ 5299: [Cqoi2018]解鎖屏幕

題目鏈接

使用過Android手機的同學一定對手勢解鎖屏幕不陌生。Android的解鎖屏幕由3x3個點組成,手指在屏幕上畫一條線將其中一些點連接起來,即可構成一個解鎖圖案。如下面三個例子所示:

畫線時還需要遵循一些規則

1.連接的點數不能少於4個。也就是說只連接兩個點或者三個點會提示錯誤。

2.兩個點之間的連線不能彎曲。

3.每個點只能"使用"一次,不可重複。這裏的"使用"是指手指劃過一個點,該點變綠。

4.兩個點之間的連線不能"跨過"另一個點,除非那個點之前已經被"使用"過了。

對於最後一條規則,參見下圖的解釋。左邊兩幅圖違反了該規則:而右邊兩幅圖(分別爲2→4→1→3→6和→5→4→1→9→2)則沒有違反規則,因爲在"跨過"點時,點已經被"使用"過了。

現在工程師希望改進解鎖屏幕,增減點的數目,並移動點的位置,不再是一個九宮格形狀,但保持上述畫線的規則不變。

請計算新的解鎖屏幕上,一共有多少滿足規則的畫線方案。

分析:

​ 這道題也算是和前邊幾個出現比較大差距的一道狀壓DP了。

​ 我們會發現前幾道題都是一個點和它周圍 \(n\) 個點之間的限制,而這道題的限制似乎更廣泛一些。而且前幾道題都是對於網格圖來說的,而這道題則是對於一些離散的點來說。

​ 我們考慮一下限定條件:

  • 點數 \(\ge 4\) 這個限定條件其實可以在最後統計時候進行判斷。
  • 不能彎曲,這個其實不用特殊處理,理解路徑就行。
  • 每個點只能使用一次,因爲我們直接使用狀態壓縮,我們直接判一下當前點在當前狀態中是否出現過即可。
  • 連線不能跨過一個沒走過的點。對於這個特殊的限定條件,我們需要在進行DP之前處理出對於 \(i\)\(j\) 兩個點的連線上有那些點。這樣可以選擇用相似三角形判斷共線或者使用斜率判斷(較麻煩)。

​ 之後考慮狀態。首先我們既然選擇把這 \(n\) 個點壓成一維,我們就一定要從這裏邊的點向外邊連線,而且我們又發現對於同一個狀態,先走某個點和後走某個點並不算做同一種情況。這時我們發現,無論如何我們的狀態中都要有當前狀態從狀態中的哪個點向外引邊。所以不妨就設狀態爲 \(f[s][i]\) 爲當前所有點的 \(0/1\) 狀態爲 \(s\),最後一個點選擇 \(i\) 的畫線方案數。

​ 轉移則是每次枚舉當前狀態的一個點,向其他沒有在當前狀態的點引邊,如果這條邊合法則可以轉移,不合法則跳過。

​ 統計答案的時候特殊判一下當前這個答案是不是最少連接了 \(4\) 個點即可。

AC代碼:

#include <bits/stdc++.h>

using namespace std;

typedef long long ll;

const int mod = 1e8 + 7;

const int N = 21;

int n, Log[1 << N];

// Log[]便於直接取出當前這個元素在第幾個位置(狀態中的這個1代表的是第幾個點)

int line[N][N];

// line[i][j] 存從i到j連線上的點,同樣這裏也將狀態壓縮成一個數

ll f[1 << N][N], ans;

struct Node {
    int x, y;
} node[N];

void init() {
    Log[1] = 0;
    for (int i = 2; i <= (1 << n) - 1; i ++ ) {
        Log[i] = Log[i >> 1] + 1;
    }
    // 預處理Log[]
}

bool check(Node xx, Node a, Node b) {
    // 檢驗點xx是否在a->b的線段上
    if (xx.x < min(a.x, b.x) || xx.x > max(a.x, b.x)) {
        // 可能在直線上但是一定不在線段上
        return 0;
    }
    if (xx.y < min(a.y, b.y) || xx.y > max(a.y, b.y)) {
        // 可能在直線上但是一定不在線段上
        return 0;
    }
    return ((a.x - xx.x) * (xx.y - b.y) == (xx.x - b.x) * (a.y - xx.y));
    // 在線段上
}

int lowbit(int x) {
    // lowbit函數(同樹狀數組中的lowbit),用來取出x在二進制下最右邊一個1
    return x & (-x);
}

int main() {
    scanf("%d", &n);
    init();
    for (int i = 0; i < n; i ++ ) {
        scanf("%d%d", &node[i].x, &node[i].y);
    }
    for (int i = 0; i < n; i ++ ) {
        f[1 << i][i] = 1ll;
        // 初值
    }
    for (int i = 0; i < n; i ++ ) {
        for (int j = 0; j < n; j ++ ) {
            for (int k = 0; k < n; k ++ ) {
                if (k != i && k != j) {
                    if (check(node[k], node[i], node[j])) {
                        // 處理出對於每一條邊經過了哪些點
                        line[i][j] |= (1 << k);
                    }
                }
            }
        }
    }
    for (int s = 0; s <= (1 << n) - 1; s ++ ) {
        for (int k = 0; k < n; k ++ ) {
            int now = s;
            while (now) {
                int ss = lowbit(now);
                int pos = Log[ss];
                // 這裏通過lowbit來實現枚舉,比一位一位判斷要更省時間
                if ((! (s & (1 << k))) && ((line[pos][k] & s) == line[pos][k])) {
                    // 更新狀態,判斷是否出現過以及是否在當前這兩個點的連線上有其他的點
                    f[s | (1 << k)][k] = (f[s | (1 << k)][k] + f[s][pos]) % mod;
                }
                now -= lowbit(now);
            }
        }
    }
    for (int s = 0; s <= (1 << n) - 1; s ++ ) {
        int cnt = 0;
        ll tmp = 0ll;
        for (int i = 0; i < n; i ++ ) {
            if (s & (1 << i)) {
                // 記錄點個數和答案
                cnt ++ ;
                tmp = (tmp + f[s][i]) % mod;
            }
        }
        if (cnt >= 4) {
            // 當個數>=4個的時候就更新答案
            ans = (ans + tmp) % mod;
        }
    }
    printf("%lld\n", ans);
    return 0;
}

四、TSP(旅行商)問題的狀壓DP解法

TSP(旅行商)問題

註明:這裏只是簡單介紹,以便於對類似TSP問題的狀壓dp問題進行討論。並且在這篇講解當中涉及到的TSP問題都是簡化的TSP問題,即並非每個城市只走一次或者路徑可以重複等等

旅行商問題,即TSP問題(Traveling Salesman Problem)又譯爲旅行推銷員問題、貨郎擔問題,是數學領域中著名問題之一。假設有一個旅行商人要拜訪 \(n\) 個城市,他必須選擇所要走的路徑,路徑的限制是每個城市只能拜訪一次,而且最後要回到原來出發的城市。路徑的選擇目標是要求得的路徑路程爲所有路徑之中的最小值。

​ 最早的旅行商問題的數學規劃是由 \(Dantzig(1959 )\) 等人提出,並且是在最優化領域中進行了深入研究。許多優化方法都用它作爲一個測試基準。儘管問題在計算上很困難,但已經有了大量的啓發式算法和精確方法來求解數量上萬的實例,並且能將誤差控制在 \(1\%\) 內。

​ 對於旅行商問題,有一些較爲廣泛的解決方案(並非最優)。通常使用動態規劃法、貪心法、分支限界法等等(包含但不僅限於回溯法、模擬退火算法、遺傳算法、蟻羣算法等等)進行對TSP問題的解決。這裏提供的就是動態規劃解法。

注:TSP問題的動態規劃解法的空間複雜度較高,不適合城市數比較多的情況。

狀壓DP求解TSP問題例題

POJ 3311 Hie with the Pie

題目大意:

​ 有N個城市(1~N且N<=10)要去送匹薩。一個PIZZA店(編號爲0),求一條迴路,從0出發,又回到0,而且距離最短(可重複走)。

分析:

​ 首先我們要求這條路最短,那麼這道題當中一定涉及到最短路,由於數據範圍不大,又是多源,所以採用Floyd處理最短路。

​ 下面考慮DP過程:

  • 狀態:\(dp[s][i]\) 表示當前對於所有城市的 \(0/1\) 狀態爲 \(s\) 的情況下,最後一個送到 \(i\) 號城市的最短距離。
  • 轉移:顯然找到在當前狀態中除了 \(i\) 的其他的所有點轉移到 \(i\) ,並找到路徑最小值。
  • 答案:答案就是所有狀態全爲 \(1\) 的答案中的最小值,但是這裏要求需要回到原點,需要加上當前點到原點的距離。

AC代碼:

// #include <bits/stdc++.h>

#include <iostream>
#include <cstdio>
#include <algorithm>

using namespace std;

const int N = 12;

const int inf = 0x3f3f3f3f;

int n, dis[N][N], dp[1 << N][N];

void floyd() {
    // 由於n很小,這裏直接Floyd處理最短路
    for (int k = 0; k <= n; k ++ ) {
        for (int i = 0; i <= n; i ++ ) {
            for (int j = 0; j <= n; j ++ ) {
                dis[i][j] = min(dis[i][j], dis[i][k] + dis[k][j]);
            }
        }
    }
}

int main() {
    while (1) {
        scanf("%d", &n);
        if (n == 0) {
            break ;
        }
        for (int i = 0; i <= n; i ++ ){
            for (int j = 0; j <= n; j ++ ) {
                scanf("%d", &dis[i][j]);
            }
        }
        floyd();
        for (int s = 0; s <= (1 << n) - 1; s ++ ) {
            for (int i = 1; i <= n; i ++ ) {
                if (s & (1 << (i - 1))) {
                    if (s == (1 << (i - 1))) {
                        dp[s][i] = dis[0][i];
                    }
                    else {
                        dp[s][i] = inf;
                        for (int j = 1; j <= n; j ++ ) {
                            if (s & (1 << (j - 1)) && j != i) {
                                dp[s][i] = min(dp[s][i], dp[s ^ (1 << (i - 1))][j] + dis[j][i]);
                                // 經典狀壓轉移操作,判斷是否可以從j轉移到i,如果可以則轉移
                                // 注意,由於這個矩陣不是對稱的,所以dis[i][j]和dis[j][i]並不一定相同
                            }
                        }
                    }
                }
            }
        }
        int ans = inf;
        for (int i = 1; i <= n; i ++ ) {
            ans = min(ans, dp[(1 << n) - 1][i] + dis[i][0]);
            // 循環統計最小值,這裏需要特殊注意由於最後要回到披薩店,所以要加上dis[i][0];
        }
        printf("%d\n", ans);
    }
    return 0;
}

五、狀壓DP的衍生

對於狀壓DP的衍生,通常指的就是:

基於連通性狀態壓縮的動態規劃問題(插頭DP、輪廓線DP)

而對於這個方面請參考原版論文:鏈接

六、總結

​ 首先對於狀壓DP要理解的就是狀態壓縮。什麼是狀態壓縮、怎麼狀態壓縮、壓縮之後有什麼用,這個不僅僅是狀壓DP的基礎,也是其他一類需要狀態壓縮來進行優化題的基礎。

​ 狀態壓縮適用於數據範圍小(特指所壓縮數據),並且類型相似(例如網格圖)、決策簡單(例如 \(0/1\) 選擇)的DP情況。

​ 在掌握狀態壓縮之前,掌握位運算可能是必須要做的事情。畢竟如果不會位運算的話,如何轉移、如何壓縮狀態都是大問題。

​ 在狀壓DP下,無論怎樣做都沒有改變動態規劃無後效性、最優子結構的本質。所以狀態壓縮可以說是對於動態規劃的一種優化方式或者是實現方式(由於真正實踐上並沒有在時間和空間上體現出有優化,理論上也沒有進行優化,所以更偏向後者)。動態規劃還是動態規劃,狀態、初值、轉移方程和答案都要按照DP的方式一步一步設定好。

​ 由於狀壓DP通常是對於每行進行壓縮,而逐行處理。所以很多時候在賦初值的時候可能會涉及到對於一整排的賦值,當然也可能出現需要對前多排進行賦值。而答案也一樣,不一定是某一個點的答案。由於我們只知道最後的狀態,而不知道如何達到這個狀態,所以在記錄答案的時候,經常需要循環找到我們所需要的值(最大值、最小值、方案數)等等。

​ 狀態壓縮這種方式也是其他很多題的優化方案之一。不妨將狀態壓縮也轉化爲一種考慮問題的方式(與 \(bitset\) 類似)。同樣也可以吧狀壓看作是二進制優化的一種形式,對於解題可能有一些幫助。

借鑑與參考的部分網站:(亂序)

https://baike.baidu.com

https://www.cnblogs.com/Tony-Double-Sky/p/9283254.html

https://blog.csdn.net/forever_dreams/article/details/81074971

https://blog.csdn.net/u013377068/article/details/81054112

https://www.cnblogs.com/Ronald-MOK1426/p/8456945.html

https://www.luogu.org/

https://lydsy.com/JudgeOnline/

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