线段树_单点更新

        最近开始接触线段树的内容,我发现线段树非常灵活和强大,很多问题都可以用线段树来做。但是,对于初次接触的人来说,可能比较难。根据大牛的博客,我开始一步步练习。现在把已经做好的单点更新的习题贴出来,讲讲自己的想法。欢迎拍砖~


补充几点说明:

(1)我采用静态方法构建线段树,即用数组来做,左子树为 2*i,右子树为 2*i+1。按照保守估计,数组空间一般开N*4,用到的空间实质上是N*2-1,但是考虑到中间会有一些空间没有使用,特别是最底层的。粗略地想,假设最底层只有最后两个空间有用到,那么这一层前面的空余空间就约为N*2了。所以N*4是足够的。

(2)这部分一般来说涉及到下面几个函数:pushUp(由下往上更新修改后的数据),build(初始化线段树),update(修改叶子结点的数据),query(查询区间数据)

(3)线段树本身并不保存当前区间具体是什么,而是由函数传递两个变量l, r 来记录当前的具体区间,这样做可节省大量空间开销,详细参见代码。

(4)前文已出现过的,后面不再重复,包括函数参数的说明。


题目:

hdu 1166 敌兵布阵

大意:敌人有N个营地,一开始知道每个营地的人数。

            接下来会询问四种命令:(1) Add i j,i 和 j 为正整数,表示第i个营地增加j个人(j不超过30);
                                                        (2) Sub i j ,i 和 j 为正整数,表示第i个营地减少j个人(j不超过30);
                                                        (3) Query i j , i 和 j 为正整数,i <= j,表示询问第i到第j个营地的总人数;
                                                        (4) End 表示结束,这条命令在每组数据最后出现。

            遇到Query则作出回答,遇到End则结束本次数据的询问。其他则修改相关营地的数据。(显然,Sub 可以归并到 Add 操作)


思路

对于每一组数据,用build初始化线段树,每个结点保存区间[i, j]内的总人数。叶子结点即是每个营地的人数,这个由输入给出,对于内结点,则采取pushUp由下往上传递值。

对于Add 操作(包括Sub操作),可以用update来修改叶子结点的值,与build相似,内结点采取pushUp更新其值。

对于Query 操作,用query来查看区间的人数。对于没有在一个存储区间表示出来的空间,则采取同时向两边走,覆盖后返回其值的方式取值,详细见代码。


函数接口

pushUp(int cur)      // cur 表示当前数组的实际下标。

build(int l, int r, int cur)     // [l, r] 表示当前数组存储的值的区间,即前面所说的由函数传入值表示。

update(int i, int val, int l, int r, int cur)     // i 表示需要更新值的数组实际下标,即线段树的相应的叶子结点,val 表示需要增加的值(对于Sub操作,主函数传入-val)。

query(int curL, int curR, int l, int r, int cur)    // [curL, curR] 表示需要查询的区间


代码如下:

#include<cstdio>
using namespace std;
const int MAX = 50000 + 10;         //数据范围
int sum[MAX << 2];          //保存各个范围的sum值
void pushUp(int cur){
    //检查下层数据,更新当前的sum值
    sum[cur] = sum[cur << 1] + sum[cur << 1 | 1];
}
void build(int l, int r, int cur){
    //初始化线段树
    if(l == r){
        scanf("%d",&sum[cur]);
        return;
    }
    int mid = (l + r) >> 1;
    build(l, mid, cur << 1);
    build(mid + 1, r, cur << 1 | 1);
    //更新当前的sum
    pushUp(cur);
}
void update(int i, int val, int l, int r, int cur){
    //更新范围i的值,并向上传递sum
    if(l == r){
        sum[cur] += val;
        return;
    }
    int mid = (l + r) >> 1;
    if(i <= mid){
        update(i, val, l, mid, cur << 1);
    }
    else{
        update(i, val, mid+1, r, cur << 1 | 1);
    }
    //传递sum值
    pushUp(cur);
}
int query(int curL, int curR, int l, int r, int cur){
    //查询curL 至 curR 的 sum
    if(curL <= l && curR >= r){
        return sum[cur];
    }
    int mid = (l + r) >> 1;
    int result = 0;
    if(curL <= mid){
        result += query(curL, curR, l, mid, cur << 1);
    }
    if(curR > mid){
        result += query(curL, curR, mid+1, r, cur << 1 | 1);
    }
    return result;
}
int main(){
    int k = 1;  //当前例子编号
    int t;      //样例数
    int n;      //营地数
    scanf("%d",&t);
    while(t--){
        scanf("%d",&n);
        build(1,n,1);
        printf("Case %d:\n",k++);
        char s[10];
        int i,j;
        while(scanf("%s",s) != EOF){
            if(s[0] == 'E')break;
            scanf("%d %d",&i, &j);
            switch(s[0]){
                case 'A': update(i, j, 1, n, 1); break;
                case 'S': update(i, -j, 1, n, 1); break;
                case 'Q': printf("%d\n",query(i, j, 1, n, 1)); break;
            }
        }
    }
    return 0;
}

hdu 1754 I Hate It

大意:给出N个学生的成绩,下面有M个操作。

            接下来有M行。每一行有一个字符 C (只取'Q'或'U') ,和两个正整数A,B。

            当C为'Q'的时候,表示这是一条询问操作,它询问ID从A到B(包括A,B)的学生当中,成绩最高的是多少。

            当C为'U'的时候,表示这是一条更新操作,要求把ID为A的学生的成绩更改为B。


思路:和上题类似,不同的是这次线段树保存的不是总和,而是最大值。

            build 与 update 和上题的一样,只是 pushUp 和 query 不再是累加两个子结点,而是比较两个子结点(如果有的话),然好返回其较大值。

函数接口

基本上和上题相同,只是update不再是累加val,而是直接修改数据为val。


代码如下:

#include<cstdio>
#include<cstring>
using namespace std;
const int MAX = 200000 + 10;
int highest[MAX << 2];          //构建最高成绩线段树
void pushUp(int cur){
    //更新 highest 的值
    if(highest[cur << 1] > highest[cur << 1 | 1]){
        highest[cur] = highest[cur << 1];
    }
    else{
        highest[cur] = highest[cur << 1 | 1];
    }
}
void build(int l, int r, int cur){
    //初始化线段树
    if(l == r){
        scanf("%d",&highest[cur]);
        return;
    }
    int mid = (l + r) >> 1;
    build(l, mid, cur << 1);
    build(mid+1, r, cur << 1 | 1);
    //由下往上传递 highest 值
    pushUp(cur);
}
void update(int i, int val, int l, int r, int cur){
    //更新 i 的值 为 val
    if(l == r){
        highest[cur] = val;
        return;
    }
    int mid = (l + r) >> 1;
    if(i <= mid){
        update(i, val, l, mid, cur << 1);
    }
    else{
        update(i, val, mid+1, r, cur << 1 | 1);
    }
    pushUp(cur);
}
int query(int a, int b, int l, int r, int cur){
    //返回[a, b]的最高成绩
    if(a <= l && b >= r){
        return highest[cur];
    }
    int mid = (l + r) >> 1;
    int result = 0,temp;
    if(a <= mid){
        temp = query(a, b, l, mid, cur << 1);
        if(result < temp){
            result = temp;
        }
    }
    if(b > mid){
        temp = query(a, b, mid+1, r, cur << 1 | 1);
        if(result < temp){
            result = temp;
        }
    }
    return result;
}
int main(){
    int n;      //人数
    int m;      //操作次数
    while(scanf("%d %d",&n,&m) != EOF){
        build(1,n,1);
        char s;
        int a,b;
        for(int i = 0; i < m; i++){
            getchar();
            scanf("%c %d %d",&s,&a,&b);
            switch(s){
                case 'U': update(a, b, 1, n, 1); break;
                case 'Q': printf("%d\n",query(a, b, 1, n, 1)); break;
            }
        }
    }
    return 0;
}

hdu 1394 Minimun Inversion Number

大意:给定一个N和关于0-N-1的序列,通过不断把首元素放到最后得出变换后的序列和其逆序数,求出最小逆序数(包括一开始那个序列)。


思路

(1)用线段树求初始序列的逆序数。用build构建线段树保存区间[l, r]中出现过的元素个数(pushUp 求子树之和),对于每一个query查询[0, val] 已经出现过的元素个数(累加这个值即逆序数),然后用update 表示 val 已出现过一次。

(2)得到最初的逆序数后,对于后面出现的每一个序列,其逆序数为前一个序列的逆序数count 减去 首元素的值val,再加上 N - val - 1,即 count = count - val + N - val - 1(注意到,序列中的每个元素均小于N,那么首元素val值则表示其后有val 个元素小于它,所以将val 移到最后即逆序数要减去这个值;而移动后,val 前面必有 N - val - 1 个元素大于它,则逆序数需要加上这个值。)


函数接口

和之前的类似,只是update没有了val,因为这次只需要将 i 结点表示出现过1次。


代码如下:

#include<cstdio>
using namespace std;
const int MAX = 5000 + 10;
int sum[MAX << 2];              //保存每个范围的数字出现次数
void build(int l, int r, int cur){
    //创建空线段树
    sum[cur] = 0;
    if(l == r){
        return;
    }
    int mid = (l + r) >> 1;
    build(l, mid, cur << 1);
    build(mid+1, r, cur << 1 | 1);
}
void pushUp(int cur){
    //更新范围出现次数
    sum[cur] = sum[cur << 1] + sum[cur << 1 | 1];
}
void update(int i, int l, int r, int cur){
    //更新结点i出现一次
    if(l == r){
        sum[cur] ++;
        return;
    }
    int mid = (l + r) >> 1;
    if(i <= mid){
        update(i, l, mid, cur << 1);
    }
    else{
        update(i, mid+1, r, cur << 1 | 1);
    }
    pushUp(cur);
}
int query(int curL, int curR, int l, int r, int cur){
    //查询[curL, curR]出现的元素个数
    if(curL <= l && curR >= r){
        return sum[cur];
    }
    int mid = (l + r) >> 1;
    int result = 0;
    if(curL <= mid){
        result += query(curL, curR, l, mid, cur << 1);
    }
    if(curR > mid){
        result += query(curL, curR, mid+1, r, cur << 1 | 1);
    }
    return result;
}
int main(){
    int n;          //元素个数
    int A[MAX];     //保存元素
    int count;      //保存当前结果
    int result;     //保存结果
    while(~scanf("%d",&n)){
        count = 0;
        build(0, n-1, 1);
        for(int i = 0; i < n; i++){
            scanf("%d",&A[i]);
            count += query(A[i], n-1, 0, n-1, 1);
            update(A[i], 0, n-1, 1);
        }
        result = count;
        for(int i = 0; i < n; i++){
            count += n - A[i] - A[i] - 1;
            if(result > count){
                result = count;
            }
        }
        printf("%d\n",result);
    }
    return 0;
}

hdu 2795 Billboard

大意:已知一块黑板高为h(有h行),宽为w。下面有n张通知需要张贴。张贴规则为尽量高,尽量靠左边,即当前最高行,和那行的最左边,可以理解为从左上角开始张贴。给出每张通知的宽度(默认一行至少可以容纳一张通知),求它张贴的行号(由上至下1到h),如果贴不下,则返回-1.


思路

(1)用黑板的高度作为区间下标构建线段树,保存当前区间所能容纳的最大宽度。build的时候每个区间保存的都是w. 为了节省时间,这里的update操作归并到query里,因为每次查询都需要查询到特定的叶结点并返回其区间下标,这时就可以修改其值并在过程中传递上去。pushUp更新当前区间能容纳的最大宽度。

(2)每次传入当前通知宽度前,都需要查看根结点的数值是否大于当前宽度,因为若根结点都存不下,那这个通知就不能张贴了。


函数接口

和之前的类似,只是query 传递 curW,查看当前区间是否能存放下curW宽度的通知,若能则找到其叶子结点返回其值。


代码如下:

#include<cstdio>
using namespace std;
const int MAX = 2000000 + 10;
int maxWidth[MAX << 2];         //保存范围内的最大宽度,用高度构建线段树
int h, w, n;            //输入
void pushUp(int cur){
    //更新宽度
    if(maxWidth[cur << 1] > maxWidth[cur << 1 | 1]){
        maxWidth[cur] = maxWidth[cur << 1];
    }
    else{
        maxWidth[cur] = maxWidth[cur << 1 | 1];
    }
}
void build(int l, int r, int cur){
    //初始化线段树,保存范围内可容纳最大宽度
    maxWidth[cur] = w;
    if(l == r)return;
    int mid = (l + r) >> 1;
    build(l, mid, cur << 1);
    build(mid+1, r, cur << 1 | 1);
}
int query(int curW,int l, int r, int cur){
    //查询[l, r]是否能容纳 curW 宽度, 若能返回行号
    if(l == r){
        maxWidth[cur] -= curW;
        return l;
    }
    int mid = (l + r) >> 1;
    int result = (maxWidth[cur << 1] >= curW) ? query(curW, l, mid, cur << 1) : query(curW, mid+1, r, cur << 1 | 1);
    pushUp(cur);
    return result;
}
int main(){
    while(~scanf("%d %d %d", &h, &w, &n)){
        if(h > n){
            //因为张贴的宽度不可能大于w, 即不可能超过n行
            h = n;
        }
        build(1, h, 1);
        int temp;
        for(int i = 0; i < n; i++){
            scanf("%d",&temp);
            if(maxWidth[1] < temp){
                printf("-1\n");
            }
            else{
                printf("%d\n",query(temp, 1, h, 1));
            }
        }
    }
    return 0;
}

pku 2828 Buy Tickets

大意:插队问题。已知有N个人,一开始队列为空,编号从1到N,可以看作所有人都排在0号的后面(0号不存在)。分别读入N个人的pos 和 val,pos 表示插队在 pos号人的后面(如pos = 0, 即插入到第1位),val 可以看作是读入的人的一个标识。求经过N次插队以后(插队一定成功),最终的队列(输出这个队列每个人的标识值)。


思路

(1)如果按输入顺序模拟插队,每次都要做很多的移动,效率低下。如果从最后一个人往前看,他的pos 表示的就是前面有 pos 个空位。根据这个他们每次插队后的位置就是最终的位置,因为没有后面的人会让他们后移(因为是从后往前看嘛~)。可以用build 构建线段树保存区间的空位数,区间下标为队列的编号。pushUp 由下往上传递求和信息。query 查询前i 个空位,并返回第i + 1 个空位的区间下标(这个可以从函数代码里那个if 语句看出,左区间是包括mid 的,但比较的时候没有包括进去,其实每次返回的就是上一次的 mid)。和上题相似,query 找到下标后直接修改区间的值,避免重复计算,然好用pushUp 传递其值。

(2)对于每次query 返回的值,我采用多开一个数组来保存每次的结果,最后再顺序输出即可。


函数接口

基本和前面出现的类似,需要注意的是query 传递的 i 的含义。每次返回的是第i + 1 个空位的下标。


代码如下:

#include<cstdio>
using namespace std;
const int MAX = 200000 + 10;
struct person{
    int pos;
    int val;
};
int sum[MAX << 2];      //保存线段树,空位数
void pushUp(int cur){
    //更新空位数
    sum[cur] = sum[cur << 1] + sum[cur << 1 | 1];
}
void build(int l, int r, int cur){
    //构建线段树
    if(l == r){
        sum[cur] = 1;
        return;
    }
    int mid = (l + r) >> 1;
    build(l, mid, cur << 1);
    build(mid+1, r, cur << 1 | 1);
    pushUp(cur);
}
int query(int i, int l, int r, int cur){
    //找到前i个空位,并返回第i+1个空位的下标
    if(l == r){
        sum[cur] = 0;
        return l;
    }
    int mid = (l + r) >> 1;
    int result;
    if(i < sum[cur << 1]){
        result = query(i, l, mid, cur << 1);
    }
    else{
        result = query(i-sum[cur << 1], mid+1, r, cur << 1 | 1);
    }
    pushUp(cur);
    return result;
}
int main(){
    int n;              //人数
    person A[MAX];      //保存每个人的值
    int R[MAX];         //结果序列
    while(~scanf("%d",&n)){
        for(int i = 0; i < n; i++){
            scanf("%d%d",&A[i].pos,&A[i].val);
        }
        build(1, n, 1);
        for(int i = n-1; i >= 0; i--){
            int index = query(A[i].pos, 1, n, 1);
            R[index] = A[i].val;
        }
        printf("%d",R[1]);
        for(int i = 2; i <= n; i++){
            printf(" %d",R[i]);
        }
        printf("\n");
    }
    return 0;
}

pku 2886 Who Gets the Most Candies?

大意:N个小朋友围成一圈玩游戏,每个人手上拿着一张有数字的卡片。输入序列给出每个小朋友的编号,一开始由第k个小朋友开始玩。被点到的小朋友跳出圈,手上卡片的数字val 若是正数,则顺时针数val 后点到小朋友;若为负值,则逆时针数-val 后点到小朋友。一直这样下去直到所有小朋友跳出圈。另外,每个跳出圈的小朋友都可以得到一定的糖果,糖果数就是 m(他是第 m 个跳出圈的) 的划分,也就是m 的因子数。问可以得到最多糖果的小朋友的名字和他得到的糖果数(输入给出小朋友的名字和他手上的卡片数字)。如果有多个获得最多糖果的孩子,则输出最早跳出圈那个。


思路

(1)如果每次游戏都玩到最后,那么需要保存所有人的糖果数,最后再比较输出。网上有人提到可以利用反素数的概念(百度百科:对于任何正整数x,其约数的个数记做g(x).例如g(1)=1,g(6)=4.如果某个正整数x满足:对于任意i(0<i<x),都有g(i)<g(x),则称x为反素数.),那么对于每个给定的N,我们进行到1-N 的最小反素数时就可以退出,这个孩子得到的就是最多糖果。所以我首先通过下面代码打表获得500000以内的所有最小反素数和获得的最大糖果,只有35个~

#include<cstdio>
#include<fstream>
using namespace std;
int A[500010];
int R[500010];
int main(){
    for(int i = 1; i <= 500000; i++){
        for(int j = 1; i * j <= 500000; j++){
            A[i * j]++;
        }
    }
    int n = 0;
    for(int i = 1; i <= 500000; i++){
        int max = 1;
        for(int j = 1; j <= i; j++){
            if(A[max] < A[j]){
                max = j;
            }
        }
        int k;
        for(k = 0; k < n; k++){
            if(R[k] == max){
                break;
            }
        }
        if(k == n){
            R[n++] = max;
        }
    }
    ofstream write("1.txt");
    for(int i = 0; i < n; i++){
        write<<R[i]<<' '<<A[R[i]]<<endl;
    }
    return 0;
}

(2)下面就需要用到线段树来快速求解类约瑟夫问题。因为普通方法往往需要遍历整个序列,当数据量很大时,必然超时。用build 构建线段树保存当前区间的未跳出人数,pushUp 求和向上传递,query 返回区间下标并删除该元素即数量减1。这个其实并不困难,只要想到了,和上面的也是差不多的。

(3)关键在于相对下标的求解。相对下标是指删除元素后,某元素在删除后序列的下标。query 每次返回的是绝对下标即原序列的下标(这是因为每次都需要取到该孩子手上的数字),我们的目的在于已知当前的相对下标(一开始给出的k就是相对下标),如何根据卡片的数字求出下一个相对下标。这个我弄了很久还是有错,最后参考了网上的思路,得到下面的递推公式。

if( k > 0){

   next相对下标 = (cur相对下标 + 偏移量 - 1)% 当前人数

   if next相对下标 <= 0{

      next相对下标 += 当前人数

   }

}

else{

   next相对下标 = (cur相对下标 + 偏移量)% 当前人数

   if next相对下标 <= 0{

      next相对下标 += 当前人数

   }

}

需要注意到,当k 大于0 时,因为当前小孩已经跳出圈,那么现在的下标其实是当前小孩的下一个小孩的下标,所以偏移量要 -1. 当k 小于0时,则不需要作此变化。取模是为了将编号控制在人数内,上面的当前人数是不包括这个卡片的持有人,因为他已经跳出圈了。另外,因为结果有可能为非正数(数组下标是1-N),这时就需要加上模数。(举个例子,若1的前1人即0,这时加上模数就是N了,也就满足围成圈。而C++的负数取模和正数取模类似,只是加多个负号。如 -7 % 5 = -2)


代码如下:

#include<cstdio>
using namespace std;
//(最小)反素数表
const int maxTurn[35]={1,2,4,6,12,24,36,48,60,120,180,240,360,720,840,1260,1680,2520,5040,7560,10080,15120,20160,25200,27720,45360,50400,55440,83160,110880,166320,221760,277200,332640,498960};
//最大糖果表
const int maxCandy[35]={1,2,3,4,6,8,9,10,12,16,18,20,24,30,32,36,40,48,60,64,72,80,84,90,96,100,108,120,128,144,160,168,180,192,200};
const int MAX = 500000 + 10;
struct child{
    char name[11];
    int k;
};
child A[MAX];             //保存输入数据
int leftSum[MAX << 2];  //保存区间内剩余人数
void pushUp(int cur){
    //更新区间剩余人数
    leftSum[cur] = leftSum[cur << 1] + leftSum[cur << 1 | 1];
}
void build(int l, int r, int cur){
    //初始化线段树
    if(l == r){
        leftSum[cur] = 1;
        return;
    }
    int mid = (l + r) >> 1;
    build(l, mid, cur << 1);
    build(mid+1, r, cur << 1 | 1);
    pushUp(cur);
}
int query(int i, int l, int r, int cur){
    //删除第i个元素并返回原下标
    if(l == r){
        leftSum[cur] = 0;
        return l;
    }
    int mid = (l + r) >> 1;
    int result;
    if(i <= leftSum[cur << 1]){
        result = query(i, l, mid, cur << 1);
    }
    else{
        result = query(i - leftSum[cur << 1], mid+1, r, cur << 1 | 1);
    }
    pushUp(cur);
    return result;
}
int main(){
    int n,m;
    while(~scanf("%d%d",&n,&m)){
        for(int i = 1; i <= n; i ++){
            scanf("%s%d",A[i].name,&A[i].k);
        }
        build(1, n, 1);
        int luckyNumber = 0;
        while(luckyNumber < 35 && maxTurn[luckyNumber] <= n){
            luckyNumber++;
        }
        luckyNumber--;
        int pos = 0;
        A[pos].k = 0;
        int mod = n;
        for(int i = 0; i < maxTurn[luckyNumber]; i++,mod--){
            if(A[pos].k > 0){
                m = (m + A[pos].k - 1) % mod;
            }
            else{
                m = (m + A[pos].k) % mod;
            }
            if(m <= 0){
                m += mod;
            }
            pos = query(m, 1, n, 1);
        }
        printf("%s %d\n",A[pos].name, maxCandy[luckyNumber]);
    }
    return 0;
}

总结

对于一些不是很明显可以用线段树来做的题目,我们要提取它们的区间特征,也就是用什么来做区间下标,需要每个区间保存什么内容,每次修改什么值,怎样更新内容。我认为用什么来做区间下标和每个区间保存什么内容尤为重要,只要说清楚这个两个问题,基本思路就出来了。

采用线段树而不是其他方法,主要是因为线段树的高效,并不是说一般方法做不到。所以很难说什么样的问题归到线段树的类型。线段树的另一个特点就是空间开销非常大,因为需要保存每一块区间的数据。典型的空间换时间。

单点更新是线段树的最基本内容,因为还不需要用到延迟标识,每次更新都是更新到底。


  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值