在這份筆記中,我們說明APCS 2024年10月實作題考試的題目解說。這些題目是根據ZeroJudge上的版本,與真實考試的版本題意應該是一樣的,但題目敘述、測資與環境並不一定相同。
每一題的題解都包含題意分析、演算法、C/C++範例程式與Python範例程式。其中後兩個單元是獨立的,也就是必要的說明會在兩個單元重複出現,為了方便有些人只看其中一種語言。
(題目連結)1.裝飲料
有一個水杯,上下兩層是底面積都是正方形,但邊長不同。逐次加入若干體積的水,問哪一次水面高度增加的最多,假設水杯若滿,則高度不再增加。輸出水面高度最大一次的增幅。
假設下層的邊長與高度是與,而上層的邊長與高度是與。
這個問題可以先分兩段來思考。
第一子題是只有一次加水,也就是只要會上述的第一項即可。完全解則需要一個迴圈。
先看第一子題的解,只要將前述的三個情形用if來判斷區分即可。
接著是完全解的寫法。除了輸入的尺寸參數外,我們使用以下變數:
程式就直接依照上述定義好的變數來做就可以了,記得在下一回合開始之前,要用curr更新prev的值。
先看第一子題的解,只要將流程中所說的三個情形用if來判斷區分即可。使用Python者需要對list有基本認識以方便處理輸入,以下面的程式前兩行為例,第一行是一行數入一個整數,第二行是一行輸入若干個以空白間隔的整數,這是常用的方法,必須一記。
接著是完全解的寫法。除了輸入的尺寸參數外,我們使用以下變數:
程式就直接依照上述定義好的變數來做就可以了,記得在下一回合開始之前,要用curr更新prev的值。
(題目連結) 2.蒐集寶石
模擬在一個的方格圖中行走,碰到寶石就撿起一顆,碰到牆壁或是界外就右轉,同時要記錄一個分數,每次走到有寶石的格子,得分就增加該格目前寶石數量,如果目前總得分是的倍數,要右轉。走到沒有寶石的格子就結束。
這就是個模擬的題目,只要定義好變數記錄狀態,依照題意來寫就可以。不過方格圖的走訪有些小技巧,如果沒有經驗可能寫得又臭又長。
依照題目的定義,我們以橫列與直行來定義位置,代表第個橫列的第個直行的位置,列與行的編號皆從0開始。我們紀錄以下的變數
第一子題是一維陣列,流程可以更簡化,以下直接說明完全解。這題是簡單的題目,C與C++差異不大,以下提供C的寫法。
第7 ~ 12行是輸入的部分,在一開始我們可以將四周圍滿一圈-1,這樣後面就不必檢查出界,所以第8行會將m與n的值加2,當然要記得開始的座標要改成。
第16行開始的while迴圈開始模擬行走,因為題目保證開始的位置不是牆壁(-1),所以結束的條件是走到的位置不是0。第21行是判斷如果得分是k的倍數就右轉。無論得分有沒有讓我們右轉,接下來第22 ~ 33行我們用一個迴圈轉到前方不是牆壁也不是界外為止,然後往前走進入下一回合。
當while迴圈結束後,印出答案即可。
第一子題是一維陣列,流程可以更簡化,以下直接說明完全解。第1 ~ 5行是輸入,我們把方格圖的四周圍一圈-1,可以簡化後面不需要判斷出界,因為Python list的[-1]是最後一個,所以我們只要在每一列的後方與最後一列加上-1就可以了(第4,5行)。
第9,10行是定義對於每一個方向(d=0,1,2,3),列與行的變化量,例如在d=0(東方)時,往前一步就是dr=0,dc=1。這是個在方格圖中走訪的常用技巧。
第11行的while迴圈開始走訪,迴圈不變性是g[r][c]是目前所在位置,停止條件是g[r][c]不是0。第12 ~ 14是調整變數的內容,第15行判斷分數是否為k的倍數,若是則右轉。第16行的迴圈會持續右轉直到前方不是牆壁,注意這樣寫while True是因為進入迴圈先不轉,到迴圈尾巴才右轉。
離開while迴圈後,印出答案即可。
(題目連結) 3. 連鎖反應
有一個方格圖,裡面有一個指定的起始點(-2),其他點如果是-1就是牆壁,否則都是地雷,地雷有一個非負整數表示爆炸的半徑(傳播距離),地雷被引爆時,在傳播距離以內的其他地雷會被引爆,可以形成連鎖反應。傳播距離的計算是往上下左右一格算一步,但不能傳入牆壁。
題目要計算的是:找出起爆點的最小爆炸半徑,使得至少有q個地雷被引爆。此處有些地雷的爆炸半徑是0,也就是它被引爆時並不會影響其他地雷,但是它也要被計算在被引爆的地雷數量中。
有三個子題,第一子題沒有牆壁,也就是說兩點的距離就是列差與行差的和,地雷可以傳到的點可以很簡單的來判斷。第二與第三子題有牆壁,距離的計算方式要使用BFS來做。此外測資的大小也是隨子題增加,所以對效率的要求也是重要的要求。
第一子題雖然比較簡單,但主要的一些觀念與程式的主要結構是一樣的,我們可以用第一子題來講解主要流程,第二三子題只要更換計算距離的程序就好。
先看看地雷爆炸的連鎖反應是怎麼回事。
地雷A爆炸時,在距離範圍內如果有BCD,則BCD也會爆炸,BCD可能進一步引爆其他的地雷,如此連續引爆下去直到沒有地雷被引爆。這其實是個遞迴的過程,其中有個重點就是地雷不會重複被引爆,否則會形成無窮遞迴。所以它也就是個圖形的走訪,可以用DFS來做,或者用個stack寫非遞迴版本。流程可以規劃如下:
見山是山
要計算最小起爆半徑,我們可以逐步嘗試0,1,2,…,直到爆炸數量滿足需求。
見山不是山
可以做得更好嗎?
爆炸數量顯然與起爆半徑的單調上升函數,可以外面套一層二分搜減少搜尋次數。
見山還是山
如果先找好所有點與起爆點的距離,依照距離由近而遠引爆並進行連鎖反應,記錄好done的值,不重複進行連鎖反應,那麼我們不需要二分搜,第一個滿足總爆炸數量的點與起始點的距離就是最小起爆半徑。
總結我們的程序
時間複雜度跟如何計算所有點距離以及如何計算在一個地雷範圍內的點有關,計算所有點的距離是陣列大小;一個爆炸半徑的地雷可以引爆的範圍是,因為不會重複引爆,所以這部分是,是半徑非零的地雷數,總時間複雜度是,本題中,,。不是很大,即使外層用二分搜的較差版本應該也可以通過。
第二三子題需要用BFS在有牆壁的方格圖中走訪,因為要做多次的BFS搜尋,因此應該單獨寫下BFS的副程式,這是個常用的標準程序,但有幾個小細節要注意:
以下是BFS的流程
整體來說,這個題目的思考並不是算太過困難,但很吃實作能力,而且APCS沒有online judge,必需對BFS/DFS了解並且有足夠的熟練度,才能在考試的有限時間內寫得出能通過的程式。
先看第一子題的寫法,比較容易了解此題的架構。
第五行的g是地圖,另外我們把標記是炸過的done放在global。第6行開始是進行連鎖反應的dfs。進入時先檢查是否界內以及是否已經引爆,是的話直接回傳0。
否則我們引爆此地雷,cnt=1, done[r][c]=1,然後要對範圍內的點進行遞迴呼叫,第一子題就直接檢查可能範圍內的所有座標就可以,這裡有更快的寫法,但並不需要。所謂可能範圍就是列差值與行差值都在半徑以內。
再看主程式的部分。第21 ~ 28行是輸入,這題須要自己找起始點,我們在第24行檢查,找到了用r0,c0紀錄。第28行先初始化done,然後要找所有點與起始點的距離,這裡我們直接歷遍所有的點,依照座標計算距離然後放入queue,此處用vector of vector來做,把距離放在最前面,這樣用排序就可以達到我們的目的,不需要使用更複雜的方法。
第35行開始的迴圈,我們依照距離由近而遠歷遍queue的所有點,呼叫dfs計算新引爆的地雷,如果數量達到,就結束並輸出答案。
完全解的差異在於距離的計算要使用BFS,先看第8行開始的BFS。這裡我們採用一個大的工作陣列dis來處理每次BFS計算距離時的需要,回傳queue的部分則用傳reference的方式來做,這裡當然也有其他的方法,例如用global變數也可以做。
dr,dc是要拜訪4個鄰居時候的列差值與行差值,是方格圖的常用手法,第12 ~ 16行初始dis陣列,只清理我們能夠走得到的範圍就可以,其中牆壁的位置放0,後面就不會走進去牆壁裡面。接著就照前面的流程,這裡的que是個vector<pair<int,int>>用來放座標,所以列座標在first而行座標在second。第24行的迴圈檢查4個鄰居,他們的座標(nr,nc)可以用dr,dc簡單得到。
接著看DFS,在第35行開始,這與前面第一子題的架構差不多,但我們這裡確定呼叫進來的不會是爆過的,而且我們用g[r][c]=-2表示爆炸過,省略用另外一個陣列done。第38行如果範圍是0就不做遞迴,否則第39行準備一個q2,第40行呼叫BFS,然後我們會得到可以走得到的點在q2中,然後對它們一一遞迴呼叫,此處的順序是不重要的,順便一提。
主程式的部份,第57 ~ 59行先做一次起始點的BFS,距離限制給相當於無限大,得到拜訪點的順序會存在que中,並且也把距離保存在d中,此處的距離是要用的而且順序是重要的。後面的部分與第一子題相同,依照que的順序一一呼叫dfs。
以下介紹Python的範例。
先看第一子題的寫法,比較容易了解此題的架構。
第1 ~ 7行是輸入,第8行是要標記是炸過的done,這題須要自己找起始點,在第6 ~ 7行找,找到了就用r0,c0紀錄。
第10行開始是進行連鎖反應的dfs。進入時先檢查是否界內以及是否已經引爆,是的話直接回傳0。否則我們引爆此地雷,cnt=1, done[r][c]=1,然後要對範圍內的點進行遞迴呼叫,第一子題就直接檢查範圍內的所有座標就可以,這裡有更快的寫法,但並不需要。所謂可能範圍就是列差值與行差值都在半徑以內。
再回到主程式的部分。第21 ~ 22行是找所有點與起始點的距離,這裡我們直接歷遍所有的點,依照座標計算距離然後放入que,每個點存距離與座標共3個整數,把距離放在最前面,這樣用排序就可以達到我們的目的,不需要使用更複雜的方法。
第25行開始的迴圈,我們依照距離由近而遠歷遍que的所有點,呼叫dfs計算新引爆的地雷,如果數量達到,就結束並輸出答案。
完全解的差異在於距離的計算要使用BFS,先看第8行開始的BFS。這裡我們採用一個大的工作陣列tem(在bfs內稱為d)來處理每次BFS計算距離時的需要。
第11 ~ 13行初始d陣列,只清理我們能夠走得到的範圍就可以,其中牆壁的位置放0,後面就不會走進去牆壁裡面。接著就照前面的流程,這裡的que中放的是座標tuple。第19行的迴圈檢查4個鄰居的座標(r,c),是方格圖的常用手法,如果座標在界內且未走過,就設定距離並加入que的尾端。
接著看主程式。第26行開始計算程序,這與前面第一子題的架構差不多,但我們不寫遞迴的DFS,因為Python的遞迴深度與記憶體限制比較嚴,我們採用非遞迴的stack方式來做。
第26 ~ 27行先做一次起始點的BFS,距離限制給相當於無限大,得到拜訪點的順序回傳到reach中,此處的距離是後面要用的,所以用另外一個dist傳過去以便保存。後面部分與第一子題相同,依照reach的順序一一檢查連鎖反應,也就是DFS,非遞迴的DFS其實很簡單,第39行準備一個stack,一開始把此次引爆的第一點放入,每次從stack取出最後一點,呼叫bfs找到它可以引爆的點,將它們放入stack中,一直跑到stack空為止。我們這裡用g[r][c]=-2表示爆炸過,並且只有半徑>0的才做,以便減少沒用的bfs呼叫浪費時間。
(題目連結) 4.搭到終點
有班車,每班行駛在某個連續的區間。要從0搭到,每搭上一班車,就要搭到該班車的終點,不可中途下車,到該班車終點下車後,再轉乘其他經過的車,任何經過的車都可以搭,但已到站的車不可搭(也就是不能上車沒有前進就下車)。請問有幾種搭車到達最後一站的方法數。輸出方法數除以P的餘數,P是輸入的正整數。
,。
第一子題n,m很小,第二子題。
這題的題意明顯,如果具備有DP(動態規劃)知識者,很容易知道要怎麼做。令dp(i)是搭到i的方法數,對於一個行駛區間為的列車,它貢獻給點的方法數就是
這個區間和,當然這裡假設在他之前所有點的到達數都已經算好了。
所以,我們將到達點由小到大的順序,依照這個計算式來計算方法數就可以算出最後答案。
如果按照定義來天真的直接實作,時間複雜度是所有區間的長度總和,也就是,這顯然太大,只能過第一子題。
如何加速呢?
要算區間和,就用前綴和。因為此處每個點的DP值算好了就不會再變動,所以用前綴和就可以。不過有幾點要注意:
這裡我們不用離散化,而用二分搜的方法。演算法如下:
時間複雜度是,因為排序與二分搜。
這題想到方法後,實作並不困難,二分搜最好可以使用系統內建,執行速度快,好用又不會跳針(出錯)。
一開始先自訂一個struct,用來存一個區間,然後寫一個排序用的比較函數cmp。當然用pair也可以,把右端放前面,也不必寫cmp。第16 ~ 19行根據題目的輸入格式,讀入區間後,進行排序。第20行我們用vector來放dp與point陣列,初值
設dp[0]=1, point[0]=0,也就是在座標0(起點)的到達數是1,此處型態使用long long,避免運算過程溢位。
接著開始歷遍每一個區間,第22行的if是進來的右端點與point最後一點不同,是個新加入的點,所以在point與dp的尾端新增一筆,因為dp是prefix sum所以將前一點的值先記入。然後我們要計算區間和,如果左端是0,就是前面的全部總和,否則以二分搜找左端,這裡二分搜用的是lower_bound再減1。
最後在輸出答案時,因為存的是前綴和,所以挑出最後兩項來相減,記得因為要取餘數,相減後可能是負值,所以把它加上mod後再取餘數。
以下是Python的寫法。這題想到方法後,實作並不困難,二分搜最好可以使用系統內建,執行速度快,好用又不會跳針(出錯)。第一行的import是為了要呼叫內建的二分搜。
第2 ~ 4行根據題目的輸入格式,讀入區間後,第5行用zip將兩個欄位合併並進行排序,我們把右端放在前面,這樣排序時就是依照右端排序。
第6行我們初設兩個list放dp與point,初值設
dp[0]=1, point[0]=0,
也就是在座標0(起點)的到達數是1。
接著開始歷遍每一個區間,第8行的if是進來的右端點與point最後一點不同,是個新加入的點,所以在point與dp的尾端新增一筆,因為dp是prefix sum所以將前一點的值先直接記入。然後我們要計算區間和,如果左端是0,就是前面的全部總和,否則以二分搜找左端,這裡二分搜用的是bisect_left再減1,bisect_left是bisect中的函數,會找到第一個>= key值的index。
最後是輸出答案時,因為存的是前綴和,所以挑出最後兩項來相減,記得因為要取餘數,Python負數取餘數會回到[0,mod-1]的範圍,所以其實不需要把它加上mod後再取餘數,不過如果有時候會忘記的話,加上不妨。
End