從“泡茶”說起
張三給客人沏茶,燒開水需要 12 分鍾,洗茶杯要 2 分鍾,買茶葉要 8 分鍾,放茶葉泡茶要 1 分鍾。為了讓客人早點喝上茶,你認為最合理的安排,多少分鍾就可以了?
小學有一種奧數題型叫“最優化策略”,誕生出這種題型的原因是由於完成一系列操作是由有先后順序的,同時做一些事情的同時可以順手做另一件事情。若這些事情是以一個無序線性地去完成,很明顯是不現實的,因為有的事件是其他事件的預備條件,就例如要放茶葉的話就要有茶葉,買茶葉就是放茶葉的預備條件。而有些是可以並行地去做,例如燒開水和洗茶杯這兩件事情可以交換順序做,亦可以一起做。
AOV 網
有向無環圖 (Directed Acycline Graph) 是描述一項工程或系統進行過程的有效工具,對於一個工程而言有存在着子工程,子工程之間可能是存在着先后順序的,一旦存在順序就會產生約束。我們可以把一個工程抽象成一個網結構,頂點表示一個個子工程,弧表示子工程之間的優先關系,這樣的抽象手法我們稱之為 AOE(Activity On Vertex Network) 網。例如對於課程的學習順序:
我們可以得出對應的 AOV-網:
同時我們也可以觀察出一個結論:AOV 網中不能夠出現有向環,因為某個子工程以自己為先決條件明顯是可笑的。
拓撲排序
因此對於一個 AOV 網就需要判其是否有環是很重要的,因此就需要進行拓撲排序,若拓撲排序中所有點都存在與序列中就證明沒有環的存在。不過這當然不是拓撲排序的最終目的啦,我認為拓撲排序最重要的作用是給出一種可行的流程,這個流程可以描述出整個工程的一種流程,有了流程就有了方向。
現在就來講講什么是拓撲排序,所謂拓撲排序就是將 AOV 網中所有頂點排成一個序列,該序列滿足坐在 AOV 網中有頂點 vi 到 vj 有一條路徑,則在該線性序列中的頂點 vi 必定在 vj 之前。
排序流程
- 在有向圖中選擇一個無前驅的頂點並輸出;
- 在圖中刪除該頂點和所有以它為尾的弧;
- 重復前兩步直至不存在無前驅的頂點;
- 對得到的序列進行判斷,若頂點數小於有向圖中的頂點數說明有環存在,否則無環。
模擬排序
就拿上面的 AOV 網來看好了。任意選擇無前驅的頂點 C1 並輸出,在圖中刪除 C1 和所有以它為尾的弧:
任意選擇無前驅的頂點 C2 並輸出,在圖中刪除 C2 和所有以它為尾的弧:
任意選擇無前驅的頂點 C3 並輸出,在圖中刪除 C3 和所有以它為尾的弧:
任意選擇無前驅的頂點 C4 並輸出,在圖中刪除 C4 和所有以它為尾的弧:
任意選擇無前驅的頂點 C5 並輸出,在圖中刪除 C5 和所有以它為尾的弧:
任意選擇無前驅的頂點 C7 並輸出,在圖中刪除 C7 和所有以它為尾的弧:
任意選擇無前驅的頂點 C9 並輸出,在圖中刪除 C9 和所有以它為尾的弧:
任意選擇無前驅的頂點 C10 並輸出,在圖中刪除 C10 和所有以它為尾的弧:
任意選擇無前驅的頂點 C11 並輸出,在圖中刪除 C11 和所有以它為尾的弧:
任意選擇無前驅的頂點 C6 並輸出,在圖中刪除 C6 和所有以它為尾的弧:
任意選擇無前驅的頂點 C12 並輸出,在圖中刪除 C12 和所有以它為尾的弧:
任意選擇無前驅的頂點 C8 並輸出,在圖中刪除 C8 和所有以它為尾的弧:
到此為止,拓撲排序結束,序列為:C1,C2,C3,C4,C5,C7,C9,C10,C11,C6,C12,C8。由此看見這個圖無環,是一個 AOV 圖,而且拓撲序列不唯一。
算法實現
結構設計
選擇鄰接表作為有向圖的存儲結構,需要的輔助結構有:
- 一維數組 indegree[i]:存放各個點的入度,沒有前驅的頂點就是入度為 0 的頂點。刪除頂點及以其為弧尾的弧可以用弧頭頂點入度減去 1 的手法描述;
- 棧或隊列 S:存儲所有入度為 0 的頂點,可以避免重復掃描數組 indegree 檢測入度為 0 的頂點;
- 一維數組 topo[i]:記錄拓撲序列的頂點序號。
算法步驟
- 求出各頂點的入度存入數組 indegree[i] 中,將入度為 0 的頂點入棧;
- 將棧頂頂點 vi 出棧並保存於拓撲序列 topo 中,對頂點 vi 的每個臨接點 vk 的入度減 1,若 vk 的入度變為 0 則 vk 入棧;
- 重復第二步直至棧為空棧;
- 判斷拓撲序列中的頂點個數,若少於 AOV 網中的頂點個數則有環,否則無環
代碼實現
bool TopologicalSort(ALGraph G,int topo[])
{
stack<int> S;
int indegree[MAXV];
int idx = 0;
ALGraph ptr;
int k;
FindInDegree(G,indegree); //求出每個頂點的入度並存入 indegree 中
for(int i = 0; i < G.n; i++)
{
if(!indegree[i])
S.push(i); //入度為 0 的元素入棧
}
while(!S.empty())
{
topo[idx++] = S.top(); //棧頂 vi 存入拓撲序列
S.pop(); //棧頂 vi 出棧
ptr = G.vertices[i].firstarc;
while(ptr)
{
k = ptr->adjvex;
indegree[k]--; //vi 的鄰接點入度減 1
if(indegree[k] == 0)
S.push(k); //若入度減為 0,入棧
ptr = ptr->nextarc;
}
}
if(idx < G.n)
return false; //圖出現回路
return true;
}
實例:剿滅魔教
情景需求
輸入樣例
3
7 10
2 1
3 1
4 1
5 2
6 2
5 3
6 3
6 4
7 5
7 6
5 0
3 3
1 2
2 3
3 1
輸出樣例
1 2 3 4 5 6 7
1 2 3 4 5
-1
情景分析
其實本質上還是拓撲排序啦,不過加入了當一些部件都可以安裝時,應當先安裝編號較小的部件這個限制。我們知道拓撲排序最大的意義在於判斷一個有向圖結構有沒有出現環,至於序列的話,我說過這是提出了一種導向,這種導向的執行是不唯一的,加入這個限制之后使得輸出的拓撲排序變的唯一。看似僅僅是加入了一個很小的需求,但是可能我們這些寫程序的就要頭痛了。我有 2 種方案:
- 我之前提過,我們總是很喜歡有序的東西,因為一個結構一旦有序我們就有很多操作可以對付之,因此第一種思路是對存儲結構進行調整。比較粗暴的方式是先做一個鄰接矩陣,然后按照倒序頭插法建鄰接表,但是這么做明顯空間復雜度巨大。還有一種手法是直接做鄰接表,不過鄰接表的話生成結點的方式使用插入法使得鄰接表本身是一個有序的結構,然后拓撲排序的時候利用好有序這個特性來就行了;
- 第二種想法是從我們的輔助結構下手,也就是說我們處理入度為 0 的頂點時使用的是棧或隊列這種操作受限的線性表,這 2 種結構會對排序序列造成影響。因此我們就想要強化這種結構使其能按照我們希望的序列去生成,這就是我們熟悉的手法:最(大\小)棧和優先級隊列。為什么這么說?因為這 2 種結構是能夠對彈棧或出隊列的元素進行限制的強化結構,和我們的需求不謀而合!
代碼實現
#include <iostream>
#include <queue>
#define MAXV 100001
using namespace std;
typedef struct ANode
{
int adjvex; //該邊的終點編號
struct ANode* nextarc; //指向下一條邊的指針
int info; //該邊的相關信息,如權重
} ArcNode; //邊表節點類型
typedef int Vertex;
typedef struct Vnode
{
Vertex data; //頂點信息
int count; //入度
ArcNode* firstarc; //指向第一條邊
} VNode; //鄰接表頭節點類型
typedef VNode AdjList[MAXV];
typedef struct
{
AdjList adjlist; //鄰接表
int n, e; //圖中頂點數n和邊數e
} AdjGraph;
//鄰接表拓撲排序
void TopSort(AdjGraph* G) //需要在該函數開始計算並初始化每個節點的入度,然后再進行拓撲排序
{
int indegree[MAXV] = { 0 };
ArcNode* ptr;
priority_queue<int, vector<int>, greater<int>>que;
int topo[MAXV];
int top = 0, idx = 0;
int i;
int v;
for (i = 1; i <= G->n; i++)
{
ptr = G->adjlist[i].firstarc;
while (ptr)
{
indegree[ptr->adjvex]++;
ptr = ptr->nextarc;
}
}
for (i = 1; i <= G->n; i++)
{
if (indegree[i] == 0)
{
que.push(i);
}
}
while (!que.empty())
{
v = topo[idx++] = que.top();
que.pop();
ptr = G->adjlist[v].firstarc;
while (ptr)
{
v = ptr->adjvex;
indegree[v]--;
if (indegree[v] == 0)
{
que.push(v);
}
ptr = ptr->nextarc;
}
}
if (idx < G->n)
{
cout << "-1 ";
}
else
{
for (i = 0; i < G->n; i++)
{
cout << topo[i] << " ";
}
}
return;
}
“泡茶”問題解決篇
這里我其實並沒有解決“泡茶問題”,而只是用這個話題做個引入。再回顧下,我認為拓撲排序提供的是一種導向,它描述了工程這么去操作是否可行,以及給了一種大致上的流程規划。但是這畢竟只是一種導向,並不精確,它指示的不是解決這個問題的最優解。
左轉博客關鍵路徑!
參考資料
《數據結構(C語言版|第二版)》—— 嚴蔚敏 李冬梅 吳偉民 編著,人民郵電出版社
堆、優先級隊列和堆排序
關鍵路徑