Я решил написать односвязный список, и у меня был план сделать внутреннюю структуру узлов неизменной.
Я столкнулся с загадкой, хотя. Скажем, у меня есть следующие связанные узлы (из предыдущих add
операций):
1 -> 2 -> 3 -> 4
и сказать, что я хочу добавить 5
.
Чтобы сделать это, так как узел 4
является неизменным, мне нужно создать новую копию 4
, но заменить его next
поле новым узлом, содержащим 5
. Проблема сейчас в том, чтобы 3
ссылаться на старое 4
; тот, без добавления 5
. Теперь мне нужно скопировать 3
и заменить его next
поле для ссылки на 4
копию, но теперь 2
ссылается на старый 3
...
Или, другими словами, чтобы добавить, кажется, что весь список нужно скопировать.
Мои вопросы:
Правильно ли мое мышление? Есть ли способ сделать дополнение, не копируя всю структуру?
Видимо "Эффективная Java" содержит рекомендацию:
Классы должны быть неизменяемыми, если только нет веской причины сделать их изменяемыми ...
Это хороший случай для изменчивости?
Я не думаю, что это дубликат предлагаемого ответа, так как я не говорю о самом списке; очевидно, что он должен быть изменяемым, чтобы соответствовать интерфейсу (без необходимости делать что-то вроде внутреннего хранения нового списка и извлечения его через геттер. Однако, подумав, даже это потребует некоторой мутации; он просто будет сведен к минимуму). Я говорю о том, должны ли внутренности списка быть неизменными.
источник
CopyOnWritexxx
классы, используемые для многопоточности. Никто не ожидает, что коллекции действительно будут неизменными (хотя это создает некоторые причуды)Ответы:
Со списками на функциональных языках вы почти всегда работаете с головой и хвостом, первым элементом и остальной частью списка. Предварительное добавление встречается гораздо чаще, потому что, как вы уже догадались, добавление требует копирования всего списка (или других ленивых структур данных, которые точно не похожи на связанный список).
В императивных языках добавление встречается гораздо чаще, потому что семантически кажется более естественным, и вас не волнует недействительность ссылок на предыдущие версии списка.
В качестве примера того, почему предварительное заполнение не требует копирования всего списка, рассмотрим:
Предъявление
1
дает вам:Но обратите внимание, что это не имеет значения, если кто-то еще все еще держит ссылку в
2
качестве главы своего списка, потому что список неизменен, и ссылки идут только в одну сторону. Там нет никакого способа сказать, что1
даже там, если у вас есть только ссылка на2
. Теперь, если вы добавили a5
в любой из списков, вам нужно будет сделать копию всего списка, потому что в противном случае он появится и в другом списке.источник
Вы правы, добавление требует копирования всего списка, если вы не хотите изменять какие-либо узлы на месте. Поскольку нам нужно установить
next
указатель (теперь) второго до последнего узла, который в неизменяемом параметре создает новый узел, а затем нам нужно установитьnext
указатель третьего до последнего узла и так далее.Я думаю, что основная проблема здесь не в неизменности, и при этом
append
операция не опрометчива. Оба прекрасно в своих доменах. Смешивать их плохо: естественный (эффективный) интерфейс для неизменяемого списка подчеркивает манипуляцию в начале списка, но для изменяемых списков часто более естественно построить список путем последовательного добавления элементов от первого к последнему.Поэтому я предлагаю вам принять решение: вы хотите эфемерный интерфейс или постоянный? Создают ли большинство операций новый список и оставляют ли неизмененную версию доступной (постоянной), или вы пишете такой код (эфемерный):
Оба варианта хороши, но реализация должна отражать интерфейс: постоянная структура данных выигрывает от неизменных узлов, в то время как эфемерная требует внутренней изменчивости, чтобы фактически выполнить обещания производительности, которые она неявно дает.
java.util.List
и другие интерфейсы эфемерны: внедрение их в неизменяемый список неуместно и фактически представляет угрозу для производительности. Хорошие алгоритмы на изменяемых структурах данных часто сильно отличаются от хороших алгоритмов на неизменяемых структурах данных, поэтому преобразование неизменяемой структуры данных в изменчивую (или наоборот) требует плохих алгоритмов.Хотя постоянный список имеет некоторые недостатки (нет эффективного добавления), это не должно быть серьезной проблемой при функциональном программировании: многие алгоритмы могут быть эффективно сформулированы путем изменения мышления и использования функций более высокого порядка, таких как
map
илиfold
(чтобы назвать две относительно примитивные ), или добавив несколько раз. Более того, никто не заставляет вас использовать только эту структуру данных: когда другие (эфемерные или постоянные, но более сложные) более уместны, используйте их. Следует также отметить, что постоянные списки имеют некоторые преимущества для других рабочих нагрузок: они разделяют свои хвосты, что может экономить память.источник
Если у вас есть односвязный список, вы будете работать с фронтом, если он больше, чем с тылом.
Функциональные языки, такие как prolog и haskel, предоставляют простые способы получить передний элемент и остальную часть массива. Присоединение к задней части - операция O (n) с копированием каждого узла.
источник
List
интерфейс (хотя я мог ошибаться там). Я не думаю, что этот бит действительно отвечает и на вопрос. Весь список все еще должен быть скопирован; это только сделало бы доступ к последнему добавленному элементу быстрее, так как полный обход не требовался.java.util.List
Как уже отмечали другие, вы правы, что неизменный односвязный список требует копирования всего списка при выполнении операций добавления.
Часто вы можете использовать обходной путь реализации вашего алгоритма в терминах
cons
(предварительных) операций, а затем один раз перевернуть окончательный список. Для этого все еще необходимо скопировать список один раз, но накладные расходы на сложность линейны по длине списка, тогда как при многократном использовании команды добавления вы можете легко получить квадратичную сложность.Списки различий (см., Например, здесь ) являются интересной альтернативой. Список различий оборачивает список и обеспечивает операцию добавления в постоянное время. В основном вы работаете с оберткой столько времени, сколько вам нужно добавить, а затем по окончании конвертировать обратно в список. Это как-то похоже на то, что вы делаете, когда вы используете a
StringBuilder
для создания строки и в конце получаете результат какString
(неизменяемый!), ВызываяtoString
. Одно из отличий состоит в том, чтоStringBuilder
переменная переменная, но список различий неизменен Кроме того, когда вы преобразуете список различий обратно в список, вам все равно нужно создать весь новый список, но, опять же, вам нужно сделать это только один раз.Должно быть довольно легко реализовать неизменный
DList
класс, который предоставляет интерфейс, аналогичный интерфейсу HaskellData.DList
.источник
Вы должны посмотреть это отличное видео из 2015 React conf создателя Immutable.js Ли Байрона. Это даст вам указатели и структуры, чтобы понять, как реализовать эффективный неизменяемый список, который не дублирует контент. Основные идеи заключаются в следующем: - пока два списка используют одинаковые узлы (одно и то же значение, один и тот же следующий узел), используется один и тот же узел - когда списки начинают различаться, в узле расхождения создается структура, которая содержит указатели на следующий конкретный узел каждого списка
Это изображение из учебника по реакции может быть более ясным, чем мой ломаный английский:
источник
Это не строго Java, но я предлагаю вам прочитать эту статью о неизменной, но производительной индексируемой постоянной структуре данных, написанной на Scala:
http://www.codecommit.com/blog/scala/implementing-persistent-vectors-in-scala
Поскольку это структура данных Scala, ее можно использовать и на Java (с немного большей детализацией). Он основан на структуре данных, доступной в Clojure, и я уверен, что есть и более «родная» библиотека Java, предлагающая ее.
Также обратите внимание на конструирование неизменяемых структур данных: вам обычно нужен своего рода «конструктор», который позволяет вам «мутировать» структуру данных «в стадии разработки», добавляя к ней элементы (в пределах одного потока); После того, как вы закончите добавление, вы вызываете метод объекта «в стадии разработки», такой как
.build()
или.result()
, который «строит» объект, давая вам неизменную структуру данных, которую вы затем можете безопасно разделить.источник
Подход, который иногда может быть полезен, состоит в том, чтобы иметь два класса объектов, содержащих список: объект прямого списка со
final
ссылкой на первый узел в списке с прямой связью и изначально нулевой не-final
ссылку, которая (когда не -null) идентифицирует объект обратного списка, содержащий те же элементы в обратном порядке, и объект обратного списка соfinal
ссылкой на последний элемент списка с обратной связью и изначально нулевой неконечной ссылкой, которая (если не -null) идентифицирует объект форвард-листа, содержащий те же элементы в обратном порядке.Для добавления элемента в прямой список или добавления элемента в обратный список потребуется просто создать новый узел, который ссылается на узел, идентифицированный
final
ссылкой, и создать новый объект списка того же типа, что и оригинал, соfinal
ссылкой к этому новому узлу.Добавление элемента в список пересылки или добавление в список пересылки потребует наличия списка противоположного типа; в первый раз, когда это делается с определенным списком, он должен создать новый объект противоположного типа и сохранить ссылку; повторение действия должно повторно использовать список.
Обратите внимание, что внешнее состояние объекта списка будет считаться одинаковым, независимо от того, является ли его ссылка на список противоположного типа нулевой или идентифицирует список противоположного порядка. Нет необходимости создавать какие-либо переменные
final
, даже при использовании многопоточного кода, поскольку каждый объект списка будет иметьfinal
ссылку на полную копию своего содержимого.Если код в одном потоке создает и кэширует обратную копию списка, а поле, в котором эта копия кэшируется, не является изменчивым, возможно, что код в другом потоке может не видеть кэшированный список, но это единственный неблагоприятный эффект Исходя из этого, другой поток должен был бы выполнить дополнительную работу по созданию другой перевернутой копии списка. Поскольку такие действия в худшем случае ухудшат эффективность, но не повлияют на правильность, а
volatile
переменные создают собственное снижение эффективности, часто лучше, чтобы переменная была энергонезависимой и принимала возможность случайных избыточных операций.источник