Минимално покриващо дърво
Накратко: Минималното покриващо дърво свързва всички върхове на граф с най-малка обща цена; Kruskal взима ребрата по цена и пропуска всяко, което затваря цикъл.
Проблем
Най-евтината мрежа
Фирмата за доставки има шест офиса в града и иска да ги свърже с оптичен кабел. Всеки офис трябва да стига до всеки друг, пряко или през други офиси. Изкопът е скъп: всеки възможен кабел има цена в хиляди евро.
List<String> offices = List.of("Център", "Север", "Юг", "Изток", "Запад", "Летище");List<Cable> cables = List.of( new Cable("Център", "Север", 4), new Cable("Център", "Юг", 6), new Cable("Център", "Изток", 8), new Cable("Център", "Запад", 5), new Cable("Север", "Изток", 9), new Cable("Север", "Запад", 3), new Cable("Юг", "Запад", 7), new Cable("Юг", "Летище", 11), new Cable("Изток", "Летище", 2));Да положиш всичките девет кабела струва 55 хил. €. Но не трябват всички. Ако три офиса са свързани в кръг, единият кабел от кръга е излишен: махни го и всичко пак е свързано. Най-евтината мрежа няма нито един цикъл (cycle). Свързан граф без цикъл е дърво (tree), а дърво с V върха има точно V − 1 ребра: тук 5 кабела.
Кои пет? Търсиш дървото, което покрива всички офиси с най-малка обща цена. Това е минимално покриващо дърво (minimum spanning tree, MST).
Идеята
Най-евтиният кабел, който не затваря цикъл
Алгоритъмът на Kruskal (Джоузеф Крускал, 1956):
- Подреди кабелите по цена, от най-евтиния.
- За всеки кабел: ако двата му края още не са свързани, положи го. Ако вече са свързани през положени кабели, кабелът би затворил цикъл: прескочи го.
- Спри, щом кабелите станат V − 1.
Отначало всеки офис е отделна група. Всеки положен кабел слива две групи в една. Въпросът „свързани ли са двата края“ е точно въпросът „в една група ли са“, а за него има структура: union-find. union(a, b) слива групите и връща false, ако a и b вече са в една група. Една проверка вместо търсене по картата.
Виж го
Kruskal отвътре
Класът UnionFind е същият като в урока за union-find, без промяна. Кабелът е record с два офиса и цена, а index превежда името на офиса в номер, защото union-find работи с номера.
static List<Cable> kruskal( List<String> offices, List<Cable> cables) { Map<String, Integer> index = new HashMap<>(); for (String o : offices) index.put(o, index.size()); List<Cable> sorted = new ArrayList<>(cables); sorted.sort(Comparator.comparingInt(Cable::cost)); var groups = new UnionFind(offices.size()); List<Cable> tree = new ArrayList<>(); for (Cable c : sorted) { int a = index.get(c.from()); int b = index.get(c.to()); if (!groups.union(a, b)) continue; // цикъл tree.add(c); if (tree.size() == offices.size() - 1) break; } return tree;}sort с Comparator.comparingInt(Cable::cost) е стабилен: кабели с еднаква цена остават в реда си.int total = 0;for (Cable c : kruskal(offices, cables)) { System.out.println(c.from() + " – " + c.to() + ": " + c.cost() + " хил. €"); total += c.cost();}System.out.println("Общо: " + total + " хил. €");Изток – Летище: 2 хил. €Север – Запад: 3 хил. €Център – Север: 4 хил. €Център – Юг: 6 хил. €Център – Изток: 8 хил. €Общо: 23 хил. €static List<Cable> kruskal( List<String> offices, List<Cable> cables) { Map<String, Integer> index = new HashMap<>(); for (String o : offices) index.put(o, index.size()); List<Cable> sorted = new ArrayList<>(cables); sorted.sort(Comparator.comparingInt(Cable::cost)); var groups = new UnionFind(offices.size()); List<Cable> tree = new ArrayList<>(); for (Cable c : sorted) { int a = index.get(c.from()); int b = index.get(c.to()); if (!groups.union(a, b)) continue; // цикъл tree.add(c); if (tree.size() == offices.size() - 1) break; } return tree;}Стъпка по стъпка, с думи
- Шест офиса, девет възможни кабела, всеки с цена в хиляди евро. Мрежата трябва да свърже всички офиси възможно най-евтино. Вдясно кабелите се подреждат по цена: Kruskal ги гледа от най-евтиния. Отначало всеки офис е отделна група.
- Изток – Летище, 2: различни групи. Кабелът се полага, а union слива двете групи в една (един цвят).
- Север – Запад, 3: пак различни групи. Появява се втора, отделна група: Kruskal строи няколко парчета наведнъж, а не едно дърво, което расте.
- Център – Север, 4: Център влиза в групата на Север и Запад.
- Център – Запад, 5: двата края вече са в една група, през Север. Този кабел би затворил цикъл: union връща false и кабелът се отхвърля.
- Център – Юг, 6: Юг е сам, затова кабелът се полага.
- Юг – Запад, 7: Юг и Запад вече са свързани през Център. Отхвърлен.
- Център – Изток, 8: най-евтиният кабел между двете групи. Слива ги, и кабелите вече са пет: шест офиса, пет кабела, всичко е свързано.
- Цикълът спира: Север – Изток и Юг – Летище изобщо не се поглеждат. Общо 23 хил. €, срещу 55 за всичките девет кабела.
Две групи растат отделно: синята (Изток, Летище) и зелената, която започва от Север и Запад. Кабелът Център – Изток е най-евтиният между тях и ги слива. Кабелите Север – Изток (9) и Юг – Летище (11) изобщо не се поглеждат.
Пробвай
Предвиди изхода
Магистралата до летището се оказва проблем: кабелът Изток – Летище вече струва 12. Каква е цената на новата мрежа?
List<Cable> changed = new ArrayList<>(cables);changed.set(changed.size() - 1, new Cable("Изток", "Летище", 12));int total = kruskal(offices, changed).stream().mapToInt(Cable::cost).sum();System.out.println("Общо: " + total + " хил. €");Пробвай
Попълни
tree.add(c);
if (tree.size() == ) break;Дървото с V офиса има V − 1 кабела. Щом са толкова, всичко е свързано и всеки следващ кабел би затворил цикъл, затова цикълът спира.
Идеята
Защо алчното тук е вярно
Kruskal е алчен алгоритъм: на всяка стъпка взима най-доброто сега (най-евтиния кабел, който става) и не се връща назад. Често алчното лъже: виж кога алчното работи и кога не работи. Тук работи, и причината е свойството на среза (cut property).
Раздели офисите на две части по какъвто и да е начин, например групата на Изток и всички останали. Кабелите, които минават между двете части, са срезът. Най-евтиният кабел в среза е в минималното дърво. Защо? Дървото трябва да има поне един кабел през среза, иначе двете части не са свързани. Ако е по-скъп, смени го с най-евтиния: мрежата пак е свързана, а цената пада.
Всеки кабел, който Kruskal слага, е най-евтиният в среза между групата на единия му край и всичко останало: всички по-евтини кабели вече са минали и са вътре в групи. Център – Изток (8) е най-евтиният от Център – Изток, Север – Изток (9) и Юг – Летище (11), кабелите между двете групи.
Пробвай
Намери грешката
Колега реши, че union е излишен, и само пита find дали двата края са в една група:
static List<Cable> kruskal( List<String> offices, List<Cable> cables) { Map<String, Integer> index = new HashMap<>(); for (String o : offices) index.put(o, index.size()); List<Cable> sorted = new ArrayList<>(cables); sorted.sort(Comparator.comparingInt(Cable::cost)); var groups = new UnionFind(offices.size()); List<Cable> tree = new ArrayList<>(); for (Cable c : sorted) { int a = index.get(c.from()); int b = index.get(c.to()); if (groups.find(a) == groups.find(b)) continue; tree.add(c); if (tree.size() == offices.size() - 1) break; } return tree;}int total = 0;for (Cable c : kruskal(offices, cables)) { System.out.println(c.from() + " – " + c.to() + ": " + c.cost() + " хил. €"); total += c.cost();}System.out.println("Общо: " + total + " хил. €");Изток – Летище: 2 хил. €Север – Запад: 3 хил. €Център – Север: 4 хил. €Център – Запад: 5 хил. €Център – Юг: 6 хил. €Общо: 20 хил. €20 хил. € вместо 23, но Изток и Летище са отрязани, а Център, Север и Запад са в кръг. На кой ред е грешката?
Избери реда с грешката.
find само пита, без да слива. Групите никога не се сливат, всеки кабел свързва „различни“ групи и влиза, докато станат пет. Трябва union: той и проверява, и слива.
Идеята
Колко струва
Подреждането на E кабела е O(E log E). След него всяка проверка с union-find е почти O(1) (виж урока за union-find). Общо O(E log E): подреждането е по-скъпото.
Без union-find „свързани ли са“ се пита с DFS по вече положените кабели:
static boolean connected(Map<String, List<String>> net, String from, String to) { Deque<String> stack = new ArrayDeque<>(List.of(from)); Set<String> seen = new HashSet<>(List.of(from)); while (!stack.isEmpty()) { String office = stack.pop(); if (office.equals(to)) return true; for (String next : net.get(office)) { if (seen.add(next)) stack.push(next); } } return false;}
static List<Cable> kruskalSlow(List<String> offices, List<Cable> cables) { Map<String, List<String>> net = new HashMap<>(); for (String office : offices) net.put(office, new ArrayList<>()); List<Cable> sorted = new ArrayList<>(cables); sorted.sort(Comparator.comparingInt(Cable::cost)); List<Cable> tree = new ArrayList<>(); for (Cable c : sorted) { if (connected(net, c.from(), c.to())) continue; net.get(c.from()).add(c.to()); net.get(c.to()).add(c.from()); tree.add(c); if (tree.size() == offices.size() - 1) break; } return tree;}Отговорът е същият. Но всяко търсене може да обходи цялата група, O(V), и се прави за всеки кабел: O(E · V).
Пусни двата варианта на мрежа от офиси в решетка: всеки офис може да се свърже с десния и долния си съсед, с цена от 1 до 1000. Брои се всяко сравнение на две цени при подреждането, всеки огледан съсед при DFS и всяка стъпка към родител при find.
- Kruskal с DFS: търсене по положените кабели
O(n²) - Kruskal с union-find
O(n log n)
| Алгоритъм | n = 100 | n = 1000 | n = 10 000 | n = 100 000 |
|---|---|---|---|---|
| Kruskal с DFS: търсене по положените кабели | 4945 | 677 351 | ≈ 67 735 100 | ≈ 6 773 510 000 |
| Kruskal с union-find | 1460 | 23 827 | 321 171 | 3 884 598 |
static List<Cable> kruskal( List<String> offices, List<Cable> cables) { Map<String, Integer> index = new HashMap<>(); for (String o : offices) index.put(o, index.size()); List<Cable> sorted = new ArrayList<>(cables); sorted.sort(Comparator.comparingInt(Cable::cost)); var groups = new UnionFind(offices.size()); List<Cable> tree = new ArrayList<>(); for (Cable c : sorted) { int a = index.get(c.from()); int b = index.get(c.to()); if (!groups.union(a, b)) continue; // цикъл tree.add(c); if (tree.size() == offices.size() - 1) break; } return tree;}При 1000 офиса с DFS са 677 351 стъпки, с union-find 23 827. При 100 000 офиса union-find прави 3 884 598, а DFS би направил милиарди: Bench не ги пуска, а ги оценява по кривата.
Идеята
Не е най-краткият път
В минималното дърво пътят от Юг до Летище е Юг – Център – Изток – Летище: 6 + 8 + 2 = 16. Директният кабел Юг – Летище е 11, но той не е в мрежата.
Минималното покриващо дърво пести общата цена на мрежата. Не обещава нищо за пътя между два офиса. Ако цената е закъснение и искаш най-бързия път между два офиса, това е друга задача: Dijkstra.
По-точно, за любопитните
Prim е другият класически алгоритъм за минимално покриващо дърво. Тръгва от един офис и всеки път добавя най-евтиния кабел от дървото към офис извън него. Опашката е приоритетна, както в Dijkstra, само че приоритетът е цената на кабела, а не пътят от началото. С двоичен heap е O(E log V). Kruskal е по-прост, когато кабелите са списък, а Prim, когато графът е даден със съседи и е гъст.
Ако има кабели с еднаква цена, минималните дървета може да са няколко, всичките с една и съща обща цена. Kruskal връща едно от тях, според реда на равните.
Къде още: електрическа и водопроводна мрежа, пътища между села, печатни платки. Ако спреш Kruskal, преди групите да станат една (например при k групи), получаваш групиране (clustering): точките в една група са близки, а групите са далеч една от друга.
Упражнение
Вече положен кабел
Реши задачата в редактора си.
Стар кабел. Между Север и Изток вече има положен кабел: не струва нищо. Напиши метод, който приема и положените кабели и намира кои нови да се сложат. Изведи положените и новите кабели и цената на новите:
Вече положен: Север – ИзтокИзток – Летище: 2 хил. €Север – Запад: 3 хил. €Център – Север: 4 хил. €Център – Юг: 6 хил. €Нови кабели: 15 хил. €Подсказка: преди цикъла слей в union-find краищата на положените кабели. Спри, щом групата е една.
Още задачи
- Отхвърлените. Изведи и отхвърлените кабели, всеки с причината: кой цикъл би затворил.
- Две мрежи. Фирмата иска две отделни мрежи. Спри Kruskal, когато групите станат две. Кои офиси са в една мрежа?
- Невъзможно. Махни всички кабели до Летище. Какво връща Kruskal? Как разбираш, че мрежата не свързва всички офиси?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Union-find · Граф: списък от съседи и матрица · DFS: острови и кръгове · Dijkstra
- Продължава в: Алчният алгоритъм и кога работи
- Задачи по темата: Графи