TL; 博士
在本文中,我试图基于两个示例来解释动态编程和分而治之方法之间的差异/相似之处:二分搜索和最小编辑距离(Levenshtein 距离)。
问题
当我开始学习算法时,我很难理解动态规划 (DP) 的主要思想以及它与分而治之 (DC) 方法的不同之处。当要比较这两种范式时,通常斐波那契函数作为一个很好的例子来拯救。但是,当我们尝试使用 DP 和 DC 方法来解决相同的问题来解释它们中的每一个时,对我来说,感觉就像我们可能会丢失可能有助于更快发现差异的有价值的细节。这些细节告诉我们,每种技术最适合解决不同类型的问题。
我还在理解DP和DC区别的过程中,我不能说到目前为止我已经完全掌握了这些概念。但我希望这篇文章能提供一些额外的启发,并帮助您学习动态规划和分而治之等有价值的算法范式。
动态规划和分而治之的相似性
正如我现在看到的,我可以说动态编程是分治范式的扩展。
我不会将它们视为完全不同的东西。因为它们都通过递归地将问题分解为两个或多个相同或相关类型的子问题来工作,直到这些子问题变得足够简单可以直接解决。然后组合子问题的解决方案以给出原始问题的解决方案。
那么为什么我们仍然有不同的范式名称以及为什么我将动态编程称为扩展。这是因为只有当问题具有一定的限制或先决条件时,才可以将动态规划方法应用于该问题。在那之后,动态编程通过记忆化或制表技术扩展了分而治之的方法。
让我们一步一步来……
动态规划先决条件/限制
正如我们刚刚发现的,为了使动态规划适用,分治问题必须具有两个关键属性:
一旦满足这两个条件,我们可以说这个分而治之的问题可以使用动态规划方法来解决。
分而治之的动态规划扩展
动态编程方法通过两种技术(记忆和制表)扩展了分而治之的方法,这两种技术的目的都是存储和重用可以显着提高性能的子问题解决方案。例如,斐波那契函数的朴素递归实现具有 O(2^n) 的时间复杂度,其中 DP 解决方案仅用 O(n) 时间执行相同的操作。
Memoization(自上而下的缓存填充)是指缓存和重用先前计算结果的技术。因此,记忆的 fib函数将如下所示:
memFib(n) {
if (mem[n] is undefined)
if (n < 2) result = n
else result = memFib(n-2) + memFib(n-1)
mem[n] = result
return mem[n]
}
制表(自底向上缓存填充)类似,但侧重于填充缓存的条目。以迭代方式计算缓存中的值是最容易的。 fib 的制表版本如下所示:
tabFib(n) {
mem[0] = 0
mem[1] = 1
for i = 2...n
mem[i] = mem[i-2] + mem[i-1]
return mem[n]
}
您可以在此处阅读有关记忆和制表比较的更多信息。
您在这里应该掌握的主要思想是,由于我们的分治问题具有重叠的子问题,因此可以缓存子问题的解决方案,从而实现记忆/制表。
那么DP和DC到底有什么区别
由于我们现在熟悉 DP 先决条件及其方法,我们准备将上面提到的所有内容放在一张图片中。
动态规划和分而治之的范式依赖
让我们去尝试使用 DP 和 DC 方法解决一些问题,以使这个说明更加清晰。
分而治之的例子:二分搜索
二分查找算法,也称为半区间查找,是一种查找目标值在已排序数组中的位置的查找算法。二分查找将目标值与数组的中间元素进行比较;如果它们不相等,则消除目标不能位于其中的一半,并继续搜索剩余的一半,直到找到目标值。如果搜索以剩余的一半为空结束,则目标不在数组中。
例子
这是二进制搜索算法的可视化,其中 4 是目标值。
二分查找算法逻辑
让我们以决策树的形式绘制相同的逻辑。
二分搜索算法决策树
您可以在这里清楚地看到解决问题的分而治之的原则。我们迭代地将原始数组分解为子数组并尝试在其中找到所需的元素。
我们可以对其应用动态规划吗?不,这是因为没有重叠的子问题。每次我们将数组拆分为完全独立的部分。并且根据分而治之的先决条件/限制,子问题必须以某种方式重叠。
通常,每次绘制决策树并且它实际上是一棵树(而不是决策图)时,这意味着您没有重叠的子问题,这不是动态规划问题。
代码
在这里您可以找到带有测试用例和解释的二进制搜索函数的完整源代码。
function binarySearch(sortedArray, seekElement) {
let startIndex = 0;
let endIndex = sortedArray.length - 1;
while (startIndex <= endIndex) {
const middleIndex = startIndex + Math.floor((endIndex - startIndex) / 2);
// If we've found the element just return its position.
if (sortedArray[middleIndex] === seekElement)) {
return middleIndex;
}
// Decide which half to choose: left or right one.
if (sortedArray[middleIndex] < seekElement)) {
// Go to the right half of the array.
startIndex = middleIndex + 1;
} else {
// Go to the left half of the array.
endIndex = middleIndex - 1;
}
}
return -1;
}
动态规划示例:最小编辑距离
通常,当涉及到动态编程示例时,默认采用斐波那契数算法。但是让我们采用更复杂的算法来获得某种有助于我们掌握概念的多样性。
最小编辑距离(或 Levenshtein 距离)是用于测量两个序列之间差异的字符串度量。非正式地,两个单词之间的 Levenshtein 距离是将一个单词更改为另一个单词所需的最小单字符编辑(插入、删除或替换)次数。
例子
例如,“kitten”和“sitting”之间的 Levenshtein 距离为 3,因为以下三个编辑将一个更改为另一个,并且少于三个编辑没有办法做到这一点:
应用
这具有广泛的应用,例如拼写检查器、光学字符识别校正系统、模糊字符串搜索以及基于翻译记忆库辅助自然语言翻译的软件。
数学定义
在数学上,两个字符串a、b(长度分别为 |a| 和 |b|)之间的 Levenshtein 距离由函数lev(|a|, |b|) 给出,其中
请注意,最小值中的第一个元素对应于删除(从 a 到 b),第二个是插入,第三个是匹配或不匹配,具体取决于各个符号是否相同。
解释
好的,让我们试着弄清楚那个公式在说什么。让我们举一个简单的例子来寻找字符串ME 和 MY 之间的最小编辑距离。直觉上你已经知道这里的最小编辑距离是 1 个操作,这个操作是“用 Y 替换 E”。但是让我们尝试以算法的形式将其形式化,以便能够执行更复杂的示例,例如将星期六转换为星期日。
要将公式应用于 ME>MY 转换,我们需要知道之前 ME>M、M>MY 和 M>M 转换的最小编辑距离。然后我们需要选择最小的一个并添加 +1 操作来转换最后一个字母 E?Y。
所以我们在这里已经可以看到解决方案的递归性质:ME>MY 转换的最小编辑距离是根据三个先前可能的转换计算的。因此我们可以说这是分治算法。
为了进一步解释这一点,让我们绘制以下矩阵。
查找 ME 和 MY字符串之间最小编辑距离的简单示例
单元格 (0, 1) 包含红色数字 1。这意味着我们需要 1 次操作将 M 转换为空字符串:删除 M。这就是为什么这个数字是红色的。
单元格 (0, 2) 包含红色数字 2。这意味着我们需要 2 个操作将 ME 转换为空字符串:删除 E,删除 M。
单元格(1, 0)包含绿色数字1。这意味着我们需要1次操作将空字符串转换为M:插入M。这就是为什么这个数字是绿色的。
单元格 (2, 0) 包含绿色数字 2。这意味着我们需要 2 个操作将空字符串转换为 MY:插入 Y,插入 M。
单元格 (1, 1) 包含数字 0。这意味着将 M 转换为 M 不需要任何成本。
单元格 (1, 2) 包含红色数字 1。这意味着我们需要 1 个操作将 ME 转换为 M:删除 E。
等等…
对于像我们这样的小矩阵(只有 3×3),这看起来很容易。但是我们如何计算更大矩阵的所有这些数字(假设 9×7 一个,周六>周日转换)?
好消息是,根据公式,您只需要三个相邻的单元格 (i-1, j)、(i-1, j-1) 和 (i, j-1) 来计算当前单元格的数量 (i , j) 。我们需要做的就是找到这三个单元格中的最小值,然后添加 +1,以防我们在行和 js 列中有不同的字母
因此,您可能会再次清楚地看到问题的递归性质。
最小编辑距离问题的递归性质
好的,我们刚刚发现我们在这里处理分而治之的问题。但是我们可以将动态规划方法应用于它吗?这个问题是否满足我们重叠的子问题和最优子结构限制?是的。让我们从决策图中看到它。
具有重叠子问题的最小编辑距离的决策图
首先,这不是决策树。它是一个决策图。您可能会在图片上看到许多用红色标记的重叠子问题。也没有办法减少操作次数并使其少于公式中这三个相邻单元格的最小值。
此外,您可能会注意到矩阵中的每个单元格编号都是根据之前的单元格编号计算的。因此,这里应用了制表技术(以自下而上的方向填充缓存)。您将在下面的代码示例中看到它。
进一步应用这个原则,我们可以解决更复杂的情况,比如周六>周日转换。
将星期六转换为星期日的最小编辑距离
代码
在这里您可以找到带有测试用例和解释的最小编辑距离函数的完整源代码。
function levenshteinDistance(a, b) {
const distanceMatrix = Array(b.length + 1)
.fill(null)
.map(
() => Array(a.length + 1).fill(null)
);
for (let i = 0; i <= a.length; i += 1) {
distanceMatrix[0][i] = i;
}
for (let j = 0; j <= b.length; j += 1) {
distanceMatrix[j][0] = j;
}
for (let j = 1; j <= b.length; j += 1) {
for (let i = 1; i <= a.length; i += 1) {
const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
distanceMatrix[j][i] = Math.min(
distanceMatrix[j][i - 1] + 1, // deletion
distanceMatrix[j - 1][i] + 1, // insertion
distanceMatrix[j - 1][i - 1] + indicator, // substitution
);
}
}
return distanceMatrix[b.length][a.length];
}
结论
在本文中,我们比较了动态规划和分治法等两种算法方法。我们发现动态规划基于分治原则,并且只有在问题具有重叠子问题和最优子结构时才可以应用(例如在 Levenshtein 距离情况下)。动态编程然后使用记忆化或制表技术来存储重叠子问题的解决方案以供以后使用。
我希望这篇文章没有给您带来更多的困惑,而是让您对这两个重要的算法概念有所了解! 🙂
您可以在 JavaScript 算法和数据结构存储库中找到更多关于分而治之和动态编程问题的示例以及解释、注释和测试用例。
快乐编码!
如果您希望与专家一起参加现场课程,请参阅DSA 现场工作专业课程和学生竞争性编程现场课程。