• 算法竞赛Java数据结构与算法类详解


    1. String/StringBuffer/StringBuilder

    String 类即字符串,在 Java 中 String 类是不可改变的,如果修改 String 对象,那么其实是开一个新的空间保存,而原空间中的字符串还存在于内存中。String 类的用法如下:

    // 定义
    String str = "Hello World!";
    
    char[] charArray = { 'A', 'B', 'C' };
    String strFromChar = new String(charArray);
    
    // 遍历
    for (int i = 0; i < str.length(); i++)
        System.out.print(str.charAt(i));
    System.out.println();
    
    for (char c: str.toCharArray())  // toCharArray将String转为字符数组
        System.out.print(c);
    System.out.println();
    
    // 常用方法
    str.charAt(0);  // H,返回第0个字符
    str.compareTo("G");  // 1,比较字符串,如果大于返回1,小于返回-1,否则返回0
    str.equals("Hello World!");  // true,Java中比较两个字符串值是否相等必须用equals
    str.startsWith("He");  // true,判断是否以"He"开头
    str.endsWith("d!");  // true,判断是否以"d!"结尾
    str.indexOf('o');  // 4,返回'o'第一次出现的位置,若不存在返回-1
    str.indexOf('o', 5);  // 7,返回'o'从下标5开始第一次出现的位置,若不存在返回-1
    str.lastIndexOf('o');  // 7,返回'o'最后出现的位置,若不存在返回-1
    str.lastIndexOf('o', 6);  // 4,返回'o'在下标6之前的字符串中最后出现的位置,若不存在返回-1
    str.replace('o', 'x');  // Hellx Wxrld!,将'o'替换成'x'
    str.split(" ");  // [Hello, World!],以" "进行分割,返回分割后的数组,支持正则表达式
    str.substring(6, 9);  // Wor,返回[6, 9)的字串,如果不指定第二个下标默认为末尾
    str.toLowerCase();  // hello world!,将所有字母转为小写后返回
    str.toUpperCase();  // HELLO WORLD!,将所有字母转为大写后返回
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30

    当需要对字符串进行频繁修改时,要用 StringBufferStringBuilder 类。这两个类的对象能够被多次修改,并且不产生新的未使用对象。

    StringBufferStringBuilder 之间最大的不同在于 StringBuilder 的方法不是线程安全的(不能同步访问)。但是 StringBuilder 相较于 StringBuffer 有速度优势,所以多数情况下建议使用 StringBuilder 类。以 StringBuilder 为例,用法如下:

    // 定义
    StringBuilder strBuilder = new StringBuilder(str);
    
    // 遍历
    for (int i = 0; i < strBuilder.length(); i++)
        System.out.print(strBuilder.charAt(i));
    System.out.println();
    
    // 常用方法
    strBuilder.append("A");  // 在末尾追加字符串/字符/数字
    strBuilder.reverse();  // 翻转字符串
    strBuilder.delete(0, 1);  // 删除[0, 1)中的字符串
    strBuilder.insert(6, "A");  // 在下标6的位置插入字符串/字符/数字
    strBuilder.replace(0, 7, "Hello");  // 将[0, 7)中的字符串替换为"Hello"
    strBuilder.toString();  // 转换回String
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    2. Arrays

    Arrays 类定义java.util 包中,该类能够方便地操作数组,用法如下:

    // 定义
    int[] arr = { 2, 4, 3, 1, 5 };
    int[][] arr2d = {{ 1, 2 }, { 3, 4 }};
    Integer[] arrInteger = { 2, 4, 3, 1, 5 };
    
    // 遍历
    for (int i = 0; i < arr.length; i++)
        System.out.print(arr[i] + " ");
    System.out.println();
    
    for (int x: arr)
        System.out.print(x + " ");
    System.out.println();
    
    // 常用方法
    Arrays.sort(arr);  // 从小到大排序
    Arrays.sort(arrInteger, (x, y) -> y - x);  // 从大到小排序,必须用Integer对象
    Arrays.binarySearch(arr, 3);  // 返回2,对排好序的数组二分查找
    Arrays.equals(arr, arr);  // 返回true,判断两个数组是否相等
    Arrays.fill(arr, 0);  // 用0填充数组所有元素
    Arrays.toString(arr);  // 返回[0, 0, 0, 0, 0],将数组的第一维转换成字符串形式
    Arrays.deepToString(arr2d);  // 返回[[1, 2], [3, 4]],递归转换数组的每一维,可用于二维及以上的数组
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    3. ArrayList

    Java 的集合框架主要包括两种类型的容器:

    • 集合(Collection):存储一个元素集合。
    • 图(Map):存储键/值对映射。

    其中,Collection 接口又包含了 List 接口和 Set 接口两大分支,前者为允许存在重复元素的有序集合,后者为不允许存在重复元素的无序集合;Map 是一个映射接口,其中的每一个元素包含一个 key 和对应的 value

    ArrayList 类实现了 List 接口,继承自 AbstractList 类,位于 java.util 包中(迭代器也位于该包中),可以用于动态地调整存储在其中的元素的大小。ArrayList 类是基于数组的数据结构,它提供了一组用于操作元素的方法,包括添加、删除、插入、搜索和排序等。

    ArrayList 用法如下:

    // 定义
    ArrayList<Integer> v = new ArrayList<>();  // <>中只能为引用数据类型
    ArrayList<Integer> v = new ArrayList<>(Arrays.asList(1, 2, 3));  // 初始化时赋值,当前为[1, 2, 3]
    List<Integer> v = Arrays.stream(new int[] { 1, 2, 3 }).boxed().collect(Collectors.toList());  // int[]转List,注意只能为List,不能用ArrayList
    
    // 遍历
    for (int i = 0; i < v.size(); i++)  // 传统for循环
        System.out.printf("%d ", v.get(i));
    System.out.println();
    
    for (int x: v)  // forEach循环,推荐这种写法
        System.out.printf("%d ", x);
    System.out.println();
    
    for (Iterator<Integer> it = v.iterator(); it.hasNext();)  // 迭代器
        System.out.printf("%d ", it.next());
    System.out.println();
    
    for (ListIterator<Integer> it = v.listIterator(); it.hasNext();)  // 列表迭代器
        System.out.printf("%d ", it.next());
    System.out.println();
    
    v.stream().forEach(x -> System.out.printf("%d ", x));  // Java8的Stream API和Lambda表达式
    System.out.println();
    
    // 常用方法
    v.add(4);  // 在尾部添加4,当前为[1, 2, 3, 4]
    v.add(1, 5);  // 在下标1处添加5,当前为[1, 5, 2, 3, 4]
    v.remove(4);  // 删除下标4处的元素,当前为[1, 5, 2, 3]
    v.set(0, 4);  // 将下标0处的元素置为4,当前为[4, 5, 2, 3]
    v.subList(0, 2);  // 返回[4, 5],截取[0, 2)的子列表
    v.indexOf(5);  // 返回1,获取元素5第一次出现的下标,若不存在返回-1
    v.isEmpty();  // 返回false,判断是否为空
    v.contains(2);  // 返回true,判断是否含有元素2
    v.sort(Comparator.naturalOrder());  // 从小到大排序,当前为[2, 3, 4, 5]
    v.sort(Comparator.reverseOrder());  // 从大到小排序,当前为[5, 4, 3, 2]
    v.addAll(Arrays.asList(1, 2));  // 添加另一个Collection对象,当前为[5, 4, 3, 2, 1, 2]
    Collections.addAll(v, 7, 8, 9);  // 添加任意数量的元素,当前为[5, 4, 3, 2, 1, 2, 7, 8, 9]
    Collections.reverse(v);  // 翻转List,当前为[9, 8, 7, 2, 1, 2, 3, 4, 5]
    ArrayList<Integer> t = (ArrayList<Integer>)v.clone();  // 复制一份,和原ArrayList不是同一份
    
    // List转换为数组
    Integer[] a = new Integer[v.size()];  // 必须为包装类
    v.toArray(a);
    
    Integer[] b = v.toArray(new Integer[0]);  // 效果同上
    
    int[] c = v.stream().mapToInt(Integer::valueOf).toArray();  // 必须为基本类型,mapToInt()将Stream转换成IntStream
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48

    4. LinkedList

    Java 的 LinkedList 类是一个实现了 List 接口和 Deque 接口的双向链表。它也可以被当作堆栈、队列或双端队列进行操作。

    ArrayList 相比,LinkedList 的增加和删除的操作效率更高,而查找和修改的操作效率较低。因此需要频繁访问列表中的某一个元素且只需要在列表末尾进行添加和删除元素操作推荐使用 ArrayList,而需要频繁的在列表开头、中间、末尾等位置进行添加和删除元素操作推荐使用 LinkedList

    LinkedList 的定义与遍历方式与 ArrayList 相同,且上述的 ArrayList 常用方法在 LinkedList 中均可使用,额外添加的部分方法如下:

    // 定义
    LinkedList<Integer> v = new LinkedList<>(Arrays.asList(1, 2, 3));
    
    // 常用方法
    v.addLast(4);  // 在尾部添加4,当前为[1, 2, 3, 4]
    v.addFirst(5);  // 在首部添加5,当前为[5, 1, 2, 3, 4]
    v.removeLast();  // 删除并返回尾部元素,当前为[5, 1, 2, 3]
    v.removeFirst();  // 删除并返回首部元素,当前为[1, 2, 3]
    v.getFirst();  // 返回首部元素1
    v.getLast();  // 返回尾部元素3
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    5. HashSet/TreeSet

    Java 的 HashSet 类是一个实现了 Set 接口的集合类,它使用 HashMap 作为基础,因此其特性和 HashMap 类似:

    • 不重复:是一个没有重复元素的集合。
    • 无序:它不保证元素的顺序。
    • 允许 null:允许使用 null 元素。
    • 非同步:即不是线程安全的,如果多个线程尝试同时修改 HashSet,则最终结果是不确定的。 必须在多线程访问时显式同步对 HashSet 的并发访问。

    HashSet 用法如下:

    // 定义
    HashSet<Integer> st = new HashSet<>();
    HashSet<Integer> st = new HashSet<>(Arrays.asList(1, 2, 3));
    
    // 遍历
    for (int x: st)  // forEach循环,推荐这种写法
        System.out.printf("%d ", x);
    System.out.println();
    
    for (Iterator<Integer> it = st.iterator(); it.hasNext();)  // 迭代器
        System.out.printf("%d ", it.next());
    System.out.println();
    
    st.stream().forEach(x -> System.out.printf("%d ", x));  // Java8的Stream API和Lambda表达式
    System.out.println();
    
    // 常用方法
    st.add(4);  // 添加元素,当前为{1, 2, 3, 4}
    st.remove(3);  // 删除元素,当前为{1, 2, 4}
    st.contains(3);  // 返回false,判断是否存在3
    st.size();  // 返回3,获取集合的元素数量
    st.isEmpty();  // 返回false,判断是否为空
    st.clear();  // 清空元素
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23

    TreeSetHashSet 的用法基本一致,区别在于 TreeSet 中的元素是有序的:

    // 定义
    TreeSet<Integer> st = new TreeSet<>();
    TreeSet<Integer> st = new TreeSet<>(Arrays.asList(3, 2, 1, 2));
    
    // 常用方法
    st.ceiling(3);  // 返回3,查找大于等于3的最小的数,不存在则返回null
    st.floor(3);  // 返回3,查找小于等于3的最大的数,不存在则返回null
    ...其余同HashSet
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    6. HashMap/TreeMap

    Java 的 HashMap 是一个实现了 Map 接口的哈希表,它存储的内容是键值对(key-value)映射。HashMap 的特性如下:

    • 键值对存储:HashMap 是以 key-value 存储形式存在,主要用来存放键值对。
    • 非同步:HashMap 的实现不是同步的,这意味着它不是线程安全的。
    • 允许 null:它的 keyvalue 都可以为 null
    • 无序:此外,HashMap 中的映射不是有序的。

    HashMap 的底层数据结构为:

    • Java8 之前:HashMap 由数组+链表组成,数组是 HashMap 的主体,链表则主要用于解决哈希碰撞
    • Java8 之后:在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8)并且当前数组的长度大于64时,此时此索引位置上的所有数据改为使用红黑树存储。

    HashMap 的保存顺序与插入顺序不同,这是因为 HashMap 使用了一种非常快速的算法,该算法会控制顺序。LinkedHashMap 则是按照插入顺序来保存键值对,同时保留了 HashMap 的查找速度。

    HashMap 用法如下:

    // 定义
    HashMap<String, Integer> mp = new HashMap<>();
    HashMap<String, Integer> mp = new HashMap<String, Integer>() {{  // 可能会导致一些未预期的副作用,因为它实际上创建了一个匿名子类
        put("ABC", 1);
        put("DEF", 2);
    }};
    
    // 遍历
    for (String k: mp.keySet())  // 遍历key
        System.out.println(k);
    
    for (int v: mp.values())  // 遍历value
        System.out.println(v);
    
    for (Map.Entry<String, Integer> e: mp.entrySet())  // 遍历key-value,推荐这种写法
        System.out.println(e.getKey() + " " + e.getValue());
    
    for (Iterator it = mp.entrySet().iterator(); it.hasNext();) {  // 迭代器
        Map.Entry e = (Map.Entry)it.next();
        System.out.println(e.getKey() + " " + e.getValue());
    }
    
    // 常用方法
    mp.put("YYJ", 666);  // 添加键值对,如果键已经存在,就替换原来的值,当前为{ABC=2, DEF=1, YYJ=666}
    mp.get("YYJ");  // 返回666,获取某个key对应的value,如果key不存在返回null
    mp.remove("YYJ");  // 删除指定的key及其对应的value,如果key不存在返回null,当前为{ABC=2, DEF=1}
    mp.containsKey("YYJ");  // 返回false,判断是否包含指定的key
    mp.containsValue(1);  // 返回true,判断是否包含指定的value
    mp.size();  // 返回2,获取键值对的数量
    mp.clear();  // 清空键值对
    mp.isEmpty();  // 返回true,判断是否为空
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    Set 同理,TreeMapHashMap 的用法基本一致,区别在于 TreeMap 中的元素是根据键的升序排好序的:

    // 定义
    TreeMap<String, Integer> mp = new TreeMap<>();
    TreeMap<String, Integer> mp = new TreeMap<String, Integer>() {{  // 可能会导致一些未预期的副作用,因为它实际上创建了一个匿名子类
        put("DEF", 1);
        put("ABC", 2);
    }};
    
    // 常用方法
    Map.Entry<String, Integer> ceiling = mp.ceilingEntry("AAA");  // 查找键大于等于"AAA"的最小的键值对,不存在则返回null
    System.out.println(ceiling.getKey() + ": " + ceiling.getValue());  // ABC: 2
    Map.Entry<String, Integer> floor = mp.floorEntry("DEG");  // 查找键小于等于"DEG"的最大的键值对,不存在则返回null
    System.out.println(floor.getKey() + ": " + floor.getValue());  // DEF: 1
    mp.ceilingKey("A");  // 返回ABC,查找键大于等于"A"的最小的键,不存在则返回null
    mp.floorKey("A");  // 返回null,查找键小于等于"A"的最大的键,不存在则返回null
    ...其余同HashMap
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    7. Stack

    栈(Stack)是一个后进先出(LIFO)的集合,它有时被称作下推栈,因为不管我们最后压入的是什么,它都会第一个弹出。

    Java 1.0 就提供了 Stack 类,结果这个类的设计非常糟糕,不过因为要向后兼容,所以我们永远也无法摆脱 Java 过去的设计错误了。Java 6 加入了 ArrayDeque,提供了直接实现栈功能的方法:

    Deque<String> stk = new ArrayDeque<>();
    
    for (String s: "This is stack".split(" "))
        stk.push(s);
    
    while (!stk.isEmpty())
        System.out.print(stk.pop() + " ");  // stack is This
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    8. Queue/PriorityQueue

    队列(Queue)是一个典型的先进先岀(FIFO)的集合。换言之,我们在一端放入在另一端拿出来,放入的顺序和取出的顺序是一样的。队列常用来将对象从程序的一个区域可靠地转移到另一个区域,队列在并发编程中特别重要,因为它们可以安全地将对象从一个任务转移到另一个任务。

    LinkedList 实现了 Queue 接口,提供了支持队列行为的方法,因此 LinkedList 可以作为 Queue 的一种实现来使用,通过将 LinkedList 向上转型为 Queue,下面这个示例使用了 Queue 接口特有的方法:

    Queue<Integer> Q = new LinkedList<>();
    
    for (int i = 0; i < 5; i++)
        Q.offer(i);
    
    System.out.println(Q);  // [0, 1, 2, 3, 4]
    
    while (Q.peek() != null)
        System.out.print(Q.remove() + " ");  // 0 1 2 3 4
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    其中:

    • offer()Queue 特有的方法之一,负责在队列尾部插入一个元素,如果无法插入则返回 false
    • peek()element() 都会返回队头元素,不会将其删除,但是如果队列为空,peek() 返回 null,而 element() 抛出 NoSuchElementException 异常。
    • poll()remove() 会将队头元素删除,同时返回该元素,但是如果队列为空,poll() 返回 null,而 remove() 抛出 NoSuchElementException 异常。

    优先级队列(Priority Queue)是指下一个要拿出的元素是需求最强烈的元素(最高优先级)。Java 5 中添加了 PriorityQueue,为这种行为提供了一个自动化的实现。

    PriorityQueue 默认的排序方法使用的是对象在队列中的自然顺序,但我们可以通过提供自己的 Comparator 来修改顺序:

    PriorityQueue<Integer> Q = new PriorityQueue<>();  // 默认是升序
    
    for (int i = 5; i > 0; i--)
        Q.offer(i);
    
    while (Q.peek() != null)
        System.out.print(Q.remove() + " ");  // 1 2 3 4 5
    System.out.println();
    
    List<Integer> nums = Arrays.asList(2, 5, 1, 3, 7);
    PriorityQueue<Integer> RQ = new PriorityQueue<>(Collections.reverseOrder());  // 逆序
    RQ.addAll(nums);
    
    while (RQ.peek() != null)
        System.out.print(RQ.remove() + " ");  // 7 5 3 2 1
    System.out.println();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    9. 深入Iterator与Collection

    Collection 是所有序列集合共同的根接口,可以认为它是一个为表示其他接口之间的共性而出现的附属接口。支持使用这样一个接口的理由是,它可以创建更通用的代码,通过面向接口而不是面向实现来编写代码,我们的代码可以应用于更多对象类型。因此,如果我们编写一个以 Collection 为参数的方法,那么该方法可以应用于任何实现了 Collection 的类型。

    在 Java 中,看上去遵循 C++ 的方式比较明智,即使用迭代器而非 Collection 来表示集合之间的共性。但是,因为在 Java 中实现 Collection 也就意味着提供了 iterator() 方法,所以这两种方式其实是绑在一起了:

    package com.yyj;
    
    import java.util.*;
    
    public class Main {
        public static void main(String[] args) {
            List<Integer> numsList = new ArrayList<>(Arrays.asList(1, 2, 3, 3));
            Set<Integer> numsSet = new HashSet<>(Arrays.asList(1, 2, 3, 3));
    
            display(numsList.iterator());  // 1 2 3 3
            display(numsSet.iterator());  // 1 2 3
            display(numsList);  // 1 2 3 3
            display(numsSet);  // 1 2 3
        }
    
        public static void display(Iterator<Integer> it) {
            while (it.hasNext())
                System.out.print(it.next() + " ");
            System.out.println();
        }
    
        public static void display(Collection<Integer> nums) {
            for (Integer x: nums)
                System.out.print(x + " ");
            System.out.println();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    10. 自定义对象数组排序

    我们需要让进行排序的类实现 Comparable 接口,重写其中的 compareTo() 方法,在其中定义排序规则,那么就可以直接调用 java.util.Arrays.sort() 来排序对象数组:

    import java.util.Arrays;
    
    class Data implements Comparable<Data> {  // 实现Comparable接口
        int x;
        String s;
    
        Data(int x, String s) {
            this.x = x;
            this.s = s;
        }
    
        @Override
        public int compareTo(Data t) {  // 重写compareTo(T obj)方法
            return t.x - x;  // 若t.x比当前对象的x小则返回-1,表示根据x从大到小排序
        }
    
        @Override
        public String toString() {
            return String.format("[%d, %s]", x, s);
        }
    }
    
    public class SortObjectArrays {
        public static void main(String[] args) {
            Data[] datas = { new Data(4, "Hello"), new Data(1, "Java"), new Data(6, "C++") };
            Arrays.sort(datas);
            Arrays.stream(datas).forEach(System.out::println);
    
            /*
             * [6, C++]
             * [4, Hello]
             * [1, Java]
             */
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
  • 相关阅读:
    无监督re-ranker,Improving Passage Retrieval with Zero-Shot Question Generation
    大疆创新2022数字芯片B卷
    宝塔面板使用docker+nginx+gunicorn部署Django项目实战教程
    SpringThirdDay
    计算机毕业设计python企业员工人事管理系统vue
    nacos简介以及两大核心(注册中心、配置中心)简介
    精准测试探索
    2023年软件测试还能不能干,怎么干!听听这些肺腑之言!
    sqli-lab靶场通关
    操作文件只留后面n行
  • 原文地址:https://blog.csdn.net/m0_51755720/article/details/133925807