前言
很久没有写算法题了。放假拿起来写两个简单的,就看到这个题。总体来说难度不大~~(虽然写了好久)~~,但是有新的知识点——卡特兰数。值得一写。
心路历程1——尝试找出数学规律
看到他是 普通- 难度的题,然后输入输出很简单,就打算手动算一下找一找数学规律。
结果如下表
n | ans |
---|---|
1 | 1 |
2 | 2 |
3 | 5 |
4 | 14 |
5 | 42 |
6 | 132 |
好像总有那么一点点规律。于是进行一顿乱蒙,没有找到简单的一次二次线性关系。
尝试推导递推公式,就是能感觉到有规律却写不出来简介的公式。
花了很多时间没有什么收获,但是对上面这个表格,有了很深的影响。
心路历程2——暴力深搜(DFS)
-
思路
想一下这个题目背景,好像有很多种情况,但是发现你能控制的只有两个操作,push进栈和pop出栈。
拿n=3的为例,一定会有3次push和3次pop。
也就是,总共六次操作,每次有两种选择,这就是一个6bit的二进制数。
不仅如此,还有一个约束:出栈之前栈内必须要有东西。
进栈当做1,出栈当做0,就可以画出一个二叉树。
粉色标出的是出栈的时候栈为空,需要剪枝。
发现画树是一个很好的理清深搜思路的方法
-
代码
#define _CRT_SECURE_NO_WARNINGS #include <bits/stdc++.h> using namespace std; #define ll long long int n; int sta; ll cnt; int push_num; void dfs(int x) { if (x == n * 2 && sta == 0) { cnt++; return; } else if (x == n * 2) { return; } if (push_num > 0) { push_num--; sta++; dfs(x + 1); sta--; push_num++; } if (sta > 0) { sta--; dfs(x + 1); sta++; } } int main() { scanf("%d", &n); push_num = n; dfs(0); printf("%lld", cnt); return 0; }
-
结果
很不出意外的TLE了一个点。
发现只是慢了一点点并没有到跑不出来的地步,打一个表结束这道题(不)
心路历程3——记忆化搜索(MS)
-
思路
记忆化搜索是一个典型的空间换时间。
某一些状态在深搜的过程中出现过很多次,我们把这个状态对应的结果保存下来,再以后遇到相同的结果时直接调用,就可以剩下很多时间。
那么这里的状态怎么确定呢?
有很多种做法,我的思路是:栈内有几个数+还能push几次,这两个值确定一个状态。
每次进入深搜时,先查找是否已经保存了该状态:
- 有,直接获取结果
- 无,继续深搜,保存状态
-
代码
这里用了很多stl容器,map和pair。其实完全可以直接用二维数组,只是想用用stl容器。
map可以用unordered_map(哈希表),理论上更快。
#define _CRT_SECURE_NO_WARNINGS #include <bits/stdc++.h> using namespace std; #define ll long long int n; //输入的数字,总共有几个待进栈数字 ll cnt; int sta; //栈内元素数量 int push_num; //还可以压几次 map<pair<int, int>, ll> result; ll dfs(int x) { auto it = result.find(make_pair(sta, push_num)); if (it != result.end()) //如果能在result中找到对应的状态 { //加入计数器,并返回 ll t = it->second; cnt += t; return t; } if (x == 2 * n && sta == 0) { cnt++; return 1; } else if (x > 2 * n) { return 0; } ll tmp = 0; if (push_num > 0) { push_num--; sta++; tmp += dfs(x + 1); sta--; push_num++; } if (sta > 0) { sta--; tmp += dfs(x + 1); sta++; } result[make_pair(sta, push_num)] = tmp; return tmp; } int main() { scanf("%d", &n); push_num = n; dfs(0); printf("%lld", cnt); return 0; }
-
结果
轻松AC本题。n取到1000也较快能算完。n再大就要爆long long了。
心路历程4——动态规划
-
思路
都写到这里了,不继续来个动归吗?
记忆化搜索和动归,感觉是完全相同的底层逻辑的两种不同表现形式。
已经写出了记忆化搜索,最难的写出递推公式那一步就迎刃而解了。
看上面的深搜
if (push_num > 0) { push_num--; sta++; tmp += dfs(x + 1); sta--; push_num++; } if (sta > 0) { sta--; tmp += dfs(x + 1); sta++; }
得到公式(i = sta, j = push_num)
dp[i][j] = dp[i + 1][j - 1] + dp[i - 1][j];
然后看边界条件
栈内没有东西,还有n次可以push的情况数量,就是要求的答案。
不能再push了(push_num=0时),就确定了一种情况
再发现,i=0时,dp[i-1][j]没有意义,所以加一个if判断一下。
上代码。
-
代码
#define _CRT_SECURE_NO_WARNINGS #include <bits/stdc++.h> using namespace std; #define ll long long ll dp[1005][1005]; int main() { int n; scanf("%d", &n); for (int i = 0; i <= n; i++) { dp[i][0] = 1; } for (int j = 1; j < n; j++) { for (int i = 0; i <= n; i++) { if (i == 0) dp[i][j] = dp[i + 1][j - 1]; else dp[i][j] = dp[i + 1][j - 1] + dp[i - 1][j]; } } printf("%lld", dp[1][n - 1]); return 0; }
-
结果
实际测试下来,当数字很大时,比记忆化搜索略微快一点点,毕竟维护一个哈希表比直接读取二维数组慢得多
心路历程5——返璞归正,追寻数学本质
-
卡特兰数
看了题解,有一个神奇的词语映入眼帘:卡特兰数。
卡特兰数是一个数列,他的前几项是:
1,1,2,5,14,42,132
眼熟吗,这就是本题的答案。
在此类和进出栈相关的问题中,经常能遇到卡特兰数。同类型问题有,括号配对,完全二叉树等等。
心路历程2中说过,这个问题的结果可以看做是一个2n位的二进制数,其中n位是1,n位是0
简单的排列组合知识,这样的数一共有 C 2 n n C_{2n}^{n} C2nn 个。(2n位中选n位放1,其他放0)
下面只要算出有多少数字不符合条件(出栈的时候栈为空),与总数相减就行了。
- 一个不符合条件的序列如:100110
如果进栈+1,出栈-1,栈内的数可以写成:+1,-1,-1,+1,+1,-1
看每一项的前缀和,如果前缀和小于零,就是栈空了还出栈了一次。
+1 -1 -1 +1 +1 -1 1 0 -1 拿这个数列为例,把第一个位置到第一次前缀和小于零的位置取反,就变成
-1 +1 +1 +1 +1 -1 取反的部分-1比+1多一个,取反之后整体上看,+1比-1多一个
也就是1有n+1个,-1有n-1个。
取反之后的数字与取反之前是一一对应的,也就是满足1有n+1位,0有n-1位的数就是不符合条件的数。
这样的数共有 C 2 n n + 1 C_{2n}^{n+1} C2nn+1 or C 2 n n − 1 C_{2n}^{n-1} C2nn−1 个。
所以该题的答案便是:
C 2 n n − C 2 n n + 1 C_{2n}^{n} - C_{2n}^{n+1} C2nn−C2nn+1
这便是卡特兰数的通项公式稍微经过化简可以得到更直接的通项公式: C 2 n n + 1 n \frac {C_{2n}^{n+1}}{n} nC2nn+1 或 C 2 n n n + 1 \frac {C_{2n}^{n}}{n+1} n+1C2nn
有了通项公式,再返回去就容易推出递推公式了: f ( n ) = f ( n − 1 ) × ( 4 − 6 n + 1 ) f(n) = f(n-1) \times (4-\frac6{n+1}) f(n)=f(n−1)×(4−n+16)
-
分析
上述三个公式本质相同,最好的还是 C 2 n n − C 2 n n + 1 C_{2n}^{n} - C_{2n}^{n+1} C2nn−C2nn+1 这个。
因为如果数据过大,可以满足模的性质。