搞竞赛的人应该都有体会,字符串相关的题看着不难,但写起来总容易在性能、边界条件和底层机制上翻车。尤其是Java选手,String这个类型你天天用,但换到竞赛和面试场景里,很多模板和细节如果不整理成自己的东西,临场很容易卡壳。这篇内容就是把我自己刷题和带学弟学妹竞赛时沉淀下来的字符串操作模板、底层原理和踩坑记录整理成文,覆盖从入门基础到进阶算法,希望对正在搞Java竞赛或者准备Java后端面试的朋友有实际帮助。
1. Java字符串底细:String不可变性到底影响什么
1.1 不可变性不只是"不能修改",它是线程安全与性能的基石
很多初学者对String的第一印象是"字符串",但真正要理解Java里的String,首先要接受它的不可变性(Immutable)。为什么要设计成不可变?核心原因有三个:字符串常量池复用、线程安全、以及作为HashMap键时的哈希缓存可靠性。
你每次执行String s = "abc",JVM会先去常量池里找有没有"abc",如果没有就创建一份并放入常量池,如果已经存在就直接返回引用。这个过程省下了大量重复对象创建的内存。而如果String可变,常量池里"abc"被改掉,其他所有引用它的地方都会跟着出问题,这种全局污染是任何系统都承受不起的。
哈希缓存也是同理:String的hashCode()只用计算一次,因为内容永远不会变,之后每次调用都直接返回缓存值。这就是为什么HashMap、HashSet里String当Key那么高效的原因之一。反过来想,如果String可变,哈希值变了但Key在HashMap里的桶位置没变,整个查找逻辑就崩了。
在实际竞赛题目中,不可变性带来的直接体验就是:你不需要考虑字符串在多线程环境下被并发修改的问题,这在写多线程模拟或者并行计算类题目时是个很大的定心丸。
1.2 常量池与new String的坑:面试高频考点
这个点几乎是Java面试八股文的必考题。看下面这段代码:
java复制String s1 = "abc";
String s2 = "abc";
String s3 = new String("abc");
System.out.println(s1 == s2); // true
System.out.println(s1 == s3); // false
==比较的是引用地址。s1和s2都指向常量池里同一个"abc"对象,所以相等。s3是通过new在堆上创建的,和常量池对象不是同一个引用,所以s1 == s3是false。
但要注意:new String("abc")实际上会在堆上创建对象,同时还可能复用常量池里的字符数组作为底层数据。Java 7之后,String对象的value数组在通过new String(字面量)创建时,可能和常量池中的字符串共享同一个char数组。这里不深入展开源码,你只需要记住结论:日常写代码、刷题,比较字符串内容一律用equals(),不要用==。
这个坑在竞赛题里经常表现为:你从输入流读了一批字符串,又手动构造了一些字符串用来匹配,结果用==一比,怎么都不对,白排查半天。
1.3 Java 9之后String底层的变化:char[]变成byte[]
Java 9做了一个非常关键的底层改动:String的存储从char[]换成了byte[],同时增加了一个coder字段来标记编码方式是LATIN1(单字节)还是UTF16(双字节)。这个改动使得纯英文字符串的内存占用直接减半,因为不需要为每个ASCII字符分配两个字节。
java复制// JDK 8及之前
private final char value[];
// JDK 9+
private final byte[] value;
private final byte coder;
这个改动对竞赛党来说有一个间接影响:s.charAt(i)、s.substring()这类操作在底层换成了字节数组操作,对于全ASCII字符串的场景(竞赛题大多如此)性能反而更好了。但你在写代码时感知不到这些,API层面完全兼容。不过如果你在做代码优化时发现某些字符串操作比预期快很多,很可能就是coder优化起的作用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 竞赛场景下的字符串高频操作模板
2.1 输入读取:Scanner和BufferedReader怎么选
竞赛里读字符串最常见的两种方式:Scanner和BufferedReader。很多新手只知道Scanner,因为它用起来方便。但Scanner在大量字符串输入时性能很差,因为它的底层做了很多正则匹配和字符流解析。
我实测过一组数据:用Scanner读十万行字符串,大约耗时六七百毫秒;用BufferedReader加StringTokenizer,只有一百多毫秒。比赛里如果输入量巨大,这差距就是超时和通过的区别。
java复制// 快读模板
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
String line;
while ((line = reader.readLine()) != null) {
// 处理每行
}
如果你需要按空格或逗号分词,用StringTokenizer或直接split()都可以。StringTokenizer是老API,但性能比split()好,因为后者底层走正则。不过字符串数量不大时差距可以忽略,看你个人习惯。
我自己习惯的组合是:BufferedReader读整行,split(" ")做分词,最多再配合Integer.parseInt()做数字转换。这套组合在绝大多数Java竞赛题里够用且不容易写错。
2.2 遍历与字符数组互转:操作String的基本功
字符串题目里最基础也最频繁的操作就是遍历、取子串、转字符数组。不同场景用不同方式,我列几个实用模板:
java复制// 1. 获取单个字符,用charAt,避免转char[]带来的额外开销
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
}
// 2. 如果需要频繁修改字符,转char[]更高效
char[] arr = s.toCharArray();
// 修改arr...
String result = new String(arr);
// 3. 按字符遍历
for (char c : s.toCharArray()) {
// 处理c
}
第一和第三种遍历方式看起来差别不大,但注意第三种每次循环调用toCharArray()会创建一个新数组。如果你在一个大循环里反复调用,会造成大量垃圾对象。正确做法是只在需要的时候转一次,或者干脆用charAt()。
关于substring(),在Java 7及以后版本,它返回的是新创建的字符串对象,底层会复制一份字符数据。所以你不需要担心它持有原字符串的引用导致内存泄漏(Java 6及以前有这个问题,后面章节再详细说)。但在竞赛场景,频繁调用substring()会产生大量中间字符串,可能影响GC。如果只是判断某个区间的字符,尽量用双指针配合charAt()定位,少用substring()。
2.3 StringBuilder拼串的正确姿势
字符串拼接是字符串操作里最常见的动作,也是性能差异最容易体现的地方。用+拼接字符串时,编译器会优化成StringBuilder操作,但那是局部的优化。
java复制String result = "";
for (int i = 0; i < 10000; i++) {
result += "a"; // 每次循环都会创建一个StringBuilder,性能极差
}
这段代码其实每次循环都会生成一个新的StringBuilder对象、追加内容、再调用toString(),相当于循环内创建了上万个中间对象。正确的写法是显式使用StringBuilder:
java复制StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append('a');
}
String result = sb.toString();
在竞赛题中,还有一个很实用的技巧:StringBuilder可以预先设置容量,避免扩容拷贝的开销。
java复制// 预估长度约1000
StringBuilder sb = new StringBuilder(1000);
估算容量后,append操作不会频繁触发数组扩容,性能能提升不少。实测在拼接大量短字符串的场景下,设置合理容量能带来20%到30%的性能提升。
StringBuilder的常用API也要背熟:append()追加、insert()插入、deleteCharAt()删除指定位置字符、reverse()反转、toString()转字符串。竞赛中翻转字符串、构造回文、组装结果集都用得上。
2.4 split、indexOf与正则的取舍
split()接收正则表达式,这是一个很多人容易忽略的点。比如你想按点号分割IP地址"192.168.1.1",如果直接split("."),得到的数组是空的,因为.在正则里匹配任意字符。正确写法是split("\\.")。
这在题目里经常出现,尤其是处理带分隔符的输入数据时。我建议所有用split()的地方都先确认分隔符是否需要转义。常见需要转义的字符包括:点号.、竖线|、星号*、加号+、反斜杠\\、美元符号$等。
indexOf()和lastIndexOf()用于查找子串位置,它们是纯字符扫描实现,不走正则,性能不错。contains()底层也是调用indexOf(),所以在判断一个字符串是否包含另一个子串时,直接用contains()即可,代码更清晰。
但如果要做复杂的模式匹配,比如验证邮箱格式、手机号、提取特定模式的子串,老老实实用Pattern和Matcher:
java复制Pattern pattern = Pattern.compile("\\d+");
Matcher matcher = pattern.matcher(s);
while (matcher.find()) {
String num = matcher.group();
// 处理数字
}
注意Pattern.compile()本身开销不小,如果只需要匹配一次,直接使用String.matches(regex)也行,但它在循环中反复调用时会把Pattern编译消耗累计放大。正确做法是把Pattern对象提到循环外,只编译一次。
3. 竞赛必背的字符串匹配核心模板
3.1 字符串哈希:用O(1)搞定子串比较
字符串哈希,也叫滚动哈希,是竞赛里非常实用的一种技巧。核心思想是把字符串映射成一个整数,两个字符串相等时,哈希值相等(可能有极小概率冲突,后文讲)。
先看最常用的进制哈希模板:
java复制public class StringHash {
private static final long BASE = 131L;
private static final long MOD = 1_000_000_007L;
private long[] hash;
private long[] pow;
public StringHash(String s) {
int n = s.length();
hash = new long[n + 1];
pow = new long[n + 1];
pow[0] = 1;
for (int i = 0; i < n; i++) {
hash[i + 1] = (hash[i] * BASE + s.charAt(i)) % MOD;
pow[i + 1] = (pow[i] * BASE) % MOD;
}
}
// 获取区间[l, r)的哈希值
public long getHash(int l, int r) {
return (hash[r] - hash[l] * pow[r - l] % MOD + MOD) % MOD;
}
}
这个模板的核心是把字符串当成一个BASE进制的数,131是我常用的进制,也可以选131313或者31。MOD选一个大的质数,比如1_000_000_007,减少冲突。
为什么getHash要这样写?简单解释一下:hash[i]存储的是前i个字符的哈希值,相当于一个前缀哈希。要计算[l, r)区间的哈希,需要把hash[l]部分对齐到与hash[r]相同的位置关系,所以乘以pow[r-l]后再相减。
使用场景非常广:
- 判断两个子串是否相等,O(1)
- 在字符串中找最长回文子串(配合二分)
- 判断字符串A是否为字符串B的子串(对比哈希值)
- 计数不同子串的个数
实际题目里,如果题目数据量大且对时间要求高,优先考虑用字符串哈希替代substring()加equals()的O(n)方案。
3.2 KMP模板:主串中高效匹配模式串
很多人背KMP容易把next数组的语义搞混。我这里给出一个我习惯的实现,next[i]表示模式串中前i个字符的后缀与最长相等前缀的长度,KMP匹配时失配就跳到前一个等长前后缀的位置。
java复制public static int[] buildNext(String pattern) {
int n = pattern.length();
int[] next = new int[n];
for (int i = 1, j = 0; i < n; i++) {
while (j > 0 && pattern.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
if (pattern.charAt(i) == pattern.charAt(j)) {
j++;
}
next[i] = j;
}
return next;
}
public static List<Integer> kmpSearch(String text, String pattern) {
List<Integer> res = new ArrayList<>();
if (pattern.length() == 0) {
return res;
}
int[] next = buildNext(pattern);
int m = pattern.length();
for (int i = 0, j = 0; i < text.length(); i++) {
while (j > 0 && text.charAt(i) != pattern.charAt(j)) {
j = next[j - 1];
}
if (text.charAt(i) == pattern.charAt(j)) {
j++;
}
if (j == m) {
res.add(i - m + 1);
j = next[j - 1];
}
}
return res;
}
这个模板返回所有匹配的起始下标。注意匹配完一次之后,j不能清零,而是跳到next[j-1],这样才能找到重叠匹配。
KMP的时间复杂度是O(n+m),比暴力匹配的O(n*m)好太多。如果题目只是判断是否存在子串,indexOf足够用;但如果题目要求统计出现次数、或者需要自定义匹配规则,KMP就派上用场了。竞赛中KMP常和动态规划结合,比如"字符串匹配DP"的题目。
3.3 Trie树:字符串统计与前缀查询利器
Trie树(字典树)特别适合处理跟前缀相关的题目:统计某个前缀出现的次数、判断单词是否存在、求所有单词里最长公共前缀等。我用的模板如下:
java复制class TrieNode {
TrieNode[] children = new TrieNode[26];
int count; // 记录经过该节点的单词数
}
class Trie {
private TrieNode root = new TrieNode();
public void insert(String word) {
TrieNode node = root;
for (char c : word.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null) {
node.children[idx] = new TrieNode();
}
node = node.children[idx];
node.count++;
}
}
public int queryPrefix(String prefix) {
TrieNode node = root;
for (char c : prefix.toCharArray()) {
int idx = c - 'a';
if (node.children[idx] == null) {
return 0;
}
node = node.children[idx];
}
return node.count;
}
}
如果字符集不只26个字母,比如包含大写字母或数字,可以把children改成HashMap<Character, TrieNode>,但性能会稍差。在处理纯小写英文字符串的竞赛题中,数组版的Trie是最快的。
Trie树的空间开销比较大,每个节点都要维护一个长度为26的引用数组。如果题目限制内存很严,可以考虑用ArrayList加Map的变体,或者直接用字符串排序加二分查找替代一大部分Trie功能。
4. 进阶字符串算法模板:回文与排序
4.1 Manacher算法:O(n)求最长回文子串
回文类题目在竞赛中出现频率很高。暴力做法是枚举每个中心向两边扩展,时间复杂度O(n^2)。Manacher算法在预处理后能在线性时间内求出所有回文半径。
java复制public static int manacher(String s) {
// 预处理,用'#'把每个字符隔开,统一处理奇数长度和偶数长度回文
StringBuilder sb = new StringBuilder("^#");
for (char c : s.toCharArray()) {
sb.append(c).append('#');
}
sb.append('$');
char[] t = sb.toString().toCharArray();
int n = t.length;
int[] p = new int[n];
int center = 0, right = 0;
for (int i = 1; i < n - 1; i++) {
p[i] = i < right ? Math.min(p[2 * center - i], right - i) : 1;
while (t[i - p[i]] == t[i + p[i]]) {
p[i]++;
}
if (i + p[i] > right) {
right = i + p[i];
center = i;
}
}
int maxLen = 0;
for (int i = 1; i < n - 1; i++) {
maxLen = Math.max(maxLen, p[i] - 1);
}
return maxLen;
}
这里预处理插入#,使得无论原串长度是奇是偶,处理后的串都是奇数长度。开头的^和结尾的$作为哨兵,可以避免边界判断。每个位置的回文半径存在p[i]里,p[i]-1就是原串中以该位置为中心的最长回文长度。
如果题目还要求输出最长回文子串本身,需要在更新maxLen时记录中心下标和半径,最后截取原串。Manacher的难点不在代码,而在理解right和center维护的含义,第一次刷的时候可以自己画图走一遍流程。
4.2 字符串排序与自定义比较器
字符串排序在竞赛里也是高频操作,包括按字典序、按长度、按照某种自定义规则排序。Java里Arrays.sort和Collections.sort都支持自定义比较器。
java复制String[] words = {"apple", "banana", "cherry"};
// 按字典序
Arrays.sort(words);
// 按长度升序
Arrays.sort(words, (a, b) -> a.length() - b.length());
// 先按长度,再按字典序
Arrays.sort(words, (a, b) -> {
if (a.length() != b.length()) {
return a.length() - b.length();
}
return a.compareTo(b);
});
compareTo是字典序比较,返回负数表示当前字符串排在前面。注意:如果直接用a.length() - b.length(),当两个字符串长度差异很大时可能溢出,但在竞赛场景里字符串长度一般不会超过Integer.MAX_VALUE的差异,所以问题不大。如果你追求严谨,可以写成Integer.compare(a.length(), b.length())。
字符串排序一个隐藏性能点:排序过程中比较字符串会调用charAt()逐个字符比较,如果字符串很长且数量很多,排序会很慢。此时可以考虑先计算字符串哈希再比较,或者使用后缀数组等更进阶的结构。当然大部分字符串排序题数据量不会大到那个程度。
4.3 最小字典序拼接:贪心思想的应用
有一类经典题目:给定多个字符串,把它们拼接成一个字符串,要求字典序最小。这种题不能简单按字典序排序,因为"b"和"ba",如果按普通字典序,结果是"bba",但正确的最小字典序是"bab"。
核心比较器设计成拼接后比较:
java复制Arrays.sort(arr, (a, b) -> (a + b).compareTo(b + a));
让我解释一下为什么这样有效:如果a+b的字典序小于b+a,那么排序时a应该排在b前面。这个思路本质上是对相邻两个字符串的相对顺序做一个局部最优判断,这种比较方式满足传递性,所以贪心排序后得到的全局结果就是字典序最小的拼接。这个模板在面试题中也很常见,值得记下来。
5. 字符串与集合联动:竞赛高频考题场景
5.1 字符频率统计与异位词分组
统计字符串中每个字符出现的次数是处理很多题目类型的基础,尤其是判断两个字符串是否为字母异位词、统计每个字符的奇偶次数等。模板很固定:
java复制String s = "hello world";
int[] freq = new int[26]; // 只针对小写字母
for (char c : s.toCharArray()) {
if (c >= 'a' && c <= 'z') {
freq[c - 'a']++;
}
}
如果字符串包含Unicode字符,用Map<Character, Integer>更通用,但性能会差不少。竞赛里绝大部分字符串题目都是ASCII或纯小写字母,所以数组法是首选。
异位词分组是LeetCode高频题,也是面试常考:
java复制public List<List<String>> groupAnagrams(String[] strs) {
Map<String, List<String>> map = new HashMap<>();
for (String s : strs) {
char[] arr = s.toCharArray();
Arrays.sort(arr);
String key = new String(arr);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(s);
}
return new ArrayList<>(map.values());
}
这个解法把每个单词排序后的结果作为分组键。时间复杂度O(n * k log k),其中k是单词最大长度。如果要优化性能,可以用计数数组生成key,把排序换成构建频率字符串。
5.2 HashMap中String作为Key的注意事项
String作为HashMap的Key非常常用,但有几点要注意:
第一,hashCode()的计算基于字符串内容,所以内容相同的字符串会有相同的哈希值,查找正常。但如果你在构造Key时用new String(...),只要equals()成立,就能正确命中,不用担心引用问题。
第二,大量字符串放入HashMap时,哈希冲突可能导致链表过长,影响查询性能。在Java 8及以后版本,当链表长度超过阈值8时,会转换为红黑树,查询复杂度从O(n)降到O(log n)。但在竞赛设置不严谨的冲突下,仍可能出现性能退化。如果感觉HashMap操作成为了瓶颈,可以考虑自己实现一个简单的字符串哈希映射表,使用更大的桶数量和更好的哈希函数。
第三,也是很多人踩过的坑:如果把StringBuilder当作Key,由于StringBuilder的equals()没有重写,比较的是引用而不是内容,所以用StringBuilder当Key会导致查找失效。正确做法是sb.toString()转成String再用。
5.3 字符串状态压缩与DP的配合
在竞赛题目中,字符串常常和状态压缩动态规划一起出现,比如计算一个字符串能否由另一个字符串的若干字符按顺序组成。此时一般结合DP状态转移,把两个字符串的下标作为状态。
举一个简单的DP模板:求两个字符串的最长公共子序列(LCS)。
java复制public static int lcs(String s1, String s2) {
int m = s1.length(), n = s2.length();
int[][] dp = new int[m + 1][n + 1];
for (int i = 1; i <= m; i++) {
for (int j = 1; j <= n; j++) {
if (s1.charAt(i - 1) == s2.charAt(j - 1)) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
return dp[m][n];
}
这类字符串DP题目里,charAt()的频繁调用可能影响性能,你可以先转成char[],这样访问速度更快,代码也不容易写错:
java复制char[] c1 = s1.toCharArray();
char[] c2 = s2.toCharArray();
// 之后直接用c1[i-1]访问
6. 面试八股里的字符串考点辨析
6.1 equals与==再辨析
这个知识点太经典了,几乎每场Java面试必问。==比较的是引用地址,equals()比较的是内容。但为什么有时候两个不同方式创建的字符串用==比较是相等的?就是因为常量池的复用机制。
java复制String a = "abc";
String b = "abc";
String c = new String("abc");
String d = c.intern();
System.out.println(a == b); // true,都在常量池
System.out.println(a == c); // false,堆对象
System.out.println(a == d); // true,intern返回常量池对象
intern()在Java 7及以后,如果常量池中没有该字符串,则会把堆中对应字符串的引用加入常量池并返回,所以d和a指向同一个对象。
6.2 StringBuilder和StringBuffer的线程安全差异
StringBuffer的方法用synchronized修饰,线程安全;StringBuilder的方法没有同步,线程不安全。对应到竞赛场景,不存在多线程竞争问题,所以一律使用StringBuilder,它的单线程性能更好。
从JDK源码看,StringBuffer的每个append方法都有synchronized关键字,这带来额外的锁竞争开销。即使没有其他线程参与竞争,JVM也要做锁的获取和释放操作,虽然在轻量级锁优化后开销很小,但在大量字符串拼接的场景下依然有差距。
6.3 正则表达式的性能陷阱
正则表达式是字符串处理的强大工具,但性能陷阱很多。一个常见的坑是"灾难性回溯":有些正则表达式在匹配失败时会进行大量回溯,导致CPU占用极高。
比如(a+)+b去匹配aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaac,就会非常慢。因为外层+和内层+的组合会导致指数级的可能性尝试。在竞赛里,如果数据里包含这种恶意构造的字符串,整个程序可能卡死。
解决办法是:尽量使用非贪婪匹配、避免嵌套的量词、使用Pattern.quote()把普通字符串转成正则字面量而不是手写转义。另外,如果需要扫描字符串并提取多个匹配,用Matcher.find()循环,而不是反复调用String.matches()。
6.4 编码问题:UTF-8与字符集相关的基础知识
很多竞赛题目虽然不直接涉及编码,但在读入中文或特殊字符时,编码设置会导致乱码。BufferedReader的默认字符集取决于操作系统,如果你需要统一使用UTF-8,可以显式指定:
java复制BufferedReader reader = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8));
String.getBytes()默认使用平台默认字符集,这在不同环境下的行为不一致。在需要确定行为时,显式传入StandardCharsets.UTF_8:
java复制byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
String restored = new String(bytes, StandardCharsets.UTF_8);
Java的字符串内部使用UTF-16编码,也就是说char是16位,一个中文汉字就是一个char,一个emoji可能是两个char。所以在处理包含emoji的字符串时,charAt()可能会返回半个字符,正确做法是用codePointAt()。
7. 实战避坑:我在字符串踩过的那些坑
7.1 substring的旧版本内存泄漏与新版本行为
这是一个在Java面试题中经常被提到的历史问题。Java 6及以前版本,substring()并不会复制字符数组,而是复用原字符串的value数组,只是偏移量和长度不同。这意味着如果你截取了一个大字符串的很小一小段,那个大字符串的整个字符数组都不会被回收,长期运行可能导致内存溢出。
Java 7及以后版本修复了这个问题,substring()会生成新的字符数组,不再持有原字符串的引用。所以在新版本里,你不需要担心这种内存泄漏。但反过来,substring()现在会复制数据,如果频繁调用并且只取很小的片段,会产生大量临时对象。在性能敏感的场景,尽量用charAt()或subSequence()等方式替代。
7.2 大量拼接字符串导致的性能超时
有一次我给选手讲题,他写的代码在本地小数据跑得很快,一到OJ大数据集就超时。排查后发现他用的都是+=拼字符串。我把那部分改成StringBuilder后,时间从超时直接降到几十毫秒。
记住一句话:循环内拼接字符串永远用StringBuilder。即使Java编译器会把+优化成StringBuilder,那也是针对单个语句级别的优化,循环级别的+=会反复创建和丢弃StringBuilder对象,成本极高。
java复制// 错误示范
String s = "";
for (int i = 0; i < n; i++) {
s += arr[i];
}
// 正确示范
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(arr[i]);
}
String s = sb.toString();
7.3 字符串哈希冲突:用双哈希规避
单哈希虽然快,但理论上有冲突风险。如果你的算法依赖哈希值完全相等的判断,而题目数据是精心构造的,很可能被卡到WA。对策是使用双哈希:
java复制public static final long MOD1 = 1_000_000_007L;
public static final long MOD2 = 1_000_000_009L;
public static final long BASE = 131L;
// 对两个模数分别生成hash数组,比较时同时相等才算相等
双哈希把冲突概率降到极低,代价是倍增的预处理和查询时间。在竞赛中,如果时间充足,建议使用双哈希;如果时间很紧,单哈希配合随机化进制也可能足够。
7.4 空指针和空字符串的边界处理
空指针是Java最常见的运行时异常之一。处理字符串时,null和空字符串""是两回事。s == null表示引用为空,对null调用任何方法都会抛NullPointerException;s.equals("")才是判断内容是否为空。
字符串题目里经常需要处理输入为空的情况。我习惯在处理输入后第一时间判断:
java复制if (s == null || s.isEmpty()) {
// 特殊情况处理
}
7.5 编译版本差异导致的字符串拼接优化差异
Java编译器对字符串拼接的优化策略在不同版本之间有差异。比如Java 8之前和之后对+的优化细节不同,Java 9引入了invokedynamic实现字符串拼接,比传统的StringBuilder更灵活,还支持StringConcatFactory做运行时优化。
这也是为什么在很多Java相关热词中会看到"源发行版17需要目标发行版17"这样的报错。当你在使用更高版本JDK编译依赖字符串拼接特性的代码时,如果项目编译级别设置不一致,会引发各种异常。在竞赛平台上,最好确认OJ使用的JDK版本和你本地一致,避免因为编译优化差异导致结果不一致。
写在最后:这个内容后续还能怎么扩展
字符串处理这块内容,完全不是看一遍就能精通的。我自己的经验是:每类模板先手写三遍,然后找一个对应的OJ题做实战,比如哈希找字符串匹配、KMP统计模式串出现次数、Trie树统计前缀。等你把模板练到不带思考就能默写出来的程度,才真正成为你的武器。
最后再分享一个小技巧:在竞赛代码里,尽量把所有字符串模板封装成独立的方法或者工具类,这样当场调试的时候心智负担会小很多。不要小看这件事,几套模板混在解题代码里,出了问题定位都费劲。做一个干净的StringUtil类,把字符串哈希、KMP、Trie、Manacher都放进去,平时练习时就反复使用,比赛时直接取用,能帮你省下大量时间。
