Чи є якийсь практичний спосіб, щоб пов'язана структура вузла була непорушною?


26

Я вирішив написати список, пов'язаний окремо, і склався план зробити внутрішню пов'язану структуру вузла незмінною.

Хоча я наткнувся на корч Скажіть, у мене є такі пов'язані вузли (з попередніх addоперацій):

1 -> 2 -> 3 -> 4

і сказати, що я хочу додати 5.

Для цього, оскільки вузол 4є незмінним, мені потрібно створити нову копію 4, але замінити його nextполе новим вузлом, що містить a 5. Проблема зараз 3полягає в посиланні на старе 4; той, що не додається 5. Тепер мені потрібно скопіювати 3і замінити його nextполе для посилання на 4копію, але тепер 2посилається на старе 3...

Або іншими словами, щоб зробити додавання, весь список, здається, потрібно скопіювати.

Мої запитання:

  • Чи правильно моє мислення? Чи є спосіб зробити додавання, не копіюючи всю структуру?

  • Мабуть, "Ефективна Java" містить рекомендації:

    Заняття повинні бути незмінними, якщо немає дуже вагомих причин зробити їх незмінними ...

    Це хороший випадок для змінності?

Я не думаю, що це дублікат запропонованої відповіді, оскільки я не кажу про сам список; це, очевидно, повинно бути виправданим, щоб відповідати інтерфейсу (не роблячи щось на зразок збереження нового списку внутрішньо та вилучення його за допомогою геттера. Хоча, по-друге, навіть це потребує мутації; він просто зведений до мінімуму). Я говорю про те, чи повинні внутрішні переліки бути непорушними чи ні.


Я б сказав, що так - рідкісні незмінні колекції на Java - це або мутаційні способи, які перекриваються для викидання, або неймовірно дорогі CopyOnWritexxxкласи, що використовуються для багатониткового нарізування. Ніхто не очікує, що колекції будуть непорушними (хоча це і створює деякі примхи)
звичайні

9
(Прокоментуйте цитату.) Ця велика цитата часто сильно не зрозуміла. Незмінність має бути застосована до ValueObject через його переваги , але якщо ви розвиваєтеся на Java, недоцільно використовувати незмінність у місцях, де очікується незмінність. Здебільшого в класі є певні аспекти, які повинні бути незмінні, і певні аспекти, які потрібно змінювати на основі вимог.
rwong

2
пов'язаний (можливо, дублікат): чи краще об'єкт колекції є незмінним? "Чи може без колективних накладних витрат ця колекція (клас LinkedList) бути представлена ​​як незмінна колекція?"
гнат

Чому б ви склали незмінний пов'язаний список? Найбільша перевага пов’язаного списку - це можливість змінити, чи не краще б вам бути з масивом?
Пітер Б

3
Не могли б ви допомогти мені зрозуміти ваші вимоги? Ви хочете мати незмінний список, який потрібно мутувати? Хіба це не протиріччя? Що ви маєте на увазі під "внутрішніми" списками? Ви маєте на увазі, що раніше додані вузли не повинні бути змінені пізніше, а лише дозволяти додавати до останнього вузла у списку?
null

Відповіді:


46

Зі списками на функціональних мовах ви майже завжди працюєте з головою та хвостом, першим елементом та рештою списку. Попередження набагато частіше, тому що, як ви вже здогадалися, для додавання потрібне копіювання всього списку (або інших лінивих структур даних, які не дуже нагадують зв'язаний список).

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

Як приклад того, чому попередньо не потрібно копіювати весь список, розгляньте, що у вас є:

2 -> 3 -> 4

Попередня подача 1дає вам:

1 -> 2 -> 3 -> 4

Але зауважте, що не має значення, чи хтось ще досі має посилання на 2голову свого списку, оскільки цей список незмінний, а посилання йдуть лише в одну сторону. Немає способу сказати, що 1існує, навіть якщо ви маєте лише посилання на нього 2. Тепер, якщо ви додали 5до будь-якого списку, вам доведеться зробити копію всього списку, бо в іншому випадку він також з’явиться в іншому списку.


А-а, хороша думка про те, чому для попередньої підготовки не потрібна копія; Я про це не думав.
Carcigenicate

17

Ви правильні, додавання вимагає копіювання всього списку, якщо ви не бажаєте мутувати будь-які вузли на місці. Бо нам потрібно встановити nextвказівник (тепер) другого-останнього вузла, який у незмінному налаштуванні створює новий вузол, а потім нам потрібно встановити nextвказівник третього-останнього вузла тощо.

Я думаю, що головна проблема тут - це незмінність, а також appendоперація нераціональна. Обидва цілком чудові у своїх областях. Змішування їх погано: Природний (ефективний) інтерфейс для непорушного списку підкреслює маніпулювання внизу списку, але для змінних списків часто більш природним є створення списку шляхом послідовного додавання елементів від першого до останнього.

Тому я пропоную вам подумати: чи хочете ви ефемерний інтерфейс чи стійкий? Чи більшість операцій створює новий список і залишає немодифіковану версію доступною (стійкою), або ви пишете такий код (ефемерний):

list.append(x);
list.prepend(y);

Обидва варіанти є чудовими, але реалізація повинна відображати інтерфейс: стійка структура даних отримує вигоду з незмінних вузлів, тоді як ефемерна потребує внутрішньої зміни, щоб реально виконати обіцянки продуктивності, які вона неявно дає. java.util.Listта інші інтерфейси є ефемерними: реалізація їх у незмінному списку є недоцільною та фактично загрожує продуктивності. Хороші алгоритми на змінних структурах даних часто сильно відрізняються від хороших алгоритмів на незмінних структурах даних, тому одягання незмінної структури даних як змінна (або навпаки) запрошує погані алгоритми.

Незважаючи на те, що постійний список має деякі недоліки (без ефективного додавання), це не повинно бути серйозною проблемою при функціональному програмуванні. Багато алгоритмів можуть бути сформульовані ефективно шляхом перемикання мислення та використання функцій вищого порядку, таких як mapабо fold(щоб назвати дві відносно примітивні функції) ), або випереджаючи повторно. Крім того, ніхто не змушує вас використовувати лише цю структуру даних: Коли інші (ефемерні, стійкі, але більш складні) є більш доцільними, використовуйте їх. Слід також зазначити, що постійні списки мають деякі переваги для інших навантажень: Вони діляться хвостиками, що може зберегти пам'ять.


4

Якщо у вас є окремо пов'язаний список, ви будете працювати з фронтом, якщо він більше, ніж ззаду.

Функціональні мови, такі як prolog та haskel, забезпечують прості способи отримати передній елемент та іншу частину масиву. До зворотного боку - операція O (n) з копією кожного вузла.


1
Я використовував Haskell. Афаїк, він також обходить частину проблеми, використовуючи лінь. Я роблю додатки, тому що думав, що саме цього очікує Listінтерфейс (я можу помилитися там). Я навіть не думаю, що цей біт насправді відповідає на питання. Весь список все ще потрібно буде скопіювати; це просто зробить доступ до останнього елемента, доданого швидше, оскільки повне проходження не потрібно.
Carcigenicate

Створення класу за інтерфейсом, який не відповідає базовій структурі / алгоритму даних, - це запрошення до болю та неефективності.
щурячий вирод

JavaDocs явно використовує слово "додати". Ви говорите, що краще ігнорувати це заради реалізації?
Carcigenicate

2
@Carcigenicate ні, я говорю, що помилка намагатися вписати в форму однолично пов'язаний список із незмінними вузламиjava.util.List
ratchet freak

1
З лінню вже не було б пов’язаного списку; немає списку, отже, немає мутації (додавання). Натомість є якийсь код, який генерує наступний елемент на запит. Але його не можна використовувати скрізь, де використовується список. А саме, якщо ви пишете метод на Java, який очікує мутувати список, який передається як аргумент, цей список повинен бути змінений в першу чергу. Генераторний підхід вимагає повної реструктуризації (реорганізації) коду, щоб він працював - і щоб було можливо фізично усунути цей список.
rwong

4

Як зазначали інші, ви впевнені, що незмінний однозв'язаний список вимагає копіювання всього списку під час виконання операцій додавання.

Часто ви можете використовувати шляхи реалізації свого алгоритму з точки зору cons(передбачуваних) операцій, а потім один раз перевернути остаточний список. Це все ще потрібно скопіювати один раз, але складність накладних даних є лінійною по довжині списку, тоді як, повторно використовуючи додавання, ви можете легко отримати квадратичну складність.

Переліки відмінностей (див., Наприклад, тут ) є цікавою альтернативою. Список різниць завершує список і забезпечує операцію додавання в постійний час. Ви в основному працюєте з обгорткою стільки, скільки вам потрібно буде додати, а потім перетворите назад у список, коли закінчите. Це якимось чином схоже на те, що ви робите, коли використовуєте a StringBuilderдля побудови рядка, і в кінці отримуєте результат у вигляді String(незмінного!) Виклику toString. Одна відмінність полягає в StringBuilderтому, що a є змінним, але список різниць є незмінним. Крім того, коли ви перетворюєте список різниць назад у список, вам все одно доведеться побудувати весь новий список, але, знову ж таки, вам потрібно це зробити лише один раз.

Реалізувати незмінний DListклас, який забезпечує аналогічний інтерфейс, як Haskell, має бути досить простим Data.DList.


4

Ви повинні подивитися це чудове відео з 2015 року React conf, створене творцем Immutable.js Лі Байроном. Це дасть вам покажчики та структури, щоб зрозуміти, як реалізувати ефективний незмінний список, який не дублює вміст. Основні ідеї: - поки два списки використовують однакові вузли (те саме значення, той самий наступний вузол), той самий вузол використовується, - коли списки починають відрізнятися, на вузлі розбіжності створюється структура, яка містить покажчики на наступний конкретний вузол кожного списку

Це зображення з навчального посібника може бути чіткішим, ніж моя зламана англійська:введіть тут опис зображення


2

Це не суто Java, але я пропоную вам прочитати цю статтю про незмінну, але стійку структуру даних, проіндексовану виконавцем, написану в Scala:

http://www.codecommit.com/blog/scala/implementing-persistent-vectors-in-scala

Оскільки це структура даних Scala, її можна використовувати і на Java (дещо більше деталізації). Він заснований на структурі даних, доступній в Clojure, і я впевнений, що є ще "рідна" бібліотека Java, яка пропонує її.

Крім того, примітка про побудову незмінних структур даних: те, що вам зазвичай потрібно, - це якийсь тип "Builder", який дозволяє "мутувати" свою структуру даних "недобудови", додаючи до неї елементи (в межах одного потоку); після завершення додавання ви викликаєте метод на об'єкт "недобудований", такий як .build()або .result(), який "будує" об'єкт, надаючи вам незмінну структуру даних, якою ви можете безпечно ділитися.


1
Там це питання про Java портах PersistentVector на stackoverflow.com/questions/15997996 / ...
James_pic

1

Підходом, який іноді може бути корисним, було б мати два класи об'єктів, що містять список - об'єкт списку вперед-переліку з finalпосиланням на перший вузол у списку, пов'язаному вперед, і початково-нульовий не- finalпосилання, який (коли не -null) ідентифікує об’єкт зворотного списку, що містить ті самі елементи в протилежному порядку, і об'єкт зворотного списку з finalпосиланням на останній елемент списку, пов'язаного з зворотним зв'язком, і початково-нульовий не остаточний посилання, який (коли не -null) ідентифікує об'єкт списку вперед, що містить ті самі елементи в протилежному порядку.

Попередження пункту до списку переадресації або додавання елемента до зворотного списку просто вимагатиме створення нового вузла, який посилається на вузол, ідентифікований finalпосиланням, та створення нового об'єкта списку того ж типу, що і оригінал, із finalпосиланням до цього нового вузла.

Для додавання елемента до списку переадресації або передпресування до зворотного списку потрібно мати список іншого типу; Перший раз, коли це робиться з певним списком, він повинен створити новий об'єкт протилежного типу та зберігати посилання; повторення дії має повторно використовувати список.

Зауважте, що зовнішній стан об'єкта списку вважатиметься однаковим, незалежно від того, чи є його посилання на список протилежного типу нульовим чи ідентифікує список протилежного порядку. Не потрібно створювати змінних final, навіть коли використовується багатопотоковий код, оскільки кожен об'єкт списку матиме finalпосилання на повну копію його вмісту.

Якщо код на одному потоці створює та кешує перевернуту копію списку, а поле, в якому кефікується ця копія, не є мінливим, можливо, що код в іншому потоці може не бачити список кешованих даних, але єдиний несприятливий ефект з цього могло б бути, що інший потік повинен буде виконати додаткову роботу, будуючи ще одну перевернуту копію списку. Оскільки така дія в гіршому випадку погіршить ефективність, але не вплине на правильність, і оскільки volatileзмінні створюють власні порушення ефективності, часто краще, щоб змінна була енергонезалежною та приймала можливість періодичних зайвих операцій.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.