20182301 2019-2020-1 《数据结构与面向对象程序设计》第十周学习总结

教材学习内容总结

图的结构构成
  • 顶点(vertex):图中的数据元素,如图一

  • 边(edge):图中连接这些顶点的线,如图一

  • G=(V,E) 或者 G=(V(G),E(G))

    • 其中 V(G)表示图结构所有顶点的集合,顶点可以用不同的数字或者字母来表示。E(G)是图结构中所有边的集合,每条边由所连接的两个顶点来表示。
    • 图结构中顶点集合V(G)不能为空,必须包含一个顶点,而图结构边集合可以为空,表示没有边。
图的基本概念
  • 无向图

  • 如果一个图结构中,所有的边都没有方向性,那么这种图便称为无向图。典型的无向图,如图二所示。由于无向图中的边没有方向性,这样我们在表示边的时候对两个顶点的顺序没有要求。例如顶点VI和顶点V5之间的边,可以表示为(V2, V6),也可以表示为(V6,V2)。

  • 有向图

  • 一个图结构中,边是有方向性的,那么这种图就称为有向图,如图三所示。由于图的边有方向性,我们在表示边的时候对两个顶点的顺序就有要求。我们采用尖括号表示有向边,例如<V2,V6>表示从顶点V2到顶点V6,而<V6,V2>表示顶点V6到顶点V2。

  • 顶点的度

  • 连接顶点的边的数量称为该顶点的度。顶点的度在有向图和无向图中具有不同的表示。对于无向图,一个顶点V的度比较简单,其是连接该顶点的边的数量,记为D(V)。

  • 对于有向图要稍复杂些,根据连接顶点V的边的方向性,一个顶点的度有入度和出度之分。

    • 入度是以该顶点为端点的入边数量, 记为ID(V)。
    • 出度是以该顶点为端点的出边数量, 记为OD(V)。
  • 邻接矩阵

  • 邻接顶点是指图结构中一条边的两个顶点。邻接顶点在有向图和无向图中具有不同的表示。对于无向图,邻接顶点比较简单。

  • 对于有向图要稍复杂些,根据连接顶点V的边的方向性,两个顶点分别称为起始顶点(起点或始点)和结束顶点(终点)。有向图的邻接顶点分为两类:

    • 入边邻接顶点:连接该顶点的边中的起始顶点。例如,对于组成<V2,V6>这条边的两个顶点,V2是V6的入边邻接顶点。
    • 出边邻接顶点:连接该顶点的边中的结束顶点。例如,对于组成<V2,V6>这条边的两个顶点,V6是V2的出边邻接顶点。
  • 无向完全图

  • 如果在一个无向图中, 每两个顶点之间都存在条边,那么这种图结构称为无向完全图。典型的无向完全图,如图四所示。

  • 理论上可以证明,对于一个包含M个顶点的无向完全图,其总边数为M(M-1)/2。比如图四总边数就是5(5-1)/ 2 = 10。

  • 有向完全图

  • 如果在一个有向图中,每两个顶点之间都存在方向相反的两条边,那么这种图结构称为有向完全图。典型的有向完全图,如图五所示。

  • 理论上可以证明,对于一个包含N的顶点的有向完全图,其总的边数为N(N-1)。这是无向完全图的两倍,这个也很好理解,因为每两个顶点之间需要两条边。

  • 有向无环图(DAG图)

  • 如果一个有向图无法从某个顶点出发经过若干条边回到该点,则这个图是一个有向无环图。

  • 有向无环图可以利用在区块链技术中。

  • 无权图和有权图

  • 这里的权可以理解成一个数值,就是说节点与节点之间这个边是否有一个数值与它对应,对于无权图来说这个边不需要具体的值。对于有权图节点与节点之间的关系可能需要某个值来表示,比如这个数值能代表两个顶点间的距离,或者从一个顶点到另一个顶点的时间,所以这时候这个边的值就是代表着两个节点之间的关系,这种图被称为有权图;

  • 图的连通性

  • 图的每个节点不一定每个节点都会被边连接起来,所以这就涉及到图的连通性,如下图:

  • 可以发现上面这个图不是完全连通的。

  • 简单图 ( Simple Graph)

  • 对于节点与节点之间存在两种边,这两种边相对比较特殊

    • 自环边(self-loop):节点自身的边,自己指向自己。
    • 平行边(parallel-edges):两个节点之间存在多个边相连接。
  • 这两种边都是有意义的,比如从A城市到B城市可能不仅仅有一条路,比如有三条路,这样平行边就可以用到这种情况。不过这两种边在算法设计上会加大实现的难度。而简单图就是不考虑这两种边。

常见图的算法
  • 广度优先遍历

    • 遍历方法:从一个顶点开始,辐射状地优先遍历其周围较广的区域
    • 实现方法:需要一个队列来保存遍历过的定点顺序,以便按出队的顺序再去访问这些顶点的邻接顶点
    • 顶部伪代码:
      • 访问初始结点v并标记结点v为已访问。
      • 结点v入队列
      • 当队列非空时,继续执行,否则算法结束。
      • 出队列,取得队头结点u。
      • 查找结点u的第一个邻接结点w。
      • 若结点u的邻接结点w不存在,则转到步骤3;否则循环执行以下三个步骤:
        1. 若结点w尚未被访问,则访问结点w并标记为已访问。
        2. 结点w入队列
        3. 查找结点u的继w邻接结点后的下一个邻接结点w,转到步骤6。
    • 图片解释:其广度优先算法的遍历顺序为:1->2->3->4->5->6->7->8
  • 深度优先遍历

    • 遍历方法:从一个顶点开始,沿边去探寻每一个顶点。(通俗一点:一条道走到黑!)
    • 实现方法:通过栈来保存遍历过的定点顺序,遇到一个顶点,只是先获得它,并不出栈(因为要多次利用它),然后把它的第一个未被访问的节点入队列。如果没有相邻的未被访问的顶点,才把这个顶点出栈。
    • 顶部伪代码:
      • 访问初始结点v,并标记结点v为已访问。
      • 查找结点v的第一个邻接结点w。
      • 若w存在,则继续执行4,否则算法结束。
      • 若w未被访问,对w进行深度优先遍历递归(即把w当做另一个v,然后进行步骤123)。
      • 查找结点v的w邻接结点的下一个邻接结点,转到步骤3。
    • 图片解释:其深度优先遍历顺序为1->2->4->8->5->3->6->7
最小生成树
  • 在连通网的所有生成树中,所有边的代价和最小的生成树,称为最小生成树。
  • Kruskal算法
  • 此算法可以称为“加边法”,初始最小生成树边数为0,每迭代一次就选择一条满足条件的最小代价边,加入到最小生成树的边集合里。
    • 把图中的所有边按代价从小到大排序;
    • 把图中的n个顶点看成独立的n棵树组成的森林;
    • 按权值从小到大选择边,所选的边连接的两个顶点ui,viui,vi,应属于两颗不同的树,则成为最小生成树的一条边,并将这两颗树合并作为一颗树。
    • 重复(3),直到所有顶点都在一颗树内或者有n-1条边为止。
  • Prim算法
  • 此算法可以称为“加点法”,每次迭代选择代价最小的边对应的点,加入到最小生成树中。算法从某一个顶点s开始,逐渐长大覆盖整个连通网的所有顶点。
    • 图的所有顶点集合为VV;初始令集合u={s},v=V−uu={s},v=V−u;
    • 在两个集合u,vu,v能够组成的边中,选择一条代价最小的边(u0,v0)(u0,v0),加入到最小生成树中,并把v0v0并入到集合u中。
    • 重复上述步骤,直到最小生成树有n-1条边或者n个顶点为止。
最短路径问题
  • 无权图的单源最短路径

  • 首先,我们将起点的路径长设为0,其他顶点路径长设为负数(也可以是其他不可能的值,图例中用?表示),下例以v1作为起点

  • 接着我们将起点所指向的顶点的路径长设为1,可以肯定的是,只有被路径长为0的起点所指向的顶点的路径长为1,本例中即v3和v4:

  • 接下来,我们将路径长为1的顶点(v3和v4)所指向的顶点的路径长设为2,同样可以肯定,只有被路径长为1的顶点所指向的顶点的路径长为2。不过此时会遇到一个问题:v3是v4所指向的顶点,但v3的路径长显然不应该被设为2。所以我们需要对已知路径长的顶点设一个“已知”标记,已知的顶点不再更改其路径长,具体做法在给出代码时将写明。本例中,路径长要被设为2的顶点是v2、v5、v6

  • 规律是:将路径长为i的顶点所指向的未知顶点的路径长设为i+1,i从0开始,结束条件即:当前路径长为i的顶点没有指向其它顶点,或所指向的顶点均为已知。

  • 需要注意的是结束条件的说法,我们并没有要求所有顶点都变为已知,因为确定某顶点为起点后,是有可能存在某个顶点无法由起点出发然后到达的,比如我们的例子中的v0,不存在从v1到v0的路径。

  • 将最短路径的计算结果存于一个线性表中,其结构如下:

  • 其中“一行”为线性表中的一个元素,每一行的四个单元格就是一个元素中的四个域:顶点、是否已知、与起点最短路径长、最短路径中自身的前一个顶点。

  • 那么之前计算最短路径的过程用这个表来表示的话,就是下面这样:

  • 简单伪代码

//无权最短路径计算,图存于邻接表graph,结果存入pathTable,起点即start
void unweightedPath(Node* graph,struct pathNode* pathTable,size_t  start)
{
    pathTable[start].known=true;
    pathTable[start].distance=0; //若pathTable[x].distance为0,则其preV是无用的,我们不予理睬

    //初始化pathTable中的其他元素

    //curDis即当前距离,我们要做的是令distance==curDis的顶点所指的未知顶点的distance=curDis+1
    for(int curDis=0;curDis<numVertex;++curDis)
    {
        for(int i=0;i<numVertex;++i)
        {
            if(!pathTable[i].known&&pathTable[i].distance==curDis)
            {
                pathTable[i].known=true;
                //遍历pathTable[i]所指向的顶点X
                {
                    if(!pathTable[X].known)
                    {
                        pathTable[X].preV=i;
                        pathTable[X].distance=curDis+1;
                    }
                }
            }
        }
    }
}
  • 令已知顶点所指未知顶点的distance=curDis+weight

  • 解决的思路是:我们罗列出所有已知顶点指向的所有未知顶点,看这些未知顶点中谁的distance被修改后会是最小的,最小的那个我们就修改其distance,并认为它已知。

  • 首先是正常的初始化(我们将边的权重也标识出来),假设起点为v0:

  • 接着我们罗列出所有已知顶点(只有v0)指向的所有未知顶点:v1、v2、v3。然后发现若修改它们的distance,则v1.distance=v0.distance+1=1,v2.distance=v0.distance+3=3,v3.distance=v0.distance+5=5。显然v1被修改后的distance是未知顶点中最小的,所以我们只修改v1的distance,并将v1设为已知,v2、v3不动:

  • 接着我们继续罗列出所有已知顶点(v0、v1)指向的所有未知顶点:v2、v3、v4。然后发现若修改它们的distance,则v2.distance=v0.distance+3=3,v4.distance=v1.distance+1=2,v3.distance=v1.distance+1=2(虽然v0也指向v3,但是通过v0到v3的路径长大于从v1到v3,所以v3的distance取其小者),其中v3和v4的新distance并列最小,我们任选其一比如v4,然后只修改v4的distance,并将v4设为已知,其它不动:

  • 继续,我们罗列出所有已知顶点(v0、v1、v4)指向的所有未知顶点:v2、v3、v6,发现若修改,则v2.distance=3,v3.distance=2,v6.distance=3,所以我们只修改v3的distance,并将v3设为已知:

  • 继续,我们罗列出所有已知顶点(v0、v1、v3、v4)指向的所有未知顶点:v2、v5、v6,发现若修改,则v2.distance=3,v5.distance=10,v6.distance=3,我们在v2和v6中任选一个如v2,只修改v2.distance,并将v2设为已知:

  • 继续,我们罗列出所有已知顶点指向的所有未知顶点:v5、v6,发现若修改,则v5.distance=5,v6.distance=3,所以我们只修改v6:

  • 最后,罗列出的未知顶点只有v5,若修改,其distance=5,我们将其修改并设为已知,算法结束:

  • 有权图伪代码:

//有权最短路径计算,图存于邻接表graph,结果存入pathTable,起点即start
void weightedPath(Node* graph,struct pathNode* pathTable,size_t  start)
{
    //初始化pathNode数组
    
    size_t curV;
    while(true)
    {
        //找到可确定distance的未知顶点中新distance最小的那个,存入curV,若没有则跳出循环
        //令pathNode[curV].distance和pathNode[curV].prev修改为正确的值
        pathNode[curV].known=true;
    }
}

教材学习中的问题和解决过程

  • 问题1:了解拓扑排序

  • 问题1解决方案:(详见链接5)

  • 拓扑排序就是对图中顶点进行的排序,其要求是:若存在从vx到vy的路径,那么排序结果中vx必须在vy之前。

  • 进行拓扑排序的图必须是有向无圈图。

    • 在无向图中,若存在边(vx,vy)则必存在边(vy,vx),那么依拓扑排序的要求,vx就必须在vy的前面,同时vy又必须在vx前面,这显然是矛盾的,所以拓扑排序只能用于有向图。
    • 在有向有圈图中,比如上图,其中的圈v0-v1-v4-v3-v0就暗含着两条子路径:v0-v1-v4和v4-v3-v0,依前一条路径而言,排序结果中v0必须在v4前面,而依后一条路径而言,v4又必须在v0前面,这显然也是矛盾的,所以拓扑排序只能用于有向无圈图。
  • 有向无圈图的两个特点:

    • 若图有向无圈,则必然存在一个入度为0的顶点。
    • 若图有向无圈,则去掉其入度为0的顶点及相连边(必为以该顶点为起点的有向边)后,图依然是有向无圈图。
  • 伪代码:

void topSort(graph* g,size_t numVertex,size_t topResult)
{
    //两个表示顶点的变量,后面用
    size_t tempV,adjV;
    //存储各顶点入度的数组,顶点x的入度为indegree[x]
    size_t indegree[numVertex];
    伪:根据图g初始化indegree数组

    for(int i=0;i<numVertex;++i)
    {
        伪:从indegree中找到一个入度为0的顶点,存入tempV
        if(伪:没找到入度为0的顶点)
            伪:报错、返回
        
        topResult[i]=tempV;

        伪:通过g[tempV]遍历tempV为起点的边的终点,存入adjV
            indegree[adjV]--;
    }
}
  • 问题2:图的广度优先遍历和深度优先遍历最根本的区别是什么?
  • 问题2解决方案:
  • 深度优先遍历的非递归做法时采用栈;广度优先遍历的非递归做法时采用队列
  • 深度优先遍历是把每个分支深入到不能深入为止。具体的有先序遍历、中序遍历、后序遍历;广度优先遍历又称层序遍历,从上往下一层一层遍历
  • 问题3:关于深度优先遍历的网络爬虫,深入了解一下
  • 问题3解决方案:
  • 网络爬虫(又被称为网页蜘蛛,网络机器人,在FOAF社区中间,更经常的称为网页追逐者),是一种按照一定的规则,自动地抓取万维网信息的程序或者脚本。另外一些不常使用的名字还有蚂蚁、自动索引、模拟程序或者蠕虫。
  • 访问频率限制;
    • Header头部信息校验;
    • 采用动态页面生成;
    • 采用动态页面生成;
    • 登录限制;
    • 验证码限制等。
  • 爬虫实例
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.MalformedURLException;
import java.net.URL;

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.jsoup.select.Elements;

public class Reptile {
    
    public static void main(String[] args) {
        // 传入你所要爬取的页面地址
        String url1 = "http://www.xxxx.com.cn/";
        // 创建输入流用于读取流
        InputStream is = null;
        // 包装流, 加快读取速度
        BufferedReader br = null;
        // 用来保存读取页面的数据.
        StringBuffer html = new StringBuffer();
        // 创建临时字符串用于保存每一次读的一行数据,然后 html 调用 append 方法写入 temp;
        String temp = "";
        try {
            // 获取 URL;
            URL url2 = new URL(url1);
            // 打开流,准备开始读取数据;
            is = url2.openStream();
            // 将流包装成字符流,调用 br.readLine() 可以提高读取效率,每次读取一行;
            br = new BufferedReader(new InputStreamReader(is));
            // 读取数据, 调用 br.readLine() 方法每次读取一行数据, 并赋值给 temp, 如果没数据则值 ==null,
            // 跳出循环;
            while ((temp = br.readLine()) != null) {
                // 将 temp 的值追加给 html, 这里注意的时 String 跟 StringBuffer
                // 的区别前者不是可变的后者是可变的;
                html.append(temp);
            }
            // 接下来是关闭流, 防止资源的浪费;
            if (is != null) {
                is.close();
                is = null;
            }
            // 通过 Jsoup 解析页面, 生成一个 document 对象;
            Document doc = Jsoup.parse(html.toString());
            // 通过 class 的名字得到(即 XX), 一个数组对象 Elements 里面有我们想要的数据, 至于这个 div的值,打开浏览器按下 F12 就知道了;
            Elements elements = doc.getElementsByClass("xx");
            for (Element element : elements) {
                // 打印出每一个节点的信息; 选择性的保留想要的数据, 一般都是获取个固定的索引;
                System.out.println(element.text());
            }
        } catch (MalformedURLException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

代码学习中的问题和解决过程

  • 问题1:计算出度和入度时,0总是多出很多,为什么?
  • 问题1解决方案:
  • 因为该并未初始化,所以没有的点都默认为0,于是
        for(i=0;i<5;i++){
            for(j=0;j<5;j++){
                a[i]=0;
                b[i]=0;
                dig[i][j]=-1;
            }
        }

  • 问题2:输入点不能溢出,如下图:

  • 问题2解决方案:

for(i=0;i<5;i++){
            System.out.println("请输入第一个: ");
            int input0 = scan.nextInt();
            dig[i][j] = input0;
            for(j=1;;j++){
            System.out.println("请问是否有下一个接点 ");
                String kong = scan.nextLine();
                char yn = scan.next().charAt(0);
                if(('Y' == yn)||(yn == 'y')){
                    String kong2 = scan.nextLine();
                    int input = scan.nextInt();
                    dig[i][j] = input;
                    a[i]++;
                }
                else{
                    j=0;
                    break;
                }

            }
        }
        for(int k=0;k<5;k++) {
            for(j=0;j<5;j++){
                for(i=0;dig[j][i]!=-1;i++){
                    if(dig[j][i]==k)
                        b[k]++;
                }
            }
        }
  • 问题3:
  • 问题3解决方案:
//java语言

public class PrintBinaryTree {

    public static class Node {
        public int value;
        public Node left;
        public Node right;

        public Node(int data) {
            this.value = data;
        }
    }

    public static void printTree(Node head) {
        System.out.println("Binary Tree:");
        printInOrder(head, 0, "H", 17);
        System.out.println();
    }

    public static void printInOrder(Node head, int height, String to, int len) {
        if (head == null) {
            return;
        }
        printInOrder(head.right, height + 1, "v", len);
        String val = to + head.value + to;
        int lenM = val.length();
        int lenL = (len - lenM) / 2;
        int lenR = len - lenM - lenL;
        val = getSpace(lenL) + val + getSpace(lenR);
        System.out.println(getSpace(height * len) + val);
        printInOrder(head.left, height + 1, "^", len);
    }

    public static String getSpace(int num) {
        String space = " ";
        StringBuffer buf = new StringBuffer("");
        for (int i = 0; i < num; i++) {
            buf.append(space);
        }
        return buf.toString();
    }

    public static void main(String[] args) {
        Node head = new Node(1);
        head.left = new Node(-222222222);
        head.right = new Node(3);
        head.left.left = new Node(Integer.MIN_VALUE);
        head.right.left = new Node(55555555);
        head.right.right = new Node(66);
        head.left.left.right = new Node(777);
        printTree(head);

        head = new Node(1);
        head.left = new Node(2);
        head.right = new Node(3);
        head.left.left = new Node(4);
        head.right.left = new Node(5);
        head.right.right = new Node(6);
        head.left.left.right = new Node(7);
        printTree(head);

        head = new Node(1);
        head.left = new Node(1);
        head.right = new Node(1);
        head.left.left = new Node(1);
        head.right.left = new Node(1);
        head.right.right = new Node(1);
        head.left.left.right = new Node(1);
        printTree(head);

    }

}

总代码

代码托管第十九章

书本代码第十九章

(statistics.sh脚本的运行结果截图)

上周考试错题总结

最近无检测,故无错题

点评过的同学博客和代码

  • 本周结对学习情况

- 结对学习内容
    - 学习图的定义
    - 学习图的遍历

其他(感悟、思考等,可选)

团队是重要,合作起来我们可以攻克所有难关。

学习进度条

代码行数(新增/累积) 博客量(新增/累积) 学习时间(新增/累积) 重要成长
目标 10000行 30篇 400小时
第一周 69/69 2/2 30/30 Scanner
第二、三周 529/598 3/5 25/55 部分常用类
第四周 300/1300 2/7 25/80 junit测试和编写类
第五周 2665/3563 2/9 30/110 接口与远程
第六周 1108/4671 1/10 25/135 多态与异常
第七周 1946/6617 3/13 25/160 栈、队列
第八周 831/7448 1/14 25/185 查找、排序
第九周 6059/13507 3/17 35/220 二叉查找树
第十周 1354/14861 3/20 45/265

尝试一下记录「计划学习时间」和「实际学习时间」,到期末看看能不能改进自己的计划能力。这个工作学习中很重要,也很有用。
耗时估计的公式:Y=X+X/N ,Y=X-X/N,训练次数多了,X、Y就接近了。

参考:软件工程软件的估计为什么这么难软件工程 估计方法

  • 计划学习时间:30小时

  • 实际学习时间:45小时

  • 改进情况:
    这周没有太多别的事情,专心学习Java,攻读数据结构。

参考资料