9-玩轉數據結構-線段樹

上一章我們介紹了堆,這一章我們介紹一種新的樹結構,線段樹(區間樹) Segment Tree

爲什麼使用線段樹?

對於有一類問題,我們關心的是線段(或者區間)

最經典的線段樹問題, 區間染色:有一面牆,長度爲n,每次選擇一段兒牆進行染色

img_d41265319032839456b6de26d37afc37.jpe

4-9染成橙色之後,對於7-15繪製綠色。橙色被綠色覆蓋

img_f446fceb2f53ce1b21e552a1e6d02208.jpe

1-5繪製成藍色,6-12繪製紅色。

m次操作後,我們可以看見多少種顏色? m次操作後,我們可以在[i, j]區間內看見多少種顏色?

整個問題: 我們關注區間。染色操作(更新區間) 查詢操作(查詢區間) 染色操作遍歷一遍,查詢遍歷一遍區間。

img_06dc864b5d4500133aa16234d4c55eb2.jpe

另一類經典問題: 區間查詢

img_2c69e2dabaedaf2d1d6e3275b430dcb3.jpe

查詢一個區間[i, j]的最大值,最小值,或者區間數字和.實質:基於區間的統計查詢

例如: 2017年註冊用戶中消費最高的用戶?消費最少的用戶?學習時間最長的用戶?某個太空區間中天體總量?

動態的一個查詢,並不限定於2017年的歷史數據,有可能2018年他還是在消費的,數據依然在不斷的變化。天體從區間跑到另一個區間。

也就是數據在不斷的更新,我們也可以進行不斷的查詢。

img_df6acf559c372d8aea8f15f3b286854b.jpe

對於這類區間類的問題,使用線段樹,它的時間複雜度將變爲O(logn)級別的。看到logn,大家應該意識到線段樹也是一種二叉樹結構的。

對於給定區間進行兩個操作 更新: 更新區間中一個元素或者一個區間的值;查詢: 一個區間[i, j]的最大值,最小值,或者區間數字和

實現: (線段樹的區間本身是固定的,2017年註冊的用戶)

img_282d9d326d6fc31e322e010534362b34.jpe

與所有的二叉樹一樣,它有一個一個的節點。每一個節點表示的是一個區間內相應的信息。以求和爲例。最後一層區間長度爲1的單個節點。

查詢2-5 區間進行合成操作。

img_7edaf36da83c3da7be814fcc3e8d5987.jpe

線段樹的基礎表示

每一個節點存儲的節點對應數字和(求和爲例)。

8個元素,2^3次方,滿二叉樹。

img_c07f76575c98a2b427af0047035caffe.jpe

5不能被平分就右邊多一點。葉子節點有可能在倒數第二層。

img_b15ba0a6cc4c012c7fe5513248a51367.jpe

線段樹不是完全二叉樹;線段樹是平衡二叉樹。平衡二叉樹的定義: 最大的深度和最小的深度之差最多爲1,如上圖我們的葉子節點要麼在深度爲4的位置,要麼在深度爲5的位置。

堆也是平衡二叉樹,完全二叉樹本身就是一棵平衡二叉樹。線段樹雖然不是完全二叉樹,但是滿足線段樹的定義。

平衡二叉樹不會退化成鏈表,依然是log級別的。

線段樹是平衡二叉樹,依然可以用數組表示。看做滿二叉樹,如果區間有n個元素,數組表示需要多少個節點?

img_1c1ed56a7d6d6afbebb95ed18bd7a09e.jpe

對滿二叉樹: h層,一共有2^h- 1個節點(大約是2h),最後一層(h-1層),有2(h-1)個節點,最後一層的節點數大致等於前面所有層節點之和。

如果n=2^k 只需要2n的空間; 最壞情況, 如果n=2^k+1 需要4n的空間

如果區間有n個元素數組表示需要有多少節點? 需要4n的空間
我們的線段樹不考慮添加元素,即區間固定,使用4n的靜態空間即可(這是保證絕對可以裝完的)

img_f2b7b61f4ca20334803c5ec92a8dc218.jpe

可以看到浪費了很多的空間,現代計算機空間換時間,這部分浪費可以不被浪費,拓展部分解決,節點方式存儲可以避免浪費。

package cn.mtianyan.segment;

public class SegmentTree<E> {

    private E[] tree;
    private E[] data; // 存儲整個線段樹數據副本

    public SegmentTree(E[] arr) {

        data = (E[]) new Object[arr.length];
        for (int i = 0; i < arr.length; i++)
            data[i] = arr[i];

        tree = (E[]) new Object[4 * arr.length];
    }

    /**
     * 獲取數組大小
     *
     * @return
     */
    public int getSize() {
        return data.length;
    }

    /**
     * 傳入index獲取該位置數據
     *
     * @param index
     * @return
     */
    public E get(int index) {
        if (index < 0 || index >= data.length)
            throw new IllegalArgumentException("Index is illegal.");
        return data[index];
    }

    /**
     * 返回完全二叉樹的數組表示中,一個索引所表示的元素的左孩子節點的索引
     *
     * @param index
     * @return
     */
    private int leftChild(int index) {
        return 2 * index + 1;
    }

    /**
     * 返回完全二叉樹的數組表示中,一個索引所表示的元素的右孩子節點的索引
     *
     * @param index
     * @return
     */
    private int rightChild(int index) {
        return 2 * index + 2;
    }
}

線段樹中是不需要去找某個節點的父親節點的。線段樹空間4n。

創建線段樹。

根節點存儲的信息,就是兩個孩子信息的綜合(遞歸即可),如何合併是由業務而定的。遞歸到底,是隻有一個元素本身。

package cn.mtianyan.segment;

public interface Merger<E> {
    E merge(E a, E b);
}
public class SegmentTree<E> {

    private E[] tree;
    private E[] data; // 存儲整個線段樹數據副本
    private Merger<E> merger; // 用戶可以傳入合併規則

    public SegmentTree(E[] arr, Merger<E> merger) {
        this.merger = merger;
        data = (E[]) new Object[arr.length];
        for (int i = 0; i < arr.length; i++)
            data[i] = arr[i];

        tree = (E[]) new Object[4 * arr.length];
        buildSegmentTree(0, 0, arr.length - 1); // 根節點索引0,區間左右端點。
    }

    /**
     * 在treeIndex的位置創建表示區間[l...r]的線段樹
     *
     * @param treeIndex
     * @param l
     * @param r
     */
    private void buildSegmentTree(int treeIndex, int l, int r) {

        // 遞歸到底了。
        if (l == r) {
            tree[treeIndex] = data[l];
            return;
        }

        int leftTreeIndex = leftChild(treeIndex);
        int rightTreeIndex = rightChild(treeIndex);

        // 要知道左右子樹相應的區間範圍。
        // int mid = (l + r) / 2; 整型異常可能
        int mid = l + (r - l) / 2;
        buildSegmentTree(leftTreeIndex, l, mid);
        buildSegmentTree(rightTreeIndex, mid + 1, r);

        // 業務相關的值合併
        tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]);
    }
/**
     * 遍歷打印樹中節點中值信息。
     * @return
     */
    @Override
    public String toString(){
        StringBuilder res = new StringBuilder();
        res.append('[');
        for(int i = 0 ; i < tree.length ; i ++){
            if(tree[i] != null)
                res.append(tree[i]);
            else
                res.append("null");

            if(i != tree.length - 1)
                res.append(", ");
        }
        res.append(']');
        return res.toString();
    }

這裏在構造函數中添加參數,使得用戶可以傳入merger規則來改變類內部的合併規則。類似於上章優先隊列中傳入元素比較器。

package cn.mtianyan;

import cn.mtianyan.segment.SegmentTree;

public class Main {

    public static void main(String[] args) {

        Integer[] nums = {-2, 0, 3, -5, 2, -1};
//        SegmentTree<Integer> segTree = new SegmentTree<>(nums,
//                new Merger<Integer>() {
//                    @Override
//                    public Integer merge(Integer a, Integer b) {
//                        return a + b;
//                    }
//                });

        SegmentTree<Integer> segTree = new SegmentTree<>(nums,
                (a, b) -> a + b);
        System.out.println(segTree);
    }
}

匿名內部類的實現可以改寫爲Lambda表達式,傳入(a,b) 返回a+b

img_bb842eb0b603a1ba40e8c826a99fb9ad.jpe

線段樹的查詢

img_239644f30cd1900fa5a981560f02b468.jpe

相當於查詢2-5區間所有元素的和。從根節點開始往下,我們知道分割位置,左節點查詢[2,3] 右節點查詢[4,5],找到兩個節點之後合併就可以了。

與樹的高度有關,高度是logn級別的。

/**
     * 返回區間[queryL, queryR]的值
     *
     * @param queryL
     * @param queryR
     * @return
     */
    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);
    }

    /**
     * 在以treeIndex爲根的線段樹中[l...r]的範圍裏,搜索區間[queryL...queryR]的值
     *
     * @param treeIndex 我們都傳入了這個treeIndex的區間範圍l r; 完全可以包裝成一個線段樹中的節點類,每個節點存儲它所處的區間範圍。
     * @param l
     * @param r
     * @param queryL
     * @param queryR
     * @return
     */
    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;
        // treeIndex的節點分爲[l...mid]和[mid+1...r]兩部分

        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 R 被一分爲二
        E leftResult = query(leftTreeIndex, l, mid, queryL, mid);
        E rightResult = query(rightTreeIndex, mid + 1, r, mid + 1, queryR);
        return merger.merge(leftResult, rightResult);
    }
System.out.println(segTree.query(0, 2));
        System.out.println(segTree.query(2, 5));
        System.out.println(segTree.query(0, 5));

運行結果:

img_54cca7063bd8aa3ea791a09b1486b6a4.jpe

運行結果1是-2+0+3;[0,5]所有元素和

LeetCode線段樹問題

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

  1. 區域和檢索 - 數組不可變(不涉及線段樹的更新操作)
package cn.mtianyan.leetcode_303;

import cn.mtianyan.segment.SegmentTree;

class NumArray {

    private SegmentTree<Integer> segmentTree;
    public NumArray(int[] nums) {

        if(nums.length > 0){
            Integer[] data = new Integer[nums.length];
            for (int i = 0; i < nums.length; i++)
                data[i] = nums[i];
            segmentTree = new SegmentTree<>(data, (a, b) -> a + b);
        }

    }

    public int sumRange(int i, int j) {

        if(segmentTree == null)
            throw new IllegalArgumentException("Segment Tree is null");

        return segmentTree.query(i, j);
    }
}

在提交我們自定義的數據結構時,內部類都改爲private,否則會造成編譯錯誤。

在數組不可變的情況下,不使用線段樹有時可以得到更好的解答。

package cn.mtianyan.leetcode_303;

/**
 * 數組進行預處理。
 */
public class NumArray2 {

    private int[] sum; // sum[i]存儲前i個元素和, sum[0] = 0
    // 即sum[i]存儲nums[0...i-1]的和
    // sum(i, j) = sum[j + 1] - sum[i]
    // 這裏會有一個偏移
    public NumArray2(int[] nums) {

        sum = new int[nums.length + 1];
        sum[0] = 0;
        for(int i = 1 ; i < sum.length ; i ++)
            sum[i] = sum[i - 1] + nums[i - 1];
    }

    public int sumRange(int i, int j) {
        return sum[j + 1] - sum[i];
    }
}

這個問題限制在數據是不變的,因此可以採用其他更優方案,線段樹更優的應用場景是數據會有更新和查詢兩種操作同時存在的情況。

Leetcode307號問題

區域和檢索 - 數組可修改

package cn.mtianyan.leetcode_307;

/**
 * 使用sum數組的思路:TLE   Time Limit Exceed   超時
 * update是O(n)複雜度,sumRange依然是O(1)
 */
class NumArray {

    private int[] data; // 原本的數組備份
    private int[] sum;

    public NumArray(int[] nums) {

        data = new int[nums.length];
        for (int i = 0; i < nums.length; i++)
            data[i] = nums[i];

        sum = new int[nums.length + 1];
        sum[0] = 0;
        for (int i = 1; i <= nums.length; i++)
            sum[i] = sum[i - 1] + nums[i - 1];
    }

    public int sumRange(int i, int j) {
        return sum[j + 1] - sum[i];
    }

    /**
     * update某一個元素的時候,整個數組也會發生變化。
     *
     * @param index
     * @param val
     */
    public void update(int index, int val) {
        data[index] = val;
        // 重建sum數組,從index+1位置後面的都更新一下
        for (int i = index + 1; i < sum.length; i++)
            sum[i] = sum[i - 1] + data[i - 1];
    }
}

老師提交時這裏是超時的,但是我在LeetCode.com與LeetCode.cn都進行了提交,都沒有發生超時現象。

線段樹添加更新操作。

/**
     * 將index位置的值,更新爲e
     * @param index
     * @param e
     */
    public void set(int index, E e){

        if(index < 0 || index >= data.length)
            throw new IllegalArgumentException("Index is illegal");

        data[index] = e; // index位置換新值
        set(0, 0, data.length - 1, index, e);
    }

    /**
     * 在以treeIndex爲根的線段樹中更新index的值爲e
     * @param treeIndex
     * @param l
     * @param r
     * @param index
     * @param e
     */
    private void set(int treeIndex, int l, int r, int index, E e){

        if(l == r){
            tree[treeIndex] = e;
            return;
        }
        // 找index對應的葉子
        int mid = l + (r - l) / 2;
        // treeIndex的節點分爲[l...mid]和[mid+1...r]兩部分

        int leftTreeIndex = leftChild(treeIndex);
        int rightTreeIndex = rightChild(treeIndex);
        if(index >= mid + 1)
            set(rightTreeIndex, mid + 1, r, index, e);
        else // index <= mid
            set(leftTreeIndex, l, mid, index, e);

        tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]);
    }

這個過程和之前二分搜索樹的更新很像,實際就是在線段樹中找index這個位置在哪邊,究竟是左子樹,還是右子樹。二分搜索樹中我們比較的是key和當前元素相應的大小關係。線段樹中看的是index對於當前所處區間,劈成兩半之後在哪一半。

index位置改變了 tree[treeIndex] = merger.merge(tree[leftTreeIndex], tree[rightTreeIndex]);

307號問題

package cn.mtianyan.leetcode_307;

import cn.mtianyan.segment.SegmentTree;

class NumArray2 {
    private SegmentTree<Integer> segTree;

    public NumArray2(int[] nums) {

        if(nums.length != 0){
            Integer[] data = new Integer[nums.length];
            for(int i = 0 ; i < nums.length ; i ++)
                data[i] = nums[i];
            segTree = new SegmentTree<>(data, (a, b) -> a + b);
        }
    }

    public void update(int i, int val) {
        if(segTree == null)
            throw new IllegalArgumentException("Error");
        segTree.set(i, val);
    }

    public int sumRange(int i, int j) {
        if(segTree == null)
            throw new IllegalArgumentException("Error");
        return segTree.query(i, j);
    }
}
img_5be98c14673c716196f61472bf166258.jpe

可以看到使用線段樹,時間是變短了的。O(logn) 原本的是 O(n)

img_84d61477726349a9e3ff5707fef07a8b.jpe

對於線段樹來說,更新和查詢操作都可以在O(logn)時間複雜度內完成,創建的過程其實是O(n)的複雜度,或者說是O(4n)的複雜度,4n空間,每一個空間賦值。

數據動態的更新, 然後查詢兩種操作,很適合用線段樹。這是一種高級數據結構,面向競賽。

更多與線段樹相關話題

不參加算法競賽,線段樹不是一個重點。

線段樹雖然不是一棵完全二叉樹,但是可以看做是一棵滿二叉樹,進而使用數組來存儲。這與之前講的堆是一致的。

理解樹這種結構,節點存儲的內容表示的意義不一樣,左右子樹表示什麼意思。賦予結構合理定義,高效處理特殊問題。

創建線段樹,查詢線段樹,更新線段樹。 遞歸之後,還要融合,實際是一種後序遍歷的思想。

對於一個區間進行更新。

將[2,5]區間中所有元素+3

通過logn找到關注的區間,找到兩個節點,對於節點進行更新。

img_9e1a47d63a4a9bef78025a0180adde9b.jpe

如果是求和就要加6,因爲每個節點中的兩個元素都要加3.祖輩節點都要進行更新,葉子節點也要更新。

img_de07396631fef1bcd3373bea7ea69ede.jpe

如果對於葉子節點也進行更新,那麼這將是一個O(n)複雜度的操作。

懶惰更新,懶惰傳播

動態數組的縮小容量操作也用過。使用lazy數組記錄末更新的內容.下一次再訪問到該節點時,如果是lazy數組中的將其更新,再進行操作。

更新區間時依然是logn,查詢lazy數組是否存在該節點。老師會提供相應的補充代碼

二堆線段樹

我們目前是一個一維的線段樹。

img_9facb41c9049a7f4546c97ba3fe2923f.jpe

擴展成二維,矩陣分塊分成四塊。每個節點有四個孩子,每個孩子又是一個更小的矩陣。

img_547073a217bfd486d96a75b790e19321.jpe

更高維數據依然可以同理可得,大數據單元拆成小的。

動態線段樹

數組4n的存儲空間,鏈式的動態線段樹。

節點類:區間左邊界右邊界,左右孩子,自己的值。 要處理節點很多,可以使用鏈式的動態線段樹。

數據量太大,我們根據需求,動態創建動態線段樹。

img_e4918673fccb08f1c989ddcaa71df314.jpe

比如我們只關注5-16時,可以如上圖實現。

區間操作相關另外一個重要數據結構 樹狀數組 Binary Index Tree(算法競賽常客)

區間相關的問題 RMQ Range Minimum Query 其他好方法也都可以解決該問題。

下一章繼續看一種全新樹結構,奇特的n叉樹,可以快速處理字符串相關問題,字典樹。