1. 问题背景与核心概念
今天我们来探讨LeetCode上两道关于稳定二进制数组的题目:3129(中等难度)和3130(困难难度)。这两道题的核心要求是找出所有满足特定条件的二进制数组,其中"稳定"的定义是指数组中连续相同数字的长度不超过给定的限制值。
这类问题在实际开发中有着广泛的应用场景,比如:
- 数据编码中的游程长度限制(RLL编码)
- 通信协议中的信号稳定性控制
- 存储系统中的数据块分布优化
2. 问题定义与数学建模
给定三个整数zero、one和limit,我们需要构造所有满足以下条件的二进制数组:
- 数组长度恰好为zero + one
- 包含恰好zero个0和one个1
- 数组中连续的0或1的个数不超过limit
用数学语言描述就是:设数组为a[0..n-1],其中n=zero+one,满足:
- count(a, 0) = zero
- count(a, 1) = one
- 对于任意i≤j,若a[i]=a[i+1]=...=a[j],则j-i+1 ≤ limit
3. 解法一:暴力递归实现
3.1 基本思路
最直观的解法是使用回溯法枚举所有可能的二进制数组,然后检查每个数组是否满足条件。这种方法虽然简单,但时间复杂度高达O(2^(zero+one)),在zero和one较大时完全不可行。
java复制// 解法一:暴力递归-StringBuffer显式回溯
class Solution {
private static final int MOD = 1_000_000_007;
private StringBuffer cur = new StringBuffer();
public int numberOfStableArrays(int zero, int one, int limit) {
return dfs(cur, zero, 0, 0, one, 0, 0, limit);
}
private int dfs(StringBuffer cur, int zero, int n0, int ln0,
int one, int n1, int ln1, int limit) {
if(cur.length() == zero + one) return 1;
int ret0 = 0, ret1 = 0;
// 尝试添加0
if(n0 < zero && ln0 < limit) {
cur.append("0");
ret0 = dfs(cur, zero, n0+1, ln0+1, one, n1, 0, limit) % MOD;
cur.deleteCharAt(cur.length()-1); // 显式回溯
}
// 尝试添加1
if(n1 < one && ln1 < limit) {
cur.append("1");
ret1 = dfs(cur, zero, n0, 0, one, n1+1, ln1+1, limit) % MOD;
cur.deleteCharAt(cur.length()-1); // 显式回溯
}
return (ret0 + ret1) % MOD;
}
}
3.2 显式回溯与隐式回溯的区别
在实现回溯算法时,我们有两种常见的处理方式:
- 显式回溯:使用可变数据结构(如StringBuffer、List等),在递归调用后需要显式地撤销修改
- 隐式回溯:使用固定长度数组,通过覆盖写入的方式避免显式回溯
java复制// 解法一变体:隐式回溯实现
class Solution {
private static final int MOD = 1_000_000_007;
public int numberOfStableArrays(int zero, int one, int limit) {
char[] cur = new char[zero + one];
return dfs(0, cur, zero, 0, 0, one, 0, 0, limit);
}
private int dfs(int i, char[] cur, int zero, int n0, int ln0,
int one, int n1, int ln1, int limit) {
if(i == zero + one) return 1;
int ret0 = 0, ret1 = 0;
// 尝试添加0
if(n0 < zero && ln0 < limit) {
cur[i] = '0';
ret0 = dfs(i+1, cur, zero, n0+1, ln0+1, one, n1, 0, limit) % MOD;
}
// 尝试添加1
if(n1 < one && ln1 < limit) {
cur[i] = '1';
ret1 = dfs(i+1, cur, zero, n0, 0, one, n1+1, ln1+1, limit) % MOD;
}
return (ret0 + ret1) % MOD;
}
}
关键经验:在算法竞赛中,隐式回溯通常性能更好,因为它避免了频繁的内存分配和释放。但在实际工程中,显式回溯可能更易读和维护。
4. 解法二:记忆化搜索优化
4.1 状态设计与分析
暴力递归的主要问题是重复计算。我们可以通过记忆化搜索来优化,将已经计算过的结果保存下来。
定义状态dfs(i,j,k)表示:
- 已经使用了i个1和j个0
- 最后一个字符是k(0或1)
- 值为满足条件的稳定数组数量
java复制class Solution {
private static final int MOD = 1_000_000_007;
public int numberOfStableArrays(int zero, int one, int limit) {
int[][][] memo = new int[zero+1][one+1][2];
for(int[][] m1 : memo)
for(int[] m2 : m1)
Arrays.fill(m2, -1);
return (dfs(zero, one, 0, limit, memo) +
dfs(zero, one, 1, limit, memo)) % MOD;
}
private int dfs(int i, int j, int k, int limit, int[][][] memo) {
if(i == 0) return k == 1 && j <= limit ? 1 : 0;
if(j == 0) return k == 0 && i <= limit ? 1 : 0;
if(memo[i][j][k] != -1) return memo[i][j][k];
if(k == 0) {
memo[i][j][k] = (int)(1L *
((dfs(i-1,j,0,limit,memo) + dfs(i-1,j,1,limit,memo)) % MOD) +
(i > limit ? MOD - dfs(i-limit-1,j,1,limit,memo) : 0)) % MOD;
} else {
memo[i][j][k] = (int)(1L *
((dfs(i,j-1,0,limit,memo) + dfs(i,j-1,1,limit,memo)) % MOD) +
(j > limit ? MOD - dfs(i,j-limit-1,0,limit,memo) : 0)) % MOD;
}
return memo[i][j][k];
}
}
4.2 容斥原理的应用
这里的关键点是使用容斥原理来处理连续字符的限制。当最后一个字符是0时:
- 如果前一个字符是1,直接加上dfs(i-1,j,1)
- 如果前一个字符是0,则需要减去不合法的情况(连续超过limit个0)
不合法情况的数量等于dfs(i-limit-1,j,1),因为:
- 我们需要确保在添加当前0之前,已经有limit个连续的0
- 然后前一个字符必须是1,才能形成连续limit+1个0的不合法情况
5. 解法三:动态规划实现
5.1 状态转移方程
将记忆化搜索转换为动态规划,可以进一步优化常数因子。定义dp[i][j][k]与dfs(i,j,k)含义相同。
状态转移方程:
- dp[i][j][0] = dp[i-1][j][1] + (dp[i-1][j][0] - (i>limit ? dp[i-limit-1][j][1] : 0))
- dp[i][j][1] = dp[i][j-1][0] + (dp[i][j-1][1] - (j>limit ? dp[i][j-limit-1][0] : 0))
java复制class Solution {
public int numberOfStableArrays(int zero, int one, int limit) {
final int MOD = 1_000_000_007;
long[][][] dp = new long[zero+1][one+1][2];
// 初始化:全0或全1的情况
for(int i = 0; i <= Math.min(zero, limit); i++)
dp[i][0][0] = 1;
for(int j = 0; j <= Math.min(one, limit); j++)
dp[0][j][1] = 1;
for(int i = 1; i <= zero; i++) {
for(int j = 1; j <= one; j++) {
long sup0 = i > limit ? (MOD - dp[i-limit-1][j][1]) % MOD : 0;
dp[i][j][0] = (dp[i-1][j][1] + dp[i-1][j][0] + sup0) % MOD;
long sup1 = j > limit ? (MOD - dp[i][j-limit-1][0]) % MOD : 0;
dp[i][j][1] = (dp[i][j-1][0] + dp[i][j-1][1] + sup1) % MOD;
}
}
return (int)((dp[zero][one][0] + dp[zero][one][1]) % MOD);
}
}
5.2 复杂度分析
- 时间复杂度:O(zero × one),因为需要填充一个(zero+1)×(one+1)×2的DP表
- 空间复杂度:O(zero × one),可以通过滚动数组优化到O(one)或O(zero)
6. 常见问题与调试技巧
6.1 为什么需要MOD运算?
由于结果可能非常大,题目要求对1e9+7取模。在Java中需要注意:
- 中间结果可能溢出,需要使用long类型
- 减法取模时要加上MOD再取模,避免负数
- 乘法运算应该先转为long再取模
6.2 如何验证DP实现的正确性?
可以采用以下测试策略:
- 小规模测试:手工计算zero=1,one=1,limit=1等简单情况
- 对比测试:用暴力解法验证小规模输入的答案
- 边界测试:测试zero或one为0的情况
- 极限测试:测试limit=1(最严格限制)和limit≥max(zero,one)(无限制)
6.3 性能优化技巧
- 空间优化:可以使用滚动数组将空间复杂度降到O(one)
- 预处理:提前计算limit相关的值,减少循环中的判断
- 并行计算:对于大规模问题,可以并行计算不同的i值
7. 实际应用与扩展
这类问题可以扩展到:
- 多进制情况(不只是0和1)
- 多维限制(行和列都有限制)
- 概率版本(每个位置有概率是0或1)
- 生成所有合法序列而不仅仅是计数
在工程实践中,类似的算法可以用于:
- 测试用例生成:确保生成的测试数据满足特定模式
- 数据压缩:设计满足特定游程限制的编码方案
- 硬件设计:确保信号转换不会过于频繁
8. 个人经验总结
在解决这类组合计数问题时,我总结了以下经验:
- 从暴力解法开始,确保理解问题本质
- 寻找重复子问题,设计合适的状态表示
- 使用记忆化搜索作为过渡,更容易验证正确性
- 考虑边界条件特别重要,特别是zero或one为0的情况
- 取模运算要小心处理,特别是在减法和乘法时
对于面试准备,建议:
- 熟练掌握从暴力到DP的转化过程
- 理解状态设计的多种可能性
- 练习手工计算小规模案例
- 注意代码整洁和命名规范,便于面试官理解