[LC] 307. Range Sum Query - Mutable

这一题比起来304https://blog.csdn.net/chaochen1407/article/details/86572593就难多了。这一题首先是一维数组,那题是二维的。但是这题加入了一个情况就是有一个update的api可以让你更新数组的内容。在这个情况下原来那个办法就没有用了,因为原来的办法我update一个数字的话最坏的情况我要update n个已经求好的和。所以这里就只能另寻他法了。基本上我们还是需要新开空间去进行某种记忆和运算以达到最好的效率,但是就不是那么无脑了。

第一种比较简单易用的方法就是分段法,把原来的数组分成长度的开根号的段落,就是开一个新数组长度就是长度的开根号,然后新数组里面每一个元素就是每一个段落的总和,所以当你进行update的时候你只需要update一个段落即可。举例说明,如果我们的原数组是1,2,3,4,5,6,7,8,9。那么新数组刚好就是长度为3,内容是6,15,24。 当你call update(1, 5)的时候,你首先根据1定位到是那个段落,这里也就是0,所以你更新就是6 - 2(原来的数字) + 5 = 9,新数组的内容就是9, 15, 24。当你做sumRange的时候,你会因为新生成的内容省去遍历全部原数组的时间。你最多需要的操作就是3倍的原长度的根号的复杂度。譬如你要sumRange(1, 7)的时候,因为1, 7涉及到了新数组的内容的index的0, 1, 2(也就是全部)。所以你第一步先把全部加起来就是9 + 15 + 24 = 48。但是头和尾并没有完全覆盖到那两个所属的段落,所以你又需要从原数组那边去稍微剪掉一部分,也就是原数组的index为0和8的部分,也就是48 - 1 - 9 = 38。这样你就可以得到答案了。因为chunk总数和每一个chunk代表的内容的长度都是原长度的根号,你最多需要遍历完全部chunk然后再遍历两个chunk的原数组内容即可,也就是3倍的原数组长度的开方。

根据这个算法,可以得到代码如下:

    private int[] chunks;
    private int[] nums;
    private int chunkSize;
    public NumArray(int[] nums) {
        this.nums = nums;
        if (nums.length == 0) return;

        double sq = Math.sqrt(nums.length);
        chunkSize = (int)Math.ceil(sq);
        int chunkLen = nums.length / chunkSize;
        chunkLen += nums.length % chunkSize == 0 ? 0 : 1;
        this.chunks = new int[chunkLen];
        for (int i = 0; i < nums.length; i++) {
            int curIndex = i / chunkSize;
            chunks[curIndex] += nums[i];
        }
    }
    
    public void update(int i, int val) {
        int chunkIdx = i / chunkSize;
        chunks[chunkIdx] += val - nums[i];
        nums[i] = val;
    }
    
    public int sumRange(int i, int j) {
        int beginIdx = i / chunkSize;
        int endIdx = j / chunkSize;
        int result = 0;
        for (int k = beginIdx; k <= endIdx; k++) result += chunks[k];
        for (int k = beginIdx * chunkSize; k < i; k++) result -= nums[k];
        for (int k = j + 1; k < Math.min(nums.length, (endIdx + 1) * chunkSize); k++) result -= nums[k];
        return result;        
    }

 

上面这个做法相对是比较邪道的,也不符合题目里面update和sumRange call次数差不多的暗示。因为sumRange是O(根号N),update是O(1),很屌的呢

那个提示其实就是要说最好update和sumRange的复杂度是差不多的。这一题的王道做法其实应该是segment tree。关于segment tree的具体介绍请各位自行google。

关于segment tree,大略上有两种实现方式。第一种是依据segment tree的严格定义作出一棵树来,这基于下面的数据结构:

class SegmentTreeNode {
    SegmentTreeNode left, right;
    int sum, start, end;
    
    public SegmentTreeNode(int sum, int start, int end) {
        this.sum = sum;
        this.start = start;
        this.end = end;
    }
}

解答可以分为几步:

1.第一步建树,复杂度是O(N)

基本上我是参考了https://www.jianshu.com/p/91f2c503e62f 做出来的所以首先建树这一步,我和它基本无异。和二叉树建树的方式差不多,只是多了一步求和的过程。走的是后序遍历的先左再右最后中的过程。

    SegmentTreeNode root;
    int[] nums;
    public NumArray(int[] nums) {
        this.root = this.buildTree(nums, 0, nums.length - 1);
        this.nums = nums;
    }
    
    public SegmentTreeNode buildTree(int[] nums, int start, int end) {
        if (start > end) {
            return null;
        }
        
        int mid = (start + end) / 2;
        SegmentTreeNode node = new SegmentTreeNode(nums[start], start, end);
        if (start == end) return node;
        node.left = buildTree(nums, start, mid);
        node.right = buildTree(nums, mid + 1, end);
        node.sum = node.left.sum + node.right.sum;
        return node;
    }

2. 节点更新, 这一步和上面那个link也差不多。只是它自下而上回归结果,而我因为用一个nums缓存了原数组,所以我最开始就会知道每一个节点需要更新的值是多少,所以我自上而下循环改变每一个对应的节点即可。复杂度是O(logN)。

    public void update(int i, int val) {
        int oldVal = nums[i];
        nums[i] = val;
        int start = 0, end = nums.length - 1;
        SegmentTreeNode current = this.root;
        while (current != null) {
            int mid = (current.start + current.end) / 2;
            current.sum += val - oldVal;
            if (i <= mid) {// 如果i在mid中左边,表示需要更新的节点在左子树
                current = current.left;
            } else {//如果i在mid右边,表示需要更新的节点在右子树
                current = current.right;
            }
        }
    }

3. 区间查询。这一点我的做法和原文差距就比较大了。当然原理还是一样的。先解释一下我的做法。我的做法是基于循环。在原题目api public int sumRange(int i, int j) 中,用i扫一次,用j扫一次。遍历的方式都是一样的,就是每次和节点的mid做比较,如果小于mid就往左走,大于mid就往右走。虽然都是同样的走法,但对于i和j来说,意义是不一样的。对于i,也就是range的起始点来说,往左走表示左边子树还有元素需要被包括进去,往右走就表示左边子树已经不存在应该被包含的元素了。对于j,也就是range的终结点来说,往左走就表示右边子树不存在应该被包含的元素,往右走就表示右边应该存在还需要被包含的元素。基于上面这套理论,我的做法就是这样的:基数是全部数字的和。当i遍历的时候,往左走不做计算,每当往右走的时候,就把母亲节点的左子树的和的部分减去。当j遍历的时候,往右走不做计算,每当往左走的时候,就把母亲节点的右子树的和的部分减去。这样就符合上述的描述。也可以得到代码如下:

    public int sumRange(int i, int j) {
        int sum = root.sum;
        SegmentTreeNode left = root, right = root;
        sum -= traverse(left, i, true) + traverse(right, j, false);        
        return sum;
    }
    
    public int traverse(SegmentTreeNode node, int target, boolean isLeft) {
        int sum = 0;
        while (node != null) {
            int mid = (node.start + node.end) / 2;
            if (target <= mid) {
                if (!isLeft) sum += node.right != null ? node.right.sum : 0;
                node = node.left;
            } else {
                if (isLeft) sum += node.left != null ? node.left.sum : 0;
                node = node.right;
            }
        }
        return sum;
    }

当然,如果按照那篇文章的做法,应该需要做的就是自下往上的加和。就会变成以下这个样子

    public int sumRange(int i, int j) {
        return query(this.root, i, j);
    }
    
    public int query(SegmentTreeNode node, int start, int end) {
        if (start <= node.start && end >= node.end) {
            return node.sum;
        }
        
        int result = 0;
        int mid = (node.start + node.end) / 2;
        if (start <= mid) {
            result += query(node.left, start, end);
        }
        
        if (end > mid) {
            result += query(node.right, start, end);
        }
        
        return result;
    }

实际上也是差不多的。这个递归看上去好像会导致O(n)的复杂度其实不然。因为每一个节点总会有一边子节点终结于第一个if的条件或者下面两个if都碰不到。自己试试就知道了。

Segment Tree还有第二种实现方式,数组。这种实现方式和clrs里面介绍如何用数组构建heap其实是差不多的。在上面的链接里,它用了4 * n的大小的数组去实现这个segment tree。经过我自己研究,其实最多3 * n就可以了。大概解释是这样的。首先用数组构建segment tree最小理论空间是n + n / 2 + n / 4 .... ~= 2n。这是假设这是一个完全二叉树的情况下,也就是一个满二叉树。但是往往情况就是它会多一层零散的出来,多出来的这一层的最大限度是n个(就是假设这一层铺满的话也是n个,因为数组就只有n个那么多)。所以这种构造树的方式需要的空间为3n,并且很多情况下这个3n的空间有些元素是永远不会访问的。自己用size为10的数组画一下就知道了。

正如我所说,这种用数组构建树的方式非常接近数组构建heap。parent节点是treeArr[n]的话,那么它的左子树的节点就是treeArr[2 * n + 1], 右子树的节点就是treeArr[2 * n + 2]。这样就能保证你treeArr[0]就是这棵树的根节点。如果你用treeArr[1]作为根节点,那么左子树就是[2 * n],右子树就是[2 * n + 1]这样也可以的。和上面树结构是一样的,如果treeArr[n]表示的范围是 [i ... j],那么treeArr[2 * n + 1]表示的就是[i .. mid],treeArr[2 *n + 2]就是[mid + 1 .. j]。譬如一个大小为10的数组arr,其中treeArr[0]就是表示arr[0 .. 9]的和,treeArr[1]就表示arr[0..4]的和,treeArr[2]表示arr[5..9]的和,以此类推。代码的整体架构和树结构的代码差别不大,唯一差别就在于如何访问左右子树。

建树的代码如下:

    int[] treeArr;
    int end;
    public NumArray(int[] nums) {
        this.treeArr = new int[nums.length * 3];
        int start = 0;
        this.end = nums.length - 1;
        if (this.end >= 0)
            buildTree(nums, start, this.end, 0);
    }

    public int buildTree(int[] nums, int start, int end, int idx) {
        if (start == end) {
            this.treeArr[idx] = nums[start];
        } else {        
            int mid = (start + end) / 2;
            int left = buildTree(nums, start, mid, 2 * idx + 1);
            int right = buildTree(nums, mid + 1, end, 2 * idx + 2);
            this.treeArr[idx] = left + right;
        }
        
        return this.treeArr[idx];
    }

update节点的代码如下:

    public void update(int i, int val) {
        this.updateTree(0, this.end, i, val, 0);
    }
    
    public void updateTree(int start, int end, int i, int val, int idx) {
        if (start == end) {
            this.treeArr[idx] = val;
        } else {
            int mid = (start + end) / 2;
            if (i <= mid) {
                updateTree(start, mid, i, val, 2 * idx + 1);
            } else {
                updateTree(mid + 1, end, i, val, 2 * idx + 2);
            }
            
            this.treeArr[idx] = this.treeArr[idx * 2 + 1] + this.treeArr[idx * 2 + 2];
        }
    }

求和的代码如下:

    public int sumRange(int i, int j) {
        return querySum(i, j, 0, this.end, 0);
    }
    
    public int querySum(int i, int j, int start, int end, int idx) {
        if (start > j || end < i) return 0;

        if (i <= start && j >= end) {
            return this.treeArr[idx];
        } else {
            int mid = (start + end) / 2;
            return querySum(i, j, start, mid, idx * 2 + 1) + querySum(i, j, mid + 1, end, idx * 2 + 2);
        }
    }

代码和上面用真树的代码是很类似的。

下面给出另一种也是用数组建树的代码,这段代码来自于leetcode的答案以及某些youtuber的介绍。这可以认为是最优的segment tree的数组解法。因为它的空间占用率是最理想的2n,也就是我刚刚说的n + n / 2 + 4 / n + .... + 1。做到这一点的原因是,上面那种办法是根节点往下递归遍历叶子结点,所以会遍历到一些空叶子结点造就了空间的浪费。而这一种方式是相反的,它们先从叶子结点遍历,然后返回到根节点,所以遍历到的每一个节点都是实际存在的。

具体做法是:
1. 首先构建一个size为2n的数组treeArr。(n为原数组的大小)
2. 复制原数组到treeArr[n ... 2n - 1]里。
3. 然后做这样的循环:for i = n - 1, i >= 1, i-- do treeArr[i] = treeArr[2 * i] + treeArr[2 * i + 1]。

这样最后treeArr[1]就是根节点,treeArr[0]是无意义的。。

其实我不是很推荐这一种做法的,很多描述包括leetcode的,各位youtuber的,它们对于数组和树的关系的描述是错误的(仅仅是完全二叉树的情况下他们的描述才是对的),我还没有找到一种很合理解释这种建树方式的文章,所以下面是我自己的理解。这种构建树的方式,数组和左右子树的结构关联是不稳定的,这也是为了节省空间的一种tradeoff。举个例子,譬如说arr的size为5。那么treeArr的size就为10,那么treeArr[5 ~ 9]就对应arr[0 ~ 4]。然后treeArr[4]就是arr[3 ~ 4],treeArr[3]就是arr[1 ~ 2],但是到了treeArr[2]的时候,就奇怪了,其实它对应的是arr[0, 3, 4],然后treeArr[1]就是arr[0 ~ 4]的总和。这种现象其实可以理解这种构树方式的子树包含的范围是一个shift过的数组,也就是说它包含的依旧是一个连续的区间,但是shift过了,3下一个是4,4下一个就是回到头的0,否则这种解题方式都不能称之为segment tree,也无从通过leetcode的检测了。但解释起来真的很麻烦,树和数组的结构关系也很不直观,不是很推荐,我就放一下代码就好了。

    private int[] segTree;
    
    public NumArray(int[] nums) {
        this.segTree = new int[nums.length * 2];
        int n = nums.length;
        for (int i = n; i < this.segTree.length; i++) {
            this.segTree[i] = nums[i - n];
        }
        
        for (int i = n - 1; i > 0; i--) {
            this.segTree[i] = this.segTree[i * 2] + this.segTree[i * 2 + 1];
        }
    }
    
    public void update(int i, int val) {
        int index = this.segTree.length / 2 + i;
        // 在这里找到真正的节点位置。从叶子节点往上递归更新。
        this.segTree[index] = val;
        while (index > 1) {
            index /= 2;
            this.segTree[index] = this.segTree[index * 2] + this.segTree[index * 2 + 1];
        }
    }
    
    public int sumRange(int i, int j) {
        int l = i + this.segTree.length / 2;
        int r = j + this.segTree.length / 2;
        int res = 0;
        while (l <= r) {
            if (l % 2 == 1) {
                res += this.segTree[l];
                l++;
            }
            
            if (r % 2 == 0) {
                res += this.segTree[r];
                r--;
            }
            
            l /= 2;
            r /= 2;
        }
        
        return res;
    }

其实还有一种做法,叫做binary index tree。这不是一棵真的树。和上面用数组表达segment tree类似,这是一种用数组表达的数据结构。具体可以参考https://blog.csdn.net/Yaokai_AssultMaster/article/details/79492190 或者http://www.cnblogs.com/grandyang/p/4985506.html 。 我就不重复解释细节了,我也解释不清楚。总而言之,在bitarr里,对于某个节点bitarr[i],它的子节点是bitarr[i + (i & ~i)],它的父亲节点就是bitarr[i - (i & -i)]。在更新的时候,update(int i, int val) 以i为根节点,不停更新delta到子节点直到所有节点被更新完毕。在求和的时候,譬如prefixSum(int i), 则是反过来的,以i为叶子结点,不停往上归溯父亲节点,把所有路过的父亲节点加进去就可以了。要注意的是这个结构并不能直接求range(i, j),它能求的是从第一个index开始到某个index节点的prefix。所以range(i, j)其实也可以就变成了prefix(j) - prefix(i - 1)。而最初始的建树的过程其实就是不停update节点的过程。要注意的是update是基于delta的,所以初始建树就相当于对一颗空树不停update原始数组的值(也就是这个值就成为了delta)的过程。算法复杂度:update是O(logN),求和是O(logN),建树因为是update n次,所以是O(nlogn),当然,可以优化成O(N),下面给出的代码的建树过程就是O(N)的。

(差点忘了,为了配合binary index tree的位操作的性质,这个数组是1-based的array,所以所有index都要右移一位)

class NumArray {

    private int[] bitArr;
    private int[] nums;
    
    public NumArray(int[] nums) {
        bitArr = new int[nums.length + 1];
        this.nums = nums;
        for (int i = 0; i < nums.length; i++) {
            bitArr[i + 1] = nums[i];
        }
        
        for (int i = 1; i <= nums.length; i++) {
            int j = i + (i & -i);
            if (j <= nums.length) {
                bitArr[j] += bitArr[i];
            }
        }
    }
    
    public void update(int i, int val) {
        int delta = val - nums[i];
        int idx = i + 1;
        while (idx < bitArr.length) {
            bitArr[idx] += delta;
            idx = idx + (idx & -idx);
        }

        nums[i] = val;
    }
    
    public int sumRange(int i, int j) {
        return prefixSum(j) - prefixSum(i - 1);
    }
    
    public int prefixSum(int i) {
        int result = 0;
        i++;
        while (i > 0) {
            result += bitArr[i];
            i = i - (i & -i);
        }
        
        return result;
    }
}

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值