Открытие Эйлера закономерным образом
приводит к рекурсивному алгоритму вычисления наибольшего общего делителя:
public Function GCD(A As Integer, B
As Integer) As Integer
If
B Mod A = 0 Then ' Делится ли B
на A нацело?
GCD = A '
Да. Процедура завершена.
Else
GCD
= GCD(B Mod A, A) ' Нет. Рекурсия.
End
If
End Function
Анализ
времени выполнения программы
Чтобы
проанализировать время выполнения этого алгоритма, необходимо определить,
насколько быстро убывает переменная A. Так как функция
останавливается, когда A доходит до
значения 1, то скорость уменьшения A дает верхнюю
границу оценки времени выполнения алгоритма. Оказывается, при каждом втором
вызове функции GCD, параметр A
уменьшается, по крайней мере, в 2 раза.
Допустим, A < B.
Это условие всегда выполняется при первом вызове функции GCD.
Если B Mod A <= A/2, то при следующем вызове функции GCD
первый параметр уменьшится, по крайней мере, в 2 раза, и доказательство
закончено.
Предположим обратное. Допустим, B Mod A > A / 2. Первым рекурсивным вызовом функции GCD
будет GCD(B Mod A, A).
Подстановка в функцию значения B Mod A и A
вместо A и B
дает следующий рекурсивный вызов GCD(B Mod A, A).
Но мы предположили, что B Mod A > A / 2. Тогда B Mod A разделится на A
только один раз, с остатком A – (B Mod A). Так как B Mod A
больше, чем A / 2, то A – (B Mod A) должно быть меньше, чем A / 2. Значит, первый параметр второго
рекурсивного вызова функции GCD меньше, чем A / 2, что и требовалось доказать.
Предположим теперь, что N —
это исходное значение параметра A. После двух
вызовов функции GCD, значение
параметра A должно
уменьшится, по крайней мере, до N / 2. После четырех вызовов, это значение
будет не больше, чем (N / 2) / 2 = N / 4. После шести вызовов, значение не
будет превосходить (N / 4) / 2 = N / 8. В общем случае, после 2 * K вызовов
функции GCD, значение
параметра A будет не больше,
чем N / 2K.
Поскольку алгоритм должен остановиться,
когда значение параметра A дойдет до 1, он
может продолжать работу только до тех, пока не выполняется равенство N/2K=1. Это происходит, когда N=2K или когда K=log2(N). Так как алгоритм выполняется за 2*K шагов это
означает, что алгоритм остановится не более, чем через 2*log2(N) шагов. С точностью до постоянного
множителя, это означает, что алгоритм выполняется за время порядка O(log(N)).
=======85
Этот алгоритм — один из множества
рекурсивных алгоритмов, которые выполняются за время порядка O(log(N)). При выполнении фиксированного числа шагов, в данном
случае 2, размер задачи уменьшается вдвое. В общем случае, если размер задачи
уменьшается, по меньшей мере, в D раз после каждых S шагов, то задача потребует
S*logD(N) шагов.
Поскольку при оценке по порядку величины
можно игнорировать постоянные множители и основания логарифмов, то любой
алгоритм, который выполняется за время S*logD(N), будет алгоритмом порядка O(log(N)). Это не обязательно означает, что этими постоянными можно
полностью пренебречь при реализации алгоритма. Алгоритм, который уменьшает
размер задачи при каждом шаге в 10 раз, вероятно, будет быстрее, чем алгоритм,
который уменьшает размер задачи вдвое через каждые 5 шагов. Тем не менее, оба
эти алгоритма имеют время выполнения порядка O(log(N)).
Алгоритмы порядка O(log(N)) обычно выполняются очень быстро, и алгоритм нахождения
наибольшего общего делителя не является исключением из этого правила. Например,
чтобы найти, что наибольший общий делитель чисел 1.736.751.235 и 2.135.723.523
равен 71, функция вызывается всего 17 раз. Фактически, алгоритм практически
мгновенно вычисляет значения, не превышающие максимального значения числа в
формате long —
2.147.483.647. Функция Visual Basic Mod
не может оперировать значениями, большими этого, поэтому это практический
предел для данной реализации алгоритма.
Программа GCD
использует этот алгоритм для рекурсивного вычисления наибольшего общего
делителя. Введите значения для A и B, затем нажмите на кнопку Go, и программа вычислит
наибольший общий делитель этих двух чисел.
Рекурсивное
вычисление чисел Фибоначчи
Можно
рекурсивно определить числа Фибоначчи XE "Числа:Фибоначчи" (Fibonacci numbers XE "Fibonacci numbers" ) при помощи уравнений:
Fib(0) = 0
Fib(1) = 1
Fib(N) = Fib(N - 1) + Fib(N - 2) для N > 1.
Третье уравнение рекурсивно дважды вызывает
функцию Fib, один раз с
входным значением N-1, а другой — со значением N-2. Это определяет необходимость 2 условий
остановки рекурсии: Fib(0)=0 и Fib(1)=1. Если задать только одно из них,
рекурсия может оказаться бесконечной. Например, если задать только Fib(0)=0, то значение Fib(2) могло бы вычисляться следующим образом:
Fib(2) = Fib(1) + Fib(0)
=
[Fib(0) + Fib(-1)] + 0
=
0 + [Fib(-2) + Fib(-3)]
=
[Fib(-3) + Fib(-4)] + [Fib(-4) + Fib(-5)]
И
т.д.
Это определение чисел Фибоначчи легко
преобразовать в рекурсивную функцию:
Public Function Fib(num As Integer)
As Integer
If
num <= 1 Then
Fib
= num
Else
Fib
= Fib(num – 1) + Fib(num - 2)
End
If
End Function
=========86
Анализ
времени выполнения программы
Анализ
этого алгоритма достаточно сложен. Во‑первых, определим, сколько раз
выполняется одно из условий остановки num <=1. Пусть G(N) — количество раз, которое алгоритм
достигает условия остановки для входа N. Если N <= 1, то функция достигает условия остановки
один раз и не требует рекурсии.
Если N > 1, то функция рекурсивно вычисляет Fib(N-1) и Fib(N-2), и завершает работу. При первом вызове
функции, условие остановки не выполняется — оно достигается только в
следующих, рекурсивных вызовах. Полное число выполнения условия остановки для
входного значения N, складывается из
числа раз, которое оно выполняется для значения N-1 и числа раз, которое оно выполнялось для
значения N-2. Все это можно записать так:
G(0) = 1
G(1) = 1
G(N) = G(N - 1) + G(N
- 2) для N > 1.
Это рекурсивное
определение очень похоже на определение чисел Фибоначчи. В табл. 5.2 приведены
некоторые значения функций G(N) и Fib(N). Легко увидеть, что G(N) = Fib(N+1).
Теперь рассмотрим,
сколько раз алгоритм достигает рекурсивного шага. Если N<=1, функция не достигает этого шага.
При N>1,
функция достигает этого шага 1 раз и затем рекурсивно вычисляет Fib(n-1) и Fib(N-2). Пусть H(N) — число раз, которое алгоритм
достигает рекурсивного шага для входа N. Тогда H(N)=1+H(N-1)+H(N-2). Уравнения, определяющие H(N):
H(0) = 0
H(1) = 0
H(N) = 1 + H(N - 1) +
H(N - 2) для N > 1.
В табл. 5.3
показаны некоторые значения для функций Fib(N) и H(N). Можно увидеть, что H(N)=Fib(N+1)-1.
@Таблица 5.2.
Значения чисел Фибоначчи и функции G(N)
======87
@Таблица 5.3.
Значения чисел Фибоначчи и функции H(N)
Объединяя
результаты для G(N) и H(N), получаем полное время выполнения для
алгоритма:
Время выполнения = G(N) + H(N)
= Fib(N + 1) + Fib(N + 1) - 1
=
2 * Fib(N + 1) - 1
Поскольку Fib(N + 1) >= Fib(N) для всех значений N,
то:
Время выполнения >= 2 * Fib(N) - 1
С точностью до
порядка это составит O(Fib(N)). Интересно, что эта
функция не только рекурсивная, но она также используется для оценки времени ее
выполнения.
Чтобы помочь вам
представить скорость роста функции Фибоначчи, можно показать, что Fib(M)>ÆM-2
где Æ — константа,
примерно равная 1,6. Это означает, что время выполнения не меньше, чем значение
экспоненциальной функции O(ÆM).
Как и другие экспоненциальные функции, эта функция растет быстрее, чем
полиномиальные функции, но медленнее, чем функция факториала.
Поскольку время
выполнения растет очень быстро, этот алгоритм довольно медленно выполняется для
больших входных значений. Фактически, настолько медленно, что на практике почти
невозможно вычислить значения функции Fib(N) для N,
которые намного больше 30. В табл. 5.4 показано время выполнения для этого
алгоритма на компьютере с процессором Pentium с тактовой частотой 90 МГц при
разных входных значениях.
Программа Fibo
использует этот рекурсивный алгоритм для вычисления чисел Фибоначчи. Введите
целое число и нажмите на кнопку Go для вычисления чисел Фибоначчи. Начните с небольших чисел,
пока не оцените, насколько быстро ваш компьютер может выполнять эти вычисления.
Рекурсивное
построение кривых Гильберта
Кривые Гильберта (Hilbert curves XE "Hilbert curves" )
XE "Кривые:Гильберта" — это самоподобные
(self‑similar) кривые, которые обычно определяются при помощи рекурсии.
На рис. 5.2. показаны кривые Гильберта с 1, 2 или 3 порядка.
@Таблица 5.4. Время
выполнения программы Fibonacci
=====88
@Рис. 5.2. Кривые
Гильберта
Кривая Гильберта,
как и любая другая самоподобная кривая, создается разбиением большой кривой на
меньшие части. Затем вы можете использовать эту же кривую, после изменения
размера и поворота, для построения этих частей. Эти части можно разбить на
более мелкие части, и так далее, пока процесс не достигнет нужной глубины
рекурсии. Порядок кривой определяется как максимальная глубина рекурсии,
которой достигает процедура.
Процедура Hilbert
управляет глубиной рекурсии, используя соответствующий параметр. При каждом
рекурсивном вызове, процедура уменьшает параметр глубины рекурсии на единицу.
Если процедура вызывается с глубиной рекурсии, равной 1, она рисует простую
кривую 1 порядка, показанную на рис. 5.2 слева и завершает работу. Это условие
остановки рекурсии.
Например, кривая
Гильберта 2 порядка состоит из четырех кривых Гильберта 1 порядка. Аналогично,
кривая Гильберта 3 порядка состоит из четырех кривых 2 порядка, каждая из
которых состоит из четырех кривых 1 порядка. На рис. 5.3 показаны кривые
Гильберта 2 и 3 порядка. Меньшие кривые, из которых построены кривые большего
размера, выделены полужирными линиями.
Следующий код
строит кривую Гильберта 1 порядка:
Line -Step (Length, 0)
Line -Step (0, Length)
Line -Step (-Length, 0)
Предполагается, что
рисование начинается с верхнего левого угла области и что Length —
это заданная длина каждого отрезка линий.
Можно набросать
черновик метода, рисующего кривые Гильберта более высоких порядков:
Private Sub Hilbert(Depth As Integer)
If
Depth = 1 Then
Нарисовать кривую Гильберта 1 порядка
Else
Нарисовать и соединить 4 кривые порядка
(Depth - 1)
End If
End Sub
====89
@Рис. 5.3. Кривые
Гильберта, образованные меньшими кривыми
Этот метод требует
небольшого усложнения для определения направления рисования кривых. Это
требуется для того, чтобы выбрать тип используемых кривых Гильберта.
Эту информацию
можно передать процедуре при помощи параметров Dx и Dy для определения
направления вывода первой линии в кривой. Для кривой 1 порядка, процедура
рисует первую линию при помощи функции Line-Step(Dx, Dy). Если кривая имеет более высокий порядок,
процедура соединяет первые две подкривых, используя функцию Line-Step(Dx,
Dy). В любом случае, процедура
может использовать параметры Dx и Dy
для выбора направления, в котором она должна рисовать линии, образующие кривую.
Код на языке Visual Basic
для рисования кривых Гильберта короткий, но сложный. Вам может потребоваться
несколько раз пройти его в отладчике для кривых 1 и 2 порядка, чтобы увидеть,
как изменяются параметры Dx и Dy,
при построении различных частей кривой.
Private Sub Hilbert(depth As Integer, Dx As
Single, Dy As Single)
If
depth > 1 Then Hilbert depth - 1, Dy, Dx
HilbertPicture.Line
-Step(Dx, Dy)
If
depth > 1 Then Hilbert depth - 1, Dx, Dy
HilbertPicture.Line
-Step(Dy, Dx)
If
depth > 1 Then Hilbert depth - 1, Dx, Dy
HilbertPicture.Line
-Step(-Dx, -Dy)
If
depth > 1 Then Hilbert depth - 1, -Dy, -Dx
End Sub
Анализ
времени выполнения программы
Чтобы
проанализировать время выполнения этой процедуры, вы можете определить число
вызовов процедуры Hilbert. При
каждой рекурсии она вызывает себя четыре раза. Если T(N) — это число вызовов процедуры, когда
она вызывается с глубиной рекурсии N, то:
T(1) = 1
T(N) = 1 + 4 * T(N -
1) для N > 1.
Если раскрыть
определение T(N), получим:
T(N) =
1 + 4 * T(N - 1)
=
1 + 4 *(1 + 4 * T(N - 2))
=
1 + 4 + 16 * T(N - 2)
=
1 + 4 + 16 * (1 + 4 * T(N - 3))
=
1 + 4 + 16 + 64 * T(N - 3)
=
...
=
40 + 41 + 42 + 43 + ... + 4K * T(N - K)
Раскрыв это
уравнение до тех пор, пока не будет выполнено условие остановки рекурсии T(1)=1, получим:
T(N) = 40 + 41 + 42 + 43
+ ... + 4N-1
Это уравнение можно
упростить, воспользовавшись соотношением:
X0 + X1 + X2
+ X3 + ... + XM = (XM+1 - 1) / (X - 1)
После
преобразования, уравнение приводится к виду:
T(N) =
(4(N-1)+1 - 1) / (4 - 1)
=
(4N - 1) / 3
=====90
С точностью до
постоянных, эта процедура выполняется за время порядка O(4N). В табл.
5.5 приведены несколько первых значений функции времени выполнения. Если вы
внимательно посмотрите на эти числа, то увидите, что они соответствуют
рекурсивному определению.
Этот алгоритм
является типичным примером рекурсивного алгоритма, который выполняется за время
порядка O(CN), где C — некоторая постоянная. При каждом вызове
подпрограммы Hilbert, она
увеличивает размерность задачи в 4 раза. В общем случае, если при каждом
выполнении некоторого числа шагов алгоритма размер задачи увеличивается не менее,
чем в C раз, то время выполнения алгоритма будет порядка O(CN).
Это поведение
противоположно поведению алгоритма поиска наибольшего общего делителя.
Процедура GCD уменьшает
размерность задачи в 2 раза при каждом втором своем вызове, и поэтому время ее
выполнения порядка O(log(N)). Процедура построения
кривых Гильберта увеличивает размер задачи в 4 раза при каждом своем вызове,
поэтому время ее выполнения порядка O(4N).
Функция (4N-1)/3 —
это экспоненциальная функция, которая растет очень быстро. Фактически, она растет настолько быстро, что вы
можете предположить, что это не слишком эффективный алгоритм. В
действительности работа этого алгоритма занимает много времени, но есть две
причины, по которым это не так уж и плохо.
Во-первых, ни один
алгоритм для построения кривых Гильберта не может быть намного быстрее. Кривые
Гильберта содержат множество отрезков
линий, и любой рисующий их алгоритм будет требовать достаточно много времени.
При каждом вызове процедуры Hilbert, она
рисует три линии. Пусть L(N) — суммарное число линий, из которых состоит
кривая Гильберта порядка N. Тогда L(N) = 3 * T(N) = 4N - 1, поэтому L(N) также
порядка O(4N). Любой алгоритм, рисующий кривые Гильберта, должен вывести O(4N)
линий, выполнив при этом O(4N) шагов. Существуют другие алгоритмы построения
кривых Гильберта, но они занимают почти столько же времени, сколько и этот
алгоритм.
@Таблица 5.5. Число
рекурсивных вызовов подпрограммы Hilbert
=====91
Второй факт,
который показывает, что этот алгоритм не так уж плох, заключается в том, что
кривые Гильберта 9 порядка содержат так много линий, что экран большинства
компьютерных мониторов при этом оказывается полностью закрашенным. Это
неудивительно, так как эта кривая содержит 262.143 отрезков линий. Это
означает, что вам вероятно никогда не понадобится выводить на экран кривые
Гильберта 9 или более высоких порядков. На каком‑то порядке вы
столкнетесь с ограничениями языка Visual Basic и вашего компьютера, но, скорее всего, вы еще раньше будете
ограничены максимальным разрешением экрана.
Программа Hilbert,
показанная на рис. 5.4, использует этот рекурсивный алгоритм для рисования
кривых Гильберта. При выполнении программы не задавайте слишком большую глубину
рекурсии (больше 6) до тех пор, пока вы не определите, насколько быстро выполняется
эта программа на вашем компьютере.
Рекурсивное
построение кривых Серпинского
Как и кривые
Гильберта, кривые Серпинского (Sierpinski curves XE "Sierpinski curves" )
XE "Кривые:Серпинского" — это самоподобные кривые, которые обычно определяются рекурсивно.
На рис. 5.5 показаны кривые Серпинского 1, 2 и 3 порядка.
Алгоритм построения
кривых Гильберта использует всего одну подпрограмму для рисования кривых.
Кривые Серпинского проще рисовать, используя четыре отдельных процедуры,
которые работают совместно. Эти процедуры называются SierpA,
SierpB, SierpC и SierpD.
Это процедуры с косвенной рекурсией — каждая процедура вызывает другие,
которые затем вызывают первоначальную процедуру. Они рисуют верхнюю, левую,
нижнюю и правую части кривой Серпинского, соответственно.
На рис. 5.6
показано, как эти процедуры работают совместно, образуя кривую Серпинского 1
порядка. Подкривые изображены стрелками, чтобы показать направление, в котором
они рисуются. Отрезки, соединяющие четыре подкривые, нарисованы пунктирными
линиями.
@Рис. 5.4.
Программа Hilbert
=====92
@Рис. 5.5. Кривые
Серпинского
Каждая из четырех
основных кривых состоит из диагонального отрезка, затем вертикального или
горизонтального отрезка, и еще одного диагонального отрезка. Если глубина
рекурсии больше единицы, каждая из этих кривых разбивается на меньшие части.
Это осуществляется разбиением каждого из двух диагональных отрезков на две
подкривые.
Например, для
разбиения кривой типа A, первый диагональный отрезок разбивается на кривую типа
A, за которой следует кривая типа B. Затем рисуется без изменений
горизонтальный отрезок из исходной кривой типа A. Наконец, второй диагональный
отрезок разбивается на кривую типа D, за которой следует кривая типа A. На рис.
5.7 показано, как кривая типа A второго порядка образуется из нескольких кривых
1 порядка. Подкривые изображены жирными линиями.
На рис. 5.8
показано, как полная кривая Серпинского 2 порядка образуется из 4 подкривых 1
порядка. Каждая из подкривых обведена контурной линией.
Можно использовать
стрелки ä и ã для обозначения
типа линий, соединяющих подкривые (тонкие линии на рис. 5.8), тогда можно будет
изобразить рекурсивные отношения между четырьмя типами кривых так, как это
показано на рис. 5.9.
@Рис. 5.6. Части
кривой Серпинского
=====93
@Рис. 5.7.
Разбиение кривой типа A
Все процедуры для
построения подкривых Серпинского очень похожи, поэтому мы приводим здесь только
одну из них. Соотношения на рис. 5.9 показывают, какие операции нужно выполнить
для рисования кривых различных типов. Соотношения для кривой типа A реализованы
в следующем коде. Вы можете использовать остальные соотношения, чтобы
определить, какие изменения нужно внести в код для рисования кривых других
типов.
Private Sub SierpA(Depth As Integer, Dist As
Single)
If
Depth = 1 Then
Line
-Step(-Dist, Dist)
Line
-Step(-Dist, 0)
Line
-Step(-Dist, -Dist)
Else
SierpA
Depth - 1, Dist
Line
-Step(-Dist, Dist)
SierpB
Depth - 1, Dist
Line
-Step(-Dist, 0)
SierpD
Depth - 1, Dist
Line
-Step(-Dist, -Dist)
SierpA
Depth - 1, Dist
End If
End
Sub
@Рис. 5.8. Кривые
Серпинского, образованные из меньших кривых Серпинского
=====94
@Рис. 5.9.
Рекурсивные соотношения между кривыми Серпинского
Кроме процедур,
которые рисуют каждую из основных кривых, потребуется еще процедура, которая по
очереди вызывает их все для создания законченной кривой Серпинского.
Sub Sierpinski (Depth As Integer, Dist As
Single)
SierpB
Depth, Dist
Line
-Step(Dist, Dist)
SierpC
Depth, Dist
Line
-Step(Dist, -Dist)
SierpD
Depth, Dist
Line
-Step(-Dist, -Dist)
SierpA
Depth, Dist
Line
-Step(-Dist, Dist)
End Sub
Анализ времени
выполнения программы
Чтобы
проанализировать время выполнения этого алгоритма, необходимо определить число
вызовов для каждой из четырех процедур рисования кривых. Пусть T(N) — число вызовов любой из четырех
основных подпрограмм основной процедуры Sierpinski при
построении кривой порядка N.
Если порядок кривой
равен 1, кривая каждого типа рисуется только один раз. Прибавив сюда основную
процедуру, получим T(1) = 5.
При каждом
рекурсивном вызове, процедура вызывает саму себя или другие процедуры четыре
раза. Так как эти процедуры практически одинаковые, то T(N) будет одинаковым, независимо от того,
какая процедура вызывается первой. Это обусловлено тем, что кривые Серпинского
симметричны и содержат одно и то же число кривых разных типов. Рекурсивные
уравнения для T(N) выглядят так:
T(1) = 5
T(N) = 1 + 4 * T(N-1) для N > 1.
Эти уравнения почти
совпадают с уравнениями, которые использовались для оценки времени выполнения
алгоритма, рисующего кривые Гильберта. Единственное отличие состоит в том, что
для кривых Гильберта T(1) = 1. Сравнение значений этих уравнений
показывает, что TSierpinski(N) = THilbert(N+1). В конце предыдущего раздела было
показано, что THilbert(N) = (4N
- 1) / 3, поэтому TSierpinski(N) = (4N+1
- 1) / 3, что также составляет O(4N).
=====95
Так же, как и
алгоритм построения кривых Гильберта, этот алгоритм выполняется за время
порядка O(4N), но это не так уж и плохо. Кривая Серпинского состоит
из O(4N) линий, поэтому ни один алгоритм не может нарисовать кривую
Серпинского быстрее, чем за время порядка O(4N).
Кривые Серпинского
также полностью заполняют экран большинства компьютеров при порядке кривой,
большем или равном 9. При каком‑то порядке, большем 9, вы столкнетесь с
ограничениями языка Visual Basic и возможностей вашего компьютера, но, скорее всего, вы еще
раньше будете ограничены предельным разрешением экрана.
Программа Sierp,
показанная на рис. 5.10, использует этот рекурсивный алгоритм для рисования
кривых Серпинского. При выполнении программы, задавайте вначале небольшую
глубину рекурсии (меньше 6), до тех пор, пока вы не определите, насколько
быстро выполняется эта программа на вашем компьютере.
Опасности рекурсии
Рекурсия может
служить мощным методом разбиения больших задач на части, но она таит в себе
несколько опасностей. В этом разделе мы пытаемся охватить некоторые из этих
опасностей и объяснить, когда стоит и не стоит использовать рекурсию. В
последующих разделах приводятся методы устранения от рекурсии, когда это
необходимо.
Бесконечная
рекурсия
Наиболее очевидная
опасность рекурсии заключается в бесконечной рекурсии. Если неправильно
построить алгоритм, то функция может пропустить условие остановки рекурсии и
выполняться бесконечно. Проще всего совершить эту ошибку, если просто забыть о
проверке условия остановки, как это сделано в следующей ошибочной версии
функции факториала. Поскольку функция не проверяет, достигнуто ли условие
остановки рекурсии, она будет бесконечно вызывать сама себя.
@Рис. 5.10
Программа Sierp
=====96
Private Function BadFactorial(num As Integer)
As Integer
BadFactorial
= num * BadFactorial (num - 1)
End Function
Функция также может
вызывать себя бесконечно, если условие остановки не прекращает все возможные
пути рекурсии. В следующей ошибочной версии функции факториала, функция будет
бесконечно вызывать себя, если входное значение — не целое число, или если
оно меньше 0. Эти значения не являются допустимыми входными значениями для
функции факториала, поэтому в программе, которая использует эту функцию, может
потребоваться проверка входных значений. Тем не менее, будет лучше, если
функция выполнит эту проверку сама.
Private Function BadFactorial2(num As Double)
As Double
If num = 0 Then
BadFactorial2 =
1
Else
BadFactorial2 =
num * BadFactorial2(num-1)
End If
End Function
Следующая версия
функции Fibonacci является
более сложным примером. В ней условие остановки рекурсии прекращает выполнение
только нескольких путей рекурсии, и возникают те же проблемы, что и при
выполнении функции BadFactorial2, если входные значения отрицательные или
не целые.
Private Function BadFib(num As Double) As
Double
If num
= 0 Then
BadFib
= 0
Else
BadFib
= BadPib(num - 1) + BadFib (num - 2)
End If
End Function
И последняя
проблема, связанная с бесконечной рекурсией, заключается в том, что
«бесконечная» на самом деле означает «до тех пор, пока не будет исчерпано
стековое пространство». Даже корректно написанные рекурсивные процедуры будут
иногда приводить к переполнению стека и аварийному завершению работы. Следующая
функция, которая вычисляет сумму N + (N - 1) + … + 2 +1, приводит к исчерпанию
стекового пространства при больших значениях N.
Наибольшее возможное значение N, при котором
программа еще будет работать, зависит от конфигурации вашего компьютера.
Private Function BigAdd(N As Double) As Double
If N <= 1
Then
BigAdd = 1
Else
BigAdd = N + BigAdd(N - 1)
End If
End Function
=====97
Программа BigAdd
демонстрирует этот алгоритм. Проверьте, насколько большое входное значение вы
можете ввести в этой программе до того, как наступит переполнение стека на
вашем компьютере.
Потери памяти
Другая опасность
рекурсии заключается в потерях памяти. При каждом вызове подпрограммы, система
выделяет память для локальных переменных новой процедуры. Во время сложной
последовательности рекурсивных вызовов, значительная часть времени и памяти
компьютера будет уходить на выделение и освобождение памяти для этих переменных
во время рекурсии. Даже если это не приведет к исчерпанию стекового
пространства, время, потраченное на работу с переменными, может быть
значительным.
Существует
несколько способов уменьшения этих накладных расходов. Во‑первых, не
следует использовать большого количества ненужных переменных. Даже если
подпрограмма не использует их, Visual Basic все равно будет отводить память под эти переменные.
Следующая версия функции BigAdd еще быстрее
приводит к переполнению стека, чем предыдущая.
Private Function BigAdd(N As Double) As Double
Dim I1 As Integer
Dim I2 As Integer
Dim I3 As Integer
Dim I4 As Integer
Dim I5 As Integer
If N
<= 1 Then
BigAdd
= 1
Else
BigAdd
= N + BigAdd (N - 1)
End If
End Function
Если вы не уверены,
нужна ли переменная, используйте оператор Option Explicit и
закомментируйте определение переменной. При попытке выполнить программу, Visual Basic
сообщит об ошибке, если переменная используется в программе.
Вы также можете
уменьшить использование стека за счет применения глобальных переменных. Если вы
определите переменные в секции Declarations
модуля вместо того, чтобы определять их в подпрограмме, то системе не
понадобится отводить память при каждом вызове подпрограммы.
Лучшим решением
будет определение переменных в процедуре при помощи зарезервированного слова Static.
Статические переменные используются совместно всеми экземплярами процедуры, и
системе не нужно отводить память под новые копии переменных при каждом вызове
подпрограммы.
Необоснованное
применение рекурсии
Менее очевидной
опасностью является необоснованное применение рекурсии. При этом использование
рекурсии не является наилучшим способом решения задачи. Приведенные выше
функции факториала, наибольшего общего делителя, чисел Фибоначчи и функции BigAdd
не обязательно должны быть рекурсивными. Лучшие, не рекурсивные версии этих
функций описываются позже в этой главе.
=====98
В случае факториала
и наибольшего общего делителя, ненужная рекурсия является по большей части
безвредной. Обе эти функции выполняются достаточно быстро для достаточно
больших выходных значений. Их выполнение также не будет ограничено размером
стека, если вы не использовали большую часть стекового пространства в других
частях программы.
С другой стороны,
применение рекурсии ухудшает алгоритм вычисления чисел Фибоначчи. Для
вычисления Fib(N), алгоритм вначале вычисляет Fib(N - 1) и Fib(N - 2). Но для вычисления Fib(N - 1) он должен сначала вычислить Fib(N - 2) и Fib(N - 3). При этом Fib(N - 2) вычисляется дважды.
Предыдущий анализ
этого алгоритма показал, что Fib(1) и Fib(0) вычисляются Fib(N + 1) раз во время вычисления Fib(N). Так как Fib(30) = 832.040 то, чтобы вычислить Fib(29), приходится вычислять одни и те же
значения Fib(0) и Fib(1) 832.040 раз. Алгоритм вычисления чисел
Фибоначчи тратит огромное количество времени на вычисление этих промежуточных
значений снова и снова.
В функции BigAdd
существует другая проблема. Хотя она выполняется быстро, она приводит к большой
глубине вложенности рекурсии, и очень быстро приводит к исчерпанию стекового
пространства. Если бы не переполнение стека, то эта функция могла бы вычислять
результаты для больших входных значений.
Похожая проблема
существует и в функции факториала. Для входного значения N
глубина рекурсии для факториала и функции BigAdd
равна N. Функция
факториала не может быть вычислена для таких больших входных значений, которые
допустимы для функции BigAdd.
Максимальное значение факториала, которое может уместиться в переменной типа double,
равно 170! »
7,257E+306, поэтому это наибольшее значение, которое может вычислить эта
функция. Хотя эта функция приводит к глубокой рекурсии, она вызывает переполнение
до того, как наступит переполнение стека.
Когда нужно
использовать рекурсию
Эти рассуждения
могут заставить вас думать, что рекурсия всегда нежелательна. Но это
определенно не так. Многие алгоритмы являются рекурсивными по своей природе. И
хотя любой алгоритм можно переписать так, чтобы он не содержал рекурсии, многие
алгоритмы сложнее понимать, анализировать, отлаживать и поддерживать, если они
написаны нерекурсивно.
В следующих
разделах приведены методы устранения рекурсии из любого алгоритма. Некоторые из
полученных нерекурсивных алгоритмов также просты в понимании. Функции,
вычисляющие без применения рекурсии факториал, наибольший общий делитель, числа
Фибоначчи, и функцию BigAdd,
относительно просты.
С другой стороны,
нерекурсивные версии алгоритмов построений кривых Гильберта и Серпинского
намного сложнее. Их труднее понять, поддерживать, и они даже выполняются
немного медленнее, чем рекурсивные версии. Они приведены лишь для того, чтобы
продемонстрировать методы, которые вы можете использовать для устранения
рекурсии из сложных алгоритмов, а не потому, что они лучше, чем рекурсивные
версии соответствующих алгоритмов.
Если алгоритм
рекурсивен по природе, записывайте его с использованием рекурсии. В лучшем
случае, вы не встретитесь ни одной из описанных проблем. Если же вы столкнетесь
с некоторыми из них, вы сможете переписать алгоритм без использования рекурсии
при помощи методов, представленных в следующих разделах. Переписать алгоритм
часто гораздо проще, чем с самого начала написать его без применения рекурсии.
======99
Хвостовая рекурсия
Вспомним
представленные ранее функции для вычисления факториалов и наибольшего общего
делителя, а также функцию BigAdd, которая
приводит к переполнению стека даже для относительно небольших входных значений.
Private Function Factorial(num As Integer) As Integer
If num
<= 0 Then
Factorial
= 1
Else
Factorial
= num * Factorial(num - 1)
End If
End Function
Private Function GCD(A As Integer, B As
Integer) As Integer
If B
Mod A = 0 Then
GCD
= A
Else
GCD
= GCD(B Mod A, A)
End If
End Function
Private Function BigAdd(N As Double) As Double
If N
<= 1 Then
BigAdd
= 1
Else
BigAdd
= N + BigAdd(N - 1)
End If
End Function
Во всех этих
функциях, последнее действие перед завершением функции — это рекурсивный
шаг. Этот тип рекурсии в конце процедуры называется XE
"Рекурсия:хвостовая" хвостовой рекурсией (tail recursion XE "recursion:tail recursion" или end recursion).
Так как после
рекурсии в процедуре ничего не происходит, существует простой способ ее
устранения. Вместо рекурсивного вызова функции, процедура сбрасывает свои
параметры, устанавливая те, которые бы она получила при рекурсивном вызове, и
затем выполняется снова.
Рассмотрим общий
случай рекурсивной процедуры:
Private Sub Recurse(A As Integer)
'
Выполняются какие‑либо действия, вычисляется B, и т.д.
Recurse B
End Sub
======100
Эту процедуру можно
переписать без рекурсии как:
Private Sub NoRecurse(A As Integer)
Do
While (not done)
'
Выполняются какие‑либо действия, вычисляется B, и т.д.
A = B
Loop
End Sub
Эта процедура
называется устранением хвостовой рекурсии
(tail recursion removal XE "tail recursion removal"
или end recursion removal). Этот прием не
изменяет время выполнения программы. Рекурсивные шаги просто заменяются
проходами в цикле While.
Устранение
хвостовой рекурсии, тем не менее, устраняет вызовы подпрограмм, и поэтому может
увеличить скорость работы алгоритма. Что более важно, этот метод также
уменьшает использование стека. Алгоритмы типа функции BigAdd,
которые ограничены глубиной рекурсии, могут от этого значительно выиграть.
Некоторые
компиляторы автоматически устраняют хвостовую рекурсию, но компилятор Visual Basic
этого не делает. В противном случае, функция BigAdd,
приведенная в предыдущем разделе, не приводила бы к переполнению стека.
Используя
устранение хвостовой рекурсии, легко переписать функции факториала, наибольшего
общего делителя, и BigAdd без
рекурсии. Эти версии используют зарезервированное слово ByVal
для сохранения значений своих параметров для вызывающей процедуры.
Private Function Factorial(ByVal N As Integer)
As Double
Dim value As Double
value
= 1# ' Это будет значением
функции.
Do While N > 1
value
= value * N
N = N - 1 ' Подготовить аргументы для "рекурсии".
Loop
Factorial
= value
End Function
Private Function GCD(ByVal A As Double, ByVal B
As Double) As Double
Dim B_Mod_A As Double
B_Mod_A
= B Mod A
Do
While B_Mod_A <> 0
'
Подготовить аргументы для "рекурсии".
B = A
A
= B_Mod_A
B_Mod_A
= B Mod A
Loop
GCD =
A
End Function
Private Function BigAdd(ByVal N As Double) As
Double
Dim value As Double
value
= 1# ' ' Это будет значением функции.
Do While N > 1
value
= value + N
N = N - 1 ' подготовить параметры для "рекурсии".
Loop
BigAdd
= value
End Function
=====101
Для алгоритмов
вычисления факториала и наибольшего общего делителя практически не существует
разницы между рекурсивной и нерекурсивной версиями. Обе версии выполняются достаточно
быстро, и обе они могут оперировать задачами большой размерности.
Для функции BigAdd,
тем не менее, разница огромна. Рекурсивная версия приводит к переполнению стека
даже для довольно небольших входных значений. Поскольку нерекурсивная версия не
использует стек, она может вычислять результат для значений N
вплоть до 10154. После этого наступит переполнение для данных типа double.
Конечно, выполнение 10154 шагов алгоритма займет очень много
времени, поэтому возможно вы не станете проверять этот факт сами. Заметим
также, что значение этой функции совпадает со значением более просто
вычисляемой функции N * N(N + 1) / 2.
Программы Facto2, GCD2 и BigAdd2 демонстрируют эти нерекурсивные
алгоритмы.
Нерекурсивное
вычисление чисел Фибоначчи
К сожалению, нерекурсивный
алгоритм вычисления чисел Фибоначчи не содержит только хвостовую рекурсию. Этот
алгоритм использует два рекурсивных вызова для вычисления значения, и второй
вызов следует после завершения первого. Поскольку первый вызов не находится в
самом конце функции, то это не хвостовая рекурсия, и от ее нельзя избавиться,
используя прием устранения хвостовой рекурсии.
Это может быть
связано и с тем, что ограничение рекурсивного алгоритма вычисления чисел
Фибоначчи связано с тем, что он вычисляет слишком много промежуточных значений,
а не глубиной вложенности рекурсии. Устранение хвостовой рекурсии уменьшает
глубину рекурсии, но оно не изменяет время выполнения алгоритма. Даже если бы
устранение хвостовой рекурсии было бы применимо к алгоритму вычисления чисел
Фибоначчи, этот алгоритм все равно остался бы чрезвычайно медленным.
Проблема этого
алгоритма в том, что он многократно вычисляет одни и те же значения. Значения Fib(1) и Fib(0) вычисляются Fib(N + 1) раз, когда алгоритм вычисляет Fib(N). Для вычисления Fib(29), алгоритм вычисляет одни и те же
значения Fib(0) и Fib(1) 832.040 раз.
Поскольку алгоритм
многократно вычисляет одни и те же значения, следует найти способ избежать
повторения вычислений. Простой и конструктивный способ сделать это — построить
таблицу вычисленных значений. Когда понадобится промежуточное значение, можно
будет взять его из таблицы, вместо того, чтобы вычислять его заново.
=====102
В этом примере
можно создать таблицу для хранения значений функции Фибоначчи Fib(N) для N,
не превосходящих 1477. Для N >= 1477 происходит переполнение
переменных типа double,
используемых в функции. Следующий код содержит измененную таким образом
функцию, вычисляющую числа Фибоначчи.
Const MAX_FIB = 1476 ' Максимальное значение.
Dim FibValues(0 To MAX_FIB) As Double
Private Function Fib(N As Integer) As Double
'
Вычислить значение, если оно не находится в таблице.
If FibValues(N) < 0 Then _
FibValues(M)
= Fib(N - 1) + Fib(N - 2)
Fib =
FibValues(N)
End Function
При запуске
программы, она присваивает каждому элементу в массиве FibValues
значение -1. Затем она присваивает FibValues(0) значение 0, и FibValues(1) — значение 1. Это условия
остановки рекурсии.
При выполнении
функции, она проверяет, находится ли уже в массиве значение, которое ей требуется.
Если его там нет, она, как и раньше, рекурсивно вычисляет это значение и
сохраняет его в массиве для дальнейшего использования.
Программа Fibo2 использует этот метод для вычисления
чисел Фибоначчи. Программа может быстро вычислить Fib(N) для N
до 100 или 200. Но если вы попытаетесь вычислить Fib(1476), то программа выполнит
последовательность рекурсивных вызовов глубиной 1476 уровней, которая вероятно
переполнит стек вашей системы.
Тем не менее, по
мере того, как программа вычисляет новые значения, она заполняет массив FibValues.
Значения из массива позволяют функции вычислять все большие и большие значения
без глубокой рекурсии. Например, если вычислить последовательно Fib(100), Fib(200), Fib(300), и т.д. то, в конце концов, можно
будет заполнить массив значений FibValues и
вычислить максимальное возможно значение Fib(1476).
Процесс медленного
заполнения массива FibValues приводит
к новому методу вычисления чисел Фибоначчи. Когда программа инициализирует
массив FibValues, она
может заранее вычислить все числа Фибоначчи.
Private Sub InitializeFibValues()
Dim i As Integer
FibValues(0)
= 0 ' Инициализация условий
остановки.
FibValues(1) = 1
For i
= 2 To MAX_FIB
FibValues(i)
= FibValues(i - 1) + FibValues(i - 2)
Next i
End Sub
Private Function Fib(N As Integer) As Duble
Fib -
FibValues(N)
End Function
=====104
Определенное время
в этом алгоритме занимает составление массива с табличными значениями. Но после
того как массив создан, для получения элемента из массива требуется всего один
шаг. Ни процедура инициализации, ни функция Fib
не используют рекурсию, поэтому ни одна из них не приведет к исчерпанию
стекового пространства. Программа Fibo3 демонстрирует этот подход.
Стоит упомянуть еще
один метод вычисления чисел Фибоначчи. Первое рекурсивное определение функции
Фибоначчи использует подход сверху вниз. Для получения значения Fib(N), алгоритм рекурсивно вычисляет Fib(N - 1) и Fib(N - 2) и затем складывает их.
Подпрограмма InitializeFibValues,
с другой стороны, работает снизу вверх. Она начинает со значений Fib(0) и Fib(1). Она затем использует меньшие значения
для вычисления больших, до тех пор, пока таблица не заполнится.
Вы можете
использовать тот же подход снизу вверх для прямого вычисления значений функции
Фибоначчи каждый раз, когда вам потребуется значение. Этот метод требует больше
времени, чем выборка значений из массива, но не требует дополнительной памяти
для таблицы значений. Это пример пространственно‑временного компромисса.
Использование большего объема памяти для хранения таблицы значений делает
выполнение алгоритма более быстрым.
Private Function Fib(N As Integer) As Double
Dim Fib_i_minus_1 As Double
Dim Fib_i_minus_2 As Double
Dim fib_i As Double
Dim i As Integer
If N
<= 1 Then
Fib
= N
Else
Fib_i_minus_2
= 0 ' Вначале Fib(0)
Fib_i_minus_1
= 1 ' Вначале Fib(1)
For
i = 2 To N
fib_i
= Fib_i_minus_1 + Fib_i_minus_2
Fib_i_minus_2
= Fib_i_minus_1
Fib_i_minus_1
= fib_i
Next
i
Fib
= fib_i
End If
End Function
Этой версии
требуется порядка O(N) шагов для вычисления Fib(N). Это больше, чем один шаг, который
требовался в предыдущей версии, но намного быстрее, чем O(Fib(N)) шагов в исходной версии алгоритма. На компьютере с
процессором Pentium с тактовой частотой 90 МГц, исходному рекурсивному
алгоритму потребовалось почти 52 секунды для вычисления Fib(32) = 2.178.309. Время вычисления Fib(1476) » 1,31E+308 при помощи нового алгоритма
пренебрежимо мало. Программа Fibo4 использует этот метод для вычисления
чисел Фибоначчи.
=====105
Устранение рекурсии в общем случае
Функции факториала,
наибольшего общего делителя, и BigAdd можно
упростить устранением хвостовой рекурсии. Функцию, вычисляющую числа Фибоначчи,
можно упростить, используя таблицу значений или переформулировав задачу с
использованием подхода снизу вверх.
Некоторые
рекурсивные алгоритмы настолько сложны, то применение этих методов затруднено
или невозможно. Достаточно сложно было бы написать нерекурсивный алгоритм для
построения кривых Гильберта или Серпинского с нуля. Другие рекурсивные
алгоритмы более просты.
Ранее было
показано, что алгоритм, который рисует кривые Гильберта или Серпинского, должен
включать порядка O(N4) шагов, так что исходные рекурсивные версии
достаточно хороши. Они достигают почти максимальной возможной
производительности при приемлемой глубине рекурсии.
Тем не менее,
встречаются другие сложные алгоритмы, которые имеют высокую глубину вложенности
рекурсии, но к которым неприменимо устранение хвостовой рекурсии. В этом
случае, все еще возможно преобразование рекурсивного алгоритма в нерекурсивный.
Основной подход при
этом заключается в том, чтобы рассмотреть порядок выполнения рекурсии на
компьютере и затем попытаться сымитировать шаги, выполняемые компьютером. Затем
новый алгоритм будет сам осуществлять «рекурсию» вместо того, чтобы всю работу
выполнял компьютер.
Поскольку новый
алгоритм выполняет практически те же шаги, что и компьютер, можно
поинтересоваться, возрастет ли скорость вычислений. В Visual Basic
это обычно не выполняется. Компьютер может выполнять задачи, которые требуются
при рекурсии, быстрее, чем вы можете их имитировать. Тем не менее, оперирование
этими деталями самостоятельно обеспечивает лучший контроль над выделением
памяти под локальные переменные, и позволяет избежать глубокого уровня
вложенности рекурсии.
Обычно, при вызове
подпрограммы, система выполняет три вещи. Во‑первых, сохраняет данные,
которые нужны ей для продолжения выполнения после завершения подпрограммы. Во‑вторых,
она проводит подготовку к вызову подпрограммы и передает ей управление. В‑третьих,
когда вызываемая процедура завершается, система восстанавливает данные,
сохраненные на первом шаге, и передает управление назад в соответствующую точку
программы. Если вы преобразуете рекурсивную процедуру в нерекурсивную, вам
приходится выполнять эти три шага самостоятельно.
Рассмотрим
следующую обобщенную рекурсивную процедуру:
Sub Subr(num)
<1
блок кода>
Subr(<параметры>)
<2 блок кода>
End Sub
Поскольку после
рекурсивного шага есть еще операторы, вы не можете использовать устранение
хвостовой рекурсии для этого алгоритма.
=====105
Вначале пометим
первые строки в 1 и 2 блоках кода. Затем эти метки будут использоваться для
определения места, с которого требуется продолжить выполнение при возврате из
«рекурсии». Эти метки используются только для того, чтобы помочь вам понять,
что делает алгоритм — они не являются частью кода Visual Basic. В
этом примере метки будут выглядеть так:
Sub Subr(num)
1 <1 блок кода>
Subr(<параметры>)
2 <2
блок кода>
End
Sub
Используем
специальную метку «0» для обозначения конца «рекурсии». Теперь можно переписать
процедуру без использования рекурсии, например, так:
Sub Subr(num)
Dim pc As Integer ' Определяет, где нужно продолжить
рекурсию.
pc = 1 '
Начать сначала.
Do
Select
Case pc
Case
1
<1 блок кода>
If (достигнуто условие остановки)
Then
' Пропустить рекурсию и
перейти к блоку 2.
pc = 2
Else
' Сохранить переменные,
нужные после рекурсии.
' Сохранить pc = 2. Точка, с
которой продолжится
' выполнение после возврата
из "рекурсии".
'
Установить переменные, нужные для рекурсии.
' Например, num = num - 1.
:
' Перейти к блоку 1 для
начала рекурсии.
pc = 1
End If
Case 2 ' Выполнить 2 блок кода
<2 блок кода>
pc = 0
Case
0
If
(это последняя рекурсия) Then Exit Do
'
Иначе восстановить pc и другие переменные,
' сохраненные перед рекурсией.
End Select
Loop
End Sub
======106
Переменная pc,
которая соответствует счетчику программы, сообщает процедуре, какой шаг она
должна выполнить следующим. Например, при pc = 1, процедура должна выполнить 1 блок кода.
Когда процедура
достигает условия остановки, она не выполняет рекурсию. Вместо этого, она
присваивает pc значение 2, и
продолжает выполнение 2 блока кода.
Если процедура не
достигла условия остановки, она выполняет «рекурсию». Для этого она сохраняет
значения всех локальных переменных, которые ей понадобятся позже после
завершения «рекурсии». Она также сохраняет значение pc
для участка кода, который она будет выполнять после завершения «рекурсии». В
этом примере следующим выполняется 2 блок кода, поэтому она сохраняет 2 в
качестве следующего значения pc. Самый простой
способ сохранения значений локальных переменных и pc
состоит в использовании стеков, подобных тем, которые описывались в 3 главе.
Реальный пример
поможет вам понять эту схему. Рассмотрим слегка измененную версию функции
факториала. В нем переписана только подпрограмма, которая возвращает свое
значение при помощи переменной, а не функции, для упрощения работы.
Private Sub Factorial(num As
Integer, value As Integer)
Dim partial As
Integer
1 If num <= 1 Then
value = 1
Else
Factorial(num -
1, partial)
2 value = num * partial
End If
End Sub
После возврата
процедуры из рекурсии, требуется узнать исходное значение переменной num,
чтобы выполнить операцию умножения value = num * partial. Поскольку
процедуре требуется доступ к значению num после возврата
из рекурсии, она должна сохранять значение переменных pc
и num до начала рекурсии.
Следующая процедура
сохраняет эти значения в двух стеках на основе массивов. При подготовке к
рекурсии, она проталкивает значения переменных num
и pc в стеки. После завершения рекурсии, она
выталкивает добавленные последними значения из стеков. Следующий код
демонстрирует нерекурсивную версию подпрограммы вычисления факториала.
Private Sub Factorial(num As Integer, value As
Integer)
ReDim num_stack(1 to 200) As Integer
ReDim pc_stack(1 to 200) As Integer
Dim stack_top As Integer ' Вершина стека.
Dim pc As Integer
pc = 1
Do
Select
Case pc
Case
1
If num <= 1 Then ' Это условие остановки. value = 1
pc = 0 ' Конец рекурсии.
Else ' Рекурсия.
' Сохранить num и следующее значение pc.
stack_top = stack_top + 1
num_stack(stack_top)
= num
pc_stack(stack_top)
= 2 ' Возобновить с 2.
' Начать рекурсию.
num = num - 1
' Перенести блок управления в начало.
pc = 1
End
If
Case
2
'
value содержит результат последней
' рекурсии. Умножить его на num.
value = value * num
' "Возврат" из
"рекурсии".
pc = 0
Case 0
' Конец "рекурсии".
' Если стеки пусты, исходный
вызов
' подпрограммы завершен.
If stack_top
<= 0 Then Exit Do
'
Иначе восстановить локальные переменные и pc.
num =
num_stack(stack_top)
pc
= pc_stack(stack_top)
stack_top
= stacK_top - 1
End
Select
Loop
End Sub
Так же, как и
устранение хвостовой рекурсии, этот метод имитирует поведение рекурсивного
алгоритма. Процедура заменяет каждый рекурсивный вызов итерацией цикла While.
Поскольку число шагов остается тем же самым, полное время выполнения алгоритма
не изменяется.
Так же, как и в
случае с устранением хвостовой рекурсии, этот метод устраняет глубокую
рекурсию, которая может переполнить стек.
Нерекурсивное
построение кривых Гильберта
Пример вычисления
факториала из предыдущего раздела превратил простую, но неэффективную
рекурсивную функцию вычисления факториала в сложную и неэффективную
нерекурсивную процедуру. Намного лучший нерекурсивный алгоритм вычисления
факториала, был представлен ранее в этой главе.
=======107-108
Может оказаться
достаточно трудно найти простую нерекурсивную версию для более сложных
алгоритмов. Методы из предыдущего раздела могут быть полезны, если алгоритм
содержит многократную или косвенную рекурсию.
В качестве более
интересного примера, рассмотрим нерекурсивный алгоритм построения кривых
Гильберта.
Private Sub Hilbert(depth As Integer, Dx As
Single, Dy As Single)
If
depth > 1 Then Hilbert depth - 1, Dy, Dx
HilbertPicture.Line
-Step(Dx, Dy)
If
depth > 1 Then Hilbert depth - 1, Dx, Dy
HilbertPicture.Line
-Step(Dy, Dx)
If
depth > 1 Then Hilbert depth - 1, Dx, Dy
HilbertPicture.Line
-Step(-Dx, -Dy)
If
depth > 1 Then Hilbert depth - 1, -Dy, -Dx
End Sub
В следующем
фрагменте кода первые строки каждого блока кода между рекурсивными шагами
пронумерованы. Эти блоки включают первую строку процедуры и любые другие точки,
в которых может понадобиться продолжить выполнение после возврата после
«рекурсии».
Private Sub Hilbert(depth As
Integer, Dx As Single, Dy As Single)
1 If
depth > 1 Then Hilbert depth - 1, Dy, Dx
2 HilbertPicture.Line
-Step(Dx, Dy)
If depth > 1 Then Hilbert depth - 1, Dx, Dy
3 HilbertPicture.Line
-Step(Dy, Dx)
If depth > 1 Then Hilbert depth - 1, Dx, Dy
4 HilbertPicture.Line
-Step(-Dx, -Dy)
If depth > 1 Then Hilbert depth - 1, -Dy,
-Dx
End Sub
Каждый раз, когда
нерекурсивная процедура начинает «рекурсию», она должна сохранять значения
локальных переменных Depth, Dx,
и Dy, а также следующее значение переменной pc.
После возврата из «рекурсии», она восстанавливает эти значения. Для упрощения
работы, можно написать пару вспомогательных процедур для заталкивания и
выталкивания этих значений из нескольких стеков.
====109
Const STACK_SIZE =20
Dim DepthStack(0 To STACK_SIZE)
Dim DxStack(0 To STACK_SIZE)
Dim DyStack(0 To STACK_SIZE)
Dim PCStack(0 To STACK_SIZE)
Dim TopOfStack As Integer
Private Sub SaveValues (Depth As Integer, Dx As
Single, _
Dy
As Single, pc As Integer)
TopOfStack
= TopOfStack + 1
DepthStack(TopOfStack)
= Depth
DxStack(TopOfStack)
= Dx
DyStack(TopOfStack)
= Dy
PCStack(TopOfStack)
= pc
End Sub
Private Sub RestoreValues (Depth As Integer, Dx
As Single, _
Dy
As Single, pc As Integer)
Depth
= DepthStack(TopOfStack)
Dx =
DxStack(TopOfStack)
Dy =
DyStack(TopOfStack)
pc =
PCStack(TopOfStack)
TopOfStack
= TopOfStack - 1
End Sub
Следующий код
демонстрирует нерекурсивную версию подпрограммы Hilbert.
Private Sub Hilbert(Depth As Integer, Dx As Single, Dy As Single)
Dim pc As Integer
Dim tmp As Single
pc = 1
Do
Select
Case pc
Case
1
If
Depth > 1 Then ' Рекурсия.
' Сохранить текущие значения.
SaveValues Depth, Dx, Dy, 2
'
Подготовиться к рекурсии.
Depth = Depth - 1
tmp
= Dx
Dx
= Dy
Dy
= tmp
pc
= 1 ' Перейти в начало рекурсивного
вызова.
Else ' Условие остановки.
' Достаточно глубокий уровень
рекурсии.
' Продолжить со 2 блоком
кода.
pc = 2
End
If
Case
2
HilbertPicture.Line
-Step(Dx, Dy)
If
Depth > 1 Then ' Рекурсия.
'
Сохранить текущие значения.
SaveValues Depth, Dx, Dy, 3
'
Подготовиться к рекурсии.
Depth = Depth - 1
' Dx и Dy остаются без
изменений.
pc = 1 Перейти в начало рекурсивного вызова.
Else ' Условие остановки.
' Достаточно глубокий уровень
рекурсии.
' Продолжить с 3 блоком кода.
pc = 3
End
If
Case
3
HilbertPicture.Line
-Step(Dy, Dx)
If
Depth > 1 Then ' Рекурсия.
'
Сохранить текущие значения.
SaveValues Depth, Dx, Dy, 4
'
Подготовиться к рекурсии.
Depth = Depth - 1
' Dx и Dy остаются без
изменений.
pc = 1 Перейти в начало рекурсивного вызова.
Else ' Условие остановки.
' Достаточно глубокий уровень
рекурсии.
' Продолжить с 4 блоком кода.
pc = 4
End
If
Case
4
HilbertPicture.Line
-Step(-Dx, -Dy)
If
Depth > 1 Then ' Рекурсия.
'
Сохранить текущие значения.
SaveValues Depth, Dx, Dy, 0
'
Подготовиться к рекурсии.
Depth = Depth - 1
tmp
= Dx
Dx
= -Dy
Dy
= -tmp
pc
= 1 Перейти в начало рекурсивного
вызова.
Else ' Условие остановки.
' Достаточно глубокий уровень
рекурсии.
' Конец этого рекурсивного
вызова.
pc = 0
End If
Case 0 ' Возврат из рекурсии.
If TopOfStack
> 0 Then
RestoreValues
Depth, Dx, Dy, pc
Else
' Стек пуст. Выход.
Exit
Do
End
If
End
Select
Loop
End Sub
======111
Время выполнения
этого алгоритма может быть нелегко оценить непосредственно. Поскольку методы
преобразования рекурсивных процедур в нерекурсивные не изменяют время
выполнения алгоритма, эта процедура так же, как и предыдущая версия, имеет
время выполнения порядка O(N4).
Программа Hilbert2 демонстрирует нерекурсивный алгоритм
построения кривых Гильберта. Задавайте вначале построение несложных кривых
(меньше 6 порядка), пока не узнаете, насколько быстро будет выполняться эта
программа на вашем компьютере.
Нерекурсивное
построение кривых Серпинского
Приведенный ранее
алгоритм построения кривых Серпинского включает в себя косвенную и
множественную рекурсию. Так как алгоритм состоит из четырех подпрограмм, которые
вызывают друг друга, то нельзя просто пронумеровать важные строки, как это
можно было сделать в случае алгоритма построения кривых Гильберта. С этой
проблемой можно справиться, слегка изменив алгоритм.
Рекурсивная версия
этого алгоритма состоит из четырех подпрограмм SierpA,
SierpB, SierpC и SierpD.
Подпрограмма SierpA выглядит
так:
Private Sub SierpA(Depth As Integer, Dist As Single)
If Depth = 1
Then
Line
-Step(-Dist, Dist)
Line
-Step(-Dist, 0)
Line
-Step(-Dist, -Dist)
Else
SierpA Depth -
1, Dist
Line
-Step(-Dist, Dist)
SierpB Depth -
1, Dist
Line
-Step(-Dist, 0)
SierpD Depth -
1, Dist
Line
-Step(-Dist, -Dist)
SierpA Depth -
1, Dist
End If
End Sub
Три другие
процедуры аналогичны. Несложно объединить эти четыре процедуры в одну подпрограмму.
Private Sub SierpAll(Depth As Integer, Dist As
Single, Func As Integer)
Select
Case Punc
Case
1 ' SierpA
<код SierpA code>
Case
2 ' SierpB
<код SierpB>
Case
3 ' SierpC
<код SierpC>
Case
4 ' SierpD
<код SierpD>
End Select
End Sub
======112
Параметр Func
сообщает подпрограмме, какой блок кода выполнять. Вызовы подпрограмм заменяются
на вызовы процедуры SierpAll с
соответствующим значением Func. Например,
вызов подпрограммы SierpA заменяется
на вызов процедуры SierpAll с
параметром Func, равным 1.
Таким же образом заменяются вызовы подпрограмм SierpB,
SierpC и SierpD.
Полученная
процедура рекурсивно вызывает себя в 16 различных точках. Эта процедура намного
сложнее, чем процедура Hilbert, но в
других отношениях она имеет такую же структуру и поэтому к ней можно применить
те же методы устранения рекурсии.
Можно использовать
первую цифру меток pc, для
определения номера блока кода, который должен выполняться. Перенумеруем строки
в коде SierpA числами 11,
12, 13 и т.д. Перенумеруем строки в коде SierpB числами 21,
22, 23 и т.д.
Теперь можно
пронумеровать ключевые строки кода внутри каждого из блоков. Для кода
подпрограммы SierpA ключевыми
строками будут:
' Код SierpA.
11 If
Depth = 1 Then
Line
-Step(-Dist, Dist)
Line -Step(-Dist, 0)
Line
-Step(-Dist, -Dist)
Else
SierpA
Depth - 1, Dist
12 Line
-Step(-Dist, Dist)
SierpB
Depth - 1, Dist
13 Line
-Step(-Dist, 0)
SierpD
Depth - 1, Dist
14 Line
-Step(-Dist, -Dist)
SierpA
Depth - 1, Dist
End
If
Типичная «рекурсия»
из кода подпрограммы SierpA в код
подпрограммы SierpB выглядит
так:
SaveValues Depth, 13 ' Продолжить с шага 13 после завершения.
Depth = Depth - 1
pc = 21 ' Передать управление на начало
кода SierpB.
======113
Метка 0 зарезервирована
для обозначения выхода из «рекурсии». Следующий код демонстрирует нерекурсивную
версию процедуры SierpAll. Код для
подпрограмм SierpB, SierpC,
и SierpD аналогичен коду для SierpA,
поэтому он опущен.
Private Sub SierpAll(Depth As Integer, pc As
Integer)
Do
Select
Case pc
' **********
' * SierpA *
' **********
Case
11
If
Depth <= 1 Then
SierpPicture.Line
-Step(-Dist, Dist)
SierpPicture.Line
-Step(-Dist, 0)
SierpPicture.Line
-Step(-Dist, -Dist)
pc
= 0
Else
SaveValues
Depth, 12 ' Выполнить SierpA
Depth
= Depth - 1
pc
= 11
End
If
Case
12
SierpPicture.Line
-Step(-Dist, Dist)
SaveValues
Depth, 13 ' Выполнить SierpB
Depth
= Depth - 1
pc
= 21
Case
13
SierpPicture.Line
-Step(-Dist, 0)
SaveValues
Depth, 14 ' Выполнить SierpD
Depth
= Depth - 1
pc
= 41
Case
14
SierpPicture.Line
-Step(-Dist, -Dist)
SaveValues
Depth, 0 ' Выполнить SierpA
Depth
= Depth - 1
pc
= 11
' Код для SierpB, SierpC и SierpD опущен.
:
' *******************
' * Конец рекурсии. *
' *******************
Case
0
If
TopOfStack <= 0 Then Exit Do
RestoreValues
Depth, pc
End
Select
Loop
End Sub
=====114
Так же, как и в
случае с алгоритмом построения кривых Гильберта, преобразование алгоритма
построения кривых Серпинского в нерекурсивную форму не изменяет время
выполнения алгоритма. Новая версия алгоритма имитирует рекурсивный алгоритм,
который выполняется за время порядка O(N4), поэтому порядок времени
выполнения новой версии также составляет O(N4). Она выполняется
немного медленнее, чем рекурсивная версия, и является намного более сложной.
Нерекурсивная
версия также могла бы рисовать кривые более высоких порядков, но построение
кривых Серпинского с порядком выше 8 или 9 непрактично. Все эти факты
определяют преимущество рекурсивного алгоритма.
Программа Sierp2 использует этот нерекурсивный алгоритм
для построения кривых Серпинского. Задавайте вначале построение несложных
кривых (меньше 6 порядка), пока не определите, насколько быстро будет
выполняться эта программа на вашем компьютере.
Резюме
При применении
рекурсивных алгоритмов следует избегать трех основных опасностей:
·
Бесконечной
рекурсии. Убедитесь, что условия остановки вашего алгоритма прекращают все
рекурсивные пути.
·
Глубокой
рекурсии. Если алгоритм достигает слишком большой глубины рекурсии, он
может привести к переполнению стека. Минимизируйте использование стека за счет
уменьшения числа определяемых в процедуре переменных, использования глобальных
переменных, или определения переменных как статических. Если процедура все
равно приводит к переполнению стека, перепишите алгоритм в нерекурсивном виде,
используя устранение хвостовой рекурсии.
·
Ненужной
рекурсии. Обычно это происходит, если алгоритм типа рекурсивного вычисления
чисел Фибоначчи, многократно вычисляет одни и те же промежуточные значения.
Если вы столкнетесь с этой проблемой в своей программе, попробуйте переписать
алгоритм, используя подход снизу вверх. Если алгоритм не позволяет прибегнуть к
подходу снизу вверх, создайте таблицу промежуточных значений.
Применение рекурсии
не всегда неправильно. Многие задачи являются рекурсивными по своей природе. В
этих случаях рекурсивный алгоритм будет проще понять, отлаживать и
поддерживать, чем его нерекурсивную версию. В качестве примера можно привести
алгоритмы построения кривых Гильберта и Серпинского. Оба по своей природе
рекурсивны и намного понятнее, чем их нерекурсивные модификации. При этом
рекурсивные версии даже выполняются немного быстрее.
Если у вас есть
алгоритм, который рекурсивен по своей природе, но вы не уверены, будет ли
рекурсивная версия лишена проблем, запишите алгоритм в рекурсивном виде и
выясните это. Может быть, проблемы не возникнут. Если же они возникнут, то,
возможно, окажется проще преобразовать эту рекурсивную версию в нерекурсивную,
чем написать нерекурсивную версию с нуля.
======115
Глава 6. Деревья
Во 2 главе
приводились способы создания динамических связных структур, таких, как
изображенные на рис 6.1. Такие структуры данных называются XE
"Граф" графами
(graphs XE "graph"
). В 12 главе алгоритмы работы с графами и
сетями обсуждаются более подробно. В этой главе рассматриваются графы особого
типа, которые называются XE
"Деревья" деревьями
(trees XE "tree"
).
В начале этой главы
приводится определение дерева и разъясняются некоторые термины. Затем в ней
описываются некоторые методы реализации деревьев различных типов на языке Visual Basic. В
последующих разделах рассматривается несколько алгоритмов обхода для деревьев,
записанных в этих разных форматах. Глава заканчивается обсуждением некоторых
специальных типов деревьев, включая упорядоченные деревья (sorted trees), деревья со ссылками[RV7] (threaded trees XE "tree:threaded"
), боры[RV8] (tries XE "tries"
) и квадродеревья[RV9] (quadtrees XE "quadtree"
).
В 7 и 8 главе
обсуждаются более сложные темы — сбалансированные деревья и деревья
решений.
@Рис. 6.1. Графы
=====117
Определения
Можно рекурсивно
определить дерево как:
*
*
Узел,
называемый XE "Деревья:корень" корнем
(node XE "node"
) дерева, связанный с нулем или более XE
"Деревья:поддерево" поддеревьев (subtrees XE "subtree"
).
На рис. 6.2
показано дерево. Корневой узел A связан с тремя поддеревьями, начинающимися в узлах B, C и D. Эти узлы связаны с
поддеревьями с корнями E, F и G, и эти узлы, в свою очередь связаны с
поддеревьями с корнями H, I и J.
Терминология
деревьев представляет собой смесь терминов, позаимствованных из ботаники и
генеалогии. Из ботаники пришли термины, такие как XE
"Деревья:узел" узел
(node), определяемый как точка, в которой
может начинаться ветвление, XE
"Деревья:ветвь" ветвь
(branch XE "branch"
), определяемая как связь между двумя
узлами, и XE "Деревья:лист" лист
(leaf) — узел, из которого не
выходят другие ветви.
Из генеалогии
пришли термины, которые описывают родство. Если один узел находится
непосредственно над другим, верхний узел называется XE
"Деревья:родитель" родителем
(parent XE "parent"
), а нижний XE
"Деревья:дочерний узел" дочерним узлом (child XE "child"
). Узлы на пути вверх от узла до корня
называются XE "Деревья:предок" предками
(ancestors XE "ancestor"
) узла. Например, на рис. 6.2 узлы E, B и
A — это все предки узла I.
Узлы, которые
находятся ниже какого‑либо узла дерева, называются XE
"Деревья:потомок" потомками
(descendants XE "descendant"
) этого узла. Узлы E, H, I и J на рис.
6.2 — это все потомки узла B.
Иногда узлы,
имеющие одного родителя, называются узлами‑братьями
или узлами‑сестрами (sibling nodes XE "node:sibling" ).
Существует еще
несколько терминов, которые не пришли из ботаники или генеалогии. XE
"Деревья:внутренний узел" Внутренним узлом (internal node XE "node:internal" ) называется узел, который не является
листом. XE "Деревья:порядок" Порядком
узла (node degree XE "node:degree" ) называется число его дочерних узлов.
Порядок дерева — это наибольший порядок его узлов. Дерево на рис.
6.2 — третьего порядка, потому что узлы с наибольшим порядком, узлы A и E,
имеют по 3 дочерних узла.
Глубина (depth XE "tree:depth"
) дерева равна числу его предков плюс 1. На
рис. 6.2 глубина узла E равна 3. Глубиной
(depth) или высотой (height) дерева называется
наибольшая глубина его узлов. Глубина дерева на рис. 6.2 равна 4.
Дерево 2 порядка
называется XE "Деревья:двоичные" двоичным
деревом (binary tree
XE "tree:binary" ). Деревья третьего порядка иногда называются XE
"Деревья:троичные" троичными[RV10]
(ternary XE "tree:ternary"
) деревьями. Более того, деревья порядка N
иногда называются N‑ичными (N‑ary) деревьями.
@Рис. 6.2. Дерево
======118
Дерево порядка 12,
например, называется 12‑ричным (12‑ary) деревом, а не
додекадеричным (dodecadary) деревом.
Некоторые избегают употребления лишних терминов и просто говорят «деревья 12
порядка».
Рис. 6.3
иллюстрирует некоторые из этих терминов.
Представления
деревьев
Теперь, когда вы
познакомились с терминологией, вы можете представить себе способы реализации
деревьев на языке Visual Basic. Один из способов — создать отдельный класс для
каждого типа узлов дерева. Для построения дерева, показанного на рис. 6.3, вы
можете определить структуры данных для узлов, которые имеют ноль, один, два или
три дочерних узла. Этот подход был бы довольно неудобным. Кроме того, что нужно
было бы управлять четырьмя различными классами, в классах потребовались бы
какие‑то флаги, которые бы указывали тип дочерних узлов. Алгоритмы,
которые оперировали бы этими деревьями, должны были бы уметь работать со всем
различными типами деревьев.
Полные узлы
В качестве простого
решения можно определить один тип узлов, который содержит достаточное число
указателей на потомков для представления всех нужных узлов. Я называю это
методом полных узлов, так как некоторые узлы могут быть большего размера, чем
необходимо на самом деле.
Дерево,
изображенное на рис 6.3, имеет 3 порядок. Для построения этого дерева с
использованием метода полных узлов (fat nodes XE "fat node" ), требуется определить единственный класс, который содержит
указатели на три дочерних узла. Следующий код демонстрирует, как эти указатели
могут быть определены в классе TernaryNode.
Public LeftChild As TernaryNode
Public MiddleChild As TernaryNode
Public RightChild As TernaryNode
@Рис. 6.3. Части
троичного (3 порядка) дерева
======119
При помощи этого
класса можно построить дерево, используя записи Child
узлов, для связи их друг с другом. Следующий фрагмент кода строит два верхних
уровня дерева, показанного на рис. 6.3.
Dim A As New TernaryNode
Dim B As New TernaryNode
Dim C As New TernaryNode
Dim D As New TernaryNode
:
Set
A.LeftChild = B
Set
A.MiddleChild = C
Set A.RightChild = D
[RV11] :
Программа Binary,
показанная на рис. 6.4, использует метод полных узлов для работы с двоичным
деревом. Когда вы выбираете узел с помощью мыши, программа подсвечивает кнопку Add Left (Добавить слева),
если узел не имеет левого потомка и кнопку Add Right (Добавить справа),
если узел не имеет правого потомка. Кнопка Remove (Удалить) разблокируется,
если выбранный узел не является корневым. Если вы нажмете на кнопку Remove, программа удалит
узел и всех его потомков.
Поскольку программа
позволяет создать узлы с нулевым числом, одним или двумя дочерними узлами, она
использует представление в виде полных узлов. Вы можете легко распространить
этот пример на деревья более высоких порядков.
Списки потомков
Если порядки узлов
в дереве сильно различаются, метод полных узлов приводит к напрасному
расходованию большого количества памяти. Чтобы построить дерево, показанное на
рис. 6.5 с использованием полных узлов, вам понадобится определить в каждом
узле по шесть указателей, хотя только в одном узле все шесть из них
используются. Это представление дерева потребует 72 указателей на дочерние
узлы, из которых в действительности будет использоваться только 11.
@Рис. 6.4.
Программа Binary
======120
Некоторые программы
добавляют и удаляют узлы, изменяя порядок узлов в процессе выполнения. В этом
случае метод полных узлов не будет работать. Такие динамически изменяющиеся
деревья можно представить, поместив дочерние узлы в списки. Есть несколько
подходов, которые можно использовать для создания списков дочерних узлов.
Наиболее очевидный подход заключается в создании в классе узла открытого (public) массива дочерних узлов, как показано в следующем коде.
Тогда для оперирования дочерними узлами можно использовать методы работы со
списками на основе массивов.
Public Children() As TreeNode
Public NumChildren As Integer
К сожалению, Visual Basic не
позволяет определять открытые массивы в классах. Это ограничение можно обойти,
определив массив как закрытый (private), и оперируя
элементами массива при помощи процедур свойств.
Private m_Chirdren() As TreeNode
Private m_NumChildren As Integer
Property Get Children(Index As Integer) As
TreeNode
Set
Children = m_Children(Index)
End Property
Property Get NumChildren() As Integer
NumChildren
= m_NumChildren()
End Property
Второй подход
состоит в том, чтобы сохранять ссылки на дочерние узлы в связных списках.
Каждый узел содержит ссылку на первого потомка. Он также содержит ссылку на
следующего потомка на том же уровне дерева. Эти связи образуют связный список
узлов одного уровня, поэтому я называю этот метод представлением в виде связного списка узлов одного уровня (linked sibling).
За информацией о связных списках вы можете обратиться ко 2 главе.
@Рис. 6.5. Дерево с
узлами различных порядков
======121
Третий подход
заключается в том, чтобы определить в классе узла открытую коллекцию, которая
будет содержать дочерние узлы:
Public
Children As New Collection
Это решение
позволяет использовать все преимущества коллекций. Программа может при этом легко
добавлять и удалять элементы из коллекции, присваивать дочерним узлам ключи, и
использовать оператор For Each для
выполнения циклов со ссылками на дочерние узлы.
Программа NAry,
показанная на рис. 6.6, использует коллекцию дочерних узлов для работы с деревьями
порядка N в основном таким же образом, как программа Binary
работает с двоичными деревьями. В этой программе, тем не менее, можно добавлять
к каждому узлу любое количество потомков.
Для того чтобы
избежать чрезмерного усложнения пользовательского интерфейса, программа NAry
всегда добавляет новые узлы в конец коллекции дочерних узлов родителя. Вы
можете модифицировать эту программу, реализовав вставку дочерних узлов в
середину коллекции, но пользовательский интерфейс при этом усложнится.
Представление
нумерацией связей
Представление
нумерацией связей
XE "Деревья:представление нумерацией связей" (forward star XE "forward star" ), впервые упомянутое в 4 главе, позволяет компактно представить
деревья, графы и сети при помощи массива. Для представления дерева нумерацией
связей, в массиве FirstLink
записывается индекс для первых ветвей, выходящих из каждого узла. В другой
массив, ToNode, заносятся
узлы, к которым ведет ветвь.
Сигнальная метка в
конце массива FirstLink
указывает на точку сразу после последнего элемента массива ToNode.
Это позволяет легко определить, какие ветви выходят из каждого узла. Ветви,
выходящие из узла I, находятся под
номерами от FirstLink(I) до FirstLink(I+1)-1. Для вывода связей, выходящих из узла
I, можно использовать следующий код:
For link = FirstLink(I) To FirstLink(I + 1) - 1
Print
Format$(I) & " -> " & Format$(ToNode(link))
Next link
@Рис. 6.6. Программа
Nary
=======123
На рис. 6.7
показано дерево и его представление нумерацией связей. Связи, выходящие из 3
узла (обозначенного буквой D) это связи от FirstLink(3) до FirstLink(4)-1. Значение FirstLink(3) равно 9, а FirstLink(4) = 11, поэтому это связи с номерами 9 и 10.
Записи ToNode для этих
связей равны ToNode(9) = 10 и ToNode(10) = 11, поэтому узлы 10 и 11 будут дочерними
для 3 узла. Это узлы, обозначенные буквами K и L. Это означает, что связи,
покидающие узел D, ведут к узлам K и L.
Представление
дерева нумерацией связей компактно и основано на массиве, поэтому деревья,
представленные таким образом, можно легко считывать из файлов и записывать в
файл. Операции для работы с массивами, которые используются при таком
представлении, также могут быть быстрее, чем операции, нужные для использования
узлов, содержащих коллекции дочерних узлов.
По этим причинам
большая часть литературы по сетевым алгоритмам использует представление
нумерацией связей. Например, многие статьи, касающиеся вычисления кратчайшего
пути, предполагают, что данные находятся в подобном формате. Если вам когда‑либо
придется изучать эти алгоритмы в журналах, таких как “Management Science”
или “Operations Research”, вам необходимо разобраться в этом представлении.
@Рис. 6.7. Дерево и
его представление нумерацией связей
=======123
Используя
представление нумерацией связей, можно быстро найти связи, выходящие из
определенного узла. С другой стороны, очень сложно изменять структуру данных,
представленных в таком виде. Чтобы добавить к узлу A на рис. 6.7 еще одного
потомка, придется изменить почти все элементы в обоих массивах FirstLink
и ToNode. Во‑первых, каждый элемент в
массиве ToNode нужно
сдвинуть на одну позицию вправо, чтобы освободить место под новый элемент.
Затем, нужно вставить новую запись в массив ToNode,
которая указывает на новый узел. И, наконец, нужно обойти массив ToNode,
обновив каждый элемент, чтобы он указывал на новое положение соответствующей
записи ToNode. Поскольку
все записи в массиве ToNode сдвинулись
на одну позицию вправо, чтобы освободить место для новой связи, потребуется
добавить единицу ко всем затронутым записям FirstLink.
На рис. 6.8
показано дерево после добавления нового узла. Записи, которые изменились,
закрашены серым цветом.
Удаление узла из
начала представления нумерацией связей так же сложно, как и вставка узла. Если
удаляемый узел имеет потомков, процесс занимает еще больше времени, поскольку
придется удалять и все дочерние узлы.
Относительно
простой класс с открытой коллекцией дочерних узлов лучше подходит, если нужно
часто модифицировать дерево. Обычно проще понимать и отлаживать процедуры,
которые оперируют деревьями в этом представлении. С другой стороны,
представление нумерацией связей иногда обеспечивает более высокую
производительность для сложных алгоритмов работы с деревьями. Оно также
являются стандартной структурой данных, обсуждаемой в литературе, поэтому вам
следует ознакомиться с ним, если вы хотите продолжить изучение алгоритмов
работы с сетями и деревьями.
@Рис. 6.8. Вставка
узла в дерево, представленное нумерацией связей
=======124
Программа Fstar
использует представление нумерацией связей для работы с деревом, имеющим узлы
разного порядка. Она аналогична программе NAry,
за исключением того, что она использует представление на основе массива, а не
коллекций.
Если вы посмотрите
на код программы Fstar, вы увидите,
насколько сложно в ней добавлять и удалять узлы. Следующий код демонстрирует
удаление узла из дерева.
Sub FreeNodeAndChildren(ByVal parent As
Integer, _
ByVal
link As Integer, ByVal node As Integer)
'
Recursively remove the node's children.
Do
While FirstLink(node) < FirstLink(node + 1)
FreeNodeAndChildren
node, FirstLink(node), _
ToNode(FirstLink(node))
Loop
' Удалить связь.
RemoveLink parent, link
' Удалить сам узел.
RemoveNode
node
End Sub
Sub RemoveLink(node As Integer, link As
Integer)
Dim i As Integer
'
Обновить записи массива FirstLink.
For i = node + 1 To NumNodes
FirstLink(i)
= FirstLink(i) - 1
Next
i
' Сдвинуть массив ToNode чтобы заполнить
пустую ячейку.
For i = link + 1 To NumLinks - 1
ToNode(i
- 1) = ToNode(i)
Next
i
' Удалить лишний элемент из ToNode.
NumLinks = NumLinks - 1
If
NumLinks > 0 Then ReDim Preserve ToNode(0 To NumLinks - 1)
End Sub
Sub RemoveNode(node As Integer)
Dim i As Integer
'
Сдвинуть элементы массива FirstLink, чтобы заполнить
' пустую ячейку.
For i = node + 1 To NumNodes
FirstLink(i
- 1) = FirstLink(i)
Next
i
' Сдвинуть элементы массива NodeCaption.
For i = node + 1 To NumNodes - 1
NodeCaption(i
- 1) = NodeCaption(i)
Next
i
' Обновить записи массива ToNode.
For i = 0 To NumLinks - 1
If
ToNode(i) >= node Then ToNode(i) = ToNode(i) - 1
Next
i
' Удалить лишнюю запись массива FirstLink.
NumNodes = NumNodes - 1
ReDim
Preserve FirstLink(0 To NumNodes)
ReDim
Preserve NodeCaption(0 To NumNodes - 1)
Unload
FStarForm.NodeLabel(NumNodes)
End Sub
Это намного
сложнее, чем соответствующий код в программе NAry:
Public Function DeleteDescendant(target As
NAryNode) As Boolean
Dim i As Integer
Dim child As NAryNode
'
Является ли узел дочерним узлом.
For i = 1 To Children.Count
If
Children.Item(i) Is target Then
Children.Remove
i
DeleteDescendant
= True
Exit
Function
End
If
Next
i
' Если это не дочерний узел, рекурсивно
' проверить остальных потомков.
For Each child In Children
If
child.DeleteDescendant(target) Then
DeleteDescendant
= True
Exit
Function
End
If
Next
child
End Function
=======125-126
Полные деревья
XE
"Деревья:полные" Полное
дерево (complete tree
XE "tree:complete" ) содержит максимально возможное число узлов на каждом уровне,
кроме нижнего. Все узлы на нижнем уровне сдвигаются влево. Например, каждый
уровень троичного дерева содержит в точности три дочерних узла, за исключением
листьев, и возможно, одного узла на один уровень выше листьев. На рис. 6.9
показаны полные двоичное и троичное деревья.
Полные деревья
обладают рядом важных свойств. Во‑первых, это кратчайшие деревья, которые
могут содержать заданное число узлов. Например, двоичное дерево на рис.
6.9 — одно из самых коротких двоичных деревьев с шестью узлами. Существуют
другие двоичные деревья с шестью узлами, но ни одно из них не имеет высоту
меньше 3.
Во‑вторых,
если полное дерево порядка D состоит из N узлов, оно будет иметь высоту порядка
O(logD(N)) и O(N) листьев. Эти
факты имеют большое значение, поскольку многие алгоритмы обходят деревья сверху
вниз или в противоположном направлении. Время выполнения алгоритма,
выполняющего одно из этих действий, будет порядка O(N).
Чрезвычайно
полезное свойство полных деревьев заключается в том, что они могут быть очень
компактно записаны в массивах. Если пронумеровать узлы в «естественном»
порядке, сверху вниз и слева направо, то можно поместить элементы дерева в
массив в этом порядке. На рис. 6.10 показано, как можно записать полное дерево
в массиве.
Корень дерева
находится в нулевой позиции. Дочерние узлы узла I находятся на позициях
2 * I + 1 и 2 * I + 2. Например, на рис. 6.10, потомки узла
в позиции 1 (узла B), находятся в позициях 3 и 4 (узлы D и E).
Легко обобщить это
представление на полные деревья более высокого порядка D. Корень дерева также
будет находиться в позиции 0. Потомки узла I занимают позиции от
D * I + 1 до D * I +(I - 1). Например, в троичном дереве,
потомки узла в позиции 2, будут занимать позиции 7, 8 и 9. На рис. 6.11
показано полное троичное дерево и его представление в виде массива.
@Рис. 6.9. Полные деревья
=========127
@Рис. 6.10. Запись
полного двоичного дерева в массиве
При использовании
этого метода записи дерева в массиве легко и просто получить доступ к потомкам
узла. При этом не требуется дополнительной памяти для коллекций дочерних узлов
или меток в случае представления нумерацией связей. Чтение и запись дерева в
файл сводится просто к сохранению или чтению массива. Поэтому это несомненно
лучшее представление дерева для программ, которые сохраняют данные в полных
деревьях.
Обход дерева
Последовательное
обращение ко всем узлам называется XE
"Деревья:обход" обходом
(traversing XE "tree:traversing" ) дерева. Существует несколько
последовательностей обхода узлов двоичного дерева. Три простейших из них —
XE "Деревья:прямой обход" прямой (preorder XE "traversal:preorder" ), XE
"Деревья:симметричный обход" симметричный (
XE "traversal:inorder" inorder),
и XE "Деревья:обратный обход" обратный (postorder XE "traversal:postorder" )обход, описываются простыми рекурсивными
алгоритмами. Для каждого заданного узла алгоритмы выполняют следующие действия:
Прямой обход:
1.
2.
3.
Симметричный обход:
1.
2.
3.
Обратный обход:
1.
2.
3.
@Рис. 6.11. Запись
полного троичного дерева в массиве
=======128
Все три порядка
обхода являются примерами XE
"Деревья:обход в глубину" обхода в глубину (depth‑first traversal XE "traversal:depth-first" ). Обход начинается с прохода вглубь дерева
до тех пор, пока алгоритм не достигнет листьев. При возврате из рекурсивного
вызова подпрограммы, алгоритм перемещается по дереву в обратном направлении,
просматривая пути, которые он пропустил при движении вниз.
Обход в глубину
удобно использовать в алгоритмах, которые должны вначале обойти листья.
Например, метод ветвей и границ, описанный в 8 главе, как можно быстрее
пытается достичь листьев. Он использует результаты, полученные на уровне
листьев для уменьшения времени поиска в оставшейся части дерева.
Четвертый метод
перебора узлов дерева — это XE
"Деревья:обход в ширину" обход в ширину (breadth‑first traversal XE "traversal:breadth-first"
). Этот метод обращается ко всем узлам на
заданном уровне дерева, перед тем, как перейти к более глубоким уровням.
Алгоритмы, которые проводят полный поиск по дереву, часто используют обход в
ширину. Алгоритм поиска кратчайшего маршрута с установкой меток, описанный в 12
главе, представляет собой обход в ширину, дерева кратчайшего пути в сети.
На рис. 6.12
показано небольшое дерево и порядок, в котором перебираются узлы во время
прямого, симметричного и обратного обхода, а также обхода в ширину.
@Рис. 6.12. Обходы
дерева
======129
Для деревьев
больше, чем 2 порядка, все еще имеет смысл определять прямой, обратный обход, и
обход в ширину. Симметричный обход определяется неоднозначно, так как обращение
к каждому узлу может происходить после обращения к одному, двум, или трем его
потомкам. Например, в троичном дереве, обращение к узлу может происходить после
обращения к его первому потомку или после обращения ко второму потомку.
Детали реализации
обхода зависят от того, как записано дерево. Для обхода дерева на основе
коллекций дочерних узлов, программа должна использовать несколько другой
алгоритм, чем для обхода дерева, записанного при помощи нумерации связей.
Особенно просто обходить
полные деревья, записанные в массиве. Алгоритм обхода в ширину, который требует
дополнительных усилий в других представлениях деревьев, для представлений на
основе массива тривиален, так как узлы записаны в таком же порядке.
Следующий код
демонстрирует алгоритмы обхода полного двоичного дерева:
Dim NodeLabel() As String ' Запись меток узлов.
Dim NumNodes As Integer
' Инициализация
дерева.
:
Private Sub Preorder(node As Integer)
Print
NodeLabel (node) ' Узел.
'
Первый потомок.
If node * 2 + 1 <= NumNodes Then
Preorder node * 2 + 1
'
Второй потомок.
If node * 2 + 2 <= NumNodes Then
Preorder node * 2 + 2
End Sub
Private Sub Inorder(node As Integer)
'
Первый потомок.
If node * 2 + 1 <= NumNodes Then
Inorder node * 2 + 1
Print
NodeLabel (node) ' Узел.
'
Второй потомок.
If node * 2 + 2 <= NumNodes Then
Inorder node * 2 + 2
End Sub
Private Sub Postorder(node As Integer)
'
Первый потомок.
If node * 2 + 1 <= NumNodes Then
Postorder node * 2 + 1
'
Второй потомок.
If node * 2 + 2 <= NumNodes Then
Postorder node * 2 + 2
Print
NodeLabel (node) ' Узел.
End Sub
Private Sub BreadthFirstPrint()
Dim i As Integer
For i
= 0 To NumNodes
Print
NodeLabel(i)
Next i
End Sub
======130
Программа Trav1 демонстрирует прямой, симметричный и
обратный обходы, а также обход в ширину для двоичных деревьев на основе
массивов. Введите высоту дерева, и нажмите на кнопку Create Tree (Создать дерево) для
создания полного двоичного дерева. Затем нажмите на кнопки Preorder (Прямой обход), Inorder (Симметричный
обход), Postorder
(Обратный обход) или Breadth-First (Обход в ширину) для того, чтобы увидеть, как
происходит обход дерева. На рис. 6.13 показано окно программы, в котором
отображается прямой обход дерева 4 порядка.
Прямой и обратный
обход для других представлений дерева осуществляется так же просто. Следующий
код демонстрирует процедуру прямого обхода для дерева, записанного в формате с
нумерацией связей:
Private Sub PreorderPrint(node As Integer)
Dim link As Integer
Print
NodeLabel(node)
For link = FirstLink(node)
To FirstLink(node + 1) - 1
PreorderPrint
ToNode (link)
Next link
End Sub
@Рис. 6.13. Пример
прямого обхода дерева в программе Trav1
=======131
Как упоминалось
ранее, сложно дать определение симметричного обхода для деревьев больше 2 порядка.
Тем не менее, после того, как вы поймете, что имеется в виду под симметричным
обходом, реализовать его достаточно просто. Следующий код демонстрирует
процедуру симметричного обхода, которая обращается к половине потомков узла (с
округлением в большую сторону), затем к самому узлу, а потом — к остальным
потомкам.
Private Sub InorderPrint(node As Integer)
Dim mid_link As Integer
Dim link As Integer
'
Найти средний дочерний узел.
mid_link - (FirstLink(node + 1) - 1
+ FirstLink(node)) 2
'
Обход первой группы потомков.
For link = FirstLink(node) To
mid_link
InorderPrint
ToNode(link)
Next
link
' Обращение к узлу.
Print NodeLabel(node)
'
Обход второй группы потомков.
For link = mid_link + 1 To
FirstLink(node + 1) - 1
InorderPrint
ToNode(link)
Next
link
End Sub
Для полных
деревьев, записанных в массиве, узлы уже находятся в порядке обхода в ширину.
Поэтому обход в ширину для этих типов деревьев реализуется просто, тогда как
для других представлений реализовать его несколько сложнее.
Для обхода деревьев
других типов можно использовать очередь для хранения узлов, которые еще не были
обойдены. Вначале поместим в очередь корневой узел. После обращения к узлу, он
удаляется из начала очереди, а его потомки помещаются в ее конец. Процесс повторяется
до тех пор, пока очередь не опустеет. Следующий код демонстрирует процедуру
обхода в ширину для дерева, которое использует узлы с коллекциями потомков:
Dim Root As TreeNode
' Инициализация
дерева.
:
Private Sub BreadthFirstPrint(}
Dim queue As New Collection ' Очередь на основе коллекций.
Dim node As TreeNode
Dim child As TreeNode
'
Начать с корня дерева в очереди.
queue.Add Root
' Многократная обработка первого элемента
' в очереди, пока очередь не опустеет.
Do While queue.Count > 0
node
= queue.Item(1)
queue.Remove
1
' Обращение к узлу.
Print NodeLabel(node)
'
Поместить в очередь потомков узла.
For Each child In node.Children
queue.Add
child
Next
child
Loop
End Sub
=====132
Программа Trav2 демонстрирует обход деревьев,
использующих коллекции дочерних узлов. Программа является объединением программ
Nary, которая оперирует деревьями порядка N, и
программы Trav1, которая демонстрирует обходы деревьев.
Выберите узел, и
нажмите на кнопку Add Child (Добавить дочерний узел), чтобы
добавить к узлу потомка. Нажмите на кнопки Preorder, Inorder, Postorder или Breadth First, чтобы увидеть
примеры соответствующих обходов. На рис. 6.14 показана программа Trav2, которая отображает обратный обход.
Упорядоченные
деревья
XE
"Деревья:упорядоченные" Двоичные деревья часто являются естественным способом
представления и обработки данных в компьютерных программах. Поскольку многие
компьютерные операции являются двоичными, они естественно преобразуются в
операции с двоичными деревьями. Например, можно преобразовать двоичное
отношение «меньше» в двоичное дерево. Если использовать внутренние узлы дерева
для обозначения того, что «левый потомок меньше правого» вы можете использовать
двоичное дерево для записи упорядоченного списка. На рис. 6.15 показано
двоичное дерево, содержащее упорядоченный список с числами 1, 2, 4, 6, 7, 9.
@Рис. 6.14. Пример
обратного обхода дерева в программе Trav2
======133
@Рис. 6.15.
Упорядоченный список: 1, 2, 4, 6, 7, 9.
Добавление
элементов
Алгоритм вставки
нового элемента в дерево такого типа достаточно прост. Начнем с корневого узла.
По очереди сравним значения всех узлов со значением нового элемента. Если
значение нового элемента меньше или равно значению узла, перейдем вниз по левой
ветви дерева. Если новое значение больше, чем значение узла, перейдем вниз по
правой ветви. Когда этот процесс дойдет до листа, элемент помещается в эту
точку.
Чтобы поместить
значение 8 в дерево, показанное на рис. 6.15, мы начинаем с корня, который
имеет значение 4. Поскольку 8 больше, чем 4, переходим по правой ветви к узлу
9. Поскольку 8 меньше 9, переходим затем по левой ветви к узлу 7. Поскольку 8
больше 7, снова пытаемся пойти по правой ветви, но у этого узла нет правого
потомка. Поэтому новый элемент вставляется в этой точке, и получается дерево,
показанное на рис. 6.16.
Следующий код
добавляет новое значение ниже узла в упорядоченном дереве. Программа начинает
вставку с корня, вызывая процедуру InsertItem Root, new_value.
Private Sub InsertItem(node As SortNode,
new_value As Integer)
Dim child As SortNode
If
node Is Nothing Then
'
Мы дошли до листа.
' Вставить элемент здесь.
Set node = New SortNode
node.Value
= new_value
MaxBox
= MaxBox + 1
Load
NodeLabel(MaxBox)
Set
node.Box = NodeLabel(MaxBox)
With
NodeLabel(MaxBox)
.Caption
= Format$(new_value)
.Visible
= True
End
With
ElseIf
new_value <= node.Value Then
'
Перейти по левой ветви.
Set child = node.LeftChild
InsertItem
child, new_value
Set
node.LeftChild = child
Else
' Перейти по правой ветви.
Set child = node.RightChild
InsertItem
child, new_value
Set
node.RightChild = child
End If
End Sub
Когда эта процедура
достигает конца дерева, происходит нечто совсем неочевидное. В Visual Basic,
когда вы передаете параметр подпрограмме, этот параметр передается по ссылке, если вы не используете зарезервированное
слово ByVal. Это
означает, что подпрограмма работает с той же копией параметра, которую
использует вызывающая процедура. Если подпрограмма изменяет значение параметра,
значение в вызывающей процедуре также изменяется.
Когда процедура InsertItem
рекурсивно вызывает сама себя, она передает указатель на дочерний узел в
дереве. Например, в следующих операторах процедура передает указатель на
правого потомка узла в качестве параметра узла процедуры InsertItem.
Если вызываемая процедура изменяет значение параметра узла, указатель на
потомка также автоматически обновляется в вызывающей процедуре. Затем в
последней строке кода значение правого потомка устанавливается равным новому
значению, так что созданный новый узел добавляется к дереву.
Set child = node.RightChild
Insertltem child, new_value
Set node.RightChild = child
Удаление элементов
Удаление элемента
из упорядоченного дерева немного сложнее, чем его вставка. После удаления
элемента, программе может понадобиться переупорядочить другие узлы, чтобы
соотношение «меньше» продолжало выполняться для всего дерева. При этом нужно
рассмотреть несколько случаев.
=====134-135
@Рис. 6.17.
Удаление узла с единственным потомком
Во‑первых,
если у удаляемого узла нет потомков, вы можете просто убрать его из дерева, так
как порядок оставшихся узлов при этом не изменится.
Во‑вторых,
если у узла всего один дочерний узел, вы можете поместить его на место
удаленного узла. Порядок остальных потомков удаленного узла останется
неизменным, поскольку они являются также потомками и дочернего узла. На рис.
6.17 показано дерево, из которого удаляется узел 4, который имеет всего один
дочерний узел.
Если удаляемый узел
имеет два дочерних, то не обязательно один из них займет место удаленного узла.
Если потомки узла также имеют по два дочерних узла, то все потомки не смогут
занять место удаленного узла. Удаленный узел имеет одного лишнего потомка, и
дочерний узел, который вы хотели бы поместить на его место, также имеет двух
потомков, так что на узел пришлось бы три потомка.
Чтобы решить эту
проблему, удаленный узел заменяется самым правым узлом из левой ветви. Другими
словами, нужно сдвинуться на один шаг вниз по левой ветви, выходившей из
удаленного узла. Затем нужно двигаться по правым ветвям вниз до тех пор, пока
не найдется узел, который не имеет правой ветви. Это самый правый узел на ветви
слева от удаляемого узла. В дереве, показанном слева на рис. 6.18, узел 3
является самым правым узлом в левой от узла 4 ветви. Можно заменить узел 4
листом 3, сохранив при этом порядок дерева.
@Рис. 6.18.
Удаление узла, который имеет два дочерних
=======136
@Рис. 6.19.
Удаление узла, если заменяющий его узел имеет потомка
Остается последний
вариант — когда заменяющий узел имеет левого потомка. В этом случае, вы
можете переместить этого потомка на место, освободившееся в результате
перемещения замещающего узла, и дерево снова будет расположено в нужном
порядке. Уже известно, что самый правый узел не имеет правого потомка, иначе он
не был бы таковым. Это означает, что не нужно беспокоиться, не имеет ли
замещающий узел двух потомков.
Эта сложная
ситуация показана на рис. 6.19. В этом примере удаляется узел 8. Самый правый
элемент в его левой ветви — это узел 7, который имеет потомка — узел
5. Чтобы сохранить порядок дерева после удаления узла 8, заменим узел 8 узлом
7, а узел 7 — узлом 5. Заметьте, что узел 7 получает новых потомков, а
узел 5 сохраняет своих.
Следующий код
удаляет узел из упорядоченного двоичного дерева:
Private Sub DeleteItem(node As SortNode,
target_value As Integer)
Dim target As SortNode
Dim child As SortNode
'
Если узел не найден, вывести сообщение.
If node Is Nothing Then
Beep
MsgBox
"Item " & Format$(target_value) & _
"
не найден в дереве."
Exit Sub
End If
If
target_value < node.Value Then
'
Продолжить для левого поддерева.
Set child = node.LeftChild
DeleteItem
child, target_value
Set
node.LeftChild = child
ElseIf
target_value > node.Value Then
'
Продолжить для правого поддерева.
Set child = node.RightChild
DeleteItem
child, target_value
Set
node.RightChild = child
Else
' Искомый узел найден.
Set target = node
If
target.LeftChild Is Nothing Then
'
Заменить искомый узел его правым потомком.
Set node =
node.RightChild
ElseIf
target.RightChild Is Nothing Then
'
Заменить искомый узел его левым потомком.
Set node =
node.LeftChild
Else
' Вызов подпрограмы ReplaceRightmost
для замены
' искомого узла самым правым узлом
' в его левой ветви.
Set child = node.LeftChild
ReplaceRightmost
node, child
Set
node.LeftChild = child
End
If
End If
End Sub
Private Sub ReplaceRightmost(target As
SortNode, repl As SortNode)
Dim old_repl As SortNode
Dim child As SortNode
If Not
(repl.RightChild Is Nothing) Then
'
Продолжить движение вправо и вниз.
Set child = repl.RightChild
ReplaceRightmost
target, child
Set
repl.RightChild = child
Else
' Достигли дна.
' Запомнить заменяющий узел repl.
Set old_repl = repl
'
Заменить узел repl его левым потомком.
Set repl = repl.LeftChild
'
Заменить искомый узел target with repl.
Set old_repl.LeftChild =
target.LeftChild
Set
old_repl.RightChild = target.RightChild
Set
target = old_repl
End If
End Sub
======137-138
Алгоритм использует
в двух местах прием передачи параметров в рекурсивные подпрограммы по ссылке.
Во‑первых, подпрограмма DeleteItem
использует этот прием для того, чтобы родитель искомого узла указывал на
заменяющий узел. Следующие операторы показывают, как вызывается подпрограмма DeleteItem:
Set child = node.LeftChild
DeleteItem child, target_value
Set node.LeftChild = child
Когда процедура
обнаруживает искомый узел (узел 8 на рис. 6.19), она получает в качестве
параметра узла указатель родителя на искомый узел. Устанавливая параметр на замещающий
узел (узел 7), подпрограмма DeleteItem задает
дочерний узел для родителя так, чтобы он указывал на новый узел.
Следующие операторы
показывают, как процедура ReplaceRightMost
рекурсивно вызывает себя:
Set child = repl.RightChild
ReplaceRightmost target, child
Set repl.RightChild = child
Когда процедура
находит самый правый узел в левой от удаляемого узла ветви (узел 7), в
параметре repl находится
указатель родителя на самый правый узел. Когда процедура устанавливает значение
repl равным repl.LeftChild, она
автоматически соединяет родителя самого правого узла с левым дочерним узлом
самого правого узла (узлом 5).
Программа TreeSort
использует эти процедуры для работы с упорядоченными двоичными деревьями.
Введите целое число, и нажмите на кнопку Add, чтобы добавить элемент к дереву. Введите целое число, и
нажмите на кнопку Remove, чтобы удалить этот элемент из дерева. После удаления
узла, дерево автоматически переупорядочивается для сохранения порядка «меньше».
Обход
упорядоченных деревьев
Полезное свойство
упорядоченных деревьев состоит в том, что их порядок совпадает с порядком
симметричного обхода. Например, при симметричном обходе дерева, показанного на
рис. 6.20, обращение к узлам происходит в порядке 2-4-5-6-7-8-9.
@Рис. 6.20.
Симметричный обход упорядоченного дерева: 2, 4, 5, 6, 7, 8, 9
=========139
Это свойство
симметричного обхода упорядоченных деревьев приводит к простому алгоритму
сортировки:
1.
2.
Этот алгоритм
обычно работает достаточно хорошо. Тем не менее, если добавлять элементы к
дереву в определенном порядке, то дерево может стать высоким и тонким. На рис.
6.21 показано упорядоченное дерево, которое получается при добавлении к нему
элементов в порядке 1, 6, 5, 2, 3, 4. Другие последовательности также могут
приводить к появлению высоких и тонких деревьев.
Чем выше
становится упорядоченное дерево, тем больше времени требуется для добавления
новых элементов в нижнюю часть дерева. В наихудшем случае, после добавления N элементов, дерево будет иметь высоту порядка O(N). Полное время вставки всех
элементов в дерево будет при этом порядка O(N2). Поскольку для обхода дерева требуется время
порядка O(N), полное время сортировки
чисел с использованием дерева будет равно O(N2)+O(N)=O(N2).
Если дерево
остается достаточно коротким, оно имеет высоту порядка O(log(N)). В этом случае для вставки элемента в дерево потребуется
всего порядка O(log(N)) шагов. Вставка всех N элементов в дерево
потребует порядка O(N * log(N)) шагов. Тогда сортировка элементов при помощи дерева потребует
времени порядка O(N * log(N)) + O(N) = O(N * log(N)).
Время
выполнения порядка O(N * log(N)) намного меньше, чем O(N2). Например, построение высокого и тонкого дерева,
содержащего 1000 элементов, потребует выполнения около миллиона шагов.
Построение короткого дерева с высотой порядка O(log(N)) займет всего около 10.000
шагов.
Если
элементы первоначально расположены в случайном порядке, форма дерева будет
представлять что‑то среднее между этими двумя крайними случаями. Хотя его
высота может оказаться несколько больше, чем log(N), оно, скорее всего, не будет слишком тонким и высоким, поэтому
алгоритм сортировки будет выполняться достаточно быстро.
@Рис. 6.21.
Дерево, полученное добавлением элементов в порядке 1, 6, 5, 2, 3, 4
==========140
В 7 главе
описываются способы балансировки деревьев, для того, чтобы они не становились
слишком высокими и тонкими, независимо от того, в каком порядке в них
добавляются новые элементы. Тем не менее, эти методы достаточно сложны, и их не
имеет смысла применять в алгоритме сортировки при помощи дерева. Многие из
алгоритмов сортировки, описанных в 9 главе, более просты в реализации и
обеспечивают при этом лучшую производительность.
Деревья со
ссылками
Во 2 главе показано,
как добавление ссылок к связным спискам позволяет упростить вывод элементов в
разном порядке. Вы можете использовать тот же подход для упрощения обращения к
узлам дерева в различном порядке. Например, помещая ссылки в листья двоичного
дерева, вы можете облегчить выполнение симметричного и обратного обходов. Для
упорядоченного дерева, это обход в прямом и обратном порядке сортировки.
Для создания
ссылок, указатели на предыдущий и следующий узлы в порядке симметричного обхода
помещаются в неиспользуемых указателях на дочерние узлы. Если не используется
указатель на левого потомка, то ссылка записывается на его место, указывая на
предыдущий узел при симметричном обходе. Если не используется указатель на
правого потомка, то ссылка записывается на его место, указывая на следующий
узел при симметричном обходе. Поскольку ссылки симметричны, и ссылки левых
потомков указывают на предыдущие, а правых — на следующие узлы, этот тип
деревьев называется XE
"Деревья:с симметричными ссылками" деревом с симметричными ссылками (symmetrically threaded tree XE "tree:symmetrically
threaded" ). На рис. 6.22 показано дерево с
симметричными ссылками, которые обозначены пунктирными линиями.
Поскольку ссылки
занимают место указателей на дочерние узлы дерева, нужно как‑то различать
ссылки и обычные указатели на потомков. Проще всего добавить к узлам новые
переменные HasLeftChild и HasRightChild
типа Boolean, которые будут равны True,
если узел имеет левого или правого потомка соответственно.
Чтобы использовать
ссылки для поиска предыдущего узла, нужно проверить указатель на левого потомка
узла. Если этот указатель является ссылкой, то ссылка указывает на предыдущий
узел. Если значение указателя равно Nothing, значит
это первый узел дерева, и поэтому он не имеет предшественников. В противном
случае, перейдем по указателю к левому дочернему узлу. Затем проследуем по
указателям на правый дочерний узел потомков, до тех пор, пока не достигнем
узла, в котором на месте указателя на правого потомка находится ссылка. Этот
узел (а не тот, на который указывает ссылка) является предшественником
исходного узла. Этот узел является самым правым в левой от исходного узла ветви
дерева. Следующий код демонстрирует поиск предшественника:
@Рис. 6.22. Дерево
с симметричными ссылками
==========141
Private Function Predecessor(node As
ThreadedNode) As ThreadedNode Dim child As ThreadedNode
If
node.LeftChild Is Nothing Then
'
Это первый узел в порядке симметричного обхода.
Set Predecessor = Nothing
Else
If node.HasLeftChild Then
'
Это указатель на узел.
' Найти самый правый узел в левой ветви.
Set child = node.LeftChild
Do
While child.HasRightChild
Set
child = child.RightChild
Loop
Set
Predecessor = child
Else
' Ссылка указывает на предшественника.
Set Predecessor = node.LeftChild
End If
End Function
Аналогично
выполняется поиск следующего узла. Если указатель на правый дочерний узел
является ссылкой, то она указывает на следующий узел. Если указатель имеет
значение Nothing, то это
последний узел дерева, поэтому он не имеет последователя. В противном случае,
переходим по указателю к правому потомку узла. Затем перемещаемся по указателям
дочерних узлов до тех, пор, пока очередной указатель на левый дочерний узел не
окажется ссылкой. Тогда найденный узел будет следующим за исходным. Это будет
самый левый узел в правой от исходного узла ветви дерева.
Удобно также ввести
функции для нахождения первого и последнего узлов дерева. Чтобы найти первый
узел, просто проследуем по указателям на левого потомка вниз от корня до тех
пор, пока не достигнем узла, значение указателя на левого потомка для которого
равно Nothing. Чтобы
найти последний узел, проследуем по указателям на правого потомка вниз от корня
до тех пор, пока не достигнем узла, значение указателя на правого потомка для
которого равно Nothing.
Private Function FirstNode() As ThreadedNode
Dim node As ThreadedNode
Set
node = Root
Do
While Not (node.LeftChild Is Nothing)
Set
node = node.LeftChild
Loop
Set
PirstNode = node
End Function
Private Function LastNode() As ThreadedNode
Dim node As ThreadedNode
Set
node = Root
Do
While Not (node.RightChild Is Nothing)
Set
node = node.RightChild
Loop
Set
FirstNode = node
End Function
=========142
При помощи этих
функций вы можете легко написать процедуры, которые выводят узлы дерева в
прямом или обратном порядке:
Private Sub Inorder()
Dim node As ThreadedNode
'
Найти первый узел.
Set node = FirstNode()
'
Вывод списка.
Do While Not (node Is Nothing)
Print
node.Value
Set
node = Successor(node)
Loop
End Sub
Private Sub PrintReverseInorder()
Dim node As ThreadedNode
'
Найти последний узел
Set node = LastNode
' Вывод списка.
Do
While Not (node Is Nothing)
Print
node. Value
Set
node = Predecessor(node)
Loop
End Sub
Процедура вывода
узлов в порядке симметричного обхода, приведенная ранее в этой главе,
использует рекурсию. Для устранения рекурсии вы можете использовать эти новые
процедуры, которые не используют ни рекурсию, ни системный стек.
Каждый указатель на
дочерние узлы в дереве содержит или указатель на потомка, или ссылку на
предшественника или последователя. Так как каждый узел имеет два указателя на
дочерние узлы, то, если дерево имеет N узлов, то оно будет содержать
2 * N ссылок и указателей. Эти алгоритмы обхода обращаются ко всем ссылкам
и указателям дерева один раз, поэтому они потребуют выполнения
O(2 * N) = O(N) шагов.
Можно немного
ускорить выполнение этих подпрограмм, если отслеживать указатели на первый и
последний узлы дерева. Тогда вам не понадобится выполнять поиск первого и
последнего узлов перед тем, как вывести список узлов по порядку. Так как при
этом алгоритм обращается ко всем N узлам дерева, время выполнения этого
алгоритма также будет порядка O(N), но на практике он будет выполняться немного
быстрее.
========143
Работа с
деревьями со ссылками
Для работы с
деревом со ссылками, нужно, чтобы можно было добавлять и удалять узлы из
дерева, сохраняя при этом его структуру.
Предположим, что
требуется добавить нового левого потомка узла A. Так как это место не занято,
то на месте указателя на левого потомка узла A находится ссылка, которая
указывает на предшественника узла A. Поскольку новый узел займет место левого
потомка узла A, он станет предшественником узла A. Узел A будет последователем
нового узла. Узел, который был предшественником узла A до этого, теперь
становится предшественником нового узла. На рис. 6.23 показано дерево с рис.
6.22 после добавления нового узла X в качестве левого потомка узла H.
Если отслеживать
указатель на первый и последний узлы дерева, то требуется также проверить, не
является ли теперь новый узел первым узлом дерева. Если ссылка на
предшественника для нового узла имеет значение Nothing,
то это новый первый узел дерева.
@Рис. 6.23.
Добавление узла X к дереву со ссылками
=========144
Учитывая все
вышеизложенное, легко написать процедуру, которая добавляет нового левого
потомка к узлу. Вставка правого потомка выполняется аналогично.
Private Sub AddLeftChild(parent As
ThreadedNode, child As ThreadedNode)
'
Предшественник родителя становится предшественником нового узла.
Set child. LeftChild =
parent.LeftChild
child.HasLeftChild
= False
' Вставить узел.
Set
parent.LeftChild = child
parent.HasLeftChild
= True
' Родитель является последователем нового
узла.
Set child.RightChild = parent
child.HasRightChild
= False
' Определить, является ли новый узел первым
узлом дерева.
If child.LeftChild Is Nothing Then
Set FirstNode = child
End Sub
Перед тем, как
удалить узел из дерева, необходимо вначале удалить всех его потомков. После
этого легко удалить уже сам узел.
Предположим, что
удаляемый узел является левым потомком своего родителя. Его указатель на левого
потомка является ссылкой, указывающей на предыдущий узел в дереве. После
удаления узла, его предшественник становится предшественником родителя удаленного
узла. Чтобы удалить узел, просто заменяем указатель на левого потомка его
родителя на указатель на левого потомка удаляемого узла.
Указатель на
правого потомка удаляемого узла является ссылкой, которая указывает на
следующий узел в дереве. Так как удаляемый узел является левым потомком своего
родителя, и поскольку у него нет потомков, эта ссылка указывает на родителя,
поэтому ее можно просто опустить. На рис. 6.24 показано дерево с рис. 6.23
после удаления узла F. Аналогично удаляется правый потомок.
Private Sub RemoveLeftChild(parent As
ThreadedNode)
Dim target As ThreadedNode
Set
target = parent.LeftChild
Set
parent.LeftChild = target.LeftChild
End Sub
@Рис. 6.24. Удаление
узла F из дерева со ссылками
=========145
Квадродеревья[RP12]
XE
"Деревья:квадродеревья" Квадродеревья (quadtrees XE "quadtree"
) описывают пространственные отношения между
элементами на площади. Например, это может быть карта, а элементы могут
представлять собой положение домов или предприятий на ней.
Каждый узел
квадродерева представляет собой участок на площади, представленной
квадродеревом. Каждый узел, кроме листьев, имеет четыре потомка, которые
представляют четыре квадранта. Листья могут хранить свои элементы в коллекциях
связных списков. Следующий код показывает секцию Declarations для класса QtreeNode.
' Потомки.
Public NWchild As QtreeNode
Public NEchild As QtreeNode
Public SWchild As QtreeNode
Public SEchild As QtreeNode
' Элементы узла, если
это не лист.
Public Items As New Collection
Элементы,
записанные в квадродереве, могут содержать пространственные данные любого типа.
Они могут содержать информацию о положении, которую дерево может использовать
для поиска элементов. Переменные в простом классе QtreeItem,
который представляет элементы, состоящие из точек на местности, определяются
так:
Public
X As Single
Public
Y As Single
Чтобы построить
квадродерево, вначале поместим все элементы в корневой узел. Затем определим,
содержит ли этот узел достаточно много элементов, чтобы его стоило разделить на
несколько узлов. Если это так, создадим четыре потомка узла и распределим
элементы между четырьмя потомками в соответствии с их положением в четырех
квадрантах исходной области. Затем рекурсивно проверяем, не нужно ли разбить на
несколько узлов дочерние узлы. Продолжим разбиение до тех пор, пока все листья
не будут содержать не больше некоторого заданного числа элементов.
На рис. 6.25
показано несколько элементов данных, расположенных в виде квадродерева. Каждая
область разбивается до тех пор, пока она не будет содержать не более двух
элементов.
Квадродеревья
удобно применять для поиска близлежащих объектов. Предположим, имеется
программа, которая рисует карту с большим числом населенных пунктов. После
того, как пользователь щелкнет мышью по карте, программа должна найти ближайший
к выбранной точке населенный пункт. Программа может перебрать весь список
населенных пунктов, проверяя для каждого его расстояние от заданной точки. Если
в списке N элементов, то сложность этого алгоритма порядка O(N).
====146
@Рис. 6.25. Квадродерево
Эту операцию можно
выполнить намного быстрее при помощи квадродерева. Начнем с корневого узла. При
каждой проверке квадродерева определяем, какой из квадрантов содержит точку,
которую выбрал пользователь. Затем спустимся вниз по дереву к соответствующему
дочернему узлу. Если пользователь выбрал верхний правый угол области узла,
нужно спуститься к северо‑восточному потомку. Продолжим движение вниз по
дереву, пока не дойдем до листа, который содержит выбранную пользователем
точку.
Функция LocateLeaf
класса QtreeNode
использует этот подход для поиска листа дерева, который содержит выбранную
точку. Программа может вызвать эту функцию в строке Set the_leaf = Root.LocateLeaf(X, Y, Gxmin, Gxmax, Gymax),
где Gxmin, Gxmax, Gymin,
Gymax — это границы представленной
деревом области.
Public Function LocateLeaf (X As Single, Y As
Single, _
xmin As Single, xmax As Single, ymin As Single,
ymax As Single) _
As QtreeNode
Dim xmid As Single
Dim ymid As Single
Dim node As QtreeNode
If NWchild Is
Nothing Then
' Узел не имеет потомков. Искомый
узел найден.
Set LocateLeaf = Me
Exit
Function
End
If
' Найти соответстующего потомка.
xmid = (xmax + xmin) / 2
ymid =
(ymax + ymin) / 2
If X
<= xmid Then
If
Y <= ymid Then
Set
LocateLeaf = NWchild.LocateLeaf( _
X,
Y, xmin, xmid, ymin, ymid)
Else
Set
LocateLeaf = SWchild.LocateLeaf _
X,
Y, xmin, xmid, ymid, ymax)
End
If
Else
If
Y <= ymid Then
Set
LocateLeaf = NEchild.LocateLeaf( _
X,
Y, xmid, xmax, ymin, ymid)
Else
Set LocateLeaf =
SEchild.LocateLeaf( _
X, Y, xmid, xmax, ymid, ymax)
End If
End If
End Function
После нахождения
листа, который содержит точку, проверяем населенные пункты в листе, чтобы
найти, который из них ближе всего от выбранной точки. Это делается при помощи
процедуры NearPointInLeaf.
Public Sub NearPointInLeaf (X As Single, Y As
Single, _
best_item As QtreeItem, best_dist As Single, comparisons As Long)
Dim new_item As QtreeItem
Dim Dx As Single
Dim Dy As Single
Dim new_dist As Single
' Начнем с заведомо плохого решения.
best_dist = 10000000
Set
best_item = Nothing
'
Остановиться если лист не содержит элементов.
If Items.Count < 1 Then Exit Sub
For
Each new_item In Items
comparisons
= comparisons + 1
Dx
= new_item.X - X
Dy
= new_item.Y - Y
new_dist
=Dx * Dx + Dy * Dy
If
best_dist > new_dist Then
best_dist
= new_dist
Set
best_item = new_item
End
If
Next
new_item
End Sub
======147-148
Элемент, который
находит процедура NearPointLeaf,
обычно и есть элемент, который пользователь пытался выбрать. Тем не менее, если
элемент находится вблизи границы между двумя узлами, может оказаться, что
ближайший к выбранной точке элемент находится в другом узле.
Предположим, что Dmin — это расстояние между выбранной
пользователем точкой и ближайшим из найденных до сих пор населенных пунктов.
Если Dmin меньше, чем расстояние
от выбранной точки до края листа, то поиск закончен. Населенный пункт находится
при этом слишком далеко от края листа, чтобы в каком‑либо другом листе
мог существовать пункт, расположенный ближе к заданной точке.
В противном случае
нужно снова начать с корня и двигаться по дереву, проверяя все узлы
квадродеревьев, которые находятся на расстоянии меньше, чем Dmin от заданной точки. Если найдутся элементы,
которые расположены ближе, изменим значение Dmin
и продолжим поиск. После завершения проверки ближайших к точке листьев, нужный
элемент будет найден. Подпрограмма CheckNearByLeaves
использует этот подход для завершения поиска.
Public Sub CheckNearbyLeaves(exclude As
QtreeNode, _
X As Single, Y
As Single, best_item As QtreeItem, _
best_dist As
Single, comparisons As Long, _
xmin As Single,
xmax As Single, ymin As Single, ymax As Single)
Dim xmid As Single
Dim ymid As Single
Dim new_dist As Single
Dim new_item As QtreeItem
' Если это лист, который мы должны
исключить,
' ничего не делать.
If Me Is exclude Then Exit Sub
'
Если это лист, проверить его.
If SWchild Is Nothing Then
NearPointInLeaf
X, Y, new_item, new_dist, comparisons
If
best_dist > new_dist Then
best_dist
= new_dist
Set
best_item = new_item
End
If
Exit
Sub
End
If
' Найти потомков, которые удалены не больше,
чем на best_dist
' от выбранной точки.
xmid = (xmax + xmin) / 2
ymid =
(ymax + ymin) / 2
If X -
Sqr(best_dist) <= xmid Then
'
Продолжаем с потомками на западе.
If Y - Sqr(best_dist) <= ymid
Then
'
Проверить северо-западного потомка.
NWchild.CheckNearbyLeaves
_
exclude,
X, Y, best_item, _
best_dist,
comparisons, _
xmin,
xmid, ymin, ymid
End
If
If
Y + Sqr(best_dist) > ymid Then
'
Проверить юго-западного потомка.
SWchiId.CheckNearbyLeaves
_
exclude,
X, Y, best_item, _
best_dist,
comparisons, _
xmin,
xmid, ymid, ymax
End
If
End If
If X +
Sqr(best_dist) > xmid Then
'
Продолжить с потомками на востоке.
If Y - Sqr(best_dist) <= ymid
Then
'
Проверить северо-восточного потомка.
NEchild.CheckNearbyLeaves _
exclude,
X, Y, best_item, _
best_dist,
comparisons, _
xmid,
xmax, ymin, ymid
End
If
If
Y + Sqr(best_dist) > ymid Then
' Проверить юговосточного потомка.
SEchild.CheckNearbyLeaves
_
exclude,
X, Y, best_item, _
best_dist,
comparisons, _
xmid,
xmax, ymid, ymax
End
If
End If
End Sub
=====149-150
Подпрограмма FindPoint использует подпрограммы LocateLeaf, NearPointInLeaf, и CheckNearbyLeaves, из класса QtreeNode для быстрого поиска элемента в квадродереве.
Function FindPoint(X As Single, Y As Single, comparisons As Long) _ As QtreeItem
Dim leaf As QtreeNode
Dim best_item As QtreeItem
Dim best_dist As Single
'
Определить, в каком листе находится точка.
Set leaf = Root.LocateLeaf( _
X,
Y, Gxmin, Gxmax, Gymin, Gymax)
'
Найти ближайшую точку в листе.
leaf.NearPointInLeaf _
X,
Y, best_item, best_dist, comparisons
'
Проверить соседние листья.
Root.CheckNearbyLeaves _
leaf,
X, Y, best_item, best_dist, _
comparisons,
Gxmin, Gxmax, Gymin, Gymax
Set FindPoint =
best_item
End Function
Программа Qtree
использует квадродерево. При старте программа запрашивает число элементов
данных, которое она должна создать, затем она создает элементы и рисует их в
виде точек. Задавайте вначале небольшое (около 1000) число элементов, пока вы
не определите, насколько быстро ваш компьютер может создавать элементы.
Интересно наблюдать
квадродеревья, элементы которых распределены неравномерно, поэтому программа
выбирает точки при помощи функции XE
"Странный аттрактор" странного аттрактора (strange attractor)
из XE "Теория:хаоса" теории
хаоса (chaos theory). Хотя кажется, что элементы следуют в случайном порядке,
они образуют интересные кластеры.
При выборе какой‑либо
точки на форме при помощи мыши, программа Qtree
находит ближайший к ней элемент. Она подсвечивает этот элемент и выводит число
проверенных при его поиске элементов.
В меню Options (Опции) программы можно задать, должна ли программа
использовать квадродеревья или нет. Если поставить галочку в пункте Use Quadtree (Использовать
квадродерево), то программа выводит на экран квадродерево и использует его для
поиска элементов. Если этот пункт не выбран, программа не отображает
квадродерево и находит нужные элементы путем перебора.
Программа проверяет
намного меньшее число элементов и работает намного быстрее при использовании
квадродерева. Если этот эффект не слишком заметен на вашем компьютере,
запустите программу, задав при старте 10.000 или 20.000 входных элементов. Вы
заметите разницу даже на компьютере с процессором Pentium с тактовой частотой
90 МГц.
На рис. 6.26
показано окно программа Qtree на котором
изображено 10.000 элементов. Маленький прямоугольник в верхнем правом углу
обозначает выбранный элемент. Метка в верхнем левом углу показывает, что
программа проверила всего 40 из 10.000 элементов перед тем, как найти нужный.
Изменение MAX_PER_NODE
Интересно
поэкспериментировать с программой Qtree, изменяя
значение MAX_PER_NODE, определенное
в разделе Declarations
класса QtreeNode. Это
максимальное число элементов, которые могут поместиться в узле квадродерева без
его разбиения. Программа обычно использует значение MAX_PER_NODE = 100.
======151
@Рис. 6.26.
Программа Qtree
Если вы уменьшите
это число, например, до 10, то в каждом узле будет находиться меньше элементов,
поэтому программа будет проверять меньше элементов, чтобы найти ближайший к
выбранной вами точке. Поиск будет выполняться быстрее. С другой стороны,
программе придется создать намного больше узлов квадродерева, поэтому она
займет больше памяти.
Наоборот, если вы
увеличите MAX_PER_NODE до 1000,
программа создаст намного меньше узлов. При этом потребуется больше времени на
поиск элементов, но дерево будет меньше, и займет меньше памяти.
Это пример
компромисса между временем и пространством. Использование большего числа узлов
квадродерева ускоряет поиск, но занимает больше памяти. В этом примере, при
значении переменной MAX_PER_NODE примерно
равном 100, достигается равновесие между скоростью и использованием памяти. Для
других приложений вам может потребоваться поэкспериментировать с различными
значениями переменной MAX_PER_NODE, чтобы найти
оптимальное.
Использование
псевдоуказателей в квадродеревьях
Программа Qtree
использует большое число классов и коллекций. Каждый внутренний узел квадродерева
содержит четыре ссылки на дочерние узлы. Листья включают большие коллекции, в
которых находятся элементы узла. Все эти объекты и коллекции замедляют работу
программы, если она содержит большое числе элементов. Создание объектов
отнимает много времени и памяти. Если программа создает множество объектов, она
может начать обращаться к файлу подкачки, что сильно замедлит ее работу.
К сожалению,
выигрыш от использования квадродеревьев будет максимальным, если программа
содержит много элементов. Чтобы улучшить производительность больших приложений,
вы можете использовать методы работы с псевдоуказателями, описанные во 2 главе.
=====152
Программа Qtree2 создает квадродерево при помощи
псевдоуказателей. Узлы и элементы находятся в массивах определенных пользователем
структур данных. В качестве указателей, эта программа использует индексы
массивов вместо ссылок на объекты. В одном из тестов на компьютере с
процессором Pentium с тактовой частотой 90 МГц, программе Qtree
потребовалось 25 секунд для построения квадродерева, содержащего 30.000
элементов. Программе Qtree2 понадобилось всего 3 секунды для создания
того же дерева.
Восьмеричные
деревья
XE
"Деревья:восьмеричные" Восьмеричные деревья (octtrees XE "octtree"
) аналогичны квадродеревьям, но они разбивают
область не двумерного, а трехмерного пространства. Восьмеричные деревья
содержат не четыре потомка, как квадродеревья, а восемь, разбивая объем области
на восемь частей — верхнюю северо‑западную, нижнюю северо‑западную,
верхнюю северо‑восточную, нижнюю северо‑восточную и так далее.
Восьмеричные
деревья полезны при работе с объектами, расположенными в пространстве.
Например, робот может использовать восьмеричное дерево для отслеживания
близлежащих объектов. Программа рейтрейсинга может использовать восьмеричное
дерево для того, чтобы быстро оценить, проходит ли луч поблизости от объекта
перед тем, как начать медленный процесс вычислений точного пересечения объекта
и луча.
Восьмеричные
деревья можно строить, используя примерно те же методы, что и для квадродеревьев.
Резюме
Существует
множество способов представления деревьев. Наиболее эффективным и компактным из
них является запись полных деревьев в массивах. Представление деревьев в виде
коллекций дочерних узлов упрощает работу с ними, но при этом программа
выполняется медленнее и использует больше памяти. Представление нумерацией
связей позволяет быстро выполнять обход дерева и использует меньше памяти, чем
коллекции потомков, но его сложно модифицировать. Тем не менее, его важно
представлять, потому что оно часто используется в сетевых алгоритмах.
=====153
Глава 7.
Сбалансированные деревья
При работе с
упорядоченным деревом, вставке и удалении узлов, дерево может стать
несбалансированным. Когда это происходит, то алгоритмы, работы с деревом
становятся менее эффективными. Если дерево становится сильно
несбалансированным, оно практически представляет всего лишь сложную форму
связного списка, и программа, использующая такое дерево, может иметь очень
низкую производительность.
В этой главе
обсуждаются методы, которые можно использовать для балансировки деревьев, даже
если узлы удаляются и добавляются с течением времени. Балансировка дерева
позволяет ему оставаться при этом достаточно эффективным.
Глава начинается с
описания того, что понимается под несбалансированным деревом и демонстрации
ухудшения производительности для несбалансированных деревьев. Затем в ней
обсуждаются АВЛ‑деревья, высота левого и правого поддеревьев в каждом
узле которых отличается не больше, чем на единицу. Сохраняя это свойство АВЛ‑деревьев,
можно поддерживать такое дерево сбалансированным.
Затем в главе
описываются Б‑деревья и Б+деревья, в которых все листья имеют одинаковую
глубину. Если число ветвей, выходящих из каждого узла находится в определенных
пределах, такие деревья остаются сбалансированными. Б‑деревья и Б+деревья
обычно используются при программировании баз данных. Последняя программа,
описанная в этой главе, использует Б+дерево для реализации простой, но
достаточно мощной базы данных.
Сбалансированность
дерева
Как упоминалось в 6
главе, форма упорядоченного дерева зависит от порядка вставки в него новых
узлов. На рис. 7.1 показано два различных дерева, созданных при добавлении
одних и тех же элементов в разном порядке.
Высокие и тонкие
деревья, такие как левое дерево на рис. 7.1, могут иметь глубину порядка O(N).
Вставка или поиск элемента в таком несбалансированном дереве может занимать
порядка O(N) шагов. Даже если новые элементы вставляются в дерево в случайном
порядке, в среднем они дадут дерево с глубиной N / 2, что также
порядка O(N).
Предположим, что
строится упорядоченное двоичное дерево, содержащее 1000 узлов. Если дерево
сбалансировано, то высота дерева будет порядка log2(1000), или
примерно равна 10. Вставка нового элемента в дерево займет всего 10 шагов. Если
дерево высокое и тонкое, оно может иметь высоту 1000. В этом случае, вставка
элемента в конец дерева займет 1000 шагов.
======155
@Рис. 7.1. Деревья,
построенные в различном порядке
Предположим теперь,
что мы хотим добавить к дереву еще 1000 узлов. Если дерево остается
сбалансированным, то все 1000 узлов поместятся на следующем уровне дерева. При
этом для вставки новых элементов потребуется около
10 * 1000 = 10.000 шагов. Если дерево было не
сбалансировано и остается таким в процессе роста, то при вставке каждого нового
элемента оно будет становиться все выше. Вставка элементов при этом потребует
порядка 1000 + 1001 + … +2000 = 1,5 миллиона шагов.
Хотя нельзя быть
уверенным, что элементы будут добавляться и удаляться из дерева в нужном
порядке, можно использовать методы, которые будут поддерживать
сбалансированность дерева, независимо от порядка вставки или удаления
элементов.
АВЛ‑деревья
XE
"Деревья:АВЛ-деревья" АВЛ‑деревья (AVL trees XE "tree:AVL
tree" ) были названы в честь русских математиков
Адельсона‑Вельского и Лэндиса, которые их изобрели. Для каждого узла АВЛ‑дерева,
высота левого и правого поддеревьев отличается не больше, чем на единицу. На
рис. 7.2 показано несколько АВЛ‑деревьев.
Хотя АВЛ‑дерево
может быть несколько выше, чем полное дерево с тем же числом узлов, оно также
имеет высоту порядка O(log(N)). Это означает, что
поиск узла в АВЛ‑дереве занимает время порядка O(log(N)), что достаточно быстро. Не столь очевидно, что можно
вставить или удалить элемент из АВЛ‑дерева за время порядка O(log(N)), сохраняя при этом порядок дерева.
======156
@Рис. 7.2. АВЛ‑деревья
Процедура, которая
вставляет в дерево новый узел, рекурсивно спускается вниз по дереву, чтобы
найти местоположение узла. После вставки элемента, происходят возвраты из
рекурсивных вызовов процедуры и обратный проход вверх по дереву. При каждом
возврате из процедуры, она проверяет, сохраняется ли все еще свойство АВЛ‑деревьев
на верхнем уровне. Этот тип обратной рекурсии, когда процедура выполняет важные
действия при выходе из цепочки рекурсивных вызовов, называется XE
"Рекурсия:восходящая" восходящей (bottom‑up) рекурсией.
При обратном
проходе вверх по дереву, процедура также проверяет, не изменилась ли высота
поддерева, с которым она работает. Если процедура доходит до точки, в которой
высота поддерева не изменилась, то высота следующих поддеревьев также не могла
измениться. В этом случае, снова требуется балансировка дерева, и процедура
может закончить проверку.
Например, дерево
слева на рис. 7.3 является сбалансированным АВЛ‑деревом. Если добавить к
дереву новый узел E, то получится среднее дерево на рисунке. Затем выполняется
проход вверх по дереву от нового узла E. В самом узле E дерево сбалансировано,
так как оба его поддерева пустые и имеют одинаковую высоту 0.
В узле D дерево
также сбалансировано, так как его левое поддерево пустое, и имеет поэтому
высоту 0. Правое поддерево содержит единственный узел E, и поэтому его высота
равна 1. Высоты поддеревьев отличаются не больше, чем на единицу, поэтому
дерево сбалансировано в узле D.
В узле C дерево уже
не сбалансировано. Левое поддерево узла C имеет высоту 0, а правое —
высоту 2. Эти поддеревья можно сбалансировать, как показано на рис. 7.3 справа,
при этом узел C заменяется узлом D. Теперь поддерево с корнем в узле D содержит
узлы C, D и E, и имеет высоту 2. Заметьте, что высота поддерева с корнем в узле
C, которое ранее находилось в этом месте, также была равна 2 до вставки нового
узла. Так как высота поддерева не изменилась, то дерево также окажется
сбалансированным во всех узлах выше D.
Вращения АВЛ‑деревьев
При вставке узла в
АВЛ‑дерево, в зависимости от того, в какую часть дерева добавляется узел,
существует четыре варианта балансировки. Эти способы называются правым и левым
вращением, и вращением влево‑вправо и вправо‑влево, и обозначаются
R, L, LR и RL.
Предположим, что в
АВЛ‑дерево вставляется новый узел, и теперь дерево становится
несбалансированным в узле X, как показано на рис. 7.4. На рисунке изображены
только узел X и два его дочерних узла, а остальные части дерева обозначены
треугольниками, так как их не требуется рассматривать подробно.
Новый узел может
быть вставлен в любое из четырех поддеревьев узла X, изображенных в виде
треугольников. Если вы вставляете узел в одно из этих поддеревьев, то для
балансировки дерева потребуется выполнить соответствующее вращение. Помните,
что иногда балансировка не нужна, если вставка нового узла не нарушает
упорядоченность дерева.
Правое вращение
XE
"Деревья:вращения" Вначале предположим, что новый узел
вставляется в поддерево R на рис. 7.4. В этом случае не нужно изменять два
правых поддерева узла X, поэтому их можно объединить, изобразив одним
треугольником, как показано на рис. 7.5. Новый узел вставляется в дерево T1,
при этом поддерево TA с корнем в узле A становится не менее, чем на
два уровня выше, чем поддерево T3.
На самом деле,
поскольку до вставки нового узла дерево было АВЛ‑деревом, то TA
должно было быть выше поддерева T3 не больше, чем на один уровень. После
вставки одного узла TA должно быть выше поддерева T3
ровно на два уровня.
Также известно, что
поддерево T1 выше поддерева T2 не больше, чем на один
уровень. Иначе узел X не был бы самым нижним узлом с несбалансированными
поддеревьями. Если бы T1 было на два уровня выше, чем T2,
то дерево было бы несбалансированным в узле A.
@Рис. 7.4. Анализ
несбалансированного АВЛ‑дерева
========158
@Рис. 7.5. Вставка
нового узла в поддерево R
В этом случае,
можно переупорядочить узлы при помощи правого вращения (right rotation XE "tree:right
rotation" ), как показано на рис. 7.6. Это вращение
называется правым, так как узлы A и X как бы вращаются вправо.
Заметим, что это
вращение сохраняет порядок «меньше» расположения узлов дерева. При симметричном
обходе любого из таких деревьев обращение ко всем поддеревьям и узлам дерева
происходит в порядке T1, A, T2, X, T3.
Поскольку симметричный обход обоих деревьев происходит одинаково, то и порядок
расположения элементов в них будет одинаковым.
Важно также
заметить, что высота поддерева, с которым мы работаем, остается неизменной.
Перед тем, как был вставлен новый узел, высота поддерева была равна высоте
поддерева T2 плюс 2. После вставки узла и выполнения правого
вращения, высота поддерева также остается равной высоте поддерева T2
плюс 2. Все части дерева, лежащие ниже узла X при этом также остаются
сбалансированными, поэтому не требуется продолжать балансировку дерева дальше.
Левое вращение
Левое вращение (left rotation XE "tree:left
rotation" ) выполняется аналогично правому. Оно
используется, если новый узел вставляется в поддерево L, показанное на рис.
7.4. На рис. 7.7 показано АВЛ‑дерево до и после левого вращения.
@Рис. 7.6. Правое
вращение
========159
@Рис. 7.7. До и
после левого вращения
Вращение влево‑вправо
Если узел
вставляется в поддерево LR, показанное на рис. 7.4, нужно рассмотреть еще один
нижележащий уровень. На рис. 7.8. показано дерево, в котором новый узел
вставляется в левую часть T2 поддерева LR. Так же легко можно
вставить узел в правое поддерево T3. В обоих случаях, поддеревья TA
и TC останутся АВЛ‑поддеревьями, но поддерево TX
уже не будет таковым.
Так как дерево до
вставки узла было АВЛ‑деревом, то TA было выше T4
не больше, чем на один уровень. Поскольку добавлен только один узел, то TA
вырастет только на один уровень. Это значит, что TA теперь будет
точно на два уровня выше T4.
Также известно, что
поддерево T2 не более, чем на один уровень выше, чем T3.
Иначе TC не было бы сбалансированным, и узел X не был бы самым
нижним в дереве узлом с несбалансированными поддеревьями.
Поддерево T1
должно иметь ту же глубину, что и T3. Если бы оно было короче, то
поддерево TA было бы не сбалансировано, что снова противоречит
предположению о том, что узел X — самый нижний узел в дереве, имеющий
несбалансированные поддеревья. Если бы поддерево T1 имело большую
глубину, чем T3, то глубина поддерева T1 была бы на 2
уровня больше, чем глубина поддерева T4. В этом случае дерево было
бы несбалансированным до вставки в него нового узла.
Все это означает,
что нижние части деревьев выглядят в точности так, как показано на рис. 7.8.
Поддерево T2 имеет наибольшую глубину, глубина T1 и T3
на один уровень меньше, а T4 расположено еще на один уровень выше,
чем T3 и T3.
@Рис. 7.8. Вставка
нового узла в поддерево LR
==========160
@Рис. 7.9. Вращение
влево‑вправо
Используя эти
факты, можно сбалансировать дерево, как показано на рис. 7.9. Это называется
вращением влево‑вправо (left‑right rotation XE "tree:left-right
rotation" ), так как при этом вначале узлы A и C как
бы вращаются влево, а затем узлы C и X вращаются вправо.
Как и другие вращения,
вращение этого типа не изменяет порядок элементов в дереве. При симметричном
обходе дерева до и после вращения обращение к узлам и поддеревьям происходит в
порядке: T1, A, T2, C, T3, X, T4.
Высота дерево после
балансировки также не меняется. До вставки нового узла, правое поддерево имело
высоту поддерева T1 плюс 2. После балансировки дерева, высота этого
поддерева снова будет равна высоте T1 плюс 2. Это значит, что
остальная часть дерева также остается сбалансированной, и нет необходимости
продолжать балансировку дальше.
Вращение вправо‑влево
Вращение вправо‑влево
(right‑left rotation)
аналогично вращению влево‑вправо () XE "tree:right-left rotation" . Оно используется для балансировки дерева после вставки узла в
поддерево RL на рис. 7.4. На рис. 7.10 показано АВЛ‑дерево до и после
вращения вправо‑влево.
Резюме
На рис. 7.11
показаны все возможные вращения АВЛ‑дерева. Все они сохраняют порядок
симметричного обхода дерева, и высота дерева при этом всегда остается
неизменной. После вставки нового элемента и выполнения соответствующего
вращения, дерево снова оказывается сбалансированным.
Вставка узлов на языке Visual Basic
Перед тем, как
перейти к обсуждению удаления узлов из АВЛ‑деревьев, в этом разделе
обсуждаются некоторые детали реализации вставки узла в АВЛ‑дерево на
языке Visual Basic.
Кроме обычных полей
LeftChild и RightChild,
класс AVLNode содержит
также поле Balance, которое указывает, которое из
поддеревьев узла выше. Его значение равно -1, если левое поддерево выше,
1 — если выше правое, и 0 — если оба поддерева имеют одинаковую
высоту.
======161
@Рис. 7.10. До и
после вращения вправо‑влево
Public LeftChild As AVLNode
Public RightChild As AVLNode
Public Balance As Integer
Чтобы сделать код
более простым для чтения, можно использовать постоянные LEFT_HEAVY, RIGHT_HEAVY, и BALANCED
для представления этих значений.
Global
Const LEFT_HEAVY = -1
Global
Const BALANCED = 0
Global
Const RIGHT_HEAVY = 1
Процедура InsertItem,
представленная ниже, рекурсивно спускается вниз по дереву в поиске нового местоположения
элемента. Когда она доходит до нижнего уровня дерева, она создает новый узел и
вставляет его в дерево.
Затем процедура InsertItem
использует восходящую рекурсию для балансировки дерева. При выходе из
рекурсивных вызовов процедуры, она движется назад по дереву. При каждом
возврате из процедуры, она устанавливает параметр has_grown, чтобы
определить, увеличилась ли высота поддерева, которое она покидает. В экземпляре
процедуры InsertItem,
который вызвал этот рекурсивный вызов, процедура использует этот параметр для
определения того, является ли проверяемое дерево несбалансированным. Если это
так, то процедура применяет для балансировки дерева соответствующее вращение.
Предположим, что
процедура в настоящий момент обращается к узлу X. Допустим, что она перед этим
обращалась к правому поддереву снизу от узла X и что параметр has_grown равен true,
означая, что правое поддерево увеличилось. Если поддеревья узла X до этого
имели одинаковую высоту, тогда правое поддерево станет теперь выше левого. В
этой точке дерево сбалансировано, но поддерево с корнем в узле X выросло, так
как выросло его правое поддерево.
Если левое
поддерево узла X вначале было выше, чем правое, то левое и правое поддеревья
теперь будут иметь одинаковую высоту. Высота поддерева с корнем в узле X не
изменилась — она по‑прежнему равна высоте левого поддерева плюс 1. В
этом случае процедура InsertItem
установит значение переменной has_grown равным false, показывая, что
дерево сбалансировано.
========162
@Рис. 7.11
Различные вращения АВЛ‑дерева
======163
В конце концов,
если правое поддерево узла X было первоначально выше левого, то вставка нового
узла делает дерево несбалансированным в узле X. Процедура InsertItem
вызывает подпрограмму RebalanceRigthGrew
для балансировки дерева. Процедура RebalanceRigthGrew
выполняет левое вращение или вращение вправо‑влево, в зависимости от
ситуации.
Если новый элемент
вставляется в левое поддерево, то подпрограмма InsertItem
выполняет аналогичную процедуру.
Public Sub InsertItem(node As AVLNode, parent
As AVLNode, _
txt As
String, has_grown As Boolean)
Dim child As AVLNode
'
Если это нижний уровень дерева, поместить
' в родителя указатель на новый узел.
If parent Is Nothing Then
Set
parent = node
parent.Balance
= BALANCED
has_grown
= True
Exit
Sub
End
If
' Продолжить с левым и правым поддеревьями.
If txt <= parent.Box.Caption Then
'
Вставить потомка в левое поддерево.
Set child = parent.LeftChild
InsertItem
node, child, txt, has_grown
Set
parent.LeftChild = child
'
Проверить, нужна ли балансировка. Она будет
' не нужна, если вставка узла не
нарушила
' балансировку дерева или оно уже было
сбалансировано
' на более глубоком уровне рекурсии. В
любом случае
' значение переменной has_grown будет
равно False.
If Not has_grown Then Exit Sub
If
parent.Balance = RIGHT_HEAVY Then
'
Перевешивала правая ветвь, теперь баланс
' восстановлен. Это поддерево не
выросло,
' поэтому дерево сбалансировано.
parent.Balance
= BALANCED
has_grown
= False
ElseIf
parent.Balance = BALANCED Then
'
Было сбалансировано, теперь перевешивает левая ветвь.
' Поддерево все еще сбалансировано,
но оно выросло,
' поэтому необходимо продолжить
проверку дерева.
parent.Balance
= LEFT_HEAVY
Else
' Перевешивала левая ветвь, осталось
несбалансировано.
' Выполнить вращение для балансировки
на уровне
' этого узла.
RebalanceLeftGrew
parent
has_grown
= False
End
If ' Закончить проверку балансировки
этого узла.
Else
' Вставить потомка в правое поддерево.
Set child = parent.RightChild
InsertItem
node, child, txt, has_grown
Set
parent.RightChild = child
'
Проверить, нужна ли балансировка. Она будет
' не нужна, если вставка узла не
нарушила
' балансировку дерева или оно уже было
сбалансировано
' на более глубоком уровне рекурсии. В
любом случае
' значение переменной has_grown будет
равно False.
If Not has_grown Then Exit Sub
If
parent.Balance = LEFT_HEAVY Then
'
Перевешивала левая ветвь, теперь баланс
' восстановлен. Это поддерево не
выросло,
' поэтому дерево сбалансировано.
parent.Balance
= BALANCED
has_grown
= False
ElseIf
parent.Balance = BALANCED Then
'
Было сбалансировано, теперь перевешивает правая
' ветвь. Поддерево все еще
сбалансировано,
' но оно выросло, поэтому необходимо
продолжить
' проверку дерева.
parent.Balance
= RIGHT_HEAVY
Else
' Перевешивала правая ветвь, осталось
несбалансировано.
' Выполнить вращение для балансировки
на уровне
' этого узла.
RebalanceRightGrew
parent
has_grown
= False
End If ' Закончить проверку балансировки этого узла.
End If ' End if для
левого поддерева else правое поддерево.
End
Sub
========165
Private Sub RebalanceRightGrew(parent As
AVLNode)
Dim child As AVLNode
Dim grandchild As AVLNode
Set
child = parent.RightChild
If
child.Balance = RIGHT_HEAVY Then
'
Выполнить левое вращение.
Set parent.RightChild =
child.LeftChild
Set
child.LeftChild = parent
parent.Balance
= BALANCED
Set
parent = child
Else
' Выполнить вращение вправо‑влево.
Set grandchild = child.LeftChild
Set
child.LeftChild = grandchild.RightChild
Set
grandchild.RightChild = child
Set
parent.RightChild = grandchild.LeftChild
Set
grandchild.LeftChild = parent
If
grandchild.Balance = RIGHT_HEAVY Then
parent.Balance
= LEFT_HEAVY
Else
parent.Balance
= BALANCED
End
If
If
grandchild.Balance = LEFT_HEAVY Then
child.Balance
= RIGHT_HEAVY
Else
child.Balance
= BALANCED
End
If
Set
parent = grandchild
End
If ' End if для правого вращения else
двойное правое
' вращение.
parent.Balance
= BALANCED
End Sub
Удаление узла из
АВЛ‑дерева
В 6 главе было
показано, что удалить элемент из упорядоченного дерева сложнее, чем вставить
его. Если удаляемый элемент имеет всего одного потомка, можно заменить его этим
потомком, сохранив при этом порядок дерева. Если у дерева два дочерних узла, то
он заменяется на самый правый узел в левой ветви дерева. Если у этого узла
существует левый потомок, то этот левый потомок также занимает его место.
======166
Так как АВЛ‑деревья
являются особым типом упорядоченных деревьев, то для них нужно выполнить те же
самые шаги. Тем не менее, после их завершения необходимо вернуться назад по
дереву, чтобы убедиться в том, что оно осталось сбалансированным. Если найдется
узел, для которого не выполняется свойство АВЛ‑деревьев, то нужно
выполнить для балансировки дерева соответствующее вращение. Хотя это те же
самые вращения, которые использовались раньше для вставки узла в дерево, они
применяются в других случаях.
Левое вращение
Предположим, что мы
удаляем узел из левого поддерева узла X. Также предположим, что правое
поддерево либо уравновешено, либо высота его правой половины на единицу больше,
чем высота левой. Тогда левое вращение, показанное на рис. 7.12, приведет к
балансировке дерева в узле X.
Нижний уровень
поддерева T2 закрашен серым цветом, чтобы показать, что поддерево TB
либо уравновешено (T2 и T3 имеют одинаковую высоту), либо
его правая половина выше (T3 выше, чем T2). Другими
словами, закрашенный уровень может существовать в поддереве T2 или
отсутствовать.
Если T2
и T3 имеют одинаковую высоту, то высота поддерева TX с
корнем в узле X не меняется после удаления узла. Высота TX при этом
остается равной высоте поддерева T2 плюс 2. Так как эта высота не
меняется, то дерево выше этого узла остается сбалансированным.
Если T3
выше, чем T2, то поддерево TX становится ниже на единицу.
В этом случае, дерево может быть несбалансированным выше узла X, поэтому
необходимо продолжить проверку дерева, чтобы определить, выполняется ли
свойство АВЛ‑деревьев для предков узла X.
Вращение вправо‑влево
Предположим теперь,
что узел удаляется из левого поддерева узла X, но левая половина правого
поддерева выше, чем правая. Тогда для балансировки дерева нужно использовать
вращение вправо‑влево, показанное на рис. 7.13.
Если левое или
правое поддеревья T2 или T3 выше, то вращение вправо‑влево
приведет к балансировке поддерева TX, и уменьшит при этом высоту TX
на единицу. Это значит, что дерево выше узла X может быть несбалансированным,
поэтому необходимо продолжить проверку выполнения свойства АВЛ‑деревьев
для предков узла X.
@Рис. 7.12. Левое
вращение при удалении узла
========167
@Рис. 7.13.
Вращение вправо‑влево при удалении узла
Другие вращения
Остальные вращения
выполняются аналогично. В этом случае удаляемый узел находится в правом
поддереве узла X. Эти четыре вращения — те же самые, которые
использовались для балансировки дерева при вставке узла, за одним исключением.
Если новый узел
вставляется в дерево, то первое выполняемое вращение осуществляет балансировку
поддерева TX, не изменяя его высоту. Это значит, что дерево выше
узла TX будет при этом оставаться сбалансированным. Если же эти
вращения используются после удаления узла из дерева, то вращение может уменьшить
высоту поддерева TX на единицу. В этом случае, нельзя быть
уверенным, что дерево выше узла X осталось сбалансированным. Нужно продолжить
проверку выполнения свойства АВЛ‑деревьев вверх по дереву.
Реализация удаления узлов на языке
Visual Basic
Подпрограмма DeleteItem
удаляет элементы из дерева. Она рекурсивно спускается по дереву в поиске
удаляемого элемента и когда она находит искомый узел, то удаляет его. Если у
этого узла нет потомков, то процедура завершается. Если есть только один
потомок, то процедура заменяет узел его потомком.
Если узел имеет
двух потомков, процедура DeleteItem
вызывает процедуру ReplaceRightMost
для замены искомого узла самым правым узлом в его левой ветви. Процедура ReplaceRightMost
выполняется примерно так же, как и процедура из 6 главы, которая удаляет
элементы из обычного (неупорядоченного) дерева. Основное отличие возникает при
возврате из процедуры и рекурсивном проходе вверх по дереву. При этом процедура
ReplaceRightMost использует восходящую
рекурсию, чтобы убедиться, что дерево остается сбалансированным для всех узлов.
При каждом возврате
из процедуры, экземпляр процедуры ReplaceRightMost
вызывает подпрограмму RebalanceRightShrunk,
чтобы убедиться, что дерево в этой точке сбалансировано. Так как процедура ReplaceRightMost
опускается по правой ветви, то она всегда использует для выполнения
балансировки подпрограмму RebalanceRightShrunk,
а не RebalanceLeftShrunk.
При первом вызове
подпрограммы ReplaceRightMost
процедура DeleteItem
направляет ее по левой от удаляемого узла ветви. При возврате из первого вызова
подпрограммы ReplaceRightMost,
процедура DeleteItem
использует подпрограмму RebalanceLeftShrunk,
чтобы убедиться, что дерево сбалансировано в этой точке.
=========168
После этого, один
за другим происходят рекурсивные возвраты из процедуры DeleteItem
при проходе дерева в обратном направлении. Так же, как и процедура ReplaceRightmost,
процедура DeleteItem
вызывает подпрограммы RebalanceRightShrunk
или RebalanceLeftShrunk в зависимости от того, по
какому пути происходит спуск по дереву.
Подпрограмма RebalanceLeftShrunk
аналогична подпрограмме RebalanceRightShrunk,
поэтому она не показана в следующем коде.
Public Sub DeleteItem(node As AVLNode, txt As
String, shrunk As Boolean)
Dim child As AVLNode
Dim target As AVLNode
If
node Is Nothing Then
Beep
MsgBox "Элемент " & txt
& " не содержится в дереве."
shrunk = False
Exit
Sub
End If
If txt
< node.Box.Caption Then
Set
child = node.LeftChild
DeleteItem
child, txt, shrunk
Set
node.LeftChild = child
If
shrunk Then RebalanceLeftShrunk node, shrunk
ElseIf
txt > node.Box.Caption Then
Set
child = node.RightChild
DeleteItem
child, txt, shrunk
Set
node.RightChild = child
If
shrunk Then RebalanceRightShrunk node, shrunk
Else
Set
target = node
If
target.RightChild Is Nothing Then
'
Потомков нет или есть только правый.
Set node =
target.LeftChild
shrunk
= True
ElseIf
target.LeftChild Is Nothing Then
'
Есть только правый потомок.
Set node =
target.RightChild
shrunk
= True
Else
' Есть два потомка.
Set child = target.LeftChild
ReplaceRightmost
child, shrunk, target
Set
target.LeftChild = child
If
shrunk Then RebalanceLeftShrunk node, shrunk
End
If
End If
End Sub
Private Sub ReplaceRightmost(repl As AVLNode,
shrunk As Boolean, target As AVLNode)
Dim child As AVLNode
If
repl.RightChild Is Nothing Then
target.Box.Caption
= repl.Box.Caption
Set target =
repl
Set repl =
repl.LeftChild
shrunk = True
Else
Set child =
repl.RightChild
ReplaceRightmost
child, shrunk, target
Set
repl.RightChild = child
If shrunk Then
RebalanceRightShrunk repl, shrunk
End If
End Sub
Private Sub RebalanceRightShrunk(node As
AVLNode, shrunk As Boolean)
Dim child As AVLNode
Dim child_bal As Integer
Dim grandchild As AVLNode
Dim grandchild_bal As Integer
If node.Balance
= RIGHT_HEAVY Then
'
Правая часть перевешивала, теперь баланс восстановлен.
node.Balance = BALANCED
ElseIf
node.Balance = BALANCED Then
'
Было сбалансировано, теперь перевешивает левая часть.
node.Balance = LEFT_HEAVY
shrunk
= False
Else
' Левая часть перевешивала, теперь не
сбалансировано.
Set child = node.LeftChild
child_bal
= child.Balance
If
child_bal <= 0 Then
'
Правое вращение.
Set
node.LeftChild = child.RightChild
Set
child.RightChild = node
If
child_bal = BALANCED Then
node.Balance
= LEFT_HEAVY
child.Balance
= RIGHT_HEAVY
shrunk
= False
Else
node.Balance
= BALANCED
child.Balance
= BALANCED
End
If
Set
node = child
Else
'
Вращение влево‑вправо.
Set grandchild
= child.RightChild
grandchild_bal
= grandchild.Balance
Set
child.RightChild = grandchild.LeftChild
Set
grandchild.LeftChild = child
Set
node.LeftChild = grandchild.RightChild
Set
grandchild.RightChild = node
If
grandchild_bal = LEFT_HEAVY Then
node.Balance
= RIGHT_HEAVY
Else
node.Balance = BALANCED
End
If
If
grandchild_bal = RIGHT_HEAVY Then
child.Balance
= LEFT_HEAVY
Else
child.Balance
= BALANCED
End
If
Set
node = grandchild
grandchild.Balance
= BALANCED
End
If
End If
End Sub
Программа AVL
оперирует АВЛ‑деревом. Введите текст и нажмите на кнопку Add, чтобы добавить
элемент к дереву. Введите значение, и нажмите на кнопку Remove, чтобы удалить
этот элемент из дерева. На рис. 7.14 показана программа AVL.
Б‑деревья
XE
"Деревья:Б-деревья" Б‑деревья (B‑trees XE "tree:B-tree"
) являются другой формой сбалансированных
деревьев, немного более наглядной, чем АВЛ‑деревья. Каждый узел в Б‑дереве
может содержать несколько ключей данных и несколько указателей на дочерние
узлы. Поскольку каждый узел содержит несколько элементов, такие узлы иногда
называются блоками.
=======171
@Рис. 7.14.
Программа AVL
Между каждой парой
соседних указателей находится ключ, который можно использовать для определения
ветви, по которой нужно следовать при вставке или поиске элемента. Например, в
дереве, показанном на рис. 7.15, корневой узел содержит два ключа: G и R. Чтобы
найти элемент со значением, которое идет перед G, нужно искать в первой ветви.
Чтобы найти элемент, имеющий значение между G и R, проверяется вторая ветвь.
Чтобы найти элемент, который следует за R, выбирается третья ветвь.
Б‑дерево
порядка K обладает следующими свойствами:
·
·
·
·
Б‑дерево на
рис. 7.15 имеет 2 порядок. Каждый узел может иметь до 4 ключей. Каждый узел,
кроме может быть корневого, должен иметь не менее двух ключей. Для удобства,
узлы Б‑дерева обычно имеют четное число ключей, поэтому порядок дерева
обычно является целым числом.
Выполнение
требования, чтобы каждый узел Бдерева порядка K содержал от K до
2 * K ключей, поддерживает дерево сбалансированным. Так как каждый
узел должен иметь не менее K ключей, он должен при этом иметь не менее
K + 1 дочерних узлов, поэтому дерево не может стать слишком высоким и
тонким. Наибольшая высота Б‑дерева, содержащего N узлов, может быть равна
O(logK+1(N)). Это означает, что сложность алгоритма поиска в таком
дереве порядка O(log(N)). Хотя это и не так
очевидно, операции вставки и удаления элемента из Б‑дерева также имеют
сложность порядка O(log(N)).
@Рис. 7.15. Б‑дерево
=======172
Производительность
Б‑деревьев
Применение Б‑деревьев
особенно полезно при разработке больших приложений, работающих с базами данных.
При достаточно большом порядке Б‑дерева, любой элемент в дереве можно
найти после проверки всего нескольких узлов. Например, высота Б‑дерева 10
порядка, содержащего миллион записей, не может быть больше log11(1.000.000),
или выше шести уровней. Чтобы найти определенный элемент, потребуется проверить
не более шести узлов.
Сбалансированное
двоичное дерево с миллионом элементов имело бы высоту log2(1.000.000),
или около 20. Тем не менее, узлы двоичного дерева содержат всего по одному
ключевому значению. Для поиска элемента в двоичном дереве, пришлось бы
проверить 20 узлов и 20 значений. Для поиска элемента в Б‑дереве пришлось
бы проверить 5 узлов и 100 ключей.
Применение Б‑деревьев
может обеспечить более высокую скорость работы, если проверка ключей
выполняется относительно просто, в отличие от проверки узлов. Например, если
база данных находится на диске, чтение данных с диска может происходить
достаточно медленно. Когда же данные находятся в памяти, их проверка может
происходить очень быстро.
Чтение данных с
диска происходит большими блоками, и считывание целого блока занимает столько
же времени, сколько и чтение одного байта. Если узлы Б‑дерева не слишком
велики, то чтение узла Б‑дерева с диска займет не больше времени, чем
чтение узла двоичного дерева. В этом случае, для поиска 5 узлов в Б‑дереве
потребуется выполнить 5 медленных обращений к диску, плюс 100 быстрых обращений
к памяти. Поиск 20 узлов в двоичном дереве потребует 20 медленных обращений к
диску и 20 быстрых обращений к памяти, при этом поиск в двоичном дереве будет
более медленным, поскольку время, затраченное на 15 лишних обращений к диску
будет намного больше, чем сэкономленное время 80 обращений к памяти. Вопросы,
связанные с обращением к диску, позднее обсуждаются в этой главе более
подробно.
Вставка элементов в
Б‑дерево
Чтобы вставить
новый элемент в Б‑дерево, найдем лист, в который он должен быть помещен.
Если этот узел содержит менее, чем 2 * K ключей, то в этом узле
остается место для добавления нового элемента. Вставим новый узел на место так,
чтобы порядок элементов внутри узла не нарушился.
Если узел уже
содержит 2 * K элементов, то места для нового элемента в узле уже не
остается. Разобьем тогда узел на два новых узла, поместив в каждый из них K
элементов в правильном порядке. Затем средний элемент переместим в родительский
узел.
Например,
предположим, что мы хотим поместить новый элемент Q в Б‑дерево,
показанное на рис. 7.15. Этот новый элемент должен находиться во втором листе,
который уже заполнен. Для разбиения этого узла, разделим элементы J, K, L, N и Q между двумя новыми узлами.
Поместим элементы J и K в левый узел, а элементы N и Q — в правый. Затем
переместим средний элемент, L[RV13]
в родительский узел. На рис. 7.16 показано новое дерево.
@Рис. 7.16. Б‑дерево
после вставки элемента Q
=========173
Разбиение узла на
два называется разбиением блока.
Когда оно происходит, к родительскому узлу добавляется новый ключ и новый
указатель. Если родительский узел уже заполнен, то это также может привести к
его разбиению. Это, в свою очередь, потребует добавления новой записи на более
высоком уровне и так далее. В наихудшем случае, вставка элемента вызовет
«цепную реакцию», которая приведет к изменениям на всех вышележащих уровнях
вплоть до разбиения корневого узла.
Когда происходит
разбиение корневого узла, Б‑дерево становится выше. Это единственный
случай, при котором его высота увеличивается. Поэтому Б‑деревья обладают
необычным свойством — они всегда растут от листьев к корню.
Удаление элементов
из Б‑дерева
Теоретически,
удалить узел из Б‑дерева так же просто, как и вставить его. На практике,
детали этого процесса достаточно сложны.
Если удаляемый узел
не является листом, то его нужно заменить другим элементом, чтобы сохранить
порядок элементов. Это похоже на случай удалений элемента из упорядоченного
дерева или АВЛ‑дерева и его можно обрабатывать аналогично. Заменим
элемент самым крайним правым элементом из левой ветви. Этот элемент всегда
будет листом. После замены элемента, можно просто считать, что вместо него
просто удален заменивший его лист.
Чтобы удалить
элемент из листа, вначале нужно при необходимости сдвинуть все другие элементы
влево, чтобы заполнить образовавшееся пространство. Помните, что каждый узел в
Б‑дереве порядка K должен иметь от K до 2 * K элементов. После
удаления элемента из листа, может оказаться, что он содержит всего
K - 1 элементов.
В этом случае,
можно попробовать взять несколько элементов из узлов на том же уровне. Затем
можно распределить элементы в двух узлах так, чтобы они оба имели не меньше K
элементов. На рис. 7.17 элемент удаляется из самого левого листа дерева, при
этом в нем остается всего один элемент. После перераспределения элементов между
узлом и правым узлом на том же уровне, оба узла имеют не меньше двух ключей.
Заметьте, что средний элемент J перемещается в родительский узел.
@Рис. 7.17.
Балансировка после удаления элемента
=======174
@Рис. 7.18. Слияние
после удаления элемента
При попытке
сбалансировать дерево таким образом, может оказаться, что соседний узел на том
же уровне содержит всего K элементов. Тогда два узла вместе содержат всего
2 * K - 1 элементов, что недостаточно для заполнения двух
узлов. В этом случае, все элементы из обоих узлов могут поместиться в одном
узле, поэтому их можно слить. Удалим ключ, который отделяет два узла от
родителя. Поместим этот элемент и 2 * K - 1 элементов из
двух узлов в один общий узел. Этот процесс называется слиянием узлов (bucket merge или bucket join). На рис. 7.18 показано слияние двух узлов.
При слиянии двух
узлов, из родительского узла удаляется ключ, при этом в родительском узле может
остаться K - 1 элементов. В этом случае, может потребоваться
балансировка или слияние родителя с одним из узлов на его уровне. Это также может
привести к тому, что в узле на более высоком уровне также останется
K - 1 элементов, и процесс повторится. В наихудшем случае, удаление
приведет к «цепной реакции» слияний блоков, которая может дойти до корневого
узла.
При удалении
последнего элемента из корневого узла, два его оставшихся дочерних узла
сливаются, образуя новый корень, и дерево при этом становится короче на один
уровень. Единственный способ уменьшения высоты Б‑дерева — слияние
двух дочерних узлов корня и образование нового корня.
Программа Btree
позволяет вам оперировать Б‑деревом. Введите текст, и нажмите на кнопку Add, чтобы добавить
элемент в дерево. Для удаления элемента введите его значение и нажмите на
кнопку Remove. На
рис. 7.19 показано окно программы Btree с Б‑деревом
2 порядка.
@Рис. 7.19.
Программа Btree
========175
Разновидности Б‑деревьев
Существует
несколько разновидностей Б‑деревьев, из которых здесь описаны только
некоторые. XE "Деревья:нисходящие Б-деревья" Нисходящие
Б‑деревья (top‑down B‑trees XE "tree:top-down
B-tree" ) немного иначе управляют структурой Б‑дерева.
За счет разбиения встречающихся полных узлов, эта разновидность алгоритма
использует при вставке элементов более наглядную нисходящую рекурсию вместо
восходящей. Эта также уменьшает вероятность возникновения длительной
последовательности разбиений блоков.
Другой
разновидностью Б‑деревьев являются XE
"Деревья:Б+деревья" Б+деревья (B+trees XE "tree:B+tree"
). В Б+деревьях внутренние узлы содержат
только ключи данных, а сами записи находятся в листьях. Это позволяет
Б+деревьям хранить в каждом блоке больше элементов, поэтому такие деревья
короче, чем соответствующие Б‑деревья.
Нисходящие Б‑деревья
Подпрограмма,
которая добавляет новый элемент в Б‑дерево, вначале выполняет рекурсивный
поиск по дереву, чтобы найти блок, в который его нужно поместить. Когда она
пытается вставить новый элемент на его место, ей может понадобиться разбить
блок и переместить один из элементов узла в его родительский узел.
При возврате из
рекурсивных вызовов процедуры, вызывающая процедура проверяет, требуется ли
разбиение родительского узла. Если да, то элемент помещается в родительский
узел. При каждом возврате из рекурсивного вызова, вызывающая процедура должна
проверять, не требуется ли разбиение следующего предка. Так как эти разбиения
блоков происходят при возврате из рекурсивных вызовов процедура, это восходящая
рекурсия, поэтому иногда Б‑деревья, которыми манипулируют таким образом,
называются восходящими Б‑деревьями
(bottom‑up B‑trees XE "tree:bottom-up
B-trees" ).
Другая стратегия
состоит в том, чтобы разбивать все полные узлы, которые встречаются процедуре
на пути вниз по дереву. При поиске блока, в который нужно поместить новый
элемент, процедура разбивает все повстречавшиеся полные узлы. При каждом
разбиении узла, она помещает один из его элементов в родительский узел. Так как
она уже разбила все выше расположенные полные узлы, то в родительском узле
всегда есть место для нового элемента.
Когда процедура
доходит до листа, в который нужно поместить элемент, то в его родительском узле
всегда есть свободное место, и если программе нужно разбить лист, то всегда
можно поместить средний элемент в родительский узел. Так как при этом процедура
работает с деревом сверху вниз, Б‑деревья такого типа иногда называются нисходящими Б‑деревьями (top‑down B‑trees).
При этом разбиение
блоков происходит чаще, чем это абсолютно необходимо. В нисходящем Б‑дереве
полный узел разбивается, даже если в его дочерних узлах достаточно много
свободного места. За счет предварительного разбиения узлов, при использовании
нисходящего метода в дереве содержится больше пустого пространства, чем в
восходящем Б‑дереве. С другой стороны, такой подход уменьшает вероятность
возникновения длительной последовательности разбиений блоков.
К сожалению, не
существует нисходящей версии для слияния узлов. При продвижении вниз по дереву,
процедура удаления узлов не может объединять встречающиеся наполовину пустые
узлы, потому что в этот момент еще неизвестно, нужно ли будет объединить два
дочерних узла и удалить элемент из их родителя. Так как неизвестно также, будет
ли удален элемент из родительского узла, то нельзя заранее сказать, потребуется
ли слияние родителя с одним из узлов, находящимся на том же уровне.
==========176
Б+деревья
Б+деревья часто
используются для хранения больших записей. Типичное Б‑дерево может
содержать записи о сотрудниках, каждая из которых может занимать несколько
килобайт памяти. Записи могли бы располагаться в Б‑дереве в соответствии
с ключевым полем, например фамилией сотрудника или его идентификационным
номером.
В этом случае
упорядочение элементов может быть достаточно медленным. Чтобы слить два блока,
программе может понадобиться переместить множество записей, каждая из которых
может быть достаточно большой. Аналогично, для разбиения блока может
потребоваться переместить множество записей большого объема.
Чтобы избежать
перемещения больших блоков данных, программа может записывать во внутренних
узлах Б‑дерева только ключи. При этом узлы также содержат ссылки на сами
записи данных, которые записаны в другом месте. Теперь, если программе
требуется переупорядочить блоки, то нужно переместить только ключи и указатели,
а не сами записи. Этот тип Б‑дерева называется XE
"Деревья:Б+деревья" Б+деревом (B+tree).
То, что элементы в
Б+дереве достаточно малы, также позволяет программе хранить больше ключей в
каждом узле. При том же размере узла, программа может увеличить порядок дерева
и сделать его более коротким.
Например,
предположим, что имеется Б‑дерево 2 порядка, то есть каждый узел имеет от
трех до пяти дочерних узлов. Такое дерево, содержащее миллион записей, должно
было бы иметь высоту между log5(1.000.000) и log3(1.000.000),
или между 9 и 13. Чтобы найти элемент в таком дереве, программа должна
выполнить от 9 до 13 обращений к диску.
Теперь допустим,
что те же миллион записей находятся в Б+дереве, узлы которого имеют примерно
тот же размер в байтах. Поскольку в узлах Б+дерева содержатся только ключи, то
в каждом узле дерева может храниться до 20 ключей к записям. В этом случае,
каждый узел будет иметь от 11 до 21 дочерних узлов, поэтому высота дерева будет
от log21(1.000.000) до log11(1.000.000), или между 5 и 6.
Чтобы найти элемент, программе понадобится всего 6 обращений к диску для
нахождения его ключа, и еще одно обращение к диску, чтобы считать сам элемент.
В Б+деревьях также
просто связать с набором записей множество ключей. В системе, оперирующей
записями о сотрудниках, одно Б+дерево может использовать в качестве ключей
фамилии, а другое — идентификационные номера социального страхования. Оба
дерева будут содержать указатели на записи данных, которые будут находиться за
пределами деревьев.
Улучшение
производительности Б‑деревьев
В этом разделе
описаны два метода улучшения производительности Б‑ и Б+деревьев. Первый
метод позволяет перераспределить элементы между узлами одного уровня, чтобы
избежать разбиения блоков. Второй позволяет помещать пустые ячейки в дерево,
чтобы уменьшить вероятность необходимости разбиения блоков в будущем.
=======177
Балансировка для
устранения разбиения блоков
При добавлении
элемента к блоку, который уже заполнен, блок разбивается на два. Этого можно
избежать, если выполнить балансировку этого узла с одним из узлов на том же
уровне. Например, вставка нового элемента Q в Б‑дерево, показанное слева
на рис. 7.20 обычно вызывает разбиение блока. Этого можно избежать, выполнив
балансировку узла, содержащего J, K, L и N и левого узла на том же уровне,
содержащего B и E. При этом получается дерево, показанное на рис. 7.20 справа.
Такая балансировка
имеет ряд преимуществ. Во‑первых, при этом блоки используются более
эффективно. В них находится меньше пустых ячеек, при этом уменьшится количество
расходуемой понапрасну памяти.
Что более важно,
если не нужно будет разбиение блоков, то не понадобится и перемещение элемента
в родительский узел. Это предотвращает возникновение длительной
последовательности разбиений блоков.
С другой стороны,
уменьшение числа неиспользуемых элементов в дереве увеличивает вероятность
необходимости разбиения блоков в будущем. Так как в дереве остается меньше
свободных ячеек, то более вероятно, что узел окажется уже полон, когда
понадобится вставить новый элемент.
Добавление свободного пространства
Предположим, что
имеется небольшая база данных клиентов, содержащая 10 записей. Можно загружать
записи в Б‑дерево так, чтобы они заполняли каждый блок целиком, как
показано на рис. 7.21. При этом дерево содержит мало свободного пространства, и
вставка нового элемента сразу же приводит к разбиению блоков. Фактически, так
как все блоки заполнены, она вызовет последовательность разбиения блоков,
которая дойдет до корневого узла.
Вместо плотного
заполнения дерева, можно добавлять к каждому узлу некоторое количество пустых
ячеек, как показано на рис. 7.22. Хотя при этом дерево будет несколько больше,
в него можно будет добавлять элементы, не вызывая сразу же последовательность
разбиений блоков. После работы с деревом в течение некоторого времени,
количество свободного пространства может уменьшиться до такой степени, при
которой разбиения блоков могут возникнуть. Тогда можно перестроить дерево,
добавив больше свободного пространства.
В реальных
приложениях Б‑деревья обычно имеют намного больший порядок, чем деревья,
приведенные здесь. Добавление свободного пространства в дерево значительно
уменьшает необходимость балансировки и разбиения блоков. Например, можно
добавить в Б‑дерево 10 порядка 10 процентов свободного пространства,
чтобы в каждом узле было место еще для двух элементов. С таким деревом можно
будет работать достаточно долго, прежде чем возникнут длинные цепочки разбиений
блоков.
Это очередной
пример пространственно‑временного компромисса. Добавка в узлы пустого
пространства увеличивает размер дерева, но уменьшает вероятность разбиения
блоков.
@Рис. 7.20.
Балансировка для устранения разбиения блоков
=======178
@Рис. 7.21. Плотное
заполнение Б‑дерева
Вопросы, связанные
с обращением к диску
Б‑ и
Б+деревья хорошо подходят для создания больших приложений баз данных. Типичное
Б+дерево может содержать сотни, тысячи и даже миллионы записей. В этом случае в
любой момент времени в памяти будет находиться только небольшая часть дерева и
при каждом обращении к узлу, программе понадобится загрузить его с диска. В
этом разделе описаны три момента, учитывать которые особенно важно, если данные
находятся на диске: применение псевдоуказателей, выбор размера блоков, и
кэширование корневого узла.
Псевдоуказатели
Коллекции и ссылки
на объекты удобны для построения деревьев в памяти, но они могут быть
бесполезны при хранении дерева на диске. Нельзя создать ссылку на запись в
файле.
Вместо этого можно
использовать методы работы с псевдоуказателями, похожие на те, которые были
описаны во 2 главе. Вместо использования в качестве указателей на узлы дерева
ссылок на объекты при этом используется номер записи узла в файле. Предположим,
что Б+дерево 12 порядка использует 80‑байтные ключи. Структуру данных
узла можно определить в следующем коде:
Global Const ORDER = 12
Global Const KEYS_PER_NODE = 2 * ORDER
Type BtreeNode
Key (1
To KEYS_PER_NODE) As String * 80 ' Ключи.
Child
(0 To KEYS_PER_NODE) As Integer ' Указатели потомков.
End Type
Значения элементов
массива Child представляют
собой номера записей из дочерних узлов в файле. Произвольный доступ к данным
Б+дерева из файла осуществляется при помощи записей, которые соответствуют
структуре BtreeNode.
@Рис. 7.22.
Свободное заполнение Б‑дерева
======179
Dim node As BtreeNode
Open
Filename For Random As #filenum Len = Len(node)
После открытия
файла, при помощи оператора Get можно выбрать любую запись:
Dim node As BtreeNode
'
Выбрать запись с номером recnum.
Get #filenum, recnum, node
Чтобы упростить
работу с Б+деревьями, можно хранить узлы Б+дерева и записи данных в разных
файлах и использовать для управления каждым из них псевдоуказатели.
Когда счетчик
ссылок на объект становится равным нулю, то Visual Basic автоматически
уничтожает его. Это облегчает работу со структурами данных в памяти. С другой
стороны, если программе больше не нужна какая‑либо запись в файле, то она
не может просто очистить все ссылки на нее. Если сделать так, то программа
больше не сможет использовать эту запись, но запись по‑прежнему будет
занимать место в файле.
Программа должна
следить за неиспользуемыми записями, чтобы позднее можно было использовать их.
Один из простых способов сделать это — вести связный список неиспользуемых
записей. Если запись больше не нужна, она добавляется в список. Если программе
нужно место для новой записи, она удаляет одну запись из списка. Если программе
нужно вставить еще один элемент, а список пуст, она увеличивает файл данных.
Выбор размера блока
Чтение данных с
диска происходит блоками, которые называются кластерами. Размер кластера обычно
составляет 512 или 1024 байта, или еще какое‑либо число байтов, равное
степени двойки. Чтение всего кластера занимает столько же времени, сколько и
чтение одного байта.
Можно
воспользоваться этим фактом и создавать блоки, размер которых составляет целое
число кластеров, а затем уместить в этот размер максимальное число ключей или
записей. Например, предположим, что мы решили создавать блоки размером 2048
байт. При создании Б+дерева с 80‑байтными ключами в каждый блок можно
поместить 24 ключа и 25 указателей (если указатель представляет собой 4‑байтное
число типа long). Затем можно создать Б+дерево 12 порядка с
блоками, которые определяются в следующем коде:
Global Const ORDER = 12
Global Const KEYS_PER_NODE = 2 * ORDER
Type BtreeNode
Key(1
To KEYS_PER_NODE) As String * 80 ' Ключ данных.
Child(0
To KEYS_PER_NODE) As Integer ' Указатели потомков.
End Type
=======180
Для того, чтобы
считывать данные максимально быстро, программа должна использовать оператор Visual Basic Get для чтения узла целиком. Если использовать
цикл For
для чтения ключей и данных для каждого элемента по очереди, то программе
придется обращаться к диску при чтении каждого элемента. Это намного медленнее,
чем считывание всего узла сразу. В одном из тестов, для массива из 1000
элементов определенного пользователем типа чтение элементов по одиночке заняло
в 27 раз больше времени, чем чтение их всех сразу. Следующий код демонстрирует
оба способа чтения данных из узла:
Dim i As Integer
Dim node As BtreeNode
'
Медленный способ доступа к данным.
For i = 1 To KEYS_PER_NODE
Get
#filenum, , node.Key(i)
Next
i
' Быстрый способ доступа к данным.
Get #filenum, , node
Кэширование узлов
Каждый поиск в Б‑дереве
начинается с корневого узла. Можно ускорить поиск, если корневой узел будет все
время находиться в памяти. Тогда во время поиска придется на один раз меньше
обращаться к диску. При этом все равно необходимо записывать корневой узел на
диск при каждом его изменении, иначе при повторной загрузке после отказа
программы изменения в Б‑дереве будут потеряны.
Можно также
кэшировать в памяти и другие узлы Б‑дерева. Если хранить в памяти все
дочерние узлы корня, то их также не потребуется считывать с диска. Для Б‑дерева
порядка K, корневой узел будет иметь от 1 до 2 * K ключей и поэтому у
него будет от 2 до 2 * K + 1 дочерних узлов. Это значит,
что в этом случае придется кэшировать до 2 * K + 1 узлов.
Программа также может
кэшировать узлы при обходе Б‑дерева. Например, при прямом обходе
программа обращается к каждому узлу и затем рекурсивно обходит все его дочерние
узлы. При этом она вначале спускается к первому дочернему узлу, а после
возврата переходит к следующему. При каждом возврате, программа должна снова
обратиться к родительскому узлу, чтобы определить, к какому из дочерних узлов
обращаться в следующую очередь. Кэшируя родительский узел в памяти, программа
избегает необходимости снова считывать его с диска.
Применение рекурсии
позволяет программе автоматически сохранять узлы в памяти без использования
сложной схемы кэширования. При каждом вызове рекурсивного алгоритма обхода,
определяется локальная переменная, в которой находится узел до тех пор, пока он
не понадобится. При возврате из рекурсивного вызова Visual Basic
автоматически освобождает эту переменную. Следующий код демонстрирует, как
можно реализовать этот алгоритм обхода на языке Visual Basic.
=======181
Private Sub PreorderPrint(node_index As
Integer)
Dim i As Integer
Dim node As BtreeNode
Get
#filenum, node_index, node ' Кэшировать узел.
Print
node_index ' Обращение к узлу.
For i
= 0 To KEYS_PER_NODE
If
node.Child(i) < 0 Then Exit For ' Вызов потомков.
PreorderPrint
node.Child(i) ' Вызов потомка.
Next i
End Sub
База данных на
основе Б+дерева
Программа Bplus
работает с базой данных на основе Б+дерева, используя два файла данных. Файл Custs.DAT содержит
записи с данными о клиентах, а файл Custs.IDX — узлы
Б+дерева.
Чтобы добавить
новую запись в базу данных, введите данные в поле Customer Record (Запись о
клиенте), и затем нажмите на кнопку Add. Для поиска записи заполните поля Last Name (Фамилия) и First Name (Имя) в верхней
части формы и нажмите на кнопку Find (Найти).
На рис. 7.23
показано окно программы после выполнения поиска записи для Рода Стивенса.
Статистика внизу показывает, что данные были найдены в записи номер 302 после
всего лишь трех обращений к диску. Высота Б+дерева в программе равна 3, и оно
содержит 1303 записей данных и 118 блоков.
Когда вы вводите
запись или проводите поиск, программа Bplus выбирает эту
запись из файла. После нажатия на кнопку Remove программа удаляет запись из базы данных.
@Рис. 7.23.
Программа Bplus
========182
Если выбрать в меню
Display
(Показать) команду Internal Nodes (Внутренние узлы),
то программа выведет список внутренних узлов дерева. Она также выводит рядом с
каждым узлом ключи, чтобы показать внутреннюю структуру дерева.
При помощи команды Complete Tree (Все дерево) из меню
Display
можно вывести структуру дерева целиком. Данные о клиентах выводятся внутри
пунктирных скобок.
Кроме обычных полей
адреса и фамилии, программа Bplus также
включает поле NextGarbage,
которое программа использует для работы со связным списком неиспользуемых в
файле записей.
Type CustRecord
LastName
As String * 20
FirstName
As String * 20
Address
As String * 40
City
As String * 20
State
As String * 2
Zip As
String * 10
Phone
As String * 12
NextGarbage
As Long
End Type
' Размер записи
данных о клиенте.
Global Const CUST_SIZE = 20 + 20 + 40 + 20 + 2
+ 10 + 12 + 4
Внутренние узлы
Б+дерева содержат ключи, которые используются для поиска данных о клиенте.
Ключом для записи является фамилия клиента, дополненная в конце пробелами до 20
символов и заканчивающаяся запятой, за которой следует имя клиента, дополненное
пробелами до 20 символов. Например, "Washington..........,George..............". При этом полная длина ключа составляет 41 символ.
Каждый внутренний
узел также содержит указатели на дочерние узлы. Эти указатели определяют
положение записей с данными о клиенте в файле Custs.DAT. Узлы также
включают переменную NumKeys, которая
содержит число используемых ключей.
Программа читает и
пишет данные блоками примерно по 1024 байта. Если предположить, что блок
содержит K ключей, то в каждом блоке будет K ключей длиной 41 байт,
K + 1 указателей на дочерние узлы длиной по 4 байта, и двухбайтное
целое число NumKeys. При этом
блоки должны иметь максимально возможный размер и быть не больше 1024 байт.
Решив уравнение
41 * K + 4 * (K + 1) + 2 <= 1.024, получим K <=
22,62, поэтому K должно быть равно 22. В этом случае Б+дерево должно иметь 11
порядок, поэтому оно содержит по 22 ключа в каждом блоке. Каждый блок занимает
41 * 22 + 4 * (22 + 1) + 2 = 996 байт. Следующий код
демонстрирует определение блоков в программе Bplus.
=======183
Const KEY_SIZE = 41
Const ORDER = 11
Global Const KEYS_PER_NODE = 2 * ORDER
Type Bucket
NumKeys
As Integer
Key(1
To KEYS_PER_NODE) As String * KEY_SIZE
Child(0
To KEYS_PER_NODE) As Long
End Type
Global Const BUCKET_SIZE = 2 + _
KEYS_PER_NODE
* KEY_SIZE + _
(KEYS_PER_NODE
+ 1) * 4
Программа Bplus
записывает блоки Б+дерева в файле Custs.IDX. Первая запись в
этом файле содержит заголовок, который описывает текущее состояние Б+дерева. В
заголовок входит указатель на корневой узел, текущая высота дерева, указатель
на первый пустой блок в файле Custs.IDX, и указатель
на первый пустой блок в файле Custs.DAT.
Чтобы упростить
чтение и запись заголовка, можно определить еще одну структуру, которая имеет в
точности такой же размер, что и блоки данных, но содержит поля заголовка.
Последнее поле в определении — это строка, которая заполняет конец
структуры, чтобы ее размер был точно равен размеру блока.
Global Const HEADER_PADDING = _
BUCKET_SIZE
- (7 * 4 + 2)
Type HeaderRecord
NumBuckets
As Long
NumRecords
As Long
Root
As Long
NextTreeRecord
As Long
NextCustRecord
As Long
FirstTreeGarbage
As Long
FirstCustGarbage
As Long
Height
As Integer
Padding
As String * HEADER_PADDING
End Type
При запуске программы
она запрашивает директорию, в которой находятся данные, и затем открывает файлы
Custs.DAT файлы Custs.IDX в этой
директории. Если эти файлы не существуют, то программа их создает. В противном
случае, она считывает заголовок с информацией о дереве из файла Custs.IDX. Затем она
считывает корневой узел Б+дерева и кэширует его в памяти.
Спускаясь по дереву
при вставке или удалении элемента, программа кэширует элементы, к которым она
обращается. При рекурсивном возврате эти узлы могут понадобиться снова, если
происходило разбиение, слияние или другое переупорядочение узлов. Так как
программа кэширует узлы на пути сверху вниз, они будут доступны при возвращении
обратно.
Увеличение размера
блоков позволяет сделать Б+деревья более эффективными, но при этом тестировать
их вручную будет сложнее. Чтобы высота Б+дерева 11 порядка стала равна 2,
необходимо добавить к базе данных 23 элемента. Чтобы увеличить высоту дерева до
3 уровня, необходимо добавить более 250 дополнительных элементов.
=======184
Чтобы было проще
тестировать программу Bplus, вы можете
захотеть уменьшить порядок Б+дерева до 2. Для этого закомментируйте в файле Bplus.BAS строку,
которая определяет 11 порядок, и уберите комментарий из строки, которая задает
2 порядок:
'Const
ORDER = 11
Const
ORDER = 2
Команда Create Data (Создать данные) в
меню Data
(Данные) позволяет быстро создать множество записей данных. Введите число
записей, которые вы хотите создать, и число, которое программа должна
использовать для создания первого элемента. Затем программа создаст записи и
вставит их в Б+дерево. Например, если задать в программе создание 100 записей,
начиная со значения 200, то программа создаст записи 200, 201, … 299, которые
будут выглядеть так:
FirstName: First
0000200
LastName: Last
0000200
Address: Addr
0000200
Cuty: City
0000200
Резюме
Применение
сбалансированных деревьев в программе позволяет эффективно работать с данными.
Для записи больших баз данных на дисках или других относительно медленных
запоминающих устройствах особенно удобны Б+деревья высокого порядка. Более
того, можно использовать несколько Б+деревьев для создания нескольких индексов
одного и того же большого набора данных.
В главе 11 описана
альтернатива сбалансированным деревьям. Хеширование в некоторых случаях
позволяет добиться еще более быстрого доступа к данным, хотя оно и не позволяет
выполнять такие операции, как последовательный вывод записей.
========185
Глава 8. Деревья
решений
Многие сложные
реальные задачи можно смоделировать при помощи XE
"Деревья:решений" деревьев
решений (decision trees XE "decision tree" ). Каждый узел дерева представляет один шаг решения задачи.
Каждая ветвь в дереве представляет решение, которое ведет к более полному
решению. Листья представляют собой окончательное решение. Цель заключается в
том, чтобы найти «наилучший» путь от корня к листу при выполнении определенных
условий. Эти условия и значение понятия «наилучший» для пути зависит от задачи.
Деревья решений
обычно имеют громадный размер. Дерево решений для игры в крестики‑нолики
содержит более полумиллиона узлов. Эта игра довольно проста, и многие реальные
задачи намного более сложны. Соответствующие им деревья решений могли бы
содержать больше узлов, чем число атомов во вселенной.
В этой главе
обсуждаются методы, которые можно использовать для поиска в таких огромных
деревьях. Во‑первых, в ней вначале рассматриваются деревья игры (game trees XE "game tree" ). На примере игры в крестики‑нолики обсуждаются способы
поиска в деревьях игры для нахождения наилучшего возможного хода.
В следующих
разделах описываются способы поиска в более общих деревьях решений. Для самых
маленьких деревьев, можно использовать XE
"Метод:полного перебора" метод полного перебора
(exhaustive searching XE "exhaustive search" ) всех возможных решений. Для деревьев большего размера, можно
использовать XE "Метод:ветвей и границ" метод
ветвей и границ (branch‑and‑bound technique XE "branch‑and‑bound technique" ) позволяет найти наилучшее решение без
необходимости выполнять поиск по всему дереву.
Для очень больших
деревьев нужно использовать XE
"Метод:эвристический" эвристический метод
или эвристику (heuristic XE "heuristic"
). При этом полученное решение может быть не
наилучшим из возможных решений, но оно, тем не менее, лежит достаточно близко к
наилучшему, чтобы его можно было использовать. Используя эвристики, можно
проводить поиск практически в любых деревьях решений.
В конце этой главы
обсуждаются некоторые очень сложные задачи, которые вы можете попытаться решить
при помощи метода ветвей и границ или эвристического метода. Многие из этих
задач имеют важные применения, и нахождение хороших решений для них крайне
необходимо.
Поиск в
деревьях игры
Стратегию
настольных игр, таких как шахматы, шашки, или крестики‑нолики можно
смоделировать при помощи XE
"Деревья:игры" деревьев
игры. Если в какой то момент игры существует 30 возможных ходов, то
соответствующий узел в дереве игры будет иметь 30 ветвей.
========187
Например, для игры
в крестики‑нолики корневой узел соответствует начальной позиции, при которой
доска пуста. Первый игрок может поместить крестик в любую из девяти клеток
доски. Каждому из этих девяти возможных ходов соответствует выходящая из корня
ветвь. Девять узлов на конце эти ветвей соответствуют девяти различным позициям
после первого хода игрока.
После того, как
первый игрок сделал ход, второй может поставить нолик в любую из оставшихся
восьми клеток. Каждому из этих ходов соответствует ветвь, выходящая из узла,
соответствующего текущей позиции игры. На рис. 8.1 показан небольшой фрагмент
дерева игры в крестики‑нолики.
Как можно увидеть
на рис. 8.1, дерево игры в крестики‑нолики растет очень быстро. Если оно
продолжит расти таким образом, так что каждый следующий узел в дереве будет
иметь на одну ветвь меньше, чем его родитель, то дерево целиком будет иметь 9 *
8 * 7 … * 1 = 362.880 листьев. В дереве будет 362.880 возможных путей,
соответствующих 362.800 возможным играм.
В действительности
многие из узлов дерева будут отсутствовать, так как соответствующие им ходы
запрещены правилами игры. Если игрок, ходивший первым, за три своих хода
поставит крестики в верхней левой, верхней средней и верхней правой клетках, то
он выиграет и игра закончится. Узел, соответствующий этой позиции, не будет
иметь потомков, так как игра завершается на этом шаге. Эта игра показана на
рис. 8.2.
После удаления всех
невозможных узлов в дереве остается около четверти миллиона листьев. Это все
еще очень большое дерево, и поиск его методом полного перебора занимает
достаточно много времени. Для более сложных игр, таких как шашки, шахматы или
го, деревья игры имеют огромный размер. Если бы во время каждого хода в
шахматах игрок имел 16 возможных вариантов, то дерево игры имело бы более
триллиона узлов после пяти ходов каждого из игроков. В конце этой главы
обсуждается поиск в таких огромных деревьях игры, а следующий раздел посвящен
более простому примеру игры в крестики‑нолики.
@Рис. 8.1. Фрагмент
дерева игры в крестики‑нолики
========188
@Рис. 8.2. Быстрое
окончание игры
Минимаксный
поиск
Для выполнения
поиска в дереве игры, нужно иметь возможность определить вес позиции на доске. Для игры в крестики‑нолики, для первого
игрока больший вес имеют позиции, в которых три крестика расположены в ряд, так
как при этом первый игрок выигрывает. Вес тех же позиций для второго игрока
мал, потому, что в этом случае он проигрывает.
Для каждого игрока,
можно присвоить позиции один из четырех весов. Если вес равен 4, то это значит,
что игрок в этой позиции выигрывает. Если вес равен 3, то из текущего положения
на доске неясно, кто из игроков выиграет в конце концов. Вес, равный 2,
означает, что позиция приводит к ничьей. И, наконец, вес, равный 1, означает,
что выигрывает противник.
Для поиска дерева
методом полного перебора можно использовать XE
"Метод:минимаксный" минимаксную (minimax XE "minimax"
) стратегию, при которой делается попытка минимизировать максимальный вес, который может иметь позиция для противника после
следующего хода. Это можно сделать, определив максимально возможный вес позиции
для противника после каждого из своих возможных ходов, и затем выбрав ход,
который дает позицию с минимальным весом для противника.
Подпрограмма BoardValue,
приведенная ниже, вычисляет вес позиции на доске, проверяя все возможные ходы.
Для каждого хода она рекурсивно вызывает себя, чтобы найти вес, который будет
иметь новая позиция для противника. Затем она выбирает ход, при котором вес
полученной позиции для противника будет наименьшим.
Для определения
веса позиции на доске процедура BoardValue
рекурсивно вызывает себя до тех пор, пока не произойдет одно из трех событий.
Во‑первых, она может дойти до позиции, в которой игрок выигрывает. В этом
случае, она присваивает позиции вес 4, что указывает на выигрыш игрока,
совершившего последний ход.
======189
Во‑вторых,
процедура BoardValue может найти позицию, в которой ни один из игроков не может
совершить следующий ход. Игра при этом заканчивается ничьей, поэтому процедура
присваивает этой позиции вес 2.
И наконец,
процедура может достигнуть заданной максимальной глубины рекурсии. В этом случае,
процедура BoardValue
присваивает позиции вес 3, что указывает, что она не может определить
победителя. Задание максимальной глубины рекурсии ограничивает время поиска в
дереве игры. Это особенно важно для более сложных игр, таких как шахматы, в
которых поиск в дереве игры может продолжаться практически вечно. Максимальная
глубина поиска также может задавать уровень мастерства программы. Чем дальше
вперед программа сможет анализировать ходы, тем лучше она будет играть.
На рис. 8.3
показано дерево игры в крестики‑нолики в конце партии. Ходит игрок,
играющий крестиками, и у него есть три возможных хода. Чтобы выбрать наилучший
ход, процедура BoardValue
рекурсивно проверяет каждый из трех возможных ходов. Первый и третий возможные
ходы (левая и правая ветви дерева) приводят к выигрышу противника, поэтому их
вес для противника равен 4. Второй возможный ход приводит к ничьей, и его вес
для противника равен 2. Процедура BoardValue
выбирает этот ход, так как он имеет наименьший вес для противника.
@Рис. 8.3. Нижняя
часть дерева игры
Private Sub BoardValue(best_move As Integer,
best_value As Integer, pl1 As Integer, pl2 As Integer, Depth As Integer)
Dim pl As Integer
Dim i As Integer
Dim good_i As Integer
Dim good_value As Integer
Dim enemy_i As Integer
Dim enemy_value As Integer
DoEvents ' Не занимать 100% процессорного времени.
' Если глубина рекурсии слишком велика,
результат неизвестен.
If Depth >= SkillLevel Then
best_value
= VALUE_UNKNOWN
Exit
Sub
End If
'
Если игра завершается, то результат известен.
pl = Winner()
If pl
<> PLAYER_NONE Then
'
Преобразовать вес для победителя pl в вес для игрока pl1.
If pl = pl1 Then
best_value
= VALUE_WIN
ElseIf
pl = pl2 Then
best_value
= VALUE_LOSE
Else
best_value
= VALUE_DRAW
End
If
Exit
Sub
End
If
' Проверить все допустимые ходы.
good_i = -1
good_value
= VALUE_HIGH
For i
= 1 To NUM_SQUARES
'
Проверить ход, если он разрешен правилами.
If Board(i) = PLAYER_NONE Then
'
Найти вес полученного положения для противника.
If ShowTrials
Then _
MoveLabel.Caption
= _
MoveLabel.Caption
& Format$(i)
'
Сделать ход.
Board(i) = pl1
BoardValue
enemy_i, enemy_value, pl2, pl1, Depth + 1
'
Отменить ход.
Board(i) =
PLAYER_NONE
If
ShowTrials Then _
MoveLabel.Caption
= _
Left$(MoveLabel.Caption,
Depth)
'
Меньше ли этот вес, чем предыдущий.
If enemy_value
< good_value Then
good_i
= i
good_value
= enemy_value
'
Если мы выигрываем, то лучшего решения нет,
' поэтому выбирается этот ход.
If good_value <= VALUE_LOSE Then Exit For
End
If
End
If ' End if Board(i) = PLAYER_NONE ...
Next
i
' Преобразовать вес позиции для противника в
вес для игрока.
If good_value = VALUE_WIN Then
'
Противник выигрывает, мы проиграли.
best_value = VALUE_LOSE
ElseIf
enemy_value = VALUE_LOSE Then
'
Противник проиграл, мы выиграли.
best_value = VALUE_WIN
Else
' Вес ничьей или неопределенной позиции
' одинаков для обоих игроков.
best_value = good_value
End If
best_move
= good_i
End Sub
Программа TicTac использует процедуру BoardValue. Основная часть кода программы обеспечивает взаимодействие с
пользователем, рисует доску, позволяет пользователю выбрать ход, задавать опции
и так далее.
Если не выбрана
команда Show Test Moves (Показывать проверяемые ходы)
из меню Options
(Опции), то производительность программы будет намного выше. Если выбрана эта
опция, то программа выводит каждый анализируемый ход. Постоянное обновление
экрана занимает намного больше времени, чем действительный поиск в дереве.
Другие команды в
меню Options
позволяют вам, выбрать уровень мастерства программы (максимальную глубину
рекурсии) и выбрать игру крестиками или ноликами. При высоком уровне мастерства
первый ход занимает намного больше времени.
=====192
Сдача
Подпрограмма BoardValue
имеет интересный побочный эффект. Если она находит два одинаково хороших хода,
то она выбирает из них первый попавшийся. Иногда это приводит к странному
поведению программы. Например, если программа определяет, что при любом своем
ходе она проигрывает, то она выбирает первый из них. Иногда этот ход может
показаться человеку глупым. Может создаться впечатление, что компьютер выбрал
случайный ход и сдается. В какой то степени это действительно так.
Например, запустим
программу TicTac с третьим
уровнем мастерства. Перенумеруем клетки так, как показано на рис. 8.4. Сделаем
первых ход в клетку 6. Программа выберет клетку 1. Выберем клетку 3, программа
ответит ходом на клетку 9. Теперь, если занять клетку 5, то наступает выигрыш,
если следующим ходом пойти на клетку 4 или 7.
Компьютер теперь
может просмотреть дерево игры до конца и убедиться в своем проигрыше. В такой
ситуации человек попытался бы заблокировать один из выигрышных ходов, либо
поместить два нолика в ряд, чтобы попытаться выиграть на следующем ходу. В
более сложной игре, такой как шахматы, человек также может выбрать одну из этих
стратегий, в надежде на то, что соперник не увидит пути к победе. Соперник
может ошибиться, давая игроку тем самым шанс на победу.
Программа же
считает, что противник играет безошибочно и также знает о своем выигрыше. Так
как ни один ход не приводит к победе, то программа выбирает первый попавшийся
ход, в данном случае занимает клетку 2. Этот ход кажется глупым, так как он не
блокирует ни одного из возможных выигрышных ходов, и не делает попытку выиграть
на следующем ходу. При этом кажется, что компьютер сдается. Эта игра показана
на рис. 8.5.
Один из способов
предотвращения такого поведения состоит в том, чтобы задать больше различных
весов позиций. В программе TicTac все
проигрышные позиции имеют одинаковый вес. Можно присвоить позиции, в которой
проигрыш происходит за два хода, больший вес, чем позиции, в которой проигрыш
наступает на следующем ходу. Тогда программа сможет выбирать ходы, которые
приведут к затягиванию игры. Также можно присваивать больший вес позиции, в
которой имеются два возможных выигрышных хода, чем позиции, в которой есть
только один выигрышный ход. В таком случае компьютер попытался бы заблокировать
один из возможных выигрышных ходов.
Улучшение
поиска в дереве игры
Если бы для поиска
в дереве игры мы располагали только минимаксной стратегией, то выполнить поиск
в больших деревьях было бы очень сложно. Такие игры, как шахматы, настолько
сложны, что программа может провести поиск всего лишь на нескольких уровнях
дерева. К счастью, существуют несколько приемов, которые можно использовать для
поиска в больших деревьях игры.
@Рис. 8.4.
Нумерация клеток доски игры в крестики‑нолики
======193
@Рис. 8.5.
Программа игры в крестики‑нолики сдается
Предварительное вычисление
начальных ходов
Во‑первых, в
программе могут быть записаны начальные ходы, выбранные экспертами. Можно
решить, что программа игры в крестики‑нолики должна делать первый ход в
центральную клетку. Это определяет первую ветвь дерева игры, поэтому программа
может игнорировать все пути, не проходящие через первую ветвь. Это уменьшает
дерево игры в крестики‑нолики в 9 раз.
Фактически,
программе не нужно выполнять поиск в дереве до того, пока противник не сделает
свой ход. В этот момент и компьютер и противник выбрали каждый свою ветвь,
поэтому оставшееся дерево станет намного меньше, и будет содержать менее чем 7!
= 5040 путей. Просчитав заранее всего один ход, можно уменьшить размер дерева
игры от четверти миллиона до менее чем 5040 путей.
Аналогично, можно
записать ответы на первые ходы, если противник ходит первым. Есть девять
вариантов первого хода, следовательно, нужно записать девять ответных ходов.
При этом программе не нужно поводить поиск по дереву, пока противник не сделает
два хода, а компьютер — один. Тогда дерево игры будет содержать менее чем
6! = 720 путей. Записано всего девять ходов, а размер дерева при этом
уменьшается очень сильно. Это еще один пример пространственно‑временного
компромисса. Использование большего количества памяти уменьшает время,
необходимое для поиска в дереве игры.
Программа TicTac2 использует 10 записанных ходов. Задайте 9
уровень мастерства, и пусть программа делает первый ход. Затем задайте те же
опции в программе TicTac. Вы увидите
громадную разницу в скорости работы этих двух программ.
Коммерческие
программы игры в шахматы также начинают с записанных ходов и ответов,
рекомендованных гроссмейстерами. Такие программы могут делать первые ходы очень
быстро. После того, как программа исчерпает все записанные заранее ходы, она
начнет делать ходы намного медленнее.
Определение важных позиций
Другой способ
улучшения поиска в дереве игры состоит в том, чтобы определять важные позиции.
Если программа распознает одну из этих позиций, она может выполнить определенные
действия или изменить способ поиска в дереве игры.
========194
Во время игры в
шахматы игроки часто располагают фигура так, чтобы они защищали другие фигуры.
Если противник берет фигуру, то игрок берет фигуру противника взамен. Часто
такое взятие позволяет противнику в свою очередь взять другую фигуру, что
приводит к серии обменов.
Некоторые программы
находят возможные последовательностей обменов. Если программа распознает
возможность обмена, она на время изменяет максимальную глубину, на которую она
просматривает дерево, чтобы проследить до конца цепочку обменов. Это позволяет
программе решить, стоит ли идти на обмен. После обмена фигур их количество
также уменьшается, поэтому поиск в дереве игры становится в будущем более
простым.
Некоторые шахматные
программы также отслеживают рокировки, ходы, при которых под боем оказывается
сразу несколько фигур, шах или нападение на ферзя и так далее.
Эвристики
В играх, более
сложных, чем крестики‑нолики, практически невозможно провести поиск даже
в небольшом фрагменте дерева игры. В этих случаях, можно использовать различные
эвристики. Эвристикой называет
алгоритм или эмпирическое правило, которое вероятно, но не обязательно даст
хороший результат.
Например, в
шахматах обычной эвристикой является «усиление преимущества». Если у противника
меньше сильных фигур и одинаковое число остальных, то следует идти на размен
при каждой возможности. Например, если вы берете коня противника, теряя при
этом своего, то такой обмен следует выполнить. Уменьшение числа оставшихся
фигур делает дерево решений короче и может увеличить относительное
преимущество. Эта стратегия не гарантирует выигрыша, но повышает его
вероятность.
Другая часто
используемая эвристика заключается в присвоении разных весов различным частям
доски. В шахматах вес клеток в центре доски выше, так как фигуры, находящиеся
на этих позициях, могут атаковать большую часть доски. Когда процедура BoardValue
вычисляет вес текущей позиции на доске, она может присваивать больший вес
фигурам, которые занимают клетки в центре доски.
Поиск в
других деревьях
решений
Некоторые методы
поиска в деревьях игры неприменимы к обобщенным деревьям решений. Многие их
этих деревьев не включают поочередных ходов игроков, поэтому минимаксный метод
и вычисленные заранее ходы в данном случае бессмысленны. В следующих разделах
описаны методы, которые можно использовать для поиска в этих типах деревьев
решений.
=======195
Метод ветвей
и границ
XE
"Метод:ветвей и границ" Метод ветвей и границ
(branch and bound)
является одним из методов отсечения (pruning XE "pruning"
) ветвей в дереве решений, чтобы не было
необходимо рассматривать все ветви дерева. Общий подход при этом состоит в том,
чтобы отслеживать границы уже обнаруженных и возможных решений. Если в какой‑то
точке наилучшее из уже найденных решений лучше, чем наилучшее возможное решение
в нижних ветвях, то можно игнорировать все пути вниз от узла.
Например, допустим,
что имеет 100 миллионов долларов, которые нужно вложить в несколько возможных
инвестиций. Каждое из вложений имеет разную стоимость и дает разную прибыль.
Необходимо решить, как вложить деньги наилучшим образом, чтобы суммарная
прибыль была максимальной.
Задачи такого типа
называются задачей XE
"Задача:формирования портфеля" формирования портфеля[RV14]
(knapsack problem XE "knapsack problem" ). Имеется несколько позиций (инвестиций), которые должны
поместиться в портфель фиксированного размера (100 миллионов долларов). Каждая
из позиций имеет стоимость (деньги) и цену (тоже деньги). Необходимо найти
набор позиций, который помещается в портфель и имеет максимально возможную
цену.
Эту задачу можно
смоделировать при помощи дерева решений. Каждый узел дерева соответствует
определенной комбинации позиций в портфеле. Каждая ветвь соответствует принятию
решения о том, чтобы удалить позицию из портфеля или добавить ее в него. Левая
ветвь первого узла соответствует первому вложению. На рис. 8.6 показано дерево
решений для четырех возможных инвестиций.
Дерево решений для
этой задачи представляет собой полное двоичное дерево, глубина которого равна
числу инвестиций. Каждый лист соответствует полному набору инвестиций.
Размер этого дерева
очень быстро растет с увеличением числа инвестиций. Для 10 возможных
инвестиций, в дереве будет находиться 210 = 1024 листа.
Для 20 инвестиций, в дереве будет уже более миллиона листьев. Можно провести
полный поиск по такому дереву, но при дальнейшем увеличении числа возможных
инвестиций размер дерева станет очень большим.
@Рис. 8.6. Дерево
решений для инвестиций
=======196
Чтобы использовать
метод ветвей и границ, создадим массив, который будет содержать позиции из
наилучшего найденного до сих пор решения. При инициализации массив должен быть
пуст. Можно также использовать переменную для отслеживания цены этого решения.
Вначале эта переменная может иметь небольшое значение, чтобы первое же
найденное реальное решение было лучше исходного.
При поиске в дереве
решений, если в какой‑то точке анализируемое решение не может быть лучше,
чем существующее, то можно прекратить дальнейший поиск по этому пути. Также,
если в какой‑то точке выбранные позиции стоят более 100 миллионов, то
можно также прекратить поиск.
В качестве
конкретного примера, предположим, что имеются инвестиции, приведенные в табл.
8.1. На рис. 8.6 показано соответствующее дерево решений. Некоторые из этих
инвестиционных пакетов нарушают граничные условия задачи. Например, самый левый
путь привел бы к вложению 178 миллионов долларов во все четыре возможных
инвестиции.
Предположим, что мы
начали поиск в дереве, изображенном на рис. 8.6 и обнаружили, что можно
потратить 97 миллионов долларов на позиции A и B, получив 23 миллиона прибыли. Это соответствует четвертому
листу слева на рис. 8.6.
При продолжении
поиска в дереве, можно дойти до второго
слева узла B на рис. 8.6. [RV15] Это
соответствует инвестиционному пакету, который включает позицию A, не включает позицию B, и может включать или не
включать позиции C и D. В этой точке пакет уже стоит 45 миллионов долларов за счет
позиции A, и приносит 10 миллионов прибыли.
Оставшиеся позиции C и D вместе взятые могут
повысить прибыль еще на 12 миллионов. Текущее решение приносит 10 миллионов
прибыли, поэтому наилучшее возможное решение ниже этого узла принесет не больше
11 миллионов прибыли. Это меньше, чем доход в 23 миллиона для уже найденного
решения, поэтому нет смысла продолжать поиск вниз по этому пути.
По мере продвижения
программы по дереву ей не нужно постоянно проверять, будет ли частичное
решение, которое она рассматривает, лучше, чем наилучшее найденное до сих пор
решение. Если частичное решение лучше, то лучше будет и самый правый узел внизу
от этого частичного решения. Этот узел представляет тот же самый набор позиций,
как и частичное решение, так как все остальные позиции при этом исключены. Это
означает, что программе необходимо искать лучшее решение только тогда, когда
она достигает листа.
@Таблица 8.1.
Возможные инвестиции
======197
Фактически, любой
лист, до которого доходит программа всегда
является более хорошим решением. Если бы это было не так, то ветвь, на котором
находится этот лист, была бы отсечена, когда программа рассматривала
родительский узел. В этой точке перемещение к листу уменьшит цену невыбранных
позиций до нуля. Если цена решения не больше, чем наилучшее найденное до сих
пор решение, то проверка нижней границы остановит продвижение программы к
листу. Используя этот факт, программа может обновлять наилучшее решение при
достижении листа.
Следующий код
использует проверку верхней и нижней границы для реализации алгоритма ветвей и
границ:
' Полная
нераспределенная прибыль.
Private unassigned_profit As Integer
Public NumItems As Integer
Public MaxItem As Integer
Global Const OPTION_EXHAUSTIVE_SEARCH = 0
Global Const OPTION_BRANCH_AND_BOUND = 1
Type Item
Cost
As Integer
Profit
As Integer
End Type
Global Items() As Item
Global NodesVisited As Long
Global ToSpend As Integer
Global best_cost As Integer
Global best_profit As Integer
' Равно True для
позиций в текущем наилучшем решении.
Public best_solution() As Boolean
' Решение, которое мы
проверяем.
Private test_solution() As Boolean
Private test_cost As Integer
Private test_profit As Integer
' Инициализация
переменных и начало поиска.
Public Sub Search(search_type As Integer)
Dim i As Integer
'
Задание размера массивов решения.
ReDim best_solution(0 To MaxItem)
ReDim
test_solution(0 To MaxItem)
'
Инициализация - пустой список инвестиций.
NodesVisited = 0
best_profit
= 0
best_cost
= 0
unassigned_profit
= 0
For i
= 0 To MaxItem
unassigned_profit
= unassigned_profit + Items(i).Profit
Next i
test_profit
= 0
test_cost
= 0
' Начнем поиск с первой позиции.
BranchAndBound 0
End Sub
' Выполнить поиск
методом ветвей и границ начиная с этой позиции.
Public Sub BranchAndBound(item_num As Integer)
Dim i As Integer
NodesVisited
= NodesVisited + 1
' Если это лист, то это лучшее решение, чем
' то, которое мы имели раньше, иначе он был
бы
' отсечен во время поиска раньше.
If item_num > MaxItem Then
For
i = 0 To MaxItem
best_solution(i)
= test_solution(i)
best_profit
= test_profit
best_cost
= test_cost
Next
i
Exit
Sub
End If
' Иначе перейти по ветви вниз по ветвям
потомка.
' Вначале попытаться добавить эту позицию.
Убедиться,
' что она не превышает ограничение по цене.
If test_cost + Items(item_num).Cost
<= ToSpend Then
'
Добавить позицию к тестовому решению.
test_solution(item_num) = True
test_cost
= test_cost + Items(item_num).Cost
test_profit
= test_profit + Items(item_num).Profit
unassigned_profit
= unassigned_profit - Items(item_num).Profit
'
Рекурсивная проверка возможного результата.
BranchAndBound item_num + 1
'
Удалить позицию из тестового решения.
test_solution(item_num) = False
test_cost
= test_cost - Items(item_num).Cost
test_profit
= test_profit - Items(item_num).Profit
unassigned_profit
= unassigned_profit + Items(item_num).Profit
End
If
' Попытаться исключить позицию. Выяснить,
принесут ли
' оставшиеся позиции достаточный доход,
чтобы
' путь вниз по этой ветви превысил нижний
предел.
unassigned_profit =
unassigned_profit - Items(item_num).Profit
If
test_profit + unassigned_profit > best_profit Then BranchAndBound item_num +
1
unassigned_profit
= unassigned_profit + Items(item_num).Profit
End Sub
Программа BandB
использует метод полного перебора и метод ветвей и границ для решения задачи о
формировании портфеля. Введите максимальную и минимальную стоимость и цену,
которые вы хотите присвоить позициям, а также число позиций, которое требуется
создать. Затем нажмите на кнопку Randomize (Рандомизировать), чтобы создать список позиций.
Затем при помощи
переключателя внизу формы выберите либо Exhaustive Search (Полный перебор),
либо Branch and Bound (Метод ветвей и границ). Когда
вы нажмете на кнопку Go (Начать), то программа найдет наилучшее решение при помощи
выбранного метода. Затем она выведет на экран это решение, а также число узлов
в полном дереве решений и число узлов, которые программа в действительности
проверила. На рис. 8.7 показано окно программы BindB
после решения задачи портфеля для 20 позиций. Перед тем, как выполнить полный
перебор для 20 позиций, попробуйте вначале запустить примеры меньшего размера.
На компьютере с процессором Pentium с тактовой частотой 90 МГц поиск решения
задачи портфеля для 20 позиций методом полного перебора занял более 30 секунд.
При поиске методом
ветвей и границ число проверяемых узлов намного меньше, чем при полном
переборе. Дерево решений для задачи портфеля с 20 позициями содержит 2.097.151
узел. При полном переборе придется проверить их все, при поиске методом ветвей
и границ понадобится проверить только примерно 1.500 из них.
@Рис. 8.7.
Программа BindB
======200
Число узлов,
которые проверяет программа при использовании метода ветвей и границ, зависит
от точных значений данных. Если цена позиций высока, то в правильное решение
будет входить немного элементов. После помещения нескольких позиций в пробное
решение, оставшиеся позиции слишком дорого стоят, чтобы поместиться в портфеле,
потому большая часть дерева будет отсечена.
С другой стороны,
если элементы имеют низкую стоимость, то в правильное решение войдет большое их
число, поэтому программе придется исследовать множество комбинаций. В табл. 8.2
приведено число узлов, проверенное программой BindB
в серии тестов при различной стоимости позиций. Программа создавала 20
случайных позиций, и полная стоимость решения была равна 100.
Эвристики
Иногда даже
алгоритм ветвей и границ не может провести полный поиск в дереве. Дерево
решений для задачи портфеля с 65 позициями содержит более 7 * 1019
узлов. Если алгоритм ветвей и границ проверяет только одну десятую процента
этих узлов, и если компьютер проверяет миллион узлов в секунду, то для решения
этой задачи потребовалось бы более 2 миллионов лет. В задачах, для которых
алгоритм ветвей и границ выполняется слишком медленно, можно использовать
эвристический подход.
Если качество
решения не так важно, то приемлемым может быть результат, полученный при помощи
эвристики. В некоторых случаях точность входных данных может быть
недостаточной. Тогда хорошее эвристическое решение может быть таким же
правильным, как и теоретически «наилучшее» решение.
В предыдущем
примере метод ветвей и границ использовался для выбора инвестиционных возможностей.
Тем не менее, вложения могут быть рискованными, и точные результаты часто
заранее неизвестны. Может быть, что заранее будет неизвестен точный доход или
даже стоимость некоторых инвестиций. В этом случае, эффективное эвристическое
решение может быть таким же надежным, как и наилучшее решение, которое вы может
вычислить точно.
@Таблица 8.2. Число
узлов, проверенных при поиске методами полного перебора и ветвей и границ
=======201
В этом разделе
обсуждаются эвристики, которые полезны при решении многих сложных задач.
Программа Heur демонстрирует
каждую из эвристик. Она также позволяет сравнить результаты, полученные при
помощи эвристик и методов полного перебора и ветвей и границ. Введите значения
минимальной и максимальной стоимости и дохода, а также число позиций и полную
стоимость портфеля в соответствующих полях области Parameters (Параметры),
чтобы задать параметры создаваемых данных. Затем выберите алгоритмы, которые вы
хотите протестировать, и нажмите на кнопку Go. Программа выведет полную стоимость
и доход для наилучшего решения, найденного при помощи каждого из алгоритмов.
Она также сортирует решения по максимальному полученному доходу и выводит время
выполнения для каждого из алгоритмов. Используйте метод ветвей и границ только
для небольших задач, а метод полного перебора только для задач еще меньшего
объема.
На рис. 8.8
показано окно программы Heur после решения
задачи формирования портфеля для 20 позиций. Эвристики Fixed1, Fixed2 и No Changes 1,
которые будут вскоре описаны, дали наилучшие эвристические решения. Заметьте,
что эти решения немного хуже, чем точные решения, которые получены при
использовании метода ветвей и границ.
Восхождение на холм
Эвристика XE
"Метод:восхождения на холм" восхождения
на холм (hill‑climbing XE "hill‑climbing" ) вносит изменения в текущее решение, чтобы
максимально приблизить его к цели. Этот процесс называется восхождением на
холм, так как он похож на то, как заблудившийся путешественник пытается ночью
добраться до вершины горы. Даже если уже слишком темно, чтобы еще можно было
разглядеть что‑то вдали, путешественник может попытаться добраться до
вершины горы, постоянно двигаясь вверх.
Конечно, существует
вероятность, что путешественник застрянет на вершине меньшего холма и не
доберется до пика. Эта проблема всегда может возникать при использовании этой
эвристики. Алгоритм может найти решение, которое может оказаться локально
приемлемым, но это не обязательно наилучшее возможное решение.
В задаче о
формировании портфеля, цель заключается в том, чтобы подобрать набор позиций,
полная стоимость которых не превышает заданного предела, а общая цена
максимальна. На каждом шаге эвристика восхождения на холм будет выбирать
позицию, которая приносит наибольшую прибыль. При этом решение будет все лучше
соответствовать цели — получению максимальной прибыли.
@Рис. 8.8.
Программа Heur
========202
Вначале программа
добавляет к решению позицию с максимальной прибылью. Затем она добавляет
следующую позицию с максимальной прибылью, если при этом полная цена еще
остается в допустимых пределах. Она продолжает добавлять позиции с максимальной
прибылью до тех пор, пока не останется позиций, удовлетворяющих условиям.
Для списка
инвестиций из табл. 8.3, программа вначале выбирает позицию A, так как она дает максимальную прибыль — 9 миллионов
долларов. Затем программа выбирает следующую позицию C, которая дает прибыль 8 миллионов. В этот момент потрачены уже
93 миллиона из 100, и программа не может приобрести больше позиций. Решение,
полученное при помощи эвристики, включает позиции A и C, имеет стоимость 93
миллиона, и приносит 17 миллионов прибыли.
@Таблица 8.3.
Возможные инвестиции
Эвристика
восхождения на холм заполняет портфель очень быстро. Если позиции изначально
были отсортированы в порядке убывания приносимой прибыли, то сложность этого
алгоритма порядка O(N). Программа просто перемещается по списку, добавляя каждую
позицию, если под нее есть место. Даже если список не упорядочен, то это
алгоритм со сложностью порядка O(N2). Это намного лучше, чем O(2N) шагов, которые требуются
для полного перебора всех узлов в дереве. Для 20 позиций эта эвристика требует
всего около 400 шагов, метод ветвей и границ — несколько тысяч, а полный
перебор — более чем 2 миллиона.
Public Sub HillClimbing()
Dim i As Integer
Dim j As Integer
Dim big_value As Integer
Dim big_j As Integer
'
Многократный обход списка и поиск следующей
' позиции, приносящей наибольшую прибыль,
' стоимость которой не превышает верхней
границы.
For i = 1 To NumItems
big_value
= 0
big_j
= -1
For
j = 1 To NumItems
'
Проверить, не находится ли он уже
' в решении.
If (Not
test_solution(j)) And _
(test_cost
+ Items(j).Cost <= ToSpend) And _
(big_value
< Items(j).Profit)
Then
big_value
= Items(j).Profit
big_j
= j
End
If
Next
j
' Остановиться, если не найдена позиция,
' удовлетворяющая условиям.
If big_j < 0 Then Exit For
test_cost
= test_cost + Items(big_j).Cost
test_solution(big_j)
= True
test_profit
= test_profit + Items(big_j).Profit
Next i
End Sub
Метод наименьшей стоимости
Стратегия, которая
в каком‑то смысле противоположна стратегии восхождения на холм,
называется стратегией XE
"Метод:наименьшей стоимости" наименьшей
стоимости (least‑cost
XE "least‑cost" ). Вместо того чтобы на каждом шаге пытаться
максимально приблизить решение к цели, можно попытаться уменьшить стоимость решения, насколько это
возможно. В примере с формированием портфеля, на каждом шаге к решению
добавляется позиция с минимальной стоимостью.
Эта стратегия
пытается поместить в решение максимально возможное число позиций. Это будет
неплохим решением, если все позиции имеют примерно одинаковую стоимость. Если
дорогая позиция приносит большую прибыль, то эта стратегия может упустить эту
возможность, давая не лучший из возможных результатов.
Для инвестиций,
показанных в табл. 8.3, алгоритм наименьшей стоимости начинает с добавления к
решению позиции E со стоимостью 23 миллиона
долларов. Затем он выбирает позицию D, стоящую 27 миллионов, и
затем позицию C со стоимостью 30 миллионов. В этой
точке алгоритм уже потратил 80 миллионов из 100 возможных, поэтому больше он не
может выбрать ни одной позиции.
Это решение имеет
стоимость 80 миллионов и дает 18 миллионов прибыли. Это на миллион лучше, чем
решение для эвристики восхождения на холм, но стратегия наименьшей стоимости не
всегда дает лучшее решение, чем восхождение на холм. Какая из эвристик дает
лучшие результаты, зависит от значений входных данных.
Структура
программы, реализующей эвристику наименьшей стоимости, почти идентична
структуре программы для эвристики восхождения на холм. Единственное различие
между ними заключается в выборе следующей позиции для добавления к решению.
Эвристика наименьшей стоимости выбирает позицию с минимальной ценой; метод
восхождения на холм выбирает позицию с максимальной прибылью. Так как эти два
метода очень похожи, они выполняются за одинаковое время. Если позиции
упорядочены соответствующим образом, то оба алгоритма выполняются за время
порядка O(N). Если позиции расположены
случайным образом, то оба выполняются за время порядка O(N2).
========203-204
Так как код на
языке Visual Basic для этих двух эвристик очень похож, то мы приводим только
строки, в которых происходит выбор очередной позиции.
If (Not test_solution(j)) And _
(test_cost
+ Items(j).Cost <= ToSpend) And _
(small_cost
> Items(j).Cost)
Then
small_cost
= Items(j).Cost
small_j
= j
End If
Сбалансированная прибыль
Стратегия
восхождения на холм не учитывает стоимость добавляемых позиций. Она выбирает
позиции с максимальной прибылью, даже если их стоимость велика. Стратегия
наименьшей стоимости не учитывает приносимую позицией прибыль. Она выбирает
позиции с низкой стоимостью, даже если они приносят мало прибыли.
Эвристика XE
"Метод:сбалансированной прибыли" сбалансированной
прибыли (balanced profit XE "balanced profit" ) сравнивает при выборе стоимость позиций и приносимую ими
прибыль. На каждом шаге эвристика выбирает позицию с наибольшим отношением
прибыль‑стоимость.
В табл. 8.4
приведены те же данные, что и в табл. 8.3, но в ней добавлена еще одна колонка
с отношением прибыль‑стоимость. При этом подходе вначале выбирается
позиция C, так как она имеет максимальное
соотношение прибыль‑стоимость — 0,27. Затем к решению добавляется
позиция D с отношением 0,26, и позиция B с отношением 0,20. В этой точке, будет потрачено 92 миллиона из
100 возможных, и в решение нельзя будет добавить больше ни одной позиции.
Решение будет иметь
стоимость 92 миллиона и давать 22 миллиона прибыли. Это на 4 миллиона лучше,
чем решение с наименьшей стоимостью и на 5 миллионов лучше, чем решение методом
восхождения на холм. В этом случае, это будет также наилучшим возможным
решением, и его также можно найти полным перебором или методом ветвей и границ.
Метод сбалансированной прибыли тем не менее, является эвристическим, поэтому он
не обязательно находит наилучшее возможное решение. Он часто находит лучшее
решение, чем методы наименьшей стоимости и восхождения на холм, но это не
обязательно так.
@Таблица 8.4.
Возможные инвестиции с соотношением прибыль‑стоимость
=========205
Структура
программы, реализующей эвристику сбалансированной прибыли, почти идентична
структуре программ для восхождения на холм и наименьшей стоимости. Единственное
отличие заключается в методе выбора следующей позиции, которая добавляется к
решению:
If (Not test_solution(j)) And _
(test_cost
+ Items(j).Cost <= ToSpend) And _
(good_ratio
< Items(j).Profit / CDbl(Items(j).Cost)) _
Then
good_ratio
= Items(j).Profit / CDbl(Items(j).Cost)
good_j
= j
End If
Случайный поиск
XE
"Метод:случайного поиска" Случайный поиск (random search XE "random search" ) выполняется в соответствии со своим названием. На каждом шаге
алгоритм добавляет случайную позицию, которая удовлетворяет верхнему
ограничению на суммарную стоимость позиций в портфеле. Этот метод поиска также
называется XE "Метод:Монте-Карло" методом
Монте‑Карло (Monte Carlo search XE "Monte Carlo search" или Monte Carlo simulation).
Так как
маловероятно, что случайно выбранное решение окажется наилучшим, необходимо
многократно повторять этот поиск, чтобы получить приемлемый результат. Хотя
может показаться, что вероятность нахождения хорошего решения при этом мала,
этот метод иногда дает удивительно хорошие результаты. В зависимости от
значений данных и числа проверенных случайных решений результат, полученный при
помощи этой эвристики, часто оказывается лучше, чем в случае применения методов
восхождения на холм или наименьшей стоимости.
Преимущество
случайного поиска состоит также и в том, что этот метод легок в понимании и
реализации. Иногда сложно представить, как реализовать решение задачи при
помощи эвристик восхождения на холм, наименьшей стоимости, или
сбалансированного дохода, но всегда просто выбирать решения случайным образом.
Даже для очень сложных проблем, случайный поиск является простым эвристическим
методом.
Подпрограмма RandomSearch
в программе Heur использует
функцию AddToSolution для
добавления к решению случайной позиции. Эта функция возвращает значение True,
если она не может найти позицию, которая удовлетворяет условиям, и False в другом
случае. Подпрограмма RandomSearch
вызывает функцию AddToSolution до
тех пор, пока больше нельзя добавить ни одной позиции.
Public Sub RandomSearch()
Dim num_trials As Integer
Dim trial As Integer
Dim i As Integer
'
Сделать несколько попыток и выбрать наилучший результат.
num_trials = NumItems ' Использовать N попыток.
For trial = 1 To num_trials
'
Случайный выбор позиций, пока это возможно.
Do While AddToSolution()
'
Всю работу выполняет функция AddToSolution.
Loop
' Определить, лучше ли это решение, чем
предыдущее.
If test_profit > best_profit Then
best_profit
= test_profit
best_cost
= test_cost
For
i = 1 To NumItems
best_solution(i)
= test_solution(i)
Next
i
End
If
'
Сбросить пробное решение и сделать еще одну попытку.
test_profit = 0
test_cost
= 0
For
i = 1 To NumItems
test_solution(i)
= False
Next
i
Next
trial
End Sub
Private Function AddToSolution() As Boolean
Dim num_left As Integer
Dim j As Integer
Dim selection As Integer
'
Определить, сколько осталось позиций, которые
' удовлетворяют ограничению максимальной
стоимости.
num_left = 0
For j
= 1 To NumItems
If
(Not test_solution(j)) And _
(test_cost
+ Items(j).Cost <= ToSpend) _
Then
num_left = num_left + 1
Next
j
' Остановиться, если нельзя найти новую
позицию.
If num_left < 1 Then
AddToSolution
= False
Exit
Function
End
If
' Выбрать случайную позицию.
selection = Int((num_left) * Rnd +
1)
'
Найти случайно выбранную позицию.
For j = 1 To NumItems
If
(Not test_solution(j)) And _
(test_cost
+ Items(j).Cost <= ToSpend) _
Then
selection
= selection - 1
If
selection < 1 Then Exit For
End
If
Next j
test_profit
= test_profit + Items(j).Profit
test_cost
= test_cost + Items(j).Cost
test_solution(j)
= True
AddToSolution
= True
End Function
Последовательное приближение
Еще одна стратегия
заключается в том, чтобы начать со случайного решения и затем делать XE
"Метод:последовательных приближений" последовательные
приближения (incremental improvements XE "incremental improvements" ). Начав со случайно выбранного решения, программа делает
случайный выбор. Если новое решение лучше предыдущего, программа закрепляет
изменения и продолжает проверку других случайных изменений. Если изменение не
улучшает решение, программа отбрасывает его и делает новую попытку.
Для задачи
формирования портфеля особенно просто порождать случайные изменения. Программа
просто выбирает случайную позицию из пробного решения, и удаляет ее из текущего
решения. Она затем снова добавляет случайные позиции в решение до тех пор, пока
они помещаются. Если удаленная позиция имела очень высокую стоимость, то на ее
место программа может поместить несколько позиций.
Момент остановки
Есть несколько
хороших способов определить момент, когда следует прекратить случайные
изменения. Для проблемы с N позициями, можно выполнить N или N2 случайных
изменений, перед тем, как остановиться.
=====206-208
В программе Heur
этот подход реализован в процедуре MakeChangesFixed.
Она выполняет определенное число случайных изменений с рядом случайных пробных
решений:
Public Sub MakeChangesFixed(K As Integer,
num_trials As Integer, num_changes As Integer)
Dim trial As Integer
Dim change As Integer
Dim i As Integer
Dim removal As Integer
For
trial = 1 To num_trials
'
Найти случайное пробное решение и использовать его
' в качестве начальной точки.
Do While AddToSolution()
'
All the work is done by AddToSolution.
Loop
' Начать с этого пробного решения.
trial_profit = test_profit
trial_cost
= test_cost
For
i = 1 To NumItems
trial_solution(i)
= test_solution(i)
Next
i
For
change = 1 To num_changes
'
Удалить K случайных позиций.
For removal = 1
To K
RemoveFromSolution
Next removal
' Добавить максимально возможное
' число позиций.
Do While
AddToSolution()
' All the work is done by AddToSolution.
Loop
' Если это улучшает пробное решение,
сохранить его.
' Иначе вернуть прежнее значение
пробного решения.
If test_profit
> trial_profit Then
'
Сохранить изменения.
trial_profit =
test_profit
trial_cost
= test_cost
For
i = 1 To NumItems
trial_solution(i)
= test_solution(i)
Next
i
Else
' Сбросить пробное решение.
test_profit =
trial_profit
test_cost
= trial_cost
For
i = 1 To NumItems
test_solution(i)
= trial_solution(i)
Next
i
End
If
Next
change
' Если пробное решение лучше предыдущего
' наилучшего решения, сохранить его.
If trial_profit > best_profit
Then
best_profit
= trial_profit
best_cost
= trial_cost
For
i = 1 To NumItems
best_solution(i)
= trial_solution(i)
Next
i
End
If
'
Сбросить пробное решение для
' следующей попытки.
test_profit = 0
test_cost
= 0
For
i = 1 To NumItems
test_solution(i)
= False
Next
i
Next
trial
End Sub
Private Sub RemoveFromSolution()
Dim num_in_solution As Integer
Dim j As Integer
Dim selection As Integer
'
Определить число позиций в решении.
num_in_solution = 0
For j
= 1 To NumItems
If
test_solution(j) Then num_in_solution = num_in_solution + 1
Next j
If
num_in_solution < 1 Then Exit Sub
'
Выбрать случайную позицию.
selection = Int((num_in_solution) *
Rnd + 1)
'
Найти случайно выбранную позицию.
For j = 1 To NumItems
If
test_solution(j) Then
selection
= selection - 1
If
selection < 1 Then Exit For
End
If
Next j
'
Удалить позицию из решения.
test_profit = test_profit -
Items(j).Profit
test_cost
= test_cost - Items(j).Cost
test_solution(j)
= False
End Sub
======209-210
Другая стратегия
заключается в том, чтобы вносить изменения до тех пор, пока несколько
последовательных изменений не приносят улучшений. Для задачи с N позициями,
программа может вносить изменения до тех пор, пока в течение N изменений подряд
улучшений не будет.
Эта стратегия
реализована в подпрограмме MakeChangesNoChange
программы Heur. Она
повторяет попытки до тех пор, пока определенное число последовательных попыток
не даст никаких улучшений. Для каждой попытки она вносит случайные изменения в
пробное решение до тех пор, пока после определенного числа изменений не
наступит никаких улучшений.
Public Sub MakeChangesNoChange(K As Integer, _
max_bad_trials
As Integer, max_non_changes As Integer)
Dim i As Integer
Dim removal As Integer
Dim bad_trials As Integer ' Неэффективных попыток подряд.
Dim non_changes As
Integer ' Неэффективных изменений
подряд.
' Повторять попытки, пока не встретится
max_bad_trials
' попыток подряд без улучшений.
bad_trials = 0
Do
' Выбрать случайное пробное решение для
' использования в качестве начальной
точки.
Do While AddToSolution()
'
All the work is done by AddToSolution.
Loop
' Начать с этого пробного решения.
trial_profit = test_profit
trial_cost
= test_cost
For
i = 1 To NumItems
trial_solution(i)
= test_solution(i)
Next
i
' Повторять, пока max_non_changes
изменений
' подряд не даст улучшений.
non_changes = 0
Do
While non_changes < max_non_changes
'
Удалить K случайных позиций.
For removal = 1
To K
RemoveFromSolution
Next removal
' Вернуть максимально возможное число
позиций.
Do While
AddToSolution()
'
All the work is done by
'
AddToSolution.
Loop
' Если это улучшает пробное значение,
сохранить его.
' Иначе вернуть прежнее значение
пробного решения.
If test_profit
> trial_profit Then
'
Сохранить улучшение.
trial_profit =
test_profit
trial_cost
= test_cost
For
i = 1 To NumItems
trial_solution(i)
= test_solution(i)
Next
i
non_changes
= 0 ' This was a good change.
Else
'
Reset the trial.
test_profit
= trial_profit
test_cost
= trial_cost
For
i = 1 To NumItems
test_solution(i)
= trial_solution(i)
Next
i
non_changes
= non_changes + 1 ' Плохое изменение.
End
If
Loop '
Продолжить проверку случайных изменений.
' Если эта попытка лучше, чем предыдущее
наилучшее
' решение, сохранить его.
If trial_profit > best_profit
Then
best_profit
= trial_profit
best_cost
= trial_cost
For
i = 1 To NumItems
best_solution(i)
= trial_solution(i)
Next
i
bad_trials
= 0 ' Хорошая попытка.
Else
bad_trials
= bad_trials + 1 ' Плохая попытка.
End
If
' Сбросить тестовое решение для
следующей попытки.
test_profit = 0
test_cost
= 0
For
i = 1 To NumItems
test_solution(i)
= False
Next
i
Loop
While bad_trials < max_bad_trials
End Sub
Локальные оптимумы
Если программа
заменяет случайно выбранную позицию в пробном решении, то может встретиться
решение, которое она не может улучшить, но которое при этом не будет наилучшим
из возможных решений. Например, рассмотрим список инвестиций, приведенный в
табл. 8.5.
Предположим, что
алгоритм случайно выбрал позиции A и B в качестве начального решения. Его стоимость будет равно 90 миллионам
долларов, и оно принесет 17 миллионов прибыли.
Если программа
удалит позиции A и B, то стоимость решения будет все еще настолько велика, что
программа сможет добавить всего лишь одну позицию к решению. Так как наибольшую
прибыль приносят позиции A и B, то замена их другими позициями уменьшит суммарную прибыль.
Случайное удаление одной позиции из этого решения никогда не приведет к
улучшению решения.
Наилучшее решение
содержит позиции C, D и E. Его полная стоимость равно
98 миллионам долларов и суммарная прибыль составляет 18 миллионов долларов.
Чтобы найти это решение, алгоритму бы понадобилось удалить из решения сразу обе
позиции A и B и
затем добавить на их место новые позиции.
Решения такого
типа, для которых небольшие изменения решения не могут улучшить его, называются
XE "Оптимум:локальный" локальным
оптимумом (local optimum XE "optimum:local"
). Можно использовать два способа для того,
чтобы программа не застревала в локальном оптимуме, и могла найти XE
"Оптимум:глобальный" глобальный оптимум (global optimum XE "optimum:global"
).
@Таблица 8.5.
Возможные инвестиции
=============213
Во‑первых,
можно изменить программу так, чтобы она удаляла более одной позиции во время
случайных изменений. В этом примере, программа могла бы найти правильное
решение, если бы она одновременно удаляла бы по две случайно выбранных позиции.
Тем не менее, для задач большего размера, удаления двух позиций может быть
недостаточно. Программе может понадобиться удалять три, четыре, или больше
позиций.
Второй, более
простой способ заключается в том, чтобы делать больше попыток, начиная с разных
начальных решений. Некоторые из начальных решений будут приводить к локальным
оптимумам, но одно из них позволит достичь глобального оптимума.
Программа Heur
демонстрирует три стратегии последовательных приближений. При выборе метода Fixed 1 (Фиксированный 1)
делается N попыток. Во время каждой попытки
выбирается случайно решение, которое программа затем пытается улучшить за 2 * N попыток, случайно удаляя по одной позиции.
При выборе
эвристики Fixed 2
(Фиксированный 2)делается всего одна попытка. При этом программа выбирает
случайное решение и пытается улучшить его, случайным образом удаляя по одной
позиции до тех пор, пока в течение N последовательных изменений
не будет никаких улучшений.
При выборе
эвристики No Changes 1 (Без изменений 1) программа выполняет попытки до
тех пор, пока после N последовательных попыток не
будет никаких улучшений. Во время каждой попытки программа выбирает случайное
решение и затем пытается улучшить его, случайным образом удаляя по одной
позиции до тех пор, пока в течение N последовательных изменений
не будет никаких улучшений.
При выборе
эвристики No Changes 2 (Без изменений 2)делается одна попытка. При этом
программа выбирает случайное решение и пытается улучшить его, случайным образом
удаляя по две позиции до тех пор, пока в течение N
последовательных изменений не будет никаких улучшений.
Названия эвристик и
их описания приведены в табл. 8.6.
Алгоритм «отжига»
Метод
XE "Метод:отжига" отжига
(simulated annealing XE "simulated annealing" ) ведет свое начало из термодинамики. При отжиге металла он
нагревается до высокой температуры. Молекулы в нагретом металле совершают
быстрые колебания, а при медленном остывании они начинают располагаться упорядоченно,
образуя кристаллы. При этом молекулы постепенно переходят в состояние с
минимальной энергией.
@Таблица 8.6.
Стратегии последовательных приближений
===========214
При медленном
остывании металла, соседние кристаллы сливаются друг с другом. Молекулы в одном
из кристаллов покидают состояние с минимальной энергией и принимают порядок
молекул в другом кристалле. Энергия получившегося кристалла большего размера
будет меньше, чем сумма энергий двух исходных кристаллов. Если охлаждение
происходит достаточно медленно, то кристаллы становятся очень большими.
Окончательное распределение молекул представляет состояние с очень низкой
энергией, и металл при этом будет очень твердым.
Начиная с состояния
с высокой энергией, молекулы в конце концов достигают состояния с очень низкой
энергией. На пути к конечному положению, они проходят множество локальных
минимумов энергии. Каждое сочетание кристаллов образует локальный минимум.
Кристаллы могут объединяться друг с другом только за счет временного повышения
энергии системы, чтобы затем перейти к состоянию с меньшей энергией.
Метод отжига
использует аналогичный подход для поиска наилучшего решения задачи. Во время
поиска решения программой, она может застрять в локальном оптимуме. Чтобы
избежать этого, программа время от времени вносит в решение случайные
изменения, даже если очередное изменение и не приводит к мгновенному улучшению
результата. Это может помочь программе выйти из локального оптимума и отыскать
лучшее решение. Если это изменение не ведет к лучшему решению, то вероятно,
через некоторое время программа его отбросит.
Чтобы эти изменения
не возникали постоянно, алгоритм изменяет вероятность возникновения случайных
изменений со временем. Вероятность P возникновения одного из
подобных изменений определяется формулой P = 1 / Exp(E / (k * T)), где E — увеличение «энергии» системы, k — некоторая постоянная, и T —
переменная, соответствующая «температуре».
Вначале температура
должна быть высокой, поэтому и вероятность изменений P = 1 / Exp(E / (k * T)) также достаточно велика. Иначе случайные изменения могли бы
никогда не возникнуть. С течением времени значение переменной T постепенно снижается, и вероятность случайных изменений также
уменьшается. После того, как модель дойдет до точки, в которой она никакие изменения
не смогут улучшить решение, и температура T станет
достаточно низкой, чтобы вероятность случайных изменений была мала, алгоритм
заканчивает работу.
Для задачи о
формирования портфеля, в качестве прибавки «энергии» E выступает уменьшение прибыли решения. Например, при удалении
позиции, которая дает прибыль 10 миллионов, и замене ее на позицию, которая
приносит 7 миллионов прибыли, энергия, добавленная к системе, будет равна 3.
Заметьте, что если
энергия велика, то вероятность изменений P = 1 / Exp(E / (k * T)) мала, поэтому вероятность
больших изменений ниже.
Алгоритм отжига в
программе Heur устанавливает
значение постоянной k равным разнице между
наибольшей и наименьшей прибылью возможных инвестиций. Начальная температура T задается равной 0,75. После выполнения определенного числа
случайных изменений, температура T уменьшается умножением на
постоянную 0,95.
=========215
Public Sub AnnealTrial(K As Integer,
max_non_changes As Integer, _
max_back_slips
As Integer)
Const TFACTOR = 0.95
Dim i As Integer
Dim non_changes As Integer
Dim t As Double
Dim max_profit As Integer
Dim min_profit As Integer
Dim doit As Boolean
Dim back_slips As Integer
'
Найти позицию с минимальной и максимальной прибылью.
max_profit = Items(1).Profit
min_profit
= max_profit
For i
= 2 To NumItems
If
max_profit < Items(i).Profit Then max_profit = Items(i).Profit
If
min_profit > Items(i).Profit Then min_profit = Items(i).Profit
Next i
t =
0.75 * (max_profit - min_profit)
back_slips
= 0
' Выбрать случайное пробное решение
' в качестве начальной точки.
Do While AddToSolution()
'
Вся работа выполняется в процедуре AddToSolution.
Loop
' Использовать в качестве пробного решения.
best_profit = test_profit
best_cost
= test_cost
For i
= 1 To NumItems
best_solution(i)
= test_solution(i)
Next
i
' Повторять, пока в течение max_non_changes
изменений
' подряд не будет улучшений.
non_changes = 0
Do
While non_changes < max_non_changes
'
Удалить случайную позицию.
For i = 1 To K
RemoveFromSolution
Next i
' Добавить максимально возможное число
позиций.
Do While AddToSolution()
'
Вся работа выполняется в процедуре AddToSolution.
Loop
' Если изменение улучшает пробное
решение, сохранить его.
' Иначе вернуть прежнее значение
решения.
If test_profit > best_profit Then
doit
= True
ElseIf
test_profit < best_profit Then
doit
= (Rnd < Exp((test_profit - best_profit) / t))
back_slips
= back_slips + 1
If
back_slips > max_back_slips Then
back_slips
= 0
t
= t * TFACTOR
End
If
Else
doit
= False
End
If
If
doit Then
'
Сохранить улучшение.
best_profit =
test_profit
best_cost
= test_cost
For
i = 1 To NumItems
best_solution(i)
= test_solution(i)
Next
i
non_changes
= 0 ' Хорошее изменение.
Else
'
Reset the trial.
test_profit
= best_profit
test_cost
= best_cost
For
i = 1 To NumItems
test_solution(i)
= best_solution(i)
Next
i
non_changes
= non_changes + 1 ' Плохое изменение.
End
If
Loop '
Продолжить проверку случайных изменений.
End Sub
Сравнение эвристик
Различные эвристики
по‑разному ведут себя в различных задачах. Для задачи о формировании
портфеля, эвристика сбалансированной прибыли работает достаточно хорошо,
учитывая ее простоту. Стратегии последовательного приближения обычно дают
сравнимые результаты, но для больших задач их выполнение занимает намного
больше времени. Для других задач наилучшей может быть какая‑либо другая
эвристика, в том числе из тех, которые не обсуждались в этой главе.
========216-217
Эвристические
методы обычно выполняются быстрее, чем метод ветвей и границ. Некоторые из них,
например методы восхождения на холм, наименьшей стоимости и сбалансированной
прибыли, выполняются очень быстро, так как они рассматривают только одно
возможное решение. Они выполняются настолько быстро, что имеет смысл выполнить
их все по очереди, и затем выбрать наилучшее из трех полученных решений. Это не
гарантирует того, что это решение будет наилучшим, но дает некоторую
уверенность, что оно окажется достаточно хорошим.
Другие
сложные задачи
Существует
множество очень сложных задач, большинство из которых не имеет решений с
полиномиальной вычислительной сложностью. Другими словами, не существует
алгоритмов, которые решали бы эти задачи за время порядка O(NC) для любых
постоянных C, даже за O(N1000).
В следующих
разделах кратко описаны некоторые из этих задач. В них также показано, почему
они являются сложными в общем случае и насколько большим может оказаться дерево
решений задачи. Вы можете попробовать применить метод ветвей и границ или
эвристики для решения некоторых из этих задач.
Задача о
выполнимости
XE
"Задача:о выполнимости" Если имеется логическое утверждение, например “(A And Not B) Or C”, то существуют ли значения переменных A,
B и C, при которых это
утверждение истинно? В данном примере легко увидеть, что утверждение истинно,
если A = true,
B = false
и C = false.
Для более сложных утверждений, содержащих сотни переменных, бывает достаточно
сложно определить, может ли быть утверждение истинным.
При помощи метода,
похожего на тот, который использовался при решении задачи о формировании
портфеля, можно простроить дерево решений для задачи о выполнимости (satisfiability problem XE "satisfiability problem" ). Каждая ветвь дерева будет соответствовать решению о
присвоении переменной значения true или false.
Например, левая ветвь, выходящая из корня, соответствует значению первой
переменной true.
Если в логическом
выражении N переменных, то дерево решений
представляет собой двоичное дерево высотой N + 1.
Это дерево имеет 2N листьев, каждый из
которых соответствует разной комбинации значений переменных.
В задаче о
формировании портфеля можно было использовать метод ветвей и границ для того,
чтобы избежать поиска в большей части дерева. В задаче о выполнимости выражение
либо истинно, либо ложно. При этом нельзя получить частичное решение, которое
можно использовать для отсечения путей в дереве.
Нельзя также
использовать эвристики для поиска приблизительного решения для задачи о
выполнимости. Любое значение переменных, полученное при помощи эвристики, будет
делать выражение истинным или ложным. В математической логике не существует
такого понятия, как приближенное решение.
Из‑за
неприменимости эвристик и меньшей эффективности метода ветвей и границ, задача
о выполнимости обычно является очень сложной и решается только в случае
небольшого размера задачи.
Задача о
разбиении
XE
"Задача:о разбиении" Если задано множество элементов со значениями X1, X2, … , XN, то существует ли способ разбить его на два
подмножества, так чтобы сумма значений всех элементов в каждом из подмножеств
была одинаковой? Например, если элементы имеют значения 3, 4, 5 и 6, то их
можно разбить на два подмножества {3, 6} и {4, 5}, сумма значений элементов в
каждом из которых равна 9.
Чтобы смоделировать
эту задачу при помощи дерева, предположим, что ветвям соответствует помещение
элемента в одно из двух подмножеств. Левая ветвь, выходящая из корневого узла,
соответствует помещению первого элемента в первое подмножество, а правая
ветвь — во второе подмножество.
Если всего
существует N элементов, то дерево решение будет
представлять собой двоичное дерево высотой N + 1.
Оно будет содержать 2N листьев и 2N+1 узлов. Каждый лист соответствует одному из вариантов
размещения элементов в двух подмножествах.
При решении этой
задачи можно применить метод ветвей и границ. При рассмотрении частичных
решений задачи можно отслеживать, насколько различаются суммарные значения
элементов в двух подмножествах. Если в какой‑то момент суммарное значение
элементов для одного из подмножеств настолько меньше, чем для другого, что добавление
всех оставшихся элементов не позволяет изменить это соотношение, то нет смысла
продолжать движение вниз по этой ветви.
Так же, как и в
случае с задачей о выполнимости, для задачи о разбиении (partition problem XE "partition problem" ) нельзя получить приближенное решение. В результате всегда
должно получиться два подмножества, суммарное значение элементов в которых
будет или не будет одинаковым. Это означает, что для решения этой задачи
неприменимы эвристики, которые использовались для решения задачи о формировании
портфеля.
Задачу о разбиении
можно обобщить следующим образом: если имеется множество элементов со
значениями X1, X2, … , XN, как разбить
его на два подмножества, чтобы разница суммы значений элементов в двух
подмножествах была минимальной?
Получить точное
решение этой задачи труднее, чем для исходной задачи о разбиении. Если бы
существовал простой способ решения задачи в общем случае, то его можно было бы
использовать для решения исходной задачи. В этом случае можно было бы просто
найти два подмножества, удовлетворяющих условиям, а затем проверить, совпадают
ли суммы значений элементов в них.
Для решения общего
случая задачи можно использовать метод ветвей и границ, примерно так же, как он
использовался для решения частного случая задачи, чтобы избежать поиска по
всему дереву. Можно также использовать при этом эвристический подход. Например,
можно проверять элементы в порядке убывания их значения, помещая очередной
элемент в подмножество с меньшей суммой значений элементов. Также можно было бы
легко использовать случайный поиск, метод последовательных приближений, или
метод отжига для поиска приближенного решения этого общего случая задачи.
Задача
поиска Гамильтонова пути
Если задана сеть,
то XE "Гамильтонов путь" Гамильтоновым
путем (Hamiltonian path
XE "Hamiltonian path" ) для нее называется путь, обходящий все
узлы в сети только один раз и затем возвращающийся в начальную точку.
На рис. 8.9 показана
небольшая сеть и Гамильтонов путь для нее, нарисованный жирной линией.
XE
"Задача:поиска Гамильтонова пути" Задача поиска Гамильтонова пути
формулируется так: если задана сеть, существует ли для нее Гамильтонов путь?
==============219
@Рис. 8.9.
Гамильтонов путь
Так как Гамильтонов
путь обходит все узлы в сети, то не нужно определять, какие из узлов попадают в
него, а какие нет. Необходимо установить только порядок, в котором их нужно
обойти для создания Гамильтонова пути.
Для моделирования
этой задачи при помощи дерева, предположим, что ветви соответствуют выбору
следующего узла в пути. Корневой узел тогда будет содержать N ветвей, соответствующих началу пути в каждом из N узлов. Каждый из узлов первого уровня будет иметь N – 1 ветвей, по одной ветви для каждого из оставшихся N – 1 узлов. Узлы на следующем уровне дерева будут иметь N – 2 ветвей, и так далее. Нижний уровень дерева будет содержать N! листьев, соответствующих N!
возможных путей. Всего в дереве будет находиться порядка O(N!) узлов.
Каждый лист
соответствует Гамильтонову пути, но число листьев может быть разным для
различных сетей. Если два узла в сети не связаны друг с другом, то в дереве
будут отсутствовать ветви, которые соответствуют переходам между этими двумя
узлами. Это уменьшает число путей в дереве и соответственно, число листьев.
Так
же, как и в задачах о выполнимости и о разбиении, для задачи поиска
Гамильтонова пути нельзя получить приближенное решение. Путь может либо
являться Гамильтоновым, либо нет. Это означает, что эвристический подход и
метод ветвей и границ не помогут при поиске Гамильтонова пути. Что еще хуже,
дерево решений для задачи поиска Гамильтонова пути содержит порядка O(N!) узлов. Это намного
больше, чем порядка O(2N) узлов, которые содержат деревья решений для задач о
выполнимости и разбиении. Например, 220 примерно равно 1 * 10 6,
тогда как 20! составляет около 2,4 * 1018 — в миллион раз
больше. Из‑за очень большого размера дерева решений задачи нахождения
Гамильтонова пути, поиск в нем можно выполнить только для задач очень
небольшого размера.
Задача
коммивояжера
XE
"Задача:коммивояжера" Задача коммивояжера (traveling salesman problem XE "traveling salesman problem" ) тесно связана с задачей поиска
Гамильтонова пути. Она формулируется так: найти самый короткий Гамильтонов путь
для сети.
========220
Эта задача имеет
примерно такое же отношение к задаче поиска Гамильтонова пути, как обобщенный
случай задачи о разбиении к простой задаче о разбиении. В первом случае
возникает вопрос о существовании решения. Во втором — какое приближенное
решение будет наилучшим. Если бы существовало простое решение второй задачи, то
его можно было бы использовать для решения первого варианта задачи.
Обычно задача
коммивояжера возникает только в сетях, содержащих большое число Гамильтоновых
путей. В типичном примере, коммивояжеру требуется посетить несколько клиентов,
используя кратчайший маршрут. В случае обычной сети улиц, любые две точки в
сети связаны между собой, поэтому любой маршрут представляет собой Гамильтонов
путь. Задача заключается в том, чтобы найти самый короткий из них.
Так же как и в
случае поиска Гамильтонова пути, дерево решений для этой задачи содержит
порядка O(N!) узлов. Так же, как и в
обобщенной задаче о разбиении, для отсечения ветвей дерева и ускорения поиска
решения задач средних размеров можно использовать метод ветвей и границ.
Существует также
несколько хороших эвристических методов последовательных приближений для задачи
коммивояжера. Например, использование стратегии пар путей, при которой
перебираются пары отрезков маршрута. Программа проверяет, станет ли маршрут
короче, если удалить пару отрезков и заменить их двумя новым, так чтобы маршрут
при этом оставался замкнутым. На рис. 8.10 показано как изменяется маршрут,
если отрезки X1 и X2 заменить отрезками Y1
и Y2. Аналогичные стратегии
последовательных приближений рассматривают замену трех или более отрезков пути
одновременно.
Обычно такие шаги
последовательного приближения повторяются многократно или до тех пор, пока не
будут проверены все возможные пары отрезков пути. После того, как дальнейшие
шаги не приводят к улучшениям, можно сохранить результат и начать работу снова,
случайным образом выбрав другой исходный маршрут. После проверки достаточно
большого числа различных случайных исходных маршрутов, вероятно будет найден
достаточно короткий путь.
Задача о
пожарных депо
XE
"Задача:о пожарных депо" Задача о пожарных депо (firehouse problem XE "firehouse problem" ) формулируется так: если задана сеть, некоторое число F, и расстояние D, то существует ли способ
размесить F пожарных депо таким образом, чтобы все
узлы сети находились не дальше, чем на расстоянии D от ближайшего пожарного депо?
@Рис. 8.10.
Последовательное приближение при решении задачи коммивояжера
========221
Эту задачу можно
смоделировать при помощи дерева решений, в котором каждая ветвь определяет
местоположение соответствующего пожарного депо в сети. Корневой узел будет
иметь N ветвей, соответствующих размещению
первого пожарного депо в одном из N узлов сети. Узлы на
следующем уровне дерева будут иметь N – 1 ветвей, соответствующих
размещению второго пожарного депо в одном из оставшихся N – 1 узлов. Если всего существует F
пожарных депо, то высота дерева решений будет равна F, и оно будет содержать порядка O(NF) узлов. В дереве будет N * (N – 1) * … * (N – F) листьев, соответствующих
разным вариантам размещения пожарных депо в сети.
Так же, как и в
задачах о выполнимости, разбиении, и поиске Гамильтонова пути, в этой задаче
нужно дать положительный или отрицательный ответ на вопрос. Это означает, что
при проверке дерева решений нельзя использовать частичные или приближенные
решения.
Можно, тем не
менее, использовать разновидность метода ветвей и границ, если на ранних этапах
решения определить, какие из вариантов размещения пожарных депо не приводят к
решению. Например, бессмысленно помещать очередное депо между двумя другими,
расположенными рядом. Если все узлы на расстоянии D от нового пожарного депо уже находятся в пределах этого
расстояния от другого депо, значит, новое депо нужно поместить в какое‑то
другое место. Тем не менее, такого рода вычисления также отнимают достаточно
много времени, и задача все еще остается очень сложной.
Так же, как и для
задач о разбиении и поиске Гамильтонова пути, существует обобщенный случай
задачи о пожарных депо. В обобщенном случае задача формулируется так: если
задана сеть и некоторое число F, в каких узлах сети нужно
поместить F пожарных депо, чтобы наибольшее
расстояние от любого узла до пожарного депо было минимальным?
Так же, как и
обобщенных случаях других задач, для поиска частичного и приближенного решений
этой задачи можно использовать метод ветвей и границ и эвристический подход.
Это несколько упрощает проверку дерева решений. Хотя дерево решений все еще
остается огромным, можно по крайней мере найти приблизительные решения, даже
если они и не являются наилучшими.
Краткая характеристика
сложных задач
Во время чтения
предыдущих параграфов вы могли заметить, что существует два варианта многих
сложных задач. Первый вариант задачи задает вопрос: «Существует ли решение
задачи, удовлетворяющее определенным условиям?». Второй, более общий случай
дает ответ на вопрос: «Какое решение задачи будет наилучшим?»
Обе задачи при этом
имеют одинаковое дерево решений. В первом случае дерево решений просматривается
до тех пор, пока не будет найдено какое‑либо решение. Так как для этих
задач не существует частичного или приближенного решения, то обычно нельзя
использовать для уменьшения объема работы эвристический подход или метод ветвей
и границ. Обычно всего лишь несколько путей в дереве ведут к решению, поэтому
решение этих задач — очень трудоемкий процесс.
При решении же
обобщенного случая задачи, часто можно использовать частичные решения и
применить метод ветвей и границ. Это не облегчает поиск наилучшего решения
задачи, поэтому не поможет получить точное решение для частной задачи. Например,
сложнее найти самый короткий
Гамильтонов путь в сети, чем найти произвольный
Гамильтонов путь для той же сети.
==========222
С другой стороны,
эти вопросы обычно относятся к различным входным данным. Обычно вопрос о существовании Гамильтонова пути возникает,
если сеть разрежена, и сложно сказать, существует ли такой путь. Вопрос о кратчайшем Гамильтоновом пути возникает
обычно, если сеть достаточно плотная и существует множество таких путей. В этом
случае легко найти частичные решения, и метод ветвей и границ может сильно
упростить решение задачи.
Резюме
Можно использовать
деревья решений для моделирования различных задач. Поиск наилучшего решения
задачи соответствует при этом поиску наилучшего пути в дереве. К сожалению,
деревья решений для многих интересных задач имеют огромный размер, поэтому
решить такие задачи методом полного перебора можно только для очень небольших
задач.
Метод ветвей и
границ позволяет отсекать большую часть ветвей в некоторых деревьях решений,
что позволяет получать точное решение для задач гораздо большего размера.
Тем не менее, для
самых больших задач, даже применение метода ветвей и границ не может помочь. В
этом случае, для получения приблизительного решения необходимо использовать
эвристический подход для получения приблизительных решений. При помощи методов
случайного поиска и последовательных приближений можно найти приемлемое
решение, даже если неизвестно, будет ли оно наилучшим возможным решением
задачи.
==========223
Глава 9. Сортировка
Сортировка — одна из наиболее активно изучаемых тем в
компьютерных алгоритмах по ряду причин. Во-первых, сортировка — это
задача, которая часть встречается во многих приложениях. Почти любой список
данных будет нести больше смысла, если его отсортировать каким‑либо
образом. Часто требуется сортировать данные несколькими различными способами.
Во‑вторых, многие алгоритмы сортировки являются
интересными примерами программирования. Они демонстрируют важные методы, такие
как частичное упорядочение, рекурсия, слияние списков и хранение двоичных деревьев
в массиве.
Наконец, сортировка является одной из немногих задач с
точными теоретическими ограничениями производительности. Можно показать, что
время выполнения любого алгоритма сортировки, который использует сравнения,
составляет порядка O(N * log(N)).
Некоторые алгоритмы достигают теоретического предела, то есть они являются
оптимальными в этом смысле. Есть даже ряд несколько алгоритмов, которые
используют другие методы вместо сравнений, которые выполняются быстрее, чем за
время порядка O(N * log(N)).
Общие соображения
В этой главе описаны некоторые алгоритмы сортировки, которые
ведут себя по‑разному в различных обстоятельствах. Например, пузырьковая
сортировка опережает быструю сортировку по скорости работы, если сортируемые
элементы уже были почти упорядочены, но работает медленнее, если элементы были
расположены хаотично.
Особенности каждого алгоритма описаны в параграфе, в котором
он обсуждается. Перед тем как перейти к рассмотрению отдельных алгоритмов,
вначале в этой главе обсуждаются вопросы, которые влияют на все алгоритмы
сортировки.
Таблицы указателей
При сортировке элементов данных, программа организует из них
некоторое подобие структуры данных. Этот процесс может быть быстрым или
медленным в зависимости от типа элементов. Перемещение целого числа на новое
положение в массиве может быть намного быстрее, чем перемещение определенной
пользователем структуры данных. Если эта структура представляет собой список
данных о сотруднике, содержащий тысячи байт информации, копирование одного
элемента может занять достаточно много времени.
========225
Для повышения производительности при сортировке больших
объектов можно помещать ключевые поля данных, используемые для сортировки, в
таблицу индексов. В этой таблице находятся ключи к записям и индексы элементов
другого массива, в котором и находятся записи данных. Например, предположим,
что вы собираетесь отсортировать список записей о сотрудниках, определяемый
следующей структурой:
Type Emloyee
ID As Integer
LastName As String
FirstName As String
<и т.д.>
End Type
‘ Выделить память под записи.
Dim
EmloyeeData(1 To 10000)
Чтобы отсортировать сотрудников по идентификационному
номеру, нужно создать таблицу индексов, которая содержит индексы и значения ID values из записей.
Индекс элемента показывает, какая запись в массиве EmployeeData содержит соответствующие данные.
Type IdIndex
ID As Integer
Index As Integer
End Type
‘ Таблица индексов.
Dim
IdIndexData(1 To 10000)
Проинициализируем таблицу индексов так, чтобы первый индекс
указывал на первую запись данных, второй — на вторую, и т.д.
For i = 1 To
10000
IdIndexData(i).ID = EmployeeData(i).ID
IdIndexData(i).Index = i
Next i
Затем, отсортируем таблицу индексов по идентификационному
номеру ID.
После этого, поле Index
в каждом элементе IdIndexData указывает на соответствующую запись данных. Например, первая
запись в отсортированном списке — это EmployeeData(IdIndexData(1).Index). На рис. 9.1 показана взаимосвязь между
индексом и записью данных до, и после сортировки.
=======226
@Рисунок 9.1. Сортировка с помощью таблицы индексов
Для того, чтобы сортировать данные в разном порядке, можно
создать несколько различных таблиц индексов и управлять ими по отдельности. В
приведенном примере можно было бы создать еще одну таблицу индексов,
упорядочивающую сотрудников по фамилии. Подобно этому списки со ссылками могут
сортировать список различными способами, как показано во 2 главе. При
добавлении или удалении записи необходимо обновлять каждую таблицу индексов
независимо.
Помните, что таблицы индексов занимают дополнительную
память. Если создать по таблице индексов для каждого из полей данных, объем
занимаемой памяти более чем удвоится.
Объединение и сжатие ключей
Иногда можно хранить ключи списка в комбинированной или
сжатой форме. Например, можно было бы XE "Ключи:объединение" объединить (combine)
в программе два поля, соответствующих имени и фамилии, в одни ключ. Это
позволило бы упростить и ускорить сравнение. Обратите внимание на различия
между двумя следующими фрагментами кода, которые сравнивают две записи о
сотрудниках:
‘ Используя разные ключи.
If
emp1.LastName > emp2.LastName Or _
(emp1.LastName = emp2.LastName And _
And emp1.FirstName > emp2.FirstName)
Then
DoSomething
‘ Используя
объединенный ключ.
If
emp1.CominedName > emp2.CombinedName Then
DoSomething
========227
Также иногда можно XE "Ключи:сжатие" сжимать (compress)
ключи. Сжатые ключи занимают меньше места, уменьшая размер таблиц индексов. Это
позволяет сортировать списки большего размера без перерасхода памяти, быстрее
перемещать элементы в списке, и часто также ускоряет сравнение элементов.
Одни из методов сжатия строк — кодирование их целыми
числами или данными другого числового формата. Числовые данные занимают меньше
места, чем строки и сравнение двух численных значений также происходит намного
быстрее, чем сравнение двух строк. Конечно, строковые операции неприменимы для
строк, представленных числами.
Например, предположим, что мы хотим закодировать строки,
состоящие из заглавных латинских букв. Можно считать, что каждый символ —
это число по основанию 27. Необходимо использовать основание 27, чтобы
представить 26 букв и еще одну цифру для обозначения конца слова. Без отметки
конца слова, закодированная строка AA
шла бы после строки B,
потому что в строке AA
две цифры, а в строке B —
одна.
Код по основанию 27 для строки из трех символов дает формула
272 * (первая буква - A + 1) + 27 * (вторая буква - A + 1) + 27 *
(третья буква - A + 1). Если в строке меньше трех символов, вместо значения
(третья буква - A + 1) подставляется 0. Например, строка FOX кодируется
так:
272
* (F - A + 1) + 27 * (O - A + 1) + (X - A +1) = 4803
Строка NO
кодируется следующим образом:
272
* (N - A + 1) + 27 * (O - A + 1) + (0) = 10.611
Заметим, что 10.611 больше 4803, поскольку NO > FOX.
Таким же образом можно закодировать строки из 6 заглавных
букв в виде числа в формате long
и строки из 10 букв — как число в формате double. Две следующие процедуры конвертируют
строки в числа в формате double
и обратно:
Const
STRING_BASE = 27
Const ASC_A =
65 ‘ ASCII код для
символа "A".
‘ Преобразование строки с число в формате double.
‘
‘ full_len — полная длина, которую должна иметь строка.
‘ Нужна, если строка слишком короткая (например
"AX" —
‘ это строка из трех символов).
Function
StringToDbl (txt As String, full_len As Integer) As Double
Dim strlen As
Integer
Dim i As
Integer
Dim value As
Double
Dim ch As
String * 1
strlen = Len(txt)
If strlen > full_len Then strlen =
full_len
value = 0#
For i = 1 To strlen
ch = Mid$(txt, i, 1)
value = value * STRING_BASE + Asc(ch) -
ASC_A + 1
Next i
For i = strlen + 1 To full_len
value = value * STRING_BASE
Next i
End Function
‘ Обратное декодирование строки из формата double.
Function
DblToString (ByVal value As Double) As String
Dim strlen As
Integer
Dim i As
Integer
Dim txt As
String
Dim Power As
Integer
Dim ch As
Integer
Dim new_value
As Double
txt = ""
Do While value > 0
new_value = Int(value / STRING_BASE)
ch = value - new_value * STRING_BASE
If ch <> 0 Then txt = Chr$(ch +
ASC_A - 1) + txt
value = new_value
Loop
DblToString = txt
End Function
===========228
В табл. 9.1 приведено время выполнения программой Encode сортировки
2000 строк различной длины на компьютере с процессором Pentium и тактовой
частотой 90 МГц. Заметим, что результаты похожи для каждого типа кодирования.
Сортировка 2000 чисел в формате double
занимает примерно одинаковое время независимо от того, представляют ли они
строки из 3 или 10 символов.
========229
@Таблица 9.1.
Время сортировки 2000 строк с использованием различных кодировок в секундах
Можно также кодировать строки, состоящие не только из
заглавных букв. Строку из заглавных букв и цифр можно закодировать по основанию
37 вместо 27. Код буквы A будет равен 1, B — 2, … , Z — 26, код 0 будет
27, … , и 9 — 36. Строка AH7
будет кодироваться как 372 * 1 + 37 * 8 + 35 = 1700.
Конечно, при использовании большего основания, длина строки,
которую можно закодировать числом типа integer, long или double будет соответственно короче. При
основании равном 37, можно закодировать строку из 2 символов в числе формата integer, из 5
символов в числе формата long,
и 10 символов в числе формата double.
Примеры программ
Чтобы облегчить сравнение различных алгоритмов сортировки,
программа Sort
демонстрирует большинство алгоритмов, описанных в этой главе. Сортировка
позволяет задать число сортируемых элементов, их максимальное значение, и
порядок расположения элементов - прямой, обратный или расположение в случайном
порядке. Программа создает список случайно расположенных чисел в формате long и сортирует
его, используя выбранный алгоритм. Вначале сортируйте короткие списки, пока не
определите, насколько быстро ваш компьютер может выполнять операции сортировки.
Это особенно важно для медленных алгоритмов сортировки вставкой, сортировки
вставкой с использованием связного списка, сортировки выбором, и пузырьковой
сортировки.
Некоторые алгоритмы перемещают большие блоки памяти.
Например, алгоритм сортировки вставкой перемещает элементы списка для того,
чтобы можно было вставить новый элемент в середину списка. Для перемещения
элементов программе, написанной на Visual Basic, приходится использовать цикл For. Следующий код показывает, как сортировка
вставкой перемещает элементы с List(j) до
List(max_sorted)
для того, чтобы освободить место под новый элемент в позиции List(j):
For k =
max_sorted To j Step -1
List(k + 1) = List(k)
Next k
List(j) =
next_num
==========230
Интерфейс прикладного программирования системы Windows включает две функции,
которые позволяют намного быстрее выполнять перемещение блоков памяти.
Программы, скомпилированные 16‑битной версией компилятора Visual Basic 4, могут использовать
функцию hmemcopy.
Программы, скомпилированные 32‑битными компиляторами Visual Basic 4 и 5, могут
использовать функцию RtlMoveMemory.
Обе функции принимают в качестве параметров конечный и исходный адреса и число
байт, которое должно быть скопировано. Следующий код показывает, как объявлять
эти функции в модуле .BAS:
#if Win16
Then
Declare Sub MemCopy Lib "Kernel"
Alias _
"hmemcpy" (dest As Any, src As
Any, _
ByVal numbytes As Long)
#Else
Declare Sub MemCopy Lib "Kernel32"
Alias _
"RtlMoveMemory" (dest As Any,
src As Any, _
ByVal numbytes As Long)
#EndIf
Следующий фрагмент кода показывает, как сортировка вставкой
может использовать эти функции для копирования блоков памяти. Этот код
выполняет те же действия, что и цикл For, приведенный выше, но делает это намного
быстрее:
If max_sorted
>= j Then _
MemCopy List(j + 1), List(j), _
Len(next_num) * (max_sorted - j + 1)
List(j) =
next_num
Программа FastSort
аналогична программе Sort,
но она использует функцию MemCopy
для ускорения работы некоторых алгоритмов. В программе FastSort
алгоритмы, использующие функцию MemCopy,
выделены синим цветом.
Сортировка выбором
XE "Сортировка:выбором" Сортировка выбором (selectionsort
XE "selectionsort"
2). Идея состоит в поиске
наименьшего элемента в списке, который затем меняется местами с элементом на
вершине списка. Затем находится наименьший элемент из оставшихся, и меняется
местами со вторым элементом. Процесс продолжается до тех пор, пока все элементы
не займут свое конечное положение.
Public Sub
Selectionsort(List() As Long, min As Long, max As Long)
Dim i As Long
Dim j As Long
Dim
best_value As Long
Dim best_j As
Long
For i = min To max - 1
‘ Найти наименьший элемент из
оставшихся.
best_value = List(i)
best_j = i
For j = i + 1 To max
If List(j) < best_value Then
best_value = List(j)
best_j = j
End If
Next j
‘ Поместить
элемент на место.
List(best_j) = List(i)
List(i) = best_value
Next i
End Sub
========231
При поиске I-го наименьшего элемента, алгоритму приходится
перебрать N-I элементов, которые еще не заняли свое конечное положение. Время
выполнения алгоритма пропорционально N + (N - 1) + (N - 2) + … + 1, или порядка
O(N2).
Сортировка выбором неплохо работает со списками, элементы в
которых расположены случайно или в прямом порядке, но несколько хуже, если
список изначально отсортирован в обратном порядке. Для поиска наименьшего
элемента в списке сортировка выбором выполняет следующий код:
If list(j)
< best_value Then
best_value = list(j)
best_j = j
End If
Если первоначально список отсортирован в обратном порядке,
условие list(j) < best_value выполняется
большую часть времени. Например, при первом проходе оно будет истинно для всех
элементов, поскольку каждый элемент меньше предыдущего. Алгоритм будет
многократно выполнять строки с оператором If, что приведет к некоторому замедлению
работы алгоритма.
Это не самый быстрый алгоритм из числа описанных в главе, но
он чрезвычайно прост. Это не только облегчает его разработку и отладку, но и
делает сортировку выбором достаточно быстрой для небольших задач. Многие другие
алгоритмы настолько сложны, что они сортируют очень маленькие списки медленнее.
Рандомизация
XE "Сортировка:рандомизация" Рандомизацию (unsorting
XE "unsorting"
Для каждого положения в списке, алгоритм случайным образом
выбирает элемент, который должен его занять из тех, которые еще не были
помещены на свое место. Затем этот элемент меняется местами с элементом,
который, находится на этой позиции.
Public Sub
Unsort(List() As Long, min As Long, max As Long)
Dim i As Long
Dim Pos As
Long
Dim tmp As
Long
For i - min To max - 1
pos = Int((max - i + 1) * Rnd + i)
tmp = List(pos)
List(pos) = List(i)
List(i) = tmp
Next i
End Sub
==============232
Т.к. алгоритм заполняет каждую позицию только один раз, его
сложность порядка O(N).
Несложно показать, что вероятность того, что элемент
окажется на какой‑либо позиции, равна 1/N. Поскольку элемент может
оказаться в любом положении с равной вероятностью, этот алгоритм действительно
приводит к случайному размещению элементов.
Результат зависит от того, насколько хорошим является
генератор случайных чисел. Функция Rnd
в Visual Basic
дает приемлемый результат для большинства случаев. Следует убедиться, что
программа использует оператор Randomize
для инициализации функции Rnd,
иначе при каждом запуске программы функция Rnd будет выдавать одну и ту же
последовательность «случайных» значений.
Заметим, что для алгоритма не важен первоначальный порядок
расположения элементов. Если вам необходимо неоднократно рандомизировать список
элементов, нет необходимости его предварительно сортировать.
Программа Unsort
показывает использование этого алгоритма для рандомизации отсортированного
списка. Введите число элементов, которые вы хотите рандомизировать, и нажмите
кнопку Go (Начать). Программа показывает
исходный отсортированный список чисел и результат рандомизации.
Сортировка вставкой
XE "Сортировка:вставкой" Сортировка вставкой (insertionsort
XE "insertionsort"
2). Идея состоит в том,
чтобы создать новый сортированный список, просматривая поочередно все элементы
в исходном списке. При этом, выбирая очередной элемент, алгоритм просматривает
растущий отсортированный список, находит требуемое положение элемента в нем, и
помещает элемент на свое место в новый список.
Public Sub
Insertionsort(List() As Long, min As Long, max As Long)
Dim i As Long
Dim j As Long
Dim k As Long
Dim
max_sorted As Long
Dim next_num
As Long
max_sorted = min -1
For i = min To max
‘
Это вставляемое число.
Next_num = List(i)
‘ Поиск его позиции в списке.
For j = min To max_sorted
If List(j) >= next_num Then Exit
For
Next j
‘ Переместить
большие элементы вниз, чтобы
‘ освободить
место для нового числа.
For k = max_sorted To j Step -1
List(k + 1) = List(k)
Next k
‘ Поместить новый
элемент.
List(j) = next_num
‘ Увеличить счетчик
отсортированных элементов.
max_sorted = max_sorted + 1
Next i
End Sub
=======233
Может оказаться, что для каждого из элементов в исходном
списке, алгоритму придется проверять все уже отсортированные элементы. Это
происходит, например, если в исходном списке элементы были уже отсортированы. В
этом случае, алгоритм помещает каждый новый элемент в конец растущего
отсортированного списка.
Полное число шагов, которые потребуется выполнить,
составляет 1 + 2 + 3 + … + (N - 1), то есть O(N2). Это не слишком
эффективно, если сравнить с теоретическим пределом O(N * log(N)) для алгоритмов на основе
операций сравнения. Фактически, этот алгоритм не слишком быстр даже в сравнении
с другими алгоритмами порядка O(N2), такими как сортировка выбором.
Достаточно много времени алгоритм сортировки вставкой тратит
на перемещение элементов для того, чтобы вставить новый элемент в середину
отсортированного списка. Использование для этого функции API MemCopy
увеличивает скорость работы алгоритма почти вдвое.
Достаточно много времени тратится и на поиск правильного
положения для нового элемента. В 10 главе описано несколько алгоритмов поиска в
отсортированных списках. Применение алгоритма интерполяционного поиска намного
ускоряет выполнение алгоритма сортировки вставкой. Интерполяционный поиск
подробно описывается в 10 главе, поэтому мы не будем сейчас на нем
останавливаться.
Программа FastSort
использует оба этих метода для улучшения производительности сортировки
вставкой. С использованием функции MemCopy
и интерполяционного поиска, эта версия алгоритма более чем в 15 раз быстрее,
чем исходная.
Вставка в связных списках
Можно использовать вариант сортировки вставкой для
упорядочения элементов не в массиве, а в связном списке. Этот алгоритм ищет
требуемое положение элемента в растущем связном списке, и затем помещает туда
новый элемент, используя операции работы со связными списками.
=========234
Public Sub
LinkInsertionSort(ListTop As ListCell)
Dim new_top
As New ListCell
Dim old_top
As ListCell
Dim cell As
ListCell
Dim after_me
As ListCell
Dim nxt As
ListCell
Set old_top = ListTop.NextCell
Do While Not (old_top Is Nothing)
Set cell = old_top
Set old_top = old_top.NextCell
‘ Найти, куда необходимо
поместить элемент.
Set after_me = new_top
Do
Set nxt = after_me.NextCell
If nxt Is Nothing Then Exit Do
If nxt.Value >= cell.Value Then
Exit Do
Set after_me = nxt
Loop
‘ Вставить
элемент после позиции after_me.
Set after_me.NextCll = cell
Set cell.NextCell = nx
Loop
Set ListTop.NextCell = new_top.NextCell
End Sub
Т.к. этот алгоритм перебирает все элементы, может
потребоваться сравнение каждого элемента со всеми элементами в отсортированном
списке. В этом наихудшем случае вычислительная сложность алгоритма порядка O(N2).
Наилучший случай для этого алгоритма достигается, когда
исходный список первоначально отсортирован в обратном порядке. При этом каждый
последующий элемент меньше, чем предыдущий, поэтому алгоритм помещает его в
начало отсортированного списка. При этом требуется выполнить только одну
операцию сравнения элементов, и в наилучшем случае время выполнения алгоритма
будет порядка O(N).
В усредненном случае, алгоритму придется провести поиск
примерно по половине отсортированного списка для того, чтобы найти
местоположение элемента. При этом алгоритм выполняется примерно за 1 + 1 + 2 +
2 + … + N/2, или порядка O(N2) шагов.
Улучшенная процедура сортировки вставкой, использующая
интерполяционный поиск и функцию MemCopy,
работает намного быстрее, чем версия со связным списком, поэтому последнюю
процедуру лучше использовать, если программа уже хранит элементы в связном
списке.
Преимущество использования связных списков для вставки в
том, что при этом перемещаются только указатели, а не сами записи данных.
Передача указателей может быть быстрее, чем копирование записей целиком, если
элементы представляют собой большие структуры данных.
=======235
Пузырьковая сортировка
XE "Сортировка:пузырьковая" Пузырьковая сортировка (bubblesort
XE "bubblesort"
2). Поэтому перед применением
пузырьковой сортировки важно убедиться, что элементы в основном расположены по
порядку.
При пузырьковой сортировке список просматривается до тех
пор, пока не найдутся два соседних элемента, расположенных не по порядку. Тогда
они меняются местами, и процедура продолжается дальше. Алгоритм повторяет этот
процесс до тех пор, пока все элементы не займут свои места.
На рис. 9.2 показано, как алгоритм вначале обнаруживает, что
элементы 6 и 3 расположены не по порядку, и поэтому меняет их местами. Во время
следующего прохода, меняются местами элементы 5 и 3, в следующем — 4 и 3.
После еще одного прохода алгоритм обнаруживает, что все элементы расположены по
порядку, и завершает работу.
Можно проследить за перемещениями элемента, который
первоначально был расположен ниже, чем после сортировки, например элемента 3 на
рис. 9.2. Во время каждого прохода элемент перемещается на одну позицию ближе к
своему конечному положению. Он движется к вершине списка подобно пузырьку газа,
который всплывает к поверхности в стакане воды. Этот эффект и дал название
алгоритму пузырьковой сортировки.
Можно внести в алгоритм несколько улучшений. Во‑первых,
если элемент расположен в списке выше, чем должно быть, вы увидите картину,
отличную от той, которая приведена на рис. 9.2. На рис. 9.3 показано, что
алгоритм вначале обнаруживает, что элементы 6 и 3 расположены в неправильном
порядке, и меняет их местами. Затем алгоритм продолжает просматривать массив и
замечает, что теперь неправильно расположены элементы 6 и 4, и также меняет их
местами. Затем меняются местами элементы 6 и 5, и элемент 6 занимает свое
место.
@Рис. 9.2. «Всплывание» элемента
========236
@Рис. 9.3. «Погружение» элемента
При просмотре массива сверху вниз, элементы, которые
перемещаются вверх, сдвигаются всего на одну позицию. Те же элементы, которые
перемещаются вниз, сдвигаются на несколько позиций за один проход. Этот факт
можно использовать для ускорения работы алгоритма пузырьковой сортировки. Если
чередовать просмотр массива сверху вниз и снизу вверх, то перемещение элементов
в прямом и обратном направлениях будет одинаково быстрым.
Во время проходов сверху вниз, наибольший элемент списка
перемещается на место, а во время проходов снизу вверх — наименьший. Если
M элементов списка расположены не на своих местах, алгоритму потребуется не
более M проходов для того, чтобы расположить элементы по порядку. Если в списке
N элементов, алгоритму потребуется N шагов для каждого прохода. Таким образом,
полное время выполнения для этого алгоритма будет порядка O(M * N).
Если первоначально список организован случайным образом,
большая часть элементов будет находиться не на своих местах. В примере,
приведенном на рис. 9.3, элемент 6 трижды меняется местами с соседними
элементами. Вместо выполнения трех отдельных перестановок, можно сохранить
значение 6 во временной переменной до тех пор, пока не будет найдено конечное
положение элемента. Это может сэкономить большое число шагов алгоритма, если
элементы перемещаются на большие расстояния внутри массива.
Последнее улучшение — ограничение проходов массива. После
просмотра массива, последние переставленные элементы обозначают часть массива,
которая содержит неупорядоченные элементы. При проходе сверху вниз, например,
наибольший элемент перемещается в конечное положение. Поскольку нет больших
элементов, которые нужно было бы поместить за ним, то можно начать очередной
проход снизу вверх с этой точки и на ней же заканчивать следующие проходы
сверху вниз.
========237
Таким же образом, после прохода снизу вверх, можно сдвинуть
позицию, с которой начнется очередной проход сверху вниз, и будут заканчиваться
последующие проходы снизу вверх.
Реализация алгоритма пузырьковой сортировки на языке Visual Basic использует переменные min и max для
обозначения первого и последнего элементов списка, которые находятся не на своих
местах. По мере того, как алгоритма повторяет проходы по списку, эти переменные
обновляются, указывая положение последней перестановки.
Public Sub
Bubblesort(List() As Long, ByVal min As Long, ByVal max As Long)
Dim last_swap
As Long
Dim i As Long
Dim j As Long
Dim tmp As
Long
‘ Повторять до завершения.
Do While min < max
‘ «Всплывание».
last_swap = min - 1
‘ То есть For i =
min + 1 To max.
i = min + 1
Do While i <= max
‘ Найти «пузырек».
If List(i - 1) > List(i) Then
‘ Найти, куда его
поместить.
tmp = List(i - 1)
j = i
Do
List(j - 1) = List(j)
j = j + 1
If j > max Then Exit Do
Loop While List(j) < tmp
List(j - 1) = tmp
last_swap = j - 1
i = j + 1
Else
i = i + 1
End If
Loop
‘ Обновить
переменную max.
max = last_swap - 1
‘ «Погружение».
last_swap = max + 1
‘ То есть For i =
max -1 To min Step -1
i = max - 1
Do While i >= min
‘ Найти «пузырек».
If List(i + 1) < List(i) Then
‘ Найти, куда его
поместить.
tmp = List(i + 1)
j = i
Do
List(j + 1) = List(j)
j = j - 1
If j < min Then Exit Do
Loop While List(j) > tmp
List(j + 1) = tmp
last_swap = j + 1
i = j - 1
Else
i = i - 1
End If
Loop
‘ Обновить
переменную min.
Min = last_swap + 1
Loop
End Sub
==========238
Для того чтобы протестировать алгоритм пузырьковой
сортировки при помощи программы Sort,
поставьте галочку в поле Sorted (Отсортированные)
в области Initial Ordering (Первоначальный порядок). Введите число элементов в
поле #Unsorted
(Число несортированных). После нажатия на кнопку Go
(Начать), программа создает и сортирует список, а затем переставляет случайно
выбранные пары элементов. Например, если вы введете число 10 в поле #Unsorted, программа переставит
5 пар чисел, то есть 10 элементов окажутся не на своих местах.
Для второго варианта первоначального алгоритма, программа
сохраняет элемент во временной переменной при перемещении на несколько шагов.
Этот происходит еще быстрее, если использовать функцию API MemCopy. Алгоритм
пузырьковой сортировки в программе FastSort,
используя функцию MemCopy,
сортирует элементы в 50 или 75 раз быстрее, чем первоначальная версия,
реализованная в программе Sort.
В табл. 9.2 приведено время выполнения пузырьковой сортировки
2000 элементов на компьютере с процессором Pentium с тактовой частотой 90 МГц в
зависимости от степени первоначальной упорядоченности списка. Из таблицы видно,
что алгоритм пузырьковой сортировки обеспечивает хорошую производительность,
только если список с самого начала почти отсортирован. Алгоритм быстрой
сортировки, который описывается далее в этой главе, способен отсортировать тот
же список из 2000 элементов примерно за 0,12 сек, независимо от первоначального
порядка расположения элементов в списке. Пузырьковая сортировка может превзойти
этот результат, только если примерно 97 процентов списка было упорядочено до
начала сортировки.
=====239
@Таблица 9.2. Время пузырьковой сортировки 2.000 элементов
Несмотря на то, что пузырьковая сортировка медленнее, чем
многие другие алгоритмы, у нее есть свои применения. Пузырьковая сортировка
часто дает наилучшие результаты, если список изначально уже почти упорядочен.
Если программа управляет списком, который сортируется при создании, а затем к
нему добавляются новые элементы, пузырьковая сортировка может быть лучшим
выбором.
Быстрая сортировка
XE "Сортировка:быстрая" Быстрая сортировка (quicksort
XE "quicksort"
Первая версия алгоритма быстрой сортировки, обсуждаемая
здесь, достаточно проста. Если алгоритм вызывается для подсписка, содержащего
не более одного элемента, то подсписок уже отсортирован, и подпрограмма
завершает работу.
Иначе, процедура выбирает какой‑либо элемент из списка
и использует его для разбиения списка на два подсписка. Она помещает элементы,
которые меньше, чем выбранный элементы в первый подсписок, а остальные —
во второй, и затем рекурсивно вызывает себя для сортировки двух подсписков.
Public Sub
QuickSort(List() As Long, ByVal min as Integer, _
ByVal max As Integer)
Dim med_value
As Long
Dim hi As
Integer
Dim lo As
Integer
‘ Если осталось менее 1 элемента,
подсписок отсортирован.
If min >= max Then Exit Sub
‘ Выбрать значение для деления
списка.
med_value = list(min)
lo = min
hi = max
Do
Просмотр от hi до значения < med_value.
Do While list(hi) >= med_value
hi = hi - 1
If hi <= lo Then Exit Do
Loop
If hi <= lo Then
list(lo) = med_value
Exit Do
End If
‘ Поменять местами значения lo и
hi.
list(lo) = list(hi)
‘ Просмотр от lo до значения
>= med_value.
lo = lo + 1
Do While list(lo) < med_values
lo = lo + 1
If lo >= hi Then Exit Do
Loop
If lo >= hi Then
lo = hi
list(hi) = med_value
Exit Do
End If
‘ Поменять местами значения lo и
hi.
list(hi) = list(lo)
Loop
‘ Рекурсивная
сортировка двух подлистов.
QuickSort list(), min, lo - 1
QuickSort list(), lo + 1, max
End Sub
=========240
Есть несколько важных моментов в этой версии алгоритма,
которые стоит упомянуть. Во‑первых, значение med_value для деления списка не входит ни в один
подсписок. Это означает, что в двух подсписках содержится на одни элемент
меньше, чем в исходном списке. Т.к. число рассматриваемых элементов
уменьшается, то в конечном итоге алгоритм завершит работу.
Эта версия алгоритма использует в качестве разделителя
первый элемент в списке. В идеале, это значение должно было бы находиться где‑то
в середине списка, так чтобы два подсписка были примерно равного размера. Тем
не менее, если элементы первоначально почти отсортированы, то первый элемент —
наименьший в списке. При этом алгоритм не поместит ни одного элемента в первый
подсписок, и все элементы во второй. Последовательность действий алгоритма
будет примерно такой, как показано на рис. 9.4.
В этом случае каждый вызов подпрограммы требует порядка O(N)
шагов для перемещения всех элементов во второй подсписок. Т.к. алгоритм
рекурсивно вызывает себя N - 1 раз, время его выполнения будет порядка O(N2),
что не лучше, чем у ранее рассмотренных алгоритмов. Ситуацию еще более ухудшает
то, что уровень вложенности рекурсии алгоритма N - 1. Для больших списков
огромная глубина рекурсии приведет к переполнению стека и сбою в работе
программы.
=========242
@Рис. 9.4. Быстрая сортировка упорядоченного списка
Существует много стратегий выбора разделительного элемента.
Можно использовать элемент из середины списка. Это может оказаться неплохим
выбором, тем не менее, может оказаться и так, что это окажется наименьший или
наибольший элемент списка. При этом один подсписок будет намного больше, чем
другой, что приведет к снижению производительности до порядка O(N2)
и глубокому уровню рекурсии.
Другая стратегия может заключаться в том, чтобы просмотреть
весь список, вычислить среднее арифметическое всех значений, и использовать его
в качестве разделительного значения. Этот подход будет давать неплохие
результаты, но потребует дополнительных усилий. Дополнительный проход со
сложностью порядка O(N) не изменит теоретическое время выполнения алгоритма, но
снизит общую производительность.
Третья стратегия — выбрать средний из элементов в
начале, конце и середине списка. Преимущество этого подхода в быстроте, потому
что потребуется выбрать всего три элемента. При этом гарантируется, что этот
элемент не является наибольшим или наименьшим в списке, и вероятно окажется где‑то
в середине списка.
И, наконец, последняя стратегия, которая используется в
программе Sort,
заключается в случайном выборе элемента из списка. Возможно, это будет неплохим
выбором. Даже если это не так, возможно на следующем шаге алгоритм, возможно,
сделает лучший выбор. Вероятность постоянного выпадения наихудшего случая очень
мала.
Интересно, что этот метод превращает ситуацию «небольшая
вероятность того, что всегда будет плохая производительность» в ситуацию
«всегда небольшая вероятность плохой производительности». Это довольно
запутанное утверждение объясняется в следующих абзацах.
При использовании других методов выбора точки раздела,
существует небольшая вероятность того, что при определенной организации списка
время сортировки будет порядка O(N2), Хотя маловероятно, что
подобная организация списка в начале сортировки встретится на самом деле, тем
не менее, время выполнения при этом будет определенно порядка O(N2),
неважно почему. Это то, что можно назвать «небольшой вероятностью того, что
всегда будет плохая производительность».
===========242
При случайном выборе точки раздела первоначальное
расположение элементов не влияет на производительность алгоритма. Существует
небольшая вероятность неудачного выбора элемента, но вероятность того, что это
будет происходить постоянно, чрезвычайно мала. Это можно обозначить как «всегда
небольшая вероятность плохой производительности». Независимо от первоначальной
организации списка, очень маловероятно, что производительность алгоритма будет
порядка O(N2).
Тем не менее, все еще остается ситуация, которая может
вызвать проблемы при использовании любого из этих методов. Если в списке очень
мало различных значений в списке,
алгоритм заносит множество одинаковых значений в подсписок при каждом вызове.
Например, если каждый элемент в списке имеет значение 1, последовательность
выполнения будет такой, как показано на рис. 9.5. Это приводит к большому
уровню вложенности рекурсии и дает производительность порядка O(N2).
Похожее поведение происходит также при наличии большого
числа повторяющихся значений. Если список состоит из 10.000 элементов со
значениями от 1 до 10, алгоритм довольно быстро разделит список на подсписки,
каждый из которых содержит только одно значение.
Наиболее простой выход — игнорировать эту проблему.
Если вы знаете, что данные не имеют такого распределения, то проблемы нет. Если
данные имеют небольшой диапазон значений, то вам стоит рассмотреть другой
алгоритм сортировки. Описываемые далее в этой главе алгоритмы сортировки
подсчетом и блочной сортировки очень быстро сортируют списки, данных в которых
находятся в узком диапазоне.
Можно внести еще одно небольшое улучшение в алгоритм быстрой
сортировки. Подобно многих другим более сложным алгоритмам, описанным далее в
этой главе, быстрая сортировка — не самый лучший выбор для сортировки
небольших списков. Благодаря своей простоте, сортировка выбором быстрее при
сортировке примерно десятка записей.
@Рис. 9.5. Быстрая сортировка списка из единиц
==========243
@Таблица 9.3. Время быстрой сортировки 20.000 элементов
Можно улучшить производительность быстрой сортировки, если
прекратить рекурсию до того, как подсписки уменьшатся до нуля, и использовать
для завершения работы сортировку выбором. В табл. 9.3 приведено время, которое
занимает выполнение быстрой сортировки 20.000 элементов на компьютере с
процессором Pentium с тактовой частотой 90 МГц, если останавливать сортировку
при достижении подсписками определенного размера. В этом тесте оптимальное
значение этого параметра было равно 15.
Следующий код демонстрирует доработанный алгоритм:
Public Sub
QuickSort*List() As Long, ByVal min As Long, ByVal max As Long)
Dim med_value
As Long
Dim hi As
Long
Dim lo As
Long
Dim i As Long
‘ Если в списке больше, чем CutOff
элементов,
‘ завершить его
сортировку процедурой SelectionSort.
If max - min < cutOff Then
SelectionSort List(), min, max
Exit Sub
End If
‘ Выбрать разделяющее значение.
i = Int((max - min + 1) * Rnd + min)
med_value = List(i)
‘ Переместить его вперед.
List(i) = List(min)
lo = min
hi = max
Do
‘ Просмотр сверху
вниз от hi до значения < med_value.
Do While List(hi) >= med_value
hi = hi - 1
If hi <= lo Then Exit Do
Loop
If hi <= lo Then
List(lo) = med_value
Exit Do
End If
‘ Поменять местами значения lo и
hi.
List(lo) = List(hi)
‘ Просмотр снизу вверх от lo до
значения >= med_value.
lo = lo + 1
Do While List(lo) < med_value
lo = lo + 1
If lo >= hi Then Exit Do
Loop
If lo >= hi Then
lo = hi
List(hi) = med_value
Exit Do
End If
‘ Поменять местами значения lo и
hi.
List(hi) = List(lo)
Loop
‘ Сортировать два
подсписка.
QuickSort List(), min, lo - 1
QuickSort List(), lo + 1, max
End Sub
=======244
Многие программисты выбирают алгоритм быстрой сортировки,
т.к. он дает хорошую производительность в большинстве обстоятельств.
Сортировка слиянием
XE "Сортировка:слиянием" сортировка слиянием (mergesort
XE "mergesort"
Сортировка слиянием делит список пополам, формируя два
подсписка одинакового размера. Затем подсписки рекурсивно сортируются, и
отсортированные подсписки сливаются, образуя полностью отсортированный список.
Хотя этап слияния легко понять, это наиболее интересная
часть алгоритма. Подсписки сливаются во временный массив, и результат
копируется в первоначальный список. Создание временного массива может быть
недостатком, особенно если размер элементов велик. Если размер временного
размера очень большой, он может приводить к обращению к файлу подкачки и
значительно снижать производительность. Работа с временным массивом также
приводит к тому, что большая часть времени уходит на копирование элементов
между массивами.
Так же, как и в случае с быстрой сортировкой, можно ускорить
выполнение сортировки слиянием, остановив рекурсию, когда подсписки достигают
определенного минимального размера. Затем можно использовать сортировку выбором
для завершения работы.
=========245
Public Sub
Mergesort(List() As Long, Scratch() As Long, _
ByVal min As Long, ByVal max As Long)
Dim middle As
Long
Dim i1 As
Long
Dim i2 As
Long
Dim i3 As
Long
‘ Если в списке больше, чем CutOff
элементов,
‘ завершить его
сортировку процедурой SelectionSort.
If max - min < CutOff Then
Selectionsort List(), min, max
Exit Sub
End If
‘ Рекурсивная сортировка подсписков.
middle = max 2 + min 2
Mergesort List(), Scratch(), min, middle
Mergesort List(), Scratch(), middle + 1, max
‘ Слить отсортированные списки.
i1 = min ‘ Индекс списка 1.
i2 = middle + 1 ‘ Индекс списка 2.
i3 = min ‘ Индекс объединенного списка.
Do While i1 <= middle And i2 <= max
If List(i1) <= List(i2) Then
Scratch(i3) = List(i1)
i1 = i1 + 1
Else
Scratch(i3) = List(i2)
i2 = i2 + 1
end If
i3 = i3 + 1
Loop
‘ Очистка непустого
списка.
Do While i1 <= middle
Scratch(i3) = List(i1)
i1 = i1 + 1
i3 = i3 + 1
Loop
Do While i2 <= max
Scratch(i3) = List(i2)
i2 = i2 + 1
i3 = i3 + 1
Loop
‘ Поместить
отсортированный список на место исходного.
For i3 = min To max
List(i3) = Scratch(i3)
Next i3
End Sub
========246
Сортировка слиянием тратит много времени на копирование
временного массива на место первоначального. Программа FastSort
использует функцию API MemCopy,
чтобы немного ускорить эту операцию.
Даже с использованием функции MemCopy, сортировка слиянием немного
медленнее, чем быстрая сортировка. В нашем тесте на компьютере с процессором
Pentium с тактовой частотой 90 МГц, сортировка слиянием потребовала 2,95 сек
для упорядочения 30.000 элементов со значениями в диапазоне от 1 до 10.000.
Быстрая сортировка потребовала всего 2,44 сек.
Преимущество сортировки слиянием в том, что время ее
выполнения остается одинаковым независимо от различных распределений и начального
расположения данных. Быстрая же сортировка дает производительность порядка O(N2)
и достигает глубокого уровня вложенности рекурсии, если список содержит много
одинаковых значений. Если список большой, быстрая сортировка может переполнить
стек и привести к аварийному завершению работы программы. Сортировка слиянием
никогда не достигает слишком глубокого уровня вложенности рекурсии, т.к. всегда
делит список на равные части. Для списка из N элементов, глубина вложенности
рекурсии для сортировки слиянием составляет всего лишь log(N).
В другом тесте, в котором использовались 30.000 элементов со
значениями от 1 до 100, сортировка слиянием потребовала столько же времени,
сколько и для элементов со значениями от 1 до 10.000 — 2,95 секунд.
Быстрая сортировка заняла 15,82 секунды. Если значения лежали между 1 и 50,
сортировка слиянием потребовала 2,95 секунд, тогда как быстрая
сортировка — 138,52 секунды.
Пирамидальная сортировка
XE "Сортировка:пирамидальная" Пирамидальная сортировка (heapsort XE "heapsort" XE "Пирамида" пирамидой (heap),
для организации элементов в списке. Пирамиды интересны сами по себе и полезны
при реализации приоритетных очередей.
В начале этой главы описываются пирамиды, и объясняется, как
вы можете реализовать пирамиды на языке Visual Basic. Затем показано, как использовать пирамиду для построения
эффективной приоритетной очереди. Располагая средствами для управления
пирамидами и приоритетными очередями, легко реализовать алгоритм пирамидальной
сортировки.
Пирамиды
Пирамида (heap
XE "heap"
Поскольку каждый узел не меньше, чем два нижележащих узла,
корень дерева — всегда наибольший элемент в пирамиде. Это делает пирамиды
удобной структурой данных для реализации приоритетных очередей. Если вам нужен
элемент очереди с самым высоким приоритетом, он всегда находится на вершине
пирамиды.
=========247
Рис. 9.6. Пирамида
Поскольку пирамида является полным двоичным деревом, вы
можете использовать методы, изложенные в 6 главе, для сохранения пирамиды в
массиве. Поместите корневой узел в 1 позицию массива. Потомки узла I
размещаются в позициях 2 * I и 2 * I + 1. Рис. 9.7 показывает пирамиду с рис.
9.6, записанную в виде массива.
Чтобы понять, как устроена пирамида, заметим, что пирамида
создана из пирамид меньшего размера. Поддерево, начинающееся с любого узла
пирамиды, также является пирамидой. Например, в пирамиде, показанной на рис.
9.8, поддерево с корнем в узле 13 также является пирамидой.
Используя этот факт, можно построить пирамиду снизу вверх.
Вначале, разместим элементы в виде дерева, как показано на рис. 9.9. Затем
организуем пирамиды из небольших поддеревьев внизу дерева. Поскольку в них
всего по три узла, сделать это достаточно просто. Сравним вершину с каждым из
потомков. Если один из потомков больше, он меняется местами с родителем. Если
оба потомка больше, больший потомок меняется местами с родителем. Этот шаг
повторяется до тех пор, пока все поддеревья, имеющие по 3 узла, не будут
преобразованы в пирамиды, как показано на рис. 9.10.
Теперь объединим маленькие пирамиды для создания более
крупных пирамид. Соединим на рис. 9.10 маленькие пирамиды с вершинами 15 и 5 и
элемент, создав пирамиду большего размера. Сравним новую вершину 7 с каждым из
потомков. Если один из потомков больше, поменяем его местами с вершиной. В
нашем случае 15 больше, чем 7 и 4, поэтому узел 15 меняется местами с узлом 7.
Поскольку правое поддерево, начинающееся с узла 4, не
изменялось, это поддерево по‑прежнему является пирамидой. Левое же
поддерево изменилось. Чтобы определить, является ли оно все еще пирамидой,
сравним его новую вершину 7 с потомками 13 и 12. Поскольку 13 больше, чем 7 и
12, необходимо поменять местами узлы 7 и 13.
@Рис. 9.7. Представление пирамиды в виде массива
========248
@Рис. 9.8. Пирамида образуется из меньших пирамид
@Рис. 9.9. Неупорядоченный список в полном дереве
@Рис. 9.10. Поддеревья второго уровня являются пирамидами
=========249
@Рис. 9.11. Объединение пирамид в пирамиду большего размера
Если поддерево выше, можно продолжить перемещение узла 7
вниз по поддереву. В конце концов, либо будет достигнута точка, в которой узел
7 больше обоих своих потомков, либо алгоритм достигнет основания дерева. На
рис. 9.11 показано дерево после преобразования этого поддерева в пирамиду.
Продолжим объединение пирамид, образуя пирамиды большего
размера до тех пор, пока все элементы не образуют одну большую пирамиду, такую
как на рис. 9.6.
Следующий код перемещает элемент из положения List(min)
вниз по пирамиде. Если поддеревья ниже List(min) являются пирамидами, то процедура сливает
пирамиды, образуя пирамиду большего размера.
Private Sub
HeapPushDown(List() s Long, ByVal min As Long, _
ByVal max As Long)
Dim tmp As
Long
Dim j As Long
tmp = List(min)
Do
j = 2 * min
If j <= max Then
‘
Разместить в j указатель на большего потомка.
If j < max Then
If List(j + 1) > List(j) Then _
j = j + 1
End If
If List(j) > tmp Then
‘ Потомок больше. Поменять его
местами с родителем.
List(min) = List(j)
‘ Перемещение этого потомка
вниз.
min =
j
Else
‘
Родитель больше. Процедура закончена.
Exit Do
End If
Else
Exit Do
End If
Loop
List(min) = tmp
End Sub
Полный алгоритм, использующий процедуру HeapPushDown для
создания пирамиды из дерева элементов, необычайно прост:
Private Sub
BuildHeap()
Dim i As
Integer
For i = (max + min) 2 To min Step -1
HeapPushDown list(), i, max
Next i
End Sub
Приоритетные очереди
XE "Очередь:приоритетная" priority queue
XE "priority queue"
BuildHeap
и HeapPushDown.
Если в качестве приоритетной очереди используется пирамида, легко найти элемент
с самым высоким приоритетом — он всегда находится на вершине пирамиды. Но
если его удалить, получившееся дерево без корня уже не будет пирамидой.
Для того, чтобы снова превратить дерево без корня в
пирамиду, возьмем последний элемент (самый правый элемент на нижнем уровне) и
поместим его на вершину пирамиды. Затем при помощи процедуры HeapPushDown
продвинем новый корневой узел вниз по дереву до тех пор, пока дерево снова не
станет пирамидой. В этот момент, можно получить на выходе приоритетной очереди
следующий элемент с наивысшим приоритетом.
Public
Function Pop() As Long
If NumInQueue < 1 Then Exit Function
' Удалить верхний элемент.
Pop = Pqueue(1)
' Переместить
последний элемент на вершину.
PQueue(1) = PQueue(NumInPQueue)
NumInPQueue = NumInPQueue - 1
' Снова сделать дерево пирамидой.
HeapPushDown PQueue(), 1, NumInPQueue
End Function
Чтобы добавить новый элемент к приоритетной очереди,
увеличьте пирамиду. Поместите новый элемент на свободное место в конце массива.
Полученное дерево также не будет пирамидой.
Чтобы снова преобразовать его в пирамиду, сравните новый
элемент с его родителем. Если новый элемент больше, поменяйте их местами.
Заранее известно, что второй потомок меньше, чем родитель, поэтому нет
необходимости сравнивать новый элемент с другим потомком. Если элемент больше
родителя, то он также больше и второго потомка.
Продолжайте сравнение нового элемента с родителем и
перемещение его по дереву, пока не найдется родитель, больший, чем новый
элемент. В этот момент, дерево снова представляет собой пирамиду, и
приоритетная очередь готова к работе.
Private Sub
HeapPushUp(List() As Long, ByVal max As Integer)
Dim tmp As
Long
Dim j As
Integer
tmp = List (max)
Do
j = max 2
If j < 1 Then Exit Do
If List(j) < tmp Then
List (max) = List(j)
max = j
Else
Exit Do
End If
Loop
List(max) = tmp
End Sub
Подпрограмма Push добавляет новый
элемент к дереву и использует подпрограмму HeapPushDown для восстановления пирамиды.
Public Sub
Push (value As Long)
NumInPQueue = NumInPQueue + 1
If NumInPQueue > PQueueSize Then
ResizePQueue
PQueue(NumInPQueue) = value
HeapPushUp PQueue(), NumInPQueue
End Sub
========252
Анализ пирамид
При первоначальном превращении списка в пирамиду, это
осуществляется при помощи создания множества пирамид меньшего размера. Для
каждого внутреннего узла дерева строится пирамида с корнем в этом узле. Если
дерево содержит N элементов, то в дереве O(N) внутренних узлов, и в итоге
приходится создать O(N) пирамид.
При создании каждой пирамиды может потребоваться продвигать
элемент вниз по пирамиде, возможно до тех пор, пока он не достигнет концевого
узла. Самые высокие из построенных пирамид будут иметь высоту порядка O(log(N)). Так как создается
O(N) пирамид, и для построения самой высокой из них требуется O(log(n)) шагов, то все пирамиды можно
построить за время порядка O(N * log(N)).
На самом деле времени потребуется еще меньше. Только
некоторые пирамиды будут иметь высоту порядка O(log(N)). Большинство из них гораздо
ниже. Только одна пирамида имеет высоту, равную log(N), и половина пирамид — высоту
всего в 2 узла. Если суммировать все шаги, необходимые для создания всех
пирамид, в действительности потребуется не больше O(N) шагов.
Чтобы увидеть, так ли это, допустим, что дерево содержит N
узлов. Пусть H — высота дерева. Это полное двоичное дерево, следовательно,
H=log(N).
Теперь предположим, что вы строите все большие и большие
пирамиды. Для каждого узла, который находится на расстоянии H-I уровней от
корня дерева, строится пирамида с высотой I. Всего таких узлов 2H-I,
и всего создается 2H-I пирамид с высотой I.
Для построения этих пирамид может потребоваться передвигать
элемент вниз до тех пор, пока он не достигнет концевого узла. Перемещение
элемента вниз по пирамиде с высотой I требует до I шагов. Для пирамид с высотой
I полное число шагов, которое потребуется для построения 2H-I
пирамид, равно I*2H-I.
Сложив все шаги, затрачиваемые на построение пирамид разного
размера, получаем 1*2H-1+2*2H-2+3*2H-3+…+(H-1)*
21. Вынеся за скобки 2H, получим 2H*(1/2+2/22+3/23+…+(H-1)/2H-1).
Можно показать, что (1/2+2/22+3/23+…+(H-1)/2H-1)
меньше 2. Тогда полное число шагов, которое нужно для построения всех пирамид,
меньше, чем 2H*2. Так как H — высота дерева, равная log(N), то полное число шагов
меньше, чем 2log(N)*2=N*2. Это означает, что для первоначального
построения пирамиды требуется порядка O(N) шагов.
Для удаления элемента из приоритетной очереди, последний
элемент перемещается на вершину дерева. Затем он продвигается вниз, пока не
займет свое окончательное положение, и дерево снова не станет пирамидой. Так
как дерево имеет высоту log(N),
процесс может занять не более log(N)
шагов. Это означает, что новый элемент к приоритетной очереди на основе
пирамиды можно добавить за O(log(N))
шагов.
Другим способом работы с приоритетными очередями является
использование упорядоченного списка. Вставка или удаление элемента из
упорядоченного списка с миллионом элементов занимает примерно миллион шагов.
Вставка или удаление элемента из сопоставимой по размерам приоритетной очереди,
основанной на пирамиде, занимает всего 20 шагов.
======253
Алгоритм пирамидальной сортировки
Алгоритм пирамидальной сортировки просто использует уже
описанные алгоритмы для работы с пирамидами. Идея состоит в том, чтобы создать
приоритетную очередь и последовательно удалять по одному элементу из очереди.
Для удаления элемента алгоритм меняет его местами с последним
элементом в пирамиде. Это помещает удаленный элемент в конечное положение в
конце массива. Затем алгоритм уменьшает счетчик элементов списка, чтобы
исключить из рассмотрения последнюю позицию
После того, как наибольший элемент поменялся местами с
последним, массив больше не является пирамидой, так как новый элемент на
вершине может оказаться меньше, чем его потомки. Поэтому алгоритм использует
процедуру HeapPushDown
для продвижения элемента на его место. Алгоритм продолжает менять элементы
местами и восстанавливать пирамиду до тех пор, пока в пирамиде не останется
элементов.
Public Sub
Heapsort(List() As Long, ByVal min As Long, ByVal max As Long)
Dim i As Long
Dim tmp As
Long
' Создать пирамиду (кроме корневого
узла).
For i = (max + min) 2 To min + 1 Step -1
HeapPushDown List(), i, max
Next i
' Повторять:
' 1. Продвинуться вниз по пирамиде.
' 2. Выдать корень.
For i = max To min + 1 Step -1
' Продвинуться вниз по пирамиде.
HeapPushDown List(), min, i
' Выдать корень.
tmp = List(min)
List(min) = List(i)
List(i) = tmp
Next i
End Sub
Предыдущее обсуждение приоритетных очередей показало, что
первоначальное построение пирамиды требует O(N) шагов. После этого требуется O(log(N)) шагов для
восстановления пирамиды, когда элемент продвигается на свое место.
Пирамидальная сортировка выполняет это действие N раз, поэтому требуется всего
порядка O(N)*O(log(N))=O(N*log(N)) шагов, чтобы получить из
пирамиды упорядоченный список. Полное время выполнения для алгоритма
пирамидальной сортировки составляет порядка O(N)+O(N*log(N))=O(N*log(N)).
=========254
Такой же порядок сложности имеет алгоритм сортировки
слиянием и в среднем алгоритм быстрой сортировки. Так же, как и сортировка
слиянием, пирамидальная сортировка тоже не зависит от значений или
распределения элементов до начала сортировки. Быстрая сортировка плохо работает
со списками, содержащими большое число одинаковых элементов, а сортировка
слиянием и пирамидальная сортировка лишены этого недостатка.
Хотя обычно пирамидальная сортировка работает немного
медленнее, чем сортировка слиянием, для нее не требуется дополнительного
пространства для хранения временных значений, как для сортировки слиянием.
Пирамидальная сортировка создает первоначальную пирамиду и упорядочивает
элементы в пределах исходного массива списка.
Сортировка подсчетом
XE "Сортировка:подсчетом" Сортировка подсчетом (countingsort
XE "countingsort"
Если список удовлетворяет этим требованиям, сортировка
подсчетом выполняется невероятно быстро. В одном из тестов на компьютере с процессором
Pentium с тактовой частотой 90 МГц, быстрая сортировка 100.000 элементов со
значениями между 1 и 1000 заняла 24,44 секунды. Для сортировки тех же элементов
сортировке подсчетом потребовалось всего 0,88 секунд — в 27 раз меньше
времени.
Выдающаяся скорость сортировки подсчетом достигается за счет
того, что при этом не используются операции сравнения. Ранее в этой главе
отмечалось, что время выполнения любого алгоритма сортировки, использующего
операции сравнения, порядка O(N*log(N)). Без использования
операций сравнения, алгоритм сортировки подсчетом позволяет упорядочивать
элементы за время порядка O(N).
Сортировка подсчетом начинается с создания массива для
подсчета числа элементов, имеющих определенное значение. Если значения
находятся в диапазоне между min_value и max_value, алгоритм
создает массив Counts
с нижней границей min_value и верхней
границей max_value. Если
используется массив из предыдущего прохода, необходимо обнулить значения его
элементов. Если существует M значений элементов, массив содержит M записей, и
время выполнения этого шага порядка O(M).
For i = min
To max
Counts(List(i)) = Counts(List(i)) + 1
Next i
В конце концов, алгоритм обходит массив Counts, помещая соответствующее число элементов
в отсортированный массив. Для каждого значения i между min_value и max_value,
он помещает Counts(i) элементов со значением i в массив. Так
как этот шаг помещает по одной записи в каждую позицию в массиве, он требует
порядка O(N) шагов.
new_index =
min
For i =
min_value To max_value
For j = 1 To Counts(i)
sorted_list(new_index) = i
new_index = new_index + 1
Next j
Next i
======255
Алгоритм целиком требует порядка O(M)+O(N)+O(N)=O(M+N)
шагов. Если M мало по сравнению с N, он выполняется очень быстро. Например,
если M
С другой стороны, если M больше, чем O(N*log(N)), тогда O(M+N) будет больше, чем O(N*log(N)). В этом случае сортировка подсчетом может оказаться
медленнее, чем алгоритмы со сложностью порядка O(N*log(N)), такие как быстрая сортировка. В одном из тестов быстрая
сортировка 1000 элементов со значениями от 1 до 500.000 потребовал 0,054 сек, в
то время как сортировка подсчетом потребовала 1,76 секунд.
Сортировка подсчетом опирается на тот факт, что значения
данных — целые числа, поэтому этот алгоритм не может просто сортировать
данные других типов. В Visual Basic
нельзя создать массив с границами от AAA до ZZZ.
Ранее в этой главе в разделе «объединение и сжатие ключей»
было продемонстрировано, как можно кодировать строковые данные при помощи целых
чисел. Если вы может закодировать данные при помощи данных типа Integer или Long, вы все еще
можете использовать сортировку подсчетом.
Блочная сортировка
XE "Сортировка:блочная" блочная сортировка (bucketsort
XE "bucketsort"
По смыслу этот алгоритм похож на быструю сортировку. Быстрая
сортировка разделяет элементы на два подсписка и рекурсивно сортирует подсписки.
Блочная сортировка делает то же самое, но делит список на множество блоков, а
не на всего лишь два подсписка.
Для деления списка на блоки, алгоритм предполагает, что
значения данных распределены равномерно, и распределяет элементы по блокам
равномерно. Например, предположим, что данные имеют значения в диапазоне от 1
до 100 и алгоритм использует 10 блоков. Алгоритм помещает элементы со
значениями 1‑10 в первый блок, со значениями 11‑20 — во
второй, и т.д. На рис. 9.12 показан список из 10 элементов со значениями от 1
до 100, которые расположены в 10 блоках.
@Рис. 9.12. Расположение элементов в блоках.
=======256
Если элементы распределены равномерно, в каждый блок
попадает примерно одинаковое число элементов. Если в списке N элементов, и
алгоритм использует N блоков, в каждый блок попадает всего один или два
элемента. Программа может отсортировать их за конечное число шагов, поэтому
время выполнения алгоритма в целом порядка O(N).
На практике, распределение данных обычно не является
равномерным. В некоторые блоки попадает больше элементов, в другие меньше. Тем
не менее, если распределение в целом близко к равномерному, то в каждом из
блоков окажется лишь небольшое число элементов.
Проблемы могут возникать, только если список содержит
небольшое число различных значений. Например, если все элементы имеют одно и то
ж значение, они все будут помещены в один блок. Если алгоритм не обнаружит это,
он снова и снова будет помещать все элементы в один и тот же блок, вызвав
бесконечную рекурсию и исчерпав все стековое пространство.
Блочная сортировка с применением связного списка
Реализовать алгоритм блочной сортировки на Visual Basic можно различными
способами. Во-первых, можно использовать в качестве блоков связные списки. Это
облегчает перемещение элементов между блоками в процессе работы алгоритма.
Этот метод может быть более сложным, если элементы
изначально расположены в массиве. В этом случае, необходимо перемещать элементы
из массива в связный список и обратно в массив после завершения сортировки. Для
создания связного списка также требуется дополнительная память. Следующий код
демонстрирует алгоритм блочной сортировки с применением связных списков:
Public Sub
LinkBucketSort(ListTop As ListCell)
Dim count As
Long
Dim min_value
As Long
Dim max_value
As Long
Dim Value As
Long
Dim item As
ListCell
Dim nxt As
ListCell
Dim bucket()
As New ListCell
Dim
value_scale As Double
Dim bucket.num As
Long
Dim i As Long
Set item = ListTop.NextCell
If item Is Nothing Then Exit Sub
' Подсчитать элементы и найти
значения min и max.
count = 1
min_value = item.Value
max_value = min_value
Set item = item.NextCell
Do While Not (item Is Nothing)
count = count + 1
Value = item.Value
If min_value > Value Then min_value =
Value
If max_value < Value Then max_value =
Value
Set item = item.NextCell
Loop
' Если min_value =
max_value, значит, есть единственное
' значение, и список
отсортирован.
If min_value = max_value Then Exit Sub
' Если в списке не более, чем CutOff
элементов,
' завершить
сортировку процедурой LinkInsertionSort.
If count <= CutOff Then
LinkInsertionSort ListTop
Exit Sub
End If
' Создать пустые
блоки.
ReDim bucket(1 To count)
value_scale = _
CDbl(count - 1) / _
CDbl(max_value - min_value)
' Разместить элементы в блоках.
Set item = ListTop.NextCell
Do While Not (item Is Nothing)
Set nxt = item.NextCell
Value = item.Value
If Value = max_value Then
bucket_num = count
Else
bucket_num = _
Int((Value - min_value) * _
value_scale) + 1
End If
Set item.NextCell = bucket
(bucket_num).NextCell
Set bucket(bucket_num).NextCell = item
Set item = nxt
Loop
' Рекурсивная сортировка блоков,
содержащих
' более одного
элемента.
For i = 1 To count
If Not (bucket(i).NextCell Is Nothing)
Then _
LinkBucketSort bucket(i)
Next i
' Объединить
отсортированные списки.
Set ListTop.NextCell = bucket(count).NextCell
For i = count - 1 To 1 Step -1
Set item = bucket(i).NextCell
If Not (item Is Nothing) Then
Do While Not (item.NextCell Is
Nothing)
Set item = item.NextCell
Loop
Set item.NextCell = ListTop.NextCell
Set ListTop.NextCell= bucket(i).NextCell
End If
Next i
End Sub
=========257-258
Эта версия блочной сортировки намного быстрее, чем
сортировка вставкой с использованием связных списков. В тесте на компьютере с
процессором Pentium с тактовой частотой 90 МГц сортировке вставкой
потребовалось 6,65 секунд для сортировки 2000 элементов, блочная сортировка
заняла 1,32 секунды. Для более длинных списков разница будет еще больше, так
как производительность сортировки вставкой порядка O(N2).
Блочная сортировка на основе массива
Блочную сортировку также можно реализовать в массиве,
используя идеи подобные тем, которые используются при сортировке подсчетом. При
каждом вызове алгоритма, вначале подсчитывается число элементов, которые
относятся к каждому блоку. Потом на основе этих данных рассчитываются смещения
во временном массиве, которые затем используются для правильного расположения
элементов в массиве. В конце концов, блоки рекурсивно сортируются, и
отсортированные данные перемещаются обратно в исходный массив.
Public Sub
ArrayBucketSort(List() As Long, Scratch() As Long, _
min As Long, max As Long, NumBuckets As
Long)
Dim counts()
As Long
Dim offsets()
As Long
Dim i As Long
Dim Value As
Long
Dim min_value
As Long
Dim max_value
As Long
Dim
value_scale As Double
Dim
bucket_num As Long
Dim next_spot
As Long
Dim
num_in_bucket As Long
' Если в списке не более чем CutOff
элементов,
' закончить
сортировку процедурой SelectionSort.
If max - min + 1 < CutOff Then
Selectionsort List(), min, max
Exit Sub
End If
' Найти значения min и max.
min_value = List(min)
max_value = min_value
For i = min + 1 To max
Value = List(i)
If min_value > Value Then min_value =
Value
If max_value < Value Then max_value =
Value
Next i
' Если min_value =
max_value, значит, есть единственное
' значение, и список
отсортирован.
If min_value = max_value Then Exit Sub
' Создать пустой массив с отсчетами
блоков.
ReDim counts(l To NumBuckets)
value_scale = _
CDbl (NumBuckets - 1) / _
CDbl (max_value - min_value)
' Создать отсчеты блоков.
For i = min To max
If List(i) = max_value Then
bucket_num = NumBuckets
Else
bucket_num = _
Int((List(i) - min_value) * _
value_scale) + 1
End If
counts(bucket_num) = counts(bucket_num)
+ 1
Next i
' Преобразовать
отсчеты в смещение в массиве.
ReDim offsets(l To NumBuckets)
next_spot = min
For i = 1 To NumBuckets
offsets(i) = next_spot
next_spot = next_spot + counts(i)
Next i
' Разместить значения
в соответствующих блоках.
For i = min To max
If List(i) = max_value Then
bucket_num = NumBuckets
Else
bucket_num = _
Int((List(i) - min_value) * _
value_scale) + 1
End If
Scratch (offsets (bucket_num)) = List(i)
offsets(bucket_num) =
offsets(bucket_num) + 1
Next i
' Рекурсивная
сортировка блоков, содержащих
' более одного
элемента.
next_spot = min
For i = 1 To NumBuckets
If counts(i) > 1 Then ArrayBucketSort
_
Scratch(), List(), next_spot, _
next_spot + counts(i) - 1, counts(i)
next_spot = next_spot + counts(i)
Next i
' Скопировать
временный массив назад в исходный список.
For i = min To max
List(i) = Scratch(i)
Next i
End Sub
Из‑за накладных расходов, которые требуются для работы
со связными списками, эта версия блочной сортировки работает намного быстрее,
чем версия с использованием связных списков. Тем не менее, используя методы
работы с псевдоуказателями, описанные во 2 главе, можно улучшить
производительность версии с использованием связных списков, так что обе версии
станут практически эквивалентными по скорости.
Новую версию также можно сделать еще быстрее, используя
функцию API MemCopy
для копирования элементов из временного массива обратно в исходный список. Эта
усовершенствованную версию алгоритма демонстрирует программа FastSort.
===========259-261
Резюме
В таб. 9.4 приведены преимущества и недостатки алгоритмов
сортировки, описанных в этой главе, из которых можно вывести несколько правил,
которые могут помочь вам выбрать алгоритм сортировки.
Эти правила, изложенные в следующем списке, и информация в
табл. 9.4 может помочь вам подобрать алгоритм, который обеспечит максимальную
производительность:
*
*
*
*
*
*
*
Если вы знаете структуру данных и различные алгоритмы
сортировки, вы можете выбрать алгоритм, наиболее подходящий для ваших нужд.
@Таблица 9.4. Преимущества и недостатки алгоритмов
сортировки
=========263
Глава 10. Поиск
После того, как список элементов отсортирован, может
понадобиться найти определенный элемент в списке. В этой главе описаны
некоторые алгоритмы для поиска элементов в упорядоченных списках. Она
начинается с краткого описания сортировки методом полного перебора. Хотя этот алгоритм
выполняется не так быстро, как другие, метод полного перебора является очень
простым, что облегчает его реализацию и отладку. Из‑за простоты этого
метода, сортировка полным перебором также выполняется быстрее других алгоритмов
для очень маленьких списков.
Далее в главе описан двоичный поиск. При двоичном поиске
список многократно разбивается на части, при этом для больших списков такой
поиск выполняется намного быстрее, чем полный перебор. Заключенная в этом
методе идея достаточно проста, но реализовать ее довольно сложно.
Затем в главе описан интерполяционный поиск. Так же, как и в
методе двоичного поиска, исходный список при этом многократно разбивается на
части. При использовании интерполяционного поиска, алгоритм делает
предположения о том, где может находиться искомый элемент, поэтому он
выполняется намного быстрее, если данные в списках распределены равномерно.
В конце главы обсуждаются методы следящего поиска.
Применение этого метода иногда уменьшает время поиска в несколько раз.
Примеры программ
Программа Search
демонстрирует все описанные в главе алгоритмы. Введите значение элементов,
которые должен содержать список, и затем нажмите на кнопку Make List (Создать список), и программа
создаст список на основе массива, в котором каждый элемент больше предыдущего
на число от 0 до 5. Программа выводит значение наибольшего элемента в списке,
чтобы вы представляли диапазон значений элементов.
После создания списка выберите алгоритмы, которые вы хотите
использовать, установив соответствующие флажки. Затем введите значение, которое
вы хотите найти и нажмите на кнопку Search (Поиск), и
программа выполнит поиск элемента при помощи выбранного вами алгоритма. Так как
список содержит не все возможные элементы в заданном диапазоне значений, то вам
может понадобиться ввести несколько различных значений, прежде чем одно из них
найдется в списке.
Программа также позволяет задать число повторений для
каждого из алгоритмов поиска. Некоторые алгоритмы выполняются очень быстро,
поэтому для того, чтобы сравнить их скорость, может понадобиться задать для них
большое число повторений.
=======265
На рис. 10.1 показано окно программы Search после
поиска элемента со значением 250.000. Этот элемент находился на позиции 99.802
в списке из 100.000 элементов. Чтобы найти этот элемент, потребовалось
проверить 99.802 элемента при использовании алгоритма полного перебора, 16
элементов — при использовании двоичного поиска и всего 3 — при
выполнении интерполяционного поиска.
Поиск методом полного перебора
При выполнении линейного
(linear) поиска или
поиска методом XE "Поиск:методом полного перебора" полного перебора (exhaustive search
XE "exhaustive search"
Public Function
LinearSearch(target As Long) As Long
Dim i As Long
For i = 1 To NumItems
If List(i) >= target Then Exit For
Next i
If i > NumItems Then
Search = 0 ' Элемент не найден.
Else
Search = i ' Элемент найден.
End If
End Function
Так как этот алгоритм проверяет элементы последовательно, то
он находит элементы в начале списка быстрее, чем элементы, расположенные в
конце. Наихудший случай для этого алгоритма возникает, если элемент находится в
конце списка или вообще не присутствует в нем. В этих случаях, алгоритм
проверяет все элементы в списке, поэтому время его выполнения сложность в
наихудшем случае порядка O(N).
@Рис. 10.1. Программа Search
========266
Если элемент находится в списке, то в среднем алгоритм
проверяет N/2 элементов
до того, как обнаружит искомый. Таким образом, в усредненном случае время
выполнения алгоритма также порядка O(N).
Хотя алгоритмы, которые выполняются за время порядка O(N), не являются очень быстрыми, этот
алгоритм достаточно прост, чтобы давать на практике неплохие результаты. Для
небольших списков этот алгоритм имеет приемлемую производительность.
Поиск в упорядоченных списках
Если список упорядочен, то можно слегка модифицировать
алгоритм полного перебора, чтобы немного повысить его производительность. В этом
случае, если во время выполнения поиска алгоритм находит элемент со значением,
большим, чем значение искомого элемента, то он завершает свою работу. При этом
искомый элемент не находится в списке, так как иначе он бы встретился раньше.
Например, предположим, что мы ищем значение 12 и дошли до
значения 17. При этом мы уже прошли тот участок списка, в котором мог бы
находится элемент со значением 12, значит, элемент 12 в списке отсутствует.
Следующий код демонстрирует доработанную версию алгоритма поиска полным
перебором:
Public
Function LinearSearch(target As Long) As Long
Dim i As Long
NumSearches = 0
For i = 1 To NumItems
NumSearches = NumSearches + 1
If List(i) >= target Then Exit For
Next i
If i > NumItems Then
LinearSearch = 0 ' Элемент не найден.
ElseIf List(i) <> target Then
LinearSearch = 0 ' Элемент не найден.
Else
LinearSearch = i ' Элемент найден.
End If
End Function
Эта модификация уменьшает время выполнения алгоритма, если
элемент отсутствует в списке. Предыдущей версии поиска требовалось проверить
весь список до конца, если искомого элемента в нем не было. Новая версия
остановится, как только обнаружит элемент больший, чем искомый.
Если искомый элемент расположен случайно между наибольшим и
наименьшим элементами в списке, то в среднем алгоритму понадобится порядка O(N) шагов, чтобы определить, что искомый
элемент отсутствует в списке. Время выполнения при этом имеет тот же порядок,
но на практике его производительность будет немного выше. Программа Search использует
эту версию алгоритма.
======267
Поиск в связных списках
Поиск методом полного перебора — это единственный
способ поиска в связных списках. Так как доступ к элементам возможен только при
помощи указателей NextCell
на следующий элемент, то необходимо проверить по очереди все элементы с начала
списка, чтобы найти искомый.
Так же, как и в случае поиска полным перебором в массиве,
если список упорядочен, то можно прекратить поиск, если найдется элемент со
значением, большим, чем значение искомого элемента.
Public
Function LListSearch(target As Long) As SearchCell
Dim cell As
SearchCell
NumSearches = 0
Set cell = ListTop.NextCell
Do While Not (cell Is Nothing)
NumSearches = NumSearches + 1
If cell.Value >= target Then Exit Do
Set cell = cell.NextCell
Loop
If Not (cell Is Nothing) Then
If cell.Value = target Then
Set LListSearch = cell ' Элемент найден.
End If
End If
End Function
Программа Search
использует этот алгоритм для поиска элементов в связном списке. Этот алгоритм
выполняется немного медленнее, чем алгоритм полного перебора в массиве из‑за
дополнительных накладных расходов, которые связаны с управлением указателями на
объекты. Заметьте, что программа Search
строит связные списки, только если список содержит не более 10.000 элементов.
Чтобы алгоритм выполнялся немного быстрее, в него можно
внести еще одно изменение. Если хранить указатель на конец списка, то можно
добавить в конец списка ячейку, которая будет содержать искомый элемент. Этот
элемент называется сигнальной меткой
(sentinel), и служит
для тех же целей, что и сигнальные метки, описанные во 2 главе. Это позволяет
обрабатывать особый случай конца списка так же, как и все остальные.
В этом случае, добавление метки в конец списка гарантирует,
что в конце концов искомый элемент будет найден. При этом программа не может
выйти за конец списка, и нет необходимости проверять условие Not (cell Is Nothing) в
каждом цикле While.
Public
Function SentinelSearch(target As Long) As SearchCell
Dim cell As
SearchCell
Dim sentinel
As New SearchCell
NumSearches = 0
' Установить
сигнальную метку.
sentinel.Value = target
Set ListBottom.NextCell = sentinel
' Найти искомый элемент.
Set cell = ListTop.NextCell
Do While cell.Value < target
NumSearches = NumSearches + 1
Set cell = cell.NextCell
Loop
' Определить найден
ли искомый элемент.
If Not ((cell Is sentinel) Or _
(cell.Value <> target)) _
Then
Set SentinelSearch = cell ' Элемент найден.
End If
' Удалить сигнальную
метку.
Set ListBottom.NextCell = Nothing
End Function
Хотя может показаться, что это изменение незначительно,
проверка Not (cell Is Nothing)
выполняется в цикле, который вызывается очень часто. Для больших списков этот
цикл вызывается множество раз, и выигрыш времени суммируется. В Visual Basic, этот версия алгоритма
поиска в связных списках выполняется на 20 процентов быстрее, чем предыдущая
версия. В программе Search
приведены обе версии этого алгоритма, и вы можете сравнить их.
Некоторые алгоритмы используют потоки для ускорения поиска в
связных списках. Например, при помощи указателей в ячейках списка можно
организовать список в виде двоичного дерева. Поиск элемента с использованием
этого дерева займет время порядка O(log(N)), если дерево
сбалансировано. Такие структуры данных уже не являются просто списками, поэтому
мы не обсуждаем их в этой главе. Чтобы больше узнать о деревьях, обратитесь к 6
и 7 главам
Двоичный поиск
XE "Поиск:двоичный" binary search XE "binary search"
Хотя по своей природе этот алгоритм является рекурсивным,
его достаточно просто записать и без применения рекурсии. Так как этот алгоритм
прост для понимания в любом варианте (с рекурсией или без), то мы приводим
здесь его нерекурсивную версию, которая содержит меньше вызовов функций.
Основная заключенная в этом алгоритме идея проста, но детали
ее реализации достаточно сложны. Программе приходится аккуратно отслеживать
часть массива, которая может содержать искомый элемент, иначе она может его
пропустить.
Алгоритм использует две переменные, min и max, в которых
находятся минимальный и максимальный индексы ячеек массива, которые могут
содержать искомый элемент. Во время выполнения алгоритма, индекс искомой ячейки
всегда будет лежать между min
и max.
Другими словами, min <= target index <= max.
==========269
@Рис. 10.2. Двоичный поиск элемента со значением 44
Во время каждого прохода, алгоритм выполняет присвоение middle = (min + max) / 2
и проверяет ячейку, индекс которой равен middle. Если ее значение равно искомому, то
цель найдена и алгоритм завершает свою работу.
Если значение искомого элемента меньше, чем значение
среднего, то алгоритм устанавливает значение переменной max равным middle – 1
и продолжает поиск. Так как теперь индексы элементов, которые могут содержать
искомый элемент, находятся в диапазоне от min до middle – 1, то программа при этом выполняет поиск
в первой половине списка.
В конце концов, программа либо найдет искомый элемент, либо
наступит момент, когда значение переменной min станет больше, чем значение max. Поскольку
индекс искомого элемента должен находиться между минимальным и максимальным
возможными индексами, это означает, что искомый элемент отсутствует в списке.
Следующий код демонстрирует выполнение двоичного поиска в
программе Search:
Public
Function BinarySearch(target As Long) As Long
Dim min As
Long
Dim max As
Long
Dim middle As
Long
NumSearches = 0
' Во время поиска
индекс искомого элемента будет находиться
' между Min и Max: Min <= target index <= Max
min = 1
max = NumItems
Do While min <= max
NumSearches = NumSearches + 1
middle = (max + min) / 2
If target = List(middle) Then ' Мы нашли искомый элемент!
BinarySearch = middle
Exit Function
ElseIf target < List(middle) Then ' Поиск в
левой половине.
max = middle - 1
Else ' Поиск в правой
половине.
min = middle + 1
End If
Loop
' Если мы оказались
здесь, то искомого элемента нет в списке.
BinarySearch = 0
End Function
На каждом шаге число элементов, которые еще могут иметь искомое
значение, уменьшается вдвое. Для списка размера N, алгоритму может потребоваться
максимум O(log(N)) шагов, чтобы найти любой элемент или
определить, что его нет в списке. Это намного быстрее, чем в случае применения
алгоритма полного перебора. Полный перебор списка из миллиона элементов
потребовал бы в среднем 500.000 шагов. Алгоритму двоичного поиска потребуется
не больше, чем log(1.000.000)
или 20 шагов.
Интерполяционный поиск
XE "Поиск:интерполяционный" interpolation search
XE "interpolation search"
Интерполяцией называется процесс предсказания неизвестных
значений на основе имеющихся. В данном случае, индексы известных значений в
списке используются для определения возможного положения искомого элемента в
списке.
Например, предположим, что имеется тот же самый список
значений, показанный на рис. 10.2. Этот список содержит 20 элементов со
значениями между 1 и 70. Предположим теперь, что требуется найти элемент в
списке, имеющий значение 44. Значение 44 составляет 64 процента расстояния
между 1 и 70 на шкале чисел. Если считать, что значения элементов распределены
равномерно, то можно предположить, что искомый элемент расположен примерно в
точке, которая составляет 64 процента от размера списка, и занимает позицию 13.
Если позиция, выбранная при помощи интерполяции, оказывается
неправильной, то алгоритм сравнивает искомое значение со значением элемента в
выбранной позиции. Если искомое значение меньше, то поиск продолжается в первой
части списка, если больше — во второй части. На рис. 10.3 графически
изображен интерполяционный поиск.
При двоичном поиске список последовательно разбивается
посередине на две части. Интерполяционный поиск каждый раз разбивает список,
пытаясь найти ближайший к искомому элемент в списке, при этом точка разбиения
определяется следующим кодом:
middle = min + (target - List(min)) * _
((max - min) / (List(max) - List(min)))
========270-271
@Рис. 10.3 Интерполяционный поиск значения 44
Этот оператор помещает значение middle между min и max в таком же соотношении, в каком искомое
значение находится между List(min) и List(max).
Если искомый элемент находится рядом с List(min), то разность target – List(min) почти равна нулю. Тогда все соотношение
целиком выглядит почти как middle = min + 0,
поэтому значение переменной middle
почти равно min.
Смысл этого заключается в том, что если индекс элемента почти равен min, то его
значение почти равно List(min).
Аналогично, если искомый элемент находится рядом с List(max),
то разность target – List(min)
почти равна разности List(max) – List(min).
Их частное почти равно единице, и соотношение выглядит почти как middle = min + (max – min),
или middle = max, если упростить
выражение. Смысл этого соотношения заключается в том, что если значение
элемента близко к List(max),
то его индекс почти равен max.
После того, как программа вычислит значение middle, она
сравнивает значение элемента в этой позиции с искомым так же, как и в алгоритме
двоичного поиска. Если эти значения совпадают, то искомый элемент найден и
процесс закончен. Если значение искомого элемента меньше, чем значение
найденного, то программа устанавливает значение max равным middle – 1 и продолжает поиск элементов списка с
меньшими значениями. Если значение искомого элемента больше, чем значение
найденного, то программа устанавливает значение min равным middle + 1 и продолжает поиск элементов списка с
большими значениями.
Заметьте, что в знаменателе соотношения, которое находит
новое значение переменной middle,
находится разность (List(max) – Lsit(min)).
Если значения List(max) и List(min)
одинаковы, то произойдет деление на ноль и программа аварийно завершит работу.
Такое может произойти, если два элемента в списке имеют одинаковые значения.
Так как алгоритм поддерживает соотношение min <= target index <= max, то эта
проблема может также возникнуть, если min будет расти, а max уменьшаться до тех пор, пока их значения
не сравняются.
Чтобы справиться с этой проблемой, программа перед
выполнением операции деления проверяет, не равны ли List(max) и List(min). Если это так, значит осталось проверить
только одно значение. При этом программа просто проверяет, совпадает ли оно с
искомым.
Еще одна тонкость заключается в том, что вычисленное
значение middle
не всегда лежит между min
и max.
В простейшем случае это может быть так, если значение искомого элемента выходит
за пределы диапазона значений элементов в списке. Предположим, что мы пытаемся
найти значение 300 в списке из элементов 100, 150 и 200. На первом шаге
вычислений min = 1
и max = 3.
Тогда middle = 1 + (300 – List(1)) * (3 – 1) / (List(3) – List(1))
= 1 + (300 – 100) * 2 / (200 – 100) = 5. Индекс 5 не только не находится в
диапазоне между min
и max,
он также выходит за границы массива. Если программа попытается обратиться к
элементу массива List(5),
то она аварийно завершит работу с сообщением об ошибке “Subscript out of range”.
===========272
Похожая проблема возникает, если значения элементов
распределены между min
и max
очень неравномерно. Предположим, что мы хотим найти значение 100 в списке 0, 1,
2, 199, 200. При первом вычислении значения переменной middle, мы получим
в программе middle = 1 + (100 – 0) *
(5 – 1) / (200 – 0) = 3. Затем программа сравнивает значение
элемента List(3)
с искомым значением 100. Так как List(3) = 2,
что меньше 100, она задает min = middle + 1,
то есть min = 4.
При следующем вычисления значения переменной middle, программа
находит middle = 4 + (100 – 199)
* (5 – 4) / (200 – 199) = -98. Значение –98 не попадает в
диапазон min <= target index <= max и также далеко
выходит за границы массива.
Если рассмотреть процесс вычисления переменной middle, то можно
увидеть, что существуют два варианта, при которых новое значение может
оказаться меньше, чем min
или больше, чем max.
Вначале предположим, что middle
меньше, чем min.
min + (target
- List(min)) * ((max - min) / (List(max) - List(min))) < min
После вычитания min
из обеих частей уравнения, получим:
(target -
List(min)) * ((max - min) / (List(max) - List(min))) < 0
Так как max >= min, то разность (max – min)
должна быть больше нуля. Так как List(max) >= List(min),
то разность (List(max) – List(min))
также должна быть больше нуля. Тогда все значение может быть меньше нуля,
только если (target – List(min))
меньше нуля. Это означает, что искомое значение меньше, чем значение элемента List(min). В
этом случае, искомый элемент не может находиться в списке, так как все элементы
списка со значением меньшим, чем List(min)
уже были исключены.
Теперь предположим, что middle больше, чем max.
min + (target
- List(min)) * ((max - min) / (List(max) - List(min))) > max
После вычитания min
из обеих частей уравнения, получим:
(target -
List(min)) * ((max - min) / (List(max) - List(min))) > 0
Умножение обеих частей на (List(max) – List(min)) / (max – min) приводит соотношение к виду:
target –
List(min) > List(max) – List(min)
И, наконец, прибавив к обеим частям List(min),
получим:
target >
List(max)
Это означает, что искомое значение больше, чем значение
элемента List(max). В
этом случае, искомое значение не может находиться в списке, так как все
элементы списка со значениями большими, чем List(max) уже были исключены.
==========273
Учитывая все эти результаты, получаем, что новое значение
переменной middle
может выйти из диапазона между min
и max
только в том случае, если искомое значение выходит за пределы диапазона от List(min) до
List(max).
Алгоритм может использовать этот факт при вычислении нового значения переменной
middle. Он вначале
проверяет, находится ли новое значение между min и max. Если нет, то искомого элемента нет в
списке и работа алгоритма завершена.
Следующий код демонстрирует реализацию интерполяционного
поиска в программе Search:
Public
Function InterpSearch(target As Long) As Long
Dim min As
Long
Dim max As
Long
Dim middle As
Long
min = 1
max = NumItems
Do While min <= max
' Избегаем деления на ноль.
If List(min) = List(max) Then
' Это искомый элемент (если он
есть в списке).
If List(min) = target Then
InterpSearch = min
Else
InterpSearch = 0
End If
Exit Function
End If
' Найти точку
разбиения списка.
middle = min + (target - List(min)) * _
((max - min) / (List(max) -
List(min)))
' Проверить, не вышли ли мы за
границы.
If middle < min Or middle > max Then
' Искомого элемента нет в
списке.
InterpSearch = 0
Exit Function
End If
NumSearches = NumSearches + 1
If target = List(middle) Then ' Искомый элемент найден.
InterpSearch = middle
Exit Function
ElseIf target < List(middle) Then ' Поиск в
левой части.
max = middle - 1
Else ' Поиск в правой
части.
min = middle + 1
End If
Loop
' Если мы дошли до
этой точки, то элемента нет в списке.
InterpSearch = 0
End Function
Двоичный поиск выполняется очень быстро, а интерполяционный
еще быстрее. В одном из тестов, двоичный поиск потребовал в 7 раз больше
времени для поиска значений в списке из 100.000 элементов. Эта разница могла бы
быть еще больше, если бы данные находились на диске или каком‑либо другом
медленном устройстве. Хотя при интерполяционном поиске на вычисления уходит
больше времени, чем в случае двоичного поиска, за счет меньшего числа обращений
к диску мы сэкономили бы гораздо больше времени.
Строковые данные
Если данные в списке представляют собой строки, можно
применить два различных подхода. Более простой состоит в применении двоичного
поиска. При двоичном поиске значения элементов сравниваются непосредственно,
поэтому этот метод может легко работать со строковыми данными.
С другой стороны, интерполяционный поиск использует
численные значения элементов данных для вычисления возможного положения
искомого элемента в списке. Если элементы представляют собой строки, то этот
алгоритм не может непосредственно использовать значения данных для вычисления
предполагаемого положения искомого элемента.
Если строки достаточно короткие, то можно закодировать их
при помощи целых чисел или чисел формата long или double, используя методы, которые были описаны
в 9 главе. После этого можно использовать для нахождения элементов в списке
интерполяционный поиск.
Если строки слишком длинные, и их нельзя закодировать даже
числами в формате double,
то все еще можно использовать для интерполяции значения строк. Вначале найдем
первый отличающийся символ для строк List(min) и List(max). Затем закодируем его и следующие два
символа в каждой строке при помощи методов из 9 главы. Затем можно использовать
эти значения для выполнения интерполяционного поиска.
Например, предположим, что мы ищем строку TARGET в списке TABULATE, TANTRUM, TARGET, TATTERED, TAXATION. Если min = 1
и max = 5,
то проверяются значения TABULATE
и THEATER.
Эти строки отличаются во втором символе, поэтому нужно рассматривать три
символа, начинающиеся со второго. Это будут символы ABU для List(1), AXA для List(5) и ARG для искомой строки.
Эти значения кодируются числами 804, 1378 и 1222
соответственно. Подставляя эти значения в формулу для переменной middle, получим:
middle = min
+ (target - List(min)) * ((max - min) / (List(max) - List(min)))
= 1 + (1222 – 804) * ((5 – 1) / (1378
– 804))
= 2,91
=========275
Это примерно равно 3, поэтому следующее значение переменной middle равно 3.
Это положение строки TARGET
в списке, поэтому поиск при этом заканчивается.
Следящий поиск
XE "Поиск:следящий" binary hunt and search
XE "binary hunt and search"
Для выполнения слежения влево, установим значения переменных
min и max равными
индексу, полученному во время предыдущего поиска. Затем уменьшим значение min на единицу и
сравним искомое значение со значением элемента List(min). Если искомое значение меньше, чем
значение List(min),
установим max = min и min = min –2,
и сделаем еще одну проверку. Если искомое значение все еще меньше, установим max = min и min = min –4,
если это не поможет, установим max = min и min = min –8
и так далее. Продолжим устанавливать значение переменной max равным
значению переменной min
и вычитать очередные степени двойки из значения переменной min до тех пор,
пока не найдется значение min,
для которого значение элемента List(min)
будем меньше искомого значения.
Необходимо следить за тем, чтобы не выйти за границы
массива, если min
меньше, чем нижняя граница массива. Если в какой‑то момент это окажется
так, то min
нужно присвоить значение нижней границы массива. Если при этом значение
элемента List(min)
все еще больше искомого, значит искомого элемента нет в списке. На рис. 10.4
показан следящий поиск элемента со значением 17 влево от предыдущего искомого
элемента со значением 44.
Слежение вправо выполняется аналогично. Вначале значения
переменных min
и max
устанавливаются равными значению индекса, полученного во время предыдущего
поиска. Затем последовательно устанавливается min = max и max = max + 1, min = max и max = max + 2, min = max и max = max + 4, и так далее до тех пор, пока в какой‑то
точке значение элемента массива List(max) не
станет больше искомого. И снова необходимо следить за тем, чтобы не выйти за
границу массива.
После завершения фазы слежения известно, что индекс искомого
элемента находится между min
и max.
После этого можно использовать обычный двоичный поиск для нахождения точного
положения искомого элемента.
@Рис. 10.4. Следящий поиск значения 17 из значения 44
===============276
Если новый искомый элемент находится недалеко от
предыдущего, то алгоритм следящего поиска очень быстро найдет значения max и min. Если новый и
старый искомые элементы отстоят друг от друга на P позиций, то потребуется порядка log(P) шагов для следящего поиска новых
значений переменных min
и max.
Предположим, что мы начали обычный двоичный поиск без фазы
слежения. Тогда потребуется порядка log(NumItems)
– log(P) шагов для
того, чтобы значения min
и max
были на расстоянии не больше, чем P позиций друг от друга. Это означает, что следящий поиск будет
быстрее обычного двоичного поиска, если log(P)
< log(NumItems) – log(P). Прибавив к обеим частям уравнения log(P), получим 2 * log(P) > log(NumItems). Если возвести обе части уравнения в степень двойки,
получим 22*log(P) < 2log(NumItems) или (2log(P))2 < NumItems, или после упрощения
P2 < NumItems.
Из этого соотношения видно, что следящий поиск будет
выполняться быстрее, если расстояние между последовательными искомыми
элементами будет меньше, чем квадратный корень из числа элементов в списке.
Если следующие друг за другом искомые элементы расположены далеко друг от
друга, то лучше использовать обычный двоичный поиск.
Интерполяционный следящий поиск
Используя методы из предыдущих разделов можно выполнить
следящий интерполяционный поиск (interpolative hunt and search
XE "interpolative hunt and search"
Для слежения влево будем теперь использовать интерполяцию,
чтобы предположить, где может находиться искомое значение в диапазоне между
предыдущим значением и значением элемента List(1). Но это будет просто интерполяционный
поиск, в котором min = 1
и max
равно индексу, полученному во время предыдущего поиска. После первого шага,
фаза слежения заканчивается и дальше можно продолжить обычный интерполяционный
поиск.
Аналогично выполняется слежение вправо. Просто приравниваем max = Numitems и
устанавливаем min
равным индексу, полученному во время предыдущего поиска. Затем продолжаем
обычный интерполяционный поиск.
На рис. 10.5 показан интерполяционный поиск элемента со значением
17, начинающийся с предыдущего элемента со значением 44.
Если значения данных расположены почти равномерно, то
интерполяционный поиск всегда выбирает значение, которое находится рядом с
искомым на первом или последующем шаге. Это означает, что начиная с предыдущего
найденного значения, нельзя значительно улучшить этот алгоритм. На первом шаге,
даже без использования результата предыдущего поиска, интерполяционный поиск,
вероятно, выберет индекс, который находится достаточно близко от индекса искомого
элемента.
@Рис. 10.5. Интерполяционный поиск значения 17 из значения
44
=============277
С другой стороны, использование предыдущего значения может
помочь в случае, если данные распределены неравномерно. Если известно, что
новое искомое значение находится близко к старому, интерполяционный поиск,
начинающийся с предыдущего значения, обязательно найдет элемент, который
находится рядом с предыдущим найденным. Это означает, что использование в
качестве стартовой точки предыдущего найденного значения может давать
определенное преимущество.
Результат предыдущего поиска также сильнее ограничивает
диапазон возможных положений нового элемента, по сравнению с диапазоном от 1 до
NumItems, поэтому
алгоритм может сэкономить при этом один или два шага. Это особенно важно, если
список находится на диске или каком‑либо другом медленном устройстве.
Если сохранять результат предыдущего поиска в памяти, то можно, по крайней
мере, сравнить новое искомое значение с предыдущим без обращения к диску.
Резюме
Если элементы находятся в связном списке, используйте поиск
методом полного перебора. По возможности используйте сигнальную метку в конце
списка для ускорения поиска.
Если вам нужно время от времени проводить поиск в списке,
содержащем десятки элементов, также используйте поиск методом полного перебора.
Алгоритм в этом случае будет проще отлаживать и поддерживать, чем более сложные
методы поиска, и он будет давать приемлемые результаты.
Если требуется проводить поиск в больших списках,
используйте интерполяционный поиск. Если значения данных распределены
достаточно равномерно, то интерполяционный поиск обеспечит наилучшую
производительность. Если список находится на диске или каком‑либо другом
медленном устройстве, разница в скорости между интерполяционным поиском и
другими методами поиска может быть достаточно велика.
Если используются строковые данные, можно попытаться
закодировать их числами в формате integer,
long или double, при этом
для их поиска можно будет использовать интерполяционный метод. Если строки
слишком длинные и не помещаются даже в числа формата double, то проще всего может оказаться
использовать двоичный поиск. В табл. 10.1 перечислены преимущества и недостатки
для различных методов поиска.
Используя двоичный или интерполяционный поиск, можно очень
быстро находить элементы даже в очень больших списках. Если значения данных
распределены равномерно, то интерполяционный поиск позволяет всего за несколько
шагов найти элемент в списке, содержащем миллион элементов.
@Таблица 10.1 Преимущества и недостатки различных методов
поиска.
===========278
Тем не менее, в такой большой список трудно вносить
изменения. Вставка или удаление элемента из упорядоченного списка займет время
порядка O(N). Если элемент находится в
начале списка, выполнение этих операций может потребовать очень большого
количества времени, особенно если список находится на каком‑либо
медленном устройстве.
Если требуется вставлять и удалять элементы из большого
списка, следует рассмотреть возможность замены его на другую структуру данных.
В 7 главе обсуждаются сбалансированные деревья, вставка и добавление элемента в
которые требует времени порядка O(log(N)).
В 11 главе обсуждаются методы, позволяющие выполнять вставку
и удаление элементов еще быстрее. Для достижения такой высокой скорости, в этих
методах используется дополнительное пространство для хранения промежуточных
данных. Хеш‑таблицы не хранят информацию о порядке расположения данных. В
хеш‑таблицу можно вставлять, удалять, и находить элементы, но сложно
вывести элементы из таблицы по порядку.
Если список будет неизменным, то применение упорядоченного
списка и использование метода интерполяционного поиска даст прекрасные
результаты. Если требуется часто вставлять и удалять элементы из списка, то
стоит рассмотреть возможность применения хеш‑таблицы. Если при этом также
нужно выводить элементы по порядку или перемещаться по списку в прямом или
обратном направлении, то оптимальную скорость и гибкость может обеспечить
применение сбалансированных деревьев. Решив, какие типа операций вам
понадобятся, вы можете выбрать алгоритм, который вам лучше всего подходит.
=============279
Глава 11. Хеширование
В предыдущей главе описывался алгоритм интерполяционного
поиска, который использует интерполяцию, чтобы быстро найти элемент в списке.
Сравнивая искомое значение со значениями элементов в известных точках, этот
алгоритм может определить вероятное положение искомого элемента. В сущности, он
создает функцию, которая устанавливает соответствие между искомым значением и
индексом позиции, в которой он должен находиться. Если первое предположение
ошибочно, то алгоритм снова использует эту функцию, делая новое предположение,
и так далее, до тех пор, пока искомый элемент не будет найден.
XE "Хеширование" Хеширование (hashing
XE "hashing" XE "Хеширование:хеш-таблица" хеш‑таблице (hash table).
Алгоритм хеширования использует некоторую функцию, которая определяет вероятное
положение элемента в таблице на основе значения искомого элемента.
Например, предположим, что требуется запомнить несколько
записей, каждая из которых имеет уникальный ключ со значением от 1 до 100. Для
этого можно создать массив со 100 ячейками и проинициализировать каждую ячейку
нулевым ключом. Чтобы добавить в массив новую запись, данные из нее просто
копируются в соответствующую ячейку массива. Чтобы добавить запись с ключом 37,
данные из нее просто копируются в 37 позицию в массиве. Чтобы найти запись с
определенным ключом, просто выбирается соответствующая ячейка массива. Для
удаления записи ключу соответствующей ячейки массива просто присваивается
нулевое значение. Используя эту схему, можно добавить, найти и удалить элемент
из массива за один шаг.
К сожалению, в реальных приложениях значения ключа не всегда
находятся в небольшом диапазоне. Обычно диапазон возможных значений ключа
достаточно велик. База данных сотрудников может использовать в качестве ключа
идентификационный номер социального страхования. Теоретически можно было бы
создать массив, каждая ячейка которого соответствовала одному из возможных
девятизначных чисел; но на практике для этого не хватит памяти или дискового
пространства. Если для хранения одной записи требуется 1 килобайт памяти, то
такой массив занял бы 1 терабайт (миллион мегабайт) памяти. Даже если можно
было бы выделить такой объем памяти, такая схема была бы очень неэкономной.
Если штат вашей компании меньше 10 миллионов сотрудников, то более 99 процентов
массива будут пусты.
=======281
Чтобы справиться с этой проблемой, схемы хеширования
отображают потенциально большое число возможных ключей на достаточно компактную
хеш‑таблицу. Если в вашей компании работает 700 сотрудников, вы можете
создать хеш‑таблицу с 1000 ячеек. Схема хеширования устанавливает
соответствие между 700 записями о сотрудниках и 1000 позициями в таблице.
Например, можно располагать записи в таблице в соответствии с тремя первыми
цифрами идентификационного номера в системе социального страхования. При этом
запись о сотруднике с номером социального страхования 123‑45‑6789
будет находиться в 123 ячейке таблицы.
Очевидно, что поскольку существует больше возможных значений
ключа, чем ячеек в таблице, то некоторые значения ключей могут соответствовать
одним и тем же ячейкам таблицы. Например, оба значения 123‑45‑6789
и 12399‑9999 отображаются на одну и ту же ячейку таблицы 123. Если
существует миллиард возможных номеров системы социального страхования, и
таблица имеет 1000 ячеек, то в среднем каждая ячейка будет соответствовать
миллиону записей.
Чтобы избежать этой потенциальной проблемы, схема хеширования
должна включать в себя XE "Хеширование:разрешение
конфликтов" алгоритм
разрешения конфликтов XE "Разрешение конфликтов" collision resolution policy
XE "collision resolution policy"
Все обсуждаемые здесь методы используют для разрешения
конфликтов примерно одинаковый подход. Они вначале устанавливают соответствие
между ключом записи и положением в хеш‑таблице. Если эта ячейка уже
занята, они отображают ключ на какую‑либо другую ячейку таблицы. Если она
также уже занята, то процесс повторяется снова о тех пор, пока в конце концов
алгоритм не найдет пустую ячейку в таблице. Последовательность проверяемых при
поиске или вставке элемента в хеш‑таблицу позиций называется [RV16]
XE "Хеширование:тестовая
последовательность" тестовой последовательностью (probe sequence XE "probe sequence"
В итоге, для реализации хеширования необходимы три вещи:
·
·
·
В следующих разделах описаны некоторые структуры данных,
которые можно использовать для хеширования. Каждая из них имеет соответствующую
функцию хеширования и один или более алгоритмов разрешения конфликтов. Так же,
как и в большинстве компьютерных алгоритмов, каждый из этих методов имеет свои
преимущества и недостатки. В последнем разделе описаны преимущества и
недостатки разных методов, чтобы помочь вам выбрать наилучший для данной
ситуации метод хеширования.
Связывание
XE "Хеширование:связывание"
На рис. 11.1 показан пример связывания хеш‑таблицы,
которая содержит 10 ячеек. Функция хеширования отображает ключ K на ячейку K Mod 10 в массиве. Каждая
ячейка массива содержит указатель на первый элемент связного списка. При
вставке элемента в таблицу он помещается в соответствующий список.
======282
@Рис. 11.1. Связывание
Чтобы создать хеш‑таблицу в Visual Basic, используйте оператор ReDim для
размещения сигнальных меток начала списков. Если вы хотите создать в хеш‑таблице
NumLists связных
списков, задайте размер массива ListTops
при помощи оператора ReDim ListTops(0 To NumLists - 1).
Первоначально все списки пусты, поэтому указатель NextCell каждой метки должен иметь значение Nothing. Если вы используете для изменения
массива меток оператор ReDim,
то Visual Basic
автоматически инициализирует указатели NextCell значением Nothing.
Чтобы найти в хеш‑таблице элемент с ключом K, нужно вычислить
K Mod NumLists, получив
индекс метки связного списка, который может содержать искомый элемент. Затем
нужно просмотреть список до тех пор, пока искомый элемент не будет найден или
процедура не дойдет до конца списка.
Global Const
HASH_FOUND = 0
Global Const
HASH_NOT_FOUND = 1
Global Const
HASH_INSERTED = 2
Private
Function LocateItemUnsorted(Value As Long) As Integer
Dim cell As
ChainCell
' Получить вершину связного списка.
Set cell = m_ListTops(Value Mod
NumLists).NextCell
Do While Not (cell Is Nothing)
If cell.Value = Value Then Exit Do
Set cell = cell.NextCell
Loop
If cell Is Nothing Then
LocateItemUnsorted = HASH_NOT_FOUND
Else
LocateItemUnsorted = HASH_FOUND
End If
End Function
Функции для вставки и удаления элементов из связных списков
аналогичны функциям, описанным во 2 главе.
========283
Преимущества и недостатки связывания
Одно из преимуществ этого метода состоит в том, что при его
использовании хеш‑таблицы никогда не переполняются. При этом вставка и
поиск элементов всегда выполняется очень просто, даже если элементов в таблице
очень много. Для некоторых методов хеширования, описанных ниже,
производительность значительно падает, если таблица почти заполнена.
Из хеш‑таблицы, которая использует связывание, также
просто удалять элементы, при этом элемент просто удаляется из соответствующего
связного списка. В некоторых других схемах хеширования удалить элемент непросто
или невозможно.
Один из недостатков связывания состоит в том, что если число
связных списков недостаточно велико, то размер списков может стать большим, при
этом для вставки или поиска элемента необходимо будет проверить большое число
элементов списка. Если хеш‑таблица содержит 10 связных списков и к ней
добавляется 1000 элементов, то средняя длина связного списка будет равна 100.
Чтобы найти элемент в таблице, придется проверить порядка 100 ячеек.
Можно немного ускорить поиск, если использовать
упорядоченные списки. Тогда можно использовать для поиска элементов в
упорядоченных связных списках методы, описанные в 10 главе. Это позволяет
прекратить поиск, если во время его выполнения встретится элемент со значением,
большим искомого. В среднем потребуется проверить только половину связного
списка, чтобы найти элемент или определить, что его нет в списке.
Private
Function LocateItemSorted(Value As Long) As Integer
Dim cell As
ChainCell
' Получить вершину связного списка.
Set cell = m_ListTops(Value Mod
NumLists).NextCell
Do While Not (cell Is Nothing)
If cell.Value >= Value Then Exit Do
Set cell = cell.NextCell
Loop
If cell Is Nothing Then
LocateItemSorted = HASH_NOT_FOUND
ElseIf cell.Value = Value Then
LocateItemSorted = HASH_FOUND
Else
LocateItemSorted = HASH_NOT_FOUND
End If
End Function
Использование упорядоченных списков позволяет ускорить
поиск, но не снимает настоящую проблему, связанную с переполнения таблицы.
Лучшим, но более трудоемким решением будет создание хеш‑таблицы большего
размера и повторное хеширование элементов в новой таблице так, чтобы связные
списки в ней имели меньший размер. Это может занять довольно много времени,
особенно если списки записаны на диске или каком‑либо другом медленном
устройстве, а не в памяти.
========284
В программе Chain реализована хеш‑таблица
со связыванием. Введите число списков в поле области Table Creation (Создание таблицы) на форме
и установите флажок Sort Lists (Упорядоченные списки), если вы хотите, чтобы программа
использовала упорядоченные списки. Затем нажмите на кнопку Create Table (Создать таблицу). Затем вы
можете ввести новые значения и снова нажать на кнопку Create Table, чтобы создать новую хеш‑таблицу.
Так как интересно изучать хеш‑таблицы, содержащие
большое число значений, то программа Chain позволяет заполнять таблицу случайными
элементами. Введите число элементов, которые вы хотите создать и максимальное
значение элементов в области Random Items (Случайные элементы), затем нажмите на кнопку Create Items
(Создать элементы), и программа добавит в хеш‑таблицу случайно созданные
элементы.
И, наконец, введите значение в области Search
(Поиск). Если вы нажмете на кнопку Add (Добавить), то
программа вставит элемент в хеш‑таблицу, если он еще не находится в ней.
Если вы нажмете на кнопку Find (Найти), то
программа выполнит поиск элемента в таблице.
После завершения операции поиска или вставки, программа
выводит статус операции в нижней части формы — была ли операция успешной и
число проверенных во время ее выполнения элементов.
В строке статуса также выводится средняя длина успешной
(если элемент есть в таблице) и безуспешной (если элемента в таблице нет)
тестовых последовательностей. Программа вычисляет эти значения, выполняя поиск
для всех чисел между единицей и наибольшим числом в хеш‑таблице, и затем
подсчитывая среднее значение длины тестовой последовательности.
На рис. 11.2 показано
окно программы Chain после
успешного поиска элемента 414.[RV17]
Блоки
XE "Хеширование:блоки"
@Рис. 11.2. Программа
Chain
[RV18]
======285
Возможно, самый простой метод обработки переполнения состоит
в том, чтобы поместить все лишние элементы в специальные блоки в конце массива
«нормальных» блоков. Это позволяет при необходимости легко увеличивать размер
хеш‑таблицы. Если требуется больше дополнительных блоков, то размер
массива блоков просто увеличивается, и в конце массива создаются новые
дополнительные блоки.
Например, чтобы добавить новый элемент K в хеш‑таблицу, которая содержит
пять блоков, вначале мы пытаемся поместить его в блок с номером K Mod 5. Если этот блок
заполнен, элемент помещается в дополнительный блок.
Чтобы найти элемент в таблице, вычислим K Mod 5, чтобы найти его
положение, и затем выполним поиск в этом блоке. Если элемента в этом блоке нет,
и блок не заполнен, значит элемента в хеш‑таблице нет. Если элемента в
блоке нет и блок заполнен, необходимо проверить дополнительные блоки.
На рис. 11.3 показаны пять блоков с номерами от 0 до 4 и
один дополнительный блок. Каждый блок может содержать по 5 элементов. В этом
примере в хеш‑таблицу были вставлены следующие элементы: 50, 13, 10 ,72,
25, 46, 68, 30, 99, 85, 93, 65, 70. При вставке элементов 65 и 70 блоки уже
были заполнены, поэтому эти элементы были помещены в первый дополнительный
блок.
Чтобы реализовать метод блочного хеширования в Visual Basic, можно использовать для
хранения блоков двумерный массив. Если требуется NumBuckets блоков, каждый из которых может
содержать BucketSize
ячеек, выделим память под блоки при помощи оператора ReDim TheBuckets(0 To BucketSize -1, 0 To NumBuckets - 1). Второе измерение соответствует
номеру блока. Оператор Visual Basic ReDim
позволяет изменить только размер массива, поэтому номер блока должен быть
вторым измерением массива.
Чтобы найти элемент K, вычислим номер блока K Mod NumBuckets. Затем
проведем поиск в блоке до тех пор, пока не найдется искомый элемент, или пустая
ячейка блока, или блок не закончится. Если элемент найден, поиск завершен. Если
встретится пустая ячейка, значит элемента в хеш‑таблице нет, и процесс
также завершен. Если проверен весь блок, и не найден искомый элемент или пустая
ячейка, требуется проверить дополнительные блоки.
@Рис. 11.3. Хеширование с использованием блоков
======286
Public
Function LocateItem(Value As Long, _
bucket_probes As Integer,
item_probes As Integer) As Integer
Dim bucket As
Integer
Dim pos As
Integer
bucket_probes = 1
item_probes = 0
' Определить, к какому блоку он
относится.
bucket = (Value Mod NumBuckets)
' Поиск элемента или пустой ячейки.
For pos = 0 To BucketSize - 1
item_probes = item_probes + 1
If Buckets(pos, bucket).Value = UNUSED
Then
LocateItem = HASH_NOT_FOUND ' Элемент отсутствует.
Exit Function
End If
If Buckets(pos, bucket).Value = Value
Then
LocateItem = HASH_FOUND ' Элемент найден.
Exit Function
End If
Next pos
' Проверить
дополнительные блоки.
For bucket = NumBuckets To MaxOverflow
bucket_probes = bucket_probes + 1
For pos = 0 To BucketSize - 1
item_probes = item_probes + 1
If Buckets(pos, bucket).Value =
UNUSED Then
LocateItem = HASH_NOT_FOUND ' Not
here.
Exit Function
End If
If Buckets(pos, bucket).Value = Value
Then
LocateItem = HASH_FOUND ' Элемент найден.
Exit Function
End If
Next pos
Next bucket
' Если элемент до сих пор не найден,
то его нет в таблице.
LocateItem = HASH_NOT_FOUND
End Function
======287
Программа Bucket
демонстрирует этот метод. Эта программа очень похожа на программу Chain, но она
использует блоки, а не связные списки. Когда эта программа выводит длину
тестовой последовательности, она показывает число проверенных блоков и число
проверенных элементов в блоках. На рис. 11.4 показано окно программы после
успешного поиска элемента 661 в первом дополнительном блоке. В этом примере
программа проверила 9 элементов в двух блоках.
Хранение хеш‑таблиц на диске
Многие запоминающие устройства, такие как стримеры,
дисководы и жесткие диски, могут считывать большие куски данных за одно
обращение к устройству. Обычно эти блоки имеют размер 512 или 1024 байта.
Чтение всего блока данных занимает столько же времени, сколько и чтение одного
байта.
Если имеется большая хеш‑таблица, записанная на диске,
то этот факт можно использовать для улучшения производительности. Доступ к
данным на диске занимает намного больше времени, чем доступ к данным в памяти.
Если сразу загружать все элементы блока, то можно будет прочитать их все во
время одного обращения к диску. После того, как все элементы окажутся в памяти,
их проверка может выполняться намного быстрее, чем если бы пришлось их
считывать с диска по одному.
Если для чтения элементов с диска используется цикл For, то Visual Basic будет обращаться к
диску при чтении каждого элемента. С другой стороны, можно использовать
оператор Visual Basic Get
для чтения всего блока сразу. При этом потребуется всего одно обращение к
диску, и программа будет выполняться намного быстрее.
Можно создать тип данных, который будет содержать массив
элементов, представляющий блок. Так как во время работы программы нельзя
изменять размер массива в определенном пользователем типе, то необходимо
заранее определить, сколько элементов сможет находиться в блоке. При этом
возможности изменения размеров блоков ограничены по сравнению с предыдущим
вариантом алгоритма.
Global Const
ITEMS_PER_BUCKET = 10 ' Число элементов в блоке.
Global Const
MAX_ITEM = 9 ' ITEMS_PER_BUCKET - 1.
Type ItemType
Value As Long
End Type
Global Const
ITEM_SIZE = 4 ' Размер данных этого типа.
Type
BucketType
Item(0 To MAX_ITEM) As ItemType
End Type
Global Const
BUCKET_SIZE = ITEMS_PER_BUCKET * ITEM_SIZE
Перед тем, как начать чтение данных из файла, он открывается
для произвольного доступа:
Open filename
For Random As #DataFile Len = BUCKET_SIZE
=========288
@Рис. 11.4. Программа Bucket
Для удобства работы можно написать функции для чтения и
записи блоков. Эти функции читают и пишут данные в глобальную переменную TheBucket, которая
содержит данные одного блока. После того, как данные загружены в эту
переменную, можно выполнить поиск среди элементов этого блока в памяти.
Так как при произвольном обращении к файлу записи нумеруются
с единицы, а не с нуля, то эти функции должны добавлять к номеру блока в хеш‑таблице
единицу перед считыванием данных из файла. Например, нулевому блоку в хеш‑таблице
будет соответствовать запись с номером 1.
Private Sub
GetBucket(num As Integer)
Get #DataFile, num + 1, TheBucket
End Sub
Private Sub
PutBucket(num As Integer)
Put #DataFile, num + 1, TheBucket
End Sub
Используя функции GetBucket и PutBucket, можно переписать процедуру поиск в
хеш‑таблице для чтения записей из файла:
Public
Function LocateItem(Value As Long, _
bucket_probes As Integer,
item_probes As Integer) As Integer
Dim bucket As
Integer
Dim pos As
Integer
item_probes = 0
' Определить, к
какому блоку принадлежит элемент.
GetBucket Value Mod NumBuckets
bucket_probes = 1
' Поиск элемента или
пустой ячейки.
For pos = 0 To MAX_ITEM
item_probes = item_probes + 1
If TheBucket.Item(pos).Value = UNUSED
Then
LocateItem = HASH_NOT_FOUND ' Элемента нет в таблице.
Exit Function
End If
If TheBucket.Item(pos).Value = Value
Then
LocateItem = HASH_FOUND ' Элемент найден.
Exit Function
End If
Next pos
' Проверить дополнительные
блоки
For bucket = NumBuckets To MaxOverflow
' Проверить следующий
дополнительный блок.
GetBucket bucket
bucket_probes = bucket_probes + 1
For pos = 0 To MAX_ITEM
item_probes = item_probes + 1
If TheBucket.Item(pos).Value = UNUSED
Then
LocateItem = HASH_NOT_FOUND ' Элемента нет.
Exit Function
End If
If TheBucket.Item(pos).Value = Value
Then
LocateItem = HASH_FOUND ' Элемент найден.
Exit Function
End If
Next pos
Next bucket
' Если элемент все еще не найден, его
нет в таблице.
LocateItem = HASH_NOT_FOUND
End Function
Программа Bucket2
аналогична программе Bucket,
но она хранит блоки на диске. Она также не вычисляет и не выводит на экран
среднюю длину тестовой последовательности, так как эти вычисления потребовали
бы большого числа обращений к диску и сильно замедлили бы работу программы.
============290
Так как при обращении к блокам происходит чтение с диска, а
обращение к элементам блока происходит в памяти, то число проверяемых блоков
гораздо сильнее влияет на время выполнения программы, чем полное число
проверенных элементов. Для сравнения среднего числа проверенных блоков и
элементов при поиске элементов можно использовать программу Bucket.
Каждый блок в программе Bucket2 может содержать до 10 элементов. Это
позволяет легко вставлять элементы в блоки до тех пор, пока они не
переполнятся. В реальной программе следует попытаться поместить в блок
максимально возможное число элементов так, чтобы размер блока оставался при
этом равным целому числу кластеров диска.
Например, можно читать данные блоками по 1024 байта. Если
элемент данных имеет размер 44 байта, то в один блок может поместиться 23
элемента данных, и при этом размер блока будет меньше 1024 байт.
Global Const
ITEMS_PER_BUCKET = 23 ' Число элементов в блоке.
Global Const
MAX_ITEM = 22 ' ITEMS_PER_BUCKET - 1.
Type ItemType
LastName As String * 20 ' 20 байт.
FirstName As String * 20 ' 20 байт.
EmloyeeId As Long ' 4 байта (это ключ).
End Type
Global Const
ITEM_SIZE = 44 Размер данных этого типа.
Type
BucketType
Item(0 To MAX_ITEM) As ItemType
End Type
Global Const
BUCKET_SIZE = ITEMS_PER_BUCKET * ITEM_SIZE
Размещение в каждом блоке большего числа элементов позволяет
считывать больше данных при каждом обращении к диску. При этом в таблице также
может быть больше элементов, прежде чем будет необходимо использовать
дополнительные блоки. Доступ к дополнительным блокам требует дополнительных
обращений к диску, поэтому следует по возможности избегать его.
С другой стороны, если блоки достаточно велики, то они могут
содержать большое число пустых ячеек. Если данные неравномерно распределены по
блокам, то одни блоки могут быть переполнены, а другие — практически
пусты. Использование другого варианта размещения с большим числом блоков меньшего
размера может уменьшить эту проблему. Даже если некоторые блоки все еще будут
переполнены, а некоторые пусты, то почти пустые блоки будут иметь меньший
размер, потому они не будут содержать так много пустых ячеек.
На рис. 11.5 показаны два варианта расположения одних и тех
же данных в блоках. В расположении наверху используются 5 блоков, каждый из
которых содержит по 5 элементов. При этом дополнительные блоки не используются,
и всего имеется 12 пустых ячеек. Расположение внизу использует 10 блоков, каждый
из которых содержит по 2 элемента. В нем имеется 9 пустых ячеек и один
дополнительный блок.
========291
@Рис. 11.5. Два варианта расположения элементов в блоках
Это пример пространственно‑временного компромисса. При
первом расположении все элементы расположены в обычных (не дополнительных)
блоках, поэтому можно быстро найти любой из них. Второе расположение занимает
меньше места, но помещает некоторые элементы в дополнительные блоки, при этом
доступ к ним занимает больше времени.
Связывание блоков
Можно использовать другой подход, если при переполнении
блоков создавать цепочки из блоков. Для каждого заполненного блока создается
своя цепочка блоков, вместо того, чтобы хранить все лишние элементы в одних и
тех же дополнительных блоках. При поиске элемента в заполненном блоке нет
необходимости проверять элементы в дополнительных блоках, которые были помещены
туда в результате переполнения других блоков. Если множество блоков
переполнено, то это может сэкономить довольно много времени.
На рис. 11.6 показано применение двух разных схем
хеширования для одних и тех же данных. Вверху лишние элементы помещаются в
общие дополнительные блоки. Чтобы найти элементы 32 и 30, нужно проверить три
блока. Во‑первых, проверяется блок, в котором элемент должен находится.
Элемента в этом блоке нет, поэтому проверяется первый дополнительный блок, в
котором элемента тоже нет. Поэтому требуется проверить второй дополнительный
блок, в котором, наконец, находится искомый элемент.
В нижнем расположении заполненные блоки связаны со своими
собственными дополнительными блоками. При таком расположении любой элемент
можно найти после обращения не более чем к двум блокам. Как и раньше, вначале
проверяется блок, в котором элемент должен находиться. Если его там нет, то
проверяется связный список дополнительных блоков. В этом примере чтобы найти
искомый элемент нужно проверить только один дополнительный блок.
=========292
@Рис. 11.6. Связные дополнительные блоки
Если дополнительные блоки хеш‑таблицы содержит большое
число элементов, то организация цепочек из дополнительных блоков может
сэкономить достаточно много времени. Предположим, что имеется относительно
большая хеш‑таблица, содержащая 1000 блоков, в каждом из которых
находится 10 элементов. Предположим также, что в дополнительных блоках
находится 1000 элементов, для которых понадобится 100 дополнительных блоков.
Чтобы найти один из последних элементов в дополнительных блоках, потребуется
проверить 101 блок.
Более того, предположим, что мы пытались найти элемент K, которого нет в таблице, но
который должен был бы находиться в одном из заполненных блоков. В этом случае
пришлось бы проверить все 100 дополнительных блоков, прежде чем выяснилось бы,
что элемент отсутствует в таблице. Если программа часто пытается найти
элементы, которых нет в таблице, то значительная часть времени будет тратиться
на проверку дополнительных блоков.
Если дополнительные блоки связаны между собой и ключевые
значения распределены равномерно, то можно будет находить элементы намного
быстрее. Если максимальное число дополнительных элементов для одного блока
равно 10, то каждый блок может иметь не больше одного дополнительного. В этом
случае можно найти элемент или определить, что его нет в таблице, проверив не
более двух блоков.
С другой стороны, если хеш‑таблица только слегка
переполнена, то многие блоки будут иметь дополнительные блоки, содержащие всего
один или два элемента. Допустим, что в каждом блоке должно находиться 11
элементов. Так как каждый блок может вместить только 10 элементов, для каждого
обычного блока нужно будет создать один дополнительный. В этом случае
потребуется 1000 дополнительных блоков, каждый из которых будет содержать всего
один элемент, и всего в дополнительных блоках будет 900 пустых ячеек.
Это еще один пример пространственно‑временного компромисса.
Связывание блоков друг с другом позволяет быстрее вставлять и находить
элементы, но оно также может заполнять хеш‑таблицу пустыми ячейками.
Конечно, можно избежать этой проблемы, создав новую хеш‑таблицу большего
размера и разместив в ней все элементы таблицы.
=====293
Удаление элементов
Удаление элементов из блоков сложнее, чем из связных
списков, но оно возможно. Во‑первых, найдем элемент, который требуется
удалить из хеш‑таблицы. Если блок не заполнен, то на место удаленного
элемента помещается последний элемент блока, при этом все непустые ячейки блока
будет находиться в его начале. Тогда, если при поиске элемента в блоке позднее
найдется пустая ячейка, то можно будет заключить, что элемента в таблице нет.
Если блок, содержащий искомый элемент, заполнен, то нужно
провести поиск заменяющего его элемента в дополнительных блоках. Если ни один
из элементов в дополнительных блоках не принадлежит к данному блоку, то искомый
элемент заменяется последним элементом в блоке, и последняя ячейка блока становится
пустой.
Иначе, если в дополнительном блоке существует элемент,
который принадлежит к данному блоку, то найденный элемент из дополнительного
блока помещается на место удаленного элемента. При этом в дополнительном блоке
образуется пустое пространство, но это легко исправить — в образовавшуюся
пустую ячейку помещается последний элемент из последнего дополнительного блока.
На рис. 11.7 показан процесс удаления элемента из
заполненного блока. Во‑первых, из блока 0 удаляется элемент 24. Так как
блок 0 был заполнен, то нужно попытаться найти элемент из дополнительных
блоков, который можно было бы вставить на его место в блок 0. В данном случае
блок 0 содержит все четные элементы, поэтому любой четный элемент из
дополнительных блоков подойдет. Первый четным элементом в дополнительных блоках
будет элемент 14, поэтому можно заменить элементы 24 в блоке 0 элементом 14.
При этом в третьей позиции первого дополнительного блока
образуется пустая ячейка. Заполним ее последним элементом из последнего
дополнительного блока, в данном случае элементом 79. В этот момент хеш‑таблица
снова готова к работе.
Другой метод состоит в том, чтобы вместо удаления элемента
помечать его как удаленный. Для поиска элементов в таком блоке нужно
игнорировать удаленные элементы. Если позднее в блок будут добавляться новые
элементы, можно будет помещать их на место элементов, помеченных как удаленные.
@Рис. 11.7. Удаление элемента из блока
=========294
Быстрее и легче вместо удаления элемента просто помечать его
как удаленный, но, в конце концов, таблица может оказаться заполненной
неиспользуемыми ячейками. Если добавить в хеш‑таблицу ряд элементов и
затем удалить большинство из них в порядке первый вошел — первый вышел, то
расположение элементов в блоках может оказаться «перевернутым». Большая часть
настоящих данных будет находиться в конце блоков и в дополнительных блоках.
Добавлять новые элементы в таблицу будет просто, но при поиске элемента
довольно много времени будет тратиться на пропуск удаленных элементов.
В качестве компромисса при удалении элемента из блока можно
перемещать последний элемент блока на освободившееся место и затем помечать
последний элемент блока как удаленный. Тогда при поиске в блоке можно
прекратить дальнейший поиск в блоке, если при этом встретится элемент, помеченный,
как удаленный. После этого можно провести поиск в дополнительных блоках, если
они существуют.
Преимущества и недостатки применения блоков
Вставка и удаление элемента в хеш‑таблицу с блоками
выполняется достаточно быстро, даже если таблица почти заполнена. Фактически,
хеш‑таблица, использующая блоки, обычно будет быстрее, чем таблица со
связыванием (связыванием из предыдущей главы, а не связыванием блоков). Если
хеш‑таблица находится на диске, блочный алгоритм может считывать за одно
обращение к диску весь блок. При использовании связных списков, следующий
элемент может находиться на диске не обязательно рядом с предыдущим. При этом
для каждой проверки элемента потребуется обращение к диску.
Удаление элемента из таблицы сложнее выполнить с использованием
блоков, чем при применении связных списков. Чтобы удалить элемент из
заполненного блока, может понадобиться проверить все дополнительные блоки в
поиске элемента, который нужно поместить на его место.
И еще одно преимущество хеш‑таблицы, использующей
блоки, состоит в том, что если таблица переполняется, то можно легко увеличить
ее размер. Когда все дополнительные блоки заполнятся, можно просто изменить
размер массива и создать в его конце новый дополнительный блок.
Если многократно увеличивать размер таблицы подобным
образом, то большая часть данных может находиться в дополнительных блоках.
Тогда для того, чтобы найти или вставить элемент, потребуется проверить
множество блоков, и производительность упадет. В этом случае, может быть лучше
создать новую хеш‑таблицу с большим числом основных блоков и поместить
элементы в нее.
Открытая адресация
[RV19] XE "Адресация:открытая"
XE "Хеширование:открытая адресация" open addressing
XE "addressing:open"
K
индекс массива, равный K Mod
100. При этом элемент со значением 1723 окажется в таблице на 23 позиции.
Затем, когда понадобится найти элемент 1723, проверяется 23 позиция в массиве.
==========295
Различные схемы открытой адресации используют разные методы
для формирования тестовых последовательностей. В следующих разделах
рассматриваются три наиболее важных метода: линейная, квадратичная и
псевдослучайная проверка.
Линейная проверка
Если позиция, на которую отображается новый элемент в
массиве, уже занята, то можно просто просмотреть массив с этой точки до тех
пор, пока не найдется незанятая позиция. Этот метод разрешения конфликтов
называется XE "Тестовая последовательность:линейная
проверка" линейной
проверкой (linear probing
XE "linear probing"
Рассмотрим снова пример, в котором имеется массив с нижней
границей 0 и верхней границей 99, и хеш‑функция отображает элемент K в позицию K Mod 100. Чтобы вставить
элемент 1723, вначале проверяется позиция 23. Если эта ячейка заполнена, то
проверяется позиция 24. Если она также занята, то проверяются позиции 25, 26,
27 и так далее до тех пор, пока не найдется свободная ячейка.
Чтобы вставить новый элемент в хеш‑таблицу,
применяется выбранная тестовая последовательность до тех пор, пока не будет
найдена пустая ячейка. Чтобы найти элемент в таблице, применяется выбранная
тестовая последовательность до тех пор, пока не будет найден элемент или пустая
ячейка. Если пустая ячейка встретится раньше, значит элемент в хеш‑таблице
отсутствует.
Можно записать комбинированную функцию проверки и
хеширования:
Hash(K, P) =
(K + P) Mod 100 где P = 0, 1, 2, ...
Здесь P — число элементов в
тестовой последовательности для K.
Другими словами, для хеширования элемента K проверяются элементы Hash(K, 0),
Hash(K, 1),
Hash(K, 2),
… до тех пор, пока не найдется пустая ячейка.
Можно обобщить эту идею для создания таблицы размера N на основе
массива с индексами от 0 до N - 1.
Хеш‑функция будет иметь вид:
Hash(K, P) =
(K + P) Mod N где P = 0, 1, 2, ...
Следующий код показывает, как выполняется поиск элемента при
помощи линейной проверки:
Public
Function LocateItem(Value As Long, pos As Integer, _
probes As Integer) As Integer
Dim new_value
As Long
probes = 1
pos = (Value Mod m_NumEntries)
Do
new_value = m_HashTable(pos)
' Элемент найден.
If new_value = Value Then
LocateItem = HASH_FOUND
Exit Function
End If
' Элемента в
таблице нет.
If new_value = UNUSED Or probes >=
NumEntries Then
LocateItem = HASH_NOT_FOUND
pos = -1
Exit Function
End If
pos = (pos + 1) Mod NumEntries
probes = probes + 1
Loop
End Function
Программа Linear
демонстрирует открытую адресацию с линейной проверкой. Заполнив поле Table Size
(Размер таблицы) и нажав на кнопку Create table (Создать таблицу), можно создавать хеш‑таблицы
различных размеров. Затем можно ввести значение элемента и нажать на кнопку Add (Добавить) или Find (Найти), чтобы
вставить или найти элемент в таблице.
Чтобы добавить в таблицу сразу несколько случайных значений,
введите число элементов, которые вы хотите добавить и максимальное значение,
которое они могут иметь в области Random Items (Случайные элементы), и затем нажмите на кнопку Create Items
(Создать элементы).
После завершения программой какой‑либо операции она
выводит статус операции (успешное или безуспешное завершение) и длину тестовой
последовательности. Она также выводит среднюю длину успешной и безуспешной
тестовой последовательностей. Программа вычисляет среднюю длину тестовой
последовательности, выполняя поиск всех значений от 1 до максимального значения
в таблице.
В табл. 11.1 приведена средняя длина успешных и безуспешных
тестовых последовательностей, полученных в программе Linear для таблицы со 100 ячейками, элементы в
которых находятся в диапазоне от 1 до 999. Из таблицы видно, что
производительность алгоритма падает по мере заполнения таблицы. Является ли
производительность приемлемой, зависит от того, как используется таблица. Если
программа тратит большую часть времени на поиск значений, которые есть в
таблице, то производительность может быть неплохой, даже если таблица
практически заполнена. Если же программа часто ищет значения, которых нет в
таблице, то производительность может быть очень низкой, если таблица
переполнена.
Как правило, хеширование обеспечивает приемлемую
производительность, не расходуя при этом слишком много памяти, если заполнено
от 50 до 75 процентов таблицы. Если таблица заполнена больше, чем на 75
процентов, то производительность падает. Если таблица заполнена меньше, чем на
50 процентов, то она занимает больше памяти, чем это необходимо. Это делает
открытую адресацию хорошим примером пространственно‑временного
компромисса. Увеличивая хеш‑таблицу, можно уменьшить время, необходимое
для вставки или поиска элементов.
=======297
@Таблица 11.1. Длина успешной и безуспешной тестовых
последовательностей
Первичная кластеризация
Линейная проверка имеет одно неприятное свойство, которое
называется XE "Тестовая последовательность:первичная
кластеризация" первичной
кластеризацией (primary clustering
XE "primary clustering"
Чтобы увидеть, как образуются кластеры, предположим, что
вначале имеется пустая хеш‑таблица, которая может содержать N элементов. Если выбрать
случайное число и вставить его в таблицу, то вероятность того, что элемент
займет любую заданную позицию P
в таблице, равна 1/N.
При вставке второго случайно выбранного элемента, он может
отобразиться на ту же позицию с вероятностью 1/N. Из‑за конфликта в этом случае
он помещается в позицию P
+ 1. Также существует вероятность 1/N, что элемент и должен располагаться в позиции P + 1, и вероятность 1/N, что он должен находиться в позиции P - 1. Во всех этих трех
случаях новый элемент располагается рядом с предыдущим. Таким образом, в целом
существует вероятность 3/N
того, что 2 элемента окажутся расположенными вблизи друг от друга, образуя
небольшой кластер.
По мере роста кластера вероятность того, что следующие
элементы будут располагаться вблизи кластера, возрастает. Если в кластере
находится два элемента, то вероятность того, что очередной элемент
присоединится к кластеру, равна 4/N, если в кластере четыре элемента, то эта вероятность равна 6/N, и так далее.
Что еще хуже, если кластер начинает расти, то его рост
продолжается до тех пор, пока он не столкнется с соседним кластером. Два
кластера сливаются, образуя кластер еще большего размера, который растет еще
быстрее, сливается с другими кластерами и образует еще большие кластеры.
======298
В идеальном случае хеш‑таблица должна быть наполовину
пуста, и элементы в ней должны чередоваться с пустыми ячейками. Тогда с
вероятностью 50 процентов алгоритм сразу же найдет пустую ячейку для нового
добавляемого элемента. Также существует 50‑процентная вероятность того,
что он найдет пустую ячейку после проверки всего лишь двух позиций в таблице.
Средняя длина тестовой последовательности равна 0,5 * 1 + 0,5 * 2 = 1,5.
В наихудшем случае все элементы в таблице будут
сгруппированы в один гигантский кластер. При этом все еще есть 50‑процентная
вероятность того, что алгоритм сразу найдет пустую ячейку, в которую можно
поместить новый элемент. Тем не менее, если алгоритм не найдет пустую ячейку на
первом шаге, то поиск свободной ячейки потребует гораздо больше времени. Если
элемент должен находиться на первой позиции кластера, то алгоритму придется
проверить все элементы в кластере, чтобы найти свободную ячейку. В среднем для
вставки элемента при таком распределении потребуется гораздо больше времени,
чем когда элементы равномерно распределены по таблице.
На практике, степень кластеризации будет находиться между
этими двумя крайними случаями. Вы можете использовать программу Linear для
исследования эффекта кластеризации. Запустите программу и создайте хеш‑таблицу
со 100 ячейками, а затем добавьте 50 случайных элементов со значениями до 999.
Вы обнаружите, что образовалось несколько кластеров. В одном из тестов 38 из 50
элементов стали частью кластеров. Если добавить еще 25 элементов к таблице, то
большинство элементов будут входить в кластеры. В другом тесте 70 из 75
элементов были сгруппированы в кластеры.
Упорядоченная линейная проверка
При выполнении поиска в упорядоченном списке методом полного
перебора, можно остановить поиск, если найдется элемент со значением большим,
чем искомое. Так как при этом возможное положение искомого элемента уже позади,
значит искомый элемент отсутствует в списке.
Можно использовать похожую идею при поиске в хеш‑таблице.
Предположим, что можно организовать элементы в хеш‑таблице таким образом,
что значения в каждой тестовой последовательности находятся в порядке
возрастания. Тогда при выполнении тестовой последовательности во время поиска
элемента можно прекратить поиск, если встретится элемент со значением, большим
искомого. В этом случае позиция, в которой должен был бы находиться искомый
элемент, уже осталась позади, и значит элемента нет в таблице.
Public
Function LocateItem(Value As Long, pos As Integer, _
probes As Integer) As Integer
Dim new_value
As Long
probes = 1
pos = (Value Mod m_NumEntries)
Do
new_value = m_HashTable(pos)
' Элемента в таблице нет.
If new_value = UNUSED Or probes > NumEntries
Then
LocateItem = HASH_NOT_FOUND
pos = -1
Exit Function
End If
' Элемент найден или его нет в
таблице.
If new_value >= Value Then Exit Do
pos = (pos + 1) Mod NumEntries
probes = probes + 1
Loop
If Value = new_value Then
LocateItem = HASH_FOUND
Else
LocateItem = HASH_NOT_FOUND
End If
End Function
Для того, чтобы этот метод работал, необходимо организовать
элементы в хеш‑таблице так, чтобы при выполнении тестовой
последовательности они встречались в возрастающем порядке. Существует
достаточно простой метод вставки элементов, который гарантирует такое
расположение элементов.
Когда в таблицу вставляется новый элемент, для него
выполняется тестовая последовательность. Если найдется свободная ячейка, то
элемент вставляется в эту позицию и процедура завершена. Если встречается
элемент, значение которого больше значения нового элемента, то они меняются
местами и продолжается выполнение тестовой последовательности для большего
элемента. При этом может встретиться элемент с еще большим значением. Тогда
элементы снова меняются местами, и выполняется поиск нового местоположения для
этого элемента. Этот процесс продолжается до тех пор, пока, в конце концов, не
найдется свободная ячейка, при этом возможно несколько элементов меняются
местами.
========299-300
Public
Function InsertItem(ByVal Value As Long, pos As Integer,_ probes As Integer) As Integer
Dim new_value
As Long
Dim status As
Integer
' Проверить, заполнена ли таблица.
If m_NumUnused < 1 Then
' Поиск элемента.
status = LocateItem(Value, pos, probes)
If status = HASH_FOUND Then
InsertItem = HASH_FOUND
Else
InsertItem = HASH_TABLE_FULL
pos = -1
End If
Exit Function
End If
probes = 1
pos = (Value Mod m_NumEntries)
Do
new_value = m_HashTable(pos)
' Если значение найдено, поиск
завершен.
If new_value = Value Then
InsertItem = HASH_FOUND
Exit Function
End If
' Если ячейка
свободна, элемент должен находиться в ней.
If new_value = UNUSED Then
m_HashTable(pos) = Value
HashForm.TableControl(pos).Caption =
Format$(Value)
InsertItem = HASH_INSERTED
m_NumUnused = m_NumUnused - 1
Exit Function
End If
' Если значение в ячейке таблицы
больше значения
' элемента,
поменять их местами и продолжить.
If new_value > Value Then
m_HashTable(pos) = Value
Value = new_value
End If
pos = (pos + 1) Mod NumEntries
probes = probes + 1
Loop
End Function
Программа Ordered
демонстрирует открытую адресацию с упорядоченной линейной проверкой. Она
идентична программе Linear,
но использует упорядоченную хеш‑таблицу.
В табл. 11.2 приведена средняя длина успешной и безуспешной
тестовых последовательностей при использовании линейной и упорядоченной
линейной проверок. Средняя длина успешной проверки для обоих методов почти
одинакова, но в случае неуспеха упорядоченная линейная проверка выполняется
намного быстрее. Разница в особенности заметна, если хеш‑таблица
заполнена более, чем на 70 процентов.
=========301
@Таблица 11.2. Длина поиска при использовании линейной и
упорядоченной линейной проверки
В обоих методах для вставки нового элемента требуется
примерно одинаковое число шагов. Чтобы вставить элемент K в таблицу,
каждый из методов начинает с позиции (K Mod NumEntries) и перемещается по таблице до тех пор,
пока не найдет свободную ячейку. Во время упорядоченного хеширования может
потребоваться поменять вставляемый элемент на другие в его тестовой
последовательности. Если элементы представляют собой записи большого размера,
то на это может потребоваться больше времени, особенно если записи находятся на
диске или каком‑либо другом медленном запоминающем устройстве.
Упорядоченная линейная проверка определенно является лучшим
выбором, если вы знаете, что программе придется совершать большое число
безуспешных операций поиска. Если программа будет часто выполнять поиск
элементов, которых нет в таблице, или элементы таблицы имеют большой размер и
перемещать их достаточно сложно, то можно получить лучшую производительность
при использовании неупорядоченной линейной проверки.
Квадратичная проверка
XE "Тестовая
последовательность:квадратичная проверка"
Hash(K, P) =
(K + P2) Mod N где P = 0, 1, 2, ...
Предположим, что при вставке элемента в хеш‑таблицу он
отображается в кластер, образованный другими элементами. Если элемент отображается
в позицию возле начала кластера, то возникнет еще несколько конфликтов прежде,
чем найдется свободная ячейка для элемента. По мере роста параметра P в тестовой функции,
значение этой функции быстро меняется. Это означает, что позиция, в которую попадет
элемент в конечном итоге, возможно, окажется далеко от кластера.
=======302
На рис. 11.8 показана хеш‑таблица, содержащая большой
кластер элементов. На нем также показаны тестовые последовательности, которые
возникают при попытке вставить два различных элемента в позиции, занимаемые
кластером. Обе эти тестовые последовательности заканчиваются в точке, которая
не прилегает к кластеру, поэтому после вставки этих элементов размер кластера
не увеличивается.
Следующий код демонстрирует поиск элемента с использованием
квадратичной проверки (quadratic probing
XE "quadratic probing"
Public
Function LocateItem(Value As Long, pos As Integer, probes As Integer) As
Integer
Dim new_value
As Long
probes = 1
pos = (Value Mod m_NumEntries)
Do
new_value = m_HashTable(pos)
' Элемент найден.
If new_value = Value Then
LocateItem = HASH_FOUND
Exit Function
End If
' Элемента нет в
таблице.
If new_value = UNUSED Or probes > NumEntries
Then
LocateItem = HASH_NOT_FOUND
pos = -1
Exit Function
End If
pos = (Value + probes * probes) Mod
NumEntries
probes = probes + 1
Loop
End Function
Программа Quad
демонстрирует открытую адресацию с использованием квадратичной проверки. Она
аналогична программе Linear,
но использует квадратичную, а не линейную проверку.
В табл. 11.3 приведена средняя длина тестовых
последовательностей, полученных в программах Linear и Quad для хеш‑таблицы со 100 ячейками,
значения элементов в которой находятся в диапазоне от 1 до 999. Квадратичная
проверка обычно дает лучшие результаты.
@Рис. 11.8. Квадратичная проверка
======303
@Таблица 11.3. Длина поиска при использовании линейной и
квадратичной проверки
Квадратичная проверка также имеет некоторые недостатки. Из‑за
способа формирования тестовой последовательности, нельзя гарантировать, что она
обойдет все ячейки в таблице, что означает, что иногда в таблицу нельзя будет
вставить элемент, даже если она не заполнена до конца.
Например, рассмотрим небольшую хеш‑таблицу, состоящую
всего из шести ячеек. Тестовая последовательность для числа 3 будет следующей:
3
3 + 12
= 4 = 4 (Mod 6)
3 + 22
= 7 = 1 (Mod 6)
3 + 32
= 12 = 0 (Mod 6)
3 + 42
= 19 = 1 (Mod 6)
3 + 52
= 28 = 4 (Mod 6)
3 + 62
= 39 = 3 (Mod 6)
3 + 72
= 52 = 4 (Mod 6)
3 + 82
= 67 = 1 (Mod 6)
3 + 92
= 84 = 0 (Mod 6)
3 + 102 = 103 = 1 (Mod 6)
и так
далее.
Эта тестовая последовательность обращается к позициям 1 и 4
дважды перед тем, как обратиться к позиции 3, и никогда не попадает в позиции 2
и 5. Чтобы пронаблюдать этот эффект, создайте в программе Quad хеш‑таблицу
с шестью ячейками, а затем вставьте элементы 1, 3, 4, 6 и 9. Программа
определит, что таблица заполнена целиком, хотя две ячейки и остались
неиспользованными. Тестовая последовательность для элемента 9 не обращается к
элементам 2 и 5, поэтому программа не может вставить в таблицу новый элемент.
=======304
Можно показать, что квадратичная тестовая последовательность
будет обращаться, по меньшей мере, к N/2 ячеек таблицы, если размер таблицы N — простое число. Хотя при этом гарантируется
некоторый уровень производительности, все равно могут возникнуть проблемы, если
таблица почти заполнена. Так как производительность для почти заполненной
таблицы в любом случае сильно падает, то возможно лучше будет просто увеличить
размер хеш-таблицы, а не беспокоиться о том, сможет ли тестовая
последовательность найти свободную ячейку.
Не столь очевидная проблема, которая возникает при
применении квадратичной проверки, заключается в том, что хотя она устраняет
первичную кластеризацию, во время нее может возникать похожая проблема, которая
называется XE "Тестовая последовательность:вторичная
кластеризация" вторичной
кластеризацией (secondary clustering
XE "secondary clustering"
На рис. 11.9 показана хеш‑таблица, которая может
содержать 10 ячеек. В таблице находятся элементы 2, 12, 22 и 32, которые все
изначально отображаются в позицию 2. Если попытаться вставить в таблицу элемент
42, то нужно будет выполнить длительную тестовую последовательность, которая
обойдет все эти элементы, прежде чем найдет свободную ячейку.
Псевдослучайная проверка
Степень кластеризации растет, если в кластер добавляются
элементы, которые отображаются на уже занятые кластером ячейки. Вторичная
кластеризация возникает, когда для элементов, которые первоначально должны
занимать одну и ту же ячейку, выполняется одна и та же тестовая последовательность,
и образуется вторичный кластер, распределенный по хеш‑таблице. Можно
устранить оба эти эффекта, если сделать так, чтобы для разных элементов
выполнялись различные тестовые последовательности, даже если элементы
первоначально и должны были занимать одну и ту же ячейку.
Один из способов сделать это заключается в использовании в
тестовой последовательности генератора псевдослучайных чисел. Для вычисления
тестовой последовательности для элемента, его значение используется для
инициализации генератора случайных чисел. Затем для построения тестовой
последовательности используются последовательные случайные числа, получаемые на
выходе генератора. Это называется XE "Тестовая
последовательность:псевдослучайная проверка" псевдослучайной проверкой (pseudo‑random probing). XE "pseudo‑random probing)."
Когда позднее требуется найти элемент в хеш‑таблице,
генератор случайных чисел снова инициализируется значением элемента, при этом
на выходе генератора мы получим ту же самую последовательность чисел, которая
использовалась для вставки элемента в таблицу. Используя эти числа, можно
воссоздать исходную тестовую последовательность и найти элемент.
@Рис. 11.9. Вторичная кластеризация
==========305
Если используется качественный генератор случайных чисел, то
разные значения элементов будут давать различные случайные числа и
соответственно разные тестовые последовательности. Даже если два значения
изначально отображаются на одну и ту же ячейку, то следующие позиции в тестовой
последовательности будут уже различными. В этом случае в хеш‑таблице не
будет возникать первичная или вторичная кластеризация.
Можно проинициализировать генератор случайных чисел Visual Basic, используя начальное
число, при помощи двух строчек кода:
Rnd -1
Randomize
seed_value
Оператор Rnd
дает одну и ту же последовательность чисел после инициализации одним и тем же
начальным числом. Следующий кода показывает, как можно выполнять поиск элемента
с использованием псевдослучайной проверки:
Public
Function LocateItem(Value As Long, pos As Integer, _
probes As Integer) As Integer
Dim new_value
As Long
' Проинициализировать генератор
случайных чисел.
Rnd -1
Randomize Value
probes = 1
pos = Int(Rnd * m_NumEntries)
Do
new_value = m_HashTable(pos)
' Элемент найден.
If new_value = Value Then
LocateItem = HASH_FOUND
Exit Function
End If
' Элемента нет в
таблице.
If new_value = UNUSED Or probes > NumEntries
Then
LocateItem = HASH_NOT_FOUND
pos = -1
Exit Function
End If
pos = Int(Rnd * m_NumEntries)
probes = probes + 1
Loop
End Function
=======306
Программа Rand
демонстрирует открытую адресацию с псевдослучайной проверкой. Она аналогична
программам Linear
и Quad,
но использует псевдослучайную, а не линейную или квадратичную проверку.
В табл. 11.4 приведена примерная средняя длина тестовой
последовательности, полученной в программах Quad или Rand для хеш‑таблицы со 100 ячейками и
элементами, значения которых находятся в диапазоне от 1 до 999. Обычно
псевдослучайная проверка дает наилучшие результаты, хотя разница между
псевдослучайной и квадратичной проверками не так велика, как между линейной и
квадратичной.
Псевдослучайная проверка также имеет свои недостатки. Так
как тестовая последовательность выбирается псевдослучайно, нельзя точно
предсказать, насколько быстро алгоритм обойдет все элементы в таблице. Если
таблица меньше, чем число возможных псевдослучайных значений, то существует
вероятность того, что тестовая последовательность обратится к одному значению
несколько раз до того, как она выберет другие значения в таблице. Возможно
также, что тестовая последовательность будет пропускать какую‑либо ячейку
в таблице и не сможет вставить новый элемент, даже если таблица не заполнена до
конца.
Так же, как и в случае квадратичной проверки, эти эффекты
могут вызвать затруднения, только если таблица почти заполнена. В этом случае
увеличение таблицы дает гораздо больший прирост производительности, чем поиск
неиспользуемых ячеек таблицы.
@Рис. 11.4. Длина поиска при использовании квадратичной и
псевдослучайной проверки
=======307
Удаление элементов
Удаление элементов из хеш‑таблицы, в которой
используется открытая адресация, выполняется не так просто, как удаление их из
таблицы, использующей связные списки или блоки. Просто удалить элемент из
таблицы нельзя, так как он может находиться в тестовой последовательности
другого элемента.
Предположим, что элемент A находится в тестовой последовательности элемента B. Если удалить из таблицы
элемент A, найти
элемент B будет
невозможно. Во время поиска элемента B встретится пустая ячейка, которая осталась после удаления
элемента A, поэтому
будет сделан неправильный вывод о том, что элемент B отсутствует в таблице.
Вместо удаления элемента из хеш‑таблицы можно просто
пометить его как удаленный. Можно использовать эту ячейку позднее, если она
встретится во время выполнения вставки нового элемента в таблицу. Если
помеченный элемент встречается во время поиска другого элемента, он просто
игнорируется и тестовая последовательность продолжится.
После того, как большое число элементов будет помечено как
удаленные, в хеш‑таблице может оказаться множество неиспользуемых ячеек,
и при поиске элементов достаточно много времени будет уходить на пропуск
удаленных элементов. В конце концов, может потребоваться рехеширование таблицы
для освобождения неиспользуемой памяти.
Рехеширование
XE "Хеширование:рехеширование" rehashing
XE "rehashing"
Rehashed.
Type ItemType
Value As Long
Rehashed As
Boolean
End Type
Вначале присвоим полю Rehashed
значение false. Затем выполним проход по таблице в
поиске ячеек, которые не помечены как удаленные, и для которых еще не было
выполнено рехеширование.
Если такой элемент встретится, то выполняется его удаление
из таблицы и повторное хеширование, при этом выполняется обычная тестовая
последовательность для элемента. Если встречается свободная или помеченная как
удаленная ячейка, элемент размещается в ней, помечается как рехешированный, и
продолжается проверка остальных элементов, для которых еще не было выполнено
рехеширование.
Если при выполнении рехеширования найдется элемент, который
уже был помечен как рехешированный, то тестовая последовательность
продолжается. Если затем встретится элемент, для которого еще не было выполнено
рехеширование, то элементы меняются местами, текущая ячейка помечается как
рехешированная и процесс начинается снова.
======308
Изменение размера хеш‑таблиц
Если хеш‑таблица становится почти заполненной,
производительность значительно падает. В этом случае может понадобиться
увеличение размера таблицы, чтобы в ней было больше места для элементов. И
наоборот, если в таблице слишком мало ячеек, может потребоваться уменьшить ее,
чтобы освободить занимаемую память. Используя методы, похожие на те, которые
использовались при рехешировании таблицы на месте, можно увеличивать и
уменьшать размер хеш‑таблицы.
Чтобы увеличить хеш‑таблицу, вначале размер массива, в
котором она находится, увеличивается при помощи оператора Dim Preserve. Затем
выполняется рехеширование таблицы, при этом элементы могут занимать ячейки в
созданной свободной области в конце таблицы. После завершения рехеширования
таблица будет готова к использованию.
Чтобы уменьшить размер таблицы, вначале определим, сколько
элементов должно содержаться в массиве таблицы после уменьшения. Затем
выполняем рехеширование таблицы, причем элементы помещаются только в
уменьшенную часть таблицы. После завершения рехеширования всех элементов,
размер массива уменьшается при помощи оператора ReDim Preserve.
Следующий код демонстрирует рехеширование таблицы с
использованием линейной проверки. Код для рехеширования таблицы с
использованием квадратичной или псевдослучайной проверки выглядит почти так же:
Public Sub
Rehash()
Dim i As
Integer
Dim pos As
Integer
Dim probes As
Integer
Dim Value As
Long
Dim new_value
As Long
' Пометить все элементы как
нерехешированные.
For i = 0 To NumEntries - 1
m_HashTable(i).Rehashed = False
Next i
' Поиск
нерехешированных элементов.
For i = 0 To NumEntries - 1
If Not m_HashTable(i).Rehashed Then
Value = m_HashTable(i).Value
m_HashTable(i).Value = UNUSED
If Value <> DELETED And Value
<> UNUSED Then
' Выполнить тестовую
последовательность
' для
этого элемента, пока не найдется свободная,
'
удаленная или нерехешированная ячейка.
probes = 0
Do
pos = (Value + probes) Mod
NumEntries
new_value =
m_HashTable(pos).Value
' Если ячейка свободна
или помечена как
'
удаленная, поместить элемент в нее.
If new_value = UNUSED Or _
new_value = DELETED _
Then
m_HashTable(pos).Value =
Value
m_HashTable(pos).Rehashed
= True
Exit Do
End If
' Если ячейка не
помечена как рехешированная,
'
поменять их местами и продолжить.
If Not m_HashTable(pos).Rehashed Then
m_HashTable(pos).Value =
Value
m_HashTable(pos).Rehashed
= True
Value = new_value
probes = 0
Else
probes = probes + 1
End If
Loop
End If
End If
Next i
End Sub
Программа Rehash
использует открытую адресацию с линейной проверкой. Она аналогична программе Linear, но позволяет также помечать объекты как
удаленные и выполнять рехеширование таблицы.
Резюме
Различные типы хеш‑таблиц, описанные в этой главе,
имеют свои преимущества и недостатки.
Для хеш‑таблиц, которые используют связные списки или
блоки можно легко изменять размер таблицы и удалять из нее элементы.
Использование блоков также позволяет легко работать с таблицами на диске,
позволяя считать за одно обращение к диску сразу множество элементов данных.
Тем не менее, оба эти метода являются более медленными, чем открытая адресация.
Линейная проверка проста и позволяет достаточно быстро
вставлять и удалять элементы из таблицы. Применение упорядоченной линейной
проверки позволяет быстрее, чем в случае неупорядоченной линейной проверки,
установить, что элемент отсутствует в таблице. С другой стороны, вставку
элементов в таблицу при этом выполнить сложнее.
Квадратичная проверка позволяет избежать кластеризации,
которая характерна для линейной проверки, и поэтому обеспечивает более высокую
производительность. Псевдослучайная проверка обеспечивает еще более высокую
производительность, так как при этом удается избавиться как от первичной, так и
от вторичной кластеризации.
В табл. 11.5 приведены преимущества и недостатки различных
методов хеширования.
======310
@Таблица 11.5. Преимущества и недостатки различных методов
хеширования
Выбор наилучшего метода хеширования для данного приложения
зависит от данных задачи и способов их использования. При применении разных
схем достигаются различные компромиссы между занимаемой памятью, скоростью и
простотой изменений. Табл. 11.5 может помочь вам выбрать наилучший алгоритм для
вашего приложения.
=======311
Глава 12. Сетевые алгоритмы
В 6 и 7 главах обсуждались алгоритмы работы с деревьями.
Данная глава посвящена более общей теме сетей. Сети играют важную роль во
многих приложениях. Их можно использовать для моделирования таких объектов, как
сеть улиц, телефонная или электрическая сеть, водопровод, канализация,
водосток, сеть авиаперевозок или железных дорог. Менее очевидна возможность
использования сетей для решения таких задач, как разбиение на районы,
составление расписания методом критического пути, планирование коллективной
работы или распределения работы.
Определения
Как и в определении деревьев, XE "Сеть" сетью (network
XE "network" XE "Граф" графом (graph
XE "graph" XE "Сеть:узел" узлов (nodes
XE "node" XE "Сеть:ребро" ребрами (edges
XE "edge" XE "Сеть:связь" связями (links
XE "link"
С ребрами сети может быть связано соответствующее
направление, тогда в этом случае сеть называется XE "Сеть:ориентированная" ориентированной сетью (directed network
XE "network:directed"
XE "Сеть:цена связи" цену (cost).
Для сети дорог, например, цена может быть равна времени, которое займет проезд
по отрезку дороги, представленному ребром сети. В телефонной сети цена может
быть равна коэффициенту электрических потерь в кабеле, представленном связью.
На рис. 12.1 показана небольшая ориентированная сеть, в которой числа рядом с
ребрами соответствуют цене ребра.
XE "Сеть:путь" Путем (path
XE "path" A и B называется
последовательность ребер, которая связывает два этих узла между собой. Если
между любыми двумя узлами сети есть не больше одного ребра, то путь можно
однозначно описать, перечислив входящие в него узлы. Так как такое описание
проще представить наглядно, то пути по возможности описываются таким образом.
На рис. 12.1 путь, проходящий через узлы B, E, F, G,E и D,
соединяет узлы B и D.
XE "Сеть:цикл" Циклом (cycle
XE "cycle" E, F, G, E на рис. 12.1 является
циклом. Путь называется XE "Сеть:простой путь" простым (simple),
если он не содержит циклов. Путь B, E, F, G, E, D не
является простым, так как он содержит цикл E, F, G, E.
Если существует какой‑либо путь между двумя узлами, то
должен существовать и простой путь между ними. Этот путь можно найти, если
удалить все циклы из исходного пути. Например, если заменить цикл E, F, G, E в
пути B, E, F, G, E, D на узел E, то получится простой путь B, E, D, связывающий узлы B и D.
=======313
@Рис. 12.1. Ориентированная сеть с ценой ребер
Сеть называется XE "Сеть:связная" связной (connected
XE "network:connected"
E в узел C.
Представления сети
В 6 главе было описано несколько представлений деревьев.
Большинство из них применимо также и для работы с сетями. Например,
представления полными узлами, списком потомков (списком соседей для сетей) или
нумерацией связей также могут использоваться для хранения сетей. За описанием
этих представлений обратитесь к 6 главе.
@Рис. 12.2. Связная (слева) и несвязная (справа) сети
======314
Для различных приложений могут лучше подходить разные
представления сети. Представление полными узлами обеспечивает хорошие
результаты, если каждый узел в сети связан с небольшим числом ребер.
Представление списком соседних узлов обеспечивает большую гибкость, чем
представление полными узлами, а представление нумерацией связей, хотя его
сложнее модифицировать, обеспечивает более высокую производительность.
Кроме этого, некоторые варианты представления ребер могут
упростить работу с определенными типами сетей. Эти представления используют
один класс для узлов и другой — для представления связей. Применение
класса для связей облегчает работу со свойствами связей, такими, как цена связи.
Например, ориентированная сеть с ценой связей может
использовать следующее определения для класса узла:
Public Id As
Integer ' Номер узла.
Public Links
As Collection ' Связи, ведущие к
соседним узлам.
Можно использовать следующее определение класса связей:
Public ToNode
As NetworkNode ' Узел на
другом конце связи.
Public Cost
As Integer ' Цена связи.
Используя эти определения, программа может найти связь с
наименьшей ценой, используя следующий код:
Dim link As
NetworkLink
Dim best_link
As NetworkLink
Dim best_cost
As Integer
best_cost = 32767
For Each link In node.Links
If link.cost < best_cost Then
Set best_link = link
best_cost = link.cost
End If
Next link
Классы node
и link
часто расширяются для удобства работы с конкретными алгоритмами. Например, к
классу node
часто добавляется флаг Marked.
Если программа обращается к узлу, то она устанавливает значение поля Marked равным true, чтобы знать, что узел уже был проверен.
Программа, управляющая неориентированной сетью, может
использовать немного другое представление. Класс node
остается тем же, что и раньше, но класс link
включает ссылку на оба узла на концах связи.
Public Node1 As NetwokNode '
Один из узлов на конце связи.
Public Node2
As NetwokNode ' Другой узел.
Public Cost
As Integer ' Цена связи.
Для неориентированной сети, предыдущее представление
использовало бы два объекта для представления каждой связи — по одному для
каждого из направлений связи. В новой версии каждая связь представлена одним
объектом. Это представление достаточно наглядно, поэтому оно используется далее
в этой главе.
=======315
Используя это представление, программа NetEdit позволяет
оперировать неориентированными сетями с ценой связей. Меню File (Файл)
позволяет загружать и сохранять сети в файлах. Команды в меню Edit (Правка) позволяют вам вставлять
и удалять узлы и связи. На рис. 12.3 показано окно программы NetEdit.
Директория OldSrcCh12
содержит программы, которые используют представление нумерацией связей. Эти
программы немного сложнее понять, но они обычно работают быстрее. Они не
описаны в тексте, но использованные в них методы похожи на те, которые
применялись в программах, написанных для 4 версии Visual Basic. Например, обе
программы SrcCh12Paths и OldSrcCh12Paths находят
кратчайший маршрут, используя описанный ниже алгоритм установки меток. Основное
отличие между ними заключается в том, что первая программа использует коллекции
и классы, а вторая — псевдоуказатели и представление нумерацией связей.
Оперирование узлами и связями
Корень дерева — это единственный узел, не имеющий
родителя. Можно найти любой узел в сети, начав от корня и следуя по указателям
на дочерние узлы. Таким образом, узел представляет основание дерева. Если
ввести переменную, которая будет содержать указатель на корневой узел, то
впоследствии можно будет получить доступ ко всем узлам в дереве.
Сети не всегда содержат узел, который занимает такое особое
положение. В несвязной сети может не существовать способа обойти все узлы по
связям, начав с одного узла.
Поэтому программы, работающие с сетями, обычно содержат
полный список всех узлов в сети. Программа также может хранить полный список
всех связей. При помощи этих списков можно легко выполнить какие‑либо
действия над всеми узлами или связями в сети. Например, если программа хранит
указатели на узлы и связи в коллекциях Nodes и Links, она может вывести сеть на экран при
помощи следующего метода:
@Рис. 12.3. Программа NetEdit
=======316
Dim node As
NetworkNode
dim link As
NetworkLink
For Each link in links
' Нарисовать связь.
:
Next link
For Each node in nodes
' Нарисовать узел.
:
Next node
Программа NetEdit
использует коллекции Nodes
и Links
для вывода сетей на экран.
Обходы сети
Обход сети выполняется аналогично обходу дерева. Можно
обходить сеть, используя либо обход в глубину, либо обход в ширину. Обход в
ширину обычно похож на прямой обход дерева, хотя для сетей можно определить
также обратный и даже симметричный обход.
Алгоритм для выполнения прямого обхода двоичного дерева,
описанный в 6 главе, формулируется так:
Обратиться к
узлу.
Выполнить
рекурсивный прямой обход левого поддерева.
Выполнить
рекурсивный прямой обход правого поддерева.
В дереве между связанными между собой узлами существует
отношение родитель‑потомок. Так как алгоритм начинается с корневого узла
и всегда выполняется сверху вниз, он не обращается дважды ни к одному узлу.
В сети узлы не обязательно связаны в направлении сверху
вниз. Если попытаться применить к сети алгоритм прямого обхода, может
возникнуть бесконечный цикл.
Чтобы избежать этого, алгоритм должен помечать узел после
обращения к нему, при этом при поиске в соседних узлах, обращение происходит
только к узлам, которые еще не были помечены. После того, как алгоритм завершит
работу, все узлы в сети будут помечены (если сеть является связной). Алгоритм
прямого обхода сети формулируется так:
Пометить узел.
Обратиться к
узлу.
========317
В Visual Basic
можно добавить флаг Marked
к классу NetworkNode.
Public Id As
Long
Public Marked
As Boolean
Public Links
As Collection
Класс NetworkNode
может включать открытую процедуру для обхода сети, начиная с этого узла.
Процедура узла PreorderPrint
обращается ко всем непомеченным узлам, которые доступны из данного узла. Если
сеть является связной, то при таком обходе произойдет обращение ко всем узлам
сети.
Public Sub
PreorderPrint()
Dim link As
NoworkLink
Dim node As
NetworkNode
' Пометить узел.
Marked = True
' Обратиться к
непомеченным узлам.
For Each link In Links
' Найти соседний узел.
If link.Node1 Is Me Then
Set node = link.Node2
Else
Set node = link.Node1
End If
' Определить,
требуется ли обращение к соседнему узлу.
If Not node.Marked Then node.PreorderPrint
Next link
End Sub
Так как эта процедура не обращается ни к одному узлу дважды,
то коллекция обходимых связей не содержит циклов и образует дерево.
Если сеть является связной, то дерево будет обходить все
узлы сети. Так как это дерево охватывает все узлы сети, то оно называется XE "Сеть:остовное дерево" остовным деревом (spanning tree
XE "spanning tree"
A, которое изображено жирными
линиями.
Можно использовать похожий подход с пометкой узлов для
преобразования обхода дерева в ширину в сетевой алгоритм. Алгоритм обхода
дерева начинается с помещения корневого узла в очередь. Затем первый узел
удаляется из очереди, происходит обращение к узлу, и затем в конце очереди
помещаются его дочерние узлы. Затем этот процесс повторяется до тех пор, пока
очередь не опустеет.
======318
@Рис. 12.4. Остовное дерево
В алгоритме обхода сети нужно вначале убедиться, что узел не
проверялся раньше или он уже не находится в очереди. Для этого мы помечаем
каждый узел, который помещается в очередь. Сетевая версия этого алгоритма
выглядит так:
a)
b)
Следующая процедура печатает список узлов сети в порядке
обхода в ширину:
Public Sub
BreadthFirstPrint(root As NetworkNode)
Dim queue As
New Collection
Dim node As
NetworkNode
Dim neighbor
As NetworkNode
Dim link As
NetworkLink
' Поместить корень в очередь.
root.Marked = True
queue.Add root
' Многократно помещать верхний
элемент в очередь
' пока очередь не
опустеет.
Do While queue.Count > 0
' Выбрать следующий узел из
очереди.
Set node = queue.Item(1)
queue.Remove 1
' Обратиться к
узлу.
Print node.Id
' Добавить в
очередь все непомеченные соседние узлы.
For Each link In node.Links
' Найти соседний узел.
If link.Node1 Is Me Then
Set neighbor = link.Node2
Else
Set neighbor = link.Node1
End If
' Проверить,
нужно ли обращение к соседнему узлу.
If Not neighbor.Marked Then
queue.Add neighbor
Next link
Loop
End Sub
Наименьшие остовные деревья
Если задана сеть с ценой связей, то XE
"Сеть:наименьшее остовное дерево" наименьшим остовным деревом (minimal spanning tree
XE "minimal spanning tree"
Например, предположим, что требуется разработать телефонную
сеть, которая должна соединить шесть городов. Можно проложить магистральный
кабель между всеми парами городов, но это будет неоправданно дорого. Меньшую
стоимость будет иметь решение, при котором города будут соединены связями,
которые содержатся в наименьшем остовном дереве. На рис. 12.5 показаны шесть
городов, каждые два из которых соединены магистральным кабелем. Жирными линиями
нарисовано наименьшее остовное дерево.
Заметьте, что сеть может иметь несколько наименьших остовных
деревьев. На рис. 12.6 показаны два изображения сети с двумя различными
наименьшими остовными деревьями, которые нарисованы жирными линиями. Полная
цена обоих деревьев равна 32.
@Рис. 12.5. Магистральные телефонные кабели, связывающие
шесть городов
========320
@Рис. 12.6. Два различных наименьших остовных дерева для
одной сети
Существует простой алгоритм поиска наименьшего остовного
дерева для сети. Вначале поместим в остовное дерево любой узел. Затем найдем
связь с наименьшей ценой, которая соединяет узел в дереве с узлом, который еще
не помещен в дерево. Добавим эту связь и соответствующий узел в дерево. Затем
эта процедура повторяется до тех пор, пока все узлы не окажутся в дереве.
Этот алгоритм похож на эвристику восхождения на холм,
описанную в 8 главе. На каждом шаге оба алгоритма изменяют решение, пытаясь его
максимально улучшить. Алгоритм остовного дерева на каждом шаге выбирает связь с
наименьшей ценой, которая добавляет новый узел в дерево. В отличие от эвристики
восхождения на холм, которая не всегда находит наилучшее решение, этот алгоритм
гарантированно находит наименьшее остовное дерево.
Подобные алгоритмы, которые находят глобальный оптимум, при
помощи серии локально оптимальных приближений называются XE "Алгоритм:поглощающий" поглощающими алгоритмами[RV20] (greedy algorithms
XE "greedy algorithms"
Алгоритм наименьшего остовного дерева использует коллекцию
для хранения списка связей, которые могут быть добавлены к остовному дереву.
Вначале алгоритм помещает в этот список связи корневого узла. Затем проводится
поиск связи с наименьшей ценой в этом списке. Чтобы максимально ускорить поиск,
программа может использовать приоритетную очередь типа описанной в 9 главе. Или
наоборот, чтобы упростить реализацию, программа может использовать для хранения
списка возможных связей коллекцию.
Если узел на другом конце связи еще не находится в остовном
дереве, то программа добавляет его и соответствующую связь в дерево. Затем она
добавляет связи, выходящие из нового узла, в список возможных узлов.
Алгоритм использует флаг Used
в классе link, чтобы определить, попадала ли эта
связь ранее в список возможных связей. Если да, то она не заносится в этот
список снова.
Может оказаться, что список возможных связей опустеет до
того, как все узлы будут добавлены в остовное дерево. В этом случае сеть
является несвязной, и не существует путь, который связывает корневой узел со
всеми остальными узлами сети.
=========321
Private Sub
FindSpanningTree(root As SpanNode)
Dim
candidates As New Collection
Dim to_node
As SpanNode
Dim link As
SpanLink
Dim i As
Integer
Dim best_i As
Integer
Dim best_cost
As Integer
Dim
best_to_node As SpanNode
If root Is Nothing Then Exit Sub
' Сбросить флаг Marked для всех узлов и флаги
' Used и InSpanningTree для всех связей.
ResetSpanningTree
' Начать с корня
остовного дерева.
root.Marked = True
Set best_to_node = root
Do
' Добавить связи последнего
узла в список
' возможных
связей.
For Each link In best_to_node.Links
If Not link.Used Then
candidates.Add link
link.Used = True
End If
Next link
' Найти самую короткую связь в
списке возможных
' связей, которая
ведет к узлу, которого еще нет
' в дереве.
best_i = 0
best_cost = INFINITY
i = 1
Do While i <= candidates.Count
Set link = candidates(i)
If link.Node1.Marked Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
If to_node.Marked Then
' Связь соединяет два
узла, которые
' оба
находятся в дереве.
' Удалить
ее из списка возможных связей.
candidates.Remove i
Else
If link.Cost < best_cost Then
best_i = i
best_cost = link.Cost
Set best_to_node = to_node
End If
i = i + 1
End If
Loop
' Если больше не
осталось связей, которые можно
' было бы добавить,
то мы сделали все, что могли.
If best_i < 1 Then Exit Do
' Добавить наилучшую связь и узел
на ее конце в дерево.
Set link = candidates(best_i)
link.InSpanningTree = True
candidates.Remove best_i
best_to_node.Marked = True
Loop
GotSpanningTree = True
' Перерисовать сеть.
DrawNetwork
End Sub
Этот алгоритм проверяет каждую связь не более одного раза.
При проверке каждой связи, она добавляется в список возможных связей, а затем
удаляется из него. Если этот список находится в приоритетной очереди на основе
пирамид, то для вставки или удаления элемента из очереди потребуется время
порядка O(log(N)), где — число связей в сети. В
этом случае полное время выполнения алгоритма будет порядка O(N * log(N)).
Если список возможных связей находится в коллекции, как в
вышеприведенном коде, то для поиска в списке связи с наименьшей ценой
потребуется время порядка O(N), при этом полное время
выполнения алгоритма будет порядка O(N2).
Для малых N
производительность будет приемлемой. Если же число связей в сети достаточно
велико, то список возможных связей следует хранить в приоритетной очереди, а не
в коллекции.
Программа Span
использует этот алгоритм для поиска наименьшего остовного дерева. Эта программа
аналогична программе NetEdit.
Она позволяет загружать, редактировать и сохранять на диске файлы,
представляющие сеть. Если выбрать какой‑либо узел в программе двойным
щелчком мыши, то программа найдет и выведет на экран наименьшее остовное дерево
с корнем в этом узле. На рис. 12.7 показано окно программы Span, в котором показано наименьшее остовное
дерево с корнем в узле 9.
======322-323
@Рис. 12.7. Программа Span
Кратчайший маршрут
XE "Сеть:кратчайший маршрут" XE "Кратчайший маршрут:дерево кратчайшего
маршрута" деревом
кратчайшего маршрута (shortest path
XE "shortest path"
tree).
На рис. 12.8 показано дерево, в котором дерево кратчайшего
маршрута с корнем в узле A
нарисовано жирной линией. Это дерево изображает кратчайший маршрут из узла A до всех остальных узлов в сети.
Например, кратчайший маршрут из узла A в узел F
проходит через узлы A, C, E, F.
Многие алгоритмы поиска кратчайшего маршрута начинают с
пустого дерева, к которому затем добавляется по одной связи до тех пор, пока
дерево не будет заполнено. Эти алгоритмы можно разбить на две категории в
соответствии со способом выбора следующей связи, которая должна быть добавлена
к растущему дереву кратчайшего маршрута.
Алгоритмы XE "Кратчайший
маршрут:установка меток" установки меток (label setting
XE "label setting"
Алгоритмы XE "Кратчайший
маршрут:коррекция меток" коррекции меток (label correcting
XE "label correcting"
=====324
@Рис. 12.8. Дерево кратчайшего маршрута
Алгоритмы установки и коррекции меток, описанные в следующих
разделах, используют похожие классы для представления узлов и связей. Класс
узла включает поле Dist,
которое определяет расстояние от корня до узла в растущем дереве кратчайшего
маршрута. В алгоритме установки меток, после вставки узла в дерево полю Dist присваивается
правильное значение, и оно в дальнейшем не меняется. В алгоритме коррекции
меток, значение поля Dist
может понадобиться обновить, если алгоритм заменит связь.
Класс узла также включает поле NodeStatus, которое указывает, находится ли
узел в дереве кратчайшего маршрута, списке возможных связей, или ни в одной из
этих структур. Поле InLink
указывает на связь, которая ведет к узлу в дереве кратчайшего маршрута.
Public Id As
Integer
Public X As
Single
Public Y As
Single
Public Links
As Collection
Public Dist As Integer '
Расстояние от корня дерева пути.
Public
NodeStatus As Integer ' Статус дерева маршрута.
Public InLink
As PathSLink ' Связь, ведущая к
узлу.
======325
Используя поле InLink,
программа может перечислить узлы в пути от корня до узла I в обратном
порядке при помощи следующего кода:
Dim node As
PathSNode
Set node = I
Do
' Вывести узел.
Print node.Id
If node Is Root Then Exit Do
' Перейти к следующему узлу вверх
по дереву.
If node.IsLink.Node1 Is node Then
Set node = node.InLink.Node2
Else
Set node = node.InLink.Node1
End If
Loop
Класс link
в алгоритме включает поле InPathTree,
которое указывает, является ли связь частью дерева кратчайшего маршрута.
Public Node1
As PathSNode
Public Node2
As PathSNode
Public Cost
As Integer
Public
InPathTree As Boolean
Оба алгоритма установки и коррекции меток используют список
возможных связей, в котором находятся связи, которые могут быть добавлены в
дерево кратчайшего маршрута, но они по‑разному оперируют этим списком.
Алгоритм установки меток всегда выбирает связь, которая обязательно окажется
частью дерева кратчайшего маршрута. Алгоритм коррекции меток выбирает элемент,
который находится на вершине списка.
Установка меток
XE "Кратчайший маршрут:установка
меток" Dist корневого узла устанавливается равным 0.
Затем корневой узел помещается в список возможных узлов, при этом значение поля
NodeStatus этого
узла принимает значение NOW_IN_LIST, указывая на
то, что он находится в списке.
После этого выполняется поиск в списке узла с наименьшим
значением Dist.
Первоначально это будет корневой узел, так как он единственный в списке.
Затем алгоритм удаляет этот узел из списка, и устанавливает
значение поля NodeStatus
для этого узла равным WAS_IN_LIST, указывая на
то, что этот узел теперь является частью дерева кратчайшего маршрута. Поля Dist и IsLink узла уже
имеют правильные значения. Для каждого корневого узла, значение поля IsLink равно Nothing, а
значение поля Dist
равно нулю.
После этого алгоритм проверяет все связи, выходящие из
выбранного узла. Если соседний узел на другом конце связи никогда не находился
в списке возможных узлов, то алгоритм добавляет его к списку. Он устанавливает
значение поля NodeStatus
соседнего узла равным NOW_IN_LIST., а значение
поля Dist —
расстоянию от корневого узла до выбранного узла плюс цене связи. И, наконец, он
присваивает значение полю InLink
соседнего узла так, чтобы оно указывало на связь с соседним узлом.
========326
Во время проверки алгоритмом связей, выходящих из выбранного
узла, если значение поля NodeStatus
соседнего узла равно NOW_IN_LIST,
то этот узел уже находится в списке возможных узлов. Алгоритм проверяет текущее
значение Dist
соседнего узла, проверяя, не будет ли путь через выбранный узел короче. Если
это так, то он обновляет поля InLink
и Dist
соседнего узла и оставляет соседний узел в списке возможных узлов.
Алгоритм повторяет этот процесс, удаляя узлы из списка
возможных узлов, проверяя соседние с ними узлы и добавляя соседние узлы в
список до тех пор, пока список не опустеет.
На рис. 12.9 показана часть дерева кратчайшего маршрута. В
этой точке алгоритм проверил узлы A и B,
удалил их из списка возможных узлов, и проверил их связи. Узлы A и B уже добавлены к дереву кратчайшего
маршрута, и теперь в списке возможных узлов находятся узлы C, D и E.
Жирные стрелки на рис. 12.9 соответствуют значениям полей InLink
узлов в этой точке. Например, значение поля InLink для узла E соответствует связи между узлами E и B.
После этого алгоритм ищет в списке возможных узлов узел с
наименьшим значением Dist.
В данной точке значения полей Dist
узлов C, D и E равны 10, 21 и 22 соответственно,
поэтому алгоритм выбирает узел C.
Узел C удаляется из
списка возможных узлов, и его полю NodeStatus присваивается значение WAS_IN_LIST. Теперь узел C является частью дерева
кратчайшего маршрута, и его поля Dist
и InLink
имеют правильные значения.
Затем алгоритм проверяет связи, выходящие из узла C. Единственная связь,
выходящая из узла C,
идет к узлу E, который
уже содержится в списке возможных узлов, поэтому алгоритм не добавляет его в
список снова.
Текущий кратчайший маршрут от корня в узел E — это путь A, B, E,
полная цена которого равна 22. Но цена пути A, C, E равна всего 17., что
меньше, чем текущая цена 22, поэтому алгоритм обновляет значение InLink для узла E, и присваивает полю Dist этого узла
значение 17.
@Рис. 12.9. Часть дерева кратчайшего маршрута
=========327
Private Sub
FindPathTree(root As PathSNode)
Dim
candidates As New Collection
Dim i As
Integer
Dim best_i As
Integer
Dim best_dist
As Integer
Dim new_dist
As Integer
Dim node As
PathSNode
Dim to_node
As PathSNode
Dim link As
PathSLink
If root Is Nothing Then Exit Sub
' Сбросить значения полей Marked и NodeStatus всех узлов,
' и флаги Used и InPathTree всех связей.
ResetPathTree
' Начать с корня дерева
кратчайшего маршрута.
root.Dist = 0
Set root.InLink = Nothing
root.NodeStatus = NOW_IN_LIST
candidates.Add root
Do While candidates.Count > 0
' Найти ближайший к корню узел‑кандидат.
best_dist = INFINITY
For i = 1 To candidates.Count
new_dist = candidates(i).Dist
If new_dist < best_dist Then
best_i = i
best_dist = new_dist
End If
Next i
' Добавить узел к дерева
кратчайшего маршрута.
Set node = candidates(best_i)
candidates.Remove best_i
node.NodeStatus = WAS_IN_LIST
' Проверить соседние узлы.
For Each link In node.Links
If node Is link.Node1 Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
If to_node.NodeStatus = NOT_IN_LIST
Then
' Узел раньше не был в списке возможных
' узлов. Добавить его в список.
candidates.Add to_node
to_node.NodeStatus = NOW_IN_LIST
to_node.Dist = best_dist +
link.Cost
Set to_node.InLink = link
ElseIf to_node.NodeStatus =
NOW_IN_LIST Then
' Узел находится в списке
возможных узлов.
' Обновить
значения его полей Dist и inlink,
' если это
необходимо.
new_dist = best_dist + link.Cost
If new_dist < to_node.Dist
Then
to_node.Dist = new_dist
Set to_node.InLink = link
End If
End If
Next link
Loop
GotPathTree = True
' Пометить входящие узлы, чтобы их
было проще вывести на экран.
For Each node In Nodes
If Not (node.InLink Is Nothing) Then _
node.InLink.InPathTree = True
Next node
' Перерисовать сеть.
DrawNetwork
End Sub
Важно, чтобы алгоритм обновлял поля InLink и Dist только для
узлов, в которых поле NodeStatus
равно NOW_IN_LIST. Для
большинства сетей нельзя получить более короткий путь, добавляя узлы, которые
не находятся в списке возможных узлов. Тем не менее, если сеть содержит цикл,
полная длина которого отрицательна, алгоритм может обнаружить, что можно
уменьшить расстояние до некоторых узлов, которые уже находятся в дереве
кратчайшего маршрута, при этом две ветви дерева кратчайшего маршрута окажутся
связанными друг с другом, так что оно перестанет быть деревом.
На рис. 12.10 показана сеть с циклом отрицательной цены и
«дерево» кратчайшего маршрута, которое получилось бы, если бы алгоритм обновлял
цену узлов, которые уже находятся в дереве.
=======329
@Рис. 12.10. Неправильное «дерево» кратчайшего маршрута для
сети с циклом отрицательной цены
Программа PathS
использует этот алгоритм установки меток для вычисления кратчайшего маршрута.
Она аналогична программам NetEdit
и Span.
Если вы не вставляете или не удаляете узел или связь, то можно выбрать узел при
помощи мыши и программа при этом найдет и выведет на экран дерево кратчайшего
маршрута с корнем в этом узле. На рис. 12.11 показано окно программы PathS с деревом
кратчайшего маршрута с корнем в узле 3.
@Рис. 12.11. Дерево кратчайшего маршрута с корнем в узле 3
=======330
Варианты метода установки меток
Узкое место этого алгоритма заключается в поиске узла с
наименьшим значением поля Dist
в списке возможных узлов. Некоторые варианты этого алгоритма используют другие
структуры данных для хранения списка возможных узлов. Например, можно было бы
использовать упорядоченный связный список. При использовании этого метода
потребуется только один шаг для того, чтобы найти следующий узел, который будет
добавлен к дереву кратчайшего маршрута. Этот список будет всегда упорядоченным,
поэтому узел на вершине списка всегда будет искомым узлом.
Это облегчит поиск нужного узла в списке, но усложнит
добавление узла в него. Вместо того чтобы просто помещать узел в начало списка,
его придется поместить в нужную позицию.
Иногда также требуется перемещать узлы в списке. Если в
результате добавления узла в дерево кратчайшего маршрута уменьшилось кратчайшее
расстояние до другого узла, который уже был в списке, то нужно переместить этот
элемент ближе к вершине списка.
Предыдущий алгоритм и этот его новый вариант представляют
собой два крайних случая управления списком возможных узлов. Первый алгоритм
совсем не упорядочивает список и тратит достаточно много времени на поиск узлов
в сети. Второй тратит много времени на поддержание упорядоченности списка, но
может очень быстро выбирать из него узлы. Другие варианты используют
промежуточные стратегии.
Например, можно использовать для хранения списка возможных
узлов приоритетную очередь на основе пирамид, тогда можно будет просто выбрать
следующий узел с вершины пирамиды. Вставка нового узла в пирамиду и ее
переупорядочение будет выполняться быстрее, чем аналогичные операции для
упорядоченного связного списка. Другие стратегии используют сложные схемы организации
блоков для того, чтобы упростить поиск возможных узлов.
Некоторые из этих вариантов достаточно сложны. Из‑за
этой их сложности эти алгоритмы для небольших сетей часто выполняются
медленнее, чем более простые алгоритмы. Тем не менее, для очень больших сетей
или сетей, в которых каждый узел имеет очень большое число связей, выигрыш от
применения этих алгоритмов может стоить дополнительного усложнения.
Коррекция меток
XE "Кратчайший маршрут:коррекция
меток" Dist корневого
узла и помещает корневой узел в список возможных узлов. При этом значения полей
Dist остальных
узлов устанавливаются равными бесконечности. Затем для вставки в дерево
кратчайшего маршрута выбирается первый узел в списке возможных узлов.
После этого алгоритм проверяет узлы, соседние с выбранным,
выясняя, будет ли расстояние от корня до выбранного узла плюс цена связи
меньше, чем текущее значение поля Dist
соседнего узла. Если это так, то поля Dist и InLink соседнего узла обновляются так, чтобы
кратчайший маршрут к соседнему узлу проходил через выбранный узел. Если
соседний узел при этом не находился в списке возможных узлов, то алгоритм также
добавляет его к списку. Заметьте, что алгоритм не проверяет, попадал ли этот
узел в список раньше. Если путь от корня до соседнего узла становится короче,
узел всегда добавляется в список возможных узлов.
Алгоритм продолжает удалять узлы из списка возможных узлов,
проверяя соседние с ними узлы и добавляя соседние узлы в список до тех пор,
пока список не опустеет.
Если внимательно сравнить алгоритмы установки меток и
коррекции меток, то видно, что они похожи. Единственное отличие заключается в
том, как каждый из них выбирает элементы из списка возможных узлов для вставки
в дерево кратчайшего маршрута.
=====331
Алгоритм установки меток всегда выбирает связь, которая
гарантированно находится в дереве кратчайшего маршрута. При этом после того,
как узел удаляется из списка возможных узлов, он навсегда помещается в дерево и
больше не попадает в список возможных узлов.
Алгоритм корректировки всегда выбирает первый узел из списка
возможных узлов, который не всегда может быть наилучшим выбором. Значения полей
Dist и InLink этого узла
могут быть не наилучшими из возможных. В этом случае алгоритм, в конце концов,
найдет в списке узел, через который проходит более короткий путь к выбранному
узлу. Тогда алгоритм обновляет поля Dist
и InLink
и снова помещает обновленный узел в список возможных узлов.
Алгоритм может использовать новый путь для создания других
путей, которые он мог пропустить раньше. Помещая обновленный узел снова в
список обновленных узлов, алгоритм гарантирует, что этот узел будет проверен
снова и будут найдены все такие пути.
Private Sub
FindPathTree(root As PathCNode)
Dim
candidates As New Collection
Dim node_dist
As Integer
Dim new_dist
As Integer
Dim node As
PathCNode
Dim to_node
As PathCNode
Dim link As
PathCLink
If root Is Nothing Then Exit Sub
' Сбросить поля Marked и NodeStatus для всех узлов,
' и флаги Used и InPathTree для всех связей.
ResetPathTree
' Начать с корня
дерева кратчайшего маршрута.
root.Dist = 0
Set root.InLink = Nothing
root.NodeStatus = NOW_IN_LIST
candidates.Add root
Do While candidates.Count > 0
' Добавить узел в дерево кратчайшего
маршрута.
Set node = candidates(1)
candidates.Remove 1
node_dist = node.Dist
node.NodeStatus = NOT_IN_LIST
' Проверить соседние узлы.
For Each link In node.Links
If node Is link.Node1 Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
' Проверить, существует ли
более короткий
' путь через
этот узел.
new_dist = node_dist + link.Cost
If to_node.Dist > new_dist Then
' Путь лучше. Обновить
значения Dist и InLink.
Set to_node.InLink = link
to_node.Dist = new_dist
' Добавить узел в список
возможных узлов,
' если его
там еще нет.
If to_node.NodeStatus = NOT_IN_LIST
Then
candidates.Add to_node
to_node.NodeStatus =
NOW_IN_LIST
End If
End If
Next link
Loop
' Пометить входящие
связи, чтобы их было проще вывести.
For Each node In Nodes
If Not (node.InLink Is Nothing) Then _
node.InLink.InPathTree = True
Next node
' Перерисовать сеть.
DrawNetwork
End Sub
В отличие от алгоритма установки меток, этот алгоритм не
может работать с сетями, которые содержат циклы с отрицательной ценой. Если
встречается такой цикл, то алгоритм бесконечно перемещается по связям внутри
него. При каждом обходе цикла расстояние до входящих в него узлов уменьшается,
при этом алгоритм снова помещает узлы в список возможных узлов, и снова может
проверять их в дальнейшем. При следующей проверке этих узлов, расстояние до них
также уменьшится, и так далее. Этот процесс будет продолжаться до тех пор, пока
расстояние до этих узлов не достигнет нижнего граничного значения -32.768, если
длина пути задана целым числом. Если известно, что в сети имеются циклы с
отрицательной ценой, то проще всего просто использовать для работы с ней метод
установки, а не коррекции меток.
Программа PathC использует этот алгоритм коррекции меток для вычисления
кратчайшего маршрута. Она аналогична программе PathS, но использует метод коррекции, а не
установки меток.
=======333
Варианты метода коррекции меток
Алгоритм коррекции меток позволяет очень быстро выбрать узел
из списка возможных узлов. Он также может вставить узел в список всего за один
или два шага. Недостаток этого алгоритма заключается в том, что когда он
выбирает узел из списка возможных узлов, он может сделать не слишком хороший
выбор. Если алгоритм выбирает узел до того, как его поля Dist и InLink получат
свои конечный значения, он должен позднее скорректировать значения этих полей и
снова поместить узел в список возможных узлов. Чем чаще алгоритм помещает узлы
назад в список возможных узлов, тем больше времени это занимает.
Варианты этого алгоритма пытаются повысить качество выбора
узлов без большого усложнения алгоритма. Один из методов, который неплохо
работает на практике, состоит в том, чтобы добавлять узлы одновременно в начало
и конец списка возможных узлов. Если узел раньше не попадал в список возможных
узлов, алгоритм, как обычно, добавляет его в конец списка. Если узел уже был
раньше в списке возможных узлов, но сейчас его там нет, алгоритм вставляет его
в начало списка. При этом повторное обращение к узлу выполняется практически
сразу, возможно при следующем же обращении к списку.
Идея, заключенная в таком подходе, состоит в том, чтобы если
алгоритм совершает ошибку, она исправлялась как можно быстрее. Если ошибка не
будет исправлена в течение достаточно долгого времени, алгоритм может
использовать неправильную информацию для построения длинных ложных путей,
которые затем придется исправлять. Благодаря быстрому исправлению ошибок,
алгоритм может уменьшить число неверных путей, которые придется перестроить. В
наилучшем случае, если все соседние узлы все еще находятся в списке возможных
узлов, повторная проверка этого узла до проверки соседей предотвратит
построение неверных путей.
Другие задачи поиска кратчайшего маршрута
Описанные выше алгоритмы поиска кратчайшего маршрута
вычисляли все кратчайшие пути из корневого узла до всех остальных узлов в сети.
Существует множество других типов задачи нахождения кратчайшего маршрута. В
этом разделе обсуждаются три из них: XE "Кратчайший маршрут:двухточечный"
двухточечный кратчайший маршрут[RV21]
(point‑to‑point shortest path
XE "point‑to‑point shortest path" XE "Кратчайший маршрут:для всех пар"
all pairs shortest path)
и XE "Кратчайший маршрут:со штрафами за повороты"
Двухточечный кратчайший маршрут
XE "Кратчайший маршрут:двухточечный"
Другой способ заключается в использовании метода установки
меток, который останавливался бы, когда будет найден путь к конечному узлу.
Алгоритм установки меток добавляет к дереву кратчайшего маршрута только те
пути, которые обязательно должны в нем находиться, следовательно, в тот момент,
когда алгоритм добавит конечный узел в дерево, будет найден искомый кратчайший
маршрут. В алгоритме, который обсуждался раньше, это происходит, когда алгоритм
удаляет конечный узел из списка возможных узлов.
=======334
Единственное изменение требуется внести в часть алгоритма
установки меток, которая выполняется сразу же после того, как алгоритм находит
в списке возможных узлов узел с наименьшим значением Dist. Перед удалением узла из списка возможных
узлов, алгоритм должен проверить, не является ли этот узел искомым. Если это
так, то дерево кратчайшего маршрута уже содержит кратчайший маршрут между
начальным и конечным узлами, и алгоритм может закончить работу.
' Найти ближайший к корню узел в списке возможных узлов.
:
' Проверить, является ли этот узел искомым.
If node =
destination Then Exit Do
' Добавить этот узел в дерево кратчайшего маршрута.
:
На практике, если две точки в сети расположены далеко друг
от друга, то этот алгоритм обычно будет выполняться дольше, чем займет
вычисление полного дерева кратчайшего маршрута. Алгоритм выполняется медленнее
из‑за того, что в каждом цикле выполнения алгоритма проверяется,
достигнут ли искомый узел. С другой стороны, если узлы расположены рядом, то
выполнение этого алгоритма может потребовать намного меньше времени, чем
построение полного дерева кратчайшего маршрута.
Для некоторых сетей, таких как сеть улиц, можно оценить,
насколько близко расположены две точки, и затем решить, какую версию алгоритма
выбрать. Если сеть содержит все улицы южной Калифорнии, и две точки расположены
на расстоянии 10 миль, следует использовать версию, которая останавливается
после того, как найдет конечный узел. Если же точки удалены друг от друга на
100 миль, возможно, меньше времени займет вычисление полного дерева кратчайшего
маршрута.
Вычисление кратчайшего маршрута для всех пар
XE "Кратчайший маршрут:для всех пар"
N2 возможных путей,
может быть быстрее вычислить все возможные пути вместо того, чтобы находить
только те, которые нужны.
Можно записать кратчайшие маршруты, используя два двумерных
массива, Dist
и InLinks.
В ячейке Dist(I, J) находится кратчайший маршрут из узла I в узел J, а в ячейке InLinks(I, J) — связь, которая ведет к узлу J в
кратчайшем пути из узла I
в узел J.
Эти значения аналогичны значениям Dist
и InLink
в классе узла в предыдущем алгоритме.
Один из способов найти все кратчайшие маршруты заключается в
том, чтобы построить деревья кратчайшего маршрута с корнем в каждом из узлов
сети при помощи одного из предыдущих алгоритмов, и затем сохранить результаты в
массивах Dists
и InLinks.
========335
Другой метод вычисления всех кратчайших маршрутов
последовательно строит пути, используя все больше и больше узлов. Вначале
алгоритм находит все кратчайшие маршруты, которые используют только первый узел
и узлы на концах пути. Другими словами, для узлов J и K алгоритм находит кратчайший маршрут между
этими узлами, который использует только узел с номером 1 и узлы J и K, если такой путь
существует
Затем алгоритм находит все кратчайшие маршруты, которые
используют только два первых узла. Затем он строит пути, используя первые три
узла, первые четыре узла, и так далее до тех пор, пока не будут построены все
кратчайшие маршруты, используя все узлы. В этот момент, поскольку кратчайшие
маршруты могут использовать любой узел, алгоритм найдет все кратчайшие маршруты
в сети.
Заметьте, что кратчайший маршрут между узлами J и K, использующий только первые I узлов, включает узел I, только если Dist(J, K) > Dist(J, I) + Dist(I, K).
Иначе кратчайшим маршрутом будет предыдущий кратчайший маршрут, который
использовал только первые I - 1
узлов. Это означает, что когда алгоритм рассматривает узел I, требуется
только проверить выполнение условия Dist(J, K) > Dist(J, I) + Dist(I, K).
Если это условие выполняется, алгоритм обновляет кратчайший маршрут из узла J в узел K. Иначе старый
кратчайший маршрут между этими двумя узлами остался бы таковым.
Штрафы за повороты
XE "Кратчайший маршрут:со штрафами за
повороты" turn penalties
XE "turn penalties"
Небольшое число штрафов за повороты
Часто важны только некоторые штрафы за повороты. Может
понадобиться предотвратить выполнение запрещенных или невозможных поворотов и
присвоить штрафы за повороты лишь на нескольких ключевых перекрестках, не
определяя штрафы для всех перекрестков в сети. В этом случае можно разбить
каждый узел, для которого заданы штрафы, на несколько узлов, которые будут
неявно учитывать штрафы.
Предположим, что требуется добавить один штраф за поворот на
перекрестке налево и другой штраф за поворот направо. На рис. 12.12 показан
перекресток, на котором требуется применить эти штрафы. Число рядом с каждой
связью соответствует ее цене. Требуется применить штрафы за вход в узел A по связи L1, и затем выход из него по
связям L2
или L3.
Для применения штрафов к узлу A, разобьем этот узел на два узла, по
одному для каждой из покидающих его связей. В данном примере, из узла A выходят две связи, поэтому
узел A разбивается на
два узла A1
и A2, и
связи, выходящие из узла A,
заменяются соответствующими связями, выходящими из полученных узлов. Можно
представить, что каждый из двух образовавшихся узлов соответствует входу в узел
A и повороту в сторону
соответствующей связи.
======336
@Рис. 12.12. Перекресток
Затем связь L1,
входящая в узел A,
заменяется на две связи, входящие в каждый из двух узлов A1 и A2. Цена этих связей равна
цене исходной связи L1
плюс штрафу за поворот в соответствующем направлении. На рис. 12.13 показан
перекресток, на котором введены штрафы за поворот. На этом рисунке штраф за
поворот налево из узла A
равен 5, а за поворот направо —2.
Помещая информацию о штрафах непосредственно в конфигурацию
сети, мы избегаем необходимости модифицировать алгоритмы поиска кратчайшего
маршрута. Эти алгоритмы будут находить правильные кратчайшие маршруты с учетом
штрафов за повороты.
При этом придется все же слегка изменить программы, чтобы
учесть разбиение узлов на несколько частей. Предположим, что требуется найти
кратчайший маршрут между узлами I
и J, но узел I оказался разбит на
несколько узлов. Полагая, что можно покинуть узел I по любой связи, можно создать ложный
узел и использовать его в качестве корня дерева кратчайшего маршрута. Соединим
этот узел связями с нулевой ценой с каждым из узлов, получившихся после
разбиения узла I.
Тогда, если построить дерево кратчайшего маршрута с корнем в ложном узле, то
при этом будут найдены все кратчайшие маршруты, содержащие любой из этих узлов.
На рис. 12.14 показан перекресток с рис. 12.13, связанный с ложным корневым
узлом.
@Рис. 12.13. Перекресток со штрафами за повороты
=======337
@Рис. 12.14. Перекресток, связанный с ложным корнем
Обрабатывать случай поиска пути к узлу, который был разбит
на несколько узлов, проще. Если требуется найти кратчайший маршрут между узлами
I и J, и узел J был разбит на несколько узлов, то
вначале, как обычно, нужно найти дерево кратчайшего маршрута с корнем в узле I. Затем проверяются все
узлы, на которые был разбит узел J и находится ближайший из них к корню дерева. Путь к этому узлу
и есть кратчайший маршрут к исходному узлу J.
Большое число штрафов за повороты
Предыдущий метод будет не слишком эффективным, если вы
хотите ввести штрафы за повороты для большинства узлов в сети. Лучше будет
создать совершенно новую сеть, которая будет включать информацию о штрафах.
·
A и B в исходной сети в новой сети создается узел AB;
·
A и B, а другая — узлы B и C.
Тогда в новой сети нужно создать связь, соединяющую узел AB с узлом BC;
·
AB и узлом BC будет равна цене связи,
соединяющей узлы B и C в исходной сети плюс штрафу
за поворот при движении из узла A
в узел B и затем в узел
C.
На рис. 12.15 изображена небольшая сеть и соответствующая
новая сеть, представляющая штрафы за повороты. Штраф за поворот налево равен 3,
за поворот направо — 2, а за «поворот» прямо — нулю. Например, так
как поворот из узла B в
узел E — это левый
поворот в исходной сети, штраф для связи между узлами BE и EF в новой сети равен 3. Цена связи, соединяющей узлы E и F в исходной сети, равна 3, поэтому
полная цена новой связи равна 3 + 3 = 6.
=======338
@Рис. 12.15. Сеть и соответствующая ей сеть со штрафами за
повороты
Предположим теперь, что требуется найти для исходной сети
дерево кратчайшего маршрута с корнем в узле D. Чтобы сделать это, создадим в новой сети ложный корневой узел,
затем построим связи, соединяющие этот узел со всеми связями, которые покидают
узел D в исходной сети.
Присвоим этим связям ту же цену, которую имеют соответствующие связи в исходной
сети. На рис. 12.16 показана новая сеть с рис. 12.15 с ложным корневым узлом,
соответствующим узлу D.
Дерево кратчайшего маршрута в этой сети нарисовано жирной линией.
Чтобы найти кратчайший маршрут из узла D в узел C, необходимо проверить все узлы в новой
сети, которые соответствуют связям, заканчивающимся в узле C. В этом примере это узлы BC и FC. Ближайший к ложному корню узел
соответствует кратчайшему маршруту к узлу C в исходной сети. Узлы в кратчайшем маршруте в новой сети
соответствуют связям в кратчайшем маршруте в исходной сети.
@Рис. 12.16. Дерево кратчайшего маршрута в сети со штрафами
за повороты
========339
На рис. 12.16 кратчайший маршрут начинается с ложного корня,
идет в узел DE, затем
узлы EF и FC и имеет полную цену 16.
Этот путь соответствует пути D,
E, F, C в исходной сети. Прибавив один штраф за левый поворот E, F, C, получим, что цена этого пути в исходной сети также равна 16.
Заметьте, что вы не нашли бы этот путь, если бы построили
дерево кратчайшего маршрута в исходной сети. Без учета штрафов за повороты,
кратчайшим маршрутом из узла D
в узел C был бы путь D, E, B, C с
полной ценой 12. С учетом штрафов цена этого пути равна 17.
Применения метода поиска кратчайшего маршрута
Вычисления кратчайшего маршрута используются во многих
приложениях. Очевидным примером является поиск кратчайшего маршрута между двумя
точками в уличной сети. Многие другие приложения используют метод поиска
кратчайшего маршрута менее очевидными способами. Следующие разделы описывают
некоторые из этих приложений.
Разбиение на районы
Предположим, что имеется карта города, на которую нанесены
все пожарные депо. Может потребоваться определить для каждой точки города
ближайшее к ней депо. На первый взгляд это кажется трудной задачей. Можно
попытаться рассчитать дерево кратчайшего маршрута с корнем в каждом узле сети,
чтобы найти, какое депо расположено ближе всего к каждому из узлов. Или можно
построить дерево кратчайшего маршрута с корнем в каждом из пожарных депо и
записать расстояние от каждого из узлов до каждого из депо. Но существует
намного более быстрый метод.
Создадим ложный корневой узел и соединим его с каждым из
пожарных депо связями с нулевой ценой. Затем найдем дерево кратчайшего маршрута
с корнем в этом ложном узле. Для каждой точки в сети кратчайший маршрут из ложного
корневого узла к этой точке пройдет через ближайшее к этой точке пожарное депо.
Чтобы найти ближайшее к точке пожарное депо, нужно просто проследовать по
кратчайшему маршруту от этой точки к корню, пока на пути не встретится одно из
депо. Построив всего одно дерево кратчайшего маршрута, можно найти ближайшие
пожарные депо для каждой точки в сети.
Программа District
использует этот алгоритм для разбиения сети на районы. Так же, как и программа PathC и другие
программы, описанные в этой главе, она позволяет загружать, редактировать и
сохранять на диске ориентированные сети с ценой связей. Если вы не добавляете и
не удаляете узлы или связи, вы можете выбрать депо для разделения на районы.
Добавьте узлы к списку пожарных депо щелчком левой кнопки мыши, затем щелкните
правой кнопкой в любом месте формы, и программа разобьет сеть на районы.
На рис. 12.17 показано окно программы, на котором изображена
сеть с тремя депо. Депо в узлах 3, 18 и 20 обведены жирными кружочками.
Разбивающие сеть на районы деревья кратчайшего маршрута изображены жирными
линиями.
=====340
@Рис. 12.17. Программа District
Составление плана работ с использованием метода критического пути
Во многих задачах, в том числе в больших программных
проектах, определенные действия должны быть выполнены раньше других. Например,
при строительстве дома до установки фундамента нужно вырыть котлован, фундамент
должен застыть до того, как начнется возведение стен, каркас дома должен быть
собран прежде, чем можно будет выполнять проводку электричества, водопровода и
кровельные работы и так далее.
Некоторые из этих задач могут выполняться одновременно,
другие должны выполняться последовательно. Например, можно одновременно
проводить электричество и прокладывать водопровод.
XE
"Сеть:критический путь"Критическим путем (critical path
XE "critical path"
Вначале создадим сеть, которая представляет временные
соотношения между задачами проекта. Пусть каждой задаче соответствует узел.
Нарисуем связь между задачей I
и задачей J, если
задача I должна быть
выполнена до начала задачи J,
и присвоим этой связи цену, равную времени выполнения задачи I.
После этого создадим два ложных узла, один из которых будет
соответствовать началу проекта, а другой — его завершению. Соединим
начальный узел связями с нулевой ценой со всеми узлами в проекте, в которые не
входит ни одна другая связь. Эти узлы соответствуют задачам, выполнение которых
можно начинать немедленно, не ожидая завершения других задач.
Затем создадим ложные связи нулевой длины, соединяющие все
узлы, из которых не выходит не одной связи, с конечным узлом. Эти узлы
представляют задачи, которые не тормозят выполнение других задач. После того,
как все эти задачи будут выполнены, проект будет завершен.
Найдя самый длинный маршрут между начальным и конечным
узлами сети, мы получим критический путь проекта. Входящие в него задачи будут
критичными для выполнения проекта.
========341
@Таблица 12.1. Этапы сборки дождевальной установки
Рассмотрим, например, упрощенный проект сборки дождевальной
установки, состоящий из пяти задач. В табл. 12.1 приведены задачи и временные
соотношения между ними. Сеть для этого проекта показана на рис. 12.18.
В этом простом примере легко увидеть, что самый длинный
маршрут в сети выполняет следующую последовательность задач: выкопать канавы,
смонтировать трубы, закопать их. Это критические задачи, и если в выполнении
какой‑либо из них наступит задержка, выполнение проекта также задержится.
Длина этого критического пути равна ожидаемому времени
завершения проекта. В данном случае, если все задачи будут выполнены вовремя,
выполнение проекта займет пять дней. При этом предполагается также, что если
это возможно, несколько задач будут выполняться одновременно. Например, один
человек может копать канавы, пока другой будет закупать трубы.
В более значительном проекте, таком как строительство
небоскреба или съемка фильма, могут содержаться тысячи задач, и критические
пути при этом могут быть совсем не очевидны.
Планирование коллективной работы
Предположим, что требуется набрать несколько сотрудников для
ответов на телефонные звонки, при этом каждый из них будет занят не весь день.
При этом нужно, чтобы суммарная зарплата была наименьшей, и нанятый коллектив
сотрудников отвечал на звонки с 9 утра до 5 вечера. В табл. 12.2 приведены
рабочие часы сотрудников, и их почасовая оплата.
@Рис. 12.18. Сеть задач сборки дождевальной установки
======342
@Таблица 12.2. Рабочие часы сотрудников и их почасовая
оплата
Для построения соответствующей сети, создадим один узел для
каждого рабочего часа. Соединим эти узлы связями, каждая из которых
соответствует рабочим часам какого‑либо сотрудника. Если сотрудник может
работать с 9 до 11, нарисуем связь между узлом 9:00 и узлом 11:00, и присвоим
этой связи цену, равную зарплате, получаемой данным сотрудником за
соответствующее время. Если сотрудник получает 6,5 долларов в час, и отрезок
времени составляет два часа, то цена связи равна 13 долларам. На рис. 12.19
показана сеть, соответствующая данным из табл. 12.2.
Кратчайший маршрут из первого узла в последний позволяет
набрать коллектив сотрудников с наименьшей суммарной зарплатой. Каждая связь в
пути соответствует работе сотрудника в определенный промежуток времени. В
данном случае кратчайший маршрут из узла 9:00 в узел 5:00 проходит через узлы
11:00, 12:00 и 3:00. Этому соответствует следующий график работы: сотрудник A работает с 9:00 до 11:00,
сотрудник D работает с
11:00 до 12:00, затем сотрудник A
снова работает с 12:00 до 3:00 и сотрудник E работает с 3:00 до 5:00. Полная зарплата всех сотрудников при
таком графике составляет 52,15 доллара.
@Рис. 12.19. Сеть графика работы коллектива
======343
Максимальный поток
Во многих сетях связи имеют кроме цены, еще и XE "Сеть:пропускная способность" пропускную способность (capacity XE "network:capacity" XE "Сеть:поток" поток ( XE "network:flow" flow), который не превышает
ее пропускной способности. Например, по улицам может проехать только
определенной число машин. Сеть с заданными пропускными способностями ее связей
называется XE "Сеть:нагруженная" нагруженной сетью [RV22] (capacitated network
XE "network:capacitated"
XE "Сеть:источник" источника (source
XE "source" XE "Сеть:сток" сток (sink
XE "sink"
На рис. 12.20 показана небольшая нагруженная сеть. Числа
рядом со связями в этой сети — это не цена связи, а ее пропускная
способность. В этом примере максимальный поток, равный 4, получается, если две
единицы потока направляются по пути A, B, E,F и еще две — по пути A, C, D, F.
Описанный здесь алгоритм начинается с того, что поток во
всех связях равен нулю и затем алгоритм постепенно увеличивает поток, пытаясь
улучшить найденное решение. Алгоритм завершает работу, если нельзя улучшить
имеющееся решение.
Для поиска путей способов увеличения полного потока,
алгоритм проверяет XE "Сеть:остаточная пропускная
способность" остаточную
пропускную способность (residual capacity
XE "residual capacity"
I и J равна максимальному дополнительному потоку, который можно
направить из узла I в
узел J, используя связь
между I и J и связь между J и I. Этот суммарный поток может включать
дополнительный поток по связи I‑J, если в этой связи есть
резерв пропускной способности, или исключать часть потока из связи J‑I, если по этой связи идет поток.
Например, предположим, что в сети, соединяющей узлы A и C на рис. 12.20, существует поток,
равный 2. Так как пропускная способность этой связи равна 3, то к этой связи
можно добавить единицу потока, поэтому остаточная пропускная способность этой
связи равна 1. Хотя сеть, показанная на рис. 12.20 не имеет связи C‑A, для этой связи существует остаточная
пропускная способность. В данном примере, так как по связи A‑C идет поток, равный 2, то можно удалить
до двух единиц этого потока. При этом суммарный поток из узла C в узел A увеличился бы на 2, поэтому остаточная
пропускная способность связи C‑A равна 2.
@Рис. 12.20. Нагруженная сеть
========344
@Рис. 12.21. Потоки в сети
Сеть, состоящая из всех связей с положительной остаточной
пропускной способностью, называется XE "Сеть:остаточная" остаточной сетью (residual network
XE "network:residual"
На рис. 12.22 показана остаточная сеть, соответствующая
потокам на рис. 12.21. Нарисованы только связи, которые действительно могут
иметь остаточную пропускную способность. Например, между узлами A и D не нарисовано ни одной связи. Исходная
сеть не содержит связи A‑D или D‑A, поэтому эти связи всегда будут иметь
нулевую остаточную пропускную способность.
Одно из свойств остаточных сетей состоит в том, что любой путь,
использующий связи с остаточной пропускной способностью больше нуля, который
связывает источник со стоком, дает способ увеличения потока в сети. Так как
этот путь дает способ увеличения или расширения потока в сети, он называется XE "Сеть:расширяющий путь" расширяющим путем (augmenting path
XE "augmenting path"
Чтобы обновить решение, используя расширяющий путь, найдем
наименьшую остаточную пропускную способность в пути. Затем скорректируем потоки
в пути в соответствии с этим значением. Например, на рис. 12.23 наименьшая
остаточная пропускная способность сетей в расширяющем пути равна 2. Чтобы
обновить потоки в сети, к любой связи I‑J
на пути добавляется поток 2, а из всех обратных им связей J‑I вычитается поток 2.
@Рис. 12.22. Остаточная сеть
========345
@Рис. 12.23. Расширяющий путь через остаточную сеть
Вместо того, чтобы корректировать потоки, и затем
перестраивать остаточную сеть, проще просто скорректировать остаточную сеть.
Затем после завершения работы алгоритма можно использовать результат для
вычисления потоков для связей в исходной сети.
Чтобы скорректировать остаточную сеть в этом примере,
проследуем по расширяющему пути. Вычтем 2 из остаточной пропускной способности
всех связей I‑J вдоль пути, и добавим 2 к
остаточной пропускной способности соответствующей связи J‑I. На рис. 12.24 показана
скорректированная остаточная сеть для этого примера.
Если больше нельзя найти ни одного расширяющего пути, то
можно использовать остаточную сеть для вычисления потоков в исходной сети. Для
каждой связи между узлами I
и J, если остаточный
поток между узлами I и J меньше, чем пропускная
способность связи, то поток должен равняться пропускной способности минус
остаточный поток. В противном случае поток должен быть равен нулю.
Например, на рис. 12.24 остаточный поток из узла A в узел C равен 1 и пропускная способность связи
A‑C равна 3. Так как 1 меньше
3, то поток через узел будет равен 3 - 1 = 2. На рис. 12.25 показаны потоки в
сети, соответствующие остаточной сети на рис. 12.24.
@Рис. 12.24. Скорректированная остаточная сеть
========346
@Рис. 12.25. Максимальные потоки
Полученный алгоритм еще не содержит метода для поиска
расширяющих путей в остаточной сети. Один из возможных методов аналогичен
методу коррекции меток для алгоритма кратчайшего маршрута. Вначале поместим
узел‑источник в список возможных узлов. Затем, если список возможных
узлов не пуст, будем удалять из него по одному узлу. Проверим все соседние
узлы, соединенные с выбранным узлом по связи, остаточная пропускная способность
которой больше нуля. Если соседний узел еще не был помещен в список возможных
узлов, добавить его в список. Продолжить этот процесс до тех пор, пока список
возможных узлов не опустеет.
Этот метод имеет два отличия от метода поиска кратчайшего
маршрута коррекцией меток. Во‑первых, этот метод не прослеживает связи с
нулевой остаточной пропускной способностью. Алгоритм же кратчайшего маршрута
проверяет все пути, независимо от их цены.
Во‑вторых, этот алгоритм проверяет все узлы не больше
одного раза. Алгоритм поиска кратчайшего маршрута коррекцией меток, будет
обновлять узлы и помещать их снова в список возможных узлов, если он позднее
найдет более короткий путь от корня к этому узлу. При поиске расширяющего пути
нет необходимости проверять его длину, поэтому не нужно обновлять пути и
помещать узлы назад в список возможных узлов.
Следующий код демонстрирует, как можно вычислять
максимальные потоки в программе на Visual Basic. Этот код предназначен для работы с неориентированными
сетями, похожими на те, которые использовались в других программах примеров,
описанных в этой главе. После завершения работы алгоритма он присваивает связи
цену, равную потоку через нее, взятому со знаком минус, если поток течет в
обратном направлении. Другими словами, если сеть содержит объект,
представляющий связь I‑J, а алгоритм определяет, что
поток должен течь в направлении связи J‑I,
то потоку через связь I‑J присваивается значение,
равное потоку, который должен был бы течь через связь J‑I, взятому со знаком минус. Это
позволяет программе определять направление потока, используя существующую
структуру узлов.
=======347
Private Sub
FindMaxFlows()
Dim
candidates As Collection
Dim
Residual() As Integer
Dim num_nodes
As Integer
Dim id1 As
Integer
Dim id2 As
Integer
Dim node As
FlowNode
Dim to_node
As FlowNode
Dim from_node
As FlowNode
Dim link As
FlowLink
Dim
min_residual As Integer
If SourceNode Is Nothing Or SinkNode
Is Nothing _
Then Exit Sub
' Задать размер
массива остаточной пропускной способности.
num_nodes = Nodes.Count
ReDim Residual(1 To num_nodes, 1 To
num_nodes)
' Первоначально значения остаточной
пропускной способности
' равны значениям
пропускной способности.
For Each node In Nodes
id1 = node.Id
For Each link In node.Links
If link.Node1 Is node Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
id2 = to_node.Id
Residual(id1, id2) = link.Capacity
Next link
Next node
' Повторять до тех пор, пока больше
' не найдется
расширяющих путей.
Do
' Найти
расширяющий путь в остаточной сети.
' Сбросить
значения NodeStatus и InLink всех узлов.
For Each node In Nodes
node.NodeStatus = NOT_IN_LIST
Set node.InLink = Nothing
Next node
' Начать с
пустого списка возможных узлов.
Set candidates = New Collection
' Поместить источник в список
возможных узлов.
candidates.Add SourceNode
SourceNode.NodeStatus = NOW_IN_LIST
' Продолжать, пока список
возможных узлов не опустеет.
Do While candidates.Count > 0
Set node = candidates(1)
candidates.Remove 1
node.NodeStatus = WAS_IN_LIST
id1 = node.Id
' Проверить
выходящие из узла связи.
For Each link In node.Links
If link.Node1 Is node Then
Set to_node = link.Node2
Else
Set to_node = link.Node1
End If
id2 = to_node.Id
' Проверить, что residual
> 0, и этот узел
' никогда
не был в списке.
If Residual(id1, id2) > 0 And _
to_node.NodeStatus =
NOT_IN_LIST _
Then
' Добавить узел в список.
candidates.Add to_node
to_node.NodeStatus =
NOW_IN_LIST
Set to_node.InLink = link
End If
Next link
' Остановиться, если помечен
узел‑сток.
If Not (SinkNode.InLink Is Nothing)
Then _
Exit Do
Loop
' Остановиться,
если расширяющий путь не найден.
If SinkNode.InLink Is Nothing Then Exit Do
' Найти наименьшую остаточную
пропускную способность
' вдоль
расширяющего пути.
min_residual = INFINITY
Set node = SinkNode
Do
If node Is SourceNode Then Exit Do
id2 = node.Id
Set link = node.InLink
If link.Node1 Is node Then
Set from_node = link.Node2
Else
Set from_node = link.Node1
End If
id1 = from_node.Id
If min_residual > Residual(id1,
id2) Then _
min_residual = Residual(id1, id2)
Set node = from_node
Loop
' Обновить
остаточные пропускные способности,
' используя
расширяющий путь.
Set node = SinkNode
Do
If node Is SourceNode Then Exit Do
id2 = node.Id
Set link = node.InLink
If link.Node1 Is node Then
Set from_node = link.Node2
Else
Set from_node = link.Node1
End If
id1 = from_node.Id
Residual(id1, id2) = Residual(id1,
id2) _
- min_residual
Residual(id2, id1) = Residual(id2,
id1) _
+ min_residual
Set node = from_node
Loop
Loop ' Повторять,
пока больше не останется расширяющих путей.
' Вычислить потоки в
остаточной сети.
For Each link In Links
id1 = link.Node1.Id
id2 = link.Node2.Id
If link.Capacity > Residual(id1, id2)
Then
link.Flow = link.Capacity -
Residual(id1, id2)
Else
' Отрицательные
значения соответствуют
' обратному
направлению движения.
link.Flow = Residual(id2, id1) -
link.Capacity
End If
Next link
' Найти полный поток.
TotalFlow = 0
For Each link In SourceNode.Links
TotalFlow = TotalFlow + Abs(link.Flow)
Next link
End Sub
=======348-350
Программа Flow
использует метод поиска расширяющего пути для нахождения максимального потока в
сети. Она похожа на остальные программы в этой главе. Если вы не добавляете или
не удаляете узел или связь, вы можете выбрать источник при помощи левой кнопки
мыши, а затем выбрать сток при помощи правой кнопки мыши. После выбора
источника и стока программа вычисляет и выводит на экран максимальный поток. На
рис. 12.26 показано окно программы, на котором изображены потоки в небольшой
сети.
Приложения максимального потока
Вычисления максимального потока используются во многих
приложениях. Хотя для многих сетей может быть важно знать максимальный поток,
этот метод часто используется для получения результатов, которые на первый
взгляд имеют отдаленное отношение к пропускной способности сети.
Непересекающиеся пути
Большие сети связи должны обладать XE "Сеть:избыточность" избыточностью (redundancy
XE "redundancy"
Можно определить число различных путей, используя метод
вычисления максимального потока. Создадим сеть с узлами и связями,
соответствующими узлам и связям в коммуникационной сети. Присвоим каждой связи
единичную пропускную способность.
@Рис. 12.26. Программа Flow
=====351
@Рис. 12.27. Сеть коммуникаций
Затем вычислим максимальный поток в сети. Максимальный поток
будет равен числу различных путей от источника к стоку. Так как каждая связь
может нести единичный поток, то ни один из путей, использованных при вычислении
максимального потока, не может иметь общей связи.
При более строгом определении избыточности можно
потребовать, чтобы различные пути не имели ни общих связей, ни общих узлов.
Немного изменив предыдущую сеть, можно использовать вычисление максимального
потока для решения и этой задачи.
Разделим каждый узел за исключением источника и стока на два
узла, соединенных связью единичной пропускной способности. Соединим первый из
полученных узлов со всеми связями, входящими в исходный узел. Все связи,
выходящие из исходного узла, присоединим ко второму полученному после разбиения
узлу. На рис. 12.28 показана сеть с рис. 12.27, узлы на которой разбиты таким
образом. Теперь найдем максимальный поток для этой сети.
Если путь, использованный для вычисления максимального
потока, проходит через узел, то он может использовать связь, которая соединяет
два получившихся после разбиения узла. Так как эта связь имеет единичную
пропускную способность, никакие два пути, полученные при вычислении максимального
потока, не могут пройти по этой связи между узлами, поэтому в исходной сети
никакие два пути не могут использовать один и тот же узел.
@Рис. 12.28. Коммуникационная сеть после преобразования
======352
@Рис. 12.29. Сеть распределения работы
Распределение работы
Предположим, что имеется группа сотрудников, каждый из
которых обладает определенными навыками. Предположим также, что существует ряд
заданий, которые требуют привлечения сотрудника, обладающего заданным набором
навыков. XE "Задача:распределения работы" work assignment
XE "work assignment"
Чтобы свести эту задачу к вычислению максимального потока,
создадим сеть с двумя столбцами узлов. Каждый узел в левом столбце представляет
одного сотрудника. Каждый узел в правом столбце представляет одно задание.
Затем сравним навыки каждого сотрудника с навыками,
необходимыми для выполнения каждого из заданий. Создадим связь между каждым
сотрудником и каждым заданием, которое он способен выполнить, и присвоим всем
связям единичную пропускную способность.
Создадим узел‑источник и соединим его с каждым из
сотрудников связью единичной пропускной способности. Затем создадим узел‑сток
и соединим с ним каждое задание, снова при помощи связей с единичной пропускной
способностью. На рис. 12.29 показана соответствующая сеть для задачи
распределения работы с четырьмя сотрудниками и четырьмя заданиями.
Теперь найдем максимальный поток из источника в сток. Каждая
единица потока должна пройти через один узел сотрудника и один узел задания.
Этот поток представляет распределение работы для этого сотрудника.
@Рис. 12.30. Программа Work
=======353
Если сотрудники обладают соответствующими навыками для
выполнения всех заданий, то вычисления максимального потока распределят их все.
Если невозможно выполнить все задания, то в процессе вычисления максимального
потока работа будет распределена так, чтобы было выполнено максимально
возможное число заданий.
Программа Work
использует этот алгоритм для распределения работы между сотрудниками. Введите
фамилии сотрудников и их навыки в текстовом поле слева, а задания, которые
требуется выполнить и требующиеся для них навыки в текстовом поле посередине.
После того, как вы нажмете на кнопку Go (Начать), программа
распределит работу между сотрудниками, используя для этого сеть максимального
потока. На рис. 12.30 показано окно программы с полученным распределением
работы.
Резюме
Некоторые сетевые алгоритмы можно применить непосредственно
к сетеподобным объектам. Например, можно использовать алгоритм поиска
кратчайшего маршрута для нахождения наилучшего пути в уличной сети. Для
определения наименьшей стоимости построения сети связи или соединения городов
железными дорогами можно использовать минимальное остовное дерево.
Многие другие сетевые алгоритм находят менее очевидные
применения. Например, можно использовать алгоритмы поиска кратчайшего маршрута
для разбиения на районы, составления плана работ методом кратчайшего пути, или
графика коллективной работы. Алгоритмы вычисления максимального потока можно
использовать для распределения работы. Эти менее очевидные применения сетевых
алгоритмов обычно оказываются более интересными и перспективными.
======354
Глава 13. Объектно‑ориентированные методы
Использование функций и подпрограмм позволяет программисту
разбить код большой программы на части. Массивы и определенные пользователем
типы данных позволяют сгруппировать элементы данных так, чтобы упросить работу
с ними.
Классы, которые впервые появились в 4-й версии Visual Basic, позволяют программисту
по‑новому сгруппировать данные и логику работы программы. Класс позволяет
объединить в одном объекте данные и методы работы с ними. Этот новый подход к
управлению сложностью программ позволяет взглянуть на алгоритмы с другой точки
зрения.
В этой главе рассматриваются вопросы объектно‑ориентированного
программирования, возникающие при применении классов Visual Basic. В ней описаны преимущества
объектно‑ориентированного программирования (ООП) и показано, какую выгоду
можно получить от их применения в программах на языке Visual Basic. Затем в главе
рассматривается набор полезных объектно‑ориентированных примеров, которые
вы можете использовать для управления сложностью ваших приложений.
Преимущества ООП
К традиционным преимуществам объектно‑ориентированного
программирования относятся инкапсуляция или скрытие (encapsulation
XE "encapsulation"
polymorphism
XE "polymorphism"
reuse
XE "reuse" Visual Basic
несколько отличается от того, как они реализованы в других объектно‑ориентированных
языках. В следующих разделах рассматриваются эти преимущества ООП и то, как
можно ими воспользоваться в программах на Visual Basic.
Инкапсуляция
XE "Инкапсуляция" public) процедуры,
функции, и процедуры изменения свойств, которые позволяют программе косвенно
манипулировать или просматривать данные. Так как при этом данные являются абстрактными
с точки зрения программы, это также называется XE "Абстракция данных" абстракцией данных (data abstraction
XE "data abstraction"
Инкапсуляция позволяет программе использовать объекты как
«черные ящики». Программа может использовать открытые методы объекта для
проверки и изменения значений без необходимости разбираться в том, что
происходит внутри черного ящика.
=========355
Поскольку действия внутри объектов скрыты от основной
программы, реализация объекта может меняться без изменения основной программы.
Изменения в свойствах объекта происходят только в модуле класса.
Например, предположим, что имеется класс FileDownload,
который скачивает файлы из Internet.
Программа сообщает классу FileDownload
положение объекта, а объект возвращает строку с содержимым файла. В этом случае
программе не требуется знать, каким образом объект производит загрузку файла.
Он может скачивать файл, используя модемное соединение или соединение по
выделенной линии, или даже извлекать файл из кэша на локальном диске. Программа
знает только, что объект возвращает строку после того, как ему передается
ссылка на файл.
Обеспечение инкапсуляции
Для обеспечения инкапсуляции класс должен предотвращать
непосредственный доступ к своим данным. Если переменная в классе объявлена как
открытая, то другие части программы смогут напрямую изменять и считывать данные
из нее. Если позднее представление данных изменится, то любые части программы,
которые непосредственно взаимодействуют с данными, также должны будут
измениться. При этом теряется преимущество инкапсуляции.
Чтобы обеспечить доступ к данным, класс должен использовать
процедуры для работы со свойствами. Например, следующие процедуры позволяют
другим частям программы просматривать и изменять значение DegreesF объекта Temperature.
Private
m_DegreesF As Single ' Градусы Фаренгейта.
Public
Property Get DegreesF() As Single
DegreesF = m_DegreesF
End Property
Public
Property Let DegreesF(new_DegreesF As Single)
m_DegreesF = new_DegreesF
End Property
Различия между этими процедурами и определением m_DegreesF как
открытой переменной пока невелики. Тем не менее, использование этих процедур
позволяет легко изменять класс в дальнейшем. Например, предположим, что вы
решите измерять температуру в градусах Кельвина, а не Фаренгейта. При этом
можно изменить класс, не затрагивая остальных частей программы, в которых
используются процедуры свойства DegreesF.
Можно также добавить код для проверки ошибок, чтобы убедиться, что программа не
попытается передать объекту недопустимые значения.
Private
m_DegreesK As Single ' Градусы Кельвина.
Public
Property Get DegreesF() As Single
DegreesF = (m_DegreesK - 273.15) * 1.8
End Property
Public
Property Let DegreesF(ByVal new_DegreesF As Single)
Dim new_value
As Single
new_value = (new_DegreesF / 1.8) + 273.15
If new_value < 0 Then
' Сообщить об ошибке ‑
недопустимое значении.
Error.Raise 380, "Temperature", _
"Температура должна быть
неотрицательной."
Else
m_DegreesK = new_value
End If
End Property
======357
Программы, описанные в этом материале, безобразно нарушают
принцип инкапсуляции, используя в классах открытые переменные. Это не слишком
хороший стиль программирования, но так сделано по трем причинами.
Во‑первых, непосредственное изменение значений данных
выполняется быстрее, чем вызов процедур свойств. Большинство программ уже и так
несколько теряют в производительности из‑за использования ссылок на
объекты вместо применения более сложного метода псевдоуказателей. Применения
процедур свойств еще сильнее замедлит их работу.
Во‑вторых, многие программы демонстрируют методы
работы со структурами данных. Например, сетевые алгоритмы, описанные в 12
главе, непосредственно используют данные объекта. Указатели, которые связывают
узлы в сети друг с другом, составляют неотъемлемую часть алгоритмов. Было бы
бессмысленно менять способ хранения этих указателей.
И, наконец, благодаря использованию открытых значений
данных, код становится проще. Это позволяет вам сконцентрироваться на
алгоритмах, и этому не мешают лишние процедуры работы со свойствами.
Полиморфизм
Второе преимущество объектно‑ориентированного
программирования — это XE
"Полиморфизм" полиморфизм (polymorphism
XE "polymorphism"
Visual Basic это означает, что один объект может иметь различный формы в
зависимости от ситуации. Например, следующий код представляет собой
подпрограмму, которая может принимать в качестве параметра любой объект. Объект
obj может быть
формой, элементом управления, или объектом определенного вами класса.
Private Sub
ShowName(obj As Object)
MsgBox TypeName(obj)
End Sub
Полиморфизм позволяет создавать процедуры, которые могут
работать буквально со всеми типами объектов. Но за эту гибкость приходится
платить. Если определить обобщенный (generic) объект, как в этом примере, то Visual Basic не сможет определить,
какие типы действий сможет выполнять объект, до запуска программы.
========357
Если Visual Basic
заранее знает, с объектом какого типа он будет иметь дело, он может выполнить
предварительные действия для того, чтобы более эффективно использовать объект.
Если используется обобщенный (generic
XE "generic"
Программа Generic
демонстрирует разницу в производительности между объявлением объектов как
принадлежащих к определенному типу или как обобщенных объектов. Тест
выполняется одинаково, за исключением того, что в одном из случаев объект
определяется, как имеющий тип Object,
а не тип SpecificClass.
При этом установка значения данных объекта с использованием обобщенного объекта
выполняется в 200 раз медленнее.
Private Sub
TestSpecific()
Const REPS = 1000000 '
Выполнить миллион повторений.
Dim obj As
SpecificClass
Dim i As Long
Dim
start_time As Single
Dim stop_time
As Single
Set obj = New SpecificClass
start_time = Timer
For i = 1 To REPS
obj.Value = I
Next i
stop_time = Timer
SpecificLabel.Caption = _
Format$(1000 * (stop_time - start_time)
/ REPS, "0.0000")
End Sub
Зарезервированное слово Implements
В 5‑й версии Visual Basic зарезервированное слово Implements
XE "implements"
(Реализует) позволяет программе использовать
полиморфизм без использования обобщенных объектов. Например, программа может
определить интерфейс Vehicle
(Средство передвижения), Если классы Car (Автомобиль) и Truck (Грузовик) оба реализуют интерфейс Vehicle, то
программа может использовать для выполнения функций интерфейса Vehicle объекты
любого из двух классов.
Создадим вначале класс интерфейса, в котором определим открытые
переменные, которые он будет поддерживать. В нем также должны быть определены
прототипы открытых процедур для всех методов, которые он будет поддерживать.
Например, следующий код демонстрирует, как класс Vehicle может определить переменную Speed (Скорость) и
метод Drive
(Вести машину):
Public Speed
Long
Public Sub
Drive()
End Sub
=======358
Теперь создадим класс, который реализует интерфейс. После
оператора Option Explicit в секции Declares
добавляется оператор Implements
определяющий имя класса интерфейса. Этот класс должен также определять все
необходимые для работы локальные переменные.
Класс Car
реализует интерфейс Vehicle.
Следующий код демонстрирует, как в нем определяется интерфейс и закрытая (private)
переменная m_Speed:
Option
Explicit
Implements
Vehicle
Private
m_Speed As Long
Когда к классу добавляется оператор Implements, Visual Basic считывает интерфейс,
определенный указанным классом, а затем создает соответствующие заглушки в коде
класса. В этом примере Visual Basic
добавит новую секцию Vehicle
в исходный код класса Car,
и определит процедуры let
и get
свойства Vehicle_Speed для
представления переменной Speed,
определенной в интерфейсе Vehicle.
В процедуре let Visual Basic
использует переменную RHS,
которая является сокращением от Right Hand Side (С правой
стороны), в которой задается новое значение переменной.
Также определяется процедура Vehicle_Drive. Чтобы реализовать функции этих
процедур, нужно написать код для них. Следующий код демонстрирует, как класс Car может
определять процедуры Speed
и Drive.
Private
Property Let Vehicle_Speed(ByVal RHS As Long)
m_Speed = RHS
End Property
Private
Property Get Vehicle_Speed() As Long
Vehicle_Speed = m_Speed
End Property
Private Sub
Get Vehicle_Drive()
' Выполнить какие‑то действия.
:
End Property
После того, как интерфейс определен и реализован в одном или
нескольких классах, программа может полиморфно использовать элементы в этих
классах. Например, допустим, что программа определила классы Car и Track, которые оба
реализуют интерфейс Vehicle.
Следующий код демонстрирует, как программа может проинициализировать значения
переменной Speed
для объекта Car
и объекта Truck.
Dim obj As
Vehicle
Set obj = New Car
obj.Speed = 55
Set obj = New Truck
obj .Speed =45
==========359
Ссылка obj
может указывать либо на объект Car,
либо на объект Truck.
Так как в обоих этих объектах реализован интерфейс Vehicle, то программа может оперировать
свойством obj.Speed независимо
от того, указывает ли ссылка obj
на Car
или Truck.
Так как ссылка obj указывает на объект, который реализует интерфейс Vehicle, то Visual Basic знает, что этот объект
имеет процедуры, работающие со свойством Speed. Это означает, что он может выполнять
вызовы процедур свойства Speed
более эффективно, чем это было бы в случае, если бы obj была ссылкой на обобщенный объект.
Программа Implem
является доработанной версией программы описанной выше программы Generic. Она
сравнивает скорость установки значений с использованием обобщенных объектов,
определенных объектов и объектов, которые реализуют интерфейс. В одном из
тестов на компьютере с процессором Pentium с тактовой частотой 166 МГц, программе потребовалось
0,0007 секунды для установки значений при использовании определенного типа
объекта. Для установки значений при использовании объекта, реализующего
интерфейс, потребовалось 0,0028 секунды (в 4 раза больше). Для установки
значений при использовании обобщенного объекта потребовалось 0,0508 секунды (в
72 раза больше). Использование интерфейса является не таким быстрым, как использование
ссылки на определенный объект, но намного быстрее, чем использование обобщенных
объектов.
Наследование и повторное использование
Процедуры и функции поддерживают XE "Повторное использование" повторное использование (reuse XE "reuse"
Аналогично, определение процедуры в классе делает ее
доступной во всей программе. Программа может использовать эту процедуру,
используя объект, который является экземпляром класса.
В среде программистов, использующих объектно‑ориентированный
подход, под повторным использованием обычно подразумевается нечто большее, а
именно XE "Наследование" наследование (inheritance
XE "inheritance"
C++ или Delphi, один класс может порождать (derive) другой. При этом второй класс наследует (inherits) всю функциональность первого
класса. После этого можно добавлять, изменять или убирать какие‑либо
функции из класса‑наследника. Это также является формой повторного
использования кода, поскольку при этом программисту не нужно заново реализовать
функции родительского класса, для того, чтобы использовать их в классе‑наследнике.
Хотя Visual Basic
и не поддерживает наследование непосредственно, можно добиться примерно тех же
результатов, используя XE "Ограничение" ограничение (containment) или XE "Делегирование" делегирование (delegation XE "delegation" ).[RP23]
При делегировании объект из одного класса содержит экземпляр класса из другого
объекта, и затем передает часть своих обязанностей заключенному в нем объекту.
Например, предположим, что имеется класс Employee, который
представляет данные о сотрудниках, такие как фамилия, идентификационный номер в
системе социального страхования и зарплата. Предположим, что нам теперь нужен
класс Manager,
который делает то же самое, что и класс Employee, но имеет еще одно свойство secretary
(секретарь).
Для использования делегирования, класс Manager должен
включать в себя закрытый объект типа Employee с именем m_Employee. Вместо прямого вычисления значений,
процедуры работы со свойствами фамилии, номера социального страхования и
зарплаты передают соответствующие вызовы объекту m_Employee. Следующий код демонстрирует, как
класс Manager
может оперировать процедурами свойства name (фамилия):
==========360
Private
m_Employee As New Employee
Property Get
Name() As String
Name = m_Employee.Name
End Property
Property Let
Name (New_Name As String)
m_Employee.Name = New_Name
End Property
Класс Manager
также может изменять результат, возвращаемый делегированной функцией, или
выдавать результат сама. Например, в следующем коде показано, как класс Employee
возвращает строку текста с данными о сотруднике.
Public
Function TextValues() As String
Dim txt As
String
txt = m_Name & vbCrLf
txt = txt & " " & m_SSN & vbCrLf
txt = txt & " " & Format$(m_Salary,
"Currency") & vbCrLf
TextValues = txt
End Function
Класс Manager
использует функцию TextValues
объекта Employee,
но добавляет перед возвратом информацию о секретаре в строку результата.
Public
Function TextValues() As String
Dim txt As
String
txt = m_Employee.TextValues
txt = txt & " " & m_Secretary & vbCrLf
TextValues = txt
End Function
Программа Inherit
демонстрирует классы Employee
и Manager.
Интерфейс программы не представляет интереса, но ее код включает простые
определения классов Employee
и Manager.
Парадигмы ООП
В первой главе мы дали определение алгоритма как
«последовательности инструкций для выполнения какого‑либо задания».
Несомненно, класс может использовать алгоритмы в своих процедурах и функциях.
Например, можно использовать класс для упаковки в него алгоритма. Некоторые из
программ, описанных в предыдущих главах, используют классы для инкапсуляции
сложных алгоритмов.
=========361
Классы также позволяют использовать новый стиль
программирования, при котором несколько объектов могут работать совместно для
выполнения задачи. В этом случае может быть бессмысленным задание
последовательности инструкций для выполнения задачи. Более адекватным может
быть задание модели поведения объектов, чем сведение задачи к
последовательности шагов. Для того чтобы отличать такое поведение от
традиционных алгоритмов, мы назовем их «парадигмами».
Следующие раздела описывают некоторые полезные объектно‑ориентированные
парадигмы. Многие из них ведут начало из других объектно‑ориентированных
языков, таких как C++
или Smalltalk, хотя они
могут также использоваться в Visual Basic.
Управляющие объекты
XE "Объект:управляющий" Управляющие объекты (command
XE "command" action objects), функций (function objects) или функторами (functors
XE "functors"
Execute
(Выполнить) для выполнения объектом этого действия. Программе не нужно знать
ничего об этом действии, она знает только, что объект имеет метод Execute.
Управляющие объекты могут иметь множество интересных
применений. Программа может использовать управляющий объект для реализации:
·
·
·
·
Чтобы создать настраиваемый интерфейс, форма может содержать
управляющий массив кнопок. Во время выполнения программы форма может загрузить
надписи на кнопках и создать соответствующий набор управляющих объектов. Когда
пользователь нажимает на кнопку, обработчику событий кнопки нужно всего лишь
вызвать метод Execute
соответствующего управляющего объекта. Детали происходящего находятся внутри
класса управляющего объекта, а не в обработчике событий.
Программа Command1
использует управляющие объекты для создания настраиваемого интерфейса для
нескольких не связанных между собой функций. При нажатии на кнопку программа
вызывает метод Execute
соответствующего управляющего объекта.
Программа может использовать управляющие объекты для
создания определенных пользователем макрокоманд. Пользователь задает
последовательность действий, которые программа запоминает в коллекции в виде
управляющих объектов. Когда затем пользователь вызывает макрокоманду, программа
вызывает методы Execute
объектов, которые находятся в коллекции.
Управляющие объекты могут обеспечивать ведение и
восстановление записей. Управляющий объект может при каждом своем вызове
записывать информацию о себе в лог‑файл. Если программа аварийно завершит
работы, она может затем использовать записанную информацию для восстановления
управляющих объектов и выполнения их для повторения последовательности команд,
которая выполнялась до сбоя программы.
И, наконец, программа может использовать набор управляющих
объектов для реализации функций отмены (undo) и повтора (redo).
=========362
Программа использует переменную LastCmd для отслеживания последнего управляющего
объекта в коллекции. Если вы выбираете команду Undo
(Отменить) в меню Draw (Рисовать), то программа
уменьшает значение переменной LastCmd
на единицу. Когда программа потом выводит рисунок, она вызывает только объекты,
стоящие до объекта с номером LastCmd.
Если вы выбираете команду Redo
(Повторить) в меню Draw, то программа увеличивает
значение переменной LastCmd
на единицу. Когда программа выводит рисунок, она выводит на один объект больше,
чем раньше, поэтому отображается восстановленный рисунок.
При добавлении новой фигуры программа удаляет любые команды
из коллекции, которые лежат после позиции LastCmd,. затем добавляет новую команду
рисования в конце и запрещает команду Redo, так как нет команд,
которые можно было бы отменить. На рис. 13.1 показано окно программы Command2
после добавления новой фигуры.
Контролирующий объект
XE "Объект:контролирующий" Контролирующий объект (visitor object
XE "visitor object"
XE "Объект:составной" составном объекте (aggregate object
XE "aggregate object"
Например, предположим, что составной объект хранит элементы
в связном списке. Следующий код показывает, как его метод Visit обходит
список, передавая каждый объект в качестве параметра методу Visit
контролирующего объекта ListVisitor:
Public Sub
Visit(obj As ListVisitor)
Dim cell As
ListCell
Set cell = TopCell
Do While Not (cell Is Nothing)
obj.Visit cell
Set cell = cell.NextCell
Loop
End Sub
@Рис. 13.1. Программа Command2
=========363
Следующий код демонстрирует, как класс ListVisitor может
выводить на экран значения элементов в окне Immediate
(Срочно).
Public Sub
Visit(cell As ListCell)
Debug.Print cell.Value
End Sub
Используя парадигму контролирующего объекта, составной класс
определяет порядок, в котором обходятся элементы. Составной класс может
определять несколько методов для обхода содержащих его элементов. Например,
класс дерева может обеспечивать методы VisitPreorder (Прямой обход), VisitPostorder
(Обратный обход), VisitInorder
(Симметричный обход) и VisitBreadthFirst
(Обход в глубину) для обхода элементов в различном порядке.
Итератор
XE "Объект:итератор" Итератор обеспечивает другой метод
обхода элементов в составном объекте. Объект‑итератор обращается к
составному объекту для обхода его элементов, и в этом случае итератор
определяет порядок, в котором проверяются элементы. С составным классом могут
быть сопоставлены несколько классов итераторов для того, чтобы выполнять
различные обходы элементов составного класса.
Чтобы выполнить обход элементов, итератор должен
представлять порядок, в котором элементы записаны, чтобы определить порядок их
обхода. Если составной класс представляет собой связный список, то объект‑итератор
должен знать, что элементы находятся в связном списке, и должен уметь
перемещаться по списку. Так как итератору известны детали внутреннего
устройства списка, это нарушает скрытие данных составного объекта.
Вместо того чтобы каждый класс, которому нужно проверять
элементы составного класса, реализовал обход самостоятельно, можно сопоставить
составному классу класс итератора. Класс итератора должен содержать простые
процедуры MoveFirst
(Переместиться в начало), MoveNext
(Переместиться на следующий элемент), EndOfList (Переместиться в конец списка) и CurrentItem
(Текущий элемент) для обеспечения косвенного доступа к списку. Новые классы
могут включать в себя экземпляр класса итератора и использовать его методы для
обхода элементов составного класса. На рис. 13.2 схематически показано, как
новый объект использует объект‑итератор для связи со списком.
Программа IterTree,
описанная ниже, использует итераторы для обхода полного двоичного дерева. Класс
Traverser
(Обходчик) содержит ссылку на объект‑итератор. Они использует
обеспечиваемые итератором процедуры MoveFirst,
MoveNext, CurrentCaption и EndOfTree для
получения списка узлов в дереве.
@Рис. 13.2. Использование итератора для косвенной связи со
списком
=========364
Итераторы нарушают скрытие соответствующих им составных
объектов, в отличие от новых классов, которые содержат итераторы. Для того,
чтобы избавиться от потенциальной путаницы, можно рассматривать итератор как
надстройку над составным объектом.
Контролирующие объекты и итераторы обеспечивают выполнение
похожих функций, используя различные подходы. Так как парадигма контролирующего
объекта оставляет детали составного объекта скрытыми внутри него, она
обеспечивает лучшую инкапсуляцию. Итераторы могут быть полезны, если порядок
обхода может часто изменяться или он должен переопределяться во время
выполнения программы. Например, составной объект может использовать методы
порождающего класса (который описан позднее) для создания объекта‑итератора
в процессе выполнения программы. Содержащий итератор класс не должен знать, как
создается итератор, он всего лишь использует методы итератора для доступа к
элементам составного объекта.
Дружественный класс
Многие классы тесно работают с другими. Например, класс
итератора тесно взаимодействует с составным классом. Для выполнения работы,
итератор должен нарушать скрытие составного класса. При этом, хотя эти
связанные классы иногда должны нарушать скрытие данных друг друга, другие
классы должны не иметь такой возможности.
XE " Дружественный класс" friend class
XE "friend class"
В 5‑й версии Visual Basic появилось зарезервированное слово Friend для
разрешения ограниченного доступа к переменным и процедурам, определенным внутри
модуля. Элементы, определенные при помощи зарезервированного слова Friend, доступны
внутри проекта, но не в других проектах. Например, предположим, что вы создали
классы LinkedList
(Связный список) и ListIterator
(Итератор списка) в проекте ActiveX
сервера. Программа может создать сервер связного списка для управления связными
списками. Порождающий метод класса LinkedList
может создавать объекты типа ListIterator
для использования в программе.
Класс LinkedList
может обеспечивать в программе средства для работы со связными списками. Этот
класс объявляет свои свойства и методы открытыми, чтобы их можно было
использовать в основной программе. Класс ListIterator позволяет программе выполнять
итерации над объектами, которыми управляет класс LinkeList. Процедуры, используемые классом ListIterator для
оперирования объектами LinkedList,
объявляются как дружественные в модуле LinkedList. Если классы LinkedList и ListIterator
создаются в одном и том же проекте, то класс ListIterator может использовать эти дружественные
процедуры. Поскольку основная программа находится в другом проекте, она этого
сделать не может.
Этот очень эффективный, но довольно громоздкий метод. Она
требует создания двух проектов, и установки одного сервера ActiveX. Он также
не работает в более ранних версиях Visual Basic.
Наиболее простой альтернативой было бы соглашение о том, что
только дружественные классы могут нарушать скрытие данных друг друга. Если все
разработчики будут придерживаться этого правила, то проектом все еще можно
будет управлять. Тем не менее, искушение обратиться напрямую к данным класса LinkedList может
быть сильным, и всегда существует вероятность, что кто‑нибудь нарушит
скрытие данных из‑за лени или по неосторожности.
Другая возможность заключается в том, чтобы дружественный
объект передавал себя другому классу в качестве параметра. Передавая себя в
качестве параметра, дружественный класс тем самым показывает, что он является
таковым. Программа Fstacks
использует этот метод для реализации стеков.
=======365
При использовании этого метода все еще можно нарушить
скрытие данных объекта. Программа может создать объект дружественного класса и
использовать его в качестве параметра, чтобы обмануть процедуры другого
объекта. Тем не менее, это достаточно громоздкий процесс, и маловероятно, что
разработчик сделает так случайно.
Интерфейс
В этой парадигме один из объектов выступает в качестве XE "Объект:интерфейс" интерфейса (interface
XE "interface"
adapter), упаковщиком (wrapper), или мостом (bridge). На рис. 13.3
схематически изображена работа интерфейса.
Интерфейс позволяет двум объектам на его концах изменяться
независимо. Например, если свойства объекта слева на рис. 13.3 изменятся,
интерфейс должен быть изменен, а объект справа — нет.
В этой парадигме процедуры, используемые двумя объектами,
поддерживаются разработчиками, которые отвечают за эти объекты. Разработчик,
который реализует левый объект, также занимается реализацией процедур
интерфейса, которые взаимодействуют с левым объектом.
Фасад
XE "Объект:фасад" Фасад (Facade
XE "facade" wrapper). На рис. 13.4.
показана схема работы фасада.
Разница между фасадом и интерфейсом в основном
умозрительная. Основная задача интерфейса — обеспечение косвенного
взаимодействия между объектами, чтобы они могли развиваться независимо.
Основная задача фасада — облегчение использования каких‑то сложных
вещей за счет скрытия деталей.
Порождающий объект
XE "Объект:порождающий" Порождающий объект (Factory
XE "factory" — это объект, который
создает другие объекты. Порождающий метод — это процедура или функция,
которая создает объект.
Порождающие объекты наиболее полезны, если два класса должны
тесно работать вместе. Например, составной класс может содержать порождающий
метод, который создает итераторы для него. Порождающий метод может
инициализировать итератор таким образом, чтобы он был готов к работе с
экземпляром класса, который его создал.
@Рис. 13.3 Интерфейс
========366
@Рис. 13.4. Фасад
Программа IterTree
создает полное двоичное дерево, записанное в массиве. После нажатия на одну из
кнопок, задающих направление обхода, программа создает объект Traverser
(Обходчик). Она также использует один из порождающих методов дерева для
создания соответствующего итератора. Объект Traverser использует итератор для обхода
дерева и вывода списка узлов в правильном порядке. На рис. 13.5 приведено окно
программы IterTree,
показывающее обратный обход дерева.
Единственный объект
XE "Объект:единственный" Единственный объект (singleton object
XE "singleton object"
Visual Basic
определен класс Printer
(Принтер). Он также определяет единственный объект с тем же названием. Этот
объект представляет принтер, выбранный в системе по умолчанию. Так как в каждый
момент времени может быть выбран только один принтер, то имеет смысл определить
объект Printer
как единственный объект.
Один из способов создания единственного объекта заключается
в использовании процедуры, работающей со свойствами в модуле BAS. Эта процедура
возвращает ссылку на объект, определенный внутри модуля как закрытый. Для
других частей программы эта процедура выглядит как просто еще один объект.
@Рис. 13.5. Программа IterTree, демонстрирующая обратный обход
=======367
Программа WinList
использует этот подход для создания единственного объекта класса WinListerClass.
Объект класса WinListerClass
представляет окна в системе. Так как операционная система одна, то нужен только
один объект класса WinListerClass.
Модуль WinList.BAS использует
следующий код для создания единственного объекта с названием WindowLister.
Private
m_WindowLister As New WindowListerClass
Property Get
WindowLister() As WindowListerClass
Set WindowLister = m_WindowLister
End Property
Единственный объект WindowLister доступен во всем проекте.
Следующий код демонстрирует, как основная программа использует свойство WindowList этого
объекта для вывода на экран списка окон.
WindowListText.Text
= WindowLister.WindowList
Преобразование в последовательную форму
Многие приложения сохраняют объекты и восстанавливают их
позднее. Например, приложение может сохранять копию своих объектов в текстовом
файле. При следующем запуске программы, она считывает это файл и загружает
объекты.
Объект может содержать процедуры, которые считывают и
записывают его в файл. Общий подход может заключаться в том, чтобы создать
процедуры, которые сохраняют и восстанавливают данные объекта, используя
строку. Поскольку запись данных объекта в одной строке преобразует объект в
последовательность символов, этот процесс иногда называется XE "Объект:преобразование в
последовательную форму" преобразованием в последовательную форму (serialization
XE "serialization"
Преобразование объекта в строку обеспечивает большую
гибкость основной программы. При этом она может сохранять и считывать объекты,
используя текстовые файлы, базу данных или область памяти. Она может переслать
представленный таким образом объект по сети или сделать его доступным на Web‑странице. Программа
или элемент ActiveX
на другом конце может использовать преобразование объекта в строку для
воссоздания объекта. Программа также может дополнительно обработать строку,
например, зашифровать ее после преобразования объекта в строку и расшифровать перед
обратным преобразованием.
Один из подходов к преобразованию объекта в последовательную
форму заключается в том, чтобы объект записал все свои данные в строку
заданного формата. Например, предположим, что класс Rectangle (Прямоугольник) имеет свойства X1, Y1, X2 и Y2.
Следующий код демонстрирует, как класс может определять процедуры свойства Serialization:
Property Get
Serialization() As String
Serialization = _
Format$(X1) & ";" &
Format$(Y1) & ";" & _
Format$(X2) & ";" &
Format$(Y2) & ";"
End Property
Property Let
Serialization(txt As String)
Dim pos1 As
Integer
Dim pos2 As
Integer
pos1 =
InStr(txt, ";")
X1 =
CSng(Left$(txt, pos1 - 1))
pos2 =
InStr(pos1 + 1, txt, ";")
Y1 =
CSng(Mid$(txt, pos1 + 1, pos2 – pos1 - 1))
pos1 =
InStr(pos2 + 1, txt, ";")
X2 =
CSng(Mid$(txt, pos2 + 1, pos1 - pos2 - 1))
pos2 =
InStr(pos1 + 1, txt, ";")
Y2 =
CSng(Mid$(txt, pos1 + 1, pos2 – pos1 - 1))
End Property
Этот метод довольно простой, но не очень гибкий. По мере
развития программы, изменения в структуре объектов могут заставить вас
перетранслировать все сохраненные ранее преобразованные в последовательную
форму объекты. Если они находятся в файлах или базах данных, для загрузки
старых данных и записи их в новом формате может потребоваться написание
программ‑конверторов.
Более гибкий подход заключается в том, чтобы сохранять
вместе со значениями элементов данных объекта их имена. Когда объект считывает
данные, преобразованные в последовательную форму, он использует имена элементов
для определения значений, который необходимо установить. Если позднее в
определение элемента будут добавлены какие‑либо элементы, или удалены из
него, то не придется преобразовывать старые данные. Если новый объект загрузит
старые данные, то он просто проигнорирует не поддерживаемые более значения.
Определяя значения данных по умолчанию, иногда можно
уменьшить размер преобразованных в последовательную форму объектов. Процедура get свойства Serialization
сохраняет только значения, которые отличаются от значений по умолчанию. Перед
тем, как процедура let
свойства начнет выполнение преобразования в последовательную форму, она
инициализирует все элементы объекта значениями по умолчанию. Значения, не
равные значениям по умолчанию, обновляются по мере обработки данных процедурой.
Программа Shapes
использует этот подход для сохранения и загрузки с диска рисунков, содержащих
эллипсы, линии, и прямоугольники. Объект ShapePicture представляет весь рисунок
целиком. Он содержит коллекцию управляющих объектов, которые представляют
различные фигуры.
Следующий код демонстрирует процедуры свойства Serialization
объекта ShapePicture.
Объект ShapePicture
сохраняет имя типа для каждого из типов объектов, а затем в скобках —
представление объекта в последовательной форме.
Property Get
Serialization() As String
Dim txt As
String
Dim i As
Integer
For i = 1 To LastCmd
txt = txt & _
TypeName(CmdObjects(i)) &
"(" & _
CmdObjects(i).Serialization &
")"
Next I
Serialization = txt
End Property
==========369
Процедура let
свойства Serialization
использует подпрограмму GetSerialization
для чтения имени объекта и списка данных в скобках. Например, если объект ShapePicture
содержит команду рисования прямоугольника, то его представление в
последовательной форме будет включать строку “RectangleCMD”, за которой будут следовать данные,
представленные в последовательной форме.
Процедура использует подпрограмму CommandFactory для создания объекта
соответствующего типа, а затем заставляет новый объект преобразовать себя из
последовательной формы представления.
Property Let
Serialization(txt As String) Dim pos As Integer Dim token_name As String Dim
token_value As String Dim and As Object
' Start a new
picture.
NewPicture
' Read values
until there are no more.
GetSerialization
txt, pos, token_name, token_value Do While token_name <> ""
' Make the
object and make it unserialize itself.
Set and =
ConiniandFactory(token_name)
If Not (and
Is Nothing) Then _
and.Serialization
= token_value
GetSerialization
txt, pos, token_name, tokerL-value Loop
LastCmd =
CmdObjects.Count End Property
Парадигма Модель/Вид/Контроллер.
Парадигма Модель/Вид/Контроллер XE "Модель/Вид/Контроллер" (МВК) (Model/View/Controller
XE "Model/View/Controller"
Для сложных систем управление взаимодействием между
объектами, которые хранят, отображают и оперируют данными, может быть
достаточно запутанным. Парадигма Модель/Вид/Контроллер разбивает
взаимодействия, так что можно работать с ними по отдельности, при этом
используются три типа объектов: модели, виды, и контроллеры.
Модели
XE "Объект:модель" Модель (Model
XE "model"
Модель включает в себя набор видов, которые отображают
данные. При изменении данных, модель сообщает об этом видам, которые изменяют
изображение на экране соответствующим образом.
Виды
XE "Объект:вид" Вид (View XE "view"
Когда программа создает вид, она должна добавить его к
набору видов модели.
Контроллеры
XE "Объект:контроллер" Контроллер (Controller
XE "controller"
Виды/Контроллеры
Многие объекты одновременно отображают и изменяют данные.
Например, текстовое поле позволяет пользователю вводить и просматривать данные.
Форма, содержащая текстовое поле, является одновременно и видом, и
контроллером. Переключатели, поля выбора опций, полосы прокрутки, и многие
другие элементы пользовательского интерфейса позволяют одновременно
просматривать и оперировать данными.
Видами/контроллерами проще всего управлять, если попытаться
максимально разделить функции просмотра и управления. Когда объект изменяет
данные, он не должен сам обновлять изображение на экране. Он может сделать это
позднее, когда модель сообщит ему как виду о произошедшем изменении.
Эти методы достаточно громоздки для реализации стандартных
объектов пользовательского интерфейса, таких как текстовые поля. Когда
пользователь вводит значение в текстовом поле, оно немедленно обновляется, и
выполнятся его обработчик события Change.
Этот обработчик событий может модель об изменении. Модель затем сообщает
виду/контроллеру (выступающему теперь как вид) о произошедшем изменении. Если
при этом объект обновит текстовое поле, то произойдет еще одно событие Change, о котором
снова будет сообщено модели и программа войдет в бесконечный цикл.
Чтобы предотвратить эту проблему, методы, изменяющие данные
в модели, должны иметь необязательный параметр, указывающий на контроллер,
который вызвал эти изменения. Если виду/контроллеру требуется сообщить об
изменении, которое он вызывает, он должен передать значение Nothing процедуре,
вносящей изменения. Если этого не требуется, то в качестве параметра объект
должен передавать себя.
=========371
@Рис. 13.6. Программа ExpMVC
Программа ExpMVC,
показанная на рис. 13.6, использует парадигму Модель/Вид/Контроллер для вывода
данных о расходах. На рисунке показаны три вида различных типов. Вид/контроллер
TableView
отображает данные в таблице, при этом можно изменять названия статей расходов и
их значения в соответствующих полях.
Вид/контроллер GraphView
отображает данные при помощи гистограммы, при этом можно изменять значения расходов,
двигая столбики при помощи мыши вправо.
Вид PieView
отображает секторную диаграмму. Это просто вид, поэтому его нельзя использовать
для изменения данных.
Резюме
Классы позволяют программистам на Visual Basic рассматривать старые
задачи с новой точки зрения. Вместо того чтобы представлять себе длинную
последовательность заданий, которая приводит к выполнению задачи, можно думать
о группе объектов, которые работают, совместно выполняя задачу. Если задача
правильно разбита на части, то каждый из классов по отдельности может быть
очень простым, хотя все вместе они могут выполнять очень сложную функцию.
Используя описанные в этой главе парадигмы, вы можете разбить классы так, чтобы
каждый из них оказался максимально простым.
==============372
Требования к аппаратному обеспечению
Для запуска и изменения примеров приложений вам понадобится
компьютер, который удовлетворяет требованиям Visual Basic к аппаратному обеспечению.
Алгоритм выполняются с различной скоростью на компьютерах
разных конфигураций. Компьютер с процессором Pentium Pro и 64 Мбайт памяти будет быстрее
компьютера с 386 процессором и 4 Мбайт памяти. Вы быстро узнаете ограничения
вашего оборудования.
Выполнение программ примеров
Один из наиболее полезных способов выполнения программ
примеров — запускать их при помощи встроенных средств отладки Visual Basic. Используя точки
останова, просмотр значений переменных и другие свойства отладчика, вы можете
наблюдать алгоритмы в действии. Это может быть особенно полезно для понимания
наиболее сложных алгоритмов, таких как алгоритмы работы со сбалансированными
деревьями и сетевые алгоритмы, представленные в 7 и 12 главах соответственно.
Некоторые и программ примеров создают файлы данных или
временные файлы. Эти программы помещают такие файлы в соответствующие
директории. Например, некоторые из программ сортировки, представленные в 9
главе, создают файлы данных в директории SrcCh9/. Все эти файлы имеют расширение “.DAT”,
поэтому вы можете найти и удалить их в случае необходимости.
Программы примеров предназначены только для демонстрационных
целей, чтобы помочь вам понять определенные концепции алгоритмов, и в них не
почти не реализована обработка ошибок или проверка данных. Если вы введете
неправильное решение, программа может аварийно завершить работу. Если вы не
знаете, какие данные допустимы, воспользуйтесь для получения инструкций меню Help (Помощь) программы.
========374
INDEX h "A" c "2" z
"1049"
A
addressing
indirect, 49
open, 314
adjacency matrix, 86
aggregate object, 382
ancestor, 139
array
irregular, 89
sparse, 92
triangular, 86
augmenting path, 363
B
B+Tree, 12
balanced profit, 222
base case, 101
best case, 27
binary hunt and search, 294
binary search, 286
branch, 139
branch‑and‑bound technique, 204
bubblesort, 254
bucketsort, 275
C
cells, 47
child, 139
circular referencing problem, 58
collision resolution policy, 299
command, 380
complexity theory, 17
controller, 391
countingsort, 273
critical path, 359
cycle, 331
D
data abstraction, 372
decision tree, 203
delegation, 378
descendant, 139
E
edge, 331
encapsulation, 371
exhaustive search, 204, 282
expected case, 27
F
facade, 386
factorial, 100
factory, 386
fake pointer, 32, 65
fat node, 12, 140
Fibonacci numbers, 105
firehouse problem, 239
First‑In‑First‑Out, 72
forward star, 12, 90, 143
friend class, 384
functors, 380
G
game tree, 204
garbage collection, 43
garbage value, 43
generic, 374
graph, 138, 331
greatest common divisor, 103
greedy algorithms, 339
H
Hamiltonian path, 237
hashing, 298
heap, 266
heapsort, 265
heuristic, 204
Hilbert curves, 108
hill‑climbing, 219
I
implements, 375
incremental improvements, 225
inheritance, 378
insertionsort, 251
interface, 385
interpolation search, 288
interpolative hunt and search, 295
K
knapsack problem, 212
L
label correcting, 342
label setting, 342
Last‑In‑First‑Out list, 69
least‑cost, 220
linear probing, 314
link, 331
list
circular, 56
doubly linked, 58
linked, 36
threaded, 61
unordered, 36, 43
M
mergesort, 263
minimal spanning tree, 338
minimax, 206
model, 391
Model/View/Controller, 390
Monte Carlo search, 223
N
network, 331
capacitated, 361
capacity, 361
connected, 332
directed, 331
flow, 361
residual, 362
node, 139, 331
degree, 139
internal, 139
sibling, 139
O
octtree, 172
optimum
global, 230
local, 230
P
page file, 30
parent, 139
partition problem, 236
path, 331
pointers, 32
point‑to‑point shortest path, 352
polymorphism, 371, 374
primary clustering, 317
priority queue, 268
probe sequence, 300
pruning, 212
pseudo‑random probing)., 324
Q
quadratic probing, 322
quadtree, 138, 165
queue, 72
circular, 75
multi-headed, 83
priority, 80
quicksort, 258
R
random search, 223
recursion
direct, 99
indirect, 25, 99
multiple, 24
tail recursion, 121
recursive procedure, 23
redundancy, 368
reference counter, 33
rehashing, 327
relatively prime, 103
residual capacity, 362
reuse, 371, 378
S
satisfiability problem, 235
secondary clustering, 324
selectionsort, 248
sentinel, 52
serialization, 388
shortest path, 342
Sierpinski curves, 112
simulated annealing, 231
singleton object, 387
sink, 361
source, 361
spanning tree, 336
stack, 69
subtree, 139
T
tail recursion removal, 121
thrashing, 31
thread, 61
traveling salesman problem, 238
traversal
breadth-first, 149
depth-first, 149
inorder, 148
postorder, 148
preorder, 148
tree, 138
AVL tree, 174
B+tree, 192
binary, 140
bottom-up B-trees, 192
B-tree, 187
complete, 147
depth, 140
left rotation, 177
left-right rotation, 178
right rotation, 176
right-left rotation, 178
symmetrically threaded, 160
ternary, 140
threaded, 138
top-down B-tree, 192
traversing, 148
tries, 138
turn penalties, 354
U
unsorting, 250
V
view, 391
virtual memory, 30
visitor object, 382
W
work assignment, 369
worst case, 27
А
Абстракция данных, 372
Адресация
косвенная, 49
открытая, 314
Алгоритм
поглощающий, 339
Г
Гамильтонов путь, 237
Граф, 138, 331
Д
Делегирование, 378
Деревья, 138
АВЛ-деревья, 174
Б+деревья, 12, 192, 193
Б-деревья, 187
ветвь, 139
внутренний узел, 139
восьмеричные, 172
вращения, 176
двоичные, 140
дочерний узел, 139
игры, 204
квадродеревья, 165
корень, 139
лист, 139
нисходящие Б-деревья, 192
обратный обход, 148
обход, 148
обход в глубину, 149
обход в ширину, 149
поддерево, 139
полные, 147
порядок, 139
потомок, 139
предок, 139
представление нумерацией связей, 12, 143
прямой обход, 148
решений, 203
родитель, 139
с полными узлами, 12
с симметричными ссылками, 160
симметричный обход, 148
троичные, 140
узел, 139
упорядоченные, 153
Дружественный класс, 384
З
Задача
коммивояжера, 238
о выполнимости, 235
о пожарных депо, 239
о разбиении, 236
поиска Гамильтонова пути, 237
распределения работы, 369
формирования портфеля, 212
Значение
"мусорное", 43
И
Инкапсуляция, 372
К
Ключи
объединение, 244
сжатие, 244
Коллекция, 37
Кратчайший маршрут
двухточечный, 352
дерево кратчайшего маршрута, 341
для всех пар, 352, 353
коррекция меток, 342, 348
со штрафами за повороты, 352, 354
установка меток, 342, 344
Кривые
Гильберта, 108
Серпинского, 112
М
Массив
нерегулярный, 89
представление в виде прямой звезды, 90
разреженный, 92
треугольный, 86
Матрица смежности, 86
Метод
ветвей и границ, 204, 212
восхождения на холм, 219
минимаксный, 206
Монте-Карло, 223
наименьшей стоимости, 220
отжига, 231
полного перебора, 204
последовательных приближений, 225
сбалансированной прибыли, 222
случайного поиска, 223
эвристический, 204
Модель/Вид/Контроллер, 390
Н
Наибольший общий делитель, 103
Наследование, 378
О
Объект
вид, 391
единственный, 387
интерфейс, 385
итератор, 383
контролирующий, 382
контроллер, 391
модель, 391
порождающий, 386
преобразование в последовательную форму, 388
составной, 382
управляющий, 380
фасад, 386
Ограничение, 378
Оптимум
глобальный, 230
локальный, 230
Очередь, 72
многопоточная, 83
приоритетная, 80, 268
циклическая, 75
П
Память
виртуальная, 30
пробуксовка, 31
чистка, 43
Пирамида, 265
Повторное использование, 378
Поиск
двоичный, 286
интерполяционный, 288
методом полного перебора, 282
следящий, 294
Полиморфизм, 374
Потоки, 61
Проблема циклических ссылок, 58
Процедура
очистки памяти, 45
рекурсивная, 23
Псевдоуказатели, 32, 65
Р
Разрешение конфликтов, 299
Рекурсия
восходящая, 175
косвенная, 25, 99
многократная, 24
прямая, 99
условие остановки, 101
хвостовая, 121
С
Сеть, 331
избыточность, 368
источник, 361
кратчайший маршрут, 341
критический путь, 359
нагруженная, 361
наименьшее остовное дерево, 338
ориентированная, 331
остаточная, 362
остаточная пропускная способность, 362
остовное дерево, 336
поток, 361
пропускная способность, 361
простой путь, 332
путь, 331
расширяющий путь, 363
ребро, 331
связная, 332
связь, 331
сток, 361
узел, 331
цена связи, 331
цикл, 331
Сигнальная метка, 52
Системный стек, 26
Случай
наилучший, 27
наихудший, 27
ожидаемый, 27
Сортировка
блочная, 275
быстрая, 258
вставкой, 251
выбором, 248
пирамидальная, 265
подсчетом, 273
пузырьковая, 254
рандомизация, 250
слиянием, 263
Список
двусвязный, 58
многопоточный, 61
неупорядоченный, 36, 43
первый вошел-первый вышел, 72
первый вошел-последний вышел, 69
связный, 36
циклический, 56
Стек, 69
Странный аттрактор, 170
Счетчик ссылок, 33
Т
Теория
сложности алгоритмов, 17
хаоса, 170
Тестовая последовательность
вторичная кластеризация, 324
квадратичная проверка, 321
линейная проверка, 314
первичная кластеризация, 317
псевдослучайная проверка, 324
У
Указатели, 32, 36
Ф
Файл подкачки, 30
Факториал, 100
Х
Хеширование, 298
блоки, 303
открытая адресация, 314
разрешение конфликтов, 299
рехеширование, 327
связывание, 300
тестовая последовательность, 300
хеш-таблица, 298
Ч
Числа
взаимно простые, 103
Фибоначчи, 105
Я
Ячейка, 47
PAGE
# "'Стр: '#'
'" Стр: 19
[RP1]Вариант – временная и
ёмкостная сложность
PAGE
# "'Page: '#'
'" Page: 31
[RP2]Вариант – перегрузкой
памяти.
PAGE
# "'Стр: '#'
'" Стр: 43
[RP3]Вероятно, жаргонизм,
может выбросить вообще?
PAGE
# "'Стр: '#'
'" Стр: 43
[RP4]Вариант: «сборка
мусора»
PAGE
# "'Стр: '#'
'" Стр: 44
[RP5]Исправлена опечатка в
книге – см. http://www.vb-helper.com/vbaupd.htm
PAGE
# "'Ñòð: '#'
'" Ñòð: 83
[RV6]Может есть более
удачный вариант термина?
PAGE
# "'Ñòð: '#'
'" Ñòð: 138
[RV7]Вариант: многопоточные
деревья.
PAGE
# "'Ñòð: '#'
'" Ñòð: 138
[RV8]Варианты:
TRIE-структуры, ТРАЙ-структуры
PAGE
# "'Ñòð: '#'
'" Ñòð: 138
[RV9]Варианты: деревья квадрантов,
Q-деревья.
PAGE
# "'Ñòð: '#'
'" Ñòð: 140
[RV10]Вариант: тернарными
PAGE
# "'Ñòð: '#'
'" Ñòð: 141
[RV11]Исправлена ошибка в
исходном листинге - Left заменено на Right
PAGE
# "'Стр: '#'
'" Стр: 165
[RP12]Варианты: деревья
квадратов, Q-деревья
PAGE
# "'Стр: '#'
'" Стр: 190
[RV13]Исправлена ошибка – в
оригинале буквы элементов не соответствуют рисунку.
PAGE
# "'Стр: '#'
'" Стр: 212
[RV14]Вариант: задача о
ранце
PAGE
# "'Стр: '#'
'" Стр: 214
[RV15]Исправлена смысловая
ошибка в оригинале — вместо узла B в нем написано узел C.
PAGE
# "'Стр: '#'
'" Стр: 300
[RV16]Варианты ‑
последовательностью проверок, последовательностью проб
PAGE
# "'Стр: '#'
'" Стр: 303
[RV17]Ошибка в оригинале -
на рисунке приведен скриншот другой программы, искомое значение не соответствует
тексту.
PAGE
# "'Стр: '#'
'" Стр: 304
[RV18]Ошибка в оригинале -
на рисунке приведен скриншот другой программы.
PAGE
# "'Стр: '#'
'" Стр: 314
[RV19]Возможно, имеется в
виду хеш‑адресация.
PAGE
# "'Стр: '#'
'" Стр: 339
[RV20]Вариант - «жадными»
алгоритмами.
PAGE
# "'Стр: '#'
'" Стр: 352
[RV21]Вариант: кратчайший
маршрут между двумя точками
PAGE
# "'Стр: '#'
'" Стр: 361
[RV22]Вариант: потоковой
сетью (flow network)
PAGE
# "'Стр: '#'
'" Стр: 378
[RP23]Не уверен в точности
терминов.
TOC o "1-3" Введение.................................................................................................. PAGEREF
_Toc3148911 h 8
Целевая аудитория.............................................................................. PAGEREF