Сейчас мы в общих чертах обсудим практическое применение метода направленного поиска с запретами (на примере всё того же развоза комбикормов). При этом будем постоянно апеллировать к аналогии с путником, блуждающим по холмам и долинам.
Итак, путник стартовал из начальной точки внизу, в долине. Мы стартуем с пустого плана перевозок.
Путник шагал из одной точки местности в другую. Мы будем «шагать» от одного плана перевозок к другому.
Путник стремился подниматься вверх, но иногда ему приходилось спускаться вниз. Для нас «подниматься вверх» будет означать добавлять в план производства один новый рейс, а «опускаться вниз» - удалять из плана один из ранее добавленных рейсов.
При этом путник был вообще совсем один, а машин, которым можно добавлять и удалять рейсы – несколько. Так что здесь уже возникают варианты. Правда, путник тоже выбирал направление движения, но здесь выбор получается множественным: сначала надо выбрать машину, потом (при добавлении рейса) – площадку, на которую она поедет, потом – когда именно поедет. Потому что – на этом важно сделать акцент – имеет смысл рассматривать добавление рейса не только в конец смены, то есть после последнего из ранее добавленных, но и в начало или в середину (то есть между ранее добавленными). И только потом нужно загружать комбикормовоз кормами, потому что это будет зависеть и от его вместимости по секциям, и от потребности (причём с учётом ранее запланированных рейсов) выбранной площадки в кормах, и от наличия кормов на ККЗ на момент загрузки…
Путник выбирал направление каждого своего следующего шага очень просто (правило запретов пока не вспоминаем): чтобы максимально круто подниматься вверх, что давало максимальный прирост целевой функции. У путника таковой была высота местонахождения, так что ему было сравнительно просто. У нас теперь целевая функция гораздо сложнее, но и мы можем посчитать её приращения для всех возможных вариантов добавления рейса и выбрать из них самый «крутой». На первых шагах это будут, скорее всего, самые дальние рейсы, но потом они могут из плана уйти, поскольку может оказаться, что, к примеру, вместо одного длинного рейса предпочтительнее два более коротких. Или вместо двух – три, или вместо трёх – четыре…. Но это будет потом. А пока путник поднимается всё выше и выше, мы добавляем в наш план перевозок один рейс за другим…
Но вот путник достиг вершины, а мы, соответственно, столкнулись с невозможностью дальнейшего добавления рейсов. Тогда путник будет вынужден сделать шаг вниз, а мы – удалить из плана один из рейсов. Чтобы потом, как и путник, поискать другие варианты. Путник запомнил достигнутую вершину и её высоту, и мы, в свою очередь, занесли в «память» нашей процедуры планирования полученный план перевозок вместе с рекордным на данный момент значением целевой функции.
И вот здесь пора вспомнить о правиле запретов. Мы предполагали, что путник, чтобы не возвращаться назад, будет запоминать раннее пройденные места – по крайней мере, будет помнить последние несколько десятков или сотню-другую шагов. Наверное, для этого потребовалась бы очень хорошая память. Но нам-то сейчас (с кормами) надо запоминать целые планы перевозок, состоящие из десятков рейсов! В мельчайших подробностях! А потом сравнивать их с новыми! Представляете себе: пришлось бы на каждом шаге, получив новый план, сравнить его с доброй сотней ранее запомненных планов, каждый из которых состоит из десятков рейсов! А потом и сам этот план запомнить! Тут любой программист сразу скажет, что подобное возможно лишь теоретически, а практически нереально! Если, конечно, мы хотим сделать не сотню-другую шагов, а на порядки больше.
К счастью, в том, о чём говорится в предыдущем абзаце, нет необходимости. Запоминать полностью надо только «рекордные» планы, то есть те, которые улучшают ранее достигнутые наилучшие значения целевой функции. В частности, если рассматривать наш итерационный процесс планирования с самого первого шага, то необходимость запоминания решения первый раз возникнет только при достижении первой «вершины», то есть на том шаге, когда добавление новых рейсов станет невозможным. А в следующий раз – по достижении более высокой «вершины» (если такое вообще случится).
А для правила запретов мы использовали следующую схему, которую опишем, не вдаваясь в детали.
• Если на i-том шаге (i = 1, 2, 3, …) описанного выше итерационного процесса планирования в план перевозок добавляется новый рейс, то номер i запоминается в специальном атрибуте рейса (как номер шага, на котором этот рейс попал в план), чтобы на протяжении какого-то числа шагов (которое будем называть длиной списка запретов на удаление рейсов) правило запретов препятствовало удалению этого рейса из плана;
• Если же на i-том шаге того же итерационного процесса из плана перевозок удаляется рейс ТС с порядковым номером v (v = 1, 2, …, V, где V – число ТС) в пункт доставки (на площадку свинокомплекса) с порядковым номером p (p = 1, 2, …, P, где P – число площадок), то номер i запоминается в специальном двумерном массиве (как номер шага, на котором рейс был удалён), чтобы на протяжении какого-то числа шагов (которое будем называть длиной списка запретов на добавление рейсов) правило запретов препятствовало добавлению в план перевозок рейса v-того ТС на p-тую площадку, если такое добавление не приводит к большему, по сравнению с предыдущими (то есть «рекордному»), значению целевой функции.
Таким образом, правило запретов у нас получилось более сложным, чем у путника: у него был всего один параметр L – длина списка запретов и один собственно список запретов – перечень мест, в которые нельзя возвращаться на следующем шаге. Например, если L = 1, то, делая любой шаг, путник должен лишь помнить, что следующий шаг нельзя будет сделать назад, если L = 2, то надо помнить ещё и предыдущее место (куда тоже нельзя будет на следующем шаге вернуться) и так далее. А если путник зашёл в тупик, из которого только один выход – назад? Понятно, что в этом случае, чтобы не останавливаться, ему, всё-таки, придётся нарушить правило запретов, причём даже при L = 1! И это только одна из проблем!
Первая проблема возникает ещё до того, как наш путник отправится в путь: какой вообще должна быть длина списка запретов L? Понятно, что чем она меньше, тем больше у путника свободы выбора каждого следующего шага, но и тем больше возможностей «зациклиться», то есть с какого-то момента начать ходить по кругу. С увеличением L свободы выбора становится меньше, чаще возникает необходимость нарушать правило запретов, но гарантий от зацикливания это всё равно не даёт. Интуитивно должно быть понятно, что чем изначально у путника больше возможных направлений движения, тем больше должно быть L. Например, если направлений всего 4 (по сторонам света: на юг, на север, на запад и на восток), то это одна ситуация, а если 8 (с добавлением юго-запада и других «диагональных» направлений), то другая. Но какой именно должна быть длина списка запретов в каждом из этих случаев, можно определить только экспериментальным путём.
Но на самом деле приведённые выше размышления наводят на мысль, что, может быть, метод вообще неработоспособен, что, возможно, в нём что-то нужно изменить?
А изменить есть что. Потому что до сих пор мы неявно предполагали, что длина списка исключений должна быть каким-то фиксированным числом. И действительно, в большинстве публикаций по методу направленного поиска с запретами это так и есть. Некоторые авторы для простейших случаев даже приводят эмпирические формулы расчёта оптимальной длины списка запретов в зависимости от таких параметров, как число ТС, число пунктов доставки и других (об этом можно прочитать в [2]). Но напрашивается совершенно другой путь (который в литературе тоже упоминается): если непонятно, какой именно должна быть длина списка исключений, то её надо варьировать, подбирать прямо по ходу работы алгоритма! Эта идея не только избавляет от необходимости определять длину списка запретов заранее, но и при её правильной реализации снимает проблему зацикливания! И действительно, если, к примеру, наш путник даже вернётся в какую-нибудь точку, в которой он побывал ранее, но длина списка запретов окажется не такой, как в предыдущий раз, то путник, вполне вероятно, пойдёт дальше по другому пути, поскольку в таком случае либо его прежний путь закроется правилом запретов, либо, наоборот, откроется какой-то другой путь, который был закрыт в предыдущий раз.
Но как именно следует варьировать длину списка запретов L? Самый простой способ – это заранее задать максимальную длину списка запретов Lmax, а величину L выбирать случайным образом (с помощью датчика случайных чисел) в пределах от 1 до Lmax. Это, на наш взгляд, будет уже неплохо, но, всё же, ещё не слишком «интеллектуально». На самом деле и величину Lmax тоже можно время от времени менять, причём целенаправленно: например, уменьшать, если окажется, что путнику приходится систематически нарушать правило запретов, поскольку оно слишком жёстко ограничивает свободу его передвижения. Кроме того, по достижении путником рекордной высоты имеет смысл предельно уменьшить (вообще до 1) длину списка запретов и потом постепенно её увеличивать, чтобы дать путнику возможность подольше походить вокруг, поскольку где-то поблизости может найтись ещё более высокая вершина. А если после определённого числа шагов нового рекорда высоты достигнуто не будет, то можно вернуться к случайному варьированию величины L.
Такого рода соображения, заложенные в алгоритм (а всех секретов мы, конечно, выдавать не будем), делают его поистине «высокоинтеллектуальным», гибко адаптирующимся по ходу поиска – как это обычно делает человек. Но человек в случае неудачи может вообще резко поменять стратегию поиска, выбрать совершенно другой метод. Разумеется, и в компьютерных программах такое тоже возможно. Например, к алгоритму поиска с запретами можно добавить, скажем, похожий на него, но, всё же, другой алгоритм имитации отжига и применять эти два алгоритма попеременно (если один перестаёт давать улучшения результатов – пробовать другой).