建模其實是一種喬裝的歸約
前四篇導覽交給你一整套工具:帶容量的 流網路、增廣路徑 與殘餘網路這具引擎、最大流最小割定理,以及 二分圖匹配 這個做透了的例子。最後這一篇講的是一項技能,而不是新演算法。當你面對一個全新的問題——排班、選專案、路由資料——致勝關鍵是去認出它其實是個喬裝的流問題,把網路建起來,跑一個你早已信得過的最大流演算法,再把答案讀回來。
這恰恰就是你在複雜度那一階會再遇到的那種歸約:一個 多項式時間的轉換,把問題 A 的實例變成問題 B 的實例,使得解出 B 就回答了 A。匹配是第一個例子——在那裡轉換幾乎是透明的。建模把這個動作一般化了。這門手藝有你必須每次都提供、然後加以論證的兩半:一個構造(文字如何變成頂點、邊與容量)以及一個對應(證明你網路裡的一個最大流會映回原問題的一個最佳解,反之亦然)。
四個標準小裝置
大多數流模型,是用一小套可重複使用的零件——稱為小裝置(gadget)——拼起來的。第一個是超級源點與超級匯點。當一個問題有許多起點(比方五個倉庫)或許多終點(比方八間商店),你不能有好幾個源點,因為流是對單一的 s 與 t 定義的。修法:加一個超級源點 S,從它連一條邊到每個真正的起點;再加一個超級匯點 T,從每個真正的終點連一條邊到它。那些邊上的容量就編碼了供給與需求——一條容量 10 的邊 S -> 倉庫,意思是「這個倉庫最多出貨 10」。
第二個小裝置回答一個學生總會問的問題:容量長在邊上,那我要怎麼限制穿過某個頂點的量?訣竅是頂點拆分。把一個頂點 v 換成兩份副本 v_in 與 v_out,中間用一條容量等於該頂點上限的內部邊 v_in -> v_out 相連;把所有進入 v 的邊改接到 v_in、所有離開 v 的邊改接到 v_out。現在任何穿過 v 的流都被逼著走那唯一一條內部邊,於是頂點容量就被遵守了。這個小小的動作悄悄解鎖了一大類問題——任何瓶頸在於某個「地點」、而非某條「連結」的問題。
第三個是用單位容量來計數。把一條邊的容量設成 1,就把「多少流量」變成了「多少個東西」,因為整數容量上的整數最大流會回傳一個整數的流(你在匹配那篇遇過的整數性性質)。一個二分圖匹配恰恰就是這樣:從 S 到每個左側節點的容量 1 邊、從每個右側節點到 T 的容量 1 邊,而最大流的值就數出了配成對的數目。第四個,下界與成本,是你升級到 最小成本最大流 的地方:給每條邊掛上一個單位價格,問的不只是最大的流,而是最便宜的最大流——當各個指派有不同品質或距離時,這才是對的模型。
一個做透的模型:把工作排給機器
讓我們把一個問題從頭到尾建一遍模。你有一些工作和一些機器;每個工作需要固定的處理量,每台機器有一個容量(可用時數),而一個工作只有在某台機器配有合適的工具時,才能在那台機器上跑。每個工作都能被完整排上嗎?這不只是匹配,因為一個工作可以被拆開分到多台機器,而一台機器服務許多工作——但上面那些小裝置乾淨俐落地處理了它。
- 建出網路:一個超級源點 S、每個工作一個頂點、每台機器一個頂點,以及一個超級匯點 T。加上容量等於該工作所需時數的邊 S -> 工作,以及容量等於該機器可用時數的邊 機器 -> T。
- 當某台機器能跑某個工作時,恰好就加一條 工作 -> 機器 的邊(容量無限,或就設為該工作的時數)。這些邊承載真正的指派;無限的上限意味著唯一真正的限制,是工作的需求與機器的供給。
- 從 S 到 T 跑任一個最大流演算法——埃德蒙茲-卡普或迪尼茨。最大流的值,就是能被排上的工作總時數。
- 讀出裁決:每個工作都能被完整排上,當且僅當最大流飽和了每一條 S -> 工作 邊,亦即流值等於所有工作需求的總和。每條 工作 -> 機器 邊上的流,恰好告訴你把該工作的多少時數跑在那台機器上。
讓這成為一個證明、而非一份指望的,是那份對應。任何合法的流都遵守守恆,所以從 S 流進一個工作的時數,等於從它流出到各機器的時數——那就是該工作的一個有效拆分。機器 -> T 邊上的容量保證沒有任何機器被超額預訂。反過來,任何有效的排程也定義出一個同值的合法流。所以最大流等於最大可排時數,而那個飽和檢驗是精確的。當某些工作無法被完整安放時,最小割 甚至告訴你為什麼:被飽和的割邊精準點出那組被超額認購、形成瓶頸的機器。
把最小割當建模工具:選專案
有些問題其實不是關於搬動任何東西——而是關於在彼此衝突的壓力下,把一個集合劃分成兩群。對這些問題,對偶的另一半才是英雄:你要建模成讓最小割成為答案。經典例子是選專案(也叫最大權閉包問題)。每個專案賺一筆利潤,但可能得買昂貴的設備;有些專案依賴另一些。在尊重所有依賴關係的前提下,選一個專案子集,使淨利潤最大。
這個模型是個小小的奇蹟。每個專案一個頂點。從 S,對每個有利潤的專案連一條容量等於其利潤的邊;從每個有成本的項目連一條容量等於其成本的邊到 T。對每條依賴「專案 A 需要項目 B」,加一條無限容量的邊 A -> B。如今一個有限的 s-t 割永遠切不斷一條無限的依賴邊,所以任何有限容量的割都自動尊重每一條依賴。最小割的 S 側集合,恰恰就是使利潤最大化的選取,而最大淨利潤等於(總利潤)減去(最小割容量)。那些無限邊是一個小裝置,它把不合法的劃分弄得無限昂貴,藉此禁止它們。
注意視角的轉換。在排程裡我們最大化流;在這裡我們最小化一個割,而最大流最小割定理讓同一個演算法兩邊通吃——你藉由算最大流、再讀出可達集合來算最小割,正如前幾篇導覽所示。影像分割(前景對背景)以及許多「把兩類分開、同時為被切斷的親和度付費」的問題,都依循同一個閉包樣板。認出你的問題要的是對偶的流那一側、還是割那一側,是整個主題裡最有用的單一直覺。
流的訣竅失效的地方
誠實在這裡、一如在這條階梯的每一處,都很要緊。流之所以強大,正是因為它待在多項式時間裡,而這替你買來一條鋭利的界線:這些問題的許多近親是 NP 困難的,沒有任何流模型救得了它們。二分圖匹配是多項式時間的,但一般(非二分)的匹配需要一個不同、更難的演算法。更糟的是,要求一個整數的多商品流——數種不同的貨物共用同一張網路、各有各的源點與匯點——是 NP 困難的,儘管單商品的整數流很容易。讓單商品流如此乾淨的那個整數性性質,根本無法延伸過去。
有一個漂亮的例外,標出那條線落在哪裡。在二分圖上,找一個最小頂點覆蓋是多項式時間的,且等於最大匹配的大小——那就是柯尼希定理,它本身是最大流最小割的一個推論。但在一般的圖上,最小頂點覆蓋是 NP 困難的,我們手上最好的只是一個 2-近似。同一個詞「頂點覆蓋」;一種結構讓它成為流問題,另一種把它擺到流搆不著的地方。教訓是去檢驗結構、而非名稱:二分、單商品、整數、無衝突的需求——這些才是承重的假設。
認出來的檢查清單
退一步看,整個這一階就收攏成一個單一的思考習慣。面對一個新問題,問:有沒有某種被守恆的東西,從源點移動到匯點?約束是邊上、還是地點上的線性上限?目標想要的是你能推送的最多量,還是把兩群分開的最便宜方式?若答案對得上,你就去拿一個流模型;若某個微妙的成分——多種商品、不可分割卻彼此衝突的選擇、一張一般(非二分)的圖——打破了這個樣板,你很可能已跨進 NP 困難的地界,該改去找近似或啟發式方法了。
而且永遠、永遠以那份對應收尾。建模是一個 歸約,而一個沒有雙向證明的歸約,不過是一張充滿指望的草圖。精確地陳述一個最大流如何成為一個解、以及一個解如何成為一個同值的流;查驗守恆處理了你的拆分、容量編碼了你的限制,而整數性(若你需要整數答案)由整數容量所保證。把這些做對,你就免費繼承了這之前四篇導覽裡的每一個演算法與每一份保證。