【正则分组】栈结构与括号匹配

2,546 阅读4分钟
1. 前言

在研究正则表达式中,遇到了一个需求。通过本文来梳理记录一下解决方案,并 分享给大家。对于正则表达式而言,一个括号就对应一个分组。现在期望解析正则表达式,获取分组情况:

(((\d{1,4})年)(\d{1,2}))月(\d{1,2})日

比如,上面的正则分组情况如下:简单来说,就是提取所有的匹配括号中内容。

第一组: ((\d{1,4})年)(\d{1,2})
第二组: (\d{1,4})年
第三组: \d{1,4}
第四组: \d{1,2}
第五组: \d{1,2}

2.基于符号分割字符串

首先,通过正则表达式匹配到所有的 () 的左右边界,如下图所示:


通过 split 方法根据上面的正则表达式进行分割,就可以得到如下的列表:

main() {
  String src = r'(((\d{1,4})年)(\d{1,2}))月(\d{1,2})日';
  RegExp exp = RegExp(r'(?<=\()|(?<=\))|(?=\()|(?=\))');
  List<String> parts = src.split(exp);
}

所以现在我们需要做的是如何匹配括号的闭合,并提取出闭合括号中的内容。


3. 括号闭合匹配思路

对闭合性的校验,最常用的当属栈结构 。可能很多朋友只是听闻,并不知道具体的处理逻辑,这里通过图解示意一下:首先场景1为空栈,此时索引为 0 的 ( 准备入栈;然后索引为 1 的 ( 准备入栈,此时栈中有一个元素,而 ( 未能与栈顶匹配,所以入栈,如下场景 3


索引为 2 的字符不是 () ,所以不作处理;场景5中,索引为 3 的字符为 ) ,此时栈顶是索引为 1( 。两者是匹配的,故 出栈 ,之后栈中只有一个元素,如场景6:


也就是说索引 1 和 索引 3 配对了,此时我们可以记录这两个索引值,以便后续处理。如下所示:


接下来继续匹配,索引为 4 的字符不是 () ,所以不作处理;场景8中,索引为 5 的字符为 ) ,此时栈顶是索引为 0( 。两者是匹配的,故 出栈 ,之后栈中只有没有元素,如场景9:


这样我们就得到了 05 索引是括号匹配的区间,也记录下来:

后面同理,根据 ) 字符对比,通过 ( 字符的出入栈情况,我们就可以获取到括号匹配的空间。


4.代码实现

首先,Dart 中的栈结构可以通过 Queue 实现,它是双链表的数据结构,可以支持两端操作,所以既可以当栈,又可以当队列来用。栈结构是先进后出,每次元素都在末尾添加,所以入栈是 addLast 方法,出栈是末尾元素先出战,即 removeLast 方法。
另外,抽象出 Position 概念,记录括号的起止点,其中包含 startend 索引位:

class Position {
  final int start;
  final int end;
  
  Position({required this.start,required this.end});
  
  String part(List parts)=> parts.sublist(start+1,end).join();
  
  @override
  String toString() {
    return 'Position{start: $start, end: $end}';
  }
}

前面的示意图理解后,逻辑处理非常简单,也就下面的十几行:( 时入栈, ) 是栈非空,栈顶出栈,并收集配对的节点即可。

main() {
  String src = r'(((\d{1,4})年)(\d{1,2}))月(\d{1,2})日';
  RegExp exp = RegExp(r'(?<=\()|(?<=\))|(?=\()|(?=\))');
  List<String> parts = src.split(exp);

  final Queue<int> stack = Queue();
  List<Position> positionList = [];
  for (int i = 0; i < parts.length; i++) {
    String target = parts[i];
    if (target == "(") {
      stack.addLast(i);
    }
    if (target == ")") {
      if (stack.isNotEmpty) {
        int matchIndex = stack.removeLast();
        // 收集配对的 节点
        positionList.add(Position(
          start: matchIndex,
          end: i,
        ));
      }
    }
  }
}

通过调试可以看出,收集的 positionList 正是图示中两个配对括号的索引位:


由于正则的分组规则,我们需要通过根据 ( 的索引位排下顺序,如下所示:

positionList.sort((a,b)=>a.start-b.start);
print('===正则表达式:== ${src}=====');
print('===分组情况=====');
positionList.forEach((element) {
  print(element.part(parts));
});

这样,给定正则表达式,我们就能分析出分组情况:


5.正则匹配的分组测试

我们可以通过一个小案例测试一下该正则的分组匹配情况:

main() {
  String src = r'光绪七年辛巳年八月初三(1881年9月25日),出生于浙江绍兴城内东昌坊新台门周家。幼名阿张,长根,长庚,学名周樟寿。';
  String reg = r'(((\d{1,4})年)(\d{1,2}))月(\d{1,2})日';
  RegExp exp = RegExp(reg);

  List<RegExpMatch> allMatches = exp.allMatches(src).toList();
  for (var match in allMatches) {
    print('match.groupCount:${match.groupCount}');
    for (int i = 0; i <= match.groupCount; i++) {
      print("groupIndex:$i====match:${match.group(i)}====");
    }
  }
}

打印结果如下,根据上面分析的结果,可以看到是正确的:

---->[打印结果]----
groupIndex:0====match:1881925日====
groupIndex:1====match:1881年====
groupIndex:2====match:1881====
groupIndex:3====match:9====
groupIndex:4====match:25====
  
---->[分析出的分组结果]----
(\d{1,4})年
\d{1,4}
\d{1,2}
\d{1,2}

再用更复杂一些的括号实验一下,比如: (((\d{1,4})年)(\d{1,2})月)((\d{1,2})日) ,测试结果如下:

---->[正则匹配打印结果]----
groupIndex:0====match:1881925日====
groupIndex:1====match:18819月====
groupIndex:2====match:1881年====
groupIndex:3====match:1881====
groupIndex:4====match:9====
groupIndex:5====match:25日====
groupIndex:6====match:25====

---->[分析出的分组结果]----
((\d{1,4})年)(\d{1,2})月
(\d{1,4})年
\d{1,4}
\d{1,2}
(\d{1,2})日
\d{1,2}

6.非捕获的处理

(?:) 可以设置非捕获,表示当前括号不需要作为一个组,我们需要对其进行处理,处理方式也非常简单,移除匹配的以 ?: 开头的记录即可:

positionList.sort((a,b)=>a.start-b.start);
positionList.removeWhere((element) => element.part(parts).startsWith("?:"));
positionList.forEach((element) {
  print(element.part(parts));
});

下面通过: (((?:\d{1,4})年))(?:(\d{1,2}))月(\d{1,2})日 实验一下,测试结果如下:

---->[正则匹配打印结果]----
groupIndex:0====match:1881925日====
groupIndex:1====match:1881年====
groupIndex:2====match:1881年====
groupIndex:3====match:9====
groupIndex:4====match:25====

---->[分析出的分组结果]----
((?:\d{1,4})年)
(?:\d{1,4})年
\d{1,2}
\d{1,2}

好了,本文到这里就结束了,这样通过一个正则表达式,就可以完美地解析出它的分组情况。《玩转正则表达式》 小册正在进行中,敬请期待 ~