树状数组的相关知识 及 求逆序对的运用

文章目录

  • 树状数组概念
  • 前缀和和区间和
  • 树状数组原理
  • 区间和——单点更新
  • 前缀和——区间查询
  • 完整代码
  • 离散化
    • sort函数
    • unique函数去重
    • erase函数仅保留不重复元素
  • 通过树状数组求逆序对


树状数组概念

树状数组又名二叉索引树,其查询与插入的复杂度都为 O(logN),其具有以下特征:

  1. 树状数组是一种实现了高效查询「前缀和」与「单点更新」操作的数据结构。
  2. 是求逆序对的经典做法。
  3. 不能解决数组有增加和修改的问题。

前缀和和区间和

既然树状数组是为了解决前缀和问题,那么我们首先要知道什么是前缀和?

要提前缀和就不得不提区间和,举个例子来说明两者:

ivec = {1, 2, 3, 4}
presum = {1, 3, 6, 10} // 前缀和
sumrange[1,3] = 9 // 下标1~3的区间和,2+3+4=9

由上可得,sumrange[beg, end] = presum[end] - presum[beg - 1] ,以例子来分析其合理性:
因为 sumrange[1,3] = 2+3+4presum[3] = 1+2+3+4 ,也就是说 sumrange[1,3] = presum[3] - ivec[0]ivec[0] = presum[0] = presum[1-1] ,因此, sumrange[1,3] = presum[3] + presum[0]

sumrange[beg, end] = presum[end] - presum[beg - 1] 有个隐患——访问 beg-1 的位置容易导致下标越界,如:sumrange[0,4] 。因此我们可以改变前缀和数组下标 i 保存的内容,当有访问越界风险时,前缀和数组下标 i 保存的是 [0, i] 的累加和;那么如果令 前缀和数组下标 i 保存 [0, i) 的累加和 ,令 presum[0] = 0 ,则可得到 sumrange[beg, end] = presum[end+1] - presum[beg] 。避免了下标越界的风险。
举例为证:

ivec = {1, 2, 3, 4}
presum = {0, 1, 3, 6, 10}
sumrange[1,3] = presum[3+1] - presum[1] = 10 - 1 = 9
sumrange[0,3] = presum[3+1] - presum[0] = 10 - 0 = 10

明晰了如何通过前缀和数组来算区间和,那么实际上树状数组实现的就是如何用区间和前缀和


树状数组原理

树状数组本质上是 空间换时间 的操作,保存 区间和 以求更快的算出 前缀和。以下图为例,红色数组为树状数组(称为C),蓝色数组为普通数组(称为A)。由于上面证明了从 1 开始存储可以避免访问越界的情况。另,也因为在计算前缀和时,终止条件通常为遇0。 因此 AC 都是从 1 开始存储元素。
在这里插入图片描述


区间和——单点更新

树状数组是如何保存 区间和 的呢?通过观察上图,我们可以得到如下规律:

C1 = A1 = sumrange[1]
C2 = C1 + A2 = A1 + A2 = sumrange[1, 2]
C3 = A3 = sumrange[3]
C4 = C2 + C3 + A4 = A1 + A2 + A3 + A4 = sumrange[1, 4]
C5 = A5 = sumrange[5]
C6 = C5 + A6 = A5 + A6 = sumrange[5, 6]
C7 = A7 = sumrange[7]
C8 = C4 + C6 + C7 + A8 = A1 + A2 + A3 + A4 + A5 + A6 + A7 + A8 = sumrange[1, 8]

以上规律可以总结归纳为这样的特征:下标 i 存储了从 i 往前 2k (k为二进制表示的 i末尾0 的个数)个元素的区间和(出现次数),举例验证:

i = 8 = 1000, k = 3, 2^3 = 8, C8 是 A1~A8 的区间和(出现次数)
i = 6 = 110, k = 1, 2^1 = 2, C6 是 A5~A6 的区间和(出现次数)
i = 5 = 101, k = 0, 2^0 = 1, C5 是 A5 的区间和(出现次数)

怎样实现这样的存储方式呢?对于一个输入的数组A,我们每一次读取的过程,其实就是一个不断更新单点值的过程,一边读入 A[i] ,一边将 C[i] 涉及到的祖先节点值更新,完成输入后树状数组也就建立成功了。举个例子:

假设更新 A[2] = 8 ,那么管辖 A[2]C[2],C[4],C[8] 都要加上 8A2 的所有祖先节点),那么怎么找到所有的祖先节点呢?通过观察他们的二进制形式我们发现:

  • C2 = C10 ; C4 = C100 ; C8 = C1000

不明显,再观察一个一个例子,A[5] 的祖先节点有 C[5],C[6],C[8] ,观察其二进制形式:

  • C5 = C101 ; C6 = C110 ; C8 = C1000

也就是说,我们不断地对 二进制i末尾1 进行 +1 操作(寻找末尾1由Lowbit函数实现),直至到达 树状数组下标最大值 n

实现单点更新update(i, v):把下标 i 位置的数加上一个值 v

int Lowbit(int x){return x & -x;
}void update(int i, int v){while(i<=n){ // n为树状数组.size()-1tree[i] += v;i += Lowbit(i);}
}

PS:在求逆序对的题目中,C[i] 保存某一区间元素出现的次数,便于快速计算前缀和。


前缀和——区间查询

如何通过 区间和 得到 前缀和 ?举例说明:

  • presum[8] = C88 = 1000
  • presum[7] = C7 + C6 + C47 = 1116 = 1104 = 100
  • presum[5] = C5 + C45 = 1014 = 100

对于 presum[i] 而言,结合着后面跟的二进制表示,不难发现,求 presum[i] 即是将 i 转换为 二进制 ,不断对 末尾的1 进行 -1 的操作(寻找末尾1由Lowbit函数实现),直到全部为0停止。

实现区间查询函数 query(i): 查询序列 [1⋯i] 区间的区间和,即 i 位置的前缀和。

PS:在求逆序对的题目中,i-1 位的前缀和 presum[i-1] 表示「有多少个数比 i 小」,也就代表了有多少个逆序对。

int query(int i){int res = 0;while(i > 0){res += tree[i];i -= Lowbit(i);}return res;
}

完整代码

class BIT {vector<int> tree;int len;
public:BIT(int n):len(n), tree(n){}BIT(vector<int>& nums>{len = nums.size();tree = vector<int>(nums.size()+1);} static int Lowbit(int x){return x & -x;}int query(int x){ // 区间查询int res = 0;while(x){res += tree[x];x -= Lowbit(x);}return res;}void update(int x){ // 单点更新while(x<len){tree[x]++;x += Lowbit(x);}}
};

离散化

离散化常常用在通过树状数组求逆序对的题目中,连续化时,树状数组的长度为普通数组的最大元素。

比如题目给出一个数组 ivec = { 7, 4, 5, 100, 7, 5 } ,通过树状数组求逆序对的步骤如下:

  1. 创建长度为 100 的树状数组,下标从 1 开始。
  2. 倒序遍历 ivec ,通过区域求和得到 tree 数组中下标 ivec[i] 的前缀和,前缀和代表着比 ivec[i] 小的元素有几个。
  3. 更新单点,执行 tree[ivec[i]]++ 。举例:ivec[i]=7tree[7]++ ,代表 7 已被遍历过,出现了一次。

具体执行:

res = 0; // 存储逆序对个数
ivec = { 7, 4, 5, 100, 7, 5 }^
0 0 0 0 1 1 0 1 0 ……  0
1 2 3 4 5 6 7 8 9 …… 100
执行 res += query(4) 【已有的小于ivec[i]的元素才构成逆序对,因此从 ivec[i]-1 开始区间查询】得到 res = 0 + 0 = 0
单点更新,下标为 568…… 的 value 加 1ivec = { 7, 4, 5, 100, 7, 5 }^
0 0 0 0 1 1 1 2 0 ……  0
1 2 3 4 5 6 7 8 9 …… 100
执行 res += query(6) 得到 res = 0 + 1 = 1
单点更新,下标为 78…… 的 value 加 1ivec = { 7, 4, 5, 100, 7, 5 }^
0 0 0 0 1 1 1 2 0 ……  1
1 2 3 4 5 6 7 8 9 …… 100
执行 res += query(99) 得到 res = 1 + 1 = 2
单点更新,下标为 100 的 value 加 1ivec = { 7, 4, 5, 100, 7, 5 }^
0 0 0 0 2 2 1 3 0 ……  1
1 2 3 4 5 6 7 8 9 …… 100
执行 res += query(4) 得到 res = 2 + 0 = 2
单点更新,下标为 568…… 的 value 加 1

以此类推,很容易算出逆序对的数量。但是!可以发现1、2、3、6、8、9、…… 、98、99这些绝大多数位置都浪费了。因此我们需要对树状数组离散化,以节省内存空间。

实现树状数组离散化:

void Discretization(vector<int>& nums) {// nums 是 输入数组 的拷贝数组sort(nums.begin(), nums.end());nums.erase(unique(nums.begin(), nums.end()), nums.end()); //元素去重,下文有详细剖析
}int getid(int x, vector<int> nums){return lower_bound(nums.begin(), nums.end(), x) - nums.begin() + 1;
}

上述代码的作用简单来讲就是,通过 Discretization函数 将 nums 中的值保存到 a 中,并进行升序排列、元素去重的操作。以 ivec 为例,经过 Discretization函数 处理,得到

a = {4, 5, 7, 100}

而通过 getid函数 将 a 中元素映射为对应的树状数组下标,也就是 4 存在树状数组下标为 1 的地方,5 存在树状数组下标为 2 的地方……以此类推。举例:

ivec = { 7, 4, 5, 100, 7, 5 }^
0 1 0  1 // value
4 5 7 100 // 映射得到的逻辑下标
1 2 3  4// 物理下标
执行 res += query(getid(5)) 得到 res = 0
单点更新,下标为 getid(5)=2getid(100)=4 的 value 加 1ivec = { 7, 4, 5, 100, 7, 5 }^
0 1 1  2 // value
4 5 7 100 // 映射得到的逻辑下标
1 2 3  4// 物理下标
执行 res += query(getid(7)) 得到 res = 1
单点更新,下标为 getid(7)=3getid(100)=4 的 value 加 1

下面是对 Discretization函数 的剖析。


sort函数

  • 接受两个迭代器,表示要排序的元素范围
  • 是利用元素类型的<运算符实现排序的,即默认升序

实例:
在这里插入图片描述


unique函数去重

  • 重排输入序列,将相邻的重复项“消除”;
  • “消除”实际上是把重复的元素都放在序列尾部,然后返回一个指向不重复范围末尾的迭代器。

实例:
在这里插入图片描述
从上图可知,unique返回的迭代器对应的vc下标为4,vc的大小并未改变,仍有10个元素,但顺序发生了变化,相邻的重复元素被不重复元素覆盖了, 原序列中的“1 2 2”被“2 3 4”覆盖,不重复元素出现在序列开始部分。


erase函数仅保留不重复元素

可以通过使用容器操作——erase删除从end_unique开始直至容器末尾的范围内的所有元素:
在这里插入图片描述

通过树状数组求逆序对

题源力扣:数组中的逆序对

代码实现:

class BIT {vector<int> tree;int st;
public:BIT(int n) :st(n), tree(n) {}BIT(vector<int>& nums) {st = nums.size();tree = vector<int>(nums.size());for (int i = 0; i < nums.size(); i++) {update(i, nums[i]);}}static int Lowbit(int x) {return x & -x;}int query(int x) { // 区间查询int res = 0;while (x) {res += tree[x];x -= Lowbit(x);}return res;}void update(int x, int v) { // 单点更新while (x < st) {tree[x] += v;x += Lowbit(x);}}void show() {for (int i : tree) {cout << i << " ";}cout << endl;cout << "  4 5 7 100" << endl;}
};
class Solution {void Discretization(vector<int>& tmp) {sort(tmp.begin(), tmp.end());tmp.erase(unique(tmp.begin(), tmp.end()), tmp.end()); //元素去重}int getid(int x, vector<int>& tmp) {return lower_bound(tmp.begin(), tmp.end(), x) - tmp.begin() + 1;}
public:int reversePairs(vector<int>& nums) {int n = nums.size();vector<int> tmp = nums; // tmp作为离散化数组Discretization(tmp); // 排序去重BIT bit(tmp.size()+1);//bit.show();int res = 0; // 逆序对个数for (int i = n - 1; i >= 0; i--) {//cout << "v[i]: " << nums[i] << endl;int id = getid(nums[i], tmp); // 寻找映射res += bit.query(id - 1);// 因为计算的是value小于nums[i]元素的数目// 因此从前一位开始,下标id保存的是当前value=nums[i]的个数bit.update(id, 1); // nums[i]的个数+1//bit.show();//cout << "res: " << res << endl;}return res;}
};int main() {vector<int> v = { 7, 4, 5, 100, 7, 5 };Solution s;/*int res = s.reversePairs(v);cout << res << endl;*/cout << s.reversePairs(v) << endl;
}
/*
7, 4, 5, 100, 7, 5
*/

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/443783.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

二叉搜索树相关知识及应用操作

文章目录概念查找二叉搜索树的第k大节点概念 二叉查找树&#xff08;Binary Search Tree&#xff09;&#xff0c;&#xff08;又名&#xff1a;二叉搜索树&#xff0c;二叉排序树&#xff09;——它或者是一棵空树&#xff0c;或者是具有下列性质的二叉树&#xff1a; 若它的…

二叉树相关知识及求深度的代码实现

文章目录树二叉树满二叉树和完全二叉树二叉树的性质代码实现求二叉树的深度树 树是一种非线性的数据结构&#xff0c;它是由n个有限结点组成一个具有层次关系的集合。 树的相关名词&#xff1a; 根节点&#xff1a;没有前驱结点的结点。父节点&#xff0c;子节点&#xff1a…

大端小端存储模式详解及判断方法

文章目录大小端模式的概念两种模式出现原因两种模式的优劣大小端的应用情景判断机器的字节序大小端模式的概念 当我们查看数据在内存中的存储情况时&#xff0c;我们经常会发现一个很奇怪的现象&#xff0c;什么现象呢&#xff1f; int main() {int i 12;return 0; }数据在内…

Linux 内存管理 | 物理内存、内存碎片、伙伴系统、SLAB分配器

文章目录物理内存物理内存分配外部碎片内部碎片伙伴系统(buddy system)slab分配器物理内存 在Linux中&#xff0c;内核将物理内存划分为三个区域。 在解释DMA内存区域之前解释一下什么是DMA&#xff1a; DMA&#xff08;直接存储器访问&#xff09; 使用物理地址访问内存&am…

Linux 内存管理 | 虚拟内存管理:虚拟内存空间、虚拟内存分配

文章目录虚拟地址空间用户空间内核空间用户空间内存分配malloc内核空间内存分配kmallocvmalloc虚拟地址空间 在早期的计算机中&#xff0c;程序是直接运行在物理内存上的&#xff0c;而直接使用物理内存&#xff0c;通常都会面临以下几种问题&#xff1a; 内存缺乏访问控制&a…

Linux | 编译原理、gcc的命令参数、自动化构建工具 make/Makefile

文章目录编译原理预处理编译汇编链接gcc的常用命令参数make 和 Makefile 的概念make的运行通配符自动化变量伪目标.PHONE:【命令】编译原理 在解释 makefile 前&#xff0c;首先解释一下 .c 文件变成 .exe 文件要经过的四个步骤——预处理、编译、汇编和链接&#xff08;参考来…

Linux | 进程概念、进程状态(僵尸进程、孤儿进程、守护进程)、进程地址空间

文章目录进程和程序操作系统如何控制和调度程序进程控制块–PCB子进程进程状态僵尸进程孤儿进程守护进程&#xff08;精灵进程&#xff09;进程地址空间引言页表进程和程序 程序&#xff1a; 一系列有序的指令集合&#xff08;就是我们写的代码&#xff09;。进程&#xff1a;…

Linux 进程控制 :进程创建,进程终止,进程等待,程序替换

文章目录进程创建进程等待程序替换进程终止进程创建 fork函数&#xff1a; 操作系统提供的创建新进程的方法&#xff0c;父进程通过调用 fork函数 创建一个子进程&#xff0c;父子进程代码共享&#xff0c;数据独有。 当调用 fork函数 时&#xff0c;通过 写时拷贝技术 来拷贝…

Linux 内存管理 | 连续分配方式 和 离散分配方式

文章目录前言连续分配单一连续分配分区式分配固定分区分配动态分区分配可重定位分区分配离散分配分段分页多级页表快表(TLB)段页式Linux前言 Linux 内存管理 | 虚拟内存管理&#xff1a;虚拟内存空间、虚拟内存分配 Linux 内存管理 | 物理内存、内存碎片、伙伴系统、SLAB分配器…

操作系统 | 用户态和内核态的切换(中断、系统调用与过程(库函数)调用)

文章目录中断过程调用系统调用过程调用和系统调用的区别中断 用户态、内核态之间的切换是怎么实现的? 用户态→内核态 是通过中断实现的。并且 中断是唯一途径 。核心态→用户态 的切换是通过执行一个特权指令&#xff0c;将程序状态字 (PSW) 的标志位设置为 用户态 。 中断…

管道实现父子进程的信息传递(二)【标准流和其文件描述符、fwrite函数、perror函数】

文章目录代码实现标准流 和 标准流文件描述符代码中用到的函数fwrite()perror()在复习进程间的通信方式时又写了一遍&#xff0c;和 管道实现父子进程的信息传递&#xff08;一&#xff09;【fork函数、pipe函数、write/read操作、wait函数】 的区别不是特别大&#xff0c;只是…

命名管道实现进程的信息传递【mkfifo函数、open函数】

文章目录代码实现mkfifo函数open函数代码实现 #include<fcntl.h> // open() #include<sys/wait.h> // wait() #include<sys/types.h> // mkfifo() #include<sys/stat.h> // mkfifo() #include<iostream> #include<unistd.h> // fork()usi…

Linux 进程 | 进程间的通信方式

文章目录管道匿名管道 pipe命名管道 FIFO共享内存共享内存的使用流程&#xff1a;消息队列信号量套接字在之前的博客中讲过&#xff0c;虚拟空间出现的其中一个目的就是解决 进程没有独立性&#xff0c;可能访问同一块物理内存 的问题。因为这种独立性&#xff0c;进程之间无法…

Linux网络编程 | socket介绍、网络字节序与主机字节序概念与两者的转换、TCP/UDP 连接中常用的 socket 接口

文章目录套接字socket 地址通用 socket 地址专用 socket 地址网络字节序与主机字节序地址转换TCP/UDP 连接中常用的 socket 接口套接字 什么是套接字&#xff1f; 所谓 套接字 (Socket) &#xff0c;就是对网络中 不同主机 上的应用进程之间进行双向通信的端点的抽象。 UNIX/L…

网络协议分析 | 传输层 :史上最全UDP、TCP协议详解,一篇通~

文章目录UDP概念格式UDP如何实现可靠传输基于UDP的应用层知名协议TCP概念格式保证TCP可靠性的八种机制确认应答、延时应答与捎带应答超时重传滑动窗口滑动窗口协议后退n协议选择重传协议流量控制拥塞控制发送窗口、接收窗口、拥塞窗口快速重传和快速恢复连接管理机制三次握手连…

JDom,jdom解析xml文件

1.要解析的文件模板如下&#xff1a; <?xml version"1.0" encoding"GBK"?> <crsc> <data><举报信息反馈><R index"1"><举报编号>1</举报编号><状态>1</状态><答复意见>填写…

网络协议分析 | 应用层:HTTP协议详解、HTTP代理服务器

文章目录概念URLHTTP协议的特点HTTP协议版本格式请求报文首行头部空行正文响应报文首行头部空行正文Cookie与SessionHTTP代理服务器正向代理服务器反向代理服务器透明代理服务器概念 先了解一下 因特网&#xff08;Internet&#xff09; 与 万维网&#xff08;World Wide Web&…

MySQL命令(一)| 数据类型、常用命令一览、库的操作、表的操作

文章目录数据类型数值类型字符串类型日期/时间类型常用命令一览库的操作显示当前数据库创建数据库使用数据库删除数据库表的操作创建表显示当前库中所有表查看表结构删除表数据类型 mysql 的数据类型主要分为 数值类型、日期/时间类型、字符串类型 三种。 数值类型 数值类型可…

C++ 继承 | 对象切割、菱形继承、虚继承、对象组合

文章目录继承继承的概念继承方式及权限using改变成员的访问权限基类与派生类的赋值转换回避虚函数机制派生类的默认成员函数友元与静态成员多继承菱形继承虚继承组合继承 继承的概念 继承可以使得子类具有父类的属性和方法或者重新定义、追加属性和方法等。 当创建一个类时&…

博弈论 | 博弈论简谈、常见的博弈定律、巴什博弈

文章目录博弈论什么是博弈论&#xff1f;博弈的前提博弈的要素博弈的分类非合作博弈——有限两人博弈囚徒困境合作博弈——无限多人博弈囚徒困境常见的博弈定律零和博弈重复博弈智猪博弈斗鸡博弈猎鹿博弈蜈蚣博弈酒吧博弈枪手博弈警匪博弈海盗分金巴什博弈博弈论 什么是博弈论…