將字符串拆分為數字和非數字子字符串
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()
方法是將String
拆分為數組的便捷工具。**例如: “a, b, c, d”.split(“, “)
返回一個字符串數組: {“a”, “b”, “c”, “d”}
。
因此,使用split()
方法可能是我們解決問題的第一個想法。然後,我們需要找到一個正則表達式模式作為分隔符並引導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)
。
接下來,讓我們編寫一個測試來驗證在兩個輸入上使用split()
和此模式是否會產生所需的結果:
String splitRE = "(?<=\\D)(?=\\d)|(?<=\\d)(?=\\D)";
String[] result1 = INPUT1.split(splitRE);
assertArrayEquals(EXPECTED1, result1);
String[] result2 = INPUT2.split(splitRE);
assertArrayEquals(EXPECTED2, result2);
如果我們運行一下,測試就會通過。所以,我們使用split()
方法解決了這個問題。
接下來,讓我們使用非正則表達式方法來解決問題。
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) { // non-digit to digit, get the substring as an element
result.add(input.substring(start, i));
start = i;
}
state = PARSING_DIGIT;
} else {
if (state == PARSING_DIGIT) { // digit to non-digit, get the substring as an element
result.add(input.substring(start, i));
start = i;
}
state = PARSING_NON_DIGIT;
}
}
result.add(input.substring(start)); // add the last part
return result;
}
現在,讓我們快速瀏覽一下上面的代碼並了解它是如何工作的:
- 首先,我們初始化一個名為
result
的空ArrayList
來存儲提取的元素。 -
int start = 0; –
此變量start
在稍後的迭代過程中跟踪每個子字符串的開始索引。 -
state
變量是一個枚舉,它指示迭代字符串時的狀態。 - 然後,我們使用
for
循環遍歷輸入字符串字符並檢查每個字符的類型。 - 如果當前字符是數字(
0
–9
)並且是非數字到數字的轉換,則表示元素已結束。因此,我們將從start
到i
(不包括)的子字符串添加到result
列表中。此外,我們將start
索引更新為當前索引i
並將state
設置為PARSING_DIGIT
狀態。 -
else
塊遵循類似的邏輯並處理數字到非數字的轉換場景。 -
for
循環結束後,我們不應該忘記使用input.substring(start).
將字符串的最後一部分添加到result
列表中。
接下來,讓我們用兩個輸入測試parseString()
方法:
List<String> result1 = parseString(INPUT1);
assertEquals(EXPECTED_LIST1, result1);
List<String> result2 = parseString(INPUT2);
assertEquals(EXPECTED_LIST2, result2);
如果我們運行測試,它就會通過。因此,我們的parseString()
方法完成了這項工作。
5. 性能
到目前為止,我們已經解決了該問題的兩種解決方案:基於正則表達式和非基於正則表達式。基於正則表達式的split()
解決方案非常簡單,只需一個方法調用。相反,我們自製的十幾行parseString()
方法需要我們自己控制輸入中的每個字符。那麼,有人可能會問,為什麼我們要引入甚至使用自製的方法來解決問題呢?
答案是“性能”。
雖然我們的parseString()
解決方案看起來很長並且需要手動控制每個字符,但它比基於正則表達式的解決方案更快。讓我們來了解一下其中的原因:
-
split()
解決方案requires
編譯正則表達式模式並應用模式匹配。這些操作在計算上被認為是昂貴的,特別是對於復雜的模式。然而,另一方面,parseString()
方法使用一個簡單的基於枚舉的狀態機來跟踪數字和非數字字符之間的轉換。它允許直接比較並避免正則表達式模式匹配和環視的複雜性。 - 在
parseString()
方法中,直接使用substring()
方法提取子字符串。這種方法避免了在將split()
方法與正則表達式一起使用時可能發生的不必要的對象創建和內存分配。此外,通過使用已知索引直接提取子字符串,parseString()
方法優化了內存使用並可能提高性能。
但是,如果輸入字符串不是相當長,則性能差異可能可以忽略不計。
接下來,讓我們對這兩種方法的性能進行基準測試。我們將使用 JMH(Java Microbenchmark Harness)來做到這一點。這是因為 JMH 允許我們輕鬆處理基準測試因素,例如 JVM 預熱、死代碼消除等:
@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);
}
}
正如上面的課程所示,我們使用相同的輸入在 10k 次迭代中對兩種方法進行了基準測試。當然,我們不會深入研究 JMH 並了解每個 JMH 註釋的含義。但有兩個註釋對於我們理解最終報告非常重要: @OutputTimeUnit(TimeUnit.MILLISECONDS)
和@BenchmarkMode(Mode.Throughput).
這種組合意味著我們測量每毫秒可以運行每種方法的次數。
接下來我們看一下JMH生成的結果:
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()
而不是split()
解決方案。
六,結論
在本文中,我們探討了基於正則表達式 ( split()
) 和非基於正則表達式 ( parseString()
) 的方法,將輸入字符串分解為包含數字元素和非數字字符串元素的字符串數組或列表。原始訂單。
split()
解決方案緊湊且簡單。然而,當處理長輸入字符串時,它可能比自製的parseString()
解決方案慢得多。
與往常一樣,本文中提供的所有代碼片段都可以在 GitHub 上找到。