ЛЕКЦИЯ 13. АЛГОРИТМЫ СОРТИРОВКИ. ПОИСК ДАННЫХ. ХЕШИРОВАНИЕ. Вопросы организации сортировки относятся к наиболее часто встречающимся в задачах машинной обработки данных. В большинстве компьютерных приложений множество объектов нуждается в переразмещении в соответствии с некоторым заранее определённым порядком, что существенно упрощает дальнейшую работу с ними. Эффективность сортировки вообще оценивается с точки зрения памяти и времени. Если же говорят об эффективности конкретного алгоритма сортировки, то имеют в виду число сравнений. Самые популярные алгоритмы сортировки, основанные на сравнении элементов, имеют сложность порядка O(n2), а наиболее эффективные требуют числа сравнений порядка O(n log2 n). ЗАДАЧА СОРТИРОВКИ обычно формулируется так: дана последовательность из n элементов a1, a2, …, an, выбранных из множества, для которого выполняется либо ai<aj, либо ai>aj, либо ai=aj. Требуется найти такую перестановку этих элементов, которая бы приводила исходную последовательность в неубывающую, то есть ak1 ak2… akn. Методы сортировки условно классифицируются на внутренние (когда данные размещаются в оперативной памяти) и внешние (когда данные размещены на внешних носителях). Внешняя сортировка применяется тогда, когда в работу вовлекается гораздо больше элементов, чем можно поместить в оперативную память. Ниже рассматривается несколько наиболее употребимых алгоритмов сортировки. Каждый из них обладает своими достоинствами и недостатками, поэтому он может иметь разную эффективность при некоторых структурах данных и аппаратной части. СОРТИРОВКА ВСТАВКАМИ Сортировка вставками элементов a1, a2, …, an относится к наиболее очевидным методам. При таком подходе вводится фиктивный элемент a0=-, а затем каждый элемент, начиная со второго, сравнивается с элементами уже упорядоченной части последовательности и вставляется в нужное место. При вставке элемент aj временно размещается в переменной w, и просматриваются элементы aj-1, aj-2, …,a1 (уже к этому времени упорядоченные). Они сравниваются с w и сдвигаются, если обнаруживается, что они больше чем w. //---------------------------------------------for (j=2; j<n+1;j++) { w=a[j]; i=j-1; while(w<a[i]) {a[i+1]=a[i];i=i-1;}; a[i+1]=w; }; //---------------------------------------------Сложность алгоритма определяется числом проверок условия w<a[i] в цикле. В худшем случае потребуется n(n-1)/2 таких сравнений, то есть сложность сортировки вставками - квадратичная. ПУЗЫРЬКОВАЯ СОРТИРОВКА Метод пузырьковой сортировки последовательности a1, a2, …, an представляет собой систематический обмен местами слева направо смежных элементов, не отвечающих выбранному порядку, до тех пор, пока они не оказываются на правильном месте. Большие элементы при этом как бы «всплывают пузырьками вверх» в конец списка. В приведённом ниже алгоритме используется переменная b, значение которой содержит число ещё не отсортированных элементов //--------------------------------------b=n; while (b!=0) { t=0; for(j=1;j<b;j++) { if (a[j]>a[j+1]) {w=a[j]; a[j]=a[j+1]; a[j+1]=w; t=j;}; } b=t; } //-------------------------------------------Сложность данного алгоритма определяется числом проверок условия a[j]>a[j+1] в цикле и является квадратичной O(n2). Число реально проделанных сравнений существенно зависит от первоначального расположения элементов массива. Рассмотренный ниже другой алгоритм так называемой «полной» пузырьковой сортировки является наиболее популярным и легко программируемым её вариантом. //----------------------------------- for(i=1;i<=n;i++) { for(j=1;j<=n-i; j++) { if (a[j]>a[j+1]) {w=a[j];a[j]=a[j+1];a[j+1]=w;}; } } //------------------------------------Сложность приведённого алгоритма определяется числом сравнений a[j]>a[j+1]. Она остаётся постоянно равной n(n-1)/2 (то есть квадратичной) и не зависит от расположения исходных данных. СОРТИРОВКА ПЕРЕЧИСЛЕНИЕМ Идея сортировки последовательности a1, a2, …, an данных перечислением состоит в том, чтобы сравнить попарно все элементы и подсчитать, сколько из них меньше каждого отдельного элемента. Для подсчёта числа элементов, меньших данного, в алгоритме используется вспомогательный вектор с1, с2, …, сn, после завершения алгоритма значения cj+1 определяют окончательное положение элементов в сортированной последовательности (это в случае, если нет одинаковых элементов, если же имеются такие, что aj=ai, то расположение элементов в отсортированном массиве также определяется вектором с 1, с2, …, сn, но с учётом того, что есть одинаковые элементы). //---------------------------------------------for(i=1;i<=n;i++) {w=0; for(j=1;j<=n;j++) { if(a[i]>a[j]) {w=w+1;}; } c[i]=w; } for(i=1;i<=n;i++) { r[c[i]+1]=a[i];// r – окончательно отсортированный массив; } //---------------------------------------------Сложность алгоритма сортировки перечислением определяется числом сравнений a[i]>a[j], то есть парой вложенных циклов, и является квадратичной и независимой от первоначального расположения данных. СОРТИРОВКА ВСПЛЫТИЕМ ФЛОЙДА Рассмотренные выше методы сортировки имеют квадратичную сложность. Рассмотрим один из наиболее эффективных методов сортировки сложности O(n log2n), предложенный Флойдом и до сих пор остающийся самым оптимальным из существующих методов. Введём понятие УПОРЯДОЧЕННОГО ДВОИЧНОГО ДЕРЕВА, значение в каждой вершине которого не меньше, чем значение в его дочерних вершинах (см. пример на рисунке). 9 5 7 3 1 6 4 Двоичное дерево называется частично упорядоченным, если свойство упорядоченности выполняется для каждой из его вершин, однако для корня это свойство нарушается. В ранее рассмотренных алгоритмах сортировки при выборе наибольшего (наименьшего) элемента информация о других элементах, забракованных на эту роль, как бы забывалась. Интерпретация же данных структурой дерева позволяет сохранить эту информацию на каждом шаге и использовать в дальнейших расчётах. Метод сортировки Флойда состоит в следующем: 1. Представление исходной последовательности a1, a2, …, an в виде двоичного дерева на смежной памяти, где рёбра присутствуют неявно, за корень дерева принимается элемент a1, а за каждой вершиной ak следуют вершины a2k и a2k+1 (см. предыдущую лекцию). 2. Приведение исходного дерева к упорядоченному, начиная от самых мелких его поддеревьев. Для этого наибольшие элементы из листьев как бы всплывают по направлению к корню, то есть происходит сортировка для каждой ветви дерева. 3. После того, как дерево упорядочено, наибольший элемент оказывается в его корне. Этот элемент меняют местами с самым последним листом в дереве (это будет последний элемент рассматриваемого массива и он теперь будет считаться уже находящимся на своём месте), дерево уменьшается на одну вершину, и оказывается почти упорядоченным. 4. Теперь осуществляется процедура всплытия, начинающаяся от корня, при которой наибольшие элементы из листьев меняются местами (всплывают) с элементами из корня. Дерево вновь оказывается упорядоченным. 5. Процедуры 3 и 4 повторяются до тех пор, пока не будет упорядочен весь исходный массив. Ниже приведена наглядная иллюстрация данного метода, при которой упорядочивается массив чисел 9, 5, 7, 3, 1, 6, 4. 1. Интерпретируем массив упорядоченным деревом: 9 5 3 7 1 6 4 2. Переставляем местами корень (наибольший элемент) и последний лист дерева, после чего уменьшим дерево на одну вершину (выбросим самую последнюю): 4 5 3 7 1 6 9 3. Выполним процедуру всплытия для корня: 7 5 3 6 1 4 9 4. Меняем местами корень и последний лист дерева, после чего уменьшаем дерево на одну вершину: 4 5 3 6 1 7 9 5. Снова выполняем процедуру всплытия для корня 6 5 3 4 1 7 9 6. Меняем местами корень и последний лист дерева, после чего уменьшаем дерево на одну вершину: 1 5 3 4 6 7 9 Далее аналогично, пока элементы не выстроятся следующим образом: 1 3 5 4 6 7 9 То есть исходный массив a1, a2, …, an оказывается упорядоченным. Рассмотрим сложность алгоритма всплытия Флойда. Сложность одной процедуры всплытия из корня к листьям определяется числом уровней в дереве, то есть log2 n. Вначале алгоритма нужно сформировать упорядоченное дерево, для чего выполнить всплытие для каждого из поддеревьев, то есть сделать n log2 n сравнений. Столько же сравнений нужно для последующей окончательной сортировки массива, то есть общая сложность алгоритма сортировки данных равна O(n log2 n). В теории алгоритмов доказывается, что это лучшая сложность алгоритма сортировки, на которую можно надеяться, если в основу алгоритма положены сравнения данных. ПОИСК ДАННЫХ Задача поиска является фундаментальной в алгоритмах обработки данных. В зависимости от структуры конкретных данных существует множество различных стратегий поиска различной степени эффективности. Сущность задачи поиска можно сформулировать следующим образом. Пусть есть множество элементов a1, a2, …, an и некоторый элемент u. Требуется ответить на вопрос, принадлежит ли u множеству a1, a2, …, an. Как правило, в основу решения данной задачи положены сравнения данных, то есть элемент u сравнивается с каждым элементом a1, a2, …, an в каком-либо порядке, пока он не будет найден или пока не будет сделан вывод об отсутствии его в данном множестве. Рассмотрим несколько конкретных алгоритмов задачи поиска. ПОСЛЕДОВАТЕЛЬНЫЙ ПОИСК При последовательном поиске подразумевается исследование на равенство с u элементов множества a1, a2, …, an в том порядке, в котором они встречаются. Такая последовательная процедура является очевидным способом поиска. //-------------------------------------------------c=0; for(i=1;i<=n;i++) { if (u==a[i]) {c=1;break}; } //------------------------------------------------В рассмотренном примере переменная с принимает значение 1, если элемент найден и 0, если он не найден. Оценим среднюю сложность последовательного поиска элементов. Для нахождения элемента, стоящего на i-м месте требуется i сравнений. Если предположить, что элементы разбросаны по исходному массиву случайно, и ко всем элементам обращаются одинаково 1 n n 1 часто, то средняя сложность поиска есть i O(n ) (линейная). n i 1 2 ДВОИЧНЫЙ ПОИСК Двоичный (бинарный, логарифмический) поиск данных применим к сортированному множеству элементов a1 a2… an, размещение которого выполнено на смежной памяти. Смысл его состоит в том, чтобы сделать пути доступа к элементам более короткими, чем просто последовательный перебор. Если имеется сортированный массив, то представляется очевидным следующий метод: начать поиск со среднего элемента, т.е. выполнить сравнение с элементом, имеющем индекс [(1+n)/2] ([] – оператор целочисленного деления). Результат сравнения позволит определить, в какой половине последовательности a1, a2, …, an продолжить поиск, применив ту же процедуру, и т.д. Для более наглядного представления алгоритма двоичного поиска интерпретируем данные a1, a2, …, an деревом сравнений. Деревом сравнений называется двоичное дерево, если для любой его вершины выполняется условие {Вершины левого поддерева}<Вершина корня<{Вершины правого поддерева}. Очевидно, что в этом случае корнем дерева будет являться элемент с индексом [(1+n)/2], а корнями его поддеревьев – середины соответствующих оставшихся массивов (см. также лекцию 3). На примере показан пример представления упорядоченного массива из 8 элементов деревом сравнений a4 a2 a1 a3 a6 a5 a7 a8 Если исходный упорядоченный массив представить деревом сравнений, то становится очевидным алгоритм двоичного поиска: если на каком-либо шаге алгоритма u < ai, следующее сравнение производится с корнем левого поддерева, если u > ai – с корнем правого поддерева. Средняя сложность двоичного поиска сравнима с высотой двоичного дерева. В худшем случае искомый элемент окажется на последнем уровне, либо вообще не будет найден. Поэтому сложность бинарного поиска – логарифмическая O(log2 n). Следует также отметить, что рассмотренный метод бинарного поиска предназначен главным образом для элементов на смежной памяти фиксированного размера. Если же размерность вектора динамически меняется, то экономия от бинарного поиска может не покрыть затрат на поддержание необходимой упорядоченности a1 a2… an. ХЕШИРОВАНИЕ Хешированием называется специальный способ доступа к данным, когда выделение элемента данных производится с помощью вспомогательного массива, содержащего условные индексы элементов. Данные индексы называются хеш-адресами или ключами. Функция, сопоставляющая элементу данных его индекс, называется хеш-функцией. Хеширование обычно применяется для уменьшения времени доступа к данным (например, современные ОС используют механизм хеширования при работе с дисковыми файлами). Оно чрезвычайно широко распространено при построении баз данных. Алгоритм доступа к данным базы выглядит в этом случае следующим образом: 1. Вводится хеш-функция, сопоставляющая каждому элементу данных числовое значение (ключ). 2. Из ключей формируется массив, при этом за каждым ключом закрепляется адрес элемента данных, которому он соответствует. На этом «предварительная подготовка» базы к работе заканчивается. 3. При поиске элемента данных вычисляется его ключ, поиск которого осуществляется в массиве ключей. 4. Когда соответствующий ключ найден, по закреплённому за ним указателю осуществляется доступ к соответствующим данным. Становится, таким образом, понятно, что главной проблемой хеширования является подбор хеш-функции, которая наиболее соответствовала бы конкретному типу данных и особенностям работы с ними. Наряду с узкоспециализированными подходами к подбору хеш-функций существует и ряд методов хеширования, подходящих для широкого круга задач. Рассмотрим один из них, основанный на контроле частоты обращения к тому или иному элементу данных. Массив ключей Данные 0 1 а1 а2 2 3 4 а3 а4 а5 Пусть каждому элементу данных массива сопоставляется ключ, равный количеству поисков в массиве, прошедших с момента последнего нахождения данного элемента. Таким образом, элементу, который был найден в последний раз, сопоставится значение 0, элементу, который был найден до этого – значение 1 и т.д. При этом первоначальные значения ключей удобно установить равными порядковым номерам элементов в массиве. При поиске вхождения элемента значение его ключа вначале устанавливается 0, если по этому ключу он не найден, то 1, если и по этому ключу он не найден, то 2 и.т.д. Смыслом такой организации процедуры поиска является то, что вначале поиск вхождения элемента будет происходить среди тех данных, доступ к которым осуществляется чаще, а значит и вероятность найти среди них нужный элемент в среднем больше. Ещё более простой реализацией такой идеи является обычный последовательный поиск с простым перемещением найденных в очередной раз данных в начало массива; в этом случае данные как бы автоматически выстраиваются в нужном порядке (здесь следует, однако, учесть дополнительные расходы на перемещение элементов в памяти). В теории алгоритмов доказывается, что рассмотренный подход к поиску данных с хешированием имеет сложность порядка n/ln n, что много меньше сложности алгоритма простого последовательного поиска. Кроме того, этот алгоритм удобно применять в случаях, когда имеет место динамическое изменение размера массива исходных данных, в связи с чем организация двоичного поиска становится затруднительной. ЗАДАНИЕ К ЛЕКЦИИ 13 Описать функцию Sorted(), сортирующую массив целых чисел методом сортировки вставками и возвращающую число произведённых сравнений. Написать пример программы, которая бы с помощью данной функции сортировала случайно заданные массивы при n=10, n=50, и n=100 и выдавала на консоль число произведённых сравнений в каждом из случаев.