<Re:从零开始的算法总结>(3)--差分与线段树

差分

基本概念

差分的定义

如果有一数列 a[1],a[2],.…a[n]
且令 b[i]=a[i]-a[i-1](i>1),b[1]=a[1]

那么就有
a[i]=b[1]+b[2]+.…+b[i]
=a[1]+a[2]-a[1]+a[3]-a[2]+.…+a[i]-a[i-1]
此时b数组称作a数组的差分数组
换句话来说a数组就是b数组的前缀和数组 例:
原始数组a:9 3 6 2 6 8
差分数组b:9 -6 3 -4 4 2
可以看到a数组是b的前缀和

前缀和

前缀和是一个数组的某项下标之前(包括此项元素)的所有数组元素的和。

且有

  • 一维前缀和:
    $$
    b[i] = \sum_{j=0}^ia[j] = b[i-1]+a[i]
    $$

  • 二维前缀和:
    $$
    b[x][y] = \sum_{i=0}x\sum_{j=0}ya[i][j]=b[x-1][y]+b[x][y-1]-b[x-1][y-1]+a[x][y]
    $$

使用例

在区间[left,right]上加一个常数c,可以利用原数组就是差分数组的前缀和这个特性,可以让b[left]+=c,b[right]-=c

当使用前缀和求某一区间的和,如果操作次数过大,那么前缀和会超市,可以采用树状数组或线段树

题目:

来先看一道裸题,有n个数。

m个操作,每一次操作,将x~y区间的所有数增加z;

最后有q个询问,每一次询问求出x~y的区间和。

思路:

很明显,直接用前缀和无法快速满足这个操作,所以我们就用到了查分数组。

设a数组表示原始的数组;

设d[i]=a[i]-a[i-1](1<i≤n,d[1]=a[1]);

设f[i]=f[i-1]+d[i](1<i≤n,f[1]=d[1]=a[1]);

设sum[i]=sum[i-1]+f[i](1<i≤n,sum[1]=f[1]=d[1]=a[1])。

即先求差分,再修改区间,随后再重新生成前缀和,最后求和

#include<iostream>
using namespace std;
int main(){
    int n,m,q;
    int x, y, z;
    cin >> n >> m>>q;
    int* src = new int[n];//原始数组
    int *sub = new int[n];//差分数组
    int* fnl = new int[n];//修改后的原始数组
    int *sum = new int[n];//前n位和
    cin >> x;
    src[0] = sub[0] = x;
    //读入原始数组,生成初始差分数组
    for (int i = 1; i < n;i++){
        cin >> src[i];
        sub[i] = sub[i - 1] + src[i];
    }
	//修改差分数组
    for (int i = 0; i < m; i++)
    {
        cin >> z >> x >> y;
        sub[x-1] += z;
        sub[y] += z;
    }
    //重新生成前缀和数组
    fnl[0] = sub[0];
    for (int i = 1; i < n;i++){
        fnl[i] = fnl[i - 1] + sub[i];
    }
    //生成前n位和
    sum[0] = fnl[0];
    for (int i = 1; i < n;i++){
        sum[i] = sum[i - 1] + fnl[i];
    }
    //输出区间x-y的和
    for (int i = 0; i < q;i++){
        cout << sum[y] - sum[x - 1];
    }
    return 0;
}

例题

题目背景

语文考试结束了,成绩还是一如既往地有问题。

题目描述

语文老师总是写错成绩,所以当她修改成绩的时候,总是累得不行。她总是要一遍遍地给某些同学增加分数,又要注意最低分是多少。你能帮帮她吗?

输入格式

第一行有两个整数n,p,代表学生数与增加分数的次数。

第二行有n个数,a1~an,代表各个学生的初始成绩。

接下来p行,每行有三个数,x,y,z,代表给第x个到第y个学生每人增加z分。

输出格式

输出仅一行,代表更改分数后,全班的最低分。

代码

#include<cstdio>
#include<cstdlib>
using namespace std;
int src[5000001];
int con[5000001];
int main() {
	int n, p;
	int x, y, z,min;
	scanf("%d%d", &n, &p);
	for (int i = 0; i < n; i++) {
		scanf("%d", &src[i]);
	}
	for (int i = n - 1; i > 0; i--) {
		src[i] -= src[i - 1];
	}
	for (int i = 0; i < p; i++) {
		scanf("%d%d%d", &x, &y, &z);
		src[x - 1] += z;
		src[y] -= z;
	}
	min = con[0] = src[0];
	for (int i = 1; i < n; i++) {
		con[i] = con[i - 1] + src[i];
		min = con[i] < min ? con[i] : min;
	}
	printf("%d", min);
	return 0;
}

线段树

基本概念

线段树是由一条一条线段组成的一棵树,每个结点都是一条线段,每个非叶子结点的子结点都是对此线段的再划分,假设此线段范围为[left,right],其左孩子的线段为[left,mid],右孩子的线段为[mid,right],可以使用一个数组存储所有结点,若父节点为k,左孩子为2k,右孩子为2k+1,叶子结点是单元结点(即孤立的点)。

每个结点包括的信息有:

  • 区间的左右端点
  • 区间要维护的信息

线段树还是一棵二叉搜索树,主要用于高效解决连续区间的动态查询问题。

基本操作

结构体定义

struct node{
	int left;//线段左端
    int right;//线段右端
    int value;//相关信息
    int flag;//用于进行区间修改时的标记
}

建立二叉树

思路:

  1. 对于每一个节点,给左右端点确定范围
  2. 如果是叶子节点,存储相关信息
  3. 父节点状态合并
void build(int l,int r,int k){
    tree[k].l = l;
    tree[k].r = r;
    if(l==r){//叶子节点,停止建树
        cin>>w;
        return;
    }
    int m = (l+r)/2;
    build(l,m,k*2);//左孩子
    build(m+1,r,k*2+1);//右孩子
    tree[k].w = tree[k*2].w+tree[k*2+1].w;
}

单点查询(二分法查找)

思路:

  1. 如果当前枚举的点左右端点相等,即叶子节点,就是目标节点。
  2. 如果不是,因为这是二分法,所以设查询位置为x,当前结点区间范围为了l,r,中点为mid,则如果x<=mid,则递归它的左孩子,否则递归它的右孩子。
void ask(int k)
{
    if(tree[k].l==tree[k].r) //当前结点的左右端点相等,是叶子节点,是最终答案 
    {
        ans=tree[k].w;
        return ;
    }
    if(tree[k].f) down(k);//在区间修改中讲解
    int m=(tree[k].l+tree[k].r)/2;
    if(x<=m) ask(k*2);//目标位置比中点靠左,就递归左孩子 
    else ask(k*2+1);//反之,递归右孩子 
}

单点修改

思想:

  1. 首先利用单点查询的思想找到目标结点x。
  2. 利用建树时合并的思想将目标结点以及其父节点进行修改。
void add(int k)
{
    if(tree[k].l==tree[k].r)//找到目标位置 
    {
        tree[k].w+=y;
        return;
    }
    if(tree[k].f) down(k);
    int m=(tree[k].l+tree[k].r)/2;
    if(x<=m) add(k*2);
    else add(k*2+1);
    tree[k].w=tree[k*2].w+tree[k*2+1].w;//所有包含结点k的结点状态更新 
}

区间查询

思想:

当前区间和待查询区间一共有三种情况:

  1. 当前结点区间的值全部是带查询区间的一部分,则直接加上当前区间的区间和。
  2. 当前结点区间包含了带查询的区间,则继续递归左右子区间,直至情况1或情况3。
  3. 当前结点区间与带查询区间交叉,继续递归直至情况1。
void sum(int k)
{
    if(tree[k].l>=x&&tree[k].r<=y) 
    {
        ans+=tree[k].w;
        return;
    }
    if(tree[k].f) down(k);
    int m=(tree[k].l+tree[k].r)/2;
    if(x<=m) sum(k*2);
    if(y>m) sum(k*2+1);
}

区间修改

如果数据量很大,要求将某个很大的区间的每个元素进行修改,那么其时间复杂度将会非常大,那么线段树算法的意义就不在了,而且修改某个区间的元素之后不一定会用到该区间的孩子结点的信息,因此为了降低复杂度,这里引入懒标记--线段树的精髓。

  • 懒标记,顾名思义,就是懒,用到的时候动,不用的时候就不动。
  • 作用就是存储这个结点的修改信息,暂时不把修改的信息传到子节点。递归到这个结点时,只更新这个结点的状态,并把当前的更改值累积到标记中。
  • 当需要递归这个结点的子结点时,标记传递给子节点。
  • 下传递时需要进行的操作:
    1. 当前结点的懒标记累积到子节点的懒标记中。
    2. 修改子节点的状态。
    3. 父节点的懒标记清0。
void down(int k)
{
    tree[k*2].f+=tree[k].f;
    tree[k*2+1].f+=tree[k].f;
    tree[k*2].w+=tree[k].f*(tree[k*2].r-tree[k*2].l+1);
    tree[k*2+1].w+=tree[k].f*(tree[k*2+1].r-tree[k*2+1].l+1);
    tree[k].f=0;
}

void add(int k)
{
    if(tree[k].l>=a&&tree[k].r<=b)//当前区间全部对要修改的区间有用 
    {
        tree[k].w+=(tree[k].r-tree[k].l+1)*x;//(r-1)+1区间点的总数
        tree[k].f+=x;
        return;
    }
    if(tree[k].f) down(k);//懒标记下传。只有不满足上面的if条件才执行,所以一定会用到当前节点的子节点 
    int m=(tree[k].l+tree[k].r)/2;
    if(a<=m) add(k*2);
    if(b>m) add(k*2+1);
    tree[k].w=tree[k*2].w+tree[k*2+1].w;//更改区间状态 
}
posted @ 2021-03-12 09:37  Faura_Sol  阅读(223)  评论(0)    收藏  举报
Live2D