Алгоритмы и структуры данных
September 11, 2021

8. Списки. Линейные односвязные и двумерные списки. Класс LinkedList<T>.


1. Списки.

И снова мы возвращаемся к структурам данных.

Мы уже научились использовать массивы для решения самых разных задач, поговорили про их основные недостатки.

Разобрали достоинства и недостатки динамических массивов и структуры List.

Сегодня мы продолжим разговор о динамических структурах и познакомимся с понятием Список.

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

2. Линейные односвязные списки.

Основной недостаток обычных массивов, кроме фиксированного (или с большим трудом изменяемого размера) – высокая сложность операций вставки и удаления элементов О(n). Для решения двух этих проблем часто динамические структуры представляются в виде связных списков. Это не значит, что связные списки идеальны: приобретая что-то, мы чем-то жертвуем взамен. Но об этом чуть позже…

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

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

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

Основные операции над линейным односвязным списком:

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

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

3. Линейные двусвязные списки.

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

Продвижение в линейном двусвязном списке возможно в любом направлении. Операции вставки элемента в линейный двусвязный список и удаления элемента из линейного двусвязного списка реализуются аналогично соответствующим операциям над линейным односвязным списком.

4. Класс LinkedList<T>.

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

Класс LinkedList<T> представляет собой двухсвязный список, в котором каждый элемент ссылается на следующий и предыдущий, как показано на рисунке:

В LinkedList<T> каждый узел представляет объект класса LinkedListNode<T>.

Этот класс имеет следующие свойства:

  • Value: само значение узла, представленное типом T.
  • Next: ссылка на следующий элемент типа LinkedListNode<T> в списке. Если следующий элемент отсутствует, то имеет значение null.
  • Previous: ссылка на предыдущий элемент типа LinkedListNode<T> в списке. Если предыдущий элемент отсутствует, то имеет значение null.

Используя методы класса LinkedList<T>, можно обращаться к различным элементам, как в конце, так и в начале списка:

  • AddAfter (LinkedListNode<T> node, LinkedListNode<T> newNode): вставляет узел newNode в список после узла node.
  • AddAfter (LinkedListNode<T> node, T value): вставляет в список новый узел со значением value после узла node.
  • AddBefore (LinkedListNode<T> node, LinkedListNode<T> newNode): вставляет в список узел newNode перед узлом node.
  • AddBefore (LinkedListNode<T> node, T value): вставляет в список новый узел со значением value перед узлом node.
  • AddFirst (LinkedListNode<T> node): вставляет новый узел в начало списка.
  • AddFirst (T value): вставляет новый узел со значением value в начало списка.
  • AddLast (LinkedListNode<T> node): вставляет новый узел в конец списка.
  • AddLast (T value): вставляет новый узел со значением value в конец списка.
  • RemoveFirst(): удаляет первый узел из списка. После этого новым первым узлом становится узел, следующий за удаленным.
  • RemoveLast(): удаляет последний узел из списка.
  • Find(): возвращает ссылку на первый узел в списке, имеющий передаваемое значение. Если искомое значение отсутствует в списке, то возвращается пустое значение.
  • Remove(): удаляет из списка первый узел, содержащий передаваемое значение. Возвращает логическое значение true, если узел удален, т.е. если узел со значением обнаружен в списке и удален; в противном случае возвращает логическое значение false.

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

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

Давайте рассмотрим пример использования связных списков.

Фрагмент кода программы:

public static void Main(string[] args)
{
    // Создадим связный список
    LinkedList<string> link = new LinkedList<string>();

    // Добавим несколько элементов
    link.AddFirst("Alex");
    link.AddFirst("Djek");
    link.AddFirst("Bob");
    link.AddFirst("Doug");

    // Отобразим элементы в прямом направлении
    LinkedListNode<string> node;
    Console.WriteLine("Элементы коллекции в прямом направлении: ");
    node = link.First;
    while (node != null)
    {
        Console.Write(node.Value + "\t");
        node = node.Next;
    }

    // Отобразить элементы в обратном направлении
    Console.WriteLine("\n\nЭлементы коллекции в обратном направлении: ");
    node = link.Last;
    while (node != null)
    {
        Console.Write(node.Value + "\t");
        node = node.Previous;
    }
}

И еще один пример на двухсвязный список в действии:

class Person
{
    public string Name { get; set; }
}

internal class Program
{
    static void Main(string[] args)
    {
        LinkedList<Person> persons = new LinkedList<Person>();

        Person tom = new Person() {Name = "Tom"};
        persons.AddLast(tom);
        persons.AddLast(new Person() {Name = "John"});
        persons.AddFirst(new Person() {Name = "Bill"});

        LinkedListNode<Person> tomLink = persons.Find(tom);

        Console.WriteLine(tomLink.Previous.Value.Name);
        Console.WriteLine(tomLink.Next.Value.Name);
    }
}

Здесь создается и используется список для объектов класса Person.

Вопрос 1: Оцените сложность поиска элемента по его порядковому номеру в LinkedList.

Вопрос 2: Оцените сложность удаления последнего элемента в LinkedList.


Итоги занятия

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

А на следующем занятии мы заставим очень динамично двигаться не только шустрого зайчика и медленную черепашку, но и шарики-ролики в наших мозгах…