数据结构与算法--使用Java实现循环双链的LinkedList

上一篇博客中,使用Java实现了单链的LinkedList,博客链接如下:

数据结构与算法–使用Java实现单链的LinkedList

但我们知道,在Java源码里LinkedList是通过循环双链实现的,
并非上篇博客中介绍的单链.
为了能更深入的LinkedList
本篇博客将采用循环双链实现LinkedList的基本功能.

本篇博客所涉及到的代码,均已上传到github:
项目github链接
本篇博客涉及代码github链接

本篇博客要点如下:

双向链表简介

使用Java代码实现循环双链链的LinkedList

一. 双链表简介

LinkedList是和ArrayList一样重要的一种数据结构
我们常说:
LinkedList的底层数据结构是链表,查询慢,增删快,线程不安全,效率高
如果我们想要知道,它为什么具有这样的特性,就需要对链表这种数据结构有一个进一步的了解

双链表概念

双向链表也叫双链表,是链表的一种.
它的每个数据结点中都有两个指针,分别指向直接后继和直接前驱.
所以,从双向链表中的任意一个结点开始,都可以很方便地访问它的前驱结点和后继结点。

双链表存储结构

请看下面的图片:
在这里插入图片描述
从图片中可以看到:
对于双向链表的每一个元素,包含了两个指针域和一个数据域.
前指针指向该元素的直接前驱,后指针指向该节点的直接后继.
对于普通双向链表来说:头结点的前指针为空,尾节点的后指针为空
对于循环双向链表来说:头结点的前指针指向尾结点,尾结点的后指针指向前节点

我们分析下普通双向链表这种存储结构具有什么优点和缺点呢?

优点:

1.不存在闲置的情况
每一个节点,都包含了一个数据域,指向后继节点的指针域,指向前驱节点的指针域.
没有闲置.
从这个角度来说,节约内存资源

缺点:

1.操作难度大,理解成本高
和数组相比,链表确实更加抽象一些
对于我这种不够聪明的人来说,学习链表的成本更高!

2.查询效率低
普通双向链表的查询,只能从头结点开始遍历,根据指针域一个一个获取元素,
查询操作的时间复杂度为T(n)=O(n)

3.除了数值域,还要为指针域分配额外的空间

看到这里,读者应该会有疑问, 为什么我列举优点的时候没有将增删性能高包含进来呢?

仔细思考下,如果我们操作普通的双向链表,进行增删操作(默认是添加到末尾元素之后,删除末尾元素)
进行添加操作时,理论上 : 只需要将原末尾元素的后指针改为新添加的元素, 将新增加元素的前指针指向原末位尾元素即可
进行删除操作时, 理论上: 只需要将原末尾元素前驱节点的后指针置为空

但是我们有没有想过: 该如何找到该链表的末尾元素呢?
对于非循环的双向链表来说, 只能通过首节点遍历,
真正进行增删操作的时间复杂度为O(1), 但是遍历的时间复杂度却为O(n),
并不会比用数组实现的ArrayList增删操作更快哦!

双向循环链表的优势
1.基于头结点,和尾结点的删除和插入操作,时间复杂度为O(1)
2.根据索引进行查询性能优于普通双向链表
首先,普通双向链表和双向循环链表进行查询操作的时间复杂度T(n)=O(n)
但循环双向链表可以根据索引i的位置,来决定是从前向后遍历(离头结点近)
还是从后向前遍历(离尾结点近),平均查询每个元素执行的运算次数为:(1 + 1/2 n) /2
而普通的双向链表只能从头结点开始遍历,平均查询每个元素执行的运算次数为:(1 + n) / 2
平均查询性能为普通双向链表的2倍

二.使用Java代码实现双向循环链表LinkedList

基于上述的了解: 我们可以开始试着手写双向循环链表LinkedList的基本功能
为了更贴近源码的实现,代码里添加了泛型
因此,List接口,和代码的实现和之前两篇博客介绍的有所区别,但总体思路,基本功能一致

集合大小,非空判断,增加元素,删除元素,修改元素,获取元素

我们把上面列举出来的每一种操作封装为一个方法,可以得到一个接口,如下:

List接口
public interface MyList<E> {
    // 返回线性表的大小,即数据元素的个数
    int size();
    // 返回线性表序号为i的数据元素
    E get(int i);
    // 判断线性表是否包含数据元素e
    boolean contains(Object e);
    // 线性表为空返回true,否则返回false
    boolean isEmpty();
    // 返回数据元素e在线性表中的序号
    int indexOf(Object e);

    // 将元素e插入到线性表末尾
    boolean add(E e);


    // 删除线性表中序号为i的元素,并返回
    E remove(int i);
    // 删除线性表中第一个与a相同的元素
    boolean remove(Object e);
    // 替换线性表中序号为i的数据元素为e,返回原数据元素
    E replace(int i, E e);
}
Node类的引入

链表中的每个元素, 包含一个数据域和两个指针域(一个指向前驱节点,一个指向后继节点)
为了更形象的描述链表结构,我们把它抽象成一个实体类Node
用泛型E类型的data来表示数据域
用Node类型的数据next来表示链表的后指针
用Node类型的数据prev来表示链表的前指针
为了更贴合源码,我们把Node类设计成为LinkedList实现类里的一个静态内部类
如下面的代码所示:


/**
 * @author xmr
 * @description 单链表 只能通过前驱节点找到后继节点,不能通过后继节点找到前驱节点
 */
public class Node {

      private  static class Node<E> {
        Node<E> prev; // 指向前一个节点的指针
        Node<E> next; // 指向后一个节点的指针
        E data; // 数据域
        Node(Node<E> prev, E element, Node<E> next) {
            this.data = element;
            this.prev = prev;
            this.next = next;
        }
    }

实现循环双链的LinkedList

我们需要三个成员变量来表示
LinkedList的头结点,Linked的尾结点,Linked的大小

 	Node<E> first; // 集合的首节点
    Node<E> last; // 集合的尾结点
    int size; // 集合大小
获取集合大小

成员变量size表示集合大小,返回它即可

  @Override
    public int size() {
        return size;
    }
获取集合元素

获取集合指定索引元素的数据域

     @Override
    public  E get(int i) {
    	// 首先要进行参数检查
        checkIndex(i);
        // 按照惯性,我们会想到从头开始遍历链表,但对于双向循环链表来说? 这种方式真的合适?
        // 我们判断索引离首节点近还是离尾结点近,离哪个节点近就从哪个节点开始遍历

        if (i < (size >> 1)) {
            // 证明离首结点更近,从首节点开始向后遍历
            Node<E> p = first;
            for (int j = 0; j < i; j++) {
                p = p.next;
            }
            return p.data;
        } else {
            // 证明离尾结点更近,从尾结点开始向前遍历
            Node<E> p = last;
            for (int j = size - 1; j > i; j--) {
                p = p.prev;
            }
            return p.data;
        }
    }

从代码中我们可以看到,链表查询的性能并不高,时间复杂度:T(n)=O(n)

获取指定元素第一次出现的索引

    @Override
    public int indexOf(Object e) {
        Node p = head.next; // 这里p指的是链表的真正意义上的第一个元素,因为头结点的数据域永远为Null
      for (int i = 0; i < size; i++) {
      	  // 首先针对空值进行判断,以免出现空指针异常
          if (e == null) {
              if (p.data == null) {
                  return i;
              }
          } else {
              if (e.equals(p.data)) {
                  return i;
              }
          }
          p = p.next;
      }
      // 在链表中找不到对应的元素,返回-1
      return -1;
    }
判断集合是否包含指定元素
   @Override
    public boolean contains(Object e) {
     
      // 若集合包含某个元素,则该元素在集合里对应的索引应该大于等于0
      // 所以,我们在这里复用上面的indexOf方法,实现如下:
      return indexOf(e) >= 0;
    }
判断集合是否为空
@Override
    public boolean isEmpty() {
    	// 集合为空,代表集合大小为0
        return size == 0;
    }
集合数据添加

我们常用的LinkedList的添加操作,默认是添加在集合末尾
下面我们就以这种常用的添加方式举例,感受下为什么LinkedList在数据添加上性能优越:

	/*
		添加元素到集合末尾
	*/
    @Override
    public boolean add(E e) {

        final Node<E> l = last; // 用一个临时变量存放集合尾指针,因为之后尾指针要重新赋值
        final Node<E> newNode = new Node<>(l, e, null); // 创建一个新节点,数据域是e,前指针为之前的尾元素,后指针为空
        last = newNode; // 因为插入到集合的最后面, 所以新插入的元素才是当前集合的尾元素
        if (l == null)
            first = newNode; // 若前尾指针为空,则集合是第一次插入数据,当前节点也是头结点
        else
            l.next = newNode; // 将前尾节点的尾指针指向新的节点
        size++; // 集合元素添加
        return true;
    }

上面的代码实现实际上是很精妙的, 用很简洁的代码, 实现了LinkedList核心的操作
为了方便大家理解,我自己画了个图来模拟代码执行的状态,以给一个LinkedList添加
3个元素"12",’‘23’’,"34"为例
在这里插入图片描述从上面的图片中大家应该能够更直观的感受到,这段代码从头到尾的执行状态:
注意: 每个add方法执行完毕后在栈中的空间会释放(我这里为了方便大家理解没有擦除)
从代码中我们可以看到, 双向循环链表尾端添加元素的时间复杂度为O(1),效率非常高!

集合数据删除
	/*
		删除集合对应索引的元素,返回旧值
		删除操作的关键点 : 将要删除元素的前驱节点的指针直接指向要删除元素的后继节点
	*/
	@Override
    public Object remove(int i) {
        if (i >= size || i < 0) {
            throw new RuntimeException("指针越界! " + i);
        }
        Node p = head;
        // 找到索引i的前一个元素
        for (int j = 0 ; j < i; j++) {
            p = p.next;
        }
        Node node = p.next; // 索引i所在位置的元素
        // 将前一个元素的指针指向 索引i的后一个元素
        p.next = p.next.next;
        System.out.println(node);
        size --;
        return node;
    }
     /*
        删除集合里指定的元素
        思路: 1. 首先对集合进行遍历,找到集合中的该元素, 若集合没有对应元素,返回false
        	  2. 使用unlink方法删除元素(修改该元素前驱后继节点的指针,数据域置空,长度缩小)
     */
      @Override
    public boolean remove(Object e) {
        if (e == null) {
            for (Node<E> x = first; x != null; x = x.next) {
                if (x.data == null) {
                    unlink(x);
                    return true;
                }
            }
        } else {
            for (Node<E> x = first; x != null; x = x.next) {
                if (e.equals(x.data)) {
                    unlink(x);
                    return true;
                }
            }
        }
        return false;
    }
 // unlink方法实现
 E unlink(Node<E> x) {
        // assert x != null;
        final E element = x.data;
        final Node<E> next = x.next;
        final Node<E> prev = x.prev;

        if (prev == null) {
            first = next;
        } else {
            prev.next = next;
            x.prev = null;
        }

        if (next == null) {
            last = prev;
        } else {
            next.prev = prev;
            x.next = null;
        }

        x.data = null;
        size--;
        return element;
    }
集合数据替换
    /*
        将集合指定索引的元素替换为特定的值,并返回原数据元素
        思路:集合元素的替换不需要考虑指针,
        找到对应索引对应的元素,直接替换data域即可
    */
    @Override
    public E replace(int index, E e) {
        Node<E> p = first;
        for (int i = 0; i < index; i++) {
            p = p.next;
            }
        E oldValue = p.data;
        p.data = e;
        return oldValue;
    }
重写toString()方法
    /*
    	对我来说,重写该方法的目的就是使输出变得更友好,
    	方便进行代码调试.如下:输出为[data1,data2]这种格式
    */
	@Override
    public String toString() {
        if (size == 0) {
            return "[]";
        }
        StringBuilder sb = new StringBuilder();
        sb.append("[");
        Node p = first;
        for (int i = 0; i < size; i++) {
            if (i != size - 1) {
                sb.append(p.data).append(",");
            } else {
                sb.append(p.data);
            }
            p = p.next;
        }
        sb.append("]");
        return sb.toString();
    }

如上,我们通过自己的方式实现了循环双链的LinkedList的基本功能!

以上内容,主要是个人的理解
可能存在疏漏或者偏差,希望大家多多批评指正!

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值