zoukankan      html  css  js  c++  java
  • 基础篇——数据结构


    这篇文章是我心血来潮把大学期间整理的数据结构笔记搬到了博客园上,提醒自己工作了学习高层概念之余也要复习基础~


    Photo by Luca Bravo on Unsplash

    算法和数据结构的学习是所有计算机科学教学计划的基础,但它并不只是对程序员和计算机系的学生有用。任何计算机使用者都希望计算机能运行得更快一些或是能解决更大规模的问题。算法已成为现代软件系统中不可或缺的一部分。这仅是几个例子而已,随着计算机应用领域 的不断扩张,这些基础方法的影响也会不断扩大。——摘自《算法》第四版

    前言:巩固基础知识,这篇文章记录常用数据结构知识的学习。

    · 线性表(Linear List)

    线性表个数据元素(结点)a[0],a[1],a[2]…,a[n-1]组成的有限序列。其中:
    数据元素的个数n定义为表的长度 = list.length( ) ("list".length() = 0(表里没有一个元素)时称为空表)
    将非空的线性表(n>=1)记作:(a[0],a[1],a[2],…,a[n-1])
    数据元素a[i](0≤i≤n-1)只是个抽象符号,其具体含义在不同情况下可以不同
    一个数据元素可以由若干个数据项组成。数据元素称为记录,含有大量记录的线性表又称为文件。这种结构具有下列特点:存在一个唯一的没有前驱的(头)数据元素;存在一个唯一的没有后继的(尾)数据元素;此外,每一个数据元素均有一个直接前驱和一个直接后继数据元素。  

    顺序表

    顺序表是在计算机内存中一般以数组的形式保存的线性表,是指用一组地址连续的存储单元依次存储数据元素的线性结构。基础实现:

    public class SequenceList<T> {
        private final int DEFAULT_SIZE = 16;
        //数组的长度
        private int capacity;
        //保存元素
        private Object[] elements;
        //保存元素的数量
        private int size;
    
        public SequenceList() {
            capacity = DEFAULT_SIZE;
            elements = new Object[capacity];
        }
    
        public SequenceList(int size) {
            capacity = 1;
            while (capacity < size) capacity <<= 1;
            elements = new Object[capacity];
        }
    
        public void add(T element) {
            //不考虑null元素的存在,也没有对数组扩容
            if (element != null) elements[size++] = element;
        }
    
        public int length() {
            return size;
        }
    
        public T get(int index) {
            if (index < 0 || index >= size) {
                //数组下标越界
                throw new IndexOutOfBoundsException("数组下标越界: " + index);
            }
            return (T) elements[index];
        }
    
        public int indexOf(T o) {
            //如果允许加入null元素这里需要加入对null的判断
            for (int i = 0; i < size; i++) {
                if (o.equals(elements[i])) return i;
            }
            return -1;
        }
    
        public void clear() {
            size = 0;
            Arrays.fill(elements, null);
        }
    
        public boolean isEmpty() {
            return size == 0;
        }
    
        //插入和删除方法实现可以看出这种方式插删效率低
        public void remove(int index) {
            if (index < 0 || index >= size) {
                throw new IndexOutOfBoundsException("数组下标越界: " + index);
            }
            int moved = size - index - 1;
            if (moved > 0) {
                System.arraycopy(elements, index + 1, elements, index, moved);
            }
        }
    
        public void insert(int index, T element) {
            //因为是插入,所以index可以与当前size相等
            if (index < 0 || index > size) {
                throw new IndexOutOfBoundsException("数组下标越界: " + index);
            }
            System.arraycopy(elements, index, elements, index + 1, size - index);
            size++;
            elements[index] = element;
        }
    }
    

    顺序表之后是链式表示,单独一段写

    · 链表(LinkedList)


    链表是一种物理存储单元上非连续、非顺序的线性存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。

    相比于线性表顺序结构,操作复杂。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,
    而线性表和顺序表相应的时间复杂度分别是O(logn)和O(1),所以适用于需要频繁增删的场景,查询性能不如其他数据结构。

    单向链表实现

    LinkedList使用firstNode和lastNode表示头尾节点,size表示当前链表内存放的元素数量,元素使用内部类Node。默认增加的元素位于链表尾部

    public class SimpleLinkedList {
        public static class Node {
            private String item;  //内容
            private Node nextNode;  //下一个节点
    
            public Node(String item) {
                this.item = item;
            }
            //省略getter、setter
        }
    
        private int size;
        private Node firstNode;
        private Node lastNode;
    
        public Node getFirstNode() {
            return firstNode;
        }
    
        public Node getLastNode() {
            return lastNode;
        }
    
        //将node添加到链表尾
        public void add(Node node) {
            Node l = lastNode;
            lastNode = node;
            if (l == null) firstNode = node;
            else l.setNextNode(node);
            size++;
        }
    
        public Node getNode(int index) {
            Node x = firstNode;
            for (int i = 0; i < index; i++) x = x.getNextNode();
            return x;
        }
    
        public void insert(Node node, int index) {
            if (index < 0 || index > size) throw new IndexOutOfBoundsException("链表索引越界");
            if (firstNode == null) add(node);
            else {
                Node pre = getNode(index - 1);
                node.setNextNode(pre.getNextNode());
                pre.setNextNode(node);
            }
        }
    
        public void remove() {
            remove(size - 1);
        }
    
        public void remove(int index) {
            if (index < 0 || index >= size) throw new IndexOutOfBoundsException("链表索引越界");
            if (index == 0) firstNode = firstNode.nextNode;
            else {
                Node pre = getNode(index - 1);
                pre.setNextNode(pre.getNextNode().getNextNode());
            }
            size--;
        }
    }
    

    双向链表实现

    在Node类中添加上一个节点的引用。而链表类则修改一下插入、获取、删除的代码。双向链表相对于单向链表的增删性能提升不少,因为不用每次都需要从头遍历整个链表了。
    节点类加上对前驱节点的引用,链表类中的方法稍作修改。

        。。。。。。
    
        public Node getNode(int index) {
            if (index < (size >> 1)) {
                Node x = firstNode;
                for (int i = 0; i < index; i++)
                    x = x.getNextNode();
                return x;
            } else {
                Node x = lastNode;
                for (int i = size - 1; i > index; i--)
                    x = x.getPreNode();
                return x;
            }
        }
    
        public void removeNode(int index) {
            if ( index > size) throw new IndexOutOfBoundsException("链表索引越界");
            Node node = getNode(index);
            Node pre = node.getPreNode();
            Node next = node.getNextNode();
            if (pre == null) firstNode = node.getNextNode();
            else pre.setNextNode(next);
            if (next == null) lastNode = pre;
            else next.setPreNode(pre);
            size--;
        }
    
        。。。。。。
    

    环形链表实现

    环形链表是一种首尾相接的链表。将单链表的尾节点next指针改为引用单链表header节点,这个单链表就成了环形链表。

    public class CircleLinkedList {
        public static class Node {
            private int no; //编号
            private Node nextNode;  //下一个节点
    
            public Node(int no) {
                this.no = no;
            }
    
        }
    
        private Node firstNode;
        private Node supNode; //辅助结点
        private int size;  //当前链表元素个数
        private int survival;   //指定约瑟夫问题需要存活的元素个数
    
        public CircleLinkedList(int survival) {
            this.survival = survival;
        }
    
        public void kill(int step) {  //step指定每次回收元素的间隔,此时first指向的元素节点就是要回收元素的前一个位置
            int count = 0; //count用来记录已经回收的元素个数,到达指定存活数目就让循环停止
            while (size - survival != count++) {
                //假如间隔为2,则只需要移动1次即可找到先序节点
                for (int i = 0; i < step - 2; i++) firstNode = firstNode.getNextNode();
                firstNode.setNextNode(firstNode.getNextNode().getNextNode());  //删除后一个元素
                firstNode = firstNode.getNextNode(); //重新开始计数
            }
        }
    
        public void addNode(Node node) {
            if (size == 0) {
                firstNode = node;
                firstNode.setNextNode(firstNode);
                supNode = firstNode;
            } else {
                supNode.setNextNode(node);
                node.setNextNode(firstNode);
                supNode = node;
            }
            size++;
        }
        
    }
    
    * 创建41个元素放进环形链表 *
    
    CircleLinkedList list = new CircleLinkedList(2);
    for(int i = 1 ;i<42;i++){
        list.addNode(new CircleLinkedList.Node(i));
    }
    list.kill(3);  //指定每计数3个元素就回收一个
    System.out.println("最后存活的元素编号为:");
    list.printAllNode();
    
    输出:
    最后存活的元素编号为:
    Node{no=16}
    Node{no=31}
    

    附:顺序表和链表的对比

    • 时间性能
      • 查找/修改效率:顺序表由数组实现,指定任意位置的序号都可以在 O(1) 时间内直接存取指定元素,而链表只能通过从表头或者表尾进行遍历,时间复杂度为 O(n)
      • 增/删效率:由于链表不是连续的内存空间,,增删操作只需改变前后引用即可,时间复杂度为O(1),而数组需要移动元素,时间复杂度 O(n)
    • 空间性能
      • 空间分配:顺序表的空间需要提前分配,扩充空间时需要将整个数组复制,易造成空间浪费;而链表无需预先分配空间
      • 存储密度:链表每个节点除了存储自身数据还需要存储节点引用保存表的逻辑关系,存储密度不足1

    · 栈(Stack)

    栈是计算机科学中的一种抽象数据类型,只允许在有序的线性数据集合的一端(称为堆栈顶端:top)进行加入数据(push)和移除数据(pop)的运算。因而按照后进先出(LIFO, Last In First Out)的原理运作。

    以下为顺序栈的简单实现,链栈参考LinkedList:

    public class MyStack<T> {
        private final int DEFAULT_INITIAL_CAPACITY = 8;
        private Object[] elements;
        private int capacity;
        private int size;
    
        public MyStack() {
            capacity = DEFAULT_INITIAL_CAPACITY;
            elements = new Object[capacity];
        }
    
        public MyStack(int initSize) {
            capacity = initSize;
            elements = new Object[capacity];
        }
    
        public void push(T e) {
            ensureCapacity(size + 1);
            elements[size++] = e;
        }
    
        public T pop() {
            T res = (T) elements[size - 1];
            elements[--size] = null;
            return res;
        }
    
        public T peek() {
            return (T) elements[size - 1];
        }
    
        public void ensureCapacity(int minSize) {
            if (minSize > capacity) {
                capacity = capacity + capacity >> 1;
                elements = Arrays.copyOf(elements, capacity);
            }
        }
    
        public boolean isEmpty(){
            return size == 0;
        }
    }
    

    · 队列(Queue)##


    队列是一种特殊的线性表, 它只允许在表的前端(ront) 进行删除操作,只允许在表的后端(rear)进行插入操作。进行插人操作的端称为队尾,进行删除操作的端称为队头。如果队列中不包含任何元素,该队列就被称为空队列。

    如何实现一个队列结构?简单的概括其要求就是:先进先出.

    核心方法参考实现:

    private void put(E x) {
            items[putIndex] = x;
            if (++putIndex == items.length)
                putIndex = 0;
            count++;
        }
    
    private E dequeue() {
        E x = (E) items[takeIndex];
        items[takeIndex] = null;
        if (++takeIndex == items.length)
            takeIndex = 0;
        count--;
        return x;
        }
    

    环形队列

    循环队列可以更简单防止伪溢出的发生,但队列大小是固定的。

    环形队列(一)

    此方法使用putIndex表示插入到队列的指针,taskIndex表示取出队列的指针

    class ArrayQueue{  //自定义环形队列类
        private int[] items;
        private int takeIndex;
        private int putIndex;
        private int count;
    
        public ArrayQueue(int length) {   //构造方法要求指定队列长度,并将指针初始化指向一个不存在的位置
            items = new int[length];
            takeIndex = -1;
            putIndex = -1;
            count = 0;
        }
    
        private boolean isEmpty(){   //判断是否为空
            if (this.count==0){
                return true;
            }
            return false;
        }
    
        private boolean isFull(){
            if(this.count==this.items.length){   //判断是否为满
                return true;
            }
            return false;
        }
    
        public void add(int num){   //增加方法,如果队列未满且putIndex和队列长度相等则让指针归零,如果队列已满则抛出非法状态异常
            if (!isFull()){
                if (++putIndex==items.length){
                    putIndex=0;
                }
                this.items[putIndex] = num;
                count++;
            } else {
                throw new IllegalStateException("容器已满");
            }
        }
    
        public int take(){  //拿取方法,如果队列未空且takeIndex和队列长度相等则让指针归零,如果队列已空则抛出非法状态异常
            if(!isEmpty()){
                if (++takeIndex==items.length){
                    takeIndex = 0;
                }
                int x =items[takeIndex];
                items[takeIndex] = 0;
    
                count--;
                return x;
            }else {
                throw new IllegalStateException("容器为空");
            }
        }
    
        public void printInfo(){   //打印出队列存放的内容
            for (int item : items) {
                System.out.print(item+" ");
            }
            System.out.println();
        }
    
        public int getTakeIndex() {
            return takeIndex;
        }
    
        public int getPutIndex() {
            return putIndex;
        }
    }
    

    环形队列(二)

    此方法参考网络资料利用公式计算实现环形队列。核心在于 (point+1)%ArrayLength,point+1取模数组长度,只要在不等于数组长度的情况下永远是point+1,而如果point+1等于数组长度则说明此时指针已经指向队列中最后一个元素,因为数组下标从0计算,而两个相等的数字进行模运算结果为0,归零指针达成目的。

    public class QueueDemo2 {
        private int maxSize;
        private int front;
        private int rear;
        private int[] arr;
    
        public QueueDemo2(int arrMaxSize) {
            this.maxSize = arrMaxSize;
            this.arr = new int[this.maxSize];
        }
    
        public boolean isFull() {
            return (this.rear + 1) % this.maxSize == this.front;
        }
    
        public boolean isEmpty() {
            return this.rear == this.front;
        }
    
        public void addQueue(int n) {
            if (this.isFull()) {
                System.out.println("队列满,不能加入数据~");
            } else {
                this.arr[this.rear] = n;
                this.rear = (this.rear + 1) % this.maxSize;
            }
        }
    
        public int getQueue() {
            if (this.isEmpty()) {
                throw new RuntimeException("队列空,不能取数据");
            } else {
                int value = this.arr[this.front];
                this.front = (this.front + 1) % this.maxSize;
                return value;
            }
        }
    
        public void showQueue() {
            if (this.isEmpty()) {
                System.out.println("队列空的,没有数据~~");
            } else {
                for(int i = this.front; i < this.front + this.size(); ++i) {
                    System.out.printf("arr[%d]=%d\n", i % this.maxSize, this.arr[i % this.maxSize]);
                }
    
            }
        }
    
        public int size() {
            return (this.rear + this.maxSize - this.front) % this.maxSize;
        }
    
        public int headQueue() {
            if (this.isEmpty()) {
                throw new RuntimeException("队列空的,没有数据~~");
            } else {
                return this.arr[this.front];
            }
        }
    }
    

    · 树(Tree)

    树状结构(Tree structure),又译树形结构,或称树状图(tree diagram)是一种将层次结构式的构造性质,以图象方式表现出来的方法。它的名称来自于以树的象征来表现出构造之间的关系,虽然在图像的呈现上,它是一个上下颠倒的树,其根部在上方,是数据的开头,而下方的数据称为叶子。

    它具有以下的特点:

    • 每个节点都只有有限个子节点或无子节点;
    • 没有父节点的节点称为根节点;
    • 每一个非根节点有且只有一个父节点;
    • 除了根节点外,每个子节点可以分为多个不相交的子树;
    • 树里面没有环路(cycle)

    树相关术语:

    术语 含义
    节点的度 一个节点含有的子树的个数称为该节点的度;
    树的度 一棵树中,最大的节点度称为树的度;
    叶子节点或终端节点: 度为零的节点;
    非终端节点或分支节点: 度不为零的节点;
    父亲节点或父节点: 若一个节点含有子节点,则这个节点称为其子节点的父节点;
    孩子节点或子节点: 一个节点含有的子树的根节点称为该节点的子节点;
    兄弟节点 具有相同父节点的节点互称为兄弟节点;
    节点的层次 从根开始定义起,根为第1层,根的子节点为第2层,以此类推;
    深度 对于任意节点n,n的深度为从根到n的唯一路径长,根的深度为0;
    高度 对于任意节点n,n的高度为从n到一片树叶的最长路径长,所有树叶的高度为0;
    堂兄弟节点 父节点在同一层的节点互为堂兄弟;
    祖先节点 从根到该节点所经分支上的所有节点;
    子孙 以某节点为根的子树中任一节点都称为该节点的子孙。
    森林 由m(m>=0)棵互不相交的树的集合称为森林;

    树的表示方法

    • 父节点表示法:在Node中保存自身父节点的引用,特点是每个节点可以快速的找到自己的父节点
    • 子节点表示法:在Node中保存自身子节点的引用,特点是每个节点可以快速的找到自己的子节点

    · 二叉树(Binary tree)

    二叉树是每个节点最多只有两个分支(即不存在分支度大于2的节点)的树结构。通常分支被称作“左子树”或“右子树”。二叉树的分支具有左右次序,不能随意颠倒。

    • 满二叉树:一棵深度为k的二叉树,如果它包含了2*-1个节点,就把这棵二叉树称为满二叉树,如上图所示。满二叉树的特点是每一层上的节点数都是最大节点数,即各层节点数分别为1、2、4、8、6.....2k-1.
    • 完全二叉树:深度为 k 有 n 个节点的二叉树,当且仅当其中的每一节点,都可以和同样深度 k 的满二叉树,序号为 1 到 n 的节点一对一对应时,称为完全二叉树,也就是叶子节点全在底部的左边且非叶子节点是满二叉树时即为完全二叉树。

    深度优先遍历(DFS)

    深度优先遍历将优先访问到树中最深层次节点的信息,以L、D、R分别表示遍历左子树、访问根结点和遍历右子树,则先(根)序遍历二叉树的顺序是DLR,中(根)序遍历二叉树的顺序是LDR,后(根)序遍历二叉树的顺序是LRD。

    如果T2是由有序树T转换而来的二叉树,那么T中结点的前序就是T2中结点的前序,T中结点的后序就是T2中结点的中序。任何一棵二叉树的叶结点在先序、中序和后序遍历中的相对次序不发改变。设n,m为一棵二叉树上的两个结点,在中序遍历时,n在m前的条件是n在m的左方。前序序列和中序序列相同的二叉树为空树或任一结点均无左孩子的非空二叉树;中序序列和后序序列相同的二叉树为空树或任一结点均无右孩子的非空二叉树;前序序列和后序序列相同的二叉树为空树或仅有一个结点的二叉树。

    假设有类似如下的节点结构:

    class Node{
        Object element;
        Node left;
        Node right;
    }
    

    则可写出这样的遍历过程:

    //先序遍历
    public void visit(Node node){
        if (node == null) return;
        System.out.print(node.val + " ");
        visit(node.left);
        visit(node.right);
    }
    
    //中序遍历,这在遍历二叉搜索树时很常用,因为它能用递增的顺序来遍历所有的值。
    public void visit(Node node){
        if (node == null) return;
        visit(node.left);
        System.out.print(node.val + " ");
        visit(node.right);
    }
    
    //后序遍历
    public void visit(Node node){
        if (node == null) return;
        visit(node.left);
        visit(node.right);
        System.out.print(node.val + " ");
    }
    

    举个具体的例子,如图,对于这样的一个二叉树,那么有遍历结果:

    先序遍历:D B A C F E G
    中序遍历:A B C D E F G
    后序遍历:A C B E G F D
    

    广度优先遍历(BFS)

    广度优先遍历又称为按层遍历,整个遍历算法先遍历二叉树的第一一层(根节点),再遍历根节点的两个子节点(第二层) ...依此类推,逐层遍历二叉树的所有节点。为了实现广度优先遍历,可以借助于具有“FIFO" 特征的队列来实现,步骤如下。
    (1) 建一个队列(先进先出),把树的根节点压入队列。
    (2)从队列中弹出一个节点(第一次弹出的就是根节点),然后把该节点的左、右节点压入队列,如果没有子节点,则说明已经到达叶子节点了。
    (3)用循环重复执行第(2) 步,直到队列为空。当队列为空时,说明所有的叶子节点(深度最深的层)都已经经过了队列,也就完成了遍历。
    如:

    public void breadthFirst() {
        ArrayDeque<Node> nodes = new ArrayDeque<>();
        nodes.addLast(root);
        while (!nodes.isEmpty()) {
            Node current = nodes.pop();
            System.out.print(current.val + " ");
            if (current.left != null) nodes.addLast(current.left);
            if (current.right != null) nodes.addLast(current.right);
        }
    }
    
    对任意一棵二叉树实验:
    
    先序遍历:10 8 7 9 15 14 20 
    中序遍历:7 8 9 10 14 15 20 
    广度优先遍历:10 8 15 7 9 14 20 
    

    树、二叉树、森林的转换

    一般有序树可映射为二叉树,但反之未必成立。
    n叉树转换为二叉树的方法:二叉树中结点x的左子结点为n叉树中结点x的左子结点;二叉树中结点x的右子结点为n叉树中结点x的第一个右边的同级结点y。
    二叉树当且仅当根节点没有右子结点时可转换为n叉树。
    例如,在左边的树中,A有6个子结点{B,C,D,E,F,G}。它能被转换成右边的二叉树。

    将一棵树转换为二叉树的方法:

    1. 在兄弟之间加一连线;
    2. 对每个结点,除了其左孩子外,去除其与其余孩子之间的联系;
    3. 以树的根结点为轴心,将整树顺时针转45度。

    同理也可以将森林转为二叉树:

    反推则可以将二叉树转换为森林或者树

    二叉树的顺序表存储

    顺序存储指的是充分利用满二叉树的特性一每层的节点数分别为1,2,4、...一棵深度为k的二叉树最多只能包含2k- 1个节点,因此只要定义一个长度为2k - 1的数组即可存储这棵二叉树。

    顺序存储二叉树有如下特点:

    • 一般只考虑完全二叉树
    • 第N个元素的左子节点为2*n+1
    • 第N个元素的右子节点为2*n+2
    • 第N个元素的父节点为(n-1)/2

    那么对于一个顺序存储二叉树的先序遍历即可为:

    public static void preIterater(int index) {
        if (isEmpty() || index >= arr.length) return;
        System.out.print(arr[index] + " ");
        preIterater(index * 2 + 1);
        preIterater(index * 2 + 2);
    }
    
    

    线索二叉树

    遍历二叉树是以一定规则将二叉树中的节点排列成一个线性序列,得到二叉树中节点的不同序列,这实质上是对非线性结构的线性化操作。但是当以链表形式存储树结构时,只能找到其左右子节点的信息而无法获得其前驱后继信息。

    在此种情况下,二叉树添加了直接指向节点的前驱和后继的指针的二叉树称为线索二叉树。所有原本为空的右(孩子)指针改为指向该节点在中序序列中的后继,所有原本为空的左(孩子)指针改为指向该节点的中序序列的前驱。

    线索二叉树能线性地遍历二叉树,从而比递归的 中序遍历更快。使用线索二叉树也能够方便的找到一个节点的父节点,这比显式地使用父亲节点指针或者栈效率更高。这在栈空间有限,或者无法使用存储父节点的栈时很有作用(对于通过深度优先搜索来查找父节点而言)。 考虑这样的例子:一个节点k有一个右孩子r,那么r的左指针可能是指向一个孩子节点,或是一个指回k的线索。如果r有左孩子,这个左孩子同样也应该有一个左孩子或是指回k的线索。对于所有的左孩子同理。因此沿着这些从r发出的左指针,我们最终会找到一个指回k的线索。这种特性是对称的:当q是p的左孩子时,我们可以沿着q的右孩子找到一个指回p的线索。

    图片取自百度百科

    需要特别注意的是,线索化二叉树是利用空链域存放在某种遍历次序下该结点的前驱结点和后继结点的指针。根据线索性质的不同,线索二叉树可分为 先序线索二叉树 中序线索二叉树后序线索二叉树 三种。

    如有这样一棵随机的二叉树,则中序线索化代码实现:

    //节点定义
    public class ThreadNode {
        public int val;
        public ThreadNode left;
        public ThreadNode right;
        public int leftType;
        public int rightType;
    }
    
    //初始化线索二叉树方法
    public void initThreadTree(ThreadNode node) {
        if (node == null) return;
        initThreadTree(node.left);
        //一直找到最左叶子节点(此例中为8),开始线索化,如节点8在中序遍历中没有前驱节点,所以一开始preNode为null
        if (node.left == null) {
            node.left = preNode;
            node.leftType = 1;
        }
        //preNode是一个辅助局部变量,用于线索化的时候保存上次使用的节点,这样就使得右子节点被推迟到递归返回时才处理
        if (preNode != null && preNode.right == null) {
            preNode.right = node;
            preNode.rightType = 1;
        }
        preNode = node;
        //当最左叶子节点8递归调用返回到递归3时,将8的后继节点连接到3,之后的节点同理
        initThreadTree(node.right);
    }
    
    //遍历线索化二叉树。以中序遍历为例,由于线索化二叉树的特殊性,遍历不再是递归的方式,而是优先找左节点,右节点通过叶子节点的线索到达
    public void center(ThreadNode root) {
        ThreadNode node = root;
        while (node != null) {
            while (node.leftType == 0) {
                System.out.println(node);
                node = node.left;
            }
            System.out.println(node);
            while (node.rightType == 1) {
                node = node.right;
                System.out.println(node);
            }
            node = node.right;
        }
    }
    

    遍历二叉树:

    先序遍历:
    1 3 8 10 6 14 20 
    中序遍历:
    8 3 10 1 14 6 20 
    遍历线索化二叉树:
    ThreadNode{val=1, leftType=0, rightType=0}
    ThreadNode{val=3, leftType=0, rightType=0}
    ThreadNode{val=8, leftType=1, rightType=1}
    ThreadNode{val=3, leftType=0, rightType=0}
    ThreadNode{val=10, leftType=1, rightType=1}
    ThreadNode{val=1, leftType=0, rightType=0}
    ThreadNode{val=6, leftType=0, rightType=0}
    ThreadNode{val=14, leftType=1, rightType=1}
    ThreadNode{val=6, leftType=0, rightType=0}
    ThreadNode{val=20, leftType=1, rightType=0}
    

    二叉查找树(BST)

    二叉查找树(BinarySearchTree)是二叉树的一个重要且经典应用。也称为二叉搜索树、有序二叉树(ordered binary tree)或排序二叉树(sorted binary tree),是指一棵空树或者具有下列性质的二叉树:

    • 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
    • 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
    • 任意节点的左、右子树也分别为二叉查找树;
    • 没有键值相等的节点。

    二叉查找树相比于其他数据结构的优势在于查找、插入的时间复杂度较低。通常采取二叉链表作为二叉查找树的存储结构。中序遍历二叉查找树可得到一个关键字的有序序列,一个无序序列可以通过构造一棵二叉查找树变成一个有序序列,构造树的过程即为对无序序列进行查找的过程。每次插入的新的结点都是二叉查找树上新的叶子结点,在进行插入操作时,不必移动其它结点,只需改动某个结点的指针,由空变为非空即可。

    在二叉搜索树b中查找x的过程为:

    • 若b是空树,则搜索失败,否则:
    • 若x等于b的根节点的数据域之值,则查找成功;否则:
    • 若x小于b的根节点的数据域之值,则搜索左子树;否则:
    • 查找右子树。

    二叉搜索树的实现只有删除节点的较为复杂,其删除逻辑在以下代码的注释中标注的很详细:

    public class BinarySearchTree {
        public class Node {
            int val;
            Node left;
            Node right;
    
            Node(int val) {
                this.val = val;
            }
    
            //省略getter、setter
        }
    
        private Node root;
    
        public void add(int num) {
            root = add(root, new Node(num));
        }
    
        /**
         *
         * @param node 要插入到的树,可以为子树
         * @param target 目标节点
         * @return 树(或子树)的根节点
         */
        private Node add(Node node, Node target) {
            //如果根节点为空则直接将新节点作为Root节点
            if (node == null) return target;
            //如果新节点的值大于等于root节点则递归调用放在右子节点,反之放在左子节点
            if (node.val <= target.val) node.right = add(node.right, target);
            else node.left = add(node.left, target);
            return node;
        }
    
        public void remove(int val) {
            Node currentNode = get(val); //查找指定值的节点,如果没有这个节点则直接返回
            if (currentNode == null) return;
            Node parent = searchParent(root, val);  //找到该节点的父节点
            if (currentNode.left == null && currentNode.right == null) { //情况1:该节点的左右节点都为空
                //如果它的父节点也为空,则要删的是root节点且这是一个只有root节点存在的二叉树,那么直接置空
                if (parent == null) root = null;
                //如果父节点不为空,那么该节点是一个叶子节点,直接删除父节点的引用即可
                else if (parent.left != null && parent.left.val == val) parent.left = null;
                else if (parent.right != null && parent.right.val == val) parent.right = null;
            } else if (currentNode.right != null && currentNode.left != null) {  //情况2:该节点的左右节点都不为空
                Node temp = currentNode.right;
                //结合二叉排序树的特点,左子树的值全小于右子树,
                //那么可以将左子树的右叶子或者右子树的左叶子和当前根节点置换
                while (temp.left != null) temp = temp.left;
                int val1 = temp.val;
                remove(val1);
                currentNode.val = val1;
            } else  { //情况3:该节点有一个子节点,则需要分别判断
                if (currentNode.left != null) {
                    if (parent == null) root = currentNode.left;
                    else if (parent.left.val == currentNode.val) parent.left = currentNode.left;
                    else parent.right = currentNode.left;
                } else {
                    if (parent == null)  root = currentNode.right;
                    else if (parent.left.val == currentNode.val) parent.left = currentNode.right;
                    else parent.right = currentNode.right;
                }
            }
    
        }
    
        /**
         * @param current 以指定节点为树根
         * @param val 要查找的节点值
         * @return 值为val的节点的父节点
         */
        private Node searchParent(Node current, int val) {
            if ((current.left != null && current.left.val == val) || (current.right != null && current.right.val == val))
                return current;
            else if (current.val > val && current.left != null) return searchParent(current.left, val);
            else if (current.val <= val && current.right != null) return searchParent(current.right, val);
            else return null;
        }
    
        public Node get(int val){
            return get(root,val);
        }
    
        /**
         *  查找一个值为val的节点是否存在于指定树/子树中
         * @param current 指定的节点,可以从子树指定
         * @param val 要查找的值
         * @return 如果找到了则返回该节点
         */
        private Node get(Node current, int val) {
            if (current == null) return null;
            if (current.val == val) return current;
            Node result = null;
            result = get(current.left, val);
            if (result != null) return result;
            result = get(current.right, val);
            if (result != null) return result;
            return null;
        }
    
        //省略不重要方法
    }
    

    AVL树

    AVL树是最早被发明的自平衡二叉查找树,得名于它的发明者G. M. Adelson-Velsky和Evgenii Landis。在AVL树中,任一节点对应的两棵子树的最大高度差为1,因此它也被称为高度平衡树。查找、插入和删除在平均和最坏情况下的时间复杂度都是 O(log n)。增加和删除元素的操作则可能需要借由一次或多次树旋转,以实现树的重新平衡。

    节点的平衡因子是它的左子树的高度减去它的右子树的高度(有时相反)。带有平衡因子1、0或 -1的节点被认为是平衡的。带有平衡因子 -2或2的节点被认为是不平衡的,并需要重新平衡这个树。平衡因子可以直接存储在每个节点中,或从可能存储在节点中的子树高度计算出来。

    在正式实现AVL树的之前先了解一个关于旋转的概念,那么,树中的节点如何实现旋转?描述参考维基百科的描述:树旋转

    动态图取自维基百科

    还以简单的字母树举例演示单旋转,右旋的转轴的为B,根节点为D,则有:

    public void rightRotation(){
        Node pivot = root.left;
        root.left = pivot.right;
        pivot.right = root;
        root = pivot;
    }
    
    右旋结果:
    旋转前先序遍历:D B A C F
    旋转前中序遍历:A B C D F
    旋转后先序遍历:B A D C F
    旋转后中序遍历:A B D C F
    

    树的左右旋转操作还可以组合执行,称作双旋转(double rotation)。先将 X 的右子树右旋、再将 X 本身左旋,就是 X 的双左旋转(double left rotation)。同理,先将 X 的左子树左旋、再将 X 本身右旋,就是 X 的双右旋转(double right rotation)。

    AVL树中唯一需要注意的就是双旋的操作,画图解释下:

    那么开始实现AVL树,由于AVL树建立在二叉查找树之上,所以只贴出AVL树类中新增的代码:

    
        //这里与BST唯一的区别就是返回值变成了子树高度判断的返回值
        private Node add(Node node, Node target) {
            if (node == null) return target;
            if (node.val <= target.val) node.right = add(node.right, target);
            else node.left = add(node.left, target);
            return adjust(node);
        }
    
        //判断一个树的子树高度是否符合AVL的规定
        private Node adjust(Node node){
            //如果左子树高度 - 右子树高度 > 1
            if (getNodeHeight(node.left) - getNodeHeight(node.right) > 1) {
                //如果左子树高度 - 右子树高度 > 1 的情况下左子树的右子树高度大于左子树的左子树高度则先对左子树进行左旋
                //然后再进行右旋。这种特殊情况必须使用才能正确的保持树的平衡且不丢失节点
                if(getNodeHeight(node.left.right) > getNodeHeight(node.left.left)){ node.left = leftRotato(node.left); }
                return rightRotato(node);
            }else if (getNodeHeight(node.right) - getNodeHeight(node.left) > 1) {
                //单左旋或双旋的逻辑与上面一致
                if(getNodeHeight(node.right.left) > getNodeHeight(node.right.right)){ node.right = rightRotato(node.right); }
                return leftRotato(node);
            }
            return node;
        }
    
        public int getNodeHeight(Node node) {
            // 如果node为null则高度为0
            if (node ==null) return 0;
            // 返回node的左子树和右子树高度中更大的一个,加上自身节点的高度
            return Math.max(
                    node.left == null ? 0 : getNodeHeight(node.left)
                    , node.right == null ? 0 : getNodeHeight(node.right)
            ) + 1;
        }
    
        private Node leftRotato(Node currentNode) {
            Node pivot = currentNode.right;
            currentNode.right = pivot.left;
            pivot.left = currentNode;
            return pivot;
        }
    
        private Node rightRotato(Node currentNode) {
            Node pivot = currentNode.left;
            currentNode.left = pivot.right;
            pivot.right = currentNode;
            return pivot;
        }
    
    ——————————————测试用例:向树中添加单调递增或递减的数字——————————————
    for(int i=1;i<10;i++){
        tree.add(i);
    }
    
    遍历结果:
    先序遍历: 4 2 1 3 6 5 8 7 9 
    中序遍历: 1 2 3 4 5 6 7 8 9 
    

    可以看到AVL代码成功的将节点平衡,如果这是普通的二叉查找树则会形参单调递增/递减的链表式树结构,大大降低了二叉树的效率。


    哈夫曼树 Huffman Tree

    哈夫曼树又称霍夫曼树、最优二叉树,是一种带权路径长度最短的二叉树。所谓树的带权路径长度,就是树中所有的叶结点的权值乘上其到根结点的路径长度(若根结点为0层,叶结点到根结点的路径长度为叶结点的层数)。树的路径长度是从树根到每一结点的路径长度之和,记为WPL=(W1L1+W2L2+W3L3+...+WnLn),N个权值Wi(i=1,2,...n)构成一棵有N个叶结点的二叉树,相应的叶结点的路径长度为Li(i=1,2,...n)。可以证明哈夫曼树的WPL是最小的。

    上图取自维基百科,示例为“this is an example of a huffman tree” 构建的Huffman Tree

    构建Huffman Tree的前置术语:

    1. 路径和路径长度
      在一棵树中,从一个结点往下可以达到的孩子或孙子结点之间的通路,称为路径。通路中分支的数目称为路径长度。若规定根结点的层数为1,则从根结点到第L层结点的路径长度为L-1。
    2. 结点的权及带权路径长度
      若将树中结点赋给一个有着某种含义的数值,则这个数值称为该结点的权。结点的带权路径长度为:从根结点到该结点之间的路径长度与该结点的权的乘积。
    3. 树的带权路径长度
      树的带权路径长度规定为所有叶子结点的带权路径长度之和,记为WPL。

    构建思想:
    假设有n个权值,则构造出的哈夫曼树有n个叶子结点。 n个权值分别设为 w1、w2、…、wn:
    (1) 将w1、w2、…,wn看成是有n 棵树的森林(每棵树仅有一个结点);
    (2) 在森林中选出两个根结点的权值最小的树合并,作为一棵新树的左、右子树,且新树的根结点权值为其左、右子树根结点权值之和;
    (3) 从森林中删除选取的两棵树,并将新树加入森林;
    (4) 重复(2)、(3)步,直到森林中只剩一棵树为止,该树即为所求得的哈夫曼树。

    上代码:

    public class HuffmanNode implements Comparable<HuffmanNode>{
        public int val; //节点的权值
        public char ch; //节点存储的字符
        public HuffmanNode left; 
        public HuffmanNode right;
    
        //哈夫曼树只有叶子节点带有存储
        public HuffmanNode(int val, char ch) {
            this.val = val;
            this.ch = ch;
        }
    
        //父节点只提供权值
        public HuffmanNode(int val) {
            this.val = val;
        }
    
        @Override
        public int compareTo(HuffmanNode o) {
            return this.val - o.val;
        }
    }
    
    public class HuffmanTree {
    
        private HuffmanNode root;
    
        public void initTree(HuffmanNode [] arr) {
            //将数组初始化为结点放入容器
            LinkedList<HuffmanNode> list = new LinkedList<>();
            for (HuffmanNode n : arr) { list.add(n); }
            HuffmanNode left;
            HuffmanNode right;
            HuffmanNode parent = null;
            //每次将容器排序,并取出前两个个元素相加得到父节点
            while (list.size() > 1) {
                Collections.sort(list);
                left = list.pop();
                right = list.pop();
                parent = new HuffmanNode(left.val + right.val);
                parent.left = left;
                parent.right = right;
                list.add(parent);
            }
            root = parent;
        }
    
        //省略不重要方法
    }
    
    ——————————————测试用例将“this is an example of a huffman tree”这句话放入初始化方法——————————————
    构造后遍历结果:
    先序遍历:{val=36, ch=“   ”} | {val=16, ch=“   ”} | {val=8, ch=“   ”} | {val=4, ch=“ a ”} | {val=4, ch=“ e ”} | 
    {val=8, ch=“   ”} | {val=4, ch=“   ”} | {val=2, ch=“ h ”} | {val=2, ch=“ i ”} | {val=4, ch=“   ”} | 
    {val=2, ch=“ m ”} | {val=2, ch=“ n ”} | {val=20, ch=“   ”} | {val=8, ch=“   ”} | {val=4, ch=“   ”} | 
    {val=2, ch=“ s ”} | {val=2, ch=“ t ”} | {val=4, ch=“   ”} | {val=2, ch=“   ”} | {val=1, ch=“ l ”} | 
    {val=1, ch=“ o ”} | {val=2, ch=“   ”} | {val=1, ch=“ p ”} | {val=1, ch=“ r ”} | {val=12, ch=“   ”} | 
    {val=5, ch=“   ”} | {val=2, ch=“   ”} | {val=1, ch=“ u ”} | {val=1, ch=“ x ”} | {val=3, ch=“ f ”} | 
    {val=7, ch=“   ”} | 
    
    中序遍历:{val=4, ch=“ a ”} | {val=8, ch=“   ”} | {val=4, ch=“ e ”} | {val=16, ch=“   ”} | 
    {val=2, ch=“ h ”} | {val=4, ch=“   ”} | {val=2, ch=“ i ”} | {val=8, ch=“   ”} | {val=2, ch=“ m ”} | 
    {val=4, ch=“   ”} | {val=2, ch=“ n ”} | {val=36, ch=“   ”} | {val=2, ch=“ s ”} | {val=4, ch=“   ”} | 
    {val=2, ch=“ t ”} | {val=8, ch=“   ”} | {val=1, ch=“ l ”} | {val=2, ch=“   ”} | {val=1, ch=“ o ”} | 
    {val=4, ch=“   ”} | {val=1, ch=“ p ”} | {val=2, ch=“   ”} | {val=1, ch=“ r ”} | {val=20, ch=“   ”} | 
    {val=1, ch=“ u ”} | {val=2, ch=“   ”} | {val=1, ch=“ x ”} | {val=5, ch=“   ”} | {val=3, ch=“ f ”} | 
    {val=12, ch=“   ”} | {val=7, ch=“   ”} | 
    
    可以看到成功构造了Huffman tree,但是从遍历结果上看和上图画出的树有稍许不一样,
    这是因为相同权值的节点有不同的处理方式,构造出的树当然完全不同,不过依旧是符合规范的Huffman Tree
    
    

    根据遍历结果还原上面构造的二叉树:


    红黑树(Red–black tree)

    红黑树是一种自平衡二叉查找树,是在计算机科学中用到的一种数据结构,典型的用途是实现关联数组。它在1972年由鲁道夫·贝尔发明,被称为"对称二叉B树",它现代的名字源于Leo J. Guibas和Robert Sedgewick于1978年写的一篇论文。红黑树的结构复杂,但它的操作有着良好的最坏情况运行时间,并且在实践中高效:它可以在 O(log n) 时间内完成查找,插入和删除,这里的 n是树中元素的数目。

    红黑树是每个节点都带有颜色属性的二叉查找树,颜色为红色或黑色。在二叉查找树强制一般要求以外,对于任何有效的红黑树我们增加了如下的额外要求:

    1. 节点是红色或黑色。
    2. 根是黑色。
    3. 所有叶子都是黑色。
    4. 每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
    5. 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点。

    这些约束确保了红黑树的关键特性:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。结果是这个树大致上是平衡的。因为操作比如插入、删除和查找某个值的最坏情况时间都要求与树的高度成比例,这个在高度上的理论上限允许红黑树在最坏情况下都是高效的,而不同于普通的二叉查找树。

    在Java中红黑树的叶子节点为红色并不违反第三条要求,这是因为Java中以Null表示叶子节点,而遍历的时候不经过Null元素,所以可能看到叶子节点为红色。另外由于红黑树的特性,对红黑树本身进行增删操作可能破坏树本身的特征,所以插入和删除方法需要进行一定的维护。

    附上:维基百科——红黑树
    理论知识和代码都太长了,我试着实现了一个红黑树,但是相信在IDE里看TreeMap(TreeMap就是JDK提供的红黑树实现)的源码会比这个页面舒服,所以就不贴我自己的实现代码了。


    · 二叉堆(Binary Heap)

    在计算机科学中,堆是一种基于树的专用数据结构,在最大堆中,对于任何给定的节点 C,如果P是C的父节点,那么P 的键(值)大于或等于C的键。在最小堆中,P的键小于或等于C的键。“顶部”的节点堆(没有父项)的根称为根节点。

    堆是优先队列的一种最大有效的实现,实际上,优先级队列通常被称为“堆”,而不管它们如何实现。在堆中,最高(或最低)优先级元素始终存储在根目录中。另外,从结构上来看,堆是一颗完全二叉树,所以一般使用数组的方式存储而非二叉链表。

    以小顶堆为例,根据概念写出下沉和上浮的方法构造小顶堆

    public class Heap {
    
        public int[] arr;
    
        public Heap(int[] arr) {
            this.arr = arr;
            //从最后一个非叶子节点开始下沉
            for (int i = (arr.length - 2) / 2; i >= 0; i--) {
                this.sink(i);
            }
        }
    
        public static void main(String[] args) {
            int[] num = {1, 6, 75, 8, 4, 56, 42, 53, 86, 2, 35, 47, 27};
            System.out.println("输入元素:" + Arrays.toString(num));
            Heap heap = new Heap(num);
            System.out.println("构造堆  :" + Arrays.toString(heap.arr));
        }
    
        /**
         * 上浮指定元素
         *
         * @param childIndex 上浮的元素下标,节点需要有父节点才可以上浮
         */
        private void swim(int childIndex) {
            int parentIndex = childIndex / 2;
            int temp = arr[childIndex];
            while (childIndex > 0 && temp < arr[parentIndex]) {
                arr[childIndex] = arr[parentIndex];
                childIndex = parentIndex;
                parentIndex /= 2;
            }
            arr[childIndex] = temp;
        }
    
        /**
         * 下沉指定元素
         *
         * @param parentIndex 下沉的元素下标,节点需要有子节点才可以下沉
         */
        private void sink(int parentIndex) {
            int temp = arr[parentIndex];
            int childIndex = parentIndex * 2 + 1;
            while (childIndex < arr.length) {
                //首先要确定右子节点存在,防止数组下标越界,然后对比左右节点的大小
                childIndex = (childIndex + 1 < arr.length && arr[childIndex] > arr[childIndex + 1]) ? childIndex + 1 : childIndex;
                if (temp < arr[childIndex]) {
                    //如果父节点小于子节点则退出
                    break;
                }
                //这里的循环赋值类似于插入排序,无需交换元素
                arr[parentIndex] = arr[childIndex];
                parentIndex = childIndex;
                childIndex = parentIndex * 2 + 1;
            }
            arr[parentIndex] = temp;
        }
    }
    
    //运行结果
    输入元素:[1, 6, 75, 8, 4, 56, 42, 53, 86, 2, 35, 47, 27]
    构造堆  :[1, 2, 27, 8, 4, 47, 42, 53, 86, 6, 35, 75, 56]
    


    · B 树

    B树(B-tree)是一种自平衡的树,能够保持数据有序。它的出现是为了解决当数据量大无法存储在内存,需要借助外存时反复交换导致的性能低下问题。国内很多资料将B树翻译为B-树(比如我现在手上的大学教材课本),但在查阅了众多资料后还是确定B树和B-树只是因为将英文B-tree中的-一起带上了。

    这种数据结构能够让查找数据、顺序访问、插入数据及删除的动作,都在对数时间内完成,概括来说它是一个一般化的二叉查找树(binary search tree)一个节点可以拥有2个以上的子节点。与自平衡二叉查找树不同,B树适用于读写相对大的数据块的存储系统,例如磁盘。B树减少定位记录时所经历的中间过程,从而加快存取速度。B树这种数据结构可以用来描述外部存储。这种数据结构常被应用在数据库和文件系统的实现上。

    一个 m 阶的B树是一个有以下属性的树:

    • 每一个节点最多有 m 个子节点
    • 每一个非叶子节点(除根节点)最少有 ⌈m/2⌉ 个子节点
    • 如果根节点不是叶子节点,那么它至少有两个子节点
    • 有 k 个子节点的非叶子节点拥有 k − 1 个键
    • 所有的叶子节点都在同一层

    关于B树和B+树更详细的描述可参考:浅谈算法和数据结构: 十 平衡查找树之B树

    虽然文章中B+树的描述似乎有些问题,但很适合初次了解B树

    · 图 (Graph)

    图(Graph) G由两个集合V和E组成,记为G=(V,E), 其中v是顶点的有穷非空集合,E是V中顶点偶对的有穷集合,这些顶点偶对称为边。V(G)和E(G)通常分别表示图G的顶点集合和边集合,E(G)可以为空集。若E(G)为空,则图G只有顶点而没有边。对于图G,若边集E(G)为有向边的集合,则称该图为有向图;若边集E(G)为无向边的集合,则称该图为无向图。

    在有向图中,顶点对<x,y>是有序的,它称为从顶点x到顶点y的一条有向边。因此,<x,y>与<y, x>是不同的两条边。顶点对用-对尖括号括起来,x是有向边的始点,y是有向边的终点。

    <x,y>也称作一条弧,则x为弧尾,y为弧头。在无向图中,顶点对(x, y)是无序的,它称为与顶点x和顶点y相关联的一条边。这条边没有特定的方向,(x, y)与(y, x)是同-条边。为了有别于有向图,无向图的顶点对用一对圆括号括起来。

    基本术语和概念集合

    • 邻接点:对于无向图G,如果图的边(v, v'>)∈E,则称顶点V和V'互为邻接点,即v和v'相邻接。边(r, v')依附于顶点v和v,或者说边(v,v')与顶点v和v'相关联。
    • 阶(Order):图G中点集V的大小称作图G的阶。
    • 子图(Sub-Graph):当图G'=(V',E')其中V‘包含于V,E’包含于E,则G'称作图G=(V,E)的子图,子图是由一幅图的所有边的一个子集(以及它们所依附的所有顶点)组成的图。每个图都是本身的子图。
    • 生成子图(Spanning Sub-Graph):指满足条件V(G') = V(G)的G的子图G'。
    • 导出子图(Induced Subgraph):以图G的顶点集V的非空子集V1为顶点集,以两端点均在V1中的全体边为边集的G的子图,称为V1导出的导出子图;以图G的边集E的非空子集E1为边集,以E1中边关联的顶点的全体为顶点集的G的子图,称为E1导出的导出子图。
    • 稀疏图稠密图:有很少条边或弧(如e<nlog2n) 的图称为稀疏图,反之称为稠密图。
    • :在实际应用中,每条边可以标上具有某种含义的数值,该数值称为该边上的权。这些权可以表示从一个顶点到另一个顶点的距离或耗费。这种带权的图通常称为网。
    • 度(Degree):一个顶点的度是指与该顶点相关联的边的条数,顶点v的度记作d(v)。
    • 入度(In-degree)出度(Out-degree):对于有向图来说,一个顶点的度可细分为入度和出度。一个顶点的入度是指与其关联的各边之中,以其为终点的边数;出度则是相对的概念,指以该顶点为起点的边数。
    • 无向完全图有向完全图:对于无向图,若具有n(n- 1)2条边,则称为无向完全图。对于有向图,若具有n(n- 1)条弧, 则称为有向完全图。
    • 自环(Loop):若一条边的两个顶点为同一顶点,则此边称作自环。
    • 路径(Path):从u到v的一条路径是指一个序列v0,e1,v1,e2,v2,...ek,vk,其中ei的顶点为vi及vi - 1,k称作路径的长度。如果它的起止顶点相同,该路径是“闭”的,反之,则称为“开”的。一条路径称为一简单路径(simple path),如果路径中除起始与终止顶点可以重合外,所有顶点两两不等。
    • 行迹(Trace):如果路径P(u,v)中的边各不相同,则该路径称为u到v的一条行迹。
    • 轨道(Track):如果路径P(u,v)中的顶点各不相同,则该路径称为u到v的一条轨道。
    • 闭的行迹称作回路(Circuit),闭的轨称作圈(Cycle)
    • (现存文献中的命名法并无统一标准。比如在另一种定义中,walk对应上述的path,path对应上述的track,trail对应上述的trace。)
    • 桥(Bridge):若去掉一条边,便会使得整个图不连通,该边称为桥。
    • 如果从任意一个顶点都存在一条路径到达另一个任意顶点,我们称这幅图是连通图。一幅非连通的图由若干连通的部分组成,它们都是其极大连通子图。
    • 无环图是一种不包含环的图。
    • 图的密度是指已经连接的顶点对占所有可能被连接的顶点对的比例。在稀疏图中,被连接的顶点对很少;而在稠密图中,只有少部分顶点对之间没有边连接。一般来说,如果一幅图中不同的边的数量只占顶点总数V的一小部分,那么我们就认为这幅图是稀疏的,否则则是稠密的。

    在图论中,二叉树是一个连通的无环图,并且每一个顶点的度不大于3。有根二叉树还要满足根节点的度不大于2。有了根节点之后,每个顶点定义了唯一的父节点,和最多2个子节点。然而,没有足够的信息来区分左节点和右节点。如果不考虑连通性,允许图中有多个连通分量,这样的结构叫做森林。 连通图的生成树是它的一幅子图,它含有图中的所有顶点且是一棵树。图的生成树森林是它的所有连通子图的生成树的集合,例如,当且仅当一幅含有V个结点的图G满足下列5个条件之一时,它就是棵树:

    • G有V-I条边且不含有环;
    • G有V-1条边且是连通的;
    • G是连通的,但删除任意一条边都会使它不再连通;
    • G是无环图,但添加任意一条边都会产生一条环;
    • G中的任意一对顶点之间仅存在一条简单路径。

    图的邻接矩阵表示

    使用多维数组来表示一个图

    图的邻接链表表示

    使用链表来表示一个图

    深度优先遍历 DFS

    深度优先搜索法是树的先根遍历的推广,它的基本思想是:从图G的某个顶点v0出发,访问v0,然后选择一个与v0相邻且没被访问过的顶点vi访问,再从vi出发选择一个与vi相邻且未被访问的顶点vj进行访问,依次继续。如果当前被访问过的顶点的所有邻接顶点都已被访问,则退回到已被访问的顶点序列中最后一个拥有未被访问的相邻顶点的顶点w,从w出发按同样的方法向前遍历,直到图中所有顶点都被访问。

    广度优先遍历 BFS

    图的广度优先搜索是树的按层次遍历的推广,它的基本思想是:首先访问初始点vi,并将其标记为已访问过,接着访问vi的所有未被访问过的邻接点vi1,vi2,…, vi t,并均标记已访问过,然后再按照vi1,vi2,…, vi t的次序,访问每一个顶点的所有未被访问过的邻接点,并均标记为已访问过,依次类推,直到图中所有和初始点vi有路径相通的顶点都被访问过为止。

    简单实现

    public class Graph1 {
        //顶点的集合
        private Map<String, Integer> vertex;
        //矩阵表示
        private int[][] matrix;
        //链表表示
        private LinkedList<String>[] linkedTab;
        //表示节点是否被访问过
        private boolean[] isVisited;
    
        public Graph1(int n) {
            matrix = new int[n][n];
            vertex = new HashMap<>();
            linkedTab = new LinkedList[n];
            isVisited = new boolean[n];
        }
    
        public void insertVertex(String... v) {
            //添加一个顶点
            for (int i = 0; i < v.length; i++) {
                vertex.put(v[i], i);
                linkedTab[i] = new LinkedList();
                linkedTab[i].add(v[i]);
            }
        }
    
        public void addEdge(String v1, String v2) {
            //添加一条边
            Integer startIndex1 = vertex.get(v1);
            Integer startIndex2 = vertex.get(v2);
            matrix[startIndex1][startIndex2] = 1;
            matrix[startIndex2][startIndex1] = 1;
            linkedTab[startIndex1].add(v2);
            linkedTab[startIndex2].add(v1);
        }
    
        /**
         * 向外提供的方法,通过节点名深度优先遍历
         * @param startVertex 开始节点
         */
        public void depthFirstSearch(String startVertex) {
            Integer index = vertex.get(startVertex);
            if (index != null) {
                depthFirstSearch(index);
                Arrays.fill(isVisited, false);
            }
        }
    
        private void depthFirstSearch(int startIndex) {
            System.out.print(linkedTab[startIndex].get(0) + " ");
            int nextVertex = getNextVertex(startIndex);
            isVisited[startIndex] = true;
            if (nextVertex >= 0) {
                depthFirstSearch(nextVertex);
            }
        }
    
        /**
         * 向外提供的方法,通过节点名广度优先遍历,做一些准备和清理工作
         * @param startVertex 开始节点
         */
        public void breadthFirstSearch(String startVertex) {
            Integer index = vertex.get(startVertex);
            if (index != null) {
                Queue<Integer> queue = new ArrayDeque();
                queue.add(index);
                breadthFirstSearch(queue);
                Arrays.fill(isVisited, false);
            }
        }
    
        private void breadthFirstSearch(Queue<Integer> queue) {
            if (queue.size() < 1) {
                return;
            }
            Integer index = queue.remove();
            isVisited[index] = true;
            System.out.print(linkedTab[index].get(0) + " ");
            int nextVertex;
            while ((nextVertex = getNextVertex(index)) >= 0) {
                if (nextVertex >= 0 && !isVisited[nextVertex]) {
                    isVisited[nextVertex] = true;
                    queue.add(nextVertex);
                }
            }
            breadthFirstSearch(queue);
        }
    
        /**
         * 获取指定节点的下一个未被访问的邻接节点
         * @param v1 指定节点数组下标
         * @return 邻接节点的下标
         */
        private int getNextVertex(int v1) {
            for (int i = 0; i < matrix.length; i++) {
                if (matrix[v1][i] > 0 && !isVisited[i]) {
                    return i;
                }
            }
            return -1;
        }
    
        //打印邻接矩阵
        public void printMatrix() {
            StringBuilder info = new StringBuilder("   ");
            Iterator<String> iterator = vertex.keySet().iterator();
            while (iterator.hasNext()) {
                info.append(iterator.next()).append("  ");
            }
            iterator = vertex.keySet().iterator();
            for (int i = 0; i < matrix.length; i++) {
                info.append("\n").append(iterator.next()).append("  ");
                for (int n = 0; n < matrix.length; n++) {
                    info.append(matrix[i][n]).append("  ");
                }
            }
            System.out.println(info.toString());
        }
    
        //打印邻接链表
        public void printLinkedTab() {
            StringBuilder info = new StringBuilder("");
            for (int j = 0; j < linkedTab.length; j++) {
                Object[] objects = linkedTab[j].toArray();
                info.append(objects[0]).append(" |");
                for (int i = 1; i < objects.length; i++) {
                    info.append("--> ").append(objects[i].toString());
                }
                info.append("\n");
            }
            System.out.println(info.toString());
        }
    }
    
    
    //main:
        public static void main(String[] args) {
            Graph1 graph1 = new Graph1(7);
            graph1.insertVertex("A", "B", "C", "D", "E", "F", "G");
            graph1.addEdge("A", "E");
            graph1.addEdge("A", "G");
            graph1.addEdge("E", "G");
            graph1.addEdge("D", "G");
            graph1.addEdge("D", "B");
            graph1.addEdge("D", "C");
            graph1.addEdge("B", "C");
            graph1.addEdge("F", "C");
            graph1.addEdge("F", "E");
            System.out.println("==============邻接矩阵==============");
            graph1.printMatrix();
            System.out.println("==============邻接链表==============");
            graph1.printLinkedTab();
            System.out.println("============深度优先遍历============");
            graph1.depthFirstSearch("A");
            System.out.println("\n============广度优先遍历============");
            graph1.breadthFirstSearch("A");
        }
    
    //打印:
    ==============邻接矩阵==============
       A  B  C  D  E  F  G  
    A  0  0  0  0  1  0  1  
    B  0  0  1  1  0  0  0  
    C  0  1  0  1  0  1  0  
    D  0  1  1  0  0  0  1  
    E  1  0  0  0  0  1  1  
    F  0  0  1  0  1  0  0  
    G  1  0  0  1  1  0  0  
    ==============邻接链表==============
    A |--> E--> G
    B |--> D--> C
    C |--> D--> B--> F
    D |--> G--> B--> C
    E |--> A--> G--> F
    F |--> C--> E
    G |--> A--> E--> D
    
    ============深度优先遍历============
    A E F C B D G 
    ============广度优先遍历============
    A E G F D C B 
    Process finished with exit code 0
    
    
    

    参考书籍:《 算法(第四版).图灵程序设计丛书 Algorithms 》

    参考书籍:《 数据结构与算法分析 第3版 》

    参考资料: 维基百科

    这是一条签名的小尾巴: 任何变化都不是突然发生的,都是自己无意间一点一点选择的。
  • 相关阅读:
    三、DQL语言
    四、DML语言
    五、DDL语言
    Set WebClient Timeout and reference Stackoverflow
    C# anonymous types and serialization
    C# get multiple folders size under upper folder and order them by size descendingly.
    C# NamedPipeServerStream NamedPipeClientStream
    Convert XLSX file to DataTable via NPOI
    C# NPOI read excel files include xls and xlsx
    C# write stream to local file
  • 原文地址:https://www.cnblogs.com/novwind/p/14351029.html
Copyright © 2011-2022 走看看