2025, Dec 23 21:03
Как решить задачу покрытия множеств с помощью MILP в SciPy
Показываем, как свести задачу покрытия множеств к MILP и решить её в Python с SciPy: бинарные переменные, разреженная матрица ограничений и масштабирование.
Покрыть конечный универсум минимальным числом подмножеств кажется обманчиво простой задачей, пока не попробуешь решить её грубой силой. Нужно выбрать минимальное количество ключей из словаря множеств так, чтобы их объединение совпадало с полным универсумом; если такого набора нет, результат должен быть пустым. На небольших входах перебор всех комбинаций срабатывает, но по мере того как число ключей приближается к сотням, этот подход быстро становится непрактичным.
Постановка задачи и базовый перебор
Рассмотрим отображение, значения которого — подмножества общего множества. Цель — выбрать наименьшее число ключей, чьи множества покрывают универсум.
import copy
pool = {'a': {1,2,8}, 'b': {3,1,2,6}, 'c': {0,4,1,2}, 'd': {9}, 'e': {2,5},
'f': {4,8}, 'g': {0,9}, 'h': {7,2,3}, 'i': {5,6,3}, 'j': {4,6,8}}
universe = set(range(10))
picks_ok = []
def walk(current, remaining):
for idx in range(len(remaining)):
next_pick = copy.copy(current)
next_pick.append(remaining[idx])
rest_labels = remaining[idx+1:]
if {elem for name in next_pick for elem in pool[name]} == universe:
picks_ok.append(next_pick)
walk(next_pick, rest_labels)
start_pick = []
labels = sorted(pool.keys())
walk(start_pick, labels)
answer = sorted(picks_ok, key=lambda r: len(r))
print(answer[0])
# ['a', 'c', 'd', 'h', 'i']
Эта исчерпывающая рекурсия перебирает все комбинации ключей, отбирает те, чьё объединение равно универсуму, и возвращает минимальную. Это работает, но стоимость растёт взрывным образом с увеличением числа ключей.
Что на самом деле происходит
Эта задача — частный случай проблемы покрытия множеств. Требуется минимизировать количество выбранных подмножеств при условии, что каждый элемент универсума покрыт хотя бы одним из них. Известно, что задача вычислительно трудная; следовательно, поиск по всем комбинациям не масштабируется.
Практический подход: MILP с разреженной матрицей покрытия
Для входов такого масштаба компактный и быстрый способ — сформулировать её как смешанно-целочисленную линейную задачу (MILP). Каждый ключ превращается в бинарную переменную. Целевая функция минимизирует число переменных, равных единице. Ограничения требуют, чтобы каждый элемент универсума был покрыт хотя бы одним выбранным множеством. Формирование этих ограничений в виде разреженной матрицы сохраняет эффективность.
import time
import numpy as np
from scipy.optimize import milp, Bounds, LinearConstraint
from scipy.sparse import coo_array
groups = {
'a': {1,2,8}, 'b': {3,1,2,6}, 'c': {0,4,1,2}, 'd': {9}, 'e': {2,5},
'f': {4,8}, 'g': {0,9}, 'h': {7,2,3}, 'i': {5,6,3}, 'j': {4,6,8},
}
u_size = 1 + max(val for subset in groups.values() for val in subset)
k_count = len(groups)
objective = np.ones(k_count)
var_bounds = Bounds(lb=0, ub=1)
nz = np.array(
[
(row, col)
for row in range(u_size)
for col, subset in enumerate(groups.values())
if row in subset
],
dtype=np.int32,
).T
covering = LinearConstraint(
A=coo_array((
np.ones(nz.shape[1]),
nz,
)).tocsc(),
lb=1,
)
t0 = time.perf_counter()
sol = milp(c=objective, integrality=1, bounds=var_bounds, constraints=covering)
t1 = time.perf_counter()
assert sol.success
print(sol.message)
print(f'Cost: {sol.fun} in {(t1 - t0)*1e3:.2f} ms')
print(
'Assigned:',
' '.join(
name
for name, flag in zip(groups.keys(), sol.x)
if flag
),
)
Optimization terminated successfully. (HiGHS Status 7: Optimal)
Cost: 5.0 in 2.58 ms
Assigned: c g h i j
Результат — покрытие минимального размера для заданного отображения.
Масштабирование до примерно сотни ключей
Тот же подход применим, когда словарь растёт. Следующий фрагмент генерирует пример со ста ключами и решает его тем же способом.
import time
import numpy as np
from scipy.optimize import milp, Bounds, LinearConstraint
from scipy.sparse import coo_array
rng = np.random.default_rng(seed=0)
u_size = 10
k_count = 100
# Random instance: each key maps to 1..4 unique elements in [0, u_size)
sets_rand = {
i: rng.choice(a=u_size, size=rng.integers(low=1, high=4, endpoint=True),
replace=False, shuffle=False)
for i in range(k_count)
}
objective = np.ones(k_count)
var_bounds = Bounds(lb=0, ub=1)
nz = np.array(
[
(row, col)
for row in range(u_size)
for col, subset in enumerate(sets_rand.values())
if row in subset
],
dtype=np.int32,
).T
covering = LinearConstraint(
A=coo_array((np.ones(nz.shape[1]), nz)).tocsc(),
lb=1,
)
t0 = time.perf_counter()
sol = milp(c=objective, integrality=1, bounds=var_bounds, constraints=covering)
t1 = time.perf_counter()
print(sol.message)
print(f'Cost: {sol.fun} in {(t1 - t0)*1e3:.2f} ms')
print(
'Assigned:',
' '.join(
str(k)
for k, flag in zip(sets_rand.keys(), sol.x)
if flag > 0.5
),
)
for (k, subset), flag in zip(sets_rand.items(), sol.x):
if flag > 0.5:
print(f'{k}: {subset}')
Optimization terminated successfully. (HiGHS Status 7: Optimal)
Cost: 3.0 in 18.49 ms
Assigned: 0 82 99
0: [4 7 2 3]
82: [6 0 5 8]
99: [1 5 4 9]
На типичном оборудовании входы с примерно 1200 ненулевыми элементами в матрице ограничений обрабатываются примерно за секунду.
Почему это важно
Хотя задача покрытия множеств вычислительно трудна, корректно поставленная MILP с разреженным представлением позволяет быстро решать реалистичные экземпляры. Вместо перебора всех комбинаций вы делегируете поиск оптимизатору, который эксплуатирует структуру задачи, отсечение и эффективную линейную алгебру «под капотом». Результат воспроизводим, оптимален и надёжен для показанных размеров.
Выводы
Если вам нужна минимальная коллекция ключей, покрывающая универсум, рассматривайте её как set cover и выражайте в виде MILP с бинарными переменными, набором простых ограничений «покрыть каждый элемент хотя бы раз» и суммирующей минимизируемой целевой функцией. Для продемонстрированных размеров формирование разреженной матрицы ограничений и запуск решателя дают оптимальные ответы в пределах от миллисекунд до секунд. Когда требуется любая одна оптимальная комбинация, решение MILP естественным образом именно её и предоставляет.