Haskell: версия хвостовой рекурсии глубины бинарного дерева

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

depth::Tree a -> Int
depth Empty        = 0
depth (Branch b l r) = 1 + max (depth l) (depth r)


depthTailRec::Tree a -> Int
depthTailRec = depthTR 0 where
           depthTR d Empty          = d 
           depthTR d (Branch b l r) = let dl = depthTR (d+1) l; dr = depthTR (d+1) r in max dl dr 

Мне просто интересно, разве люди не говорят о том, как хвостовая рекурсия может быть полезна для производительности? И куча вопросов лезет в голову:

  1. Как сделать так, чтобы глубина работала быстрее?
  2. Я читал что-то о том, как лень Haskell может уменьшить потребность в хвостовой рекурсии, это правда?
  3. Правда ли, что любую рекурсию можно преобразовать в хвостовую рекурсию?
  4. Наконец, хвостовая рекурсия может быть быстрее и экономить пространство, потому что ее можно превратить в циклы и, таким образом, уменьшить необходимость толкать и выталкивать стек, я правильно понимаю?

person Boyu Fang    schedule 18.01.2014    source источник
comment
Ваша функция не хвостовая рекурсия. И не стоит об этом беспокоиться в данном случае. Ибо не так просто сделать хвостовую рекурсивную функцию, которая должна вызывать себя дважды, чтобы получить результат.   -  person Ingo    schedule 18.01.2014
comment
@lngo Можете ли вы уточнить, почему это не хвостовая рекурсия? Есть ли хороший материал по хвостовой рекурсии? Спасибо.   -  person Boyu Fang    schedule 18.01.2014
comment
Поскольку последний вызов функции, который должен быть сделан в depthTailRec для получения результата, будет не depthTailRec, а max.   -  person Ingo    schedule 18.01.2014
comment
Есть замечания по пункту №2 про лень? Хотелось бы услышать и об этом.   -  person Yogesh Sajanikar    schedule 18.01.2014
comment
Если бы существовала бесстековая реализация Haskell, то рекурсивная итерация по деревьям вообще не была бы проблемой, поскольку вы все равно не можете поддерживать постоянное пространство. Некоторые из комментариев здесь говорят о такой ситуации: lambda-the-ultimate.org/node/2673   -  person CMCDragonkai    schedule 11.09.2015


Ответы (3)


1. Почему ваша функция не хвостовая рекурсия?

Чтобы рекурсивная функция была хвостовой рекурсией, все рекурсивные вызовы должны находиться в хвостовой позиции. Функция находится в хвостовой позиции, если она вызывается последней перед возвратом функции. В вашем первом примере у вас есть

depth (Branch _ l r) = 1 + max (depth l) (depth r)

что эквивалентно

depth (Branch _ l r) = (+) 1 (max (depth l) (depth r))

Последней функцией, вызываемой перед возвратом функции, является (+), так что это не хвостовая рекурсия. Во втором примере у вас есть

depthTR d (Branch _ l r) = let dl = depthTR (d+1) l
                               dr = depthTR (d+1) r
                            in max dl dr

что эквивалентно (после того, как вы переформулировали все операторы let) и немного перестроили

depthTR d (Branch _ l r) = max (depthTR (d+1) r) (depthTR (d+1) l)

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

2. Как сделать хвост рекурсивным?

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

depth t = go t id
 where
   go  Empty         k = k 0
   go (Branch _ l r) k = go l $ \dl ->
                           go r $ \dr ->
                             k (1 + max dl dr)

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

3. Значит, это все?

(Обратите внимание, что этот раздел основан на ответах на this предыдущий вопрос.)

Нет! Этот трюк только отодвигает проблему в другое место. Вместо функции без хвостовой рекурсии, которая использует много места в стеке, теперь у нас есть функция с хвостовой рекурсией, которая потребляет санки (непримененные функции), которые потенциально сами могут занимать много места. К счастью, нам не нужно работать с произвольными функциями — на самом деле их всего три вида.

  1. \dl -> go r (\dr -> k (1 + max dl dr)) (который использует свободные переменные r и k)
  2. \dr -> k (1 + max dl dr) (со свободными переменными k и dl)
  3. id (без свободных переменных)

Поскольку существует только конечное число функций, мы можем представить их в виде данных

data Fun a = FunL (Tree a) (Fun a)  -- the fields are 'r' and 'k'
           | FunR  Int     (Fun a)  -- the fields are 'dl' and 'k'
           | FunId

Нам также придется написать функцию eval, которая говорит нам, как оценивать эти функции при конкретных аргументах. Теперь вы можете переписать функцию как

depth t = go t FunId
 where
  go  Empty         k = eval k 0
  go (Branch _ l r) k = go l (FunL r k)

  eval (FunL r  k) d = go r (FunR d k)
  eval (FunR dl k) d = eval k (1 + max dl d)
  eval (FunId)     d = d

Обратите внимание, что и go, и eval имеют вызовы либо go, либо eval в хвостовой позиции, поэтому они являются парой взаимно хвостовых рекурсивных функций. Таким образом, мы преобразовали версию функции, которая использовала стиль передачи продолжения, в функцию, которая использует данные для представления продолжений и использует пару взаимно рекурсивных функций для интерпретации этих данных.

4. Звучит очень сложно

Ну, я думаю, это так. Но ждать! Мы можем упростить его! Если вы посмотрите на тип данных Fun a, вы увидите, что на самом деле это просто список, где каждый элемент представляет собой либо Tree a, для которого мы собираемся вычислить глубину, либо Int, представляющий глубину, которую мы вычислили. уже.

Какая польза от того, что вы это замечаете? На самом деле этот список представляет собой стек вызовов цепочки продолжений из предыдущего раздела. Добавление нового элемента в список означает добавление нового аргумента в стек вызовов! Таким образом, вы могли бы написать

depth t = go t []
 where
  go  Empty         k = eval k 0
  go (Branch _ l r) k = go l (Left r : k)

  eval (Left r   : k) d = go   r (Right d : k)
  eval (Right dl : k) d = eval k (1 + max dl d)
  eval  []            d = d

Каждый новый аргумент, который вы помещаете в стек вызовов, имеет тип Either (Tree a) Int, и по мере рекурсии функций они продолжают помещать в стек новые аргументы, которые представляют собой либо новые деревья для исследования (всякий раз, когда вызывается go), либо максимальную глубину, найденную на данный момент. (всякий раз, когда вызывается eval).

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

5. Хорошо... что-нибудь еще?

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

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

depth t = go 0 [(0,t)]
 where
  go depth  []    = depth
  go depth (t:ts) = case t of
    (d, Empty)        -> go (max depth d)  ts
    (d, Branch _ l r) -> go (max depth d) ((d+1,l):(d+1,r):ts)

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

6. Так вот что я должен использовать?

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

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

person Chris Taylor    schedule 18.01.2014
comment
почему go l (\dl -> (go r (\dr -> k (1 + max dl dr))) дает правильный результат? это кажется мне довольно запутанным ... Также действительно ли это помогает с производительностью, независимо от времени или пространства? Ваше здоровье - person Boyu Fang; 18.01.2014
comment
Кроме того, что вообще означает \dr -> k (1 + max dl dr)? - person Boyu Fang; 18.01.2014
comment
@dorafmon Я понятия не имею, работают ли какие-либо из них быстрее или занимают меньше места на практике (хотя они, безусловно, используют меньше места в стеке). Вы можете попробовать профилировать их! - person Chris Taylor; 18.01.2014
comment
@dorafmon \dr -> k (1 + max dl dr) — это функция одного аргумента, dr, которая находит max из dl (которая находится в более высокой области видимости) и dr, добавляет к ней единицу и использует ее в качестве аргумента функции k (которая также находится в более высокий размах). - person Chris Taylor; 18.01.2014
comment
примечание: в вашей последней версии (#5) я думаю, что max безопасно брать только в случае Empty, чтобы сохранить некоторые расчеты. - person Will Ness; 20.01.2014
comment
@ChrisTaylor, почему тайна?? :) наведите указатель мыши на отметку +100 и откройте имя. Или просмотрите историю редактирования своего ответа, нажав на отредактированное 19 января.... (вы можете шутить там, но не так, если вы сосредоточитесь на содержании и уделите меньше внимания постороннему механизму SO... ). :) - person Will Ness; 17.02.2014
comment
О, я понятия не имел, что ты можешь делать такие причудливые штуки. В таком случае, спасибо за очки, Уилл (странно не знать, кто ты такой, поскольку я часто вижу тебя в последнее время... не стесняйся, напиши мне по электронной почте и развей туман тайны!) - person Chris Taylor; 17.02.2014
comment
Просто сравните вашу явную версию стека вызовов со стилем передачи продолжения. Оба по-прежнему используют пространство O (n), верно? Но оба ли выделяют место в куче? В этом случае версия csp может быть адекватной, если все, о чем вы заботитесь, это предотвращение переполнения стека. Однако явный стек вызовов может использовать меньше места на n. - person CMCDragonkai; 11.09.2015
comment
Правильно, оба являются пространством O (n). Не уверен, что более эффективно на практике (хотя, как и вы, я бы предположил, что явный стек вызовов) - person Chris Taylor; 11.09.2015

Частично хвостовая рекурсивная версия будет такой:

depth d Empty = d
depth d (Branch _ l Empty) = depth (d+1) l
depth d (Branch _ Empty r) = depth (d+1) r
depth d (Branch _ l r)     = max (depth (d+1) l) (depth (d+1) r)

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

Но этого должно быть достаточно, если предположить, что глубина ваших деревьев не больше некоторого двузначного числа. На самом деле, если вы правильно сбалансируете свое дерево, все будет хорошо. Если ваши деревья, OTOH, используют для вырождения в списки, то это уже поможет избежать переполнения стека (это гипотеза, которую я не доказал, но она, безусловно, верна для полностью вырожденного дерева, у которого нет ветви с 2 непустыми дети.).

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

person Ingo    schedule 18.01.2014

к вашему 3., да, напр. с использованием техники CPS (как показано в ответе Криса);

на ваш 4., правильно.

к вашему 2., с ленивым корекурсивным обходом дерева в ширину мы, естественно, получаем решение, подобное до последнего Криса (т. е. его #5., обход в глубину с развернутым стеком), даже без каких-либо вызовов max:

treedepth :: Tree a -> Int
treedepth tree = fst $ last queue
  where
    queue = (0,tree) : gen 1 queue

    gen  0   p                     = []
    gen len ((d,Empty)        : p) =                     gen (len-1) p 
    gen len ((d,Branch _ l r) : p) = (d+1,l) : (d+1,r) : gen (len+1) p 

Хотя оба варианта имеют пространственную сложность O(n) в наихудшем случае, сами наихудшие случаи различны и противоположны друг другу: наиболее вырожденные деревья являются наихудшим случаем для обхода в глубину ( ДПФ) и наилучший случай (с точки зрения пространства) для поиска в ширину (БПФ); и точно так же наиболее сбалансированные деревья являются лучшим случаем для DFT и худшим для BFT.

person Will Ness    schedule 19.01.2014