# AP325-Python 第4章 貪心演算法與掃瞄線演算法 「貪心通常比較簡單,但多半不能解決問題,在演算法與人生中都是這樣。」 孔子說:「及其老也,血氣既衰,戒之在得。」日常生活中最常看到貪的狀況就是政治人物,而想到政治人物就想到金庸的笑傲江湖。這一章的例題習題部分就以笑傲江湖中的人物來串場,不過不必擔心,沒看過故事不影響解題。 --- 這一章介紹貪心演算法(Greedy algorithm)以及掃描線演算法(sweep-line algorithm),這兩種演算法有點類似,所以放在同一章。這裡的貪心沒有負面的意思,單純指以當前最有利的方式做決定。貪心演算法通常很直覺,所以寫程式往往簡單,但不是對所有問題都能夠找到最佳解。Greedy通常需要證明,但上機考試與競賽的場合,不需要證明,所以很多人不知道怎麼做的時候就用Greedy寫下去猜猜看,考試本來就是該猜就要猜,不過貪心也要貪的正確。 掃描線演算法的名字出自計算幾何,是指解題過程想像用一條掃描線沿著某個方向掃描過去,他是一個很好的思考模式,很多經典的分治演算法都有掃瞄線的版本。有一些數列的問題往往可以看成數線上的問題,所以也可以用掃描線的方式來思考。 貪心演算法與掃描線演算法通常需要搭配排序或優先佇列(priority_queue,常簡稱PQ),我們在第二章介紹過排序了,所以這裡不再重複。優先佇列是一種重要的資料結構,在大部分資料結構的課本中都有介紹自製的方法(heap)以及應用,它在Python中也有庫存函數可以呼叫,但它應該不能算「常用簡單」的資料結構,所以在APCS考試中應該不會出現非用它不可的題目,我們會對PQ做簡單的介紹。 ## 基本原理 所謂貪心演算法指的是:**一個算法由一連串的決定組成,每次決定時都以當時最有利的方式挑選,而且一旦挑選後就不再更改**。我們先以舉一個很簡單的例子來說明。 --- ##### P-4-1. 少林寺的代幣 令狐沖去少林寺觀光,少林寺有四種代幣,面額分別是$(1, 5, 10, 50)$,令狐沖拿了 $n$ 元去換代幣,請問以這四種代幣湊成 $n$ 元,最少要多少枚代幣? Time limit: 1秒 輸入格式:第一行是一個正整數 $m$,代表有幾筆測資,以下有 $m$ 行,每行是一筆測資,每筆測資是一個正整數 $n$,$n<1000$。 輸出:依序每一行輸出每一筆測資的最少的代幣數量。 範例輸入: 3 21 50 123 範例結果: 3 1 7 說明:$21 = 10\times 2 + 1$, $50 = 50\times 1$, $123 = 50\times 2 + 10\times 2 + 1\times 3$。 --- 因為代幣的數量希望要最少,直覺貪心的想法就是要盡量使用面額大的硬幣,代幣由大面額的往小的考慮,能換盡量換,剩下不足的再考慮比較小的面額。以下是這樣的程式。 ```python= # P-4-1 num of coin, greedy m = int(input()) coin = [1,5,10,50] for i in range(m): n = int(input()) num = 0 for c in coin[::-1]: # large to small num += n//c # num of coin c n %= c # remaining print(num) # ``` 程式寫起來很簡單,簡單到如果考試碰到這種題目,會懷疑是不是有陷阱。這個方法很直覺,但怎麼知道它是對的呢?有些人可能會說:就很直覺啊,路邊隨便找個少林寺掃地的也會這樣做。 那麼請看看下面的狀況:假設代幣的面額是$(1, 4, 5)$三種,上面的貪心方法會找到正確答案嗎?假設$n=8$,貪心的方法會先換一個5元,然後剩下3元就換3個1元代幣,所以8元換了4個代幣。但是如果拿兩個4元代幣就可以湊8元了,所以正確答案應該是2而不是4。 請不要懷疑人生,P-4-1的做法是對的,只是並不是能用在任何代幣面額的狀況,我們必須證明。如何證明呢? 在代幣是$(1, 5, 10, 50)$的情形下,如果1元的代幣總額不小於5,一定可以拿出5個換成一個5元,所以1元代幣的個數不會超過4個;同樣的你可以證明5元代幣不會超過1個,10元代幣不會超過4個。也就是說,對任意第$j$種代幣$coin[j]$,小於$coin[j]$的代幣總額不會超過$coin[j]$,所以Greedy是對的。在$(1, 4, 5)$的狀況,就沒有這個性質了。順便一提,不要小看少林寺的掃地僧,掃地僧往往是最厲害的。 這個例子中我們看到了貪心法的一些特質。 * 算法由一連串決定組成(由大到小逐一考慮各種代幣的數量),每次考慮當前最好決定(能換盡量換),選擇後就不再更改。 * 算法的正確性必須經過證明,內容就是要證明所下的決定是對的,也就是一定有一個最佳解包含了貪心法所做的決定,而證明的方法通常都是用換的:假設有一個最佳解跟貪心法做的決定不一樣,那麼,我們可以換成一個與貪心法一樣的決定,而解會更好,或至少不會變差。 在下一節中我們會看到更多的例子。貪心算法往往都是要不斷的找最大值或最小值,如果資料不會變動,可以在開始的時候做一次排序,如果資料是會變動的呢?就必須用一個動態的資料結構來處理,以下我們對優先佇列做一點介紹。 ### 優先佇列 (priority_queue) (\*) 第三章介紹過佇列,佇列跟現實生活中排隊的狀況一樣,是先進者先出,但更貼近現實生活中的狀況是有些事情後發生的卻必須先處理,因為緊急或是更重要。 優先佇列就是具有優先權的排列,不管進入的順序,優先權高的會排在前面。Python標準函數庫中的heapq提供了PQ基本操作函數,資料結構的課本中也都有教以heap來實作,heap是一個完全二元樹(complete binary tree),但實作在陣列中,也就是心中有樹但程式中沒有樹。heap的實作並不算困難,但考試與比賽時間寶貴,通常能使用內建的就不要自己寫,因此本書中並不介紹heap的實作,有興趣的可以人可以自己查詢網路或書籍。 Python的heapq說明可以參考[heapq --- 堆積佇列 (heap queue) 演算法](https://docs.python.org/zh-tw/3/library/heapq.html),使用時自己準備一個list,然後呼叫它所提供的函數來對list進行操作。常用操作的時間複雜度如下,其中$n$表示成員的數量,也就是佇列的大小: * 加入新元素(push),$O(\log n)$。 * 查詢最小值(top),$O(1)$。 * 刪除最小值(pop),$O(\log n)$。 * 檢查是否為空(empty),$O(1)$。 請注意刪除與查詢只能針對最小值,若要改變大小順序,最簡單的方法就是改變key的正負號。以下的範例程式介紹基本用法,說明請見程式內註解。 ```python= import heapq h1 = [] # initial empty list as heap heapq.heappush(h1, 5) # push, O(logn) for v in [4,1,5]: heapq.heappush(h1, v) # print(h1,"min is ",h1[0]) # min element at [0] q = heapq.heappop(h1) # pop min to q, O(logn) print("previous min =",q, "current min =",h1[0]) print("current size:",len(h1)) # size of heap O(1) if len(h1): print('not empty') # check if empty O(1) else : print('empty') a = [2,-4,-5,-4,8] # initial from a list heapq.heapify(a) # initial a nonempty heap, O(n) print(a,"min =",a[0]) # min at top a.sort() # can also initialized by sort but O(nlogn) # to make a max-heap, using -v for b = [-x for x in a] print("minus value of a =",b) heapq.heapify(b) imax = -heapq.heappop(b) # original max in a print("max of a is",imax) # element can be list a = [2,-4,-3,-4,8] pa = [[v,i] for i,v in enumerate(a)] #[value,index] print(a); print(pa) heapq.heapify(pa) v,i = heapq.heappop(pa) print("min of a is a[",i,"] = ",v) ``` 輸出結果如下: ``` [1, 5, 4, 5] min is 1 previous min = 1 current min = 4 current size: 3 not empty [-5, -4, 2, -4, 8] min = -5 minus value of a = [5, 4, 4, -2, -8] max of a is 8 [2, -4, -3, -4, 8] [[2, 0], [-4, 1], [-3, 2], [-4, 3], [8, 4]] min of a is a[ 1 ] = -4 ``` ## 例題與習題 這一節我們來看貪心與掃瞄線演算法的題目,我們將題型區分為以下幾類,每一類挑選一些經典題來說明,另外在下一節有一些其他的補充習題: * 單欄位資料排序 * 多欄位資料排序 * 以PQ處理動態資料 * 貪心搭配二分搜 * 掃描線演算法 很多貪心算法的重點在證明,但本書以程式實作為主,不以證明為重點,所以有些題目的證明只簡略說明重點,或者請讀者上網路查詢。 ### 單欄位資料排序 --- ##### P-4-2. 笑傲江湖之三戰 笑傲江湖的遊戲中,你扮演令狐沖的角色,因為「長恨此身非我有,何時忘卻盈盈」,所以你率領任我行等魔教高手前往少林寺搭救任盈盈,欲知故事原委可參見笑傲江湖第27回「三戰」。 套句丸尾班長的口頭禪,總而言之,少林寺與五嶽劍派等正教派出$n$位高手,而你方也有$n$位高手,每個人都有一個戰力值。雙方要進行一對一的對戰,每個人不管輸或贏都只能比一場,假設兩人對戰時,戰力值高的獲勝。對於對方的每一位高手,你可以指定派哪一位與其對戰,為了解救盈盈,你希望贏越多場越好,平手則沒有幫助。請計算出最多能贏多少場。 Time limit: 1秒 輸入格式:第一行是一個正整數$n$,第二行有$n$個非負整數是對方的戰力,第三行有$n$個非負整數是我方的戰力,同行數字間以空白間格。$n$與戰力值不超過1e5。 輸出:輸出最多可能的勝場數。 範例輸入: 5 3 1 7 0 2 8 3 5 0 0 範例結果: 3 --- 我們只要關心可以戰勝的那些場次需要誰去出戰,其他場次贏不了,就在剩下的人裡面隨便挑選去應戰。假設我方目前最弱的戰力是$a$而對方最弱的是$b$,如果$a \le b$,則$a$對誰都不會贏,是沒用的,可以忽略。否則$a > b$,我們宣稱「一定有一個最佳解是派$a$去出戰$b$」,證明如下: 假設在一個最佳解中,$a$對戰$x$而$y$對戰$b$,我們將其交換改成「$a$對$b$而$y$對$x$」。根據我們的假設$y \ge a$,交換後$a$可以贏$b$而$y$對$x$的戰績不會比$a$對$x$差,所以交換後勝場數不會變少。 既然如此,我們可以確定派$a$對戰$b$一定可以得到最佳解,並將$a$與$b$移除後,繼續依照上述原則挑選。 範例程式如下,因為要不斷的挑選最小值,而戰力不會改變,所以是個靜態的資料。我們可以將雙方戰力先進行一次排序,然後依序由小往大考慮即可。 ```python= # P_4_2, one-on-one O(nlogn), sorting+greedy n = int(input()) enemy = [int(x) for x in input().split()] ours = [int(x) for x in input().split()] enemy.sort() ours.sort() win = 0 # num of win ei = 0 # index of weakest enemy for p in ours: # each of our power if p > enemy[ei]: # can win some enemy win += 1 ei += 1 # next enemy # otherwise, p is useless, do nothing # print(win) ``` 這一題也可以由大到小考慮,基本原則是能贏的贏一點就好,當然可以用優先佇列(PQ),在範例程式中有以PQ寫的方式,做法簡單,我們就不在此說明,有興趣練習PQ的可以自行參考。 --- ##### P-4-3. 十年磨一劍 (最少完成時間) 人們常用說「十年磨一劍」來比喻下的功夫深,但是這句話對於華山磨劍坊就不適用了,因為要磨劍的客人非常的多,一把劍如果磨太久,客人等待的時間過長是會被客訴的。華山磨劍坊目前有$n$筆訂單,每筆訂單要磨一把劍,每把劍所需的時間不盡相同。磨劍師傅每次只能磨一把劍,現在希望每一筆訂單的完成時間之總和能夠最小,希望能找出最好的磨劍順序。 舉例來說,如果有四把劍,磨劍需要的時間分別是(3,1,3,4),如果以(3,1,3,4)的順序來磨,第一把的完成時間是3,第二把完成時間是3+1=4,第三把是3+1+3=7,第四把是3+1+3+4=11,總和是3+4+7+11=25。如果把順序改成(1,3,3,4),那麼完成時間分別是(1,4,7,11),總和是23,這是這一題最好的解。 Time limit: 1秒 輸入格式:第一行是一個正整數$n$,第二行有$n$個正整數是每一把劍需要的時間,同行數字間以空白間格。$n$與每把劍的時間都不超過1e5。 輸出:輸出最小的完成時間總和。 範例輸入: 4 3 1 3 4 範例結果: 23 --- 有$n$個工作要安排一個順序後依序完成,希望每個工作的完成時間總和最小,這是個經典的minimum completion time問題。直覺上短的工作要先做,放在越前面的工作,等它的人越多。如果把計算時間的方式仔細看一下,你會發現如果$job[i]$是第$i$個工作所需的時間,那麼完成總時間是 $\sum_{i=0}^{n-1} (n-i)\times job [i]$, 所以shortest-job-first是對的。另外一個證明方法是假設$job[i]$的順序是最佳解而存在某個工作$job[i]>job[i+1]$,那麼,我們交換這兩個相鄰工作,其他的工作的完成時間不會改變,但這兩個工作的完成時間總和會減少,如此可知,最佳解中不可能大的排在小的前面。這種考慮相鄰交換的情形是個證明的技巧,因為其它的工作不會改變,可以得到很簡單的證明。 程式非常簡單,排序後計算就好了,但是不要把計算寫成$O(n^2)$的笨方法了,運用prefix-sum的手法就很容易在排序後以$O(n)$完成計算,另外一個方法是用上面的公式。 ```python= # P_4_3, min completion time, sorting+greedy n = int(input()) job = [int(x) for x in input().split()] job.sort() # shortest job first curr = 0 # current completion time total = 0 # total completion time for t in job: curr += t # completion time of i total += curr # total completion time # print(total) ``` ### 多欄位資料排序 在上面兩個例題中,每個資料都只是一個數字,接下來我們要看需要資料具有多欄位(項目)的狀況。在第二章我們介紹排序時,說明過python中list與tuple是以字典順序比較大小,以及sort()中使用key的方法,如果不熟悉的人,請再次參考第二章的方法。 下面這一題是很有名的經典題 **Activity selection problem**。 --- ##### P-4-4. 幾場華山論劍 (activity selection) 自從華山論劍聲名大噪之後,想參加的人絡繹不絕,因此華山派決定每年都舉辦非常多場的華山論劍,每一場都有自己的開始時間與結束時間,參加者必須全程在場,所以不能在同一時間參加兩場。 令狐沖拿到了今年所有場次的資料,希望能參加越多場越好,以便盡速累積經驗值,請你幫忙計算最多可以參加幾場。請注意,所挑選活動必須完全不重疊,兩場活動只在端點重疊也是不可以同時參加的,也就是說**前一場的結束時間必須小於下一場的開始時間**。 Time limit: 2秒 輸入格式:第一行是一個正整數$n$,代表一共舉辦了$n$場華山論劍,接下來$n$行,每行兩個非負整數$s$與$t$,代表這場活動的時間區間是$[s,t]$,兩數字間以空白間格。$n$不超過1e5,$s < t < 1e9$。 輸出:最多參加幾場活動。 範例輸入: 4 1 4 0 3 3 4 4 6 範例結果: 2 說明:可以挑選[0,3]與[4,6]兩場活動。 --- 這個題目簡單說就是:數線上有若干線段,要挑選出最多的不重疊的線段。第一個想法可能是盡量挑短的,但是範例中有一個反例,挑短的不是正確答案。我們宣稱下面這個的性質: **「若$[x,y]$是所有現存線段中右端點最小的,那麼一定有個最佳解是挑選了$[x,y]$。」** 證明:假設最佳解沒有挑$[x,y]$,令$[a,b]$是最佳解中右端點最小的。根據我們的假設,$y \le b$,因此將$[x,y]$取代$[a,b]$不會重疊到任何最佳解中的其他線段,我們可以得到另外一個包含$[x,y]$的最佳解。 根據此性質,我們可以不斷地將最小右端的線段挑選進解答,然後略過任何與它重疊的線段。因為線段不會改變,一開始依照右端由小排到大,然後從頭到尾掃描一次就可以得到答案。 以下是範例程式,在此程式中我們用一個list $[start,finish]$來表示一個活動的起始與結束時間,排序時用 key=lambda x: x[1] 來定義排序的key值是一個元素$x$的第1個欄位$x[1]$,也就是結束時間,對排序lambda函數不熟悉的請複習第二章。 在排序後,以一個迴圈從頭到尾掃描一次,以變數endtime保持著前一個已挑選活動的結束時間,初值給 -1 因為輸入的時間皆為非負,迴圈內以if來判斷開始時間是否大於endtime,若是,挑選起來,修改答案與endtime;若否,可以直接丟棄它,不做任何事。 ```python= # P_4_4,activity selection, sorting+greedy n = int(input()) act = [] # [start,finish] for i in range(n): act.append([int(x) for x in input().split()]) act.sort(key=lambda x:x[1]) # sort by finish time endtime = -1 # last finished time total = 0 # num of selected activity for start,finish in act: if start > endtime: # compatible, select it total += 1 endtime = finish # else do nothing # print(total) ``` 如果因為不熟悉排序時下key的方式,可以將要比較的值放在每一個元素的第0個欄位,因為每個元素是list,兩個list比較時是先比第0個欄位的字典順序,所以會依照我們的需要的方式排序而省略寫key函數。在本題中,我們希望以右端點結束時間排序,我們就將右端點放在前面就行了。 以下是範例程式,大致上與前一支相同,在第6行讀入每一個活動的起始與結束時間時,我們以結束時間在前的順序放入$act$中,第8行排序時間就沒有給key了。留意在第11行歷遍時,每一筆資料取出的是finish,start,結束時間在前。 ```python= # P_4_4,activity selection, sorting+greedy # without key lambda n = int(input()) act = [] for i in range(n): s,t = [int(x) for x in input().split()] # [start,finish] act.append([t,s]) # finish at first act.sort() # sort by finish time endtime = -1 # last finished time total = 0 # num of selected activity for finish,start in act: if start > endtime: # compatible, select it total += 1 endtime = finish # else do nothing # print(total) ``` 本題中我們只需安排欄位的順序,但利用這個方式來避免寫key函數有的時候會多花記憶體,例如要比較的是幾個欄位運算的結果(例如下一題),如果不下key,就變成要在每筆資料前方多開一個欄位,所以並非一定可行。 在P-4-3十年磨一劍的例題中,每一件磨劍工作,無論需要時間的長短,重要性都是一樣的,我們要求的只是完成時間的總和最小化,下面是一個變化題,每一個工作延後的代價不同。 --- ##### P-4-5. 嵩山磨劍坊的問題(加權最小完成時間) 嵩山磨劍坊接了$n$筆磨劍工作,磨劍師傅每次只能磨一把劍。除了每把劍各有所需的時間之外,每件工作的重要性也不同。假設第$i$筆訂單需要的時間是$t[i]$,而重要性是$w[i]$。磨劍坊的計價方式是:每件工作都已經先收了一筆款項,假設第$i$筆訂單在時間$f$時完成,則需要扣款$f\times w[i]$,現在希望將$n$筆磨劍工作安排最好的順序,使得扣款總額越小越好。嵩山派掌門左冷禪是非常嚴厲的老闆,希望你能幫磨劍師傅找出最好的順序以免他遭到處罰。 舉例來說,如果有四把劍,磨劍需要的時間分別是$t=(1,4,5,6)$,而重要性依序是$w=(1,3,4,7)$。如果依訂單編號順序$(1,2,3,4)$來磨,也剛好是工作時間由短到長的順序,每件工作的完成時間依序是$(1,5,10,16)$,扣款總額是$1\times 1 + 5\times 3 + 10\times 4 + 16\times 7 = 168$。如果依照訂單編號順序$(4,1,3,2)$ 來磨,則$t$與$w$重新排列後分別是$t=(6,1,5,4)$,$w=(7,1,4,3)$。完工時間是$(6,7,12,16)$,扣款總額是$6\times 7 + 7\times 1 + 12\times 4 + 16\times 3 = 145$。這是這一題24種排列中最好的解。 Time limit: 2秒 輸入格式:輸入的第一行工作數$N$,$N<1e5$。第二行有$N$個正整數,依序是各訂單所需時間$t[1],t[2],\ldots,t[N]$。第三行有$N$個非負整數,依序是各訂單的重要性$w[1],w[2],\ldots,w[N]$,時間與重要性皆不超過1000,相鄰以空白間隔。 輸出:輸出最小的扣款總額。答案不超過1e18。 範例輸入: 4 1 4 5 6 1 3 4 7 範例結果: 145 --- 如果每個工作(訂單)的重要性(w)都是一樣的話,這一題跟P-4-3就是一樣的,答案就是依照工作時間以Shortest-job first的方式排列,但是在有權重的狀況下,題目範例中就告訴你時間短的不一定在前面。我們再想想看,只看時間的話,時間短的應該排前面;那麼如果只看權重的話呢?權重大的應該排前面,因為延遲的話罰得重。但是兩個都要考慮的話,就必須兩個因子都列入考慮。 我們宣稱以下特性:**最佳解中,排在前面的,$t/w$值一定比較小**。證明?跟P-4-3類似,考慮排在相鄰的兩個工作,假設$t[i]/w[i] > t[i+1]/w[i+1]$。我們交換兩工作,其他的工作完成時間不會改變,而$i$延後了$t[i+1]$的時間,$i+1$則提早了$t[i]$的時間,因此變化量為$w[i]\times t[i+1] - w[i+1]\times t[i] < 0$,也就是解會變好。 程式的寫法不難,但是需要兩個欄位相除後的結果來排序。這裡要提醒,相除的結果是個浮點數,在類似的情形下也許有精準度的問題。以這一題的範圍,1~1000,不至於造成誤差。第二個問題,分母是否有可能是0?這一題是會。所以需要特殊處理一下,處理的方式是若$w=0$,則將key設為無窮大(本題1001就夠),也可以先把$w=0$的都刪除,因為權重為0的沒有罰款,可以直接刪除。 ```python= # P_4_4,activity selection, sorting+greedy # without key lambda n = int(input()) t = [int(x) for x in input().split()] w = [int(x) for x in input().split()] job = [(x,y) for x,y in zip(t,w)] job.sort(key=lambda x: x[0]/x[1] if x[1] else 1001) # sort by t/w, if w==0, key=oo curr = 0 # current completion time total = 0 # total weighted completion time for t,w in job: curr += t # completion time of i total += curr*w # total completion time # print(total) ``` 下面一題很類似,因此留做習題,不多解釋。 --- ##### Q-4-6. 少林寺的自動寄物櫃 (APCS201710) 少林寺的自動寄物櫃系統存放物品時,是將$N$個物品堆在一個垂直的貨架上,每個物品各佔一層。系統運作的方式如下:每次只會取用一個物品,取用時必須先將在其上方的物品貨架升高,取用後必須將該物品放回,然後將剛才升起的貨架降回原始位置,之後才會進行下一個物品的取用。 每一次升高貨架所需要消耗的能量是以這些物品的總重來計算。現在有$N$個物品,第$i$個物品的重量是$w(i)$而需要取用的次數為$f(i)$,我們需要決定如何擺放這些物品的順序來讓消耗的能量越小越好。 舉例來說,有兩個物品$w(1)=1$、$w(2)=2$、$f(1)=3$、$f(2)=4$,也就是說物品1的重量是1需取用3次,物品2的重量是2需取用4次。我們有兩個可能的擺放順序(由上而下): * (1,2),也就是物品1放在上方,2在下方。那麼,取用1的時候不需要能量,而每次取用2的能量消耗是$w(1)=1$,因為2需取用$f(2)=4$次,所以消耗能量數為$w(1)\times f(2)=4$。 * (2,1)。取用2的時候不需要能量,而每次取用1的能量消耗是$w(2)=2$,因為1需取用$f(1)=3$次,所以消耗能量數$=w(2)\times f(1)=6$。 在所有可能的兩種擺放順序中,最少的能量是4,所以答案是4。再舉一例,若有三物品而$w(1)=3,w(2)=4,w(3)=5$而$f(1)=1,f(2)=2,f(3)=3$。假設由上而下以$(3,2,1)$的順序,此時能量計算方式如下:取用物品3不需要能量,取用物品2消耗$w(3)\times f(2)=10$,取用物品1消耗$(w(3)+w(2))\times f(1)=9$,總計能量為19。如果以(1,2,3)的順序,則消耗能量為$3\times 2+(3+4)\times 3=27$。事實上,我們一共有3!=6種可能的擺放順序,其中順序(3,2,1)可以得到最小消耗能量19。 Time limit: 1秒 輸入格式:輸入的第一行是物品件數$N$,$N<1e5$。第二行有$N$個正整數,依序是各物品的重量$w(1),w(2),\ldots,w(N)$。第三行有$N$個正整數,依序是各物品的取用次數$f(1),f(2),\ldots,f(N)$,重量與次數皆不超過1000,相鄰以空白間隔。 輸出:輸出最小能量消耗值。答案不超過1e18。 範例輸入: 3 3 4 5 1 2 3 範例結果: 19 --- ### 以PQ處理動態資料(\*) 這一小節中我們提出需使用優先佇列(priority queue, PQ)的題目。由於APCS的範圍應該不包含PQ,所以照理說不太會出現在APCS考試中。 --- ##### P-4-7. 岳不群的併派問題(Two-way merge) (\*) 江湖上門派林立,華山派掌門岳不群認為要消除門派的衝突就是要合併各門派,但不能操之過急,必須每次挑兩個門派合併,如此逐步合併,最後將所有門派合併成一派。合併門派需要一些成本,每個門派都有他的成員數,第 i 個門派的成員數是 $m(i)$,合併兩派的成本就是雙方成員數之和,而兩派合併後,雙方成員就會歸到新合併的門派。岳不群不希望耗費太多成本,輸入各派人數,請幫他計算最少的合併總成本。 舉例來說,一開始如果有三派,$m=(3, 5, 1)$,若3人與5人的兩派先合併,成本是3+5=8,合併後剩下兩派,人數是(8,1),再合併這兩派,成本是8+1=9,此時已經只剩下一派,也就是完成所有合併統一江湖了,總成本是8+9=17。如果我們更換合併的順序,先合併3人與1人的兩派(成本4),再合併剩下的5人(成本4+5=9),則總成本只有4+9=13。這是這個例子的最低成本。 Time limit: 2秒 輸入格式:第一行是一個正整數 $n$,代表初始的門派數量,第二行有 $n$ 個正整數是每派的成員數,同行數字間以空白間格。$n$ 不超過2e5,每個門派初始人數都不超過 1e5。 輸出:第一行輸出總人數,第二行輸出最小的合併總成本。 範例輸入: 3 3 5 1 範例結果: 9 13 --- 這一題事實上是Two-way merge tree的問題,與一個有名的Huffman code是等價的。正確的合併順序的計算方法是:每次挑選人數最少的兩派合併,重複此步驟直到剩下一派為止。任何一種合併的過程可以用一個二元樹表示,下圖是一個例子。 ![image](https://hackmd.io/_uploads/HyxvMhiUa.png) 圖中每個方塊表示一個初始的門派,圓圈表示一個合併。初始人數$m(i)$ 是 (7,2,3,5),先合併 (2,3) 變成5,再合併(5,5) 變成10,最後合併 (7,10) 變成 17。合併的成本圓圈中的數字相加,這個例子 5+10+17=32 就是是最低成本。這一題的證明稍嫌麻煩,我們在此不給完整的證明,只做一些說明。總成本也可用另外一個方式計算,每一個方塊往上走到頂會經過幾次圓圈(稱為深度),就代表他經過幾次合併,所以每個 $m(i)$ 乘上他的深度加總後就是總成本,所以直覺上看得出來,數字越大的要放在深度小的地方,也就是說數字越小的要越早合併,完整的證明留給讀者上網搜尋Huffman code或two-way merge tree。 雖然證明有點麻煩,但是程式非常好寫,我們只要每次找出最小的兩個數,將他們刪除,再將他們的和放入。重複這個步驟直到剩下一個數字。但是要有效率的做到必須借助資料結構,如果使用排序,每次刪除最小值或許不是大問題(記住有效位置,每次後移兩格就好),但是插入兩數相加的和就會很浪費時間了。以下是用陣列的方法寫的範例程式,時間複雜度是 $O(n^2)$。雖然他效率不夠好,但是我們也使用了一些技巧來減少資料搬移的動作,所以也算個好的練習。 我們維持list $m$永遠是排好序的狀態,每次要找最小的兩個元素把它們移除後,再將他們相加後的和插入 $m$ 中並保持排好序的狀態。插入排序好的list可以用bisect中的insort,或是bisect_left加上insert,但必須是上升遞增;要移除方便,最好刪除的是在尾端。因此我們用負值放入進行遞增排序,這樣是原始資料的遞減排序。每次從尾端pop兩個資料就是最小的兩個。但是insort還是$O(n)$,所以整體時間還是$O(n^2)$。 ```python= # P_4_7 greedy, slow n^2 method using array import bisect n = int(input()) m = [-int(x) for x in input().split()] # negative m.sort() # origibal val large to small total = 0 # total cost for i in range(n-1): # merge n-1 times cost = m.pop() # smallest cost += m.pop() # second smallest total += cost # insert into increasing list bisect.insort(m, cost) # print(-m[0]) print(-total) ``` 要能夠有效率的做到增刪資料,我們必須借助資料結構,優先佇列每次可以找到最小值,是這一題最適合的資料結構,只要每次pop出兩個資料,相加後push回去就可以了。 ```python= # P_4_7 greedy, O(nlogn) using pq import heapq n = int(input()) m = [int(x) for x in input().split()] heapq.heapify(m) # heap initialization total = 0 # total cost for i in range(n-1): # merge n-1 times cost = heapq.heappop(m) # smallest cost += heapq.heappop(m) # second smallest total += cost # insert into heap heapq.heappush(m,cost) # print(m[0]) print(total) ``` 下面一個題目是在工作排程時的問題,做法簡單,但證明需要想一下,留做習題,如果資料量不大,可以用陣列做,但資料量大時,必須使用資料結構。 --- ##### Q-4-8. 先到先服務 (\*) 有 $n$ 個客人要分配到 $m$ 個櫃台來服務,客人依編號順序到達,第 $i$ 個客人需要 $t(i)$ 的時間來完成(無論分配到哪一個櫃台),而且同一位客人的需求必須在同一個櫃台完成,不能分拆。因為公平的因素,**先到客人不可以比晚到客人更晚開始服務**。現在希望安排每個客人的服務櫃檯,以便可以在最短的時間內完成所有客人的需求。 舉例來說,如果有三位客人與兩個櫃台,需求的時間依序是(3,1,5),我們可以如下分配,最後的完成時間是6。 櫃台1:3 櫃台2:1+5 請注意,我們不可以如下分配,雖然這樣的完成時間會減少到5,但是第3個客人(5)比第2個客人(1)早開始服務,違反了先到先服務的原則。 櫃台1:3+1 櫃台2:5 Time limit: 2秒 輸入格式:輸入兩行,第一行是正整數 $n$ 與 $m$,第二行是 $n$ 個正整數$t(1), t(2),\ldots ,t(n)$,同行數字間以空白間格。$n$ 與 $m$ 不超過2e5,$t(i)$ 不超過1e4。 輸出:最早可能服務完所有客人的時間。 範例輸入: 3 2 3 1 5 範例結果: 6 --- **End of Chapter 4 (I)** 下接 [AP325-Python 第4章 貪心演算法與掃瞄線演算法 (II)](/mEi9wqzjRCuj2aixy3Ea2Q)