圖解 洛谷 P1985/POJ 3279/USACO 2007 Open Silver Fliptile
原創文章: https://www.huilon.net.cn/article/21
洛谷 P1985/POJ 3279
Description
Farmer John 知道智商高的奶牛產奶量也會很大,所以他為奶牛們準備了一個翻瓦片的益智遊戲,來看看它們誰最智商最高。
在一個 M × N M \times N M×N 的矩陣上,每個格子作為一個瓦片都可以翻轉。瓦片的一面是黑色,另一面是白色。對一個瓦片進行一次翻轉,可以讓它的顏色由黑到白,或是由白到黑。
但是奶牛們的蹄腳實在太大了,它們翻轉一個格子的瓦片時,與其有公共邊的所有瓦片也會一起翻轉。換句話說, 就是中間與之相連的上下左右共
5
5
假設有 4 × 4 4×4 4×4 個方格可以翻動, 當選定 ( 2 , 3 ) (2,3) (2,3) 進行翻動時, 其周圍的方格會一起翻動.
問: 是否可以經過最少次的翻轉,使所有的瓦片都變成白色朝上?如果可以, 並且最少次數的翻轉的結果有多個, 只需要求出字典序最小的那個.
範圍;
1 ≤ M , N ≤ 15 1 \leq M,N \leq 15 1≤M,N≤15
Tutorial
因為翻動的邊緣的單向, 所以咱們可以利用這個特性, 對單一個格子進行翻動. 比如下方這個矩陣:
咱們暫且不管第二行第三行有什麼東西, 只要第二行都翻一下, 那最終第一行就會都變成
0
0
在第二行第一個格子翻一下, 第一行第一個格子變成
0
0
0. 第二行第二個格子翻一下, 第一行第二個格子也變成了
0
0
0.
以此類推, 把第二行的格子都翻一下, 就有下方這種情況, 第一行都變成
0
0
0.
想像一下, 如果給定的 N N N 是無窮大的. 那咱們是不是可以通過下一行的翻動, 把上一行的瓦片都變成白色? 這樣就可以把無窮多行都翻成白色.
回過頭來, 給定的 N N N 是有限的, 那通過下一行的翻動, 上一行變成白色, 最終, 咱們都把 1 1 1 留在了最後一行, 其他行都是 0 0 0. 比如上方的例子:
現在只有最後一行才有
1
1
1. 也就是說, 咱們從一開始要解決
M
M
0000
0001
0010
0011
0100
0101
0110
0111
...
1111
這些翻動的情況到底哪一個才能讓最後一行都是 0 0 0
咱們可以觀察到上方就是 4 4 4位二進位制數的加法, 從 0000 0000 0000 加到 1111 1111 1111, 二進位制的 1111 1111 1111 轉換到十進位制就是 2 4 = 8 2^4=8 24=8, 所以第一行有 n n n 個格子時, 總共要遍歷 2 n 2^n 2n 次, n n n 最大是 15 15 15, 2 15 = 32768 2^{15}=32768 215=32768, 可以接受. 而且從 0000 0000 0000 加到 1111 1111 1111的結果剛好是按字典序遞增的, 所以如果有多個相同翻動次數的解的時候, 最先遇到的解, 就是咱們要的字典序最小的解. (不過不是翻動次數最少的解, 依舊需要遍歷完整 2 n 2^n 2n 次)
遍歷時, 對於每一個解, 也就是第一行的翻動情況, 要根據下一行的翻動, 把上一行的瓦片都變成白色這種方式, 從第二行根據第一行來翻動開始, 一直翻到最後一行格子, 邊翻動邊記錄翻動情況. 翻完之後, 如果最後一行都是
0
0
0, 那麼這個時候整個矩陣也都是
0
0
0. 整個矩陣的翻動情況可能就是最終答案. 把這個答案的翻動瓦片的次數記錄下來, 把這個可能是最終答案的答案也記錄下來. 在下次得出第二個解的時候, 比較一下, 如果第二個解的翻動瓦片的次數比第一個解的翻動次數要小, 說明第二個解更接近最終答案. 把第二個解記錄下來, 第一個解不要了.
以此類推, 最終翻動次數最小的答案就是最終答案.
值得提出的是, 如果是用 C++ 來實現, 遍歷從 0000 0000 0000 到 1111 1111 1111, 咱們有很多方法, 比較方便的就是用 bitset 資料結構.
C++的 bitset 在 bitset 標頭檔案中,它是一種類似陣列的結構,它的每一個元素只能是0或1,每個元素僅用1bit空間。
.
取自網路
bitset 有一個模板構造:
template<size_t _Nb> class bitset {...}
_Nb 是儲存大小, 可以把他當做是陣列大小, 如果咱們把他定義為 4 4 4, 就是隻能儲存 0000 到1111. 這個儲存大小的數只能是常量, 所以不能動態定義 n n n 個大小的 bitset, 對於這道題, 咱們定義最大值 15 15 15, 寬度不足 15 15 15 的時候擷取前面部分.
bitset 有一個建構函式:
bitset(unsigned long long __val) : _Base(...) {...}
__val
需要一個十進位制整數, 當用這個建構函式時, 就可以把十進位制轉換成二進位制儲存. 比如咱們可以用他來列舉和輸出 0000 到 1111:
#include <iostream>
#include <bitset>
using namespace std;
int main() {
for (int i = 0; i < (1<<4)); ++i) {
// 1<<4 是指 2^4. "<<" 是左移符號, 表示把二進位制整體往左移動. 左邊超出範圍的捨棄掉
// 考慮一下 1 的二進位制數(補零之後): "00000001"
// 往左移動一個單位就是 "00000010"
// 往左移動四個單位就是 "00010000"
// 這個 10000 是 16 的的二進位制數
// 2^4 = 16, 從 0 迴圈到 16 剛好是 0000 到 1111
bitset<4> bit(i);
cout << bit << endl;
}
return 0;
}
輸出:
0000
0001
0010
0011
0100
0101
0110
0111
1000
1001
1010
1011
1100
1101
1110
1111
Process finished with exit code 0
bitset 有很多好用的函式, 這道題要用到 .flip()
, .count()
, .reset()
這三個函式.
.
bitset<8> foo ("10011011"); cout << foo.count() << endl; //5 (count函式用來求bitset中1的位數,foo中共有5個1 cout << foo.size() << endl; //8 (size函式用來求bitset的大小,一共有8位 cout << foo.test(0) << endl; //true (test函式用來查下標處的元素是0還是1,並返回false或true,此處foo[0]為1,返回true cout << foo.test(2) << endl; //false (同理,foo[2]為0,返回false cout << foo.any() << endl; //true (any函式檢查bitset中是否有1 cout << foo.none() << endl; //false (none函式檢查bitset中是否沒有1 cout << foo.all() << endl; //false (all函式檢查bitset中是全部為1
test函式會對下標越界作出檢查,而通過 [ ] 訪問元素卻不會經過下標檢查,所以,在兩種方式通用的情況下,選擇test函式更安全一些
.bitset<8> foo ("10011011"); cout << foo.flip(2) << endl; //10011111 (flip函式傳引數時,用於將引數位取反,本行程式碼將foo下標2處"反轉",即0變1,1變0 cout << foo.flip() << endl; //01100000 (flip函式不指定引數時,將bitset每一位全部取反 cout << foo.set() << endl; //11111111 (set函式不指定引數時,將bitset的每一位全部置為1 cout << foo.set(3,0) << endl; //11110111 (set函式指定兩位引數時,將第一引數位的元素置為第二引數的值,本行對foo的操作相當於foo[3]=0 cout << foo.set(3) << endl; //11111111 (set函式只有一個引數時,將引數下標處置為1 cout << foo.reset(4) << endl; //11101111 (reset函式傳一個引數時將引數下標處置為0 cout << foo.reset() << endl; //00000000 (reset函式不傳引數時將bitset的每一位全部置為0
取自網路
另外, .clip()
函式可以按下標來用, 比如foo[i].flip()
是對第 i 個位進行取反. 而且下標從 0 開始, 從末尾開始數, 比如 11010, 第 0 個位置 foo[0] 是 0, 第 4 個位置 foo[4] 是開頭的 1.
回過頭來, 咱們對第一行進行列舉, 有以下虛擬碼:
#include <bitset>
using namespace std;
bitset<15> result[15];
// result 是記錄哪個方塊需要翻動, 需要的記為 1. 預設是 0
// bitset<15> result 算作一個數組, 那 bitset<15> result[15] 就是二維陣列
int main() {
int n,m; cin >> m >> n;
for (int caseCounter = 0; caseCounter < (1 << n); ++caseCounter) {
// caseCounter: 第幾種情況, 從 0 開始
result[0] = bitset<15> (caseCounter); // 每次迴圈重新賦值, 算是初始化吧
for (int i = 0; i < 15; ++i)
if(result[0][i]) // 如果第一行的某個方塊的翻動記錄是 1
flip(1, n-i); // 翻動他
}
}
整體的流程就有以下虛擬碼:
#include <iostream>
#include <stdio.h>
#include <bitset>
using namespace std;
int a[17][17];
int aCopy[17][17];
bitset<15> result[15];
bitset<15> answer[15];
int main() {
輸入 n 和 m;
輸入矩陣, 記為 a;
bool 有解 = false;
int 最小翻動的次數 = INT32_MAX;
for (int caseCounter = 0; caseCounter < (1 << n); ++caseCounter) {
重置矩陣和翻動記錄 (比如把 a 矩陣複製一份, 命名為 aCopy, 把翻動記錄 result 重新賦值為 0)
根據 caseCounter 翻動第一行, 然後根據第一行翻動第二行到最後一行,
把結果矩陣記錄到 aCopy, 把翻動的情況記為 result;
if (aCopy 全部是 0 /* 也就是說全是白色瓦片, 沒有黑色瓦片 */,
並且 最小翻動的次數 > 當前翻動記錄 result 的翻動次數 ) {
有解 = true;
複製 result 到 answer 一份;
最小翻動的次數 = 當前翻動記錄 result 的翻動次數;
}
}
if(有解) {
輸出 answer;
} else 輸出 "IMPOSSIBLE";
return 0;
}
對於 main 函式來說, 有以下主要程式碼:
#include <iostream>
#include <stdio.h>
#include <bitset>
using namespace std;
int a[17][17]; // 定義 17 個長度, 這樣在翻動的時候, 四周就不用考慮是否是邊緣,
int aCopy[17][17]; // 只要咱們在取的時候不要越界就行
bitset<15> result[15];
bitset<15> answer[15]; // 最終答案
int main() {
int m, n; cin >> m >> n; // 輸入 m 和 n
for (int i = 1; i <= m; ++i) for (int j = 1; j <= n; ++j)
scanf("%d", &a[i][j]); // 輸入矩陣, 記為 a
bool hasAnswer = false; // 有解
int minFlipCount = INT32_MAX; // 最小翻動的次數
for (int caseCounter = 0; caseCounter < (1 << n); ++caseCounter) {
reset(m, n); // 重置矩陣和翻動記錄
generateFirstLineForResultByInput(caseCounter, n); // 根據 caseCounter 翻動第一行
flipAll(m, n); // 根據第一行翻動第二行到最後一行, 把結果矩陣記錄到 aCopy, 把翻動的情況記為 result
int flipCountOfCurrentResult = countFlipOfResult(m); // 當前翻動記錄 result 的翻動次數
if(flipCountOfCurrentResult < minFlipCount && isAllFlipped(m, n)) {
// 最小翻動的次數 > 當前翻動記錄 result 的翻動次數 並且 aCopy 全部是 0
hasAnswer = true;
saveAnswer(m); // 複製 result 到 answer 一份
minFlipCount = flipCountOfCurrentResult;
}
}
if(hasAnswer) {
for (int i = 0; i < m; ++i) { // 輸出 answer
for (int j = n-1; j >= 0; --j) {
if(j != n-1) cout << " ";
cout << answer[i][j];
}
cout << endl;
}
} else cout << "IMPOSSIBLE" << endl;
return 0;
}
加上函式實現, 有以下程式碼:
#include <iostream>
#include <stdio.h>
#include <bitset>
using namespace std;
int a[17][17];
int aCopy[17][17];
bitset<15> result[15];
bitset<15> answer[15];
// 重置矩陣 a 和翻動記錄情況 result.
// 咱們每次翻動瓦片的時候, 只要用 aCopy 就好了, a 是用來重置的, 不能改他. 重置的時候就把 aCopy 重置到剛輸入的時候, 也就是 a 的模樣.
inline void reset(int rowCount, int columnCount) {
for (int i = 0; i < rowCount; ++i)
result[i].reset();
for (int i = 1; i <= rowCount; ++i)
for (int j = 1; j <= columnCount; ++j)
aCopy[i][j] = a[i][j];
}
// 根據給定座標 (row, column), 翻動指定座標
int adjacentPosition[5][2] = {{-1,0},{0,-1},{0,0},{0,1},{1,0}}; // 4個方向加自己的翻動的相對位置
inline void flip (int row, int column) {
for (int i = 0; i < 5; ++i) {
int x = adjacentPosition[i][0] + row;
int y = adjacentPosition[i][1] + column;
aCopy[x][y] = !aCopy[x][y]; // 取反
}
}
// 根據第一行翻動第二行到最後一行
inline void flipAll(int rowCount, int columnCount) {
for (int row = 2, i = 1; row <= rowCount; ++row, ++i) {
for (int column = columnCount, j = 0; column > 0; --column, ++j) {
if(aCopy[row-1][column] == 1) { // 在矩陣中, 上面一行是1, 也就是黑色.
flip(row, column); // 翻動
result[i][j].flip(); // 記錄翻動情況, 由 0 變為 1
}
}
}
}
// 計算翻動情況 result 有多少個 1, 也就是翻動了多少次
inline int countFlipOfResult(int rowCount) {
int counter = 0;
for (int i = 0; i < rowCount; ++i) {
counter += result[i].count();
}
return counter;
}
// 是否 aCopy 全部都是白色瓦片
inline bool isAllFlipped(int rowCount, int columnCount) {
for (int i = 1; i <= rowCount; ++i)
for (int j = 1; j <= columnCount; ++j)
if(aCopy[i][j] == 1)
return false;
return true;
}
// 拷貝 result 到最終答案 answer
inline void saveAnswer(int rowCount) {
for (int i = 0; i < rowCount; ++i)
for (int j = 0; j < 15; ++j)
answer[i][j] = result[i][j];
}
// 根據 caseCounter 翻動第一行瓦片
inline void generateFirstLineForResultByInput(int input, int columnCount) {
result[0] = bitset<15> (input);
for (int i = 0; i < 15; ++i)
if(result[0][i])
flip(1, columnCount-i);
}
int main() {
int m, n; cin >> m >> n;
for (int i = 1; i <= m; ++i) for (int j = 1; j <= n; ++j)
scanf("%d", &a[i][j]);
bool hasAnswer = false;
int minFlipCount = INT32_MAX;
for (int caseCounter = 0; caseCounter < (1 << n); ++caseCounter) {
reset(m, n);
generateFirstLineForResultByInput(caseCounter, n);
flipAll(m, n);
int flipCountOfCurrentResult = countFlipOfResult(m);
if(flipCountOfCurrentResult < minFlipCount && isAllFlipped(m, n)) {
hasAnswer = true;
saveAnswer(m);
minFlipCount = flipCountOfCurrentResult;
}
}
if(hasAnswer) {
for (int i = 0; i < m; ++i) {
for (int j = n-1; j >= 0; --j) {
if(j != n-1) cout << " ";
cout << answer[i][j];
}
cout << endl;
}
} else cout << "IMPOSSIBLE" << endl;
return 0;
}
inline
百度解釋:inline 關鍵字用來定義一個類的行內函數,引入它的主要原因是用它替代 C 中表達式形式的巨集定義。
通俗解釋:inline 類似於 #define,不過它可以來定義函式。
inline 好處:這種巨集定義在形式及使用上像一個函式,但它使用前處理器實現,沒有了引數壓棧,程式碼生成等一系列的操作,因此,效率很高。
通俗一點:inline 可以提升速度。
.
取自網路
為了理清流程, 我把很多程式碼都提取出了一個函式, 在實際過程中, 可以不提取, 只留下 flip ()
這個函式.