【问题标题】:How to find the longest substring with equal amount of characters efficiently如何有效地找到具有等量字符的最长子字符串
【发布时间】:2015-05-16 22:25:09
【问题描述】:

我有一个由字符 A、B、C 和 D 组成的字符串,我正在尝试计算最长子字符串的长度,该子字符串以任意顺序具有相等数量的这些字符。 例如 ABCDB 将返回 4、ABCC 0 和 ADDBCCBA 8。

我目前的代码:

    public int longestSubstring(String word) {
        HashMap<Integer, String> map = new HashMap<Integer, String>();

        for (int i = 0; i<word.length()-3; i++) {
            map.put(i, word.substring(i, i+4));
        }

        StringBuilder sb;   

        int longest = 0;

        for (int i = 0; i<map.size(); i++) {
            sb = new StringBuilder();
            sb.append(map.get(i));

            int a = 4;

            while (i<map.size()-a) {
                sb.append(map.get(i+a));
                a+= 4;
            }

            String substring = sb.toString();

            if (equalAmountOfCharacters(substring)) {
                int length = substring.length();
                if (length > longest)
                    longest = length;
            }

        }

    return longest;
}

如果字符串长度为 10^4,这目前工作得很好,但我正在尝试使其为 10^5。任何提示或建议将不胜感激。

【问题讨论】:

  • 你的字符串总是4的倍数,你的子字符串只能4对齐吗?
  • 字符串可以是任意长度

标签: java algorithm substring


【解决方案1】:

假设在长度为 N 的字符串中有 K 个可能的字母。我们可以用长度为 K 的向量 pos 跟踪看到的字母的平衡,该向量更新如下:

  • 如果看到字母 1,则添加 (K-1, -1, -1, ...)
  • 如果看到字母 2,则添加 (-1, K-1, -1, ...)
  • 如果看到字母 3,则添加 (-1, -1, K-1, ...)

维护一个哈希值,将 pos 映射到第一个到达 pos 的字符串位置。只要 hash[pos] 已经存在且子字符串值为 s[hash[pos]:pos],就会出现平衡子字符串。

维护哈希的成本是 O(log N),因此处理字符串需要 O(N log N)。这与迄今为止的解决方案相比如何?这些类型的问题往往有线性解决方案,但我还没有遇到过。

这里有一些代码演示了 3 个字母和使用有偏随机字符串运行的想法。 (统一随机字符串允许解决方案的长度约为字符串长度的一半,打印起来很麻烦)。

#!/usr/bin/python
import random
from time import time

alphabet = "abc"
DIM = len(alphabet)


def random_string(n):
    # return a random string over choices[] of length n
    # distribution of letters is non-uniform to make matches harder to find
    choices = "aabbc"
    s = ''
    for i in range(n):
        r = random.randint(0, len(choices) - 1)
        s += choices[r]
    return s


def validate(s):
    # verify frequencies of each letter are the same
    f = [0, 0, 0]
    a2f = {alphabet[i] : i for i in range(DIM)}
    for c in s:
        f[a2f[c]] += 1
    assert f[0] == f[1] and f[1] == f[2]


def longest_balanced(s):
    """return length of longest substring of s containing equal
       populations of each letter in alphabet"""

    slen = len(s)
    p = [0 for i in range(DIM)]
    vec = {alphabet[0] : [2, -1, -1],
           alphabet[1] : [-1, 2, -1],
           alphabet[2] : [-1, -1, 2]}
    x = -1
    best = -1
    hist = {str([0, 0, 0]) : -1}

    for c in s:
        x += 1
        p = [p[i] + vec[c][i] for i in range(DIM)]
        pkey = str(p)

        if pkey not in hist:
            hist[pkey] = x
        else:
            span = x - hist[pkey]
            assert span % DIM == 0
            if span > best:
                best = span

                cand = s[hist[pkey] + 1: x + 1]
                print("best so far %d = [%d,%d]: %s" % (best,
                                                        hist[pkey] + 1,
                                                        x + 1,
                                                        cand))
                validate(cand)

    return best if best > -1 else 0


def main():
    #print longest_balanced( "aaabcabcbbcc" )

    t0 = time()
    s = random_string(1000000)
    print "generate time:", time() - t0
    t1 = time()
    best = longest_balanced( s )
    print "best:", best
    print "elapsed:", time() - t1


main()

在输入 10^6 个字母和 3 个字母的字母表上运行示例:

$ ./bal.py 
...
best so far 189 = [847894,848083]: aacacbcbabbbcabaabbbaabbbaaaacbcaaaccccbcbcbababaabbccccbbabbacabbbbbcaacacccbbaacbabcbccaabaccabbbbbababbacbaaaacabcbabcbccbabbccaccaabbcabaabccccaacccccbaacaaaccbbcbcabcbcacaabccbacccacca
best: 189
elapsed: 1.43609690666

【讨论】:

    【解决方案2】:

    您可能希望缓存 String 的每个索引的累积字符数——这才是真正的瓶颈所在。尚未彻底测试,但类似下面的东西应该可以工作。

    public class Test {
        static final int LEN = 4;
    
        static class RandomCharSequence implements CharSequence {
            private final Random mRandom = new Random();
            private final int mAlphabetLen;
            private final int mLen;
            private final int mOffset;
    
            RandomCharSequence(int pLen, int pOffset, int pAlphabetLen) {
                mAlphabetLen = pAlphabetLen;
                mLen = pLen;
                mOffset = pOffset;
            }
    
            public int length() {return mLen;}
    
            public char charAt(int pIdx) {
                mRandom.setSeed(mOffset + pIdx);
                return (char) (
                    'A' +
                     (mRandom.nextInt() % mAlphabetLen + mAlphabetLen) % mAlphabetLen
                );
            }
    
            public CharSequence subSequence(int pStart, int pEnd) {
                return new RandomCharSequence(pEnd - pStart, pStart, mAlphabetLen);
            }
    
            @Override public String toString() {
                return (new StringBuilder(this)).toString();
            }
        }
    
        public static void main(String[] pArgs) {
            Stream.of("ABCDB", "ABCC", "ADDBCCBA", "DADDBCCBA").forEach(
                pWord -> System.out.println(longestSubstring(pWord))
            );
    
            for (int i = 0; ; i++) {
                final double len = Math.pow(10, i);
                if (len >= Integer.MAX_VALUE) break;
    
                System.out.println("Str len 10^" + i);
                for (int alphabetLen = 1; alphabetLen <= LEN; alphabetLen++) {
                    final Instant start = Instant.now();
                    final int val = longestSubstring(
                        new RandomCharSequence((int) len, 0, alphabetLen)
                    );
    
                    System.out.println(
                        String.format(
                            "  alphabet len %d; result %08d; time %s",
                            alphabetLen,
                            val,
                            formatMillis(ChronoUnit.MILLIS.between(start, Instant.now()))
                        )
                    );
                }
            }
        }
    
        static String formatMillis(long millis) {
            return String.format(
                "%d:%02d:%02d.%03d",
                TimeUnit.MILLISECONDS.toHours(millis),
                TimeUnit.MILLISECONDS.toMinutes(millis) -
                 TimeUnit.HOURS.toMinutes(TimeUnit.MILLISECONDS.toHours(millis)),
                TimeUnit.MILLISECONDS.toSeconds(millis) -
                 TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(millis)),
                TimeUnit.MILLISECONDS.toMillis(millis) -
                 TimeUnit.SECONDS.toMillis(TimeUnit.MILLISECONDS.toSeconds(millis))
            );
        }
    
        static int longestSubstring(CharSequence pWord) {
            // create array that stores cumulative char counts at each index of string
            // idx 0 = char (A-D); idx 1 = offset
            final int[][] cumulativeCnts = new int[LEN][];
            for (int i = 0; i < LEN; i++) {
                cumulativeCnts[i] = new int[pWord.length() + 1];
            }
    
            final int[] cumulativeCnt = new int[LEN];
    
            for (int i = 0; i < pWord.length(); i++) {
                cumulativeCnt[pWord.charAt(i) - 'A']++;
                for (int j = 0; j < LEN; j++) {
                    cumulativeCnts[j][i + 1] = cumulativeCnt[j];
                }
            }
    
            final int maxResult = Arrays.stream(cumulativeCnt).min().orElse(0) * LEN;
            if (maxResult == 0) return 0;
    
            int result = 0;
            for (int initialOffset = 0; initialOffset < LEN; initialOffset++) {
                for (
                    int start = initialOffset;
                    start < pWord.length() - result;
                    start += LEN
                ) {
                    endLoop:
                    for (
                        int end = start + result + LEN;
                        end <= pWord.length() && end - start <= maxResult;
                        end += LEN
                    ) {
                        final int substrLen = end - start;
                        final int expectedCharCnt = substrLen / LEN;
                        for (int i = 0; i < LEN; i++) {
                            if (
                                cumulativeCnts[i][end] - cumulativeCnts[i][start] !=
                                 expectedCharCnt
                            ) {
                                continue endLoop;
                            }
                        }
                        if (substrLen > result) result = substrLen;
                    }
                }
            }
            return result;
        }
    }
    

    【讨论】:

    • 这是一个非常好的方法。效果比我的好,但它仍然需要更快。
    • 代码可能在某些输入字符串上运行缓慢。我编辑了答案以避免测试没有机会产生改进结果的子序列。
    【解决方案3】:

    您可以统计word 中字符的出现次数。那么,一个可能的解决方案可能是:

    1. 如果minword 中任何字符的最小出现次数,那么min 也是我们要查找的子字符串中每个字符的最大可能出现次数。在下面的代码中,minmaxCount
    2. 我们迭代maxCount 的递减值。在每一步,我们正在搜索的字符串的长度都是maxCount * alphabetSize。我们可以将其视为可以滑动的滑动窗口的大小word
    3. 我们将窗口滑过word,计算窗口中字符的出现次数。如果窗口是我们正在搜索的子字符串,我们返回结果。否则,我们会继续搜索。

    [已修复]代码:

    private static final int ALPHABET_SIZE = 4;
    
    public int longestSubstring(String word) {
        // count
        int[] count = new int[ALPHABET_SIZE];
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);
            count[c - 'A']++;
        }
        int maxCount = word.length();
        for (int i = 0; i < count.length; i++) {
            int cnt = count[i];
            if (cnt < maxCount) {
                maxCount = cnt;
            }
        }
        // iterate over maxCount until found
        boolean found = false;
        while (maxCount > 0 && !found) {
            int substringLength = maxCount * ALPHABET_SIZE;
            found = findSubstring(substringLength, word, maxCount);
            if (!found) {
                maxCount--;
            }
        }
        return found ? maxCount * ALPHABET_SIZE : 0;
    }
    
    private boolean findSubstring(int length, String word, int maxCount) {
        int startIndex = 0;
        boolean found = false;
        while (startIndex + length <= word.length()) {
            int[] count = new int[ALPHABET_SIZE];
            for (int i = startIndex; i < startIndex + length; i++) {
                char c = word.charAt(i);
                int cnt = ++count[c - 'A'];
                if (cnt > maxCount) {
                    break;
                }
            }
            if (equalValues(count, maxCount)) {
                found = true;
                break;
            } else {
                startIndex++;
            }
        }
        return found;
    }
    
    // Returns true if all values in c are equal to value
    private boolean equalValues(int[] count, int value) {
        boolean result = true;
        for (int i : count) {
            if (i != value) {
                result = false;
                break;
            }
        }
        return result;
    }
    

    [MERGED] 这是 Hollis Waite 使用累积计数的解决方案,但将我在第 1 点和第 2 点的观察考虑在内。这可能会提高某些输入的性能:

    private static final int ALPHABET_SIZE = 4;
    
    public int longestSubstring(String word) {
        // count
        int[][] cumulativeCount = new int[ALPHABET_SIZE][];
        for (int i = 0; i < ALPHABET_SIZE; i++) {
            cumulativeCount[i] = new int[word.length() + 1];
        }
        int[] count = new int[ALPHABET_SIZE];
        for (int i = 0; i < word.length(); i++) {
            char c = word.charAt(i);
            count[c - 'A']++;
            for (int j = 0; j < ALPHABET_SIZE; j++) {
                cumulativeCount[j][i + 1] = count[j];
            }
        }
        int maxCount = word.length();
        for (int i = 0; i < count.length; i++) {
            int cnt = count[i];
            if (cnt < maxCount) {
                maxCount = cnt;
            }
        }
        // iterate over maxCount until found
        boolean found = false;
        while (maxCount > 0 && !found) {
            int substringLength = maxCount * ALPHABET_SIZE;
            found = findSubstring(substringLength, word, maxCount, cumulativeCount);
            if (!found) {
                maxCount--;
            }
        }
        return found ? maxCount * ALPHABET_SIZE : 0;
    }
    
    private boolean findSubstring(int length, String word, int maxCount, int[][] cumulativeCount) {
        int startIndex = 0;
        int endIndex = (startIndex + length) - 1;
        boolean found = true;
        while (endIndex < word.length()) {
            for (int i = 0; i < ALPHABET_SIZE; i++) {
                if (cumulativeCount[i][endIndex] - cumulativeCount[i][startIndex] != maxCount) {
                    found = false;
                    break;
                }
            }
            if (found) {
                break;
            } else {
                startIndex++;
                endIndex++;
            }
        }
        return found;
    }
    

    【讨论】:

    • 感谢您的回答,但您的代码并不总是返回应有的值。例如,ABCDAAABCDABCD 在应该返回 8 时返回 4,而且它的运行速度比我的版本慢。
    • 感谢您指出这一点。现已修复并添加了一些性能改进。
    • 如果字符串长度为 10^5,第一个仍然很慢。第二个似乎不起作用。
    • 您能否发布一些大小为 10^4 和 10^5 的输入,以及预期的输出?您从第二种解决方案中得到什么错误?
    【解决方案4】:
    1. 假设cnt(c, i) 是长度为i 的前缀中字符c 的出现次数。

    2. 子字符串(low, high] 有两个相等数量的字符ab iff cnt(a, high) - cnt(a, low) = cnt(b, high) - cnt(b, low),或者换句话说,cnt(b, high) - cnt(a, high) = cnt(b, low) - cnt(a, low)。因此,每个位置都由值cnt(b, i) - cnt(a, i) 描述。现在我们可以将它概括为两个以上的字符:每个位置都由一个元组 (cnt(a_2, i) - cnt(a_1, i), ..., cnt(a_k, i) - cnt(a_1, i)) 描述,其中 a_1 ... a_k 是字母表。

    3. 我们可以遍历给定的字符串并维护当前的元组。在每一步,我们都应该通过检查i - first_occurrence(current_tuple) 的值来更新答案,其中first_occurrence 是一个哈希表,它存储了迄今为止看到的每个元组的第一次出现。不要忘记在迭代之前将零元组放入哈希映射(它对应于空前缀)。

    【讨论】:

    • @Deduplicator 它是开始独占和结束包含的。它是反转常用符号,但在减去前缀和时避免+/-1 很方便。
    【解决方案5】:

    好吧,首先不要构造任何字符串。
    如果您不产生任何(或几乎不)垃圾,则无需收集它,这是一个主要优点。

    接下来,使用不同的数据结构:

    我建议使用 4 个字节数组,将它们各自符号的计数存储在从相应字符串索引开始的 4-span 中。
    这应该会大大加快速度。

    【讨论】:

    • 如果效率是最重要的,我认为最好有 4 个长度为 str.length() 的数组,而不是长度为 4 的 str.length() 数组。
    • @HollisWaite:也许我不够清楚,但这就是我的意思。
    【解决方案6】:

    如果只有 A 和 B,那么你可以这样做。

    def longest_balanced(word):
        length = 0
        cumulative_difference = 0
        first_index = {0: -1}
        for index, letter in enumerate(word):
            if letter == 'A':
                cumulative_difference += 1
            elif letter == 'B':
                cumulative_difference -= 1
            else:
                raise ValueError(letter)
            if cumulative_difference in first_index:
                length = max(length, index - first_index[cumulative_difference])
            else:
                first_index[cumulative_difference] = index
        return length
    

    所有四个字母的生活都更加复杂,但想法大致相同。对于 A 与 B,我们保留三个累积差异,而不是只保留 A 与 B、A 与 C 和 A 与 D 的累积差异。

    【讨论】:

    • @Deduplicator 它是 Python。
    • 是的。但是在 Java 问题上,Python 并不比任何其他类型的伪代码更好,这就是我想要说明的一点。 (并不是说这是一个坏主意,因为算法似乎是 OP 真正的问题。)
    • @Deduplicator 这是一个算法问题。如果我编写 Java 示例,我将永远没有时间完成任何工作。
    猜你喜欢
    • 1970-01-01
    • 2013-06-09
    • 1970-01-01
    • 2021-08-02
    • 2015-07-21
    • 2023-04-05
    • 1970-01-01
    • 2019-02-17
    • 2021-05-23
    相关资源
    最近更新 更多