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

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


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


題記

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

  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,而題目中並無給定圖,因此須要咱們本身創建圖。性能

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

    解法1、單向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;  
	}  
};  

    解法2、雙向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,回到正題。下文使用的圖示說明(全部看到的都是橫切面):

首先說明「?方塊」的做用

「?方塊」,表示這個位置是空位置,能夠任意擺放。
上圖的意思就是,當右上角被綠色木塊佔用,此位置固定不變,其餘位置任意擺放,在這種狀況下的堆放方案數。

    解法1、窮舉遍歷

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

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

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

    解法2、遞歸分解

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

2.一、算法演示

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

2.1.一、n=1,k=2的狀況

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

圖 2-1

上圖2-1中:

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

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

這個是排列組合的知識。

2.1.二、n=2,k=3的狀況

其次,咱們再來演示下面更具通常性的計算分解,即當n=2,k=3的狀況,以下圖2-2:

 圖 2-2

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

 圖 2-3

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

2.1.三、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、0一、十、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-五、圖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.三、參考代碼

下面代碼就是根據上面函數原理編寫的。最終執行效率,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.一、核心算法講解

    由於它最終都是分解成一列的狀況進行處理,這就會致使很慢。爲了提升速度,本文使用了緩存機制來提升性能。緩存原理就是,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. 當第一、二、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.四、再次優化

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

  解法3、動態規劃

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

3.一、暴力搜索不可行

首先,由於木塊的寬度都是1,咱們能夠想成2維的問題。也就是說三種木板的規格分別爲1* 1, 1 * 2, 2 * 1。

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

3.二、另闢蹊徑

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

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

    假設現咱們已經徹底覆蓋了前(i– 1)行。那麼因爲覆蓋前(i-1)行致使第i行也不「完整」了。以下圖:

xxxxxxxxx

ooxooxoxo

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

    咱們考慮第i行的狀態,上圖中,第1列咱們能夠用1*1的覆蓋掉,也能夠用1*2的覆蓋前兩列。第四、5列的覆蓋方式和第一、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.三、動態規劃

    這個矩陣做用很大,其實咱們覆蓋的過程能夠認爲是這樣:第一行是空的,咱們看看把它覆蓋了,第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.四、參考代碼

    最終結果,咱們最初的行是空得,要求最後一行以後也不能被覆蓋,因此最終結果是矩陣的第[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、二零一三年八月十四日。

相關文章
相關標籤/搜索