在這份筆記中,我們說明APCS 2025年1月實作題考試的題目解說。這些題目是根據ZeroJudge上的版本,與真實考試的版本題意應該是一樣的,但題目敘述、測資與環境並不一定相同。
每一題的題解都包含題意分析、演算法、C/C++範例程式與Python範例程式。其中後兩個單元是獨立的,也就是必要的說明會在兩個單元重複出現,為了方便有些人只看其中一種語言。
(題目連結)等紅綠燈
操場起跑線上有一個紅綠燈,綠燈為秒,紅燈為秒,依照綠燈紅燈的順序循環。 有位小朋友,從操場的起跑線騎腳踏車在綠燈開始時一起起跑,每一位小朋友若騎到終點時為紅燈,需要等待紅燈結束變為綠燈才可以結束。
求出這位小朋友共需要等待幾秒的紅燈秒數。
有位小朋友,但他們每一個人的等待時間是獨立無關的,所以只要會求每一位小朋友的等待時間,跑一個迴圈把它們加起來就是答案。
第一子題只有一位小朋友,所以不需要迴圈,只要會求一位的等待時間就是答案。重點是如何計算一位小朋友的等待時間。
綠燈與紅燈以與的時間週期循環,所以一次循環的週期是,也就是說,如果經過秒回到起點,那麼就是在綠燈開始後到達。等待時間分成兩種狀況:
先看第一子題的解,也就是計算一位小朋友的等待時間。雖然第一子題綠燈與紅燈的秒數固定是10秒,我們只要能讀入跑一圈的秒數就能夠進行計算。不過根據題目的輸入格式,在程式中我們還是必須把所有輸入的參數讀進來。
我們需要設計以下變數來儲存資料:
本題C與C++寫法的差別只有在標頭檔與輸入輸出指令,我們寫在同一個程式中,scanf與printf的用法在註解中。以下是第一子題的範例程式。
完全解的寫法只差在需要一個迴圈把每一位小朋友的等待時間加總。除了上述變數外,我們需要一個紀錄總和的變數isum,記得要給初值0,這是很多初學者容易犯的錯誤。
使用Python考APCS的人需要對list與迴圈有基本認識以方便處理輸入,否則幾乎無法做題目,以下面的程式前三行為例,第一行是一行數入兩個整數,第二行是一行輸入一個整數,第三行是一行輸入若干個以空白間隔的整數,這些是常用的方法,必須一記。
雖然第一子題綠燈與紅燈的秒數固定是10秒,我們只要能讀入跑一圈的秒數就能夠進行計算。不過根據題目的輸入格式,在程式中我們還是必須把所有輸入的參數讀進來。這裡我們假設讀者具備基本的變數、list與迴圈的能力,所以不單獨說明第一子題的解,直接說明完全解。
我們需要設計以下變數來儲存資料:
程式就直接依照上述定義好的變數來做就可以了,注意第6行以迴圈依序歷遍列表中每一個元素的寫法。
給一個字串,有三種字串操作,分別如下
給定個操作,請依序操作字串,輸出最後的字串結果。
第一子題只有一次完美洗牌的操作。
這就是個字串的模擬操作題目,只要依照定義進行操作就可以。不過字串在C、C++與Python的處理方式都不一樣,必須熟悉自己所使用語言中有關陣列或字串的操作指令。
第一子題只有一個操作而且一定是完美洗牌,其實前兩個操作兩兩交換與兩兩排序都比較容易,洗牌的操作可能有些人容易迷惑,我們先看完美洗牌的流程,主要有兩種思考方式,只要會其中一種就可以。
完全解的主要流程規劃如下:
要留意操作過程可能會轉存在另外一個字串T,每次操作完存回到S。
細節因語言不同有不同做法,我們留在下節說明。
第一子題只有一個操作而且一定是完美洗牌,先看C++的寫法。
C++的字串是用string來宣告,第8行之前是輸入,輸入後我們先宣告一個字串t,他會是個空的字串,然後我們用前述第一種思考的流程來做洗牌,s[i]就是左半部的第i個,s[n/2+i]就是右半部的第i個。
這個程式碼中被註解掉的後半部是用第二種流程來寫的,也提供讀者參考。
以下介紹C的第一子題寫法,有些寫C++的人可能字串也會用C的方式來處理。C的字串是字元的陣列,有些時候指令會稍微麻煩一點,以下是C的第一子題寫法。
第8行直接宣告兩個字串,輸入放在s,操作後放在t。第14行的迴圈中,這裡用3個變數來記錄位置,i是t的位置,le是s左半部的位置,而ri是s右半部的位置。
後半部註解掉的部分是第二種思考流程的寫法,分兩個迴圈來處理s的左半部與右半部。
接著來看完全解。先看C++的寫法。
依照前面介紹的主要流程來寫。兩兩交換的部分,第12 ~ 16行我們用傳統的三步驟基本寫法,C++有提供swap函數來做,但如果不記得的話用基本指令也很容易完成。
第18 ~ 22行是兩兩排序的操作,這裡是利用min與max函數取出後放回所需的位置。
第24 ~ 30行是完美洗牌的操作,跟前面第一子題的寫法略有不同,我們先宣告一個t字串,並將s的內容拷貝給他(第24行),然後再將他洗牌搬回s,當然也可以用前面第一子題的方法。
再來介紹C的完全解。依照前面介紹的主要流程來寫。C的字串是字元的陣列,不要忘記結尾有個字串結尾符號(第10行)。
兩兩交換的部分,第14 ~ 18行我們用標準的三步驟交換程序來寫。
第20 ~ 25行是兩兩排序的操作,第21 ~ 22行先取出兩者中較小與較大者,然後放回所需的位置。
第27 ~ 33行是完美洗牌的操作,我們將s的內容洗牌到t,然後用字串拷貝的函數抄回s。
Python的字串是個不可更改的物件,所以他的做法與C有很大的差別,但如果了解他的使用方式,操作指令算是很簡潔方便的。
第一子題只有一個操作而且一定是完美洗牌,來看Python的寫法。
第6與第7行取出s的左右兩半,然後就讓s變成空字串,以下用附加的方式逐步把左右半部的第i個放加到s的尾端就可以了(第10 ~ 11行)。這個迴圈有另外一個寫法放在註解掉的第9行,那是利用了zip這個函數,供參考。
再來介紹Python的完全解。主架構是依照前面介紹的主要流程來寫。Python的字串是個不可更改的物件,除了在尾端附加,我們不能去變動(刪除或更改)字串中間的內容。
我們有兩個方式來處理這個題目,一個是先將字串改成list,全部操作完畢後再將他轉回字串。以下是完全用字串來做,對於每一個操作,我們都重新建構出另外一個字串,再把他存回s以便進行下一個操作。
兩兩交換的部分,第7 ~ 10行我們用一個迴圈每次處理一對,注意到第9行我們放入t的順序與s中的位置相反,這就做到兩者交換的目的。操作完畢後第10行將他回給s。
第12 ~ 15行是兩兩排序的操作,與交換類似,我們先讓t是空字串,用一個迴圈每次處理一對,第14行先取出兩者中較小者再取較大者,附加到t的尾端。第15行也是把操作完畢後的字串回給s。
第17 ~ 21行是完美洗牌的操作,跟第一子題的寫法一樣。
(題目連結) 3. 重組問題
有一個遞增整數序列,已知 為陣列中任兩個數字的絕對值差所組成的一群數字。給定的內容,輸出可以生成該 的最小與最大字典序序列。
舉例來說,如果n = 3且 ∆ = (3, 4, 7),距離最大的是7,因為最大距離必定是在最小與最大兩數之間,我們可以推論原序列的最大的數字是7。接著,目前已知原序列有 (0, 7),而距離序列扣除7之後剩下(3, 4),我們要決定中間點的位置。此時最大的距離是4,這個距離必然是該未知點與「最小的0」或者與「最大的7」之間的距離,所以這個點可能是4或者3,在檢驗距離序列後,發現兩者皆可能,所以原序列可能是 (0, 3, 7) 或者 (0, 4, 7),其中字典序最小的是 (0, 3, 7),最大的是 (0, 4, 7)。
第一子題,可以用效率比較差的方法做,完全解,需要比較好的方法。
題目就是要找出字典序最小與最大(或是所有)可能的A序列,滿足他的距離差是輸入的(距離陣列)。
以下說明時,我們可以把A序列看成數線上的n個點,除了給定的第一點,要做的就是決定其它個點,這個題目看起來難理解,即使第一子題n比較小,也有無從下手的感覺。思考重點在題目中舉例所給的提示。首先,最大的數一定是距離陣列中的最大值,這個蠻明顯的。因此,我們已知我們知道最左邊的是0,最右邊的是,其它需要的點都在兩者之間。其次,在剩下的距離陣列中的最大值如果是d,那麼必然有一個點是d或者,是哪一個呢? 我們暫時無法決定,需要根據距離陣列來檢查,對於任何一種可能,他與已知點的距離必須在距離陣列中,如果不滿足此要求,那就是不可能;如果滿足,這個點目前暫時是可能的,可以依照這個程序在往下搜尋可能的下一點。
所以這是個遞迴暴搜的程序,設計遞迴程序可能有多種方式,通常遞迴程序中有三個部份:
遞迴暴搜的寫法可以寫成「先檢查再呼叫」或者是「先呼叫再檢查」,兩種都是可以的,筆者本人的經驗習慣順序是「剪枝+終端+呼叫」。本題中,我們的遞迴程序規劃如下:
我們主程序可以規劃如下:
上述程序中,我們可以看到有幾個實作上要求:
時間複雜度:因為只有n-2個點需要決定,粗略估計遞迴需要次,本題n不超過25,是沒有問題的。事實上,因為剪枝的關係,遞迴次數遠低於此,目前知道的最差狀況不超過次。
註:一般的遞迴暴搜題,剪枝可能可有可無,剪枝帶來的效率提升其實是非常難估算的。但本題的剪枝是必要的,因為距離陣列的長度是n(n-1)/2,如果全部都拿來枚舉,那只能通過第一子題。
註:本題因為只要找出最小與最大的序列,還有一個做法是找出最小的序列,因為最大的序列必然是最小序列的左右對稱解,也就是把所有的點v改成last-v所構成的序列。不過這個解法對考試並非必須的,且在考試時未必能想到,我們在下節中會揭示這個版本。
先看C++的寫法。我們使用vector 來存放陣列,C++ vector允許一些簡單的操作如比較大小、複製以及刪除元素,寫起來會比較簡單,否則這些都需要自己寫。全域變數last是放最右邊的點,imin與imax是目前找到的最小與最大序列。
dfs傳入的陣p與d,本題因為陣列會被修改,我們用pass by value的方式整個copy進來,這樣比較方便,而且不容易出錯。第9 ~ 13行做檢查距離與剪枝的動作,對p中每一個既存點x,看看與q的距離abs(x-q)是否在d中,若否,則直接return,也就是q是不合法的,放棄此搜尋,否則,我們在d中刪除此距離。如果對全部的既存點都通過檢查,q就是可以放進來的(第14行)。
第15 ~ 20行是終端情形,我們要先將p排序,然後更新imin與imax。
第21行我們取出d中剩餘的最大值(d永遠保持排序好的狀態),然後進行兩次遞迴呼叫,一次是以q為新的點,另一次則以last-q為新的點。
再看主程式的部分。第29行是一個特判n=1的情形,接著輸入距離陣列並排序,接著找出最大值,並設定初始的既存點。第38行遞迴呼叫後,就輸出答案。
接著看C的寫法,這題C比較麻煩,一個原因是陣列的比較、複製、與刪除元素可能都要自己寫,排序的qsort也要寫比較函數。這些基本操作雖然不難,但是自己寫就增加了程式碼的長度。另外一個差異是C在副程式傳遞陣列時,是傳位置過去,因為遞迴呼叫會改變掉距離陣列的內容,所以我們要自己複製一份。
再者,C在陣列中刪除元素自己寫比較囉嗦,這裡我們改變一種方式,把刪除的距離改寫成不可能的-1,而並不將其移除。其它的流程與說明與前面C++程式差不多,就不再贅述。
這一題我們可以取個巧,因為數值範圍不超過100,我們可以用char來存放這些整數,這麼一來C的string.h中有些mem類的函數可以方便做陣列操作。此外,前面提到這題有一個方法是只找出最小序列,這裡我們用C的方式來說明這個方法。
除了mem函數之外,與前面的程式差異不大,但dfs程式改成回傳int,傳回1表示成功找到,傳回0表示沒有找到。注意第41 ~ 42行遞迴呼叫的地方,先嘗試以last-q呼叫,因為q是最大距離,這會找到比較小的序列,如果成功,就不再繼續搜;如果失敗才會嘗試q的呼叫。這樣寫找到的一定是最小序列,在第60行我們輸出他的對稱序列,必然為最大。
以下介紹Python的範例。Python的陣列是用list來做,他的操作是很方便的
前兩行是輸入,第3行是一個特判n=1的情形。全域變數last是放最右邊的點,imin與imax是目前找到的最小與最大序列。
dfs傳入的list point與dist,分別是已經存在的點與目前剩下的距離陣列,q則是試圖新加入的點。第13 ~ 16行做檢查距離與剪枝的動作,對point中每一個既存點v,看看與q的距離abs(q-v)是否在dist中,若否,則直接return,也就是q是不合法的,放棄此搜尋,否則,我們在dist中刪除此距離。如果對全部的既存點都通過檢查,q就是可以放進來的(第18行)。
第19 ~ 23行是終端情形,我們要先將point排序,然後更新imin與imax。
第25行我們取出dist中剩餘的最大值(dist永遠保持排序好的狀態),然後進行兩次遞迴呼叫,一次是以q為新的點,另一次則以last-q為新的點。本題因為list內容會被修改,傳遞時我們都將list複製一份後傳遞,這樣比較方便,而且不容易出錯。
主程式的後半段在第30行開始。遞迴呼叫後,就輸出答案。
前面提到這題有一個方法是只找出最小序列,這裡我們來說明這個方法。
與前面的程式差異不大,但dfs程式改成會回傳True或False,傳回True表示成功找到,傳回False表示沒有找到。注意第24行遞迴呼叫的地方,先嘗試以last-q呼叫,因為q是最大距離,這會找到比較小的序列,如果成功,就不再繼續搜;如果失敗才會嘗試q的呼叫。這樣寫找到的一定是最小序列,在第31行我們輸出他的對稱序列,必然為最大。
(題目連結) 4. 分組開會
有 n 個人在一個數線上,他們的位置座標分別為 。今天要從 n 個人中選出 2k 個人開兩場會議,每一場會議要恰好 k 個人參與,並且每一個人最多只能參與一個會議。
若一個人位在 x,欲前往 y 處開會需要 |x−y|。請求出安排這兩場會議,使得參與會議的人移動距離總和最小值為何。
,座標值為不超過的非負整數。
第一子題n ≤ 100。
這個題目需要以下的思考:
有了這兩點知識後之後,我們需要三個步驟:
以下分別說明後兩個步驟。
計算每一個區間的最小成本。
如果直接計算,那麼有n-k+1個區間,時間複雜度將會達到,在k很大的時候將會是。如何加速運算呢?方法不只一種,以下是一種所謂sliding window的方法。
因為所有區間都要計算,而每一個相鄰區間是非常相似的,區間[i-k+1,i]與下一個區間[i-k+2,i+1]只增減一個成員,所以我們可以去考慮兩者之間的差值,只要這個差值是可以很快算得出來,那麼就能夠節省全部的運算時間。
假設座標存在p,移動後的區間是[left, right]且中位數在med 而前一個已經算好的區間是[left-1, right-1]中位數在med-1。我們來考慮兩者的差異:
全部合計距離增加(p[right]-p[med])- (p[med-1]-p[left-1]) + (p[med]-p[med-1])*((med-1-left+1 – (right-1-med+1)) = (p[right]-p[med])- (p[med-1]-p[left-1]) + (p[med]-p[med-1])*(med+med-left– right)。
這個差值直接就可以在O(1)時間計算出來。
另外一種計算每個區間距離和的方法是利用前綴和。對於區間是[left, right]且中位數在med來說,右半部的點 med< i ≤ right,距離是p[i]-p[med],總和是sum(p[med+1, right])-(right-med)*p[med];左半部的點 left ≤ i < med的來說,距離是p[med]-p[i],總和是(med-left)*p[med] – sum(p[left, med-1]])。若psum[i]是前i項的前綴和,兩者總和為
psum[right]-psum[med]-(psum[med-1]-psum[left-1])+p[med]*(med+med-left-right)
每個區間可以在O(1)完成計算。
計算兩個獨立區間的最小成本
假設我們要在一個數列中找總和最小的兩個元素,我們可以簡單地找出最小與第二小的數字即可。但是如果要找出兩個成本總和最小的獨立區間,就不能這麼簡單的找兩個最小成本的區間,因為找出來的兩個區間可能是有重疊的。
假設位置i是最佳解的第二個區間右端點,那麼第二個區間是[i-k+1,i],而最佳解的第一個區間就是「右端<=i-k而成本最小的區間」。這樣可以保證兩個區間不重疊。如果對每一個i都算出這個最好的總和,從中取最小值就會是我們要的答案。
因此,我們可以對每一個位置i計算出成本其前綴最小值,也就是右端<=i的所有區間的最小成本,我們就可以很簡單的找到最小成本總和的兩個獨立區間。
這個方法算是一種簡單的DP觀念,有很多類似的題目都會用到這個方式。
整個計算流程如下:
時間複雜度是,除排序外都是。
這題想到方法後,實作並不困難,C與C++的寫法差不多,此處就不分兩個版本,以C++來示範。
準備三個陣列p, d, 與pmin放在全域變數,這題的數值可能很大,因此宣告為long long,避免運算過程溢位。輸入座標後,第12行排序(C的話需要使用qsort),第17行先計算第一個區間的成本(距離總和),我們用三個變數left, right, med記錄著左右端與中位數,雖然可以只用一個變數來做,其他兩個可以用運算得到,但多用兩個變數比較不傷腦筋,程式也清楚。
接著開始歷遍每一個區間,第19 ~ 23行計算出每一個區間的成本,這裡用的是sliding window計算差值的方式。
第25 ~ 26行計算成本的前綴最小值放在pmin,第30 ~ 31行計算出答案,最後,輸出答案。
以下是Python的寫法。這題想到方法後,實作並不困難,前兩行輸入資料,第3行把座標排序,第7行先計算第一個區間的成本(距離總和),接著開始歷遍每一個區間,第9 ~14行計算每一個區間的成本,我們用三個變數le, ri, med記錄著左右端與中位數,雖然可以只用一個變數來做,其他兩個可以用運算得到,但多用兩個變數比較不傷腦筋,程式也清楚。這裡用的是sliding window計算差值的方式。
第16 ~ 18行計算成本的前綴最小值放在pmin,第20行計算出答案,最後輸出答案。
下面我們也揭示第二種利用前綴和的成本計算方式,其餘部分則沒什麼改變。
End