数据结构与算法习题总结——分治与动态规划

本篇为Datawhale编程实践项目的学习笔记,会随着学习进程不断更新,题目都借鉴自网络或书籍,仅用作个人学习。由于水平实在有限,不免产生谬误,欢迎读者多多批评指正。如需要转载请与博主联系,谢谢


分治法

一般来讲,分治法(“分而治之”)是一种解决问题的思路而并非具体的算法,它的核心在于将一个难以解决的复杂问题分解为结构相似又易于解决的小问题,从而各个击破。
分治法的具体步骤:

  1. 分:递归地将问题分解为性质相同的、相互独立的子问题
  2. 治:求解这些规模更小的子问题
  3. 合:将已解决的子问题逐层合并,最终得出原问题的解
    分治法应用需要满足的必要条件:
  4. 能分(问题可以分解为与其自身结构类似又相互独立的子问题)
  5. 能解(分解到一定程度后的子问题可以解决)
  6. 能合(解决后的子问题可以较为容易地合并出原问题的答案,如果满足前两条而不满足第三条则可尝试贪心法或动态回归)

例题:

  1. 最大子序和
    给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
class Solution:
    def maxSubArray(self, nums: List[int]) -> int:
        '''
        # 解法一:动态回归,如果仅维护当前已发现的最大和,空间复杂度还可以优化
        f = [None for i in range(len(nums))]    # 用于存储当前元素之前的最大和
        f[0] = nums[0]                          
        for i in range(1,len(nums)):
            f[i] = max(f[i-1]+nums[i],nums[i])  # 转移方程,判断当前元素加入之前序列还是单独开启新序列更好
        return max(f)
        '''
        # 解法二:分治法,核心思路是一个数组从中间分成两个子数组后,其最大子序和可能存在于左子数组、右子数组或横跨两个子数组。
        # 向下切分过程
        lens = len(nums)
        if lens == 1:                   # 这一步是切分的边界条件,判断切分到单个元素后返回
            return nums[0]   
        # 递归语句          
        left = self.maxSubArray(nums[:lens//2])         # 递归过程,先切分左右子数组到不能再切,后向上回溯求解
        right = self.maxSubArray(nums[lens//2:])
        # 向上回溯过程
        max_l = nums[lens//2-1]             # max_l为左子数组中包含其最右侧元素的最大子序和;max_r为右子数组中包含其最左侧元素的最大子序和
        sums = 0
        for i in range(lens//2-1,-1,-1):
            sums += nums[i]
            max_l = max(sums,max_l)
        max_r = nums[lens//2]
        sums = 0
        for i in range(lens//2,lens,1):
            sums += nums[i]
            max_r = max(sums,max_r)
            
        return max(left,right,max_l+max_r)     # 最后当前数组的最大子序和从 1-左子数组内部最大子序和 2-右子数组内部最大子序和 以及 3-横跨两个子数组的最大子序和 中产生。
  1. Pow(x, n)——快速幂求解
    实现 pow(x, n) ,即计算 x 的 n 次幂。
class Solution:
    def myPow(self, x: float, n: int) -> float:
        '''
        # 解法一:暴力求解,简单但会超时
        result = 1
        for i in range(abs(n)):
            result *= x
        if n < 0: result = 1/result
        return result
        '''
        # 解法二:分治法,核心思路是当我们求x的n次幂时,可以通过对x的n//2次幂求平方(n为偶数时)或对x的n//2次幂求平方再乘一个x(n为奇数时)来简化求解。
        N = abs(n)
        if N == 0:
            return 1.0       # 切分终止条件

        y = self.myPow(x,N//2)   # 递归求解

        temp = y*y if N%2 == 0 else y*y*x     # 计算当前递归层中x^n的值
        return temp if n >= 0 else 1.0 / temp  # 判断n的正负,向上回溯
        '''
        # 解法三:位运算,基本分治思想基础上再引入位运算,对n进行判断和处理
        if x == 0.0: return 0.0
        res = 1
        if n < 0: x, n = 1 / x, -n   # 幂指数小于0时先将x取倒数
        while n:
            if n & 1: res *= x      # n & 1 等同于 n % 2 == 1
            x *= x                  # 底数 x = x ^ 2
            n >>= 1                 # 指数 n = n // 2
        return res  # 初始状态res = 1, x^n = x^n * res, 
        '''
  1. 多数元素
    给定一个大小为 n 的数组,找到其中的多数元素。多数元素是指在数组中出现次数大于 ⌊ n/2 ⌋ 的元素。
    你可以假设数组是非空的,并且给定的数组总是存在多数元素。
class Solution:
    def majorityElement(self, nums: List[int]) -> int:
        '''
        # 解法一:利用哈希表存储元素出现次数,时间复杂度为O(n)
        dic = {}
        for i in range(len(nums)):
            if nums[i] not in dic:
                dic[nums[i]] = 1
            else:
                dic[nums[i]] += 1
        ans = max(zip(dic.values(),dic.keys()))
        return ans[1]
        '''
        # 解法二:分治法,核心思路是数组从中间分为左右两个子数组,如果两个子数组众数相同,则原数组众数等于子数组众数,否则需要比较两个众数在原数组内出现的次数来决定原数组的众数。
        def findnum(nums):
            lens = len(nums)
            if lens == 1:
                return nums[0], 1            # 向下切分边界条件,函数第一个返回值为众数数值,第二个返回值为该数在当前数组中出现的次数
        
            left, lnum = findnum(nums[:lens//2])     # 递归
            right, rnum = findnum(nums[lens//2:])

            if left == right:               # 左右子数组众数相等时
                return left, lnum+rnum
            
            ltotal = sum(1 for i in nums[lens//2:] if i == left) + lnum     # 计算左子数组众数在整个数组中出现的次数
            rtotal = sum(1 for j in nums[:lens//2] if j == right) + rnum    # 计算右...
            
            if ltotal > rtotal:         # 比较哪个是更多的数,作为当前数组的众数
                return left, ltotal
            else:
                return right, rtotal
        return findnum(nums)[0]         

动态规划

动态规划在各类笔试中频繁出现,也是最让人头疼的题型之一。通常动态规划是用来求解包含重复子结构的问题,也就是说当前问题总可以由一个与其结构相似但更易求解的子问题通过简单推导得出。
基本操作可分为以下几步:

  1. 定义状态:动态规划通常要维护一个数组,这个数组中的元素l[i]或l[i][j]等即为我们要处理的状态。最终状态往往是我们要求的问题的解,或者可以直接推导出问题的解。而前一个状态则是解出当前状态的必要条件,以此类推。
  2. 写出状态转移方程:即前后两个状态间转换的算法,整个动态规划的核心操作步骤。
  3. 考虑边界条件和初始状态:初始状态或边界条件往往不能再通过转移方程推导得出,因此要预先进行处理。
  4. 考虑计算顺序:大部分情况下需要从初始状态开始向后计算,关键取决于哪个方向的计算条件已知。
  5. 考虑输出:如果最后状态不是要求的输出,那要考虑如何获得正确的输出(如求状态序列中的最大值等等)。

例题:

  1. 最长回文子串
    给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
class Solution:
    def longestPalindrome(self, s: str) -> str:
        '''
        # 解法一:暴力法,可行但会超时,时间复杂度O(N^3)
        if len(list(s)) <= 1:
            return s
        def IfPalindrome(l:list):
            lens = len(l)
            for i in range(lens//2):
                if l[i] != l[-i-1]:
                    return False
            return True
        s = list(s)
        ans = []
        for i in range(len(s)-1):
            if len(s[i:]) <= len(ans):
                break
            for j in range(len(s),i,-1):
                if len(s[i:j]) <= len(ans):
                    break
                if IfPalindrome(s[i:j]):
                        ans = s[i:j]
        return ''.join(ans)
        '''
        # 解法二:动态规划(利用了之前已找出的短回文串来判断在其基础上的长回文串),这里写得很乱,建议参考leetcode大牛们的题解,时间复杂度O(N^2),但空间复杂度也为O(N^2)
        # 核心思想就是维护一个二维数组,每个元素记录以当前横纵索引所确定的子串是不是回文串,如果其右上角的元素是子串,则当前元素仅需判断第一个和最后一个字符是否相等即可
        s = list(s)
        lens = len(s)
        if lens <= 1:
            return ''.join(s)
        if lens == 2 and s[0] == s[1]:
            return ''.join(s)
        m_start = 0
        m_end = 1
        d = [[None for i in range(lens-1)] for j in range(lens-1)]
        for i in range(lens-2,-1,-1):
            for j in range(2,lens+1):
                if j-i<=1:
                    d[j-2][i] = True   # 为后续处理方便,假设字符串长度小于等于1的情况都看作回文
                    continue
                if j-i==2 and s[i]==s[j-1]:
                    d[j-2][i] = True
                    if j-i > m_end-m_start:
                        m_start = i
                        m_end = j
                    continue
                if j-i>=3 and d[j-3][i+1]==True and s[i]==s[j-1]:
                    d[j-2][i] = True
                    if j-i > m_end-m_start:
                        m_start = i
                        m_end = j
                else:
                    d[j-2][i] = False
        return ''.join(s[m_start:m_end])
  1. 最长回文子序列
    给定一个字符串 s ,找到其中最长的回文子序列,并返回该序列的长度。可以假设 s 的最大长度为 1000 。(注意子序列与上一题的子串不同,它不要求序列的元素相连续,但前后顺序不能改变)。如"bbbab"的最长回文子序列为"bbbb"。
class Solution:
    def longestPalindromeSubseq(self, s: str) -> int:
        # 动态规划的核心思路仍然是维护一个二维数组,行索引代表回文序列的起点,列索引为终点
        # 求序列s[i:j]中最长回文子序列长度时,若s[i]等于s[j],则为s[i+1:j-1]子序列长度+2;否则可能为s[i:j-1]或者s[i+1:j]的子序列长度中更长的一个
        N = len(s)
        dp = [[0]*N for _ in range(N)]  # 建立二维数组

        for i in range(N):
            dp[i][i] = 1       # 对角线上的点代表单个元素组成的序列,最大回文子序列长度都为1,对角线左下方区域不合法均初始化为0
        
        for i in range(N-1, -1, -1):
            for j in range(i+1, N):      # 状态转移部分,注意遍历的顺序是从左向右,从下往上,第一个被遍历元素的左侧的下方都是1
                if s[i] == s[j]:
                    dp[i][j] = dp[i+1][j-1] + 2  
                else:
                    dp[i][j] = max(dp[i][j-1], dp[i+1][j])    
        return dp[0][N-1]   # 最终所要求的是最右上角的元素
  1. 打家劫舍I
    你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。
    给定一个代表每个房屋存放金额的非负整数数组,计算你不触动警报装置的情况下,一夜之内能够偷窃到的最高金额。
class Solution:
    def rob(self, nums: List[int]) -> int:
        # 动态规划的核心思路就是当前状态最大值等于 1、前一个状态最大值 或者 2、倒数第二个状态最大值加当前值
        if nums == []:
            return 0
        if len(nums) == 1:
            return nums[0]
        if len(nums) == 2:
            return max(nums[0],nums[1])
        arr = [0 for x in range(len(nums))]      # arr记录选择了索引位置情况下最大累积值
        arr[0] = nums[0]
        arr[1] = max(nums[0],nums[1])            # 还可以进一步优化空间,仅记录前两个位置的最大累计值而不必维护数组
        for i in range(2,len(nums)):             
            arr[i] = arr[i-1] if arr[i-1] > arr[i-2]+nums[i] else arr[i-2]+nums[i]    # 状态转移方程
        return arr[i]
  1. 打家劫舍II
    在前一题的基础上增加一个要求:所有房屋围成一个环,即第一个房屋和最后一个房屋相邻,仍求能偷到的最大总金额。
class Solution:
    def rob(self, nums: List[int]) -> int:
        # 这里的区别就是偷第一家则不能考虑偷最后一家,或者选择不偷第一家。两种方案取最优值即可
        N = len(nums)
        if N == 0 :
            return 0
        if N == 1:
            return nums[0]
        if N <= 3:
            return max(nums)
        last2 = nums[0]
        last1 = nums[0]
        sumsx,sumsy = 0,0
        for i in range(2,N-1):       # 偷第一家的情况,最后一家直接忽略
            sumsx = max(last1,last2+nums[i])
            last2 = last1
            last1 = sumsx
        last2 = nums[1]  
        last1 = max(nums[1],nums[2])
        for j in range(3,N):         # 不偷第一家的情况,则可能从第二家或第三家开始偷起
            sumsy = max(last1,last2+nums[j])
            last2 = last1
            last1 = sumsy
        return max(sumsx,sumsy) 
        # 同样的思路,题解中的大神有更简洁的写法
        def my_rob(nums):
            cur, pre = 0, 0
            for num in nums:
                cur, pre = max(pre + num, cur), cur
            return cur
        return max(my_rob(nums[:-1]),my_rob(nums[1:])) if len(nums) != 1 else nums[0]   
  1. 最长连续递增序列
    给定一个未经排序的整数数组,找到最长且连续的的递增序列,并返回该序列的长度。
class Solution:
    def findLengthOfLCIS(self, nums: List[int]) -> int:
        # 这个题做动态规划时注意,要维护的数组记录的是原数组中以当前元素结尾的最长递增序列长度
        if not nums: return 0
        ans = 1
        N = len(nums)
        lists = [1] * N
        
        for i in range(1,N):  # 这里如果当前元素小于前一个元素,则在规划数组中记录为1(重新开始记录一个递增序列),已在初始化部分完成
            if nums[i] > nums[i-1]:
                lists[i] = lists[i-1] + 1
                if lists[i] > ans:
                    ans = lists[i]
        return ans
  1. 编辑距离
    给你两个单词 word1 和 word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。
    可执行如下三种操作
    插入一个字符
    删除一个字符
    替换一个字符
class Solution:
    def minDistance(self, word1: str, word2: str) -> int:
        # 参考别人的答案,详细思路可见题解。dp[i][j]代表代表word1中前 i 个字符,变换到word2中前 j 个字符,最短需要操作的次数。
        # 另外两个单词都可能为空(即i=0或j=0)也需要考虑。
        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]

参考资料:

  1. https://github.com/datawhalechina/team-learning-program/blob/master/LeetCodeClassification/1.分治.md Datawhale小组学习资料
  2. https://blog.csdn.net/weixin_43872728/article/details/101082875 分治算法详解(超详细)
  3. https://leetcode-cn.com/ 力扣
  4. https://mp.weixin.qq.com/s/UE8h1UNdfzHMSfEPfZITng 动态规划4部曲
原文地址:https://www.cnblogs.com/liugd-2020/p/13524311.html