Кратчайший путь в графе
Дан файл с дорогами между пунктами, надо найти самый короткий маршрут. Как навигатор, только навигатор пишем сами: один шаблон на все задачи номера.
- 1Что просят сделать
- 2Идея: уточняем снова и снова
- 3Шаг 1. Читаем файл
- 4Шаг 2. Список расстояний
- 5Шаг 3. Ищем кратчайшие пути
- 6Обязательное первое и последнее ребро
- 7Самый длинный путь и тупики
- 8Второй способ: рекурсия как в №13
- 9Разновидности задачи
- 10Как записать ответ
- 11Ловушки
- 12Практика
- 13Домашка
Что просят сделать
Это новое задание, и по смыслу оно похоже на навигатор в телефоне. Есть пункты, между ними дороги разной длины, и нужно найти самый короткий маршрут из одного пункта в другой.
На картинке с пятью пунктами ответ видно глазами. Но в задании дан файл, где пунктов и дорог тысячи, поэтому решаем кодом.
Как выглядит файл
В каждой строке одна дорога: откуда, куда и какой она длины.
1 20 10.0 1 5 4.5 5 20 3.0 20 8 1.5
Дороги односторонние, а циклов в графе нет: из 1 в 20 можно, а обратно только если в файле есть отдельная такая строка. Длина бывает дробной.
Простыми словами
Ты не обязан знать весь маршрут сразу. Достаточно для каждого пункта помнить лучшее расстояние от старта, которое удалось найти на этот момент. Нашёл дорогу короче, заменил число. Когда заменять стало нечего, ответ готов.
На дорогу из пунктов A в B смотрим так: лучший путь до A плюс длина этой дороги. Если это короче того, что записано у B, записываем новое число.
Идея: уточняем снова и снова
Возьмём маленький граф и будем идти по строкам файла по порядку:
| откуда | куда | длина |
|---|---|---|
| 5 | 20 | 3 |
| 20 | 8 | 1,5 |
| 1 | 20 | 10 |
| 3 | 20 | 9 |
| 1 | 5 | 4,5 |
| 1 | 3 | 2 |
Старт в пункте 1. До него расстояние 0, до остальных пока неизвестно, запишем ∞. Вот что получается после каждого прохода по всему файлу:
| проход | до 1 | до 3 | до 5 | до 20 | до 8 |
|---|---|---|---|---|---|
| 1 | 0 | 2 | 4,5 | 10 | ∞ |
| 2 | 0 | 2 | 4,5 | 7,5 | 9 |
| 3 | 0 | 2 | 4,5 | 7,5 | 9 |
Посмотри на первую строку файла, дорогу 5 → 20. В первом проходе она бесполезна: расстояние до 5 ещё неизвестно, а ∞ + 3 это снова ∞. Строки в файле идут в случайном порядке, поэтому одного прохода не хватает.
Выход простой: проходим по файлу ещё раз. Теперь расстояние до 5 уже известно, и эта же строка даёт путь до 20 короче прежнего. Так числа постепенно уточняются.
Когда остановиться? Когда очередной проход не изменил ни одного числа. Значит, все кратчайшие пути уже найдены. В таблице это последний проход: он совпал с предыдущим.
Посмотри на таблицу проходов. Чему равна длина кратчайшего пути из 1 в 20? Ответ запиши без запятой, умножив на 10.
Подсказка
Смотри столбец «до 20» в последней строке.
Шаг 1. Читаем файл
Сначала превращаем файл в список дорог. Учебный файл 23-demo.txt лежит рядом с кодом: 60 пунктов, номера от 1 до 60.
data = [] for line in open('23-demo.txt'): a, b, l = line.split() data.append([int(a), int(b), float(l)]) print(len(data)) print(data[0])
line.split()разрезает строку на три части по пробелам и табуляциям.- Номера пунктов целые, поэтому
int. Длина может быть дробной, поэтомуfloat. - Каждая дорога лежит в
dataотдельным списком[a, b, l].
Шаг 2. Список расстояний
Нам нужно хранить лучшее расстояние до каждого пункта. Удобный приём: номер элемента списка это номер пункта. Тогда dist[7] это лучшее известное расстояние до пункта 7.
dist = [float('inf')] * 61 dist[1] = 0
Разберём по частям:
float('inf')это бесконечность. Она больше любого числа, поэтому первая же найденная дорога окажется лучше.[x] * 61создаёт список из 61 одинакового элемента. Посмотри сам:[1] * 3даёт[1, 1, 1].- Почему 61, а не 60? Индексы начинаются с нуля, а пункта с номером 0 нет. Чтобы существовал
dist[60], нужен 61 элемент. Размер бери из условия: наибольший номер пункта плюс один. dist[1] = 0: от старта до самого себя идти ноль. Здесь старт это пункт 1, в задаче номер будет свой. Без этой строки все числа так и останутся бесконечностью.
Шаг 3. Ищем кратчайшие пути
Весь шаблон целиком. Он один на все задачи номера, дальше меняются только детали.
data = [] for line in open('23-demo.txt'): a, b, l = line.split() data.append([int(a), int(b), float(l)]) dist = [float('inf')] * 61 dist[1] = 0 while True: old = dist.copy() for a, b, l in data: dist[b] = min(dist[b], dist[a] + l) if old == dist: break print(dist[60])
Главная строка:
dist[b] = min(dist[b], dist[a] + l)
Читается так: лучший путь до b это меньшее из двух чисел. Первое мы уже знаем, dist[b]. Второе получится, если прийти в a лучшим путём и пройти по этой дороге, dist[a] + l. Обрати внимание: прибавлять только l нельзя, ведь нужно расстояние от старта, а не от a.
Остальное:
while Trueповторяет проходы, пока мы сами не остановим цикл.old = dist.copy()делает снимок списка перед проходом.if old == dist: break: если после прохода ничего не поменялось, все пути найдены.
Найди длину кратчайшего пути в учебном файле из пункта 1 в пункт 60. В ответ запиши целую часть.
data = []
for line in open('23-demo.txt'):
a, b, l = line.split()
data.append([int(a), int(b), float(l)])
# дальше твой код
Подсказка
Запусти шаблон выше. Посмотри на вывод и возьми целую часть глазами.
Обязательное первое и последнее ребро
Иногда условие требует: путь обязан начинаться с определённой дороги или заканчиваться определённой дорогой.
Самый простой способ: выбросить все дороги, по которым идти нельзя. Тогда алгоритм просто не сможет ошибиться.
Пусть путь из 1 в 60, первая дорога обязана вести в 14, а последняя обязана идти из 59. Какие строки файла лишние?
- из старта, но не в 14;
- в финиш, но не из 59.
data = [] for line in open('23-demo.txt'): a, b, l = line.split() a, b, l = int(a), int(b), float(l) if a == 1 and b != 14: # первое ребро обязано вести в 14 continue if b == 60 and a != 59: # последнее ребро обязано идти из 59 continue data.append([a, b, l]) dist = [float('inf')] * 61 dist[1] = 0 while True: old = dist.copy() for a, b, l in data: dist[b] = min(dist[b], dist[a] + l) if old == dist: break print(dist[60])
continue пропускает остаток шага цикла. До строки append мы не доходим, поэтому лишняя дорога в список не попадает. Вся остальная часть шаблона не меняется.
Запусти код выше и запиши целую часть длины пути.
data = []
for line in open('23-demo.txt'):
a, b, l = line.split()
data.append([int(a), int(b), float(l)])
# дальше твой код
Подсказка
Код уже написан выше, его можно скопировать.
Самый длинный путь и тупики
Встречается вопрос про самую длинную дорогу. Логика та же, только всё наоборот:
| что меняем | кратчайший | самый длинный |
|---|---|---|
| начальное значение | float('inf') | -float('inf') |
| выбор | min(...) | max(...) |
Минус бесконечность меньше любого числа, поэтому первая найденная дорога окажется лучше неё.
Что такое тупик
Тупик это пункт, из которого не выходит ни одна дорога. Значит, его номер ни разу не стоит на месте a. Собираем все номера, которые встречались как a, а остальные и есть тупики:
data = [] for line in open('23-demo.txt'): a, b, l = line.split() data.append([int(a), int(b), float(l)]) dist = [-float('inf')] * 61 dist[5] = 0 while True: old = dist.copy() for a, b, l in data: dist[b] = max(dist[b], dist[a] + l) if old == dist: break starts = {a for a, b, l in data} tupiki = [x for x in range(1, 61) if x not in starts] print(tupiki) print(max(dist[x] for x in tupiki))
Фигурные скобки {a for ...} создают множество: номера без повторов, и проверка not in работает мгновенно. Недостижимые тупики сами отсеются: у них осталась минус бесконечность, и max их не выберет.
Самый длинный путь этим кодом можно искать, только если в графе нет циклов. В условии №23 граф так и описан: направленный и без циклов.
В учебном файле старт в пункте 5. Найди наибольшую длину пути до какого-либо тупика и запиши целую часть.
data = []
for line in open('23-demo.txt'):
a, b, l = line.split()
data.append([int(a), int(b), float(l)])
# дальше твой код
Подсказка
Запусти код выше.
Второй способ: рекурсия как в №13
Эту же задачу можно решить иначе, и многие решают именно так. Способ похож на №13: там считали, сколько программ из одного числа в другое. Здесь граф без циклов, поэтому работает та же рекурсия, только вместо количества ищем наименьшую длину.
Выбери один способ и решай им все задачи. Способ с проходами короче для простых вопросов, а рекурсия удобнее для разновидностей из следующего раздела.
Простыми словами
Встань в пункт и спроси: куда я могу пойти отсюда? Длина лучшего пути от тебя равна длине дороги плюс лучшему пути из следующего пункта. Из всех дорог выбираем самую выгодную. Дошли до цели, осталось идти ноль. Попали в тупик, такой путь считаем бесконечным, и min его отбросит.
Сначала словарь
Для рекурсии удобно хранить для каждого пункта список дорог из него. Для этого подходит словарь:
d = { 1: [(20, 10.0), (5, 4.5), (3, 2.0)], 5: [(20, 3.0)] }
Из пункта 1 идут три дороги: в 20 длиной 10, в 5 длиной 4,5 и в 3 длиной 2.
Весь код
from functools import cache d = {} for line in open('23-demo.txt'): a, b, l = line.split() a, b, l = int(a), int(b), float(l) if a not in d: d[a] = [] d[a].append((b, l)) @cache def f(c, e): if c == e: # пришли в цель return 0 if c not in d: # тупик, сюда идти незачем return float('inf') return min(l + f(b, e) for b, l in d[c]) print(f(1, 60))
c not in dзначит, что из этого пункта нет дорог.l + f(b, e)это длина дороги плюс лучший путь от следующего пункта.@cacheзапоминает уже посчитанные пункты. На маленьком файле код работает и без него, но на большом без кэша он почти не закончится: путей слишком много.
Для самого длинного пути всего две замены: max вместо min и -float('inf') вместо float('inf').
Разновидности задачи
Вот какие ещё бывают вопросы. Каждый решается маленькой правкой функции из прошлого раздела.
Не проходит через вершину
Запрещённый пункт считаем таким же плохим, как тупик. Здесь нельзя идти через пункт 30:
if c not in d or c == 30: return float('inf')
Проходит через вершину
Путь разбивается на две части, а длины складываются. В №13 в этом месте было умножение, потому что считали количество. Здесь длины, поэтому плюс:
print(f(1, 31) + f(31, 60))
Содержит конкретное ребро
Та же идея: путь до начала ребра, само ребро и путь от его конца.
print(f(1, 31) + 1.62 + f(45, 60))
Не больше k рёбер
Добавляем третий параметр: сколько рёбер ещё можно потратить. Кэш здесь работает, потому что ответ зависит только от пункта и остатка.
from functools import cache d = {} for line in open('23-demo.txt'): a, b, l = line.split() a, b, l = int(a), int(b), float(l) if a not in d: d[a] = [] d[a].append((b, l)) @cache def f(c, e, k): if c == e: return 0 if c not in d or k == 0: return float('inf') return min(l + f(b, e, k - 1) for b, l in d[c]) print(f(1, 60, 7))
Наименьшее число рёбер
Вместо длины дороги прибавляем единицу, и функция считает рёбра:
from functools import cache d = {} for line in open('23-demo.txt'): a, b, l = line.split() a, b, l = int(a), int(b), float(l) if a not in d: d[a] = [] d[a].append((b, l)) @cache def f(c, e): if c == e: return 0 if c not in d: return float('inf') return min(1 + f(b, e) for b, l in d[c]) print(f(1, 60))
Длина в заданных пределах
Например: самый длинный путь, но не больше 22. Тут нельзя запоминать один ответ на пункт, и это самая коварная разновидность. Лучший путь от промежуточного пункта может вылезти за предел, а чуть более короткий подошёл бы. Кэш же хранит только один лучший ответ на пункт, поэтому нужный вариант теряется. Приходится перебирать все пути и держать накопленную сумму параметром:
d = {} for line in open('23-demo.txt'): a, b, l = line.split() a, b, l = int(a), int(b), float(l) if a not in d: d[a] = [] d[a].append((b, l)) def f(c, e, s): if c == e: return s if s <= 22 else -float('inf') if c not in d: return -float('inf') return max(f(b, e, s + l) for b, l in d[c]) print(f(40, 60, 0))
Это видно прямо на учебном файле: перебор находит 21,99, а если убрать параметр s и запоминать один лучший ответ на пункт, получится 19,46, то есть неверный ответ. На большом файле это может считаться очень долго, ведь перебираются все пути. Такие задачи на экзамене маловероятны, но в списке возможных разновидностей они есть.
Сводка
| вопрос | что поменять | кэш |
|---|---|---|
| самый длинный путь | max и -inf | да |
| не через вершину | вершина как тупик | да |
| через вершину | сумма двух вызовов | да |
| через ребро | два вызова плюс длина ребра | да |
| не больше k рёбер | параметр k | да |
| меньше всего рёбер | 1 + вместо l + | да |
| длина в пределах | параметр s, полный перебор | без пользы |
В учебном файле найди длину кратчайшего пути из 1 в 60, проходящего через пункт 31. Запиши целую часть.
d = {}
for line in open('23-demo.txt'):
a, b, l = line.split()
a, b, l = int(a), int(b), float(l)
if a not in d:
d[a] = []
d[a].append((b, l))
# дальше твой код
Найди длину кратчайшего пути из 1 в 60, не проходящего через пункт 30. Запиши целую часть.
d = {}
for line in open('23-demo.txt'):
a, b, l = line.split()
a, b, l = int(a), int(b), float(l)
if a not in d:
d[a] = []
d[a].append((b, l))
# дальше твой код
Какое наименьшее число рёбер может быть в пути из 1 в 60?
d = {}
for line in open('23-demo.txt'):
a, b, l = line.split()
a, b, l = int(a), int(b), float(l)
if a not in d:
d[a] = []
d[a].append((b, l))
# дальше твой код
Подсказка
Прибавляй единицу вместо длины дороги.
Сколько всего разных путей ведёт из 1 в 60? Если между двумя пунктами несколько дорог, пути по разным дорогам считаются разными.
d = {}
for line in open('23-demo.txt'):
a, b, l = line.split()
a, b, l = int(a), int(b), float(l)
if a not in d:
d[a] = []
d[a].append((b, l))
# дальше твой код
Подсказка
Такая задача решается как №13. Без кэша может считаться долго.
Как записать ответ
Часто просят целую часть длины. Хочется написать int(dist[60]), но тут есть подвох. Дробные числа Python считает с крошечной погрешностью:
print(0.1 + 0.2)
Сумма может оказаться чуть меньше настоящей, например 284.99999999999994 вместо 285. А int просто отбрасывает дробную часть и даст 284.
Поэтому выведи число как есть и посмотри на него глазами. Если после запятой длинный ряд девяток, настоящее число это следующее целое.
Ловушки
В ответе вывелось inf: пути нет, а чаще в коде опечатка. Проверь номера пунктов и цикл чтения. Зато это сразу видно, в отличие от правдоподобного, но неверного числа.
Размер списка бери из условия. Если номера идут до 10000, нужен список из 10001 элемента, иначе программа упадёт с IndexError.
Пиши old = dist.copy(). Просто old = dist не создаёт копию: обе переменные смотрят на один список, сравнение сразу даст равенство, и цикл остановится после первого прохода.
Не забудь dist[старт] = 0. Без этого все значения останутся бесконечностью.
dist[a] + l, а не просто l: нам нужно расстояние от старта.
Если в файле дробные числа записаны через запятую, добавь line.replace(',', '.').
Файл с задачей должен лежать в одной папке с кодом, а имя файла надо переписать точно.
Практика
Порядок действий одинаков для любой задачи номера:
- Прочитай условие: откуда, куда, что особенного и какой наибольший номер пункта.
- Выбери способ: проходы с
distили рекурсию. - Учти особенности: запреты,
maxвместоmin, тупики, число рёбер. - Запусти, посмотри на число и запиши ответ.
Закрепи всё на учебном файле, без подсказок:
Учебный файл. Найди длину кратчайшего пути из пункта 3 в пункт 50. Запиши целую часть. Заготовка содержит только чтение файла, остальное напиши сам.
data = []
for line in open('23-demo.txt'):
a, b, l = line.split()
data.append([int(a), int(b), float(l)])
# дальше твой код
Подсказка
Не забудь поменять старт: dist[3] = 0.
Там же: сколько пунктов вообще достижимо из пункта 40? Сам пункт 40 тоже считай.
data = []
for line in open('23-demo.txt'):
a, b, l = line.split()
data.append([int(a), int(b), float(l)])
# дальше твой код
Подсказка
Посчитай элементы, где dist[x] != float('inf').
Домашка
Задача из демоверсии. Файл уже лежит рядом с кодом, открывай его как task_31518_A.txt. Обрати внимание: номера вершин идут до 1000 и не подряд, поэтому список расстояний нужен на 1001 элемент. В ответ запиши целую часть.
Задачи «Попробуй сам» учебные, в домашке задачи с настоящих экзаменов и из банка ФИПИ. Код в блоках можно менять и запускать, он выполняется прямо в браузере.