ACM程式設計學期總結
心路歷程
大一的時候就跟著費老師學了近一個學期的ACM程式設計課程,但是由於種種出自於自己的原因,當時並沒有完全塌下心去學,只是學到了些基礎的東西,做的題太少,當然也並沒有深入理解。大二又有了這個機會,自然不能放過,又認認真真重新學了一遍。
雖然這學期這門課一共只學了四個專題,但是對我來說,收穫非常大,正像費老師說的:acm是思維的體操。它不停留在基本的語言層面,而是專注於解決各種實際問題的有一定難度的演算法,它雖然會用到各種思想、各種工具,但是它不拘泥於某種固定的程式,而是需要無時不刻根據問題來變通,更要為了提高執行效率、降低時間複雜度而不斷改進。 我相信這門課不僅對我以後的“程式猿”之路很有幫助,得到了新鮮雨露的邏輯思維對我的人生也必定有很大幫助。
ACM作為一個選修課,由於比較困難而且不好拿到學分,大多數同專業的同學都沒有選,但我認為上大學去努力多拿學分和得一些暫時看著非常耀眼的獎項並不是多麼重要的,重要的是能在這個過程中收穫到什麼,能夠得到多少,能對自己的未來發展有多大的幫助。而這門課除了學分比較難拿以外,我覺得從各個方面對自己的提升和幫助都非常大,這也是我堅持下去努力去學的原因。本來還想著看有沒有機會成為一個acm隊員,但是費老師說如果是14級的準備考研的同學,原則上是不收的,因為時間已經不允許了,明年的秋天正是備戰比賽的時候,而作為考研黨那時也正是備戰考研的關鍵時期。想當初,在大一的時候,如果一直堅持、努力,自己是有機會的,可是卻被自己浪費掉了,再也沒有後悔藥。
無論如何,在人生的道路上,只能向前看,沒有回頭路,現在還是剛剛20歲的青年,還是清晨剛剛升起的太陽,只要從現在開始努力,一切都不算晚。對於自己的目標來說,自己的努力和付出還有很大的不足,不過令人高興的事情還是有很多,自己一直在成長,一直在進步。下個學期還可以選費老師的acm後續課程,我會繼續努力,希望能夠可以更上一層樓。
在馬上要走向尾聲的這個學期裡,對於這門課來說,學習它的大部分時間就是刷題,在做題之中理解相應的演算法,實踐自己的思想。大部分題都是自己獨立完成,還有一部分自己實在ac不了也不去copy別人的程式碼,而是去看別人的解題思路,或者去理解別人程式碼中所蘊含的演算法思想,自己理解透徹之後再自己去寫,直到這道題通過。通過後,緊接著就去寫部落格,寫清題目大意、解題思路、ac的程式碼以及自己的感想。費老師安排的這種方式非常有效,既督促了自己去刷題,又在寫部落格的過程中將自己解題所用到的思想在心中得到了很大的鞏固,以後還可以去看自己的部落格來實現高效率的複習,非常棒。
這個學期馬上就要結束,第四個專題還在進行,自己的努力更會馬不停蹄,堅持住自己的心,去朝著自己的目標不斷邁進!
貪心演算法
所謂貪心演算法,就是在求最優解問題的過程中,依據某種貪心標準,從問題的初始狀態出發,直接去求每一步的最優解,通過若干次的貪心選擇,最終得出整個問題的最優解。
貪心演算法不是對所有問題都能得到整體最優解,關鍵是貪心策略的選擇,選擇的貪心策略必須具備無後效性,即某個狀態以前的過程不會影響以後的狀態,只與當前狀態有關。
貪心演算法的一般流程:
Greedy(A) //A是問題的輸入集合即候選集合
{
S={ }; //初始解集合為空集
while (not solution(S)) //集合S沒有構成問題的一個解
{
x = select(A); //在候選集合A中做貪心選擇
if feasible(S, x) //判斷集合S中加入x後的解是否可行
S = S+{x};
A = A-{x};
}
return S;
}
我所認為的貪心演算法並不是一個固定的程式,而是一種貪心思想,一種演算法思想。雖然貪心演算法是做出區域性最優解來取得全域性最優解,但是我認為要解決好一個問題首先要用全域性的眼光看待和分析問題。就比如 ProblemF 1005 花最多數量的紙幣 這個問題:給出1角、5角、1元、5元、10元的數量,以及想購買的圖書價格,在不需要賣家找零的前提下,求出最少需要花多少張紙幣以及最多需要花多少張紙幣,如果不能實現則輸出-1 -1。
要算出最少花多少紙幣比較容易,但是如果直接正向的去求最多能花多少紙幣則相當麻煩,但是如果反過來想,求最多花多少紙幣就相當於最少剩多少紙幣,這與前一相對簡單的問題幾乎一致,先去求它,就能將一個複雜的問題轉化成了一個相對簡單的問題。先用全域性的眼光去看,只有這樣才能清楚該怎麼做最好,該怎麼辦,該怎麼將大問題化解成小問題,也才知道該怎麼在區域性用貪心演算法去解決整體的問題。
除了先用全域性的眼光去看以外,還要學會用從數學的角度分析、解決問題。如:Problem D 1003 哈夫曼編碼 這個問題:給出一組字串,求出普通編碼將佔用的位數和哈夫曼編碼所用的位數,以及普通編碼與哈夫曼編碼比率(普通編碼長度除以哈夫曼編碼長度)。
雖然演算法上都是用哈夫曼樹去解決問題,但是如果從數學的角度仔細分析、觀察,會發現其中的數學規律:哈夫曼編碼所佔用的位數正好等於哈夫曼樹各個非根節點的權值之和。因此,將字元儲存好後按每個字母的數量將其進行排序,從小到大進行遍歷相加進行哈夫曼樹的組建,與此同時對哈夫曼樹各個非根節點的權值進行累加即可得結果。而如果用這個規律去解決哈夫曼編碼問題,問題將會迎刃而解,變得相當簡單。
由於是用區域性最優解獲得全域性最優解,在解決問題的過程中很容易產生邊界問題,一些本必須被考慮、被處理的情況很有可能並沒有被納入貪心標準,也因此沒有被處理,最終導致錯誤。所以一定要注意邊界情況,不要少、漏。
此外,STL容器往往是伴隨著貪心演算法的一個重要幫手,利用STL容器可以在解決貪心問題的時候更加方便、快捷,效率也更高。因此在恰當的時候儘量多使用STL容器,既提高效率,也能提升對STL容器的熟練度。
典型題:一個兩維的方格陣列,從左上角走到右下角,每個格子裡都有一個數字K ( |K|<100 ),每一步(從(x,y)走)有三種走法:向下走一個格(x+1,y)、向右走一個格(x,y+1)或者(x,y*k) ,k為大於等於2的整數,走過的數字將累加。問:到達右下角時最大值為多少?
思路:先列遍歷,再對每一行遍歷,每一個格的最大值為當前格子原有的值加上所有的可能的上一步的最大值。注意格子裡值的範圍,有可能為負數。
實現程式碼:
#include <iostream>
#include <stdio.h>
#include <string.h>
#include <algorithm>
using namespace std;
int c,n,m;
int dp[21][1001];
int cmax(int _i,int _j)
{
int _cmax=dp[_i][_j-1];
for(int i=2;i<=_j;++i)
if(_j%i==0)
if(dp[_i][_j/i]>_cmax)
_cmax=dp[_i][_j/i];
if(dp[_i-1][_j]>_cmax)
_cmax=dp[_i-1][_j];
return _cmax;
}
int main()
{
scanf("%d",&c);
while(c--)
{
scanf("%d%d",&n,&m);
for(inti=0;i<=m;++i)
dp[0][i]=-9999999;
for(inti=0;i<=n;++i)
dp[i][0]=-9999999;
for(inti=1;i<=n;++i)
for(intj=1;j<=m;++j){
scanf("%d",&dp[i][j]);
if(!(i==1&&j==1)) dp[i][j]+=cmax(i,j);
}
printf("%d\n",dp[n][m]);
}
return 0;
}
搜尋
所謂搜尋演算法,就是在解的空間裡,從一個狀態轉移(按照要求拓展)到其他狀態,這樣進行下去,將解的空間中的狀態遍歷,找到答案。
搜尋又分為廣搜(BFS)和深搜(DFS)。
深搜(DFS)即深度優先搜尋,始終對下一層的結點優先進行搜尋,後面層數的結點遍歷過後再返回上一層,逐層返回,直到根節點。深搜強調的是“全”,即所有結點都將至少遍歷一遍。因此,深搜往往適用於解決一些“所有”型別的問題,如求:“所有方案”,“所有個數”。
廣搜(BFS)即廣度優先搜尋,往往利用queue佇列,始終優先遍歷前一層的結點,當前一層的結點全部遍歷過後再遍歷下一層的結點。當發現所需目標時,立即結束搜尋,不再進行下一步的遍歷。因此,廣搜往往適用於求“最”型別的問題,如求:“最少步數的”,“最小轉折次數”。
搜尋的實質是遞迴(尤其是深搜)、是窮舉,遍歷的過程中,所需求的狀態必須滿足一定的條件,最常見的如:不能超出地圖範圍,行走的方式等。
搜尋實際上也是利用電腦的強大的計算能力進行大規模的運算求解答案,因此“剪枝”也非常的重要(對於oj的題來說,有時候不剪枝還會TLE),利用剪枝來減少計算量以及減少重複運算,從而降低時間複雜度。在剪枝時,常用的方法如:使用一個數組,來儲存遍歷過的位置、狀態。
當掌握了搜尋演算法的思想、熟悉了DFS和BFS格式之後,解決問題的難點就在對問題的分析和條件的分析上了。要清楚如何利用搜索這個工具去解決這個問題,這關係著程式碼的具體實現。而當問題比較複雜的時候,需要滿足的條件往往也不是那麼顯而易見,需要去思考、捉摸,考慮有可能出現的各個情況(符合要求的和不符合要求的條件都要考慮到),不能漏條件,更不能錯。
典型題:給出一個地圖,其中有一個起始點,標記為"."的地方可以走,為"#"的不能走。只能直走,不能斜向前進。求能到達的所有地區數。
思路:利用DFS,找出能到達的所有"."地區,每找到一個進行標記,從而剪枝、減少重複計算。
因此,此題遞迴共有3個條件:
①:所遍歷到的行、列不能超出地圖範圍;
②:所遍歷到的地區必須為"."標記;
③:此地區在之前的遍歷過程中沒有沒標記過;
實現程式碼:
#include<iostream>
#include<stdio.h>
#include<string.h>
#include<queue>
using namespace std;
int cmap[21][21];
bool mark[21][21];
int dir[4][2]={{-1,0},{0,-1},{0,1},{1,0}};//上、左、右、下
int w,h;
int dfs(int r,int c)
{
int num=1;
for(int i=0;i<4;++i)
{
int temp_r=r;
int temp_c=c;
temp_r+=dir[i][0];
temp_c+=dir[i][1];
if(temp_r>=0&&temp_r<h&&temp_c>=0&&temp_c<w){
if(cmap[temp_r][temp_c]==2&&mark[temp_r][temp_c]==false){
mark[temp_r][temp_c]=true;
num+=dfs(temp_r,temp_c);
}
}
}
return num;
}
int main()
{
while(scanf("%d%d",&w,&h)!=EOF)
{
if(w==0)
return 0;
memset(mark,false,sizeof(mark));
int r_s,c_s;
for(int i=0;i<h;++i)
for(int j=0;j<w;++j){
char temp;
cin>>temp; //如果用scanf("%c",&temp);會出現錯誤,需要考慮輸入中的回車問題。
if(temp=='@'){
cmap[i][j]=1;
r_s=i;
c_s=j;
}
else if(temp=='.')
cmap[i][j]=2;
else if(temp=='#')//此條件語句可不寫。
cmap[i][j]=3;
}
mark[r_s][c_s]=true;
int cnt=dfs(r_s,c_s);
printf("%d\n",cnt);
}
}
動態規劃(DP)
所謂動態規劃,就是先求取區域性最優解,最後來得到全域性最優解。或者是,先求得當前階段的最優解,最後得到全部階段結束後的最優解。
當求區域性最優解時,也不能只是僅僅著眼於區域性,而是考慮著全域性,在符合全域性的目標和條件下來求解區域性最優解(這點有點像現實中的規劃)。
既然重點是求區域性,那麼要弄清從哪裡開始,到哪裡結束。更要弄清開始時怎麼設計,結束時和中間部分又是怎麼設計,需不需要特殊的設定。
只有真正將對應的問題理解透徹,才能將對應的動態規劃演算法寫好。
要弄清是用一個橫向的結構來實現 如:蜜蜂爬蜂房,蜜蜂只能爬向右側相鄰的蜂房,計算蜜蜂從蜂房a爬到蜂房b的可能路線數。狀態轉移方程為:a[i]=a[i-1]+a[i-2]
還是樹形結構來實現 如:數塔問題,給出一個數塔,從頂層走到底層,每一步只能走到相狀態轉移方程為:鄰的結點,求經過的結點的最大數字之和。狀態轉移方程為:a[i][j]+=max(a[i+1][j],a[i+1][j+1]),
亦或矩陣結構來實現 如:求最長上升子序列的長度,給出X和Z兩個字串,求最長上升子序列的長度。利用矩陣。X字串中的各字元依次作為行標,Z字串中的各字元依次作為列標。從第一行第一列開始逐行遍歷:如果當前位置對應的兩個字元相同,則在這個位置記錄"前一行前一列"的對應的數+1;如果當前位置對應的兩個字元不同,則在這個位置記錄"此行前一列"和"此列前一行"對應的兩個數的最大值。遍歷結束後,最後一行最後一列獲得的數便是最長上升子序列的長度。
狀態轉移方程為:
if(a[i-1]==b[j-1])
cmap[i][j]=cmap[i-1][j-1]+1;
else
cmap[i][j]=cmax(cmap[i-1][j],cmap[i][j-1]);
這些結構都是具體實現演算法的基礎。
在做過的題中,有很多是揹包問題,它們的結構都很相似,往往都是兩層迴圈,外層對物品進行遍歷,內層對揹包的容量進行遍歷。
雖然揹包問題看著都很相似,但要想真正解決問題依然需要對問題有完全的認識和掌控,需要對細節滴水不漏的考量,也只有這樣,才能在一些變化比較大的問題裡遊刃有餘,不被固定的格式所限制,如:反向考慮的揹包,某人準備搶銀行,可以承受的最大被抓的概率為p(總共),共有n個銀行可搶,分別給出各個銀行所擁有的money:mi,搶各個銀行被抓的概率pi。求可以搶到的最大金額。因為概率值的範圍為0~1,即有小數,所以必須反向來考慮。
可以承受的最大被抓的概率為p,即:如果逃跑的概率大於1-p則符合要求。將所有銀行的總錢數作為揹包的容量,dp陣列各元素對應的值為逃跑的概率。如果搶兩個銀行i和j,則逃跑概率為(1-pi)*(1-pj),即兩個銀行逃跑的概率之積。(涉及到概率論)狀態轉移方程為:dp[j]=max(dp[j],dp[j-a[i]]*(1-b[i]));
典型題:在一個無限大的平面,只能向前、向左、向右走,不能向後走,走過的路不能再走。給出走的步數n(n<=20),求總方案數。
思路:設F(n)為走n步的總方案數,a(n)為走n步最後一步為向前走的總數,b(n)為走n步最後一步為向左走或向右走的總數。
可以推出:
①F(n)=a(n)+b(n); (比較顯而易見)
②a(n)=a(n-1)+b(n-1); (第n-1步不管是向前走的還是向左或向右走的都可以在第n步向前走)
③b(n)=2*a(n-1)+b(n-1);(第n-1步如果是向前走的,那麼在第n步既可以向左走,也可以向右走,所以a(n-1)要乘以2;而第n-1步如果是向左走的,則不能向右走,第n-1步向右走的不能向左走,否則道路會塌陷,因此b(n-1)不用乘以2)
④a(n-1)=F(n-2); (不管第n-2步是如何走的,都可以在第n-1步向前走)
由上述4式可得狀態轉移方程式:a[i]=2*a[i-1]+a[i-2];
又因:a[0]=3; a[1]=7; 問題便迎刃而解了。
實現程式碼:
#include <iostream>
#include <stdio.h>
#include <string.h>
using namespace std;
int a[21];
void dp()
{
a[0]=3;
a[1]=7;
for(int i=2;i<=19;++i){
a[i]=2*a[i-1]+a[i-2];
}
}
int main()
{
int n;
scanf("%d",&n);
dp();
while(n--)
{
int m;
scanf("%d",&m);
printf("%d\n",a[m-1]);
}
return 0;
}