1. 问题背景与定义
这道LeetCode周赛题目要求我们找出所有"稳定"的二进制数组。首先需要明确几个关键概念:
稳定二进制数组的定义:题目中定义的"稳定"是指数组中任意长度为k的子数组(连续元素)中,0和1的数量差不超过1。换句话说,对于所有i,满足:
code复制|count(nums[i...i+k-1], 0) - count(nums[i...i+k-1], 1)| ≤ 1
输入参数:
- n:二进制数组的长度
- k:稳定性的窗口大小
- 限制条件:1 ≤ k ≤ n ≤ 1000
输出要求:返回满足条件的稳定二进制数组的总数,由于结果可能很大,需要对10^9+7取模。
注意:这里的k是指滑动窗口的大小,而不是指数组中0和1的数量差。这是很多同学初次接触时容易混淆的概念。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 暴力解法与问题规模分析
2.1 暴力枚举法的可行性
最直观的想法是生成所有可能的二进制数组(共2^n种可能),然后检查每个数组是否满足稳定性条件。对于n=20的情况:
- 总可能性:2^20 = 1,048,576
- 每个数组需要检查(n-k+1)个子数组
当n=20,k=10时,总检查次数约为1千万次,这在现代计算机上还是可以处理的。但当n达到题目上限1000时:
- 2^1000 ≈ 10^301种可能性
- 完全不可行
2.2 问题规模与算法选择
题目给出的n和k都可能达到1000,这直接排除了O(2^n)的暴力解法。我们需要寻找多项式时间复杂度的解法,理想情况下应该是O(n^2)或更低。
从热词中可以看到,这道题与"动态规划"和"滑动窗口"高度相关。实际上,这是一道典型的计数类动态规划问题,需要设计合适的状态表示和转移方程。
3. 动态规划解法设计
3.1 状态定义
定义dp[i][d]表示:
- 前i个元素构成的二进制数组
- 最后min(i,k)个元素中,0和1的数量差为d(即count(0)-count(1)=d)
- 满足所有窗口条件的数组数量
这里的关键观察是:我们只需要跟踪最后k-1个元素的状态,因为新加入的元素只会影响包含它的窗口。
3.2 状态转移方程
对于第i+1个位置,我们可以选择放0或1:
-
放0:
- 新的差值d' = d + 1
- 需要检查所有包含新元素的窗口是否满足|d'| ≤ 1
-
放1:
- 新的差值d' = d - 1
- 同样需要检查窗口条件
转移方程可以表示为:
code复制dp[i+1][d'] += dp[i][d] (如果转移有效)
3.3 初始条件和边界处理
- 初始状态:dp[0][0] = 1(空数组,差值为0)
- 对于i < k的情况,只需要保证当前所有元素的差值满足条件
- 对于i ≥ k的情况,需要确保新加入元素后,所有包含它的窗口都满足条件
4. 算法优化与实现细节
4.1 差值范围限制
由于题目要求|d| ≤ 1,所以d的可能取值只有-1,0,1。这大大减少了状态空间:
- 原始状态空间:O(n×k)
- 优化后状态空间:O(n×3) = O(n)
4.2 滑动窗口维护
我们可以维护一个滑动窗口的差值:
- 当窗口移动时,移除最左边元素的影响
- 添加新元素的影响
- 更新当前窗口的差值
这可以通过双端队列(deque)或简单的变量维护实现。
4.3 模运算处理
由于结果可能很大,需要在每次状态转移时进行模运算:
code复制MOD = 10^9 + 7
dp[i+1][d'] = (dp[i+1][d'] + dp[i][d]) % MOD
5. 完整代码实现(Python)
python复制def numberOfStableArrays(n: int, k: int) -> int:
MOD = 10**9 + 7
# dp[i][d]:前i个元素,最后min(i,k)个元素的差值为d(0-1)
# d的取值范围:-1,0,1 → 用0,1,2表示
dp_prev = [[0]*3 for _ in range(n+1)]
dp_prev[0][1] = 1 # 初始状态:0个元素,差值为0
for i in range(1, n+1):
dp_curr = [[0]*3 for _ in range(n+1)]
for cnt in range(0, i):
for d in range(3):
if dp_prev[cnt][d] == 0:
continue
actual_d = d - 1
# 尝试添加0
new_d = actual_d + 1
new_cnt = cnt + 1 if cnt < k-1 else k-1
if -1 <= new_d <= 1:
dp_curr[new_cnt][new_d+1] = (dp_curr[new_cnt][new_d+1] + dp_prev[cnt][d]) % MOD
# 尝试添加1
new_d = actual_d - 1
new_cnt = cnt + 1 if cnt < k-1 else k-1
if -1 <= new_d <= 1:
dp_curr[new_cnt][new_d+1] = (dp_curr[new_cnt][new_d+1] + dp_prev[cnt][d]) % MOD
dp_prev = dp_curr
return (dp_prev[k-1][0] + dp_prev[k-1][2]) % MOD # d=-1和d=1都符合条件
6. 复杂度分析与优化空间
6.1 时间复杂度
- 外层循环:n次
- 内层循环:对于每个i,最多k×3次操作
- 总时间复杂度:O(n×k)
对于n,k≤1000,这是可以接受的(约1e6次操作)。
6.2 空间优化
当前实现使用了O(n×k)的空间,可以进一步优化:
- 滚动数组:由于dp[i]只依赖于dp[i-1],可以只用两个二维数组交替使用,空间降为O(k)
- 差值压缩:如前面所述,d只有3种可能,可以进一步压缩
优化后的空间复杂度可以是O(k)或O(1)。
7. 测试用例与验证
为了验证算法的正确性,这里提供几个测试用例:
-
n=1, k=1
- 可能数组:[0], [1]
- 预期输出:2
-
n=2, k=2
- 可能数组:[0,0], [0,1], [1,0], [1,1]
- 都满足条件
- 预期输出:4
-
n=3, k=2
- 无效数组:[0,0,0](子数组[0,0]差为2)
- 有效数组:其余7种
- 预期输出:6
-
n=4, k=3
- 需要更仔细的枚举验证
- 预期输出:12
8. 常见错误与调试技巧
在实现这类动态规划问题时,容易犯以下错误:
-
窗口大小理解错误:误以为k是全局0和1的数量差,而不是滑动窗口的大小。这会导致完全错误的状态定义。
-
边界条件处理不当:
- 忘记处理i<k时的特殊情况
- 在i≥k时没有正确检查所有包含新元素的窗口
-
模运算遗漏:在中间步骤忘记取模,导致整数溢出。
调试建议:
- 从小例子开始(n=1,2,3),手工计算验证
- 打印中间状态(dp表)检查是否符合预期
- 对每个状态转移,确认是否所有条件都满足
9. 算法扩展与变种
这道题目可以有多种变种,考察不同的算法技巧:
-
更大数据范围:如果n和k扩大到1e5,需要O(n)或O(n log n)的算法。可能需要数学组合或生成函数的方法。
-
不同稳定性定义:例如要求所有窗口的0和1数量完全相等(差为0),而不仅仅是差≤1。
-
多维扩展:将二进制数组推广到更多符号(如0,1,2),要求窗口内最大频次与最小频次差不超过1。
-
在线查询:给定一个二进制数组,需要快速回答多个关于不同k值的稳定性查询。
10. 竞赛中的解题策略
在LeetCode周赛中遇到这类问题时,建议采取以下策略:
-
仔细阅读题目:确保完全理解"稳定"的定义和所有约束条件。
-
从小例子入手:手工计算几个小的n和k,验证自己的理解。
-
识别算法模式:
- 计数问题 → 动态规划
- 窗口约束 → 滑动窗口状态维护
-
先写暴力解法:即使不能通过,也能帮助理解问题本质。
-
优化状态表示:寻找可以减少状态空间的关键观察(如差值范围有限)。
-
编写干净代码:使用清晰的变量名和注释,便于调试。
-
预留测试时间:写完代码后,立即用多个测试用例验证,包括边界情况。
