# APCS 2023年01月題解 ## 第一題 程式考試 ### 題目敘述 https://zerojudge.tw/ShowProblem?problemid=j605 ### 解套 N/A(抱歉我真的不知道要寫甚麼) ### 題目分析 維護三個變數$mx$、$mxtime$、$errors$存最高分、最高分發生時間與嚴重錯誤次數 接下來就一一讀入測資並依題目敘述處理,時間複雜度$O(K)$ ### 解題流程 讀入測資$→$更新變數$→$輸出 ### 解題過程 #### 變數宣告 ```cpp=3 int k,s,t,mx=-1,mxtime,errors=0; ``` #### 輸入與處理 ```cpp=5 cin>>k; for(int i=0;i<k;i++){ cin>>t>>s; if(s==-1)errors++; else if(s>mx){ mx=s; mxtime=t; } } ``` 得到時間與分數後,若為嚴重錯誤則將$errors$加一, 不然判斷該分數是否為目前最高的,若是則更新$mx$與$mxtime$ #### 輸出 ```cpp=14 cout<<(mx-k-errors*2>0?mx-k-errors*2:0)<<" "<<mxtime; ``` 分數部分按照題意計算後,若為負數輸出$-1$ ※三元運算子: ```cpp if(mx-k-errors*2>0)cout<<mx-k-errors*2; else cout<<0; //等價於 cout<<(mx-k-errors*2>0?mx-k-errors*2:0); ``` ### 完整程式碼 ```cpp=1 #include<bits/stdc++.h> using namespace std; int k,s,t,mx=-1,mxtime,errors=0; int main(){ cin>>k; for(int i=0;i<k;i++){ cin>>t>>s; if(s==-1)errors++; else if(s>mx){ mx=s; mxtime=t; } } cout<<(mx-k-errors*2>0?mx-k-errors*2:0)<<" "<<mxtime; } ``` ## 第二題 造字程式 ### 題目敘述 https://zerojudge.tw/ShowProblem?problemid=j606 ### 解套 裸的字串處理,唯一需要注意的是輸出需求 ### 題目分析 按照題目需求照做就好,並在每個操作後把會用到的字元存起來,最後再輸出。 實作上採用比較直觀但稍微沒有效率的做法(反正這是APCS第二題複雜度不是重點),就是每次操作直接原地複製一個新字串,並以其為操作基準 每次操作完再將新字串第$0~r-1$個字元存起來,時間複雜度$O(Q(R+K))$ ### 解題流程 輸入$→$$($輸入操作$→$處理、儲存$)_{循環}$$→$輸出 ### 解題過程 #### 變數宣告 ```cpp=3 string s; int k,q,r,p[20]; ``` 變數名稱與題目描述相同 ```cpp=7 vector<vector<char>>ans(r,vector<char>(q,' ')); ``` 儲存答案的陣列,大小為$r*q$ #### 操作處理 ```cpp=8 for(int i=0;i<q;i++){ for(int j=0;j<k;j++)cin>>p[j]; string tmp=s; for(int j=0;j<k;j++)s[p[j]-1]=tmp[j]; for(int j=0;j<r;j++)ans[j][i]=s[j]; } ``` 先讀入操作陣列 $p$,再複製一個跟$s$一樣的$tmp$當做操作時的參考 接下來(第11行)就是按照$tmp$ 與 $p$把新字串複製到$s$ 裡 最後(第12行)將操作後的字串存到答案陣列裡 #### 輸出 ```cpp=14 for(int i=0;i<r;i++){ for(int j=0;j<q;j++)cout<<ans[i][j]; cout<<"\n"; } ``` ### 完整程式碼 ```cpp=1 #include<bits/stdc++.h> using namespace std; string s; int k,q,r,p[20]; int main(){ cin>>k>>q>>r>>s; vector<vector<char>>ans(r,vector<char>(q,' ')); for(int i=0;i<q;i++){ for(int j=0;j<k;j++)cin>>p[j]; string tmp=s; for(int j=0;j<k;j++)s[p[j]-1]=tmp[j]; for(int j=0;j<r;j++)ans[j][i]=s[j]; } for(int i=0;i<r;i++){ for(int j=0;j<q;j++)cout<<ans[i][j]; cout<<"\n"; } } ``` ## 第三題 先加後乘與函數 ### 題目敘述 https://zerojudge.tw/ShowProblem?problemid=j607 ### 解套 計算一式子,但是先加減後乘,並且有一神秘函數$f$,會回傳參數內的最大值減最小值 ### 題目分析 基本上跟經典的「計算式子」題目相似,只是少了除法,以及多了$f$函數 就我所知一般會用遞迴或是stack寫,我個人是偏好遞迴的寫法 實作上我寫了三個函式,分別是獲取數字的getnum、一般計算的cal以及算$f$函式的fcal 在一開始先讀入整個字串,並用全域變數id紀錄目前處理到哪一個位置 時間複雜度$O(N)$,其中$N$為字串長度 ### 解題流程 呼叫cal函式$→$輸出答案 ### 解題過程 #### 開 long long ```cpp=2 #define int long long ``` 因為到最後才發現,而且懶得把每個int都改成long long 不過int main()要改成signed main(),因為main函式不能是 long long #### 變數宣告 ```cpp=4 string s; int id=0; ``` id紀錄處理到字串的第幾個位置 #### 函式宣告 **1.getnum 函式** ```cpp=6 int getnum(){ int ans=0; while(id<s.size()&&'0'<=s[id]&&s[id]<='9'){ ans=ans*10+s[id]-'0'; id++; } return ans; } ``` 跟一般字串轉數字的方法差不多,好像可以用stoi,但我不太會用 **2.cal、fcal函式** ```cpp=14 int cal(bool cmp); int fcal(); ``` 因為他們會互相調用,所以先宣告,到main函式後再定義 **3.cal函式** ```cpp=33 int cal(bool cmp){ int n; if('0'<=s[id]&&s[id]<='9')n=getnum(); else n=fcal(); if(cmp||s[id]==','||s[id]==')')return n; while(id<s.size()){ if(s[id]=='+'){ id++; int b=cal(1); n+=b; } else if(s[id]=='-'){ id++; int b=cal(1); n-=b; } else if(s[id]=='*'){ id++; int b=cal(0); n*=b; } if(s[id]==','||s[id]==')')return n; } return n; } ``` cal的參數cmp是決定「要不要馬上回傳」 因為本題先加減後乘,如果遇到加或減需要先運算,乘則不用,在後面會有詳細解釋 一開始先宣告一個變數n來運算與當作回傳的答案 因為呼叫cal時第一個不是數字就是f,故依此決定n的數值(第34~36行) 此時若cmp為1(須馬上回傳)、碰到「,」或「)」(代表在f函式內),則直接回傳n(第37行) 接下來就是不斷向後運算(第38~55行),此時分做4種狀況 * 遇到加號 1.將id加一,移到下一個數字 2.呼叫cal(1)並將其值存在變數b裡 3.把n加上b * 遇到減號 1.將id加一,移到下一個數字 2.呼叫cal(1)並將其值存在變數b裡 3.把n減掉b * 遇到乘號 1.將id加一,移到下一個數字 2.呼叫cal(0)並將其值存在變數b裡 3.把n除上b * 遇到「,」或「)」 直接回傳數字 在這邊解釋一下cmp的運作: 假設有一式子$5+2*3+1$,依照這題的規則會得到28(7*4) 當我算到5後面的加號時,因為先加減後乘除,我希望這個加號會**馬上算到**,因此cmp設為1, 這樣算到2時2就會直接回傳,而非繼續往後算$*$的部分 同理算到$*$時,會希望後面的3+1先算完再一起乘,所以cmp設為0, 這樣算到3時就會繼續往後算,最後再回傳 函式最後回傳n(第57行) **4.fcal函式** ```cpp=20 int fcal(){ id+=2; int mx=cal(0),mn=mx; while(s[id]!=')'){ id++; int tmp=cal(0); mx=max(mx,tmp); mn=min(mn,tmp); } id++; return mx-mn; } ``` 一開始先把id加二以跳過$"f("$(第21行) 因為f函式內至少有一個數,故將此數以cal(0)取得後同時設為最大與最小值 接下來就是不斷地將id加一、用cal(0)取得f裡的數字、與更新最大最小值 這個步驟重複到遇到「)」為止(f函式的結束)(第23~29行) 最後將id加一以跳過「)」,並回傳最大值減最小值 #### main函式 ```cpp=16 signed main(){ cin>>s; cout<<cal(0); } ``` 輸入字串後輸出cal(0)的值 其中signed跟int同義,用signed是避免被define轉成long long ### 完整程式碼 ```cpp=1 #include<bits/stdc++.h> #define int long long using namespace std; string s; int id=0; int getnum(){ int ans=0; while(id<s.size()&&'0'<=s[id]&&s[id]<='9'){ ans=ans*10+s[id]-'0'; id++; } return ans; } int cal(bool cmp); int fcal(); signed main(){ cin>>s; cout<<cal(0); } int fcal(){ id+=2; int mx=cal(0),mn=mx; while(s[id]!=')'){ id++; int tmp=cal(0); mx=max(mx,tmp); mn=min(mn,tmp); } id++; return mx-mn; } int cal(bool cmp){ int n; if('0'<=s[id]&&s[id]<='9')n=getnum(); else n=fcal(); if(cmp||s[id]==','||s[id]==')')return n; while(id<s.size()){ if(s[id]=='+'){ id++; int b=cal(1); n+=b; } else if(s[id]=='-'){ id++; int b=cal(1); n-=b; } else if(s[id]=='*'){ id++; int b=cal(0); n*=b; } if(s[id]==','||s[id]==')')return n; } return n; } ``` ## 第四題 機器出租 ### 題目敘述 https://zerojudge.tw/ShowProblem?problemid=j608 ### 解套 很像是經典的排程問題,只是有不只一個機器 ### 題目分析 這題如果只看前兩組測資,會發現機器的數量$K$都是一,也就是一般常見的排程問題 這時常用的貪心解是將所有時間區段按照結束時間由小到大排序,若相同再按開始時間由大到小排序,最後再從頭遍歷,遇到能執行的活動就執行 ~~唬爛的~~理由是因為工作越早結束代表越能趕快做下一個所以排前面, 而同樣結束時間下時間越短的(開頭越大)越可能讓前一項工作完成,故排前面 這時我們再考慮滿分解,也就是機器數$K$大於一的情況 原本的排序方式可以照用,但需要多考慮的是要怎麼安排不同機器的使用時間 這時我們發現,若考慮我們的排序方式,在所有能用的機器當中,用「上一次結束時間最接近的」不會比較差,畢竟更早結束的搞不好能處理後面更早開始的 實作上我們用C++內建的multiset,但考慮到要找「小於」的元素只能用「大於等於」的lower_bound找等於再往前推,感覺會出事,所以把它做一下轉換 因為題目說時間最大只有$10^8$,所以用$10^8$減每個數字就變成找「大於」的,就能用upper_bound了! 這樣時間複雜度$O(NlogN+NlogK)$ (後來看別人的題解才發現可以用lower_bound再把指標往前移,多判指標是不是開頭就好,但感覺有點麻煩,我也沒想到) ### 解題流程 讀入測資$→$排序$→$一次遍歷、處理$→$輸出 ### 解題過程 #### define ```cpp=2 #define F first #define S second ``` 為了少打一些字 #### 變數宣告 ```cpp=4 int n,k; vector<pair<int,int>>v; ``` n,k與題目定義一樣,v儲存各活動的開始與結束時間 #### 自定義排序 ```cpp=7 bool cmp(pair<int,int>a,pair<int,int>b){ if(a.S!=b.S)return a.S<b.S; else return a.F>b.F; } ``` 按照前述條件進行排序 #### 輸入 ```cpp=12 cin>>n>>k; v.assign(n,{0,0}); for(int i=0;i<n;i++)cin>>v[i].F; for(int i=0;i<n;i++)cin>>v[i].S; ``` 其中第13行讓v大小變成n,且每個元素都是{0,0} #### 排序 ```cpp=16 sort(v.begin(),v.end(),cmp); ``` #### 遍歷、處理 ```cpp=17 int ans=0; multiset<int>st; for(int i=0;i<k;i++)st.insert(1e8+1); for(auto i:v){ auto it=st.upper_bound(1e8-i.F); if(it!=st.end()){ ans++; st.erase(it); st.insert(1e8-i.S); } } ``` 一開始先在multiset放k個$10^8-(-1)$,代表尚未執行任務的機器(上一次結束時間-1) 接下來遍歷(第20~27行),每次用$10^8$減開始時間找upper_bound,若upper_bound存在代表任務可以執行,於是把答案加一,並用新的結束時間取代舊的結束時間(第24、25行) #### 輸出 ```cpp=28 cout<<ans; ``` ### 完整程式碼 ```cpp=1 #include<bits/stdc++.h> #define F first #define S second using namespace std; int n,k; vector<pair<int,int>>v; bool cmp(pair<int,int>a,pair<int,int>b){ if(a.S!=b.S)return a.S<b.S; else return a.F>b.F; } int main(){ cin>>n>>k; v.assign(n,{0,0}); for(int i=0;i<n;i++)cin>>v[i].F; for(int i=0;i<n;i++)cin>>v[i].S; sort(v.begin(),v.end(),cmp); int ans=0; multiset<int>st; for(int i=0;i<k;i++)st.insert(1e8+1); for(auto i:v){ auto it=st.upper_bound(1e8-i.F); if(it!=st.end()){ ans++; st.erase(it); st.insert(1e8-i.S); } } cout<<ans; } ``` ## 心得 這次APCS因為有事所以也沒有考,原本有點懊悔,但因為那陣子剛好在段考前,過得十分忙碌,所以後來想想沒去考或許就結果來說是比較好的 後來聽有去考的同學的說法,有的說這次「很簡單,跟送分一樣」,也有的說自己「死定了」,所以結論就是沒有結論...(可能就只是跟題目種類擅不擅長有關吧) --- 個人認為第一題的難度跟之前差不多,但第二題明顯感覺比之前簡單(除了輸出稍微複雜一點點以外沒有甚麼複雜的操作),或許也能因此增加考試中想第三、四題的時間(我猜的),這兩題基本上都蠻快寫出來的,也不需要甚麼特殊技巧 第三題時間主要是花在想$f$函數的處理方式,試了幾次才想到用逗點和右括弧當額外的中止條件,id的控制也花了一點時間思考。 個人還是習慣用遞迴,但聽說用stack好像可能比較有效率?(但我這題排行榜第一所以...我也不知道) (更新:被擠下去了,但還在排行榜裡) 第四題一開始根本沒想法。 後來想了一下之後想起來$K=1$的Greedy解法,但機器多於一台還是不知道怎麼處理 後來只好去看別人的題解,知道了要找能用的機器中上一次結束時間最接近的 後來自己想到用$10^8$減活動時間來換成用upper_bound,也成功AC,但後來看別人的code才發現直接把指標往前移就好了,不過個人認為兩種做法都行 --- 這次APCS讓我發現我Greedy還需要加強,畢竟只想得到以前寫過的題目的寫法。 雖然DP和Greedy某種程度上或許比較靠天分一點才能推(通)論(靈)出解法,但多練習應該能培養出解題的感覺,讓自己更快想出關鍵的步驟 而且它們都是APCS第四題很容易出的範圍,如果要拿五級分不弄懂不行