快速解决这个问题,从而避免耗尽计算机的资源,例如堆栈,需要您重用之前计算的部分答案。
假设我们想解决一个类似的问题,即我们试图找出仅使用 1、2 或 5 美分硬币有多少种方法可以赚取 15 美分。我们要解决两个问题——第一个是正确解决问题。二是快速解决问题。
正确解决问题
为了正确解决这个问题,我们需要避免重新计算我们已经计算过的硬币组合。例如,我们可以通过以下方式赚取 15 美分:
- 2个五分硬币,5个一分硬币
- 1个五分硬币,5个一分硬币,1个五分硬币
- 2个一分硬币,1个五分硬币,2个一分硬币,1个五分硬币,1个一分硬币
上述所有示例都使用相同的硬币组合。他们都使用 2 个 5 美分硬币和 5 个 1 美分硬币,按不同的顺序计数。
我们可以通过始终以相同的顺序发行我们的硬币来避免上述问题。这提出了一个简单的算法,我们可以通过多少种方式从硬币列表中进行一定数量的更改。我们可以使用第一种类型的硬币,也可以承诺不再使用那种类型的硬币。
waysToMake 0 _ = 1
waysToMake x _ | x < 0 = 0
waysToMake x [] = 0
waysToMake x (c:cs) = waysToMake (x-c) (c:cs) + waysToMake x cs
前面的案例涵盖了边界条件。假设没有有问题的零或负值硬币,有1
办法制作0
. 有一些0
方法可以进行负 ( < 0
) 的更改。如果您没有可用来找零的硬币类型,就没有办法找零。
让我们看看如果我们尝试评估会发生什么waysToMake 15 [1,2,5]
。我们将评估每个waysToMake
步骤以保持简短。
waysToMake 15 [1,2,5]
waysToMake 14 [1,2,5] + waysToMake 15 [2,5]
waysToMake 13 [1,2,5] + waysToMake 14 [2,5] + waysToMake 13 [2,5] + waysToMake 15 [5]
waysToMake 12 [1,2,5] + waysToMake 13 [2,5] + waysToMake 12 [2,5] + waysToMake 14 [5]
+ waysToMake 11 [2,5] + waysToMake 13 [5] + waysToMake 10 [5] + waysToMake 15 []
waysToMake 11 [1,2,5] + waysToMake 12 [2,5] + waysToMake 11 [2,5] + waysToMake 13 [5]
+ waysToMake 10 [2,5] + waysToMake 12 [5] + waysToMake 9 [5] + waysToMake 14 []
+ waysToMake 9 [2,5] + waysToMake 11 [5] + waysToMake 8 [5] + waysToMake 13 []
+ waysToMake 5 [5] + waysToMake 10 [] + 0
前三个步骤看起来并不太可疑,但我们已经遇到过waysToMake 13 [2,5]
两次。在第四步中,我们看到waysToMake 12 [2, 5]
, waysToMake 11 [2, 5]
,waysToMake 13 [5]
所有这些都是我们之前看到的。我们可以看到我们将重复我们生成的大多数其他表达式,这些表达式本身将生成重复表达式的表达式。啊; 我有限的脑力计算机已经在抱怨要做的工作太多了。我们可以寻找更好的顺序来使用(有一个)中的硬币,但它仍然会重复子问题,而子问题本身也会重复子问题等。
快速解决问题
有一种更快的方法可以做到这一点。每一步,我们将使用较少硬币和不使用硬币的数字相加。让我们做一个表格,并在计算时记录每个结果。每一步,我们都需要表格左侧的数字(使用第一个硬币中的一个)和表格下方的一个(不要使用任何第一个硬币)。我们最终将探索整个表格。我们可以从填充边界条件覆盖的左边缘和下边缘的数字开始。
Coins\Change 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[1,2,5] 1
[2,5] 1
[5] 1
[] 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
现在我们将添加所有可以从表中已有数字计算的数字。使用 5 美分硬币需要左边的第 5 个位置,以及下方的第 1 个位置。
Coins\Change 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[1,2,5] 1
[2,5] 1
[5] 1 0 0 0 0 1
[] 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
使用 2 美分硬币需要左边的 2 号运动,以及向下的第 1 点。
Coins\Change 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[1,2,5] 1
[2,5] 1 0 1
[5] 1 0 0 0 0 1 0 0 0 0 1
[] 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
使用 1 美分硬币需要左边的第 1 点,以及下面的第 1 点。
Coins\Change 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[1,2,5] 1 1
[2,5] 1 0 1 0 1
[5] 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
[] 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
我们再走一步。我们可以看到,在另外 13 个简单的步骤中,我们将计算出第一行中 15 的数字,我们就完成了。
Coins\Change 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[1,2,5] 1 1 2
[2,5] 1 0 1 0 1 1 1
[5] 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
[] 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
这是所有步骤之后的表格。我的心算机毫不费力地计算出来waysToMake 15 [1,2,5] = 18
。
Coins\Change 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
[1,2,5] 1 1 2 2 3 4 5 6 7 8 10 11 13 14 16 18
[2,5] 1 0 1 0 1 1 1 1 1 1 2 1 2 1 2 2
[5] 1 0 0 0 0 1 0 0 0 0 1 0 0 0 0 1
[] 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
如果我们找到更好的顺序来使用(有一个)中的硬币,我们就不需要填写所有表格,但工作量大致相同。
我们可以使用数组包Array
中的fromData.Array
在Haskell 中制作这样的表。
使用表的一般计划是根据我们的函数定义一个表waysToMake
。无论在哪里waysToMake
递归到自身,都可以在表格中查找结果。我们在途中有两个皱纹要处理。
第一个问题是 anArray
要求数组中的索引是 的实例Ix
。我们的硬币列表没有很好的数组索引。相反,我们可以用我们跳过的硬币数量替换硬币列表。0
第一行,1
第二行,最后一行的硬币列表的长度。
第二个问题是我们想要超越桌子的边缘。我们可以定义一个特殊的查找过程来用 0 填充表外的部分,我们可以更改代码使其永远不会在表外查找,或者我们可以制作一个两倍大的超大表。我将跳过所有这些路线,并检查该值是否属于表的责任部分。
waysToMake x coins = waysToMake' (x,0)
where
tabled = tableRange ((0,0),(x,length coins)) waysToMake'
waysToMake' (n, s) = waysToMake'' n (drop s coins)
where
waysToMake'' 0 _ = 1
waysToMake'' n _ | n < 0 = 0
waysToMake'' n [] = 0
waysToMake'' n (c:cs) = tabled (n-c, s) + tabled (n, s+1)
tableRange
创建一个在一定范围内记忆其结果的函数。它构建了Array
一个在这些范围内保存函数的惰性求值结果。它返回的函数检查参数是否在边界范围内,如果是,则从表中查找结果,否则直接询问原始函数。
tableRange :: Ix a => (a, a) -> (a -> b) -> (a -> b)
tableRange bounds f = lookup
where
lookup x = if inRange bounds x then table ! x else f x
table = makeArray bounds f
makeArray
是一个便利函数,它使包含所提供函数的数组f
应用于所提供的每个索引bounds
。
makeArray :: Ix i => (i, i) -> (i -> e) -> Array i e
makeArray bounds f = listArray bounds . map f $ range bounds
我们的代码现在几乎可以立即运行,即使对于像waysToMake 10000 [200,100,50,20,10,5,2,1]
.
我们可以继续讨论如何使递归函数的这种“表格”或“备忘录”或“记忆”或“动态编程”代码通用,但我认为讨论属于不同的问题。如果您想了解函数不动点的非常实际的使用,这是一个很好的主题。