УДК 519.8
Вестник СПбГУ. Сер. 1, 2005, вып. 2
И. В. Романовский, С. В. Кузнецов
ОБОБЩЕННЫЙ АЛГОРИТМ СУММИРОВАНИЯ ПЕРЕЧИСЛИТЕЛЕЙ СУБОПТИМАЛЬНЫХ РЕШЕНИЙ
1. Введение
Перебор субоптимальных (или к-оптимальных) решений представляется нам одним из важнейших вопросов дискретной оптимизации. С одной стороны интерес к методам такого перебора повышается из-за необходимости анализа нескольких вариантов решения одной и той же задачи в условиях, когда математическая модель была изначально упрощена. С другой стороны, выросшая мощность современных вычислительных систем позволяет решать некоторые дискретные задачи не всегда оптимально с математической точки зрения, а, принимая во внимание сторонние факторы, выбирать решение, «близкое» к оптимальному.
Процессы перебора субоптимальных решений часто рассматривались для конкретных оптимизационных задач. В таких исследованиях предлагались специализированные подходы, пригодные лишь для конкретной задачи. Однако последнее время все больше внимания уделяется созданию общих средств, которые бы охватывали как можно более широкий класс задач. В работе [1] предложено выделить набор операций, с помощью которых процесс перебора решений конкретной задачи можно составить из более простых процессов. Например, перебор остовных деревьев в связном графе в порядке неубывания их веса [2] в случае, если этот граф состоит из нескольких бисвязных компонент, может быть разбит на аналогичные задачи в каждой компоненте (мы решаем исходную задачу для более простых графов). И уже затем «полное» решение будет скомпоновано (или сложено) из более простых, соответствующих этим компонентам.
Данная работа посвящена одному из важных вопросов такого подхода — эффективной реализации суммирования процессов перебора субоптимальных решений. Роль этой операции была отмечена в статье Миниеки и Шира [3], где были введены операции суммирования для действий над матрицами списков ^-кратчайших путей, в связи с попыткой переноса метода Флойда на задачу построения таких матриц.
Здесь мы будем пользоваться терминологией из [1], которая представляется нам удобной и при описании алгоритмов перебора решений, и в их программной реализации.
1.1. Терминология
Выше говорилось о процессе перебора решений. Сначала формально определим сам термин процесс.
Определение. Процесс — пара (Б,Т), где Б — множество, именуемое множеством состояний, в котором выделен элемент ¿1 € Б — начальное состояние, и отображение Т : Б ^ Б, называемое правилом перехода из одного состояния в другое.
Здесь определение процесса совпадает с аналогичным из [4]. Мы рассматриваем более узкий класс процессов — процессы перебора решений, которые будем называть перечислителями.
Определение. Перечислитель — процесс (Б,Т), где Б —дискретное (или счетное) множество решений рассматриваемой задачи, упорядоченное по оптимальности1; Б =
© И. В. Романовский, С. В. Кузнецов, 2005
1 Например, если решаем задачу минимизации, то упорядоченных по неубыванию значений решений.
{в1, сС2,...}, где в1 — одно из решений-оптимумов, а отображение Т устроено следующим образом: Т(Ск) = вк+1 для всех к > 0.
Часто рассматривают конечные множества решений, и тогда Т определено, разумеется, не на всех решениях.
Нам будет удобно рассматривать перечислитель как процесс, который может вызываться, что будет соответствовать переходу из одного состояния (вк) в другое (вк+1). При каждом вызове он сообщает логическое значение IsContinued и два объекта. Значение IsContinued истинно, если процесс продолжен, в том смысле, что еще не все решения данной задачи были возвращены. Из упомянутых двух объектов, первый является численным значением возвращаемого решения, и мы будем обозначать его через у(вк), а второй — самим этим решением (вк). Значения этих объектов определены только, если IsContinued.
1.2. Операции над перечислителями
Теперь можно определить операции над перечислителями (приведем лишь основные).
• Монотонное преобразование перечислителя. Пусть заданы перечислитель Р и монотонно неубывающая функция Г(■). Результирующий процесс возвращает Г(у(вк)) —результат подстановки очередного значения перечислителя Р в функцию Г и само вк. Одним из частных случаев является преобразование сдвига, которое к значению исходного перечислителя прибавляет константу.
• Слияние перечислителей. Пусть задан набор перечислителей. Результирующий процесс вызывает все исходные перечислители, выбирает из полученных от них значений наименьшее и возвращает его. При каждом следующем вызове ранее возвращенные значения не рассматриваются, а от прочих перечислителей используются прежние значения, причем перечислители, которые «закончили свою работу» (все их значения уже были выбраны), больше не рассматриваются.
• Сумма перечислителей. Пусть задан набор перечислителей. Предположим, что известны все их значения. Рассмотрим всевозможные суммы этих значений, по одному от каждого процесса, и введем новый процесс, который будет в качестве значений возвращать такие суммы в порядке неубывания, а решением при каждом значении будет набор решений исходных перечислителей, соответствующих слагаемым этой суммы. Конечно же, предварительное вычисление всех значений у всех процессов предполагаться не должно.
Возвращаясь к примеру о переборе остовных деревьев, легко понять, что итоговый процесс будет являться суммой перечислителей остовных деревьев в бисвязных
2
компонентах и перечислителей-констант, возвращающих значения «дуг-перемычек».2
Ниже мы рассмотрим «ленивое» исполнение алгоритма суммирования перечислителей, основанный на использовании понятия границы Парето и алгоритма поиска минимального элемента, адаптированного специально для данной задачи. Сначала мы приведем эффективный алгоритм для случая двух процессов, который отличается от предложенного в [1] тем, что хранит вспомогательный массив для быстрого вычисления границы Парето. Но главной нашей целью будут варианты его обобщения для
2 Перечислитель-константа —это процесс, возвращающий всегда одно и то же значение. На самом деле, процесс, соответствующий перемычке, может быть сложнее.
произвольного числа процессов и сравнение его с рассмотренным там же алгоритмом попарного сложения перечислителей. Также мы уделим внимание реализации оптимального хранения «многомерного куба уровней», который позволит нам эффективно вычислять границу Парето в многомерном случае. И в заключение рассмотрим варианты алгоритма поиска минимального элемента и сравнение его с известным алгоритмом приоритетной очереди применительно к нашей задаче.
2. Реализация алгоритмов
Будем стремиться к максимальной независимости конструирования отдельных частей для удобства их повторного использования. Очень привлекательна принятая в объектно-ориентированном программировании схема выделения основных структур и алгоритмов в виде классов, которые можно далее расширять и переопределять. Для всех перечислителей введен единый базовый класс Process, который затем удобно расширять для определения, например, класса процесса суммирования перечислителей (ProcessSum).
Безусловным преимуществом этого подхода является использование интерфейсов [5]. Интерфейс понимается как набор определений методов, которые затем «наполняются кодом» в реализующих их классах, причем реализация может быть разной. Так, например, для нахождения минимального остовного дерева мы можем определить интерфейс, который будет состоять из двух методов: метод инициализации с начальной подготовкой init(IGraph), где на вход поступает сам граф, заданный интерфейсом IGraph, и метод, возвращающий минимальное остовное дерево findMinSpanTree(). Вводя для описания дерева интерфейс ITree, наш новый интерфейс можно описать так:
public interface ISpanTreeFinding { void init(IGraph p_graph); ITree findMinSpanTree();
}
Принято использовать интерфейсы для определения логики взаимодействия классов и способов использования этих интерфейсов. Программа, использующая подобные объекты, «не знает» о способе их реализации. Когда возникает необходимость поменять реализацию, для замены нужно лишь написать другой класс, реализующий тот же самый интерфейс, и нет необходимости исправлять весь код программы
В упомянутом примере мы могли бы изначально в качестве реализации интерфейса ISpanTreeFinding взять алгоритм Прима, но затем в силу специфики конкретной задачи обнаружить, что лучше подходит алгоритм Краскала. Как уже говорилось, нам не придется ничего переписывать, а будет достаточно добавить класс, реализующий указанный интерфейс в виде алгоритма Краскала.
Далее при рассмотрении каждого алгоритма сначала заданием интерфейса перечисляются нужные методы, а уже затем предлагается реализация этих методов.
Среда программирования Java выбрана не только потому, что является сейчас одной из наиболее распространенных объектно-ориентированных платформ. Другое ее преимущество — обилие базовых структур и механизмов (сортировка, динамические массивы, хеш-массивы, итераторы и пр.) позволяющих сосредоточится на алгоритмической реализации самого метода.
Оказалось, что концепция наших перечислителей сходна с уже имеющейся в этом языке концепцией итераторов java.util.Iterator. В Java итератор — это произвольная структура данных, которая может возвращать некоторые значения одно за другим.
Более формально, это интерфейс, состоящий из двух методов: первый проверяет, имеется ли следующий элемент (hasNext()), а второй собственно возвращает этот элемент (next()). В перечислителях все то же самое: мы каждый раз возвращаем следующее решение, поэтому любой класс-перечислитель, в нашем случае, реализует интерфейс Iterator и, собственно, поэтому является итератором.
Мы также воспользуемся хеш-массивами (java.util.HashMap), но напишем свой вариант этой структуры данных, который позволит нам использовать специфику задачи и эффективно хранить многомерные целочисленные массивы.
Более подробная информация о различных структурах данных, их преимуществах и недостатках3, а также об уже имеющихся алгоритмах в Java, приведена в [6].
3. Суммирование перечислителей
Пусть задан исходный набор перечислителей Pi = (Di,Ti),. ..,Pn = (Dn,Tn) (они будут называться подчиненными). У каждого Pk занумеруем решения, начиная с 1 для минимального решения. Для удобства будем обозначать i-е решение перечислителя Pk через dlk.
Сумма перечислителей — перечислитель P = (D,T), где D = Di х ... х Dn, а численными значениями новых решений являются суммы соответствующих компонент: v(d) = v(di) + .. .+v(dn) для всех d = (di,..., dn) G D и d1 = (d^,..., d^). Отображение T таково, что последовательность чисел {v(dk)}k>i неубывает. Таким образом, операция суммирования перебирает в порядке неубывания значения функции, заданной на прямом произведении n множеств и равной сумме функций для множеств-сомножителей.
Легко понять, что вычисление всех значений исходных процессов с их последующим перебором не эффективно. Более того, в некоторых задачах вычисление всех значений исходных перечислителей не нужно, — можно ограничиться лишь несколькими первыми значениями, число которых может определиться в ходе перебора.
Итак, наша цель — эффективные алгоритмы суммирования. Начнем с очевидного факта: для вычисления самого минимального решения необходимо иметь лишь минимальные решения подчиненных процессов. Для получения следующего решения придется проанализировать еще и «вторые» решения исходных перечислителей.
Такие наблюдения говорят о том, что при каждом следующем вызове необходимо взять следующие значения некоторых подчиненных процессов. Ниже мы покажем как алгоритм распознает, какие значения вычислять и какие структуры данных поддерживают работу. Нам понадобится используемое в экономическом моделировании понятие границы Парето.
3.1. Граница Парето
Для каждого процесса Pk рассмотрим отрезок натурального ряда Ik = 1 : imaxk, где imaxk — номер последнего решения в Pk. Прямое произведение R = Ii х I2 х ... х In есть совокупность всех возможных наборов номеров решений в подчиненных процессах. Каждый такой набор однозначно определяет решение в сумме перечислителей P.
Определение. Набор i = (ii,...,in) G R доминирует набор i' = (i'i,...,i'n) G R, если ik < i'k для каждого к G 1 : n.
Сформулируем очевидную лемму, которая покажет связь введенного соотношения с перечислителями.
3 Одним из основных недостатков является худшая, в сравнении с языком ^ производительность, в частности, скорость работы с памятью.
Лемма 1. Пусть набор i = (ii,...,in) доминирует набор i' = (i'1,...,i'n). Тогда сумма значений решений подчиненных перечислителей в наборе i' будет не меньше аналогичной суммы для набора i, то есть:
Е *(4fc) ^ Е v(dk).
kGi:n kGi:n
Определение. Границей Парето множества S G R называется такое его подмножество B, что любой элемент (ii,..., in) G S \ B доминируется каким-либо элементом из B, и никакой элемент из B не доминируется другим элементом из B.
В процессе суммирования на каждом m-м шаге вычислений мы рассматриваем некоторое множество Sm таких наборов. Изначальное множество So совпадает с R. Затем при каждом вызове m исключается элемент sm, соответствующий возвращаемому значению, так что Sm+i = Sm \ {sm}. Ясно, что каждый раз элемент sm следует выбирать из границы Парето (Bm) множества Sm.
Таким образом, связь понятия границы Парето и операции сложения перечислителей очевидна. Разобьем алгоритм на два этапа. Первый — вычисление очередной границы Парето, второй — перебор ее элементов. В обоих этапах есть своя специфика. Действительно, при пересчете границы Парето все «старые» ее элементы, кроме выбранного на предыдущем шаге, в ней останутся.
Ниже будет предложена схема, эффективно использующая этот факт. Она будет допускать «ленивое исполнение». Выше определялись отрезки Ik номеров решений подчиненных процессов. На самом деле их вычислять не нужно. Не важно даже, сколько этих значений, потому что на каждом шаге перевычисления границы Парето анализируется лишь следующее решение подчиненных процессов, а иногда даже и этого не требуется.
3.2. Суммирование двух перечислителей
Сначала рассмотрим алгоритм суммирования для двух перечислителей. В соответствии со сказанным, выделим механизмы вычисления границы Парето и поиска минимального элемента в отдельные классы и сейчас лишь определим необходимый набор операций в интерфейсах IParetoBoundary и ISortingQueue соответственно.
Из определения множеств S и B видно, что их элементы — это целочисленные наборы номеров решений подчиненных перечислителей. Поэтому ниже мы будем задавать такие элементы целочисленными массивами (int[]), а сами множества будут являться динамическими массивами — объектами класса Boundary (будем считать, что он задан извне).
В интерфейсе IParetoBoundary для вычисления границы Парето понадобятся следующие функции.
• next() —возвращает следующее множество Bm, реализуемое как объект класса Boundary.
• disable(int[] p_element) —удаляет элемент (набор уровней, заданный целочисленным массивом) p_element из множества Sm и образует тем самым множество Sm+i. В отличие от B, множество S не хранится!
• hasNext() —возвращает булево значение «следующее Bm+i существует».
Для определения минимального решения введем интерфейс ISortingQueue.
• add(IComparable p_element, Object p_id) добавляет в очередь указанный элемент p_element, который именуется идентификатором p_id.
• IComparable getHead() возвращает текущий минимальный элемент из очереди и удаляет его из имеющегося списка элементов.
• ObjectgetHeadId() — возвращает идентификатор текущего минимального элемента.
Интерфейс IComparable реализуется классами, объекты которых могут сравниваться между собой, и содержит лишь два метода, один из которых указывает, лучше ли базовый объект4, чем объект-параметр, а другой — равны ли эти объекты.
public interface IComparable {
// базовый объект и объект p_element равны boolean equals(IComparable p_element);
// базовый объект лучше, чем объект p_element boolean better(IComparable p_element);
}
Вернемся к алгоритму суммирования двух перечислителей. Будем считать, что у нас есть объекты _pareto интерфейса IParetoBoundary и _line интерфейса ISortingQueue. Тогда сам алгоритм запишется очень просто:
Boundary boundary = _pareto.next(); // Текущая граница Парето
// Проверка решений, которые уже были вычислены и записаны //в массив _CV (Cashed Values). Если решения, уже попавшие в // текущую границу Парето, не были вычислены, они вычисляются и // заносятся в очередь сортировки. for (int i = 0; i < boundary.size(); i++) { int[] el = (int[]) boundary.get(i);
// Проверка, не были ли значения уже вычислены if (_CV[0].size() <= el[0]) // Вычисление этих значений
_CV[0].add( ((Process) listProcesses.get(0)).next() ); if (_CV[1].size() <= el[1])
_CV[1].add( ((Process) listProcesses.get(1)).next() ); _line.add( (_CV[0].get(el[0])).shift(_CV[1].get(el[1])), el);
}
// Удаление минимального элемента из множества наборов уровней _pareto.disable((int[]) _line.getHeadId());
// Возврат минимального элемента return _line.getHead();
Видно, что вся сложность алгоритма спрятана в реализации классов расчета границы Парето и поиска минимального элемента. Конечно же, описанные интерфейсы можно реализовывать по-разному, сохраняя основной алгоритма неизменным (!).
В качестве одной такой реализации мы предложим алгоритм поиска минимального решения, адаптированный специально для нашей задачи, а потом сравним его с известным алгоритмом приоритетной очереди.
4 Базовый объект — это тот объект, чей метод вызывается.
4. Алгоритм вычисления границы Парето для R2
Пусть I1 = K = 1: к, I2 = L = 1: l, R = K x L.
Введем вспомогательный массив «уровней» G = {go,...,gk}. Изначально положим go = l, а все остальные уровни примем равными 1. Далее на каждом m-м шаге, удаляя из R некоторый элемент sm = (x, y), мы будем увеличивать уровень gx на единицу.
Лемма 2. Подмножество Bm G Sm = {(x,y) | gx-i > gx,y = gx, x G 1 : к} является границей Парето множества Sm.
Доказательство. Для множества So это утверждение очевидно, поскольку указанное соотношение выполнено лишь для уровня gi, а значит Bo = {(1,1)}.
Предположим, утверждение выполнено для множества Sm при m > 0. Множество Sm+i получается удалением из Sm элемента sm = (x,y). Массив уровней (G) также претерпевает изменение: уровень gx увеличивается на единицу и становится равным y +1. Проследим, как получается Bm+i из Bm.
Во-первых, необходимо отметить, что в любом случае в множество Bm+i войдет элемент (x + 1,gx+i), поскольку значение уровня gx увеличено на единицу, а gx+i < y < gx, поскольку (x,y) G Bm.
Возможны два случая.
Первый: gx-i = y + 1. Это значит, что элемент (x,y + 1), определяемый новым значением gx, не попадает в множество Bm+i, но там содержатся элементы (x — 1,y + 1), и, тем самым, доминируются элементы множества {(x',y') | x — 1 < x' ,y + 1 < y'}, и элемент (x +1, gx+i), который доминирует {(x', y') | x +1 < x', gx+i < y'}. Пересекая эти множества, и, принимая во внимание, что элемент (x, y) уже исключен, а множество Bm было границей Парето, видим, что Bm+i = Bm \ {(x, y)} U {(x + 1, gx+i)} является границей Парето.
И второй: gx-i > y +1. Здесь, в множество Bm+i войдет новый элемент (x,y + 1). Осталось лишь проверить те элементы, которые им не доминируются, а именно E = {(x',y') I x < x',y = y'}. И здесь мы опять вспомним о том, что (x + 1,gx+i) G Bm+i, а значит доминируются элементы {(x',y') | x + 1 < x',gx+i < y'}. Тогда, принимая во внимание, что (x,y) уже исключен из Sm+i и gx+i < y, получаем, что множество E \ {(x, y)} доминируется элементом (x + 1, gx+i) G Bm+i.
Данная лемма рождает алгоритм нахождения границы Парето для R2, причем здесь удобно на каждом шаге при очередном удалении элемента sm производить пересчет массива уровней Gm и, тем самым, делать обновление самой границы Парето Bm.
int[] _g; // массив G
// p_element - удаляемый элемент s_m public void disable(int[] p_element)
if (_g[p_element[0]] < _g[0]) _g[p_element[0]]++; // _boundary - B_m _boundary.remove(p_element); //удаляем элемент из границы
// если разница между соседними уровнями больше 1, то элемент уже был добавлен, // если разница равна 1, то элемент необходимо добавить if ((p_element[0] < _g.length-1) &&
((_g[p_element[0]] - _g[p_element[0]+1])==1)) _boundary.add(new int[]{p_element[0]+1, _g[p_element[0]+1]}); // Необходимо проверить уровень слева, поскольку его // значение может быть больше текущего. В этом случае необходимо // добавить текущий уровень с его измененным значением.
(_g[p_element[0]-1] > _g[p_element[0]])
_boundary.add(new int[]{p_element[0], _g[p_element[0]]});
Отметим, что здесь мы храним не самое границу Парето, а структуру, которая позволяет эффективно считать это множество (О). В данном случае это оправдано, поскольку хранить одномерный массив «дешевле», чем саму границу в виде двумерного массива. Обобщим эту схему для произвольного числа перечислителей и посмотрим как скажется хранение этой избыточной информации.
5. Обобщенный алгоритм вычисления границы Парето
Введем аналогично массиву уровней множество уровней (О), которое удобно воспринимать как многомерный куб (конечно же, параллелепипед — мы просто упрощаем терминологию) уровней. Более формально, для случая суммирования произвольного числа перечислителей (т + 1) мы рассматриваем прямое произведение К = К х Ь\ х ... х Ът отрезков натурального ряда К = 1: к и Ъ = 1: /¿. Введем вспомогательную функцию
О : Ь\ х ...х Ът ^ К.
Положим ее значения равными 1, для всех наборов аргументов, кроме тех, где хотя бы один аргумент равен нулю. Для последних положим значения функции равными к. Далее, каждый раз при удалении из К элемента = (у,х\,..., хт) будем увеличивать значение функции О(х) на единицу; нам будет удобно обозначить х = (х\,...,хт). Теперь мы готовы обобщить Лемму 2.
Лемма 3. Подмножество Вт € = {(у,х\,...,хт)\О(х\,...,х'п,...,хт) > О(х), х'п = хп — 1 \ Ун € 1 : т, у = О(х)} является границей Парето.
Если в Лемме 2 массив уровней можно понимать как функцию О, отображающую значения из отрезка натурального ряда в другой отрезок, то теперь мы имеем отображение из т-мерного куба в отрезок. Поэтому мы и назвали этот объект многомерным кубом уровней. Лемма 3 говорит о том, что в границу Парето «попадают» те элементы куба, для которых их непосредственные верхние соседи (т. е. элементы, у которых ровно одна координата меньше на единицу) имеют большие значения. Напомним, что фактически в границу Парето записывается элемент, размерность которого на единицу больше, так как в него включается значение функции О.
Доказательство леммы проходит по тому же принципу, что и для Леммы 2, с поправкой на большое число индексов элементов (т + 1).
Алгоритм вычисления границы Парето практически не изменится, в его реализации лишь придется заменить целочисленный массив уровней на некую структуру, которая будет хранить куб уровней, а также появится больше циклов для перебора всех т непосредственных верхних соседей.
Начнем с того, что определим список методов структуры хранения куба уровней в виде интерфейса ILayer:
• increase(int[]) увеличивает на единицу значение функции О для данного аргумента (набора уровней);
• возвращает значение О для данного аргумента (набора уровней);
• returnBoundaryElement(int[] в1) создает элемент границы Парето по указанному набору уровней, т.е. массив размерности на единицу больше.
Алгоритм практически такой же, мы лишь поменяли «естественные» операции над целочисленным массивом _g из предыдущего алгоритма на аналогичные операции интерфейса ILayer.
ILayer _g;
public void disable(int[] p_element) { if (p_element == null){ m_hasNext = false; return;
}
int[] el = strip(p_element); //возвращает массив без его первого элемента.
_boundary.remove(p_element); int value = _g.increase(el);
// Проверяем верхний угол boolean flag = true;
for (int i = 1; (i < p_element.length) && flag; i++) { el[i-1]--;
if (_g.get(el) <= value)
flag = false; el[i-1]++;
}
if (flag)
_boundary.add(_g.returnBoundaryElement(el)); for (int j = 0; j < el.length; j++) { if (el[j] >= m_length[j+1]) continue; int[] ind = new int[el.length];
// копируем массив el в массив ind System.arraycopy(el, 0, ind, 0, el.length); ind[j]++;
int childValue = _g.get(ind);
// новый элемент для границы Парето if ((value - childValue) == 1 && (childValue >-1)) { flag = true;
for (int k = 0; (k < el.length) && flag; k++) { if (k == j) continue; ind[k]--;
if (_g.get(ind) <= childValue) flag = false; ind[k]++;
}
if (flag) // Добавляем элемент в границу Парето _boundary.add(_g.returnBoundaryElement(ind));
}
} // for (j)
}
Однако не стоит соблазняться той легкостью, с которой получен этот алгоритм. Дело в том, что для значений большой размерности существенным, а иногда и решающим, фактором оказывается то, как организовано хранение куба уровней, т. е. реализация интерфейса ILayer.
5.1. Хранение многомерного массива наборов уровней
Хранение куба уровней в виде многомерного целочисленного массива, очень не эффективно. Основное время тратится на поиск нужного значения по указанным координатам.
Воспользуемся понятием хеш-массива. Введем специальную хеш-функцию, которая вычисляет уникальное хеш-значение (число) для координат и по этому значению в качестве ключа извлекает необходимый элемент5. Таким образом, мы, в некотором смысле, «разворачиваем» многомерный массив в линейный.
Применим технику ведерного хранения этого массива, аналогичную используемой в алгоритме Денардо и Фокса для задачи о кратчайшем пути в графе [7]. Разобьем исходное множество элементов на подмножества, называемые ведрами. В каждом ведре будем хранить элементы с определенными хеш-значениями (как правило, это числовой отрезок, в котором лежит хеш-значение). Будем идентифицировать ведро таким числовым отрезком. Безусловно, числовые отрезки ведер должны составлять разбиение множества хеш-значений.
При поиске элемента мы сначала вычисляем его хеш-значение. Затем ищем ведро, числовой отрезок которого содержит это хеш-значение. И, наконец, извлекаем сам элемент из ведра.
За счет подбора параметров (числа ведер и хранимых в ведре элементов), можно добиться константного времени, затрачиваемого на поиск. Более того, в сложных структурах при наличии огромного числа элементов более эффективным может быть использование вложенных ведер, которые содержатся в других ведрах.
5.2. Сравнение алгоритмов суммирования перечислителей
Сравним два алгоритма суммирования перечислителей: первый, основанный на применении обобщенного алгоритма вычисления границы Парето из предыдущего параграфа, и второй, алгоритм попарного суммирования перечислителей [1].
При двух процессах эффективно применение схемы, включающей алгоритм поиска границы Парето для Д2. Однако для произвольного числа перечислителей эффективность такого алгоритма может вызывать сомнения, из-за необходимости хранить куб уровней, размерность которого на единицу меньше числа перечислителей.
Схема попарного суммирования перечислителей очень проста. Сначала суммируем первые два перечислителя, затем их результат просуммируем с третьим и так далее. Вычислительная реализация также оказывается несложной, поскольку результат суммирования двух перечислителей, также является процессом (в терминологии классов — наследуется от базового класса Process).
// ProcessSumFor2 - класс, суммирующий два процессора // p_processes -- список исходных перечислителей Process p = new ProcessSumFor2(p_processes.subList(0,2)); for (int i=2; i < p_processes.size(); i++) { List list = new ArrayList(2); list.add(p);
list.add(p_processes.get(i));
5 Хеш-функция является сверткой координат и обеспечивает уникальное значение для каждого набора координат. Один из простейших видов такой свертки — запись числа в системе счисления, где каждая координата рассматривается как отдельный разряд. Этот пример может служить лишь для понимания концепции хеш-функции, поскольку полученное число может оказаться достаточно большим; такие схемы на практике не применяют, а используют специальные алгоритмы, например
SHA-1, MD5.
p = new ProcessSumFor2(list);
}
При испытаниях на больших размерностях (число перечислителей более 15) данный алгоритм давал сбои, вызванные переполнением памяти, поскольку при попарном суммировании процессов каждый раз запоминается контекст выполнения суммируемого перечислителя. В то же время алгоритм, основанный на вычислении границы Парето для многомерного множества, занимал памяти существенно меньше.
Также оказалось, что для поиска небольшого числа первых решений при суммировании нескольких перечислителей, эффективным оказывается подход, основанный на обобщенном алгоритме поиска границы Парето. Однако для поиска всех решений (упорядочивании решений по оптимальности), будет более эффективна схема попарного суммирования перечислителей.
6. Алгоритм поиска минимального решения
Предлагаемый ниже алгоритм отличается от известного алгоритма приоритетной очереди [8], хотя и имеет в своей основе схожие с ним идеи.
Пусть есть список элементов, который периодически пополняется новыми и возвращает минимальный элемент списка по запросу. Выделим «голову» очереди элементов множества и будем возвращать ее в качестве минимального элемента, а затем перестраивать список. Основные шаги алгоритма — добавление нового элемента и «переупорядочение» списка. На самом деле мы будем перестраивать список лишь при необходимости, то есть при очередном запросе минимального элемента пометим флагом (headGone) тот факт, что минимальный элемент уже возвращен и при следующем запросе переупорядочим очередь.
Рассмотрим процедуру добавления элемента в очередь. Если выставлен флаг headGone, то очередь необходимо переупорядочить. Если новый элемент лучше текущего головного элемента (в терминах интерфейса IComparable), то он становится «головой» списка со старым минимальным элементом в качестве дочернего.
В случае, когда текущий минимальный элемент ic («голова» списка) лучше нового элемента in, перебираем все дочерние элементы ic, пока не найдется элемент iw, хуже in. Меняем местами iw и in так, что in становится дочерним для ic, а iw будет теперь иметь уже нового «родителя» in. Если же все дочерние элементы лучше in, добавим in в конец списка дочерних элементов ic.
Для формального описания алгоритма введем класс LineElement, из объектов которого будет состоять собственно сама очередь. В классе будут следующие методы для операций над дочерними элементами.
IComparable getValue(); // Возвращает собственно значение элемента очереди
Iterator getChildren(); // Возвращает все дочерние элементы
void addChild(LineElement p_child); //Добавляет новый дочерний элемент
// Меняет местами дочерние элементы void replaceChild(LineElement i_w, IComparable i_n);
А теперь рассмотрим функцию добавления элемента в очередь.
//_head - текущий ''головной элемент'' public void add(IComparable i_n) { if (_headGone)
reBuildLine(); // перестройка очереди
// Лучше ли добавляемый элемент, чем текущий головной if (i_n.better(_head.getValue())) {
LineElement head = new LineElement(i_n);
head.addChild(_head);
_head = head;
return;
}
// Перебор всех дочерних элементов Iterator i = _head.getChildren(); while (i.hasNext()) {
LineElement i_c = (LineElement) i.next(); if (i_n.better(i_c.getValue())) { _head.replaceChild(i_c, i_n); return; } else
continue;
}
_head.addChild(new LineElement(i_n));
}
В результате этой операции «головной» элемент списка является минимальным, первый дочерний элемент всегда лучше остальных и, даже более того, дочерние элементы упорядочены! Именно этот факт лежит в основе второго основного действия — перестраивания списка.
Перестраивание списка состоит из одного шага — первый дочерний элемент становится «головой» списка, а все остальные его дочерними элементами. И, как легко заметить, все сделанные выше утверждения будут также выполняться.
protected void reBuildLine() { _headGone = false; Iterator i = _head.getChildren(); if (i.hasNext())
// Первый дочерний элемент становится головным _head = (LineElement) i.next(); else {
// Больше элементов в очереди нет _head = null; return;
}
// Остальные дочерние элементы - становятся дочерними нового головного while (i.hasNext()) {
LineElement i_c = (LineElement) i.next(); if (i_c.getValue().better(_head.getValue())) { i_c.addChild(_head); _head = i_c; } else
_head.addChild(i_c);
}
}
Отметим, что предложенный алгоритм не сортирует все элементы в своей очереди, а хранит как бы «полуупорядоченное» множество. Также видно, что алгоритм является «в меру ленивым» и не делает лишних шагов, которые могут потом не потребоваться.
Одним из недостатков предложенного алгоритма является потенциальная возможность «вытягивания» списка дочерних элементов из-за отсутствия механизма балансирования числа дочерних элементов.
Ниже предложена модификация алгоритма, в которой мы уже храним список головных элементов (_list), первый из которых является минимальным, и осуществляем «более жесткий» отбор дочерних элементов по монотонности. В отличие от первого алгоритма, перестройка списка является более тяжелой операцией, но за счет ведения монотонного отбора дочерних элементов сохраняется большая сбалансированность дерева перебора, что экономит время при добавлении нового элемента.
public void add(IComparable i_n) { if (_headGone) reBuildLine(); LineElement newEl = new LineElement(i_n); LineElement oldEl = null; int k = 0;
for (Iterator i = _list.iterator(); i.hasNext(); k++) { LineElement i_c = (LineElement) i.next(); if (!i_c.getValue().better(i_n)) { newEl.addChild(i_c); oldEl = i_c; break;
}
}
if (oldEl != null ) { _list.remove(oldEl);
_list.add(k, newEl); // Вставляется элемент 'newEl' на позицию 'k' } else
_list.add(newEl);
}
Соответственно изменяется и процедура перестраивания списка при возвращении головного элемента.
protected void reBuildLine() { if (!_headGone) return;
// Если список пустой, то нечего перестраивать if (_list.size() == 0) return;
// Удаляется из списка первый, уже возвращенный, элемент LineElement el = (LineElement)_list.get(0); _list.remove(0);
if (_list.size() > 0) {
// Головным элементом становится следующий по списку LineElement newHead = (LineElement)_list.get(0);
// Перебираются дочерние элементы прежнего головного элемента Iterator i = el.getChildren(); while (i.hasNext()) {
i_c = (LineElement) i.next();
// Если новый головной элемент лучше текущего дочернего if (newHead.getValue().better(i_c.getValue()))
// Меняется родитель у этого дочернего элемента newHead.addChild(i_c);
else
_list.add(0, i_c);// Иначе он добавляется в список
}
}
_headGone = false;
}
Summary
J. V. Romanovsky, S. V. Kuznetsov. A generalized algorithm of summing enumerators of suboptimal solutions.
An approach enumerating suboptimal solutions based on the processes-enumerators is developed. The proposed algorithm sums an arbitrary number of enumerators using the Pareto boundary concept as well as an adopted version of the algorithm finding the minimal element of the set. This approach turned out to be effective for finding a small number of first solutions as well as for cases with a big number of enumerators and hence a big number of first solutions in question. It is noted that the enumerator pair sum algorithm remains more efficient in enumerating all solutions ordered by values of the objective function.
Литература
1. Романовский И. В. Субоптимальные решения. Петрозаводск: Из-во Петрозаводского ун-та. 1998.
2. Gabov H. N. Two algorithms for generation weighted spanning trees in order // SIAM Journal of Computing, 1977. Vol. 6. P. 139-150.
3. Minieka E., Shier D. A note on an algebra for the к best routes in a network // Journ. Inst. Math. Appl. 1973. Vol. 11. P. 145-149.
4. Романовский И. В. Дискретный анализ. Изд. 3-е. СПб.: Невский диалект. 2003.
5. Horstmann C. S., Cornell G. Java 2. Vol. I — Fundamentals. Prentice Hall. Sun Microsystems Press.
6. Sedgewick R. Algorithms in Java 3rd Edition. Parts 1-4. Princeton University, Addison-Wesley.
7. Denardo E. V., Fox B.L. Shortest-route methods: 1. reaching, pruning, and buckets // Operations Research. 1979. Vol. 27. P. 161-186.
8. Кормен Т., Лайзерсон Ч., Ривест Р. Алгоритмы: построение и анализ. М.: МЦНМО, 1999. 960 с.
Статья поступила в редакцию 27 января 2005 г.