若是说数据结构是算法的基础,那么数组和链表就是数据结构的基础。 由于像堆,栈,对,图等比较复杂的数组结基本上均可以由数组和链表来表示,因此掌握数组和链表的基本操做十分重要。java
链表的知识点蛮多的,因此分红上下两篇,这篇主要讲解链表翻转的解题技巧,下篇主要讲关于链表快慢指针的知识点,干货不少,建议先收藏再看。看完保证收获满满!node
今天就来看看链表的基本操做及其在面试中的常看法题思路,本文将从如下几个点来说解链表的核心知识git
相信你们已经开始火烧眉毛地想用链表解题了,不过在开始以前咱们仍是要先来温习下链表的定义,以及它的优点与劣势,磨刀不误砍柴功!github
链表是物理存储单元上非连续的、非顺序的存储结构,它是由一个个结点,经过指针来联系起来的,其中每一个结点包括数据和指针。面试
链表的非连续,非顺序,对应数组的连续,顺序,咱们来看看整型数组 1,2,3,4 在内存中是如何表示的算法
那链表在内存中是怎么表示的呢数组
能够看到每一个结点都分配在非连续的位置,结点与结点之间经过指针连在了一块儿,因此若是咱们要找好比值为 3 的结点时,只能经过结点 1 从头至尾遍历寻找,若是元素少还好,若是元素太多(好比超过一万个),每一个元素的查找都要从头开始查找,时间复杂度是O(n),比起数组的 O(1),差距不小。缓存
除了查找性能链表不如数组外,还有一个优点让数组的性能高于链表,这里引入程序局部性原理,啥叫程序局部性原理。数据结构
咱们知道 CPU 运行速度是很是快的,若是 CPU 每次运算都要到内存里去取数据无疑是很耗时的,因此在 CPU 与内存之间每每集成了挺多层级的缓存,这些缓存越接近CPU,速度越快,因此若是能提早把内存中的数据加载到以下图中的 L1, L2, L3 缓存中,那么下一次 CPU 取数的话直接从这些缓存里取便可,能让CPU执行速度加快,那什么状况下内存中的数据会被提早加载到 L1,L2,L3 缓存中呢,答案是当某个元素被用到的时候,那么这个元素地址附近的的元素会被提早加载到缓存中函数
以上文整型数组 1,2,3,4为例,当程序用到了数组中的第一个元素(即 1)时,因为 CPU 认为既然 1 被用到了,那么紧邻它的元素 2,3,4 被用到的几率会很大,因此会提早把 2,3,4 加到 L1,L2,L3 缓存中去,这样 CPU 再次执行的时候若是用到 2,3,4,直接从 L1,L2,L3 缓存里取就好了,能提高很多性能
画外音:若是把CPU的一个时种当作一秒,则从 L1 读取数据须要 3 秒,从 L2 读取须要 11 秒,L3读取须要 25秒,而从内存读取呢,须要 1 分 40 秒,因此程序局部性原理能对 CPU 执行性能有很大的提高
而链表呢,因为链表的每一个结点在内存里都是随机分布的,只是经过指针联系在一块儿,因此这些结点的地址并不相邻,天然没法利用 程序局部性原理 来提早加载到 L1,L2,L3 缓存中来提高程序性能。
画外音:程序局部性原理是计算机中很是重要的原理,这里不作展开,建议你们查阅相关资料详细了解一下
如上所述,相比数组,链表的非连续,非顺序确实让它在性能上处于劣势,那什么状况下该使用链表呢?考虑如下状况
因为数组空间的连续性,若是要为数组分配 500M 的空间,这 500M 的空间必须是连续的,未使用的,因此在内存空间的分配上数组的要求会比较严格,若是内存碎片太多,分配连续的大空间极可能致使失败。而链表因为是非连续的,因此这种状况下选择链表更合适。
若是涉及到元素的频繁删除和插入,用链表就会高效不少,对于数组来讲,若是要在元素间插入一个元素,须要把其他元素一个个日后移(如图示),觉得新元素腾空间(同理,若是是删除则须要把被删除元素以后的元素一个个往前移),效率上无疑是比较低的。
而链表的插入删除相对来讲就比较简单了,修改指针位置便可,其余元素无需作任何移动操做(如图示:以插入为例)
综上所述:若是数据以查为主,不多涉及到增和删,选择数组,若是数据涉及到频繁的插入和删除,或元素所需分配空间过大,倾向于选择链表。
说了这么多理论,相信读者对数组和链表的区别应该有了更深入地认识了,尤为是 程序局部性原理,是否是开了很多眼界^_^,若是面试中问到数组和链表的区别能回答到程序局部性原理,会是一个很是大的亮点!
接下来咱们来看看链表的表现形式和解题技巧
须要说明的是有些代码像打印链表等限于篇幅的关系没有在文中展现,我把文中全部相关代码都放到 github 中了,你们若是须要,能够访问个人 github 地址 下载运行,文中全部代码均已用 Java 实现并运行经过
因为链表的特色(查询或删除元素都要从头结点开始),因此咱们只要在链表中定义头结点便可,另外若是要频繁用到链表的长度,还能够额外定义一个变量来表示。
须要注意的是这个头结点的定义是有讲究的,通常来讲头结点有两种定义形式,一种是直接以某个元素结点为头结点,以下
一种是以一个虚拟的节点做为头结点,即咱们常说的哨兵,以下
定义这个哨兵有啥好处呢,假设咱们不定义这个哨兵,来看看链表及添加元素的基本操做怎么定义的
/** * 链表中的结点,data表明节点的值,next是指向下一个节点的引用 */
class Node {
int data;// 结点的数组域,值
Node next = null;// 节点的引用,指向下一个节点
public Node(int data) {
this.data = data;
}
}
/** * 链表 */
public class LinkedList {
int length = 0; // 链表长度,非必须,可不加
Node head = null; // 头结点
public void addNode(int val) {
if (head == null) {
head = new Node(val);
} else {
Node tmp = head;
while (tmp.next != null) {
tmp.next = tmp;
}
tmp.next = new Node(val);
}
}
}
复制代码
发现问题了吗,注意看下面代码
有两个问题:
若是定义了哨兵结点,以上两个问题均可解决,来看下使用哨兵结点的链表定义
public class LinkedList {
int length = 0; // 链表长度,非必须,可不加
Node head = new Node(0); // 哨兵结点
public void addNode(int val) {
Node tmp = head;
while (tmp.next != null) {
tmp.next = tmp;
}
tmp.next = new Node(val);
}
}
复制代码
能够看到,定义了哨兵结点的链表逻辑上清楚了不少,不用每次插入元素都对头结点进行判空,也统一了每个结点的添加逻辑。
因此以后的习题讲解中咱们使用的链表都是使用定义了哨兵结点的形式。
作了这么多前期的准备工做,终于要开始咱们的正餐了:链表解题经常使用套路--翻转!
既然咱们要用链表解题,那咱们首先就构造一个链表吧 题目:给定数组 1,2,3,4 构形成以下链表 head-->4---->3---->2---->1
看清楚了,是逆序构造链表!顺序构造咱们都知道怎么构造,对每一个元素持续调用上文代码定义的 addNode 方法便可(即尾插法),与尾插法对应的,是头插法,即把每个元素插到头节点后面便可,这样就能作到逆序构造链表,如图示(以插入1,2 为例)
头插法比较简单,直接上代码,直接按以上动图的步骤来完成逻辑,以下
public class LinkedList {
int length = 0; // 链表长度,非必须,可不加
Node head = new Node(0); // 哨兵节点
// 头插法
public void headInsert(int val) {
// 1.构造新结点
Node newNode = new Node(val);
// 2.新结点指向头结点以后的结点
newNode.next = head.next;
// 3.头结点指向新结点
head.next = newNode;
}
public static void main(String[] args) {
LinkedList linkedList = new LinkedList();
int[] arr = {1,2,3,4};
// 头插法构造链表
for (int i = 0; i < arr.length; i++) {
linkedList.headInsert(arr[i]);
}
// 打印链表,将打印 4-->3-->2-->1
linkedList.printList();
}
}
复制代码
如今咱们加大一下难度,来看下曾经的 Google 面试题: 给定单向链表的头指针和一个节点指针,定义一个函数在 O(1) 内删除这个节点。
咱们知道,若是给定一个结点要删除它的后继结点是很简单的,只要把这个结点的指针指向后继结点的后继结点便可
如图示:给定结点 2,删除它的后继结点 3, 把结点 2 的 next 指针指向 3 的后继结点 4 便可。
但给定结点 2,该怎么删除结点 2 自己呢,注意题目没有规定说不能改变结点中的值,因此有一种很巧妙的方法,狸猫换太子!咱们先经过结点 2 找到结点 3,再把节点 3 的值赋给结点 2,此时结点 2 的值变成了 3,这时候问题就转化成了上图这种比较简单的需求,即根据结点 2 把结点 3 移除便可,看图
不过须要注意的是这种解题技巧只适用于被删除的指定结点是中间结点的状况,若是指定结点是尾结点,仍是要老老实实地找到尾结点的前继结点,再把尾结点删除,代码以下
/** * 删除指定的结点 * @param deletedNode */
public void removeSelectedNode(Node deletedNode) {
// 若是此结点是尾结点咱们仍是要从头遍历到尾结点的前继结点,再将尾结点删除
if (deletedNode.next == null) {
Node tmp = head;
while (tmp.next != deletedNode) {
tmp = tmp.next;
}
// 找到尾结点的前继结点,把尾结点删除
tmp.next = null;
} else {
Node nextNode = deletedNode.next;
// 将删除结点的后继结点的值赋给被删除结点
deletedNode.data = nextNode.data;
// 将 nextNode 结点删除
deletedNode.next = nextNode.next;
nextNode.next = null;
}
}
复制代码
接下来咱们会重点看一下链表的翻转,链表的翻转能够衍生出不少的变形,是面试中很是热门的考点,基本上考链表必考翻转!因此掌握链表的翻转是必修课!
什么是链表的翻转:给定链表 head-->4--->3-->2-->1,将其翻转成 head-->1-->2-->3-->4 ,因为翻转链表是如此常见,如此重要,因此咱们分别详细讲解下如何用递归和非递归这两种方式来解题
关于递归的文章以前写了三篇,若是以前没读过的,强烈建议点击这里查看,总结了递归的常看法题套路,给出了递归解题的常见四步曲,若是看完对如下递归的解题套路会更加深入,这里不作赘述了,咱们直接套递归的解题思路:
首先咱们要查看翻转链表是否符合递归规律:问题能够分解成具备相同解决思路的子问题,子子问题...,直到最终的子问题再也没法分解。
要翻转 head--->4--->3-->2-->1 链表,不考虑 head 结点,分析 4--->3-->2-->1,仔细观察咱们发现只要先把 3-->2-->1 翻转成 3<----2<----1,以后再把 3 指向 4 便可(以下图示)
图:翻转链表主要三步骤
只要按以上步骤定义好这个翻转函数的功能便可, 这样因为子问题与最初的问题具备相同的解决思路,拆分后的子问题持续调用这个翻转函数便可达到目的。
注意看上面的步骤1,问题的规模是否是缩小了(以下图),从翻转整个链表变成了只翻转部分链表!问题与子问题都是从某个结点开始翻转,具备相同的解决思路,另外当缩小到只翻转一个结点时,显然是终止条件,符合递归的条件!以后的翻转 3-->2-->1, 2-->1 持续调用这个定义好的递归函数便可!
既然符合递归的条件,那咱们就能够套用递归四步曲来解题了(注意翻转以后 head 的后继节点变了,须要从新设置!别忘了这一步)
一、定义递归函数,明确函数的功能 根据以上分析,这个递归函数的功能显然是翻转某个节点开始的链表,而后返回新的头结点
/** * 翻转结点 node 开始的链表 */
public Node invertLinkedList(Node node) {
}
复制代码
二、寻找递推公式 上文中已经详细画出了翻转链表的步骤,简单总结一下递推步骤以下
三、将递推公式代入第一步定义好的函数中,以下 (invertLinkedList)
/** * 递归翻转结点 node 开始的链表 */
public Node invertLinkedList(Node node) {
if (node.next == null) {
return node;
}
// 步骤 1: 先翻转 node 以后的链表
Node newHead = invertLinkedList(node.next);
// 步骤 2: 再把原 node 节点后继结点的后继结点指向 node (4),node 的后继节点设置为空(防止造成环)
node.next.next = node;
node.next = null;
// 步骤 3: 返回翻转后的头结点
return newHead;
}
public static void main(String[] args) {
LinkedList linkedList = new LinkedList();
int[] arr = {4,3,2,1};
for (int i = 0; i < arr.length; i++) {
linkedList.addNode(arr[i]);
}
Node newHead = linkedList.invertLinkedList(linkedList.head.next);
// 翻转后别忘了设置头结点的后继结点!
linkedList.head.next = newHead;
linkedList.printList(); // 打印 1,2,3,4
}
复制代码
画外音:翻转后因为 head 的后继结点变了,别忘了从新设置哦!
四、计算时间/空间复杂度 因为递归调用了 n 次 invertLinkedList 函数,因此时间复杂度显然是 O(n), 空间复杂度呢,没有用到额外的空间,可是因为递归调用了 n 次 invertLinkedList 函数,压了 n 次栈,因此空间复杂度也是 O(n)。
递归必定要从函数的功能去理解,从函数的功能看,定义的递归函数清晰易懂,定义好了以后,因为问题与被拆分的子问题具备相同的解决思路,因此子问题只要持续调用定义好的功能函数便可,切勿层层展开子问题,此乃递归常见的陷阱!仔细看函数的功能,其实就是按照下图实现的。(对照着代码看,是否是清晰易懂^_^)
咱们知道递归比较容易形成栈溢出,因此若是有其余时间/空间复杂度相近或更好的算法,应该优先选择非递归的解法,那咱们看看如何用迭代来翻转链表,主要思路以下
步骤 1: 定义两个节点:pre, cur ,其中 cur 是 pre 的后继结点,若是是首次定义, 须要把 pre 指向 cur 的指针去掉,不然因为以后链表翻转,cur 会指向 pre, 就进行了一个环(以下),这一点须要注意
步骤2:知道了 cur 和 pre,翻转就容易了,把 cur 指向 pre 便可,以后把 cur 设置为 pre ,cur 的后继结点设置为 cur 一直往前重复此步骤便可,完整动图以下
注意:同递归翻转同样,迭代翻转完了以后 head 的后继结点从 4 变成了 1,记得从新设置一下。
知道了解题思路,实现代码就容易多了,直接上代码
/** * 迭代翻转 */
public void iterationInvertLinkedList() {
// 步骤 1
Node pre = head;
Node cur = pre.getNext();
pre.setNext(null); // pre 是头结点,避免翻转链表后造成环
// 步骤 2
while (cur != null) {
/** * 务必注意!!!:在 cur 指向 pre 以前必定要先保留 cur 的后继结点,否则若是 cur 先指向 pre,以后就再也找不到后继结点了 */
Node next = cur.getNext();
cur.setNext(pre);
pre = cur;
cur = next;
}
// 此时 pre 指向的是原链表的尾结点,翻转后即为链表 head 的后继结点
head.next = pre;
}
复制代码
用迭代的思路来作因为循环了 n 次,显然时间复杂度为 O(n),另外因为没有额外的空间使用,也未像递归那样调用递归函数不断压栈,因此空间复杂度是 O(1),对比递归,显然应该使用迭代的方式来处理!
花了这么大的精力咱们总算把翻转链表给搞懂了,若是你们看了以后几道翻转链表的变形,会发现咱们花了这么大篇幅讲解翻转链表是值得的。
接下来咱们来看看链表翻转的变形
变形题 1: 给定一个链表的头结点 head,以及两个整数 from 和 to ,在链表上把第 from 个节点和第 to 个节点这一部分进行翻转。 例如:给定以下链表,from = 2, to = 4 head-->5-->4-->3-->2-->1 将其翻转后,链表变成 head-->5--->2-->3-->4-->1
有了以前翻转整个链表的解题思路,如今要翻转部分链表就相对简单多了,主要步骤以下:
知道了以上的思路,代码就简单了,按上面的步骤1,2,3 实现,注释也写得很详细,看如下代码(对 from 到 to 结点的翻转咱们使用迭代翻转,固然使用递归也是能够的,限于篇幅关系不展开,你们能够尝试一下)。
/** * 迭代翻转 from 到 to 的结点 */
public void iterationInvertLinkedList(int fromIndex, int toIndex) throws Exception {
Node fromPre = null; // from-1结点
Node from = null; // from 结点
Node to = null; // to 结点
Node toNext = null; // to+1 结点
// 步骤 1:找到 from-1, from, to, to+1 这四个结点
Node tmp = head.next;
int curIndex = 1; // 头结点的index为1
while (tmp != null) {
if (curIndex == fromIndex-1) {
fromPre = tmp;
} else if (curIndex == fromIndex) {
from = tmp;
} else if (curIndex == toIndex) {
to = tmp;
} else if (curIndex == toIndex+1) {
toNext = tmp;
}
tmp = tmp.next;
curIndex++;
}
if (from == null || to == null) {
// from 或 to 都超过尾结点不翻转
throw new Exception("不符合条件");
}
// 步骤2:如下使用循环迭代法翻转从 from 到 to 的结点
Node pre = from;
Node cur = pre.next;
while (cur != toNext) {
Node next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 步骤3:将 from-1 节点指向 to 结点(若是从 head 的后继结点开始翻转,则须要从新设置 head 的后继结点),将 from 结点指向 to + 1 结点
if (fromPre != null) {
fromPre.next = to;
} else {
head.next = to;
}
from.next = toNext;
}
复制代码
变形题 2: 给出一个链表,每 k 个节点一组进行翻转,并返回翻转后的链表。k 是一个正整数,它的值小于或等于链表的长度。若是节点总数不是 k 的整数倍,那么将最后剩余节点保持原有顺序。
示例 : 给定这个链表:head-->1->2->3->4->5 当 k = 2 时,应当返回: head-->2->1->4->3->5 当 k = 3 时,应当返回: head-->3->2->1->4->5 说明 :
这道题是 LeetCode 的原题,属于 hard 级别,若是这一题你懂了,那对链表的翻转应该基本没问题了,有了以前的翻转链表基础,相信这题不难。
只要咱们能找到翻一组 k 个结点的方法,问题就解决了(以后只要重复对 k 个结点一组的链表进行翻转便可)。
接下来,咱们以如下链表为例
首先,咱们要记录 3 个一组这一段链表的前继结点,定义为 startKPre,而后再定义一个 step, 从这一段的头结点 (1)开始遍历 2 次,找出这段链表的起始和终止结点,以下图示
找到 startK 和 endK 以后,根据以前的迭代翻转法对 startK 和 endK 的这段链表进行翻转
而后将 startKPre 指向 endK,将 startK 指向 endKNext,即完成了对 k 个一组结点的翻转。
知道了一组 k 个怎么翻转,以后只要重复对 k 个结点一组的链表进行翻转便可,对照图示看以下代码应该仍是比较容易理解的
/** * 每 k 个一组翻转链表 * @param k */
public void iterationInvertLinkedListEveryK(int k) {
Node tmp = head.next;
int step = 0; // 计数,用来找出首结点和尾结点
Node startK = null; // k个一组链表中的头结点
Node startKPre = head; // k个一组链表头结点的前置结点
Node endK; // k个一组链表中的尾结点
while (tmp != null) {
// tmp 的下一个节点,由于因为翻转,tmp 的后继结点会变,要提早保存
Node tmpNext = tmp.next;
if (step == 0) {
// k 个一组链表区间的头结点
startK = tmp;
step++;
} else if (step == k-1) {
// 此时找到了 k 个一组链表区间的尾结点(endK),对这段链表用迭代进行翻转
endK = tmp;
Node pre = startK;
Node cur = startK.next;
if (cur == null) {
break;
}
Node endKNext = endK.next;
while (cur != endKNext) {
Node next = cur.next;
cur.next = pre;
pre = cur;
cur = next;
}
// 翻转后此时 endK 和 startK 分别是是 k 个一组链表中的首尾结点
startKPre.next = endK;
startK.next = endKNext;
// 当前的 k 个一组翻转完了,开始下一个 k 个一组的翻转
startKPre = startK;
step = 0;
} else {
step++;
}
tmp = tmpNext;
}
}
复制代码
时间复杂度是多少呢,对链表从头至尾循环了 n 次,同时每 k 个结点翻转一次,能够认为总共翻转了 n 次,因此时间复杂度是O(2n),去掉常数项,即为 O(n)。 注:这题时间复杂度比较误认为是O(k * n),实际上并非每一次链表的循环都会翻转链表,只是在循环链表元素每 k 个结点的时候才会翻转
变形3: 变形 2 针对的是顺序的 k 个一组翻转,那如何逆序 k 个一组进行翻转呢
例如:给定以下链表, head-->1-->2-->3-->4-->5 逆序 k 个一组翻转后,链表变成(k = 2 时) head-->1--->3-->2-->5-->4
这道题是字节跳动的面试题,确实够变态的,顺序 k 个一组翻转都已经属于 hard 级别了,逆序 k 个一组翻转更是属于 super hard 级别了,不过其实有了以前知识的铺垫,应该不难,只是稍微变形了一下,只要对链表作以下变形便可
代码的每一步其实都是用了咱们以前实现好的函数,因此咱们以前作的每一步都是有伏笔的哦!就是为了解决字节跳动这道终极面试题!
/** * 逆序每 k 个一组翻转链表 * @param k */
public void reverseIterationInvertLinkedListEveryK(int k) {
// 先翻转链表
iterationInvertLinkedList();
// k 个一组翻转链表
iterationInvertLinkedListEveryK(k);
// 再次翻转链表
iterationInvertLinkedList();
}
复制代码
因而可知,掌握基本的链表翻转很是重要!难题可能是在此基础了作了相应的变形而已
本文详细讲解了链表与数组的本质区别,相信你们对二者的区别应该有了比较深入的认识,尤为是程序局部性原理,相信你们看了应该会眼前一亮,以后经过对链表的翻转由浅入深地介绍,相信以后的链表翻转对你们应该不是什么难事了,不过建议你们亲自实现一遍文中的代码哦,这样印象会更深入一些!有一些看起来思路是这么一回事,但真正操做起来仍是会有很多坑,纸上得来终觉浅,绝知此事要躬行!
文中的全部代码均已更新在个人 github 地址上 ,你们若是须要,能够下载运行
下一篇,咱们未来看看链表解题的另外一个关键的点:快慢指针。敬请期待!
若有帮助,有劳转发+在看,多谢啦!