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

# теория · шаг 2 из 6

Порядок обхода: в глубину или лучший-первый

Коротко

У ветвей и границ есть второе решение помимо оценки - в каком порядке брать открытые узлы. Обход в глубину хранит только путь (O(n) памяти) и быстро находит первый рекорд. Лучший-первый всегда берёт узел с самой высокой оценкой и раскрывает меньше узлов - но держит в памяти целый фронт, а в задачах, где недостроенное решение ответом не является, до самого конца может не иметь ни одного решения. Замер на рюкзаке: в глубину 23-61 узел и 598-1677 шагов, лучший-первый 15-45 узлов, но 1095-3315 шагов и до 17 узлов в памяти. Это тот же приём, что A* из раздела 11, и требование к оценке там и здесь одно и то же.

Если ты знаешь разницу между обходом в глубину и лучшим-первым по памяти и по числу раскрытых узлов и помнишь, что это A\* другими словами, - листай до «Теперь сам».

Как думаешь, что выгоднее - идти вглубь или идти в лучшее

Оценка у нас есть, рекорд есть. Теперь представь, что в какой-то момент перебора известны три нераскрытых узла с оценками 780, 640 и 500. Какой раскрывать следующим?

Прежде чем читать дальше, подумай про два варианта. Первый: продолжать тот, в который мы только что спустились, то есть идти вглубь, пока не упрёмся. Второй: всегда выбирать узел с наибольшей оценкой, где бы он ни находился. Что получится дешевле по числу раскрытых узлов - и чего это будет стоить?

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

Поиск пропавшей вещи в квартире. Обход в глубину - это «начал с одного ящика комода и выпотрошил его до дна, потом следующий». Лучший-первый - это «обошёл все комнаты, оценил, где вероятнее всего, и копаю там; если стало менее вероятно - перехожу в другое место».

Второй способ умнее и находит быстрее, но требует держать в голове список всех мест с оценками - и в большой квартире этот список сам становится проблемой. Первый способ глупее, зато в голове нужен только путь «комната - комод - ящик».

Механизм: фронт открытых узлов

ОБХОД В ГЛУБИНУ (стек, он же стек вызовов рекурсии)
память:    O(глубины) = O(n) - только текущий путь
рекорд:    появляется СРАЗУ, на первом же дошедшем до дна пути
отсечение: сравнивает с рекордом, который уже есть
узлов:     больше минимума - часть веток раскрывается до того,
           как рекорд стал хорошим

ЛУЧШИЙ-ПЕРВЫЙ (очередь с приоритетом по оценке)
память:    O(размера фронта) - в худшем случае экспоненциальная
рекорд:    там, где недостроенное решение - не ответ, может не появиться
           почти до конца: раскрываются узлы с лучшей ОЦЕНКОЙ,
           а не те, что ближе к листу
отсечение: сильнее - узел с оценкой хуже рекорда вообще не доживает
           до раскрытия
узлов:     минимум возможного при данной оценке

Ключевое свойство лучшего-первого стоит проговорить отдельно. Если оценка допустима, то первый ДОШЕДШИЙ ДО КОНЦА узел сразу оптимален: у всех остальных открытых узлов оценка не выше, а получить больше собственной оценки не может ни один из них - в этом и состоит допустимость. То есть лучший-первый не нуждается в доказательстве «а дальше лучше не станет» - он получает его бесплатно из порядка обхода. Это буквально A* из раздела 11, где оценка остатка называлась эвристикой h, а требование к ней - допустимостью.

Правило

Обход в глубину тратит O(n) памяти, немедленно даёт первый рекорд и потому годится всегда; лучший-первый раскрывает минимально возможное число узлов, но хранит целый фронт и может долго не иметь ни одного решения. Выбор между ними - это выбор между памятью и числом узлов, и на практике чаще берут гибрид: обход в глубину, в котором КАНДИДАТЫ на каждом уровне отсортированы по оценке - «лучший первым». Это даёт хороший рекорд рано, сохраняя память O(n). У лучшего-первого есть и скрытая цена реализации: выбрать максимум из фронта надо дёшево, иначе каждый шаг стоит O(размера фронта) - на замере это дало двукратный проигрыш по шагам при меньшем числе узлов.

Разбор: лучший-первый на рюкзаке, замер по трём величинам

Фронт - список узлов [оценка, номер предмета, вес, цена]. На каждом круге выбираем узел с наибольшей оценкой, раскрываем, двух детей кладём во фронт - но только если их оценка выше рекорда:

otkrytye = [[ocenka(0, 0, 0), 0, 0, 0]]
rekord = 0
while len(otkrytye) > 0:
    luchshij = 0
    k = 1
    while k < len(otkrytye):                 # выбор максимума: O(фронта)
        if otkrytye[k][0] > otkrytye[luchshij][0]:
            luchshij = k
        k = k + 1
    uzel = otkrytye[luchshij]
    otkrytye[luchshij] = otkrytye[len(otkrytye) - 1]
    otkrytye.pop()
    if uzel[0] <= rekord:
        continue                             # оценка устарела: рекорд вырос
    i = uzel[1]
    v = uzel[2]
    c = uzel[3]
    if i == len(ves):
        continue                             # предметы кончились, раскрывать нечего
    if v + ves[i] <= C:                      # ребёнок «взять предмет»
        if c + cena[i] > rekord:
            rekord = c + cena[i]             # набор допустим - пробуем в рекорд
        o = ocenka(i + 1, v + ves[i], c + cena[i])
        if o > rekord:
            otkrytye.append([o, i + 1, v + ves[i], c + cena[i]])
    o = ocenka(i + 1, v, c)                  # ребёнок «не брать»
    if o > rekord:
        otkrytye.append([o, i + 1, v, c])
print(rekord)

Две строки после выбора максимума - обычный приём удаления из середины списка, когда порядок не важен: на место выбранного узла переставляют последний и снимают хвост через pop(). Это одна операция вместо сдвига всех следующих элементов, и работает она и тогда, когда выбранный узел сам последний: он записывается на своё же место и снимается.

Цикл идёт до пустого фронта, а не до первого дошедшего узла, но лишней работы в этом нет: как только рекорд сравнялся с оптимумом, все оставшиеся узлы отбрасываются проверкой при извлечении, не раскрываясь.

Замер на тех же данных, что в прошлом уроке. «Узлов» - сколько узлов РАСКРЫТО, «во фронте» - сколько максимум лежало в памяти одновременно:

предметов   в глубину                лучший-первый
15          23 узла,    598 шагов    15 узлов, 1 095 шагов, во фронте 7
20          61 узел,  1 346 шагов    45 узлов, 3 315 шагов, во фронте 12
25          52 узла,  1 573 шага     23 узла,  2 828 шагов, во фронте 15
30          35 узлов, 1 677 шагов    20 узлов, 3 114 шагов, во фронте 17

Ответы совпадают: 420, 532, 675, 783. А вот выводы из таблицы расходятся в разные стороны.

По узлам лучший-первый выигрывает всегда и заметно - от 1,4 раза на двадцати предметах до 2,3 на двадцати пяти. По шагам он проигрывает тоже всегда: в 1,8-2,5 раза. Причина видна в коде: выбор максимума - это цикл по всему фронту, и он выполняется на каждом круге. Если заменить список на очередь с приоритетом из раздела про кучи, цена выбора станет O(log k) вместо O(k), и часть проигрыша исчезнет - но не весь: узлы во фронте надо хранить целиком, со всем состоянием, а в обходе в глубину состояние лежит в стеке вызовов и ничего не копируется.

Разбор: когда лучший-первый долго не имеет ответа

Сначала - когда этой беды НЕТ, и это как раз наш рюкзак. В рюкзаке недостроенный набор уже допустимое решение, поэтому рекорд у лучшего-первого растёт с первого же раскрытия. На двадцати пяти предметах оценка корня равна 676, оптимум 675, и рекорд по ходу раскрытий шёл так: 68, 117, 204, 231 и дальше до 668 после тринадцатого узла, а 675 появилось на последнем, двадцать третьем. Первые тринадцать шагов - тот же жадный спуск, что у обхода в глубину: у ребёнка «взять» оценка совпадает с родительской, она остаётся лучшей во фронте, и лучший-первый сам идёт вниз.

Беда начинается в задачах, где недостроенное решение ответом НЕ является. Маршрут коммивояжёра, пока он не замкнут, - не маршрут; расписание, в котором расставлена половина занятий, - не расписание. Там рекорд появляется, только когда какой-то узел дойдёт до дна, а лучший-первый на дно не торопится: у коротких недостроенных маршрутов оценка снизу меньше, они выглядят самыми перспективными, и он прыгает между неглубокими узлами. Пока он это делает, НИ ОДНОГО готового решения у него нет.

Чем это плохо практически: если программу прервать по тайм-ауту, обход в глубину отдаст хоть какой-то ответ (у него рекорд есть с первого пути до дна), а лучший-первый в такой задаче - ничего. Поэтому в реальных решателях почти всегда работает гибрид: порядок обхода определяется оценкой, но вниз спускаются сразу, чтобы быстро получить допустимое решение и начать отсекать.

anytime-поведение (что отдадим, если прервут)
в глубину        есть ответ почти сразу, он улучшается со временем
лучший-первый    если недостроенное решение не ответ - до первого
                 дошедшего узла ответа нет вовсе
гибрид           ответ сразу + порядок по оценке

Второе, о чём стоит помнить: память. Во фронте у нас лежало максимум 17 узлов, потому что оценка сильная и почти всё отсекается. На задаче со слабой оценкой фронт растёт экспоненциально, и лучший-первый упирается в память раньше, чем во время. У обхода в глубину такой беды нет никогда - он хранит только путь.

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

Первое: фронт как список с линейным поиском максимума. Пока узлов десятки, это незаметно; на десятках тысяч узлов выбор максимума становится главной статьёй расходов. Очередь с приоритетом - не украшение, а условие применимости приёма.

Второе: забыть проверить оценку узла при извлечении. Узел лёг во фронт с оценкой 780, пока рекорд был 700; к моменту извлечения рекорд стал 783. Если не проверить оценку ещё раз, узел будет раскрыт напрасно. Именно для этого в коде стоит if uzel[0] <= rekord: continue.

Третье: хранить во фронте ссылку на общее изменяемое состояние. В обходе в глубину можно было держать один массив занятости и откатывать его. В лучшем-первом так нельзя: узлы раскрываются не в порядке спуска, и состояние каждого надо хранить ЦЕЛИКОМ. Это ещё одна причина, по которой фронт дорог по памяти.

Четвёртое: ждать, что лучший-первый всегда дешевле. По узлам - да, по шагам и памяти - нет. Замер выше: узлов меньше в полтора-два раза, шагов больше примерно вдвое.

Пятое: сортировать кандидатов по оценке и не проверять, что это окупилось. В гибридном варианте сортировка b кандидатов на каждом уровне стоит b·log b на узел. Если ветвление маленькое (как в рюкзаке - всего две ветки), сортировать нечего: достаточно выбрать, какую ветку пробовать первой.

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

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

Второй - планировщики и маршрутизаторы. A* в навигаторе - это лучший-первый из этого урока; он хранит фронт и потому требует памяти, пропорциональной площади обследованной области. Отсюда и приёмы вида «поиск с ограничением памяти», где фронт подрезают.

Третий - разговор про «а что, если прервать». Требование «дай лучшее, что успел» - это требование anytime-поведения, и оно прямо диктует порядок обхода: в глубину или гибрид, но не чистый лучший-первый.

Теперь сам

У задачи ветвление два (взять предмет или нет) и сильная оценка. Какой порядок обхода выбрать и почему гибрид в этом случае вырождается в одно простое правило?

Ответ. При ветвлении два и сильной оценке правильный выбор - обход в глубину, а «гибрид» сводится к одному решению: какую из двух ветвей пробовать первой. Пробовать надо ту, у которой оценка выше - для рюкзака с предметами, отсортированными по удельной цене, это ветка «взять предмет».

ветвление 2 → сортировать нечего, выбор из двух
ветка «взять»    оценка обычно выше: самый выгодный предмет
ветка «не брать» оценка ниже - её и откладываем
⇒ в глубину, сначала «взять», память O(n), рекорд появляется сразу

Именно так и написан перебор из прошлого урока - там строка rec(i + 1, v + ves[i], c + cena[i]) стоит ПЕРВОЙ, и это не случайность: рекорд от жадного спуска появляется на первом же пути до дна, и дальше отсечения работают уже с ним. Поменяй эти две строки местами - и первым найдётся пустой рюкзак с ценой нуль, а рекорд будет расти медленно.

Главное

Порядок обхода - второе решение ветвей и границ после оценки, и выбирается он не по красоте, а по трём величинам: память, число раскрытых узлов и наличие ответа при досрочной остановке. Обход в глубину: память O(n), рекорд сразу, узлов чуть больше минимума. Лучший-первый: узлов минимум возможного (и первый дошедший до конца узел сразу оптимален - это A* из раздела 11), но память под весь фронт, а там, где недостроенное решение ответом не является, до самого конца может не быть ни одного решения. Замер на рюкзаке: у лучшего-первого узлов меньше в полтора-два раза, шагов больше примерно вдвое, во фронте до 17 узлов. На практике берут гибрид - спуск в глубину, но кандидаты в порядке оценки; при ветвлении два это сводится к одному правилу «сначала пробуем ветку с лучшей оценкой», и именно поэтому в рюкзаке строка «взять предмет» стоит первой.

Комментарии

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

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

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