1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 4 из 7

Пути на ДАГе: один проход в топологическом порядке

Коротко

Если граф ациклический, задача становится резко проще: достаточно обойти вершины в топологическом порядке и релаксировать исходящие рёбра каждой - по одному разу. Цена O(V + E), веса любые, включая отрицательные, и никакой кучи не нужно. Работает это потому, что в топологическом порядке все пути в вершину приходят «слева»: к моменту, когда мы её обрабатываем, все рёбра, ведущие в неё, уже релаксированы, и оценка окончательна. Бонусом получается то, чего в общем графе не бывает: самый ДЛИННЫЙ путь на ДАГе тоже ищется за O(V + E) - меняем минимум на максимум. В произвольном графе эта задача NP-трудна - то есть быстрого (полиномиального) алгоритма для неё не знают, и большинство считает, что его нет: самый длинный простой путь по всем вершинам - это гамильтонов путь, одна из известных трудных задач. Разница ровно в отсутствии циклов.

Если ты знаешь алгоритм «топологический порядок плюс одна релаксация на ребро», понимаешь, почему оценка окончательна, и помнишь про длиннейшие пути и критический путь, листай до «Теперь сам».

Как думаешь, зачем тут вообще алгоритм

Мы уже умеем считать топологический порядок и умеем релаксировать. Прежде чем читать дальше, сложи два и два: если обрабатывать вершины ровно в топологическом порядке, сколько раз придётся релаксировать каждое ребро, чтобы все расстояния стали верными? И почему на ациклическом графе отрицательные веса перестают быть проблемой?

На что это похоже

Сборка изделия по технологической карте. Деталь нельзя ставить, пока не готовы все её комплектующие, - и карта как раз выстроена так, чтобы к моменту установки всё нужное уже лежало рядом. Посчитать «самый ранний срок готовности каждой детали» можно за один проход по карте сверху вниз: у каждой детали берём максимум сроков её комплектующих плюс время сборки.

Механизм: топологический порядок плюс один проход

def dag_shortest(start):
    dist = [INF] * V
    dist[start] = 0
    for v in topological_order:            # порядок из главы про топологическую сортировку
        if dist[v] == INF:
            continue                       # вершина недостижима - релаксировать нечего
        for (u, w) in adj[v]:
            if dist[v] + w < dist[u]:
                dist[u] = dist[v] + w
                prev[u] = v
    return dist

Каждое ребро релаксируется ровно один раз - когда обрабатывается его начало. Итого O(V + E) на сам проход плюс O(V + E) на топологическую сортировку.

Самый длинный путь - тот же код с перевёрнутым сравнением:

кратчайший путь : dist[u] = min(dist[u], dist[v] + w)   старт: 0, остальные +∞
длиннейший путь : dist[u] = max(dist[u], dist[v] + w)   старт: 0, остальные −∞
                  (в коде −∞ - это, как и INF, просто число: NEG = -1000000;
                   можно и проще - взять веса с минусом и искать минимум)

Второй вариант в общем графе не работает вовсе: там задача о самом длинном ПРОСТОМ пути NP-трудна (если бы мы умели решать её быстро, умели бы и находить гамильтонов путь). На ДАГе она решается за линию, и причина ровно одна - циклов нет, а значит нет и соблазна накручивать их ради длины.

Правило

На ациклическом графе кратчайшие пути считаются за O(V + E): обойти вершины в топологическом порядке и релаксировать исходящие рёбра каждой по одному разу. Веса любые, включая отрицательные; куча не нужна. Оценка вершины окончательна к моменту её обработки, потому что все ведущие в неё рёбра идут от вершин, стоящих в порядке раньше. Заменив min на max, тем же кодом получаем ДЛИННЕЙШИЙ путь - на ДАГе это линейно, в произвольном графе NP-трудно.

Разбор: почему оценка уже окончательна

Возьмём вершину u и момент, когда алгоритм до неё дошёл. Любой путь из s в u заканчивается каким-то ребром x → u. В топологическом порядке начало ребра стоит раньше конца, значит x была обработана РАНЬШЕ u - а при обработке x мы релаксировали ребро x → u.

Дальше индукция по позиции в порядке: если для всех вершин левее u оценки уже окончательны, то и dist[u] окончательна, потому что она получилась минимумом по всем входящим рёбрам от вершин, чьи оценки верны. База - стартовая вершина с нулём.

Обрати внимание, чего в этом рассуждении НЕТ: ни слова про знак весов. Именно поэтому на ДАГе отрицательные веса ничего не ломают - оптимальный порядок обработки известен заранее из структуры графа, а не выводится из значений dist, как у Дейкстры. И отрицательных циклов тоже быть не может: циклов нет вовсе.

Разбор: критический путь в плане работ

Задача из практики: есть работы с длительностями и зависимости «эту после той». Когда закончится проект и какие работы нельзя задерживать?

Строим ДАГ: вершина - работа, ребро A → B означает «B после A», вес ребра - длительность A. Самый длинный путь от начала до конца и есть критический путь: его длина равна минимально возможному сроку проекта, а работы на нём не имеют запаса - задержка любой сдвигает весь срок.

Чтобы в сумму попала длительность ПОСЛЕДНЕЙ работы, к графу добавляют две фиктивные вершины - «начало» и «конец»: из начала в каждую работу без зависимостей ведёт ребро веса 0, а из каждой работы без продолжений - ребро в конец весом её длительности.

работы и длительности:  A(3), B(5), C(2), D(4)
зависимости:            A → C,   B → C,   C → D
вес ребра = длительность его НАЧАЛА

начало →(0) A →(3) C →(2) D →(4) конец
начало →(0) B →(5) C

самый длинный путь: начало → B → C → D → конец = 0 + 5 + 2 + 4 = 11 дней
путь через A:       начало → A → C → D → конец = 0 + 3 + 2 + 4 =  9 дней
                    → у работы A есть запас в 2 дня

Считается это тем же одним проходом по топологическому порядку с max вместо min. В главе про топологическую сортировку мы считали ЧИСЛО уровней - это частный случай: все длительности равны единице. Теперь длительности произвольные, и ответ получается таким же линейным проходом.

Отсюда же и практический вывод, который стоит унести: увидев в задаче слова «планирование», «зависимости», «самый ранний/поздний срок», ищи ДАГ - и считай один проход, а не запускай Дейкстру.

Что ломается без этого

Первое: применить метод к графу с циклом - топологического порядка не существует, и алгоритм просто не определён; проверку на цикл делают заранее (она же бесплатна при построении порядка). Второе: забыть пропуск недостижимых вершин (dist[v] == INF) - и получить «бесконечность плюс вес» в оценках соседей. Третье: искать длиннейший путь в произвольном графе тем же приёмом - задача NP-трудна, и линейного решения не существует; на графе с циклом положительного веса «длиннейшего» не существует вовсе, как не существует кратчайшего при отрицательном цикле. Четвёртое: считать, что для ДАГа годится и Дейкстра - годится, но она дороже (log на операцию) и запрещает отрицательные веса; на ДАГе нет причин её использовать. Пятое: путать топологический порядок с порядком по номерам вершин: он совпадает только если граф специально так пронумерован.

Зачем это в работе

Планирование проектов и сборок: критический путь, самые ранние и самые поздние сроки, запас по каждой работе - всё это один проход по ДАГу (метод так и называется - сетевое планирование, PERT и CPM). Компиляторы и системы сборки считают по ДАГу порядок и стоимость шагов. Динамическое программирование - это вообще всегда путь по ДАГу подзадач: «посчитать значение через уже посчитанные» буквально означает релаксацию в топологическом порядке, и потому многие задачи ДП удобно видеть как поиск самого длинного или самого короткого пути. А практическая привычка простая: прежде чем брать Дейкстру, проверь, нет ли в графе циклов - если нет, ты сэкономишь и логарифм, и ограничение на знак весов.

Теперь сам

ДАГ: s→a = 3, s→b = 1, a→c = −2, b→c = 4, c→t = 2, топологический порядок s, a, b, c, t. Посчитай кратчайшие расстояния одним проходом. Затем посчитай ДЛИННЕЙШИЕ пути от s и скажи, какая работа лежит на критическом пути.

Ответ. Кратчайшие. Начало: dist[s] = 0, остальные +∞. Обрабатываем s: s→a: dist[a] = 3; s→b: dist[b] = 1. Обрабатываем a: a→c: 3 + (−2) = 1 → dist[c] = 1. Обрабатываем b: b→c: 1 + 4 = 5 < 1? нет - оценка не меняется. Обрабатываем c: c→t: 1 + 2 = 3 → dist[t] = 3. Итог: dist = {s:0, a:3, b:1, c:1, t:3}, маршрут до t - s → a → c → t. Каждое ребро тронули ровно один раз, отрицательный вес −2 не потребовал ничего особенного.

Длиннейшие. Тот же массив оценок, но теперь он хранит не «самый дешёвый», а «самый дорогой» путь; назову его far, чтобы не путать с dist выше. Начало: far[s] = 0, остальные −∞. s: far[a] = 3, far[b] = 1. a: a→c: 3 − 2 = 1 → far[c] = 1. b: b→c: 1 + 4 = 5 > 1 → far[c] = 5. c: c→t: 5 + 2 = 7 → far[t] = 7. Итог: самый длинный путь равен 7 по маршруту s → b → c → t. Обрати внимание: кратчайший и длиннейший пути пошли РАЗНЫМИ дорогами (через a и через b соответственно), и критическим - тем, который определяет срок, - оказался путь через b. Значит задержка работы b сдвинет весь проект, а у работы a есть запас.

Главное

На ациклическом графе кратчайшие пути считаются одним проходом в ТОПОЛОГИЧЕСКОМ порядке: каждое ребро релаксируется ровно один раз, цена O(V + E), куча не нужна, веса любые - включая отрицательные. Корректность следует из порядка, а не из значений: все рёбра, ведущие в вершину, идут от вершин, стоящих раньше, значит к моменту обработки её оценка окончательна (индукция по позиции в порядке; про знак весов в рассуждении нет ни слова). Заменив min на max, тем же кодом получаем ДЛИННЕЙШИЙ путь - на ДАГе это линейно, а в произвольном графе задача NP-трудна. Именно так считают критический путь в планировании: длина самого длинного пути - минимальный срок проекта, а работы на нём не имеют запаса. Частный случай с единичными весами мы уже видели - это уровни параллельности из алгоритма Кана. Общая привычка: прежде чем брать Дейкстру, проверь, нет ли циклов; если их нет, проход по топологическому порядку и дешевле, и свободнее по весам.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.