在開發的過程當中,咱們會使用各類指令。有時候,咱們因爲這樣或者那樣的緣由,寫錯了某些指令。此時,應用程序每每會爆出錯誤。css
Unrecognized option 'xxx' (did you mean 'xxy'?)
能夠看到,當前代碼不只僅提示了當前你輸入的配置錯誤。同時還提供了相似當前輸入的近似匹配指令。很是的智能。此時,咱們須要使用算法來計算,即模糊集。git
事實上,模糊集其實能夠解決一些現實的問題。例如咱們有一個「高個子」集合 A,定義 1.75m 爲高個子。那麼在通用邏輯中咱們會認爲某一個元素隸屬或者不隸屬該集合。也就是 1.78 就是高個子,而 1.749 就不是高個子,即便它距離 1.75 米只差裏一毫米。該集合被稱爲(two-valued 二元集),與此相對的,模糊集合則沒有這種問題。github
在模糊集合中,全部人都是集合 A 的成員,所不一樣的僅僅是匹配度而已。咱們能夠經過計算匹配度來決定差別性。算法
言歸正轉,咱們回到當前實現。對於模糊集的實現,咱們能夠參考 fuzzyset.js (注: 該庫須要商業許可) 和 fuzzyset.js 交互式文檔 進行學習。數組
在這裏,我僅僅只介紹基本算法,至於數據存儲和優化在完整實現中。緩存
經過查看交互式文檔,咱們能夠算法是經過餘弦類似度公式去計算。框架
在直角座標系中,類似度公式如此計算。函數
cos = (a b) / (|a| |b| ). => 等同於( (x1, y1) (x2,y2)) / (Math.sqrt(x1 2 + y1 2) Math.sqrt(x2 2 + y2 2))oop
而類似度公式是經過將字符串轉化爲數字矢量來計算。若是當前的字符串分別爲 「smaller」 和 「smeller」。咱們須要分解字符串子串來計算。學習
當前能夠分解的字符串子串能夠根據項目來自行調整,簡單起見,咱們這裏使用 2 爲單位。
兩個字符串能夠被分解爲:
const smallSplit: string[] = [ '-s', 'sm', 'ma', 'al', 'll', 'l-' ] const smelllSplit: string[] = [ '-s', 'sm', 'me', 'el', 'll', 'll', 'l-' ]
咱們能夠根據當前把代碼變爲以下向量:
const smallGramCount = { '-s': 1, 'sm': 1, 'ma': 1, 'al': 1, 'll': 1, 'l-': 1 } const smallGramCount = { '-s': 1, 'sm': 1, 'me': 1, 'el': 1, 'll': 2, 'l-': 1 }
const _nonWordRe = /[^a-zA-Z0-9\u00C0-\u00FF, ]+/g; /** * 能夠直接把 'bal' 變爲 ['-b', 'ba', 'al', 'l-'] */ function iterateGrams (value: string, gramSize: number = 2) { // 當前 數值添加先後綴 '-' const simplified = '-' + value.toLowerCase().replace(_nonWordRe, '') + '-' // 經過計算當前子字符串長度和當前輸入數據長度的差值 const lenDiff = gramSize - simplified.length // 結果數組 const results = [] // 若是當前輸入的數據長度小於當前長度 // 直接添加 「-」 補差計算 if (lenDiff > 0) { for (var i = 0; i < lenDiff; ++i) { value += '-'; } } // 循環截取數值而且塞入結果數組中 for (var i = 0; i < simplified.length - gramSize + 1; ++i) { results.push(simplified.slice(i, i + gramSize)); } return results; } /** * 能夠直接把 ['-b', 'ba', 'al', 'l-'] 變爲 {-b: 1, 'ba': 1, 'al': 1, 'l-': 1} */ function gramCounter(value: string, gramSize: number = 2) { const result = {} // 根據當前的 const grams = _iterateGrams(value, gramSize) for (let i = 0; i < grams.length; ++i) { // 根據當前是否有數據來進行數據增長和初始化 1 if (grams[i] in result) { result[grams[i]] += 1; } else { result[grams[i]] = 1; } } return result; }
而後咱們能夠計算 small \* smell 爲:
small gram | small count | smell gram | smell gram | |
---|---|---|---|---|
-s | 1 | \* | -s | 1 |
sm | 1 | \* | sm | 1 |
ma | 1 | \* | ma | 0 |
me | 0 | \* | me | 1 |
al | 1 | \* | al | 0 |
el | 0 | \* | el | 1 |
ll | 1 | \* | ll | 1 |
l- | 1 | \* | l- | 1 |
sum | 4 |
function calcVectorNormal() { // 獲取向量對象 const small_counts = gramCounter('small', 2) const smell_counts = gramCOunter('smell', 2) // 使用 set 進行字符串過濾 const keySet = new Set() // 把兩單詞組共有的字符串塞入 keySet for (let key in small_counts) { keySet.add(key) } for (let key in smell_counts) { keySet.add(key) } let sum: number = 0 // 計算 small * smell for(let key in keySet.keys()) { sum += (small_count[key] ?? 0) * (smell_count[key] ?? 0) } return sum }
同時咱們能夠計算 |small|\*|smell| 爲:
small Gram | SmAll Count | Count \\ 2 |
---|---|---|
-s | 1 | 1 |
sm | 1 | 1 |
ma | 1 | 1 |
al | 1 | 1 |
ll | 1 | 1 |
l- | 1 | 1 |
sum | 6 | |
sqrt | 2.449 |
同理可得當前 smell sqrt 也是 2.449。
最終的計算爲: 4 / (2.449 \* 2.449) = 0.66 。
計算方式爲
// ... 上述代碼 function calcVectorNormal() { // 獲取向量對象 const gram_counts = gramCounter(normalized_value, 2); // 計算 let sum_of_square_gram_counts = 0; let gram; let gram_count; for (gram in gram_counts) { gram_count = gram_counts[gram]; // 乘方相加 sum_of_square_gram_counts += Math.pow(gram_count, 2); } return Math.sqrt(sum_of_square_gram_counts); }
則 small 與 smell 在子字符串爲 2 狀況下匹配度爲 0.66。
固然,咱們看到開頭和結束添加了 - 也做爲標識符號,該標識是爲了識別出 sell 與 llse 之間的不一樣,若是使用
const sellSplit = [ '-s', 'se', 'el', 'll', 'l-' ] const llseSplit = [ '-l', 'll', 'ls', 'se', 'e-' ]
咱們能夠看到當前的類似的只有 'll' 和 'se' 兩個子字符串。
編譯型框架 svelte 項目代碼中用到此功能,使用代碼解析以下:
const valid_options = [ 'format', 'name', 'filename', 'generate', 'outputFilename', 'cssOutputFilename', 'sveltePath', 'dev', 'accessors', 'immutable', 'hydratable', 'legacy', 'customElement', 'tag', 'css', 'loopGuardTimeout', 'preserveComments', 'preserveWhitespace' ]; // 若是當前操做不在驗證項中,纔會進行模糊匹配 if (!valid_options.includes(key)) { // 匹配後返回 match 或者 null const match = fuzzymatch(key, valid_options); let message = `Unrecognized option '${key}'`; if (match) message += ` (did you mean '${match}'?)`; throw new Error(message); }
實現代碼以下所示:
export default function fuzzymatch(name: string, names: string[]) { // 根據當前已有數據創建模糊集,若是有字符須要進行匹配、則能夠對對象進行緩存 const set = new FuzzySet(names); // 獲取當前的匹配 const matches = set.get(name); // 若是有匹配項,且匹配度大於 0.7,返回匹配單詞,不然返回 null return matches && matches[0] && matches[0][0] > 0.7 ? matches[0][1] : null; } // adapted from https://github.com/Glench/fuzzyset.js/blob/master/lib/fuzzyset.js // BSD Licensed // 最小子字符串 2 const GRAM_SIZE_LOWER = 2; // 最大子字符串 3 const GRAM_SIZE_UPPER = 3; // 進行 Levenshtein 計算,更適合輸入完整單詞的匹配 function _distance(str1: string, str2: string) { if (str1 === null && str2 === null) throw 'Trying to compare two null values'; if (str1 === null || str2 === null) return 0; str1 = String(str1); str2 = String(str2); const distance = levenshtein(str1, str2); if (str1.length > str2.length) { return 1 - distance / str1.length; } else { return 1 - distance / str2.length; } } // Levenshtein距離,是指兩個字串之間,由一個轉成另外一個所需的最少的編輯操做次數。 function levenshtein(str1: string, str2: string) { const current: number[] = []; let prev; let value; for (let i = 0; i <= str2.length; i++) { for (let j = 0; j <= str1.length; j++) { if (i && j) { if (str1.charAt(j - 1) === str2.charAt(i - 1)) { value = prev; } else { value = Math.min(current[j], current[j - 1], prev) + 1; } } else { value = i + j; } prev = current[j]; current[j] = value; } } return current.pop(); } // 正則匹配除單詞 字母 數字以及逗號和空格外的數據 const non_word_regex = /[^\w, ]+/; // 上述代碼已經介紹 function iterate_grams(value: string, gram_size = 2) { const simplified = '-' + value.toLowerCase().replace(non_word_regex, '') + '-'; const len_diff = gram_size - simplified.length; const results = []; if (len_diff > 0) { for (let i = 0; i < len_diff; ++i) { value += '-'; } } for (let i = 0; i < simplified.length - gram_size + 1; ++i) { results.push(simplified.slice(i, i + gram_size)); } return results; } // 計算向量,上述代碼已經介紹 function gram_counter(value: string, gram_size = 2) { const result = {}; const grams = iterate_grams(value, gram_size); let i = 0; for (i; i < grams.length; ++i) { if (grams[i] in result) { result[grams[i]] += 1; } else { result[grams[i]] = 1; } } return result; } // 排序函數 function sort_descending(a, b) { return b[0] - a[0]; } class FuzzySet { // 數據集合,記錄全部的可選項目 // 1.優化初始化時候,相同的可選項數據,同時避免屢次計算相同向量 // 2.當前輸入的值與可選項相等,直接返回,無需計算 exact_set = {}; // 匹配對象存入,存儲全部單詞的向量 // 如 match_dist['ba'] = [ // 第2個單詞,有 3 個 // {3, 1} // 第5個單詞,有 2 個 // {2, 4} // ] // 後面單詞匹配時候,能夠根據單詞索引進行匹配而後計算最終分數 match_dict = {}; // 根據不一樣子字符串獲取不一樣的單詞向量,最終有不一樣的匹配度 // item[2] = [[2.6457513110645907, "aaab"]] items = {}; constructor(arr: string[]) { // 當前選擇 2 和 3 爲子字符串匹配 // item = {2: [], 3: []} for (let i = GRAM_SIZE_LOWER; i < GRAM_SIZE_UPPER + 1; ++i) { this.items[i] = []; } // 添加數組 for (let i = 0; i < arr.length; ++i) { this.add(arr[i]); } } add(value: string) { const normalized_value = value.toLowerCase(); // 若是當前單詞已經計算,直接返回 if (normalized_value in this.exact_set) { return false; } // 分別計算 2 和 3 的向量 for (let i = GRAM_SIZE_LOWER; i < GRAM_SIZE_UPPER + 1; ++i) { this._add(value, i); } } _add(value: string, gram_size: number) { const normalized_value = value.toLowerCase(); // 獲取 items[2] const items = this.items[gram_size] || []; // 獲取數組的長度做爲索引 const index = items.length; // 沒有看出有實際的用處?實驗也沒有什麼做用?不會影響 items.push(0); // 獲取 向量數據 const gram_counts = gram_counter(normalized_value, gram_size); let sum_of_square_gram_counts = 0; let gram; let gram_count; // 同上述代碼,只不過把全部的匹配項目和當前索引都加入 match_dict 中去 // 如 this.match_dict['aq'] = [[1, 2], [3,3]] for (gram in gram_counts) { gram_count = gram_counts[gram]; sum_of_square_gram_counts += Math.pow(gram_count, 2); if (gram in this.match_dict) { this.match_dict[gram].push([index, gram_count]); } else { this.match_dict[gram] = [[index, gram_count]]; } } const vector_normal = Math.sqrt(sum_of_square_gram_counts); // 添加向量 如: this.items[2][3] = [4.323, 'sqaaaa'] items[index] = [vector_normal, normalized_value]; this.items[gram_size] = items; // 設置當前小寫字母,優化代碼 this.exact_set[normalized_value] = value; } // 輸入當前值,獲取選擇項 get(value: string) { const normalized_value = value.toLowerCase(); const result = this.exact_set[normalized_value]; // 若是當前值徹底匹配,直接返回 1,沒必要計算 if (result) { return [[1, result]]; } let results = []; // 從多到少,若是多子字符串沒有結果,轉到較小的大小 for ( let gram_size = GRAM_SIZE_UPPER; gram_size >= GRAM_SIZE_LOWER; --gram_size ) { results = this.__get(value, gram_size); if (results) { return results; } } return null; } __get(value: string, gram_size: number) { const normalized_value = value.toLowerCase(); const matches = {}; // 得到當前值的向量值 const gram_counts = gram_counter(normalized_value, gram_size); const items = this.items[gram_size]; let sum_of_square_gram_counts = 0; let gram; let gram_count; let i; let index; let other_gram_count; // 計算獲得較爲匹配的數據 for (gram in gram_counts) { // 獲取 向量單詞用於計算 gram_count = gram_counts[gram]; sum_of_square_gram_counts += Math.pow(gram_count, 2); // 取得當前匹配的 [index, gram_count] if (gram in this.match_dict) { // 獲取全部匹配當前向量單詞的項目,而且根據 索引加入 matches for (i = 0; i < this.match_dict[gram].length; ++i) { // 得到當前匹配的索引 === 輸入單詞[index] index = this.match_dict[gram][i][0]; // 得到匹配子字符串的值 other_gram_count = this.match_dict[gram][i][1]; // 單詞索引添加,注:只要和當前子字符串匹配的 索引都會加入 matches if (index in matches) { matches[index] += gram_count * other_gram_count; } else { matches[index] = gram_count * other_gram_count; } } } } const vector_normal = Math.sqrt(sum_of_square_gram_counts); let results = []; let match_score; // 構建最終結果 [分數, 單詞] for (const match_index in matches) { match_score = matches[match_index]; results.push([ // 分數 match_score / (vector_normal * items[match_index][0]), // 單詞 items[match_index][1] ]); } // 雖然全部的與之匹配子字符串都會進入,但咱們只須要最高的分數 results.sort(sort_descending); let new_results = []; // 若是匹配數目很大,只取的前 50 個數據進行計算 const end_index = Math.min(50, results.length); // 因爲是字符類型數據,根據當前數據在此計算 levenshtein 距離 for (let i = 0; i < end_index; ++i) { new_results.push([ _distance(results[i][1], normalized_value), results[i][1] ]); } results = new_results; // 在此排序 results.sort(sort_descending); new_results = []; for (let i = 0; i < results.length; ++i) { // 由於 第一的分數是最高的,全部後面的數據若是等於第一個 // 也能夠進入最終選擇 if (results[i][0] == results[0][0]) { new_results.push([results[i][0], this.exact_set[results[i][1]]]); } } // 返回最終結果 return new_results; } }
若是你以爲這篇文章不錯,但願能夠給與我一些鼓勵,在個人 github 博客下幫忙 star 一下。