引入
如果说数论是数学体系中专门用来研究数字性质的一个分支,那么初等数论则是对整数的性质进行系统性的探讨与研究。千万不要因为其中的“初等”二字小瞧这初等数论尽管名称和学习难度上都没有高等数论那么有逼格,就像初等数学之于高数,数论的所有内容均筑基于此。其中欧几里得证明的算数基本定理(一切合数都可被分解为有限个质数的乘积)在质数筛、GCD(以及LCA)计算、无理数证明等问题上均有用武之地。可以说高等数论奠基于初等数论。它同时也是初学者接触数论的必经之路。
Part1. 前置知识
Div1. 数论有关定理
算术基本定理 :每一个合数都可以被分解为有限个质数的乘积。即对于任意合数 ,都存在:
,其中 为质数。
推论一 :正整数 的正因数集合为:
推论二 :正整数 的正因数个数为:
推论三 :正整数 的所有正因数之和为
质数分布定理 :区间 中,当 时,质数个数 。
费马小定理: 若 是一个质数,则 ( 为同余符号)。
欧拉定理(费马小定理扩展): 若 ( 与 互质),则有 ,其中 为欧拉函数。
Div2. 同余
同余 ,顾名思义,两个数分别除以一个正整数 后得到相同的余数。即 。但是它的定义给出了这样一句话:“对于正整数 和 ,若 ( 能被 整除),则称 和 对模 同余,记作 ” 。当然以上两种说法是等价的。
同余具有以下三种基本性质:
反身性:对于任何正整数 ,
对称性:即对于 ,有
传递性:若 ,且 ,有
当然,它可以延申到计算机的模运算(毕竟出现了 )。模运算有三种基本运算:
加运算:
减运算:
乘运算:
还有两个推论:
幂运算:
求和运算:
同余消去原则:
若同余号两端的项相等,且都与模 互质,则可以同时消去
举例: ,如果 ,则 。
Part2. 质数
质数的判断:除了它自身 以及 以外,不存在其他正整数 使得 。
Div1. 质数判断
试除法:
这是三种方法中,唯一一种能做到100%正确的质数判断方法 。对于给定数 ,遍历所有 间的正整数 ,若出现 则证明它不是质数,因为质数只能被1以及它本身整除 。
1 2 3 4 5 6 bool isPrime (int n) { for (int i = 2 ; i * i <= n; i++) { if (n % i == 0 ) return false ; } return true ; }
复杂度:
适用范围:普及、提高
试除法の大胜利!
费马素性检验:
是上述费马小定理的实际运用,它与常规算法思想有所不同:它主张在 中随机选取一个数 。若出现与费马小定理不符的情况,那么 一定为合数;若每次均符合定理,称为
费马伪素数 ,因为它很大概率 是一个质数。
1 2 3 4 5 6 7 8 9 10 11 bool isPrime (int n) { if (n <= 2 ) return false ; int k = 10 ; while (k--) { srand (time (0 )); int a = rand () % (n - 2 ) + 2 ; if (__gcd(a, n) != 1 ) return false ; if (qpow (a, n - 1 , n) != 1 ) return false ; } return true ; }
复杂度: ,其中 为随机数检验次数, 是因为使用了快速幂算法。
适用范围:提高T2及以下(慎用)
为什么用该方法判断的质数 大概率
是个质数呢?不妨测试一下561
(3)、1105
(5)、1729
(7)(括号内为它的最小因子),你会发现函数的返回值均为true
,即都为质数。可见这个算法不是100%正确的,这些“漏网之鱼”被称为“Carmichael数”。它们极其罕见,一亿范围内仅255个。也因如此,你可以通过打表特判的方式抠掉这些特例(你保证记得住就行)。2016年中国物流工人余建春给出了一个Carmichael数的判断准则,这个标准目前在国际上得到了广泛认同。
对于优化,你可以在函数起始点加入类似于if (n % 2 == 0 || n % 3 == 0) return false;
的特判,进一步降低复杂度。
Miller-Rabin算法:
该算法同样无法保证结果100%准确,慎用!
MB算法实质上是对费马素性检验算法的效率和准确度优化。算法流程如下:
将 分解为 的形式,其中 为奇数
从 中选取整数 ,称为“基数”
计算 的值,若结果为 或 ,则可能为质数,继续检验
若结果不等于 或 ,计算 、 、 …… 的值,若结果等于 ,则可能为质数,继续检验
若都不等于 ,则 一定是合数。称为强费马证据 。
当然,它同样有特例,称为强伪质数 ,如2047
(23)、3277
(29)、4033
(39)等(括号内为它的最小因子)。
Div2. 质数筛
常见的质数筛法有:试除法、埃氏筛、线性筛。
试除法 :从质数定义出发,即存在一个正整数 ,对于任意 间的正整数 ,总有 成立。代码实现只需枚举 间所有正整数,并让 对其取余。若取模运算出现 则代表它不为质数,没有出现 则为质数。
1 2 3 4 5 6 bool isPrime (int n) { for (int i = 2 ; i * i <= n; i++) { if (n % i == 0 ) return false ; } return true ; }
复杂度:
适用范围:普及T2及以下
这里所展示的试除法代码实际上经过一轮优化。若严格根据质数定义,第二行的循环上限应为 。考虑到如下性质: ,若 ,则一定有 。因此可以将循环上限压缩至 。
埃氏筛 :全称叫埃拉托斯特尼筛法 ,老哥生活在2200年前的古希腊,不借助望远镜就计算出了地球的周长(与真实值偏差仅0.96%)、同时他也是第一位根据经纬线绘制出世界地图的人、也是最先提出将地球根据南北回归线分为“五带”的大人物。他提出的筛法核心思想如下:
第一步:列出从2开始的一列连续数字;第二步:选出第一个质数(本例中为2),将该质数标记,将数列中它的的所有倍数划去;第三步:若数列中的末项小于它前一项的平方,则质数已全部筛出;否则返回第二步 。
1 2 3 4 5 6 7 8 void get (int n) { for (int i = 2 ; i <= n; i++) { if (!vis[i]) { prime[cnt++] = i; } for (int j = 2 ; i * j <= n; j ++) vis[i * j] = true ; } }
其中,prime
数组存储质数,vis
数组用于标记(即上文中“划去数字”),变量cnt
则存储 中质数的个数。
复杂度:
适用范围:普及T2及以下
但是继续观察算法发现:我们其实无需将所有 的倍数删去,只需删去前一步得出的质数的所有倍数即可。
这与前文介绍的埃氏法核心相符。因此将 循环迁移至条件判断中即可:
1 2 3 4 5 6 7 8 void get (int n) { for (int i = 2 ; i <= n; i++) { if (!vis[i]) { prime[cnt++] = i; for (int j = i + i; j <= n; j += i) vis[j] = true ; } } }
优化复杂度:
适用范围:普及T3及以下
线性筛/欧拉筛 :实质是埃氏筛的线性优化。因为在埃氏筛中,有些数字被重复筛了多次(例如30会被2、3、5筛到)。本着线性优化的原则,我们需要找到一个方法,使得每个合数仅被筛选一次。主要思想如下:
我们发现,线性筛和埃氏筛均使用了质数的 倍为合数 的结论。我们只需要保证每一个数仅被它自身的最小质因数筛出即可。即对于数字 , 是一个合数,且 只会被 筛出。
1 2 3 4 5 6 7 8 9 void get (int n) { for (int i = 2 ; i <= n; i++) { if (!vis[i]) prime[cnt++] = j; for (int j = 1 ; prime[j] <= n / i; j++) { vis[prime[j] * i] = true ; if (i % prime[j] == 0 ) break ; } } }
复杂度:
适用范围:普及、提高
例题:
P5736
【深基7.例2】质数筛
P5723
【深基4.例13】质数口袋
Part3. 因数
因数定义: 对于一个数 ,若存在一个正整数 使得 ,则称 是 的因数。
Div1. 因数分解法
试除法: 万能暴力解法。即遍历 间的所有数 ,若可以整除 ,则 和 均为 的因数。特殊情况: 为整数时,因数仅有 本身,因此需特判。
1 2 3 4 5 6 7 8 9 10 11 12 vector<int > get (int n) { vector<int > ret; for (int i = 2 ; i * i <= n; i++) { if (n % i == 0 ) { ret.push_back (i); ret.push_back (n / i); } if (n % (i * i) == 0 ) ret.pop_back (); } sort (ret.begin (), ret.end ()); return ret; }
复杂度:
适用范围:普及T1
Div2.最大公约数
辗转相除法:
又是我们大名鼎鼎的欧几里得老先生提出的一套公约数算法,整个算极其简洁:核心只有一行,即:
两个数的最大公约数等于其中较小的数字和二者之间余数的最大公约数
可以写出:
1 2 3 int gcd (int a, int b) { return b ? gcd (a, a % b) : a; }
但是为什么 呢?我们可以通过以下方法证明:
假设如下关系: 。其中被除数 ,除数 ,商 ,余数 。则 。
首先证明充分性:令 、 ,即二者有相同因子 。
代入初始除法算式得:
接着由于加减乘法的封闭性,即一个整数进行加减乘运算得到的结果同样是一个整数。可以得出: 。即 ( )与 有共同因子。
接下来证必要性。令 、
Stein算法:
上一个方法的明显缺点在于,它处理大质数的效率并不好(但总体来说是很好的),因为它使用了取余运算,这会减慢一些速度。可以理解,生在2000多年前——一个没有电脑和OI的古希腊社会,这个算法已经足够兼顾常规效率和手推难度了。但是步入21世纪,加快的生活节奏毒瘤数据使得人们对更快算法的需求空前高涨。Stein算法便应运而生。
算法流程如下:
任意给定两个正整数,先判断它们是否都是偶数,若是,则用2约简,若不是,则执行第二步。
若两数是一奇一偶,则偶数除以2,直至两数都成为奇数。再以较大的数减较小的数,接着取所得的差与较小的数,若两数一奇一偶,仍然偶数除以2,直至两数都成为奇数。再次以大数减小数。不断重复这个操作,直到所得的减数和差相等为止。
两数相等时,第一步中约掉的若干个2与第二步中最终的等数的乘积就是所求的最大公约数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 int gcd (int a, int b) { int p = 0 , t; if (!(1 & a) && !(1 & b)) { a >>= 1 ; b >>= 1 ; p++; } while (!(1 & a)) a >>= 1 ; while (!(1 & b)) b >>= 1 ; if (a < b) { t = a; a = b; b = t; } while (a = ((a - b) >> 1 )) { while (!(1 & a)) a >>= 1 ; if (a < b) { t = a; a = b; b = t; } } return b << p; }
这个算法的优点在于:它大大优化了大质数的运算。但可惜的是,它的代码量膨胀了8倍,因此不太建议赛时使用。毕竟C++都给你内置了__gcd()
函数嘛,干嘛不偷个懒?
Div3. 最小公倍数
我们可以简单概括成一句话:
两个数的最小公倍数等于这两个数的乘积与这两个数最大公约数的商
即:
凭啥呀?
我们假设两个数 和 有最大公约数 ,则 ,且 。并且 和 一定互质(若不互质, 和 的最大公约数就不会是 ,而是一个比 大的值)。
由乘法交换律,可知:
消去 得: 。因为 、 互质,所以 或者 即为两个数的最小公倍数。得证。
例题:
P1075 [NOIP2012
普及组] 质因数分解
P2424 约数和
(需要逆向思维)
Part4. 欧拉函数相关
Div1. 欧拉函数推导
问:论牧师欧拉有多么的高产
答:平均每年800页数学论文你说高不高产嘛
欧拉函数 ,记作 。表示 中与 互质的数的个数,即 ,满足 的 的总个数即为 的值。举个例子, ,因为在 中, 和 均与 互质。特殊地, 。
欧拉函数有如下计算公式:若 可被表示为 (算术基本定理分解式)的形式,则 。
推导思想即为用 减去所有 中所有与 不互质的数 。在计算机上实现,首先需要分解质因数。思路如下:首先抛出第一个质因数 ,那么将 中所有的 的倍数删去,因而可以保证筛出的数一定是 的质因子,否则他们将存在最大公约数 。那么能被 整除的数的个数(也就是 以内 的倍数个数)为 ——其中的中括号代表整除。
因此我们离解出欧拉函数就进了一步了,我们的过渡式子就是 。
好耶
别急着好耶,我们可以发现一个小小的推导谬误(可能并不是很容易发现)。当我们用 去筛数时,使用的算式仍然是 。对于形如 的数,会被重复筛去多次,导致多减,最终结果会小于 。有些抽象,我们来看这张图:
容斥原理
易知 中可被 整除的数字共有 个,能被 整除的数字共有 个。但是如果说能被 和 整除的数字共有 个,显然不合常理,因为 、 、 都既能被 整除,也能被 整除,如果不加排除,他们将会被减去2次 。因此需要补偿损失,正确的计算方法是(仅计算能被 和 整除的数的总个数):
能被 整除的: 个
能被 整除的: 个
同时被 和 整除的: 个
总个数: 个(就是被绿圈和橙圈捆住的的数的个数)
那么对于 、 、 整除问题,中间的 被重复加了3次,需减去两次平衡收支。此即容斥原理的简单思想表示。
回到欧拉函数推导上来:过渡公式 中的容斥问题可以解决一部分了。对于可同时被两个不同质数整除的数(例如 、 ),我们加上它的总个数。
得到 。
当然这又有一个小问题没完没了了是不是?:对于 的公倍数,会被先减去3次,然后被上一步的操作加上3次,总体不加不减 。还是回到上图:中间的 会被每个颜色的圈先减去一次、共3次,上一步的补偿操作,可以看作又被橙绿圈(橙圈和绿圈的交集)、蓝绿圈、蓝橙圈一共加上了3次。减3次加3次相当于没动,为了让它被算上,我们需要加上它,对于 则是全部减去(因为括号外有减号需要变号 ,不要忘记 是由一系列不合规的数字个数相减 得来的)。得到我们的过渡态3:
又是如上的容斥判断,这里我们省去讨论。将最终的产物合并得到: !(我不会合并,但是你可以把 括号拆开看看是不是上述形式。总之,欧拉牛逼!)
,好耶!终于可以好耶了……
Div2. 欧拉函数代码实现
主要是如果压成一个Div会非常的长,因此这里新开一个Div2
我们明确了欧拉函数的推导,接下来就是整理思路写代码的时间了!我们也只需跟着原始思路走就可以了。再次回忆一下:首先我们需要筛出质因数,除去它的所有倍数,再用公式 代入 就可以了。
1 2 3 4 5 6 7 8 9 10 11 long long eular (int n) { long long res = n; for (int i = 2 ; i * i <= n; i++) { if (n % i == 0 ) { res = res * (i - 1 ) / i; while (n % i == 0 ) n /= i; } } if (n > 1 ) res = res * (n - 1 ) / n; return res; }
时间复杂度:
适用范围:All Clear
Div3. 欧拉函数推论
若 为质数,则
让我们回到欧拉函数的定义上去: 是 中与 互质的数的个数(特殊地, )。那么对于 这个质数,有多少数与它互质呢?
很显然,答案是 个!因为 的质因子只有 本身,若不止 一个质因子,很显然它不是一个质数。因此 。
假设 是一个质数, (或 ),且 的值已知,那么
凭啥呀?
因为 已经是一个质数,换句话说:在这个条件下 是 的一个质因子。在计算 时, 就已经作为一个质因子以 的形式乘进去了。此时 可以写作
那么函数值多乘了一个
我们发现:(2)式中包含了(1)式,只是头上乘以了 。因而得到
假设 是一个质数, (或 ),且 的值已知,那么
这东西长得和性质2很相似,唯一不同的是 不再是 的一个质因子了。但是 变成了 的质因子。因此我们计算 的值时,不仅需要在头部乘上 ,而且还需要将 乘进去:
因为 ,所以得到性质3,即 。
这三个性质将作为重点性质出现在欧拉函数筛法中。
Div4. 欧拉函数线性筛
我们已经接触了简单的欧拉函数计算方法,那么又该如何解决形如:“给定一个正整数 ,求 的值”的问题呢?
考虑继续使用上面的朴素算法,时间复杂度将会是 。明显无法满足需求,更何况,每个数与每个数之间的 值之间有一种推导关系,使得我们无需每次重新计算 值,而是用已经求出的 来线性推出 的值。
欧拉函数涉及到质因子的拆分,我们又需要在线性时间内求各种质数。自然而然想到了先前所学的线性筛:
1 2 3 4 5 6 7 8 9 10 11 12 13 int primes[N];bool st[N];int cnt = 0 ;void sieve (int n) { for (int i = 2 ; i <= n; i++) { if (!st[i]) primes[++cnt] = i; for (int j = 1 ; primes[j] * i <= n; j++) { st[primes[j] * i] = true ; if (i % primes[j] == 0 ) break ; } } }
我们运用这段代码可以得出 范围内所有的质数,用st[N]
数组可以筛出所有的合数。也就是说对于筛出的合数,我们能够得知组成它的质因子是什么,比如st[primes[j]*i]=true;
这一行代码。接着套用上述三种性质,我们可以得出 值。
此时我们就需要新建一个phi[N]
数组来存储每个数的 值,并且在代码中三个地方加入对于三种性质的公式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 typedef long long ll;ll primes[N]; bool st[N];int cnt = 0 ;ll phi[N]; void phi_sieve (int n) { for (int i = 2 ; i <= n; i++) { if (!st[i]) { primes[++cnt] = i; phi[i] = i - 1 ; } for (int j = 1 ; primes[j] * i <= n; j++) { st[primes[j] * i] = true ; if (i % primes[j] == 0 ) { phi[primes[j] * i] = phi[i] * primes[j]; break ; } phi[primes[j] * i] = phi[i] * (primes[j] - 1 ); } } }
没错我开了long long
防止爆int
时间复杂度:
适用范围:普及&提高
对于开头提出的求和问题,遍历phi[1]
到phi[n]
的所有值求和即可。
Div5. 欧拉定理
若正整数 与 互质,则有
对于它的证明,百度百科 中如此写到:
取 的缩系 ,故 也为 的缩系。有
通俗来讲就是这样:
在 中取所有与 互质的数 ,很容易知道这样的 共有 个(根据欧拉函数定义得来)。它们都与 互质。
给这列数同时乘上 ,得到 。它们也都和 互质,并且各不相同。
提出括号里乘了 次的 ,得到以下关系式:
那么根据同余号两端的消去原则(左右两端两个项相同且与模 互质),可以消去 。得到 ,欧拉定理得证。
特殊地,如果 是一个质数,有 。这被称作费马小定理(先前的费马素性检验就是基于这个原理编写的)。
真不知道明明可以写得通俗点为什么非得省那点空间写看起来那么高深莫测的专业术语,真的是只写给自己看的。
Div6. 降幂算法
尤其对于绿题以上的题目,题面中可能出现“答案可能很大,请对大质数 取余 ”的字样。这意味着题目可能涉及到大规模的幂运算,需要我们用简便的方法计算幂。对于一般的题目,我们使用快速幂。
快速幂 :快速幂思想如下:
我们将指数 分解为若干 的和(二进制表示),例如: ,因为 。因而不必将 连续乘11次,效率大幅提升。
1 2 3 4 5 6 7 8 9 10 11 typedef long long ll;int qpow (int a, int k, int p) { int res = 1 ; while (k) { if (k & 1 ) res = (ll) res * a % p; a = a * a % p; k >>= 1 ; } return res; }
时间复杂度:
适用范围:基本All Clear
欧拉降幂 :上面方法一个缺点在于无法处理过大的指数,在处理类似于 的计算时将会疯狂掉san。接下来介绍一种使用上面讲到的欧拉定理来解决大指数幂运算的方法。
欧拉降幂核心公式: (又称
扩展欧拉定理 )
也就是说:我们只需要算出 的值,再用快速幂算法,将 作为新指数带入计算即可。当然,这里的 可能会爆long long
,因此可以选择使用字符串进行高精度计算。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 typedef long long ll;ll primes[N]; bool st[N];int cnt = 0 ;int qpow (int a, int k, int p) { int res = 1 ; while (k) { if (k & 1 ) res = (ll) res * a % p; a = a * a % p; k >>= 1 ; } return res; } ll eular (int n) { ll res = 0 ; for (int i = 2 ; i <= n; i++) { if (!st[i]) { primes[++cnt] = i; res = res * (i - 1 ) / i; while (n % i == 0 ) n /= i; } for (int j = 1 ; i * primes[j] <= n; j++) { st[i * primes[j]] = true ; if (i % primes[j] == 0 ) break ; } } if (n > 1 ) res = res * (n - 1 ) / n; return res; } int edp (int a, string k, int p) { ll phi = eular (p); int drop = 0 ; for (int i = 0 ; i < k.length (); i++) { drop *= 10 ; drop += k[i] % phi; } drop += phi; return qpow (a, drop, p); }
其中eular(int n)
函数用于计算欧拉函数的值、edp(int a, string k, int p)
用于计算降幂后的指数、qpow(int a, int k, int p)
是快速幂算法。
时间复杂度:
适用范围:所有
扩展欧拉定理可谓是欧拉定理的一般形式,它的定义如下:对于任意正整数 、 、 ,满足:
$a^k
$
其中第二个式子就是欧拉降幂的核心公式。
扩展欧拉定理的证明见这里 。因为太复杂了我不会证
例题:
P2158 [SDOI2008]
仪仗队 (欧拉函数板子)
P1447 [NOI2010]
能量采集 (上一个问题的变式)
P1226 [模板]
快速幂
P5091 [模板]
扩展欧拉定理 (欧拉降幂)
P4139
上帝与集合的正确用法 (欧拉降幂+递归)
Part5. 同余方程的解法
这里会涉及到一元线性同余方程,一元线性同余方程组和高次同余方程的算法解法。
Div.1 裴蜀定理
很多人会把他读成裴除(chú)(比如我的某位好友),这个名词正确的读法是裴蜀(shǔ) 。或者可以直接改称作“贝祖定理”,它的提出者艾蒂安·裴蜀估计怎么也没想到后人居然连他的名字都读不对(想想如果这种事情发生到你身上会怎么样)。你也可以读他名字的法语发音 (显得你很优雅且有文化)。
切入正题,裴蜀定理表述为:对于任意正整数 , ,总有整数 、 ,使得 ,其中 等价于 ,是数论中最大公约数的表述方式。
首先可以知道 且 ,因为 和 都具有约数 ,让他们分别乘上另两个数 和 并不会改变 这一约数。所以假设 ,有 。在这里, 。
Div2. 扩展欧几里得(EXGCD)
加了“扩展”二字是不是感觉逼格上来了?
扩展欧几里得算法用于求出线性同余方程的解。线性同余方程,即形如 的方程,我们需要求出 的值。
回忆一下欧几里得算法的核心思路: 。
再看看刚刚讲到的裴蜀定理,发现 。根据余数的定义,有: 。
那么我们的任务就是求出这里的 和 值,因此拆开括号,整理出 和 的系数: 。观察裴蜀定理的形式: ,我们得出的式子中, 变成了 。因此每次递归时需要将 的值减去 。
既然我们设计的是一个递归算法,我们就必须明确它的递归出口。根据欧几里得算法,当 时, 。我们把 和 代入发现: ,得到 ,此时 可取任意整数值, 。这里我所取的解是 。
最后,因为这本质上还是一个欧几里得算法,所以返回 是有必要的(事实上exgcd算法返回的 将作为推导式中的 参与运算)。我们可以写出如下函数。
1 2 3 4 5 6 7 8 9 10 int exgcd (int a, int b, int &x, int &y) { if (!b) { x = 1 ; y = 0 ; return a; } int d = exgcd (b, a % b, y, x); y -= a / b * x; return d; }
时间复杂度:
但是,题目中一般不会给出裴蜀定理那样的形式,而是形如 的形式,让你求出 的值,并且上述方法仅求出了一元线性同余方程的一组特解,如果题目中让你求出最小正整数解 呢?接下来就是解决上述问题的方法:
1. 同余—等式互转 (自己起的名字):
在上面的介绍中,我们遇到了一个问题:如何将 这样的同余式变为 这样的二元一次不定方程的裴蜀定理形式呢?
考虑到同余方程的定义(或者你可以把以下关系死记住),得到 。接着由余数定义,得到 ,移项得到: 。提出负号,令 ,则 。它有解的充要条件是 。经过如上变换后就变成了裴蜀定理的形式,可以直接用exgcd
求解 和 。
2. 最值解问题 :
二元一次不定方程通解的证明
Div3. 中国剩余定理(CRT)
又称孙子定理(但我认为还是中国剩余定理听起来更有实力一些),最早见于《孙子算经》中“物不知数”问题,首次提出了有关一元线性同余方程的问题与解法。
对于一元线性同余方程: ,可以构造以下方法求出通解。
首先,令 。
然后,令 ,即除了 外所有 的乘积。
接着,令 为 在模 意义下的逆元,即 。
所以, 的通解为: 。
Div4. Baby Step Giant
Step算法(BSGS)
这个算法用于解决一元高次同余方程问题,模意义下的对数也可以求。又称“北上广深算法”(想出这种名字的人真是人才)。
高次同余方程长成这个样子:
发现 跑到了指数上边真是变态呢。这种问题显然没公式解,于是苦恼的人们只得选择一条略显暴力的求解道路,即搜索 。严格来说,BSGS所使用的是双搜索,其中的一个变量的搜索步长会长于另一个变量的搜索步长,因而得名“大步小步算法”。或者叫北上广深/拔山盖世算法!
朴素BSGS ( 与 互质):不妨令 ,原式为 。根据消去原则,两边同乘 得 。
接下来我们对同余号右侧的部分求值,再任命一个固定的 值,使得左侧模 的值等于右侧模 的值。为了快速比对左右侧的值,我们选择将右侧预先计算出来的值存入一个哈希表中,让 (键为 ,对应值为 )。接着就是选择 值,计算 并比对了。
关于哈希表冲突,我们希望找到 的最小值,因而 需要尽可能大。每次冲突即代表一个更大的 值被发现了。因此无需处理冲突问题。
对于 的选择。可以发现 有 个可能的取值, 有 个。 取 时最佳。因此代码就可以写出来了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ll bsgs (ll a, ll b, ll m) { unordered_map<ll, ll> hash; ll bs = 1 ; int t = sqrt (m) + 1 ; for (int B = 1 ; B <= t; B++) { bs *= a; bs %= m; hash[b * bs % m] = B; } ll gs = bs; for (int A = 1 ; A <= t; A++) { auto iter = hash.find (gs); if (iter != hash.end ()) return A * t - it->second; gs *= bs; gs %= m; } return -1 ; }
时间复杂度:
扩展BSGS ( 和 不互质):
例题:
P1082 [NOIP2012
提高组] 同余方程 (exgcd)
P5656 [模板]
二元一次不定方程 (exgcd)
P1495 [模板]
中国剩余定理(CRT)/ 曹冲养猪
P1516
青蛙的约会 (CRT+exgcd)
P3846 [TJOI2007]
可爱的质数/ [模板] BSGS
P2485 [SDOI2011]
计算器 (欧拉降幂+乘法逆元+BSGS)
P3306 [SDOI2013]
随机数生成器 (等比数列推导+BSGS)
P4195 [模板] 扩展
BSGS/exBSGS
Part6. 乘法逆元
乘法逆元定义如下(注意和矩阵求逆不是一个东西):
若 ,且 与 互质,则 是 在模 条件下的乘法逆元,记作
简单来说乘法逆元 就是模 意义下的 的倒数。
费马小定理求逆元 :大部分题目会给出一个质数模数,因而互质是可以保证的。此时我们的乘法逆元就是使式子 成立的 值,考虑到模数 为质数,可以带回开头所说的费马小定理中。
得到 ,由于 与 互质,消去得: ,所以乘法逆元为 。
1 2 3 int inv (int a, int p) { return qpow (a, p - 2 , p); }
扩展欧几里得求逆元 :这是万能的方法,对任意模数均成立。它不像上面费马小定理那样限制模数必须是质数,因而只要时间充裕,都建议使用这种求逆元的方式。
因为 ,运用同余-等式互转可以得到 。符合exgcd
的形式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int exgcd (int a, int b, int &x, int &y) { if (!b) { x = 1 ; y = 0 ; return a; } int d = exgcd (b, a % b, y, x); y -= a / b * x; return d; } int inv (int a, int m) { int x, y; exgcd (a, m, x, y); return (x + m) % m; }
递推求逆元 :
例题:
P3811 [模板]
模意义下的乘法逆元 (递推求逆元)
来张弔图
Part7. 矩阵相关
矩阵,是一个按照长方排列的实数或复数集合。它最早用来表示方程组中的系数和常数,简单理解就是它将 元一次方程组中的系数,按照未知数的顺序依次挑出它们的系数组合为矩阵的某一行。 元一次方程的矩阵有 列,而行数则取决于方程组中方程的个数。
Div1. 初等行变换
考虑这个方程组:
按照如上所述,将它转换为系数矩阵(只有 的系数)就是:
你也可以写成增广矩阵(与系数矩阵相比多了一列常数,即等号右边的常数,这里用竖线隔开)的形式:
不难看出第一列代表了 的系数,第二列和第三列是 和 的系数。那么如果需要求解这个矩阵(得到方程组的解),我们应该通过初等行变换将它变成方便我们求解的模式。初等行变换内容如下(最好用方程组消元的思想简化理解):
交换某两行
把矩阵的某一行同乘以一个非零的数
把某行的若干倍加和到另一行
假设我们有一个 元线性方程组,如何设计算法使计算机能够快速求出它的解呢。我们需要引入三角矩阵 的概念:
顾名思义,系数排列看起来像一个三角形的矩阵,叫做三角矩阵。分为上三角矩阵和下三角矩阵。前者的非零系数均分布在对角线的右上方、后者都在左下方,例如矩阵: 就是一个上三角矩阵(这里是增广矩阵)。通常用字母 表示,求解线性方程组时经常化为这种形式方便求解:本例中当最后一个未知数(见最后一行)已知时,可以通过向上代入求解每一行中待求的未知数值。
那么如何将一个一般矩阵转换为上三角矩阵呢?答案是前面介绍过的初等行变换!步骤如下:
枚举每一列 ,选出无序组中第 列系数绝对值最大的一行 ,并移到无序组的最上边。
行通过自乘,将第 列的系数变成 ,并标记 为有序。
通过加减有序组中某一行的非零倍,将之后所有行的第 列系数化为 。
文字还是太抽象,我们来举个例子:
令矩阵 (有序组用绿色表示)
枚举第一列, 。开始时,所有行均无序。选出绝对值最大的那一项,本例中为第二行,进行移动,原矩阵变为:
第二步,自乘并标记有序,因此第一行除以 ,原矩阵就变成了:
第三步,将无序组的第 列消成 。本例中,我们让第二行减去二倍第一行;第三行直接减去第一行,得到:
枚举第二列,此时 。第一步,选出第二列系数绝对值最大的那一行,移到无序组最上端。本例中无需移动,自乘 ,标记有序,原矩阵为:
最终的最终,第三行减 倍第二行,得到我们心心念念的上三角矩阵:
我们假设从左到右,分别为 、 、 的系数,竖线右侧为常数。矩阵可以改写成方程组的形式:
根据最后一行,显然 。将 代入2式,解得 ,以此类推,由下向上代入解出的值即可,本例的唯一解是: 。
然而心细的你估计发现了疏漏之处:“求一元二次方程时都要先检验根是否存在( 判别式法)再来作答,你这里怎么没有讨论根的分布情况呢?”
事实上,矩阵的解的分布确实不止一种情况,这里是矩阵有唯一解的情况。类比高中立体几何求平面法向量的情景,我们通常都要令某个坐标为 或者是其他方便于计算的值,这里就是矩阵有无数组解的经典例子。要想系统分析矩阵方程解的数量情况,我们需要引入秩 的概念。
Div2. 秩
在上一节中我们通过初等行变换求出了矩阵的解,然而并不是所有矩阵都能轻而易举求出唯一解,因为它可能无解、也有可能无唯一解(默认最高次数为一)。类比一元二次方程中的 判别式法,矩阵是否也有判断根存在性的方法?
答案是:有滴!在矩阵运算中,我们使用秩 来描述矩阵的一些关于解的个数的关系。秩被定义为:将矩阵通过初等行变换后形成的梯形矩阵中非零行的个数。试看如下例子:
定义一个 的矩阵:
经过初等行变换后出现了这样的情况:
(第二行减去乘3的第一行,第一行乘2减去第三行)
第二行变成了纯 的一行,一、三行说什么都无法消成一个未知数的形式。如果写成方程组就是:
它有无数组解,原因是:矩阵的秩与矩阵增广矩阵的秩相等且小于了它的阶 。简单来说就是你用两个方程去求三个未知数的值(初一内容),当然是有无数多组解。
规定对于矩阵 ,它的秩用 表示( 、 、 均可)。因此令方程组的 阶增广矩阵秩为 ,系数矩阵的秩为 。矩阵有无数组解的条件就是 (严格来说:有无数组解的充要条件是 )
看第二个例子:
定义增广矩阵: ;它的系数矩阵: 。
增广矩阵变换后: ;系数矩阵:
根据定义,得到 , ,此时 。方程组无解。因而矩阵无解的充要条件是 。简单理解起来就是方程组中的两个方程起了冲突,矩阵 被省去的其中一步变换是: ,第一行和第三行相当于要你求解如下的方程组: 。显然矛盾,因此矩阵无解。
加上第一节里面的结论,我们总结出了矩阵解分布的三种情况(方程组的增广矩阵为 、系数矩阵为 ,阶为 ):
当 时,矩阵有唯一解
当 时,矩阵有无数解
当 时,矩阵无解
因此就有了一套组合算法:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 #include <bits/stdc++.h> #define N 110 #define NO_SOLUTION -1 #define INFINITE 0 #define SOLVE_OK 1 using namespace std;typedef long long ll;double mat[N][N];int n;double eps = 1e-6 ; int solve () { int rank = 0 ; for (int c = 0 , r = 0 ; c < n; c++) { int t = r; for (int i = r; i < n; i++) { if (fabs (mat[i][c]) > fabs (mat[t][c])) t = i; } if (fabs (mat[t][c]) < eps) continue ; for (int i = c; i <= n; i++) swap (mat[r][i], mat[t][i]); for (int i = n; i >= c; i--) mat[r][i] /= mat[r][c]; for (int i = r + 1 ; i < n; i++) { if (fabs (mat[i][c]) > eps) { for (int j = n; j >= c; j--) { mat[i][j] -= (mat[r][j] * mat[i][c]); } } } r++; rank = r; } if (rank < n) { for (int i = rank; i < n; i++) { for (int j = 0 ; j < n + 1 ; j++) { if (fabs (mat[i][j]) > eps) return NO_SOLUTION; } } return INFINITE; } for (int i = n - 1 ; i >= 0 ; i--) { for (int j = i + 1 ; j < n; j++) { mat[i][n] -= mat[i][j] * mat[j][n]; } } return SOLVE_OK; } int main () { cin>>n; for (int i = 0 ; i < n; i++) { for (int j = 0 ; j < n + 1 ; j++) cin>>mat[i][j]; } int res = solve (); if (res != SOLVE_OK) cout<<res<<endl; else for (int i = 0 ; i < n; i++) { if (fabs (mat[i][n]) < eps) mat[i][n] = fabs (mat[i][n]); printf ("x%d=%.2lf\n" , i + 1 , mat[i][n]); } return 0 ; }
时间复杂度:
以后上大学解高次线性方程就可以用这段程序秒了。
Div3. 矩阵基本运算
1. 加法 :
注意类比 元一次方程组的加减消元,两个矩阵相加意味着同一位置的元素相加。需要注意:只有同型的矩阵才有加法运算 (同型即行数列数相等)。
可以知道,四则运算的加法交换律和结合律仍然适用于矩阵加法。
2. 减法 :
加法的逆运算,让矩阵同一位置的元素相减即可。也是仅限于同型矩阵之间才可做减法。
3. 数乘 :
即矩阵中每个元素都跟数字相乘。符合乘法交换律和结合律
矩阵的加法、减法和数乘合称为矩阵的线性运算
4. 转置 :
矩阵 的转置矩阵用 表示。
直观来讲就是将原矩阵旋转一下(行和列互换)。满足如下性质:
(转置一次后再转置一次还是原来的矩阵)
(常数转置后就是它本身)
(上一条是它的特殊形式,类比两数乘积的幂)
5. 共轭 :
矩阵 的共轭矩阵用 表示。
类比共轭复数的定义:实部不变、虚部取相反数。矩阵共轭变换就是将矩阵中的所有复数变为其共轭形式。
6. 共轭转置 :
矩阵 的共轭转置矩阵记作 、 、 或 。
字面意思,先取共轭,再转置。它具备转置矩阵的三条性质。
Div4. 矩阵乘法
只有一个矩阵的行数和另一个矩阵的列数相等时才可进行乘法运算 。
例如一个 矩阵 和一个 矩阵 。记它们的乘积 。则 中的某个元素 。并且 是一个 的矩阵。
因此
它满足结合律、分配律,但是大多数情况下不满足交换律。交换律不成立可以看到下面这个例子:
首先根据定义, 矩阵的行列数取决于做乘法的两个矩阵 和 的行列数,比如 矩阵和 矩阵相乘,得到一个 矩阵,但是将它颠倒顺序,让一个 矩阵与 矩阵相乘,结果将是一个 矩阵,和前者行列数相反。
对于结果是正方形矩阵的,可以自己随便设置两个矩阵进行计算。但是部分矩阵仍然可以进行交换律运算:矩阵乘一个单位矩阵/数量矩阵[ / ]、矩阵乘它的伴随矩阵( )。
Div5. 其他常用类型的矩阵
1. 零矩阵 :顾名思义,由 组成的矩阵称作零矩阵。零矩阵不可逆,且任何符合条件的矩阵与一个零矩阵的积均为零矩阵。
2. 单位矩阵 :形如 的矩阵被称作单位矩阵,通常用字母 或 表示。单位矩阵指仅对角线系数为 、且其他系数为 的矩阵。 阶矩阵与它的逆矩阵相乘得到的结果就是一个 阶单位矩阵,即 。
3. 数量矩阵 :形如 的矩阵叫数量矩阵,可以看作实数 与单位矩阵 进行数乘运算后的结果,通常表示成 。矩阵与一个数量矩阵的乘积满足乘法交换律。
4. 逆矩阵 :如果存在一个矩阵 和单位矩阵 ,使得 ,则称矩阵 可逆, 是 的逆矩阵,也可记作 。单位矩阵的逆矩阵是它本身;零矩阵不可逆。 阶矩阵可逆的充要条件是 。
5.
对称矩阵 :转置矩阵与自身相等的矩阵叫做对称矩阵,特征是所有元素关于对角线对称,例如: 。对称矩阵必为方形矩阵,反之不一定成立,对于一个方形矩阵 , 必定是对称矩阵。
Div6. 矩阵的几何表示
平面直角坐标系上,一个向量 可以被表示成 的形式,即 。
计算机中,用两个不共线向量 和 能够表示整个平面直角坐标系。运用一点高中数学的空间几何知识,这里的 和 被称作基底(当然,如果需要描述三维空间坐标系,则需要三个不共线的基底向量)。于是我们使用矩阵 来描述这个平面直角坐标系就是非常简洁明了且优雅的了。
假设我们常规想法中的平面直角坐标系是 ,经过一轮线性变换后得到的新坐标系是: 。用一张图看一下变换后的坐标系:
如果在最开始的坐标系中有一个向量 ,我们如何在新的坐标系中表示它呢?再根据我们高中数学所学,只需要算出 的值即可。因为 是 轴的基底,相当于 上的一个单位,我们求新向量时只需求出在新的参考系中的新 值和 值,因而直接用 方向的系数乘以一个单位即可,在这里就是 ,得到 。
抽象之后变成:
例题:
P3389 [模板]
高斯消元法 (上三角矩阵的转换)
P2455 [SDOI2006]
线性方程组 (前一道题的升级版)
Part8. 组合计数
StarterDiv1. 阶乘概述
阶乘,数学中用 表示, 表示 的值,即
特殊地, 。
StarterDiv2. 常用排列总结
1. 排列数 :数学中用 表示( ,老教材记作 , )。表示从 个数中选择 个进行排列,公式为:
为啥呢?,有弔图为证↓
2. 组合数 :假设有 个物品,从中任选出 个排成一组,叫做组合;所有可能的选法总数叫做组合数。用 表示,计算公式为: 。简记为:乌鸦坐飞机
弔图×2↓
GZ表示就凭这几张图他能速通整个组合数的内容
StarterDiv3. 二项式定理
学过初中的大家都知道: ,这是完全平方和公式。高中的一些牛逼娃还知道完全立方和公式,也就是: 。这些式子其实都是可以由二项式定理套出来的。
二项式定理定义式如下:
这里出现的 。是不是突然发现它和组合数公式的共同之处喽?但是这一章并不会用它,只是作补充知识的说……
有这三条就够了,接下来进入组合计数的内容。
Div1. 高考娃狂喜——组合数计算
一个小栗子:
宇宙榜一大学阿福大学的榜一博士后导师黑虎阿福给你出了一道难题:
给你两个正整数 和 ( ),让你求出 的值。
你 :“这还不简单?”
阿福 : “好的,我这里将 设为 , 设为 ,请你求解。”
你 : “WTF?”
于是你决定用程序来代替人脑,阿福教授也做出了一定让步,让你求出 的值。但是不幸的是,人类的计算机科学水平自从2024之后就被来自几光年外的八体星人文明发出的“侄子一号(NEPHEW
1)”探测僚机锁定了,因此你需要设计一个高效的计算方式,而不是妄想着用2077年的赛博机器运行暴力计算,来解决这个问题。
一旦你的运行时间超过一秒,阿福教授就会使用战技“乌鸦坐飞机”对你造成大量阶乘伤害。已经学习了阶乘的你想必已了解了它的威力,所以还是老老实实推导公式吧!
递推版:
组合数递推公式: 。
分析思路类似于动态规划问题:我们要从 个物品中挑选 个出来,求组合数。
上图中,若包含这个红色物体,那么我们只需再从剩下的 个物体里挑选,因为红色物体自身占据了 个位置中的其中一个,因此留给其他物体的总名额就只有 个,因此该情况下组合数: ;同样地,若不包含红色物体,从剩下的 个物体中选出 个,因为在该情况下红色的物体不计入组合,因此剩余名额还是 个,组合数就是 。最后,因为从 个物体里选,只有包含红色和不包含红色两种情况(就好像你的双亲,不是你的母亲就是你的父亲),因此可以做到不重不漏。所以总组合数就是 。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 #include <bits/stdc++.h> #define N 2010 using namespace std;int c[N][N];void Csieve (int p) { for (int i = 0 ; i < N; i++) { for (int j = 0 ; j <= i; j++) { if (!j) c[i][j] = 1 ; else c[i][j] = (c[i - 1 ][j - 1 ] + c[i - 1 ][j]) % p; } } } int main () { int a, b, p; cin>>a>>b>>p; Csieve (p); cout<<c[a][b]<<endl; return 0 ; }
时间复杂度:
适用于 的大部分情况。
预处理版:
但是众所周知,递归有两大痛点:对于主观思维来说,是边界问题;对于客观条件来说,是内存。递归过程中CPU里储存了大量的未运行或者待返回的函数实例,当 和 的值增大时,尽管它能在时间方面表现出色,但是内存就不那么理想了,反而会显得臃肿至极。当题目中给出 时,建议用这种方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 #include <bits/stdc++.h> #define N 100010 using namespace std;typedef long long ll;int fact[N], infact[N];int qpow (int a, int b, int p) { int res = 1 ; while (b) { if (b & 1 ) res = (ll) res * a % p; a = (ll) a * a % p; b >>= 1 ; } return res; } int inv (int a, int p) { return qpow (a, p - 2 , p); } int C (int a, int b, int p) { return ((fact[a] % p) * (infact[b] % p)) % p * infact[a - b] % p; } int main () { int a, b, p; cin>>a>>b>>p; fact[0 ] = infact[0 ] = 1 ; for (int i = 1 ; i <= N; i++) { fact[i] = (ll) fact[i - 1 ] * i % p; infact[i] = (ll) infact[i - 1 ] * inv (i, p) % p; } cout<<C (a, b, p)<<endl; return 0 ; }
时间复杂度:
适用范围, 且 均在int
范围内的大部分情况。
Lucas定理优化版:
定理如下: ( 为质数)。证明在此 (建议直接背结论)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 typedef long long ll;int p;int qpow (int a, int b) { int res = 1 ; while (b) { if (b & 1 ) res = (ll) res * a % p; a = (ll) a * a % p; b >>= 1 ; } return res; } int inv (int a) { return qpow (a, p - 2 ); } int C (int a, int b) { int res = 1 ; for (int i = 1 , j = a; i <= b; i++, j--) { res = (ll) res * j % p; res = (ll) res * inv (i) % p; } return res; } ll lucas (int a, int b) { if (a < p && b < p) return C (a, b); return (ll) C (a % p, b % p) * lucas (a / p, b / p) % p; } int main () { ios::sync_with_stdio (false ); cin.tie (0 ); cout.tie (0 ); int t; int n, m; cin>>t; while (t--) { cin>>n>>m>>p; cout<<lucas (n + m, n)<<endl; } return 0 ; }
时间复杂度:
其本质是套用 定理计算 ,因为是模 意义下的除法,因而我们使用逆元来操作除法。
适用范围, 在long long
范围内的大部分情况。
高精度版(选修):
什么?你厌倦了组合数后面挂着的模 ?不妨试试高精度版的组合数计算吧!它适用于作业上的题目求解!(虽然前面几种也可以,毕竟手算的题数据很小取不取模都一样)是不是心动了呢?
常规思路来说,我们的组合数公式经过一轮分式化简可以得到: 。因此我们可以实现高精度的乘除法来计算这个炒鸡长的算式,但是这样不仅效率低下,手写和调试的难度也会增加。我们急切地想知道如何简化成一种高精度算法。
我们看到了Part1 里面讲的算术基本定理,将组合数转化为 的质数乘积分解式,最后我们只需要解决质数头顶的指数即可。我们使用以下这个公式:
。
用它可以计算出 中 的个数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 #include <bits/stdc++.h> #define N 10010 using namespace std;typedef long long ll;vector<int > num; ll primes[N], sum[N]; bool st[N];int cnt = 0 ;void prime_sieve (int n) { for (int i = 2 ; i <= n; i++) { if (!st[i]) primes[++cnt] = i; for (int j = 1 ; i * primes[j] <= n; j++) { st[i * primes[j]] = true ; if (i % primes[j] == 0 ) break ; } } } int get (int a, int p) { int res = 0 ; while (a) { res += a / p; a /= p; } return res; } vector<int > mul (vector<int > a, int b) { vector<int > res; int t = 0 ; for (int i = 0 ; i < a.size (); i++) { t += a[i] * b; res.push_back (t % 10 ); t /= 10 ; } while (t) { res.push_back (t % 10 ); t /= 10 ; } return res; } int main () { int a, b; cin>>a>>b; prime_sieve (a); for (int i = 1 ; i <= cnt; i++) sum[i] = get (a, primes[i]) - get (b, primes[i]) - get (a - b, primes[i]); vector<int > res; res.push_back (1 ); for (int i = 1 ; i <= cnt; i++) { for (int j = 1 ; j <= sum[i]; j++) { res = mul (res, primes[i]); } } for (int i = res.size () - 1 ; i >= 0 ; i--) cout<<res[i]; cout<<endl; return 0 ; }
时间复杂度:
适用范围, 在int
范围内。
有了这段代码,我们就可以完成开头阿福教授的原问题了(不模不限数据)!
Div2.
世界上最OI的IDE——Catalan数
当你翻开Catalan数的介绍文章,并大学特学了一番,感觉自己完全掌握了这神奇的数列,正当你兴致勃勃地打开题库搜索到一道Catalan数的题目正准备大展身手时,你会发现,面对这神奇的题干,不同于往常秒模板题的你,你甚至完全看不出来它和Catalan数有任何的关系,而且很有可能,你其实连Catalan数究竟是什么东西都不知道!
苏子愀然,正襟危坐而问客曰:“何为其然也?”
其实还真不能让那些博客背上黑锅,这种现象与Catalan数本身的应用有很大的关系。
Catalan数,或者习惯叫卡特兰数、明安图数,是组合数学中常用的特殊数列。数列如下:“ ”,它是一个无穷数列,数与数之间看起来似乎也没什么太大联系……其实它和斐波那契数列有类似之处,它们不具有特定的数学意义(只是斐波那契的递推方法简单得多罢了),只是一个十分普遍的数学规律。所以学习时应该挂靠于例子本身而不是一味依赖于定义所写,那我们就开始吧:
用最经典的例子写出来就是:
给你一个 的网格,你将从原点 开始移动。对于每次移动,你只能向上/向右一格( 坐标/ 坐标加一),但是需要保证你总向右走的次数不少于向上走的次数,问从原点到 有多少种不同的合法路径?
假设你某时刻走到了点 ,根据题目要求,意味着需要保证 。我们拟合一条经过点 的正比例函数,不难看出它的斜率 。对于这个 的网格,所有的点都在整数刻度上。我们接着画出直线 的图像,然后尽可能画几条不合法的路径出来比对一下,你会发现:不合法的路径与直线 至少有一个交点,合法路径一定与 没有交点 。用一张图来直观体会一下:
终点 ,其中红线为不合法路径,蓝线为合法路径。不难发现,不合法的路径与绿线( )都有至少一个交点,因为它们在某次移动后的端点与原点拟合而成的正比例函数的斜率 ,因此不是合法路径。
那么如何来计算合法和不合法路径的条数呢?直接求出合法路径不好求,规律不好找,因此我们计算出总路径数量,减去不合法数量即是合法路径数量。
可以看到,无论选择什么样的路径,在不左移、不下移的前提下,到达 ,你都只能移动 次(小学内容,把横线和竖线平移到一块数格子),其中右移 次、上移 次。转化一下,就是在 次移动中选出 次进行右移操作,总数就是 。
因为所有路径,包括合法的和不合法的路径都最终抵达了 ,难以将内鬼剔除出来。我们选择将不合法路径关于判定线 对称过去,它们的新终点将是 ,也就是 。根据上面的推导方法,这里就是在 轮移动中挑出 次右移操作,于是不合法路径的数量就是: ,合法路径数量是: 。
(至于为什么用右移次数而不是上移次数,是因为上移受到限制,这意味着你可以一直右移到 而无需担心条件限制;但是你就不能先一直上移到 ,因为这不符合题目要求)
扩展 :如果题干中指明向右走的次数不少于向上走的次数 ,则只需将判定线上下平移为 即可。
那这些又和宇宙第一IDE有什么关系呢
应用场景一:括号匹配
将向右走转化为左括号“(”,向上走转化为右括号“)”。对于每一次输入,检查一下左括号输入次数是否永不小于右括号输入次数。若是,当输入最后一个右括号,使左右括号数量相同时,即为匹配成功;若不是,且左括号个数大于右括号个数,则表明括号等待补全;若不是,且左括号个数小于右括号个数,即立即宣布失配。
应用场景二:合法进出栈序列计数问题
假设一个初始为空的栈,有 次操作, 次进栈, 次出栈,请问合法进出栈序列总数(空栈不出)是多少?
答案就是Catalan数,自行套公式计算。
应用场景三:圆的不相交弦计数问题
假设一个圆周上分布着偶数个点,对这些点两两连线,使相连的线不相交的所有方案数。其中一个合法解如下图:
聪明如你,答案还是Catalan数!那么如何转化为已知问题求解呢?
我们将出发点标记为左括号“(”,从出发点引出去的线与其他线/点的所有交点标记为右括号“)”。当所有点两两连接完毕时,根据场景一的模型,一旦左右括号失配即代表不合法,否则合法。因此这个问题也就变成了:给定 个左括号和右括号,求出使左右括号匹配的排列个数。在这里,如果问题无解,将会是这样:
例题:
P3807 [模板]
卢卡斯定理/Lucas 定理
P5014
水の三角(修改版) (Catalan数公式变形推导)