题单 · 习题解析

洛谷 P1464 Function

题面把递归都给你了,全部分数却在别处:跑得动吗、四条规则的顺序对吗、读得对吗

原题:洛谷 P1464出自 第 2 章 递归的分解思维:汉诺塔与斐波那契 的题单题面本地存档:2026-08-25
⚠ 先自己写一遍,再往下看

这一页不是标准答案,是一条阶梯:把大多数人真实会写出的第一版摆出来, 看它错在哪、慢在哪,再一步一步改。所以它对「已经自己动手撞过一次墙」的人最有用。
还没写过的话,先去写 —— 写出来的哪怕是错的,也比直接看这一页收获大。

0题目原文(本地存了一份)

为什么要在这儿抄一份题面

原题在洛谷上(页头有链接)。别人的网站不归我们管,打不开、改版、题号调整都可能发生。 所以每个解析页都把题面转录一份存在本地,跟着仓库一起进版本库。

转录自洛谷 P1464,日期见页头。两边不一致时信原站。

题目描述

对于一个递归函数 w(a, b, c):

  1. 如果 a ≤ 0 或 b ≤ 0 或 c ≤ 0 就返回值 1。
  2. 如果 a > 20 或 b > 20 或 c > 20 就返回 w(20, 20, 20)。
  3. 如果 a < b 并且 b < c 就返回 w(a,b,c-1) + w(a,b-1,c-1) − w(a,b-1,c)。
  4. 其它的情况就返回 w(a-1,b,c) + w(a-1,b-1,c) + w(a-1,b,c-1) − w(a-1,b-1,c-1)。

这是个简单的递归函数,但实现起来可能会有些问题。当 a、b、c 均为 15 时, 调用的次数将非常的多。你要想个办法才行。

注意:例如 w(30, -1, 0) 又满足条件 1 又满足条件 2, 请按照最上面的条件来算,答案为 1。

输入格式:会有若干行,并以 -1 -1 -1 结束。

输出格式:输出若干行,每一行格式 w(a, b, c) = ans。注意空格。

数据范围:保证输入的数在 [-2⁶³, 2⁶³ - 1] 之间,并且是整数。 不包括 -1 -1 -1 的输入行数 T 满足 1 ≤ T ≤ 10⁵。

输入输出样例

输入

1 1 1
2 2 2
-1 -1 -1

输出

w(1, 1, 1) = 2
w(2, 2, 2) = 4

样例只有两行。注意输出格式里逗号后面有一个空格。 上面那段输出是仓库里的 p1464.cpp 真跑出来的。

学生版:一页看懂这道题

这道题考什么:把题面给的递归一字不差抄进代码,再用记忆化跑得动。 题面没有给算法,只给了四条规则,全部分数都在「抄得对、跑得动、读得对」上。

最容易错的三处

  1. 抄规则时数错项数。规则 3 是三项,规则 4 是四项。规则 3 当成四项,w(5,5,5) 会从 32 变成 124,程序不报任何错。 抄完逐项数一遍加号和减号。
  2. 判断的顺序。w(30,-1,0) 既满足「≤ 0」又满足「> 20」,题面说按最上面的条件算。 题面专门举例的地方,就是坑的位置。
  3. 读入要用 long long,表要跨询问保留:w 是纯函数,表留着,问得越多越划算。

能 AC 的:第 ③ 版(顺序照题面、记忆化、表跨询问保留)。

下面每一步里灰色的「深入」块是折叠的:那是这些结论怎么实测出来的,第一遍可以跳过。

1先手算一遍:w(1,1,1) 凭什么等于 2

★ 这一步不能跳。 题面里的 w 没有任何现实意义 —— 它不是「求什么」, 它就是那四条规则本身。这种题读一遍是读不懂的, 唯一的办法是:挑最小的一组,照着规则手算一次。

样例第一行就是 w(1, 1, 1),答案 2。它为什么是 2?照规则从上往下问一遍:

① 三个数都 > 0 吗?   是  ->  规则 1 不管它
② 有谁 > 20 吗?      没有 ->  规则 2 不管它
③ a < b 并且 b < c?  1 < 1 不成立  ->  规则 3 不管它
⇒ 落到规则 4

于是它按规则 4 摊开成四项,而这四项里每一个都有 0,全部直接命中规则 1 返回 1:

w(1,1,1) = w(0,1,1) + w(0,0,1) + w(0,1,0) - w(0,0,0)        <- 规则 4
  w(0,1,1) = 1        <- 规则 1(有一个数 <= 0)
  w(0,0,1) = 1        <- 规则 1(有一个数 <= 0)
  w(0,1,0) = 1        <- 规则 1(有一个数 <= 0)
  w(0,0,0) = 1        <- 规则 1(有一个数 <= 0)
w(1,1,1) = 1 + 1 + 1 - 1 = 2

再往上一层,形状一模一样 —— w(2,2,2) 的四项各自又摊成上面这种四项:

w(2,2,2) = w(1,2,2) + w(1,1,2) + w(1,2,1) - w(1,1,1)        <- 规则 4
  w(1,2,2) = 2
  w(1,1,2) = 2
  w(1,2,1) = 2
  w(1,1,1) = 2
w(2,2,2) = 2 + 2 + 2 - 2 = 4
p1464Trace.cpp把展开打出来
不用输入,直接跑。上面那两段就是它打出来的;它还有第 ③ 段,第 ④ 步会用到。
// P1464 —— 把 w 的展开打出来看:这个函数到底在算什么,以及为什么它慢
//
// 输入:无
// 输出:三段
// ① w(1,1,1) 的完整展开(四项都直接命中规则 1,手算就能跟完)
// ② w(2,2,2) 的完整展开(多了一层,形状和 ① 一样)
// ③ 算 w(5,5,5) 时,同一个 (a,b,c) 被**重新算了多少遍** —— 这就是记忆化的全部理由
//
// ★ 这份程序不解题,它只回答一个问题:**看不懂题面的时候,先手算一次。**
// w(a,b,c) 没有任何现实意义,它就是四条规则;而四条规则里到底走哪一条,
// 只有把一层层展开摆出来才看得见。
//
// ⚠ 规则的判定顺序和正解完全一致(规则 1 排最前)—— 否则这张展开图就是错的。
#include <bits/stdc++.h>
using namespace std;
/** 判断 (a,b,c) 该走哪条规则,返回 1~4。 */
int ruleOf(int a, int b, int c) {
if (a <= 0 || b <= 0 || c <= 0) return 1;
if (a > 20 || b > 20 || c > 20) return 2;
if (a < b && b < c) return 3;
return 4;
}
/**
* 规则 3 / 4 各自的子问题,连同它前面的符号。
* ⚠ 两条规则的项数**不一样**:规则 3 是三项(两加一减),规则 4 是四项(三加一减)。
* 最早写这份程序时把规则 3 也当成四项,w(5,5,5) 就成了 124(正确是 32)—— 展开图会整张骗人。
*/
vector<pair<int, array<int, 3>>> childrenOf(int a, int b, int c) {
if (ruleOf(a, b, c) == 3)
return {{+1, {a, b, c - 1}}, {+1, {a, b - 1, c - 1}}, {-1, {a, b - 1, c}}};
return {{+1, {a - 1, b, c}}, {+1, {a - 1, b - 1, c}},
{+1, {a - 1, b, c - 1}}, {-1, {a - 1, b - 1, c - 1}}};
}
long long w(int a, int b, int c) {
int r = ruleOf(a, b, c);
if (r == 1) return 1;
if (r == 2) return w(20, 20, 20);
long long s = 0;
for (auto& [sign, t] : childrenOf(a, b, c)) s += sign * w(t[0], t[1], t[2]);
return s;
}
string name(int a, int b, int c) {
return "w(" + to_string(a) + "," + to_string(b) + "," + to_string(c) + ")";
}
/** 缩进打印一棵展开树。depth 只是缩进用;叶子(规则 1)直接打 = 1。 */
void show(int a, int b, int c, int depth) {
string pad(depth * 2, ' ');
int r = ruleOf(a, b, c);
if (r == 1) {
cout << pad << name(a, b, c) << " = 1 <- 规则 1(有一个数 <= 0)\n";
return;
}
auto ch = childrenOf(a, b, c);
cout << pad << name(a, b, c) << " = ";
for (size_t i = 0; i < ch.size(); i++) {
cout << (i == 0 ? "" : (ch[i].first < 0 ? " - " : " + "))
<< name(ch[i].second[0], ch[i].second[1], ch[i].second[2]);
}
cout << " <- 规则 " << r << "\n";
for (auto& [sign, t] : ch) show(t[0], t[1], t[2], depth + 1);
cout << pad << name(a, b, c) << " = " << w(a, b, c) << "\n";
}
int main() {
cout << "① 展开 w(1,1,1):四项全都撞上规则 1,一步到底\n\n";
show(1, 1, 1, 0);
cout << "\n② 展开 w(2,2,2):多一层,形状和上面一模一样\n\n";
show(2, 2, 2, 0);
// ③ 子问题重叠:算 w(5,5,5) 的过程里,同一个 (a,b,c) 被重新算了几遍
map<array<int, 3>, long long> cnt;
// 这里不能调用上面的 w()(它不记次数),单独写一份带计数的
function<long long(int, int, int)> count = [&](int a, int b, int c) -> long long {
cnt[{a, b, c}]++;
int r = ruleOf(a, b, c);
if (r == 1) return 1;
if (r == 2) return count(20, 20, 20);
long long s = 0;
for (auto& [sign, t] : childrenOf(a, b, c)) s += sign * count(t[0], t[1], t[2]);
return s;
};
long long ans = count(5, 5, 5);
long long total = 0;
for (auto& [k, v] : cnt) total += v;
vector<pair<long long, array<int, 3>>> top;
for (auto& [k, v] : cnt) top.push_back({v, k});
sort(top.rbegin(), top.rend());
cout << "\n③ 算 w(5,5,5) = " << ans << " 的过程里,一共调用了 " << total << " 次 w(),\n";
cout << " 而不同的 (a,b,c) 只有 " << cnt.size() << " 个。被重算最多的五个:\n\n";
for (int i = 0; i < 5 && i < (int)top.size(); i++) {
cout << " " << name(top[i].second[0], top[i].second[1], top[i].second[2])
<< " 被算了 " << top[i].first << " 遍\n";
}
cout << "\n " << total << " 次调用 / " << cnt.size() << " 个不同的子问题"
<< " —— 重复的那些,就是记忆化省下来的。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 规则 3 和规则 4 的项数不一样 —— 这是抄题面时最容易抄错的地方
  • 规则 3 是三项:w(a,b,c-1) + w(a,b-1,c-1) − w(a,b-1,c)
  • 规则 4 是四项:w(a-1,b,c) + w(a-1,b-1,c) + w(a-1,b,c-1) − w(a-1,b-1,c-1)

⚠ 写这一页那份 p1464Trace.cpp 时就把规则 3 也当成了四项 —— w(5,5,5) 当场从 32 变成 124,而程序不报任何错。

⇒ 抄题面给的递归时,逐项数一遍加号和减号,比读三遍题都管用。

2先看清楚:这道题不用你想算法

递归是题面直接给的,四条规则一字不差抄进代码就行。所以这道题考的完全不是「想出解法」, 而是三件很具体的事:

① 跑得动吗          四条规则照抄,w(15,15,15) 要调用多少次?
② 规则的顺序对吗    题面专门举了 w(30,-1,0) 这个例子 —— 那句话就是分数
③ 读得对吗          输入范围到 2^63,用 int 读会悄悄截断

★ 这三件事没有一件是「算法」,但它们决定了这道题的全部分数。

3第 ① 版:把四条规则照抄一遍(对,但跑不完)

p1464Naive.cpp第 ① 版(跑不完)
先跑这两组。再把 8 8 8 改成 10 10 10 —— 会明显卡一下。题面点名的 15 15 15 就别试了。
// 洛谷 P1464 Function —— 第 ① 版:把题面上那四条规则照抄成代码(对,但跑不完)
//
// 输入:若干行 a b c,以 -1 -1 -1 结束
// 输出:每行 "w(a, b, c) = ans"
//
// 这份为什么存在:这道题的递归**是题面直接给的**,一个字都不用自己想 ——
// 所以它是「照着写就完事了吗」这个问题最干净的一次反例。
//
// ⚠ 它慢得非常夸张,而且题面自己都提醒了:「当 a,b,c 均为 15 时,调用的次数将非常的多」。
// 实测(p1464Count.cpp 跑出来的):
// w(8,8,8) 4 464 671 次
// w(10,10,10) 489 048 226 次
// w(12,12,12) 56 533 377 950 次
// 而题目允许有 10^5 行询问。⇒ 一行都跑不完。
//
// ★ 顺带一个跑出来才看见的规律:**w(n,n,n) 正好是 2^n**(2、4、32、256、1024、4096)。
// 知道这条不影响做题,但它是个提醒:**答案很小,不代表算出它很便宜。**
#include <bits/stdc++.h>
using namespace std;
long long w(long long a, long long b, long long c) {
if (a <= 0 || b <= 0 || c <= 0) return 1; // ★ 这一条必须排在最前面,见第 ③ 版
if (a > 20 || b > 20 || c > 20) return w(20, 20, 20);
if (a < b && b < c) return w(a, b, c - 1) + w(a, b - 1, c - 1) - w(a, b - 1, c);
return w(a - 1, b, c) + w(a - 1, b - 1, c) + w(a - 1, b, c - 1) - w(a - 1, b - 1, c - 1);
}
int main() {
long long a, b, c;
while (cin >> a >> b >> c) {
if (a == -1 && b == -1 && c == -1) break;
cout << "w(" << a << ", " << b << ", " << c << ") = " << w(a, b, c) << "\n";
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
⚠ 题面自己都提醒了:「调用的次数将非常的多」

实测(第 ⑦ 步那张表):

w(8,8,8)      4 464 671 次
w(10,10,10)   489 048 226 次
w(12,12,12)   56 533 377 950 次

而题目允许 10^5 行询问。⇒ 一行都跑不完。

★ 顺带一个跑出来才看见的规律:w(n,n,n) 正好是 2^n(2、4、32、256、1024、4096)。

这条规律不影响做题,但它是个很重要的提醒: 答案很小,不代表算出它便宜。 w(10,10,10) 的答案只有 1024,却要走近五亿步。

4记忆化怎么加:先回答「这张表凭什么开得下」

第 ① 版慢在哪?不是规则复杂,是同一个 (a,b,c) 被反复算。 这句话可以直接量出来 —— 上面那份 p1464Trace.cpp 的第 ③ 段跑的就是这件事:

算 w(5,5,5) = 32 的过程里,一共调用了 5237 次 w(),
而不同的 (a,b,c) 只有 88 个。被重算最多的五个:

   w(0,1,2) 被算了 647 遍
   w(0,0,2) 被算了 437 遍
   w(0,1,1) 被算了 391 遍
   w(0,1,3) 被算了 362 遍
   w(0,0,3) 被算了 362 遍

5237 次调用,只有 88 个不同的问题。 剩下的全是重复劳动 —— 把算过的答案记下来,下次直接取,就是记忆化。代码只多三行:

long long memo[21][21][21];
bool has[21][21][21];                       // 这个格子算过没有

    // …在四条规则判完、真正开算之前:
    if (has[a][b][c]) return memo[a][b][c];  // 算过就直接拿

    // …算完之后,存回去再返回:
    has[a][b][c] = true;
    return memo[a][b][c] = r;
★★ 但凭什么开得下 memo[21][21][21]?—— 这一步才是这道题的关键

题面里 a、b、c 的范围是 [-2⁶³, 2⁶³ - 1]。照这个范围开数组,多少台机器都放不下。

能开得下,是因为规则 1 和规则 2 自己把参数夹住了:

      a 的取值                     它会怎样                    进不进 memo 表
  ----------------  ------------------------------  --------------------
   a <= 0            规则 1:直接返回 1               不进(根本没往下走)
   1 <= a <= 20      ★ 真正要记的就这一段              进
   a > 20            规则 2:整个换成 w(20,20,20)      不进(换成别人了)

⇒ 真正需要记的下标只有 1..20 这 20 个值,三个参数合起来 20 × 20 × 20 = 8000 格 —— 一个 21×21×21 的数组绰绰有余。

★ 这就是「该不该上记忆化」的判断方法,而且和答案有多大毫无关系:

一个子问题由哪几个量唯一决定?这几个量的组合有多少种? 组合数够小就上记忆化。

⚠ 顺带解释一个容易对不上的数:上面 w(5,5,5) 数到 88 个「不同的 (a,b,c)」, 而第 ⑦ 步那张表里 n = 5 的「真正算的格子」写的是 55。差的 33 个, 就是「有一个数 ≤ 0」的那些 —— 它们撞规则 1 直接返回,从不进表。

5第 ② 版:记忆化加对了,可答案错了

上一步那三行加完,速度立刻就够了 —— 可这道题的坑不在缓存上,在四条规则的顺序上。

题面把它写在明处了:

注意:例如 w(30, -1, 0) 又满足条件 1 又满足条件 2,请按照最上面的条件来算,答案为 1。

这一版故意把两条判断调了个个儿:

if (a > 20 || b > 20 || c > 20) return w(20, 20, 20);   // ⚠ 顺序反了
if (a <= 0 || b <= 0 || c <= 0) return 1;
p1464Order.cpp第 ② 版(快,但答案错)
前两行(样例)全对。看第三行:它给 1048576,而正确答案是 1。
// 洛谷 P1464 Function —— 第 ② 版:加了记忆化,快了,**但答案错了**
//
// 输入 / 输出:同第 ① 版
//
// 这份为什么存在:记忆化本身(解析页第 ④ 步)加对了,可**这道题的坑不在缓存上,
// 在四条规则的顺序上** —— 这一版就是「速度已经够了、答案却错了」的样子。
//
// 题面把这个坑写在明处了:
// 「注意:例如 w(30,-1,0) 又满足条件 1 又满足条件 2,请按照最上面的条件来算,答案为 1。」
//
// 而这一版故意把两条判断调了个个儿 —— 先看「有没有超过 20」,再看「有没有小于等于 0」:
//
// if (a > 20 || b > 20 || c > 20) ... <- 错:它把 (30,-1,0) 抢走了
// if (a <= 0 || b <= 0 || c <= 0) return 1;
//
// ⇒ w(30,-1,0) 会被当成 w(20,20,20) 来算,得到 1048576,而正确答案是 1。
//
// ★★ 这一版最值钱的一课和递归无关:
// **当几条规则可能同时成立时,「先判哪一条」就是题目的一部分,不是实现细节。**
// 题面特意举了个例子来说明顺序,那句话就是分数所在。
//
// ⚠ 而且这个错**很难自己发现**:只要你的测试数据里没有「又负又超 20」的组合,它就一直是对的。
#include <bits/stdc++.h>
using namespace std;
long long memo[21][21][21];
bool has[21][21][21];
long long w(long long a, long long b, long long c) {
if (a > 20 || b > 20 || c > 20) return w(20, 20, 20); // ⚠ 顺序反了
if (a <= 0 || b <= 0 || c <= 0) return 1;
int x = (int)a, y = (int)b, z = (int)c;
if (has[x][y][z]) return memo[x][y][z];
long long r;
if (a < b && b < c) r = w(a, b, c - 1) + w(a, b - 1, c - 1) - w(a, b - 1, c);
else r = w(a - 1, b, c) + w(a - 1, b - 1, c) + w(a - 1, b, c - 1) - w(a - 1, b - 1, c - 1);
has[x][y][z] = true;
return memo[x][y][z] = r;
}
int main() {
long long a, b, c;
while (cin >> a >> b >> c) {
if (a == -1 && b == -1 && c == -1) break;
cout << "w(" << a << ", " << b << ", " << c << ") = " << w(a, b, c) << "\n";
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
★★ 这一课和递归无关,但值一整道题

当几条规则可能同时成立时,「先判哪一条」就是题目的一部分,不是实现细节。

题面特意举了个例子来说明顺序 —— 凡是题面专门举例说明的地方,都是分数所在。

⚠ 而且这个错极难自己发现:只要你的测试数据里没有「又是负数、又超过 20」的组合, 它就一直是对的。样例正好没有这种组合。

⇒ 读题的时候,看到「又满足…又满足…请按照…」这种句子, 立刻回到代码里去数一遍 if 的顺序。

6第 ③ 版:顺序照题面来 + 记忆化 + 表跨询问保留(正解)

和前两版的差别一共三处,每一处对应一个真会挂人的地方:

① 加了记忆化              治第 ① 版的「跑不完」
② 规则顺序照题面来        治第 ② 版的「w(30,-1,0) 算错」
③ 缓存表跨询问保留        治一个前两版都看不出来的问题
★ 第 ③ 条:为什么表不能每次询问清一遍

题目最多有 10^5 行询问。如果每读一行就 memset 一次表, 等于每一行都从零开始重算 —— 记忆化白加。

而 w 是个纯函数:w(3,4,5) 是多少,跟前面问过什么毫无关系。 ⇒ 表本来就该一直留着,问得越多越划算。

★ 一般化一句:缓存能不能跨询问复用,取决于「这个函数的答案会不会随外部状态变」。 纯函数就能。这个判断以后会一直用。

⚠ 第 ③ 条之外还有一个读入的坑

题面写着数值范围是 [-2⁶³, 2⁶³ - 1] ⇒ 必须用 long long 读。

用 int 读的话,一个 30000000000 会被悄悄截断成一个完全不同的数, 程序不报任何错,只是答案不对。

⚠ 但下标仍然是 int:clamp 到 20 之后最大就是 20, 别把数组开成 long long 索引 —— 那是另一种误解。

p1464.cpp第 ③ 版(正解)
第 ① 版跑不完的 15 15 15,这一版一瞬间;而 30 -1 0 也给出正确的 1。
// 洛谷 P1464 Function —— 第 ③ 版:记忆化 + 规则顺序照题面来(正解)
//
// 输入:若干行 a b c,以 -1 -1 -1 结束(最多 10^5 行)
// 输出:每行 "w(a, b, c) = ans",注意逗号后面有空格
//
// 和前两版的差别,一共就三处,每一处都对应一个真会挂人的地方:
//
// ① 加了记忆化 —— 治第 ① 版的「跑不完」
// ② 规则顺序照题面来 —— 治第 ② 版的「w(30,-1,0) 算错」
// ③ 缓存表**跨询问保留** —— 治一个前两版都看不出来的问题:
// 题目最多有 10^5 行询问,每行都清一次表的话,等于记忆化白加。
// 而 w 是个纯函数,答案跟前面问过什么毫无关系 ⇒ 表本来就该一直留着。
//
// ⚠ 输入必须用 long long:题面写着数值范围是 [-2^63, 2^63-1]。
// 用 int 读的话,一个 30000000000 会被截成一个完全不同的数,而程序不会有任何反应。
// ⇒ 但**下标**仍然是 int(clamp 到 20 之后最大就是 20),别把数组开成 long long 索引。
#include <bits/stdc++.h>
using namespace std;
long long memo[21][21][21];
bool has[21][21][21]; // ★ 全局,跨询问保留
long long w(long long a, long long b, long long c) {
if (a <= 0 || b <= 0 || c <= 0) return 1; // ★ 规则 1 必须排最前
if (a > 20 || b > 20 || c > 20) return w(20, 20, 20); // ★ 规则 2
int x = (int)a, y = (int)b, z = (int)c;
if (has[x][y][z]) return memo[x][y][z];
long long r;
if (a < b && b < c) r = w(a, b, c - 1) + w(a, b - 1, c - 1) - w(a, b - 1, c);
else r = w(a - 1, b, c) + w(a - 1, b - 1, c) + w(a - 1, b, c - 1) - w(a - 1, b - 1, c - 1);
has[x][y][z] = true;
return memo[x][y][z] = r;
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
long long a, b, c;
while (cin >> a >> b >> c) {
if (a == -1 && b == -1 && c == -1) break;
cout << "w(" << a << ", " << b << ", " << c << ") = " << w(a, b, c) << "\n";
}
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果

7换一把可复现的尺子:加不加记忆化差多少

深入:这张表是怎么数出来的
p1464Count.cpp数次数
不用输入,直接跑(第 1 列到 n=10 要四亿多次调用,会跑一秒左右)。
// P1464 加不加记忆化,差多少 —— 换一把可复现的尺子
//
// 输入:无(表是写死的几组 (n,n,n))
// 输出:一张表,对每组列出
// 答案 w(n,n,n)
// 1.没记忆化 调用了多少次 w()
// 2.有记忆化 调用了多少次(含直接命中缓存的)
// 真正算的格子 有多少个 (a,b,c) 被真的算了一遍
//
// ★ 两件事值得看:
// ① **答案本身很小**(w(n,n,n) 正好是 2^n),可算出它要走的路却是天文数字 ——
// 「答案小」和「好算」完全是两回事。
// ② 最后一列**永远不超过 20×20×20 = 8000** —— 状态就三个量,每个不超过 20。
// 这就是「该不该上记忆化」的全部判断依据。
//
// ⚠ 第 1 列到 n = 10 已经是四亿多次调用(这份程序因此要跑两三秒)。
// 题面里点名的 w(15,15,15) 就不在表里了 —— 它跑不完。
#include <bits/stdc++.h>
using namespace std;
long long naiveCalls = 0;
long long naive(long long a, long long b, long long c) {
naiveCalls++;
if (a <= 0 || b <= 0 || c <= 0) return 1;
if (a > 20 || b > 20 || c > 20) return naive(20, 20, 20);
if (a < b && b < c) return naive(a, b, c - 1) + naive(a, b - 1, c - 1) - naive(a, b - 1, c);
return naive(a - 1, b, c) + naive(a - 1, b - 1, c) + naive(a - 1, b, c - 1) - naive(a - 1, b - 1, c - 1);
}
long long memo[21][21][21];
bool has[21][21][21];
long long memoCalls = 0, memoFilled = 0;
long long withMemo(long long a, long long b, long long c) {
memoCalls++;
if (a <= 0 || b <= 0 || c <= 0) return 1;
if (a > 20 || b > 20 || c > 20) return withMemo(20, 20, 20);
int x = (int)a, y = (int)b, z = (int)c;
if (has[x][y][z]) return memo[x][y][z];
memoFilled++;
long long r;
if (a < b && b < c) r = withMemo(a, b, c - 1) + withMemo(a, b - 1, c - 1) - withMemo(a, b - 1, c);
else r = withMemo(a - 1, b, c) + withMemo(a - 1, b - 1, c) + withMemo(a - 1, b, c - 1) - withMemo(a - 1, b - 1, c - 1);
has[x][y][z] = true;
return memo[x][y][z] = r;
}
int main() {
cout << " n 答案 w(n,n,n) 1.没记忆化 2.有记忆化 真正算的格子\n";
cout << "----- ------------- ------------ ---------- -----------\n";
for (int n : {1, 2, 5, 8, 10}) {
naiveCalls = 0;
long long ans = naive(n, n, n);
memset(has, 0, sizeof has);
memoCalls = memoFilled = 0;
withMemo(n, n, n);
cout << setw(5) << n
<< setw(16) << ans
<< setw(15) << naiveCalls
<< setw(13) << memoCalls
<< setw(14) << memoFilled << "\n";
}
cout << "\n答案那一列正好是 2^n —— 答案小,不代表算出它便宜。\n";
cout << "最后一列永远不超过 20*20*20 = 8000,因为状态就三个量、每个不超过 20。\n";
return 0;
}
点一下即可编辑
输入(stdin)
输出
点「运行 ▶」看结果
    n   答案 w(n,n,n)     1.没记忆化   2.有记忆化  真正算的格子
-----   -------------   ------------   ----------   -----------
    1               2              5            5             1
    2               4             21           21             5
    5              32           5237          211            55
    8             256        4464671          761           204
   10            1024      489048226         1421           385
  • 答案那一列正好是 2^n —— 小得可怜,可算它要走的路是天文数字。
  • 最后一列永远不超过 20 × 20 × 20 = 8000 —— 就是第 ④ 步算过的那 8000 格, 状态就三个量、每个只有 20 种取值。
  • 第 1 列和最后一列在 n = 10 时差了 一百二十多万倍。
★ 三道题,同一个判断

P1028 状态一个量、P1044 两个量、这道题三个量 —— 判断方法从头到尾没变过:

一个子问题由哪几个量唯一决定?这几个量的组合有多少种?组合数够小就上记忆化。

⇒ 记住这句问法,比记住「哪些题能记忆化」有用得多。

8回头看:这道题在教什么

✓ 四件带得走的东西
  1. ★ 读不懂题面,就手算最小的一组。 w 没有任何意义,硬读是读不懂的; 而把 w(1,1,1) 摊开成四项、看着它们全撞上规则 1,一次就够了。
  2. 题面给了递归,不等于题目简单。 这道题全部的分数在「跑得动 / 顺序对 / 读得对」上, 没有一件是算法。
  3. 题面专门举的例子,就是坑的位置。 看到「又满足…又满足…请按照…」, 立刻回去数 if 的顺序。
  4. 纯函数的缓存可以跨询问复用。 10^5 行询问时,这一条和记忆化本身一样重要。
★ 记忆化这一路,三道题是连着的

P1028(状态一个量)→ P1044(两个量)→ 这道题(三个量)。 每一次都要先回答同一个问题:这张表凭什么开得下? 这道题的答案藏在规则 1 和规则 2 里 —— 它们把三个参数夹进了 1..20, 于是 2⁶³ 的取值范围缩成 8000 格。