Обратные числа диофанта

Опубликовано 06.03.2021
Последние изменения 24.10.2021

В этой статье рассмотрим решение задач 108 и 110 Project Euler

Задача 108 с projecteuler.net звучит так:

В представленном ниже равенстве \( x \), \( y \) и \( n \) являются целыми положительными числами.

\[ \frac{1}{x} + \frac{1}{y} = \frac{1}{n} \]

При \( n = 4 \) существуют ровно три различных решения:

\[ \begin{array}{l} \frac{1}{5} + \frac{1}{20} = \frac{1}{4}\\ \\ \frac{1}{6} + \frac{1}{12} = \frac{1}{4}\\ \\ \frac{1}{8} + \frac{1}{8} = \frac{1}{4} \end{array} \]

Каково наименьшее значение \( n \), при котором число различных решений превышает одну тысячу?

Примечание: Данная задача является более простым вариантом 110-й задачи. Перед тем, как браться за нее, настоятельно рекомендуем Вам решить данную задачу.

В 110 задаче требуется найти наименьшее \( n \), при котором число различных решений превышает 4 миллиона.

Обозначим \( s(n) \) число различных решений уравнения

\[ \frac{1}{x} + \frac{1}{y} = \frac{1}{n} \]

Задача состоит в нахождении наименьшего \( n \), для которого \( s(n)>1000 \).

Найдём связь между \( x \) и \( y \):

\[ \frac{1}{y} = \frac{1}{n}-\frac{1}{x},\\ y = \frac{xn}{x-n} = n + \frac{n^2}{x-n}, \]
\[ \left\{ \begin{array}{l} x = n + k,\\ y = n + n^2/k \end{array} \right. \]

Отсюда, \( s(n) \) равно числу делителей \( n^2 \) из отрезка \( [1, n \)]. Это число можно вычислить непосредственно, проверяя остаток от деления \( n^2 \) на каждое из чисел этого диапазона:

def solutions(n):
    return sum(n**2 % k == 0 for k in range(1, n+1))

А теперь можно в цикле пробежаться по натуральным числам и остановиться на первом, у которого число решений будет больше 1000:

def minimal(s):
    n = 1
    while solutions(n) <= s:
        n += 1
    return n

Что-то долго считается. Давайте проведём замеры времени на меньших входных данных:

2021-03-10T21:36:52.648400 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/ 10 0 10 1 10 2 10 3 Количество разложений 10 −5 10 −3 10 −1 10 1 10 3 Время, с

Для \( s=1000 \) вычисления должны занять \( \sim 10^4 \) секунд, т.е пару-тройку часов, что конечно не устраивает нас. Можно написать этот код на C, что позволит уложиться в приемлемое время для 1000, но проблему с 4000000 из 110 задачи это не решит.

Почему всё так плохо? Функция minimal(s) имеет сложность \( O(n^2) \), где \( n \) — это искомое число, при этом \( n \) быстро растёт с ростом \( s \):

2021-03-10T21:38:47.917051 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/ 10 0 10 1 10 2 Количество разложений  s 10 1 10 2 10 3 10 4 Наименьшее , для которого число разложений превышает  ns

Давайте внимательно посмотрим на зависимость \( s(n) \):

2021-03-11T07:58:36.269474 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/ 0 20 40 60 80 100 n 0 5 10 15 20 Количество разложений () sn

Функция ведёт себя очень интересно. Можно выделить числа с аномально большими значениями \( s(n) \) по отношению к своим соседям: \( 6,\ 12,\ 24,\ 30,\ 60,\ldots \) Также видно большие группы чисел с одинаковыми значениями. Например, \( s = 2 \) для \( 2,\ 3,\ 5,\ 7,\ 11,\ 13,\ 17,\ldots \) Нетрудно увидеть, что это простые числа. Действительно, у квадрата простого числа \( p \) только \( 3 \) делителя — \( 1,\ p\ \text{и}\ p^2 \), поэтому \( p^2 = 1\cdot p^2 = p\cdot p \) и есть только 2 варианта разложения \( 1/p \):

\[ \frac{1}{p} = \frac{1}{p+1} + \frac{1}{p+p^2},\quad \frac{1}{p} = \frac{1}{2p} + \frac{1}{2p} \]

Ещё бросаются в глаза группы с \( s = 5 \) (\( 6,\ 10,\ 14,\ 15,\ 16,\ldots \)) и \( s = 8 \) (\( 12,\ 18,\ 20,\ 28\ldots \)).

В первой группе все числа либо имеют 2 простых делителя (\( 6=2\cdot 3 \), \( 15=3\cdot 5 \)), либо 1, но входящий в 4 степени (\( 16 = 2^4 \), \( 81 = 3^4 \)). Числа вида \(n^2 = (p_1\cdot p_2)^2 \) имеет 9 делителей: \( 1,\ p_1,\ p_1^2,\ p_2,\ p_2^2,\ p_1\cdot p_2,\ p_1^2\cdot p_2,\ p_1\cdot p_2^2,\ p_1^2\cdot p_2^2 \). Аналогично, 9 делителей имеет число \( \left(p^4\right)^2 = p^8 \). А раз число \( n^2 \) имеет 9 делителей, но они образуют 5 пар вида \( a_1\cdot a_2 = n^2 \), т.к \( n \) будет парой самому себе.

Во второй группе представлены числа вида \( p_1^2\cdot p_2 \). Кажется, всё дело в простых множителях и степенях, в которых они входят в разложение данного числа.

А что насчёт \( 6,\ 12,\ 24,\ 30,\ 60,\ldots \)? Почему для них \( s(n) \) заметно больше, чем у соседей? Посмотрим их разложения:

\[ \begin{array}{rcl} 6 & = & 2\cdot 3,\\ 12 & = & 2^2\cdot 3,\\ 24 & = & 2^3\cdot 3,\\ 30 & = & 2\cdot 3\cdot 5,\\ 60 & = & 2^2\cdot 3\cdot 5. \end{array} \]

Это подтверждает мысль, что нужно смотреть на разложение числа \( n \) на простые множители.

Согласно основной теореме арифметики, число \( n \) можно разложить на простые множители

\[ n = \prod_{i=1}^m p_i^{d_i}, \]

где \( m \) — число простых делителей \( n \), \( p_i \) — простые числа. Тогда

\[ n^2 = \prod_{i=1}^m p_i^{2d_i} \]

Теперь посчитаем количество делителей \( n^2 \). Число является делителем, если любой его простой множитель \( p_i \) встречается в \( n^2 \) в степени, не превышающей \( 2d_i \). То есть для каждого простого множителя \( p_i \) есть \( |\{0, 1, \ldots, 2d_i\}| = 2d_i+1 \) вариантов, а всего делителей \( \displaystyle\prod_{i=1}^m(2d_i+1) \). Также, заметим, что число всех делителей \( n^2 \) равно \( 2s(n)-1 \). Отсюда,

\[ 2s(n) - 1 = \prod_{i=1}^m(2d_i+1),\\ s(n) = \frac{1}{2}\left[1+\prod_{i=1}^m(2d_i+1)\right]. \]

Получаем следующую задачу минимизации

\[ \left\{ \begin{array}{l} \displaystyle\prod_{i=1}^m p_i^{d_i} \to \min,\\ \displaystyle\prod_{i=1}^m(2d_i+1) > 1999, \end{array} \right. \]

Добавление нового простого множителя к числу увеличивает число его делителей в \( 2\cdot 1 + 1 = 3 \) раза. Поэтому можно достаточно просто подобрать число, удовлетворяющее условию \( \displaystyle\prod_{i=1}^m(2d_i+1) > 1999 \):

PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47]
divisors = 1
n = 1
for p in PRIMES:
    n *= p
    divisors *= 3
    if divisors > 1999:
        break
print(n)

Нетрудно видеть, что это произведение первых \( \lceil\log_3 2s\rceil \) простых чисел. Но вряд ли оно будет наименьшим среди всех чисел, удовлетворяющих ему. Поэтому нам нужно перебрать различные варианты \( \{d_i\} \) не длиннее построенного выше.

Каждое из значений \( d_i \) имеет смысл перебирать в диапазоне \( [0, \log_{d_i}n \)], так как при больших значениях \( d_i \) будет получаться число, превышающее \( n \), что нас не устраивает. Получаем вот такой вот перебор

from math import prod, log
from itertools import product

PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53]


def number(degrees):
    return prod(p ** d for p, d in zip(PRIMES, degrees))


def divisors(degrees):
    return prod(2 * d + 1 for d in degrees)


def minimal(s):
    m = int(log(2*s)/log(3)) + 1
    n = prod(PRIMES[:m])

    ranges = [range(0, int(log(n) / log(PRIMES[i])) + 1) for i in range(m)]
    for degrees in product(*ranges):
        if divisors(degrees) > 2 * s - 1:
            n_ = number(degrees)
            if n_ < n:
                n = n_
    return n


if __name__ == '__main__':
    import sys
    s = int(sys.argv[1])
    print(minimal(s))

Функция product(*iterables) возвращает итератор по декартову произведению множеств-аргументов. В нашем случае это позволяет нам перебрать наборы степеней простых множителей \( \{d_i\} \), являющиеся элементами пространства \( \displaystyle\prod_{i=1}^m \{0, 1,\ldots, \lceil \log_{p_i}n\rceil\} \).

minimal(1000) выполняется за 9 с. Искомое число \(180180 = 2^2 \cdot 3^2 \cdot 5^1 \cdot 7^1 \cdot 11^1 \cdot 13^1 \) имеет \( 5^2 \cdot 3^4 = 2025 \) делителей и, соответственно, 1013 способов разложения.

2021-03-10T21:36:53.741224 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/ 10 0 10 1 10 2 10 3 Количество разложений 10 −5 10 −3 10 −1 10 1 10 3 Время, с

Мы смогли решить 108 задачу, но 110 таким способом явно не решить, нужно что-то получше. Проблема в том, что \( \displaystyle\prod_{i=1}^m \{0, 1,\ldots, \lceil \log_{p_i}n\rceil\} \) содержит огромное количество заведомо неподходящих наборов \( \{d_i\} \).

Количество делителей числа зависит только от количества и значений степеней его простых делителей и не зависит от самих делителей. Так, например, \( 12 = 2^2 \cdot 3^1 \) и \( 75 = 3^1 \cdot 5^2 \) имеют по

\[ s(12) = s(75) = (1 + (2 \cdot 2 + 1) \cdot (2 \cdot 1 + 1)) / 2 = 8 \]

способов разложения, но наименьшим из всех чисел с \( s(n) = 8 \) является 12, так как 2 и 3 — наименьшие из возможных простых множителей. Таким образом, для получения наименьшего \( n \), соответствующего данному набору \( \{d_i\} \) необходимо отсортировать \( \{d_i\} \) по убыванию, а в качестве \( \{p_i\} \) выбрать первые \( m \) простых чисел в порядке возрастания.

То есть можно заметно ускорить перебор, производя его только по подходящим наборам \( \{d_i\} \).

from math import prod


PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23,
          29, 31, 37, 41, 43, 47, 53, 59,
          61, 67, 71, 73, 79, 83, 89, 97]


def number(degrees):
    return prod(p ** d for p, d in zip(PRIMES, degrees))


def divisors(degrees):
    return prod(2 * d + 1 for d in degrees)


def minimal(s):
    # constructive initial guess
    result = []
    while divisors(result) <= 2 * s - 1:
        result.append(1)
    n = number(result)
    m = len(result)

    # let's get it
    i = 0
    guess = [0] * m
    iters = 0
    while i > -1:
        iters += 1
        guess[i] += 1
        n_ = number(guess)

        if (i and guess[i] > guess[i - 1]) or n_ > n:
            # if state is unordered, make it ordered
            # by setting value to 0 and move to previous degree

            # if current value is greater, than already found,
            # also go to smaller numbers
            guess[i] = 0
            i -= 1
            continue

        if divisors(guess) + 1 > 2 * s:
            # if smaller than previous result, update it
            if n_ < n:
                result = guess[:]
                n = n_
            # we don't need to make number bigger,
            # because it already has at least s solutions
            guess[i] = 0
            i -= 1
        else:
            # not enough solutions, go to next factor
            if i < m - 1:
                i += 1
    return result, iters


if __name__ == '__main__':
    import sys

    def to_string(degrees):
        return ' • '.join(f'{p}^{d}' for p, d in zip(PRIMES, degrees) if d)

    s = int(sys.argv[1])

    result, iters = minimal(s)
    n = number(result)

    print(f'Solution: {n} = {to_string(result)}')
    print(f'It has {(divisors(result) + 1)//2} decompositions')
    print(f'Done in {iters} iters')

Этот подход, позволяет решить 110 задачу за 154 мс!

2021-03-10T21:36:54.797249 image/svg+xml Matplotlib v3.3.4, https://matplotlib.org/ 10 0 10 1 10 2 10 3 10 4 10 5 10 6 10 7 Количество разложений 10 −5 10 −4 10 −3 10 −2 10 −1 Время, с