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 главе показано, как добавление ссылок к связным спискам позволяет упростить вывод элементов в разном порядке. Вы можете использовать тот же подход для упрощения обращения к узлам дерева в различном порядке. Например, помещая ссылки в листья двоичного дерева, вы можете облегчить выполнение симметричного и обратного обходов. Для упорядоченного дерева, это обход в прямом и обратном порядке сортировки.
Для создания ссылок, указатели на предыдущий и следующий узлы в порядке симметричного обхода помещаются в неиспользуемых указателях на дочерние узлы. Если не используется указатель на левого потомка, то ссылка записывается на его место, указывая на предыдущий узел при симметричном обходе. Если не используется указатель на правого потомка, то ссылка записывается на его место, указывая на следующий узел при симметричном обходе. Поскольку ссылки симметричны, и ссылки левых потомков указывают на предыдущие, а правых — на следующие узлы, этот тип деревьев называется деревом с симметричными ссылками (symmetrically threaded tree). На рис. 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]Квадродеревья (quadtrees) описывают пространственные отношения между элементами на площади. Например, это может быть карта, а элементы могут представлять собой положение домов или предприятий на ней.
Каждый узел квадродерева представляет собой участок на площади, представленной квадродеревом. Каждый узел, кроме листьев, имеет четыре потомка, которые представляют четыре квадранта. Листья могут хранить свои элементы в коллекциях связных списков. Следующий код показывает секцию 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) число элементов, пока вы не определите, насколько быстро ваш компьютер может создавать элементы.
Интересно наблюдать квадродеревья, элементы которых распределены неравномерно, поэтому программа выбирает точки при помощи функции странного аттрактора (strange attractor) из теории хаоса (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 секунды для создания того же дерева.
Восьмеричные деревьяВосьмеричные деревья (octtrees) аналогичны квадродеревьям, но они разбивают область не двумерного, а трехмерного пространства. Восьмеричные деревья содержат не четыре потомка, как квадродеревья, а восемь, разбивая объем области на восемь частей — верхнюю северо‑западную, нижнюю северо‑западную, верхнюю северо‑восточную, нижнюю северо‑восточную и так далее.
Восьмеричные деревья полезны при работе с объектами, расположенными в пространстве. Например, робот может использовать восьмеричное дерево для отслеживания близлежащих объектов. Программа рейтрейсинга может использовать восьмеричное дерево для того, чтобы быстро оценить, проходит ли луч поблизости от объекта перед тем, как начать медленный процесс вычислений точного пересечения объекта и луча.
Восьмеричные деревья можно строить, используя примерно те же методы, что и для квадродеревьев.
РезюмеСуществует множество способов представления деревьев. Наиболее эффективным и компактным из них является запись полных деревьев в массивах. Представление деревьев в виде коллекций дочерних узлов упрощает работу с ними, но при этом программа выполняется медленнее и использует больше памяти. Представление нумерацией связей позволяет быстро выполнять обход дерева и использует меньше памяти, чем коллекции потомков, но его сложно модифицировать. Тем не менее, его важно представлять, потому что оно часто используется в сетевых алгоритмах.
=====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 миллиона шагов.
Хотя нельзя быть уверенным, что элементы будут добавляться и удаляться из дерева в нужном порядке, можно использовать методы, которые будут поддерживать сбалансированность дерева, независимо от порядка вставки или удаления элементов.
АВЛ‑деревьяАВЛ‑деревья (AVL trees) были названы в честь русских математиков Адельсона‑Вельского и Лэндиса, которые их изобрели. Для каждого узла АВЛ‑дерева, высота левого и правого поддеревьев отличается не больше, чем на единицу. На рис. 7.2 показано несколько АВЛ‑деревьев.
Хотя АВЛ‑дерево может быть несколько выше, чем полное дерево с тем же числом узлов, оно также имеет высоту порядка O(log(N)). Это означает, что поиск узла в АВЛ‑дереве занимает время порядка O(log(N)), что достаточно быстро. Не столь очевидно, что можно вставить или удалить элемент из АВЛ‑дерева за время порядка O(log(N)), сохраняя при этом порядок дерева.
======156
@Рис. 7.2. АВЛ‑деревья
Процедура, которая вставляет в дерево новый узел, рекурсивно спускается вниз по дереву, чтобы найти местоположение узла. После вставки элемента, происходят возвраты из рекурсивных вызовов процедуры и обратный проход вверх по дереву. При каждом возврате из процедуры, она проверяет, сохраняется ли все еще свойство АВЛ‑деревьев на верхнем уровне. Этот тип обратной рекурсии, когда процедура выполняет важные действия при выходе из цепочки рекурсивных вызовов, называется восходящей (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, изображенных в виде треугольников. Если вы вставляете узел в одно из этих поддеревьев, то для балансировки дерева потребуется выполнить соответствующее вращение. Помните, что иногда балансировка не нужна, если вставка нового узла не нарушает упорядоченность дерева.
Правое вращениеВначале предположим, что новый узел вставляется в поддерево 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), как показано на рис. 7.6. Это вращение называется правым, так как узлы A и X как бы вращаются вправо.
Заметим, что это вращение сохраняет порядок «меньше» расположения узлов дерева. При симметричном обходе любого из таких деревьев обращение ко всем поддеревьям и узлам дерева происходит в порядке T1, A, T2, X, T3. Поскольку симметричный обход обоих деревьев происходит одинаково, то и порядок расположения элементов в них будет одинаковым.
Важно также заметить, что высота поддерева, с которым мы работаем, остается неизменной. Перед тем, как был вставлен новый узел, высота поддерева была равна высоте поддерева T2 плюс 2. После вставки узла и выполнения правого вращения, высота поддерева также остается равной высоте поддерева T2 плюс 2. Все части дерева, лежащие ниже узла X при этом также остаются сбалансированными, поэтому не требуется продолжать балансировку дерева дальше.
Левое вращениеЛевое вращение (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), так как при этом вначале узлы A и C как бы вращаются влево, а затем узлы C и X вращаются вправо.
Как и другие вращения, вращение этого типа не изменяет порядок элементов в дереве. При симметричном обходе дерева до и после вращения обращение к узлам и поддеревьям происходит в порядке: T1, A, T2, C, T3, X, T4.
Высота дерево после балансировки также не меняется. До вставки нового узла, правое поддерево имело высоту поддерева T1 плюс 2. После балансировки дерева, высота этого поддерева снова будет равна высоте T1 плюс 2. Это значит, что остальная часть дерева также остается сбалансированной, и нет необходимости продолжать балансировку дальше.
Вращение вправо‑влевоВращение вправо‑влево (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.
Б‑деревьяБ‑деревья (B‑trees) являются другой формой сбалансированных деревьев, немного более наглядной, чем АВЛ‑деревья. Каждый узел в Б‑дереве может содержать несколько ключей данных и несколько указателей на дочерние узлы. Поскольку каждый узел содержит несколько элементов, такие узлы иногда называются блоками.
=======171
@Рис. 7.14. Программа AVL
Между каждой парой соседних указателей находится ключ, который можно использовать для определения ветви, по которой нужно следовать при вставке или поиске элемента. Например, в дереве, показанном на рис. 7.15, корневой узел содержит два ключа: G и R. Чтобы найти элемент со значением, которое идет перед G, нужно искать в первой ветви. Чтобы найти элемент, имеющий значение между G и R, проверяется вторая ветвь. Чтобы найти элемент, который следует за R, выбирается третья ветвь.
Б‑дерево порядка K обладает следующими свойствами:
· Каждый узел содержит не более 2 * K ключей.
· Каждый узел, кроме может быть корневого, содержит не менее K ключей.
· Внутренний узел, имеющий M ключей, имеет M + 1 дочерних узлов.
· Все листья дерева находятся на одном уровне.
Б‑дерево на рис. 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
Разновидности Б‑деревьевСуществует несколько разновидностей Б‑деревьев, из которых здесь описаны только некоторые. Нисходящие Б‑деревья (top‑down B‑trees) немного иначе управляют структурой Б‑дерева. За счет разбиения встречающихся полных узлов, эта разновидность алгоритма использует при вставке элементов более наглядную нисходящую рекурсию вместо восходящей. Эта также уменьшает вероятность возникновения длительной последовательности разбиений блоков.
Другой разновидностью Б‑деревьев являются Б+деревья (B+trees). В Б+деревьях внутренние узлы содержат только ключи данных, а сами записи находятся в листьях. Это позволяет Б+деревьям хранить в каждом блоке больше элементов, поэтому такие деревья короче, чем соответствующие Б‑деревья.
Нисходящие Б‑деревьяПодпрограмма, которая добавляет новый элемент в Б‑дерево, вначале выполняет рекурсивный поиск по дереву, чтобы найти блок, в который его нужно поместить. Когда она пытается вставить новый элемент на его место, ей может понадобиться разбить блок и переместить один из элементов узла в его родительский узел.
При возврате из рекурсивных вызовов процедуры, вызывающая процедура проверяет, требуется ли разбиение родительского узла. Если да, то элемент помещается в родительский узел. При каждом возврате из рекурсивного вызова, вызывающая процедура должна проверять, не требуется ли разбиение следующего предка. Так как эти разбиения блоков происходят при возврате из рекурсивных вызовов процедура, это восходящая рекурсия, поэтому иногда Б‑деревья, которыми манипулируют таким образом, называются восходящими Б‑деревьями (bottom‑up B‑trees).
Другая стратегия состоит в том, чтобы разбивать все полные узлы, которые встречаются процедуре на пути вниз по дереву. При поиске блока, в который нужно поместить новый элемент, процедура разбивает все повстречавшиеся полные узлы. При каждом разбиении узла, она помещает один из его элементов в родительский узел. Так как она уже разбила все выше расположенные полные узлы, то в родительском узле всегда есть место для нового элемента.
Когда процедура доходит до листа, в который нужно поместить элемент, то в его родительском узле всегда есть свободное место, и если программе нужно разбить лист, то всегда можно поместить средний элемент в родительский узел. Так как при этом процедура работает с деревом сверху вниз, Б‑деревья такого типа иногда называются нисходящими Б‑деревьями (top‑down B‑trees).
При этом разбиение блоков происходит чаще, чем это абсолютно необходимо. В нисходящем Б‑дереве полный узел разбивается, даже если в его дочерних узлах достаточно много свободного места. За счет предварительного разбиения узлов, при использовании нисходящего метода в дереве содержится больше пустого пространства, чем в восходящем Б‑дереве. С другой стороны, такой подход уменьшает вероятность возникновения длительной последовательности разбиений блоков.
К сожалению, не существует нисходящей версии для слияния узлов. При продвижении вниз по дереву, процедура удаления узлов не может объединять встречающиеся наполовину пустые узлы, потому что в этот момент еще неизвестно, нужно ли будет объединить два дочерних узла и удалить элемент из их родителя. Так как неизвестно также, будет ли удален элемент из родительского узла, то нельзя заранее сказать, потребуется ли слияние родителя с одним из узлов, находящимся на том же уровне.
==========176
Б+деревьяБ+деревья часто используются для хранения больших записей. Типичное Б‑дерево может содержать записи о сотрудниках, каждая из которых может занимать несколько килобайт памяти. Записи могли бы располагаться в Б‑дереве в соответствии с ключевым полем, например фамилией сотрудника или его идентификационным номером.
В этом случае упорядочение элементов может быть достаточно медленным. Чтобы слить два блока, программе может понадобиться переместить множество записей, каждая из которых может быть достаточно большой. Аналогично, для разбиения блока может потребоваться переместить множество записей большого объема.
Чтобы избежать перемещения больших блоков данных, программа может записывать во внутренних узлах Б‑дерева только ключи. При этом узлы также содержат ссылки на сами записи данных, которые записаны в другом месте. Теперь, если программе требуется переупорядочить блоки, то нужно переместить только ключи и указатели, а не сами записи. Этот тип Б‑дерева называется Б+деревом (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. Деревья решенийМногие сложные реальные задачи можно смоделировать при помощи деревьев решений (decision trees). Каждый узел дерева представляет один шаг решения задачи. Каждая ветвь в дереве представляет решение, которое ведет к более полному решению. Листья представляют собой окончательное решение. Цель заключается в том, чтобы найти «наилучший» путь от корня к листу при выполнении определенных условий. Эти условия и значение понятия «наилучший» для пути зависит от задачи.
Деревья решений обычно имеют громадный размер. Дерево решений для игры в крестики‑нолики содержит более полумиллиона узлов. Эта игра довольно проста, и многие реальные задачи намного более сложны. Соответствующие им деревья решений могли бы содержать больше узлов, чем число атомов во вселенной.
В этой главе обсуждаются методы, которые можно использовать для поиска в таких огромных деревьях. Во‑первых, в ней вначале рассматриваются деревья игры (game trees). На примере игры в крестики‑нолики обсуждаются способы поиска в деревьях игры для нахождения наилучшего возможного хода.
В следующих разделах описываются способы поиска в более общих деревьях решений. Для самых маленьких деревьев, можно использовать метод полного перебора (exhaustive searching) всех возможных решений. Для деревьев большего размера, можно использовать метод ветвей и границ (branch‑and‑bound technique) позволяет найти наилучшее решение без необходимости выполнять поиск по всему дереву.
Для очень больших деревьев нужно использовать эвристический метод или эвристику (heuristic). При этом полученное решение может быть не наилучшим из возможных решений, но оно, тем не менее, лежит достаточно близко к наилучшему, чтобы его можно было использовать. Используя эвристики, можно проводить поиск практически в любых деревьях решений.
В конце этой главы обсуждаются некоторые очень сложные задачи, которые вы можете попытаться решить при помощи метода ветвей и границ или эвристического метода. Многие из этих задач имеют важные применения, и нахождение хороших решений для них крайне необходимо.
Поиск в деревьях игрыСтратегию настольных игр, таких как шахматы, шашки, или крестики‑нолики можно смоделировать при помощи деревьев игры. Если в какой то момент игры существует 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, означает, что выигрывает противник.
Для поиска дерева методом полного перебора можно использовать минимаксную (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
Метод ветвей и границМетод ветвей и границ (branch and bound) является одним из методов отсечения (pruning) ветвей в дереве решений, чтобы не было необходимо рассматривать все ветви дерева. Общий подход при этом состоит в том, чтобы отслеживать границы уже обнаруженных и возможных решений. Если в какой‑то точке наилучшее из уже найденных решений лучше, чем наилучшее возможное решение в нижних ветвях, то можно игнорировать все пути вниз от узла.
Например, допустим, что имеет 100 миллионов долларов, которые нужно вложить в несколько возможных инвестиций. Каждое из вложений имеет разную стоимость и дает разную прибыль. Необходимо решить, как вложить деньги наилучшим образом, чтобы суммарная прибыль была максимальной.
Задачи такого типа называются задачей формирования портфеля[RV14] (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, которые будут вскоре описаны, дали наилучшие эвристические решения. Заметьте, что эти решения немного хуже, чем точные решения, которые получены при использовании метода ветвей и границ.
Восхождение на холмЭвристика восхождения на холм (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
Метод наименьшей стоимостиСтратегия, которая в каком‑то смысле противоположна стратегии восхождения на холм, называется стратегией наименьшей стоимости (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
Сбалансированная прибыльСтратегия восхождения на холм не учитывает стоимость добавляемых позиций. Она выбирает позиции с максимальной прибылью, даже если их стоимость велика. Стратегия наименьшей стоимости не учитывает приносимую позицией прибыль. Она выбирает позиции с низкой стоимостью, даже если они приносят мало прибыли.
Эвристика сбалансированной прибыли (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
Случайный поискСлучайный поиск (random search) выполняется в соответствии со своим названием. На каждом шаге алгоритм добавляет случайную позицию, которая удовлетворяет верхнему ограничению на суммарную стоимость позиций в портфеле. Этот метод поиска также называется методом Монте‑Карло (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
Последовательное приближениеЕще одна стратегия заключается в том, чтобы начать со случайного решения и затем делать последовательные приближения (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 и затем добавить на их место новые позиции.
Решения такого типа, для которых небольшие изменения решения не могут улучшить его, называются локальным оптимумом (local optimum). Можно использовать два способа для того, чтобы программа не застревала в локальном оптимуме, и могла найти глобальный оптимум (global optimum).
@Таблица 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.
Алгоритм «отжига»Метод отжига (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).
В следующих разделах кратко описаны некоторые из этих задач. В них также показано, почему они являются сложными в общем случае и насколько большим может оказаться дерево решений задачи. Вы можете попробовать применить метод ветвей и границ или эвристики для решения некоторых из этих задач.
Задача о выполнимостиЕсли имеется логическое утверждение, например “(A And Not B) Or C”, то существуют ли значения переменных A, B и C, при которых это утверждение истинно? В данном примере легко увидеть, что утверждение истинно, если A = true, B = false и C = false. Для более сложных утверждений, содержащих сотни переменных, бывает достаточно сложно определить, может ли быть утверждение истинным.
При помощи метода, похожего на тот, который использовался при решении задачи о формировании портфеля, можно простроить дерево решений для задачи о выполнимости (satisfiability problem). Каждая ветвь дерева будет соответствовать решению о присвоении переменной значения true или false. Например, левая ветвь, выходящая из корня, соответствует значению первой переменной true.
Если в логическом выражении N переменных, то дерево решений представляет собой двоичное дерево высотой N + 1. Это дерево имеет 2N листьев, каждый из которых соответствует разной комбинации значений переменных.
В задаче о формировании портфеля можно было использовать метод ветвей и границ для того, чтобы избежать поиска в большей части дерева. В задаче о выполнимости выражение либо истинно, либо ложно. При этом нельзя получить частичное решение, которое можно использовать для отсечения путей в дереве.
Нельзя также использовать эвристики для поиска приблизительного решения для задачи о выполнимости. Любое значение переменных, полученное при помощи эвристики, будет делать выражение истинным или ложным. В математической логике не существует такого понятия, как приближенное решение.
Из‑за неприменимости эвристик и меньшей эффективности метода ветвей и границ, задача о выполнимости обычно является очень сложной и решается только в случае небольшого размера задачи.
Задача о разбиенииЕсли задано множество элементов со значениями X1, X2, … , XN, то существует ли способ разбить его на два подмножества, так чтобы сумма значений всех элементов в каждом из подмножеств была одинаковой? Например, если элементы имеют значения 3, 4, 5 и 6, то их можно разбить на два подмножества {3, 6} и {4, 5}, сумма значений элементов в каждом из которых равна 9.
Чтобы смоделировать эту задачу при помощи дерева, предположим, что ветвям соответствует помещение элемента в одно из двух подмножеств. Левая ветвь, выходящая из корневого узла, соответствует помещению первого элемента в первое подмножество, а правая ветвь — во второе подмножество.
Если всего существует N элементов, то дерево решение будет представлять собой двоичное дерево высотой N + 1. Оно будет содержать 2N листьев и 2N+1 узлов. Каждый лист соответствует одному из вариантов размещения элементов в двух подмножествах.
При решении этой задачи можно применить метод ветвей и границ. При рассмотрении частичных решений задачи можно отслеживать, насколько различаются суммарные значения элементов в двух подмножествах. Если в какой‑то момент суммарное значение элементов для одного из подмножеств настолько меньше, чем для другого, что добавление всех оставшихся элементов не позволяет изменить это соотношение, то нет смысла продолжать движение вниз по этой ветви.
Так же, как и в случае с задачей о выполнимости, для задачи о разбиении (partition problem) нельзя получить приближенное решение. В результате всегда должно получиться два подмножества, суммарное значение элементов в которых будет или не будет одинаковым. Это означает, что для решения этой задачи неприменимы эвристики, которые использовались для решения задачи о формировании портфеля.
Задачу о разбиении можно обобщить следующим образом: если имеется множество элементов со значениями X1, X2, … , XN, как разбить его на два подмножества, чтобы разница суммы значений элементов в двух подмножествах была минимальной?
Получить точное решение этой задачи труднее, чем для исходной задачи о разбиении. Если бы существовал простой способ решения задачи в общем случае, то его можно было бы использовать для решения исходной задачи. В этом случае можно было бы просто найти два подмножества, удовлетворяющих условиям, а затем проверить, совпадают ли суммы значений элементов в них.
Для решения общего случая задачи можно использовать метод ветвей и границ, примерно так же, как он использовался для решения частного случая задачи, чтобы избежать поиска по всему дереву. Можно также использовать при этом эвристический подход. Например, можно проверять элементы в порядке убывания их значения, помещая очередной элемент в подмножество с меньшей суммой значений элементов. Также можно было бы легко использовать случайный поиск, метод последовательных приближений, или метод отжига для поиска приближенного решения этого общего случая задачи.
Задача поиска Гамильтонова путиЕсли задана сеть, то Гамильтоновым путем (Hamiltonian path) для нее называется путь, обходящий все узлы в сети только один раз и затем возвращающийся в начальную точку.
На рис. 8.9 показана небольшая сеть и Гамильтонов путь для нее, нарисованный жирной линией.
Задача поиска Гамильтонова пути формулируется так: если задана сеть, существует ли для нее Гамильтонов путь?
==============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 — в миллион раз больше. Из‑за очень большого размера дерева решений задачи нахождения Гамильтонова пути, поиск в нем можно выполнить только для задач очень небольшого размера.
Задача коммивояжераЗадача коммивояжера (traveling salesman problem) тесно связана с задачей поиска Гамильтонова пути. Она формулируется так: найти самый короткий Гамильтонов путь для сети.
========220
Эта задача имеет примерно такое же отношение к задаче поиска Гамильтонова пути, как обобщенный случай задачи о разбиении к простой задаче о разбиении. В первом случае возникает вопрос о существовании решения. Во втором — какое приближенное решение будет наилучшим. Если бы существовало простое решение второй задачи, то его можно было бы использовать для решения первого варианта задачи.
Обычно задача коммивояжера возникает только в сетях, содержащих большое число Гамильтоновых путей. В типичном примере, коммивояжеру требуется посетить несколько клиентов, используя кратчайший маршрут. В случае обычной сети улиц, любые две точки в сети связаны между собой, поэтому любой маршрут представляет собой Гамильтонов путь. Задача заключается в том, чтобы найти самый короткий из них.
Так же как и в случае поиска Гамильтонова пути, дерево решений для этой задачи содержит порядка O(N!) узлов. Так же, как и в обобщенной задаче о разбиении, для отсечения ветвей дерева и ускорения поиска решения задач средних размеров можно использовать метод ветвей и границ.
Существует также несколько хороших эвристических методов последовательных приближений для задачи коммивояжера. Например, использование стратегии пар путей, при которой перебираются пары отрезков маршрута. Программа проверяет, станет ли маршрут короче, если удалить пару отрезков и заменить их двумя новым, так чтобы маршрут при этом оставался замкнутым. На рис. 8.10 показано как изменяется маршрут, если отрезки X1 и X2 заменить отрезками Y1 и Y2. Аналогичные стратегии последовательных приближений рассматривают замену трех или более отрезков пути одновременно.
Обычно такие шаги последовательного приближения повторяются многократно или до тех пор, пока не будут проверены все возможные пары отрезков пути. После того, как дальнейшие шаги не приводят к улучшениям, можно сохранить результат и начать работу снова, случайным образом выбрав другой исходный маршрут. После проверки достаточно большого числа различных случайных исходных маршрутов, вероятно будет найден достаточно короткий путь.
Задача о пожарных депоЗадача о пожарных депо (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 главе. При добавлении или удалении записи необходимо обновлять каждую таблицу индексов независимо.
Помните, что таблицы индексов занимают дополнительную память. Если создать по таблице индексов для каждого из полей данных, объем занимаемой памяти более чем удвоится.
Объединение и сжатие ключейИногда можно хранить ключи списка в комбинированной или сжатой форме. Например, можно было бы объединить (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
Также иногда можно сжимать (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, выделены синим цветом.
Сортировка выборомСортировка выбором (selectionsort) — простой алгоритм со сложность порядка O(N2). Идея состоит в поиске наименьшего элемента в списке, который затем меняется местами с элементом на вершине списка. Затем находится наименьший элемент из оставшихся, и меняется местами со вторым элементом. Процесс продолжается до тех пор, пока все элементы не займут свое конечное положение.
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, что приведет к некоторому замедлению работы алгоритма.
Это не самый быстрый алгоритм из числа описанных в главе, но он чрезвычайно прост. Это не только облегчает его разработку и отладку, но и делает сортировку выбором достаточно быстрой для небольших задач. Многие другие алгоритмы настолько сложны, что они сортируют очень маленькие списки медленнее.
РандомизацияВ некоторых программах требуется выполнение операции, обратной сортировке. Получив список элементов, программа должна расположить их в случайном порядке. Рандомизацию (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 (Начать). Программа показывает исходный отсортированный список чисел и результат рандомизации.
Сортировка вставкойСортировка вставкой (insertionsort) — еще один алгоритм со сложностью порядка O(N2). Идея состоит в том, чтобы создать новый сортированный список, просматривая поочередно все элементы в исходном списке. При этом, выбирая очередной элемент, алгоритм просматривает растущий отсортированный список, находит требуемое положение элемента в нем, и помещает элемент на свое место в новый список.
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
Пузырьковая сортировкаПузырьковая сортировка (bubblesort) — это алгоритм, предназначенный для сортировки списков, которые уже находятся в почти упорядоченном состоянии. Если в начале процедуры список полностью отсортирован, алгоритм выполняется очень быстро за время порядка O(N). Если часть элементов находятся не на своих местах, алгоритм выполняется медленнее. Если первоначально элементы расположены в случайном порядке, алгоритм выполняется за время порядка O(N2). Поэтому перед применением пузырьковой сортировки важно убедиться, что элементы в основном расположены по порядку.
При пузырьковой сортировке список просматривается до тех пор, пока не найдутся два соседних элемента, расположенных не по порядку. Тогда они меняются местами, и процедура продолжается дальше. Алгоритм повторяет этот процесс до тех пор, пока все элементы не займут свои места.
На рис. 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 элементов
Несмотря на то, что пузырьковая сортировка медленнее, чем многие другие алгоритмы, у нее есть свои применения. Пузырьковая сортировка часто дает наилучшие результаты, если список изначально уже почти упорядочен. Если программа управляет списком, который сортируется при создании, а затем к нему добавляются новые элементы, пузырьковая сортировка может быть лучшим выбором.
Быстрая сортировкаБыстрая сортировка (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
Многие программисты выбирают алгоритм быстрой сортировки, т.к. он дает хорошую производительность в большинстве обстоятельств.
Сортировка слияниемКак и быстрая сортировка, сортировка слиянием (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 секунды.
Пирамидальная сортировкаПирамидальная сортировка (heapsort) использует специальную структуру, называемую пирамидой (heap), для организации элементов в списке. Пирамиды интересны сами по себе и полезны при реализации приоритетных очередей.
В начале этой главы описываются пирамиды, и объясняется, как вы можете реализовать пирамиды на языке Visual Basic. Затем показано, как использовать пирамиду для построения эффективной приоритетной очереди. Располагая средствами для управления пирамидами и приоритетными очередями, легко реализовать алгоритм пирамидальной сортировки.
ПирамидыПирамида (heap) — это полное двоичное дерево, в котором каждый узел не меньше, чем оба его потомка. Это ничего не говорит о взаимосвязи между потомками. Они должны быть меньше родителя, но любой из них может быть больше, чем другой. На рис. 9.6 показана небольшая пирамида.
Поскольку каждый узел не меньше, чем два нижележащих узла, корень дерева — всегда наибольший элемент в пирамиде. Это делает пирамиды удобной структурой данных для реализации приоритетных очередей. Если вам нужен элемент очереди с самым высоким приоритетом, он всегда находится на вершине пирамиды.
=========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
Приоритетные очередиПриоритетной очередью (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
Такой же порядок сложности имеет алгоритм сортировки слиянием и в среднем алгоритм быстрой сортировки. Так же, как и сортировка слиянием, пирамидальная сортировка тоже не зависит от значений или распределения элементов до начала сортировки. Быстрая сортировка плохо работает со списками, содержащими большое число одинаковых элементов, а сортировка слиянием и пирамидальная сортировка лишены этого недостатка.
Хотя обычно пирамидальная сортировка работает немного медленнее, чем сортировка слиянием, для нее не требуется дополнительного пространства для хранения временных значений, как для сортировки слиянием. Пирамидальная сортировка создает первоначальную пирамиду и упорядочивает элементы в пределах исходного массива списка.
Сортировка подсчетомСортировка подсчетом (countingsort) — специализированный алгоритм, который очень хорошо работает, если элементы данных — целые числа, значения которых находятся в относительно узком диапазоне. Этот алгоритм работает достаточно быстро, например, если значения находятся между 1 и 1000.
Если список удовлетворяет этим требованиям, сортировка подсчетом выполняется невероятно быстро. В одном из тестов на компьютере с процессором 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<N, то O(M+N)=O(N), что довольно быстро. Если N=100.000 и M=1000, то M+N=101.000, тогда как N*log(N)=1,6 миллиона. Шаги, выполняемые алгоритмом сортировки подсчетом, также относительно просты по сравнению с шагами быстрой сортировки. Все эти факты объединяются, обеспечивая вместе невероятно высокую скорость выполнения сортировки подсчетом.
С другой стороны, если 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, вы все еще можете использовать сортировку подсчетом.
Блочная сортировкаКак и сортировка подсчетом, блочная сортировка (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 может помочь вам подобрать алгоритм, который обеспечит максимальную производительность:
* если вам нужно быстро реализовать алгоритм сортировки, используйте быструю сортировку, а затем при необходимости поменяйте алгоритм;
* если более 99 процентов списка уже отсортировано, используйте пузырьковую сортировку;
* если список очень мал (100 или менее элементов), используйте сортировку выбором;
* если значения находятся в связном списке, используйте блочную сортировку на основе связного списка;
* если элементы в списке — целые числа, разброс значений которых невелик (до нескольких тысяч), используйте сортировку подсчетом;
* если значения лежат в широком диапазоне и не являются целыми числами, используйте блочную сортировку на основе массива;
* если вы не можете тратить дополнительную память, которая требуется для блочной сортировки, используйте быструю сортировка
Если вы знаете структуру данных и различные алгоритмы сортировки, вы можете выбрать алгоритм, наиболее подходящий для ваших нужд.
@Таблица 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) поиска или поиска методом полного перебора (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 главам
Двоичный поискКак уже упоминалось в предыдущих разделах, поиск полным перебором выполняется очень быстро для небольших списков, но для больших списков намного быстрее выполняется двоичный поиск. Алгоритм двоичного поиска (binary search) сравнивает элемент в середине списка с искомым. Если искомый элемент меньше, чем найденный, то алгоритм продолжает поиск в первой половине списка, если больше — в правой половине. На рис. 10.2 этот процесс изображен графически.
Хотя по своей природе этот алгоритм является рекурсивным, его достаточно просто записать и без применения рекурсии. Так как этот алгоритм прост для понимания в любом варианте (с рекурсией или без), то мы приводим здесь его нерекурсивную версию, которая содержит меньше вызовов функций.
Основная заключенная в этом алгоритме идея проста, но детали ее реализации достаточно сложны. Программе приходится аккуратно отслеживать часть массива, которая может содержать искомый элемент, иначе она может его пропустить.
Алгоритм использует две переменные, 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 шагов.
Интерполяционный поискДвоичный поиск обеспечивает значительное увеличение скорости поиска по сравнению с полным перебором, так как он исключает большие части списка, не проверяя при этом значения исключаемых элементов. Если, кроме того, известно, что значения элементов распределены достаточно равномерно, то можно исключать на каждом шаге еще больше элементов, используя интерполяционный поиск (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 в списке, поэтому поиск при этом заканчивается.
Следящий поискЧтобы начать двоичный следящий поиск (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). Вначале, как и раньше, сравним искомое значение из предыдущего поиска с новым. Если новое искомое значение меньше, начнем слежение влево, если больше — вправо.
Для слежения влево будем теперь использовать интерполяцию, чтобы предположить, где может находиться искомое значение в диапазоне между предыдущим значением и значением элемента 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. ХешированиеВ предыдущей главе описывался алгоритм интерполяционного поиска, который использует интерполяцию, чтобы быстро найти элемент в списке. Сравнивая искомое значение со значениями элементов в известных точках, этот алгоритм может определить вероятное положение искомого элемента. В сущности, он создает функцию, которая устанавливает соответствие между искомым значением и индексом позиции, в которой он должен находиться. Если первое предположение ошибочно, то алгоритм снова использует эту функцию, делая новое предположение, и так далее, до тех пор, пока искомый элемент не будет найден.
Хеширование (hashing) использует аналогичный подход, отображая элементы в хеш‑таблице (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 ячеек, то в среднем каждая ячейка будет соответствовать миллиону записей.
Чтобы избежать этой потенциальной проблемы, схема хеширования должна включать в себя алгоритм разрешения конфликтов (collision resolution policy), который определяет последовательность действий в случае, если ключ соответствует позиции в таблице, которая уже занята другой записью. В следующих разделах описываются несколько различных методов разрешения конфликтов.
Все обсуждаемые здесь методы используют для разрешения конфликтов примерно одинаковый подход. Они вначале устанавливают соответствие между ключом записи и положением в хеш‑таблице. Если эта ячейка уже занята, они отображают ключ на какую‑либо другую ячейку таблицы. Если она также уже занята, то процесс повторяется снова о тех пор, пока в конце концов алгоритм не найдет пустую ячейку в таблице. Последовательность проверяемых при поиске или вставке элемента в хеш‑таблицу позиций называется [RV16] тестовой последовательностью (probe sequence).
В итоге, для реализации хеширования необходимы три вещи:
· Структура данных (хеш‑таблица) для хранения данных;
· Функция хеширования, устанавливающая соответствие между значением ключа и положением в таблице;
· Алгоритм разрешения конфликтов, определяющий последовательность действий, если несколько ключей соответствуют одной ячейке таблицы.
В следующих разделах описаны некоторые структуры данных, которые можно использовать для хеширования. Каждая из них имеет соответствующую функцию хеширования и один или более алгоритмов разрешения конфликтов. Так же, как и в большинстве компьютерных алгоритмов, каждый из этих методов имеет свои преимущества и недостатки. В последнем разделе описаны преимущества и недостатки разных методов, чтобы помочь вам выбрать наилучший для данной ситуации метод хеширования.
СвязываниеОдин из методов разрешения конфликтов заключается в хранении записей, которые занимают одинаковое положение в таблице, в связных списках. Чтобы добавить в таблицу новую запись, при помощи функции хеширования выбирается связный список, который должен его содержать. Затем запись добавляется в этот список.
На рис. 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]
БлокиДругой способ разрешения конфликтов заключается в том, чтобы выделить ряд блоков, каждый из которых может содержать несколько элементов. Для вставки элемента в таблицу, он отображается на один из блоков и затем помещается в этот блок. Если блок уже заполнен, то используется обработка переполнения.
@Рис. 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] Иногда элементы данных слишком велики, чтобы их было удобно размещать в блоках. Если требуется список из 1000 элементов, каждый из которых занимает на диске 1 Мбайт, может быть сложно использовать блоки, которые содержали бы более одного или двух элементов. Если каждый из блоков будет содержать всего один или два элемента, то для поиска или вставки элемента потребуется проверить множество блоков.
При использовании открытой адресации (open addressing) хеш‑функция используется для непосредственного вычисления положения элементов данных в массиве. Например, можно использовать в качестве хеш‑таблицы массив с нижним индексом 0 и верхним 99. Тогда хеш‑функция может сопоставлять ключу со значением K индекс массива, равный K Mod 100. При этом элемент со значением 1723 окажется в таблице на 23 позиции. Затем, когда понадобится найти элемент 1723, проверяется 23 позиция в массиве.
==========295
Различные схемы открытой адресации используют разные методы для формирования тестовых последовательностей. В следующих разделах рассматриваются три наиболее важных метода: линейная, квадратичная и псевдослучайная проверка.
Линейная проверкаЕсли позиция, на которую отображается новый элемент в массиве, уже занята, то можно просто просмотреть массив с этой точки до тех пор, пока не найдется незанятая позиция. Этот метод разрешения конфликтов называется линейной проверкой (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. Длина успешной и безуспешной тестовых последовательностей
Первичная кластеризацияЛинейная проверка имеет одно неприятное свойство, которое называется первичной кластеризацией (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) и перемещается по таблице до тех пор, пока не найдет свободную ячейку. Во время упорядоченного хеширования может потребоваться поменять вставляемый элемент на другие в его тестовой последовательности. Если элементы представляют собой записи большого размера, то на это может потребоваться больше времени, особенно если записи находятся на диске или каком‑либо другом медленном запоминающем устройстве.
Упорядоченная линейная проверка определенно является лучшим выбором, если вы знаете, что программе придется совершать большое число безуспешных операций поиска. Если программа будет часто выполнять поиск элементов, которых нет в таблице, или элементы таблицы имеют большой размер и перемещать их достаточно сложно, то можно получить лучшую производительность при использовании неупорядоченной линейной проверки.
Квадратичная проверкаОдин из способов уменьшить первичную кластеризацию состоит в том, чтобы использовать хеш‑функцию следующего вида:
Hash(K, P) = (K + P2) Mod N где P = 0, 1, 2, ...
Предположим, что при вставке элемента в хеш‑таблицу он отображается в кластер, образованный другими элементами. Если элемент отображается в позицию возле начала кластера, то возникнет еще несколько конфликтов прежде, чем найдется свободная ячейка для элемента. По мере роста параметра P в тестовой функции, значение этой функции быстро меняется. Это означает, что позиция, в которую попадет элемент в конечном итоге, возможно, окажется далеко от кластера.
=======302
На рис. 11.8 показана хеш‑таблица, содержащая большой кластер элементов. На нем также показаны тестовые последовательности, которые возникают при попытке вставить два различных элемента в позиции, занимаемые кластером. Обе эти тестовые последовательности заканчиваются в точке, которая не прилегает к кластеру, поэтому после вставки этих элементов размер кластера не увеличивается.
Следующий код демонстрирует поиск элемента с использованием квадратичной проверки (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 — простое число. Хотя при этом гарантируется некоторый уровень производительности, все равно могут возникнуть проблемы, если таблица почти заполнена. Так как производительность для почти заполненной таблицы в любом случае сильно падает, то возможно лучше будет просто увеличить размер хеш-таблицы, а не беспокоиться о том, сможет ли тестовая последовательность найти свободную ячейку.
Не столь очевидная проблема, которая возникает при применении квадратичной проверки, заключается в том, что хотя она устраняет первичную кластеризацию, во время нее может возникать похожая проблема, которая называется вторичной кластеризацией (secondary clustering). Если два элемента отображаются в одну ячейку, для них будет выполняться одна и так же тестовая последовательность. Если множество элементов отображаются на одну из ячеек таблицы, они образуют вторичный кластер, который распределен по хеш‑таблице. Если появляется новый элемент с тем же самым начальным значением, для него приходится выполнять длительную тестовую последовательность, прежде чем он обойдет элементы во вторичном кластере.
На рис. 11.9 показана хеш‑таблица, которая может содержать 10 ячеек. В таблице находятся элементы 2, 12, 22 и 32, которые все изначально отображаются в позицию 2. Если попытаться вставить в таблицу элемент 42, то нужно будет выполнить длительную тестовую последовательность, которая обойдет все эти элементы, прежде чем найдет свободную ячейку.
Псевдослучайная проверкаСтепень кластеризации растет, если в кластер добавляются элементы, которые отображаются на уже занятые кластером ячейки. Вторичная кластеризация возникает, когда для элементов, которые первоначально должны занимать одну и ту же ячейку, выполняется одна и та же тестовая последовательность, и образуется вторичный кластер, распределенный по хеш‑таблице. Можно устранить оба эти эффекта, если сделать так, чтобы для разных элементов выполнялись различные тестовые последовательности, даже если элементы первоначально и должны были занимать одну и ту же ячейку.
Один из способов сделать это заключается в использовании в тестовой последовательности генератора псевдослучайных чисел. Для вычисления тестовой последовательности для элемента, его значение используется для инициализации генератора случайных чисел. Затем для построения тестовой последовательности используются последовательные случайные числа, получаемые на выходе генератора. Это называется псевдослучайной проверкой (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 отсутствует в таблице.
Вместо удаления элемента из хеш‑таблицы можно просто пометить его как удаленный. Можно использовать эту ячейку позднее, если она встретится во время выполнения вставки нового элемента в таблицу. Если помеченный элемент встречается во время поиска другого элемента, он просто игнорируется и тестовая последовательность продолжится.
После того, как большое число элементов будет помечено как удаленные, в хеш‑таблице может оказаться множество неиспользуемых ячеек, и при поиске элементов достаточно много времени будет уходить на пропуск удаленных элементов. В конце концов, может потребоваться рехеширование таблицы для освобождения неиспользуемой памяти.
РехешированиеЧтобы освободить удаленные элементы из хеш‑таблицы, можно выполнить ее рехеширование (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 главах обсуждались алгоритмы работы с деревьями. Данная глава посвящена более общей теме сетей. Сети играют важную роль во многих приложениях. Их можно использовать для моделирования таких объектов, как сеть улиц, телефонная или электрическая сеть, водопровод, канализация, водосток, сеть авиаперевозок или железных дорог. Менее очевидна возможность использования сетей для решения таких задач, как разбиение на районы, составление расписания методом критического пути, планирование коллективной работы или распределения работы.
ОпределенияКак и в определении деревьев, сетью (network) или графом (graph) называется набор узлов (nodes), соединенных ребрами (edges) или связями (links). Для графа, в отличие от дерева, не определено понятие родительского или дочернего узла.
С ребрами сети может быть связано соответствующее направление, тогда в этом случае сеть называется ориентированной сетью (directed network). Для каждой связи можно также определить ее цену (cost). Для сети дорог, например, цена может быть равна времени, которое займет проезд по отрезку дороги, представленному ребром сети. В телефонной сети цена может быть равна коэффициенту электрических потерь в кабеле, представленном связью. На рис. 12.1 показана небольшая ориентированная сеть, в которой числа рядом с ребрами соответствуют цене ребра.
Путем (path) между узлами A и B называется последовательность ребер, которая связывает два этих узла между собой. Если между любыми двумя узлами сети есть не больше одного ребра, то путь можно однозначно описать, перечислив входящие в него узлы. Так как такое описание проще представить наглядно, то пути по возможности описываются таким образом. На рис. 12.1 путь, проходящий через узлы B, E, F, G,E и D, соединяет узлы B и D.
Циклом (cycle) называется путь который связывает узел с ним самим. Путь E, F, G, E на рис. 12.1 является циклом. Путь называется простым (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. Ориентированная сеть с ценой ребер
Сеть называется связной (connected), если между любыми двумя узлами существует хотя бы один путь. В ориентированной сети не всегда очевидно, является ли сеть связной. На рис. 12.2 сеть слева является связной. Сеть справа не является связной, так как не существует пути из узла 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.
Директория OldSrc\Ch12 содержит программы, которые используют представление нумерацией связей. Эти программы немного сложнее понять, но они обычно работают быстрее. Они не описаны в тексте, но использованные в них методы похожи на те, которые применялись в программах, написанных для 4 версии Visual Basic. Например, обе программы Src\Ch12\Paths и OldSrc\Ch12\Paths находят кратчайший маршрут, используя описанный ниже алгоритм установки меток. Основное отличие между ними заключается в том, что первая программа использует коллекции и классы, а вторая — псевдоуказатели и представление нумерацией связей.
Оперирование узлами и связямиКорень дерева — это единственный узел, не имеющий родителя. Можно найти любой узел в сети, начав от корня и следуя по указателям на дочерние узлы. Таким образом, узел представляет основание дерева. Если ввести переменную, которая будет содержать указатель на корневой узел, то впоследствии можно будет получить доступ ко всем узлам в дереве.
Сети не всегда содержат узел, который занимает такое особое положение. В несвязной сети может не существовать способа обойти все узлы по связям, начав с одного узла.
Поэтому программы, работающие с сетями, обычно содержат полный список всех узлов в сети. Программа также может хранить полный список всех связей. При помощи этих списков можно легко выполнить какие‑либо действия над всеми узлами или связями в сети. Например, если программа хранит указатели на узлы и связи в коллекциях 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
Так как эта процедура не обращается ни к одному узлу дважды, то коллекция обходимых связей не содержит циклов и образует дерево.
Если сеть является связной, то дерево будет обходить все узлы сети. Так как это дерево охватывает все узлы сети, то оно называется остовным деревом (spanning tree). На рис. 12.4 показана небольшая сеть с остовным деревом с корнем в узле 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
Наименьшие остовные деревьяЕсли задана сеть с ценой связей, то наименьшим остовным деревом (minimal spanning tree) называется остовное дерево, в котором суммарная цена всех связей в дереве будет наименьшей. Наименьшее остовное дерево можно использовать, чтобы связать все узлы в сети путем с наименьшей ценой.
Например, предположим, что требуется разработать телефонную сеть, которая должна соединить шесть городов. Можно проложить магистральный кабель между всеми парами городов, но это будет неоправданно дорого. Меньшую стоимость будет иметь решение, при котором города будут соединены связями, которые содержатся в наименьшем остовном дереве. На рис. 12.5 показаны шесть городов, каждые два из которых соединены магистральным кабелем. Жирными линиями нарисовано наименьшее остовное дерево.
Заметьте, что сеть может иметь несколько наименьших остовных деревьев. На рис. 12.6 показаны два изображения сети с двумя различными наименьшими остовными деревьями, которые нарисованы жирными линиями. Полная цена обоих деревьев равна 32.
@Рис. 12.5. Магистральные телефонные кабели, связывающие шесть городов
========320
@Рис. 12.6. Два различных наименьших остовных дерева для одной сети
Существует простой алгоритм поиска наименьшего остовного дерева для сети. Вначале поместим в остовное дерево любой узел. Затем найдем связь с наименьшей ценой, которая соединяет узел в дереве с узлом, который еще не помещен в дерево. Добавим эту связь и соответствующий узел в дерево. Затем эта процедура повторяется до тех пор, пока все узлы не окажутся в дереве.
Этот алгоритм похож на эвристику восхождения на холм, описанную в 8 главе. На каждом шаге оба алгоритма изменяют решение, пытаясь его максимально улучшить. Алгоритм остовного дерева на каждом шаге выбирает связь с наименьшей ценой, которая добавляет новый узел в дерево. В отличие от эвристики восхождения на холм, которая не всегда находит наилучшее решение, этот алгоритм гарантированно находит наименьшее остовное дерево.
Подобные алгоритмы, которые находят глобальный оптимум, при помощи серии локально оптимальных приближений называются поглощающими алгоритмами[RV20] (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
Кратчайший маршрутАлгоритмы поиска кратчайшего маршрута, которые обсуждаются в следующих разделах, находят все кратчайшие пути из заданной точки до всех остальных точек сети, при этом предполагается, что сеть является связанной. Набор связей, используемый всеми кратчайшими маршрутами, называется деревом кратчайшего маршрута (shortest path tree).
На рис. 12.8 показано дерево, в котором дерево кратчайшего маршрута с корнем в узле A нарисовано жирной линией. Это дерево изображает кратчайший маршрут из узла A до всех остальных узлов в сети. Например, кратчайший маршрут из узла A в узел F проходит через узлы A, C, E, F.
Многие алгоритмы поиска кратчайшего маршрута начинают с пустого дерева, к которому затем добавляется по одной связи до тех пор, пока дерево не будет заполнено. Эти алгоритмы можно разбить на две категории в соответствии со способом выбора следующей связи, которая должна быть добавлена к растущему дереву кратчайшего маршрута.
Алгоритмы установки меток (label setting) всегда выбирают связь, которая гарантированно окажется частью конечного кратчайшего маршрута. Этот метод работает аналогично методу поиска наименьшего остовного дерева. Если связь добавлена в дерево, то она не будет удалена позже.
Алгоритмы коррекции меток (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
Оба алгоритма установки и коррекции меток используют список возможных связей, в котором находятся связи, которые могут быть добавлены в дерево кратчайшего маршрута, но они по‑разному оперируют этим списком. Алгоритм установки меток всегда выбирает связь, которая обязательно окажется частью дерева кратчайшего маршрута. Алгоритм коррекции меток выбирает элемент, который находится на вершине списка.
Установка метокВ начале этого алгоритма значения поля 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 в списке возможных узлов. Некоторые варианты этого алгоритма используют другие структуры данных для хранения списка возможных узлов. Например, можно было бы использовать упорядоченный связный список. При использовании этого метода потребуется только один шаг для того, чтобы найти следующий узел, который будет добавлен к дереву кратчайшего маршрута. Этот список будет всегда упорядоченным, поэтому узел на вершине списка всегда будет искомым узлом.
Это облегчит поиск нужного узла в списке, но усложнит добавление узла в него. Вместо того чтобы просто помещать узел в начало списка, его придется поместить в нужную позицию.
Иногда также требуется перемещать узлы в списке. Если в результате добавления узла в дерево кратчайшего маршрута уменьшилось кратчайшее расстояние до другого узла, который уже был в списке, то нужно переместить этот элемент ближе к вершине списка.
Предыдущий алгоритм и этот его новый вариант представляют собой два крайних случая управления списком возможных узлов. Первый алгоритм совсем не упорядочивает список и тратит достаточно много времени на поиск узлов в сети. Второй тратит много времени на поддержание упорядоченности списка, но может очень быстро выбирать из него узлы. Другие варианты используют промежуточные стратегии.
Например, можно использовать для хранения списка возможных узлов приоритетную очередь на основе пирамид, тогда можно будет просто выбрать следующий узел с вершины пирамиды. Вставка нового узла в пирамиду и ее переупорядочение будет выполняться быстрее, чем аналогичные операции для упорядоченного связного списка. Другие стратегии используют сложные схемы организации блоков для того, чтобы упростить поиск возможных узлов.
Некоторые из этих вариантов достаточно сложны. Из‑за этой их сложности эти алгоритмы для небольших сетей часто выполняются медленнее, чем более простые алгоритмы. Тем не менее, для очень больших сетей или сетей, в которых каждый узел имеет очень большое число связей, выигрыш от применения этих алгоритмов может стоить дополнительного усложнения.
Коррекция метокКак и алгоритм установки меток, этот алгоритм начинает с обнуления значения поля 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 получат свои конечный значения, он должен позднее скорректировать значения этих полей и снова поместить узел в список возможных узлов. Чем чаще алгоритм помещает узлы назад в список возможных узлов, тем больше времени это занимает.
Варианты этого алгоритма пытаются повысить качество выбора узлов без большого усложнения алгоритма. Один из методов, который неплохо работает на практике, состоит в том, чтобы добавлять узлы одновременно в начало и конец списка возможных узлов. Если узел раньше не попадал в список возможных узлов, алгоритм, как обычно, добавляет его в конец списка. Если узел уже был раньше в списке возможных узлов, но сейчас его там нет, алгоритм вставляет его в начало списка. При этом повторное обращение к узлу выполняется практически сразу, возможно при следующем же обращении к списку.
Идея, заключенная в таком подходе, состоит в том, чтобы если алгоритм совершает ошибку, она исправлялась как можно быстрее. Если ошибка не будет исправлена в течение достаточно долгого времени, алгоритм может использовать неправильную информацию для построения длинных ложных путей, которые затем придется исправлять. Благодаря быстрому исправлению ошибок, алгоритм может уменьшить число неверных путей, которые придется перестроить. В наилучшем случае, если все соседние узлы все еще находятся в списке возможных узлов, повторная проверка этого узла до проверки соседей предотвратит построение неверных путей.
Другие задачи поиска кратчайшего маршрутаОписанные выше алгоритмы поиска кратчайшего маршрута вычисляли все кратчайшие пути из корневого узла до всех остальных узлов в сети. Существует множество других типов задачи нахождения кратчайшего маршрута. В этом разделе обсуждаются три из них: двухточечный кратчайший маршрут[RV21] (point‑to‑point shortest path), кратчайший маршрут для всех пар(all pairs shortest path) и кратчайший маршрут со штрафами за повороты.
Двухточечный кратчайший маршрутВ некоторых приложениях может понадобиться найти кратчайший маршрут между двумя точками, при этом остальные пути в полном дереве кратчайшего маршрута не важны. Простой способ решить эту задачу — вычислить полное дерево кратчайшего маршрута при помощи метода установки или коррекции меток, а затем выбрать из дерева кратчайший путь между двумя точками.
Другой способ заключается в использовании метода установки меток, который останавливался бы, когда будет найден путь к конечному узлу. Алгоритм установки меток добавляет к дереву кратчайшего маршрута только те пути, которые обязательно должны в нем находиться, следовательно, в тот момент, когда алгоритм добавит конечный узел в дерево, будет найден искомый кратчайший маршрут. В алгоритме, который обсуждался раньше, это происходит, когда алгоритм удаляет конечный узел из списка возможных узлов.
=======334
Единственное изменение требуется внести в часть алгоритма установки меток, которая выполняется сразу же после того, как алгоритм находит в списке возможных узлов узел с наименьшим значением Dist. Перед удалением узла из списка возможных узлов, алгоритм должен проверить, не является ли этот узел искомым. Если это так, то дерево кратчайшего маршрута уже содержит кратчайший маршрут между начальным и конечным узлами, и алгоритм может закончить работу.
' Найти ближайший к корню узел в списке возможных узлов.
:
' Проверить, является ли этот узел искомым.
If node = destination Then Exit Do
' Добавить этот узел в дерево кратчайшего маршрута.
:
На практике, если две точки в сети расположены далеко друг от друга, то этот алгоритм обычно будет выполняться дольше, чем займет вычисление полного дерева кратчайшего маршрута. Алгоритм выполняется медленнее из‑за того, что в каждом цикле выполнения алгоритма проверяется, достигнут ли искомый узел. С другой стороны, если узлы расположены рядом, то выполнение этого алгоритма может потребовать намного меньше времени, чем построение полного дерева кратчайшего маршрута.
Для некоторых сетей, таких как сеть улиц, можно оценить, насколько близко расположены две точки, и затем решить, какую версию алгоритма выбрать. Если сеть содержит все улицы южной Калифорнии, и две точки расположены на расстоянии 10 миль, следует использовать версию, которая останавливается после того, как найдет конечный узел. Если же точки удалены друг от друга на 100 миль, возможно, меньше времени займет вычисление полного дерева кратчайшего маршрута.
Вычисление кратчайшего маршрута для всех парВ некоторых приложениях может потребоваться быстро найти кратчайший маршрут между всеми парами узлов в сети. Если нужно вычислить большую часть из 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. Иначе старый кратчайший маршрут между этими двумя узлами остался бы таковым.
Штрафы за поворотыВ некоторых сетях, в особенности сетях улиц, бывает полезно добавить штраф и запреты на повороты (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
Составление плана работ с использованием метода критического путиВо многих задачах, в том числе в больших программных проектах, определенные действия должны быть выполнены раньше других. Например, при строительстве дома до установки фундамента нужно вырыть котлован, фундамент должен застыть до того, как начнется возведение стен, каркас дома должен быть собран прежде, чем можно будет выполнять проводку электричества, водопровода и кровельные работы и так далее.
Некоторые из этих задач могут выполняться одновременно, другие должны выполняться последовательно. Например, можно одновременно проводить электричество и прокладывать водопровод.
Критическим путем (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
Максимальный потокВо многих сетях связи имеют кроме цены, еще и пропускную способность (capacity). Через каждый узел сети может проходить поток (flow), который не превышает ее пропускной способности. Например, по улицам может проехать только определенной число машин. Сеть с заданными пропускными способностями ее связей называется нагруженной сетью [RV22] (capacitated network). Если задана нагруженная сеть, задача о максимальном потоке заключается в определении наибольшего возможного потока через сеть из заданного источника (source) в заданный сток (sink).
На рис. 12.20 показана небольшая нагруженная сеть. Числа рядом со связями в этой сети — это не цена связи, а ее пропускная способность. В этом примере максимальный поток, равный 4, получается, если две единицы потока направляются по пути A, B, E,F и еще две — по пути A, C, D, F.
Описанный здесь алгоритм начинается с того, что поток во всех связях равен нулю и затем алгоритм постепенно увеличивает поток, пытаясь улучшить найденное решение. Алгоритм завершает работу, если нельзя улучшить имеющееся решение.
Для поиска путей способов увеличения полного потока, алгоритм проверяет остаточную пропускную способность (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. Потоки в сети
Сеть, состоящая из всех связей с положительной остаточной пропускной способностью, называется остаточной сетью (residual network). На рис. 12.21 показана сеть с рис. 12.20, каждой связи в которой присвоен поток. Для каждой связи, первое число равно потоку через связь, а второе — ее пропускной способности. Надпись «1/2», например, означает, что поток через связь равен 1, и ее пропускная способность равна 2. Связи, поток через которые больше нуля, нарисованы жирными линиями.
На рис. 12.22 показана остаточная сеть, соответствующая потокам на рис. 12.21. Нарисованы только связи, которые действительно могут иметь остаточную пропускную способность. Например, между узлами A и D не нарисовано ни одной связи. Исходная сеть не содержит связи A‑D или D‑A, поэтому эти связи всегда будут иметь нулевую остаточную пропускную способность.
Одно из свойств остаточных сетей состоит в том, что любой путь, использующий связи с остаточной пропускной способностью больше нуля, который связывает источник со стоком, дает способ увеличения потока в сети. Так как этот путь дает способ увеличения или расширения потока в сети, он называется расширяющим путем (augmenting path). На рис. 12.23 показана остаточная сеть с рис. 12.22 с расширяющим путем, нарисованным жирной линией.
Чтобы обновить решение, используя расширяющий путь, найдем наименьшую остаточную пропускную способность в пути. Затем скорректируем потоки в пути в соответствии с этим значением. Например, на рис. 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 показано окно программы, на котором изображены потоки в небольшой сети.
Приложения максимального потокаВычисления максимального потока используются во многих приложениях. Хотя для многих сетей может быть важно знать максимальный поток, этот метод часто используется для получения результатов, которые на первый взгляд имеют отдаленное отношение к пропускной способности сети.
Непересекающиеся путиБольшие сети связи должны обладать избыточностью (redundancy). Для заданной сети, например такой, как на рис. 12.27, может потребоваться найти число непересекающихся путей из источника к стоку. При этом, если между двумя узлами сети есть множество непересекающихся путей, все связи в которых различны, то соединение между этими узлами останется, даже если несколько связей в сети будут разорваны.
Можно определить число различных путей, используя метод вычисления максимального потока. Создадим сеть с узлами и связями, соответствующими узлам и связям в коммуникационной сети. Присвоим каждой связи единичную пропускную способность.
@Рис. 12.26. Программа Flow
=====351
@Рис. 12.27. Сеть коммуникаций
Затем вычислим максимальный поток в сети. Максимальный поток будет равен числу различных путей от источника к стоку. Так как каждая связь может нести единичный поток, то ни один из путей, использованных при вычислении максимального потока, не может иметь общей связи.
При более строгом определении избыточности можно потребовать, чтобы различные пути не имели ни общих связей, ни общих узлов. Немного изменив предыдущую сеть, можно использовать вычисление максимального потока для решения и этой задачи.
Разделим каждый узел за исключением источника и стока на два узла, соединенных связью единичной пропускной способности. Соединим первый из полученных узлов со всеми связями, входящими в исходный узел. Все связи, выходящие из исходного узла, присоединим ко второму полученному после разбиения узлу. На рис. 12.28 показана сеть с рис. 12.27, узлы на которой разбиты таким образом. Теперь найдем максимальный поток для этой сети.
Если путь, использованный для вычисления максимального потока, проходит через узел, то он может использовать связь, которая соединяет два получившихся после разбиения узла. Так как эта связь имеет единичную пропускную способность, никакие два пути, полученные при вычислении максимального потока, не могут пройти по этой связи между узлами, поэтому в исходной сети никакие два пути не могут использовать один и тот же узел.
@Рис. 12.28. Коммуникационная сеть после преобразования
======352
@Рис. 12.29. Сеть распределения работы
Распределение работыПредположим, что имеется группа сотрудников, каждый из которых обладает определенными навыками. Предположим также, что существует ряд заданий, которые требуют привлечения сотрудника, обладающего заданным набором навыков. Задача распределения работы (work assignment) состоит в том, чтобы распределить работу между сотрудниками так, чтобы каждое задание выполнял сотрудник, имеющий соответствующие навыки.
Чтобы свести эту задачу к вычислению максимального потока, создадим сеть с двумя столбцами узлов. Каждый узел в левом столбце представляет одного сотрудника. Каждый узел в правом столбце представляет одно задание.
Затем сравним навыки каждого сотрудника с навыками, необходимыми для выполнения каждого из заданий. Создадим связь между каждым сотрудником и каждым заданием, которое он способен выполнить, и присвоим всем связям единичную пропускную способность.
Создадим узел‑источник и соединим его с каждым из сотрудников связью единичной пропускной способности. Затем создадим узел‑сток и соединим с ним каждое задание, снова при помощи связей с единичной пропускной способностью. На рис. 12.29 показана соответствующая сеть для задачи распределения работы с четырьмя сотрудниками и четырьмя заданиями.
Теперь найдем максимальный поток из источника в сток. Каждая единица потока должна пройти через один узел сотрудника и один узел задания. Этот поток представляет распределение работы для этого сотрудника.
@Рис. 12.30. Программа Work
=======353
Если сотрудники обладают соответствующими навыками для выполнения всех заданий, то вычисления максимального потока распределят их все. Если невозможно выполнить все задания, то в процессе вычисления максимального потока работа будет распределена так, чтобы было выполнено максимально возможное число заданий.
Программа Work использует этот алгоритм для распределения работы между сотрудниками. Введите фамилии сотрудников и их навыки в текстовом поле слева, а задания, которые требуется выполнить и требующиеся для них навыки в текстовом поле посередине. После того, как вы нажмете на кнопку Go (Начать), программа распределит работу между сотрудниками, используя для этого сеть максимального потока. На рис. 12.30 показано окно программы с полученным распределением работы.
РезюмеНекоторые сетевые алгоритмы можно применить непосредственно к сетеподобным объектам. Например, можно использовать алгоритм поиска кратчайшего маршрута для нахождения наилучшего пути в уличной сети. Для определения наименьшей стоимости построения сети связи или соединения городов железными дорогами можно использовать минимальное остовное дерево.
Многие другие сетевые алгоритм находят менее очевидные применения. Например, можно использовать алгоритмы поиска кратчайшего маршрута для разбиения на районы, составления плана работ методом кратчайшего пути, или графика коллективной работы. Алгоритмы вычисления максимального потока можно использовать для распределения работы. Эти менее очевидные применения сетевых алгоритмов обычно оказываются более интересными и перспективными.
======354
Глава 13. Объектно‑ориентированные методыИспользование функций и подпрограмм позволяет программисту разбить код большой программы на части. Массивы и определенные пользователем типы данных позволяют сгруппировать элементы данных так, чтобы упросить работу с ними.
Классы, которые впервые появились в 4-й версии Visual Basic, позволяют программисту по‑новому сгруппировать данные и логику работы программы. Класс позволяет объединить в одном объекте данные и методы работы с ними. Этот новый подход к управлению сложностью программ позволяет взглянуть на алгоритмы с другой точки зрения.
В этой главе рассматриваются вопросы объектно‑ориентированного программирования, возникающие при применении классов Visual Basic. В ней описаны преимущества объектно‑ориентированного программирования (ООП) и показано, какую выгоду можно получить от их применения в программах на языке Visual Basic. Затем в главе рассматривается набор полезных объектно‑ориентированных примеров, которые вы можете использовать для управления сложностью ваших приложений.
Преимущества ООПК традиционным преимуществам объектно‑ориентированного программирования относятся инкапсуляция или скрытие (encapsulation), полиморфизм (polymorphism) и повторное использование (reuse). Реализация их в классах Visual Basic несколько отличается от того, как они реализованы в других объектно‑ориентированных языках. В следующих разделах рассматриваются эти преимущества ООП и то, как можно ими воспользоваться в программах на Visual Basic.
ИнкапсуляцияОбъект, определенный при помощи класса, заключает в себе данные, которые он содержит. Другие части программы могут использовать объект для оперирования его данными, не зная о том, как хранятся или изменяются значения данных. Объект предоставляет открытые (public) процедуры, функции, и процедуры изменения свойств, которые позволяют программе косвенно манипулировать или просматривать данные. Так как при этом данные являются абстрактными с точки зрения программы, это также называется абстракцией данных (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 главе, непосредственно используют данные объекта. Указатели, которые связывают узлы в сети друг с другом, составляют неотъемлемую часть алгоритмов. Было бы бессмысленно менять способ хранения этих указателей.
И, наконец, благодаря использованию открытых значений данных, код становится проще. Это позволяет вам сконцентрироваться на алгоритмах, и этому не мешают лишние процедуры работы со свойствами.
ПолиморфизмВторое преимущество объектно‑ориентированного программирования — это полиморфизм (polymorphism), что означает «имеющий множество форм». В Visual Basic это означает, что один объект может иметь различный формы в зависимости от ситуации. Например, следующий код представляет собой подпрограмму, которая может принимать в качестве параметра любой объект. Объект obj может быть формой, элементом управления, или объектом определенного вами класса.
Private Sub ShowName(obj As Object)
MsgBox TypeName(obj)
End Sub
Полиморфизм позволяет создавать процедуры, которые могут работать буквально со всеми типами объектов. Но за эту гибкость приходится платить. Если определить обобщенный (generic) объект, как в этом примере, то Visual Basic не сможет определить, какие типы действий сможет выполнять объект, до запуска программы.
========357
Если Visual Basic заранее знает, с объектом какого типа он будет иметь дело, он может выполнить предварительные действия для того, чтобы более эффективно использовать объект. Если используется обобщенный (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 (Реализует) позволяет программе использовать полиморфизм без использования обобщенных объектов. Например, программа может определить интерфейс 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 раза больше). Использование интерфейса является не таким быстрым, как использование ссылки на определенный объект, но намного быстрее, чем использование обобщенных объектов.
Наследование и повторное использованиеПроцедуры и функции поддерживают повторное использование (reuse). Вместо того, чтобы каждый раз писать код заново, можно поместить его в подпрограмму, тогда вместо блока кода можно просто подставить вызов подпрограммы.
Аналогично, определение процедуры в классе делает ее доступной во всей программе. Программа может использовать эту процедуру, используя объект, который является экземпляром класса.
В среде программистов, использующих объектно‑ориентированный подход, под повторным использованием обычно подразумевается нечто большее, а именно наследование (inheritance). В объектно‑ориентированных языках, таких как C++ или Delphi, один класс может порождать (derive) другой. При этом второй класс наследует (inherits) всю функциональность первого класса. После этого можно добавлять, изменять или убирать какие‑либо функции из класса‑наследника. Это также является формой повторного использования кода, поскольку при этом программисту не нужно заново реализовать функции родительского класса, для того, чтобы использовать их в классе‑наследнике.
Хотя Visual Basic и не поддерживает наследование непосредственно, можно добиться примерно тех же результатов, используя ограничение (containment) или делегирование (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.
Управляющие объектыУправляющие объекты (command) также называются объектами действия (action objects), функций (function objects) или функторами (functors). Управляющий объект представляет какое‑либо действие. Программа может использовать метод Execute (Выполнить) для выполнения объектом этого действия. Программе не нужно знать ничего об этом действии, она знает только, что объект имеет метод Execute.
Управляющие объекты могут иметь множество интересных применений. Программа может использовать управляющий объект для реализации:
· Настраиваемых элементов интерфейса;
· Макрокоманд;
· Ведения и восстановления записей;
· Функций «отмена» и «повтор».
Чтобы создать настраиваемый интерфейс, форма может содержать управляющий массив кнопок. Во время выполнения программы форма может загрузить надписи на кнопках и создать соответствующий набор управляющих объектов. Когда пользователь нажимает на кнопку, обработчику событий кнопки нужно всего лишь вызвать метод Execute соответствующего управляющего объекта. Детали происходящего находятся внутри класса управляющего объекта, а не в обработчике событий.
Программа Command1 использует управляющие объекты для создания настраиваемого интерфейса для нескольких не связанных между собой функций. При нажатии на кнопку программа вызывает метод Execute соответствующего управляющего объекта.
Программа может использовать управляющие объекты для создания определенных пользователем макрокоманд. Пользователь задает последовательность действий, которые программа запоминает в коллекции в виде управляющих объектов. Когда затем пользователь вызывает макрокоманду, программа вызывает методы Execute объектов, которые находятся в коллекции.
Управляющие объекты могут обеспечивать ведение и восстановление записей. Управляющий объект может при каждом своем вызове записывать информацию о себе в лог‑файл. Если программа аварийно завершит работы, она может затем использовать записанную информацию для восстановления управляющих объектов и выполнения их для повторения последовательности команд, которая выполнялась до сбоя программы.
И, наконец, программа может использовать набор управляющих объектов для реализации функций отмены (undo) и повтора (redo).
=========362
Программа использует переменную LastCmd для отслеживания последнего управляющего объекта в коллекции. Если вы выбираете команду Undo (Отменить) в меню Draw (Рисовать), то программа уменьшает значение переменной LastCmd на единицу. Когда программа потом выводит рисунок, она вызывает только объекты, стоящие до объекта с номером LastCmd.
Если вы выбираете команду Redo (Повторить) в меню Draw, то программа увеличивает значение переменной LastCmd на единицу. Когда программа выводит рисунок, она выводит на один объект больше, чем раньше, поэтому отображается восстановленный рисунок.
При добавлении новой фигуры программа удаляет любые команды из коллекции, которые лежат после позиции LastCmd,. затем добавляет новую команду рисования в конце и запрещает команду Redo, так как нет команд, которые можно было бы отменить. На рис. 13.1 показано окно программы Command2 после добавления новой фигуры.
Контролирующий объектКонтролирующий объект (visitor object) проверяет все элементы в составном объекте (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 (Обход в глубину) для обхода элементов в различном порядке.
ИтераторИтератор обеспечивает другой метод обхода элементов в составном объекте. Объект‑итератор обращается к составному объекту для обхода его элементов, и в этом случае итератор определяет порядок, в котором проверяются элементы. С составным классом могут быть сопоставлены несколько классов итераторов для того, чтобы выполнять различные обходы элементов составного класса.
Чтобы выполнить обход элементов, итератор должен представлять порядок, в котором элементы записаны, чтобы определить порядок их обхода. Если составной класс представляет собой связный список, то объект‑итератор должен знать, что элементы находятся в связном списке, и должен уметь перемещаться по списку. Так как итератору известны детали внутреннего устройства списка, это нарушает скрытие данных составного объекта.
Вместо того чтобы каждый класс, которому нужно проверять элементы составного класса, реализовал обход самостоятельно, можно сопоставить составному классу класс итератора. Класс итератора должен содержать простые процедуры MoveFirst (Переместиться в начало), MoveNext (Переместиться на следующий элемент), EndOfList (Переместиться в конец списка) и CurrentItem (Текущий элемент) для обеспечения косвенного доступа к списку. Новые классы могут включать в себя экземпляр класса итератора и использовать его методы для обхода элементов составного класса. На рис. 13.2 схематически показано, как новый объект использует объект‑итератор для связи со списком.
Программа IterTree, описанная ниже, использует итераторы для обхода полного двоичного дерева. Класс Traverser (Обходчик) содержит ссылку на объект‑итератор. Они использует обеспечиваемые итератором процедуры MoveFirst, MoveNext, CurrentCaption и EndOfTree для получения списка узлов в дереве.
@Рис. 13.2. Использование итератора для косвенной связи со списком
=========364
Итераторы нарушают скрытие соответствующих им составных объектов, в отличие от новых классов, которые содержат итераторы. Для того, чтобы избавиться от потенциальной путаницы, можно рассматривать итератор как надстройку над составным объектом.
Контролирующие объекты и итераторы обеспечивают выполнение похожих функций, используя различные подходы. Так как парадигма контролирующего объекта оставляет детали составного объекта скрытыми внутри него, она обеспечивает лучшую инкапсуляцию. Итераторы могут быть полезны, если порядок обхода может часто изменяться или он должен переопределяться во время выполнения программы. Например, составной объект может использовать методы порождающего класса (который описан позднее) для создания объекта‑итератора в процессе выполнения программы. Содержащий итератор класс не должен знать, как создается итератор, он всего лишь использует методы итератора для доступа к элементам составного объекта.
Дружественный классМногие классы тесно работают с другими. Например, класс итератора тесно взаимодействует с составным классом. Для выполнения работы, итератор должен нарушать скрытие составного класса. При этом, хотя эти связанные классы иногда должны нарушать скрытие данных друг друга, другие классы должны не иметь такой возможности.
Дружественный класс (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
При использовании этого метода все еще можно нарушить скрытие данных объекта. Программа может создать объект дружественного класса и использовать его в качестве параметра, чтобы обмануть процедуры другого объекта. Тем не менее, это достаточно громоздкий процесс, и маловероятно, что разработчик сделает так случайно.
ИнтерфейсВ этой парадигме один из объектов выступает в качестве интерфейса (interface) между двумя другими. Один объект может использовать свойства и методы первого объекта для взаимодействия со вторым. Интерфейс иногда также называется адаптером (adapter), упаковщиком (wrapper), или мостом (bridge). На рис. 13.3 схематически изображена работа интерфейса.
Интерфейс позволяет двум объектам на его концах изменяться независимо. Например, если свойства объекта слева на рис. 13.3 изменятся, интерфейс должен быть изменен, а объект справа — нет.
В этой парадигме процедуры, используемые двумя объектами, поддерживаются разработчиками, которые отвечают за эти объекты. Разработчик, который реализует левый объект, также занимается реализацией процедур интерфейса, которые взаимодействуют с левым объектом.
ФасадФасад (Facade) аналогичен интерфейсу, но он обеспечивает простой интерфейс для сложного объекта или группы объектов. Фасад также иногда называется упаковщиком (wrapper). На рис. 13.4. показана схема работы фасада.
Разница между фасадом и интерфейсом в основном умозрительная. Основная задача интерфейса — обеспечение косвенного взаимодействия между объектами, чтобы они могли развиваться независимо. Основная задача фасада — облегчение использования каких‑то сложных вещей за счет скрытия деталей.
Порождающий объектПорождающий объект (Factory) — это объект, который создает другие объекты. Порождающий метод — это процедура или функция, которая создает объект.
Порождающие объекты наиболее полезны, если два класса должны тесно работать вместе. Например, составной класс может содержать порождающий метод, который создает итераторы для него. Порождающий метод может инициализировать итератор таким образом, чтобы он был готов к работе с экземпляром класса, который его создал.
@Рис. 13.3 Интерфейс
========366
@Рис. 13.4. Фасад
Программа IterTree создает полное двоичное дерево, записанное в массиве. После нажатия на одну из кнопок, задающих направление обхода, программа создает объект Traverser (Обходчик). Она также использует один из порождающих методов дерева для создания соответствующего итератора. Объект Traverser использует итератор для обхода дерева и вывода списка узлов в правильном порядке. На рис. 13.5 приведено окно программы IterTree, показывающее обратный обход дерева.
Единственный объектЕдинственный объект (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
Преобразование в последовательную формуМногие приложения сохраняют объекты и восстанавливают их позднее. Например, приложение может сохранять копию своих объектов в текстовом файле. При следующем запуске программы, она считывает это файл и загружает объекты.
Объект может содержать процедуры, которые считывают и записывают его в файл. Общий подход может заключаться в том, чтобы создать процедуры, которые сохраняют и восстанавливают данные объекта, используя строку. Поскольку запись данных объекта в одной строке преобразует объект в последовательность символов, этот процесс иногда называется преобразованием в последовательную форму (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
Парадигма Модель/Вид/Контроллер.Парадигма Модель/Вид/Контроллер (МВК) (Model/View/Controller) позволяет программе управлять сложными соотношениями между объектами, которые сохраняют данные, объектами, которые отображают их на экране, и объектами, которые оперируют данными. Например, приложение работы с финансами может выводить данные о расходах в виде таблицы, секторной диаграммы, или графика. Если пользователь изменяет значение в таблице, приложение должно автоматически обновить изображение на экране. Может также понадобиться записать измененные данные на диск.
Для сложных систем управление взаимодействием между объектами, которые хранят, отображают и оперируют данными, может быть достаточно запутанным. Парадигма Модель/Вид/Контроллер разбивает взаимодействия, так что можно работать с ними по отдельности, при этом используются три типа объектов: модели, виды, и контроллеры.
МоделиМодель (Model) представляет данные, обеспечивая методы, которые другие объекты могут использовать для проверки и изменения данных. В приложении работы с финансовыми данными, модель содержит данные о расходах. Она обеспечивает процедуры для просмотра и изменения значений расходов и ввода новых значений. Она также может обеспечивать функции для вычисления суммарных величин, таких как полные издержки, расходы по подразделениям, средние расходы за месяц, и так далее
Модель включает в себя набор видов, которые отображают данные. При изменении данных, модель сообщает об этом видам, которые изменяют изображение на экране соответствующим образом.
ВидыВид (View) отображает представленные в модели данные. Так как виды обычно выводят данные для просмотра пользователем, иногда удобнее создавать их, используя форму, а не класс.
Когда программа создает вид, она должна добавить его к набору видов модели.
КонтроллерыКонтроллер (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 главе, создают файлы данных в директории Src\Ch9/. Все эти файлы имеют расширение “.DAT”, поэтому вы можете найти и удалить их в случае необходимости.
Программы примеров предназначены только для демонстрационных целей, чтобы помочь вам понять определенные концепции алгоритмов, и в них не почти не реализована обработка ошибок или проверка данных. Если вы введете неправильное решение, программа может аварийно завершить работу. Если вы не знаете, какие данные допустимы, воспользуйтесь для получения инструкций меню Help (Помощь) программы.
========374
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
Стр: 19
[RP1]Вариант – временная и ёмкостная сложность
Page: 31
[RP2]Вариант – перегрузкой памяти.
Стр: 43
[RP3]Вероятно, жаргонизм, может выбросить вообще?
Стр: 43
[RP4]Вариант: «сборка мусора»
Стр: 44
[RP5]Исправлена опечатка в книге – см. http://www.vb-helper.com/vbaupd.htm
Ñòð: 83
[RV6]Может есть более удачный вариант термина?
Ñòð: 138
[RV7]Вариант: многопоточные деревья.
Ñòð: 138
[RV8]Варианты: TRIE-структуры, ТРАЙ-структуры
Ñòð: 138
[RV9]Варианты: деревья квадрантов, Q-деревья.
Ñòð: 140
[RV10]Вариант: тернарными
Ñòð: 141
[RV11]Исправлена ошибка в исходном листинге - Left заменено на Right
Стр: 165
[RP12]Варианты: деревья квадратов, Q-деревья
Стр: 190
[RV13]Исправлена ошибка – в оригинале буквы элементов не соответствуют рисунку.
Стр: 212
[RV14]Вариант: задача о ранце
Стр: 214
[RV15]Исправлена смысловая ошибка в оригинале — вместо узла B в нем написано узел C.
Стр: 300
[RV16]Варианты ‑ последовательностью проверок, последовательностью проб
Стр: 303
[RV17]Ошибка в оригинале - на рисунке приведен скриншот другой программы, искомое значение не соответствует тексту.
Стр: 304
[RV18]Ошибка в оригинале - на рисунке приведен скриншот другой программы.
Стр: 314
[RV19]Возможно, имеется в виду хеш‑адресация.
Стр: 339
[RV20]Вариант - «жадными» алгоритмами.
Стр: 352
[RV21]Вариант: кратчайший маршрут между двумя точками
Стр: 361
[RV22]Вариант: потоковой сетью (flow network)
Стр: 378
[RP23]Не уверен в точности терминов.
... разработки программ, но и разработку пакетов прикладных программ. Эти разработки должны обеспечивать высокое качество и вестись примерно так же, как и выпуск промышленной продукции. Достижения компьютерной техники 1. Универсальные настольные ПК Что такое настольный компьютер, объяснять никому не надо — это любимое молодежью устройство, чтобы красиво набирать тексты рефератов, а ...
... и дальнейшего использования «Автоматизированной системы агентства недвижимости» на предприятии. 1.4 Постановка цели и подзадач автоматизации. Критерии достижения цели 1.4.1 Экономическая сущность задачи Экономической сущностью задачи автоматизации риэлтерской деятельности агентства недвижимости «Елена» является повышение результативности труда посредством автоматизации ...
... по соответствующему полю). В окне Конструктора таблиц созданные связи отображаются визуально, их легко изменить, установить новые, удалить (клавиша Del). 1 Многозвенные информационные системы. Модель распределённого приложения БД называется многозвенной и её наиболее простой вариант – трёхзвенное распределённое приложение. Тремя частями такого приложения являются: ...
... доступа с записью равной байту. Такие файлы называются двоичными. Файлы прямого доступа незаменимы при написании программ, которые должны работать с большими объемами информации, хранящимися на внешних устройствах. В основе обработке СУБД лежат файлы прямого доступа. Кратко изложим основные положения работы с файлами прямого доступа. 1). Каждая запись в файле прямого доступа имеет свой номер ...
0 комментариев