Day 2 上午

内容提要:

二叉搜索树

二叉

区间RMQ问题

 


 

二叉搜索树

前置技能
本节课可能用到的一些复杂度:
O(log n).

n/1+n/2+...+n/n=O(n log n)

入门题:

给出N次操作,每次加入一个数,删除一个之前加入过的数,或者询问当前所有数的最大值。
N ≤ 100000.
二叉搜索树
二叉搜索树(BST)是用有根二叉树来存储的一种数据结构,二叉树中每个节点代表一个数据。每个节点包含一个指向父亲的指针,和两个指向儿子的指
针。如果没有则为空。
每个节点还包含一个key值,代表他本身这个点的权值
特征:
二叉搜索树的key值是决定树形态的标准。
每个点的左子树中,节点的key值都小于这个点。
每个点的右子树中,节点的key值都大于这个点。
示例:

 

用结构体或者是若干个数组存储
在接下来的介绍中,我们将以ls[x]表示x的左儿子,rs[x]表示x的右儿子,fa[x]表示x的父亲,key[x]表示x这个点的权值。


BST是一种支持对权值进行查询的数据结构,它兹磁:
插入一个数,删除一个数,询问最大/最小值,询问第k大值。
当然,在所有操作结束后,它还能把剩下的数从小到大输出来


查询最大/最小值
注意到BST左边的值都比右边小,所以如果一个点有左儿子,就往左儿子走,否则这个点就是最小值啦。

代码(最小值)

int FindMin()
{
    intx = root;
    while (ls[x]) x = ls[x];
    return key[x];
}

一直找左儿子


现在我们要插入一个权值为x的节点。
为了方便,我们插入的方式要能不改变之前整棵树的形态。
首先找到根,比较一下key[root]和x,如果key[root] < x,节点应该插在root右侧,否则再左侧。
看看root有没有右儿子,如果没有,那么直接把root的右儿子赋成x就完事了。
否则,为了不改变树的形态,我们要去右儿子所在的子树里继续这一操作,直到可以插入为止。


代码:

void insert(intval)
{
    key[+ + tot] = val; ls[tot] = rs[tot] = 0;
    int now = root;//当前访问的节点为now.
    for(; ; )
    {
        if (val < key[now])
        if (!ls[now]) ls[now] = x, fa[x] = now, break; 
        else now =ls[now];
        else if (!rs[now]) rs[now] = x, fa[x] =now, break; 
        else now = rs[now];
    }
}

删除一个值

现在我们要删除一个权值为x的点
之前增加一个点我们能够不改变之前的形态,那删除可以吗?


定位一个节点
要删掉一个权值,首先要知道这个点在哪。
从root开始,像是插入一样找权值为x的点在哪


每次比较当前的key值和节点的大小关系,然后重复操作

代码:

int Find(int x)
{
    int now = root;
    while(key[now]! = x)
        if (key[now] < x) now = rs[now]; else now = ls[now];
    return now;
}

删除的方案:

方案一
直接把这个点赋成一种空的状态.但是这样的话查询起来不太方便(会有非常大的困难)。
所以还是稍微麻烦一点吧。

方案二
对这个节点x的儿子情况进行考虑。
如果x没有儿子,删掉x就行了。
如果x有一个儿子,直接把x的儿子接到x的父亲下面就行了。
这样子是肯定没有问题的,因为左边总比右边小嘛,所以x的儿子肯定比右边的大儿子小,这样就可以交换了
如果x有两个儿子,这种情况就比较麻烦了。
定义x的后继y,是x右子树中所有点里,权值最小的点。(有很多方法,这样比较方便,如果找左子树中最大的或者是直接删掉然后调整顺序应该也可以)
找这个点可以x先走一次右儿子,再不停走左儿子。
如果y是x的右儿子,那么直接把y的左儿子赋成原来x的左儿子,然后用y代替x的位置。

 

代码:

build2
先排序,从中间分开,两边生成的二叉搜索树接到中间的点上


求解第k大值(小扩展)
如果不能求的话它将会被堆完爆。
对每个节点在多记一个size[x]表示x这个节点子树里节点的个数(包含他自己)。
每个节点的size就是他的左右儿子的size之和+1
从根开始,如果右子树的size ≥ k,就说明第k大值在右侧,往右边走,如果右子树size + 1 = k,那么说明当前这个点就是第k大值。
否则,把k减去右子树size + 1,然后递归到左子树继续操作。
有相等的数据的话可以定义右边的为≥
插入或者删除的话他所有父亲的size都要+1或者-1
二叉搜索树也可以实现一个排序的作用
遍历
注意到权值在根的左右有明显的区分。.
做一次中序遍历就可以从小到大把所有树排好了。(左儿子--中节点--右儿子)

int dfs(int now)
{
    if (ls[now]) dfs(ls[now]);
    print(key[now]);
    if (rs[now]) dfs(rs[now]);
}

回到最初的题
让我们回到最初的题。
一个良好的例子:3 1 2 4 5
一个糟糕的例子:1 2 3 4 5
二叉搜索树最坏的情况下每次操作访问O(h)个节点。(h为树高)


总结
既然他的复杂度与直接暴力删除类似,那我们为什么要学他呢?
1.因为教学安排里有(大误).
2.这是第一个能够利用树的中序遍历的性质的数据结构。
3.扩展性强。更复杂的splay,treap,SGT等都基于二叉搜索树,只是通过一些对树的形态的改变来保证操作的复杂度,且保持树中序遍历的形态。
4.因为数据很水。随机数据还是很强势的(树高期望是log n)

 

 二叉堆

满 二叉树:除了最后一层节点其他都是满的,而且最后一层也是从左到右
每个节点的儿子是当前节点的序号*2和序号*2+1;
每个节点的父亲就是当前节点的序号/2

 

定义
用二叉搜索树还是没法解决我们之前的问题。
堆是一种特殊的二叉树,并且是一棵满二叉树。
第i个节点的父亲是i/2,这样我们就不用存每个点的父亲和儿子了。
二叉搜索树需要保持树的中序遍历不变,而堆则要保证每个点比两个儿子的权值都小。

 

建堆
首先是要建出这么一个堆,最快捷的方法就是直接O(N log N)排一下序。
反正堆的所有操作几乎都是O(log N)的。
之后可以对这个建堆进行优化。

 

求最小值
可以发现每个点都比两个儿子小,那么最小值显然就是a[1]辣,是不是很simple啊。

 

插入一个值
注意到二叉搜索树中的复杂度都是O(h).
在堆中我们也想让复杂度是O(h) = O(log n).
这样一来我们就要让树的形态不变,所以我们每次改变的都是权值的位置。
首先我们先把新加入的权值放入到n + 1的位置。
然后把这个权值一路往上比较,如果比父亲小就和父亲交换.
注意到堆的性质在任何一次交换中都满足。

 

修改一个点的权值
咦,为什么没有删除最小值?
删除最小值只要把一个权值改到无穷大就能解决辣
比较简单的是把一个权值变小,那只要把这个点像插入一样向上动就行了。

 


变大权值
那么这个点应该往子树方向走。
看看这个点的两个儿子哪个比较小,如果小的那个儿子的权值比他小,就交换,直到无法操作。
就是不断找到两个儿子中较小的一个进行交换

 

解决定位问题
一般来说,堆的写法不同,操作之后堆的形态不同,所以一般给的都是改变一个权值为多少的点.
假设权值两两不同,再记录一下某个权值现在哪个位置,在交换权值的时候顺便交换位置信息。

 

删除权值
理论上来说删除一个点的权值就只需要把这个点赋成inf 然后down一次。
但是这样堆里的元素只会越来越多(最后一层全是inf)
我们可以把堆里第n号元素跟这个元素交换一下。
然后n--,把堆down一下就行了。

#include<cstdio>
#include<algorithm>
#include<cstring>
#include<iostream>
#include<cstring>
#include<string>
#include<cmath>
#include<ctime>
#include<set>
#include<vector>
#include<map>
#include<queue>

#define N 300005
#define M 8000005

#define ls (t<<1)
#define rs ((t<<1)|1)
#define mid ((l+r)>>1)

#define mk make_pair
#define pb push_back
#define fi first
#define se second

using namespace std;

int i,j,m,n,p,k,a[N];

int FindMin()
{
        return a[1];
}

void build1()
{
        sort(a+1,a+n+1);
}

void up(int now)
{
        while (now&&a[now]<a[now/2]) swap(a[now],a[now/2]),now/=2;
}

void ins(int x)
{
        a[++n]=x; up(n);
}

void down(int now)
{
        while (now*2<=n)
        {
                if (now*2==n)
                {
                        if (a[now]>a[now*2]) swap(a[now],a[now*2]),now*=2; 
                }
                else
                {
                        if (a[now]<=a[now*2]&&a[now]<=a[now*2+1]) break;
                        if (a[now*2]<a[now*2+1]) swap(a[now],a[now*2]),now*=2;
                        else swap(a[now],a[now*2+1]),now=now*2+1; 
                }
        }
}

void del(int x)
{
        swap(a[x],a[n]); --n;
        up(x);
        down(x);
}

void change(int x,int val)
{
        if (a[x]>val)
        {
            a[x]=val;
            up(x);
        }
        else
        {
            a[x]=val;
            down(x);
        }
}

void build2()
{
        for (i=n/2;i>=1;--i) down(i);
}

int main()
{ 
     scanf("%d",&n);
     for (i=1;i<=n;++i) scanf("%d",&a[i]);
     build2();
}
    

del那里可以比较两个大小再选择up或者down,也可以先up再down

建堆
现在来考虑一种新的建堆方法。
倒序把每个节点都down一下,正确性肯定没有问题。
复杂度n/2 + n/4 * 2 + n/8 * 3 + .... = O(n)


堆排序
给N个数,输出他们从小到大排序的结果.
N ≤ 100000.

题解:
把数全部插进去,每次询问最小值,然后把根删掉就行了.
复杂度O(N log N).


丑数
丑数指的是质因子中仅包含2, 3, 5, 7的数,最小的丑数是1,求前k个丑数。
K ≤ 6000.

题解:
打表大法好!没有什么是打表解决不了的.
算了说正经的。
考虑递增的来构造序列.
x被选中之后,接下来塞进去x * 2, x * 3, x * 5, x * 7.
如果当前最小的数和上一次选的一样,就跳过.
复杂度O(K log N).

Queue
每次都要写堆太麻烦了有没有什么方便的。
在C + +的include < queue >里有一个叫priority queue的东西。

基本操作

Q.push()
Q.top()
Q.pop()
Q.clear()

set
堆好弱小啊,有没有什么更好用的。
在C + +的include < set >里有一个叫set的东西。
高级的二叉搜索树
左闭右开

基本操作:

set<int>st
st.insert()//插入
st.erase()//删除
st.fnd()//查找
st.lower/upper bound()//第一个≥他的位置/第一个>他的位置
st.begin()/st.end()//最大值/最小值后面的部分
有独有的迭代器
set<int>::iterator it=st.lower_bound(x);
++it;--it;//下标右移一位/左移一位
x=*it;//下表所对应的值

堆有啥用
我也不知道它有啥用(大雾
了解一种数据结构
为将来学习可并堆,斐波那契堆打下坚实基础(政治课即视
比STL快。
能优化dij(图论)

RMQ
区间RMQ问题是指这样一类问题。
给出一个长度为N的序列,我们会在区间上干的什么(比如单点加,区间加,区间覆盖),并且询问一些区间有关的信息(区间的和,区间的最大值)等。


最简单的问题
给出一个序列,每次询问区间最大值.
N ≤ 100000, Q ≤ 1000000

ST表
ST表是一种处理静态区间可重复计算问题的数据结构,一般也就求求最大最小值辣。
ST表的思想是先求出每个[i, i + 2k)的最值。
注意到这样区间的总数是O(N log N)的.
log N这一复杂度是OI最常用复杂度。
而sqrt(N)是OI最玄学的复杂度。


预处理
不妨令fi,j为[i, i + 2^j)的最小值。
那么首先fi,0的值都是它本身。
而fi,j = min(fi,j−1, fi+2^j−1,j−1)
这样在O(N log N)的时间内就处理好了整个ST表

ST表代码:

#include<cstdio>
#include<algorithm>
#include<cstring>
#include<iostream>
#include<cstring>
#include<string>
#include<cmath>
#include<ctime>
#include<set>
#include<vector>
#include<map>
#include<queue>

#define N 300005
#define M 8000005
#define K 18 

#define ls (t<<1)
#define rs ((t<<1)|1)
#define mid ((l+r)>>1)

#define mk make_pair
#define pb push_back
#define fi first
#define se second

using namespace std;

int i,j,m,n,p,k,ST[K+1][N],a[N],Log[N];

int Find(int l,int r)
{
        int x=Log[r-l+1];
        return max(ST[x][l],ST[x][r-(1<<x)+1]); //注意到对于[l,r],[l,l+2^x-1],[r-2^x+1,r]并起来是[l,r] 
}

int main()
{
        scanf("%d",&n);
        for (i=1;i<=n;++i) scanf("%d",&a[i]);
        for (i=1;i<=n;++i) ST[0][i]=a[i];
        for (i=1;i<=K;++i)
            for (j=1;j+(1<<i)-1<=n;++j)
                    ST[i][j]=max(ST[i-1][j],ST[i-1][j+(1<<(i-1))]); //ST[i][j]为从j开始的长度为2^i的区间的最大值 
                                                        //显然[j,j+2^i)=[j,j+2^(i-1))+[j+2^(i-1),j+2^i)=max(ST[i-1][j],ST[i-1][j+2^(i-1)])
        for (i=1;(1<<i)<N;++i) Log[1<<i]=i; //令Log[x]为比x小的最大的2^y 
        for (i=1;i<N;++i) if (!Log[i]) Log[i]=Log[i-1];
        printf("%d\n",Find(1,3));
}

询问
比如我们要询问[l, r]这个区间的最小值.
找到最大的k满足2^k ≤ r − l + 1.
取[l, l + 2^k), [r − 2^k + 1, r + 1)这两个区间。
注意到这两个区间完全覆盖了[l, r],所以这两个区间最小值
较小的一个就是[l, r]的最小值。
注意到每次询问只要找区间就行了,所以复杂度是O(1).


注意
ST表确实是一个询问O(1)的数据结构,但是它的功效相对也较弱.
例如每次求一个区间的和,利用前缀可以做
到O(N) − O(1).而ST却无法完成。

做题四部曲:

比较简单的问题
给出一个序列,支持对某个点的权值修改,或者询问某个区间的最大值.
N, Q ≤ 100000


冷静分析
嗯,刚学了ST表
它只要O(1)就能询问了。
能不能让它动起来呢?

仔细思考
来考虑一下能不能强行维护ST表.
比如改5这个点.
j = 0 改了一个位置,嗯,完美.
j = 1 改了两个位置4, 5,稳.
j = 2 改了四个位置2, 3, 4, 5,还行.
...


发现问题
注意到当j往上走的时候,要改的区间的个数是这个点的编号.
现在修改一次要修改O(N)个点.
一看就很不靠谱

妈耶我凉了啊
ST表不靠谱,那怎么办呢
稍微考虑一下,可能是我们选的区间不靠谱。(因为会重叠)
换一种区间选取方式也许会靠谱。


线段树
其实线段树被称为区间树比较合适,本质是一棵不会改变形态的二叉树.
树上的每个节点对应于一个区间[a, b](也称线段),a,b通常为整数
他是维护区间的信息

基本形态
同一层的节点所代表的区间,相互不会重叠
同一层节点所代表的区间,加起来是个连续的区间
对于每一个非叶结点所表示的结点[a,b],其左儿子表示的区间为[a,(a+b)/2],右儿子表示的区间为[(a+b)/2+1,b](除法去尾取整)
叶子节点表示的区间长度为1.

示例1:n=10

 

示例2:n=9

 

 

注意到线段树的结构有点像分治结构,深度也是O(log N)的.

每层里面区间长度相差不超过一


区间拆分
区间拆分是线段树的核心操作,我们可以将一个区间[a, b]拆分成若干个节点,使得这些节点代表的区间加起来是[a, b],并且相互之间不重叠.
所有我们找到的这些节点就是”终止节点”.


区间拆分的步骤
从根节点[1, n]开始,考虑当前节点是[L, R].
如果[L, R]在[a, b]之内,那么它就是一个终止节点.
否则,分别考虑[L, Mid],[Mid + 1, R]与[a, b]是否有交,递归两
边继续找终止节点

 

区间拆分的例子

 

 

注意:

有多少个底层的点,至少需要开四倍

解题方法
充分利用区间分解的性质.
思考在终止节点要存什么信息,如何快速维护这些信息,不要每次一变就到最底层.

例1
给一个数的序列A1, A2, ..., An.并且可能多次进行下列两个操作:
1.对序列里面的某个数进行加减
2.询问这个序列里面任意一个连续的子序列Ai, Ai+1...Aj的和是多少.
希望单个操作能在O(log N)的时间内完成.

题解:
对于每个节点[L, R],我们记录AL + ... + AR.
对于操作1:相当于我们对[i, i]这个区间做了一个区间分解.沿路我们在找到[i,i]时经过的所有祖先节点.
对于操作2:我们对[L, R]做一个区间分解,将每个区间对应的和累加起来就是想要知道的区间和.

 

伪代码:

#include<cstdio>
#include<algorithm>

#define N 100005

#define ls (t<<1)
#define rs ((t<<1)|1)
#define mid ((l+r)>>1)

int tree[N*4];
//操作1 
void modify(int ll,int rr,int c,int l,int r,int t)//将t修改为c,并且进行更新 
{
        if (ll<=l&&r<=rr) 
        {
                tree[t]=c;
                return;
        }
        if (ll<=mid) modify(ll,rr,c,l,mid,ls);
        if (rr>mid)  modify(ll,rr,c,mid+1,r,rs);
        tree[t]=tree[ls]+tree[rs];
}
/*
void modify(int ll,int c,int r,int t)
{
    if(l==r)
    {
        tree[t]=c;
        return ;
    }
    if(ll<=mid) modify(ll,rr,c,l,mid,ls);
    else modity(ll, rr,c,mid+1,r,rs);
    tree[t]=tree[ls]+tree[rs];
}

*/
int S;
//操作2 
void ask(int ll,int rr,int l,int r,int t)//[ll,rr]是要分解的区间 当前正分解到的节点编号是t,区间范围是[l,r] 
{
        if (ll<=l&&r<=rr)
        {
                S+=tree[t];
                return;
        }
        if (ll<=mid) ask(ll,rr,l,mid,ls);
        if (rr>mid)  ask(ll,rr,mid+1,r,rs);
}

int main()
{
        modify(l,l,c,1,n,1);
        //相当于是从1~n对[l,l]这个区间进行区间分解 
        S=0;
        ask(l,r,1,n,1);
}

完整代码

#include<cstdio>
#include<algorithm>
#include<cstring>
#include<iostream>
#include<cstring>
#include<string>
#include<cmath>
#include<ctime>
#include<set>
#include<vector>
#include<map>
#include<queue>

#define N 300005
#define M 8000005

#define ls (t*2)
#define rs (t*2+1)
#define mid ((l+r)/2)

#define mk make_pair
#define pb push_back
#define fi first
#define se second

using namespace std;

int i,j,m,n,p,k,add[N*4],sum[N*4],a[N],ans,x,c,l,r;

void build(int l,int r,int t)
{
        if (l==r) sum[t]=a[l];
        else
        {
             build(l,mid,ls);
             build(mid+1,r,rs);
             sum[t]=max(sum[ls],sum[rs]); //预先处理区间[l,r]的最大值 
        }
}

void modify(int x,int c,int l,int r,int t) //将a[x]修改为c,然后需要对所有包含x的区间进行更新 
{
        if (l==r) sum[t]=c; //只有一个点的时候可以直接计算 
        else 
        {
                if (l<=x&&x<=mid) modify(x,c,l,mid,ls);
                else modify(x,c,mid+1,r,rs);
                sum[t]=max(sum[ls],sum[rs]);//回溯的时候[l,mid],[mid+1,r]的答案已经算出,可以利用两个儿子进行更新 
        }
}

void ask(int ll,int rr,int l,int r,int t) //询问[ll,rr]这个区间的最大值,l,r,t表示的是当前线段树上位置代表的区间[l,r]和编号t 
{
        if (ll<=l&&r<=rr) ans=max(ans,sum[t]); //找到了一个完整被[ll,rr]区间包含的区间,直接把答案记进去 
        else
        {
                if (ll<=mid) ask(ll,rr,l,mid,ls); //如果和左儿子有交就往左儿子走 
                if (rr>mid)  ask(ll,rr,mid+1,r,rs);  //如果和右儿子有交就往右儿子走 
        }
}

int main()
{
        scanf("%d",&n); 
        for (i=1;i<=n;++i) scanf("%d",&a[i]); 
        build(1,n,1); 
        modify(1,5,1,n,1);
        ask(1,5,1,n,1); 
}

[POJ 3264]Balanced Lineup
给定Q个数A1, ..., AQ,多次询问,每次求某一区间[L, R]中最大值和最小值的差.
Q ≤ 50000

Sol
改存每个区间中的最大最小值,然后更新的时候注意要从左儿子和右儿子处更新过来
(就是min和max)

转载于:https://www.cnblogs.com/lcezych/p/10788398.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值