DFS: острови и кръгове
Накратко: DFS (търсене в дълбочина) върви по един път докрай и се връща, с рекурсия или със стек; намира свързаните компоненти и кръговете в граф за O(V + E).
Проблем
Градът, разрязан от реката
Общината затваря двата моста за ремонт. Куриерите вече не могат да минат реката, и диспечерът трябва да знае на колко части се разпада градът и кои спирки са във всяка част: всяка част ще обслужва отделен бус.
От урока за BFS знаеш как да намериш докъде се стига от склада. Но частите може да са много, и всяка трябва да се намери цялата. Трябва обход, който тръгва от всяка спирка, още без част, и маркира всичко, до което стига.
Има и втори проблем в същия град. Планът на доставките има правила „А се доставя преди Б“. Някой е добавил грешно правило, и вече няма ред, който да спазва всички. Кое правило е виновно?
Идеята
Докъдето стига улицата
DFS (depth-first search, обхождане в дълбочина) върви като човек в лабиринт: тръгва по първата улица към непозната спирка, оттам по първата към следващата непозната, и така, докато стигне спирка, около която всичко е видяно. Тогава се връща една спирка назад и опитва следващата ѝ улица.
С рекурсия връщането назад става само: visit за спирката вика visit за първия непознат съсед, и когато той свърши, цикълът продължава със следващия. Стекът на извикванията помни докъде е стигнала всяка спирка.
- Всяка спирка получава номера на острова си веднага при влизане. Това е и множеството на видените: спирка с номер не се посещава втори път.
- Остров (свързана компонента) е всичко, до което стига един
visit. Цикълът по всички спирки пуска новvisitот всяка спирка без номер.
Виж го
Надолу и назад
static void Visit(Dictionary<string, List<string>> graph, string stop, int island, Dictionary<string, int> islandOf){ islandOf[stop] = island; foreach (string next in graph[stop]) { if (!islandOf.ContainsKey(next)) { Visit(graph, next, island, islandOf); } }}
static Dictionary<string, int> Islands( Dictionary<string, List<string>> graph, string[] stops){ var islandOf = new Dictionary<string, int>(); int count = 0; foreach (string stop in stops) { if (islandOf.ContainsKey(stop)) continue; count++; Visit(graph, stop, count, islandOf); } return islandOf;}static void Visit(Dictionary<string, List<string>> graph, string stop, int island, Dictionary<string, int> islandOf){ islandOf[stop] = island; foreach (string next in graph[stop]) { if (!islandOf.ContainsKey(next)) { Visit(graph, next, island, islandOf); } }}
static Dictionary<string, int> Islands( Dictionary<string, List<string>> graph, string[] stops){ var islandOf = new Dictionary<string, int>(); int count = 0; foreach (string stop in stops) { if (islandOf.ContainsKey(stop)) continue; count++; Visit(graph, stop, count, islandOf); } return islandOf;}Стъпка по стъпка, с думи
- Двата моста са затворени. islands минава спирките по ред: Склад още няма остров, затова count = 1 и visit тръгва от него.
- visit(Склад) му дава остров 1 и веднага слиза в първия съсед без остров: Гара. Склад чака сив в стека на извикванията.
- Все по-надълбоко: Гара → Площад → Пазар. Четири извиквания са отворени едновременно и всяко чака по-горното.
- Около Пазар всичко вече има остров. visit се връща и Пазар почернява. Назад по стека: и при Площад и Гара няма нищо ново.
- Склад пак е отгоре. Последният му съсед Пазар вече е видян и първият обход свършва: остров 1 е Склад, Гара, Пазар, Площад. Затворените мостове са го спрели на реката.
- Цикълът в islands продължава: Гара, Пазар и Площад вече имат остров и се прескачат. Парк няма: count = 2.
- Същият обход на другия бряг: Парк → Стадион → Болница → Кино, после назад до Парк.
- Два острова: по 4 спирки на всеки бряг. Всяка спирка получи номер веднъж, всяка улица беше погледната от двата края: O(V + E).
Програмата затваря мостовете, пуска Islands и печата островите:
(string, string)[] closed =[ ("Пазар", "Парк"), ("Площад", "Кино"),];foreach (var (a, b) in closed){ graph[a].Remove(b); graph[b].Remove(a);}var islandOf = Islands(graph, stops);int count = islandOf.Values.Max();Console.WriteLine("Острови: " + count);for (int i = 1; i <= count; i++){ var members = stops.Where(s => islandOf[s] == i); Console.WriteLine($"Остров {i}: {string.Join(", ", members)}");}Острови: 2Остров 1: Склад, Гара, Пазар, ПлощадОстров 2: Парк, Кино, Болница, СтадионНомерът на острова пази Dictionary<string, int>, а броят им е най-големият номер в него.
Пробвай
Предвиди изхода
Мостът Пазар – Парк е отворен, но около Кино се копае: затворени са Площад – Кино и Кино – Болница. Колко острова печата програмата?
(string, string)[] closed =[ ("Площад", "Кино"), ("Кино", "Болница"),];foreach (var (a, b) in closed){ graph[a].Remove(b); graph[b].Remove(a);}var islandOf = Islands(graph, stops);int count = islandOf.Values.Max();Console.WriteLine("Острови: " + count);Идеята
Свой стек вместо рекурсия
Тук островите са по 4 спирки и рекурсията е дълбока 4 извиквания. В град-решетка от 100 000 кръстовища обходът в дълбочина може да слезе на десетки хиляди нива, а всяко ниво е рамка в стека на извикванията. Стекът е малък и се препълва. Как се превръща рекурсия в цикъл, е в урока „От рекурсия към цикъл“. Тук е същото: стекът става обикновена колекция.
static List<string> Walk(Dictionary<string, List<string>> graph, string start){ var order = new List<string>(); var seen = new HashSet<string>(); var stack = new Stack<string>(); stack.Push(start); while (stack.Count > 0) { string stop = stack.Pop(); if (!seen.Add(stop)) continue; order.Add(stop); var next = graph[stop]; for (int i = next.Count - 1; i >= 0; i--) { if (!seen.Contains(next[i])) stack.Push(next[i]); } } return order;}Две неща са по-различни от рекурсията:
- Спирка може да влезе в стека два пъти: от двама съседи, преди да е посетена. Затова проверката „видяна ли е“ е и при взимането: втория път се прескача.
- Съседите влизат наобратно. Последният сложен излиза пръв, затова първият съсед трябва да е най-отгоре. Така редът е същият като при рекурсията:
var islandOf = new Dictionary<string, int>();Visit(graph, "Склад", 1, islandOf);Console.WriteLine("Рекурсия: " + string.Join(", ", islandOf.Keys));Console.WriteLine("Свой стек: " + string.Join(", ", Walk(graph, "Склад")));Рекурсия: Склад, Гара, Площад, Пазар, Парк, Стадион, Болница, КиноСвой стек: Склад, Гара, Площад, Пазар, Парк, Стадион, Болница, КиноПо-точно, за любопитните
Ако сложиш съседите в реда на списъка, обходът пак е в дълбочина и пак намира същите острови, само редът на посещение е друг. За островите редът няма значение. За кръговете в насочен граф по-долу има: там трябва да знаеш кога свършва всяка спирка, а със стек от спирки това е по-трудно. Затова findCycle е рекурсивен, а при дълбоки графи в стека се слага двойка (спирка, докъде е стигнала в списъка си).
Идеята
Кръг в плана на доставките
Правилата на плана са насочен граф: стрелката Пазар → Парк значи „Пазар преди Парк“. Ако стрелките образуват кръг, няма ред, който да ги спазва: всяка спирка от кръга трябва да е преди себе си.
Само „видена“ не стига, за да хванеш кръг. Пазар → Площад води до видена спирка, но кръг няма: от Площад не се стига обратно до Пазар. Затова спирките имат три цвята:
- бяла: още не е виждана;
- сива: на текущия път, извикването ѝ още е отворено;
- черна: всичко след нея е проверено и кръг няма.
Стрелка към сива спирка води обратно в собствения път: това е кръгът.
static List<string>? FindCycle( Dictionary<string, List<string>> plan, string stop, Dictionary<string, string> color, List<string> path){ color[stop] = "grey"; path.Add(stop); foreach (string next in plan[stop]) { string c = color.GetValueOrDefault(next, "white"); if (c == "grey") { var cycle = path[path.IndexOf(next)..]; cycle.Add(next); return cycle; } if (c == "white") { var found = FindCycle(plan, next, color, path); if (found != null) return found; } } color[stop] = "black"; path.RemoveAt(path.Count - 1); return null;}static List<string>? FindCycle( Dictionary<string, List<string>> plan, string stop, Dictionary<string, string> color, List<string> path){ color[stop] = "grey"; path.Add(stop); foreach (string next in plan[stop]) { string c = color.GetValueOrDefault(next, "white"); if (c == "grey") { var cycle = path[path.IndexOf(next)..]; cycle.Add(next); return cycle; } if (c == "white") { var found = FindCycle(plan, next, color, path); if (found != null) return found; } } color[stop] = "black"; path.RemoveAt(path.Count - 1); return null;}Стъпка по стъпка, с думи
- Всяка стрелка е правило: спирката, от която излиза, се доставя първа. Всички спирки са бели, никой не е виждан. Обходът тръгва от Склад.
- Сиво значи „на текущия път“: Склад → Гара → Площад. Тези извиквания още са отворени.
- От Площад не излизат правила: почернява, всичко след нея е проверено и тя излиза от пътя. И Гара е готова.
- И Пазар сочи към Площад, но Площад е черна: от нея няма път обратно до Пазар. Две правила към една спирка не са кръг.
- Отвъд реката пътят расте: Парк → Кино → Болница → Стадион, всички сиви.
- Стадион → Парк: Парк е сив, още е на пътя. Стрелката води обратно в самия път, а това е кръг.
- Кръгът е пътят от Парк до края: Парк → Кино → Болница → Стадион → Парк. Никой ред на тези четири спирки не спазва всички правила, затова правилото Стадион → Парк трябва да отпадне.
Цикълът пуска findCycle от всяка бяла спирка, както islands пуска visit:
static List<string>? AnyCycle( Dictionary<string, List<string>> plan, string[] stops){ var color = new Dictionary<string, string>(); foreach (string stop in stops) { if (color.ContainsKey(stop)) continue; var found = FindCycle(plan, stop, color, []); if (found != null) return found; } return null;}(string, string)[] rules =[ ("Склад", "Гара"), ("Гара", "Площад"), ("Склад", "Пазар"), ("Пазар", "Площад"), ("Пазар", "Парк"), ("Парк", "Кино"), ("Кино", "Болница"), ("Болница", "Стадион"), ("Стадион", "Парк"),];var cycle = AnyCycle(plan, stops);Console.WriteLine(cycle == null ? "Няма кръг" : "Кръг: " + string.Join(" → ", cycle));Кръг: Парк → Кино → Болница → Стадион → ПаркМахни последното правило, и програмата печата Няма кръг. Тогава редът, в който спирките почерняват, е обърнатият ред на доставките. На това стъпва топологичното сортиране, следващият урок.
static List<string>? FindCycle(
Dictionary<string, List<string>> plan, string stop,
Dictionary<string, string> color, List<string> path)
{
color[stop] = "grey";
path.Add(stop);
foreach (string next in plan[stop])
{
string c = color.GetValueOrDefault(next, "white");
if (c == "grey")
{
var cycle = path[path.IndexOf(next)..];
cycle.Add(next);
return cycle;
}
if (c == "white")
{
var found = FindCycle(plan, next, color, path);
if (found != null) return found;
}
}
color[stop] = ;
path.RemoveAt(path.Count - 1);
return null;
}Когато всичко след спирката е проверено и кръг няма, тя става "black": стрелка към нея по-късно не е кръг. Ако остане сива, следващата стрелка към нея ще бъде взета за кръг.
По-точно, за любопитните
В двупосочен граф всяка улица е стрелка и в двете посоки, и всеки съсед, от който си дошъл, е „видян“. Там кръг има, ако стигнеш видена спирка, която не е тази, от която току-що идваш. Трите цвята трябват за насочен граф.
Идеята
Колко струва
Всяка спирка получава номер веднъж и се посещава веднъж. При посещението си поглежда своите улици, а двупосочна улица се вижда от двата края. Общо: V посещения и 2E погледа, O(V + E), колкото BFS.
Линейно е само ако номерът на острова е общ за целия обход. Ако за всяка спирка питаш „на кой остров съм?“ с нов обход и празно множество на видените, правиш n обхода. Пусни и двата върху град-решетка от n кръстовища, която реката реже на две:
- Един общ islandOf за целия град
O(n) - Нов обход за всяко кръстовище
O(n²)
| Алгоритъм | n = 10 | n = 1000 | n = 2000 | n = 100 000 |
|---|---|---|---|---|
| Един общ islandOf за целия град | 28 | 4808 | 9730 | 498 100 |
| Нов обход за всяко кръстовище | 148 | 2 405 440 | 9 730 980 | ≈ 24 327 450 000 |
static Dictionary<string, int> Islands( Dictionary<string, List<string>> graph, string[] stops){ var islandOf = new Dictionary<string, int>(); int count = 0; foreach (string stop in stops) { if (islandOf.ContainsKey(stop)) continue; count++; Visit(graph, stop, count, islandOf); } return islandOf;}Броят се всяка спирка, взета от стека, и всеки поглед към съсед. И двата са със свой стек: при 100 000 кръстовища рекурсията в браузъра пада с препълнен стек. При 1000 кръстовища общият номер прави 4 808 операции, а новият обход за всяко кръстовище 2 405 440. При 2000 новият обход е вече 9 730 980, а общият при 100 000 прави 498 100.
Пробвай
Намери грешката
Колега реши, че в visit островът трябва да расте с всяка стъпка. Програмата печата:
static void Visit(Dictionary<string, List<string>> graph, string stop, int island, Dictionary<string, int> islandOf){ islandOf[stop] = island; foreach (string next in graph[stop]) { if (!islandOf.ContainsKey(next)) { Visit(graph, next, island + 1, islandOf); } }}Острови: 5Остров 1: СкладОстров 2: Гара, ПаркОстров 3: Площад, СтадионОстров 4: Пазар, БолницаОстров 5: КиноОстровите са пет, а Гара и Парк са „на един остров“, макар мостовете да са затворени. На кой ред е грешката?
Избери реда с грешката.
Всички спирки, до които стига един visit, са на един остров: номерът се подава надолу непроменен. С island + 1 номерът става дълбочината в обхода, а спирки на една дълбочина от различни острови получават един и същ номер.
По-точно
Колко дълбоко може рекурсията
Стекът на нишка в .NET е около 1 MB. Препълването (StackOverflowException) не може да се хване: процесът спира. Затова при голям граф ползвай Stack<T>. Ако все пак трябва рекурсия, може да я пуснеш в Thread с по-голям стек.
Упражнение
Критичните улици
Реши задачата в редактора си.
Критични улици. Мостът Пазар – Парк е затворен за месец, а Площад – Кино още работи. Улица е критична, ако затварянето ѝ разделя града на повече острови. Затвори моста, изведи колко острова има, после за всяка отворена улица я махни, преброй островите с islands и я върни. Изведи критичните улици в реда на streets.
(string, string)[] closed =[ ("Пазар", "Парк"),];Острови: 1Критични: Площад - Кино, Кино - Болница, Парк - Стадион, Болница - СтадионОще задачи
- Най-големият остров. Изведи острова с най-много спирки и колко бус-часа на седмица му трябват, ако всяка спирка иска 3 часа.
- Кръгът в плана. Направи
findCycleсъс свой стек: в стека слагай двойка (спирка, индекс на следващия съсед), а спирката почернява, когато индексът стигне края на списъка ѝ. - Острови на картата. Картата на склада е решетка от
#(рафт) и.(свободно). Преброй свободните зони, като съседи на клетка са горе, долу, ляво и дясно.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Стекът от извиквания отвътре · От рекурсия към цикъл · Граф: списък от съседи и матрица · BFS: най-малко спирки
- Продължава в: Топологично сортиране · Минимално покриващо дърво · Архитектурен преглед
- Задачи по темата: Графи