仿写LinkList:基于节点的数据结构——链表(包含详细数据结构与常用算法)

​ 当我们学完数组后,我们知道,数组的存取必须使用连续的内存空间,并会预留一部分空间方便扩展。我们假设下面的场景:如果在电影院分配座位时,也通过这样的规则进行分配,会发生什么情况?

在这里插入图片描述

​ 如上图所示,所有人都希望自己和朋友坐一起,并且为之后可能到来的朋友预留位置。这样的做法会大大降低影院的入座率。在内存中的话,同样会大大降低内存的使用率,这种事情肯定是不会被科学家所接受的。所以出现了一种新的数据结构,叫做链表

1.链表的创建

​ 链表的发明是对数组的一种补充,因此它的功能和数组十分相似,都是用于存储一系列相同类型的数组,并且都有增删查改的功能。也就是说,数组能做的事情,一般都能用链表来实现,只是他们的存储方式不同,导致他们的实现逻辑也不同,同样时间复杂度也将不同。下面我们就具体分析一下链表的特性:

节点

​ 链表里的每一个数称之为一个节点,节点由两部分组成:节点内容和下一个节点地址

在这里插入图片描述

为什么每个节点都要存储下一个节点地址呢?

在数组中,由于是连续内存空间存储,我们可以通过start_address + item_size * i计算出元素的位置。 而在链表中,存储空间是分散存储的,所以我们需要每个节点保存下一个节点的位置。 也就是可以通过节点 1 找到节点 2,节点 2 找到节点 3…依次遍历所有的节点信息。

​ 在这个数据结构中,他将数据分散存储在内存中,以达到内存最大利用率。

​ 我们可以创建一个Node.java类,来实现链表节点的创建。

public class Node {

  private int content;//链表内容

  private Node next;//链表指针,指向下一节点

  public Node() {
  }

  public Node(int content, Node next) {
    this.content = content;
    this.next = next;
  }

  public int getContent() {
    return content;
  }

  public void setContent(int content) {
    this.content = content;
  }

  public Node getNext() {
    return next;
  }

  public void setNext(Node next) {
    this.next = next;
  }
}

链表

​ 完成了节点的创建,接下来便可以实现链表。

​ 我们首先创建一个根元素,然后从链表末尾开始创建链表。首次添加只需要让根元素指向新创建的节点,之后每次创建的新的节点对象,都令其指向链表已有元素的头部节点,使其成为新的头部节点,再令根元素指向新添加的头部节点,最后返回根结点,我们便得到了一个链表,根节点指向的就是链表的第一个节点。

public static Node createLinkedList(int[] array) {
    // 创建一个根节点
    Node root = null;
    // 从末尾元素开始依次创建Node节点
    for (int i = array.length - 1; i >= 0; i--) {
      Node node = new Node(array[i], null);
      // 创建两个节点的连接关系
      if (root != null) {
        node.setNext(root);
        root = node;
      } else {
        root = node;
      }
    }
    return root;
  }

public static void main(String[] args) {
    int[] array = {9, 2, 4};
    Node root = createLinkedList(array);
    System.out.println(root.getContent());
  }

得出结果:9,我们的链表创建成功,根节点内容为9。

2.链表的读取与查找

首先回顾一下在数组中如何进行数据的读取:

因为数组是连续内存空间存储的,我们通过 start_address + item_size * i 便能计算出索引地址,然后 1 步便完成内容的读取,时间复杂度是O(1)

​ 那在链表中,我们应该如何操作呢?链表在计算机中是分散存储的,我们现在只有第一个节点的地址,因此需要依次遍历三个节点的地址:

在这里插入图片描述

时间复杂度:

​ 从上面图中我们看到,链表中的读取时间复杂度和索引值有关。最好的情况是读取第一个节点,只需要一步。最坏的情况是读取最后一个节点,需要N步。 因此链表的读取时间复杂度为O(N)

​ 查找过程与遍历相似,如果要查找的是第一个节点,只需要一步。最坏的情况是查找最后一个节点,需要N步。 因此链表的查找时间复杂度为也O(N)

在这里插入图片描述

​ 接下来的过程中,我们通过所学内容的实践,自己仿写一个LinkList方法。我们在链表中加入两个属性:root表示存储链表的第一个节点,size表示链表的长度。

 public YKDLinkedList(int[] array) {
    this.root = YKDLinkedList.createLinkedLNode(array);
    this.size = array.length;
  }

​ 本节我们完成查找功能:

// 获取长度
public int size() {
    return 0;
}

// 获取某个索引节点内容
public int get(int index) {
    return 0;
}

// 查找某个值的索引值,默认不存在为-1
public int find(int value) {
    return -1;
}

代码演练——获取长度

​ 在构造函数中我们已经设置了链表的长度为数组长度,因此获取长度只需要返回size值即可:

// 获取长度
public int size() {
    return this.size;
}

代码演练——获取索引节点内容

​ 给定索引获取节点,实际上就是将指针移动索引值的次数后指向的节点,因此我们可以遍历链表,每次遍历将索引减一,当索引值为零时结束遍历;也可以设置自增指针i,当i的值与索引相等时结束循环:

// 获取某个索引节点内容
public int get(int index) {
    int i = 0;
    Node node = this.root;
    // 依次往前推进
    while (i < index) {
      node = node.getNext();
      i++;
    }
    return node.getContent();
  }

代码演练——获取节点元素索引值

​ 查找某个值的索引值,即为查找节点元素脚标,我们只需要一次遍历数组并储存索引值,当指针命中内容后返回索引值即可:

// 查找某个值的索引值,默认不存在为-1
  public int find(int value) {
    Node node = this.root;
    // index 保存当前的索引
    int index = 0;
    // 依次遍历链表,找到内容等于value的node,返回index
    while (node != null) {
      if (node.getContent() == value) {
        return index;
      }
      node = node.getNext();
      index++;
    }
    return -1;
  }

3.链表的插入

假设我们在为周末做规划,规划如下:

eat breakfast  // 吃早饭
shopping       // 购物
have lunch     // 吃午饭
have dinner    // 吃晚饭

头部插入

​ 现在突然想在吃早饭之前,加入一项取快递(take express),应该如何插入呢?

在这里插入图片描述

如上图所示,分两部分执行:

  1. 新节点的next指向原来的root节点;
  2. root指针指向新节点。

时间复杂度为O(1)

中间插入

如果想在购物之后再取快递,应该怎么处理呢?

在这里插入图片描述

如上图所示,分两部分执行:

  1. 新节点的next指向原来前置节点的next节点;
  2. 原来前置节点的next指向新节点。

​ 看起来好像也只需要O(1)就能完成插入,但是这样的插入有一个前置条件,也就是我们知道插入节点的前置节点。 否则,我们只能根据索引通过N步找到前置节点。

尾部插入

如果想在吃完晚饭后看电影,应该怎么处理呢?

在这里插入图片描述

和中间插入罗伊完全一样,只是指向的下一个节点为null:

  1. 新节点的next指向原来前置节点的next节点(null);
  2. 原来前置节点的next指向新节点。

同样的,我们要先通过N步找到前置节点,在进行上面的操作。

总结:

如果已经知道插入节点的前置节点,那么链表的插入时间复杂度为O(1)。普通情况(根据索引值插入节点),插入的时间复杂度为O(N)这个和数组是一样的。 但有趣的是,链表插入的最好和最差的情况刚好和数组相反,链表在头部插入和方便,但是数组开头插入确很麻烦。而数组在尾部插入很方便,但链表尾部插入要先扫描再插入。

接下来我们便完善我们的LinkList类,本次完成如下三个函数:

// 末尾添加元素
public boolean add(int value) {
    return true;
}

// 头部插入元素
public boolean addFirst(int value) {
    return true
}

// 插入元素
// index = -1,表示在头部插入
// index = 0, 表示在第一个元素之后插入
// index = n,表示在 索引n 位置之后插入
public boolean add(int index, int value) {
    return true;
}

代码演练——插入元素

​ 需要判断链表为空和索引溢出的情况,链表为空则直接插入,索引溢出则返回失败;其次判断index值,-1表示在头部插入:

/**
  *  在链表中指定位置插入一个新节点
  *  @param  index  新节点需要插入的位置,如果为-1,则在链表头部插入
  *  @param  value  新节点的值
  *  @return  插入成功则返回true,否则返回false
  */
public  boolean  add(int  index,  int  value)  {
    //判断元素是否溢出
    if (index < -1 || index > this.size - 1){
      return false;
    }
    //  如果需要在链表头部插入新节点
    if  (index  ==  -1)  {
        //  判断链表是否为空,如果不为空,则在头部插入
        if  (this.root  !=  null)  {
            this.root  =  new  Node(value,  this.root);
        }  else  {
            //  如果链表为空,直接将新节点作为头节点
            this.root  =  new  Node(value,  null);
        }
    }  else  {
        //  如果需要在链表中插入新节点
        Node  pre  =  this.root;
        //  通过循环定位到要插入节点的位置
        while  (index  >  0)  {
            //  如果链表长度不足直接返回false
            if  (pre.getNext()  ==  null)  {
                return  false;
            }
            pre  =  pre.getNext();
            index--;
        }
        //  如果要插入节点的位置为null,则不插入,直接返回false
        if  (pre  ==  null)  {
            return  false;
        }
        //  如果要插入节点的位置pre不为null,则在其后面插入新节点
        Node  newNode  =  new  Node(value,  pre.getNext());
        pre.setNext(newNode);
    }
    //  插入成功后,链表长度加一,返回true
    this.size++;
    return  true;
}

代码演练——末尾添加元素

上面的分析已经很详细了,我们只需要先遍历到末尾即可:

public boolean add(int value) {
    return this.add(this.size - 1, value);
  }

代码演练——末尾添加元素

令索引值为-1:

 public boolean addFirst(int value) {
    return this.add(-1, value);
  }

4.链表的删除

​ 掌握了链表的插入,删除就简单多了。我们还以周末规划为例:

eat breakfast  // 吃早饭
shopping       // 购物
have lunch     // 吃午饭
have dinner    // 吃晚饭

头部删除

如果我周末想睡懒觉,不想吃早饭了,应该怎么删除吃早饭?

在这里插入图片描述

如上图所示,分两个部分执行:

  1. root指针指向第二个节点;
  2. 原始root节点的next指针设为null。

时间复杂度为O(1)

中间删除

​ 我今天比较饿,吃完早饭后没吃饱,想直接吃午饭,不想购物了,应该怎么操作?

在这里插入图片描述

如上图所示,分两个部分执行:

  1. 待删除节点的前置节点的next指针指向待删除节点的后置节点;
  2. 待删除节点的next指针设为null。

时间复杂度为O(N)

尾部删除

​ 我今天吃的有点撑了,我晚上不想吃饭了,我应该怎么操作?

在这里插入图片描述

如上图所示,我们只需要执行如下步骤:

  • 待删除节点的前置节点的next指针指向null。

总结

​ 删除操作其实就是插入操作的反操作,只要能理解这一点,弄懂删除操作的原理并不难。

如果已经知道待删除节点的前置节点,那么链表的删除时间复杂度为O(1)。普通情况(根据索引值删除节点),因为需要新找到前置节点,所以的时间复杂度为O(N)这个和数组是一样的。 和链表插入一样,链表删除的最好和最差的情况刚好和数组相反,链表在头部删除和方便,但是数组开头删除确很麻烦。而数组在尾部删除很方便,但链表尾部删除要先扫描再删除。

代码演练——删除元素

​ 要判断链表为空和索引值溢出的情况,如有则直接返回false。

// 删除最后一个元素
  public boolean removeLast() {
    return this.remove(this.size-1);
  }

  // 删除第一个元素
  public boolean removeFirst() {
    return this.remove(0);
  }

  // 删除元素
  // index = 0, 表示删除第 1 个元素
  // index = n,表示删除第 n+1 个元素
  public boolean remove(int index) {
      //删除第一个元素
    if(index == 0){
        //判断链表是否为空
      if (this.root!=null){
        Node node = this.root;
          //如不为空,让root指向next的下一个节点
        this.root=this.root.getNext();
          //让头结点的next置空
        node.setNext(null);
      }else {
          //若为空则返回false
        return false;
      }
    }else {
        //删除第index个元素
      Node  pre  =  this.root;
      while (index>1){
          //判断index是否溢出
        if (pre.getNext()==null){
          return  false;
        }
          //若未溢出则指针后移
        pre=pre.getNext();
          //减少下标,代表前移一位
        index--;
      }
        //判断链表是否为空
      if  (pre  ==  null)  {
          //为空则返回false
        return  false;
      }
        //设置node为pre的next节点
      Node node = pre.getNext();
        //设置pre节点的next指针为node的next节点
      pre.setNext(node.getNext());
        //将node的next指针置空
      node.setNext(null);
    }
      //链表长度减一
    this.size--;
    return true;
  }

代码演练——删除头部元素

 // 删除第一个元素
  public boolean removeFirst() {
    return this.remove(0);
  }

代码演练——删除尾部元素

// 删除最后一个元素
  public boolean removeLast() {
    return this.remove(this.size-1);
  }

5.链表vs数组

链表和数组在性能上到底有什么区别?

我们可以通过如下表格观察:

数组链表
存储连续内存存储分散内存存储
读取O(1)O(N)
插入O(N)O(N)
插入最好情况数组末尾插入链表开头插入
插入最坏情况数组开头插入链表末尾插入
删除O(N)O(N)
删除最好情况数组末尾删除链表开头删除
删除最坏情况数组开头删除链表末尾删除

​ 表面上看起来,链表除了在存储上面有优势,其他的好像并不比数组强,那为何我们还有使用链表?

​ 我们设想下面一个场景:

现在爆发了丧尸危机,但是还在初期可控期,因此我们现在要寻找拥有丧尸的城市并炸掉,然后再数据库中删除掉这个城市信息,最后按顺序整理起来看看还剩多少城市。

在这样的场景下,我们应该用什么数据结构来存储城市信息呢?

我们设想一下操作这个数据库最关键的两个步骤:遍历和删除。

遍历

​ 我们需要遍历所有城市,以找到爆发丧尸危机的城市。数组和链表的遍历时间复杂度都为O(N)。

删除

​ 当我们成功炸毁城市并打算删除时,如果在数组中删除,那么我们需要依次移动红棉的所有元素,所以每次删除时间复杂度都为O(N)。

​ 而如果使用链表的话,我们在遍历时就已经拿到了这个节点的前置节点,所以在这个场景下,每次删除的时间复杂度都为O(1)。

总结

从这个例子我们可以看出,如果我们需要频繁的插入和删除,那么链表相对于数组有绝对的优势。

6.链表经典算法题——多指针的使用

​ 链表是初级数据结构中最喜欢涉及的数据结构,基于链表的算法更是层出不穷。下面我们来学习一个经典场景——多指针在链表算法中的应用。

我们以剑指offer中的一道题目为例:

如何查找单向链表中倒数第K个节点?

​ 单向链表,我们无法从尾部开始遍历,只能从头开始遍历,因而导致单项链表只知道自己后面的节点,那如何取到倒数第k个节点呢?

  • 暴力法

我们可以通过两次循环获取倒数第k个节点的索引。

  1. 第一次循环,获取到链表中的节点个数,记为n;
  2. 第二次循环,只需要推进到n-k次即可。

代码如下:

// 找寻倒数第 k 个节点内容
public static int findK(Node node, int k) {
    // 第一步,计算链表的长度
    int size = 0;
    Node p = node;
    while (p != null) {
        p = p.getNext();
        size++;
    }

    // 第二步,推进 size - k 步
    Node c = node;
    int index = size - k;
    while (index > 0) {
        c = c.getNext();
        index--;
    }
    return c.getContent();
}

那么有没有办法可以减少时间复杂度呢?

  • 双指针法

我们可以使用两个指针完成这个任务:

第一步,先声明一个先驱指针,探索到链表的边界。我们将先驱指针先移动k-1位,然后声明一个实际指针,用来寻找目标节点。

在这里插入图片描述

第二步,同时移动两个指针,寻找目标节点。

在这里插入图片描述

第三步,当先驱指针移动到链表结尾的时候,此时的实际指针就是我们要寻找的倒数第k个节点。

在这里插入图片描述

代码如下:

// 找寻倒数第 k 个节点内容
  public static int findK(Node node, int k) {
    // 移动先驱指针
    int pre = k;
    Node pioneer = node;
    while(pre > 1){
      pioneer = pioneer.getNext();
      pre--;
    }

    // 创建实际指针
    Node current = node;
    while(pioneer.getNext() != null){
      current = current.getNext();
      pioneer = pioneer.getNext();
    }

    return current.getContent();
  }

​ 接下来,我们将题目进行变形:现在我们要获取一个链表的中心节点的内容。如果数组是奇数个,则返回中心节点内容;如果是偶数个,则返回中心右侧节点的内容。如此又该如何操作呢?

​ 其实也很简单,我们先分析一下题目:如果链表为奇数个,我们只需要让实际指针移动一个的同时,令先驱指针移动两个。如此当先驱指针移动到结尾时,实际指针正好指在中心元素。

​ 而当链表元素为偶数个时,我们需要考虑如下情况:在先驱指针移动最后一步时,剩余的链表元素仅剩一个,不足以让指针移动两步,此时便会报出空指针异常。此时我们应该怎么解决呢?

​ 我们可以将先驱指针的移动分为两步:第一步,和实际指针一起移动一步;第二步,判断下节点是否为空,若为空则直接返回节点内容,若不为空则再往前走一步,继续循环。

我在第一次写demo的时候,让偶数个元素也采用了先驱指针一次移动两步的方式。我的思路如下:判断先驱指针的下个节点和下下个节点是否为空,都不为空则向前移动,若下下个节点为空,则让实际指针向前移动一步,然后返回节点内容。但是这样有一个问题:我没有考虑下个节点为空的情况,导致了系统报出了空指针异常,因为此时无法判断下下节点是否为空。大家可以思考一下这个过程。

实现代码如下:

 // 找寻中心节点内容
  public static int findCenter(Node node) {
    // 创建先驱指针
    Node pioneer = node;
    // 创建实际指针
    Node current = node;

    // 每次遍历需要实际指针移动一步,先驱指针移动二步
    while(pioneer.getNext() != null){
      current = current.getNext();
      pioneer = pioneer.getNext();
      if(pioneer.getNext() != null){
        pioneer = pioneer.getNext();
      }
    }

    return current.getContent();
  }

有环链表

​ 有环链表,也就是链表中出现了环状结构。在之前我们看到的链表都是一条直线的,但在实际情况下对于Node这种节点,很容易会造成有环链表

如下图两个模式,都属于是有环链表

在这里插入图片描述

那有什么方法判断一个链表是否是有环链表么?我们尝试完善一下代码:

// 查询链表是否有环,true是有环,false是无环
public static boolean hasCircle(Node node) {
    return false;
}

给大家提示下思路,我们考虑一个生活场景:

假设两个人在一条笔直的马路上赛跑,一个人跑得快,一个人跑的慢,那么理论上从开始比赛到终点,两个人永远不会相遇(除了起跑的时候),直到跑到终点。 那如果两个人在一个环形的操场上赛跑呢?那么两位同学肯定会再次相遇,当跑得快的同学超过跑的慢的同学刚好 1 圈的时候。

如果把这两位同学,当做两个指针,那就可以判断环形情况了。

 // 查询链表是否有环,true是有环,false是无环
  public static boolean hasCircle(Node node) {
    // 移动先驱指针
    if (node == null || node.getNext() == null) {
      return false;
    }

    // first 每次前进一步
    Node first = node;
    // second 每次前进两步
    Node second = node.getNext();

    // 核心逻辑:如果链表中有环,两个指针用不同速度运行,最终second会追上first,也就是 first == second。
    while (first != null && second != null && first != second) {
      first = first.getNext();
      second = second.getNext();
      if (second != null) {
        second = second.getNext();
      }
    }

    // 如果相等,并且不为空,则表示有环
    if (first != null && first == second) {
      return true;
    }
    return false;
  }

7.双向链表

​ 我们前面的问题,如果可以使用双向链表,那么问题便会迎刃而解。

​ 比如查找中间元素,我们可以设置头尾双指针,当左>=右时,返回左指针指向的节点值。

​ 这种结构的链表,便叫作双向链表。

在这里插入图片描述

这种链表的特性如下:

  1. 对于每个节点,由三部分组成,prev(指向前一个节点),next(指向后一个节点),content(节点内容)。
  2. 对于双向链表,为了方便遍历,我们需要同时保留第一个节点和最后一个节点。

LinkList

在之前我们自己实现过YKDLinkedList,如果大家以后仔细阅读LinkedList.java的源码,我们会发现LinkedList实际上就是利用双向链表实现的,在源码中我们可以找到如下代码:

public class LinkedList<E>
    extends AbstractSequentialList<E>
    implements List<E>, Deque<E>, Cloneable, java.io.Serializable
{
    transient int size = 0;

    /**
     * Pointer to first node.
     * Invariant: (first == null && last == null) ||
     *            (first.prev == null && first.item != null)
     */
    transient Node<E> first;

    /**
     * Pointer to last node.
     * Invariant: (first == null && last == null) ||
     *            (last.next == null && last.item != null)
     */
    transient Node<E> last;

    /**
     * Constructs an empty list.
     */
    public LinkedList() {
    }

    ......
}

​ 这里我们只需要关注两个变量firstlast,分别表示双向链表的头结点和尾节点。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值