【算法手记01】经典无环链表

定义

  • 链表是线性表的一种。
  • 每个结点除了存放数据外,还存放指向下一个结点的指针;
  • 不支持随机存取,只能顺序读取。
  • 找到头结点就等于找到了一个链表,所以头结点可以用来表示一个链表
  • 链表分为无环链表和有环链表,本文仅讨论无环链表,即链表的最后一个节点的next指向NULL。

代码定义

Leetcode链表节点统一定义为:

 class ListNode {
	 int val;
	 ListNode next;
	 ListNode(int x) {
		 val = x;
		 next = null;
	 }
}

head节点和dummy节点

严蔚敏/408 对链表的定义

严蔚敏《数据结构》(以下简称”严书“)中,链表分为带头节点不带头节点两种。
头节点head是专门用来标识链表的起始位置,即head.next指向链表的第一个元素,头节点本身不存放数据。
image.png

在严书中,标识链表的变量为头指针L.例如,可用如下c++代码实现链表判空:

  • 不带头结点
bool Empty(LinkList L){
	return L==NULL;
}
  • 带头结点
bool Empty(LinkList L){
	return (L->next==NULL);
}

leetcode及工程中对链表的定义

Leetcode中,head指针指向链表第一个结点,也就是严书中的头指针L。
无特殊情况下,leetcode中的所有链表题,结构都是如此。也就是严书中所谓”不带头节点链表“。
image.png

很多情况下,如果按默认的结构去对链表进行增删改查,都要对head节点做额外判断操作,有时候会很复杂。于是,我们引入dummy节点,其作用与严书定义的头结点一致。其next指针指向head,存放的数据无意义,不会去读取它。
引入dummy节点后,无需另外判断欲处理节点是否为头结点。
image.png

常见应用:

fun(ListNode head){
	ListNode dummy = new ListNode(0);
	dummy.next = head;
}

增删

我对于增删操作,总结了一个口诀:
先接管后继,再接入前驱
什么意思呢?

比如,在下面的例子中,我打算在a、b节点之间插入一个c。
那么,我就可以先让c接管a的后继b,也就是c.next = b;
随后,再让c接入b的前驱a,也就是a.next = c,完成一次插入操作。
image.png

对于删除操作,比如我们要删除节点b,那么就让a接管b的后继,再让b指向Null即可,gc会回收。不过很多时候,不用让被删节点指向null也行,因为链表不会走到b,删不删意义也不大了。反正遍历出来链表不带b就完事了。
image.png

经典题目

以下所有题号都是leetcode题号。

相交链表

160.相交链表

image.png
题目很明确。所谓相交链表,也就是两条链表在某一处指向了同一个节点,那么就会相交。由于链表特性,允许多个指针指向它,但是next指针只能指向一个结点,因此,链表一旦相交,后续的结点都是一样的。

再来看示例1:
image.png

示例中我们注意到相交节点的值不是’1’,也就是说实际上A中val为1的节点和B中val为1的节点不是一回事。这其实也很容易理解,链表中,节点的值和节点存放的地址无关。两个节点虽然值一致,但是地址不一致。

这道题解法很多,也是著名的有梗,号称算法第一深情。此处就不过多展开,有兴趣可自行搜索或查看题解。不用掌握过多解法,掌握一两种普适性的即可。这里介绍两种不同的解法。


  1. 使用高级数据结构
    使用set很容易解决这个问题。A、B的节点分别放入set,只要在某结点处发现set中已存在该节点,那么就说明这个结点是交点。这个解法非常容易想到也很好写。时间复杂度和空间复杂度都是 O ( n ) O(n) O(n)
public ListNode getIntersectionNode(ListNode headA, ListNode headB){  
    ListNode a = headA,b = headB;  
    Set<ListNode> set = new HashSet<>();  
    while (a!=null){  
        set.add(a);  
        a = a.next;  
    }  
    while (b!=null){  
        if(set.contains(b)){  
            return b;  
        }  
        set.add(b);  
        b = b.next;  
    }  
    return null;  
  
}

  1. 使用双指针
    由示例可以看出,如果两个链表相交,取两个指针同时从交点前N位开始每次向交点移动1位,一定会同时到达交点。
    声明两个指针a,b分别指向链表头结点,较长的链表指针先移动两表距离的差距步,如示例1中,指针b先走1步,再和指针a一起向后走。因为它们到交点的距离是一致的,所以当两个指针相等时,这就是交点。
    同时,链表如果不相交,那么两个指针也会同时到达Null。这就是循环退出条件。
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {  
    ListNode a = headA,b = headB;  
    int lenA = 0,lenB = 0;  
    while (a!=null){  
        lenA++;  
        a = a.next;  
    }  
    while (b!=null){  
        lenB++;  
        b = b.next;  
    }  
    a = headA;  
    b = headB;  
    int diff = Math.abs(lenA - lenB),cnt=0;  
    //a、b指针移动到同一起跑线
    if(lenA>=lenB){  
        while (cnt<diff){  
            a = a.next;  
            cnt++;  
        }  
    }else {  
        while (cnt<diff){  
            b = b.next;  
            cnt++;  
        }  
    }  
    while (a!=b){  
        a = a.next;  
        b = b.next;  
    }  
    return a;  
  
  
}

链表合并

21.合并两个有序链表

题干如下:
image.png

首先,做题时,我一般先考虑边界条件,把容易想到的边界条件丢掉后再考虑一般情况。
由示例2、3可以很容易想到,只要有一个链表为空,直接返回另一个链表即可。这是最容易想到的边界条件。
对于示例1,也很容易想到如果链表等长,我们可以逐一判断结点值大小,先把小的拼上去,再把大的拼上去。
如果链表不等长,那么当一条链表遍历完毕后,直接把另一条链表剩下的部分拼上去即可。代码如下:

//判空,直接返回另一个  
if(list1==null) return list2;  
if(list2==null) return list1;  
  
//建立一个新链表  
ListNode dummy = new ListNode();  
ListNode p = dummy;  
while (list1!=null && list2!=null){  
    if(list1.val<=list2.val){  
        p.next = new ListNode(list1.val);  
        p = p.next;  
        list1 = list1.next;  
    }else {  
        p.next = new ListNode(list2.val);  
        p = p.next;  
        list2 = list2.next;  
    }  
}
//还有链表不为空  
if (list1!=null || list2 != null){
	if(list1!=null){
		p.next = list1;
	}else {
		p.next = list2;
	}
}
return dummy.next;

在这里,我的新链表是带dummy结点的,因为在第一步,我们需要对链表头部进行插入,此处当然也可以用do-while或进循环之前额外的判断解决,但是不美观、代码冗余且边界问题和判断更复杂了。
后续的逻辑也非常简单。判断到更小的,就先接上,然后对应的链表指针前进一步,继续判断。

如果合并K个链表,怎么做?

最简单的做法,就是先合并前两个,再依次合并后续的。

1669.合并两个链表

题干如下,难度标为中等,实际上非常简单。
image.png

首先明确题意:
题目要求我们将list2插入list1中,list2的先驱改为list1的a-1节点,后继改为list1的b+1节点,list1中间的节点全删掉。
实际上,本题主要就是考察链表的增删操作,非常简单。
我们可以把被删除部分和list2看成一个”大节点“,按照正常的节点删除、插入操作即可。
按题目图示,我们找到被删除部分的前驱和后继,再找到list2的后继,图示如下:

image.png

想到这里,很容易写出如下代码。

ListNode aPrev = null,bRear = null,l2Rear = null;  
ListNode p1 = list1;  
ListNode p2 = list2;  
int cnt = 0;  
while (p1!=null){  
    if(cnt==a-1)    aPrev = p1;  
    if(cnt==b+1)    bRear = p1;  
    p1 = p1.next;  
    cnt++;  
}  
  
while (p2!=null){  
    if(p2.next==null)   l2Rear = p2;  
    p2 = p2.next;  
}  
  
l2Rear.next = bRear;  
aPrev.next = list2;  
  
return list1;

双指针应用

链表中有许多题目需要使用到双指针。在链表中,一般用快慢指针。

876.链表的中间结点

image.png

此题有两种解法:

  1. 先遍历一遍,得到长度len,再从头走len/2步即可。
  2. 定义双指针slow、fast,同时出发,slow走一步,fast走两步,fast走到NULL时,slow机会走到中间。
    上面的时间复杂度实际上都是 O ( n ) O(n) O(n),但是双指针可以少走半个循环。
ListNode fast = head,slow = head;  
if(fast==null || fast.next == null) return fast;  
while(fast.next!=null){  
    fast = fast.next.next;  
    slow = slow.next;  
}  
return slow;

写出如上代码后,会发现有部分测试点出现了空指针异常。
可以发现是循环跳出条件有问题。
FAST如果走到最后一个结点,就不可能再往后走两步。那么,多加一个条件即可。
image.png

ListNode fast = head,slow = head;  
if(fast==null || fast.next == null) return fast;  
while(fast!=null && fast.next!=null){  
    fast = fast.next.next;  
    slow = slow.next;  
}  
return slow;

面试题02.02 返回倒数第 k 个节点

image.png
这是一道非常经典的题目,往后只要是找倒数第k个节点,都需要使用一样的算法。
fast先走k步,再和slow一起走,当fast走到null之后,slow就到了倒数第k个节点。
image.png

如果k大于链表长,返回错误。由于本题限定了k是有效的,因此不需要处理。

ListNode fast = head,slow = head;  
int cnt = 0;  
while (fast!=null && cnt<k){  
    fast = fast.next;  
    cnt++;  
}  
while (fast!=null){  
    slow = slow.next;  
    fast =fast.next;  
}  
return slow.val;

61.旋转链表

image.png
本题,需要我们观察到,题目的意思就是从倒数第k处断开,然后断开处接到head。
注意,如果k的值是有可能大于链表长度len的。那么,假设k的值大于链表长度,有如下式子:
k = n ∗ l e n + m k =n*len + m k=nlen+m
也就是说,链表的实际有效移动长度是m,因为链表每移动len位,就会回到初始状态。即:
k % = l e n k \%= len k%=len

容易发现,这道题跟1669很像,也是将多个节点视作一个结点,随后进行增删操作。只不过这里是要接到链表头而已。

也就是说,题目等价为:将remove节点插入到a位置,slow指针对应节点指向null。

要插入到a位置,还是那个口诀,remove先接管NULL的后继a,再接入前驱NULL。由于插入到a为止后,remove的第一个节点变为头节点,返回这个节点即可,也就相当于前驱是NULL了。

那么,我们就可以明确slow和fast最后的位置应该是在:

  • slow:在remove节点之前一个位置;
  • fast:链表最后一个节点。

image.png

明确以上算法后,编码实现就非常简单了。此处快指针和02.02的快指针移动的步伐不同,少走一步,fast最后next指向null。

ListNode fast = head,slow = head,p = head;  
int len = 0;  
//获取链表长度  
while (p!=null){  
    len++;  
    p = p.next;  
}  
k %= len;  
if(k==0)    return head;  
int cnt = 0;  
while (cnt<k){  
    fast = fast.next;  
    cnt++;  
}  
while (fast.next!=null){  
    slow = slow.next;  
    fast = fast.next;  
}  
ListNode newHead = slow.next;  
fast.next = head;  
slow.next = null;  
return newHead;

删除节点

82.删除排序链表中的重复元素 II

删除节点实际上前面已经接触过了不少题目。这里就讲一道最具代表性的题。此题难度mid。
image.png

先看题。容易想到边界条件是len为0和1,即head==nullhead.next==null时,返回原表即可。

随后,声明指针cur指向链表头,如果cur.next.val == cur.next.next.val,那么就把这俩都删了。但是我们马上想到,如果像示例2,我们还得把头结点删了。

在链表删除操作中,常常会引入dummy节点来解决头结点的特殊性问题。引入dummy节点后,cur指向dummy,依然是之前的判断,然而这次就不需要再对头结点有特殊操作了。

引入dummy后,先判断存不存在后两个节点相等的情况。如果存在,先记下这个值为val,随后,只要cur.next的值还等于val,就一直删除cur.next节点。由于链表保证是不递减的,因此只要发现next不为val,cur就可以继续移动了。

想通这些,编码就非常简单了。

image.png

ListNode dummy = new ListNode();  
dummy.next = head;  
ListNode p = dummy;  
while (p.next!=null){  
    if(p.next.next!=null && p.next.val == p.next.next.val){  
        int val = p.next.val;  
        while (p.next!=null && p.next.val == val){  
            ListNode del = p.next;  
            p.next = del.next;  
            del.next = null;  
        }  
    }else {  
        p = p.next;  
    }  
}  
return dummy.next;
  • 23
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值