Структуры данных: связанные списки

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

  1. Хэш-карты
  2. Связанные списки
  3. Деревья
  4. Стеки и очереди
  5. Кучи
  6. Графики

Что такое связанный список?

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

Вы можете думать об указателе как об адресе памяти следующего узла или как о самом следующем узле. Первый узел в связанном списке называется Head или Root узлом. Связанный список будет иметь указатель, указывающий на головной узел, чтобы узнать, где начинается список. У последнего узла в связанном списке следующий указатель установлен в нуль.

В чем разница между массивами и связными списками?

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

Если мы хотим получить доступ к третьему элементу в указанном выше массиве, мы можем просто сказать array[2] и иметь возможность получить доступ к нему за постоянное время O (1).

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

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

Зачем использовать связанный список поверх массива?

  1. Связанные списки экономят память: связанный список выделяет только память, необходимую для хранения значений. В массивах вы должны установить размер массива, прежде чем заполнять его значениями, что потенциально приводит к потере памяти.
  2. Вставки и удаления в связанных списках могут выполняться очень быстро.
  • Если вы хотите вставить или удалить элемент в начале связанного списка, это можно сделать за постоянное время O (1).
  • Если вы хотите добавить или удалить элемент в конце списка, вам нужно пройти по списку до самого последнего элемента, а затем добавить или удалить. Это займет линейное время O (n).

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

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

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

Связанные списки в Python

Давайте посмотрим, как реализовать односвязный список. Сначала нам нужно создать класс Node:

class Node:
  def __init__(self, data=None):
    self.data = data
    self.next = None

Когда у нас есть класс Node, мы можем реализовать наш класс LinkedList:

Глава списка

Во-первых, мы должны назначить узел head; верхняя часть нашего списка. Когда список инициализируется впервые, он не имеет узлов, поэтому для заголовка установлено значение None.

class LinkedList:
  def __init__(self, head=None):
    self.head = head

Вставка

Теперь мы хотим иметь возможность добавлять узлы в наш связанный список. Нам нужен метод вставки, который берет данные и добавляет их в список. Мы можем вставить узел в любом месте списка, но самый простой и быстрый способ - поместить его в начало списка и указать новый узел в старом заголовке. Это занимает O (1) по временной сложности.

def insertAtHead(self, data):
  new_node = Node(data)
  new_node.next = self.head
  self.head = new_node  

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

def insertAtTail(self, data):
  new_node = Node(data)
  current = self.head
  while current.next != None:
    current = current.next
  current.next = new_node

Длина

Определить длину нашего связанного списка довольно просто. Мы начинаем с головы и используем счетчик, который увеличивается после каждого найденного узла. Как только мы не можем найти другой узел next = None, мы возвращаем счетчик. Временная сложность для этого метода составляет O (n), потому что мы должны посетить каждый отдельный узел, чтобы его посчитать.

def length(self):
  current = self.head
  count = 0
  if current != None:
    count += 1
    while current.next != None:
      count += 1
      current = current.next
  return count

Поиск

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

def search(self, data):
  current = self.head
  while current:
    if current.data == data:
      return current
    else:
      current = current.next
  return None

Удалить

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

def delete(self, data):
  current = self.head
  previous = None
  while current:
    if current.data == data:
      if previous is None:
        self.head = current.next
      else:
        previous.next = current.next
    else:
      previous = current
      current = current.next

Другой способ реализации удаления - это фактическое изменение данных, хранящихся в узле. Сначала мы ищем узел, который хотим удалить, после нахождения мы меняем его данные на данные следующего узла. Затем мы меняем найденный узел next на его next.next. Таким образом, нам не нужно отслеживать предыдущий узел.

def delete(self, data):
  current = self.head
  while current:
    if current.data == data:
      current.data = current.next.data
      current.next = current.next.next
    else:
     current = current.next

Переворачивание связного списка

Чтобы перевернуть связанный список, нам нужно пройти через весь список и поменять местами указатели. Это займет O (n) линейного времени. Как и в первой реализации удаления, нам нужно отслеживать предыдущий узел и текущий узел. Чтобы реализовать реверс, нам также нужно отслеживать следующий узел.

def reverse(self):
  previous = None
  current = self.head
  while current:
    temp = current.next
    current.next = previous
    previous = current
    current = temp
  self.head = previous

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

Затем мы проверяем, является ли следующий узел node None:

  1. Если да, это означает, что мы достигли конца связанного списка. Установите указатель головы на этот узел.
  2. Если нет, передайте следующий узел node обратному методу.

Как только будет достигнут последний узел, произойдет разворот.

def recursiveReverse(self, node):
  if node.next == None:
    self.head = node
    return
  self.recursiveReverse(node.next)
  temp = node.next 
  temp.next = node
  node.next = None 

Заключение

Вот шпаргалка для сравнения временной сложности общих операций со связанными списками и массивами:

Спасибо за прочтение!

использованная литература