从零开始手撕一个数据结构(1)——双向链表

系列文章目录


前言

刚做完某厂的笔试,要求实现一个LRU算法,没有复习到LRU的我直接就凉了一半了

闲着没事把数据结构都过一遍吧,加深一下印象
作为《从零开始手撕一个数据结构》的第一篇,先从最简单的链表开始吧~

源码

点击这里获取源码,别忘了点个Star哦~



一、要实现一个什么样的结构?

LinkedList list=new LinkedList();
list.add(3);
list.add(6);
list.add(9);
System.out.println(list.get(2)); //输出 9
System.out.println(list.toString()); //输出[3,6,9]

总所周知,LinkedList是Java中链表的一个实现类,那我们也来尝试着实现一个类似的链表容器出来

本文只把只能存放int的容器实现,不考虑泛型

容器的实例方法

(1)void add():对链表进行尾插
(2)int get(int index):依据传入的索引,找到对应元素
(3)int remove(int index):依据传入的索引,删除对应元素,并返回被删除的元素值
(4)int set(int index, int val):依据传入的索引和值,将对应元素的值改为传入值,并返回修改前的值
(5)int size():返回容器中的元素个数

我们的目标是把基本的增删改查做出来

二、实现步骤

1. 核心定义

首先我们要对我们要创建的容器的核心进行定义

1)创建链表容器类LinkedList

链表的英文就是linked list,就直接用它命名好了

//手写一个双向链表容器
public class LinkedList {
    //定义头尾节点指针,在链表中有元素使分别指向链表的头和尾
    ListNode head=null,tail=null;
    //记录链表中的元素个数
    int size=0;
}

在这里我们定义了两个指针一个记录链表长度的变量,都是为了方便我们接下来对链表的操作

2)定义链表核心内部类ListNode

    //内部类,双向链表的核心
    private class ListNode{
    	//节点中存放的元素
        int val;
        //前、后指针
        ListNode pre,next;
        //构造方法
        ListNode(int val){
            this.val=val;
        }
    }

经常在leetcode上刷题的朋友应该对这个结构熟悉得不能再熟悉了,它就是一个双向链表的结构,pre指针指向自己的前一个节点,next指针指向自己的后一个节点

2. 方法实现

在我们对链表容器的核心定义完成之后,我们就可以开始实现这个容器对外提供的操作链表的方法了

1)添加元素的add方法

    //添加元素方法
    public void add(int val){
        //往链表中添加元素时,链表长度自增
        ++size;
        //当链表为空时
        if(head==null){
            //new一个链表头
            head=new ListNode(val);
            //由于链表里只有一个元素,所以链头和链尾是一样的
            tail=head;
        }else{
            //链表不为空时,在链表尾插一个新节点
            tail.next=new ListNode(val);
            //新节点的pre指向链尾
            tail.next.pre=tail;
            //将链尾指针指向新节点
            tail=tail.next;
        }
    }

注:这里只实现了尾插,想要实现头插的话用类里面的head指针可以很方便地实现,想要实现依据索引插入的话也可以根据下面在实现remove方法中实现的getNode方法方便地实现

2)查询元素的get方法

在链表中,如果用索引查询一个元素,开销是有点大的,因为程序需要把整个链表遍历一遍,直到遍历到一个符合索引值的节点才会停下来,其时间复杂度为O(n),相比于数组索引查询的O(1),简直太慢了

    //通过索引获取元素方法
    public int get(int index){
        //当传入索引大于链表长度所支持的最大索引,返回-1
        if(index>size-1)
            return -1;
        //在链表中要通过索引获取元素,要对整个链表进行遍历,时间复杂度为O(n)
        //定义一个指针指向头节点,从头开始往后遍历
        ListNode cur=head;
        //当索引值大于0时,索引值自减,cur指针往后走一步
        while(index-->0)
            cur=cur.next;
        //直到索引值被减为0,当前的cur指针指向的节点就是我们想要的节点
        //返回节点数据
        return cur.val;
    }

从头开始找也太慢了呀,这我万一每次要取的元素都离尾节点近,岂不是几乎要遍历整条链表,得改进下:

    //通过索引获取元素方法
    public int get(int index){
        //当传入索引大于链表长度所支持的最大索引,返回-1
        if(index>size-1)
            return -1;
        //优化:当所求索引大于链表长度的一半,从尾节点开始遍历,否则从头节点开始遍历
        int midIndex=size/2;
        ListNode cur=null;
        if(index>=midIndex){
            cur=tail;
            //索引也要反过来
            index=size-index-1;
            //反过来遍历
            while(index-->0)
                cur=cur.pre;
        }else{
            cur=head;
            while(index-->0)
                cur=cur.next;
        }
        return cur.val;
    }

这样的get方法可以稍微的快一点了

3)删除元素的remove方法

写到这里一想,remove方法也需要像get方法一样一个个找呀,和我之前写的get方法也差不多,不会要再写一遍吧
再写一遍差不多的代码也太拉跨了,这里我们得重构一下
新定义一个通过索引找节点的方法getNode(int index)

    public ListNode getNode(int index) {
        //当传入索引大于链表长度所支持的最大索引,返回null
        if(index>size-1)
            return null;
        //优化:当所求索引大于链表长度的一半,从尾节点开始遍历,否则从头节点开始遍历
        int midIndex=size/2;
        ListNode cur=null;
        if(index>=midIndex){
            cur=tail;
            //索引也要反过来
            index=size-index-1;
            //反过来遍历
            while(index-->0)
                cur=cur.pre;
        }else{
            cur=head;
            while(index-->0)
                cur=cur.next;
        }
        return cur;
    }

再对get(int index)方法进行修改:

    //通过索引获取元素方法
    public int get(int index){
        ListNode res=getNode(index);
        //当getNode方法传出null时,返回-1
        if(res==null) return -1;
        else return res.val;
    }

这样,我们写remove(int index)方法的时候就不必在写一遍遍历了:

    //通过索引删除元素方法
    public int remove(int index){
        ListNode res=getNode(index);
        //当getNode方法传出null时,返回-1
        if(res==null) return -1;
        //删除节点后size自减
        //当size大于1时,正常自减
        if(size--<=1){//当size小于等于1,删除链表中最后一个元素,头尾指针置空
            head=null;
            tail=null;
            return res.val;
        }
        //若要删除的节点是尾节点,删除并返回尾节点
        if(res==tail){
            tail=tail.pre;
            tail.next=null;
            return res.val;
        }
        //若要删除的节点是头节点,删除并返回头节点
        if(res==head){
            head=head.next;
            head.pre=null;
            return res.val;
        }
        //若都不是,获取被删除节点的前后节点
        ListNode pre=res.pre;
        ListNode next=res.next;
        //删除一个节点就是要让被删除节点的前后节点都跳过他
        pre.next=next;
        next.pre=pre;
        //返回被删除节点的值
        return res.val;
    }

需要注意的是,我们在删除一个元素的时候,需要把被删除的元素的位置当前链表的长度都考虑周全,不然的话极可能出现错误

4)修改元素的set方法

有些时候我们需要对某索引的元素值进行修改,这是我们就需要set(int index,int val)方法:

    //通过索引修改元素方法
    public int set(int index,int val){
        //老样子,通过遍历拿到所需的节点
        ListNode node=getNode(index);
        int oldVal=node.val;
        //直接修改对应节点的val字段即可
        node.val=val;
        //返回该节点被修改前的值
        return oldVal;
    }

5)返回当前链表长度的size方法

既然是一个容器,怎么能没有size方法呢:

    //返回链表长度
    public int size(){
        return size;
    }

我们之前写的方法都维护着容器中的size字段,直接返回size即可

6)toString方法

    @Override
    public String toString(){
        if(size==0) return null;
        StringBuilder sb = new StringBuilder();
        ListNode cur = head;
        while(cur != null){
            sb.append(cur.val).append(",");
            cur = cur.next;
        }
        return sb.toString();
    }

遍历,组成字符串,不多说了吧

3. LRU所需要的方法

LRU的实现看这里
在上文中,LRU需要的方法有:

  • 尾删
  • 头插
  • 查询链表中有无包含此元素
  • 将一个节点移动到链表头

等等。。

1)尾删 & 头插

    // 尾删方法
    public int removeLast(){
        // 存储链尾的值
        int res = tail.val;

        //尾删
        tail=tail.pre;
        tail.next.pre=null;
        tail.next=null;
        --size;

        return res;
    }
    // 头插方法
    private void addFirst(ListNode node){
        node.next = head;
        head.pre = node;
        node.pre = null;
        head = node;
        ++size;
    }

尾删和头插方法很简单,维护好之前维护的size即可,不然很可能出现错误

2)查询链表中有无包含此元素

对于这个问题,我们只能遍历整个链表
在java.util.LinkedList中的contains方法就是用于处理此问题的,实际上它依赖于java.util.LinkedList的indexOf方法,indexOf方法的实现也是遍历整条链表,所以时间复杂度是O(N)

实现一个方法,通过值寻找节点

LinkedList的indexOf是通过值寻找索引,我们则写一个方法让它返回节点而非索引,方便我们进行内部操作

    // 通过值寻找节点, 时间复杂度 O(n)
    private ListNode getNodeByVal(int val){
        ListNode cur = head;
        while (cur != null){
            if(cur.val == val) return cur;
            cur = cur.next;
        }
        return null;
    }

有了以上的方法,我们就可以简单的实现contains方法

    // 查询链表是否包含所传入值
    public boolean contains(int val){
        return getNodeByVal(val)!=null;
    }

3)将一个节点移动到链表头

在实现这个方法之前,我们先实现一个方法用于删除指定节点

    // 删除指定节点方法
    private void removeNode(ListNode node){
        ListNode nodePre = node.pre;
        ListNode nodeNext = node.next;
        nodePre.next = nodeNext;
        nodeNext.pre = nodePre;
        --size;
    }

(有增删操作时记得维护好size。。)

    // 将节点移到链头
    public boolean moveToHead(int val){
        // 用 node记录被移动节点
        ListNode node;
        // 当链表中不存在这个值,返回 false
		// 这里我们为了快速获取到 node值,就不用 contains了
        if ((node = getNodeByVal(val)) == null)
            return false;

		// 分情况讨论:node为头节点?尾节点?还是其它节点
        if(node == head) // 若 node本就是头节点,无需移动
            return true;

        // 当 node不是尾节点
        if (node != tail){
            removeNode(node);
        }else { // node是尾节点时,先尾删
            removeLast();
        }
        
        //将node头插
        addFirst(node);
        return true;
    }

总结

自此,一个链表容器基本的增删改查就写好了,测试如下:

        LinkedList l=new LinkedList();

        // add 测试 正常
        l.add(3);
        l.add(6);
        l.add(1);

        // moveToHead 测试 正常
        System.out.println(l.toString()); // 3,6,1,
        System.out.println(l.moveToHead(1)); // true
        System.out.println(l.toString()); // 1,3,6, (移动成功)
        System.out.println(l.moveToHead(3)); // true
        System.out.println(l.toString());

        // contains 测试 正常
        System.out.println(l.contains(3)); // true (链表中有值3
        System.out.println(l.contains(5)); // false (链表中没有值5

        // removeLast 测试 正常
        l.removeLast();
        System.out.println(l.toString()+" | removeLast"); // 3,1, | removeLast

        // remove(int index) 测试 正常
        System.out.println(l.size());           // 2
        System.out.println(l.remove(2)); // -1
        System.out.println(l.toString());  // 3,1,
        System.out.println(l.remove(0)); //3
        System.out.println(l.toString()); // 1,
        System.out.println(l.remove(0)); //1
        System.out.println(l.toString()); // null

        // set(int index, int val) 测试 正常
        l.add(9);
        l.add(7);
        System.out.println(l.toString()); // 9,7,
        System.out.println(l.set(0,3));         //9
        System.out.println(l.toString()); // 3,7,

欢迎有更好想法的朋友在评论区提意见,有问题也可以在评论区留言~


版权声明:本文为Yuc0114原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。