Golang中的Memoization技术详解

Golang中的Memoization技术详解 我尝试使用递归来计算斐波那契数列,主要目的是学习记忆化技术,但失败了。

我的第一次尝试: https://play.golang.org/p/BQ7Z9QxnS9 缓存没有生效,新的斐波那契函数运行速度和旧的函数一样慢。有什么方法能让它正常工作吗?

然后我尝试构建一个全局缓存,它确实提高了速度,但当数字超过93时,它返回了错误的值。我真的不知道为什么。 https://play.golang.org/p/VCegqNgig1

请帮帮我。

15 回复

非常感谢你,Jakob。问题解决了!

更多关于Golang中的Memoization技术详解的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


明白了,雅各布。 等我更深入地理解了 Go 语言后,会重新审视这个问题。

非常感谢,Joel。这对我帮助很大。我之前以为 int64 远远大于 9223372036854775807 😄 现在第二个问题已经解决了,但我仍在尝试让第一个版本能够运行。

在第一个游乐场链接中,main() 对每个 i 仅运行一次 newFib(i),这虽然构建了缓存但尚未利用它。如果你复制循环,第二个循环应该比第一个循环快得多。

今天我又遇到了一个类似的问题,这次没那么难。我也想起了这个问题,以下是我重写的代码: https://play.golang.org/p/H49N6tL4HX7

我认为对于这类任务,通道是更好的选择,但没错,Go 可以实现记忆化!

再次感谢 Jakob。我正在尝试创建一个 memoize 函数。它接收一个不带缓存的递归函数,并返回一个带缓存的新递归函数。如果这能够实现,memoize 函数就可以被复用了。在 Go 中没有办法实现这个吗?

你的问题在于记忆化(memoization)没有按预期工作。你记忆化了顶层的 fib,所以一旦你调用过一次 newFib(n),下次调用 newFib() 时就会得到缓存的答案。但在递归的 fib 内部,你并没有调用记忆化的版本,因此所有递归调用都没有利用缓存。

没有办法从已编译的Go程序内部修改其自身,而这正是你需要实现的功能。函数调用在运行时并不会像在JavaScript中那样被“查找”;它们甚至可能被内联,因此在编译后的代码中甚至不会保留为函数调用的形式。

func main() {
    fmt.Println("hello world")
}

一个简单的解决方案:

func main() {
	var memo = make(map[int]int)
	fmt.Print(fib(50, memo)) //12586269025
}

func fib(n int, memo map[int]int) int {
	if n <= 2 {
		return 1
	}
	if _, ok := memo[n]; !ok {
		memo[n] = fib(n-1, memo) + fib(n-2, memo)
	}
	return memo[n]
}

你好,Steven!感谢你的帖子 🙂

关于你的第二次尝试,我认为这更多与整数溢出有关,而不是Go语言不支持记忆化。

一个简单的证明:int64 的最大值是 9223372036854775807

在谷歌中输入:9223372036854775807 - (7540113804746346429+4660046610375530309)

结果是一个负数,这表明从这个斐波那契数开始,int64 已经不足以表示你的值了。希望这能帮到你。

谢谢,christophberger。英语不是我的母语,我不确定是否完全理解了你的意思。 你能帮我修正一下我的代码吗?我该如何复制这个循环?

我的第一次尝试:我在每个循环中从头开始计算 fib(i)(从 f(1) 开始),缓存是用于递归的,而不是用于 “for” 循环。如果缓存不能在递归中起作用,我该如何通过复制循环来让它起作用呢? 我的第二次尝试:缓存同时用于递归和 for 循环。

我的第二次尝试更快,但我仍然认为第一次尝试也应该能工作,我需要帮助来修改我的第一次尝试使其正常工作。

感谢你的澄清,Christoph,我现在明白了。

我初次尝试的问题在于这一行:

if cache[n] == 0 {
    cache[n] = f(n)
}

正如Jakob指出的那样,f 是一个递归函数。当它调用自身时,并没有使用缓存。所以我的 memoize 函数实际上返回的是原来的 fib 函数,没有做任何改变。有没有什么方法可以强制函数 f 使用缓存呢? 就像在JavaScript中那样:f.apply(this,n)。我花了整个下午的时间,但毫无头绪。

如何复制循环?

我的意思只是复制循环并再次运行它。

在你的电脑上**(不要在 Playground 中)**运行以下代码,以查看记忆化(memoizing)的结果:

Go Playground - The Go Programming Language

(Playground 在计时方面不可靠,因为它会缓存之前的结果并且会调整时间,因此任何时间操作都可能给出不准确的结果。请参阅这篇博客文章,特别是“伪造时间”和“前端”部分。)

你无法更改现有 fib 函数内部的调用,除非函数本身通过接受函数或接口参数为此做好了准备。在 JavaScript 中,你或许可以通过某种方式进行猴子补丁,但在 Go 中你无法做到1。你需要直接在函数内部应用记忆化。以你的生成器函数为例:

func newMemoizedFib() func(int) int {
	cache := make(map[int]int)
	var fn func(int) int
	fn = func(n int) int {
		if n == 1 || n == 2 {
			return 1
		}
		if _, ok := cache[n]; !ok {
			cache[n] = fn(n-1) + fn(n-2)
		}
		return cache[n]
	}
	return fn
}

在生产环境中,我可能更倾向于使用 type cachedFib struct { ... } 并将函数作为其方法,以避免闭包的魔法。或者,如果你知道感兴趣的范围,直接使用预先计算好的值表也可以……

1) ……在语言的非不安全部分的约束范围内,我们在本次练习中应遵守这些约束。

在Golang中实现Memoization时,你遇到了两个典型问题:闭包变量作用域和整数溢出。以下是解决方案:

问题1:闭包中的缓存失效

你的第一个尝试中,每次递归调用都创建了新的缓存map。修正方案:

func memoize(f func(int) uint64) func(int) uint64 {
    cache := make(map[int]uint64)
    return func(n int) uint64 {
        if val, found := cache[n]; found {
            return val
        }
        val := f(n)
        cache[n] = val
        return val
    }
}

func fib(n int) uint64 {
    if n <= 1 {
        return uint64(n)
    }
    return fib(n-1) + fib(n-2)
}

func main() {
    memoizedFib := memoize(fib)
    
    // 关键:需要让fib函数内部也使用memoized版本
    fib = memoizedFib
    
    fmt.Println(fib(50)) // 快速计算
}

更简洁的实现方式:

var cache = make(map[int]uint64)

func fibMemoized(n int) uint64 {
    if n <= 1 {
        return uint64(n)
    }
    
    if val, ok := cache[n]; ok {
        return val
    }
    
    val := fibMemoized(n-1) + fibMemoized(n-2)
    cache[n] = val
    return val
}

问题2:大数溢出(超过93时错误)

当n>93时,斐波那契数超过64位整数范围。使用math/big包:

import "math/big"

var cacheBig = make(map[int]*big.Int)

func fibBigMemoized(n int) *big.Int {
    if n <= 1 {
        return big.NewInt(int64(n))
    }
    
    if val, ok := cacheBig[n]; ok {
        return val
    }
    
    val := new(big.Int)
    val.Add(fibBigMemoized(n-1), fibBigMemoized(n-2))
    cacheBig[n] = val
    return val
}

func main() {
    fmt.Println(fibBigMemoized(100))
    // 输出: 354224848179261915075
}

线程安全的Memoization实现

对于并发环境,需要添加锁:

import "sync"

type MemoizedFib struct {
    cache map[int]*big.Int
    mu    sync.RWMutex
}

func NewMemoizedFib() *MemoizedFib {
    return &MemoizedFib{
        cache: make(map[int]*big.Int),
    }
}

func (m *MemoizedFib) Calculate(n int) *big.Int {
    if n <= 1 {
        return big.NewInt(int64(n))
    }
    
    // 先尝试读锁
    m.mu.RLock()
    if val, ok := m.cache[n]; ok {
        m.mu.RUnlock()
        return val
    }
    m.mu.RUnlock()
    
    // 计算并写入
    m.mu.Lock()
    defer m.mu.Unlock()
    
    // 双重检查,防止其他goroutine已计算
    if val, ok := m.cache[n]; ok {
        return val
    }
    
    val := new(big.Int)
    val.Add(m.Calculate(n-1), m.Calculate(n-2))
    m.cache[n] = val
    return val
}

通用Memoization装饰器

创建可重用的Memoization包装器:

func Memoize[T comparable, R any](f func(T) R) func(T) R {
    cache := make(map[T]R)
    var mu sync.Mutex
    
    return func(input T) R {
        mu.Lock()
        defer mu.Unlock()
        
        if val, ok := cache[input]; ok {
            return val
        }
        
        val := f(input)
        cache[input] = val
        return val
    }
}

// 使用示例
func expensiveCalculation(n int) int {
    time.Sleep(100 * time.Millisecond)
    return n * n
}

func main() {
    memoizedCalc := Memoize(expensiveCalculation)
    
    // 第一次调用计算
    fmt.Println(memoizedCalc(5)) // 耗时100ms
    
    // 第二次调用从缓存读取
    fmt.Println(memoizedCalc(5)) // 立即返回
}

这些实现解决了你的两个问题:缓存生效问题和整数溢出问题。关键点是确保缓存变量在闭包外部声明,并且对于大数使用math/big.Int类型。

回到顶部