Мышление в ленивых последовательностях - PullRequest
20 голосов
/ 06 февраля 2010

Взяв пример серии Фибоначчи из Clojure Wiki, код Clojure:

(def fib-seq
 (lazy-cat [0 1] (map + (rest fib-seq) fib-seq)))

Если вы думаете об этом, начиная с [0 1], как это работает? Было бы здорово, если бы были предложения по мыслительному процессу, который входит в мышление в этих терминах.

Ответы [ 3 ]

37 голосов
/ 06 февраля 2010

Как вы заметили, [0 1] устанавливает базовые случаи: первые два значения в последовательности равны нулю, а затем единице. После этого каждое значение должно быть суммой предыдущего значения и значения перед этим. Следовательно, мы не можем даже вычислить третье значение в последовательности, не имея по крайней мере двух, которые предшествуют ему. Вот почему нам нужно начать с двух значений.

Теперь посмотрите на форму map. В нем говорится, что нужно взять элементы заголовка из двух разных последовательностей, объединить их с функцией + (добавление нескольких значений для получения одной суммы) и представить результат как следующее значение в последовательности. Форма map объединяет две последовательности & mdash; предположительно равной длины & mdash; в одну последовательность одинаковой длины.

Две последовательности, поданные на map, представляют собой разные виды одной и той же базовой последовательности, смещенные на один элемент. Первая последовательность - это «все, кроме первого значения базовой последовательности». Вторая последовательность - это сама базовая последовательность, которая, конечно, включает в себя первое значение. Но какой должна быть базовая последовательность?

В приведенном выше определении говорится, что каждый новый элемент является суммой предыдущего ( Z - 1 ) и предшественника предыдущего элемента ( Z - 2 ). Это означает, что расширение последовательности значений требует доступа к ранее вычисленным значениям в той же последовательности. Нам определенно нужен двухэлементный сдвиговый регистр , но мы также можем запросить доступ к нашим предыдущим результатам. Вот что делает здесь рекурсивная ссылка на последовательность под названием fib-seq. Символ fib-seq относится к последовательности, которая представляет собой конкатенацию нуля, единицы, а затем суммы собственных Z - 2 и Z - 1 значений.

Взяв последовательность, называемую fib-seq, при рисовании первого элемента получается первый элемент вектора [0 1] & mdash; нуль. Рисование второго элемента дает второй элемент вектора & mdash; один. После отрисовки третьего элемента мы консультируемся с map, чтобы сгенерировать последовательность и использовать ее в качестве оставшихся значений. Последовательность, генерируемая map, здесь начинается с суммы первого элемента «остатка» [0 1], который равен единице, и первого элемента [0 1], который равен нулю. Эта сумма равна единице.

Рисование четвертого элемента снова обращается к map, которое теперь должно вычислять сумму второго элемента "остальной части" базовой последовательности, который генерируется map, и второго элемента базы последовательность, которая из вектора [0 1]. Эта сумма равна двум.

Рисование пятого элемента обращается к map, суммируя третий элемент "остальной части" базовой последовательности & mdash; опять же, результат, полученный в результате суммирования нуля, а один - и третий элемент базовой последовательности & mdash; которого мы только что нашли.

Вы можете видеть, как это строится, чтобы соответствовать намеченному определению для серии. Что еще труднее понять, так это то, что при рисовании каждого элемента все предыдущие значения пересчитываются дважды & mdash; один раз для каждой последовательности, проверенной map. Оказывается, такого повторения здесь нет.

Чтобы подтвердить это, добавьте определение fib-seq, как это, чтобы инструментально использовать функцию +:

(def fib-seq
  (lazy-cat [0 1] 
            (map 
              (fn [a b]
                (println (format "Adding %d and %d." a b))
                (+ a b))
            (rest fib-seq) fib-seq)))

Теперь попросите первые десять пунктов:

> (doall (take 10 fib-seq))
Adding 1 and 0.
Adding 1 and 1.
Adding 2 and 1.
Adding 3 and 2.
Adding 5 and 3.
Adding 8 and 5.
Adding 13 and 8.
Adding 21 and 13.
(0 1 1 2 3 5 8 13 21 34)

Обратите внимание, что существует восемь вызовов + для генерации первых десяти значений.


С момента написания предыдущего обсуждения я потратил некоторое время на изучение реализации отложенных последовательностей в Clojure & mdash; в частности, файл LazySeq.java & mdash; и подумал, что это хорошее место, чтобы поделиться несколькими наблюдениями.

Во-первых, обратите внимание, что многие функции обработки ленивых последовательностей в Clojure в конечном итоге используют lazy-seq по сравнению с другими коллекциями. lazy-seq создает экземпляр типа Java LazySeq, который моделирует небольшой конечный автомат. У него есть несколько конструкторов, которые позволяют запускать его в разных состояниях, но наиболее интересный случай - это тот, который начинается с ссылки на нулевую функцию. Построенный таким образом, LazySeq не оценивал функцию и не находил последовательность делегатов (тип ISeq в Java). Первый раз спрашивает LazySeq о первом элементе & mdash; через first & mdash; или любые преемники & ndash; через next или rest & mdash; он оценивает функцию, просматривает полученный объект, чтобы очистить все слои-обертки других LazySeq экземпляров, и, наконец, передает внутренний объект через функцию java RT#seq(), что приводит к экземпляру ISeq.

На данный момент LazySeq имеет ISeq, которому можно делегировать вызовы от имени first, next и rest. Обычно «head» ISeq будет иметь тип Cons, который хранит постоянное значение в своем «первом» (или «автомобильном») слоте, а другой ISeq в своем «остальном» (или «cdr») слоте , То, что ISeq в своем слоте "rest" может, в свою очередь, быть LazySeq, и в этом случае для доступа к нему снова потребуется такая же оценка функции, отстранение всех ленивых оболочек возвращаемого значения и передача этого значения через RT#seq() чтобы получить еще один ISeq для делегирования.

Экземпляры LazySeq остаются связанными вместе, но наличие принудительного единицы (через first, next или rest) заставляет его делегировать прямо некоторым не ленивым ISeq после этого. Обычно это форсирование оценивает функцию, которая выдает Cons, привязанную к первому значению, а его хвост, привязанный к другому LazySeq; это цепочка функций генератора, каждая из которых выдает одно значение («первый» слот Cons), связанное с другой возможностью выдавать больше значений (LazySeq в «110» * слоте «rest»).

Связывая это, в приведенном выше примере последовательности Фибоначчи, map возьмет каждую из вложенных ссылок на fib-seq и проведет их по отдельности с помощью повторных вызовов rest. Каждый такой вызов преобразует не более одного LazySeq, содержащего неоцененную функцию, в LazySeq, указывающий на что-то вроде Cons. После преобразования любые последующие обращения быстро разрешатся до Cons es & mdash; где хранятся фактические значения. Когда одна ветвь map zipping обходит fib-seq один элемент за другим, значения уже разрешены и доступны для доступа в постоянном времени, без дальнейшей оценки функции генератора.

Вот несколько диаграмм, которые помогут визуализировать эту интерпретацию кода:

        +---------+
        | LazySeq |
fib-seq |  fn -------> (fn ...)
        |  sv     |
        |  s      |
        +---------+


        +---------+
        | LazySeq |
fib-seq |  fn -------> (fn ...) -+
        |  sv <------------------+
        |  s      |
        +---------+


        +---------+
        | LazySeq |
fib-seq |  fn     |
        |  sv -------> RT#seq() -+
        |  s  <------------------+
        +---------+


        +---------+   +------+
        | LazySeq |   | ISeq |
fib-seq |  fn     |   |      |
        |  sv     |   |      |
        |  s  ------->|      |
        +---------+   +------+


        +---------+   +--------+        +------+
        | LazySeq |   | Cons   |        | ISeq |
fib-seq |  fn     |   |  first ---> 1   |      |
        |  sv     |   |  more  -------->|      |
        |  s  ------->|        |        |      |
        +---------+   +--------+        +------+


        +---------+   +--------+        +---------+
        | LazySeq |   | Cons   |        | LazySeq |
fib-seq |  fn     |   |  first ---> 1   |  fn -------> (fn ...)
        |  sv     |   |  more  -------->|  sv     |
        |  s  ------->|        |        |  s      |
        +---------+   +--------+        +---------+

По мере продвижения map он переходит от LazySeq до LazySeq (и, следовательно, Cons до Cons), а крайний правый край расширяется только при первом вызове first, next или rest для данного LazySeq.

12 голосов
/ 06 февраля 2010

Мой Clojure немного заржавел, но, похоже, это буквальный перевод знаменитой однострочной записи на Haskell:

fibs = 0 : 1 : zipWith (+) fibs (tail fibs)

[Я собираюсь использовать псевдо-Haskell, потому что он немного более лаконичен.]

Первое, что вам нужно сделать, это просто позволить себе впасть в лень. Когда вы смотрите на такое определение:

zeroes = 0 : zeroes

Ваша непосредственная внутренняя реакция как строгого программиста была бы "Бесконечный цикл ZOMG! Должен исправить ошибку!" Но это не бесконечный цикл. Это ленивый бесконечный цикл. Если вы делаете что-то глупое, как

print zeroes

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

Лень подобна денежной системе: она основана на предположении, что подавляющее большинство людей никогда не использует подавляющее большинство своих денег. Поэтому, когда вы вкладываете 1000 долларов в банк, они не хранят его в сейфе. Они одалживают это кому-то еще. На самом деле они пользуются деньгами, а это значит, что они дают взаймы 5000 долларов кому-то еще. Им только когда-нибудь нужно достаточно денег, чтобы они могли быстро перетасовать их, чтобы они были там , когда вы смотрите на них, давая вам внешний вид , что они фактически сохраняют ваши деньги.

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

Лень работает так же: всякий раз, когда вы смотрите на это, ценность есть. Если вы посмотрите на первый, десятый, хундретский, квадриллионный элемент zeroes, он будет . Но он будет только , , если и , когда вы посмотрите на него, не раньше.

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

Следующий шаг - zipWith. (Clojure map - это просто обобщение того, что в других языках программирования обычно представляют три разные функции: map, zip и zipWith. В этом примере оно используется как zipWith.)

Причина, по которой семейство функций zip названо таким образом, заключается в том, что она на самом деле работает как застежка-молния, и это также, как лучше всего ее визуализировать. Скажем, у нас есть какое-то спортивное событие, где каждый участник получает две попытки, и счет с обеих попыток суммируется, чтобы дать конечный результат. Если у нас есть две последовательности, run1 и run2 с оценками по каждому прогону, мы можем вычислить конечный результат следующим образом:

res = zipWith (+) run1 run2

Предполагая, что наши два списка (3 1 6 8 6) и (4 6 7 1 3), мы выровняем оба этих списка бок о бок, как две половинки молнии, а затем скрепим их вместе, используя нашу данную функцию (+ в в этом случае), чтобы получить новую последовательность:

3   1   6   8   6
+   +   +   +   +
4   6   7   1   3
=   =   =   =   =
7   7  13   9   9

Победа # 3.

Итак, как выглядит наш fib?

Ну, это начинается с 0, затем мы добавляем 1, затем мы добавляем сумму бесконечного списка с бесконечным списком, сдвинутым на один элемент. Проще всего это нарисовать:

  • первый элемент равен нулю:

    0
    
  • второй элемент один:

    0   1
    
  • третий элемент - первый элемент плюс первый элемент остальных (то есть второй элемент). Мы снова визуализируем это как застежка-молния, помещая два списка друг на друга.

    0   1
    +
    1
    =
    1
    
  • Теперь элемент, который мы только что вычислили, - это не просто output функции zipWith, это одновременно и input , так как добавляется к обоим спискам (которые фактически являются одним и тем же списком, только смещенным на один):

    0   1   1
    +   +
    1   1
    =   =
    1   2
    
  • и т. Д .:

    0   1   1   2
    +   +   +
    1   1   2
    =   =   =
    1   2   3
    
    
    0   1   1   2   3   ^
    +   +   +   +       |
    1   1   2   3   ^   |
    =   =   =   =   |   |
    1   2   3   5---+---+
    

Или, если вы рисуете его немного по-другому и объединяете список результатов и второй список ввода (которые в любом случае действительно совпадают) в один:

0   1   1   2   3   ^
+   +   +   +   +   |
1 = 1 = 2 = 3 = 5---+

Вот как Я все равно визуализирую это.

3 голосов
/ 06 февраля 2010

Как это работает:

Каждый член ряда Фибоначчи, очевидно, является результатом сложения двух предыдущих членов. Это то, что карта делает здесь, карта применяет + к каждому элементу в каждой последовательности, пока одна из последовательностей не закончится (что, конечно, не будет). Таким образом, результатом является последовательность чисел, которая получается в результате добавления одного члена в последовательности к следующему члену в последовательности. Затем вам нужен lazy-cat, чтобы дать ему отправную точку и убедиться, что функция возвращает только то, о чем она просила.

Проблема с этой реализацией заключается в том, что fib-seq удерживает всю последовательность до тех пор, пока определен fib-seq, так что в конечном итоге он будет исчерпан из памяти.

Книга Стюарта Хэллоуэя посвящена анализу различных реализаций этой функции. Я думаю, что наиболее интересная из них ниже (это книга Кристофа Гранде):

(defn fibo []
    (map first (iterate (fn [[a b]] [b (+ a b)]) [0 1])))

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

Как мыслить в этих терминах - сложный вопрос. На данный момент для меня это вопрос знакомства с множеством различных способов выполнения действий и их опробования, в то время как в целом необходимо искать способы применения существующей библиотеки функций вместо использования рекурсии и lazy-cat. Но в некоторых случаях рекурсивное решение действительно велико, поэтому оно зависит от проблемы. Я с нетерпением жду получения книги Joy of Clojure , потому что я думаю, что она очень поможет мне в этом вопросе.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...