今天做了一道有趣的算法题:小M的数组交换。这道题背后隐藏了不少有趣的思路和优化技巧,我来分享几种解法,从暴力解法到优化过的埃氏筛法和欧拉筛法。
题目:
小M拿到一个数组,她可以进行多次操作,每次操作可以选择两个元素
ai和aj,并选择ai的一个因子x,然后将ai变为ai/x,并将aj变为aj*x。她的目标是通过有限次操作,使得数组中的每个元素最多只包含一种素因子。 素因子的定义是:若x能被素数p整除,那么p是x的一个素因子。例如,12 的素因子有 2 和 3。 你的任务是判断是否有可能通过有限次操作,使数组中的每个元素最多只包含一种素因子。如果可以,输出"Yes",否则输出"No"。
样例:
输入:
n = 3 ,a = [6, 9, 15]
输出:'Yes'
分析思路
以上面的样例为例,我们对所有元素进行因式分解:
6 = 2 * 3
9 = 3 * 3
15 = 3 * 5
贪心地想,为了让每个元素只包含一种素因子,就要把其他素因子分配给其它元素。如:6中已经有了素因子“2”,应该把3分配给9,相当于让6除以3的同时,让9乘以3,此时三个元素变成:
2 = 2
27 = 3 * 3 * 3
15 = 3 * 5
其中,9只有一个素因子,不需要处理,15中已经有了5,应该把3分配给9,相当于让15除以3的同时,让27乘以3:
2 = 2
81 = 3 * 3 * 3 * 3
5 = 5
至此,数组中每个元素只包含一种素因子,数组变为 [2, 81, 5] 。
通过分析我们发现,素因子的种类个数必须小于等于元素个数,否则便无法分配,这就是抽屉原理。
现在我们从贪心的视角窥见了这道题的本质:求所有元素所包含的素因子种类数目!
暴力解法
容易想到的是,对于数组中所有元素,暴力求出所有素因子,加入到Set中去重,最终set的大小即为素因子种类数。 代码:
public static String solution(int n, int[] a) {
// write code here
// 拿到所有元素的素因子
Set<Integer> primeFactors = new HashSet<>();
for (int num : a) {
for (int i = 2; i * i <= num; i++) {
while (num % i == 0) {
primeFactors.add(i);
num /= i;
}
}
// 如果剩余的数字大于1,它本身就是一个素数
if (num > 1) {
primeFactors.add(num);
}
}
// 抽屉原理,不同的素因子个数不能多于元素个数,不然无法分配
return primeFactors.size() <= n ? "Yes" : "No";
}
时间复杂度:
- 外层循环运行 次,内层循环对每个数字运行 次,因此总时间复杂度为:
- 其中,
m是数组中最大值。
空间复杂度:
- 集合
primeFactors是主要开销,空间复杂度为:
埃氏筛选法
既然涉及到素数判断,当然是埃氏筛选法更胜一筹。
该算法由希腊数学家厄拉多塞(Eratosthenes)提出,称为厄拉多塞筛法,简称埃氏筛。
我们考虑这样一个事实:如果 x 是质数,那么大于 x 的 x 的倍数 2x,3x,… 一定不是质数,因此我们可以从这里入手。
我们设 isPrime[i] 表示数 i 是不是质数,如果是质数则为 1,否则为 0。从小到大遍历每个数,如果这个数为质数,则将其所有的倍数都标记为合数(除了该质数本身),即 0,这样在运行结束的时候我们即能知道质数的个数。
这种方法的正确性是比较显然的:这种方法显然不会将质数标记成合数;另一方面,当从小到大遍历到数 x 时,倘若它是合数,则它一定是某个小于 x 的质数 y 的整数倍,故根据此方法的步骤,我们在遍历到 y 时,就一定会在此时将 x 标记为 isPrime[x]=0。因此,这种方法也不会将合数标记为质数。
当然这里还可以继续优化,对于一个质数 x,如果按上文说的我们从 2x 开始标记其实是冗余的,应该直接从 x⋅x 开始标记,因为 2x,3x,… 这些数一定在 x 之前就被其他数的倍数标记过了,例如 2 的所有倍数,3 的所有倍数等。
代码:
public static String solution(int n, int[] a) {
// write code here
// 使用埃氏筛选法得到素数表
int maxNum = Arrays.stream(a).max().getAsInt();
List<Integer> primes = getPrimes(maxNum);
// 拿到所有元素的素因子
Set<Integer> primeFactors = new HashSet<>();
for (int num : a) {
for (int prime : primes) {
if (prime > num) {
break;
}
if (num % prime == 0) {
primeFactors.add(prime);
}
}
}
// 抽屉原理,不同的素因子个数不能多于元素个数,不然无法分配
return primeFactors.size() <= n ? "Yes" : "No";
}
// 埃氏筛选法生成素数表
public static List<Integer> getPrimes(int limit) {
List<Integer> primes = new ArrayList<>();
boolean[] isPrime = new boolean[limit + 1];
Arrays.fill(isPrime, true);
for (int i = 2; i <= limit; i++) {
if (isPrime[i]) {
primes.add(i);
for (int j = i * i; j <= limit; j += i) {
isPrime[j] = false;
}
}
}
return primes;
}
时间复杂度:
- 外层循环 ,内层循环 。
- 加上素数筛的复杂度 ,最终为:
- 其中,
m是数组中最大值。
空间复杂度:
- (来自
isPrime数组)。 - (来自
primes列表)。 - 因此,总空间复杂度为:
欧拉筛选法
埃氏筛选法已经是很高效的算法,但是仍然有优化空间:比如对于 45 这个数,它会同时被 3,5 两个数标记为合数,因此我们优化的目标是让每个合数只被标记一次,这样时间复杂度即能保证为 ,这就是我们接下来要介绍的欧拉筛选法。
与埃氏筛不同的是,「标记过程」不再仅当 x 为质数时才进行,而是对每个整数 x 都进行。对于整数 x,我们不再标记其所有的倍数 x * x, x * (x+1), …,而是只标记质数集合中的数与 x 相乘的数,即 x * primes[0], x * primes[1], …,且在发现 xmodprimes[i] = 0 的时候结束当前标记。
核心点在于:如果 x 可以被 primes[i] 整除,那么对于合数 y = x * primes[i] + 1 而言,它一定在后面遍历到 primes[i] / x * primes[i] + 1 这个数的时候会被标记,其他同理,这保证了每个合数只会被其「最小的质因数」筛去,即每个合数被标记一次。
代码:
public static String solution(int n, int[] a) {
// write code here
// 使用欧拉筛选法得到素数表
int maxNum = Arrays.stream(a).max().getAsInt();
List<Integer> primes = getPrimes(maxNum);
// 拿到所有元素的素因子
Set<Integer> primeFactors = new HashSet<>();
for (int num : a) {
for (int prime : primes) {
if (prime > num) {
break;
}
if (num % prime == 0) {
primeFactors.add(prime);
}
}
}
// 抽屉原理,不同的素因子个数不能多于元素个数,不然无法分配
return primeFactors.size() <= n ? "Yes" : "No";
}
// 欧拉筛法生成素数表
public static List<Integer> getPrimes(int limit) {
List<Integer> primes = new ArrayList<>();
boolean[] isPrime = new boolean[limit + 1];
Arrays.fill(isPrime, true);
for (int i = 2; i <= limit; i++) {
if (isPrime[i]) {
primes.add(i);
}
for (int prime : primes) {
if (i * prime > limit) {
break;
}
isPrime[i * prime] = false;
if (i % prime == 0) {
break;
}
}
}
return primes;
}
时间复杂度:
- 外层循环 ,内层循环 。
- 加上素数筛的复杂度 ,最终为:
- 其中,
m是数组中最大值。
空间复杂度:
- (来自
isPrime数组)。 - (来自
primes列表)。 - 因此,总空间复杂度为:
欧拉筛法通过优化标记过程,避免了重复的标记操作,因此在实际运行时通常比埃氏筛法更高效。
the end!Thank you!