有關狀壓DP

【以下內容僅為本人在學習中的所感所想,本人水平有限目前尚處學習階段,如有錯誤及不妥之處還請各位大佬指正,請諒解,謝謝!】

引言

動態規劃雖然已經是對暴力演算法的優化,但在某些比較特別的情況下,可以通過一些小技巧進一步對其優化,通產我們會在時間與空間中做權衡,在時間可以接受度範圍內,適當的以時間為代價換取更小空間的佔用;在不爆空間的情況下,適當的以空間換時間。在此,本人將以目前總結的經驗詳細介紹狀態壓縮與狀壓DP。

狀態壓縮

(一)狀態

狀態指某個事物表現出來的形態(百度百科)。聯繫前面的文章(有關動態規劃 – PaperHammer – 部落格園 (cnblogs.com)),我們在分析解決動態規劃的問題時,其重點是在「分情況定變數」這一步,即有多少種選擇,如何選擇。不妨把這每一個選擇視為一個事物,則它表現出來的形態就是所做出的選擇,更進一步就是選擇的結果。所以,動態規劃中的狀態實際上就是每一種情況

(二)壓縮

壓縮是一種通過特定的演算法來減小電腦文件大小的機制,其目的是減少所佔空間的同時提高運算速度(百度百科)。壓縮的本質是減小,但不可否認雖然減小了文件體積提高了傳輸速度,但會存在文件品質的下降。

(三)狀態壓縮

一般地,我們規定利用電腦的二進位性質來描述所做出的選擇,即將所有情況統一分為:行或不行、放或不放等兩種情況(將很多情況歸於兩類情況)。由此可以發現,對於狀態壓縮,我們是針對有多少種選擇進行了壓縮,即對空間進行優化,那麼根據互斥性原則(自己編的名字),對空間上的優化勢必會帶來時間上的複雜。所以,狀態壓縮在時間上其實是一種很暴力的思想,它需要遍歷每一種情況,每種情況有兩種選擇(0或1),最高會達到2n的時間複雜度,但針對一些題目,可以依靠某些限定條件,大幅降低時間複雜度,從而變相達到既優化了空間也優化了時間的目的。

【註:相關位運算內容在此不作說明】

舉例

(一)吃乳酪

鏈接:P1433 吃乳酪 – 洛谷 | 電腦科學教育新生態 (luogu.com.cn)

題意:房間里放著n塊乳酪。一隻小老鼠要把它們都吃掉,問至少要跑多少距離?老鼠一開始在(0,0)點處。

分析:據題意可將其抽象為,從原點出發,返回走過所有點的最小距離。最值問題容易想到搜索。搜索時跳過重複路徑,最後進行比較即可。

 1 using System;
 2 using System.Collections.Generic;
 3 using System.Linq;
 4 using System.Text;
 5 
 6 namespace ConsoleApp1
 7 {
 8     internal class Program
 9     {
10         static int n;
11         static double res = double.MaxValue;
12         static NodeToNode[][] ntn;
13         static int[] vis;
14         static void Main(string[] args)
15         {
16             Program p = new Program();
17             n = int.Parse(Console.ReadLine());
18             n += 1;
19             double[] x = new double[n], y = new double[n];
20             x[0] = 0;
21             y[0] = 0;
22             for (int i = 1; i < n; i++)
23             {
24                 string[] inp = Console.ReadLine().Split(' ');
25                 x[i] = double.Parse(inp[0]);
26                 y[i] = double.Parse(inp[1]);
27             }//錄入所有點,包括(0,0)
28             ntn = new NodeToNode[n][];
29             for (int i = 0; i < n; i++) ntn[i] = new NodeToNode[n];
30             for (int i = 0; i < n; i++)
31             {
32                 for (int j = 0; j < n; j++)
33                 {
34                     ntn[i][j].dis = Dis(x[i], y[i], x[j], y[j]);//計算每兩個點間的距離並編號
35                     ntn[i][j].next = j;
36                 }
37                 ntn[i] = ntn[i].OrderBy(k => k.dis).ThenBy(k => k.next).ToArray();//一定要注意這個排序方式!!!!!
38             }           
39 
40             //for (int i = 0; i < n; i++)
41             //    for (int j = 0; j < n; j++)
42             //        Console.WriteLine(ntn[i][j].dis + " " + ntn[i][j].next);
43 
44             vis = new int[n];
45             vis[0] = 1;
46             p.Dfs(0, 0.0, 1);
47             Console.WriteLine(res.ToString("0.00"));
48             //Console.ReadLine();
49         }
50         public void Dfs(int cur, double sum, int cnt)
51         {
52             if(cnt == n)
53             {
54                 res = res <= sum ? res : sum;
55                 return;
56             }
57             if (sum >= res) return;
58             for(int i = 0; i < n; i++)
59             {
60                 if(vis[ntn[cur][i].next] == 0 && cur != ntn[cur][i].next)
61                 {
62                     vis[ntn[cur][i].next] = 1;
63                     Dfs(ntn[cur][i].next, sum + ntn[cur][i].dis, cnt + 1);
64                     vis[ntn[cur][i].next] = 0;
65                 }
66             }
67         }
68         public struct NodeToNode
69         {
70             public double dis;
71             public int next;
72         }
73         private static double Dis(double x1, double y1, double x2, double y2)
74         {
75             return Math.Sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
76         }
77     }
78 }

View Code

這種思路本質上是對除原點以外的所有點,進行全排列,比較每一種排列結果,其時間複雜度為O(N*N!),(應該是),在比賽中一般僅能承受N<=10的數據範圍。

 

 

再分析:因為在深度搜索中會出現大量重複訪問的數據,所以優化搜索演算法,最先想到的一般是記憶化處理,記住到達某一個點的最小距離。但這種方法不能保證最壞的情況,有一部分情況和之前無異。本題除了距離因素,就只剩乳酪因素,既然記住距離還不夠,那就只能在「乳酪」上下手了。

對於每個乳酪,只有兩種情況:吃過/沒吃過;抽象出來即,對於每個點:走過/沒走過。發現對於每個事物(乳酪/點),僅有兩種狀態(訪問過/沒訪問過),滿足基本狀態壓縮的前提,所以我們考慮使用二進位來表示每個狀態。

  a.  大化小:最終問題是讓總距離最小,最終距離是基於每一次選擇得來的,那麼就把從一個點到一個新的點,這一選擇視為一個子問題。並且每次選擇總以最小最小距離為基礎進行操作,每次選擇處理方式相同。

  b.  分情況定變數:對於每個點,只有兩種情況(走或不走);我們需要知道當前這個點是否走過,所以要記錄當前所在點;還需要知道已經走過了幾個乳酪,所以要記錄對點(乳酪)的訪問情況,共兩個變數。用f[i][j]表示老鼠當前走到第i個點(乳酪)處,且走過的點的二進位狀態為j時,最短的距離。

:可以使用二進位10100110來表示已經走過第2、3、6、8個乳酪(定義此處索引從1開始),此時j的值為166。需要注意的是,第i個狀態是從低位向高位的第i位,即從低位向高位進行轉移

  c.  推方程:如果要走這個點(保證該點可訪問)那麼f[i][j] = f[j][k – (1 << (i − 1)] + dis(i, j)其中f[i][j]表示以i為起點走成狀態j的最小距離,dis表示兩點間距離。

 

解釋 k – (1 << i − 1) 】

(1)  符號『<<』:表示某十進位數對應的二進位數向右移,等價於對十進位數*2。

如:1 << 3表示將1對應的二進位編碼向右移動4位  (1)10 = (0001)2 右移5位變為(1000)2 = (8)10 = 1 * 23

(2)式子:最終目標的狀態 – 當前位置的狀態 = 中間狀態

      如:(5)10 =(0101)2 = (0111)10 – (0010)2 = (7)10 – (2)10

                    上一個中間狀態 = 目標狀態 – 當前位置狀態

 

【難點】為什麼可以用 k & (1 << (i – 1)判斷合法位置

我們設一個狀態 k = 01101,表示第一、三、四列(從低位開始)中的某個點已經訪問過;

由於我們是一行一行訪問的,所以在k狀態我們應該訪問第三行的點了,那第三行我們應該訪問哪個點,或者說狀態k由哪些狀態轉移而來呢?

狀態k(01101)由三種狀態(必然是前兩行的狀態)來的:

  前兩行在三、四列已訪問,第三行只好訪問第一列;(01100)

  前兩行在一、四列已訪問,第三行只好訪問第三列;(01001)

  前兩行在一、三列已訪問,第三行只好訪問第四列;(00101)

無非就是這三種情況,現在我們來考慮怎麼來表示狀態s由這三種狀態來的,k & (1 << (i – 1))就是用來實現這個功能的,即判斷當前情況是否為其上一個情況轉移而來。

for (int i = 1; i <= 4; i++)

  01101 & (1<<0) = 01101 & 1 = 00001

  01101 & (1<<1) = 01101 & 10 = 00000

  01101 & (1<<2) = 01101 & 100 = 00100

  01101 & (1<<3) = 01101 & 1000 = 01000

  01101 & (1<<4) = 01101 & 10000 = 00000

由此得出,只有和k=01101有1重合結果才大於0,根據這個特性判斷此列是否可以訪問。

  d.  定邊界:本題在搜索過程中不存在索引非法而導致的無法訪問點,但需要對初值進行設定,因為其默認值為0,我們需要存儲最小距離,所以應對初值設定為一個較大的值。

【註:文末程式碼中已附有更加詳細的注釋】

 

(二)01背包

【註:題目解析請轉至該文章有關動態規劃 – PaperHammer – 部落格園 (cnblogs.com)

在該問題中,對於每種物品只有兩種選擇,不放,故也可以使用狀態壓縮進行優化。

如:有5件物品,

  如果這5件物品都不放的話,那就是00000;

  如果這5件物品都放的話,那就是11111;

觀察可知,在上面的例子中00000 ~ 11111可以代表所有的情況,轉化為十進位就是0到(1 << (5 – 1));

其中,所以f[10000]只能從f[00000] + W[1] 轉移過來;f[11000]可以從f[01000] + W[1]或者f[10000] + W[2]轉移過來,以此類推

總結

  1.  注意位運算的運算等級順序,括弧很重要

  2.  狀壓dp的特點一般是規模比較小,一般小於15,最多不超過20;而且一般只有兩種決策

狀態壓縮比較難理解,本篇文章也花了快兩周時間,雖然寫成了筆記,但本人理解程度依舊不深,在此希望與各位大佬相互交流一起學習

【感謝您可以抽出時間閱讀到這裡;受限於水平,內容可能會有許多不妥之處,許多地方可能存在錯誤,還請各位大佬留言指正,請見諒,謝謝!】

 

#附文中所提到的第一題的程式碼 及 狀壓模板

(1)吃乳酪

 1 using System;
 2 using System.Collections.Generic;
 3 using System.Linq;
 4 using System.Text;
 5 
 6 namespace ConsoleApp1
 7 {
 8     internal class Program
 9     {
10         static int n;
11         static double res = double.MaxValue;
12         static double[][] f;
13         static double[] x, y;
14         static void Main(string[] arg)
15         {
16             n = int.Parse(Console.ReadLine());
17             x = new double[20];
18             y = new double[20];
19             
20             for (int i = 1; i <= n; i++)
21             {
22                 string[] inp = Console.ReadLine().Split(' ');
23                 x[i] = double.Parse(inp[0]);
24                 y[i] = double.Parse(inp[1]);
25             }
26 
27             //初始化,因為之後的比較是取較小的一個,所以將所有值預設為最大
28             f = new double[20][];
29             for(int i = 1; i <= n; i++)
30             {
31                 f[i] = new double[35000];
32                 Array.Fill(f[i], double.MaxValue);
33             }
34 
35             DP(f, n);
36             Console.WriteLine(res.ToString("0.00"));
37             //Console.ReadLine();
38         }
39         static public double DP(double[][] f, int n)
40         {
41             for (int k = 1; k <= (1 << n) - 1; k++)
42                 for (int i = 1; i <= n; i++)
43                 {
44                     if ((k & (1 << (i - 1))) == 0) continue;//已經訪問過
45                     if (k == (1 << (i - 1)))
46                     {
47                         f[i][k] = 0;//標記為不可訪問
48                         continue;//當前位置與目標位置相同
49                     }
50                     for(int j = 1; j <= n; j++)
51                     {
52                         if ((k & (1 << (j - 1))) == 0 || i == j) continue;//如果不可訪問 或 兩個位置相同 則跳過
53                         f[i][k] = Math.Min(f[i][k], f[j][k - (1 << (i - 1))] + Dis(i, j));
54                     }
55                 }
56             for (int i = 1; i <= n; i++)
57             {
58                 double cur = f[i][(1 << n) - 1] + Dis(i, 0);
59                 res = Math.Min(res, cur);
60             }
61                 
62             return res;
63         }
64         private static double Dis(int a,int b)
65         {
66             return Math.Sqrt((x[a] - x[b]) * (x[a] - x[b]) + (y[a] - y[b]) * (y[a] - y[b]));
67         }
68     }
69 }

 

(2)一般狀壓模板

 1 int n;
 2 int maxn = 1 << n;//總狀態數。
 3     //枚舉已有的集合數。按照狀態轉移的順序,一般從小編號到大編號。
 4     for(int i = 1; i <= m; ++ i){
 5         //枚舉當前集合中的狀態。
 6         for(int j = 0; j < maxn; ++ j){
 7             //判斷當前集合是否處於合法狀態,通常我們需用一個數組提前處理好。如g數組;
 8             if(當前狀態是否合格){
 9                 for(int k = 0; k < maxn; ++ k){
10                     //枚舉上一個集合的狀態。
11                     if(上一個集合的狀態是否合格 + 上一個集合的狀態和當前狀態的集合是否產生了衝突){
12                         列寫狀態轉移方程。
13                     }
14                 }
15             }
16         }
17     }
18 }