动态规划(dp)-最短编辑距离

应该是我接触的第一个动态规划的算法,可以说是很经典了,Levenstein 距离。

问题描述

设A和B是2个字符串。要用最少的字符操作将字符串A转换为字符串B。这里所说的字符操作包括:
(1)删除一个字符;
(2)插入一个字符;
(3)将一个字符改为另一个字符。
将字符串 A 变换为字符串 B 所用的最少字符操作数,称为字符串 A 到 B 的编辑距离,记为d(A,B)。试设计一个有效算法,对任给的2个字符串 A 和 B,计算出它们的编辑距离d(A,B)

input

输入数据的第一行是一个正整数,表示一共有几组数据。

  • 每组数据有两行,每行一个字符串。
  • 每个字符串长度不超过1000
  • 字符串中只含小写英文字母

output

对于每组数据,请输出一个整数表示两个字符串的编辑距离(最短编辑距离)

Sample Input :
10 AGTCTGACGC
11 AGTAAGTAGGC
5 abcdy
4 abds
7 dewjfsr
4 ejfr
Sample output
4
2
3

推导

dp[ i ][ j ]=Min\left\{ \begin{aligned} dp[i - 1][j] + 1\\ dp[i][j - 1] + 1 \\ dp[i - 1][j - 1] & & {当 A[i - 1] = B[j - 1]} \\ dp[i - 1][j - 1] + 1& & {当 A[i - 1] \neq B[j - 1]} \\ \end{aligned} \right.
假设求 'abcy' 和 'aby' 的编辑距离

dp[i - 1][j] + 1

删除'abcy'中的y —— 也可以理解为在求 'abc' 和 'aby' 编辑距离的基础上,在 'abc' 尾部插入一个 y,推导而来

dp[i][j - 1] + 1

删除 'aby' 中的 y, —— 也可以理解为在求 'abcy' 和 'ab'编辑距离的基础上,在 ’ab' 尾部插入一个 y,推导而来

dp[i - 1][j - 1] \qquad{ A[i - 1] = B[j - 1]}

因为 'abcy' 第 i 个字符 'y' 和 'aby' 中当前第 j 个字符 'y' 相等,所以,可以认为在求 'abc' 和 'ab' 编辑距离的基础上没有任何编辑操作,后接一个空字符。

dp[i - 1][j - 1] + 1 \qquad{ A[i - 1] \neq B[j - 1]}

模板

求最短编辑距离 POJ 3356

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define Min(a , b , c) (a < b ? (a < c ? a : c) : (b < c ? b : c))
int dp[1010][1010];
char s1[1010] , s2[1010];

int main(){
    // freopen("Levenshtein.in", "r", stdin);
    int n , m , i , j , cost;
    while(~scanf("%d%s%d%s" , &n, s1 , &m, s2)){
        memset(dp, 0, sizeof(dp));
        for(i = 0; i <= n; ++i) dp[i][0] = i;  // 另一个字符串长度为 0 时,长度就是本字符串当前长度
        for(i = 0; i <= m; ++i) dp[0][i] = i;
        for(i = 1; i <= n; ++i)
            for(j = 1; j <= m; ++j){
                // 若当前字符相等,dp[i][j] = dp[i - 1][j - 1];否则,替换当前字符,编辑次数 +1
                cost = s1[i - 1] == s2[j - 1] ? 0 : 1;    
                dp[i][j] = Min(dp[i-1][j] + 1 , dp[i][j - 1] + 1 , dp[i - 1][j - 1] + cost);
            }    
        printf("%d\n", dp[n][m]);
    }
    return 0;
}

分析:

  1. dp[i][j] 代表的是句子 s1 到了第 i 个字符,s2 到了第 j 个字符的编辑距离,例如:
    s1 = 'abcy'
    s2 = 'aby'
    dp[3][2] 代表 s1的子句 'abc',和 s2 的子句 'ab' 的(最短)编辑距离,是 1
    根据循环特性,可得 0 <= i < 3,0 <= j < 2,dp[i][j] 都推导出来了。
  2. for(i = 0; i <= n; ++i) dp[i][0] = i;
    初始化推导的首项(类似数组的推导,一般都具有首项),当两个句子中有一个句子为空,最短编辑距离就是非空的句子长度,在这里,i 代表的是当前的非空句子长度。
  3. cost = s1[i - 1] == s2[j - 1] ? 0 : 1;
    因为字符串从下标0开始存储,所以 s1[i - 1] 代表句子 s1 当前的字符,s2[j - 1] 同理。
    (1). 如果 s1 和 s2 当前的字符相等,那么 dp[i][j] 有可能是 dp[i - 1[j - 1],例子:
    求 'abcy' 和 'aby' 的编辑距离,已知 'abc' 和 'ab' 的编辑距离是 1,恰好最后一个字符 'y' 相等,那么 'abcy' 和 'aby' 的编辑距离可能依旧是 1.
    (2). 如果 s1 和 s2 当前的字符不相等,说明,在已知 dp[i - 1][j - 1] 的基础,至少还要再编辑一次(替换),编辑次数 + 1
  4. dp[i][j] = Min(dp[i-1][j] + 1 , dp[i][j - 1] + 1 , dp[i - 1][j - 1] + cost);
    dp[i][j] 可以有三种可能性,从中选最小的距离作为当前的最短编辑距离。

优化 or 变体

1. 滚动数组,降低空间复杂度

观察主循环

for(i = 1; i <= n; ++i)
    for(j = 1; j <= m; ++j){
         cost = s1[i - 1] == s2[j - 1] ? 0 : 1;   
         dp[i][j] = Min(dp[i-1][j] + 1 , dp[i][j-1] + 1 , dp[i-1][j-1] + cost);
    }

外循环是 i,i 代表逐行;可以发现,每次推导 dp[i][j] ,都只利用了 dp[i - 1] 的信息,也就是说,推导第 i 层,只需要记录第 i - 1层的状态,所以也只需要存储第 i - 1 层的信息。
原来的 dp[1005][1005] 存储的是所有层的状态,现在减少到相邻两层 dp[2][1005],也就是 dp[0][1005] 和 dp[1][1005],假设偶数层都存储在 dp[0][1005],奇数层都存储在 dp[1][1005]。
通过判断奇偶数 %2,但位运算 &1 可以略微加速,实现类似“滚动”的效果。
故修改代码如下:

#include <stdio.h>
#include <string.h>
#define Min(a , b , c) (a < b ? (a < c ? a : c) : (b < c ? b : c))
int dp[2][1010];
char s1[1010] , s2[1010];

int main(){
    // freopen("Levenshtein.in", "r", stdin);
    int n , m , i , j , cost;
    while(~scanf("%d%s%d%s" , &n, s1 , &m, s2)){
        memset(dp, 0, sizeof(dp));
        for(i = 0; i <= m; ++i) 
            dp[0][i] = i;
        for(i = 1; i <= n; ++i) {
            dp[i & 1][0] = i;    // 因为 dp 只能记录相邻两层的状态,所以不能像原来一样初始化
            for(j = 1; j <= m; ++j){
                // 若当前字符相等,dp[i][j] = dp[i - 1][j - 1];否则,替换当前字符,编辑次数 +1
                cost = s1[i - 1] == s2[j - 1] ? 0 : 1;     
                dp[i & 1][j] = Min(dp[(i-1) & 1][j] + 1 , dp[i & 1][j-1] + 1 , dp[(i-1) & 1][j-1] + cost);
            }   
        }
        printf("%d\n", dp[n & 1][m]);
    }
    return 0;
}

前后对比:

可以看出,时间消耗一样,但内存消耗缩小到原来的十几分之一。滚动数组对于这种情况能节省极大内存。同时,也能看出位运算 & 对于程序速度的影响其实很细微。
但滚动数组也存在缺点,因为只记录了相邻两层的状态,所以不能找回推导的路径( A如何编辑得到 B )。

递归

根据推导方程,递归的回溯过程是正向的推导。代码如下:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define Min(a, b, c) (a < b ? (a < c ? a : c) : (b < c ? b : c))
int dp[1010][1010];
char s1[1010], s2[1010];

int edit_distance(const int i, const int j) {
    // 判断边界条件
    if(i == 0) return j;
    if(j == 0) return i;
    // 判断当前两个句子的字符是否一样,是否需要额外的编辑操作
    const int cost = s1[i - 1] == s2[j - 1] ? 0 : 1;
    const int a = edit_distance(i - 1, j) + 1;
    const int b = edit_distance(i, j - 1) + 1;
    const int c = edit_distance(i - 1, j - 1) + cost;
    return Min(a, b, c);
}

int main(){
    // freopen("Levenshtein.in", "r", stdin);
    int n, m;
    while(~scanf("%d%s%d%s", &n, s1, &m, s2))
        printf("%d\n", edit_distance(n, m));
    return 0;
}

但是根据题目要求,字符串长度可达 1000,这对于递归来说深度太大,压栈参数多,在 OJ 上会超时,但是递归过程很明晰。
注:又掉进了一个坑!

return Min(
    edit_distance(i - 1, j) + 1,
    edit_distance(i, j - 1) + 1,
    edit_distance(i - 1, j - 1) + cost
);

define 宏定义和递归不可以一起用,宏定义是展开,会计算多次递归,浪费时间与空间!

内容扩展

1. 应用场景

可以用于模糊搜索、找相似度较高的句子,应用有拼写检查、求相似度等。
例如给定两个句子:

A = “最佳的编辑软件” 和
B = “最佳的游戏软件”,

二者的最短编辑距离是 2 (2次替换),那么二者的相似度可以用 1 - 编辑距离/ [len(A) + len(B)]表示,这里是

1 - 2 / (7 + 7) = 1 - 0.1428 = 0.8571,

编辑距离越短,表示这两个句子经过越少次数的插入或替换等操作即可相互转换,相似度越高

2. 存在的问题

字符串编辑距离只考虑到了句子在“字形”上的距离,并没有考虑到每个字符(短语)背后代表的深层含义,在上个例子中:

“编辑”和“游戏”编辑距离是2,但二者在语义上的差距较大;
考虑“休闲”和“游戏”,字形上的编辑距离也是2,但是二者在语义上无疑更近一步。

对于搜索等任务来说,用户更渴望得到在语义上更相近的答案。
这也是传统文本搜索的弊病,没考虑语义距离;

3. 更好的距离衡量

那么如何衡量词语之间的语义距离呢?词向量给出了答案 :word2vec

基本原理可以直观理解为:

  1. 利用向量代表一个词语,将词语映射到“语义空间”,两个词语之间的距离可以通过它们的向量距离来衡量。
  2. 如果表达【游戏】对 【编辑】与【休闲】的不同距离呢?共现频率的统计,经常出现在一起的词语在语义空间上应该是相近的,例如“游戏”和“休闲”经常出现[在一起开黑、网吧]等相关的上下文中,那么“游戏”和“休闲”在语义空间上就是相似的。但“编辑”和“游戏”几乎不会出现在相似的上下文中,二者的语义距离就很大了。通过梯度下降等算法不断拟合以上的情况,使得【游戏】与【休闲】的向量距离更小,但【游戏】与【编辑】之间的向量距离更大。

类似的算法

最大上升子序列
最大公共子序列

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,383评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,522评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,852评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,621评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,741评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,929评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,076评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,803评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,265评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,582评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,716评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,395评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,039评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,027评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,488评论 2 361
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,612评论 2 350

推荐阅读更多精彩内容