Реализация Data.STM.LinkedList

Я смотрю на реализацию Data.STM.LinkedList для высокопроизводительного связанного списка. Глядя на документацию, функция длины выполняется за O (n) - почему это так? Была ли реальная проблема с его реализацией в O (1)?

Вот исходный код https://hackage.haskell.org/package/stm-linkedlist-0.1.0.0/docs/src/Data-STM-LinkedList-Internal.html#length

Можно ли реализовать это в O (1)? Я новичок в Haskell, поэтому не уверен, что хранение некоторых метаданных о списке проблематично.

Спасибо!


person Tomer    schedule 01.05.2020    source источник
comment
Не могли бы вы расширить этот вопрос с помощью небольшого исследования? Можете ли вы опубликовать соответствующий код или ссылку на него? Вы можете придумать реализацию O (1)? Я чувствую, что это сделает ваш вопрос более интересным как для чтения, так и для ответа.   -  person MikaelF    schedule 01.05.2020
comment
Достичь этого эффективно не так просто. Потребуется добавить указатель на каждый узел в списке, указывающий на текущую переменную размера. В противном случае для каждой вставки / удаления потребуются два аргумента (список и узел), что позволит пользователю попытаться удалить узел из неправильного списка, потенциально нарушая инвариант переменной размера. Проверка против этого потребует вставки / удаления O (N), AFAICS.   -  person chi    schedule 02.05.2020
comment
По сути, вы говорите, что нет элегантного способа реализовать это в Haskell?   -  person Tomer    schedule 02.05.2020
comment
Похоже, что нет полностью безопасного и эффективного способа реализовать его на любом языке. Это не зависит от Haskell. Если вы согласны с добавлением предварительных условий для операций (например, вы никогда не должны удалять узел из списка, если он действительно не является узлом внутри этого списка) и соглашаетесь с тем, что нарушение этого предварительного условия приведет к неправильному размеру, то вы можете реализовать его как тонкая обертка к библиотеке.   -  person chi    schedule 02.05.2020
comment
Но c ++ stl реализует его в o (1). Таким образом, вы проверяете флаг при каждом удалении, чтобы гарантировать согласованность. Мне кажется, это цена, которую стоит заплатить.   -  person Tomer    schedule 02.05.2020
comment
@Gil Shafriri - Да, в C ++ это O (1), но это НЕ включает никаких затрат, связанных с взаимным исключением. Философия C ++: вы не платите за то, что не используете. Код C ++ STL написан для однопоточного приложения, в отличие от STM. Единственный способ получить O (1) - поддерживать счетчик на уровне list, следовательно, повышается риск провала обычно невинной insertBetween транзакции. Обратите внимание, что этот документ о реализации Haskell STM, обычно находящийся за платным доступом, теперь свободно распространяется. доступная любезность коронавируса, я имею в виду ACM.   -  person jpmarinier    schedule 02.05.2020
comment
Спасибо! Итак, каковы мои варианты в Haskell, чтобы иметь список с двойной связью с производительностью, аналогичной c ++ stl, а именно O (1) для функции длины?   -  person Tomer    schedule 03.05.2020
comment
Можете ли вы предоставить ссылку на документацию по C ++ для нужного вам контейнера? Я предполагаю, что C ++ нормально нарушает инвариант, если пользователь нарушает предварительные условия. Например. Вы можете написать list.remove(node), когда node исходит от другого list, и это вызовет неопределенное поведение. Если вас это устраивает, вы можете реализовать тонкую оболочку над текущей реализацией Haskell и будьте осторожны, чтобы правильно вызывать процедуры удаления с тем же списком, в котором находится узел.   -  person chi    schedule 03.05.2020
comment
Например, попробуйте вызвать std::list.erase с итератором, исходящим из другого объекта списка. Я ожидаю, что это испортит счетчик размера и / или другие инварианты.   -  person chi    schedule 03.05.2020
comment
@chi вместо того, чтобы включать в каждый узел напрямую указатель на текущую переменную размера, пусть он указывает на сам List (nodes, size), нет? затем remove node list проверяет, действительно ли узел указывает на этот список, и только затем удаляет узел путем хирургического повторного связывания его соседей, в противном случае ничего не делает. что плохого в таком подходе?   -  person Will Ness    schedule 03.05.2020
comment
@WillNess Конечно, это работает, но требует дополнительного указателя в каждом узле, что снижает эффективность памяти. Упомянутая реализация STM требует только 3 указателя на узел: data / prev / next. Я исходил из предположения, что мы не можем этого изменить.   -  person chi    schedule 03.05.2020
comment
Спасибо вам, ребята! Так что, возможно, Data.STM.LinkedList не для меня. Мне не нужно взаимное исключение. А просто что-то вроде stl без защиты. Придется ли мне писать самому или есть что-нибудь готовое, что я могу использовать?   -  person Tomer    schedule 03.05.2020
comment
@chi - я думаю, вы можете получить безопасную реализацию без особых накладных расходов, если вы введете итератор как отдельную концепцию от Node. Итератор может быть парой из узла и списка, к которому этот узел принадлежит. Чтобы удалить элемент, вы вызываете remove итератор, а затем там можно выполнить проверку и обновление размера. Но сам узел не будет содержать дополнительных данных. Я не понимаю, какой именно тип данных для узла мне следует использовать. Стоит ли использовать Data.IORef? Какое значение имеет память?   -  person Tomer    schedule 03.05.2020
comment
Да, использование умного итератора выглядит возможным. IORef подойдет, если вам нужна изменчивость, но не параллелизм, и вы можете жить внутри монады IO. Грубо говоря, это эквивалент указателя. STRef s также можно использовать, если IO слишком ограничительный.   -  person chi    schedule 03.05.2020
comment
Каким будет объем памяти (IORef a), если размер a равен x? Также есть что-нибудь готовое, что я могу использовать?   -  person Tomer    schedule 03.05.2020
comment
@GilShafriri лучший способ узнать след - написать его самому и протестировать. :) двусвязные списки просты, это не похоже на тысячи строк кода ...   -  person Will Ness    schedule 04.05.2020


Ответы (1)


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

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

Однако с практической точки зрения сопоставление фактической (в отличие от асимптотической) производительности C ++ реализации изменяемой структуры данных может оказаться чрезвычайно трудным, если не невозможным. Может быть разумным получить в 2-3 раза производительность выше, чем у C ++, а в 5-10 раз довольно легко (см. Ниже). Однако, если вам нужно соответствовать производительности C ++, вам, вероятно, будет лучше написать высокопроизводительный изменяющий код на C ++ и использовать FFI (интерфейс внешней функции) для взаимодействия с этим кодом.

В любом случае, двусвязный список с «умеренной производительностью» с O (1) length, безусловно, возможен, и нет никаких фундаментальных трудностей с поддержанием изменяемых метаданных для всего списка. Причина, по которой stm-linkedlist не предоставляет O (1) length, вероятно, та же самая причина, по которой C ++ гарантировал только производительность O (n) std::list<>::size до C ++ 11. А именно, многие практические применения двусвязных списков никогда не требуют вызова _5 _ / _ 6_, а обеспечение производительности O (1) требует дополнительных затрат на ведение бухгалтерского учета.

В качестве доказательства концепции следующих типов данных достаточно для реализации полностью изменяемого двусвязного списка с функцией длины O (1). Здесь типы и идентификаторы, оканчивающиеся подчеркиванием, предназначены только для внутреннего использования. Список строг по своим указателям (так что никаких бесконечных списков!), Но ленив по своим значениям.

data List a = List
  { headNode_ :: !(IORef (Node_ a))
  , length_ :: !(IORef Int) }
data Node_ a = Node_
  { prev_ :: !(IORef (Node_ a))
  , next_ :: !(IORef (Node_ a))
  , value_ :: a }

Тип List содержит указатель (т.е. IORef) на неполный headNode, который указывает на начало и конец списка (или на себя для пустого списка), но имеет поле неопределенного значения. Это делает это значение узла небезопасным, поэтому оно никогда не должно быть напрямую доступно конечному пользователю. List также содержит указатель на значение длины списка.

Дополнительный тип Node (без подчеркивания) используется для украшения указателя узла его соответствующим списком (например, «итератор» из комментариев), чтобы сделать метаданные списка доступными для функций, которым они нужны:

data Node a = Node
  { node_ :: !(IORef (Node_ a))
  , list_ :: !(List a) }

Обратите внимание, что List и Node - это типы данных, ориентированные на пользователя для работы со списками.

Вы создаете empty список следующим образом:

empty :: IO (List a)
empty = mdo
  n <- newIORef (Node_ n n undefined)
  List n <$> newIORef 0

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

insertBefore :: a -> Node a -> IO (Node a)
insertBefore x Node{node_=rnode2, list_} = do
  Node_{prev_=rnode1} <- readIORef rnode2
  insertBetween_ x list_ rnode1 rnode2

insertAfter :: a -> Node a -> IO (Node a)
insertAfter x Node{node_=rnode1, list_} = do
  Node_{next_=rnode2} <- readIORef rnode1
  insertBetween_ x list_ rnode1 rnode2

insertBetween_ :: a -> List a -> IORef (Node_ a) -> IORef (Node_ a) -> IO (Node a)
insertBetween_ x l rnode1 rnode2 = do
  modifyIORef' (length_ l) succ
  newnode <- newIORef (Node_ rnode1 rnode2 x)
  modifyIORef' rnode1 (\n -> n{next_=newnode})
  modifyIORef' rnode2 (\n -> n{prev_=newnode})
  return $ Node newnode l

Поскольку пользователю не разрешено «иметь» головной узел, нам нужны дополнительные функции, ориентированные на пользователя, для вставки в начало и конец списка:

prepend :: a -> List a -> IO (Node a)
prepend x l = insertAfter x (Node (headNode_ l) l)

append :: a -> List a -> IO (Node a)
append x l = insertBefore x (Node (headNode_ l) l)

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

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

delete :: Node a -> IO ()
delete Node{node_,list_} = do
  modifyIORef' (length_ list_) pred
  Node_{next_, prev_} <- readIORef node_
  modifyIORef' prev_ (\n -> n{next_=next_})
  modifyIORef' next_ (\n -> n{prev_=prev_})

Удаление головного узла было бы катастрофой, но пользователям не разрешается иметь такой Node, так что мы в безопасности.

Если у пользователя есть Node, он может перемещаться вперед и назад по списку:

prev :: Node a -> IO (Maybe (Node a))
prev Node{node_, list_} = do
  Node_{prev_} <- readIORef node_
  return $ maybeNode_ prev_ list_

next :: Node a -> IO (Maybe (Node a))
next Node{node_, list_} = do
  Node_{next_} <- readIORef node_
  return $ maybeNode_ next_ list_

maybeNode_ :: IORef (Node_ a) -> List a -> Maybe (Node a)
maybeNode_ n l =
  if n == headNode_ l
  then Nothing
  else Just (Node n l)

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

Для начала пользователь может получить начало или конец List, используя следующие функции (которые используют prev или next на запрещенном головном узле):

start :: List a -> IO (Maybe (Node a))
start l = next $ Node (headNode_ l) l

end :: List a -> IO (Maybe (Node a))
end l = prev $ Node (headNode_ l) l

Не хватает только нескольких различных функций запросов:

value :: Node a -> IO a
value = fmap value_ . readIORef . node_

null :: List a -> IO Bool
null l = (==0) <$> length l

length :: List a -> IO Int
length = readIORef . length_

некоторые утилиты для преобразования в простые списки:

toList :: List a -> IO [a]
toList = toList_ next_

toListRev :: List a -> IO [a]
toListRev = toList_ prev_

toList_ :: (Node_ a -> IORef (Node_ a)) -> List a -> IO [a]
toList_ dir l = go =<< readIORef h
  where h = headNode_ l
        go n = do
          if dir n == h then return []
            else do
            n' <- readIORef (dir n)
            (value_ n':) <$> go n'

и экземпляр Show для отладки:

instance (Show a) => Show (List a) where
  showsPrec d lst = showParen (d > 10) $ showString "fromList " . showsPrec 11 (unsafePerformIO $ toList lst)

ПРЕДУПРЕЖДЕНИЕ: этот Show экземпляр небезопасен, если список изменен до того, как сгенерированная строка будет полностью оценена, поэтому его следует использовать только для отладки (и, вероятно, удалить из производственной версии).

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

modify :: (a -> a) -> Node a -> IO ()
modify f Node{node_} = modifyIORef' node_ (\n -> n { value_ = f (value_ n) })

Вот полный код. (См. Определение ex1 для примера использования.) Вы можете использовать его в качестве отправной точки для вашей собственной реализации. Он не тестировался и не тестировался, за исключением того, что пара быстрых тестов показывает, что он, вероятно, примерно в 5-10 раз медленнее, чем реализация на C ++.

{-# LANGUAGE NamedFieldPuns, RecursiveDo #-}

module LinkedList
  ( List, Node
  , value, null, length
  , empty, prepend, append, insertBefore, insertAfter, delete, modify
  , prev, next, start, end
  , toList, toListRev
  ) where

import System.IO.Unsafe
import Control.Monad
import Prelude hiding (null, length)

import Data.IORef

data List a = List
  { headNode_ :: !(IORef (Node_ a))
  , length_ :: !(IORef Int) }
data Node a = Node
  { node_ :: !(IORef (Node_ a))
  , list_ :: !(List a) }
data Node_ a = Node_
  { prev_ :: !(IORef (Node_ a))
  , next_ :: !(IORef (Node_ a))
  , value_ :: a }

-- unsafe show instance: remove from production version
instance (Show a) => Show (List a) where
  showsPrec d lst = showParen (d > 10) $ showString "fromList " . showsPrec 11 (unsafePerformIO $ toList lst)

value :: Node a -> IO a
value = fmap value_ . readIORef . node_

null :: List a -> IO Bool
null l = (==0) <$> length l

length :: List a -> IO Int
length = readIORef . length_

empty :: IO (List a)
empty = mdo
  n <- newIORef (Node_ n n undefined)
  List n <$> newIORef 0

prepend :: a -> List a -> IO (Node a)
prepend x l = insertAfter x (Node (headNode_ l) l)

append :: a -> List a -> IO (Node a)
append x l = insertBefore x (Node (headNode_ l) l)

insertBefore :: a -> Node a -> IO (Node a)
insertBefore x Node{node_=rnode2, list_} = do
  Node_{prev_=rnode1} <- readIORef rnode2
  insertBetween_ x list_ rnode1 rnode2

insertAfter :: a -> Node a -> IO (Node a)
insertAfter x Node{node_=rnode1, list_} = do
  Node_{next_=rnode2} <- readIORef rnode1
  insertBetween_ x list_ rnode1 rnode2

insertBetween_ :: a -> List a -> IORef (Node_ a) -> IORef (Node_ a) -> IO (Node a)
insertBetween_ x l rnode1 rnode2 = do
  modifyIORef' (length_ l) succ
  newnode <- newIORef (Node_ rnode1 rnode2 x)
  modifyIORef' rnode1 (\n -> n{next_=newnode})
  modifyIORef' rnode2 (\n -> n{prev_=newnode})
  return $ Node newnode l

delete :: Node a -> IO ()
delete Node{node_,list_} = do
  modifyIORef' (length_ list_) pred
  Node_{next_, prev_} <- readIORef node_
  modifyIORef' prev_ (\n -> n{next_=next_})
  modifyIORef' next_ (\n -> n{prev_=prev_})

modify :: (a -> a) -> Node a -> IO ()
modify f Node{node_} = modifyIORef' node_ (\n -> n { value_ = f (value_ n) })

prev :: Node a -> IO (Maybe (Node a))
prev Node{node_, list_} = do
  Node_{prev_} <- readIORef node_
  return $ maybeNode_ prev_ list_

next :: Node a -> IO (Maybe (Node a))
next Node{node_, list_} = do
  Node_{next_} <- readIORef node_
  return $ maybeNode_ next_ list_

maybeNode_ :: IORef (Node_ a) -> List a -> Maybe (Node a)
maybeNode_ n l =
  if n == headNode_ l
  then Nothing
  else Just (Node n l)

start :: List a -> IO (Maybe (Node a))
start l = next $ Node (headNode_ l) l

end :: List a -> IO (Maybe (Node a))
end l = prev $ Node (headNode_ l) l

toList :: List a -> IO [a]
toList = toList_ next_

toListRev :: List a -> IO [a]
toListRev = toList_ prev_

toList_ :: (Node_ a -> IORef (Node_ a)) -> List a -> IO [a]
toList_ dir l = go =<< readIORef h
  where h = headNode_ l
        go n = do
          if dir n == h then return []
            else do
            n' <- readIORef (dir n)
            (value_ n':) <$> go n'

ex1 :: IO (List Int)
ex1 = do
  t <- empty
  mapM_ (flip prepend t) [10,9..1]
  mapM_ (flip append t) [11..20]
  return t
person K. A. Buhr    schedule 06.05.2020
comment
не могли бы вы дать несколько советов относительно того, почему unsafePerformIO . toList безопасно использовать здесь? или если есть какие-то предварительные условия, которым должен следовать пользовательский код, то каковы они? - person Will Ness; 06.05.2020
comment
Ха! Да, это совершенно небезопасно и должно использоваться только для отладки. Вы можете продемонстрировать, что это небезопасно, с помощью такой программы, как: main = do { t <- ex1; let { s = show t }; print (head s); Just e <- start t; modify (+1) e; print s }. Я добавил к ответу предупреждение, так как это важный момент. - person K. A. Buhr; 06.05.2020
comment
Большое спасибо! Интересно, почему этот вид изменяемого кода связанного списка не является частью стандартных библиотек Haskell. Справедливо будет сказать, что сообщество Haskell еще не инвестировало в изменяемые стандартные библиотеки? - person Tomer; 07.05.2020
comment
может быть, добавить пояснение к ответу о том, что означает небезопасный - будет ли он печатать измененное содержимое вместо исходного или вызывать segfault (если, например, был удален головной узел)? (Сам не тестировал) - person Will Ness; 07.05.2020