项目中模糊匹配算法的实现
最近在项目中遇到了很多模糊匹配字符串的需求,总结一下实现思路。
大体需求场景是这样的:省项目中,各个地市报送的本地证照目录非常不规范,只有不规范的证照名称,且没有与国家标准证照目录中的证照名称进行对应。需要将这些名称不规范的证照与国家标准目录中的证照对应起来。
拿到一个不规范的证照名称,需要将其与国家标准目录中的证照名称进行一一比对,并选取匹配度最高的一个国家标准证照作为结果。
匹配度的计算
那么首先,需要设计一个方法,对两个字符串进行模糊匹配并计算两个字符串的匹配度。
字符串比对,首先想到了 KMP 算法。但原生的 KMP 算法只能用来判断两个字符串的包含关系,“匹配度” 并不是只用是否包含就可以表示的。比如 “建设工程施工许可”、“施工(房屋)许可证书”虽然不存在包含关系,但实际上是同一个证照。“匹配度” 应该由两个字符串的最长公共子序列来描述更为确切。
一个字符串的 子序列 是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。例如,"ace" 是 "abcde" 的子序列,但 "aec" 不是 "abcde" 的子序列。两个字符串的「公共子序列」是这两个字符串所共同拥有的子序列。
使用 最长公共子序列的长度%较短字符串的长度 可以很好的描述两个字符串的匹配度。
求取最长公共子序列的暴力实现:
public int longestCommonSubsequence(String text1, String text2) { return searchSame(text1, text2, 0, 0, new int[text1.length()][text2.length()]); } /** * @Author Niuxy * @Date 2020/10/2 7:12 下午 * @Description * 最长公共子序列实现中,每个节点将面临四种不同的选择情况,设 N=Max(str1.length,str2.length),时间复杂度为 3 的 N 次幂 * 函数 searchSame( point1,point2 ) 表示 在 point1,point2 指向的字节前的字符串最大的重合长度 * 则可以发现,递归过程中,每对 point 指针指向的结果是可以被复用的 * 建立缓存避免重复计算 */ private static int searchSame(String str1, String str2, int point1, int point2, int[][] cache) { if (point1 == str1.length() || point2 == str2.length()) return 0; if (cache[point1][point2] != 0) return cache[point1][point2]; int re = 0; if (str1.charAt(point1) == str2.charAt(point2)) re = searchSame(str1, str2, point1 + 1, point2 + 1, cache) + 1; re = Math.max(re, searchSame(str1, str2, point1 + 1, point2, cache)); re = Math.max(re, searchSame(str1, str2, point1, point2 + 1, cache)); re = Math.max(re, searchSame(str1, str2, point1 + 1, point2 + 1, cache)); System.out.println("point 1:" + point1 + " ,point2:" + point2); return cache[point1][point2] = re; }
递归优化为递推,在缓存表上进行二维 DP :
public static int longestCommonSubsequenceDP(String str1, String str2) { if (str1 == null || str2 == null) return 0; int m = str1.length(), n = str2.length(); int[][] cache = new int[m + 1][n + 1]; for (int i = m - 1; i >= 0; i--) { for (int j = n - 1; j >= 0; j--) { if (str1.charAt(i) == str2.charAt(j)) cache[i][j] = cache[i + 1][j + 1] + 1; else cache[i][j] = Math.max(cache[i][j + 1], cache[i + 1][j]); } } return cache[0][0]; }
时间复杂度又 3 的 max(n,m) 次幂 优化为 n*m (n,m 分别为两个入参字符串的长度)。至此,求取最长公共子序列的方法便确定下来了。
因为缓存使用的是二维数组,需要连续的存储空间,在待比较字符串长度较长时所需连续空间较大。空间较为紧张时可使用 Map 来替代数组:
public static int longestCommonSubsequenceDP(String str1, String str2) { if (str1 == null || str2 == null) return 0; int m = str1.length(), n = str2.length(); Map<Long, Integer> cache = new HashMap<Long, Integer>(); for (int i = m - 1; i >= 0; i--) { for (int j = n - 1; j >= 0; j--) { long key = getKey(i, j); if (str1.charAt(i) == str2.charAt(j)) cache.put(key, cache.getOrDefault(getKey(i + 1, j + 1),0)+1); else cache.put(key, Math.max(cache.getOrDefault(getKey(i, j + 1),0), cache.getOrDefault(getKey(i + 1, j),0))); } } return cache.get(getKey(0, 0)); } private static long getKey(int i, int j) { return ((long)i<<32)|j; }
在 key 值的计算上使用了 i,j 分别表示 long 高低位字节的方式来避免 key 的冲突。
最长公共子序列的长度 % min(n,m) 便可表示重合率:
//计算重合率 private static double coincidenceRate(String str1, String str2, int length) { int coincidenc = longestCommonSubsequence(str1, str2); return MathUtils.txfloat(coincidenc, length); }
去除冗余信息
在计算匹配度前,应当去除字符串中的冗余信息,进一步提高比对的精度。
比如 “中华人民共和国结婚证”、“中国结婚证书” 中,“中华人民共和国” 与 “中国” 对于比对来说,实际上是冗余信息。比对这两个字符串是否是同一个证照,只需要比对 “结婚证” 三个字即可。一股脑的对所有信息进行比对,会造成较大的误差。
因此在比对前,对于一些可以提前确定的常用的冗余信息,应当提前去除掉。比如对于证照名称比对的场景来说:“中华人民共和国”、“中国”、“山东省”、“XX市”、"书" 等都是应当提前去除的冗余信息。“中华人民共和国结婚证” 与 “山东省结婚证” 实际上都是同一个证照。
private static String deleteRedundances(String str, String[] redundances) { StringBuilder stringBuilder = new StringBuilder(str); for (String redundance : redundances) { int index = stringBuilder.indexOf(redundance); if (index != -1) stringBuilder.replace(index, index + redundance.length(), ""); } return stringBuilder.toString(); }
可以将可预判的冗余信息放在 redundances 中,将 str 中的冗余信息依次剔除。
阈值的设置
可以计算两个字符串的重合率,并可以提前去除一些冗余字段来提升判断的精度后,还需要对阈值的设置进行支持。
重合率高于阈值时,则认定两个字符串为相似字符串,指向同一证照。
//比较重合率与阈值 public static boolean isSame(String str1, String str2, int length, double threshold) { double re = coincidenceRate(str1, str2, length); return re >= threshold; }
至此,一个通用且简陋的模糊查询工具便完成了,完整代码:
/** * @Author Niuxy * @Date 2020/9/30 1:12 下午 * @Description 字符串模糊匹配 */ public class StringCompareUtil { /** * @Author Niuxy * @Date 2020/9/30 2:08 下午 * @Description str1, str2 待比较字符串,threshold: 比较阈值,redundances: 冗余信息项 */ public static boolean isSame(String str1, String str2, double threshold, String[] redundances) { if (str1 == null || str2 == null || str1.length() == 0 || str2.length() == 0) throw new NullPointerException("str1 or str2 is null"); str1 = deleteRedundances(str1, redundances); str2 = deleteRedundances(str2, redundances); int length = Math.max(str1.length(), str2.length()); return isSame(str1, str2, length, threshold); } //比较重合率与阈值 public static boolean isSame(String str1, String str2, int length, double threshold) { double re = coincidenceRate(str1, str2, length); return re >= threshold; } //计算重合率 private static double coincidenceRate(String str1, String str2, int length) { int coincidenc = longestCommonSubsequence(str1, str2); return MathUtils.txfloat(coincidenc, length); } //去处冗余 private static String deleteRedundances(String str, String[] redundances) { StringBuilder stringBuilder = new StringBuilder(str); for (String redundance : redundances) { int index = stringBuilder.indexOf(redundance); if (index != -1) stringBuilder.replace(index, index + redundance.length(), ""); } return stringBuilder.toString(); } //计算最长公共子序列 public static int longestCommonSubsequence(String str1, String str2) { if (str1 == null || str2 == null) return 0; int m = str1.length(), n = str2.length(); int[][] cache = new int[m + 1][n + 1]; for (int i = m - 1; i >= 0; i--) { for (int j = n - 1; j >= 0; j--) { if (str1.charAt(i) == str2.charAt(j)) cache[i][j] = cache[i + 1][j + 1] + 1; else cache[i][j] = Math.max(cache[i][j + 1], cache[i + 1][j]); } } return cache[0][0]; } }
针对特定场景(证照名称比对)的使用:
public class LicenseStringUtils { static final String[] redundances = new String[]{"中华人民共和国", "中国", "证书", "证照", "书", "审批表", "申请表", "表","(",")","(",")","批准","登记","书","经营","许可证"}; static public boolean isSame(String str0, String str1) { return StringCompareUtil.isSame(str0, str1, 0.75, redundances); } }
方法简单,但可以满足我目前的需求。
只是对于一些及其相似的证照,比如 “中华人民共和国残疾人证”、“中华人民共和国残疾军人证”,在阈值为 0.75 时无法分辨。
阈值设置太大又会造成一些证照的漏配,像这种极为相似的证照,就需要在阈值的选择上和冗余信息的选择上进行更贴合使用场景的优化了。
因为我的需求场景要求没有这么细致,且该类情况较少。类似的情况我直接在后期进行了人工干预(按名称排序人工检查一下即可)、
欢迎指出更好的方案或优化建议。