累加器、conj 和递归



我已经解决了 4clojure.com 的45个问题,我注意到在我尝试使用递归和累加器解决一些问题的方式中反复出现的问题。

我会尽力解释我正在做的事情,最终得到愚蠢的解决方案,希望一些 Clojurers 能够"得到"我没有得到的东西。

例如,问题 34 要求编写一个函数(不使用范围(,将两个整数作为参数并创建一个范围(不使用范围(。 简单地说,你做(...1 7(你得到(1 2 3 4 5 6(。

现在这个问题不是关于解决这个特定问题。

如果我想使用递归和累加器来解决这个问题怎么办?

我的思考过程是这样的:

  • 我需要编写一个包含两个参数的函数,我从 (fn [x y] ( 开始

  • 我需要递归并且需要跟踪列表,我将使用累加器,所以我在第一个函数中编写了第二个函数,其中包含一个额外的参数:

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

(显然我无法在SO上正确格式化该Clojure代码!?

在这里我已经不确定我是否正确:第一个函数必须恰好接受两个整数参数(不是我的调用(,我不确定:如果我想使用累加器,我可以在不创建嵌套函数的情况下使用累加器吗?

然后我想叨叨,但我不能做:

(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)

我认为这是一个能够解决一些问题的开始,但我对我倾向于产生的丑陋解决方案有点失望......

我认为这里有几件事需要学习。

首先,一种一般规则 - 递归函数通常具有自然顺序,添加累加器会逆转这种情况。 你可以看到这一点,因为当一个"正常"(没有累加器(递归函数运行时,它会做一些工作来计算一个值,然后递归以生成列表的尾部,最后以空列表结束。 相反,使用累加器,您从空列表开始,然后将内容添加到前面 - 它正在向另一个方向增长

所以通常情况下,当你添加一个累加器时,你会得到一个相反的顺序。

现在这通常无关紧要。 例如,如果您生成的不是序列,而是交换运算符(如加法或乘法(的重复应用的值。 然后无论哪种方式,您都会得到相同的答案。

但在你的情况下,这将很重要。 您将向后获取列表:

(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-1my-range-2中看到的,使用累加器编写函数的一种好方法是作为具有两组不同参数的函数。 这为您提供了一个非常干净(恕我直言(的实现,而无需嵌套函数。


此外,您还有一些关于序列、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正在向右添加。 我没有在my-range-1中使用conj,所以这里重写以更清晰:

(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将被视为命令(。

读完所有这些,我仍然不确定为什么你需要一个累加器。

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

似乎是一个非常直观的递归解决方案。 我在"真实"代码中唯一要更改的是使用 lazy-seq,这样您就不会在大范围内耗尽堆栈。

我是如何得到这个解决方案的:

当你考虑使用递归时,我发现尝试用你能想到的尽可能少的术语来陈述问题会有所帮助,并尝试将尽可能多的"工作"交给递归本身。

特别是,如果你怀疑你可以删除一个或多个参数/变量,这通常是要走的路 - 至少如果你希望代码易于理解和调试;有时你最终会为了执行速度或减少内存使用而牺牲简单性。

在这种情况下,当我开始编写时,我的想法是:"函数的第一个参数也是范围的开始元素,最后一个参数是最后一个元素"。递归思维是你必须训练自己做的事情,但一个相当明显的解决方案是:范围[a, b]是从元素a开始的序列,后跟一系列[a + 1, b]。因此,范围确实可以递归描述。我写的代码几乎是这个想法的直接实现。

补遗:

我发现在编写函数式代码时,最好避免使用累加器(和索引(。有些问题需要它们,但如果你能找到摆脱它们的方法,如果你这样做,你通常会更好。

增编2:

关于递归函数和列表/序列,在编写这种代码时,最有用的思考方法是用"列表的第一项(头("和"列表的其余部分(尾部("来陈述你的问题。

我无法补充您收到的已经很好的答案,但我会大致回答。当你经历Clojure学习过程时,你可能会发现许多但不是所有的解决方案都可以使用Clojure内置来解决,比如map,也可以从序列的角度思考问题。这并不意味着你不应该递归解决问题,但你会听到 - 我相信这是明智的建议 - Clojure递归是为了解决你无法用其他方式解决的非常低级的问题。

我碰巧做了很多.csv文件处理,最近收到一条评论,说 nth 创建依赖项。确实如此,使用地图可以让我按名称而不是位置来比较元素。

我不会抛弃在两个已经在生产中的小应用程序中使用 nth 的代码和 clojure-csv 解析的数据。但下次我会以更连续的方式思考问题。

很难从谈论向量和nth,循环..递归等的书中学习,然后意识到学习Clojure会让你从那里向前发展。

我发现学习Clojure的好处之一是社区是尊重和乐于助人的。毕竟,他们正在帮助那些第一次学习语言是CDC Cyber上的Fortran IV的人打孔卡,并且他们的第一种商业编程语言是PL/I。

如果我使用累加器解决这个问题,我会做如下的事情:

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 .

我也在 4 clojure 上,顺便说一句。我一直很忙,所以我最近

落后了。

看起来你的问题更多的是关于"如何学习",然后是技术/代码问题。你最终会写出这种代码,因为无论你从什么方式或来源学习编程,或者特定的Clojure,在你的大脑中创造了一条"神经高速公路",让你以这种特定的方式思考解决方案,你最终写出了这样的代码。基本上,每当你遇到任何问题(在这种特殊情况下是递归和/或积累(时,你最终都会使用那个"神经高速公路",并且总是想出那种代码。

摆脱这种"神经高速公路"的解决方案是暂时停止编写代码,远离键盘并开始阅读大量现有的 clojure 代码(从现有的 4clojure 问题解决方案到 github 上的开源项目(并深入思考(甚至阅读一个函数 2-3 次,让它真正在你的大脑中安定下来(。这样,你最终会破坏你现有的"神经高速公路"(产生你现在编写的代码(,并将创建一个新的"神经高速公路",产生美丽而惯用的Clojure代码。另外,尽量不要在看到问题后立即跳到键入代码,而是给自己一些时间清晰而深入地思考问题和解决方案。

相关内容

  • 没有找到相关文章

最新更新