dairydemon.net

Sieving Multiplicative Functions

Posted

The sieve of Eratosthenes is a well-known algorithm for computing all the prime numbers up to a given bound. There are also well-known variants on the sieve. In particular, a segmented version can be implemented to limit memory usage.

Furthermore, a similar approach can be used to "sieve" the values of multiplicative functions. For instance, this page discusses computing values of Euler's totient function with an approach that results in the same time complexity as the standard sieve.

This Codeforces page also discusses an approach for sieving any multiplicative function. However, the provided code has some downsides: it's not segmented, and the modifications needed for a given multiplicative function are not always obvious.

Here, I'll discuss a variant of the sieve that

Demo

Note that for large ranges, outputting the full result will chew up a lot of memory.

Multiplicative functions

Multiplicative functions are functions with a special property: if two numbers a and b are coprime (i.e. their greatest common divisor is 1), then f ( a b ) = f ( a ) f ( b ) . This means if we can factor a number n into coprime integers x and y, we can find f(n) by instead finding f(x) and f(y) and multiplying them.

Going a step further, if we have a prime factorization of a number n, along with a formula for f at prime powers, we can easily compute f(n) by evaluating f at each prime power and multiplying.

It turns out there are lots of multiplicative functions, including many important functions in number theory, such as:

Furthermore, many of these functions have very simple formulas for prime power inputs, making them easy to evaluate if you have a prime factorization.

An example

Suppose we want to know how many divisors 120 has. We could try counting every divisor by hand, but that would be pretty tedious and error-prone. We could also write a simple loop to have a computer find the answer, but that approach wouldn't scale well for larger numbers. Instead, we'll take advantage of the fact that the number of divisors function is multiplicative to find the answer.

We'll use d(n) to denote the function. It has a convenient formula for prime powers:1

d ( p k ) = k + 1

This lets us answer our question by factoring 120:2

d ( 120 ) = d ( 2 3 × 3 × 5 ) = d ( 2 3 ) d ( 3 ) d ( 5 ) = 4 × 2 × 2 = 16

Sure enough, the 16 divisors are 1, 2, 3, 4, 5, 6, 8, 10, 12, 15, 20, 24, 30, 40, 60, and 120.

Suppose, however, that we want to find the number of divisors for each number up to 120. There's no general algorithm to efficiently factor a number, so it isn't a great idea to factor every number to get the solution. We'll take a different approach, but it will still take advantage of the multiplicative property.

What about additive functions?

There's another class of functions called additive functions with similar properties to multiplicative functions, namely that f ( a b ) = f ( a ) + f ( b ) whenever a and b are coprime. We can sieve these functions in nearly the exact same manner as multiplicative functions (e.g. the prime omega functions in the demo are additive). When we get to the code, you'll see the provided functions take a boolean indicating whether the function is additive to apply the proper logic.

The algorithm

Here's the routine for sieving multiplicative functions:

function sieveSegment(l, r, f, primes, additive) {
	const [sieve, quo] = initArrays(l, r, additive);

	for (let i = 0; i < primes.length; i++) {
		if (i*i > r) {
			break;
		}

		if (!primes[i]) {
			continue;
		}

		sievePowers(i, l, r, f, sieve, quo, additive);
	}

	unusuals(f, sieve, quo, additive);

	return sieve;
}
This function performs a segmented sieve on any multiplicative (or additive) function.

l and r are the lower and upper bounds for the sieve, respectively. f is the function we are sieving. primes is an array indicating all the prime numbers up to the square root of r, which we can precompute with the standard sieve. Finally, additive is a boolean indicating if the function is additive or multiplicative.

The first step of the function is to initialize two arrays of size r - l called sieve and quo. sieve is what ultimately gets returned and stores the values of the function we're sieving. quo is an internal array we use to finalize the values of sieve at the very end of the function.

function initArrays(l, r, additive) {
	const sieve = Array(r - l);
	const quo = Array(r - l);
	for (let i = 0; i < r-l; i++) {
		quo[i] = l + i;
		if (additive) {
			sieve[i] = 0;
		} else {
			sieve[i] = 1;
		}
	}

	return [sieve, quo];
}
The initArrays function initializes arrays that keep track of the value of the function we're sieving and the running quotients.

Say we want to calculate the number of divisors function from 30 to 60. After initArrays runs, here's what the sieve and quo arrays look like:

Initial values of sieve and quo
n 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
sieve 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
quo 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59

Next, we'll call the following functions for every prime number in the primes array. This is where the function spends most of its time.

function sievePowers(p, l, r, f, sieve, quo, additive) {
	let i = 1;
	for (let q = p; q < r; q *= p) {
		const y = f(p, i);
		sieveMultiples(p, q, l, r, y, sieve, quo, additive);
		i++;
	}
}

function sieveMultiples(p, q, l, r, y, sieve, quo, additive) {
	const low = Math.ceil(l / q);
	for (let i = low * q; i < r; i += q) {
		if (i % (p*q) === 0) {
			continue;
		}

		if (additive) {
			sieve[i - l] += y;
		} else {
			sieve[i - l] *= y;
		}

		quo[i - l] /= q
	}
}
These functions are where most of the computation happens.

In short, these functions evaluate our function at prime powers only, then update the values in the sieve array for non-prime powers using the multiplicative property. We also update the quo array by dividing out the factors we find along the way.

Here's what the arrays will look like after sieving with 2:

Values of sieve and quo after sieving 2
n 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
sieve 2 1 6 1 2 1 3 1 2 1 4 1 2 1 3 1 2 1 5 1 2 1 3 1 2 1 4 1 2 1
quo 15 31 1 33 17 35 9 37 19 39 5 41 21 43 11 45 23 47 3 49 25 51 13 53 27 55 7 57 29 59

And here's what the arrays will look like after subsequently sieving with 3:

Values of sieve and quo after sieving 3
n 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
sieve 4 1 6 2 2 1 9 1 2 2 4 1 4 1 3 3 2 1 10 1 2 2 3 1 8 1 4 2 2 1
quo 5 31 1 11 17 35 1 37 19 13 5 41 7 43 11 5 23 47 1 49 25 17 13 53 1 55 7 19 29 59

After we've gone through the entire primes array, the two arrays will look like this:

Values of sieve and quo just before calling unusuals
n 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
sieve 8 1 6 2 2 4 9 1 2 2 8 1 8 1 3 6 2 1 10 3 6 2 3 1 8 2 8 2 2 1
quo 1 31 1 11 17 1 1 37 19 13 1 41 1 43 11 1 23 47 1 1 1 17 13 53 1 11 1 19 29 59

At this point, there's one last step. Numbers can have up to one prime factor greater than their own square root. When that happens, they're called unusual numbers.3 But since we only require the primes array to hold primes up to the square root of r, any unusual number will have one last prime factor that we never evaluated. Fortunately, we can identify those numbers easily using the quo array, so we just need to do one more pass through that array to finalize everything.

function unusuals(f, sieve, quo, additive) {
	for (let i = 0; i < quo.length; i++) {
		const q = quo[i];
		if (q === 1) {
			continue;
		}

		if (additive) {
			sieve[i] += f(q, 1);
		} else {
			sieve[i] *= f(q, 1);
		}
	}
}
The last step is to finalize the values of any unusual numbers (numbers n with a prime factor greater than sqrt(n)).

Analysis

It's pretty easy to see that initArrays and unusuals are O(r-l).4 Analyzing sievePowers and its impact is a bit more involved.

Looking at sieveMultiples, its main loop performs a fixed number of arithmetic operations O ( r l q ) times. sieveMultiples, in turn, is called by sievePowers at values of q=p, q=p2, q=p3, and so on until pk exceeds r. This means the total time complexity of sievePowers is4

O ( r l p + r l p 2 + r l p 3 + ) = O ( r l p )

With this in mind, we can evaluate sieveSegment, which calls sievePowers for every prime less than the square root of r. Thanks to Euler, we know that the sum of the reciprocal of primes up to k is asymptotic to log log k . Therefore, the time complexity of sieveSegment is

O ( r l 2 + r l 3 + r l 5 + ) = O ( ( r l ) log log r ) = O ( ( r l ) log log r )

This is the same complexity as the method given here for sieving the totient function.

Final thoughts

This all ends up being more lines of code than writing a single sieve for, say, totient or the number of divisors. However, if you want to sieve multiple functions, it can be tricky to make the right modifications to an existing sieve to work with a different function.

In contrast, with this approach, you can use the same routine and simply pass a different function as an argument. For example, here's all the functions available in the above demo:

function numDivisors(p, k) {
	return k + 1;
}

function sumDivisors(p, k) {
	return (p**(k+1) - 1) / (p - 1)
}

function totient(p, k) {
	return p**(k-1) * (p - 1);
}

function radical(p, k) {
	return p;
}

function littleOmega(p, k) {
	return 1;
}

function bigOmega(p, k) {
	return k;
}

function mobius(p, k) {
	if (k === 1) {
		return -1;
	}

	return 0;
}
Functions that can be sieved in the demo.

Many multiplicative and additive functions have simple formulas for prime powers, which makes adding a new function to sieve very easy with this approach.

Footnotes

  1. 1 This is because the only numbers that divide pk are 1, p, p2, ..., up to pk, which is k+1 total numbers.
  2. 2 You might be thinking at this point that factoring is also not very efficient. Sit tight; this is only an example to get you comfortable with multiplicative functions. We're going to take advantage of this property later.
  3. 3 "Unusual" is actually a bit of a misnomer - most numbers have a prime factor greater than their square root, so unusual numbers are actually more common than so-called "usual" numbers!
  4. 4 Well, technically unusuals and sievePowers are also dependent on the time complexity of the function we're sieving, but generally that complexity isn't too bad, so I'm going to gloss over that bit.