程序员编程艺术第三十二~三十三章:最小操作数,木块砌墙问题

    第三十二~三十三章:最小操作数,木块砌墙问题

作者:July、caopengcs、红色标记
时间:二零一三年八月十二日

题记

    可能再过一两月,便又到了每年九月十月校招高峰期,在此推荐:

  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

    我在学校那会曾经无比疯狂的创作程序员编程艺术这个系列,因为那会我坚信它能帮到更多的人找到更好的工作,此刻今后,我更加无比坚信这点。

    OK,本文主要讲下述两个问题:

  • 第三十二章:最小操作数问题,主要由caopengcs完成;
  • 第三十三章:木块砌墙问题,主要由caopengcs、红色标记完成。
    编程艺术系列的创作原则是把定位为一个编程初学者,从看到问题后最先想到的思路开始讲解,一点一点改进,不断优化。
    还是很真诚的那句话:有任何问 题,欢迎读者随时批评指正,感谢。


第三十二章、最小操作数

    题目详情如下:

    给定一个单词集合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"。

    详解:本题是一个典型的图搜索算法问题。通过此文: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比较大时,这个复杂度非常高,有没有更好的方法呢?
  • 第二种方法是:我们把字典里地每个单词的每个位置的字母修改一下,从字典里查找一下,修改后的单词是否在字典里出现过。即我们需要遍历字典里地每一个单词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;
			if ((d[from] >= 0)&& (now > d[from])) {
				break;
			}
			q.pop();
			for (i = 0; i <con[x].size(); ++i) {
				int y = con[x][i];
				if (d[y] < 0) {  //第一次经过
					d[y] = now;
					q.push(y);
					next[y].push_back(x);
				}
				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的三种木块,

搭建K × 2^N × 1的墙,不能翻转、旋转(0<=N<=1024,1<=K<=4)

有多少种方案,输出结果对1000000007取模。

 

举例:

举个例子如给定N=1 K=2

答案是7,如下图所示

 

    下文使用的图示说明:

首先说明“?方块”的作用

 

“?方块”,表示这个位置可以摆放任意合理的木块,合理的意思就是在边缘位置,不能摆放一些木块超出边缘,或者和已占用的位置重叠。

    解法一、遍历方法求解

    遍历思路是:用二维数组模拟墙,从左下角开始摆放,从左往右,从下往上,最后一个格子是右上角那个位置;每个格子把每种可以摆放木块都摆放一次,每堆满一次算一种用摆放方法。为了便于描述,为木块的每个格子进行编号:

 

下面演示当n=1,k=2的算法过程:

 

 

    解法二、递归求解

 

 

问题分解就是把一个大问题,分解成小问题,逐个求解,然后再解决大问题。

首先把墙分解成左右对称的两堵墙,可以将两堵墙从没有互相嵌入的木块(绿色木块)到全是互相嵌入的木块,我们暂且把分界处互相嵌入的木块称为衔接木块。衔接木块从0到k,根据排列组合的性质,把每一种状态下左右木墙堆放数量相乘,再把所有乘积求和,就得到木墙的堆放结果数。以此类推,将问题分解成只有一列的墙,问题就好解决了。

 

首先演示,n=1,k=2时的算法演算

                       图 2-1

 

 我们再来看看下面更具一般性的计算分解。当n=2,k=3的情况。

 图 2-2

 

再从分解的结果中,挑选一组进行分解演示:

              

 图 2-3

 

从上面的情况可以看出,最终求解都是分解成一列的情况,然后再逐级向上归纳。

 

我们假设函数 fun(n,k)是计算木块堆放的可能方案数,那么fun似乎可以依赖于fun(n-1,k)的结果进行计算。

可以想象一下,当n>3时,分解的中间结果中肯定会存在下面这一种情况:

图 2-4

 

 

在针对左边的墙的分解中不同的边界状态,也就是绿色木块的情况。如下(ls表示左边界状态,rs表示右边状态):

图 2-5

 

通过图2-4和图2-5,计算左边状态=leftState和右边状态=rightState的墙的计算公式可以如下表示。

求墙的堆砌方案数,也就是左边状态和右边状态都为0的情况。

如,n=1024,k=16,进行如下调用即可:f(1024,16,0,0)

 


后记

    围绕"编程”"面试”"算法”3大主题的程序员编程艺术系列http://blog.csdn.net/v_JULY_v/article/details/6460494,始创作于2011年4月,那会还在学校,如今已写了31章,今年内我会Review已写的这31章,且继续更新,朋友们若有发现任何问题,欢迎随时评论于原文下或向我反馈,我会迅速修正,感激不尽。

参考文献及推荐阅读

  1. caopengcs,最小操作数:http://blog.csdn.net/caopengcs/article/details/9919341
  2. 红色标记,木块砌墙:http://blog.csdn.net/dw14132124/article/details/9038417
  3. LoveHarvy,木块砌墙:http://blog.csdn.net/wangyan_boy/article/details/9131501
原文地址:https://www.cnblogs.com/james1207/p/3253783.html