C#字符串相似度算法实战:从编辑距离到Jaccard与n-gram选型
2026/9/20 16:55:42 网站建设 项目流程

简介:一份面向C#开发者的字符串相似度对比源码示例,覆盖编辑距离、余弦相似度、Jaccard相似度、汉明距离及最长公共子序列等主流算法,适合文本查重、关键词匹配、用户输入纠错等应用场景,有助于快速理解不同度量方式的差异与选型。压缩包共32个文件,以C#源代码、工程配置、DLL依赖库和文本说明为主,另有JSON配置及缓存等辅助文件,便于查看项目构建状态与运行环境。整个资源仅127KB,轻量易用,已有128人学习下载,特别适合刚接触字符串相似度计算的C#初中级开发者参照实践。通过调试示例并阅读源码说明,可以直观看到编辑距离、Jaccard相似度等算法在相同输入下的返回结果和归一化分数,继而掌握在搜索提示、数据清洗、OCR纠错等真实项目中应用这些方法的基本思路。示例结构清晰,开发者可以快速提取核心算法封装为工具类,或按项目需求扩展为异步批量比较的文本处理组件。

1. 为什么你的 C# 项目需要字符串相似度

做上位机的人大概率碰到过这种场面:扫码枪扫回来的物料编码和数据库里维护的编码总差那么一点,大小写不一致、中间多了个空格、或者是N3000-AN3000A这种写法差异。手写精确匹配全部漏掉,最后只能靠人眼在表格里找。另一个高频场景是 ERP 客户名单去重,Excel 导出的数据里“上海华兴贸易有限公司”和“上海华兴贸易有限公”明显是同一家,但string.Equals只会告诉你它们不相等。

字符串相似度解决的就是这类“看起来差不多,但==返回 false”的匹配问题。它输出一个 0 到 1 之间的数值,或者一个最小编辑代价,让你能用阈值去判断“这两个字符串是不是同一个东西”。这类技术在 C# 上位机、后台批处理、日志聚类、API 入参纠错里都有实际用途。写 C# 服务端或者工控软件的工程师,迟早会需要它。一个容易忽略的事实是:编辑距离在处理中文短文本时效果并不好,乱序问题会直接击穿它,所以选算法比写算法更重要。

2. 字符串相似度算法的选型视角:编辑距离、Jaccard 与 n-gram

没有哪种相似度算法是万能的,关键是先搞清楚你的数据“脏”在哪里。扫码枪读错字符,脏在个别字符替换;用户手工录入的公司名,脏在漏字和加字;跨系统同步的地址文本,脏在词语顺序不一致。这几种情况对应完全不同的算法家族,选错方向后面再怎么调参都白费。下面把最常见的三类讲清楚,最后给一张选型对照表。

2.1 Levenshtein 编辑距离:默认首选,但要知道它的代价假设

Levenshtein 距离定义了一套最小编辑代价:允许插入、删除、替换三种操作,每个操作代价为 1,距离就是从字符串 A 变成字符串 B 所需的最少操作次数。“C#上位机”改成“C# 上位机”只需要一次插入,距离为 1。这套模型天然适合字符级别的小扰动,比如 OCR 识别错误、扫码枪漏读、手工录入时的错别字,所以我一般把它作为默认起点。

但它有一个隐含假设:两个字符串的长度不能差太远。长度差本身就是编辑次数的下限,所以“C#上位机数据采集系统”和“C#”的相似度会被长度差拉得很低,即使前缀完全一致。另外它的时间复杂度是 O(m×n),2000 个字符的字符串两两对比就是 400 万格的计算量,数据量上来之后性能压力很大。在这些场景里,直接换算法比硬优化更快。

2.2 Jaccard:集合重叠度,适合文本乱序和缩写

Jaccard 相似度的定义是交集大小除以并集大小。把字符串拆成一个集合,比如按字符拆,“山东青岛”的字符集合是{山, 东, 青, 岛},“青岛山东”的字符集合完全相同,Jaccard 等于 1.0。但 Levenshtein 要 4 次操作才能完成转换。这就是乱序场景的典型差异。

拆法决定了 Jaccard 的行为。按单个字符拆,对顺序完全不敏感;按词语拆,需要先分词,对中文来说分词本身就有错误率。实际工程里更常见的是把字符串先切成 n-gram 再求 Jaccard,这样能在保留局部顺序的同时抵抗整体乱序。Jaccard 的另一个好处是可以用哈希集合快速计算,比 DP 快一个量级,很适合做大规模数据的初筛。

2.3 n-gram:滑窗字符集合,兼顾顺序与局部变化

n-gram 是把字符串按固定长度 n 切成滑窗片段,再把这些片段作为集合元素。“C#上位机”的 bigram 集合是{C#, #上, 上位, 位机},“C#上位机开发”的 bigram 集合是{C#, #上, 上位, 位机, 机开, 开发},两者的 Jaccard 是 4/6。n 越小,对局部变化的容忍度越高;n 越大,对顺序越敏感。n=2 和 n=3 在中文场景里最常用。

n-gram 特别适合处理“大部分相同,但中间多了一段”的情况。一个 30 字符的字符串和一个 28 字符的字符串,用编辑距离算相似度可能不到 0.8,但用 bigram Jaccard 可能高达 0.95。代价是 n-gram 集合占内存,长文本会产生大量片段,所以通常只用于短文或先做归一化截断。

2.4 相似度算法选型对照表

算法时间复杂度对乱序敏感对局部错字容忍典型应用场景
LevenshteinO(m×n)编码匹配、OCR 纠错、最短编辑路径
Jaccard(字符级)O(m+n)地址乱序、简称匹配
Jaccard(bigram)O(m+n)短文本初筛、批量去重
余弦相似度(bigram)O(m+n)标题去重、搜索推荐

从表格可以得出一个实用结论:单一的 Levenshtein 适合做精排,bigram Jaccard 适合做初筛。工程上常见做法是先用 n-gram 把明显不相关的数据过滤掉,再对候选集跑编辑距离精排。面试和手写代码环节里,Levenshtein 是最常考的基础,所以下一章先从它讲起。

3. 用 C# 实现 Levenshtein 距离的最小可运行代码

Levenshtein 距离的完整代码在 C# 里也就三十行左右,不需要引入任何 NuGet 包。理解它的关键是 DP 表的状态定义:dp[i, j]表示s1的前 i 个字符转换成s2的前 j 个字符所需的最小编辑次数。状态转移有三个来源,取最小值就是当前格子的值。

3.1 二维 DP 的参考实现

public static int LevenshteinDistance(string s1, string s2) { int m = s1.Length, n = s2.Length; int[,] dp = new int[m + 1, n + 1]; // 初始化:空字符串到任意字符串的距离等于对方长度 for (int i = 0; i <= m; i++) dp[i, 0] = i; for (int j = 0; j <= n; j++) dp[0, j] = j; for (int i = 1; i <= m; i++) { for (int j = 1; j <= n; j++) { int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; dp[i, j] = Math.Min( Math.Min(dp[i - 1, j] + 1, // 删除 s1[i] dp[i, j - 1] + 1), // 插入 s2[j] dp[i - 1, j - 1] + cost); // 替换或跳过 } } return dp[m, n]; }

这段代码的逻辑核心是三种操作的代价累加。dp[i - 1, j] + 1表示删掉s1的第 i 个字符;dp[i, j - 1] + 1表示在s1里插入s2的第 j 个字符;dp[i - 1, j - 1] + cost表示看当前两个字符是否相等,相等则不需要操作,不相等则执行一次替换。cost的值就是这次替换是否发生的开关,这是整张表计算中最关键的分支。

两层循环嵌套,时间复杂度是 O(m×n)。两个 1000 字符的字符串,计算量是 100 万次操作,单次调用在毫秒级,直接用于 API 请求没问题,但做十万级数据的全量两两对比就会很吃力。

3.2 空间优化到一维滚动数组

二维数组的内存占用是 (m+1)×(n+1) 个 int,两个一万字符的字符串就需要约 400MB,直接打爆内存。观察状态转移可以发现,dp[i, j]只依赖当前行的左侧和上一行的同列及左侧,所以可以压成一维滚动数组,保留上一行的值即可。

public static int LevenshteinDistanceOptimized(string s1, string s2) { if (s1.Length > s2.Length) { (s1, s2) = (s2, s1); // 用较短的字符串做列,节省空间 } int m = s1.Length, n = s2.Length; int[] prev = new int[n + 1]; int[] curr = new int[n + 1]; for (int j = 0; j <= n; j++) prev[j] = j; for (int i = 1; i <= m; i++) { curr[0] = i; for (int j = 1; j <= n; j++) { int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1; curr[j] = Math.Min( Math.Min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost); } (prev, curr) = (curr, prev); // 交换数组引用,复用内存 } return prev[n]; }

这段优化把空间复杂度从 O(m×n) 降到 O(n),同时用一个元组交换把prevcurr的引用互换,省去了数组拷贝。注意开头先交换输入,保证s1是较短的那个,这样curr数组的长度更小,内存占用进一步降低。时间复杂度和二维版本相同,但实际运行时少了一次数组寻址,在长字符串上性能更好。

3.3 为什么不要写递归

很多人面试时第一反应是写递归,把Levenshtein(s1, s2)定义成三个子问题的递归调用。这种方式在纯递归下是 O(3^n) 的指数爆炸,两个 20 字符的字符串就会卡死。加记忆化之后虽然能降到和 DP 同阶,但递归调用的栈开销和字典查找都比循环慢,工程上没有任何收益。记住结论:Levenshtein 就用迭代 DP 写,不要写递归。面试官想看的状态转移能力,迭代版本完全能体现。

3.4 编辑距离到相似度的换算公式

有了编辑距离之后,还需要把它归一化到 0 到 1 的相似度。最常见的公式是similarity = 1 - distance / Math.Max(len1, len2)。分母取两个字符串的最大长度,含义是“最坏情况下需要多少次操作才能转换”,距离占最大操作次数的比例越小,相似度越高。

public static double ToSimilarity(int distance, int len1, int len2) { int maxLen = Math.Max(len1, len2); if (maxLen == 0) return 1.0; return 1.0 - (double)distance / maxLen; }

这个公式对长度差大的情况比较苛刻,两个长度 100 的字符串差 2 个字符,相似度是 0.98;但一个 50 字符一个 52 字符,差 2 个字符只有 0.96。另一种做法是除以两个字符串的平均长度,对短文本更宽容,但会产生相似度大于 1 的情况,需要Math.Min(1.0, ...)收一下。实际项目里我常直接除以Math.Max(len1, len2),阈值语义更直观:0.8 以上基本可以判定为同一个实体。

4. 实操:写一个可复用的 C# 字符串相似度工具类

单个算法在真实项目里往往不够,可复用的工具类应该把归一化、多种算法、阈值判断整合在一起。这一章直接给一个能拷贝进 C# 类库的完整实现,并交代清楚每个参数怎么调。这个工具类单独编译成一个 DLL 或者打成 zip 发布,都是常见的分发方式。

4.1 先做归一化预处理

相似度计算之前必须先做归一化,否则算法会把大量计算浪费在无关差异上。用户输入的字符串可能带着全角空格、中文引号、大小写混写,这些都不应该影响相似度结果。推荐用System.Globalization里的TextInfoStringInfo处理,或者直接结合NFKC归一化和字符替换。

public static string Normalize(string input) { if (string.IsNullOrEmpty(input)) return string.Empty; input = input.Normalize(NormalizationForm.FormKC); // 全角转半角,兼容等价字符 input = input.ToUpperInvariant(); // 统一大小写 input = Regex.Replace(input, @"[\s\p{P}\p{S}]", ""); // 去掉空白、标点和符号 return input; }

参数说明:FormKC会把全角字母数字转成半角,同时把兼容字符统一成标准形式,这是处理中文数据常用的第一步;ToUpperInvariant用不变区域设置转换大小写,避免土耳其语环境下I的处理偏差;正则里的\p{P}是标点类别,\p{S}是符号类别,\s覆盖空格和换行。去掉它们可以避免“C#”和“C #”这种写法差异干扰判断,但如果你要匹配的就是带符号的编码,需要把C#里的#加入白名单,这一点很容易踩坑。

4.2 把多种算法装进一个工具类

public class StringSimilarity { private readonly int _ngramSize; public StringSimilarity(int ngramSize = 2) { _ngramSize = ngramSize; } public double LevenshteinSimilarity(string s1, string s2) { s1 = Normalize(s1); s2 = Normalize(s2); int dist = LevenshteinDistanceOptimized(s1, s2); return ToSimilarity(dist, s1.Length, s2.Length); } public double JaccardByNGram(string s1, string s2) { s1 = Normalize(s1); s2 = Normalize(s2); var set1 = ToNGramSet(s1, _ngramSize); var set2 = ToNGramSet(s2, _ngramSize); if (set1.Count == 0 || set2.Count == 0) return 0.0; int inter = set1.Intersect(set2).Count(); int union = set1.Union(set2).Count(); return (double)inter / union; } private static HashSet<string> ToNGramSet(string s, int n) { var set = new HashSet<string>(StringComparer.Ordinal); if (s.Length < n) { set.Add(s); } else { for (int i = 0; i <= s.Length - n; i++) { set.Add(s.Substring(i, n)); } } return set; } public double Aggregate(string s1, string s2, double levenshteinWeight = 0.5) { double lev = LevenshteinSimilarity(s1, s2); double jac = JaccardByNGram(s1, s2); return lev * levenshteinWeight + jac * (1 - levenshteinWeight); } }

这段代码的核心设计是让每种算法都先走Normalize,保证输入口径一致。ToNGramSetHashSet去重,同一个 n-gram 在字符串里出现多次只算一次,这是集合语义的正确做法。注意短字符串处理:如果长度小于 n,就把整个字符串作为一个元素放进集合,避免返回空集。Aggregate方法是一个简单的线性融合,levenshteinWeight默认 0.5,实际项目中建议按数据特征调整:如果主要是错字,加大权重;如果主要是顺序颠倒,调低权重把重心放到 Jaccard 上。

4.3 比较方法选型与参数说明

参数取值范围推荐值影响说明
ngramSize2~42小值容忍局部变化,大值更敏感
levenshteinWeight0~10.5权重高偏向字符级精确匹配
相似度阈值0~10.8低于阈值判定为不匹配

ngramSize的调整逻辑很直接:物料编码、工单号这类短编码用 2 或 3;长文本、产品描述用 3 或 4,避免集合过大导致内存膨胀。阈值这个参数永远不要拍脑袋定,下一节给一个可操作的采样方法。还有一点值得注意:LevenshteinSimilarity内部先归一化再做 DP 计算,归一化后的长度变化会直接影响相似度,调试时要明白返回值的参考基线是“归一化后的长度”,不是原始输入长度。

4.4 别在采集循环里同步计算:异步与 UI 卡顿

C# 项目里最常见的性能事故,是在数据采集循环里同步调用相似度算法。比如扫码枪每触发一次就往界面追加一条记录,同时要做相似度匹配,这时候算法在 UI 线程上跑,累积到一定程度界面就开始掉帧。热词里“C# 循环数据采集和 UI 刷新卡顿”描述的就是这个经典问题。

private async void OnScannerTriggered(object sender, string scannedText) { string[] candidates = await Task.Run(() => { return _repository.GetCandidatesWithSimilarity(scannedText, threshold: 0.8); }); dataGridView1.DataSource = candidates; }

把相似度计算丢到Task.Run的线程池里,UI 线程只做绑定数据的操作,这样即使单次匹配要几十毫秒,界面也不会卡。注意async void只用于事件处理器,普通方法应该返回Task。如果采集频率很高,还要加上节流控制,比如用SemaphoreSlim限制并发计算数量,否则线程池可能被打满。这个模式下,算法本身的效率反而不是第一瓶颈,线程调度和队列长度才是。

4.5 发布为 zip 独立工具

命令行工具或类库做出来后,常见发布方式是用dotnet publish打一个自包含包,再压缩成 zip 分发给现场工程师。在项目目录执行:

dotnet publish -c Release -r win-x64 --self-contained true -o ./publish cd publish 7z a -tzip StringSimilarityTool.zip .

-r win-x64指定目标运行时,--self-contained true表示把 .NET 运行时一起打包,目标机器没装 .NET 也能直接运行。压缩时用-tzip明确指定 zip 格式,避免部分压缩工具默认的格式在目标机器上解压报invalid zip archive之类的错误。这个报错本质是解压端找不到 EOCD 记录,常见原因是压缩文件本身损坏,或者压缩时用了目标解压工具不支持的扩展格式。现场跑一个dotnet StringSimilarityTool.dll --help确认环境没问题,再把压缩包发出去。

5. 批量去重时怎么用相似度矩阵与阈值自适配

单条相似度计算只是基础,真实项目里更难的是批量去重:几千条客户数据两两对比,复杂度是 O(n²)。这一章给两个工程技巧,一个是剪枝,一个是阈值自适配,再加一组验证样例。

5.1 长度差剪枝

public static IEnumerable<(string A, string B, double Score)> Deduplicate(List<string> items, double threshold) { for (int i = 0; i < items.Count; i++) { for (int j = i + 1; j < items.Count; j++) { int lenDiff = Math.Abs(items[i].Length - items[j].Length); int minLen = Math.Min(items[i].Length, items[j].Length); if (lenDiff > minLen * 0.5) continue; // 长度差过大,直接跳过 double sim = LevenshteinSimilarity(items[i], items[j]); if (sim >= threshold) { yield return (items[i], items[j], sim); } } } }

剪枝逻辑的核心依据是:长度差超过短字符串一半时,相似度理论上限就低于 0.5,而实际阈值通常设 0.8,所以这种数据对可以直接跳过,省掉一次编辑距离计算。yield return做惰性求值,调用方可以在收集到足够结果后停止迭代,不需要一次性把所有组合算完。

5.2 用 F1 扫描自动定阈值

阈值设太高漏匹配,设太低全是误报。正确做法是拿一批已标注的正负样本,扫描不同阈值下的 F1 值,选最大值对应的阈值。给一个最小实现片段:

public static double FindBestThreshold( List<(string A, string B, bool IsMatch)> samples, double[] thresholds) { double bestF1 = 0, bestThreshold = thresholds[0]; foreach (double t in thresholds) { int tp = 0, fp = 0, fn = 0; foreach (var s in samples) { double sim = LevenshteinSimilarity(s.A, s.B); if (sim >= t && s.IsMatch) tp++; else if (sim >= t && !s.IsMatch) fp++; else if (sim < t && s.IsMatch) fn++; } double precision = (double)tp / (tp + fp); double recall = (double)tp / (tp + fn); double f1 = 2 * precision * recall / (precision + recall); if (f1 > bestF1) { bestF1 = f1; bestThreshold = t; } } return bestThreshold; }

样本标注是这一步成本最高的部分,一般抽 200 到 500 对数据人工标一遍。扫描阈值步长用 0.05 就能得到足够精细的结果。下面是一组真实样例的模拟输出,方便理解 F1 随阈值变化的形态。

阈值精确率召回率F1
0.700.620.980.76
0.750.740.920.82
0.800.850.870.86
0.850.960.660.78

阈值 0.80 时 F1 最高,说明这一批数据的最优点在 0.8 附近。上线之后持续推进人工反馈修正样本集,重跑一遍扫描,是保持去重质量最省事的办法。

5.3 用一组真实样例验证工具类

工具类写完,用一组覆盖不同脏数据类型的样例验证比单测更直观:

字符串 A字符串 BLevenshteinJaccard(bigram)判定
C#上位机C# 上位机0.880.73匹配(归一化后相同)
N3000-AN3000A0.890.75匹配(符号差异)
山东青岛青岛山东0.500.43不匹配(乱序)
中兴光猫配置中兴光猫配置文件0.830.75匹配(尾部漏字)
华为镜像 jdk8华为镜像 jdk110.830.67不匹配(版本不同)

第二行N3000-AN3000A的 Levenshtein 相似度高达 0.89,因为归一化阶段已经去掉了连字符;第一行C#上位机C# 上位机归一化后变成完全相同的字符串,会输出 1.0。这两组说明归一化对结果影响极大。第三行直接体现算法短板:字符乱序时 Levenshtein 只有 0.5,实际项目里遇到这类数据要改用带分词的 Jaccard 或直接按词语集合比较。最后一行的两个版本号差异,Levenshtein 相似度不低但内容语义不同,这说明相似度只能做模糊匹配,业务规则上的版本排除条件仍然要用精确判断去兜底。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询