1. 程式人生 > 其它 >圖解 洛谷 P1985/POJ 3279/USACO 2007 Open Silver Fliptile

圖解 洛谷 P1985/POJ 3279/USACO 2007 Open Silver Fliptile

技術標籤:演算法計算機資料結構演算法矩陣c++

原創文章: https://www.huilon.net.cn/article/21


洛谷 P1985/POJ 3279

Description


Farmer John 知道智商高的奶牛產奶量也會很大,所以他為奶牛們準備了一個翻瓦片的益智遊戲,來看看它們誰最智商最高。

在一個 M × N M \times N M×N 的矩陣上,每個格子作為一個瓦片都可以翻轉。瓦片的一面是黑色,另一面是白色。對一個瓦片進行一次翻轉,可以讓它的顏色由黑到白,或是由白到黑。

但是奶牛們的蹄腳實在太大了,它們翻轉一個格子的瓦片時,與其有公共邊的所有瓦片也會一起翻轉。換句話說, 就是中間與之相連的上下左右共 5 5

5 個瓦片會一起翻轉, 如圖:

示意圖

假設有 4 × 4 4×4 4×4 個方格可以翻動, 當選定 ( 2 , 3 ) (2,3) (2,3) 進行翻動時, 其周圍的方格會一起翻動.

問: 是否可以經過最少次的翻轉,使所有的瓦片都變成白色朝上?如果可以, 並且最少次數的翻轉的結果有多個, 只需要求出字典序最小的那個.

範圍;

1 ≤ M , N ≤ 15 1 \leq M,N \leq 15 1M,N15

Tutorial

因為翻動的邊緣的單向, 所以咱們可以利用這個特性, 對單一個格子進行翻動. 比如下方這個矩陣:

示意圖

咱們暫且不管第二行第三行有什麼東西, 只要第二行都翻一下, 那最終第一行就會都變成 0 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

M 行裡面都有 1 1 1 的問題, 轉為只要解決 1 1 1 行的問題. 這最後一行有多少個 1 1 1 是由第一行翻了哪些格子決定的. 你看看上面的例子, 是不是第一行永遠沒動過? 咱們說通過下一行的翻動, 把上一行的瓦片都變成白色, 現在第一行是最頂行, 他沒有上一行. 要解決這一行, 最簡單的方式就是遍歷結果, 把能翻的都翻一遍. 按翻動是 1 1 1, 不翻動是 0 0 0 來標記, 假設第一行有 4 4 4 個格子, 也就是要看看

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 () 這個函式.