前面学习过线性表的顺序存储结构。但它是有缺点的,最大的缺点就是插入和删除时需要移动大量元素。为了解决这个缺点,我们可以这样想。我们不再考虑元素的相邻位置,只让每个元素知道下一个元素的位置就可以合理的解决这个问题了。
于是,我们有了链表这个概念。
链表的节点分为两部分,为数据域和指针域。数据域存放数据元素信息,指针域存放其后继指针地址(即后继节点的存储位置)。
我认为链表就好比是玩具的小火车一样,有一个火车头,有很多车厢。每节车厢前后都有挂钩的地方,用来连接前面和后面的车厢,而火车头只有后面的挂钩。当我们需要拿掉哪一节车厢或者在某处加入一个车厢时,只需要把它的挂钩取下或者加入新车厢挂到挂钩上。
这里的火车头就指的是链表的头结点,而每节车厢就相当于链表中的节点。挂钩就当于链表中的指针。
我们在学习中采用虚拟头结点。
接下来,我们要用代码实现这些功能。
先来看一下线性表的链式存储结构的定义
先用LinkedList实现之前写的List这个接口,链表中存在节点,所以要再写一个节点这个类(即Node类),Node里面有数据域和指针域,这里Node算是LinkedList里面的内部类。
1.链表的类的定义LinkedList
public class LinkedList<E> implements List<E> {
/**
* 单向链表的结点类
* */
private class Node{ //创建一个私有的内部类
E data; //数据域
Node next; //指针域
public Node(){ //默认无参的构造函数
this(null,null);
}
public Node(E data,Node next){ //一个有参的构造函数,传入一个指定的数据域和指针域
this.data=data;
this.next=next;
}
@Override //返回传入对象的数据类型的tostring方法
public String toString() {
return data.toString();
}
}
private Node head; //指向虚拟头结点的头指针
private Node rear; //指向尾结点的尾指针
private int size; //记录元素的个数
public LinkedList(){ //无参的构造函数
head=new Node();
rear=head;
size=0;
}
public LinkedList(E[] arr){ //传入一个数组,把数组封装成链表
this();
for(E e:arr){
addLast(e);
}
}
下面我们来看一下链表的插入和删除。
因为我们不确定插入的位置和链表中节点的个数,所以我们将插入情况分为三种。
一.头插法
1.链表为空时头插法
head为头指针指向头结点,rear为尾指针指向尾节点。
此时是链表为空的情况,头指针和尾指针都指向虚拟头结点,而虚拟头结点的指针则指向空,因为此时链表为空。
此时我们要将“A”元素插入到链表中,首先要将“A”元素封装到一个节点中。此时这个新节点的指针指向空,因为它后面没有其他节点。
准备插入时,1.我们把虚拟头结点的后继指针赋给新节点的后继指针,让新节点指向空,2.然后让虚拟头结点指向新节点。3.再让尾指针rear指向新节点。这样,我们的插入就完成了。
因为链表为空,当我们插入新节点时,新节点了就成了尾节点,所以要移动rear指针。
链表为空时的头插法相当于一种特殊的尾插法。
效果如下图。
2.链表不为空时头插法
此时我们要把“B”元素所在的新节点用头插法插入到链表中,也就是把“B”元素所在的节点插入到“A”元素所在节点的前面。
1.我们把虚拟头结点的指针后继指针赋给“B”元素所在的节点,等于就让“B”元素所在的节点指向了“A”元素所在的节点,2.然后再让虚拟头结点指向“B”元素所在的节点。这样,我们的头插法就完成了。
效果如下图
此时的尾指针Rear不用变化,因为是头插法,所以链表的尾节点是不会变动的。
二.尾插法
1.链表为空时尾插法
链表为空时,没有真实的头节点和尾节点,所以插法的方法和链表为空时头插法是一样的。
2.链表不为空时尾插法
此时我们要把元素“B”所在的节点通过尾插法插入到链表中。
先封装“B”元素到节点中,这不用多说。
1.我们让插入前的尾节点指向新节点(因为尾节点都指向空)
2.再让rear指针指向插入新节点后的尾节点,也就是“B”元素所在的节点
插入完成,效果如下图
三.头插尾插结合
头插尾插结合就是把上面的头插法和尾插法结合起来运用。
1.先把虚拟头结点的后继指针赋给“A”元素所在的节点,让虚拟头结点指向“A”元素所在的节点。
2.让rear指针指向“A”元素所在的节点
3.把虚拟头结点的后继指针赋给“B”元素所在的节点,等于此时“B”元素所在的节点指向了“A”元素所在的节点。
4.让虚拟头结点指向“B”元素所在的节点
四.一般插入
1.把“A”元素所在节点的后继指针赋给“D”元素所在的节点
2.让“A”元素所在节点指向“D”元素所在节点
效果如下图
2.插入代码如下:
@Override
public void add(int index, E e) {
if(index<0||index>size){
throw new IllegalArgumentException("插入角标非法!");
}
Node n=new Node(e,null);
if(index==0){ //头插
n.next=head.next;
head.next=n;
if(size==0){
rear=n;
}
}else if(index==size){ //尾插
rear.next=n;
rear=rear.next;
}else{
Node p=head;
for(int i=0;i<index;i++){
p=p.next;
}
n.next=p.next;
p.next=n;
}
size++;
}
@Override
public void addFirst(E e) { //头插
add(0,e);
}
@Override
public void addLast(E e) { //尾插
add(size,e);
}
五.删除头部元素
1.一般删头
1.把“A”元素所在节点的后继指针赋给虚拟头结点,相当于虚拟头结点指向“B”元素所在的节点
2.让“A”元素所在节点指向null
效果如下图
2.只有一个节点时删头
1.把“A”元素所在节点的后继指针赋给虚拟头结点
2.让“A”元素所在节点指向null
2.让rear指针指向虚拟头结点
六.删除尾部元素
此时有三个节点,我们要删除尾节点,也就是“C”元素所在节点
1.先从头开始找,找到要删除尾节点的前一个节点
2.让rear指针指向要删除节点的前一个节点
3.让rear指向的节点(即“B”元素所在节点)指向null
效果如下图
七.一般删除
此时我们要删除“B”元素所在节点
1.把“B”元素所在节点的后继指针赋给“A”元素所在节点,即现在“A”元素所在节点指向了“C”元素所在节点
2.让“B”元素所在节点指向null
效果如下图
3.删除代码如下:
@Override
public E remove(int index) {
if(index<0||index>=size){
throw new IllegalArgumentException("删除角标非法!");
}
E res=null;
if(index==0){ //头删
Node p=head.next;
res=p.data;
head.next=p.next;
p.next=null;
p=null;
if(size==1){
rear=head;
}
}else if(index==size-1){//尾删
Node p=head;
res=rear.data;
while(p.next!=rear){
p=p.next;
}
p.next=null;
rear=p;
}else{
Node p=head;
for(int i=0;i<index;i++){
p=p.next;
}
Node del=p.next;
res=del.data;
p.next=del.next;
del.next=null;
del=null;
}
size--;
return res;
}
@Override
public E removeFirst() { //删头
return remove(0);
}
@Override
public E removeLast() { //尾删
return remove(size-1);
}
@Override
public void removeElement(E e) { //指定位置删
int index=find(e);
if(index==-1){
throw new IllegalArgumentException("元素不存在");
}
remove(index);
}
4.getSize()
获取元素个数
@Override
public int getSize() {
return size;
}
5.isEmpty()
@Override
public boolean isEmpty() {
return size==0&&head.next==null;
}
6.给定下标返回查找元素 get()
先判断下标是否非法
下标为0时返回(真实头结点)的数据
下标为size-1时返回最后一个节点的数据
其他情况就遍历数据然后获取指定下标的数据
@Override
public E get(int index) {
if(index<0||index>=size){
throw new IllegalArgumentException("查找角标非法!");
}
if(index==0){
return head.next.data;
}else if(index==size-1){
return rear.data;
}else{
Node p=head;
for(int i=0;i<=index;i++){
p=p.next;
}
return p.data;
}
}
@Override
public E getFirst() {
return get(0);
}
@Override
public E getLast() {
return get(size-1);
}
7.给定下标和元素修改数据 set()
修改和获取很像,都是先判断下标是否非法
然后分下标为0时和size-1两种特殊情况
其他则是遍历找到要修改的下标然后修改数据
@Override
public void set(int index, E e) {
if(index<0||index>=size){
throw new IllegalArgumentException("修改角标非法!");
}
if(index==0){
head.next.data=e;
}else if(index==size-1){
rear.data=e;
}else{
Node p=head;
for(int i=0;i<=index;i++){
p=p.next;
}
p.data=e;
}
}
8.查找 find()
这里我们让下标从-1开始
先判断是否为空
我们要查找所以肯定从第一个节点开始遍历
我们不知道要循环的次数,但是知道终止循环的条件。所以我们用while循环,当p指针指向最后一个节点时,终止循环。
@Override
public int find(E e) {
int index=-1;
if(isEmpty()){
return index;
}
Node p=head;
while(p.next!=null){
p=p.next;
index++;
if(p.data==e){
return index;
}
}
return -1;
}
9.判断是否包含 contains()
因为前面已经写过find函数,这里只需要调用find函数即可。
包含就返回节点的下标,不包含就返回-1.
@Override
public boolean contains(E e) {
return find(e)!=-1;
}
10.清空链表 clear()
清空列表等于就只剩下虚拟头结点了
让head和rear都指向虚拟头节点即可
再让size=0
@Override
public void clear() {
head.next=null;
rear=head;
size=0;
}
11.toString()
拼接字符串,返回链表的元素和长度
@Override
public String toString() {
StringBuilder sb=new StringBuilder();
sb.append("LinkedList:size="+getSize()+"\n");
if(isEmpty()){
sb.append("[]");
}else{
sb.append("[");
Node p=head;
while(p.next!=null){
p=p.next;
if(p==rear){
sb.append(p.data+"]");
}else{
sb.append(p.data+",");
}
}
}
return sb.toString();
}
12.equals()比较函数
先判断传入数据类型是否属于链表
是的话先判断元素个数是否相等
相等再遍历元素判断是否相等
最后返回结果
@Override
public boolean equals(Object obj) {
//是否空 是否是本身 否则比较
if(obj==null){
return false;
}
if(obj==this){
return true;
}
if(obj instanceof LinkedList){
LinkedList l = (LinkedList) obj;
if(getSize()==l.getSize()){
for(int i =0;i<getSize();i++){
if(get(i)!=l.get(i)){
return false;
}
}
return true;
}
}
return false;
}
代码部分就到这里,后面需要再写一个main函数进行测试。
线性表的链式存储结构就到这里,随着不断的学习,我会进行补充。