Аккумуляторы, conj и рекурсия

Я решил 45 задач с 4clojure.com и заметил повторяющуюся проблему в том, как я пытаюсь решить некоторые задачи с помощью рекурсии и аккумуляторов.

Я попытаюсь объяснить как можно лучше, что я делаю, чтобы в итоге получить нелепые решения, надеясь, что некоторые Clojurers «получат» то, чего не получаю я.

Например, в задаче 34 предлагается написать функцию (без использования диапазона), которая принимает два целых числа в качестве аргументов и создает диапазон (без использования диапазона). Проще говоря, вы делаете (... 1 7) и получаете (1 2 3 4 5 6).

Теперь этот вопрос не о решении этой конкретной проблемы.

Что, если я захочу решить эту проблему с помощью рекурсии и аккумулятора?

Мой мыслительный процесс выглядит следующим образом:

  • Мне нужно написать функцию с двумя аргументами, я начинаю с (fn [x y] )

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

    (fn [x y]
    ((fn g [x y acc] ...) x y '())

(очевидно, я не могу правильно отформатировать этот код Clojure на SO!?)

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

Затем я хочу conj, но не могу:

(conj 0 1)

поэтому я делаю странные вещи, чтобы сначала убедиться, что у меня есть последовательность, и в итоге я получаю это:

(fn
   [x y]
   ((fn g [x y acc] (if (= x y) y (conj (conj acc (g (inc x) y acc)) x)))
    x
    y
    '()))

Но тогда это производит это:

(1 (2 (3 4)))

Вместо этого:

(1 2 3 4)

В итоге я делаю дополнительное сглаживание, и это работает, но совершенно некрасиво.

Я начинаю понимать некоторые вещи и даже начинаю, в некоторых случаях, "думать" в более кложурском стиле, но у меня проблема с написанием решения.

Например, здесь я решил:

  • использовать аккумулятор
  • для рекурсии, увеличивая x, пока не достигнет y

Но я заканчиваю с чудовищем выше.

Есть множество способов решить эту проблему, и, опять же, это не то, что мне нужно.

Что мне нужно, так это то, как после того, как я решил использовать cons/conj, использовать аккумулятор и рекурсию, я могу получить это (не написано мной):

#(loop [i %1
        acc nil]
  (if (<= %2 i)
    (reverse acc)
    (recur (inc i) (cons i acc))))

Вместо этого:

((fn
  f
  [x y]
  (flatten
   ((fn
     g
     [x y acc]
     (if (= x y) acc (conj (conj acc (g (inc x) y acc)) x)))
    x
    y
    '())))
 1
 4)

Я понимаю, что это только начало, чтобы решить несколько проблем, но я немного разочарован уродливыми решениями, которые я склонен создавать...


person Cedric Martin    schedule 19.05.2012    source источник
comment
Не бойтесь выбрасывать плохие решения. Если вы начинаете замечать, что ваш код становится громоздким, сделайте шаг назад и обдумайте его. Когда это чувствуется неправильно, скорее всего, это не так.   -  person Jeremy    schedule 19.05.2012
comment
@JeremyHeiler: хорошо, но идея не так уж плоха, плоха реализация/фактический код. Например, краткое решение с использованием и аккумулятор+рекурсивность было написано кем-то, кто решил 150 задач 4clojure (и некоторые из них действительно нетривиальны). Так что моя идея не кажется плохой: но я не могу (пока) чисто реализовать свои идеи. Я так понимаю, нужно время, чтобы кусочки головоломки встали на свои места :-/   -  person Cedric Martin    schedule 19.05.2012
comment
Это, безусловно, так. Просто продолжайте практиковаться и играть с кодом!   -  person Jeremy    schedule 19.05.2012
comment
Я немного удивлен положительными отзывами и фаворитами: возможно, я не единственный, кто сталкивается с такой же проблемой при изучении Clojure (или любого диалекта Lisp, если уж на то пошло).   -  person Cedric Martin    schedule 20.05.2012


Ответы (5)


Я думаю, что здесь есть чему поучиться.

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

поэтому обычно, когда вы добавляете аккумулятор, вы получаете обратный порядок.

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

но в вашем случае это будет иметь значение. вы собираетесь получить список в обратном порядке:

(defn my-range-0 [lo hi] ; normal recursive solution
  (if (= lo hi)
    nil
    (cons lo (my-range-0 (inc lo) hi))))

(deftest test-my-range-1
  (is (= '(0 1 2) (my-range-0 0 3))))

(defn my-range-1 ; with an accumulator
  ([lo hi] (my-range-1 lo hi nil))
  ([lo hi acc]
    (if (= lo hi)
      acc
      (recur (inc lo) hi (cons lo acc)))))

(deftest test-my-range-1
  (is (= '(2 1 0) (my-range-1 0 3)))) ; oops!  backwards!

и часто лучшее, что вы можете сделать, чтобы исправить это, — это просто перевернуть этот список в конце.

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

(defn my-range-2
  ([lo hi] (my-range-2 lo hi nil))
  ([lo hi acc]
    (if (= lo hi)
      acc
      (let [hi (dec hi)]
        (recur lo hi (cons hi acc))))))

(deftest test-my-range-2
  (is (= '(0 1 2) (my-range-2 0 3)))) ; back to the original order

[примечание - есть еще один способ изменить ситуацию ниже; я не очень хорошо структурировал свой аргумент]

во-вторых, как вы можете видеть на примерах my-range-1 и my-range-2, хороший способ написать функцию с аккумулятором — это функция с двумя разными наборами аргументов. это дает вам очень чистую (imho) реализацию без необходимости использования вложенных функций.


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

(defn my-range-3 ; this looks like my-range-1
  ([lo hi] (my-range-3 lo hi []))
  ([lo hi acc]
    (if (= lo hi)
      acc
      (recur (inc lo) hi (conj acc lo)))))

(deftest test-my-range-3 ; except that it works right!
  (is (= [0 1 2] (my-range-3 0 3))))

здесь conj добавляется вправо. я не использовал conj в my-range-1, так что здесь это переписано, чтобы было понятнее:

(defn my-range-4 ; my-range-1 written using conj instead of cons
  ([lo hi] (my-range-4 lo hi nil))
  ([lo hi acc]
    (if (= lo hi)
      acc
      (recur (inc lo) hi (conj acc lo)))))

(deftest test-my-range-4
  (is (= '(2 1 0) (my-range-4 0 3))))

обратите внимание, что этот код выглядит очень похожим на my-range-3, но результат обратный, потому что мы начинаем с пустого списка, а не с пустого вектора. в обоих случаях conj добавляет новый элемент в «естественную» позицию. для вектора справа, а для списка слева.

и мне только что пришло в голову, что вы, возможно, не совсем понимаете, что такое список. в основном cons создает поле, содержащее две вещи (его аргументы). первый - это содержимое, а второй - остальная часть списка. так что список (1 2 3) в основном (cons 1 (cons 2 (cons 3 nil))). Напротив, вектор [1 2 3] работает больше как массив (хотя я думаю, что он реализован с использованием дерева).

поэтому conj немного сбивает с толку, потому что его работа зависит от первого аргумента. для списка он вызывает cons и таким образом добавляет элементы слева. но для вектора он расширяет массив (подобную вещь) вправо. также обратите внимание, что conj принимает существующую последовательность в качестве первого аргумента, а то, что нужно добавить, в качестве второго, а cons является обратным (то, что нужно добавить, идет первым).


весь приведенный выше код доступен по адресу https://github.com/andrewcooke/clojure-lab.


обновление: я переписал тесты так, чтобы ожидаемым результатом был список в кавычках в тех случаях, когда код генерирует список. = будет сравнивать списки и векторы и возвращать true, если содержимое одинаковое, но явное отображение более четко показывает, что вы на самом деле получаете в каждом случае. обратите внимание, что '(0 1 2) с ' впереди точно так же, как (list 0 1 2) - ' останавливает оценку списка (без него 0 будет рассматриваться как команда).

person andrew cooke    schedule 19.05.2012
comment
+1, это отличный ответ (другие ответы тоже хороши). Мне понадобится некоторое время, чтобы все это переварить, но некоторые вещи уже щелкнули для меня :) - person Cedric Martin; 20.05.2012

Прочитав все это, я все еще не уверен, зачем вам нужен аккумулятор.

((fn r [a b]
    (if (<= a b) 
       (cons a (r (inc a) b)))) 
  2 4)
=> (2 3 4)

кажется довольно интуитивным рекурсивным решением. единственное, что я бы изменил в «реальном» коде, - это использовать ленивую последовательность, чтобы у вас не исчерпался стек для больших диапазонов.

как я пришел к этому решению:

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

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

В данном случае я подумал, когда начал писать: «первый аргумент функции также является начальным элементом диапазона, а последний аргумент — последним элементом». Рекурсивное мышление — это то, чему вам нужно научиться, но довольно очевидное решение — это сказать: диапазон [a, b] — это последовательность, начинающаяся с элемента a, за которым следует диапазон из [a + 1, b]. Таким образом, диапазоны действительно могут быть описаны рекурсивно. Код, который я написал, в значительной степени является прямой реализацией этой идеи.

дополнение:

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

дополнение 2:

Что касается рекурсивных функций и списков/последовательностей, самый полезный способ мышления при написании такого рода кода – формулировать проблему в терминах "первый элемент (голова) списка" и "остальные списка (хвоста)».

person Joost Diepenmaat    schedule 19.05.2012
comment
Хорошо, это здорово, но не могли бы вы объяснить, как вы написали это, когда решили использовать рекурсивность? Обратите внимание, что другое решение (с использованием аккумулятора) было написано кем-то, кто решил 150 задач 4clojure (и некоторые из более сложных задач довольно сложны), поэтому использование аккумулятора не так уж надуманно :) - person Cedric Martin; 19.05.2012

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

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

Я не собираюсь выбрасывать код, который использует nth с проанализированными данными clojure-csv в двух небольших приложениях, которые уже находятся в производстве. Но в следующий раз я буду думать о вещах более последовательно.

Трудно учиться по книгам, в которых говорится о векторах и n-м, циклах, повторениях и так далее, а затем понять, что изучение Clojure продвинет вас вперед.

Я обнаружил, что в изучении Clojure хорошо то, что сообщество уважительно и готово помочь. В конце концов, они помогают кому-то, чьим первым изучаемым языком был Fortran IV на CDC Cyber ​​с перфокартами, а первым коммерческим языком программирования был PL/I.

person octopusgrabbus    schedule 19.05.2012

Если бы я решил это с помощью аккумулятора, я бы сделал что-то вроде:

user=> (defn my-range [lb up c]
         (if (= lb up)
           c
           (recur (inc lb) up (conj c lb))))
#'user/my-range

затем назовите его с

#(my-range % %2 [])

Конечно, я бы использовал letfn или что-то подобное, чтобы обойти отсутствие defn.

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

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

Использование вектора очень помогает, потому что к нему добавляется conj, и нет необходимости использовать reverse.

Я тоже на 4clojure, кстати. Я был занят, поэтому я отстал в последнее время.

person Nathan Hughes    schedule 19.05.2012
comment
+1... И хорошая оценка на 4clojure :) Я опубликую новый вопрос о внутренней функции и другом наборе аргументов. - person Cedric Martin; 20.05.2012

Похоже, ваш вопрос больше о том, «как учиться», чем о технической проблеме/проблеме с кодом. В конечном итоге вы пишете такой код, потому что каким бы способом или из какого источника вы ни изучали программирование в целом или Clojure в частности, в вашем мозгу создается «нейронная магистраль», которая заставляет вас думать о решениях именно таким образом, и в итоге вы пишете код. нравится. По сути, всякий раз, когда вы сталкиваетесь с какой-либо проблемой (в данном конкретном случае с рекурсией и/или накоплением), вы в конечном итоге используете эту «нейронную магистраль» и всегда придумываете такой код.

Решение избавиться от этой «нейронной магистрали» состоит в том, чтобы на данный момент прекратить писать код, убрать эту клавиатуру и начать читать много существующего кода clojure (от существующих решений проблемы 4clojure до проектов с открытым исходным кодом на github) и думать о это глубоко (даже прочитать функцию 2-3 раза, чтобы она действительно осела в вашем мозгу). Таким образом, вы в конечном итоге уничтожите свою существующую «нейронную магистраль» (которая создает код, который вы пишете сейчас) и создадите новую «нейронную магистраль», которая будет создавать красивый и идиоматический код Clojure. Кроме того, старайтесь не перескакивать к вводу кода, как только вы увидели проблему, а дайте себе некоторое время, чтобы ясно и глубоко подумать о проблеме и ее решениях.

person Ankur    schedule 19.05.2012
comment
это интересно, но на самом деле я думаю, что отсутствие какой-либо нейронной магистрали Лиспа заставляет меня писать такой код: я вроде как вижу, как должно выглядеть решение (моя идея в основном такая же, как у пользователя, который решил все 150 задач), но потом... Я начинаю играть в REPL и получаю неправильное решение, но потом понимаю, что знаю (супердерьмовый) вариант решения моей проблемы. Например, я получаю (1 (2 (3 4))) и думаю: а, я могу просто сгладить это. Конечно это бред :( - person Cedric Martin; 20.05.2012
comment
Хм ... ваше решение в конечном итоге создает другую проблему (вложенный список), а затем вы решаете эту новую проблему и, надеюсь, получаете требуемое решение, а если нет, то вы решаете новую результирующую проблему :) и в конце концов вам каким-то образом удается решить исходная проблема, но промежуточные проблемы делают ваш код таким. Похоже, вам не хватает возможности просматривать всю проблему снова и снова, решая проблему, и скорее продолжаете применять грубую силу. - person Ankur; 20.05.2012
comment
-1... Это был именно мой вопрос. Я специально заявил, что понимаю проблему и понимаю, что требуется для ее решения, но проблему вызывает реализация. Вы продолжаете переписывать в точности то, что я написал, только для того, чтобы подчеркнуть, что я не понимаю. Извините, но ваш ответ не конструктивен. Вы, по-видимому, не желаете здесь помочь: единственное, что вы готовы сделать, это повторить: я не понимаю. И ты опять это делаешь в комментарии: -1. - person Cedric Martin; 21.05.2012