十一、线段树如此神奇

489 阅读5分钟

线段树

目标:

  • 认识线段树
  • 创建线段树
  • 线段树的操作
    • 区间查询
    • 更新操作

1.1 认识线段树

  • 什么是线段树

    • 使用线段树可以快速的查找某一个节点在若干条线段中出现的次数,时间复杂度为O(logN)。不考虑添加或者删除元素,大多数情况下,解决的问题都是区间固定的。
    • 对于线段树来说,每一个节点表示的都是一个区间内的信息。
  • 基本结构

    • 线段树是建立在线段的基础上,每个结点都代表了一条线段[a,b]。

    • 长度为1的线段称为元线段。非元线段都有两个子结点,左结点代表的线段为[a,(a + b) / 2],右结点代表的线段为[((a + b) / 2)+1,b]。

      • 线段树不是完全二叉树(奇数个元素),但是平衡二叉树(任意节点的子树的高度差都小于等于1)。
    • 满二叉树

      接下来我们思考一个问题,如果区间有n个元素,用数组表示的话,需要多少节点?

      • h层,一共有2^h^-1个节点,大约是2^h^
      • 最后一层(h-1)层,有2^(h-1)^个节点,最后一层的节点数大致等于前面所有节点数之和
      • 如果区间有n个元素,n=2^k^,那么需要2n的空间,最坏情况,如果n=2^k^+1,那么需要4n的空间。

      那么如果我们的线段树不考虑添加元素,即固定区间的话,使用4n的静态空间即可。

  • 解决的问题

    • 区间染色

      • 对于给定区间,更新其中一个元素或者一个区间的值
    • 区间查询

      • 对于给定区间查询一个区间[i,j]的最大值,最小值,或者区间数字和
      • 实质:基于区间的统计查询
    • 对于更新和查询来说

      • 链表:O(n)
      • 线段树:O(logn)
      image-20200917192547430

1.2 创建线段树

  • 创建线段树

    代码如下:

    // 返回完全二叉树的数组表示中,一个索引所表示的元素的左孩子节点的索引
    public int leftChild(int index) {
        return 2 * index + 1;
    }
    
    // 返回完全二叉树的数组表示中,一个索引所表示的元素的右孩子节点的索引
    public int rightChild(int index) {
        return 2 * index + 2;
    }
    
    public SegmentTree(E[] arr, Merger<E> merger) {
    
        this.merger = merger;
    
        data = (E[]) new Object[arr.length];
    
        for (int i = 0; i < data.length; i++) {
            data[i] = arr[i];
        }
        tree = (E[]) new Object[4 * arr.length];
        buildSegmentTree(0, 0, data.length - 1);
    }
    
    // 在treeIndex的位置创建表示区间[l...r]的线段树
    private void buildSegmentTree(int treeIndex, int l, int r) {
        // 当l==r时,只有一个元素
        if (l == r) {
            tree[treeIndex] = data[l];
            return;
        }
    
        // 当l!=r时,l<r
        int leftTreeIndex = leftChild(treeIndex);
        int rightTreeIndex = rightChild(treeIndex);
    
        // 计算中间位置,当l和r很大的时候,可能会有溢出
        int mid = l + (r - l) / 2;
        buildSegmentTree(leftTreeIndex, l, mid);
        buildSegmentTree(rightTreeIndex, mid + 1, r);
    
        tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]);
    }
    
  • 求和线段树

    image-20200917193753947

    如上图所示,根节点为所有元素的和,左子节点为左半部分元素的和,右子节点为右半部分元素的和,然后继续递归,直至最后节点不能再划分了,节点只有一个元素。

    当l==r时,只有一个元素,由于treeIndex节点放置的就是[l,r]区间的值,所以此时直接让treeIndex节点的值为data[r]即可。

    当l!=r时,计算中间节点位置mid,然后调用递归函数即可。由于我们创建求和线段树,所以需要考虑当不是整型Interger时的情况,所以创建merge函数,进行合并操作。

  • 查询操作

    如下图:

    image-20200917194859027

    假如我们查询[2,5]区间,由上图可知,从根节点出发,[2,5]内元素是[0,7]的子集,由于左子树和右子树的区分是中间节点位置,所以[2,5]在左子树和右子树都有。所以我们从左子树查询[2,3],从右子树查询[4,5],然后合并即可。

    实现代码如下:

    // 返回区间[queryL, queryR]的值
    public E query(int queryL, int queryR) {
        if (queryL < 0 || queryL >= data.length || queryR < 0 || queryR >= data.length || queryL > queryR) {
            throw new IllegalArgumentException("Index is illegal.");
        }
        return query(0, 0, data.length - 1, queryL, queryR);
    }
    
    // 在以treeID为根的线段树中[l...r]的范围里,查询区间[queryL...queryR]的值
    private E query(int treeIndex, int l, int r, int queryL, int queryR) {
        if (l == queryL && r == 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);
        }
        E leftResult = query(leftTreeIndex, l, mid, queryL, mid);
        E rightResult = query(rightTreeIndex, mid + 1, r, mid + 1, queryR);
    
        return merger.merge(leftResult, rightResult);
    }
    
  • 更新操作

    代码如下:

    // 更新操作:将index位置的值,更新为e
    public void set(int index, E e) {
        if (index < 0 || index >= data.length) {
            throw new IllegalArgumentException("Index is illegal.");
        }
        data[index] = e;
        set(0, 0, data.length - 1, index, e);
    }
    
    // 在以treeIndex为根的线段树中更新Index的值为e
    private void set(int treeIndex, int l, int r, int index, E e) {
        if (l == r) {
            tree[treeIndex] = e;
            return;
        }
        int mid = l + (r - l) / 2;
        int leftTreeIndex = leftChild(treeIndex);
        int rightTreeIndex = rightChild(treeIndex);
    
        if (index >= mid + 1) {
            set(rightTreeIndex, mid + 1, r, index, e);
        } else {
            set(leftTreeIndex, l, mid, index, e);
        }
    
        tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]);
    }
    

    代码逻辑是这样的,当我们更新index位置的元素为e时,同样先计算mid的值,然后判断index和mid的大小,也就是确定index是在左子树还是右子树中。

    需要注意的是,当我们确定index的位置,并且更新完成之后,是不够的,只是因为在返回的过程中,其父节点等等都会受到影响,都是区间内的统计结果,所以index节点的值发生变化,其父节点也要发生变化。

    额……好开心,今天把线段树的知识点汇总了一遍。总体来说收获不小,对于树结构的操作和递归思想的理解更加深刻。

    当然,只是简单的总结肯定还是不够的,后续需要多加练习,这样才能掌握的更好。

    唉,刷题一时难,一直刷题一直难。

    OK,今天就到这里,下班走起。

    拜了个拜~