Минимално покриващо дърво
Накратко: Минималното покриващо дърво свързва всички върхове на граф с най-малка обща цена; Kruskal взима ребрата по цена и пропуска всяко, което затваря цикъл.
Проблем
Най-евтината мрежа
Фирмата за доставки има шест офиса в града и иска да ги свърже с оптичен кабел. Всеки офис трябва да стига до всеки друг, пряко или през други офиси. Изкопът е скъп: всеки възможен кабел има цена в хиляди евро.
var offices = new List<string> { "Център", "Север", "Юг", "Изток", "Запад", "Летище" };var cables = new List<Cable>{ new("Център", "Север", 4), new("Център", "Юг", 6), new("Център", "Изток", 8), new("Център", "Запад", 5), new("Север", "Изток", 9), new("Север", "Запад", 3), new("Юг", "Запад", 7), new("Юг", "Летище", 11), new("Изток", "Летище", 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){ var index = new Dictionary<string, int>(); foreach (string o in offices) index[o] = index.Count; var sorted = cables.OrderBy(c => c.Cost).ToList(); var groups = new UnionFind(offices.Count); var tree = new List<Cable>(); foreach (Cable c in sorted) { int a = index[c.From]; int b = index[c.To]; if (!groups.Union(a, b)) continue; // цикъл tree.Add(c); if (tree.Count == offices.Count - 1) break; } return tree;}OrderBy е стабилен: кабели с еднаква цена остават в реда си. ToList() го изпълнява веднага.int total = 0;foreach (Cable c in Kruskal(offices, cables)){ Console.WriteLine($"{c.From} – {c.To}: {c.Cost} хил. €"); total += c.Cost;}Console.WriteLine($"Общо: {total} хил. €");Изток – Летище: 2 хил. €Север – Запад: 3 хил. €Център – Север: 4 хил. €Център – Юг: 6 хил. €Център – Изток: 8 хил. €Общо: 23 хил. €static List<Cable> Kruskal( List<string> offices, List<Cable> cables){ var index = new Dictionary<string, int>(); foreach (string o in offices) index[o] = index.Count; var sorted = cables.OrderBy(c => c.Cost).ToList(); var groups = new UnionFind(offices.Count); var tree = new List<Cable>(); foreach (Cable c in sorted) { int a = index[c.From]; int b = index[c.To]; if (!groups.Union(a, b)) continue; // цикъл tree.Add(c); if (tree.Count == offices.Count - 1) break; } return tree;}Стъпка по стъпка, с думи
- Шест офиса, девет възможни кабела, всеки с цена в хиляди евро. Мрежата трябва да свърже всички офиси възможно най-евтино. Вдясно кабелите се подреждат по цена: Kruskal ги гледа от най-евтиния. Отначало всеки офис е отделна група.
- Изток – Летище, 2: различни групи. Кабелът се полага, а union слива двете групи в една (един цвят).
- Север – Запад, 3: пак различни групи. Появява се втора, отделна група: Kruskal строи няколко парчета наведнъж, а не едно дърво, което расте.
- Център – Север, 4: Център влиза в групата на Север и Запад.
- Център – Запад, 5: двата края вече са в една група, през Север. Този кабел би затворил цикъл: union връща false и кабелът се отхвърля.
- Център – Юг, 6: Юг е сам, затова кабелът се полага.
- Юг – Запад, 7: Юг и Запад вече са свързани през Център. Отхвърлен.
- Център – Изток, 8: най-евтиният кабел между двете групи. Слива ги, и кабелите вече са пет: шест офиса, пет кабела, всичко е свързано.
- Цикълът спира: Север – Изток и Юг – Летище изобщо не се поглеждат. Общо 23 хил. €, срещу 55 за всичките девет кабела.
Две групи растат отделно: синята (Изток, Летище) и зелената, която започва от Север и Запад. Кабелът Център – Изток е най-евтиният между тях и ги слива. Кабелите Север – Изток (9) и Юг – Летище (11) изобщо не се поглеждат.
Пробвай
Предвиди изхода
Магистралата до летището се оказва проблем: кабелът Изток – Летище вече струва 12. Каква е цената на новата мрежа?
var changed = new List<Cable>(cables);changed[^1] = new("Изток", "Летище", 12);int total = Kruskal(offices, changed).Sum(c => c.Cost);Console.WriteLine($"Общо: {total} хил. €");Пробвай
Попълни
tree.Add(c);
if (tree.Count == ) break;Дървото с V офиса има V − 1 кабела. Щом са толкова, всичко е свързано и всеки следващ кабел би затворил цикъл, затова цикълът спира.
Идеята
Защо алчното тук е вярно
Kruskal е алчен алгоритъм: на всяка стъпка взима най-доброто сега (най-евтиния кабел, който става) и не се връща назад. Често алчното лъже: виж кога алчното работи и кога не работи. Тук работи, и причината е свойството на среза (cut property).
Раздели офисите на две части по какъвто и да е начин, например групата на Изток и всички останали. Кабелите, които минават между двете части, са срезът. Най-евтиният кабел в среза е в минималното дърво. Защо? Дървото трябва да има поне един кабел през среза, иначе двете части не са свързани. Ако е по-скъп, смени го с най-евтиния: мрежата пак е свързана, а цената пада.
Всеки кабел, който Kruskal слага, е най-евтиният в среза между групата на единия му край и всичко останало: всички по-евтини кабели вече са минали и са вътре в групи. Център – Изток (8) е най-евтиният от Център – Изток, Север – Изток (9) и Юг – Летище (11), кабелите между двете групи.
Пробвай
Намери грешката
Колега реши, че union е излишен, и само пита find дали двата края са в една група:
static List<Cable> Kruskal( List<string> offices, List<Cable> cables){ var index = new Dictionary<string, int>(); foreach (string o in offices) index[o] = index.Count; var sorted = cables.OrderBy(c => c.Cost).ToList(); var groups = new UnionFind(offices.Count); var tree = new List<Cable>(); foreach (Cable c in sorted) { int a = index[c.From]; int b = index[c.To]; if (groups.Find(a) == groups.Find(b)) continue; tree.Add(c); if (tree.Count == offices.Count - 1) break; } return tree;}int total = 0;foreach (Cable c in Kruskal(offices, cables)){ Console.WriteLine($"{c.From} – {c.To}: {c.Cost} хил. €"); total += c.Cost;}Console.WriteLine($"Общо: {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 bool Connected(Dictionary<string, List<string>> net, string from, string to){ var stack = new Stack<string>(); stack.Push(from); var seen = new HashSet<string> { from }; while (stack.Count > 0) { string office = stack.Pop(); if (office == to) return true; foreach (string next in net[office]) { if (seen.Add(next)) stack.Push(next); } } return false;}
static List<Cable> KruskalSlow(List<string> offices, List<Cable> cables){ var net = offices.ToDictionary(office => office, _ => new List<string>()); var tree = new List<Cable>(); foreach (Cable c in cables.OrderBy(c => c.Cost)) { if (Connected(net, c.From, c.To)) continue; net[c.From].Add(c.To); net[c.To].Add(c.From); tree.Add(c); if (tree.Count == offices.Count - 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){ var index = new Dictionary<string, int>(); foreach (string o in offices) index[o] = index.Count; var sorted = cables.OrderBy(c => c.Cost).ToList(); var groups = new UnionFind(offices.Count); var tree = new List<Cable>(); foreach (Cable c in sorted) { int a = index[c.From]; int b = index[c.To]; if (!groups.Union(a, b)) continue; // цикъл tree.Add(c); if (tree.Count == offices.Count - 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
- Продължава в: Алчният алгоритъм и кога работи
- Задачи по темата: Графи