目录
引言:
链表是java语言开发中常见的一种数据结构,应用也很广泛。比如Linked系列的Collection子类,以及Map下的HashMap和ConcurrentHashMap等等,都或多或少的用到了链表结构,来优化其元素的增删效率。
之所以写这篇博文,是因为CSDN每日一练中有些题涉及到了链表,特别是链表反转,颇为让人头疼,经过几天的琢磨,今天想着分享一下自己对链表的理解与心得,也希望码友们有一天碰到类似问题,可引以为参考。
一、链表介绍
1)链表是一种物理存储单元上非连续、非顺序存储的数据结构,区别于数组,数组在内存中固定分配一块连续的内存,来存储其元素。
什么是连续?
就是这些元素有序的放在一起方便按照编号快速寻找,他们每个都会被数字来标记,这些数字就是下标也即索引,他们必须是0、1、2、3、4这样具有连续性的。
非连续意味着:
它们存储在堆中的某些地方,各自有自己的指针(类似句柄、名字等等),如果你记得我,肯定是记得我的名字,这里指“地址值”或指针,这样你寻找我时需要打电话确认我在哪里,然后走很远找到我,也因此链表查询效率较低,但是如果你与我绝交,你只需要忘记我即可,这里指删除。反观数组,如果队列里某个调皮的学生请假了,队列编号需要重新调整,并且位置也要调整。
数据结构中有一种叫做“散列表”的大家可以去了解一下。
2)链表由一系列节点(Node)组成,且节点与节点之间单向或双向引用,即 N.next 指向 N+1或N.previous指向N-1,或两者结合。next和previous其实就是指向其它元素的引用,也可以说是指针。
3)链表的单个节点组成部门由当前节点的描述信息与下一个或前一个节点的指针域或两者并存的指针域组成。
4)链表插入的时间复杂度为O(1),访问节点最坏情况下则需要 O(n) 的时间。
二、链表的应用
1)LinkedList
LinkedList是有序且增删效率较高的线性双向链表之一。双向链表其实也没那么高深莫测,就是比普通链表多了一个指向前置节点的引用。下面是LinkedList的节点类,大家可以看看
private static class Node<E> { // 当前节点描述信息 E item; // 指向 N + 1 节点的指针域 Node<E> next; // 指向 N - 1 节点的指针域 Node<E> prev; // 带参构造 Parameterized constructor Node(Node<E> prev, E element, Node<E> next) { this.item = element; this.next = next; this.prev = prev; } }
2) HashMap
HashMap,底层其实是数组+链表+红黑树这样一个组合式数据结构,目的是为了更好的改善随着数据量增加而面临的增删改查效率问题。既然到到这儿了,也顺表讲一下为何HashMap会用到链表。大家都知道,HashMap使用hash值来索引元素,尽管hash算法可以让hash值唯一,但不是绝对的,因此在数据量特别庞大时,就会出现hash值碰撞,即两个元素计算出来的hash值一样,如果一样两个元素放到数组中不就被其中一个覆盖了吗?当hash值碰撞,就将新元素的引用赋值给数组中同样hash值的另一个元素的next指针域,接下来重复这样的操作就可以了。红黑树是什么呢?红黑树和AVL树很相似,只是前者多了红黑节点标识的特征。但他们的出现都是为了解决搜索性能相关问题的,这么说你就明白红黑树的意义了。链表的问题是当数据里大时,搜索性能会大打折扣,这时红黑树的作用不言自明。
放个图理解更好:这个是我从百度找的,大家看一下,画的非常好,红黑树都包括了。
嗯,感觉有些跑题了,但是这些知识本身是互相关联的,且可举一反三。
下面是HashMap的链表节点类,这是一个单向链表:
static class Node<K,V> implements Map.Entry<K,V> { // 节点描述信息 final int hash; final K key; V value; // 指向下一个节点的指针域 Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; }// Methods follow
......
}
单向链表图解:
其它Linked系列的,除此之外还有ConcurrentHashMap都使用了链表结构,我这里就不一一列举了,码友们可以自己去阅读源码,一探究竟。
三、链表的操作
💡💡💡 操作之前,我们需要先创建一个链表类,下面我分别创建1个单向以及1个双向链表类
// 单向链表
public class SimpleNode {
private int value;
private SimpleNode next;
public SimpleNode(SimpleNode next, int v){
this.next = next;
this.value = v;
}
}
// 双向链表
class LinkedList{
private int element;
private LinkedList prev;
private LinkedList next;
public LinkedList(LinkedList prev, int e, LinkedList next){
this.prev = prev;
this.element = e;
this.next = next;
}
}
1)增删改查(刚刚接触的可以看这里), 这里只讲单向链表
- 创建一个长度为 10 的链表,基本操作:Node.next = new Node();
public static void nodeCreation(){
// t初始化一个链表节点
SimpleNode n1 = new SimpleNode(1);
// 创建一个 tmp 容器,临时存储 n1 节点
SimpleNode tmp = n1;
// 创建一个长度为10的链表
for (int i = 2; i <= 10; i++) {
tmp.next = new SimpleNode(i);
tmp = tmp.next;
}
}
- 删除链表中的某一个节点,基本操作:Node.next = Node.next.next;
这里并不是实际性的删除,而是逻辑删除。如果删除的节点在2个节点之间,删除其实就是将被删除节点的前置节点指向其后置节点,即为删除。如果是末尾节点,就是将其前置节点指向 null 值即可。头节点删除,则是将原节点赋值为其next节点即可。
// 删除最后一个节点
public static void nodeRemove(SimpleNode head){
// 创建一个虚拟节点,其next指向 head 节点,这是为了防止当节点的长度为 1 时空指针
SimpleNode h = new SimpleNode(-1);
h.next = head;
while (h.next.next != null) {
h = h.next;
}
h.next = null;
}
- 修改链表中某一个节点的值,Node.value = value;
// 修改第5个节点的 Node 节点的值为 10
public void changeValue(Node node){
Node tmp = node;
// 创建索引
int idx = 1;
while (tmp != null) {
if(idx == 5) {
tmp.value = 10;
}
tmp = tmp.next;
idx++;
}
}
- 搜索某一个节点
// 搜索值为 100 的节点
public void nodeSearch(Node node){
Node tmp = node;
while (tmp != null) {
if(tmp.value == 100) {
System.out.println(tmp.value);
}
tmp = tmp.next;
}
}
2)下面有一个小插曲。LinkedList中用到了简单的链表二分搜索,相比于线性搜素,效率更高。但前提是,该链表为双向链表,且记录了链表中的首尾节点。
Node<E> node(int index) { // assert isElementIndex(index); // size是链表长度,右移1位,相当于 size / 2. 为了更快命中目标, // 需要利用LinkedList中的first和last节点。当index < (size >> 1), // 意味着该节点处于链表的左侧部分,我们就需要从左向右遍历 // 反之从右向左遍历 if (index < (size >> 1)) { // 左向右 Node<E> x = first; for (int i = 0; i < index; i++) x = x.next; return x; } else { // 右向左 Node<E> x = last; for (int i = size - 1; i > index; i--) x = x.prev; return x; } }
四、练习
1) 反转链表
描述:给你一个单向链表,你能不能把它反转过来?大家可以先不看代码,自己写一遍,再来看,会有很大收获!
什么是反转链表?
比如某个链表是这样的,为了方便理解,我使用有序字符,A => B => C => D,反转后就是 D => C => B => A。它不像数组反转,使用任何一个排序算法即可解决。下面是代码,大家看一遍,后面我详细分析反转流程以及总结
private static ListNode nodeReverse(ListNode head){
// pre 用来接收反转后的链表, tmp 为临时容器
ListNode pre = null, tmp;
while (head != null){
// tmp 记录下一个节点
tmp = head.next;
// 反转
head.next = pre;
// pre 赋值为刚才反转的节点
pre = head;
// 将 tmp 赋值给 head, 等同于 head = head.next
head = tmp;
}
return pre;
}
pre变量是反转后我们需要交代的数据,temp,当交换数据时,用来临时存储数据。
while循环出口:当遍历到最后节点时,while循环结束,因为尾部节点的next指向的是null!
第一圈时:tmp= 'B';head.next = pre = null; pre = head = 'A'; head = tmp = 'B';
第二圈时:tmp = 'C'; head.next = pre = 'A'; pre = head = 'B'; head = tmp = 'C';
第三圈时:tmp = 'D'; head.next = pre = 'B'; pre = head = 'C'; head = tmp = 'D';
第四圈时:next = null; cur.next = pre = 'C'; pre = head = 'D'; head = tmp = 'null';
此时,循环结束,链表反转成功。
这里的pre相当于一个新的容器, 用来存储反转后的节点。
我们假设一个场景:
tmp是head的辅助。他们要从出发点A走到D,head负责记住自己走过的路,tmp负责记录下一步要走的路。pre则在终点等着它们,当head每走到一个地方,都需要向pre报告,pre则为head记录每次走过的路线,因此pre为head做的记录是倒着的,最后由辅助tmp告诉head下一个要怎么走。
第一次,head自己看了地图,发现自己的下一步是B,就把地图给了tmp,说我们下一步要走到B,此后,地图交给你,我不在负责下一步的分析,但是我需要记住上一步走的地方,tmp鞠了躬,说:"当然,在下之荣幸。" 走完A,head打电话告诉pre,我们走完A了,你帮我记录一下。
....
第四圈,head与tmp两人走完了全程,根据每个人的职责,
pre记录的是:
① A;② B => A;③ C => B => A;④ D => C => B => A;
head的记录是(.next的值):
null(出发);① A;② B;③ C;④ D;
tmp的记录是:
①:B => C => D;② C => D;③ D; ④ null(行程结束);
链表全反转,就讲到这里,下面我们一起讨论反转链表的某一个段落;
2)根据提供的区间,来反转该链表区间内的所有节点。
描述:有链表 A => B => C => D => E, m = 2, n = 4, 请将 m - n 区间内的链表进行反转。
1、先创建一个空节点或者说是虚拟节点,作为存储反转链表的容器;
2、找到区间头节点的上一个节点;
// 虚拟节点
ListNode dummy = new ListNode(0);
dummy.next = head;
// pre为dummy的副本, tmp为临时存储容器
ListNode pre = dummy, tmp;
// 遍历到m - 1的位置,也就是区间头节点的上一个节点
for(int i = 1; i < m; i++) {
pre = pre.next;
}
2.1 这里解释一下为何是dummy.next = head 而不是 dummy = head; 当遍历的区间开始节点是全链表的头节点时,也就是pre=head,pre.next是无法指向头节点的,因此我们需要为pre前置一个虚拟节点,来指向head即头节点。
3、取出区间头节点
4、开始遍历并反转
// 取出区间的头节点
head = pre.next;
for(int i = m; i < n; i++){
// tmp临时存储遍历的剩余部分节点
tmp = head.next;
// 准备下一个要遍历的节点
head.next = tmp.next;
// 开始反转
tmp.next = pre.next;
pre.next = tmp;
}
下面我通过两个步骤,画了一张反转m to n之间的过程
3)链表是否有环
描述:有链表,1 -> 2 -> 3 -> 4 -> 1 ,请写一个算法证明该链表是环形链表
这个涉及到算法了,相对前面的基础有些困难,若有了思路,其实也就一般般的题。
我先给大家想象一个场景,作为男生我们都喜欢跑步,特别是在运动场,一般运动场的设计都是圆形的。假设你和苏神一起跑,作为亚洲第一百米飞人,当你跑第完第一圈时,苏神已经跑完3圈了。这期间发生了什么?
这个场景能很好的解释环的概念,环没有终点,但只要在环形上逆向或相同方向保持不同速度运动的两个点,始终会相遇。就像每天你能看到太阳升起一样。
下面我们用代码来实现这个算法:
public boolean hasCycle(ListNode head) {
// 定义快慢指针
ListNode fast = head, slow = head;
// 这里只判断快指针, 因为慢指针在循环内已经判断了
while(fast != null) {
// 遍历慢指针
slow = slow.next;
// 遍历快指针
fast = fast.next;
// 如果快或慢指针为null,说明无环
if(fast == null || slow == null) return false;
// 这里才是快指针的真正操作
if(fast.next != null) {
fast = fast.next;
} else { // 若快指针的next为null,说明无环
return false;
}
// 快慢指针相遇且hashCode都相等说明有环
if(slow.val == fast.val && slow.hashCode() == fast.hashCode()) {
return true;
}
}
return false;
}
五、总结
1、反转链表需要注意两大点
1)在反转的过程中要确保链表的遍历能够正常完成
2)为了避免原链表与反转后的链表互相干扰或藕断丝连,应尽量重新定义一个节点,来链接反转后的顺序节点。当然练习第一题的代码并不是按照这个原则来做的,但是tmp作为保证遍历的顺序进行的关键,始终没有被篡改,因此在head原链表被修改之后,tmp又将本属于head的值赋给了它,以保证遍历的顺序执行。
以上是我对链表的理解,以及对链表如何反转的一些见解和经验,原创不易,希望您高抬贵手一键三连。最后再给大家留一个题,就是如何反转双向链表?有思路的同学可以在评论区留言,共同讨论🙂🙂🙂