Мегаобучалка Главная | О нас | Обратная связь


Структуры, ссылающиеся на себя; двоичные деревья



2019-12-29 207 Обсуждений (0)
Структуры, ссылающиеся на себя; двоичные деревья 0.00 из 5.00 0 оценок




 

Пример 7-4. Предположим, что нам надо справиться с более общей задачей, состоящей в подсчете числа появлений всех слов в некотором файле ввода. Так как список слов заранее не известен, мы не можем их упорядочить удобным образом и использовать бинарный поиск. Мы даже не можем осуществлять последовательный просмотр при поступлении каждого слова, с тем, чтобы установить, не встречалось ли оно ранее; такая программа будет работать «вечно». (Более точно, ожидаемое время работы растет как квадрат числа вводимых слов). Как же нам организовать программу, чтобы справиться со списком произвольных слов?

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

Каждому новому слову соответствует один «узел» дерева; каждый узел содержит:

· указатель текста слова;

· счетчик числа появлений;

· указатель узла левого потомка;

· указатель узла правого потомка.

 

Никакой узел не может иметь более двух «детей» (отсюда название «двоичного» дерева); возможно отсутствие детей или наличие только одного потомка.

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


 

 

Вот как выглядит дерево, построенное для фразы: «now is the time for all good men to come to the aid of their party» (пер. с англ.: «настало время всем добрым людям помочь своей партии»), – по завершении процесса, в котором для каждого нового слова в него добавляется новый узел (рис. 7.1):

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

 

struct tnode

{               // struct: Узел дерева

char *word;     // Указатель на текст

int count;    // Число вхождений (появлений)

struct tnode *left; // Левый «сын»

struct tnode *right; // Правый «сын»

};

 

Это «рекурсивное» описание узла может показаться рискованным, но на самом деле оно вполне корректно. Структура не имеет права содержать саму себя, но:

struct tnode *left;

описывает left как указатель на узел, а не как сам узел.

 

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

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

 

#define maxword 20

main() /* word freguency count */

{

struct tnode *root, *tree();

char word[maxword];

int t;

root = null;

while ((t = getword(word, maxword)) != eof)

if (t == letter)

    root = tree(root, word);

treeprint(root);

}

 

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

// tree: Добавляет узел со словом w в p или ниже него

struct tnode *tree(struct tnode *p, char *w)

{

struct tnode *talloc();

char *strsave();

int cond;

if (p == null)

{          //Если слово встречается впервые

p == talloc(); //Создается новый узел

p->word = strsave(w);

p->count = 1;

p->left = p->right = null;

}

else if ((cond = strcmp(w, p->word)) == 0)

p->count++; //Это слово уже встречалось

else if (cond < 0) //Меньше корня левого поддерева

p->left = tree(p->left, w);

else          //Больше корня правого поддерева

p->right = tree(p->right, w);

return(p);

}       

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

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

// treeprint: напечатать дерево p рекурсивно

treeprint (struct tnode *p)

{

if (p != null)   

{

treeprint (p->left);

printf("%4d %s\n", p->count, p->word);

treeprint (p->right);

}

}

 

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

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

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

Второй: как организовать описания, чтобы справиться с тем, что функция alloc должна возвращать различные виды указателей?

Вообще говоря, требования выравнивания легко выполнить за счет выделения некоторого лишнего пространства, просто обеспечив то, чтобы распределитель памяти всегда возвращал указатель, удовлетворяющий всем ограничениям выравнивания. Например, на PDP-11 достаточно, чтобы функция alloc всегда возвращала четный указатель, поскольку в четный адрес можно поместить любой тип объекта. единственный расход при этом лишний символ при запросе на нечетную длину. Аналогичные действия предпринимаются на других машинах. Таким образом, реализация alloc может не оказаться переносимой, но ее использование будет переносимым. Функция alloc из главы 6 не предусматривает никакого определенного выравнивания. В главе 8 мы продолжим обсуждение темы, связанной с динамическим распределением памяти.

Вопрос описания типа функции alloc является мучительным для любого языка, который серьезно относится к проверке типов. Лучший способ в языке «C» – объявить, что alloc возвращает указатель на переменную типа char, а затем явно преобразовать этот указатель к желаемому типу с помощью операции перевода типов. Таким образом, если описать p в виде:

char *p;

то

(struct tnode *) p

преобразует его в выражениях в указатель на структуру типа tnode . Следовательно, функцию talloc можно записать в виде:

struct tnode *talloc()

 {

 char *alloc();

 return((struct tnode *) alloc(sizeof(struct tnode)));

 }

 

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

 

Упражнение 7-4. Напишите программу, которая читает «C»-программу и печатает в алфавитном порядке каждую группу имен переменных, которые совпадают в первых семи символах, но отличаются где-то дальше. (Сделайте так, чтобы 7 было параметром).

Упражнение 7-5. Напишите программу выдачи перекрестных ссылок, т.е. программу, которая печатает список всех слов документа и для каждого из этих слов печатает список номеров строк, в которые это слово входит.

Упражнение 7-6. Напишите программу, которая печатает слова из своего файла ввода, расположенные в порядке убывания частоты их появления. Перед каждым словом напечатайте число его появлений.      

 

Поиск в таблице

 

Пример 7-5. Для иллюстрации дальнейших аспектов использования структур в этом разделе мы напишем программу, представляющую собой содержимое пакета поиска в таблице. Эта программа является типичным представителем подпрограмм управления символьными таблицами макропроцессора или компилятора. Рассмотрим, например, оператор #define языка «C». Когда встречается строка вида:

#define yes 1

то имя yes и заменяющий текст 1 помещаются в таблицу. Позднее, когда имя yes появляется в операторе вида:

inword = yes;

оно должно быть замещено на 1.

 

Имеются две основные процедуры, которые управляют именами и заменяющими их текстами. Функция install(s,t) записывает имя s и заменяющий текст t в таблицу; здесь s и t просто символьные строки. Функция lookup(s) ищет имя s в таблице и возвращает либо указатель того места, где это имя найдено, либо null, если этого имени в таблице не оказалось.

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

Блоком цепи является структура, содержащая указатели на соответствующее имя, на заменяющий текст и на следующий блок в цепи. Нулевой указатель следующего блока служит признаком конца данной цепи.

 

struct nlist 

{               // Основной элемент таблицы

char *name;

char *def;

struct nlist *next; // Следующий элемент таблицы

};

 

Массив указателей – это просто:

 

#define hashsize 100 // hashtab: Таблица указателей

static struct nlist *hashtab[hashsize]

 

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

 

// hash: Получает хэш-код для строки s

unsigned hash(char *s)  

{

int hashval;

for (hashval = 0; *s != '\0'; )

hashval += *s++;

return(hashval % hashsize);

}

 

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

 

// lookup: Ищет s в hashtab

struct nlist *lookup(char *s)

{

struct nlist *np;

for (np=hashtab[hash(s)]; np != null; np=np->next)

if (strcmp(s, np->name) == 0)

    return(np);      // Нашли

return(null);          // Не нашли

}

 

Функция install использует функцию lookup для определения, не присутствует ли уже вводимое в данный момент имя; если это так, то новое определение должно вытеснить старое. В противном случае создается совершенно новый элемент. Если по какой-либо причине для нового элемента больше нет места, то функция install возвращает null.

 

// install: Заносит имя и текст (name, def) в таблицу

struct nlist *install(char *name, char *def)

{

struct nlist *np, *lookup();

char *strsave(), *alloc();

int hashval;

if((np = lookup(name)) == null)

{               // Не найден

np = (struct nlist *) alloc(sizeof(*np));

if (np == null)

    return(null);

if ((np->name = strsave(name)) == null)

    return(null);

hashval = hash(np->name);

np->next = hashtab[hashval];

hashtab[hashval] = np;

}

else               // Уже имеется

free((np->def); // Освобождаем прежний defn

if ((np->def = strsave(def)) == null)

    return (null);

return(np);

}     

 

Функция strsave просто копирует строку, указанную в качестве аргумента, в место хранения, полученное в результате обращения к функции alloc. Мы уже привели эту функцию в главе 6. Так как обращение к функции alloc и free могут происходить в любом порядке и в связи с проблемой выравнивания, простой вариант функции alloc из главы 6 нам больше не подходит (смотрите главу 8).

 

Упражнение 7-7. Напишите процедуру, которая будет удалять имя и определение из таблицы, управляемой функциями lookup и install.

Упражнение 7-8. Разработайте простую, основанную на функциях этого раздела, версию процессора для обработки конструкций #define, пригодную для использования с «C»-программами. Вам могут также оказаться полезными функции getchar и ungetch.

 

Битовые поля

 

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

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

Обычный способ, которым это делается, состоит в определении набора «масок», отвечающих соответствущим битовым позициям, как в:

#define keyword 01

#define external 02

#define static 04

(числа должны быть степенями двойки). Тогда обработка битов сведется к «жонглированию битами» с помощью операций сдвига, маскирования и дополнения, описанных нами в главе 3.

 

Некоторые часто встречающиеся идиомы:

flags |= external | static;

включает биты external и static в flags, в то время как:

flags &= ~( external | static);

их выключает, а:

if ((flags & (external | static)) == 0) ...

истинно, если оба бита выключены.

 

Хотя этими идиомами легко овладеть, язык «C» в качестве альтернативы предлагает возможность определения и обработки полей внутри слова непосредственно, а не посредством побитовых логических операций. Поле – это набор смежных битов внутри одной переменной типа int. Синтаксис определения и обработки полей основывается на структурах. Например, символьную таблицу конструкций #define, приведенную выше, можно бы было заменить определением трех полей:

struct 

{

unsigned is_keyword : 1;

unsigned is_extern : 1;

unsigned is_static : 1;

} flags;

Здесь определяется переменная с именем flags, которая содержит три однобитовых поля поля. Следующее за двоеточием число задает ширину поля в битах. Поля описаны как unsigned, чтобы подчеркнуть, что они действительно будут величинами без знака.

На отдельные поля можно ссылаться, как:

· flags.is_statie,

· flags.is_extern,

· flags.is_keyword

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

· для включения битов

flags.is_extern = flags.is_static = 1;

· для выключения битов

flags.is_extern = flags.is_static = 0;

· для их проверки 

if(flags.is_extern == 0 && flags.is_static == 0)...

 

Поле не может перекрывать границу int; если указанная ширина такова, что это должно случиться, то поле выравнивается по границе следующего int. Полям можно не присваивать имена; неименованные поля (только двоеточие и ширина) используются для заполнения свободного места. Чтобы вынудить выравнивание на границу следующего int, можно использовать специальную ширину 0.

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

Другие ограничения, которые следует иметь в виду: поля не имеют знака; они могут храниться только в переменных типа int (или, что эквивалентно, типа unsigned); они не являются массивами; они не имеют адресов, так что к ним не применима операция &.

 


Объединения

 

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

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

union u_tag

{

int ival;

float fval;

char *pval;

} uval;

 

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

Синтаксически доступ к элементам (членам) объединения осуществляется следующим образом:

· Имя_объединения.элемент      ,

· Указатель_объединения->элемент ,

 

то есть точно так же, как и в случае структур. Если для отслеживания типа, хранимого в данный момент в uval, используется переменная utype, то можно встретить такой участок программы:

 


if (utype == int)

printf("%d\n", uval.ival);

else if (utype == float)

printf("%f\n", uval.fval);

else if (utype == string)

printf("%s\n", uval.pval);

else

printf("bad type %d in utype\n", utype);

 

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

struct

{

char *name;

int flags;

int utype;

union

{

int ival;

float fval;

char *pval;

} uval;

} symtab[nsym];

 

на переменную ival можно сослаться как:

symtab[i].uval.ival ,

а на первый символ строки pval как  

*symtab[i].uval.pval .

 

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

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




2019-12-29 207 Обсуждений (0)
Структуры, ссылающиеся на себя; двоичные деревья 0.00 из 5.00 0 оценок









Обсуждение в статье: Структуры, ссылающиеся на себя; двоичные деревья

Обсуждений еще не было, будьте первым... ↓↓↓

Отправить сообщение

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



©2015-2024 megaobuchalka.ru Все материалы представленные на сайте исключительно с целью ознакомления читателями и не преследуют коммерческих целей или нарушение авторских прав. (207)

Почему 1285321 студент выбрали МегаОбучалку...

Система поиска информации

Мобильная версия сайта

Удобная навигация

Нет шокирующей рекламы



(0.009 сек.)