题目
我们给出 S,一个源于 {‘D’, ‘I’} 的长度为 n 的字符串 。(这些字母代表 “减少” 和 “增加”。)
有效排列 是对整数 {0, 1, …, n} 的一个排列 P[0], P[1], …, P[n],使得对所有的 i:
- 如果 S[i] == ‘D’,那么 P[i] > P[i+1],以及 ;
- 如果 S[i] == ‘I’,那么 P[i] < P[i+1]。
有多少个有效排列?因为答案可能很大,所以请返回你的答案模 10^9 + 7.
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/valid-permutations-for-di-sequence
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
这里主要想和大家讨论一下动态规划的相关思路。
思路一
题目中有这么一句话:
- 如果 S[i] == ‘D’,那么P[i] > P[i+1],
- 如果 S[i] == ‘I’,那么 P[i] < P[i+1]。
其实这就暗示了本题的一种解决思路,S[ i ] 决定的只是有效排列 P 中的相对大小,排列 P[ i + 1 ] 的值仅由 S[ i ] 和 P[ i ] 决定。
如果把整数范围 i 作为 dp 数组的一维,将P[ i ] 以 j 的方式( j = P[ i ] )作为第二维,那么我们可以大胆尝试一下,初步写出一个 不成熟的 状态转移方程:
-
dp[ i ][ j ] = dp[ i - 1 ][ j + 1] + dp [ i - 1][ j + 2 ] + … + dp[ i - 1][ i - 1 ] if (S[ i - 1 ] == ‘D’)
-
dp[ i ][ j ] = dp[ i - 1 ][ j - 1] + dp [ i - 1][ j - 2 ] + … + dp[ i - 1][ 0 ] if (S[ i - 1 ] == ‘I’)
意思是 S[ i - 1] = 'D’时,整数范围 i 并以 j 为末尾的有效排列数量 ,是所有 整数范围 i - 1 并以大于 j 结尾的排列数量的总和。另一种情况,同理。
但很明显!这是错误的方程,因为对于子问题 i - 1,上述方程漏掉了dp[ i - 1][ j ]。 想要知道 dp[ i - 1][ j ] 应该加在哪里,就必须弄清 随着 i 的增加,P 排列是如何增加数字的。接下来我们开始正面分析问题。
输入 “DID” ,字符串的长度n = 3。
当 n = 2 时,一共有两种有效排列 【1 0 2】 和 【2 0 1】,当 n = 3 时 ,以【1 0 2】 为例,我们想要在后面添加一个数字。可加的数字有 3,2,1,0 ,我们可以依次看一下如何添加。
添加 k ,就让之前大于等于k都加1。折线图很好的反应了相对大小关系,以及这个规则下产生的排列是唯一的。然后,我们综合起来看。
我们可以发现,n = 3 时,以 1 结尾的有【2 0 3 1】和【 3 0 2 1】两个,可以分别由 n = 2 中 大于等于 1 结尾的排列得到。再比如,n = 3 时,以 2 结尾的【1 0 3 2】只有一个,因为 n = 2 中 大于等于 2 结尾的只有【1 0 2】一项。
所以 dp[ i - 1] [ j ] 应该加在 下降 ‘D’ 的条件中,下面给出 真正的 状态转移方程:
dp[ i ][ j ] = dp[ i - 1 ][ j ] + dp [ i - 1][ j + 1 ] + ... + dp[ i - 1][ i - 1 ] if (S[ i - 1 ] == 'D')
dp[ i ][ j ] = dp[ i - 1 ][ j - 1] + dp [ i - 1][ j - 2 ] + ... + dp[ i - 1][ 0 ] if (S[ i - 1 ] == 'I')
考虑边界 i = 0时 ,数字范围只有0,只有一种排法,初始化 dp[ 0 ][ 0 ] = 1。
int numPermsDISequence(string S) {
int n = S.length();
vector<vector<int>> dp(n + 1,vector<int>(n + 1, 0));
int mod = 1000000007;
dp[0][0] = 1;
for(int i = 1 ; i <= n; i++){
for(int j = 0 ; j <= i; j++){
if(S[i- 1] == 'D'){
for(int k = j; k < i ; k++){
dp[i][ j] += dp[i - 1][k] ;
dp[i][ j] = dp[i][ j]%mod;
}
}else{
for(int k = 0 ; k < j ; k++){
dp[i ][ j] += dp[i - 1][k];
dp[i ][ j] = dp[i ][ j]%mod;
}
}
}
}
int ans = 0;
for(int j = 0; j <= n ; j++){
ans += dp[n][j];
ans = ans % mod;
}
return ans;
}
复杂度分析
- 时间复杂度: O ( N 3 ) O(N^3) O(N3)
- 空间复杂度: O ( N 2 ) O(N^2) O(N2)
优化
我们再次上述的状态转移方程,
(1) 当S[ i - 1 ] == ‘D’。
dp[ i ][ j ] = dp[ i - 1 ][ j ] + dp [ i - 1 ][ j + 1 ] + … + dp[ i - 1 ][ i - 1 ]
dp[ i ][ j + 1] = dp[ i - 1 ][ j + 1] + dp [ i - 1 ][ j + 2 ] + … + dp[ i - 1 ][ i - 1 ]
(2) 当S[ i - 1 ] == ‘I’。
dp[ i ][ j ] = dp[ i - 1 ][ j - 1] + dp [ i - 1 ][ j - 2 ] + … + dp[ i - 1 ][ 0 ]
dp[ i ][ j - 1 ] = dp[ i - 1 ][ j - 2] + dp [ i - 1 ][ j - 3 ] + … + dp[ i - 1 ][ 0 ]
可以发现当 S [ i - 1] 为 ‘D’ 时,dp[ i ][ j ] 只比 dp[ i ][ j + 1 ] 多出 dp[ i - 1 ][ j ] , 而当S[ i - 1 ]为‘I’,dp[ i ][ j ] 比dp[ i ][ j - 1] 多出了 dp[ i - 1 ][ j - 1 ]。状态转移可以修改为:
dp[ i ][ j ] = dp[ i ][ j - 1 ] + dp[ i - 1 ][ j - 1] if S[i - 1] == 'I'
dp[ i ][ j ] = dp[ i ][ j + 1 ] + dp[ i - 1 ][ j ] if S[i - 1] == 'D'
考虑边界同理dp[ 0 ][ 0 ] 为1,并为了方便 把 dp 第二维扩大到 n +2。
class Solution {
public:
int numPermsDISequence(string S) {
int n = S.length();
vector<vector<int>> dp(n + 1,vector<int>(n + 2));
int mod = 1000000007;
dp[0][0] = 1;
for(int i = 1 ; i <= n; i++){
if(S[i- 1] == 'D'){
for(int j = i - 1; j >= 0 ;j--){
dp[i][ j] = dp[i][j + 1] + dp[i - 1][j ];
dp[i][ j] = dp[i][ j]%mod;
}
}else{
for(int j = 1 ; j <= i ; j++){
dp[i ][ j] = dp[i ][j - 1] + dp[i - 1][j - 1];
dp[i ][ j] = dp[i ][ j]%mod;
}
}
}
int ans = 0;
for(int j = 0; j <= n ; j++){
ans += dp[n][j];
ans = ans % mod;
}
return ans;
}
};
复杂度分析
- 时间复杂度: O ( N 2 ) O(N^2) O(N2)
- 空间复杂度: O ( N 2 ) O(N^2) O(N2),每次循环只涉及前后两组状态,设置滚动数组,空间复杂度还可以进一步优化为 O ( N ) O(N) O(N),这里不再展开。
思路二
接下来,我们换一种思路来思考这个问题。
这里不再逐步扩大数字选择范围 来解决此问题。而是一开始我们就能选择从0 到 n 的数字。为了方便理解,我们发挥点想象力,可以将数字从小到大,如同木块堆叠起来。每次就从中抽出一块放入排列P中。
这里我们还是以“DID” 为例,每次抽出一个数字块,这个块上的其他块掉下来补齐,整个过程保持从上到下,由大到小。
那么这里我们可以思考一个问题。如果 我们第2次 抽取 第1层的木块,第1次应该抽取哪一层才能 构成降序‘D’。
我们罗列出所有情况。
我们观察可以发现一个规律,想要构成‘ D ’,即 p[ i - 1 ] > p[ i ],就必须让上次抽取的层数 大于 本次抽取的层数;想要构成‘ I ’,即p[ i - 1 ] < p[ i ] ,就必须让上次抽取的层数 小于等于 本次抽取的层数。将那些层数的方案数 加起来 就是本次抽取层的方案数。
让 i 代表已经排了 i 个数字,现在应该排p[ i ] ,让 j 代表选择第 j 层数字(或 选择剩余未排序数字中第 j + 1小的数字),dp[ i ][ j ] 为方案数量。我们就可以写出状态转移方程了。
dp[ i ][ j ] = dp[i - 1][ j + 1] + dp [i - 1][j + 2] + ... + dp[i - 1][ n - i ] if (S[ i - 1 ] == 'D')
dp[ i ][ j ] = dp[ i - 1 ][ j ] + dp [ i - 1][ j - 1 ] + ... + dp[ i - 1][ 0 ] if (S[ i - 1 ] == 'I')
我们可以发现,这与未优化的思路一的 方程相似。不同的是,两个思路的 j 的限制范围不同,一个是逐步扩大,另一个是逐步减小;思路一的最终有效排列数量是dp[ n ] 的总和,而思路二 则直接就是dp[ n ][ 0 ]。
直接按照这个方程书写的时间复杂度是 O ( N 3 ) O(N^3) O(N3),与未优化的思路一程序结构相似,避免啰嗦,直接优化。
优化
同理,我们分别对比 dp[ i ][ j ] 和 dp[ i ][ j + 1]、dp[ i ][ j ] 和 dp[ i ][ j - 1],获得优化后的方程。
dp[ i ][ j ] = dp[ i ][ j + 1 ] + dp[ i - 1 ][ j + 1 ] if S[i - 1] == 'D'
dp[ i ][ j ] = dp[ i ][ j - 1 ] + dp[ i - 1 ][ j ] if S[i - 1] == 'I'
考虑边界,i = 0时,可以选择范围为n 的数字,选择每种数字的方案数分别为1,所以初始化dp[ 0 ] 为1。
并且引入变量cur 处理 j 的边界问题。
int numPermsDISequence(string S) {
int n = S.length();
vector<vector<int>> dp(n + 1,vector<int>(n + 1));
int mod = 1000000007;
int cur = 0;
for (int j = 0; j <= n; j++) dp[0][j] = 1;
for (int i = 1; i <= n;i++)
{
cur = 0;
if(S[i - 1] == 'D')
{
// 注意与思路一的范围区别
for (int j = n - i; j >= 0; j-- )
{
// 这里用cur 避免了一下边界问题
// 实际上就是 dp[i][j] = dp[i][j+1] + dp[i-1][j+1];
cur = ( cur + dp[i - 1][j + 1] ) %mod;
dp[i][j] = cur ;
}
}
else
{
for(int j = 0 ; j <= n - i; j++)
{
// dp[i][j] = dp[i][j-1] + dp[i-1][j];
cur = (cur + dp[i - 1][j])%mod ;
dp[i][j] = cur;
}
}
}
return dp[n][0];
}
复杂度分析
- 时间复杂度: O ( N 2 ) O(N^2) O(N2)
- 空间复杂度: O ( N 2 ) O(N^2) O(N2),设置滚动数组,空间复杂度还可以进一步优化为 O ( N ) O(N) O(N)。
水平有限,若有错误,请多包涵 (Θ3Θ)
参考
[1] https://leetcode-cn.com/problems/valid-permutations-for-di-sequence/solution/di-xu-lie-de-you-xiao-pai-lie-by-leetcode/
[2] https://leetcode.com/problems/valid-permutations-for-di-sequence/discuss/168278/C%2B%2BJavaPython-DP-Solution-O(N2)