ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从排列计数到多项式求解:容斥与生成函数的算法实践

从排列计数到多项式求解:容斥与生成函数的算法实践 1. 问题引入与核心思路拆解看到“Yet Another Permutation Problem”这个标题很多搞过组合数学或者算法竞赛的朋友可能会心一笑——这又是一个关于排列的计数问题。这类问题在LOJLibreOJ这样的在线评测系统里尤其是集训队作业中可以说是家常便饭。但别被这个看似普通的标题骗了后缀括号里的“容斥生成函数多项式”已经暗示了它的分量。这绝对不是一道让你手算几个小规模排列数就能解决的题目它考察的是如何将复杂的组合约束通过一系列经典的组合工具转化成一个可以高效计算的数学模型。我拿到这个题的第一反应是去理解它到底在问什么。通常这类“Yet Another”问题会在经典排列问题上增加一些新的、奇怪的限制条件。比如可能要求排列中某些位置必须大于其相邻位置即“峰”或“谷”或者要求某些数字对必须满足特定的大小关系。这道题的具体描述虽然输入中未给出但根据经验和关键词可以推断很可能涉及对排列中“逆序对”、“上升序列”或某种“模式”出现次数的限制。问题的核心挑战在于直接枚举所有排列并检查条件在n稍大时比如n1000是完全不可能的我们需要一个能在多项式时间内通常是O(n log n)或O(n^2)计算出答案的解析方法或算法。那么标题中给出的工具链“容斥 - 生成函数 - 多项式”就是解决此类问题的标准“组合套餐”。容斥原理是我们处理“至少”或“恰好”满足某些条件计数的利器它能把复杂的交集条件转化为更简单的补集运算。生成函数尤其是普通生成函数OGF和指数生成函数EGF则是将组合对象如排列和计数序列转化为代数对象的魔法它允许我们使用代数运算如乘法、求逆、复合来代替复杂的组合推理。最后多项式则是承载生成函数的容器问题的答案往往就是某个多项式的某一项系数而我们需要通过多项式运算如卷积、求逆、exp/ln来高效地计算出这个系数。接下来我会按照“理解约束 - 容斥转化 - 生成函数建模 - 多项式求解”这条主线一步步拆解这个问题的通用解法思路并分享在实现过程中容易踩坑的细节。即使你之前对生成函数有些发怵我相信通过这个具体问题的牵引你也能感受到这套组合工具的威力与美感。1.1 从排列约束到容斥框架第一步也是最关键的一步是把题目中那些关于排列的、难以直接处理的条件转化成一个可以用容斥原理处理的逻辑形式。我们假设题目要求排列p[1..n]满足k个限制条件每个条件可能形如“数字i不能出现在位置j”或者“数字a必须排在数字b之前”甚至是更复杂的“不存在长度为3的下降子序列”。直接计算满足所有条件的排列数非常困难。容斥原理提供了一个框架满足所有条件的排列数 总排列数 - 至少违反一个条件的排列数 至少违反两个条件的排列数 - ...。这里“违反条件”通常比“满足条件”更容易刻画。例如如果条件是“数字i不能出现在位置j”那么“违反”这个条件就意味着“数字i恰好出现在位置j”这实际上固定了一个位置剩下的部分就是一个(n-1)的排列很容易计算。因此我们的策略是定义一组“坏事件”A1, A2, ..., Am每个坏事件对应违反了某个或某组原始条件。利用容斥原理答案 Σ_{S ⊆ [m]} (-1)^{|S|} * (同时违反S中所有坏事件的排列数)。计算“同时违反S中所有坏事件”的排列数。如果这些坏事件涉及的约束是独立的比如固定了若干个互不相同的位置那么这个数就是(n - |S|)!。如果约束间有冲突比如要求同一个位置放两个不同的数那么计数为0。在实际问题中坏事件的设计需要技巧。有时我们需要对“违反条件”的程度进行更精细的划分比如不是简单地“违反”而是“违反至少t次”这可能会引入新的求和指标。这时生成函数就能很好地帮我们管理这些指标。1.2 生成函数将组合结构转化为代数方程当我们用容斥原理写出一个带多重求和的表达式后生成函数就能大显身手了。它的核心思想是把一个序列a_0, a_1, a_2, ...包装成一个形式幂级数A(x) Σ a_i * x^i。这里的x只是一个形式符号它的指数i通常代表我们关心的某个组合量如违反条件的次数、选取的元素个数等。为什么这样做有用因为幂级数的运算规则加法、乘法、复合恰好对应了组合结构的合并、拼接、选择等操作。例如乘法如果A(x)计数“完成任务甲”的方式数任务甲有i种方式则贡献a_i * x^iB(x)计数“完成任务乙”的方式数那么A(x) * B(x)的系数就计数了“先独立完成甲再独立完成乙”的所有方式数并且总“量”是两者之和。这在组合中称为卷积。指数生成函数EGF专门用于处理带标号对象的计数比如排列。排列的EGF是Σ_{n0} n! / n! * x^n Σ x^n 1/(1-x)。但更重要的是当我们用EGF表示一个“结构”时其乘法对应了标号集合的合并与重排这非常适合处理排列问题。在这道题中我们很可能会构造一个生成函数F(x)其中[x^k] F(x)即x^k的系数代表了与“违反条件”的某种度量相关的计数。通过容斥原理最终的答案可以表示为对这个生成函数进行一系列运算如求1 - F(x)的某种变换后提取x^n系数或代入x1等操作的结果。1.3 多项式科技从生成函数到可计算系数生成函数给出了一个优美的封闭形式但我们的最终目标是要一个具体的数字——通常是这个生成函数某一项的系数。这时就需要“多项式科技”了。我们通常只能在模一个质数如998244353,1000000007的意义下进行计算因为这些质数域存在原根支持快速数论变换NTT从而能在O(n log n)时间内完成多项式乘法、求逆、exp、ln等操作。整个解题过程可以看作一个多项式计算流水线建立模型根据容斥原理推导出答案的生成函数表达式。多项式表示将生成函数F(x)截断到n次因为高于n次的项对答案无贡献用长度为O(n)的多项式系数数组来表示它。多项式运算按照表达式对多项式进行一系列运算。常见的操作包括乘法/卷积对应独立事件的组合。求逆求解1 / (1 - F(x))这类形式。指数函数Exp当我们需要计算“将若干个不可区分的组件组合起来”时EGF的Exp会出现。对数函数Ln常与Exp配对出现用于解耦。提取系数从最终得到的多项式G(x)中取出x^n的系数或者根据题意可能是对系数进行求和即为答案。这个过程将组合推理完全转化为了代数计算使得我们能够处理规模很大的n例如n10^5这是暴力枚举或动态规划无法企及的。2. 一个具体模型带禁止位置的排列问题为了让大家更有体感我们不妨用一个经典的、与题目可能相关的模型来具体演练一下这套流程计算恰好有k个位置满足p_i i的排列数即恰好有k个不动点。这个问题被称为“错位排列”的推广。虽然原题可能更复杂但这个例子涵盖了容斥、生成函数和多项式的基本运用。2.1 容斥原理推导设A_i为事件“数字i放在位置i”即它是一个不动点违反了“错位”的要求。总排列数n!。至少有一个不动点的排列数Σ |A_i| C(n,1)*(n-1)!。至少有两个不动点的排列数Σ |A_i ∩ A_j| C(n,2)*(n-2)!其中ij。……至少有t个不动点的排列数C(n, t) * (n-t)!。根据容斥原理没有不动点的排列数即经典错排数D_n为D_n Σ_{t0}^{n} (-1)^t * C(n, t) * (n-t)! n! * Σ_{t0}^{n} (-1)^t / t!。而我们要求的是恰好有k个不动点的排列数。这可以理解为先选定k个位置作为不动点有C(n, k)种选法然后剩下的n-k个位置必须形成一个无不动点的排列即错排。因此答案Ans_k为Ans_k C(n, k) * D_{n-k} C(n, k) * (n-k)! * Σ_{t0}^{n-k} (-1)^t / t!。这个公式已经可以计算了。但如果我们想为所有k0..n一次性求出Ans_k或者n很大这个求和式的计算就显得有些孤立。这时生成函数可以提供更统一的视角。2.2 生成函数建模我们尝试构造一个生成函数使其x^k的系数与Ans_k相关。观察公式Ans_k C(n, k) * (n-k)! * Σ_{t0}^{n-k} (-1)^t / t!。 令a_m m! * Σ_{t0}^{m} (-1)^t / t!那么Ans_k C(n, k) * a_{n-k}。我们知道数列C(n, k)的生成函数是(1x)^n。而数列a_m的指数生成函数EGFA(x)是什么a_m m! * [x^m] ( Σ_{t0} (-1)^t / t! * x^t )不对因为求和上限是m不是无穷。这提示我们a_m其实是m!与错排数D_m的卷积让我们重新审视。实际上D_m m! * Σ_{t0}^{m} (-1)^t / t!。所以a_m D_m。 那么Ans_k C(n, k) * D_{n-k}。现在考虑二元生成函数F(x, y)我们希望[x^n y^k] F(x, y)正比于Ans_k。这不容易直接构造。更常见的做法是固定n为序列{Ans_k}构造一个普通生成函数OGF。对于固定的n定义G_n(z) Σ_{k0}^{n} Ans_k * z^k。 将Ans_k C(n, k) * D_{n-k}代入G_n(z) Σ_{k0}^{n} C(n, k) * D_{n-k} * z^k Σ_{j0}^{n} C(n, n-j) * D_j * z^{n-j}令j n-k Σ_{j0}^{n} C(n, j) * D_j * z^{n-j}。这个形式还不够漂亮。我们利用D_j的EGF。已知错排数的EGF是D(x) Σ_{j0} D_j / j! * x^j e^{-x} / (1-x)。 那么G_n(z)可以写成G_n(z) z^n * Σ_{j0}^{n} C(n, j) * (D_j / j!) * (j!) * z^{-j} 这样处理起来很麻烦。一个更聪明的办法是回到容斥的源头。定义生成函数H(t)其中[t^s] H(t)表示至少有s个不动点的排列数。根据容斥我们知道H(t) Σ_{s0}^{n} (至少s个不动点的排列数) * t^s。 而“至少s个不动点”的排列数等于先选s个位置作为不动点C(n, s)然后剩下n-s个位置任意排列(n-s)!。所以H(t) Σ_{s0}^{n} C(n, s) * (n-s)! * t^s Σ_{s0}^{n} n! / s! * t^s。那么恰好有k个不动点的排列数就是[t^k] H(t-1)。这是因为容斥原理在生成函数中的体现就是代入-1进行“符号交替”。我们可以验证H(t-1) Σ_{s0}^{n} n! / s! * (t-1)^s Σ_{s0}^{n} n! / s! * Σ_{k0}^{s} C(s, k) * t^k * (-1)^{s-k}交换求和顺序[t^k] H(t-1) Σ_{sk}^{n} n! / s! * C(s, k) * (-1)^{s-k} n! / k! * Σ_{sk}^{n} (-1)^{s-k} / (s-k)! n! / k! * Σ_{t0}^{n-k} (-1)^t / t! C(n, k) * (n-k)! * Σ_{t0}^{n-k} (-1)^t / t! Ans_k。完美所以我们得到了一个简洁的生成函数表达式Ans_k [t^k] Σ_{s0}^{n} n! / s! * (t-1)^s。2.3 多项式计算实现现在我们要计算G(t) Σ_{s0}^{n} n! / s! * (t-1)^s这个多项式然后取出它的各个系数。这完全是一个多项式运算问题。构造多项式令F(x) Σ_{s0}^{n} (n! / s!) * x^s。这是一个简单的多项式系数a_s n! / s!。多项式平移我们需要计算G(t) F(t-1)。这等价于计算F(x)在x t-1处的值。多项式平移可以通过卷积来实现。设F(x) Σ a_i x^i则F(xc) Σ a_i (xc)^i Σ a_i Σ_{j0}^{i} C(i,j) c^{i-j} x^j。交换求和顺序后这可以写成两个序列的卷积b_j a_j * j!和c_{i-j} c^{i-j} / (i-j)!的卷积结果再除以j!。这就是经典的多项式“泰勒平移”算法利用卷积和阶乘预处理可以在O(n log n)时间内完成。提取系数平移后得到的多项式G(t)的系数[t^k] G(t)就是我们要求的Ans_k。在实际代码实现中以模998244353为例步骤如下#include bits/stdc.h using namespace std; const int mod 998244353, G 3; // NTT模数与原根 // 此处省略NTT多项式乘法、求逆等模板代码... vectorint solve_fixed_point(int n) { // 1. 预处理阶乘和逆元 vectorint fac(n1), ifac(n1); fac[0] 1; for(int i1; in; i) fac[i] 1LL * fac[i-1] * i % mod; ifac[n] powmod(fac[n], mod-2); for(int in; i1; i--) ifac[i-1] 1LL * ifac[i] * i % mod; // 2. 构造多项式 F(x) Σ_{s0..n} (n! / s!) * x^s vectorint F(n1); for(int s0; sn; s) { F[s] 1LL * fac[n] * ifac[s] % mod; // n! / s! } // 3. 计算 G(t) F(t-1)使用多项式平移算法 // 多项式平移已知 F(x) Σ a_i x^i, 求 F(xc) 的各项系数 // 算法令 A[i] a_i * i! , B[j] c^j / j! // 计算 C A * B (卷积), 则 F(xc) 的系数 coeff_k C[k] / k! int c mod - 1; // c -1 vectorint A(n1), B(2*n1); for(int i0; in; i) A[i] 1LL * F[i] * fac[i] % mod; int pow_c 1; for(int j0; j2*n; j) { B[j] 1LL * pow_c * ifac[j] % mod; pow_c 1LL * pow_c * c % mod; } vectorint C multiply(A, B); // 多项式乘法使用NTT长度需处理 vectorint G(n1); for(int k0; kn; k) { G[k] 1LL * C[k] * ifac[k] % mod; } // G[k] 现在就是恰好有k个不动点的排列数 Ans_k return G; }注意这里的多项式平移算法是标准做法但需要注意卷积后数组的长度只取前n1项。multiply函数是封装好的NTT卷积。通过这个例子我们看到了如何将一个具体的排列计数问题恰好k个不动点通过容斥原理转化为生成函数表达式最终落地为多项式平移的计算。原题“LOJ3395”的约束条件肯定比“不动点”更复杂可能涉及逆序对、上升序列等但其核心方法论是相通的定义合适的“坏事件”应用容斥用生成函数整合容斥系数最后用多项式科技快速计算。3. 扩展到更一般的排列约束问题现在让我们把思路从具体的“不动点”问题抽离回到“Yet Another Permutation Problem”可能涵盖的更一般情形。常见的排列约束包括局部大小关系例如要求对于所有i有p_i p_{i1}上升或p_i p_{i1}下降在某些特定位置成立。模式避免要求排列中不出现特定的模式如3,1,2这种子序列。基于值的条件例如p_i必须是i的倍数或者|p_i - i|在一定范围内。对于这类问题一个强大的工具是多项式插值或动态规划与生成函数的结合。思路是先设计一个关于某个参数k的动态规划DP求出当参数为k时的答案f(k)。如果我们可以证明f(k)是一个关于k的、次数不超过n的多项式那么我们就可以通过计算f(0), f(1), ..., f(n)这n1个点值然后利用多项式插值拉格朗日插值求出f(k)的多项式表达式进而可以快速求出任意k对应的值。3.1 动态规划设计与多项式证明如何设计这样的DP考虑一个经典的例子计算有多少个n的排列其最长上升子序列LIS长度恰好为k。直接计算是困难的。但我们可以计算有多少个排列其LIS长度不超过k。记这个数为g(k)。那么答案就是g(k) - g(k-1)。如何计算g(k)这需要用到Robinson-Schensted correspondenceRSK对应它建立了排列与杨表Young Tableau之间的一一对应。而一个排列的LIS长度等于其对应的杨表的第一行的长度。标准杨表的形状是一个整数分拆λ ⊢ n且第一行长度不超过k的杨表数量可以由钩子公式Hook-length Formula给出。对所有满足λ_1 k的分拆求和可以得到g(k)。可以证明g(k)是关于k的多项式函数在k n时是常数n!。另一个常见的DP状态设计是按值域从小到大插入数字。设dp[i][j]表示考虑了1..i这些数字构成的排列实际上是{1..i}的一个排列满足某种约束的状态为j时的方案数。当插入下一个数字i1时我们考虑它可以插入的位置并分析状态j如何转移。如果状态j的定义是“当前有多少个违反条件的位置”或者“当前已经形成的某种模式的数量”那么最终我们关心的f(k) Σ_{j} dp[n][j]其中求和可能只针对特定的j如jk也可能需要容斥求和。证明f(k)是k的多项式通常需要分析DP转移方程。如果转移方程中只包含k的加减、乘法而不包含除法在模意义下除法可能对应乘法逆元但多项式性可能被破坏并且循环次数固定那么f(k)很可能是一个多项式。一个实用的判断是如果我们可以将DP过程看作是在一个固定大小的图上进行固定步数的游走每条边的权重是k的多项式那么最终的结果就是k的多项式。3.2 多项式插值计算一旦我们确定f(k)是次数不超过D的多项式通常D O(n)并且我们有能力在O(D)或O(D log D)时间内计算出f(0), f(1), ..., f(D)这D1个点值那么我们就可以通过插值得到这个多项式。拉格朗日插值公式f(x) Σ_{i0}^{D} f(i) * Π_{j≠i} (x - j) / (i - j)。直接计算的复杂度是O(D^2)。但我们可以优化到O(D log D)预处理所有(x - j)的乘积P(x) Π_{j0}^{D} (x - j)。对于每个i计算P(i) Π_{j≠i} (i - j)。这可以通过预处理阶乘得到P(i) (i)! * (-1)^{D-i} * (D-i)!。那么f(x) P(x) * Σ_{i0}^{D} f(i) / ( (x-i) * P(i) )。如果我们只需要求一个点值x K那么用这个公式计算是O(D)的。如果我们需要恢复整个多项式系数就需要用到多项式多点求值、快速插值等更高级的算法复杂度O(D log^2 D)。在实际解题如LOJ3395时我们可能不需要显式求出整个多项式而是直接利用插值公式计算题目所要求的特定k对应的f(k)。3.3 综合应用一道模拟赛题的思路假设有这样一道题求n的排列中满足“不存在长度大于等于k的连续下降子串”的排列数量。例如对于n4, k3排列[3,2,1,4]是无效的因为它有连续下降子串[3,2,1]长度为3。我们可以这样思考容斥用总排列数n!减去存在至少一个长度k的连续下降子串的排列数。但“至少一个”不好直接算因为子串可能重叠。转化考虑排列的“下降块”划分。将一个排列划分成若干个极长的连续下降子串。例如[4,3,1,2,5]可以划分成[4,3,1]和[2,5]。要求每个下降块的长度都 k。生成函数一个长度为L的下降块其内部只有一种排列方式严格递减。但是这些块在排列中是有顺序的并且块与块之间前一个块的最后一个元素与后一个块的第一个元素构成一个上升关系。这有点像将n个元素划分成一些有序的“盒子”每个盒子大小k盒子内部强制递减。DP与多项式设dp[i]表示i个元素组成若干下降块每个块长度k的方案数。转移时枚举最后一个块的长度j (1jk)则dp[i] dp[i-j]因为最后一个块确定后前面的i-j个元素任意排。初始dp[0]1。我们要求的是dp[n]。但注意这dp[n]计算的是“将n个元素分成若干下降块”的方案数每个方案对应一个唯一的排列吗是的因为块内递减顺序固定块间顺序就是这些块在排列中的出现顺序。但这里我们忽略了块间元素的相对大小实际上当我们用1..i的数字时dp[i]计数的是满足块结构的所有排列数。可以证明这个DP是正确的。生成函数优化这个DP的生成函数D(x) Σ dp[i] x^i满足D(x) 1 (x x^2 ... x^{k-1}) * D(x)。所以D(x) 1 / (1 - (x x^2 ... x^{k-1}))。那么dp[n] [x^n] 1/(1 - (xx^2...x^{k-1}))。这个系数可以用多项式求逆在O(n log n)时间内得到。你看通过将问题重新解释为“下降块划分”我们绕开了复杂的容斥直接得到了一个简洁的生成函数。这展示了组合视角转化的重要性。原题LOJ3395很可能也需要类似的、更巧妙的组合解释。4. 实战中的常见陷阱与优化技巧即使思路正确在实现这类涉及容斥、生成函数和多项式的问题时也有很多细节容易出错。下面分享一些我踩过坑后总结的经验。4.1 容斥系数与符号处理这是最容易出错的地方之一。容斥原理的符号是(-1)^{|S|}其中|S|是违反条件的集合大小。但在生成函数中我们可能会将违反条件的“次数”作为指数。例如在“不动点”问题中我们构造了H(t) Σ_{s} (至少s个不动点的方案数) * t^s然后通过H(t-1)得到“恰好”的计数。这里(t-1)^s展开后t^k项的系数天然就包含了C(s, k) * (-1)^{s-k}这个容斥系数。关键检查点当你的生成函数代表“至少”或“钦定”某种情况时代入-1或更一般的使用(1-z)的幂往往能实现“恰好”的转换。务必用小数据 (n1,2,3) 手动验证你的生成函数表达式。计算几个系数看看是否符合暴力枚举的结果。4.2 多项式运算的边界与长度进行多项式乘法、求逆、exp/ln时必须明确你需要保留的次数。例如在计算[x^n] 1/(1 - A(x))时如果A(x)的次数是m那么1/(1-A(x))的x^n项系数可能依赖于A(x)的更高次项吗不会因为这是一个无限级数但在模x^{n1}的意义下计算就足够了。所以我们通常在模x^{M}的意义下进行多项式运算其中M是所需的最大次数加一。常见错误多项式乘法后结果数组长度应该是len(A)len(B)-1但如果你只关心前n项可以只保留前n1项以节省计算量和内存。多项式求逆、exp、ln等操作要求初始多项式的常数项满足特定条件如求逆要求常数项非零exp要求常数项为0。在执行这些操作前务必检查。4.3 模运算下的除法与阶乘组合计数通常涉及大量的阶乘n!和组合数C(n, k)。在模质数P下我们需要预处理阶乘fac[i]和阶乘的逆元ifac[i]。这样C(n, k) fac[n] * ifac[k] % P * ifac[n-k] % P。注意事项预处理数组的长度至少要开到n的最大可能值通常还会多开一点如n5以防越界。当公式中出现除法时如1/s!在模运算下必须乘以s!的乘法逆元。永远不要直接做除法。4.4 时间复杂度的分析与优化这类问题的典型时间复杂度是O(n log n)或O(n log^2 n)取决于多项式运算的复杂度。NTT的卷积是O(n log n)。多项式求逆、exp、ln可以通过牛顿迭代法在O(n log n)内完成。优化点减少多项式运算次数有时表达式可以化简。例如exp(A(x)) * exp(B(x)) exp(A(x)B(x))先做加法再做一次exp比做两次exp再卷积要快。利用对称性如果生成函数是偶数函数或奇数函数可能可以简化计算。分治NTT如果DP转移是dp[i] Σ_{j i} dp[j] * a[i-j]这种形式且a已知那么这就是一个标准的卷积可以用NTT一次算出。如果转移方程更复杂可能需要分治NTT将复杂度从O(n^2)降为O(n log^2 n)。4.5 调试与对拍这类题目调试起来比较困难因为中间结果多项式系数往往很长。我的调试策略是小数据暴力写一个O(n! * n)的暴力程序枚举所有排列并检查条件计算n8时的答案。用这个来验证你的多项式程序在小数据下的结果。中间输出将关键步骤的多项式前几项系数打印出来与手算或另一种思路的计算结果对比。例如计算“至少s个不动点”的生成函数H(t)你可以手动计算n4时H(t)的系数与程序输出对比。对拍如果题目有部分分比如n20用暴力程序跑部分分用多项式程序跑全部数据在交叉部分n20验证答案是否一致。静态查错仔细检查容斥的符号、生成函数中变量的意义是指数还是普通系数、多项式运算的长度、模运算的正确性。处理这类问题就像在搭建一个精密的仪器。容斥原理是设计图生成函数是传动系统多项式算法是加工工具。任何一个环节的微小误差都可能导致最终结果谬以千里。但一旦你成功搭建起来看到对于n100000的规模也能在秒级内计算出答案那种成就感是无与伦比的。它让你真正体会到组合数学不仅仅是巧妙的计数更是可以将复杂问题转化为可计算模型的强大数学框架。
返回列表