非準程序員請繞道,這篇文章不是你想看的。(而且很長,雖然滿滿的干貨)
寫下這個字的時間點是23:53,是時候關(guān)掉電腦鉆進被窩戴上耳機聽會歌準備入睡了。或許是睡意還不太明顯,畢竟放假整天吃吃喝喝睡睡嘛,腦洞就閃進了一個問題,你想聽歌啊?聽多久?歌單怎樣選最好?
這樣,問題就來了。。。
睡前歌單
我網(wǎng)易云歌單有539首歌,現(xiàn)在要從中選出一些今晚要聽的歌來組成睡前歌單,如果我給每首歌標上喜歡程度(真希望網(wǎng)易云給加上這個功能),星級從一到五。而且聽歌時間不能多于一個小時。那,怎樣選才使自己最滿意呢?
顯然,衡量滿意度的標準就是選上的歌的喜歡程度的星級總和。記為S。
這個問題并沒有我文字描述的那樣無聊和簡單,暴力破解的方法當然可以把睡前歌單給弄出來,把所有歌都組合一遍,選出既滿足總時間長度小于一個小時又星級總和最高的不就搞定了嗎!?對啊,這樣當然可以,然而,算算運算量是多大吧,n首歌就是O(2n)的運算量,我有539首歌就是2539量級的運算量啊,全世界的人幫我算我今晚甚至這輩子歌也是聽不成了!
來來來,程序員入場,分析一下選歌過程吧。
總歌單是已經(jīng)有的,記這個歌單為Like,歌單排列第i的歌表示為Like[ i ],歌曲長度為Like[ i ].Length,星級為Like[ i ].Star,聽歌時間記為T(為簡化問題,假設時間的最少長度均為1s,即都是整數(shù))。
現(xiàn)在,我們從歌單Like上的第一首歌來慢慢分析,第一首歌選不選呢?選的話,這首歌要占去Like[1].Length的時間,而S增加了Like[1].Star,然后我們再考慮T-Like[1].Length時間下選不選Like[2]。不選的話,那跳過它,我們繼續(xù)考慮是否選Like[2]。咦,選或不選之后,我們好像又是要重復考慮選或不選哦,遞歸!最優(yōu)子結(jié)構(gòu)啊!動態(tài)規(guī)劃可以來解決這個問題!
動態(tài)規(guī)劃
動態(tài)規(guī)劃(Dynamic Programming),一般稱DP。
對于某一類問題,我們可以用DP方法來解決。《算法導論》中是這樣說的
1.刻畫一個最優(yōu)解的結(jié)構(gòu)特征
2.遞歸地定義最優(yōu)解的值
3.計算最優(yōu)解的值
4.利用計算出的信息構(gòu)造一個最優(yōu)解
算法導論上的表述很是通俗,其實對于DP,核心是兩個東西,一個是狀態(tài)的定義,一個是確定狀態(tài)轉(zhuǎn)移方程。
狀態(tài)是什么?就是問題本身,我們怎樣描述這個問題,就是在描述這個狀態(tài)。
狀態(tài)轉(zhuǎn)移方程是什么?就是從問題到子問題的狀態(tài)轉(zhuǎn)移,對應于上面的第2點,一般形式就是遞歸方程。
一臺計算機,歸根到底就是一臺狀態(tài)機,我們把一個狀態(tài)輸入進去(比如2+2),狀態(tài)機通過某種方法,將狀態(tài)轉(zhuǎn)化為一個更易解的狀態(tài)(比如變成了1+1+1+1),依次下去,直到得出解。這個就不展開說了,可參見:有限狀態(tài)機-維基百科
回到我們的問題。現(xiàn)在我們來定義狀態(tài),對于時間為T(單位秒)的聽歌時間,有N首歌,定義MaxS[N][T]:在前N首歌中選擇歌單,使其總長度不多于T,使S(星級總和)最大。最大值就記為MaxS[N][T]。對應我的歌單,最優(yōu)解就可表示為MaxS[539][3600],在前539歌中選擇歌單,使其總長度不多于3600秒,并使S最大。這里我用了二維數(shù)組來表述,是為了說明在實際代碼中我們可以用二維數(shù)組來直接表達這個狀態(tài)。
接下來就是重中之重的狀態(tài)轉(zhuǎn)移方程,我先直接寫出來:
MaxS[N][T]=max{ MaxS[N-1][T] , MaxS[N-1][T-Like[i].Length] + Like[i].Star }
上面這鬼式子什么意思呢?在N首歌中選擇,那第N首選不選?不選的話,則狀態(tài)變?yōu)镸axS[N-1][T],因為現(xiàn)在我們要從前N-1首歌中選了,而可以聽的時間依然不變,并沒有減少。而選呢,那么狀態(tài)變?yōu)镸axS[N-1][T-Like[i].Length],因為依然要繼續(xù)從剩下的N-1首歌中選,可以聽的時間也減少了Like[i].Length,而S增加了Like[i].Star。選或不選哪個最大?最大的那個自然就等于MaxS[N][T],狀態(tài)轉(zhuǎn)移方程就這樣出來了。
借助狀態(tài)轉(zhuǎn)移方程,就可以動手碼代碼了。
其中核心偽代碼如下,初始條件是當i等于0時,MaxS均等于0。
for i=0 to N
for j=0 to T
MaxS[i][j]=MaxS[i-1][j]
if j >= Like[i].Length and MaxS[i-1][j] < MaxS[i-1][T-Like[i].Length] + Like[i].Star
MaxS[i][j] = MaxS[i-1][T-Like[i].Length] + Like[i].Star
上面的偽代碼表現(xiàn)的是自底向上的動態(tài)規(guī)劃算法,時間復雜度顯然是O(N*T)。好像比O(2^N)少了好多了誒。
在上面?zhèn)未a循環(huán)完成后,我們只要在后面加一個for循環(huán),比較所有MaxS[i][j]和MaxS[i-1][j],如果前者大于后者,則第i首歌是要選的,否則,第i首歌是不選的,這樣最優(yōu)解就構(gòu)造出來了,這個過程復雜度是O(N),所以整個過程時間復雜度還是O(N*T)。
動態(tài)規(guī)劃一般之所以能大幅度減少運算時間,是因為其遞歸分解出來的子問題有很大一部分是重疊的,我們稱之為重疊子問題。在樸素算法中,由于不對重疊子問題做處理,而是直接暴力運算,所以導致時間復雜度一般為指數(shù)型的,比如前面說到的組合來求解睡前歌單,復雜度為O(2*N),而動態(tài)規(guī)劃則利用空間換時間的思想,將所有遇到的子問題的運算結(jié)果都保存下來,這樣,接下來的運算中,如果遇到了同樣的問題,則直接得到結(jié)果而不用再算一遍,所以一般能將時間復雜度降到多項式時間。能運用動態(tài)規(guī)劃來快速解決的問題一般具有兩個要素:最優(yōu)子結(jié)構(gòu),重疊子問題。最優(yōu)子結(jié)構(gòu)保證了能用動態(tài)規(guī)劃算法,重疊子問題保證了動態(tài)規(guī)劃算法具有很好的時間復雜度。在上面的偽代碼中,我們自底向上,利用二維數(shù)組保存運算數(shù)據(jù),類似的問題一般也是這樣做的,當后面想要獲取MaxS[i-1][j]的值的時候,我們已經(jīng)計算好并只計算了一次這個值,直接給就行了,所以時間復雜度就大幅降低變成O(N*T)了,今晚就能算完開始聽了哈哈哈哈。。。。。
冷靜冷靜
好像哪里不對,運用動態(tài)規(guī)劃能降低時間復雜度是因為具有大量的重疊子問題,在上面的偽代碼中,有哪些數(shù)組的值我們需要用到至少兩次的???好像并沒有啊!基本都只用到了一次啊!!!媽蛋問題又來了。
在上面的偽代碼中,只有在很巧合的情況下,某些數(shù)組的值會被用到兩次,比如某首歌的時間長度為0,或者其中一首歌的長度恰好為另外兩首歌的長度之和。其實,在寫狀態(tài)轉(zhuǎn)移方程的時候,我就覺得那式子的形式很眼熟了。現(xiàn)在細看,不就是背包問題的狀態(tài)轉(zhuǎn)移方程嗎,兩者是等價的好嗎。。。而背包問題是著名的NP完全問題。
NP完全問題
P類問題是指能在多項式時間內(nèi)解決的問題,即O(n^k),比如排序,我們能在O(nlgn)時間內(nèi)解決。
NP類問題是指能在多項式時間內(nèi)被證明的問題。
P明顯是NP的子集,進一步的,P是否是NP的真子集?即P是否等于NP?答案就不那么顯而易見了,這個問題其實是當今最難的問題之一,你能給出答案,你就能走上人生癲瘋~
對于很多問題,如果我給出一個解,一般你都能在多項式時間內(nèi)證明這個解是不是最優(yōu)解,即NP類問題,然而,要在多項式時間內(nèi)直接給出這個問題的最優(yōu)解,就不是一個簡單的事情了,如果這個問題很難很難,目前并沒有多項式時間的解決算法,我們稱之為NP完全問題(NP-Complete,NPC),你能在多項式時間內(nèi)解決一個NP完全問題,就能在多項式時間內(nèi)解決所有的NP完全問題,然后你的獎金能在北上廣深買很多很多好吃的,相信我:)。
我們的選歌單問題是NP完全問題嗎?很不幸,是的。選歌單問題和0-1背包問題是等價的,兩者的復雜度形式都是O(x*y),看起來是多項式時間,其實并不是,通俗的理解,我們的歌曲數(shù)量是一首一首數(shù)的,那輸入數(shù)據(jù)規(guī)模就是一首一首增加的,而T是什么鬼?是時間啊,誰說時間跟歌曲數(shù)量是多項式相關(guān)的?從而得出結(jié)論是多項式時間的?Naive!
造物主說,時間在計算機中是以二進制表示的,所以時間的輸入數(shù)據(jù)長度其實是lgT=B,所以O(N*T)=O(N*2^B),哭了,是指數(shù)增長的。對于這種看起來是多項式時間算法,實際并不是的,稱之為偽多項式時間算法,同樣是偽多項式時間算法的還有素數(shù)求解算法等。
所以,又回到最初了,不管是直接組合暴力求解,還是動態(tài)規(guī)劃求解,時間復雜度均是非多項式的,所以能不能在睡前挑選完歌單,隨著聽歌時間長度和歌曲數(shù)目的增加,需要的挑選時間均是爆炸性增長的。然而,動態(tài)規(guī)劃依然是這個問題的最優(yōu)解法之一,雖然同是指數(shù)增長,但是動態(tài)規(guī)劃的求解比組合暴力求解需要的時間還是大大減少的。兩者至少不是同一個量級的。所以對于背包問題,我們一般也是用動態(tài)規(guī)劃來求解的。這里可以看出,利用重疊子問題從而可以空間換時間的性質(zhì)并不是動態(tài)規(guī)劃的本質(zhì),僅僅是錦上添花而已,動態(tài)規(guī)劃的本質(zhì)是利用最優(yōu)子結(jié)構(gòu)遞歸求解,專業(yè)的講是:對問題狀態(tài)的定義和狀態(tài)轉(zhuǎn)移方程的定義。
那,怎樣?有沒折中的方法?保證在不管時間長度和歌曲數(shù)量多少的情況下,我都能今晚就挑選出歌單。。。。
貪心算法
可以用動態(tài)規(guī)劃,當然就能用貪心算法了,算法導論中是這樣表述的:
1.將最優(yōu)化問題轉(zhuǎn)化為這樣的形式:對其做出一次選擇后,只剩下一個子問題需要求解。
2.證明做出貪心選擇后,原問題總是存在最優(yōu)解,即貪心選擇總是安全的。
3.證明做出貪心選擇后,剩余的子問題滿足性質(zhì):其最優(yōu)解與貪心選擇組合即可得到原問題的最優(yōu)解,這樣就得到了最優(yōu)子結(jié)構(gòu)。
又是啰里啰嗦的表述,簡單明了可以這樣說,對于一個問題,我們做出當下看起來最好的選擇,然后繼續(xù)求解剩下的唯一的子問題。
那選歌單的貪心算法是怎樣的呢?
很容易想到,要想S值最大,則聽歌過程每一秒獲得的Star值應該盡可能的大。
對于歌曲Like[i],聽它的時候,每一秒獲得的Star值是Like[i].Star / Like[i].Length。將這個值記為Like[i].StarPerSecond。
利用StarPerSecond進行從大到小排序,然后貪心算法就啟動啦,排最前的肯定選啊,然后選第二的,然后選第三的,,,,直到聽歌時間用完了,挑選也就完成了。時間復雜度是O(n),靠,多項式時間就這樣出來了。
那,這樣選出來的歌單是最優(yōu)的嗎?
不是的,貪心算法只能保證取得次優(yōu)解而不能保證最優(yōu)解。只有在踩到狗屎運的情況下,才能取得最優(yōu)解。
我們這樣選出來的歌單,只能使最后取得的S'接近最大值S,而不能保證取得S'=S。
畢竟我的歌單Like有很多歌而且要聽一個小時,我就選貪心算法吧,犧牲那么點滿意度換取大量的時間值得了。
對于NP完全問題,我們一般也是采用近似算法來取得多項式時間,而不強求一定要取得最優(yōu)解。
至于為什么不能保證S'=S
證明,略:)
原文轉(zhuǎn)自謝培陽的博客