Python: генерация разложений числа на слагаемые

Раздел: Алгоритмы -> Разложение на слагаемые

Разложение числа на слагаемые (integer partitions) - задача комбинаторики, которая находит все способы представить натуральное число в виде суммы других натуральных чисел. Порядок слагаемых не учитывается. В Python существует несколько подходов для решения этой задачи. В этой статье рассматриваются различные алгоритмы и их реализация, от простых рекурсивных до итеративных, а также способы подсчета количества разбиений.

Методы разложения числа на слагаемые в Python

Наиболее эффективное решение: рекурсивный генератор с ограничением максимального слагаемого

Этот метод позволяет получить все уникальные разбиения числа, избегая дубликатов, за счет передачи в рекурсию ограничения на максимальное слагаемое. Каждое разбиение возвращается в виде списка чисел в невозрастающем порядке.


def partitions(n, max_part=None):
    if max_part is None:
        max_part = n
    if n == 0:
        yield []
    else:
        for i in range(min(max_part, n), 0, -1):
            for p in partitions(n - i, i):
                yield [i] + p

for p in partitions(5):
    print(p)
  

слагаемые числа python (разложение числа на слагаемые в python)

[5]
[4, 1]
[3, 2]
[3, 1, 1]
[2, 2, 1]
[2, 1, 1, 1]
[1, 1, 1, 1, 1]
  

Пояснение: Параметр max_part ограничивает слагаемые, чтобы не генерировать перестановки. Цикл идет от min(max_part, n) вниз до 1, гарантируя невозрастающий порядок. Для n=0 возвращается пустой список.

Проблемы и типичные ошибки: Глубина рекурсии для больших n (например, 1000) может превысить лимит. Решение - увеличить лимит через sys.setrecursionlimit(10000). Другая ошибка - забыть передать i как новый max_part, что приводит к дубликатам. Для n=5 это даст также [1, 4], [2, 3] и т.д.

Цель использования: получение всех разбиений для чисел до 100-200. При больших n количество разбиений растет экспоненциально, и любой алгоритм будет работать долго.

Вариант 1: Простой рекурсивный перебор (с дубликатами)

Вопрос: "Как получить все последовательности слагаемых, включая перестановки?"

Следующий код генерирует все упорядоченные суммы (композиции), но для разбиений (без учета порядка) он выдает дубликаты. Это неправильный подход для задачи разложения на слагаемые, но может быть полезен, если порядок важен.


def wrong_partitions(n):
    if n == 0:
        yield []
    else:
        for i in range(1, n+1):
            for p in wrong_partitions(n-i):
                yield [i] + p

for p in wrong_partitions(4):
    print(p)
  
[1, 1, 1, 1]
[1, 1, 2]
[1, 2, 1]
[1, 3]
[2, 1, 1]
[2, 2]
[3, 1]
[4]
  

Проблемы: Разбиение [1, 3] и [3, 1] считаются разными, хотя для разложения на слагаемые они эквивалентны. Количество вариантов - 2^(n-1), что быстро растет. Для n=20 число композиций превышает 500 тысяч, что может вызвать проблемы с памятью и временем.

Решение: если требуется именно разбиение без учета порядка, необходимо вводить ограничение на максимальное слагаемое, как в предыдущем разделе.

Вариант 2: Динамическое программирование для подсчета количества разбиений

Вопрос: "Как вычислить количество способов разложить число на слагаемые без вывода самих разбиений?"

Классический алгоритм на основе ДП позволяет найти число разбиений (partition number) за O(n^2) времени. Идея: dp[j] - количество способов составить сумму j, используя слагаемые от 1 до i (по мере прохода i).


def count_partitions(n):
    dp = [0] * (n + 1)
    dp[0] = 1
    for i in range(1, n + 1):
        for j in range(i, n + 1):
            dp[j] += dp[j - i]
    return dp[n]

print(count_partitions(5))
print(count_partitions(10))
  
7
42
  

Пояснение: Начальное состояние dp[0]=1. Для каждого i (слагаемое) обновляем dp[j] суммой с использованием dp[j-i]. Итоговое dp[n] содержит количество разбиений.

Ограничения: Метод не выводит сами разбиения. Для больших n (> 1000) числа разбиений становятся огромными (выходят за пределы int в Python, но Python поддерживает длинные числа, поэтому можно). Однако время O(n^2) может быть значительным для n=10^5. Альтернатива - формула Рамануджана-Харди, но она сложна в реализации.

Цель использования: когда требуется только количество, например, в комбинаторных расчетах или для оценки сложности.

Вариант 3: Использование библиотеки sympy

Вопрос: "Как быстро получить разбиения, используя встроенные средства Python?"

Sympy предоставляет функцию partitions, которая генерирует разбиения в виде словарей (слагаемое - количество).


from sympy.utilities.iterables import partitions

for p in partitions(5):
    print(p)
  
{5: 1}
{4: 1, 1: 1}
{3: 1, 2: 1}
{3: 1, 1: 2}
{2: 2, 1: 1}
{2: 1, 1: 3}
{1: 5}
  

Пояснение: Ключи - слагаемые, значения - количество повторений. Это компактное представление. Можно преобразовать в список списков.

Проблемы: Sympy - сторонняя библиотека, требует установки. Для больших n генерация может быть медленнее собственной эффективной реализации. Кроме того, результаты представлены в необычном формате, что может потребовать дополнительной обработки.

Цель: быстрый прототип или когда не нужно заботиться об оптимизации.

Вариант 4: Итеративный алгоритм (next partition)

Вопрос: "Как получить все разбиения без рекурсии, используя итеративный подход?"

Этот алгоритм основан на представлении разбиения в виде невозрастающей последовательности и переходе к следующему разбиению по правилу. Он не использует рекурсию и эффективен по памяти.


def partitions_iterative(n):
    a = [0] * (n + 1)
    k = 1
    a[1] = n
    while k > 0:
        # текущее разбиение a[1..k]
        yield a[1:k+1]
        # находим следующее
        rem_val = 0
        while k >= 1 and a[k] == 1:
            rem_val += a[k]
            k -= 1
        if k == 0:
            break
        a[k] -= 1
        rem_val += 1
        # распределяем остаток
        while rem_val > a[k]:
            a[k+1] = a[k]
            rem_val -= a[k]
            k += 1
        a[k+1] = rem_val
        k += 1

for p in partitions_iterative(5):
    print(p)
  
[5]
[4, 1]
[3, 2]
[3, 1, 1]
[2, 2, 1]
[2, 1, 1, 1]
[1, 1, 1, 1, 1]
  

Пояснение: Алгоритм поддерживает текущее разбиение в виде списка a[1..k]. В цикле он выдает текущее разбиение, затем ищет элемент, который можно уменьшить, и перераспределяет остаток так, чтобы слагаемые оставались невозрастающими.

Проблемы: Код сложнее для понимания. Легко допустить ошибку в индексах и условиях. При n=0 необходимо обрабатывать отдельно. Алгоритм требует аккуратной реализации.

Цель: когда требуется избежать рекурсии (например, при ограничении на глубину стека), или для очень больших n, где рекурсия может привести к переполнению стека.

Вариант 5: Генерация композиций (упорядоченных сумм)

Вопрос: "Как получить все представления числа в виде суммы, где порядок слагаемых важен?"

Композиции числа - это все последовательности положительных целых чисел, дающие в сумме n. Их количество равно 2^(n-1). Реализация с рекурсией без ограничения max_part дает этот результат.


def compositions(n):
    if n == 0:
        yield []
    else:
        for i in range(1, n+1):
            for comp in compositions(n-i):
                yield [i] + comp

list(compositions(4))
  
[[1, 1, 1, 1], [1, 1, 2], [1, 2, 1], [1, 3], [2, 1, 1], [2, 2], [3, 1], [4]]
  

Пояснение: Рекурсия перебирает первое слагаемое от 1 до n и рекурсивно генерирует композиции для остатка. Отсутствие ограничения порождает разные порядки.

Проблемы: Огромное количество вариантов (2^(n-1)), что делает метод неприменимым для n > 20-25 без дополнительных ограничений. Возможно переполнение стека при больших n.

Цель: когда порядок слагаемых имеет значение, например, при моделировании последовательности шагов.

Расширенный пример 1: разложение с ограничением по количеству слагаемых

Часто требуется получить разбиения, содержащие не более k слагаемых. Модифицируем рекурсивную функцию, добавив параметр max_len.

Пример

def partitions_max_len(n, max_len, max_part=None):
    if max_part is None:
        max_part = n
    if n == 0:
        yield []
    elif max_len > 0:
        for i in range(min(max_part, n), 0, -1):
            for p in partitions_max_len(n - i, max_len - 1, i):
                yield [i] + p

# Пример: разбиения 5 не более чем из 3 слагаемых
for p in partitions_max_len(5, 3):
    print(p)
[5]
[4, 1]
[3, 2]
[3, 1, 1]
[2, 2, 1]

Пояснение: Параметр max_len уменьшается при каждом добавлении слагаемого. Когда max_len достигает 0, рекурсия прекращается. Это позволяет отсечь разбиения с большим количеством единиц.

Расширенный пример 2: разложение на различные слагаемые

Иногда требуется, чтобы все слагаемые были различны. В рекурсии достаточно изменить ограничение: максимальное слагаемое меньше текущего (i-1).

Пример

def partitions_distinct(n, max_part=None):
    if max_part is None:
        max_part = n
    if n == 0:
        yield []
    else:
        for i in range(min(max_part, n), 0, -1):
            for p in partitions_distinct(n - i, i - 1):
                yield [i] + p

for p in partitions_distinct(8):
    print(p)
[8]
[7, 1]
[6, 2]
[5, 3]
[5, 2, 1]
[4, 3, 1]

Пояснение: Передача i-1 гарантирует, что следующее слагаемое будет строго меньше предыдущего. Для 8 получаются все разбиения с уникальными слагаемыми.

Расширенный пример 3: разложение на нечетные слагаемые

Ограничим перебор только нечетными числами.

Пример

def partitions_odd(n, max_part=None):
    if max_part is None:
        max_part = n
    if n == 0:
        yield []
    else:
        # перебираем только нечетные числа, не превышающие max_part
        start = min(max_part, n)
        if start % 2 == 0:
            start -= 1
        for i in range(start, 0, -2):
            for p in partitions_odd(n - i, i):
                yield [i] + p

for p in partitions_odd(10):
    print(p)
[9, 1]
[7, 3]
[7, 1, 1, 1]
[5, 5]
[5, 3, 1, 1]
[5, 1, 1, 1, 1, 1]
[3, 3, 3, 1]
[3, 3, 1, 1, 1, 1]
[3, 1, 1, 1, 1, 1, 1, 1]
[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

Пояснение: Шаг -2 в цикле гарантирует перебор только нечетных чисел. Результат включает все разбиения, состоящие из нечетных слагаемых.

Расширенный пример 4: разложение с использованием заданного набора слагаемых

Задача: найти все способы представить число 12 суммой чисел из множества {2, 3, 5} (неограниченное количество).

Пример

def partitions_from_set(target, allowed, max_part=None):
    if max_part is None:
        max_part = target
    if target == 0:
        yield []
    else:
        for i in allowed:
            if i > max_part or i > target:
                continue
            for p in partitions_from_set(target - i, allowed, i):
                yield [i] + p

allowed = [2, 3, 5]
for p in sorted(partitions_from_set(12, allowed)):
    print(p)
[5, 5, 2]
[5, 3, 2, 2]
[5, 2, 2, 2, 1]  # 1 нет в allowed? Ошибка! Надо исправить

Примечание: В примере выше случайно попала 1. Необходимо убедиться, что allowed не содержит 1. Правильный список: {2,3,5}. Результат для 12:

[5, 5, 2]
[5, 3, 2, 2]
[3, 3, 3, 3]
[3, 3, 2, 2, 2]
[2, 2, 2, 2, 2, 2]

Пояснение: Функция перебирает только числа из множества allowed, отсортированные по убыванию, и передает ограничение i. Это гарантирует невозрастающий порядок и отсутствие дубликатов.

Расширенный пример 5: использование декоратора lru_cache для подсчета количества разбиений

Рекурсивная функция для подсчета количества разбиений может быть ускорена мемоизацией.

Пример

from functools import lru_cache

@lru_cache(maxsize=None)
def partition_count(n, max_part=None):
    if max_part is None:
        max_part = n
    if n == 0:
        return 1
    if max_part == 0:
        return 0
    # рекуррентное соотношение: p(n, m) = p(n-m, m) + p(n, m-1)
    if n < max_part:
        return partition_count(n, n)
    return partition_count(n - max_part, max_part) + partition_count(n, max_part - 1)

print(partition_count(50))
print(partition_count(100))
204226
190569292

Пояснение: Используется стандартная рекуррентная формула для числа разбиений. Декоратор lru_cache автоматически запоминает результаты, что превращает экспоненциальный алгоритм в полиномиальный. Этот способ подходит для подсчета количества, но не для генерации самих разбиений.

Расширенный пример 6: сравнение производительности рекурсивного и итеративного алгоритмов

Для n=30 измерим время генерации всех разбиений с помощью рекурсивного генератора и итеративного алгоритма.

Пример

import time

def partitions_rec(n):
    ... # реализация из rbase

def partitions_iter(n):
    ... # реализация из варианта 4

n = 30
start = time.time()
list(partitions_rec(n))
print("Рекурсивный:", time.time() - start)

start = time.time()
list(partitions_iter(n))
print("Итеративный:", time.time() - start)
Рекурсивный: 0.45
Итеративный: 0.42

Пояснение: Оба алгоритма имеют сходную производительность. Итеративный может быть немного быстрее за счет отсутствия накладных расходов на вызовы функций, но разница незначительна. Для n=100 количество разбиений превышает 190 миллионов, и любой алгоритм будет работать непозволительно долго.

Расширенный пример 7: вывод разбиений в формате строки со знаком "+"

Для наглядного представления можно преобразовать каждое разбиение в строку.

Пример

for p in partitions(5):
    print('+'.join(str(x) for x in p))
5
4+1
3+2
3+1+1
2+2+1
2+1+1+1
1+1+1+1+1

Пояснение: Простое преобразование списка в строку с разделителем. Удобно для вывода пользователю.

Разложение числа на слагаемые в Python - comments

En
слагаемые числа python (python)