Опубликован: 05.01.2015 | Доступ: свободный | Студентов: 2178 / 0 | Длительность: 63:16:00
Лекция 3:

Элементарные структуры данных

Простые операции со списками

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

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

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

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

Определение 3.3. Связный список представляет собой либо пустую ссылку, либо ссылку на узел, который содержит элемент и ссылку на связный список.

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

Одна из наиболее распространенных операций со списками - обход (traverse). Это последовательный перебор элементов списка и выполнение некоторых операций с каждым из них. Например, если x является указателем на первый узел списка, последний узел содержит пустой указатель, а visit - процедура, которая принимает элемент в качестве аргумента, то обход списка можно реализовать следующим образом:

for (link t = x; t != 0; t = t->next) visit(t->item);
        

Этот цикл (либо его эквивалентная форма while) постоянно встречается в программах обработки списков, подобно циклу for (int i = 0; i < N; i++) в программах обработки массивов. Программа 3.10 является реализацией простой задачи обработки списка - изменение порядка следования узлов на обратный. Она принимает связный список в качестве аргумента и возвращает связный список, состоящий из тех же узлов, но расположенных в обратном порядке.

Программа 3.10. Обращение списка

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

link reverse(link x)
  { link t, y = x, r = 0;
    while (y != 0)
      { t = y->next; y->next = r; r = y; y = t; }
    return r;
  }
        
 Обращение списка

Рис. 3.7. Обращение списка

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

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

Программа 3.11 служит реализацией другой задачи обработки списков: переупорядочение узлов по возрастанию их элементов. Она генерирует N случайных целых чисел, помещает их в список в порядке появления, а затем сортирует узлы по возрастанию элементов и выводит полученную последовательность. Как будет показано в "Элементарные методы сортировки" , ожидаемое время выполнения программы пропорционально N 2, поэтому она непригодна для больших значений N. Обсуждение темы сортировки также откладывается до "Элементарные методы сортировки" , поскольку в главах 6-10 будет рассмотрено множество методов сортировки. А сейчас нам просто нужен пример приложения, выполняющего обработку списков.

Списки в программе 3.11 демонстрируют еще одно часто используемое соглашение: в начале каждого списка содержится фиктивный узел, называемый ведущим (head node). Поле элемента ведущего узла игнорируется, а его ссылка всегда содержит указатель на узел с первым элементом списка. В программе используется два списка: один для накопления случайных чисел, добавляемых в первом цикле, а другой для накопления сортированного результата во втором цикле. На рис. 3.8 показаны изменения, вносимые программой 3.11 в течение одной итерации главного цикла. Из входного списка извлекается очередной узел, находится его позиция в выходном списке, и узел вставляется в эту позицию.

Программа 3.11. Сортировка методом вставки в список

Этот код генерирует N случайных целых чисел в диапазоне от 0 до 999, строит связный список, в котором на каждый узел приходится по одному числу (первый цикл for), затем переупорядочивает узлы, чтобы при обходе списка числа следовали по возрастанию (второй цикл for). Для выполнения сортировки используется два списка - входной (несортированный) и выходной (сортированный). В каждой итерации цикла из входного списка удаляется узел и вставляется в нужную позицию выходного списка. Код упрощен благодаря использованию в каждом списке ведущих узлов, содержащих ссылки на первые узлы. В объявлениях ведущих узлов применен конструктор, поэтому их данные инициализируются при создании.

node heada(0, 0); link a = &heada, t = a;
for (int i = 0; i < N; i++)
  t = (t->next = new node(rand() % 1000, 0));
node headb(0, 0); link u, x, b = &headb;
for (t = a->next; t != 0; t = u)
  { u = t->next;
    for (x = b; x->next != 0; x = x->next)
      if (x->next->item > t->item) break;
    t->next = x->next; x->next = t;
  }
        
 Сортировка связного списка

Рис. 3.8. Сортировка связного списка

На этой диаграмме показан один шаг преобразования неупорядоченного связного списка (заданного указателем a) в упорядоченный связный список (заданный указателем b) с использованием сортировки вставками. Сначала берется первый узел неупорядоченного списка, и указатель на него сохраняется в t (вверху). Затем выполняется поиск в b первого узла x, для которого справедливо условие x->next->item > t->item (или x->next = NULL), и t вставляется в список после x (в середине). Эти операции уменьшают на один узел размер списка a и увеличивают на один узел размер списка b, сохраняя список b упорядоченным (внизу). После завершения цикла список a окажется пустым, а список b будет содержать все узлы в упорядоченном виде.

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

  • Дублировать цикл for, который обнаруживает наименьший элемент, и создавать список из одного узла таким же образом, как в программе 3.9.
  • Перед каждой вставкой узла проверять, не является ли список вывода пустым.
  • Использовать фиктивный ведущий узел, ссылка которого указывает на первый узел списка, как в данном примере.

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

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

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

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

В программе 3.12 объявлен набор функций в виде "черного ящика", которые реализуют базовый список операций. Это позволит нам не повторять фрагменты кода в тексте программ и не зависеть от деталей реализации. Программа 3.13 реализует выбор Иосифа (см. программу 3.9), но уже в виде клиентской программы, использующей этот интерфейс. Идентификация важных операций, используемых в вычислениях, и их описание в интерфейсе обеспечивают гибкость, которая позволяет рассматривать различные конкретные реализации важных операций и проверять их эффективность.

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

Таблица 3.1. Соглашения о первом и последнем узлах в связных списках
Циклический список, всегда непустой
первая вставка: head->next = head;
вставка t после х: t->next = x->next; x->next = t;
удаление после х: x->next = x->next->next;
цикл обхода: t = head;
do { ... t = t->next; } while (t != head);
проверка на наличие лишь одного элемента: if (head->next == head)
Указатель на начало, пустой указатель в конце
инициализация: head = 0;
вставка t после х: if (x == 0) {head = t; head->next = 0; }
{ t->next = x->next; x->next = t; }
удаление после х: t = x->next; x->next = t->next;
цикл обхода: for (t = head; t != 0; t = t->next)
проверка на пустоту: if (head == 0)
Фиктивный ведущий узел, пустой указатель в конце
инициализация: head = new node; head->next = 0;
вставка t после х: t->next = x->next; x->next = t;
удаление после х: t = x->next; x->next = t->next;
цикл обхода: for (t = head->next; t != 0; t = t->next)
проверка на пустоту: if (head->next == 0)
Фиктивные ведущий и завершающий узлы
инициализация: head = new node;
z = new node;
head->next = z; z->next = z;
вставка t после х: t->next = x->next; x->next = t;
удаление после х: x->next = x->next->next;
цикл обхода: for (t = head->next; t != z; t = t->next)
проверка на пустоту: if (head->next == z)

Программа 3.12. Интерфейс обработки списков

В этом коде, который можно сохранить в интерфейсном файле list.h, описаны типы узлов и ссылок, а также выполняемые над ними операции. Для выделения памяти под узлы списка и ее освобождения объявляются собственные функции. Функция construct применена для удобства реализации. Эти описания позволяют клиентам использовать узлы и связанные с ними операции без зависимости от подробностей реализации. Как будет показано в "Абстрактные типы данных" , несколько отличный интерфейс, основанный на классах C++, может обеспечить независимость клиентских программ от подробностей реализации.

typedef int Item;
struct node { Item item; node *next; };
typedef node *link;
typedef link Node;
void construct(int);
Node newNode(int);
void deleteNode(Node);
void insert(Node, Node);
Node remove(Node);
Node next(Node);
Item item(Node);

        

Программа 3.13. Организация списка для задачи Иосифа

Эта программа решения задачи Иосифа служит примером клиентской программы, использующей примитивы обработки списков, которые объявлены в программе 3.12 и реализованы в программе 3.14.

#include <iostream.h>
#include <stdlib.h>
#include "list.h"
int main(int argc, char *argv[])
  { int i, N = atoi(argv[1]), M = atoi(argv[2]);
    Node t, x;
    construct(N);
    for (i = 2, x = newNode(1); i <= N; i++)
      { t = newNode(i); insert(x, t); x = t; }
    while (x != next(x))
      { for (i = 1; i < M; i++) x = next(x);
        deleteNode(remove(x));
      }
    cout << item(x) << endl;
    return 0;
  }
        

В разделе 3.5 рассматривается реализация операций из программы 3.12 (см. программу 3.14), но можно опробовать и другие решения, не изменяя программу 3.13 (см. упражнение 3.51). Эта тема еще будет неоднократно затронута в данной книге. В языке C++ имеется несколько механизмов, специально предназначенных для упрощения разработки инкапсулированных реализаций; речь об этом пойдет в "Абстрактные типы данных" .

Некоторые программисты предпочитают инкапсулировать все операции в низкоуровневых структурах данных, таких как связные списки, путем описания функция для каждой низкоуровневой операции в интерфейсах, подобных показанному в программе 3.12. Действительно, как будет продемонстрировано в "Абстрактные типы данных" , классы C++ позволяют легко это сделать. Однако такой дополнительный уровень абстракции иногда скрывает факт использования лишь небольшого количества операций низкого уровня. В данной книге при реализации высокоуровневых интерфейсов низкоуровневые операции со связными структурами обычно записываются непосредственно, чтобы были четко видны важные подробности алгоритмов и структур данных. Множество примеров мы увидим в "Абстрактные типы данных" .

С помощью дополнительных ссылок можно реализовать возможность обратного перемещения по связному списку. Например, применение двухсвязного списка (double linked list) позволяет выполнять операцию "найти элемент, предшествующий данному". В таком списке каждый узел содержит две ссылки: одна (prev) указывает на предыдущий элемент, а другая (next) - на следующий. При наличии фиктивных узлов либо цикличного двухсвязного списка выражения x, x->next->prev и x->prev->next эквивалентны для каждого узла. На рис. 3.9 и 3.10 показаны основные действия со ссылками, необходимые для реализации операций удалить (remove), вставить после (insert after) и вставить перед (insert before) в двухсвязных списках. Обратите внимание, что для операции удаления не требуется дополнительной информации о предшествующем (либо следующем) узле в списке, как для односвязных списков - эта информация содержится в самом узле.

 Удаление в двухсвязном списке

Рис. 3.9. Удаление в двухсвязном списке

Как видно из диаграммы, для удаления узла в двухсвязном списке достаточно знать указатель на этот узел. Для заданного t в t->next->prev заносится значение t->prev (в середине), а в t->prev->next - значение t->next (внизу).

 Вставка в двухсвязном списке

Рис. 3.10. Вставка в двухсвязном списке

Для вставки узла в двухсвязный список необходимо установить четыре указателя. Новый узел можно вставить после данного узла (как показано на диаграмме), либо перед ним. Для вставки узла t после узла x в t->next заносится значение x->next, а в x->next->prev - значение t (в середине). Затем в x->next заносится значение t, а в t->prev - значение x (внизу).

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

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

Упражнения

3.33. Напишите функцию, которая перемещает наибольший элемент данного списка в конец списка.

3.34. Напишите функцию, которая перемещает наименьший элемент данного списка в начало списка.

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

3.36. Реализуйте фрагмент кода для связного списка, меняющий местами узлы, которые следуют после узлов, указываемых ссылками t и u.

3.37. Напишите функцию, которая принимает ссылку на список в качестве аргумента и возвращает ссылку на копию списка (новый список, содержащий те же элементы в том же порядке).

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

3.39. Выполните упражнение 3.38, но создайте копии узлов, которые прошли проверку и возвратите ссылку на список, содержащий эти узлы, в порядке их следования в исходном списке.

3.40. Реализуйте версию программы 3.10, в которой используется ведущий узел.

3.41. Реализуйте версию программы 3.11, в которой не используются ведущие узлы.

3.42. Реализуйте версию программы 3.9, в которой используется ведущий узел.

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

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

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

Бактыгуль Асаинова
Бактыгуль Асаинова

Здравствуйте прошла курсы на тему Алгоритмы С++. Но не пришел сертификат и не доступен.Где и как можно его скаачат?

Александра Боброва
Александра Боброва

Я прошла все лекции на 100%.

Но в https://www.intuit.ru/intuituser/study/diplomas ничего нет.

Что делать? Как получить сертификат?