借助豆包做的这个相似度匹配公式,vlookup公式的模糊匹配使用起来一言难尽,经测试1500对1500的匹配大概3分钟完成。使用方法:打开空白表,将代码复制到宏编辑器,保存为 .xlam文件,再加载到wps里面后续使用就跟普通公式一样使用了。
/**
* 【自定义函数】多粒度精准相似度匹配(跨Sheet + 阈值过滤 + 显示最高匹配度)
* 使用方法:
* =mlooklup(查找值, 查找列) → 返回最优匹配
* =mlooklup(查找值, 查找列, 0.8) → 大于等于80%匹配度才返回
* =mlooklup(查找值, 查找列, 80%) → 同上
* =mlooklup(查找值, 查找列, 1.2) → 仅完全一致返回
* =mlooklup(查找值, 查找列, 0) → 不限制,返回最优
* @param {Range} target - 查找值所在单元格
* @param {Range} matchRange - 要匹配的列/区域(鼠标直接点选,支持跨Sheet)
* @param {number|string} [threshold=null] - 匹配阈值(支持小数/百分比)
* @returns {string} 最匹配结果 或 无匹配提示(含最高匹配度)
*/
function mlooklup(target, matchRange, threshold = null) {
// ===================== 1. 获取匹配区域的工作表与列信息(跨Sheet核心) =====================
// 获取用户选中区域所在的工作表(不是当前活动表,保证跨表正常)
const lookupSheet = matchRange.Worksheet;
// 获取匹配区域所在的列号(自动识别,无需手动输入数字)
const matchColNum = matchRange.Column;
// 获取该列最后一行非空行号,全空时默认1,避免循环报错
const maxRow = lookupSheet.Cells(lookupSheet.Rows.Count, matchColNum).End(-4162).Row || 1;
// ===================== 2. 初始化最优结果变量 =====================
let bestItem = null; // 存储最匹配的文本内容
let bestScore = 0; // 存储最高综合相似度(0~1)
// ===================== 3. 处理查找值(格式统一) =====================
// 安全获取查找值,容错空值/无效对象
let targetValue = (target?.Value2 ?? "").toString().trim();
// 查找值为空,直接返回提示
if (!targetValue) return "目标值不能为空";
// 替换罗马数字:II→2,I→1
targetValue = replaceIIandI(targetValue);
// 文本清洗:全角转半角 + 小写 + 去空格
const tClean = cleanText(targetValue);
// ===================== 4. 遍历匹配列,计算相似度 =====================
// 从第2行开始遍历,跳过表头
for (let row = 2; row <= maxRow; row++) {
try {
// 获取当前行单元格内容
let cellValue = (lookupSheet.Cells(row, matchColNum).Value2 ?? "").toString().trim();
// 空值跳过
if (!cellValue) continue;
// 统一格式:替换罗马数字 + 文本清洗
cellValue = replaceIIandI(cellValue);
const cClean = cleanText(cellValue);
// 核心:计算4维度综合相似度
const sim = getMultiSimilarity(tClean, cClean);
// 更新最高分与最优匹配项
if (sim > bestScore) {
bestScore = sim;
bestItem = cellValue;
}
} catch (e) {
// 单条数据异常,跳过并打印日志
console.log(`【行${row}】异常:${e.message}`);
}
}
// ===================== 5. 阈值处理 + 最终返回 =====================
// 初始化阈值
let finalThreshold = 0;
// 如果传入了阈值,处理格式(支持百分比 80% → 0.8)
if (threshold !== null && threshold !== undefined && threshold !== "") {
let val = threshold;
// 字符串百分比处理
if (typeof val === "string" && val.includes("%")) {
val = parseFloat(val.replace("%", "")) / 100;
}
finalThreshold = Number(val) || 0;
}
// 把最高相似度格式化为百分比(保留2位小数)
const bestPercent = (bestScore * 100).toFixed(2);
// 根据阈值规则返回结果
if (finalThreshold > 1) {
// 阈值 >1 → 仅完全匹配(100%)才返回
return bestScore === 1 ? bestItem : `无匹配,最高匹配度 ${bestPercent}%`;
}
else if (finalThreshold <= 0) {
// 阈值 ≤0 → 直接返回最优匹配
return bestItem || `无匹配,最高匹配度 ${bestPercent}%`;
}
else {
// 0~1之间 → 达到阈值返回,否则提示无匹配
return bestScore >= finalThreshold ? bestItem : `无匹配,最高匹配度 ${bestPercent}%`;
}
}
/**
* 文本清洗函数:统一格式,消除干扰
* @param {string} str - 原始字符串
* @returns {string} 清洗后字符串(半角、小写、去空格)
*/
function cleanText(str) {
// 全角字符转半角
const toHalf = s => s.replace(/[\uff00-\uffff]/g, ch => String.fromCharCode(ch.charCodeAt(0) - 65248));
// 全角转半角 → 小写 → 去首尾空格
return toHalf(str).toLowerCase().trim();
}
/**
* 替换罗马数字:II→2,I→1
* @param {string} str
* @returns {string}
*/
function replaceIIandI(str) {
return str.replace(/II/gi, "2").replace(/I/gi, "1");
}
/**
* 4维度综合相似度计算(高精度核心)
* @param {string} t - 目标清洗后文本
* @param {string} c - 对比清洗后文本
* @returns {number} 综合得分 0~1
*/
function getMultiSimilarity(t, c) {
const charSim = charSimilarity(t, c); // 单字符匹配
const biSim = ngramSimilarity(t, c, 2); // 双字符组合
const triSim = ngramSimilarity(t, c, 3); // 三字符组合
const editSim = editDistanceSimilarity(t, c); // 编辑距离
// 四个维度等权平均
return (charSim + biSim + triSim + editSim) / 4;
}
/**
* 单字符重合相似度(Jaccard)
*/
function charSimilarity(a, b) {
const setA = new Set(a.split(''));
const setB = new Set(b.split(''));
const intersect = [...setA].filter(x => setB.has(x)).length;
const union = new Set([...setA, ...setB]).size;
return union === 0 ? 0 : intersect / union;
}
/**
* N-gram连续子串相似度(2-gram、3-gram)
*/
function ngramSimilarity(a, b, n) {
// 生成连续N字符组合
function getNgrams(s, n) {
const ngrams = [];
for (let i = 0; i <= s.length - n; i++) {
ngrams.push(s.slice(i, i + n));
}
return ngrams;
}
const g1 = getNgrams(a, n);
const g2 = getNgrams(b, n);
const set1 = new Set(g1);
const set2 = new Set(g2);
const intersect = [...set1].filter(x => set2.has(x)).length;
const union = new Set([...set1, ...set2]).size;
return union === 0 ? 0 : intersect / union;
}
/**
* 编辑距离相似度(Levenshtein)
* 衡量两个字符串的差异程度
*/
function editDistanceSimilarity(a, b) {
const lenA = a.length;
const lenB = b.length;
const maxLen = Math.max(lenA, lenB);
if (maxLen === 0) return 1;
// 构建动态规划表
const dp = Array.from({ length: lenA + 1 }, () => Array(lenB + 1).fill(0));
for (let i = 0; i <= lenA; i++) dp[i][0] = i;
for (let j = 0; j <= lenB; j++) dp[0][j] = j;
// 计算最小修改次数
for (let i = 1; i <= lenA; i++) {
for (let j = 1; j <= lenB; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost
);
}
}
// 转为相似度 0~1
return 1 - dp[lenA][lenB] / maxLen;
}