Leetcode #2521: Distinct Prime Factors of Product of Array

Table of contents

A fun Leetcode problem I solved recently was #2521. In this blog post I'll discuss a couple of solutions to it.

Problem statement🔗

The problem statement simply asks us to find the number of distinct prime factors in the product of the elements of an array of positive integers. So for example the product of the elements of the array [1, 2, 6, 7] is 84, whose prime factors are [2, 2, 3, 7], which has 3 distinct elements, so our answer should be 3.

The array can have at most elements and each element lies between and , both inclusive.

A first attempt🔗

A useful observation here is that we don't even need to multiply together all the array elements. We're only trying to count the distinct prime factors of the product, so if we build up a set of prime factors for each element and union all of them together and find its size, we'd be done. This way we sidestep issues with loss of precision or numeric type overflows.

We could of course get around the latter restriction by using a language like Python or a language library that supports arbitrary-precision integers, but that seems like overkill when we already have the simpler idea discussed above.

How do we find the prime factors of each element of the array? My first thought was to build up a list of primes from 2 to 1000, since that's the range the array elements lie in. Then we could check each number against the list of primes, like so:

class Solution:
    def distinctPrimeFactors(self, nums: List[int]) -> int:
        def is_prime(n):
            return not any(n % i == 0 for i in range(2, n))
        PRIMES = [k for k in range(2, 1001) if is_prime(k)]
        seen = set()
        for num in nums:
            for prime in PRIMES:
                if prime > num:
                    break
                if prime not in seen:
                    if num % prime == 0:
                        seen.add(prime)
        return len(seen)

In this code:

This solution passes all testcases, but the runtime looks to be suboptimal.

A first attempt at solving Leetcode #2521. The runtime only beats 21% of submissions.
A first attempt at solving Leetcode #2521. The runtime only beats 21% of submissions.

Let's analyze the runtime. I'm going to assume that the % (modulo) operation is constant time to simplify calculations.

  1. Let's use to denote the upper bound of each positive integer in the array. In our problem statement, this is . Let's also use to denote the number of elements in our array.

  2. We perform operations to compute the list of all primes up to , because for each number we check if it's prime by iterating from .

  3. Then, for each number in the array we perform at most checks, where is the number of primes between and .

Our total runtime is then . Note that grows slower than . For , is just .

This term is not great. Can we get a faster runtime?

One thing we can do is: when checking if an integer is prime, we can save some time by running from to instead of .

class Solution:
    def distinctPrimeFactors(self, nums: List[int]) -> int:
        def is_prime(n):
            upper_bound = 1 + math.ceil(math.sqrt(n)) if n > 2 else n
            return not any(n % i == 0 for i in range(2, upper_bound))
        PRIMES = [k for k in range(2, 1001) if is_prime(k)]
        seen = set()
        for num in nums:
            for prime in PRIMES:
                if prime > num:
                    break
                if prime not in seen:
                    if num % prime == 0:
                        seen.add(prime)
        return len(seen)

We now iterate up to the ceiling of the square root of . The edge case to consider is . If we did that for we'd end up with an empty range, so instead we iterate up to in that case.

Running this, we get:

We're marginally better than before, but this is still pretty inefficient.
We're marginally better than before, but this is still pretty inefficient.

A different approach: the sieve of Eratosthenes🔗

There isn't much else to refine in the above approach. We'll need to try something else.

A faster way to generate prime numbers is the sieve of Eratosthenes. This is an algorithm to generate all prime numbers up to a given number, in this case . The simplest implementation of this algorithm looks like this in pseudocode.

Translating this to Python, we'd have something like

isPrime = [True for _ in range(N+1)]
for i in range(2, N):
    if isPrime[i]:
        for j in range(2*i, N+1, i):
            isPrime[j] = False

It turns out we can extend the sieve in a clever way — we can store the smallest prime factor for each number instead of a simple boolean value. I read about this approach in this GeeksForGeeks post.

N = 1000  # Upper bound on primes to generate, as per the problem statement
smallestPrimeFactor = [i for i in range(N+1)]
for i in range(2, N):
    if smallestPrimeFactor[i] == i:  # prime!
        for j in range(2*i, N+1, i):
            if smallestPrimeFactor[j] == j:
                smallestPrimeFactor[j] = i

Compare this code to the previous code and notice the extra if condition on line 6. Why do we need it? smallestPrimeFactor[j] != j means that in a previous iteration, for a smaller value of i, we had updated smallestPrimeFactor[j]. And if that was the case, then we don't want to overwrite that value with this current, larger value of i.

Fair enough. But why store the smallest prime factor for each number at all? Because it gives us a quick way to factorize any number lying between and :

# Returns the prime factorization of `num` in sorted ascending order.
def factorize(num: int, smallestPrimeFactor: list[int]) -> list[int]:
    prime_factors = []
    while num > 1:
        prime = smallestPrimeFactor[num]
        prime_factors.append(prime)
        num //= prime
    return prime_factors

It's straightforward to extend this function to instead dump all prime factors for a given number into a set external to the function. When we're done calling this function for every number in the array, we will have a set consisting of all of the distinct prime factors of the product of the entire array!

distinctPrimes = set()
def factorizeAndUpdateDistinctSet(num: int, smallestPrimeFactor: list[int], distinctPrimes: set[int]):
    while num > 1:
        prime = smallestPrimeFactor[num]
        distinctPrimes.add(prime)
        num //= prime

And putting it all together, we have a working solution:

N = 1000
smallestPrimeFactor = [i for i in range(N+1)]
for i in range(2, N+1):
    if smallestPrimeFactor[i] == i:  # prime!
        for j in range(2*i, N+1, i):
            if smallestPrimeFactor[j] == j:
                smallestPrimeFactor[j] = i

distinctPrimes = set()
def factorizeAndUpdateDistinctSet(num, smallestPrimeFactor, distinctPrimes):
    while num > 1:
        prime = smallestPrimeFactor[num]
        distinctPrimes.add(prime)
        num //= prime

for num in nums:
    factorizeAndUpdateDistinctSet(num, smallestPrimeFactor, distinctPrimes)
return len(distinctPrimes)

...and it's much faster than before!

Our runtime now beats 92% of all submissions.
Our runtime now beats 92% of all submissions.

Let's analyze the runtime for this algorithm. Again, I'm going to assume that integer division (//) is constant time to simplify calculations. Again, not true, but interestingly, integer division is about as fast as the modulo operation is, so we're making the "same" deliberate error when comparing these two algorithms.

  1. In the phase where we compute the smallest prime factor for each number, we update each element of the smallestPrimeFactor array at most once. So that's , because that's how many elements the array has.

  2. Next, we factorize each member of the array, spending time on each, where is the maximum number of not-necessarily-distinct prime factors that any element of the array has. It would be nice if we could get a not-too-coarse upper bound on , to keep things simple. As it happens, we can! To get as a high value as possible for the number of not-necessarily-distinct prime factors of a number, we can just keep multiplying by the smallest prime number there is, . Notice how has 9 not-necessarily-distinct prime factors, and so we can confidently say that no number under has more than 10 not-necessarily-distinct prime factors.

  3. In general, a nice not-too-coarse upper bound for would be And so our running time for the factorization phase is .

So our total runtime for this algorithm is , which is just . Much better than our earlier runtime!

Our space complexity is :

Optimization: skipping a few iterations🔗

Every time we encounter a prime number, we "cross out" (set the corresponding index in smallestPrimeFactor) all of its multiples. To do so we'd move the index from all the way up to (upper bound exclusive) by steps of , where is the upper bound on primes that we want to generate.

But there's a nice optimization available to us. Consider a prime number . By the time our iteration variable reaches , we have already processed every multiple of every prime that was smaller than . For example:

To be precise, by the time reaches , we have already processed every multiple of smaller than . We know for a fact that we haven't already processed itself, because is prime and therefore would not be part of any of the previous iterations.

And so we can start our iteration from instead of , skipping a few if checks that would always have failed (because smallestPrimeFactor[i] would've been set to something other than i by that point).

N = 1000  # as per the problem statement
smallestPrimeFactor = [i for i in range(N+1)]
for i in range(2, N):
    if smallestPrimeFactor[i] == i:  # prime!
        for j in range(i*i, N+1, i):
            if smallestPrimeFactor[j] == j:
                smallestPrimeFactor[j] = i

Closing thoughts🔗

Thanks for reading!

[1] It isn't constant time, but as it turns out, if implemented correctly, it's as fast as other operations like multiplication and computing modular remainders. This allows us to simplify our runtime analysis.