КаталогИндекс раздела
НазадОглавлениеВперед


3. СТАТИЧЕСКИЕ СТРУКТУРЫ ДАННЫХ

Статические структуры относятся к разряду непримитивных структур, которые, фактически, представляют собой структурированное множество примитивных, базовых, структур. Например, вектор может быть представлен упорядоченным множеством чисел. Поскольку по определению статические структуры отличаются отсутствием изменчивости, память для них выделяется автоматически - как прави- ло, на этапе компиляции или при выполнении - в момент активизации того программного блока, в котором они описаны. Ряд языков программирования (PL/1, ALGOL-60) допускают размещение статических структур в памяти на этапе выполнения по явному требованию программиста, но и в этом случае объем выделенной памяти остается неизменным до уничтожения структуры. Выделение памяти на этапе компиляции является столь удобным свойством статических структур, что в ряде задач программисты используют их даже для представления объектов, обладающих изменчивостью. Например, когда размер массива неизвестен заранее, для него резервируется максимально возможный размер.

Каждую структуру данных характеризуют еЯ логическим и физическим представлениями. Очень часто говоря о той или иной структуре данных, имеют в виду еЯ логическое представление. Физическое представление обычно не соответствует логическому, и кроме того, может существенно различаться в разных программных системах. Нередко физической структуре ставится в соответствие дескриптор, или заголовок, который содержит общие сведения о физической структуре. Дескриптор необходим, например, в том случае, когда граничные значения индексов элементов массива неизвестны на этапе компиляции, и, следовательно, выделение памяти для массива может быть выполнено только на этапе выполнения программы (как в языке PL/1, ALGOL-60). Дескриптор хранится как и сама физическая структура, в памяти и состоит из полей, характер, число и размеры которых зависят от той структуры, которую он описывает и от приня- тых способов ее обработки. В ряде случаев дескриптор является совершенно необходимым, так как выполнение операции доступа к структуре требует обязательного знания каких-то ее параметров, и эти параметры хранятся в дескрипторе. Другие хранимые в дескрипторе параметры не являются совершенно необходимыми, но их использование позволяет сократить время доступа или обеспечить контроль правильности доступа к структуре. Дескриптор структуры данных, поддерживаемый языками программирования, является "невидимым" для программиста; он создается компилятором и компилятор же, формируя объектные коды для доступа к структуре, включает в эти коды команды, обращающиеся к дескриптору.

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


3.1. Векторы

Логическая структура.

Вектор (одномерный массив) - структура данных с фиксированным числом элементов одного и того же типа типа. Каждый элемент вектора имеет уникальный в рамках заданного вектора номер. Обращение к элементу вектора выполняется по имени вектора и номеру требуемого элемента.

Машинное представление. Адресация элементов структур.

Элементы вектора размещаются в памяти в подряд расположенных ячейках памяти. Под элемент вектора выделяется количество байт памяти, определяемое базовым типом элемента этого вектора. Необходимое число байтов памяти для хранения одного элемента вектора называется слотом. Размер памяти для хранения вектора определяется произведением длины слота на число элементов.

В языках программирования вектор представляется одномерным массивом с синтаксисом описания вида (PASCAL):

                 < Имя > : array [n..k] of < тип >;

где n-номер первого элемента, k-номер последнего элемента. Представление в памяти вектора будет такое, как показано на рис. 3.1.

Рис. 3.1. Представление вектора в памяти

где @ Имя -адрес вектора или, что тоже самое, адрес первого элемента вектора,

Sizeof(тип)-размер слота (количество байтов памяти для записи одного элемента вектора), (k-n)*Sizeof(тип) - относительный адрес элемента с номером k, или, что тоже самое, смещение элемента с номером k.

     Например:
     var   m1:array[-2..2] of real;

представление данного вектора в памяти будет как на рис. 3.2.

Рис. 3.2. Представление вектора m1 в памяти

В языках, где память распределяется до выполнения программы на этапе компиляции (C, PASCAL, FORTRAN), при описании типа вектора граничные значения индексов должны определены. В языках, где память может распределяться динамически (ALGOL, PL/1), значения индексов могут быть заданы во время выполнения программы.

Количество байтов непрерывной области памяти, занятых одновременно вектором, определяется по формуле:

        ByteSise = ( k - n + 1 ) * Sizeof (тип)

Обращение к i-тому элементу вектора выполняется по адресу вектора плюс смещение к данному элементу. Смещение i-ого элемента вектора определяется по формуле:

        ByteNumer = ( i- n ) * Sizeof (тип),
а адрес его: @ ByteNumber = @ имя + ByteNumber.

где @ имя - адрес первого элемента вектора.

    Например:
     var   МAS: array [ 5..10 ] of word.

Базовый тип элемента вектора - Word требует 2 байта, поэтому на каждый элемент вектора выделяется по два байта. Тогда таблица 3.1 смещений элементов вектора относительно @Mas выглядит так:

Смещение (байт) + 0 + 2 + 4 + 6 + 8 + 10
Идентификатор поля MAS[5] MAS[6] MAS[7] MAS[8] MAS[9] MAS[10]

Таблица 3.1

Этот вектор будет занимать в памяти: (10-5+1)*2 = 12 байт.
Смещение к элементу вектора с номером 8: (8-5)*2 = 6
Адрес элемента с номером 8: @ MAS + 6.

При доступе к вектору задается имя вектора и номер элемента вектора. Таким образом, адрес i-го элемента может быть вычислен как:

     @Имя[i] = @Имя + i*Sizeof(тип) - n*Sizeof(тип)        (3.1)

Это вычисление не может быть выполнено на этапе компиляции, так как значение переменной i в это время еще неизвестно. Следовательно, вычисление адреса элемента должно производиться на этапе выполнения программы при каждом обращении к элементу вектора. Но для этого на этапе выполнения, во-первых, должны быть известны параметры формулы (3.1): @Имя Sizeof(тип), n, а во-вторых, при каждом обращении должны выполняться две операции умножения и две - сложения. Преобразовав формулу (3.1) в формулу (3.2),

         @Имя[i] = A0 + i*Sizeof(тип) --                   (3.2)
         A0 = @Имя - n*Sizeof(тип)    --

сократим число хранимых параметров до двух, а число операций - до одного умножения и одного сложения, так как значение A0 может быть вычислено на этапе компиляции и сохранено вместе с Sizeof(тип) в дескрипторе вектора. Обычно в дескрипторе вектора сохраняются и граничные значения индексов. При каждом обращении к элементу вектора заданное значение сравнивается с граничными и программа аварийно завершается, если заданный индекс выходит за допустимые пределы.

Таким образом, информация, содержащаяся в дескрипторе вектора, позволяет, во-первых, сократить время доступа, а во-вторых, обеспечивает проверку правильности обращения. Но за эти преимущества приходится платить, во-первых, быстродействием, так как обращения к дескриптору - это команды, во-вторых, памятью как для размещения самого дескриптора, так и команд, с ним работающих.

Можно ли обойтись без дескриптора вектора?

В языке C, например, дескриптор вектора отсутствует, точнее, не сохраняется на этапе выполнения. Индексация массивов в C обязательно начинается с нуля. Компилятор каждое обращение к элементу массива заменяет на последовательность команд, реализующую частный случай формулы (3.1) при n = 0:

                 @Имя[i] = @Имя + i*Sizeof(тип)

Программисты, привыкшие работать на C, часто вместо выражения вида: Имя[i] употребляют выражение вида: *(Имя+i).

Но во-первых, ограничение в выборе начального индекса само по себе может являться неудобством для программиста, во-вторых, отсутствие граничных значений индексов делает невозможным контроль выхода за пределы массива. Программисты, работающие с C, хорошо знают, что именно такие ошибки часто являются причиной "зависания" C-программы при ее отладке.


3.2. Массивы

3.2.1. Логическая структура

Массив - такая структура данных, которая характеризуется:

Другое определение: массив - это вектор, каждый элемент которого - вектор.

Синтаксис описания массива представляется в виде:

     < Имя > :  Array [n1..k1] [n2..k2] .. [nn..kn] of < Тип >.

Для случая двумерного массива:

      Mas2D : Array [n1..k1] [n2..k2] of < Тип >,   или
      Mas2D : Array [n1..k1 , n2..k2] of < Тип >

Наглядно двумерный массив можно представить в виде таблицы из (k1-n1+1) строк и (k2-n2+1) столбцов.


3.2.2. Физическая структура

Физическая структура - это размещение элементов массива в памяти ЭВМ. Для случая двумерного массива, состоящего из (k1-n1+1) строк и (k2-n2+1) столбцов физическая структура представлена на рис. 3.3.

Рис. 3.3. Физическая структура двумерного массива из (k1-n1+1) строк и (k2-n2+1) столбцов

Многомерные массивы хранятся в непрерывной области памяти. Размер слота определяется базовым типом элемента массива. Количество элементов массива и размер слота определяют размер памяти для хранения массива. Принцип распределения элементов массива в памяти определен языком программирования. Так в FORTRAN элементы распределяются по столбцам - так, что быстрее меняется левые индексы, в PASCAL - по строкам - изменение индексов выполняется в направлении справа налево.

Количество байтов памяти, занятых двумерным массивом, определяется по формуле :

  ByteSize = (k1-n1+1)*(k2-n2+1)*SizeOf(Тип)

Адресом массива является адрес первого байта начального компонента массива. Смещение к элементу массива Mas[i1,i2] определяется по формуле:

        ByteNumber = [(i1-n1)*(k2-n2+1)+(i2-n2)]*SizeOf(Тип)
его адрес :  @ByteNumber = @mas + ByteNumber.
     Например:
     var   Mas : Array [3..5] [7..8] of Word;

Базовый тип элемента Word требует два байта памяти, тогда таблица 3.2 смещений элементов массива относительно @Mas будет следующей:

Смещение (байт) Идентификатор поля Смещение (байт) Идентификатор поля
+ 0 Mas[3,7] + 2 Mas[3,8]
+ 4 Mas[4,7] +6 Mas[4,8]
+ 8 Mas[5,7] + 10 Mas[5,8]

Таблица 3.2

Этот массив будет занимать в памяти: (5-3+1)*(8-7+1)*2=12 байт; а адрес элемента Mas[4,8]:

   @Mas+((4-3)*(8-7+1)+(8-7)*2 = @Mas+6

3.2.3. Операции

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

В соответствии с формулами (3.3), (3.4) и по аналогии с вектором (3.1), (3.2) для двумерного массива c границами изменения индексов:

[B(1)..E(1)][B(2)..E(2)], размещенного в памяти по строкам, адрес элемента с индексами [I(1),I(2)] может быть вычислен как:

 Addr[I(1),I(2)] = Addr[B(1),B(2)] +
 { [I(1)-B(1)] * [E(2)-B(2)+1] + [I(2)-B(2)] }*SizeOf(Тип)  (3.5)
     Обобщая (3.5) для массива произвольной размерности:
           Mas[B(1)..E(2)][B(2)..E(2)]...[B(n)..E(n)]
получим:
     Addr[I(1),I(2),...,I(n)] =   Addr[B(1),B(2),...B(n)] -
                n                              n            (3.6)
- Sizeof(Тип)*СУММА[B(m)*D(m)] + Sizeof(Тип)*СУММА[I(m)*D(m)]
               m=1                            m=1

где Dm зависит от способа размещения массива. При размещении по строкам:

     D(m)=[E(m+1)-B(m+1)+1]*D(m+1), где m = n-1,...,1 и D(n)=1

при размещении по столбцам:

     D(m)=[E(m-1)-B(m-1)+1]*D(m-1), где m = 2,...,n и D(1)=1

При вычислении адреса элемента наиболее сложным является вычисление третьей составляющей формулы (3.6), т.к. первые две не зависят от индексов и могут быть вычислены заранее. Для ускорения вычислений множители D(m) также могут быть вычислены заранее и сохраняться в дескрипторе массива. Дескриптор массива, таким образом, содержит:

Одно из определений массива гласит, что это вектор, каждый элемент которого - вектор. Некоторые языки программирования позволяют выделить из многомерного массива одно или несколько измерений и рассматривать их как массив меньшей мерности.

Например, если в PL/1-программе объявлен двумерный массив:

                 DECLARE A(10,10) BINARY FIXED;

то выражение: A[*,I] - будет обращаться к одномерному массиву, состоящему из элементов: A(1,I), A(2,I),...,A(10,I).

Символ-джокер "*" означает, что выбираются все элементы массива по тому измерению, которому соответствует заданный джокером индекс. Использование джокера позволяет также задавать групповые операции над всеми элементами массива или над выбранным его измерением,

                 например:   A(*,I) = A(*,I) + 1

К операциям логического уровня над массивами необходимо отнести такие как сортировка массива, поиск элемента по ключу. Наиболее распространенные алгоритмы поиска и сортировок будут рассмотрены в данной главе ниже.


3.2.4. Адресация элементов с помощью векторов Айлиффа

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

Рис. 3.4. Представление массивов с помощью векторов Айлиффа

Для массива любой мерности формируется набор дескрипторов: основного и несколько уровней вспомогательных дескрипторов, называемых векторами Айлиффа. Каждый вектор Айлиффа определЯнного уровня содержит указатель на нулевые компоненты векторов Айлиффа следующего, более низкого уровня, а векторы Айлиффа самого нижнего уровня содержат указатели групп элементов отображаемого масси- ва. Основной дескриптор массива хранит указатель вектора Айлиффа первого уровня. При такой организации к произвольному элементу В(j1,j2,...,jn) многомерного массива можно обратиться пройдя по цепочке от основного дескриптора через соответствующие компоненты векторов Айлиффа.

На рис. 3.4 приведена физическая структура трЯхмерного массива В[4..5,-1..1,0..1], представленная по методу Айлиффа. Из этого рисунка видно, что метод Айлиффа, увеличивая скорость доступа к элементам массива, приводит в то же время к увеличению суммарного объЯма памяти, требуемого для представления массива. В этом заключается основной недостаток представления массивов с по- мощью векторов Айлиффа.


3.2.5. Специальные массивы

На практике встречаются массивы, которые в силу определенных причин могут записываться в память не полностью, а частично. Это особенно актуально для массивов настолько больших размеров, что для их хранения в полном объеме памяти может быть недостаточно. К таким массивам относятся симметричные и разреженные массивы.

Симметричные массивы.

Двумерный массив, в котором количество строк равно количеству столбцов называется квадратной матрицей. Квадратная матрица, у которой элементы, расположенные симметрично относительно главной диагонали, попарно равны друг другу, называется симметричной. Если матрица порядка n симметрична, то в ее физической структуре достаточно отобразить не n^2, а лишь n*(n+1)/2 еЯ элементов. Иными словами, в памяти необходимо представить только верхний (включая и диагональ) треугольник квадратной логической структуры. Доступ к треугольному массиву организуется таким образом, чтобы можно было обращаться к любому элементу исходной логической структуры, в том числе и к элементам, значения которых хотя и не представлены в памяти, но могут быть определены на основе значений симметричных им элементов.

На практике для работы с симметричной матрицей разрабатываются процедуры для:

а) преобразования индексов матрицы в индекс вектора,
б) формирования вектора и записи в него элементов верхнего треугольника элементов исходной матрицы,
в) получения значения элемента матрицы из ее упакованного представления. При таком подходе обращение к элементам исходной матрицы выполняется опосредованно, через указанные функции.

В приложении приведен пример программы для работы с симметричной матрицей.

Разреженные массивы.

Разреженный массив - массив, большинство элементов которого равны между собой, так что хранить в памяти достаточно лишь небольшое число значений отличных от основного (фонового) значения остальных элементов.

Различают два типа разреженных массивов:

В случае работы с разреженными массивами вопросы размещения их в памяти реализуются на логическом уровне с учетом их типа.

Массивы с математическим описанием местоположения нефоновых элементов.

К данному типу массивов относятся массивы, у которых местоположения элементов со значениями отличными от фонового, могут быть математически описаны, т. е. в их расположении есть какая-либо закономерность.

Элементы, значения которых являются фоновыми, называют нулевыми; элементы, значения которых отличны от фонового, - ненулевыми. Но нужно помнить, что фоновое значение не всегда равно нулю.

Ненулевые значения хранятся, как правило, в одномерном массиве, а связь между местоположением в исходном, разреженном, массиве и в новом, одномерном, описывается математически с помощью формулы, преобразующей индексы массива в индексы вектора.

На практике для работы с разреженным массивом разрабатываются функции:

При таком подходе обращение к элементам исходного массива выполняется с помощью указанных функций. Например, пусть имеется двумерная разреженная матрица, в которой все ненулевые элементы расположены в шахматном порядке, начиная со второго элемента. Для такой матрицы формула вычисления индекса элемента в линейном представлении будет следующей : L=((y-1)*XM+x)/2), где L - индекс в линейном представлении; x, y - соответственно строка и столбец в двумерном представлении; XM - количество элементов в строке исходной матрицы.

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

 {===== Программный пример 3.1 =====}  Unit ComprMatr;
 Interface
  Function PutTab(y,x,value : integer) : boolean;
  Function GetTab(x,y: integer) : integer;
 Implementation
  Const XM=...;
  Var arrp: array[1..XM*XM div 2] of integer;
  Function NewIndex(y, x : integer) : integer;
     var  i: integer;
     begin   NewIndex:=((y-1)*XM+x) div 2);  end;
  Function PutTab(y,x,value : integer) : boolean;
     begin
        if NOT ((x mod 2<>0) and (y mod 2<>0)) or
           NOT ((x mod 2=0) and (y mod 2=0))  then begin
              arrp[NewIndex(y,x)]:=value;  PutTab:=true; end
                                              else PutTab:=false;
     end;
  Function GetTab(x,y: integer) : integer;
     begin
        if ((x mod 2<>0) and (y mod 2<>0)) or
           ((x mod 2=0) and (y mod 2=0)) then GetTab:=0
        else GetTab:=arrp[NewIndex(y,x)];
     end;
end.

Сжатое представление матрицы хранится в векторе arrp.

Функция NewIndex выполняет пересчет индексов по вышеприведенной формуле и возвращает индекс элемента в векторе arrp.

Функция PutTab выполняет сохранение в сжатом представлении одного элемента с индексами x, y и значением value. Сохранение выполняется только в том случае, если индексы x, y адресуют не заведомо нулевой элемент. Если сохранение выполнено, функция возвращает true, иначе - false.

Для доступа к элементу по индексам двумерной матрицы используется функция GetTab, которая по индексам x, y возвращает выбранное значение. Если индексы адресуют заведомо нулевой элемент матрицы, функция возвращает 0.

Обратите внимание на то, что массив arrp, а также функция NewIndex не описаны в секции IMPLEMENTATION модуля. Доступ к содержимому матрицы извне возможен только через входные точки PutTab, GetTab с заданием двух индексов.

В программном примере 3.2 та же задача решается несколько иным способом: для матрицы создается дескриптор - массив desc, который заполняется при инициализации матрицы таким образом, что i-ый элемент массива desc содержит индекс первого элемента i-ой строки матрицы в ее линейном представлении. Процедура инициализации InitTab включена в число входных точек модуля и должна вызываться перед началом работы с матрицей. Но доступ к каждому элементу матрицы (функция NewIndex) упрощается и выполняется быстрее: по номеру строки y из дескриптора сразу выбирается индекс начала строки и к нему прибавляется смещение элемента из столбца x. Процедуры PutTab и GetTab - такие же, как и в примере 3.1 поэтому здесь не приводятся.

 {===== Программный пример 3.2 =====}
 Unit ComprMatr;
 Interface
  Function PutTab(y,x,value : integer) : boolean;
  Function GetTab(x,y: integer) : integer;
  Procedure InitTab;
 Implementation
  Const XM=...;
  Var arrp: array[1..XM*XM div 2] of integer;
      desc: array[1..XM] of integer;
  Procedure InitTab;
  var i : integer;
   begin
      desc[1]:=0;  for i:=1 to XM do desc[i]:=desc[i-1]+XM;
   end;
  Function NewIndex(y, x : integer) : integer;
  var  i: integer;
  begin  NewIndex:=desc[y]+x div 2;  end;
end. 

Разреженные массивы со случайным расположением элементов.

К данному типу массивов относятся массивы, у которых местоположения элементов со значениями отличными от фонового, не могут быть математически описаны, т. е. в их расположении нет какой-либо закономерности.

Ё 0  0  6  0  9  0  0 Ё
Ё 2  0  0  7  8  0  4 Ё
Ё10  0  0  0  0  0  0 Ё
Ё 0  0 12  0  0  0  0 Ё
Ё 0  0  0  3  0  0  5 Ё

Пусть есть матрица А размерности 5*7, в которой из 35 элементов только 10 отличны от нуля.

Представление разреженным матриц методом последовательного размещения.

Один из основных способов хранения подобных разреженных матриц заключается в запоминании ненулевых элементов в одномерном массиве и идентификации каждого элемента массива индексами строки и столбца, как это показано на рис. 3.5 а).

Доступ к элементу массива A с индексами i и j выполняется выборкой индекса i из вектора ROW, индекса j из вектора COLUM и значения элемента из вектора A. Слева указан индекс k векторов наибольшеее значение, которого определяется количеством нефоновых элементов. Отметим, что элементы массива обязательно запоминаются в порядке возрастания номеров строк.

Более эффективное представление, с точки зрения требований к памяти и времени доступа к строкам матрицы, показано на рис.3.5.б). Вектор ROW уменьшнен, количество его элементов соответствует числу строк исходного массива A, содержащих нефоновые элементы. Этот вектор получен из вектора ROW рис. 3.5.а) так, что его i-й элемент является индексом k для первого нефонового элемента i-ой строки.

Представление матрицы А, данное на рис. 3.5 сокращает требования к объему памяти более чем в 2 раза. Для больших матриц экономия памяти очень важна. Способ последовательного распределения имеет также то преимущество, что операции над матрицами могут быть выполнены быстрее, чем это возможно при представлении в виде последовательного двумерного массива, особенно если размер матрицы велик.

Рис. 3.5. Последовательное представление разреженных матриц.

Представление разреженных матриц методом связанных структур.

Методы последовательного размещения для представления разреженных матриц обычно позволяют быстрее выполнять операции над матрицами и более эффективно использовать память, чем методы со связанными структурами. Однако последовательное представление матриц имеет определенные недостатки. Так включение и исключение новых элементов матрицы вызывает необходимость перемещения большого числа других элементов. Если включение новых элементов и их исключение осуществляется часто, то должен быть выбран описываемый ниже ме- тод связанных структур.

Метод связанных структур, однако, переводит представляемую структуру данных в другой раздел классификации. При том, что логическая структура данных остается статической, физическая структура становится динамической.

Для представления разреженных матриц требуется базовая структура вершины (рис.3.6), называемая MATRIX_ELEMENT ("элемент матрицы"). Поля V, R и С каждой из этих вершин содержат соответственно значение, индексы строки и столбца элемента матрицы. Поля LEFT и UP являются указателями на следующий элемент для строки и столбца в циклическом списке, содержащем элементы матрицы. Поле LEFT указывает на вершину со следующим наименьшим номером строки.

Рис.3.6. Формат вершины для представления разреженных матриц

На рис. 3.7 приведена многосвязная структура, в которой используются вершины такого типа для представления матрицы А, описанной ранее в данном пункте. Циклический список представляет все строки и столбцы. Список столбца может содержать общие вершины с одним списком строки или более. Для того, чтобы обеспечить использование более эффективных алгоритмов включения и исключения элементов, все списки строк и столбцов имеют головные вершины. Головная вершина каждого списка строки содержит нуль в поле С; аналогично каждая головная вершина в списке столбца имеет нуль в поле R. Строка или столбец, содержащие только нулевые элементы, представлены головными вершинами, у которых поле LEFT или UP указывает само на себя.

Рис. 3.7. Многосвязная структура для представления матрицы A

Может показаться странным, что указатели в этой многосвязной структуре направлены вверх и влево, вследствие чего при сканировании циклического списка элементы матрицы встречаются в порядке убывания номеров строк и столбцов. Такой метод представления используется для упрощения включения новых вершин в структуру. Предполагается, что новые вершины, которые должны быть добавлены к матрице, обычно располагаются в порядке убывания индексов строк и индексов столбцов. Если это так, то новая вершина всегда добавляется после головной и не требуется никакого просмотра списка.


3.3. Множества

Логическая структура.

Множество - такая структура, которая представляет собой набор неповторяющихся данных одного и того же типа. Множество может принимать все значения базового типа. Базовый тип не должен превышать 256 возможных значений. Поэтому базовым типом множества могут быть byte, char и производные от них типы.

Физическая структура.

Множество в памяти хранится как массив битов, в котором каждый бит указывает является ли элемент принадлежащим объявленному множеству или нет. Т.о. максимальное число элементов множества 256, а данные типа множество могут занимать не более 32-ух байт.

Число байтов, выделяемых для данных типа множество, вычисляется по формуле: ByteSize = (max div 8)-(min div 8) + 1, где max и min - верхняя и нижняя границы базового типа данного множества.

Номер байта для конкретного элемента Е вычисляется по формуле:

      ByteNumber = (E div 8)-(min div 8), 

номер бита внутри этого байта по формуле:

      BitNumber = E mod 8
{===== Программный пример 3.3 =====}
     const max=255; min=0; E=13;
     var  S : set of byte;
          ByteSize, ByteNumb, BitNumb  : byte;
     begin
        S:=[];                 {    обнуление множества         }
        S:=S+[E];              {    запись числа в множество    }
        ByteSize:=(max div 8)-(min div 8)+1;
        Bytenumb:=(E div 8)-(min div 8);
        BitNumb:=E mod 8;
        writeln(bytesize);     {     на экране 32               }
        writeln(bytenumb);     {     на экране 1                }
        writeln(bitnumb);      {     на экране 5                }
     end. 

3.3.1. Числовые множества

Стандартный числовой тип, который может быть базовым для формирования множества - тип byte.

Множество хранится в памяти как показано в таблице 3.3.

Таблица 3.3

где @S - адрес данного типа множество.

Бит поля установлен в 1, если элемент входит в множество, и в 0 - если не входит.

    Например,  S : set of byte;   S:=[15,19];
    Содержимое памяти при  этом будет следующим:
   @S+0 - 00000000     @S+2  - 00001000
   @S+1 - 10000000     .  .  . .  .  .
                       @S+31 - 00000000

3.3.2. Символьные множества

Символьные множества хранятся в памяти также как и числовые множества. Разница лишь в том, что хранятся не числа, а коды ASCII символов.

     Например, S : set of char;  S:=['A','C'];

В этом случае представление множества S в памяти выглядит следующим образом :

   @S+0 - 00000000        .  .  .  .  .  .
   .  .  .  .  .  .       @S+31 - 00000000
   @S+8 - 00001010

3.3.3. Множество из элементов перечислимого типа

Множество, базовым типом которого есть перечислимый тип, хранится также, как множество, базовым типом которого является тип byte. Однако, в памяти занимает место, которое зависит от количества элементов в перечислимом типе.

     Пример:
      Type
         Video=(MDA,CGA,HGC,EGA,EGAm,VGA,VGAm,SVGA,PGA,XGA);
      Var
         S : set of Video;

В памяти будет занимать :
ByteSize = (9 div 8)-(0 div 8)+1=2 байта
При этом память для переменной S будет распределена как показано на рис. 3.8.

Рис. 3.8. Распределение памяти для переменной типа set of Video

Если выполнить оператор S:=[CGA,SVGA], содержимое памяти при этом будет:

  
             @S+0  - 10000010
             @S+1  - 00000000

3.3.4. Множество от интервального типа

Множество, базовым типом которого есть интервальный тип, хранится также, как множество, базовым типом которого является тип byte. Однако, в памяти занимает место, которое зависит от количества элементов, входящих в объявленный интервал.

     Например,
       type    S=10..17;
       var     I:set of S;

Это не значит, что первый элемент будет начинаться с 10-того или 0-ого бита, как может показаться на первый взгляд. Как видно из формулы вычисления смещения внутри байта 10 mod 8 = 2, смещение первого элемента множества I начнЯтся со второго бита. И, хотя множество этого интервала свободно могло поместиться в один байт, оно займЯт (17 div 8)-(10 div 8)+1 = 2 байта.

В памяти это множество имеет представление как на рис. 3.9.

Рис. 3.9. Представление переменной типа set of S

Для конструирования множеств интервальный тип самый экономичный, т.к. занимает память в зависимости от заданных границ.

     Например, Type  S = 510..520;
               Var   I : S;
               begin  I:=[512];  end.
     Представление в памяти переменной I будет:
     @i+0 - 00000000    @i+1 - 00000001

3.3.5. Операции над множествами

Пусть S1, S2, S3 : set of byte , Над этими множествами определены следующие специфические операции:


3.4. Записи

3.4.1. Логическое и машинное представление записей

Запись - конечное упорядоченное множество полей, характеризующихся различным типом данных. Записи являются чрезвычайно удобным средством для представления программных моделей реальных объектов предметной области, ибо, как правило, каждый такой объект обладает набором свойств, характеризуемых данными различных типов.

Пример записи - совокупность сведений о некотором студенте.

Объект "студент" обладает свойствами:

Пример: var
          rec:record
              num  :byte;        {    личный номер студента     }
              name :string[20];  {    Ф.И.О.                    }
         fac, group:string[7];   {  факультет,  группа          }
    math,comp,lang :byte;        {оценки по математике, выч.    }
              end;               {технике, ин. языку            }

В памяти эта структура может быть представлена в одном из двух видов :

а) в виде последовательности полей, занимающих непрерывную область памяти (рис. 3.10). При такой организации достаточно иметь один указатель на начало области и смещение относительно начала. Это дает экономию памяти, но лишнюю трату времени на вычисление адресов полей записи.

Рис. 3.10. Представление в памяти переменной типа record в виде последовательности полей

б) в виде связного списка с указателями на значения полей записи. При такой организации имеет место быстрое обращение к элементам, но очень неэкономичный расход памяти для хранения. Структура хранения в памяти связного списка с указателями на элементы приведена на рис. 3.11.

Рис. 3.11. Представление в памяти переменной типа record в виде связного списка.

Примечание: для экономии объема памяти, отводимой под запись, значения некоторых ее полей хранятся в самом дескрипторе, вместо указателей, тогда в дескрипторе должны быть записаны соответствующие признаки.

В соответствии с общим подходом языка C дескриптор записи (в этом языке записи называются структурами) не сохраняется до выполнения программы. Поля структуры просто располагаются в смежных слотах памяти, обращения к отдельным полям заменяются на их адреса еще на этапе компиляции.

Полем записи может быть в свою очередь интегрированная структура данных - вектор, массив или другая запись. В некоторых языках программирования (COBOL, PL/1) при описании вложенных записей указывается уровень вложенности, в других (PASCAL, C) - уровень вложенности определяется автоматически.

Полем записи может быть другая запись,но ни в коем случае не такая же. Это связано прежде всего с тем, что компилятор должен выделить для размещения записи память. Предположим, описана запись вида:

    type rec = record
       f1 : integer;
       f2 : char[2];
       f3 : rec;  end;

Как компилятор будет выделять память для такой записи? Для поля f1 будет выделено 2 байта, для поля f2 - 2 байта, а поле f3 - запись, которая в свою очередь состоит из f1 (2 байта), f2 (2 байта) и f3, которое... и т.д. Недаром компилятор C, встретив подобное описание, выдает сообщение о нехватке памяти.

Однако, полем записи вполне может быть указатель на другую такую же запись: размер памяти, занимаемой указателем известен и проблем с выделением памяти не возникает. Этот прием широко используется в программировании для установления связей между однотипными записями (см. главу 5).


3.4.2. Операции над записями

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

           < имя переменной-записи >.< имя поля >

Так, для записи, описанной в начале п.3.5.1, конструкции: stud1.num и stud1.math будут обеспечивать обращения к полям num и math соответственно.

Над выбранным полем записи возможны любые операции, допустимые для типа этого поля.

Большинство языков программирования поддерживает некоторые операции, работающие с записью, как с единым целым, а не с отдельными ее полями. Это операции присваивания одной записи значения другой однотипной записи и сравнения двух однотипных записей на равенство/неравенство. В тех же случаях, когда такие операции не поддерживаются языком явно (язык C), они могут выполняться над отдельными полями записей или же записи могут копироваться и сравниваться как неструктурированные области памяти.


3.5. Записи с вариантами

В ряде прикладных задач программист может столкнуться с группами объектов, чьи наборы свойств перекрываются лишь частично. Обработка таких объектов производится по одним и тем же алгоритмам, если обрабатываются общие свойства объектов, или по разным - если обрабатываются специфические свойства. Можно описать все группы единообразно, включив в описание все наборы свойств для всех групп, но такое описание будет неэкономичным с точки зрения расходуемой памяти и неудобным с логической точки зрения. Если же описать каждую группу собственной структурой, теряется возможность обрабатывать общие свойства по единым алгоритмам.

Для задач подобного рода развитые языки программирования (C, PASCAL) предоставляют в распоряжение программиста записи с вариантами. Запись с вариантами состоит из двух частей. В первой части описываются поля, общие для всех групп объектов, моделируемых записью. Среди этих полей обычно бывает поле, значение которого позволяет идентифицировать группу, к которой данный объект принадлежит и, следовательно, какой из вариантов второй части записи должен быть использован при обработке. Вторая часть записи содержит описания непересекающихся свойств - для каждого подмножества таких свойств - отдельное описание. Язык программирования может требовать, чтобы имена полей-свойств не повторялись в разных вариантах (PASCAL), или же требовать именования каждого варианта (C). В первом случае идентификация поля, находящегося в вариантной части записи при обращении к нему ничем не отличается от случая простой записи:

        < имя переменной-записи >.< имя поля >

Во втором случае идентификация немного усложняется:

        < имя переменной-записи >.< имя варианта >.< имя поля >

Рассмотрим использование записей с вариантами на примере. Пусть требуется размещать на экране видеотерминала простые геометрические фигуры - круги, прямоугольники, треугольники. Для "базы данных", которая будет описывать состояние экрана, удобно представлять все фигуры однотипными записями. Для любой фигуры описание ее должно включать в себя координаты некоторой опорной точки (центра, правого верхнего угла, одной из вершин) и код цвета. Другие же параметры построения будут разными для разных фигур. Так для круга - радиус; для прямоугольника - длины непараллельных сторон; для треугольника - координаты двух других вершин.

Запись с вариантами для такой задачи в языке PASCAL выглядит, как:

    type figure = record
      fig_type : char;    { тип фигуры }
      x0, y0   : word;    { координаты опорной точки }
      color    : byte;    { цвет }
      case fig_t : char of
        'C': ( radius : word);     { радиус окружности }
        'R': (len1, len2 : word);  { длины сторон прямоугольника }
        'T': (x1,y1,x2,y2 : word); { координаты двух вершин }
      end;

а в языке C, как:

  typedef struct
  { char fig_type;       /* тип фигуры */
    unsigned int x0, y0; /* координаты опорной точки */
    unsigned char color; /* цвет */
    union
    { struct
     { unsigned int radius;     /* радиус окружности */
     } cyrcle;
    struct
     { unsigned int len1, len2;  /* длины сторон прямоугольника */
     } rectangle;
    struct
     { unsigned int x1,y1,x2,y2; /* координаты двух вершин */
     } triangle;
    } fig_t;
  } figure; 

И если в программе определена переменная fig1 типа figure, в которой хранится описание окружности, то обращение к радиусу этой окружности в языке PASCAL будет иметь вид: fig1.radius, а в C: fig1.circle.radius

Поле с именем fig_type введено для представления идентификатора вида фигуры, который, например, может кодироваться символами: "C"- окружность или "R"- прямоугольник, или "T"- треугольник.

Выделение памяти для записи с вариантами показано на рис.3.12.

Рис.3.12. Выделение памяти для записи с вариантами

Как видно из рисунка, под запись с вариантами выделяется в любом случае объем памяти, достаточный для размещения самого большого варианта. Если же выделенная память используется для меньшего варианта, часть ее остается неиспользуемой. Общая для всех вариантов часть записи размещается так, чтобы смещения всех полей относительно начала записи были одинаковыми для всех вари- антов. Очевидно, что наиболее просто это достигается размещением общих полей в начале записи, но это не строго обязательно. Вариантная часть может и "вклиниваться" между полями общей части. Поскольку в любом случае вариантная часть имеет фиксированный (максимальный) размер, смещения полей общей части также останутся фиксированными.


3.6. Таблицы

Когда речь шла о записях, было отмечено, что полями записи могут быть интегрированные структуры данных - векторы, массивы, другие записи. Аналогично и элементами векторов и массивов могут быть также интегрированные структуры. Одна из таких сложных структур - таблица. С физической точки зрения таблица представляет собой вектор, элементами которого являются записи. Характерной логической особенностью таблиц, которая и определила их рассмотрение в отдельном разделе, является то, что доступ к элементам таблицы производится не по номеру (индексу), а по ключу - по значению одного из свойств объекта, описываемого записью-элементом таблицы. Ключ - это свойство, идентифицирующее данную запись во множестве однотипных записей. Как правило, к ключу предъявляется требование уникальности в данной таблице. Ключ может включаться в состав записи и быть одним из ее полей, но может и не включаться в запись, а вычисляться по положению записи. Таблица может иметь один или несколько ключей. Например, при интеграции в таблицу записей о студентах (описание записи приведено в п.3.5.1) выборка может производиться как по личному номеру студента, так и по фамилии.

Основной операцией при работе с таблицами является операция доступа к записи по ключу. Она реализуется процедурой поиска. Поскольку поиск может быть значительно более эффективным в таблицах, упорядоченных по значениям ключей, довольно часто над таблицами необходимо выполнять операции сортировки. Эти операции рассматриваются в следующих разделах данной главы.

Иногда различают таблицы с фиксированной и с переменной длиной записи. Очевидно, что таблицы, объединяющие записи совершенно идентичных типов, будут иметь фиксированные длины записей. Необходимость в переменной длине может возникнуть в задачах, подобных тем, которые рассматривались для записей с вариантами. Как правило таблицы для таких задач и составляются из записей с вариантами, т.е. сводятся к фиксированной (максимальной) длине записи. Значительно реже встречаются таблицы с действительно переменной длиной записи. Хотя в таких таблицах и экономится память, но возможности работы с такими таблицами ограничены, так как по номеру записи невозможно определить ее адрес. Таблицы с записями переменной длины обрабатываются только последовательно - в порядке возрастания номеров записей. Доступ к элементу такой таблицы обычно осуществляется в два шага. На первом шаге выбирается постоянная часть записи, в которой содержится - в явном или неявном виде - длина записи. На втором шаге выбирается переменная часть записи в соответствии с ее длиной. Прибавив к адресу текущей записи ее длину, получают адрес следующей записи.

Так таблица с записями переменной длины может, например, рассматриваться в некоторых задачах программируемых в машинных кодах. Каждая машинная команда - запись, состоит из одного или нескольких байт. Первый байт - всегда код операции, количество и формат остальных байтов определяется типом команды. Процессор выбирает байт по адресу, задаваемому программным счетчиком, и определяет тип команды. По типу команды процессор определяет ее длину и выбирает остальные ее байты. Содержимое программного счетчика увеличивается на длину команды.


3.7. Операции логического уровня над статическими структурами. Поиск

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

Объективным критерием, позволяющим оценить эффективность того или иного алгоритма, является, так называемый, порядок алгоритма. Порядком алгоритма называется функция O(N), позволяющая оценить зависимость времени выполнения алгоритма от объема перерабатываемых данных (N - количество элементов в массиве или таблице). Эффективность алгоритма тем выше, чем меньше время его выполнения зависит от объема данных. Большинство алгоритмов с точки зрения порядка сводятся к трем основным типам:

Эффективность степенных алгоритмов обычно считается плохой, линейных - удовлетворительной, логарифмических - хорошей.

Аналитическое определение порядка алгоритма, хотя часто и сложно, но возможно в большинстве случаев. Возникает вопрос: зачем тогда нужно такое разнообразие алгоритмов, например, сортировок, если есть возможность раз и навсегда определить алгоритм с наилучшим аналитическим показателем эффективности и оставить "право на жизнь" исключительно за ним? Ответ прост: в реальных задачах имеются ограничения, определяемые как логикой задачи, так и свойствами конкретной вычислительной среды, которые могут помогать или мешать программисту, и которые могут существенно влиять на эффективность данной конкретной реализации алгоритма. Поэтому выбор того или иного алгоритма всегда остается за программистом.

В последующем изложении все описания алгоритмов даны для работы с таблицей, состоящей из записей R[1], R[2], ..., R[N] с ключами K[1], K[2], ..., K[N]. Во всех случаях N - количество элементов таблицы. Программные примеры для сокращения их объема работают с массивами целых чисел. Такой массив можно рассматривать как вырожденный случай таблицы, каждая запись которой состо- ит из единственного поля, которое является также и ключом. Во всех программных примерах следует считать уже определенными: константу N- целое положительное число, число элементов в массиве; константу EMPTY - целое число, признак "пусто" (EMPTY=-1); тип - type SEQ = array[1..N] of integer; сортируемые последовательности.


3.7.1. Последовательный или линейный поиск

Простейшим методом поиска элемента, находящегося в неупорядоченном наборе данных, по значению его ключа является последовательный просмотр каждого элемента набора, который продолжается до тех пор, пока не будет найден желаемый элемент. Если просмотрен весь набор, но элемент не найден - значит, искомый ключ отсутствует в наборе.

Для последовательного поиска в среднем требуется (N+1)/2 сравнений. Таким образом, порядок алгоритма - линейный - O(N).

Программная иллюстрация линейного поиска в неупорядоченном массиве приведена в следующем примере, где a - исходный массив, key - ключ, который ищется; функция возвращает индекс найденного элемента или EMPTY - если элементт отсутствует в массиве.

 {===== Программный пример 3.4 =====}
 Function LinSearch( a : SEQ; key : integer) : integer;
   var i : integer;
   for i:=1 to N do           { перебор эл-тов массива }
     if a[i]=key then begin   { ключ найден - возврат индекса }
       LinSearch:=i; Exit;   end;
   LinSearch:=EMPTY; {просмотрен весь массив, но ключ не найден }
 end;

3.7.2. Бинарный поиск

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

В рассматриваемом методе поиск отдельной записи с определенным значением ключа напоминает поиск фамилии в телефонном справочнике. Сначала приближенно определяется запись в середине таблицы и анализируется значение ее ключа. Если оно слишком велико, то анализируется значение ключа, соответствующего записи в середине первой половины таблицы, и указанная процедура повторяется в этой половине до тех пор, пока не будет найдена требуемая запись. Если значение ключа слишком мало, испытывается ключ, соответствующий записи в середине второй половины таблицы, и процедура повторяется в этой половине. Этот процесс продолжается до тех пор, пока не будет найден требуемый ключ или не станет пустым интервал, в котором осуществляется поиск.

Для того, чтобы найти нужную запись в таблице, в худшем случае требуется log2(N) сравнений. Это значительно лучше, чем при последовательном поиске.

Программная иллюстрация бинарного поиска в упорядоченном массиве приведена в следующем примере, где a - исходный массив, key - ключ, который ищется; функция возвращает индекс найденного элемента или EMPTY - если элементт отсутствует в массиве.

 {===== Программный пример 3.5 =====}
 Function BinSearch(a : SEQ; key : integer) : integer;
 Var b, e, i : integer;
 begin
    b:=1; e:=N;   { начальные значения границ }
    while b<=e do { цикл, пока интервал поиска не сузится до 0 }
    begin   i:=(b+e) div 2;     { середина интервала }
      if a[i]=key then
      begin BinSearch:=i; Exit; {ключ найден - возврат индекса }
      end         else
        if a[i] < key then b:=i+1  { поиск в правом подинтервале }
                    else e:=i-1; { поиск в левом подинтервале }
      end;    BinSearch:=EMPTY;  { ключ не найден }
 end; 

Трассировка бинарного поиска ключа 275 в исходной последовательности:

         75, 151, 203, 275, 318, 489, 524, 519, 647, 777 

представлена в таблице 3.4.

Интерация b e i K[i]
1 1 10 5 318
2 1 4 2 151
3 3 4 3 203
4 4 4 4 275

Таблица 3.4

Алгоритм бинарного поиска можно представить и несколько иначе, используя рекурсивное описание. В этом случае граничные индексы интервала b и e являются параметрами алгоритма.

Рекурсивная процедура бинарного поиска представлена в программном примере 3.6. Для выполнения поиска необходимо при вызове процедуры задать значения ее формальных параметров b и е - 1 и N соответственно, где b, e - граничные индексы области поиска.

 {===== Программный пример 3.6 =====}
 Function BinSearch( a: SEQ; key, b, e : integer) : integer;
 Var i : integer;
 begin
   if b > e then BinSearch:=EMPTY { проверка ширины  интервала }
   else begin
     i:=(b+e) div 2;               { середина интервала }
     if a[i]=key then BinSearch:=i {ключ найден, возврат индекса}
          else   if a[i] < key then { поиск в правом подинтервале }
         BinSearch:=BinSearch(a,key,i+1,e)
                             else { поиск в левом подинтервале }
         BinSearch:=BinSearch(a,key,b,i-1);
  end; end;  

Известно несколько модификаций алгоритма бинарного поиска, выполняемых на деревьях, которые будут рассмотрены в главе 5.


3.8. Операции логического уровня над статическими структурами. Сортировка

Для самого общего случая сформулируем задачу сортировки таким образом: имеется некоторое неупорядоченное входное множество ключей и должны получить выходное множество тех же ключей, упорядоченных по возрастанию или убыванию в численном или лексикографическом порядке.

Из всех задач программирования сортировка, возможно, имеет самый богатый выбор алгоритмов решения. Назовем некоторые факторы, которые влияют на выбор алгоритма (помимо порядка алгоритма).

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

2). Исходная упорядоченность входного множества: во входном множестве (даже если оно сгенерировано датчиком случайных величин) могут попадаться упорядоченные участки. В предельном случае входное множество может оказаться уже упорядоченным. Одни алгоритмы не учитывают исходной упорядоченности и требуют одного и того же времени для сортировки любого (в том числе и уже упорядоченного) множества данного объема, другие выполняются тем быстрее, чем лучше упорядоченность на входе.

3). Временные характеристики операций: при определении порядка алгоритма время выполнения считается обычно пропорциональным числу сравнений ключей. Ясно, однако, что сравнение числовых ключей выполняется быстрее, чем строковых, операции пересылки, характерные для некоторых алгоритмов, выполняются тем быстрее, чем меньше объем записи, и т.п. В зависимости от характеристик записи таблицы может быть выбран алгоритм, обеспечивающий минимизацию числа тех или иных операций.

4). Сложность алгоритма является не последним соображением при его выборе. Простой алгоритм требует меньшего времени для его реализации и вероятность ошибки в реализации его меньше. При промышленном изготовлении программного продукта требования соблюдения сроков разработки и надежности продукта могут даже превалировать над требованиями эффективности функционирования.

Разнообразие алгоритмов сортировки требует некоторой их классификации. Выбран один из применяемых для классификации подходов, ориентированный прежде всего на логические характеристики применяемых алгоритмов. Согласно этому подходу любой алгоритм сортировки использует одну из следующих четырех стратегий (или их комбинацию).

1). Стратегия выборки. Из входного множества выбирается следующий по критерию упорядоченности элемент и включается в выходное множество на место, следующее по номеру.

2). Стратегия включения. Из входного множества выбирается следующий по номеру элемент и включается в выходное множество на то место, которое он должен занимать в соответствии с критерием упорядоченности.

3). Стратегия распределения. Входное множество разбивается на ряд подмножеств (возможно, меньшего объема) и сортировка ведется внутри каждого такого подмножества.

4). Стратегия слияния. Выходное множество получается путем слияния маленьких упорядоченных подмножеств.

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


3.8.1. Сортировки выборкой

Сортировка простой выборкой.

Данный метод реализует практически "дословно" сформулированную выше стратегию выборки. Порядок алгоритма простой выборки - O(N^2). Количество пересылок - N.

Алгоритм сортировки простой выборкой иллюстрируется программным примером 3.7.

В программной реализации алгоритма возникает проблема значения ключа "пусто". Довольно часто программисты используют в качестве такового некоторое заведомо отсутствующее во входной последовательности значение ключа, например, максимальное из теоретически возможных значений. Другой, более строгий подход - создание отдельного вектора, каждый элемент которого имеет логический тип и отражает состояние соответствующего элемента входного множества ("истина" - "непусто", "ложь" - "пусто"). Именно такой подход реализован в нашем программном примере. Роль входной последовательности здесь выполняет параметр a, роль выходной - параметр b, роль вектора состояний - массив c. Алгоритм несколько усложняется за счет того, что для установки начального значения при поиске минимума приходится отбрасывать уже "пустые" элементы.

 {===== Программный пример 3.7 =====}
 Procedure Sort( a : SEQ; var b : SEQ);
 Var  i, j, m : integer;
      c: array[1..N] of boolean; {состояние эл-тов вх.множества}
 begin
   for i:=1 to N do c[i]:=true;  { сброс отметок}
   for i:=1 to N do {поиск 1-го невыбранного эл. во вх.множестве}
    begin j:=1;
          while not c[j] do j:=j+1;
          m:=j;      { поиск минимального элемент а}
     for j:=2 to N do
       if c[j] and (a[j] < a[m]) then m:=j;
     b[i]:=a[m]; { запись в выходное множество}
     c[m]:=false; { во входное множество - "пусто" }
 end; end;

Обменная сортировка простой выборкой.

Алгоритм сортировки простой выборкой, однако, редко применяется в том варианте, в каком он описан выше. Гораздо чаще применяется его, так называемый, обменный вариант. При обменной сортировке выборкой входное и выходное множество располагаются в одной и той же области памяти; выходное - в начале области, входное - в оставшейся ее части. В исходном состоянии входное множество занимает всю область, а выходное множество - пустое. По мере выполнения сортировки входное множество сужается, а выходное - расширяется.

Обменная сортировка простой выборкой показана в программном примере 3.8. Процедура имеет только один параметр - сортируемый массив.

 {===== Программный пример 3.8 =====}
 Procedure Sort(var a : SEQ);
 Var  x, i, j, m : integer;
 begin
   for i:=1 to N-1 do    { перебор элементов выходного множества}
   { входное множество - [i:N]; выходное - [1:i-1] }
     begin  m:=i;
      for j:=i+1 to N do  { поиск минимума во входном множестве }
        if (a[j] < a[m]) then m:=j;
      { обмен 1-го элемента вх. множества с минимальным }
      if i<>m then begin
        x:=a[i]; a[i]:=a[m]; a[m]:=x;
  end;end; end; 

Результаты трассировки программного примера 3.8 представлены в таблице 3.5. Двоеточием показана граница между входным и выходным множествами.

Шаг Содержимое массива а
Исходный :242 447 286 708_24_11 192 860 937 561
1 _11:447 286 708_ 24 242 192 860 937 561
2 _11_24:286 708 447 242 192 860 937 561
3 _11_24 192:708 447 242 286 860 937 561
4 _11_24 192 242:447 708 286 860 937 561
5 _11_24 192 242 286:708 447 860 937 561
6 _11_24 192 242 286 447:708 860 937 561
7 _11_24 192 242 286 447 561:860 937 708
8 _11_24 192 242 286 447 561 708:937 860
9 _11_24 192 242 286 447 561 708 860:937
Результат _11_24 192 242 286 447 561 708 860 937:

Таблица 3.5

Очевидно, что обменный вариант обеспечивает экономию памяти. Очевидно также, что здесь не возникает проблемы "пустого" значения. Общее число сравнений уменьшается вдвое - N*(N-1)/2, но порядок алгоритма остается степенным - O(n^2). Количество перестановок N-1, но перестановка, по-видимому, вдвое более времяемкая операция, чем пересылка в предыдущем алгоритме.

Довольно простая модификация обменной сортировки выборкой предусматривает поиск в одном цикле просмотра входного множества сразу и минимума, и максимума и обмен их с первым и с последним элементами множества соответственно. Хотя итоговое количество сравнений и пересылок в этой модификации не уменьшается, достигается экономия на количестве итераций внешнего цикла.

Приведенные выше алгоритмы сортировки выборкой практически нечувствительны к исходной упорядоченности. В любом случае поиск минимума требует полного просмотра входного множества. В обменном варианте исходная упорядоченность может дать некоторую экономию на перестановках для случаев, когда минимальный элемент найден на первом месте во входном множестве.

Пузырьковая сортировка.

Входное множество просматривается, при этом попарно сравниваются соседние элементы множества. Если порядок их следования не соответствует заданному критерию упорядоченности, то элементы меняются местами. В результате одного та- кого просмотра при сортировке по возрастанию элемент с самым большим значением ключа переместится ("всплывет") на последнее место в множестве. При следующем проходе на свое место "всплывет" второй по величине ключа элемент и т.д. Для постановки на свои места N элементов следует сделать N-1 проходов. Выходное множество, таким образом, формируется в конце сортируемой последовательности, при каждом следующем проходе его объем увеличивается на 1, а объем входного множества уменьшается на 1.

Порядок пузырьковой сортировки - O(N^2). Среднее число сравнений - N*(N-1)/2 и таково же среднее число перестановок, что значительно хуже, чем для обменной сортировки простым выбором. Однако, то обстоятельство, что здесь всегда сравниваются и перемещаются только соседние элементы, делает пузырьковую сортировку удобной для обработки связных списков. Перестановка в связных списках также получается более экономной.

Еще одно достоинство пузырьковой сортировки заключается в том, что при незначительных модификациях ее можно сделать чувствительной к исходной упорядоченности входного множества. Рассмотрим некоторые их таких модификаций.

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

Во-вторых, может быть учтено то обстоятельство, что за один просмотр входного множества на свое место могут "всплыть" не один, а два и более элементов. Это легко учесть, запоминая в каждом просмотре позицию последней перестановки и установки этой позиции в качестве границы между множествами для следующего просмотра. Именно эта модификация реализована в программной иллюстрации пузырьковой сортировке в примере 3.9. Переменная nn в каждом проходе устанавливает верхнюю границу входного множества. В переменной x запоминается позиция перестановок и в конце просмотра последнее запомненное значение вносится в nn. Сортировка будет закончена, когда верхняя граница входного множества станет равной 1.

 {===== Программный пример 3.9 =====}
 Procedure Sort( var a : seq);
 Var nn, i, x : integer;
 begin
   nn:=N; { граница входного множества }
   repeat x:=1; { признак перестановок }
     for i:=2 to nn do { перебор входного множества }
     if a[i-1] > a[i] then begin { сравнение соседних эл-в }
       x:=a[i-1]; a[i-1]:=a[i]; a[i]:=x; { перестановка }
       x:=i-1; { запоминание позиции  }
     end;  nn:=x;   { сдвиг границы }
   until (nn=1); {цикл пока вых. множество не захватит весь мас.}
 end;

Результаты трассировки программного примера 3.9 представлены в таблице 3.6.

Шаг nn Содержимое массива а
Исходный 10 717 473 313 160 949 764_34 467 757 800:
1 9 473 313 160 717 764_34 467 757 800:949
2 7 313 160 473 717_34 467 757:764 800 949
3 5 160 313 473_34 467:717 757 764 800 949
4 4 160 313_34 467:473 717 757 764 800 949
5 2 160_34:313 467 473 717 757 764 800 949
6 1 _34:160 313 467 473 717 757 764 800 949
Результат : 34 160 313 467 473 717 757 764 800 949

Таблица 3.6

Еще одна модификация пузырьковой сортировки носит название шейкер-сортировки. Суть ее состоит в том, что направления просмотров чередуются: за просмотром от начала к концу следует просмотр от конца к началу входного множества. При просмотре в прямом направлении запись с самым большим ключом ставится на свое место в последовательности, при просмотре в обратном направлении - запись с самым маленьким. Этот алгоритм весьма эффективен для задач восстановления упорядоченности, когда исходная последовательность уже была упорядочена, но подверглась не очень значительным изменениям. Упорядоченность в последовательности с одиночным изменением будет гарантированно восстановлена всего за два прохода.

Сортировка Шелла.

Это еще одна модификация пузырьковой сортировки. Суть ее состоит в том, что здесь выполняется сравнение ключей, отстоящих один от другого на некотором расстоянии d. Исходный размер d обычно выбирается соизмеримым с половиной общего размера сортируемой последовательности. Выполняется пузырьковая сортировка с интервалом сравнения d. Затем величина d уменьшается вдвое и вновь выполняется пузырьковая сортировка, далее d уменьшается еще вдвое и т.д. Последняя пузырьковая сортировка выполняется при d=1. Качественный порядок сортировки Шелла остается O(N^2), среднее же число сравнений, определенное эмпирическим путем - log2(N)^2*N. Ускорение достигается за счет того, что выяв- ленные "не на месте" элементы при d>1, быстрее "всплывают" на свои места.

Пример 3.10 иллюстрирует сортировку Шелла.

 {===== Программный пример 3.10 =====}
 Procedure Sort( var a : seq);
 Var d, i, t : integer;  k : boolean; { признак перестановки }
 begin   d:=N div 2;     { начальное значение интервала }
   while d > 0 do          { цикл с уменьшением интервала до 1 }
     begin  k:=true;     {пузырьковая сортировка с интервалом d}
     while k do          { цикл, пока есть перестановки }
       begin  k:=false; i:=1;
       for i:=1 to N-d do { сравнение эл-тов на интервале d }
          begin  if a[i] > a[i+d] then begin
           t:=a[i]; a[i]:=a[i+d]; a[i+d]:=t; { перестановка }
           k:=true;  { признак перестановки }
           end; { if ... }  end; { for ... }  end; { while k }
     d:=d div 2;  { уменьшение интервала }
     end;  { while d>0 }  end;

Результаты трассировки программного примера 3.10 представлены в таблице 3.7.

Шаг d Содержимое массива а
Исходный 76 22_ 4 17 13 49_ 4 18 32 40 96 57 77 20_ 1 52
1 8 32 22_ 4 17 13 20_ 1 18 76 40 96 57 77 49_ 4 52
2 8 32 22_ 4 17 13 20_ 1 18 76 40 96 57 77 49_ 4 52
3 4 13 20_ 1 17 32 22_ 4 18 76 40_ 4 52 77 49 96 57
4 4 13 20_ 1 17 32 22_ 4 18 76 40_ 4 52 77 49 96 57
5 2 13 20_ 1 17 32 22_ 4 18 76 40_ 4 52 77 49 96 57
6 2 13 20_ 1 17 32 22_ 4 18 76 40_ 4 52 77 49 96 57
7 2 _1 17_ 4 18_ 4 20 13 22 32 40 76 49 77 52 96 57
8 2 _1 17_ 4 18_ 4 20 13 22 32 40 76 49 77 52 96 57
9 1 _1_ 4 17_ 4 18 13 20 22 32 40 49 76 52 77 57 96
10 1 _1_ 4_ 4 17 13 18 20 22 32 40 49 52 76 57 77 96
11 1 _1_ 4_ 4 13 17 18 20 22 32 40 49 52 57 76 77 96
12 1 _1_ 4_ 4 13 17 18 20 22 32 40 49 52 57 76 77 96
Результат _1_ 4_ 4 13 17 18 20 22 32 40 49 52 57 76 77 96

Таблица 3.7


3.8.2. Сортировки включением

Сортировка простыми вставками.

Этот метод - "дословная" реализации стратегии включения. Порядок алгоритма сортировки простыми вставками - O(N^2), если учитывать только операции сравнения. Но сортировка требует еще и в среднем N^2/4 перемещений, что де- лает ее в таком варианте значительно менее эффективной, чем сортировка выборкой.

Алгоритм сортировки простыми вставками иллюстрируется программным примером 3.11.

 {===== Программный пример 3.11 =====}
 Procedure Sort(a : Seq; var b : Seq);
   Var i, j, k : integer;
   begin
     for i:=1 to N do begin  { перебор входного массива }
       { поиск места для a[i] в выходном массиве }
       j:=1; while (j < i) and (b[j] <= a[i]) do j:=j+1;
       { освобождение места для нового эл-та }
       for k:=i downto j+1 do b[k]:=b[k-1];
       b[j]:=a[i];        { запись в выходной массив }
       end; end;

Эффективность алгоритма может быть несколько улучшена при применении не линейного, а дихотомического поиска. Однако, следует иметь в виду, что такое увеличение эффективности может быть достигнуто лишь на множествах значительного по количеству элементов объема. Т.к. алгоритм требует большого числа пересылок, при значительном объеме одной записи эффективность может определяться не количеством операций сравнения, а количеством пересылок.

Реализация алгоритма обменной сортировки простыми вставками отличается от базового алгоритма только тем, что входное и выходное множество разделяют одну область памяти.

Пузырьковая сортировка вставками.

Это модификация обменного варианта сортировки. В этом методе входное и выходное множества находятся в одной последовательности, причем выходное - в начальной ее части. В исходном состоянии можно считать, что первый элемент последовательности уже принадлежит упорядоченному выходному множеству, остальная часть последовательности - неупорядоченное входное. Первый элемент входного множества примыкает к концу выходного множества. На каждом шаге сортировки происходит перераспределение последовательности: выходное множество увеличивается на один элемент, а входное - уменьшается. Это происходит за счет того, что первый элемент входного множества теперь считается последним элементом выходного. Затем выполняется просмотр выходного множества от конца к началу с перестановкой соседних элементов, которые не соответствуют критерию упорядоченности. Просмотр прекращается, когда прекращаются перестановки. Это приводит к тому, что последний элемент выходного множества "выплывает" на свое место в множестве. Поскольку при этом перестановка приводит к сдвигу нового в выходном множестве элемента на одну позицию влево, нет смысла всякий раз производить полный обмен между соседними элементами - достаточно сдвигать старый элемент вправо, а новый элемент записать в выходное множество, когда его место будет установлено. Именно так и построен программный пример пузырьковой сортировки вставками - 3.12.

 {===== Программный пример 3.12 =====}
 Procedure Sort (var a : Seq);
 Var i, j, k, t : integer;
 begin
   for i:=2 to N do begin  { перебор входного массива }
   {*** вх.множество - [i..N], вых.множество - [1..i] }
     t:=a[i]; { запоминается значение нового эл-та }
     j:=i-1; {поиск места для эл-та в вых. множестве со сдвигом}
       { цикл закончится при достижении начала или, когда
         будет встречен эл-т, меньший нового }
       while (j >= 1) and (a[j] > t) do begin
         a[j+1]:=a[j]; { все эл-ты, большие нового сдвигаются }
         j:=j-1; { цикл от конца к началу выходного множества }
         end;
       a[j+1]:=t; { новый эл-т ставится на свое место }
       end;
 end; 

Результаты трассировки программного примера 3.11 представлены в таблице 3.8.

Шаг Содержимое массива a
Исходный 48:43 90 39_ 9 56 40 41 75 72
1 43 48:90 39_ 9 56 40 41 75 72
2 43 48 90:39_ 9 56 40 41 75 72
3 39 43 48 90:_9 56 40 41 75 72
4 _9 39 43 48 90:56 40 41 75 72
5 _9 39 43 48 56 90:40 41 75 72
6 _9 39 40 43 48 56 90:41 75 72
7 _9 39 40 41 43 48 56 90:75 72
8 _9 39 40 41 43 48 56 75 90:72
Результат _9 39 40 41 43 48 56 72 75 90:

Таблица 3.8

Хотя обменные алгоритмы стратегии включения и позволяют сократить число сравнений при наличии некоторой исходной упорядоченности входного множества, значительное число пересылок существенно снижает эффективность этих алгоритмов. Поэтому алгоритмы включения целесообразно применять к связным структурам данных, когда операция перестановки элементов структуры требует не пересылки данных в памяти, а выполняется способом коррекции указателей (см. главу 5).

Еще одна группа включающих алгоритмов сортировки использует структуру дерева. Мы рекомендуем читателю повторно вернуться к рассмотрению этих алгоритмов после ознакомления с главой 6.

Сортировка упорядоченным двоичным деревом.

Алгоритм складывается из построения упорядоченного двоичного дерева и последующего его обхода. Если нет необходимости в построении всего линейного упорядоченного списка значений, то нет необходимости и в обходе дерева, в этом случае применяется поиск в упорядоченном двоичном дереве. Алгоритмы работы с упорядоченными двоичными деревьями подробно рассмотрены в главе 6. Отметим, что порядок алгоритма - O(N*log2(N)), но в конкретных случаях все зависит от упорядоченности исходной последовательности, который влияет на степень сбалансированности дерева и в конечном счете - на эффективность поиска.

Турнирная сортировка.

Этот метод сортировки получил свое название из-за сходства с кубковой системой проведения спортивных соревнований: участники соревнований разбиваются на пары, в которых разыгрывается первый тур; из победителей первого тура составляются пары для розыгрыша второго тура и т.д. Алгоритм сортировки состоит из двух этапов. На первом этапе строится дерево: аналогичное схеме розыгрыша кубка.

Например, для последовательности чисел a:

               16 21 8 14 26 94 30 1

такое дерево будет иметь вид пирамиды, показанной на рис.3.13.

Рис.3.13. Пирамида турнирной сортировки

В примере 3.12 приведена программная иллюстрация алгоритма турнирной сортировки. Она нуждается в некоторых пояснениях. Построение пирамиды выполняется функцией Create_Heap. Пирамида строится от основания к вершине. Элементы, составляющие каждый уровень, связываются в линейный список, поэтому каждый узел дерева помимо обычных указателей на потомков - left и right - содержит и указатель на "брата" - next. При работе с каждым уровнем указатель содержит начальный адрес списка элементов в данном уровне. В первой фазе строится линейный список для нижнего уровня пирамиды, в элементы которого заносятся ключи из исходной последовательности. Следующий цикл while в каждой своей итерации надстраивает следующий уровень пирамиды. Условием завершения этого цикла является получение списка, состоящего из единственного элемента, то есть, вершины пирамиды. Построение очередного уровня состоит в попарном переборе элементов списка, составляющего предыдущий (нижний) уровень. В новый уровень переносится наименьшее значение ключа из каждой пары.

Следующий этап состоит в выборке значений из пирамиды и формирования из них упорядоченной последовательности (процедура Heap_Sort и функция Competit). В каждой итерации цикла процедуры Heap_Sort выбирается значение из вершины пирамиды - это наименьшее из имеющихся в пирамиде значений ключа. Узел-вершина при этом освобождается, освобождаются также и все узлы, занимаемые выбранным значением на более низких уровнях пирамиды. За освободившиеся узлы устраивается (снизу вверх) состязание между их потомками. Так, для пирамиды, исходное состояние которой было показано на рис 3. , при выборке первых трех ключей (1, 8, 14) пирамида будет последовательно принимать вид, показанный на рис.3.14 (символом x помечены пустые места в пирамиде).

Рис.3.14. Пирамида после последовательных выборок

Процедура Heap_Sort получает входной параметр ph - указатель на вершину пирамиды. и формирует выходной параметр a - упорядоченный массив чисел. Вся процедура Heap_Sort состоит из цикла, в каждой итерации которого значение из вершины переносится в массив a, а затем вызывается функция Competit, которая обеспечивает реорганизацию пирамиды в связи с удалением значения из вершины.

Функция Competet рекурсивная, ее параметром является указатель на вершину того поддерева, которое подлежит реорганизации. В первой фазе функции устанавливается, есть ли у узла, составляющего вершину заданного поддерева, потомок, значение данных в котором совпадает со значением данных в вершине. Если такой потомок есть, то функция Competit вызывает сама себя для реорганизации того поддерева, вершиной которого является обнаруженный потомок. После реорганизации адрес потомка в узле заменяется тем адресом, который вернул рекурсивный вызов Competit. Если после реорганизации оказывается, что у узла нет потомков (или он не имел потомков с самого начала), то узел уничтожается и функция возвращает пустой указатель. Если же у узла еще остаются потомки, то в поле данных узла заносится значение данных из того потомка, в котором это значение наименьшее, и функция возвращает прежний адрес узла.

 {===== Программный пример 3.12 =====}
 { Турнирная сортировка }
 type nptr = ^node;     { указатель на узел }
     node = record     { узел дерева }
       key : integer;     { данные }
       left, right : nptr; { указатели на потомков }
       next : nptr;        { указатель на "брата" }
       end;
 { Создание дерева - функция возвращает указатель на вершину
                     созданного дерева }
 Function Heap_Create(a : Seq) : nptr;
   var i : integer;
       ph2 : nptr;       { адрес начала списка уровня }
       p1 : nptr;        { адрес нового элемента }
       p2 : nptr;        { адрес предыдущего элемента }
       pp1, pp2 : nptr;  { адреса соревнующейся пары }
   begin
   { Фаза 1 - построение самого нижнего уровня пирамиды }
   ph2:=nil;
   for i:=1 to n do begin
     New(p1);        { выделение памяти для нового эл-та }
     p1^.key:=a[i]; { запись данных из массива }
     p1^.left:=nil; p1^.right:=nil;   { потомков нет }
     { связывание в линейный список по уровню }
     if ph2=nil then ph2:=p1 else p2^.next:=p1;
     p2:=p1;
     end; { for }
   p1^.next:=nil;
   { Фаза 2 - построение других уровней }
   while ph2^.next<>nil do begin  { цикл до вершины пирамиды }
     pp1:=ph2; ph2:=nil;          { начало нового уровня }
     while pp1<>nil do begin      { цикл по очередному уровню }
       pp2:=pp1^.next;
       New(p1);
       { адреса потомков из предыдущего уровня }
       p1^.left:=pp1; p1^.right:=pp2;
       p1^.next:=nil;
       { связывание в линейный список по уровню }
       if ph2=nil then ph2:=p1 else p2^.next:=p1;
       p2:=p1;
       { состязание данных за выход на уровень }
       if (pp2=nil)or(pp2^.key > pp1^.key) then p1^.key:=pp1^.key
       else p1^.key:=pp2^.key;
       { переход к следующей паре }
       if pp2<>nil then pp1:=pp2^.next else pp1:=nil;
       end; { while pp1<>nil }
     end; { while ph2^.next<>nil }
   Heap_Create:=ph2;
 end;
   { Реорганизация поддерева - функция возвращает 
     указатель на вершину реорганизованного дерева }
 Function Competit(ph : nptr) : nptr;
   begin
   { определение наличия потомков, выбор потомка для
     реорганизации, реорганизация его }
   if (ph^.left<>nil)and(ph^.left^.key=ph^.key) then
     ph^.left:=Competit(ph^.left)
   else if (ph^.right<>nil) then
     ph^.right:=Competit(ph^.right);
   if (ph^.left=nil)and(ph^.right=nil) then begin
     { освобождение пустого узла }
     Dispose(ph); ph:=nil;
     end;
   else
     { состязание данных потомков }
     if (ph^.left=nil) or
     ((ph^.right<>nil)and(ph^.left^.key > ph^.right^.key)) then
        ph^.key:=ph^.right^.key
      else ph^.key:=ph^.left^.key;
   Competit:=ph;
 end;
 { Сортировка }
 Procedure Heap_Sort(var a : Seq);
   var ph : nptr;   { адрес вершины дерева }
       i : integer;
   begin
   ph:=Heap_Create(a);  { создание дерева }
   { выборка из дерева }
   for i:=1 to N do begin
     { перенос данных из вершины в массив }
     a[i]:=ph^.key;
     { реорганизация дерева }
     ph:=Competit(ph);
     end;
 end;

Построение дерева требует N-1 сравнений, выборка - N*log2(N) сравнений. Порядок алгоритма, таким образом, O(N*log2(N)). Сложность операций над связными структурами данных, однако, значительно выше, чем над статическими структурами. Кроме того, алгоритм неэкономичен в отношении памяти: дублирование данных на разных уровнях пирамиды приводит к тому, что рабочая область памяти содержит примерно 2*N узлов.

Сортировка частично упорядоченным деревом.

В двоичном дереве, которое строится в этом методе сортировки для каждого узла справедливо следующее утверждение: значения ключа, записанное в узле, меньше, чем ключи его потомков. Для полностью упорядоченного дерева имеются требования к соотношению между ключами потомков. Для данного дерева таких требований нет, поэтому такое дерево и называется частично упорядоченным. Кроме того, наше дерево должно быть абсолютно сбалансированным. Это означает не только то, что длины путей к любым двум листьям различаются не более, чем на 1, но и то, что при добавлении нового элемента в дерево предпочтение всегда отдается левой ветви/подветви, пока это не нарушает сбалансированность. Более подробно деревья рассматриваются в гл.6.

Например, последовательность чисел:

              3 20 12 58 35 30 32 28

будет представлена в виде дерева, показанного на рис.3.15 .

Рис.3.15. Частично упорядоченное дерево

Представление дерева в виде пирамиды наглядно показывает нам, что для такого дерева можно ввести понятия "начала" и "конца". Началом, естественно, будет считаться вершина пирамиды, а концом - крайний левый элемент в самом нижнем ряду (на рис.3.15 это 58).

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

Алгоритм вставки состоит в следующем. Новый элемент вставляется на первое свободное место за концом дерева (на рис.3.15 это место обозначено символом "*"). Если ключ вставленного элемента меньше, чем ключ его предка, то предок и вставленный элемент меняются местами. Ключ вставленного элемента теперь сравнивается с ключом его предка на новом месте и т.д. Сравнения заканчиваются, когда ключ нового элемента окажется больше ключа предка или когда новый элемент "выплывет" в вершину пирамиды. Пирамида, показанная на рис.3.15, построена именно последовательным включением в нее чисел из приведенного ряда. Если мы включим в нее, например, еще число 16, то пирамида примет вид, представленный на рис.3.16. (Символом "*" помечены элементы, перемещенные при этой операции.)

Рис.3.16. Частично упорядоченное дерево, включение элемента

Процедура выборки элемента несколько сложнее. Очевидно, что минимальный элемент находится в вершине. После выборки за освободившееся место устраивается состязание между потомками, и в вершину перемещается потомок с наименьшим значением ключа. За освободившееся место перемешенного потомка состязаются его потомки и т.д., пока свободное место не опустится до листа пирамиды. Состояние нашего дерева после выборки из него минимального числа (3) показано на рис.3.17.а.

Рис.3.17. Частично упорядоченное дерево, исключение элемента

Упорядоченность дерева восстановлена, но нарушено условие его сбалансированности, так как свободное место находится не в конце дерева. Для восстановления сбалансированности последний элемент дерева переносится на освободившееся место, а затем "всплывает" по тому же алгоритму, который применялся при вставке. Результат такой балансировки показан на рис.3.17.б.

Прежде, чем описывать программный пример, иллюстрирующий сортировку частично упорядоченным деревом - пример 3.13, обратим внимание читателей на способ, которым в нем дерево представлено в памяти. Это способ представления двоичных деревьев в статической памяти (в одномерном массиве), который может быть применен и в других задачах. Элементы дерева располагаются в соседних слотах памяти по уровням. Самый первый слот выделенной памяти занимает вершина. Следующие 2 слота - элементы второго уровня, следующие 4 слота - третьего и т.д. Дерево с рис.3.17.б, например, будет линеаризовано таким образом:

               12 16 28 20 35 30 32 58

В таком представлении отпадает необходимость хранить в составе узла дерева указатели, так как адреса потомков могут быть вычислены. Для узла, представленного элементом массива с индексом i индексы его левого и правого потомков будут 2*i и 2*i+1 соответственно. Для узла с индексом i индекс его предка будет i div 2.

После всего вышесказанного алгоритм программного примера 3.13 не нуждается в особых пояснениях. Поясним только структуру примера. Пример оформлен в виде законченного программного модуля, который будет использован и в следующем примере. Само дерево представлено в массиве tree, переменная nt является индексом первого свободного элемента в массиве. Входные точки модуля:

Кроме того в модуле определены внутренние программные единицы:

 {===== Программный пример 3.13 =====}
 { Сортировка частично упорядоченным деревом }
 Unit SortTree;
 Interface
 Procedure InitSt;
 Function CheckST(var a : integer) : integer;
 Function DeleteST(var a : integer) : boolean;
 Function InsertST(a : integer) : boolean;
 Implementation
 Const NN=16;
 var tr : array[1..NN] of integer;  { дерево }
     nt : integer;     { индекс последнего эл-та в дереве }
 {** Всплытие эл-та с места с индексом l **}
 Procedure Up(l : integer);
   var h : integer; { l - индекс узла, h - индекс его предка }
       x : integer;
   begin
   h:=l div 2;   { индекс предка }
   while h > 0 do  { до начала  дерева }
     if tr[l] < tr[h] then begin { ключ узла меньше, чем у предка }
      x:=tr[l]; tr[l]:=tr[h]; tr[h]:=x; { перестановка }
      l:=h; h:=l div 2; { предок становится текущим узлом }
      end
    else h:=0; { конец всплытия }
 end;  { Procedure Up }
 {** Спуск свободного места из начала дерева **}
 Function Down : integer;
   var h, l : integer; { h - индекс узла, l - индекс его потомка }
   begin
   h:=1;    { начальный узел - начало дерева }
   while true do begin
     l:=h*2;  { вычисление индекса 1-го потомка }
     if l+1 <= nt then begin { у узла есть 2-й потомок }
       if tr[l] <= tr[l+1] then begin { 1-й потомок меньше 2-го }
         tr[h]:=tr[l]; { 1-й потомок переносится в текущ.узел }
         h:=l;  { 1-й потомок становится текущим узлом }
         end
       else begin  { 2-й потомок меньше 1-го }
         tr[h]:=tr[l+1]; { 2-й потомок переносится в текущ.узел }
         h:=l+1; { 2-й потомок становится текущим узлом }
         end;
       end
     else
       if l=nt then begin { 1-й потомок есть, 2-го нет }
         tr[h]:=tr[l]; { 1-й потомок переносится в текущ.узел }
         Down:=l;  Exit; { спуск закончен }
         end
       else begin { потомков нет - спуск закончен }
         Down:=h; Exit;
         end;
     end; { while }
 end; { Function Down }
 {** Инициализация сортировки деревом **}
 Procedure InitSt;
   begin
   nt:=0; { дерево пустое }
   end; { Procedure InitSt }
 {** Проверка состояния дерева **}
 Function CheckST(var a : integer) : integer;
   begin
   a:=tr[1];   { выборка эл-та из начала }
   case nt of  { формирование возвращаемого значения функции }
     0: { дерево пустое } CheckSt:=0;
     NN: { дерево полное } CheckSt:=2;
     else { дерево частично заполнено } CheckSt:=1;
     end;
   end; {  Function CheckST }
 {** Вставка эл-та a в дерево **}
 Function InsertST(a : integer) : boolean;
   begin
   if nt=NN then { дерево заполнено - отказ } InsertST:=false
   else begin { в дереве есть место }
     nt:=nt+1; tr[nt]:=a; { запись в конец дерева }
     Up(nt);              { всплытие }
     InsertSt:=true;
     end;
   end; { Function InsertST }
 {** Выборка эл-та из дерева **}
 Function DeleteST(var a : integer) : boolean;
   var n : integer;
   begin
   if nt=0 then { дерево пустое - отказ } DeleteST:=false
   else begin { дерево не пустое }
     a:=tr[1];  { выборка эл-та из начала }
     n:=Down;   { спуск свободного места в позицию n }
     if n < nt then begin
       { если свободное место спустилось не в конец дерева }
       tr[n]:=tr[nt]; { эл-т из конца переносится на своб.место }
       Up(n);         { всплытие }
       end;
     nt:=nt-1;
     DeleteSt:=true;
   end;
 end; { Function DeleteST }
 END.

Если применять сортировку частично упорядоченным деревом для упорядочения уже готовой последовательности размером N, то необходимо N раз выполнить вставку, а затем N раз - выборку. Порядок алгоритма - O(N*log2(N)), но среднее значение количества сравнений примерно в 3 раза больше, чем для турнирной сортировки. Но сортировка частично упорядоченным деревом имеет одно существенное преимущество перед всеми другими алгоритмами. Дело в том, что это самый удобный алгоритм для "сортировки on-line", когда сортируемая последовательность не зафиксирована до начала сортировки, а меняется в процессе работы и вставки чередуются с выборками. Каждое изменение (добавление/удаление элемента) сортируемой последовательности потребует здесь не более, чем 2*log2(N) сравнений и перестановок, в то время, как другие алгоритмы потребуют при единичном изменении переупорядочивания всей последовательности "по полной программе".

Типичная задача, которая требует такой сортировки, возникает при сортировке данных на внешней памяти (файлов). Первым этапом такой сортировки является формирование из данных файла упорядоченных последовательностей максимально возможной длины при ограниченном объеме оперативной памяти. Приведенный ниже программный пример (пример 3.14) показывает решение этой задачи.

Последовательность чисел, записанная во входном файле поэлементно считывается и числа по мере считывания включаются в дерево. Когда дерево оказывается заполненным, очередное считанное из файла число сравнивается с последним числом, выведенным в выходной файл. Если считанное число не меньше последнего выведенного, но меньше числа, находящегося в вершине дерева, то в выходной файл выводится считанное число. Если считанное число не меньше последнего выведенного, и не меньше числа, находящегося в вершине дерева, то в выходной файл выводится число, выбираемое из дерево, а считанное число заносится в дерево. Наконец, если считанное число меньше последнего выведенного, то поэлементно выбирается и выводится все содержимое дерева, и формирование новой последовательности начинается с записи в пустое дерево считанного числа.

 {===== Программный пример 3.14 =====}
 { Формирование отсортированных последовательностей в файле }
 Uses SortTree;
 var x : integar;   { считанное число }
     y : integer;   { число в вершине дерева }
     old : integer; { последнее выведенное число }
     inp : text;    { входной файл }
     out : text;    { выходной файл }
     bf : boolean;  { признак начала вывода последовательности }
     bx : boolean;  { рабочая переменная }
 begin
 Assign(inp,'STX.INP'); Reset(inp);
 Assign(out,'STX.OUT'); Rewrite(out);
 InitST;      { инициализация сортировки }
 bf:=false;   { вывод последовательности еще не начат }
 while not Eof(inp) do begin
   ReadLn(inp,x);  { считывание числа из файла }
   { если в дереве есть свободное место - включить в дерево }
   if CheckST(y) <= 1 then bx:=InsertST(x)
   else { в дереве нет свободного места }
     if (bf and (x < old)) or (not bf and (x < y))  then begin
       { вывод содержимого дерева }
       while DeleteST(y) do Write(out,y:3,' ');
       WriteLn(out);
       bf:=false;       { начало новой последовательности }
       bx:=InsertST(x); { занесение считанного числа в дерево }
       end
     else begin { продолжение формирования последовательности }
       if x < y then begin { вывод считанного числа }
         Write(out,x:3,' '); old:=x;
         end;
       else begin { вывод числа из вершины дерева }
         bx:=DeleteST(y);
         Write(out,y:3,' '); old:=y;
         { занесение считанного в дерево }
         bx:=InsertST(x);
         end;
       bf:=true; { вывод последовательности начался }
       end;
     end;
   Close(inp);
   { вывод остатка }
   while DeleteST(y) do Write(out,y:3,' ');
   WriteLn(out);  Close(out);
   end.

3.8.3. Сортировки распределением.

Поразрядная цифровая сортировка.

Алгоритм требует представления ключей сортируемой последовательности в виде чисел в некоторой системе счисления P. Число проходов сортировка равно максимальному числу значащих цифр в числе - D. В каждом проходе анализируется значащая цифра в очередном разряде ключа, начиная с младшего разряда. Все ключи с одинаковым значением этой цифры объединяются в одну группу. Ключи в группе располагаются в порядке их поступления. После того, как вся исходная последовательность распределена по группам, группы располагаются в порядке возрастания связанных с группами цифр. Процесс повторяется для второй цифры и т.д., пока не будут исчерпаны значащие цифры в ключе. Основание системы счисления P может быть любым, в частном случае 2 или 10. Для системы счисления с основанием P требуется P групп.

Порядок алгоритма качественно линейный - O(N), для сортировки требуется D*N операций анализа цифры. Однако, в такой оценке порядка не учитывается ряд обстоятельств.

Во-первых, операция выделения значащей цифры будет простой и быстрой только при P=2, для других систем счисления эта операция может оказаться значительно более времяемкой, чем операция сравнения.

Во-вторых, в оценке алгоритма не учитываются расходы времени и памяти на создание и ведение групп. Размещение групп в статической рабочей памяти потребует памяти для P*N элементов, так как в предельном случае все элементы могут попасть в какую-то одну группу. Если же формировать группы внутри той же последовательности по принципу обменных алгоритмов, то возникает необходимость перераспределения последовательности между группами и все проблемы и недостатки, присущие алгоритмам включения. Наиболее рациональным является формирование групп в виде связных списков с динамическим выделением памяти.

В программном примере 3.15 мы, однако, применяем поразрядную сортировку к статической структуре данных и формируем группы на том же месте, где расположена исходная последовательность. Пример требует некоторых пояснений.

Область памяти, занимаемая массивом перераспределяется между входным и выходным множествами, как это делалось и в ряде предыдущих примеров. Выходное множество (оно размещается в начале массива) разбивается на группы. Разбиение отслеживается в массиве b. Элемент массива b[i] содержит индекс в массиве a,с которого начинается i+1-ая группа. Номер группы определяется значением анали- зируемой цифры числа, поэтому индексация в массиве b начинается с 0. Когда очередное число выбирается из входного множества и должно быть занесено в i-ую группу выходного множества, оно будет записано в позицию, определяемую значением b[i]. Но предварительно эта позиция должна быть освобождена: участок массива от b[i] до конца выходного множества включительно сдвигается вправо. После записи числа в i-ую группу i-ое и все последующие значения в массиве b корректируются - увеличиваются на 1.

 {===== Программный пример 3.15 =====}
 { Цифровая сортировка (распределение) }
 const D=...;   { максимальное количество цифр в числе }
      P=...;   { основание системы счисления }
 Function Digit(v, n : integer) : integer;
 { возвращает значение n-ой цифры в числе v }
 begin
   for n:=n downto 2 do v:=v div P;
   Digit:=v mod P;
 end;
 Procedure Sort(var a : Seq);
   Var b : array[0..P-2] of integer; { индекс элемента,
                          следующего за последним в i-ой группе }
       i, j, k, m, x : integer;
   begin
     for m:=1 to D do begin   { перебор цифр, начиная с младшей }
     for i:=0 to P-2 do b[i]:=1; { нач. значения индексов }
     for i:=1 to N do begin   { перебор массива }
       k:=Digit(a[i],m);      { определение m-ой цифры }
       x:=a[i];
       { сдвиг - освобождение места в конце k-ой группы }
       for j:=i downto b[k]+1 do a[j]:=a[j-1];
       { запись в конец k-ой группы }
       a[b[k]]:=x;
       { модификация k-го индекса и всех больших }
       for j:=k to P-2 do b[j]:=b[j]+1;
       end;
 end;

Результаты трассировки программного примера 3.15 при P=10 и D=4 представлены в таблице 3.9.

Таблица 3.9

Быстрая сортировка Хоара.

Данный алгоритм относится к распределительным и обеспечивает показатели эффективности O(N*log2(N)) даже при наихудшем исходном распределении.

Используются два индекса - i и j - с начальными значениями 1 и N соответственно. Ключ K[i] сравнивается с ключом K[j]. Если ключи удовлетворяют критерию упорядоченности, то индекс j уменьшается на 1 и производится следующее сравнение. Если ключи не удовлетворяют критерию, то записи R[i] и R[j] меняются местами. При этом индекс j фиксируется и начинает меняться индекс i(увеличиваться на 1 после каждого сравнения). После следующей перестановки фиксируется i и начинает изменяться j и т.д. Проход заканчивается, когда индексы i и j становятся равными. Запись, находящаяся на позиции встречи индексов, стоит на своем месте в последовательности. Эта запись делит последовательность на два подмножества. Все записи, расположенные слева от нее имеют ключи, меньшие чем ключ этой записи, все записи справа - большие. Тот же самый алгоритм применяется к левому подмножеству, а затем к правому. Записи подмножества распределяются по двум еще меньшим подмножествам и т.д., и т.д. Распределение заканчивается, когда полученное подмножество будет состоять из единственного элемента - такое подмножество уже является упорядоченным.

Процедура сортировки в примере 3.16 рекурсивная. При ее вызове должны быть заданы значения границ сортируемого участка от 1 до N.

 {===== Программный пример 3.16 =====}
 { Быстрая сортировка Хоара }
 Procedure Sort(var a : Seq; i0,j0 : integer);
 { i0, j0 - границы сортируемого участка }
 Var i, j : integer;  { текущие индексы в массиве }
     flag : boolean;  { признак меняющегося индекса: если
             flag=true - уменьшается j, иначе - увеличивается i }
     x : integer;
 begin
   if j0 <= i0 Exit; { подмножество пустое или из 1 эл-та }
   i:=i0; j:=j0;
   flag:=true; { вначале будет изменяться j }
   while i < j do begin
     if a[i] > a[j] then begin
       x:=a[i]; a[i]:=a[j]; a[j]:=x; { перестановка }
       { после перестановки меняется изменяемый индекс }
       flag:= not flag;
       end;
     { реально изменяется только один индекс }
     if flag then j:=j-1 else i:=i+1;
     end;
   Sort(a,i0,i-1); { сортировка левого подмассива }
   Sort(a,i+1,j0); { сортировка правого подмассива }
 end;

Результаты трассировки примера приведены в таблице 3.10. В каждой строке таблицы показаны текущие положения индексов i и j, звездочками отмечены элементы, ставшие на свои места. Для каждого прохода показаны границы подмножества, в котором ведется сортировка.

Таблица 3.10


3.8.4. Сортировки слиянием.

Алгоритмы сортировки слиянием, как правило, имеют порядок O(N*log2(N)), но отличаются от других алгоритмов большей сложностью и требуют большого числа пересылок. Алгоритмы слияния применяются в основном, как составная часть внеш- ней сортировки, которая более подробно будет рассматриваться нами во втором томе нашего пособия. Здесь же для понимания принципа слияния мы приводим простейший алгоритм слияния в оперативной памяти.

Сортировка попарным слиянием.

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

Важнейшей частью алгоритма является слияние двух упорядоченных множеств. Эту часть алгоритма мы опишем строго.

Программный пример 3.17 иллюстрирует сортировку попарным слиянием в ее обменном варианте - выходные множества формируются на месте входных.

 {===== Программный пример 3.17 =====}
 { Сортировка слиянием }
 Procedure Sort(var a :Seq);
 Var i0,j0,i,j,si,sj,k,ke,t,m : integer;
   begin
   si:=1;  { начальный размер одного множества }
   while si < N do begin
     { цикл пока одно множество не составит весь массив }
     i0:=1; { нач. индекс 1-го множества пары }
     while i0 < N do begin
       { цикл пока не пересмотрим весь массив }
       j0:=i0+si;   { нач. индекс 2-го множества пары }
       i:=i0; j:=j0;
       { размер 2-го множества пары может ограничиваться
         концом массива }
       if si > N-j0+1 then sj:=N-j0+1 else sj:=si;
       if sj > 0 then begin
         k:=i0; { нач. индекс слитого множества }
         while (i < i0+si+sj) and (j a[j] then begin
       { если эл-т 1-го <= элемента 2-го, он остается на
         своем месте, но вых.множество расширяется иначе - 
         освобождается место в вых.множестве и туда заносится 
         эл-т из 2-го множества }
             t:=a[j];
             for m:=j-1 downto k do a[m+1]:=a[m];
             a[k]:=t;
             j:=j+1; { к след. эл-ту во 2-м множестве }
             end;  { if a[i] > a[j] }
           k:=k+1; { вых. множество увеличилось }
           i:=i+1; { если был перенос - за счет сдвига, если 
                    не было - за счет перехода эл-та в вых. }
           end; { while }
         end; { if sj > 0 }
       i0:=i0+si*2;  { начало следующей пары }
      end; { while i0 < N }
    si:=si*2;  { размер эл-тов пары увеличивается вдвое }
    end; { while si < N }
 end;

Результаты трассировки примера приведены в таблице 3.11. Для каждого прохода показаны множества, которые на этом проходе сливаются. Обратите внимание на обработку последнего множества, оставшегося без пары.

Таблица 3.11


КаталогИндекс раздела
НазадОглавлениеВперед