\documentclass[12pt,fleqn,a4paper]{book}
%
% Packages used:
%
\usepackage[koi8-r]{inputenc}
\usepackage[russian,english]{babel}
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{amsfonts}
\usepackage{amsthm}
\usepackage{url}
\makeatletter
\def\@listI {\leftmargin\leftmarginii
              \labelwidth\leftmarginii
              \advance\labelwidth-\labelsep
              \topsep0pt
              \parsep0pt
              \itemsep0pt}
\def\@listii {\leftmargin\leftmarginii
              \labelwidth\leftmarginii
              \advance\labelwidth-\labelsep
              \topsep0pt
              \parsep0pt
              \itemsep0pt}
\def\@listiii{\leftmargin\leftmarginiii
              \labelwidth\leftmarginiii
              \advance\labelwidth-\labelsep
              \topsep0pt
              \parsep0pt
              \partopsep0pt
              \itemsep0pt}
\def\@listiv {\leftmargin\leftmarginiv
              \labelwidth\leftmarginiv
              \advance\labelwidth-\labelsep}
\def\@listv  {\leftmargin\leftmarginv
              \labelwidth\leftmarginv
              \advance\labelwidth-\labelsep}
\def\@listvi {\leftmargin\leftmarginvi
              \labelwidth\leftmarginvi
              \advance\labelwidth-\labelsep}
\makeatother
%
% Common customization:
%
\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]{\marginpar{\raggedright\ \fbox{\small #1}}}
\def\gap{\medskip\centerline{\fbox{\Huge\bfseries{ПРОБЕЛ В КОНСПЕКТЕ.}}}\medskip}
\advance\headheight by 7pt
\def\headsep{15mm}
\newcommand{\lecture}[2]{%
\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}}
\thispagestyle{headings}}
%
% Things to customize for the course are here:
%
\newcommand\lecturername{Э. А. Гирш}
\newcommand\coursetitle{``Информатика'' (отд. информатики, 1 курс)}
%
% Now, this particular lecture definitions:
%
%
% The document
%
\begin{document}
\selectlanguage{russian}
\sloppy
%
% Lecture title
%
\lecture{7}{Сортировка произвольных данных и порядковые статистики}
%
% The lecture
%
%-----------------------------------------------------------
\section{Файл. Сортировка на четырех лентах}

Вспомним, что
\emph{файл последовательного доступа} --- это абстрактная структура
данных, допускающая операции READ (с переходом к следующей записи),
WRITE (с переходом к следующей записи) и REWIND.
Его также можно рассматривать как ленту, по которой движется
читающая/пишущая головка: каждый раз после чтения или записи она передвигается
на одну позицию в направлении конца файла; по команде
REWIND она возвращается к самому началу.

Разобьем исходный файл пополам на две ленты (последовательно считывая
элементы, будем нечетные записывать на первую ленту, а четные --- на вторую).
%произвольным образом,
%сортировать не надо).
Далее будем из двух лент, состоящих из отсортированных блоков 
по $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}

\begin{remark}
Этот алгоритм полезно применять, когда данных так много, что они
не помещаются в оперативную память (хотя дополнительной
памяти на жестком диске он требует довольно много).

Также удобно этот алгоритм применять к спискам: в этом случае
все происходит в оперативной памяти; при этом дополнительной памяти
практически не требуется, так как вместо копирования данных
на другую ленту происходит просто переброска указателей.
\end{remark}

\section{Массив}

Абстрактное понятие массива. Линейный (полный) порядок.
Сортировка массива.

\gap

\section{HeapSort}
\begin{remark}
Мы уже изучили один алгоритм, сортирующий файл за $O(n\log n)$
операций. Его можно применить и к массиву; однако, этот алгоритм
использует $\Omega(n)$ ячеек дополнительной памяти.
Следующий алгоритм использует лишь $O(1)$ ячеек
(и также имеет сложность $O(n\log n)$).
Однако при необходимости отсортировать большой файл
следует использовать все же алгоритм сортировки на четырех лентах,
поскольку он не требует нахождения всего массива
в \emph{оперативной} памяти.
\end{remark}

Алгоритм HeapSort получил свое название от английского 
слова \emph{heap} --- куча.
Неформально говоря, в этом алгоритме
данные из массива организуются в виде <<\emph{кучи}>>:
двоичного дерева, в каждой вершине которого хранится элемент,
не превосходящий элемента, хранящегося в родителе этой вершины.

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

Таким образом, для достижения цели нам достаточно научиться строить
кучу и восстанавливать правильность ее структуры после удаления ее корня.
И то, и другое мы будем делать при помощи рекурсивной операции 
<<\emph{утапливания}>> вершины: если в некоторой вершине хранится элемент,
строго меньший элемента, хранящегося в одном из сыновей этой вершины,
то этот элемент надо поменять местами с его сыном (с тем из двух сыновей, 
в котором ключ наибольший), а затем, если необходимо, продолжить его
<<утапливание>>.

Для того, чтобы описать этот алгоритм более строго, зафиксируем способ
представления нашей кучи. Будем ее поддерживать в том же самом массиве,
который нам дан. Укладка дерева в массив $a$ производится следующим образом.
Занумеруем дерево по уровням: корень --- это $a[1]$,
вершины следующего уровня --- это $a[2]$ и $a[3]$, и$\;$т.$\;$д.
При такой нумерации массив $a$ содержит все элементы этого дерева,
причем сыновья вершины $a[i]$ расположены в $a[2i]$ и $a[2i+1]$.
Нам достаточно такого представления дерева, поскольку
нам нужны лишь две операции: чтение конкретного элемента $a[i]$
и перестановка \emph{содержимого}
двух его вершин: swap$(a[i],a[j])$.

Глобальная переменная $last$ будет содержать номер последнего необработанного
элемента массива
(пока это просто $n$; потом, по мере обработки части массива, $last$ будет
уменьшаться), это поможет определить наличие (и количество) потомков у
заданной вершины. Также будем использовать как глобальные переменные
сам массив $a$ и количество элементов в нем $n$.

\medskip
\newpage
\begin{description}
\item[] <<Утопим>> вершину:\\

\noindent
procedure pushnode ( $i$ : integer );\\
begin
\begin{itemize}
\item[] if $2i\le last$ then (* если $i$ --- не лист\ldots *)\\[-14pt]
\item[] begin
\begin{itemize}
\item[] (* выбрать из потомков $i$ наибольший: *)
\item[] $j:=2i$; (* это может быть $2i$ *)
\item[] (* но это может быть и $2i+1$, если он есть и больше: *)
\item[] if $2i<last$ then if $a[2i+1]>a[2i]$ then $j:=2i+1$;\\[-7pt]
\item[] if $a[j]>a[i]$ \hfill (* \emph{что неправильно!} *)
\item[] then begin swap$(a[i],a[j])$; pushnode($j$) end
\end{itemize}
\item[] end
\end{itemize}
end;\\

\medskip

\item[] Построим правильную кучу (пусть всего в ней $n$ вершин):\\

\noindent
procedure pushall;\\
begin
\begin{itemize}
\item[] for $i$:=$n$ downto $1$ do pushnode($i$)
\end{itemize}
end;\\

\begin{lemma}\label{lem:pushall:corr}
В дереве, построенном процедурой {\rm pushall}, 
никакой потомок не превосходит родителя.
\end{lemma}
\begin{proof}
Индукция по \emph{убыванию} номера вершины (от $n$ до $1$).
Иначе говоря, по построению дерева (добавлению корня к двум поддеревьям).
На первом же шаге новая вершина становится больше всех своих потомков,
на втором --- единственная вершина, в которой что-то могло испортиться,
также становится больше всех своих потомков, и$\;$т.$\;$д.
\end{proof}

\begin{lemma}\label{lem:pushall:time}
Процедура {\rm pushall} (вместе с вызовами процедуры {\rm pushnode})
использует $O(n\log n)$ операций обмена ({\rm swap}).
\end{lemma}
\begin{proof}
Для каждой из $n$ вершин вызывается процедура {\rm pushnode}.
Она делает не более $\log n$ рекурсивных вызовов
(поскольку такова высота дерева),
в каждом из них происходит лишь константное число обращений к элементам
массива.
\end{proof}

\begin{exercise}
Показать, что на самом деле используется лишь $O(n)$ операций
(хотя для дальнейших рассуждений нам это не будет важно).
\qed
\end{exercise}
\medskip

\item[] Наконец, отсортируем массив:\\

\noindent
procedure heapsort;\\
begin
\begin{itemize}
\item[] $last:=n$;
\item[] pushall;\\[-7pt]
\item[] for $i$:=$n$ downto $1$ do
\item[] begin
\begin{itemize}
\item[] swap($a[1],a[i]$); \mbox{\small(*\emph{$a[1]$ --- наибольший из оставшихся ---
                                      в конец!}*)}
\item[] $last:=i-1$;
\item[] pushnode($1$); \hfill {\small(*\emph{ведь $a[1]$ <<испортился>>}*)}
\end{itemize}
\item[] end
\end{itemize}
end;\\
\end{description}

\begin{theorem}\label{th:heapsort}
Процедура {\rm heapsort} правильно сортирует массив
и затрачивает на это лишь $O(n\log n)$ операций
с элементами массива.
\end{theorem}
\begin{proof}
\begin{description}
\item[Время работы] складывается из времени работы {\rm pushall}
(см. лемму~\ref{lem:pushall:time}) и времени работы
процедуры pushnode (в доказательстве леммы~\ref{lem:pushall:time}
мы уже видели, что это $O(\log n)$ операций), вызванной $n$ раз.
\item[Корректность] \emph{построения} кучи
доказана в лемме~\ref{lem:pushall:corr}.
То, что на каждом шаге после отправки $a[1]$ в конец
куча восстанавливается правильно,
можно доказать аналогично индуктивному шагу 
в доказательстве леммы~\ref{lem:pushall:corr}.
Наконец, благодаря основному свойству кучи,
на каждом шаге мы действительно <<вытаскиваем>> из нее (отправляем в конец массива)
наибольший из оставшихся элементов.
\end{description}
\end{proof}

\begin{remark}
Теорема~\ref{th:heapsort} справедлива для \emph{любого} массива $a$
(с \emph{любыми} значениями). Таким образом, мы оценили время работы
алгоритма \emph{в наихудшем случае}.\qed
\end{remark}

\begin{exercise}
Точное время работы зависит от того, какие элементы мы сортируем.
Какое время займет сортировка массива целых чисел на RAM-машине
при помощи алгоритма {\rm heapsort}?
\qed
\end{exercise}

\begin{remark}
Куча (heap) --- один из вариантов реализации очереди с приоритетами.

\gap
\end{remark}


%-----------------------------------------------------------
\section{QuickSort}

В этом разделе для простоты будем считать,
что все элементы в массиве различны
(анализ для случая, когда имеются одинаковые элементы,
совершенно аналогичен).

Алгоритм QuickSort:
возьмем какой-нибудь (скажем, первый в массиве) элемент,
поставим его на нужное место $i$
(так что все меньшие его элементы находятся слева, все б\'ольшие --- справа)
и рекурсивно отсортируем полученные массивы,
состоящие из $i-1$ и $n-i$ элементов соответственно.

\begin{theorem}\label{th:quicksort:wc:upper}
В алгоритме QuickSort
количество операций над элементами массива
в наихудшем случае составляет $O(n^2)$.
\end{theorem}
\begin{exercise}
Доказать теорему~\ref{th:quicksort:wc:upper}.
\qed
\end{exercise}

%\begin{remark}
%Говорят, что $f=\Omega(g)$, если $g=O(f)$.
%Однако, есть и другое определение
%(часто используемое в теории сложности): $f=\Omega(g)$,
%если $f\neq o(g)$, т.е. $f(n)$ бесконечно часто бывает больше
%$cg(n)$ для некоторой константы $c>0$.
%\end{remark}

\begin{theorem}
В алгоритме QuickSort
количество операций над элементами массива
в наихудшем случае составляет $\Omega(n^2)$.
\end{theorem}
\begin{proof}
Рассмотрим поведение алгоритма на уже отсортированном массиве.
\end{proof}

\begin{theorem}\label{th:quicksort:avg}
В алгоритме QuickSort
количество операций над элементами массива
\emph{в среднем} составляет $O(n\log n)$.
\end{theorem}

\begin{proof}
Пусть $t(\alpha)$ обозначает количество операций,
затрачиваемое на массив, исходное упорядочение которого
задано перестановкой $\alpha$ (как легко заметить,
количество операций зависит только от этой перестановки,
а не от конкретных элементов массива: $(9,5,7)$ и $(3,1,2)$
сортируются за одно и то же время).
Количество операций, затрачиваемых в среднем на массивы размера $n$,
обозначим через $T(n)$. Размер массива (или соответствующей перестановки)
$\alpha$ обозначим через $|\alpha|$. Часть перестановки $\alpha$
со \underline{значениями} от $j$ до $k$ (перенумерованными так, чтобы получилась
правильная перестановка) обозначим через $\alpha[j:k]$.
\begin{eqnarray}
T(n)&=&\frac{1}{n!} \sum_{|\alpha|=n} t(\alpha)=\nonumber\\
&=&\frac{1}{n!} \sum_{i=1}^n \sum_{|\alpha|=n,\,\alpha[1]=i} t(\alpha)\le\nonumber\\ 
&\le&\frac{1}{n!} \sum_{i=1}^n \sum_{|\alpha|=n,\,\alpha[1]=i}
( cn + t(\alpha[1:i-1]) + t(\alpha[i+1:n]) )=\nonumber\\
&=&
cn+\frac{1}{n!} \sum_{i=1}^n \left(
 i (i\!+\!1) \ldots (n\!-\!1) \!\!\sum_{|\beta|=i-1} t(\beta) +\right.\nonumber\\
&&\left.\qquad\qquad\qquad
+(n\!-\!i\!+\!1) (n\!-\!i\!+\!2) \ldots (n\!-\!1) \!\!\sum_{|\gamma|=n-i} t(\gamma)
\right) =\nonumber\\
&=&
cn+\frac{1}{n} \sum_{i=1}^n \left(
 \frac{1}{(i-1)!} \sum_{|\beta|=i-1} t(\beta) +
 \frac{1}{(n-i)!} \sum_{|\gamma|=n-i} t(\gamma)
\right) =\nonumber\\
&=&cn + \frac{1}{n} \sum_{i=1}^n (T(i-1)+T(n-i))= \nonumber\\
&=&cn + \frac{2}{n} \sum_{i=0}^{n-1} T(i).\label{eq:qs:av:rec}
\end{eqnarray}

Остается показать, что решение этого рекуррентного неравенства
удовлетворяет условию $T(n)=O(n\log n)$. 
Предварительно убедимся, что
$$
\sum_{i=2}^{n-1} i \ln i \le 
\int_2^n x \ln x\ \textrm{d}x \le
\frac{n^2\ln n}{2}-\frac{n^2}{4}
$$
(в этом можно убедиться при помощи интеграла --- по монотонности
функции $x\ln x$; или, вместо интеграла, по индукции).
%Итак, пусть $b=\max\{T(0),T(1)\}$, $k=2b+2c$. Итак,
%Покажем, что $T(n)\le kn\ln n$.

Пусть $b=\max\{T(0),T(1)\}$, $k=2b+2c$.
Покажем по индукции, что для всех $n\ge2$ выполняется $T(n)\le kn\ln n$.
База ($n=2$) очевидна из (\ref{eq:qs:av:rec}).
Для $n\ge3$ имеем
\begin{eqnarray*}
&&T(n)\le cn + \frac{2}{n} \sum_{i=0}^{n-1} T(i)
\le
cn+\frac{4b}{n} + \frac{2}{n} \sum_{i=2}^{n-1} T(i)
\le
\\
&&\le
cn+\frac{4b}{n}+\frac{2}{n} \sum_{i=2}^{n-1} ki \ln i
\le
cn+\frac{4b}{n}+\frac{2k}{n}  
\left(\frac{n^2\ln n}{2}-\frac{n^2}{4}\right)\\
&&=kn\ln n + cn+\frac{4b}{n} - (b+c)n \le kn\ln n.
\end{eqnarray*}
\end{proof}

\section{Randomized QuickSort}

Алгоритм Randomized QuickSort отличается от QuickSort тем, что
на каждом шаге элемент выбирается случайным образом.
Оценим время его работы \emph{в наихудшем случае}.
Оно будет зависеть от того, какие нам достанутся случайные числа.

\begin{theorem}\label{th:quicksort:rand}
Для \emph{любого} входного массива
математическое ожидание
количества операций 
в алгоритме Randomized QuickSort
над элементами массива
составляет $O(n\log n)$.
\end{theorem}

\begin{proof}\hfill

\gap

\end{proof}

\section{Поиск $k$-го элемента за линейное время в наихудшем случае}
Для удобства сделаем так, чтобы количество элементов в массиве делилось
на 10 (просто проверим лишние элементы по одному: найдем максимальный;
если он не $k$-й --- выкинем; это надо проделать не более 9 раз).

Разобьем массив на пятерки; возьмем медианы (третьи элементы) полных пятерок
и вычислим их медиану. Полученным элементом и разобьем массив <<пополам>>
(как в предыдущем алгоритме). Время работы (количество операций над элементами
массива) в наихудшем случае
составляет время на поиск медианы + время на поиск искомого элемента в одной
из полученных <<половинок>>:
$
T(n) \le T(\frac{n}{5}) + T(\frac{7n}{10}) + cn.
$
%При $n\ge50$ это выражение $\le T(\frac{n}{5}) + T(\frac{3n}{4}) + cn$.
Нетрудно по индукции доказать, что $T(n)\le 10cn$.
Мы доказали следующую теорему.
\begin{theorem}
Приведенный алгоритм затрачивает в наихудшем случае
лишь $O(n)$ операций на поиск
$k$-го элемента в массиве из $n$ элементов.
\end{theorem}

\section{Поиск $k$-го элемента аналогично Randomized QuickSort}
Заметим, что этот алгоритм совершает довольно много (хотя и линейное число)
действий (в частности, сравнений).
Рассмотрим похожий алгоритм, математическое ожидание количества сравнений
в котором будет лишь $4n$.

Изменение заключается лишь в том, что
элемент для ``разделения'' массива на половинки выберем случайно
(как в Randomized QuickSort).

\begin{theorem}\label{th:orderquick}
Для любого входного массива
математическое ожидание
количества операций 
в этом алгоритме
над элементами массива
составляет $4n$.
\end{theorem}

\begin{problem}
Доказать теорему~\ref{th:orderquick}.
\end{problem}

\begin{remark}
Можно добиться $2n$ сравнений (но это непросто).
\end{remark}

\end{document}

