【问题标题】:How to efficiently find similar strings in a unique string in JavaScript?如何有效地在 JavaScript 中的唯一字符串中找到相似的字符串?
【发布时间】:2019-04-23 04:32:04
【问题描述】:

背景:我有一个包含 13,000 条人名记录的列表,其中一些是重复的,我想找出相似的人名来进行手动复制过程。

对于像这样的数组:

["jeff","Jeff","mandy","king","queen"] 

什么是获得的有效方法:

[["jeff","Jeff"]]

解释["jeff","Jeff"]因为他们的Levenshtein距离是1(可以像3一样可变)。

/* 
Working but a slow solution
*/
function extractSimilarNames(uniqueNames) {
  let similarNamesGroup = [];

  for (let i = 0; i < uniqueNames.length; i++) {
    //compare with the rest of the array
    const currentName = uniqueNames[i];

    let suspiciousNames = [];

    for (let j = i + 1; j < uniqueNames.length; j++) {
      const matchingName = uniqueNames[j];
      if (isInLevenshteinRange(currentName, matchingName, 1)) {
        suspiciousNames.push(matchingName);
        removeElementFromArray(uniqueNames, matchingName);
        removeElementFromArray(uniqueNames, currentName);
        i--;
        j--;
      }
    }
    if (suspiciousNames.length > 0) {
      suspiciousNames.push(currentName);
    }
  }
  return similarNamesGroup;
}

我想通过 Levenshtein 距离找到相似度,而不仅仅是小写/大写相似度

我已经找到 fastest Levenshtein implementation 之一,但我仍然需要 35 分钟才能获得 13000 个项目列表的结果。

【问题讨论】:

  • @alex 认为比较两个字符串的 levenshtein 实现在这里没有帮助。
  • 我怀疑removeElementFromArray 函数正在扼杀您的性能,因为它会改变您正在遍历的数组。删除suspiciousNames.push(matchingName); 之后的4 行并使用console.timeconsole.timeEnd 测试性能,最好从较小的数组开始。
  • ["Jeff", "eff", "effl"] 的预期输出是什么?另外,您是否只对 1 的 Levenshtein 距离感兴趣,或者它可能是可变的?

标签: javascript algorithm


【解决方案1】:

您的问题不在于 Levenshtein 距离实施的速度。您的问题是您必须将每个单词与其他单词进行比较。这意味着您进行 13000² 次比较(每次都计算 Levenshtein 距离)。

所以我的方法是尽量减少比较次数。

这里有一些想法:

  • 单词只有在它们的长度相差小于 20% 时才相似(只是我的估计)
    → 我们可以按长度分组,只比较长度为±20%的其他单词

  • 单词只有在共享大量字母时才相似
    → 我们可以创建一个列表,例如3-grams(全部小写),指代它们所属的单词。
    → 只比较(例如,与 Levenshtein 距离)一个词与其他有几个 3-gram 相同的词。

【讨论】:

  • 主要问题是如果我们有 10 个长度为 [1,2,3,4,5,6,7,8,9,10] 的单词,我们如何对它们进行聚类?使用任何聚类方法,都会有 2 个包含单词的聚类,它们的长度仅相差 1。在这种情况下,唯一的方法是将所有人都放在一个组中。 @MrSmith42,你的想法很好,请给我投票。但在不丢失数据的情况下实施是不现实的。
  • "20%" 是一个相当随机的猜测,为什么不直接说只有长度为 +-1 的单词才会被比较呢?根据问题,这将是准确的。
  • @Jonas Wilms:我可能会使用 20%。 Levenshtein 距离至少与长度差一样大,因此您可以使用适用于您的案例的最大允许 Levenshtein 距离作为如何设置此最大可接受长度差的指导。
【解决方案2】:

删除相似名称的方法:

  1. 使用单词的语音表示。 cmudict 它适用于 python nltk。您可以找到在拼音上彼此接近的名称。
  2. 尝试不同形式的词干或简化。我会尝试最激进的词干分析器,比如 Porter 词干分析器。
  3. 列文斯坦特里。您可以创建 trie 数据结构,这将有助于查找与搜索项目的最小距离的单词,这用于某些搜索引擎中的全文搜索。据我所知,它已经在 J​​ava 中实现了。在您的情况下,您需要搜索一个项目,然后在每一步将其添加到结构中,您需要确保您搜索的项目尚未在结构中。

  4. 手动幼稚方法。找到每个单词/名称的所有合适表示,将所有表示映射并找到超过 1 个单词的表示。如果一个单词有大约 15 种不同的表示形式,则只需要 280K 次迭代即可生成此对象(比将每个单词与另一个单词进行比较要快得多,后者需要与 13K 个名称进行大约 80M 次比较)。

-- 编辑--

如果可以选择,我会使用 Python 或 Java 之类的东西而不是 JS。这只是我的观点:我不了解所有要求,使用 Java/Python 进行自然语言处理很常见,任务看起来更像是繁重的数据处理而不是前端。

【讨论】:

  • 我喜欢这个答案,除了第一句话。即使我也不喜欢 javascript,也没有真正的理由不使用它来实现算法。我相信你可以找到大多数提到的算法的实现也适用于 java 脚本(或者它们可以很容易地被翻译成 java 脚本)。
  • @MrSmith42 我理解您的担忧,我知道使用 java 脚本可能还有其他原因,我终于也喜欢它了。我更喜欢将 javascript 保留为前端语言,如果你需要做一些需要大量数据处理的事情,它看起来不像前端。因此,对于这种情况,它的现成解决方案将较少。使用 Java 和 Python 进行自然语言处理是很常见的,如果您可以选择为什么不这样做。
  • 这个答案可能很好......如果它不包含对其他语言的所有引用,而是专注于具体算法(适用于任何语言)
  • @JonasWilms,对不起,我开始把自己装扮成 javascript 种族主义者
【解决方案3】:

在您的工作代码中,您仅使用 Levenshtein 距离 1,我假设不需要找到其他距离。

我将提出与 Jonas Wilms 发布的类似解决方案,但有以下区别:

  • 无需调用isLevenshtein函数
  • 只产生唯一的对
  • 每一对都按词法排序

// Sample data with lots of similar names
const names = ["Adela","Adelaida","Adelaide","Adele","Adelia","AdeLina","Adeline",
               "Adell","AdellA","Adelle","Ardelia","Ardell","Ardella","Ardelle",
               "Ardis","Madeline","Odelia","ODELL","Odessa","Odette"];

const map = {};
const pairs = new Set;
for (const name of names) {
    for (const i in name+"_") { // Additional iteration to NOT delete a character
        const key = (name.slice(0, i) + name.slice(+i + 1, name.length)).toLowerCase();
        // Group words together where the removal from the same index leads to the same key
        if (!map[key]) map[key] = Array.from({length: key.length+1}, () => new Set);
        // If NO character was removed, put the word in EACH group
        for (const set of (+i < name.length ? [map[key][i]] : map[key])) {
            if (set.has(name)) continue;
            for (let similar of set) pairs.add(JSON.stringify([similar, name].sort()));
            set.add(name);
        }
    }
}
const result = [...pairs].sort().map(JSON.parse); // sort is optional
console.log(result);

我对一组 13000 个名称进行了测试,其中包括至少 4000 个不同的名称,它在大约 0.3 秒内产生了 8000 对。

【讨论】:

    【解决方案4】:

    如果我们从“Jeff”的不同位置删除一个字符,我们最终会得到“eff”、“Jff”、“Jef”和“Jef”。如果我们对“jeff”做同样的事情,我们会得到“eff”、“jff”、“Jef”和“jef”。现在,如果您仔细观察,您会发现两个字符串都产生“eff”作为结果,这意味着我们可以创建这些组合到其原始版本的 Map,然后为每个字符串生成所有组合并在地图。 通过查找,您将获得相似的结果,例如"abc" 和 "cab" 但它们的 levenshtein 距离不一定为 1,因此我们必须事后检查。

    现在为什么会更好?

    迭代所有名称是 O(n)(n 是单词的数量),创建所有组合是 O(m)(m 是单词中的平均字符数)并且在 Map 中查找是 O( 1),因此它在 O(n * m) 中运行,而你的算法是 O(n * n * m),这意味着对于 10.000 个单词,我的算法要快 10.000 倍(或者我的计算是错误的:))

      // A "OneToMany" Map
      class MultiMap extends Map {
        set(k, v) {
          if(super.has(k)) {
            super.get(k).push(v);
           } else super.set(k, [v]);
         }
         get(k) {
            return super.get(k) || [];
         }
      }
    
      function* oneShorter(word) {
        for(let pos = 0; pos < word.length; pos++)
           yield word.substr(0, pos) + word.substr(pos + 1);
      }
    
      function findDuplicates(names) {
        const combos = new MultiMap();
        const duplicates = [];
    
        const check = (name, combo) => {
          const dupes = combos.get(combo);
          for(const dupe of dupes) {
             if((isInLevenshteinRange(name, combo, 1))
             duplicates.push([name, dupe]);
          }
          combos.set(combo, name);
        };
    
        for(const name of names) {
          check(name, name);
    
          for(const combo of oneShorter(name)) {
             check(name, combo);
          }
        }
    
         return duplicates;
     }
    

    【讨论】:

    • fn("Jeff ", "Jff") 失败
    • @kaiido 如果我重复一遍并省略一个字符而不是用下划线替换怎么办?
    • 在索引 1 处“Jeff”和“Jff”之间仍然没有匹配项。还要注意 OP 在他们的示例中使用了索引 1,但他们可能还想利用一点过滤器并继续深至 2 级或 3 级。这样做,我不确定你的算法会比真正的 levenshtein 表现更好
    • @kaiido 如果我将名称本身添加到地图中。从“eff”、“Jff”、“Jef”的结果中删除一个。是的,这可能只对较小的 levenshtein 距离更好
    【解决方案5】:

    我还有一种完全不同的方法来解决这个问题,但我相信我提出了一个相当快的方法(但关于正确/不正确的程度值得商榷)。我的方法是将字符串映射到数值,对这些值进行一次排序,然后遍历该列表一次,将相邻值相互比较。像这样:

    // Test strings (provided by OP) with some additions
    var strs = ["Jeff","mandy","jeff","king","queen","joff", "Queen", "jff", "tim", "Timmo", "Tom", "Rob", "Bob"] 
    
    // Function to convert a string into a numeric representation
    // to aid with string similarity comparison
    function atoi(str, maxLen){
      var i = 0;
      for( var j = 0; j < maxLen; j++ ){
        if( str[j] != null ){
          i += str.toLowerCase().charCodeAt(j)*Math.pow(64,maxLen-j) - 'a'.charCodeAt(0)*Math.pow(64,maxLen-j)
        } else {
          // Normalize the string with a pad char
          // up to the maxLen (update the value, but don't actually
          // update the string...)
          i += '-'.charCodeAt(0)*Math.pow(64,maxLen-j) - 'a'.charCodeAt(0)*Math.pow(64,maxLen-j)
        }
      }
      valMap.push({
         str,
         i 
      })
      return i;
    }
    
    Number.prototype.inRange = function(min, max){ return(this >= min && this <= max) }
    
    var valMap = []; // Array of string-value pairs
    
    var maxLen = strs.map((s) => s.length).sort().pop() // maxLen of all strings in the array
    console.log('maxLen', maxLen)
    strs.forEach((s) => atoi(s, maxLen)) // Map strings to values
    
    var similars = [];
    var subArr = []
    var margin = 0.05;
    valMap.sort((a,b) => a.i > b.i ? 1 : -1) // Sort the map...
    valMap.forEach((entry, idx) => {  
      if( idx > 0 ){
          var closeness = Math.abs(entry.i / valMap[idx-1].i);
          if( closeness.inRange( 1 - margin, 1 + margin ) ){
            if( subArr.length == 0 ) subArr.push(valMap[idx-1].str)
            subArr.push(entry.str)
            if( idx == valMap.length - 1){
              similars.push(subArr)
            }
          } else {
            if( subArr.length > 0 ) similars.push(subArr)
            subArr = []
          }
      }
    })
    console.log('similars', similars)

    我将每个字符串都视为“64 位数字”,其中每个“位”可以采用字母数字值,“a”代表 0。然后我将其排序 一次时间>。然后,如果遇到与前一个相似的值(即,如果两者的比率接近 1),那么我推断我有相似的字符串。

    我做的另一件事是检查最大字符串长度,并在计算“64 位值”时将所有字符串标准化为该长度。

    --- 编辑:更多压力测试 --- 然而,这里有一些额外的测试,它会提取大量名称并相当快地执行处理(20k+ 名称大约需要 50 毫秒,有很多误报)。无论如何,这个 sn-p 应该更容易排除故障:

    var valMap = []; // Array of string-value pairs
    
    /* Extensions */
    Number.prototype.inRange = function(min, max){ return(this >= min && this <= max) }
    
    /* Methods */
    // Function to convert a string into a numeric representation
    // to aid with string similarity comparison
    function atoi(str, maxLen){
      var i = 0;
      for( var j = 0; j < maxLen; j++ ){
        if( str[j] != null ){
          i += str.toLowerCase().charCodeAt(j)*Math.pow(64,maxLen-j) - 'a'.charCodeAt(0)*Math.pow(64,maxLen-j)
        } else {
          // Normalize the string with a pad char
          // up to the maxLen (update the value, but don't actually
          // update the string...)
          i += '-'.charCodeAt(0)*Math.pow(64,maxLen-j) - 'a'.charCodeAt(0)*Math.pow(64,maxLen-j)
        }
      }
      valMap.push({ str, i })
      return i;
    }
    
    function findSimilars(strs){
      var maxLen = strs.map((s) => s.length).sort().pop() // maxLen of all strings in the array
      console.log('maxLen', maxLen)
      strs.forEach((s) => atoi(s, maxLen)) // Map strings to values
    
      var similars = [];
      var subArr = []
      var margin = 0.05;
      valMap.sort((a,b) => a.i > b.i ? 1 : -1) // Sort the map...
      valMap.forEach((entry, idx) => {  
        if( idx > 0 ){
            var closeness = Math.abs(entry.i / valMap[idx-1].i);
            if( closeness.inRange( 1 - margin, 1 + margin ) ){
              if( subArr.length == 0 ) subArr.push(valMap[idx-1].str)
              subArr.push(entry.str)
              if( idx == valMap.length - 1){
                similars.push(subArr)
              }
            } else {
              if( subArr.length > 0 ) similars.push(subArr)
              subArr = []
            }
        }
      })
      console.log('similars', similars)
    }
    
    // Stress test with 20k+ names 
    $.get('https://raw.githubusercontent.com/dominictarr/random-name/master/names.json')
    .then((resp) => {
      var strs = JSON.parse(resp);
      console.time('processing')
      findSimilars(strs)
      console.timeEnd('processing')
    })
    .catch((err) => { console.err('Err retrieving JSON'); })
    &lt;script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"&gt;&lt;/script&gt;

    (由于某种原因,当我在JSFiddle 中运行它时,我让它在大约 50 毫秒内运行,但在 Stackoverflow sn-p 中,它更接近 1000 毫秒。)

    【讨论】:

    • 非常好,但这使得第一个字符更重要。因此,兄弟项目将始终仅匹配按字母顺序排序的列表中的邻居。
    • 不,这行不通。 "abc" 和 "zbc" 在你的排序中距离很远,但是 levenshtein 距离是 1。
    • 从措辞上看,这是一个特别有趣的问题。如果字符串被视为names,那么我的问题是:我们是否希望将“abc”和“zbc”归为相似?同样,如果我们有 Mo 或 Bo?或者,如果我们有像“Tim”和“Tom”这样非常接近的名字怎么办。非常相似的 strings 但完全不同的 names。还是“Rob”与“Bob”?英语有时很棘手。
    • 我在回答中承认这一点,可以争论我的结果有多准确,而且似乎最终会在算法速度和准确性之间进行权衡。如果我们将 names 视为一组特殊的字符串,那么这就是我以我所做的方式为字符添加权重的原因。
    猜你喜欢
    • 2018-06-15
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2014-08-25
    • 2012-04-12
    • 1970-01-01
    • 1970-01-01
    • 2017-08-07
    相关资源
    最近更新 更多