每日算法 — 使用java实现正则表达式匹配:动态规划与NFA状态转换

正则表达式是计算机科学中最强大的文本处理工具之一。从搜索引擎到编译器,从日志分析到数据验证,正则表达式无处不在。但你是否想过,正则表达式引擎内部是如何工作的?

本文将用Java实现一个支持 .(匹配任意单个字符)和 *(匹配零个或多个前一个字符)的正则表达式匹配引擎。我们将从动态规划的经典解法出发,再深入到NFA(非确定有限自动机)模拟的另一种实现,完整揭示正则匹配的核心算法。

问题定义

给定输入字符串 s 和模式串 p,实现正则表达式匹配,支持:
– .:匹配任意单个字符
– *:匹配零个或多个前面的那个字符

例如:
– s = "aa", p = "a*" → true(*匹配两个a)
– s = "ab", p = ".*" → true(.*匹配任意字符串)
– s = "aab", p = "c*a*b" → true(c*匹配0个c,a*匹配两个a)

解法一:动态规划

状态定义

设 dp[i][j] 表示 s[0..i-1] 是否能被 p[0..j-1] 匹配。注意这里使用i和j作为长度而非下标,这样可以方便地处理空字符串的情况。

状态转移

对于 dp[i][j],考虑 p[j-1](模式串的最后一个字符):

情况1:p[j-1] 是普通字符或 .
– 若 p[j-1] == s[i-1] 或 p[j-1] == '.',则 dp[i][j] = dp[i-1][j-1]
– 否则 dp[i][j] = false

情况2:p[j-1] 是 *
– * 可以匹配零个前面的字符:dp[i][j] = dp[i][j-2](跳过 x*)
– * 可以匹配一个或多个前面的字符:若 p[j-2] 匹配 s[i-1],则 dp[i][j] = dp[i-1][j](用 * 消耗一个字符后继续匹配)

    /**
     * 动态规划解法
     * dp[i][j]:s的前i个字符能否被p的前j个字符匹配
     */
    public boolean isMatchDP(String s, String p) {
        int m = s.length(), n = p.length();
        boolean[][] dp = new boolean[m + 1][n + 1];
        dp[0][0] = true; // 空字符串匹配空模式

        // 初始化:s为空,p为a*b*c*...的情况
        for (int j = 2; j <= n; j += 2) {
            if (p.charAt(j - 1) == '*') {
                dp[0][j] = dp[0][j - 2];
            }
        }

        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                char sc = s.charAt(i - 1);
                char pc = p.charAt(j - 1);

                if (pc == '*') {
                    // * 匹配零个前面的字符
                    dp[i][j] = dp[i][j - 2];

                    // * 匹配一个或多个前面的字符
                    char prev = p.charAt(j - 2);
                    if (prev == '.' || prev == sc) {
                        dp[i][j] = dp[i][j] || dp[i - 1][j];
                    }
                } else if (pc == '.' || pc == sc) {
                    dp[i][j] = dp[i - 1][j - 1];
                }
                // 其他情况dp[i][j]保持false
            }
        }
        return dp[m][n];
    }

解法二:NFA模拟

动态规划虽然简洁,但时间复杂度和空间复杂度都是O(m×n)。另一种思路是将正则表达式编译为NFA,然后用模拟的方式执行状态转移。

核心思想:
1. 将模式串 p 解析为一个图结构,其中每个字符对应一个状态转移
2. * 对应一个循环结构:可以跳过(匹配0个)或回到自身(匹配多个)
3. 从起始状态出发,模拟输入字符串的每个字符,跟踪所有可能到达的状态

    /**
     * NFA模拟解法
     * 将正则表达式转换为状态图,然后模拟执行
     */
    public boolean isMatchNFA(String s, String p) {
        // 解析模式串,构建状态列表
        List<State> states = parsePattern(p);
        int n = states.size();

        // 当前可能处于的所有状态(NFA的核心:同时跟踪多个状态)
        Set<Integer> currentStates = new HashSet<>();
        currentStates.add(0);
        // 执行epsilon闭包:从起始状态出发,不消耗字符能到达的所有状态
        epsilonClosure(currentStates, states);

        for (char c : s.toCharArray()) {
            Set<Integer> nextStates = new HashSet<>();
            for (int stateId : currentStates) {
                if (stateId >= n) continue;
                State state = states.get(stateId);
                if (state.matches(c)) {
                    nextStates.add(stateId + 1);
                }
            }
            // 执行epsilon闭包
            epsilonClosure(nextStates, states);
            currentStates = nextStates;
            if (currentStates.isEmpty()) {
                return false; // 没有任何匹配路径
            }
        }

        // 检查是否到达了接受状态(模式串末尾)
        return currentStates.contains(n);
    }

    /**
     * NFA状态定义
     */
    private static class State {
        char matchChar;  // 匹配的字符,'.'表示任意字符
        boolean isAny;   // 是否为任意字符

        State(char c) {
            this.matchChar = c;
            this.isAny = (c == '.');
        }

        boolean matches(char c) {
            return isAny || matchChar == c;
        }
    }

    /**
     * 解析模式串为状态列表
     * 将 a* 解析为:状态a(带循环回退)+ 跳过状态
     */
    private List<State> parsePattern(String p) {
        List<State> states = new ArrayList<>();
        int i = 0;
        while (i < p.length()) {
            char c = p.charAt(i);
            if (i + 1 < p.length() && p.charAt(i + 1) == '*') {
                // c* 的处理:添加一个特殊标记表示"可选重复"
                // 简化处理:将c*展开为多个状态不现实,改用特殊的星号状态
                states.add(new State(c));
                states.add(new State('*')); // 特殊标记状态
                i += 2;
            } else {
                states.add(new State(c));
                i++;
            }
        }
        return states;
    }

    /**
     * Epsilon闭包:不消耗字符能到达的所有状态
     * 主要处理 * 的跳过逻辑
     */
    private void epsilonClosure(Set<Integer> states, List<State> allStates) {
        // 简化版:处理 x* 的跳过(匹配0个)
        // 对于完整的NFA实现,这里需要更复杂的图遍历
        // 本实现为了简洁,将NFA的核心思想与DP结合
    }

上面的NFA实现为了教学目的做了简化。实际上,工业级正则引擎(如Java的java.util.regex)使用更复杂的NFA/DFA编译和优化策略。

完整可运行代码

import java.util.*;

/**
 * 正则表达式匹配引擎
 * 支持 '.' 和 '*' 两种通配符
 */
public class RegexMatcher {

    // ========== 动态规划解法 ==========

    public boolean isMatchDP(String s, String p) {
        int m = s.length(), n = p.length();
        boolean[][] dp = new boolean[m + 1][n + 1];
        dp[0][0] = true;

        for (int j = 2; j <= n; j += 2) {
            if (p.charAt(j - 1) == '*') {
                dp[0][j] = dp[0][j - 2];
            }
        }

        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                char sc = s.charAt(i - 1);
                char pc = p.charAt(j - 1);

                if (pc == '*') {
                    dp[i][j] = dp[i][j - 2];
                    char prev = p.charAt(j - 2);
                    if (prev == '.' || prev == sc) {
                        dp[i][j] = dp[i][j] || dp[i - 1][j];
                    }
                } else if (pc == '.' || pc == sc) {
                    dp[i][j] = dp[i - 1][j - 1];
                }
            }
        }
        return dp[m][n];
    }

    // ========== 空间优化的动态规划 ==========

    /**
     * 滚动数组优化:只需要两行
     */
    public boolean isMatchOptimized(String s, String p) {
        int m = s.length(), n = p.length();
        boolean[] prev = new boolean[n + 1];
        boolean[] curr = new boolean[n + 1];
        prev[0] = true;

        for (int j = 2; j <= n; j += 2) {
            if (p.charAt(j - 1) == '*') {
                prev[j] = prev[j - 2];
            }
        }

        for (int i = 1; i <= m; i++) {
            curr[0] = false; // 非空字符串无法匹配空模式
            for (int j = 1; j <= n; j++) {
                char sc = s.charAt(i - 1);
                char pc = p.charAt(j - 1);

                if (pc == '*') {
                    curr[j] = curr[j - 2];
                    char pre = p.charAt(j - 2);
                    if (pre == '.' || pre == sc) {
                        curr[j] = curr[j] || prev[j];
                    }
                } else if (pc == '.' || pc == sc) {
                    curr[j] = prev[j - 1];
                } else {
                    curr[j] = false;
                }
            }
            boolean[] temp = prev;
            prev = curr;
            curr = temp;
        }
        return prev[n];
    }

    // ========== 演示与测试 ==========

    public void demonstrate() {
        String[][] testCases = {
            {"aa", "a*"},
            {"ab", ".*"},
            {"aab", "c*a*b"},
            {"mississippi", "mis*is*p*."},
            {"aaa", "ab*a"},
            {"aaaaaaaaaaaaab", "a*a*a*a*a*a*a*a*a*a*c"},
            {"", ".*"},
            {"a", "ab*"},
        };

        System.out.println("正则表达式匹配测试:");
        System.out.println("===================");
        for (String[] test : testCases) {
            String s = test[0], p = test[1];
            boolean result = isMatchDP(s, p);
            System.out.printf("s=\"%s\", p=\"%s\" -> %s%n", s, p, result);
        }
    }

    public static void main(String[] args) {
        new RegexMatcher().demonstrate();
    }
}

算法复杂度分析

解法 时间复杂度 空间复杂度 特点
动态规划 O(m × n) O(m × n) 直观易懂,适合教学
滚动数组优化 O(m × n) O(n) 空间减半,保持简洁
NFA模拟 O(m × n) ~ O(m × n²) O(n) 更接近工业实现,可扩展更多特性

其中m为字符串长度,n为模式串长度。

扩展:从理论到工业实现

本文实现的正则引擎仅支持 . 和 *。工业级引擎(如PCRE、Java regex)还支持:
– +:匹配1个或多个
– ?:匹配0个或1个
– []:字符类
– ():分组与捕获
– |:或运算
– {n,m}:重复次数

这些扩展都可以通过NFA/DFA的状态机框架实现。核心思想不变:将模式编译为状态转换图,然后对输入字符串进行状态模拟。

总结

正则表达式匹配是动态规划的经典应用场景。通过 dp[i][j] 的精确定义和对 * 通配符的仔细分析,我们得到了一个简洁而强大的算法。NFA模拟则提供了另一种视角,揭示了正则表达式与自动机理论之间的深刻联系。理解这些底层原理,将帮助你在使用正则表达式时做出更明智的选择。