题目描述

✅ 471. 编码最短长度的字符串

题意分析

将非空字符串写成尽可能短的等价编码。连续重复的片段可以写成 k[encoded_string],括号内也允许继续编码;若编码不能缩短长度,就保留原文。多个答案同样短时,返回任意一个。

解法:区间动态规划 + 最短整周期

核心思路

[!blue]

一段字符串既可能整体重复,也可能由几个不同的可压缩片段拼接而成,不能只压缩整串。定义 dp[i][j] 为子串 s[i..j] 的最短编码,保存字符串本身,更新时比较编码后的长度。按区间长度递增计算,让更短片段的答案先准备好。

先用原文初始化状态。再枚举每个分割点 split,比较 dp[i][split] + dp[split + 1][j]。如果最优表示由多个部分拼接,总能在其中一个部分的边界切开;左右分别取最短编码,不会比使用其他编码更长,因此两段转移也能覆盖更多段的拼接。

还要尝试整段折叠。设当前长度为 length,最短整周期为 period,若 period < length,当前串就是首个周期片段重复 length / period 次,可构造 重复次数[dp[i][i + period - 1]]。括号内使用周期片段已经求出的最短编码,才能支持嵌套压缩;整段折叠仍要与原文、分段方案比较,不能一发现重复就强制采用。

minPeriod 使用 KMP 前缀函数。prefix[t] 表示当前区间前 t + 1 个字符的最长相等真前后缀长度,真前后缀不能是整个字符串。已匹配长度为 matched 时,如果新字符不能接上,就退到 prefix[matched - 1],尝试之前匹配部分的更短相等前后缀;能够接上时,匹配长度加一。

令 border = prefix[length - 1],前后缀相等表示将串平移 length - border 后,重叠部分完全相同,所以得到最短周期候选 period = length - border。还必须满足 length % period == 0,才能由完整的周期块拼满整段;否则按无整周期处理,返回 length。

所有候选都只在严格更短时替换当前状态,因此不会为了括号形式而让结果变长;最后返回整个区间的编码。

解题步骤

  1. 创建二维编码表 dp,依次处理长度 1..n 的所有区间。
  2. 将 dp[i][j] 初始化为原子串,枚举 i <= split < j,尝试拼接左右最短编码。
  3. 为当前区间计算前缀函数,取得最短整周期 period。
  4. 若整段包含至少两个完整周期,构造重复形式,与当前最短编码比较。
  5. 返回 dp[0][n - 1]。

代码实现

class Solution {
    public String encode(String s) {
        int n = s.length();
        String[][] dp = new String[n][n];

        // 短区间先完成,分段和周期内部编码都能直接复用。
        for (int length = 1; length <= n; length++) {
            for (int i = 0; i + length <= n; i++) {
                int j = i + length - 1;

                dp[i][j] = s.substring(i, j + 1);

                for (int split = i; split < j; split++) {
                    String candidate = dp[i][split] + dp[split + 1][j];

                    if (candidate.length() < dp[i][j].length()) {
                        dp[i][j] = candidate;
                    }
                }

                int period = minPeriod(s, i, length);

                if (period < length) {
                    // 重复单元使用已经算出的最短编码,允许内部继续压缩。
                    String candidate = (length / period) + "[" + dp[i][i + period - 1] + "]";

                    if (candidate.length() < dp[i][j].length()) {
                        dp[i][j] = candidate;
                    }
                }
            }
        }

        return dp[0][n - 1];
    }

    private int minPeriod(String s, int start, int length) {
        int[] prefix = new int[length];

        for (int i = 1; i < length; i++) {
            int matched = prefix[i - 1];

            while (matched > 0 && s.charAt(start + i) != s.charAt(start + matched)) {
                matched = prefix[matched - 1];
            }

            if (s.charAt(start + i) == s.charAt(start + matched)) {
                matched++;
            }

            prefix[i] = matched;
        }

        // 最长相等前后缀给出周期候选,还需检查是否整除区间长度。
        int period = length - prefix[length - 1];

        return length % period == 0 ? period : length;
    }
}
import "fmt"

func encode(s string) string {
    n := len(s)
    dp := make([][]string, n)
    for i := range dp {
        dp[i] = make([]string, n)
    }

    // 短区间先完成,分段和周期内部编码都能直接复用。
    for length := 1; length <= n; length++ {
        for i := 0; i+length <= n; i++ {
            j := i + length - 1
            dp[i][j] = s[i : j+1]

            for split := i; split < j; split++ {
                candidate := dp[i][split] + dp[split+1][j]
                if len(candidate) < len(dp[i][j]) {
                    dp[i][j] = candidate
                }
            }

            period := minPeriod(s, i, length)
            if period < length {
                // 重复单元使用已经算出的最短编码,允许内部继续压缩。
                candidate := fmt.Sprintf("%d[%s]", length/period, dp[i][i+period-1])
                if len(candidate) < len(dp[i][j]) {
                    dp[i][j] = candidate
                }
            }
        }
    }
    return dp[0][n-1]
}

func minPeriod(s string, start int, length int) int {
    prefix := make([]int, length)
    for i := 1; i < length; i++ {
        matched := prefix[i-1]
        for matched > 0 && s[start+i] != s[start+matched] {
            matched = prefix[matched-1]
        }
        if s[start+i] == s[start+matched] {
            matched++
        }
        prefix[i] = matched
    }

    // 最长相等前后缀给出周期候选,还需检查是否整除区间长度。
    period := length - prefix[length-1]
    if length%period == 0 {
        return period
    }
    return length
}

复杂度分析

  • 时间复杂度:$O(n^4)$ 上界。共有 $O(n^2)$ 个区间,每个区间枚举 $O(n)$ 个分割点,每次拼接最多复制 $O(n)$ 个字符;所有区间的前缀函数计算另需 $O(n^3)$。
  • 空间复杂度:$O(n^3)$ 存储上界。二维表有 $O(n^2)$ 个编码,每个长度至多为原区间长度 $O(n)$;单次前缀函数还需 $O(n)$ 临时空间。

关键点总结

[!green]

  • 状态保存编码字符串,比较其长度决定是否更新。
  • 周期候选必须整除区间长度。
  • 先算短区间,才能在拼接与折叠时复用已完成的最优编码。

易错点总结

[!yellow]

  • 只看相等前后缀就判定重复:长度不能整除时,不构成完整周期。
  • 括号内总放原始片段:错过片段内部的进一步压缩。
  • 只比较重复形式,不枚举分割点:不能处理由不同片段组成的更短编码。
  • 一旦有重复就强制编码:括号和次数本身也占长度。

相似题目

题目 难度 关联与区别
459. 重复的子字符串 简单 判断子串是否由某周期重复组成,是本题选择k[pattern]编码的前置条件。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2025/26323550
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!