线性表的顺序与单链表存储结构 Java版

线性表

线性表:零个或多个数据元素的有限序列

线性表的数据对象集合为{a1, a2, ……, an},每个数据元素的类型相同。其中,除了第一个元素a1外,每一个元素有且只有一个直接前驱元素,除了最后一个元素an外,每一个元素有且只有一个直接后继元素。数据元素之间的关系是一对一的关系。

线性表之顺序存储结构

用一段地址连续的存储单元一次存储线性表的数据元素

a1 a2 ...... ai-1 ai ...... an

顺序存储结构需要三个属性:

  • 存储空间的起始位置:数据data,它的存储位置就是存储空间的存储位置。

  • 线性表的最大存储容量:数组长度MaxSize。

  • 线性表的当前长度:length。

    注意,数组长度与线性表长度的区别:数组的长度是存放线性表的存储空间的长度,存储分配后这个量一般是不变的。线性表的长度是线性表中数据元素的个数,随着线性表插入和删除操作而变化。在任意时刻,线性表的长度应该小于等于数组的长度。

内存地址计算方法

用数组存储顺序表意味着要分配固定长度的数组空间,由于线性表中可以进行插入删除操作,因此分配的数组空间要大于等于当前线性表的长度。

内存中的地址,就和图书馆里的座位一样,都是有编号的。存储器中的每个存储单元都有自己的编号,即地址。由于是顺序结构,所以当第一个位置确定之后,后面的位置都是可以计算的。比如,我在班里的成绩为第五名,我后面的10名同学成绩名次分别就是6,7,...,15,因为5+1,5+2,...,5+10。每个数据元素,不管他是整形、实型还是字符型,他都是需要占用一定的存储单元空间的。假设占用的是c个存储单元,那么线性表中第i+1个数据元素的存储位置和第i个数据元素的存储位置满足下列关系:

LOC(ai+1)=LOC(ai)+c

所以对于第i个数据元素ai的存储位置可以有a1推算得出:

LOC(ai)=LOC(a1)+(i-1)*c

通过这个公式,可以算出线性表中任意位置的地址,任何位置,都是相同的时间。它的时间复杂度为O(1)。通常把具有这一特点的存储结构称为随机存取结构。

顺序存储结构的插入与删除(参考:Java的ArraryList类
1. 获取元素操作代码:
public class ArrayList<E> {
 
  transient Object[] elementData;
  
  public E get(int index) {
    Objects.checkIndex(index, this.size);
    return this.elementData(index);
	}
  
  E elementData(int index) {
        return this.elementData[index];
    }
}
复制代码
2. 插入操作

举个例子,春运买火车票。大家都排队排的好好的。这是来了个美女,对着队伍中排在第三位的你说,“大哥,求求你帮帮忙,家里母亲病了,着急回去看她,队伍这么长,你可否让我排你的前面?”你心一软,就同意了。后面的人就得全部都退一步。这个例子其实已经说明了线性表的顺序存储结构,在插入数据时的实现过程。

顺序插入.png

插入的思路:

  1. 如果插入位置不合理,抛出异常;
  2. 如果线性表长度大于等于数组长度,则抛出异常或者动态增加容量;
  3. 从最后一个元素开始向前遍历到第i个位置,分别将它们都向后移动一个位置;
  4. 将要插入元素填入位置i,表长加1。

代码实现:

public boolean add(E e) {
        ++this.modCount;
        this.add(e, this.elementData, this.size);
        return true;
}

public void add(int index, E element) {
    this.rangeCheckForAdd(index);
    ++this.modCount;
    int s;
    Object[] elementData;
    if ((s = this.size) == (elementData = this.elementData).length) {
        elementData = this.grow();
    }

    System.arraycopy(elementData, index, elementData, index + 1, s - index);
    elementData[index] = element;
    this.size = s + 1;
}
复制代码
3. 删除操作

接着上面的例子。就在此时,出现一个警察对美女说,“跟我到局里走一趟。”女子离开了队伍。原来她是倒卖火车票的黄牛,装可怜来插队买票。这时排队的人,又均向前移动了一步。这就是线性表的顺序存储结构删除元素的过程。

顺畅删除.jpg

删除思路

  1. 如果删除位置不合理,抛出异常;

  2. 取出删除元素;

  3. 从删除元素位置开始遍历到最后一个元素位置,分别将它们都向前移动一个位置;

  4. 表长减1。

代码实现:

public E remove(int index) {
    Objects.checkIndex(index, this.size);
    Object[] es = this.elementData;
    E oldValue = es[index];
    this.fastRemove(es, index);
    return oldValue;
}

复制代码
插入和删除的时间复杂度。

最好的情况,如果元素要插入到最后一个位置,或者删除最后一个元素,此时时间复杂度为O(1),因为不需要移动元素。

最坏的情况,如果元素要插入到第一个位置或者删除第一个元素,此时时间复杂度为O(n),因为向后或向前移动了原来1位置后的所有元素。

至于平均的情况,由于元素插入到第i个位置,或者删除第i个元素,需要移动n-i个元素。根据概率原理,每个位置插入或者删除元素的可能性是相同的,也就说位置靠前,移动元素多,位置靠后,移动元素少。最终平均移动次数和最中间的那个元素的移动次数相等,为(n-1)/2,即O(n)。

分析得,线性表的顺序存储结构,在存、读数据时,不管是哪个位置,时间复杂度都是O(1);而插入或删除时,时间复杂度都是O(n)。说明它比较适合元素个数不太变化,而更多是存取数据的应用。

线性表顺序存储结构的优缺点
优点:
  • 无须为表中元素之间的逻辑关系而增加额外的存储空间
  • 可以快速地取表中任意位置的元素
缺点:
  • 插入和删除操作需要移动大量元素
  • 当线性表长度变化较大时,难以确定存储空间的容量
  • 造成存储空间的”碎片“

线性表之单链表式存储结构

线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。这就意味着,这些数据元素可以存在内存未被占用的任意位置,如下图:

31239386-9E64-4336-AA02-EA1A198361BE.png

在顺序结构中,每个元素只需要存数据信息就可以了。现在链表结构中,除了要存数据信息外,还要存储它的后继元素的存储地址

数据信息+后继元素的地址=结点(Node)

CD0EEFAE-97DA-4822-AEBF-B344AEA8B992.png

单链表中第一个结点的存储位置叫头指针,整个链表的存取必须是从头指针开始进行。之后的每一个结点,就是上一个的后继指针指向的位置。最后一个结点的指针为“空“。如下图:

指针说明.jpg

单链表中的第一个结点叫做头结点。头结点的数据可以不存储任何信息,也可以用来存储线性表的长度等附加信息,头结点的指针域存储指向第一个结点的指针。如下图:

结点说明.jpg

头指针与头结点的异同:
头指针:
  • 头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针
  • 头指针具有标识作用,所以常用头指针冠以链表的名字
  • 无论链表是否为空,头指针均不为空。头指针是链表的必须要元素
头结点:
  • 头结点是为了操作的统一和方便而设立的,放在第一个元素的节点之前,其数据域一般无意义
  • 有了头结点,对在第一元素节点前插入节点和删除第一节点,其操作与其它节点的操作就统一了
  • 头结点不一定是链表必须要素

参考:《链表的基本概念》Keiven_LY 2016-03-25 21:14

线性表链式存储结构代码实现
1.单链表类整体代码设计 (参考:《Java基础--单链表的实现》唐·吉坷德 2019-03-25 20:57
public class Linked<E>
  private Node head; //头结点
	private int size;  //链表元素数量
  
	public Linked(){
    this.head=null;
    this.size=0;
  }	

  ......
  //链表结点
  private static class Node<E> {
      E item; 			//结点数据
      Node<E> next;	//后继 (单向链表没有”前驱“)

      Node(E element, Linked.Node<E> next) {
          this.item = element;
          this.next = next;
      }
  }
	......
}
复制代码
2.单链表的结点

结点由存放数据元素的数据域和存放后继结点地址的指针域组成

//链表结点
private static class Node<E> {
  E item; 			//结点数据
  Node<E> next;	//后继 (单向链表没有”前驱“)
  
  Node(E element, LinkedList.Node<E> next) {
      this.item = element;
      this.next = next;
  }  
}
复制代码
3.单链表的读取

在线性表的顺序存储结构中,计算任意一个元素的存储位置是很容易的。但单链表中,由于第i个元素在哪里,是没有办法一开始就知道的,必须从头开始找。

获得链表第i个数据的思路:

  1. 声明一个结点p指向链表第一个结点,初始化j从1开始;
  2. 当j<index时,就遍历链表,让p的指针向后移动,不断指向一个结点,j累加1;
  3. 若到链表末尾p为空,则说明第index个元素不存在;
  4. 否则查找成功,返回结点p的数据。

代码实现:

public E get(int index) {
    int j = 1;
    Node<E> p = head.next; 
    while (p != null && j < index) {
        p = p.next;
        ++j;
    }
    if (p == null || j > index) {
        return null;
    } else {
        return p.item;
    }
}
复制代码

说白了,就是从头开始找,直到第i个结点为止。由于这个算法的时间复杂度取决于i的位置,当i=1时,则不需遍历,第一个就取出数据了,而当i=n时则遍历n-1次才可以。因此最坏情况的时间复杂度是O(n)。

4.单链表的插入

假设存储元素e的结点为s,要实现结点p、p->next和s之间的逻辑关系的变化,只需要将结点s插入到结点p和p->next之间。如下图:

912C4464-49D3-41FA-A9FC-B660B9A39CFE.png

s->next = p->next; p->next = s。解读这两句代码就是,让p的后继结点改成s的后继结点,再把结点s编程p的后继结点。如下图:

49109A75-D680-48BB-9A1A-A2BAE8DB3614.png

单链表第i个数据插入结点的算法思路:

  1. 声明一指针p指向链表头结点,初始化j从1开始;
  2. 当j<index时,就遍历链表,让p的指针向后移动,不断指向下一结点,j累加1;
  3. 若到链表末尾p为空,则说明第index个结点不存在;
  4. 否则查找成功,在系统中生成一个空结点s;
  5. 将数据元素e赋值给s->data;
  6. 单链表的插入标准语句s->next=p->next;p->next=s。
//向链表中间插入元素
public void add(E e, int index) {
    if (index < 0 || index > size) {
        throw new IllegalArgumentException("Index is error");
    }
    if (index == 0) {
        this.addFirst(e);
        return;
    }
    Node<E> p = this.head;
    int j = 1;
    //找到要插入节点的前一个节点
    while (p != null && j < index) {
        p = p.next;
        ++j;
    }
    if (p == null || j > index) {
        throw new NullPointerException("Node is not exist");
    }
    Node<E> node = new Node<>(e, null);
    //要插入的节点的下一个节点指向preNode节点的下一个节点
    node.next = p.next;
    //preNode的下一个节点指向要插入节点node
    p.next = node;
    this.size++;
}

//向链表尾部插入元素
public void add(E e) {
    this.add(e, this.size);
}

//链表头部添加元素
public void addFirst(E e) {
    Node<E> node = new Node<>(e, null);    //节点对象
    node.next = this.head;
    this.head = node;
    this.size++;
}
复制代码
5.单链表的删除

设存储元素ai的结点为q,要实现将结点q删除单链表的操作,其实就是将它的前继结点的指针绕过,指向它的后继结点即可,如下图:

A1114225-5A49-4AFF-A03B-843AFE4F4904.png

q=p->next; p->next=q->next。就是说把p的后继结点改成p的后继的后继结点。

例子:一家三口,妈妈(p结点)->爸爸(q结点)->女儿(r结点),手牵手逛街。突然出现个美女,爸爸一下子看呆了。妈妈很生气,甩开爸爸的手,并扯开爸爸牵着女儿的手,自己拉着女儿走了。

单链表第i个数据删除结点的算法思路:

  1. 声明一指针p指向链表头结点,初始化j从1开始;
  2. 当j<i时,就遍历链表,让p的指针向后移动,不断指向下一个结点,j累加1;
  3. 若到链表末尾p为空,则说明第i个结点不存在;
  4. 否则查找成功,将欲删除的结点p->next赋值给q;
  5. 单链表的删除标准语句p->next=q->next;
  6. 将q结点中的数据赋值给e,作为返回;
  7. 释放q结点。
//删除链表元素
public E remove(int index) {
    if (head == null) {
        return null;
    }
    //删除头结点
    if (index == 0) {
        head = head.next;
        this.size--;
        return head == null ? null : head.data;
    }
    Node<E> p = this.head;
    int j = 1;
    //找到要删除节点的前一个节点
    while (p != null && j < index) {
        p = p.next;
        ++j;
    }
    if (p == null || j > index) {
        return null;
    }
    //需要删除的结点q
    Node<E> q = p.next;
    final Node<E> next = q.next;
    final E e = q.data;
    //将q的后继赋值给p的后继
    p.next = next;
    //help gc
    q.data = null;
    q.next = null;
    //将q结点中的数据返回
    return e;
}
复制代码

分析一下刚才我们讲解的单链表插入和删除算法,我们发现,它们其实都是由两部分组成:第一部分就是遍历查找第i个结点;第二部分就是插入和删除结点。

从整个算法来说,可以推导出:它们的时间复杂度都是O(n)。如果在我们不知道第i个结点的指针位置,单链表数据结构在插入和删除操作上,与线性表的顺序存储结构是没有太大优势的。但如果,我们希望从第i个位置,插入10个结点,对于顺序存储结构意味着,每一次插入都需要移动n-i个结点,每次都是O(n)。而单链表,我们只需要在第一次时,找到第i个位置的指针,此时为O(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是O(1)。显然,对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显。

单链表结构与顺序存储结构优缺点

简单地对单链表结构和顺序存储结构做对比:

存储分配方式 时间性能 空间性能
1.顺序结构用一段连续的存储单元依次存储线性表的数据元素;
2.单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素。
1.查找
顺序结构O(1);
单链表O(n)。
2.插入和删除
顺序结构需要平均移动表长一半的元素,时间为O(n);
单链表在找出某个位置的指针后,插入和删除时仅为O(1)。
1.顺序结构需要预分配空间,分大了,浪费,分小了易发生上溢;
2.单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制。

结论:

  • 若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。比如说游戏开发中,对于用户注册的个人信息,除了注册时插入数据外,绝大多数情况都是读取,所以应该考虑用顺序存储结构。而游戏中的玩家的武器或者装备列表,随着玩家的游戏过程中,可能会随时增加或删除,此时再用顺序存储就不太合适了,单链表结构就可以大展拳脚。当然,这只是简单的类比,现实中的软件开发,要考虑的问题会复杂得多。
  • 当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。而如果事先知道线性表的大致长度,比如一年12个月,一周就是星期一至星期日共七天,这种用顺序存储结构效率会高很多。

参考资料:

《大话数据结构》程杰

猜你喜欢

转载自juejin.im/post/7031192252542255134