一、原理
接着文本搜索的话题,来聊聊查询推荐(Query Suggestion)的实现过程,以及它所使用的数学思想,动态规划(Dynamic Programming)。
什么是动态规划呢?在递归那一节,我们可以通过不断分解问题,将复杂的任务简化为最基本的小问题,比如基于递归实现的归并排序、排列和组合等。
不过有时候,我们并不用处理所有可能的情况,只要找到满足条件的最优解就行了。在这种情况下,我们需要在各种可能的局部解中,找出那些可能达到最优的局部解,而放弃其他的局部解。这个寻找最优解的过程其实就是动态规划。
动态规划需要通过子问题的最优解,推导出最终问题的最优解,因此这种方法特别注重子问题之间的转移关系。我们通常把这些子问题之间的转移称为状态转移,并把用于刻画这些状态转移的表达式称为状态转移方程。很显然,找到合适的状态转移方程,是动态规划的关键。
1.1 如何想到dp
如果仅仅看案例,也许你觉得动态规划不难理解。不过,在实际应用中,你可能会产生这些疑问:什么时候该用动态规划?这个问题可以用动态规划解决啊,为什么我没想到?这里讲一些个人的经验。
- 首先,如果一个问题有很多种可能,看上去需要使用排列或组合的思想,但是最终求的只是某种最优解(例如最小值、最大值、最短子串、最长子串等等),那么你不妨试试是否可以使用动态规划。
- 其次,状态转移方程是个关键。你可以用状态转移表来帮助自己理解整个过程。如果能找到准确的转移方程,那么离最终的代码实现就不远了。当然,最好的方式,还是结合工作中的项目,不断地实践,尝试,然后总结。
二、案例
下文通过实际的案例,详细解释如何用动态规划法找最优解,包括如何分解问题、发现状态转移的规律,以及定义状态转移方程。
2.1 编辑距离
当你在搜索引擎的搜索框中输入单词的时候,有没有发现,搜索引擎会返回一系列相关的关键词,方便你直接点击。甚至,当你某个单词输入有误的时候,搜索引擎依旧会返回正确的搜索结果。
搜索下拉提示和关键词纠错,这两个功能其实就是查询推荐。查询推荐的核心思想其实就是,对于用户的输入,查找相似的关键词并进行返回。而测量拉丁文的文本相似度,最常用的指标是编辑距离(Edit Distance)。
刚才说了,查询推荐的这两个功能是针对输入有缺失或者有错误的字符串,依旧返回相应的结果。那么,将错误的字符串转成正确的,以此来返回查询结果,这个过程究竟是怎么进行的呢?
由一个字符串转成另一个字符串所需的最少编辑操作次数,我们就叫作编辑距离。这个概念是俄罗斯科学家莱文斯坦提出来的,所以我们也把编辑距离称作莱文斯坦距离(Levenshtein distance)。很显然,编辑距离越小,说明这两个字符串越相似,可以互相作为查询推荐。编辑操作有这三种:把一个字符替换成另一个字符;插入一个字符;删除一个字符。
比如,我们想把 mouuse 转换成 mouse,有很多方法可以实现,但是很显然,直接删除一个“u”是最简单的,所以这两者的编辑距离就是 1。
2.1.1 状态转移
对于 mouse 和 mouuse 的例子,我们肉眼很快就能观察出来,编辑距离是 1。但是我们现实的场景中,常常不会这么简单。如果给定任意两个非常复杂的字符串,如何高效地计算出它们之间的编辑距离呢?
我们之前讲过排列和组合。我们先试试用排列的思想来进行编辑操作。
- 比如,把一个字符替换成另一个字符,我们可以想成把 A 中的一个字符替换成 B 中的一个字符。假设 B 中有 m 个不同的字符,那么替换的时候就有 m 种可能性。
- 对于插入一个字符,我们可以想成在 A 中插入来自 B 的一个字符,同样假设 B 中有 m 个不同的字符,那么也有 m 种可能性。
- 至于删除一个字符,我们可以想成在 A 中删除任何一个字符,假设 A 有 n 个不同的字符,那么有 n 种可能性。
可是,等到实现的时候,你会发现实际情况比想象中复杂得多。
- 首先,计算量非常大。我们假设字符串 A 的长度是 n,而 B 字符串中不同的字符数量是 m,那么 A 所有可能的排列大致在 m^n 这个数量级,这会导致非常久的处理时间。对于查询推荐等实时性的服务而言,服务器的响应时间太长,用户肯定无法接受。
- 其次,如果需要在字符串 A 中加字符,那么加几个呢,加在哪里呢?同样,删除字符也是如此。因此,可能的排列其实远不止 m^n。
我们现在回到问题本身,其实编辑距离只需要求最小的操作次数,并不要求列出所有的可能。而且排列过程非常容易出错,还会浪费大量计算资源。看来,排列的方法并不可行。
好,这里再来思考一下,其实我们并不需要排列的所有可能性,而只是关心最优解,也就是最短距离。那么,我们能不能每次都选择出一个到目前为止的最优解,并且只保留这种最优解?如果是这样,我们虽然还是使用迭代或者递归编程来实现,但效率上就可以提升很多。
- 我们先考虑最简单的情况。假设字符串 A 和 B 都是空字符串,那么很明显这个时候编辑距离就是 0。如果 A 增加一个字符 a1,B 保持不动,编辑距离就增加 1。同样,如果 B 增加一个字符 b1,A 保持不动,编辑距离增加 1。
- 但是,如果 A 和 B 有一个字符,那么问题就有点复杂了,我们可以细分为以下几种情况。
- 先看插入字符的情况。A 字符串是 a1 的时候,B 空串增加一个字符变为 b1;或者 B 字符串为 b1 的时候,A 空串增加一个字符变为 a1。很明显,这种情况下,编辑距离都要增加 1。
- 再看替换字符的情况。当 A 和 B 都是空串的时候,同时增加一个字符。如果要加入的字符 a1 和 b1 不相等,表示 A 和 B 之间转化的时候需要替换字符,那么编辑距离就是加 1;如果 a1 和 b1 相等,无需替换,那么编辑距离不变。
- 无需考虑删除,这是因为删除就是插入的逆操作。如果我们从完整的字符串 A 或者 B 开始,而不是从空串开始,这就是删除操作了。
- 最后,取上述三种情况中编辑距离的最小值作为当前的编辑距离。注意,这里我们【只需要保留这个最小的值,而舍弃其他更大的值】。这是为什么呢?因为编辑距离随着字符串的增长,是单调递增的。所以,要求最终的最小值,必须要保证对于每个子串,都取得了最小值。有了这点,之后我们就可以使用迭代的方式,一步步推导下去,直到两个字符串结束比较。
- 从上述的过程可以看出,我们确实可以把求编辑距离这个复杂的问题,划分为更多更小的子问题。而且,更为重要的一点是,【我们在每一个子问题中,都只需要保留一个最优解。之后的问题求解,只依赖这个最优值】。这种求编辑距离的方法就是动态规划,而【这些子问题在动态规划中被称为不同的状态】。
如果文字描述不是很清楚的话,我这里又画一张表,把各个状态之间的转移都标示清楚,你就一目了然了。
还是用 mouuse 和 mouse 的例子。
- 把 mouuse 的字符数组作为表格的行,每一行表示其中一个字母(如下图第一行是字符串B)
- 而 mouse 的字符数组作为列,每列表示其中一个字母(如下图第一列是字符串A)
- 这样就得到下面这个表格:
- 从(0,0)到(m,m)的情况如下:
-前两种插入情况,都是空串插入,A和B各插入一个m,一共做了两次插入所以编辑距离为0+1+1=2;因为编辑距离2表示的是A添加一个m字符,B再添加一个m字符。虽然在人看来两者相等,但对于计算机而言要遍历这种情况。- 对于第三种替换情况:A、B都插入m,不需要替换,所以全程没有替换操作,所以编辑距离为0+0=0(PS:如何替换的字母相同,那么就是0,否则是1)。
- 因此第三种情况最小,可以取到最小值0
- 从(0,m)到(0,mo)的情况,可能的路径是删掉一个m,在分别增加m和o,那么编辑距离就是3了,不过这不是最优的,在后面min函数会取直接在m上加o的这种可能,距离为1
- 从(0,0)到(m,m)的情况如下:
这张表格里的不同状态之间的转移,就是状态转移。其中红色部分表示字符串演变(或者说状态转移)的方式以及相应的编辑距离计算。对于表格中其他空白的部分,我暂时不给出,你可以试着自己来推导。
编辑距离是具有对称性的,也就是说从字符串 A 到 B 的编辑距离,和从字符串 B 到 A 的编辑距离,两者一定是相等的。这个应该很好理解:其实是由编辑距离的三种操作决定的。比如说,从字符串 A 演变到 B 的每一种操作,都可以转换为从字符串 B 演变到 A 的某一种操作(如下图)。所以说,从字符串 A 演变到 B 的每一种变化方式,都可以找到对应的从字符串 B 演变到 A 的某种方式,两者的操作次数一样。自然,代表最小操作次数的编辑距离也就一样了。
2.1.2 状态转移方程和编程实现
将上表完善后,完整的状态转移表格如下:
表格中求最小值的 min 函数里有三个参数,分别对应我们上节讲的三种情况的编辑距离,分别是:替换、插入和删除字符。在表格的右下角我标出了两个字符串的编辑距离 1。
概念和分析过程都理解了,作为程序员,最终还是要落脚在编码上,我这里带你做些编码前的准备工作。
假设字符数组 A[] 和 B[] 分别表示字符串 A 和 B,A[i] 表示字符串 A 中第 i 个位置的字符,B[i] 表示字符串 B 中第 i 个位置的字符。二维数组 d[,] 表示刚刚用于推导的二维表格,而 d[i,j] 表示这张表格中第 i 行、第 j 列求得的最终编辑距离。函数 r(i, j) 表示替换时产生的编辑距离。如果 A[i] 和 B[j] 相同,函数的返回值为 0,否则返回值为 1。
有了这些定义,下面我们用迭代来表达上述的推导过程。
- 如果 i 为 0,且 j 也为 0,那么 d[i, j] 为 0。
- 如果 i 为 0,且 j 大于 0,那么 d[i, j] 为 j。
- 如果 i 大于 0,且 j 为 0,那么 d[i, j] 为 i。
- 如果 i 大于 0,且 j 大于 0,那么 d[i, j]=min(d[i-1, j] + 1, d[i, j-1] + 1, d[i-1, j-1] + r(i, j))。这个表达式表示的是动态规划中从上一个状态到下一个状态之间可能存在的一些变化,以及基于这些变化的最终决策结果。我们把这样的表达式称为状态转移方程。
有了状态转移方程,我们就可以很清晰地用数学的方式,来描述状态转移及其对应的决策过程,而且,有了状态转移方程,具体的编码其实就很容易了。基于编辑距离的状态转移方程,我在这里列出了一种编码的实现,你可以看看。
// 首先要定义函数的参数和返回值,需要注意判断一下 a 和 b 为 null 的情况。
public class Lesson10_1 {
/**
* @Description: 使用状态转移方程,计算两个字符串之间的编辑距离
* @param a- 第一个字符串,b- 第二个字符串
* @return int- 两者之间的编辑距离
*/
public static int getStrDistance(String a, String b) {
if (a == null || b == null) return -1; // 防御式编程, 无效的空指针
// 然后,初始化状态转移表。我用 int 型的二维数组来表示这个状态转移表,并对 i 为 0 且 j 大于 0 的元素,以及 i 大于 0 且 j 为 0 的元素,赋予相应的初始值。
int[][] d = new int[a.length() + 1][b.length() + 1]; // 初始用于记录化状态转移的二维表
for (int j = 0; j <= b.length(); j++) { // 如果 i 为 0,且 j 大于等于 0,那么 d[i, j] 为 j
d[0][j] = j;
}
for (int i = 0; i <= a.length(); i++) { // 如果 i 大于等于 0,且 j 为 0,那么 d[i, j] 为 i
d[i][0] = i;
}
// 我这里实现的时候,i 和 j 都是从 0 开始,所以我计算的 d[i+1, j+1],而不是 d[i, j]。而 d[i+1, j+1] = min(d[i, j+1] + 1, d[i+1, j] + 1, d[i, j] + r(i, j)。
// 实现状态转移方程
// 请注意由于 Java 语言实现的关系,代码里的状态转移是从 d[i, j] 到 d[i+1, j+1],而不是从 d[i-1, j-1] 到 d[i, j]。本质上是一样的。
for (int i = 0; i < a.length(); i++) {
for (int j = 0; j < b.length(); j++) {
int r = 0;
if (a.charAt(i) != b.charAt(j)) {
r = 1;
}
int first_append = d[i][j + 1] + 1;
int second_append = d[i + 1][j] + 1;
int replace = d[i][j] + r;
int min = Math.min(first_append, second_append);
min = Math.min(min, replace);
d[i + 1][j + 1] = min;
}
}
return d[a.length()][b.length()];
}
}
// 最后,我们用测试代码测试不同字符串之间的编辑距离。
public static void main(String[] args) {
System.out.println(getStrDistance("mouse", "mouuse"));
}
// 从推导的表格和最终的代码可以看出,我们相互比较长度为 m 和 n 的两个字符串,一共需要求 m * n 个子问题,因此计算量是 m * n 这个数量级。和排列法的 m^n 相比,这已经降低太多太多了。
// 我们现在可以快速计算出编辑距离,所以就能使用这个距离作为衡量字符串之间相似度的一个标准,然后就可以进行查询推荐了。
// 到这里,使用动态规划来实现的编辑距离其实就讲完了。我把两个字符串比较的问题,分解成很多子串进行比较的子问题,然后使用状态转移方程来描述状态(也就是子问题)之间的关系,并根据问题的定义,保留最小的值作为当前的编辑距离,直到过程结束。
// 如果我们使用动态规划法来实现编辑距离的测算,那就能确保查询推荐的效率和效果。不过,基于编辑距离的算法也有局限性,它只适用于拉丁语系的相似度衡量,所以通常只用于英文或者拼音相关的查询。如果是在中文这种亚洲语系中,差一个汉字(或字符)语义就会差很远,所以并不适合使用基于编辑距离的算法。
2.2 钱币组合
和排列组合等穷举的方法相比,动态规划法关注发现某种最优解。如果一个问题无需求出所有可能的解,而是要找到满足一定条件的最优解,那么你就可以思考一下,是否能使用动态规划来降低求解的工作量。
之前我们提到的新版舍罕王奖赏的故事吗?国王需要支付一定数量的赏金,而宰相要列出所有可能的钱币组合,这使用了排列组合的思想。如果这个问题再变化为“给定总金额和可能的钱币面额,能否找出钱币数量最少的奖赏方式?”,那么我们是否就可以使用动态规划呢?
思路和之前是类似的。我们先把这个问题分解成很多更小金额的子问题,然后试图找出状态转移方程。如果增加一枚钱币 c,那么当前钱币的总数量就是增加 c 之前的钱币总数再加上当前这枚。举个例子,假设这里我们有三种面额的钱币,2 元、3 元和 7 元。为了凑满 100 元的总金额,我们有三种选择。
- 第一种,总和 98 元的钱币,加上 1 枚 2 元的钱币。如果凑到 98 元的最少币数是 x1,则增加一枚 2 元后就是 (x1 + 1) 枚。
- 第二种,总和 97 元的钱币,加上 1 枚 3 元的钱币。如果凑到 97 元的最少币数是 x2,则增加一枚 3 元后就是 (x2 + 1) 枚。
- 第三种,总和 93 元的钱币,加上 1 枚 7 元的钱币。如果凑到 93 元的最少币数是 x3,则增加一枚 7 元后就是 (x3 + 1) 枚。
比较一下以上三种情况的钱币总数,取最小的那个就是总额为 100 元时,最小的钱币数。换句话说,由于奖赏的总金额是固定的,所以最后选择的那枚钱币的面额,将决定到上一步为止的金额,同时也决定了上一步为止钱币的最少数量。根据这个,我们可以得出如下状态转移方程:
其中,c[i] 表示总额为 i 的时候,所需要的最少钱币数,其中 j=1,2,3,…,n,表示 n 种面额的钱币,value[j] 表示第 j 种钱币的面额。c[i - values(j)] 表示选择第 j 种钱币时,上一步为止最少的钱币数。需要注意的是,i - value(j) 需要大于等于 0,而且 c[0] = 0。
这里使用这个状态转移方程,做些推导,具体的数据可看下表。表格每一行表示奖赏的总额,前 3 列表示 3 种钱币的面额,最后一列记录最少的钱币数量。表中的“/”表示不可能,或者说无解。
这张状态转移表同样可以帮助理解状态转移方程的正确性。一旦状态转移方程确定了,要编写代码来实现就不难了,参考 LeetCode 322. 零钱兑换。