[ЗМІСТ]      [Далі]       [Назад]

 

10. РЕКУРСИВНІ СТРУКТУРИ ДАНИХ

10.1. Статична та динамічна пам’ять

10.2. Вказівники

10.3. Черга та дек

10.3.1. Черга

10.3.2. Дек

10.4. Списки

10.4.1 Класичний список

10.4.2 Список з поточним елементом

10.4.3 Кільцевий список

10.4.4 Двозв’язний список

10.5. Дерева та графи

10.5.1 Бінарне дерево

10.5.2 Сильно розгалужене дерево

10.5.3 Граф

 

10. РЕКУРСИВНІ СТРУКТУРИ ДАНИХ

 

      Всі структури даних, які ми розглядали у попередніх розділах (за виключенням файлів), були статичними в тому розумінні, що розмір пам’яті, необхідної для збереження даних, визначався до початку виконання алгоритму чи програми. Звичайно, розмір файлів може змінюватись під час виконання програм, але дані файлів, як правило, зберігаються на зовнішніх носіях, а не у оперативній пам’яті.

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

      Динамічні структури даних також часто називають рекурсивними. Ми вже зустрічались з рекурсивними алгоритмами та програмами. Аналогічно рекурсивним програмам, рекурсивні структури даних у своєму описі посилаються самі на себе. Взагалі поняття динамічних структур даних є більш широким, ніж поняття рекурсивних структур даних, але в цьому розділі ми будемо розглядати саме рекурсивні структури даних, їх визначення, реалізацію та використання. На відміну від попередніх розділів, ми не будемо використовувати алгоритмічну мову, а всі поняття будемо розглядати з точки зору їх реалізації у мовах програмування Паскаль та Сі.

 

10.1. Статична та динамічна пам’ять

 

      Пам’ять комп’ютера, яку системи програмування виділяють для роботи програм, можна поділити на 4 частини:

1.    Програмний код.

2.    Дані.

3.    Програмний стек.

4.    Купа.

      Пам’ять під програмний код призначена для збереження програм у внутрішньому представленні системи програмування або у машинних кодах.

      Пам’ять під дані призначена для збереження статичних даних програм (глобальних змінних та констант).

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

      Купа призначена для динамічного розподілу пам’яті під структури даних.

      Пам’ять під програмний код та дані є статичною, а програмний стек та купа – динамічною пам’яттю. Програмним стеком керує система програмування без втручання програміста в той час, як купа спеціально призначена для використання програмістом.

      Визначають дві базові операції над динамічною пам’яттю (купою): „виділити блок пам’яті” та „звільнити блок пам’яті”. Блоком називають неперервну ділянку пам’яті визначеного розміру. У деякий момент роботи програми купу можна представити у вигляді двох списків (Рис. 10.1): список зайнятих блоків (L1) та список вільних блоків (L2).

      Після отримання команди на виділення блоку пам’яті (Рис. 10.1 а) ) програма, що керує купою, шукає у списку вільних блоків блок пам’яті потрібного розміру (або більше) та заносить його у список зайнятих блоків, повертаючи посилання на цей блок (Рис. 10.1 б) ). Якщо блок потрібного розміру не знайдено, виникає помилка.

      Після отримання команди на звільнення блоку пам’яті (Рис. 10.2 а) ) програма, що керує купою, звільняє цей блок, можливо об’єднавши його з сусідніми вільними блоками, якщо такі є (Рис. 10.2 б) ).

 

Рис. 10.1 - Виділення блоку пам’яті

 

Рис. 10.2 - Звільнення блоку пам’яті m

 

10.2. Вказівники

 

      Вказівником називають змінну, значенням якої є адреса пам’яті. Вказівники використовують для роботи з динамічною пам’яттю та організації рекурсивних структур даних. Ми вже зустрічались з вказівниками, розглядаючи, зокрема, підпрограми у мові Сі.

      Можна вважати, що вказівник вказує на початок деякого блоку пам’яті або на деяку змінну, яку ми так і будемо називати: „змінна, на яку вказує вказівник” (Рис. 10.3).

 

Рис. 10.3 – Графічне зображення вказівників

 

      Розглянемо реалізацію вказівників у мовах Паскаль та Сі.

      У мові Паскаль опис типу вказівника та змінної цього типу виглядає так:

 

      type t = ^t;

      var p: t;

 

де t - тип вказівника, t – тип змінної, на яку вказує вказівник. Наприклад:

 

      type  pint = ^integer;

            pchar = ^char;

 

      Для вказівників у Паскалі визначено такі операції, відношення та інструкції:

 

      1. операції

 

- розіменування вказівника

 

      _^ : t -> t

 

наприклад, p^ за вказівником p дає змінну, на яку вказує вказівник.

 

- отримання адреси змінної

 

      @_ : t -> t

 

наприклад, @x дає адресу змінної x (вказівник на x).

 

      2. відношення

 

для вказівників визначені відношення = та <>.

 

      3. інструкції

 

- виділення пам’яті

 

      new(_)

 

new(p) виділяє у динамічній пам’яті блок пам’яті, який має розмір, достатній для розміщення змінної типу t. Вказівник p буде вказувати на початок цього блоку пам’яті (Рис. 10.4 а) ). Треба відмітити, що пам’ять виділяється не під сам вказівник, а під змінну, на яку цей вказівник буде вказувати.

 

- звільнення пам’яті

 

      dispose(_)

 

dispose(p) звільняє блок пам’яті, на який вказував вказівник p (Рис. 10.4 б) ). Після цього значення змінної, на яку вказує вказівник p, стає недосяжним.

 

Рис. 10.4 Виділення, звільнення пам’яті та константа nil

 

- присвоєння, наприклад, після присвоєння p1:=p2 (Рис. 10.5 а), б) ) вказівник p1 буде вказувати на той самий блок пам’яті, що й p2. Присвоєння для вказівників треба відрізняти від присвоєння для змінних, на які вони вказують, яке записують з використанням розіменування. Наприклад, присвоєння p1^:=p2^ копіює значення змінної, на яку вказує p2, у змінну, на яку вказує p1 (Рис. 10.5 а), в) ).

 

 

Рис. 10.5 – Присвоєння для вказівників та присвоєння для змінних, на які вони вказують

 

      Для вказівників у Паскалі також визначено константу nil. Nil - це вказівник, що не вказує на жодну клітинку пам’яті (Рис 10.4 в) ). Значення nil часто використовують у реалізації рекурсивних структур даних для позначення завершення структури.

      Роботу із вказівниками та побудову рекурсивних структур даних розглянемо на прикладі стеку. Визначимо стек як:

1.    Порожній стек.

2.    Верхівка стеку; стек.

Читати це означення треба так: стек – це або порожній стек, або верхівка стеку, за яким слідує стек.

      Стек можна представити, як сукупність однотипних елементів, в якій ми маємо доступ тільки до верхнього елемента (Рис. 10.6). Цей елемент називають верхівкою стеку.

 

Рис. 10.6 Стек

 

Операції, відношення та інструкції для стеків:

1.    Почати роботу.

2.    Чи порожній стек?

3.    Вштовхнути елемент у стек.

4.    Верхівка стеку.

5.    Забрати верхівку стеку.

 

      Дії 1, 3, 5 – інструкції; 2 – відношення; 4 – операція.

      “Почати роботу” означає створити порожній стек.

      “Вштовхнути елемент у стек” – додати до стеку один елемент, який стає верхівкою стеку.

      “Верхівка стеку” – повернути верхній елемент. Стек при цьому не змінюється. Для порожнього стеку ця операція повинна давати відмову.

      “Забрати верхівку стеку” – видалити верхній елемент. Верхнім стає попередній елемент стеку або стек стає порожнім. Для порожнього стеку ця інструкція повинна давати відмову.

 

      Можлива реалізація стеку з використанням вказівників зображена на Рис. 10.7. Стек реалізовано, як вказівник на верхній елемент. Якщо стек порожній, то елементів немає, а вказівник на верхівку дорівнює nil. Елементи стеку – це записи з двох полів: поле даних та вказівник на попередній елемент. За допомогою вказівника елементи стеку з’єднані у ланцюг. У першому елементі вказівник на попередній елемент дорівнює nil.

 

Рис. 10.6 Реалізація стеку

 

      Розглянемо модуль реалізації стеку символів.

 

{ Стек символів }

unit ChStack;

 

interface

 

type stack = ^selem;                      { Стек }

     selem  = record                      { Елемент стеку }

                   d: char;

                   next: stack

             end;

 

procedure Init(var s: stack);             { Почати роботу }

function Empty (s: stack): boolean;       { Чи порожній стек? }

procedure Push (var s: stack; c: char);   { Вштовхнути елемент у стек }

function Top (s: stack): char;            { Верхівка стеку }

procedure Pop (var s: stack);             { Забрати верхівку стеку }

 

implementation

 

procedure Init(var s: stack);

 begin

   s := nil

 end;

 

function Empty (s: stack): boolean;

 begin

   Empty:= s=nil

 end;

 

procedure Push (var s: stack; c: char);

 var p: stack;

 begin

   new(p); p^.d := c; p^.next := s;

   s := p

 end;

 

function Top (s: stack): char;

 begin

   if s = nil then begin

     writeln(' Top: Стек порожній'); halt

   end;

   Top := s^.d

 end;

 

procedure Pop (var s: stack);

 var p: stack;

 begin

   if s = nil then begin

     writeln(' Pop: Стек порожній'); halt

   end;

   p:=s; s:= s^.next; dispose(p)

 end;

 

end.

 

      У описах типів ми спочатку описуємо тип вказівника (stack),

 

      type stack = ^selem;

 

а потім тип елемента стеку, на який вказує вказівник (selem).

 

     selem  = record

                   d: char;

                   next: stack

             end;

 

При цьому порушується правило, згідно з яким кожний об’єкт повинен бути описаний до його використання. Але оскільки у описі присутня рекурсія (stack використовує selem, а selem - stack), дотриматись цього правила неможливо. Тому у Паскалі роблять виключення для типів вказівників. А саме: тип вказівника може бути описаний раніше, ніж тип змінної, на яку він вказує, але останній повинен бути описаний у тому ж розділі описів, що й тип вказівника.

      Почати роботу зі стеком (процедура Init) – це просто присвоїти вказівнику на верхівку стеку значення nil.

      Перевірити, чи стек порожній (функція Empty), означає порівняти вказівник на верхівку стеку з nil.

      Зміни стеку під час дії „вштовхнути елемент” (процедура Push) зображені на Рис. 10.7. Після new(p) (Рис. 10.7 а) ) виділяється пам’ять під новий елемент стеку. Ланцюг p^.d:=c; p^.next:=s (Рис. 10.7 б) ) забезпечує запис символа c у поле даних та зв’язування нового елемента з поточною верхівкою стеку. Дійсно, p^ дає змінну, на яку вказує вказівник. Ця змінна є записом, отже p^.d та p^.next – це відповідні поля цього запису. s:=p змінює вказівник на верхівку стеку (Рис. 10.7. в) ). Пунктирна стрілка тут і далі позначає розірваний зв’язок.

      Функція Top перевіряє, чи порожній стек, і якщо так, то видає повідомлення про помилку та аварійно завершує роботу всієї програми викликом стандартної підпрограми halt. Якщо стек не порожній, то функція повертає символ, що зберігається у полі даних верхнього елемента стеку.

 

Рис. 10.7 – Додавання елемента у стек

 

      Зміни стеку під час дії „забрати верхівку стеку” (процедура Pop) зображені на Рис. 10.8. Після p:=s запам’ятовуємо вказівник на верхівку стека у змінній p (Рис. 10.8 а) ). s:=s^.next змінює вказівник на верхівку стеку (Рис. 10.8 б) ). dispose(p) звільнає пам’ять, яку займав колишній верхній елемент стеку (Рис. 10.8 в) ).

 

Рис. 10.8 – Видалення верхівки стеку

 

      Покажемо використання модуля стеку символів для розв’язку такої задачі. Вводиться послідовність символів, яка завершується символом ‘#’. Треба показати цю послідовність у зворотньому порядку.

      У програмі Inversion послідовність символів, що введена, записується у стек. Потім символи забираються зі стеку та виводяться на екран у зворотньому порядку.

 

{ Запис послідовності символів у зворотньому порядку }

program Inversion;

 

uses ChStack;

 

const EndCh = '#';

 

var s: stack; c : char;

 

begin

  Init(s);

  writeln('Введіть послідовність символів. Кінец введення - ',EndCh);

  read(c);

  while c <> EndCh do begin

    Push(s,c); read(c)

  end;

  writeln('Послідовність у зворотньому порядку');

  while not Empty(s) do begin

    write(Top(s));

    Pop(s)

  end;

  writeln;

end.

 

      Модуль ChStack та програма Inversion демонструють стандартний підхід до використання вказівників. Воно обмежене модулем, а програма тільки викликає підпрограми, що реалізують дії над стеком. Обмеження використання вказівників обумовлене небезпекою, пов’язаною з безпосередньою роботою з пам’яттю. Дійсно, розглянемо ланнцюг:

 

      new(p2); … p1:=p2; dispose(p2)

 

Після цього блок пам’яті, на який вказував p2, буде звільнено і він може бути використаний для розміщення інших даних. В той же час, можливе подальше використання вказівника p1 для посилання на вже звільнену (і можливо – перерозподілену) пам’ять. Така ситуація є дуже небезпечною та може привести навіть до виведення з ладу операційної системи.

 

      У мові Сі вказівники мають більш широке застосування, ніж у Паскалі. Зокрема, вказівники використовуються не тільки для організації динамічних структур даних, але й для передачі параметрів підпрограм. Масиви та рядки теж є, по суті, вказівниками на початок масиву або рядка. Опис вказівника на змінні деякого типу t:

 

      t *p;

 

наприклад,

 

      int *pit;

      char *pch;

 

У Сі визначені такі операції, відношення та інструкції для вказівників:

 

      1. операції

 

- розіменування вказівника

 

      *_ , наприклад, *p за вказівником p дає змінну, на яку вказує вказівник.

 

- отримання адреси змінної

 

      &_ , наприклад, &x дає адресу змінної x.

 

- арифметичні операції додавання та віднімання цілого числа i:

 

      p+i повертає адресу, яка більша p на i елементів типу t,

      p-i повертає адресу, яка менша p на i елементів типу t.

 

- знаходження різниці вказівників

 

      p1-p2 повертає ціле число, яке є різницею значень p1 та p2.

 

      2. відношення

 

для вказівників визначені стандартні відношення =, !=, >, <, >=, <=.

 

      3. інструкції

 

- виділення пам’яті

 

      malloc(_)

 

p = malloc(n) виділяє у динамічній пам’яті блок пам’яті, який має розмір, рівний або більший n байт. Вказівник p буде вказувати на початок цього блоку пам’яті. Функція malloc повертає вказівник на тип void.

 

- звільнення пам’яті

 

      free(_)

free(p) звільняє блок пам’яті, на який вказував вказівник p. Функції malloc() та free() описано у бібліотеці stdlib.h.

 

- присвоєння, наприклад, після присвоєння p1=p2 вказівник p1 буде вказувати на той самий блок пам’яті, що й p2. Присвоєння для змінних, на які вказують вказівники, записують з використанням розіменування: *p1=*p2.

 

- збільшення та зменшення вказівника на одиницю

 

      p++ або p--

 

збільшує або зменшує адресу на один елемент типу t.

 

Роль константи nil у мові Сі грає NULL.

 

      Зауваження. Операції додавання та віднімання цілого числа, різниці вказівників, відношення >, <, >=, <=, інструкції збільшення та зменшення вказівника на одиницю мають сенс тільки тоді, коли два вказівники вказують на один масив (рядок) у пам’яті.

 

      Розглянемо тепер реалізацію стеку символів у мові Сі. Модуль для реалізації стеку розміщено у 2 файлах: stack.h, stack.c. У файлі stack.h описано типи даних для елемента стеку (struct selem) та стеку (stack) а також заголовки функцій для реалізації дій над стеком. Оскільки у функціях init, push та pop стек є результатом або модифікованим параметром, ми використовуємо вказівник на стек *sp. У Сі, як і у Паскалі, ми спочатку описуємо тип вказівника stack, а потім структуру struct selem.

 

/* Стек символів stack.h */

 

typedef struct selem *stack;              /* Стек */

struct selem {                            /* Елемент стеку */

            char d;

            stack next;

            };

 

extern int stack_error;                   /* Код помилки */

 

extern void init(stack *sp);              /* Почати роботу */

extern int empty(stack s);                /* Чи порожній стек? */

extern void push(stack *sp, char ch);     /* Вштовхнути елемент у стек */

extern char top(stack s);                 /* Верхівка стеку */

extern void pop(stack *sp);               /* Забрати верхівку стеку */

 

      У файлі stack.c описано реалізацію дій над стеком. Ця реалізація відрізняється від описаної вище для Паскаля обробкою виключних ситуацій. Замість генерації відмов безпосередньо у модулі, використовується змінна stack_error, яка зберігає код останньої помилки (0, якщо все норамльно). Виклик

 

       p = (stack) malloc(sizeof(struct selem));

 

виділяє пам’ять під елемент стеку розміром sizeof(struct selem) та явно приводить вказівник до типу stack. Позначення ->” використовується у Сі як альтернативне для вибору поля запису, на який вказує вказівник. Тобто, замість (*p).d пишемо p -> d, щоб не ставити дужки.

 

/* Стек символів stack.c */

 

#include <stdlib.h>

#include "stack.h"

 

int stack_error = 0;

 

void init(stack *sp)

  {

      *sp = NULL;

      stack_error = 0;

  }

 

int empty(stack s)

  {

       return s == NULL;

  }

 

void push(stack *sp, char ch)

  {

       stack p;

 

       p = (stack) malloc(sizeof(struct selem));

       p -> d = ch; p -> next = *sp; *sp = p;

  }

 

char top(stack s)

  {

      if (s == NULL)

     {

      stack_error = 2;

      return 0;

     }

      else return s -> d;

  }

 

void pop(stack *sp)

  {

       stack p;

 

       p = *sp;

       if (p == NULL) stack_error = 3;

       else

             {

                  *sp = p -> next;

                  free(p);

             }

  }

 

      Програма, яка записує введену послідовність символів у звортньому порядку, наведена нижче. У цій програмі функція process_error аналізує код помилки, видає повідомлення та аварійно завершує роботу програми. Сама програма викликає функцію process_error, якщо код помилки останньої дії не дорівнює 0.

 

/* Inversion */

 

#include <stdio.h>

#include <stdlib.h>

#include "stack.h"

 

#define ENDCH '#'

 

void process_error()

  {

   switch (stack_error)

    {

     case 2: printf("*** Неможливо взяти верхівку порожнього стеку ***\n");

           break;

     case 3: printf("*** Неможливо забрати елемент з порожнього стеку ***\n");

           break;

    }

    exit(1);

  }

 

main()

  {

       stack s;

       char ch;

 

   printf("Введіть послідовність символів до %c\n", ENDCH);

   init(&s);

   while (1)

     {

      scanf("%c",&ch);

      if (ch == ENDCH) break;

      push(&s,ch);

     }

   printf("Послідовність у зворотньому порядку\n");

   while (!empty(s))

     {

      printf("%c",top(s));

      if (stack_error != 0) process_error();

      pop(&s);

      if (stack_error != 0) process_error();

     }

   printf("\n");

  }

 

[ЗМІСТ]      [Далі]       [Назад]