最近在項目中遇到了很多模糊匹配字符串的需求,總結一下實現思路。
大體需求場景是這樣的:省項目中,各個地市報送的本地證照目錄非常不規范,只有不規范的證照名稱,且沒有與國家標准證照目錄中的證照名稱進行對應。需要將這些名稱不規范的證照與國家標准目錄中的證照對應起來。
拿到一個不規范的證照名稱,需要將其與國家標准目錄中的證照名稱進行一一比對,並選取匹配度最高的一個國家標准證照作為結果。
匹配度的計算
那么首先,需要設計一個方法,對兩個字符串進行模糊匹配並計算兩個字符串的匹配度。
字符串比對,首先想到了 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 時無法分辨。
閾值設置太大又會造成一些證照的漏配,像這種極為相似的證照,就需要在閾值的選擇上和冗余信息的選擇上進行更貼合使用場景的優化了。
因為我的需求場景要求沒有這么細致,且該類情況較少。類似的情況我直接在后期進行了人工干預(按名稱排序人工檢查一下即可)、
歡迎指出更好的方案或優化建議。