Coursera - Algorithm (Princeton) - 课程笔记 - Week 9

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+rbwc
    • 考察队伍不会被淘汰,当且仅当所有从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 R1之间(可以将键作为数组之索引)
    • 因为一些键还和数据关联着,因此单纯地数键的个数是不能很好排序的
  • 目标:对一个保存着N的 0 0 0 R − 1 R-1 R1之间的整数的数组排序

  • 步骤:

    • 以键作为索引,对键进行计数(结果的数组中存着这个键出现的个数)
    • 按照键增长方向做累加,以明确下一个键出现的相对位置
    • 按顺序遍历原数组,以键作为索引遍历计算好的累加值,向结果数组指定位置送入键,同时累加值加一(下一个同样的键所在的新位置)
    • 将结果数组中的值复制到原数组,完成排序
    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;
    }
    
  • 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} 2i1个字符的后缀数组,创建排序到前 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路基数快排甚至可以线性时间排序

你可能感兴趣的:(Cousera-课程笔记,数据结构,算法,java)