1. Counting Towers问题概述
Counting Towers是一道经典的动态规划练习题,它要求我们计算用特定大小的方块搭建不同高度塔楼的所有可能方式。这个问题看似简单,却蕴含着动态规划思想的精髓——将复杂问题分解为重叠子问题,并通过记忆化存储避免重复计算。
我第一次遇到这个问题是在准备算法面试时,当时被它简洁描述下隐藏的复杂性所吸引。题目通常这样描述:给定高度为n的塔楼,使用大小为1×1×1和1×1×2的方块进行搭建,要求相邻两层不能使用相同类型的方块,问有多少种不同的搭建方式?
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题分析与状态定义
2.1 理解问题约束条件
首先我们需要明确问题的三个关键约束:
- 方块类型:1×1×1(简称为A类)和1×1×2(简称为B类)
- 高度限制:塔楼总高度为n
- 相邻限制:相邻两层不能使用相同类型的方块
这些约束条件直接影响我们的状态定义和转移方程。例如,第三条规定意味着如果我们第i层使用了A类方块,那么第i+1层只能选择B类,反之亦然。
2.2 状态定义的艺术
在动态规划问题中,正确的状态定义是成功的一半。对于Counting Towers,最自然的状态定义是:
dp[i][j]:表示高度为i的塔楼,且第i层使用j类型方块时的总方案数(j∈{A,B})
这种定义方式直接反映了问题的约束条件,同时也便于我们建立状态转移关系。我刚开始尝试时曾犯过一个错误——只定义dp[i]表示高度为i的总方案数,忽略了最后一层的方块类型信息,结果无法正确处理相邻层不能相同的约束。
3. 状态转移方程推导
3.1 基础情况处理
任何动态规划问题都需要明确定义基础情况。对于本问题:
- dp[1][A] = 1(高度为1,使用A类方块)
- dp[1][B] = 1(高度为1,使用B类方块)
这表示单层塔楼无论使用哪种方块都只有一种搭建方式。值得注意的是,虽然B类方块高度为2,但当我们把它放在最底层时,它自然就构成了一个高度为2的塔楼。
3.2 转移关系建立
根据相邻层不能使用相同类型方块的约束,我们可以得到:
- 如果第i层使用A类方块(高度1),那么第i-1层必须使用B类方块
- 如果第i层使用B类方块(高度2),那么第i-1层必须使用A类方块
因此,状态转移方程为:
dp[i][A] = dp[i-1][B]
dp[i][B] = dp[i-1][A]
但这里有个关键点容易被忽略——B类方块本身高度为2,所以当我们在第i层放置B类方块时,实际上塔楼高度增加了2。因此更准确的状态转移应该是:
dp[i][A] = dp[i-1][B]
dp[i][B] = dp[i-2][A]
这种定义下,i表示的是塔楼的实际高度,而不是层数。这是我在实际编码时踩过的一个坑,错误的定义会导致结果偏大。
4. 算法实现与优化
4.1 基础递归实现
基于上述分析,我们可以先写出递归版本的解法:
python复制def count_towers(n):
memo = {}
def dp(h, last_type):
if h == 0: return 1
if h < 0: return 0
if (h, last_type) in memo:
return memo[(h, last_type)]
if last_type == 'A':
res = dp(h - 1, 'B')
else:
res = dp(h - 2, 'A')
memo[(h, last_type)] = res
return res
return dp(n, 'A') + dp(n, 'B')
这个实现使用了记忆化技术来避免重复计算,时间复杂度为O(n),空间复杂度也是O(n)。我在第一次实现时忘记了处理h<0的情况,导致栈溢出,这是一个需要注意的边界条件。
4.2 迭代优化版本
递归实现虽然直观,但在实际应用中可能会遇到栈深度限制。我们可以将其改写为迭代版本:
python复制def count_towers(n):
if n == 0: return 1
if n == 1: return 2
dp = [[0]*2 for _ in range(n+1)]
dp[0][0] = dp[0][1] = 1 # 高度为0时视为基础情况
dp[1][0] = dp[1][1] = 1
for i in range(2, n+1):
dp[i][0] = dp[i-1][1] # A类方块
dp[i][1] = dp[i-2][0] # B类方块
return dp[n][0] + dp[n][1]
这个版本更加高效,且更容易理解。在实际测试中,当n=1000时,递归版本可能会达到最大递归深度,而迭代版本则能轻松处理。
4.3 空间复杂度优化
观察状态转移方程可以发现,dp[i]只依赖于dp[i-1]和dp[i-2],因此我们可以进一步优化空间复杂度:
python复制def count_towers(n):
if n == 0: return 1
if n == 1: return 2
a, b = 1, 1 # 初始化dp[0]和dp[1]
c, d = 1, 1 # 分别代表使用A和B的方案数
for _ in range(2, n+1):
new_a = d # 当前层用A,前一层必须用B
new_b = a # 当前层用B,前前层必须用A
a, b, c, d = new_a, new_b, a, b
return a + b
这个优化版本将空间复杂度降到了O(1),是面试中最受青睐的写法。不过在实际编码时,变量命名需要更加清晰,否则容易混淆各状态的含义。
5. 问题变种与扩展
5.1 方块类型扩展
原问题只考虑了两种方块,我们可以扩展为更多类型。例如增加1×1×3的方块,约束条件变为相邻层方块高度不能相同。这时状态定义需要相应调整:
dp[i][k]:高度为i且顶层方块高度为k时的方案数
转移方程变为:
dp[i][k] = Σ dp[i-k][m] for all m ≠ k
这种扩展很好地展示了动态规划的灵活性。我在一次在线编程比赛中遇到过类似的变种题,当时因为没有及时调整状态定义而错失了分数。
5.2 相邻约束变化
另一个有趣的变种是修改相邻层的约束条件。比如规定相邻层方块高度差不能超过1,或者相同类型方块不能连续出现超过k次等。这类变种通常需要增加状态维度来记录连续次数:
dp[i][j][k]:高度为i,顶层方块类型j,已连续出现k次
这种三维状态在解决更复杂的约束时非常有用,但也带来了更高的空间复杂度。
5.3 实际应用场景
Counting Towers虽然是一个理论问题,但其思想在实际中有广泛应用。比如在图形渲染中计算不同形状的堆叠方式,在建筑设计中评估结构稳定性,甚至在金融衍生品定价模型中计算路径依赖型期权的价值。理解这类基础问题的解法,有助于我们在面对更复杂的实际问题时快速建立模型。
6. 常见错误与调试技巧
6.1 初始化错误
在实现过程中,最容易犯的错误是初始化不正确。例如:
- 忘记处理n=0的情况(空塔楼算一种方案)
- 错误初始化dp[1][B]=1(实际上B类方块在高度1时也是可行的,因为它本身高度为2)
一个实用的调试技巧是手工计算小规模案例(n=0,1,2,3),与程序输出对比。
6.2 状态转移错误
另一个常见错误是状态转移方程写错,特别是忽略方块高度的影响。比如错误地写成:
dp[i][B] = dp[i-1][A] # 错误!应该用i-2
这类错误可以通过添加打印语句或使用调试器逐步跟踪状态变化来发现。
6.3 空间优化陷阱
在进行空间优化时,容易因为状态覆盖顺序错误而引入bug。例如:
python复制a, b = b, a # 错误的更新顺序
正确的做法应该是先计算新值,再批量更新:
python复制new_a = ...
new_b = ...
a, b = new_a, new_b
7. 性能分析与优化
7.1 时间复杂度
基础实现的时间复杂度是O(n),因为我们需要计算从1到n的所有状态。对于大多数应用场景,这已经足够高效。
7.2 矩阵快速幂优化
当n非常大时(如1e18),我们可以使用矩阵快速幂技术将时间复杂度优化到O(log n)。这需要将递推关系表示为矩阵形式:
code复制| dp[i][A] | = | 0 1 | | dp[i-1][A] |
| dp[i][B] | | 1 0 | | dp[i-1][B] |
然后通过快速幂计算矩阵的n次方。这种技巧在编程竞赛中非常有用,但在面试中不常要求。
7.3 并行计算可能性
由于状态转移只依赖于前几个状态,这个问题的计算过程可以并行化。我们可以将n分成若干块,分别计算后再合并结果。不过对于O(n)的算法,这种优化通常得不偿失。
8. 与其他DP问题的联系
8.1 与斐波那契数列的关系
仔细观察可以发现,Counting Towers问题的解实际上就是斐波那契数列的变种。总方案数f(n)满足:
f(n) = f(n-1) + f(n-2)
这与斐波那契数列的定义非常相似。这种发现可以帮助我们更好地理解问题的本质。
8.2 与爬楼梯问题的对比
经典的爬楼梯问题(每次可以走1或2步,问有多少种方式爬到第n阶)与Counting Towers非常相似。关键区别在于:
- 爬楼梯问题没有相邻步数的限制
- Counting Towers增加了相邻层不能使用相同类型方块的约束
这种对比有助于我们理解约束条件如何影响状态转移方程。
8.3 与01背包问题的异同
虽然Counting Towers与01背包问题都属于动态规划,但它们的状态定义和转移方式有很大不同:
- 01背包是典型的选择/不选择问题,状态维度包括物品和容量
- Counting Towers是序列构造问题,状态关注的是最后一步的选择
理解这种区别有助于我们在面对新问题时快速确定合适的DP模型。
