BFS: най-малко спирки
Накратко: BFS (търсене в ширина) обхожда графа на слоеве с опашка и намира пътя с най-малко ребра за O(V + E).
Проблем
Шест улици вместо четири
Диспечерът на куриерите иска маршрут от склада до болницата с най-малко улици. В града всяка улица е светофар, завой и спиране, затова броят улици е по-важен от метрите.
Колегата е написал търсене, което тръгва по първата свободна улица и продължава, докато стигне. Ако заседне, се връща и опитва следващата:
function anyRoute(graph, at, target, seen) { if (at === target) return [at]; seen.add(at); for (const next of graph.get(at)) { if (seen.has(next)) continue; const rest = anyRoute(graph, next, target, seen); if (rest !== null) return [at, ...rest]; } return null;}const route = anyRoute(graph, "Склад", "Болница", new Set());console.log(`${route.join(" → ")}: ${route.length - 1} улици`);Склад → Гара → Площад → Пазар → Парк → Стадион → Болница: 6 улициПът има, но е с 6 улици. Погледни картата от предишния урок: Склад → Гара → Площад → Кино → Болница са 4. Търсенето не е сгрешило: то не е питало за най-краткия, а е взело първия, който е намерило.
Идеята
Вълна от склада
Представи си, че от склада тръгва вълна. Първо стига до всички спирки на една улица разстояние. После от тях до всички нови на две. После на три. Спирка, до която вълната е стигнала, вече е заета: следващите вълни не я пипат.
Така спирката получава разстояние в момента, в който вълната я докосне за първи път, а то е най-малкото възможно.
Редът „първо по-близките“ го пази опашка: откритите спирки влизат отзад, а обработваната излиза отпред. Това е BFS (breadth-first search, обхождане в ширина).
distпази разстоянието на всяка открита спирка. Той е и множеството на видените: спирка сdistне влиза в опашката втори път.prevпази откъде е открита всяка спирка. С него после се връща самият път.
Виж го
Слой по слой
function bfs(graph, start) { const dist = new Map([[start, 0]]); const prev = new Map(); const queue = [start]; for (let head = 0; head < queue.length; head++) { const stop = queue[head]; for (const next of graph.get(stop)) { if (dist.has(next)) continue; dist.set(next, dist.get(stop) + 1); prev.set(next, stop); queue.push(next); } } return { dist, prev };}function bfs(graph, start) { const dist = new Map([[start, 0]]); const prev = new Map(); const queue = [start]; for (let head = 0; head < queue.length; head++) { const stop = queue[head]; for (const next of graph.get(stop)) { if (dist.has(next)) continue; dist.set(next, dist.get(stop) + 1); prev.set(next, stop); queue.push(next); } } return { dist, prev };}
function pathTo(prev, target) { const path = []; let at = target; while (at !== undefined) { path.push(at); at = prev.get(at); } return path.reverse();}Стъпка по стъпка, с думи
- Започваме от Склад: dist = 0 и е единствената спирка в опашката.
- Склад излиза от опашката. Съседите Гара и Пазар са нови: dist = 1, prev = Склад, и влизат в края на опашката.
- Гара: Склад вече има dist и се прескача. Площад получава dist = 2.
- Пазар също вижда Площад, но Площад вече е в опашката с dist = 2. Всяка спирка влиза в опашката само веднъж. Парк: dist = 2.
- Слой 2 излиза от опашката и отваря слой 3: Кино отвъд моста и Стадион. В опашката има най-много два слоя, по-близкият е отпред.
- Кино открива Болница: dist = 4. Когато и Стадион стигне до нея, тя вече има dist: първото пристигане е най-краткото.
- Около Болница няма нищо ново. Опашката е празна: всяка спирка има разстоянието си от Склад.
- pathTo върви назад по prev: Болница ← Кино ← Площад ← Гара ← Склад. Обърнат, това е маршрутът: 4 улици.
Програмата пуска BFS от склада, печата разстоянието до всяка спирка и пътя до болницата:
const { dist, prev } = bfs(graph, "Склад");for (const stop of stops) { console.log(`${stop}: ${dist.get(stop)}`);}const path = pathTo(prev, "Болница");console.log(`${path.join(" → ")}: ${path.length - 1} улици`);Склад: 0Гара: 1Пазар: 1Площад: 2Парк: 2Кино: 3Болница: 4Стадион: 3Склад → Гара → Площад → Кино → Болница: 4 улициМасивът е опашка с индекс head: push слага отзад, а head показва коя е отпред. Нищо не се маха от началото на масива (защо, виж „По-точно“ по-долу). Как работи опашката отвътре, е в урока „Стек и опашка“.
Пробвай
Подреди редовете
Това е сърцето на BFS: едно завъртане на цикъла. Подреди редовете:
const stop = queue[head];}if (dist.has(next)) continue;prev.set(next, stop);dist.set(next, dist.get(stop) + 1);for (const next of graph.get(stop)) {queue.push(next);
Пробвай
Предвиди изхода
Същият BFS, но тръгва от Кино. Какво печата програмата за Склад, Стадион и Парк?
const { dist } = bfs(graph, "Кино");const ask = ["Склад", "Стадион", "Парк"];console.log(ask.map((s) => dist.get(s)).join(" "));Идеята
Пътят назад
dist казва колко улици са, но не кои. Пътят е в prev: Болница е открита от Кино, Кино от Площад, Площад от Гара, Гара от Склад. Склад няма prev, защото оттам е тръгнало всичко.
Затова пътят се чете отзад напред: от целта по prev до началото, а накрая се обръща.
function pathTo(prev, target) { const path = []; let at = target; while (at !== undefined) { path.push(at); at = prev.get(at); } return path.reverse();}let at = target;
while (at !== undefined) {
path.push(at);
at = ;
}Следващата спирка назад е тази, от която е открита текущата: prev.get(at). При началото няма prev, стойността е undefined и цикълът спира.
По-точно, за любопитните
Пътищата в prev образуват дърво: всяка спирка сочи една-единствена, тази, от която е открита, а коренът е складът. Това се казва дърво на BFS. Всеки клон от корена до спирка е най-краткият път до нея. Затова един BFS от склада дава най-късите пътища до всички спирки наведнъж, не само до болницата.
Ако спирката изобщо не е открита (няма път), тя няма dist. Провери това, преди да викаш pathTo: иначе ще получиш „път“ от една спирка, самата цел.
Идеята
Защо първото пристигане е най-краткото
Опашката държи най-много два слоя: d и d + 1, като по-близкият е отпред. Спирка на d + 1 улици се открива от спирка на d улици, а спирките на d + 2 идват чак когато започнат да излизат тези на d + 1. Затова спирката получава dist от най-краткия път, а всеки по-късен път до нея е поне толкова дълъг и се прескача.
Тук е важно и кога спирката се отбелязва като видяна: в момента, в който влиза в опашката, а не когато излиза. Иначе Площад, който се вижда и от Гара, и от Пазар, щеше да влезе два пъти.
По-точно, за любопитните
Доказателството в една фраза: по индукция по d, всички спирки на разстояние d излизат от опашката преди всички на разстояние d + 1, и всяка получава dist = d. Това важи само ако всяко ребро струва 1. Ако улиците имат различни дължини, по-малко улици не значи по-малко метри. Тогава опашката се сменя с приоритетна опашка по изминатото разстояние, и това е Dijkstra (урокът за Dijkstra в този модул).
Идеята
Колко струва
Всяка спирка влиза в опашката най-много веднъж и излиза веднъж. Когато излезе, поглежда своите улици. Двупосочна улица се вижда от двата си края. Общо: V излизания и 2E погледа, O(V + E).
Това важи за списък от съседи. С матрица „кои са съседите“ е целият ред, и BFS прави V погледа на спирка: O(V²). Пусни и двата върху град-решетка от n кръстовища:
- BFS със списък от съседи
O(n) - BFS с матрица
O(n²)
| Алгоритъм | n = 10 | n = 1000 | n = 5000 | n = 100 000 |
|---|---|---|---|---|
| BFS със списък от съседи | 36 | 4872 | 24 716 | 498 734 |
| BFS с матрица | 110 | 1 001 000 | 25 005 000 | ≈ 10 002 000 000 |
function bfs(graph, start) { const dist = new Map([[start, 0]]); const prev = new Map(); const queue = [start]; for (let head = 0; head < queue.length; head++) { const stop = queue[head]; for (const next of graph.get(stop)) { if (dist.has(next)) continue; dist.set(next, dist.get(stop) + 1); prev.set(next, stop); queue.push(next); } } return { dist, prev };}Броят се всяка спирка, излязла от опашката, и всеки поглед към съсед. При 1000 кръстовища списъкът прави 4 872 операции, а матрицата 1 001 000. При 100 000 кръстовища списъкът прави 498 734, около 5 на кръстовище: едно излизане и до 4 улици. Матрицата при 5000 вече е 25 005 000.
Пробвай
Намери грешката
Колега реши, че разстоянието на новата спирка е „следващият номер“. Програмата печата:
function bfs(graph, start) { const dist = new Map([[start, 0]]); const prev = new Map(); const queue = [start]; for (let head = 0; head < queue.length; head++) { const stop = queue[head]; for (const next of graph.get(stop)) { if (dist.has(next)) continue; dist.set(next, dist.size); prev.set(next, stop); queue.push(next); } } return { dist, prev };}Склад: 0Гара: 1Пазар: 2Площад: 3Парк: 4Кино: 5Болница: 7Стадион: 6Склад → Гара → Площад → Кино → Болница: 4 улициПътят е верен, 4 улици, а Болница излиза на 7. На кой ред е грешката?
Избери реда с грешката.
Разстоянието на новата спирка е разстоянието на тази, от която я откриваш, плюс една улица: dist.get(stop) + 1. Броят на откритите спирки е само поредният ѝ номер в опашката. Двете съвпадат само докато всеки слой има по една спирка.
По-точно
Опашката във всеки език
JavaScript няма вградена опашка. queue.shift() маха първия елемент и по спецификация мести всички останали: O(n) на взимане. Масивът с индекс head не маха нищо, само мести индекса: O(1). Цената е, че масивът пази всички минали спирки до края на BFS, но те са V на брой, колкото и dist.
Упражнение
Слоевете на града
Реши задачата в редактора си.
Слоеве и мостове. С BFS от склада изведи спирките по слоеве (спирките на всеки слой в реда на stops) и най-далечната спирка. После затвори двата моста: махни ги от списъка от съседи, пусни BFS пак и изведи до кои спирки вече не се стига.
const bridges = [ ["Пазар", "Парк"], ["Площад", "Кино"],];Слой 0: СкладСлой 1: Гара, ПазарСлой 2: Площад, ПаркСлой 3: Кино, СтадионСлой 4: БолницаНай-далеч: Болница (4 улици)Без мостовете не се стига до: Парк, Кино, Болница, СтадионОще задачи
- Най-близката аптека. Някои спирки имат аптека. Пусни един BFS, който тръгва едновременно от всички аптеки (всички са в опашката с
dist0), и изведи за всяка спирка колко улици я делят от най-близката. - Лабиринт от сгради. Складът е решетка от рафтове (
#) и пътеки (.). Намери най-малко стъпки от входа до изхода, като съседите на клетка са горе, долу, ляво и дясно. - Всички най-къси. Преброй колко различни най-къси пътя има от склада до всяка спирка.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Big-O: как расте работата · Сложност по памет · Стек и опашка · Граф: списък от съседи и матрица
- Продължава в: DFS: острови и кръгове · Топологично сортиране · Dijkstra · Навигация в града
- Задачи по темата: Графи