|
| 1 | +### 题目描述 |
| 2 | + |
| 3 | +这是 LeetCode 上的 **[686. 重复叠加字符串匹配](https://leetcode-cn.com/problems/repeated-string-match/solution/gong-shui-san-xie-yi-ti-san-jie-qia-chan-3hbr/)** ,难度为 **中等**。 |
| 4 | + |
| 5 | +Tag : 「字符串哈希」、「KMP」 |
| 6 | + |
| 7 | + |
| 8 | + |
| 9 | +给定两个字符串 `a` 和 `b`,寻找重复叠加字符串 `a` 的最小次数,使得字符串 `b` 成为叠加后的字符串 `a` 的子串,如果不存在则返回 `-1`。 |
| 10 | + |
| 11 | +注意:字符串 `"abc"` 重复叠加 `0` 次是 `""`,重复叠加 `1` 次是 `"abc"`,重复叠加 `2` 次是 `"abcabc"`。 |
| 12 | + |
| 13 | +示例 1: |
| 14 | +``` |
| 15 | +输入:a = "abcd", b = "cdabcdab" |
| 16 | +
|
| 17 | +输出:3 |
| 18 | +
|
| 19 | +解释:a 重复叠加三遍后为 "abcdabcdabcd", 此时 b 是其子串。 |
| 20 | +``` |
| 21 | +示例 2: |
| 22 | +``` |
| 23 | +输入:a = "a", b = "aa" |
| 24 | +
|
| 25 | +输出:2 |
| 26 | +``` |
| 27 | +示例 3: |
| 28 | +``` |
| 29 | +输入:a = "a", b = "a" |
| 30 | +
|
| 31 | +输出:1 |
| 32 | +``` |
| 33 | +示例 4: |
| 34 | +``` |
| 35 | +输入:a = "abc", b = "wxyz" |
| 36 | +
|
| 37 | +输出:-1 |
| 38 | +``` |
| 39 | + |
| 40 | +提示: |
| 41 | +* $1 <= a.length <= 10^4$ |
| 42 | +* $1 <= b.length <= 10^4$ |
| 43 | +* `a` 和 `b` 由小写英文字母组成 |
| 44 | + |
| 45 | +--- |
| 46 | + |
| 47 | +### 基本分析 |
| 48 | + |
| 49 | +首先,可以分析复制次数的「下界」和「上界」为何值: |
| 50 | + |
| 51 | +**对于「下界」的分析是容易的:至少将 `a` 复制长度大于等于 `b` 的长度,才有可能匹配。** |
| 52 | + |
| 53 | +在明确了「下界」后,再分析再经过多少次复制,能够明确得到答案,能够得到明确答案的最小复制次数即是上界。 |
| 54 | + |
| 55 | +**由于主串是由 `a` 复制多次而来,并且是从主串中找到子串 `b`,因此可以明确子串的起始位置,不会超过 `a` 的长度。** |
| 56 | + |
| 57 | + |
| 58 | + |
| 59 | +即**长度越过 `a` 长度的起始匹配位置,必然在此前已经被匹配过了。** |
| 60 | + |
| 61 | +由此,我们可知复制次数「上界」最多为「下界 + $1$」。 |
| 62 | + |
| 63 | +令 `a` 的长度为 $n$,`b` 的长度为 $m$,下界次数为 $c1$,上界次数为 $c2 = c1 + 1$。 |
| 64 | + |
| 65 | +因此我们可以对 `a` 复制 $c2$ 次,得到主串后匹配 `b`,如果匹配成功后的结束位置不超过了 $n * c1$,说明复制 $c1$ 即可,返回 $c1$,超过则返回 $c2$;匹配不成功则返回 $-1$。 |
| 66 | + |
| 67 | +--- |
| 68 | + |
| 69 | +### 卡常 |
| 70 | + |
| 71 | +这是我最开始的 AC 版本。 |
| 72 | + |
| 73 | +虽然这是道挺显然的子串匹配问题,但是昨晚比平时晚睡了一个多小时,早上起来精神状态不是很好,身体的每个细胞都在拒绝写 KMP 🤣 |
| 74 | + |
| 75 | +就动了歪脑筋写了个「卡常」做法。 |
| 76 | + |
| 77 | +通过该做法再次印证了 LC 的评测机制十分奇葩:居然不是对每个用例单独计时,也不是算总的用例用时,而是既算单用例耗时,又算总用时?? |
| 78 | + |
| 79 | +导致我直接 `TLE` 了 $6$ 次才通过(从 $700$ 试到了 $100$),其中有 $4$ 次 `TLE` 是显示通过了所有样例,但仍然 `TLE`,我不理解为什么要设置这样迷惑的机制。 |
| 80 | + |
| 81 | +回到该做法本身,首先对 `a` 进行复制确保长度大于等于 `b`,然后在一定时间内,不断的「复制 - 检查」,如果在规定时间内能够找到则返回复制次数,否则返回 `-1`。 |
| 82 | + |
| 83 | +代码: |
| 84 | +```Java |
| 85 | +import java.time.Clock; |
| 86 | +class Solution { |
| 87 | + public int repeatedStringMatch(String a, String b) { |
| 88 | + StringBuilder sb = new StringBuilder(); |
| 89 | + int ans = 0; |
| 90 | + while (sb.length() < b.length() && ++ans > 0) sb.append(a); |
| 91 | + Clock clock = Clock.systemDefaultZone(); |
| 92 | + long start = clock.millis(); |
| 93 | + while (clock.millis() - start < 100) { |
| 94 | + if (sb.indexOf(b) != -1) return ans; |
| 95 | + sb.append(a); |
| 96 | + ans++; |
| 97 | + } |
| 98 | + return -1; |
| 99 | + } |
| 100 | +} |
| 101 | +``` |
| 102 | +* 时间复杂度:$O(C)$ |
| 103 | +* 空间复杂度:$O(C)$ |
| 104 | + |
| 105 | +--- |
| 106 | + |
| 107 | +### 上下界性质 |
| 108 | + |
| 109 | +通过「基本分析」后,我们发现「上下界」具有准确的大小关系,其实不需要用到「卡常」做法。 |
| 110 | + |
| 111 | +只需要进行「上界」次复制后,尝试匹配,根据匹配结果返回答案即可。 |
| 112 | + |
| 113 | +代码: |
| 114 | +```Java |
| 115 | +class Solution { |
| 116 | + public int repeatedStringMatch(String a, String b) { |
| 117 | + StringBuilder sb = new StringBuilder(); |
| 118 | + int ans = 0; |
| 119 | + while (sb.length() < b.length() && ++ans > 0) sb.append(a); |
| 120 | + sb.append(a); |
| 121 | + int idx = sb.indexOf(b); |
| 122 | + if (idx == -1) return -1; |
| 123 | + return idx + b.length() > a.length() * ans ? ans + 1 : ans; |
| 124 | + } |
| 125 | +} |
| 126 | +``` |
| 127 | +* 时间复杂度:需要 $\left \lceil \frac{m}{n} \right \rceil + 1$ 次拷贝 和 一次子串匹配。复杂度为 $O(n * (\left \lceil \frac{m}{n} \right \rceil + 1))$ |
| 128 | +* 空间复杂度:$O(n * (\left \lceil \frac{m}{n} \right \rceil + 1))$ |
| 129 | + |
| 130 | +--- |
| 131 | + |
| 132 | +### KMP |
| 133 | + |
| 134 | +其中 `indexOf` 部分可以通过 KMP/字符串哈希 实现,不熟悉 KMP 的同学,可以查看 [一文详解 KMP 算法](https://mp.weixin.qq.com/s?__biz=MzU4NDE3MTEyMA==&mid=2247486317&idx=1&sn=9c2ff2fa5db427133cce9c875064e7a4&chksm=fd9ca072caeb29642bf1f5c151e4d5aaff4dc10ba408b23222ea1672cfc41204a584fede5c05&token=1782709324&lang=zh_CN#rd),里面通过大量配图讲解了 KMP 的匹配过程与提供了实用模板。 |
| 135 | + |
| 136 | +使用 KMP 代替 `indexOf` 可以有效利用主串是由多个 `a` 复制而来的性质。 |
| 137 | + |
| 138 | +代码: |
| 139 | +```Java |
| 140 | +class Solution { |
| 141 | + public int repeatedStringMatch(String a, String b) { |
| 142 | + StringBuilder sb = new StringBuilder(); |
| 143 | + int ans = 0; |
| 144 | + while (sb.length() < b.length() && ++ans > 0) sb.append(a); |
| 145 | + sb.append(a); |
| 146 | + int idx = strStr(sb.toString(), b); |
| 147 | + if (idx == -1) return -1; |
| 148 | + return idx + b.length() > a.length() * ans ? ans + 1 : ans; |
| 149 | + } |
| 150 | + |
| 151 | + int strStr(String ss, String pp) { |
| 152 | + if (pp.isEmpty()) return 0; |
| 153 | + |
| 154 | + // 分别读取原串和匹配串的长度 |
| 155 | + int n = ss.length(), m = pp.length(); |
| 156 | + // 原串和匹配串前面都加空格,使其下标从 1 开始 |
| 157 | + ss = " " + ss; |
| 158 | + pp = " " + pp; |
| 159 | + |
| 160 | + char[] s = ss.toCharArray(); |
| 161 | + char[] p = pp.toCharArray(); |
| 162 | + |
| 163 | + // 构建 next 数组,数组长度为匹配串的长度(next 数组是和匹配串相关的) |
| 164 | + int[] next = new int[m + 1]; |
| 165 | + // 构造过程 i = 2,j = 0 开始,i 小于等于匹配串长度 【构造 i 从 2 开始】 |
| 166 | + for (int i = 2, j = 0; i <= m; i++) { |
| 167 | + // 匹配不成功的话,j = next(j) |
| 168 | + while (j > 0 && p[i] != p[j + 1]) j = next[j]; |
| 169 | + // 匹配成功的话,先让 j++ |
| 170 | + if (p[i] == p[j + 1]) j++; |
| 171 | + // 更新 next[i],结束本次循环,i++ |
| 172 | + next[i] = j; |
| 173 | + } |
| 174 | + |
| 175 | + // 匹配过程,i = 1,j = 0 开始,i 小于等于原串长度 【匹配 i 从 1 开始】 |
| 176 | + for (int i = 1, j = 0; i <= n; i++) { |
| 177 | + // 匹配不成功 j = next(j) |
| 178 | + while (j > 0 && s[i] != p[j + 1]) j = next[j]; |
| 179 | + // 匹配成功的话,先让 j++,结束本次循环后 i++ |
| 180 | + if (s[i] == p[j + 1]) j++; |
| 181 | + // 整一段匹配成功,直接返回下标 |
| 182 | + if (j == m) return i - m; |
| 183 | + } |
| 184 | + return -1; |
| 185 | + } |
| 186 | +} |
| 187 | +``` |
| 188 | +* 时间复杂度:需要 $\left \lceil \frac{m}{n} \right \rceil + 1$ 次拷贝 和 一次子串匹配。复杂度为 $O(n * (\left \lceil \frac{m}{n} \right \rceil + 1))$ |
| 189 | +* 空间复杂度:$O(n * (\left \lceil \frac{m}{n} \right \rceil + 1))$ |
| 190 | + |
| 191 | +--- |
| 192 | + |
| 193 | +### 字符串哈希 |
| 194 | + |
| 195 | +结合「基本分析」,我们知道这本质是一个子串匹配问题,我们可以使用「字符串哈希」来解决。 |
| 196 | + |
| 197 | +令 `a` 的长度为 $n$,`b` 的长度为 $m$。 |
| 198 | + |
| 199 | +仍然是先将 `a` 复制「上界」次,得到主串 `ss`,目的是从 `ss` 中检测是否存在子串为 `b`。 |
| 200 | + |
| 201 | +在字符串哈希中,为了方便,我们将 `ss` 和 `b` 进行拼接,设拼接后长度为 $len$,那么 `b` 串的哈希值为 $[len - m + 1, len]$ 部分(下标从 $1$ 开始),记为 $target$。 |
| 202 | + |
| 203 | +然后在 $[1, n]$ 范围内枚举起点,尝试找长度为 $m$ 的哈希值与 $target$ 相同的哈希值。 |
| 204 | + |
| 205 | +代码: |
| 206 | +```Java |
| 207 | +class Solution { |
| 208 | + public int repeatedStringMatch(String a, String b) { |
| 209 | + StringBuilder sb = new StringBuilder(); |
| 210 | + int ans = 0; |
| 211 | + while (sb.length() < b.length() && ++ans > 0) sb.append(a); |
| 212 | + sb.append(a); |
| 213 | + int idx = strHash(sb.toString(), b); |
| 214 | + if (idx == -1) return -1; |
| 215 | + return idx + b.length() > a.length() * ans ? ans + 1 : ans; |
| 216 | + } |
| 217 | + int strHash(String ss, String b) { |
| 218 | + int P = 131; |
| 219 | + int n = ss.length(), m = b.length(); |
| 220 | + String str = ss + b; |
| 221 | + int len = str.length(); |
| 222 | + int[] h = new int[len + 10], p = new int[len + 10]; |
| 223 | + h[0] = 0; p[0] = 1; |
| 224 | + for (int i = 0; i < len; i++) { |
| 225 | + p[i + 1] = p[i] * P; |
| 226 | + h[i + 1] = h[i] * P + str.charAt(i); |
| 227 | + } |
| 228 | + int r = len, l = r - m + 1; |
| 229 | + int target = h[r] - h[l - 1] * p[r - l + 1]; // b 的哈希值 |
| 230 | + for (int i = 1; i <= n; i++) { |
| 231 | + int j = i + m - 1; |
| 232 | + int cur = h[j] - h[i - 1] * p[j - i + 1]; // 子串哈希值 |
| 233 | + if (cur == target) return i - 1; |
| 234 | + } |
| 235 | + return -1; |
| 236 | + } |
| 237 | +} |
| 238 | +``` |
| 239 | +* 时间复杂度:需要 $\left \lceil \frac{m}{n} \right \rceil + 1$ 次拷贝 和 一次子串匹配。复杂度为 $O(n * (\left \lceil \frac{m}{n} \right \rceil + 1))$ |
| 240 | +* 空间复杂度:$O(n * (\left \lceil \frac{m}{n} \right \rceil + 1))$ |
| 241 | + |
| 242 | +--- |
| 243 | + |
| 244 | +### 最后 |
| 245 | + |
| 246 | +这是我们「刷穿 LeetCode」系列文章的第 `No.686` 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先把所有不带锁的题目刷完。 |
| 247 | + |
| 248 | +在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。 |
| 249 | + |
| 250 | +为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:https://github.com/SharingSource/LogicStack-LeetCode。 |
| 251 | + |
| 252 | +在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。 |
| 253 | + |
0 commit comments