Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
kernigan_paik.doc
Скачиваний:
0
Добавлен:
01.07.2025
Размер:
2.91 Mб
Скачать

2.7. Списки

По своей встречаемости в типичных программах списки занимают второе место после массивов. Многие языки имеют встроенные типы списков, некоторые, такие как Lisp, даже построены на них, но в языке С мы должны конструировать их самостоятельно. В C++ и Java работа со списками поддерживается стандартными библиотеками, но и в этом случае нужно знать их возможности и типичные применения. В данном параграфе мы собираемся обсудить использование списков в С, но уро­ки из этого обсуждения можно извлечь и для более широкого примене­ния.

Простым цепным списком (single-linked list) называется последова­тельность элементов, каждый из которых содержит данные и указатель на следующий элемент. Головой списка является указатель на первый элемент, а конец помечен нулевым указателем. Ниже показан список из четырех элементов:

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

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

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

Вместо того чтобы определить специальный тип List, обычно в С на­чинают определение списка с описания типа для элементов, вроде наше­го Nameval, и добавляют в него указатель на следующий элемент:

typedef struct Nameval Nameval;

struct Nameval {

char *name;

int value;

Nameval *next; /* следующий в списке */

};

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

назвали newitem:

/* newitem: создать новый элемент по полям пате и value */

Nameval *newitem(char *name, int value)

{

Nameval *newp;

newp = (Nameval *) emalloc(sizeof(Nameval));

newp->name = name;

newp->value = value;

newp->next = NULL;

return newp;

}

Функцию emalloc мы будем использовать и далее во всей книге; она вызывает malloc, а при ошибке выделения памяти выводит сообщение и завершает программу. Мы представим код этой функции в главе 4, а пока считайте, что эта функция всегда корректно и без сбоев выделяет память.

Простейший и самый быстрый способ собрать список — это добав­лять новые элементы в его начало:

/* addfront: добавить элемент newp в начало списка listp */

Nameval *addfront(Nameval *listp, Nameval *newp)

{

newp->next = listp;

return newp;

}

При изменении списка у него может измениться первый элемент, что и происходит при вызове addf ront. Функции, изменяющие список, должны возвращать указатель на новый первый элемент, который хра­нится в переменной, указывающей на список. Функция addf ront и дру­гие функции этой группы передают указатель на первый элемент в ка­честве возвращаемого значения; вот типичное использование таких функций:

nvlist = addfront(nvlist, newitem("smiley", Ox263A));

Такая конструкция работает, даже если существующий список пуст (NULL), она хороша и тем, что позволяет легко объединять вызовы функций в выра­жениях. Это более естественно, чем альтернативный вариант — передавать указатель на указатель на голову списка.

Добавление элемента в конец списка — процедура порядка О(п), по­скольку нам нужно пройтись по всему списку до конца:

/* addend: добавить элемент newp в конец списка listp */

Nameval *addend(Nameval *listp, Nameval *newp)

{

Nameval *p;

if (listp == NULL)

return newp;

for (p = listp; p->next != NULL; p = p->next):

p->next = newp;

return listp;

}

Чтобы сделать addend операцией порядка O(1), мы могли бы завести от­дельный указатель на конец списка. Недостаток этого подхода, кроме того, что нам нужно заботиться о корректности этого указателя, состоит в том, что список теперь уже представлен не одной переменной, а двумя. Мы будем придерживаться более простого стиля.

Для поиска элемента с заданным именем нужно пройтись по указате­лям next:

/* lookup: последовательный поиск имени в списке */

Nameval- *lookup(Nameval *listp, char *name)

{

for ( ; listp != NULL; listp = listp->next)

if (strcmp(name, listp->name) == 0)

return listp;

return NULL; } /* нет совпадений */ }

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

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

/* apply: применить функцию fn

для каждого элемента списка listp */

void apply(Nameval *listp,

void (*fn)(Nameval*, void*), void *arg)

{

for ( ; listp != NULL; listp = listp->next)

(*fn)(listp, arg); /* вызов функции */ }

Второй аргумент apply — указатель на функцию, которая принимает два параметра и возвращает void. Стандартный, хотя и весьма неуклюжий, синтаксис

void (*fn)(Nameval*, void*)

определяет fn как указатель на функцию с возвращаемым значением типа void, то есть как переменную, содержащую адрес функции, которая воз­вращает void. Функция имеет два параметра — типа Nameval * (элемент списка) и void * (обобщенный указатель на аргумент для этой функции). Для использования apply, например для вывода элементов списка, мы можем написать тривиальную функцию, параметр которой будет вос­приниматься как строка форматирования:

/* printnv: вывод имени и значения

с использованием формата в arg */

void printnv(Nameval *p, void *arg)

{

char *fmt;

fmt = (char *) arg;

printf(fmt, p->name, p->value);

}

тогда вызывать мы ее будем так:

apply(nvlist, printnv, "%s: %x\n");

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

/* inccounter: увеличить счетчик *arg */

void inccounter(Nameval *p, void *arg)

{

int *ip;

/* p не используется */

ip = (int *) arg;

(*ip)++; }

Вызывается она следующим образом:

int n;

n = 0;

apply(nvlist, inccouncer, &n);

printf("B nvlist %d элементов\n", n);

He каждую операцию над списками удобно выполнять таким об­разом. Например, при удалении списка надо действовать более ак­куратно:

/* freeall: освободить все элементы списка listp */

void frecall(Nameval *listp)

{

Nameval *next;

for ( ; listp != NULL; listp = next) {

next = listp->next;

/* считаем, что память, занятая строкой name,

освобождена где-то в другом месте */

free(listp);

}

}

Память нельзя использовать после того, как мы ее освободили, поэтому до освобождения элемента, на который указывает listp, указатель llistp->next нужно сохранить в локальной переменной next. Если бы I цикл, как и раньше, выглядел так:

? for ( ; listp != NULL; listp = listp->next)

? free(listp);

то значение listp->next могло быть затерто вызовом free и код бы не работал.

Заметьте, что функция freeall не освобождает память, выделенную под строку listp->name. Это подразумевает, что поле name каждого элемента типа Nameval было освобождено где-то еще либо память под него не была выделена. Чтобы обеспечить корректное выделение памяти под элементы и ее освобождение, нужно согласование работы newitem и f ree-all; это некий компромисс между гарантиями того, что память будет освобождена, и того, что ничего лишнего освобождено не будет. Именно здесь при неграмотной реализации часто возникают ошибки. В других языках, включая Java, данную проблему за вас решает сборка мусора. К теме управления ресурсами мы еще вернемся в главе 4.

Удаление одного элемента из списка — более сложный процесс, чем добавление:

/* delitSm: удалить первое вхождение "name" в listp */

Nameval *delitem(Nameval *listp, char *name)

{

Nameval *p, *prev;

prev = NULL;

for (p = listp; p != NULL; p = p->next) {

if (strcmp(name, p->name) == 0) {

if (prev == NULL)

listp = p->next;

else

prev->next = p->next;

free(p); return listp;

}

prev = p;

}

eprintf("delitem: %s в списке отсутствует", name);

return NULL; /* сюда не дойдет */

}

Как и в f reeall, delitem не освобождает память, занятую полем name.

Функция eprintf выводит сообщение об ошибке и завершает про­грамму, что в лучшем случае неуклюже. Грамотное восстановление пос­ле произошедших ошибок может быть весьма трудным и требует долго­го обсуждения, которое мы отложим до главы 4, где покажем также реализацию eprintf.

Представленные основные списочные структуры и операции приме­нимы в подавляющем большинстве случаев, которые могут встретиться в ваших программах. Однако есть много альтернатив. Некоторые библио­теки, включая библиотеку стандартных шаблонов (Standard Template Library, STL) в C++, поддерживают двухсвязные списки (double-linked lists: списки с двойными связями), в которых у каждого элемента есть два указателя: один — на последующий, а другой — на предыдущий элемент. Двухсвязные списки требуют больше ресурсов, но поиск последнего эле­мента и удаление текущего — операции порядка О( 1). Иногда память под указатели списка выделяют отдельно отданных, которые они связывают; такие списки несколько труднее использовать, но зато одни и те же эле­менты могут встречаться более чем в одном списке одновременно.

Кроме того, что списки годятся для ситуации, когда происходят уда­ления и вставки элементов в середине, они также хороши для управле­ния данными меняющегося размера, особенно когда доступ к ним проис­ходит по принципу стека: последним вошел, первым вышел (last in, first out — LIFO). Они используют память эффективнее, чем массивы, при наличии нескольких стеков, которые независимо друг от друга растут и уменьшаются. Они также хороши в случае, когда информация внут­ренне связана в цепочку неизвестного заранее размера, например как последовательность слов в документе. Однако если вам нужны как час­тые обновления, так и случайный доступ к данным, то разумнее будет использовать не такую непреклонно линейную структуру данных, а что-нибудь вроде дерева или хэш-таблицы.

Упражнение 2-7

Реализуйте некоторые другие операции над списком: копирование, слияние, разделение списка, вставку до или после указанного элемента. Как эти две операции вставки отличаются по сложности? Много ли вы можете использовать из того, что мы написали, и много ли вам надо на­писать самому?

Соседние файлы в предмете [НЕСОРТИРОВАННОЕ]