一、前言
数组对于内存的要求比较高,需要一块连续的内存空间来存储,如果你申请的一个100M大小的数组,当内存中没有连续的,足够大的存储空间时,即便剩余内存可用空间大于100MB,申请仍然会是失败的。第二方面在当顺序表中内存容量不足时,则必须重新申请一个数组,将原来的数据复制到新的数组中,数据搬移消耗了大量的性能和时间(数据达到一定量级)。在顺序表中元素的插入和删除也可能存在数据的搬移,也会消耗一定的性能和时间,而这时链表这种数据结构就出现了。
二、链表的特点
先来解下链表,如上图,链表是和数组恰恰是相反的一种基础数据结构。链表是一组零散的内存结构,而连接这些内存的介质我们称之为指针(也就是图中的next,而在Java语言中,指针就是引用,也就是下一节点的地址值)。在单链表中,我们把图中内存块称之为“”结点”,结点包括存储数据和指向下一节点的后继指针(next)。在单链表中有两个特殊的结点,第一个结点我们称之为头结点用来存储链表的基地址,而最后一个结点称为尾结点,尾结点指向一个空地址(null),表示链表的终点。链表同数组一样也具有插入、查找和删除的操纵,下面我就从这个几个方面来实现下单链表。
三、单链表的实现细节
3.1、首先先实现链表的存储单元,也就是结点类,结点类包括存储数据和后继指针两个元素
/**
* 结点类
* @author Administrator
*/
public class Node<E> {
private E data;//存储数据
private Node<E> next;
public Node(E data){
this.data=data;
}
public Node(E data,Node<E> next){
this.data=data;
this.next=next;
}
}
3.2、实现单链表的顶级接口,包含链表的具体操作方法
package cn.cast.LinkedList;
/**
* 链表的顶级接口,包含所有
* 链表的基本方法
* @author Administrator
*
*/
public interface SingList<T> {
//判断链表是否为空
boolean isEmpty();
//链表长度
int length();
//获取元素
T get(int index);
//根据index添加元素data
T set(int index,T data);
//根据index添加元素data
boolean add(T data);
//根据index插入元素
boolean add (int index,T data);
//删除指定位置的元素
T remove(int index);
//删除指定元素
boolean remove(T data);
//根据data移除所有相同元素
T removeAll(T data);
//清空链表
void clear();
//是否包含特定元素
boolean contains(T data);
//获取元素的位置
int indexOf(T data);
//根据data值查询最后一个出现在顺序表的下标
int lastIndexOf(T data);
// 输出格式
String toString();
}
3.3、实现顶级接口SingList<T>,声明链表的头结点代表链表的开始位置以及两个基本的构造方法。
public class MySingList<T> implements SingList<T>{
private Node<T> head;//头结点
private int length;//链表的长度
public MySingList(Node<T> head){
this.head=head;
}
判断链表是否为空:由于链表的结构特点,头结点是链表的开始位置,所有判断链表是否为空,只要判断链表的头结点是为空
/**
* 判断链表是否为空
*/
@Override
public boolean isEmpty() {
return this.head==null;
}
链表的长度大小:链表是存储的单元是结点,所有结点的数量也就是链表的长度。结点中存储着后继结点的引用地址,链表的终点是尾结点的指针为null,因此只需要判断在首结点不为空的情况下,依次沿着后继指针循环,直到指针为null,就可以得到链表的长度。
/**
* 获取链表的长度
*/
@Override
public int length() {
Node<T> p=head;//将头结点赋值到临时变量p
if(p!=null){
while(p.next!=null){
p=p.next;//获取指向下一结点的指针
length++;//链表长度+1
}
}
return length;
}
获取元素:由于链表的结构特点,获取链表首先声明变量count(从0开始)来表示结点指向的位置,需要依次按照后继指针循环直到获取结点从而取得结点存储的数据。从程序来看,链表获取元素在最坏情况下需要依次遍历所结点,最好情况下时间复杂度为O(1),最坏情况下时间复杂度O(n)。从而看出链表获取的元素效率要比顺序表低。而在LinkedList中,获取元素已经采用二分查找来进步提高获取元素的效率。(方法还存在一些问题,可利用二分查找优化提高效率)
/**
* 获取元素
*/
@Override
public T get(int index) {
if (head != null && index >= 0) {
int count = 0;// 用来元素的索引位置
Node<T> p = this.head;// 存储结点的临时变量
// 获取对应的索引位置
while (p != null && count < index) {
p = p.next;
count++;
}
if (p != null) {
return p.data;
}
}
return null;
}
修改指定位置的元素:在获取元素位置时,与获取元素位置相同,修改元素只是在获取元素后用新的存储数据替换旧的存储数据data。在时间复杂度上,与获取元素相同。
/**
*修改元素
*/
@Override
public T set(int index, T data) {
if (head != null && index >= 0) {
int count = 0;// 用来元素的索引位置
Node<T> p = this.head;// 存储结点的临时变量
// 获取对应的索引位置
while (p != null && count < index) {
p = p.next;
count++;
}
//获取需要替换的存储数据,用新的存储数据替换旧的存储数据,返回旧值
if(p!=null){
T oldData=p.data;
p.data=data;
return oldData;
}
}
return null;
}
添加元素:单链表添加元素主要分为四种场景,a.空链表插入结点;b.头结点插入元素,新增结点为头结点;c.中间情况插入节点;d.尾结点插入元素,也就是在插入在单链表末尾,下面从流程图分析下四种场景 。
下面具体来分析下四种具体情况:
a.插入空链表:此时链表为空,插入结点即为空结点
if(head==null){
head=new Node<T>(data,null);
}
b.头结点前插入元素:新插入结点即为新头结点,插入结点的指针指向原头结点
if(index==0 && head!=null){
Node<T> node =new Node<T>(data);//新结点实例
node.next=head;//将新结点的后继指针指向原头结点
head=node;//新结点赋值为头结点
}
c.中间结点插入元素:在链表中间位置插入新元素,首先获取插入位置的前一个结点,将插入位置的索引向前移动,将新插入元素的后继指针指向原插入位置的结点。
if(index>0 && head!=null && index<length()){
int scanIndex = 0;// 扫描索引
Node<T> p = this.head;// 存储结点的临时变量
// 获取对应的索引位置
while (p != null && scanIndex < index-1) {
p = p.next;//将索引向前移
scanIndex++;
}
Node<T> node =new Node<T>(data);
node.next=p.next;//新结点后继指针指向原结点的后继结点
p.next=node;
}
d.链表末尾插入元素:在链表末尾插入结点,即新插入结点为新的尾结点,同时,尾结点后继指针为null。
if(index>0 && head!=null && index=length()){
int scanIndex = 0;// 扫描索引
Node<T> p = this.head;// 存储结点的临时变量
// 获取对应的索引位置
while (p != null && scanIndex < index-1) {
p = p.next;//将索引向前移
scanIndex++;
}
Node<T> node =new Node<T>(data);
node.next=p.next;//新结点后继指针指向原结点的后继结点
p.next=node;
return true;
}
在末尾和中间插入代码可以合并,最终代码如下:
@Override
public boolean add(int index, T data) {
if(head==null){
head=new Node<T>(data,null);
return true;
}
if(index==0 && head!=null){
Node<T> node =new Node<T>(data);
node.next=head;
head=node;
return true;
}
if(index>0 && head!=null && index<=length()){
int scanIndex = 0;// 扫描索引
Node<T> p = this.head;// 存储结点的临时变量
// 获取对应的索引位置
while (p != null && scanIndex < index-1) {
p = p.next;//将索引向前移
scanIndex++;
}
Node<T> node =new Node<T>(data);
node.next=p.next;//新结点后继指针指向原结点的后继结点
p.next=node;
return true;
}
return false;
}
在尾部插入结点:
@Override
public boolean add(T data) {
return this.add(length(),data);
}
删除指定位置的元素:删除元素与插入结点类型,首先要先找到要删除指定位置的索引位置分为三种情况,删除头结点,删除中间位置结点,删除尾结点;
@Override
public T remove(int index) {
// 链表尾空
if (isEmpty()) {
return null;
}
// 删除头结点
if (index == 0) {
Node<T> oldHead = head;
head = head.next;
return oldHead.data;
}
// 删除中间结点
if (index != 0 && index <= length()) {
int scanIndex = 0;// 扫描索引
Node<T> p = this.head;// 存储结点的临时变量
// 找到目标结点的前一个结点
while (p != null && scanIndex < index - 1) {
p = p.next;// 将索引向前移
scanIndex++;
}
Node<T> targetNode = p.next;// 目标结点
if (targetNode != null) {
T oldData = targetNode.data;
p.next = targetNode.next;
targetNode = null;// 将目标结点置为null,切断关联关系
return oldData;
}
}
return null;
}
其他方法:
//清空链表
@Override
public void clear() {
this.head=null;
}
//判断链表是否含有某元素结点
@Override
public boolean contains(T data) {
//数据合法性校验
if(data==null){
return false;
}
//链表为空时
if(isEmpty()){
return false;
}
//依据后继指针循环链表
Node<T> p = head;
while(p!=null){
T nodeData= p.data;
if(data.equals(nodeData)){
return true;
}
p=p.next;
}
return false;
}
总结:从上面分析的结构可以看出,由于链表不需要保存内存数据的连续性,不需要搬移结点,因此链表的插入和删除动作时非常快;同时,由于不支持随机访问,无法根据首地址和下标获取元素,需要依次遍历甚至是整个链表,直达找到相应的节点。