字符串匹配 ---- BM 算法原理

轉自:https://zhuanlan.zhihu.com/p/63596339

關於字符串匹配有很多算法, BF, BK, KMP, 但這些都不是這篇文章的重點. 文章的重點是另外一種更高效的算法 Boyer-Moore 算法, 一般文本編輯器中的查找功能都是基於它實現的.

前置內容

什麼是字符串匹配? 它只不過是在一堆字符串中查找特定的字符串的行爲, 就像下面的例子一樣.

現在爲了方便起見, 我們把一堆的字符串稱爲主串 main, 特定的字符串稱爲模式串 pattern, 查找過程就可以理解爲不斷移動 pattern 的過程. 我們從主串的第一個字符開始, 逐個匹配, 遇到不匹配的字符, 就將 pattern 移動一位,直到全部匹配上.

 

所以到這裏, 我們就可以說, 更高效的字符串匹配就是更高效的模式串 pattern 移動. 如何進行高效的移動就是算法的意義所在.

暴力匹配

如果我們用一般方法來完成字符串匹配, 我們可以用一個循環來完成. 試着想象, 我們把模式串抽象的想象爲一個字符, 同樣的主串中的所有子串我也都抽象的想象爲一個字符, 這個過程就變爲了對一個字符的匹配, 代碼就可以寫成下面這樣:

function search(main, pattern) {
    if (main.length === 0 || pattern.length === 0 || main.length < pattern.length) {
        return -1
    }
    for (let i = 0; i <= main.length - pattern.length; i++) {
        let subStr = main.slice(i, i + pattern.length) // 此處可以想象爲一個字符
        if (subStr === pattern) {
            return i
        }
    }
    return -1;
}

這個方法簡單粗暴, 所以有個名副其實的稱呼叫做暴力匹配算法.

雖然我們上面把主串的子串, 和模式串都抽象爲一個字符, 但實際比較時, 我們還是需要另一個循環來比較每個字符. 於是有人想了個改進的方法, 對主串中的子串和模式串進行哈希, 這樣就每次比較哈希值就可以了. 但是這種方法, 治標不治本, 並沒有提高移動模式串的效率.

壞字符匹配

現在我們來說另外一種匹配方法, 倒着匹配.

爲什麼要倒着匹配? 仔細的想一下我們在正方向匹配的時候, 遇到不匹配的字符我們能做什麼?我們可以將字符一位一位的移動進行暴力匹配, 也可以拿主串中不匹配的字符在模式串中找到相等的字符, 然後移動該字符到主串中對應的位置, 如下圖.

原本我們是正着過來的, 但現在卻需要返回去, 那爲什麼不從一開始我們就倒着進行匹配? 我們從模式串的末尾倒着匹配, 當發現主串中無法匹配的字符時, 我們就把這個字符稱爲壞字符. 然後我們就可以像上面說的在模式串中找相等的字符, 然後移動. 如果模式串中沒有相等的字符, 我們就將整個模式串移動到壞字符的後面.

好了, 現在我們知道了基本原理, 但還有個問題沒解決, 我們怎麼知道模式串的前面有沒有和壞字符相等的字符?這裏我們就需要一個技巧性的預處理, 用一個散列表來存儲模式串中的字符和下標, 這樣就可以快速定位字符的位置, 下面是一個最簡單的實現:

/**
 * 
 * @param {String} pattern
 * @description 以 ascii 碼作爲下標, 存儲壞字符串在模式串中的位置 
 */
function generatebc(pattern) {
    const bc = new Array(265).fill(-1)
    for (let i = 0; i < pattern.length; i++) {//如果有相同的字符,記錄後面的字符減少移動
//避免錯過匹配
        const index = pattern[i].charCodeAt()
        bc[index] = i
    }
    return bc
}

/**
 * 
 * @param {String} substr 主串中的子串
 * @param {String} pattern 模式串
 * @param {Array} bc
 * @description 查找壞字符串是查找主串中不匹配的字符在模式串中的位置
 * 假如主串中壞字符的位置對應的模式串中的位置是 si, 我們在模式串中找到同樣的字符位置在 xi,
 * 
 * 那麼 si - xi, 就是模式串要移動的位置。
 */
function findBadChar(substr, pattern, bc) {
    let len = substr.length - 1
    let j = -1 //記錄壞字符主串中的下標
    let k = -1 // 記錄模式串中對應的壞字符下標
    let badChar = '' // 記錄壞字符
    for (let i = len; i >= 0; i--) {
        if (substr[i] !== pattern[i]) {
            j = i
            badChar = substr[i]
            break
        }
    }
    if (j > 0) {
        k = bc[badChar.charCodeAt()]
    }
    return {
        patternBadCharIndex: k,
        mainBadCharIndex: j
    }
}

好後綴規則

假設我們現在有這樣一個主字符串 aaaaaaaaaaaaa, 模式串爲 baaa, 我們用壞字符規則來處理, 好了, 計算出來在模式串中的壞字符位置爲 -1. 所以, 光憑壞字符是不夠的, 我們需要另一種處理方式, 好後綴規則.

像上面的, 我們把已經匹配到的 bcab 字符串, 就稱爲好後綴, 現在我們就利用它來進行模式串的移動.

在這之前我們要明確兩個概念, 後綴子串和前綴子串. 就拿 bcab 來講, 它的後綴子串如下表:

前綴子串也是同理.

到這裏我們就可以去了解好後綴處理的基本規則:

   1. 找出好後綴的所有後綴子串 
   2. 找出模式串的所有前綴子串
   3. 找到好後綴中最長的能和模式串的前綴子串匹配的後綴子串
   
注意:好後綴的後綴子串,本身也是模式串的後綴子串,所以我們可以利用這個在模式串中找到另外的對應匹配的字符

我們就可以移動模式串中和好後綴子串相等的字符串到對應的位置.

爲了更高效的移動模式串, 我們同樣需要一些預處理, 將原本的循環處理成散列表查詢.

第一步: 引入 suffix 數組

這個非常簡單, 假設我們在好後綴中的某個子串的長度爲 k, 它在模式串中的前綴子串中有相等的, 且起始位置爲 i, 那麼我們就記錄 suffix[k] = i, 如果不存在,我們就記錄爲 suffix[k] = -1.

第二步: 引入 prefix 數組

除了 suffix 數組之外,我們還需要另外一個 Boolean 類型的 prefix 數組,來記錄模式串的後綴子串(好後綴的後綴子串)是否能匹配模式串的前綴子串.

/**
 * @description 處理好後綴
 * @param pattern 模式串 
 * suffix: 用子串長度爲 k 存儲主串的好後綴{u} 對應的子串中 {u*} 對應的起始位置
 * prefix:用子串長度爲 k 存儲 模式串中是否存在和好後綴相同的字符串
*/
function generateGS(pattern) {
    const len = pattern.length
    const suffix = new Array(len).fill(-1)
    const prefix = new Array(len).fill(false)
    for (let i = 0; i < len - 1; i++) {
        let j = i;
        let k = 0;
        while (j >= 0 && pattern[j] === pattern[len - 1 - k]) {
            j--;
            k++;
            suffix[k] = j + 1
        }
        if (j === -1) {
            prefix[k] = true
        }
    }
    return {
        suffix,
        prefix
    }
}

當我們有了有了這兩個數組, 我們既可以確定模式串的滑動位數.

}
/**
 * 
 * @param { Number} badCharStartIndex  壞字符的對應的模式串的下標
 * @param { Number} patternLength 模式串的長度
 * @param { Array<-1>} suffix 
 * @param { Array<boolean>} prefix 
 */
function moveByGS(badCharStartIndex, patternLength, suffix, prefix) {
    let k = patternLength - badCharStartIndex - 1 // 好後綴長度
    // 完全匹配
    if (suffix[k] !== -1) {
        return badCharStartIndex - suffix[k] + 1
    }
    // 部分匹配
    for (let r = badCharStartIndex + 2; r <= patternLength - 1; r++) {
        if (prefix[patternLength - r]) {
            return r
        }
    }
    return patternLength
}

至此, 原理部分全部結束, 下面是完整的 JavaScript 代碼

function generatebc(pattern) {
    const bc = new Array(265).fill(-1)
    for (let i = 0; i < pattern.length; i++) {
        const index = pattern[i].charCodeAt()
        bc[index] = i
    }
    return bc
}


function generateGS(pattern) {
    const len = pattern.length
    const suffix = new Array(len).fill(-1)
    const prefix = new Array(len).fill(false)
    for (let i = 0; i < len - 1; i++) {
        let j = i;
        let k = 0; // 公共後綴子串長度
        while (j >= 0 && pattern[j] === pattern[len - 1 - k]) {
            j--;
            k++;
            suffix[k] = j + 1
        }
        if (j === -1) {
            prefix[k] = true
        }
    }
    return {
        suffix,
        prefix
    }
}
function moveByGS(badCharStartIndex, patternLength, suffix, prefix) {
    let k = patternLength - badCharStartIndex - 1 // 好後綴長度
    // 完全匹配
    if (suffix[k] !== -1) {
        return badCharStartIndex - suffix[k] + 1
    }
    // 部分匹配
    for (let r = badCharStartIndex + 2; r <= patternLength - 1; r++) {
        if (prefix[patternLength - r]) {
            return r
        }
    }
    return patternLength
}
function Bm(main, pattern) {
    if (main.length === 0 || pattern.length === 0 || pattern.length > main.length) {
        return -1
    }
    const mainLen = main.length
    const patternLen = pattern.length
    const bc = generatebc(pattern)
    const { suffix, prefix } = generateGS(pattern)
    let step = 1
    // i, start index of main string
    for (let i = 0; i <= mainLen - patternLen; i = i + step) {
        let substr = main.slice(i, i + patternLen)
        const { patternBadCharIndex, mainBadCharIndex } = findBadChar(substr, pattern, bc)
        let stepForBC = mainBadCharIndex - patternBadCharIndex
        if (mainBadCharIndex === -1) { // mainBadCharIndex 壞字符出現的位置, 爲 -1 時說明沒有壞字符,在開始位置就匹配了
            return i
        }
        let stepForGS = -1
        if (mainBadCharIndex < patternLen - 1) {
            stepForGS = moveByGS(patternBadCharIndex, patternLen, suffix, prefix)
        }
        step = Math.max(stepForBC, stepForGS)
    }
    return -1;
}


function findBadChar(substr, pattern, bc) {
    let len = substr.length - 1
    let j = -1 //記錄壞字符主串中的下標
    let k = -1 // 記錄模式串中對應的壞字符下標
    let badChar = '' // 記錄壞字符
    for (let i = len; i >= 0; i--) {
        if (substr[i] !== pattern[i]) {
            j = i
            badChar = substr[i]
            break
        }
    }
    if (j > 0) {
        k = bc[badChar.charCodeAt()]
    }
    return {
        patternBadCharIndex: k,
        mainBadCharIndex: j
    }
}

console.log('查詢結果爲: ' + Bm('yaoyaozhuona', 'zhuo'))
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章