Introduction
Fuzzy text matching in C# finds strings that are similar but not identical — handling typos, abbreviations, and word reordering. The main approaches are edit-distance algorithms (Levenshtein), token-based comparison (splitting into words and comparing sets), and libraries like FuzzySharp and FuzzyString. For most use cases, FuzzySharp provides the best balance of accuracy and ease of use, while Levenshtein distance gives you full control when you need a custom implementation.
Levenshtein Distance
The Levenshtein distance counts the minimum number of single-character edits (insertions, deletions, substitutions) needed to transform one string into another:
1public static int LevenshteinDistance(string source, string target)
2{
3 if (string.IsNullOrEmpty(source)) return target?.Length ?? 0;
4 if (string.IsNullOrEmpty(target)) return source.Length;
5
6 int[,] matrix = new int[source.Length + 1, target.Length + 1];
7
8 for (int i = 0; i <= source.Length; i++) matrix[i, 0] = i;
9 for (int j = 0; j <= target.Length; j++) matrix[0, j] = j;
10
11 for (int i = 1; i <= source.Length; i++)
12 {
13 for (int j = 1; j <= target.Length; j++)
14 {
15 int cost = source[i - 1] == target[j - 1] ? 0 : 1;
16 matrix[i, j] = Math.Min(
17 Math.Min(matrix[i - 1, j] + 1, matrix[i, j - 1] + 1),
18 matrix[i - 1, j - 1] + cost
19 );
20 }
21 }
22
23 return matrix[source.Length, target.Length];
24}
25
26// Convert to a 0-1 similarity ratio
27public static double Similarity(string a, string b)
28{
29 int distance = LevenshteinDistance(a, b);
30 int maxLen = Math.Max(a.Length, b.Length);
31 return maxLen == 0 ? 1.0 : 1.0 - (double)distance / maxLen;
32}
33
34// Usage
35Console.WriteLine(LevenshteinDistance("kitten", "sitting")); // 3
36Console.WriteLine(Similarity("kitten", "sitting")); // 0.571
37Console.WriteLine(Similarity("hello world", "hello wrold")); // 0.909
Using FuzzySharp (Recommended Library)
FuzzySharp is a C# port of Python's fuzzywuzzy library and provides multiple matching strategies:
dotnet add package FuzzySharp
1using FuzzySharp;
2
3// Simple ratio — character-by-character comparison
4int score = Fuzz.Ratio("Introduction to Algorithms", "Introduction to Algorithm");
5Console.WriteLine(score); // 96
6
7// Partial ratio — best substring match
8score = Fuzz.PartialRatio("Algorithms", "Introduction to Algorithms");
9Console.WriteLine(score); // 100
10
11// Token sort — ignores word order
12score = Fuzz.TokenSortRatio("World Hello", "Hello World");
13Console.WriteLine(score); // 100
14
15// Token set — handles extra/missing words
16score = Fuzz.TokenSetRatio(
17 "Introduction to Algorithms 4th Edition",
18 "Algorithms Introduction"
19);
20Console.WriteLine(score); // 100
Matching Against a List
1using FuzzySharp;
2using FuzzySharp.SimilarityRatio;
3using FuzzySharp.SimilarityRatio.Scorer.StrategySensitive;
4
5string[] titles = {
6 "Design Patterns",
7 "Clean Code",
8 "Introduction to Algorithms",
9 "The Pragmatic Programmer",
10 "Refactoring"
11};
12
13string query = "intro algorithms";
14
15// Find best match
16var bestMatch = Process.ExtractOne(query, titles);
17Console.WriteLine($"Best: {bestMatch.Value} (Score: {bestMatch.Score})");
18// Best: Introduction to Algorithms (Score: 90)
19
20// Find top N matches above a threshold
21var matches = Process.ExtractTop(query, titles, limit: 3, cutoff: 60);
22foreach (var match in matches)
23{
24 Console.WriteLine($"{match.Value}: {match.Score}");
25}
Jaro-Winkler Distance
Jaro-Winkler works well for short strings like names and titles. It gives higher scores to strings that share a common prefix:
1public static double JaroWinkler(string s1, string s2, double prefixScale = 0.1)
2{
3 if (s1 == s2) return 1.0;
4
5 int maxDist = Math.Max(s1.Length, s2.Length) / 2 - 1;
6 if (maxDist < 0) maxDist = 0;
7
8 bool[] s1Matches = new bool[s1.Length];
9 bool[] s2Matches = new bool[s2.Length];
10 int matches = 0, transpositions = 0;
11
12 for (int i = 0; i < s1.Length; i++)
13 {
14 int start = Math.Max(0, i - maxDist);
15 int end = Math.Min(i + maxDist + 1, s2.Length);
16 for (int j = start; j < end; j++)
17 {
18 if (s2Matches[j] || s1[i] != s2[j]) continue;
19 s1Matches[i] = s2Matches[j] = true;
20 matches++;
21 break;
22 }
23 }
24
25 if (matches == 0) return 0.0;
26
27 int k = 0;
28 for (int i = 0; i < s1.Length; i++)
29 {
30 if (!s1Matches[i]) continue;
31 while (!s2Matches[k]) k++;
32 if (s1[i] != s2[k]) transpositions++;
33 k++;
34 }
35
36 double jaro = ((double)matches / s1.Length + (double)matches / s2.Length +
37 (matches - transpositions / 2.0) / matches) / 3.0;
38
39 // Winkler prefix bonus
40 int prefix = 0;
41 for (int i = 0; i < Math.Min(4, Math.Min(s1.Length, s2.Length)); i++)
42 {
43 if (s1[i] == s2[i]) prefix++;
44 else break;
45 }
46
47 return jaro + prefix * prefixScale * (1 - jaro);
48}
49
50Console.WriteLine(JaroWinkler("MARTHA", "MARHTA")); // 0.961
51Console.WriteLine(JaroWinkler("John Smith", "Jon Smith")); // 0.963
Token-Based Matching
For sentences and titles, splitting into tokens and comparing word sets handles word reordering and extra words:
1public static double TokenOverlap(string a, string b)
2{
3 var tokensA = a.ToLower().Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
4 var tokensB = b.ToLower().Split(' ', StringSplitOptions.RemoveEmptyEntries).ToHashSet();
5
6 int intersection = tokensA.Intersect(tokensB).Count();
7 int union = tokensA.Union(tokensB).Count();
8
9 return union == 0 ? 0 : (double)intersection / union; // Jaccard similarity
10}
11
12Console.WriteLine(TokenOverlap("Clean Code by Robert Martin", "Robert Martin Clean Code"));
13// 0.8 (4 shared words / 5 unique words)
Preprocessing for Better Results
Normalize text before comparing to reduce noise:
1public static string Normalize(string input)
2{
3 if (string.IsNullOrWhiteSpace(input)) return "";
4
5 // Lowercase, remove punctuation, collapse whitespace
6 var cleaned = new string(input.ToLower()
7 .Where(c => char.IsLetterOrDigit(c) || c == ' ')
8 .ToArray());
9
10 return string.Join(" ", cleaned.Split(' ', StringSplitOptions.RemoveEmptyEntries));
11}
12
13string a = Normalize("Introduction to Algorithms, 4th Ed."); // "introduction to algorithms 4th ed"
14string b = Normalize("INTRODUCTION TO ALGORITHMS (4TH ED)"); // "introduction to algorithms 4th ed"
15Console.WriteLine(Fuzz.Ratio(a, b)); // 100
Common Pitfalls
Not normalizing case and punctuation before comparison: "Hello World" vs "hello world" gives a low Levenshtein similarity even though the words are identical. Always normalize to lowercase and strip punctuation before fuzzy matching.
Using simple ratio for sentences with different word order: Fuzz.Ratio("World Hello", "Hello World") returns a low score because it compares character by character. Use Fuzz.TokenSortRatio or Fuzz.TokenSetRatio for sentences where word order may differ.
O(n*m) complexity for large-scale matching: Levenshtein distance is O(n*m) per pair. Comparing every string against every other in a large dataset is O(k^2 * n * m). Use indexing strategies (n-gram indexes, locality-sensitive hashing) to prefilter candidates before running expensive comparisons.
Choosing the wrong threshold: A threshold of 80 works well for short titles but may be too strict for longer sentences. Test with your specific dataset and adjust. FuzzySharp's Process.ExtractTop with a cutoff parameter helps filter low-quality matches.
Ignoring Unicode and diacritics: "cafe" vs "café" may score poorly. Use string.Normalize(NormalizationForm.FormD) to decompose accented characters before comparison, or use a library that handles Unicode normalization.
Summary
Levenshtein distance measures character-level edit distance — good for short strings and typo detection
FuzzySharp provides Ratio, PartialRatio, TokenSortRatio, and TokenSetRatio for different matching scenarios
Jaro-Winkler works well for names and titles (prefix-weighted)
Token overlap (Jaccard similarity) handles word reordering in sentences
Always normalize text (lowercase, strip punctuation) before comparing
Use Process.ExtractTop from FuzzySharp to find the best matches from a collection