二维线段树的讲解【建立在线段树上的提升】

2023-11-01

二维线段树

      二维线段树最主要用于平面统计问题。类似一维线段树,最经典的就是求区间最值(或区间和),推广到二维,求得就是矩形区域最值(或矩形区域和),对于矩形区域和,二维树状数组更加高效,而矩形区域最值,更加高效的方法是二维RMQ,但是二维RMQ不支持动态更新,所以二维线段树还是有用武之地的。

      如果对一维线段树已经驾轻就熟,那么直接来看下面两段对比,就可以轻松理解二维线段树了。

      一维线段树是一棵二叉树,树上每个结点保存一个区间和一个域,非叶子结点一定有两个儿子结点,儿子结点表示的两个区间交集为空,并集为父结点表示的区间;叶子结点的表示区间长度为1,即单位长度;域则表示了需要求的数据,每个父结点的域可以通过两个儿子结点得出。

      二维线段树是一棵四叉树,树上每个结点保存一个矩形和一个域,非叶子结点一定有二或四 个儿子结点,儿子结点表示的四个矩形交集为空,并集为父结点表示的矩形;叶子结点表示的矩形长宽均为1,域则表示了需要求的数据,每个父结点的域可以通过四个儿子结点得出。

      一个4x3的矩形,可以用图1的树形结构来表示,给每个单位方块标上不同的颜色易于理解。

图1

       图1中,每个叶子结点的单位面积为1,非叶子结点表示的矩形进行四分后,如图2所示,四个子矩形分别表示的是儿子结点表示的矩形区域。特殊的,当矩形面积为1 X H或者W X 1的时候,变成了一维的情况,这就是为什么有些结点有四个子结点,而有些结点只有两个子结点的原因了。

图2

       基本结构已经清楚了,按照惯例,要介绍一下时空复杂度。

       首先来看空间复杂度,一个 W x H 的矩形,根结点表示的矩形是W x H,令N = max{W, H},那么这棵二维线段树的深度D = log2(N)+1,当这棵树是一棵满四叉树的时候,结点数达到最大值,根据等比数列求和公式,最大情况的结点数为 (4^D - 1) / 3。更加直观的,当N = W = H = 2^k, 必定是一棵满四叉树,结点数为(4^D-1) / 3 = ( 4^(k+1) - 1 ) / 3 = (2^(2k+2)-1) / 3,去掉分子上的零头1,约等于(4/3)*N^2, 所以空间复杂度为O(N^2)。

       再来看时间复杂度,需要分情况:

       建树:建树时必定访问到每个结点,而且都是访问一次,所以建树的复杂度为O(N^2);

       单点更新:每次更新一个单位矩形的值,访问时只会访问从树的根结点到叶子结点的一条路径,所以单点更新的复杂度为O( log2(N) )。

       区域询问:情况类似一维的区间询问。从根结点开始拆分区域,当询问区域完全覆盖结点区域时,不需要递归往下走,总体复杂度是O( log2(N) * log2(N) )  ? 这里还是打个问号先,具体是一个log还是两个log记不清了,找个时间证明一下,可以肯定的是,不会退化成O(N)。

     

       接下来看看每个树结点需要保存一些什么信息, 以最值为例,除了保存最值以外,有可能需要知道这个最值在整个矩形的具体坐标,所以我们的最值信息dataInfo需要保存三个信息,posx和posy表示最值的具体位置,val保存最值,由于二维线段树的空间复杂度为O(N^2),所以坐标信息不会很大,为了尽力节省内存,坐标值用short来存即可。最值val的话看实际情况而定,一般用int就够了。

       treeNode则是线段树结点的结构体,其中成员由dataInfo对应的最值和son[4]表示的子结点编号组成,我们的线段树结点采用静态结点,即每个线段树结点都对应静态数组 nodes中的某个元素,便于通过编号在O(1)的时间内获取到对应树结点的指针,son[4]记录了四个子结点在nodes中的下标。仔细观察可以发现,如果对于一棵线段树,保证所有结点编号都连续的情况下,如果父结点的编号确定,那么子结点的编号也就确定了。例如,根结点编号为1,那么四个子结点编号为2、3、4、5,父结点编号为2,四个子结点的编号为6、7、8、9,根据数学归纳法,当结点编号为p,那么它的四个子结点编号为(4*p-2+x),其中x取值为[0, 3],所以四个子结点的编号信息可以通过O(1)的时间计算出来,就不用存储在线段树结点上了,大大节省了内存开销。

结构定义代码如下:

 

#define LOGN 10

#define MAXN (1<<LOGN)

#define MAXNODES 3*(1<<(2*LOGN)/4 + 100)

#define son(x) (p*4-2+x)

// 最值信息

struct dataInfo {

    short posx, posy;

    int val;

    dataInfo() {

        posx = posy = val = -1;

    }
    dataInfo(short _posx, short _posy, int _val) {
        posx = _posx;
        posy = _posy;
        val = _val;

    }

};

// 线段树结点信息

struct treeNode {

    // int son[4]

    dataInfo maxv, minv;

    void reset() {

        maxv = dataInfo(0, 0, INT_MIN);

        minv = dataInfo(0, 0, INT_MAX);

    }

}nodes[ MAXNODES ];

 

// 注意,这里需要返回指针,因为在后续使用中需要对这个结点的信息进行改变,如果返回对象的话只是一个copy,不会改变原结点的内容

treeNode* getNode(int id) {

    return &nodes[id];

}

 

       这时候,我们发现线段树的结点上还缺少一个很重要的信息,因为每个结点表示了一个矩形区域,那为什么没有存下这个矩形区域呢?毋庸置疑,也是为了节省内存,在接下来的区域查询、单点更新的介绍中会讲到,这个区域其实在每次递归的时候是作为传参进入函数内部的,结点编号确定,矩形区域就确定了,所以没必要存储在结点中。

       为了处理方便,我们还需要封装一个区间类(由于矩形可以表示成两个不同维度的区间,所以这里只需要封装一个区间类即可,矩形类的操作没有区间内那么简单,一目了然),它支持一些基本操作,如判交、判包含、取左右半区间等等、,具体代码如下:

 

struct Interval {

    int l, r;
    Interval() {}
    Interval(int _l, int _r) {
        l = _l;
        r = _r;

    }

    // 区间中点 
    int mid() {
        return (l + r) >> 1;
    }
    // 区间长度 
    int len() {
        return r - l + 1;
    }
    // 左半区间 
    Interval left() {
        return Interval(l, mid());
    }
    // 右半区间 
    Interval right() {
        return Interval(mid()+1, r);

    }

    // 区间判交

    bool isIntersectWith( Interval& tarI ) {
        return !( l > tarI.r || r < tarI.l );

    } 

    // 区间判包含

    bool isInclude( Interval& tarI ) {
        return l <= tarI.l && tarI.r <= r;

    }

    bool in (int v) {
        return l <= v && v <= r;

    }

};

        那么接下来就是建树了,建树就是递归生成结点的过程,这里的生成并非创建,原因是因为我们的结点是静态的。每建一次树,只是把所有线段树结点的信息进行一次重置,对于一个W x H的矩形,假定它的两个对角线坐标为(1, 1) - (W, H),那么我们首先将它切割成四个矩形,令WM = (1+W)/2, HM = (1+H)/2对角线坐标分别为:

        (1, 1) - (WM, HM)

        (WM+1, 1) - (W, HM)

        (1, HM+1) - (WM, H)

        (WM+1, HM+1) - (W, H)

        如图3所示,四个切割完后的矩形如下:

图3

        这个切割过程是递归进行的,当某次切割的矩形为单位面积的时候,即为递归出口。当然还有一种情况,就是当某次切割后的矩形的某一维为1,而另一维大于1时,这里假设W = 1,H > 1,那么继续切割时会发现WM+1 > W,导致 (WM+1, 1) - (W, HM) 和 (WM+1, HM+1) - (W, H) 这两个矩形面积为负,所以在递归入口处需要判断是否有某一维的右端点小于左端点,如果有,这种矩形是不合法的,不能做为线段树的结点,不需要继续往下递归创建。

        

建树代码如下:

void build_segtree(int p, Interval xI, Interval yI) {

    // 空矩形(右端点小于左端点)

    if(xI.len() <= 0 || yI.len() <= 0) {
        return ;
    }
    treeNode* now = getNode(p);

    // 结点初始化

    now->reset();

    // 单位矩形

    if(xI.len() == 1 && yI.len() == 1) {
        return ;
    }
    build_segtree( son(0), xI.left(), yI.left() );
    build_segtree( son(1), xI.right(), yI.left());
    build_segtree( son(2), xI.left(), yI.right() );
    build_segtree( son(3), xI.right(), yI.right());   

}

        其中p为当前线段树结点的编号,son(0) - son(3)则是作为子结点编号穿参进入切割后的矩形, getNode(p)用于获取编号为p的结点的结构指针,建树的目的就是为每个结点进行初始化,如果求的是最大值,那么将结点上的域都设成 INT_MIN ( 只要比所有接下来要插入的值小即可 ),如果求的是最小值,那么结点上的域都设成 INT_MAX( 只要比所有接下来要插入的值大即可 )。

        建树完毕后,这些结点都有了一个初始值,那么接下来就是需要在矩形的每个点插入一个值,然后更新线段树上每个结点的最值信息了。

        插入过程和建树过程的思想是一致的,同样是将矩形切割成四份,因为插入的是一个点,所以不可能同时存在于任意两个矩形中(因为是个矩形是互不相交的),所以每次四分只会选择一个矩形进行插入,为了让代码简介,我们还是先将矩形进行切割,然后模拟所有的矩形都能够插入,然后在递归入口处判断该点是否在矩形区域中,如果不在矩形区域直接返回。这样,当递归到单位矩形的时候,这个点的坐标一定是和矩形的坐标重合的,就可以直接更新该矩形所在的线段树结点的域信息了,更新完这个单位矩形还不够,还需要将信息传递给它的父结点,因为每次更新只有一个点,所以改变的结点只有从这个单位矩形所在结点到根结点的一条路径上的结点,所以复杂度是树的深度,即O(log2(N))。

插入结点(单点更新)代码如下:

bool insert_segtree(int p, Interval xI, Interval yI, int x, int y, int val) {
    if(xI.len() <= 0 || yI.len() <= 0) {
        return false;
    }
    if( !xI.in(x) || !yI.in(y) ) {
        return true;
    }
    treeNode *now = getNode(p);
    if(xI.len() == 1 && yI.len() == 1) {
        now->maxv = now->minv = dataInfo(x, y, val);
        return true;
    }
    bool isvalid[4];
    isvalid[0] = insert_segtree( son(0), xI.left(), yI.left(), x, y, val );
    isvalid[1] = insert_segtree( son(1), xI.right(), yI.left(), x, y, val );
    isvalid[2] = insert_segtree( son(2), xI.left(), yI.right(), x, y, val );
    isvalid[3] = insert_segtree( son(3), xI.right(), yI.right(), x, y, val ); 
   
    // 通过四个子结点的信息更新父结点 
    now->maxv = dataInfo(0, 0, MIN_VAL);
    now->minv = dataInfo(0, 0, MAX_VAL);
    int i;
    for(i = 0;i < 4; i++) {
        if( !isvalid[i] ) continue;
        treeNode *sonNode = getNode(son(i));
        now->maxv = sonNode->maxv.val > now->maxv.val ? sonNode->maxv : now->maxv;
        now->minv = sonNode->minv.val < now->minv.val ? sonNode->minv : now->minv;
    }
    return true; 

}

       可以发现,插入的核心代码和建树是一致的,但是这里的插入操作,返回了一个值,表示的是当前插入的线段树结点p是否合法,因为我们在插入的时候无论如何都会将矩形切割成四份,没有去考虑上文中提到的有一维为1的情况,父结点的域值是通过子结点回溯上来进行更新的,如果子结点不合法,不应该作为更新的依据,所以作为父结点,需要知道哪些结点是不合法的。

       有了更新,当然需要询问,没有询问,更新也就失去了意义。

       询问一般是区域询问(单点询问就没必要用线段树了),如图4所示,在一个4 x 3的矩形中,需要询问灰色的矩形(3 x 2的矩形,以下统一称为询问矩形)中最大的数是什么。首先来说说原理,同样,和建树以及插入操作一样,我们首先不断将矩形进行切割,每当访问到一个结点的时候将询问矩形和结点矩形进行判交测试,一共有以下几种情况:

       1、询问矩形 和 结点矩形 没有交集 (图4中所有白色的叶子结点);

       2、询问矩形 完全包含 结点矩形 (图4中根结点的第三个子结点);

       3、询问矩形 不完全包含 结点矩形,并且存在交集(图4中根结点的第一、二、四个子结点);

       首先我们需要保存一个全局最大值信息,这个信息可以通过引用的方式传递到函数中去,在递归的过程中不断迭代更新;

       对于第1、2两种情况都是不需要继续往下递归的,第1种情况不会影响目前的最大值,第2种情况需要将结点上的最大值和全局最大值进行比较,保留大的那个;第三种情况有交集,所以我们需要将矩形继续分割,直到出现第1或者第2种情况为止,而且一定是可以出现的。

图4

区域询问代码如下:

// query_type 0 最大值   1最小值

void query_segtree(int p, Interval xI, Interval yI, Interval tarXI, Interval tarYI, dataInfo& ans, int query_type) {

    if(xI.len() <= 0 || yI.len() <= 0) {
        return ;
    }
   
    if( !tarXI.isIntersectWith(xI) || !tarYI.isIntersectWith(yI) ) {
        return ;
    }
    treeNode *now = getNode(p);

   

    // 最大值优化 

    if(query_type == 0 && ans.val >= now->maxv.val) {

        return ;

    }

    // 最小值优化 

    if(query_type == 1 && ans.val <= now->minv.val) {
        return ;
    }
       
    if(tarXI.isInclude(xI) && tarYI.isInclude(yI)) {
        if(query_type == 0) {
            ans = now->maxv;
        }else {
            ans = now->minv;
        }
        return ;
    }
    query_segtree( son(0), xI.left(), yI.left(), tarXI, tarYI, ans, query_type );
    query_segtree( son(1), xI.right(), yI.left(), tarXI, tarYI, ans, query_type );
    query_segtree( son(2), xI.left(), yI.right(), tarXI, tarYI, ans, query_type );

    query_segtree( son(3), xI.right(), yI.right(), tarXI, tarYI, ans, query_type ); 

}    

        这里加入了一个query_type,表示求的是最大值还是最小值,因为有的时候既需要知道最大值,又需要知道最小值,为了简化函数个数引入的一个变量。这里我们发现,当求最大值的时候,如果 询问矩形 和 结点矩形 是有交集并且并非完全包含的情况下,如果结点最大值比全局最大值(以上代码中的ans即全局最大值信息)还小,那么没必要再往下递归了,因为递归下去的最大值不会比当前结点的最大值大,这个优化很重要。

        以上就是二维线段树通过三个函数实现求区域最值的全部内容,建树(build_segtree)、插入(insert_segtree) 、询问(query_segtree),其实当我们将这三个函数中的 yI 这个区间变成[1, 1]的时候,就变成了一维线段树的模板了。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

二维线段树的讲解【建立在线段树上的提升】 的相关文章

  • 关系型数据库RDBMS -MySQL基础入门(五)数据备份与恢复

    一 物理备份 innodbackupex 二 逻辑备份 mysqldump Binlog 二进制 日志文件 备份恢复 主从同步 除查询sql所有命令都记录 索引文件 bin index bin 000001 Purge master log
  • 蓝牙之九-AT命令

    AT命令用于HF协议 该命令使参考3GPP 27 007协议 以下是HFP规范 每个命令行只有一个命令 AG侧默认不回显命令 AG使用冗长的格式返回结果 以下字符将被用于AT命令和返回结果格式中
  • Spring MVC详解

    第一节 Spring MVC 简介 1 Spring MVC SpringMVC是一个Java 开源框架 是Spring Framework生态中的一个独立模块 它基于 Spring 实现了Web MVC 数据 业务与展现 设计模式的请求驱

随机推荐

  • Session的生命周期

    以前在学习的时候没怎么注意 今天又回过头来仔细研究研究了一下Session的生命周期 Session存储在服务器端 一般为了防止在服务器的内存中 为了高速存取 Sessinon在用户访问第一次访问服务器时创建 需要注意只有访问JSP Ser
  • 什么是DDL和DML语句?

    1 什么是DDL和DML语句 SQL语言分成三大类 1 DDL语言 2 DML语言 3 DCL语言 可恶 竟然在技术内幕只提及了一部分 内幕661 要知道DDL是什么意思 必须先了解它的英文全称 data definition langua
  • 【华为OD机试】TLV解析Ⅰ【2023 B卷

    华为OD机试 真题 点这里 华为OD机试 真题考点分类 点这里 题目描述 TLV 编码是按 Tag Length Value 格式进行编码的 一段码流中的信元用Tag标识 Tag在码流中 唯一不重复 Length表示信元Value的长度 V
  • Linux 定时任务crontab配置

    https www cnblogs com zoulongbin p 6187238 html
  • [718]canal的配置详解

    文章目录 canal的配置加载方式 properties配置文件 canal properties介绍 instance properties介绍 canal的配置加载方式 介绍配置之前 先了解下canal的配置加载方式 canal配置方式
  • 【斯坦福CS224W笔记之一】 图的基本表示

    是来自b站up主同济子豪兄的中文精讲 自己做来总结给自己学习滴 如果感兴趣的话可以去b站搜索或者去子豪兄的github图神经进行学习 https github com TommyZihao zihao course blob main CS
  • Java线程安全与等待通知

    目录 1 线程不安全原因 1 1 引入 线程不安全的例子 抢占式执行 1 2 线程不安全的原因 5点 其他 2 抢占式执行引起的线程不安全 synchronized 3 内存可见性引起的线程不安全 volatile 3 1 例子 编译器优化
  • Win10操作系统隐藏6个实用小功能

    目录 功能一 分屏 功能二 录屏 功能三 截图 功能四 便签功能 功能五 视频剪辑 功能六 计算器 功能一 分屏 Win10操作系统其实是自带分屏功能的 这个功能对我来说真的太喜欢了 尤其是核对文档的时候 真的是太方便了 操作方法 在文档页
  • 内网离线安装Docker

    文章目录 描述 下载docker离线包地址 1 安装docker 1 下载 Docker 二进制文件 离线安装包 2 上传离线包 3 解压安装包 4 将docker 相关命令拷贝到 usr bin 5 docker注册成系统服务 6 添加执
  • QT--信号槽与带参Lambda表达式

    一 常规的不带参数的Lambda表达式响应槽函数 connect ui btn QushButton clicked to do something 二 带参的Lambda表达式响应槽函数 connect ui spbox static c
  • ArcSDE超出最大连接数问题解决

    ARCSDE数据库默认连接的实例数为48 可用 sdemon o info I users 命令查看当前的用户链接数 因为连接的用户很多时 会导致实例数不够用 这个时候需要对连接的实例数进行修改 修改方法如下 1 plsql登录sde用户
  • 你还没有Aspose 2021中文开发指南吗?中文指南打包带走

    说到文档处理 就得来说说Aspose系列 Aspose是全球领先的图表控件开发商 旗下产品覆盖Word Excel PDF 条码 ZIP CAD HTML 电子邮件 PSD等各个文档管理领域 为全球 NET Java C 等各大平台程序员提
  • ARM平台移植libcurl curl-7.49.0

    libcurl是免费的轻量级的客户端网络库 支持DICT FILE FTP FTPS Gopher HTTP HTTPS IMAP IMAPS LDAP LDAPS POP3 POP3S RTMP RTSP SCP SFTP SMTP SM
  • SpringMVC重要接口(三)HttpMessageConverter

    大部分人的第一反应是通过SpringMVC拦截器 Interceptor 中的postHandler方法处理 实际这是行不通的 因为当程序运行到该方法 是在返回数据之后 渲染页面之前 所以这时候HttpServletResponse中的输出
  • C++-类和对象(上)

    类和对象 上 一 构造函数 1 概念 2 特性 二 析构函数 1 概念 2 特性 三 拷贝构造 1 概念 2 特性 四 运算符重载 1 概念 2 赋值运算符重载 五 重载与const 重载 六 总结 一 构造函数 1 概念 class St
  • Java后端、前端、大数据、python、Golang学习路线

    面向招聘要求学编程 加油 小y同学 blibli很多资源 黑马 尚硅谷 狂神说等等 1 后端 Java基础 完成 Java Web 完成 Maven 完成 Git 完成 SSM Spring框架 SpringMVC 持久层框架Mybatis
  • 充电IC和电量计的驱动调试

    LK是little kernel的缩写 高通平台android普遍采用LK作为其bootloader LK是一个开源项目 但是 LK只是整个系统的引导部分 所以它不是独立存在 LK是一个功能极其强大的bootloader 但只支持arm和x
  • 手把手教SYSU_2023SpringRL之实践作业二 (期末):Multi-Agent Reinforcement Learning (MARL)

    手把手教SYSU 2023SpringRL之实践作业二 期末 Multi Agent Reinforcement Learning MARL 0 前言 最终成绩90 1 环境概述 观测空间 动作空间 奖励函数 2 代码实战 训练环境配置 训
  • python Statsmodel 回归模型笔记

    Statsmodels是Python中一种常用的统计分析库 支持多种回归模型的建立和分析 以下是Statsmodels中常见的几种回归模型及其用途 线性回归模型 Linear Regression Model 用于建立自变量和因变量之间线性
  • 二维线段树的讲解【建立在线段树上的提升】

    二维线段树 二维线段树最主要用于平面统计问题 类似一维线段树 最经典的就是求区间最值 或区间和 推广到二维 求得就是矩形区域最值 或矩形区域和 对于矩形区域和 二维树状数组更加高效 而矩形区域最值 更加高效的方法是二维RMQ 但是二维RMQ