#haskell #memory #iterator #binary-tree
#haskell #память #итератор #двоичное дерево
Вопрос:
Я знаю, что есть решение для итерации через Tree
using Zipper
s (смотрите Подробности здесь). Хотя мне не ясно, можно ли применить ограничения памяти к этому подходу.
Контекст
Мне было предложено решить следующую проблему в Haskell:
Создайте итератор, который будет выполнять итерации по двоичному дереву по порядку.
Предположим, что двоичное дерево хранится на диске и может содержать до 10 уровней и, следовательно, может содержать до (2 ^ 10 — 1) узлов, и мы можем хранить не более 100 узлов в памяти в любой момент времени.
Цель этого итератора — загружать небольшую часть двоичного дерева с диска в память каждый раз, когда оно увеличивается, так что нам не нужно загружать все дерево в память сразу.
Я предположил, что часть памяти невозможно представить в Haskell, но мне сказали, что это неправда.
Вопрос: что можно использовать в Haskell для достижения такого поведения памяти? Приветствуются любые предложения, подходы и направления. Это просто из любопытства, я уже потерпел неудачу в решении этой проблемы.
Комментарии:
1. Для написания фактического ответа потребовалось бы гораздо больше деталей; это не может быть всей проблемой, не так ли? Под «не более 100 узлов в памяти» подразумевается «не более 100 узлов, доступных для GC» (потому что остальные будут освобождены по мере необходимости) или это более жестко? В худшем случае, я полагаю, вы можете просто изменить массив на месте, чтобы избежать выделения?
2. Мне не требуется писать ответ на конкретную проблему (что, конечно, я не возражаю). Меня больше интересуют подходы, которые можно было бы использовать для работы с ограничениями памяти и выделениями по требованию в Haskell. И отвечая на ваш вопрос, это целая задача, и мне не было предоставлено больше подробностей.
3. Это кажется странным, потому что (будучи языком GCd) у вас нет точного контроля над тем, когда / где выделяется (де) память. Существует некоторая возможность ручного управления памятью, особенно для использования с FFI, например. ro-che.info/articles/2017-08-06-manage-allocated-memory-haskell . Поскольку ваш обход, в частности, возможен с (глубинными) узлами в памяти, тогда вы могли бы написать свой собственный обход на основе стека с ручным распределением (я полагаю), который соответствовал бы вашим требованиям.
4. Вопрос, ИМХО, некорректный. Предположим, что дерево хранится на диске в виде текстового файла по порядку. Тогда мне даже не нужно хранить более одного узла одновременно в основной памяти, я просто копирую файл как есть. Теперь, если оно сохранено как предварительный заказ или последующий заказ, как бы вы сделали то, что запрашивается на любом языке?
Ответ №1:
Если итератор загружает часть дерева каждый раз, когда оно увеличивается, тогда есть два варианта:
-
Он существует в монаде ввода-вывода и работает так же, как в императивном языке.
-
Он использует лень и чередующийся ввод-вывод. Это подход, используемый такими функциями, как
readFile
, которые предоставляют вам все содержимое файла в виде одного отложенного списка. Фактический файл считывается по требованию, когда ваше приложение просматривает список.
Последний вариант здесь интересен.
Сложная часть отложенных списков — это фиксаторы. Предположим, ваш файл содержит список чисел. Если вы вычисляете сумму следующим образом
nums <- map read . lines <$> readFile "numbers.txt"
putStrLn $ "The total is " <> show (sum nums)
тогда программа будет выполняться в постоянном пространстве. Но если вы хотите среднее:
putStrLn $ "The average is " <> show (sum nums / fromIntegral (length nums))
затем программа загрузит весь файл в память. Это потому, что ему приходится дважды проходить по списку, один раз для вычисления суммы и один раз для вычисления длины. Это можно сделать, только удерживая весь список.
(Решение состоит в том, чтобы вычислить сумму и длину параллельно в течение одного прохода. Но здесь это не относится к делу).
Задача для проблемы с деревом, которую вы ставите, состоит в том, чтобы придумать подход к итерации, который позволяет избежать сохранения дерева.
Давайте предположим, что каждый узел в файле содержит смещения в файле для левого и правого дочерних узлов. Мы можем написать функцию в монаде ввода-вывода, которая ищет смещение и считывает там узел.
data MyNode = MyNode Int Int ..... -- Rest of data to be filled in.
readNodeData :: Handle -> Int -> IO MyNode
Оттуда было бы просто написать функцию, которая обходит весь файл для создания Tree MyNode
. Если вы реализуете это с помощью unsafeInterleaveIO
, то вы можете получить дерево, которое лениво считывается при прохождении по нему.
unsafeInterleaveIO
небезопасно, потому что вы не знаете, когда будет выполнен ввод-вывод. Вы даже не знаете, в каком порядке это будет происходить, потому что это происходит только тогда, когда значение вводится принудительно во время оценки. Таким образом, это похоже на структуры «promise«, которые вы получаете в некоторых других языках. В данном конкретном случае это не проблема, потому что мы можем предположить, что файл не изменяется во время оценки.
К сожалению, это не решает проблему, потому что к моменту завершения все дерево будет храниться в памяти. Ваш обход должен сохранять корень, по крайней мере, до тех пор, пока он проходит по левой стороне, и до тех пор, пока он это делает, он будет сохранять все остальное дерево.
Решение состоит в том, чтобы переписать часть ввода-вывода, чтобы возвращать список вместо дерева, что-то вроде этого:
readNode :: Handle -> Int -> IO [MyNode]
readNode _ (-1) = return [] -- Null case for empty child.
readNode h pos = unsafeInterleaveIO $ do
n <- readNodeData h pos -- Needs to be defined elsewhere.
lefts <- readNode (leftChild n)
rights <- readNode (rightChild n)
return $ lefts [n] rights
Это возвращает все дерево в виде отложенного списка. По мере прохождения списка соответствующие узлы будут считываться по требованию. Пока вы не сохраняете список (см. Выше), вашей программе не нужно будет содержать ничего, кроме текущего узла и его родителей.