树状数组优化dp

原理及模板

树状数组通常用于解决区间修改和区间查询等问题,可以看做线段树的简化版,它所维护的区间信息需要满足可差分性。

对于区间的修改操作,树状数组的维护很麻烦,一般用线段树来解决;对于区间最大值一类的问题,树状数组没法维护这类信息的原因是在需要区间的差操作,但是当我们所需要的最大值信息仅涉及到一个前缀的时候是不需要作差的,此时树状数组可以维护前缀最大值。

综上所述:树状数组一般用于优化的\(dp\)类型分为以下几种

  1. 转移的过程需要单点修改+查询前缀最值
  2. 转移的过程需要单点修改+ 查询区间和

下面简单回顾一下单点修改和区间查询的代码

code:

// 一维
int query(int x)
{
    int ret = 0;
    for(int i = x; i; i -= lowbit(i)) ret = max(ret, s[i]);
    return ret;
}

void modify(int x, int t)
{
    for(int i = x; i <= all; i += lowbit(i)) s[i] = max(s[i], t);
}


// 二维
void modify(int x, int y, LL z)
{
    for (int i = x; i <= N - 1; i += lowbit(i))
        for (int j = y; j <= k + 1; j += lowbit(j))
            s[i][j] = max(s[i][j], z);
}

LL query(int x, int y)
{
    LL ret = 0;
    for (int i = x; i; i -= lowbit(i))
        for (int j = y; j; j -= lowbit(j))
            ret = max(ret, s[i][j]);
    return ret;
}

【模板】最长上升子序列

定义\(dp_i\)表示:前\(i\)个数以\(a_i\)结尾组成的最长的上升子序列,此时的状态转移:\(dp_i=\max_{1}^{i-1}(dp_j)+1\) \(a_j<a_i\),如果没有\(a_j<a_i\)这个限制的话,一个前缀\(max\)数组即可解决。由于\(dp_i\)是由前面所有的满足\(a_j<a_i\)的位置的\(dp\)值转移而来,不难发现如果我们让这些\(dp\)值,按照\(a_i\)的顺序进行排序,也就是说让\(a_i\)去当作\(dp_i\)的下标(由于\(a_i\)的大小可能会很大或者出现负数,先进行离散化,令离散化之后的长度为\(all\)),此时状态的转移就变成了\(\max_{j=1}^{all}(s_j)+1\),更新完\(dp_i\)后把\(alls_{a_i}\)位置的值对\(dp_i\)取最大值。树状数组单调修改+前缀最大值查询.

code:

#include <iostream>
#include <vector>
#include <unordered_map>
#include <map>
#include <unordered_set>
#include <set>
#include <algorithm>
#include <cmath>
#include <string>
#include <cstring>
#include <queue>
#include <cstring>


using namespace std;
#define endl '\n'
typedef long long LL;
typedef pair<int, int> PII;
#define lc p << 1
#define rc p << 1 | 1
#define lowbit(x) (x & -x)
const int N = 2e5 + 10;
const LL MOD = 1e9 + 7;
const double ln2 = log(2);
const double rec_ln2 = 1.0 / ln2;
unordered_map<int, int> alls;
int s[N], n, a[N], all;

int query(int x)
{
    int ret = 0;
    for(int i = x; i; i -= lowbit(i)) ret = max(ret, s[i]);
    return ret;
}

void modify(int x, int t)
{
    for(int i = x; i <= all; i += lowbit(i)) s[i] = max(s[i], t);
}

void solve()
{
    cin >> n;
    vector<int> tmp(n);
    for(int i = 1; i <= n; i++) 
    {
        cin >> a[i];
        tmp[i - 1] = a[i];
    }
    sort(tmp.begin(), tmp.end());
    for(auto& x : tmp)
    {
        if(alls.count(x)) continue;
        alls[x] = ++all;
    }
    int ans = 0;
    for(int i = 1; i <= n; i++)
    {
        int x = alls[a[i]];
        int t = query(x - 1) + 1;
        ans = max(ans, t);
        modify(x, t);
    }
    cout << ans << endl;
}

int main()
{
    cin.tie(0), cout.tie(0), ios::sync_with_stdio(false);
    int T = 1;
    while(T--)
    {
        solve();
    }
    return 0;
}

对于本道题目,需要说明的是,最优雅的写法并不是用树状数组,而是用二分+dp+贪心

定义\(dp_x\):表示遍历到第\(i\)个数的时候,\(x\)长度上升子序列的结尾最小值,这样做的目的显然是为了让后续的值可以在子序列的末尾插入

状态转移方程:\(dp_x=min(dp_x, a_i)\),其中\(x\)位置为大于等于\(a_i\)的第一个位置,用lower_bound查找即可,如果找到末尾就插入,表示子序列出现了一个新的最长长度

最后的答案就是\(dp\)\(size\).

code:

#include <iostream>
#include <vector>
#include <unordered_map>
#include <map>
#include <unordered_set>
#include <set>
#include <algorithm>
#include <cmath>
#include <string>
#include <cstring>
#include <queue>
#include <cstring>


using namespace std;
#define endl '\n'
typedef long long LL;
typedef pair<int, int> PII;
#define lc p << 1
#define rc p << 1 | 1
#define lowbit(x) (x & -x)
const int N = 2e5 + 10;
const LL MOD = 1e9 + 7;
const double ln2 = log(2);
const double rec_ln2 = 1.0 / ln2;
int n;
int a[N];

// 这个用于求最长非递减
int f1()
{
    vector<int> tmp;
    for(int i = n; i >= 1; i--)
        {
            auto it = upper_bound(tmp.begin(), tmp.end(), a[i]);
            if(it == tmp.end()) tmp.push_back(a[i]);
            else *it = a[i];
        }
    return tmp.size();
}

// 这个即是本题的答案
int f2()
{
    vector<int> tmp;
    for(int i = 1; i <= n; i++)
        {
            auto it = lower_bound(tmp.begin(), tmp.end(), a[i]);
            if(it == tmp.end()) tmp.push_back(a[i]);
            else *it = a[i];
        }
    return tmp.size();
}

void solve()
{
    while(cin >> a[++n]);
    n--;
    cout << f1() << endl;    
    cout << f2() << endl;
}

int main()
{
    cin.tie(0), cout.tie(0), ios::sync_with_stdio(false);
    int T = 1;
    while(T--)
        {
            solve();
        }
    return 0;
}

AT_dp_q Flowers

本题可以看做一个广义的最长严格递增子序列问题,求最长严格上升子序列长度的时候位置\(i\)提供的贡献为1,在本道题中无非就是提供的贡献变成\(a_i\)而已.

  • \(dp_i\)表示:以\(i\)为结尾的最长严格上升子序列的数值和,\(s_x\)表示:查询 -> 区间\([1,i-1]\)中选,以\(x\)这个数作为结尾的最大长严格上升子序列的数值和。
  • 状态转移方程:\(dp_i=\max_{j=1}^{h_i-1}(dp_j)+a_i\),转移完成后修改\(s_{h_i}\)->\(s \)所以包含\(h_i\)的区间的数值对\(dp_i\)\(max\)

code:

#include <iostream>

using namespace std;
#define lowbit(x) (x & (-x))
const int N = 2e5 + 10;
typedef long long LL;
LL a[N], s[N], h[N];
LL dp[N];
int n;

void modify(int x, LL y)
{
    for(int i = x; i < N; i += lowbit(i)) s[i] = max(y, s[i]);
}

LL query(int x)
{
    LL ret = 0;
    for(int i = x; i; i -= lowbit(i)) ret = max(s[i], ret);
    return ret;
}

int main()
{
    cin >> n;
    for(int i = 1; i <= n; i++) cin >> h[i];
    for(int i = 1; i <= n; i++) cin >> a[i];
    
    for(int i = 1; i <= n; i++)
    {
        LL t = query(h[i] - 1);
        dp[i] = t + a[i];
        modify(h[i], dp[i]);
    }
    LL ans = 0;
    for(int i = 1; i <= n; i++) ans = max(ans, dp[i]);
    cout << ans << endl;
    return 0;
}

练习题

P1637 三元上升子序列

三元上升子序列是由二元上升子序列在末尾插入一个大于末尾位置的数得来

  • \(dp_i\)表示:前\(i\)个数所能组成的三元上升子序列
  • \(dp_i=\sum_{j=1}^{a_i-1}{s_j}\), \(s_j\)表示以\(j\)作为结尾的二元上升子序列的数量,\(s \)用树状数组\(tr2\)维护
  • 二元上升子序列是由单元素在末尾插入一个大于其的数得来,对于\(a_i\)为结尾能组成的二元上升子序列的个数,及\(\sum_{j=1}^{a_i-1}t_j\)\(t_j\)\(j\)的数量,用树状数组\(tr_1\)来维护。
  • 单元素的数量在\(dp_i\)更新完毕后对\( tr_1\)做单点修改即可。

code:

#include <iostream>
#include <vector>

using namespace std;
#define lowbit(x) (x & (-x))
typedef long long LL;
const int N = 3e4 + 10, M = 1e5 + 10;
int n, a[N];

struct tr
{
    LL s[M];
    void modify(int x, LL y)
    {
        for(int i = x; i <= M - 1; i += lowbit(i)) s[i] += y;
    }
    LL query(int x) 
    {
        LL ret = 0;
        for(int i = x; i; i -= lowbit(i)) ret += s[i];
        return ret;
    }
}tr1, tr2;

int main()
{
    cin >> n;
    for(int i = 1; i <= n; i++) cin >> a[i];
    vector<LL> dp(n + 1);
    for(int i = 1; i <= n; i++) 
    {
        int x = a[i];
        dp[i] = tr2.query(x - 1);
        tr2.modify(x, tr1.query(x - 1));  
        tr1.modify(x, 1);
    }
    LL ans = 0;
    for(int i = 3; i <= n; i++) ans += dp[i];
    cout << ans << endl;
    return 0;
}

P3431 [POI 2005] AUT-The Bus

本道题在某场牛客周赛中好像考过,比较简单。

对于第\(i\)个点,不难发现它只能由满足\(x_j \le x_i,y_j<y_i\)\(x_j < x_i,y_j =y_i\)的点\(j\)转移而来,这是一道二维偏序问题后续会总结,把点按照\(x\)排序,将\(y\)作为下标取\(\max_{j=1}^{alls_{a[i].y}}dp_j\), 及\(dp_i=\max_{j=1}^{alls_{a[i].y}}dp_j+a[i].cnt\)

用树状数组维护即可,更新完毕后把\(y \)位置的值对\(dp_i\)取最大值.

code:

#include <iostream>
#include <vector>
#include <unordered_map>
#include <algorithm>

using namespace std;
#define lowbit(x) (x & (-x))
const int N = 1e5 + 10;
typedef long long LL;

struct node
{
    int x, y;
    LL cnt;
};

int n, m, k;
unordered_map<int, int> alls;
int all;

LL s[N];
void modify(int x, LL y)
{
    for(int i = x; i <= all; i += lowbit(i)) s[i] = max(s[i], y);
}

LL query(int x) 
{
    LL ret = 0;
    for(int i = x; i; i -= lowbit(i)) ret = max(ret, s[i]);
    return ret;
}

int main()
{
    cin >> n >> m >> k;
    vector<node> a(k + 1);
    vector<int> tmp;
    for(int i = 1; i <= k; i++)
    {
        int x, y, cnt; cin >> x >> y >> cnt;
        tmp.push_back(y);
        a[i] = {x, y, cnt};
    }
    sort(tmp.begin(), tmp.end());
    for(auto& x : tmp) 
    {
        if(alls.count(x)) continue;
        alls[x] = ++all;
    }
    sort(a.begin() + 1, a.end(), [](node& a, node& b){
        if(a.x != b.x) return a.x < b.x;
        return a.y < b.y;
    }); 
    vector<LL> dp(k + 1);
    LL ans = 0;
    for(int i = 1; i <= k; i++) 
    {
        int x = alls[a[i].x];
        int y = alls[a[i].y];
        dp[i] = query(y) + a[i].cnt;
        modify(y, dp[i]);
        ans = max(ans, dp[i]);
    }
    cout << ans << endl;
    return 0;
}

P9097 [PA 2020] Elektrownie i fabryki

如果一个区间合法,那么区间和必定大于等于0, 所以本道题考察的就是把区间划分成若干块,每块的区间和大于等于0,代价的最小值,划分的代价即为\(r-l\),对于不连接电线的路段,可以把它当做单元素集合,这样花费就是0.

  • \(dp_i\)表示:\([1,i]\)区间合法的最小花费
  • 如果区间\([j+1,i]\)合法,那么合法的花费就可以取到\(dp_j+i-j-1\),合法的前提是\(sum_i-sum_j\ge0\)\(sum\)即为前缀和数组,即合法的\(j\)需要满足\(sum_j\le sum_i\),同样的让\(sum\)作为下标(离散化后),查询\(\min_{j=1}^{alls_{sum_i}}(dp_j-j)\),用树状数组维护即可。

细节问题:前缀和为0的花费为0,在更新前需要修改一下.

code:

#include <iostream>
#include <vector>
#include <algorithm>
#include <unordered_map>

using namespace std;
typedef long long LL;
#define lowbit(x) (x & (-x))
typedef pair<LL, int> PII;
const int N = 5e5 + 10;
const LL INF = 2e18;
int n;
LL a[N];

unordered_map<LL, int> alls;
int all;

LL s[N];
LL query(int x) 
{
    LL ret = INF;
    for(int i = x; i; i -= lowbit(i)) ret = min(ret, s[i]);
    return ret; 
}

void modify(int x, LL y)
{
    for(int i = x; i <= all; i += lowbit(i)) s[i] = min(s[i], y);
}

int main()
{
    cin >> n;
    vector<LL> tmp;
    for(int i = 1; i <= n; i++) 
    {
        cin >> a[i];
        a[i] += a[i - 1];
        tmp.push_back(a[i]);
    }
    tmp.push_back(0);
    sort(tmp.begin(), tmp.end());
    for(auto& x : tmp) 
    {
        if(alls.count(x)) continue;
        alls[x] = ++all;
    }
    for(int i = 0; i <= all; i++) s[i] = INF;
    vector<LL> dp(n + 1, INF);
    dp[0] = 0; 
    modify(alls[0], 0);
    for(int i = 1; i <= n; i++) 
    {
        int x = alls[a[i]];
        dp[i] = min(query(x) + i - 1, dp[i]);
        modify(x, dp[i] - i);
    }
    cout << (dp[n] == INF ? -1 : dp[n]) << endl;
    return 0;
}

P3287 [SCOI2014] 方伯伯的玉米田

修改一个区间统一加上\(x\),不会影响区间内部数的大小关系,只会影响操作区间结尾和结尾的下一个数,以及开头和开头的前一个数的大小关系,也就是说区间操作一次就相当于对 操作区间左区间\(L\)、操作区间\(Mid\),操作区间右区间\(R\),这三个区间进行单调不下降序列的合并或者分裂。

上述操作可以看做\(Mid\)\(L\)之间的单调不下降序列的合并或者分裂,因此前\(i\)个位置的经过\(j\)次操作后的单调不下降序列可以由其前缀转移而来,为了方便叙述,\(i\)位置操作一次选择的区间都为\([i,n]\)

  • \(dp_{i,j}\) 表示:前 \(i\) 个数,\(i\) 位置进行了 \(j\) 次区间操作后的单调不下降序列。

  • \[dp_{i,j} = \max_{k_1=1}^{a_i+j} \max_{k_2=0}^{j} dp_{k_1,k_2} \]

    \(k_2\)\(j\) 的原因是,每次区间加操作可以同时覆盖多个保留玉米。对于两个保留位置 \(p < i\),若我们希望它们的增加量分别为 \(t\)\(j\)\(t \le j\),则只需进行 \(j\) 次操作:先做 \(t\) 次覆盖 \([p,i]\) 的区间,再做 \(j-t\) 次只覆盖 \([i,i]\) 的区间。总操作次数为 \(j \le k\)

细节问题:由于树状数组的区间是从 \(1\) 开始的,我们循环 \(k_2\) 的时候统一 \(+1\) 做偏移。

code:

code:

#include <iostream>
#include <vector>
#include <algorithm>
#include <unordered_map>

using namespace std;

const int N = 2e4 + 10, M = 510;
#define lowbit(x) (x & (-x))
typedef long long LL;
int n, k;
int a[N];
// dp[i][k]: 前 i 个数,经过 k 次[i, n]次操作得到的最长不下降子序列长度
LL dp[N][M];
// 以 i 为子序列为结尾的修改了 k 次的最长子序列
// i 为数值的逆序排名
LL s[N][M];

void modify(int x, int y, LL z)
{
    for (int i = x; i <= N - 1; i += lowbit(i))
        for (int j = y; j <= k + 1; j += lowbit(j))
            s[i][j] = max(s[i][j], z);
}

LL query(int x, int y)
{
    LL ret = 0;
    for (int i = x; i; i -= lowbit(i))
        for (int j = y; j; j -= lowbit(j))
            ret = max(ret, s[i][j]);
    return ret;
}

int main()
{
    cin >> n >> k;
    for (int i = 1; i <= n; i++)
        cin >> a[i];
    LL ans = 0;
    for (int i = 1; i <= n; i++)
    {
        for (int j = 0; j <= k; j++)
        {
            dp[i][j] = query(a[i] + j, j + 1) + 1;
            ans = max(ans, dp[i][j]);
        }
        for (int j = 0; j <= k; j++)
        {
            modify(a[i] + j, j + 1, dp[i][j]);
        }
    }
    cout << ans << endl;
    return 0;
}

总结

树状数组优化的dp类型可以统一看做二维偏序问题(实际上多维也可以,只要空间开的下)。

二维偏序:定义集合\(S\)中的每个元素\(x\)有两个属性\((a_x,b_x)\)。定义二元关系\(\preceq\)为:

\(x \preceq y \iff a_x \le a_y 且b_x\le b_y\),其中\(\le\)是实数(或整数)上的自然全序关系。那么$(S, \preceq) $构成一个偏序集,该关系称为二维偏序。


在算法竞赛中,二维偏序的表现形式一般为:

给定\(n\)个点\((x_i,y_i)\),二维偏序问题通常要求:

  • 统计所有满足\(x_i \le x_j\)\(y_i \le y_j\)的点对\((i,j)\)的数量。
  • 或对于每个点\(j\),查询所有满足\(x_i<x_j\)\(y_i<y_j\)\(i\)的某种聚合信息(如\(max \ dp_i\)\(sum \ dp_i\))。

这两点其实都可以看做广义上的和,第一点是点对的和,第二点是最值的和。

  • 【模板】最长上升子序列,在第一道题目中,状态转移方程为\(dp=\max_{j=1}^{all}(s_j)+1\),二维偏序关系\(x \preceq y \iff a_x \le a_y 且b_x\le b_y\), 其中\(a_x\)为下标\(j\)\(b_x\)\(j\)位置的值,求的是满足条件的聚合信息\(max\ dp_j\)
  • P1637 三元上升子序列, 在第三道目题中,状态转移方程为\(dp_i=\sum_{j=1}^{a_i-1}{s_j}\),二维偏序关系\(x \preceq y \iff a_x \le a_y 且b_x\le b_y\),其中$a_x \(为下标\)
    j\(,\)b_j\(为下标同样是\)j\(位置的值,求的是满足条件的点对\)(i,j)$的数量,其中维护的信息为上升二元组的数量,计算这个也是一个二维偏序问题,本质是一个双二维偏序的题目。
  • 9097 [PA 2020] Elektrownie i fabryki,在第四道题目中,状态转移方程为\(dp_i=\max_{j=1}^{alls_{a[i].y}}dp_j+a[i].cnt\),二维偏序关系\(x \preceq y \iff a_x \le a_y 且b_x\le b_y\), 其中\(a_x\)\(x\)点的横坐标,\(b_x\)\(x\)点的纵坐标,求的同样是满足条件的聚合信息\(max \ dp_i\)
  • P9097 [PA 2020] Elektrownie i fabryki,在第五道题目中,状态在转移方程为\(dp_i=\min_{j=1}^{alls_{sum_i}}(dp_j-j)+i-1\),二维偏序关系\(x \preceq y \iff a_x \le a_y 且b_x\le b_y\), 其中\(a_x\)为下标\(j\)\(b_x\)\(j\)位置的值,求的是满足条件的聚合信息\(max\ dp_j-j\)
  • P3287 [SCOI2014] 方伯伯的玉米田,在最后一道问题中,\(dp_{i,j}=\max_{k_1=1}^{a_i+j}\max_{k_2=0}^{j}dp_{k_1,k_2}\),第一位偏序关系显然是下标\(j\),但是\(\max_{k_1=1}^{a_i+j}\max_{k_2=0}\)也是一个二维偏序求前缀最大值,所以加起来就到三维偏序问题了?树状数组可以维护吗?首先需要澄清的一点是,树状数组可以单独维护偏序问题,二维树状数组可以维护二维偏序问题,不要被前面的题带偏了,一般用排序优化掉一维全序关系然后用树状数组去维护另一维是因为二维的树状数组开不下。下标排序后自然变成二维偏序问题,可以用树状数组直接维护这个信息。
    • 多维偏序问题的可以用多维dp解决,用数据结构优化本身不是为了降维,而是为了动态维护一维区间的相关信息,降维操作是排序+循环遍历做到的,前面题的下标自然有序就可以优化掉一维,用树状数组维护另一维度可以做到快速查询以及修改,实际上树状数组中的信息在更新\(i\)位置的时候都统一带上了\(j\le i\)这个条件了,单调队列优化dp的很多问题也是二维偏序,只不过它的第二的特征是需要定长区间的最值,树状数组就没法维护。
    • 另外说三维偏序问题是通过\(CDQ\)分治解决,本质原因是因为排序只能优化掉一维,剩下的两维用二维树状数组又开不下,\(CDQ\)分治的核心思想是通过分治解决第一维,排序解决第二维,然后是树状数组维护第三维信息
      • 第一维:通常先对全部点按第一维排序(或者 CDQ 分治过程中按第一维划分区间),这样在分治时左半区间的第一维一定 ≤ 右半区间的第一维。
      • 第二维:在合并左右区间时,对第二维进行归并排序,利用双指针扫描,使得第二维的比较在线性时间内完成,同时将符合条件的左半点的第三维插入树状数组。
      • 第三维:树状数组维护第三维的前缀信息(如个数、最值等),用于查询右半点的贡献。

posted on 2026-06-29 09:43  我不爱吃汉堡  阅读(1)  评论(0)    收藏  举报

导航