Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)

奋斗吧
奋斗吧
擅长邻域:未填写

标签: Acwing - 算法基础课 - 笔记(数学知识 · 三)(补) HarmonyOS博客 51CTO博客

2023-07-12 18:24:22 179浏览

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补),高斯消元&组合数


数学知识(三)

这一小节讲的是高斯消元,组合数。

高斯消元

高斯消元是用来解方程的,通常来说可以在 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法 的时间复杂度内,求出包含 n 个未知数的,n个方程的多元线性方程组的解。如下的方程组就称为多元线性方程组

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_02

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_03

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_04

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_05

多元线性方程组的解,通常有三种情况:无解;有唯一解;有无穷组解。

上面的方程组,可以将其系数提取出来,形成系数矩阵,并在最右侧添加常数列,形成增广矩阵,即

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_06

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_07

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_08

而矩阵具有三种初等行列变换

  1. 把某一行乘以一个非零的数
  2. 交换某2行
  3. 把某行的若干倍加到另一行上

这三种变换,不会影响方程组的解

高斯消元,可以用某种统一的步骤,通过执行上面的初等行列变换,使得方程组的系数矩阵,变成一个倒三角形的矩阵。即变成

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_09

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_10

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_11

从最后一个方程能直接求出 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_12,依次可以往上求解出 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_13Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_14,…,Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_15

当执行完高斯消元后,如果得到的方程组,是

  • 完美的阶梯型(第一行有n个未知数,第二行有n-1个未知数,最后一行有1个未知数)
    即一共有n个有效方程,此时有唯一解
  • 得到的方程组中有形如 0=非零
    此时无解
  • 得到的方程组中有形如 0=0的(这种方程是可以被其他方程表示的,是多余的无效方程)
    即有效方程的个数小于n,此时有无穷组解

高斯消元的算法步骤如下

枚举每一列(第c列)

  • 找到当前这一列系数绝对值最大的一行(从剩余的行中找)
  • 将该行交换到最上面(不是交换到第一行,而是交换到剩余行中最上面的一行)
  • 将该行的该列的系数变成1
  • 使用该行,对所有剩余行进行消元,将该列消成0

注意,每次遍历,找到当前列的绝对值最大的那一行,处理完毕后,后续的处理只针对剩余的行。

练习题:acwing - 883: 高斯消元解线性方程组

#include <iostream>
#include <cmath>
using namespace std;
const int N = 110;
double a[N][N];
int n;
double eps = 1e-8;

int solve() {
	int c = 0, r = 0;
	for (; c < n; c++) {
        // 每轮循环消掉一列
		int t = r;
        // 先找到当前需要处理的列的最大系数的那一行
		for (int i = r + 1; i < n; i++) {
			if (fabs(a[i][c]) > fabs(a[t][c])) t = i;
		}
        // 当前列的最大系数为0, 则不用处理
		if (fabs(a[t][c]) < eps) continue;
		
        // 将最大系数的行交换到最上面来
		for (int i = c; i <= n; i++) swap(a[t][i], a[r][i]);
		
        // 将该列系数化为1
		for (int i = n; i >= c; i--) a[r][i] /= a[r][c];
		
        // 用该行消掉下面所有行的该列
		for (int i = r + 1; i < n; i++) {
			for (int j = n; j >= c; j--) {
				a[i][j] -= a[r][j] * a[i][c];
			}
		}
		// 有效方程数+1
		r++;
	}
	
	if (r < n) {
        // 有效方程数 < n, 可能是无解或无穷多解
		for (int i = r; i < n; i++) {
			if (fabs(a[i][n]) > eps) return 0; // 无解
		}
		return 2; // 无穷多解
	} else {
		// 唯一解
		for (int i = n - 1; i >= 0; i--) {
			for (int j = n - 1; j > i; j--) {
				a[i][n] -= a[i][j] * a[j][n];
			}
		}
		return 1;
	}
}

int main() {
	std::ios::sync_with_stdio(false);
	cin >> n;
	for (int i = 0; i < n; i++) {
		for (int j = 0; j <= n; j++) {
			cin >> a[i][j];
		}
	}
	
	int res = solve();
	if (res == 0) printf("No solution\n");
	else if (res == 2) printf("Infinite group solutions\n");
	else {
		for (int i = 0; i < n; i++) {
		    if (fabs(a[i][n]) < eps) a[i][n] = 0;
			printf("%.2lf\n", a[i][n]);
		}
	}
	return 0;
}

对上面的代码略加解释:

最外圈的循环是针对而言的,从左到右枚举0到n-1列(这些列都是方程组中的未知数的系数,第n列是某一个方程组右侧的常数)。

最外圈的循环每进行一次,就能够消掉一个未知数(消掉一个元)。

循环的内部,先是从剩余的行r行及其下面的行)中找到当前列系数绝对值最大的那一行(若当前列系数最大的都是0,则说明当前列无需进行消元了,直接continue,继续处理下一列);随后,将这一行交换到最上方(交换到r行);接着,将这一行当前列的系数化为1(注意代码中是从右往左处理的,因为处理时需要用到最左侧的那一列,若从左往右处理,则最左侧的列一开始就会被更新,从而影响右侧列的更新);最后,遍历剩余行,将剩余行减掉当前行的某一个倍数,使得能够消掉当前列。最后,将r加一,表示该行处理完毕,接下来的处理都针对该行下面的行。

循环结束后,r的值就是最后得到的有效方程的个数。若每次循环都能恰好消去一个元,则r最后就等于n,此时得到的方程组就是一个完美的阶梯型(从上往下,第一个方程有n个未知数,最后一个方程有1个未知数)。此时方程组有唯一解。

r小于n,则说明方程组中有些方程是无效的。并且,r还没走到最后一行时,就已经处理到了最后一列(已经消元结束)。此时需要对r以下的行,进行判断。r以下的行,由于消元结束,都不再包含未知数了,所以这些行的0到n-1列(未知数的系数)应当都为0,此时需要判断第n列的常数。若这个常数为0,则方程就是0=0,无效方程;若这个常数不为0,则这个方程本身就矛盾了,所以整个方程组无解。

当方程有唯一解时。我们从最后一个方程往上递推,可以依次求解出每个未知数的值。

需要注意,由于我们在消元的过程中,对每个未知数的系数,都化简成了1。则消元结束后,最后一行应当是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_16

即消元结束后最后一行的常数列,直接就是未知数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_12

这一部分也是整个高斯消元的代码中最难以理解的部分,单独拎出来说。

for(int i = n - 1; i >= 0; i--) {
	for(int j = i + 1; j < n; j++) {
		a[i][n] -= a[i][j] * a[j][n];
	}
}

最后,每个未知数的解,我们都是保存在最右侧的常数列上面的。

针对第i行,我们需要解出 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_18

比如我们化简后得到的方程组如下

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_19

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_20

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_21

一共3个方程组,我们枚举从第2行到第0行。

即,n=3,我们枚举i,从20

i=2时,j3开始,发现j < n,则直接结束;

i=1时,j2开始,我们计算 a[1][3] -= a[1][2] * a[2][3],即相当于把 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_20 移向,变成 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_23

i=0时,j1开始,先把 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_24 减掉,Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_15 的值直接取下一行的常数列 a[1][3];随后 j=2,再尝试把 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_26 减掉,Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_27 的值直接取第2行的常数列 a[2][3]

我们可以发现,从最后一行往上计算,每一行的常数列其实都是某个未知数的解。

比如最后一行的常数列,一定是最后一个未知数的解;倒数第二行的常数列,是倒数第二个未知数的解。

更一般的,在我们循环到第 i 行时,其实需要求解的就是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_18。而这一行中,还有剩下的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_29Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_13 个未知数需要消掉,这些未知数的解,在先前求解 i 下面的行时,已经求解了出来(是i下面那些行的常数列)。所以我们遍历 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_31,依次消掉这些未知数,即用这一行的常数列 a[i][n] 来依次 减掉 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_29Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_13

对于 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_31Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_35 就是先前已经求解出来的第 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_36 行的常数列,即 a[j][n],它的系数就是a[i][j]。所以只需要减掉

a[i][j] * a[j][n] 即可。

所以,我们处理某一行 i,求解 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_18 时,只需要用这一行的常数列 a[i][n],依次减掉 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_35Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_31)这些项。而某个未知数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_35 的值为 a[j][n],系数为 a[i][j]。所以更新的公式就是 a[i][n] -= a[i][j] * a[j][n]

最后,所有行的常数列,就是方程组的解。即 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_41 = a[0][n]Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_15 = a[1][n],…,Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_13 = a[n-1][n]

扩展练习题:高斯消元解异或线性方程组

#include <iostream>
using namespace std;
const int N = 110;
int a[N][N];
int n;

int solve() {
	int c = 0, r = 0;
	for (; c < n; c++) {
        // 找到第一个系数非0的列
		int t = -1;
		for (int i = r; i < n; i++) {
			if (a[i][c] == 1) {
				t = i;
				break;
			}
		}
		if (t == -1) continue;
		// 交换到最上方
		for (int i = c; i <= n; i++) swap(a[t][i], a[r][i]);
		// 消掉下面方程的该列
		for (int i = r + 1; i < n; i++) {
            // 只有系数同样为1时才能消掉
			if (a[i][c] == 0) continue;
			for (int j = c; j <= n; j++) {
				a[i][j] = a[r][j] ^ a[i][j];
			}
		}
		r++;
	}
	
	if (r < n) {
		for (int i = r; i < n; i++) {
			if (a[i][n]) return 0;
		}
		return 2;
	} else {
		for (int i = n - 1; i >= 0; i--) {
			for (int j = n - 1; j > i; j--) {
                // 只有系数为1时, 需要异或上这个值
				if (a[i][j]) a[i][n] ^= a[j][n];
			}
		}
		return 1;
	}
}

int main() {
	std::ios::sync_with_stdio(false);
	cin >> n;
	for (int i = 0; i < n; i++) {
		for (int j = 0; j <= n; j++) {
			cin >> a[i][j];
		}
	}
	
	int res = solve();
	if (res == 0) printf("No solution\n");
	else if (res == 2) printf("Multiple sets of solutions\n");
	else {
		for (int i = 0; i < n; i++) {
			printf("%d\n", a[i][n]);
		}
	}
	return 0;
}

组合数

求组合数有很多种方式,需要根据数据范围来选择使用哪种方式。

递推

对于组合数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_44 可以用一个递推式来表达,即Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_45

所以每个组合数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_44

这样我们就可以根据上面的递推式,预处理出数据范围内全部的组合数,查询时,直接查表即可。

针对递推式 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_45

考虑一个口袋里一共有 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_48 个苹果?,我们从中选 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_49 个苹果,则总共的选法就是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_44,我们考虑将其中一个苹果打上标记(比如称其为金苹果)。那么总共的选法可以分为2大类:包含这个金苹果;不包含这个金苹果。

包含这个金苹果的选法,则是从余下的苹果中,再选 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_51 个,即 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_52

不包含这个金苹果的选法,则是从余下的苹果中,再选 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_49 个,即 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_54

所以 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_55

练习题:acwing - 885: 求组合数I

数据范围:1 <= n <= 10000, 1 <= b <= a <= 2000

#include <iostream>
using namespace std;

const int MOD = 1e9 + 7;

const int N = 2010;

int c[N][N];

void init() {
    for(int i = 0; i < N; i++) {
        for(int j = 0; j <= i; j++) {
            if(j == 0) c[i][j] = 1;
            else c[i][j] = (c[i - 1][j] + c[i - 1][j - 1]) % MOD;
        }
    }
}

int main() {
    int n;
    cin >> n;
    init();
    while(n--) {
        int a, b;
        cin >> a >> b;
        cout << c[a][b] << endl;
    }
}

需要注意,上面预处理时,i需要从0开始,需要算出 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_56,而针对 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_44 ,当 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_58

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_59

递推法的复杂度是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_60,对于这道题的数据范围,计算量就是2000^2 = 4 × 10^6

预处理阶乘

组合数的公式为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_61

需要的都是某个数的阶乘,那么我们把所有数的阶乘预处理一下,阶乘数组用fact来表示,即fact[i] = i! mod 10^9 + 7

同时我们注意到,阶乘项也出现在分母中,而由于我们需要对结果取一个模,所以我们可以对i的阶乘,求一下乘法逆元。把除法转化为乘法。某个数的阶乘在模10^9 + 7下的乘法逆元,我们用infact[i]来表示。

于是用这两个预处理出的数组,就能在 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_62

由于模的10^9 - 7是个质数,所以我们求乘法逆元可以直接用欧拉定理的特例,费马小定理进行求解。

:若a和m互质,求a mod m下的乘法逆元时,若m是质数,则用快速幂进行求解,乘法逆元为ap-2;若m不是质数,则用扩展欧几里得算法进行求解 ,就不要傻乎乎的先对m求个欧拉函数,再用欧拉定理做幂运算来求解,太慢了。

设a-1 为逆元,则有 a × a-1 mod m = 1,那么有a × a-1 = km + 1,移项一下就是 a× a-1 - km = 1。我们将a-1用x替换,m用b替换,-k用y替换,则就是 ax + by = 1,其中a和b是固定的,未知数是x和y。我们需要求解的逆元就是x,而a和b是互质的,所以gcd(a,b) = 1,根据裴蜀定理,这个二元线性方程一定有解,并且能用扩展欧几里得算法进行求解。

练习题:acwing - 886: 求组合数II

数据范围:1 <= n <= 10000,1 <= b <= a <= 10^5

#include <iostream>
typedef long long LL;
using namespace std;
const int N = 100010, MOD = 1e9 + 7;

LL fac[N], infac[N];

int qmi(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;
}

void init() {
	fac[0] = infac[0] = 1;
	for (int i = 1; i < N; i++) {
		fac[i] = fac[i - 1] * i % MOD;
		infac[i] = infac[i - 1] * qmi(i, MOD - 2, MOD) % MOD;
	}
}

int main() {
	std::ios::sync_with_stdio(false);
	init();
	int n, a, b;
	cin >> n;
	while (n--) {
		cin >> a >> b;
		LL res = fac[a] * infac[a - b] % MOD * infac[b] % MOD;
		printf("%lld\n", res);
	}
	return 0;
}

对于预处理时,求解factinfact数组时,

fact[i] = fact[i - 1] * i 这个很好理解,但是逆元数组 infact的递推怎么理解呢?

可以这样想,对于阶乘 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_63 ,其逆元为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_64,有 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_65

对于阶乘Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_66,我们将其拆分为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_67,则有 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_68

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_66 的逆元为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_70,根据模运算的特性,我们将上式进行一下组合变换,有

  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_71
  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_72

那么很明显的,Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_73

也就是说,我们只需要求解一下 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_74的逆元

于是就能得到 infact数组的递推式: Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_75

预处理出2个阶乘数组,时间复杂度为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_76,实际性能瓶颈在于计算 infact[i]时,需要使用快速幂计算 iMOD - 2次方,需要的复杂度是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_77,而本题的 MOD = 10^9 + 7log MOD大概等于30,所以实际上复杂度是 30n

注意,只有在模数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_78 为质数,并且 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_48Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_49 都小于 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_78 时,才能用预处理阶乘的方式求解。若 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_82,则可能 [1, a]中有的数和 p 不互质,不互质的话,就不存在乘法逆元。

卢卡斯定理

参考:

定义

对于非负整数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_48Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_49 和质数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_78,有 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_86,其中

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_87Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_88

一般,使用的是下面的式子

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_89,其中 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_78

证明

  • 引理1:对于组合数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_91,其中 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92 为质数,始终满足 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_93
    这个引理很好证明,根据组合数公式,有Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_94,而 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92 是质数,其只存在 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_96Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92 两个因子,所以分子中的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92 一定不会被消去,故计算出的结果一定是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92 的某个倍数,故其在 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_100 下为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_101
  • 引理2:对于整数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_102 和质数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92,始终满足 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_104
    Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_105进行二项式展开,得到 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_106,根据引理1,中间项在Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_100下都为0,可以消去,最后得到 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_108
  • Lucas定理
    我们先将 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_109Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_110 转换为对应的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92
  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_112
  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_113

接着,有

KaTeX parse error: {align*} can be used only in display mode.

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_114

根据二项式展开,容易知道 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_115 就是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_116 展开式中的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_117 的系数,而在上述等式右侧中,就是要知道 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_118

对于等式右侧的方程 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_119

  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_120 即为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_121Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_122
  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_123 即为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_124Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_125
  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_126 即为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_127Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_128

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_129Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_130Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_131,…,Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_132 乘起来,得到 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_133

所以右式中 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_117 的系数为 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_135,所以有 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_136

为了将 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_136 转化为这样的形式:Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_138

我们先把 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_135 拆分成 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_140Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_141 两部分,首先对于 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_140,它就等于 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_143,因为我们将 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_109Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_110 写成 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92 进制表示时,最后的余数就是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_147Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_148 。接着,我们只需要证明一下 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_149

由于我们是先将 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_109Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_110,分别写成了 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92

  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_153
  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_154

随后推出的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_136

现在,我们将 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92 进制下的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_109Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_110 分别右移一位(相当于除以 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92),得到的是

  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_160
  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_161

我们只要将 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_162Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_163 带入前面的公式,就能得到如下式子(下面用 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_164 替代 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_162Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_166

  • Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_167

于是,整个卢卡斯定理得证,对于非负整数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_109Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_110 以及质数 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_92

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_171

练习题:求组合数III

数据范围:1 <= n <= 20, 1 <= b <= a <= 10^18,1 <= p <= 10^5,其中 p 是质数

由于每组数据中 p 都不一样,所以这道题用预处理阶乘的方式不是特别合适,因为一次预处理的结果,只能适用于一组数据,对于下一组数据,由于 p 不同,还得重新计算一次阶乘。

// 1283ms
#include <iostream>
typedef long long LL;
using namespace std;

// 快速幂
LL qmi(LL a, LL b, LL p) {
	LL res = 1;
	while (b) {
		if (b & 1) res = res * a % p;
		a = a * a % p;
		b >>= 1;
	}
	return res;
}

// 小于p的组合数直接求
LL C(LL a, LL b, LL p) {
    if (b > a) return 0;
	LL res = 1;
	for (int i = a, j = 1; j <= b; j++, i--) {
		res = res * i % p;
		res = res * qmi(j, p - 2, p) % p;
	}
	return res;
}

// 卢卡斯定理
LL lucas(LL a, LL b, LL p) {
	if (a < p && b < p) return C(a, b, p);
	return C(a % p, b % p, p) * lucas(a / p, b / p, p) % p;
}

int main() {
	std::ios::sync_with_stdio(false);
	LL n, a, b, p;
	cin >> n;
	while (n--) {
		cin >> a >> b >> p;
		printf("%lld\n", lucas(a, b, p));
	}
	return 0;
}

将上面的C函数修改一下,只在最后,算一次乘法逆元,可以极大的优化时间复杂度

LL C(LL a, LL b, LL p) {
	if (b > a) return 0;
	if (b > a - b) b = a - b;
	LL x = 1, y = 1;
	for (int i = a, j = 1; j <= b; j++, i--) {
		x = x * i % p;
		y = y * j % p;
	}
	return x * qmi(y, p - 2, p) % p;
}

完整代码如下

// 66ms
#include <iostream>
typedef long long LL;
using namespace std;

LL qmi(LL a, LL b, LL p) {
	LL res = 1;
	while (b) {
		if (b & 1) res = res * a % p;
		a = a * a % p;
		b >>= 1;
	}
	return res;
}

LL C(LL a, LL b, LL p) {
	if (b > a) return 0;
	if (b > a - b) b = a - b; //小优化, 因为 C(a, b) = C(a, a - b)
	LL x = 1, y = 1;
	for (int i = a, j = 1; j <= b; j++, i--) {
		x = x * i % p;
		y = y * j % p;
	}
    // 只在最后求一次乘法逆元
	return x * qmi(y, p - 2, p) % p;
}

LL lucas(LL a, LL b, LL p) {
	if (a < p && b < p) return C(a, b, p);
	return C(a % p, b % p, p) * lucas(a / p, b / p, p) % p;
}

int main() {
	std::ios::sync_with_stdio(false);
	LL n, a, b, p;
	cin >> n;
	while (n--) {
		cin >> a >> b >> p;
		printf("%lld\n", lucas(a, b, p));
	}
	return 0;
}

至于时间复杂度,因为每次递归都是除以了个 p ,递归的层数就是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_172,每层中的复杂度就是计算 C(a % p, b % p) 的复杂度,如果是优化之前,计算 C(a, b)最多需要算 b 次乘法,每次还需要求解个逆元,复杂度应该是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_173,优化后,只需要在最后求解一次逆元,复杂度是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_174 ,我们将 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_49 看成最大能取到 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_48,而 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_48 实际最大只能取到 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_178,那么,优化前的复杂度是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_179,优化后的复杂度是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_180 ,实际上由于在 b > a - b时,做了 b = a - b的优化,所以b实际最多取到 a / 2,优化后实际的复杂度会更低,实际只需要 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_181,其中的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_182 表示 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_48 的取值,这道题目的数据范围,Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_182 最大能取到 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_185

卢卡斯定理,适用于 a 和 b 特别大,但是模数 p 是质数并且比较小的情况。

当 a 大于 p 时,不能依靠乘法逆元来进行计算,一是因为时间复杂度很高,二是因为 a 大于 p 时,[1, a]中间的一些数的乘法逆元不一定存在,因为某个数 x 在模 p 下的乘法逆元存在,一定要求 x 与 p 互质。

故需要使用卢卡斯定理。

高精度

前面几种求解组合数,都是求解在模某个数下的结果,而现在需要精确的求解出组合数的结果,这个结果可能很大。

求组合数IV

数据范围:1 <= b <= a <= 5000

思路:高精度,直接从公式出发。

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_186

可以实现一个高精度乘法,以及一个高精度除法。但是那样的计算效率比较低,也比较难以实现。所以可以换一种思路,先对 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_44

问题变成了,如何对 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_44分解质因数呢?可以先对分子中的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_63 分解一下质因数,看某个因子出现了多少次,再对分母中的 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_190Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_191

但是如果对分子分母中每个数依次分解质因数,那么时间复杂度是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_192,这道题目的数据范围是 5000,这个复杂度下也是不会超时的,但效率较低(之后自己试了下,效率相差也没有很大),可以换另一种方法,我们可以先把 5000 以内的质数全部预处理出来。

然后对于如何求解 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_63 的某个质因子 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_78

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_195

第一项是所有小于 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_48 的数中,Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_78 的倍数的个数;但有的数可能是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_198 的倍数,但第一项只加了一次;所以我们需要再加上第二项 ,同理需要加上第三项,…,假设某个数分解质因数后,包含了 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_199 ,那么该数对 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_78 的次数的贡献一共是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_201,该数会被恰好加 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_201 次,在 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_203 时加1次,在 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_方程组_204 时加1次,…,在 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_205

所以我们的求解步骤是:

  1. 先用筛法求出1-5000内的全部质数
  2. 对于分子和分母的阶乘表示,求出每个质数的次数
  3. 用高精度乘法将每个质因子乘起来
// 47ms
#include <iostream>
#include <vector>
using namespace std;

const int N = 5010;

int primes[N], cnt;

bool st[N];

int sum[N]; // 某个质数出现的次数

void get_primes(int n) {
	for (int i = 2; i <= n; i++) {
		if (!st[i]) primes[cnt++] = i;
		for (int j = 0; primes[j] <= n / i; j++) {
			st[primes[j] * i] = true;
			if (i % primes[j] == 0) break;
		}
	}
}

// n! 中 , p 的次数为多少
int get(int n, int p) {
	int res = 0;
	while (n) {
		res += n / p;
		n /= p;
	}
	return res;
}

// 高精度乘法
vector<int> mul(vector<int> a, int b) {
	int c = 0;
	vector<int> res;
	for (int i = 0; i < a.size(); i++) {
		c += a[i] * b;
		res.push_back(c % 10);
		c /= 10;
	}
	while (c) {
		res.push_back(c % 10);
		c /= 10;
	}
	return res;
}

int main() {
    std::ios::sync_with_stdio(false);
	int a, b;
	cin >> a >> b;
	
	get_primes(a);
	
	for (int i = 0; i < cnt; i++) {
		int p = primes[i];
		sum[i] = get(a, p) - get(b, p) - get(a - b, p);
	}
	
	vector<int> res;
	res.push_back(1);
	
	for (int i = 0; i < cnt; i++) {
		for (int j = 0; j < sum[i]; j++) {
			res = mul(res, primes[i]);
		}
	}
	
	for (int i = res.size() - 1; i >= 0; i--) {
		printf("%d", res[i]);
	}
	printf("\n");
	return 0;
	
}

分别对每个数分解质因数,并计算次数,发现效率也没有相差的特别大

// 53ms
#include <iostream>
#include <vector>
#include <unordered_map>
using namespace std;

void process(int x, unordered_map<int, int>& m, bool add) {
	for (int i = 2; i <= x / i; i++) {
		while (x % i == 0) {
			if (add) m[i]++;
			else m[i]--;
			x /= i;
		}
	}
	if (x > 1) {
		if (add) m[x]++;
		else m[x]--;
	}
}

vector<int> mul(vector<int> a, int b) {
	vector<int> res;
	int c = 0;
	for (int i = 0; i < a.size(); i++) {
		c += a[i] * b;
		res.push_back(c % 10);
		c /= 10;
	}
	while (c) {
		res.push_back(c % 10);
		c /= 10;
	}
	return res;
}

int main() {
	std::ios::sync_with_stdio(false);
	int a, b;
	cin >> a >> b;
	unordered_map<int, int> primes;
	
	for (int i = a, j = 1; j <= b; j++, i--) {
		process(i, primes, true);
		process(j, primes, false);
	}
	
	vector<int> res;
	res.push_back(1);
	
	for (auto& [k, v] : primes) {
		for (int i = 0; i < v; i++) {
			res = mul(res, k);
		}
	} 
	
	for (int i = res.size() - 1; i >= 0; i--) {
		printf("%d", res[i]);
	}
	printf("\n");
	return 0;
}

卡特兰数

卡特兰数的应用非常广,很多方案的方案数都是卡特兰数。

比如火车进出栈问题(在某个操作序列的任意前缀中,进栈操作次数一定得大于等于出栈操作次数),合法括号序列(任意前缀中,左括号数量一定得大于等于右括号数量)。

应用题:889. 满足条件的01序列

给定 n 个 0 和 n 个 1,它们按照某种顺序排成长度为 2n 的序列,求满足任意前缀中0的个数都不小于1的个数的序列,有多少个。

我们可以先把问题进行一下转化

把任何一个排列转化成一个路径(比如一共6个1和6个0,0表示向右走,1表示向上走,那么每个排列,都可以表示为一条从坐标[0,0]走到[6,6]的路径)

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_206

如果某个排列中,任意前缀中0的个数要大于等于1的个数,也就等价于,这个排列对应的路径,路径上的任何点的坐标(x, y),都要满足x >= y,于是我们可以画一条对角线 y = x,那么只要保证,路径始终位于这条对角线的下方(可以恰好到达对角线,但不能穿过这条对角线)。根据下图,也就等价于,在任何时刻,路径都必须在红色这条线的下方(严格的下方,不能到达红色这根线)

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_207

那么,现在问题等价于,求解所有从[0, 0]走到[6, 6],且不经过红色这条线的,所有路径的个数。

那么我们可以先求解所有路径的总数,然后再求解一下经过红色这条线的路径的个数,将两者相减,即得到答案。

对于上述 n = 6,总的路径数量就是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_208

那么经过红色这条线的路径的个数要怎么求呢?

对于任意一条经过红色这条线的路径,我们可以找到该路径与红线的第一个交点,然后把该点之后的剩余路径部分,关于红线做一个轴对称。

比如下面的橙线为一条经过红线的路径,我们将其与红线第一个交点之后的路径部分,关于红线做轴对称(加粗绿线),会发现其最终到达 [5, 7]这个点,这个点恰好和 [6, 6] 关于红线对称。

那么,任何一条经过红线的路径,我们都可以通过上述变换,将其转变为一条从[0, 0],走到[5, 7]的路径。所以,任何一条经过红线,从[0, 0]走到[6, 6]的路径,都能对应一条从[0, 0]走到[5, 7]的路径,反之同样成立。所以这两者是一一对应的,是可以等价的。

Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_线性方程组_209

所以,经过红线从[0, 0] 走到[6, 6]的路径的个数,就等于从[0, 0] 走到 [5, 7] 的路径个数,而后者的个数计算很简单,就是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_210,所以对于 n = 6,问题的答案就是 Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_算法_211

对于一般的 n,问题的答案则是:Acwing - 算法基础课 - 笔记(数学知识 · 三)(补)_高斯消元_212

而这个数就被称为卡特兰数

// 295ms
#include <iostream>
typedef long long LL;
using namespace std;
const int MOD = 1e9 + 7;

int qmi(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 main() {
    std::ios::sync_with_stdio(false);
	int n;
	cin >> n;
	int res = 1;
	for (int i = 2 * n, j = 1; j <= n; j++, i--) {
		res = (LL)res * i % MOD;
		res = (LL)res * qmi(j, MOD - 2, MOD) % MOD;
	}
	res = (LL)res * qmi(n + 1, MOD - 2, MOD) % MOD;
	printf("%d\n", res);
	return 0;
}

优化:

// 20ms
#include <iostream>
typedef long long LL;
using namespace std;
const int MOD = 1e9 + 7;

int qmi(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 main() {
    std::ios::sync_with_stdio(false);
	int n;
	cin >> n;
	int x = 1, y = n + 1;
	for (int i = 2 * n, j = 1; j <= n; j++, i--) {
		x = (LL)x * i % MOD;
		y = (LL)y * j % MOD;
	}
	int res = (LL)x * qmi(y, MOD - 2, MOD) % MOD;
	printf("%d\n", res);
	return 0;
}


好博客就要一起分享哦!分享海报

此处可发布评论

评论(0展开评论

暂无评论,快来写一下吧

展开评论

您可能感兴趣的博客

客服QQ 1913284695