3n + 1 问题——引发的缓存思考

数学基础 2019-10-01 3655 字 1095 浏览 点赞

问题描述

有这样一个规律:对任意大于 1 的自然数 n,如果 n 为奇数,将其变化为 3n + 1;如果 n 为偶数,将其变化为 n/2。经过若干次变化后,一定会使 n 变为 1。

如果 n = 5:

  • 5 -> 16 -> 8 -> 4 -> 2 -> 1

如果 n = 3:

  • 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1

现在,输入一个整数 n,请计算出总的变化次数。例:n = 5,l = 5;n = 3,l = 7。(l 表示变化次数)

方法 1(实时计算)

最简单处理方式是递归。由于每一次 n/2 或者 3n-1 操作后,变化次数 l 会加 1,可以得出公式模型为:f(n) = f(n-1) + 1。递归函数必须有出口,当 n = 1 时,会进入 1 -> 4 -> 2 -> 1 -> 4 ... 这样的死循环。所以出口条件是 n <= 1。最终得到代码如下。

size_t solve_3nplus1(size_t n)
{
    if (n <= 1)
        return 0;

    return solve_3nplus1( (n%2 == 0) ? n/2 : 3*n+1 ) + 1;
}

使用:

int main()
{
    cout << solve_3nplus1(3) << endl;
    cout << solve_3nplus1(5) << endl;
}

/* 输出:*/
7
5

方法 2(结果缓存)

事实上,你会发现 方法1 是一种实时计算。也就是说,第一次 n = 3 的时候,程序会 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1,第二次 n = 3 的时候,程序还是会 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1,于是这中间多了许多重复计算。

提升程序效率的一种、众所周知的方式是缓存。常见的缓存处理方式是,在核心处理逻辑(solve_3nplus1)之上,套一层缓存逻辑(solve_3nplus1_cache)。缓存层负责将计算结果保存下来,每次外部接口请求时,缓存层会先去缓存里看看,“我有没有计算过 n = 3 呀”,有就直接将结果返回;没有,那么调用 solve_3nplus1 计算出结果。向外部返回结果的同时,拷贝一份结果放在缓存里。

完整代码如下:

/* 计算逻辑 */
size_t solve_3nplus1(size_t n)
{
    if (n <= 1)
        return 0;

    return solve_3nplus1( (n%2 == 0) ? n/2 : 3*n+1 ) + 1;
}
/* 缓存逻辑 */
size_t solve_3nplus1_cache(vector<size_t>& v, size_t n)
{
    size_t l = 0;
    
    /* 避免越界访问 */
    while (n >= v.size()) {
        n = (n%2 == 0) ? n/2 : 3*n+1;
        ++l;
    }

    if (v[n] == 0) {
        cout << "实时计算... n = " << n << endl;
        v[n] = solve_3nplus1(n);
    }
    else
        cout << "缓存中取得... n = " << n << endl;

    return v[n] + l;
}

用简单的测试用例进行功能测试:

int main()
{
    vector<size_t> v(6);
    cout << solve_3nplus1_cache(v, 3) << endl;
    cout << solve_3nplus1_cache(v, 5) << endl;
    cout << solve_3nplus1_cache(v, 3) << endl;
}

/* 输出:*/
实时计算... n = 3
7
实时计算... n = 5
5
缓存中取得... n = 3
7

瞧上去功能是实现了,但是哪里不对劲。得捋捋:

  • 3 -> 1变化中,完整过程是:3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1。
  • 5 -> 1变化中,完整过程是:5 -> 16 -> 8 -> 4 -> 2 -> 1。

看得出,n = 3 与 n = 5 时有部分是重合的,更准确说,n = 3 包含了 n = 5。结论显而易见:程序还是在做重复计算。这是因为,上述代码仅仅对计算结果缓存,而非计算过程。

方法 3(过程缓存)

3n + 1 问题显然是符合过程缓存的,因为过程中产生的数据能够推导出目标结果(这句话会不会太抽象?总之结合 n = 3、n = 5 就能明了许多)。

size_t solve_3nplus1_cache(vector<size_t>& v, size_t n);

size_t solve_3nplus1(vector<size_t>& v, size_t n)
{
    v[1] = 1;  /* 设置出口 */
    return solve_3nplus1_cache(v, n) - 1;  /* 由于v[1]=1, 所以最后需要减1 */
}

size_t solve_3nplus1_cache(vector<size_t>& v, size_t n)
{
    size_t l = 0;
    /* 防越界 */
    while (n >= v.size()) {
        n = (n%2 == 0) ? n/2 : 3*n+1;
        ++l;
    }

    if (v[n] == 0)
        v[n] = solve_3nplus1_cache(v, (n%2 == 0) ? n/2 : 3*n+1) + 1;
    else if (n != 1)  /* 避免 n = 1 的影响 */
        cout << "从缓存中取得... n = " << n << endl;
    return v[n] + l;
}

与 方法2 的不同之处是,方法3 把递归过程中计算出来的所有数据都保存下来,而不是只保留最终结果。

测试代码:

int main()
{
    vector<size_t> v(20);

    cout << solve_3nplus1(v, 3) << endl;
    cout << solve_3nplus1(v, 5) << endl;
    cout << solve_3nplus1(v, 3) << endl;

    cout << "v[i] = ";
    for (int i=0; i<v.size(); ++i) {
        cout << v[i] << " ";
    }
    cout << endl;
}

/* 输出:*/
7
从缓存中取得... n = 5
5
从缓存中取得... n = 3
7
v[i] = 0 1 2 8 3 6 0 0 4 0 7 0 0 0 0 0 5 0 0 0
             *   *
  • 注:容器 v 中存储的次数 l 要比实际值大 1。

方法3 比 方法2 在缓存上做得更极致。但是不可避免的是,方法3 中,缓存层与业务层基本耦合在一起,代码可阅读性较差。虽说不是不可以优化,但对程序员的编码能力要求更高了。

最后

3n + 1 问题可以说是一个很简单的问题,但在过程中引发的“缓存”思想却很重要。

—— 其实更像是无聊人的胡思乱想而已!但少闲人如吾一人者耳……



本文由 Guan 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论