1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 3 из 7

Паросочетание в общем графе: цветки Эдмондса и веса

Коротко

В двудольном графе дополняющий путь ищут обходом: каждая вершина в дереве поиска либо «чётная», либо «нечётная», и это раз и навсегда. В графе с НЕЧЁТНЫМ циклом вершину можно достичь и чётным путём, и нечётным, и обход, пометивший её не той стороной, пропускает существующий путь. Алгоритм Эдмондса находит такой цикл - цветок - и сжимает его в одну вершину: дополняющий путь в сжатом графе разворачивается в путь в исходном, обходя цветок с нужной стороны. Получается наибольшее паросочетание в любом графе за O(V³). Для весов есть его взвешенная версия той же сложности; простая жадность (тяжёлые рёбра первыми) гарантирует лишь половину оптимума.

Если ты знаешь, почему нечётный цикл ломает поиск дополняющего пути, как сжимают цветок и что гарантирует жадность на весах, листай до «Теперь сам».

Как думаешь, что мешает алгоритму Куна на любом графе

Пары для командной работы: любой может работать с любым, если они знакомы, - граф не двудольный. Нужно составить как можно больше пар. Алгоритм Куна из прошлого урока ищет дополняющий путь обходом и двудольность нигде не проверяет.

Прежде чем читать дальше, попробуй угадать: на графе с циклом из пяти вершин код Куна запустится и что-то вернёт. Будет ли ответ наибольшим? Если нет - где именно поиск свернёт не туда?

На что это похоже

Лабиринт с кольцевым коридором. Ты отмечаешь мелом развилки: «пришёл сюда, шагая с левой ноги». Правило лабиринта: выйти можно только с правой. В лабиринте без колец нога в каждой развилке определена раз и навсегда. Кольцо нечётной длины позволяет прийти в ту же развилку и с левой ноги, и с правой - если обойти кольцо с другой стороны. Пометка мелом «здесь был, с левой» закрывает выход, который на самом деле есть. Эдмондс делает простую вещь: кольцо целиком считается одной развилкой, в которую можно прийти «с любой ноги».

Механизм: внешние вершины, цветок, сжатие

поиск дополняющего пути от свободной вершины r:
    r - внешняя (чётная); сосед u внешней вершины:
        u свободна               → путь найден, перевернуть его
        u в паре с x             → u внутренняя (нечётная),
                                   x внешняя, продолжаем от x
        u уже ВНЕШНЯЯ            → ребро между двумя внешними:
                                   нечётный цикл - ЦВЕТОК
сжать цветок в его основание (ближайшую к r вершину цикла):
    все вершины цветка становятся внешними
продолжать поиск в сжатом графе
найденный путь, проходящий через цветок, разворачивается:
    внутри цветка идут по той стороне цикла, где чередование
    «свободное - в паре» сходится

Почему сжатие ничего не теряет: в цветке из 2k + 1 вершин k рёбер в паре, и основание - единственная вершина, которая «смотрит наружу» парой. До любой вершины цикла от основания можно дойти и чётным, и нечётным путём - значит, через любую из них путь может выйти наружу. Сжатая вершина выражает ровно это: «из цветка можно выйти где угодно».

Веса. Паросочетание наибольшего веса в двудольном графе решал венгерский алгоритм, в общем - его взвешенная версия Эдмондса, O(V³), с теми же цветками и потенциалами на вершинах и на нечётных множествах. Реализация длинная, и в работе её берут из библиотеки. Важно знать гарантию запасного варианта: жадность (сортировка рёбер по убыванию веса) даёт не меньше половины оптимума - каждое её ребро «закрывает» не больше двух рёбер оптимума, и оба не тяжелее его.

Правило

В графе с нечётными циклами дополняющий путь обходом «как в двудольном» можно пропустить: вершина цикла достижима и чётным, и нечётным путём. Алгоритм Эдмондса, встретив ребро между двумя внешними вершинами, сжимает найденный нечётный цикл (цветок) в основание и продолжает поиск; путь в сжатом графе разворачивается в исходный. Наибольшее паросочетание - O(V³), наибольший вес - взвешенная версия той же сложности; жадность по весам гарантирует только половину.

Разбор: цикл из пяти и две свободные вершины

рёбра: 0–2 0–3 1–3 1–4 1–5 2–4 2–5      пары сейчас: 0–2, 1–3
свободны: 4 и 5.  Цикл 4–1–3–0–2–4 из пяти вершин

поиск от 4 «как в двудольном»:
    4 внешняя → соседи 1 и 2 внутренние, их пары 3 и 0 внешние
    от 3: соседи 0, 1 уже помечены;  от 0: соседи 2, 3 помечены
    5 соседствует только с 1 и 2 - а они ВНУТРЕННИЕ, из них не выходят
    → пути нет, ответ 2

Эдмондс: та же разметка, но ребро 0–3 соединяет две ВНЕШНИЕ вершины
    → цикл 4–1–3–0–2–4 - цветок, сжимаем его в основание 4,
      все его вершины, включая 1 и 2, становятся внешними
    из 2 ребро к свободной 5 → путь найден
    разворот через цветок чётной стороной: 5–2, 2–0, 0–3, 3–1, 1–4
    переворот: пары 5–2, 0–3, 1–4 - три пары

В обходе «как в двудольном» вершина 2 получила метку «внутренняя», потому что её первой достигли нечётным путём 4–2. Тот же 2 достижим и чётным путём 4–1–3–0–2 - и именно из него открывается выход к 5. Цветок объединяет обе возможности.

Разбор: триста случайных графов

Программа реализует алгоритм Эдмондса и обход без сжатия цветков, сравнивает их с точным перебором динамикой по маскам на трёхстах графах из 14 вершин, а на взвешенных - жадность с оптимумом.

cvetki.py
import random
from collections import deque
from functools import lru_cache


def edmonds(n, sos):                  # наибольшее паросочетание, цветки
    para = [-1] * n

    def dopolnit(koren):
        otec, baza = [-1] * n, list(range(n))
        v_dereve, och = [False] * n, deque([koren])
        v_dereve[koren] = True

        def obshchij_predok(a, b):    # LCA двух вершин в дереве поиска
            byl = [False] * n
            while True:
                a = baza[a]
                byl[a] = True
                if para[a] == -1:
                    break
                a = otec[para[a]]
            while True:
                b = baza[b]
                if byl[b]:
                    return b
                b = otec[para[b]]

        def pometit(v, b, rebenok, cvetok):
            while baza[v] != b:       # идём по цветку к его основанию
                cvetok[baza[v]] = cvetok[baza[para[v]]] = True
                otec[v] = rebenok
                rebenok = para[v]
                v = otec[para[v]]

        while och:
            v = och.popleft()
            for u in sos[v]:
                if baza[v] == baza[u] or para[v] == u:
                    continue
                vneshnyaya = para[u] != -1 and otec[para[u]] != -1
                if u == koren or vneshnyaya:
                    b = obshchij_predok(v, u)   # нечётный цикл - цветок
                    cvetok = [False] * n
                    pometit(v, b, u, cvetok)
                    pometit(u, b, v, cvetok)
                    for x in range(n):        # сжать цветок в основание
                        if cvetok[baza[x]]:
                            baza[x] = b
                            if not v_dereve[x]:
                                v_dereve[x] = True
                                och.append(x)
                elif otec[u] == -1:
                    otec[u] = v
                    if para[u] == -1:         # свободная - путь найден
                        while u != -1:
                            pv = para[otec[u]]
                            para[u], para[otec[u]] = otec[u], u
                            u = pv
                        return True
                    v_dereve[para[u]] = True
                    och.append(para[u])
        return False

    return sum(dopolnit(v) for v in range(n) if para[v] == -1)


def bez_cvetkov(n, sos):              # «как в двудольном»: обход без
    para, razmer = [-1] * n, 0        # сжатия нечётных циклов
    for koren in range(n):
        if para[koren] != -1:
            continue
        otec, och = {koren: None}, deque([koren])
        while och:
            v = och.popleft()
            for u in sos[v]:
                if u in otec or para[v] == u:
                    continue
                otec[u] = v
                if para[u] == -1:
                    while u is not None:
                        pv = para[otec[u]]
                        para[u], para[otec[u]] = otec[u], u
                        u = pv if pv != -1 else None
                    razmer += 1
                    och.clear()
                    break
                otec[para[u]] = u
                och.append(para[u])
    return razmer


def perebor(n, ves):                  # точный ответ динамикой по маскам
    @lru_cache(None)
    def f(maska):
        v = next((v for v in range(n) if not maska >> v & 1), None)
        if v is None:
            return 0
        luchshe = f(maska | 1 << v)   # v остаётся без пары
        for u in range(n):
            if not maska >> u & 1 and (v, u) in ves:
                luchshe = max(luchshe,
                              ves[v, u] + f(maska | 1 << v | 1 << u))
        return luchshe
    return f(0)


def zhadno(ves):                      # самое тяжёлое ребро первым
    zanyato, itog = set(), 0
    for (u, v), w in sorted(ves.items(), key=lambda x: -x[1]):
        if u not in zanyato and v not in zanyato:
            zanyato |= {u, v}
            itog += w
    return itog


def graf(n, rebra):
    sos = [[] for _ in range(n)]
    for u, v in rebra:
        sos[u].append(v)
        sos[v].append(u)
    return sos


rebra = [(0, 2), (0, 3), (1, 3), (1, 4), (1, 5), (2, 4), (2, 5)]
print("граф с циклом 0-2-4-1-3: Эдмондс", edmonds(6, graf(6, rebra)),
      " без цветков", bez_cvetkov(6, graf(6, rebra)))

rng = random.Random(21)
oshibok, hudshee = 0, 1.0
for opyt in range(300):
    n = 14
    rebra = [(u, v) for u in range(n) for v in range(u + 1, n)
             if rng.random() < 0.2]
    edin, ves = {}, {}
    for u, v in rebra:
        edin[u, v] = edin[v, u] = 1
        ves[u, v] = ves[v, u] = rng.randint(1, 100)
    tochno = perebor(n, edin)
    assert edmonds(n, graf(n, rebra)) == tochno
    oshibok += bez_cvetkov(n, graf(n, rebra)) < tochno
    if ves:
        hudshee = min(hudshee, zhadno(ves) / perebor(n, ves))
print("300 графов: Эдмондс совпал с перебором всегда,",
      "без цветков ошибся", oshibok, "раз")
print(f"веса: жадность в худшем случае {hudshee:.2f} от оптимума")
↳  выводpython3 cvetki.py
граф с циклом 0-2-4-1-3: Эдмондс 3  без цветков 2
300 графов: Эдмондс совпал с перебором всегда, без цветков ошибся 4 раз
веса: жадность в худшем случае 0.70 от оптимума

Эдмондс совпал с перебором на всех трёхстах графах; обход без цветков ошибся четыре раза - редко, и поэтому опасно: тесты на нескольких графах ошибку легко пропустят. Жадность по весам в худшем из трёхсот случаев набрала 70% оптимума - выше гарантированной половины, но заметно ниже ста.

Что ломается без этого

Первая ошибка - запускать Куна на недвудольном графе: код работает, ответ правдоподобный и иногда меньше наибольшего, без единого сообщения об ошибке. Вторая - при сжатии цветка не пометить внешними ВСЕ его вершины: путь, выходящий из внутренней вершины цикла, останется ненайденным. Третья - развернуть путь через цветок не той стороной цикла: чередование «свободное - в паре» нарушится, и после переворота у вершины окажется две пары. Четвёртая - считать жадное паросочетание по весам оптимальным: гарантия - лишь половина.

Зачем это в работе

Пары без двух «сторон»: напарники в командах, соседи по комнатам, обмен донорскими органами между парами «донор - реципиент», объединение мелких грузов в пары по совместимости, раскладка задач на двухъядерные машины. Паросочетание в общем графе - строительный блок других алгоритмов: оно стоит в задаче китайского почтальона (нечётные вершины разбивают на пары паросочетанием минимального веса) и в алгоритме Кристофидеса для коммивояжёра.

Теперь сам

Треугольник a–b–c и ребро c–d. Пара сейчас: a–b. Есть ли дополняющий путь? Как поведёт себя поиск от d?

Ответ. Свободны c и d, и ребро c–d - уже дополняющий путь длины один: паросочетание {a–b, c–d} из двух пар. Поиск от d: сосед c свободен - путь найден сразу. Цветок a–b–c здесь не понадобился бы, но если бы пара была b–c, а свободны a и d, путь d–c–b–a нашёлся бы обходом d → c → b → a, и цикл снова не помешал: нечётный цикл опасен, только когда нужный выход лежит на «не той» стороне.

Главное

Нечётный цикл позволяет прийти в вершину и чётным, и нечётным путём, поэтому обход «как в двудольном» теряет дополняющие пути - в программе 4 ошибки на 300 графов, без единого предупреждения. Алгоритм Эдмондса сжимает такой цикл-цветок в основание и находит наибольшее паросочетание в любом графе за O(V³); взвешенная версия той же сложности решает задачу с весами, а жадность гарантирует лишь половину оптимума.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.