E061. Thuật toán Куна нахождения наибольшего паросочетания в двудольном đồ thịе
Источник: e-maxx.ru/algo, страница PDF 184.
Дан двудольный đồ thị
, содержащий
вершин и
рёбер. it is required find наибольшее паросочетание, т.е. выбрать как можно больше рёбер, чтобы ни одно выбранное edge не имело общей вершины ни с каким другим выбранным edgeм.
Описание Thuật toánа
Необходимые определения
Паросочетанием
называется набор попарно несмежных рёбер đồ thịа (иными словами, любой вершине
đồ thịа должно быть инцидентно не более одного ребра из множества
). Мощностью паросочетания назовём
количество рёбер в нём. Наибольшим (или максимальным) паросочетанием назовём паросочетание, мощность которого максимальна среди всех возможных паросочетаний в данном đồ thịе. Все те вершины, у которых есть смежное edge из паросочетания (т.е. которые имеют степень ровно один в подđồ thịе, образованном
),
назовём насыщенными этим паросочетанием.
Цепью длины
назовём некоторый простой путь (т.е. не содержащий повторяющихся вершин или рёбер),
содержащий
рёбер. Чередующейся цепью (в двудольном đồ thịе, относительно некоторого паросочетания) назовём цепь, в которой рёбра поочередно принадлежат/не принадлежат паросочетанию. Увеличивающей цепью (в двудольном đồ thịе, относительно некоторого паросочетания) назовём чередующуюся цепь, у которой начальная и конечная вершины не принадлежат паросочетанию.
Теорема Бержа
Формулировка. Паросочетание является максимальным тогда и только тогда, когда не существует увеличивающих относительно него цепей.
Chứng minh необходимости. Покажем, что если паросочетание
максимально, то не
существует увеличивающей относительно него цепи. Chứng minh это будет конструктивным: мы покажем,
как увеличить с помощью этой увеличивающей цепи
мощность паросочетания
на единицу. Для этого выполним так называемое чередование паросочетания вдоль цепи
. Мы помним, что по определению
первое edge цепи
не принадлежит паросочетанию, второе — принадлежит, третье — снова не принадлежит, четвёртое — принадлежит, и т.д. Давайте поменяем состояние всех рёбер вдоль цепи
: те рёбра, которые не Đầu vàoили
в паросочетание (первое, третье и т.д. до последнего) включим в паросочетание, а рёбра, которые раньше Đầu vàoили в паросочетание (второе, четвёртое и т.д. до предпоследнего) — удалим из него. Понятно, что мощность паросочетания при этом увеличилась на единицу (потому что было добавлено на одно edge больше, чем удалено). Осталось проверить, что мы построили корректное паросочетание, т.е. что никакая vertex đồ thịа не имеет сразу двух смежных рёбер из этого паросочетания. Для всех вершин чередующей цепи
,
кроме первой и последней, это следует из самого Thuật toánа чередования: сначала мы у каждой такой вершины удалили смежное edge, потом добавили. Для первой и последней вершины цепи
также ничего не могло
нарушиться, поскольку до чередования они должны были быть ненасыщенными. Наконец, для всех остальных вершин,
— не Đầu vàoящих в цепь
, — очевидно, ничего не поменялось. Таким образом, мы в самом деле построили паросочетание, и на единицу большей мощности, чем старое, что и завершает Chứng minh необходимости. Chứng minh достаточности. Докажем, что если относительно некоторого паросочетания
нет увеличивающих путей, то оно — максимально.
Chứng minh проведём от противного. Пусть есть паросочетание
, имеющее бОльшую мощность, чем
. Рассмотрим симметрическую разность
этих двух паросочетаний, т.е. оставим все рёбра, Đầu vàoящие в
или в
, но не в оба одновременно.
Понятно, что множество рёбер
— уже наверняка не паросочетание. Рассмотрим, какой вид это множество рёбер имеет; для удобства будем рассматривать его как đồ thị. В этом đồ thịе каждая vertex, очевидно, имеет степень не выше 2 (потому что каждая vertex может иметь максимум два смежных ребра — из одного паросочетания и из другого). Легко понять, что тогда этот đồ thị состоит только из циклов или путей, причём ни те, ни другие не пересекаются друг с другом.
Теперь заметим, что и пути в этом đồ thịе
могут быть не любыми, а только чётной длины. В самом деле, в любом пути
в đồ thịе
рёбра чередуются: после ребра из
идёт edge из
, и наоборот. Теперь, если мы рассмотрим какой-
то путь нечётной длины в đồ thịе
, то получится, что в исходном đồ thịе
это будет увеличивающей цепью либо
для паросочетания
, либо для
. Но этого быть не могло, потому что в случае паросочетания
это противоречит
с Đề bàiм, а в случае
— с его максимальностью (ведь мы уже доказали необходимость теоремы, из которой следует, что при существовании увеличивающей цепи паросочетание не может быть максимальным). Докажем теперь аналогичное утверждение и для циклов: все циклы в đồ thịе могут иметь только чётную длину. Это доказать совсем просто: понятно, что в цикле рёбра также должны чередоваться (принадлежать по очереди то
,
то
), но это Đề bài не может выполниться в цикле нечётной длины — в нём обязательно найдутся два соседних ребра из одного паросочетания, что противоречит определению паросочетания.
Таким образом, все пути и циклы đồ thịа
имеют чётную длину. Следовательно, đồ thị
содержит равное количество рёбер из
и из
. Но, given, что в
содержатся все рёбра
и
,
за исключением их общих рёбер, то отсюда следует, что мощность
и
совпадают. Мы пришли к противоречию:
по предположению паросочетание
было не максимальным, значит, теорема доказана.
Thuật toán Куна
Thuật toán Куна — непосредственное применение теоремы Бержа. Его можно кратко описать так: сначала возьмём пустое паросочетание, а потом — пока в đồ thịе удаётся find увеличивающую цепь, — будем выполнять чередование паросочетания вдоль этой цепи, и повторять процесс поиска увеличивающей цепи. Как только такую цепь find не удалось — процесс останавливаем, — текущее паросочетание и есть максимальное. Осталось детализировать способ нахождения увеличивающих цепей. Thuật toán Куна — просто ищет любую из таких цепей с помощью обхода в глубину или в ширину. Thuật toán Куна просматривает все вершины đồ thịа по очереди, запуская из каждой обход, пытающийся find увеличивающую цепь, начинающуюся в этой вершине. Удобнее описывать этот Thuật toán, считая, что đồ thị уже разбит на две доли (хотя на самом деле Thuật toán можно реализовать и так, чтобы ему не давался на Đầu vào đồ thị, явно разбитый на две доли).
Thuật toán просматривает все вершины
первой доли đồ thịа:
. Если текущая vertex
уже
насыщена текущим паросочетанием (т.е. уже выбрано какое-то смежное ей edge), то эту вершину пропускаем. Иначе — Thuật toán пытается насытить эту вершину, для чего запускается поиск увеличивающей цепи, начинающейся с этой вершины. Поиск увеличивающей цепи осуществляется с помощью специального обхода в глубину или ширину (обычно в целях простоты реализации используют именно обход в глубину). Изначально обход в глубину стоит в
текущей ненасыщенной вершине
первой доли. Просматриваем все рёбра из этой вершины, пусть текущее edge —
это edge
. Если vertex
ещё не насыщена паросочетанием, то, значит, мы смогли find
увеличивающую цепь: она состоит из единственного ребра
; в таком случае просто включаем это edge
в паросочетание и прекращаем поиск увеличивающей цепи из вершины
. Иначе, — если
уже насыщена каким-
то edgeм
, то попытаемся пройти вдоль этого ребра: тем самым мы попробуем find увеличивающую
цепь, проходящую через рёбра
,
. Для этого просто перейдём в нашем обходе в вершину
— теперь
мы уже пробуем find увеличивающую цепь из этой вершины. Можно понять, что в результате этот обход, запущенный из вершины
, либо найдёт увеличивающую цепь, и тем
самым насытит вершину
, либо же такой увеличивающей цепи не найдёт (и, следовательно, эта vertex
уже
не сможет стать насыщенной).
После того, как все вершины
будут просмотрены, текущее паросочетание будет максимальным.
Thời gian chạy
Итак, Thuật toán Куна можно представить как серию из
запусков обхода в глубину/ширину на всём đồ thịе.
Следовательно, всего этот Thuật toán исполняется за время
, что в худшем случае есть
. Однако эту оценку можно немного улучшить. Оказывается, для Thuật toánа Куна важно то, какая доля выбрана за первую, а какая — за вторую. В самом деле, в описанной выше реализации запуски обхода в глубину/ ширину происходят только из вершин первой доли, поэтому весь Thuật toán исполняется за время
, где
— number вершин первой доли. В худшем случае это составляет
(где
— number вершин второй
доли). Отсюда видно, что выгоднее, когда первая доля содержит меньшее number вершин, нежели вторая. На
очень несбалансированных đồ thịах (когда
и
сильно отличаются) это выливается в значительную разницу
времён работы.
Cài đặt
Приведём здесь реализацию вышеописанного Thuật toánа, основанную на обходе в глубину, и принимающей двудольный đồ thị в виде явно разбитого на две доли đồ thịа. Эта Cài đặt весьма лаконична, и, возможно, её стоит запомнить именно в таком виде.
Здесь
— number вершин в первой доле,
— во второй доле,
— список рёбер из вершины
первой доли (т.е.
список номеров вершин, в которые ведут эти рёбра из
). Вершины в обеих долях занумерованы независимо, т.е.
первая доля — с номерами
, вторая — с номерами
. Дальше идут два вспомогательных mảngа:
и
. Первый —
— содержит в себе информацию о
текущем паросочетании. Для удобства программирования, информация эта содержится только для вершин второй доли:
— это номер вершины первой доли, связанной edgeм с вершиной
второй доли (или
, если
никакого ребра паросочетания из
не Đầu raит). Второй mảng —
— обычный mảng "посещённостей" вершин
в обходе в глубину (он нужен, просто чтобы обход в глубину не заходил в одну вершину дважды).
Функция
— и есть обход в глубину. Она returns
, если ей удалось find увеличивающую цепь
из вершины
, при этом считается, что эта функция уже произвела чередование паросочетания вдоль найденной цепи.
Внутри функции просматриваются все рёбра, исходящие из вершины
первой доли, и затем проверяется: если это
edge ведёт в ненасыщенную вершину
, либо если эта vertex
насыщена, но удаётся find увеличивающую
цепь рекурсивным запуском из
, то мы говорим, что мы нашли увеличивающую цепь, и перед возвратом
из функции с результатом
производим чередование в текущем ребре: перенаправляем edge, смежное с
,
в вершину
. В основной программе сначала указывается, что текущее паросочетание — пустое (список
заполняется числами
). Затем перебирается vertex
первой доли, и из неё запускается обход в глубину
,
предварительно обнулив mảng
. Стоит заметить, что размер паросочетания легко получить как number вызовов
в основной
программе, вернувших результат
. Само искомое максимальное паросочетание содержится в mảngе
.
int n, k;
vector < vector<int> > g;
vector<int> mt;
vector<char> used;
bool try_kuhn (int v) {
if (used[v]) return false;
used[v] = true;
for (size_t i=0; i<g[v].size(); ++i) {
int to = g[v][i];
if (mt[to] == -1 || try_kuhn (mt[to])) {
mt[to] = v;
return true;
} }
return false;
}
int main() {
... чтение đồ thịа ...
mt.assign (k, -1);
for (int v=0; v<n; ++v) {
used.assign (n, false);
try_kuhn (v);
}
for (int i=0; i<k; ++i)
if (mt[i] != -1)
printf ("%d %d\n", mt[i]+1, i+1);
} Ещё раз повторим, что Thuật toán Куна легко реализовать и так, чтобы он работал на đồ thịах, про которые известно, что они двудольные, но явное их разбиение на две доли не найдено. В этом случае придётся отказаться от удобного разбиения на две доли, и всю информацию хранить для всех вершин đồ thịа. Для этого mảng списков
теперь задаётся не только для вершин первой доли, а для всех вершин đồ thịа (понятно, теперь вершины обеих
долей занумерованы в общей нумерации — от
до
). mảngы
и
теперь также определены для
вершин обеих долей, и, соответственно, их нужно поддерживать в этом состоянии.
Улучшенная Cài đặt
Модифицируем Thuật toán следующим образом. До основного цикла Thuật toánа найдём каким-нибудь простым Thuật toánом произвольное паросочетание (простым эвристическим Thuật toánом), и лишь затем будем выполнять цикл с вызовами функции kuhn(), который будет улучшать это паросочетание. В результате Thuật toán будет работать заметно быстрее на случайных đồ thịах — потому что в большинстве đồ thịов можно легко набрать паросочетание достаточно большого веса с помощью эвристики, а потом улучшить найденное паросочетание до максимального уже обычным Thuật toánом Куна. Тем самым мы сэкономим на запусках обхода в глубину из тех вершин, которые мы уже включили с помощью эвристики в текущее паросочетание. НаVí dụ, можно просто перебрать все вершины первой доли, и для каждой из них find произвольное edge, которое можно добавить в паросочетание, и добавить его. Даже такая простая эвристика способна ускорить Thuật toán Куна в несколько раз. Следует обратить внимание на то, что основной цикл придётся немного модифицировать. Поскольку при вызове
функции
в основном цикле предполагается, что текущая vertex ещё не Đầu vàoит в паросочетание, то нужно добавить соответствующую проверку. В реализации изменится только код в функции main():
int main() {
... чтение đồ thịа ...
mt.assign (k, -1);
vector<char> used1 (n);
for (int i=0; i<n; ++i)
for (size_t j=0; j<g[i].size(); ++j)
if (mt[g[i][j]] == -1) {
mt[g[i][j]] = i;
used1[i] = true;
break;
}
for (int i=0; i<n; ++i) {
if (used1[i]) continue;
used.assign (n, false);
try_kuhn (i);
}
for (int i=0; i<k; ++i)
if (mt[i] != -1)
printf ("%d %d\n", mt[i]+1, i+1);
} Другой хорошей эвристикой является следующая. На каждом шаге будет искать вершину наименьшей степени (но не изолированную), из неё выбирать любое edge и добавлять его в паросочетание, затем удаляя обе эти вершины со всеми инцидентными им рёбрами из đồ thịа. Такая жадность работает очень хорошо на случайных đồ thịах, даже в большинстве случаев строит максимальное паросочетание (хотя и против неё есть тест, на котором она найдёт паросочетание значительно меньшей величины, чем максимальное).
C# lời giải
bản nháp tự động, xem lại trước khi gửiusing System;
using System.Collections.Generic;
using System.Linq;
public static class AlgorithmDraft
{
// Auto-generated C# draft from the original e-maxx C/C++ listing. Review before production use.
int n, k;
vector < List<int> > g;
List<int> mt;
List<char> used;
bool try_kuhn (int v) {
if (used[v]) return false;
used[v] = true;
for (size_t i=0; i<g[v].size(); ++i) {
int to = g[v][i];
if (mt[to] == -1 || try_kuhn (mt[to])) {
mt[to] = v;
return true;
}
}
return false;
}
int main() {
... чтение графа ...
mt.assign (k, -1);
for (int v=0; v<n; ++v) {
used.assign (n, false);
try_kuhn (v);
}
for (int i=0; i<k; ++i)
if (mt[i] != -1)
Console.Write ("%d %d\n", mt[i]+1, i+1);
}
int main() {
... чтение графа ...
mt.assign (k, -1);
List<char> used1 (n);
for (int i=0; i<n; ++i)
for (size_t j=0; j<g[i].size(); ++j)
if (mt[g[i][j]] == -1) {
mt[g[i][j]] = i;
used1[i] = true;
break;
}
for (int i=0; i<n; ++i) {
if (used1[i]) continue;
used.assign (n, false);
try_kuhn (i);
}
for (int i=0; i<k; ++i)
if (mt[i] != -1)
Console.Write ("%d %d\n", mt[i]+1, i+1);
}
}
C++ lời giải
đã khớp/gốcint n, k;
vector < vector<int> > g;
vector<int> mt;
vector<char> used;
bool try_kuhn (int v) {
if (used[v]) return false;
used[v] = true;
for (size_t i=0; i<g[v].size(); ++i) {
int to = g[v][i];
if (mt[to] == -1 || try_kuhn (mt[to])) {
mt[to] = v;
return true;
}
}
return false;
}
int main() {
... чтение графа ...
mt.assign (k, -1);
for (int v=0; v<n; ++v) {
used.assign (n, false);
try_kuhn (v);
}
for (int i=0; i<k; ++i)
if (mt[i] != -1)
printf ("%d %d\n", mt[i]+1, i+1);
}
int main() {
... чтение графа ...
mt.assign (k, -1);
vector<char> used1 (n);
for (int i=0; i<n; ++i)
for (size_t j=0; j<g[i].size(); ++j)
if (mt[g[i][j]] == -1) {
mt[g[i][j]] = i;
used1[i] = true;
break;
}
for (int i=0; i<n; ++i) {
if (used1[i]) continue;
used.assign (n, false);
try_kuhn (i);
}
for (int i=0; i<k; ++i)
if (mt[i] != -1)
printf ("%d %d\n", mt[i]+1, i+1);
}
Java lời giải
bản nháp tự động, xem lại trước khi gửiimport java.util.*;
import java.math.*;
public class AlgorithmDraft {
// Auto-generated Java draft from the original e-maxx C/C++ listing. Review before production use.
int n, k;
vector < ArrayList<Integer> > g;
ArrayList<Integer> mt;
ArrayList<Character> used;
boolean try_kuhn (int v) {
if (used[v]) return false;
used[v] = true;
for (size_t i=0; i<g[v].size(); ++i) {
int to = g[v][i];
if (mt[to] == -1 || try_kuhn (mt[to])) {
mt[to] = v;
return true;
}
}
return false;
}
int main() {
... чтение графа ...
mt.assign (k, -1);
for (int v=0; v<n; ++v) {
used.assign (n, false);
try_kuhn (v);
}
for (int i=0; i<k; ++i)
if (mt[i] != -1)
System.out.print ("%d %d\n", mt[i]+1, i+1);
}
int main() {
... чтение графа ...
mt.assign (k, -1);
ArrayList<Character> used1 (n);
for (int i=0; i<n; ++i)
for (size_t j=0; j<g[i].size(); ++j)
if (mt[g[i][j]] == -1) {
mt[g[i][j]] = i;
used1[i] = true;
break;
}
for (int i=0; i<n; ++i) {
if (used1[i]) continue;
used.assign (n, false);
try_kuhn (i);
}
for (int i=0; i<k; ++i)
if (mt[i] != -1)
System.out.print ("%d %d\n", mt[i]+1, i+1);
}
}
Материал разбит как Thuật toánическая Bài toán: изучить постановку, понять асимптотику и реализовать Thuật toán на выбранном языке.
Vacancies for this task
việc làm đang hoạt động with overlapping task tags are đã hiển thị.