D1 KMP

kaskito
1
2025-07-02

介绍

KMP(Knuth-Morris-Pratt)算法是一种高效的字符串匹配算法,核心思想是利用已经部分匹配的信息,避免重复比较,在文本串中快速查找模式串。KMP算法特别适合处理长文本和重复性高的模式串,时间复杂度是O(m+n),m是模式串长度,n是文本串长度。

KMP算法的关键在于构建一个部分匹配表(也叫失败函数或者next数组),这个表记录了当匹配失败时,模式串指针应该回退到的位置,让算法跳过已知不可能匹配的位置,提高匹配效率。

算法步骤

KMP算法主要分为两个阶段:

  1. 预处理阶段:计算模式串的部分匹配表(next数组)

    • 构建一个数组,记录每个位置的最长相等前后缀长度

    • 该数组用于在匹配失败时确定模式串指针的回退位置

  2. 匹配阶段:使用部分匹配表在文本串中查找模式串

    • 从左到右同时遍历文本串和模式串

    • 当字符不匹配时,根据next数组回退模式串指针

    • 当模式串完全匹配时,记录匹配位置并继续查找其他匹配

核心特性

  • 线性时间复杂度:O(m+n),其中m是模式串长度,n是文本串长度

  • 高效利用历史信息:通过预处理避免了重复比较

  • 只需一次遍历文本串:文本串指针不会回退

  • 空间复杂度:O(m),仅需存储模式串的部分匹配表

  • 适用场景:特别适合长文本和具有重复性的模式串

基础实现

暴力解法

先看下暴力解法:

public class NaiveStringMatcher {
    
    /**
     * 朴素字符串匹配算法
     * @param text 文本串
     * @param pattern 模式串
     * @return 匹配成功则返回模式串在文本串中的起始位置,否则返回-1
     */
    public static int naiveSearch(String text, String pattern) {
        int n = text.length();
        int m = pattern.length();
        
        // 特殊情况处理
        if (m == 0) return 0;
        if (n < m) return -1;
        
        // 尝试所有可能的匹配位置
        for (int i = 0; i <= n - m; i++) {
            int j;
            
            // 从当前位置开始比较模式串和文本串
            for (j = 0; j < m; j++) {
                if (text.charAt(i + j) != pattern.charAt(j)) {
                    break; // 发现不匹配字符,终止内层循环
                }
            }
            
            // 如果j等于m,说明模式串完全匹配
            if (j == m) {
                return i; // 返回匹配位置
            }
        }
        
        return -1; // 未找到匹配
    }
    
    // 使用示例
    public static void main(String[] args) {
        String text = "ABABDABACDABABCABAB";
        String pattern = "ABABCABAB";
        
        int position = naiveSearch(text, pattern);
        
        if (position == -1) {
            System.out.println("未找到匹配");
        } else {
            System.out.println("模式串在位置 " + position + " 处匹配");
        }
    }
}

上述实现暴力枚举所有可能的匹配位置,逐一比较文本串与模式串的每个字符,直到找到完全匹配或确定不存在匹配。

接下来展示KMP算法的实现:

Java实现

public class KMP {
    // 构建部分匹配表(next数组)
    private static int[] buildNext(String pattern) {
        int m = pattern.length();
        int[] next = new int[m];
        next[0] = 0; // 第一个字符的最长相等前后缀长度为0
        
        for (int i = 1, j = 0; i < m; i++) {
            // 当前字符不匹配,回退j
            while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
                j = next[j - 1];
            }
            
            // 当前字符匹配,j向前移动
            if (pattern.charAt(i) == pattern.charAt(j)) {
                j++;
            }
            
            // 记录当前位置的最长相等前后缀长度
            next[i] = j;
        }
        
        return next;
    }
    
    // KMP搜索算法
    public static int kmpSearch(String text, String pattern) {
        if (pattern == null || pattern.length() == 0) {
            return 0;
        }
        
        if (text == null || text.length() < pattern.length()) {
            return -1;
        }
        
        int n = text.length();
        int m = pattern.length();
        
        // 构建next数组
        int[] next = buildNext(pattern);
        
        // 进行匹配
        for (int i = 0, j = 0; i < n; i++) {
            // 当前字符不匹配,根据next数组回退j
            while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
                j = next[j - 1];
            }
            
            // 当前字符匹配,j向前移动
            if (text.charAt(i) == pattern.charAt(j)) {
                j++;
            }
            
            // 完全匹配,返回起始索引
            if (j == m) {
                return i - m + 1;
            }
        }
        
        return -1; // 未找到匹配
    }
    
    // 查找所有匹配位置
    public static List<Integer> kmpSearchAll(String text, String pattern) {
        List<Integer> positions = new ArrayList<>();
        if (pattern == null || pattern.length() == 0) {
            return positions;
        }
        
        if (text == null || text.length() < pattern.length()) {
            return positions;
        }
        
        int n = text.length();
        int m = pattern.length();
        
        // 构建next数组
        int[] next = buildNext(pattern);
        
        // 进行匹配
        for (int i = 0, j = 0; i < n; i++) {
            // 当前字符不匹配,回退j
            while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
                j = next[j - 1];
            }
            
            // 当前字符匹配,j向前移动
            if (text.charAt(i) == pattern.charAt(j)) {
                j++;
            }
            
            // 完全匹配,记录位置并继续匹配
            if (j == m) {
                positions.add(i - m + 1);
                // 回退j以寻找下一个匹配
                j = next[j - 1];
            }
        }
        
        return positions;
    }
    
    public static void main(String[] args) {
        String text = "ABABDABACDABABCABAB";
        String pattern = "ABABCABAB";
        
        int pos = kmpSearch(text, pattern);
        List<Integer> allPos = kmpSearchAll(text, pattern);
        
        System.out.println("文本: " + text);
        System.out.println("模式: " + pattern);
        System.out.println("首次匹配位置: " + (pos != -1 ? pos : "未找到"));
        System.out.println("所有匹配位置: " + allPos);
        
        // 打印next数组,帮助理解
        int[] next = buildNext(pattern);
        System.out.print("next数组: ");
        for (int val : next) {
            System.out.print(val + " ");
        }
        System.out.println();
    }
}

在上述代码中,

// 当前字符不匹配,回退j
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
    j = next[j - 1];
}

是 KMP 算法的核心,在匹配失败时根据预先计算的next数组来确定模式串指针的回退位置。

Python实现

def build_next(pattern):
    m = len(pattern)
    next_array = [0] * m
    
    for i in range(1, m):
        j = next_array[i - 1]
        
        while j > 0 and pattern[i] != pattern[j]:
            j = next_array[j - 1]
        
        if pattern[i] == pattern[j]:
            j += 1
        
        next_array[i] = j
    
    return next_array

def kmp_search(text, pattern):
    if not pattern:
        return 0
    
    if not text or len(text) < len(pattern):
        return -1
    
    n, m = len(text), len(pattern)
    next_array = build_next(pattern)
    
    j = 0
    for i in range(n):
        while j > 0 and text[i] != pattern[j]:
            j = next_array[j - 1]
        
        if text[i] == pattern[j]:
            j += 1
        
        if j == m:
            return i - m + 1
    
    return -1

# 测试
text = "ABABDABACDABABCABAB"
pattern = "ABABCABAB"
print(f"文本: {text}")
print(f"模式: {pattern}")
print(f"匹配位置: {kmp_search(text, pattern)}")
print(f"next数组: {build_next(pattern)}")

优化策略

优化后的 next 数组代码实现:

// 优化next数组,避免匹配失败后回退到同样会失败的位置
private static int[] buildOptimizedNext(String pattern) {
    int m = pattern.length();
    int[] next = new int[m];
    next[0] = 0;
    
    for (int i = 1, j = 0; i < m; i++) {
        while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
            j = next[j - 1];
        }
        
        if (pattern.charAt(i) == pattern.charAt(j)) {
            j++;
        }
        
        // 当前位置匹配失败时,如果回退位置的字符与当前位置相同,则继续回退
        if (i + 1 < m && pattern.charAt(i + 1) == pattern.charAt(j)) {
            next[i] = next[j - 1];
        } else {
            next[i] = j;
        }
    }
    
    return next;
}

预处理减少分支实现:

// 预处理字符映射,减少字符比较的分支
public static int kmpSearchOptimized(String text, String pattern) {
    if (pattern == null || pattern.length() == 0) {
        return 0;
    }
    
    if (text == null || text.length() < pattern.length()) {
        return -1;
    }
    
    int n = text.length();
    int m = pattern.length();
    
    // 使用数组映射来加速字符比较(假设字符集为ASCII)
    // 为每个模式字符的每个位置创建一个状态转移表
    int[][] dfa = new int[256][m];
    
    // 初始化第一个字符的DFA
    dfa[pattern.charAt(0)][0] = 1;
    
    for (int X = 0, j = 1; j < m; j++) {
        // 复制匹配失败情况下的值
        for (int c = 0; c < 256; c++) {
            dfa[c][j] = dfa[c][X];
        }
        // 设置匹配成功情况下的值
        dfa[pattern.charAt(j)][j] = j + 1;
        // 更新重启状态
        X = dfa[pattern.charAt(j)][X];
    }
    
    // 模式匹配
    int i, j;
    for (i = 0, j = 0; i < n && j < m; i++) {
        j = dfa[text.charAt(i)][j];
    }
    
    if (j == m) {
        return i - m; // 找到匹配
    } else {
        return -1;    // 未找到匹配
    }
}

优缺点

优点

  • 时间复杂度为O(m+n),优于朴素的字符串匹配算法(暴力解法)

  • 文本串只需扫描一次,不会回退

  • 对于包含重复模式的字符串会高效

  • 预处理模式串,可以多次用于不同的文本串

  • 能快速跳过已知不会匹配的位置

缺点

  • 需要额外的空间存储next数组

  • 构建next数组的逻辑较为复杂,不易理解

  • 在模式串较短或无重复模式时,相比简单算法优势不明显

  • 实现时容易出错,特别是处理边界情况

应用场景

1)生物信息学中的DNA序列匹配

2)网络入侵检测系统中的模式匹配

3)搜索引擎的关键词匹配

4)数据压缩算法中的模式识别

扩展

多模式字符串匹配

// Aho-Corasick算法 - KMP的多模式扩展
public static class AhoCorasick {
    static class TrieNode {
        TrieNode[] children = new TrieNode[256];
        TrieNode fail;
        List<Integer> patternIndices = new ArrayList<>();
        
        public TrieNode() {
            fail = null;
        }
    }
    
    private TrieNode root;
    private String[] patterns;
    
    public AhoCorasick(String[] patterns) {
        this.patterns = patterns;
        buildTrie();
        buildFailureLinks();
    }
    
    private void buildTrie() {
        root = new TrieNode();
        
        for (int i = 0; i < patterns.length; i++) {
            String pattern = patterns[i];
            TrieNode node = root;
            
            for (char c : pattern.toCharArray()) {
                if (node.children[c] == null) {
                    node.children[c] = new TrieNode();
                }
                node = node.children[c];
            }
            
            node.patternIndices.add(i);
        }
    }
    
    private void buildFailureLinks() {
        Queue<TrieNode> queue = new LinkedList<>();
        
        // 初始化根节点的子节点
        for (int i = 0; i < 256; i++) {
            if (root.children[i] != null) {
                root.children[i].fail = root;
                queue.offer(root.children[i]);
            } else {
                root.children[i] = root;
            }
        }
        
        // BFS构建失败链接
        while (!queue.isEmpty()) {
            TrieNode node = queue.poll();
            
            for (int i = 0; i < 256; i++) {
                if (node.children[i] != null) {
                    TrieNode failNode = node.fail;
                    
                    while (failNode != root && failNode.children[i] == null) {
                        failNode = failNode.fail;
                    }
                    
                    failNode = failNode.children[i];
                    node.children[i].fail = failNode;
                    
                    // 合并匹配结果
                    node.children[i].patternIndices.addAll(failNode.patternIndices);
                    
                    queue.offer(node.children[i]);
                }
            }
        }
    }
    
    public List<Pair<Integer, Integer>> search(String text) {
        List<Pair<Integer, Integer>> results = new ArrayList<>();
        TrieNode currentState = root;
        
        for (int i = 0; i < text.length(); i++) {
            char c = text.charAt(i);
            
            while (currentState != root && currentState.children[c] == null) {
                currentState = currentState.fail;
            }
            
            currentState = currentState.children[c];
            
            for (int patternIndex : currentState.patternIndices) {
                int endPos = i;
                int startPos = endPos - patterns[patternIndex].length() + 1;
                results.add(new Pair<>(patternIndex, startPos));
            }
        }
        
        return results;
    }
    
    static class Pair<K, V> {
        K first;
        V second;
        
        public Pair(K first, V second) {
            this.first = first;
            this.second = second;
        }
    }
}

测验

这里准备了一些测试题,方便大家判断自己的掌握情况:

  1. KMP算法的时间复杂度是多少?它与暴力匹配算法的主要区别是什么?

  2. KMP算法中next数组的含义是什么?如何利用它避免不必要的比较?

  3. 在KMP算法中,文本指针是否会回退?为什么?

测验答案

  1. KMP算法的时间复杂度是O(m+n),其中m是模式串长度,n是文本串长度。与暴力匹配算法O(m*n)相比,KMP算法利用已匹配信息避免重复比较,文本指针不会回退。

  2. next数组记录了模式串中每个位置的最长相等前后缀长度,表示当匹配失败时,模式串指针应该回退到的位置。通过跳过已知不可能匹配的位置,实现减少不必要的比较次数。

  3. 文本指针不会回退,只有模式串指针会根据next数组回退。因为KMP算法的核心思想就是利用已经部分匹配的信息,避免对文本串的重复扫描。

相关的 LeetCode 热门题目

给大家推荐一些可以用来练手的 LeetCode 题目:

KMP算法是字符串处理中的经典算法,用来解决字符串匹配问题,理解它对提升算法设计能力还是很有帮助的

动物装饰