JavaDS —— 单链表 与 LinkedList

news2024/11/16 20:54:21

顺序表和链表区别

ArrayList :
底层使用连续的空间,可以随机访问某下标的元素,时间复杂度为O(1)
但是在插入和删除操作的时候,需要将该位置的后序元素整体往前或者向后移动,时间复杂度为O(N)
增容需要申请新空间,有时候需要拷贝数据释放旧空间,这会有不小的消耗
顺序表的增容一般是2倍增加的,势必会有一定的kong’jian浪费,例如当前容量为100时,需要扩容的话,就是将容量增加到200,如果只是再插入几个数据,就一定会浪费九十几的空间。

既然如此,我们就会思考如何减少空间的浪费,这时候链表就登场了,下面是单链表的示意图:
在这里插入图片描述
链表由两个部分组成,一个是数据域,一个是指针域,数据域是用来存放数据的,指针域是用来存放下一个或者前一个的引用的,这样就把数据给串联起来了,大家也就不难发现,链表的优点就是用多少空间就申请多少空间,做到空间不浪费,并且在下面的内容,你还会感受到链表的插入删除操作效率很高。

链表的分类

链表有8大类,带头和不带头,单向还是双向,循环还是不循环,2^3 = 8种

带头和不带头是指链表有没有一个哨兵节点,就是只是充当头结点的作用,不存放任何有效的数据。
上面的图片就是不带头的,下面的是带头的:
在这里插入图片描述

单向和双向是指:链表的节点是只指向后一个节点的话就是单向的,如果链表的节点即指向前一个结点又指向后一个节点的话就是双向的。
在这里插入图片描述

循环和不循环是指链表是否头尾相连,如果头尾相连就是循环的,否则就是不循环的:
在这里插入图片描述
在这里插入图片描述

实现单链表

下面是自己写的IList接口,会被单链表拓展:

public interface IList {
    //头插法
    public void addFirst(int data);
    
    //尾插法
    public void addLast(int data);
    
    //任意位置插入,第一个数据节点为0号下标
    public void addIndex(int index,int data);
    
    //查找是否包含关键字key是否在单链表当中
    public boolean contains(int key);
    
    //删除第一次出现关键字为key的节点
    public void remove(int key);
    
    //删除所有值为key的节点
    public void removeAllKey(int key);
    
    //得到单链表的长度
    public int size();
	
	//清空链表
    public void clear();
 	
 	//打印链表
    public void display();
}

单链表的节点需要一个数据域和一个指针域,我们先来写一个静态内部类来构造节点类:

    static class ListNode {
        public int val;
        public ListNode next;

        public ListNode(int val) {
            this.val = val;
        }
    }

除此之外,我们还需要一个头指针来指向第一个节点:

    public ListNode head;

打印

循环遍历链表,打印每一个节点的数据,这个方法有利于我们的测试:

    @Override
    public void display() {
        ListNode cur = head;
        while(cur != null) {
            System.out.print(cur.val + " ");
            cur = cur.next;
        }
        System.out.println();
    }

头插

在单链表的头部插入一个数据,我们需要将新头节点的next指向原先头部的节点,然后改变head的指向。

    @Override
    public void addFirst(int data) {
        ListNode node = new ListNode(data);
        node.next = head;
        head = node;
    }

尾插

循环遍历单链表找到尾节点,然后改变尾节点的指向即可。

这里要注意如果head为空的时候,直接赋值就可以了,不能直接使用null,会报空指针异常,所以在循环前面加多一个判断条件即可。

    @Override
    public void addLast(int data) {
        ListNode node = new ListNode(data);
        if(head == null) {
            head = node;
            return;
        }
        ListNode cur = head;
        while(cur.next != null) {
            cur = cur.next;
        }
        cur.next = node;
    }

求节点总个数

这个很简单,直接循环遍历即可。

    @Override
    public int size() {
        ListNode cur = head;
        int count = 0;
        while(cur != null) {
            cur = cur.next;
            count++;
        }
        return count;
    }

指定位置插入

先判断指定的位置有没有越界,和之前的顺序表是一样的,这里不赘述:

public class IndexException extends RuntimeException{
    public IndexException(String message) {
        super(message);
    }
}
    private void checkIndexInAdd(int index) throws IndexException {
        if(index < 0 || index > size()) {
            throw new IndexException("下标范围不合法!");
        }
    }

我们要先找到index前一个结点,因为这个插入操作是对三个节点进行操作的,首先先把index的引用放入新结节点的next中,然后再把index前一个结点的next改成新结点的引用,这是一般情况,如果index == 0的话就是头插操作,为什么要做一个判断,因为我们得出的一般规律最后是cur.next = node,这是建立在新结点前面一定有结点的情况下,但是如果是头插的话就不符合了,所以头插需要单独说明。

    @Override
    public void addIndex(int index, int data) {
        try{
            checkIndexInAdd(index);
            if(index == 0) {
                addFirst(data);
                return;
            }
            //找到index前一个的节点
            ListNode cur = head;
            for (int i = 0; i < index - 1; i++) {
                cur = cur.next;
            }
            ListNode node = new ListNode(data);
            node.next = cur.next;
            cur.next = node;
        } catch (IndexException e) {
            System.out.println("index 不合法!");
            e.printStackTrace();
        }
    }

对于插入操作,我们要先处理后面的结点,避免后面的结点丢失。

contains

是否包含某个元素,直接遍历循环即可:

    @Override
    public boolean contains(int key) {
        ListNode cur = head;
        while(cur != null) {
            if(cur.val == key) {
                return true;
            }
            cur = cur.next;
        }
        return false;
    }

删除第一次出现的key

删除某个结点的时候,由于这是单链表,所以我们最好事先拿到删除节点的前一个结点,然后我们要考虑一些特殊的情况,如果这个链表为空就不需要删除,如果要删除的结点就是头结点,那么我们就需要改变头指针的指向,最后就是一般情况下,我们直接修改删除结点的前一个结点的 next 域 就可以了。

    private ListNode findFrontNodeOfKey(int key) {
        ListNode cur = head;
        while(cur != null) {
            if(cur.next.val == key) {
                return cur;
            }
            cur = cur.next;
        }
        return null;
    }

    @Override
    public void remove(int key) {

        //空链表
        if(head == null) {
            return;
        }

        //头删
        if(head.val == key) {
            head = head.next;
            return;
        }

        ListNode prev = findFrontNodeOfKey(key);
        if(prev == null) {
            return;//不存在key
        }

        ListNode del = prev.next;
        prev.next = del.next;
    }

删除所有出现的key

我们使用两个指针,一个从头结点开始,另一个从头结点的下一个结点开始遍历链表,当第二个指针遇到要删除的结点时,配合第一个指针完成此工作,然后prev不变,cur继续移动,如果没有遇到删除的结点,两个指针是一起继续向后运动。

要注意如果链表为空的话就直接return ,避免发生空指针异常

这时候大家一定知道还差一个结点没有判断,就是第一个结点,所以我们最后还有判断一下头结点。

    @Override
    public void removeAllKey(int key) {
        if(head == null) {
            return;
        }

        ListNode prev = head;
        ListNode cur = head.next;
        while(cur != null) {
            if(cur.val == key) {
                prev.next = cur.next;
            } else {
                prev = cur;
            }
            cur = cur.next;
        }
        
        if(head.val == key) {
            head = head.next;
        }
    }

clear

清空链表,你可以直接把头指针赋值为null,由于链表没有被引用,会被JVM自动回收,

    @Override
    public void clear() {
        ListNode cur = head;
        while(cur != null) {
            ListNode tmp = cur.next;
            cur.next = null;
            cur = tmp;
        }
        head = null;
    }

模拟实现LinkedList

LinkedList 是不带头,双向的,循环的链表

构建节点

双向的意味着有两个节点,一个指向前一个结点,一个指向后一个结点,还有一个头指针指向头节点,一个尾指针指向尾节点。

    static class ListNode {
        public int val;
        public ListNode prev;
        public ListNode next;

        public ListNode(int val) {
            this.val = val;
        }
    }

    public ListNode head;
    public ListNode last;

打印

    public void display() {
        ListNode cur = head;
        while(cur != null) {
            System.out.print(cur.val + " ");
            cur = cur.next;
        }
        System.out.println();
    }

头插

要注意如果头指针为null,意味着链表为空,尾指针自然也是null,链表为空的话,插入新数据要改变头尾指针的指向。
正常情况下是链表至少有一个结点,改变原先头节点的prev指向,新结点的next也要改变。

    public void addFirst(int data) {
        ListNode node = new ListNode(data);
        if(head == null) {
            head = last = node;
            return;
        }

        head.prev = node;
        node.next = head;
        head = node;
    }

尾插

注意如果尾指针为null时,说明链表为空。和上面的头插一样,要单独讨论说明。

    public void addLast(int data) {
        ListNode node = new ListNode(data);
        if(last == null) {
            head = last = node;
        }

        last.next = node;
        node.prev = last;
        last = node;
    }

求结点个数

    public int size() {
        ListNode cur = head;
        int count = 0;
        while(cur != null) {
            count++;
            cur = cur.next;
        }
        return count;
    }

指定位置插入

先判断index是否合法,不合法还是和之前一样抛异常。

public class IndexOutOfBoundException extends RuntimeException {
    public IndexOutOfBoundException() {
        super();
    }

    public IndexOutOfBoundException(String message) {
        super(message);
    }
}
    private void checkIndexInAdd(int index) throws IndexOutOfBoundException{
        if(index < 0 || index > size()) {
            throw new IndexOutOfBoundException("下标越界!!!");
        }
    }

我们先讨论一般情况,如果待插入的结点正好前后都是由结点的,那么我们需要修改三个结点的指针:
cur.prev.next = node;
node.prev = cur.prev;
node.next = cur;
cur.prev = node;
在这里插入图片描述
现在来注意特殊情况,如果index == 0时,就是头插,不管怎么样,头插就一定要改变头指针,所以要单独讨论。换一种思路,如果是头插的话,cur.prev = null ,所以 cur.prev.next 一定会报空指针异常。所以头插还是要单独讨论。
那如果是尾插呢?尾插意味着 cur == null ,还是和头插思考方式一样,尾节点一定要改变所以要单独讨论,还有cur.prev 一定会报空指针异常。

    public void addIndex(int index,int data) {
        try {
            checkIndexInAdd(index);
            if(index == 0) {
                addFirst(data);
                return;
            }

            ListNode node = new ListNode(data);
            ListNode cur = head;
            for (int i = 0; i < index; i++) {
                cur = cur.next;
            }
            if(cur == null) {
                addLast(data);
                return;
            }

            cur.prev.next = node;
            node.prev = cur.prev;
            node.next = cur;
            cur.prev = node;

        } catch (IndexOutOfBoundException e) {
            e.printStackTrace();
        }
    }

remove

删除第一次出现关键字为key的节点

如果链表为空不能继续删除操作
如果删除头节点,就必须改变头指针,所以要单独说明
一般情况下,需要变动cur前后结点,自然会想到:cur.prev.next = cur.next; cur.next.prev = cur.prev;
那如果是尾删呢?上面两行代码只有前面一行还能继续用,由于是尾删,尾节点就要发生改变,所以last = cur.prev;

public void remove(int key) {
        if(head == null) {
            return;
        }

        if(head.val == key) {
            head = head.next;
            if(head != null) {
                head.prev = null;
            }
            return;
        }

        ListNode cur = head.next;
        while(cur != null) {
            if(cur.val == key) {
                cur.prev.next = cur.next;
                if(cur.next == null) {
                    last = cur.prev;
                } else {
                    cur.next.prev = cur.prev;
                }
                return;
            }
            cur = cur.next;
        }
    }

removeAllKey

删除所有值为key的节点

删除所有的key,上面我们写了删除第一次出现key的结点,这里把代码直接帮过来,删掉return就可以继续用,但是一定是对的吗?
前面的链表判空直接返回没有问题,但是头删的话就有问题了,假设头节点是你要删除的结点就意味着头指针要发生改变,那如果新的头节点又要发生改变呢?这里我们选择尽量不改变我们的祖传代码,把头删放在最后面去做即可。

    public void removeAllKey(int key) {
        if(head == null) {
            return;
        }

        ListNode cur = head.next;
        while(cur != null) {
            if(cur.val == key) {
                cur.prev.next = cur.next;
                if(cur.next == null) {
                    last = cur.prev;
                } else {
                    cur.next.prev = cur.prev;
                }
            }
            cur = cur.next;
        }

        if(head.val == key) {
            head = head.next;
            if(head != null) {
                head.prev = null;
            }
        }

    }

contains

是否包含key这个元素

    public boolean contains(int key) {
        ListNode cur = head;
        while(cur != null) {
            if(cur.val == key) {
                return true;
            }
            cur = cur.next;
        }
        return false;
    }

clear

你可以直接将head 和last都置为null,这样链表就会被JVM自动回收。
这里模仿源码的写法,源码是一个一个结点都置为null,最后头尾指针再置为null

    public void clear() {
        ListNode cur = head;
        while(cur != null) {
            ListNode tmp = cur.next;
            cur.prev = null;
            cur.next = null;
            cur = tmp;
        }
        head = last = null;
    }

LinkedList 使用

Java集合类中给我们提供了LinkedList,这是一个无头双向循环链表,我们来看一下它里面的方法,方法名字和上面我们模拟实现的差不多。
在这里插入图片描述

LinkedList 的构造方法

在这里插入图片描述
第二个构造方法是可以传入一个对象,和之前ArrayList表示二维数组是一个意思。

LinkedList 的方法

在这里插入图片描述

要注意LinkedList和ArrayList 的subList是一样的原理,截取的list还是原来的对象list,只是范围不同,并没有创建新的对象。

add(默认尾插)

注意LinkedList的add方默认是尾插

    public static void main(String[] args) {
        LinkedList<Integer> list = new LinkedList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        System.out.println(list);
    }

在这里插入图片描述

addAll

尾插一个对象

    public static void main(String[] args) {
        LinkedList<Integer> list = new LinkedList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        System.out.println(list);
        ArrayList<Integer> list1 = new ArrayList<>();
        list1.add(10);
        list1.add(20);
        list.addAll(list1);
        System.out.println(list);
    }

在这里插入图片描述

遍历链表

直接打印

LinkedList也是重写了toString 方法

    public static void main(String[] args) {
        LinkedList<Integer> list = new LinkedList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        System.out.println(list);
    }

在这里插入图片描述

for 循环

    public static void main(String[] args) {
        LinkedList<Integer> list = new LinkedList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        int size = list.size();
        for (int i = 0; i < size; i++) {
            System.out.print(list.get(i) + " ");
        }
        System.out.println();
    }

在这里插入图片描述

for each

    public static void main(String[] args) {
        LinkedList<Integer> list = new LinkedList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        for(int x : list) {
            System.out.print(x + " ");
        }
        System.out.println();
    }

在这里插入图片描述

迭代器

在这里插入图片描述
ListIterator 是继承 Iterator 的,这两个都可以来遍历链表打印数据。

迭代器的使用可以类似下面的图:
在这里插入图片描述

while(it.hasNext())hasNext表示是否由下一个数据,通过next()方法打印下一个数据,之后 it 一直向后移动。

Iterator
    public static void main(String[] args) {
        LinkedList<Integer> list = new LinkedList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        
        System.out.println("===== Iterator ====");
        Iterator<Integer> it = list.iterator();
        while (it.hasNext()) {
            System.out.print(it.next()+" ");
        }
        System.out.println();
    }

在这里插入图片描述

ListIterator
    public static void main(String[] args) {
        LinkedList<Integer> list = new LinkedList<>();
        list.add(1);
        list.add(2);
        list.add(3);

        ListIterator<Integer> lit =  list.listIterator();
        while (lit.hasNext()) {
            System.out.print(lit.next()+" ");
        }
        System.out.println();
    }

在这里插入图片描述

ListIterator(逆向遍历)
    public static void main(String[] args) {
        LinkedList<Integer> list = new LinkedList<>();
        list.add(1);
        list.add(2);
        list.add(3);

        System.out.println("===== ListIterator ====");
        ListIterator<Integer> lit2 =  list.listIterator(list.size());
        while (lit2.hasPrevious()) {
            System.out.print(lit2.previous()+" ");
        }
        System.out.println();
    }

在这里插入图片描述

listIterator(int n) ,可以指定从哪个下标开始遍历链表

ArrrayList 和 LinkedLisrt 的总结

在这里插入图片描述

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1919622.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

二分查找算法——部分OJ题详解

目录 关于二分查找算法 部分OJ题详解 704.二分查找 一&#xff0c;分析题目 二&#xff0c;细节处理 三&#xff0c;题目代码 四&#xff0c;*总结朴素模板 *34.在排序数组中查找元素的第一个和最后一个位置 一&#xff0c;查找左端点 二&#xff0c;处理左端点细…

ts实现将相同类型的数据通过排序放在一起

看下效果&#xff0c;可以将相同表名称的字段放在一起 排序适用于中英文、数字 // 排序 function sortByType(items: any) {// 先按照类型进行排序items.sort((a: any, b: any) > {if (a.label < b.label) return -1;if (a.label > b.label) return 1;return 0;});r…

【记录】LaTex|LaTex调整算法、公式、表格内的字体大小(10种内置字号)

文章目录 【记录】LaTex&#xff5c;LaTex调整算法、公式、表格内的字体大小&#xff08;10种内置字号&#xff09;省流版1 字体大小2 测试代码 详细版1 \tiny2 \scriptsize3 \footnotesize4 \small5 \normalsize6 \large7 \Large8 \LARGE9 \huge10 \Huge 【记录】LaTex&#x…

实验02 黑盒测试(组合测试、场景法)

1. 组合测试用例设计技术 指出等价类划分法和边界值分析法通常假设输入变量相互独立&#xff0c;但实际情况中变量间可能存在关联。全面测试&#xff1a;覆盖所有输入变量的所有可能组合&#xff0c;测试用例数量随输入变量的增加而指数增长。 全面测试需要对所有输入的各个取…

Geoserver源码解读六 插件

系列文章目录 Geoserver源码解读一 环境搭建 Geoserver源码解读二 主入口 Geoserver源码解读三 GeoServerBasePage Geoserver源码解读四 REST服务 Geoserver源码解读五 Catalog Geoserver源码解读六 插件&#xff08;怎么在开发模式下使用&#xff09; 目录 系列文章目…

ubuntu计划任务反弹

目录 实验环境 实验步骤 目标主机构造任务计划 构造语句 语句解释 kali开启监听 监听成功 问题 原因 实验环境 攻击者 操作系统&#xff1a;kali IP&#xff1a;192.168.244.141 目标主机 操作系统&#xff1a;ubuntu IP&#xff1a;192.168.244.151 实验步骤 目…

CSS 中的 ::before 和 ::after 伪元素

目录 一、CSS 伪元素 二、::before ::after 介绍 1、::before 2、::after 3、content 常用属性值 三、::before ::after 应用场景 1、设置统一字符 2、通过背景添加图片 3、添加装饰线 4、右侧展开箭头 5、对话框小三角 6、插入icon图标 一、CSS 伪元素 CSS伪元…

数据库使用SSL加密连接

简介 数据库开通SSL加密连接是确保数据传输过程中安全性的关键措施&#xff0c;它通过加密数据、验证服务器身份、保护敏感信息、维护数据完整性和可靠性&#xff0c;同时满足行业标准和法规要求&#xff0c;进而提升用户体验和信任度&#xff0c;为企业的数据安全和业务连续性…

做工和音质都堪称典范!悠律Ringbuds pro耳机动感低音享受

想要长时间佩戴舒适&#xff0c;又要听歌看电影音质好&#xff0c;还想户外运动时不影响听到环境声音&#xff0c;开放式毋容置疑是最好的选择&#xff0c;像我每天坐地铁上下班的时候都会习惯戴耳机&#xff0c;但以前戴入耳式耳机的时候经常会错过站点&#xff0c;耽误了不少…

TREK高压功率放大器维修trek高压电源609E-6

美国Trek维修产品包括&#xff1a;高压放大器、电源、静电电压表、高压功能发生器、放大器、静电测量仪、荷电板、信号放大器、高压电源、功率放大器、高压功能发生器、高压放大器、接触电压表、非接触式电压表、板载控制器、ESD和传感器、ESD电压表、带电板、电离器、表面电阻…

HashMap----源码解读

源码分析&#xff1a; public class HashMap<K,V> extends AbstractMap<K,V>implements Map<K,V>, Cloneable, Serializable 在类的开头声明了几个常量&#xff0c;以下是较为重要的&#xff1a; /*** 定义初始容量大小为16*/ static final int DEFAULT_I…

【射频器件供应】 Marki Microwave

射频、微波和毫米波组件 裸片、表面贴装、连接器和波导 直流至Sub-THz Marki Microwave通过创建强大的性能突破性射频和微波组件产品组合&#xff0c;解决了业界最棘手的技术问题。Marki Microwave 成立于 1991 年&#xff0c;以开发业内最好的混频器为目标&#xff0c;如今已…

kaggle提交csv文件

使用colab完成实验后 将要提交的csv文件拖到kaggle网站自己加入的competition就行 如果kaggle网站无法注册&#xff0c;往往是人机验证问题&#xff1a; kaggle网站验证

6-5,web3浏览器链接区块链(react+区块链实战)

6-5&#xff0c;web3浏览器链接区块链&#xff08;react区块链实战&#xff09; 6-5 web3浏览器链接区块链&#xff08;调用读写合约与metamask联动&#xff09; 6-5 web3浏览器链接区块链&#xff08;调用读写合约与metamask联动&#xff09; 这里就是浏览器端和智能合约的交…

语言模型演进:从NLP到LLM的跨越之旅

在人工智能的浩瀚宇宙中&#xff0c;自然语言处理&#xff08;NLP&#xff09;一直是一个充满挑战和机遇的领域。随着技术的发展&#xff0c;我们见证了从传统规则到统计机器学习&#xff0c;再到深度学习和预训练模型的演进。如今&#xff0c;我们站在了大型语言模型&#xff…

【最经典的79个】软件测试面试题(内含答案)提前备战“金九银十”

001.软件的生命周期(prdctrm) 计划阶段(planning)-〉需求分析(requirement)-〉设计阶段(design)-〉编码(coding)->测试(testing)->运行与维护(running maintrnacne) 测试用例 用例编号 测试项目 测试标题 重要级别 预置条件 输入数据 执行步骤 预期结果 0002.问&…

ctfshow-web入门-文件上传(web166、web167)(web168-web170)免杀绕过

目录 1、web166 2、web167 3、web168 4、web169 5、web170 1、web166 查看源码&#xff0c;前端只让传 zip 上传 zip 成功后可以进行下载 随便搞一个压缩包&#xff0c;使用记事本编辑&#xff0c;在其内容里插入一句话木马&#xff1a; 上传该压缩包&#xff0c;上传成功…

LLM独角兽们就要活不下去了!C.AI被资本抛弃,核心员工跑路;Perplexity陷入传统媒体口水战;微软发明的新型收购方式靠谱么?| ShowMeAI日报

&#x1f440;日报&周刊合集 | &#x1f3a1;ShowMeAI官网 | &#x1f9e1; 点赞关注评论拜托啦&#xff01; 1. 亚马逊「招聘式收购」Adept AI&#xff0c;始作俑者微软正在被联邦「反垄断」调查 Adept AI 官网 → https://www.adept.ai Adept AI 成立于2022年4月&#xf…

分享一个 .NET 通过监听器拦截 EF 消息写日志的详细例子

前言 EF 开发效率确实很高也很便捷&#xff0c;但当它发生错误时&#xff0c;也挺让人头疼的&#xff0c;为什么&#xff1f;因为 EF 就像是一个黑盒子&#xff0c;一切全被封装起来&#xff0c;出错的时候很难定位原因&#xff0c;如果能够知道并打印 EF 生成的 SQL 语句&…

历年HW已公开漏洞合集!(目前漏洞库更新至84个,Goby持续更新...)

截至2024年7月11日&#xff0c;Goby红队版已扩充以下历年HW已公开漏洞库&#xff0c;本次更新84个&#xff1a; &#xff08;后续将持续更新…) 华天动力OA 华天动力 OA getHtmlContent 文件读取漏洞华天动力OA办公系统 /OAapp/bfapp/buffalo/TemplateService 文件读取漏洞华…