第四章 最基础的动态数据结构:链表
4-1 什么是链表
4-2 在链表中添加元素
4-3 使用链表的虚拟头结点
4-4 链表的遍历,查询和修改
4-5 从链表中删除元素
4-6 使用链表实现栈
4-7 带有尾指针的链表:使用链表实现队列
4-1 什么是链表
链表是一种非常重要的线性数据结构。
动态数组、栈、队列 => 这三者底层依托静态数组;靠resize解决固定容量问题
链表 => 真正的动态数据结构
为什么链表很重要?
- 最简单的动态数据结构(还有二分搜索树、平衡二叉树等是更高级的动态数据结构)
- 更深入的理解引用(或者指针):和内存相关
- 更深入的理解递归
- 辅助组成其他数据结构 (图、哈希表、栈、队列etc.)
链表Linked List
数据存储在“节点”(Node)中
class Node {
E e;
Node next;
}
优点:真正的动态,不需要处理固定容量的问题
缺点:丧失了随机访问的能力(不能像数组一样,给一个索引就能从数组中拿出索引对应的元素)
- 链表存储数据是有限的,如果一个节点的next为NULL(即为空),那么这个节点一定是链表中的最后一个节点。
- 在链表中,需要多少个数据,就可以生成多少个节点,把它们“挂接起来”
- 从底层机制上,数组所开辟的空间在内存里是连续分布的,所以可以直接寻找索引对应的偏移,直接计算出相应的数据所存储的内存地址,用O(1)的复杂度即可。而链表由于是靠next一层一层链接,所以在计算机的底层,每一个节点所在的内存的位置是不同的,必须要靠next一点一点来找到所想要的元素。
- 数组和链表的对比:数组最好用于索引有语意的情况。最大的优点:支持快速查询;链表不适合用于索引有语意的情况。最大的优点:动态
代码实现链表:
public class LinkedList<E> {
private class Node{
public E e;
public Node next;
public Node(E e, Node next){
this.e = e;
this.next = next;
}
public Node(E e){
this(e, null);
}
public Node(){
this(null, null);
}
@Override
public String toString(){
return e.toString();
}
}
}
4-2 在链表中添加元素
(1)在链表头添加元素:
在为数组添加元素时,最简单的是在数组尾添加元素;而在链表中添加元素时,最简单的是在链表头添加元素。
- 第一步:Node node = new Node(e); 假设我们要将一个666元素添加进链表中,相应的就要把666放入一个节点中,在这个节点里存储里666这个元素以及相应的一个next。添加元素的关键在于如何将新节点挂接到链表中,同时不破坏链表的结构。
- 第二步:node.next = head; 让这个node的next指向现在链表的头,此时666成为新的链表的头。
- 第三步:head = node; 让head指向新的666这个节点,这样就完成了将666插入到整个链表的头部。
具体的代码实现,注意代码实现的三个步骤和以上三张图片是一一对应的:
private Node head;
private int size;
public LinkedList(){
head = null;
size = 0;
}
// 在链表头添加新的元素e
public void addFirst(E e){
// 这三个步骤和图片一一对应
Node node = new Node(e);
node.next = head;
head = node;
// 一行优雅代码可概括上方三行步骤(记得注释掉上三行):
head = new Node(e, head);
size ++;
}
最终链表状态如下:
(2)在链表中间添加元素:
目标:在“索引”为2的地方添加元素666; 关键:找到要添加的节点的前一个节点
- 第一步:Node node = new Node(e);
- 第二步:node.next = prev.next; 将node的next,指向prev的下一个元素
- 第三步:prev.next = node; 再让prev的next指向node
具体的代码实现,注意代码实现的三个步骤和以上三张图片是一一对应的:
// 在链表的index (0-based)位置添加新的元素e
// 在链表中不是一个常用的操作,练习用:)
public void add(int index, E e){
if (index < 0 || index > size)
throw new IllegalArgumentException("Add failed. Illegal index.");
if (index == 0)
addFirst(e);
else{
Node prev = head;
for (int i = 0; i < index - 1; i ++)
prev = prev.next;
// 上三行代码表示的是:prev这个变量在链表中一直向前移动,直到移动到 index - 1 这个位置
// 这样就找到了待插入节点的前一个节点
// 这三个步骤和图片一一对应
Node node = new Node(e);
node.next = prev.next;
prev.next = node;
// 和上方三行代码相同,一行代码可概括:
prev.next = new Node(e, prev.next);
size ++;
}
}
最终链表状态如下:
(3)在链表末尾添加元素
// 在链表末尾添加新的元素e
public void addLast(E e){
add(size, e);
}
4-3 使用链表的虚拟头节点
在链表头添加一个null的节点,称为虚拟头节点 (dummyHead),这样使得在链表头添加元素的逻辑和在链表中添加元素的逻辑一致,在索引为0处插入元素时,就不需要额外处理。
- dummyHead位置的元素是根本不存在的,对用户来讲也是没有意义的,它知识为了我们编写逻辑方便而出现的一个虚拟的头节点。这样的内部机制对于用户而言也是屏蔽的。
private Node dummyHead;
private int size;
public LinkedList(){
dummyHead = new Node(null, null);
size = 0;
}
注意将以下代码和原先的add函数进行对比:
// 在链表的index (0-based)位置添加新的元素e
// 在链表中不是一个常用的操作,练习用:)
public void add(int index, E e){
if (index < 0 || index > size)
throw new IllegalArgumentException("Add failed. Illegal index.");
Node prev = dummyHead;
for (int i = 0; i < index; i ++)
prev = prev.next;
prev.next = new Node(e, prev.next);
size ++;
}
// 以上的代码将add的逻辑统一后,可以将(1)的代码简化为:
// 在链表末尾添加新的元素e
public void addLast(E e){
add(size, e);
}
//在链接头添加新的元素e
public void addFirst(E e){
add(0, e);
}
4-4 链表的遍历,查询和修改
(1)链表的遍历:
获取链表的第index个位置的元素使用get函数。get函数从索引为0的元素位置开始遍历;而插入元素(add函数)时,要找到添加的节点的前一个节点。
// 获得链表的第index (0-based)个位置的元素e
// 在链表中不是一个常用的操作,练习用:)
public E get(int index){
if (index < 0 || index >= size)
throw new IllegalArgumentException("Get failed. Illegal index.");
Node cur = dummyHead.next; // current
for (int i = 0; i < index; i ++)
cur = cur.next;
return cur.e;
}
- 根据get函数,输入index = 0 或 index = size -1 可以轻松写出获得链表第一个元素和最后一个元素:
// 获得链表的第一个元素
public E getFirst(){
return get(0);
}
// 获得链表的最后一个元素
public E getLast(){
return get(size - 1);
}
(2)链表的修改:修改链表的第index (0-based)个位置的元素为e
// 修改链表的第index (0-based)个位置的元素为e
// 在链表中不是一个常用的操作,练习用:)
public void set(int index, E e){
if (index < 0 || index >= size)
throw new IllegalArgumentException("Set failed. Illegal index");
Node cur = dummyHead.next;
for (int i = 0 ; i < index; i ++)
cur = cur.next;
cur.e = e;
}
(3)链表的查询:查找链表中是否有元素e
// 查找链表中是否有元素e
public boolean contains(E e){
Node cur = dummyHead.next;
while (cur != null){
if (cur.e.equals(e))
return true;
cur = cur.next;
}
return false;
}
@Override
public String toString(){
StringBuilder res = new StringBuilder();
// 方法一:
Node cur = dummyHead.next;
while (cur != null){
res.append(cur + "->");
cur = cur.next;
}
//方法二(使用二时要注释掉一):
for (Node cur = dummyHead.next; cur != null; cur = cur.next)
res.append(cur + "->");
res.append("Null");
return res.toString();
}
测试一下以上相关代码:
public class Main {
public static void main(String[] args) {
LinkedList<Integer> linkedList = new LinkedList<>();
for (int i = 0 ; i < 5; i ++){
linkedList.addFirst(i);
System.out.println(linkedList);
}
linkedList.add(2,666);
System.out.println(linkedList);
}
}
运行结果如下:
0->Null
1->0->Null
2->1->0->Null
3->2->1->0->Null
4->3->2->1->0->Null
4->3->666->2->1->0->Null
4-5 从链表中删除元素
目标:删除索引为2位置的元素
(1)第一步:从dummyNode开始遍历, 使用prev node找到待删除元素之前的一个节点,将prev节点的next指针,赋值为要删除节点delNode的next. 这样一来,prev节点直接跳过了它原本的next节点,指向了它原本next节点的next节点(即delete节点的next节点),numm -> 0 -> 1 -> 3 -> 4 在某种意义上相当于把这个2位置的节点删除了。
(2)第二步:为了方便java能够回收空间,将2位置节点的next和整个链表分离开来,即delNode.next = null.
具体的代码实现如下:
// 从链表中删除index(0-based)位置的元素,返回删除的元素
// 在链表中不是一个常用的操作,练习用:)
public E remove(int index){
if (index < 0 || index >= size)
throw new IllegalArgumentException("Remove failed. Index is illegal");
Node prev = dummyHead;
for (int i = 0; i < index; i ++)
prev = prev.next;
Node retNode = prev.next; // 要返回的节点retNode
prev.next = retNode.next; // prev的next直接跨过了retNode,指向了retNode的Next
retNode.next = null; // 让retNode和当前链表脱离
size --;
return retNode.e;
}
有了上方的remove方法,可以举一反三写出下面的removeFirst & removeLast方法:
// 从链表中删除第一个元素,返回删除的元素
public E removeFirst(){
return remove(0);
}
// 从链表中删除最后一个元素,返回删除的元素
public E removeLast(){
return remove(size - 1);
}
在Main函数中测试以下链表的删除操作,具体代码如下:
linkedList.remove(2);
System.out.println(linkedList);
linkedList.removeFirst();
System.out.println(linkedList);
linkedList.removeLast();
System.out.println(linkedList);
结果输出为下:
4->3->666->2->1->0->Null
4->3->2->1->0->Null
3->2->1->0->Null
3->2->1->Null
在这一小节结尾,分析一下链表的时间复杂度:
(1) 添加操作:O(n)
addLast (e) --> O(n) //从链表头遍历到链表尾
addFirst (e) --> O(1)
add(index, e) --> O(n/2) = O(n) // 平均
(2) 删除操作:O(n)
removeLast(e) --> O(n)
removeFirst(e) --> O(1)
remove(index, e) --> O(n/2) = O(n)
(3) 修改操作:O(n)
set(index, e) --> O(n) // 由于链表不支持随机访问,如果修改某一个位置的元素,必须从头到尾遍历找到他的位置
(4) 查找操作:O(n)
get(index) --> O(n) // 都需要从头遍历整个链表
contains(e) --> O(n) // 都需要从头遍历整个链表
这里需要注意find(e)方法在链表中没有用处,因为链表就算拿到了这个索引也没办法快速访问。
链表的时间复杂度及适用场景:
对于链表来说,它的增、删、改、查,复杂度都是O(n)。如果只对链表头进行操作,增、删:O(1); 对于链表来说,它适合做的事情其实是不去修改;对于查找来说,如果只查链表头的元素,查:O(1)。
4-6 使用链表实现栈
Interface Stack< E > <-------- LinkedListStack< E >
- void push (E)
- E pop ( )
- E peek ( )
- int getSize ( )
- boolean isEmpty ( )
代码实现如下:
public class LinkedListStack<E> implements Stack<E> {
private LinkedList<E> list;
public LinkedListStack(){
list = new LinkedList<>();
}
@Override
public int getSize(){
return list.getSize();
}
@Override
public boolean isEmpty(){
return list.isEmpty();
}
@Override
public void push(E e){
list.addFirst(e);
}
@Override
public E pop(){
return list.removeFirst();
}
@Override
public E peek(){
return list.getFirst();
}
@Override
public String toString(){
StringBuilder res = new StringBuilder();
res.append("Stack: top");
res.append(list);
return res.toString();
}
public static void main(String[] args) {
LinkedListStack<Integer> stack = new LinkedListStack<>();
//入栈5次
for (int i = 0; i < 5; i ++){
stack.push(i);
System.out.println(stack);
}
//出栈1次
stack.pop();
System.out.println(stack);
}
}
运行结果如下:
Stack:[0] top
Stack:[0, 1] top
Stack:[0, 1, 2] top
Stack:[0, 1, 2, 3] top
Stack:[0, 1, 2, 3, 4] top
Stack:[0, 1, 2, 3] top
接下来,我们来比较一下ArrayStack和LinkedListStack,看一看数组实现栈和链表实现栈两者的性能如何,性能测试代码如下:
import java.util.Random;
public class Main {
// 测试使用stack运行opCount个push和pop操作所需要的时间
private static double testStack(Stack<Integer> stack, int opCount){
long startTime = System.nanoTime();
Random random = new Random();
for (int i = 0; i < opCount; i++)
stack.push(random.nextInt(Integer.MAX_VALUE));
for (int i = 0; i < opCount; i++)
stack.pop();
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args){
int opCount = 100000;// int opCount = 10000000;
ArrayStack<Integer> arrayStack = new ArrayStack<>();
double time1 = testStack(arrayStack, opCount);
System.out.println("ArrayStack, time: " + time1 + " s");
LinkedListStack<Integer> linkedListStack = new LinkedListStack<>();
double time2 = testStack(linkedListStack, opCount);
System.out.println("LinkedListStack, time: " + time2 + " s");
// 其实这个时间比较复杂,因为LinkedListStack中包含更多的new操作
}
}
(1) 当int opCount = 100000时:
ArrayStack, time: 0.025875512 s
LinkedListStack, time: 0.021290711 s
(2) 当int opCount = 10000000时:
ArrayStack, time: 0.847503224 s
LinkedListStack, time: 1.759287239 s
发现因为两者的时间复杂度是一致的,所以性能差异不大(1.x倍,了不得两三倍,不会出现几百倍的情况),数组栈耗时的地方是数组大小的resize,链表栈耗时的地方是频繁new节点。
4-7 带有尾指针的链表:使用链表实现队列
链表的改进:
增加一个tail变量记录最后一个节点,这样在首尾添加元素都是O(1)复杂度,在队首删除元素是O(1),在队尾删除元素是O(n).
- 从两端插入元素 (head、tail) 都是容易的
- 从tail删除元素并不容易
- 从head端删除元素,从tail端插入元素
- 由于对链表的操作全在head端或者tail端完成,所以不使用dummyHead节点。
因为不涉及到对链表中间的元素进行插入或删除操作,所以不需要统一对链表中间元素和对链表两端元素操作的逻辑一致性,但仍要注意当链表为空时,head和tail都将指向空。使用链表实现队列的代码如下:
public class LinkedListQueue<E> implements Queue<E> {
private class Node {
public E e;
public Node next;
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node(E e) {
this(e, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
private Node head, tail;
private int size; // 链表中存储了多少元素
public LinkedListQueue(){
head = null;
tail = null;
size = 0;
}
@Override
public int getSize(){
return size;
}
@Override
public boolean isEmpty(){
return size == 0;
}
@Override
public void enqueue(E e){
if (tail == null){
tail = new Node(e);
head = tail;
}
else {
tail.next = new Node(e);
tail = tail.next;
}
size ++;
}
@Override
public E dequeue(){
if (isEmpty())
throw new IllegalArgumentException("Cannot dequeue from any empty queue");
Node retNode= head;
head = head.next;
retNode.next = null;
if (head == null)
tail = null;
size --;
return retNode.e;
}
@Override
public E getFront(){
if (isEmpty())
throw new IllegalArgumentException("Queue is empty.");
return head.e;
}
@Override
public String toString(){
StringBuilder res = new StringBuilder();
res.append("Queue: front");
Node cur = head;
while (cur != null){
res.append(cur + "->");
cur = cur.next;
}
res.append("NULL tail");
return res.toString();
}
public static void main(String[] args){
LinkedListQueue<Integer> queue = new LinkedListQueue<>();
for (int i = 0; i < 10; i ++){
queue.enqueue(i);
System.out.println(queue);
if (i % 3 == 2){
queue.dequeue();
System.out.println(queue);
}
}
}
}
代码运行后的结果如下:
Queue: front0->NULL tail
Queue: front0->1->NULL tail
Queue: front0->1->2->NULL tail
Queue: front1->2->NULL tail
Queue: front1->2->3->NULL tail
Queue: front1->2->3->4->NULL tail
Queue: front1->2->3->4->5->NULL tail
Queue: front2->3->4->5->NULL tail
Queue: front2->3->4->5->6->NULL tail
Queue: front2->3->4->5->6->7->NULL tail
Queue: front2->3->4->5->6->7->8->NULL tail
Queue: front3->4->5->6->7->8->NULL tail
Queue: front3->4->5->6->7->8->9->NULL tail
接下来我们来比较数组队列、循环队列、链表队列三者的性能如何,代码如下:
public static void main(String[] args){
int opCount = 100000;
ArrayQueue<Integer> arrayQueue = new ArrayQueue<>();
double time1 = testQueue(arrayQueue, opCount);
System.out.println("ArrayQueue, time: " + time1 + " s");
LoopQueue<Integer> loopQueue = new LoopQueue<>();
double time2 = testQueue(loopQueue, opCount);
System.out.println("LoopQueue, time: " + time2 + " s");
LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<>();
double time3 = testQueue(linkedListQueue, opCount);
System.out.println("linkedListQueue, time: " + time3 + " s");
}
运行结果如下,可以发现性能对比:数组队列 < 循环队列 < 链表队列
ArrayQueue, time: 21.331849579 s
LoopQueue, time: 0.015065302 s
linkedListQueue, time: 0.010613044 s