← Back to DevBytes

Solving Edit Distance (Levenshtein) in JavaScript: Step-by-Step Guide

Solving Edit Distance (Levenshtein) in JavaScript: Step-by-Step Guide

Edit distance is one of those classic computer science problems that shows up everywhere—from spell checkers to DNA sequence alignment to fuzzy search features in your favorite apps. In this tutorial, we'll break down the Levenshtein distance algorithm, understand how it works under the hood, and implement it in JavaScript from scratch.

What Is Levenshtein Distance?

The Levenshtein distance, named after Soviet mathematician Vladimir Levenshtein, measures the minimum number of single-character edits required to transform one string into another. The allowed operations are:

For example, transforming "kitten" into "sitting" requires three operations: substitute 'k' with 's', substitute 'e' with 'i', and insert 'g' at the end. So the Levenshtein distance is 3.

Why Edit Distance Matters

Edit distance is the backbone of many real-world features you interact with daily:

Understanding this algorithm gives you a powerful tool for building intelligent text-processing features in your applications.

The Dynamic Programming Approach

The most efficient way to solve Levenshtein distance is through dynamic programming. The idea is to build a matrix where each cell [i][j] represents the edit distance between the first i characters of string A and the first j characters of string B.

Here's the recurrence relation:

Step-by-Step Implementation

Let's build the solution incrementally. First, here's the complete basic implementation:

function levenshteinDistance(str1, str2) {
  // Create a matrix of size (str1.length + 1) x (str2.length + 1)
  const m = str1.length;
  const n = str2.length;
  
  // Handle edge cases
  if (m === 0) return n;
  if (n === 0) return m;
  
  // Initialize the matrix
  const dp = Array.from({ length: m + 1 }, () => 
    new Array(n + 1).fill(0)
  );
  
  // Base cases: transforming to/from empty string
  for (let i = 0; i <= m; i++) {
    dp[i][0] = i;
  }
  for (let j = 0; j <= n; j++) {
    dp[0][j] = j;
  }
  
  // Fill the matrix
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      // If characters match, no operation needed
      if (str1[i - 1] === str2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1];
      } else {
        // Take minimum of insert, delete, or substitute
        dp[i][j] = 1 + Math.min(
          dp[i - 1][j],     // deletion
          dp[i][j - 1],     // insertion
          dp[i - 1][j - 1]  // substitution
        );
      }
    }
  }
  
  return dp[m][n];
}

// Test the function
console.log(levenshteinDistance("kitten", "sitting")); // Output: 3
console.log(levenshteinDistance("flaw", "lawn"));       // Output: 2
console.log(levenshteinDistance("", "abc"));            // Output: 3
console.log(levenshteinDistance("abc", "abc"));         // Output: 0

Let's trace through what happens with "kitten" and "sitting" to understand the matrix filling. The algorithm builds up a table where each cell represents the best solution for a sub-problem:

    ""  s  i  t  t  i  n  g
""   0  1  2  3  4  5  6  7
k    1  1  2  3  4  5  6  7
i    2  2  1  2  3  4  5  6
t    3  3  2  1  2  3  4  5
t    4  4  3  2  1  2  3  4
e    5  5  4  3  2  2  3  4
n    6  6  5  4  3  3  2  3

The bottom-right cell gives us our answer: 3. Each cell was computed by looking at its left, top, and top-left neighbors.

Space-Optimized Version

The basic implementation uses O(m × n) space. However, since we only ever need the previous row to compute the current row, we can reduce space complexity to O(min(m, n)):

function levenshteinDistanceOptimized(str1, str2) {
  // Ensure str1 is the shorter string for less memory usage
  if (str1.length > str2.length) {
    [str1, str2] = [str2, str1];
  }
  
  const m = str1.length;
  const n = str2.length;
  
  if (m === 0) return n;
  
  // Only keep two rows in memory
  let previousRow = Array.from({ length: m + 1 }, (_, i) => i);
  let currentRow = new Array(m + 1).fill(0);
  
  for (let j = 1; j <= n; j++) {
    currentRow[0] = j;
    
    for (let i = 1; i <= m; i++) {
      if (str1[i - 1] === str2[j - 1]) {
        currentRow[i] = previousRow[i - 1];
      } else {
        currentRow[i] = 1 + Math.min(
          previousRow[i],      // deletion
          currentRow[i - 1],   // insertion
          previousRow[i - 1]   // substitution
        );
      }
    }
    
    // Swap rows for next iteration
    [previousRow, currentRow] = [currentRow, previousRow];
  }
  
  return previousRow[m];
}

console.log(levenshteinDistanceOptimized("kitten", "sitting")); // Output: 3

This version is particularly useful when working with very long strings where memory becomes a constraint.

Computing Similarity Percentage

Often, you don't just want the raw distance—you want a similarity score between 0 and 1 (or 0% to 100%). Here's how to convert edit distance into a normalized similarity:

function similarityScore(str1, str2) {
  const distance = levenshteinDistance(str1, str2);
  const maxLength = Math.max(str1.length, str2.length);
  
  if (maxLength === 0) return 1.0; // Both strings are empty
  
  return 1.0 - (distance / maxLength);
}

function similarityPercentage(str1, str2) {
  return (similarityScore(str1, str2) * 100).toFixed(2) + "%";
}

console.log(similarityScore("kitten", "sitting"));           // 0.5714...
console.log(similarityPercentage("javascript", "javascrpt")); // 90.00%
console.log(similarityPercentage("hello", "hello"));          // 100.00%

Building a Fuzzy Search Function

Now let's put our Levenshtein implementation to practical use by building a fuzzy search function that finds the closest matching words from a list:

function fuzzySearch(query, wordList, threshold = 0.6) {
  return wordList
    .map(word => ({
      word,
      similarity: similarityScore(query.toLowerCase(), word.toLowerCase())
    }))
    .filter(result => result.similarity >= threshold)
    .sort((a, b) => b.similarity - a.similarity)
    .map(result => result.word);
}

const dictionary = [
  "apple", "banana", "orange", "grape", 
  "apricot", "pineapple", "application", "snapple"
];

console.log(fuzzySearch("aple", dictionary));
// Output: ["apple", "snapple", "apricot", "grape"]

console.log(fuzzySearch("banan", dictionary));
// Output: ["banana"]

Implementing a Simple Spell Checker

Here's another practical application—a basic spell checker that suggests corrections:

class SpellChecker {
  constructor(dictionary) {
    this.dictionary = dictionary;
  }
  
  check(word) {
    const lowerWord = word.toLowerCase();
    
    // Exact match found
    if (this.dictionary.includes(lowerWord)) {
      return { correct: true, word, suggestions: [] };
    }
    
    // Find closest matches
    const suggestions = this.dictionary
      .map(dictWord => ({
        word: dictWord,
        distance: levenshteinDistance(lowerWord, dictWord)
      }))
      .filter(item => item.distance <= 3)
      .sort((a, b) => a.distance - b.distance)
      .slice(0, 5)
      .map(item => item.word);
    
    return { correct: false, word, suggestions };
  }
}

const spellChecker = new SpellChecker([
  "hello", "world", "javascript", "programming",
  "algorithm", "function", "variable", "object"
]);

console.log(spellChecker.check("helo"));
// { correct: false, word: "helo", suggestions: ["hello"] }

console.log(spellChecker.check("javascrpt"));
// { correct: false, word: "javascrpt", suggestions: ["javascript"] }

console.log(spellChecker.check("function"));
// { correct: true, word: "function", suggestions: [] }

Handling Unicode and Special Characters

The basic implementation treats each character as a single unit, which breaks with multi-byte Unicode characters or emoji. Here's a Unicode-aware version:

function levenshteinDistanceUnicode(str1, str2) {
  // Convert strings to arrays of code points
  const arr1 = [...str1];
  const arr2 = [...str2];
  
  const m = arr1.length;
  const n = arr2.length;
  
  if (m === 0) return n;
  if (n === 0) return m;
  
  const dp = Array.from({ length: m + 1 }, () => 
    new Array(n + 1).fill(0)
  );
  
  for (let i = 0; i <= m; i++) dp[i][0] = i;
  for (let j = 0; j <= n; j++) dp[0][j] = j;
  
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (arr1[i - 1] === arr2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1];
      } else {
        dp[i][j] = 1 + Math.min(
          dp[i - 1][j],
          dp[i][j - 1],
          dp[i - 1][j - 1]
        );
      }
    }
  }
  
  return dp[m][n];
}

console.log(levenshteinDistanceUnicode("café", "cafe"));   // Output: 1
console.log(levenshteinDistanceUnicode("😀😁", "😀😂"));   // Output: 1

Using the spread operator [...str] correctly splits strings into Unicode code points rather than UTF-16 code units, ensuring emoji and accented characters are handled properly.

Best Practices

When working with Levenshtein distance in production applications, keep these guidelines in mind:

Here's an example of memoization to cache results:

function memoizedLevenshtein() {
  const cache = new Map();
  
  return function(str1, str2) {
    const key = `${str1}|${str2}`;
    
    if (cache.has(key)) {
      return cache.get(key);
    }
    
    const result = levenshteinDistance(str1, str2);
    cache.set(key, result);
    
    return result;
  };
}

const memoLevenshtein = memoizedLevenshtein();
console.log(memoLevenshtein("kitten", "sitting")); // Computes
console.log(memoLevenshtein("kitten", "sitting")); // Returns cached result

Performance Considerations

The time complexity of the standard Levenshtein algorithm is O(m × n), where m and n are the lengths of the two strings. For most practical text-processing tasks, this is perfectly acceptable. However, if you're comparing very long strings or running comparisons at scale, consider these optimizations:

// Early exit version for threshold-based comparisons
function isWithinDistance(str1, str2, maxDistance) {
  const m = str1.length;
  const n = str2.length;
  
  // Quick length check
  if (Math.abs(m - n) > maxDistance) return false;
  
  let previousRow = Array.from({ length: m + 1 }, (_, i) => i);
  
  for (let j = 1; j <= n; j++) {
    let currentRow = [j];
    let rowMin = j;
    
    for (let i = 1; i <= m; i++) {
      if (str1[i - 1] === str2[j - 1]) {
        currentRow[i] = previousRow[i - 1];
      } else {
        currentRow[i] = 1 + Math.min(
          previousRow[i],
          currentRow[i - 1],
          previousRow[i - 1]
        );
      }
      rowMin = Math.min(rowMin, currentRow[i]);
    }
    
    // If minimum value in row exceeds threshold, exit early
    if (rowMin > maxDistance) return false;
    
    previousRow = currentRow;
  }
  
  return previousRow[m] <= maxDistance;
}

console.log(isWithinDistance("cat", "bat", 1));    // true
console.log(isWithinDistance("cat", "dog", 1));    // false

This early-exit approach can dramatically reduce computation time when you only need to know if two strings are "close enough" rather than computing the exact distance.

Conclusion

The Levenshtein distance algorithm is a fundamental tool every developer should have in their toolkit. We've walked through what it is, why it matters, and how to implement it in JavaScript—from the basic dynamic programming solution to space-optimized and Unicode-aware variants. We also explored practical applications like fuzzy search and spell checking, along with performance optimizations for production use. Whether you're building a search feature, a spell checker, or any system that needs to understand string similarity, the Levenshtein algorithm provides a reliable, well-understood foundation. Start with the basic implementation, profile it against your actual data, and apply the optimizations we discussed as your needs evolve. With this knowledge, you're well-equipped to add intelligent text-matching capabilities to your JavaScript applications.

— Ad —

Google AdSense will appear here after approval

← Back to all articles