【板子】短序列dp(换成维护更小常数维度的dp)

当dp的转移常数空间非常大,比如dp100000100000,但是dp内维护的值非常小,比如1-10;我们就可以选择维护dp10000010或dp10100000;,而值代表另一个维度;比如 l r的中间翻倍子序列长度,翻倍子序列很小,所以可以维护从任意 r 结尾,子序列长度为 x 的最小 l 来极大缩小常数。

DP 状态的某一维(通常是"长度")很小(≤60),但每一层的转移涉及"在满足某个权值条件的历史状态中找最优值",需要用线段树/树状数组加速。


一、方法总结

核心思想

步骤 做什么 为什么
① 发现长度上界 证明/观察到答案枚举长度 ≤ O(logV) 可以逐长度枚举,不会 TLE
② 定义 DP 状态 fi,j​ = 以 i 结尾、长度为 j 的某种极值(通常是最小起点 / 最大终点) 把"选子序列"转化为递推
③ 值域限制 → 区间查询 转移条件形如 L≤ak​≤R,转化为在离散化权值上的区间查询 线段树/树状数组直接上
④ 控制下标顺序 正序/倒序枚举 i,保证查到的都是"前面/后面"的状态 不用再管下标维,线段树只管权值
⑤ 离线/半在线回答询问 每算完一层,顺手用后缀/前缀最值更新所有询问的答案 免去存所有 fi,j​

适用场景速判

看到以下特征就可以往这个方向想:

  • 子序列需要满足某种 乘法/指数型增长条件(2x≤y≤3x、y≥kx、y=y×c 等)//dp值限制在非常小的范围

  • 多次区间询问,求最长合法子序列长度

  • 转移条件是 "前面找一个满足条件的位置,取某个最值"

常见变体

变体 改哪里
条件是 y≥2x(只有下界) rightId 设为 m(无穷大)
条件是 y=2x 精确相等 区间退化为单点查询,用数组/map 就行
求的是最长长度的具体方案 多记一个 pre 指针回溯
询问强制在线 把所有 fi,j​ 存下来,对每列做 RMQ/线段树
长度不是 log 级而是 O(n) 失效,得换思路(单调栈/贪心/决策单调性)

二、通用板子

定义序列 b = (b1, b2, . . . , bm) 是合法的,当且仅当对于所有 2 ≤ i ≤ m,都有

2bi−1 ≤ bi ≤ 3bi−1.

特别地,长度为 1 的序列一定合法。

给定一个长度为 n 的正整数序列 a,需要回答 q 个区间询问。每次给定区间 l, r,求 al

, al+1, . . . , ar 中最长合法子序列的长度。所选元素不要求连续,但必须保持相对顺序。

1 ≤ n, q ≤ 2 · 10e5,1 ≤ ai ≤ 10e18,1 ≤ l ≤ r ≤ n。

核心思路

1. DP 状态设计

设 fi,j​ 表示以第 i 个元素结尾 、长度为 j 的合法子序列中,最靠左的开头位置(即最小的起始下标)。

  • 当 j=1 时,fi,1​=i(单个元素自己就是合法子序列)。

  • 当 j>1 时,我们要找一个 k<i,满足 2ak​≤ai​≤3ak​,且 fk,j−1​ 尽可能小(这样得到的序列整体更靠右,更容易被包含在询问区间里)。转移方程:

复制代码
fi,j​=k<i2ak​≤ai​≤3ak​​min​fk,j−1​
2. 用线段树加速转移

转移有两个限制:

  1. 下标限制:k<i(我们只需从小到大或从大到小枚举 i,保证只用到之前的 k)。

  2. 权值限制:ak​∈⌈ai​/3⌉,⌊ai​/2⌋,等价于 ai​∈2ak​,3ak​

代码采用了**倒序枚举 i(从 n 到 1)**的巧妙方式:

  • 维护一棵线段树,以离散化后的 a 值 为下标,线段树每个节点存储已经遍历过的元素(下标更大)的 fk,j−1​ 的最小值

  • 对于当前 i,需要找的是后面的某个 k(k>i),满足 2ai​≤ak​≤3ai​。这正好对应线段树上区间 leftIdi​,rightIdi​ 的查询。

  • 查询得到的最小值就是 fi,j​(代码中记为 cur[i])。

  • 然后把当前的 fi,j−1​(即 prev[i])更新进线段树,供更前面的 i 使用。

这样,我们在 O(logm)(m 为离散化后数值种类)时间内完成了单次转移。

3. 如何回答区间询问

对于一个询问 l,r,如果存在某个 i∈l,r 使得 fi,j​≥l,那就说明能在 l,r 内找到一个长度为 j 的合法子序列(开头 ≥l,结尾 i≤r)。

代码中使用了一个等价但更易实现的判断:

  • 计算出 cur[i](即 fi,j​)后,求一个后缀最小值数组 suf[i] = min_{k \ge i} cur[k]

  • 如果 suf[l] <= r,说明存在一个结尾 ≥l 且开头 ≤r 的长度为 j 的序列(结合 f 的定义,这个序列整体落在 l,r 内的条件被满足),于是更新答案为 j。

由于我们是按长度 j=2,3,... 依次计算的,并且只要当前长度可行就更新 ans[id] = len,最终每个询问就会留下最大可行长度

核心结构//涉及权值线段树,离散化,不是最基础的板子,仅面向类似题目

复制代码
// ============================================================
// 通用板子:逐层DP + 权值线段树优化转移
// 解决:区间询问最长合法子序列长度
// 时间复杂度:O(n log n * max_len)
// ============================================================

#include <bits/stdc++.h>
using namespace std;

const int INF = 1e9;
const int MAXN = 200005;

// ---------- 权值线段树(区间最小值)----------
struct SegMin {
    int n;
    vector<int> t;
    SegMin(int m) {
        n = 1; while (n < m) n <<= 1;
        t.assign(2 * n, INF);
    }
    void update(int p, int val) {
        if (val >= INF) return;
        int x = p + n - 1;
        if (t[x] <= val) return;
        t[x] = val;
        for (x >>= 1; x; x >>= 1)
            t[x] = min(t[x<<1], t[x<<1|1]);
    }
    int query(int l, int r) {
        if (l > r) return INF;
        int res = INF;
        l += n - 1; r += n - 1;
        while (l <= r) {
            if (l & 1) res = min(res, t[l++]);
            if (!(r & 1)) res = min(res, t[r--]);
            l >>= 1; r >>= 1;
        }
        return res;
    }
};

// ---------- 主逻辑 ----------
void solve(const vector<long long>& a, const vector<pair<int,int>>& queries) {
    int n = a.size() - 1; // a[1..n]
    int q = queries.size();

    // --- Step 1: 离散化 ---
    vector<long long> vals(a.begin() + 1, a.end());
    sort(vals.begin(), vals.end());
    vals.erase(unique(vals.begin(), vals.end()), vals.end());
    int m = vals.size();

    auto get_id = [&](long long x) {
        return lower_bound(vals.begin(), vals.end(), x) - vals.begin() + 1;
    };
    auto get_end = [&](long long x) { // upper_bound 版本
        return upper_bound(vals.begin(), vals.end(), x) - vals.begin();
    };

    vector<int> pos(n + 1), L(n + 1), R(n + 1);
    for (int i = 1; i <= n; ++i) {
        pos[i] = get_id(a[i]);
        L[i] = get_id(a[i] * 2);           // ← 题目条件:2*a[i] ≤ a[k]
        R[i] = get_end(a[i] * 3 - 1);      // ← 题目条件:a[k] ≤ 3*a[i]
    }

    // --- Step 2: 初始化长度为1 ---
    vector<int> prev(n + 2);
    for (int i = 1; i <= n; ++i) prev[i] = i;

    // --- Step 3: 逐层 DP ---
    vector<int> cur(n + 2);
    vector<int> suf(n + 2);
    vector<int> ans(q, 1);

    SegMin seg(m);

    for (int len = 2; ; ++len) {
        seg = SegMin(m); // 重置
        fill(cur.begin(), cur.end(), INF);

        // 倒序枚举(找后面的 k > i)
        for (int i = n; i >= 1; --i) {
            if (L[i] <= R[i])
                cur[i] = seg.query(L[i], R[i]);
            seg.update(pos[i], prev[i]);
        }

        // 后缀最小值
        int best = INF;
        for (int i = n; i >= 1; --i) {
            best = min(best, cur[i]);
            suf[i] = best;
        }
        if (best >= INF) break; // 这一层已经没有合法序列了

        // 更新所有询问
        for (int id = 0; id < q; ++id) {
            int l = queries[id].first, r = queries[id].second;
            if (suf[l] <= r) ans[id] = len;
        }

        prev.swap(cur);
    }

    // 输出
    for (int x : ans) cout << x << '\n';
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    int n, q; cin >> n >> q;
    vector<long long> a(n + 1);
    for (int i = 1; i <= n; ++i) cin >> a[i];
    vector<pair<int,int>> queries(q);
    for (int i = 0; i < q; ++i) cin >> queries[i].first >> queries[i].second;
    solve(a, queries);
    return 0;
}

权值线段树 SegMin

复制代码
struct SegMin {
    int n;
    vector<int> t;
    SegMin(int m) {
        n = 1; while (n < m) n <<= 1;
        t.assign(2 * n, INF);
    }
    void update(int p, int val) { ... }
    int query(int l, int r) { ... }
};
普通线段树 权值线段树
下标含义 数组的位置(第几个元素) 元素的值本身
叶子节点 对应 a[i] 这个位置的数 对应值为 x 的数有多少个
典型用途 区间求和、区间修改、区间最值 查排名、查第 k 大、统计某个值范围内有多少个数

这是一个维护区间最小值 的线段树,但它的下标不是数组的位置,而是"数值离散化后的值"

  • 比如原数组里有数字 {3, 5, 12, 100},离散化后变成 {1, 2, 3, 4}

  • 那线段树下标 1~4 分别对应这些数。

  • 在每个下标里,我们存的是:已经遍历过的、值为该数的那些元素,它们的 DP 值的最小值

两个操作:

  • update(p, val):告诉线段树"值为 p 的位置,我来报到一个值 val,你帮我取个 min"。

  • query(l, r):问线段树"在区间 [l, r](也就是值在 [l, r] 范围内的所有元素)里,最小的 DP 值是多少?"

为什么用线段树?

因为我们要快速在一段"权值区间"里找最小值,暴力扫是 O(n) 的,线段树是 O(logn)。


离散化部分

复制代码
vector<long long> vals(a.begin() + 1, a.end());
sort(vals.begin(), vals.end());
vals.erase(unique(vals.begin(), vals.end()), vals.end());
int m = vals.size();

原数组 ai​ 的范围是 1∼10e18,不能直接当下标用。离散化就是把它们压缩到 1∼m(m≤n),让线段树开得下。

复制代码
auto get_id = [&](long long x) {
    return lower_bound(vals.begin(), vals.end(), x) - vals.begin() + 1;
};

二分查找:给我一个数 x,告诉我它在离散化数组里的编号(从 1 开始,方便线段树)。

复制代码
pos[i] = get_id(a[i]);                    // a[i] 自己的编号
L[i] = get_id(a[i] * 2);                  // 2*a[i] 的下界编号
R[i] = get_end(a[i] * 3 - 1);             // 3*a[i] 的上界编号

回忆题目要求:2ak​≤ai​≤3ak​(如果我们正序 DP 找前面的 k)。

但我们的板子是倒序 做的,含义反过来:找后面的 k,满足 2ai​≤ak​≤3ai​。

  • L[i]:后面那个数 ak​ 至少要是 2ai​,所以在离散化数组里的最小位置。

  • R[i]:后面那个数 ak​ 最多是 3ai​,所以在离散化数组里的最大位置。

  • 也就是说,后面所有合法的 k,它们的 pos[k] 一定落在 [L[i], R[i]] 这个闭区间里

改成别的题时需要动的地方

位置 改什么
L[i] = get_id(a[i] * 2) 改成下界条件
R[i] = get_end(a[i] * 3 - 1) 改成上界条件
seg.query(L[i], R[i]) 如果要求最大值就换成 SegMax,相应改 min→maxINF→-INF
prev[i] = i 如果 DP 定义变了(比如以 i 开头的),初始化跟着改
suf[l] <= r 判断条件根据你定义的 f 的含义调整
相关推荐
shwill1231 小时前
PID 算法(三)--- 增量 PID ↔ 单神经元 PID 等价映射
linux·算法
Dory_Youth2 小时前
联想笔记本电脑失灵
运维·笔记·电脑
wabs6662 小时前
关于图论【卡码网110.字符串迁移的思考】
数据结构·算法·图论
2501_926978332 小时前
认知边缘的双向耦合:从核技术类比到智力货币时代
人工智能·经验分享·笔记·ai写作
hanlin033 小时前
刷题笔记:力扣第242、349题(哈希表)
笔记·算法·leetcode
茯苓gao3 小时前
嵌入式开发笔记:CAN与CAN FD完全指南——从帧格式差异到MCU选型实战
网络·笔记·stm32·嵌入式硬件·学习·汽车
aramae3 小时前
C++ IO流完全指南:从C标准库到C++流式编程
服务器·c语言·开发语言·c++·后端
_wyt0013 小时前
c++里的族谱:树
c++·
FellAveal4 小时前
【Go语言入门学习笔记】Part19.速率控制、原子计数器、互斥锁(Mutex)、状态协程
笔记·学习·golang