系列文章请查看 「数据结构&算法」必知必会系列:1. 开篇#计划路线

承接上篇请查看 「数据结构&算法」必知必会系列:6. 链表(上)

本文为系列文章的链表下篇,将针对链表的一些经典问题进行实战,并总结出一些链表解题技巧。

是不是跃跃欲试了呢?开始吧 (๑•̀ㅂ•́)و✧

理解“指针”

看懂链表的结构并不是很难,但加入了指针,就很容易让人摸不着头脑。所以,要想写好链表代码,首先就要理解好「指针」。

C 语言和 Golang 中存在指针概念及对应类型,但有些语言中没有指针,取而代之的是“引用”,比如 Java、Python等。不管是“指针”还是“引用”,实际上,它们的意思都是一样的,都是存储所指对象的内存地址。

重点领会以下概念:

将变量赋值给指针,实际上就是将这个变量的 内存地址赋值给指针

反之,若 指针中存储了变量的内存地址,通过指针即能找到该变量。

下面,具体通过一些简单的单链表操作: 删除、插入、交换结点 来加深「指针」的理解。

删除结点

删除某个链表中给定的(非末尾)节点。传入函数的唯一参数为 要被删除的节点

参考 LeetCode 237. 删除链表中的节点

链表上篇,给了一个思路是改变前置 a 结点的 Next 指向即可,但是 b 的前置 a结点未知,怎么办?

这里如果对「指针」有深入认识的话,就可以想到把 b 变成 c 就成了(前提:b 非末尾结点)

一行代码搞定,是不是超简单~ (注释的方式也可,其为 LeetCode 官方解法)

/**
 * 下文的示例,这段定义代码将省略
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */
func DeleteNode(dNode *ListNode) {
	*dNode = *dNode.Next 
  // dNode.Val = dNode.Next.Val
  // dNode.Next = dNode.Next.Next
}

https://play.golang.org/p/SsD42moMy9Y 可尝试删除末尾结点,会发生什么?

为什么不可删除末尾结点呢?自己思考下吧~

插入结点

在某个结点之后插入结点,这个也不难实现

func InsertAfterNode(iNode *ListNode, val int) {
	insertNode := new(ListNode)
	insertNode.Val = val
	insertNode.Next = iNode.Next
	iNode.Next = insertNode
}

https://play.golang.org/p/xgHz7qk4osO

那,在某个结点之前插入结点呢?其实就是值交换下而已

func InsertBeforeNode(iNode *ListNode, val int) {
	insertNode := new(ListNode)
	insertNode.Val = iNode.Val
	iNode.Val = val
	insertNode.Next = iNode.Next
	iNode.Next = insertNode
}

交换结点

已知结点,将其与之后的结点交换(非尾结点)

有了删除了插入的经验,其实就不难了,并且可利用这两个已有方法。

比如 b 和 c 要交换(b 在 c 前),可以先在 c 节点后加入值为 b 的结点,然后删除之前的 b 结点即可。

func ExchangeNode(cNode *ListNode) {
	InsertAfterNode(cNode.Next, cNode.Val)
	*cNode = *cNode.Next
}

https://play.golang.org/p/A8m-_gGqRg6


掌握了指针或引用的概念,链表代码至少可以轻松看懂,然后就开始实战吧~

以下参考: LeetCode 链表——经典问题

经典问题

反转链表

先来个经典的高频面试 🌰,如何反转一个单链表?

输入: 1->2->3->4->5->NULL

输出: 5->4->3->2->1->NULL

先不写,仅考虑指针变化,你会怎么做?

可能有人会想到:遍历该链表,到尾部了,将尾结点存入新链表,原链表尾结点删除,继续递归上述操作直至原链表为空。

(⊙o⊙)… 上面这种可能会是习惯数组操作的人的解法。

复杂度如何?n + n-1 + … + 1 => O(n²) ,而且额外增加了空间复杂度 O(n),如果面试这么答大概率被 GO(GameOver😂)

链表,我们就要利用好插入/删除方便的优势,减弱查找的劣势。

一个解决方案如下:按原始顺序迭代,将迭代到的结点依次移为头结点,直至尾结点结束。

看下图应该比较容易理解(红色箭头表示需迭代的结点):

时间复杂度为 O(n),另,只使用了常量级的额外空间,所以空间复杂度为 O(1)

有了思路,代码也就水到渠成了(其他语言的代码思路类似,可参考 206.反转链表

⚠️⚠️注意,空链表的判断

func reverseList(head *ListNode) *ListNode {
	if head == nil || head.Next == nil {
		return head
	}

	var prev *ListNode
	curr := head
	for curr != nil {
		tmp := curr.Next
		curr.Next = prev
		prev = curr
		curr = tmp
	}
	return prev
}

https://play.golang.org/p/gD_uCUI831G

反转链表 真的是面试非常喜欢考的,这题看起来简单,但是能用 两种方法 一遍 bug free 也是不容易的。

问题来了,那么你还能想到什么方式么?比如递归? 参考 LeetCode 206题解


数组转换链表

以上示例代码中,用了穷举的方式添加链表元素,显然有点低效。

假设已知数组 [1, 2, 6, 3, 4, 5, 6] ,如何把数组转换为链表呢?

这其实不是算法范畴,但是需要理解链表结构,Golang 参考如下:

func Ints2LinkedList(nums []int) *ListNode {
	if len(nums) == 0 {
		return nil
	}

	l := &ListNode{}
	t := l
	for _, v := range nums {
		t.Next = &ListNode{Val: v}
		t = t.Next
	}
	return l.Next
}

其他语言实现的逻辑也类似,分析步骤如下:

  1. 空数组,直接返回 nil
  2. 定义空链表 l,指针 t 指向链表内存地址,
  3. 遍历数组 nums
    • 依次赋值 Val:v,Next: &ListNode{Val: v} 先赋Next 的内存值 =》后该内存地址存入Val 的值
    • 指针 t 移至后一位,直至数组遍历完全

上述示例代码:https://play.golang.org/p/xH8TU7_1Zsd;参考:大佬的解法

个人原始解法:https://play.golang.org/p/CwRYrDxUPFZ;和大佬相形见绌呀

问题来了,那么链表如何转数组呢?有兴趣的可以研究下哦 (づ。◕‿‿◕。)づ


移除链表元素

删除链表中等于给定值 val 的所有结点。

输入: 1->2->6->3->4->5->6->NULL, val = 6 输出: 1->2->3->4->5>NULL

这里提供两种思路:

第一种,遍历查找,找到 ==val 的结点,删除即可(即前置结点 .Next 指向后一个结点的地址,即 CurrentNode.Next.Next

但是,这种方式须注意,头结点的指向变化(仅当 head.Val == val 的时候)

参考 Golang 代码如下(其他语言可参考 237.删除链表中的节点 ):

func RemoveElements(head *ListNode, val int) *ListNode {
	for head != nil && head.Val == val {
		head = head.Next
	}

	if head == nil {
		return head
	}

	curr := head
	for curr.Next != nil {
		if curr.Next.Val == val {
			curr.Next = curr.Next.Next
		} else {
			curr = curr.Next
		}
	}

	return head
}

https://play.golang.org/p/-QjWV4drwAE

第二种,递归方式,略有点难理解,可参考 LeetCode 题解 ,其中也提供了其他的一些方式方法

func RemoveElements(head *ListNode, val int) *ListNode {
	if head == nil {
		return head
	}

	head.Next = RemoveElements(head.Next, val)
	if head.Val == val {
		return head.Next
	}

	return head
}

https://play.golang.org/p/OtXnPRk2NnF


奇偶链表

给定一个单链表,把所有的奇数结点和偶数结点分别排在一起。请注意,这里的奇数结点和偶数结点指的是结点编号的奇偶性,而不是结点的值的奇偶性。

请尝试使用原地算法完成。你的算法的空间复杂度应为 O(1),时间复杂度应为 O(nodes),nodes 为结点总数。

示例 1:

输入: 1->2->3->4->5->NULL

输出: 1->3->5->2->4->NULL

示例 2:

输入: 2->1->3->5->6->4->7->NULL

输出: 2->3->6->7->1->5->4->NULL

说明:

应当保持奇数结点和偶数结点的相对顺序。 链表的第一个结点视为奇数结点,第二个结点视为偶数结点,以此类推。

同样,延续迭代的思路,先画图如下(主要思考指针如何移动,结点如何操作)

我们可以先分离 奇数链 和 偶数链,然后将其串联起来,参考下图,参考 328.奇偶链表

跟着思路逻辑,直接开干吧~

func OddEvenList(head *ListNode) *ListNode {
	if head == nil {
		return head
	}
	evenHead := head.Next
	odd := head
	even := evenHead
	for even != nil && even.Next != nil {
		odd.Next = even.Next
		odd = odd.Next
		even.Next = odd.Next
		even = even.Next
	}

	odd.Next = evenHead
	return head

}

https://play.golang.org/p/n3EZdaVvxWZ


回文链表

请判断一个链表是否为回文链表。234.回文链表

示例 1:

输入: 1->2

输出: false

示例 2:

输入: 1->2->2->1

输出: true

进阶: 你能否用 O(n) 时间复杂度和 O(1) 空间复杂度解决此题?

方法一:记录下链表的值,可考虑链表转数组(之前的引申思考)。然后,遍历对比数组的值即可。

这个方式,复杂度 O(n) + O(n/2) => O(n),空间复杂度 +O(n)

func isPalindrome(head *ListNode) bool {
	vals := []int{}
	for head != nil {
		vals = append(vals, head.Val)
		head = head.Next
	}
	n := len(vals)
	for i, v := range vals[:n/2] {
		if v != vals[n-1-i] {
			return false
		}
	}
	return true
}

https://play.golang.org/p/etaWnP1oJo-

方法二:递归实现,但是,时间和空间复杂度仍需要 O(n)

func isPalindrome(head *ListNode) bool {
    frontPointer := head
    var recursivelyCheck func(*ListNode) bool
    recursivelyCheck = func(curNode *ListNode) bool {
        if curNode != nil {
            if !recursivelyCheck(curNode.Next) {
                return false
            }
            if curNode.Val != frontPointer.Val {
                return false
            }
            frontPointer = frontPointer.Next
        }
        return true
    }
    return recursivelyCheck(head)
}

方法三:将链表的后半部分反转(修改链表结构),然后将前半部分和后半部分进行比较

比较完成后我们应该将链表恢复原样。虽然不需要恢复也能通过测试用例,但是使用该函数的人通常不希望链表结构被更改。

该方法虽然可以将空间复杂度降到 O(1),但是在并发环境下,该方法也有缺点。在并发环境下,函数运行时需要锁定其他线程或进程对链表的访问,因为在函数执行过程中链表会被修改。

然后呢,怎么搞?使用双指针记录&对比,空间复杂度可降为 O(1) 。哎,好累 ヽ( ̄▽ ̄)و

func reverseList(head *ListNode) *ListNode {
    var prev, cur *ListNode = nil, head
    for cur != nil {
        nextTmp := cur.Next
        cur.Next = prev
        prev = cur
        cur = nextTmp
    }
    return prev
}

func endOfFirstHalf(head *ListNode) *ListNode {
    fast := head
    slow := head
    for fast.Next != nil && fast.Next.Next != nil {
        fast = fast.Next.Next
        slow = slow.Next
    }
    return slow
}

func isPalindrome(head *ListNode) bool {
    if head == nil {
        return true
    }

    // 找到前半部分链表的尾节点并反转后半部分链表
    firstHalfEnd := endOfFirstHalf(head)
    secondHalfStart := reverseList(firstHalfEnd.Next)

    // 判断是否回文
    p1 := head
    p2 := secondHalfStart
    result := true
    for result && p2 != nil {
        if p1.Val != p2.Val {
            result = false
        }
        p1 = p1.Next
        p2 = p2.Next
    }

    // 还原链表并返回结果
    firstHalfEnd.Next = reverseList(secondHalfStart)
    return result
}

技巧总结

熟练掌握指针

要想写对链表代码,首先就要理解好「指针」。如果想追本溯源,计算机系统的内存使用值得你去探索。

针对链表指针掌握,本文开头就有重点说明和举例,不熟练的就多练,温故而知新。

注意指针丢失

⚠️⚠️注意,空指针引用报错,如 Golang 就可能报错如下:

指针混乱是比较容易出现的问题,初学者包括我。。。也常常会混乱。

比如,在插入结点,一定要注意操作的顺序。

对于有些语言来说,比如 C 语言,内存管理是由程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄露。这就更应该引起重视了

同理,删除链表结点时,也一定要记得手动释放内存空间,否则,也会出现内存泄漏的问题。当然,对于像 Java 这种虚拟机自动管理内存的编程语言来说,就不需要考虑这么多了。

留意边界处理

代码在一些边界或者异常情况下,最容易产生 Bug。在代码编写过程中,是否有注意检查边界条件?这很重要,往往决定了开发能力的好坏,这也是面试中,面试官一定会注意考察的点。

所以,通常在写方法或函数中,一开始就会判空。(可看上之前的示例)

检查链表代码是否正确的边界条件通常有以下几个:

  • 链表为空时
  • 链表只包含一个结点时
  • 链表只包含两个结点时
  • 代码逻辑在处理头结点和尾结点的时候

其实,此类“检查边界”的套路适用于几乎所有代码检测,这也是「边界值测试」的范畴。

尽可能多分析会遇到哪些边界情况或者异常情况,遇到了又该如何应对,这样写出来的代码才够健壮!

画图辅助思考

复杂的算法题,往往需要先举例以及画图,尤其是类似链表这种包含指针的。

打草稿不可耻,画的好看不好看都不重要,能解决问题才是硬道理。

上面有个例子特意配了张手稿,有没有注意到呢?多色笔辅助能帮助我们理清思路(尤其是双指针)。

有关双指针(快、慢指针)准备之后特别整理一章节做类似算法技巧的整合。

其实呢,这种题就是初看很可能没有思路,但一旦知道了就不容易忘。

如判断链表是否有环,以及环的入口等,如 141.环形链表

其次,写完代码后,也可以举几个例子,画在纸上,照着代码走一遍,很容易就能发现代码中的 Bug。

递归思维方式

链表中的很多操作,都可以使用「递归」的逻辑思维方式来完成。

其实,「递归」也是需要练习的,这里只是在例子中蜻蜓点水一下。递归的 时间空间复杂度的理解和分析相对更麻烦一些(头都大了…)。

准备之后特别整理出「递归算法」,主要讲解递归的算法思路和复杂度分析。学完了之后再来看这些链表的题,用递归的方式再来一遍吧~