把两个工作变量的指数当作状态 (a,b),用迭代加深 DFS 求最少步数,靠 a·2^rest<P 的上界剪枝和 P%gcd(a,b) 的整除剪枝把分支压到可跑。
OJ: roj
题目 ID: 3084
难度:提高+/省选-
标签:搜索剪枝迭代加深数学python
创建: 2026-10-01 15:52
更新: 2026-10-07 13:50
形式化题目
有两个工作变量 A、B,初值分别是
由于两个工作变量在任何时刻都是
四者之一(减法只在结果非负时合法,否则
例如
graph LR s0["x^1 , x^0"] s1["x^2 , x^1"] s2["x^4 , x^1"] s3["x^8 , x^1"] s4["x^16 , x^1"] s5["x^32 , x^1"] s6["x^31 , x^1"] s0 -->|"x+x → B"| s1 s1 -->|"x+x → A"| s2 s2 -->|"x+x → A"| s3 s3 -->|"x+x → A"| s4 s4 -->|"x+x → A"| s5 s5 -->|"x-y → A"| s6
前五步不断把较大的指数翻倍到
正解
思路
先想最直接的做法。 把指数对
答案是 set 存全部访问过的状态则会吃掉大量内存。
换一个方向:用深度换宽度。 既然层数很少,就从小到大枚举步数上限 limit,
每轮只回答一个问题——「只用 limit 步能不能到 limit
就是最少步数。这就是迭代加深 DFS。它按层数递增枚举,所以第一次成功必然是最短步数;
每层虽然从头重搜,但搜索规模随层数快速增长,重复的总代价只是常数倍。
迭代加深必须配合剪枝。 每轮都从头 DFS,若不加剪枝,重复搜索反而更慢。四把刀:
- 归一化
。 交换两个工作变量只是重新命名,后续可行的操作集合完全一样, 所以 与 是同一个局面。每步写回后按大小排一下序,直接砍掉一半分支。 - 上界剪枝。 设当前较大指数为
,还剩 步。一步操作的结果最大是 , 所以 步内指数的最大值不超过 。若 , 后续所有状态的坐标都小于 ,永远到不了 ,整枝剪掉。 代码里写成 x << rest < p,<<就是乘。 这相当于 A* 里的启发函数 ,而且它是 admissible 的 ——它假设的是最快增长,不会高估,因此不会误杀最优解。 - 整除剪枝。 设
,四种结果 、 、 、 全是 的倍数, 于是整棵子树里所有坐标都是 的倍数。若 , 根本不在这个格子里, 剪掉。 是质数这一点让这条剪枝特别有效:任何 的状态直接出局。 初始 时 ,不特判也正确: 恒成立所以初始态不被剪, 而 时正好砍掉「从此只会走 的倍数」的分支。 - 单层记忆化。 同一轮
limit里,同一个可能被多条路径到达, 只需保留「剩余步数最大」的那一次——它的搜索空间严格包含剩余步数更小的那些, 更大的预算都失败了,更小的必然失败。实现上用一个 dict,值是见过的最大剩余步数。
两条剪枝在小数据上的效果可以直接看出来(
| 状态 |
剩余步数 | 判定 | 原因 |
|---|---|---|---|
| 3 | 剪枝 | ||
| 3 | 剪枝 | ||
| 2 | 剪枝 | ||
| 3 | 剪枝 |
表的第二列是「还剩几步可走」,第三列是判定结果,第四列是给出判定依据的那条不等式:
前两行是「数值还没长够」,最后一行是「数值格局不对」。读者应注意到,同一个状态在
剩余步数不同时结论会变——
迭代加深本身的每层规模也确实很小:limit
从小到大枚举来提供。
最后是正确性的两点交代。上界剪枝不会误杀:limit 恰好是
代码
Python 版:
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 15:52
# update_at: 2026-10-01 16:05
import sys
from itertools import count
from math import gcd
def can_finish(
x: int, y: int, used: int, limit: int, p: int, widest: dict[tuple[int, int], int]
) -> bool:
"""在 limit-used 步的预算内,能否让某个工作变量恰好变成 P 次幂。
x >= y >= 0 是两个工作变量此刻的指数;widest 记录「搜索到该状态时还剩多少步」的最大值。
"""
if x == p or y == p:
return True
rest = limit - used
if rest == 0:
return False
if x << rest < p: # 一步至多让较大的指数翻倍,再走 rest 步也追不上 p
return False
if p % gcd(x, y): # 两个变量永远是 gcd(x, y) 的倍数,p 也必须能被它整除
return False
if widest.get((x, y), -1) >= rest: # 曾经用更宽的预算搜过同一个状态
return False
widest[(x, y)] = rest
nxt = used + 1
# 一步操作 = 从 {x+y, x-y, x+x, y+y} 取一个值写进某个工作变量,写回后按指数大小归一化
for value in (x + y, x - y, x + x, y + y):
for a, b in ((value, y), (x, value)):
hi, lo = (a, b) if a >= b else (b, a)
if can_finish(hi, lo, nxt, limit, p, widest):
return True
return False
def min_ops(p: int) -> int:
"""迭代加深:limit 从小到大,第一次搜到 P 次幂的层数就是最少操作数。"""
for limit in count():
if can_finish(1, 0, 0, limit, p, {}):
return limit
def solve() -> None:
p = next(iter(map(int, sys.stdin.buffer.read().split())))
print(min_ops(p))
if __name__ == "__main__":
solve()C++ 版(同一算法):
/**
* Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
* rbook: -> https://rbook.roj.ac.cn https://rbook2.roj.ac.cn
* rainboy的学习导航网站: https://idx.roj.ac.cn
* create_at: 2026-10-06 18:11
* update_at: 2026-10-06 18:11
*/
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;
ll p; // 目标次幂数 P
map<pair<ll, ll>, ll> widest; // 单层记忆化:widest[(a,b)] = 该状态下已搜过的最大剩余步数
// 判断在 rest 步预算内,能否从指数对 (x, y)(保证 x >= y >= 0)到达 P 次幂。
// used 是已用步数,limit 是本轮迭代的步数上限。
bool can_finish(ll x, ll y, ll used, ll limit) {
if (x == p || y == p) return true;
ll rest = limit - used; // 还剩多少步可走
if (rest == 0) return false;
// 上界剪枝:一步至多让较大的指数翻倍,rest 步内最大指数是 x * 2^rest,
// 若仍小于 P 则这棵子树永远到不了 P,剪掉。
if (x << rest < p) return false;
// 整除剪枝:四个结果 x+y, x-y, x+x, y+y 都是 gcd(x,y) 的倍数,
// 子树里所有指数恒为 g 的倍数,P 不整除 g 就到不了。
// 初始 y=0 时 gcd(x,0)=x,x=1 恒整除,特判可省。
ll g = __gcd(x, y);
if (p % g != 0) return false;
// 单层记忆化:同一状态若曾以更大(或相等)的剩余预算搜过且失败,
// 这次更小的预算必然也失败,直接剪。
pair<ll, ll> st = make_pair(x, y);
map<pair<ll, ll>, ll>::iterator it = widest.find(st);
if (it != widest.end() && it->second >= rest) return false;
widest[st] = rest;
// 一步操作 = 从 {x+y, x-y, x+x, y+y} 取一个结果写回任一工作变量,
// 写回后按指数大小归一化成 hi >= lo(交换两个变量是同一局面)。
ll nxt = used + 1;
ll cand[4];
cand[0] = x + y;
cand[1] = x - y;
cand[2] = x + x;
cand[3] = y + y;
for (int i = 0; i < 4; i++) {
ll value = cand[i];
ll a = value, b = y; // 写回第一个变量
if (a < b) swap(a, b);
if (can_finish(a, b, nxt, limit)) return true;
a = x; b = value; // 写回第二个变量
if (a < b) swap(a, b);
if (can_finish(a, b, nxt, limit)) return true;
}
return false;
}
// 迭代加深:limit 从 0 开始逐层增大,第一次成功的 limit 就是最少操作数。
ll min_ops() {
for (ll limit = 0;; limit++) {
widest.clear(); // 每一层记忆化只对本轮有效,必须清空
if (can_finish(1, 0, 0, limit)) return limit;
}
}
int main() {
cin >> p;
cout << min_ops() << endl;
return 0;
}复杂度
- 时间复杂度:单轮
can_finish(limit)的规模为剪枝后的搜索树, 上界 ,实际被两条剪枝压到很小。 总代价是 ,因为 随层数快速增长, 这个和是 的常数倍, 为答案( ,实际最大 18)。 - 空间复杂度:递归栈
,加上每轮 widest字典保存该轮状态, 实测最坏约 22.6 万条(十余 MB,远低于 128 MB),每轮结束即丢弃。
总结
- 把两个工作变量的指数
当状态,一步操作就是 、 、 、 写回任意一个变量,题目变成二维无权图上的最短步数。 - 直接 BFS 会状态爆炸:答案只有 18 左右,但宽度按
增长, 二维状态又无法用数组打表。形状是「层数小、宽度大」。 - 迭代加深 DFS 用深度换宽度:从小到大枚举步数上限,第一次成功就是最少步数, 重复搜索的代价只是常数倍。
- 迭代加深必须配剪枝:归一化
、上界剪枝 、 整除剪枝 、以及同一轮内的记忆化。 - 两条剪枝的安全性都有明确理由:前者是 admissible 的启发式,后者来自
「
只会被保持或放大」这条不变量。
图示解析
上文的推导可以压成一条主线:
状态建模 (a,b) ← 工作变量指数对,起点 (1,0)
│
├─ 朴素 BFS ──────► 正确,但宽度 ~8^深度,二维状态无法打表 ⇒ 爆炸
│
▼
迭代加深 DFS limit = 0,1,2,… 第一次「能到 P」的 limit 即答案
│
├─ 剪枝:归一化 a ≥ b (交换变量是同一局面)
├─ 剪枝:a · 2^rest < P (k 步内最大指数是 a·2^k,到不了 P)
├─ 剪枝:P % gcd(a,b) ≠ 0 (g 只被保持/放大,P 不在该格子)
└─ 记忆化:(a,b) 只留剩余步数最大的一次
│
▼
输出 limit = D (第一个成功的层数 = 最少操作数)图中的关键读法是:从