程序員編程藝術第三十二~三十三章:最小操作數,木塊砌牆問題

    第三十二~三十三章:最小操作數,木塊砌牆問題


作者:July、caopengcs、紅色標記。致謝:fuwutu、demo。
時間:二零一三年八月十二日


題記

    再過一兩月,便又到了每年的九月十月校招高峯期,在此依次推薦:

  1. 程序員編程藝術http://blog.csdn.net/column/details/taopp.html
  2. 秒殺99%的海量數據處理面試題http://blog.csdn.net/v_july_v/article/details/7382693
  3. 《編程之美》;
  4. 微軟面試100題系列http://blog.csdn.net/column/details/ms100.html
  5. 《劍指offer》

    一年半前在學校的那會,我曾經無比瘋狂的創作程序員編程藝術這個系列,因爲當時我堅信它能幫到更多的人找到更好的工作,此刻今後,我更加無比堅信這點。

    同時,相信你也已看到,編程藝術系列的創作原則是把受衆定位爲一個編程初學者,從看到問題後最先想到的思路開始講解,一點一點改進,不斷優化。

    而本文主要講下述兩個問題:

  • 第三十二章:最小操作數問題,主要由caopengcs完成;
  • 第三十三章:木塊砌牆問題,主要由紅色標記和caopengcs完成。
    全文由July統一整理修訂完成。OK,還是很真誠的那句話:有任何問題,歡迎讀者隨時批評指正,感謝。


第三十二章、最小操作數

    題目詳情如下:

    給定一個單詞集合Dict,其中每個單詞的長度都相同。現從此單詞集合Dict中抽取兩個單詞A、B,我們希望通過若干次操作把單詞A變成單詞B,每次操作可以改變單詞的一個字母,同時,新產生的單詞必須是在給定的單詞集合Dict中。求所有行得通步數最少的修改方法。

   舉個例子如下:
Given:
   A = "hit"
   B = "cog"
   Dict = ["hot","dot","dog","lot","log"]
Return
 [
   ["hit","hot","dot","dog","cog"],
   ["hit","hot","lot","log","cog"]
 ]

    即把字符串A = "hit"轉變成字符串B = "cog",有以下兩種可能:
"hit" -> "hot" ->  "dot" ->  "dog" -> "cog";

"hit" ->  "hot" ->  "lot" ->  "log"  ->"cog"。

    詳解:本題是一個典型的圖搜索算法問題。此題看似跟本系列的第29章的字符串編輯距離相似,但其實區別特別大,原因是最短編輯距離是讓某個單詞增加一個字符或減少一個字符或修改一個字符達到目標單詞,來求變換的最少次數,但此最小操作數問題就只是改變一個字符。 

    通過此文:http://blog.csdn.net/v_JULY_v/article/details/6111353,我們知道,在圖搜索算法中,有深度優先遍歷DFS和廣度優先遍歷BFS,而題目中並沒有給定圖,所以需要我們自己建立圖。

    涉及到圖就有這麼幾個問題要思考,節點是什麼?邊如何建立?圖是有方向的還是無方向的?包括建好圖之後,如何記錄單詞序列等等都是我們要考慮的問題。

    解法一、單向BFS法

    1建圖

    對於本題,我們的圖的節點就是字典裏的單詞,兩個節點有連邊,對應着我們可以把一個單詞按照規則變爲另外一個單詞。比如我們有單詞hat,它應該與單詞cat有一條連邊,因爲我們可以把h變爲c,反過來我們也可以把c變爲h,所以我們建立的連邊應該是無向的。
            如何建圖?有兩種辦法,

  • 第一種方法是:我們可以把字典裏的任意兩個單詞,通過循環判斷一下這兩個單詞是否只有一個位置上的字母不同。即假設字典裏有n個單詞,我們遍歷任意兩個單詞的複雜度是O(n2),如果每個單詞長度爲length,我們判斷兩個單詞是否連邊的複雜度是O(length),所以這個建圖的總複雜度是O(n2*length)。但當n比較大時,這個複雜度非常高,有沒有更好的方法呢?
  • 第二種方法是:我們把字典裏地每個單詞的每個位置的字母修改一下,從字典裏查找一下(若用基於red-black tree的map查找,其查找複雜度爲O(logn),若用基於hashmap的unordered_map,則查找複雜度爲O(1)),修改後的單詞是否在字典裏出現過。即我們需要遍歷字典裏地每一個單詞O(n),嘗試修改每個位置的每個字母,對每個位置我們需要嘗試26個字母(其實是25個,因爲要改得和原來不同),因此這部分複雜度是O(26*length),總複雜度是O(26 * n * length)  第二種方法優化版:這第二種方法能否更優?在第二種方法中,我們對每個單詞每個位置嘗試了26次修改,事實上我們可以利用圖是無向的這一特點,我們對每個位置試圖把該位置的字母變到字典序更大的字母。例如,我們只考慮cat變成hat,而不考慮hat變成cat,因爲再之前已經把無向邊建立了。這樣,只進行一半的修改次數,從而減少程序的運行時間。當然這個優化從複雜度上來講是常數的,因此稱爲常數優化,此雖算是一種改進,但不足以成爲第三種方法,原因是我們經常忽略O背後隱藏的常數

    OK,上面兩種方法孰優孰劣呢?直接比較n2*length 與 26 * n * length的大小。很明顯,通常情況下,字典裏的單詞個數非常多,也就是n比較大,因此第二種方法效果會好一些,稍後的參考代碼也會選擇上述第二種方法的優化。

    2記錄單詞序列

    對於最簡單的bfs,我們是如何記錄路徑的?如果只需要記錄一條最短路徑的話,我們可以對每個走到的位置,記錄走到它的前一個位置。這樣到終點後,我們可以不斷找到它的前一個位置。我們利用了最短路徑的一個特點:即第二次經過一個節點的時候,路徑長度不比第一次經過它時短。因此這樣的路徑是沒有圈的。
    但是本題需要記錄全部的路徑,我們第二次經過一個節點時,路徑長度可能會和第一次經過一個節點時路徑長度一樣。這是因爲,我們可能在第i層中有多個節點可以到達第(i + 1)層的同一個位置,這樣那個位置有多條路徑都是最短路徑。

    如何解決呢?——我們記錄經過這個位置的前面所有位置的集合。這樣一個節點的前驅不是一個節點,而是一個節點的集合。如此,當我們第二次經過一個第(i+ 1)層的位置時,我們便保留前面那第i層位置的集合作爲前驅。

    3遍歷
            解決了以上兩個問題,我們最終得到的是什麼?如果有解的話,我們最終得到的是從終點開始的前一個可能單詞的集合,對每個單詞,我們都有能得到它的上一個單詞的集合,直到起點。這就是bfs分層之後的圖,我們從終點開始遍歷這個圖的到起點的所有路徑,就得到了所有的解,這個遍歷我們可以採用之前介紹的dfs方法(路徑的數目可能非常多)。
            其實,爲了簡單起見,我們可以從終點開始bfs,因爲記錄路徑記錄的是之前的節點,也就是反向的。這樣最終可以按順序從起點遍歷到終點的所有路徑。

參考代碼如下:

//copyright@caopengcs   
//updated@July 08/12/2013  
class Solution  
{  
public:  
	// help 函數負責找到所有的路徑  
	void help(intx,vector<int> &d, vector<string> &word,vector<vector<int> > &next,vector<string> &path,vector<vector<string> > &answer) {  
		path.push_back(word[x]);  
		if (d[x] == 0) {   //已經達到終點了  
			answer.push_back(path);  
		}  
		else {  
			int i;  
			for (i = 0; i <next[x].size(); ++i) {  
				help(next[x][i],d, word, next,path,answer);  
			}  
		}  
		path.pop_back();   //回溯  
	}  

	vector<vector<string>> findLadders(string start, string end, set<string>& dict)  
	{  

		vector<vector<string> > answer;  
		if (start == end) {   //起點終點恰好相等  
			return answer;  
		}  
		//把起點終點加入字典的map  
		dict.insert(start);  
		dict.insert(end);  
		set<string>::iterator dt;  
		vector<string> word;  
		map<string,int>allword;  
		//把set轉換爲map,這樣每個單詞都有編號了。  
		for (dt = dict.begin(); dt!= dict.end(); ++dt) {  
			word.push_back(*dt);  
			allword.insert(make_pair(*dt, allword.size()));  
		}  

		//建立連邊 鄰接表  
		vector<vector<int> > con;  
		int i,j,n =word.size(),temp,len = word[0].length();  
		con.resize(n);  
		for (i = 0; i < n; ++i){  
			for (j = 0; j <len; ++j) {  
				char c;  
				for (c =word[i][j] + 1; c <= 'z'; ++c) {  //根據上面第二種方法的優化版的思路,讓每個單詞每個位置變更大  
					char last =word[i][j];  
					word[i][j] =c;  
					map<string,int>::iterator t = allword.find(word[i]);  
					if (t !=allword.end()) {  
						con[i].push_back(t->second);  
						con[t->second].push_back(i);  
					}  
					word[i][j] =last;  
				}  
			}  

		}  

		//以下是標準bfs過程  
		queue<int> q;  
		vector<int> d;  
		d.resize(n, -1);  
		int from = allword[start],to = allword[end];  
		d[to] = 0;  //d記錄的是路徑長度,-1表示沒經過  
		q.push(to);  
		vector<vector<int> > next;  
		next.resize(n);  
		while (!q.empty()) {  
			int x = q.front(), now= d[x] + 1;  
			//now相當於路徑長度
			//當now > d[from]時,則表示所有解都找到了
			if ((d[from] >= 0)&& (now > d[from])) {  
				break;  
			}  
			q.pop();  
			for (i = 0; i <con[x].size(); ++i) {  
				int y = con[x][i];  
				//第一次經過y
				if (d[y] < 0) {    
					d[y] = now;  
					q.push(y);  
					next[y].push_back(x);  
				}  
				//非第一次經過y
				else if (d[y] ==now) {  //是從上一層經過的,所以要保存  
					next[y].push_back(x);  
				}  

			}  
		}  
		if (d[from] >= 0) {  //有解  
			vector<string>path;  
			help(from, d,word,next, path,answer);  
		}  
		return answer;  
	}  
};  

    解法二、雙向BFS法

    BFS需要把每一步搜到的節點都存下來,很有可能每一步的搜到的節點個數越來越多,但最後的目的節點卻只有一個。後半段的很多搜索都是白耗時間了。

    上面給出了單向BFS的解法,但看過此前blog中的這篇文章“A*、Dijkstra、BFS算法性能比較演示”可知:http://blog.csdn.net/v_JULY_v/article/details/6238029,雙向BFS性能優於單向BFS。

    舉個例子如下,第1步,是起點,1個節點,第2步,搜到2個節點,第3步,搜到4個節點,第4步搜到8個節點,第5步搜到16個節點,並且有一個是終點。那這裏共出現了31個節點。從起點開始廣搜的同時也從終點開始廣搜,就有可能在兩頭各第3步,就相遇了,出現的節點數不超過(1+2+4)*2=14個,如此就節省了一半以上的搜索時間。

    下面給出雙向BFS的解法,參考代碼如下:

//copyright@fuwutu 6/26/2013
class Solution
{
public:
	vector<vector<string>> findLadders(string start, string end, set<string>& dict)
	{
		vector<vector<string>> result, result_temp;
		if (dict.erase(start) == 1 && dict.erase(end) == 1) 
		{
			map<string, vector<string>> kids_from_start;
			map<string, vector<string>> kids_from_end;

			set<string> reach_start;
			reach_start.insert(start);
			set<string> reach_end;
			reach_end.insert(end);

			set<string> meet;
			while (meet.empty() && !reach_start.empty() && !reach_end.empty())
			{
				if (reach_start.size() < reach_end.size())
				{
					search_next_reach(reach_start, reach_end, meet, kids_from_start, dict);
				}
				else
				{
					search_next_reach(reach_end, reach_start, meet, kids_from_end, dict);
				}
			}

			if (!meet.empty())
			{
				for (set<string>::iterator it = meet.begin(); it != meet.end(); ++it)
				{
					vector<string> words(1, *it);
					result.push_back(words);
				}

				walk(result, kids_from_start);
				for (size_t i = 0; i < result.size(); ++i)
				{
					reverse(result[i].begin(), result[i].end());
				}
				walk(result, kids_from_end);
			}
		}

		return result;
	}

private:
	void search_next_reach(set<string>& reach, const set<string>& other_reach, set<string>& meet, map<string, vector<string>>& path, set<string>& dict)
	{
		set<string> temp;
		reach.swap(temp);

		for (set<string>::iterator it = temp.begin(); it != temp.end(); ++it)
		{
			string s = *it;
			for (size_t i = 0; i < s.length(); ++i)
			{
				char back = s[i];
				for (s[i] = 'a'; s[i] <= 'z'; ++s[i])
				{
					if (s[i] != back)
					{
						if (reach.count(s) == 1)
						{
							path[s].push_back(*it);
						}
						else if (dict.erase(s) == 1)
						{
							path[s].push_back(*it);
							reach.insert(s);
						}
						else if (other_reach.count(s) == 1)
						{
							path[s].push_back(*it);
							reach.insert(s);
							meet.insert(s);
						}
					}
				}
				s[i] = back;
			}
		}
	}

	void walk(vector<vector<string>>& all_path, map<string, vector<string>> kids)
	{
		vector<vector<string>> temp;
		while (!kids[all_path.back().back()].empty())
		{
			all_path.swap(temp);
			all_path.clear();
			for (vector<vector<string>>::iterator it = temp.begin(); it != temp.end(); ++it)
			{
				vector<string>& one_path = *it;
				vector<string>& p = kids[one_path.back()];
				for (size_t i = 0; i < p.size(); ++i)
				{
					all_path.push_back(one_path);
					all_path.back().push_back(p[i]);
				}
			}
		}
	}
};


第三十三章、木塊砌牆

題目:用 1×1×1, 1× 2×1以及2×1×1的三種木塊(橫綠豎藍,且綠藍長度均爲2),

搭建高長寬分別爲K × 2^N × 1的牆,不能翻轉、旋轉(其中,0<=N<=1024,1<=K<=4)

有多少種方案,輸出結果

對1000000007取模。

舉個例子如給定高度和長度:N=1 K=2,則答案是7,即有7種搭法,如下圖所示:

    詳解:此題很有意思,涉及的知識點也比較多,包括動態規劃,快速矩陣冪,狀態壓縮,排列組合等等都一一考察了個遍。而且跟一個比較經典的矩陣乘法問題類似:即用1 x 2的多米諾骨牌填滿M x N的矩形有多少種方案,M<=5,N<2^31,輸出答案mod p的結果

OK,回到正題。下文使用的圖示說明(所有看到的都是橫切面):

首先說明“?方塊”的作用

“?方塊”,表示這個位置是空位置,可以任意擺放。
上圖的意思就是,當右上角被綠色木塊佔用,此位置固定不變,其他位置任意擺放,在這種情況下的堆放方案數。

    解法一、窮舉遍歷

    初看此題,你可能最先想到的思路便是窮舉:用二維數組模擬牆,從左下角開始擺放,從左往右,從下往上,最後一個格子是右上角那個位置;每個格子把每種可以擺放木塊都擺放一次,每堆滿一次算一種用擺放方法。爲了便於描述,爲木塊的每個格子進行編號:

下面演示當n=1,k=2的算法過程(7種情況):

    窮舉遍歷在數據規模比較小的情況下還撐得住,但在0<=N<=1024這樣的數據規模下,此方法則立刻變得有心無力,因此我們得尋找更優化的解法。

    解法二、遞歸分解

遞歸求解就是把一個大問題,分解成小問題,逐個求解,然後再解決大問題。

2.1、算法演示

假如有牆規模爲(n,k),如果從中間切開,被分爲規模問(n-1,k)的兩堵牆,那麼被分開的牆和原牆有什麼關係呢?我們首先來看一下幾組演示。

2.1.1、n=1,k=2的情況

首先演示,n=1,k=2時的情況,如下圖2-1:

圖 2-1

上圖2-1中:

 表示,左邊牆的所有堆放方案數 * 右邊牆所有堆放方案數 = 2 * 2 = 4

表示,當切開處有一個橫條的時候,空位置存在的堆放方案數。左邊*右邊 = 1*1 = 2;剩餘兩組以此類推。

這個是排列組合的知識。

2.1.2、n=2,k=3的情況

其次,我們再來演示下面更具一般性的計算分解,即當n=2,k=3的情況,如下圖2-2:

 圖 2-2

再從分解的結果中,挑選一組進行分解演示:

 圖 2-3

通過圖2-2和圖2-3的分解演示,可以說明,最終都是分解成一列求解。在逐級向上彙總。

2.1.3、n=4,k=3的情況

我們再假設一堵牆n=4,k=3,也就是說,寬度是16,高度是3時,會有以下分解:

圖2-4

根據上面的分解的一箇中間結果,再進行分解,如下:

圖 2-5

    通過上面圖2-1~圖2-5的演示可以明確如下幾點:

  1. 假設f(n)用於計算問題,那麼f(n)依賴於f(n-1)的多種情況。
  2. 切開處有什麼特殊的地方呢?通過上面的演示,我們得知被切開的兩堵牆從沒有互相嵌入的木塊(綠色木塊)到全是互相連接的木塊,相當於切口綠色木塊的全排列(即有綠色或者沒有綠色的所有排列),即有2^k種狀態(比如k=2,且有綠色用1表示,沒有綠色用0表示,那麼就有00、01、10、11這4種狀態)。根據排列組合的性質,把每一種狀態下左右木牆堆放方案數相乘,再把所有乘積求和,就得到木牆的堆放結果數。以此類推,將問題逐步往下分解即可。
  3. 此外,從圖2-5中可以看出,除了需要考慮切口綠色木塊的狀態,還需要考慮最左邊一列和最右邊一列的綠色木塊狀態。我們把這兩種邊界狀態稱爲左邊界狀態和右邊界狀態,分別用leftStaterightState表示。 

且在觀察圖2-5被切分後,所有左邊的牆,他們的左邊界ls狀態始終保持不變,右邊界rs狀態從0~maxState, maxState = 2^k-1(有綠色方塊表示1,沒有表示0;ls表示左邊界狀態,rs表示右邊狀態):

圖 2-6

 同樣可以看出右邊的牆的右邊界狀態保持不變,而左邊界狀態從0~maxState。要堆砌的木牆可以看做是左邊界狀態=0,和右邊界狀態=0的一堵牆。

    有一點可能要特別說明下,即上文中說,有綠色方塊的狀態表示標爲1,無綠色方塊的狀態表示標爲0,特意又拿上圖2-6標記了一些數字,以讓絕大部分讀者能看得一目瞭然,如下所示:

       

  圖2-7

這下,你應該很清楚的看到,在上圖中,左邊木塊的狀態表示一律爲010,右邊木塊的狀態表示則是000~111(即從下至上開始計數,右邊木塊rs的狀態用二進制表示爲:000 001 010 011 100 101 110 111,它們各自分別對應整數則是:0 1 2 3 4 5 6 7)。

2.2、計算公式

通過圖2-4、圖2-5、圖2-6的分解過程,我們可以總結出下面公式(leftState=最左邊邊界狀態,rightState=最右邊邊界狀態):

即:

    接下來,分3點解釋下上述公式:

    1、上述函數返回結果是當左邊狀態爲=leftState,右邊狀態=rightState時木牆的堆砌方案數,相當於直接分解的左右狀態都爲0的情況,即直接分解f(n,k,0,0)即可。看到這,讀者可能便有疑問了,既然直接分解f(n,k,0,0)即可,爲何還要加leftstate和leftstate兩個變量呢?回顧下2.1.3節中n=4,k=3的演示例子,即當n=4,k=3時,其分解過程即如下圖(上文2.1.3節中的圖2-4
    也就是說,剛開始直接分解f(4,3,0,0),即n=4,k=3,leftstate=0,rightstate=0,但分解過程中leftstate和rightstate皆從0變化到了maxstate,故才讓函數的第3和第4個參數採用leftstate和rightstate這兩個變量的形式,公式也就理所當然的寫成了f(n,k,leftstate,rightstate)。
    2、然後我們再看下當n=4,k=3分解的一箇中間結果,即給定如上圖最下面部分中紅色框框所框住的木塊時

 

它用方程表示即爲 f(2,3,2,5),怎麼得來的呢?其實還是又回到了上文2.1.3節中,當n=2,k=3 時(下圖即爲上文2.1.3節中的圖2-5和圖2-6


    左邊界ls狀態始終保持不變時,右邊界rs狀態從0~maxState;右邊界狀態保持不變時,而左邊界狀態從0~maxState。

    故上述分解過程用方程式可表示爲:

f(2,3,2,5) = f(1,3,2,0) * f(1,3,0,5)
            + f(1,3,2,1) * f(1,3,1,5)
           + f(1,3,2,2) * f(1,3,2,5)
            + f(1,3,2,3) * f(1,3,3,5)
           + f(1,3,2,4) * f(1,3,4,5)
           + f(1,3,2,5) * f(1,3,5,5)
            + f(1,3,2,6) * f(1,3,6,5)
            + f(1,3,2,7) * f(1,3,7,5)

    說白了,我們曾在2.1節中從圖2-2到圖2-6正推推導出了公式,然上述過程中,則又再倒推推了一遍公式進行了說明。
    3、最後,作者是怎麼想到引入 leftstate 和rightstate 這兩個變量的呢?如紅色標記所說:"因爲切開後,發現綠色條,在分開出不斷的變化,當時也進入了死衚衕,我就在想,藍色的怎麼辦。後來纔想明白,與藍色無關。每一種變化就是一種狀態,所以就想到了引入leftstate 和rightstate這兩個變量。"

2.3、參考代碼

下面代碼就是根據上面函數原理編寫的。最終執行效率,n=1024,k=4 時,用時0.2800160秒(之前代碼用的是字典作爲緩存,用時在1.3秒左右,後來改爲數組結果,性能大增)。

//copyright@紅色標記 12/8/2013  
//updated@July 13/8/2013
using System;  
using System.Collections.Generic;  
using System.Text;  
using System.Collections;  

namespace HeapBlock  
{  
	public class WoolWall  
	{          
		private int n;  
		private int height;  
		private int maxState;  
		private int[, ,] resultCache;   //結果緩存數組  

		public WoolWall(int n, int height)  
		{  
			this.n = n;  
			this.height = height;  
			maxState = (1 << height) - 1;  
			resultCache = new int[n + 1, maxState + 1, maxState + 1];   //構建緩存數組,每個值默認爲0;  
		}  

		/// <summary>  
		/// 靜態入口。計算堆放方案數。  
		/// </summary>  
		/// <param name="n"></param>  
		/// <param name="k"></param>  
		/// <returns></returns>  
		public static int Heap(int n, int k)  
		{  
			return new WoolWall(n, k).Heap();  
		}  

		/// <summary>  
		/// 計算堆放方案數。  
		/// </summary>  
		/// <returns></returns>  
		public int Heap()  
		{  
			return (int)Heap(n, 0, 0);  
		}  

		private long Heap(int n, int lState, int rState)  
		{  
			//如果緩存數組中的值不爲0,則表示該結果已經存在緩存中。  
			//直接返回緩存結果。  
			if (resultCache[n, lState, rState] != 0)  
			{  
				return resultCache[n, lState, rState];  
			}  

			//在只有一列的情況,無法再進行切分  
			//根據列狀態計算一列的堆放方案  
			if (n == 0)  
			{  
				return CalcOneColumnHeapCount(lState);  
			}  

			long result = 0;  
			for (int state = 0; state <= maxState; state++)  
			{  
				if (n == 1)  
				{  
					//在只有兩列的情況,判斷當前狀態在切分之後是否有效  
					if (!StateIsAvailable(n, lState, rState, state))  
					{  
						continue;  
					}  
					result += Heap(n - 1, state | lState, state | lState)  //合併狀態。因爲只有一列,所以lState和rState相同。  
						* Heap(n - 1, state | rState, state | rState);  
				}  
				else  
				{  
					result += Heap(n - 1, lState, state) * Heap(n - 1, state, rState);   
				}  
				result %= 1000000007;//爲了防止結果溢出,根據題目要求求模。  
			}  

			resultCache[n, lState, rState] = (int)result;   //將結果寫入緩存數組中  
			resultCache[n, rState, lState] = (int)result;   //對稱的牆結果相同,所以直接寫入緩存。  
			return result;  
		}  

		/// <summary>  
		/// 根據一列的狀態,計算列的堆放方案數。  
		/// </summary>  
		/// <param name="state">狀態</param>  
		/// <returns></returns>  
		private int CalcOneColumnHeapCount(int state)  
		{  
			int sn = 0; //連續計數  
			int result = 1;  
			for (int i = 0; i < height; i++)  
			{  
				if ((state & 1) == 0)  
				{  
					sn++;  
				}  
				else  
				{  
					if (sn > 0)  
					{  
						result *= CalcAllState(sn);  
					}  
					sn = 0;  
				}  
				state >>= 1;  
			}  
			if (sn > 0)  
			{  
				result *= CalcAllState(sn);  
			}  

			return result;  
		}  

		/// <summary>  
		/// 類似於斐波那契序列。  
		/// f(1)=1  
		/// f(2)=2  
		/// f(n) = f(n-1)*f(n-2);  
		/// 只是初始值不同。  
		/// </summary>  
		/// <param name="k"></param>  
		/// <returns></returns>  
		private static int CalcAllState(int k)  
		{  
			return k <= 2 ? k : CalcAllState(k - 1) + CalcAllState(k - 2);  
		}  

		/// <summary>  
		/// 判斷狀態是否可用。  
		/// 當n=1時,分割之後,左牆和右邊牆只有一列。  
		/// 所以state的狀態碼可能會覆蓋原來的邊緣狀態。  
		/// 如果有覆蓋,則該狀態不可用;沒有覆蓋則可用。  
		/// 當n>1時,不存在這種情況,都返回狀態可用。  
		/// </summary>  
		/// <param name="n"></param>  
		/// <param name="lState">左邊界狀態</param>  
		/// <param name="rState">右邊界狀態</param>  
		/// <param name="state">切開位置的當前狀態</param>  
		/// <returns>狀態有效返回 true,狀態不可用返回 false</returns>  
		private bool StateIsAvailable(int n, int lState, int rState, int state)  
		{  
			return (n > 1) || ((lState | state) == lState + state && (rState | state) == rState + state);  
		}  
	}  
}  

上述程序中,

  • WoolWall.Heap(1024,4); //直接通過靜態方法獲得結果
  • new  WoolWall(n, k).Heap();//通過構造對象獲得結果

2.3.1、核心算法講解

    因爲它最終都是分解成一列的情況進行處理,這就會導致很慢。爲了提高速度,本文使用了緩存機制來提高性能。緩存原理就是,n,k,leftState,rightState相同的牆,返回的結果肯定相同。利用這個特性,每計算一種結果就放入到緩存中,如果下次計算直接從緩存取出。剛開始緩存用字典類實現,有網友給出了更好的緩存方法——數組。這樣性能好了很多,也更加簡單。程序結構如下圖所示:

    上圖反應了Heep調用的主要方法調用,在循環中,result 累加 lResult 和 rResult。

①在實際代碼中,首先是從緩存中讀取結果,如果沒有緩存中讀取結果在進行計算。 

分解法分解到一列時,不在分解,直接計算機過

if (n == 0)
{
     return CalcOneColumnHeap(lState);
}

②下面是整個程序的核心代碼,通過for循環,求和state=0到state=2^k-1的兩邊木牆乘積

            for (int state = 0; state <= maxState; state++)
            {
                if (n == 1)
                {
                    if (!StateIsAvailable(n, lState, rState, state))
                    {
                        continue;
                    }
                    result += Heap(n - 1, state | lState, state | lState) *
                        Heap(n - 1, state | rState, state | rState);
                }
                else
                {
                    result += Heap(n - 1, lState, state)
                        * Heap(n - 1, state, rState);
                }
                result %= 1000000007;
            }

    當n=1切分時,需要特殊考慮。如下圖:

圖2-8

    看上圖中,因爲左邊牆中間被綠色方塊佔用,所以在(1,0)-(1,1)這個位置(位置的標記方法同解法一)不能再放綠色方塊。所以一些狀態需要排除,如state=2需要排除。同時在還需要合併狀態,如state=1時,左邊牆的狀態=3。

    特別說明下:依據我們上文2.2節中的公式,如果第i行有這種木塊,state對應2^(i-1),加上所有行的貢獻就得到state(0就是沒有這種橫跨木塊,2^k-1就是所有行都是橫跨木塊),然後遍歷state,還記得上文中的圖2-7麼?

    當第i行被這樣的木塊或這樣的木塊佔據時,其各自對應的state值分別爲:

  1. 當第1行被佔據,state=1;
  2. 當第2行被佔據,state=2;
  3. 當第1和第2行都被佔據,state=3;
  4. 當第3行被佔據,state=4;
  5. 當第1和第3行被佔據,state=5;
  6. 當第2和第3行被佔據,state=6;
  7. 當第1、2、3行全部都被佔據,state=7。
    至於原因,即如2.1.3節節末所說:二進制表示爲:000 001 010 011 100 101 110 111,它們各自分別對應整數則是:0 1 2 3 4 5 6 7。

    具體來說,下面圖中所有框出來的位置,不能有綠色的:


③CalcOneColumnHeap(int state)函數用於計算一列時擺放方案數。

    計算方法是, 求和被綠色木塊分割開的每一段連續方格的擺放方案數。每一段連續的方格的擺放方案通過CalcAllState方法求得。經過分析,可以得知CalcAllState是類似斐波那契序列的函數。

    舉個例子如下(分步驟講述):

  1. 令state = 4546(state=2^k-1,k最大爲4,故本題中state最大在15,而這裏取state=4546只是爲了演示如何計算),二進制是:1000111000010。位置上爲1,表示被綠色木塊佔用,0表示空着,可以自由擺放。
  2. 1000111000010  被分割後 1  000  111  0000  1  0, 那麼就有 000=3個連續位置, 0000=4個連續位置 , 0=1個連續位置。
  3. 堆放結果=CalcAllState(3) + CalcAllState(4) + CalcAllState(1) = 3 + 5 + 1 = 9。

2.4、再次優化

    上面程序因爲調用性能的樹形結構,形成了大量的函數調用和緩存查找,所以其性能不是很高。 爲了得到更高的性能,可以讓所有的運算直接依賴於上一次運算的結果,以防止更多的調用。即如果每次運算都算出所有邊界狀態的結果,那麼就能爲下一次運算提供足夠的信息。後續優化請查閱此文第3節:http://blog.csdn.net/dw14132124/article/details/9038417#t2

  解法三、動態規劃

相信讀到上文,不少讀者都已經意識到這個問題其實就是一個動態規劃問題,接下來咱們換一個角度來分析此問題。

3.1、暴力搜索不可行

首先,因爲木塊的寬度都是1,我們可以想成2維的問題。也就是說三種木板的規格分別爲1* 1, 1 * 2, 2 * 1。

     通過上文的解法一,我們已經知道這個問題最直接的想法就是暴力搜索,即對每個空格嘗試放置哪種木板。但是看看數據規模就知道,這種思路是不可行的。因爲有一條邊範圍長度高達21024,普通的電腦,230左右就到極限了。於是我們得想想別的方法。

3.2、另闢蹊徑

    爲了方便,我們把牆看做有2n行,k列的矩形。這是因爲雖然矩形木塊不能翻轉,但是我們同時擁有1*2和2*1的兩種木塊。

    假設我們從上到下,從左到右考慮每個1*1的格子是如何被覆蓋的。顯然,我們每個格子都要被覆蓋住。木塊的特點決定了我們覆蓋一個格子最多隻會影響到下一行的格子。這就可以讓我們暫時只考慮兩行。

    假設現我們已經完全覆蓋了前(i– 1)行。那麼由於覆蓋前(i-1)行導致第i行也不“完整”了。如下圖:

xxxxxxxxx

ooxooxoxo

我們用x表示已經覆蓋的格子,o表示沒覆蓋的格子。爲了方便,我們使用9列。

    我們考慮第i行的狀態,上圖中,第1列我們可以用1*1的覆蓋掉,也可以用1*2的覆蓋前兩列。第4、5列的覆蓋方式和第1、2列是同樣的情況。第7列需要覆蓋也有兩種方式,即用1*1的覆蓋或者用2*1的覆蓋,但是這樣會導致第(i+1)行第7列也被覆蓋。第9列和第7列的情況是一樣的。這樣把第i行覆蓋滿了之後,我們再根據第(i+1)行被影響的狀態對下一行進行覆蓋。

    那麼每行有多少種狀態呢?顯然有2k,由於k很小,我們只有大約16種狀態。如果我們對於這些狀態之間的轉換製作一個矩陣,矩陣的第i行第j列的數表示的是我們第m行是狀態i,我們把它完整覆蓋掉,並且使得第(m + 1)行變成狀態j的可能的方法數,這個矩陣我們可以暴力搜索出來,搜索的方式就是枚舉第m行的狀態,然後嘗試放木板,用所有的方法把第m行覆蓋掉之後,下一行的狀態。當然,我們也可以認爲只有兩行,並且第一行是2k種狀態的一種,第二行起初是空白的,求使得第一行完全覆蓋掉,第二行的狀態有多少種類型以及每種出現多少次。

3.3、動態規劃

    這個矩陣作用很大,其實我們覆蓋的過程可以認爲是這樣:第一行是空的,我們看看把它覆蓋了,第2行是什麼樣子的。根據第二行的狀態,我們把它覆蓋掉,看看第3行是什麼樣子的。

    如果我們知道第i行的狀態爲s,怎麼考慮第i行完全覆蓋後,第(i+1)行的狀態?那隻要看那個矩陣的狀態s對應的行就可以了。我們可以考慮一下,把兩個這樣的方陣相乘得到得結果是什麼。這個方陣的第i行第j個元素是這樣得到的,是第i行第k個元素與第k行第j個元素的對k的疊加。它的意義是上一行是第m行是狀態i,把第m行和第(m+ 1)行同時覆蓋住,第(m+2)行的狀態是j的方法數。這是因爲中間第(m+1)行的所有狀態k,我們已經完全遍歷了。

    於是我們發現,每做一次方陣的乘法,我們相當於把狀態推動了一行。那麼我們要坐多少次方陣乘法呢?就是題目中牆的長度2n,這個數太大了。但是事實上,我們可以不斷地平方n次。也就是說我們可以算出A2,A4, A8, A16……方法就是不斷用結果和自己相乘,這樣乘n次就可以了。

    因此,我們最關鍵的問題就是建立矩陣A。我們可以這樣表示一行的狀態,從左到右分別叫做第0列,第1列……覆蓋了我們認爲是1,沒覆蓋我們認爲是0,這樣一行的狀態可以表示維一個整數。某一列的狀態我們可以用爲運算來表示。例如,狀態x第i列是否被覆蓋,我們只需要判斷x & (1 << i) 是否非0即可,或者判斷(x >> i) & 1, 用右移位的目的是防止溢出,但是本題不需要考慮溢出,因爲k很小。 接下來的任務就是遞歸嘗試放置方案了

3.4、參考代碼

    最終結果,我們最初的行是空得,要求最後一行之後也不能被覆蓋,所以最終結果是矩陣的第[0][0]位置的元素。另外,本題在乘法過程中會超出32位int的表示範圍,需要臨時用C/C++的long long,或者java的long。

    參考代碼如下:
//copyright@caopengcs 12/08/2013
#ifdef WIN32
#define ll __int64 
#else
#define ll long long
#endif

// 1 covered 0 uncovered

void cal(int a[6][32][32],int n,int col,int laststate,int nowstate) {
	if (col >= n) {
		++a[n][laststate][nowstate];
		return;
	}
	//不填 或者用1*1的填
	cal(a,n, col + 1, laststate, nowstate);
	if (((laststate >> col) & 1) == 0) {
		cal(a,n, col + 1, laststate, nowstate | (1 << col));
		if ((col + 1 < n) && (((laststate >> (col + 1)) & 1) == 0)) {
			cal(a,n, col + 2, laststate, nowstate);
		}
	}
}

inline int mul(ll x, ll y) {
	return x * y % 1000000007;
}

void multiply(int n,int a[][32],int b[][32]) { // b = a * a
	int i,j, k;
	for (i = 0; i < n; ++i) {
		for (j = 0; j < n; ++j) {
			for (k = b[i][j] = 0; k < n; ++k) {
				if ((b[i][j] += mul(a[i][k],a[k][j])) >= 1000000007) {
					b[i][j] -= 1000000007;
				}
			}
		}
	}
}

int calculate(int n,int k) {
	int i, j;
	int a[6][32][32],mat[2][32][32];
	memset(a,0,sizeof(a));
	for (i = 1; i <= 5; ++i) {
		for (j = (1 << i) - 1; j >= 0; --j) {
			cal(a,i, 0, j, 0);
		}
	}
	memcpy(mat[0], a[k],sizeof(mat[0]));
	k = (1 << k);
	for (i = 0; n; --n) {
		multiply(k, mat[i], mat[i ^ 1]);
		i ^= 1;
	}
	return mat[i][0][0];
}


參考鏈接及推薦閱讀

  1. caopengcs,最小操作數:http://blog.csdn.net/caopengcs/article/details/9919341
  2. caopengcs,木塊砌牆:http://blog.csdn.net/caopengcs/article/details/9928061
  3. 紅色標記,木塊砌牆:http://blog.csdn.net/dw14132124/article/details/9038417
  4. LoveHarvy,木塊砌牆:http://blog.csdn.net/wangyan_boy/article/details/9131501
  5. 在線編譯測試木塊砌牆問題:http://hero.pongo.cn/Question/Details?ID=36&ExamID=36
  6. 編程藝術第29章字符串編輯距離:http://blog.csdn.net/v_july_v/article/details/8701148#t4
  7. matrix67,十個利用矩陣乘法解決的經典題目:http://www.matrix67.com/blog/archives/276
  8. leetcode上最小操作數一題:http://leetcode.com/onlinejudge#question_126
  9. hero上木塊砌牆一題:http://hero.pongo.cn/Question/Details?ExamID=36&ID=36&bsh_bid=273040296
  10. 超然煙火,http://blog.csdn.net/sunnianzhong/article/details/9326289

後記

    在本文的創作過程中,caopengcs開始學會不再自以爲是了,意識到文章應該儘量寫的詳細點,很不錯;而紅色標記把最初的關於木塊砌牆問題的原稿給我後,被我拉着來來回回修改了幾十遍才罷休,尤其畫了不少的圖,辛苦感謝。

    此外,圍繞"編程”"面試”"算法”3大主題的程序員編程藝術系列http://blog.csdn.net/v_JULY_v/article/details/6460494,始創作於2011年4月,那會還在學校,如今已寫了33章,今年內我會Review已寫的這33章,且繼續更新,朋友們若有發現任何問題,歡迎隨時評論於原文下或向我反饋,我會迅速修正,感激不盡。

    July、二零一三年八月十四日。

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