攤還分析,核算法與勢能法
為什麼我們需要攤還分析
上篇文章我們提到了演算法的時間複雜度分析,給定輸入規模,我們分析出演算法的耗時,但是這樣夠了嗎?
有時輸入規模不是一個靜態的值,可能輸入是一系列操作,比如在這棵樹裡先插入結點,再做一個查詢,再刪除最小值,再與另一棵樹合併。(插入,查詢,刪除最小值,合併)就是一個輸入的操作序列。
為了對這類操作序列耗時進行分析,我們引入了攤還分析:n個操作的總耗時除以n。
看到這兒你可能會想,這不還是要分析總時間,和時間複雜度的區別不就除以個n而已嘛。但是對於時間複雜度的分析,我們關心的是最壞情況,假設我們有100個操作A,耗時為1,1個操作B耗時為1000,重點是:如果這個大耗時的操作發生,那麼它前一定要發生100個耗時小的操作,也就是必須發生100個A才會發生B。如果我有50個操作(無A),100個操作(無A),101個操作(無B),這時我們分析時間複雜度還要把這個大耗時操作分情況考慮進去。這種情況就是攤還分析大展身手的時候,它的思想就是把大操作的耗時分攤到前面的小鋪墊上
攤還分析在分析資料結構的操作耗時上特別有用,接下來我們來看幾個例子,他們都來自演算法導論:
例1. 用一個列表來實現棧
棧是一種資料結構,它具有後進先出的性質,如果要把資料從棧裡彈出去,後加進來的數會被先彈出去。舉個例子,洗碗池就是一個棧,碗就是資料,大家吃完飯,按吃完的順序把碗放入洗碗池裡,一個一個地按順序疊起來。最後放進來的碗會被第一個洗掉,後放進來的碗會被先洗掉移出洗碗池,這就叫後進先出。(哈哈哈哈現實中洗碗不是這樣的,誰會把它疊起來啊!)
儘管我們一般用的是連結串列來實現棧,但是這次就用用陣列吧。。
目標:從長度為1的陣列開始形成一個長度為n的棧。
我們現在有個陣列,它的長度固定了,假設它長度為5,就是它能放5個數據,OK,我們前5個數據入棧沒有任何問題,可是我想要第6個數據也入棧,怎麼辦?
假設1個數據入棧耗時為1。
我們建立一個新的陣列,長度比5長1,然後把這6個數據入棧。這會耗時為6。
按這種方法:一次就小氣地只新建比原來長1的陣列,我們想從長度為1的陣列開始,做n次入棧操作。因為每次入棧都要一次複製(棧的長度從1長到2,再長到3,…),我們會耗時1+2+3+4+…+n = Θ(n2),攤還分析的時間複雜度就是Θ(n2)/n = Θ(n)
還有一種更聰明的做法,當我們棧滿時還要加入元素,我們將棧的長度翻倍,比如從m變到2m,這耗費時間m(這是在原棧上直接翻倍,不是弄一個新的棧出來,所以不會花費時間2m)。所以這種做法有兩種操作:
1.翻倍操作,耗時為當前棧的長度
2.入棧操作,耗時為1
所以我們從長度為1的陣列開始,入棧n個元素,會出現如下操作序列:(入棧,翻倍,入棧,翻倍,入棧×2,翻倍,入棧×4,翻倍,入棧×8,翻倍…)這個序列也是我們之前提到的,需要有足夠多的入棧操作進行鋪墊,才會進行耗時多的翻倍操作。
接下來對這種聰明的辦法進行攤還分析:
翻倍操作總耗時為1+2+4+8+…+2logn< 2n = O(n)
入棧操作總耗時是n,因為有n個元素要入棧
所以它們總耗時是O(n)+O(n) = O(n)
那麼它的攤還代價是O(n) / n = O(1)。
可以看到,進行同一個事情,如果演算法不一樣,他們的攤還分析會不一樣,採用第二個聰明的方法,我們使得攤還到每個操作上的時間代價從O(n)變到了O(1)。
核算法與勢能法
為何要引進這兩個方法,因為有的時候總代價並不好算,我們需要一些更巧妙的方法來計算攤還代價。我們需要的找的攤還代價最後找的要是實際耗費時間的上界,因為我們關心的是最壞情況。
核算法和勢能法其實是一個方法的兩種角度,我們進行攤還分析其實核心就在於把耗時長的大操作付出的代價攤還給每個耗時短的小操作,讓大家共同承擔。
所以核算法就是,假設你有一個銀行賬戶,你看著這個序列有了初步想法,每次操作來臨時,你先存一筆固定的錢,錢的數目你之前已經想好了,可以是3,可以是n,可以是n2,這個每次固定存的錢就是攤還代價,然後按操作實際耗時來扣賬戶裡的錢。
這個存錢操作唯一要求就是你的銀行賬戶一直不能為負,因為我們要找的是攤還代價的上界,我們存進去的錢只能比總實際耗時多。假設有一個操作序列,是100個耗時為1的小操作後跟著1個耗時200的大操作,你打算每次存3元進去,小操作只花1元錢,所以你每次銀行賬戶裡還剩2元,接下來又來了99個小操作,你發財了,賬戶裡存了200元,恐怖的是耗時200的大操作接踵而至,由於之前的規定,你還是固定存3元進去去硬抗這次大操作,扣了200元后你的賬戶還剩3元,這是因為吃了之前的老本,你的賬戶還是正的(也就是攤還總代價還是實際代價的上界),所以攤還代價就是3,是常數時間。這就是攤還分析!固定存3元就代表了一種耗時代價均攤的思想。
關於勢能法,也是一樣的,只不過它關心的是每一次操作後狀態的改變。首先我定義一個勢函式,一開始勢函式的值是0,之後進行操作都會改變勢函式,具體怎麼改變是跟我的勢函式有關的。我們定義攤還代價如下所示:
c’i = ci+Φi-Φi-1
c’i是攤還代價,ci是第i次實際操作的代價,Φi-Φi-1是第i次操作後勢函式的變化,初始時勢函式Φ0=0。
對兩邊進行從1到n的求和,得出總攤還代價=總時間代價+Φn-Φ0
為了使得總攤還代價>總時間代價,Φn>0。
也就是為了使得總攤還代價是總時間代價的上界,我們需要讓勢函式最後不能為負。就像過山車一樣,有的操作是在蓄勢,像過山車爬坡,有的操作在放勢,像過山車下坡,但是總體來說不管怎麼蓄勢放勢,勢函式最後要高於水平線0。
核算法與勢能法的難點
這兩種方法還是比較巧妙的,但是需要比較強的分析能力和構造能力。核算法難就難在想出我固定存多少錢進銀行賬戶,是存常數?存n元?還是logn元?
勢能法難點在於勢函式的構造,我需要一個合理的勢函式,它的勢變化(Φi-Φi-1)和那一次操作實際代價(ci)之間的關聯最好能夠清楚地表現出來,成為我們的攤還代價(c’i)。
接下來舉例說明這兩種方法的應用
例2. 二進位制計數器
我們對一個二進位制數從0開始進行每次加1的操作,每翻一位數耗時為1,比如0000+1->0001,我們把最後一位從0翻成了1,耗時1。0101+1->0110,翻了最後兩位,耗時為2。
這一系列的加法操作的攤還代價是多少?
1.核算法
我們對每一次的加法操作都把它想成往銀行賬戶裡固定存2元,這樣我們對於二進位制中從0變到1的情況,除了支付它從0變到1的代價,還存好了它從1變到0的代價。比如0000+1->0001,我們賬戶還有1元,這1元是為了讓最後一位1變成0的,
0001+1->0010,我們用第一步的1元償還了最後一位1變成0,並且我們新投入了兩元支付了倒數第二位的0->1,並且還剩餘1元,這1元是為了支付倒數第二位的1->0的。
0010+1->0011,0011+1->0100…這個累加操作其實就是使0變成1,1變成0,每次最多隻有1個0變成1,我們存進去的2元就是為了滿足從0變成1的花費的,並且還存下了1元,已經考慮到了他以後從1變成0了。所以每一位的變化,我們的2元錢都有充分考慮到,我們的銀行賬戶永遠不會變負,所以2就是我們的攤還代價。
2.勢能法
我們定義勢函式 Φ為計數器中1的個數,從定義可知道我們的勢函式永遠不會為負數,並且Φ0 = 0,c’i = ci+Φi-Φi-1 = 2,因為如果我們就是單純地在末尾的0加上一個1,那麼ci=1,Φi-Φi-1=1因為整個二進位制多了1個1。如果因為我們的加1使得00100111…1(最後m個1)變成了00101000…0(最後m個0),那麼ci=m+1因為翻了m+1個數,Φi-Φi-1=-(m-1)因為整個二進位制計數器少了m-1個1,所以不管什麼情況,c’i = ci+Φi-Φi-1 = 2。
總結一下
1.引入攤還分析是為了衡量操作序列的平均耗時,因為有些情況耗時高的操作需要耗時低的操作來鋪墊,割裂他們來分析時間複雜度沒有意義,所以需要用攤還分析把他們合起來均分著來分析。
2.計算攤還代價有三種方法:根據定義來算,核算法,勢能法。後兩種方法的核心在於通過猜想和構造得到實際操作總代價的上界。
3.核算法強調了一個固定投入多少攤還代價,對於便宜和貴重的操作一視同仁,固定投入多少的決定是它的難點。
4.勢能法強調的是每一次實際操作的代價和其引起的勢函式變化之間的關係展現,因為我們定義的攤還代價是每一次實際操作的代價(ci)+其引起的勢函式變化(Φi-Φi-1)。如何定義勢函式是它的難點。