数据结构与算法之双向链表

前言

前文介绍了单向链表,相信大家也了解单向链表是一个怎样的数据结构,除了单向链表,链表还有双向链表。
从名字也可以看出来,双向链表就是不止于从前(头部)向后(尾部)寻址,还可以从后向前寻址,显而易见的是,这样的数据结构,在某些场景,比如从尾部插入,删除等,比单向链表更有效率,相应的,由于每一个结点中存储了两个结点的地址,所以,双向链表比单向链表需要更大的空间,其实,这便是数据结构中空间换时间的思想。
在实际的应用中,并没有绝对最好的数据结构这样的说法, 事实上,根据场景的不同,选择合适的数据结构,合理平衡空间与时间是最有益的。

双向链表

对于链表来说,仍然是理解结点以及结点寻址!
以下代码均由java实现:

/**
 * @program: thinking-in-all
 * @description: 双向链表
 * @author: Lucifinil
 * @create: 2019-12-30
 **/
public class TwoWayLinkedList<T> {
      
}

Node

对于双向链表的结点,与单向链表的结点相比,其区别就是结点存储两个地址,分别指向前驱和后继。
在这里插入图片描述
使用java来实现:

    private class Node {
        T value;
        Node precursor;
        Node successor;
        public Node(T value, Node precursor, Node successor) {
            this.value = value;
            this.precursor = precursor;
            this.successor = successor;
        }
        Node(T value) {
            this(value, null, null);
        }
        Node() {
            this(null, null, null);
        }
        @Override
        public String toString() {
            return value.toString();
        }
    }
属性

从上图的结点,我们很容易能想象到双向链表的样子:

在这里插入图片描述
可以看到,有两个特殊结点head和tail,head只存储后继结点地址,而tail只存储前驱结点地址,中间的所有node都存储前驱和后继结点的地址。
同样的,头结点和尾结点我们可以使用虚拟结点来表示,这样做的好处是:避免了在空链表或者只有一个结点时,head和tail的重叠,以及创建和删除结点时的复杂操作。
我们可以看看其区别,没有虚拟头尾结点:
在这里插入图片描述
我们可以看到,添加一个第一个结点,第二个结点,以及第三个及以后结点是三种不同的情况,究其原因,便是我们需要对头尾结点进行单独处理,而有虚拟头尾结点:

在这里插入图片描述
我们无论添加多少个结点,都是从头尾结点中间添加,不涉及到对头尾结点的操作,唯一的缺点的是多占了两个node的空间,微乎不计…
下面,用代码来实现虚拟头结点和尾结点!

    //虚拟头结点
    private Node dummyHead;   
    //虚拟尾结点结点
    private Node dummyTail;

如此简单,如此美妙!
除此之外,元素数量是我们必不可少的属性!

    //链表元素数量
    private int size;
构造器

其实,链表本身的构造器很简单,因为链表是真正意义上的动态数据结构,它的创建不需要预先设置一个大小,我们需要给其固有的属性赋予初始值,并且让头尾结点指向彼此形成空链表的初始状态:在这里插入图片描述

    public TwoWayLinkedList() {
        dummyHead = new Node();
        dummyTail = new Node();
        dummyHead.successor = dummyTail;
        dummyTail.precursor = dummyHead;
        size = 0;
    }
辅助函数

获取元素数量,判空,是否包含元素等是各个数据结构几乎都有的辅助函数:

获取元素数量:

    //获取链表元素数量
    public int size() {
        return size;
    }

判断链表是否为空:

    //获取链表是否为空链表
    public boolean isEmpty() {
        return size == 0;
    }

这两个函数很简单,获得元素数量,直接返回size就可以了,我们需要注意的是对size的维护,判断是否为空,只要判断size是否尾0。
是否包含元素e :

    //判断链表中是否包含元素e
    public boolean contains(T e) {
        Node cur = dummyHead.successor;
        while (cur != null) {
            if (cur.value.equals(e)) {
                return true;
            }
            cur = cur.successor;
        }
        return false;
    }

这里我们需要注意的是,是否包含元素e这个方法,虽然双向链表相较于单向链表增加了尾结点,但事实上这个方法我们不知道元素e在链表更靠近头部还是尾部,所以,这里我沿用了单向链表的实现,从头部遍历,直到找到该元素或者遍历全部,当然,我们也可以选择从尾部遍历,甚至,两端一起遍历:

    public boolean contains(T e) {
        Node cur1 = dummyHead.successor;
        Node cur2 = dummyTail.precursor;
        while (true) {
            if (cur1.value.equals(e)) {
                return true;
            }
            if (cur2.value.equals(e)){
                return true;
            }
            cur1 = cur1.successor;
            if (cur1.equals(cur2)){
                return false;
            }
            cur2 = cur2.precursor;
        }
    }
增加与删除结点

其实,看到这里,很容易可以想到双向链表的增删结点怎么完成的,无非是在单向链表的基础上,多改变一次结点的指向地址!
增加结点:
在这里插入图片描述
从上图可以看到,增加结点,无论是头部还是尾部增加,都是改变新增结点的前驱结点和后继结点的地址指向,只不过与单向链表不同的是,它是改变两个地址,而且它的遍历是可选择的,不再是一定要通过头部来寻址,它也可以通过尾部来寻址。
越靠近头部的便从头部开始遍历,越靠近尾部的便从尾部开始遍历,这样可以保证寻址效率更高:

    public void add(int index, T e) {
        if (index < 0 || index > size) {
            throw new IllegalArgumentException("Add failed.Illegal index.");
        }
        if (index <= size / 2) {
            Node prev = dummyHead;
            for (int i = 0; i < index; i++) {
                //获得新增结点的前驱
                prev = prev.successor;
            }
            //创建新增结点
            Node node = new Node(e);
            //获得新增结点的后继
            Node succ = prev.successor;

            //为了结构清晰,这里把新增操作的三个结点都表示出来

            //使新增结点的后继指向succ
            node.successor = succ;
            //使新增结点的前驱指向prev
            node.precursor = prev;

            //此刻新增结点已经添加进去了,但是其前驱和后继的地址还有待修正

            //修正前驱结点,使其后继指向node,而非succ
            prev.successor = node;
            //修正后继结点,使其前驱指向node,而非prev
            succ.precursor = node;

        } else {
            Node succ = dummyTail;
            for (int i = size - 1; i > index; i--) {
                //同上,只是从尾部开始寻址!
                succ = succ.precursor;
            }
            Node node = new Node(e);
            Node prev = succ.precursor;

            node.precursor = prev;
            node.successor = succ;

            succ.precursor = node;
            prev.successor = node;
        }
        size++;
    }

代码中的关键步骤,已经给出说明,这里不再赘述!其实上述代码是可以简化的,数据结构与算法的学习,在前期不太熟悉的时候,写清晰每一步,有助于我们的理解!不过,我们很容易地观察到,除了找到三个相关结点(新增结点,前驱后继)的方式有所差异,后续操作完全相同,优化后的代码为:

       public void add(int index, T e) {
            if (index < 0 || index > size) {
                throw new IllegalArgumentException("Add failed.Illegal index.");
            }
            Node prev;
            //创建新增结点
            Node node = new Node(e);
            Node succ;
            if (index <= size / 2) {
                prev = dummyHead;
                for (int i = 0; i < index; i++) {
                    //获得新增结点的前驱
                    prev = prev.successor;
                }
                //获得新增结点的后继
                succ = prev.successor;
            } else {
                succ = dummyTail;
                for (int i = size; i > index; i--) {
                    //同上,只是从尾部开始寻址!
                    //获得新增结点的后继
                    succ = succ.precursor;
                }
                //获得新增结点的前驱
                prev = succ.precursor;
            }

            //为了结构清晰,这里把新增操作的三个结点都表示出来

            //使新增结点的后继指向succ
            node.successor = succ;
            //使新增结点的前驱指向prev
            node.precursor = prev;

            //此刻新增结点已经添加进去了,但是其前驱和后继的地址还有待修正

            //修正前驱结点,使其后继指向node,而非succ
            prev.successor = node;
            //修正后继结点,使其前驱指向node,而非prev
            succ.precursor = node;

            size++;
        }

删除结点:
在这里插入图片描述
从图片可以看到,被删除结点,所有与其相关引用全部置空,其前驱与后继建立相互引用!
代码如下:

     public T remove(int index){
        if (isEmpty()) {
            throw new IllegalArgumentException("Remove failed.Linklist is empty.");
        }
        if (index < 0 || index >= size) {
            throw new IllegalArgumentException("Remove failed.Illegal index.");
        }
        if (index <= size / 2) {
            Node prev = dummyHead;
            for (int i = 0; i < index; i++) {
                //获得待删除结点的前驱
                prev = prev.successor;
            }

            //获得待删除结点
            Node node = prev.successor;
            //获得待删除结点的后继
            Node succ = node.successor;

            //同样的,我们获得删除操作所涉及的三个结点,更加清晰地进行结点的删除操作

            //将待删除结点的前驱结点的后继指向待删除结点的后继结点
            //这句话需要好好理解,也就是说前驱结点跳过了待删除结点指向后继结点,在图中可以看到
            prev.successor = succ;
            //同样的,将待删除结点的后继结点的前驱指向待删除结点的前驱结点
            //经过这样两步,这样结点就在链表中被"剔除"了
            succ.precursor = prev;

            //由于java的内存是GC管理,但存在引用的对象,是可能继续占用内存的
            //养成良好的编码习惯,将不适用的对象引用置空
            node.precursor = null;
            node.successor = null;
            size--;
            return node.value;
        }else {
            Node succ = dummyTail;
            for (int i = size - 1; i > index; i--) {
                //同理
                succ = succ.precursor;
            }
            Node node = succ.precursor;
            Node prev = node.precursor;

            node.precursor = null;
            node.successor = null;

            prev.successor = succ;
            succ.precursor = prev;
            size--;
            return node.value;
        }
    }

代码中的关键步骤已经用注释说明,这样我们需要注意的是,对于链表中管理索引的步骤,应当小心警惕!
另外,经过观察可以看到,文中有大量重复代码,经过优化后:

       public T remove(int index) {
            if (isEmpty()) {
                throw new IllegalArgumentException("Remove failed.Linklist is empty.");
            }
            if (index < 0 || index >= size) {
                throw new IllegalArgumentException("Remove failed.Illegal index.");
            }
            //我们获得删除操作所涉及的三个结点,更加清晰地进行结点的删除操作
            Node prev;
            Node node;
            Node succ;
            if (index <= size / 2) {
                prev = dummyHead;
                for (int i = 0; i < index; i++) {
                    //获得待删除结点的前驱
                    prev = prev.successor;
                }
                //获得待删除结点
                node = prev.successor;
                //获得待删除结点的后继
                succ = node.successor;
            } else {
                succ = dummyTail;
                for (int i = size-1 ; i > index; i--) {
                    //同理
                    succ = succ.precursor;
                }
                node = succ.precursor;
                prev = node.precursor;
            }

            //将待删除结点的前驱结点的后继指向待删除结点的后继结点
            //这句话需要好好理解,也就是说前驱结点跳过了待删除结点指向后继结点,在图中可以看到
            prev.successor = succ;
            //同样的,将待删除结点的后继结点的前驱指向待删除结点的前驱结点
            //经过这样两步,这样结点就在链表中被"剔除"了
            succ.precursor = prev;

            //由于java的内存是GC管理,但存在引用的对象,是可能继续占用内存的
            //养成良好的编码习惯,将不适用的对象引用置空
            node.precursor = null;
            node.successor = null;
            size--;
            return node.value;
        }

代码的优化,说明了一个关键点,从图中我们也能观察到这个结论,那就是删除结点,除了遍历方式不同,删除结点的方式,修正前驱后继结点引用的方式都是相同的!

查改元素

到了这样,我觉得小伙伴们想到修改与查询元素是very easy滴,上图!

在这里插入图片描述
从图上,我们可以看到双向链表的修改与单向链表几乎是一样的,不同的只是双向链表寻址的方式可以从尾部开始遍历,结合上文的增删结点,我们可以得到查改的代码:
查询:

public T get(int index ){
        if (isEmpty()) {
            throw new IllegalArgumentException("Get failed.Linklist is empty.");
        }
        if (index < 0 || index >= size) {
            throw new IllegalArgumentException("Get failed.Illegal index.");
        }
        Node cur;
        if (index <= size / 2) {
            cur = dummyHead.successor;
            for (int i = 0; i < index; i++) {
                cur = cur.successor;
            }
        } else {
            cur = dummyTail.precursor;
            for (int i = size-1; i >index ; i--) {
                cur = cur.precursor;
            }
        }
        return cur.value;
    }

修改:

    public void set(int index, T e) {
        if (isEmpty()) {
            throw new IllegalArgumentException("Set failed.Linklist is empty.");
        }
        if (index < 0 || index >= size) {
            throw new IllegalArgumentException("Set failed.Illegal index.");
        }
        Node cur;
        if (index <= size / 2) {
            cur = dummyHead.successor;
            for (int i = 0; i < index; i++) {
                cur = cur.successor;
            }
        } else {
            cur = dummyTail.precursor;
            for (int i = size-1; i >index ; i--) {
                cur = cur.precursor;
            }
        }
        cur.value = e;
    }
头尾操作

既然双向链表头部和尾部的操作效率都是O(1),我们可以利用其特性,复用上文实现的函数,实现对头尾的crud操作:
头部操作:

    public void addFirst(T e){
        add(0,e);
    }
    public T removeFirst(){
        return remove(0);
    }
    public T getFirst(){
        return get(0);
    }
    public void setFirst(T e){
        set(0,e);
    }

尾部操作:

     public void addLast(T e) {
        add(size , e);
    }

    public T removeLast() {
        return remove(size-1);
    }

    public T getLast() {
        return get(size-1 );
    }

    public void setLast(T e) {
        set(size-1 , e);
    }

因为添加操作是在index所在位置添加,在尾部添加其实是对一个不存在的元素进行添加,该位置是index等于size的地方!
我相信,小伙伴们对这种头部和尾部的操作一定不会陌生!因为栈,队列等结构便是涉及头部尾部的操作!
我们可以使用双向链表封装自己的栈和队列等!

完整代码

以下代码已经经过测试,小伙伴们放心食用,不要忘了点赞哟!–来自帅帅的路西菲尔:)

    package com.lucifer.linkedlist;

    /**
     * @program: thinking-in-all
     * @description: 双向链表
     * @author: Lucifinil
     * @create: 2019-12-30
     **/
    public class TwoWayLinkedList<T> {

        private class Node {
            T value;
            Node precursor;
            Node successor;

            Node(T value, Node precursor, Node successor) {
                this.value = value;
                this.precursor = precursor;
                this.successor = successor;
            }

            Node(T value) {
                this(value, null, null);
            }

            Node() {
                this(null, null, null);
            }

            @Override
            public String toString() {
                return value.toString();
            }
        }

        //虚拟头结点
        private Node dummyHead;
        //虚拟尾结点结点
        private Node dummyTail;
        //链表元素数量
        private int size;

        public TwoWayLinkedList() {
            dummyHead = new Node();
            dummyTail = new Node();
            dummyHead.successor = dummyTail;
            dummyTail.precursor = dummyHead;
            size = 0;
        }

        //获取链表元素数量
        public int size() {
            return size;
        }

        //获取链表是否为空链表
        public boolean isEmpty() {
            return size == 0;
        }

        public boolean contains(T e) {
            Node cur1 = dummyHead.successor;
            Node cur2 = dummyTail.precursor;
            while (true) {
                if (cur1.value.equals(e)) {
                    return true;
                }
                if (cur2.value.equals(e)) {
                    return true;
                }
                cur1 = cur1.successor;
                if (cur1.equals(cur2)) {
                    return false;
                }
                cur2 = cur2.precursor;
            }
        }

        public void add(int index, T e) {
            if (index < 0 || index > size) {
                throw new IllegalArgumentException("Add failed.Illegal index.");
            }
            Node prev;
            //创建新增结点
            Node node = new Node(e);
            Node succ;
            if (index <= size / 2) {
                prev = dummyHead;
                for (int i = 0; i < index; i++) {
                    //获得新增结点的前驱
                    prev = prev.successor;
                }
                //获得新增结点的后继
                succ = prev.successor;
            } else {
                succ = dummyTail;
                for (int i = size; i > index; i--) {
                    //同上,只是从尾部开始寻址!
                    //获得新增结点的后继
                    succ = succ.precursor;
                }
                //获得新增结点的前驱
                prev = succ.precursor;
            }

            //为了结构清晰,这里把新增操作的三个结点都表示出来

            //使新增结点的后继指向succ
            node.successor = succ;
            //使新增结点的前驱指向prev
            node.precursor = prev;

            //此刻新增结点已经添加进去了,但是其前驱和后继的地址还有待修正

            //修正前驱结点,使其后继指向node,而非succ
            prev.successor = node;
            //修正后继结点,使其前驱指向node,而非prev
            succ.precursor = node;

            size++;
        }

            public T remove(int index) {
                if (isEmpty()) {
                    throw new IllegalArgumentException("Remove failed.Linklist is empty.");
                }
                if (index < 0 || index >= size) {
                    throw new IllegalArgumentException("Remove failed.Illegal index.");
                }
                //我们获得删除操作所涉及的三个结点,更加清晰地进行结点的删除操作
                Node prev;
                Node node;
                Node succ;
                if (index <= size / 2) {
                    prev = dummyHead;
                    for (int i = 0; i < index; i++) {
                        //获得待删除结点的前驱
                        prev = prev.successor;
                    }
                    //获得待删除结点
                    node = prev.successor;
                    //获得待删除结点的后继
                    succ = node.successor;
                } else {
                    succ = dummyTail;
                    for (int i = size-1 ; i > index; i--) {
                        //同理
                        succ = succ.precursor;
                    }
                    node = succ.precursor;
                    prev = node.precursor;
                }
    
                //将待删除结点的前驱结点的后继指向待删除结点的后继结点
                //这句话需要好好理解,也就是说前驱结点跳过了待删除结点指向后继结点,在图中可以看到
                prev.successor = succ;
                //同样的,将待删除结点的后继结点的前驱指向待删除结点的前驱结点
                //经过这样两步,这样结点就在链表中被"剔除"了
                succ.precursor = prev;
    
                //由于java的内存是GC管理,但存在引用的对象,是可能继续占用内存的
                //养成良好的编码习惯,将不适用的对象引用置空
                node.precursor = null;
                node.successor = null;
                size--;
                return node.value;
            }

        public void set(int index, T e) {
            if (isEmpty()) {
                throw new IllegalArgumentException("Set failed.Linklist is empty.");
            }
            if (index < 0 || index >= size) {
                throw new IllegalArgumentException("Set failed.Illegal index.");
            }
            Node cur;
            if (index <= size / 2) {
                cur = dummyHead.successor;
                for (int i = 0; i < index; i++) {
                    cur = cur.successor;
                }
            } else {
                cur = dummyTail.precursor;
                for (int i = size - 1 ; i > index; i--) {
                    cur = cur.precursor;
                }
            }
            cur.value = e;
        }

        public T get(int index) {
            if (isEmpty()) {
                throw new IllegalArgumentException("Get failed.Linklist is empty.");
            }
            if (index < 0 || index >= size) {
                throw new IllegalArgumentException("Get failed.Illegal index.");
            }
            Node cur;
            if (index <= size / 2) {
                cur = dummyHead.successor;
                for (int i = 0; i < index; i++) {
                    cur = cur.successor;
                }
            } else {
                cur = dummyTail.precursor;
                for (int i = size - 1; i > index; i--) {
                    cur = cur.precursor;
                }
            }
            return cur.value;
        }

        public void addFirst(T e) {
            add(0, e);
        }

        public T removeFirst() {
            return remove(0);
        }

        public T getFirst() {
            return get(0);
        }

        public void setFirst(T e) {
            set(0, e);
        }

        public void addLast(T e) {
            add(size, e);
        }

        public T removeLast() {
            return remove(size - 1);
        }

        public T getLast() {
            return get(size - 1);
        }

        public void setLast(T e) {
            set(size - 1, e);
        }

        @Override
        public String toString() {
            StringBuilder res = new StringBuilder();
            res.append("head [ ");
            Node cur = dummyHead.successor;
            for (int i = 0; i < size; i++) {
                res.append(cur.toString()).append(" <-> ");
                cur = cur.successor;
            }
            res.append("tail ]");
            return res.toString();
        }
    }


总结

链表的形式多种多样,不止于此,除了这样的双向链表,还有单向环链表,双向环链表等等。
除了前文提到的使用循环遍历来寻址,链表和树这样的动态结构,是具有递归性质的,我们可以使用递归来实现。
其实,就算是基础的数据结构,我们在剖析与实现的过程中,也会学习到一些思想,对于双向链表来说,以空间换时间就是对于单向链表效率的进一步提升,当然,除了这样的思路,数据结构和算法,还有许许多多的方法和技巧,我觉得这就是数据结构与算法 的魅力所在。
最后,今天是2020年的最后一天,希望我与小伙伴们以及我爱的人们都能身体健康,万事如意!也期待未来的一年能得到更好的发展,能将学习作为自己的事业坚持下去!
我是帅气的路西菲尔,期待与你一同成长:)
如有不正,敬请指出,原文来自路西菲尔https://blog.csdn.net/csdn_1364491554/article/details/103768706

  • 4
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值