1. 程式人生 > 其它 >樹狀陣列學習筆記

樹狀陣列學習筆記

前置芝士

lowbit 函式。

lowbit(\(n\)) 定義為非負整數 \(n\) 在二進位制表示下最低位的 \(1\) 及其後邊所有的 \(0\) 構成的數值。例如 \(n=10\) 的二進位制表示為 \((1010)_2\),則 \(lowbit(10)=2=(10)_2\)。下面來推導一下 \(lowbit\) 的公式。

\(n > 0\)\(n\) 在二進位制下的第 \(k\) 位是 \(1\),其後邊全部為 \(0\)

首先把 \(n\) 取反(~), 第 \(k\) 位變為 \(0\),第 \(0\)~\(k-1\) 位都是 \(1\)。再令 \(n=n+1\),此時因為進位,第 \(k\)

位變為 \(1\),第 \(0\)~\(k-1\) 位變為 \(0\)

可以發現,對於 \(n\) 取反加 \(1\) 後。 \(n\) 的第 \(k+1\) 位到最高位都與原來相反,且只有第 \(k\) 位唯一。根據“與”運算中,只有兩個數這一位都為 \(1\),才會得到 \(1\),那麼 \(n\) & ~\(n+1\) 顯然就只有第 \(k\) 位為 \(1\),而在補碼錶示下,~\(n=-n-1\)。那麼就可以得到:

\(lowbit(n)=n\) & (~\(n+1\)\(=n\) & \((-n)\)

摘自 lyd 大佬的《演算法競賽進階指南》。

樹狀陣列能做什麼?

求字首和以及單點修改。時間複雜度都是 \(O(\log n)\)

普通的陣列求字首和是 \(O(n)\),單點修改是 \(O(1)\)

普通的字首和陣列和是 \(O(1)\),單點修改是 \(O(n)\)

所以在同時要求求字首和和單點修改時,使用樹狀陣列的時間複雜度會更優。

樹狀陣列的思想

根據任意正整數關於 2 的不重複次冪的唯一分解性質(也就是說二進位制數和十進位制數一一對應),可以把一個正整數 \(x\) 進行二進位制分解,得到:

\(x=2^{i_1}+2^{i_2}+...+2^{i_m}\)

不妨設 \(i_1 > i_2 >...> i_m\),進一步地,區間 \([1,x]\)

可以分成 \(\log x\)\(m\))個子區間:

1.長度為 \(2^{i_1}\)的子區間 \([1,2^{i_1}]\)

2.長度為 \(2^{i_2}\)的子區間 \([2^{i_1}+1,2^{i_1}+2^{i_2}]\)

3.長度為 \(2^{i_3}\)的子區間 \([2^{i_1}+2^{i_2}+1,2^{i_1}+2^{i_2}+2^{i_3}]\)

……

m.長度為 \(2^{i_m}\)的子區間 \([2^{i_1}+2^{i_2}+...+2^{i_{m_1}}+1,2^{i_1}+2^{i_2}+...+2^{i_m}]\)

這些子區間的共同特點是:若區間結尾 \(R\),則區間長度就等於 \(R\) 在二進位制下最小的2的冪次,也就是 lowbit(\(R\))。

例如當 \(x=7\) 時,\(x=7=2^2+2^1+2^0\),區間 \([1,7]\) 也就會分成 \([1,4],[5,6],[7,7]\) 三個子區間。如果把區間的端點用二進位制表示,那麼就是 \([001,100],[101,110],[111,111]\)。也就符合了上面提到的特點。

並且可以發現, \(R_i-1=R_i-lowbit(R_i)=L_i-1\)

那麼就可以得出列舉區間 \([1,x]\) 分成的所有子區間的程式碼:

while(x)
{
	printf("%d %d\n",x-lowbit(x)+1,x);
	x-=lowbit(x);
}

以上就是樹狀陣列的核心思想

如果把區間 \([x-lowbit(x)+1,x]\) 內所有的數的和儲存到 \(C[x]\) 中。同時,如果把 \(c[x]\) 陣列之間的關係用邊相連,那麼可以發現 \(c[x]\) 具有樹形結構。如當 \(N=16\) ,儲存區間 \([1,N]\) 時,就會得到 \(c[x]\) 的關係如下圖所示:

同時可以發現,該結構中滿足一些性質:

1.每個內部節點 \(c[x]\) 儲存以它為根的子樹中所有葉子節點的和。

2.除樹根外,每個節點 \(c[x]\) 的父節點就是 \(c[x+lowbit(x)]\)

3.樹的深度為 \(\log N\)

這裡舉的是 \(N\) 為 2 的整次冪的特殊情況。對於一般的情況,最終得到的 \(c[x]\) 也是一個滿足上述性質的森林結構

那麼求 \([1,x]\) 的區間和,程式碼就可以這樣寫:

int query(int x)
{
	int res=0;
	while(x)
	{
		res+=c[x];
		x-=lowbit(x);
	}	
	return res;
}

對於單點修改操作,即將 \(a[x]\) 加上 \(k\)。根據上述樹形結構,只有節點 \(c[x]\) 和所有的祖先節點中包含 \(a[x]\) ,只需對這些節點進行修改即可。而根據性質 \(2\),就可以得到單點修改操作的程式碼:

void updata(int x,int k)
{
	while(x<=n)
	{
		c[x]+=k;
		x+=lowbit(x);
	}
}

於是就可以通過這道 樹狀陣列 1

而對於 樹狀陣列 2 這道題。題目中雖然是區間修改,但是是單點查詢。於是就可以運用差分的思想。在樹狀陣列中記錄差分陣列的字首和,查詢的時候直接輸出差分陣列的字首和加上該點原來的數值即可。

所以樹狀陣列的程式碼是資料結構中最簡單的,沒有之一。

樹狀陣列與逆序對

對於求逆序對數量,可以用歸併排序直接進行求解。但是也可以用樹狀陣列來求解。

對於一個序列 \(a\) ,在它的數值範圍上建立一個樹狀陣列。

倒序掃描給定的序列 \(a\),對於每一個數 \(a[i]\) ,在樹狀陣列中查詢 \([1,a[i]-1]\)的字首和,即與 \(a[i]\) 構成的且 \(a[i]\) 在前的逆序對數量。

每次掃描完以後,就把這個數新增到樹狀陣列中,注意是把樹狀陣列中表示 \(a[i]\) 的這個子節點 \(+1\),而不是 \(+a[i]\)。也就是 \(updata(a[i],1)\)

最終得到的就是總的逆序對數量。時間複雜度為 \(O((N+M)\log M)\),其中 \(M\)
\(a_i\) 的範圍大小(一般情況下就是最大的 \(a_i\))。但是需要注意,如果 \(M\) 太大,那麼這樣做的時間複雜度就太高了。當然也可以離散化,但是離散化本身就包含了排序,所以當資料範圍太大時還是直接用歸併排序就好了。

應用——康託展開

一個簡單的整數問題2

問題1就是樹狀陣列模板2,這裡就直接省略了。

題意

給定一個整數序列,要求支援區間修改和區間查詢兩種操作。

思路

可以發現,這次的兩個操作都是區間,那麼如果按照單點一個個修改過去肯定會 TLE。所以說需要更優的做法。

如果按照前一題的做法,用樹狀陣列維護差分陣列。那麼要求修改後的字首和 \(c[x]\) 可以這樣推導:

$c[x]=\sum_{i=1}{x}\sum_{j=1}{i}b[j] $。

對於 \(b[i]\) ,在公式中不容易看出端倪,於是可以畫出來,得:

\(\begin{bmatrix} b_1& & & & \\ b_1& b_2& & & \\ b_1& b_2& b_3& & \\ ... \\ b_1& b_2& b_3& ...&b_n \end{bmatrix} \)

如果再將這個矩陣補齊,就可以得到:

\( \begin{bmatrix} {\color{Red} b_1}& {\color{Red} b_2} & {\color{Red} b_3}&...&{\color{Red} b_5}\\ b_1& {\color{Red} b_2} & {\color{Red} b_3}&...&{\color{Red} b_5} \\ b_1& b_2& {\color{Red} b_3}&... &{\color{Red} b_5} \\ b_1& b_2& b_3& &{\color{Red} b_5} \\ ... \\ b_1& b_2& b_3& ...&b_n \end{bmatrix} \)

於是 \(\sum_{i=1}^{n}\sum_{j=1}^{i}b[j]=(b[1]+b[2]+..b[n])*(n+1)-b_1*1-b_2*2-...-b_n*n\)

於是就可以建立兩個樹狀陣列,分別維護 \(b[i]\) 的字首和,\(b[i]*i\) 的字首和。這樣就可以通過本題了。

code:

#include<cstdio>
using namespace std;
const int N=1e5+10;
#define int long long
int n,m,a[N],c1[N],c2[N];
int lowbit(int x){return x&(-x);}
void updata(int c[],int x,int k)
{
	while(x<=n)
	{
		c[x]+=k;
		x+=lowbit(x);
	}
}
int query(int c[],int x)
{
	int res=0;
	while(x)
	{
		res+=c[x];
		x-=lowbit(x);
	}
	return res;
}
int sum(int x){return query(c1,x)*(x+1)-query(c2,x);}
signed main()
{
	scanf("%lld%lld",&n,&m);
	for(int i=1;i<=n;i++)
	{
		scanf("%lld",&a[i]);
		int b=a[i]-a[i-1];
		updata(c1,i,b);
		updata(c2,i,i*b);
	}
	while(m--)
	{
		char op[2];
		int l,r,d;
		scanf("%s%lld%lld",op,&l,&r);
		if(op[0]=='Q') printf("%lld\n",sum(r)-sum(l-1));
		else
		{
			scanf("%lld",&d);
			updata(c1,l,d),updata(c2,l,l*d);
			updata(c1,r+1,-d),updata(c2,r+1,-(r+1)*d);
		}
	}
	return 0;
}

謎一樣的牛

題意

\(n\) 頭身高從 \(1\)~\(n\) 的奶牛排成一列,每頭奶牛隻知道自己前面有多少隻奶牛比自己矮。求每隻奶牛具體的身高。

思路

首先可以注意到,對於最後一頭牛,如果他前面有 \(a_n\) 只奶牛比他矮,那他的身高就應該是 \(a_n+1\)。同時由於每隻奶牛的身高互不相同,前面的奶牛的身高就不可能是 \(a_n+1\)了。於是就可以從後往前推奶牛的身高。

而對於第 \(i\) 只奶牛,如果他後面的 \(n-i\) 只奶牛的身高都已經確定,並且前面還有 \(a_i\) 只奶牛比他矮,那麼這隻奶牛的身高就是在除去已經確定的身高後,可能的身高中的第 \(a_i+1\) 小數。

而如果我們維護一個 \(01\) 序列,其中第 \(i\) 個數為 \(1\) 表示 \(i\) 這個身高還沒有確定的奶牛。而如果我們統計一下前 \(i\) 個數的字首和 \(s_i\),那麼身高 \(i\) 在沒有確定的身高內排第 \(s_i\) 小。同時顯然這個字首和具有單調性,那麼就可以用二分的方法快速找出在未確定的身高中第 \(k\) 大的數,同時為了快速查詢字首和。就可以用樹狀陣列維護這個 \(01\) 序列。

最終的時間複雜度就是 \(O(n\log^2 n)\)

code:

#include<cstdio>
using namespace std;
const int N=1e5+10;
int c[N],a[N],n,ans[N];
int lowbit(int x){return x&(-x);}
void add(int x,int k)
{
	while(x<=n)
	{
		c[x]+=k;
		x+=lowbit(x);
	}
}
int query(int x)
{
	int res=0;
	while(x)
	{
		res+=c[x];
		x-=lowbit(x);
	}
	return res;
}
int find(int k)
{
	int l=1,r=n;
	while(l<r)
	{
		int mid=l+r>>1;
		if(query(mid)>=k) r=mid;
		else l=mid+1;
	}
	return l;
}
int main()
{
    scanf("%d",&n);
    add(1,1);
	for(int i=2;i<=n;i++) scanf("%d",&a[i]),add(i,1);	
	for(int i=n;i>=1;i--)
	{
		ans[i]=find(a[i]+1);
		add(ans[i],-1);
	}
	for(int i=1;i<=n;i++) printf("%d\n",ans[i]);
	return 0;
}

[GZOI2017]配對統計

樹狀陣列題的難點在於想到可以用樹狀陣列做。

題意

給定 \(n\) 個正整數,對於一組匹配 \((x,y)\),如果對於任意的 \(i=1,2,⋯,n\),滿足 \(|a_x-a_y| \leq |a_x-a_i|(i \ne x)\)。那麼就稱 \((x,y)\) 為一組好的匹配。

給出 \(m\) 詢問,每次詢問區間 \([l,r]\) 中好的匹配的數量。

記第 \(i\) 次詢問的答案為 \(ans_i\),那麼最終只需輸出 \(\sum_{i=1}^{m} ans_i*i\)

思路

首先觀察題目中對於“好的匹配”的定義,可以發現。對於一個數 \(a_x\) ,能和它組成好的匹配的數 \(a_y\) 一定滿足 \(|a_x-a_y|\) 的值最小,也就是 \(a_y\) 是與 \(a_x\) 相差最小的數。於是就可以想到排序,將原陣列排序後,對於任意的 \(i=2,3,⋯,i-2,i-1\)。能和 \(a_i\) 組成好的匹配的數一定在 \(a_{i-1}\)\(a_{i+1}\) 中(\(a[1]\)\(a[n]\) 需要特判) 。於是就可以在 \(O(n \log n)\) 的時間內求出所有的好的匹配。

然而對於題目中的查詢操作。有一個很 navie 的想法:用樹狀陣列 \(c[x]\) 維護區間 \([1,x]\) 內的配對數量,對於每一個 \((x,y)\)(設 \(x<y\) 的),直接 $ add(y,1) $。在查詢的時候輸出 \(query(r)-query(l-1)\)

可惜這樣的想法是錯誤的,因為在相減的時候只減去了 \(x<l,y<l\) 時的不合法配對,並沒有減去 \(x<l,y>l\) 時的不合法配對。

注意到本題中並未要求支援修改操作,於是可以考慮離線做法

上述錯誤做法出現的問題是並未減去 \(x<l,y>l\) 時的不合法配對。如果將 \(add(y,l)\) 改成 \(add(l,1)\),可以發現當 \(r=n\) 時,\(query(r)-query(l-1)\) 得到的答案就是正確的了,因為此時的 \(query(l-1)\) 中只限制了 \(x<l\),而 \(y \geq l\)的配對也被計算在其中。

但是一旦當 \(r < n\) 時,這樣的做法又是錯誤的,因為此時的 \(query(r)\)\(y\) 可以比 \(r\) 還大。相減時無法減去這些不合法匹配。

那如果在樹狀陣列中不儲存這些不合法匹配呢?

於是可以想到先將配對按照 \(y\) 的大小進行排序,將所有的詢問先記錄下來,再按照右端點 \(r\) 的大小進行排序。在求解每一次詢問時,都只把 \(y \leq r\) 的配對記錄到樹狀陣列中。這樣就避免了 \(r >y\) 的不合法匹配。再將已經記錄到樹狀陣列中的配對的數量減去 \(x \ l\) 的不合法匹配,就可以得到正確答案了。

還有一些實現上的細節見程式碼。

最後,別忘了開 long long

code:

#include<cstdio>
#include<algorithm>
using namespace std;
#define LL long long
const int N=3e5+10;
const int M=3e5+10;
int n,m,c[N];
LL ans;
struct node{
	int val,id;
	bool operator <(const node &t)const{
		return val<t.val;
	}
}a[N];
struct match{
	int l,r;
	bool operator <(const match &t)const{
	    if(r!=t.r)return r<t.r;
	    return l<t.l;
	}
}p[N<<1];//除了最大和最小的數,極端情況下每個數都能有兩個好的匹配,所以陣列大小要*2 
int tot=0;
struct quest{
	int l,r,id;
	bool operator <(const quest &t)const{
	    if(r!=t.r)return r<t.r;
	    return l<t.l;
	}
}q[M];
int max(int a,int b){return a>b?a:b;}
int min(int a,int b){return a<b?a:b;}
int lowbit(int x){return x&(-x);}
void updata(int x,int k)
{
	for(int i=x;i<=n;i+=lowbit(i)) c[i]+=k;
}
int query(int x)
{
	int res=0;
	for(int i=x;i;i-=lowbit(i)) res+=c[i];
	return res;
}
void add(int a,int b)
{
	p[++tot].l=min(a,b);
	p[tot].r=max(a,b);
}
int main()
{
	scanf("%d%d",&n,&m);
	for(int i=1;i<=n;i++) scanf("%d",&a[i].val),a[i].id=i;
    sort(a+1,a+n+1);
    for(int i=2;i<n;i++)
    {
    	int res1=a[i].val-a[i-1].val,res2=a[i+1].val-a[i].val;
    	if(res1<res2) add(a[i].id,a[i-1].id);
    	else if(res1>res2) add(a[i].id,a[i+1].id);
    	else add(a[i].id,a[i-1].id),add(a[i].id,a[i+1].id);//當兩邊都相等時當然就都是好的匹配 
	}
	add(a[1].id,a[2].id);
	add(a[n-1].id,a[n].id);//特判 
	sort(p+1,p+tot+1);
	for(int i=1;i<=m;i++) scanf("%d%d",&q[i].l,&q[i].r),q[i].id=i;//別忘了記錄是第幾個詢問,最終的答案還要*i 
	sort(q+1,q+m+1);
	int now=1;
	for(int i=1;i<=m;i++)
	{
		while(p[now].r<=q[i].r&&now<=tot) updata(p[now++].l,1);
		ans+=(LL)q[i].id*(now-1-query(q[i].l-1));//因為出迴圈時的匹配是不合法的,所以實際已記錄的匹配數量為now-1 
	}
	printf("%lld\n",ans);
	return 0;
}

[eJOI2019]異或橙子

異或字首和問題。

題意

給出一個整數序列 \(a\) ,要求支援兩種操作,單點修改和詢問在 \([l,r]\) 中所有的子區間異或值的異或和。如當 \(l=1,r=3\) 時,最終的答案就是 \(a_1 \wedge a_2 \wedge a_3 \wedge(a_1 \wedge a_2)\wedge(a_2 \wedge a_3)\wedge(a_1 \wedge a_2 \wedge a_3)\)

思路

首先需要知道一些關於異或的前置芝士

\(a \wedge a=0,a \wedge 0=a\)

異或運算既滿足結合律,又滿足交換律

回到本題上來,對於每一個區間,可以統計一下每個數出現的次數。(為了方便,以下用 \(|x|\) 表示 \(x\) 出現的次數,用 \(ans\) 表示詢問的答案)如:

\(l=1,r=3\) 時,\(|a_1|=3,|a_2|=4,|a_3|=3,ans=a_1 \wedge a_3\)

\(l=1,r=4\) 時,\(|a_1|=4,|a_2|=6,|a_3|=6,|a_4|=4,ans=0\)

\(l=1,r=4\) 時,\(|a_1|=5,|a_2|=6,|a_3|=7,|a_4|=6,|a_5|=5,ans=a_1 \wedge a_3 \wedge a_5\)

於是就可以大膽地猜想。當區間長度為偶數時,\(ans=0\)

因為當區間長度為偶數時,每一個數都出現了偶數次,自然答案就為 \(0\)

而當區間長度為奇數時,下標和區間端點下標奇偶性相同的數出現了奇數次。不相同的出現了偶數次。於是 \(ans=a_l \wedge a_{l+2} \wedge...\wedge a_{r-2} \wedge a_r\)

於是可以用兩個字首和陣列分別儲存下標為奇數和偶數的數的異或字首和。

同時題目中要求單點修改,於是就可以用樹狀陣列來維護字首和。但是需要注意,在將 \(a[x]\) 修改為 \(y\) 時,不能直接 \(add(x,y)\),因為這樣子只是多 \(\wedge y\) ,並沒有刪去 \(a[x]\),因此需要 \(add(x,a[x]^y)\),讓 \(a[x] \wedge a[x]\),也就是刪去這個數。

code:

#include<cstdio>
using namespace std;
const int N=2e5+10;
int lowbit(int x){return x&-x;}
int n,q,c[2][N],a[N];
int max(int a,int b){return a>b?a:b;}
void updata(int c[],int x,int k){for(int i=x;i<=n;i+=lowbit(i)) c[i]^=k;}
int query(int c[],int x){int res=0;for(int i=x;i;i-=lowbit(i)) res^=c[i];return res;}
int main()
{
	scanf("%d%d",&n,&q);
	for(int i=1;i<=n;i++) scanf("%d",&a[i]),updata(c[i&1],i,a[i]);
	while(q--)
	{
		int opt,l,r;
		scanf("%d%d%d",&opt,&l,&r);
		if(opt==1) updata(c[l&1],l,a[l]^r),a[l]=r;
		else
		{
			if((r-l+1)&1)
			{
			    printf("%d\n",query(c[l&1],r)^query(c[l&1],l-1)); //奇數 
		    }
			else puts("0");
		}
	}
	return 0;
}