一道集众多技巧于一身的题目——《密文游戏》
题目描述
小明是你的好朋友,想和你一起玩密文游戏。你俩约定了一个简单的加密解密方案:字符 0 映射字母 A,字符 1 映射字母 B,依次类推,字符 9 映射字母 J。例如:原文1314的密文为BDBE。
昨天,你给小明发了一条信息。由于未知原因,密文到小明那里发生了缺失。今天上课,你俩见面对照了原文和密文,发现密文丢失了若干字符。设原文S的长度为n,密文T的长度为m,小明突发奇想:对照原文,有多少种方案能把密文补全,字母数量和位置不同,都视为不同的方案。作为好友的你,帮他计算一下吧。
输入格式
第一行,原文S,长度为n(2≤n≤2000)。
第二行,密文T,长度为m(1≤m ≤ n-1 )。
输出格式
一行,所求的方案数。
样例 #1
样例输入 #1
1 |
|
样例输出 #1
1 |
|
样例说明
样例中3种补全方案如下。()中代表所补的密文。
BE(BE)
B(EB)E
(BE)BE
代码长度限制 | 时间限制 | 内存限制 |
---|---|---|
16KB | 1000ms | 2MB |
分析
可以看出,本题就是一道方案统计的问题。
一般来说,方案统计的问题也需要借助状态之间的转移,所以我们也总是将这种方案统计的问题与动态规划问题统称为DP,尽管这类问题不涉及最优解。
所以,这个问题我们依旧可以使用之前用来解决动态规划问题的思想,在状态转移的过程中完成对结果方案的统计,即:定状态,找转移,初始化
定状态
对于这个问题,我们可以定义状态:
$$
\begin{vmatrix}
dp[i][j](j\leq i,0\leq i\leq n,0\leq j\leq m) \
n=len(A) \
m=len(B)
\end{vmatrix}
$$
表示$a_1…a_i$与$b_1… b_j$补全匹配的方案数
找转移
定义好了状态,来处理状态间的转移。
可以分成两种情况:
$$
dp[i][j] = \begin{cases}
dp[i - 1][j] & a_i \neq b_j\
dp[i - 1][j - 1]+dp[i - 1][j] & a_i = b_j
\end{cases}
$$
转换成代码即为:
1 |
|
好了,找到转移方程式之后,下面中要的事情就是要规定初始化,不然从头到尾跑完结果就是0。
一种可行的初始化方案是:$dp[i][0]=1(0≤i≤n)$即对于所有A的前缀序列,不使用B中字符,全部自动补全的方案数是1.
加上初始化后:
1 |
|
边界判断
在dp的过程中,一些状态是不可能出现的,比如超过原序列长度的译码序列.
还有一种情况,由于译码序列需要全部出现在源码序列中,所以译码序列的剩余长度不能超过原序列的剩余长度。
其实上面的条件分别对应着对b枚举的上下边界:
对于j上界,有:$j\leq min(i,m)$
对于j下界,有:$n-i \leq m-j$即$j\geq m-n+i$
加上边界判断:
1 |
|
答案输出
经过一顿状态转移,易知最终的答案为A序列前n项和B序列前m项的匹配方案:
$$
dp[n][m]
$$
完整代码
初始的代码非常简洁:
1 |
|
优化
上面虽然解决了题目的核心问题,但是这版代码却是不能够通过的。
最直观的问题就是空间复杂度太高了。还有一些问题就是其可表示的答案范围太小(是的,long long也不够,2000规模的组合结果是非常巨大的数字)
下面就来通过优化算法依次解决这些问题。
为了防止抄袭酿成悲剧,优化部分可能只会展示伪代码。完整的代码会在实验课程时间过后放出。
空间复杂度优化
从题目时空限制来看:
时间限制 | 内存限制 |
---|---|
1000ms | 2MB |
这道题只给了我们2M的空间。不妨做个粗略的计算,假设这2M的空间全部用来开long long数组,那么我们可以开$2\frac{2^{21}}{8}=2^{18}$ 个数组长度。
而我们仅dp数组就开了$2000*2000=4 \times 10^6 \approx2^{22}$个。
因此这空间是妥妥的超限的,所以我们需要优化算法,将空间复杂度优化到规定一下。
滚动数组优化
让我们将目光聚焦到占据最大空间的dp数组上,再次观察一下转移方程式:
$$
dp[i][j] = \begin{cases}
dp[i - 1][j] & a_i \neq b_j\
dp[i - 1][j - 1]+dp[i - 1][j] & a_i = b_j
\end{cases}
$$
经过观察,可以发现,在转移的过程中,本行的数据仅由前一行的数据转移而来!。
也就是说,如果我们想要的答案在最后一行,那么:仅需要保留正在计算的一行和前一行的数据!
因此这个结论,对于空间优化就存在巨大利好,可以知道:dp数组仅需要开2行
这样以来,我们就将空间复杂度从$O(n^2)$降到了$O(n)$了,肯定可以满足题目的空间需求。
接下来的问题就是:如何使用这两行数组呢?
一个可行方法是每次在第1行计算完值后,将这行值复制到第0行,循环往复。
但是还有一种思路是交替使用两行,每次使用完不需要复制,下次切换到另一行进行求解。
实现也非常简单,可以根据奇偶判断当前使用哪一行
-
当前行:k = i & 1
-
上一行:k ^ 1
改造一下之前的dp过程:
1 |
|
最终答案的获取:
$$
dp[n&1][m]
$$
倒序遍历优化
你以为优化到两行就达到最优了?当然不是,我们还可以进行更加**的优化,将转移矩阵优化到一维!
如果在进一步观察我们的转移方程式,可以发现其实对于上一行使用到的项都在j的一边(<=j)。
因此我们不妨直接在原序列上进行迭代,转移方程为:
$$
dp[i][j] = \begin{cases}
dp[j] & a_i \neq b_j\
dp[j - 1]+dp[j] & a_i = b_j
\end{cases}
$$
当然,这里的$dp[j - 1]$应该是上一次计算的值,在计算$dp[j]$之前不应该被改变。
所以不妨倒着枚举j,这样求解过程就可以准确无误的进行了。
其实将转移压缩到一维,连初始化都变得简单了,代码实现:
1 |
|
大数运算优化
好了,解决了空间问题,还要解决精度的问题。由于题目的结果非常巨大,连long long的范围也会超出(其实有数百位)。因此我们需要突破long long的限制,手动实现大整数的加法。
封装高精度运算
为了不改变原有的代码,有一个非常不错的想法就是实现一个大整数的类,在其中重载+和整数赋值。然后将dp的数据类型换成这个类。最后,还要能够打印这个数字。
所以,让我们梳理一下这个类型需要的功能:
-
同类型对象相加运算符重载
-
整数类型赋值
-
打印输出
好的,让我们来动手实现一下:
1 |
|
高精度压位
虽然我们使用高精度模拟大数加减解决了精度问题。但是到了上一步还是不能保证题目能够完美通过。
从时间上说,每次大数运算最长可能达到了数百位的运算,这个常数非常大,在$O(n^2)$的算法复杂度下很有可能超时。
从空间上说,每个dp数组元素都有长达500的高精度数位,其实相当于开了一个2000*500的数组,非常有可能爆空间。
所以我们还要进一步优化(相信我铁汁,这是最后一次!!)
考虑到int能表示的数据范围非常大,而只用它来表示0-9确实有很大浪费。所以我们可以采取高精度压位的操作。
简单来说,就是让高精度数列中的一位表示更多的位数。一位可以表示0-9
,也可以表示0-99
,也可以表示0-999
。区别仅仅是进位时%10
,%100
,%1000
。于是可以将10换成一个基底变量BASE
,这个变量是10的幂,几次幂就是表示了几位。
那么这个BASE
选多少合适呢?在int下,最大可以选1000000000
(10^9)。因为int最大上限为2.1 * 10 ^ 9
。所以选择10 ^ 9
刚好可以保证两个数位相加不会溢出!
对于实现,简单修改下之前的代码即可:
1 |
|