1. 程式人生 > 其它 >Ubuntu 16.04 下 Typecho 部署

Ubuntu 16.04 下 Typecho 部署

(3-13至3-14重修)

什麼是網路流

網路流是指在網路(或者流網路, Flow Network )中的

網路

網路是指一個有向圖 \(G=(V,E)\)

每條邊 \((u,v)\in E\) 都有一個權值 \(c(u,v)\),稱之為容量(Capacity),當 \((u,v)\notin E\) 時有 \(c(u,v)=0\)

其中有兩個特殊的點:源點(Source)\(s\in V\) 和匯點(Sink)\(t\in V,(s\neq t)\)

\(f(u,v)\) 定義在二元組 \((u\in V,v\in V)\) 上的實數函式且滿足

  1. 容量限制:對於每條邊,流經該邊的流量不得超過該邊的容量,即,\(f(u,v)\leq c(u,v)\)
  2. 斜對稱性:每條邊的流量與其相反邊的流量之和為 0,即 \(f(u,v)=-f(v,u)\)
  3. 流守恆性:從源點流出的流量等於匯點流入的流量,即 \(\forall x\in V - \lbrace s,t \rbrace , \sum_{(u,x) \in E} f(u,x) = \sum_{(x,v) \in E} f(x,v)\)

那麼 \(f\) 稱為網路 \(G\) 的流函式。對於 \((u,v)\in E\)\(f(u,v)\) 稱為邊的流量\(c(u,v)-f(u,v)\) 稱為邊的剩餘容量。整個網路的流量為 \(\sum_{(s,v)\in E}f(s,v)\),即從源點發出的所有流量之和

一般而言也可以把網路流理解為整個圖的流量。而這個流量必滿足上述三個性質。

流函式的完整定義為

\[f(u,v)= \begin{cases} f(u,v), & (u,v) \in E, \\\\ -f(v,u), & (v,u) \in E, \\\\ 0, & (u,v) \not\in E , (v,u) \not\in E. \end{cases} \]

反向邊

反向邊是網路流中很重要的一類邊。

一般的時候,在題目給定的流網路中是不包含有關反向邊的資訊的,我們畫圖的時候也一般不將反向邊畫出來。

但是,反向邊可以利用流網路的一些性質,通過對其流量進行操作,使得我們的子程式可以經由其進行反悔的操作。

建立反向邊的時候可以使用一些小trick。

我們如果使用鄰接表(或稱鏈式前向星)來建圖的話,可以選擇同時建正向邊和反向邊,並使邊的編號從0開始,從而可以通過使用異或操作來訪問當前邊的反向邊。

網路流的常見問題

網路流問題中常見的有以下三種:最大流,最小割,費用流。

解決網路流問題的難點不是演算法或者程式碼,而是建圖。對於大多數的網路流題目,我們需要仔細分辨琢磨才可以知道如何將問題轉換為網路流這幾種問題的其中一種或幾種,並將題目中的限制用邊/點的限制體現出來。

最大流

簡介

對於一個給定的網路,其合法的流函式其實有很多。其中使得整個網路的流量最大的流函式被稱為網路的最大流。

求解一個網路的最大流其實有很多用處,例如可以將二分圖的最大匹配問題轉化為求解最大流。

求解最大流的演算法有很多種,比如Ford-Fulkerson增廣路演算法、Push-Relable預流推進演算法等等。
實際上,最常用的還是Ford-Fulkerson增廣路演算法中的EK和Dinic兩種。

Ford-Fulkerson 增廣路演算法

該方法通過尋找增廣路來更新最大流,有EK,dinic,SAP,ISAP等主流演算法。

求解最大流之前,我們先認識一些概念。

殘量網路

首先我們介紹一下一條邊的剩餘容量 \(c_f(u,v)\)(Residual Capacity),它表示的是這條邊的容量與流量之差,即 \(c_f(u,v) = c(u,v) - f(u,v)\)

對於流函式 \(f\),殘存網路 \(G_f\)(Residual Network)是網路 \(G\) 中所有結點和剩餘容量大於 0 的邊構成的子圖。形式化的定義,即 \(G_f = (V_f = V,E_f = \lbrace(u,v) \in E,c_f(u,v) > 0 \rbrace)\)

注意,剩餘容量大於 0 的邊可能不在原圖 \(G\) 中(根據容量、剩餘容量的定義以及流函式的斜對稱性得到)。可以理解為,殘量網路中包括了那些還剩了流量空間的邊構成的圖,也包括虛邊(即反向邊)。

增廣路

在原圖 \(G\) 中若一條從源點到匯點的路徑上所有邊的剩餘容量都大於 0,這條路被稱為增廣路(Augmenting Path)。

或者說,在殘存網路 \(G_f\) 中,一條從源點到匯點的路徑被稱為增廣路。如圖:

我們從 \(4\)\(3\),肯定可以先從流量為 \(20\) 的這條邊先走。那麼這條邊就被走掉了,不能再選,總的流量為 \(20\)(現在)。然後我們可以這樣選擇:

  1. \(4 \to 2 \to 3\) 這條 增廣路 的總流量為 \(20\)。到 \(2\) 的時候還是 \(30\),到 \(3\) 了就只有 \(20\) 了。

  2. \(4 \to 2 \to 1 \to 3\) 這樣子我們就很好的保留了 \(30\) 的流量。

所以我們這張圖的最大流就應該是 \(20 + 30 = 50\)

Edmonds-Karp 動能演算法

這個演算法很簡單,就是BFS找增廣路,然後對其進行增廣,直到圖上再也沒有增廣路了為止。

我們不用管我們找到的增廣路的正確性,畢竟如果我們找到了一條更優的路徑的話可以通過之前經過的反向邊進行反悔。這也就意味著,我們每次需要BFS的邊雞是包括反向邊的。

在具體實現的時候,我們每一次找到增廣路的時候,記錄下這條路徑上所有的最小流量 \(minf\) ,那麼整個圖的流量就增加了 \(minf\)。同時我們給這條路徑上的所有邊的反向邊都加上 \(minf\) 的容量,以便將來反悔。

EK 演算法的時間複雜度為 \(O(nm^2)\)(其中 \(n\) 為點數,\(m\) 為邊數)。
其效率還有很大提升空間,但實際情況下不一定能跑滿,應付 \(10^3 \sim 10^4\) 大小的圖應該足夠了。

參考程式碼:

#include<bits/stdc++.h>
using namespace std;
const int N = 1010, M = 20010, INF = 1e8;
int n, m, S, T;
int h[N], e[M], f[M], ne[M], idx;
int q[N], d[N], pre[N];
bool st[N];

void add(int a, int b, int c)
{
	e[idx] = b, f[idx] = c, ne[idx] = h[a], h[a] = idx++;
	e[idx] = a, f[idx] = 0, ne[idx] = h[b], h[b] = idx++;
}

bool bfs()
{
	int hh = 0, tt = 0;
	memset(st, false, sizeof(st));
	q[0] = S, st[S] = true, d[S] = INF;
	while(hh <= tt)
	{
		int t = q[hh++];
		for(int i = h[t]; ~i; i = ne[i])
		{
			int ver = e[i];
			if(!st[ver] && f[i])
			{
				st[ver] = true;
				d[ver] = min(d[t], f[i]);
				pre[ver] = i;
				if(ver == T) return true;
				q[++tt] = ver;
			}
		}
	}
	return false;
}

int EK()
{
	int r = 0;
	while(bfs())
	{
		r += d[T];
		for(int i = T; i != S; i = e[pre[i] ^ 1])
			f[pre[i]] -= d[T], f[pre[i] ^ 1] += d[T];
	}
	return r;
}

int main()
{
	scanf("%d%d%d%d", &n, &m, &S, &T);
	memset(h, -1, sizeof(h));
	while(m--)
	{
		int a, b, c;
		scanf("%d%d%d", &a, &b, &c);
		add(a, b, c);
	}
	printf("%d\n", EK());
	return 0;
}

Dinic 演算法

EK 演算法每一次遍歷殘量網路的時候只能最多找到一條增廣路,但很可能我們的子程式為此而遍歷了整個殘量網路。這裡還有很大的優化空間。

Dinic 演算法的過程是這樣的:每次增廣前,我們先用 BFS 來將圖分層。設源點的層數為 \(0\),那麼一個點的層數便是它離源點的最近距離。

通過分層,我們可以幹兩件事情:

  1. 如果不存在到匯點的增廣路(即匯點的層數不存在),我們即可停止增廣。
  2. 確保我們找到的增廣路是最短的。(原因見下文)

接下來是 DFS 找增廣路的過程。

我們每次找增廣路的時候,都只找比當前點層數多 \(1\) 的點進行增廣(這樣就可以確保我們找到的增廣路是最短的)。

Dinic 演算法會不斷重複這兩個過程,直到沒有增廣路了為止。

Dinic 演算法有兩個優化:

  1. 多路增廣:每次找到一條增廣路的時候,如果殘餘流量沒有用完怎麼辦呢?我們可以利用殘餘部分流量,再找出一條增廣路。這樣就可以在一次 DFS 中找出多條增廣路,大大提高了演算法的效率。
  2. 當前弧優化:如果一條邊已經被增廣過,那麼它就沒有可能被增廣第二次。那麼,我們下一次進行增廣的時候,就可以不必再走那些已經被增廣過的邊。

Dinic 演算法的時間複雜度是 \(O(n^2m)\) 級別的,但實際上其實跑不滿這個上限。
Dinic演算法可以說是演算法實現難易程度與時間複雜度較為平衡的一個演算法,可以應對 \(10^4 \sim 10^5\)級別的圖。
特別的,Dinic演算法在求解二分圖最大匹配問題的時候的時間複雜度是 \(O(m\sqrt{n})\) 級別的,實際情況下則比這更優。

時間複雜度證明搬自 OI-Wiki。

時間複雜度

設點數為 \(n\),邊數為 \(m\),那麼 Dinic 演算法的時間複雜度(在應用上面兩個優化的前提下)是 \(O(n^2m)\),在稀疏圖上效率和 EK 演算法相當,但在稠密圖上效率要比 EK 演算法高很多。

首先考慮單輪增廣的過程。在應用了當前弧優化的前提下,對於每個點,我們維護下一條可以增廣的邊,而當前弧最多變化 \(m\) 次,從而單輪增廣的最壞時間複雜度為 \(O(nm)\)

接下來我們證明,最多隻需 \(n-1\) 輪增廣即可得到最大流。

我們先回顧下 Dinic 的增廣過程。對於每個點,Dinic 只會找比該點層數多 \(1\) 的點進行增廣。

首先容易發現,對於圖上的每個點,一輪增廣後其層數一定不會減小。而對於匯點 \(t\),情況會特殊一些,其層數在一輪增廣後一定增大。

對於後者,我們考慮用反證法證明。如果 \(t\) 的層數在一輪增廣後不變,則意味著在上一次增廣中,仍然存在著一條從 \(s\)\(t\) 的增廣路,且該增廣路上相鄰兩點間的層數差為 \(1\)。這條增廣路應該在上一次增廣過程中就被增廣了,這就出現了矛盾。

從而我們證明了匯點的層數在一輪增廣後一定增大,即增廣過程最多進行 \(n-1\) 次。

綜上 Dinic 的最壞時間複雜度為 \(O(n^{2}m)\)。事實上在一般的網路上,Dinic 演算法往往達不到這個上界。

特別地,在求解二分圖最大匹配問題時,Dinic 演算法的時間複雜度是 \(O(m\sqrt{n})\)。接下來我們將給出證明。

首先我們來簡單歸納下求解二分圖最大匹配問題時,建立的網路的特點。我們發現這個網路中,所有邊的流量均為 \(1\),且除了源點和匯點外的所有點,都滿足入邊最多隻有一條,或出邊最多隻有一條。我們稱這樣的網路為單位網路

對於單位網路,一輪增廣的時間複雜度為 \(O(m)\),因為每條邊只會被考慮最多一次。

接下來我們試著求出增廣輪數的上界。假設我們已經先完成了前 \(\sqrt{n}\) 輪增廣,因為匯點的層數在每次增廣後均嚴格增加,因此所有長度不超過 \(\sqrt{n}\) 的增廣路都已經在之前的增廣過程中被增廣。設前 \(\sqrt{n}\) 輪增廣後,網路的流量為 \(f\),而整個網路的最大流為 \(f'\),設兩者間的差值 \(d=f'-f\)

因為網路上所有邊的流量均為 \(1\),所以我們還需要找到 \(d\) 條增廣路才能找到網路最大流。又因為單位網路的特點,這些增廣路不會在源點和匯點以外的點相交。因此這些增廣路至少經過了 \(d\sqrt{n}\) 個點(每條增廣路的長度至少為 \(\sqrt{n}\)),且不能超過 \(n\) 個點。因此殘量網路上最多還存在 \(\sqrt{n}\) 條增廣路。也即最多還需增廣 \(\sqrt{n}\) 輪。

綜上,對於包含二分圖最大匹配在內的單位網路,Dinic 演算法可以在 \(O(m\sqrt{n})\) 的時間內求出其最大流。

參考程式碼:

#include<bits/stdc++.h>
using namespace std;
const int N = 10010, M = 200010, INF = 1e8;
int n, m, S, T;
int h[N], e[M], f[M], ne[M], idx;
int q[N], d[N], cur[N];

void add(int a, int b, int c)
{
	e[idx] = b, f[idx] = c, ne[idx] = h[a], h[a] = idx++;
	e[idx] = a, f[idx] = 0, ne[idx] = h[b], h[b] = idx++;
}

bool bfs()
{
	int hh = 0, tt = 0;
	memset(d, -1, sizeof(d));
	q[0] = S, d[S] = 0, cur[S] = h[S];
	while(hh <= tt)
	{
		int t = q[hh++];
		for(int i = h[t]; ~i; i = ne[i])
		{
			int ver = e[i];
			if(d[ver] == -1 && f[i])
			{
				d[ver] = d[t] + 1;
				cur[ver] = h[ver];
				if(ver == T)  return true;
				q[++tt] = ver;
			}
		}
	}
	return false;
}

int find(int u, int limit)
{
	if(u == T) return limit;
	int flow = 0;
	for(int i = cur[u]; ~i && flow < limit; i = ne[i])
	{
		cur[u] = i;  // 當前弧優化
		int ver = e[i];
		if(d[ver] == d[u] + 1 && f[i])
		{
			int t = find(ver, min(f[i], limit - flow));
			if(!t) d[ver] = -1;
			f[i] -= t, f[i ^ 1] += t, flow += t;
		}
	}
	return flow;
}

int dinic()
{
	int r = 0, flow;
	while(bfs()) while(flow = find(S, INF)) r += flow;
	return r;
}

int main()
{
	scanf("%d%d%d%d", &n, &m, &S, &T);
	memset(h, -1, sizeof(h));
	while(m--)
	{
		int a, b, c;
		scanf("%d%d%d", &a, &b, &c);
		add(a, b, c);
	}
	printf("%d\n", dinic());
	return 0;
}

最小割

簡介

對於一個給定的網路,我們刪去網路中的一個邊集,使得源點與匯點不連通,這個被刪去的邊集就是這張圖的一個。在所有的割中,邊集的容量和最小的被稱為最小割。

最大流最小割定理

任何一個網路的最大流量等於最小割中邊的容量值和。

證明

我們如果回想一下最大流演算法走完之後的結果。

對於每一條從源點到匯點的流量為正的路徑,我們都能找到至少一條容量跑滿的邊。

這些邊的容量之和就是我們最終得到的最大流。

我們考慮將這些邊刪去。

那麼我們如果對剩下的邊跑最大流演算法的話,我們得到的最大流將會是0,也就意味著源點將不再與匯點直接連通。

這些跑滿的邊構成的邊集就滿足了我們對割的定義。

實現

所以說,我們完全可以用求解最大流的演算法來求解最小割問題。

費用流

簡介

給定一個網路 \(G=(V,E)\),每條邊除了有容量限制 \(c(u,v)\),還有一個單位流量的費用 \(w(u,v)\)

\((u,v)\) 的流量為 \(f(u,v)\) 時,需要花費 \(f(u,v)\times w(u,v)\) 的費用。

\(w\) 也滿足斜對稱性,即 \(w(u,v)=-w(v,u)\)

則該網路中總花費最小的最大流稱為最小費用最大流,即在最大化 \(\sum_{(s,v)\in E}f(s,v)\) 的前提下最小化 \(\sum_{(u,v)\in E}f(u,v)\times w(u,v)\)

最小費用最大流問題與帶權二分圖最大匹配問題的關係就和最大流問題和二分圖最大匹配問題的關係類似,這就意味著我們可以使用求解費用流問題的演算法來求解帶權二分圖最大匹配問題。

SSP 演算法

SSP(Successive Shortest Path)演算法是一個貪心的演算法。它的思路是每次尋找單位費用最小的增廣路進行增廣,直到圖上不存在增廣路為止。

如果圖上存在單位費用為負的圈,SSP 演算法正確無法求出該網路的最小費用最大流。此時需要先使用消圈演算法消去圖上的負圈。

證明搬自 OI-Wiki。

證明

我們考慮使用數學歸納法和反證法來證明 SSP 演算法的正確性。

設流量為 \(i\) 的時候最小費用為 \(f_i\)。我們假設最初的網路上 沒有負圈,這種情況下 \(f_0=0\)

假設用 SSP 演算法求出的 \(f_i\) 是最小費用,我們在 \(f_i\) 的基礎上,找到一條最短的增廣路,從而求出 \(f_{i+1}\)。這時 \(f_{i+1}-f_i\) 是這條最短增廣路的長度。

假設存在更小的 \(f_{i+1}\),設它為 \(f_{i+1}'\)。因為 \(f_{i+1}-f_i\) 已經是最短增廣路了,所以 \(f_{i+1}'-f_i\) 一定對應一個經過 至少一個負圈 的增廣路。

這時候矛盾就出現了:既然存在一條經過至少一個負圈的增廣路,那麼 \(f_i\) 就不是最小費用了。因為只要給這個負圈新增流量,就可以在不增加 \(s\) 流出的流量的前提下,使 \(f_i\) 對應的費用更小。

綜上,SSP 演算法可以正確求出無負圈網路的最小費用最大流。

時間複雜度

如果使用 Bellman-Ford 演算法求解最短路,每次找增廣路的時間複雜度為 \(O(nm)\)。設該網路的最大流為 \(f\),則最壞時間複雜度為 \(O(nmf)\)。事實上,這個時間複雜度是偽多項式的

實現

只需將 EK 演算法或 Dinic 演算法中找增廣路的過程,替換為用最短路演算法尋找單位費用最小的增廣路即可。

基於EK演算法的實現:

#include<bits/stdc++.h>
using namespace std;
const int N = 5010, M = 100010, INF = 1e8;
int n, m, S, T;
int h[N], e[M], f[M], w[M], ne[M], idx;
int q[N], d[N], pre[N], incf[N];
bool st[N];

void add(int a, int b, int c, int d)
{
	e[idx] = b, f[idx] = c, w[idx] = d, ne[idx] = h[a], h[a] = idx++;
	e[idx] = a, f[idx] = 0, w[idx] = -d, ne[idx] = h[b], h[b] = idx++;
}

bool spfa()
{
	int hh = 0, tt = 1;
	memset(d, 0x3f, sizeof(d));
	memset(incf, 0, sizeof(incf));
	q[0] = S, d[S] = 0, incf[S] = INF;
	while(hh != tt)
	{
		int t = q[hh++];
		if(hh == N) hh = 0;
		st[t] = false;

		for(int i = h[t]; ~i; i = ne[i])
		{
			int ver = e[i];
			if(f[i] && d[ver] > d[t] + w[i])
			{
				d[ver] = d[t] + w[i];
				pre[ver] = i;
				incf[ver] = min(f[i], incf[t]);
				if(!st[ver])
				{
					q[tt++] = ver;
					if(tt == N) tt = 0;
					st[ver] = true;
				}
			}
		}
	}

	return incf[T] > 0;
}

void EK(int &flow, int &cost)
{
	flow = cost = 0;
	while(spfa())
	{
		int t = incf[T];
		flow += t, cost += t * d[T];
		for(int i = T; i != S; i = e[pre[i] ^ 1])
		{
			f[pre[i]] -= t;
			f[pre[i] ^ 1] += t;
		}
	}
}

int main()
{
	scanf("%d%d%d%d", &n, &m, &S, &T);
	memset(h, -1, sizeof(h));
	while(m--)
	{
		int a, b, c, d;
		scanf("%d%d%d%d", &a, &b, &c, &d);
		add(a, b, c, d);
	}
	int flow, cost;
	EK(flow, cost);
	printf("%d %d\n", flow, cost);
	return 0;
}

基於 Dinic 演算法的實現:

#include<bits/stdc++.h>
using namespace std;
const int N = 5010, M = 200010, INF = 1e16;
int n, m, S, T;
int h[N], e[M], f[M], w[M], ne[M], idx;
int q[N], d[N], cur[N], pre[N], incf[N];
bool st[N];

void add(int a, int b, int c, int d)
{
	e[idx] = b, f[idx] = c, w[idx] = d, ne[idx] = h[a], h[a] = idx++;
	e[idx] = a, f[idx] = 0, w[idx] = -d, ne[idx] = h[b], h[b] = idx++;
}

bool spfa()
{
	int hh = 0, tt = 1;
	memset(d, 0x3f, sizeof(d));
	memset(incf, 0, sizeof(incf));
	memcpy(cur, h, sizeof(h));
	q[0] = S, d[S] = 0, incf[S] = INF;
	while(hh != tt)
	{
		int t = q[hh++];
		if(hh == N)hh = 0;
		st[t] = false;
		for(int i = h[t]; ~i; i = ne[i])
		{
			int ver = e[i];
			if((f[i]) && (d[ver] > d[t] + w[i]))
			{
				d[ver] = d[t] + w[i];
				pre[ver] = i;
				incf[ver] = min(f[i], incf[t]);
				if(!st[ver])
				{
					q[tt++] = ver;
					if(tt == N)tt = 0;
					st[ver] = true;
				}
			}
		}
	}
	return incf[T] > 0;
}

int dfs(int u, int lim)
{
	if(u == T)return lim;
	int flow = 0;
	for(int i = cur[u]; ~i && flow < lim; i = ne[i])
	{
		cur[u] = i;
		int ver = e[i];
		if((d[ver] == d[u] + 1) && (f[i]))
		{
			int t = dfs(ver, min(f[i], lim - flow));
			if(!t)d[ver] = -1;
			f[i] -= t, f[i ^ 1] += t, flow += t;
		}
	}
	return flow;
}
void dinic(int &flow, int &cost)
{
	flow = cost = 0;
	int r = 0;
	while(spfa())
	{
		while(r = dfs(S, INF))
		{
			flow = r;
			cost += r * d[T];
		}
	}
}

int main()
{
	scanf("%d%d%d%d", &n, &m, &S, &T);
	memset(h, -1, sizeof(h));
	for(int i = 1; i <= m; i++)
	{
		int a, b, c, d;
		scanf("%d%d%d%d", &a, &b, &c, &d);
		add(a, b, c, d);
	}
	int flow, cost;
	dinic(flow, cost);
	printf("%d %d\n", flow, cost);
	return 0;
}

(貌似寄了,求調)

常見技巧

拆點

我們經常需要應對一些對點的訪問次數或者流量上限有限制的題目。

或者我們有時候需要處理多類點的匹配問題,例如某一個USACO的題目

對於這種問題,我們可以把一個點拆成兩個點,並用一條邊連線這兩個點。

如果我們對點的最大流量有限制,那麼我們就可以將這條邊的流量設定為原圖中對應的點的最大流量。

如果我們走過點的時候有費用,那麼我們就把費用加到邊上。

等等等等。

以剛才我們提到的題目為例。

這道題要求我們將食物、飲料與牛進行配對。
為了防止一頭牛吃兩頓飯,我們將代表牛的點拆成兩個,中間連線一條容量為1的邊,代表這個點只能被訪問一次。

程式碼:

#include<bits/stdc++.h>
using namespace std;
const int N = 510, M = 200010, INF = 1e8;
int n, f, d;
int h[N], e[M], c[M], ne[M], idx;
int num[N];
int minn, s, t, minflow, maxflow, tot;
queue<int>q;

void add(int u, int v, int a)
{
	e[++idx] = v, c[idx] = a, ne[idx] = h[u], h[u] = idx;
	e[++idx] = u, c[idx] = 0, ne[idx] = h[v], h[v] = idx;
}

bool add_num()
{
	while(!q.empty())q.pop();
	for(int i = s; i <= t + n; i++)num[i] = -1;
	num[s] = 1;
	q.push(s);
	while(!q.empty())
	{
		int now = q.front();
		q.pop();
		for(int i = h[now]; i; i = ne[i])
		{
			if(c[i] && num[e[i]] == -1)
			{
				num[e[i]] = num[now] + 1;
				q.push(e[i]);
			}
		}
	}
	if(num[t] == -1)return false;
	else return true;
}

int dfs(int u, int s)
{
	if(u == t)
	{
		return s;
	}
	for(int i = h[u]; i; i = ne[i])
	{
		if(c[i] && num[u] + 1 == num[e[i]] && (minflow = dfs(e[i], min(s, c[i]))))
		{
			c[i] -= minflow;
			c[i ^ 1] += minflow;
			return minflow;
		}
	}
	return 0;
}

void dinic()
{
	while(add_num())
		while((minn = dfs(1, INF)))
			maxflow += minn;
}

int main()
{
	scanf("%d%d%d", &n, &f, &d);
	idx = 1;
	s = 1;
	t = 1 + f + n + d + 1;
	for(int i = 1; i <= f; i++)add(s, 1 + i, 1);
	for(int i = 1; i <= d; i++)add(1 + f + n + i, t, 1);
	for(int i = 1; i <= n; i++)add(1 + f + i, 1 + f + n + d + 1 + i, 1);
	for(int i = 1; i <= n; i++)
	{
		int dn, fn;
		scanf("%d%d", &fn, &dn);
		for(int q = 1; q <= fn; q++)
		{
			int fi;
			scanf("%d", &fi);
			add(1 + fi, 1 + f + i, 1);
		}
		for(int q = 1; q <= dn; q++)
		{
			int di;
			scanf("%d", &di);
			add(1 + f + n + d + 1 + i, 1 + f + n + di, 1);
		}
	}
	dinic();
	printf("%d\n", maxflow);
	return 0;
}