1. 概述

字符串处理是Java编程的基础任务之一,有时我们需要将字符串拆分成多个子串以便进一步处理。无论是解析用户输入还是处理数据文件,掌握高效的字符串拆分技巧都至关重要。

本教程将探讨多种方案,用于将输入字符串按原始顺序拆分成包含数字和非数字元素的字符串数组或列表。

2. 问题引入

还是老规矩,通过示例理解问题。假设有两个输入字符串:

String INPUT1 = "01Michael Jackson23Michael Jordan42Michael Bolton999Michael Johnson000";
String INPUT2 = "Michael Jackson01Michael Jordan23Michael Bolton42Michael Johnson999Great Michaels";

如示例所示,两个字符串都由连续的数字和非数字字符组成。例如INPUT1中的连续数字子串有"01"、"23"、"42"、"999"和"000";非数字子串有"Michael Jackson"、"Michael Jordan"、"Michael Bolton"等。INPUT2类似,但以非数字子串开头。由此可总结输入特征:

✅ 数字/非数字子串长度动态变化
✅ 输入字符串可能以数字或非数字子串开头

我们的目标是将输入拆分成如下数组或列表:

String[] EXPECTED1 = new String[] { "01", "Michael Jackson", "23", "Michael Jordan", "42", "Michael Bolton", "999", "Michael Johnson", "000" };
List<String> EXPECTED_LIST1 = Arrays.asList(EXPECTED1);

String[] EXPECTED2 = new String[] { "Michael Jackson", "01", "Michael Jordan", "23", "Michael Bolton", "42", "Michael Johnson", "999", "Great Michaels" };
List<String> EXPECTED_LIST2 = Arrays.asList(EXPECTED2);

本教程将同时使用正则和非正则方案解决问题,并在最后对比性能。为简化验证,我们将使用单元测试断言。

3. 使用String.split()方法

首先用正则方案解决问题。我们知道**String.split()是拆分字符串的利器**,例如"a, b, c, d".split(", ")返回{"a", "b", "c", "d"}

split()解决当前问题看似直接,但仔细想想会发现难点。回顾上述示例:使用", "作为分隔符后,所有匹配的分隔符都不会出现在结果数组中。但观察我们的输入输出,输入的每个字符都会出现在结果中。因此必须使用零宽断言(如环视[lookahead和lookbehind])作为分隔模式。

分析输入结构:

01[!]Michael Jackson[!]23[!]Michael Jordan[!]42[!]Michael Bolton...

(用[!]标记理想分隔符位置)可见分隔符出现在\d(数字)和\D(非数字)之间,或\D\d之间。转换为环视正则:(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)

验证该方案:

String splitRE = "(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)";
String[] result1 = INPUT1.split(splitRE);
assertArrayEquals(EXPECTED1, result1);

String[] result2 = INPUT2.split(splitRE);
assertArrayEquals(EXPECTED2, result2);

测试通过。接下来看非正则方案。

4. 非正则表达式方案

已通过正则split()解决问题,现在尝试无正则方案。核心思路是从头遍历字符串,检查每个字符类型:

enum State {
    INIT, PARSING_DIGIT, PARSING_NON_DIGIT
}

List<String> parseString(String input) {
    List<String> result = new ArrayList<>();
    int start = 0;
    State state = INIT;
    for (int i = 0; i < input.length(); i++) {
        if (input.charAt(i) >= '0' && input.charAt(i) <= '9') {
            if (state == PARSING_NON_DIGIT) { // 非数字转数字,截取子串
                result.add(input.substring(start, i));
                start = i;
            }
            state = PARSING_DIGIT;
        } else {
            if (state == PARSING_DIGIT) { // 数字转非数字,截取子串
                result.add(input.substring(start, i));
                start = i;
            }
            state = PARSING_NON_DIGIT;
        }
    }
    result.add(input.substring(start)); // 添加最后部分
    return result;
}

代码解析:

  1. 初始化空ArrayList存储结果
  2. start跟踪子串起始索引
  3. state枚举记录当前解析状态
  4. 遍历字符:
    • 当前为数字且状态为PARSING_NON_DIGIT时,截取子串并更新start和状态
    • 当前为非数字且状态为PARSING_DIGIT时,同理处理
  5. 循环结束后添加最后部分子串

测试验证:

List<String> result1 = parseString(INPUT1);
assertEquals(EXPECTED_LIST1, result1);

List<String> result2 = parseString(INPUT2);
assertEquals(EXPECTED_LIST2, result2);

测试通过,parseString()方法有效。

5. 性能对比

我们提供了两种方案:简洁的正则split()和手写的parseString()。后者代码量更大且需手动控制每个字符,为何还要用?答案是性能

虽然parseString()看似复杂,但比正则方案更快,原因如下:

⚠️ 正则方案需编译模式并执行匹配,计算开销大(尤其复杂模式)
parseString()用简单状态机跟踪转换,直接字符比较,避免正则开销
✅ 直接通过索引截取子串,避免split()的额外对象创建,优化内存使用

⚠️ 注意:当输入字符串较短时,性能差异可能不明显

使用JMH基准测试(10,000次迭代):

@State(Scope.Benchmark)
@Threads(1)
@BenchmarkMode(Mode.Throughput)
@Fork(warmups = 1, value = 1)
@Warmup(iterations = 2, time = 10, timeUnit = TimeUnit.MILLISECONDS)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class BenchmarkLiveTest {
    private static final String INPUT = "01Michael Jackson23Michael Jordan42Michael Bolton999Michael Johnson000";

    @Param({ "10000" })
    public int iterations;

    @Benchmark
    public void regexBased(Blackhole blackhole) {
        blackhole.consume(INPUT.split("(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)"));
    }

    @Benchmark
    public void nonRegexBased(Blackhole blackhole) {
        blackhole.consume(parseString(INPUT));
    }

    @Test
    public void benchmark() throws Exception {
        String[] argv = {};
        org.openjdk.jmh.Main.main(argv);
    }
}

测试结果(单位:操作数/毫秒):

Benchmark                        (iterations)   Mode  Cnt     Score     Error   Units
BenchmarkLiveTest.nonRegexBased         10000  thrpt    5  3880.989 ± 134.021  ops/ms
BenchmarkLiveTest.regexBased            10000  thrpt    5   297.282 ±  24.818  ops/ms

非正则方案吞吐量是正则方案的13倍以上(3880/297≈13.06)。因此在处理长字符串且性能敏感的场景,应优先选择parseString()

6. 总结

本文探讨了两种将字符串拆分为数字/非数字子串的方案:

  • 正则方案(split()):简洁直接
  • 非正则方案(parseString()):性能更优

虽然split()代码更短,但在处理长字符串时显著慢于手写方案。性能关键场景下,parseString()是更优选择。

所有代码片段可在GitHub获取。


原始标题:Split a String Into Digit and Non-Digit Substrings