05-双向链表

1.双向链表

我们之前学习的链表,也叫做单向链表,它有一些缺点:

  • 无论访问哪个节点,即使是最后一个节点,也永远都是从头结点向后查找访问。

使用双向链表可以提升链表的综合性能。

1.1双向链表的设计

1.双向链表除了指向头结点的first指针,还多多了一个last指针,指向最后一个节点。

  • 这样如果找的是比较靠后的位置的节点,那么就可以从last指针开始向前扫描。

2.双向链表的每个节点中多了一个prev前指针,指向它的上一个节点。
在这里插入图片描述

2.实现双向链表

在原来的LinkedList基础上实现双向链表。保留原来的LinkedList并改名为SingleLinkedList。

在这里插入图片描述

2.1.属性和构造

public class LinkedList<E> extends AbstractList<E>{
	private Node<E> first;
	private Node<E> last;
	
	//内部类:Node节点
	private static class Node<E>{
		E element;
		Node<E> next;
		Node<E> prev;
		public Node(Node<E> prev, E element, Node<E> next){
			this.prev = prev;
			this.element = element;
			this.next = next;
		}	
	}
}

2.2.node(int index)

1.返回索引位置处的节点:原来都是从头结点向后找,现在不能这样写了,因为还可以从后向前找。

靠近前半部分,从前面开始找;靠近后半部分,从后半部分开始找。

	/**
	 * 返回索引位置处的节点
	 * @param index
	 * @return
	 */
	private Node<E> node(int index) {
		rangeCheck(index);
		Node<E> node = null;
		//如果索引靠近左侧
		if(index < (size >> 1)) {
			node = first;
			for (int i = 0; i < index; i++) {
				node = node.next;
			}
		}else {//否则索引靠近右侧
			node = last;
			for (int i = size-1; i > index; i--) {
				node = node.prev;
			}
		}
		return node;
	}

2.get/set:改(存)查(取)方法调用node()方法,既然node()方法改过了,它们就不用改了

在这里插入图片描述

2.3.clear()

1.清空链表的话,first和last指针都要清空

	@Override
	public void clear() {
		size = 0;
		first = null;
		last = null;
	}

2.疑惑:之前我们说,如果一个对象,没有引用指向的话,那么它就会成功死掉。所以我们之前清空单向链表,直接将first置为null即可。

但是现在即使我们将first和last指针都清空,但是每个节点都还有引用/指针指向,那么这些节点还能被成功的清空吗?
在这里插入图片描述

3.gc root:能够成功清理
Java里面有一个gc root对象,如果这些节点没有被gc root引用的话,就会被干掉。

比如栈指针(局部变量)就是gc root变量:这个机制会判断,这些节点有没有被栈指针(局部变量)指向包括间接指向,如果没有就会被回收清理。

4.所以clear()这样写即可,我们可以用finalize()方法测试。

2.4.add()方法

  1. 具体代码
	@Override
	public void add(int index, E element) {
		rangeCheckForAdd(index);
		//1.当插入到链尾时index=size
		if(index == size) {
			Node<E> beforeNode = last;
			Node<E> newNode = new Node<E>(beforeNode, element, null);
			if(beforeNode == null) {
				//2.当链表为空没有元素时,size=0
				first = newNode;
			}else {
				beforeNode.next = newNode;
			}
			last = newNode;
		}else {
			//找到插入位置的节点:这个节点之后会变成新节点的下一个节点
			Node<E> nextNode = node(index);
			//得到新节点的前一个节点
			Node<E> beforeNode = nextNode.prev;
			//新节点
			Node<E> newNode = new Node<E>(beforeNode, element, nextNode);
			//连线
			if(beforeNode == null) {
				//3.index = 0时
				first = newNode;
			}else {
				beforeNode.next = newNode;
			}
			nextNode.prev = newNode;
		}
		size++;
	}
  1. 注意size=0,添加第一个元素的情况

在这里插入图片描述

在这里插入图片描述

  1. 注意当插入到链尾时index=size
    在这里插入图片描述
  2. 注意:index = 0时
    在这里插入图片描述

2.5.remove()方法

在这里插入图片描述
代码:

	@Override
	public E remove(int index) {
		rangeCheck(index);
		
		Node<E> oldNode = node(index);
		Node<E> prevNode = oldNode.prev;
		Node<E> nextNode = oldNode.next;
		
		//如果删除的是0结点,那么prevNode是null
		if(prevNode == null) {
			first = nextNode;
		}else {
			prevNode.next = nextNode;
		}
		
		//如果删除的是size-1结点,那么nextNode是null
		if(nextNode == null) {
			last = prevNode;
		}else {
			nextNode.prev = prevNode;
		}
		
		size--;
		return oldNode.element;
	}

2.6.toString()方法改进

public class LinkedList<E> extends AbstractList<E>{
	private Node<E> first;
	private Node<E> last;
	
	//内部类:Node节点
	private static class Node<E>{
		E element;
		Node<E> next;
		Node<E> prev;
		public Node(Node<E> prev, E element, Node<E> next){
			this.prev = prev;
			this.element = element;
			this.next = next;
		}
		
		@Override
		public String toString() {
			StringBuilder sb = new StringBuilder();
			if(prev != null) {
				sb.append(prev.element);
			}else {
				sb.append("null");
			}
			sb.append("-").append(element).append("-");
			if(next != null) {
				sb.append(next.element);
			}else {
				sb.append("null");
			}
			return sb.toString();
		}
	}
	
	@Override
	public String toString() {
		StringBuilder string = new StringBuilder();
		Node<E> tmpNode = first;
		string.append("[");
		for (int i = 0; i < size; i++) {
			if(i != 0) string.append(", ");
			string.append(tmpNode);
			tmpNode = tmpNode.next;
		}
		string.append("]");
		return string.toString();
	}
}

3.双向链表小结

3.1.双向链表VS单向链表

1.粗略对比一下删除的操作数量:需要查找到要删除的位置

  • 单向链表平均时间复杂度:(1+2+3+…+n)/n=1/2+n/2
  • 双向链表,单次最多查找n/2,平均时间复杂度:(1+2+…+n/2)/n,1/2+n/4
  • 复杂度虽然还是O(n):但是删除操作的效率提高了近一半。

3.2.双向链表VS动态数组

动态数组:开辟,销毁内存空间的次数相对较少,但可能造成内存空间的浪费。(可以通过动态缩容机制解决)

双向链表:开辟,销毁内存很频繁。每次添加,删除节点都要开辟销毁内存,但不会造成内存空间的浪费,需要多少用多少。

小结

1.如果频繁在尾部进行添加,删除操作:动态数组,双向链表均可选择。因为数组直接在尾部添加元素不需要移动其他元素,复杂度是O(1);双向链表由于有last指针,能直接找到尾节点,不用从头遍历了,复杂度也是O(1);

2.如果频繁的在头部进行添加,删除操作,建议选择双向链表:首先肯定不能选择动态数组,动态数组此时是最坏的复杂度O(n)。单向链表和双向链表此时差不多,只不过LinkedList在Java中的实现本来就是双向链表。

3.如果有频繁的在任意位置添加,删除操作,建议选择使用双向链表:双向链表的查找效率比单向链表高一倍,所以就会导致删除和添加操作比单向链表更高。

4.如果有频繁的查询操作(随机访问操作),建议选择使用动态数组。

5.有了双向链表,单向链表是否就没有任何用处了呢?

并非如此,在哈希表的设计中就用到了单链表。至于原因,后续再讲。

4.源码分析

1.Java官方的LinkedList也是一个双向链表

在这里插入图片描述
2.对比一下源码和我们的实现

  1. clear:

在这里插入图片描述
在之前我们分析过,只要将firstlast指针置位null,那么剩下的节点由于没有被gc root引用,仍然会被清空回收,所以我们没有对剩下的节点在做单独的处理。

但是我们发现JDK中的clear()方法,除了将firstlast指针置位null外,还分别对单独的每个节点都做了清空处理:这是因为JDK中实现的LinkedList有一个迭代器,这个迭代器主要是用来遍历链表的。这就可能存在一个情况,虽然firstlast指针不再指向这些节点了,但是迭代器对象还可能指向这些节点,正在使用着这些节点,那么就会导致被指向的节点不会销毁。由于LinkedList是双向链表,节点相互指向,就又会导致所有节点都不会被清空。

所以JDK源码将节点的next和prev指针主动置为null,那么只有被迭代器引用的节点还存在,其他的节点就会被回收,方便GC回收。

A:好的,下面是K - 双向链表练习题的解答。 题目描述: 设计一个数据结构,要求实现双向链表的基本操作:插入、删除、反转等,并实现一个方法向链表中添加一个元素。同时,该数据结构需要支持以下操作: 1. get(k):返回链表中第 k 个节点的值。如果 k 小于 1 或大于链表的长度,则返回 -1。 2. addAtHead(val):在链表的头部插入一个值为 val 的节点。 3. addAtTail(val):在链表的尾部插入一个值为 val 的节点。 4. addAtIndex(index,val):在链表中的第 index 个节点前插入一个值为 val 的节点。如果 index 等于链表的长度,则在链表的末尾添加该节点。如果 index 大于链表的长度,则不会插入。 5. deleteAtIndex(index):如果索引 index 有效,则删除链表中的第 index 个节点。 解题思路: 题目要求我们设计一个双向链表数据结构,并提供选定的一些操作。这是一个经典的链表问题,我们需要先设计链表节点的结构体,并在节点中保留前驱和后继指针,便于双向操作。然后,我们可以定义一个链表类,在该类中定义一些方法来实现链表的基本操作。 本题需要实现的方法包括: 1. get(k):遍历链表,找到第 k 个节点并返回该节点的值。 2. addAtHead(val):创建一个新节点,将该节点作为头节点,并更新头节点的前驱指针。 3. addAtTail(val):创建一个新节点,将该节点作为尾节点,并更新尾节点的后继指针。 4. addAtIndex(index,val):遍历链表,找到第 index - 1 个节点,创建一个新节点,并将其插入到该节点的后面。如果 index 为零,则将新节点插入到头部。如果 index 等于链表的长度,则将新节点插入到末尾。 5. deleteAtIndex(index):遍历链表,找到第 index - 1 个节点,并将其后继指针指向第 index + 1 个节点。如果 index 为零,则更新头节点。如果 index 等于链表的长度 - 1,则更新尾节点。 代码实现: 下面是基于C++的实现代码,其中Node是一个链表节点的结构体,List是链表类的定义: ```cpp #include<iostream> using namespace std; // 链表节点结构体 struct Node { int val; // 节点的值 Node* pre; // 前驱指针 Node* nxt; // 后继指针 Node(int _val):val(_val),pre(nullptr),nxt(nullptr){} // 构造函数 }; // 链表类 class List{ private: Node* head; // 头节点 Node* tail; // 尾节点 int size; // 链表长度 public: List():head(nullptr),tail(nullptr),size(0){} // 构造函数 int get(int k){ if(k < 1 || k > size) // 判断k是否合法 return -1; Node* p = head; for(int i=1; i<k; i++) // 遍历链表,找到第k个节点 p = p->nxt; return p->val; // 返回节点的值 } void addAtHead(int val){ Node* p = new Node(val); // 创建新节点 if(size == 0){ // 链表为空的情况 head = p; tail = p; }else{ // 链表非空的情况 p->nxt = head; // 插入节点 head->pre = p; head = p; } size++; // 更新链表长度 } void addAtTail(int val){ Node* p = new Node(val); // 创建新节点 if(size == 0){ // 链表为空的情况 head = p; tail = p; }else{ // 链表非空的情况 tail->nxt = p; // 插入节点 p->pre = tail; tail = p; } size++; // 更新链表长度 } void addAtIndex(int index, int val){ if(index > size) // index不合法,不插入 return; if(index <= 0) // 如果index小于等于0,插入到头部 addAtHead(val); else if(index == size) // 如果index等于size,插入到尾部 addAtTail(val); else{ // 如果index在链表中 Node* p = head; for(int i=1; i<index; i++) // 找到第index-1个节点 p = p->nxt; Node* q = new Node(val); // 创建新节点 q->nxt = p->nxt; // 插入节点 p->nxt->pre = q; p->nxt = q; q->pre = p; size++; // 更新链表长度 } } void deleteAtIndex(int index){ if(index < 0 || index >= size) // index不合法,不删除 return; if(index == 0){ // 如果要删除的是头节点 head = head->nxt; // 更新头节点 if(head == nullptr) // 如果链表为空,尾节点也需要更新 tail = nullptr; else head->pre = nullptr; }else if(index == size-1){ // 如果要删除的是尾节点 tail = tail->pre; // 更新尾节点 tail->nxt = nullptr; }else{ // 如果要删除的是中间节点 Node* p = head; for(int i=1; i<index; i++) // 找到第index-1个节点 p = p->nxt; p->nxt = p->nxt->nxt; // 删除节点 p->nxt->pre = p; } size--; // 更新链表长度 } }; int main(){ List l; l.addAtHead(1); l.addAtTail(3); l.addAtIndex(1,2); // 链表变为[1,2,3] cout<<l.get(1)<<" "; // 返回2 l.deleteAtIndex(1); // 现在链表是[1,3] cout<<l.get(1)<<" "; // 返回3 return 0; } ``` 总结: 双向链表实现相对较多的语言,相对单向链表更适合一些场景;比如说LUR Cache。对于双向链表的实现,我们需要注意节点间指针的指向关系,以及头节点和尾节点的处理。感兴趣的读者可以继续尝试其他链表问题,如链表的分割、链表的反转等。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值