你的每一個贊,我都當作了喜歡
印子:
先看一個問題:
給出n個數,m個詢問每次詢問l,r,k,求[l..r]區間內第k小的數是什麼?
一種簡單的思路是每次把[l..r]區間的數取出來,做一次快排,得到第k小。
時間複雜度是O(mnlog(n))的,如果n,m稍微大一點,就會超時。
我們再來想,
假設題目變成了只有一次詢問求[1..n]區間的第k小值,我們怎麼用log的時間得到詢問的答案呢?
一種方法是先對每個數進行離散化(因爲只要大小關係不變),然後建立一棵權值線段樹,表示的是數列[1..n]區間內的數(就是讀入的那個數組)在數值l..r這個範圍中有多少個數,然後就可以通過線段樹上的二分找出第k小值了。(如果沒學過權值線段樹的,請先查看相關資料並認真學習)
那如果是有n次詢問,每次詢問[1..i]區間的第k小值呢?
按照上面的方法,我們可以建立n棵權值線段樹,每棵權值線段樹表示的是數列[1..i]區間內的數在數值l..r這個範圍中有多少個數。然後每次詢問[1..i]區間,就找到對應的那棵權值線段樹去求。
那麼,時間是O(nlog(n))的,空間卻是n^2以上的。
我們如何想辦法優化空間呢?
這個待會再說,我們先把這個問題再轉化成文章開頭提出的那個問題。
推導:
上面我們已經知道了每次詢問[1..i]區間的做法,那我們現在每次詢問是[l..r]區間呢?
我們還是和上面一樣先建立n棵這樣的權值線段樹。
不難發現,對於某一個數值a..b這個數值範圍,
數列[1..r]區間內的數在這個數值範圍內的個數-數列[1..l-1]區間內的數在這個數值範圍內的個數,
就等於數值[l..r]區間內的數在這個數值範圍內的個數。
利用這一個性質,又因爲兩棵權值線段樹的結構是相同的,
我們就可以對於每次詢問的[l..r]區間,找到[1..l]區間對應的權值線段樹和[1..r]區間對應的權值線段樹([1..i]區間對應的樹當然是在建權值線段樹的時候就要記錄好了),
然後按照性質就可以得出樹上每個節點所對應的數值a..b這個範圍內,數值[l..r]區間內的數在這個數值範圍內的個數。
於是就可以像上一個問題那樣來做了。
空間問題及主席樹思想:
再說說那個空間的問題,
上面的做法空間都是n^2以上的,很容易會炸掉。
如何減小空間呢?
我們想想,每一棵數列[1..i]區間內的數構成的權值線段樹,和數列[1..i-1]區間內的數構成的權值線段樹有什麼不同呢?
因爲數列新增加了一個數,所以權值線段樹的某個葉子結點加了1,導致修改了一條從根節點到葉子結點的鏈,對吧?
所以它們不同的地方只有一條鏈。
所以我們可以只要新開一條鏈,而不是新開一個權值線段樹。
除了新開的那條鏈上的節點,其它節點都沿用上一棵權值線段樹的。
這樣就可以大大節省空間了。
這就是主席樹的思想了。
實現及圖解:
那怎麼沿用上一棵權值線段樹呢?
因爲樹的結構都是一樣的,
所以直接把兒子節點連過去就好了。
例如像下圖:
這是一棵根節點爲1的樹
假設我們現在要修改1-3-7這條鏈:
那麼,修改後就變成了一棵根節點爲8的樹(就是用棕色邊連起來的)。
其中,以2和6爲根的子樹我們直接沿用,我們新增8-9-10(紅色的點)這條鏈來替換1-3-7(黃色的點)這條鏈。
這樣,我們就可以只新增一條鏈了,還可以記錄下每次修改後的版本(例如第一次是以1爲根的樹,第二次是以8爲根的樹)。
值得注意的是,這樣的線段樹不再滿足左兒子是2x,右兒子的2x+1的性質,需要單獨記錄每個點的左右兒子。
空間複雜度:
那麼,空間複雜度是多少呢?
理論上是nlog(n)的,但實際線段樹操作時很容易越界,建議開成30n(也就是說n=100000時就開到3000000)。
時間複雜度:
O(nlog(n))
模板及詳細註釋:
這是本文最開始那個經典的求區間第k小問題的模板。
#include<cstdio> #include<algorithm> using namespace std; int a[100010];//輸入的數列 int b[100010];//輸入的a數列進行離散化後的數列 int sum[3000010];//以i爲根的子樹的那個數值範圍內有sum[i]個數 int lson[3000010];//線段樹上每個點的左兒子 int rson[3000010];//線段樹上每個點的有兒子 int root[100010];//root[i]表示第i棵樹的根節點位置 int p[100010]; //p[i]表示排名爲i的數是輸入的第p[i]個 (用於離散化) int cnt; //當前新建到第幾個節點 void kuaipai(int l,int r) { int i=l; int j=r; int mid=a[(l+r)/2]; while (i<=j) { while (a[i]<mid) i++; while (a[j]>mid) j--; if (i<=j) { swap(a[i],a[j]); swap(p[i],p[j]); i++; j--; } } if (i<r) kuaipai(i,r); if (l<j) kuaipai(l,j); } void init(int x,int l,int r) { if (x>cnt) cnt=x; if (l==r) return; int mid=(l+r)/2; lson[x]=x*2; rson[x]=x*2+1; init(x*2,l,mid); init(x*2+1,mid+1,r); } void update(int rt,int num,int l,int r)//rt表示當前這棵權值線段樹的當前節點所要沿用上一棵權值線段樹的對應節點rt,num表示加入的這個數的數值,l..r表示當前節點的數值範圍 { cnt++; //總節點數+1 (新建一個節點) lson[cnt]=lson[rt]; // 沿用左兒子 rson[cnt]=rson[rt]; // 沿用右兒子 sum[cnt]=sum[rt]+1; //總個數等於所沿用的那個點的+1 if (l==r) return; rt=cnt; //cnt在進入遞歸後會變,因此要記錄下來在這一層裏的cnt,剛好rt又沒用了,就拿rt來記錄cnt int mid=(l+r)/2; if (num<=mid) //如果新增數值在左兒子 { update(lson[rt],num,l,mid); //右兒子保持沿用不變,不用再遞歸,左兒子不再整體沿用,繼續遞歸下去(部分可沿用,部分要修改) sum[rt]=sum[rt+1]+sum[rson[rt]]; //重新複製總兒子數(rt+1剛好表示新建的那個左兒子節點) lson[rt]=rt+1; //左兒子不再整體沿用,所以修改左兒子 } else //那麼就是新增數值在右兒子了 { update(rson[rt],num,mid+1,r);//左兒子保持沿用不變,不用再遞歸,右兒子不再整體沿用,繼續遞歸下去(部分可沿用,部分要修改) sum[rt]=sum[rt+1]+sum[lson[rt]];//重新複製總兒子數(rt+1剛好表示新建的那個右兒子節點) rson[rt]=rt+1; //右兒子不再整體沿用,所以修改右兒子 } } int query(int i,int j,int num,int l,int r)//i,j表示兩顆權值線段樹現在所到的節點位置 { if (l==r) return l; int d=sum[lson[j]]-sum[lson[i]]; //根據前面講的性質 int mid=(l+r)/2; if (num<=d) return query(lson[i],lson[j],num,l,mid); else return query(rson[i],rson[j],num-d,mid+1,r); } int main() { freopen("a.in","r",stdin); int n,m; scanf("%d%d",&n,&m); //n個數,m個詢問 int i; for (i=1;i<=n;i++) scanf("%d",&a[i]),p[i]=i; //輸入及記錄位置(方便做離散化)p[i]表示排名爲i的數是輸入的第p[i]個 kuaipai(1,n); //排序,做離散化 for (i=1;i<=n;i++) b[p[i]]=i; //離散化 b是輸入的a數列進行離散化後的數列 root[0]=1; //第0棵樹的根節點是1(原始樹) init(1,1,n); //將那顆原始的權值線段樹的左右兒子搞出來(此時它的左右兒子還滿足是2x和2x+1) for (i=1;i<=n;i++) { root[i]=cnt+1; //第i棵樹的根節點是新建的下一個節點 update(root[i-1],b[i],1,n);//加入b[i]這個數後更新權值線段樹 } int x,y,k; for (i=1;i<=m;i++) { scanf("%d%d%d",&x,&y,&k); printf("%d\n",a[query(root[x-1],root[y],k,1,n)]);//query()返回的是查詢的值離散化後是多少(排第幾) } return 0; }