Файл: Алгоритмы и структуры данныхНовая версия для Оберона cdмосква, 2010Никлаус ВиртПеревод с английского под редакцией.pdf
ВУЗ: Не указан
Категория: Не указан
Дисциплина: Не указана
Добавлен: 30.11.2023
Просмотров: 227
Скачиваний: 3
ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.
СОДЕРЖАНИЕ
1.8. Поиск
Задача поиска – одна из наиболее часто встречающихся в программировании.
Она также дает прекрасные возможности показать применение рассмотренных структур данных. Есть несколько основных вариаций на тему поиска, и здесь при%
думано множество алгоритмов. Основное предположение, которое мы делаем в дальнейшем изложении, состоит в том, что набор данных, в котором ищется за%
данное значение, фиксирован. Будем предполагать, что этот набор
N
элементов представлен массивом, скажем a: ARRAY N OF Item
Обычно элементы являются записями, одно из полей которых играет роль ключа. Тогда задача состоит в нахождении элемента, у которого поле ключа равно заданному значению x
, которое еще называют аргументом поиска. Найденный индекс i
, удовлетворяющий условию a[i].key = x
, позволит обратиться к другим полям найденного элемента. Поскольку нам здесь интересна только задача поис%
ка, но не данные, ради которых производится поиск, мы будем предполагать, что тип
Item состоит только из ключа, то есть сам является ключом.
Поиск
Фундаментальные структуры данных
50
1.8.1. Линейный поиск
Если нет никакой дополнительной информации о данных, то очевидное реше%
ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%
стно как линейный поиск. Поиск прекращается при одном из двух условий:
1. Элемент найден, то есть a
i
= x
2. Просмотрен весь массив, но элемент не найден.
Приходим к следующему алгоритму:
i := 0;
(* ADruS18_ *)
WHILE (i < N) & (a[i] # x) DO INC(i) END
Отметим, что порядок операндов в булевском выражении важен.
До и после каждого шага цикла выполняется следующее условие:
(0
≤ i < N) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Такое условие называется инвариантом. В данном случае инвариант означает,
что для всех значений k
, меньших, чем i
, среди a
k искомого значения x
нет. Заме%
тим, что до и после каждого шага цикла значения i
– разные. Сохранение инвари%
анта при изменении i
имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами
WHILE
и
DO
).
Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%
ного фрагмента):
((i = N) OR (a i
= x )) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Это условие не только является искомым результатом, но еще и подразумева%
ет, что если найден элемент, равный x
, то это первый такой элемент. При этом i = N
означает, что искомого значения не найдено.
Окончание цикла гарантировано, так как величина i
на каждом шаге увели%
чивается и поэтому обязательно достигнет границы
N
после конечного числа ша%
гов; на самом деле после
N
шагов, если искомого значения в массиве нет.
На каждом шаге нужно вычислять булевское выражение и увеличивать ин%
декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%
ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%
печить, помещая дополнительный элемент со значением x
в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%
ствует выходу поиска за границу массива. Теперь массив a
объявляется так:
a: ARRAY N+1 OF INTEGER,
51
и алгоритм линейного поиска с барьером выражается следующим образом:
a[N] := x; i := 0;
(* ADruS18_ *)
WHILE a[i] # x DO INC(i) END
В итоге получается условие, выведенное из того же инварианта, что и ранее:
(a i
= x) & (Ak: 0
≤ k < i : a k
≠ x)
Очевидно, из i = N
следует, что искомое значение не встретилось (не считая значения%барьера).
1.8.2. Поиск делением пополам
Понятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%
раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%
вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%
пользует информацию о том, что массив a
упорядочен, то есть что выполняется условие
A
A
A
A
Ak: 1
≤ k < N : a k–1
≤ a k
Ключевая идея – в том, чтобы выбрать наугад элемент, скажем a
m
, и сравнить его с искомым значением x
. Если он равен x
, то поиск прекращается; если он мень%
ше x
, то можно заключить, что все элементы с индексами, равными или меньшими m
, можно игнорировать в дальнейшем поиске; а если он больше x
, то можно игно%
рировать все значения индекса, большие или равные m
. Это приводит к следую%
щему алгоритму, который носит название поиск делением пополам (binary search);
в нем используются две индексные переменные
L
и
R
, отмечающие в массиве a
левый и правый концы отрезка, в котором искомое значение все еще может быть найдено:
L := 0; R := N–1;
(* ADruS18_ *)
m :=
L R;
WHILE (L <= R) & (a[m] # x) DO
IF a[m] < x THEN
L := m+1
ELSE
R := m–1
END;
m :=
L R
END
Подчеркнем фундаментальное структурное подобие этого алгоритма и алго%
ритма линейного поиска в предыдущем разделе: роль i
теперь играет тройка
L, m,
R
. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%
ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной m
Поиск
Фундаментальные структуры данных
52
Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R < k < N : a k
> x)
откуда выводим для цикла такое постусловие:
((L > R) OR (a m
= x)) & (A
A
A
A
Ak: 0
≤ k < L : a k
< x ) & (A
A
A
A
Ak: R < k < N : a k
> x)
Из него следует, что
((L > R) & (A
A
A
A
Ak: 0
≤ k < N : a k
≠ x)) OR (a m
= x)
Выбор m
, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%
ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log
2
N
, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%
кальное улучшение по сравнению с линейным поиском, где среднее число срав%
нений равно
N/2
Как уже упоминалось, можно заняться устранением дублирования инструк%
ции присваивания m
. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%
нений. Напомним, что число шагов не превышает log N
Более быстрое решение основано на следующем инварианте:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R
≤ k < N : a k
≥ x)
а поиск продолжается, пока два отрезка не будут покрывать весь массив:
L := 0; R := N;
(* ADruS18_ *)
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] < x THEN L := m+1 ELSE R := m END
END
Можно сказать, что теперь ищется не элемент a[m] = x
, а граница, отделяющая все элементы, меньшие x
, от всех прочих.
Условие окончания –
L
≥
R
. Есть ли гарантия его достижения? Чтобы убедить%
ся в этом, нужно показать, что в любых обстоятельствах разность
R–L
умень%
шается на каждом шаге. Условие
L < R
справедливо в начале каждого шага. Тогда
53
среднее арифметическое удовлетворяет условию
L
≤ m < R
. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1
пере%
менной
L
(что увеличивает
L
), либо значению m
переменной
R
(что уменьшает
R
),
и цикл прекращается при
L = R
Однако выполнение инварианта вместе с условием
L = R
еще не гарантирует успеха поиска. Разумеется, если
R = N
, то искомого значения в массиве нет. В про%
тивном случае нужно учесть, что элемент a[R]
еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x
. В отличие от первого решения, дан%
ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.
1.8.3. Поиск в таблице
Поиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%
дний случай встречается часто; массивы литер называют цепочками литер
(string), или словами. Определим тип
String так:
String = ARRAY M OF CHAR
и пусть отношение порядка для цепочек определено так, что для цепочек x
и y
:
(x = y)
≡ (A
A
A
A
Aj: 0
≤ j < M : x j
= y j
)
(x < y)
≡ EEEEEi: 0 ≤ i < N : ((A
A
A
A
Aj: 0
≤ j < i : x j
= y j
) & (x i
< y i
))
Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,
достаточно искать пару неравных литер. Отсутствие такой пары означает равен%
ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%
ше 30, и будем использовать линейный поиск.
В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%
руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения
M
. Такая схема оставляет достаточно гибкос%
ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:
1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.
Обычно для этой цели используют литеру
0X
,
не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)
2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%
ка литер s имеет вид s = s
0
, s
1
, s
2
, ... , s
N–1
где s
1
... s
N–1
суть фактические литеры цепочки, а s
0
= CHR(N)
. Преимуще%
ство этого способа – в том, что длина доступна непосредственно, а недоста%
Поиск
Фундаментальные структуры данных
54
ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.
В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;
WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 END
Здесь концевая литера играет роль барьера, а инвариант цикла имеет вид
A
A
A
A
Aj: 0
≤ j < i : x j
= y j
≠ 0X,
и в результате выполняется следующее условие:
((x i
= 0X) OR (x i
≠ y i
)) & (Aj: 0
≤ j < i : x j
= y j
≠ 0X).
Отсюда следует совпадение цепочек x
и y
, если x
i
= y
i
; при этом x < y
, если x
i
< y i
Теперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.
Например, пусть таблица
T
и аргумент поиска x
определены так:
T: ARRAY N OF String;
x: String
Предполагая, что
N
может быть большим и что таблица упорядочена по алфа%
виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:
i := –1;
(* ADruS183 *)
L := 0; R := N;
WHILE L < R DO
m := (L+R) DIV 2;
i := 0;
WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;
IF T[m,i] < x[i] THEN L := m+1 ELSE R := m END
END;
IF R < N THEN
i := 0;
WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 END
END
(* (R < N) & (T[R,i] = x[i]) *)
1.9. Поиск образца в тексте
(string search)
Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s
из
N
элементов и массив p
из
M
элемен%
тов, где
0 < M < N
:
55
s: ARRAY N OF Item p: ARRAY M OF Item
Требуется найти первое вхождение p
в s
. Обычно элементы этих массивов
(
Item
) являются литерами; тогда s
можно считать текстом, а p
– образцом
(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.
Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,
с помощью которых осуществляются просмотры, определяется данными. Коррект%
ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант цикла
WHILE
. Поскольку эта фундаментальная и мощная управляющая структура пред%
ставлена не во всех языках программирования, ее описанию посвящено приложе%
ние C.
Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),
представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%
тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%
смотре базовой идеи простого алгоритма.
1.9.1. Простой поиск образца в тексте
Прежде чем думать об эффективности, опишем простейший алгоритм поиска.
Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p
длины
M
, сопоставляемый с текстом s
длины
N
в позиции i
. При этом индекс j
нумерует элементы образца, и элементу образца p[j]
сопоставляется элемент текста s[i+j]
Рис. 1.9. Образец длины
M, сопоставляемый с текстом s в позиции i
Предикат
R(i)
, описывающий полное совпадение образца с литерами текста в позиции i
, формулируется так:
R(i) = A
A
A
A
Ak: 0
≤ j < M : p j
= s i+j
Поиск образца в тексте (string search)
Фундаментальные структуры данных
56
Допустимые значения i
, в которых может реализоваться совпадение, – от
0
до
N–M
включительно. Вычисление условия
R
естественным образом сводится к пов%
торным сравнениям отдельных пар литер. Если применить теорему де Моргана к
R
, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:
R(i) = (A
A
A
A
Aj: 0
≤ j < M : p j
= s i+j
) = (E
E
E
E
Ej: 0
≤ j < M : p j
≠ s i+j
)
Поэтому предикат
R(i)
легко реализуется в виде процедуры%функции, постро%
енной по схеме линейного поиска:
PROCEDURE R (i: INTEGER): BOOLEAN;
VAR j: INTEGER;
BEGIN
(* 0 <= i < N *)
j := 0;
WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;
RETURN (j < M)
END R
Пусть искомым результатом будет значение индекса i
, указывающее на первое
вхождение образца в тексте s
. Тогда должно удовлетворяться условие
R(i)
. Но так как требуется найти именно первое вхождение образца, то условие
R(k)
должно быть ложным для всех k < i
. Обозначим это новое условие как
Q(i)
:
Q(i) = A
A
A
A
Ak: 0
≤ k < i : R(k)
Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):
i := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & R(i) DO INC(i) END
Инвариантом этого цикла является предикат
Q(i)
, который выполняется как до инструкции
INC(i)
, так и – благодаря второму операнду охраны – после нее.
Достоинство этого алгоритма – легкость понимания логики благодаря чет%
кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%
цедуры%функции
R
. Однако это же свойство может обернуться и недостатком:
во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%
сматриваемые ниже, используют информацию, получаемую во внутреннем цик%
ле, для увеличения i
во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%
ся от процедуры вычисления
R
, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i
. Од%
нако логика взаимодействия двух вложенных циклов через логическую пере%
менную теряет исходную прозрачность, что чревато ошибками при эволюции программы.
57
В подобных случаях удобно использовать так называемый цикл Дейкстры –
цикл
WHILE
с несколькими ветвями, каждая из которых имеет свою охрану (см.
приложение C). В данном случае две ветви будут соответствовать шагам по i
и j
соответственно. Вспомним рис. 1.9 и введем предикат
P(i, j)
, означающий совпаде%
ние первых j
литер образца с литерами текста, начиная с позиции i
:
P(i, j) = A
A
A
A
Ak: 0
≤ k < j : s i+k
= p k
Тогда
R(i) = P(i, M)
Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%
ется значениями пары переменных i и j
. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%
ле каждого шага при новых i или j
) можно выбрать так: в позициях до i
совпадений образца нет, а в позиции i
имеется частичное совпадение первых j
литер образца,
что формально записывается следующим образом:
Q(i) & P(i, j)
Очевидно, j = M
означает, что имеет место искомое вхождение образца в текст в позиции i
, а i > N – M
означает, что вхождений нет вообще.
Очевидно, для поиска достаточно пытаться увеличивать j
на 1, чтобы расши%
рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%
вую позицию, увеличив i
на
1
, и сбросить j
в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:
i := 0; j := 0;
WHILE
v # DO
INC( j )
ELSIF
DO
INC( i ); j := 0
END;
Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%
но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (
i
≤
N–M) & (j < M) & (s i+j
= p j
)
,
гаранти%
рующее
P(i, j)
после увеличения j
. Для второй ветки последний операнд конъюн%
кции должен содержать неравенство вместо равенства, что влечет
R(i)
и гаранти%
рует
Q(i)
после увеличения i
. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:
i := 0; j := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DO
INC( j )
ELSIF (i <= N–M) & (j < M) DO
INC( i ); j := 0
END;
Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%
ран, то есть (
i
>
N–M) OR (j >= M)
, причем из структуры шагов цикла дополнитель%
Поиск образца в тексте (string search)
Фундаментальные структуры данных
58
но следует, что два операнда не могут быть истинны одновременно, а j
не может превысить
M
. Тогда i
>
N–M
означает, что вхождений образца нет, а j = M
– что справедливо
Q(i) & P(i, M)
, то есть найдено искомое полное вхождение в позиции i
Анализ простого поиска в тексте. Этот алгоритм довольно эффективен,
если предполагать, что неравенство литер обнаруживается лишь после небольшо%
го числа сравнений литер, то есть при небольших j
. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%
ет тревогу. Например, рассмотрим текст, состоящий из
N–1
букв
A
, за которыми следует единственная буква
B
, и пусть образец состоит из
M–1
литер
A
, за которы%
ми следует одна
B
. Тогда нужно порядка
N*M
сравнений, чтобы обнаружить совпа%
дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%
ды, которые в этом худшем случае ведут себя гораздо лучше.
1.9.2. Алгоритм Кнута, Морриса и Пратта
Около 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%
ко порядка
N
сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%
нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%
цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%
щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%
ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%
ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%
ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Здесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i
; переменная j
, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10).
59
Центральным пунктом алгоритма является сравнение элементов s[i]
и p[j]
, при равенстве i и
j одновременно сдвигаются вправо на единицу, а в случае неравен%
ства должен быть сдвинут образец, что выражается присваиванием j
некоторого меньшего значения
D
. Граничный случай j = 0
показывает, что нужно предусмо%
треть возможность сдвига образца целиком за текущую позицию сравнения (что%
бы элемент p[0]
оказался выравнен с s[i+1]
). Для такого случая удобно положить
D = –1
. Главный цикл алгоритма приобретает следующий вид:
i := 0; j := 0;
WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DO
INC( i ); INC( j )
ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)
j := D
END;
Эта формулировка не совсем полна, так как еще не определено значение сдви%
га
D
. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это
Q(i–j) & P(i–j, j)
Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%
ражение
(j >= M) OR (i >= N)
, но реально могут реализоваться только равенства.
Если алгоритм останавливается при j = M
, то имеет место совпадение с образцом в позиции i–M
(член
P(i–j, j)
инварианта влечет
P(i–M, M) = R(i)
). В противном слу%
чае остановка происходит с i = N
, и так как здесь j < M
, то первый член инварианта,
Q(i–j)
, влечет отсутствие совпадений с образцом во всем тексте.
Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,
инвариант выполнен в начале цикла при значениях i = j = 0
. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i
и j
на 1. В самом деле,
Q(i–j)
не нарушается, так как разность i–j не меняется.
Не нарушается и
P(i–j, j)
при увеличении j благодаря равенству в охране ветки (ср.
определение
P
). Что касается второй ветки, то мы просто потребуем, чтобы значе%
ние
D
всегда было таким, чтобы замена j
на
D
сохраняла инвариант.
Присваивание j := D при условии
D < j означает сдвиг образца вправо на j–D
позиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать
D
как можно меньше. Это иллюстрируется на рис. 1.11.
Рис. 1.10. В обозначениях алгоритма КМП
позиция в тексте первой литеры образца равна i–j
(а не i, как в простом алгоритме)
Поиск образца в тексте (string search)
Фундаментальные структуры данных
60
Если мы хотим, чтобы инвариант
P(i–j, j) & Q(i–j)
выполнялся после присваива%
ния j := D
, то до присваивания должно выполняться условие
P(i–D, D) & Q(i–D)
Это предусловие и будет ключом к поиску подходящего выражения для
D наряду с условием
P(i–j, j) & Q(i–j)
, которое предполагается уже выполненным перед при%
сваиванием (к этой точке программы относятся все дальнейшие рассуждения).
Решающее наблюдение состоит в том, что истинность
P(i–j, j)
означает p
0
... p j–1
= s i–j
... s i–1
(ведь мы только что просмотрели первые j
литер образца и установили их совпа%
дение с соответствующими литерами текста). Поэтому условие
P(i–D, D)
при
D
< j
,
то есть p
0
... p
D–1
= s i–D
... s i–1
превращается в уравнение для
D
:
p
0
... p
D–1
= p j–D
... p j–1
Что касается
Q(i–D)
, то это условие следует из
Q(i–j)
, если
R(i–k)
для k = D+1 ... j
. При этом истинность
R(i–k)
для j = k гарантируется неравенством s[i] # p[j]
. Хотя условия
R(i–k)
≡
P(i–k, M) для k = D+1 ... j–1
нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия
P(i–k,k)
. Раскрывая их с учетом уже найденных равенств между элементами s
и p
, получаем следующее условие:
p
0
... p k–1
≠
p j–k
... p j–1
для всех k = D+1 ... j–1
То есть
D
должно быть максимальным решением вышеприведенного уравнения.
Рисунок 1.12 показывает, как работает этот механизм сдвигов.
Если решения для
D
нет, то совпадение с образцом невозможно ни в какой по%
зиции ниже i+1
. Тогда полагаем
D := –1
. Такая ситуация показана в верхнем при%
мере на рис. 1.13.
Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%
чуть улучшить: если бы литера p
j была равна
A
вместо
F
, то мы знали бы, что соот%
ветствующая литера в тексте никак не может быть равна
A
, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с
D = –1
(ср. нижнюю часть рис. 1.13). Поэтому при поиске
D
можно сразу наложить дополнительное ограничение p
D
≠
p j
. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.
Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций
61
Рис. 1.12. Частичные совпадения образца и вычисление
D
Рис. 1.13. Сдвиг образца за позицию последней литеры
Поиск образца в тексте (string search)
Фундаментальные структуры данных
62
Важный итог состоит в том, что значение
D
определяется только образцом и значением j
, но не зависит от текста. Обозначим
D
для заданного j
как d
j
. Таблицу d
можно построить до начала собственно поиска, что можно назвать предкомпи%
ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (
M << N
). Если нужно найти несколько вхож%
дений одного и того же образца, то таблицу d
можно использовать повторно.
Величина d
j
< j является длиной максимальной последовательности, удовлет%
воряющей условию p
0
... p d[j]–1
= p j–d[j]
... p j–1
с дополнительным ограничением p
d[j]
≠
p j
. Проход построенного цикла по самому образцу p
вместо s
последовательно находит максимальные последовательности,
что позволяет вычислить d
j
:
PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS192_ *)
(* p M s N; M <= Mmax*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY Mmax OF INTEGER;
BEGIN
(* d p*)
d[0] := –1;
IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;
j := 1; k := 0;
WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DO
k := d[k]
ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)
INC( j ); INC( k );
IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );
END;
(* *)
i := 0; j := 0;
WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DO
j := d[j];
ELSIF (j < M) & (i < N) DO
INC(i); INC(j);
END;
IF j = M THEN r := i–M ELSE r := –1 END
END Search
Анализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,
как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок
M+N
, что гораздо лучше, чем
M*N
в простом поиске. Они также указали на то приятное обстоятельство, что указатель i
всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%
тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%
жет привести к трудностям, если текст считывается из внешней памяти, так как
63
в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%
ные данные буферизуются, образец может оказаться таким, что потребуется воз%
врат за пределы буфера.
1.9.3. Алгоритм Бойера и Мура
Хитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%
наруживается после частичного совпадения некоторого фрагмента. Только в та%
ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%
этому выигрыш от использования КМП%стратегии оказывается не слишком су%
щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.
БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%
тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d
. Для каждой литеры x
из всего множества литер обозначим как d
x расстояние от конца образца до ее самого пра%
вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%
стом и образцом. Тогда образец можно сразу сдвигать вправо на d
p[M–1]
позиций,
и весьма вероятно, что эта величина окажется больше, чем 1. Если p
M–1
вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Так как литеры теперь сравниваются справа налево, то будет удобнее исполь%
зовать следующие слегка модифицированные версии предикатов
P
,
R
и
Q
:
P(i, j) = A
A
A
A
Ak: j
≤ k < M : s i–M+k
= p k
R(i)
= P(i, 0)
Q(i)
= A
A
A
A
Ak: M
≤ k < i : R(k)
В этих терминах инвариант цикла будет иметь прежний вид:
Q(i) & P(i, j)
. Мы еще введем переменную k = i–M+j.
Теперь можно дать следующую формулировку
БМ%алгоритма:
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END
Поиск образца в тексте (string search)
Фундаментальные структуры данных
64
Индексы удовлетворяют ограничениям
0
≤
j
≤
M, M
≤
i, 0 < k
≤
i
. Остановка алгоритма с j = 0
влечет
P(i, 0) = R(i)
, то есть совпадение в позиции k = i–M
. Для остановки c j > 0
нужно, чтобы i > N
; тогда
Q(i)
влечет
Q(N)
и, следовательно, от%
сутствие совпадений. Разумеется, нам еще нужно убедиться, что
Q(i)
и
P(i, j)
действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку
Q(M)
и
P(x, M)
всегда истинны.
Сначала рассмотрим первую ветку. Одновременное уменьшение k
и j
никак не влияет на
Q(i)
, и, следовательно, поскольку уже установлено, что s
k–1
= p j–1
, а
P(i, j)
выполняется до операции уменьшения j
, то
P(i, j)
выполняется и после нее.
Во второй ветке достаточно показать, что присваивание i := i + d s[i–1]
не нару%
шает инвариант
Q(i)
, так как после остальных присваиваний
P(i, j) выполняется автоматически.
Q(i) выполняется после изменения i, если до него выполняется
Q(i+d s[i–1]
)
. Поскольку мы знаем, что выполняется
Q(i)
, достаточно установить, что
R(i+h)
для h = 1 .. d s[i–1]
–1
. Вспомним, что величина d
x определена как расстояние от конца до крайнего правого вхождения x
в образце. Формально это выражается в виде:
A
A
A
A
Ak: M–d x
≤ k < M–1 : p k
≠ x
Для x = s i–1
получаем
A
A
A
A
Ak: M–d s[i–1]
k < M–1 : s i–1
≠ p k
= A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : s i–1
≠ p
M–1–h
⇒ A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : R(i+h)
В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:
PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS193_ *)
(* p M s N*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY 128 OF INTEGER;
BEGIN
FOR i := 0 TO 127 DO d[i] := M END;
FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END;
IF j <= 0 THEN r := k ELSE r := –1 END
END Search
Анализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%
ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем
N
, сравнений, за исключением специ%
65
ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно
N/M
Авторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%
торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%
ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%
го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%
ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%
ния деталей, так как дополнительная сложность вычисления таблиц и самого по%
иска, по%видимому, не приводит к существенному выигрышу в эффективности.
Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.
Замечание переводчика. Три алгоритма поиска образца в тексте – простой,
КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.
КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построение
БМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%
ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%
ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%
граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,
что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%
явные, инстинктивные предположения – еще до первых попыток составить кон%
кретное решение, когда внимание уже будет поглощено не исходной задачей,
а красотой и эффективностью предполагаемого решения, ярко демонстрирую%
щего остроумие и изобретательность программиста, а также его познания в ме%
тодах оптимизации.
Упражнения
1.1. Обозначим мощности стандартных типов
INTEGER
,
REAL
и
CHAR
как c
int
, c
real и
c char
. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров:
Complex
,
Date
,
Person
,
Row
,
Card
,
Name
?
Упражнения
Фундаментальные структуры данных
66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)
соответствуют следующим операциям:
a) чтение и запись для элементов упакованных записей и массивов;
b) операции для множеств, включая проверку принадлежности числа мно%
жеству?
1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%
ных в виде последовательностей, а не массивов?
1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%
вов, записей или последовательностей, которое было бы удобно для опреде%
ления времени прибытия и отправления для заданных станции и направле%
ния поезда.
1.5. Пусть даны текст
T
, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов
A
и
B
. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%
мальной длины. Напишите программу, преобразующую текст
T
в текст
S
за%
меной каждого вхождения слова
A
i соответствующим словом
B
i
1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%
лагается, что определены константа
N > 0
и следующие переменные:
VAR i, j, k, x: INTEGER;
a: ARRAY N OF INTEGER;
Программа A:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF a[k] < x THEN i := k ELSE j := k END
UNTIL (a[k] = x) OR (i > j)
Программа B:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k–1 END;
IF a[k] < x THEN i := k+1 END
UNTIL i > j
Программа C:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k ELSE i := k+1 END
UNTIL i > j
67
Подсказка. Все программы должны заканчиваться с a
k
= x
, если такой эле%
мент присутствует, или a
k
≠ x
, если элемента со значением x нет.
1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%
сен. Песни нумеруются числами от
1
до
N
(пусть
N = 30
). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.
Подсказка: Используйте процедуры
Read и
ReadInt для чтения данных опроса.
TYPE hit = INTEGER;
reponse = RECORD name, firstname: Name;
male: BOOLEAN;
age: INTEGER;
choice: ARRAY 5 OF hit
END;
VAR poll: Files.File
Этот файл содержит входные данные для программы, вычисляющей следую%
щие результаты:
1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.
2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.
Перед каждым из пяти списков должен идти подходящий заголовок.
Литература
[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,
Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,
Хоор К. Структурное программирование. – М.: Мир, 1975).
[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:
Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)
[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,
1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).
[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:
Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).
Литература
Фундаментальные структуры данных
68
[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,
6, No. 4, (1974) 247–259.
[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.
ACM, 17, 10 (Oct. 1974), 549–557.
[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAM
J. Comput., 6, 2, (June 1977), 323–349.
[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,
10 (Oct. 1977), 762–772.
1 2 3 4 5 6 7 8 9 ... 22
1.8. Поиск
Задача поиска – одна из наиболее часто встречающихся в программировании.
Она также дает прекрасные возможности показать применение рассмотренных структур данных. Есть несколько основных вариаций на тему поиска, и здесь при%
думано множество алгоритмов. Основное предположение, которое мы делаем в дальнейшем изложении, состоит в том, что набор данных, в котором ищется за%
данное значение, фиксирован. Будем предполагать, что этот набор
N
элементов представлен массивом, скажем a: ARRAY N OF Item
Обычно элементы являются записями, одно из полей которых играет роль ключа. Тогда задача состоит в нахождении элемента, у которого поле ключа равно заданному значению x
, которое еще называют аргументом поиска. Найденный индекс i
, удовлетворяющий условию a[i].key = x
, позволит обратиться к другим полям найденного элемента. Поскольку нам здесь интересна только задача поис%
ка, но не данные, ради которых производится поиск, мы будем предполагать, что тип
Item состоит только из ключа, то есть сам является ключом.
Поиск
Фундаментальные структуры данных
50
1.8.1. Линейный поиск
Если нет никакой дополнительной информации о данных, то очевидное реше%
ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%
стно как линейный поиск. Поиск прекращается при одном из двух условий:
1. Элемент найден, то есть a
i
= x
2. Просмотрен весь массив, но элемент не найден.
Приходим к следующему алгоритму:
i := 0;
(* ADruS18_ *)
WHILE (i < N) & (a[i] # x) DO INC(i) END
Отметим, что порядок операндов в булевском выражении важен.
До и после каждого шага цикла выполняется следующее условие:
(0
≤ i < N) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Такое условие называется инвариантом. В данном случае инвариант означает,
что для всех значений k
, меньших, чем i
, среди a
k искомого значения x
нет. Заме%
тим, что до и после каждого шага цикла значения i
– разные. Сохранение инвари%
анта при изменении i
имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами
WHILE
и
DO
).
Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%
ного фрагмента):
((i = N) OR (a i
= x )) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Это условие не только является искомым результатом, но еще и подразумева%
ет, что если найден элемент, равный x
, то это первый такой элемент. При этом i = N
означает, что искомого значения не найдено.
Окончание цикла гарантировано, так как величина i
на каждом шаге увели%
чивается и поэтому обязательно достигнет границы
N
после конечного числа ша%
гов; на самом деле после
N
шагов, если искомого значения в массиве нет.
На каждом шаге нужно вычислять булевское выражение и увеличивать ин%
декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%
ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%
печить, помещая дополнительный элемент со значением x
в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%
ствует выходу поиска за границу массива. Теперь массив a
объявляется так:
a: ARRAY N+1 OF INTEGER,
51
и алгоритм линейного поиска с барьером выражается следующим образом:
a[N] := x; i := 0;
(* ADruS18_ *)
WHILE a[i] # x DO INC(i) END
В итоге получается условие, выведенное из того же инварианта, что и ранее:
(a i
= x) & (Ak: 0
≤ k < i : a k
≠ x)
Очевидно, из i = N
следует, что искомое значение не встретилось (не считая значения%барьера).
1.8.2. Поиск делением пополам
Понятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%
раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%
вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%
пользует информацию о том, что массив a
упорядочен, то есть что выполняется условие
A
A
A
A
Ak: 1
≤ k < N : a k–1
≤ a k
Ключевая идея – в том, чтобы выбрать наугад элемент, скажем a
m
, и сравнить его с искомым значением x
. Если он равен x
, то поиск прекращается; если он мень%
ше x
, то можно заключить, что все элементы с индексами, равными или меньшими m
, можно игнорировать в дальнейшем поиске; а если он больше x
, то можно игно%
рировать все значения индекса, большие или равные m
. Это приводит к следую%
щему алгоритму, который носит название поиск делением пополам (binary search);
в нем используются две индексные переменные
L
и
R
, отмечающие в массиве a
левый и правый концы отрезка, в котором искомое значение все еще может быть найдено:
L := 0; R := N–1;
(* ADruS18_ *)
m :=
L R;
WHILE (L <= R) & (a[m] # x) DO
IF a[m] < x THEN
L := m+1
ELSE
R := m–1
END;
m :=
L R
END
Подчеркнем фундаментальное структурное подобие этого алгоритма и алго%
ритма линейного поиска в предыдущем разделе: роль i
теперь играет тройка
L, m,
R
. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%
ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной m
Поиск
Фундаментальные структуры данных
52
Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R < k < N : a k
> x)
откуда выводим для цикла такое постусловие:
((L > R) OR (a m
= x)) & (A
A
A
A
Ak: 0
≤ k < L : a k
< x ) & (A
A
A
A
Ak: R < k < N : a k
> x)
Из него следует, что
((L > R) & (A
A
A
A
Ak: 0
≤ k < N : a k
≠ x)) OR (a m
= x)
Выбор m
, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%
ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log
2
N
, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%
кальное улучшение по сравнению с линейным поиском, где среднее число срав%
нений равно
N/2
Как уже упоминалось, можно заняться устранением дублирования инструк%
ции присваивания m
. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%
нений. Напомним, что число шагов не превышает log N
Более быстрое решение основано на следующем инварианте:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R
≤ k < N : a k
≥ x)
а поиск продолжается, пока два отрезка не будут покрывать весь массив:
L := 0; R := N;
(* ADruS18_ *)
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] < x THEN L := m+1 ELSE R := m END
END
Можно сказать, что теперь ищется не элемент a[m] = x
, а граница, отделяющая все элементы, меньшие x
, от всех прочих.
Условие окончания –
L
≥
R
. Есть ли гарантия его достижения? Чтобы убедить%
ся в этом, нужно показать, что в любых обстоятельствах разность
R–L
умень%
шается на каждом шаге. Условие
L < R
справедливо в начале каждого шага. Тогда
53
среднее арифметическое удовлетворяет условию
L
≤ m < R
. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1
пере%
менной
L
(что увеличивает
L
), либо значению m
переменной
R
(что уменьшает
R
),
и цикл прекращается при
L = R
Однако выполнение инварианта вместе с условием
L = R
еще не гарантирует успеха поиска. Разумеется, если
R = N
, то искомого значения в массиве нет. В про%
тивном случае нужно учесть, что элемент a[R]
еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x
. В отличие от первого решения, дан%
ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.
1.8.3. Поиск в таблице
Поиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%
дний случай встречается часто; массивы литер называют цепочками литер
(string), или словами. Определим тип
String так:
String = ARRAY M OF CHAR
и пусть отношение порядка для цепочек определено так, что для цепочек x
и y
:
(x = y)
≡ (A
A
A
A
Aj: 0
≤ j < M : x j
= y j
)
(x < y)
≡ EEEEEi: 0 ≤ i < N : ((A
A
A
A
Aj: 0
≤ j < i : x j
= y j
) & (x i
< y i
))
Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,
достаточно искать пару неравных литер. Отсутствие такой пары означает равен%
ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%
ше 30, и будем использовать линейный поиск.
В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%
руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения
M
. Такая схема оставляет достаточно гибкос%
ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:
1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.
Обычно для этой цели используют литеру
0X
,
не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)
2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%
ка литер s имеет вид s = s
0
, s
1
, s
2
, ... , s
N–1
где s
1
... s
N–1
суть фактические литеры цепочки, а s
0
= CHR(N)
. Преимуще%
ство этого способа – в том, что длина доступна непосредственно, а недоста%
Поиск
Фундаментальные структуры данных
54
ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.
В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;
WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 END
Здесь концевая литера играет роль барьера, а инвариант цикла имеет вид
A
A
A
A
Aj: 0
≤ j < i : x j
= y j
≠ 0X,
и в результате выполняется следующее условие:
((x i
= 0X) OR (x i
≠ y i
)) & (Aj: 0
≤ j < i : x j
= y j
≠ 0X).
Отсюда следует совпадение цепочек x
и y
, если x
i
= y
i
; при этом x < y
, если x
i
< y i
Теперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.
Например, пусть таблица
T
и аргумент поиска x
определены так:
T: ARRAY N OF String;
x: String
Предполагая, что
N
может быть большим и что таблица упорядочена по алфа%
виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:
i := –1;
(* ADruS183 *)
L := 0; R := N;
WHILE L < R DO
m := (L+R) DIV 2;
i := 0;
WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;
IF T[m,i] < x[i] THEN L := m+1 ELSE R := m END
END;
IF R < N THEN
i := 0;
WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 END
END
(* (R < N) & (T[R,i] = x[i]) *)
1.9. Поиск образца в тексте
(string search)
Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s
из
N
элементов и массив p
из
M
элемен%
тов, где
0 < M < N
:
55
s: ARRAY N OF Item p: ARRAY M OF Item
Требуется найти первое вхождение p
в s
. Обычно элементы этих массивов
(
Item
) являются литерами; тогда s
можно считать текстом, а p
– образцом
(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.
Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,
с помощью которых осуществляются просмотры, определяется данными. Коррект%
ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант цикла
WHILE
. Поскольку эта фундаментальная и мощная управляющая структура пред%
ставлена не во всех языках программирования, ее описанию посвящено приложе%
ние C.
Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),
представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%
тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%
смотре базовой идеи простого алгоритма.
1.9.1. Простой поиск образца в тексте
Прежде чем думать об эффективности, опишем простейший алгоритм поиска.
Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p
длины
M
, сопоставляемый с текстом s
длины
N
в позиции i
. При этом индекс j
нумерует элементы образца, и элементу образца p[j]
сопоставляется элемент текста s[i+j]
Рис. 1.9. Образец длины
M, сопоставляемый с текстом s в позиции i
Предикат
R(i)
, описывающий полное совпадение образца с литерами текста в позиции i
, формулируется так:
R(i) = A
A
A
A
Ak: 0
≤ j < M : p j
= s i+j
Поиск образца в тексте (string search)
Фундаментальные структуры данных
56
Допустимые значения i
, в которых может реализоваться совпадение, – от
0
до
N–M
включительно. Вычисление условия
R
естественным образом сводится к пов%
торным сравнениям отдельных пар литер. Если применить теорему де Моргана к
R
, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:
R(i) = (A
A
A
A
Aj: 0
≤ j < M : p j
= s i+j
) = (E
E
E
E
Ej: 0
≤ j < M : p j
≠ s i+j
)
Поэтому предикат
R(i)
легко реализуется в виде процедуры%функции, постро%
енной по схеме линейного поиска:
PROCEDURE R (i: INTEGER): BOOLEAN;
VAR j: INTEGER;
BEGIN
(* 0 <= i < N *)
j := 0;
WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;
RETURN (j < M)
END R
Пусть искомым результатом будет значение индекса i
, указывающее на первое
вхождение образца в тексте s
. Тогда должно удовлетворяться условие
R(i)
. Но так как требуется найти именно первое вхождение образца, то условие
R(k)
должно быть ложным для всех k < i
. Обозначим это новое условие как
Q(i)
:
Q(i) = A
A
A
A
Ak: 0
≤ k < i : R(k)
Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):
i := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & R(i) DO INC(i) END
Инвариантом этого цикла является предикат
Q(i)
, который выполняется как до инструкции
INC(i)
, так и – благодаря второму операнду охраны – после нее.
Достоинство этого алгоритма – легкость понимания логики благодаря чет%
кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%
цедуры%функции
R
. Однако это же свойство может обернуться и недостатком:
во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%
сматриваемые ниже, используют информацию, получаемую во внутреннем цик%
ле, для увеличения i
во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%
ся от процедуры вычисления
R
, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i
. Од%
нако логика взаимодействия двух вложенных циклов через логическую пере%
менную теряет исходную прозрачность, что чревато ошибками при эволюции программы.
57
В подобных случаях удобно использовать так называемый цикл Дейкстры –
цикл
WHILE
с несколькими ветвями, каждая из которых имеет свою охрану (см.
приложение C). В данном случае две ветви будут соответствовать шагам по i
и j
соответственно. Вспомним рис. 1.9 и введем предикат
P(i, j)
, означающий совпаде%
ние первых j
литер образца с литерами текста, начиная с позиции i
:
P(i, j) = A
A
A
A
Ak: 0
≤ k < j : s i+k
= p k
Тогда
R(i) = P(i, M)
Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%
ется значениями пары переменных i и j
. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%
ле каждого шага при новых i или j
) можно выбрать так: в позициях до i
совпадений образца нет, а в позиции i
имеется частичное совпадение первых j
литер образца,
что формально записывается следующим образом:
Q(i) & P(i, j)
Очевидно, j = M
означает, что имеет место искомое вхождение образца в текст в позиции i
, а i > N – M
означает, что вхождений нет вообще.
Очевидно, для поиска достаточно пытаться увеличивать j
на 1, чтобы расши%
рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%
вую позицию, увеличив i
на
1
, и сбросить j
в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:
i := 0; j := 0;
WHILE
v # DO
INC( j )
ELSIF
DO
INC( i ); j := 0
END;
Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%
но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (
i
≤
N–M) & (j < M) & (s i+j
= p j
)
,
гаранти%
рующее
P(i, j)
после увеличения j
. Для второй ветки последний операнд конъюн%
кции должен содержать неравенство вместо равенства, что влечет
R(i)
и гаранти%
рует
Q(i)
после увеличения i
. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:
i := 0; j := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DO
INC( j )
ELSIF (i <= N–M) & (j < M) DO
INC( i ); j := 0
END;
Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%
ран, то есть (
i
>
N–M) OR (j >= M)
, причем из структуры шагов цикла дополнитель%
Поиск образца в тексте (string search)
Фундаментальные структуры данных
58
но следует, что два операнда не могут быть истинны одновременно, а j
не может превысить
M
. Тогда i
>
N–M
означает, что вхождений образца нет, а j = M
– что справедливо
Q(i) & P(i, M)
, то есть найдено искомое полное вхождение в позиции i
Анализ простого поиска в тексте. Этот алгоритм довольно эффективен,
если предполагать, что неравенство литер обнаруживается лишь после небольшо%
го числа сравнений литер, то есть при небольших j
. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%
ет тревогу. Например, рассмотрим текст, состоящий из
N–1
букв
A
, за которыми следует единственная буква
B
, и пусть образец состоит из
M–1
литер
A
, за которы%
ми следует одна
B
. Тогда нужно порядка
N*M
сравнений, чтобы обнаружить совпа%
дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%
ды, которые в этом худшем случае ведут себя гораздо лучше.
1.9.2. Алгоритм Кнута, Морриса и Пратта
Около 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%
ко порядка
N
сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%
нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%
цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%
щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%
ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%
ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%
ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Здесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i
; переменная j
, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10).
59
Центральным пунктом алгоритма является сравнение элементов s[i]
и p[j]
, при равенстве i и
j одновременно сдвигаются вправо на единицу, а в случае неравен%
ства должен быть сдвинут образец, что выражается присваиванием j
некоторого меньшего значения
D
. Граничный случай j = 0
показывает, что нужно предусмо%
треть возможность сдвига образца целиком за текущую позицию сравнения (что%
бы элемент p[0]
оказался выравнен с s[i+1]
). Для такого случая удобно положить
D = –1
. Главный цикл алгоритма приобретает следующий вид:
i := 0; j := 0;
WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DO
INC( i ); INC( j )
ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)
j := D
END;
Эта формулировка не совсем полна, так как еще не определено значение сдви%
га
D
. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это
Q(i–j) & P(i–j, j)
Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%
ражение
(j >= M) OR (i >= N)
, но реально могут реализоваться только равенства.
Если алгоритм останавливается при j = M
, то имеет место совпадение с образцом в позиции i–M
(член
P(i–j, j)
инварианта влечет
P(i–M, M) = R(i)
). В противном слу%
чае остановка происходит с i = N
, и так как здесь j < M
, то первый член инварианта,
Q(i–j)
, влечет отсутствие совпадений с образцом во всем тексте.
Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,
инвариант выполнен в начале цикла при значениях i = j = 0
. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i
и j
на 1. В самом деле,
Q(i–j)
не нарушается, так как разность i–j не меняется.
Не нарушается и
P(i–j, j)
при увеличении j благодаря равенству в охране ветки (ср.
определение
P
). Что касается второй ветки, то мы просто потребуем, чтобы значе%
ние
D
всегда было таким, чтобы замена j
на
D
сохраняла инвариант.
Присваивание j := D при условии
D < j означает сдвиг образца вправо на j–D
позиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать
D
как можно меньше. Это иллюстрируется на рис. 1.11.
Рис. 1.10. В обозначениях алгоритма КМП
позиция в тексте первой литеры образца равна i–j
(а не i, как в простом алгоритме)
Поиск образца в тексте (string search)
Фундаментальные структуры данных
60
Если мы хотим, чтобы инвариант
P(i–j, j) & Q(i–j)
выполнялся после присваива%
ния j := D
, то до присваивания должно выполняться условие
P(i–D, D) & Q(i–D)
Это предусловие и будет ключом к поиску подходящего выражения для
D наряду с условием
P(i–j, j) & Q(i–j)
, которое предполагается уже выполненным перед при%
сваиванием (к этой точке программы относятся все дальнейшие рассуждения).
Решающее наблюдение состоит в том, что истинность
P(i–j, j)
означает p
0
... p j–1
= s i–j
... s i–1
(ведь мы только что просмотрели первые j
литер образца и установили их совпа%
дение с соответствующими литерами текста). Поэтому условие
P(i–D, D)
при
D
< j
,
то есть p
0
... p
D–1
= s i–D
... s i–1
превращается в уравнение для
D
:
p
0
... p
D–1
= p j–D
... p j–1
Что касается
Q(i–D)
, то это условие следует из
Q(i–j)
, если
R(i–k)
для k = D+1 ... j
. При этом истинность
R(i–k)
для j = k гарантируется неравенством s[i] # p[j]
. Хотя условия
R(i–k)
≡
P(i–k, M) для k = D+1 ... j–1
нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия
P(i–k,k)
. Раскрывая их с учетом уже найденных равенств между элементами s
и p
, получаем следующее условие:
p
0
... p k–1
≠
p j–k
... p j–1
для всех k = D+1 ... j–1
То есть
D
должно быть максимальным решением вышеприведенного уравнения.
Рисунок 1.12 показывает, как работает этот механизм сдвигов.
Если решения для
D
нет, то совпадение с образцом невозможно ни в какой по%
зиции ниже i+1
. Тогда полагаем
D := –1
. Такая ситуация показана в верхнем при%
мере на рис. 1.13.
Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%
чуть улучшить: если бы литера p
j была равна
A
вместо
F
, то мы знали бы, что соот%
ветствующая литера в тексте никак не может быть равна
A
, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с
D = –1
(ср. нижнюю часть рис. 1.13). Поэтому при поиске
D
можно сразу наложить дополнительное ограничение p
D
≠
p j
. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.
Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций
61
Рис. 1.12. Частичные совпадения образца и вычисление
D
Рис. 1.13. Сдвиг образца за позицию последней литеры
Поиск образца в тексте (string search)
Фундаментальные структуры данных
62
Важный итог состоит в том, что значение
D
определяется только образцом и значением j
, но не зависит от текста. Обозначим
D
для заданного j
как d
j
. Таблицу d
можно построить до начала собственно поиска, что можно назвать предкомпи%
ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (
M << N
). Если нужно найти несколько вхож%
дений одного и того же образца, то таблицу d
можно использовать повторно.
Величина d
j
< j является длиной максимальной последовательности, удовлет%
воряющей условию p
0
... p d[j]–1
= p j–d[j]
... p j–1
с дополнительным ограничением p
d[j]
≠
p j
. Проход построенного цикла по самому образцу p
вместо s
последовательно находит максимальные последовательности,
что позволяет вычислить d
j
:
PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS192_ *)
(* p M s N; M <= Mmax*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY Mmax OF INTEGER;
BEGIN
(* d p*)
d[0] := –1;
IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;
j := 1; k := 0;
WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DO
k := d[k]
ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)
INC( j ); INC( k );
IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );
END;
(* *)
i := 0; j := 0;
WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DO
j := d[j];
ELSIF (j < M) & (i < N) DO
INC(i); INC(j);
END;
IF j = M THEN r := i–M ELSE r := –1 END
END Search
Анализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,
как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок
M+N
, что гораздо лучше, чем
M*N
в простом поиске. Они также указали на то приятное обстоятельство, что указатель i
всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%
тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%
жет привести к трудностям, если текст считывается из внешней памяти, так как
63
в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%
ные данные буферизуются, образец может оказаться таким, что потребуется воз%
врат за пределы буфера.
1.9.3. Алгоритм Бойера и Мура
Хитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%
наруживается после частичного совпадения некоторого фрагмента. Только в та%
ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%
этому выигрыш от использования КМП%стратегии оказывается не слишком су%
щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.
БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%
тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d
. Для каждой литеры x
из всего множества литер обозначим как d
x расстояние от конца образца до ее самого пра%
вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%
стом и образцом. Тогда образец можно сразу сдвигать вправо на d
p[M–1]
позиций,
и весьма вероятно, что эта величина окажется больше, чем 1. Если p
M–1
вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Так как литеры теперь сравниваются справа налево, то будет удобнее исполь%
зовать следующие слегка модифицированные версии предикатов
P
,
R
и
Q
:
P(i, j) = A
A
A
A
Ak: j
≤ k < M : s i–M+k
= p k
R(i)
= P(i, 0)
Q(i)
= A
A
A
A
Ak: M
≤ k < i : R(k)
В этих терминах инвариант цикла будет иметь прежний вид:
Q(i) & P(i, j)
. Мы еще введем переменную k = i–M+j.
Теперь можно дать следующую формулировку
БМ%алгоритма:
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END
Поиск образца в тексте (string search)
Фундаментальные структуры данных
64
Индексы удовлетворяют ограничениям
0
≤
j
≤
M, M
≤
i, 0 < k
≤
i
. Остановка алгоритма с j = 0
влечет
P(i, 0) = R(i)
, то есть совпадение в позиции k = i–M
. Для остановки c j > 0
нужно, чтобы i > N
; тогда
Q(i)
влечет
Q(N)
и, следовательно, от%
сутствие совпадений. Разумеется, нам еще нужно убедиться, что
Q(i)
и
P(i, j)
действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку
Q(M)
и
P(x, M)
всегда истинны.
Сначала рассмотрим первую ветку. Одновременное уменьшение k
и j
никак не влияет на
Q(i)
, и, следовательно, поскольку уже установлено, что s
k–1
= p j–1
, а
P(i, j)
выполняется до операции уменьшения j
, то
P(i, j)
выполняется и после нее.
Во второй ветке достаточно показать, что присваивание i := i + d s[i–1]
не нару%
шает инвариант
Q(i)
, так как после остальных присваиваний
P(i, j) выполняется автоматически.
Q(i) выполняется после изменения i, если до него выполняется
Q(i+d s[i–1]
)
. Поскольку мы знаем, что выполняется
Q(i)
, достаточно установить, что
R(i+h)
для h = 1 .. d s[i–1]
–1
. Вспомним, что величина d
x определена как расстояние от конца до крайнего правого вхождения x
в образце. Формально это выражается в виде:
A
A
A
A
Ak: M–d x
≤ k < M–1 : p k
≠ x
Для x = s i–1
получаем
A
A
A
A
Ak: M–d s[i–1]
k < M–1 : s i–1
≠ p k
= A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : s i–1
≠ p
M–1–h
⇒ A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : R(i+h)
В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:
PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS193_ *)
(* p M s N*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY 128 OF INTEGER;
BEGIN
FOR i := 0 TO 127 DO d[i] := M END;
FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END;
IF j <= 0 THEN r := k ELSE r := –1 END
END Search
Анализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%
ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем
N
, сравнений, за исключением специ%
65
ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно
N/M
Авторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%
торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%
ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%
го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%
ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%
ния деталей, так как дополнительная сложность вычисления таблиц и самого по%
иска, по%видимому, не приводит к существенному выигрышу в эффективности.
Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.
Замечание переводчика. Три алгоритма поиска образца в тексте – простой,
КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.
КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построение
БМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%
ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%
ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%
граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,
что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%
явные, инстинктивные предположения – еще до первых попыток составить кон%
кретное решение, когда внимание уже будет поглощено не исходной задачей,
а красотой и эффективностью предполагаемого решения, ярко демонстрирую%
щего остроумие и изобретательность программиста, а также его познания в ме%
тодах оптимизации.
Упражнения
1.1. Обозначим мощности стандартных типов
INTEGER
,
REAL
и
CHAR
как c
int
, c
real и
c char
. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров:
Complex
,
Date
,
Person
,
Row
,
Card
,
Name
?
Упражнения
Фундаментальные структуры данных
66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)
соответствуют следующим операциям:
a) чтение и запись для элементов упакованных записей и массивов;
b) операции для множеств, включая проверку принадлежности числа мно%
жеству?
1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%
ных в виде последовательностей, а не массивов?
1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%
вов, записей или последовательностей, которое было бы удобно для опреде%
ления времени прибытия и отправления для заданных станции и направле%
ния поезда.
1.5. Пусть даны текст
T
, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов
A
и
B
. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%
мальной длины. Напишите программу, преобразующую текст
T
в текст
S
за%
меной каждого вхождения слова
A
i соответствующим словом
B
i
1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%
лагается, что определены константа
N > 0
и следующие переменные:
VAR i, j, k, x: INTEGER;
a: ARRAY N OF INTEGER;
Программа A:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF a[k] < x THEN i := k ELSE j := k END
UNTIL (a[k] = x) OR (i > j)
Программа B:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k–1 END;
IF a[k] < x THEN i := k+1 END
UNTIL i > j
Программа C:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k ELSE i := k+1 END
UNTIL i > j
67
Подсказка. Все программы должны заканчиваться с a
k
= x
, если такой эле%
мент присутствует, или a
k
≠ x
, если элемента со значением x нет.
1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%
сен. Песни нумеруются числами от
1
до
N
(пусть
N = 30
). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.
Подсказка: Используйте процедуры
Read и
ReadInt для чтения данных опроса.
TYPE hit = INTEGER;
reponse = RECORD name, firstname: Name;
male: BOOLEAN;
age: INTEGER;
choice: ARRAY 5 OF hit
END;
VAR poll: Files.File
Этот файл содержит входные данные для программы, вычисляющей следую%
щие результаты:
1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.
2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.
Перед каждым из пяти списков должен идти подходящий заголовок.
Литература
[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,
Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,
Хоор К. Структурное программирование. – М.: Мир, 1975).
[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:
Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)
[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,
1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).
[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:
Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).
Литература
Фундаментальные структуры данных
68
[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,
6, No. 4, (1974) 247–259.
[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.
ACM, 17, 10 (Oct. 1974), 549–557.
[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAM
J. Comput., 6, 2, (June 1977), 323–349.
[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,
10 (Oct. 1977), 762–772.
1 2 3 4 5 6 7 8 9 ... 22
1.8. Поиск
Задача поиска – одна из наиболее часто встречающихся в программировании.
Она также дает прекрасные возможности показать применение рассмотренных структур данных. Есть несколько основных вариаций на тему поиска, и здесь при%
думано множество алгоритмов. Основное предположение, которое мы делаем в дальнейшем изложении, состоит в том, что набор данных, в котором ищется за%
данное значение, фиксирован. Будем предполагать, что этот набор
N
элементов представлен массивом, скажем a: ARRAY N OF Item
Обычно элементы являются записями, одно из полей которых играет роль ключа. Тогда задача состоит в нахождении элемента, у которого поле ключа равно заданному значению x
, которое еще называют аргументом поиска. Найденный индекс i
, удовлетворяющий условию a[i].key = x
, позволит обратиться к другим полям найденного элемента. Поскольку нам здесь интересна только задача поис%
ка, но не данные, ради которых производится поиск, мы будем предполагать, что тип
Item состоит только из ключа, то есть сам является ключом.
Поиск
Фундаментальные структуры данных
50
1.8.1. Линейный поиск
Если нет никакой дополнительной информации о данных, то очевидное реше%
ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%
стно как линейный поиск. Поиск прекращается при одном из двух условий:
1. Элемент найден, то есть a
i
= x
2. Просмотрен весь массив, но элемент не найден.
Приходим к следующему алгоритму:
i := 0;
(* ADruS18_ *)
WHILE (i < N) & (a[i] # x) DO INC(i) END
Отметим, что порядок операндов в булевском выражении важен.
До и после каждого шага цикла выполняется следующее условие:
(0
≤ i < N) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Такое условие называется инвариантом. В данном случае инвариант означает,
что для всех значений k
, меньших, чем i
, среди a
k искомого значения x
нет. Заме%
тим, что до и после каждого шага цикла значения i
– разные. Сохранение инвари%
анта при изменении i
имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами
WHILE
и
DO
).
Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%
ного фрагмента):
((i = N) OR (a i
= x )) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Это условие не только является искомым результатом, но еще и подразумева%
ет, что если найден элемент, равный x
, то это первый такой элемент. При этом i = N
означает, что искомого значения не найдено.
Окончание цикла гарантировано, так как величина i
на каждом шаге увели%
чивается и поэтому обязательно достигнет границы
N
после конечного числа ша%
гов; на самом деле после
N
шагов, если искомого значения в массиве нет.
На каждом шаге нужно вычислять булевское выражение и увеличивать ин%
декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%
ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%
печить, помещая дополнительный элемент со значением x
в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%
ствует выходу поиска за границу массива. Теперь массив a
объявляется так:
a: ARRAY N+1 OF INTEGER,
51
и алгоритм линейного поиска с барьером выражается следующим образом:
a[N] := x; i := 0;
(* ADruS18_ *)
WHILE a[i] # x DO INC(i) END
В итоге получается условие, выведенное из того же инварианта, что и ранее:
(a i
= x) & (Ak: 0
≤ k < i : a k
≠ x)
Очевидно, из i = N
следует, что искомое значение не встретилось (не считая значения%барьера).
1.8.2. Поиск делением пополам
Понятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%
раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%
вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%
пользует информацию о том, что массив a
упорядочен, то есть что выполняется условие
A
A
A
A
Ak: 1
≤ k < N : a k–1
≤ a k
Ключевая идея – в том, чтобы выбрать наугад элемент, скажем a
m
, и сравнить его с искомым значением x
. Если он равен x
, то поиск прекращается; если он мень%
ше x
, то можно заключить, что все элементы с индексами, равными или меньшими m
, можно игнорировать в дальнейшем поиске; а если он больше x
, то можно игно%
рировать все значения индекса, большие или равные m
. Это приводит к следую%
щему алгоритму, который носит название поиск делением пополам (binary search);
в нем используются две индексные переменные
L
и
R
, отмечающие в массиве a
левый и правый концы отрезка, в котором искомое значение все еще может быть найдено:
L := 0; R := N–1;
(* ADruS18_ *)
m :=
L R;
WHILE (L <= R) & (a[m] # x) DO
IF a[m] < x THEN
L := m+1
ELSE
R := m–1
END;
m :=
L R
END
Подчеркнем фундаментальное структурное подобие этого алгоритма и алго%
ритма линейного поиска в предыдущем разделе: роль i
теперь играет тройка
L, m,
R
. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%
ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной m
Поиск
Фундаментальные структуры данных
52
Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R < k < N : a k
> x)
откуда выводим для цикла такое постусловие:
((L > R) OR (a m
= x)) & (A
A
A
A
Ak: 0
≤ k < L : a k
< x ) & (A
A
A
A
Ak: R < k < N : a k
> x)
Из него следует, что
((L > R) & (A
A
A
A
Ak: 0
≤ k < N : a k
≠ x)) OR (a m
= x)
Выбор m
, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%
ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log
2
N
, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%
кальное улучшение по сравнению с линейным поиском, где среднее число срав%
нений равно
N/2
Как уже упоминалось, можно заняться устранением дублирования инструк%
ции присваивания m
. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%
нений. Напомним, что число шагов не превышает log N
Более быстрое решение основано на следующем инварианте:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R
≤ k < N : a k
≥ x)
а поиск продолжается, пока два отрезка не будут покрывать весь массив:
L := 0; R := N;
(* ADruS18_ *)
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] < x THEN L := m+1 ELSE R := m END
END
Можно сказать, что теперь ищется не элемент a[m] = x
, а граница, отделяющая все элементы, меньшие x
, от всех прочих.
Условие окончания –
L
≥
R
. Есть ли гарантия его достижения? Чтобы убедить%
ся в этом, нужно показать, что в любых обстоятельствах разность
R–L
умень%
шается на каждом шаге. Условие
L < R
справедливо в начале каждого шага. Тогда
53
среднее арифметическое удовлетворяет условию
L
≤ m < R
. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1
пере%
менной
L
(что увеличивает
L
), либо значению m
переменной
R
(что уменьшает
R
),
и цикл прекращается при
L = R
Однако выполнение инварианта вместе с условием
L = R
еще не гарантирует успеха поиска. Разумеется, если
R = N
, то искомого значения в массиве нет. В про%
тивном случае нужно учесть, что элемент a[R]
еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x
. В отличие от первого решения, дан%
ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.
1.8.3. Поиск в таблице
Поиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%
дний случай встречается часто; массивы литер называют цепочками литер
(string), или словами. Определим тип
String так:
String = ARRAY M OF CHAR
и пусть отношение порядка для цепочек определено так, что для цепочек x
и y
:
(x = y)
≡ (A
A
A
A
Aj: 0
≤ j < M : x j
= y j
)
(x < y)
≡ EEEEEi: 0 ≤ i < N : ((A
A
A
A
Aj: 0
≤ j < i : x j
= y j
) & (x i
< y i
))
Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,
достаточно искать пару неравных литер. Отсутствие такой пары означает равен%
ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%
ше 30, и будем использовать линейный поиск.
В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%
руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения
M
. Такая схема оставляет достаточно гибкос%
ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:
1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.
Обычно для этой цели используют литеру
0X
,
не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)
2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%
ка литер s имеет вид s = s
0
, s
1
, s
2
, ... , s
N–1
где s
1
... s
N–1
суть фактические литеры цепочки, а s
0
= CHR(N)
. Преимуще%
ство этого способа – в том, что длина доступна непосредственно, а недоста%
Поиск
Фундаментальные структуры данных
54
ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.
В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;
WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 END
Здесь концевая литера играет роль барьера, а инвариант цикла имеет вид
A
A
A
A
Aj: 0
≤ j < i : x j
= y j
≠ 0X,
и в результате выполняется следующее условие:
((x i
= 0X) OR (x i
≠ y i
)) & (Aj: 0
≤ j < i : x j
= y j
≠ 0X).
Отсюда следует совпадение цепочек x
и y
, если x
i
= y
i
; при этом x < y
, если x
i
< y i
Теперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.
Например, пусть таблица
T
и аргумент поиска x
определены так:
T: ARRAY N OF String;
x: String
Предполагая, что
N
может быть большим и что таблица упорядочена по алфа%
виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:
i := –1;
(* ADruS183 *)
L := 0; R := N;
WHILE L < R DO
m := (L+R) DIV 2;
i := 0;
WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;
IF T[m,i] < x[i] THEN L := m+1 ELSE R := m END
END;
IF R < N THEN
i := 0;
WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 END
END
(* (R < N) & (T[R,i] = x[i]) *)
1.9. Поиск образца в тексте
(string search)
Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s
из
N
элементов и массив p
из
M
элемен%
тов, где
0 < M < N
:
55
s: ARRAY N OF Item p: ARRAY M OF Item
Требуется найти первое вхождение p
в s
. Обычно элементы этих массивов
(
Item
) являются литерами; тогда s
можно считать текстом, а p
– образцом
(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.
Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,
с помощью которых осуществляются просмотры, определяется данными. Коррект%
ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант цикла
WHILE
. Поскольку эта фундаментальная и мощная управляющая структура пред%
ставлена не во всех языках программирования, ее описанию посвящено приложе%
ние C.
Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),
представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%
тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%
смотре базовой идеи простого алгоритма.
1.9.1. Простой поиск образца в тексте
Прежде чем думать об эффективности, опишем простейший алгоритм поиска.
Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p
длины
M
, сопоставляемый с текстом s
длины
N
в позиции i
. При этом индекс j
нумерует элементы образца, и элементу образца p[j]
сопоставляется элемент текста s[i+j]
Рис. 1.9. Образец длины
M, сопоставляемый с текстом s в позиции i
Предикат
R(i)
, описывающий полное совпадение образца с литерами текста в позиции i
, формулируется так:
R(i) = A
A
A
A
Ak: 0
≤ j < M : p j
= s i+j
Поиск образца в тексте (string search)
Фундаментальные структуры данных
56
Допустимые значения i
, в которых может реализоваться совпадение, – от
0
до
N–M
включительно. Вычисление условия
R
естественным образом сводится к пов%
торным сравнениям отдельных пар литер. Если применить теорему де Моргана к
R
, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:
R(i) = (A
A
A
A
Aj: 0
≤ j < M : p j
= s i+j
) = (E
E
E
E
Ej: 0
≤ j < M : p j
≠ s i+j
)
Поэтому предикат
R(i)
легко реализуется в виде процедуры%функции, постро%
енной по схеме линейного поиска:
PROCEDURE R (i: INTEGER): BOOLEAN;
VAR j: INTEGER;
BEGIN
(* 0 <= i < N *)
j := 0;
WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;
RETURN (j < M)
END R
Пусть искомым результатом будет значение индекса i
, указывающее на первое
вхождение образца в тексте s
. Тогда должно удовлетворяться условие
R(i)
. Но так как требуется найти именно первое вхождение образца, то условие
R(k)
должно быть ложным для всех k < i
. Обозначим это новое условие как
Q(i)
:
Q(i) = A
A
A
A
Ak: 0
≤ k < i : R(k)
Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):
i := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & R(i) DO INC(i) END
Инвариантом этого цикла является предикат
Q(i)
, который выполняется как до инструкции
INC(i)
, так и – благодаря второму операнду охраны – после нее.
Достоинство этого алгоритма – легкость понимания логики благодаря чет%
кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%
цедуры%функции
R
. Однако это же свойство может обернуться и недостатком:
во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%
сматриваемые ниже, используют информацию, получаемую во внутреннем цик%
ле, для увеличения i
во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%
ся от процедуры вычисления
R
, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i
. Од%
нако логика взаимодействия двух вложенных циклов через логическую пере%
менную теряет исходную прозрачность, что чревато ошибками при эволюции программы.
57
В подобных случаях удобно использовать так называемый цикл Дейкстры –
цикл
WHILE
с несколькими ветвями, каждая из которых имеет свою охрану (см.
приложение C). В данном случае две ветви будут соответствовать шагам по i
и j
соответственно. Вспомним рис. 1.9 и введем предикат
P(i, j)
, означающий совпаде%
ние первых j
литер образца с литерами текста, начиная с позиции i
:
P(i, j) = A
A
A
A
Ak: 0
≤ k < j : s i+k
= p k
Тогда
R(i) = P(i, M)
Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%
ется значениями пары переменных i и j
. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%
ле каждого шага при новых i или j
) можно выбрать так: в позициях до i
совпадений образца нет, а в позиции i
имеется частичное совпадение первых j
литер образца,
что формально записывается следующим образом:
Q(i) & P(i, j)
Очевидно, j = M
означает, что имеет место искомое вхождение образца в текст в позиции i
, а i > N – M
означает, что вхождений нет вообще.
Очевидно, для поиска достаточно пытаться увеличивать j
на 1, чтобы расши%
рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%
вую позицию, увеличив i
на
1
, и сбросить j
в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:
i := 0; j := 0;
WHILE
v # DO
INC( j )
ELSIF
DO
INC( i ); j := 0
END;
Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%
но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (
i
≤
N–M) & (j < M) & (s i+j
= p j
)
,
гаранти%
рующее
P(i, j)
после увеличения j
. Для второй ветки последний операнд конъюн%
кции должен содержать неравенство вместо равенства, что влечет
R(i)
и гаранти%
рует
Q(i)
после увеличения i
. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:
i := 0; j := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DO
INC( j )
ELSIF (i <= N–M) & (j < M) DO
INC( i ); j := 0
END;
Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%
ран, то есть (
i
>
N–M) OR (j >= M)
, причем из структуры шагов цикла дополнитель%
Поиск образца в тексте (string search)
Фундаментальные структуры данных
58
но следует, что два операнда не могут быть истинны одновременно, а j
не может превысить
M
. Тогда i
>
N–M
означает, что вхождений образца нет, а j = M
– что справедливо
Q(i) & P(i, M)
, то есть найдено искомое полное вхождение в позиции i
Анализ простого поиска в тексте. Этот алгоритм довольно эффективен,
если предполагать, что неравенство литер обнаруживается лишь после небольшо%
го числа сравнений литер, то есть при небольших j
. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%
ет тревогу. Например, рассмотрим текст, состоящий из
N–1
букв
A
, за которыми следует единственная буква
B
, и пусть образец состоит из
M–1
литер
A
, за которы%
ми следует одна
B
. Тогда нужно порядка
N*M
сравнений, чтобы обнаружить совпа%
дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%
ды, которые в этом худшем случае ведут себя гораздо лучше.
1.9.2. Алгоритм Кнута, Морриса и Пратта
Около 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%
ко порядка
N
сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%
нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%
цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%
щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%
ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%
ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%
ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Здесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i
; переменная j
, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10).
59
Центральным пунктом алгоритма является сравнение элементов s[i]
и p[j]
, при равенстве i и
j одновременно сдвигаются вправо на единицу, а в случае неравен%
ства должен быть сдвинут образец, что выражается присваиванием j
некоторого меньшего значения
D
. Граничный случай j = 0
показывает, что нужно предусмо%
треть возможность сдвига образца целиком за текущую позицию сравнения (что%
бы элемент p[0]
оказался выравнен с s[i+1]
). Для такого случая удобно положить
D = –1
. Главный цикл алгоритма приобретает следующий вид:
i := 0; j := 0;
WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DO
INC( i ); INC( j )
ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)
j := D
END;
Эта формулировка не совсем полна, так как еще не определено значение сдви%
га
D
. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это
Q(i–j) & P(i–j, j)
Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%
ражение
(j >= M) OR (i >= N)
, но реально могут реализоваться только равенства.
Если алгоритм останавливается при j = M
, то имеет место совпадение с образцом в позиции i–M
(член
P(i–j, j)
инварианта влечет
P(i–M, M) = R(i)
). В противном слу%
чае остановка происходит с i = N
, и так как здесь j < M
, то первый член инварианта,
Q(i–j)
, влечет отсутствие совпадений с образцом во всем тексте.
Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,
инвариант выполнен в начале цикла при значениях i = j = 0
. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i
и j
на 1. В самом деле,
Q(i–j)
не нарушается, так как разность i–j не меняется.
Не нарушается и
P(i–j, j)
при увеличении j благодаря равенству в охране ветки (ср.
определение
P
). Что касается второй ветки, то мы просто потребуем, чтобы значе%
ние
D
всегда было таким, чтобы замена j
на
D
сохраняла инвариант.
Присваивание j := D при условии
D < j означает сдвиг образца вправо на j–D
позиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать
D
как можно меньше. Это иллюстрируется на рис. 1.11.
Рис. 1.10. В обозначениях алгоритма КМП
позиция в тексте первой литеры образца равна i–j
(а не i, как в простом алгоритме)
Поиск образца в тексте (string search)
Фундаментальные структуры данных
60
Если мы хотим, чтобы инвариант
P(i–j, j) & Q(i–j)
выполнялся после присваива%
ния j := D
, то до присваивания должно выполняться условие
P(i–D, D) & Q(i–D)
Это предусловие и будет ключом к поиску подходящего выражения для
D наряду с условием
P(i–j, j) & Q(i–j)
, которое предполагается уже выполненным перед при%
сваиванием (к этой точке программы относятся все дальнейшие рассуждения).
Решающее наблюдение состоит в том, что истинность
P(i–j, j)
означает p
0
... p j–1
= s i–j
... s i–1
(ведь мы только что просмотрели первые j
литер образца и установили их совпа%
дение с соответствующими литерами текста). Поэтому условие
P(i–D, D)
при
D
< j
,
то есть p
0
... p
D–1
= s i–D
... s i–1
превращается в уравнение для
D
:
p
0
... p
D–1
= p j–D
... p j–1
Что касается
Q(i–D)
, то это условие следует из
Q(i–j)
, если
R(i–k)
для k = D+1 ... j
. При этом истинность
R(i–k)
для j = k гарантируется неравенством s[i] # p[j]
. Хотя условия
R(i–k)
≡
P(i–k, M) для k = D+1 ... j–1
нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия
P(i–k,k)
. Раскрывая их с учетом уже найденных равенств между элементами s
и p
, получаем следующее условие:
p
0
... p k–1
≠
p j–k
... p j–1
для всех k = D+1 ... j–1
То есть
D
должно быть максимальным решением вышеприведенного уравнения.
Рисунок 1.12 показывает, как работает этот механизм сдвигов.
Если решения для
D
нет, то совпадение с образцом невозможно ни в какой по%
зиции ниже i+1
. Тогда полагаем
D := –1
. Такая ситуация показана в верхнем при%
мере на рис. 1.13.
Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%
чуть улучшить: если бы литера p
j была равна
A
вместо
F
, то мы знали бы, что соот%
ветствующая литера в тексте никак не может быть равна
A
, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с
D = –1
(ср. нижнюю часть рис. 1.13). Поэтому при поиске
D
можно сразу наложить дополнительное ограничение p
D
≠
p j
. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.
Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций
61
Рис. 1.12. Частичные совпадения образца и вычисление
D
Рис. 1.13. Сдвиг образца за позицию последней литеры
Поиск образца в тексте (string search)
Фундаментальные структуры данных
62
Важный итог состоит в том, что значение
D
определяется только образцом и значением j
, но не зависит от текста. Обозначим
D
для заданного j
как d
j
. Таблицу d
можно построить до начала собственно поиска, что можно назвать предкомпи%
ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (
M << N
). Если нужно найти несколько вхож%
дений одного и того же образца, то таблицу d
можно использовать повторно.
Величина d
j
< j является длиной максимальной последовательности, удовлет%
воряющей условию p
0
... p d[j]–1
= p j–d[j]
... p j–1
с дополнительным ограничением p
d[j]
≠
p j
. Проход построенного цикла по самому образцу p
вместо s
последовательно находит максимальные последовательности,
что позволяет вычислить d
j
:
PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS192_ *)
(* p M s N; M <= Mmax*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY Mmax OF INTEGER;
BEGIN
(* d p*)
d[0] := –1;
IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;
j := 1; k := 0;
WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DO
k := d[k]
ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)
INC( j ); INC( k );
IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );
END;
(* *)
i := 0; j := 0;
WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DO
j := d[j];
ELSIF (j < M) & (i < N) DO
INC(i); INC(j);
END;
IF j = M THEN r := i–M ELSE r := –1 END
END Search
Анализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,
как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок
M+N
, что гораздо лучше, чем
M*N
в простом поиске. Они также указали на то приятное обстоятельство, что указатель i
всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%
тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%
жет привести к трудностям, если текст считывается из внешней памяти, так как
63
в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%
ные данные буферизуются, образец может оказаться таким, что потребуется воз%
врат за пределы буфера.
1.9.3. Алгоритм Бойера и Мура
Хитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%
наруживается после частичного совпадения некоторого фрагмента. Только в та%
ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%
этому выигрыш от использования КМП%стратегии оказывается не слишком су%
щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.
БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%
тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d
. Для каждой литеры x
из всего множества литер обозначим как d
x расстояние от конца образца до ее самого пра%
вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%
стом и образцом. Тогда образец можно сразу сдвигать вправо на d
p[M–1]
позиций,
и весьма вероятно, что эта величина окажется больше, чем 1. Если p
M–1
вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Так как литеры теперь сравниваются справа налево, то будет удобнее исполь%
зовать следующие слегка модифицированные версии предикатов
P
,
R
и
Q
:
P(i, j) = A
A
A
A
Ak: j
≤ k < M : s i–M+k
= p k
R(i)
= P(i, 0)
Q(i)
= A
A
A
A
Ak: M
≤ k < i : R(k)
В этих терминах инвариант цикла будет иметь прежний вид:
Q(i) & P(i, j)
. Мы еще введем переменную k = i–M+j.
Теперь можно дать следующую формулировку
БМ%алгоритма:
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END
Поиск образца в тексте (string search)
Фундаментальные структуры данных
64
Индексы удовлетворяют ограничениям
0
≤
j
≤
M, M
≤
i, 0 < k
≤
i
. Остановка алгоритма с j = 0
влечет
P(i, 0) = R(i)
, то есть совпадение в позиции k = i–M
. Для остановки c j > 0
нужно, чтобы i > N
; тогда
Q(i)
влечет
Q(N)
и, следовательно, от%
сутствие совпадений. Разумеется, нам еще нужно убедиться, что
Q(i)
и
P(i, j)
действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку
Q(M)
и
P(x, M)
всегда истинны.
Сначала рассмотрим первую ветку. Одновременное уменьшение k
и j
никак не влияет на
Q(i)
, и, следовательно, поскольку уже установлено, что s
k–1
= p j–1
, а
P(i, j)
выполняется до операции уменьшения j
, то
P(i, j)
выполняется и после нее.
Во второй ветке достаточно показать, что присваивание i := i + d s[i–1]
не нару%
шает инвариант
Q(i)
, так как после остальных присваиваний
P(i, j) выполняется автоматически.
Q(i) выполняется после изменения i, если до него выполняется
Q(i+d s[i–1]
)
. Поскольку мы знаем, что выполняется
Q(i)
, достаточно установить, что
R(i+h)
для h = 1 .. d s[i–1]
–1
. Вспомним, что величина d
x определена как расстояние от конца до крайнего правого вхождения x
в образце. Формально это выражается в виде:
A
A
A
A
Ak: M–d x
≤ k < M–1 : p k
≠ x
Для x = s i–1
получаем
A
A
A
A
Ak: M–d s[i–1]
k < M–1 : s i–1
≠ p k
= A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : s i–1
≠ p
M–1–h
⇒ A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : R(i+h)
В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:
PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS193_ *)
(* p M s N*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY 128 OF INTEGER;
BEGIN
FOR i := 0 TO 127 DO d[i] := M END;
FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END;
IF j <= 0 THEN r := k ELSE r := –1 END
END Search
Анализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%
ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем
N
, сравнений, за исключением специ%
65
ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно
N/M
Авторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%
торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%
ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%
го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%
ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%
ния деталей, так как дополнительная сложность вычисления таблиц и самого по%
иска, по%видимому, не приводит к существенному выигрышу в эффективности.
Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.
Замечание переводчика. Три алгоритма поиска образца в тексте – простой,
КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.
КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построение
БМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%
ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%
ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%
граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,
что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%
явные, инстинктивные предположения – еще до первых попыток составить кон%
кретное решение, когда внимание уже будет поглощено не исходной задачей,
а красотой и эффективностью предполагаемого решения, ярко демонстрирую%
щего остроумие и изобретательность программиста, а также его познания в ме%
тодах оптимизации.
Упражнения
1.1. Обозначим мощности стандартных типов
INTEGER
,
REAL
и
CHAR
как c
int
, c
real и
c char
. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров:
Complex
,
Date
,
Person
,
Row
,
Card
,
Name
?
Упражнения
Фундаментальные структуры данных
66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)
соответствуют следующим операциям:
a) чтение и запись для элементов упакованных записей и массивов;
b) операции для множеств, включая проверку принадлежности числа мно%
жеству?
1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%
ных в виде последовательностей, а не массивов?
1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%
вов, записей или последовательностей, которое было бы удобно для опреде%
ления времени прибытия и отправления для заданных станции и направле%
ния поезда.
1.5. Пусть даны текст
T
, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов
A
и
B
. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%
мальной длины. Напишите программу, преобразующую текст
T
в текст
S
за%
меной каждого вхождения слова
A
i соответствующим словом
B
i
1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%
лагается, что определены константа
N > 0
и следующие переменные:
VAR i, j, k, x: INTEGER;
a: ARRAY N OF INTEGER;
Программа A:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF a[k] < x THEN i := k ELSE j := k END
UNTIL (a[k] = x) OR (i > j)
Программа B:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k–1 END;
IF a[k] < x THEN i := k+1 END
UNTIL i > j
Программа C:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k ELSE i := k+1 END
UNTIL i > j
67
Подсказка. Все программы должны заканчиваться с a
k
= x
, если такой эле%
мент присутствует, или a
k
≠ x
, если элемента со значением x нет.
1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%
сен. Песни нумеруются числами от
1
до
N
(пусть
N = 30
). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.
Подсказка: Используйте процедуры
Read и
ReadInt для чтения данных опроса.
TYPE hit = INTEGER;
reponse = RECORD name, firstname: Name;
male: BOOLEAN;
age: INTEGER;
choice: ARRAY 5 OF hit
END;
VAR poll: Files.File
Этот файл содержит входные данные для программы, вычисляющей следую%
щие результаты:
1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.
2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.
Перед каждым из пяти списков должен идти подходящий заголовок.
Литература
[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,
Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,
Хоор К. Структурное программирование. – М.: Мир, 1975).
[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:
Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)
[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,
1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).
[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:
Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).
Литература
Фундаментальные структуры данных
68
[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,
6, No. 4, (1974) 247–259.
[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.
ACM, 17, 10 (Oct. 1974), 549–557.
[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAM
J. Comput., 6, 2, (June 1977), 323–349.
[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,
10 (Oct. 1977), 762–772.
1 2 3 4 5 6 7 8 9 ... 22
1.8. Поиск
Задача поиска – одна из наиболее часто встречающихся в программировании.
Она также дает прекрасные возможности показать применение рассмотренных структур данных. Есть несколько основных вариаций на тему поиска, и здесь при%
думано множество алгоритмов. Основное предположение, которое мы делаем в дальнейшем изложении, состоит в том, что набор данных, в котором ищется за%
данное значение, фиксирован. Будем предполагать, что этот набор
N
элементов представлен массивом, скажем a: ARRAY N OF Item
Обычно элементы являются записями, одно из полей которых играет роль ключа. Тогда задача состоит в нахождении элемента, у которого поле ключа равно заданному значению x
, которое еще называют аргументом поиска. Найденный индекс i
, удовлетворяющий условию a[i].key = x
, позволит обратиться к другим полям найденного элемента. Поскольку нам здесь интересна только задача поис%
ка, но не данные, ради которых производится поиск, мы будем предполагать, что тип
Item состоит только из ключа, то есть сам является ключом.
Поиск
Фундаментальные структуры данных
50
1.8.1. Линейный поиск
Если нет никакой дополнительной информации о данных, то очевидное реше%
ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%
стно как линейный поиск. Поиск прекращается при одном из двух условий:
1. Элемент найден, то есть a
i
= x
2. Просмотрен весь массив, но элемент не найден.
Приходим к следующему алгоритму:
i := 0;
(* ADruS18_ *)
WHILE (i < N) & (a[i] # x) DO INC(i) END
Отметим, что порядок операндов в булевском выражении важен.
До и после каждого шага цикла выполняется следующее условие:
(0
≤ i < N) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Такое условие называется инвариантом. В данном случае инвариант означает,
что для всех значений k
, меньших, чем i
, среди a
k искомого значения x
нет. Заме%
тим, что до и после каждого шага цикла значения i
– разные. Сохранение инвари%
анта при изменении i
имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами
WHILE
и
DO
).
Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%
ного фрагмента):
((i = N) OR (a i
= x )) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Это условие не только является искомым результатом, но еще и подразумева%
ет, что если найден элемент, равный x
, то это первый такой элемент. При этом i = N
означает, что искомого значения не найдено.
Окончание цикла гарантировано, так как величина i
на каждом шаге увели%
чивается и поэтому обязательно достигнет границы
N
после конечного числа ша%
гов; на самом деле после
N
шагов, если искомого значения в массиве нет.
На каждом шаге нужно вычислять булевское выражение и увеличивать ин%
декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%
ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%
печить, помещая дополнительный элемент со значением x
в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%
ствует выходу поиска за границу массива. Теперь массив a
объявляется так:
a: ARRAY N+1 OF INTEGER,
51
и алгоритм линейного поиска с барьером выражается следующим образом:
a[N] := x; i := 0;
(* ADruS18_ *)
WHILE a[i] # x DO INC(i) END
В итоге получается условие, выведенное из того же инварианта, что и ранее:
(a i
= x) & (Ak: 0
≤ k < i : a k
≠ x)
Очевидно, из i = N
следует, что искомое значение не встретилось (не считая значения%барьера).
1.8.2. Поиск делением пополам
Понятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%
раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%
вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%
пользует информацию о том, что массив a
упорядочен, то есть что выполняется условие
A
A
A
A
Ak: 1
≤ k < N : a k–1
≤ a k
Ключевая идея – в том, чтобы выбрать наугад элемент, скажем a
m
, и сравнить его с искомым значением x
. Если он равен x
, то поиск прекращается; если он мень%
ше x
, то можно заключить, что все элементы с индексами, равными или меньшими m
, можно игнорировать в дальнейшем поиске; а если он больше x
, то можно игно%
рировать все значения индекса, большие или равные m
. Это приводит к следую%
щему алгоритму, который носит название поиск делением пополам (binary search);
в нем используются две индексные переменные
L
и
R
, отмечающие в массиве a
левый и правый концы отрезка, в котором искомое значение все еще может быть найдено:
L := 0; R := N–1;
(* ADruS18_ *)
m :=
L R;
WHILE (L <= R) & (a[m] # x) DO
IF a[m] < x THEN
L := m+1
ELSE
R := m–1
END;
m :=
L R
END
Подчеркнем фундаментальное структурное подобие этого алгоритма и алго%
ритма линейного поиска в предыдущем разделе: роль i
теперь играет тройка
L, m,
R
. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%
ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной m
Поиск
Фундаментальные структуры данных
52
Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R < k < N : a k
> x)
откуда выводим для цикла такое постусловие:
((L > R) OR (a m
= x)) & (A
A
A
A
Ak: 0
≤ k < L : a k
< x ) & (A
A
A
A
Ak: R < k < N : a k
> x)
Из него следует, что
((L > R) & (A
A
A
A
Ak: 0
≤ k < N : a k
≠ x)) OR (a m
= x)
Выбор m
, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%
ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log
2
N
, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%
кальное улучшение по сравнению с линейным поиском, где среднее число срав%
нений равно
N/2
Как уже упоминалось, можно заняться устранением дублирования инструк%
ции присваивания m
. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%
нений. Напомним, что число шагов не превышает log N
Более быстрое решение основано на следующем инварианте:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R
≤ k < N : a k
≥ x)
а поиск продолжается, пока два отрезка не будут покрывать весь массив:
L := 0; R := N;
(* ADruS18_ *)
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] < x THEN L := m+1 ELSE R := m END
END
Можно сказать, что теперь ищется не элемент a[m] = x
, а граница, отделяющая все элементы, меньшие x
, от всех прочих.
Условие окончания –
L
≥
R
. Есть ли гарантия его достижения? Чтобы убедить%
ся в этом, нужно показать, что в любых обстоятельствах разность
R–L
умень%
шается на каждом шаге. Условие
L < R
справедливо в начале каждого шага. Тогда
53
среднее арифметическое удовлетворяет условию
L
≤ m < R
. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1
пере%
менной
L
(что увеличивает
L
), либо значению m
переменной
R
(что уменьшает
R
),
и цикл прекращается при
L = R
Однако выполнение инварианта вместе с условием
L = R
еще не гарантирует успеха поиска. Разумеется, если
R = N
, то искомого значения в массиве нет. В про%
тивном случае нужно учесть, что элемент a[R]
еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x
. В отличие от первого решения, дан%
ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.
1.8.3. Поиск в таблице
Поиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%
дний случай встречается часто; массивы литер называют цепочками литер
(string), или словами. Определим тип
String так:
String = ARRAY M OF CHAR
и пусть отношение порядка для цепочек определено так, что для цепочек x
и y
:
(x = y)
≡ (A
A
A
A
Aj: 0
≤ j < M : x j
= y j
)
(x < y)
≡ EEEEEi: 0 ≤ i < N : ((A
A
A
A
Aj: 0
≤ j < i : x j
= y j
) & (x i
< y i
))
Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,
достаточно искать пару неравных литер. Отсутствие такой пары означает равен%
ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%
ше 30, и будем использовать линейный поиск.
В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%
руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения
M
. Такая схема оставляет достаточно гибкос%
ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:
1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.
Обычно для этой цели используют литеру
0X
,
не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)
2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%
ка литер s имеет вид s = s
0
, s
1
, s
2
, ... , s
N–1
где s
1
... s
N–1
суть фактические литеры цепочки, а s
0
= CHR(N)
. Преимуще%
ство этого способа – в том, что длина доступна непосредственно, а недоста%
Поиск
Фундаментальные структуры данных
54
ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.
В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;
WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 END
Здесь концевая литера играет роль барьера, а инвариант цикла имеет вид
A
A
A
A
Aj: 0
≤ j < i : x j
= y j
≠ 0X,
и в результате выполняется следующее условие:
((x i
= 0X) OR (x i
≠ y i
)) & (Aj: 0
≤ j < i : x j
= y j
≠ 0X).
Отсюда следует совпадение цепочек x
и y
, если x
i
= y
i
; при этом x < y
, если x
i
< y i
Теперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.
Например, пусть таблица
T
и аргумент поиска x
определены так:
T: ARRAY N OF String;
x: String
Предполагая, что
N
может быть большим и что таблица упорядочена по алфа%
виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:
i := –1;
(* ADruS183 *)
L := 0; R := N;
WHILE L < R DO
m := (L+R) DIV 2;
i := 0;
WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;
IF T[m,i] < x[i] THEN L := m+1 ELSE R := m END
END;
IF R < N THEN
i := 0;
WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 END
END
(* (R < N) & (T[R,i] = x[i]) *)
1.9. Поиск образца в тексте
(string search)
Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s
из
N
элементов и массив p
из
M
элемен%
тов, где
0 < M < N
:
55
s: ARRAY N OF Item p: ARRAY M OF Item
Требуется найти первое вхождение p
в s
. Обычно элементы этих массивов
(
Item
) являются литерами; тогда s
можно считать текстом, а p
– образцом
(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.
Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,
с помощью которых осуществляются просмотры, определяется данными. Коррект%
ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант цикла
WHILE
. Поскольку эта фундаментальная и мощная управляющая структура пред%
ставлена не во всех языках программирования, ее описанию посвящено приложе%
ние C.
Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),
представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%
тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%
смотре базовой идеи простого алгоритма.
1.9.1. Простой поиск образца в тексте
Прежде чем думать об эффективности, опишем простейший алгоритм поиска.
Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p
длины
M
, сопоставляемый с текстом s
длины
N
в позиции i
. При этом индекс j
нумерует элементы образца, и элементу образца p[j]
сопоставляется элемент текста s[i+j]
Рис. 1.9. Образец длины
M, сопоставляемый с текстом s в позиции i
Предикат
R(i)
, описывающий полное совпадение образца с литерами текста в позиции i
, формулируется так:
R(i) = A
A
A
A
Ak: 0
≤ j < M : p j
= s i+j
Поиск образца в тексте (string search)
Фундаментальные структуры данных
56
Допустимые значения i
, в которых может реализоваться совпадение, – от
0
до
N–M
включительно. Вычисление условия
R
естественным образом сводится к пов%
торным сравнениям отдельных пар литер. Если применить теорему де Моргана к
R
, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:
R(i) = (A
A
A
A
Aj: 0
≤ j < M : p j
= s i+j
) = (E
E
E
E
Ej: 0
≤ j < M : p j
≠ s i+j
)
Поэтому предикат
R(i)
легко реализуется в виде процедуры%функции, постро%
енной по схеме линейного поиска:
PROCEDURE R (i: INTEGER): BOOLEAN;
VAR j: INTEGER;
BEGIN
(* 0 <= i < N *)
j := 0;
WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;
RETURN (j < M)
END R
Пусть искомым результатом будет значение индекса i
, указывающее на первое
вхождение образца в тексте s
. Тогда должно удовлетворяться условие
R(i)
. Но так как требуется найти именно первое вхождение образца, то условие
R(k)
должно быть ложным для всех k < i
. Обозначим это новое условие как
Q(i)
:
Q(i) = A
A
A
A
Ak: 0
≤ k < i : R(k)
Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):
i := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & R(i) DO INC(i) END
Инвариантом этого цикла является предикат
Q(i)
, который выполняется как до инструкции
INC(i)
, так и – благодаря второму операнду охраны – после нее.
Достоинство этого алгоритма – легкость понимания логики благодаря чет%
кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%
цедуры%функции
R
. Однако это же свойство может обернуться и недостатком:
во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%
сматриваемые ниже, используют информацию, получаемую во внутреннем цик%
ле, для увеличения i
во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%
ся от процедуры вычисления
R
, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i
. Од%
нако логика взаимодействия двух вложенных циклов через логическую пере%
менную теряет исходную прозрачность, что чревато ошибками при эволюции программы.
57
В подобных случаях удобно использовать так называемый цикл Дейкстры –
цикл
WHILE
с несколькими ветвями, каждая из которых имеет свою охрану (см.
приложение C). В данном случае две ветви будут соответствовать шагам по i
и j
соответственно. Вспомним рис. 1.9 и введем предикат
P(i, j)
, означающий совпаде%
ние первых j
литер образца с литерами текста, начиная с позиции i
:
P(i, j) = A
A
A
A
Ak: 0
≤ k < j : s i+k
= p k
Тогда
R(i) = P(i, M)
Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%
ется значениями пары переменных i и j
. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%
ле каждого шага при новых i или j
) можно выбрать так: в позициях до i
совпадений образца нет, а в позиции i
имеется частичное совпадение первых j
литер образца,
что формально записывается следующим образом:
Q(i) & P(i, j)
Очевидно, j = M
означает, что имеет место искомое вхождение образца в текст в позиции i
, а i > N – M
означает, что вхождений нет вообще.
Очевидно, для поиска достаточно пытаться увеличивать j
на 1, чтобы расши%
рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%
вую позицию, увеличив i
на
1
, и сбросить j
в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:
i := 0; j := 0;
WHILE
v # DO
INC( j )
ELSIF
DO
INC( i ); j := 0
END;
Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%
но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (
i
≤
N–M) & (j < M) & (s i+j
= p j
)
,
гаранти%
рующее
P(i, j)
после увеличения j
. Для второй ветки последний операнд конъюн%
кции должен содержать неравенство вместо равенства, что влечет
R(i)
и гаранти%
рует
Q(i)
после увеличения i
. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:
i := 0; j := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DO
INC( j )
ELSIF (i <= N–M) & (j < M) DO
INC( i ); j := 0
END;
Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%
ран, то есть (
i
>
N–M) OR (j >= M)
, причем из структуры шагов цикла дополнитель%
Поиск образца в тексте (string search)
Фундаментальные структуры данных
58
но следует, что два операнда не могут быть истинны одновременно, а j
не может превысить
M
. Тогда i
>
N–M
означает, что вхождений образца нет, а j = M
– что справедливо
Q(i) & P(i, M)
, то есть найдено искомое полное вхождение в позиции i
Анализ простого поиска в тексте. Этот алгоритм довольно эффективен,
если предполагать, что неравенство литер обнаруживается лишь после небольшо%
го числа сравнений литер, то есть при небольших j
. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%
ет тревогу. Например, рассмотрим текст, состоящий из
N–1
букв
A
, за которыми следует единственная буква
B
, и пусть образец состоит из
M–1
литер
A
, за которы%
ми следует одна
B
. Тогда нужно порядка
N*M
сравнений, чтобы обнаружить совпа%
дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%
ды, которые в этом худшем случае ведут себя гораздо лучше.
1.9.2. Алгоритм Кнута, Морриса и Пратта
Около 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%
ко порядка
N
сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%
нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%
цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%
щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%
ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%
ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%
ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Здесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i
; переменная j
, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10).
59
Центральным пунктом алгоритма является сравнение элементов s[i]
и p[j]
, при равенстве i и
j одновременно сдвигаются вправо на единицу, а в случае неравен%
ства должен быть сдвинут образец, что выражается присваиванием j
некоторого меньшего значения
D
. Граничный случай j = 0
показывает, что нужно предусмо%
треть возможность сдвига образца целиком за текущую позицию сравнения (что%
бы элемент p[0]
оказался выравнен с s[i+1]
). Для такого случая удобно положить
D = –1
. Главный цикл алгоритма приобретает следующий вид:
i := 0; j := 0;
WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DO
INC( i ); INC( j )
ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)
j := D
END;
Эта формулировка не совсем полна, так как еще не определено значение сдви%
га
D
. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это
Q(i–j) & P(i–j, j)
Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%
ражение
(j >= M) OR (i >= N)
, но реально могут реализоваться только равенства.
Если алгоритм останавливается при j = M
, то имеет место совпадение с образцом в позиции i–M
(член
P(i–j, j)
инварианта влечет
P(i–M, M) = R(i)
). В противном слу%
чае остановка происходит с i = N
, и так как здесь j < M
, то первый член инварианта,
Q(i–j)
, влечет отсутствие совпадений с образцом во всем тексте.
Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,
инвариант выполнен в начале цикла при значениях i = j = 0
. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i
и j
на 1. В самом деле,
Q(i–j)
не нарушается, так как разность i–j не меняется.
Не нарушается и
P(i–j, j)
при увеличении j благодаря равенству в охране ветки (ср.
определение
P
). Что касается второй ветки, то мы просто потребуем, чтобы значе%
ние
D
всегда было таким, чтобы замена j
на
D
сохраняла инвариант.
Присваивание j := D при условии
D < j означает сдвиг образца вправо на j–D
позиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать
D
как можно меньше. Это иллюстрируется на рис. 1.11.
Рис. 1.10. В обозначениях алгоритма КМП
позиция в тексте первой литеры образца равна i–j
(а не i, как в простом алгоритме)
Поиск образца в тексте (string search)
Фундаментальные структуры данных
60
Если мы хотим, чтобы инвариант
P(i–j, j) & Q(i–j)
выполнялся после присваива%
ния j := D
, то до присваивания должно выполняться условие
P(i–D, D) & Q(i–D)
Это предусловие и будет ключом к поиску подходящего выражения для
D наряду с условием
P(i–j, j) & Q(i–j)
, которое предполагается уже выполненным перед при%
сваиванием (к этой точке программы относятся все дальнейшие рассуждения).
Решающее наблюдение состоит в том, что истинность
P(i–j, j)
означает p
0
... p j–1
= s i–j
... s i–1
(ведь мы только что просмотрели первые j
литер образца и установили их совпа%
дение с соответствующими литерами текста). Поэтому условие
P(i–D, D)
при
D
< j
,
то есть p
0
... p
D–1
= s i–D
... s i–1
превращается в уравнение для
D
:
p
0
... p
D–1
= p j–D
... p j–1
Что касается
Q(i–D)
, то это условие следует из
Q(i–j)
, если
R(i–k)
для k = D+1 ... j
. При этом истинность
R(i–k)
для j = k гарантируется неравенством s[i] # p[j]
. Хотя условия
R(i–k)
≡
P(i–k, M) для k = D+1 ... j–1
нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия
P(i–k,k)
. Раскрывая их с учетом уже найденных равенств между элементами s
и p
, получаем следующее условие:
p
0
... p k–1
≠
p j–k
... p j–1
для всех k = D+1 ... j–1
То есть
D
должно быть максимальным решением вышеприведенного уравнения.
Рисунок 1.12 показывает, как работает этот механизм сдвигов.
Если решения для
D
нет, то совпадение с образцом невозможно ни в какой по%
зиции ниже i+1
. Тогда полагаем
D := –1
. Такая ситуация показана в верхнем при%
мере на рис. 1.13.
Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%
чуть улучшить: если бы литера p
j была равна
A
вместо
F
, то мы знали бы, что соот%
ветствующая литера в тексте никак не может быть равна
A
, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с
D = –1
(ср. нижнюю часть рис. 1.13). Поэтому при поиске
D
можно сразу наложить дополнительное ограничение p
D
≠
p j
. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.
Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций
61
Рис. 1.12. Частичные совпадения образца и вычисление
D
Рис. 1.13. Сдвиг образца за позицию последней литеры
Поиск образца в тексте (string search)
Фундаментальные структуры данных
62
Важный итог состоит в том, что значение
D
определяется только образцом и значением j
, но не зависит от текста. Обозначим
D
для заданного j
как d
j
. Таблицу d
можно построить до начала собственно поиска, что можно назвать предкомпи%
ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (
M << N
). Если нужно найти несколько вхож%
дений одного и того же образца, то таблицу d
можно использовать повторно.
Величина d
j
< j является длиной максимальной последовательности, удовлет%
воряющей условию p
0
... p d[j]–1
= p j–d[j]
... p j–1
с дополнительным ограничением p
d[j]
≠
p j
. Проход построенного цикла по самому образцу p
вместо s
последовательно находит максимальные последовательности,
что позволяет вычислить d
j
:
PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS192_ *)
(* p M s N; M <= Mmax*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY Mmax OF INTEGER;
BEGIN
(* d p*)
d[0] := –1;
IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;
j := 1; k := 0;
WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DO
k := d[k]
ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)
INC( j ); INC( k );
IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );
END;
(* *)
i := 0; j := 0;
WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DO
j := d[j];
ELSIF (j < M) & (i < N) DO
INC(i); INC(j);
END;
IF j = M THEN r := i–M ELSE r := –1 END
END Search
Анализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,
как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок
M+N
, что гораздо лучше, чем
M*N
в простом поиске. Они также указали на то приятное обстоятельство, что указатель i
всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%
тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%
жет привести к трудностям, если текст считывается из внешней памяти, так как
63
в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%
ные данные буферизуются, образец может оказаться таким, что потребуется воз%
врат за пределы буфера.
1.9.3. Алгоритм Бойера и Мура
Хитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%
наруживается после частичного совпадения некоторого фрагмента. Только в та%
ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%
этому выигрыш от использования КМП%стратегии оказывается не слишком су%
щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.
БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%
тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d
. Для каждой литеры x
из всего множества литер обозначим как d
x расстояние от конца образца до ее самого пра%
вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%
стом и образцом. Тогда образец можно сразу сдвигать вправо на d
p[M–1]
позиций,
и весьма вероятно, что эта величина окажется больше, чем 1. Если p
M–1
вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Так как литеры теперь сравниваются справа налево, то будет удобнее исполь%
зовать следующие слегка модифицированные версии предикатов
P
,
R
и
Q
:
P(i, j) = A
A
A
A
Ak: j
≤ k < M : s i–M+k
= p k
R(i)
= P(i, 0)
Q(i)
= A
A
A
A
Ak: M
≤ k < i : R(k)
В этих терминах инвариант цикла будет иметь прежний вид:
Q(i) & P(i, j)
. Мы еще введем переменную k = i–M+j.
Теперь можно дать следующую формулировку
БМ%алгоритма:
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END
Поиск образца в тексте (string search)
Фундаментальные структуры данных
64
Индексы удовлетворяют ограничениям
0
≤
j
≤
M, M
≤
i, 0 < k
≤
i
. Остановка алгоритма с j = 0
влечет
P(i, 0) = R(i)
, то есть совпадение в позиции k = i–M
. Для остановки c j > 0
нужно, чтобы i > N
; тогда
Q(i)
влечет
Q(N)
и, следовательно, от%
сутствие совпадений. Разумеется, нам еще нужно убедиться, что
Q(i)
и
P(i, j)
действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку
Q(M)
и
P(x, M)
всегда истинны.
Сначала рассмотрим первую ветку. Одновременное уменьшение k
и j
никак не влияет на
Q(i)
, и, следовательно, поскольку уже установлено, что s
k–1
= p j–1
, а
P(i, j)
выполняется до операции уменьшения j
, то
P(i, j)
выполняется и после нее.
Во второй ветке достаточно показать, что присваивание i := i + d s[i–1]
не нару%
шает инвариант
Q(i)
, так как после остальных присваиваний
P(i, j) выполняется автоматически.
Q(i) выполняется после изменения i, если до него выполняется
Q(i+d s[i–1]
)
. Поскольку мы знаем, что выполняется
Q(i)
, достаточно установить, что
R(i+h)
для h = 1 .. d s[i–1]
–1
. Вспомним, что величина d
x определена как расстояние от конца до крайнего правого вхождения x
в образце. Формально это выражается в виде:
A
A
A
A
Ak: M–d x
≤ k < M–1 : p k
≠ x
Для x = s i–1
получаем
A
A
A
A
Ak: M–d s[i–1]
k < M–1 : s i–1
≠ p k
= A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : s i–1
≠ p
M–1–h
⇒ A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : R(i+h)
В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:
PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS193_ *)
(* p M s N*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY 128 OF INTEGER;
BEGIN
FOR i := 0 TO 127 DO d[i] := M END;
FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END;
IF j <= 0 THEN r := k ELSE r := –1 END
END Search
Анализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%
ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем
N
, сравнений, за исключением специ%
65
ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно
N/M
Авторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%
торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%
ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%
го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%
ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%
ния деталей, так как дополнительная сложность вычисления таблиц и самого по%
иска, по%видимому, не приводит к существенному выигрышу в эффективности.
Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.
Замечание переводчика. Три алгоритма поиска образца в тексте – простой,
КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.
КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построение
БМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%
ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%
ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%
граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,
что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%
явные, инстинктивные предположения – еще до первых попыток составить кон%
кретное решение, когда внимание уже будет поглощено не исходной задачей,
а красотой и эффективностью предполагаемого решения, ярко демонстрирую%
щего остроумие и изобретательность программиста, а также его познания в ме%
тодах оптимизации.
Упражнения
1.1. Обозначим мощности стандартных типов
INTEGER
,
REAL
и
CHAR
как c
int
, c
real и
c char
. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров:
Complex
,
Date
,
Person
,
Row
,
Card
,
Name
?
Упражнения
Фундаментальные структуры данных
66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)
соответствуют следующим операциям:
a) чтение и запись для элементов упакованных записей и массивов;
b) операции для множеств, включая проверку принадлежности числа мно%
жеству?
1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%
ных в виде последовательностей, а не массивов?
1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%
вов, записей или последовательностей, которое было бы удобно для опреде%
ления времени прибытия и отправления для заданных станции и направле%
ния поезда.
1.5. Пусть даны текст
T
, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов
A
и
B
. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%
мальной длины. Напишите программу, преобразующую текст
T
в текст
S
за%
меной каждого вхождения слова
A
i соответствующим словом
B
i
1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%
лагается, что определены константа
N > 0
и следующие переменные:
VAR i, j, k, x: INTEGER;
a: ARRAY N OF INTEGER;
Программа A:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF a[k] < x THEN i := k ELSE j := k END
UNTIL (a[k] = x) OR (i > j)
Программа B:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k–1 END;
IF a[k] < x THEN i := k+1 END
UNTIL i > j
Программа C:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k ELSE i := k+1 END
UNTIL i > j
67
Подсказка. Все программы должны заканчиваться с a
k
= x
, если такой эле%
мент присутствует, или a
k
≠ x
, если элемента со значением x нет.
1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%
сен. Песни нумеруются числами от
1
до
N
(пусть
N = 30
). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.
Подсказка: Используйте процедуры
Read и
ReadInt для чтения данных опроса.
TYPE hit = INTEGER;
reponse = RECORD name, firstname: Name;
male: BOOLEAN;
age: INTEGER;
choice: ARRAY 5 OF hit
END;
VAR poll: Files.File
Этот файл содержит входные данные для программы, вычисляющей следую%
щие результаты:
1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.
2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.
Перед каждым из пяти списков должен идти подходящий заголовок.
Литература
[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,
Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,
Хоор К. Структурное программирование. – М.: Мир, 1975).
[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:
Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)
[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,
1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).
[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:
Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).
Литература
Фундаментальные структуры данных
68
[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,
6, No. 4, (1974) 247–259.
[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.
ACM, 17, 10 (Oct. 1974), 549–557.
[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAM
J. Comput., 6, 2, (June 1977), 323–349.
[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,
10 (Oct. 1977), 762–772.
1 2 3 4 5 6 7 8 9 ... 22
1.8. Поиск
Задача поиска – одна из наиболее часто встречающихся в программировании.
Она также дает прекрасные возможности показать применение рассмотренных структур данных. Есть несколько основных вариаций на тему поиска, и здесь при%
думано множество алгоритмов. Основное предположение, которое мы делаем в дальнейшем изложении, состоит в том, что набор данных, в котором ищется за%
данное значение, фиксирован. Будем предполагать, что этот набор
N
элементов представлен массивом, скажем a: ARRAY N OF Item
Обычно элементы являются записями, одно из полей которых играет роль ключа. Тогда задача состоит в нахождении элемента, у которого поле ключа равно заданному значению x
, которое еще называют аргументом поиска. Найденный индекс i
, удовлетворяющий условию a[i].key = x
, позволит обратиться к другим полям найденного элемента. Поскольку нам здесь интересна только задача поис%
ка, но не данные, ради которых производится поиск, мы будем предполагать, что тип
Item состоит только из ключа, то есть сам является ключом.
Поиск
Фундаментальные структуры данных
50
1.8.1. Линейный поиск
Если нет никакой дополнительной информации о данных, то очевидное реше%
ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%
стно как линейный поиск. Поиск прекращается при одном из двух условий:
1. Элемент найден, то есть a
i
= x
2. Просмотрен весь массив, но элемент не найден.
Приходим к следующему алгоритму:
i := 0;
(* ADruS18_ *)
WHILE (i < N) & (a[i] # x) DO INC(i) END
Отметим, что порядок операндов в булевском выражении важен.
До и после каждого шага цикла выполняется следующее условие:
(0
≤ i < N) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Такое условие называется инвариантом. В данном случае инвариант означает,
что для всех значений k
, меньших, чем i
, среди a
k искомого значения x
нет. Заме%
тим, что до и после каждого шага цикла значения i
– разные. Сохранение инвари%
анта при изменении i
имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами
WHILE
и
DO
).
Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%
ного фрагмента):
((i = N) OR (a i
= x )) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Это условие не только является искомым результатом, но еще и подразумева%
ет, что если найден элемент, равный x
, то это первый такой элемент. При этом i = N
означает, что искомого значения не найдено.
Окончание цикла гарантировано, так как величина i
на каждом шаге увели%
чивается и поэтому обязательно достигнет границы
N
после конечного числа ша%
гов; на самом деле после
N
шагов, если искомого значения в массиве нет.
На каждом шаге нужно вычислять булевское выражение и увеличивать ин%
декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%
ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%
печить, помещая дополнительный элемент со значением x
в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%
ствует выходу поиска за границу массива. Теперь массив a
объявляется так:
a: ARRAY N+1 OF INTEGER,
51
и алгоритм линейного поиска с барьером выражается следующим образом:
a[N] := x; i := 0;
(* ADruS18_ *)
WHILE a[i] # x DO INC(i) END
В итоге получается условие, выведенное из того же инварианта, что и ранее:
(a i
= x) & (Ak: 0
≤ k < i : a k
≠ x)
Очевидно, из i = N
следует, что искомое значение не встретилось (не считая значения%барьера).
1.8.2. Поиск делением пополам
Понятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%
раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%
вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%
пользует информацию о том, что массив a
упорядочен, то есть что выполняется условие
A
A
A
A
Ak: 1
≤ k < N : a k–1
≤ a k
Ключевая идея – в том, чтобы выбрать наугад элемент, скажем a
m
, и сравнить его с искомым значением x
. Если он равен x
, то поиск прекращается; если он мень%
ше x
, то можно заключить, что все элементы с индексами, равными или меньшими m
, можно игнорировать в дальнейшем поиске; а если он больше x
, то можно игно%
рировать все значения индекса, большие или равные m
. Это приводит к следую%
щему алгоритму, который носит название поиск делением пополам (binary search);
в нем используются две индексные переменные
L
и
R
, отмечающие в массиве a
левый и правый концы отрезка, в котором искомое значение все еще может быть найдено:
L := 0; R := N–1;
(* ADruS18_ *)
m :=
L R;
WHILE (L <= R) & (a[m] # x) DO
IF a[m] < x THEN
L := m+1
ELSE
R := m–1
END;
m :=
L R
END
Подчеркнем фундаментальное структурное подобие этого алгоритма и алго%
ритма линейного поиска в предыдущем разделе: роль i
теперь играет тройка
L, m,
R
. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%
ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной m
Поиск
Фундаментальные структуры данных
52
Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R < k < N : a k
> x)
откуда выводим для цикла такое постусловие:
((L > R) OR (a m
= x)) & (A
A
A
A
Ak: 0
≤ k < L : a k
< x ) & (A
A
A
A
Ak: R < k < N : a k
> x)
Из него следует, что
((L > R) & (A
A
A
A
Ak: 0
≤ k < N : a k
≠ x)) OR (a m
= x)
Выбор m
, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%
ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log
2
N
, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%
кальное улучшение по сравнению с линейным поиском, где среднее число срав%
нений равно
N/2
Как уже упоминалось, можно заняться устранением дублирования инструк%
ции присваивания m
. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%
нений. Напомним, что число шагов не превышает log N
Более быстрое решение основано на следующем инварианте:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R
≤ k < N : a k
≥ x)
а поиск продолжается, пока два отрезка не будут покрывать весь массив:
L := 0; R := N;
(* ADruS18_ *)
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] < x THEN L := m+1 ELSE R := m END
END
Можно сказать, что теперь ищется не элемент a[m] = x
, а граница, отделяющая все элементы, меньшие x
, от всех прочих.
Условие окончания –
L
≥
R
. Есть ли гарантия его достижения? Чтобы убедить%
ся в этом, нужно показать, что в любых обстоятельствах разность
R–L
умень%
шается на каждом шаге. Условие
L < R
справедливо в начале каждого шага. Тогда
53
среднее арифметическое удовлетворяет условию
L
≤ m < R
. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1
пере%
менной
L
(что увеличивает
L
), либо значению m
переменной
R
(что уменьшает
R
),
и цикл прекращается при
L = R
Однако выполнение инварианта вместе с условием
L = R
еще не гарантирует успеха поиска. Разумеется, если
R = N
, то искомого значения в массиве нет. В про%
тивном случае нужно учесть, что элемент a[R]
еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x
. В отличие от первого решения, дан%
ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.
1.8.3. Поиск в таблице
Поиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%
дний случай встречается часто; массивы литер называют цепочками литер
(string), или словами. Определим тип
String так:
String = ARRAY M OF CHAR
и пусть отношение порядка для цепочек определено так, что для цепочек x
и y
:
(x = y)
≡ (A
A
A
A
Aj: 0
≤ j < M : x j
= y j
)
(x < y)
≡ EEEEEi: 0 ≤ i < N : ((A
A
A
A
Aj: 0
≤ j < i : x j
= y j
) & (x i
< y i
))
Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,
достаточно искать пару неравных литер. Отсутствие такой пары означает равен%
ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%
ше 30, и будем использовать линейный поиск.
В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%
руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения
M
. Такая схема оставляет достаточно гибкос%
ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:
1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.
Обычно для этой цели используют литеру
0X
,
не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)
2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%
ка литер s имеет вид s = s
0
, s
1
, s
2
, ... , s
N–1
где s
1
... s
N–1
суть фактические литеры цепочки, а s
0
= CHR(N)
. Преимуще%
ство этого способа – в том, что длина доступна непосредственно, а недоста%
Поиск
Фундаментальные структуры данных
54
ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.
В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;
WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 END
Здесь концевая литера играет роль барьера, а инвариант цикла имеет вид
A
A
A
A
Aj: 0
≤ j < i : x j
= y j
≠ 0X,
и в результате выполняется следующее условие:
((x i
= 0X) OR (x i
≠ y i
)) & (Aj: 0
≤ j < i : x j
= y j
≠ 0X).
Отсюда следует совпадение цепочек x
и y
, если x
i
= y
i
; при этом x < y
, если x
i
< y i
Теперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.
Например, пусть таблица
T
и аргумент поиска x
определены так:
T: ARRAY N OF String;
x: String
Предполагая, что
N
может быть большим и что таблица упорядочена по алфа%
виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:
i := –1;
(* ADruS183 *)
L := 0; R := N;
WHILE L < R DO
m := (L+R) DIV 2;
i := 0;
WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;
IF T[m,i] < x[i] THEN L := m+1 ELSE R := m END
END;
IF R < N THEN
i := 0;
WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 END
END
(* (R < N) & (T[R,i] = x[i]) *)
1.9. Поиск образца в тексте
(string search)
Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s
из
N
элементов и массив p
из
M
элемен%
тов, где
0 < M < N
:
55
s: ARRAY N OF Item p: ARRAY M OF Item
Требуется найти первое вхождение p
в s
. Обычно элементы этих массивов
(
Item
) являются литерами; тогда s
можно считать текстом, а p
– образцом
(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.
Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,
с помощью которых осуществляются просмотры, определяется данными. Коррект%
ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант цикла
WHILE
. Поскольку эта фундаментальная и мощная управляющая структура пред%
ставлена не во всех языках программирования, ее описанию посвящено приложе%
ние C.
Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),
представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%
тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%
смотре базовой идеи простого алгоритма.
1.9.1. Простой поиск образца в тексте
Прежде чем думать об эффективности, опишем простейший алгоритм поиска.
Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p
длины
M
, сопоставляемый с текстом s
длины
N
в позиции i
. При этом индекс j
нумерует элементы образца, и элементу образца p[j]
сопоставляется элемент текста s[i+j]
Рис. 1.9. Образец длины
M, сопоставляемый с текстом s в позиции i
Предикат
R(i)
, описывающий полное совпадение образца с литерами текста в позиции i
, формулируется так:
R(i) = A
A
A
A
Ak: 0
≤ j < M : p j
= s i+j
Поиск образца в тексте (string search)
Фундаментальные структуры данных
56
Допустимые значения i
, в которых может реализоваться совпадение, – от
0
до
N–M
включительно. Вычисление условия
R
естественным образом сводится к пов%
торным сравнениям отдельных пар литер. Если применить теорему де Моргана к
R
, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:
R(i) = (A
A
A
A
Aj: 0
≤ j < M : p j
= s i+j
) = (E
E
E
E
Ej: 0
≤ j < M : p j
≠ s i+j
)
Поэтому предикат
R(i)
легко реализуется в виде процедуры%функции, постро%
енной по схеме линейного поиска:
PROCEDURE R (i: INTEGER): BOOLEAN;
VAR j: INTEGER;
BEGIN
(* 0 <= i < N *)
j := 0;
WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;
RETURN (j < M)
END R
Пусть искомым результатом будет значение индекса i
, указывающее на первое
вхождение образца в тексте s
. Тогда должно удовлетворяться условие
R(i)
. Но так как требуется найти именно первое вхождение образца, то условие
R(k)
должно быть ложным для всех k < i
. Обозначим это новое условие как
Q(i)
:
Q(i) = A
A
A
A
Ak: 0
≤ k < i : R(k)
Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):
i := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & R(i) DO INC(i) END
Инвариантом этого цикла является предикат
Q(i)
, который выполняется как до инструкции
INC(i)
, так и – благодаря второму операнду охраны – после нее.
Достоинство этого алгоритма – легкость понимания логики благодаря чет%
кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%
цедуры%функции
R
. Однако это же свойство может обернуться и недостатком:
во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%
сматриваемые ниже, используют информацию, получаемую во внутреннем цик%
ле, для увеличения i
во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%
ся от процедуры вычисления
R
, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i
. Од%
нако логика взаимодействия двух вложенных циклов через логическую пере%
менную теряет исходную прозрачность, что чревато ошибками при эволюции программы.
57
В подобных случаях удобно использовать так называемый цикл Дейкстры –
цикл
WHILE
с несколькими ветвями, каждая из которых имеет свою охрану (см.
приложение C). В данном случае две ветви будут соответствовать шагам по i
и j
соответственно. Вспомним рис. 1.9 и введем предикат
P(i, j)
, означающий совпаде%
ние первых j
литер образца с литерами текста, начиная с позиции i
:
P(i, j) = A
A
A
A
Ak: 0
≤ k < j : s i+k
= p k
Тогда
R(i) = P(i, M)
Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%
ется значениями пары переменных i и j
. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%
ле каждого шага при новых i или j
) можно выбрать так: в позициях до i
совпадений образца нет, а в позиции i
имеется частичное совпадение первых j
литер образца,
что формально записывается следующим образом:
Q(i) & P(i, j)
Очевидно, j = M
означает, что имеет место искомое вхождение образца в текст в позиции i
, а i > N – M
означает, что вхождений нет вообще.
Очевидно, для поиска достаточно пытаться увеличивать j
на 1, чтобы расши%
рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%
вую позицию, увеличив i
на
1
, и сбросить j
в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:
i := 0; j := 0;
WHILE
v # DO
INC( j )
ELSIF
DO
INC( i ); j := 0
END;
Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%
но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (
i
≤
N–M) & (j < M) & (s i+j
= p j
)
,
гаранти%
рующее
P(i, j)
после увеличения j
. Для второй ветки последний операнд конъюн%
кции должен содержать неравенство вместо равенства, что влечет
R(i)
и гаранти%
рует
Q(i)
после увеличения i
. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:
i := 0; j := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DO
INC( j )
ELSIF (i <= N–M) & (j < M) DO
INC( i ); j := 0
END;
Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%
ран, то есть (
i
>
N–M) OR (j >= M)
, причем из структуры шагов цикла дополнитель%
Поиск образца в тексте (string search)
Фундаментальные структуры данных
58
но следует, что два операнда не могут быть истинны одновременно, а j
не может превысить
M
. Тогда i
>
N–M
означает, что вхождений образца нет, а j = M
– что справедливо
Q(i) & P(i, M)
, то есть найдено искомое полное вхождение в позиции i
Анализ простого поиска в тексте. Этот алгоритм довольно эффективен,
если предполагать, что неравенство литер обнаруживается лишь после небольшо%
го числа сравнений литер, то есть при небольших j
. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%
ет тревогу. Например, рассмотрим текст, состоящий из
N–1
букв
A
, за которыми следует единственная буква
B
, и пусть образец состоит из
M–1
литер
A
, за которы%
ми следует одна
B
. Тогда нужно порядка
N*M
сравнений, чтобы обнаружить совпа%
дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%
ды, которые в этом худшем случае ведут себя гораздо лучше.
1.9.2. Алгоритм Кнута, Морриса и Пратта
Около 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%
ко порядка
N
сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%
нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%
цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%
щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%
ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%
ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%
ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Здесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i
; переменная j
, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10).
59
Центральным пунктом алгоритма является сравнение элементов s[i]
и p[j]
, при равенстве i и
j одновременно сдвигаются вправо на единицу, а в случае неравен%
ства должен быть сдвинут образец, что выражается присваиванием j
некоторого меньшего значения
D
. Граничный случай j = 0
показывает, что нужно предусмо%
треть возможность сдвига образца целиком за текущую позицию сравнения (что%
бы элемент p[0]
оказался выравнен с s[i+1]
). Для такого случая удобно положить
D = –1
. Главный цикл алгоритма приобретает следующий вид:
i := 0; j := 0;
WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DO
INC( i ); INC( j )
ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)
j := D
END;
Эта формулировка не совсем полна, так как еще не определено значение сдви%
га
D
. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это
Q(i–j) & P(i–j, j)
Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%
ражение
(j >= M) OR (i >= N)
, но реально могут реализоваться только равенства.
Если алгоритм останавливается при j = M
, то имеет место совпадение с образцом в позиции i–M
(член
P(i–j, j)
инварианта влечет
P(i–M, M) = R(i)
). В противном слу%
чае остановка происходит с i = N
, и так как здесь j < M
, то первый член инварианта,
Q(i–j)
, влечет отсутствие совпадений с образцом во всем тексте.
Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,
инвариант выполнен в начале цикла при значениях i = j = 0
. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i
и j
на 1. В самом деле,
Q(i–j)
не нарушается, так как разность i–j не меняется.
Не нарушается и
P(i–j, j)
при увеличении j благодаря равенству в охране ветки (ср.
определение
P
). Что касается второй ветки, то мы просто потребуем, чтобы значе%
ние
D
всегда было таким, чтобы замена j
на
D
сохраняла инвариант.
Присваивание j := D при условии
D < j означает сдвиг образца вправо на j–D
позиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать
D
как можно меньше. Это иллюстрируется на рис. 1.11.
Рис. 1.10. В обозначениях алгоритма КМП
позиция в тексте первой литеры образца равна i–j
(а не i, как в простом алгоритме)
Поиск образца в тексте (string search)
Фундаментальные структуры данных
60
Если мы хотим, чтобы инвариант
P(i–j, j) & Q(i–j)
выполнялся после присваива%
ния j := D
, то до присваивания должно выполняться условие
P(i–D, D) & Q(i–D)
Это предусловие и будет ключом к поиску подходящего выражения для
D наряду с условием
P(i–j, j) & Q(i–j)
, которое предполагается уже выполненным перед при%
сваиванием (к этой точке программы относятся все дальнейшие рассуждения).
Решающее наблюдение состоит в том, что истинность
P(i–j, j)
означает p
0
... p j–1
= s i–j
... s i–1
(ведь мы только что просмотрели первые j
литер образца и установили их совпа%
дение с соответствующими литерами текста). Поэтому условие
P(i–D, D)
при
D
< j
,
то есть p
0
... p
D–1
= s i–D
... s i–1
превращается в уравнение для
D
:
p
0
... p
D–1
= p j–D
... p j–1
Что касается
Q(i–D)
, то это условие следует из
Q(i–j)
, если
R(i–k)
для k = D+1 ... j
. При этом истинность
R(i–k)
для j = k гарантируется неравенством s[i] # p[j]
. Хотя условия
R(i–k)
≡
P(i–k, M) для k = D+1 ... j–1
нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия
P(i–k,k)
. Раскрывая их с учетом уже найденных равенств между элементами s
и p
, получаем следующее условие:
p
0
... p k–1
≠
p j–k
... p j–1
для всех k = D+1 ... j–1
То есть
D
должно быть максимальным решением вышеприведенного уравнения.
Рисунок 1.12 показывает, как работает этот механизм сдвигов.
Если решения для
D
нет, то совпадение с образцом невозможно ни в какой по%
зиции ниже i+1
. Тогда полагаем
D := –1
. Такая ситуация показана в верхнем при%
мере на рис. 1.13.
Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%
чуть улучшить: если бы литера p
j была равна
A
вместо
F
, то мы знали бы, что соот%
ветствующая литера в тексте никак не может быть равна
A
, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с
D = –1
(ср. нижнюю часть рис. 1.13). Поэтому при поиске
D
можно сразу наложить дополнительное ограничение p
D
≠
p j
. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.
Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций
61
Рис. 1.12. Частичные совпадения образца и вычисление
D
Рис. 1.13. Сдвиг образца за позицию последней литеры
Поиск образца в тексте (string search)
Фундаментальные структуры данных
62
Важный итог состоит в том, что значение
D
определяется только образцом и значением j
, но не зависит от текста. Обозначим
D
для заданного j
как d
j
. Таблицу d
можно построить до начала собственно поиска, что можно назвать предкомпи%
ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (
M << N
). Если нужно найти несколько вхож%
дений одного и того же образца, то таблицу d
можно использовать повторно.
Величина d
j
< j является длиной максимальной последовательности, удовлет%
воряющей условию p
0
... p d[j]–1
= p j–d[j]
... p j–1
с дополнительным ограничением p
d[j]
≠
p j
. Проход построенного цикла по самому образцу p
вместо s
последовательно находит максимальные последовательности,
что позволяет вычислить d
j
:
PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS192_ *)
(* p M s N; M <= Mmax*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY Mmax OF INTEGER;
BEGIN
(* d p*)
d[0] := –1;
IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;
j := 1; k := 0;
WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DO
k := d[k]
ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)
INC( j ); INC( k );
IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );
END;
(* *)
i := 0; j := 0;
WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DO
j := d[j];
ELSIF (j < M) & (i < N) DO
INC(i); INC(j);
END;
IF j = M THEN r := i–M ELSE r := –1 END
END Search
Анализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,
как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок
M+N
, что гораздо лучше, чем
M*N
в простом поиске. Они также указали на то приятное обстоятельство, что указатель i
всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%
тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%
жет привести к трудностям, если текст считывается из внешней памяти, так как
63
в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%
ные данные буферизуются, образец может оказаться таким, что потребуется воз%
врат за пределы буфера.
1.9.3. Алгоритм Бойера и Мура
Хитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%
наруживается после частичного совпадения некоторого фрагмента. Только в та%
ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%
этому выигрыш от использования КМП%стратегии оказывается не слишком су%
щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.
БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%
тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d
. Для каждой литеры x
из всего множества литер обозначим как d
x расстояние от конца образца до ее самого пра%
вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%
стом и образцом. Тогда образец можно сразу сдвигать вправо на d
p[M–1]
позиций,
и весьма вероятно, что эта величина окажется больше, чем 1. Если p
M–1
вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Так как литеры теперь сравниваются справа налево, то будет удобнее исполь%
зовать следующие слегка модифицированные версии предикатов
P
,
R
и
Q
:
P(i, j) = A
A
A
A
Ak: j
≤ k < M : s i–M+k
= p k
R(i)
= P(i, 0)
Q(i)
= A
A
A
A
Ak: M
≤ k < i : R(k)
В этих терминах инвариант цикла будет иметь прежний вид:
Q(i) & P(i, j)
. Мы еще введем переменную k = i–M+j.
Теперь можно дать следующую формулировку
БМ%алгоритма:
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END
Поиск образца в тексте (string search)
Фундаментальные структуры данных
64
Индексы удовлетворяют ограничениям
0
≤
j
≤
M, M
≤
i, 0 < k
≤
i
. Остановка алгоритма с j = 0
влечет
P(i, 0) = R(i)
, то есть совпадение в позиции k = i–M
. Для остановки c j > 0
нужно, чтобы i > N
; тогда
Q(i)
влечет
Q(N)
и, следовательно, от%
сутствие совпадений. Разумеется, нам еще нужно убедиться, что
Q(i)
и
P(i, j)
действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку
Q(M)
и
P(x, M)
всегда истинны.
Сначала рассмотрим первую ветку. Одновременное уменьшение k
и j
никак не влияет на
Q(i)
, и, следовательно, поскольку уже установлено, что s
k–1
= p j–1
, а
P(i, j)
выполняется до операции уменьшения j
, то
P(i, j)
выполняется и после нее.
Во второй ветке достаточно показать, что присваивание i := i + d s[i–1]
не нару%
шает инвариант
Q(i)
, так как после остальных присваиваний
P(i, j) выполняется автоматически.
Q(i) выполняется после изменения i, если до него выполняется
Q(i+d s[i–1]
)
. Поскольку мы знаем, что выполняется
Q(i)
, достаточно установить, что
R(i+h)
для h = 1 .. d s[i–1]
–1
. Вспомним, что величина d
x определена как расстояние от конца до крайнего правого вхождения x
в образце. Формально это выражается в виде:
A
A
A
A
Ak: M–d x
≤ k < M–1 : p k
≠ x
Для x = s i–1
получаем
A
A
A
A
Ak: M–d s[i–1]
k < M–1 : s i–1
≠ p k
= A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : s i–1
≠ p
M–1–h
⇒ A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : R(i+h)
В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:
PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS193_ *)
(* p M s N*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY 128 OF INTEGER;
BEGIN
FOR i := 0 TO 127 DO d[i] := M END;
FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END;
IF j <= 0 THEN r := k ELSE r := –1 END
END Search
Анализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%
ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем
N
, сравнений, за исключением специ%
65
ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно
N/M
Авторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%
торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%
ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%
го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%
ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%
ния деталей, так как дополнительная сложность вычисления таблиц и самого по%
иска, по%видимому, не приводит к существенному выигрышу в эффективности.
Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.
Замечание переводчика. Три алгоритма поиска образца в тексте – простой,
КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.
КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построение
БМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%
ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%
ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%
граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,
что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%
явные, инстинктивные предположения – еще до первых попыток составить кон%
кретное решение, когда внимание уже будет поглощено не исходной задачей,
а красотой и эффективностью предполагаемого решения, ярко демонстрирую%
щего остроумие и изобретательность программиста, а также его познания в ме%
тодах оптимизации.
Упражнения
1.1. Обозначим мощности стандартных типов
INTEGER
,
REAL
и
CHAR
как c
int
, c
real и
c char
. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров:
Complex
,
Date
,
Person
,
Row
,
Card
,
Name
?
Упражнения
Фундаментальные структуры данных
66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)
соответствуют следующим операциям:
a) чтение и запись для элементов упакованных записей и массивов;
b) операции для множеств, включая проверку принадлежности числа мно%
жеству?
1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%
ных в виде последовательностей, а не массивов?
1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%
вов, записей или последовательностей, которое было бы удобно для опреде%
ления времени прибытия и отправления для заданных станции и направле%
ния поезда.
1.5. Пусть даны текст
T
, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов
A
и
B
. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%
мальной длины. Напишите программу, преобразующую текст
T
в текст
S
за%
меной каждого вхождения слова
A
i соответствующим словом
B
i
1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%
лагается, что определены константа
N > 0
и следующие переменные:
VAR i, j, k, x: INTEGER;
a: ARRAY N OF INTEGER;
Программа A:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF a[k] < x THEN i := k ELSE j := k END
UNTIL (a[k] = x) OR (i > j)
Программа B:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k–1 END;
IF a[k] < x THEN i := k+1 END
UNTIL i > j
Программа C:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k ELSE i := k+1 END
UNTIL i > j
67
Подсказка. Все программы должны заканчиваться с a
k
= x
, если такой эле%
мент присутствует, или a
k
≠ x
, если элемента со значением x нет.
1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%
сен. Песни нумеруются числами от
1
до
N
(пусть
N = 30
). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.
Подсказка: Используйте процедуры
Read и
ReadInt для чтения данных опроса.
TYPE hit = INTEGER;
reponse = RECORD name, firstname: Name;
male: BOOLEAN;
age: INTEGER;
choice: ARRAY 5 OF hit
END;
VAR poll: Files.File
Этот файл содержит входные данные для программы, вычисляющей следую%
щие результаты:
1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.
2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.
Перед каждым из пяти списков должен идти подходящий заголовок.
Литература
[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,
Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,
Хоор К. Структурное программирование. – М.: Мир, 1975).
[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:
Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)
[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,
1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).
[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:
Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).
Литература
Фундаментальные структуры данных
68
[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,
6, No. 4, (1974) 247–259.
[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.
ACM, 17, 10 (Oct. 1974), 549–557.
[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAM
J. Comput., 6, 2, (June 1977), 323–349.
[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,
10 (Oct. 1977), 762–772.
1 2 3 4 5 6 7 8 9 ... 22
1.8. Поиск
Задача поиска – одна из наиболее часто встречающихся в программировании.
Она также дает прекрасные возможности показать применение рассмотренных структур данных. Есть несколько основных вариаций на тему поиска, и здесь при%
думано множество алгоритмов. Основное предположение, которое мы делаем в дальнейшем изложении, состоит в том, что набор данных, в котором ищется за%
данное значение, фиксирован. Будем предполагать, что этот набор
N
элементов представлен массивом, скажем a: ARRAY N OF Item
Обычно элементы являются записями, одно из полей которых играет роль ключа. Тогда задача состоит в нахождении элемента, у которого поле ключа равно заданному значению x
, которое еще называют аргументом поиска. Найденный индекс i
, удовлетворяющий условию a[i].key = x
, позволит обратиться к другим полям найденного элемента. Поскольку нам здесь интересна только задача поис%
ка, но не данные, ради которых производится поиск, мы будем предполагать, что тип
Item состоит только из ключа, то есть сам является ключом.
Поиск
Фундаментальные структуры данных
50
1.8.1. Линейный поиск
Если нет никакой дополнительной информации о данных, то очевидное реше%
ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%
стно как линейный поиск. Поиск прекращается при одном из двух условий:
1. Элемент найден, то есть a
i
= x
2. Просмотрен весь массив, но элемент не найден.
Приходим к следующему алгоритму:
i := 0;
(* ADruS18_ *)
WHILE (i < N) & (a[i] # x) DO INC(i) END
Отметим, что порядок операндов в булевском выражении важен.
До и после каждого шага цикла выполняется следующее условие:
(0
≤ i < N) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Такое условие называется инвариантом. В данном случае инвариант означает,
что для всех значений k
, меньших, чем i
, среди a
k искомого значения x
нет. Заме%
тим, что до и после каждого шага цикла значения i
– разные. Сохранение инвари%
анта при изменении i
имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами
WHILE
и
DO
).
Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%
ного фрагмента):
((i = N) OR (a i
= x )) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Это условие не только является искомым результатом, но еще и подразумева%
ет, что если найден элемент, равный x
, то это первый такой элемент. При этом i = N
означает, что искомого значения не найдено.
Окончание цикла гарантировано, так как величина i
на каждом шаге увели%
чивается и поэтому обязательно достигнет границы
N
после конечного числа ша%
гов; на самом деле после
N
шагов, если искомого значения в массиве нет.
На каждом шаге нужно вычислять булевское выражение и увеличивать ин%
декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%
ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%
печить, помещая дополнительный элемент со значением x
в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%
ствует выходу поиска за границу массива. Теперь массив a
объявляется так:
a: ARRAY N+1 OF INTEGER,
51
и алгоритм линейного поиска с барьером выражается следующим образом:
a[N] := x; i := 0;
(* ADruS18_ *)
WHILE a[i] # x DO INC(i) END
В итоге получается условие, выведенное из того же инварианта, что и ранее:
(a i
= x) & (Ak: 0
≤ k < i : a k
≠ x)
Очевидно, из i = N
следует, что искомое значение не встретилось (не считая значения%барьера).
1.8.2. Поиск делением пополам
Понятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%
раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%
вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%
пользует информацию о том, что массив a
упорядочен, то есть что выполняется условие
A
A
A
A
Ak: 1
≤ k < N : a k–1
≤ a k
Ключевая идея – в том, чтобы выбрать наугад элемент, скажем a
m
, и сравнить его с искомым значением x
. Если он равен x
, то поиск прекращается; если он мень%
ше x
, то можно заключить, что все элементы с индексами, равными или меньшими m
, можно игнорировать в дальнейшем поиске; а если он больше x
, то можно игно%
рировать все значения индекса, большие или равные m
. Это приводит к следую%
щему алгоритму, который носит название поиск делением пополам (binary search);
в нем используются две индексные переменные
L
и
R
, отмечающие в массиве a
левый и правый концы отрезка, в котором искомое значение все еще может быть найдено:
L := 0; R := N–1;
(* ADruS18_ *)
m :=
L R;
WHILE (L <= R) & (a[m] # x) DO
IF a[m] < x THEN
L := m+1
ELSE
R := m–1
END;
m :=
L R
END
Подчеркнем фундаментальное структурное подобие этого алгоритма и алго%
ритма линейного поиска в предыдущем разделе: роль i
теперь играет тройка
L, m,
R
. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%
ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной m
Поиск
Фундаментальные структуры данных
52
Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R < k < N : a k
> x)
откуда выводим для цикла такое постусловие:
((L > R) OR (a m
= x)) & (A
A
A
A
Ak: 0
≤ k < L : a k
< x ) & (A
A
A
A
Ak: R < k < N : a k
> x)
Из него следует, что
((L > R) & (A
A
A
A
Ak: 0
≤ k < N : a k
≠ x)) OR (a m
= x)
Выбор m
, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%
ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log
2
N
, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%
кальное улучшение по сравнению с линейным поиском, где среднее число срав%
нений равно
N/2
Как уже упоминалось, можно заняться устранением дублирования инструк%
ции присваивания m
. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%
нений. Напомним, что число шагов не превышает log N
Более быстрое решение основано на следующем инварианте:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R
≤ k < N : a k
≥ x)
а поиск продолжается, пока два отрезка не будут покрывать весь массив:
L := 0; R := N;
(* ADruS18_ *)
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] < x THEN L := m+1 ELSE R := m END
END
Можно сказать, что теперь ищется не элемент a[m] = x
, а граница, отделяющая все элементы, меньшие x
, от всех прочих.
Условие окончания –
L
≥
R
. Есть ли гарантия его достижения? Чтобы убедить%
ся в этом, нужно показать, что в любых обстоятельствах разность
R–L
умень%
шается на каждом шаге. Условие
L < R
справедливо в начале каждого шага. Тогда
53
среднее арифметическое удовлетворяет условию
L
≤ m < R
. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1
пере%
менной
L
(что увеличивает
L
), либо значению m
переменной
R
(что уменьшает
R
),
и цикл прекращается при
L = R
Однако выполнение инварианта вместе с условием
L = R
еще не гарантирует успеха поиска. Разумеется, если
R = N
, то искомого значения в массиве нет. В про%
тивном случае нужно учесть, что элемент a[R]
еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x
. В отличие от первого решения, дан%
ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.
1.8.3. Поиск в таблице
Поиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%
дний случай встречается часто; массивы литер называют цепочками литер
(string), или словами. Определим тип
String так:
String = ARRAY M OF CHAR
и пусть отношение порядка для цепочек определено так, что для цепочек x
и y
:
(x = y)
≡ (A
A
A
A
Aj: 0
≤ j < M : x j
= y j
)
(x < y)
≡ EEEEEi: 0 ≤ i < N : ((A
A
A
A
Aj: 0
≤ j < i : x j
= y j
) & (x i
< y i
))
Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,
достаточно искать пару неравных литер. Отсутствие такой пары означает равен%
ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%
ше 30, и будем использовать линейный поиск.
В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%
руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения
M
. Такая схема оставляет достаточно гибкос%
ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:
1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.
Обычно для этой цели используют литеру
0X
,
не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)
2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%
ка литер s имеет вид s = s
0
, s
1
, s
2
, ... , s
N–1
где s
1
... s
N–1
суть фактические литеры цепочки, а s
0
= CHR(N)
. Преимуще%
ство этого способа – в том, что длина доступна непосредственно, а недоста%
Поиск
Фундаментальные структуры данных
54
ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.
В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;
WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 END
Здесь концевая литера играет роль барьера, а инвариант цикла имеет вид
A
A
A
A
Aj: 0
≤ j < i : x j
= y j
≠ 0X,
и в результате выполняется следующее условие:
((x i
= 0X) OR (x i
≠ y i
)) & (Aj: 0
≤ j < i : x j
= y j
≠ 0X).
Отсюда следует совпадение цепочек x
и y
, если x
i
= y
i
; при этом x < y
, если x
i
< y i
Теперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.
Например, пусть таблица
T
и аргумент поиска x
определены так:
T: ARRAY N OF String;
x: String
Предполагая, что
N
может быть большим и что таблица упорядочена по алфа%
виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:
i := –1;
(* ADruS183 *)
L := 0; R := N;
WHILE L < R DO
m := (L+R) DIV 2;
i := 0;
WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;
IF T[m,i] < x[i] THEN L := m+1 ELSE R := m END
END;
IF R < N THEN
i := 0;
WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 END
END
(* (R < N) & (T[R,i] = x[i]) *)
1.9. Поиск образца в тексте
(string search)
Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s
из
N
элементов и массив p
из
M
элемен%
тов, где
0 < M < N
:
55
s: ARRAY N OF Item p: ARRAY M OF Item
Требуется найти первое вхождение p
в s
. Обычно элементы этих массивов
(
Item
) являются литерами; тогда s
можно считать текстом, а p
– образцом
(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.
Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,
с помощью которых осуществляются просмотры, определяется данными. Коррект%
ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант цикла
WHILE
. Поскольку эта фундаментальная и мощная управляющая структура пред%
ставлена не во всех языках программирования, ее описанию посвящено приложе%
ние C.
Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),
представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%
тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%
смотре базовой идеи простого алгоритма.
1.9.1. Простой поиск образца в тексте
Прежде чем думать об эффективности, опишем простейший алгоритм поиска.
Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p
длины
M
, сопоставляемый с текстом s
длины
N
в позиции i
. При этом индекс j
нумерует элементы образца, и элементу образца p[j]
сопоставляется элемент текста s[i+j]
Рис. 1.9. Образец длины
M, сопоставляемый с текстом s в позиции i
Предикат
R(i)
, описывающий полное совпадение образца с литерами текста в позиции i
, формулируется так:
R(i) = A
A
A
A
Ak: 0
≤ j < M : p j
= s i+j
Поиск образца в тексте (string search)
Фундаментальные структуры данных
56
Допустимые значения i
, в которых может реализоваться совпадение, – от
0
до
N–M
включительно. Вычисление условия
R
естественным образом сводится к пов%
торным сравнениям отдельных пар литер. Если применить теорему де Моргана к
R
, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:
R(i) = (A
A
A
A
Aj: 0
≤ j < M : p j
= s i+j
) = (E
E
E
E
Ej: 0
≤ j < M : p j
≠ s i+j
)
Поэтому предикат
R(i)
легко реализуется в виде процедуры%функции, постро%
енной по схеме линейного поиска:
PROCEDURE R (i: INTEGER): BOOLEAN;
VAR j: INTEGER;
BEGIN
(* 0 <= i < N *)
j := 0;
WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;
RETURN (j < M)
END R
Пусть искомым результатом будет значение индекса i
, указывающее на первое
вхождение образца в тексте s
. Тогда должно удовлетворяться условие
R(i)
. Но так как требуется найти именно первое вхождение образца, то условие
R(k)
должно быть ложным для всех k < i
. Обозначим это новое условие как
Q(i)
:
Q(i) = A
A
A
A
Ak: 0
≤ k < i : R(k)
Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):
i := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & R(i) DO INC(i) END
Инвариантом этого цикла является предикат
Q(i)
, который выполняется как до инструкции
INC(i)
, так и – благодаря второму операнду охраны – после нее.
Достоинство этого алгоритма – легкость понимания логики благодаря чет%
кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%
цедуры%функции
R
. Однако это же свойство может обернуться и недостатком:
во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%
сматриваемые ниже, используют информацию, получаемую во внутреннем цик%
ле, для увеличения i
во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%
ся от процедуры вычисления
R
, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i
. Од%
нако логика взаимодействия двух вложенных циклов через логическую пере%
менную теряет исходную прозрачность, что чревато ошибками при эволюции программы.
57
В подобных случаях удобно использовать так называемый цикл Дейкстры –
цикл
WHILE
с несколькими ветвями, каждая из которых имеет свою охрану (см.
приложение C). В данном случае две ветви будут соответствовать шагам по i
и j
соответственно. Вспомним рис. 1.9 и введем предикат
P(i, j)
, означающий совпаде%
ние первых j
литер образца с литерами текста, начиная с позиции i
:
P(i, j) = A
A
A
A
Ak: 0
≤ k < j : s i+k
= p k
Тогда
R(i) = P(i, M)
Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%
ется значениями пары переменных i и j
. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%
ле каждого шага при новых i или j
) можно выбрать так: в позициях до i
совпадений образца нет, а в позиции i
имеется частичное совпадение первых j
литер образца,
что формально записывается следующим образом:
Q(i) & P(i, j)
Очевидно, j = M
означает, что имеет место искомое вхождение образца в текст в позиции i
, а i > N – M
означает, что вхождений нет вообще.
Очевидно, для поиска достаточно пытаться увеличивать j
на 1, чтобы расши%
рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%
вую позицию, увеличив i
на
1
, и сбросить j
в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:
i := 0; j := 0;
WHILE
v # DO
INC( j )
ELSIF
DO
INC( i ); j := 0
END;
Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%
но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (
i
≤
N–M) & (j < M) & (s i+j
= p j
)
,
гаранти%
рующее
P(i, j)
после увеличения j
. Для второй ветки последний операнд конъюн%
кции должен содержать неравенство вместо равенства, что влечет
R(i)
и гаранти%
рует
Q(i)
после увеличения i
. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:
i := 0; j := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DO
INC( j )
ELSIF (i <= N–M) & (j < M) DO
INC( i ); j := 0
END;
Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%
ран, то есть (
i
>
N–M) OR (j >= M)
, причем из структуры шагов цикла дополнитель%
Поиск образца в тексте (string search)
Фундаментальные структуры данных
58
но следует, что два операнда не могут быть истинны одновременно, а j
не может превысить
M
. Тогда i
>
N–M
означает, что вхождений образца нет, а j = M
– что справедливо
Q(i) & P(i, M)
, то есть найдено искомое полное вхождение в позиции i
Анализ простого поиска в тексте. Этот алгоритм довольно эффективен,
если предполагать, что неравенство литер обнаруживается лишь после небольшо%
го числа сравнений литер, то есть при небольших j
. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%
ет тревогу. Например, рассмотрим текст, состоящий из
N–1
букв
A
, за которыми следует единственная буква
B
, и пусть образец состоит из
M–1
литер
A
, за которы%
ми следует одна
B
. Тогда нужно порядка
N*M
сравнений, чтобы обнаружить совпа%
дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%
ды, которые в этом худшем случае ведут себя гораздо лучше.
1.9.2. Алгоритм Кнута, Морриса и Пратта
Около 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%
ко порядка
N
сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%
нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%
цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%
щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%
ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%
ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%
ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Здесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i
; переменная j
, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10).
59
Центральным пунктом алгоритма является сравнение элементов s[i]
и p[j]
, при равенстве i и
j одновременно сдвигаются вправо на единицу, а в случае неравен%
ства должен быть сдвинут образец, что выражается присваиванием j
некоторого меньшего значения
D
. Граничный случай j = 0
показывает, что нужно предусмо%
треть возможность сдвига образца целиком за текущую позицию сравнения (что%
бы элемент p[0]
оказался выравнен с s[i+1]
). Для такого случая удобно положить
D = –1
. Главный цикл алгоритма приобретает следующий вид:
i := 0; j := 0;
WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DO
INC( i ); INC( j )
ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)
j := D
END;
Эта формулировка не совсем полна, так как еще не определено значение сдви%
га
D
. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это
Q(i–j) & P(i–j, j)
Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%
ражение
(j >= M) OR (i >= N)
, но реально могут реализоваться только равенства.
Если алгоритм останавливается при j = M
, то имеет место совпадение с образцом в позиции i–M
(член
P(i–j, j)
инварианта влечет
P(i–M, M) = R(i)
). В противном слу%
чае остановка происходит с i = N
, и так как здесь j < M
, то первый член инварианта,
Q(i–j)
, влечет отсутствие совпадений с образцом во всем тексте.
Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,
инвариант выполнен в начале цикла при значениях i = j = 0
. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i
и j
на 1. В самом деле,
Q(i–j)
не нарушается, так как разность i–j не меняется.
Не нарушается и
P(i–j, j)
при увеличении j благодаря равенству в охране ветки (ср.
определение
P
). Что касается второй ветки, то мы просто потребуем, чтобы значе%
ние
D
всегда было таким, чтобы замена j
на
D
сохраняла инвариант.
Присваивание j := D при условии
D < j означает сдвиг образца вправо на j–D
позиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать
D
как можно меньше. Это иллюстрируется на рис. 1.11.
Рис. 1.10. В обозначениях алгоритма КМП
позиция в тексте первой литеры образца равна i–j
(а не i, как в простом алгоритме)
Поиск образца в тексте (string search)
Фундаментальные структуры данных
60
Если мы хотим, чтобы инвариант
P(i–j, j) & Q(i–j)
выполнялся после присваива%
ния j := D
, то до присваивания должно выполняться условие
P(i–D, D) & Q(i–D)
Это предусловие и будет ключом к поиску подходящего выражения для
D наряду с условием
P(i–j, j) & Q(i–j)
, которое предполагается уже выполненным перед при%
сваиванием (к этой точке программы относятся все дальнейшие рассуждения).
Решающее наблюдение состоит в том, что истинность
P(i–j, j)
означает p
0
... p j–1
= s i–j
... s i–1
(ведь мы только что просмотрели первые j
литер образца и установили их совпа%
дение с соответствующими литерами текста). Поэтому условие
P(i–D, D)
при
D
< j
,
то есть p
0
... p
D–1
= s i–D
... s i–1
превращается в уравнение для
D
:
p
0
... p
D–1
= p j–D
... p j–1
Что касается
Q(i–D)
, то это условие следует из
Q(i–j)
, если
R(i–k)
для k = D+1 ... j
. При этом истинность
R(i–k)
для j = k гарантируется неравенством s[i] # p[j]
. Хотя условия
R(i–k)
≡
P(i–k, M) для k = D+1 ... j–1
нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия
P(i–k,k)
. Раскрывая их с учетом уже найденных равенств между элементами s
и p
, получаем следующее условие:
p
0
... p k–1
≠
p j–k
... p j–1
для всех k = D+1 ... j–1
То есть
D
должно быть максимальным решением вышеприведенного уравнения.
Рисунок 1.12 показывает, как работает этот механизм сдвигов.
Если решения для
D
нет, то совпадение с образцом невозможно ни в какой по%
зиции ниже i+1
. Тогда полагаем
D := –1
. Такая ситуация показана в верхнем при%
мере на рис. 1.13.
Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%
чуть улучшить: если бы литера p
j была равна
A
вместо
F
, то мы знали бы, что соот%
ветствующая литера в тексте никак не может быть равна
A
, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с
D = –1
(ср. нижнюю часть рис. 1.13). Поэтому при поиске
D
можно сразу наложить дополнительное ограничение p
D
≠
p j
. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.
Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций
61
Рис. 1.12. Частичные совпадения образца и вычисление
D
Рис. 1.13. Сдвиг образца за позицию последней литеры
Поиск образца в тексте (string search)
Фундаментальные структуры данных
62
Важный итог состоит в том, что значение
D
определяется только образцом и значением j
, но не зависит от текста. Обозначим
D
для заданного j
как d
j
. Таблицу d
можно построить до начала собственно поиска, что можно назвать предкомпи%
ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (
M << N
). Если нужно найти несколько вхож%
дений одного и того же образца, то таблицу d
можно использовать повторно.
Величина d
j
< j является длиной максимальной последовательности, удовлет%
воряющей условию p
0
... p d[j]–1
= p j–d[j]
... p j–1
с дополнительным ограничением p
d[j]
≠
p j
. Проход построенного цикла по самому образцу p
вместо s
последовательно находит максимальные последовательности,
что позволяет вычислить d
j
:
PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS192_ *)
(* p M s N; M <= Mmax*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY Mmax OF INTEGER;
BEGIN
(* d p*)
d[0] := –1;
IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;
j := 1; k := 0;
WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DO
k := d[k]
ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)
INC( j ); INC( k );
IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );
END;
(* *)
i := 0; j := 0;
WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DO
j := d[j];
ELSIF (j < M) & (i < N) DO
INC(i); INC(j);
END;
IF j = M THEN r := i–M ELSE r := –1 END
END Search
Анализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,
как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок
M+N
, что гораздо лучше, чем
M*N
в простом поиске. Они также указали на то приятное обстоятельство, что указатель i
всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%
тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%
жет привести к трудностям, если текст считывается из внешней памяти, так как
63
в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%
ные данные буферизуются, образец может оказаться таким, что потребуется воз%
врат за пределы буфера.
1.9.3. Алгоритм Бойера и Мура
Хитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%
наруживается после частичного совпадения некоторого фрагмента. Только в та%
ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%
этому выигрыш от использования КМП%стратегии оказывается не слишком су%
щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.
БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%
тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d
. Для каждой литеры x
из всего множества литер обозначим как d
x расстояние от конца образца до ее самого пра%
вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%
стом и образцом. Тогда образец можно сразу сдвигать вправо на d
p[M–1]
позиций,
и весьма вероятно, что эта величина окажется больше, чем 1. Если p
M–1
вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Так как литеры теперь сравниваются справа налево, то будет удобнее исполь%
зовать следующие слегка модифицированные версии предикатов
P
,
R
и
Q
:
P(i, j) = A
A
A
A
Ak: j
≤ k < M : s i–M+k
= p k
R(i)
= P(i, 0)
Q(i)
= A
A
A
A
Ak: M
≤ k < i : R(k)
В этих терминах инвариант цикла будет иметь прежний вид:
Q(i) & P(i, j)
. Мы еще введем переменную k = i–M+j.
Теперь можно дать следующую формулировку
БМ%алгоритма:
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END
Поиск образца в тексте (string search)
Фундаментальные структуры данных
64
Индексы удовлетворяют ограничениям
0
≤
j
≤
M, M
≤
i, 0 < k
≤
i
. Остановка алгоритма с j = 0
влечет
P(i, 0) = R(i)
, то есть совпадение в позиции k = i–M
. Для остановки c j > 0
нужно, чтобы i > N
; тогда
Q(i)
влечет
Q(N)
и, следовательно, от%
сутствие совпадений. Разумеется, нам еще нужно убедиться, что
Q(i)
и
P(i, j)
действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку
Q(M)
и
P(x, M)
всегда истинны.
Сначала рассмотрим первую ветку. Одновременное уменьшение k
и j
никак не влияет на
Q(i)
, и, следовательно, поскольку уже установлено, что s
k–1
= p j–1
, а
P(i, j)
выполняется до операции уменьшения j
, то
P(i, j)
выполняется и после нее.
Во второй ветке достаточно показать, что присваивание i := i + d s[i–1]
не нару%
шает инвариант
Q(i)
, так как после остальных присваиваний
P(i, j) выполняется автоматически.
Q(i) выполняется после изменения i, если до него выполняется
Q(i+d s[i–1]
)
. Поскольку мы знаем, что выполняется
Q(i)
, достаточно установить, что
R(i+h)
для h = 1 .. d s[i–1]
–1
. Вспомним, что величина d
x определена как расстояние от конца до крайнего правого вхождения x
в образце. Формально это выражается в виде:
A
A
A
A
Ak: M–d x
≤ k < M–1 : p k
≠ x
Для x = s i–1
получаем
A
A
A
A
Ak: M–d s[i–1]
k < M–1 : s i–1
≠ p k
= A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : s i–1
≠ p
M–1–h
⇒ A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : R(i+h)
В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:
PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS193_ *)
(* p M s N*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY 128 OF INTEGER;
BEGIN
FOR i := 0 TO 127 DO d[i] := M END;
FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END;
IF j <= 0 THEN r := k ELSE r := –1 END
END Search
Анализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%
ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем
N
, сравнений, за исключением специ%
65
ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно
N/M
Авторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%
торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%
ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%
го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%
ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%
ния деталей, так как дополнительная сложность вычисления таблиц и самого по%
иска, по%видимому, не приводит к существенному выигрышу в эффективности.
Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.
Замечание переводчика. Три алгоритма поиска образца в тексте – простой,
КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.
КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построение
БМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%
ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%
ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%
граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,
что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%
явные, инстинктивные предположения – еще до первых попыток составить кон%
кретное решение, когда внимание уже будет поглощено не исходной задачей,
а красотой и эффективностью предполагаемого решения, ярко демонстрирую%
щего остроумие и изобретательность программиста, а также его познания в ме%
тодах оптимизации.
Упражнения
1.1. Обозначим мощности стандартных типов
INTEGER
,
REAL
и
CHAR
как c
int
, c
real и
c char
. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров:
Complex
,
Date
,
Person
,
Row
,
Card
,
Name
?
Упражнения
Фундаментальные структуры данных
66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)
соответствуют следующим операциям:
a) чтение и запись для элементов упакованных записей и массивов;
b) операции для множеств, включая проверку принадлежности числа мно%
жеству?
1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%
ных в виде последовательностей, а не массивов?
1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%
вов, записей или последовательностей, которое было бы удобно для опреде%
ления времени прибытия и отправления для заданных станции и направле%
ния поезда.
1.5. Пусть даны текст
T
, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов
A
и
B
. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%
мальной длины. Напишите программу, преобразующую текст
T
в текст
S
за%
меной каждого вхождения слова
A
i соответствующим словом
B
i
1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%
лагается, что определены константа
N > 0
и следующие переменные:
VAR i, j, k, x: INTEGER;
a: ARRAY N OF INTEGER;
Программа A:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF a[k] < x THEN i := k ELSE j := k END
UNTIL (a[k] = x) OR (i > j)
Программа B:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k–1 END;
IF a[k] < x THEN i := k+1 END
UNTIL i > j
Программа C:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k ELSE i := k+1 END
UNTIL i > j
67
Подсказка. Все программы должны заканчиваться с a
k
= x
, если такой эле%
мент присутствует, или a
k
≠ x
, если элемента со значением x нет.
1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%
сен. Песни нумеруются числами от
1
до
N
(пусть
N = 30
). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.
Подсказка: Используйте процедуры
Read и
ReadInt для чтения данных опроса.
TYPE hit = INTEGER;
reponse = RECORD name, firstname: Name;
male: BOOLEAN;
age: INTEGER;
choice: ARRAY 5 OF hit
END;
VAR poll: Files.File
Этот файл содержит входные данные для программы, вычисляющей следую%
щие результаты:
1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.
2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.
Перед каждым из пяти списков должен идти подходящий заголовок.
Литература
[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,
Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,
Хоор К. Структурное программирование. – М.: Мир, 1975).
[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:
Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)
[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,
1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).
[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:
Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).
Литература
Фундаментальные структуры данных
68
[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,
6, No. 4, (1974) 247–259.
[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.
ACM, 17, 10 (Oct. 1974), 549–557.
[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAM
J. Comput., 6, 2, (June 1977), 323–349.
[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,
10 (Oct. 1977), 762–772.
1 2 3 4 5 6 7 8 9 ... 22
1.8. Поиск
Задача поиска – одна из наиболее часто встречающихся в программировании.
Она также дает прекрасные возможности показать применение рассмотренных структур данных. Есть несколько основных вариаций на тему поиска, и здесь при%
думано множество алгоритмов. Основное предположение, которое мы делаем в дальнейшем изложении, состоит в том, что набор данных, в котором ищется за%
данное значение, фиксирован. Будем предполагать, что этот набор
N
элементов представлен массивом, скажем a: ARRAY N OF Item
Обычно элементы являются записями, одно из полей которых играет роль ключа. Тогда задача состоит в нахождении элемента, у которого поле ключа равно заданному значению x
, которое еще называют аргументом поиска. Найденный индекс i
, удовлетворяющий условию a[i].key = x
, позволит обратиться к другим полям найденного элемента. Поскольку нам здесь интересна только задача поис%
ка, но не данные, ради которых производится поиск, мы будем предполагать, что тип
Item состоит только из ключа, то есть сам является ключом.
Поиск
Фундаментальные структуры данных
50
1.8.1. Линейный поиск
Если нет никакой дополнительной информации о данных, то очевидное реше%
ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%
стно как линейный поиск. Поиск прекращается при одном из двух условий:
1. Элемент найден, то есть a
i
= x
2. Просмотрен весь массив, но элемент не найден.
Приходим к следующему алгоритму:
i := 0;
(* ADruS18_ *)
WHILE (i < N) & (a[i] # x) DO INC(i) END
Отметим, что порядок операндов в булевском выражении важен.
До и после каждого шага цикла выполняется следующее условие:
(0
≤ i < N) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Такое условие называется инвариантом. В данном случае инвариант означает,
что для всех значений k
, меньших, чем i
, среди a
k искомого значения x
нет. Заме%
тим, что до и после каждого шага цикла значения i
– разные. Сохранение инвари%
анта при изменении i
имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами
WHILE
и
DO
).
Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%
ного фрагмента):
((i = N) OR (a i
= x )) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Это условие не только является искомым результатом, но еще и подразумева%
ет, что если найден элемент, равный x
, то это первый такой элемент. При этом i = N
означает, что искомого значения не найдено.
Окончание цикла гарантировано, так как величина i
на каждом шаге увели%
чивается и поэтому обязательно достигнет границы
N
после конечного числа ша%
гов; на самом деле после
N
шагов, если искомого значения в массиве нет.
На каждом шаге нужно вычислять булевское выражение и увеличивать ин%
декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%
ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%
печить, помещая дополнительный элемент со значением x
в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%
ствует выходу поиска за границу массива. Теперь массив a
объявляется так:
a: ARRAY N+1 OF INTEGER,
51
и алгоритм линейного поиска с барьером выражается следующим образом:
a[N] := x; i := 0;
(* ADruS18_ *)
WHILE a[i] # x DO INC(i) END
В итоге получается условие, выведенное из того же инварианта, что и ранее:
(a i
= x) & (Ak: 0
≤ k < i : a k
≠ x)
Очевидно, из i = N
следует, что искомое значение не встретилось (не считая значения%барьера).
1.8.2. Поиск делением пополам
Понятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%
раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%
вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%
пользует информацию о том, что массив a
упорядочен, то есть что выполняется условие
A
A
A
A
Ak: 1
≤ k < N : a k–1
≤ a k
Ключевая идея – в том, чтобы выбрать наугад элемент, скажем a
m
, и сравнить его с искомым значением x
. Если он равен x
, то поиск прекращается; если он мень%
ше x
, то можно заключить, что все элементы с индексами, равными или меньшими m
, можно игнорировать в дальнейшем поиске; а если он больше x
, то можно игно%
рировать все значения индекса, большие или равные m
. Это приводит к следую%
щему алгоритму, который носит название поиск делением пополам (binary search);
в нем используются две индексные переменные
L
и
R
, отмечающие в массиве a
левый и правый концы отрезка, в котором искомое значение все еще может быть найдено:
L := 0; R := N–1;
(* ADruS18_ *)
m :=
L R;
WHILE (L <= R) & (a[m] # x) DO
IF a[m] < x THEN
L := m+1
ELSE
R := m–1
END;
m :=
L R
END
Подчеркнем фундаментальное структурное подобие этого алгоритма и алго%
ритма линейного поиска в предыдущем разделе: роль i
теперь играет тройка
L, m,
R
. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%
ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной m
Поиск
Фундаментальные структуры данных
52
Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R < k < N : a k
> x)
откуда выводим для цикла такое постусловие:
((L > R) OR (a m
= x)) & (A
A
A
A
Ak: 0
≤ k < L : a k
< x ) & (A
A
A
A
Ak: R < k < N : a k
> x)
Из него следует, что
((L > R) & (A
A
A
A
Ak: 0
≤ k < N : a k
≠ x)) OR (a m
= x)
Выбор m
, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%
ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log
2
N
, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%
кальное улучшение по сравнению с линейным поиском, где среднее число срав%
нений равно
N/2
Как уже упоминалось, можно заняться устранением дублирования инструк%
ции присваивания m
. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%
нений. Напомним, что число шагов не превышает log N
Более быстрое решение основано на следующем инварианте:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R
≤ k < N : a k
≥ x)
а поиск продолжается, пока два отрезка не будут покрывать весь массив:
L := 0; R := N;
(* ADruS18_ *)
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] < x THEN L := m+1 ELSE R := m END
END
Можно сказать, что теперь ищется не элемент a[m] = x
, а граница, отделяющая все элементы, меньшие x
, от всех прочих.
Условие окончания –
L
≥
R
. Есть ли гарантия его достижения? Чтобы убедить%
ся в этом, нужно показать, что в любых обстоятельствах разность
R–L
умень%
шается на каждом шаге. Условие
L < R
справедливо в начале каждого шага. Тогда
53
среднее арифметическое удовлетворяет условию
L
≤ m < R
. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1
пере%
менной
L
(что увеличивает
L
), либо значению m
переменной
R
(что уменьшает
R
),
и цикл прекращается при
L = R
Однако выполнение инварианта вместе с условием
L = R
еще не гарантирует успеха поиска. Разумеется, если
R = N
, то искомого значения в массиве нет. В про%
тивном случае нужно учесть, что элемент a[R]
еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x
. В отличие от первого решения, дан%
ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.
1.8.3. Поиск в таблице
Поиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%
дний случай встречается часто; массивы литер называют цепочками литер
(string), или словами. Определим тип
String так:
String = ARRAY M OF CHAR
и пусть отношение порядка для цепочек определено так, что для цепочек x
и y
:
(x = y)
≡ (A
A
A
A
Aj: 0
≤ j < M : x j
= y j
)
(x < y)
≡ EEEEEi: 0 ≤ i < N : ((A
A
A
A
Aj: 0
≤ j < i : x j
= y j
) & (x i
< y i
))
Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,
достаточно искать пару неравных литер. Отсутствие такой пары означает равен%
ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%
ше 30, и будем использовать линейный поиск.
В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%
руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения
M
. Такая схема оставляет достаточно гибкос%
ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:
1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.
Обычно для этой цели используют литеру
0X
,
не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)
2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%
ка литер s имеет вид s = s
0
, s
1
, s
2
, ... , s
N–1
где s
1
... s
N–1
суть фактические литеры цепочки, а s
0
= CHR(N)
. Преимуще%
ство этого способа – в том, что длина доступна непосредственно, а недоста%
Поиск
Фундаментальные структуры данных
54
ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.
В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;
WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 END
Здесь концевая литера играет роль барьера, а инвариант цикла имеет вид
A
A
A
A
Aj: 0
≤ j < i : x j
= y j
≠ 0X,
и в результате выполняется следующее условие:
((x i
= 0X) OR (x i
≠ y i
)) & (Aj: 0
≤ j < i : x j
= y j
≠ 0X).
Отсюда следует совпадение цепочек x
и y
, если x
i
= y
i
; при этом x < y
, если x
i
< y i
Теперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.
Например, пусть таблица
T
и аргумент поиска x
определены так:
T: ARRAY N OF String;
x: String
Предполагая, что
N
может быть большим и что таблица упорядочена по алфа%
виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:
i := –1;
(* ADruS183 *)
L := 0; R := N;
WHILE L < R DO
m := (L+R) DIV 2;
i := 0;
WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;
IF T[m,i] < x[i] THEN L := m+1 ELSE R := m END
END;
IF R < N THEN
i := 0;
WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 END
END
(* (R < N) & (T[R,i] = x[i]) *)
1.9. Поиск образца в тексте
(string search)
Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s
из
N
элементов и массив p
из
M
элемен%
тов, где
0 < M < N
:
55
s: ARRAY N OF Item p: ARRAY M OF Item
Требуется найти первое вхождение p
в s
. Обычно элементы этих массивов
(
Item
) являются литерами; тогда s
можно считать текстом, а p
– образцом
(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.
Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,
с помощью которых осуществляются просмотры, определяется данными. Коррект%
ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант цикла
WHILE
. Поскольку эта фундаментальная и мощная управляющая структура пред%
ставлена не во всех языках программирования, ее описанию посвящено приложе%
ние C.
Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),
представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%
тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%
смотре базовой идеи простого алгоритма.
1.9.1. Простой поиск образца в тексте
Прежде чем думать об эффективности, опишем простейший алгоритм поиска.
Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p
длины
M
, сопоставляемый с текстом s
длины
N
в позиции i
. При этом индекс j
нумерует элементы образца, и элементу образца p[j]
сопоставляется элемент текста s[i+j]
Рис. 1.9. Образец длины
M, сопоставляемый с текстом s в позиции i
Предикат
R(i)
, описывающий полное совпадение образца с литерами текста в позиции i
, формулируется так:
R(i) = A
A
A
A
Ak: 0
≤ j < M : p j
= s i+j
Поиск образца в тексте (string search)
Фундаментальные структуры данных
56
Допустимые значения i
, в которых может реализоваться совпадение, – от
0
до
N–M
включительно. Вычисление условия
R
естественным образом сводится к пов%
торным сравнениям отдельных пар литер. Если применить теорему де Моргана к
R
, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:
R(i) = (A
A
A
A
Aj: 0
≤ j < M : p j
= s i+j
) = (E
E
E
E
Ej: 0
≤ j < M : p j
≠ s i+j
)
Поэтому предикат
R(i)
легко реализуется в виде процедуры%функции, постро%
енной по схеме линейного поиска:
PROCEDURE R (i: INTEGER): BOOLEAN;
VAR j: INTEGER;
BEGIN
(* 0 <= i < N *)
j := 0;
WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;
RETURN (j < M)
END R
Пусть искомым результатом будет значение индекса i
, указывающее на первое
вхождение образца в тексте s
. Тогда должно удовлетворяться условие
R(i)
. Но так как требуется найти именно первое вхождение образца, то условие
R(k)
должно быть ложным для всех k < i
. Обозначим это новое условие как
Q(i)
:
Q(i) = A
A
A
A
Ak: 0
≤ k < i : R(k)
Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):
i := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & R(i) DO INC(i) END
Инвариантом этого цикла является предикат
Q(i)
, который выполняется как до инструкции
INC(i)
, так и – благодаря второму операнду охраны – после нее.
Достоинство этого алгоритма – легкость понимания логики благодаря чет%
кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%
цедуры%функции
R
. Однако это же свойство может обернуться и недостатком:
во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%
сматриваемые ниже, используют информацию, получаемую во внутреннем цик%
ле, для увеличения i
во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%
ся от процедуры вычисления
R
, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i
. Од%
нако логика взаимодействия двух вложенных циклов через логическую пере%
менную теряет исходную прозрачность, что чревато ошибками при эволюции программы.
57
В подобных случаях удобно использовать так называемый цикл Дейкстры –
цикл
WHILE
с несколькими ветвями, каждая из которых имеет свою охрану (см.
приложение C). В данном случае две ветви будут соответствовать шагам по i
и j
соответственно. Вспомним рис. 1.9 и введем предикат
P(i, j)
, означающий совпаде%
ние первых j
литер образца с литерами текста, начиная с позиции i
:
P(i, j) = A
A
A
A
Ak: 0
≤ k < j : s i+k
= p k
Тогда
R(i) = P(i, M)
Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%
ется значениями пары переменных i и j
. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%
ле каждого шага при новых i или j
) можно выбрать так: в позициях до i
совпадений образца нет, а в позиции i
имеется частичное совпадение первых j
литер образца,
что формально записывается следующим образом:
Q(i) & P(i, j)
Очевидно, j = M
означает, что имеет место искомое вхождение образца в текст в позиции i
, а i > N – M
означает, что вхождений нет вообще.
Очевидно, для поиска достаточно пытаться увеличивать j
на 1, чтобы расши%
рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%
вую позицию, увеличив i
на
1
, и сбросить j
в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:
i := 0; j := 0;
WHILE
v # DO
INC( j )
ELSIF
DO
INC( i ); j := 0
END;
Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%
но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (
i
≤
N–M) & (j < M) & (s i+j
= p j
)
,
гаранти%
рующее
P(i, j)
после увеличения j
. Для второй ветки последний операнд конъюн%
кции должен содержать неравенство вместо равенства, что влечет
R(i)
и гаранти%
рует
Q(i)
после увеличения i
. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:
i := 0; j := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DO
INC( j )
ELSIF (i <= N–M) & (j < M) DO
INC( i ); j := 0
END;
Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%
ран, то есть (
i
>
N–M) OR (j >= M)
, причем из структуры шагов цикла дополнитель%
Поиск образца в тексте (string search)
Фундаментальные структуры данных
58
но следует, что два операнда не могут быть истинны одновременно, а j
не может превысить
M
. Тогда i
>
N–M
означает, что вхождений образца нет, а j = M
– что справедливо
Q(i) & P(i, M)
, то есть найдено искомое полное вхождение в позиции i
Анализ простого поиска в тексте. Этот алгоритм довольно эффективен,
если предполагать, что неравенство литер обнаруживается лишь после небольшо%
го числа сравнений литер, то есть при небольших j
. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%
ет тревогу. Например, рассмотрим текст, состоящий из
N–1
букв
A
, за которыми следует единственная буква
B
, и пусть образец состоит из
M–1
литер
A
, за которы%
ми следует одна
B
. Тогда нужно порядка
N*M
сравнений, чтобы обнаружить совпа%
дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%
ды, которые в этом худшем случае ведут себя гораздо лучше.
1.9.2. Алгоритм Кнута, Морриса и Пратта
Около 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%
ко порядка
N
сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%
нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%
цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%
щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%
ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%
ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%
ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Здесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i
; переменная j
, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10).
59
Центральным пунктом алгоритма является сравнение элементов s[i]
и p[j]
, при равенстве i и
j одновременно сдвигаются вправо на единицу, а в случае неравен%
ства должен быть сдвинут образец, что выражается присваиванием j
некоторого меньшего значения
D
. Граничный случай j = 0
показывает, что нужно предусмо%
треть возможность сдвига образца целиком за текущую позицию сравнения (что%
бы элемент p[0]
оказался выравнен с s[i+1]
). Для такого случая удобно положить
D = –1
. Главный цикл алгоритма приобретает следующий вид:
i := 0; j := 0;
WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DO
INC( i ); INC( j )
ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)
j := D
END;
Эта формулировка не совсем полна, так как еще не определено значение сдви%
га
D
. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это
Q(i–j) & P(i–j, j)
Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%
ражение
(j >= M) OR (i >= N)
, но реально могут реализоваться только равенства.
Если алгоритм останавливается при j = M
, то имеет место совпадение с образцом в позиции i–M
(член
P(i–j, j)
инварианта влечет
P(i–M, M) = R(i)
). В противном слу%
чае остановка происходит с i = N
, и так как здесь j < M
, то первый член инварианта,
Q(i–j)
, влечет отсутствие совпадений с образцом во всем тексте.
Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,
инвариант выполнен в начале цикла при значениях i = j = 0
. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i
и j
на 1. В самом деле,
Q(i–j)
не нарушается, так как разность i–j не меняется.
Не нарушается и
P(i–j, j)
при увеличении j благодаря равенству в охране ветки (ср.
определение
P
). Что касается второй ветки, то мы просто потребуем, чтобы значе%
ние
D
всегда было таким, чтобы замена j
на
D
сохраняла инвариант.
Присваивание j := D при условии
D < j означает сдвиг образца вправо на j–D
позиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать
D
как можно меньше. Это иллюстрируется на рис. 1.11.
Рис. 1.10. В обозначениях алгоритма КМП
позиция в тексте первой литеры образца равна i–j
(а не i, как в простом алгоритме)
Поиск образца в тексте (string search)
Фундаментальные структуры данных
60
Если мы хотим, чтобы инвариант
P(i–j, j) & Q(i–j)
выполнялся после присваива%
ния j := D
, то до присваивания должно выполняться условие
P(i–D, D) & Q(i–D)
Это предусловие и будет ключом к поиску подходящего выражения для
D наряду с условием
P(i–j, j) & Q(i–j)
, которое предполагается уже выполненным перед при%
сваиванием (к этой точке программы относятся все дальнейшие рассуждения).
Решающее наблюдение состоит в том, что истинность
P(i–j, j)
означает p
0
... p j–1
= s i–j
... s i–1
(ведь мы только что просмотрели первые j
литер образца и установили их совпа%
дение с соответствующими литерами текста). Поэтому условие
P(i–D, D)
при
D
< j
,
то есть p
0
... p
D–1
= s i–D
... s i–1
превращается в уравнение для
D
:
p
0
... p
D–1
= p j–D
... p j–1
Что касается
Q(i–D)
, то это условие следует из
Q(i–j)
, если
R(i–k)
для k = D+1 ... j
. При этом истинность
R(i–k)
для j = k гарантируется неравенством s[i] # p[j]
. Хотя условия
R(i–k)
≡
P(i–k, M) для k = D+1 ... j–1
нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия
P(i–k,k)
. Раскрывая их с учетом уже найденных равенств между элементами s
и p
, получаем следующее условие:
p
0
... p k–1
≠
p j–k
... p j–1
для всех k = D+1 ... j–1
То есть
D
должно быть максимальным решением вышеприведенного уравнения.
Рисунок 1.12 показывает, как работает этот механизм сдвигов.
Если решения для
D
нет, то совпадение с образцом невозможно ни в какой по%
зиции ниже i+1
. Тогда полагаем
D := –1
. Такая ситуация показана в верхнем при%
мере на рис. 1.13.
Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%
чуть улучшить: если бы литера p
j была равна
A
вместо
F
, то мы знали бы, что соот%
ветствующая литера в тексте никак не может быть равна
A
, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с
D = –1
(ср. нижнюю часть рис. 1.13). Поэтому при поиске
D
можно сразу наложить дополнительное ограничение p
D
≠
p j
. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.
Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций
61
Рис. 1.12. Частичные совпадения образца и вычисление
D
Рис. 1.13. Сдвиг образца за позицию последней литеры
Поиск образца в тексте (string search)
Фундаментальные структуры данных
62
Важный итог состоит в том, что значение
D
определяется только образцом и значением j
, но не зависит от текста. Обозначим
D
для заданного j
как d
j
. Таблицу d
можно построить до начала собственно поиска, что можно назвать предкомпи%
ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (
M << N
). Если нужно найти несколько вхож%
дений одного и того же образца, то таблицу d
можно использовать повторно.
Величина d
j
< j является длиной максимальной последовательности, удовлет%
воряющей условию p
0
... p d[j]–1
= p j–d[j]
... p j–1
с дополнительным ограничением p
d[j]
≠
p j
. Проход построенного цикла по самому образцу p
вместо s
последовательно находит максимальные последовательности,
что позволяет вычислить d
j
:
PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS192_ *)
(* p M s N; M <= Mmax*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY Mmax OF INTEGER;
BEGIN
(* d p*)
d[0] := –1;
IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;
j := 1; k := 0;
WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DO
k := d[k]
ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)
INC( j ); INC( k );
IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );
END;
(* *)
i := 0; j := 0;
WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DO
j := d[j];
ELSIF (j < M) & (i < N) DO
INC(i); INC(j);
END;
IF j = M THEN r := i–M ELSE r := –1 END
END Search
Анализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,
как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок
M+N
, что гораздо лучше, чем
M*N
в простом поиске. Они также указали на то приятное обстоятельство, что указатель i
всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%
тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%
жет привести к трудностям, если текст считывается из внешней памяти, так как
63
в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%
ные данные буферизуются, образец может оказаться таким, что потребуется воз%
врат за пределы буфера.
1.9.3. Алгоритм Бойера и Мура
Хитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%
наруживается после частичного совпадения некоторого фрагмента. Только в та%
ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%
этому выигрыш от использования КМП%стратегии оказывается не слишком су%
щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.
БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%
тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d
. Для каждой литеры x
из всего множества литер обозначим как d
x расстояние от конца образца до ее самого пра%
вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%
стом и образцом. Тогда образец можно сразу сдвигать вправо на d
p[M–1]
позиций,
и весьма вероятно, что эта величина окажется больше, чем 1. Если p
M–1
вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Так как литеры теперь сравниваются справа налево, то будет удобнее исполь%
зовать следующие слегка модифицированные версии предикатов
P
,
R
и
Q
:
P(i, j) = A
A
A
A
Ak: j
≤ k < M : s i–M+k
= p k
R(i)
= P(i, 0)
Q(i)
= A
A
A
A
Ak: M
≤ k < i : R(k)
В этих терминах инвариант цикла будет иметь прежний вид:
Q(i) & P(i, j)
. Мы еще введем переменную k = i–M+j.
Теперь можно дать следующую формулировку
БМ%алгоритма:
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END
Поиск образца в тексте (string search)
Фундаментальные структуры данных
64
Индексы удовлетворяют ограничениям
0
≤
j
≤
M, M
≤
i, 0 < k
≤
i
. Остановка алгоритма с j = 0
влечет
P(i, 0) = R(i)
, то есть совпадение в позиции k = i–M
. Для остановки c j > 0
нужно, чтобы i > N
; тогда
Q(i)
влечет
Q(N)
и, следовательно, от%
сутствие совпадений. Разумеется, нам еще нужно убедиться, что
Q(i)
и
P(i, j)
действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку
Q(M)
и
P(x, M)
всегда истинны.
Сначала рассмотрим первую ветку. Одновременное уменьшение k
и j
никак не влияет на
Q(i)
, и, следовательно, поскольку уже установлено, что s
k–1
= p j–1
, а
P(i, j)
выполняется до операции уменьшения j
, то
P(i, j)
выполняется и после нее.
Во второй ветке достаточно показать, что присваивание i := i + d s[i–1]
не нару%
шает инвариант
Q(i)
, так как после остальных присваиваний
P(i, j) выполняется автоматически.
Q(i) выполняется после изменения i, если до него выполняется
Q(i+d s[i–1]
)
. Поскольку мы знаем, что выполняется
Q(i)
, достаточно установить, что
R(i+h)
для h = 1 .. d s[i–1]
–1
. Вспомним, что величина d
x определена как расстояние от конца до крайнего правого вхождения x
в образце. Формально это выражается в виде:
A
A
A
A
Ak: M–d x
≤ k < M–1 : p k
≠ x
Для x = s i–1
получаем
A
A
A
A
Ak: M–d s[i–1]
k < M–1 : s i–1
≠ p k
= A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : s i–1
≠ p
M–1–h
⇒ A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : R(i+h)
В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:
PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS193_ *)
(* p M s N*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY 128 OF INTEGER;
BEGIN
FOR i := 0 TO 127 DO d[i] := M END;
FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END;
IF j <= 0 THEN r := k ELSE r := –1 END
END Search
Анализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%
ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем
N
, сравнений, за исключением специ%
65
ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно
N/M
Авторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%
торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%
ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%
го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%
ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%
ния деталей, так как дополнительная сложность вычисления таблиц и самого по%
иска, по%видимому, не приводит к существенному выигрышу в эффективности.
Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.
Замечание переводчика. Три алгоритма поиска образца в тексте – простой,
КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.
КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построение
БМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%
ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%
ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%
граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,
что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%
явные, инстинктивные предположения – еще до первых попыток составить кон%
кретное решение, когда внимание уже будет поглощено не исходной задачей,
а красотой и эффективностью предполагаемого решения, ярко демонстрирую%
щего остроумие и изобретательность программиста, а также его познания в ме%
тодах оптимизации.
Упражнения
1.1. Обозначим мощности стандартных типов
INTEGER
,
REAL
и
CHAR
как c
int
, c
real и
c char
. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров:
Complex
,
Date
,
Person
,
Row
,
Card
,
Name
?
Упражнения
Фундаментальные структуры данных
66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)
соответствуют следующим операциям:
a) чтение и запись для элементов упакованных записей и массивов;
b) операции для множеств, включая проверку принадлежности числа мно%
жеству?
1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%
ных в виде последовательностей, а не массивов?
1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%
вов, записей или последовательностей, которое было бы удобно для опреде%
ления времени прибытия и отправления для заданных станции и направле%
ния поезда.
1.5. Пусть даны текст
T
, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов
A
и
B
. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%
мальной длины. Напишите программу, преобразующую текст
T
в текст
S
за%
меной каждого вхождения слова
A
i соответствующим словом
B
i
1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%
лагается, что определены константа
N > 0
и следующие переменные:
VAR i, j, k, x: INTEGER;
a: ARRAY N OF INTEGER;
Программа A:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF a[k] < x THEN i := k ELSE j := k END
UNTIL (a[k] = x) OR (i > j)
Программа B:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k–1 END;
IF a[k] < x THEN i := k+1 END
UNTIL i > j
Программа C:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k ELSE i := k+1 END
UNTIL i > j
67
Подсказка. Все программы должны заканчиваться с a
k
= x
, если такой эле%
мент присутствует, или a
k
≠ x
, если элемента со значением x нет.
1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%
сен. Песни нумеруются числами от
1
до
N
(пусть
N = 30
). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.
Подсказка: Используйте процедуры
Read и
ReadInt для чтения данных опроса.
TYPE hit = INTEGER;
reponse = RECORD name, firstname: Name;
male: BOOLEAN;
age: INTEGER;
choice: ARRAY 5 OF hit
END;
VAR poll: Files.File
Этот файл содержит входные данные для программы, вычисляющей следую%
щие результаты:
1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.
2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.
Перед каждым из пяти списков должен идти подходящий заголовок.
Литература
[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,
Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,
Хоор К. Структурное программирование. – М.: Мир, 1975).
[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:
Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)
[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,
1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).
[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:
Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).
Литература
Фундаментальные структуры данных
68
[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,
6, No. 4, (1974) 247–259.
[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.
ACM, 17, 10 (Oct. 1974), 549–557.
[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAM
J. Comput., 6, 2, (June 1977), 323–349.
[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,
10 (Oct. 1977), 762–772.
1 2 3 4 5 6 7 8 9 ... 22
Фундаментальные структуры данных
50
1.8.1. Линейный поиск
Если нет никакой дополнительной информации о данных, то очевидное реше%
ние – последовательно проходить по массиву, шаг за шагом увеличивая величину той части массива, где искомое значение заведомо отсутствует. Это решение изве%
стно как линейный поиск. Поиск прекращается при одном из двух условий:
1. Элемент найден, то есть a
i
= x
2. Просмотрен весь массив, но элемент не найден.
Приходим к следующему алгоритму:
i := 0;
(* ADruS18_ *)
WHILE (i < N) & (a[i] # x) DO INC(i) END
Отметим, что порядок операндов в булевском выражении важен.
До и после каждого шага цикла выполняется следующее условие:
(0
≤ i < N) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Такое условие называется инвариантом. В данном случае инвариант означает,
что для всех значений k
, меньших, чем i
, среди a
k искомого значения x
нет. Заме%
тим, что до и после каждого шага цикла значения i
– разные. Сохранение инвари%
анта при изменении i
имеет место в данном случае благодаря истинности охраны цикла (условия между ключевыми словами
WHILE
и
DO
).
Из выполнения инварианта и из того факта, что поиск прекращается, только если станет ложной охрана цикла, получается условие, выполняющееся после окончания данного фрагмента программы (так называемое постусловие для дан%
ного фрагмента):
((i = N) OR (a i
= x )) & (A
A
A
A
Ak: 0
≤ k < i : a k
≠ x)
Это условие не только является искомым результатом, но еще и подразумева%
ет, что если найден элемент, равный x
, то это первый такой элемент. При этом i = N
означает, что искомого значения не найдено.
Окончание цикла гарантировано, так как величина i
на каждом шаге увели%
чивается и поэтому обязательно достигнет границы
N
после конечного числа ша%
гов; на самом деле после
N
шагов, если искомого значения в массиве нет.
На каждом шаге нужно вычислять булевское выражение и увеличивать ин%
декс. Можно ли упростить эту задачу и тем самым ускорить поиск? Единственная возможность – упростить булевское выражение, состоящее, как видим, из двух членов. Поэтому построить более простое решение удастся, только если найти ус%
ловие с единственным членом, из которого будут следовать оба. Это возможно лишь при гарантии, что искомое значение всегда будет найдено, а это можно обес%
печить, помещая дополнительный элемент со значением x
в конец массива. Будем называть такой вспомогательный элемент барьером (sentinel), так как он препят%
ствует выходу поиска за границу массива. Теперь массив a
объявляется так:
a: ARRAY N+1 OF INTEGER,
51
и алгоритм линейного поиска с барьером выражается следующим образом:
a[N] := x; i := 0;
(* ADruS18_ *)
WHILE a[i] # x DO INC(i) END
В итоге получается условие, выведенное из того же инварианта, что и ранее:
(a i
= x) & (Ak: 0
≤ k < i : a k
≠ x)
Очевидно, из i = N
следует, что искомое значение не встретилось (не считая значения%барьера).
1.8.2. Поиск делением пополам
Понятно, что ускорить поиск невозможно, если нет дополнительной информации о данных, в которых он выполняется. Хорошо известно, что поиск может быть го%
раздо более эффективным, если данные упорядочены. Достаточно представить себе телефонный справочник, в котором номера даны не по алфавиту: такой спра%
вочник совершенно бесполезен. Поэтому обсудим теперь алгоритм, который ис%
пользует информацию о том, что массив a
упорядочен, то есть что выполняется условие
A
A
A
A
Ak: 1
≤ k < N : a k–1
≤ a k
Ключевая идея – в том, чтобы выбрать наугад элемент, скажем a
m
, и сравнить его с искомым значением x
. Если он равен x
, то поиск прекращается; если он мень%
ше x
, то можно заключить, что все элементы с индексами, равными или меньшими m
, можно игнорировать в дальнейшем поиске; а если он больше x
, то можно игно%
рировать все значения индекса, большие или равные m
. Это приводит к следую%
щему алгоритму, который носит название поиск делением пополам (binary search);
в нем используются две индексные переменные
L
и
R
, отмечающие в массиве a
левый и правый концы отрезка, в котором искомое значение все еще может быть найдено:
L := 0; R := N–1;
(* ADruS18_ *)
m :=
L R;
WHILE (L <= R) & (a[m] # x) DO
IF a[m] < x THEN
L := m+1
ELSE
R := m–1
END;
m :=
L R
END
Подчеркнем фундаментальное структурное подобие этого алгоритма и алго%
ритма линейного поиска в предыдущем разделе: роль i
теперь играет тройка
L, m,
R
. Чтобы не потерять это подобие и тем самым надежней гарантировать коррект%
ность цикла, мы воздержались от соблазна мелкой оптимизации программы с целью устранения дублирования инструкции присваивания переменной m
Поиск
Фундаментальные структуры данных
52
Следующее условие является инвариантом цикла, то есть выполняется до и после каждого шага:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R < k < N : a k
> x)
откуда выводим для цикла такое постусловие:
((L > R) OR (a m
= x)) & (A
A
A
A
Ak: 0
≤ k < L : a k
< x ) & (A
A
A
A
Ak: R < k < N : a k
> x)
Из него следует, что
((L > R) & (A
A
A
A
Ak: 0
≤ k < N : a k
≠ x)) OR (a m
= x)
Выбор m
, очевидно, произволен в том смысле, что правильность алгоритма от него не зависит. Но от него зависит эффективность алгоритма. Ясно, что на каждом шаге нужно исключать из дальнейшего поиска как можно больше эле%
ментов независимо от результата сравнения. Оптимальное решение – выбрать средний элемент, так как здесь в любом случае из поиска исключается половина отрезка. Получается, что максимальное число шагов равно log
2
N
, округленное до ближайшего целого. Поэтому данный алгоритм представляет собой ради%
кальное улучшение по сравнению с линейным поиском, где среднее число срав%
нений равно
N/2
Как уже упоминалось, можно заняться устранением дублирования инструк%
ции присваивания m
. Однако такая оптимизация на уровне кода является преждевременной в том смысле, что сначала лучше попытаться оптимизировать алгоритм на уровне логики задачи. Здесь это действительно возможно: ввиду сходства алгоритма с алгоритмом линейного поиска естественно искать решение с более простым условием окончания, то есть без второго операнда конъюнкции в охране цикла. Для этого нужно отказаться от наивного желания закончить поиск сразу после нахождения искомого значения. На первый взгляд это неразумно, но при ближайшем рассмотрении оказывается, что выигрыш в эффективности на каждом шаге больше, чем потери из%за небольшого числа дополнительных срав%
нений. Напомним, что число шагов не превышает log N
Более быстрое решение основано на следующем инварианте:
(A
A
A
A
Ak: 0
≤ k < L : a k
< x) & (A
A
A
A
Ak: R
≤ k < N : a k
≥ x)
а поиск продолжается, пока два отрезка не будут покрывать весь массив:
L := 0; R := N;
(* ADruS18_ *)
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] < x THEN L := m+1 ELSE R := m END
END
Можно сказать, что теперь ищется не элемент a[m] = x
, а граница, отделяющая все элементы, меньшие x
, от всех прочих.
Условие окончания –
L
≥
R
. Есть ли гарантия его достижения? Чтобы убедить%
ся в этом, нужно показать, что в любых обстоятельствах разность
R–L
умень%
шается на каждом шаге. Условие
L < R
справедливо в начале каждого шага. Тогда
53
среднее арифметическое удовлетворяет условию
L
≤ m < R
. Поэтому разность действительно уменьшается благодаря либо присваиванию значения m+1
пере%
менной
L
(что увеличивает
L
), либо значению m
переменной
R
(что уменьшает
R
),
и цикл прекращается при
L = R
Однако выполнение инварианта вместе с условием
L = R
еще не гарантирует успеха поиска. Разумеется, если
R = N
, то искомого значения в массиве нет. В про%
тивном случае нужно учесть, что элемент a[R]
еще не сравнивался. Поэтому нужна дополнительная проверка равенства a[R] = x
. В отличие от первого решения, дан%
ный алгоритм – как и линейный поиск – находит искомое значение в позиции с наименьшим индексом.
1.8.3. Поиск в таблице
Поиск в массиве иногда называют поиском в таблице, особенно если ключи сами являются составными объектами, такими как массивы чисел или литер. После%
дний случай встречается часто; массивы литер называют цепочками литер
(string), или словами. Определим тип
String так:
String = ARRAY M OF CHAR
и пусть отношение порядка для цепочек определено так, что для цепочек x
и y
:
(x = y)
≡ (A
A
A
A
Aj: 0
≤ j < M : x j
= y j
)
(x < y)
≡ EEEEEi: 0 ≤ i < N : ((A
A
A
A
Aj: 0
≤ j < i : x j
= y j
) & (x i
< y i
))
Очевидно, равенство цепочек эквивалентно равенству всех литер. В сущности,
достаточно искать пару неравных литер. Отсутствие такой пары означает равен%
ство цепочек. Будем предполагать, что длина слов достаточно мала, скажем мень%
ше 30, и будем использовать линейный поиск.
В большинстве приложений нужно считать, что цепочки литер имеют переменную длину. Это достигается тем, что с каждой цепочкой литер ассоции%
руется ее длина. Учитывая данное выше определение типа, эта длина не должна превышать максимального значения
M
. Такая схема оставляет достаточно гибкос%
ти для многих задач, избегая при этом сложностей динамического распределения памяти. Чаще всего используются два представления длины цепочек литер:
1. Длина задается неявно тем, что к концу цепочки литер приписывается так называемая концевая литера, которая в других целях не используется.
Обычно для этой цели используют литеру
0X
,
не имеющую графического образа. (Для дальнейшего важно, что это самая младшая литера во всем множестве литер.)
2. Длина хранится непосредственно в первом элементе массива, то есть цепоч%
ка литер s имеет вид s = s
0
, s
1
, s
2
, ... , s
N–1
где s
1
... s
N–1
суть фактические литеры цепочки, а s
0
= CHR(N)
. Преимуще%
ство этого способа – в том, что длина доступна непосредственно, а недоста%
Поиск
Фундаментальные структуры данных
54
ток – что максимальная длина ограничена размером множества литер, то есть в случае множества ASCII значением 256.
В дальнейшем мы придерживаемся первой схемы. Сравнение цепочек литер принимает вид i := 0;
WHILE (x[i] # 0X) & (x[i] = y[i]) DO i := i+1 END
Здесь концевая литера играет роль барьера, а инвариант цикла имеет вид
A
A
A
A
Aj: 0
≤ j < i : x j
= y j
≠ 0X,
и в результате выполняется следующее условие:
((x i
= 0X) OR (x i
≠ y i
)) & (Aj: 0
≤ j < i : x j
= y j
≠ 0X).
Отсюда следует совпадение цепочек x
и y
, если x
i
= y
i
; при этом x < y
, если x
i
< y i
Теперь мы готовы вернуться к поиску в таблицах. Здесь требуется «поиск в поиске», то есть поиск среди элементов таблицы, причем для каждого элемента таблицы нужна последовательность сравнений между компонентами массивов.
Например, пусть таблица
T
и аргумент поиска x
определены так:
T: ARRAY N OF String;
x: String
Предполагая, что
N
может быть большим и что таблица упорядочена по алфа%
виту, будем использовать поиск делением пополам. Используя построенные выше алгоритмы для поиска делением пополам и для сравнения строк, получаем следующий программный фрагмент:
i := –1;
(* ADruS183 *)
L := 0; R := N;
WHILE L < R DO
m := (L+R) DIV 2;
i := 0;
WHILE (x[i] # 0X) & (T[m,i] = x[i]) DO i := i+1 END;
IF T[m,i] < x[i] THEN L := m+1 ELSE R := m END
END;
IF R < N THEN
i := 0;
WHILE (x[i] # 0X) & (T[R,i] = x[i]) DO i := i+1 END
END
(* (R < N) & (T[R,i] = x[i]) *)
1.9. Поиск образца в тексте
(string search)
Часто встречается особый вид поиска – поиск образца в тексте. Он определяется следующим образом. Пусть даны массив s
из
N
элементов и массив p
из
M
элемен%
тов, где
0 < M < N
:
55
s: ARRAY N OF Item p: ARRAY M OF Item
Требуется найти первое вхождение p
в s
. Обычно элементы этих массивов
(
Item
) являются литерами; тогда s
можно считать текстом, а p
– образцом
(pattern) или словом, и тогда нужно найти первое вхождение слова в тексте. Это основная операция в любой системе обработки текстов, и для нее важно иметь эффективный алгоритм.
Особенность этой задачи – наличие двух массивов данных и необходимость их одновременного просмотра, причем координация движения индексов%бегунков,
с помощью которых осуществляются просмотры, определяется данными. Коррект%
ная реализация подобных «сплетенных» циклов упрощается, если выражать их, используя так называемый цикл Дейкстры – многоветочный вариант цикла
WHILE
. Поскольку эта фундаментальная и мощная управляющая структура пред%
ставлена не во всех языках программирования, ее описанию посвящено приложе%
ние C.
Мы последовательно рассмотрим три алгоритма: простой поиск, построенный самым наивным образом; алгоритм Кнута, Морриса и Пратта (КМП%алгоритм),
представляющий собой оптимизацию простого поиска; и, наконец, самый эффек%
тивный из трех алгоритм Бойера и Мура (БМ%алгоритм), основанный на пере%
смотре базовой идеи простого алгоритма.
1.9.1. Простой поиск образца в тексте
Прежде чем думать об эффективности, опишем простейший алгоритм поиска.
Назовем его простым поиском образца в тексте. Удобно иметь в виду рис. 1.9, на котором схематически показан образец p
длины
M
, сопоставляемый с текстом s
длины
N
в позиции i
. При этом индекс j
нумерует элементы образца, и элементу образца p[j]
сопоставляется элемент текста s[i+j]
Рис. 1.9. Образец длины
M, сопоставляемый с текстом s в позиции i
Предикат
R(i)
, описывающий полное совпадение образца с литерами текста в позиции i
, формулируется так:
R(i) = A
A
A
A
Ak: 0
≤ j < M : p j
= s i+j
Поиск образца в тексте (string search)
Фундаментальные структуры данных
56
Допустимые значения i
, в которых может реализоваться совпадение, – от
0
до
N–M
включительно. Вычисление условия
R
естественным образом сводится к пов%
торным сравнениям отдельных пар литер. Если применить теорему де Моргана к
R
, то окажется, что эти повторные сравнения должны представлять собой поиск на неравенство среди пар соответствующих литер текста и образца:
R(i) = (A
A
A
A
Aj: 0
≤ j < M : p j
= s i+j
) = (E
E
E
E
Ej: 0
≤ j < M : p j
≠ s i+j
)
Поэтому предикат
R(i)
легко реализуется в виде процедуры%функции, постро%
енной по схеме линейного поиска:
PROCEDURE R (i: INTEGER): BOOLEAN;
VAR j: INTEGER;
BEGIN
(* 0 <= i < N *)
j := 0;
WHILE (j < M) & (p[j] = s[i+j]) DO INC(j) END;
RETURN (j < M)
END R
Пусть искомым результатом будет значение индекса i
, указывающее на первое
вхождение образца в тексте s
. Тогда должно удовлетворяться условие
R(i)
. Но так как требуется найти именно первое вхождение образца, то условие
R(k)
должно быть ложным для всех k < i
. Обозначим это новое условие как
Q(i)
:
Q(i) = A
A
A
A
Ak: 0
≤ k < i : R(k)
Такая формулировка задачи сразу наводит на мысль построить программу по образцу линейного поиска (раздел 1.8.1):
i := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & R(i) DO INC(i) END
Инвариантом этого цикла является предикат
Q(i)
, который выполняется как до инструкции
INC(i)
, так и – благодаря второму операнду охраны – после нее.
Достоинство этого алгоритма – легкость понимания логики благодаря чет%
кой развязке двух циклов поиска за счет упрятывания одного из них внутрь про%
цедуры%функции
R
. Однако это же свойство может обернуться и недостатком:
во%первых, дополнительный вызов процедуры на каждом шаге потенциально длинного цикла может оказаться слишком дорогостоящим для такой базовой операции, как поиск образца. Во%вторых, более совершенные алгоритмы, рас%
сматриваемые ниже, используют информацию, получаемую во внутреннем цик%
ле, для увеличения i
во внешнем цикле на величину, большую единицы, так что два цикла уже нельзя рассматривать как вполне независимые. Можно избавить%
ся от процедуры вычисления
R
, введя дополнительную логическую переменную для ее результата и вложив цикл из процедуры в тело основного цикла по i
. Од%
нако логика взаимодействия двух вложенных циклов через логическую пере%
менную теряет исходную прозрачность, что чревато ошибками при эволюции программы.
57
В подобных случаях удобно использовать так называемый цикл Дейкстры –
цикл
WHILE
с несколькими ветвями, каждая из которых имеет свою охрану (см.
приложение C). В данном случае две ветви будут соответствовать шагам по i
и j
соответственно. Вспомним рис. 1.9 и введем предикат
P(i, j)
, означающий совпаде%
ние первых j
литер образца с литерами текста, начиная с позиции i
:
P(i, j) = A
A
A
A
Ak: 0
≤ k < j : s i+k
= p k
Тогда
R(i) = P(i, M)
Рисунок 1.9 показывает, что текущее состояние процесса поиска характеризу%
ется значениями пары переменных i и j
. При этом инвариант (условие, которому удовлетворяет состояние поиска и к которому процесс должен возвращаться пос%
ле каждого шага при новых i или j
) можно выбрать так: в позициях до i
совпадений образца нет, а в позиции i
имеется частичное совпадение первых j
литер образца,
что формально записывается следующим образом:
Q(i) & P(i, j)
Очевидно, j = M
означает, что имеет место искомое вхождение образца в текст в позиции i
, а i > N – M
означает, что вхождений нет вообще.
Очевидно, для поиска достаточно пытаться увеличивать j
на 1, чтобы расши%
рить совпадающий сегмент, а если это невозможно, то продвинуть образец в но%
вую позицию, увеличив i
на
1
, и сбросить j
в нуль, чтобы начать с начала проверку совпадения образца в новой позиции:
i := 0; j := 0;
WHILE
v # DO
INC( j )
ELSIF
DO
INC( i ); j := 0
END;
Остается сосредоточиться на каждом из двух шагов по отдельности и аккурат%
но выписать условия, при которых каждый шаг имеет смысл, то есть сохраняет инвариант. Для первой ветки это условие (
i
≤
N–M) & (j < M) & (s i+j
= p j
)
,
гаранти%
рующее
P(i, j)
после увеличения j
. Для второй ветки последний операнд конъюн%
кции должен содержать неравенство вместо равенства, что влечет
R(i)
и гаранти%
рует
Q(i)
после увеличения i
. Учитывая, что охраны веток вычисляются в порядке их перечисления в тексте, можно опустить последний операнд конъюнкции во второй охране и получить окончательную программу:
i := 0; j := 0;
(* ADruS191_ *)
WHILE (i <= N–M) & (j < M) & (s[i+j] = p[j]) DO
INC( j )
ELSIF (i <= N–M) & (j < M) DO
INC( i ); j := 0
END;
Поcле цикла гарантировано условие, равное конъюнкции отрицаний всех ох%
ран, то есть (
i
>
N–M) OR (j >= M)
, причем из структуры шагов цикла дополнитель%
Поиск образца в тексте (string search)
Фундаментальные структуры данных
58
но следует, что два операнда не могут быть истинны одновременно, а j
не может превысить
M
. Тогда i
>
N–M
означает, что вхождений образца нет, а j = M
– что справедливо
Q(i) & P(i, M)
, то есть найдено искомое полное вхождение в позиции i
Анализ простого поиска в тексте. Этот алгоритм довольно эффективен,
если предполагать, что неравенство литер обнаруживается лишь после небольшо%
го числа сравнений литер, то есть при небольших j
. Такое вполне вероятно, если мощность типа элементов велика. Для поиска в тексте с мощностью множества литер, равной 128, разумно предположить, что неравенство имеет место уже после проверки одной или двух литер. Тем не менее поведение в худшем случае вызыва%
ет тревогу. Например, рассмотрим текст, состоящий из
N–1
букв
A
, за которыми следует единственная буква
B
, и пусть образец состоит из
M–1
литер
A
, за которы%
ми следует одна
B
. Тогда нужно порядка
N*M
сравнений, чтобы обнаружить совпа%
дение в конце текста. К счастью, как мы увидим в дальнейшем, существуют мето%
ды, которые в этом худшем случае ведут себя гораздо лучше.
1.9.2. Алгоритм Кнута, Морриса и Пратта
Около 1970 г. Кнут, Моррис и Пратт придумали алгоритм, который требует толь%
ко порядка
N
сравнений литер, причем даже в худшем случае [1.8]. Алгоритм ос%
нован на том наблюдении, что, всегда сдвигая образец по тексту только на едини%
цу, мы не используем полезную информацию, полученную в предыдущих сравнениях. Ведь после частичного совпадения начала образца с соответствую%
щими литерами в тексте мы фактически знаем пройденную часть текста и могли бы использовать заранее подготовленную информацию (полученную из анализа образца) для более быстрого продвижения по тексту. Следующий пример, в кото%
ром ищется слово Hooligan, иллюстрирует идею алгоритма. Подчеркнуты лите%
ры, которые уже сравнивались. Каждый раз, когда сравниваемые литеры оказыва%
ются не равны, образец сдвигается на весь уже пройденный путь, так как полное совпадение с образцом заведомо невозможно для слова Hooligan при меньшем сдвиге:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Здесь, в отличие от простого алгоритма, точка сравнения (позиция очередного элемента в тексте, сравниваемого с некоторым элементом образца) никогда не сдвигается назад. Именно эту точку сравнения (а не позицию первого элемента образца) будем теперь хранить в переменной i
; переменная j
, как и раньше, будет указывать на соответствующий элемент образца (см. рис. 1.10).
59
Центральным пунктом алгоритма является сравнение элементов s[i]
и p[j]
, при равенстве i и
j одновременно сдвигаются вправо на единицу, а в случае неравен%
ства должен быть сдвинут образец, что выражается присваиванием j
некоторого меньшего значения
D
. Граничный случай j = 0
показывает, что нужно предусмо%
треть возможность сдвига образца целиком за текущую позицию сравнения (что%
бы элемент p[0]
оказался выравнен с s[i+1]
). Для такого случая удобно положить
D = –1
. Главный цикл алгоритма приобретает следующий вид:
i := 0; j := 0;
WHILE (i < N) & (j < M) & ((j < 0) OR (s[i] = p[j])) DO
INC( i ); INC( j )
ELSIF (i < N) & (j < M) DO (* ((j >= 0) & (s[i] # p[j]) *)
j := D
END;
Эта формулировка не совсем полна, так как еще не определено значение сдви%
га
D
. Мы к этому вернемся чуть ниже, а пока отметим, что инвариант здесь берется как в предыдущей версии алгоритма; в новых обозначениях это
Q(i–j) & P(i–j, j)
Постусловие цикла, вычисленное как конъюнкция отрицаний охран, – это вы%
ражение
(j >= M) OR (i >= N)
, но реально могут реализоваться только равенства.
Если алгоритм останавливается при j = M
, то имеет место совпадение с образцом в позиции i–M
(член
P(i–j, j)
инварианта влечет
P(i–M, M) = R(i)
). В противном слу%
чае остановка происходит с i = N
, и так как здесь j < M
, то первый член инварианта,
Q(i–j)
, влечет отсутствие совпадений с образцом во всем тексте.
Нужно убедиться, что инвариант в алгоритме всегда сохраняется. Очевидно,
инвариант выполнен в начале цикла при значениях i = j = 0
. Не нарушается он и в первой ветке цикла, то есть после выполнения двух операторов, увеличивающих i
и j
на 1. В самом деле,
Q(i–j)
не нарушается, так как разность i–j не меняется.
Не нарушается и
P(i–j, j)
при увеличении j благодаря равенству в охране ветки (ср.
определение
P
). Что касается второй ветки, то мы просто потребуем, чтобы значе%
ние
D
всегда было таким, чтобы замена j
на
D
сохраняла инвариант.
Присваивание j := D при условии
D < j означает сдвиг образца вправо на j–D
позиций. Хотелось бы сделать этот сдвиг как можно больше, то есть сделать
D
как можно меньше. Это иллюстрируется на рис. 1.11.
Рис. 1.10. В обозначениях алгоритма КМП
позиция в тексте первой литеры образца равна i–j
(а не i, как в простом алгоритме)
Поиск образца в тексте (string search)
Фундаментальные структуры данных
60
Если мы хотим, чтобы инвариант
P(i–j, j) & Q(i–j)
выполнялся после присваива%
ния j := D
, то до присваивания должно выполняться условие
P(i–D, D) & Q(i–D)
Это предусловие и будет ключом к поиску подходящего выражения для
D наряду с условием
P(i–j, j) & Q(i–j)
, которое предполагается уже выполненным перед при%
сваиванием (к этой точке программы относятся все дальнейшие рассуждения).
Решающее наблюдение состоит в том, что истинность
P(i–j, j)
означает p
0
... p j–1
= s i–j
... s i–1
(ведь мы только что просмотрели первые j
литер образца и установили их совпа%
дение с соответствующими литерами текста). Поэтому условие
P(i–D, D)
при
D
< j
,
то есть p
0
... p
D–1
= s i–D
... s i–1
превращается в уравнение для
D
:
p
0
... p
D–1
= p j–D
... p j–1
Что касается
Q(i–D)
, то это условие следует из
Q(i–j)
, если
R(i–k)
для k = D+1 ... j
. При этом истинность
R(i–k)
для j = k гарантируется неравенством s[i] # p[j]
. Хотя условия
R(i–k)
≡
P(i–k, M) для k = D+1 ... j–1
нельзя полностью вычислить, используя только уже прочитанный фрагмент текста, но зато можно вычислить достаточные условия
P(i–k,k)
. Раскрывая их с учетом уже найденных равенств между элементами s
и p
, получаем следующее условие:
p
0
... p k–1
≠
p j–k
... p j–1
для всех k = D+1 ... j–1
То есть
D
должно быть максимальным решением вышеприведенного уравнения.
Рисунок 1.12 показывает, как работает этот механизм сдвигов.
Если решения для
D
нет, то совпадение с образцом невозможно ни в какой по%
зиции ниже i+1
. Тогда полагаем
D := –1
. Такая ситуация показана в верхнем при%
мере на рис. 1.13.
Последний пример на рис. 1.12 подсказывает, что алгоритм можно еще чуть%
чуть улучшить: если бы литера p
j была равна
A
вместо
F
, то мы знали бы, что соот%
ветствующая литера в тексте никак не может быть равна
A
, и сразу в следующей итерации цикла пришлось бы выполнить сдвиг образца с
D = –1
(ср. нижнюю часть рис. 1.13). Поэтому при поиске
D
можно сразу наложить дополнительное ограничение p
D
≠
p j
. Это позволяет в полной мере использовать информацию из неравенства в охране данной ветки цикла.
Рис. 1.11. Присваивание j := D сдвигает образец на j–D позиций
61
Рис. 1.12. Частичные совпадения образца и вычисление
D
Рис. 1.13. Сдвиг образца за позицию последней литеры
Поиск образца в тексте (string search)
Фундаментальные структуры данных
62
Важный итог состоит в том, что значение
D
определяется только образцом и значением j
, но не зависит от текста. Обозначим
D
для заданного j
как d
j
. Таблицу d
можно построить до начала собственно поиска, что можно назвать предкомпи%
ляцией образца. Очевидно, такие накладные расходы оправдаются, только если текст значительно длиннее образца (
M << N
). Если нужно найти несколько вхож%
дений одного и того же образца, то таблицу d
можно использовать повторно.
Величина d
j
< j является длиной максимальной последовательности, удовлет%
воряющей условию p
0
... p d[j]–1
= p j–d[j]
... p j–1
с дополнительным ограничением p
d[j]
≠
p j
. Проход построенного цикла по самому образцу p
вместо s
последовательно находит максимальные последовательности,
что позволяет вычислить d
j
:
PROCEDURE Search (VAR p, s: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS192_ *)
(* p M s N; M <= Mmax*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY Mmax OF INTEGER;
BEGIN
(* d p*)
d[0] := –1;
IF p[0] # p[1] THEN d[1] := 0 ELSE d[1] := –1 END;
j := 1; k := 0;
WHILE (j < M–1) & (k >= 0) & (p[j] # p[k]) DO
k := d[k]
ELSIF j < M–1 DO (* (k < 0) OR (p[j] = p[k]) *)
INC( j ); INC( k );
IF p[j] # p[k] THEN d[j] := k ELSE d[j] := d[k] END; ASSERT( d[j] = D(j) );
END;
(* *)
i := 0; j := 0;
WHILE (j < M) & (i < N) & (j >= 0) & (s[i] # p[j]) DO
j := d[j];
ELSIF (j < M) & (i < N) DO
INC(i); INC(j);
END;
IF j = M THEN r := i–M ELSE r := –1 END
END Search
Анализ КМПалгоритма. Точный анализ эффективности КМП%алгоритма,
как и сам алгоритм, весьма непрост. В работе [1.8] его изобретатели доказали, что число сравнений литер имеет порядок
M+N
, что гораздо лучше, чем
M*N
в простом поиске. Они также указали на то приятное обстоятельство, что указатель i
всегда движется по тексту только вперед, тогда как в простом поиске просмотр текста всегда начинается с первой литеры образца после обнаружения неравенства ли%
тер, и поэтому уже просмотренные литеры могут просматриваться снова. Это мо%
жет привести к трудностям, если текст считывается из внешней памяти, так как
63
в таких случаях обратный ход по тексту может дорого обойтись. Даже если вход%
ные данные буферизуются, образец может оказаться таким, что потребуется воз%
врат за пределы буфера.
1.9.3. Алгоритм Бойера и Мура
Хитроумная схема КМП%алгоритма дает выигрыш, только если несовпадение об%
наруживается после частичного совпадения некоторого фрагмента. Только в та%
ком случае происходит сдвиг образца более чем на одну позицию. Увы, это скорее исключение, чем правило: совпадения встречаются реже, чем несовпадения. По%
этому выигрыш от использования КМП%стратегии оказывается не слишком су%
щественным в большинстве случаев обычного поиска в текстах. Метод, который мы теперь обсудим, улучшает поведение не только в наихудшем случае, но и в среднем. Он был изобретен около 1975 г. Бойером и Муром [1.9], и мы будем называть его БМалгоритмом. Мы представим его упрощенную версию.
БМ%алгоритм основан на несколько неожиданной идее – начать сравнение ли%
тер не с начала, а с конца образца. Как и в КМП%алгоритме, до начала собственно поиска для образца предкомпилируется таблица d
. Для каждой литеры x
из всего множества литер обозначим как d
x расстояние от конца образца до ее самого пра%
вого вхождения. Теперь предположим, что обнаружено несовпадение между тек%
стом и образцом. Тогда образец можно сразу сдвигать вправо на d
p[M–1]
позиций,
и весьма вероятно, что эта величина окажется больше, чем 1. Если p
M–1
вообще не встречается в образце, сдвиг еще больше и равен длине всего образца. Следующий пример иллюстрирует такую процедуру:
Hoola–Hoola girls like Hooligans.
Hooligan
Hooligan
Hooligan
Hooligan
Hooligan
Так как литеры теперь сравниваются справа налево, то будет удобнее исполь%
зовать следующие слегка модифицированные версии предикатов
P
,
R
и
Q
:
P(i, j) = A
A
A
A
Ak: j
≤ k < M : s i–M+k
= p k
R(i)
= P(i, 0)
Q(i)
= A
A
A
A
Ak: M
≤ k < i : R(k)
В этих терминах инвариант цикла будет иметь прежний вид:
Q(i) & P(i, j)
. Мы еще введем переменную k = i–M+j.
Теперь можно дать следующую формулировку
БМ%алгоритма:
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END
Поиск образца в тексте (string search)
Фундаментальные структуры данных
64
Индексы удовлетворяют ограничениям
0
≤
j
≤
M, M
≤
i, 0 < k
≤
i
. Остановка алгоритма с j = 0
влечет
P(i, 0) = R(i)
, то есть совпадение в позиции k = i–M
. Для остановки c j > 0
нужно, чтобы i > N
; тогда
Q(i)
влечет
Q(N)
и, следовательно, от%
сутствие совпадений. Разумеется, нам еще нужно убедиться, что
Q(i)
и
P(i, j)
действительно являются инвариантами двух циклов. Они очевидным образом удовлетворены в начале цикла, поскольку
Q(M)
и
P(x, M)
всегда истинны.
Сначала рассмотрим первую ветку. Одновременное уменьшение k
и j
никак не влияет на
Q(i)
, и, следовательно, поскольку уже установлено, что s
k–1
= p j–1
, а
P(i, j)
выполняется до операции уменьшения j
, то
P(i, j)
выполняется и после нее.
Во второй ветке достаточно показать, что присваивание i := i + d s[i–1]
не нару%
шает инвариант
Q(i)
, так как после остальных присваиваний
P(i, j) выполняется автоматически.
Q(i) выполняется после изменения i, если до него выполняется
Q(i+d s[i–1]
)
. Поскольку мы знаем, что выполняется
Q(i)
, достаточно установить, что
R(i+h)
для h = 1 .. d s[i–1]
–1
. Вспомним, что величина d
x определена как расстояние от конца до крайнего правого вхождения x
в образце. Формально это выражается в виде:
A
A
A
A
Ak: M–d x
≤ k < M–1 : p k
≠ x
Для x = s i–1
получаем
A
A
A
A
Ak: M–d s[i–1]
k < M–1 : s i–1
≠ p k
= A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : s i–1
≠ p
M–1–h
⇒ A
A
A
A
Ah: 1
≤ h ≤ d s[i–1]
–1 : R(i+h)
В следующей программе рассмотренная упрощенная стратегия Бойера–Мура оформлена подобно предыдущей программе, реализующей КМП%алгоритм:
PROCEDURE Search (VAR s, p: ARRAY OF CHAR; M, N: INTEGER; VAR r: INTEGER);
(* ADruS193_ *)
(* p M s N*)
(* p , r # s, r = –1*)
VAR i, j, k: INTEGER;
d: ARRAY 128 OF INTEGER;
BEGIN
FOR i := 0 TO 127 DO d[i] := M END;
FOR j := 0 TO M–2 DO d[ORD(p[j])] := M–j–1 END;
i := M; j := M; k := i;
WHILE (j > 0) & (i <= N) & (s[k–1] = p[j–1]) DO
DEC(k); DEC(j)
ELSIF (j > 0) & (i <= N) DO
i := i + d[ORD(s[i–1])]; j := M; k := i;
END;
IF j <= 0 THEN r := k ELSE r := –1 END
END Search
Анализ алгоритма Бойера и Мура. В оригинальной публикации этого алго%
ритма [1.9] детально изучена и его производительность. Замечательно то, что он всегда требует существенно меньше, чем
N
, сравнений, за исключением специ%
65
ально построенных примеров. В самом удачном случае, когда последняя литера образца всегда падает на неравную ей литеру текста, число сравнений равно
N/M
Авторы предложили несколько возможных путей дальнейшего улучшения алгоритма. Один состоит в том, чтобы скомбинировать описанную стратегию, ко%
торая обеспечивает большие шаги сдвига для случаев несовпадения, со стратеги%
ей Кнута, Морриса и Пратта, которая допускает большие сдвиги после (частично%
го) совпадения. Такой метод потребует предварительного вычисления двух таблиц – по одной для БМ%алгоритма и для КМП%алгоритма. И тогда можно брать больший из сдвигов, полученных двумя способами, так как оба гарантиру%
ют, что меньшие сдвиги не могут дать совпадения. Мы воздержимся от обсужде%
ния деталей, так как дополнительная сложность вычисления таблиц и самого по%
иска, по%видимому, не приводит к существенному выигрышу в эффективности.
Зато при этом увеличиваются накладные расходы, так что непонятно, является ли столь изощренное усовершенствование улучшением или ухудшением.
Замечание переводчика. Три алгоритма поиска образца в тексте – простой,
КМП и БМ – иллюстрируют одну закономерность, которую важно понимать любому проектировщику. В простом алгоритме инстинктивно реализуется дебютная идея, что проверку совпадения литер образца с литерами текста надо производить в том же направлении, в котором продвигается по тексту образец.
КМП%алгоритм, унаследовав эту идею, не осознавая этого факта, оптимизирует ее добавлением изощренной «заплатки» – механизма продвижения образца на более чем одну позицию с учетом уже просмотренных литер. Зато построение
БМ%алгоритма начинается с критического пересмотра самой дебютной идеи простого алгоритма: ведь сравнение литер образца в порядке движения образца по тексту обусловлено, в сущности, лишь инерцией мышления. При этом и уско%
ряющая «заплатка» здесь оказывается несравненно проще (ср. вычисление вспомогательных таблиц d в двух программах), и итоговый алгоритм суще%
ственно быстрее, и его математический анализ сильно облегчается. Это общая закономерность: первый импульс к конкретной деятельности, который про%
граммист ощущает, заметив какой%нибудь «очевидный» способ решения (когда,
что называется, «руки чешутся» начать писать код), способен помешать увидеть путь к наилучшему решению. Поэтому нужны специальные усилия, требующие дисциплины, для «пристального разглядывания» проблемы, чтобы выявить не%
явные, инстинктивные предположения – еще до первых попыток составить кон%
кретное решение, когда внимание уже будет поглощено не исходной задачей,
а красотой и эффективностью предполагаемого решения, ярко демонстрирую%
щего остроумие и изобретательность программиста, а также его познания в ме%
тодах оптимизации.
Упражнения
1.1. Обозначим мощности стандартных типов
INTEGER
,
REAL
и
CHAR
как c
int
, c
real и
c char
. Каковы мощности следующих типов данных, определенных в этой главе в качестве примеров:
Complex
,
Date
,
Person
,
Row
,
Card
,
Name
?
Упражнения
Фундаментальные структуры данных
66 1.2. Какие последовательности машинных инструкций (на вашем компьютере)
соответствуют следующим операциям:
a) чтение и запись для элементов упакованных записей и массивов;
b) операции для множеств, включая проверку принадлежности числа мно%
жеству?
1.3. Каковы могут быть аргументы в пользу определения некоторых наборов дан%
ных в виде последовательностей, а не массивов?
1.4. Пусть дано ежедневное железнодорожное расписание для поездов на нескольких линиях. Найдите такое представление этих данных в виде масси%
вов, записей или последовательностей, которое было бы удобно для опреде%
ления времени прибытия и отправления для заданных станции и направле%
ния поезда.
1.5. Пусть даны текст
T
, представленный последовательностью, а также списки небольшого числа слов в виде двух массивов
A
и
B
. Предположим, что слова являются короткими массивами литер небольшой фиксированной макси%
мальной длины. Напишите программу, преобразующую текст
T
в текст
S
за%
меной каждого вхождения слова
A
i соответствующим словом
B
i
1.6. Сравните следующие три варианта поиска делением пополам с тем, который был дан в основном тексте. Какие из трех программ правильны? Определите соответствующие инварианты. Какие варианты более эффективны? Предпо%
лагается, что определены константа
N > 0
и следующие переменные:
VAR i, j, k, x: INTEGER;
a: ARRAY N OF INTEGER;
Программа A:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF a[k] < x THEN i := k ELSE j := k END
UNTIL (a[k] = x) OR (i > j)
Программа B:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k–1 END;
IF a[k] < x THEN i := k+1 END
UNTIL i > j
Программа C:
i := 0; j := N–1;
REPEAT
k := (i+j) DIV 2;
IF x < a[k] THEN j := k ELSE i := k+1 END
UNTIL i > j
67
Подсказка. Все программы должны заканчиваться с a
k
= x
, если такой эле%
мент присутствует, или a
k
≠ x
, если элемента со значением x нет.
1.7. Некая компания проводит опрос, чтобы определить, насколько популярна ее продукция – записи эстрадных песен, а самые популярные песни должны быть объявлены в хит%параде. Опрашиваемую выборку покупателей нужно разделить на четыре группы в соответствии с полом и возрастом (скажем, не старше 20 и старше 20). Каждого покупателя просят назвать пять любимых пе%
сен. Песни нумеруются числами от
1
до
N
(пусть
N = 30
). Результаты опроса нужно подходящим образом закодировать в виде последовательности литер.
Подсказка: Используйте процедуры
Read и
ReadInt для чтения данных опроса.
TYPE hit = INTEGER;
reponse = RECORD name, firstname: Name;
male: BOOLEAN;
age: INTEGER;
choice: ARRAY 5 OF hit
END;
VAR poll: Files.File
Этот файл содержит входные данные для программы, вычисляющей следую%
щие результаты:
1. Список A песен в порядке популярности. Каждая запись списка состоит из номера песни и количества ее упоминаний в опросе. Ни разу не названные песни в список не включаются.
2. Четыре разных списка с фамилиями и именами всех респондентов, которые поставили на первое место один из трех самых популярных хитов в своей группе.
Перед каждым из пяти списков должен идти подходящий заголовок.
Литература
[1.1] Dahl O%.J., Dijkstra E. W., Hoare C. A. R. Structured Programming. F. Genuys,
Ed., New York, Academic Press, 1972 (имеется перевод: Дал У., Дейкстра Э.,
Хоор К. Структурное программирование. – М.: Мир, 1975).
[1.2] Hoare C. A. R., in Structured Programming [1.1]. Р. 83–174 (имеется перевод:
Хоор К. О структурной организации данных. В кн. [1.1]. С. 98–197.)
[1.3] Jensen K. and Wirth N. PASCAL – User Manual and Report. Springer%Verlag,
1974 (имеется перевод: Йенсен К., Вирт Н. Паскаль. Руководство для пользователя и описание языка. – М.: Финансы и статистика, 1988).
[1.4] Wirth N. Program development by stepwise refinement. Comm. ACM, 14, No. 4
(1971), 221–27.
[1.5] Wirth N. Programming in Modula%2. Springer%Verlag, 1982 (имеется перевод:
Вирт Н. Программирование на языке Модула%2. – М.: Мир, 1987).
Литература
Фундаментальные структуры данных
68
[1.6] Wirth N. On the composition of well%structured programs. Computing Surveys,
6, No. 4, (1974) 247–259.
[1.7] Hoare C. A. R. The Monitor: An operating systems structuring concept. Comm.
ACM, 17, 10 (Oct. 1974), 549–557.
[1.8] Knuth D. E., Morris J. H. and Pratt V. R. Fast pattern matching in strings. SIAM
J. Comput., 6, 2, (June 1977), 323–349.
[1.9] Boyer R. S. and Moore J. S. A fast string searching algorithm. Comm. ACM, 20,
10 (Oct. 1977), 762–772.
1 2 3 4 5 6 7 8 9 ... 22
Глава 2
Сортировка
2.1. Введение ............................ 70 2.2. Сортировка массивов ........ 72 2.3. Эффективные методы сортировки ................................ 81 2.4. Сортировка последовательностей ................ 97
Упражнения ............................. 128
Литература .............................. 130
Сортировка
70
2.1. Введение
Главная цель этой главы – дать богатый набор примеров, иллюстрирующих ис%
пользование структур данных, введенных в предыдущей главе, а также показать,
что выбор структуры обрабатываемых данных оказывает глубокое влияние на ал%
горитмы решения задачи. Тема сортировки хороша еще и тем, что показывает, как одна и та же задача может решаться с помощью многих разных алгоритмов, при%
чем у каждого из них есть преимущества и недостатки, которые следует взвесить в условиях конкретной задачи.
Под сортировкой обычно понимают процесс перестановки объектов некоторого множества в определенном порядке. Цель сортировки – облегчить в дальнейшем поиск элементов отсортированного множества. Это очень часто выполняемая, фун%
даментальная операция. Объекты сортируются в телефонных справочниках,
в списках налогоплательщиков, в оглавлениях книг, в библиотеках, в словарях, на складах, то есть почти всюду, где нужно искать хранимые объекты. Даже детей учат приводить вещи «в порядок», и они сталкиваются с некоторыми видами сортировки задолго до того, как начнут изучать арифметику.
Поэтому сортировка – весьма важная операция, особенно в обработке данных.
Кажется, ничто не поддается сортировке лучше, чем данные! И все же при изуче%
нии сортировки наибольший интерес для нас будут представлять еще более фундаментальные приемы, используемые при построении алгоритмов. Нелегко найти приемы, которые не использовались бы так или иначе в связи с алгоритма%
ми сортировки. При этом сортировка – идеальная тема для демонстрации широ%
кого разнообразия алгоритмов, решающих одну и ту же задачу, причем многие из них в каком%нибудь смысле оптимальны, и почти каждый из них имеет какие%ни%
будь преимущества перед остальными. Поэтому здесь хорошо видно, зачем нужен анализ эффективности алгоритмов. Более того, на примере сортировок становит%
ся понятно, что можно получить весьма существенный выигрыш в эффектив%
ности, разрабатывая хитроумные алгоритмы, даже если уже имеются очевидные методы.
Влияние структуры обрабатываемых данных на выбор алгоритма – само по себе обычное явление – в случае сортировок проявляется настолько сильно, что методы сортировки обычно делят на два типа, а именно сортировки массивов и сортировки (последовательных) файлов. Эти два типа часто называют внутрен
ней и внешней сортировками, так как массивы хранятся в быстрой, допускающей произвольный доступ внутренней оперативной памяти, а файлы уместны при ра%
боте с медленными, но вместительными внешними устройствами хранения (дис%
ками и лентами). Важность такого деления очевидна из примера сортировки пронумерованных карточек. Представление карточек массивом соответствует раскладыванию их перед сортировщиком так, чтобы каждая карточка была непос%
редственно видна и доступна (см. рис. 2.1).
Однако при файловой организации карточек в каждой стопке видна только верхняя карточка (см. рис. 2.2).
71
Очевидно, что такое ограничение будет иметь серьезные последствия для ме%
тода сортировки, но оно неизбежно, если карточек так много, что они не умещают%
ся на столе все сразу.
Сначала введем некоторые термины и обозначения, которые будут использо%
ваться на протяжении этой главы. Если даны n
элементов a
0
, a
1
, ... , a n–1
то сортировка состоит в их перестановке к виду a
k0
, a k1
, ... , a k[n–1]
таким образом, чтобы для некоторой упорядочивающей функции f
f(a k0
)
≤ f(a k1
)
≤ ... ≤ f(a k[n–1]
)
Рис. 2.1. Сортировка массива
Рис. 2.2. Сортировка файла
Введение
Сортировка
72
Часто значение такой упорядочивающей функции не вычисляется, а хранится как явная компонента (поле) в каждом элементе. Это значение называется клю
чом элемента. Следовательно, записи особенно хорошо подходят для представле%
ния элементов и могут быть объявлены, например, следующим образом:
TYPE Item = RECORD key: INTEGER;
(* # *)
END
Другие компоненты представляют данные, нужные для иных целей, а ключ используется просто для идентификации элементов. Однако для алгоритмов сортировки важен только ключ. Поэтому в дальнейшем мы будем игнорировать любую дополнительную информацию и примем, что тип
Item определен как
INTEGER
. Такой выбор типа ключа несколько произволен. Очевидно, с равным ус%
пехом можно использовать любой тип, для которого определено отношение пол%
ного порядка.
Метод сортировки называют устойчивым, если он сохраняет относительный порядок элементов с равными ключами. Устойчивость сортировки часто жела%
тельна, если элементы уже упорядочены (отсортированы) по некоторым вторич%
ным ключам, то есть свойствам, не отраженным в основном ключе.
Данная глава не является всеобъемлющим обзором методов сортировки. Мы предпочитаем уделить больше внимания некоторым избранным алгоритмам. Ин%
тересующийся читатель найдет тщательный разбор сортировок в великолепном и полном обзоре Кнута [2.7] (см. также Лорин [2.8]).
2.2. Сортировка массивов
Главное требование при разработке алгоритмов сортировки массивов – эконом%
ное использование доступной оперативной памяти. Это означает, что пере%
становки, с помощью которых упорядочиваются элементы, должны выполняться
in situ (лат.: на месте – прим. перев.), то есть не требуя дополнительной временной памяти, так что методы, которые требуют копирования элементов из массива a
в массив%результат b
, не представляют интереса. Ограничив таким образом выбор методов, попробуем классифицировать их в соответствии с эффективностью, то есть временем работы. Хорошая мера эффективности – число необходимых срав%
нений ключей
C
, а также число перестановок элементов
M
. Эти величины являют%
ся функциями числа сортируемых элементов n
. Хотя хорошие алгоритмы сорти%
ровки требуют порядка n*log(n)
сравнений, мы сначала рассмотрим несколько простых и очевидных методов, которые называются простыми и требуют порядка n
2
сравнений ключей. Есть три важные причины, почему нужно рассмотреть про%
стые методы, прежде чем переходить к быстрым алгоритмам:
1. Простые методы особенно хороши для обсуждения основных принципов сортировки.
2. Соответствующие программы легко понять. К тому же они короткие: ведь нужно помнить, что программы тоже занимают место в памяти!
73 3. Хотя более изощренные методы требуют меньшего числа операций, но эти операции обычно сложнее устроены; поэтому простые методы оказываются быстрее для малых n
, хотя их нельзя использовать для больших n
Алгоритмы сортировки, упорядочивающие элементы in situ, могут быть разде%
лены на три основные категории в соответствии с используемым приемом:
• сортировка вставками;
• сортировка выбором;
• сортировка обменом.
Изучим и сравним эти три подхода. Алгоритмы будут работать с глобальной переменной a
, чьи компоненты нужно отсортировать in situ. Компонентами являют%
ся сами ключи. Для простоты мы игнорируем прочие данные, хранящиеся в записях типа
Item.
Во всех алгоритмах, которые будут разработаны в этой главе, предпо%
лагается наличие массива a
и константы n
, равной числу элементов массива a
:
TYPE Item = INTEGER;
VAR a: ARRAY n OF Item
2.2.1. Простая сортировка вставками
Этот метод часто используется игроками в карты. Элементы (карты) мысленно разделяются на последовательность%приемник a
0
... a i–1
и последовательность%
источник a
i
... a n–1
. На каждом шаге, начиная с i = 1
и затем увеличивая i
на едини%
цу, в последовательности%источнике берется i
%й элемент и ставится в правильную позицию в последовательности%приемнике. В табл. 2.1 работа сортировки встав%
ками показана на примере восьми взятых наугад чисел.
Алгоритм простых вставок имеет вид
FOR i := 1 TO n–1 DO
x := a[i];
x a
0
... a i–1
END
Таблица 2.1.
Таблица 2.1.
Таблица 2.1.
Таблица 2.1.
Таблица 2.1. Пример работы простой сортировки вставками
Начальные ключи:
44 55 55 55 55 55 12 42 94 18 06 67
i=1 44 55 12 12 12 12 12 42 94 18 06 67
i=2 12 44 55 42 42 42 42 42 94 18 06 67
i=3 12 42 44 55 94 94 94 94 94 18 06 67
i=4 12 42 44 55 94 18 18 18 18 18 06 67
i=5 12 18 42 44 55 94 06 06 06 06 06 67
i=6 06 12 18 42 44 55 94 67 67 67 67 67
i=7 06 12 18 42 44 55 67 94
Сортировка массивов
Сортировка
74
При поиске правильной позиции для элемента удобно чередовать сравнения и пересылки, то есть позволять значению x
«просеиваться» вниз, при этом сравни%
ваем x
со следующим элементом a
j
, а затем либо вставляем x
, либо передвигаем a
j вправо и переходим влево. Заметим, что есть два разных условия, которые могут вызвать прекращение процесса просеивания:
1. У элемента a
j ключ оказался меньше, чем ключ x
2. Обнаружен левый конец последовательности%приемника.
PROCEDURE StraightInsertion;
(* ADruS2_Sorts *)
VAR i, j: INTEGER; x: Item;
BEGIN
FOR i := 1 TO n–1 DO
x := a[i]; j := i;
WHILE (j > 0) & (x < a[j–1]) DO
a[j] := a[j–1]; DEC(j)
END;
a[j] := x
END
END StraightInsertion
Анализ простой сортировки вставками. Число сравнений ключей в i
%м про%
сеивании
C
i не больше i–1
, как минимум равно
1
и – предполагая, что все пере%
становки n
ключей равновероятны, – в среднем равно i/2
. Число
M
i пересылок
(присваиваний элементов) равно
C
i
+ 1
. Поэтому полные числа сравнений и пере%
сылок равны
C
min
= n – 1
M
min
= 2*(n – 1)
C
ave
= (n
2
– n)/4
M
ave
= (n
2
+ 3n – 4)/4
C
max
= (n
2
– 3n + 2)/2
M
max
= (n
2
– n)/2
Минимальные значения получаются, когда элементы изначально упорядоче%
ны; наихудший случай имеет место, когда элементы изначально стоят в обратном порядке. В этом смысле сортировка вставками ведет себя вполне естественно.
Ясно также, что представленный алгоритм является устойчивой сортировкой: от%
носительный порядок элементов с равными ключами не нарушается.
Этот алгоритм нетрудно усовершенствовать, заметив, что последователь%
ность%приемник a
0
... a i–1
, в которую нужно вставить новый элемент, уже упоря%
дочена. Поэтому можно использовать более быстрый способ нахождения позиции вставки. Очевидный выбор – алгоритм деления пополам, в котором проверяется середина последовательности%приемника и затем продолжаются деления попо%
лам, пока не будет найдена точка вставки. Такой модифицированный алгоритм называется сортировкой двоичными вставками:
PROCEDURE BinaryInsertion;
(* ADruS2_Sorts *)
VAR i, j, m, L, R: INTEGER; x: Item;
BEGIN
FOR i := 1 TO n–1 DO
x := a[i]; L := 0; R := i;
75
WHILE L < R DO
m := (L+R) DIV 2;
IF a[m] <= x THEN L := m+1 ELSE R := m END
END;
FOR j := i TO R+1 BY –1 DO a[j] := a[j–1] END;
a[R] := x
END
END BinaryInsertion
Анализ сортировки двоичными вставками. Позиция вставки найдена, если
L = R
. Поэтому интервал поиска должен в итоге иметь длину 1; для этого нужно делить интервал длины i
пополам log(i)
раз. Поэтому
C = S
S
S
S
Si: 0
≤ i ≤ n–1: ⎡log(i)⎤
Аппроксимируем эту сумму интегралом
Integral (0:n–1) log(x) dx = n*(log(n) – c) + c где c = log(e) = 1/ln(2) = 1.44269...
В сущности, число сравнений не зависит от первоначального порядка элемен%
тов. Однако из%за округления при делении интервала истинное число сравнений для i
элементов может быть на
1
больше, чем ожидается. Причина этого отклоне%
ния – в том, что позиции вставки в нижней части последовательности в среднем находятся немного быстрее, чем позиции в верхней части, что благоприятствует тем случаям, когда элементы изначально сильно разупорядочены. На самом деле число сравнений минимально, когда элементы изначально стоят в обратном порядке, и максимально, когда они уже упорядочены. Здесь мы имеем пример неестественного поведения алгоритма сортировки. Тогда число сравнений примерно равно
C
≈ n*(log(n) – log(e) ± 0.5)
К сожалению, использование двоичного поиска уменьшает только число срав%
нений, но не число пересылок. На самом деле пересылки элементов, то есть клю%
чей и прочей информации, обычно более затратны, чем сравнение двух ключей,
так что получающееся здесь улучшение не принципиально: важная величина
M
все еще имеет порядок n
2
. Причем сортировка уже упорядоченного массива тре%
бует больше времени, чем при использовании простых вставок с последователь%
ным поиском.
Этот пример показывает, что «очевидное улучшение» вполне может оказаться гораздо менее полезным, чем ожидалось, а в некоторых случаях (которые действительно встречаются) «улучшение» может оказаться и ухудшением. При%
ходится заключить, что сортировка вставками не выглядит удачным методом для применения в компьютерных программах: вставка элемента, при которой весь ряд элементов сдвигается только на одну позицию, не является экономной. Лучших результатов можно ожидать от метода, в котором пересылались бы только оди%
ночные элементы, причем на более далекие расстояния. Это наблюдение приво%
дит к сортировке выбором.
Сортировка массивов