Acwing - 算法基础课 - 笔记(十)

334 阅读5分钟

第四章的内容是数学知识,主要讲解了

  • 数论
  • 组合计数
  • 高斯消元
  • 简单博弈论

数学知识(一)

这一小节主要讲解的是数论,主要包括了质数,约数,欧几里得算法。

质数

对所有的大于1的自然数字,定义了【质数/合数】这一概念。对于所有小于等于1的自然数,没有这个概念,它们既不是质数也不是合数。

质数的定义:对于大于1的自然数,如果这个数的约数只包含1和它本身,则这个数被称为质数,或者素数

质数的判定

采用试除法。

对于一个数n,从2枚举到n-1,若有数能够整除n,则说明除了1和n本身,n还有其他约数,则n不是质数;否则,n是质数

优化:由于一个数的约数都是成对出现的。比如12的一组约数是3,4,另一组约数是2,6。则我们只需要枚举较小的那一个约数即可

我们用dnd | n来表示d整除n,比如3123|12

只要满足dnd|n,则一定有 ndn\frac{n}{d} | n,因为约数总是成对出现的

比如3123|12,就一定有4124|12

我们只枚举小的那一部分的数即可,令dndd \le \frac{n}{d},则 dnd \le \sqrt{n}

则对于数n,只需要枚举2到n\sqrt{n} 即可

bool is_prime(int n) {
    if(n < 2) return false;
    for(int i = 2; i <= n / i; i++) {
        if(n % i == 0) return false;
    }
    return true;
}

注意有一个细节,for循环的结束条件,推荐写成i <= n / i

有的人可能会写成 i <= sqrt(n),这样每次循环都会执行一次sqrt函数,而这个函数是有一定时间复杂度的。而有的人可能会写成

i * i < =n,这样当i很大的时候(比如i比较接近int的最大值时),i * i可能会溢出,从而导致结果错误。

练习题:acwing - 866: 试除法判定质数

#include<iostream>
using namespace std;

bool is_prime(int n) {
	if(n < 2) return false;
	for(int i = 2; i <= n / i; i++) {
		if(n % i == 0) return false;
	}
	return true;
}

int main() {
	int m;
	scanf("%d", &m);
	while(m--) {
		int a;
		scanf("%d", &a);
		if(is_prime(a)) printf("Yes\n");
		else printf("No\n");
	}
	return 0;
}

分解质因数

朴素思路

还是采用试除法。

对于一个数N,总能够写成如下的式子:

N=P1k1×P2k2×......PnknN = P_1^{k_1} × P_2^{k_2} × ......P_n^{k_n},其中P1P_1PnP_n皆是质数,k1k_1knk_n 都是大于0的正整数。

对于一个数nn 求解质因数的过程如下:

从2到n,枚举所有数,依次判断是否能够整除 n 即可

这里可能有个疑问,不是应当枚举所有的质数吗?怎么是枚举所有数?枚举所有数如果取到合数怎么办?那分解出来的就不是质因子了啊。

下面进行一下解释:

我们枚举数时,对于每个能整除n的数,先把这个数除干净了,再继续枚举后面的数,这样能保证,后续再遇到能整除的数,一定是质数而不是合数。

除干净是什么意思呢?比如 n=24,我们先枚举2,发现2能整除24,则除之,24÷2=12,得到的结果是12,发现2仍然能整除之,则除之,12÷2=6,仍能整除,除之!6÷2=3。2不能整除3了。则停止。继续枚举下一个数3,3÷3=1。

质因数分解结束。则24=23×3124=2^3×3^1,24的质因子就有两个,分别是2和3。

那么,把一个数除干净了,怎么就能保证后续遇到能整除的数一定是质数了呢?

假设后续枚举到一个合数k,这个合数能整除n,则这个合数的某个质因子p,也能整除n。就比如合数6能整除24,则6的质因子2,肯定也能整除24。

合数k的质因子p一定比合数本身小,而我们是从小到大进行枚举,则p一定在k之前被枚举过了,而之前枚举p时,是把p除干净了的,此时不应当还能被p整除,这就矛盾了。所以在枚举时,如果遇到能整除的数,只可能是质数,而不可能是合数。(我们是从2开始枚举的,而2是一个质数)

void divide(int n) {
    for(int i = 2; i <= n; i++) {
        if(n % i == 0) {
            int s = 0;
            while(n % i == 0) {
                s++;
                n /= i;
            }
            printf("%d %d\n", i, s);
        }
    }
}
优化

从2枚举到n,时间复杂度就是O(n)。其实不必枚举到n。下面进行一下优化

有一个重要性质nn 中最多只包含一个大于n\sqrt{n} 的质因子

这个结论很好证明,因为我们知道一个数nn 分解质因数后可以写成

n=P1k1×P2k2×......Pnknn = P_1^{k_1} × P_2^{k_2} × ......P_n^{k_n}

其中P1P_1PnP_n 都是 nn 的质因子,若存在两个大于n\sqrt{n} 的质因子,就算两个质因子的指数都是最小的1,这两个质因子乘起来也大于 nn 了,于是就矛盾了。

所以我们只用枚举到 n\sqrt{n} 即可,即枚举的 ii 一定满足 ini \le \sqrt{n},即 inii \le \frac{n}{i}

优化后的求解质因子的代码如下(时间复杂度为 O(n)O(\sqrt{n})

void divide(int n) {
    for(int i = 2; i <= n / i; i++) {
        if(n % i == 0) {
            int s = 0;
            while(n % i == 0) {
                s++;
                n /= i;
            }
            printf("%d %d\n", i, s);
        }
    }
    // 如果除完之后, n是大于1的, 
    // 说明此时的n就是那个大于 原根号n 的最大的质因子, 单独输出一下
    if(n > 1) printf("%d %d\n", n, 1);
}

注意枚举完毕后,如果最终的n不等于1,则最后剩下的这个n,就是最大的一个质因子(大于原来的 n\sqrt{n} 的那个质因子),需要单独输出一下。

比如 n=39,由于枚举时的条件是 inii \le \frac{n}{i} ,则只会枚举到6,for循环就结束了,而39有一个质因子是13。

练习题:acwing - 867: 分解质因数

#include<iostream>
using namespace std;

void divide(int n) {
	for(int i = 2; i <= n / i; i++) {
		if(n % i == 0) {
			int s = 0;
			while(n % i == 0) {
				s++;
				n /= i;
			}
			printf("%d %d\n", i, s);
		}
	}
	if(n > 1) printf("%d %d\n", n, 1);
}

int main() {
	int m;
	scanf("%d", &m);
	while(m--) {
		int a;
		scanf("%d", &a);
		divide(a);
		printf("\n");
	}
	return 0;
}

筛选质数

练习题:acwing - 868: 筛质数

对于一个数n,求解1~n中质数的个数

朴素筛法

将2到n全部数放在一个集合中,遍历2到n,删除集合中这个数的倍数。最后集合中剩下的数就是质数。

解释:如果一个数p没有被删掉,那么说明在2到p-1之间的所有数,p都不是其倍数,即2到p-1之间,不存在p的约数。故p一定是质数。

时间复杂度:

n2+n3+....+nn=n(12+13+....+1n)=nlnn\frac{n}{2}+\frac{n}{3}+....+\frac{n}{n} = n(\frac{1}{2} + \frac{1}{3} + ....+\frac{1}{n})=n\ln n

nlnn=nlogenn\ln n = n\log_en,而e=2.71828e=2.71828左右,是大于2的,所以nlnn<nlog2nn\ln n \lt nlog_2n

故,朴素思路筛选质数的时间复杂度大约为 O(nlog2n)O(nlog_2n)

#include<iostream>
using namespace std;

const int N = 1e6 + 10;

int ctn;

bool st[N];

void get_primes(int n) {
	for(int i = 2; i <= n; i++) {
		if(!st[i]) ctn++; // i是质数
		for(int j = i; j <= n; j += i) st[j] = true; // 删数
	}
}

int main() {
	int n;
	scanf("%d", &n);
	get_primes(n);
	printf("%d", ctn);
}

其实上面的代码的运行过程不是完全按照朴素思路所描述的那样。上面的代码用一个布尔数组来表示一个数是否被删除。

遍历2到n,对每个数,先看一下其是否被删除了,若没有,则说明其是一个质数,随后将这个数以及其倍数全部删除(布尔数组置为true)。每当遍历到一个数时,如果这个数没有被前面的数所删掉,则说明这个数是个质数。

埃氏筛法

其实不需要把全部数的倍数删掉,而只需要删除质数的倍数即可。

对于一个数p,判断其是否是质数,其实不需要把2p-1全部数的倍数删一遍,只要删掉2p-1之间的质数的倍数即可。因为,若p不是个质数,则其在2p-1之间,一定有质因数,只需要删除其质因数的倍数,则p就能够被删掉。优化后的代码如下

#include<iostream>
using namespace std;

const int N = 1e6 + 10;

int ctn;

bool st[N];

void get_primes(int n) {
	for(int i = 2; i <= n; i++) {
		if(!st[i]) {
			ctn++;
			for(int j = i; j <= n; j += i) st[j] = true;
		}
	}
}

int main() {
	int n;
	scanf("%d", &n);
	get_primes(n);
	printf("%d", ctn);
}

那么优化后的时间复杂度如何呢?

原本我们需要对每个数都删掉其倍数,现在只需要对是质数的数,删掉其倍数。需要操作的数的个数明显减少了很多。要估算优化后的算法的时间复杂度,问题是,质数的个数究竟有多少个呢?

根据质数定理,在1到n之间,质数的个数大约为 nlnn\frac{n}{\ln n},我们原本需要对n个数进行操作,现在只需要对nlnn\frac{n}{\ln n}个数进行操作,所以时间复杂度就除以个lnn\ln n(其实这样算是不正确的),即nlnn÷lnn=nn\ln n \div \ln n = n,所以优化后的算法的时间复杂度大约是 O(n)O(n),其实准确复杂度是nloglognn\log{\log n}

这种优化后的筛选质数的方法,被称为埃氏筛法(埃拉托斯特尼筛法)。

线性筛法

下面多介绍一种线性筛法,其性能要优于埃氏筛法(在10^6^ 下两个算法差不多,在10^7^下线性筛法大概快一倍),其思想也类似,把每个合数,用它的某一个质因子删掉就可以了。

核心思路是:对于某一个合数n,其只会被自己的最小质因子给筛掉。

先上一下代码

#include<iostream>
using namespace std;

const int N = 1e6 + 10;

int ctn;

int primes[N];

bool st[N];

void get_primes(int n) {
	for(int i = 2; i <= n; i++) {
		if(!st[i]) primes[ctn++] = i;
		for(int j = 0; primes[j] <= n / i; j++) {
			st[primes[j] * i] = true;
            // 当下面的if条件成立时, primes[j]一定是i的最小质因子
			if(i % primes[j] == 0) break;
		}
	}
}

int main() {
	int n;
	scanf("%d", &n);
	get_primes(n);
	printf("%d", ctn);
}

对上面的代码解释如下:

pjpj 来表示primes[j]

  • i%pj=0i \% pj = 0

    pjpj一定是 ii 的最小质因子,因为我们是从小到大枚举质数的,首先遇到的满足i%pj=0i \% pj = 0的,pjpj一定是 ii 的最小质因子,

    并且 pjpj 一定是 pj×ipj \times i 的最小质因子。

    这么说可能不太好理解,假设4的最小质因子为2,写成分解质因数的形式,即为 4=224=2^2

    则,4的倍数中最小的数,且最小质因子同样是2的,一定是给4本身,再乘以一个其最小质因子,得到的数,即8。

    再举个例子,15=3×515 = 3 \times 5,15的最小质因子是3,则15的倍数中最小的数,且最小质因子同样是3的,一定是给15乘以一个最小质因子3,即45。

  • i%pj0i \% pj \ne 0

    pjpj 一定不是ii 的质因子。并且由于是从小到大枚举质数的,那么 pjpj 一定小于 ii 的全部质因子。那么pjpj 就一定是 pj×ipj \times i 的最小质因子。

则无论哪种情况,pjpj 都一定是 pj×ipj \times i 的最小质因子。

那么线性筛法是如何保证,所有的合数都一定能被删掉呢? 假设,有一个合数 xx,那么其一定有一个最小质因子 pjpj,那么当枚举到 i=xpji = \frac{x}{pj} 的时候,就能把 xx 删掉

线性筛法保证了,每个合数,都是被其最小质因子给删掉的,且只会被删一次

运行时间如下

从上往下依次是线性筛法埃氏筛法朴素筛法

小结

三种筛选质数的方法,都是基于删除数来完成的,我们只要删除合数,剩下的就是质数了。由于质数的约数只有1和它本身,而合数一定存在其他约数。先想一个比较朴素的暴力做法,那么就是枚举2到n,依次删除每个数的倍数,那么n以内的全部合数就被删除完毕,剩下的数就是质数。

用动图表示如下(动图来源于知乎,原链接在此

朴素筛法代码如下

void get_primes(int n) {
	for(int i = 2; i <= n; i++) {
		if(!st[i]) ctn++; // i是质数
		for(int j = i; j <= n; j += i) st[j] = true; // 删数
	}
}

随后,反观这个过程,我们考虑如何进行优化。

首先,暴力做法枚举了2到n全部的数,它做的无用功主要有

  1. 枚举的过程中,有些后面的数已经被前面的数的倍数给删掉了,但也会枚举到

    比如4,在枚举2的时候已经被删除了,但还是会被枚举到

  2. 删除时,有的数已经被前面的数删了,但还可能会被后面的数再删一次

    比如6,在枚举2时已经被删除了,但在枚举3时还会被再删一次

针对1,没什么好优化的,因为在算法执行过程中,被删除的数是动态变化的,我们并不能预先确定哪些数不需要枚举。

优化主要针对2。我们的目的是删除合数,做法是通过删除一个数的倍数来完成。因为每个合数都能够分解质因数,合数一定是某个质数的倍数。所以,我们其实不需要删除所有数的倍数,只需要删除所有质数的倍数,即可删除所有合数

故可对朴素筛法进行优化,仅当枚举到的是质数时,才执行删除。这便是埃氏筛法,代码如下

void get_primes(int n) {
	for(int i = 2; i <= n; i++) {
		if(!st[i]) {
			ctn++; // i 是质数
			for(int j = i; j <= n; j += i) st[j] = true; // 删除这个质数的倍数
		}
	}
}

埃氏筛法能优化算法性能,但还不是最优。我们考虑6这个合数,它会被质数2删除一次,还会被质数3再删除一次。

于是,我们接着想,删除一个合数,其实只需要删除这个合数的最小质因子的倍数即可。比如6有两个质因子,2和3,其实只需要用2来删除即可。这便是线性筛法。线性筛法需要记录质数,故需要开一个额外的数组来存质数。其代码如下

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

线性筛法的思想很简单:对每个合数,都用其最小质因子的倍数来删掉它。但是代码理解起来不是很容易。下面进行一个说明

首先从2到n,枚举每个数,用 ii 表示当前枚举的数,边枚举边保存得到的质数,则 primesprimes 数组中保存的是小于等于 ii 的全部质数。

在每次枚举 ii 时,尝试枚举小于 ii 的全部质数,即尝试枚举全部的 primesprimes 数组,并尝试删除 primesj×iprimes_j \times i

需要保证 primesj×iprimes_j \times i 小于等于 nn ,因为我们求的是 nn 以内的质数,删除合数只需要删除小于 nn 的合数即可,否则会导致 stst 数组越界。

每次从最小的质数开始枚举,删除 primesj×iprimes_j \times i。因为,无论 primesjprimes_j 能否整除 ii,都能保证 primesjprimes_j 一定是 primesj×iprimes_j \times i 的最小质因子。

而当 i%primesj=0i \% primes_j = 0 时,退出循环。

为什么在 i%primesj=0i \% primes_j = 0 时要退出呢?这是为了保证,每个合数都被其最小质因子给删掉,且只删一次。

假设在 i%primesj=0i \% primes_j = 0 时不退出循环,则下一轮会枚举到下一个质数 primesj+1primes_{j+1} ,此时会删除 primesj+1×iprimes_{j+1} \times i ,且是用的 primesj+1primes_{j+1} 去删除的,但 primesj+1×iprimes_{j+1} \times i 的最小质因子应该是 primesjprimes_j,因为 i%primesj=0i \% primes_j = 0 。而对于每个合数,我们应当用其最小质因子来删掉它。所以这里需要break

并且,如果不break的话,下一轮j++可能导致 primesprimes 数组越界。

其实,在 i%primesj=0i \% primes_j = 0 时也可以不break,但是为了防止j越界,此时应当在循环条件中加上 j<ctnj \lt ctn ,这样才能保证算法正确性。但是如此以来,会导致一个合数被删除多次,所以其性能会有所降低,并不能算是线性筛法。

如果在 i%primesj=0i \% primes_j = 0break 了,则能保证每个合数都只被删除一次,且都是被其最小质因子删除的。并且也能保证枚举 primesprimes 数组时,jj 不会越界。因为 primesprimes 数组存储的是小于等于 ii 的所有质数,

ii 是合数,则一定存在一个小于 ii 的质数,能够整除 ii,则一定会在 jj 越界前break掉;

ii 是质数,那么 ii 一定是当前 primesprimes 数组中的最后一个数,则在 jj 的最后一个位置一定会有 primesj=iprimes_j = i,所以 jj 也不会越界。

约数

求一个数的所有约数

试除法求一个数的所有约数,和试除法判断质数的思路一样

练习题:acwing - 869: 试除法求约数

#include<iostream>
using namespace std;
const int N = 2e8 + 10;

int l[N], h[N];

void get_dividers(int n) {
	// 只枚举较小的约数即可
	int lctn = 0, hctn = 0;
	for(int i = 1; i <= n / i; i++) {
		if(n % i == 0) {
			l[lctn++] = i;
			if(i != n / i) h[hctn++] = n / i; // 重复约数需要排除
		}
	}
	for(int i = 0; i < lctn; i++) printf("%d ", l[i]);
	for(int i = hctn - 1; i >= 0; i--) printf("%d ", h[i]);
	printf("\n");
}

int main() {
	int m;
	scanf("%d", &m);
	while(m--) {
		int n;
		scanf("%d", &n);
		get_dividers(n);
	}
	return 0;
}

求约数个数

假设一个数 NN ,其分解质因数可写成 N=P1k1×P2k2×......PnknN = P_1^{k_1} × P_2^{k_2} × ......P_n^{k_n}

NN 的约数个数为 (k1+1)×(k2+1)×....×(kn+1)(k_1+1) \times (k_2+1) \times .... \times (k_n+1)

其实就是排列组合。

对于 NN 的每个质因子,我们在构造一个约数时,可以选择是否将其纳入。

比如对于质因子 P1P_1,它的指数是 k1k_1,则我们有 k1+1k_1+1 种选择,即:纳入 00P1P_1,纳入 11P1P_1,....,纳入 k1k_1P1P_1,对于质因子 P2P_2 同理。

当所有的质因子我们都不纳入时,得到的约数就是 11,当所有的质因子我们全纳入时(每个质因子的指数取最大),得到的约数就是 NN 本身。

一共有多少种组合方式呢?

对于每个质因子 PiP_i 我们都有 ki+1k_i + 1 种选择,总共的组合方式就是将每个质因子的选择数相乘,即得到上面的公式。

int范围内的全部数,约数个数最多的一个数,其约数个数大概有1500个

练习题:acwing - 870: 约数个数

#include<iostream>
#include<unordered_map>
using namespace std;

typedef long long LL;

const int N = 1e9 + 7;

int main() {
	int m;
	scanf("%d", &m);
	unordered_map<int, int> primes; // 计数所有的质因子及其指数
	while(m--) {
		int n;
		scanf("%d", &n);
		for(int i = 2; i <= n / i; i++) {
			while(n % i == 0) {
				n /= i;
				primes[i]++;
			}
		}
		if(n > 1) primes[n]++;
	}
	unordered_map<int, int>::iterator it = primes.begin();
	LL res = 1;
	while(it != primes.end()) {
		res = (res * (it->second + 1)) % N;
		it++;
	}
	printf("%lld", res);
	return 0;
}

求约数之和

NN 的所有约数之和等于

(P10+P11+...+P1k1)×.....×(Pn0+Pn1+...+Pnkn)(P_1^0+P_1^1+...+P_1^{k_1}) \times ..... \times (P_n^0+P_n^1+...+P_n^{k_n})

将上面的式子按照乘法分配律展开,会得到如下的形式

(..×..)+(..×..)+(..×..)+...(.. \times ..) + (.. \times ..) + (.. \times ..) + ...

每一项都是一个乘积,而这个乘积,就是从每个 PiP_i 中选择了一项,互相乘了起来,这一个乘积就是 NN 的一个约数。

#include<iostream>
#include<unordered_map>
using namespace std;

const int N = 1e9 + 7;

typedef long long LL;

int main() {
	int m;
	scanf("%d", &m);
	unordered_map<int, int> primes;
	while(m--) {
		int n;
		scanf("%d", &n);
		for(int i = 2; i <= n / i; i++) {
			while(n % i == 0) {
				primes[i]++;
				n /= i;
			}
		}
		if(n > 1) primes[n]++;
	}
	LL res = 1;
	for(auto p : primes) {
		int a = p.first, b = p.second; // 质因数的底数和指数
		LL t = 1;
		for(int i = 0; i < b; i++) {
			t = (t * a + 1) % N;
		}
		res = (res * t) % N;
	}
	printf("%lld", res);
	return 0;
}

求最大公约数

欧几里得算法(辗转相除法)

我们用gcd(a,b)gcd(a,b) 来表示 aabb 的最大公约数,有如下公式

gcd(a,b)=gcd(b,amodb)gcd(a,b) = gcd(b, a \mod b)

amodb=0a \mod b = 0 时,最大公约数就是 bb

即,aabb 的最大公约数,等于 bbamodba \mod b 的最大公约数,当 bb 能整除 aa 时,最大公约数就是 bb

证明如下:

由于 bb 一定能写成 b=a×c+db = a \times c + d

则求解 aabb 的最大公约数,就等价于求解 aaa×c+da \times c + d 的最大公约数

假设 aabb 的最大公约数是 kk ,则 a=k×aka = k \times a_kb=a×c+d=k×bkb = a \times c + d = k \times b_k

a×c+da \times c + d 中的 aak×akk \times a_k 替换,得到 k×ak×c+dk \times a_k \times c + d

k×ak×c+d=k×bkk \times a_k \times c + d = k \times b_k,在等式左半边的部分将 kk 提取出来

k×ak×c+dk=k×bkk \times (a_k \times c + \frac{d}{k}) = k \times b_k

由于 bkb_k 是个整数,则 ak×c+dka_k \times c + \frac{d}{k} 一定是个整数,即 dk\frac{d}{k} 一定是整数,即 kk 一定能整除 dd

所以求解 kk,就相当于求解 bbdd 的最大公约数,即相当于求 bbamodba \mod b 的最大公约数。

一开始会考虑,gcd(a,b)=gcd(a,amodb)gcd(a,b) = gcd(a, a \mod b) ,是否需要注意 aabb 的大小关系,实际发现不用。

举例如下

假设求解 12123030 的最大公约数,如果我们写成 gcd(12,30)gcd(12, 30)

gcd(12,30)=gcd(30,12)gcd(12,30) = gcd(30, 12),会自动调整好顺序,使得 a>ba \gt b ,只是多一层递归而已。

随后便是 gcd(30,12)=gcd(12,6)=6gcd(30,12) = gcd(12, 6) = 6

练习题:acwing - 872: 最大公约数

#include<iostream>
using namespace std;

// 写代码时可以假设一定满足 a > b 
// 就算 a < b , 也会在第一次递归时调转位置
int gcd(int a, int b) {
    // b == 0 时, 直接返回a, 否则进行辗转相除
    return b ? gcd(b, a % b) : a;
}

int main() {
    int m;
    scanf("%d", &m);
    while(m--) {
        int a, b;
        scanf("%d%d", &a, &b);
        printf("%d\n", gcd(a, b));
    }
    return 0;
}