好的,我注意到你的列表中没有关于**“粉刷栅栏”问题的经典变种**。那么,我们来讲解一个与它密切相关,但约束条件略有变化且更具一般性的线性动态规划问题。
线性动态规划:粉刷栅栏的进阶版——最多允许k个相邻栅栏同色的总方案数
问题描述
你有一段长度为 n 的栅栏,需要为每一段栅栏柱子涂上颜色。总共有 m 种不同的颜色可供选择。给你一个整数 k (k >= 2),要求任意连续 k 段栅栏中,不能全部是同一种颜色。
换句话说,你涂色的方案中,不允许出现长度恰好等于或大于 k 的连续同色栅栏段。
题目要求:计算给 n 段栅栏涂色的总方案数。由于结果可能很大,请将结果对 10^9 + 7 取模。
示例:
- 输入:
n = 3,m = 2,k = 3 - 解释: 有3段栅栏,2种颜色(比如红、蓝),不允许连续3段同色。
- 所有涂色方案:
- 红红蓝
- 红蓝红
- 红蓝蓝
- 蓝红红
- 蓝红蓝
- 蓝蓝红
- 输出:
6(注意:方案“红红红”和“蓝蓝蓝”因连续3段同色而被禁止)。
问题分析与思路构建
这是一个典型的计数类动态规划问题。关键限制是“最长连续同色段”的长度不能达到 k。
1. 状态定义
我们不能只定义 dp[i] 为前 i 段栅栏的方案数,因为当前的选择依赖于最后一段的“连续同色长度”。
因此,我们需要用两个维度来精确描述状态:
i: 已经涂完的前i段栅栏。j: 以第i段栅栏结尾的连续同色段的长度。显然,1 <= j < k(因为长度一旦达到k就是非法状态,我们根本不会去计算它)。
我们定义:
dp[i][j] = 涂完前 i 段栅栏,且最后一段(第 i 段)所属的连续同色段长度为 j 的合法方案总数。
2. 状态转移方程
我们考虑如何从 dp[i-1][*] 转移到 dp[i][j]。
-
情况A:第
i段颜色与第i-1段颜色不同。- 这种情况下,无论
i-1段之前连续同色长度是多少,到了i段,由于颜色变了,连续同色长度会“重置”为1。 - 对于
dp[i][1],它可以由所有合法的dp[i-1][*]状态转移而来,转移的方式是:为第i段选择一种与第i-1段不同的颜色。 - 有多少种选择?总共有
m种颜色,其中一种被第i-1段用掉了,所以有(m - 1)种选择。 - 因此转移方程为:
dp[i][1] += dp[i-1][j] * (m - 1), 对所有1 <= j < k求和。
- 这种情况下,无论
-
情况B:第
i段颜色与第i-1段颜色相同。- 这种情况下,第
i段的连续同色长度j等于第i-1段的连续同色长度j-1加1。 - 前提是
j必须大于1(因为要和前一段相同),并且j必须小于k(否则非法)。 - 转移时,第
i段的颜色选择是唯一的(必须和第i-1段相同)。 - 因此转移方程为:
dp[i][j] = dp[i-1][j-1], 对于所有2 <= j < k。
- 这种情况下,第
3. 边界条件 (初始状态)
- 对于第一段栅栏 (
i=1):- 我们可以涂任何颜色。
- 涂完后,以第一段结尾的连续同色段长度就是
1。 - 所以
dp[1][1] = m(m种颜色,每种都产生一个j=1的状态)。 - 对于
j>1,dp[1][j] = 0。
4. 最终答案
我们需要求出所有涂完 n 段栅栏 (i = n) 的合法方案。这些方案分布在 dp[n][j] 中,其中 j 从 1 到 k-1。
所以:
answer = sum(dp[n][j]) for j = 1 to k-1。
5. 复杂度分析
- 状态数量:
O(n * k)。 - 每个状态转移:
dp[i][1]需要O(k)时间求和(但可以通过维护前缀和优化到O(1)),dp[i][j] (j>1)的转移是O(1)。 - 总时间复杂度:朴素实现为
O(n * k),优化后为O(n)。 - 空间复杂度:
O(n * k),可以滚动数组优化到O(k)。
循序渐进的计算过程(以示例 n=3, m=2, k=3 为例)
-
初始化:
dp[1][1] = m = 2(方案:段1红,段1蓝)。dp[1][2] = 0(因为只有一段,不可能连续长度为2)。
-
计算 i=2:
- 计算
dp[2][1]: 第2段与第1段颜色不同。- 来源:
dp[1][1] = 2。 - 不同颜色选择:
m-1 = 1(假设第1段是红,第2段只能蓝;反之亦然)。 - 所以
dp[2][1] = dp[1][1] * 1 = 2 * 1 = 2。 - 对应方案:[红, 蓝] 和 [蓝, 红]。
- 来源:
- 计算
dp[2][2]: 第2段与第1段颜色相同。- 来源:
dp[1][1] = 2(因为j=2由j-1=1转移来)。 - 相同颜色没有选择,直接继承。
- 所以
dp[2][2] = dp[1][1] = 2。 - 对应方案:[红, 红] 和 [蓝, 蓝]。
- 来源:
- 状态表(
i=2):
dp[2][1] = 2,dp[2][2] = 2。
- 计算
-
计算 i=3:
- 计算
dp[3][1]: 第3段与第2段颜色不同。- 来源:
dp[2][1] + dp[2][2] = 2 + 2 = 4。 - 不同颜色选择:
m-1 = 1。 - 所以
dp[3][1] = 4 * 1 = 4。 - 方案:在
i=2的所有4种方案后,加上一个与第2段不同的颜色。
例如[红,蓝]后加红->[红,蓝,红]。
具体是:[红,蓝,红],[蓝,红,蓝],[红,红,蓝],[蓝,蓝,红]。
- 来源:
- 计算
dp[3][2]: 第3段与第2段颜色相同,且连续长度j=2。- 来源:
dp[2][1] = 2。 dp[3][2] = dp[2][1] = 2。- 方案:从
[红,蓝]变成[红,蓝,蓝],从[蓝,红]变成[蓝,红,红]。
注意:从[红,红]不能转移到dp[3][2]吗?可以,但那会导致连续长度j=3([红,红,红]),而k=3,这是非法的。我们的状态j限制在1到k-1,所以根本不会计算dp[3][3],因此在i=3时,dp[2][2]不能作为dp[3][2]的来源,因为j从j-1=2来,而j=3是非法的。在我们的转移方程中,只有dp[i-1][j-1]当j<k时才成立。当j=2,j-1=1,来源是dp[i-1][1],完全正确。
为了更清晰:dp[3][2] = dp[2][1] = 2。
- 来源:
- 状态表(
i=3):
dp[3][1] = 4,dp[3][2] = 2。
- 计算
-
计算最终答案:
answer = dp[3][1] + dp[3][2] = 4 + 2 = 6。- 与手动列举的结果一致。
优化与代码实现思路(Python伪代码)
我们可以用滚动数组将空间优化到 O(k)。
def numWays(n: int, m: int, k: int) -> int:
MOD = 10**9 + 7
if k == 1: # 不允许任何相邻同色,退化为基础问题
return m * pow(m-1, n-1, MOD) % MOD if n > 1 else m
if n == 0:
return 0
if n == 1:
return m
# dp[j] 表示“当前段”结尾连续同色长度为j的方案数
dp = [0] * k
# 初始化 i=1
dp[0] = m # 这里我们让索引从0开始,dp[0]代表j=1,dp[k-2]代表j=k-1
for i in range(2, n+1):
new_dp = [0] * k
# 情况B:与上一段颜色相同 (j > 1)
for j in range(1, k-1): # j的范围是2到k-1,对应索引是1到k-2
new_dp[j] = dp[j-1] % MOD
# 情况A:与上一段颜色不同 (j = 1)
total_prev = sum(dp) % MOD # 上一轮所有合法状态的和
new_dp[0] = total_prev * (m - 1) % MOD
dp = new_dp
return sum(dp) % MOD
思考延伸:
这个模型是线性DP中处理“连续元素限制”的经典范式。通过增加一个维度(结尾连续长度j)来记录与当前决策相关的历史信息,从而能够判断新加入元素是否会违反约束。这种方法可以推广到很多类似场景,例如:
- 最多允许连续
k个1的二进制字符串计数。 - 字符串中不能出现连续
k个相同字符的排列计数问题。