
Типы структур данных
Любой набор знаков, рассматриваемый безотносительно к его содержательному смыслу, называют данными. Данные обычно изображают некоторую информацию, которую можно получить, если известен смысл, приписываемый данным. Однако в программировании, особенно в системном, часто приходится иметь дело именно с данными. Например, разрабатывая систему хранения и поиска некоторых текстов, программист может не знать их содержания. Его задача -обеспечить экономное использование памяти и быстрый поиск требуемых текстов по заданным признакам. Для решения этой задачи достаточно знать лишь количественные характеристики текстов, рассматриваемых как данные. Вообще вычислительные машины выполняют только обработку данных, которая заинтересованным лицам, приписывающим этим данным некоторый смысл, представляется обработкой информации.
Совокупности данных, организованные некоторым образом, называют структурами данных. Структура определяется отношениями между ее элементами. В рамках данного курса будем изучать структуры данных: стек, очередь, массивы, списки, деревья и таблицы.
Стек
Стек (магазин) - это одномерный, динамически изменяемый линейный набор данных. Новый элемент всегда добавляют к одному и тому же концу набора и как правило доступен только самый последний элемент. Удаление элементов производится с того же конца, с которого происходит добавление. Этот принцип называется LIFO (Last In First Out) - “последний пришел, первый ушел”. Стек называют также магазином по аналогии с магазином огнестрельного оружия, в котором патрон, вставленный последним, выходит первым. Конец стека, с которого добавляются и удаляются элементы, называют вершиной стека. На рисунке стек можно изобразить следующим образом.
top _
Простым примером использования стека может служить ситуация, когда мы просматриваем множество данных и составляем список особых данных, которые должны обрабатываться позднее. Когда первоначальное множество обработано, возвращаемся к этому списку и выполняем последующую обработку, удаляя элементы из списка, пока список не станет пустым.
Простейший способ работы со стеком – представление его в виде массива.
#define MAXSTACK 20 //объявляем максимальную величину стека
... stack [MAXSTACK]; //объявляем сам стек в виде массива нужного типа
int V; //указатель на вершину стека
Когда V = -1 – это признак пустоты стека.
Рассмотрим операции над стеком:
Поместить в стек.
V++;
if (V >= MAXSTACK) переполнение();
stack [V] = новые данные;
Взять из стека.
if (V < 0) пусто(); данные = stack [V--];
переполнение() и пусто() – некоторые функции, обрабатывающие соответствующие ситуации.
Организация рекурсий
Если функция А(а1, а2, ..., аn) обращается сама к себе с аргументами A(b1, b2, ..., bn), то такая операция называется рекурсией. При этом аргументы а1, а2, ..., аn опускаются в стек.
Рекурсией называют способ описания функций или процессов через самих себя. По-видимому, наиболее известным примером рекурсивно описанной функции является факториальная функция от положительного целого аргумента. Другим примером рекурсивной функции может служить рекуррентное соотношение между функциями Бесселя различных порядков. Еще одна возможная форма рекурсии встречается тогда, когда процесс описывается через подпроцессы, один из которых идентичен основному. Например, двойной интеграл. Один из методов вычисления двойного интеграла состоит в двукратном простом интегрировании.
Это типичный пример рекурсивного использования процедуры. Следует отличать такую рекурсию от другого типа рекурсии, при котором функция присутствует в правой части своего описания. В случае двойного интеграла вычисление внешнего интеграла требует вычисления другого интеграла, а внутренний интеграл вычисляется непосредственно. Принято говорить в таких случаях, что рекурсия имеет глубину равную единице; это означает: процесс обращается к себе как к подпроцессу только один раз. При вычислении функции factorial (3) в соответствии с рекурсивным описанием нужно вычислить factorial (2), factorial (1) и factorial (0); в этом случае глубина рекурсии равна трем.
Для факториала глубина рекурсии видна сразу. Однако это исключение, а обычно глубина рекурсии не является очевидной даже при простейших описаниях.
Например, рассмотрим алгоритм Эвклида для вычисления наибольшего общего делителя (НОД) двух положительных целых чисел. Этот алгоритм может быть описан следующим образом. Даны два положительных числа m и n. Требуется найти их наибольший общий делитель, т.е. наибольшее положительное целое число, которое нацело делит как m, так и n.
Шаг 1. Разделим m на n. Пусть остаток от деления равен r.
Шаг 2. Если r = 0, то НОД найден; n – искомое число.
Шаг 3. Присвоим m значение n, а n – r и вернемся к шагу 1.
В общем случае можно утверждать, что рекурсивное использование процедуры требует очевидной конечной глубины рекурсии, а вычисление рекурсивно описанной функции приводит к неопределенной глубине рекурсии, зависящей от значений аргументов. Оба типа рекурсии могут присутствовать одновременно.
Существует еще одна ситуация, в которой применять рекурсивные методы выгодно; она возникает при обработке данных, имеющих рекурсивную структуру. Такой структурой является, например, дерево.
Рассмотрим теперь способ организации рекурсии на базе стека. Такая система включает стек и две подпрограммы, которые будем называть ОБРАЩЕНИЕ и ВОЗВРАТ. Подпрограмма ОБРАЩЕНИЕ имеет три параметра:
а) адрес подпрограммы, в которую нужно войти;
б) адрес первой ячейки подлежащего запоминанию участка рабочего поля;
в) число ячеек, подлежащих запоминанию. Работа подпрограммы ОБРАЩЕНИЕ состоит в том, чтобы запомнить в стеке адрес возврата и указанные ячейки памяти, а затем обычным образом передать управление на ту подпрограмму, к которой требуется обратиться.
Подпрограмма ВОЗВРАТ имеет два аргумента:
а) адрес первой из подлежащих восстановлению ячеек рабочего поля;
б) число ячеек, подлежащих восстановлению.
Подпрограмма ВОЗВРАТ восстанавливает по содержимому стека состояние указанных ячеек, а затем передает управление по адресу возврата, который хранится в стеке.
Различие между рекурсивным языком и нерекурсивным языком состоит в том, что на языке, не поддерживающим механизм рекурсии программист, желающий ею воспользоваться, должен сам организовать стек и включить в текст программы фрагменты, соответствующие подпрограмма ОБРАЩЕНИЕ и ВОЗВРАТ, тогда как на рекурсивном языке это обеспечивается системой, так сказать, “за кулисами”. Во многих рекурсивных системах за это приходится платить тем, что автоматический аппарат, предусмотренный для обеспечения рекурсии, вступает в действие даже тогда, когда программа не является рекурсивной; это приводит к напрасным затратам машинного времени.
Рассмотрим использование стека для организации рекурсий на примере.
Пример:
Требуется обойти шахматную доску шагом коня, побывав в каждой клетке только один раз.
Сначала напишем функцию хода конем из заданной клетки.
Всевозможные хода из заданной клетки следующие.
|
7 |
|
0 |
|
6 |
|
|
|
1 |
|
|
* |
|
|
5 |
|
|
|
2 |
|
4 |
|
3 |
|
#define N 8
int hod (int x1, int y1, int nomhod, int *x2, int *y2) { //x1, y1 – координаты текущей клетки //nomhod – номер хода // x2, y2 – новые координаты
// функция возвращает 1, если ход допустим; 0, если выход за пределы доски switch (nomhod) { case 0:
*x2 = x1 + 1; *y2 = y1 + 2; break; case 1: ...
default: сообщение о недопустимости хода; }
return (*x2 < N && *x2 >= 0 && *y2 < N && *y2 >= 0); }
В стеке будем держать координаты клетки и номер хода, с помощью которого покинули клетку. Тогда для задания элемента стека удобно воспользоваться структурой. typedef struct { int x,y; //координаты клетки, начальная координата (0, 0)
int hod; //номер хода
} MOVE;
Обход заключается в следующем. Будем делать случайный ход (от 1 до 8). Если ход допустим, то поместим в стек координаты и номер хода, которым мы покидаем клетку. Если ни один ход из 8 не является допустимым, следовательно нужно изменить предыдущий ход. Тогда достаем из стека координаты и номер предыдущего хода и делаем другой ход. Если это невозможно, то меняем еще один предыдущий ход и т.д.
Функция horse будет получать результат обхода в стеке stack. Функция вернет 1, если обход доски завершен успешно, и 0, если обход невозможен. int horse (MOVE *stack) { int V = -1; //указатель на вершину стека, сначала он пуст
int x1, y1; //координаты текущей клетки, сначала они нулевые
int x2, y2; //новые координаты
int nomhod, nom; //номер хода
int doska [N][N]; //массив игровой доски, если doska [i][j] = 1, то клетка занята ходом int good; //флаг, сообщающий, найден допустимый ход (1) или нет (0)
x1 = y1 = 0;
nomhod = 0; //ход, с которого начинается перебор
memset (doska, 0, N*N*sizeof(int)); //заполнение массива нулями
for (;;) { good = 0; for (nom = nomhod; nom < N; nom ++) {
if (hod(x1, y1, nom, &x2, &y2) && !doska[x2][y2]) {
good = 1; //допустимый ход найден, его номер nom
break; } } if (good) {
V ++;
stack [V].x = x1; stack [V].y = y1; stack [V].hod = nom;
if (V = = N*N - 1) return 1; //обход доски завершен
nomhod = 0; //снова устанавливаем ход, с которого начинается перебор
doska [x1][y1] = 1; //помечаем клетку как посещаемую
} else {
if (V < 0) return 0; //тупиковая ситуация, обход доски невозможен
x1 = stack [V].x;
y1 = stack [V].y;
nomhod = stack [V].hod + 1;
V --; doska [x1][y1] = 0; //клетка больше не посещается
}
}
Стек для распределения памяти
Посмотрим как используется стек при вызове функций. При вызове функции аргументы функции опускаются в стек.
Например, имеем программу следующей структуры. void main (void) {
(7)
(л)
int x; char p;
fl (x); (T)
£2 (x,p); Q
}
void f1 (int z) {
float r;
int l;
GD
£2 (z,l);
CD CD
}
void f2 (int c, char d) { double q;
}
Рассмотрим состояние стека в процессе выполнения программы.
|
байты памяти |
|||||||||||||||||||||||||||||||||||||
0 | 1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 1 10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
|||||||||||||||||||
1 |
X |
p |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||
2 |
X |
p |
z |
r |
1 |
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||
5 |
X |
p |
z |
r |
1 |
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||
6 |
X |
p |
z |
r |
1 |
С |
d |
q |
||||||||||||||||||||||||||||||
7 |
X |
p |
z |
r |
1 |
|
|
|
|
|
|
|
|
|
|
|
||||||||||||||||||||||
3 |
X |
p |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|||||||||||||||||||||
4 |
X |
p |
с |
d |
q |
|
|
|
|
|
|
|
|
Реентерабельность – повторный вход модуля (reenter).
Пример:
Преобразование выражения в обратную польскую запись.
Имеем выражение (a + b) * ((c - d) + p/q).
Обратная польская запись этого выражения ab+cd-pq/+*. Сначала записываем операнды, а затем знак операции.
Текст любой программы можно преобразовать в обратную польскую запись.
Исходные данные – символьная строка, содержащая преобразуемое выражение. Строка содержит операнды (только буквы) и знаки операций ( + - / *). Результат работы – символьная строка, содержащая обратную польскую запись.
Алгоритм преобразования выражения в польскую запись следующий. Входную строку просматриваем слева направо. если встретился операнд, то сразу помещаем его в выходную строку. Открывающую скобку помещаем в стек. Знак операции тоже будет помещен в стек, но перед этим его приоритет сравниваем с приоритетом операции на вершине стека и все операции, имеющие приоритет выше или равный приоритету входной операции выталкиваем из стека в
выходную строку. Теперь помещаем в стек знак операции. Закрывающая скобка в стек не помещается, но выталкивает из него все в выходную строку. Ближайшей открывающая скобка выталкивается из стека, но в выходную строку не записывается. Когда входная строка закончится, остаток стека переносится в строку результата.
Алгоритм вычисления обратной польской записи стековый. Просматриваем запись слева направо. Операнды помещаются в стек. Если встречается знак операции, то она выполняется над операндами на вершине стека. Результат операции помещается в стек на вершину вместо выбранных операндов.