数据结构与算法之美——笔记(二)

295 阅读19分钟

散列表(上)

散列表是基于数组的随机访问实现的,本身是数组的一种扩展。

散列函数

hash(key),根据关键字获得对应散列表的下标,hash即散列函数。散列函数设计的三大要求:

  1. 散列函数计算得到的散列值是一个非负整数;
  2. 如果key1 = key2,那hash(key1) == hash(key2);
  3. 如果key1 ≠ key2,那hash(key1) ≠ hash(key2)。 其中第三点是无法实现的,无论多好的散列函数都会出现hash冲突。

散列冲突

装载因子: 散列表的装载因子=填入表中的元素个数/散列表的长度,用于表示散列表中空闲位置的多少,装载因子越大,说明空闲位置越小,冲突越大。

解决方法

1、开放寻址法。以线性探测法为例,每次添加数据时,如果当前下标已经有值,就向后查找,直到有空闲位置。查找类似添加功能。删除功能不能直接删除数据,而是添加一个delete标识,否则当某一数据删除时,在线性查找一个数据时,会由于遇到空数据而提前停止查找。

优点:可有效利用cpu缓存,提高查询速度,序列化简单。

缺点:当插入的数据越多时,其发生hash冲突的概率就越大,最差情况下时间复杂度为O(n)。删除数据时需要使用标记,因此装载因子上限不能太大。这导致他比链表法更浪费空间。

ThreadLocalMap内部就采用开放寻址法

2、链表法

image.png 插入时,直接插入到指定位置的尾部即可,时间复杂度为O(1),查找和删除的时间复杂度和链表长度k成正比,k = m/N(m表示数据的个数,N表示槽的个数)。

优点:对装载因子容忍度高。

缺点:指针需要额外空间。无法利用cpu缓存

思考

  1. 假设我们有10万条URL访问日志,如何按照访问次数给URL排序?
  2. 有两个字符串数组,每个数组大约有10万条字符串,如何快速找出两个数组中相同的字符串

解答:

1、使用散列表,key为url,value为访问次数,当数据量比较小时,可以使用计数排序,当数据量大时使用快排。

2、将第一个数组构建成散列表,key为字符串,即可判断第二个数组中的字符串是否出现在第一个数组中。

散列表(中)

为什么要打造一个好的散列表?

如果散列函数设计得不好,或者装载因子过高,都可能导致散列冲突发生的概率升高,查询效率下降。 在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲 突解决方法,那这个时候,散列表就会退化为链表,查询的时间复杂度就从O(1)急剧退化为O(n)。

如何设计散列表

首先,散列函数的设计不能太复杂。过于复杂的散列函数,势必会消耗很多计算时间,也就间接的影响到散列表的性能。其次,散列函数生成的值要尽可能随机 并且均匀分布,这样才能避免或者最小化散列冲突,

如何解决装载因子过大问题?

对于静态数组,只需要确认数据的规模,设置一个较好的装载因子阈值即可。

对于动态数组,可以进行必要的扩容操作,当数据过大时,申请一个更大的新数组,将旧的数据迁移到新数组中。通过摊还分析法,扩容的平均时间复杂度为O(1).

实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果我们对空间消耗非常敏感,我们可以在装载因子小于某个 值之后,启动动态缩容。当然,如果我们更加在意执行效率,能够容忍多消耗一点内存空间,那就可以不用费劲来缩容了

装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率 要求又不高,可以增加负载因子的值,甚至可以大于1。

如何高效扩容?

为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。

当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过 程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。

对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。

小结

一个工业级散列表应该满足的要求:

  1. 支持快速的查询、插入、删除操作;
  2. 内存占用合理,不能浪费过多的内存空间;
  3. 性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况。

设计思路:

  1. 设计一个合适的散列函数;
  2. 定义装载因子阈值,并且设计动态扩容策略;
  3. 选择合适的散列冲突解决方法

散列表(下)

LRU实现

package algorithm;

import java.util.HashMap;
import java.util.Map;

/**
 * @author EnochStar
 * @title: LRU
 * @projectName basic_use
 * @description: TODO
 * @date 2021/10/30 10:59
 */
public class LRU<K,V> {
    private final static int DEFAULT_CAPACITY = 10;
    private int capacity;
    private DNode<K,V> head;
    private DNode<K,V> tail;
    private int length;
    private Map<K,DNode<K,V>> table;

    public LRU(int capacity) {
        this.capacity = capacity;

        head = new DNode<>();
        tail = new DNode<>();

        head.next = tail;
        tail.pre = head;
        length = 0;

        table = new HashMap<>();
    }

    public LRU() {
        this(DEFAULT_CAPACITY);
    }

    public void add(K key,V value) {
        DNode<K, V> node = table.get(key);
        if (node == null) {
            node = new DNode<>(key,value);
            table.put(node.key,node);
            addNode(node);
            if (++length > capacity) {
                DNode<K, V> tailNode = removeTail();
                table.remove(tailNode.key);
                length--;
            }
        }else{
            node.value = value;
            removeToHead(node);
        }
    }

    public DNode<K,V> getNode(K key){
        DNode<K, V> node = table.get(key);
        if (node == null) return null;
        else {
            removeToHead(node);
            return node;
        }
    }

    public void remove(K key) {
        DNode<K,V> node = table.get(key);
        if (node == null) return;
        else{
            removeNode(node);
            table.remove(key);
            length--;
        }
    }

    public void printAll() {
        DNode<K, V> node = head.next;
        while (node.next != null) {
            System.out.print(node.value + ",");
            node = node.next;
        }
        System.out.println();
    }

    public DNode<K,V> removeTail(){
        DNode<K,V> node = tail.pre;
        removeNode(node);
        return node;
    }

    public void removeToHead(DNode<K,V> node) {
        removeNode(node);
        addNode(node);
    }

    public void removeNode(DNode<K,V> node){
        node.pre.next = node.next;
        node.next.pre = node.pre;
    }

    public void addNode(DNode<K,V> node) {
        node.next = head.next;
        head.next.pre = node;
        head.next = node;
        node.pre = head;
    }
}
class DNode<K,V>{
    public K key;
    public V value;
    public DNode<K,V> pre;
    public DNode<K,V> next;

    public DNode() {
    }

    public DNode(K key, V value) {
        this.key = key;
        this.value = value;
    }
}

哈希算法

概念:将任意长度的字符串映射为固定长度的二进制串。哈希算法基本要求:

  1. 只支持单向哈希
  2. 哈希算法必须运行高效
  3. 尽可能使发生哈希冲突的概率低
  4. 具有敏感性,任意字符修改,都会导致哈希值的改变

应用

安全加密,基于单向哈希和哈希冲突概率低

唯一标识,当要判断某一张图在图库中是否存在时,比较笨的方法是依次比较图库中的图,另一种方法是可以给每个图的二进制串,取固定值然后求哈希值,在判断是否存在时,只需要比对哈希值即可。

数据校验,基于敏感性

散列函数,要求算法实现高效,数据能均匀分布

思考:了解区块链吗?

解答:区块链是由一个个区块组成,区块包括区块体和区块头,区块头存储自己的区块体和上一个区块头的哈希值,哈希算法比较耗时,当某一个区块被修改了,后续的区块头存储的内容就不对了,所以短时间修改区块链是比较困难的。

哈希算法(二)

哈希算法还可以运用到分布式系统当中。

应用

负载均衡,哈希算法可以实现一个会话粘滞的哈希算法,即将一个会话id对应到一个服务器,这一功能可以用哈希表去实现,但是当数据较大时,会消耗过多内存,同时,客户频繁的上线下线会使维护映射表的成本过大。可以用哈希算法对会话id进行计算,使每一个会话id定位到一个服务器。

数据分片,在数据量很大的情况下要计算某一关键字出现的次数,有两大难点,一是搜索日志很大,无法全部放入内存,二是只用一台机器会导致计算时间过长。可以使用哈希算法对关键字求值,然后取模,分配到对应的机器上,这样的话求某一关键字出现的次数只需要在对应的机器上计算即可。

分布式存储,利用一致性哈希,可以使新加入机器时不用进行大规模的数据迁移,只需要对部分数据进行迁移即可。

二叉树(一)

基本概念

节点的高度:节点到叶子节点的最长路径(边数)

节点的深度:根节点到该节点所经历的边的个数

节点的层数:节点的深度+1

二叉树存储

1、基于链表存储 image.png 2、基于数组存储

将根节点存储在下标为i的位置,左节点存储在2*i的位置,右结点存储在2 * i + 1的位置。

image.png

显然这种方法如果存储的是完全二叉树的话,只会浪费下标为0的空间,而存储非完全二叉树的话,会浪费过多空间。

二叉树的遍历

中序遍历:

/* 辅助函数,将二叉树存入 StringBuilder */
void serialize(TreeNode root, StringBuilder sb) {
    if (root == null) {
        return;
    }

    serialize(root.left, sb);
    /****** 中序遍历位置 ******/
    sb.append(root.val).append(SEP);
    /***********************/
    serialize(root.right, sb);
}

前序遍历:

/* 辅助函数,将二叉树存入 StringBuilder */
void serialize(TreeNode root, StringBuilder sb) {
    if (root == null) {
        return;
    }
    /****** 前序遍历位置 ******/
    sb.append(root.val).append(SEP);
    /***********************/
    serialize(root.left, sb);
    serialize(root.right, sb);


}

后序遍历:

/* 辅助函数,将二叉树存入 StringBuilder */
void serialize(TreeNode root, StringBuilder sb) {
    if (root == null) {
        return;
    }

    serialize(root.left, sb);
    serialize(root.right, sb);

    /****** 后序遍历位置 ******/
    sb.append(root.val).append(SEP);
    /***********************/
}

层次遍历:

public void LevelTraverse(TreeNode root) {
    if (root == null) {
        return;
    }
    Queue<TreeNode> queue = new LinkedList<>();
    queue.add(root);
    while (!queue.isEmpty()) {
        int size = queue.size();
        while (size > 0) {
            TreeNode node = queue.poll();
            // to do something with node
            System.out.println(node.val);
            if (node.left != null) {
                queue.add(node.left);
            }
            if (node.right != null) {
                queue.add(node.right);
            }
            size--;
        }
    }
}

二叉树(二)

二叉查找树是一种特殊的二叉树,支持动态数据集合的快速插入、查找、删除。其特点是树中的任意节点,其左子树每个节点的值都小于该节点的值,其右子树每个节点的值都大于该节点的值。

查找操作

public TreeNode search(TreeNode root,int val){
    if (root == null) return null;
    if (root.val == val) {
        return root;
    }else if (root.val > val) {
        return search(root.left,val);
    }else{
        return search(root.right,val);
    }
}

插入操作

leetcode-cn.com/problems/in…

public TreeNode insertIntoBST(TreeNode root, int val) {
    if(root == null) {
        return new TreeNode(val);
    }
    if(val > root.val) {
        root.right = insertIntoBST(root.right,val);
    }
    if(val < root.val) {
        root.left = insertIntoBST(root.left,val);
    }
    return root;
}

删除操作

leetcode-cn.com/problems/de…

public TreeNode deleteNode(TreeNode root, int key) {
    if(root == null) return null;
    if (root.val == key) {
        if (root.left == null) return root.right;
        if (root.right == null) return root.left;
        TreeNode node = getMin(root.right);
        root.val = node.val;
        root.right = deleteNode(root.right,node.val);
    }else if (root.val > key) {
        root.left = deleteNode(root.left,key);
    }else {
        root.right = deleteNode(root.right,key);
    }
    return root;
}
public TreeNode getMin(TreeNode root) {
    while (root.left != null) root = root.left;
    return root;
}

二叉搜索树的特性

中序遍历时的数据是有序的

包含重复数据的二叉查找树

当用二叉查找树存储重复键时,有两种方法。

1、每个结点为<K,[V]>类型,对于相同键的值,将其存储在数组中(链表也行)

2、对于重复键的数据,就将其添加到当前节点的右侧,再进行比较决定插入的位置,缺点在于查找数据时,尽管查找到了对应的键,仍要继续查找。删除操作也是如此。

image.png

时间复杂度分析

当二叉查找树平衡时,则查找的效率与树高有关为O(logn),当二叉查找树极度不平衡时,就会退化为链表,时间复杂度为O(n)。

为什么需要二叉查找树

散列表的增删改只需要O(1)的时间复杂度,而二叉查找树的效率不稳定,且最优情况下才O(logn),那么为什么还要二叉查找树呢?

  1. 散列表是无序的,而二叉查找树只需要中序遍历即可得到有序数据。
  2. 散列表需要考虑哈希函数的设计,当遇到哈希冲突时,散列表的操作也是不稳定的,而平衡二叉查找树的效率是稳定的
  3. 一个优秀的散列表并不好设计,需要注意扩容,散列函数,哈希冲突的解决思路,缩容等问题,而二叉查找树只需要解决平衡问题即可

思考

用编程实现获得二叉查找树的高度。

答:

public int getHeight(TreeNode root) {
    if (root == null) return 0;
    int height = 1 + Math.max(getHeight(root.left),getHeight(root.right));
    return height;
}

红黑树

红黑树是非严格的平衡二叉查找树。平衡二叉树中任意节点的左右子树相差不能大于1。AVL树是严格平衡的二叉查找树。

红黑树的定义

  1. 根节点是黑色的
  2. 每个叶子节点都是黑色的空节点
  3. 任意相邻节点不能同时为红色
  4. 每个节点到根节点都经过相同数量的黑色节点

红黑树的近似平衡?

将红黑树中的红色节点剔除,若一个节点的父节点为空,则取其祖父节点作为父节点。

image.png

由红黑树的性质每个节点到根节点都经过相同数量的黑色节点可知,从上图四叉树中取出某些节点放到下一层的节点,即形成了完全二叉树,显然上图的四叉树的高度是低于完全二叉树的高度的,黑色节点的个数为n,则完全二叉树高度为logn,所以四叉树的高度小于logn,当把红色节点再加入时,由于一个黑色节点最多相邻一个红色节点,所以红黑树的整体高度不大于2logn。

既然有AVL树,为什么还要选择红黑树呢?

AVL树的维护成本比红黑树大,二者的插入删除查找操作都很稳定,所以偏向红黑树。

什么是堆

堆是特殊的树,需要满足两个要求:

  1. 堆是一个完全二叉树
  2. 堆中每一个节点的值必须大于等于(小于等于)其子树每个结点的值

实现堆

由于堆是完全二叉树,因此采用数组存储数据。 其插入、删除、建堆、排序的代码如下:

package algorithm;

/**
 * @author EnochStar
 * @title: Heap
 * @projectName basic_use
 * @description: TODO
 * @date 2021/11/4 10:33
 */
public class Heap {
    // 数组的容量
    private int capacity;
    private int[] nums;
    // 数组内存在的元素个数
    private int count;

    public Heap(int capacity) {
        this.capacity = capacity;
        nums = new int[capacity + 1];
        count = 0;
    }

    public boolean insert(int num) {
        if (count >= capacity) return false;
        count++;
        nums[count] = num;
        int i = count;
        while (i / 2 > 0 && nums[i] > nums[i / 2]) {
            swap(nums,i,i / 2);
            i = i / 2;
        }
        return true;
    }

    public boolean deleteTop() {
        if (count == 0) return false;
        nums[1] = nums[count];
        count--;
        heapyfy(nums,count,1);
        return true;
    }

    // 自上而下的堆化  堆化是顺着树向下走的,因此时间复杂度不超过O(logn)
    public void heapyfy(int[] nums,int count,int start) {
        while (true) {
            int maxPos = start;

            if (start * 2 <= count && nums[start] < nums[2 * start]) {
                maxPos = 2 * start;
            }
            if (2 * start + 1 <= count && nums[maxPos] < nums[2 * start + 1]) {
                maxPos = 2 * start + 1;
            }
            if (maxPos == start) break;
            swap(nums,maxPos,start);
            start = maxPos;
        }
    }

    public void buildHeap(int[] nums,int count) {
        for (int i = count / 2;i >= 0;i--) {
            heapyfy(nums,count,i);
        }
    }

    public void sort(int[] nums,int count) {
        int k = count;
        while (k > 1) {
            swap(nums,k,1);
            k--;
            heapyfy(nums,k,1);
        }
    }

    public void swap(int[]nums,int s1,int s2){
        int tmp = nums[s1];
        nums[s1] = nums[s2];
        nums[s2] = tmp;
    }
}

插入是自下而上的堆化,删除和建堆是自上而下的堆化。由于插入删除操作时间主要消耗在堆化上,堆化的时间复杂度与树的高度成正比,因此堆化的时间复杂度为O(logn)。建堆操作,每一个节点比较和交换与节点高度成正比,因此每一层的时间复杂度为(该层节点个数 * 节点高度),计算可知时间复杂度为O(n)。 排序的时间复杂度为O(nlogn)。

堆排非稳定排序,属于原地排序。

快排与堆排

1、快排是顺序访问的,而堆排是跳着访问的,因此快排对cpu缓存更加友好

2、堆排交换次数比快排多

堆的应用

优先队列

堆可以用于实现优先队列。

合并有序小文件

假设有100个小文件,每个小文件内存储的是有序字符串,现要将这些小文件合并成一个有序的大文件。

可以采用归并排序的思路,依次取出每个文件的第一个放入数组中比较,然后取最小的放入新文件中,再从数组中删除。

如果采用数组的话,需要遍历数组找出最小的,时间复杂度为O(n),而如果使用优先队列的话,其插入和删除只需要O(logn)的时间复杂度。

高性能定时器

假设定时器维护了很多定时任务,定时器每过一个很小的时间单位,就去遍历定时任务,判断是否需要执行。

这种方法有两个弊端: 1、任务执行的时间可能距离当前时间还要很久,那么这个遍历是徒劳的 2、如果任务队列很长,那么遍历会非常耗时

可以将定时任务放入优先队列当中,然后取出优先队列的第一个,获取距离任务执行时间,当任务执行时间到的适合,再从优先队列中取出该任务。

求topk

如果求最大的k个元素,可以维护一个大小为k的小顶堆,如果新插入的元素比堆顶元素大,就删除堆顶元素,插入新的元素,否则,就不变。时间复杂度:遍历数组需要O(n),堆化的时间复杂度为O(logk),最差情况是n个数据都需要堆化,时间复杂度为O(nlogk)

package algorithm;

import java.util.Comparator;
import java.util.HashMap;
import java.util.Map;
import java.util.PriorityQueue;

/**
 * @author EnochStar
 * @title: TopK
 * @projectName basic_use
 * @description: TODO
 * @date 2021/11/4 15:43
 */
public class TopK {
    public int[] topKFrequent(int[] nums, int k) {
        PriorityQueue<Map.Entry<Integer, Integer>> priority =
                new PriorityQueue(k, new Comparator<Map.Entry<Integer, Integer>>() {
                    @Override
                    public int compare(Map.Entry<Integer, Integer> o1, Map.Entry<Integer, Integer> o2) {
                        return o1.getValue() - o2.getValue();
                    }
                });
        Map<Integer, Integer> map = new HashMap();
        for (int i : nums) {
            map.put(i, map.getOrDefault(i, 0) + 1);
        }
        for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
            if (priority.size() < k) {
                priority.add(entry);
            } else {
                if (priority.peek().getValue() < entry.getValue()) {
                    priority.poll();
                    priority.add(entry);
                }
            }
        }
        int[] top = new int[k];
        int index = 0;
        while (!priority.isEmpty()) {
            top[index++] = priority.poll().getKey();
        }
        return top;
    }
}

求中位数

可以维护两个堆,一个大顶堆,一个小顶堆,小顶堆的任意元素都比大顶堆的大。如果是奇数的话,可以令大顶堆存放n/2+1个元素,小顶堆存放n/2个元素,那么中位数就是大顶堆的堆顶元素;如果是偶数的话,那么分别存放n/2个元素。

当数据是动态变化的情况,如果新插入的数据比大顶堆小,就插入到大顶堆中,如果比小顶堆大,就插入到小顶堆当中,为了保证两个堆中数据量的比值保持一定,需要动态的将数据进行迁移(即从一个堆迁移到另一个堆)。

package algorithm;

import java.util.PriorityQueue;

/**
 * @author EnochStar
 * @title: MidNum
 * @projectName basic_use
 * @description: TODO
 * @date 2021/11/4 14:48
 */
class MedianFinder {
    PriorityQueue<Integer> smallHeap;
    PriorityQueue<Integer> bigHeap;
    /** initialize your data structure here. */
    public MedianFinder() {
        smallHeap = new PriorityQueue<Integer>();
        bigHeap = new PriorityQueue<Integer>((o1,o2) -> o2 - o1);
    }

    public void addNum(int num) {
        // 保持平衡 即小顶堆的所有元素均大于大顶堆
        bigHeap.add(num);
        smallHeap.add(bigHeap.poll());
        // 使其相差最大为1
        if(smallHeap.size() > bigHeap.size() + 1) {
            bigHeap.add(smallHeap.poll());
        }
    }

    public double findMedian() {
        if(smallHeap.size() > bigHeap.size()) return smallHeap.peek();
        else return (double)(bigHeap.peek()+smallHeap.peek())/2;
    }
}

思考

假设有10亿条数据,如果快速获得搜索热度最高的数据。首先可以使用哈希函数将数据分别放入10个文件中,然后使用散列表存储关键词及其搜索频率,在10个文件中分别建大小为10的小顶堆,最后合并即可。

图是非线性表,包括有向图和无向图

image.png

image.png 由于好友是双向的,所以一般用无向图,而博主与粉丝是单向的,所以一般用有向图。有些场景下需要有双方的亲密度,这个时候只需要在关系上加权值即可,带权图。

image.png

图的存储方式

1、邻接矩阵。

image.png 显然邻接矩阵对于无向图来说会浪费一半的空间,如果存储的是稀疏图,那么浪费的空间更多,其优点是简单直接,获取顶点间的关系非常直接。

2、邻接表。

image.png 邻接表是用空间换时间,不过可以对其进行优化,比如可以用跳表、散列表、红黑树等进行优化。

如何设计一个微博?

  1. 大致思考微博需要的一个功能:
  2. 判断用户A是否为用户B的粉丝
  3. 判断用户B有无被用户A关注
  4. 获取当前用户的粉丝
  5. 获取当前用户关注的人
  6. 对粉丝和关注人按名字进行排序

社交网络是一个稀疏图,所以使用邻接矩阵会浪费大量的空间,因此推荐邻接表,假设邻接表存储当前用户的粉丝,那么该如何查找当前用户关注的人?可以再加一个逆邻接表,用于存储关注的用户。

由于邻接表遍历的时间复杂度为O(n),可以对邻接表进行优化,如红黑树、跳表、散列表,以提高查询效率,微博需要一个排序的功能,考虑设计的简便,可以使用跳表来优化。这样查询排序的时间复杂度都为O(logn)。

当数据量很大的时候,无法完全将数据存放在内存中。有两种方式,第一种是将数据存放在硬盘中,第二种方式就是添加机器,无论哪种方式,都根据哈希函数将数据映射到对应的数据库(机器)上。

深搜和广搜

广搜

image.png

时间复杂度为O(V+E),V为顶点个数,E为边数,对应连通图而言V <= E-1,所以时间复杂度为O(E), 由于需要辅助数组等存放顶点,所以空间复杂度为O(V)

深搜

image.png

采用了回溯的思想,每条边最多遍历两次,故时间复杂度为O(E),内部会使用辅助数组等,同时递归调用栈的最大深度不超过顶点的个数,因此空间复杂度为O(V)。

具体实现

package algorithm;

import java.util.LinkedList;
import java.util.Queue;

/**
 * @author EnochStar
 * @title: Graph
 * @projectName basic_use
 * @description: TODO
 * @date 2021/11/5 14:53
 */
public class Graph {
    private int v;
    private LinkedList<Integer>[] adj;

    public Graph(int v) {
        this.v = v;
        adj = new LinkedList[v];
        for (int i = 0;i < v;i++) {
            adj[i] = new LinkedList<>();
        }
    }

    // 无向图
    public void addEdge(int sour,int obj) {
        adj[sour].add(obj);
        adj[obj].add(sour);
    }

    public void bfs(int start,int end){
        int[] prev = new int[v];
        for (int i = 0;i < v;i++) {
            prev[v] = -1;
        }
        boolean[] visited = new boolean[v];
        visited[start] = true;
        Queue<Integer> queue = new LinkedList();
        queue.add(start);
        while (!queue.isEmpty()) {
            int s = queue.poll();
            for (int i = 0;i < adj[s].size();i++) {
                int num = adj[s].get(i);
                if (!visited[num]) {
                    prev[num] = s;
                    if (num == end) {
                        printRoad(prev,start,end);
                        return;
                    }
                    visited[num] = true;
                    queue.add(num);
                }
            }
        }
    }

    private void printRoad(int[] prev,int start,int end){
        while (end != start && prev[end] != -1) {
            printRoad(prev,start,prev[end]);
        }
        System.out.println(end + " -> ");
    }

    public void dfs(int start,int end) {
        int[] prev = new int[v];
        boolean[] visited = new boolean[v];
        for (int i = 0;i < v;i++) {
            prev[i] = -1;
        }
        boolean found = false;
        dfsRecur(start,end,visited,prev,found);
        printRoad(prev,start,end);
    }

    private void dfsRecur(int start,int end,boolean[] visited,int[] prev,boolean found) {
        if (found) return;
        visited[start] = true;
        if (start == end) {
            found = true;
            return;
        }

        for (int i = 0;i < adj[start].size();i++) {
            int num = adj[start].get(i);
            if (!visited[num]) {
                prev[num] = start;
                dfsRecur(num,end,visited,prev,found);
            }
        }
    }

}