Week 9
最大流和最小割 Maximum Flow and Minimum Cut
最大流 Maximum Flow
- 最小割问题:
- 输入:一个带权有向图(每条边有一个正容量值),源点s和目标点t
- 定义:一个st-割,即把顶点划分到两个互斥集合中,s和t分别在两个集合A和B中
- 定义:一个割的容量是从A到B的边的容量之和
- 最小st-割问题:找到具有最小容量的割
- 最大流问题:
- 输入:一个带权有向图(每条边有一个正容量值),源点s和目标点t
- 定义:一个st-流表示从s到t对边的赋值
- 容量限制:一条边的流为非负数同时应不超过边的容量
- 局部平衡:入流=来自每一个顶点的出流(源点无入流,目标点无出流)
- 定义:一个流的值是在目标点的入流
- 最大st-流问题:寻找一个流的最大值
- 这两个问题实质是一个问题
福德-福克斯算法 Ford-Fulkerson Algorithm
- 初始化:从0流量开始
- 扩展路径:找到一条从s到t的无向路径,要求
- 可以在前向路径上增加流量(未满)
- 可以在反向路径上减小流量(未空)
- 扩展路径时,我们应尽可能增加前向路径的流量(加满瓶颈容量,即所有前向边的最小容量),同时为了维护局部平衡,应相应地减小路径上的反向边的等量权重
- 算法会在没有这样的扩展路径存在时终止
最大流-最小割理论 Maxflow-Mincut Theorem
- 流与割的关系
- 跨割之流:一个跨越割的流,为从A到B(源点到目标点)边的流量之和减去从B到A的边的流量之和
- 流值引理:如果有任意一个流f以及任意一个割(A,B),那么跨越这个割的流的值必等于f
- 推论:s的出流总量=t的入流总量=流的值
- 弱二相性(weak duality):对任一个流f以及任一个割(A,B),流的值f不超过割的流容量
- 最大流-最小割理论:
- 扩展路径理论:一个流f是最大流,让且仅当没有扩展路径
- 最大流的值f=最小割的容量
- 从最大流f计算最小割(A,B):图搜索
- 根据扩展路径理论:流f没有扩展路径
- 计算A为与a在无向图上的连通点中,无满前向边和无空反向边的最大集合(BFS)
运行时间分析 Running Time Analysis
-
一些有关FF算法的问题
- 计算一个最小割?很简单,如上述
- 如何找到一条扩展路径?BFS
- 当FF算法终止时,是否计算得到了一个最大流?是的
- FF总会终止吗?如果是,会在多少次扩张后终止?前者是的,但是有限制条件(容量为整数,或者仔细选择扩展路径);后者需要好好分析
-
一个特例:每一个边的容量都是1到U之间的整数
- 不变特征:流值在整个算法构成中都是整数
- 性质:扩展次数不会超过最大流(因为每一次扩展都会增加流值至少1)
- 完整性理论:存在一条整数值的最大流
- 问题:尽管是整数,很有可能扩展次数等于最大流值,但是可以避免(最短/最宽路)
-
一些分析
扩展方式 路径数量 实现 最短路 ≤ 1 2 E V \le \frac12EV ≤21EV 队列(BFS) 最宽路 ≤ E ln ( E U ) \le E \ln (EU) ≤Eln(EU) 优先队列 随机路 ≤ E U \le EU ≤EU 随机队列 DFS路 ≤ E U \le EU ≤EU 栈(DFS)
Java实现 Java Implementation
-
流网络表示
- 流边数据类型:将流f和边的容量c(可以视为之前的权重)关联到边上
- 流网络数据类型:需要在两个方向上处理该边,即加入到边的两个端点的邻接列表中(遍历过程中,边的方向只起到加减权重的判定,因此整个图在遍历时视为无向图)
- 残差容量(用于计算扩展路径瓶颈值)
- 对前向边:c-f
- 对后向边:f
- 扩展流:
- 对前向边:加之
- 对后向边:减之
- 残差网络:使用残差直接表现出原始网络中边的空满情况
- 对任一条边,使用两条方向相反的边表示其残差(同向则为正向边,异向则为反向边)
- 原始网络中的扩展路径便等价于残差网络中的有向路径
- 流边API
public class FlowEdge { FlowEdge(int v, int w, double capacity);// create a flow edge v→w int from(); // vertex this edge points from int to(); // vertex this edge points to int other(int v); // other endpoint double capacity(); // capacity of this edge double flow(); // flow in this edge double residualCapacityTo(int v); // residual capacity toward v void addResidualFlowTo(int v, double delta); // add delta flow toward v String toString(); // string representation }
- 流边实现
public class FlowEdge { private final int v, w; // from and to private final double capacity; // capacity private double flow; // flow public FlowEdge(int v, int w, double capacity) { this.v = v; this.w = w; this.capacity = capacity; } public int from() { return v; } public int to() { return w; } public double capacity() { return capacity; } public double flow() { return flow; } public int other(int vertex) { if (vertex == v) return w; else if (vertex == w) return v; else throw new RuntimeException("Illegal endpoint"); } public double residualCapacityTo(int vertex) { if (vertex == v) return flow; else if (vertex == w) return capacity - flow; else throw new IllegalArgumentException(); } public void addResidualFlowTo(int vertex, double delta) { if (vertex == v) flow -= delta; // backward else if (vertex == w) flow += delta; //forward else throw new IllegalArgumentException(); } }
- 流网络API
public class FlowNetwork { FlowNetwork(int V); // create an empty flow network with V vertices FlowNetwork(In in); // construct flow network input stream void addEdge(FlowEdge e); // add flow edge e to this flow network Iterable<FlowEdge> adj(int v); // forward and backward edges incident to v Iterable<FlowEdge> edges(); // all edges in this flow network int V(); // number of vertices int E(); // number of edges String toString(); // string representation }
- 流网络实现
public class FlowNetwork { private final int V; private Bag<FlowEdge>[] adj; public FlowNetwork(int V) { this.V = V; adj = (Bag<FlowEdge>[]) new Bag[V]; for (int v = 0; v < V; v++) adj[v] = new Bag<FlowEdge>(); } public void addEdge(FlowEdge e) { int v = e.from(); int w = e.to(); // add to both vertices adj[v].add(e); adj[w].add(e); } public Iterable<FlowEdge> adj(int v) { return adj[v]; } }
- 福德-福克森算法实现
public class FordFulkerson { private boolean[] marked; // true if s->v path in residual network private FlowEdge[] edgeTo; // last edge on s->v path private double value; // value of flow public FordFulkerson(FlowNetwork G, int s, int t) { value = 0.0; while (hasAugmentingPath(G, s, t)) { double bottle = Double.POSITIVE_INFINITY; // conpute bottleneck capacity for (int v = t; v != s; v = edgeTo[v].other(v)) bottle = Math.min(bottle, edgeTo[v].residualCapacityTo(v)); // augment flow for (int v = t; v != s; v = edgeTo[v].other(v)) edgeTo[v].addResidualFlowTo(v, bottle); // update flow value += bottle; } } // BFS private boolean hasAugmentingPath(FlowNetwork G, int s, int t) { edgeTo = new FlowEdge[G.V()]; marked = new boolean[G.V()]; Queue<Integer> queue = new Queue<Integer>(); queue.enqueue(s); marked[s] = true; while (!queue.isEmpty()) { int v = queue.dequeue(); for (FlowEdge e : G.adj(v)) { int w = e.other(v); // a path from s to w if (e.residualCapacityTo(w) > 0 && !marked[w]) { edgeTo[w] = e; marked[w] = true; queue.enqueue(w); } } } return marked[t]; } public double value() { return value; } // is v in the cut containing s? or reachable from s in the residual network public boolean inCut(int v) { return marked[v]; } }
最大流应用 Maxflow Applications
- 双边匹配(Bipartite Matching)问题:给定N个职位和N个申请者,是否存在一个方式,每个申请者都得到工作
- 给定一张二部图,找到完美的匹配
- 双边匹配问题的网络流形式化
- 创建s,t,每个申请者一个点,每个职位一个点
- 从s到每个申请者各一条边,容量为1
- 从每个职位到t各一条边,容量为1
- 从每个申请者到每个提供给他的职位各一条边,容量为无穷
- 问题:在一张二部图上寻找流为N的1对1对应
- 目标:如果不存在完美匹配,解释为什么
- 可能出现一种情况,S个学生只在T个职位中获得,如果 ∣ S ∣ > ∣ T ∣ |S| > |T| ∣S∣>∣T∣,那么必然不能存在完美匹配
- 最小割可以发现并解释这个问题
- 棒球淘汰赛问题:哪个队伍可以获得最大胜利
- 给定胜局,负局和即将进行的比赛情况,判断淘汰情况
- 找到一定能赢过某一队的队伍,并判断其是否数学上必被淘汰
- 对剩下的队伍和比赛建立二部图(从比赛到队伍,容量无穷)计算最大流
- s到每个比赛的容量为两个队伍之间的剩余比赛数量
- 队伍到t的容量为该队伍为相比于考察队,该队仍能胜利的数量( w b + r b − w c w_b + r_b - w_c wb+rb−wc)
- 考察队伍不会被淘汰,当且仅当所有从s出发的边在最大流中均满
基数排序 Radix Sort
Java字符串 Strings in Java
- 字符串:字符的序列,用于很多的数据应用的数据抽象中
- C的字符的数据结构:一般是一个8位整数
- 支持7位表示的ASCII码
- 只能表示256个字符
- Java使用Unicode字符实现:16位无符号整数
- 字符串长度:字符个数
- 索引:获得字符串的第i个字符
- 子串操作:后的一个连续的字符子序列(可常数时间实现)
- 字符串连接:将一个字符附加到字符串尾部(不可常数时间实现,正比于字符个数)
- Java的字符串是不可异变的
- 字符串的Java实现
public final class String implements Comparable<String>
{
private char[] value; // characters
private int offset; // index of first char in array
private int length; // length of string
private int hash; // cache of hashCode()
public int length()
{ return length; }
public char charAt(int i)
{ return value[i + offset]; }
private String(int offset, int length, char[] value)
{
this.offset = offset;
this.length = length;
this.value = value;
}
public String substring(int from, int to)
{ return new String(offset + from, to - from, value); }
...
- 内存占用: 40 + 2 N 40+2N 40+2N
StringBuilder
:可异变的字符序列- 实现:可调整大小的字符数组
- 字串操作是正比于字符个数的时间
- 连接操作是分摊的线性时间
StringBuffer
十分相似,但是线程安全的(更慢)- 字符串逆序,Builder更快
- 取后缀(取子串),String更快
- 数字键:在一个固定字符表中的数字序列
- 基数:用于字符表键的数字个数R(ASCII的基数是256)
键索引计数 Key-Indexed Counting
-
在不依赖键的比较的情况下,性能下界可以优于 N log N N \log N NlogN
-
键索引计数有关键的假设
- 键的取值在 0 0 0到 R − 1 R-1 R−1之间(可以将键作为数组之索引)
- 因为一些键还和数据关联着,因此单纯地数键的个数是不能很好排序的
-
目标:对一个保存着N的 0 0 0到 R − 1 R-1 R−1之间的整数的数组排序
-
步骤:
- 以键作为索引,对键进行计数(结果的数组中存着这个键出现的个数)
- 按照键增长方向做累加,以明确下一个键出现的相对位置
- 按顺序遍历原数组,以键作为索引遍历计算好的累加值,向结果数组指定位置送入键,同时累加值加一(下一个同样的键所在的新位置)
- 将结果数组中的值复制到原数组,完成排序
int N = a.length; int[] count = new int[R+1]; for (int i = 0; i < N; i++) count[a[i]+1]++; for (int r = 0; r < R; r++) count[r+1] += count[r]; for (int i = 0; i < N; i++) aux[count[a[i]]++] = a[i]; for (int i = 0; i < N; i++) a[i] = aux[i];
-
性质:使用大约 ∼ 11 N + 4 R \sim11N+4R ∼11N+4R次数组访问,空间占用正比于 N + R N+R N+R
-
该排序方法稳定,因为在移动时,仍然保持着元素的相对顺序
LSD基数排序 LSD Radix Sort
- 最小有效数字优先(least significant digit first)排序(以下考虑定长数组排序)
- 从右到左考察字符串中的每一个字符
- 对每一个字符,以当前字符为键进行计数排序,直到考察完所有字符
- 性质:LSD可以实现对定长字符串的升序排序
- Java实现
public class LSD
{
public static void sort(String[] a, int W)
{
int R = 256;
int N = a.length;
String[] aux = new String[N];
for (int d = W-1; d >= 0; d--)
{
int[] count = new int[R+1];
for (int i = 0; i < N; i++)
count[a[i].charAt(d) + 1]++;
for (int r = 0; r < R; r++)
count[r+1] += count[r];
for (int i = 0; i < N; i++)
aux[count[a[i].charAt(d)]++] = a[i];
for (int i = 0; i < N; i++)
a[i] = aux[i];
}
}
}
- 时间保证在最佳 2 W N 2WN 2WN,空间占用正比于 N + R N+R N+R
- 计数排序算法的稳定性既保证了LSD排序算法的稳定性,同时还保证了LSD排序本身的正确性(当前位排序时仍保证右边所有位的相对有序性)
MSD基数排序 MSD Radix Sort
-
最大有效数字优先(most significant)排序:
- 根据第一个字符将这些字符串划分到R个部分中(这里使用计数方法排序)
- 然后递归地对后面的各个字符按顺序划分,同样适用排序算法
-
边长字符串的处理:每个字符串处理成最后都有一个-1(比任何字符都小)
- 如果该算法用于C的字符串就不用处理,因为C字符串末尾自带一个
\0
private static int charAt(String s, int d) { if (d < s.length()) return s.charAt(d); else return -1; }
- 如果该算法用于C的字符串就不用处理,因为C字符串末尾自带一个
-
java实现
public static void sort(String[] a)
{
aux = new String[a.length];
sort(a, aux, 0, a.length-1, 0);
}
private static void sort(String[] a, String[] aux, int lo, int hi, int d)
{
// 查到只剩一个,就退出
if (hi <= lo) return;
// 避免了count的递归,节省空间
int[] count = new int[R+2];
// 计数算法
for (int i = lo; i <= hi; i++)
count[charAt(a[i], d) + 2]++; //多挪了一位,因为要处理-1的情况
for (int r = 0; r < R+1; r++)
count[r+1] += count[r];
for (int i = lo; i <= hi; i++)
aux[count[charAt(a[i], d) + 1]++] = a[i];
for (int i = lo; i <= hi; i++)
a[i] = aux[i - lo];
// 递归往后查,注意只查一个组
for (int r = 0; r < R; r++)
sort(a, aux, lo + count[r], lo + count[r+1] - 1, d+1);
}
- 问题:
- 对于小的子数组,创建count数组将会非常慢
- 由于递归,会产生大量的小的子数组
- 解决方案:对小的子数组,使用插入排序
- 从第d个字符开始进行插入排序
- 注意实现
less()
比较器
- 性能分析
- MSD排序只会检查足够用于排序的字符
- 检查次数依赖于键的长度
- 因此可以做到随即情况下的输入大小的次线性时间复杂度
- 最坏情况 2 N W 2NW 2NW,随机情况 N log R N N \log_R N NlogRN,空间占用 N + D R N+DR N+DR(D为递归深度),稳定(这里的W 是平均长度)
- MSD的问题:
- 内存访问存在一定随机性(无法利用高效缓存提升效率)
- 内循环指令过多
- 需要aux数组以及count数组的额外空间
- 快排的问题:
- MSD的形式(划分和交换)真的很像快排
- 在字符串比较时的时间复杂度是线性对数的
- 在进行长前缀比较时,需要重新检查每一个字符
- 思路:结合二者的优点
3路基数快排 3-way Raidx Quicksort
- 对第d个字符开始使用3路快排(小的在上,大的在下,相等的在中间,三部分分别递归)
- 相比于R路MSD,少了很多性能问题
- 不会重新遍历过去与划分字符相等的字符(不相等会重新查看)
- java实现:
private static void sort(String[] a)
{ sort(a, 0, a.length - 1, 0); }
// 3-way partitioning (using d th character)
private static void sort(String[] a, int lo, int hi, int d)
{
if (hi <= lo) return;
int lt = lo, gt = hi;
int v = charAt(a[lo], d); // to handle variable-length strings
int i = lo + 1;
while (i <= gt)
{
int t = charAt(a[i], d);
if
(t < v) exch(a, lt++, i++);
else if (t > v) exch(a, i, gt--);
else
i++;
}
// 分别对三个部分递归,注意,中间部分是查看下一个字符,而上下两部分仍然按照当前字符划分
sort(a, lo, lt-1, d);
if (v >= 0) sort(a, lt, gt, d+1); // sort 3 subarrays recursively
sort(a, gt+1, hi, d);
}
- 优势:
- 对随机情况平均使用 ∼ 2 N ln N \sim 2N \ln N ∼2NlnN的字符比较操作
- 对公共前缀避免了重复比较(标准快排的致命伤)
- 与MSD相比:
- 更少的内循环操作
- 缓存友好
- 原位划分
- 性能(由快排的性能导出):
- 底线情况为 13.9 W N lg R 13.9WN\lg R 13.9WNlgR
- 随机情形为 1.39 N lg N 1.39N \lg N 1.39NlgN
- 内存占用为 log N + W \log N + W logN+W
- 不稳定
后缀数组 Suffix Arrays
-
上下文关键字搜索(keyword-in-context):
- 给定一个含有N个字符的文段,处理之以实现快速的子串搜索
- 也就是给定一个文段,然后根据输入的查询词找到其对应所在的上下文
-
后缀搜索:
- 对一个字符串,以后缀形式将其处理成多个子串(N个,每个少一个头部字符,在Java的String中,取子串操作时常数时间的)
- 对这些子串排序(上面的算法)
- 针对查询串,对排好序的子串进行二分查找
-
最长重复子串:
- 给定一个N字符的符串,找到最长的重复子串(出现超过两次的子串,这里要求找到这些子串中最长的)
-
暴力方法找LRS:
- 尝试所有可能的索引对i和j
- 计算这些索引对开头的最长公共前缀(LCP)
- 时间复杂度太高( D N 2 DN^2 DN2),D为LRS的长度
-
后缀搜索方法:
- 使用后缀搜索的方式处理字符串,在排序后,LRS一定是挨着的,计算相邻后缀数组的LCP即可
- Java实现:
public String lrs(String s) { int N = s.length(); // create suffixes String[] suffixes = new String[N]; for (int i = 0; i < N; i++) suffixes[i] = s.substring(i, N); // sort them Arrays.sort(suffixes); // find LCP between adjacent suffixes in sorted order String lrs = ""; for (int i = 0; i < N-1; i++) { int len = lcp(suffixes[i], suffixes[i+1]); if (len > lrs.length()) lrs = suffixes[i].substring(0, len); } return lrs; }
-
简单的解决方案:后缀搜索+3路基数快排+LCP查找(前提是LRS不是很长)
-
如果LRS过长,上述解决方案并不适用
- 因为在形成后缀数组时,LRS的所有的后缀配对均会出现
- 这意味着检查次数至少是 1 + 2 + … + D 1+2+\ldots+D 1+2+…+D,这是二次方的时间复杂度
-
最坏情况下对数线性时间复杂度的方案:Manber-Myers MSD算法
- 阶段0:使用计数排序针对第一个字符排序
- 阶段i:给定排序到前 2 i − 1 2^{i-1} 2i−1个字符的后缀数组,创建排序到前 2 i 2^i 2i个字符的后缀数组
- (将每一次遍历中查看的字符加倍???)
-
最坏情形运行时间: N lg N N \lg N NlgN
- 在 lg N \lg N lgN个阶段之后结束算法
- 实际可以在线性时间内完成该算法
-
常数时间的比较:
- 假设目前完成了前4个字符的比较,现在要比较前8个字符
- 在课程ppt中,0和9在前四个字符上相同因此相邻
- 欲比较二者的相对顺序,对索引加4(因为从4到8要多看4个),即查看4号后缀和13号后缀在4字符排序后的相对关系
- 由这二者的相对关系决定当前0和9号的相对关系
- 这种情况下使用3路基数快排甚至可以线性时间排序