算法设计与分析-Dynamic Programming「国科大」卜东波老师

1.Question Number 1: Money Robbing

A robber is planning to rob houses along a street. Each house has a certain amount of money stashed, the only constraint stopping you from robbing each of them is that adjacent houses have security system connected and it will automatically contact the police if two adjacent houses were broken into on the same night.

(a) Given a list of non-negative integers representing the amount of money of each house, determine the maximum amount of money you can rob tonight without alerting the police.

(b) What if all houses are arranged in a circle?

针对“Money Robbing”问题,可分为线性街道和环形街道的情况。

1.1 问题 (a): 线性街道

1.1.1 算法描述
  • 自然语言描述: 使用动态规划来解决这个问题。对于每个房子,强盗有两个选择:抢或不抢。如果他决定抢劫第i个房子,那么他不能抢劫第i-1个房子;如果他不抢劫第i个房子,那么他可以从前i-1个房子中获得的最大金额。

  • 伪代码:

rob(houses):
    # 如果没有房子,返回0
    if houses is empty:
        return 0

    # 如果只有一个房子,返回该房子的金额
    if length of houses is 1:
        return houses[0]

    # 初始化动态规划数组
    dp[0] = houses[0]  # 只有一个房子时的最大金额
    dp[1] = max(houses[0], houses[1])  # 前两个房子中的最大金额

    # 从第三个房子开始,计算每个房子的最大抢劫金额
    for i from 2 to length of houses - 1:
        # dp[i] 是前i个房子的最大金额,等于以下两者中的较大者:
        # 1. 不抢第i个房子,金额为dp[i-1]
        # 2. 抢第i个房子,金额为dp[i-2]加上第i个房子的金额
        dp[i] = max(dp[i - 1], dp[i - 2] + houses[i])

    # 返回最后一个房子的最大抢劫金额
    return dp[length of houses - 1]
1.1.2 最优子结构和DP方程
  • 最优子结构: 对于第i个房子,最大抢劫金额是基于前i-1个房子的最大抢劫金额决定的。
  • DP方程: dp[i] = max(dp[i - 1], dp[i - 2] + houses[i])
1.1.3 算法正确性证明
  • 基本情况: 当只有一个或两个房子时,选择金额最大的房子。
  • 归纳步骤: 假设对于前i-1个房子的最大金额我们已经知道,那么对于第i个房子,我们可以选择不抢(保持dp[i-1]),或者抢(dp[i-2] + houses[i])。这两种选择保证了我们总是得到最大金额。
1.1.4 算法复杂度分析
  • 时间复杂度: O(n),其中n是房子的数量。
  • 空间复杂度: O(n),用于存储每个房子的最大抢劫金额。

1.2问题 (b): 环形街道

1.2.1 算法描述
  • 自然语言描述: 在环形街道的情况下,问题变成了不能同时抢第一个和最后一个房子。我们可以把问题分成两个子问题:一个不包括第一个房子,另一个不包括最后一个房子。对这两个子问题分别使用上面的动态规划算法,然后取两者的最大值。

  • 伪代码:

    rob_circular(houses):
        if houses is empty:
            return 0
        if length of houses is 1:
            return houses[0]
        return max(rob(houses[1:]), rob(houses[:-1]))
    
1.2.2 最优子结构和DP方程
  • 最优子结构: 和线性街道一样,但需要考虑环状结构,将问题分为两个子问题。
  • DP方程: 与线性街道相同。
1.2.3 算法正确性证明
  • 算法将问题分为两个子问题:一个包括第一个房子但不包括最后一个,另一个包括最后一个房子但不包括第一个。由于这两个子问题都是线性的,因此它们的解决方案有效,并且取两者的最大值可以保证总体解决方案的有效性。
1.2.4 算法复杂度分析
  • 时间复杂度: O(n),其中n是房

一个具体的例子并用python实现:

假设有一个街道,房屋中的金额分别为 [1, 2, 3, 1]。在这种情况下,强盗应该选择第二个和第三个房子来抢劫,以获得最大金额 2 + 3 = 5。

def rob_linear(houses):
    if not houses:
        return 0
    if len(houses) == 1:
        return houses[0]

    dp = [0] * len(houses)
    dp[0] = houses[0]
    dp[1] = max(houses[0], houses[1])

    for i in range(2, len(houses)):
        dp[i] = max(dp[i - 1], dp[i - 2] + houses[i])

    return dp[-1]

def rob_circular(houses):
    if not houses:
        return 0
    if len(houses) == 1:
        return houses[0]

    return max(rob_linear(houses[:-1]), rob_linear(houses[1:]))

# 测试例子
houses = [1, 2, 3, 1]
print("Max amount in linear street:", rob_linear(houses))
print("Max amount in circular street:", rob_circular(houses))

2.Question Number 2: Ugly Number

An ugly number is a positive integer whose prime factors are limited to 2, 3, and 5. Given an integer
n, return the nth ugly number.
(a) Using a brute-force algorithm to solve this problem, analyze the time complexity of your implemented brute-force algorithm and explain why the algorithm’s time complexity is O(n2), where n is the number of points.
(b) Propose an improved algorithm to solve this problem with a time complexity better than the brute-force algorithm. Describe the algorithm’s idea and analyze its time complexity.

问题:丑数(Ugly Number)

丑数是只包含质因数2、3和5的正整数。给定一个整数n,返回第n个丑数。

(a) 暴力算法
1. 算法描述
  • 自然语言描述:从1开始,对每个整数检查它是否为丑数。检查方法是不断地除以2、3和5,如果最终结果为1,则该数为丑数。重复这个过程直到找到第n个丑数。
  • 伪代码:
    find_nth_ugly_number(n):
        count = 0
        number = 1
        while count < n:
            if is_ugly(number):
                count += 1
            number += 1
        return number - 1
    
    is_ugly(num):
        for i in [2, 3, 5]:
            while num % i == 0:
                num /= i
        return num == 1
    
2. 最优子结构和DP方程
  • 这个暴力算法没有使用动态规划,因此没有最优子结构和DP方程。
3. 算法正确性证明
  • 每个数通过除以2、3和5来检查是否是丑数。如果一个数只由这三个质因数组成,最终结果将是1。
4. 算法复杂度分析
  • 时间复杂度:O(n^2),其中n是目标丑数的索引。对于每个数,我们需要O(log n)的时间来判断它是否为丑数,而我们需要检查大约n个数。
  • 空间复杂度:O(1),不需要额外的空间。
(b) 改进算法
1. 算法描述
  • 自然语言描述:使用三个队列,分别乘以2、3和5。每次从这三个队列的头部选取最小的数作为新的丑数,并将其乘以2、3和5加入相应的队列中。这样可以确保按顺序生成丑数。
  • 伪代码:
    improved_find_nth_ugly_number(n):
        ugly_numbers = [1]
        i2, i3, i5 = 0, 0, 0
        while len(ugly_numbers) < n:
            next2, next3, next5 = ugly_numbers[i2] * 2, ugly_numbers[i3] * 3, ugly_numbers[i5] * 5
            next_ugly = min(next2, next3, next5)
            ugly_numbers.append(next_ugly)
            if next_ugly == next2:
                i2 += 1
            if next_ugly == next3:
                i3 += 1
            if next_ugly == next5:
                i5 += 1
        return ugly_numbers[-1]
    
2. 最优子结构和DP方程
  • 最优子结构:第n个丑数依赖于之前的丑数。
  • DP方程ugly_numbers[n] = min(ugly_numbers[i2] * 2, ugly_numbers[i3] * 3, ugly_numbers[i5] * 5)
3. 算法正确性证明
  • 该算法确保每次添加

到列表的丑数是当前最小的丑数。通过乘以2、3和5并比较,我们可以保证按顺序生成丑数,并且不会遗漏任何丑数。

4. 算法复杂度分析
  • 时间复杂度:O(n),其中n是目标丑数的索引。每个丑数只需要常数时间就能计算出来,因此总的时间复杂度为线性。
  • 空间复杂度:O(n),用于存储前n个丑数。

小结

暴力算法简单直接,但效率低下,因为它需要为每个数都检查是否为丑数。改进的算法则通过维护三个队列,每次只计算可能成为下一个丑数的数,大大提高了效率。通过这种方法,我们可以按顺序生成丑数,并且保证每次添加的都是当前最小的丑数,从而有效地解决了这个问题。

3.Question Number 3: Unique Binary Search Trees

Given n, how many structurally unique BST’s (binary search trees) that store values 1…n? Note: Given n = 3, there are a total of 5 unique BST’s:

问题:唯一的二叉搜索树(Unique Binary Search Trees)

给定一个整数n,计算存储值1到n的结构上唯一的二叉搜索树(BST)的数量。

1. 算法描述
  • 自然语言描述:使用动态规划来解决这个问题。对于给定的n,我们可以将每个数i(1 到 n)作为根节点,然后左子树由小于i的数构成,右子树由大于i的数构成。对于每个i,其唯一的BST数量等于左子树的BST数量乘以右子树的BST数量。我们可以计算从1到n的所有可能性,并将它们相加得到总数。
  • 伪代码:
unique_bst(n):
    # 如果n小于等于1,直接返回1,因为没有或只有一个节点时只有一种BST
    if n <= 1:
        return 1

    # 初始化动态规划数组,长度为n+1,初始值都设为0
    dp = [0] * (n + 1)

    # dp[0]和dp[1]都是1,因为没有节点或只有一个节点时只有一种情况
    dp[0] = 1
    dp[1] = 1

    # 从2到n遍历,计算每个数i的唯一BST数量
    for i in range(2, n + 1):
        # 遍历所有可能的根节点j
        for j in range(1, i + 1):
            # dp[i]是以j为根节点的BST数量,等于左子树的BST数量乘以右子树的BST数量
            # dp[j-1]是左子树的BST数量,dp[i-j]是右子树的BST数量
            dp[i] += dp[j - 1] * dp[i - j]

    # 返回存储值1到n的唯一BST的总数量
    return dp[n]
2. 最优子结构和DP方程
  • 最优子结构:一个结构上唯一的BST的数量可以由其左右子树的唯一BST数量决定。
  • DP方程dp[i] += dp[j - 1] * dp[i - j],其中dp[i]是存储值1到i的唯一BST的数量,j是根节点的值。
3. 算法正确性证明
  • 由二叉搜索树的性质,任何一个节点将树分为左右两个子树,且左子树的所有值都小于根,右子树的所有值都大于根。
  • 对于根节点j,其左子树由`[1,

j-1]的数构成,右子树由[j+1, i]`的数构成。

  • 左子树的唯一BST数量为dp[j-1],右子树的唯一BST数量为dp[i-j]。因此,以j为根节点的唯一BST的总数为dp[j-1] * dp[i-j]
  • 通过累加每个可能的根节点j的唯一BST数量,我们得到了dp[i]的值。
4. 算法复杂度分析
  • 时间复杂度:O(n

^2),其中n是给定的数字。这是因为我们需要两层循环来计算每个数i(从2到n)的唯一BST数量,内层循环对于每个i遍历从1到i的所有可能的根节点j

  • 空间复杂度:O(n),用于存储动态规划数组dp,其中dp[i]表示存储值1到i的唯一BST的数量。

总体而言,这个动态规划算法通过计算较小问题的解来有效地构建更大问题的解,从而避免了重复的计算工作,并能够准确地计算出给定n的唯一二叉搜索树的数量。

4.Question Number 4

Largest Divisible Subset
Given a set of distinct positive integers, find the largest subset such that every pair (Si, Sj) of elements in this subset satisfies: Si%Sj = 0 or Sj%Si = 0. Please return the largest size of the subset.
Note: Si%Sj = 0 means that Si is divisible by Sj.

问题:最大可整除子集(Largest Divisible Subset)

给定一组不同的正整数,找出最大的子集,使得该子集中的每一对元素(Si, Sj)满足:Si%Sj = 0或Sj%Si = 0。返回这个子集的最大大小。

1. 算法描述
  • 自然语言描述:首先,将数组排序。排序后,任何两个相邻的元素,较小的数能整除较大的数的可能性更大。然后,使用动态规划来找到最大可整除子集。对于每个元素,检查它能整除哪些之前的元素,并更新它能构成的最大子集的大小。最后,返回所有元素中最大子集的大小。

  • 伪代码:

    largest_divisible_subset(nums):
        if not nums:
            return 0
    
        # 对数组进行排序
        nums.sort()
    
        # 初始化dp数组,每个位置表示以当前元素结尾的最大子集大小
        dp = [1 for _ in nums]
    
        # 遍历数组,更新每个位置的最大子集大小
        for i in range(1, len(nums)):
            for j in range(i):
                if nums[i] % nums[j] == 0:
                    dp[i] = max(dp[i], dp[j] + 1)
    
        # 返回最大的子集大小
        return max(dp)
    
2. 最优子结构和DP方程
  • 最优子结构:对于数组中的每个元素nums[i],它能构成的最大可整除子集取决于它之前的元素nums[j](j < i)所能构成的最大子集,并且nums[i]能整除nums[j]
  • DP方程dp[i] = max(dp[i], dp[j] + 1),其中j < inums[i] % nums[j] == 0
3. 算法正确性证明
  • 通过对数组进行排序,我们确保了如果nums[j]能整除nums[i],那么对于任何k < jnums[k]也能整除nums[i]
  • 使用动态规划,我们考虑了所有可能的元素对,并更新了以每个元素为结尾的最大子集的大小,确保了考虑了所有可能的组合。
4. 算法复杂度分析
  • 时间复杂度:O(n^2),其中n是数组的长度。这是因为有两层嵌套循环,每层循环都遍历整个数组。
  • 空间复杂度:O(n),用于存储dp数组,其中dp[i]表示以第i个元素结尾的最大子集的大小。

5.Question Number 5

You are given an integer array nums and an integer target.
You want to build an expression out of nums by adding one of the symbols ’+’ and ’-’ before each integer in nums and then concatenate all the integers.
For example, if nums = [2, 1], you can add a ’+’ before 2 and a ’-’ before 1 and concatenate them to build the expression ”+2-1”.
Return the number of different expressions that you can build, which evaluates to target. Example:

Input: nums = [1,1,1,1,1], target = 3

Output: 5

Explanation: There are 5 ways to assign symbols to make the sum of nums be target 3.

-1 + 1 + 1 + 1 + 1 = 3

+1 - 1 + 1 + 1 + 1 = 3

+1 + 1 - 1 + 1 + 1 = 3

+1 + 1 + 1 - 1 + 1 = 3

+1 + 1 + 1 + 1 - 1 = 3

问题:构建表达式以达到目标和

给定一个整数数组nums和一个整数目标target。你想通过在nums中的每个整数前添加符号’+‘或’-',然后将所有整数串联起来来构建一个表达式。返回你可以构建的、计算结果为target的不同表达式的数量。

1. 算法描述
  • 自然语言描述:这个问题可以通过动态规划解决。我们可以将问题转换为子集求和问题。计算数组中元素能组成的所有可能的和,并统计其中等于target的数量。对于数组中的每个元素,我们可以选择加上它或减去它。我们使用一个字典来存储中间结果,键为可能的和,值为达到这个和的方法数量。

  • 伪代码:

    find_target_sum_ways(nums, target):
        dp = {0: 1}  # 初始化,和为0的方法有1种
    
        # 遍历数组中的每个元素
        for num in nums:
            temp = {}
            # 遍历当前可能的和及其对应的方法数
            for sum in dp:
                # 计算加上或减去当前元素后的和
                temp[sum + num] = temp.get(sum + num, 0) + dp[sum]
                temp[sum - num] = temp.get(sum - num, 0) + dp[sum]
            dp = temp
    
        # 返回达到目标和的方法数
        return dp.get(target, 0)
    
2. 最优子结构和DP方程
  • 最优子结构:对于每个元素,它可以加上或减去,这影响了达到最终目标和的方法数。
  • DP方程dp[sum + num] += dp[sum]dp[sum - num] += dp[sum],这里dp[sum]是在没有考虑当前元素时达到和为sum的方法数。
3. 算法正确性证明
  • 算法基于这样一个事实:每添加一个元素,都会基于前一个状态的和创建新的和。即对于每个元素,我们都在之前所有可能和的基础上加上或减去这个元素。
  • 由于每个元素都独立考虑加和减的情况,所有可能的和都会被考虑到。因此,最终得到的和为target的方法数就是所有可能性的总和。
4. 算法复杂度分析
  • 时间复杂度:O(n * m),其中n是数组nums的长度,m是所有可能的和的数量。对于每个元素,我们都需要遍历目前所有可能的和并更新它们,所以时间复杂度取决于这两个因素的乘积。
  • 空间复杂度:O(m),其中m是所有可能的和的数量。我们需要一个字典来存储每种可能和的计数,其空间复杂度与所有可能的和的数量成正比。

综上所述,这个动态规划算法通过考虑数组中每个元素对可能和的影响,有效地找到了达到目标和target的所有可能方法的数量。

你可能感兴趣的:(算法,动态规划)