多種方法求解區間最值問題
湖南省衡陽市第八中學 鄒毅
著名計算機學家曾提出:程序=算法+數據結構,這句話被廣大程序員們奉為圭臬。我是這樣理解這句話的:如果說算法是指導我們用什么樣的方法與步驟來解決一個問題,則在問題中不可避免的要處理各種數據信息,如何來組織這些數據信息,就依賴於數據結構了,是將這些數據組織成線性的,還是樹型的,則見仁見智、不一而足了。
例如下面這個問題:
給定M及一列數,每個數在0到100,000之間。(為了方便描述,設共N個數,1< N < = 2500000) 輸出每M個數中的最大數,即1~M中的最大數,2~M+1中的最大數……N-M+1~N中的最大數,共N-M+1個。
輸入
第一行是一個數M,接下來是N個數,每個數一行,以-1作為結尾.
輸出
輸出N-M+1個最大數,每個數一行。
樣例輸入
3
10
11
10
0
0
0
1
2
3
2
-1
樣例輸出
11
11
10
0
1
2
3
3
這個題意非常簡單,就是求一個固定長度區間內的最大值。如果我們不加任何思考的話,可以將讀入的數據放到一個線性表f數組中,然后枚舉開始點i,遍歷求出區間[i,i+m-1]中最大值,於是時間復雜度為O(n*m),程序代碼如下:
for (int i=1;i<=n-m+1;i++) { ans=f[i]; for (int j=i+1;j<=i+m-1;j++) if (f[j]>ans) ans=f[j]; cout<<ans<<endl; }
對於題中的信息,有兩個要素即位置和權值。上面這個做法優先考慮了位置關系,即先固定好要考察的區間[i,i+m-1],然后再來解決求最大值的問題。如果我們變換下思維方式,優先考慮最大值,再來解決區間這個約束條件,會發現這個問題中最核心的需求就是不斷的求最大值,而堆是解決這一類需要的一個利器。於是我們可以將數據組織成樹型結構,即將讀入的數值設計成一個大根堆,即堆頂元素就是全局最大值,並記下每個數值對應的在輸入時的位置,由於本題是求一個指定區間的最大值,於是我們還需要對堆頂元素進行判斷一下它是否位於指定區間,如果在的話,則直接輸出堆頂元素的值,否則踢掉堆頂元素。程序代碼如下:
#include<bits/stdc++.h> using namespace std; const int N=6e5+5; struct num { int w,v; bool operator <(const num x)const { return (v<x.v)||(v==x.v&&w<x.w); } } p; priority_queue<num>q; int n,k,top,a[N],ans[N]; int main() { int x,k; cin>>k; while(~scanf("%d",&x)&&x!=-1) a[++n]=x; for(int i=1; i<=k; i++) { p.w=i,p.v=a[i]; q.push(p); } top=1; ans[top]=q.top().v; for(int i=k+1; i<=n; i++) { p.w=i,p.v=a[i]; q.push(p); while(i-q.top().w>=k) q.pop(); ans[++top]=q.top().v; } for(int i=1; i<=top; i++) printf("%d\n",ans[i]); }
進一步反思上面這個做法,會發現存在大量的數據冗余------存在大量明顯無用的數據在堆里面。而在求出一個區間[i,i+m-1]的結果后,接下來我們要求[i+1,i+m]這個區間的結果,這兩個區間比較一下就會發現,無非將第i個位置上的數值去掉,加入第i+m個位置上的值。此時的結果有兩種可能,要么仍是從前區間[i,i+m-1]結果,要么是新加入的元素。於是我們又回到線性數據結構,用代表第個數對應的答案,表示第個數,於是維護這樣一個隊列:隊列中的每個元素有兩個域{position,value},分別代表他在原隊列中的位置和,我們隨時保持這個隊列中的元素position域單調遞增,value域單調遞減,。則在計算的時候,先將加入到隊列中,如何加入隊列呢?我們讓a[i]與隊尾元素的 value域進行比較,但凡發現小於a[i]的,一律從隊列中踢掉,為什么要踢掉呢?那是因為由於是隨着單調遞增的,所以對於,在計算任意一個狀態的時候,都不會比優,所以j被踢掉是有理有據的,並且通過這樣的“踢數據”的操作,我們有效的降低了需要維護的數據的量,對比堆的操作中,這些無效的數據仍放在堆中,在加數據的操作時,無疑增加了操作的次數。於是采用這種方法,每個數據進出隊列都只有一次,所以時間復雜度為O(n),而堆的時間復雜度為O(n*log2n)。接下來我們還要在隊首不斷刪除,直到隊首的position大於等於,那此時隊首的value必定是的不二人選,因為隊列是單調的!程序代碼如下:
#include<bits/stdc++.h> using namespace std; int n,d,num,f[2500001],a[2500001],b[2500001]; int main() { num=0; scanf("%d",&n); while(true) { scanf("%d",&d); if(d==-1) break; num++; a[num]=d; } int head=1,tail=1,now=1; f[1]=a[1]; //存放值域 b[1]=1; //存放位置 for (int now=2;now<=num;now++) { while(tail>=head&&a[now]>f[tail]) //維護一個值域單調不上升的隊列 tail--; tail++; f[tail]=a[now]; b[tail]=now; if(now-b[head]>=n) //控制隊列頭的位置域在所要求的范圍之內 head++; if(now>=n) printf("%d\n",f[head]); } }
此外本題還可以使用st表,線段樹等數據結構來進行維護,介於篇幅原因,不再贅述,列表如下:
數據結構 |
空間復雜度 |
時間復雜度 |
編碼難度 |
堆 |
O(n) |
O(n*log2n) |
簡單 |
單調隊列 |
O(n) |
O(n) |
簡單 |
線段樹 |
O(4*n) |
O(n*log2n) |
中等 |
St表 |
O(n*log2n) |
O(n*log2 m) |
中等 |
綜上所述,有兩點心得體會,首先對於給定的信息,往往會有多個屬性,當我們選擇的主攻方向,如果實際效果並不好時,就要變換思考的方式了,這個現象是在編程學習中經常遇到的。其次數據結構與算法如同習武之人的內功與外功,兩者相輔相成,我們在針對某個問題設計程序時,當發現在算法上沒有好的方法時,就想想如何從數據結構上進行突破,反之亦然,而當有多種數據結構可供選擇時,則需要細細體會它們之間的優劣,例如此題用線段樹也可以完成,然而線段樹的常數較大,並且編碼相對來說要復雜一些,所以並不推薦,但如果本題進行改編,變成即有詢問操作,又有數值的更改操作時,線段樹就能發揮其特長了,所以每種數據結構都有其適用的場景,關鍵是我們在了解其來龍去源的前提下,活學活用。
本文涉及的相關試題及數據如下:
鏈接:https://pan.baidu.com/s/1ESnmrahatNlgf0QROsdr5g
提取碼:5ng3