积木谜团

积木,小时候大家应该都玩过吧,一些简单的积木堆积起来就能组成各种新奇好玩的形状,但不知你有没尝试过用多种组合方法堆成相同的形状。这里,我们就有这样一个谜题,给出9*9方格,用1*2 的积木和L形(2*2 的去掉一个角)的积木填充,求填充满9*9方格的不重复方案数。

 

解法一

这样的问题到手,初看似乎没有一点头绪,是用数学的方法的推导?当然,这个是可以有,但推导过程也是艰辛无比。在这,要提一下动态规划的好处,它能归纳出状态转移公式,再根据公式,利用计算机高速的计算能力来帮我们推算出最后的结果。

首先,我们先来解决状态的表示问题。可以用二进制来表示积木的形状。

图1 所有形状的积木

积木总类不多,所有形状也就图1的6种,我们可以一一地用二进制描述出来,比如第1种积木,二进制表示为11,则十进制就是3。而第2种积木,由于覆盖到了两行格子,我们可以用二进制表示为1,000000001,或者我们记录成一个结构(1, 1),这两种表示方法都是二进制的,但各有优劣,直接用二进制的表示会导致需要保存的状态数过大,比如这个题目的积木最多覆盖两行,那状态数就是218=262144。

积木的状态用二进制表示,那当前9*9方格的状态也能用二进制表示,方法和积木的相同,方格有被填充则为1,未填充则为0。这样,我们需要用int64[9][1<<18]这么大的数组来表示9*9方格的状态。因为9*9方格的方案数大于int值的范围,所以我们要用int64数组来保存。

再来说说状态转移的问题,我们上图来说明吧。

 

图2 积木示例1

我们约定方格从左开始是最低位,再从下往上,第0行第0列为最左下角的方格。当积木2放入黑色位置,方格状态做一次或的位运算即可。

 000100000,110110000+001000000,001000000= 001100000,111110000

图3 状态转移示例1

依次类推,所有状态都可以由那6种积木转换到其他状态,公式如下:

但这里需要注意一个问题,就是积木填充过程中的顺序问题,比如先放第1种积木再放第2种积木,和先放第2种再放第1种所得到的方格状态是一致时,两种应该视为同一种方案。

const unsigned int lowmask = (1<<9)-1;

const unsigned int hihmask = lowmask<<9;

int64 dp[10][1<<9];

int brks[6][2] = {{1,1},{1,3},{3,0},{3,1},{3,2},{2,3}};

 

inline bool can_place(int gl, int gh, int bi, int off)

{

    int lbrk = brks[bi][0] << off, hbrk = brks[bi][1] << off;

    if ((lbrk&gl) || (lbrk&~lowmask)) return false;

    if ((hbrk&gh) || (hbrk&~lowmask)) return false;

    return true;

}

 

inline void pile(int h, int f, int gl, int gh)

{

    if (gl == lowmask) {//如果第i行被填充满

       dp[h+1][gh] += dp[h][f];//第i+1行的gh状态加上方案数

       return;

    }

    for (int i = 0; i < 9; i ++) {

       if (! (gl&(1<<i))) {//第i个位置是个空位

           for (int j = 0; j < 5; j ++) {

              if (can_place(gl, gh, j, i))

                  pile(h,f,gl|(brks[j][0]<<i),gh|(brks[j][1]<<i));

           }

           if (i > 0 && can_place(gl, gh, 5, i-1))//该积木特殊处理

              pile(h,f,gl|(brks[5][0]<<(i-1)),

                  gh|(brks[5][1]<<(i-1)));

           break;//找到空位,不管能否填充,都应该跳出

       }

    }

}

 

int64 brick()

{

    dp[0][0] = 1;

    for (int i = 0; i < 9; i ++) {//枚举行

       for (int j = 0; j < (1<<9); j ++) {//枚举状态

           if (dp[i][j] == 0) continue;

           pile(i, j, j, 0);

       }

    }

    return dp[9][0];

}

代码清单1 动态规划解法

虽然动态规划的状态转移公式是简单的,但实际编码中还有很多需要考虑的问题和优化的地方,我们根据代码清单1来说明。

一、代码中的brick函数,对各行的方格状态进行了枚举。如果该状态的方案数为0则不必进行计算,否则调用pile函数进行堆叠。DP[i][j]表示i*9方格填满后,第i行状态为j时的方案数。我们将DP[0][0]初始化为1,最后的答案就是DP[9][0],即9*9方格填满后的方案数。

二、pile函数是进行状态转移的,h表示当前行号,f表示当前行的初始状态(肯定小于等于lowmask),gl和gh表示当前组合的第h行和第h+1行的方格状态。这里的第二维数组只开了[1<<9] 大小,因为pile函数计算的是填充满第h行时的状态,低9位的状态都为1,这样就不再记录低9位的状态,开[1<<9]大小足够了。这种做法的思想是用时间换空间。

三、pile函数里的for循环是为了查找第一个格子空位,如果这个空位无法被积木填充的话,那这时的状态就不可能构造出填满方格的状态,可以直接跳出。并且如果有构造方法的话,也是处理完这个空位后直接break,这个就是之前所说的处理顺序问题,我们强制空位的处理方向和状态枚举方向相同,不能出现先填充后面空位再回头填充前面空位的操作。由于1~5形状的积木左下角都有方格,而第6号积木左下角为空,所以第6号积木需要些特殊处理。至于can_place函数,就是判断该积木能否填入,用位运算我们能高效且方便的解决。

四、因为动态规划当前状态一般只和前一个或者前几个状态有关系,所以我们可以使用滚动数组来减少空间消耗,这种优化方法在动态规划中很常见。

int64 dp[2][1<<9];

int idx = 0;

 

int64 brick()

{

    dp[idx][0] = 1;

    for (int i = 0; i < 9; i ++) {

       memset(&dp[idx^1][0], 0, sizeof(dp[0]));

       for (int j = 0; j < (1<<9); j ++) {

           if (dp[idx][j] == 0) continue;

           pile(idx, j, j, 0);

       }

       idx ^= 1;//滚动idx下标

    }

    return dp[idx][0];

}

代码清单2 滚动数组

当然代码1中还有很多地方可以优化,比如pile函数里的找空位的循环可以不用从0号位置开始,从上一个空位位置后进行查找。而且pile函数也可以改造下,将状态转移保存下来,就不用每次枚举状态时都要重新构造目标状态,这对状态转移数目比较巨大情况下是很有效的。

总结

动态规划可以把多阶段决策问题的求解过程看成是一个连续的递推过程。在求解时,各种状态之前的状态,对后面的子问题而言,只不过相当于其初始条件而己,不会影响后面过程的最优策略。分划好子问题,再确定状态的表示方法,按照状态转移的方法给出方程,而算法的时间复杂度就和状态总数有关。

扩展问题

你是否理解了上述动态规划算法的思想,如果我们改变方格大小为N*M(N和M可以任意输入),不再是9*9,或者我们再添加一些不同形状的积木,你是否也能改造代码1得到解决方法?

posted @ 2012-10-08 11:23  有深度的程序员面试题  阅读(2067)  评论(1编辑  收藏  举报