二維線段樹專題

二維線段樹

      二維線段樹最主要用於平面統計問題。類似一維線段樹,最經典的就是求區間最值(或區間和),推廣到二維,求得就是矩形區域最值(或矩形區域和),對於矩形區域和,二維樹狀數組更加高效,而矩形區域最值,更加高效的方法是二維RMQ,但是二維RMQ不支持動態更新,所以二維線段樹還是有用武之地的。
      如果對一維線段樹已經駕輕就熟,那麼直接來看下面兩段對比,就可以輕鬆理解二維線段樹了。
      一維線段樹是一棵二叉樹,樹上每個結點保存一個區間和一個域,非葉子結點一定有個兒子結點,兒子結點表示的兩個區間交集爲空,並集爲父結點表示的區間;葉子結點的表示區間長度爲1,即單位長度;域則表示了需要求的數據,每個父結點的域可以通過兩個兒子結點得出。
      二維線段樹是一棵四叉樹,樹上每個結點保存一個矩形和一個域,非葉子結點一定有二或四 個兒子結點,兒子結點表示的四個矩形交集爲空,並集爲父結點表示的矩形;葉子結點表示的矩形長寬均爲1,域則表示了需要求的數據,每個父結點的域可以通過四個兒子結點得出。
      一個4x3的矩形,可以用圖1的樹形結構來表示,給每個單位方塊標上不同的顏色易於理解。
圖1
       圖1中,每個葉子結點的單位面積爲1,非葉子結點表示的矩形進行四分後,如圖2所示,四個子矩形分別表示的是兒子結點表示的矩形區域。特殊的,當矩形面積爲1 X H或者W X 1的時候,變成了一維的情況,這就是爲什麼有些結點有四個子結點,而有些結點只有兩個子結點的原因了。
圖2
       基本結構已經清楚了,按照慣例,要介紹一下時空複雜度。
       首先來看空間複雜度,一個 W x H 的矩形,根結點表示的矩形是W x H,令N = max{W, H},那麼這棵二維線段樹的深度D = log2(N)+1,當這棵樹是一棵滿四叉樹的時候,結點數達到最大值,根據等比數列求和公式,最大情況的結點數爲 (4^D - 1) / 3。更加直觀的,當N = W = H = 2^k, 必定是一棵滿四叉樹,結點數爲(4^D-1) / 3 = ( 4^(k+1) - 1 ) / 3 = (2^(2k+2)-1) / 3,去掉分子上的零頭1,約等於(4/3)*N^2, 所以空間複雜度爲O(N^2)。
       再來看時間複雜度,需要分情況:
       建樹:建樹時必定訪問到每個結點,而且都是訪問一次,所以建樹的複雜度爲O(N^2);
       單點更新:每次更新一個單位矩形的值,訪問時只會訪問從樹的根結點到葉子結點的一條路徑,所以單點更新的複雜度爲O( log2(N) )。
       區域詢問:情況類似一維的區間詢問。從根結點開始拆分區域,當詢問區域完全覆蓋結點區域時,不需要遞歸往下走,總體複雜度是O( log2(N) * log2(N) )  ? 這裏還是打個問號先,具體是一個log還是兩個log記不清了,找個時間證明一下,可以肯定的是,不會退化成O(N)。
     
       接下來看看每個樹結點需要保存一些什麼信息, 以最值爲例,除了保存最值以外,有可能需要知道這個最值在整個矩形的具體座標,所以我們的最值信息dataInfo需要保存三個信息,posx和posy表示最值的具體位置,val保存最值,由於二維線段樹的空間複雜度爲O(N^2),所以座標信息不會很大,爲了盡力節省內存,座標值用short來存即可。最值val的話看實際情況而定,一般用int就夠了。
       treeNode則是線段樹結點的結構體,其中成員由dataInfo對應的最值和son[4]表示的子結點編號組成,我們的線段樹結點採用靜態結點,即每個線段樹結點都對應靜態數組 nodes中的某個元素,便於通過編號在O(1)的時間內獲取到對應樹結點的指針,son[4]記錄了四個子結點在nodes中的下標。仔細觀察可以發現,如果對於一棵線段樹,保證所有結點編號都連續的情況下,如果父結點的編號確定,那麼子結點的編號也就確定了。例如,根結點編號爲1,那麼四個子結點編號爲2、3、4、5,父結點編號爲2,四個子結點的編號爲6、7、8、9,根據數學歸納法,當結點編號爲p,那麼它的四個子結點編號爲(4*p-2+x),其中x取值爲[0, 3],所以四個子結點的編號信息可以通過O(1)的時間計算出來,就不用存儲在線段樹結點上了,大大節省了內存開銷。
結構定義代碼如下:

#define LOGN 10
#define MAXN (1<<LOGN)
#define MAXNODES 3*(1<<(2*LOGN)/+ 100)
#define son(x) (p*4-2+x)
// 最值信息
struct dataInfo {
    short posx, posy;
    int val;
    dataInfo() {
        posx = posy = val = -1;
    }
    dataInfo(short _posx, short _posy, int _val) {
        posx = _posx;
        posy = _posy;
        val = _val;
    }
};
// 線段樹結點信息
struct treeNode {
    // int son[4]
    dataInfo maxv, minv;
    void reset() {
        maxv = dataInfo(0, 0, INT_MIN);
        minv = dataInfo(0, 0, INT_MAX);
    }
}nodes[ MAXNODES ];

// 注意,這裏需要返回指針,因爲在後續使用中需要對這個結點的信息進行改變,如果返回對象的話只是一個copy,不會改變原結點的內容
treeNode* getNode(int id) {
    return &nodes[id];
}

       這時候,我們發現線段樹的結點上還缺少一個很重要的信息,因爲每個結點表示了一個矩形區域,那爲什麼沒有存下這個矩形區域呢?毋庸置疑,也是爲了節省內存,在接下來的區域查詢、單點更新的介紹中會講到,這個區域其實在每次遞歸的時候是作爲傳參進入函數內部的,結點編號確定,矩形區域就確定了,所以沒必要存儲在結點中。
       爲了處理方便,我們還需要封裝一個區間類(由於矩形可以表示成兩個不同維度的區間,所以這裏只需要封裝一個區間類即可,矩形類的操作沒有區間內那麼簡單,一目瞭然),它支持一些基本操作,如判交、判包含、取左右半區間等等、,具體代碼如下:

struct Interval {
    int l, r;
    Interval() {}
    Interval(int _l, int _r) {
        l = _l;
        r = _r;
    }
    // 區間中點 
    int mid() {
        return (+ r) >> 1;
    }
    // 區間長度 
    int len() {
        return r - l + 1;
    }
    // 左半區間 
    Interval left() {
        return Interval(l, mid());
    }
    // 右半區間 
    Interval right() {
        return Interval(mid()+1, r);
    }
    // 區間判交
    bool isIntersectWith( Interval& tarI ) {
        return !( l > tarI.|| r < tarI.);
    } 
    // 區間判包含
    bool isInclude( Interval& tarI ) {
        return l <= tarI.&& tarI.<= r;
    }
    bool in (int v) {
        return l <= v && v <= r;
    }
};
        那麼接下來就是建樹了,建樹就是遞歸生成結點的過程,這裏的生成並非創建,原因是因爲我們的結點是靜態的。每建一次樹,只是把所有線段樹結點的信息進行一次重置,對於一個W x H的矩形,假定它的兩個對角線座標爲(1, 1) - (W, H),那麼我們首先將它切割成四個矩形,令WM = (1+W)/2, HM = (1+H)/2對角線座標分別爲:
        (1, 1) - (WM, HM)
        (WM+1, 1) - (W, HM)
        (1, HM+1) - (WM, H)
        (WM+1, HM+1) - (W, H)
        如圖3所示,四個切割完後的矩形如下:
圖3
        這個切割過程是遞歸進行的,當某次切割的矩形爲單位面積的時候,即爲遞歸出口。當然還有一種情況,就是當某次切割後的矩形的某一維爲1,而另一維大於1時,這裏假設W = 1,H > 1,那麼繼續切割時會發現WM+1 > W,導致 (WM+1, 1) - (W, HM) 和 (WM+1, HM+1) - (W, H) 這兩個矩形面積爲負,所以在遞歸入口處需要判斷是否有某一維的右端點小於左端點,如果有,這種矩形是不合法的,不能做爲線段樹的結點,不需要繼續往下遞歸創建。
        
建樹代碼如下:
void build_segtree(int p, Interval xI, Interval yI) {
    // 空矩形(右端點小於左端點)
    if(xI.len() <= 0 || yI.len() <= 0) {
        return ;
    }
    treeNode* now = getNode(p);
    // 結點初始化
    now->reset();
    // 單位矩形
    if(xI.len() == 1 && yI.len() == 1) {
        return ;
    }
    build_segtree( son(0), xI.left(), yI.left() );
    build_segtree( son(1), xI.right(), yI.left());
    build_segtree( son(2), xI.left(), yI.right() );
    build_segtree( son(3), xI.right(), yI.right());   
}
        其中p爲當前線段樹結點的編號,son(0) - son(3)則是作爲子結點編號穿參進入切割後的矩形getNode(p)用於獲取編號爲p的結點的結構指針,建樹的目的就是爲每個結點進行初始化,如果求的是最大值,那麼將結點上的域都設成 INT_MIN ( 只要比所有接下來要插入的值小即可 ),如果求的是最小值,那麼結點上的域都設成 INT_MAX( 只要比所有接下來要插入的值大即可 )。
        建樹完畢後,這些結點都有了一個初始值,那麼接下來就是需要在矩形的每個點插入一個值,然後更新線段樹上每個結點的最值信息了。
        插入過程和建樹過程的思想是一致的,同樣是將矩形切割成四份,因爲插入的是一個點,所以不可能同時存在於任意兩個矩形中(因爲是個矩形是互不相交的),所以每次四分只會選擇一個矩形進行插入,爲了讓代碼簡介,我們還是先將矩形進行切割,然後模擬所有的矩形都能夠插入,然後在遞歸入口處判斷該點是否在矩形區域中,如果不在矩形區域直接返回。這樣,當遞歸到單位矩形的時候,這個點的座標一定是和矩形的座標重合的,就可以直接更新該矩形所在的線段樹結點的域信息了,更新完這個單位矩形還不夠,還需要將信息傳遞給它的父結點,因爲每次更新只有一個點,所以改變的結點只有從這個單位矩形所在結點到根結點的一條路徑上的結點,所以複雜度是樹的深度,即O(log2(N))。
插入結點(單點更新)代碼如下:
bool insert_segtree(int p, Interval xI, Interval yI, int x, int y, int val) {
    if(xI.len() <= 0 || yI.len() <= 0) {
        return false;
    }
    if( !xI.in(x) || !yI.in(y) ) {
        return true;
    }
    treeNode *now = getNode(p);
    if(xI.len() == 1 && yI.len() == 1) {
        now->maxv = now->minv = dataInfo(x, y, val);
        return true;
    }
    bool isvalid[4];
    isvalid[0] = insert_segtree( son(0), xI.left(), yI.left(), x, y, val );
    isvalid[1] = insert_segtree( son(1), xI.right(), yI.left(), x, y, val );
    isvalid[2] = insert_segtree( son(2), xI.left(), yI.right(), x, y, val );
    isvalid[3] = insert_segtree( son(3), xI.right(), yI.right(), x, y, val ); 
   
    // 通過四個子結點的信息更新父結點 
    now->maxv = dataInfo(0, 0, MIN_VAL);
    now->minv = dataInfo(0, 0, MAX_VAL);
    int i;
    for(= 0;< 4; i++) {
        if( !isvalid[i] ) continue;
        treeNode *sonNode = getNode(son(i));
        now->maxv = sonNode->maxv.val > now->maxv.val ? sonNode->maxv : now->maxv;
        now->minv = sonNode->minv.val < now->minv.val ? sonNode->minv : now->minv;
    }
    return true; 
}
       可以發現,插入的核心代碼和建樹是一致的,但是這裏的插入操作,返回了一個值,表示的是當前插入的線段樹結點p是否合法,因爲我們在插入的時候無論如何都會將矩形切割成四份,沒有去考慮上文中提到的有一維爲1的情況,父結點的域值是通過子結點回溯上來進行更新的,如果子結點不合法,不應該作爲更新的依據,所以作爲父結點,需要知道哪些結點是不合法的。
       有了更新,當然需要詢問,沒有詢問,更新也就失去了意義。
       詢問一般是區域詢問(單點詢問就沒必要用線段樹了),如圖4所示,在一個4 x 3的矩形中,需要詢問灰色的矩形(3 x 2的矩形,以下統一稱爲詢問矩形)中最大的數是什麼。首先來說說原理,同樣,和建樹以及插入操作一樣,我們首先不斷將矩形進行切割,每當訪問到一個結點的時候將詢問矩形和結點矩形進行判交測試,一共有以下幾種情況:
       1、詢問矩形 和 結點矩形 沒有交集 (圖4中所有白色的葉子結點);
       2、詢問矩形 完全包含 結點矩形 (圖4中根結點的第三個子結點);
       3、詢問矩形 不完全包含 結點矩形,並且存在交集(圖4中根結點的第一、二、四個子結點);
       首先我們需要保存一個全局最大值信息,這個信息可以通過引用的方式傳遞到函數中去,在遞歸的過程中不斷迭代更新;
       對於第1、2兩種情況都是不需要繼續往下遞歸的,第1種情況不會影響目前的最大值,第2種情況需要將結點上的最大值和全局最大值進行比較,保留大的那個;第三種情況有交集,所以我們需要將矩形繼續分割,直到出現第1或者第2種情況爲止,而且一定是可以出現的。
圖4
區域詢問代碼如下:
// query_type 0 最大值   1最小值
void query_segtree(int p, Interval xI, Interval yI, Interval tarXI, Interval tarYI, dataInfo& ans, int query_type) {
    if(xI.len() <= 0 || yI.len() <= 0) {
        return ;
    }
   
    if( !tarXI.isIntersectWith(xI) || !tarYI.isIntersectWith(yI) ) {
        return ;
    }
    treeNode *now = getNode(p);
   
    // 最大值優化 
    if(query_type == 0 && ans.val >= now->maxv.val) {
        return ;
    }
    // 最小值優化 
    if(query_type == 1 && ans.val <= now->minv.val) {
        return ;
    }
       
    if(tarXI.isInclude(xI) && tarYI.isInclude(yI)) {
        if(query_type == 0) {
            ans = now->maxv;
        }else {
            ans = now->minv;
        }
        return ;
    }
    query_segtree( son(0), xI.left(), yI.left(), tarXI, tarYI, ans, query_type );
    query_segtree( son(1), xI.right(), yI.left(), tarXI, tarYI, ans, query_type );
    query_segtree( son(2), xI.left(), yI.right(), tarXI, tarYI, ans, query_type );
    query_segtree( son(3), xI.right(), yI.right(), tarXI, tarYI, ans, query_type ); 
}    
        這裏加入了一個query_type,表示求的是最大值還是最小值,因爲有的時候既需要知道最大值,又需要知道最小值,爲了簡化函數個數引入的一個變量。這裏我們發現,當求最大值的時候,如果 詢問矩形 和 結點矩形 是有交集並且並非完全包含的情況下,如果結點最大值比全局最大值(以上代碼中的ans即全局最大值信息)還小,那麼沒必要再往下遞歸了,因爲遞歸下去的最大值不會比當前結點的最大值大,這個優化很重要。
        以上就是二維線段樹通過三個函數實現求區域最值的全部內容,建樹(build_segtree)、插入(insert_segtree) 詢問(query_segtree),其實當我們將這三個函數中的 yI 這個區間變成[1, 1]的時候,就變成了一維線段樹的模板了。
發佈了83 篇原創文章 · 獲贊 63 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章