第一次寫部落格,還有點興奮。好久以前就想寫部落格,遲遲沒動筆,今天算是一個開始。
這幾天研究了一下廣度搜尋演算法,有一點心得。以前老是深度搜尋,用遞迴實現,從來沒有用過廣搜,直到前幾天參加ITAT複賽時,看了下資料結構上迷宮的廣搜演算法,大概知道了廣搜是如何?的了。於是就動手用廣搜解決一個Sramoc問題,沒想到一下就解決了,只不過遇到了int溢出問題,所以在CSDN提問了,遲遲沒人回答。接下來,就動手陸續解決了”六數位“,”奇怪的電梯“,還有”農夫過樁渡河“等問題。其實,個人感覺廣搜比深思更容易實現一下,因為廣搜不需要回溯,只要遇到滿足條件的就進隊,減少了回溯的實現過程。而且實現步驟也很簡單,大致分為這幾步:(1)初始狀態入隊;(2)隊前端節點出隊,以此節點擴充,如果滿足條件,則後續節點入隊;(3)如果到達目標狀態,則輸出,可以退出了,否則轉(2);(4)如果隊列為空白,則無解。這樣有一個明顯的特徵就是擴充是按層進行的,其實本質就是樹裡面的層次序遍曆過程,層數就是目前狀態下已經走過的步數(或次數等等),那麼最短路徑(最少次數,最短時間等)也就是求這個層數,不過一般不會特別在意這個層數,也不會刻意去儲存它。程式中需要重點處理的是節點狀態的表示,避免節點的重複以及限制條件的表示。
節點如何表示?一般都是採用結構體,把重要的資訊儲存起來,以用來擴充,這個結構體一般包括當前所處位置(即訪問數組的下標)和資料等,如果需要列印路徑則需要儲存前驅節點(即從哪個節點擴充來的,就是我們所說的父節點),迷宮就是一個典型的例子,還有特殊情況就需要儲存當前所處層數,本題就是如此。這裡需要說明一下,一般最好不要用C++類庫裡面提供的queue用法,主要有兩個問題:(1)不能列印路徑,因為隊前端節點都已經彈出了嘛,當然也不能求最少次數了;(2)這裡的queue是一個環形隊列,可能在隊列很長時會覆蓋某些已擴充節點(跳到隊頭),以節損空間,這是我們班高手告訴我的。總之,用隊列主要解決的是相對不是很複雜的,而且不需要列印路徑或求最少次數而只需要判斷能否到達目標狀態(即是否有解)的題目,這樣用隊列處理起來比較方便,程式也比較簡潔。
如何判斷節點是否重複?一般我們會用一個很大的標記數組來表示所有可能的狀態,注意:這個數組一定要能夠表示所有狀態,否則會無解或不能夠求得最優解。處理方法就是:如果當前節點已經入隊,則標記為1(初始均為0),在下次出隊遇到時便跳過去,不重複擴充已擴充節點,這樣會大大減少冗餘的擴充次數,提高效率。其實,我們可以這樣想,存在重複的節點則說明繞圈形成環了,也就是說由樹變為圖了,這樣不停地繞來繞去,什麼時候是個頭啊?而且在極少數情況下會形成死迴圈無能求出解,或者至少也會大大延長求解時間,因為隊列多了很多冗餘的值,需要不停地出隊才能找到那個解。
還有一個就是限制條件的表示,只有滿足條件的節點才能入隊,這樣做的優點類似於節點的判重,而且判重本身也是限制條件。由於各種題目不一樣,限制條件也不同,所以需要具體問題具體分析了。
說了這麼多,我們可以看出深搜與廣搜的一些不同。(1)首先從題目入手,帶”最“字求最優解的一般都是用廣搜,而求所有解的一般都是用深搜,還有就是有明顯邊界條件的用深搜,反之用廣搜。(2)廣搜用隊列實現,可以說是空間換時間,很耗記憶體,儲存了那麼多節點而且還要用標記數組判重,但是效率很高,不停地往前走,找到的解就是最優解;而深搜是用棧或遞迴(本質還是用棧)實現,用時間換空間(深度很大時也會很耗記憶體),因為每次只需要儲存當前路徑的狀態資訊,但是會有回溯過程,不停地向前或向後跑,浪費了很多時間,而且找到的解還不能確定是最優解,還有存起來最後進行判斷和選擇。不過為了效率,兩者都可以剪枝,進行啟發學習法搜尋(3)從實現上來說,個人認為廣搜更容易編程實現,因為不需要回溯,只要遇到滿足條件的就入隊,最後的最優解一定是在這個隊列裡的,套用一句經典的話就是”不管黑貓白貓,抓到老鼠就是好貓“。這兩種搜尋方法各有優劣,用哪種要看個人習慣,也要因題而異。典型的八皇后,馬的巡遊等問題用深搜,而八數位,農夫過河(與此題不同)等問題則用廣搜。
好,說到正題了。乍看題目,覺得”動態廣搜“這個詞語有點意思,有人會不會覺得是動態規劃+廣度搜尋?其實不然,這裡所說的動態,是相對於一般的廣搜問題的靜態而言的。為什麼說一般的題目都是靜態?因為題目中有固定不變的擴充條件,就像六數位問題中只有固定的α,β變幻方式,不會隨著其他的因素而產生變化。而本題的最大特點在於每種擴充方式在不同的時刻會發生變化,不是固定不變的。於是也就產生了不同的處理方法。從程式中也可以看出來,一般廣搜程式的模式就是最外層while(隊列非空).....而此題的while語句外面還有一層迴圈,就是時間自增的迴圈。那麼求解方法會產生什麼不一樣的呢?一般的廣搜不需要管上層是否擴充完,就可以實現下層的擴充,而此題不行,因為外面還有時間的自增。可能存在上層(t-1時刻)的節點還沒有被擴充完,便進入了下層(t時刻)擴充,這樣本來在t-1時刻擴充完畢的情況下能找到最優解的,但是現在不能了,因為隨著時間的增加,節點記錄的時間會不斷增大,當然最後求得的時間不是最短時間了。不信你可以試一下,去掉這個while迴圈,你會看到”最短時間“會變大,也就不是最短時間了。我沒加while迴圈時求得的時間是17,而正確答案是4。仔細體會這裡面的區別,相信你會理解的。還有程式中兩個為什麼的注釋也需要仔細揣摩(當然這是個人編程風格不同),說一下不這麼處理的情況。(1)如果在while迴圈裡面自增了,那麼也就意味著出隊了,那麼在下降狀態足夠長的情況下,農夫可能只能站在一個樁上了,現在再一出隊隊列就為空白了,而這樣不就無解了嘛,但是事實是只要時間足夠長就一定會到達對岸的(粗略證明一下:如果當前時刻農夫只能站在原樁上,那麼以後必然存在一個時刻使農夫能夠往前走,那麼只要這樣不停地走下去,就一定會到達對岸),不信你試一下把(2,1)後面的(1,1)改成(1,100)試試,你會發現程式中途停止了運行,原因就是隊列為空白了。(2)另外就是為什麼Push(cur,t)的位置不能放後面,有人會說先入後入(相對於cur+1->min(cur+5,len))不是一樣的嘛,因為由於front在while裡面沒有自增也就是沒有出隊,那麼可能本次擴充會延續到下一時刻(t+1時刻),但是記錄的還是本時刻,因此”最短時間“會縮短,不信試一下,把這個語句放到後面,你求得的最短時間會是3。這兩種處理方法會產生連鎖反應,聯合在一起才會達到想要的效果。
好了,下面是題目、代碼和詳細注釋,歡迎各位提出更好的處理方法,進一步降低處理難度並提高程式的執行效率。
/****************************************************************
Description
農夫每天去種地都要經過一條河,這條河很寬,過河要走上面的木樁。木樁有n支,排成一排,從左岸延伸到右岸,編號為1到n.左岸在1號樁的左邊,右岸在n號樁的右邊。但 這些木樁會定時升降,因此每天他都花不少時間在過河上。所以他想找一種最快過河的方法。 在時刻0,農夫在左岸,他要在最短時間內到達對岸。在任何時刻,每一支樁都只能處在升或降的其中一種狀態。 升起的樁才可以站上去,農夫只能站在升起的樁上或岸上。 每一支樁在時刻0都是降的狀態,接著升起A分鐘,降下B分鐘,再升起A分鐘,降下B分鐘,這樣一直交替下去。例如A=2,B=3的樁,在時刻1
2升,在時刻3 4 5降。A和B是時間常數,對每個樁可能不一樣。 設在時刻t農夫站在p樁,那麼在時刻t+1,農夫能走到p樁的左右5個樁上或岸上,也可以原地不動,當然樁是可站立的。例如,在5號樁,他能走到1,2,3,4,5,6,7,8,9,10或到左岸。 請幫農夫找一種能最快到達右岸的方法。
Input
第一行是樁的數目n( 5 <n <=1000).接下來的n行每一行有2個整數A和B(1<=A,B <=5),按從1到n的順序描述每個樁的升降情況。
Output
最早到右岸的時刻。當不可以到達時輸出NO
Sample Input
10
11
11
11
11
21
11
11
11
11
11
Sample Output
4
*****************************************************************/
#include<iostream>
#include<cstdlib>
using namespace std;
const int MAX = 1000;
const int TIME = 10000;
const int SIZE = 100000000;
const int DN = 0;
const int UP = 1;
int a[MAX][3]; //每根樁升降的時間間隔
int mark[TIME][MAX]; //標記t時刻某根樁上是否已經站了人,防止重複站人,大大提高效率
int front, rear;
struct Queue
{
int pos; //所在位置(即哪個樁上)
int time; //本次所處時間
}q[SIZE];
int min(int a, int b)
{
return(a < b ? a : b);
}
void Push(int t, int pos)
{
if(mark[t][pos]) return; //若目前時間下該狀態已入隊,則不再入隊,避免重複擴充,大大減少了擴充次數
q[++rear].pos = pos;
q[rear].time = t;
mark[t][pos] = 1; //置標記為1,表示已入隊
}
void main()
{
int n; //實際輸入數組長度
int cur; //當前所處位置
int len = 1;
int state; //表示木樁所處狀態
cin >> n;
while(len <= n)
{
cin >> a[len][0] >> a[len][1];
a[len][2] = a[len][0] + a[len][1]; //計算出每次完整時間周期,減少在隊列中的重複運算
len ++;
}
front = rear = -1; //初始狀態入隊
q[++rear].pos = 0;
q[rear].time = 0;
for(int t = 1; ; t ++)
{ //隊列按時間t分層,在本層擴充時上層的必須擴充完畢,這樣才能保證求到最短時間
while(front == -1 || q[front].time == t-1)
//(1)為什麼這裡front自增1次會出現問題? 因為這裡可能導致隊列為空白 ,但是隊列是不可能也不可為空的,至少農夫可以不動
{
cur = q[++front].pos; //出隊,開始擴充節點
mark[t][cur] = 0; //始終可以站在原樁上,標記為0,可擴充
Push(t, cur);
for(int i = cur+1, mod = 0; i <= min(cur+5,len); i ++)
{
if(i == len) //到達對岸便輸出最短時間,並退出(入隊時判斷而不是出隊時判斷)
{
cout << endl << t << endl;
exit(0);
}
mod = t % a[i][2];
state = (mod && mod <= a[i][0] ? UP : DN);
//得到後5個樁目前時間的狀態,不需要每個都計算
if(state == DN) break; //如果將上的樁是下降狀態,則停止前進
Push(t, i);
} //不必再跑到(當前樁)前5個樁上去了,即使此時沒有樁可以上也可以停在原樁上,而不必再後退到前面的樁上;退一步講,即使前面5個某樁可以跳到原樁面,那原樁必然也可以走到此處,因為這段肯定都是上升狀態而且說不定還可以走得更遠,所以無論如何都不需要往後退到前5個樁上,題目說能走到前面5個樁上實際是個幹擾
//Push(t, cur); //(2)為什麼這句放這裡會出現問題?可能導致求解時間更短(不是正確的解)
} //應該是因為在while判斷時front沒有自增然後繼續擴充到下1時刻去了
}
}