如何为基于Y组合子的F#找零计算函数实现缓存优化
先给你梳理下核心问题:你原来的递归函数带了acc累加器,不仅逻辑上有偏差(会把有序的硬币组合当成不同方式,比如1+2和2+1会被重复计数,不符合欧拉计划第31题的要求),还直接导致缓存失效——每次递归的acc都不一样,缓存key全是独一无二的,自然起不到复用计算的作用。下面一步步帮你解决:
步骤1:修正递归逻辑,去掉多余的acc
先写出正确的无累加器递归逻辑:对于当前硬币,要么选它(金额减少,硬币列表不变,因为可以重复选),要么不选它(硬币列表去掉当前硬币,金额不变),两种情况的方式数相加就是最终结果:
let coins = [200; 100; 50; 20; 10; 5; 2; 1] // 正确的递归逻辑:返回给定硬币子集和金额的找零方式数 let rec countWays coins amount = if amount < 0 then 0L elif amount = 0 then 1L // 找到一种有效组合 else match coins with | [] -> 0L // 没硬币可选,无法凑出金额 | c::cs -> countWays coins (amount - c) + countWays cs amount
步骤2:调整参数,让缓存可以命中
直接用硬币列表当缓存key效率不高(F#列表是引用类型),我们可以利用硬币列表固定有序的特点,用起始索引代表当前可用的硬币子集(比如索引i代表从coins.[i]开始的子列表),这样缓存key就变成(int * int)(索引+金额),非常适合作为字典的key。
同时改成适配Y组合子的形式(需要传入自身作为参数):
// 用索引i代替硬币列表,i表示当前可用的起始硬币索引 let countWaysSelf self i amount = if amount < 0 then 0L elif amount = 0 then 1L elif i >= coins.Length then 0L else let currentCoin = coins.[i] // 选当前硬币:金额减少,索引不变(可以重复选) self i (amount - currentCoin) + // 不选当前硬币:索引+1,金额不变 self (i + 1) amount
步骤3:结合Y组合子与缓存实现高效计算
现在实现带缓存的memoization函数,再用Y组合子绑定递归,重复的(索引, 金额)计算会直接从缓存读取:
open System.Collections.Generic // 通用的memoization函数:接收字典和函数,返回带缓存的函数 let memoize (dict: Dictionary<_, _>) f = fun args -> match dict.TryGetValue(args) with | true, result -> result | false, _ -> let result = f args dict.Add(args, result) result // Y组合子实现递归 let rec Y f = f (Y f) // 最终的找零方式数计算函数 let numberOfWaysToChange amount = let cache = Dictionary<(int * int), int64>() // 把参数打包成元组,方便缓存key处理 let wrappedSelf self (i, amt) = countWaysSelf self i amt // 用Y组合子绑定递归,同时套上缓存 Y (fun self -> memoize cache (wrappedSelf self)) (0, amount)
关于你想要的Dictionary<int, int64>
这个需求其实不可行,因为同一个金额,在不同的硬币子集下的找零方式数完全不同:比如金额5,用[5;2;1]的方式数是4(5、2+2+1、2+1+1+1、1*5),但用[2;1]的方式数是3,所以必须把硬币子集的状态(这里用索引)加入缓存key,才能保证缓存的正确性。
额外优化:解决栈溢出问题
如果要处理超大金额,上面的递归还是可能栈溢出,这时候可以改成尾递归或者续传风格(CPS),比如尾递归版本:
// 尾递归版本,用累加器acc保存中间结果 let countWaysTailRec amount = let rec loop i amt acc = if amt < 0 then acc elif amt = 0 then acc + 1L elif i >= coins.Length then acc else // 选当前硬币:继续用当前索引,金额减少 loop i (amt - coins.[i]) acc + // 不选当前硬币:索引+1,金额不变 loop (i + 1) amt acc loop 0 amount 0L
你可以把之前的memoization逻辑套到这个尾递归版本上,进一步提升大金额的处理效率。
内容的提问来源于stack exchange,提问作者primfaktor

