LeetCode刷题笔记整理Python

文章目录


前言

2020年9月开始至现在,我先是通过一本名为《漫画算法:小灰的算法之旅》的书学习了常见的数据结构与算法,然后九月末开始,我开始在力扣(LeetCode)网站上刷题,频率保持基本每天一题,一直刷到现在。

每次刷题,我的感觉就是,怎么说呢,引用网上看到的一句话来描述一下:“虽然是不同的题型,但是却是相同而又熟悉的手法,复制粘贴题解即可解决。”从一开始的一脸懵逼,但现在的半脸懵逼,感觉进步了没多少。应该是刷到的题目太零碎了,每次都是今天二叉树明天栈后天队列,导致脑子里没有一个成套的系统。

因此,我打算用这篇博客来整理一下我以前刷过的题,按照题目涉及到的数据结构进行分类整理,使自己形成一个思维体系,遇到什么样的数据结构大体上该用什么解法。


一、数组

1. N数之和系列

两数之和

题目

给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍。

你可以按任意顺序返回答案。
在这里插入图片描述
解答

① 列表解法

这道题给出了一个很好的限制条件来进行简化,那就是明确了给定数组中有且仅有两个数会凑成目标值 target,那么我们可以运用列表的性质,判断目标值减去当前值得到的结果是否在当前位置之后的列表里。

注意:下面代码中的 index() 函数用于从列表中找出某个值第一个匹配项的索引位置,查找的起始位置为 index +1。

代码如下:

def twoSum(self, nums: List[int], target: int) -> List[int]:
    for index, element in enumerate(nums[:-1]): #没有必要遍历到数组最后一个值
        if target - element in nums[index+1:]:
            return [index, nums.index(target - element, index+1)]

② 字典解法

运用字典的性质,判断目标值减去当前值得到的结果是否在字典的key中,在的话直接返回结果,不在的话将当前键值对存入字典中。

代码如下:

# 该代码允许nums数组里存在重复元素
def twoSum(self, nums: List[int], target: int) -> List[int]:
    dict1 = {}
    for index, element in enumerate(nums):
        a = dict1.get(target - element)
        if a:
            return [a, index]
        dict1[element] = index

三数之和

题目

给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有满足条件且不重复的三元组。

注意:答案中不可以包含重复的三元组。
在这里插入图片描述
解答

暴力解法

从数组的第一个元素开始,每次固定一个元素,然后开始遍历固定元素位置之后的元素。

问题在于,怎么能够判断出结果是否重复。例如,[1, -1, 0] 和 [0, -1, 1] 属于重复结果,而数组是有序的,因此这两个数组是不同的。那怎么能够排除掉这种重复结果呢?

我的思路是创建一个中间数组 l,用来存放形式为 {a, b, c} 的集合(其中,a + b + c = 0),因为集合是无序的,因此每次在将结果放入最终结果数组之前,首先判断该集合是否在数组 l 中以排除重复结果。

代码如下:

def threeSum(self, nums: List[int]) -> List[List[int]]:
    if len(nums) < 3:
        return []
    l = []
    result = []
    for i, a in enumerate(nums[:-2]):
        for m, b in enumerate(nums[i+1:-1], i+1):
            for c in nums[m+1:]:
                if a+b+c == 0 and {a, b, c} not in l:
                    l.append({a, b, c})
                    result.append([a, b, c])
    return result

双指针解法

题目中要求结果为“不重复的三元组”,我们可以一开始对给定数组进行排序,以便后面更方便地排除重复结果。

解题思路如下:

  • 对原数组 nums 进行升序排序;
  • 从头开始遍历数组,固定一个元素,并创建双指针 left 和 right 来寻找另外两个满足条件的元素;
  • 如果此时固定位置的元素已经大于0,那么直接结束总循环,因为指针都在固定位置的右边,不可能再有元素满足相加为0的条件;
  • 如果当前固定位置元素在以前固定元素中出现过,那么结束当前循环,进入下一个循环;
  • 创建双向指针 left 和 right 分别指向固定位置的右1侧和数组的末尾,如果当前固定元素和指针指向的两个元素数值相加和为0,将结果存入结果数组,同时判断两个指针的元素是否与下一个要移动的位置相同,相同的话继续移动(以排除重复结果);
  • 如果当前固定元素和指针指向的两个元素数值相加和不等于0:大于0 right 指针向左移动,小于0 left 指针向右移动;
  • 最终返回结果数组。

代码如下:

def threeSum(self, nums: List[int]) -> List[List[int]]:
    n = len(nums)
    if n < 3:
        return []
    nums.sort()
    result = []
    for i in range(n):
        if nums[i] > 0:
            return result
        if i > 0 and nums[i] == nums[i-1]: 
            continue
        left = i+1
        right = n-1
        while left < right:
            if nums[i] + nums[left] + nums[right] == 0:
                result.append([nums[i],nums[left],nums[right]])
                while left < right and nums[left] == nums[left+1]:
                    left += 1
                while left < right and nums[right] == nums[right-1]:
                    right -= 1
                left += 1
                right -= 1
            elif nums[i] + nums[left] + nums[right] > 0:
                right -= 1
            else:
                left += 1
    return result

四数之和

题目

给定一个包含 n 个整数的数组 nums 和一个目标值 target,判断 nums 中是否存在四个元素 a,b,c 和 d ,使得 a + b + c + d 的值与 target 相等?找出所有满足条件且不重复的四元组。

注意:答案中不可以包含重复的四元组。

在这里插入图片描述
解答

双指针解法

三数之和不同的是,这道题要求找出四个满足要求的元素,而且目标和不再固定为0,而是通过参数 target 给出。这道题仍然可以通过双指针解法解决。

解题思路如下:

  • 对原数组 nums 进行升序排序;
  • 从头开始遍历数组,固定一个元素,当当前元素与前一个元素相等时,为了避免重复结果的影响,我们结束本次循环,遍历下一元素;
  • 从固定的第一个元素的右侧开始固定第二个元素,因为我们的目标值不再固定为0,可能为正也可能为负,所以不存在像三数之和那样如果此时固定位置的元素已经大于0,那么直接结束总循环等提前结束循环的条件;
  • 如果当前固定的第二个位置的元素与第一位置元素不相邻且与前一个元素相等时,那么结束本次循环,遍历下一元素;
  • 创建双向指针 left 和 right 分别指向固定位置的右1侧和数组的末尾,如果当前固定元素和指针指向的两个元素数值相加和为0,将结果存入结果数组,同时判断两个指针的元素是否与下一个要移动的位置相同,相同的话继续移动(以排除重复结果);
  • 如果当前固定元素和指针指向的两个元素数值相加和不等于0:大于0 right 指针向左移动,小于0 left 指针向右移动;
  • 最终返回结果数组。

代码如下:

def fourSum(self, nums: List[int], target: int) -> List[List[int]]:
    n = len(nums)
    if n < 4:
        return []
    nums.sort()
    result = []
    for i in range(n-3):
        if i > 0 and nums[i] == nums[i-1]: 
                continue
        for j in range(i+1, n):
            if j > i+1 and nums[j] == nums[j-1]: 
                continue
            left = j+1
            right = n-1
            while left < right:
                if nums[i] + nums[j] + nums[left] + nums[right] == target:
                    result.append([nums[i],nums[j],nums[left],nums[right]])
                    while left < right and nums[left] == nums[left+1]:
                        left += 1
                    while left < right and nums[right] == nums[right-1]:
                        right -= 1
                    left += 1
                    right -= 1
                elif nums[i] + nums[j] + nums[left] + nums[right] > target:
                    right -= 1
                else:
                    left += 1
    return result

递归解法

这道题其实有点像背包问题,给定一个数组,要求你从其中选定一组数,使其和等于 target:

  • 对所有数字来说,都有选择和不选择两种情况;
  • 选择一个数,target 就减去这个数,最后 target 等于 0,并且只选择了4个数就结束;
  • 不选择这个数,就去检查下一个数。

按照以上思路,我们得到的结果中会含有重复元素,而且缺乏适当的提前停止条件。其实如果排序了数组nums之后,可以进行适当的剪枝:

  • 当我们选了这个数,但是选了它之后,即使后面连续选择全数组最大的数,也不能达到 target。这种情况说明我们当前这个数太小了。这时候可以肯定这个数是一定不用选择的。用表达式表示这种情况:target - nums[i] - (3 - len(oneSolution)) * nums[-1] > 0
  • 在我们当前的数组的基础上,连续选择当前这个数的话,整体的解的和会比target大。由于我们提前排序了nums,往后去寻找其他的数,只会比当前的数更大,所以这种情况下,我们就不需要往后再去找了。用表达式表示这种情况:target - (4 - len(oneSolution)) * nums[i] < 0
  • 对于剩下的情况,都有选择不选择两种情况。

对于去重,加一个 notSelected 列表就可以,思路很简单:我如果决定不选这个数了,后面遇到这个数也应该不选。这样就相当于去重了。

代码如下:

def fourSum(self, nums: List[int], target: int) -> List[List[int]]:
    nums.sort()
    output = []
    
    def Search(i, target, oneSolution, notSelected):
        if target == 0 and len(oneSolution) == 4:
            output.append(oneSolution)
            return
        elif len(oneSolution) > 4 or i >= len(nums):
            return

        if target - nums[i] - (3 - len(oneSolution)) * nums[-1] > 0 or nums[i] in notSelected:
            Search(i + 1, target, oneSolution, notSelected)
        elif target - (4 - len(oneSolution)) * nums[i] < 0:
            return
        else:
            Search(i + 1, target, oneSolution, notSelected + [nums[i]])
            Search(i + 1, target - nums[i], oneSolution + [nums[i]], notSelected)


    Search(0, target, [], [])

    return output

2. 打家劫舍系列

打家劫舍Ⅰ

题目

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。
在这里插入图片描述
解答

动态规划解法

这道题是典型的动态规划解法,问题要求我们求出从全部房子中能够偷到的最大金额,我们假设一共有 n 个房子,那么我们可以分为以下两种情况:①偷最后一间房子,那么根据题意,我们将无法偷倒数第二间房子,那么偷取的金额 = 最后一间房子的金额 + 从前 n-2 间房子偷取的金额;②不偷最后一间房子,那么根据题意,我们可以偷倒数第二间房子,那么偷取的金额 = 从前 n-1 间房子偷取的金额。

那我们只要求出步骤①②中的最大值,即为最终结果。也就是说,我们可以将原问题逐渐分解为子问题,直到只剩一间房子时,能够偷取的最大金额为该房子拥有的金额,没有剩余的房子时,能够偷取的最大金额为0。

解题思路如下:
①建立一个数组 dp,该数组对应位置的元素 dp[i] 表示从前 i 间房能够偷取的最大金额;
②初始化 dp[0]=0,dp[1]=nums[0],那么我们便可以递归地求取 dp 的下一个元素,直到遍历完数组 nums 的元素。

代码如下:

def rob(self, nums: List[int]) -> int:
    N = len(nums)
    if not N:
        return 0
    dp = [0] * (N+1)
    dp[0] = 0
    dp[1] = nums[0]
    for k in range(2, N+1):
        dp[k] = max(dp[k-1], nums[k-1] + dp[k-2])
    return dp[-1]

观察刚刚的程序,我们发现实际上我们在计算数组 dp 的下一个值的时候,我们只用到了当前计算出的 dp 最后两个值,因此我们可以进行如下的代码空间优化:

def rob(self, nums: List[int]) -> int:
    N = len(nums)
    if not N:
        return 0
    pre = 0
    cur = nums[0]
    for k in range(2, N+1):
        pre, cur = cur, max(cur, nums[k-1] + pre)
    return cur

打家劫舍Ⅱ

题目

你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下 ,能够偷窃到的最高金额。

在这里插入图片描述
解答

动态规划解法

与“打家劫舍Ⅰ”不同的是,这道题所给的数组 nums 是首尾相连的,即如果打劫第一间房就不能打劫最后一间房,反之也一样。我认为这道题可以仿照“打家劫舍Ⅰ”的思路来解决:

  • 计算①:找出除最后一间房外(即数组变成了 nums[:-1])所能获得的最大金额;
  • 计算②:找出除第一间房外(即数组变成了 nums[1:])所能获得的最大金额;
  • 最终结果就是计算①②结果中的最大值。

为什么这样就能获得最大金额?

  • 假设原数组 nums 获得最大金额时偷取了第一间房但未偷取最后一间房,那么计算①就能获得最大金额;
  • 假设原数组 nums 获得最大金额时偷取了最后一间房但未偷取第一间房,那么计算②就能获得最大金额;
  • 假设原数组 nums 获得最大金额时既未偷取第一间房也未偷取最后一间房,那么计算①②均能获得最大金额;
  • 假设原数组 nums 获得最大金额时既偷取了第一间房也偷取了最后一间房,这种情况是不允许出现的。

因此,我们通过以上思想就能够得到正确结果。

代码如下:

def rob(self, nums: List[int]) -> int:
    N = len(nums)
    if N <= 2:
        return max(nums)
    
    def rob_1(m):
        dp = [0] * N
        dp[0] = 0
        dp[1] = nums[m]
        for k in range(2, N):
            dp[k] = max(dp[k-1], nums[k-1+m] + dp[k-2])
        return dp[-1]
            
    return max(rob_1(0), rob_1(1))

代码空间优化:

def rob(self, nums: List[int]) -> int:
    N = len(nums)
    if N <= 2:
        return max(nums)
    
    def rob_2(m):
        pre = 0
        cur = nums[m]
        for k in range(2, N):
            pre, cur = cur, max(cur, nums[k-1+m] + pre)
        return cur
            
    return max(rob_2(0), rob_2(1))

打家劫舍Ⅲ

题目

“打家劫舍Ⅲ”属于二叉树问题,但是为了将同一系列的问题归纳在一起,我们将该题的解答放在了这里。

在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为“根”。 除了“根”之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。

计算在不触动警报的情况下,小偷一晚能够盗取的最高金额。
在这里插入图片描述
在这里插入图片描述
解答

递归解法

这道题用的是递归解法,对于二叉树的每个节点,我们返回两个值(即一个元组),分别代表偷取该节点时所能获得的最大金额和不偷取该节点时所能获得的最大金额。

解题思路如下:

  • 利用递归函数从叶节点开始计算,因为叶节点没有左右节点,所以当节点为空时(即叶节点的“左右节点”),返回值 0,0;
  • 从叶节点到根节点,我们逐渐考虑在每个节点所能得到的最大金额:
    • 如果偷取该节点的金额。那么该节点的左右节点肯定不能偷,那么我们返回“当前节点值+左右节点元组的第二个值”;
    • 如果不偷取该节点的金额。那么该节点的左右节点可偷可不偷,如果偷能得到最大金额那么我们就偷,如果不偷能得到最大金额那么我们就不偷,因此我们返回“当前节点值+左右节点元组的最大值”;
  • 最终,我们返回根节点结果元组中的最大值。

代码如下:

def rob(self, root: TreeNode) -> int:
    def rob_3(root):
        if not root:
        	return 0, 0  # 偷,不偷
        left = rob_3(root.left)
        right = rob_3(root.right)
        # 偷当前节点, 则左右子树都不能偷
        v1 = root.val + left[1] + right[1]
        # 不偷当前节点, 则取左右子树中最大的值
        v2 = max(left) + max(right)
        return v1, v2

    return max(rob_3(root))

3. 最大矩形和正方形

最大矩形

题目

给定一个仅包含 0 和 1 、大小为 rows x cols 的二维二进制矩阵,找出只包含 1 的最大矩形,并返回其面积。

在这里插入图片描述
在这里插入图片描述
解答

位运算解法

解题思路如下:

  • 将给定矩阵的每一行看作一个二进制数,并将其转化为相应整数,形成一个整数数组;
  • 遍历该整数数组,将当前行对应的二进制数和其之后的每个行对应的数依次进行按位与运算;
  • 假设当前行为第 i 行,按位与到了第 j 行,如果这其中有矩形的话,那么对应的二进制位应该始终为1,相关矩形的高度为 j-i+1;
  • 那怎么确定矩形的宽度呢?这里用了一个很巧妙的方法:将按位与的结果和其左移一位得到的数进行按位与运算,并覆盖原来的结果,不断左移直到结果为0,那么最终的左移次数就表示最宽的有效宽度;
  • 更新最大矩形面积。

代码如下:

def maximalRectangle(self, matrix: List[List[str]]) -> int:
        if not matrix or not matrix[0]:
            return 0
        rows_to_nums = [int(''.join(row), base=2) for row in matrix]
        result = 0
        n = len(rows_to_nums)
        for i in range(n):
            j, num_i = i, rows_to_nums[i]
            while j < n:
                num_i = num_i & rows_to_nums[j]
                if not num_i:
                    break
                width, cur_num = 0, num_i
                while cur_num:
                    width += 1
                    cur_num = cur_num & (cur_num << 1)
                result = max(result, width * (j - i + 1))
                j += 1
        return result

最大正方形

题目

在一个由 ‘0’ 和 ‘1’ 组成的二维矩阵内,找到只包含 ‘1’ 的最大正方形,并返回其面积。
在这里插入图片描述
在这里插入图片描述
解答

位运算解法

容易发现,这道题利用“最大矩形”中的解题思路完全可以解出来,但不同的是,这道题求的是最大正方形,正方形的宽和高相等,因此我们可以利用这一点来简化最大矩形的解题思路:当数组某一行与后面行累积相与的时候,1 的个数只可能变少,即宽度只会越变越小,当宽度小于高度的时候,会以宽度作为正方形的边长,此时高度再增加已经没有意义了,即没必要再继续累积相与了,因为不可能再有比当前更大的正方形产生了,所以可以跳出循环,直接遍历下一行。

代码如下:

def maximalSquare(self, matrix: List[List[str]]) -> int:
    if not matrix or not matrix[0]:
        return 0
    rows_to_nums = [int(''.join(row), base=2) for row in matrix]
    result = 0
    n = len(rows_to_nums)
    for i in range(n):
        j, num_i = i, rows_to_nums[i]
        while j < n:
            num_i = num_i & rows_to_nums[j]
            if not num_i:
                break
            width, cur_num = 0, num_i
            while cur_num:
                width += 1
                cur_num = cur_num & (cur_num << 1)
            if width < j-i+1:
                result = max(result, width)
                break
            else:
                result = max(result, j-i+1)
            j += 1
    return result**2

4. 盛最多水的容器

题目

给你 n 个非负整数 a1,a2,…,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的两个端点分别为 (i, ai) 和 (i, 0)。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。

说明:你不能倾斜容器,且给定 n 的值至少为 2。

在这里插入图片描述
在这里插入图片描述
解答

暴力解法

这道题的暴力解法很容易想到,把所有可能的面积求出来,找出其中的最大值。

代码如下:

def maxArea(self, height: List[int]) -> int:
    area = 0
    n = len(height)
    for i in range(n-1):
        for j in range(i+1, n):
            area = max(area, (j-i)*min(height[i], height[j]))
    return area

双指针解法

在数组 height 所代表的容器两端创建双指针 left 和 right ,然后根据规则逐步移动指针来寻找最大面积。

移动规则:选定较短容器壁对应的指针,并向内移动一个单位。

为什么要移动短板呢?不管是移动短板还是长板,面积的宽肯定要减1。如果移动长板,那么下一个短板的长度肯定不长于当前短板,面积只能减小;如果移动短板,那么下一个短板的长度可能长于当前短板,面积可能增大。因此我们移动短板。

移动结束条件:两个指针到达相同位置。

代码如下:

def maxArea(self, height: List[int]) -> int:
    left, right = 0, len(height) - 1
    area = 0
    while left < right:
        if height[left] < height[right]:
            area = max(area, height[left] * (right - left))
            left += 1
        else:
            area = max(area, height[right] * (right - left))
            right -= 1
    return area

5. 找到所有数组中消失的数字

题目

给定一个范围在 1 ≤ a[i] ≤ n ( n = 数组大小 ) 的 整型数组,数组中的元素一些出现了两次,另一些只出现一次。

找到所有在 [1, n] 范围之间没有出现在数组中的数字。

您能在不使用额外空间且时间复杂度为O(n)的情况下完成这个任务吗? 你可以假定返回的数组不算在额外空间内。

在这里插入图片描述

解答

我们通过遍历两次数组来解决该问题:

  • 第一次遍历,我们假设数组中的元素是一个位置索引,例如元素1对应位置索引0、元素6对应位置索引5,以此类推,然后我们将对应位置索引的数组元素变为负数,那么遍历完这一遍之后数组中只有缺失数对应的位置索引处的元素为正;
  • 第二次遍历,找到数组中正元素位置处对应的索引,然后加1将其放入结果数组即为最终结果。

代码如下:

def findDisappearedNumbers(self, nums: List[int]) -> List[int]:
    res = []
    for i in nums:
        index = abs(i)-1
        if nums[index]>0:
            nums[index] *= -1
    for index, element in enumerate(nums):
        if element>0:
            res.append(index+1)
    return res

6. 缺失的第一个正数

题目

给你一个未排序的整数数组 nums ,请你找出其中没有出现的最小的正整数。

进阶:你可以实现时间复杂度为 O(n) 并且只使用常数级别额外空间的解决方案吗?

在这里插入图片描述
解答

暴力解法

这道题我的思路是:

  • 遍历给定数组找出最大最小值,如果最大值小于1或者最小值大于1,说明1就是缺失的最小正整数,直接返回1;
  • 否则创建一个长度等于 (最大值-最小值+1) 、初始值全为0的数组 result,遍历数组中的元素 i ,并将结果数组 result 中 i - 最小值处的元素置为1;
  • 遍历结果数组中的元素 element,如果当前元素值 element 为0且当前元素位置表示的数(index + 最小值)已经是正整数了,说明该元素缺失,直接返回当前元素位置表示的数——index + 最小值;
  • 如果结果数组中表示正数的位置处元素均为1,说明缺失的正数为最大值+1。

以上思路存在严重的空间复杂度问题,在测试用例[1,2,3,10,2147483647,9]时出现了严重的内存问题,因此暴力解法不可取。

代码如下:

def firstMissingPositive(self, nums: List[int]) -> int:
    max_num = float("-inf")
    min_num = float("inf")
    for i in nums:
        if i > max_num:
            max_num = i
        if i < min_num:
            min_num = i
    if min_num > 1 or max_num < 1:
        return 1
    result = [0]*(max_num - min_num +1)
    for i in nums:
        result[i - min_num] = 1

    for i, element in enumerate(result):
        if element == 0 and i + min_num > 0:
            return min_num + i
    return max_num+1

哈希表解法

一种简单的思路是:我们可以将数组所有的数放入哈希表,随后从 1 开始依次枚举正整数,并判断其是否在哈希表中。

仔细想一想,我们为什么要使用哈希表?这是因为哈希表是一个可以支持快速查找的数据结构:给定一个元素,我们可以在 O(1) 的时间查找该元素是否在哈希表中。因此,我们可以考虑将给定的数组设计成哈希表的「替代产品」。

实际上,对于一个长度为 N 的数组,其中没有出现的最小正整数只能在 [1, N+1] 中。这是因为如果 [1,N] 都出现了,那么答案是 N+1,否则答案是 [1,N] 中没有出现的最小正整数。这样一来,我们将所有在 [1, N] 范围内的数放入哈希表,也可以得到最终的答案。而给定的数组恰好长度为 N,这让我们有了一种将数组设计成哈希表的思路:

  • 我们对数组进行遍历,对于遍历到的数 x,如果它在 [1, N] 的范围内,那么就将数组中的第 x−1 个位置(注意:数组下标从 0 开始)打上「标记」。在遍历结束之后,如果所有的位置都被打上了标记,那么答案是 N+1,否则答案是最小的没有打上标记的位置加 1。
  • 那么如何设计这个「标记」呢?由于我们只在意 [1, N] 中的数,因此我们可以先对数组进行遍历,把不在 [[1,N] 范围内的数修改成任意一个大于 N 的数(例如 N+1)。这样一来,数组中的所有数就都是正数了,因此我们就可以将「标记」表示为「负号」。算法的流程如下:
    • 我们将数组中所有小于等于 0 的数修改为 N+1;
    • 我们遍历数组中的每一个数 x,它可能已经被打了标记,因此原本对应的数为 |x|,其中 ∣∣ 为绝对值符号。如果 ∣x∣∈[1,N],那么我们给数组中的第 |x| - 1 个位置的数添加一个负号。注意如果它已经有负号,不需要重复添加;
    • 在遍历完成之后,如果数组中的每一个数都是负数,那么答案是 N+1,否则答案是第一个正数的位置加 1。

这道题哈希表解法的思路和第五题的解题思路差不多。

代码如下:

def firstMissingPositive(self, nums: List[int]) -> int:
    n = len(nums)
    for i in range(n):
        if nums[i] <= 0:
            nums[i] = n + 1
    
    for i in range(n):
        num = abs(nums[i])
        if num <= n and nums[num - 1] > 0:
            nums[num - 1] *= -1
    
    for i in range(n):
        if nums[i] > 0:
            return i + 1
    
    return n + 1

置换解法

除了打标记以外,我们还可以使用置换的方法,将给定的数组「恢复」成下面的形式:

  • 如果数组中包含 x ∈ [1,N],那么恢复后,数组的第 x-1 个元素为 x

在恢复后,数组应当有 [1, 2, …, N] 的形式,但其中有若干个位置上的数是错误的,每一个错误的位置就代表了一个缺失的正数。以 [3,4,-1,1] 为例,恢复后的数组应当为 [1, -1, 3, 4],我们就可以知道缺失的数为2。

那么我们如何将数组进行恢复呢?我们可以对数组进行一次遍历,对于遍历到的数 x = nums[i],如果 x ∈[1,N],我们就知道 x 应当出现在数组中的 x -1 的位置,因此交换 nums[i] 和 nums[x - 1],这样 x 就出现在了正确的位置。在完成交换后,新的 nums[i] 可能还在[1,N]的范围内,我们需要继续进行交换操作,直到 x ∉ [1, N]。

注意,上面的方法可能会陷入死循环。如果 numsli] 恰好与 nums[x -1] 相等,那么就会无限交换下去。此时我们有 nums[i] = x = nums[x - 1],说明 x 已经出现在了正确的位置。因此我们可以跳出循环,开始遍历下一个数。

代码如下:

def firstMissingPositive(self, nums: List[int]) -> int:
    n = len(nums)
    for i in range(n):
        while 1 <= nums[i] <= n and nums[nums[i] - 1] != nums[i]:
            nums[nums[i] - 1], nums[i] = nums[i], nums[nums[i] - 1]
    for i in range(n):
        if nums[i] != i + 1:
            return i + 1
    return n + 1

7. 接雨水

题目

给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。

在这里插入图片描述
上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。

在这里插入图片描述
解答

暴力解法

解题思路如下:

  • 遍历 height 每一个元素,分别找出每个元素的左侧最大值和右侧最大值;
  • 如果两侧最大值中的最小值不大于该元素,说明该元素对应的位置无法接水;
  • 否则说明该元素的位置上有水,且接水量等于两侧最大值中的最小值减去该元素。

代码如下:

def trap(self, height: List[int]) -> int:
    n = len(height)
    if n <= 2:
        return 0
    result = 0
    for index, element in enumerate(height[1:-1], 1):
        maxleft = height[0]
        maxright = height[n-1]
        for i in range(index):
            maxleft = max(height[i], maxleft)
        for j in range(index+1, n):
            maxright = max(height[j], maxright)
        mid = min(maxleft, maxright)
        if mid > element:
            result += mid - element
    return result

动态规划解法

暴力解法的时间复杂度很大,观察程序可以发现,时间大部分都是用在寻找元素两侧的最大值了,每遍历一个新的元素,就要重新寻找两侧最大值。因此,我们改进一下,先求出来每个元素的两侧最大值。

代码如下:

def trap(self, height: List[int]) -> int:
    n = len(height)
    if n <= 2:
        return 0
    result = 0
    maxleft = [height[0]] + [0]*(n-1)
    maxright = [0]*(n-1) + [height[-1]]
    for i in range(1, n):
        maxleft[i] = max(height[i], maxleft[i-1])
    for j in range(n-2, -1, -1):
        maxright[j] = max(height[j], maxright[j+1])
    for index, element in enumerate(height[1:-1], 1):
        mid = min(maxleft[index], maxright[index])
        if mid > element:
            result += mid - element
    return result

双指针解法

双指针解法就是将动态规划解法里的 index 变为 left 和 right,然后从两端向中间移动。由于接水量是以短边为基准,因此我们应该先考虑短边,短边先移动。

代码如下:

def trap(self, height: List[int]) -> int:
    n = len(height)
    if n <= 2:
        return 0
    result = 0
    left, right = 0, n-1
    maxleft, maxright = height[0], height[-1]
    while left < right:
        maxleft = max(maxleft, height[left])
        maxright = max(maxright, height[right])
        if maxleft < maxright:
            result += maxleft - height[left]
            left += 1
        else:
            result += maxright - height[right]
            right -= 1
    return result

8. 不同路径

题目

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

问总共有多少条不同的路径?
在这里插入图片描述
在这里插入图片描述
解法

动态规划解法

解题思路如下:

  • 从起点向终点移动,初始创建一个 m*n 的矩阵,并将第0行和第0列全部置为1,其余位置初始值为0;
  • 矩阵的每个元素值代表从起点移动到当前位置有几条路径,很明显与起点相关的边缘位置(第0行和第0列)都是1;
  • 更新除第0行和第0列的其他位置上的元素值,更新方法为该位置的元素值等于左侧+上侧的元素值;
  • 返回 m*n 矩阵的最后一个元素值作为最终结果。

代码如下:

def uniquePaths(self, m: int, n: int) -> int:
    dp = [[1]*n] + [[1]+[0] * (n-1) for _ in range(m-1)]
    for i in range(1, m):
        for j in range(1, n):
            dp[i][j] = dp[i-1][j] + dp[i][j-1]
    return dp[-1][-1]

9. 爬楼梯

题目

假设你正在爬楼梯。需要 n 阶你才能到达楼顶。

每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?

注意:给定 n 是一个正整数。
在这里插入图片描述
解法

动态规划解法

这道题与上一题有着相同的思路。

假设一共有 n 阶台阶:如果最后只爬1阶台阶,那此时爬到第 n 阶的方法 = 爬到第 n-1 阶的方法 × 1,如果最后爬两阶台阶,那此时爬到第 n 阶的方法 = 爬到第 n-2 阶的方法 × 1。即当爬到第 n-1 阶时,再爬一阶就能到达第 n 阶;当爬到第 n-2 阶时,再爬两阶就能到达第 n 阶。因此,爬到第 n 阶的方法等于爬到第 n-1 阶和 n-2 阶的方法之和。

这里容易产生几个疑惑:

  • 爬到第 n-2 阶时,有两种方法可以爬到第 n 阶,为什么只考虑一次爬两阶?
    当从第 n-2 阶爬一阶时,爬到了第 n-1 阶,此时的方法属于爬到第 n-1 阶的方法,因此与前面方法重叠。
  • 为什么只考虑 n-1、n-2 阶,不接着考虑 n-3 阶?
    因为一次只能爬一阶或者两阶。

因此,我们初始化爬到1阶和爬到2阶的方法数,就可以逐渐推导出爬到第 n 阶的方法。

代码如下:

def climbStairs(self, n: int) -> int:
    one_step = 1
    two_step = 2
    n_step = 0
    for i in range(3, n+1):
        n_step = one_step + two_step
        one_step = two_step
        two_step = n_step       
    return max(n_step, n)  # 避免出现1、2阶计算错误

10. 和为K的子数组

题目

给定一个整数数组和一个整数 k,你需要找到该数组中和为 k 的连续的子数组的个数。
在这里插入图片描述
解答

这道题中的”连续“字眼是真的迷惑,我想了半天也想不出来怎么算是连续怎么算是不连续。后来看了题解,才发现这个”连续“的意思是和为整数 k 的子数组元素要靠在一起,不能是分散不连续的,也就是说求给定数组中有多少个满足该要求的不同子数组。

这道题刚看到时我想到的是用滑动窗口来解决该问题,但是我忽略了一个事实,给定数组并没说是正整数数组,因此滑动窗口到底是应该扩张还是收缩比较难判断。

暴力解法

暴力解法很容易想到:暴力求解所有的子数组,然后分别计算和,如果等于 k,count 就+1。代码如下:

def subarraySum(self, nums: List[int], k: int) -> int:
    cnt, n =  0, len(nums)
    for i in range(n):
        for j in range(i, n):
            if (sum(nums[i:j + 1]) == k): cnt += 1
    return cnt

前缀和解法

解题思路如下:

  • 创建并维护一个字典 dic,字典的 key 为累加值 acc,value 为累加值 acc 出现的次数(因为数组中可能有负数,所以累加值并不是一直增大的);
  • 遍历给定数组 nums,不断更新 acc 和 dic,如果 acc 等于 k,那么很明显结果变量 res 应该+1;
  • 如果 dic[acc - k] 存在,说明当前累加值减去前面得到的某一累加值可以得到目标和,那么总共有几种得到目标和的方法呢?因为得到当前累加值就是将遍历过的元素加起来即可,只有一种方法,因此得到前面的某一累加值有多少种方法,就总共有多少种方案得到目标和,所以我们就把 dic[acc - k] 加到结果中去即可。

代码如下:

def subarraySum(self, nums: List[int], k: int) -> int:
    dic = {}
    acc, res = 0, 0
    for num in nums:
        acc += num
        if acc == k: res += 1
        if acc - k in dic: res += dic[acc - k]
        dic[acc] = dic.get(acc, 0) + 1
    return res

11. 合并两个有序数组

题目

给你两个有序整数数组 nums1 和 nums2,请你将 nums2 合并到 nums1 中,使 nums1 成为一个有序数组。

初始化 nums1 和 nums2 的元素数量分别为 m 和 n 。你可以假设 nums1 的空间大小等于 m + n,这样它就有足够的空间保存来自 nums2 的元素。
在这里插入图片描述
解答

面试百度地图时候遇到的一道题,然后脑子抽抽了没做出来。

暴力解法

直接将数组 nums1 的前 m 个元素和 nums2 的元素合并然后直接排序并覆盖原来的 nums1 数组即可。

代码如下:

def merge(self, nums1, m, nums2, n):
    nums1[:] = sorted(nums1[:m]+nums2)

指针解法//从前往后

因为题目要求将最后结果写到 nums1 中去,所以我们无法创造新的结果数组,但是我们可以创造新的数组来存放 nums1 初始要排序的数组,这样就可以覆盖写数组 nums1 了。

解题思路如下:

  • 将 nums1 的数组的前 n 个元素复制给数组 nums1_cp,然后创建三个指针,p1 指向 nums1_cp 的头,p2 指向 nums2 的头,p 指向 nums1 的头
  • 当 p1 的索引位置小于 m 且 p2 的索引位置小于 n 时,进行以下循环:
    • 当 p1 指向的元素小于 p2 时,将 p1 指向的元素写入 p 指向的元素,然后 p1向后移动一位;反之,将 p2 指向的元素写入 p 指向的元素,然后 p2向后移动一位
    • p 向后移动一位
  • 跳出循环后,根据 p1、p2 指向的索引位置来判断哪个数组还没有遍历完,并将没遍历完的数组写入 nums1 中。

代码如下:

def merge(self, nums1, m, nums2, n):
    p1 = 0
    p2 = 0
    p = 0
    nums1_cp = nums1[:m]
    while p1 < m and p2 < n:
        if nums1_cp[p1] < nums2[p2]:
            nums1[p] = nums1_cp[p1]
            p1 += 1
        else:
            nums1[p] = nums2[p2]
            p2 += 1
        p += 1
    if p1 < m:
        nums1[p:] = nums1_cp[p1:]
    else:
        nums1[p:] = nums2[p2:]

指针解法//从后往前

观察题目给出的示例,我们可以发现数组 nums1 后面的0对应的就是数组 nums2 的个数,为此我们可以通过从后往前遍历数组来避免使用额外的空间。

解题思路如下:

  • 创建三个指针,p1 指向 nums1 的 m-1 位置,p2 指向 nums2 的尾,p 指向 nums1 的尾
  • 当 p1 的索引位置不小于 0 且 p2 的索引位置不小于 0 时,进行以下循环:
    • 当 p1 指向的元素大于 p2 时,将 p1 指向的元素写入 p 指向的元素,然后 p1向前移动一位;反之,将 p2 指向的元素写入 p 指向的元素,然后 p2 向前移动一位
    • p 向前移动一位
  • 跳出循环后,根据 p1、p2 指向的索引位置来判断哪个数组还没有遍历完,如果是数组 nums2 还没有遍历完,并将没遍历完的数组写入 nums1 中。(nums1 没遍历完不需要写)

代码如下:

def merge(self, nums1, m, nums2, n):
    p1 = m-1
    p2 = n-1
    p = m+n-1
    while p1 >= 0 and p2 >= 0:
        if nums1[p1] > nums2[p2]:
            nums1[p] = nums1[p1]
            p1 -= 1
        else:
            nums1[p] = nums2[p2]
            p2 -= 1
        p -= 1
    if p1 < 0:
        nums1[:p+1] = nums2[:p2+1]

12. 合并区间

题目

给出一个区间的集合,请合并所有重叠的区间。

在这里插入图片描述
解法

排序解法

解题思路如下:

  • 对给定数组按照元素值升序排序;
  • 当数组不为空时,进行以下循环:
    • 取数组的第一个元素给变量 mid,遍历数组剩下的元素 x;
    • 由于已经对数组排好了序,因此 x 的左值必不小于 mid 的左值,当 x 的左值不大于 mid 的右值时,说明 mid 与当前 x 有重叠,那么此时我们更新 mid 的右值为 max(x[1], mid[1]);
    • 我们在遍历元素时会记录当前遍历位置(变量 cur1、cur2),遍历结束后我们直接从下一个未重叠区间开始即可。

代码如下:

def merge(self, intervals: List[List[int]]) -> List[List[int]]:
    if len(intervals) <= 1:
        return intervals
    result = []
    intervals.sort()
    while intervals:
        mid = intervals[0]
        cur1 = 0
        cur2 = 0
        for i, x in enumerate(intervals[1:], 1):
            if mid[1] >= x[0]:
                mid[1] = max(x[1], mid[1])
                cur1 = i
            else:
                cur2 = i
                break
        result.append(mid)
        intervals = intervals[max(cur1+1, cur2):]
    return result

13. 最短无序连续子数组

题目

给你一个整数数组 nums ,你需要找出一个连续子数组,如果对这个子数组进行升序排序,那么整个数组都会变为升序排序。

请你找出符合题意的最短子数组,并输出它的长度。
在这里插入图片描述
解法

排序解法

解题思路如下:

  • 先判断给定数组 nums 的长度,如果长度不大于1,那么直接返回0;
  • 创建一个结果数组 res 用来存放满足题目要求子数组的起始索引和结尾索引;
  • 我们对给定数组 nums 进行排序得到一个新数组 sort_nums,然后分别正序和逆序将 sort_nums 和 nums 进行逐元素比较,并将第一个元素不同的位置索引存入 res 数组中;
  • 如果 res 为空返回0,否则返回 res 数组中元素值的差+1。

代码如下:

def findUnsortedSubarray(self, nums: List[int]) -> int:
    n = len(nums)
    if n <= 1:
        return 0
    res = []
    sort_nums = sorted(nums)
    for i in range(n):
        if nums[i] != sort_nums[i]:
            res.append(i)
            break
    for i in range(n-1, -1, -1):
        if nums[i] != sort_nums[i]:
            res.append(i)
            break
    return 0 if not res else res[1]-res[0]+1

    # 以下为代码简化写法
    sort_nums = sorted(nums)
    left = 0
    right = len(nums) - 1
    while left <= right and nums[left] == sort_nums[left]:
        left += 1
    while left <= right and nums[right] == sort_nums[right]:
        right -= 1
    return right - left + 1

14. 最小路径

题目

给定一个包含非负整数的 m x n 网格,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

说明:每次只能向下或者向右移动一步。
在这里插入图片描述
解法

动态规划解法
解题思路如下:

  • 第0行或者第0列的元素值只能是前面值(包括该元素)的累加和;
  • 其他位置元素的值,等于左方、上方中的最小值+当前元素值;
  • 直接在原来的数组上修改即可,空间复杂度为O(1)。

代码如下:

def minPathSum(self, grid: [[int]]) -> int:
    m = len(grid)
    n = len(grid[0])
    for i in range(m):
        for j in range(n):
            if i ==0 and j == 0:
                continue
            elif i == 0:
                grid[i][j] += grid[i][j - 1]
            elif j == 0:  
                grid[i][j] += grid[i - 1][j]
            else: 
                grid[i][j] += min(grid[i - 1][j], grid[i][j - 1])
    return grid[-1][-1]

递归解法

这道题在面试百度地图的时候,面试官追加了一问,就是能否同时写出来相关的路径。当时没有想出来,只是觉得动态规划应该行不通了,得用递归。面试结束后,研究了差不多一个小时,写出来了相应的代码:

def minPathSum(grid):
    res = []
    def look_for_path(i, j, path, s):
        if i >= len(grid) or j >= len(grid[0]):
            return
        if i == len(grid)-1 and j == len(grid[0])-1:
            res.append((s, path+[(i, j)]))
            return
        look_for_path(i, j+1, path+[(i, j)], s+grid[i][j])
        look_for_path(i+1, j, path+[(i, j)], s+grid[i][j])

    look_for_path(0, 0, [], 0)
    res.sort()
    return res[0]

15. 岛屿数量

题目

给你一个由 ‘1’(陆地)和 ‘0’(水)组成的的二维网格,请你计算网格中岛屿的数量。

岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。

此外,你可以假设该网格的四条边均被水包围。
在这里插入图片描述
解法

深度优先遍历解法 DFS

解题思路如下:

  • 遍历整个网格 grid,当遇到 grid[i][j] == ‘1’ 时,从该点开始做深度优先遍历,并在深度优先遍历中删除此岛屿,然后将岛屿数 count +1;
  • 深度优先遍历方法如下:
    • 设目前指针指向一个岛屿中的某一点 (i, j),寻找包括此点的岛屿边界
    • 从 (i, j) 向此点的上下左右 (i+1,j), (i-1,j), (i,j+1), (i,j-1) 做深度搜索。
    • 终止条件为下列情况之一:
      • (i, j) 越过矩阵边界;
      • grid[i][j] == 0,代表此分支已越过岛屿边界。
  • 在搜索岛屿的同时,执行 grid[i][j] = ‘0’,即将同一个岛屿包含的所有节点置为0,以免后续重复搜索相同岛屿。

代码如下:

def numIslands(self, grid: List[List[str]]) -> int:
    def dfs(grid, i, j):
        if not 0 <= i < len(grid) or not 0 <= j < len(grid[0]) or grid[i][j] == '0':
            return
        grid[i][j] = '0'
        dfs(grid, i + 1, j)
        dfs(grid, i, j + 1)
        dfs(grid, i - 1, j)
        dfs(grid, i, j - 1)
    count = 0
    for i in range(len(grid)):
        for j in range(len(grid[0])):
            if grid[i][j] == '1':
                dfs(grid, i, j)
                count += 1
    return count

广度优先遍历解法 BFS

  • 借用一个队列 queue,判断队列首部节点 (i, j) 是否未越界且为 “1”:
    • 若为1则置零(删除岛屿节点),并将此节点上下左右节点 (i+1,j),(i-1,j),(i,j+1),(i,j-1) 加入队列;
    • 若不是则跳过此节点;
  • 当队列不为空时继续从中取元素,直到整个队列为空,则此时已经遍历完此岛屿。

代码如下:

def numIslands(self, grid: List[List[str]]) -> int:
    def bfs(grid, i, j):
        queue = [[i, j]]
        while queue:
            [i, j] = queue.pop(0)
            if 0 <= i < len(grid) and 0 <= j < len(grid[0]) and grid[i][j] == '1':
                grid[i][j] = '0'
                queue += [[i + 1, j], [i - 1, j], [i, j - 1], [i, j + 1]]
    count = 0
    for i in range(len(grid)):
        for j in range(len(grid[0])):
            if grid[i][j] == '1':
                bfs(grid, i, j)
                count += 1
    return count

16. 任务调度器

题目

给你一个用字符数组 tasks 表示的 CPU 需要执行的任务列表。其中每个字母表示一种不同种类的任务。任务可以以任意顺序执行,并且每个任务都可以在 1 个单位时间内执行完。在任何一个单位时间,CPU 可以完成一个任务,或者处于待命状态。

然而,两个相同种类的任务之间必须有长度为整数 n 的冷却时间,因此至少有连续 n 个单位时间内 CPU 在执行不同的任务,或者在待命状态。

你需要计算完成所有任务所需要的 最短时间 。
在这里插入图片描述
在这里插入图片描述
解答

完成所有任务的最短时间取决于出现次数最多的任务数量

题目给出的例子如下:

输入: tasks = ["A","A","A","B","B","B"], n = 2
输出: 8
执行顺序: A -> B -> (待命) -> A -> B -> (待命) -> A -> B.

因为相同任务必须要有时间片为 n 的间隔,所以我们先把出现次数最多的任务 A 安排上(当然你也可以选择任务 B)。例子中 n = 2,那么任意两个任务 A 之间都必须间隔 2 个单位的时间:

A -> (单位时间) -> (单位时间) -> A -> (单位时间) -> (单位时间) -> A

中间间隔的单位时间可以用来安排别的任务,也可以处于“待命”状态。当然,为了使总任务时间最短,我们要尽可能地把单位时间分配给其他任务。现在把任务 B 安排上:

A -> B -> (单位时间) -> A -> B -> (单位时间) -> A -> B

很容易观察到,前面两个 A 任务一定会固定跟着 2 个单位时间的间隔。最后一个 A 之后是否还有任务跟随取决于是否存在与任务 A 出现次数相同的任务

该例子的计算过程为:

(任务 A 出现的次数 - 1) * (n + 1) + (出现次数为 3 的任务个数),即:

(3 - 1) * (2 + 1) + 2 = 8

所以整体的解题步骤如下:

  • 计算每个任务出现的次数;
  • 找出出现次数最多的任务,假设出现次数为 x;
  • 计算至少需要的时间 (x - 1) * (n + 1),记为 min_time;
  • 计算出现次数为 x 的任务总数 count,计算最终结果为 min_time + count;

存在一种特殊情况——出现次数非最多的所有任务在填满 (x-1)*n 个坑之后,仍然有剩余任务,例如:

输入: tasks = ["A","A","A","B","B","B","C","C","D","D"], n = 2
输出: 10
执行顺序: A -> B -> C -> A -> B -> D -> A -> B -> C -> D

此时如果按照上述方法计算将得到结果为 8,比数组总长度 10 要小,应返回数组长度。

代码如下:

def leastInterval(self, tasks: List[str], n: int) -> int:
    length = len(tasks)
    if length <= 1:
        return length
    
    task_map = {}
    for task in tasks:
        task_map[task] = task_map.get(task, 0) + 1
    task_sort = sorted(task_map.items(), key=lambda x: x[1], reverse=True)
    
    max_task_count = task_sort[0][1]
    res = (max_task_count - 1) * (n + 1)
    
    for sort in task_sort:
        if sort[1] == max_task_count:
            res += 1
    
    return max(res, length)

17. 每日温度

题目

请根据每日气温列表,重新生成一个列表。对应位置的输出为:要想观测到更高的气温,至少需要等待的天数。如果气温在这之后都不会升高,请在该位置用 0 来代替。

例如,给定一个列表 temperatures = [73, 74, 75, 71, 69, 72, 76, 73],你的输出应该是 [1, 1, 4, 2, 1, 1, 0, 0]。

提示:气温列表长度的范围是 [1, 30000]。每个气温的值的均为华氏度,都是在 [30, 100] 范围内的整数。

解答

暴力解法

这道题的暴力解法很容易想到。具体的解题思路如下:

  • 创建一个与给定温度数组 T 长度相同的结果数组 res,元素初始化为0;
  • 创建一个存放数组遍历时当前最大值的变量 max_value,初始化最大值为数组的最后一个元素;
  • 从数组 T 倒数第二个元素开始,逆序遍历数组:如果当前元素大于等于 max_vaule,更新 max_vaule 并结束本次循环;否则,遍历当前元素之后的元素,找到比当前元素值大的元素的相对位置并用它来更新 res 数组当前元素对应位置的值。

代码如下:

def dailyTemperatures(self, T: List[int]) -> List[int]:
    if not T:
        return []
    n = len(T)
    res = [0]*n
    max_value = T[-1]
    for index, element in enumerate(T[:n-1][::-1], 1):
        if element >= max_value:
            max_value = element
        else:
            for cur_i, cur_n in enumerate(T[n-index:], 1):
                if cur_n > element:
                    res[n-index-1] = cur_i
                    break    
    return res

字典解法

用字典来优化上述暴力解法:

  • 创建一个空字典 d,将来在遍历温度数组的时候存放键值,键为元素值,值为元素对应的位置索引;
  • 创建一个与给定温度数组 T 长度相同的结果数组 ans,元素初始化为0;
  • 逆序遍历给定温度数组 T,遍历字典中的键,如果键大于当前元素值,那么将键对应的值减去当前元素的位置索引得到的结果(即相对位置)存入 tmp 数组中;
  • 用当前 tmp 数组中的最小值来更新结果数组 ans 中当前元素对应位置的值。

代码如下:

def dailyTemperatures(self, T: List[int]) -> List[int]:
    d = {}
    ans = [0] * len(T)
    for i in range(len(T)-1, -1, -1):
        tmp = [d[t] - i for t in d if t > T[i]]
        d[T[i]] = i
        ans[i] = min(tmp) if tmp else 0
    return ans

单调栈解法

可以维护一个存储下标的单调栈,从栈底到栈顶的下标对应的温度列表中的温度依次递减。如果一个下标在单调栈里,则表示尚未找到下一次温度更高的下标。解题思路如下:

  • 创建一个与给定温度数组 T 长度相同的结果数组 ans,元素初始化为0;
  • 创建一个空栈 stack;
  • 遍历给定温度数组 T ,当栈不为空且栈顶索引对应的元素小于当前遍历元素时,用索引差(当前遍历元素的索引-栈顶索引)来更新结果数组 ans 中栈顶索引对应位置的值,然后将栈顶元素去除并继续判断当前栈顶对应的元素和当前遍历元素的关系;
  • 最后将当前遍历元素的索引入栈。

代码如下:

def dailyTemperatures(self, T: List[int]) -> List[int]:
    ans = [0] * len(T)
    stack = []
    for i in range(len(T)):
        while stack and T[stack[-1]] < T[i]:
            ans[stack[-1]] = i - stack[-1]
            stack.pop()
        stack.append(i)
    return ans

18. 前 K 个高频元素

题目

给定一个非空的整数数组,返回其中出现频率前 k 高的元素。
在这里插入图片描述
提示:

  • 你可以假设给定的 k 总是合理的,且 1 ≤ k ≤ 数组中不相同的元素的个数。
  • 你的算法的时间复杂度必须优于 O(n log n) , n 是数组的大小。
  • 题目数据保证答案唯一,换句话说,数组中前 k 个高频元素的集合是唯一的。
  • 你可以按任意顺序返回答案。

解答

二叉堆与优先队列

这道题用到了二叉堆与优先队列的相关知识点。

二叉树本质是一种完全二叉树,它分为最大堆最小堆。最大堆的任何一个父节点,都大于等于它的左子节点或右子节点的值。最小堆的任何一个父节点,都小于等于它的左子节点或右子节点的值。二叉堆的根节点叫作堆顶,最大堆的堆顶是整个堆中的最大元素,最小堆的堆顶是整个堆中的最小元素。

队列的特点是先进先出(FIFO)。而优先队列不再遵循先入先出的规则,而是分为两类:最大优先队列和最小优先队列。最大优先队列是无论入队顺序如何,都是当前最大的元素优先出队。最小优先队列是无论入队顺序如何,都是当前最小的元素优先出队。

因此,我们可以结合以上两部分知识来解决这道题。具体思路如下:

  • 根据给定数组计算每个元素的个数并将其转换成列表的形式 stat,列表 stat 中的元素形式为 (某一种元素, 元素个数);
  • 因为最小堆是一种完全二叉树,我们假设完全二叉树是从索引 1 开始计算的,因此对于索引 i,它的左子节点索引是 2i,右子节点索引是 2i+1;
  • 创建最小堆 heap,在最开始插入一个占位节点 (0, 0),此时最小堆堆顶索引与数组 heap 索引为 1 的位置相同;
  • 读取 stat 中前 k 个元素,将其逐个放入数组 heap 中并利用上浮方法进行排序,最终二叉堆的规模为 k+1,多出来的1 为占位节点;
  • 依次读取 stat 中除前 k 个元素外剩下的元素,并判断该元素对应的元素个数是否大于二叉堆的堆顶,即是否 heap[1];
  • 如果大于,说明相比于 heap[1] 该元素更满足要求,将堆顶用该元素替换,然后将该元素逐渐下沉到它应在的位置;
  • 最后,排除占位节点输出 heap[1:] 即为所求结果。

代码如下:

def topKFrequent(self, nums: List[int], k: int) -> List[int]:
    def sift_down(arr, root, k):
        """下沉log(k),如果新的根节点>子节点就一直下沉"""
        val = arr[root] # 用类似插入排序的赋值交换
        while root<<1 < k:
            child = root << 1
            # 选取左右孩子中小的与父节点交换
            if child|1 < k and arr[child|1][1] < arr[child][1]:
                child |= 1
            # 如果子节点<新节点,交换,如果已经有序break
            if arr[child][1] < val[1]:
                arr[root] = arr[child]
                root = child
            else:
                break
        arr[root] = val

    def sift_up(arr, child):
        """上浮log(k),如果新加入的节点<父节点就一直上浮,最小堆 """
        val = arr[child]
        while child>>1 > 0 and val[1] < arr[child>>1][1]:
            arr[child] = arr[child>>1]
            child >>= 1
        arr[child] = val

    stat = collections.Counter(nums)
    stat = list(stat.items())
    heap = [(0,0)]

    # 构建规模为k+1的堆,新元素加入堆尾,上浮
    for i in range(k):
        heap.append(stat[i])
        sift_up(heap, len(heap)-1) 
    # 维护规模为k+1的堆,如果新元素大于堆顶,入堆,并下沉
    for i in range(k, len(stat)):
        if stat[i][1] > heap[1][1]:
            heap[1] = stat[i]
            sift_down(heap, 1, k+1) 
    return [item[0] for item in heap[1:]]

最小优先队列

这道题在面试的时候遇到过,但是由于当时没复习这道题,所以硬着头皮换了一种投机取巧的方式写了出来。利用了 Python 的 heapq 库,该库的作用类似于最小优先队列,最小优先队列是无论入队顺序如何,都是当前最小的元素优先出队。

解题思路如下:

  • 利用 Counter() 计算给定数组中每个元素的个数;
  • 将结果转换成列表的形式,列表中的元素形式为 (某一种元素, 元素个数);
  • 将列表中的元素压入最小优先队列,压入时的元素形式为 (元素个数, 对应某一元素);
  • 计算原列表的长度 n,然后从最小优先队列中移除 n-k 个最小(个数)的元素,那么此时最小优先队列中剩下的元素就是前 K 个高频元素。

代码如下:

def topKFrequent(self, nums: List[int], k: int) -> List[int]:
    from collections import Counter
    import heapq
    
    heap = []
    d_count = Counter(nums)
    length = len(d_count)
    for element, number in d_count.items():
        heapq.heappush(heap, (number, element))
    for i in range(length-k):
        heapq.heappop(heap)
    return [i[1] for i in heap]

19. 组合总和系列

组合总和

题目

给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的数字可以无限制重复被选取。

说明:

  • 所有数字(包括 target)都是正整数。
  • 解集不能包含重复的组合。

在这里插入图片描述
解法

二叉树解法

这道题用的是深度优先遍历解法。

以示例1为例,我们假设输入 candidates = [2, 3, 6, 7],target = 7。

candidates 数组第一个元素为 2,如果我们能够找到数组中总和为 7 - 2 = 5 的所有组合,再加上 2 ,就是 7 的所有组合;同理,对于元素 3,如果找到了组合总和为 7 - 3 = 4 的所有组合,再加上 3 ,就是 7 的所有组合,依次这样找下去。

基于以上思想,可以画出如下的树形图。
在这里插入图片描述
根据上图我们发现,产生的结果中有重复。这是因为在每一个结点做减法、展开分支的时候,由于题目中说“每一个元素可以重复使用”,我因此们考虑了所有的候选数,因此出现了重复的列表。

因此,我们需要对二叉树进行剪枝。根据上面的树形图我们可以发现,如果 target 减去一个数得到负数,那么减去一个更大的数依然是负数,同样搜索不到结果。基于这个想法,我们可以对输入数组进行排序,添加相关逻辑达到剪枝的目的。

代码如下:

def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
    def dfs(begin, size, path, target):
        if target == 0:
            res.append(path)
            return

        for index in range(begin, size):
            residue = target - candidates[index]
            if residue < 0:
                break

            dfs(index, size, path + [candidates[index]], residue)

    size = len(candidates)
    if size == 0:
        return []
    candidates.sort()
    path = []
    res = []
    dfs(0, size, path, target)
    return res

组合总和 II

题目

给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用一次。

说明:

  • 所有数字(包括目标数)都是正整数。
  • 解集不能包含重复的组合。

在这里插入图片描述
解答

这道题与前一道题目不同的是,数组中可能含有重复元素,而且每个元素只能使用一次。

解题思路与前面相同,需要更改的地方是:

  • 由于每个元素只能使用一次,因此在进行递归时,下一递归函数的起始索引需要加1;
  • 为了避免重复结果,我们多加了一条语句if begin < index and candidates[index-1] == candidates[index]: continue。这样能够避免结果中含有重复元素。(本来的思路是,将 path 变成集合,每找到一个满足要求的 path 就判断是否集合 path 是否已经在结果 res 中,但是这样做代码超时了)
    这条语句之所以能够去掉重复结果,是因为如果数组中含有重复元素,我们在前面的函数深度优先遍历中已经会遍历过该情形,不需要再遍历一次。

代码如下:

def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
    def dfs(begin, size, path, target):
        if target == 0:
            res.append(path)
            return

        for index in range(begin, size):
            if begin < index and candidates[index-1] == candidates[index]:
                continue
            residue = target - candidates[index]
            if residue < 0:
                break

            dfs(index+1, size, path + [candidates[index]], residue)

    size = len(candidates)
    if size == 0:
        return []
    candidates.sort()
    path = []
    res = []
    dfs(0, size, path, target)
    return res

20. 全排列问题

全排列的下一个排列

题目

实现获取下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。

如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。

必须原地修改,只允许使用额外常数空间。

以下是一些例子,输入位于左侧列,其相应输出位于右侧列。

1,2,3 → 1,3,2
3,2,1 → 1,2,3
1,1,5 → 1,5,1

解答

为了和当前数字接近,我们应该尽量保持高位不变,在最小的范围内变换低位的顺序。解题思路如下:

  • 逆向遍历数组nums,找出其逆序区域的前一位索引 index,它就是数字置换的边界;
  • 找出逆序区域中大于index对应元素的最小数字,交换这两个元素;
  • 将原来范围的逆序区域变成顺序;
  • 由于题目要求“如果不存在下一个更大的排列,则将数字重新排列成最小的排列”,因此我这里设置了一个变量exchange来判断是否需要交换元素。

代码如下:

def nextPermutation(self, nums: List[int]) -> None:
    """
    Do not return anything, modify nums in-place instead.
    """
    if len(nums) > 1:
        exchange = False
        for i in range(len(nums)-1, 0, -1):
            if nums[i] > nums[i-1]: # 如果找到了
                index = i-1
                exchange = True
                break
        if exchange:
            for i in range(len(nums)-1, index, -1):
                if nums[index] < nums[i]:
                    nums[index], nums[i] = nums[i], nums[index]
                    break
            for i in range((len(nums)-index-1)//2):
                nums[index+i+1], nums[len(nums)-i-1] = nums[len(nums)-i-1], nums[index+i+1] 
        else:                   
            for i in range(len(nums)//2):
                nums[i], nums[len(nums)-i-1] = nums[len(nums)-i-1], nums[i]

写出所有的全排列 I

题目

给定一个没有重复数字的序列,返回其所有可能的全排列。

在这里插入图片描述
解答

递归解法

解题思路如下:

  • 定义一个与给定数组 nums 等长的数组 included,并且初始元素全部置为 0;
  • 定义一个函数,然后开始遍历数组中的每一个元素,当该元素已经被遍历过时,对应的 included 数组索引置为 1 ,进一步递归时不会再遍历该元素;
  • 当已遍历数组的长度等于原数组长度时,将该数组的加到结果数组中。

代码如下:

def permute(self, nums: List[int]) -> List[List[int]]:
    def permute_method(cur_list, length):
        if length == n:
            result.append(cur_list)
            return
        for i in range(n):
            if included[i] :
                continue
            included[i] = 1
            permute_method(cur_list + [nums[i]], length + 1)
            included[i] = 0

    n = len(nums)
    result = []
    included = [0] * n
    permute_method([],0)
    return result

写出所有的全排列 II

题目

给定一个可包含重复数字的序列 nums ,按任意顺序返回所有不重复的全排列。

在这里插入图片描述
解答

递归解法

这道题可以按照“全排列 I ”的思想进行解答。在结果中去重:在我们每次要把 cur_list 放入结果数组 result 中时,我们首先判断 cur_list 是否已经在结果数组中,如果在的话则不放入结果数组中。

代码如下:

def permuteUnique(self, nums: List[int]) -> List[List[int]]:
    def permute_method(cur_list, length):
        if length == n:
            if cur_list not in result:
                result.append(cur_list)
            return
        for i in range(n):
            if included[i]:
                continue
            included[i] = 1
            permute_method(cur_list + [nums[i]], length + 1)
            included[i] = 0

    n = len(nums)
    result = []
    included = [0]*n
    permute_method([],0)
    return result

全排列 I 和 II 有点类似于组合总和 I 和 II,均是数组一个含有重复元素一个不含有重复元素。在组合总和里,我们加了一条判断语句if begin < index and candidates[index-1] == candidates[index]: continue来去除重复结果。

同样,在这里我们也可以利用同样思想来进行去重。在之前的剪枝条件1:用过的元素不能再使用之外,又添加了一个新的剪枝条件,也就是我们考虑重复部分的结果,剪枝条件2:当当前元素和前一个元素值相同(此处隐含当前元素的 index>0 ),并且前一个元素还没有被使用过的时候,我们要剪枝:if i>0 and nums[i-1] == nums[i] and included[i-1] == 0: continue

代码如下:

def permuteUnique(self, nums: List[int]) -> List[List[int]]:
    def permute_method(cur_list, length):
        if length == n:
            result.append(cur_list)
            return
        for i in range(n):
            if included[i] or (i>0 and nums[i-1] == nums[i] and included[i-1] == 0):
                continue
            included[i] = 1
            permute_method(cur_list + [nums[i]], length + 1)
            included[i] = 0

    n = len(nums)
    nums.sort()
    result = []
    included = [0]*n
    permute_method([],0)
    return result

21. 只出现一次的数字系列

只出现一次的数字 I

题目

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

在这里插入图片描述

解答

移除元素解法

当数组不为空时,取出数组中的一个元素(同时从数组中移除),如果该元素在数组中仍然存在,那么说明该元素并不是只出现一次,从数组中再次移除该元素,如果数组不为空再次遍历;如果该元素在数组中不存在了,说明该元素就是最终的结果,直接返回。

感觉我的算法用了额外的空间(变量 i),代码如下:

def singleNumber(self, nums: List[int]) -> int:
    while nums:
        i = nums.pop()
        if i in nums:
            nums.remove(i)
        else:
            return i

集合解法

将数组转换成集合,然后对集合两倍求和,与原数组和的差值就是目标值。

代码如下:

def singleNumber(self, nums: List[int]) -> int:
    return sum(set(nums))*2-sum(nums)

异或解法

既然给定数组除了目标元素以外都出现了两次。那我们将数组的元素不断异或,出现两次的元素异或结果为0,那么最终得到的异或结果就是单数元素。

代码如下:

def singleNumber(self, nums: List[int]) -> int:
    result = nums[0]
    for i in nums[1:]:
        result ^= i
    return result

排序解法

解题思路如下:

  • 对数组进行排序,使相同元素靠在一起;
  • 步长为2来遍历数组,验证相邻元素是否相同,如果不相同,直接返回结果;
  • 如果遍历完数组依旧没返回最终结果,说明单值元素出现在了数组的最后一位。

代码如下:

def singleNumber(self, nums: List[int]) -> int:
    nums.sort()
    for i in range(1,len(nums),2):
        if nums[i-1] != nums[i]:
            return nums[i-1]
    return nums[-1]

只出现一次的数字 II

题目

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现三次。找出那个只出现了一次的元素。

在这里插入图片描述

解答

移除元素解法

当数组不为空时,取出数组中的一个元素(同时从数组中移除),如果该元素在数组中仍然存在,那么说明该元素出现了三次,从数组中再两次移除该元素,如果数组不为空再次遍历;如果该元素在数组中不存在了,说明该元素就是最终的结果,直接返回。

代码如下:

def singleNumber(self, nums: List[int]) -> int:
    while nums:
        i = nums.pop()
        if i in nums:
            nums.remove(i)
            nums.remove(i)
        else:
            return i

集合解法

将数组转换成集合,然后对集合三倍求和,与原数组和的差值的二分之一就是目标值。

代码如下:

def singleNumber(self, nums: List[int]) -> int:
    return (sum(set(nums))*3-sum(nums))//2

异或解法

给定数组除了目标元素以外都出现了三次,我们依旧可以用异或的思想来解答。我的思路是,将给定数组排序后划分成三个元素一组,然后对每一组元素进行异或。如果单数元素出现在组中,那么该元素一定是本组的头元素,而且本组元素异或的结果一定等于目标值,且与本组中间位置元素不同。如果单数元素在所有的组中均未出现,说明目标值为数组最后一个元素。

代码如下:

def singleNumber(self, nums: List[int]) -> int:
    n = len(nums)
    m = n//3
    nums.sort()
    for i in range(m):
        result = nums[3*i]^nums[3*i+1]^nums[3*i+2]
        if result != nums[3*i+1]:
            return result
    return nums[-1]

排序解法

解题思路如下:

  • 对数组进行排序,使相同元素靠在一起;
  • 步长为3来遍历数组,判断当前元素与前一个元素是否相等,如果不相等,说明前一个元素是最终结果;
  • 如果遍历完数组依旧没返回最终结果,说明单值元素出现在了数组的最后一位。

代码如下:

def singleNumber(self, nums: List[int]) -> int:
    nums.sort()
    for i in range(1,len(nums),3):
        if nums[i-1] != nums[i]:
            return nums[i-1]
    return nums[-1]

22. 买卖股票

最佳买卖股票时机含冷冻期

题目

给定一个整数数组,其中第 i 个元素代表了第 i 天的股票价格 。​

设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):

你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。
卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。
在这里插入图片描述
解答

动态规划解法

解题思路如下:

  • 首先计算 prices 的长度 n,如果 n 小于2,那么直接返回0;
  • 建立一个长度为 n、初始元素为 [0, 0] 的数组 dp,则可以用 dp[i][0] 来选取数组的数组的元素,dp[][] 这两个框中前面代表第几天,后面代表是否持有股票,对应的值就是对应的利润最大值;
  • dp[i][0] 代表第 i-1 天不持股或者第 i-1 天持股然后第 i 天卖出;
  • dp[i][1] 有所不同,按照正常的理解应该是第 i-1 天持股或者第 i-1 天不持股然后第 i 天持股,但是这里有一个冷冻期,是需要隔一天的,因此如果你在第 i-1 天不持股那么在第 i-2 天一定也不能持股,否则第 i-2 天持股在第 i-1 天不持股说明在第 i-1 天卖出,那么第 i 天是冷冻期,是不允许持股的。因此对于 dp[i][1] 来说,第 i-1 天如果不持股,那么第 i-2 天也不应该持股,这样将 第 i-1 天作为冷冻期,第 i 天才能持股;
  • 最后返回dp[-1][0]因为不持股利润是要大于等于持股的。

代码如下:

def maxProfit(self, prices: List[int]) -> int:
    n = len(prices)
    if n < 2: return 0
    dp = [[0,0] for i in range(n)]   #dp的初始化

    dp[0][0] = 0     #第0天不持股自然就为0了
    dp[0][1] = -prices[0]   #第0天持股,那么价格就是-prices[0]了
    #第1天不持股,要么第0天就不持股,要么就是第0天持股,然后第1天卖出
    dp[1][0] = max(dp[0][0],dp[0][1]+prices[1]) 
    #第一天持股,要么就是第0天就持股了,要么就是第0天不持股第1天持股
    dp[1][1] = max(dp[0][1],dp[0][0]-prices[1])
    
    for i in range(2,n):
        dp[i][0] = max(dp[i-1][0],dp[i-1][1]+prices[i])
        dp[i][1] = max(dp[i-1][1],dp[i-2][0]-prices[i])
    return dp[-1][0]

(一次)买卖股票的最佳时机 I

题目

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

如果你最多只允许完成一笔交易(即买入和卖出一支股票一次),设计一个算法来计算你所能获取的最大利润。

注意:你不能在买入股票前卖出股票。

在这里插入图片描述
解答

暴力解法

解题思路是:

  • 从左到右遍历找到到当前元素为止已遍历元素的最小元素,放在数组 left_min 中;
  • 从右到左遍历找到到当前元素为止已遍历元素的最大元素,放在数组 right_max 中;
  • 将 right_max 与 left_min 逐元素相减,得到的结果中的最大值就是最终结果。

代码如下:

def maxProfit(self, prices: List[int]) -> int:
    length = len(prices)
    if length < 2:
        return 0
    left_min = [0]*length
    right_max = [0]*length
    left_min[0] = prices[0]
    right_max[-1] = prices[-1]
    left = 1
    while left < length:
        left_min[left] = min(prices[left], left_min[left-1])
        left += 1
    right = length-2
    while right >= 0:
        right_max[right] = max(prices[right], right_max[right+1])
        right -= 1
    result = [right_max[i] - left_min[i] for i in range(length)]
    return max(result)

动态规划解法

遍历数组,保存到当前遍历位置的最小价格,同时用当前价格减去最小价格得到的结果更新最大利润,最后返回最大利润。

代码如下:

def maxProfit(self, prices: List[int]) -> int:
    minprice = float('inf')
    maxprofit = 0
    for price in prices:
        minprice = min(minprice, price)
        maxprofit = max(maxprofit, price - minprice)
    return maxprofit

买卖股票的最佳时机 II

题目

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你可以尽可能地完成更多的交易(多次买卖一支股票)。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

在这里插入图片描述
解答

动态规划解法

这道题的解题思路与“最佳买卖股票时机含冷冻期”一题的解题思路类似,均可多次进行交易。但是不同的是,本题的股票不含有冷冻期,在前一天卖出后第二天可以直接购入。因此我们在计算当天股票获润时不必考虑前天的股票售卖情况。

代码如下:

def maxProfit(self, prices: List[int]) -> int:
    n = len(prices)
    if n < 2: return 0
    dp = [[0,0] for i in range(n)]   #dp的初始化

    dp[0][0] = 0     #第0天不持股自然就为0了
    dp[0][1] = -prices[0]   #第0天持股,那么价格就是-prices[0]了
    
    for i in range(1,n):
        dp[i][0] = max(dp[i-1][0],dp[i-1][1]+prices[i])
        dp[i][1] = max(dp[i-1][1],dp[i-1][0]-prices[i])
    return dp[-1][0]

贪婪算法

题解中提出了一种贪心解法。解题思路如下:

  • 股票买卖策略
    • 单独交易日: 设今天价格 p1、明天价格 p2,则今天买入、明天卖出可赚取金额 p2 - p1(负值代表亏损)。
    • 连续上涨交易日: 设此上涨交易日股票价格分别为 p1, p2, … , pn,则第一天买最后一天卖收益最大,即 pn - p1;等价于每天都买卖,即 pn - p1=(p2 - p1)+(p3 - p2)+…+(pn - pn-1)。
    • 连续下降交易日: 则不买卖收益最大,即不会亏钱。
  • 算法流程
    • 遍历整个股票交易日价格列表 prices,策略是所有上涨交易日都买卖(赚到所有利润),所有下降交易日都不买卖(永不亏钱)。
    • 设 tmp 为第 i-1 日买入与第 i 日卖出赚取的利润,即 tmp = prices[i] - prices[i - 1] ;
    • 当该天利润为正 tmp > 0,则将利润加入总利润 profit;当利润为 0 或为负,则直接跳过;遍历完成后,返回总利润 profit。

代码如下:

def maxProfit(self, prices: List[int]) -> int:
    profit = 0
    for i in range(1, len(prices)):
        tmp = prices[i] - prices[i-1]
        if tmp > 0: profit += tmp
    return profit

买卖股票的最佳时机 III

题目

给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成两笔交易。

注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

在这里插入图片描述
解答

动态规划解法1

我们定义状态转移数组 dp[第几天][当天结束时是否持股][卖出的次数],一天结束时,可能有持股、可能未持股、可能卖出过1次、可能卖出过2次、也可能未卖出过。

具体一天结束时的6种状态:

  • 未持股,未卖出过股票:说明从未进行过买卖,利润为0:
    dp[i][0][0]=0
  • 未持股,卖出过1次股票:可能是今天卖出,也可能是之前卖的(昨天也未持股且卖出过):
    dp[i][0][1]=max(dp[i-1][1][0]+prices[i],dp[i-1][0][1])
  • 未持股,卖出过2次股票:可能是今天卖出,也可能是之前卖的(昨天也未持股且卖出过)
    dp[i][0][2]=max(dp[i-1][1][1]+prices[i],dp[i-1][0][2])
  • 持股,未卖出过股票:可能是今天买的,也可能是之前买的(昨天也持股)
    dp[i][1][0]=max(dp[i-1][0][0]-prices[i],dp[i-1][1][0])
  • 持股,卖出过1次股票:可能是今天买的,也可能是之前买的(昨天也持股)
    dp[i][1][1]=max(dp[i-1][0][1]-prices[i],dp[i-1][1][1])
  • 持股,卖出过2次股票:最多交易2次,这种情况不存在
    dp[i][1][2]=float(’-inf’)

代码如下:

def maxProfit(self, prices: List[int]) -> int:
    length=len(prices)
    if length < 2:
        return 0
    
    #结束时的最高利润=[天数][是否持有股票][卖出次数]
    dp=[ [[0,0,0],[0,0,0] ] for i in range(length) ]
    #第一天休息
    dp[0][0][0]=0
    #第一天买入
    dp[0][1][0]=-prices[0]
    # 第一天不可能已经有卖出
    dp[0][0][1] = float('-inf')
    dp[0][0][2] = float('-inf')
    #第一天不可能已经卖出
    dp[0][1][1]=float('-inf')
    dp[0][1][2]=float('-inf')
    for i in range(1,length):
        #未持股,未卖出过,说明从未进行过买卖
        dp[i][0][0]=0  # 可以注释掉
        #未持股,卖出过1次,可能是今天卖的,可能是之前卖的
        dp[i][0][1]=max(dp[i-1][1][0]+prices[i],dp[i-1][0][1])
        #未持股,卖出过2次,可能是今天卖的,可能是之前卖的
        dp[i][0][2]=max(dp[i-1][1][1]+prices[i],dp[i-1][0][2])
        #持股,未卖出过,可能是今天买的,可能是之前买的
        dp[i][1][0]=max(dp[i-1][0][0]-prices[i],dp[i-1][1][0])
        #持股,卖出过1次,可能是今天买的,可能是之前买的
        dp[i][1][1]=max(dp[i-1][0][1]-prices[i],dp[i-1][1][1])
        #持股,卖出过2次,不可能
        dp[i][1][2]=float('-inf')
    return max(dp[-1][0][1],dp[-1][0][2],0)

动态规划解法2

到最后交易结束时,一共会有5种状态:

  • dp0:一直不买
  • dp1:到最后也只买入了一笔
  • dp2:到最后买入一笔,卖出一笔
  • dp3:到最后买入两笔,卖出一笔
  • dp4:到最后买入两笔,卖出两笔

初始化5种状态:

  • dp0 = 0
  • dp1 = - prices[0]
  • 因为第一天不可能会有dp2,dp3,dp4三种状态,因此将这三者置为负无穷
  • dp2 = float("-inf")
  • dp3 = float("-inf")
  • dp4 = float("-inf")

对5种状态进行状态转移:

  • dp0 = 0 # 一直为0
  • dp1 = max(dp1, dp0 - prices[i])  # 前一天也是dp1状态,或者前一天是dp0状态,今天买入一笔变成dp1状态
  • dp2 = max(dp2, dp1 + prices[i]) # 前一天也是dp2状态,或者前一天是dp1状态,今天卖出一笔变成dp2状态
  • dp3 = max(dp3, dp2 - prices[i])  #前一天也是dp3状态,或者前一天是dp2状态,今天买入一笔变成dp3状态
  • dp4 = max(dp4, dp3 + prices[i]) #前一天也是dp4状态,或者前一天是dp3状态,今天卖出一笔变成dp4状态

最后一定是手里没有股票赚的钱最多,但不一定交易次数越多赚得越多,因此返回的是dp0,dp2,dp4的最大值

代码如下:

def maxProfit(self, prices: List[int]) -> int:
    if not prices:
        return 0
    
    dp0 = 0             # 一直不买
    dp1 = - prices[0]   # 到最后也只买入了一笔
    dp2 = float("-inf") # 到最后买入一笔,卖出一笔
    dp3 = float("-inf") # 到最后买入两笔,卖出一笔
    dp4 = float("-inf") # 到最后买入两笔,卖出两笔

    for i in range(1, len(prices)):
        dp1 = max(dp1, dp0 - prices[i])
        dp2 = max(dp2, dp1 + prices[i])
        dp3 = max(dp3, dp2 - prices[i])
        dp4 = max(dp4, dp3 + prices[i])
    return max(dp0, dp2, dp4)

23. 寻找重复数

题目

给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
在这里插入图片描述
说明:

  • 不能更改原数组(假设数组是只读的)。
  • 只能使用额外的 O(1) 的空间。
  • 时间复杂度小于 O(n2) 。
  • 数组中只有一个重复的数字,但它可能不止重复出现一次。

解答

原本以为这个题只需要用原数组总和减去原数组转换成集合后的总和就可以得到结果,后来程序运行报错,发现自己忽略了一点:“组中只有一个重复的数字,但它可能不止重复出现一次”。

二分法

利用二分解法能够找到这个重复值。

这道题中展现了一个概念叫“抽屉原理”:桌上有十个苹果,要把这十个苹果放到九个抽屉里,无论怎样放,我们会发现至少会有一个抽屉里面放不少于两个苹果。

具体思路如下:

  • 取有效范围 [left, right] 里的中间数 mid,统计原始数组中小于等于这个中间数的元素的个数 cnt;
  • 如果 cnt 大于 mid,根据抽屉原理,重复元素就在区间 [left, mid] 里,那我们就将 right 改为 mid,继续二分;
  • 如果 cnt 小于等于 mid,那么重复元素应该在区间 [mid+1, right] 里,那我们就将 left 改为 mid+1,继续二分;
  • 当 left 不小于 right 时,即 left 和 right 相等时,停止二分,返回 left 作为最终结果。

代码如下:

def findDuplicate(self, nums: List[int]) -> int:
    size = len(nums)
    left = 0  # 1也行
    right = size - 1  #数组一共有 n+1 个值,当最大值为 n

    while left < right:
        mid = left + (right - left) // 2
        cnt = 0
        for num in nums:
            if num <= mid:
                cnt += 1
        if cnt > mid:
            right = mid
        else:
            left = mid + 1
    return left

24. 颜色分类

题目

给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。

此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。

注意:

  • 不能使用代码库中的排序函数来解决这道题。

在这里插入图片描述

进阶:

  • 一个直观的解决方案是使用计数排序的两趟扫描算法:首先,迭代计算出0、1 和 2 元素的个数,然后按照0、1、2的排序,重写当前数组。
  • 你能想出一个仅使用常数空间的一趟扫描算法吗?

解答

双指针解法

按照题目的提示,暴力解法简单易懂,但是题目要求设计“仅使用常数空间的一趟扫描算法”,因此我这里用的是双指针算法。

具体的解题思路如下:

  • 计两个指针变量 left 和 right,初始时分别指向数组的首位置和末位置;
  • 遍历数组元素,若当前元素等于 0,交换 left 和 当前位置的元素,left 指针右移一位,因此指针 left 的左侧元素一定全都为 0;
  • 若当前元素等于 1,不做任何交换操作,直接遍历下一个元素,因此当前元素位置的左侧元素要么为 0,要么为 1;
  • 若当前元素等于 2,交换 right 和 当前位置的元素,right 指针左移一位,下一次继续遍历当前位置的元素(因为 right 指针可能在交换前就指向2),因此指针 right 的右侧元素一定全都为 2;
  • 当遍历到 right(包括right)位置时,停止遍历。

代码如下:

def sortColors(self, nums: List[int]) -> None:
    """
    Do not return anything, modify nums in-place instead.
    """
    left = 0
    right = len(nums) - 1
    i = 0
    while i <= right:
        if nums[i] == 0:
            nums[left], nums[i] = nums[i], nums[left]
            left += 1
            i += 1
        elif nums[i] == 1:
            i += 1
        else:
            nums[right], nums[i] = nums[i], nums[right]
            right -= 1

25. 柱状图中最大的矩形

题目

给定 n 个非负整数,用来表示柱状图中各个柱子的高度。每个柱子彼此相邻,且宽度为 1 。

求在该柱状图中,能够勾勒出来的矩形的最大面积。

在这里插入图片描述
在这里插入图片描述

解答

双指针解法

原以为这道题会像以前做过的题一样,用左右双指针逐渐向中间移动来求得最大矩形面积,后来发现左右指针移动规则存在问题,从两边向中间移动实现不了。

后来看题解发现这道题的双指针解法应该是从中间向两边移动,解题思路如下:遍历给定数组 heights 的每个元素,以当前元素 i 为中心,向左找第一个小于 heights[i] 的位置 left_i,向右找第一个小于 heights[i] 的位置 right_i,则以第 i 根柱子为最矮柱子所能延伸的最大面积为 heights[i] * (right_i - left_i -1) 。

代码如下:

def largestRectangleArea(self, heights: List[int]) -> int:
    result = 0
    n = len(heights)
    for i in range(n):
        left_i = i
        right_i = i
        while left_i >= 0 and heights[left_i] >= heights[i]:
            left_i -= 1
        while right_i < n and heights[right_i] >= heights[i]:
            right_i += 1
        result = max(result, (right_i - left_i - 1) * heights[i])
    return result

栈解法

解题思路如下:

  • 这道题的巧妙之处在于在给定数组 heights 的前后两端各添加一个值为 0 的元素(因为如果在不添加 0 元素时用栈方法去解决问题的话,如果遍历结束后之后栈不为空,那么还需要一步:弹出栈中所有元素,分别计算最大面积。当加了两个0以后,在结束后,栈一定为空);
  • 维护一个单调递增的栈 stack:遍历修改后的 heights 中的元素,当栈不为空且栈的最后一个元素对应的 heights 元素大于当前遍历的元素时,说明开始不满足栈的单调性了,此时逐渐从栈中取出不满足要求的元素并计算相应的矩形面积并更新最大面积变量 result。

代码如下:

def largestRectangleArea(self, heights: List[int]) -> int:
    stack = []
    heights = [0] + heights + [0]
    result = 0
    for i in range(len(heights)):
        while stack and heights[stack[-1]] > heights[i]:
            tmp = stack.pop()
            result = max(result, (i - stack[-1] - 1) * heights[tmp])
        stack.append(i)
    return result

26. 根据身高重建队列

题目

假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面正好有 ki 个身高大于或等于 hi 的人。

请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。
在这里插入图片描述

解答

排序解法

对于整数对 (h, k),其中 h 是这个人的身高,k 是排在这个人前面且身高大于或等于 h 的人数。

一般这种数对,还涉及排序的,根据第一个元素正向排序,根据第二个元素反向排序,或者根据第一个元素反向排序,根据第二个元素正向排序,往往能够简化解题过程。

在本题目中,我首先对数对进行排序,按照数对的元素 1 降序排序,按照数对的元素 2 升序排序。原因是,按照元素 1 进行降序排序,对于每个元素,在其之前的元素的个数,就是大于等于该元素的数量,而按照第二个元素升序排序,我们希望 k 大的尽量在后面,减少插入操作的次数。

然后我们进行数据插入操作:创建结果数组 res 并初始化为空列表,遍历排序后数组中的每一个数对。对于每一个数对,我们判断数对的后一个元素值 p[1](即排在这个人前面且身高大于或等于这个人身高的人数)是否大于当前结果数组中的人数(元素个数):如果大于,则将该数对插入到结果数组的 p[1] 位置;否则就将其放到结果数组的最后。最后,res 就是最终结果。

代码如下:

def reconstructQueue(self, people: List[List[int]]) -> List[List[int]]:
    res = []
    people = sorted(people, key = lambda x: (-x[0], x[1]))
    for p in people:
        if len(res) <= p[1]:
            res.append(p)
        else:
            res.insert(p[1], p)
    return res

上述方法还可以在空间上进行优化,我们直接在排好序的数组上进行修改,不再单独创建结果数组 res:遍历排好序的数组,如果当前数对的位置大于当前数对的人数(数对的第二个值),那么将该数对移动到该数对人数对应的位置。

代码如下:

    def reconstructQueue(self, people: List[List[int]]) -> List[List[int]]:
        people = sorted(people, key = lambda x: (-x[0], x[1]))
        i = 0
        for i in range(len(people)):
            if i > people[i][1]:
                people.insert(people[i][1], people[i])
                people.pop(i+1)
        return people

27. 分割等和子集

题目

给定一个只包含正整数的非空数组。是否可以将这个数组分割成两个子集,使得两个子集的元素和相等。

注意:

  • 每个数组中的元素不会超过 100;
  • 数组的大小不会超过 200。

在这里插入图片描述

解答

动态规划解法

这道题本质上是一道背包问题,换个问法:我们是否可以从给定的数组中挑出一些数,这些数的总和恰好是数组总和的一半。

而背包问题的描述为:我们是否可以从给定的物品中挑出一些物品,使得这些物品恰好可以装满整个背包。既然我们可以将这道题转化为常见的背包问题,因此我们应该使用动态规划的思想来解决这道题。

解题思路如下:

  • 首先定义一个二维的动态规划数组dp[n][m+1],并将其全部初始化为0。其中 n 表示数组的元素总个数,而 m 表示数组总和的一半。dp 数组在容量这里多加一维是为了考虑容量为 0 这个边界条件。
  • 我们定义状态 dp[i][j] 为在前 i 个物品中存不存在一种选择的可能性,使得它们的总和为 j。如果存在,我们就将 dp[i][j] 的值置为1。
  • 对于元素 i, 只有两种选择的可能性,放或者是不放:
    • 如果我们如果选择不放第 i 个物品,那么dp[i][j]=dp[i-1][j]。
    • 如果我们选择放第 i 个物品,那么 dp[i][j]=dp[i-1][j-nums[i]],当然这里的前提条件是 j>=nums[i]。
  • 只要我们当发现 dp[i][-1]=1 的时候,就说明前 i 个元素就可以等于我们的既定目标,那么我们就可以直接结束我们的程序,从而完成剪枝。

借用题解中的状态转移图来加深一下理解:
在这里插入图片描述
代码如下:

def canPartition(self, nums: List[int]) -> bool:
    total_sum = sum(nums)
    if total_sum%2 == 1:  # 如果数组一半不为整数,说明该数组一定不满足要求
        return False
    
    n = len(nums)
    half_sum = total_sum//2
    dp = [[0]*(half_sum+1) for i in range(n)]  # 定义二维的动态规划数组dp[n][m+1]
    if nums[0]<=half_sum:  # 如果数组的第一个数不超过数组一半,那么将其对应位置置为1
        dp[0][nums[0]]=1
    
    for i in range(n):  # 每一行数组一定都能满足总和为0,数组元素都不选即可
        dp[i][0]=1

    for i in range(1,n):
        for j in range(1, half_sum+1):
            if nums[i]<=j:  # 选当前i时,要求当前商品i对应的值不大于j
                dp[i][j] = dp[i-1][j] or dp[i-1][j-nums[i]]  # 合并上述两种情况
            else:
                dp[i][j] = dp[i-1][j]
        if dp[i][-1]==1:  # 剪枝
              return True
    return False

我们还可以对算法的空间复杂度进一步优化。上述代码的空间复杂度为O(mn),我们可以将其降到O(m)。

dp[i] 的更新仅仅依赖于 dp[i-1],和前面的状态是不相关的,并且 dp[i][j] 的状态仅仅和 dp[i-1][j] 以及 dp[i-1][j-nums[i]] 相关。因此,之前的历史状态其实是可以不保存的;我们可以仅仅维护一个动态数组 dp[m+1],而其中最核心的技巧就是将 j 从大到小更新,这样上一轮 dp[j-nums[i]] 的值就可以在这一轮更新 dp[j] 的时候保持不变,代码如下:

def canPartition(self, nums: List[int]) -> bool:
    total_sum = sum(nums)
    if total_sum%2==1:
        return False 
    
    n = len(nums)
    half_sum = total_sum//2
    dp = [1] + [0]*half_sum
    if nums[0] <= half_sum:
        dp[nums[0]]=1
    for i in range(1,n):
        for j in range(half_sum,-1,-1):
            if nums[i]<=j:
                dp[j] = dp[j] or dp[j-nums[i]]
        if dp[-1] == 1:
            return True
    return False

28. 汉明距离

题目

两个整数之间的汉明距离指的是这两个数字对应二进制位不同的位置的数目。

给出两个整数 x 和 y,计算它们之间的汉明距离。
在这里插入图片描述
解答

位运算

这道题我是按照二进制数逐渐移位并判断奇偶来解决的。解题思路如下:

  • 判断两个数的最后一位是否相同,如果不相同,那么我们将结果加1,相同则不进行任何操作;
  • 判断方法为:如果两个数的奇偶不同,那么它们相加后肯定为奇数;
  • 将两个数分别右移一位并判断移位后的两个数是否都为0,不都为0继续执行上述步骤,都为0停止循环返回结果。

代码如下:

def hammingDistance(self, x: int, y: int) -> int:
    count = 0
    while x or y:
        if (x+y)%2 == 1:
            count += 1
        x = x>>1
        y = y>>1
    return count

29. 回文子串

题目

给定一个字符串,你的任务是计算这个字符串中有多少个回文子串。

具有不同开始位置或结束位置的子串,即使是由相同的字符组成,也会被视作不同的子串。
在这里插入图片描述
解答

暴力解法
这道题的暴力解法很容易想到,我们逐个找出给定字符串 s 的子串,并判断该子串是否为回文子串,是的话就将结果变量值加1。

判断是否为回文子串的方法是:利用双指针 left 和 right 分别从字符串头和尾进行遍历,观察 left 和 right 指向的元素是否相同,如果相同那么 left 指针右移、right 指针左移,继续判断,直到 left>right 或者双指针指向的元素不相同。如果最终 left 大于 right,说明双指针指向的元素始终对应相等,直到 left 大于 right 跳出循环,说明该子串是回文子串;如果最终 left 不大于 right,说明双指针指向的元素存在不相等的情况,提前结束了循环,说明该子串不是回文子串。

代码如下:

def countSubstrings(self, s: str) -> int:
    n = len(s)
    res = 0
    for i in range(n):
        for j in range(i, n):
            cur_s = s[i:j+1]
            left = 0
            right = len(cur_s)-1
            while left <= right:
                if cur_s[left] != cur_s[right]:
                    break
                left += 1
                right -= 1
            if left > right:
                res += 1
    return res

暴力法的优化——中心扩展法

我们只需要得到同中心的回文子串数目,显然只要我们找到子串的中心,然后使用两个指针不断向两端延伸即可,也就是中心扩展法,这样空间复杂度就降到O(1)了。找中心很简单,字符串中的元素逐一遍历即可,但是要注意是奇数还是偶数回文串,奇数回文串中心只有一个,而偶数回文串中心有两个,我们事先不知道奇偶,可以都计算一遍。

代码如下:

def countSubstrings(self, s: str) -> int:
    n = len(s)
    self.res = 0
    def helper(i,j):
        while i >= 0 and j < n and s[i] == s[j]:
            i -= 1
            j += 1
            self.res += 1
    for i in range(n):
        helper(i,i)
        helper(i,i+1)
    return self.res

动态规划解法

暴力法主要浪费在判断回文串上,不能有效利用同中心的回文串的状态。简单来说就是此时我们假设前面的子串s[j,i]是回文串,那么,子串s[j-1,i+1]也有可能是回文串,不难想出当且仅当子串s[j,i]是回文串且s[j-1]=s[i+1]时,子串s[j-1,i+1]也是回文串。因此,我们可以通过数组保存子串是否是回文串,然后通过递推上一次的状态,得到下一次的状态,属于动态规划的解法,令dp[j][i]表示子串s[j,i]是否是回文串,状态转移如下:

  • 当i=j时,单个字符肯定是回文串,可以看成奇数回文串的起点;
  • 当s[i]=s[j]且i-j=1,则dp[j][i]是回文串,可以看成偶数回文串的起点;
  • 当s[i]=s[j]且dp[j+1][i-1]是回文串,则dp[j][i]也是回文串;
  • 其他情形都不是回文串。

其中上述条件是可以合并的:

  • 当s[i]=s[j]时,dp[j+1][i-1]是回文串或i-j<2,则dp[j][i]也是回文串。

代码如下:

def countSubstrings(self, s: str) -> int:
    res = 0
    n = len(s)
    dp = [[0] * n for _ in range(n)]
    for i in range(n):
        for j in range(i+1):
            if s[i] == s[j] and (i - j < 2 or dp[j + 1][i - 1]):
                dp[j][i] = 1
                res += 1
    return res

30. 括号生成

题目

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且有效的括号组合。

在这里插入图片描述

解答

深度优先遍历DFS解法
在这里插入图片描述
解题思路如下:

  • 当左右括号的剩余个数均为0时,停止分支;
  • 产生左分支时,只需要查看是否还有剩余的左括号;
  • 而产生右分支的时候,除了查看是否剩余右括号以外,还需要查看右括号与左括号剩余数量的大小,以保证字符串中的左括号数量始终不小于右括号数量。

代码如下:

def generateParenthesis(self, n: int) -> List[str]:
    result = []
    cur_str = ""

    def dfs(cur_str, left, right):
        if left == 0 and right == 0: #左右括号剩余个数都为0,停止产生分支,result存放结果
            result.append(cur_str)
            return
        if right < left: #如果右括号剩余个数小于左括号,说明字符串中右括号放多了,回退一步
            return
        if left > 0:  #先进行左分支
            dfs(cur_str + '(', left - 1, right)
        if right > 0:  #再进行右分支
            dfs(cur_str + ')', left, right - 1)

    dfs(cur_str, n, n)
    return result

31. 搜索旋转排序数组

题目

假设按照升序排序的数组在预先未知的某个点上进行了旋转。

( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。

搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。

你可以假设数组中不存在重复的元素。

在这里插入图片描述

解答

二分解法

既然数组是由升序排序的数组旋转得到的,因此我们将数组从中间分开,一定一半是有序的一半是无序的。而判断数组是否是有序的方法就是看数组的左端是否不大于右端,因此,我们首先找出有序数组,然后判断数组是否在有序数组的区间内,如果在则抛弃另一半数组,如果不在则抛弃有序数组,继续遍历,直到数组左指针超过数组右指针。

代码如下:

def search(self, nums: List[int], target: int) -> int:
    if not nums:
        return -1
    left = 0
    right = len(nums) - 1
    while left <= right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid

        if nums[left] <= nums[mid]: #说明左侧数组有序
            if nums[left] <= target and target <= nums[mid]:
                right = mid - 1
            else:
                left = mid + 1
        else: #说明右侧数组有序
            if nums[mid] <= target and target <= nums[right]:
                left = mid + 1
            else:
                right = mid - 1
    return -1

32. 在排序数组中查找元素的第一个和最后一个位置

题目

给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。

你的算法时间复杂度必须是 O(log n) 级别。

如果数组中不存在目标值,返回 [-1, -1]。

在这里插入图片描述

解答

这个题的暴力解法很简单,顺序遍历一遍数组nums找最小下标,逆序再遍历一遍找最大下标。但是这样做的时间复杂度为O(n),而题目要求“时间复杂度必须是 O(log n) 级别”,因此这道题我们同样得用二分查找。

二分解法

不同于上一道题的是,我们这次要寻找的是元素下标的最大最小位置,即不确定元素在数组中的个数,因此这道题不像上题那样单纯地判断元素与目标值是否相等就行了。

具体的解题思路如下:

  • 创建左右两个指针 left 和 right,初始位置分别为 0 和 len(nums)。
  • 首先寻找元素的第一个位置对应的坐标:令 mid 取 left 和 right 的中间位置,当 mid 对应的元素大于或等于目标元素时,说明元素的最小下标肯定不大于当前 mid,那么将 right 移动到 mid 位置;当 mid 对应的元素小于目标元素时,说明元素的最小下标肯定在当前 mid 的右侧,那么将 left 移动到mid+1 的位置。
  • 重复上述步骤以更新 mid。当左右指针重合时,停止循环,返回 left 作为元素的位置最小下标 left_index。
  • 如果 left_index 等于数组长度或者对应的元素不等于目标元素,说明目标元素不存在数组中,直接返回 [-1, -1]。
  • 寻找元素的最后一个位置对应坐标的方法与寻找首位置坐标的思路相同,因此我们可以将寻找首位置坐标对应的代码封装成函数重用,为了区分我们调用函数时是寻找最小下标还是最大下标,我们加了一个参数 is_min

代码如下:

def searchRange(self, nums: List[int], target: int) -> List[int]:
    def search_left_or_right(nums, target, is_min):
        left = 0
        right = len(nums)

        while left < right:
            mid = (left + right) // 2
            if nums[mid] > target or (is_min and target == nums[mid]):
                right = mid
            else:
                left = mid + 1

        return left
    
    left_idx = search_left_or_right(nums, target, True)

    if left_idx == len(nums) or nums[left_idx] != target:
        return [-1, -1]
    right_idx = search_left_or_right(nums, target, False)-1

    return [left_idx, right_idx]

33. 旋转图像

题目

给定一个 n × n 的二维矩阵表示一个图像。

将图像顺时针旋转 90 度。

说明:

  • 你必须在原地旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要使用另一个矩阵来旋转图像。

在这里插入图片描述

解答

解题思路如下:

  • 采用分层来对矩阵进行旋转,将每一层都分开进行旋转,比如5*5的矩阵可以分为3层;
  • 旋转的时候,每四个矩阵块作为一组进行旋转;

第一组旋转
第二组旋转

  • 可以看出,第二组旋转比第一组旋转偏移了一格,这里我们使用add变量来记录矩阵块的偏移量,首先不考虑偏移量的时候写出左上角的坐标为(pos1,pos1),右上角的坐标为(pos1,pos2),左下角的坐标为(pos2,pos1),右下角的坐标为(pos2,pos2),这样能够写出偏移之后对应的坐标;
    在这里插入图片描述
  • 每次计算完一层之后,矩阵向内收缩一层,所以有pos1 = pos1+1,pos2 = pos2-1,终止的条件为pos1 >= pos2。
    在这里插入图片描述

代码如下:

def rotate(self, matrix: List[List[int]]) -> None:
    pos1, pos2 = 0, len(matrix)-1
    while pos1 < pos2:
        add = 0
        while add < pos2-pos1:
            #左上角为0块,右上角为1块,右下角为2块,左下角为3块
            temp = matrix[pos2-add][pos1]
            #temp ← 0
            matrix[pos2-add][pos1] = matrix[pos2][pos2-add]
            #3 ← 2
            matrix[pos2][pos2-add] = matrix[pos1+add][pos2]
            #2 ← 1
            matrix[pos1+add][pos2] = matrix[pos1][pos1+add]
            #1 ← 0
            matrix[pos1][pos1+add] = temp
            #0 ← temp
            add = add+1
        pos1 = pos1+1
        pos2 = pos2-1

34. 字母异位词分组

题目

给定一个字符串数组,将字母异位词组合在一起。字母异位词指字母相同,但排列不同的字符串。

在这里插入图片描述

解答

字典解法

解题思路如下:

  • 创建一个字典 result;
  • 对给定数组 strs 中的每一个元素排序并转换成元组,作为字典的键;
  • 如果字符串的字符相同,只是顺序不同,那么它将会被放到同一个键里;
  • 最终返回字典的值组成的列表。

代码如下:

def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
    result = {}
    for stri in strs:
        key = tuple(sorted(stri))
        result[key] = result.get(key, []) + [stri]
    return list(result.values())

35. 最大子序和

题目

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

在这里插入图片描述
解答

暴力解法

这个题的暴力解法就是从头开始遍历数据,找出以每个元素为头的连续数组的最大值,然后取所有结果的最大值。

动态规划解法

这道题的关键在于怎么确定是否应该舍弃当前的连续数组,因为如果连续数组的下一个元素即使是负数也未必需要舍弃,就像题目示例那样。

解题思路如下:

  • 将数组的第0个元素复制给变量 cur,表示当前最大连续数组的值;
  • 从数组 nums 的第一个元素开始遍历,如果当前最大值 cur 大于 0,说明 cur 加上当前遍历元素有可能超过最大值,相加并更新最大值 max_result;
  • 如果当前最大值小于 0,那么不论当前遍历元素与 0 的大小关系,cur + 当前元素 一定会比当前元素小,那么我们直接将当前元素赋值给 cur,并更新最大值 max_result。

代码如下:

def maxSubArray(self, nums: List[int]) -> int:
    cur = nums[0]
    max_result = cur
    for i in range(1, len(nums)):
        if cur > 0:
            cur += nums[i]
            max_result = max(max_result, cur)
        else:
            cur = nums[i]
            max_result = max(max_result, cur)
    return max_result

分治解法

分治法其实就是分类讨论,数组 nums 的最大子序和要么在左半边,要么在右半边,要么是穿过中间。而对于左右边的序列,情况也是一样,因此可以用递归处理。

具体来说,最大子序和主要从以下三部分子区间里元素的最大和得到:①子区间 [left, mid];②子区间 [mid + 1, right];③包含子区间 [mid , mid + 1] 的子区间。求取这三个部分的最大值即可。

代码如下:

class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        self.nums = nums
        size = len(nums)
        if not size:
            return 0
        return self.__max_sub_array(0, size - 1)

    def __max_sub_array(self, left, right):
        if left == right:
            return self.nums[left]
        mid = (left + right)//2
        return max(self.__max_sub_array(left, mid),
                    self.__max_sub_array(mid + 1, right),
                    self.__max_cross_array(left, mid, right))

    def __max_cross_array(self, left, mid, right):
        left_sum_max = 0
        start_left = mid - 1
        s1 = 0
        while start_left >= left:
            s1 += self.nums[start_left]
            left_sum_max = max(left_sum_max, s1)
            start_left -= 1

        right_sum_max = 0
        start_right = mid + 1
        s2 = 0
        while start_right <= right:
            s2 += self.nums[start_right]
            right_sum_max = max(right_sum_max, s2)
            start_right += 1
        return left_sum_max + self.nums[mid] + right_sum_max

36. 跳跃游戏系列

跳跃游戏 I

题目

给定一个非负整数数组,你最初位于数组的第一个位置。

数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个位置。

在这里插入图片描述
解答

贪婪算法

刚看到这个题目的时候,我觉得应该用回溯或者动态规划算法:从元素的倒数第二个位置开始遍历,看看它能否到达最后一个位置——如果不能到达,继续向前遍历其他元素;如果能到达,将该元素作为新数组的最后位置,看看是否有元素能够到达该元素,以此类推,直到找到第0个元素。后来整了半天还是没有写出来程序,题解里用的是贪婪算法,关键思想是:如果能到达某个位置,那一定能到达它前面的所有位置。

解题思路如下:

  • 假设当前能到达的最远位置为变量 max_position,并初始化为 0;
  • 遍历数组 nums 中的元素,如果当前能到达的最远位置大于等于当前位置 i,并且当前位置 i 加上其对应元素 jump 能够达到的位置超过 max_position,那么更新 max_position;
  • 如果当前能到达的最远位置变量 max_position 到不了当前位置 i ,直接返回 False 提前结束循环;如果当前能到达的最远位置变量 max_position 大于等于数组最远位置,直接返回 True 提前结束循环。

代码如下:

def canJump(self, nums: List[int]) -> bool:
    max_position = 0
    n = len(nums)
    for i, jump in enumerate(nums):
        if max_position < i:
            return False
        if i+jump>max_position:
            max_position = i+jump
        if max_position >= n-1:
            return True
    return False

跳跃游戏 II

题目

给定一个非负整数数组,你最初位于数组的第一个位置。

数组中的每个元素代表你在该位置可以跳跃的最大长度。

你的目标是使用最少的跳跃次数到达数组的最后一个位置。

在这里插入图片描述
说明:

  • 假设你总是可以到达数组的最后一个位置。

解答

这道题在已知可以跳跃到数组最后一个位置的基础上,要求求出来最小跳跃次数。

贪婪算法

如果我们贪心地进行正向查找,每次找到可到达的最远位置,就可以在线性时间内得到最少的跳跃次数。

例如,对于数组 [2,3,1,2,4,2,3],初始位置是下标 0,从下标 0 出发,最远可到达下标 2。下标 0 可到达的位置中,下标 1 的值是 3,从下标 1 出发可以达到更远的位置,因此第一步到达下标 1。

从下标 1 出发,最远可到达下标 4。下标 1 可到达的位置中,下标 4 的值是 4 ,从下标 4 出发可以达到更远的位置,因此第二步到达下标 4。
在这里插入图片描述
在具体的实现中,我们维护当前能够到达的最大下标位置,记为边界。我们从左到右遍历数组,到达边界时,更新边界并将跳跃次数增加 1。

在遍历数组时,我们不访问最后一个元素,这是因为在访问最后一个元素之前,我们的边界一定大于等于最后一个位置(假设总是可以到达数组的最后一个位置),否则就无法跳到最后一个位置了。如果访问最后一个元素,在边界正好为最后一个位置的情况下,我们会增加一次「不必要的跳跃次数」,因此我们不必访问最后一个元素。

代码如下:

def jump(self, nums: List[int]) -> int:
    n = len(nums)
    maxPos, end, step = 0, 0, 0
    for i in range(n-1):
	    maxPos = max(maxPos, i + nums[i])
        if i == end:
            end = maxPos
            step += 1
   return step

跳跃游戏 I + II

我们可以把以上两个跳跃游戏的题目进行组合:如果能够跳跃到最后一个位置,返回最少跳跃次数;如果跳跃不到最后一个位置,返回 False。

对跳跃游戏 II 的思路进行了更改:

  • 遍历数组的每一个元素(包括最后一个元素),如果当前遍历位置已经超过了所能到达的最远位置(maxPos),那么说明不能到达最后一个数组位置,返回False;
  • 我们新增加了一个变量 last_end,该变量用于存放上一个边界。如果遍历结束后,上一个边界为数组最后位置,说明其实上一步就已经到达了数组的最后位置,平白无故多跳了一步,因此返回 step-1,否则返回 step。

代码如下:

def canJump(self, nums: List[int]) -> bool:
    n = len(nums)
    last_end = 0
    maxPos, end, step = 0, 0, 0
    for i in range(n):
        if maxPos >= i:
            maxPos = max(maxPos, i + nums[i])
            if i == end:
                last_end = end
                end = maxPos
                step += 1
        else:
            break
    else:
        if last_end == n-1:
            return step-1
        else:
            return step
    return False

37. N 皇后

题目

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 ‘Q’ 和 ‘.’ 分别代表了皇后和空位。

在这里插入图片描述

提示:

  • 1 <= n <= 9
  • 皇后彼此不能相互攻击,也就是说:任何两个皇后都不能处于同一条横行、纵行或斜线上。

解答

递归解法

N皇后问题是一个经典的问题,可以递归求解。

回溯算法的求解过程可以理解成一棵决策树的遍历过程,类似于穷举所有情况。可以用回溯算法解决的问题,基本都可以用决策树表示出来。

在N皇后问题中,这颗决策树的每一行节点对应棋盘上的行,每行节点中的节点位置对应棋盘上的列,这样可以构建出一颗决策树。此时,回溯法求解N皇后问题,就变成求解决策树中所有可能的从根节点到叶节点的路径。需要注意的是,N皇后问题中各皇后不能冲突,因此在遍历树的路径时,设定一些条件即可:

  • 皇后不能在同一行:backtrack函数中,依次从上到下处理不同的行,即可满足这一条件;
  • 皇后不能在同一列:用col_selected列表记录曾经选择过的列,下次选择时,如果要选择的列没有被选择过,才可以加入路径;
  • 皇后所在的主对角线上不能有其他皇后:若令其行和列的索引分别为i和j,则同一主对角线上元素的行列索引之差等于同一常数i-j;
  • 皇后所在的次对角线上不能有其他皇后:若令其行和列的索引分别为i和j,则同一次对角线上元素的行列索引之和等于同一常数i+j。

利用上述规则,即可在选择皇后位置时做出决策。

代码如下:

def solveNQueens(self, n: int) -> List[List[str]]:
    def backtrack(path, i, col_selected, z_diag, f_diag):
        if i == n:
            res.append(path)
            return 
        for j in range(n):
            if j not in col_selected and i-j not in z_diag and i+j not in f_diag:
                backtrack(path+[s[:j]+'Q'+s[j+1:]], i+1, col_selected+[j], z_diag|{i-j}, f_diag|{i+j})
        
    res = []
    s = '.' * n
    backtrack([], 0, [], set(), set())
    return res

38. 子集

题目

给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。

说明:解集不能包含重复的子集
在这里插入图片描述

解答

迭代算法

迭代算法的思路很简单,遍历给定数组 nums 的所有元素,每遍历一个元素就把存放当前结果的数组 result 中的所有子集加上该元素组成新的子集,并将这些新子集放入 result 中。

代码如下:

def subsets(self, nums: List[int]) -> List[List[int]]:
    result = [[]]
    for i in nums:
        result += [[i] + sub for sub in result]
    return result

递归算法

这道题的递归解法不如迭代算法简单易懂,定义一个函数 helper,有两个参数,分别是将要遍历的元素位置和当前子集,不断回溯,直到遍历完所有元素。

代码如下:

def subsets(self, nums: List[int]) -> List[List[int]]:
    result = []
    n = len(nums)
    def helper(i, tmp):
        result.append(tmp)
        for j in range(i, n):
            helper(j + 1,tmp + [nums[j]])
    helper(0, [])
    return result

39. 最长连续序列

题目

给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。

在这里插入图片描述

解答

哈希表解法

解题思路如下:

  • 建立一个空字典,然后遍历数组中的元素;
  • 当数组中的元素不在字典中时,分别取该元素加减一后的数值对应的字典结果,然后将这两个结果值与1相加得到的结果就是该元素所在的连续区间对应的长度,如果该长度超过了最大长度,那么更新最大长度;
  • 更新连续区间左右端点所对应的长度值;
  • 如果数组中的元素已经在字典中,那么遍历下一个元素。

代码如下:

def longestConsecutive(self, nums):
    hash_dict = {}
    
    max_length = 0
    for num in nums:
        if num not in hash_dict:
            left = hash_dict.get(num - 1, 0)
            right = hash_dict.get(num + 1, 0)
            
            cur_length = 1 + left + right
            if cur_length > max_length:
                max_length = cur_length
            
            hash_dict[num] = cur_length
            hash_dict[num - left] = cur_length
            hash_dict[num + right] = cur_length
    return max_length

40. 除自身以外数组的乘积

题目

给你一个长度为 n 的整数数组 nums,其中 n > 1,返回输出数组 output ,其中 output[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积。

在这里插入图片描述
说明:

  • 请不要使用除法,且在 O(n) 时间复杂度内完成此题。

解答

暴力解法

这道题最容易想到的是暴力解法,创建结果数组 result,初始化长度等于 nums 的长度,初始化元素值均为1。

利用枚举函数遍历数组 nums 中的元素,然后内循环遍历结果数组 result 的索引,当外循环的索引和内循环的索引不相等时,将 nums 对应位置的元素与 result 对应位置的元素相乘并更新 result 对应位置的元素。

代码如下:

def productExceptSelf(self, nums: List[int]) -> List[int]:
    length = len(nums)
    result = [1]*length
    for index, element in enumerate(nums):
        for i in range(length):
            if i != index:
                result[i] *= element
    return result

矩阵解法

将结果数组 res 表示成乘积形式,不同的 n 组成每行内容,形成一个矩阵,可以发现矩阵主对角线全部为 1 (当前数字不相乘,等价为乘 1),因此,我们分别计算矩阵的下三角和上三角,并且在计算过程中储存过程值,最终可以在遍历 2 遍 nums 下完成结果计算。

在这里插入图片描述
代码如下:

def productExceptSelf(self, nums: List[int]) -> List[int]:
    res, p, q = [1], 1, 1
    for i in range(len(nums)-1): #下三角
        p *= nums[i]
        res.append(p)
    for i in range(len(nums)-1, 0, -1): #上三角
        q *= nums[i]
        res[i-1] *= q
    return res

41. 乘积最大子数组

题目

给你一个整数数组 nums ,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积。

在这里插入图片描述

解答

动态规划解法

解题思路如下:

  • 记录数组前 i 个元素的最小值、最大值,同时每遍历一个元素就更新最大值结果。
  • 为什么这里需要考虑最小值呢?因为我们求的是元素的乘积,当元素出现负数时,那么结果有可能从最大值变成最小值,同理再次出现负数时,又有可能从最小值变为最大值,因此我们在这里同时考虑了最小值和最大值。
  • 对于元素为0的情况,我们不需要单独考虑,因为当相乘不管最大值和最小值,都会置0,相当于从元素0处重新开始计算当前最大值和最小值。

代码来自力扣题解,具体如下:

def maxProduct(self, nums: List[int]) -> int:
    if not nums: 
        return 
    res = nums[0]
    pre_max = nums[0]
    pre_min = nums[0]
    for num in nums[1:]:
        cur_max = max(pre_max * num, pre_min * num, num)
        cur_min = min(pre_max * num, pre_min * num, num)
        res = max(res, cur_max)
        pre_max = cur_max
        pre_min = cur_min
    return res

42. 课程表

题目

你这个学期必须选修 numCourse 门课程,记为 0 到 numCourse-1 。

在选修某些课程之前需要一些先修课程。 例如,想要学习课程 0 ,你需要先完成课程 1 ,我们用一个匹配来表示他们:[0,1]

给定课程总量以及它们的先决条件,请你判断是否可能完成所有课程的学习?
在这里插入图片描述

解答

深度优先遍历解法(DFS)

本题可简化为: 课程安排图是否是有向无环图(DAG)。即课程间规定了前置条件,但不能构成任何环路,否则课程前置条件将不成立。

解题思路如下:

①借助一个标志列表 flags,用于判断每个节点 i (课程)的状态:

  • 未被 DFS 访问:i == 0;
  • 已被其他节点启动的 DFS 访问:i == -1;
  • 已被当前节点启动的 DFS 访问:i == 1。

②对 numCourses 个节点依次执行 DFS,判断每个节点起步 DFS 是否存在环,若存在环直接返回 False;

③DFS 流程如下:

  • 终止条件:
    当 flag[i] == -1,说明当前访问节点已被其他节点启动的 DFS 访问过,无需再重复搜索,直接返回 True。
    当 flag[i] == 1,说明在本轮 DFS 搜索中节点 i 被第 2 次访问,即课程安排图有环 ,直接返回 False。
  • 将当前访问节点 i 对应 flag[i] 置 1,即标记其被本轮 DFS 访问过;
  • 递归访问当前节点 i 的所有邻接节点 j,当发现环直接返回 False;
  • 当前节点所有邻接节点已被遍历,并没有发现环,则将当前节点 flag 置为 -1 并返回 True。

④若整个图 DFS 结束并未发现环,返回 True。

代码如下:

def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
    def dfs(i, adjacency, flags):
        if flags[i] == -1: return True
        if flags[i] == 1: return False
        flags[i] = 1
        for j in adjacency[i]:
            if not dfs(j, adjacency, flags): return False
        flags[i] = -1
        return True

    adjacency = [[] for _ in range(numCourses)]
    flags = [0 for _ in range(numCourses)]
    for cur, pre in prerequisites:
        adjacency[pre].append(cur)
    for i in range(numCourses):
        if not dfs(i, adjacency, flags): return False
    return True

43. 单词搜索

题目

给定一个二维网格和一个单词,找出该单词是否存在于网格中。

单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

在这里插入图片描述
提示:board 和 word 中只包含大写和小写英文字母。

解答

递归解法

解题思路如下:

  • 创建一个形状和原数组相同的二维数组 marked,并初始化里面的元素全部为 False;
  • 定义一个函数进行回溯,如果当前位置的元素和正在寻找的元素相同,将 marked 相应位置置为 True,表示该位置已经被搜索过了,然后利用回溯算法继续匹配 word 的下一个元素;
  • 由于下一个元素有可能向上下左右移动,因此我们需要判断移动之后 xy 有没有超出边界,是否当前路径是否已经搜索过该位置,然后再去判断是否匹配;
  • 如果上下左右都找不到满足要求的元素时,先将 marked 的相应位置重置为 False,然后返回上一级重新匹配;
  • 回溯算法结束的条件是:匹配到了最后一个元素,直接返回最后一个元素匹配的结果。

代码如下:

def exist(self, board: List[List[str]], word: str) -> bool:
    def search_word(word, index, start_x, start_y, m, n):
        if index == len(word) - 1:  # 递归终止条件
            return board[start_x][start_y] == word[index]

        # 中间匹配了,再进行下一步搜索
        if board[start_x][start_y] == word[index]:
            # 先占住这个位置,搜索不成功的话,要释放掉
            marked[start_x][start_y] = True
            for direction in directions:
                new_x = start_x + direction[0]
                new_y = start_y + direction[1]
                # 注意:如果这一次 search word 成功的话,就返回
                if 0 <= new_x < m and 0 <= new_y < n and not marked[new_x][new_y] \
                        and search_word(word, index+1, new_x, new_y, m, n):
                    return True
            marked[start_x][start_y] = False
        return False

    m = len(board)
    n = len(board[0])
    directions = [(0, -1), (-1, 0), (0, 1), (1, 0)]
    marked = [[False for _ in range(n)] for _ in range(m)]

    for i in range(m):
        for j in range(n):
            # 对每一个格子都从头开始搜索
            if search_word(word, 0, i, j, m, n):
                return True
    return False

44. 多数元素

题目

给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数大于 n/2 的元素。

你可以假设数组是非空的,并且给定的数组总是存在多数元素。

在这里插入图片描述

解答

哈希表解法

建立一个字典,字典的键为数组元素,对应的值是该元素在数组中出现的次数,然后遍历字典中的值,如果值大于数组长度的一半,则返回对应的键。

代码如下:

def majorityElement(self, nums: List[int]) -> int:
    dict_count = {}
    for i in nums:
        dict_count[i] = dict_count.get(i, 0) + 1
    n = len(nums)
    for key, value in dict_count.items():
        if value > n/2:
            return key

排序解法

看到题解中一种超简单的解法:既然“多数元素是指在数组中出现次数大于 ⌊ n/2 ⌋ 的元素”,那我们对数组排序之后取中间的数,该数一定是我们要求的多数元素。

代码如下:

def majorityElement(self, nums: List[int]) -> int:
    return sorted(nums)[len(nums) // 2]

摩斯投票法

摩尔投票法,也被称作「多数投票法」。算法解决的问题是:如何在任意多的候选人中(选票无序),选出获得票数最多(必须超过得票数一半)的那个。

该算法可以分为两个阶段:①对抗阶段:分属两个候选人的票数进行两两对抗抵消;②计数阶段:计算对抗结果中最后留下的候选人票数是否有效。

根据上述的算法思想,我们遍历投票数组,将当前票数最多的候选人与其获得的(抵消后)票数分别存储在 major 与 count 中。

当我们遍历下一个选票时,判断当前 count 是否为零:

  • 若 count == 0,代表当前 major 空缺,直接将当前候选人赋值给 major,并令 count++;
  • 若 count != 0,代表当前 major 的票数未被完全抵消,因此令 count–,即使用当前候选人的票数抵消 major 的票数

代码如下:

def majorityElement(self, nums: List[int]) -> int:
    major = 0
    count = 0
    for n in nums:
        if count == 0:
            major = n
        if n == major:
            count += 1
        else:
            count -= 1
    return major

45. 最小栈

题目

设计一个支持 push ,pop ,top 操作,并能在常数时间内检索到最小元素的栈:

  • push(x) —— 将元素 x 推入栈中。
  • pop() —— 删除栈顶的元素。
  • top() —— 获取栈顶元素。
  • getMin() —— 检索栈中的最小元素。
    在这里插入图片描述
    提示:
  • pop、top 和 getMin 操作总是在非空栈上调用。

解答

这道题用的是“备用”栈解法,即除了原来存放数组的栈(主栈)以外,我们还定义了一个最小栈用来存放当前数组的最小值。

解题思路如下:

  • 当第一个元素放入主栈时,将其也放入最小栈,表示当前主栈最小值就是该元素;
  • 以后再将元素放入主栈时,将其与最小栈最后一个值进行比较,如果该元素不大于最小栈最后一个元素,将该元素也顺便压入最小栈;
  • 删除栈顶元素时,如果该元素与最小栈最后一个元素相同,删除最小栈最后一个元素,确保同时更新当前主栈最小值;
  • 获取栈顶元素——即获取主栈最后一个元素,检索栈中的最小元素——即获取最小栈最后一个元素。

代码如下:

class MinStack:
    def __init__(self):
        self.main_stack = []
        self.min_stack = []

    def push(self, x: int) -> None:
        self.main_stack.append(x)
        if (not self.min_stack) or (x <= self.min_stack[-1]):
            self.min_stack.append(x)

    def pop(self) -> None:
        x = self.main_stack.pop()
        if x == self.min_stack[-1]:
            self.min_stack.pop()

    def top(self) -> int:
        return self.main_stack[-1]

    def getMin(self) -> int:
        return self.min_stack[-1]

46. LRU 缓存机制

题目

运用你所掌握的数据结构,设计和实现一个 LRU (最近最少使用) 缓存机制 。

实现 LRUCache 类:

  • LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1 。
  • void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。

进阶:你是否可以在 O(1) 时间复杂度内完成这两种操作?

在这里插入图片描述

解答

哈希+双向链表解法

这道题在题目中指出了数据形式为键值类型,而且题目中还提到了在 O(1)时间复杂度完成,那我们自然而然地就会想到哈希表,将数据存入哈希表,每次存取的时间复杂度都是 O(1),但是字典是无序的,没办法做到像题目要求的那样存取方式,因此我们在这里考虑添加一个双向链表来满足顺序要求。

解题思路如下:

  • 构造一个双向链表的类,该类使节点具有键、值、前向节点和后向节点等属性;
  • 对于给定的类 LRUCache,我们在其构造函数中添加容量、缓存字典 、链表的头尾等属性;
  • 除了题目中给定的 get、put 等函数外,我额外构造了三个函数,分别是 remove_node(从双向链表中移除节点)、add_node(向双向链表尾部添加节点)以及 refresh_node(更新节点:先将节点从链表中移除,再将其添加到链表的尾部);
  • 对于 get 函数,我们根据 key 从字典中取值(其实是一个节点),如果没有取到,那我们直接返回 -1,如果取到了,那么更新该节点,同时返回该节点的值;
  • 对于 put 函数,我们同样先根据 key 从字典中取节点,如果该节点不存在,同时容量满了,那我们就移除头节点,然后从字典中移除对应键值,如果容量未满,则不对头节点进行移除,只向字典中添加节点;如果该节点存在,那么我们更新节点对应的值,并更新节点。

代码如下:

class Node:
    def __init__(self, key, value):
        self.key = key
        self.value = value
        self.pre = None
        self.nex = None

class LRUCache:
    def __init__(self, capacity: int):
        self.capacity = capacity
        self.hash_dict = {}
        self.head = None
        self.end = None

    def get(self, key: int) -> int:
        node = self.hash_dict.get(key)
        if not node:
            return -1
        self.refresh_node(node)
        return node.value

    def put(self, key: int, value: int) -> None:
        node = self.hash_dict.get(key)
        if not node:
            if len(self.hash_dict) >= self.capacity:
                old_key = self.remove_node(self.head)
                self.hash_dict.pop(old_key)
            node = Node(key, value)
            self.add_node(node)
            self.hash_dict[key] = node
        else:
            node.value = value
            self.refresh_node(node)

    def refresh_node(self, node):
        if self.end == node:
            return
        self.remove_node(node)
        self.add_node(node)
    
    def remove_node(self, node):
        if self.head == node and self.end == node:
            self.head = None
            self.end = None
        elif self.head == node:
            self.head = node.nex
            self.head.pre = None
        elif self.end == node:
            self.end = self.end.pre
            self.end.nex = None
        else:
            node.pre.nex = node.nex
            node.nex.pre = node.pre
        return node.key

    def add_node(self, node):
        if self.end:
            self.end.nex = node
            node.pre = self.end
            node.nex = None
        self.end = node
        if not self.head:
            self.head = node

47. 滑动窗口最大值

题目

给定一个数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

在这里插入图片描述
在这里插入图片描述

解答

暴力解法

这道题的暴力解法挺容易想出来的,从头开始,每次取 k 长度的子数组,然后直接计算该子数组的最大值,并将其放入结果数组。

代码如下:

def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
    result = []
    for i in range(len(nums)-k+1):
        result.append(max(nums[i:i+k]))
    return result

动态规划解法

解题思路如下:

  • 将输入数组不重叠地分割成有 k 个元素的块,若 n % k != 0,则最后一块的元素个数可能更少;
    在这里插入图片描述
  • 开头元素为 i ,结尾元素为 j 的当前滑动窗口可能在一个块内,也可能在两个块中;
    在这里插入图片描述
  • 建立数组 left, 其中 left[j] 是从块的开始到下标 j 最大的元素,方向 左->右;
    在这里插入图片描述
  • 建立数组 right,其中 right[j] 是从块的结尾到下标 j 最大的元素,方向 右->左;
    在这里插入图片描述
  • 考虑从下标 i 到下标 j的滑动窗口。 根据定义,right[i] 是左侧块内的最大元素, left[j] 是右侧块内的最大元素,因此滑动窗口中的最大元素为 max(right[i], left[j])。
    在这里插入图片描述

代码如下:

def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
    n = len(nums)
    if n * k == 0:
        return []
    if k == 1:
        return nums
    
    left = [0] * n
    left[0] = nums[0]
    right = [0] * n
    right[n-1] = nums[n-1]
    for i in range(1, n):
        # from left to right
        if i % k == 0:
            # block start
            left[i] = nums[i]
        else:
            left[i] = max(left[i - 1], nums[i])
        # from right to left
        j = n - i - 1
        if (j + 1) % k == 0:
            # block end
            right[j] = nums[j]
        else:
            right[j] = max(right[j + 1], nums[j])
    
    output = []
    for i in range(n - k + 1):
        output.append(max(left[i + k - 1], right[i]))
        
    return output

48. 完全平方数

题目

给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, …)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。

在这里插入图片描述

解答

广度优先遍历BFS解法

在这里插入图片描述
这道题用到的是广度优先遍历解法。因为是广度优先遍历,顺序遍历每一行,所以当节点差出现0时,此时一定是最短的路径:

  • 如绿色所示,若此时节点为0,表示根节点可以由路径上的平方数 {1,1,9} 构成,返回此时的路径长度3,后续不再执行。
  • 如红色所示,若节点值在之前已经出现,则不需要再次重复计算。

解题思路如下:

  • 初始化队列 queue=[n],空集合 visited={},路径长度step=0;
  • 执行如下大循环:
    • step += 1:因为每循环一次,意味着一层中的节点已经遍历完,所以路径长度需要+1。
    • 遍历当前层的所有节点,对于每一节点,将其减掉比其小的所有平方数(每次只减一个),遍历的平方基数的区间为 [1, int(tmp1/2)]。
    • 如果相减后的结果等于 0,直接返回当前的路径长度;否则判断该结果是否已经出现在集合 visited 中,如果未出现,将该结果加入队列以及集合中,如果出现,不进行操作。之后进行下一次相减。
  • 当前层遍历结束后,判断队列是否为空,如果不为空,再次执行上述循环。

代码如下:

def numSquares(self, n: int) -> int:
    queue = [n]
    step = 0
    visited = set()
    while queue:
        step += 1
        l = len(queue)
        for _ in range(l):
            tmp = queue.pop(0)
            for i in range(1, int(tmp**0.5)+1):
                x = tmp-i**2
                if x == 0:
                    return step
                if x not in visited:
                    queue.append(x)
                    visited.add(x)

49. 零钱兑换

题目

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

你可以认为每种硬币的数量是无限的。
在这里插入图片描述

解答

这道题和“完全平方数”一题很相似,都可以用广度优先遍历解法来做。

BFS 解法

广度优先遍历是顺序遍历每一行,所以当节点差出现0时,此时一定是最短的路径。

解题思路如下:

  • 如果要凑的总金额为0,直接返回0即可;
  • 初始化队列 queue=[amount],空集合 visited={},路径长度step=0;
  • 执行如下大循环:
    • step += 1:因为每循环一次,意味着一层中的节点已经遍历完,所以路径长度需要+1。
    • 遍历当前层的所有节点,对于每一节点,将其减掉数组 coins 中的元素(每次只减一个)。
    • 如果相减后的结果等于 0,直接返回当前的路径长度;否则判断该结果是否大于0且是否已经出现在集合 visited 中,如果未出现,将该结果加入队列以及集合中,如果出现,不进行操作。之后进行下一次相减。
  • 当前层遍历结束后,判断队列是否为空,如果不为空,再次执行上述循环,如果为空说明没有任何一种硬币组合能组成总金额,直接返回-1。

代码如下:

def coinChange(self, coins: List[int], amount: int) -> int:
    if not amount:
        return 0
    queue = [amount]
    step = 0
    visited = set()
    while queue:
        step += 1
        l = len(queue)
        for _ in range(l):
            tmp = queue.pop(0)
            for i in coins:
                x = tmp-i
                if x == 0:
                    return step
                if x > 0 and x not in visited:
                    queue.append(x)
                    visited.add(x)
    return -1

50. 搜索二维矩阵系列

搜索二维矩阵 I

题目

编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性:

  • 每行的元素从左到右升序排列。
  • 每列的元素从上到下升序排列。

在这里插入图片描述
在这里插入图片描述

解答

广度优先搜索

观察这个矩阵,我们可以发现:矩阵左下角的值永远比该列的其他值大,永远比该行的其他值小。因此,我们有以下解题思路:

  • 初始化一个指向矩阵左下角的指针;
  • 直到找到目标并返回 True 为止,执行以下操作:如果当前指向的值大于目标值,则可以 “向上” 移动一行;如果当前指向的值小于目标值,则向右移动一列。

代码如下:

def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
    if not len(matrix) or not len(matrix[0]):
        return False

    height = len(matrix)
    width = len(matrix[0])

    row = height-1
    col = 0

    while col < width and row >= 0:
        if matrix[row][col] > target:
            row -= 1
        elif matrix[row][col] < target:
            col += 1
        else:
            return True
    
    return False

搜索二维矩阵 II

题目

编写一个高效的算法来搜索 m x n 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性:

  • 每行中的整数从左到右按升序排列。
  • 每行的第一个整数大于前一行的最后一个整数。
    在这里插入图片描述
    在这里插入图片描述
    解答

这道题完全可以用上一题的方法解出来,而且代码都不需要更改。

广度优先搜索

见上题

二分查找

与上一道题不同的是,这道题说了,每行的第一个整数大于前一行的最后一个整数。因此我们如果把二维数组平铺成一维数组的话,得到的是一个有序数组。因此我们可以用二分法来查找给定数组中是否包含目标值。

在这里插入图片描述
解题思路如下:

  • 初始化左右指针:left = 0 和 right = m x n - 1。
  • While left <= right :
    • 选取虚数组最中间的序号作为中间序号: pivot_idx = (left + right) // 2。
    • 该序号对应于原矩阵中的 row = pivot_idx // n行 col = pivot_idx % n 列, 由此可以拿到中间元素pivot_element。该元素将虚数组分为两部分。
    • 比较 pivot_element 与 target 以确定在哪一部分进行进一步查找

代码如下:

def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
    m = len(matrix)
    n = len(matrix[0])

    if not (m or n):
        return False       
    #二分查找
    left, right = 0, m * n - 1
    while left <= right:
            pivot_idx = (left + right) // 2
            pivot_element = matrix[pivot_idx // n][pivot_idx % n]
            if target == pivot_element:
                return True
            else:
                if target < pivot_element:
                    right = pivot_idx - 1
                else:
                    left = pivot_idx + 1
    return False

51. 最长递增子序列

题目

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

在这里插入图片描述

解答

动态规划解法

解题思路如下:

  • 初始化一个长度等于给定数组 nums 且值全为 1 的数组 dp,该数组中的元素 dp[i] 表示 nums 前 i 个数字的最长子序列长度,dp[i] 所有元素置 1,含义是每个元素都至少可以单独成为子序列,此时长度都为 1;
  • 外循环用指针 i 来遍历数组,内循环指针 j 的遍历范围为:[0, i) :
    • 当 nums[i] > nums[j] 时: nums[i] 可以接在 nums[j] 之后,此情况下前 i 个数字的最长上升子序列的长度(即 dp[i] 位置的元素值)为 max(dp[j] + 1, dp[i]) ;
    • 当 nums[i] <= nums[j] 时: nums[i] 无法接在 nums[j] 之后,此情况上升子序列不成立,跳过
  • 最后返回数组 dp 中的最大值。

代码如下:

def lengthOfLIS(self, nums: List[int]) -> int:
    if not nums: return 0
    dp = [1] * len(nums)
    for i in range(1, len(nums)):
        for j in range(i):
            if nums[j] < nums[i]:
                dp[i] = max(dp[i], dp[j] + 1)
    return max(dp)

52. 目标和

题目

给定一个非负整数数组,a1, a2, …, an, 和一个目标数,S。现在你有两个符号 + 和 -。对于数组中的任意一个整数,你都可以从 + 或 -中选择一个符号添加在前面。

返回可以使最终数组和为目标数 S 的所有添加符号的方法数。
在这里插入图片描述

解答

递归解法

这道题我用的是递归解法,对于给定的数组 nums 中的每个元素,要么加要么减,这里我写了一个递归函数 calc_sum,它包含两个参数:一个是当前遍历过的元素所得到的总和 cur_sum,另一个是当前未遍历过的元素组成的顺序列表 cur_list。

一个 calc_sum 函数会调用两次递归:一次是将元素总和 cur_sum 加上顺序列表 cur_list 的首位元素作为下一递归的第一个参数,另一次是将元素总和 cur_sum 减去顺序列表 cur_list 的首位元素作为下一递归的第一个参数,下一递归的第二个参数均为当前 cur_list 取出首位元素后剩余的列表。当当前列表 cur_list 为空时,停止递归并将当前元素总和(即列表 nums 的元素组成的总和)放入结果数组中。

最终返回结果数组中等于目标和的元素个数。

代码如下:

def findTargetSumWays(self, nums: List[int], S: int) -> int:
    def calc_sum(cur_sum, cur_list):
        if not cur_list:
            result.append(cur_sum)
            return
        calc_sum(cur_sum+cur_list[0], cur_list[1:])
        calc_sum(cur_sum-cur_list[0], cur_list[1:])
        
    result = []
    calc_sum(0, nums)
    return result.count(S)

动态规划解法

这题解中提出了一种动态规划的解法:

  • 我们用 dp[i][j] 表示用数组中的前 i 个元素,组成和为 j 的方案数。、
  • 考虑第 i 个数 nums[i],它可以被添加 + 或 - ,那么从dp[i-1][m] —> dp[i][j] 可以在 dp[i-1][m]的基础上加上 nums[i] 或者减去 nums[i],因此状态转移方程如下:dp[i][j] = dp[i-1][j-nums[i]] + dp[i-1][j + nums[i]]

解题思路如下:

  • 首先获取数组长度以及数组和的最大值。
  • 定义和初始化dp数组。因为该数组所能组成的值的区间为[min_sum, max_sum],即一共有 max_sum - min_sum + 1 种值,但是为了方便,可以直接定义为 dict 的形式,这样就可以一直增加key而不需要去计算 dp 数组第二维的长度,同时不用管下标为负数的情况,直接将下标转化为 dict 中的 key,对于不存在的key,也会默认是0。
  • 初始化 dp[0],程序中之所以dp[0][-nums[0]] 使用 += 1 而不是直接赋值1,是为了处理nums[0]就等于0的情况,如果nums[0]等于0, 那么dp[0][0] 等于 2 而不是 1。
  • 使用两层循环遍历nums数组,更行dp数组的值:外循环表示遍历到数组的哪个元素了,内循环用来更新总和(从最小值到最大值)的方案数。

代码如下:

import collections
def findTargetSumWays(self, nums: List[int], S: int) -> int:
    if sum(nums) < S:
        return 0
    array_length = len(nums)
    max_sum = sum(nums)
    dp = [collections.defaultdict(int) for _ in range(len(nums))]
    dp[0][nums[0]] = 1
    dp[0][-nums[0]] += 1
    for i in range(1, array_length):
        for j in range(-max_sum, max_sum+1):
            dp[i][j] = dp[i-1][j+nums[i]] + dp[i-1][j-nums[i]]
    return dp[-1][S]

01背包解法

原问题是给定一些数字,加加减减,使得它们等于targert。例如,1 - 2 + 3 - 4 + 5 = target(3)。如果我们把加的和减的结合在一起,可以写成:

(1+3+5)  +  (-2-4) = target(3)
-------     ------
 -> 正数    -> 负数

所以,我们可以将原问题转化为: 找到nums一个正子集和一个负子集,使得总和等于target,统计这种可能性的总数。

我们假设P是正子集,N是负子集。让我们看看如何将其转换为子集求和问题:

	                 sum(P) - sum(N) = target
    	           (两边同时加上sum(P)+sum(N))
	sum(P) + sum(N) + sum(P) - sum(N) = target + sum(P) + sum(N)
    	        (因为 sum(P) + sum(N) = sum(nums))
        	               2 * sum(P) = target + sum(nums)

因此,原来的问题已转化为一个求子集的和问题: 找到nums的一个子集 P,使得:
在这里插入图片描述
根据公式,若 target + sum(nums) 不是偶数,就不存在答案,即返回0个可能解。

解题思路如下:

  • 开辟一个长度为P+1的数组,命名为dp,dp的第x项,代表组合成数字x有多少方法。
  • 初始化dp[0]=1,表示存在一种正子集为空的方案,此时的总和就是0,而这种方案通过遍历数组元素找不出来,所以我们初始化的时候表示出来。
  • 设计两层循环:外循环遍历 nums,遍历的数记作num;内循环逆序遍历从P到num,遍历的数记作 j,更新dp[j] = dp[j - num] + dp[j]。
  • 所以最后返回的就是dp[-1],代表组合成P的方法有多少种。

代码如下:

def findTargetSumWays(self, nums: List[int], S: int) -> int:
    if sum(nums) < S or (sum(nums) + S) % 2 == 1: 
        return 0
    P = (sum(nums) + S) // 2
    dp = [1] + [0 for _ in range(P)]
    for num in nums:
        for j in range(P,num-1,-1):
            dp[j] += dp[j-num]
    return dp[-1]

53. 除法求值

题目

给出方程式 A / B = k, 其中 A 和 B 均为用字符串表示的变量, k 是一个浮点型数字。根据已知方程式求解问题,并返回计算结果。如果结果不存在,则返回 -1.0。

输入总是有效的。你可以假设除法运算中不会出现除数为 0 的情况,且不存在任何矛盾的结果。

在这里插入图片描述
在这里插入图片描述

解答

构造图+DFS解法

这道题用到以前从未用到的图算法。

解题思路如下:

  • 先利用给定的 equations 构造图:对每个 equation 如 “a/b=v” 构造 a 到 b 的带权 v 的有向边和 b 到 a 的带权 1/v 的有向边;
  • 之后对于每个 query,只需要进行 dfs 并将路径上的边权重叠乘就是结果了,如果路径不可达则结果为-1。

代码如下:

def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
    # 构造图,equations的第一项除以第二项等于value里的对应值,第二项除以第一项等于其倒数
    graph = {}
    for (x, y), v in zip(equations, values):
        if x in graph:
            graph[x][y] = v
        else:
            graph[x] = {y: v}
        if y in graph:
            graph[y][x] = 1/v
        else:
            graph[y] = {x: 1/v}
    
    # dfs找寻从s到t的路径并返回结果叠乘后的边权重即结果
    def dfs(s, t):
        if s not in graph:
            return -1
        if t == s:
            return 1
        for node in graph[s].keys():
            if node == t:
                return graph[s][node]
            elif node not in visited:
                visited.add(node)  # 添加到已访问避免重复遍历
                v = dfs(node, t)
                if v != -1:
                    return graph[s][node]*v
        return -1

    # 逐个计算query的值
    res = []
    for qs, qt in queries:
        visited = set()
        visited.add(qs)
        res.append(dfs(qs, qt))
    return res

54. 移动零

题目

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。
在这里插入图片描述
说明:

  • 必须在原数组上操作,不能拷贝额外的数组。
  • 尽量减少操作次数。

解答

双指针解法

创建两个指针 element 和 point,初始化 point 为 0,对数组进行两次遍历:

  • 第一次遍历:用指针 element 遍历数组中的元素,每遇到一个非0元素就将其放到 point 指向的数组位置,然后 point 右移一位;
  • 第一次遍历完后,point 指针指向结果 0 元素的起始位置;
  • 第二次遍历:起始位置就从数组 point 位置开始到结束,将剩下的这段区域内的元素全部置为0。

代码如下:

def moveZeroes(self, nums: List[int]) -> None:
    point = 0
    for element in nums:
        if element:
            nums[point] = element
            point += 1
    for i in range(point, len(nums)):
        nums[i] = 0

快速排序

这道题我们还可以只进行一次遍历来解答。我们参考了快速排序的思想:快速排序首先要确定一个待分割的元素做中间点 x,然后把所有小于等于 x 的元素放到 x的左边,大于 x 的元素放到其右边。

这里我们用 0 当做这个中间点,把不等于 0 (注意,题目没说不能有负数)的放到中间点的左边,等于0的放到其右边。使用两个指针 point 和 index,只要指针 index 对应的元素 element 不为空,那我们就交换 nums[index] 和 nums[point]。

代码如下:

def moveZeroes(self, nums: List[int]) -> None:
    point = 0
    for index, element in enumerate(nums):
        if element:
            nums[point], nums[index] = nums[index], nums[point]
            point += 1

55. 比特位计数

题目

给定一个非负整数 num。对于 0 ≤ i ≤ num 范围中的每个数字 i ,计算其二进制数中的 1 的数目并将它们作为数组返回。
在这里插入图片描述

解答

位运算

解题思路:

  • 迭代统计每个数二进制位为1的个数;
  • 通过 n &= n-1 方式依次消掉二进制最低位的1,累加 count 直到 n 为 0。

代码如下:

def countBits(self, num: int) -> List[int]:
    def bin_count(n):
        count=0
        while n:
            count += 1
            n &= n-1
        return count

    res=[]
    for i in range(num+1):
        res.append(bin_count(i))
    return res

哈希表

解题思路:

  • 哈希表 dic 记录每个数对应的比特位为1的个数;
  • 如果某个数 i 与 i-1 相与后得到的数为0,说明该数的二进制表示中只有一位为1,其余为0,也说明接下来的几个数中最高位1构成的数 pre 就是 i(直到下次出现上述情况,到时候更新 pre 即可)。毫无疑问,此时数 i 的比特位1的个数为1;
  • 如果某个数 i 与 i-1 相与后得到的数不为0,那么这个数 i 减去最高位1构成的数 pre 后得到的数肯定之前被访问过,那么该数的比特位1的个数为1+dic[i-pre];
    示例:十进制7对应的二进制为111,去掉最高位1后构成的二进制数为11,对应十进制为3,前面已经计算出dic[3]=2,所以7的二进制位为1的个数是1+dic[3]=3

代码如下:

def countBits(self, num: int) -> List[int]:
    if not num:
        return [0]
    res=[0]
    dic={0:0}
    for i in range(1,num+1):
        if i&(i-1) == 0:
            pre=i
            count=1
        else:
            count=1+dic[i-pre]
            
        dic[i]=count
        res.append(count)
    return res

动态规划

解题思路①:

  • 某个偶数肯定能由前面的某个数左移一位得到,如十进制6对应的二进制为110,由十进制3对应二进制11左移一位得到。
  • 某个奇数肯定能由前面的某个数左移一位并加上1得到,如十进制7对应的二进制为111,是由十进制3对应二进制11左移一位为110并加一得到。

代码如下:

def countBits(self, num: int) -> List[int]:
    dp=[0]*(num+1)
    for i in range(num//2+1):
        dp[i*2]=dp[i]
        if i*2+1<=num:
            dp[i*2+1]=dp[i]+1
    return dp

解题思路②:

  • 我们在前面说过,通过 i &= i-1 的方式可以逐渐消掉二进制最低位的1,因为我们是由小到大逐渐求解数的比特位1的个数的,因此通过 i&(i-1) 消除最低位后得到的数肯定小于当前的 i,且已被计算过。

代码如下:

def countBits(self, num: int) -> List[int]:
    dp=[0]*(num+1)
    for i in range(1,num+1):
        dp[i]=dp[i&(i-1)]+1
    return dp

56. 寻找两个正序数组的中位数

题目

给定两个大小为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的中位数。
在这里插入图片描述
在这里插入图片描述
解答

暴力解法

这道题一个简单的做法就是直接把两个数组进行归并操作,中位数结果就出来了。

二分查找

中位数把一个升序数组分成了长度相等的两部分,其中左半部分的最大值永远小于或等于右半部分的最小值。举例来说,假设我们有偶数长度的数组1 2 3 4 5 6 7 7 8 9 15,可以根据中位数分成长度相等的两部分,左半部分的最大元素(6)永远小于或等于右半部分的最小元素(7)。对于奇数长度的数组1 2 3 4 5 5 6 7 9 10 11,同样可以根据中位数分成两部分,如果把中位数本身归入左半部分,则左半边的长度=右半边长度+1,同样,左半部分的最大元素(5)永远小于或等于右半部分的最小元素(6)。

我们假设以下两个数组:

数组 A数组 B
3 5 7 8 9 1 2 6 7 15

数组合并后如下所示:

合并后的数组
1 2 3 5 6 7 7 8 9 15

大数组(合并后的数组)被中位数等分的左右两部分,每一部分根据来源又可以再划分为两部分,其中一部分来自数组 A 的元素,另一部分来自数组 B 的元素。如上表格所示,原始数组 A 和数组 B 各自分成绿色和橙色两部分,其中数值较小的绿色元素组成了大数组的左半部分,数值较大的橙色元素组成了大数组的右半部分,最重要的是绿色元素和橙色元素的数量是相等的(偶数情况),而最大的绿色元素小于或等于最小的橙色元素。

更一般的情况,我们假设数组 A 的长度为 m,绿色和橙色元素的分界点是 i;数组 B 的长度是 n,绿色和橙色元素的分界点是 j,那么为了让大数组的左右两部分长度相等,则 i 和 j 需要符合以下两个条件:

  • i + j = (m + n + 1) // 2
    之所以 m + n 后面要再加1,是为了应对大数组长度为奇数的情况
  • max(A[i-1], B[j-1]) ≤ min(A[i], B[j])
    简单来说,就是最大的绿色元素小于或等于最小的橙色元素

由于 m+n 的值是恒定的,所以我们只要确定一个合适的 i,就可以确定 j,从而找到大数组左半部分和右半部分的分界,也就找到了归并之后大数组的中位数。我们可以利用二分查找来确定 i 的值:

  • 第1步,就像二分查找那样,把 i 设在数组 A 的正中位置;

  • 第2步,根据 i 的值来确定 j 的值, j = (m + n + 1) // 2 - i;

  • 第3步,验证 i 和 j,分为下面三种情况:

    • B[j-1] ≤ A[i] && A[i-1] ≤ B[j]:说明 i 和 j 左侧的元素都小于或等于右侧的元素,这一组 i 和 j 是我们想要的;
    • A[i] < B[j-1]:说明 i 对应的元素偏小了,i 应该向右侧移动;
    • A[i-1] > B[j]:说明 i-1 对应的元素偏大了,i 应该向左侧移动;

    假设我们遇到的情况属于第二种,所以 i 应该向右移动;

  • 第4步,在数组 A 的右半部分重新确定 i 的值,就像二分查找一样;

  • 第5步,同第2步,根据 i 的值来确定 j 的值;

  • 第6步,同第3步,验证 i 和 j;

  • 第7步,找出中位数:

    • 如果大数组的长度是奇数,那么中位数=max(A[i-1], B[j-1]),也就是大数组左半部分的最大值;
    • 如果大数组的长度是偶数,那么中位数=(max(A[i-1], B[j-1]) + min(A[i], B[j]))/2,也就是大数组左半部分的最大值和大数组右半部分的最小值取平均。

在寻找中位数的过程中,存在以下两种特殊情况:

  • 数据 A 的长度远大于数组 B
    当我们设定了 i 的值的时候,也就是数组 A 正中间的元素,再计算 j 的时候有可能发生数组越界。
    因此我们可以提前把数组 A 和数组 B 进行交换,较短的数组放在前面,i 从较短的数据中取。这样做还有一个好处,由于数组 A 是较短数组,i 的搜索次数减少了。
  • 无法找到合适的 i 值
    什么情况下会无法找到合适的 i 值呢?有两种情况:
    • 数组 A 的长度小于数组 B 的长度,并且数组 A 的所有元素都大于数组 B 的元素
      在这种情况下,我们无法通过二分查找寻找到符合 B[j-1] ≤ A[i] && A[i-1] ≤ B[j] 的 i 值,一直到 i 等于0为止,此时我们可以跳出二分查找的循环,所求的中位数是 B[j-1](仅限奇数情况)。
    • 数组 A 的长度小于数组 B 的长度,并且数组 A 的所有元素都小于数组 B 的元素
      在这种情况下,同样无法通过二分查找寻找到符合 B[j-1] ≤ A[i] && A[i-1] ≤ B[j] 的 i 值,一直到i=(数组 A 的长度)-1为止,此时我们可以跳出二分查找的循环,所求的中位数是 max(A[i-1], B[j-1])(仅限奇数情况)。

代码如下:

def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
    m, n = len(nums1), len(nums2)
    if m > n:
        nums1, nums2 = nums2, nums1
        m, n = n, m
    start, end, half_len = 0, m, (m + n + 1)//2
    while start<= end:
        i = (start+end)//2
        j = half_len - i
        if i < m and nums2[j-1] > nums1[i]: #nums2左侧的值大于nums1中位数,说明i需要向右移动
            start = i + 1
        elif i > 0 and nums1[i-1] > nums2[j]: #nums1左侧的值大于nums2中位数,说明i需要向左移动
            end = i - 1
        else: #找到中位数或者到达数组边界了
            if i == 0: #说明nums1中所有值均不小于nums2
                max_left = nums2[j-1]
            elif j == 0: #说明nums1中所有值均不大于nums2
                max_left = nums1[i-1]
            else:
                max_left = max(nums1[i-1], nums2[j-1])
            if (m + n)%2 == 1:
                return max_left
            if i == m: #说明nums1中所有值均不大于nums2
                min_right = nums2[j]
            elif j == n: #说明nums1中所有值均不小于nums2
                min_right = nums1[i]
            else:
                min_right = min(nums1[i], nums2[j])
            return (max_left + min_right)/2

57. 戳气球

题目

有 n 个气球,编号为0 到 n-1,每个气球上都标有一个数字,这些数字存在数组 nums 中。

现在要求你戳破所有的气球。如果你戳破气球 i ,就可以获得 nums[left] * nums[i] * nums[right] 个硬币。 这里的 left 和 right 代表和 i 相邻的两个气球的序号。注意当你戳破了气球 i 后,气球 left 和气球 right 就变成了相邻的气球。

求所能获得硬币的最大数量。

说明:

  • 你可以假设 nums[-1] = nums[n] = 1,但注意它们不是真实存在的所以并不能被戳破。
  • 0 ≤ n ≤ 500, 0 ≤ nums[i] ≤ 100

在这里插入图片描述

解答

动态规划解法

我们来看一个区间,这个区间的气球长这样
在这里插入图片描述
假设这个区间是个开区间,最左边索引 i,最右边索引 j ,“开区间” 的意思是,我们只能戳爆 i 和 j 之间的气球,i 和 j 不能戳。

动态规划的思路是这样的:先别管前面的气球是怎么戳的,只要管这个区间最后一个被戳破的是哪个气球。假设最后一个被戳爆的气球是粉色的,它对应的索引为 k。

然后由于 k 是最后一个被戳爆的,所以它被戳爆之前是这个样子的:
在这里插入图片描述
因为是最后一个被戳爆的,所以它周边没有气球了,只剩下开区间首尾的 i 和 j 了。

假设 dp[i][j] 表示开区间 (i,j) 内你能拿到的最多金币,那么很明显当 i 和 j 的距离小于2时,对应的 dp[i][j] 的值为0。同时,数组 dp 的形状应该是 n*n 大小的,其中 n 表示数组 nums 的长度。

在 (i,j) 开区间得到的金币可以由 dp[i][k] 和 dp[k][j] 进行转移,如果你此刻选择戳爆气球 k,那么你得到的金币数量就是:

total = dp[i][k] + val[i] * val[k] * val[j] + dp[k][j]

注意:

  • val[i] 表示 i 位置气球的数字,然后 (i,k) 和 (k,j) 也都是开区间

戳爆粉色气球能获得 val[i]*val[k]*val[j] 这么多金币能理解(因为戳爆 k 的时候只剩下这三个气球了),但为什么前后只要加上 dp[i][k] 和 dp[k][j] 的值就行了呢?

因为 k 是最后一个被戳爆的,所以 (i,j) 区间中 k 两边的东西必然是先各自被戳爆了的,左右两边互不干扰,这就是为什么我们 DP 时要看 “最后一个被戳爆的” 气球,这就是为了让左右两边互不干扰。

所以我们把 (i,k) 开区间所有气球戳爆,然后把戳爆这些气球的所有金币都收入囊中,金币数量记录在 dp[i][k] 中。同理,(k,j) 开区间也已经都戳爆了,金币记录在 dp[k][j] 中。所以把这些之前已经拿到的钱 dp[i][k]+dp[k][j] 收着,再加上新赚的钱 val[i]*val[k]*val[j] 就得到了现在戳爆气球 k 一共手上能得的钱数。

而在 (i,j) 开区间可以选的 k 是有多个的,如一开始的图所示,除了粉色之外,你还可以戳绿色和红色,所以枚举一下这几个 k,从中选择使得 total 值最大的即可用来更新 dp[i][j]。

然后我们就从 (i,j) 开区间只有三个数字的时候开始计算,储存每个小区间可以得到金币的最大值,然后慢慢扩展到更大的区间,利用小区间里已经算好的数字来算更大的区间。

代码如下:

def maxCoins(self, nums: List[int]) -> int:

    #nums首尾添加1,方便处理边界情况
    nums.insert(0,1)
    nums.append(1)

    store = [[0]*(len(nums)) for i in range(len(nums))]

    def range_best(i,j):
        m = 0 
        #k是(i,j)区间内最后一个被戳的气球
        for k in range(i+1,j): #k取值在(i,j)开区间中
            #以下都是开区间(i,k), (k,j)
            left = store[i][k]
            right = store[k][j]
            a = left + nums[i]*nums[k]*nums[j] + right
            if a > m:
                m = a
        store[i][j] = m

    #对每一个区间长度进行循环
    for n in range(2,len(nums)): #区间长度从3开始,n从2开始
        #开区间长度会从3一直到len(nums)
        #因为这里取的是range,所以最后一个数字是len(nums)-1

        #对于每一个区间长度,循环区间开头的i
        for i in range(0,len(nums)-n): #i+n = len(nums)-1
            #计算这个区间的最多金币
            range_best(i,i+n)

    return store[0][len(nums)-1]

58. 排序算法

数组中的第K个最大元素

题目

在未排序的数组中找到第 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
在这里插入图片描述
说明:

  • 你可以假设 k 总是有效的,且 1 ≤ k ≤ 数组的长度。

解答

sort() 函数

这道题首先想到的就是直接对原数组利用 sorted 函数进行排序,然后取出来数组的第 k 个值即可。

一行代码就能解决,代码如下:

def findKthLargest(self, nums: List[int], k: int) -> int:
      return sorted(nums, reverse = True)[k-1]

但是,既然这个题就是单纯地让你排序,那肯定不能这么利用函数一步出来,而是要探究各种排序算法。

冒泡排序

冒泡排序的思想如下:把相邻的元素两两比较,当一个元素大于右侧相邻元素时,交换它们的位置;当一个元素小于或者等于右侧相邻元素时,位置不变。

代码如下:

def findKthLargest(self, nums: List[int], k: int) -> int:
    for count in range(k): # 只求第k个最大元素,排序k次即可
        for i in range(len(nums)-count-1):
            if nums[i] > nums[i+1]:
                nums[i], nums[i+1] = nums[i+1], nums[i]
    return nums[-k]

优化:

  • 如果能够判断出数组已经有序,并做出标记,那就不必执行剩下的几轮排序了,可以提前结束工作;
  • 数列真正的有序区可能会大于排序的轮数(外循环的次数),因此我们可以在每一轮排序之后,记录下最后一次元素交换的位置,该位置即为无序数列的边界,再往后就是有序区了。

优化后的代码如下:

def findKthLargest(self, nums: List[int], k: int) -> int:
    last_exchange_index = 0
    sort_border = len(nums)-1
    for count in range(k):
        is_sorted = True
        for i in range(sort_border):
            if nums[i] > nums[i+1]:
                is_sorted = False
                nums[i], nums[i+1] = nums[i+1], nums[i]
                last_exchange_index = i
        sort_border = last_exchange_index
        if is_sorted or sort_border<len(nums)-k:  # 如果已经排好序或者无序数列的边界已经低于倒数第k个
            break
    return nums[-k]

鸡尾酒排序

鸡尾酒排序的元素比较和交换过程是双向的,即每进行一次大循环,就会确定数组左边和右边各一个元素。感觉鸡尾酒排序并不适用于此题,鸡尾酒更适用于对数组进行完整排序,而不是求出第 k 个最大元素。

代码如下:

def findKthLargest(self, nums: List[int], k: int) -> int:
   for count in range(len(nums)//2):
        is_sorted = True
        for i in range(count, len(nums)-count-1):
            if nums[i] > nums[i+1]:
                is_sorted = False
                nums[i], nums[i+1] = nums[i+1], nums[i]
        if is_sorted:
            break

        is_sorted = True
        for i in range(len(nums)-count-1, count, -1):
            if nums[i] < nums[i-1]:
                is_sorted = False
                nums[i], nums[i-1] = nums[i-1], nums[i]
        if is_sorted:
            break
    return nums[-k]

快速排序

同冒泡排序一样,快速排序也属于交换排序,通过元素之间的比较和交换位置来达到排序的目的。不同的是,冒泡排序在每一轮中只把1个元素冒泡到数列的一端,而快速排序则在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成两个部分。这种思路就叫作分治法

快速排序的流程是什么样子的呢?在分治法的思想下,原数列在每一轮都被拆分成两部分,每一部分在下一轮又分别被拆分成两部分,直到不可再分为止。每一轮的比较和交换,需要把数组中的全部元素都遍历一遍,时间复杂度是O(n)。这样的遍历一共需要多少轮呢?假如元素个数是n,那么平均情况下需要logn轮,因此快速排序算法总体的平均时间复杂度是O(nlogn)。

分治法果然神奇!那么基准元素是如何选的呢?又如何把其他元素移动到基准元素的两端呢?基准元素的选择,以及元素的交换,都是快速排序的核心问题。

让我们先来看看如何选择基准元素。最简单的方式是选择数列的第一个元素,或者随机选择一个元素作为基准元素。当然,即使是随机选择基准元素,也会有极小的概率选到数列的最大值或最小值,同样会影响分治的效果。

选定了基准元素,我们要做的就是把其他元素中小于基准元素的都交换到基准元素的一边,大于基准元素的都交换到基准元素的另一边。具体如何实现呢?有两种方法:

  • 双边循环法
  • 单边循环法

双边循环法:

  • 首先,选定基准元素 pivot,并且设置两个指针left和right,指向数列的最左和最右两个元素。
  • 接下来进行第1次循环,从 right 指针开始让指针所指向的元素和基准元素做比较。如果大于或等于pivot,则指针向左移动:如果小于pivot,则 right 指针停止移动,切换到 left 指针。
  • 轮到left指针行动,让指针所指向的元素和基准元素做比较。如果小于或等于pivot,则指针向右移动;如果大于pivot,则 left 指针停止移动。由于left开始指向的是基准元素,判断肯定相等,所以left右移1位。
  • 接下来,进入第2次循环,重新切换到right指针,向左移动。以此类推…

代码如下:

def findKthLargest(self, nums: List[int], k: int) -> int:
    def quick_sort(start_index, end_index):
        if start_index >= end_index:
            return
        pivot_index = partition_v1(start_index, end_index)
        quick_sort(start_index, pivot_index-1)
        quick_sort(pivot_index+1, end_index)
    
    def partition_v1(start_index, end_index):
        pivot = nums[start_index]
        left = start_index
        right = end_index
        while left != right:
            while left<right and nums[right]>pivot:
                right -= 1
            while left<right and nums[left]<=pivot:
                left += 1
            if left < right:
                nums[left], nums[right] = nums[right], nums[left]
        nums[start_index], nums[left] = nums[left], pivot
        return left
    
    quick_sort(0, len(nums)-1)

    return nums[-k]

双边循环法从数组的两边交替遍历元素,虽然更加直观,但是代码实现相对烦琐。而单边循环法则简单得多,只从数组的一边对元素进行遍历和交换:

  • 开始和双边循环法相似,首先选定基准元素pivot。同时,设置一个mark指针指向数列起始位置,这个mark指针代表小于基准元素的区域边界。
  • 接下来,从基准元素的下一个位置开始遍历数组:如果追历到的元素大于基准元素,就继续往后遍历;如果遍历到的元素小于基准元素,则需要做两件事:第一,把mark指针右移1位,因为小于pivot的区域边界增大了1;第二,让最新遍历到的元素和mark指针所在位置的元素交换位置,因为最新遍历的元素归属于小于pivot的区域。
  • 按照这个思路,继续遍历…

代码如下:

def findKthLargest(self, nums: List[int], k: int) -> int:
    def quick_sort(start_index, end_index):
        if start_index >= end_index:
            return
        pivot_index = partition_v2(start_index, end_index)
        quick_sort(start_index, pivot_index-1)
        quick_sort(pivot_index+1, end_index)
    
    def partition_v2(start_index, end_index):
        pivot = nums[start_index]
        mark = start_index
        for i in range(start_index+1, end_index+1):
            if nums[i] < pivot:
                mark += 1
                nums[i], nums[mark] = nums[mark], nums[i]
        nums[start_index], nums[mark] = nums[mark], pivot
        return mark
    
    quick_sort(0, len(nums)-1)

    return nums[-k]

59. 判断一个整数是否是素数

解答

直接根据规则:如果一个整数为素数,那么它不能被 2~int(math.sqrt(该整数)) 中的任何一个数整除。

代码如下:

import math

def is_prime(x):
  if x<1 or not isinstance(x, int):
    print("输入有误")
    return
  elif x == 1:
    return False
  
  mid = int(math.sqrt(x))
  
  for i in range(2, mid+1):
    if x%i == 0:
      return False
  
  return True

60. 最大在线人数

题目

这是字节跳动三面的时候碰到的一个题,面试官现场出的题,但是没做出来,暴力解法都没有整出来。

题目大致是这样:给定一个数组 nums,里面每一个元素是一个元组,元组的形式是 (login, logout) ,代表某一用户的登录时间和登出时间,要求你求出同时在线的最大人数。

解答

解法1

如果数组中的元素是以整数代表时间,如(1, 3)、(2, 4)这样的形式,那我们可以这样解决该问题:

  • 通过排序找出登录时间的最小值 min_time 和登出时间的最大值 max_time,创建长度为 max_time-min_time +1、初始值全为 0 的数组 result;
  • 循环数组 nums 中的每个元素,将当前登录时间对应的数组 result 相应位置(login_i - min_time)处的元素值 +1,登出时间对应的数组 result 相应位置(logout_i - min_time)处的元素值 -1;
  • 直接累加初始化数组 result,当前时刻在线人数等于前一时刻累加在线人数加当前时刻在线人数;
  • 找出数组中最大的值即为最大在线人数。

解法2

如果数组中的元素是以时:分:秒的形式表示登入登出时间,且数组内的元素只表示当天的时刻,如(08:02:15, 09:42:35)、(02:18:32, 14:25:52)这样的形式,那我们可以这样解决该问题:

  • 现在的大型在线服务,每天至少有几十亿次用户登入登出,这么多记录排序根本没法排。这种问题是典型的大数据处理问题,直接使用水桶逻辑(bucketize);
  • 每天一共246060*60秒,就设置这么多个计数器(数组)。遍历所有数据,把每条数据在线时间内对应的所有水桶计数器+1;
  • 最后找出数组中最大的值即为最大在线人数。进一步,找到最大的相连水桶可以找到最大人数的最长在线时间。

二、二叉树

1. 打家劫舍Ⅲ

该题的题解见 “一、数组 5. 打家劫舍系列”。

2. 二叉树的遍历

中序遍历

题目

给定一个二叉树的根节点 root ,返回它的中序遍历。

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
解答

颜色标记解法

解题思路如下:

  • 设置两个变量 WHITE 和 GRAY分别表示颜色,用来标记节点状态:新节点为白色,已访问的节点为灰色;
  • 如果遇到的节点为白色,则将其标记为灰色,然后将其右子节点、自身、左子节点依次入栈;
  • 如果遇到的节点为灰色,则将节点的值输出。

代码如下:

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

def inorderTraversal(self, root: TreeNode) -> List[int]:
    WHITE, GRAY = 0, 1
    res = []
    stack = [(WHITE, root)]
    while stack:
        color, node = stack.pop()
        if node is None: continue
        if color == WHITE:
            stack.append((WHITE, node.right))
            stack.append((GRAY, node))
            stack.append((WHITE, node.left))
        else:
            res.append(node.val)
    return res

栈解法

上述算法可以优化,WHITE 对应的是 TreeNode 数据类型,GRAY对应 int 数据类型,所以不需要额外的颜色标记。优化后的代码如下:

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

def inorderTraversal(self, root: TreeNode) -> List[int]:
    stack,res = [root],[]
    while stack:
        i = stack.pop()
        if isinstance(i,TreeNode):
            stack.extend([i.right,i.val,i.left])
        elif isinstance(i,int):
            res.append(i)
    return res

前序遍历

题目

给定一个二叉树的根节点 root ,返回它的前序遍历。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
解答

栈解法

解题思路与中序遍历一样。

代码如下:

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

class Solution:
    def preorderTraversal(self, root: TreeNode) -> List[int]:
        stack,res = [root],[]
        while stack:
            i = stack.pop()
            if isinstance(i,TreeNode):
                stack.extend([i.right,i.left,i.val])
            elif isinstance(i,int):
                res.append(i)
        return res

后序遍历

题目

给定一个二叉树的根节点 root ,返回它的后序遍历。

在这里插入图片描述
解答

栈解法

解题思路与中序遍历一样。

代码如下:

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

class Solution:
    def postorderTraversal(self, root: TreeNode) -> List[int]:
        stack,res = [root],[]
        while stack:
            i = stack.pop()
            if isinstance(i,TreeNode):
                stack.extend([i.val,i.right,i.left])
            elif isinstance(i,int):
                res.append(i)
        return res

层序遍历

题目

给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。

在这里插入图片描述
解答

队列解法

层序遍历的返回结果与前面三个题的返回结果不同。前面三个题的返回结果是按照顺序的一维列表结构,而本题要求的返回结果是每一层作为一个列表,然后再合并返回大列表,大列表的元素是列表。

返回一维列表结构的层序遍历的队列解法的代码为:

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

def levelTraversal(self, root: TreeNode) -> List[int]:
    queue,res = [root],[]
    while queue:
        i = queue.pop(0)
        if isinstance(i,TreeNode):
            queue.extend([i.val,i.left,i.right])
        elif isinstance(i,int):
            res.append(i)
    return res

本题的解题思路如下:

  • 首先将根节点放在队列 queue 中;
  • 当队列不为空时,先记录目前队列的长度作为本层需要访问的节点数 n,然后从队列 queue 中逐次(先入先出)取出 n 个节点;
  • 每一次,当节点不为空时,将节点值放在存放在临时数组中,然后将该节点的左右节点依次存入队列中;
  • 最后,当临时数组不为空时将其存入结果数组中并清空临时数组,继续进行下一次循环。

修改后的代码如下:

def levelOrder(self, root: TreeNode) -> List[List[int]]:
    queue = [root]
    result = []
    while queue:
        n = len(queue)
        mid = []
        for i in range(n):
            cur = queue.pop(0)
            if not cur:
                continue
            mid.append(cur.val)
            queue.extend([cur.left,cur.right])
        if mid:
            result.append(mid)
    return result

3. 二叉树的最大深度

题目

给定一个二叉树,找出其最大深度。

二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。

说明: 叶子节点是指没有子节点的节点。

在这里插入图片描述

解答

层序遍历解法(广度优先遍历)

这道题用上述层序遍历的思路就能很轻松地解出来,求二叉树的最大深度无非是求二叉树有几层。

代码如下:

def maxDepth(self, root: TreeNode) -> int:
    queue = [root]
    result = 0
    while queue:
        n = len(queue)
        mid = []
        for i in range(n):
            cur = queue.pop(0)
            if not cur:
                continue
            mid.append(cur.val)
            queue.extend([cur.left,cur.right])
        if mid:
            result += 1
    return result

题解里给出了该解法的另外一种代码实现:

def maxDepth(self, root: TreeNode) -> int:
    if not root:
        return 0
    result = 0
    queue = [root]
    while queue:
        result += 1
        next_layer = []
        while queue:
            node = queue.pop(0)
            if node.left:
                next_layer.append(node.left)
            if node.right:
                next_layer.append(node.right)
        queue = next_layer
    return result

回溯解法(深度优先遍历)

def maxDepth(self, root: TreeNode) -> int:
    return 0 if not root else max(self.maxDepth(root.left), self.maxDepth(root.right))+1

4. 合并二叉树

题目

给定两个二叉树,想象当你将它们中的一个覆盖到另一个上时,两个二叉树的一些节点便会重叠。

你需要将他们合并为一个新的二叉树。合并的规则是如果两个节点重叠,那么将他们的值相加作为节点合并后的新值,否则不为 NULL 的节点将直接作为新二叉树的节点。
在这里插入图片描述
注意: 合并必须从两个树的根节点开始。

解答

递归解法

如果没有头绪的话,可以将这两棵树想象成是两个数组:

1 3 2 5
2 1 3 4 7

合并两个数组很直观,将数组 2 的值合并到数组 1 中,再返回数组 1 就可以了。

对于二叉树来说,如果我们像遍历数组那样,挨个遍历两棵二叉树中的每个节点,再把它们相加,那问题就比较容易解决了。

用前序遍历来遍历二叉树,再依次把访问到的节点值相加,因为题目没有说不能改变树的值和结构,我们无需创建新节点,直接将树2合并到树1上再返回就可以了。

需要注意:这两颗树并不是长得完全一样,有的树可能有左节点,但有的树没有。对于这种情况,我们统一地都把它们挂到树 1 上面就可以了,代码如下:

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

class Solution:
    def mergeTrees(self, t1: TreeNode, t2: TreeNode) -> TreeNode:
        if not t1 or not t2:
            return t1 or t2
        t1.val = t1.val + t2.val
        t1.left = self.mergeTrees(t1.left, t2.left)
        t1.right = self.mergeTrees(t1.right, t2.right)
        return t1

5. 二叉搜索树

第一次接触二叉搜索树,我还以为和二叉树一样,结果发现二叉搜索树有一个额外的条件,那就是:树所有左子节点的数字都比父节点数字小,所有右节点的数字都比父节点数字大。即每个父节点分出来的左子树里,任何一个数字都比这个父节点的数字小;右子树里,任何一个数字都比这个父节点的数字大。

不同的二叉搜索树

题目

给定一个整数 n,求以 1 … n 为节点组成的二叉搜索树有多少种?
在这里插入图片描述

解答

动态规划解法

对于本题,由于二叉搜索树以1, 2, …, n 递增数列为节点,因此我们从任意一个位置“提起”这棵树(即以该位置作为根节点),都满足二叉搜索树的条件:左子树均小于父节点,右子树均大于父节点。

从 1, 2, …, n 数列构建二叉搜索树,实际上只是一个不断细分的过程。以数列 [1, 2, 3, 4, 5, 6] 为例构建二叉搜索树:

  • 提起"2"作为根节点,[1]为左子树,[3,4,5,6]为右子树;
  • 现在就变成了一个更小的问题:如何用[3,4,5,6]构建搜索树?比如,我们可以提起"5"作为树根,[3,4]是左子树,[6]是右子树;
  • 现在就变成了一个更更小的问题:如何用[3,4]构建搜索树?那么这里就可以提起"3"作为树根、[4]是右子树,或者"4"作为树根、[3]是左子树。

假设 f(n) 代表我们有n个数字时可以构建几种搜索树,那么我们很容易得知几个简单情况 f(0) = 1, f(1) = 1, f(2) = 2。(我们这里理解为 f(0)=1,即没有数字时只有一种情况,就是空的情况)

那么 n=3 时呢?

如果提起1作为树根,左边有f(0)种情况,右边f(2)种情况,左右搭配一共有f(0)*f(2)种情况;如果提起2作为树根,左边有f(1)种情况,右边f(1)种情况,左右搭配一共有f(1)*f(1)种情况;如果提起3作为树根,左边有f(2)种情况,右边f(0)种情况,左右搭配一共有f(2)*f(0)种情况。容易得知f(3) = f(0)*f(2) + f(1)*f(1) + f(2)*f(0)

同理,
f(4) = f(0)*f(3) + f(1)*f(2) + f(2)*f(1) + f(3)*f(0)
f(5) = f(0)*f(4) + f(1)*f(3) + f(2)*f(2) + f(3)*f(1) + f(4)*f(0)

可以发现,对于每一个n,其式子都是有规律的:每一项两个f()的数字加起来都等于n-1。

既然我们已知f(0) = 1, f(1) = 1,那么就可以先算出f(2),再算出f(3),然后f(4)也可以计算…最后得到的f(n)就是我们需要的结果。

代码如下:

def numTrees(self, n: int) -> int:
    store = [1,1] #f(0),f(1)
    if n <= 1:
        return store[n]
    for m in range(2,n+1):
        count = 0
        for i in range(m):
            count += store[i]*store[m-i-1]
        store.append(count)
    return store[-1]

不同的二叉搜索树 II

题目

给定一个整数 n,生成所有由 1 … n 为节点所组成的 二叉搜索树。

在这里插入图片描述
解答

递归解法

二叉搜索树关键的性质是根节点的值大于左子树所有节点的值,小于右子树所有节点的值,且左子树和右子树也同样为二叉搜索树。因此在生成所有可行的二叉搜索树的时候,假设当前序列长度为 n,如果我们枚举根节点的值为 i,那么根据二叉搜索树的性质我们可以知道左子树的节点值的集合为 [1…i−1],右子树的节点值的集合为 [i+1…n]。而左子树和右子树的生成相较于原问题是一个序列长度缩小的子问题,因此我们可以想到用递归的方法来解决这道题目。

我们定义 generateTrees(start, end) 函数表示当前值的集合为 [start,end],返回序列 [start,end] 生成的所有可行的二叉搜索树。按照上文的思路,我们考虑枚举 [start,end] 中的值 i 为当前二叉搜索树的根,那么序列划分为了 [start,i−1] 和 [i+1,end] 两部分。我们递归调用这两部分,即 generateTrees(start, i - 1) 和 generateTrees(i + 1, end),获得所有可行的左子树和可行的右子树,那么最后一步我们只要从可行左子树集合中选一棵,再从可行右子树集合中选一棵拼接到根节点上,并将生成的二叉搜索树放入答案数组即可。

递归的入口即为 generateTrees(1, n),出口为当 start>end 的时候,当前二叉搜索树为空,返回空节点即可。

代码如下:

# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
    def generateTrees(self, n: int) -> List[TreeNode]:
        def generateTrees(start, end):
            if start > end:
                return [None,]
            
            allTrees = []
            for i in range(start, end + 1):  # 枚举可行根节点
                # 获得所有可行的左子树集合
                leftTrees = generateTrees(start, i - 1)
                
                # 获得所有可行的右子树集合
                rightTrees = generateTrees(i + 1, end)
                
                # 从左子树集合中选出一棵左子树,从右子树集合中选出一棵右子树,拼接到根节点上
                for l in leftTrees:
                    for r in rightTrees:
                        currTree = TreeNode(i)
                        currTree.left = l
                        currTree.right = r
                        allTrees.append(currTree)
            
            return allTrees
        
        return generateTrees(1, n) if n else []

验证二叉搜索树

题目

给定一个二叉树,判断其是否是一个有效的二叉搜索树。

假设一个二叉搜索树具有如下特征:

  • 节点的左子树只包含小于当前节点的数。
  • 节点的右子树只包含大于当前节点的数。
  • 所有左子树和右子树自身必须也是二叉搜索树。

在这里插入图片描述
解答

深度优先遍历

解题思路如下:

  • 设计一个递归函数 helper(root, lower, upper),函数表示考虑以 root 为根的子树,判断子树中所有节点的值是否都在相应的范围内;
  • 根据二叉搜索树的性质,在递归调用左子树时,我们需要把上界 upper 改为 root.val,即调用 helper(root.left, lower, root.val),因为左子树里所有节点的值均小于它的根节点的值;
  • 同理,递归调用右子树时,我们需要把下界 lower 改为 root.val,即调用 helper(root.right, root.val, upper);
  • 如果 root 节点的值 val 不在相应范围内,说明不满足条件直接返回 False,否则我们要继续递归调用检查它的左右子树是否满足范围条件,如果都满足才说明这是一棵二叉搜索树;
  • 函数递归调用的入口为 helper(root, -inf, +inf), inf 表示一个无穷大的值。

代码如下:

def isValidBST(self, root: TreeNode) -> bool:
    def helper(node, lower = float('-inf'), upper = float('inf')):
        if not node:
            return True
        
        val = node.val
        if val <= lower or val >= upper:
            return False

        if not helper(node.right, val, upper):
            return False
        if not helper(node.left, lower, val):
            return False
        return True

    return helper(root)

6. 对称二叉树

题目

给定一个二叉树,检查它是否是镜像对称的。

例如,二叉树 [1,2,2,3,4,4,3] 是对称的。
在这里插入图片描述
但是下面这个 [1,2,2,null,3,null,3] 则不是镜像对称的:
在这里插入图片描述

解答

递归解法(深度优先遍历DFS)

镜像对称,就是指左右两边对称相等,即左子树和右子树是相当的,因此我们要递归地比较左子树和右子树。

解题思路如下:

  • 我们将根节点的左子树记做 left,右子树记做 right。比较 left 的值是否等于 right 的值,不相等的话直接返回 False;
  • 如果相等,比较 left 的左节点和 right 的右节点,再比较 left 的右节点和 right 的左节点,即递归地比较 left.left 和 right.right,left.right 和 right.left;
  • 当 left 和 right 的值不等,或者 left 和 right 都为 None 时,停止递归。

代码如下:

def isSymmetric(self, root: TreeNode) -> bool:
    if not root:
        return True
    def dfs(left,right):
        # 递归的终止条件是两个节点都为空
        # 或者两个节点中有一个为空
        # 或者两个节点的值不相等
        if not (left or right):
            return True
        if not (left and right):
            return False
        if left.val != right.val:
            return False
        return dfs(left.left,right.right) and dfs(left.right,right.left)
    # 用递归函数,比较左节点,右节点
    return dfs(root.left,root.right)

迭代解法(广度优先遍历BFS)

解题思路如下:

  • 当根结点为空时直接返回 True,否则将根节点的左右子结点放进队列中。
  • 当队列不为空时,进行如下循环:
    • 每次循环判断 left 的值是否等于 right 的值,不相等的话直接返回 False;
    • 如果相等,将 left 的 left 节点和 right 的 right 节点放入队列,再将 left 的 right 节点和 right 的 left 节点放入队列;
    • 当队列不为空时,继续进行循环。
  • 当队列为空时,结束循环并返回 True。

代码如下:

def isSymmetric(self, root: TreeNode) -> bool:
    if not root:
        return True
    # 用队列保存节点	
    queue = [root.left,root.right]
    while queue:
        # 从队列中取出两个节点,再比较这两个节点
        left = queue.pop(0)
        right = queue.pop(0)
        # 如果两个节点都为空就继续循环,两者有一个为空就返回false
        if not (left or right):
            continue
        if not (left and right):
            return False
        if left.val!=right.val:
            return False
        # 将左节点的左孩子, 右节点的右孩子放入队列
        queue.append(left.left)
        queue.append(right.right)
        # 将左节点的右孩子,右节点的左孩子放入队列
        queue.append(left.right)
        queue.append(right.left)
    return True

7. 从前序与中序遍历序列构造二叉树

题目

根据一棵树的前序遍历与中序遍历构造二叉树。

注意,你可以假设树中没有重复的元素。

在这里插入图片描述

解答

递归解法

这道题给出了前序遍历和中序遍历的列表,要求根据这两个列表构造出二叉树。前序遍历的遍历顺序是:根节点→左子树→右子树,中序遍历的遍历顺序是:左子树→根节点→右子树,每个子树的遍历顺序也和上述相同。

因此,前序遍历列表的第一个元素一定是当前根节点,中序遍历列表中根节点的左侧一定是当前左子树、右侧一定是当前右子树,因此我们只需要不断递归,直到没有元素可分则递归结束。

代码如下:

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

class Solution:
    def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode:
        if len(inorder) == 0:
            return
        # 前序遍历第一个值为根节点
        root = TreeNode(preorder[0])
        # 因为没有重复元素,所以可以直接根据值来查找根节点在中序遍历中的位置
        mid = inorder.index(preorder[0])
        # 构建左子树
        root.left = self.buildTree(preorder[1:mid+1], inorder[:mid])
        # 构建右子树
        root.right = self.buildTree(preorder[mid+1:], inorder[mid+1:])
        
        return root

8. 路径总和系列

路径总和 I

题目

给你二叉树的根节点 root 和一个表示目标和的整数 targetSum ,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和 targetSum 。

叶子节点 是指没有子节点的节点。

在这里插入图片描述
在这里插入图片描述
解答

DFS解法

解题思路如下:

  • 利用深度优先遍历的思路,当当前节点为有效非叶子结点时,我们进行递归,递归函数的两个参数分别为:当前遍历节点 root,当前目标值还差多少 targetSum;
  • 如果节点为 None,直接返回 False;
  • 如果节点为叶子节点,说明我们已经遍历将要完了一条路径,返回当前目标值是否等于叶子结点的值的布尔结果即可;
  • 如果节点为非叶子节点,我们执行递归,分别访问当前节点的左右节点,并将目标值减去当前节点值作为新参数;
  • 只要有一条路径满足要求即可。

代码如下:

def hasPathSum(self, root: TreeNode, targetSum: int) -> bool:
    if not root:
        return False
    if not root.left and not root.right:
        return targetSum == root.val
    return self.hasPathSum(root.left, targetSum-root.val) or self.hasPathSum(root.right, targetSum-root.val)

路径总和 II

题目

给你二叉树的根节点 root 和一个整数目标和 targetSum ,找出所有从根节点到叶子节点路径总和等于给定目标和的路径。

叶子节点 是指没有子节点的节点。

在这里插入图片描述
在这里插入图片描述
解答

DFS解法

这道题的思路和“路径总和 I ”类似,唯一不同的是需要在递归函数中多添加一个表示路径的参数,这也导致了我们不能在原函数上进行递归,需要重新写一个递归函数。

当当前节点为叶子结点时,我们还需要判断当前目标值是否和叶子结点值相等,如果相等我们才会将当前路径添加到结果数组中。

def pathSum(self, root: TreeNode, targetSum: int) -> List[List[int]]:
    def dfs(root, path, targetSum):
        if not root:
            return
        if not root.left and not root.right:
            if targetSum == root.val:
                res.append(path+[root.val])
            return 
        return dfs(root.left, path+[root.val], targetSum-root.val) or dfs(root.right, path+[root.val], targetSum-root.val)
    res = []
    dfs(root, [], targetSum)
    return res

路径总和 III

题目

给定一个二叉树,它的每个结点都存放着一个整数值。

找出路径和等于给定数值的路径总数。

路径不需要从根节点开始,也不需要在叶子节点结束,但是路径方向必须是向下的(只能从父节点到子节点)。

二叉树不超过1000个节点,且节点数值范围是 [-1000000,1000000] 的整数。
在这里插入图片描述

解答

DFS解法

解题思路如下:

  • 数组 sumlist 来记录当前路径上的和,在如下样例中:
    在这里插入图片描述
    当 DFS 走到2时,此时数组 sumlist 从根节点10到2的变化过程为:
     10
     15 5
     17 7 2
    
    当 DFS 继续走到 1 时,此时数组 sumlist 从节点2到1的变化为:
    18 8 3 1   
    
    因此,只需计算每一步中,sum 在数组 sumlist 中出现的次数,然后与每一轮递归的结果相加即可。
  • 程序中count = sumlist.count(sum)等价于:
     count = 0
     for num in sumlist:
         if num == sum:
             count += 1
    
    count计算本轮 sum 在数组 sumlist 中出现的次数。

代码如下:

class Solution:
    def pathSum(self, root: TreeNode, sum: int) -> int:
        def dfs(root, sumlist):
            if not root: 
                return 0
            sumlist = [num + root.val for num in sumlist] + [root.val]
            return sumlist.count(sum) + dfs(root.left, sumlist) + dfs(root.right, sumlist)
        return dfs(root, [])

9. 实现 Trie (前缀树)

题目

实现一个 Trie (前缀树),包含 insert, search, 和 startsWith 这三个操作。

在这里插入图片描述
说明:

  • 你可以假设所有的输入都是由小写字母 a-z 构成的。
  • 保证所有输入均为非空字符串。

解答

这道题首先你得弄懂什么是前缀树。这里我是参考的该博客:字典树(前缀树)

因此,看名称“字典” 就知道要实现该树,需要构造字典,然后开始构建节点,节点递归下一个节点。因此我们在初始化时构造了一个空字典。

具体的解题思路如下:
① insert 功能:

  • 从头开始遍历给定的 word 字符,然后判断该字符是否存在当前的字典键里(初始时在自建的字典键里寻找);
  • 如果不存在,那么建立一个键值对,键为当前字符,值为空字典;如果存在,不做任何操作;
  • 最后,跳到该字符的键所对应的值(字典)里,然后遍历下一个字符;
  • 因为有一些单词可能存在包含关系,因此我们要在最后一个字符对应的字典里加一个结束标志 “#”,证明这是一个单词的结束点。

②我们可以按照类似于实现 insert 功能的思想来实现 search 功能:

  • 从头开始遍历给定的 word 字符,然后判断该字符是否存在于当前的字典键里(初始时在自建的字典键里寻找);
  • 如果不存在,直接返回 False;如果存在,继续遍历下一字符;
  • 当所有字符遍历完成时,我们还应该判断最后一个字符对应的字典里是否存在结束标志;
  • 如果存在,说明的确存在该 word,返回 True;如果不存在,说明该 word 只是其他单词的一个子串,字典中并不存在该 word,返回 False。

③ startsWith 功能和 search 功能基本一样,只不过 startsWith 功能只是判断字典树中是否以 prefix 字符串为开始,而不必判断 prefix 是否为一个单词,因此与步骤②相比,它无需判断是否存在结束标志。

代码如下:

class Trie:
    def __init__(self):
        self.dict_ = {}

    def insert(self, word: str) -> None:
        tree = self.dict_
        for i in word:
            if i not in tree:
                tree[i] = {}
            tree = tree[i]
        tree["#"] = "#"

    def search(self, word: str) -> bool:
        tree = self.dict_
        for i in word:
            if i not in tree:
                return False
            tree = tree[i]
        if "#" in tree:
            return True
        return False

    def startsWith(self, prefix: str) -> bool:
        tree = self.dict_
        for i in prefix:
            if i not in tree:
                return False
            tree = tree[i]
        return True

10. 二叉树展开为链表

题目

给定一个二叉树,原地将它展开为一个单链表。

在这里插入图片描述
在这里插入图片描述

解答

前序遍历解法(栈)

这道题可以用前面前序遍历的思想解开。观察这道题给的示例,我发现它是对给出的二叉树按照前序遍历(根节点→左子树→右子树),然后将遍历到的结果逐个连接在当前节点的右节点上,每个子树的左节点一定为 None。

代码如下:

# class TreeNode:
#     def __init__(self, val=0, left=None, right=None):
#         self.val = val
#         self.left = left
#         self.right = right
class Solution:
	def flatten(self, root: TreeNode) -> None:
	    """
	    Do not return anything, modify root in-place instead.
	    """
	    stack,rst = [root],[]
	    while stack:
	        i = stack.pop()
	        if isinstance(i,TreeNode):
	            stack.extend([i.right,i.left,i.val])
	        elif isinstance(i,int):
	            rst.append(i)
	    cur = root
	    for i in rst[1:]:
	        cur.left = None
	        cur.right = TreeNode(i)
	        cur = cur.right

11. 二叉树中的最大路径和

题目

给定一个非空二叉树,返回其最大路径和。

本题中,路径被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。该路径至少包含一个节点,且不一定经过根节点。

在这里插入图片描述

解答

递归解法

解题思路如下:

  • 初始化最大值变量 maxsum 为无穷小,然后开始调用递归函数;
  • 从根节点一直递归,直到递归到叶子节点;
  • 从叶子节点开始,逐渐回溯,并在回溯过程中更新最大值;
  • 我们将当前节点的左子树最大值+右子树最大值+当前节点值与当前最大值进行比较来更新最大值(因为允许路径不经过根节点),然后返回左右子树中的最大值+当前节点值与0比较得到的最大值(返回的路径只能有一条,不可能左右子树兼顾)。

代码如下:

def maxPathSum(self, root: TreeNode) -> int:
    self.maxsum = float('-inf')
    def dfs(root):
        if not root: return 0
        left = dfs(root.left)
        right = dfs(root.right)
        self.maxsum = max(self.maxsum, left + right + root.val)
        return max(0, max(left, right) + root.val)
    dfs(root)
    return self.maxsum

12. 二叉树的直径

题目

给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。
在这里插入图片描述
注意:两结点之间的路径长度是以它们之间边的数目表示。

解答

深度优先遍历

解题思路如下:

  • 创建一个递归函数,这个递归函数会进行深度优先遍历,从根节点开始,递归访问当前节点的左子节点和右子节点,直到访问到叶子节点,递归结束,返回0,表示当前节点左右子树的直径长度 L、R 均为0;
  • 然后我们用当前节点的左子树直径长度+右子树直径长度,存放结果的变量 self.ans(初始值为0)中的最大值来更新 self.ans,函数返回当前函数 L、R中的最大值+1作为父节点的某一子树中的最大直径长度,加1是因为子节点到父节点的直径长度为1;
  • 最后返回结果变量 self.ans。

代码如下:

def diameterOfBinaryTree(self, root: TreeNode) -> int:
    self.ans = 0
    def depth(root):
        if not root: return 0
        L = depth(root.left)
        R = depth(root.right)
        self.ans = max(self.ans, L + R)
        return max(L, R) + 1
    depth(root)
    return self.ans

13. 翻转二叉树

题目

翻转一棵二叉树。

在这里插入图片描述

解答

递归解法

这道题的递归解法很容易想出来,我们先遍历树的左节点,再遍历右节点,这样函数会不断地递归,直到叶子节点,然后我们自底向上(由叶子节点到根节点)逐渐交换节点的左右节点即可解出该题目。

代码如下:

def invertTree(self, root: TreeNode) -> TreeNode:
    if not root:
        return
    self.invertTree(root.left)
    self.invertTree(root.right)
    root.left, root.right = root.right, root.left
    return root

14. 二叉树的最近公共祖先

题目

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

百度百科中最近公共祖先的定义为:“对于有根树 T 的两个结点 p、q,最近公共祖先表示为一个结点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”

例如,给定如下二叉树: root = [3,5,1,6,2,0,8,null,null,7,4]

在这里插入图片描述

解答

递归解法

解题思路如下:
①利用递归,先遍历树的左节点,再遍历树的右节点;
②递归解析:

  • 终止条件:
    当越过叶节点,则直接返回 null ;
    当 root 等于 p, q,则直接返回 root
  • 递推工作:
    开启递归左子节点,返回值记为 left ;
    开启递归右子节点,返回值记为 right ;
  • 返回值: 根据 left 和 right ,可展开为四种情况:
    • 当 left 和 right 同时为空 :说明 root 的左 / 右子树中都不包含 p,q,返回 null;
    • 当 left 和 right 同时不为空 :说明 p,q 分列在 root 的 异侧 (分别在左 / 右子树),因此 root 为最近公共祖先,返回 root ;
    • 当 left 为空 ,right 不为空 :p,q 都不在 root 的左子树中,直接返回 right 。具体可分为两种情况:
      • p,q 其中一个在 root 的 右子树 中,此时 right 指向 p(假设为 p);
      • p,q 两节点都在 root 的 右子树 中,此时的 right 指向最近公共祖先节点 ;
    • 当 left 不为空 , right 为空 :与上一情况同理

代码将解题思路②中情况1、3和4合并在了一起,具体如下:

def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
    if not root or root == p or root == q:
        return root
    left = self.lowestCommonAncestor(root.left, p, q)
    right = self.lowestCommonAncestor(root.right, p, q)
    if not left: return right
    if not right: return left
    return root

15. 二叉树的序列化与反序列化

题目

序列化是将一个数据结构或者对象转换为连续的比特位的操作,进而可以将转换后的数据存储在一个文件或者内存中,同时也可以通过网络传输到另一个计算机环境,采取相反方式重构得到原数据。

请设计一个算法来实现二叉树的序列化与反序列化。这里不限定你的序列 / 反序列化算法执行逻辑,你只需要保证一个二叉树可以被序列化为一个字符串并且将这个字符串反序列化为原始的树结构。
在这里插入图片描述
说明: 不要使用类的成员 / 全局 / 静态变量来存储状态,你的序列化和反序列化算法应该是无状态的。

解答

深度优先遍历

这个题目与以前遇到的题目不同,通过序列化函数对二叉树进行序列化,然后利用序列化的数据来反序列化恢复二叉树。因此不论你将二叉树数据序列化成什么样子,只要你能利用该数据将其恢复成原来的二叉树就行。

以递归方式,按照前序遍历顺序将二叉树序列化:

  • 创建一个空列表 vals,然后从根节点开始前序遍历;
  • 如果节点不为 None,我们将节点值的字符类型存入列表 vals 中,然后分别对节点的左节点和右节点进行递归遍历;
  • 如果节点为 None,那我们将字符 “#” 存入 vals 中代表节点结束标志;
  • 最后,我们将列表 vals 中的字符以逗号连接形成一个字符串返回。

对数据反序列化:

  • 将序列化数据以逗号分割成列表,然后将列表转换成迭代器;
  • 创建一个递归函数 dfs,每递归一次便从迭代器中逐次取一个值,该函数在所取值不为空时返回利用该值创建的节点;
  • 如果该值为 “#”,说明为字节结束标志,直接返回 None;
  • 如果该值不为 “#”,利用该值创建节点,然后根据序列化时的数据顺序两次递归调用 dfs 函数,将其返回值分别作为当前节点的左节点和右节点。

代码如下:

# Definition for a binary tree node.
# class TreeNode(object):
#     def __init__(self, x):
#         self.val = x
#         self.left = None
#         self.right = None

class Codec:
    def serialize(self, root):
        """Encodes a tree to a single string.
        
        :type root: TreeNode
        :rtype: str
        """
        def dfs(node):
            if node:
                vals.append(str(node.val))
                dfs(node.left)
                dfs(node.right)
            else:
                vals.append("#")

        vals = []
        dfs(root)
        return ",".join(vals)

    def deserialize(self, data):
        """Decodes your encoded data to tree.
        :type data: str
        :rtype: TreeNode
        """
        def dfs():
            v = next(vals)
            if v == "#":
                return None
            node = TreeNode(int(v))
            node.left = dfs()
            node.right = dfs()
            return node
        vals = iter(data.split(","))
        return dfs()       

16. 把二叉搜索树转换为累加树

题目

给出二叉搜索树的根节点,该树的节点值各不相同,请你将其转换为累加树(Greater Sum Tree),使每个节点 node 的新值等于原树中大于或等于 node.val 的值之和。

提醒一下,二叉搜索树满足下列约束条件:

  • 节点的左子树仅包含键小于节点键的节点。
  • 节点的右子树仅包含键大于节点键的节点。
  • 左右子树也必须是二叉搜索树。
    在这里插入图片描述
    在这里插入图片描述

解答

反向中序遍历+颜色标记

这道题用的是反向中序遍历的解法。正常的中序遍历的解法是:左节点→父节点→右节点,如下图所示:

在这里插入图片描述
代码如下:

  • 我们利用颜色来标记节点,1表示节点并未访问,0表示节点已经访问。初始时将元组 (1, root) 放入栈 stack 中,并初始化累加变量 dp_sum 为0;
  • 当栈 stack 不为空时,执行以下循环:
    • 从栈中取出最后一个元素(这样可以实现反向中序遍历),并判断节点是否为 None:如果节点为当前循环;如果节点不为 None,判断颜色标记值;
    • 如果颜色标记值为1,说明当前节点未访问,将当前节点的左节点、当前节点、右节点依次放入栈中,颜色标记分别为1、0、1,结束当前循环;
    • 如果颜色标记值为0,说明当前节点已经访问,将当前节点的值加入累加变量中并将累加变量作为当前节点的值,结束当前循环。

代码如下:

def convertBST(self, root: TreeNode) -> TreeNode:
    stack = [(1, root)]
    dp_sum = 0
    while stack:
        color, curNode = stack.pop()
        if not curNode: continue
        if color == 1:
            stack.append((1, curNode.left))
            stack.append((0, curNode))
            stack.append((1, curNode.right))
        else:
            dp_sum += curNode.val
            curNode.val = dp_sum
    return root

三、链表

链表是一种在物理上非连续、非顺序的数据结构,由若干节点所组成。单向链表的每一个节点又包含两部分,一部分是存放数据的变量,另一部分是指向下一个节点的指针。因此要查询链表中的某个元素,只能从头挨个遍历。

1. 两数相加

题目

给出两个非空的链表用来表示两个非负的整数。其中,它们各自的位数是按照逆序的方式存储的,并且它们的每个节点只能存储一位数字。

如果,我们将这两个数相加起来,则会返回一个新的链表来表示它们的和。

您可以假设除了数字 0 之外,这两个数都不会以 0 开头。
在这里插入图片描述
在这里插入图片描述

解答

解题思路如下:

  • 首先创建一个 prehead 结点,用来指向结果链表的头结点;
  • 创建变量 jinwei 表示数字相加是否产生进位,初始值为0;
  • 从头结点开始遍历链表 l1、l2,当 l1 和 l2 中有一个链表未遍历结束,便执行以下循环:
    • 分别取 l1、l2 链表当前遍历结点的值(如果结点为空则为0),将当前两个链表的值与前一对值对应的进位相加,得到的结果对10取余作为结果链表下一节点对应的值,得到的结果对10整除来更新进位变量 jinwei;
    • 继续遍历链表 l1、l2 的下一结点;
  • 最后这一步容易忽略:在链表 l1 和 l2 均遍历结束后,需要判断最高位是否产生了进位,如果产生了进位,需要创建值为1的结点作为结果链表的下一节点。

代码如下:

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

class Solution:
    def addTwoNumbers(self, l1, l2):
        prehead = ListNode(0)
        jinwei = 0
        cur = prehead

        while l1 or l2:
            val_1 = l1.val if l1 else 0
            val_2 = l2.val if l2 else 0
            cur.next = ListNode((val_1+val_2+jinwei)%10)
            jinwei = (val_1+val_2+jinwei)//10
            if l1:
                l1 = l1.next
            if l2:
                l2 = l2.next
            cur = cur.next
        
        if jinwei:
            cur.next = ListNode(1)

        return prehead.next

2. 环形链表

环形链表Ⅰ

题目

给定一个链表,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意:pos 不作为参数进行传递,仅仅是为了标识链表的实际情况。

如果链表中存在环,则返回 true 。 否则,返回 false 。

在这里插入图片描述
在这里插入图片描述
解答

暴力解法

首先从头节点开始,依次建立单链表中的每一个节点。每遍历一个新节点,就从头检查新节点之前的所有节点。用新节点和此新节点之前的所有节点做比较,如果发现新节点和之前的某个节点相同(这里的相同并不是指单纯的节点值 val 相同,而是说节点值以及节点指向的下一个节点(包括值与指向的下一个节点)相同。因此如果两个节点是相同的,那么它们就是同一个节点。),这说明该节点被遍历过两次,链表有环,如果之前的所有节点中不存在与新节点相同的节点,就继续遍历下一个新节点,继续重复刚才的操作。

集合解法

首先创建一个以节点 ID 为 Key 的 set 集合,用来存储曾经遍历过的节点。然后同样从头节点开始,依次遍历单列表中的每一个节点,每遍历一个新节点,都用新节点 和 set 集合中存储的节点进行比较,如果发现 set 中存在与之相同的节点 ID,则说明链表有环,如果 set 中不存在与新节点相同的节点 ID,就把这个新节点 ID 存入 set 中,之后进入下一节点,继续重复刚才的操作。

双指针解法

解题思路如下:

  • 创建双指针 fast 和 low,让它们同时指向这个链表的头节点;
  • 开始一个大循环:在循环体中让指针 low 每次向后移动一个节点,让指针 fast 每次向后移动两个节点;
  • 比较两个指针指向的节点是否相同:如果相同则可以判断出链表有环,如果不同则继续进行下一次循环;
  • 当 fast 指针或 fast 指针的下一步为 None 时,结束循环,返回 False。

此方法就类似于数学上的追及问题:在一个环形跑道上,两个运动员从同一地点起跑,一个运动员速度快,另一个运动员速度慢,当两人跑了一段时间后,速度快的运动员必然会再次追上并超过速度慢的运动员,原因很简单,因为跑道是环形的。

代码如下:

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

class Solution:
    def hasCycle(self, head: ListNode) -> bool:
        low = head
        fast = head
        while fast and fast.next:
            low = low.next
            fast = fast.next.next
            if low == fast:
                return True
        return False

环形链表Ⅱ(百度地图)

题目

给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。

为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。注意,pos 仅仅是用于标识环的情况,并不会作为参数传递到函数中。

说明:不允许修改给定的链表。

在这里插入图片描述
在这里插入图片描述
解答

双指针解法

这道题是上一题“环形链表Ⅰ”的延伸,在上一道题判断出链表是否有环的基础上,在有环的情况下求出入环节点。

在这里插入图片描述
解题思路如下:

  • 假设从链表表头到入环节点的距离为 D,从入环节点到首次相遇点的距离为 S1,从首次相遇点回到入环节点的距离是 S2;
  • 那么当两个指针首次相遇时,low 指针走的距离为 D+S1,fast 指针走的距离是 D + S1 + n*(S1+S2),其中 n 代表快指针多走的圈数;
  • 由于 fast 指针的速度是 low 指针的两倍,因此 2*(D+S1) = D + S1 + n*(S1+S2),整理得 D=(n-1)*(S1+S2)+S2,即从链表表头到入环节点的距离的等于从首次相遇点饶环 n-1 圈,然后回到入环节点的距离;
  • 因此,我们让一个指针在链表表头的位置,一个指针在首次相遇点的位置,两个指针以相同速度移动,那么它们再次相遇的节点就是入环节点。

代码如下:

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

class Solution:
    def detectCycle(self, head: ListNode) -> ListNode:
        low = head
        fast = head
        is_cycle = False
        while fast and fast.next:
            low = low.next
            fast = fast.next.next
            if low == fast:
                is_cycle = True
                break
        if is_cycle:
            low = head
            while low != fast:
                low = low.next
                fast = fast.next
            return low

环形链表其实还能延伸出一个问题:如果有环求环的长度为多少?

当两个指针首次相遇证明链表有环的时候,让两个指针从相遇点继续循环前进,并统计前进的次数,直到两个指针第2次相遇,此时统计出来的前进次数就是环长。因为指针 low 每次走一步,指针 fast 每次都两步,两人速度差是一步,当两个指针再次相遇时,fast 比 low 多走了整整一圈。因此,环长 = 每一次的速度差 × 前进次数 = 前进次数

3. 基于整数的链表重排序(百度地图)

题目

这道题是在我面试百度地图的时候牛客网上遇到的,但是我忘记题目了所以没在 LeetCode 上找到原题,这里按照回忆来叙述一下。

给定一个链表(head 为头结点)和一个整数 x ,要求将链表按照整数进行重排序,规则是:比整数 x 小的链表节点在左侧,大于等于整数 x 的链表节点在右侧,且两侧的节点各自的相对位置不能改变。

示例:

  • 输入:链表:1→3→7→2→6→5→8,整数 x=5
  • 输出结果:1→3→2→7→6→5→8

解答

这道题我居然做出来了哈哈哈。

解题思路如下:

  • 创建两个链表 l1、l2,并创建两个指向链表头的变量 start1 和 start2;
  • 当头结点 head不为None时,进行以下循环:
    • 如果 head 对应的值小于给定整数 x,那么让链表 l1 的下一个节点指向当前 head,并将 l1 转移到下一节点;如果 head 对应的值不小于给定整数 x,那么让链表 l2 的下一个节点指向当前 head,并将 l2 转移到下一节点
    • 将 head 移到下一节点
  • 排好序后需要将这两个链表连接起来,同时让链表 l2 的的尾部指向 NULL,最后返回 start1 指向的下一节点即为结果链表。

代码如下:

# class ListNode:
#     def __init__(self, x):
#         self.val = x
#         self.next = None
class Solution:
    def resort(self, head, x):
        l1 = ListNode(0)
        l2 = ListNode(0)
        start1 = l1
        start2 = l2
        while head:
            if head.val < x:
                l1.next = head
                l1 = l1.next
            else:
                l2.next = head
                l2 = l2.next
            head = head.next
        l1.next = start2.next
        l2.next = None
        return start1.next

4. 合并链表

合并两个有序链表

题目

将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
在这里插入图片描述
解答

递归解法

对于本题来说,其终止条件是:当两个链表都为空时,表示对链表已合并完成;其递归方法是:判断链表 l1 和 l2 的头结点哪个更小,然后较小结点的 next 指针指向其余结点的合并结果。

代码如下:

def mergeTwoLists(self, l1: ListNode, l2: ListNode) -> ListNode:
    # l1(l2)为空,说明l1(l2)已经遍历完,返回剩下的l2(l1)即可
    if not l1: return l2
    if not l2: return l1
    if l1.val <= l2.val:  # 递归调用
        l1.next = self.mergeTwoLists(l1.next, l2)
        return l1
    else:
        l2.next = self.mergeTwoLists(l1, l2.next)
        return l2

合并K个升序链表

题目

给你一个链表数组,每个链表都已经按升序排列。

请你将所有链表合并到一个升序链表中,返回合并后的链表。
在这里插入图片描述
解答

分而治之

这道题可以利用上面“合并两个有序链表”的思想来进行解题。基本的思想就是链表两两合并。

代码如下:

class Solution:
    def mergeKLists(self, lists: List[ListNode]) -> ListNode:
        if not lists:return 
        n = len(lists)
        return self.merge(lists, 0, n-1)
    def merge(self, lists, left, right):
        if left == right:
            return lists[left]
        mid = left + (right - left) // 2
        l1 = self.merge(lists, left, mid)
        l2 = self.merge(lists, mid+1, right)
        return self.mergeTwoLists(l1, l2)
    def mergeTwoLists(self,l1, l2):
        if not l1:return l2
        if not l2:return l1
        if l1.val < l2.val:
            l1.next = self.mergeTwoLists(l1.next, l2)
            return l1
        else:
            l2.next = self.mergeTwoLists(l1, l2.next)
            return l2

堆解法

堆是一种优先队列,能够让你以任意顺序添加对象,并且随时可以找出最小队列。它的效率高于列表中的 min() 函数。

实际上,Python中并没有独立的堆类型,只有一个包含一些堆操作函数的模块——heapq。heapq模块中有6个函数如下表所示,我们必须用列表来表示堆对象本身。

函数说明
heappush(heap, i)把元素 i 压入堆heap中
heappop(heap)从堆heap中取出最小元素
heapify(heap)让列表heap具备堆的特征
heapreplace(heap, x)从堆heap中取出最小元素,并把元素x压入堆heap中
nlargest(n, heap)找出堆heap中前n个最大的元素
nsmallest(n, heap)找出堆heap中前n个最小的元素

具体解题思路如下:

  • 创建堆(列表)minheap,用来存放每个链表当前遍历到的节点;
  • 从每个链表中找到不为空的头结点,将该结点的值以及对应的索引(Lists中的第几个链表)放入堆minheap中;
  • 当minheap不为空时,进行以下循环:
    • 创建一个用来存放结果链表的头结点start以及移动结点cur_node;
    • 从minheap中取出最小值以及对应的索引 index,将cur_node.next指向该结点;
    • 移动 cur_node 结点并更新索引index对应的链表头结点,如果新的头结点不为空,将其压入堆minheap中;
  • 最后返回 start 指向的结果链表。

代码如下:

# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
	def mergeKLists(self, lists: List[ListNode]) -> ListNode:
	    import heapq
	    minheap = [] 
	    # minheap用来存放每个链表当前遍历到的节点
	    for index, node in enumerate(lists): # index代表这是第几个链表, node代表每个链表的头结点
	        if node:  
	        # 如果头结点不空,把它放入堆minheap中
	            heapq.heappush(minheap, (node.val, index)) 
	            # 将当前头结点的value以及所在的链表索引index入堆
	
	    start = ListNode() # 用来存放结果
	    cur_node = start      
	    while minheap:
	        val, index = heapq.heappop(minheap) 
	        # 堆内value最小的出堆,同时找到链表索引index,方便寻找下一个位置
	        cur_node.next = lists[index]       # 加入到结果链表中
	        cur_node = cur_node.next                # 移动到结果链表的下一个位置
	        lists[index] = lists[index].next # 找到当前链表的下一个元素
	        if lists[index]:      # 如果不为空的话就入堆
	            heapq.heappush(minheap, (lists[index].val, index)) # 下一个元素入堆
	    return start.next

5. 删除链表的倒数第N个节点

题目

给定一个链表,删除链表的倒数第 n 个节点,并且返回链表的头结点。
在这里插入图片描述
在这里插入图片描述
解答

普通解法

由于题目给出的是链表的倒数节点数,因此我们首先遍历一遍链表计算出链表总长度 count ,然后用 l - n 即可得到正数节点数,删除即可。

代码如下:

def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
    start = ListNode(0)
    start.next = head
    count = 0
    mid = head
    while mid:
        count += 1
        mid = mid.next
    mid = start
    for i in range(count-n):
        mid = mid.next
    mid.next = mid.next.next
    return start.next

快慢指针解法

解题思路如下:

  • 设置一个指向头节点的节点start,并设置两个指向start节点的指针 fast、low;
  • 将fast指针移动 n 个节点,然后同时移动 fast、low 指针,直到 fast 指针到达尾节点;
  • 此时 low 指针的下一个节点就是要删除的节点,直接删除就行了。

代码如下:

def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
    start = ListNode(0)
    start.next = head
    fast = start
    low = start
    for i in range(n):
        fast = fast.next
    while fast.next:
        low = low.next
        fast = fast.next
    low.next = low.next.next
    return start.next

回溯解法

调用递归函数直到最后一个节点,然后开始计数,当计数达到 n 时,对前一个函数返回当前节点的下一个节点,即越过当前节点。

代码如下:

def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode:
    if not head: 
        self.count = 0
        return head  
    head.next = self.removeNthFromEnd(head.next, n) # 递归调用
    self.count += 1 # 回溯时进行节点计数
    return head.next if self.count == n else head

6. 反转链表

题目

反转一个单链表。
在这里插入图片描述
解答

双指针解法

解题思路如下:

  • 创建两个指针,初始时一个指针 pre 指向 null ,另一个指针 cur 指向 head;
  • 不断遍历指针 cur:将指针 cur 的 next 指向指针 pre,然后 pre 和 cur 沿着原链表前移一位;
  • 当 cur 为 null 时,此时 pre 就是最后一个节点,也就是反转链表的头节点。

代码如下:

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

迭代解法

迭代法不好理解,递归有两个条件:

  • 终止条件是当前节点或者下一个节点为 null;
  • 在函数内部,改变节点的指向,也就是 head 的下一个节点指向 head。

递归函数中一直不变的一个变量是 cur,它一直指向原单向链表的最后一个节点。

代码如下:

def reverseList(self, head: ListNode) -> ListNode:
    # 递归终止条件是当前节点为空,或者下一个节点为空
    if not head or not head.next:
        return head
    # 这里的cur就是最后一个节点
    cur = self.reverseList(head.next)
    # 如果链表是 1->2->3->4->5,那么此时的cur就是5
    # 而head是4,head的下一个是5,下下一个是空
    # 所以head.next.next 就是5->4
    head.next.next = head
    # 防止链表循环,需要将head.next设置为空
    head.next = None
    return cur

7. 回文链表

题目

请判断一个链表是否为回文链表。
在这里插入图片描述
解答

双指针解法

解题思路如下:

  • 创建两个初始都指向链表表头的指针 slow 和 fast,然后快指针 fast 每次走两步,慢指针 slow 每次走一步;
  • 当快指针 fast 指向的下一个节点或者下下个节点为空时,慢指针刚好位于链表的中点(当链表长度为奇数时,慢指针恰好指向中点;当链表长度为偶数时,慢指针指向链表左半部分的最后一个节点);
  • 截取链表的右半部分(将 slow.next置为None),并对该部分链表进行反转,即节点的 next 指向它的前一个值;
  • 从头开始比较链表的左半部分和翻转后的右半部分,当遇到节点的值不相同时直接返回 False,当右半部分遍历完时返回 True(左半部分此时可能存在一个多出来的中点值)。

代码如下:

def isPalindrome(self, head: ListNode) -> bool:
    if not head:
        return True
    slow = head
    fast = head
    while fast.next and fast.next.next:
        fast = fast.next.next
        slow = slow.next
    pre = None
    cur = slow.next
    slow.next = None
    while cur:
        cur.next, cur, pre = pre, cur.next, cur
    while pre:
        if head.val != pre.val:
            return False
        head = head.next
        pre = pre.next
    return True

8. 相交链表

题目

编写一个程序,找到两个单链表相交的起始节点。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

解答

双指针+链表拼接

这道题我原本想的是从两个链表的尾部开始向前寻找,直到找到两链表的节点不同位置,那么该位置的下一个节点就是我们要求的结果。但是这是个单向链表,好像很难实现在不改变方向的情况下向前寻找的。

看了题解,发现用的是链表拼接的方法来实现的,解题思路如下:

  • 将两个链表进行拼接,设长-短链表的指针为 PA,短-长链表为 PB (分别代表长链表在前和短链表在前的拼接链表),则当 PA 走到长-短链表交接处时,PB 走在长链表中,且与长链表表头距离为长度差;
  • 当 PA 与 PB 相等时,结束循环,返回 PA 即为最终结果;
  • 如果长链表与短链表长度相等,如果两个链表有相交节点,那么在指针在遍历到链表交接处之前就会得到最终结果(因为此时两链表没有长度差);如果两个链表没有相交节点,那么在指针遍历到链表交接处时,会得到两个None,即为最终结果。
    在这里插入图片描述
    代码如下:
def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode:
    PA, PB = headA, headB
    while PA!= PB:
        PA = PA.next if PA else headB
        PB = PB.next if PB else headA
    return PA

9. 排序链表

题目

给你链表的头结点 head ,请将其按升序排列并返回排序后的链表 。

在这里插入图片描述
在这里插入图片描述

解答

归并排序解法

解题思路如下:

  • 首先求出给定链表的长度,然后创建一个节点 res,该节点后面接 head 节点;
  • 解体的思路是:将给定链表分块,以块为单位进行合并和排序,初始化块的长度为1,然后将长度为1的块合并为长度为2的块,将长度为2的块合并为长度为4的块…以此类推,当需要合并的块长度不再小于链表长度时,停止合并
  • 合并的方法是:首先找到要合并的两个块的头 h1 和 h2,如果找不到 h2,说明不存在要合并的第二个块,直接结束内循环;如果存在 h2,那我们求出 h1 和 h2 的长度(h2的长度不一定为块的当前合并长度)
  • 接下来我们对两个块进行排序合并,小值在前,大值在后,当两个块中有一个遍历完了,我们停止排序,直接将没有遍历完的另一个块单元直接接到合并链表的后面;
  • 继续合并其他的块单元,当所有的块单元合并完了之后,我们将要合并的块单元大小乘2,继续按照步骤③④的方法来合并更大的块,直到需要合并的块长度不再小于链表长度;
  • 最终返回 res.next

代码如下:

# class ListNode:
#     def __init__(self, val=0, next=None):
#         self.val = val
#         self.next = next
class Solution:
	def sortList(self, head: ListNode) -> ListNode:
	    h = head
	    length = 0
	    while h: 
	        h = h.next
	        length += 1  # 求出链表的长度
	    res = ListNode()
	    res.next = head
	    intv = 1
	    while intv < length:
	        pre = res
	        h = res.next
	        while h:
	            h1 = h  # 先找第一个块
	            i = intv
	            while i and h: 
	                h = h.next
	                i -= 1
	            if i: break  # 如果第一个块还没达到长度就已经结束了,直接跳出循环
	            h2 = h  # 再找第二个块
	            i = intv
	            while i and h: 
	                h = h.next
	                i -= 1
	            c1 = intv  # 块1的长度
	            c2 = intv - i  # 块2的长度
	            while c1 and c2:  # 合并块1块2
	                if h1.val < h2.val: 
	                    pre.next = h1
	                    h1 = h1.next
	                    c1 -= 1
	                else: 
	                    pre.next = h2
	                    h2 = h2.next
	                    c2 -= 1
	                pre = pre.next
	            pre.next = h1 if c1 else h2  # 将剩下未合并的块接入
	            while c1 > 0 or c2 > 0: 
	                pre = pre.next
	                c1 -= 1
	                c2 -= 1
	            pre.next = h
	        intv *= 2
	    return res.next

四、字符串

1. 无重复字符的最长子串

题目

给定一个字符串,请你找出其中不含有重复字符的最长子串的长度。
在这里插入图片描述

解答

队列解法

这道题用队列的思想来解决问题。队列有什么特点?先进先出(FIFO),即先进入队列的数据会先出来。

解题思路如下:

  • 创建数组 queue 来表示队列,创建数组 length 来存放不含有重复字符的子串长度;
  • 遍历字符串中的字符:
    • 如果字符不在 queue 中,说明该字符与 queue 中的字符不存在重复;
    • 如果字符在 queue 中,说明该字符与 queue 中的某个字符重复,先将当前队列 queue 长度保存至数组 length 中,然后以先入先出的规则逐渐从队列 queue 中取出元素,直到取出的元素与当前遍历字符相同;
    • 将当前遍历字符放入队列 queue 中,继续遍历下一字符;
  • 遍历结束后,将当前队列 queue 的长度保存至数组 length 中,然从保存的数组 length 中选取最大值即为结果。

注意,我们这里牺牲了空间来换取时间,实际上可以不用数组 length 的,只用一个变量,每次遍历到重复字符的时候取当前数组 queue 的长度和变量中的最大值来更新变量,但是这样比使用数组存放子串长度最后取最大值占用的时间格外多,所以这里使用了数组 length 来以空间换取时间。

代码如下:

def lengthOfLongestSubstring(self, s: str) -> int:
    length = []
    queue = []
    for i in s:
        if i not in queue:
            queue.append(i)
        else:
            length.append(len(queue))
            while queue[0] != i:
                queue.pop(0)
            queue.pop(0)
            queue.append(i)
    length.append(len(queue))
    return max(length)

2. 最长回文子串

题目

给你一个字符串 s,找到 s 中最长的回文子串。
在这里插入图片描述
解答

双指针中心扩散法

中心扩散法寻找回文串的思想是:从每一个位置出发,向两边扩散即可,遇到不是回文的时候结束。

解题思路如下:

  • 判断所给字符串长度是否小于2,小于的话直接返回原字符串;
  • 以某一字符为中心,利用双指针 left 和 right 分别向左向右寻找与中心字符相同的字符;
  • 将双指针分别向左和向右移动,观察左右指针指向的字符是否对应相等,不相等或越界时停止移动;
  • 如果当前回文子串的长度大于已经找到的最大回文串长度,更新最终作为结果返回的变量。

代码如下:

def longestPalindrome(self, s: str) -> str:
    size = len(s)
    if size < 2:
        return s
    max_len = 0
    for i in range(size):
        left = i
        right = i
        while left > 0 and s[left-1] == s[i]:
            left -= 1
        while right < size - 1 and s[right + 1] == s[i]:
            right += 1
        while left >= 0 and right < size and s[left] == s[right]:
            left -= 1
            right += 1 
        substr = s[left+1: right]
        if len(substr) > max_len:
            max_len = len(substr)
            lstr = substr
    return lstr

3. Z字形变换

题目

将一个给定字符串根据给定的行数,以从上往下、从左到右进行 Z 字形排列。

比如输入字符串为 “LEETCODEISHIRING” 行数为 3 时,排列如下:

在这里插入图片描述
之后,你的输出需要从左往右逐行读取,产生出一个新的字符串,比如:“LCIRETOESIIGEDHN”。

请你实现这个将字符串进行指定行数变换的函数。
在这里插入图片描述
解答

这道题的关键在于,原字符串并不是每次都按照从上往下的顺序填充数据,而是一种回旋填充。

解题思路如下:

  • 创建一个包含 numRows 个空字符串的数组,以便将来按 Z 字顺序进行填充;
  • 创建变量 z 表示要将当前字符填充在第几行,初始值为0;
  • 创建变量 flag,表示填充方向:当填充到第0行时,flag=1,代表正向;当填充到最后一行时,flag=-1,代表负向
  • 考虑到可能会出现一行z变换的情况,在将z进行加减的时候先判断加减后是否超过列表索引

代码如下:

def convert(self, s: str, numRows: int) -> str:
    if numRows == 1:
        return s
    result = [""]*numRows
    z = 0
    for x in s:
        if z == 0:
            flag = 1
        elif z == numRows - 1:
            flag = -1
        result[z] += x
        z += flag
    return "".join(result)

4. 正则表达式匹配

题目

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 .*的正则表达式匹配。
注意:

  • .匹配任意单个字符
  • *匹配零个或多个前面的那一个元素

所谓匹配,是要涵盖整个字符串 s 的,而不是部分字符串。

说明:

  • s 可能为空,且只包含从 a-z 的小写字母。
  • p 可能为空,且只包含从 a-z 的小写字母,以及字符.*
  • 保证每次出现字符*时,前面都匹配到有效的字符。

在这里插入图片描述
在这里插入图片描述
解答

动态规划解法

这道题的关键点有如下几点:

  • p 的第一个字符一定不为 *,因为 * 匹配零个或多个前面那一个元素;
  • 如果 p 的第二个字符不为 * ,那么 p 的第一个字符必须与 s 的第一个字符相匹配,要么为 . 要么为相同字符;
  • 如果 p 的第二个字符为 * ,那么可能匹配 s 中的0个字符,也可能匹配多个字符。

我们创建了一个递归函数 build 来解决问题,递归函数接收两个参数:s 和 p,用于判断当前给定的 s 和 p 是否匹配。递归结束的条件是字符规律 p 为空。我们创建一个字典 self.memory 用来存放已经遍历过的 (s, p) 对(键),对应的值为 (s, p) 对的匹配结果(True 或 False)。

在递归函数中:

  • 我们首先判断是否满足递归结束条件,如果不满足再判断当前 (s, p) 对是否已经遍历过;
  • 如果上述两个条件都不满足,我们将“字符串 s 的长度是否大于0并且 p 的第一个字符是否与 s 的第一个字符相匹配”的布尔结果赋给变量 first_match;
  • 然后我们先处理字符串匹配 * 的情况*:要匹配 *,那么当前字符规律 p 的长度肯定不小于2并且第二个字符肯定是 *。因为 * 表示匹配零个或多个字符,那么要么 s 会和 p[2:] 匹配,要么 s[1:] 会和 p 匹配,调用递归函数并将匹配结果赋给变量 result;
  • 如果当前字符规律 p 的长度小于2或者第二个字符不是 *,那么如果匹配的话 s 的首字符肯定与 p的首字符相同(即要求 first_match 为真)并且 s[1:] 会和 p[1:] 匹配,调用递归函数并将匹配结果赋给变量 result;
  • 之后将结果存入字典 self.memory 中并返回。

代码如下:

class Solution:
    def isMatch(self, s: str, p: str) -> bool:
        if not p:  # 结束条件
            return not s
        
        self.memory = {}  # 记录处理过的(s-p)对
        self.build(s, p)  # 构建缓存
        return self.memory[(s, p)]

    def build(self, s, p):
        if not p:  # 结束条件
            return not s

        # 查缓存
        if (s, p) in self.memory:
            return self.memory[(s, p)]

        first_match = (len(s) > 0) and p[0] in {s[0], '.'}

        # 先处理 `*`
        if len(p) >=2 and p[1] == '*':
            # 匹配0个或多个
            result = self.build(s, p[2:]) or (first_match and self.build(s[1:], p))
        else:
            # 处理 s[0]或`.` ,匹配一个
            result = first_match and self.build(s[1:], p[1:])

        self.memory[(s, p)] = result
        return result

5. 有效的括号

题目

给定一个只包括 ‘(’,’)’,’{’,’}’,’[’,’]’ 的字符串,判断字符串是否有效。

有效字符串需满足:

  • 左括号必须用相同类型的右括号闭合。
  • 左括号必须以正确的顺序闭合。

注意空字符串可被认为是有效字符串。

在这里插入图片描述
解答

递归解法

观察到在字符串不为空时,如果该字符串有效,那么一定存在一对相匹配的括号紧紧相邻。利用该方法,每一次从字符串中去掉一对括号,再将剩下的字符串拼接在一起,重新寻找相邻括号,直到 s 为空或找不到相邻括号。

代码如下:

def isValid(self, s: str) -> bool:
    if not s:
        return True
    if len(s)%2 == 1:
        return False
    l = [['(', ')'], ['{', '}'], ['[', ']']]
    for i in range(len(s)-1):
        match = [s[i], s[i+1]]
        if match in l:
            break
    else:
        return False
    snew = s[:i] + s[i+2:]
    return self.isValid(snew)

栈解法

解题思路如下:

  • 建立一个字典d1,键为左括号、值为与之相匹配的右括号;
  • 遍历字符串 s :如果当前字符是左括号,入栈;如果当前字符是右括号,将栈顶的第一个元素出栈,观察左右括号是否匹配,如果匹配继续遍历,如果不匹配提前结束遍历返回 False;
  • 为了防止出现首字符是右括号而导致空栈 pop 错误这一情况,在栈底加了 “?” 字符 。

代码如下:

def isValid(self, s: str) -> bool:
    if not s:
        return True
    if len(s)%2 == 1:
        return False   
    d1 = {"(":")", "{":"}", "[":"]", "?":"?"}
    result = ["?"]
    for x in s:
        if x in d1:
            result.append(x)
        else:
            m = result.pop()
            if d1[m] != x:
                return False
    if result[-1] == "?":
        return True
    else:
        return False

6. 删除无效的括号

题目

删除最小数量的无效括号,使得输入的字符串有效,返回所有可能的结果。

说明: 输入可能包含了除 ( 和 ) 以外的字符。
在这里插入图片描述
解答

广度优先遍历(BFS)解法

这道题很巧妙地用了一个 Python 内置函数 filter,该函数的功能如下:

  • filter() 函数用于过滤序列,过滤掉不符合条件的元素,返回一个迭代器对象,如果要转换为列表,可以使用 list() 来转换。
  • 该接收两个参数,第一个为函数,第二个为序列,序列的每个元素作为参数传递给函数进行判断,然后返回 True 或 False,最后将返回 True 的元素放到新列表中。

因此,我们设计了一个用来判断函数是否有效的方法:

  • 初始化一个变量值为 0,然后遍历字符串中的每个字符:如果该字符为 “(” 则变量值加1,如果该字符为 “)” 则变量值减1。
  • 如果变量值出现负数了,说明到目前为止该字符串 “)” 的数量已经超过 “(”,字符串肯定无效,直接返回 Fsale;
  • 最后,根据变量值是否等于0来判断整体字符串是否有效。

因为题目中只要求“删除最小数量的无效括号”,因此存在可能删除的数量为0。因此,我们首先将原字符串 s 放在集合中,利用 filter 函数判断字符串是否有效:如果原字符串 s 有效,那么返回的结果不为空,直接结束循环返回列表结果;否则,将集合 level 里的每个字符串逐渐删除一个 “(” 或 “)” 字符再重新放入集合 level 中,然后判断是否存在有效字符串。

注意:

  • 放入集合 level 中可以避免重复字符串;
  • 没必要删除 “(” 和 “)” 以外的字符,没有意义。

代码如下:

def removeInvalidParentheses(self, s: str) -> List[str]:
    def isvalid(string):  # 判断括号串是否合法
        l_minus_r = 0
        for c in string:
            if c == '(':
                l_minus_r += 1
            elif c == ')':
                l_minus_r -= 1
                if l_minus_r < 0:
                    return False
        return l_minus_r == 0

    level = {s}
    while True:  # BFS
        valid = list(filter(isvalid, level))
        if valid:
            return valid
        level = {s[:i] + s[i + 1:] for s in level for i in range(len(s)) if s[i] in '()'}

7. 最长有效括号

题目

给定一个只包含 ‘(’ 和 ‘)’ 的字符串,找出最长的包含有效括号的子串的长度。
在这里插入图片描述

解答

栈解法

解题思路如下:

  • 设置栈 stack 用来存放 “(” 字符的索引位置,初始栈中有个元素 -1;
  • 遍历字符串 s ,当字符为 “(” 时,将对应的索引存入栈stack中;
  • 当字符为 “)” 时,去除栈stack的最后一个元素,判断当前栈是否为空:如果为空说明右括号太多了,不会计算当前的有效括号长度,并将当前索引压入栈中作为新的有效括号起始点;不为空说明当前括号长度仍然有效,更新最大有效括号长度。

代码如下:

def longestValidParentheses(self, s: str) -> int:
    if not s:
        return 0
    res = 0
    stack = [-1]
    for i in range(len(s)):
        if s[i] == "(":
            stack.append(i)
        else:
            stack.pop()
            if not stack:
                stack.append(i)
            else:
                res = max(res,i - stack[-1])
    return res

8. 单词拆分系列

单词拆分 I

题目

给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

  • 拆分时可以重复使用字典中的单词。
  • 你可以假设字典中没有重复的单词。

在这里插入图片描述

解答

动态规划解法

解题思路如下:

  • 初始化 dp=[False, ⋯, False],长度为 n+1,其中 n 为字符串长度,dp[i] 表示字符串 s 的前 i 位是否可以用 wordDict 中的单词表示;
  • 初始化 dp[0]=True,代表空字符可以用 wordDict 表示;
  • 遍历字符串的所有子串,遍历开始索引为 i,遍历区间 [0,n);结束索引为 j,遍历区间 [i+1,n+1);
  • 若 dp[i]=True 且 s[i,⋯,j) 在 wordlist 中,则dp[j]=True(dp[i]=True,说明 s 的前 i 位可以用 wordDict 表示,又因为 s[i,⋯,j) 出现在 wordDict 中,说明 s 的前 j 位可以用 wordDict 表示);
  • 返回 dp[-1],即 dp 的前 n 位是否能够用 wordDict 表示。

注意:当 dp[j] 等于 True时,我们要继续遍历内循环,因为 wordDict 中可能有字符串包含另一字符串。

代码如下:

def wordBreak(self, s: str, wordDict: List[str]) -> bool:
    n = len(s)
    dp = [True] + [False]*n
    for i in range(n):
        for j in range(i+1,n+1):
            if dp[i] and s[i:j] in wordDict:
                dp[j]=True
        if dp[-1]:
            return True
    return False

单词拆分 II

题目

给定一个非空字符串 s 和一个包含非空单词的列表 wordDict,在字符串中增加空格来构建一个句子,使得句子中所有的单词都在词典中。返回所有这些可能的句子。

说明:

  • 拆分时可以重复使用字典中的单词。
  • 你可以假设字典中没有重复的单词。

在这里插入图片描述
在这里插入图片描述
解答

**递归解法 **

与上一道题不同的是,这道题在判定“ s 是否可以被空格拆分为一个或多个在字典中出现的单词”的基础之上,要求你返回所有可能的结果。

解题思路如下:

  • 构造一个递归函数,函数接收两个参数:参数 pos 用来表示接下来要查询的字符子串的起始位置,参数 temp 是一个存放 pos 位置之前的子串所划分的单词的列表;
  • 当起始位置 pos 等于原字符串长度时,说明已经遍历完了,且 s 可以被空格拆分为一个或多个在字典中出现的单词,那我们将 temp 里面的单词用空格拼接起来放入结果数组中即可;
  • 否则我们遍历以 pos 为起始位置的字符子串,并判断该子串是否在字典 wordDict 中:如果不在,我们遍历下一子串;如果在,我们将该子串加入 temp 中作为接下来递归的参数 temp,同时传递子串的结束下一位置作为新的 pos 参数。

代码如下:

def wordBreak(self, s: str, wordDict: List[str]) -> List[str]:
    def dfs(temp,pos):
        if pos == len(s):
            res.append(" ".join(temp))
            return
        for i in range(pos+1,len(s)+1):
            if s[pos:i] in wordDict:
                dfs(temp+[s[pos:i]],i)
    
    res = []
    dfs([],0)
    return res

9. 电话号码的字母组合

题目

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。
在这里插入图片描述
说明:

  • 尽管上面的答案是按字典序排列的,但是你可以任意选择答案输出的顺序。

解答

递归解法

当题目中出现“所有组合”等相关字眼时,我们首先需要想到用递归方法。

解题思路如下:

  • 定义一个函数 fun(com, rest),其中参数com表示当前已遍历数字的字母组合,参数rest表示剩余的未遍历数字;
  • 当剩余未遍历数字不为空时,取出rest中第一个数字rest[0],将该数字所对应的字母分别加入com中,并执行递归 fun(com, rest[1:]),直到rest为空;
  • 为了将数字能够映射到字母上,我们还要创建一个字典来存放数字对应的字母列表。

代码如下:

def letterCombinations(self, digits: str) -> List[str]:
    if not digits:
        return
    phone = {'2':['a','b','c'],
             '3':['d','e','f'],
             '4':['g','h','i'],
             '5':['j','k','l'],
             '6':['m','n','o'],
             '7':['p','q','r','s'],
             '8':['t','u','v'],
             '9':['w','x','y','z']}
    def fun(com, rest):
        if len(rest) == 0:
            result.append(com)
        else:
            for letter in phone[rest[0]]:
                fun(com + letter, rest[1:])
    result = []
    fun("", digits)
    return result

队列解法

另一种解法是队列解法,具体思路如下:

  • 将digits的第一个数字对应的字母放入队列;
  • 将队列中的每一个字母分别出队,与下一个数字对应的每个字母分别组合,然后重新进入队列;
  • 重复上述步骤,直到所有的数字都被遍历完。

代码如下:

def letterCombinations(self, digits: str) -> List[str]:
    if not digits:
        return
    phone = {'2':['a','b','c'],
             '3':['d','e','f'],
             '4':['g','h','i'],
             '5':['j','k','l'],
             '6':['m','n','o'],
             '7':['p','q','r','s'],
             '8':['t','u','v'],
             '9':['w','x','y','z']}
    result = [""]
    for digit in digits:
        for i in range(len(result)):
            mid = result.pop(0)
            for letter in phone[digit]:
                result.append(mid + letter)
    return result

10. 最小覆盖子串

题目

给你一个字符串 S、一个字符串 T 。请你设计一种算法,可以在 O(n) 的时间复杂度内,从字符串 S 里面找出:包含 T 所有字符的最小子串。
在这里插入图片描述
提示:

  • 如果 S 中不存这样的子串,则返回空字符串 “”
  • 如果 S 中存在这样的子串,我们保证它是唯一的答案

解答

滑动窗口解法

解题思路如下:

  • 用 left、right 表示滑动窗口的左边界和右边界,通过改变 left、right 来扩展和收缩滑动窗口,可以想象成一个窗口在字符串上游走,当这个窗口包含的元素满足条件,即包含字符串 t 的所有元素,记录下这个滑动窗口的长度 right-left+1,这些长度中的最小值就是要求的结果。

具体如下:

  • 不断增加 right 使滑动窗口增大,直到窗口包含了字符串 t 的所有元素;
  • 不断增加 left 使滑动窗口缩小,因为是要求最小字串,所以将不必要的元素排除在外,使长度减小,直到碰到一个必须包含的元素,这个时候不能再扔了,再扔就不满足条件了,记录此时滑动窗口的长度,并保存最小值;
  • 让 left 再增加一个位置,这个时候滑动窗口肯定不满足条件了,那么继续从第一步开始执行,寻找新的满足条件的滑动窗口,如此反复,直到超出了字符串 S 范围。

代码如下:

def minWindow(self, s: str, t: str) -> str:
    import collections
    dict_t =collections.defaultdict(int)
    for i in t:
        dict_t[i] += 1
    all_need = len(t)
    left = 0
    result = (0, float('inf'))
    for right, s_i in enumerate(s):
        if dict_t[s_i] > 0:
            all_need -= 1
        dict_t[s_i] -= 1
        if all_need == 0:
            while True:
                cur = s[left]
                if dict_t[cur] == 0:
                    break
                dict_t[cur] += 1 
                left += 1
            if right-left < result[1]-result[0]:
                result = (left, right)
            dict_t[s[left]] += 1
            all_need += 1
            left += 1
    return "" if result[1]-result[0] >= len(s) else s[result[0]:result[1]+1]

11. 字符串解码

题目

给定一个经过编码的字符串,返回它解码后的字符串。

编码规则为: k[encoded_string],表示其中方括号内部的 encoded_string 正好重复 k 次。注意 k 保证为正整数。

你可以认为输入字符串总是有效的;输入字符串中没有额外的空格,且输入的方括号总是符合格式要求的。

此外,你可以认为原始数据不包含数字,所有的数字只表示重复的次数 k ,例如不会出现像 3a 或 2[4] 的输入。
在这里插入图片描述

解答

栈解法

解题思路如下:

  • 构建辅助栈 stack, 遍历字符串 s 中每个字符 c:
    • 当 c 为数字时,将数字字符转化为数字 multi,用于后续倍数计算;
    • 当 c 为字母时,在 res 尾部添加该字母;
    • 当 c 为 [ 时,将当前 multi 和 res 入栈,并分别置空置 0:
      • 记录此 [ 前的临时结果 res 至栈,用于发现对应 ] 后的拼接操作;
      • 记录此 [ 前的倍数 multi 至栈,用于发现对应 ] 后,获取 multi × […] 字符串。
      • 进入到新 [ 后,res 和 multi 重新记录。
    • 当 c 为 ] 时,stack 出栈,拼接字符串 res = last_res + cur_multi * res,其中:
      • last_res是上个 [ 到当前 [ 的字符串,即出栈中的字符串结果,例如 “3[a2[c]]” 中的 a;
      • cur_multi是当前 [ 到 ] 内字符串的重复倍数,即出栈中的数字结果,例如 “3[a2[c]]” 中的 2。
  • 返回字符串 res。

代码如下:

def decodeString(self, s: str) -> str:
    stack, res, multi = [], "", 0
    for c in s:
        if c == '[':
            stack.append([multi, res])
            res, multi = "", 0
        elif c == ']':
            cur_multi, last_res = stack.pop()
            res = last_res + cur_multi * res
        elif '0' <= c <= '9':
            multi = multi*10 + int(c)     # 为了防止两位数字符
        else:
            res += c
    return res

递归解法

解题思路如下:

  • 当 s[i] == ‘]’ 时,返回当前括号内记录的 res 字符串与 ] 的索引 i (更新上层递归指针位置);
  • 当 s[i] == ‘[’ 时,开启新一层递归,记录此 […] 内字符串 tmp 和递归后的最新索引 i,并执行 res + multi * tmp 拼接字符串。
  • 遍历完毕后返回 res。

代码如下:

def decodeString(self, s: str) -> str:
    def dfs(s, i):
        res, multi = "", 0
        while i < len(s):
            if '0' <= s[i] <= '9':
                multi = multi * 10 + int(s[i])
            elif s[i] == '[':
                i, tmp = dfs(s, i + 1)
                res += multi * tmp
                multi = 0
            elif s[i] == ']':
                return i, res
            else:
                res += s[i]
            i += 1
        return res
    return dfs(s,0)

12. 找到字符串中所有字母异位词

题目

给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。

字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。

说明:

  • 字母异位词指字母相同,但排列不同的字符串。
  • 不考虑答案输出的顺序。

在这里插入图片描述
在这里插入图片描述

解答

滑动窗口

解题思路如下:

  • 统计给定字符串 p 中每个元素的个数;
  • 然后从头开始逐渐遍历字符串 s 中长度等于字符串 p 的子字符串,判断该子字符串中元素的个数是否与 p 中的对应相等;
  • 如果相等将子字符串开始索引放入结果数组中,最终返回结果数组。

代码如下:

from collections import Counter
def findAnagrams(self, s: str, p: str) -> List[int]:
    lens = len(s)
    lenp = len(p)
    if not s or lens<lenp:
        return []
    dp = Counter(p)
    result = []
    for i in range(lens-lenp+1):                   
        if Counter(s[i:lenp+i]) == dp:
            result.append(i)
    return result

上述代码的运行时间很长,我对其进行了改进。仔细研究,我发现如果找到了一个满足要求的字母异位词以后,那我下一次滑动窗口时只需判断子字符串对应的最后一个字符是否与刚刚滑过的字符相等即可。因此,我引入了一个布尔变量 flag 并初始化为 False,当遇见满足要求的子字符串时将其置为 True,在下一次滑动时直接判断两个字符是否相等即可。

代码如下:

from collections import Counter
def findAnagrams(self, s: str, p: str) -> List[int]:
    lens = len(s)
    lenp = len(p)
    if not s or lens<lenp:
        return []
    dp = Counter(p)
    result = []
    flag = False
    for i in range(lens-lenp+1):
        if flag and s[i-1] == s[lenp+i-1]:
            result.append(i)
            continue
        else:
            flag = False
            
        if Counter(s[i:lenp+i]) == dp:
            result.append(i)
            flag = True
        else:
            flag = False
    return result

题解中提出了一种与题目“最小覆盖子串”类似的解法:

  • 首先将字符串 p 中的字符个数统计到字典 dic 中,并计算字符串 p 的长度作为满足字母异位词还需要的字符串长度 count;
  • 然后开始遍历字符串 s 的字符,如果该字符在 dic 中存在,将字典对应值减1,并判断此时该字符在字典中对应的值:如果值小于0,表明不需要这个字符,不能更新 count,否则将 count 值减1;
  • 如果字符串 s 从开始到位置索引组成的子字符串长度已经超过了字符串 p,那么我们判断上一个被舍弃的字符是否在字典中,如果在,我们将字典对应的值加1,并再次判断此时该字符在字典中对应的值:如果值大于0,说明需要该字符,count 的值加1;
  • 最后判断 count 的值是否为0,如果为0将对应的位置索引加入到结果数组中,最终返回最后的结果数组。

代码如下:

def findAnagrams(self, s: str, p: str) -> List[int]:
    res = []
    if len(p) > len(s):     
        return res
    dic = {}
    m = len(p)
    count = m
    for i in range(m):
        dic[p[i]] = dic.get(p[i], 0) +1   #记录所需元素及个数
    i = 0
    while i < len(s):
        if s[i] in dic:              
            dic[s[i]] -= 1               #如果s[i]在dic中存在,更新值
            if dic[s[i]] >= 0:           #如果dic[s[i]]<0,表明不需要这个数,不能更新count
                count -= 1
        if i >= m:                       # 当i>=m, 弹出失效的字符,保证长度相等
            if s[i-m] in dic:
                dic[s[i-m]] += 1        #s[i-m]未必一定在dic中,需要判断
                if dic[s[i-m]] > 0:     #需要的数被弹出,更新count
                    count += 1
        if count == 0:
            res.append(i-m+1)
        i += 1
    return res

13. 编辑距离

题目

给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。

你可以对一个单词进行如下三种操作:

  • 插入一个字符
  • 删除一个字符
  • 替换一个字符

在这里插入图片描述
解答

动态规划解法

解题思路如下:

  • dp[ i ][ j ] 代表 word1 到 i 位置的前面所有元素转换成 word2 到 j 位置的前面所有元素所需要最少步数;
  • 当 word1[ i ] == word2[ j ],说明 word1 的位置 i 元素和 word2 的位置 j 元素相同,无需进行任何操作,dp[ i ][ j ] = dp[ i-1 ] [ j-1 ];
  • 当 word1[ i ] != word2[ j ],dp[ i ][ j ] = min(dp[ i-1 ][ j-1 ], dp[ i-1 ][ j ], dp[ i ][ j-1 ]) + 1,其中 dp[ i-1 ][ j-1 ] 表示替换操作,dp[ i-1 ][ j ] 表示删除操作,dp[ i ][ j-1 ] 表示插入操作。

注意,针对第一行,第一列要单独考虑,我们引入 ‘’ 下图所示:
在这里插入图片描述
第一行,代表 word1 为空,变成 word2 当前元素位置时需要的最少步数,即插入操作;第一列,代表 word1 由当前元素位置 变成 word2(为空)时需要的最少步数,即删除操作。

代码如下:

def minDistance(self, word1: str, word2: str) -> int:
    n1 = len(word1)
    n2 = len(word2)
    dp = [[0] * (n2 + 1) for _ in range(n1 + 1)]  # 生成一个如思路所示的矩阵数组
    for j in range(1, n2 + 1):  # 第一行
        dp[0][j] = dp[0][j-1] + 1
    for i in range(1, n1 + 1):  # 第一列
        dp[i][0] = dp[i-1][0] + 1
    for i in range(1, n1 + 1):
        for j in range(1, n2 + 1):
            if word1[i-1] == word2[j-1]:
                dp[i][j] = dp[i-1][j-1]
            else:
                dp[i][j] = min(dp[i][j-1], dp[i-1][j], dp[i-1][j-1] ) + 1    
    return dp[-1][-1]

14. 解码方法

题目

一条包含字母 A-Z 的消息通过以下映射进行了 编码 :
在这里插入图片描述
要 解码 已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,“11106” 可以映射为:

  • “AAJF” ,将消息分组为 (1 1 10 6)
  • “KJF” ,将消息分组为 (11 10 6)

注意,消息不能分组为 (1 11 06) ,因为 “06” 不能映射为 “F” ,这是由于 “6” 和 “06” 在映射中并不等价。

给你一个只含数字的 非空 字符串 s ,请计算并返回 解码 方法的 总数 。

题目数据保证答案肯定是一个 32 位 的整数。

在这里插入图片描述
解答

动态规划解法

解题思路如下:

  • dp[i] 为字符串 s 的前 i+1 个字符有多少种解码方法,则 dp[-1] 就是最终要求的结果。
  • 对 2 个字符,可能解码成 0 种、1 种、2 种情况。所以需要分类讨论这2个字符什么时候解码成 0 种,什么时候解码成 1种,什么时候解码成 2种。设置包含 1-26 字符类数字的集合以判断2个字符连起来是否可解码以及分开时是否可以分别解码,分类讨论具体如下:
    • s[i] 不在合法集合中,也就是 s[i] 等于 0 的情况:
      • s[i - 1] + s[i] = s[i - 1: i + 1] 也不在合法集合中(比如 40):无法解码字符串,直接返回 0;
      • s[i - 1: i + 1] 在合法集合中(比如10):s[i - 1: i + 1] 只有一种解码可能,所以 s[i - 1: i + 1] 的解码是固定的,dp[i] = dp[i - 2] 也就是 到i的解码方案 = 到i-2的解码方案,因为i-1到i的位置是确定的。
    • s[i]在合法集合中:
      • s[i - 1: i + 1] 不在合法集合中(比如 31):s[i - 1: i + 1] 无法解码,s[i] 单独解码,所以 dp[i] = dp[i - 1];
      • s[i - 1: i + 1] 在合法集合中(比如 21):这种时候 s[i - 1: i + 1] 可以解码,也可以拆开分别解码,那么这两种不同的解码会导致之后的整体解码都是不同的。所以 dp[i] = dp[i - 2] + dp[i - 1]。
  • 由于状态转移方程需要 dp[i - 2] ,所以我们初始化的时候要初始化 dp[0] 和 dp[1] , dp[1] 表示两个字符的解码方案,此时有三种可能性,所以也要分类讨论。

代码如下:

def numDecodings(self, s: str) -> int:
    if s[0] == '0': 
        return 0
    if len(s) == 1: 
        return 1

    legalstr = set(str(i) for i in range(1, 27))

    dp = [0] * (len(s))
    dp[0] = 1 
    if s[1] not in legalstr:  # s[1]为0
        dp[1] = 1 if s[:2] in legalstr else 0
    else:
        dp[1] = 2 if s[:2] in legalstr else 1            

    # 因为要用到i-2 所以至少初始化 dp[0] dp[1]
    for i in range(2, len(s)):
        if s[i] not in legalstr:
            if s[i - 1: i + 1] not in legalstr:
                return 0
            else:
                dp[i] = dp[i - 2]
        else:
            if s[i - 1: i + 1] in legalstr:
                dp[i] = dp[i - 1]+dp[i - 2]
            else:
                dp[i] = dp[i - 1]
    return dp[-1]
  • 2
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值