木块砌墙算法(C#版)
首先感谢 网友“⒌1→⑤Ⅵ℡” 提供算法讲解和其他网友提供的帮助。
原题链接:http://hero.pongo.cn/Question/Details?ID=36&ExamID=36
简化图形:因为厚度始终是1,所以这个墙可以简化成平面的图形。即 2^N * K 的图形。
刚看到这个题,第一想到的就是遍历法,也想不出更好的办法。但是这个问题的规模很大,用遍历只能求规模很小的情况,当规模变大时,基本上不能用该算法进行求解。但是为了更快的看到结果,我还是实现了这个算法,这样可能有助于新算法的编写,至少可以知道规模较小情况下的结果,用于验证新算法是否正确。
本文使用的图示说明:
我的遍历思路是:用二维数组模拟墙,从左下角开始摆放,从左往右,从下往上,最后一个格子是右上角那个位置;每个格子把每种可以摆放木块都摆放一次,每堆满一次算一种用摆放方法。下面图示演示算法过程(n=1,k=2)
下面是我的遍历算法的演示过程。
此方法优点,可以输出每种堆放方法。
因为这种算法只是过渡算法,所以这里不做详细解释。如有不理解的,请留言。下面给出上面算法的代码:
using System;
using System.Collections.Generic;
using System.Text;
namespace HeapBlock
{
public delegate void OnFindWallDelegate(int[,] wall);
public class Wall
{
private int width;
private int height;
private int[,] wall;
private int x;
private int y;
private int result = 0;
public Wall(int width, int height)
{
this.x = 0;
this.y = 0;
this.width = width;
this.height = height;
this.wall = new int[height, width];
}
public event OnFindWallDelegate OnFindWall;
public int GetResult()
{
return result;
}
public void HeapBlock()
{
if (IsFull())
{
result++;
if (OnFindWall != null)
{
OnFindWall(wall);
}
return;
}
if (CellNotUsed())
{
if (CanBeSetBlock(1))
{
this.SetBlock(1);
this.NextCell();
HeapBlock();
this.PrevCell();
this.ClearBlock(1);
}
if (CanBeSetBlock(2))
{
this.SetBlock(2);
this.NextCell();
HeapBlock();
this.PrevCell();
this.ClearBlock(2);
}
if (CanBeSetBlock(3))
{
this.SetBlock(3);
this.NextCell();
HeapBlock();
this.PrevCell();
this.ClearBlock(3);
}
}
else
{
this.NextCell();
HeapBlock();
this.PrevCell();
return;
}
}
private void SetBlock(int blockType)
{
if (blockType == 1)
{
wall[y,x] = blockType;
}
else if (blockType == 2)
{
wall[y,x] = blockType;
wall[y,x + 1] = -1;
}
else if (blockType == 3)
{
wall[y,x] = blockType;
wall[y + 1,x] = -1;
}
}
private void ClearBlock(int blockType)
{
if (blockType == 1)
{
wall[y,x] = 0;
}
else if (blockType == 2)
{
wall[y,x] = 0;
wall[y,x + 1] = 0;
}
else if (blockType == 3)
{
wall[y,x] = 0;
wall[y + 1,x] = 0;
}
}
private bool CanBeSetBlock(int blockType)
{
if (!InWallArea() && !CellNotUsed())
{
return false;
}
if (blockType == 1)
{
return true;
}
else if (blockType == 2)
{
return InWallArea(x + 1, y) && CellNotUsed(x + 1, y);
}
else if (blockType == 3)
{
return InWallArea(x, y + 1) && CellNotUsed(x, y + 1);
}
else
{
return false;
}
}
private bool CellNotUsed(int x, int y)
{
return wall[y,x] == 0;
}
private bool CellNotUsed()
{
return CellNotUsed(x, y);
}
private bool InWallArea(int x, int y)
{
return (0 <= x && x < width) && (0 <= y && y < height);
}
private bool InWallArea()
{
return InWallArea(x, y);
}
private void NextCell()
{
x = x < width - 1 ? x + 1 : 0;
y = x == 0 ? y + 1 : y;
}
private void PrevCell()
{
x = x == 0 ? width - 1 : x - 1;
y = x == width - 1 ? y - 1 : y;
}
private bool IsFull()
{
return x >= width || y >= height;
}
}
}
Wall类使用方法:
Wall wal = new Wall(width, height);
//wal.OnFindWall += new OnFindWallDelegate(wal_OnFindWall); //可以注册事件。没找到一种堆放方法,就触发该事件。
wal.HeapBlock(); //开始堆放木墙
Console.Write(wal.GetResult()); //输出结果。
问题分解就是把一个大问题,分解成小问题,逐个求解,然后再解决大问题。
首先说明“?方块”的作用
这个块表示右上角位置被半块绿色木块占用,其他位置空闲。
“?方块”,表示这个位置是空位置,可以任意摆放。
上图的意思就是,当右上角被绿色木块占用,此位置固定不变,其他位置任意摆放,在这种情况下的堆放方案数。
假如有墙规模为(n,k),如果从中间切开,被分为规模问(n-1,k)的两堵墙。那么被分开的墙和原墙有什么关系呢?我们首先来看一下几组演示。
首先演示,n=1,k=2时的算法演算
图 2-1
图示说明:
表示,左边墙的所有堆放方案数 * 右边墙所有堆放方案数 = 2 * 2 = 4
表示,当切开处有一个横条的时候,空位置存在的堆放方案数。左边*右边 = 1*1 = 2;剩余两组以此类推。
这个是排列组合的知识。
再演示一组更具一般性的计算分解。当n=2,k=3的情况。
图 2-2
再从分解的结果中,挑选一组进行分解演示:
图 2-3
通过图2-2和图2-3的分解演示,可以说明,最终都是分解成一列求解。在逐级向上汇总。
我们再假设一堵墙n=4,k=3。也就是说,宽度是16,高度是3。会有以下分解。
图2-4
根据上面的分解的一个中间结果,再进行分解,如下:
图 2-5
通过上面的演示可以明确一点,假设f(n)用于计算问题,那么f(n)依赖于f(n-1)的多种情况。我们再来看看,切开处的有什么特殊的地方。
通过对上面图示分解演示过程,可以知道,被切开的两堵墙从没有互相嵌入的木块(绿色木块)到全是互相连接的木块。切口绿色木块的全排列,有2^k种状,比如k=2,就有00、01、10、11,4中状态。根据排列组合的性质,把每一种状态下左右木墙堆放方案数相乘,再把所有乘积求和,就得到木墙的堆放结果数。以此类推,将问题逐步往下分解。从图2-5中可以看出,除了需要考虑切口绿色木块的状态,还需要考虑最左边一列和最右边一列的绿色木块状态。我们把这两种边界状态称为左边界状态和右边界状态,分别用leftState和rightState表示。
观察图2-5被切分后,所有左边的墙,他们的左边界状态始终保持不变,右边界状态从0~maxState, maxState = 2^k-1(有绿色方块表示1,没有表示0;ls表示左边界状态,rs表示右边状态):
图 2-6
同样可以看出右边的墙的右边界状态保持不变,而左边界状态从0~maxState。
要堆砌的木墙可以看做是左边界状态=0,和右边界状态=0的一堵墙。
根据图示分解过程可以得到以下方程
函数返回结果,就是,当左边状态为=leftState,右边状态=rightState时,木墙的堆砌方案数。本题要求解的就是左右状态都为0的情况。
如,n=1024,k=4,进行如下调用即可:f(1024,4,0,0)
用方程来表示图形:
这个用方程表示为 f(2,3,2,5)
来看看方程是如何表示图形的分解。
比如,图2-2 的方程式表示为:
f(2,3,0,0) = f(1,3,0,0)*f(1,3,0,0) + f(1,3,0,1)*f(1,3,1,0) + f(1,3,0,2)*f(1,3,2,0)
+ f(1,3,0,3)*f(1,3,3,0) + f(1,3,0,4)*f(1,3,4,0) + f(1,3,0,5)*f(1,3,5,0)
+ f(1,3,0,6)*f(1,3,6,0) + f(1,3,0,7)*f(1,3,7,0)
当n=0,表示墙就一列,此时不能再分解。直接计算结果进行返回。
当n=1,墙两列,分解时有些状态可能不能使用,需要排除。如图2-3
下面代码就是根据上面函数原理编写的。最终执行效率,n=1024,k=4 时,用时0.2800160秒。
源代码使用过了字典作为缓存,用时在1.3秒左右。后来改为数组结果,性能大增。
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;
}
///
/// 静态入口。计算堆放方案数。
///
///
///
///
public static int Heap(int n, int k)
{
return new WoolWall(n, k).Heap();
}
///
/// 计算堆放方案数。
///
///
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;
}
///
/// 根据一列的状态,计算列的堆放方案数。
///
/// 状态
///
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;
}
///
/// 类似于斐波那契序列。
/// f(1)=1
/// f(2)=2
/// f(n) = f(n-1)*f(n-2);
/// 只是初始值不同。
///
///
///
private static int CalcAllState(int k)
{
return k <= 2 ? k : CalcAllState(k - 1) + CalcAllState(k - 2);
}
///
/// 判断状态是否可用。
/// 当n=1时,分割之后,左墙和右边墙只有一列。
/// 所以state的状态码可能会覆盖原来的边缘状态。
/// 如果有覆盖,则该状态不可用;没有覆盖则可用。
/// 当n>1时,不存在这种情况,都返回状态可用。
///
///
/// 左边界状态
/// 右边界状态
/// 切开位置的当前状态
/// 状态有效返回 true,状态不可用返回 false
private bool StateIsAvailable(int n, int lState, int rState, int state)
{
return (n > 1) || ((lState | state) == lState + state && (rState | state) == rState + state);
}
}
}
代码分析
1,如何使用
WoolWall.Heap(1024,4); //直接通过静态方法获得结果。
new WoolWall(n, k).Heap();//通过构造对象获得结果。
2,缓存
因为他最终都是分解成一列的情况进行处理,这就会导致很慢。为了提高速度,本文使用了缓存机制来提高性能。缓存原理就是,n,k,leftState,rightState相同的墙,返回的结果肯定相同。利用这个特性,每计算一种结果就放入到缓存中,如果下次计算直接从缓存取出。刚开始缓存用字典类实现,有网友给出了更好的缓存方法——数组。这样性能好了很多,也更加简单。
3,核心算法讲解
上图反应了Heep调用的主要方法调用,在循环中,result 累加 lResult 和 rResult。
在实际代码中,首先是从缓存中读取结果,如果没有缓存中读取结果在进行计算。
分解法分解到一列时,不在分解,直接计算机过
if (n == 0)
{
return CalcOneColumnHeapCount(lState);
}
整个程序的核心代码
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;
}
通过for循环,求和state=0到state=2^k-1的两边木墙乘积。
当n=1切分时,需要特殊考虑。如下图:
图2-7
看上图中,因为左边墙中间被绿色方块占用,所以在(1,0)-(1,1)这个位置不能再放绿色方块。所以一些状态需要排除,如state=2需要排除。同时在还需要合并状态,如state=1时,左边墙的状态=3。
CalcOneColumnHeap(int state)函数用于计算一列时摆放方案数。
计算方法是, 求和被绿色木块分割开的每一段连续方格的摆放方案数。每一段连续的方格的摆放方案通过CalcAllState方法求得。经过分析,可以得知CalcAllState是类似斐波那契序列的函数。
如:state = 4546(当然题目中state最大=15,这里只是为了演示如何计算),二进制是:1000111000010。位置上为1,表示被绿色木块占用,0表示空着,可以自由摆放。
1000111000010 被分割后 1 000 111 0000 1 0, 那么就有 000=3个连续位置, 0000=4个连续位置 , 0=1个连续位置,。堆放结果=CalcAllState(3) + CalcAllState(4) + CalcAllState(1) = 3 + 5 + 1 = 9;
上面的性能不是很高,是因为调用性能的树形结构,形成了大量的函数调用和缓存查找, 为了得到更高的性能,可以通过新的解决思路。那就是所有的运算直接依赖于上一次运算的结果,这样可以防止更多的调用。如果每次运算都算出所有边界状态的结果,那么就能为下一次运算提供足够的信息。
下面是根这种思想编写的代码,
1024,4执行时间70ms左右以内
using System;
using System.Collections.Generic;
using System.Text;
namespace HeapBlock
{
public class HeapTheWall
{
private int n;
private int height;
private int maxState;
public HeapTheWall(int n, int height)
{
this.n = n;
this.height = height;
maxState = (1 << height) - 1;
}
public static int Heap(int n, int k)
{
return new HeapTheWall(n, k).Heap();
}
public int Heap()
{
int[,] result = new int[maxState + 1, maxState + 1];
int[,] prevResult = new int[maxState + 1, maxState + 1];
Heap(n, result, prevResult);
return result[0, 0];
}
private void Heap(int n, int[,] result, int[,] prevResult)
{
if (n == 0)
{
CalcOneColumnResult(result);
return;
}
Heap(n - 1, prevResult, result);
for (int lState = 0; lState <= maxState; lState++)
{
for (int rState = 0; rState <= lState; rState++)
{
CalcResult(prevResult, result, lState, rState);
result[rState, lState] = result[lState, rState];
}
}
}
private void CalcOneColumnResult(int[,] result)
{
for (int i = 0; i <= maxState; i++)
{
for (int j = 0; j <= maxState; j++)
{
result[i, j] = ((i | j) != i + j) ? 0 : CalcOneColumnHeap(i + j);
}
}
}
private void CalcResult(int[,] prevResult, int[,] result, int lState, int rState)
{
result[lState, rState] = 0;
for (int middState = 0; middState <= maxState; middState++)
{
result[lState, rState] = (int)((result[lState, rState] + 1L * prevResult[lState, middState] * prevResult[middState, rState]) % 1000000007);
}
}
private int CalcOneColumnHeap(int state)
{
int result = 1;
foreach (int serialCount in GetAllSerialFreeCellCount(state))
{
result *= CalcAllState(serialCount);
}
return result;
}
private List GetAllSerialFreeCellCount(int state)
{
List result = new List();
int serialCountn = 0;
for (int idx = 0; idx < height; idx++)
{
if ((state & 1) == 0)
{
serialCountn++;
}
else
{
if (serialCountn > 0)
{
result.Add(serialCountn);
}
serialCountn = 0;
}
state >>= 1;
if (idx == height - 1)
{
if (serialCountn > 0)
{
result.Add(serialCountn);
}
}
}
return result;
}
private static int CalcAllState(int n)
{
return n <= 2 ? n : CalcAllState(n - 1) + CalcAllState(n - 2);
}
}
}
1,如何调用?
HeapTheWall.Heap(100,4);//直接通过静态方法调用
new HeapTheWall(100,4).Heap();//通过构造对象,然后调用实例方法
2,思路
创建两个数组,一个用于存放当前计算的,一个用于存放上一次计算的结果。这个数组除了包含墙的堆放结果(这其实就是边界状态为0,0是的堆放结果),还包括所有不同边界状态时的堆放结果。
如果f(n,k)要直接依赖于f(n-1,k)的结果,那么f(n,k)每次计算就需要计算出左右边界状态所有组合情况的堆放数。
此思路,首先计算出n=0时的结果,然后计算n=1时的结果,以此类推。
3,代码解释
public int Heap()
{
int[,] result = new int[maxState + 1, maxState + 1];
int[,] prevResult = new int[maxState + 1, maxState + 1];
Heap(n, result, prevResult);
return result[0, 0];
}
构造两个数组,一个用于存放零时结果(上一次的结果),一个用于存放最终计算结果。result[0,0] 存放状态(0,0) 的堆放方案数,result[lState,rState],返回状态(lState,rState)时墙的堆放方案数。
private void Heap(int n, int[,] result, int[,] prevResult)
{
if (n == 0)
{
CalcOneColumnResult(result);
return;
}
Heap(n - 1, prevResult, result);
for (int lState = 0; lState <= maxState; lState++)
{
for (int rState = 0; rState <= lState; rState++)
{
CalcResult(prevResult, result, lState, rState);
result[rState, lState] = result[lState, rState];
}
}
}
如果n == 0,直接计算出结果,如果n > 1 是,首先计算出 n-1 的结果。两个for循环则是遍历左右边界的所有状态组合,然后调用CalcResult方法,计算每种边界状态的结果,然后写入到结果数组中。