统计构造好字符串的方案数 图解题解
这道题到底在问什么
- 输入
- low=3, high=3, zero=1, one=1
- 输出
- 8
- 输入
- low=2, high=3, zero=1, one=2
- 输出
- 5
最优解:为什么这么做
一句话答案:LeetCode 2466 数好串方案数:每步接 zero 个 0 或 one 个 1,只看长度。dp[len]=dp[len-zero]+dp[len-one],low 到 high 求和取模,时间、空间都 O(high)。
不写出具体的串,这题到底在数什么
从空串出发,每步末尾接 zero 个 0 或 one 个 1,步数随意;最后长度落在 low 到 high 里就算好字符串。方案数极大,只要对 10 的 9 次方加 7 取模(除以它留余数)。第一个例子 zero=one=1,长度须正好 3,长度 3 的 01 串都能拼,共 2 的 3 次方等于 8 个。
把每条构造路径都走一遍,为什么走不完
high 最大 10 的 5 次方,一条串可接几万步,每步在「接 0 块还是 1 块」里二选一,路径数翻成 2 的几万次方量级,根本列不出。而大量路径后半截一样:只要拼到同一长度,往后怎么接只由「还差多少」决定,跟前面接了什么无关,相同后续被从头重算。
串的内容都不看,只记长度为什么不丢方案
两条不同的构造顺序,接出的串必然不同:串里每段连续 0、连续 1 都只能拆成整块 zero 个 0、one 个 1,拆法唯一,从串能反推怎么接出来。所以数不同的好字符串就等于数不同的构造顺序。
既然后续只看当前长度,就用动态规划(把「拼出长度 len 有几种接法」的小问题算好存表,长的查短的):定义 dp[len] 为恰好拼出长度 len 的方案数。起点 dp[0]=1,空串什么都不接、算一种,是累加的种子。
最后一块是 0 还是 1,两条来路为什么加起来就是全部
盯住拼到长度 len 的顺序的最后一步,即转移(由更短长度的已知答案推出当前格):最后接的要么是 zero 个 0,去掉剩拼到 len-zero 的顺序;要么是 one 个 1,剩拼到 len-one 的。按最后一块分两堆,不重叠也无第三种结尾,所以 dp[len]=dp[len-zero]+dp[len-one]。下标减成负数说明退不回去,按 0 跳过。
答案不是 dp[high] 一格,low 到 high 里每格都算好串,要把 dp[low] 一路加到 dp[high],每加一步都取模。
zero=1、one=2 的示例,5 是怎么加出来的
拿第二个例子 low=2、high=3、zero=1、one=2 填表。dp[0]=1。长度 1:退 1 步落在 dp[0]=1,退 2 步负下标不算,dp[1]=1,对应 0。长度 2:dp[1]+dp[0]=1+1=2,对应 00 和 11。长度 3:dp[2]+dp[1]=2+1=3,对应 000、011、110。长度 2、3 都在区间,答案 dp[2]+dp[3]=2+3=5,正是题面输出的 5。
dp[0] 记成 0,整张表为什么会一路塌成 0
dp[0] 是全表唯一凭空给定的,其余每格都由它加出来,记成 0 就全表塌成 0,答案归零——空串必须算一种。长度从 1 推到 high,每格看两条来路,时间 O(high)(随 high 线性增长的记法);dp 数组开 high+1 格,空间 O(high)。
两条来路全断的格子记 0 正常,不是算错。求和两端都要取到,low 等于 high 时区间只剩一格,写成开区间就漏了。取模别攒到最后:两格相加就可能冲破 32 位,C++、Java 用 long 承接再取模,Python 大整数不溢出,但仍要按题意取模。
▶ 动画逐步走查(共 24 步)——想跟着动画一帧帧对照就展开
- 3记牢这一句:dp[i] 是拼出长度 i 的方案数,它只由两个更短的长度转移过来,一个退回 zero 步,一个退回 one 步。这跟爬楼梯几乎一模一样,只不过一步能跨 zero 级或 one 级。下面用 low 等于 4,high 等于 8,zero 等于 2,one 等于 3 这组数据,把 dp 一格一格填出来。
- 4递推起点先把 dp 数组铺开,下标就是字符串长度,从 0 一直到 high 也就是 8。递推的地基是 dp[0] 等于 1,意思是长度 0 的空串,天然存在一种方案,就是什么都不接。绿色这格是我们唯一的已知量,后面每一格都要靠它和前面算好的格子推出来。灰色的都还没轮到。
- 5转移读取轮到长度 1。要拼出它,上一步只有两种可能:从长度 1 减 2 接一段 0 上来,或者从长度 1 减 3 接一段 1 上来。1 减 2 为负,长度不够退 2 步,不加;1 减 3 为负,长度不够退 3 步,不加。这一步两条来源都越界,没有来源格被高亮,dp[1] 直接记 0;蓝色是已经算好的,紫色是正在填的当前格。
- 6写入 dp[1]把两个来源加起来:0 加 0 等于 0,说明用步长 2 和 3 根本凑不出长度 1,方案数是 0。dp[1] 就此定格,变成蓝色加入已知,继续往右推下一格,一路都别忘了取模,防止数字撑爆。
- 7转移读取轮到长度 2。要拼出它,上一步只有两种可能:从长度 2 减 2 接一段 0 上来,或者从长度 2 减 3 接一段 1 上来。dp[2 减 2] = dp[0] = 1;2 减 3 为负,长度不够退 3 步,不加。绿色只高亮 dp[0] 这一个来源,退 3 步越界那一路不高亮也不加;蓝色是已经算好的,紫色是正在填的当前格。
- 8写入 dp[2]把两个来源加起来:1 加 0 等于 1。dp[2] 就此定格,变成蓝色加入已知,继续往右推下一格,一路都别忘了取模,防止数字撑爆。
- 9转移读取轮到长度 3。要拼出它,上一步只有两种可能:从长度 3 减 2 接一段 0 上来,或者从长度 3 减 3 接一段 1 上来。dp[3 减 2] = dp[1] = 0;dp[3 减 3] = dp[0] = 1。绿色高亮的就是这两个来源格,蓝色是已经算好的,紫色是正在填的当前格。
- 10写入 dp[3]把两个来源加起来:0 加 1 等于 1。dp[3] 就此定格,变成蓝色加入已知,继续往右推下一格,一路都别忘了取模,防止数字撑爆。
- 11转移读取轮到长度 4。要拼出它,上一步只有两种可能:从长度 4 减 2 接一段 0 上来,或者从长度 4 减 3 接一段 1 上来。dp[4 减 2] = dp[2] = 1;dp[4 减 3] = dp[1] = 0。绿色高亮的就是这两个来源格,蓝色是已经算好的,紫色是正在填的当前格。
- 12写入 dp[4]把两个来源加起来:1 加 0 等于 1。dp[4] 就此定格,变成蓝色加入已知,继续往右推下一格,一路都别忘了取模,防止数字撑爆。
- 13转移读取轮到长度 5。要拼出它,上一步只有两种可能:从长度 5 减 2 接一段 0 上来,或者从长度 5 减 3 接一段 1 上来。dp[5 减 2] = dp[3] = 1;dp[5 减 3] = dp[2] = 1。绿色高亮的就是这两个来源格,蓝色是已经算好的,紫色是正在填的当前格。
- 14写入 dp[5]把两个来源加起来:1 加 1 等于 2。dp[5] 就此定格,变成蓝色加入已知,继续往右推下一格,一路都别忘了取模,防止数字撑爆。
- 15转移读取轮到长度 6。要拼出它,上一步只有两种可能:从长度 6 减 2 接一段 0 上来,或者从长度 6 减 3 接一段 1 上来。dp[6 减 2] = dp[4] = 1;dp[6 减 3] = dp[3] = 1。绿色高亮的就是这两个来源格,蓝色是已经算好的,紫色是正在填的当前格。
- 16写入 dp[6]把两个来源加起来:1 加 1 等于 2。dp[6] 就此定格,变成蓝色加入已知,继续往右推下一格,一路都别忘了取模,防止数字撑爆。
- 17转移读取轮到长度 7。要拼出它,上一步只有两种可能:从长度 7 减 2 接一段 0 上来,或者从长度 7 减 3 接一段 1 上来。dp[7 减 2] = dp[5] = 2;dp[7 减 3] = dp[4] = 1。绿色高亮的就是这两个来源格,蓝色是已经算好的,紫色是正在填的当前格。
- 18写入 dp[7]把两个来源加起来:2 加 1 等于 3。dp[7] 就此定格,变成蓝色加入已知,继续往右推下一格,一路都别忘了取模,防止数字撑爆。
- 19转移读取轮到长度 8。要拼出它,上一步只有两种可能:从长度 8 减 2 接一段 0 上来,或者从长度 8 减 3 接一段 1 上来。dp[8 减 2] = dp[6] = 2;dp[8 减 3] = dp[5] = 2。绿色高亮的就是这两个来源格,蓝色是已经算好的,紫色是正在填的当前格。
- 20写入 dp[8]把两个来源加起来:2 加 2 等于 4。dp[8] 就此定格,变成蓝色加入已知,到这里 dp 表就填满了,下面开始统计答案。
- 21进入求和dp 从头到尾填满了,整张表是 1、0、1、1、1、2、2、3、4。注意好字符串的长度可以是 low 到 high 之间任意一个,不是只数 high。所以答案不是某一格,而是把底色框住的 low 等于 4 到 high 等于 8 这一段 dp 值全部加起来。下面从 dp[4] 开始逐格累加。
- 22区间求和把 dp[4] 等于 1 加进答案,累计变成 1。绿色是已经累进答案的那些长度,紫色是当前刚加的这一格,继续往右。
- 23区间求和把 dp[5] 等于 2 加进答案,累计变成 3。绿色是已经累进答案的那些长度,紫色是当前刚加的这一格,继续往右。
- 24区间求和把 dp[6] 等于 2 加进答案,累计变成 5。绿色是已经累进答案的那些长度,紫色是当前刚加的这一格,继续往右。
- 25区间求和把 dp[7] 等于 3 加进答案,累计变成 8。绿色是已经累进答案的那些长度,紫色是当前刚加的这一格,继续往右。
- 26区间求和把 dp[8] 等于 4 加进答案,累计变成 12。框内五格全部加完,最终答案就是 12,和整张表的区间求和结果一致。
⚠️ 容易写错的地方
✗ 错:dp[0] 初始化成 0
✓ 对:dp[0] 必须是 1
空串是所有拼法的共同起点,代表一种方案。若记 0,整条递推乘不起来,答案恒为 0
✗ 错:只把 dp[high] 当答案
✓ 对:答案是 dp 在 low 到 high 的区间和
好字符串长度可以是闭区间内任意一个,不是只要求正好等于 high,漏加中间的长度会少算
✗ 错:把 low 到 high 一整段和攒完再取模,或迟迟不取模
✓ 对:每加一次就立即对 10 的 9 次方加 7 取模
方案数增长很快。只要每步及时取模,两个已取模的数相加最多接近两倍模数,32 位也放得下;可一旦把一长段区间和先全部累计再取模,数值就会冲出范围。稳妥做法是每次累加后都及时取模,中间量用 long 承接更保险
✗ 错:算 dp[i] 时读到负下标
✓ 对:i 减 zero 或 i 减 one 为负就跳过该来源
长度不够时那条转移根本不存在,强行读负下标会越界或把不该有的方案加进来
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
class ListNode:
def __init__(self, val=0, next=None):
self.val = val
self.next = next
class Solution:
def countGoodStrings(self, low: int, high: int, zero: int, one: int) -> int:
mod = 10**9 + 7
dp = [0] * (high + 1)
dp[0] = 1
ans = 0
for i in range(1, high + 1):
if i >= zero:
dp[i] += dp[i - zero]
if i >= one:
dp[i] += dp[i - one]
dp[i] %= mod
if i >= low:
ans = (ans + dp[i]) % mod
return ansC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <optional>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
struct TreeNode {
int val;
TreeNode *left;
TreeNode *right;
TreeNode() : val(0), left(nullptr), right(nullptr) {}
TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}
};
struct ListNode {
int val;
ListNode *next;
ListNode() : val(0), next(nullptr) {}
ListNode(int x) : val(x), next(nullptr) {}
ListNode(int x, ListNode *next) : val(x), next(next) {}
};
class Solution {
public:
const int mod = 1e9 + 7;
int countGoodStrings(int low, int high, int zero, int one) {
vector<int> dp(high + 1, 0);
dp[0] = 1;
long ans = 0;
for (int i = 1; i <= high; i++) {
long cur = 0;
if (i >= zero) cur += dp[i - zero];
if (i >= one) cur += dp[i - one];
dp[i] = cur % mod;
if (i >= low) ans = (ans + dp[i]) % mod;
}
return (int) ans;
}
};Java
import java.util.*;
class TreeNode { int val; TreeNode left; TreeNode right; TreeNode(){} TreeNode(int val){this.val=val;} TreeNode(int val, TreeNode left, TreeNode right){this.val=val;this.left=left;this.right=right;} }
class ListNode { int val; ListNode next; ListNode(){} ListNode(int val){this.val=val;} ListNode(int val, ListNode next){this.val=val;this.next=next;} }
class Solution {
private static final int MOD = (int) 1e9 + 7;
public int countGoodStrings(int low, int high, int zero, int one) {
int[] dp = new int[high + 1];
dp[0] = 1;
long ans = 0;
for (int i = 1; i <= high; i++) {
long cur = 0;
if (i >= zero) {
cur += dp[i - zero];
}
if (i >= one) {
cur += dp[i - one];
}
dp[i] = (int) (cur % MOD);
if (i >= low) {
ans = (ans + dp[i]) % MOD;
}
}
return (int) ans;
}
}复杂度
时间
O(high)
长度从 0 到 high 每个值只被计算一次,每次转移只看退 zero 步和退 one 步两个来源,是常数操作;最后区间求和也是扫一遍 low 到 high。整体随 high 线性增长
空间
O(high)
只需要一个长度 high 加 1 的 dp 数组存每个长度的方案数,答案用一个变量顺手累加,不额外开销。自底向上是一遍循环,没有递归栈,空间就是这张 dp 表的 O(high)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 统计构造好字符串的方案数 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这题和爬楼梯 LeetCode 70 是什么关系?+
转移同构。爬楼梯是 dp[i]=dp[i-1]+dp[i-2],每步跨 1 或 2 级;本题每步让长度涨 zero 或 one,dp[len]=dp[len-zero]+dp[len-one]。差别在三处:步幅从固定的 1、2 变成参数 zero、one,下标减成负数的来路要跳过;答案从单点 dp[n] 变成 low 到 high 的区间和;方案数巨大要全程取模。会了爬楼梯,这题就是把步幅和统计口径换掉。
zero 和 one 相等时,两种操作会不会数重?+
不会,而且必须分开数。接 zero 个 0 和接 one 个 1 哪怕让长度涨得一样多,接上的字符不同,得到的串就不同。题面第一个例子 zero=one=1,转移变成 dp[len]=2·dp[len-1],长度 3 的答案是 2 的 3 次方等于 8,正说明两种选择各算一票,不能合并成一种。
为什么用自底向上循环,不写记忆化递归?+
两者答案一样,记忆化递归(自顶向下,算过的存表下次直接取)同样让每个长度只算一次。但 high 最大 10 的 5 次方,递归要从 high 一路压栈退到 0,深度几万层,Python 默认的递归深度限制直接报错,C++ 和 Java 也有栈溢出风险。一遍 for 循环从 1 推到 high 没有这个隐患,代码还更短。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 统计构造好字符串的方案数 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。