Освоение структур данных в Ruby — постоянные списки

В завершение этой серии я хочу ввести понятие постоянные структуры данных , термин, введенный Driscoll et al. в статье 1986 года, что в наши дни он лежит в основе большинства чисто функциональных структур данных.

Впервые я услышал этот термин в докладе Рича Хикки, создателя Clojure, где он показал, как структуры данных Closure внутренне справляются с мутациями (точнее, с их отсутствием).

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

Давайте начнем с рассмотрения того, как работают мутации.

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

> list = (1)
> list.insert(2)
> list.print(1, 2)

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

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

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

> list1 = List.new(1)
> list2 = List.insert(list1, 2)
> list1.print(1)
> list2.print(1, 2)

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

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

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

Интерфейс постоянного списка

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

Методы

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

Детали реализации

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

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

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

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

Пустой

Этот метод создает пустой список. Его сложность O (1).

def self.empty
  list = LinkedList.new
  list.freeze
end

Вставлять

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

Так как мы должны скопировать весь список сложность этого метода O (n).

def self.insert list, data
  ret = self.copy list
  ret.insert data
  ret.freeze
end

Обновлять

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

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

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

Сложность этого метода O(n) где n — количество элементов от головы до целевого узла.

def self.update list, node, data
  ret = LinkedList.new
  reuse = false
  found = false
  list.each do |nd|
    unless found
      found = (nd.data == node.data)
      if found
        ret.insert(data)
        reuse = true
        next
      end
    end
    unless reuse
      ret.insert(data)
    else # Reuse nodes from target to tail.
      ret.reuse_from_node(nd)
        break
    end
  end
  ret.freeze
end

Удалять

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

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

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

Сложность этого метода O(n) где n — количество элементов от головы до целевого узла.

def self.remove list, node
  ret = LinkedList.new
  reuse = false
  found = false
  list.each do |nd|
    unless found
      found = (nd.data == node.data)
      if found
        reuse = true
        next # skip the target node.
      end
    end
    unless reuse
      ret.insert(nd.data)
    else # Reuse nodes from target to tail.
      ret.reuse_from_node(nd)
      break
    end
  end
  ret.freeze
end

Кошка

Этот метод создает список только для чтения, который содержит копии всех узлов из LHS (слева) и RHS (справа).

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

Сложность этого метода O(n) где n — длина LHS.

def self.cat lhs, rhs
  ret = self.copy lhs
  ret.cat rhs
  ret.freeze
 end

Лен

Этот метод возвращает длину данного списка.

Поскольку внутренний список имеет атрибут, содержащий эту информацию, сложность этого метода O (1).

def self.len list
  list&.length || 0
end

Найти первым

Этот метод находит первое вхождение данного предиката, примененного к указанному списку.

Сложность этого метода составляет O(n).

def self.find_first list, &predicate
  return nil unless list
  return list.find_first &predicate
end

Каждый

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

Сложность этого метода O(n).

def self.each list, &block
  return nil unless list
  list.each &block
end

Распечатать

Этот метод печатает содержимое указанного списка. Сложность этого метода составляет O(n).

def self.print list
  unless list
    print "empty"
  else
    list.print
  end
end

Копировать

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

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

Сложность этого метода составляет O(n).

private
def self.copy src
  dst = LinkedList.new
  src.each { |node| dst.insert node.data }
  dst
end

Когда использовать постоянные структуры данных

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

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

Итак, это краткое введение в постоянные структуры данных. Я надеюсь, что вам понравилось!

Как обычно, вы можете получить код от здесь.

Спасибо за чтение! Надеюсь, поможет.

PS: если вы являетесь участником программы Kindle Unlimited, вы можете прочитать немного отполированную версию этой серии бесплатно на вашем устройстве Kindle.


Структуры данных с нуля — Ruby Edition.

Похожие записи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *