\documentclass[12pt,fleqn,a4paper]{book}
%
%
%
\usepackage[koi8-r]{inputenc}
\usepackage[russian,english]{babel}
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{amsfonts}
\usepackage{amsthm}
\usepackage{url}
\usepackage{EZlist}
%
%
%
\pagestyle{headings}
\newtheorem{theorem}{Теорема}[chapter]
\newtheorem{lemma}{Лемма}[chapter]
\newtheorem{proposition}{Утверждение}[chapter]
\newtheorem{fact}{Факт}[chapter]
\theoremstyle{definition}
\newtheorem{problem}{Задача}[chapter]
\newtheorem{exercise}{Упражнение}[chapter]
\newtheorem{example}{Пример}[chapter]
\newtheorem{definition}{Определение}[chapter]
\newtheorem{remark}{Замечание}[chapter]
\newtheorem{algorithm}{Алгоритм}[chapter]
\newcommand{\marginnote}[1]{}
\def\gap{\medskip\centerline{\fbox{\Huge\bfseries{ПРОБЕЛ В КОНСПЕКТЕ.}}}\medskip}
\advance\headheight by 7pt
\def\headsep{15mm}
\newcommand{\lecture}[3]{%
\def\rightmark{\fbox{\parbox{125mm}{\lecturername: \coursetitle%
\hfil\phantom{.}}}}
\def\leftmark{\fbox{\parbox{125mm}{Лекция {#1}. {#2}%
\hfil\phantom{.}}}}
\renewcommand\chaptername{Лекция}\renewcommand\thechapter{#1}\chapter{{#2}\\{\small }}
\thispagestyle{headings}}
%
%
%
\newcommand\lecturername{Э. А. Гирш}
\newcommand\coursetitle{``Информатика'' (отд.математики, 1 курс)}
%
%
%
%
%
%
\begin{document}
\selectlanguage{russian}
\sloppy
%
%
%
\lecture{4}{Представление данных (III).\\\mbox{\small Файл (сортировка на четырех лентах). Списки. Хеш-таблицы. Деревья.}}{1:30}
%
%
%
%
\section{Файл}\marginnote{15}
\emph{Файл последовательного доступа} --- 
это структура данных, к которой применимы следующие элементарные операции:
\begin{itemize}
\item READNEXT --- считать следующий элемент;
\item WRITENEXT --- записать следующий элемент;
\item REWIND --- вернуться к первому элементу (т.е. следующая операция
                  READNEXT или WRITENEXT будет обращаться к первому элементу).
\end{itemize}
Прямого доступа к $i$-му элементу нет (точнее, он не является
элементарной операцией и занимает не константное время).

Размер дисковой памяти (а тем более --- магнитных лент)
может превышать размер оперативной памяти. Поэтому при сортировке файла
может случиться так, что мы не сможем полностью считать файл в оперативную память (и
отсортировать полученный массив). Сейчас мы предъявим алгоритм сортировки,
который будет использовать лишь \emph{константное} число ячеек
оперативной памяти (правда, будет пользоваться четырьмя файлами,
а не одним). Файлы будем называть \emph{лентами}.

\paragraph{Сортировка на четырех лентах.}
Разобьем исходный файл пополам на две ленты (последовательно считывая
элементы, будем нечетные записывать на первую ленту, а четные --- на вторую).
%
%
Далее будем из двух лент, состоящих из отсортированных блоков 
по $i$ элементов, составлять две ленты, состоящие из отсортированных
блоков по $2i$ элементов (\emph{распространенный прием}: 
для простоты будем считать, что количество элементов
является степенью двойки $2^k$, --- в противном случае время работы вырастет
заведомо не более, чем в константу раз, поскольку размер входа
вырастет не более, чем в константу раз, даже если его округлить до степени двойки
в большую сторону).

Делается это так:
читаем поэлементно блоки с обеих лент (назовем эти ленты A и B), 
пишем блок удвоенной длины на одну ленту (назовем ее C)
(а следующий --- на другую, назовем ее D); 
при этом каждый раз на ленту C мы пишем наименьший элемент $v$ 
из двух считанных (с ленты A и с ленты B) 
и читаем следующий элемент
с той ленты, с которой взяли $v$ (если текущий блок на ней еще не закончился).
\begin{lemma}
Если ленты A, B длины $2^{k-1}$
состояли из блоков, отсортированных по $i$ элементов,
то после этой операции ленты C, D будут состоять из блоков, отсортированных
по $2i$ элементов (и по-прежнему будут иметь длину $2^{k-1}$). 
Эта процедура займет $O(n)$ операций считывания/записи
элементов и $O(1)$ ячеек оперативной памяти.
\end{lemma}

После этого ленты (A,B) и (C,D) меняются местами
(читаем C и D, пишем на A и B). Очевидно, за $t$ итераций
ленты, отсортированные по $1$ элементу, превратятся в ленты,
отсортированные по $2^t$ элементов. Таким образом, мы доказали
следующую теорему.

\begin{theorem}
Приведенный алгоритм сортирует исходный файл за $O(n\log n)$
обращений к файлам.
\end{theorem}

%
\section{Списки}\marginnote{10}
\paragraph{Однонаправленные списки.}
Однонаправленный список состоит из элементов\footnote{На Паскале --- записей.},
каждый из которых содержит полезные данные и указатель на следующий элемент
(пустой указатель, если следующего элемента нет).
На RAM-машине список чисел можно хранить в виде пар регистров
(число; номер регистра, содержащего следующий элемент списка).

Очевидно, над однонаправленными списками
легко (за константное число операций над элементами)
реализуются следующие операции:

\begin{itemize}
\item NEXT --- вернуть указатель на следующий элемент списка;
\item INSERT\_AFTER --- вставить новый элемент после заданного;
\item DELETE\_AFTER --- удалить элемент, следующий за заданным;
\item DELETE\_ROOT --- удалить первый элемент списка;
\item ROOT --- вернуть указатель на первый элемент списка.
\end{itemize} 

\smallskip
Поиск элемента, занимающего позицию $i$, занимает линейное время. 
То же верно для нахождения элемента, предшествующего в списке заданному.

\begin{exercise}
Списки удобно сортировать сортировкой на четырех лентах.
Заметим, что для этого не потребуется дополнительной памяти
(даже файла).\qed
\end{exercise}
 
\paragraph{Двунаправленные списки.}
Отличаются от однонаправленных тем,
что каждый элемент списка содержит также и указатель на \emph{предыдущий}
элемент. Отдельно хранится указатель на последний элемент
списка. Благодаря этому легко реализуются следующие 
дополнительные операции:

\begin{itemize}
\item LAST --- вернуть указатель на последний элемент;
\item PREVIOUS --- вернуть указатель на предыдущий элемент;
\item INSERT\_BEFORE --- вставить элемент перед заданным;
\item DELETE --- удалить элемент (заменяет 
      операции DELETE\_AFTER и DELETE\_ROOT над однонаправленными списками).
\end{itemize}


\paragraph{Skip-lists.}

Снабдим список дополнительной структурой, облегчающей поиск элемента
по ключу из линейно упорядоченного множества. Над каждым элементом
надстроим список из нескольких (для разных элементов --- из разного
количества) элементов, объединив надстроенные элементы по этажам.
Теперь можно сначала искать элемент на верхнем (самом маленьком)
этаже, затем спуститься в нужное место следующего этажа, и$\;$т.$\;$д.

\begin{remark}
В нескольких экземплярах надо хранить только ключи;
остальные данные достаточно хранить только на нижнем уровне.
\end{remark}

%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%

Один из способов построить эффективный в среднем skip-list --- строить его 
случайным образом, т.е. надстраивать над данным элементом следующий этаж с 
вероятностью $1/2$ (аналогично --- добавлять новые элементы). Точные
формулировки и доказательства опустим, т.к. они потребовали бы знания
теории вероятностей.

%
\section{Хеш-таблицы}\marginnote{5}
Представим, что нам нужно хранить словарь.
Проиндексировать строки строками (т.е. составить массив, к которому можно было бы
обращаться $A[\mbox{строка}]$) невозможно --- их слишком много.
Проиндексировать строки последовательными числами --- неудобно
(будет трудно найти заданное слово --- его придется искать во всем словаре;
к тому же, очень неудобно вставлять новое слово).

\emph{Хеш-функция} $f$ --- это функция, отображающая
множество объектов в множество ключей.
\emph{Хеш-таблица} --- это массив, 
проиндексированный возможными значениями хеш-функции.
В каждой ячейке этого массива хранится список 
объектов с соответствующим значением хеш-функции.

Важно выбрать хеш-функцию так, чтобы в разных списках
было примерно одинаковое количество элементов.

Имеется очевидный компромисс между временем работы (которое
зависит от длин списков) и занимаемой памятью 
(которая зависит от мощности образа хеш-функции).

%
\section{Деревья}
Будем говорить о деревьях с корнем.

\subsection{Представление деревьев в компьютере}\marginnote{5}
Имеется много разновидностей структур данных, называемых деревьями.
Соответственно, и набор операций над деревьями может быть разным.
Поэтому \emph{представление зависит от требуемых операций}. 
(Например, мы уже видели нетрадиционное представление двоичного дерева
в виде массива в ситуации, когда перестановки поддеревьев и даже 
вставка/удаление не нужны.)

Как представить двоичное дерево, ясно: элемент, соответствующий каждой
вершине, содержит хранимые данные и два указателя: на левого и правого сына;
если нужно --- указатель на родителя.
Для дерева с произвольной степенью вершин имеется несколько вариантов:
например, список сыновей (полезно указать их количество, а указатель наверх
хранить в каждом из них).

\begin{remark}
Динамический массив вместо списка не подойдет --- трудно вставлять!
\end{remark}

\subsection{Деревья поиска}\marginnote{5}
Дерево поиска --- структура данных, для которой эффективно реализуемы
операции INSERT, DELETE и FIND (поиск по ключу). (<<Эффективно>> в данном
случае означает <<не больше, чем за $O(\log \mbox{количества элементов})$
операций с элементами>>.
Даже если исходно данные не представлены в виде дерева, их может быть
полезно представить в таком виде, если они упорядочены и над ними часто
приходится выполнять указанные операции.

Свойство 
\begin{equation}\label{eq:search}
\mbox{\parbox{95mm}{
<<все элементы левого поддерева меньше ключа,
ключ больше всех элементов правого поддерева>>
}}
\end{equation}
позволяет реализовать FIND за $O(\textrm{высоты дерева})$ операций.
Если дерево <<идеально>> (высота $h$, количество вершин $2^{h+1}-1$), 
имеем $O(\log n)$ операций. Ниже мы изучим разновидности деревьев
поиска, позволяющие поддерживать себя в <<почти идеальном>> виде,
при этом ограничиваясь логарифмическим количеством операций
с элементами при реализации операций INSERT и DELETE.

\subsection{АВЛ-деревья\protect\footnote{Сокращение произошло
от фамилий авторов этой конструкции (Адельсон-Вельский и Ландис).}}\marginnote{20}

\begin{definition}
\emph{АВЛ-дерево (сбалансированное дерево)}: двоичное дерево поиска
(удовлетворяющее свойству (\ref{eq:search})),
для любой вершины которого высоты левого и правого поддеревьев
отличаются не более, чем на единицу.
\end{definition}

\begin{lemma}
Высота АВЛ-дерева составляет $O(\log n)$.
\end{lemma}
\begin{proof}
Покажем по индукции, что в АВЛ-дереве высоты $h$ имеется не менее
\[
\frac{5+2\sqrt{5}}{5}\left(\frac{1+\sqrt{5}}{2}\right)^h +
\frac{5-2\sqrt{5}}{5}\left(\frac{1-\sqrt{5}}{2}\right)^h -1
\qquad(=\Omega(\phi^h))
\]
вершин\footnote{Имеется в виду обозначение $\Omega$, принятое в 
(непрерывном) математическом анализе.}. Очевидно, в дереве высоты $h$
имеется не менее $G_{h-1}+G_{h-2}+1$ вершин,
где $G_i$ --- наименьшее количество вершин в АВЛ-дереве высоты $i$.
По предположению индукции
%
%
%
%
%
%
%
%
%
%
%
%
%
%
доказательство завершается 
(NB: $(\frac{1\pm\sqrt{5}}{2})^{{}^2}=(\frac{3\pm\sqrt{5}}{2})^{{}^2}$).
\end{proof}

Следовательно, поиск элемента отнимает лишь $O(\log n)$ операций.
Покажем, что то же самое относится и к операциям INSERT и DELETE.
Для поддержания сбалансированности нам понадобится выяснять высоты поддеревьев.
Для этого будем хранить в каждой из вершин разность высот левого
и правого поддеревьев (очевидно, высота любого поддерева тогда вычисляется
за $O(\log n)$ операций --- но в большинстве случаев это даже не нужно).

\begin{description}
\item[INSERT.] Попробуем вставить вершину с ключом $i$.
Найдем место, где она должна находиться, и вставим ее туда.
Если это второй потомок какой-то вершины, то высота не изменилась, и все OK.
В противном случае, посмотрим на высоты всех (пра)родителей вершины $i$.
По ним мы сможем найти самую нижнюю из разбалансированных вершин (назовем ее $v$);
пусть $w$ --- первая вершина на пути из $v$ в $i$.

Если $i$ была вставлена во <<внешнее>> поддерево вершины $w$,
мы можем совершить следующее <<вращение>>: $w$ становится корнем,
а $v$ --- ее сыном. <<Внутреннее>> поддерево вершины $w$ становится <<внутренним>>
поддеревом вершины $v$.
\begin{verbatim}
            v                             w
           / \                           / \
          A   w              --->       v   I
             / \                       / \
            B   I (в нем i)           A   B
\end{verbatim}
Если же $i$ была вставлена во <<внутреннее>> поддерево вершины $w$,
то сначала надо произвести другое <<вращение>> поддерева с корнем $w$, 
чтобы попасть в только что рассмотренную ситуацию:
\begin{verbatim}
            v                             v
           / \                           / \
          A   w              --->       A   x
             / \                           / \
            x   C                         I   w
           / \                               / \
          I  I'                             I'  C
\end{verbatim}
Теперь правое поддерево вершины $x$ (играющей роль $w$) заведомо выше левого,
так что вершина, разбалансирующая $v$, расположена именно в правом поддереве.

\begin{remark}
Заметим, что все эти операции затрагивают только поддерево с корнем $v$!\qed
\end{remark}

\medskip
\item[DELETE.] Аналогично. Однако, заметим, что после <<вращений>> высота
всего дерева с корнем $v$ может уменьшиться. При добавлении это не мешало,
так как получалась в точности высота этого дерева до добавления, а остальные
вершины уже были сбалансированы при этом условии. При удалении же
может разбалансироваться другая вершина (выше) и операции надо будет повторить
(и$\;$т.$\;$д. --- вплоть до $O(\log n)$ раз).
\end{description}

%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%
%

\subsection{2-3-4-деревья}\marginnote{15}
В 2-3-4-дереве внутренняя вершина может иметь от 2 до 4 сыновей
и от 1 до 3 ключей соответственно. В листьях может храниться от 2 до 4 ключей.
Все листья 2-3-4-дерева находятся на одной и той же глубине.

Ключи, хранящиеся в вершине, разделяют (в смысле операции сравнения) ключи,
лежащие в соответствующих поддеревьях:

\vbox{%
\begin{verbatim}
    ________________
   |_10_|_20_|__30__|
  /     |     \      \
 /\    /\     /\     /\
/  \  /  \   /  \   /  \
1..9 11..19 21..29 31..40
\end{verbatim}
}

Таким образом, поиск занимает $O(\log n)$ операций.

\begin{description}
\item[INSERT.]
Чтобы вставить ключ в 2-3-4-дерево, найдем лист, в котором он должен был бы
находиться. Вставим его туда. Если вершина переполнилась (5 ключей), разобьем ее
на две, а средний (третий) ключ используем в качестве нового разделяющего
ключа в родительской вершине. Если и она переполнилась (4 ключа, 5 поддеревьев),
разделим и ее пополам (1 ключ/2 поддерева и 2 ключа/3 поддерева, разделяющий
их ключ отправляется в родительскую вершину). Так будем продолжать, пока
вершины не перестанут переполняться. Если дойдем до корня, разделим его
пополам, увеличив высоту дерева.

\item[DELETE.]
\begin{exercise}
Аналогично (но вершины не разделяются, а сливаются).
При этом может понадобиться заимствовать ключи из соседних вершин,
в том числе, при помощи <<вращений>>.\qed
\end{exercise}
\end{description}

Следующая теорема теперь очевидна.
\begin{theorem}
Операции
INSERT, DELETE и FIND над 2-3-4-деревьями
можно реализовать за $O(\log n)$ операций
над их элементами.
\end{theorem}

\subsection{B-деревья} \marginnote{10}
Это обобщение 2-3-4-деревьев.
Мотивировка: хранение базы данных на диске;
заодно с нужными данными с диска автоматически
(так работают диски) считывается сразу много других
(целый \emph{блок} ---
физическая единица информации на диске);
хорошо бы, чтобы они в дальнейшем тоже были небесполезны.

Степень вершин теперь между $t/2$ и $t$ (кроме корня: его степень $\ge2$) --- 
так, чтобы запись всей вершины в точности поместилась в блок.
Соответственно, в каждой вершине хранится больше ключей.

Для четных $t$ операции аналогичны.
\begin{exercise}
Реализовать операции для нечетных $t$.\qed
\end{exercise}

\subsection{B${}^+$-деревья} \marginnote{5}
Можно не хранить данные во внутренних вершинах, а повторять там ключи
из нижних уровней дерева. Тогда, объединив листья в двунаправленный список,
мы сможем быстро находить предыдущий и следующий (в смысле упорядочения) элемент.

\begin{exercise}
Реализовать операции INSERT и DELETE.\qed
\end{exercise}
%
\end{document}

