七个步骤解决面试中的动态规划问题

七个步骤解决面试中的动态规划问题

附注:本文翻译自 《Dynamic Programming – 7 Steps to Solve any DP Interview Problem》

即使有丰富的构建软件产品的经验,很多工程师都会对面试中的算法问题感到紧张。我面试过数百名工程师,其中最让工程师觉得困难的是动态规划问题。

很多技术公司喜欢在面试中考察动态规划问题。虽然这些问题是否能有效评估工程师的能力仍需讨论,动态规划一直是工程师在求职道路上最容易被绊倒的地方。

动态规划——可预测、可准备

我个人认为动态规划不能很好地测试工程师能力的原因之一是:这些问题是可预测的,并且容易套用模板。这些问题让更多是让我们考察候选人的准备情况,而不是工程能力。

这些问题通常从外表看上去非常复杂,可能给你一种印象:解决这些问题的人非常精通算法。同样的,一些无法绕过动态规划问题中思维陷阱的人看上去算法知识不足。

事实并非如此。影响面试表现的最主要因素是准备。让我们确保每个人都做好准备。

解决动态规划问题的最主要因素是准备情况。

七个步骤解决动态规划问题

在本文的剩余部分,我会介绍一个“菜谱”,让你可以遵照它来判断一个问题是不是动态规划问题,并找到解决问题的方法。具体来说,我将遵照下面的步骤:

  1. 如何确认动态规划问题
  2. 识别变量
  3. 清晰表述递归关系
  4. 识别边界情况
  5. 决定使用迭代还是递归
  6. 增加记录表
  7. 计算时间复杂度

简单的动态规划问题

为了避免过于抽象,我们介绍一个简单的问题作为示例。在下面的每一节我都会讨论这个问题。不过你也可以抛开这个问题独立地阅读每一节。

问题陈述

在这个问题中,我们坐在一个跳跃球上,尝试将球停下来,同时躲避地面的尖刺。

规则

1. 给定一个跑道,上面有一些尖刺。跑道用一个布尔数组描述,每个元素表示跑道上一点是否有尖刺。True表示没有尖刺,False表示有尖刺。

示例数组

2. 给定一个初始速度S。S是一个非负整数,表示下次跳跃时前进的步数。

3. 每次跳跃到一个点后,在下次跳跃前,你可以调整速度最多1个单位。

4. 你需要将跳跃球安全的停在跑道上(不要求停在跑道尽头)。跳跃球的速度变为0时就会停下来。如果你跳跃到一个尖刺上,跳跃球会爆炸,游戏结束。

你需要编写一个输出布尔值的函数,表明跳跃球是否可以安全停止在跑道上。

步骤1:如何确认动态规划问题

首先我们要明确的是,动态规划主要是一项优化技术。动态规划方法将问题分解为一组更简单的子问题,解决每个子问题,将子问题的解保存起来。当下次同样的输入时,不再重复计算,而是查询早前的计算结果。这种技术节省了计算时间,成本是少量的存储空间。

确认一个问题能否使用动态规划技术是解决问题的第一步,通常也是最难的一步。你应当问问自己,问题的解能否表达成相似子问题解的函数。

在我们的示例问题中,给定一个点,一个速度和一个跑道,我们可以决定跳跃到哪一点上。而且从当前点开始以指定速度向前跳跃时,能否停下来仅依赖于我们从所选的下一个跳跃点出发后能否停止。这一点非常重要,向前移动时我们缩短了剩余的跑道长度,降低了问题规模。我们可以重复这一过程直到我们到达一个容易判断能否停止跳跃的点。

确认一个动态规划问题通常是解决过程中最难的一步。问题的解可以表述为一些相似子问题解的函数吗?

步骤2:识别变量

现在我们已经认识到这些子问题中存在某种递归结构。接下来我们需要使用函数和参数来表述这个问题,并且观察哪些参数会发生变化。通常在面试中,你会观察到1到2个参数会发生变化,但理论上的数量也可以是任意多个。一个经典的单变量问题是计算第n个斐波那契数。一个双变量问题的例子是计算字符串的编辑距离。如果你不熟悉这些问题也不用担心。

一个判断变量数目的方法是列出几个子问题作为例子,并对参数进行比较。计算变量的数目有助于判断子问题的数量,同时也有助于我们加强对步骤1中递归关系的理解。

在我们的例子中,子问题中两个可能发生变化的参数是:

  1. 数组位置(P)
  2. 速度(S)

可能有人认为剩余的跑道也在发生变化。考虑到跑道整体不变,位置P的变化已经包含了这部分信息。

现在,使用这2个变量和其他静态参数,我们完整的描述子问题了。

识别变量并确定子问题数目。

步骤3:清晰表述递归关系

这是重要的一步,常被人匆匆掠过而直接开始编码。尽量清晰表述递归关系将加强你对问题的理解,并让接下来的事情变得简单。

一旦你指出递归关系并通过参数来描述问题,这是很自然的一步。子问题之间是如何关联的?换言之,假设你已经计算出了子问题,你将如何解决主问题?

下面是我们对示例问题的思考:

由于在下次跳跃前可以调整速度最多一个单位,有3个可能的速度和3个跳跃点。

更正式的,如果我们的速度是S,位置是P,我们可以从(S,P)跳跃到:

  1. (S,P+S); # 如果我们没有改变速度
  2. (S-1,P+S-1); # 如果我们降低速度1
  3. (S+1,P+S+1); # 如果我们增加速度1

如果我们可以找到办法在上述的任意一个子问题中停下来,我们就可以从(S,P)处停下来。这是因为我们可以从(S,P)跳跃到上述3个点。

这是典型的对问题的准确理解(用文字来表达)。有时你需要用数学语言来表达。我们把要计算的函数称作canStop:

canStop(S,P) = canStop(S,P+S) || canStop(S-1,P+S-1) || canStop(S+1,P+S+1)

Woohoo,看来我们已经找到了递归关系。

递归关系:假设你已经计算出了子问题,你将如何计算主问题?

步骤4:识别边界情况

边界情况是一类特殊的子问题,这类子问题不依赖于任何其他子问题。为了找到这样的子问题,你通常要尝试找一些例子,观察问题是如何被简化为子问题的,还有在什么情况下子问题无法被进一步简化。

子问题不能被进一步简化的原因是某些参数的值将会不再满足问题的约束。

在我们的示例问题中,我们有两个变量S和P。思考有哪些值会让S和P变得不合法法:

  1. P应当在跑道的边界内。
  2. P不能是让runway[P]为False的点。这表明我们跳跃到尖刺上。
  3. S不能是负数。S等于0表示我们已经完成了工作。

将我们对变量的断言转换为可编程的边界条件有时候存在一些挑战。这是因为,除了列出断言外,如果你想要代码更精确并省去不必要的条件,你需要考虑哪些情况可能成立。

在我们的例子中:

  1. P<0 || P >= length_of_runway 看起来是正确的。一个可选方案是考虑让P == end_of_runway作为边界情况。无论怎样,细分出的子问题可能出现超出跑道的情况,我们要对这种情况进行检查。
  2. 看起来很明显。我们可以直接检查runway[P]是不是False。
  3. 类似#1,我们可以直接检查S<0和S==0。我们可以说明为何S不可能小于0,因为S每次最多调整1个单位。所以我们可以直接检查S==0的情况。故此S==0是有效的边界情况。

步骤5:决定使用迭代还是递归

目前为止我们讨论的内容可能让你认为我们会采用递归方式来实现解决方案。其实我们的讨论的完全没有假定你的实现方法是递归还是迭代。无论那种方式你都需要找出递归关系和边界情况。

为了决定使用迭代还是递归,你需要仔细权衡。

 递归迭代
渐进时间复杂度相同(如果使用记录表)相同
内存使用递归栈、稀疏表(sparse memoization)内存表(full memoization)
执行速度较快,依赖于输入较慢,需要一些额外工作
栈溢出可能只要内存足够,没有问题。
直观/易于实现易于解释难以解释

栈溢出的问题通常是你不想在生产系统中使用递归的原因。在面试时,只要提及二者的权衡,通常可以使用任意一种实现方式。你应当对两种方案都感觉良好。

对于我们的示例问题,我同时实现了两个版本。下面是python代码。

这是递归版本:

def canStopRecursive(runway, initSpeed, startIndex = 0):
  # negative base cases need to go first
  if (startIndex >= len(runway) or startIndex < 0 or
      initSpeed < 0 or not runway[startIndex]):
    return False
  # base case for a stopping condition
  if initSpeed == 0:
    return True
  # Try all possible paths
  for adjustedSpeed in [initSpeed, initSpeed - 1, initSpeed + 1]:
    # Recurrence relation: If you can stop from any of the subproblems,
    # you can also stop from the main problem
    if canStopRecursive(
        runway, adjustedSpeed, startIndex + adjustedSpeed):
      return True
  return False

这是迭代版本:

def canStopIterative(runway, initSpeed, startIndex = 0):
  # maximum speed cannot be larger than length of the runway. We will talk about
  # making this bound tighter later on.
  maxSpeed = len(runway)
  if (startIndex >= len(runway) or startIndex < 0 or initSpeed < 0 or initSpeed > maxSpeed or not runway[startIndex]):
    return False
  # {position i : set of speeds for which we can stop from position i}
  memo = {}
  # Base cases, we can stop when a position is not a spike and speed is zero.
  for position in range(len(runway)):
    if runway[position]:
      memo[position] = set([0])
  # Outer loop to go over positions from the last one to the first one
  for position in reversed(range(len(runway))):
    # Skip positions which contain spikes
    if not runway[position]:
      continue
    # For each position, go over all possible speeds
    for speed in range(1, maxSpeed + 1):
      # Recurrence relation is the same as in the recursive version.
      for adjustedSpeed in [speed, speed - 1, speed + 1]:
        if (position + adjustedSpeed in memo and
            adjustedSpeed in memo[position + adjustedSpeed]):
          memo[position].add(speed)
          break
  return initSpeed in memo[startIndex]

步骤6:增加记录表

记录表(memoization)是一种和动态规划算法紧密联系的技术。记录表把函数的结果保存起来,在遇到相同的输入时,返回缓存的结构。为何要在递归中使用记录表?没有记录表,我们遇到的子问题会重复计算。反复的计算通常会导致时间复杂度以指数增长。

在递归里增加记录表是非常直观的。我们来看原因。记住记录表只是函数结果的缓存。有时你会想违背这个定义以获得一些微小的优化,但是将记录表看作是函数结果的缓存是最直观的实现方法。

这意味着你应当:

  1. 在返回前保存函数结果到内存中。
  2. 在进行计算之前查询内存中的结果。

下面是增加了记录表之后的递归版本:

def canStopRecursiveWithMemo(runway, initSpeed, startIndex = 0, memo = None):
  # Only done the first time to initialize the memo.
  if memo == None:
    memo = {}
  # First check if the result exists in memo
  if startIndex in memo and initSpeed in memo[startIndex]:
    return memo[startIndex][initSpeed]
  # negative base cases need to go first
  if (startIndex >= len(runway) or startIndex < 0 or
      initSpeed < 0 or not runway[startIndex]):
    insertIntoMemo(memo, startIndex, initSpeed, False)
    return False
  # base case for a stopping condition
  if initSpeed == 0:
    insertIntoMemo(memo, startIndex, initSpeed, True)
    return True
  # Try all possible paths
  for adjustedSpeed in [initSpeed, initSpeed - 1, initSpeed + 1]:
    # Recurrence relation: If you can stop from any of the subproblems,
    # you can also stop from the main problem
    if canStopRecursiveWithMemo(
        runway, adjustedSpeed, startIndex + adjustedSpeed, memo):
      insertIntoMemo(memo, startIndex, initSpeed, True)
      return True
  insertIntoMemo(memo, startIndex, initSpeed, False)
  return False

为了展示不同方法以及记录表技术的效果,我们做一些测试。我测试了全部的3中方法。下面是测试方法:

  1. 我建立了一个长度为1000的跑道,尖刺随机分布。(每个点存在尖刺的概率是20%)
  2. initSpeed = 30。
  3. 每个函数运行10次然后计算平均运行时间。

下面是测试结果(单位为秒)

 Time(s)
canStopRecursive10.239
canStopIterative0.021
canStopRecursiveWithMemo0.008

你可以看到,单纯递归的方式相比迭代方式多消耗了500倍以上的时间,相比使用记录表递归方式多消耗了1300倍以上的时间。要注意这种差异会随着跑道长度的增加急速增长。我建议你实际运行一下代码。

步骤7:计算时间复杂度

有一些简单的规则可以让动态规划问题的时间复杂度变得易于计算。你需要做两个步骤:

  1. 计算状态的数量——这将依赖于问题中变量的数量
  2. 考虑每个状态需要的计算量。换句话说,如果只剩一个状态没有计算,你还需要多少工作来计算出最终状态

在我们的例子中,状态数量是|P|*|S|。其中

  1. P是全部跳跃点的集合(|P|是集合P中元素的数量)
  2. S是全部合法速度的集合

每个状态需要的工作是O(1),因为给定全部其他状态,我们只需要简单查看3个子问题来确定当前状态。

根据我们之前的源代码,|S|由跑道的长度限制(|P|)。所以我们可以说状态的数量是|P|^2 而且每个状态的工作量是O(1),全部的时间复杂度是O(|P|^2)。

但是看起来|S|可以被进一步缩小范围,如果|P|明确表示停止。

所以我们可以对|S|设定一个边界。我们来计算最大速度S。假设我们从位置0开始,如果我们为了尽快停止而忽略尖刺,我们可以多快停下来?

在第一次迭代中,我们需要跳跃到(S-1)点。通过调整速度减一,我们下一次可以到达(S-2)点。依次类推。

对于长度为L的跑道,下面的公式成立:

=> (S-1) + (S-2) + ... + 1 < L

=> S * (S-1) / 2 < L

=> S^2 - S - 2L < 0

这个方程的两个根是:

r1 = 1/2 + sqrt(1/4 + 2L) 和 r2 = 1/2 - sqrt(1/4 + 2L)

我们可以将公式写为:

(S - r1)*(S - r2) < 0

当S>0和L>0时,S - r2 > 0。所以只需要

S - 1/2 - sqrt(1/4 + 2L) < 0

=> S < 1/2 + sqrt(1/4 +2L)

在一个长度L的跑道上,这是最大的速度。如果超过这个速度,无论尖刺的位置如何分布,我们的跳跃球都不可能停下来。

这意味着全部的时间复杂度依赖于跑道的长度L:

O(L * sqrt(L))比O(L^2)更好。

O(L * sqrt(L))是时间复杂度上界。

非常棒,你解决了难题。

我们一起学习的这7个步骤可以给你一个系统的框架来解决动态规划问题。我建议你使用这个方法来做一些练习,来完善你自己的的方法。

你接下来可以:

  1. 扩展这个简单的问题,尝试找到一个跳跃路径。我们解决的问题告诉了你是否能够停下来,但是如果你想知道需要跳跃到哪些点上,你会如何修改上面的代码?
  2. 有一个方法可以增强你对记录表作为函数缓存的理解,你可以学习python中的装饰器(decorator)或者其他编程语言中的类似概念。思考这种机制如何帮助你实现一般函数可以使用的记录表。
  3. 按照上面步骤,解决更多的动态规划问题。你可以在网上(LeetCode或GeeksForGeeks)找到大量习题。在你练习时,记住一件事情:学习思路而不是具体问题。思路的数量更少,更容易记忆,但会给你更多的帮助。

如果你感觉可以解决这些问题,可以访问 Refdash 。在这里你可以得到一位高级工程师的面试,并得到在编码、算法和系统设计方面的详细的反馈。

转载于:https://my.oschina.net/u/131191/blog/1837387

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值