小M的数组变换,抽屉原理+素数筛 | 豆包MarsCode AI刷题

208 阅读6分钟

今天做了一道有趣的算法题:小M的数组交换。这道题背后隐藏了不少有趣的思路和优化技巧,我来分享几种解法,从暴力解法到优化过的埃氏筛法和欧拉筛法。


题目:

小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";
}

时间复杂度

  • 外层循环运行 O(n)O(n) 次,内层循环对每个数字运行 O(m)O(\sqrt{m}) 次,因此总时间复杂度为:
    O(nm)O(n \cdot \sqrt{m})
  • 其中,m 是数组中最大值。

空间复杂度

  • 集合 primeFactors 是主要开销,空间复杂度为: O(nlog(m))O(n \cdot \log(m))

埃氏筛选法

既然涉及到素数判断,当然是埃氏筛选法更胜一筹。

该算法由希腊数学家厄拉多塞(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;
}

时间复杂度

  • 外层循环 O(n)O(n),内层循环 O(m)O(\sqrt{m})
  • 加上素数筛的复杂度 O(mloglogm)O(m \log \log m),最终为:O(mloglogm)+O(nm)O(m \log \log m) + O(n \cdot \sqrt{m})
  • 其中,m 是数组中最大值。

空间复杂度

  • O(m)O(m)(来自 isPrime 数组)。
  • O(mlogm)O(\frac{m}{\log m})(来自 primes 列表)。
  • 因此,总空间复杂度为:O(m)O(m)

欧拉筛选法

埃氏筛选法已经是很高效的算法,但是仍然有优化空间:比如对于 45 这个数,它会同时被 3,5 两个数标记为合数,因此我们优化的目标是让每个合数只被标记一次,这样时间复杂度即能保证为 O(n)O(n),这就是我们接下来要介绍的欧拉筛选法。

与埃氏筛不同的是,「标记过程」不再仅当 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;
}

时间复杂度

  • 外层循环 O(n)O(n),内层循环 O(m)O(\sqrt{m})
  • 加上素数筛的复杂度 O(mloglogm)O(m \log \log m),最终为:O(mloglogm)+O(nm)O(m \log \log m) + O(n \cdot \sqrt{m})
  • 其中,m 是数组中最大值。

空间复杂度

  • O(m)O(m)(来自 isPrime 数组)。
  • O(mlogm)O(\frac{m}{\log m})(来自 primes 列表)。
  • 因此,总空间复杂度为:O(m)O(m)

欧拉筛法通过优化标记过程,避免了重复的标记操作,因此在实际运行时通常比埃氏筛法更高效。


the end!Thank you!