同構——用數論指紋尋找子串排列

劉新宇發表於2020-05-02

有一道程式設計趣題,

要求判斷一段文字T中,是否包含一個字串W的某種排列。

據說不少公司還用這道題目用來面試程式設計師。題目的答案在網路上也到處都能搜尋到。這道題目存在一個特別優雅的解法,體現了數學同構的優美。

數論中的算術基本定理說:任何一個正整數都可以唯一地表示成若干素數的乘積。我們的思路是,將每一個不同字元對應到一個素數上去,a對應2,b對應 3,c對應5......。這樣任意給定一個字串W,不管它是否包含重複的字元,我們都可以把它表示為素數的乘積:

F = product([primes[c] for c in W]])

我們稱其為字串W的數論指紋F。如果W是空串,我們規定它的指紋等於1。根據整數乘法的交換律,我們知道無論W怎樣排列,其數論指紋都不變,並且根據算術基本定理,這個數論指紋是唯一的。現在我們就得到 了一個特別簡潔的解法:我們首先計算出W的數論指紋F ,然後用一個長度為|W|的視窗沿著TXT從左向右滑動。一開始我們需要計算TXT在這個視窗內的數論指紋,並和F比較,如果相等就說明TXT包含W的某種排列。如果不等我們將這個視窗向右滑動一個字元。此時我們可以非常容易地計算新視窗內的數論指紋:只要把滑出的字元對應的素數除掉,再把滑入的字元對應的素數乘上就可以了。任何時候如果新視窗內的數論指紋等於F,就說明找到了一個排列。當然為了獲得每個不同字元對應的素數,我們還要利用埃拉託斯特尼篩法產生一串素數。下面是一個例子Java程式:

import java.util.stream.LongStream;
import java.util.function.LongPredicate;

public class PermuteSubstr {
    private static final int ASCII = 128;
    private static LongPredicate sieves = x -> true; // initialize sieve as id
    private final static long[] PRIMES = LongStream
        .iterate(2, i -> i + 1)
        .filter(i -> sieves.test(i))  // Sieve of Eratosthenes
        .peek(i -> sieves = sieves.and(v -> v % i != 0)) // update, chain the sieve
        .limit(ASCII)                 // only support ASCII
        .toArray();

    private static long product(String str) {
        return str.chars().mapToLong(c -> PRIMES[c]).reduce(1, (a, b) -> a * b);
    }

    public static boolean exist(String w, String txt) {
        if (w.isEmpty()) {
            return true;
        }
        int m = w.length(), n = txt.length();
        if (n < m) {
            return false;
        }
        long target = product(w);
        long fp = product(txt.substring(0, m));
        for (int i = m; i < n && target != fp; ++i) {
            fp = fp / PRIMES[txt.charAt(i - m)] * PRIMES[txt.charAt(i)];
        }
        return target == fp;
    }
}

這段程式中素數序列的產生也很有趣,它利用了埃拉託斯特尼篩法,使用了無窮流的概念,並擷取了前128個素數以處理所有的ASCII碼。關於無窮流可以參見《同構——程式設計中的數學》中“無窮”一章。有關算術基本定理的證明也是數學中優美證明的經典,大數學家柯朗和羅賓的科普讀物《什麼是數學》中有詳細的介紹。

相關文章