[資料結構] 劃分樹

Peppa_Even_Pig發表於2024-08-09

介紹

劃分樹,一種資料結構,和線段樹很像,常用來解決求區間第 $ k $ 小的問題,不支援修改,時間複雜度:建樹 $ \Theta(n \log n) $ + 單次查詢 $ \Theta(\log n) $,空間複雜度 $ \Theta(n \log n) $,在這種問題及其擴充套件問題上具有優良的效能,但其它問題就凸顯出其侷限性;

思想

劃分樹主體思想是快排 + 線段樹,可以說把它倆揉一塊就成了劃分樹;

類似線段樹,劃分樹也是每次將區間分成左右兩個子區間,這樣就方便了我們遞迴求解;

劃分樹,顧名思義,就是把原序列不斷劃分的一種資料結構,對於其需要解決的求區間第 $ k $ 小的問題,它的解決方法是:對於每個樹上的節點,儲存其管轄範圍內(不妨設為 $ [L, R] $)所有的元素,這些元素的特點是:順序和原陣列的輸入順序相同,但這些元素都出現在,且只能出現在原陣列排好序後的 $ [L, R] $ 中(就是第 $ L $ 小到第 $ R $ 小);

這樣就保證了不會更改原序列的位置,從而方便查詢;

具體實現

需要維護的東西:

設當前節點所管轄的範圍是 $ [L, R] $, $ mid = \lfloor \frac{L + R}{2} \rfloor $;

  1. $ tr[lev][i] $ :用於儲存樹的結構,第一維是級數(也就是現在遞迴到的層數),從 $ 0 $ 開始,第二維儲存了當前層的元素(和上面說的一樣),特殊的,當層數為 $ 0 $ 時,儲存的是原序列(未排序的);

  2. $ tole[lev][i] $ :表示在 $ [L, i] $ 這段區間中,被分到左子區間的數有多少(這是為了查詢而維護的)。可以發現,這本質上是一個 $ DP $ 陣列,可以用 $ DP $ 的思想去維護一下;

  3. $ a[i] $ :表示原陣列排好序的陣列;

以這道題為例:POJ 2761 Feed the dogs

也可以從 $ Luogu $ 上找到(連結 $ from $ hzoi_ShadowLuogu P1533 可憐的狗狗

但貌似Luogu的題解區裡很少有劃分樹的做法呢

求區間第 $ k $ 小;

首先,進行輸入與排序;

cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		cin >> tr[0][i];
		a[i] = tr[0][i];
	}
	sort(a + 1, a + 1 + n);

然後是建樹操作;

對於建樹,根據前面的思想,我們要讓左子區間的所有數非嚴格小於右子區間的所有數,顯然我們要讓小於中位數的數去左區間,大於中位數的數去右區間,對於中位數本身,我們根據左子區間的區間長度以及現有的數的個數分類討論;

怎樣找中位數呢?

別忘了我們有一個排好序的陣列 $ a $,那麼中位數其實就是 $ a[mid] $;

有了這些,建樹就好辦了;

對於具體過程,我們可以從左到右掃一遍整個區間,如有小於中位數的,下放到左子區間,同時更新 $ tole $ 陣列($ tole[lev][i]++ $) ,大於中位數的,下放到右子區間,等於中位數的,判斷一下左子區間還有沒有位置,如有,則放到左子區間,否則放到右子區間;

最後如果到葉子節點,回溯即可;

完事,時間複雜度 $ T(n) = 2T( \frac{n}{2} ) + \Theta(n) = \Theta(n \log n) $ (貌似是這麼分析,今天剛跟學長學的。。。)

建樹部分的程式碼:

void bt(int lev, int l, int r) {
	if (l == r) return;
	int mid = (l + r) >> 1;
	int midl = mid - l + 1; //代表現在左子區間還有多少空位置能放數,初始化為左子區間長度;
	for (int i = l; i <= r; i++) {
		if (tr[lev][i] < a[mid]) midl--;
	}
	int subl = l;
	int subr = mid + 1;
	for (int i = l; i <= r; i++) {
		if (i == l) tole[lev][i] = 0;
		else tole[lev][i] = tole[lev][i - 1]; //繼承上一步的結果;
		if (tr[lev][i] < a[mid] || tr[lev][i] == a[mid] && midl > 0) {
			tr[lev + 1][subl++] = tr[lev][i]; //給左子區間;
			tole[lev][i]++;
			if (tr[lev][i] == a[mid]) midl--;
		} else {
			tr[lev + 1][subr++] = tr[lev][i]; //給右子區間;
		}
	}
	bt(lev + 1, l, mid);
	bt(lev + 1, mid + 1, r);
}

然後是查詢操作;

設查詢的區間為 $ [l, r] $;

對於查詢,採用遞迴,如果 $ k $ 在左子區間,則遞迴左子區間,否則遞迴右子區間,最後到葉子節點返回答案;

這時候,我們維護的 $ tole $ 陣列就派上用場了;

具體地,在當前節點時,我們要判斷下一步到底是遞迴左邊還是右邊,那麼我們就判斷 $ tole[lev][r] - tole[lev][l - 1] $ (其實就是 $ [l, r] $ 中被分到左子區間的元素個數,不妨記為 $ tolef $)與 $ k $ 的大小關係,若後者比前者大,則遞迴右區間,否則遞迴左區間;

明確了遞迴區間後,我們要考慮詢問區間和 $ k $ 的值是否會改變;

  1. 遞迴到了左子區間;

設 $ [L, l - 1] $ 中放到左子區間的數的個數為 $ lef $,其值為 $ tole[lev][l - 1] $,那麼現在我們的詢問區間就變成了:

左端點:為 $ L + lef $;

右端點:為 $ L + lef + tolef - 1 $;

$ k $ 不變,直接遞迴即可;

  1. 遞迴到了右子區間;

$ lef $ 和 $ tolef $ 的定義不變,那麼現在我們的詢問區間就變成了:

左端點:為 $ mid + 1 $ + $ [L, l - 1] $ 中進入右子區間的數的個數;

$ [L, l - 1] $ 中進入右子區間的數的個數為這段區間的長度 - 進入左子區間的數的個數,即為 $ l - 1 - L + 1 - lef = l - L - lef $;

所以左端點即為:$ mid + l - L - lef + 1 $;

右端點:為 $ mid + 1 $ + $ [L, r] $ 中進入右子區間的數的個數 - $ 1 $;

$ [L, r] $ 中進入右子區間的數的個數為這段區間的長度 - 進入左子區間的數的個數,即為 $ r - L + 1 - lef - tolef $;

所以右端點即為:$ mid + r - L - lef - tolef + 1 $;

此時 $ k $ 變成了 $ k - tolef $,然後遞迴即可;

這樣,查詢就完事了;

int ask(int lev, int l, int r, int L, int R, int k) { //定義和上面寫的一樣;
	if (l == r) return tr[lev][l];
	int mid = (L + R) >> 1;
	int lef, tolef;
	if (l == L) {
		lef = 0;
		tolef = tole[lev][r];
	} else {
		lef = tole[lev][l - 1];
		tolef = tole[lev][r] - lef;
	}
	if (k <= tolef) {
		int newl = L + lef;
		int newr = L + lef + tolef - 1;
		return ask(lev + 1, newl, newr, L, mid, k);
	} else {
		int newl = mid + l - L - lef + 1;
		int newr = mid + r - L + 1 - lef - tolef;
		return ask(lev + 1, newl, newr, mid + 1, R, k - tolef);
	}
}

解決這道題的程式碼:

點選檢視程式碼
#include <iostream>
#include <cstdio>
#include <algorithm>
using namespace std;
int n, m;
int tr[35][200005];
int tole[35][200005];
int a[200005];
namespace divtr{
	void bt(int lev, int l, int r) {
		if (l == r) return;
		int mid = (l + r) >> 1;
		int midl = mid - l + 1;
		for (int i = l; i <= r; i++) {
			if (tr[lev][i] < a[mid]) midl--;
		}
		int subl = l;
		int subr = mid + 1;
		for (int i = l; i <= r; i++) {
			if (i == l) tole[lev][i] = 0;
			else tole[lev][i] = tole[lev][i - 1];
			if (tr[lev][i] < a[mid] || tr[lev][i] == a[mid] && midl > 0) {
				tr[lev + 1][subl++] = tr[lev][i];
				tole[lev][i]++;
				if (tr[lev][i] == a[mid]) midl--;
			} else {
				tr[lev + 1][subr++] = tr[lev][i];
			}
		}
		bt(lev + 1, l, mid);
		bt(lev + 1, mid + 1, r);
	}
	int ask(int lev, int l, int r, int L, int R, int k) {
		if (l == r) return tr[lev][l];
		int mid = (L + R) >> 1;
		int lef, tolef;
		if (l == L) {
			lef = 0;
			tolef = tole[lev][r];
		} else {
			lef = tole[lev][l - 1];
			tolef = tole[lev][r] - lef;
		}
		if (k <= tolef) {
			int newl = L + lef;
			int newr = L + lef + tolef - 1;
			return ask(lev + 1, newl, newr, L, mid, k);
		} else {
			int newl = mid + l - L - lef + 1;
			int newr = mid + r - L + 1 - lef - tolef;
			return ask(lev + 1, newl, newr, mid + 1, R, k - tolef);
		}
	}
}
int main() {
	cin >> n >> m;
	for (int i = 1; i <= n; i++) {
		cin >> tr[0][i];
		a[i] = tr[0][i];
	}
	sort(a + 1, a + 1 + n);
	divtr::bt(0, 1, n);
	int l, r, k;
	for (int i = 1; i <= m; i++) {
		cin >> l >> r >> k;
		cout << divtr::ask(0, l, r, 1, n, k) << endl;
	}
	return 0;
}

感覺這東西貌似沒啥用,但還是學了。。。

相關文章