左偏树,可并堆详解,OJ练习,代码详解

一、左偏树的定义和性质

1.1优先队列的定义

优先队列(Priority Queue)是一种抽象的数据类型,它是一种容器,里面存有一些元素,也称为队列中的节点。优先队列的节点要保持一种有序性,这也就要求任意两节点之间可以比较大小。优先队列有三个基本操作:插入节点、取得最小节点、删除最小节点。

1.2可并堆的定义

**可并堆(Mergerable Heap)**也是一种抽象数据类型,它除了支持优先队列的三个基本操作外,还支持一个额外操作——合并操作。

如果不需要合并操作,二叉堆就是我们常用的一种理想的优先队列,但是合并两个二叉堆需要O(n)的时间复杂度,如果直接使用二叉堆朴素合并来作为可并堆的实现,那么合并的时间开销很多时候是我们不能够承受的。

1.3左偏树

1.3.1左偏树的定义

左偏树(Leftlist Tree)是一种具有左偏性质的堆有序的二叉树,是可并堆的一种实现,以下讨论皆为小根堆可并堆

左偏树的每一个节点x存储的信息包括左右子节点lc[x],rc[x],权值v[x]和距离dist[x]

左儿子或右儿子为空的节点我们称之为外节点

我们定义节点的距离节点到达最近的外节点经过的边数

规定,外界点的距离的-1,空节点的距离为-1

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

1.3.2左偏树的性质
  • 性质1:堆的性质,对于任意节点v[x] <= v[lc[x]],v[x] <= v[rc[x]](小根堆)
  • 性质2:左偏性质,左儿子距离 >= 右儿子距离,dist[lc] >= dist[rc]
  • 性质3:任意节点的距离 = 右儿子距离 + 1,dist[x] = dist[rc] + 1
  • 性质4:一棵有n个节点的二叉树,根的dist <= log(n + 1) - 1
    • 证明:
    • 根的距离为x,说明有x + 1层是满二叉树,那么至少有2 ^ (x + 1) - 1个节点,即n >= 2 ^ (x + 1) - 1,则可推出x <= log(n + 1) - 1
1.3.3左偏树的合并操作
1.3.3.1合并操作流程

合并操作是左偏树最重要的操作:

  • 维护堆的性质:先取值较小的根作为合并后的根节点,然后递归合并其右儿子和另一个堆,作为合并后的堆的右儿子。
  • 维护左偏性质,合并后如果左儿子的dist小于右儿子的dist,就交换两个儿子
  • 维护根的dist:根的dist为右儿子dist + 1

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

1.3.3.2合并操作的代码实现
//int a[N], p[N], dist[N], lc[N], rc[N], n, m;
int merge(int x, int y)
{
    if (!x || !y)
        return x + y;
    if (a[x] == a[y] ? x > y : a[x] > a[y])
        swap(x, y);
    rc[x] = merge(rc[x], y);
    if (dist[lc[x]] < dist[rc[x]])
        swap(lc[x], rc[x]);
    dist[x] = dist[rc[x]] + 1;
    return x;
}

1.4左偏树OJ练习

1.4.1模板

1.4.1.1原题链接

P3377 【模板】左偏树/可并堆 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

1.4.1.2思路分析

题目要求我们实现两种操作,合并两个数所在堆和删除某个数所在堆的最小数

那么我们可以实现一个最小堆的左偏树

但是有个问题,我们还要能够快速找到任意数字所在堆,这我们不难想到在维护可并堆的同时维护一个并查集

实际上我们维护了两棵树,左偏树和并查集树,对于删除的元素我们将其权值置为-1

对操作1:

如果x,y中有某个数字权值为-1,我们跳过

否则找到其所在堆的根px、py,如果px和py不同,就合并px,py,然后更新p[px] = p[py] = merge(px, py),即合并堆的同时维护并查集

对操作2:

如果x已经删除,那么输出-1,直接跳过

找到堆顶px,然后p[px] = p[lc[px]] = p[rc[px]] = merge(lc[px], rc[px])

1.4.1.3AC代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 1e5 + 10;
int a[N], p[N], dist[N], lc[N], rc[N], n, m;
int findp(int x) { return p[x] == x ? x : p[x] = findp(p[x]); }
int merge(int x, int y)
{
    if (!x || !y)
        return x + y;
    if (a[x] == a[y] ? x > y : a[x] > a[y])
        swap(x, y);
    rc[x] = merge(rc[x], y);
    if (dist[lc[x]] < dist[rc[x]])
        swap(lc[x], rc[x]);
    dist[x] = dist[rc[x]] + 1;
    return x;
}
int main()
{
    ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
    //freopen("in.txt", "r", stdin);
    cin >> n >> m;
    for (int i = 1; i <= n; i++)
        cin >> a[i], p[i] = i;
    dist[0] = -1;
    for (int i = 0, op, x, y; i < m; i++)
    {
        cin >> op;
        if (op == 1)
        {
            cin >> x >> y;
            if (a[x] == -1 || a[y] == -1)
                continue;
            x = findp(x), y = findp(y);
            if (x != y)
                p[x] = p[y] = merge(x, y);
        }
        else
        {
            cin >> x;
            if (a[x] == -1)
            {
                cout << -1 << '\n';
                continue;
            }
            x = findp(x);
            cout << a[x] << '\n';
            a[x] = -1;
            p[lc[x]] = p[rc[x]] = p[x] = merge(lc[x], rc[x]);
        }
    }
    return 0;
}

1.4.2P1552 [APIO2012] 派遣

1.4.2.1原题链接

[P1552 APIO2012] 派遣 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

1.4.2.2思路分析

有点像力扣某道打家劫舍的意思,会想到树形dp

但是树形dp的话我们可以计算某个节点为根的树的最大满意度,但是当预算超出的时候我们就没法调整了

我们考虑自底向上进行遍历,计算每个忍者作为管理者时,其所在子树能够派遣的最大忍者数目以及所花费的钱,然后向前枚举的时候把后面的状态合并进来就行了

这样我们就想到了左偏树

因为左偏树既能让我们在超出预算的时候弹出堆中最大薪水的忍者又能把当前这个堆合并给下一个忍者

1.4.2.3AC代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1e5 + 10;
#define int long long
int n, m, ans;
int fa[N], sz[N], sum[N], cost[N], lead[N], root[N], lc[N], rc[N], dist[N];
int merge(int x, int y)
{
    if (!x || !y)
        return x + y;
    if (cost[x] < cost[y])
        swap(x, y);
    rc[x] = merge(rc[x], y);
    if (dist[lc[x]] < dist[rc[x]])
        swap(lc[x], rc[x]);
    dist[x] = dist[rc[x]] + 1;
    return x;
}
signed main()
{
    ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
    //freopen("in.txt", "r", stdin);
    cin >> n >> m, dist[0] = -1;
    for (int i = 1; i <= n; i++)
        cin >> fa[i] >> cost[i] >> lead[i], sz[i] = 1, sum[i] = cost[i], root[i] = i, ans = max(ans, lead[i]);
    for (int i = n, f; i >= 1; i--)
    {
        f = fa[i];
        sum[f] += sum[i], sz[f] += sz[i], root[f] = merge(root[f], root[i]);
        while (sum[f] > m)
            sz[f]--, sum[f] -= cost[root[f]], root[f] = merge(lc[root[f]], rc[root[f]]);
        ans = max(ans, sz[f] * lead[f]);
    }
    cout << ans;
    return 0;
}

1.4.3P4331 [BalticOI 2004] Sequence 数字序列

1.4.3.1原题链接

[P4331 BalticOI 2004] Sequence 数字序列 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)

1.4.3.2思路分析

很难想的一道题,但是这道题可以作为求将某序列转化为严格单调序列的最小代价的模板

我们将a[] 和 b[] 都减去下标i得到a’ 和 b’,我们发现Σ|a[i] - b[i]| = Σ|a’[i] - b’[i] - i + i|

显然转化后,绝对值差的和是和原问题等价的

为什么要这样转换呢?

b’i+1 - b’i = bi+1 - bi + i + 1 - i = bi+1 - bi - 1 >= 0,也就是说b‘是一个非降序序列

对于序列a’,它一定是由非降序区间和降序区间组成的

对于非降序区间,我们取b’[i] = a’[i],显然最优

对于降序区间,我们如果取b’[i] = a’[i]可能无法保证b’非降序,这个时候就要求我们对前面的策略进行调整

有一个广为人知的结论:当x为序列a的中位数时,有Σ|a[i] - x|最小

那么我们此时可以把b’[i]和前面区间合并,直到区间中位数不小于前面区间的中位数,这样就保证了当前处理过的每一段都在最优解

具体步骤:

  • 可并堆的节点为一个区间,保存区间的右端点(左端点可以和前一个区间做差得到),区间中位数下标,区间小于等于中位数的元素个数sz
  • 为了方便和前面的区间合并,我们开一个栈s
  • 将a转化为a’,遍历a‘
    • 开一个右端点为i,小于等于中位数的元素个数为1,中位数下标为i的区间,区间先入栈
    • 如果栈内区间个数大于1,并且栈顶下面那个区间中位数大于栈顶区间中位数,合并两个区间
    • 如果合并后sz > 区间长度/2 + 1,说明堆顶不是中位数因为合并后多了个元素,我们弹出堆顶,保证堆顶就是中位数
    • 如此重复下去直到遍历完a’
  • 遍历栈中区间,每个区间内的b值都是区间中位数加i

这道题比较难想,主要得理解合并后保证每个区间都在最优解

1.4.3.3AC代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1e6 + 10;
struct line
{
    int mid, r, sz;
} s[N];
int n, a[N], b[N], lc[N], rc[N], dist[N], top;
int merge(int x, int y)
{
    if (!x || !y)
        return x + y;
    if (a[x] < a[y])
        swap(x, y);
    rc[x] = merge(rc[x], y);
    if (dist[lc[x]] < dist[rc[x]])
        swap(lc[x], rc[x]);
    dist[x] = dist[rc[x]] + 1;
    return x;
}
int main()
{
    //freopen("in.txt", "r", stdin);
    ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
    cin >> n, dist[0] = -1;
    for (int i = 1; i <= n; i++)
    {
        cin >> a[i], a[i] -= i;
        s[++top] = (line){i, i, 1};
        while (top > 1 && a[s[top - 1].mid] > a[s[top].mid])
        {
            s[top - 1].mid = merge(s[top - 1].mid, s[top].mid);
            s[top - 1].r = s[top].r, s[top - 1].sz += s[top].sz, top--;
            if (s[top].sz > (s[top].r - s[top - 1].r) / 2 + 1)
                s[top].mid = merge(lc[s[top].mid], rc[s[top].mid]), s[top].sz--;
        }
    }
    for (int i = 1, j = 1; i <= top; i++)
        while (j <= s[i].r)
            b[j++] = a[s[i].mid];
    long long res = 0;
    for (int i = 1; i <= n; i++)
        res += abs(a[i] - b[i]);
    cout << res << '\n';
    for (int i = 1; i <= n; i++)
        cout << b[i] + i << ' ';
    return 0;
}

1.4.4K-Monotonic

1.4.4.1原题链接

3016 – K-Monotonic (poj.org)

1.4.4.2思路分析

1.4.3数字序列和POJ3666 Making the Grade的缝合版

关于POJ3666想了解的可以见:线性dp+中位数,POJ3666 Making the Grade-CSDN博客

就是一个基础的dp题目

我们定义f[i][j]为前i个数字划分为j个严格单调区间的最小代价,cost[i][j]为区间[i, j]变为严格单调区间的最小代价

那么有f[i][j] = min(f[i][j], f[i - len][j - 1] + cost[i - len + 1][i])

方程还是很好理解的,我们枚举第i个数所在区间len,那么f[i][j]就能由前i-len个数字划分为j - 1个严格单调区间以及i - len + 1到i转化为严格单调区间的代价转移

我们方程的总时间复杂度为O(n^2 * k),k才10,n也就1000,时间复杂度是够的

那么如何求cost呢?我们发现求区间变成严格单调区间的代价就是1.4.3数字序列那道题目

所以我们只需要按照1.4.3数字序列的方法预处理cost即可,注意到题目要求的是单调区间,所以我们要递增求一次,递减求一次

1.4.3数字序列中我们相当于在O(nlogn)内求出了cost[1][n],实际上我们已经计算了cost[i][n],只需要枚举的时候维护一下即可

这要求我们能够快速求出区间内Σ|ai - mid|

设区间内一共由tot_sz个数字,小于等于中位数的有tr_sz个数字,区间总和为tot_sum,小于等于中位数的总和为tr_sum

那么区间内的贡献就是mid * tr_sz - tr_sum + tot_sum - tr_sum - (tot_sz - tr_sz) * mid

这四个值在合并的时候也都很好维护,详细看代码即可

1.4.4.3AC代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
#define int long long
const int N = 1010;
int a[N], lc[N], rc[N], dist[N], cost[N][N], f[N][12], top, n, k;
struct line
{
    int mid, r, tot_sum, tot_sz, tr_sum, tr_sz;
    int get_cost()
    {
        return a[mid] * tr_sz - tr_sum + tot_sum - tr_sum - (tot_sz - tr_sz) * a[mid];
    }
} s[N];
int merge(int x, int y)
{
    if (!x || !y)
        return x + y;
    if (a[x] < a[y])
        swap(x, y);
    rc[x] = merge(rc[x], y);
    if (dist[lc[x]] < dist[rc[x]])
        swap(lc[x], rc[x]);
    dist[x] = dist[rc[x]] + 1;
    return x;
}
void cal(int idx)
{
    top = 0, memset(s, 0, sizeof s), memset(lc, 0, sizeof lc), memset(rc, 0, sizeof rc), memset(dist, 0, sizeof dist), dist[0] = -1, s[0].r = idx - 1;
    for (int i = idx, res = 0; i <= n; i++)
    {
        s[++top].mid = i, s[top].r = i, s[top].tot_sum = s[top].tr_sum = a[i], s[top].tot_sz = s[top].tr_sz = 1;
        while (top > 1 && a[s[top - 1].mid] > a[s[top].mid])
        {
            res -= s[top - 1].get_cost();
            s[top - 1].mid = merge(s[top - 1].mid, s[top].mid);
            s[top - 1].r = s[top].r, s[top - 1].tot_sum += s[top].tot_sum, s[top - 1].tr_sum += s[top].tr_sum, s[top - 1].tr_sz += s[top].tr_sz, s[top - 1].tot_sz += s[top].tot_sz, top--;
            while (s[top].tr_sz > (s[top].r - s[top - 1].r) / 2 + 1)
                s[top].tr_sum -= a[s[top].mid], s[top].tr_sz--, s[top].mid = merge(lc[s[top].mid], rc[s[top].mid]);
        }
        res += s[top].get_cost();
        cost[idx][i] = min(cost[idx][i], res);
    }
}
signed main()
{
    //freopen("in.txt", "r", stdin);
    ios::sync_with_stdio(false), cin.tie(0), cout.tie(0);
    while (cin >> n >> k, n)
    {
        memset(cost, 0x3f, sizeof cost);
        for (int i = 1; i <= n; i++)
            cin >> a[i], a[i] -= i;
        for (int i = 1; i <= n; i++)
            cal(i);
        for (int i = 1; i <= n; i++)
            a[i] += i, a[i] = -a[i], a[i] -= i;
        for (int i = 1; i <= n; i++)
            cal(i);
        memset(f, 0x3f, sizeof f), f[0][0] = 0;
        for (int i = 1; i <= n; i++)
            for (int j = 1; j <= k; j++)
                for (int len = 1; len <= i; len++)
                    f[i][j] = min(f[i][j], f[i - len][j - 1] + cost[i - len + 1][i]);
        cout << f[n][k] << '\n';
    }
    return 0;
}
  • 60
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

EQUINOX1

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值