[303]. 区域和检索 - 数组不可变(线段树区间查询)

区域和检索 - 数组不可变

 


题目

题目:https://leetcode-cn.com/problems/range-sum-query-immutable/
 


函数原型

class NumArray {
public:
    NumArray(vector<int>& nums) {}
    int sumRange(int left, int right) {}
};

 


线段树(区间树)

线段树,专门用于处理区间数据,因此又名区间树。

为什么需要线段树,其实数组就可以各种区间操作,数组本身就是一个区间是吧。

因为线段树、动态线段树、数状数组、SQRT分解这些数据结构,都是专门用于解决区间操作的,如果是数组就需要遍历一次,整个区间的查询、整个区间的更新都是 O ( n ) O(n) O(n),而线段树都是 O ( l o g n ) O(logn) O(logn)

线段树解决的问题类型:

  • 区间更新:更新区间中一个元素或一个区间的值
  • 区间查询:查询区间中最大值、最小值、区间数字和

线段树,是如何用 O ( l o g n ) O(logn) O(logn) 的时间达到的呢?

线段树,是用空间换时间、升维实现的。

每个节点存储的是一个区间内的信息


相对于把时间预处理了,比如求区间 [5, 9] 的和,直接调用即可。

那如何求区间 [2, 5] 的和呢,貌似没有这个区间?

把几个区间拼起来即可,如下图:

那怎么创建线段树?

比如上面线段树的根节点有 10 个元素,左孩子是前 5 个元素,右孩子是后 5 个元素。

  • 前 5 个元素的和 + 后 5 个元素的和 = 10 个元素的和
  • 左孩子 + 右孩子 = 父节点

对于左、右孩子,以此划分,直到叶节点(只有一个元素)。

整个创建过程,是递归的。

线段树的创建,创建好左右子树之后,也就能决定自身节点的值了;

其实,从树的角度看,他们本质都是“后序遍历”,即处理完自己的子树之后,再处理自己。

线段树的物理存储,是数组。

如上图,线段树是一个平衡二叉树(不会退化为链表,整体元素分布平均),堆也是平衡二叉树。

但是一些叶节点是不存在的,为了放入数组中,我们可以假设那些不存在的叶节点为空,形成一个完全二叉树。

那就有一个问题了,如果区间有 n 个元素,用数组表示需要多少个节点?

对于一个满二叉树,树的层数和节点之间的关系:

  • 第 0 层:1 个
  • 第 1 层:2 个
  • 第 2 层:4 个
  • 第 3 层:8 个
  • 第 h-1 层: 2 h − 1 2^{h-1} 2h1

最后一层 h-1 层,有 2 h − 1 2^{h-1} 2h1 个节点,说明最后一层的节点数大致等于前面所有层节点之和。

  • 最好:如果 n = 2 的幂,那么只需要 2n 的空间就可以存储
  • 最坏:如果 n = 2 的幂 + 1,那么需要多一层空间存储这个额外的元素,而最后一层的节点数大致等于前面所有层节点之和,也就是需要 4n 的空间存储,最坏的情况下,有一半的空间是浪费的(如果是链式存储,可以避免浪费)

所以,数组表示需要开 4n 空间。

class NumArray {
public:
	NumArray(vector<int>& arr) {
		if (arr.size() > 0) {
			for (auto v : arr) 
				data.push_back(v);

			tree.resize(4 * data.size());
			buildTree(0, 0, data.size() - 1);        // 创建一个线段树 
		}
	}
	
	int sumRange(int i, int j) {
		if (data.size() == 0)	return 0;
		return query(0, 0, data.size() - 1, i, j);   // 从根节点开始查询区间[i, j]和
	}

private:
	vector<int> data;     // 数组,用于保存数据副本
	vector<int> tree;     // 线段树数组
	
	// 返回完全儿叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引(知道根节点,就知道左孩子)
	int leftchild(int index) {   
		return index * 2 + 1;
	}
	
	// 返回完全儿叉树的数组表示中,一个索引所表示的元素的右孩子节点的索引(知道根节点,就知道右孩子)
	int rightchild(int index) {
		return index * 2 + 2;
	}
	
	// 在 treeIndex 位置创建表示区间 [l, r] 的线段树
	void buildTree(int treeIndex, int l, int r) {
		if (l == r) {                                // 结束条件:只有 1 个元素
			tree[treeIndex] = data[l];               // 所存储的信息等于元素本身
			return;
		}
		
		// 表示一个区间
		int leftTreeIndex = leftchild(treeIndex);    // 必定有左孩子 
		int rightTreeIndex = rightchild(treeIndex);  // 相应的右孩子 
		
		// 创建左右子树相应的区间范围 
		int mid = l + (r - l) / 2;                   // 以 (l+r)/2 为中心
		buildTree(leftTreeIndex, l, mid);            // 区间 [l, mid] 创建线段树
		buildTree(rightTreeIndex, mid+1, r);         // 区间 [mid+1, r] 创建线段树
		tree[treeIndex] = tree[leftTreeIndex] + tree[rightTreeIndex]; 
		// 把左、右子树的值加在一起
 	}

	/* 查询区间
		- treeIndex 表示当前处理的线段树的节点,在 tree 这个数组的什么位置
		- 在以 treeIndex 为根的线段树中 [l, r] 区间内,寻找 [queryL, queryR] 的值
	*/
	int query(int treeIndex, int l, int r, int queryL, int queryR) {
		if (l == queryL && r == queryR)         
		// 结束条件:左边界和想查询的 queryL 重合 且 右边界和想查询的 queryR 重合
			return tree[treeIndex];                       // 当前节点值是目标值
			
		// 如果这个节点不是要找的区间,就需要到当前节点的孩子节点去找
		int mid = l + (r - l) / 2;
		int leftTreeIndex = leftchild(treeIndex);         // 计算左孩子索引
		int rightTreeIndex = rightchild(treeIndex);       // 计算右孩子索引
		
		if (queryL >= mid + 1)                            // 如果目标区间和左孩子无关
			return query(rightTreeIndex, mid + 1, r, queryL, queryR); // 去右孩子找
		else if (queryR <= mid)                           // 如果目标区间和右孩子无关
			return query(leftTreeIndex, l, mid, queryL, queryR);      // 去左孩子找

		// 如果俩种情况都不是,目标区间没有完全落在孩子的区间中,那就需要进行拼接,俩边都需要找
		// 从查找 [queryL, queryR] 变成查找[queryL, mid]、[mid + 1, queryR]
		int leftresult = query(leftTreeIndex, l, mid, queryL, mid);   
		int rightresult = query(rightTreeIndex, mid + 1, r, mid + 1, queryR);
		return leftresult + rightresult;                  // 融合
	}
};

线段树好的地方,在于支持动态维护,适合多次查询、多次更新、边更新边查询。

 


SRQT 分解

线段树用来解决区间问题是 O ( l o g n ) O(log n) O(logn),SRQT 分解解决区间问题是 O ( n ) O(\sqrt n) O(n ),在大规模数据上比不上线段树,好处在于,编程简单。

SRQT 分解:把一个含有 N 个元素的数组分成 n \sqrt n n 份。

还有一种特殊情况,元素数量不等:

分好后,对每一份都计算一下元素和:

  • 第 0 组:130
  • 第 1 组:145
  • 第 2 组:151
  • 第 3 组:223
  • 第 4 组:107

比如,查询区间 [6, 9]:

  • 6 / 4 = 1(下取整),在第 1 组
  • 9 / 4 = 2(下取整),在第 2 组

从 6 到 9 的元素遍历一次就好了,但这种情况貌似就没发挥出 SQRT 的作用。

SQRT 适合查询那种跨度大的区间,如区间[3, 16]:

本来我们应该从 3 到 16 遍历一次,但其实我们不用遍历第 1、2、3 组了。

  • 头组:从第 0 组遍历到 55
  • 中间:只需要把第 1、2、3 组的值加起来即可
  • 尾组:从第 4 组遍历到 91
  • 区间[3, 16] = 头组 + 中间 + 尾组
class NumArray {
    vector<int> data;
    vector<int> blocks;  // 存储各组的和
    int N;               // 元素总数
    int B;               // 每组元素个数
    int Bn;              // 组数
    
public:
    NumArray(vector<int>& nums) {
        N = nums.size();
        if (N == 0)
            return;
        
        B = (int)sqrt(N);
        Bn = N / B + (N % B > 0 ? 1 : 0);    // 不能整除,组数+1
        
        for(auto v : nums) 
			data.push_back(v);

		blocks.assign(N, 0);
        for(int i=0; i<N; i++)       
            blocks[i / B] += nums[i];        // 求组和
    }
    
    int sumRange(int left, int right) {
        int bstart = left / B;               // 起始组号
        int bend = right / B;                // 终止组号

        int res = 0;
        if( bstart == bend ) {               // 所求的区间和属于同一组
            for(int i=left; i<=right; i++)   // 遍历得到结果
                res += data[i];
            return res;
        }

        // 所求的区间和不属于同一组,需要分成 3 组
        int x = (bstart + 1) * B;
        for(int i=left; i<x; i++)            // 头组
            res += data[i];

        for(int i=bstart+1; i<bend; i++)     // 中间
            res += blocks[i];

        for(int i=bend * B; i<=right; i ++)  // 尾组
            res += data[i];
        return res;
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值