天天看点

Go 语言实现字符串匹配算法 -- BF(Brute Force) 和 RK(Rabin Karp)

今天介绍两种基础的字符串匹配算法,当然核心还是熟悉一下Go的语法,巩固一下基础知识

  • BF(Brute Force)
  • RK(Rabin Karp)

源字符串:src, 目标字符串:dest; 确认dest是否是src 的一部分。

BF算法很简单暴力,维护两个下标i,j,i控制src的遍历顺序, j控制dest遍历顺序。

记录一下i的起始位置,当j和i所在的字符匹配的时候,j和i都移动,知道j达到末尾则直接返回匹配。

否则i 回到起始位置的下一个位置,j 回到起始位置,两者重新进行匹配搜索。

由于比较简单,直接看一下Go实现的代码即可:

func Brute_force(str1 string, str2 string) bool{
  if len(str1) < len(str2){
    return  false
  }

  var (
    begin int = 0 // 维护src的起始位置
    i int
    j int
  )

  for i = 0; i < len(str1); begin++ {
    for j = 0;j < len(str2); j++ {
      if i < len(str1) && str1[i] == str2[j] {
        i++
        continue
      } else {
        break
      }
    }
    if j == len(str2) {
      return  true
    }
    i = begin
    i++
  }

  return false
}      

以上最坏的情况下是 主串是“aaaaa…aaaaaa”(省略号表示有很多重复的字符a),模式串是“aaaaab”。我们每次都比 对m个字符,要比对n-m+1次,所以,这种算法的最坏情况时间复杂度是O(n*m)。

当然这种模式匹配在实际的软件开发中还是比较常用的,主要有如下两种原因:

  • 实际的软件开发中,大部分情况下,模式串和主串的长度都不会太长。而且每次模式串与主串中的子串匹配的时候,当中途遇到不能匹配的字符的时候, 就可以就停止了,不需要把m个字符都比对一下。所以,尽管理论上的最坏情况时间复杂度是O(n*m),但是,统计意义上,大部分情况下,算法执行效率要比这 个高很多。
  • 朴素字符串匹配算法思想简单,代码实现也非常简单。简单意味着不容易出错,如果有bug也容易暴露和修复。

接下来看一下第二种算法,Rabin Karp。

这个算法的大体思路是:

过哈希算法对主串中的n-m+1个子串分别求哈希值,然后逐个与模式串的哈希值比较大小。如果某个子串的哈希值与模式串相 等,那就说明对应的子串和模式串匹配了。因为哈希值是一个数字,数字之间比较是否相等是非常快速的, 所以模式串和子串比较的效率就提高了。

Go 语言实现字符串匹配算法 -- BF(Brute Force) 和 RK(Rabin Karp)

为了提升效率,我们需要尽可能避免遍历字符串中的每一个字符,否则就是仅仅提高了比较效率而已(数值的比较效率)。

hash函数的设计需要精巧一些,假设我们设置的字符集只包含k个字符,我们可以使用一个K进制的数来表示一个字符串,将这个 K进制转化为10进制的值即可作为hash 值。

比如要处理的字符串包括a-z 26个字母,我们可以使用26进制表示一个字符串,同时将26进制转化为一个10进制的值作为这个字符串的hash值。

对于字符串:“hjk”

h: (‘h’- ‘a’) *26^0

j: (‘j’ - ‘a’) * 26^1

j: (‘k’ - ‘a’) * 26^2

hash(“hjk”) = (‘h’- ‘a’) *26^0 + (‘j’ - ‘a’) * 26^1 + (‘k’ - ‘a’) * 26^2

为了减少遍历源字符串中的字符 次数,我们可以看看如下规律:

源字符串“hjkl”,我们先取 “hjk”, 后取"jkl"

hash(“hjk”) = (‘h’- ‘a’) *26^0 +​

​('j' - 'a') * 26^1 + ('k' - 'a') * 26^2​

​​ hash(“jkl”) = ​

​('j' - 'a') * 26^0 + ('k' - 'a')​

​* 26^1 + (‘l’ - ‘a’) * 26^2

依据此规律,我们可以总结出来两个公式:

hash(i-1) = 26^0 * (s[i-1] - ‘a’) + 26^1 * (s[i] - ‘a’) + … + 26^(m-1) * (s[i+m-2] -‘a’)

hash(i) = 26^0 * (s[i] - ‘a’) + … + 26^(m-2) * (s[i + m - 2] - ‘a’) + 26^(m-1) * (s[i+m-2] -‘a’)

​i​

​​表示源字符串的起始遍历位置字符下标,​

​m​

​​表示目标字符串的长度, ​

​s​

​表示源字符串 src。

两个公式进行运算:hash(i) - hash(i-1) / 26 = 26^(m-1) * (s[i+m-2] -‘a’) - (26^0 * (s[i-1] - ‘a’)) / 26

最终可以得到: hash(i) = (hash(i-1) - s[i-1] -‘a’ ) / 26 + 26^(m-1) * (s[i+m-2] -‘a’) 这样的计算公式。

这个时候,我们只需要扫描一遍主串即能够完成目标字符串的匹配, 主串的长度为n, 也就是我们需要O(n)的扫描复杂度。模式串的hash值 和每一个子串的hash值之间的比较是O(1) , 总共需要比较n-m+1个子串的哈希值,所以,这部分的时间复杂度也是O(n)。

// Calculate a string's hash function
func Hash(str string, m [] int) int {
  if len(str) == 0 {
    return 0
  }

  var (
    t int
    res int = 0
  )

  for i := 0; i < len(str); i ++ {
    t = m[i] * int(str[i] - 'a')
    res = res + t
  }
  return res
}

// match the substring with hash function
// we can calculate the string's hash value with below formula
//
// 's' is source string, m is the length of the substring
// h(i-1) = 26^0 * (s[i-1] - 'a') +
//      26^1 * (s[i] - 'a') + ... +
//      26^(m-1) * (s[i+m-2] -'a')
//
// h(i) = 26^0 * (s[i] - 'a') + ... +
//      26^(m-2) * (s[i + m - 2] - 'a') +
//      26^(m-1) * (s[i+m-2] -'a')
//
// so
// h(i) = (h(i-1) - s[i-1] -'a' ) / 26 + 26^(m-1) * (s[i+m-2] -'a')
// we can use the formula to reduce the cpu's calculation
func Rabin_Karp_Hash(str1 string, str2 string) bool {
  if len(str1) < len(str2) {
    return false
  }

  var m []int
  var t int = 1
  m = append(m,1)

  for i := 1; i < len(str2) + 1; i ++ {
    t = t*26
    m = append(m,t) // m store with 26^0, 26^1, 26^2 ... 26^(len(str2))
  }

  str2_hash := Hash(str2, m)
  fmt.Println(str2_hash)
  str1_hash := Hash(string([]byte(str1)[:len(str2)]),m)

  if str2_hash == str1_hash {
    return  true
  }

  for i := 1; i < len(str1) - len(str2) + 1; i ++ {
    new_hash := (str1_hash - int(str1[i-1]-'a')) / 26 +
          m[len(str2)-1] * int(str1[i+len(str2) -1] - 'a')

    if new_hash == str2_hash {
      return  true
    } else {
      str1_hash = new_hash
    }
  }
  return  false
}