数据结构与算法--字符串匹配深度长文


如果大家觉得写的还可以,请关注微信公众号:后台开发笔记

字符串匹配

​ 就是在串A中查找是否存在串B,此时串A叫做主串,串B叫做模式串。假设串A长度为n,串B为m,则n>=m,在工业级主要应用于搜索引擎敏感词过滤

BF算法

暴力匹配法,时间复杂度为O(nm)

​ 虽然复杂度高,但在实际开发中常常用到。原因是实际开发中模式串和主串都不会太长,而且中途匹配到就会跳出,所以性能相对还可以。除此之外代码实现简单不易出bug。

伪代码实现:

bool isSubstring(char *master, char *pattern) {
    for (int i = 0; i <= len(master) - len(pattern); i++) {	//遍历匹配
    	bool flag = true;
    	for (int j = 0; j < len(pattern); j++) {
        	if (master[i + j] != pattern[j]) {
            	flag = false;
            	break;
        	}
    	}
    	if (flag) return true;	//若完全匹配成功则直接返回true
	}
    return false;
}

RK算法

​ 对主串n-m+1个子串分别hash,逐个与模式串hash值进行比较,若相同则匹配成功

方式一

​ 对于纯小写字母字符串(当然大小写、大小写+字母也可以)来说,我们可以把它想象成26进制数进行hash,哈希规则为:(上一次hash值 % 26^(m-1))×26+(字符-‘a’)

​ 特点:效率高O(n),不支持模式串过长,否则爆longlong

方式二

​ 我们可以对每个子串进行累加和的方式hash:这种方法会出现哈希冲突(可能字符串hash值一致但字符串并不相等),就需要我们对每个hash值相等的字符串进行检验,若确实一致则匹配成功

​ 特点:无爆longlong风险,最坏时间复杂度为O(nm),当然最坏可能性非常低

伪代码:

//进一步确认是否相同
bool isSame(char *master, char *pattern, int startInx) {
	for (int i = 0; i < len(pattern); i++) {
		if (master[startInx + i] != pattern[i]) return false; 
	}
	return true;
}

bool isSubstring(char *master, char *pattern) {
	int hash = 0;				//hash为模式串hash值
	int tempHash = 0;			//hash为主串中对应子串hash值
	for (int i = 0; i < len(pattern); i++) {	
		hash += pattern[i] - 'a' + 1;
		tempHash += master[i] - 'a' + 1;
	}
	if (tempHash == hash) {	 //若hash值相同,则检验是否完全一致
		if (isSame(master, pattern, 0)) {
			return true;	//若完全一致则返回
		}
	}
	//否则继续遍历
    for (int i = len(pattern); i < len(master); i++) {
    	tempHash += master[i] - 'a' + 1;
   		tempHash -= master[i - len(pattern)] - 'a' + 1;
   		if (tempHash == hash) {	 //遇到hash值相同就检验是否完全一致
   			if (isSame(master, pattern, i - len(pattern) + 1)) {
   				return true;
   			}
   		}
	}
    return false;
}

BM算法

​ BM算法效率高的实质是: 遇到不可匹配字符时,根据一些规律,可以将模式串往后多滑动几位,跳过那些肯定不会匹配的情况

​ 为了达到上述目的,BM算法有坏字符规则好后缀规则两个规则, 我们可以分别计算好后缀和坏字符往后滑动的位数,然后取最大作为模式串往后滑动的位数

​ PS:因为坏字符规则实现比较耗内存,我们可以只用好后缀规则来实现BM算法

坏字符规则

  1. 模式串从后往前主串进行匹配,遇到第一个不匹配主串字符即为坏字符
  2. 从坏字符对应的模式串字符开始往前遍历模式串,找到第一个同坏字符相同的字符,然后与坏字符对齐(若不存在则整体移到坏字符后)


好后缀规则

  1. 模式串从后往前主串进行匹配,遇到第一个不匹配字符后的所有字符好后缀
  2. 从坏字符对应的模式串字符开始往前遍历模式串,找到第一个相同的好后缀对齐(若不存在则整体移到好后缀后)


KMP算法

​ KMP同BM算法一致,都是通过遇到不匹配情况多移动几位跳过一定不匹配情况来提高效率的,时间复杂度为O(n+m)

​它的做法是: 查找最长的可以跟模式串前缀子串匹配的好前缀后缀子串,然后移动到对应位置。具体看图:

我们是怎样查找到的最长好前缀的后缀子串呢?如图:

这样我们只需预处理模式串(因为好前缀一定是模式串的前缀子串),我们预处理next数组来保存最长可匹配前缀子串结尾下标,如图:

优化版next数组求法

​ 其核心就是利用前面计算出的next[i-1]、next[i-2]…来快速推导next[i],其推导规则为:

  1. 若next[i-1] = k,且pattern[k+1]==pattern[i],则next[i] = k+1
  2. 若next[i-1] = k,但pattern[k+1]!=pattern[i],我们则递归查找次大前缀匹配,直到pattern[k+1]==pattern[i]或者到头为止

伪代码

// n, m 分别是主串和模式串的长度。
int kmp(char* master, char* pattern, int n, int m) {
  getNexts(pattern, m);	//预处理next数组
  int j = 0;	//j代表模式串当前匹配到的位置
  for (int i = 0; i < n; ++i) {
    while (j > 0 && master[i] != pattern[j]) {	//遇到坏字符,就移动模式串
      j = next[j - 1] + 1;
    }
    if (master[i] == pattern[j]) {
      ++j;
    }
    if (j == m) { //在主串中找到完全匹配的子串,返回子串开始下角标
      return i - m + 1;
    }
  }
  return -1;
}

// 预处理next数组
void getNexts(char* pattern, int m) {
  next[0] = -1;		//长度为1的好前缀无最长匹配前缀子串
  int k = -1;
  for (int i = 1; i < m; ++i) {
    //若pattern[k+1] != pattern[i],递归查找次大前缀匹配
    //直到pattern[k+1]==pattern[i]或者到头为止
    while (k != -1 && pattern[k + 1] != pattern[i]) {
      k = next[k];
    }
    //若pattern[k+1] == pattern[i],则next[i] = k+1
    if (pattern[k + 1] == pattern[i]) {
      ++k;
    }
    next[i] = k;
  }
   return;
}

字典树

​ 上面的BF、RK、BM、KMP算法为单模式串匹配算法,适用于在一个主串中一次查找一个模式串;而接下来讲的字典树则是多模式匹配算法,可以在一个主串中同时查找多个模式串。假设我们想要在一串字符中查找是否存在arm、hi、hill、pair、part、pen、pencil,就可以用这几个单词来构建一颗字典树:

​ 这样我们只需从字符串的第一个字符C开始在字典树中匹配,当匹配到字典树的叶子节点或中途遇到不匹配字符时,我们从字符C的下个字符(也就是主串后移一位)开始重新在字典树中匹配,时间复杂度O(n x len),其中n为主串长度,len为模式串的平均长度

​ 字典树的本质,就是将字符串间的公共前缀合并在一起,支持多模式串匹配查询,缺点就是耗费内存(每个节点都有长度为26的指针数组)

​ 对于字典树内存的优化,我们可以采用缩点优化,就是对只有一个子节点的节点,且此节点不是串的结束节点,可以将此节点与子节点合并(虽然节省了内存但增加了操作难度,如插入和删除某个单词可能涉及到节点的合并和拆分),如下图:

AC自动机

​ 从字典树匹配可以知道, 当匹配到字典树叶子节点或中途遇到不匹配字符时,主串开始匹配位置后移一位,并重新在字典树中匹配。 这种方式其实并不高效,我们可不可以利用类似KMP的算法,让主串每次尽可能多后移几位?

​ 而AC自动机,实际上就是在字典树基础上,加了类似 KMP 的 next 数组,也就是说在字典树上跑KMP。它的最坏时间复杂度为O(n x len),实际情况接近O(n)

​ AC 自动机的构建包含两个操作:

  • 将多个模式串构建成字典树
  • 字典树中每个节点包含一个失败指针,它的作用和构建过程同KMP的next数组相似

如果某个后缀子串可以匹配某个模式串的前缀,那我们就把这个后缀子串叫作可匹配后缀子串。至于失败指针,就是用来指向最长匹配后缀子串对应模式串前缀的最后一个节点,如图对于abc来说,最长可匹配后缀子串为bc:

快速推导失败指针

记住一点:节点的失败指针只可能出现在它所在层的上一层, 因此我们可以通过已求深度更小的失败指针来推导深度更大的失败指针(按层遍历树)

​ (1) 若节点p的失败指针指向qq存在和pc字符相同的子节点qc,则pc指向qc

(2) 若节点p的失败指针指向qq不存在和pc字符相同的子节点, 则令 q=q->fail 继续向上查找,直到找到或root为止

伪代码

typedef struct AcNode {
  char data; 	//数据
  AcNode* children[26];	//26个指针分别指向不同字母
  bool isEndingChar = false; // 结尾字符为true
  int length = -1;	 	// 当 isEndingChar=true 时,记录模式串长度
  AcNode* fail; 			// 失败指针
} AcNode;

//初始化AC自动机的失败指针(按层遍历)
void buildFailurePointer() {
  Queue<AcNode*> queue;
  root->fail = null;
  queue.push(root);
  //bfs按层遍历AC自动机
  while (!queue.isEmpty()) {
    AcNode* p = queue.pop();
    //维护其子节点的失败指针
    for (int i = 0; i < 26; ++i) {
      AcNode* pc = p->children[i];
      if (pc == null) continue;
      //root的失败指针一定是root(递归终止条件)
      if (p == root) {
        pc->fail = root;
      } else {
        AcNode* q = p->fail;
        while (q != null) {
          AcNode* qc = q->children[pc->data - 'a'];
          if (qc != null) {
            pc->fail = qc;
            break;
          }
          q = q->fail;
        }
        //特判继续向上直到root的情况
        if (q == null) {
          pc.fail = root;
        }
      }
      queue.push(pc);
    }
  }
}

//AC自动机匹配流程
bool match(char* text) { // text 是主串
  int n = strlen(text);
  AcNode* p = root;
  //遍历主串
  for (int i = 0; i < n; ++i) {
    int idx = text[i] - 'a';
    //当无法继续匹配时,循环向上找
    while (p->children[idx] == null && p != root) {
      p = p->fail; // 失败指针发挥作用的地方
    }
    p = p->children[idx];
    if (p == null) p = root; // 如果没有匹配的,从 root 开始重新匹配
    //如果有匹配的,向上挨个看看其节点是否为结束点
    AcNode* tmp = p;
    while (tmp != root) {
      if (tmp->isEndingChar == true) {
        return true;
      }
      tmp = tmp->fail;
    }
  }
  return false;
}
  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值