很多逻辑结构的底层实现都逃不出数组和链表这两个最基本的数据结构,比如我们熟悉的栈、队列等,就可以有数组和链表两种实现形式,它们反映的是数据在存储中的组织方式。以下是我自己做的总结,以实现为主,难免有疏漏和不严谨的地方,欢迎交流指正。
数组
数组指的是连续存储数据,好处是方便按索引查找元素。例如在内存中开辟一块内存地址连续的空间,地址0存放第一个元素,地址1存放第二个元素,地址2存放第三个元素……以此类推。我知道了数组的初始位置是0,待查找的是第五个元素,那么地址就是0+5-1=4;假设数组的初始位置是3,那么第五个元素的地址就是3+5-1=7。只需要做一次加法就能找到待查找的元素,这个时间复杂度就是O(1)。
数组的不方便的地方也显而易见,那就是扩展的灵活性不够,基本上开辟之后位置就定死了,不论是插入还是删除元素都会比较费劲。这里介绍其中一种思路,不考虑优化空间复杂度。以插入为例,从待插入位置往后每个元素都必须向后挪一位,则需要重新开辟一块length+1长度的新数组,将原数组和待插入元素一起复制过去。以删除为例,则同样需要重新开辟一块length-1长度的数组,这是因为数组不允许中间有空元素,删除中间的元素后,后面的元素必须依次前进一位。
参照上文实现了增删改查功能,并且事先判断位置是否合法。代码如下:
public class Array {
private int[] arr; //数组
//初始化
public Array() {
arr = new int[0];
}
//判断非空
public boolean isEmpty() {
return arr.length==0;
}
//获取长度
public int size() {
return arr.length;
}
//往数组末尾添加一个元素
public void add(int element) {
int[] newArr = new int[arr.length+1];
for(int i = 0; i < arr.length; i++) newArr[i] = arr[i];
newArr[arr.length] = element;
arr = newArr;
}
//删除数组中的元素
public void delete(int index) {
//判断位置是否合法
if (index >arr.length || index < 1) throw new RuntimeException("下标越界");
int[] newArr = new int[arr.length-1];
for (int i = 0; i <arr.length - 1; i++) { //这里容易数组下标越界,建议将短数组作为下标遍历范围
if (i < index - 1){
newArr[i] = arr[i];
} else {
newArr[i] = arr[i + 1];
}
}
arr = newArr;
}
//打印所有元素到控制台
public void show() {
System.out.println(Arrays.toString(arr));
// for (int j : arr) System.out.print(j);
}
//获取指定位置的元素
public int get(int index) {
if (index > arr.length || index < 1) throw new RuntimeException("数组越界");
return arr[index-1];
}
//插入一个元素到指定的位置
public void insert(int index, int element) {
if (index > arr.length + 1 || index < 1) throw new RuntimeException("数组越界");
int[] newArr = new int[arr.length + 1];
int i = 0;
for (; i < index - 1; i++) newArr[i] = arr[i];
newArr[i] = element;
for (; i < arr.length; i++) newArr[i+1] = arr[i];
arr = newArr;
}
//替换指定位置的元素
public void set (int index, int element) {
if (index > arr.length || index < 1) throw new RuntimeException("数组越界");
arr[index - 1] = element;
}
}
测试代码如下:
public static void main(String[] args) {
Array testArr = new Array();
//初始化测试
System.out.println("===========数组测试程序===========");
System.out.println("===========初始化测试===========");
System.out.println(testArr.isEmpty());
System.out.println("size = " + testArr.size());
testArr.show();
//添加元素
System.out.println("===========添加元素===========");
testArr.add(99);
testArr.add(98);
testArr.add(97);
testArr.show();
//删除元素
System.out.println("===========删除第2个位置的元素===========");
testArr.delete(2);
testArr.show();
//取出指定位置元素
System.out.println("===========取出第1个位置的元素===========");
System.out.println(testArr.get(1));
System.out.println("size = " + testArr.size());
//插入元素到指定位置
System.out.println("===========添加元素===========");
testArr.add(96);
testArr.add(95);
testArr.add(94);
testArr.show();
System.out.println("===========在第4个位置插入元素33===========");
testArr.insert(4, 33);
testArr.show();
//替换指定位置的元素
System.out.println("===========将第1个位置的元素替换为100===========");
testArr.set(1, 100);
testArr.show();
System.out.println("size = " + testArr.size());
}
运行结果截图如下:
链表
链表指的是数据在存储空间的存放不连续,每个节点的位置是任意的,除了自身数据外还会存储下一个节点的地址,有了地址就能按图索骥找到下一个节点。链表大家族成员就多了,按仅有指向后一个节点的指针还是指向前后节点的指针都有分为单链表和双向链表,按首尾节点是否相接分为循环链表和非循环链表。链表和数组优劣互补。如果需要灵活增减数据,那么链表是更为合适的结构。
单链表
以删除元素为例,链表只需要将待删除节点的前一个节点指向的地址修改为待删除节点后一个节点的地址就ok了,这时待删除的节点没有任何指针指向,由系统的垃圾回收机制回收。以单链表添加元素为例,待添加节点无需按顺序存储,在存储空间的任意位置均可,使它的下一个节点指向插入位置的下一个节点,待插入位置的上一个节点指向它,就完成了插入。双链表比单链表的稍微复杂一点,不仅要梳理好指向后一个节点的指针,还要考虑指向前一个节点的指针,下一小节将会介绍。
增删确实是方便了,然而,链表的查询却没有数组快捷。上文说过,按索引查找数组只需要做一次加法就能实现,而对于链表来说,我们并不知道第五个元素的地址,我们只知道当前节点的下一个节点在哪里,所以唯一能做的是从初始位置开始一个一个往下找,直到找到第五个元素。数组查找的时间复杂度是O(1),链表查找的时间复杂度是O(n),比前者高了一个数量级。
这里单链表的实现过程如下:
public class LinkNode {
private int data; //数据
private LinkNode nextNode; //下一节点
public LinkNode(int input) {
data = input;
nextNode = null;
}
//寻找最末尾节点并添加新节点
public LinkNode append(LinkNode appended) {
LinkNode currentNode = this;
while(currentNode.nextNode != null) {
currentNode = currentNode.nextNode;
}
currentNode.nextNode = appended;
return this;
}
//获取节点数据
public int getData() {
return data;
}
//获取下一个节点
public LinkNode getNextNode() {
return nextNode;
}
//设置下一个节点的指针,主要是为插入做准备
protected boolean setNextNode(LinkNode node) {
if(nextNode == null) {
nextNode = node;
return true;
} else return false;
}
//判断当前节点是否是最后一个
public boolean isLast() {
return nextNode == null;
}
//删除下一个节点
public boolean removeNext() {
LinkNode next;
if((next = this.nextNode) != null) {
this.nextNode = next.nextNode;
return true;
}
return false;
}
//插入一个节点作为当前节点的下一节点
public boolean insertNext(@NotNull LinkNode inserted) {
if(inserted.setNextNode(this.nextNode)) {
this.nextNode = inserted;
return true;
}
else return false;
}
//显示所有节点信息
public void show() {
LinkNode current = this;
StringBuilder sb = new StringBuilder(String.valueOf(current.getData()));
while(!current.isLast()) {
current = current.nextNode;
sb.append(current.getData());
}
System.out.println("当前链表:" + sb);
}
}
测试代码如下:
public static void main(String[] args) {
//创建及添加节点
LinkNode node1 = new LinkNode(1);
LinkNode node2 = new LinkNode(2);
LinkNode node3 = new LinkNode(3);
node1.append(node2).append(node3);
//显示所有节点内容
System.out.println("===========显示链表===========");
node1.show();
//判断并查看末尾节点的数据
System.out.println("===========末尾节点===========");
LinkNode nodeTest = node1.getNextNode().getNextNode();
System.out.println("判断是否为末节点:" + nodeTest.isLast());
System.out.println("查看节点:" + nodeTest.getData());
//删除中间节点
System.out.println("===========删除中间节点===========");
// if(node1.getNextNode().getNextNode().removeNext()) node1.show(); //删除失败测试,原因:末尾节点的下一个无法删除
if(node1.removeNext()) node1.show(); //删除成功测试
else System.out.println("节点删除失败!");
//在中间插入新节点
System.out.println("===========插入节点===========");
// if(node1.insertNext(new LinkNode(4).append(new LinkNode(5)))) node1.show(); //插入失败测试,原因:插入了节点串
if(node1.insertNext(new LinkNode(4))) node1.show(); //插入成果测试
else System.out.println("节点插入失败!");
}
运行结果如下:
这是插入和删除都成功了的测试结果。下面将测试代码中原本注释掉的测试失败代码保留,将测试成功的代码注释,得到运行结果如下:
删除失败是因为试图删除最后一个节点指向的空指针,插入失败是因为试图插入一个节点串,而插入函数暂时仅允许接受单个节点。双向链表可以解决删除失败的问题,因为它根本不会遇到空指针。
双向链表
双向链表的节点比单链表节点多了一个指针,每个节点不仅存储下一个节点的地址,同样存储上一个节点的地址。这样做的好处是可以向前遍历,想想看单链表的一个问题就是只知道后一个节点的地址,是没有办法获取前一个节点地址的。双向链表就不会有这个问题,从一个节点开始,可以向前找也可以向后找。空间上双链表每个节点要比单链表多存储一个地址,换取时间上不用从头遍历的高效。
双向链表的插入和删除比单链表要稍微复杂一点。一个单独的节点要插入链表,需要先把它和下一个节点的双向连接建立起来,然后断开上一个节点和下一个节点之间的连接,使其指向待插入的节点。注意这个顺序最好不要反过来,除非多开一个指针指向下一个节点,不然的话,如果先断开原有连接,下一个节点就找不到了(因为没有指针指向它了)。删除节点就是上述过程反过来。首先让前一个节点和下一个节点建立双向连接,然后删除中间节点即可。
核心代码如下:
public class DoubleNode {
private int data; //节点数据
private DoubleNode nextNode; //下一节点
private DoubleNode lastNode; //上一节点
public DoubleNode(int input) {
data = input;
nextNode = null;
lastNode = null;
}
//插入节点
public void insertNext(DoubleNode inserted) {
inserted.nextNode = nextNode;
inserted.lastNode = this;
nextNode.lastNode = inserted;
nextNode = inserted;
}
//删除节点
public void removeNext() {
DoubleNode next = nextNode;
nextNode = next.nextNode;
next.nextNode.lastNode = this; //此时没有指针指向next,会在随后被释放
}
//获取下一个节点
public DoubleNode getNextNode() {
return nextNode;
}
//获取上一个节点
public DoubleNode getLastNode() {
return lastNode;
}
}
下面来测试一下插入和删除节点操作,测试代码如下:
public static void main(String[] args) {
//创建节点
DoubleNode dn1 = new DoubleNode(1);
DoubleNode dn2 = new DoubleNode(2);
DoubleNode dn3 = new DoubleNode(3);
//创建链表
dn1.insertNext(dn2);
dn1.insertNext(dn3);
System.out.println("当前节点: " + dn3.getData() + " 下一节点: " + dn3.getNextNode().getData() +
" 上一节点: " + dn3.getLastNode().getData());
//删除节点
dn1.removeNext();
System.out.println("当前节点: " + dn1.getData() + " 下一节点: " + dn1.getNextNode().getData() +
" 上一节点: " + dn1.getLastNode().getData());
}
这段仅建立了一个三节点的双向链表,首先测试在1和2之间插入3,然后将3删除,每步输出节点及其前后节点的数据。结果这里先不展示,将在下一小节介绍循环链表概念后一并展示。
循环链表
循环链表其实不是一个独立于单双向链表的概念,单链表可以是循环链表,也可以不是循环链表,双向链表同理。它指的是首尾相连成环的链表,这样一来,没有队头和队尾之说,也就不用判断队尾空指针的情况了。
单链表代码改起来很简单,删除isLast()
和append()
两个方法,初始化时让节点的下一个指向自己即可。各位可以验证一下,在初始只有一个节点并且该节点的下一个指向自己的时候,调用insertNext()
就可以直接实现两个节点构成循环链表,以此类推得到的不管多长的链表都是循环链表。
双向链表也是同理。初始化时让前后指针都指向自己,后面每次加入节点就是个插入过程,insert函数都不带改的。这里将双向链表作为一个循环链表,测试验证下。首先初始化节点1、2、3,查看单独节点的首尾指针是否指向了自己,然后调用上一小节的insertNext()
方法在1后面先插入2再插入3,这样会得到1、3、2的链表序列,2的下一个节点指向1,形成循环双向链表。测试的结果如下: