Последовательный поиск — поиск в конечном линейном списке с последовательным доступом.
Такой список представляет собой итератор.
Сразу же с порога коллизия в терминологии.
Здесь важно, что мы можем двигаться только вперед (в некоторых экзотических случаях и назад),
но произвольного доступа у нас нет. То есть мы не можем вытащить элемент с конкретным номером.
Пусть у нас есть итератор из ключей K1,K2,…,Kn (на их структуру нам пока наплевать).
Нам нужно понять, есть ли в этом наборе ключ t, и, если он есть, найти его номер.
Банальная идея «начать с начала и искать, пока не найдешь» не так уж и плоха.
Код, реализующий этот подход
functionsearch(iterable x, target)->bool:for index, element ofenumerate(x):if element == target:return index
returnnot found
Этот же алгоритм применим и для массивов (в общем случае последовательностей),
про внутреннюю структуру которых мы ничего не знаем.
Более того, при отсутствующей информации о структуре это самый эффективный алгоритм.
Понятно, что такой алгоритм работает за время O(n).
Показатель времени работа такого алгоритма — количество проверок (сравнений) C.
Если позиция целевого элемента равновероятно может равняться любому числу от 1 до n,
то ожидаемое количество проверок
EC=i=1∑ni⋅P(i)=n1i=1∑ni=2n+1
Упорядоченная последовательность
Пусть все элементы нашего итератора упорядочены, то есть xj>xi для j>i.
Мы хотим найти элемент t и его позицию.
Если на какой-то итерации i элемент xi оказался больше t,
то дальше продолжать смысла нет: из-за упорядоченности итератора все остальные
functionsearch(iterable x, target):for index, element ofenumerate(x):if element == target:return index
if element > target:returnnot foundreturnnot found
Временная сложность этого алгоритма совпадает с временной сложностью обычного алгоритма
последовательного поиска.
Отличие здесь состоит лишь в том, что для отсутствующих в итераторе элементов этот факт отсутствия может быть
установлен гораздо раньше.
Частота обращений
Возвращаемся к неупорядоченным итераторам aka линейным спискам с последовательным доступом.
Мы предполагали, что все аргументы последовательного поиска равновероятны.
Вообще это не так. Распределение вероятностей может быть любым.
Пусть вероятность запроса на поиск ключа Kj равна pj.
И пусть ответ на наш запрос всегда есть, то есть искомый элемент точно в итераторе.
j=1∑npj=1
Математическое ожидание количества проверок в алгоритме последовательного поиска равно
EC=j=1∑nj⋅pj
Количество сравнений EC не может быть больше энтропии.
Пусть есть какое-то «идеальное» распределение вероятностей q1,q2,…,qn
Если нам подвластна структура итератора, то минимальное ожидаемое время работы достигается при
p1⩾p2⩾⋯⩾pn
То есть, нам нужно переупорядочить элементы так, чтобы вероятности запроса на них убывали.
Вообще, это все немного противоречит самой концепции итераторов,
но допустим, что мы с самого начала определяем структуру итератора, а потом этим пользуемся.
Распределение Зипфа
Более типично для реальных ситуаций распределение Зипфа:
p1=Hn1p2=2Hn1⋯pj=jHn1⋯pn=nHn1
Джордж Кингсли Зипф (George Kingsley Zipf), изучая статистические закономерности в естественных языках,
обнаружил, что n-е по частоте слово встречается с частотой,
примерно обратно пропорциональной n.
По этому распределению pi=c/j.
Осталось найти константу из условия нормировки
j=1∑npj=1⟹c⋅Hn=1⟹c=Hn1
При таком распределении ожидаемое количество сравнений
EC=Hnn∼lnnn
Получается, что это почти в lnn/2 раз быстрее,
чем при предположении о равномерном распределении.
Распределение Парето
Еще более близкое к реальному распределение Парето (наиболее известное как правило 80-20),
которое на самом деле возникло в процессе изучения спроса и транзакций.
Человеческим языком его можно сформулировать так «80% транзакций работают с 20% данных».
Коротко, можно записать это распределение (с упорядоченными вероятностями) формулой
p1+p2+⋯+pmp1+p2+⋯+p⌊0.2⋅n⌋=0.8
На практике, значения 20% и 80% могут чуть-чуть отличатся, поэтому целесообразно ввести коэффициент
θ=log0.2log0.8
Из формулы-определения следуют формулы для вероятностей в терминах коэффициента θ
Используя асимптотическую оценку суммы j=1∑njθ∼nθ+1/(θ+1)+nθ/2+O(nθ−1) получаем, что
EC=n+1−nθ1j=1∑njθ=θ+1θ⋅n+21+O(n1)
Результат довольно хороший.
При коэффициенте θ=log0.20.8≈0.139 количество сравнений оценивается
по только что выведенной формуле как 0.5+0.122037⋅n.
Несмотря на немного разную грубость в оценках по Зипфу и по Паретто, распределение Паретто
выигрывает у распределение Зипфа примерно до значения n=3586.
Самоорганизующийся список
Зная распределение запросов поиска, мы можем организовать наш список по убыванию вероятностей,
и тем самым добиться минимального времени поиска.
Однако, в большинстве случаев распределение априори неизвестно.
Можно для каждой записи в списке хранить счётчик обращений к ней,
и на основе значений этих счётчиков переупорядочивать записи.
Вариант конечно рабочий, но эту дополнительную память лучше потратить на реализацию нелинейных методов поиска.
Можно ли реализовать самоорганизовывающийся список без дополнительной памяти? Можно.
Идея довольно простая: после обращения к какой-то записи перемещать её в начало списка.
В результате наиболее часто используемые записи окажутся в начале списка.
Эта эвристика называется «move to front» (MTF)
Пусть вероятность запроса на поиск ключа Rj равна pj.
Тогда в предельное среднее число сравнений, совершаемых при поиске ключа, равно
EC=1+21⩽i<j⩽n∑pi+pjpipj
Пусть fm(x1,x2,…,xm) равно бесконечной сумме
всех различных упорядоченных произведений xi1,xi2,…,xik,
для которых 1⩽i1,i2,…,ik⩽m,
причём каждая из переменных x1,x2,…,xm входит во все произведения.
Здесь Pn,m(X) — сумма fm по всем m-элементным подмножествам X, Qn,m(X) — сумма 1/(1−k=1∑mxjk) по тем же подмножествам X,
и Pn,0(X)=Qn,0(X)=1 по определению.
Пусть мы достаточно долго работаем с нашим самоорганизующимся списком.
Длина списка n.
Тогда элемент Rj будет находиться на позиции m с предельной вероятностью
Pn−1,m−1(X) — сумма fm−1 по всем (m−1)-элементным
подмножествам X, то есть по всем наборам из m−1 других элементов.
Предельная вероятность того, что Rj находится на m-м месте
равна вероятности того, что непосредственно перед Rj в списке находятся какие-то m−1 других элементов,
причём порядок этих m−1 элементов соответствует тому,
что они были запрошены после последнего запроса Rj в определённом порядке.
Функция fm(⋯) как раз перечисляет все последовательности запросов
к этим m−1 элементам, в которых каждый из них встречается, и которые могли привести к тому,
что они оказались перед Rj в момент перед запросом Rj.
Просуммировав этот факт для m=1,2,…,n, получаем тождество
Эвристика «Move to Front» — не просто академическое упражнение.
Она лежит, например, в основе алгоритмов адаптивного сжатия (например, Burrows-Wheeler в bzip2),
где частота символов пересчитывается на лету,
или например в стратегиях кэширования, таких как Adaptive Replacement Cache.
Упражнения
1
Пусть все ключи последовательного поиска равновероятны.
Найдите среднеквадратичное отклонение количества сравнений
при успешном поиске в линейном списке с n записями.
2
Вычислите среднее число сравнений при поиске в самоорганизующемся списке,
если распределение ключей поиска
бинарное распределение, то есть pj=1/2j при 1⩽j<n и pn=1/2n−1
Ответ
Нам нужно подставить значения pj в формулу EC.
Здесь pj=1/2j при 1⩽j<n и pn=1/2n−1.
1+21⩽i<j⩽n∑pi+pjpipj
Весь прикол задачи в вычислении суммы
1⩽i<j⩽n∑pi+pjpipj
Я могу приблизить эту сумму другой суммой, с более удобными значениями с точностью до O(n/2n)
1⩽i<j⩽n∑pi+pjpipj=1⩽i<j⩽n∑2i+2j1+O(2nn)
Составляем рекуррентное соотношение и получаем следующее равенство
1⩽i<j⩽n∑2i+2j1=(k=1∑n−11+2k1)⋅(1+2n1)−2nn−1
В итоге нам нужно посчитать, чему равно значение суммы
Найдя значение суммы по всем парам мы найдём значение исходной суммы.
Пусть s=i+j.
Тогда s меняется от 2 до 2n.
Теперь посмотрим, сколько пар (i,j) дают данное значение s.
Если s⩽n+1, то i может принимать
любые значения от 1 до s−1, всего s−1 штук.
Если s>n+1, то i может принимать
любые значения от s−n до n, всего 2n−s+1 штук.
распределение Парето, то есть pj=(jθ−(j−1)θ)/nθ при 1⩽j⩽n
3
Даны две невозрастающие последовательности чисел x1,x2,…,xn и y1,y2,…,yn.
Какая перестановка σ∈Sn делает сумму произведений j=1∑nxjyσ(j) максимальной? А какая минимальной?
4
У нас есть n условий, одновременное выполнение которых нужно проверить.
Ну то есть нужно посчитать конъюнкцию всех n условий.
Понятно, что начав с начала, и дойдя до какого-то ложного условия, можно дальше не продолжать проверку.
Мы знаем, что проверка j-го условия выполняется за время Tj,
и что это условие выполняется с вероятностью pj.
Эта вероятность не зависит от результата проверки других условий.
В каком порядке нужно проверять условия?
5
Нам нужно выполнить n заданий.
Выполнение j-го задания занимает время Tj.
Крайний срок выполнения j-го задания Dj, то есть это задание
нужно выполнить не позднее момента времени Dj.
Какое расписание работ a1,a2,…,an минимизирует максимальное опоздание, то есть величину