zoukankan      html  css  js  c++  java
  • 【学习笔记】卡特兰数

    【学习笔记】卡特兰数

    基本概念

    问题引入:网格行走问题

    在一个平面直角坐标系内,你位于 ((0, 0)),你想要走到 ((n, n)) ((ngeq 1))。每步只能向右或向上走一单位长度。要求在任意时刻,你所处的坐标 ((x, y)) 满足 (xgeq y)(也就是不能越过第一象限角平分线)。求有多少种合法的方案。

    即不超过蓝线的方案

    做法:如果不考虑“不能越过第一象限角平分线”的要求,那么总方案数显然是 ({2nchoose n}),也就是在总共 (2n) 步中,任选出 (n) 步向上走(剩下的 (n) 步向右走)。

    下面考虑减去不合法的方案。

    如果一种方案不合法,那这条路径上至少会有一个点,碰到了直线 (y = x + 1)。假设路径第一次碰到 (y = x + 1) 的点为 (p)。将 (p) 之后的路径(即从 (p)((n, n)) 的路径)关于直线 (y = x + 1) 做对称。

    如下图,绿线即 (y = x + 1)线为原路径越过第一象限角平分线后的部分,线为关于 (y = x + 1) 对称后的结果。

    发现任何一条不合法的路径,对称后都唯一对应一条从 ((0, 0))((n - 1, n + 1)) 的路径。并且,任何一条从 ((0, 0))((n - 1, n + 1)) 的路径,也唯一对应了一条不合法的原路径(从第一次经过 (y = x + 1) 的点开始,对称回来,就能得到原路径了)。因此,二者之间是一一映射的关系。

    所以,不合法的路径数量,就等于从 ((0, 0))((n - 1, n + 1)) 的路径数量,即 ({2nchoose n + 1})

    所以答案就等于:

    [{2nchoose n} - {2nchoose n + 1} ]

    示意图:

    示意图


    定义:卡特兰数

    [c_n = {2nchoose n} - {2nchoose n + 1} ]

    它的前几项(从 (c_0) 开始)是:(1), (1), (2), (5), (14), (42), (132), (429), (1430), (4862) ...


    引理1:卡特兰数的另一种形式

    [c_n = frac{{2nchoose n}}{n + 1} ]

    证明

    [egin{align} c_n &= {2nchoose n} - {2nchoose n + 1}\ &= frac{(2n)!}{n!n!} - frac{(2n)!}{(n + 1)!(n - 1)!}\ &= frac{(2n)!cdot (n + 1)!(n - 1)! - (2n)!cdot n!n!}{n!n!(n + 1)!(n - 1)!}\ &= frac{(2n)!(n - 1)!n!cdot ((n + 1) - n)}{n!n!(n + 1)!(n - 1)!}\ &= frac{(2n)!}{n!(n + 1)!}\ &= frac{1}{n + 1}cdot frac{(2n)!}{n!n!}\ &= frac{{2nchoose n}}{n + 1} end{align} ]


    引理2:卡特兰数的递推式

    (c_{0} = 1),则对任意 (n > 0),有:

    [c_n = sum_{i = 1}^{n}c_{i - 1}cdot c_{n - i} ]

    证明

    考虑上述的网格行走问题,我们枚举路径里(除起点外)第一次碰到直线 (y = x) 的点,设它的坐标为 ((i, i)) ((1leq ileq n))。

    那么从 ((0, 0)) 走到 ((i, i)) 的方案数,就相当于从 ((1, 0)) 走到 ((i, i - 1)) 且不越过直线 (y = x - 1) 的方案数(因为我们要保证 ((i, i)) 是第一次碰到 (y = x),所以之前不能碰),即 (c_{i - 1})

    ((i, i)) 走到 ((n, n)) 的部分,方案数显然是 (c_{n - i})

    所以每个 (i) 贡献的方案数就是 (c_{i - 1} cdot c_{n - i})

    小练习:用生成函数方法,从【递推式】推出【定义式】。


    几种常见的实际意义

    • (n) 对括号的合法括号序列数。把左括号看做向右走,右括号看做向上走,则等价于上述的网格行走问题。
    • (n) 个数入栈、出栈(以固定顺序入栈,在任意栈非空的时刻可以选择弹出一个数)得到的排列数。入栈即向右走,出栈即向上走,等价于网格行走问题。
    • (n) 个节点的二叉树数量。观察上述递推式,相当于枚举左子树大小为 (i - 1),右子树大小为 (n - i)
    • (n) 层的阶梯切割为 (n) 个矩形的切法数(见「AHOI2012」树屋阶梯)。

    再探网格行走问题

    将【网格行走问题】中的终点从 ((n, n)) 改为 ((n, m)),保证 (ngeq m)。仍然要求在任意时刻你所处的坐标 ((x, y)) 满足 (xgeq y)。求有多少种合法的方案。

    做法:仍然考虑在第一次碰到直线 (y = x + 1) 时,将此后的路径关于 (y = x + 1) 对称。发现不合法的路径,与从 ((0, 0))((m - 1, n + 1)) 的路径一一映射。所以答案就是

    [{n + mchoose n} - {n + mchoose n + 1} ]

    详见此题:「SCOI2010」生成字符串

    例题1:「HNOI2009」有趣的数列

    题目链接

    题目大意

    我们称一个长度为 (2n) 的数列是有趣的,当且仅当该数列满足以下三个条件:

    • 它是从 (1 sim 2n)(2n) 个整数的一个排列 ({a_n}_{n=1}^{2n})
    • 所有的奇数项满足 (a_1 < a_3 < dots < a_{2n-1}),所有的偶数项满足 (a_2 < a_4 < dots < a_{2n})
    • 任意相邻的两项 (a_{2i-1})(a_{2i}) 满足:(a_{2i-1}<a_{2i})

    例如,(n = 3) 时共有 (5) 个有趣的数列:((1,2,3,4,5,6)), ((1,2,3,5,4,6)), ((1,3,2,4,5,6)), ((1,3,2,5,4,6)), ((1,4,2,5,3,6))

    对于给定的 (n),请求出有多少个不同的长度为 (2n) 的有趣的数列。答案对一个给定的数 (p) 取模(注意,(p) 不一定是质数)。

    数据范围:(1leq nleq 10^{6})(1leq pleq 10^{9})


    考虑将数字 (1, 2,dots, 2n) 依次填入排列,使结果是有趣的。那么,我们每次一定会选择【最小的空奇数位】或【最小的空偶数位】。因为只有这样才能使得奇数项和偶数项分别递增。

    但此时仍然不一定满足【(forall i: a_{2i-1}<a_{2i})】的要求。考虑如果存在 (a_{2i - 1} > a_{2i}),说明 (2i - 1) 这个位置上的数,填的时间比 (2i) 位置迟。也就是第 (i) 个奇数位填的时间比第 (i) 个偶数位迟。我们要避免这种情况,等价于保证在任意时刻【奇数位上的数的数量】(geq)【偶数位上的数的数量】。

    把【在奇数位填一个数】看做向右走一步,【在偶数位填一个数】看做向上走一步,那么原问题等价于【网格行走问题】。所以答案就是卡特兰数,即:(frac{{2nchoose n}}{n + 1})

    本题的另一个难点是,模数不一定是质数,不方便求逆元。考虑如何不使用除法。

    [frac{{2nchoose n}}{n + 1} = frac{(2n)!}{n!(n+1)!} =frac{prod_{i = n + 2}^{2n}i}{prod_{i = 1}^{n}i} ]

    考虑求出每个质数对答案的贡献(几次幂),再相乘。分别算出分子、分母里每个质数的次幂,然后相减即可(除法被转化为了减法!)。计算每个质数的次幂,我的做法是枚举所有 (i),并分解质因数。暴力分解质因数,单次的复杂度是 (mathcal{O}(sqrt{n})) 的,太慢了。可以先用线性筛预处理出每个数的最小质因子,这样在分解质因数时,可以省去不必要的枚举,单次分解的复杂度就是每个数的质因子数量,是 (mathcal{O}(log n)) 的,总时间复杂度 (mathcal{O}(nlog n))

    参考代码
    // problem: P3200
    #include <bits/stdc++.h>
    using namespace std;
    
    #define mk make_pair
    #define fi first
    #define se second
    #define SZ(x) ((int)(x).size())
    
    typedef unsigned int uint;
    typedef long long ll;
    typedef unsigned long long ull;
    typedef pair<int, int> pii;
    
    template<typename T> inline void ckmax(T& x, T y) { x = (y > x ? y : x); }
    template<typename T> inline void ckmin(T& x, T y) { x = (y < x ? y : x); }
    
    const int MAXM = 2e6;
    
    int n, MOD;
    
    inline int mod1(int x) { return x < MOD ? x : x - MOD; }
    inline int mod2(int x) { return x < 0 ? x + MOD : x; }
    inline void add(int &x, int y) { x = mod1(x + y); }
    inline void sub(int &x, int y) { x = mod2(x - y); }
    inline int pow_mod(int x, int i) {
    	int y = 1;
    	while (i) {
    		if (i & 1) y = (ll)y * x % MOD;
    		x = (ll)x * x % MOD;
    		i >>= 1;
    	}
    	return y;
    }
    
    int p[MAXM + 5], cnt;
    bool v[MAXM + 5];
    int minp[MAXM + 5];
    
    int f[MAXM + 5];
    
    int main() {
    	cin >> n >> MOD;
    	
    	int m = 2 * n;
    	for (int i = 2; i <= m; ++i) {
    		if (!v[i]) {
    			p[++cnt] = i;
    			minp[i] = i;
    		}
    		for (int j = 1; j <= cnt && p[j] * i <= m; ++j) {
    			v[p[j] * i] = 1;
    			minp[p[j] * i] = p[j]; // 最小质因子
    			if (i % p[j] == 0) {
    				break;
    			}
    		}
    	}
    	
    	for (int i = n + 2; i <= m; ++i) {
    		int x = i;
    		while (x != 1) {
    			int y = minp[x];
    			while (x % y == 0) {
    				f[y]++;
    				x /= y;
    			}
    		}
    	}
    	for (int i = 2; i <= n; ++i) {
    		int x = i;
    		while (x != 1) {
    			int y = minp[x];
    			while (x % y == 0) {
    				f[y]--;
    				x /= y;
    			}
    		}
    	}
    	
    	int ans = 1;
    	for (int i = 2; i <= m; ++i) {
    		if (!v[i]) {
    			assert(f[i] >= 0);
    			ans = (ll)ans * pow_mod(i, f[i]) % MOD;
    		}
    	}
    	cout << ans << endl;
    	
    	return 0;
    }
    

    例题2:「NOI2018」冒泡排序

    题目链接

    题目大意

    冒泡排序算法:

    输入:一个长度为 n 的排列 p[1...n]
    输出:p 排序后的结果。
    for i = 1 to n do
    	for j = 1 to n - 1 do
    		if(p[j] > p[j + 1])
    			交换 p[j] 与 p[j + 1] 的值
    

    可以证明,交换次数的一个下界是 (frac{1}{2}sum_{i = 1}^{n}|i - p_i|)

    称一个长度为 (n) 的排列是好的,当且仅当对它进行冒泡排序的交换次数恰好等于 (frac{1}{2}sum_{i = 1}^{n}|i - p_i|)

    给定一个长度为 (n) 的排列 (q)。求字典序严格大于 (q) 的好的排列数。答案对 (998244353) 取模。

    数据范围:每个测试点有 (5) 组测试数据,每组测试数据满足 (1leq nleq 6 imes 10^5),整个测试点满足 (sum nleq 2 imes 10^6)


    发现,一个排列是好的,当且仅当不存在长度 (geq 3) 的下降子序列。

    考虑逐位构造一个好的排列,现在填到位置 (i),前 (i-1) 位的最大值为 (mathrm{mx})。则第 (i) 位要么填任意一个 (> mathrm{mx}) 的数,要么填 $ < mathrm{mx}$ 的最小的数(否则就一定会出现长度为 (3) 的下降子序列)。

    于是想到 DP。设 (mathrm{dp}(i,j)) 表示前 (i) 位的最大值是 (j) 的情况下,第 (i+1) 到第 (n) 位的填数方案。这样我们可以从后往前转移(或者用记忆化搜索实现),即:

    [mathrm{dp}(i, j) = left(sum_{k=j+1}^{n}mathrm{dp}(i + 1, k) ight)+mathrm{dp}(i + 1, j)=sum_{k=j}^{n}mathrm{dp}(i + 1, k) ]

    特别地,如果 (j < i),则 (mathrm{dp}(i, j) = 0)

    为什么要把 DP 数组定义成“第 (i) 位之后的填数方案”呢?因为这样便于我们处理字典序的问题。我们统计答案时,枚举从第 (i) 位开始,字典序第一次大于输入的排列 (q)(前 (i-1) 位全部和 (q) 相等)。设 (mathrm{mx}_i=max_{j=1}^{i}q_j),则:

    [mathrm{ans}_i=sum_{j = mathrm{mx}_i+1} ^ {n} mathrm{dp}(i, j) = mathrm{dp}(i-1,mathrm{mx}_i+1) ]

    答案就是所有 (mathrm{ans}_i) 之和。

    这样暴力 DP 是 (mathcal{O}(n^3)) 的,用后缀和优化可做到 (mathcal{O}(n^2))

    继续观察这个 DP。发现 (mathrm{dp}(i, j)) 就相当于在一个二维平面上,从点 ((i, j)) 走到点 ((n, n)) 的方案数。同时我们有一些要求:

    1. 每轮必须先向右走一步(也就是 (i o i + 1))。
    2. 然后可以向上走若干步,或不向上走(也就是 (j o k), (kgeq j))。
    3. 每轮结束时,需保证所在位置 ((i, j)) 满足 (ileq j)
    4. 如此进行 (n - i) 轮之后,恰好到达点 ((n, n))

    称这样的 (n - i) 个“轮”,为一个“方案”。我们要计算满足上述要求的“方案”的数量。

    直接对“方案”计数,其实就是上述 DP 的过程了。但我们要优化它,就必须跳出这个思路的局限。把方案里的所有“轮”拆散了看,它就是一条从 ((i, j)) 走到 ((n, n))路径(这里和后文中所有“路径”都是指:每步只能向上或向右走一格),其中每向右走一步,就相当于开始了新的一轮。并且任意一条路径一定恰有 (n - i) 步是向右走的,因此我们不需要刻意地去划分出轮次,直接对路径计数即可。

    具体来说,路径需要满足如下要求:

    1. 路径的第一步必须是向右走的:也就是 ((i, j) o (i + 1, j)),而不能是 ((i, j) o (i, j + 1))
    2. 在原来的“方案”里,要求每轮结束时满足 (ileq j),但在过程中(比如说先向右走了一步,还没向上走之前)是不一定的。实际上,要求可以转化为:路径里不能存在 ((i, j) o (i + 1, j))(i > j),因为这一步是向右走的,意味着 ((i, j)) 这一轮已经结束了。所以要求可以进一步转化为,整个路径中,不能存在 (i - jgeq 2)

    考虑如何统计满足上述两个要求的路径数。第 1 个要求很好实现,我们把起点设为 ((i + 1, j)) 即可!

    第 2 个要求相当于,整个过程里,不能碰到直线 (y = x - 2)。类比卡特兰数的推导方法,考虑用总数减去不合法的路径数。

    • 总数即从 ((i + 1, j)) 走到 ((n, n)) 的路径数,显然是 ({n - (i + 1) + n - jchoose n - (i + 1 )} = {2n-i-j-1choose n - i - 1})
    • 不合法即碰到了直线 (y = x - 2)。我们在它第一次碰到时,将路径关于 (y = x - 2) 对称。那么【不合法的路径】和【从 ((i + 1, j)) 走到 ((n + 2, n - 2)) 的路径】形成了一一映射。所以不合法的路径数等于【从 ((i + 1, j)) 走到 ((n + 2, n - 2)) 的路径数】,即 ({(n + 2) - (i + 1) + (n - 2) - jchoose (n + 2) - (i + 1)} = {2n-i-j-1choose n - i + 1})

    综上所述,(mathrm{dp}(i, j) = {2n - i - j - 1choose n - i - 1} - {2n - i - j - 1choose n - i + 1})。按之前的方法,直接统计答案即可。

    注意,如果 (q) 的前 (i) 位已经存在不合法的情况(不符合“第 (i) 位要么填一个 (> mathrm{mx}_{i-1}) 的数,要么填 (< mathrm{mx}_{i-1}) 的最小的数”这条规则),要及时 ( exttt{break})

    时间复杂度 (mathcal{O}(n))

    参考代码

    实际提交时请使用读入优化,详见本博客公告。

    // problem: LOJ2719
    #include <bits/stdc++.h>
    using namespace std;
    
    #define mk make_pair
    #define fi first
    #define se second
    #define SZ(x) ((int)(x).size())
    
    typedef unsigned int uint;
    typedef long long ll;
    typedef unsigned long long ull;
    typedef pair<int, int> pii;
    
    template<typename T> inline void ckmax(T& x, T y) { x = (y > x ? y : x); }
    template<typename T> inline void ckmin(T& x, T y) { x = (y < x ? y : x); }
    
    const int MAXN = 6e5, MOD = 998244353;
    inline int mod1(int x) { return x < MOD ? x : x - MOD; }
    inline int mod2(int x) { return x < 0 ? x + MOD : x; }
    inline void add(int &x, int y) { x = mod1(x + y); }
    inline void sub(int &x, int y) { x = mod2(x - y); }
    inline int pow_mod(int x, int i) {
    	int y = 1;
    	while (i) {
    		if (i & 1) y = (ll)y * x % MOD;
    		x = (ll)x * x % MOD;
    		i >>= 1;
    	}
    	return y;
    }
    
    int fac[MAXN * 2 + 5], ifac[MAXN * 2 + 5];
    inline int comb(int n, int k) {
    	if (n < 0 || k < 0 || n < k) return 0;
    	return (ll)fac[n] * ifac[k] % MOD * ifac[n - k] % MOD;
    }
    void facinit(int lim = MAXN) {
    	fac[0] = 1;
    	for (int i = 1; i <= lim; ++i) fac[i] = (ll)fac[i - 1] * i % MOD;
    	ifac[lim] = pow_mod(fac[lim], MOD - 2);
    	for (int i = lim - 1; i >= 0; --i) ifac[i] = (ll)ifac[i + 1] * (i + 1) % MOD;
    }
    
    int n, a[MAXN + 5];
    bool vis[MAXN + 5];
    
    inline int f(int i,int j){
    	if(i > j) return 0;
    	if (i == n && j == n) return 1;
    	return mod2(comb(n + n - i - j - 1, n - i - 1) - comb(n + n - i - j - 1, n - i + 1));
    }
    void solve_case() {
    	
    	cin >> n;
    	for (int i = 1; i <= n; ++i) {
    		cin >> a[i];
    		vis[i] = 0;
    	}
    	
    	int ans = 0;
    	for (int i = 1, mx = 0, pos = 1; i <= n; ++i) {
    		// 枚举从第 i 个位置起, 新序列大于原序列
    		mx = max(mx, a[i]);
    		add(ans, f(i - 1, mx + 1));
    		
    		// pos 是最小的没有填过的值
    		if (a[i] < mx && a[i] != pos)
    			break; // 已经不合法了!
    		vis[a[i]] = 1;
    		while (vis[pos]) ++pos;
    	}
    	cout << ans << endl;
    }
    
    int main() {
    	freopen("inverse.in","r",stdin);
    	freopen("inverse.out","w",stdout);
    	facinit(MAXN * 2);
    	
    	int T; cin >> T; while (T--) {
    		solve_case();
    	}
    	return 0;
    }
    
    一道类似的题目

    ARC068D Solitaire(加强版)

    原题题目链接

    该加强版见于六校联考,目前不公开,无法提交。

    题目大意

    给定正整数 (n),和一个初始为空的双端队列。将 (1,2,dots,n) 顺次插入该双端队列的任何一端。再以任意顺序从两端弹出数形成一个长为 (n) 的排列。对于一个排列,若存在一种操作方式得到它,则称它是好的

    现在有 (q) 次询问,每次给定 (n, m(1le mle n)),请求出长度为 (n) 且第 (m) 项为 (1)好的排列的个数,对 (998244853) 取模。

    数据范围:(1leq nle 3 imes 10^6)(1leq qle 5 imes 10^5)


    称通过把 (1dots n) 依次从两侧加入得到的排列为一个“双端队列”。发现一个排列是双端队列当且仅当其从开头到 (1) 递减,从 (1) 到结尾递增。

    按照题目的定义,一个好的排列,指它能够通过从一个双端队列两侧弹出数字得到。发现一个排列是好的,当且仅当它能被拆分为两个子序列 (A, B),且 (A + mathrm{reverse}(B)) 是一个双端队列。这里 (A) 就代表从左边弹出的数,(B) 就代表从右边弹出的数。

    不妨假设 (1) 是从左边弹出的。如果它是从右边弹出的,则把双端队列反转一下即可。换句话说,我们通过 (1) 被弹出的方向,来定义“左”和“右”。

    那么 (A) 应该先递减,减到 (1),然后递增;(B) 应该一直递减。且 (B) 里的所有数,应该都大于 (A) 中在 (1) 后面的数。

    我们先假设,(1)(A) 里的最后一个数。也就是结果序列的 (m + 1dots n) 位置全部划给 (B)。假设此时 (A), (B) 已经确定。然后枚举一个 (kin[0, n - m]), 把 (B) 里前 (k) 小的数还给 (A)。相当于本来 (B)(n - m)的数字,是按从大到小填在 (m + 1dots n) 这些位置上,现在我们要从中选出 (k) 个位置,把前 (k) 小的数从小到大填在这 (k) 个位置上,其他数从大到小填在剩下的位置上。这么做的方案数是:

    [sum_{k = 0}^{n - m}left({n - mchoose k} - {n - m - 1choose k - 1} ight) = sum_{k = 0}^{n - m - 1}{n - m - 1choose k} = 2^{n - m - 1} ]

    其中,({n - mchoose k}) 表示选出还给 (A) 的这 (k) 个位置。减去 ({n - m - 1choose k - 1}),是如果位置 (n) 出现在这 (k) 个位置当中,那么同样的排列在 (k - 1) 时已经被统计过了(也就是说,如果位置 (n) 恰好填第 (k) 小的数,则把它划分给 (A) 或划分给 (B) 都是合法的,所以这种排列会被计算两次,要减掉)。

    注:后来读了题解,发现一种更简单的理解方法。考虑 (1) 被从双端队列里弹出后,队列里剩余 (n - m) 个数。每个数都可以选择从左边弹出或从右边弹出,所以方案数是 (2^{n - m - 1})

    现在我们已经会处理后半部分了。接下来只需要考虑【(1)(A) 里的最后一个数】的划分方案,把这个方案乘以 (2^{n - m - 1}) 就是答案(注意特判 (m = n) 时不用乘)。问题转化为:求一个排列,满足它能被划分为两个单调减序列,且位置 (m) 上是 (1)

    定义一个排列是优美的,当且仅当它能被划分为两个单调减序列。根据 ( ext{Dilworth}) 定理,一个排列是优美的,当且仅当它不存在长度 (geq 3) 的上升子序列。

    对排列 (p),定义 (p^{-1}) 也是一个排列,满足 (p^{-1}_{p_i} = i),也就是把原排列里的“数值”和“位置”互换了。发现一个排列 (p) 是优美的,等价于 (p^{-1}) 是优美的。

    所以问题转化为,求位置 (1) 上是 (m) 的、不存在长度 (geq 3) 的上升子序列的,排列数量。

    这个问题几乎就是 NOI2018 冒泡排序,只不过把下降改成了上升。方法是一样的:通过 DP 和卡特兰数,可以推出,答案就是从 ((2, m)) 走到 ((n, 1))(每步只能向右或向下),且不碰到直线 (y = -x + n + 3) 的路径数,是 ({n - 2 + m - 1choose n - 2} - {n + m - 3choose n})。推导过程留给读者自行完成。

  • 相关阅读:
    C#调用Delphi的dll 详解
    C# 用API截取桌面屏幕
    C# 控件代码设置置顶和置底属性
    C#用API 获取电脑桌面背景图地址
    利用JS使IE浏览器默认打开是全屏显示
    aspx页面生成xml数据
    MacOS下安装Anaconda+Pycharm+TensorFlow+Keras
    GitHub编辑README
    Win10(64位)下安装Anaconda+Tensorflow(GPU)
    Win7(64位)下安装Anaconda+Tensorflow(CPU)
  • 原文地址:https://www.cnblogs.com/dysyn1314/p/14453129.html
Copyright © 2011-2022 走看看