审校 | 梁策 孙淑娟
如果你已有不少编程经历,对动态规划这个术语大概不会陌生。动态规划常常是技术面试的重点话题,在设计评审会议或与开发者的交流互动中也会涉及。本文将介绍什么是动态规划及运用动态规划的原因。
为清晰阐释动态规划概念,我将用Swift代码示例来说明,其他语言亦可适用。
思维方式
与特定的编码语法或设计模式不同,动态编程不是一种具体算法,而是一种思维方式。因此,具体到执行层面,动态规划有多种表现形式。
动态规划的核心思想是将一个大问题化整为零。同时,执行动态规划的推荐方式也需要数据存储和重用来提高算法效率。软件开发中的许多问题都可以通过各种形式的动态规划来解决,关键是要知道什么时候用简单变量,什么时候用复杂的数据结构或算法来设计出最佳解决方案。
例如,代码变量可视为动态规划的基本形式。变量的目的是在内存中保留一个特定的位置,以便之后调用。
//非记忆函数
func addNumbers(lhs: Int, rhs: Int) -> Int {
return lhs + rhs
}
//记忆函数
func addNumbersMemo(lhs: Int, rhs: Int) -> Int {
let result: Int = lhs + rhs
return result
}
上面的addNumbersMemo 进行了一个简要引入,而动态规划解决方案的目标则是将先前看到的值保留,这种设计技巧被称为记忆。
代码挑战-数字对
多年来,我曾与几十名准备面试苹果、Facebook和亚马逊等顶级公司的开发人员进行了模拟面试,大多数人都很乐意在模拟时跳过那些可怕的现场白板面试或带回家的编程项目。但事实是,当中很多题目正是专门测试一个人对计算机科学基础知识的基本理解。例如,下面这种情况:
/*
在技术面试中,你被给到一个数组,然后需要找到一对与给定目标值相等的数字。数字可正可负,或两者兼有。你能设计出一个在O(n)线性时间内工作的算法吗?
let sequence = [8, 10, 2, 9, 7, 5]
let results = pairValues(sum: 11) = //returns (9, 2)
*/
对开发人员来说,解决问题的方式通常有很多种。在本例中,我们的目标是找到数字对来达到预期结果。人用肉眼能快速浏览数字序列,很容易找到9和2的一对。但是,算法需要检查并比较序列中的每个值,或者开发一个更精简的解决方案才能帮助我们找到正在寻找的值。接下来我将分别阐述这两种技术。
暴力方式
第一种方法是先查看第一个值,然后后续一一检查每个值,判断其差异能否实现目标。例如,我们的算法检查数组中的第一个数值8,然后在剩下的值里找3(例如 11-8=3)。但我们发现这一组里没有3,那么算法就以相同的方式继续找下一个值(也就是例子里的10),直到找到成功匹配的一对。
在不考虑大O符号的细节的情况下,我们可以认为这类解决方式的平均时间复杂度是O(n ^ 2)或更大,这主要是因为我们算法的工作方式是每个值与其他值比较。其过程可以通过下面的代码实现:
let sequence = [8, 10, 2, 9, 7, 5]
//非记忆方法 - O(n ^ 2)
func pairNumbers(sum: Int) -> (Int, Int) {
for a in sequence {
let diff = sum - a
for b in sequence {
if (b != a) && (b == diff) {
return (a, b)
}
}
}
return (0, 0)
}
记忆方式
接下来,让我们来利用记忆的思维方式来解决问题。在执行代码之前,我们需要思考如何利用存储以前计算到的值帮助简化这个过程。使用标准数组是可行的,但集合对象(也称为哈希表或散列表)也可提供优化的解决方案。
//记忆方法 - O(n + d)
func pairNumbersMemoized(sum: Int) -> (Int, Int) {
var addends = Set<Int>()
for a in sequence {
let diff = sum - a
if addends.contains(diff) { //O(1) - constant time lookup
return (a, diff)
}
//store previously seen value
else {
addends.insert(a)
}
}
return (0, 0)
}
通过使用记忆方法,我们将之前看到的值添加到集合对象中,从而将算法的平均运行时效率提高到O(n + d)。熟悉哈希结构的人会知道,项目的插入和检索的时间复杂度是O(1)——常数时间内。这进一步简化了我们的解决方案,因为集合被设计为以优化方式检索值而无需考虑其大小。
斐波那契序列
递归是人们在学习各种编程技术时会碰到的一个主题。递归解决方案通过一个引用自身的模型来工作,因此递归技术是通过算法或数据结构实现的。斐波那契数列就是一个著名的递归案例,在这个数列中每一项等于前两项之和(0、1、1、2、3、5、8、13、21等):
public func fibRec(_ n: Int) -> Int {
if n < 2 {
return n
} else {
return fibRec(n-1) + fibRec(n-2)
}
}
在检查上述代码时,代码未报错并按预期实现。但是,该算法的性能有些需要注意:
如上述表格所示,函数被调用的次数显著增加。与我们前面的示例类似,算法的性能根据输入大小呈指数级下降,这是因为该操作不存储以前的计算值。如果存储变量不能访问,我们获取前面所需值的唯一方法就是递归。假设在生产环境中使用此代码,该函数可能会引入bug或性能错误。让我们来重构代码以使用记忆方法:
func fibMemoizedPosition(_ n: Int) -> Int {
var sequence: Array<Int> = [0, 1]
var results: Int = 0
var i: Int = sequence.count
//trivial case
guard n > i else {
return n
}
//all other cases..
while i <= n {
results = sequence[i - 1] + sequence[i - 2]
sequence.append(results)
i += 1
}
return results
}
修改后的解决方案通过使用存储变量可支持记忆化方法,且重构后的代码不再需要递归技术。最前面的两个值相加,其和被添加到结果中,结果再被添加到主数组序列中。虽然算法的性能仍然取决于序列大小,但我们的修改将算法的时间复杂度提高到O(n) ——线性时间。另外,因为只添加单个函数到调用堆栈中,我们的迭代解决方案较容易修改、测试和调试,从而减少了内存管理和对象作用域的复杂性。
总结
通过本文我们了解了动态规划并不是一种特定的设计模式,而是一种思维方式。它的目标是创建一个解决方案来保存以前看到的值,以提高时间效率。虽然示例涵盖的是基本算法,但动态规划几乎为所有程序提供了基础,这当中既包括使用简单变量也包括复杂的数据结构。
译者介绍
赵青窕,51CTO社区编辑,从事多年驱动开发。研究兴趣包含安全OS和网络安全领域,曾获得陕西赛区数学建模奖,发表过网络相关专利。
原文The complete beginners guide to dynamic programming,作者:Wayne Bishop