Python 全栈 60 天精通之路

Day 45:LeetCode 经典算法面试题

发布日期:2022年3月14日 14:53 阅读: 212 访问: 212

今天一起同大家刷刷 LeetCode 中最经典的练习题,它们代表几大类常用的数据结构和基本算法思想。作为程序员必须要熟练掌握,因为它们经常在面试中被考察到。就算不为面试,掌握它们也会有助于我们编写出更高质量的代码,提升自己的计算机思维。 链表反转 链表作为经典的数据结构,应用极其广泛,列表、二叉树等都会看到它的身影,面试也是经常被问道。不要小瞧链表

今天一起同大家刷刷 LeetCode 中最经典的练习题,它们代表几大类常用的数据结构和基本算法思想。作为程序员必须要熟练掌握,因为它们经常在面试中被考察到。就算不为面试,掌握它们也会有助于我们编写出更高质量的代码,提升自己的计算机思维。

链表反转

链表作为经典的数据结构,应用极其广泛,列表、二叉树等都会看到它的身影,面试也是经常被问道。不要小瞧链表,老鸟也会经常翻车,所以不要掉以轻心,需要格外小心。

链表 ListNode 对象通常被定义为如下,val 为 ListNode 对象的值域,next 指向下一个 Node,下一个 Node 又指向下下个 Node,从而形成一条链式结构。

class ListNode:
    def __init__(self, x):
        self.val = x
        self.next = None

链表反转是指反转后原最后 Node 变为头节点,并指向原倒数第二个节点,依次类推。

那么,如何实现链表反转?代码其实只有几行,关键如何利用链表的特点构思出链表的反转,下面解释思考的过程,同时理解链表的精髓所在。

首先给出递归版本的求解代码。假设原链表序列如下图所示:

求解代码 reverseList 函数的前两行表示边界条件或递归终止的条件是 head 节点为 None 或 head 指向的下一个节点为 None。

令变量 tmp 指向链表的第二个节点,如下图所示,然后递归调用 reverseList 函数,反转 tmp 指向的链表:

反转后的结果如下图所示,newhead 指向反转后链表的头部:

那么,如何将值等于 1 的节点经过 O(1) 时间复杂度链接到 newhead 链表的最后呢?

这就用到已经标记下来的 tmp 节点,因为反转后 tmp 节点的指向不正是等于 1 的节点吗!所以 tmp.next = head 操作实现建立值等于 2 的节点和值等于 1 的节点的链接:

但是务必要小心,此时值为 1 的节点还是会指向其他节点,所以务必将其 next 值置为 None。

到此完成递归版链表反转的解释。

class Solution:
    def reverseList(self, head):
        if head is None or head.next is None:
            return head
        tmp = head.next
        newhead = self.reverseList(tmp)
        tmp.next = head
        head.next = None
        return newhead

链表反转的迭代版本应该怎么写,平时链表使用多的读者会深有体会,链表操作最容易犯错的地方,就是有意无意中形成一个环(cycle),如下 5->4->3->5 的环结构:

如下链表反转的迭代版,一不小心就写出一个带环的链表结构。下面借助示意图分析这个环结构如何形成的。

class Solution:
    def reverseList(self, head: ListNode) -> ListNode:
        pre = head
        cur = head.next
        while cur:
            tmp = cur.next
            cur.next = pre
            pre = cur
            cur = tmp
        return pre

当执行到 cur.next = pre 时,已经形成一个 1->2->1 的环形结构。

要想避免入坑,使用链表需要慎之又慎。pre 和 cur 的初始指向非常重要,此处只需对以上代码稍作修改,仅仅修改 pre 和 cur 的初始指向,便得到链表反转的迭代版本。

class Solution:
    def reverseList(self, head: ListNode) -> ListNode:
        pre = None
        cur = head
        while cur:
            tmp = cur.next
            cur.next = pre
            pre = cur
            cur = tmp
        return pre

修改后的代码,链表反转的迭代步骤示意图。初始状态如下图所示,pre 指向一个虚拟的 None 节点,这是关键一步,保证避免环的存在。

迭代步骤中,先保存住 tmp 节点,通过 cur.next = pre 建立 1 节点和 None 节点的指向,然后令 pre 和 cur 的指向分别前移一位,至此完成一轮迭代。迭代时的临时变量为 tmp,起到修改 cur.next 值前缓存 cur 节点的 next 域的作用。

二叉树

链是单向的,节点的 next 域指向下一个节点。二叉树在单向的链表基础上,又增加出一个维度,它的节点一次指向两个节点,并称它们为儿子节点。以此递归,形成一棵树,并被称作二叉树,也就不难理解了。链表和二叉树实则神态上相似,前者为一维指向,后者为二维指向。

因此,在实际使用二叉树时,以上我们讲解的链表思维都能应用到二叉树中,并且二叉树因为指向两个节点,求解的时间性能往往更优于链表。

二叉树的应用案例,我们先从一道二叉树的路径求和题目开始。

二叉树的节点对象定义为:

class TreeNode:
    def __init__(self, x):
        self.val = x
        self.left = None
        self.right = None

二叉树示意图如下,父节点对象有三个值,val、left、right 分别为当前节点的值,指向左儿子、右儿子的变量。

读者们请注意,val 这个取值不仅仅局限于我们通常理解的一个数值,它可以扩展为更加具有表达力和想象力的其他各种对象,包括自定义对象。

本题目中求解二叉树从根节点(最开始的头节点)到叶节点(既没有左孩子也没有右孩子的节点)的路径求和等于 22,返回所有满足此条件的路径。如 5->4->11->2 求和等于 22,就是满足条件的一条路径。

如链表的解题思路相似,二叉树的解题构思一般也是先前进一步,使得问题的求解规模减少一点,然后递归调用,注意递归调用的终止条件,求得满足条件的解。

完整代码如下,下面分布介绍如何构思出这些代码。

class Solution:
    def pathSum(self, root: TreeNode, sum: int) -> List[List[int]]:
        paths = []
        if root is None:
            return paths
        if root.val == sum and root.left is None and root.right is None:
            paths.append([root.val])
            return paths

        ls = self.pathSum(root.left,sum - root.val)
        for l in ls:
            l.insert(0,root.val)
        if len(ls)>0:
            paths.extend(ls)

        rs = self.pathSum(root.right,sum-root.val)
        for r in rs:
            r.insert(0,root.val) 
        if len(rs)>0:
            paths.extend(rs)

        return paths

pathSum 函数的两个参数,root 路径和 sum 值,边界条件也是递归调用的终止条件为如下。注意,if root.val == sum and root.left is None and root.right is None:root.left is None and root.right is None 条件非常重要,确保已搜索到叶节点,如果遗漏此条件,可能搜索出某条从根节点到非叶结点的路径,这与题目中要求的从根节点到叶结点的路径相违背。

        paths = []
        if root is None:
            return paths
        if root.val == sum and root.left is None and root.right is None:
            paths.append([root.val])
            return paths

调用 pathSum 一次后,如果以上 2 个边界条件都不满足,则表明需要继续向下搜索,此时剩余部分的路径总和为 sum - root.val,分别探索以 root.lef 节点为根节点的子树对应路径,表达为代码就是:

rs = self.pathSum(root.right,sum-root.val)

rs 等于剩余部分的路径,然后需要插入它的父节点 root 到索引 0 处;搜索以 root.right 为根节点的子树原理相似。最后 paths 就是所有可能的路径。

动态规划

动态规划(简写为 DP)作为比较难的算法代表,此专栏在前几天单独有一讲。实话讲,这仍然不够,DP 非常灵活,一旦求解问题找到 DP 的解,通常都会带来时间复杂度的大幅改善。苦难是有的,带来的好处也非常明显,所以想培养对 DP 的敏锐度,多做一些题目是必要的。

下面再分析一道使用 DP 求解的问题:连续最大子列表的乘积,元素类型为整型。

输入: [2,3,-2,4]
输出: 6
解释: [2,3] 子数组连续乘积最大为 6  

如果元素都为非负,求解相对容易,因为是连续子数组,所以 max(it, it * tmp) 意味着要么重新从 it 开始,要么一直在 tmp 上累积:

class Solution(object):
    def maxProduct(self, nums):
        ret,tmp = nums[0],nums[0]
        for it in nums[1:]:
            tmp = max(it, it * tmp)
            ret = max(tmp,ret)
        return ret

如果列表中也存在负数,相比上面代码需要考虑当前遍历到的元素 it 的正负,如果为负数则 tmp_max 被赋值为当前得到的最小累积 tmp_min(注意它可能为非负或为负),如果 tmp_min 为非负,则乘以 it,大概率不会得到一个新的最大值,所以运行到 ret = max(tmp_max, ret) 时,会抛弃 tmp_max,但是如果 tmp_min 为负,乘以 it 后有可能得到一个新的最大值。

class Solution(object):
    def maxProduct(self, nums):
        ret, tmp_min, max_prod = [nums[0]]*3
        for it in nums[1:]:
            if it < 0:
                tmp_min,tmp_max = tmp_max,tmp_min
            tmp_max = max(it, it * tmp_max)
            tmp_min = min(it, it * tmp_min)            
            ret = max(tmp_max, ret)
        return ret

贪心

贪心算法有时得到的只是可行解,而不是最优解。但在满足某些假定条件下,能获得最优解。如下买卖股票的经典题目,在满足假定条件:卖股票发生前只能先买一次股票下,使用贪心求解便能求出最大收益。

[7,1,5,3,6,4] 的最大收益为 7,如下图所示。

绘图代码:

import plotly.graph_objects as go
fig = go.Figure()
fig.add_trace(
    go.Scatter(
        x=[0, 1, 2, 3, 4, 5],
        y=[7,1,5,3,6,4]
    ))
fig.show()

也就是贪婪的找到所有相邻的呈现上升的点对,并求累加和就是最优解,也就是最大收益。

代码如下所示:

class Solution(object):
    def maxProfit(self, prices):
        pair = zip(prices[:-1],prices[1:])
        return sum( x1 - x0 for x0, x1 in pair if x0 < x1 )

回溯 + DFS

回溯就是带有规律的来回尝试搜寻完整解结构的过程,所谓的搜索规律,常见的一种就是结合 DFS 深度优先搜索。比如求解集合的所有子集。

已知列表 [1,2,3],里面没有重复元素,它的所有子集为:

[[], [1], [2], [3], [1, 2], [1, 3], [2, 3], [1, 2, 3]]

如何使用回溯 + DFS,构思出这个问题的解呢?关键是定义的 dfs 方法,第一个参数 e 表示一个子集,beg 表示待搜索路径的起始位置,digits 表示当前子集包括的元素个数。

class Solution:
    def subsets(self, nums):
        if nums is None:
            return None
        ret, e = [[]], []

        def dfs(e, beg, digits):
            if len(e) == digits: # dfs 递归的终止条件
                ret.append(e.copy()) # 满足条件表示找到一个子集
                return
            while digits <= len(nums): # 求解框架的第一层
                for i in range(beg, len(nums)): # 求解框架的第二层
                    e.append(nums[i])
                    dfs(e, i+1, digits) # 递归:头部分为 e,路径起始位置 i+1, 元素个数 digits
                    e.remove(e[-1]) # 下面五行代码实现对 e 部分的出栈控制 
                if len(e) == 0:
                    digits += 1
                else:
                    return

        dfs(e, 0, 1) # 调用 dfs
        return ret

求解过程的解释说明,请参考代码注释。

字符串、位运算、栈、队列

字符串类型题目,比如求由空格分隔的字符串的最后一个单词的字符个数:

class Solution:
    def lengthOfLastWord(self, s: str) -> int:
        if len(s)==0:
            return 0
        return len(s.strip().split(' ')[-1])

需要注意如果输入的字符串为 'a ' 需要先使用 strip 函数去掉前后的空格。

常见的位运算符号 & 位与,| 位或,^ 异或,介绍一个使用异或求解的问题,列表中只有 1 个元素出现 1 次,其他都出现 2 次,找出出现 1 次的元素。

class Solution(object):
    def singleNumber(self, nums):
        a = 0
        for i in nums:
            a ^= i
        return a

栈先进后出,队列先进先出,两者可以相互模拟,下面我们使用栈模拟队列,用 head, tail 分别指向 s 的头和尾,以实现队列的 push、pop、peek 都为 O(1) 时间复杂度。下面假定不会在空队列中使用 pop、peek 操作。

class MyQueue:

    def __init__(self):
        """
        初始化
        """
        self.s = [] # 模拟栈:先进后出
        self.head = 0 # 头索引
        self.tail = 0 # 尾索引

    def push(self, x: int) -> None:
        """
        Push 元素到队列的尾部
        """
        self.s.append(x)
        self.tail += 1


    def pop(self) -> int:
        """
        移除并返回队列的头元素
        """
        pe = self.s[self.head]
        self.head += 1
        return pe

    def peek(self) -> int:
        """
        返回队列的头元素
        """
        return self.s[self.head]


    def empty(self) -> bool:
        """
        返回队列是否为空
        """
        return self.head == self.tail

使用队列:

obj = MyQueue()
obj.push(x)
p2 = obj.pop()
p3 = obj.peek()
p4 = obj.empty()

小结

今天分类总结基础算法的经典练习题,首先要熟悉主要的算法和数据结构:

  • 单维度链表
  • 双维度二叉树
  • 高效的动态规划解法
  • 贪心有时也能求得最优解
  • 回溯和 DFS
  • 字符串、位运算、栈和队列

以上每一类我们都列举 1 到 2 个经典的题目,最好提到某类数据结构和算法时,马上在脑海中闪现出对应的题目,进而联想到这类问题常见的解题思路,多想多练习,最后彻底理解透这些数据结构和算法的精髓。

要想做到举一反三,并非易事,刻意练习真的很有必要,最后祝愿读者们都能理解透今天的所有题目。