Unicode 文本分段(UAX #29)
"按空格拆分"是最常见的文本处理 Bug。它对 CJK(词间无空格)、泰语/老挝语(词间无空格)、德语复合词和 emoji 序列都会失败。Unicode Annex #29 定义了正确的算法。
三种边界类型
| 边界 | 用途 | 示例 |
|---|---|---|
| 字位簇 | 用户感知的一个"字符" | 👨👩👧 = 1 个字位簇(5 个码位) |
| 词 | 分词、光标移动、双击选择 | "don't" = 1 个词还是 3 段? |
| 句 | 句子计数、首字母大写 | "Dr. Smith went to D.C." = 1 句 |
字位簇边界
字位簇是用户感知为单个字符的最小单元。UAX #29 使用字符属性定义断开规则:
不要断开:
- 基础字符和组合标记之间(e + ◌́ = é)
- 区域指示符对之间(🇯 + 🇵 = 🇯🇵)
- Emoji ZWJ 序列中(👨 + ZWJ + 👩 + ZWJ + 👧 = 👨👩👧)
- 韩文字母在音节块内
- CR 和 LF 之间
要断开:
- 其他所有字符对之间
实现:
// 正确:Intl.Segmenter(符合 UAX #29)
function graphemes(str) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return [...segmenter.segment(str)].map(s => s.segment);
}
graphemes("👨👩👧👦") // ["👨👩👧👦"] — 1 个字位簇
graphemes("café") // ["c", "a", "f", "é"] — 4 个字位簇(NFC)
// 错误:展开运算符(按码位拆分,非字位簇)
[..."👨👩👧👦"] // ["👨", "", "👩", "", "👧", "", "👦"] — 7 个码位
词边界
词分段是语言相关的:
const segmenter = new Intl.Segmenter('en', { granularity: 'word' });
const words = [...segmenter.segment("I can't believe it's 3:30pm")]
.filter(s => s.isWordLike)
.map(s => s.segment);
// ["I", "can't", "believe", "it's", "3:30pm"]
// CJK:需要基于词典的分词
const zhSegmenter = new Intl.Segmenter('zh', { granularity: 'word' });
const zhWords = [...zhSegmenter.segment("今天天气很好")]
.filter(s => s.isWordLike)
.map(s => s.segment);
// ["今天", "天气", "很", "好"](基于词典)
对于 CJK 语言,Intl.Segmenter 使用 ICU 的基于词典的分词。没有它,朴素的字符计数对中文、日文和泰文给出错误的词数。
句子边界
句子边界检测必须处理缩写、小数和省略号:
"Dr. Smith earned $3.5M in Q4. Impressive!"
→ 2 句(不是 4 句,尽管有 4 个句号)
规则:
- 缩写后不断开(Dr., Mr., U.S. 等)
- 数字内部不断开(3.14, $1,000.00)
- 省略号内部不断开(...)
- 句终标点后跟大写字母时要断开
区域感知大小写映射
突厥语 I 问题
大多数语言中,i 大写为 I,I 小写为 i。但在土耳其语和阿塞拜疆语中:
土耳其规则:
i → İ(U+0130,带点的拉丁大写 I)
I → ı(U+0131,无点的拉丁小写 i)
英语规则:
i → I
I → i
这就是为什么 "FILE".toLowerCase() 在 tr_TR 和 en_US 区域设置下给出不同结果:
// JavaScript:区域敏感
"FILE".toLocaleLowerCase('tr') // "fıle"(无点 i)
"FILE".toLocaleLowerCase('en') // "file"
// Python:casefold() 是区域无关的
"straße".casefold() // "strasse"(德语 ß → ss)
"straße".lower() // "straße"(lower() 保留 ß)
大小写映射不是 1:1
| 操作 | 输入 | 输出 | 码位数变化 |
|---|---|---|---|
| 大写 | ß | SS | 1 → 2 |
| 大写 | fi | FI | 1 → 2 |
| 小写 | İ | i̇ | 1 → 2(非突厥语环境) |
| 标题 | dž | Dž | 1 → 1(特殊标题形式) |
这意味着 str.length 在大小写转换后可能改变:
text = "straße"
print(len(text)) # 6
print(len(text.upper())) # 7("STRASSE")
print(len(text.casefold())) # 7("strasse")
大小写无关比较
Unicode 标准定义的正确算法:
1. 正规化为 NFD
2. 应用 case folding(toCasefold 映射)
3. 再次正规化为 NFD(case folding 可能引入可分解字符)
4. 逐码位比较
Python 的 str.casefold() 执行第 2 步。完全正确的方法:
import unicodedata
def case_insensitive_equal(a, b):
def normalize(s):
s = unicodedata.normalize('NFD', s)
s = s.casefold()
s = unicodedata.normalize('NFD', s)
return s
return normalize(a) == normalize(b)
case_insensitive_equal("straße", "STRASSE") # True
case_insensitive_equal("file", "FILE") # True(兼容 casefold)
编程命名规范:算法
在命名规范之间转换(camelCase、snake_case、kebab-case)需要正确的词边界检测:
import re
def split_identifier(name: str) -> list[str]:
"""将标识符拆分为词组件。
处理:camelCase、PascalCase、snake_case、kebab-case、
SCREAMING_SNAKE、dot.case 以及混合模式如 HTMLParser。
"""
# 在小写后跟大写之前插入边界
# 但保持连续大写在一起(HTML → [HTML] 不是 [H,T,M,L])
name = re.sub(r'([a-z])([A-Z])', r'\1_\2', name)
name = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', name)
# 按非字母数字字符拆分
return [w for w in re.split(r'[_\-.\s/]+', name) if w]
def to_camel(words: list[str]) -> str:
return words[0].lower() + ''.join(w.capitalize() for w in words[1:])
def to_snake(words: list[str]) -> str:
return '_'.join(w.lower() for w in words)
def to_kebab(words: list[str]) -> str:
return '-'.join(w.lower() for w in words)
# 示例:
split_identifier("HTMLParser") # ["HTML", "Parser"]
split_identifier("getHTTPResponse") # ["get", "HTTP", "Response"]
split_identifier("my-kebab-case") # ["my", "kebab", "case"]
可读性度量:数学基础
Flesch-Kincaid 年级水平
FKGL = 0.39 × (总词数 / 总句数)
+ 11.8 × (总音节数 / 总词数)
− 15.59
解读:理解该文本所需的美国学校年级水平。得分 8.0 表示八年级学生能理解。
Flesch 阅读易度
FRE = 206.835
− 1.015 × (总词数 / 总句数)
− 84.6 × (总音节数 / 总词数)
| 分数 | 难度 | 典型读者 |
|---|---|---|
| 90–100 | 非常简单 | 五年级 |
| 60–70 | 标准 | 八到九年级 |
| 30–50 | 困难 | 大学 |
| 0–30 | 非常困难 | 研究生 |
Coleman-Liau 指数
与 Flesch-Kincaid 不同,Coleman-Liau 使用字符数而非音节数,使其更容易编程计算:
CLI = 0.0588 × L − 0.296 × S − 15.8
其中:
L = 每 100 个词的平均字符数
S = 每 100 个词的平均句数
可读性公式的局限
这些度量测量表面复杂度,不是理解难度:
- "量子纠缠违反贝尔不等式"评分为简单(短词、简单结构)
- "鉴于上述法规要求的实施需要..."评分为困难(长词),尽管概念简单
它们还对以下情况失效:
- 非英语语言:音节计数规则不同;CJK 没有同等意义的音节
- 技术内容:领域术语短但概念密集
- 列表和代码:结构格式扭曲句/词比率
音节计数算法
英语音节计数是近似值(英语拼写不具有语音规则性):
def count_syllables(word: str) -> int:
word = word.lower().strip()
if len(word) <= 3:
return 1
# 移除静音 e
if word.endswith('e') and not word.endswith('le'):
word = word[:-1]
# 计数元音组
vowels = 'aeiouy'
count = 0
prev_vowel = False
for char in word:
is_vowel = char in vowels
if is_vowel and not prev_vowel:
count += 1
prev_vowel = is_vowel
return max(1, count)
# 英语近似精度:~85%
# 生产使用:CMU 发音词典或语音查找
Slug 生成:正确的 Unicode 处理
问题空间
URL slug 必须只包含 ASCII 小写字母、数字和连字符。转换任意 Unicode 文本需要:
- 正规化:NFKD(兼容分解)将基础字符与组合标记分离
- 音译:将非 ASCII 映射到 ASCII 等价物
- CJK 罗马化:将表意文字转换为罗马化形式
- 清理:移除剩余非 ASCII、合并分隔符
基于 NFKD 的音译
import unicodedata
import re
def slugify(text: str, max_length: int = 80) -> str:
# NFKD 分解将重音与基础字符分离
text = unicodedata.normalize('NFKD', text)
# 移除组合标记(重音、变音符号)
text = ''.join(c for c in text if not unicodedata.combining(c))
# 音译常见非 ASCII 字符
transliterations = {
'ß': 'ss', 'æ': 'ae', 'œ': 'oe', 'ø': 'o',
'đ': 'd', 'ð': 'd', 'þ': 'th', 'ł': 'l',
}
for src, dst in transliterations.items():
text = text.replace(src, dst)
# 小写
text = text.lower()
# 将非字母数字替换为连字符
text = re.sub(r'[^a-z0-9]+', '-', text)
# 合并多个连字符并去除首尾
text = re.sub(r'-+', '-', text).strip('-')
# 在词边界处截断
if len(text) > max_length:
text = text[:max_length].rsplit('-', 1)[0]
return text
slugify("Café au lait — Recipe") # "cafe-au-lait-recipe"
slugify("Ångström unit ≈ 0.1nm") # "angstrom-unit-0-1nm"
slugify("C++ für Anfänger") # "c-fur-anfanger"
CJK 罗马化的挑战
中文文本需要拼音转换,在没有分词的情况下存在歧义:
"银行" → "yín háng"(银行)或 "yín xíng"(行走)取决于上下文
"长城" → "cháng chéng"(长城)
日文需要区分汉字读音:
"東京" → "tōkyō"(地名)—— 但汉字有多种读音
"今日" → "kyō"(今天)或 "konnichi"(今日)取决于上下文
生产级 slug 生成器通常使用基于词典的分词 + 最常见读音查找。
数字转文字:跨语言复杂度
短标度 vs 长标度问题
| 数字 | 短标度(美/英现代) | 长标度(法/德) |
|---|---|---|
| 10⁶ | million | million |
| 10⁹ | billion | milliard(billion = 10¹²) |
| 10¹² | trillion | billion |
| 10¹⁵ | quadrillion | billiard |
美式英语、现代英式英语和大多数编程上下文使用短标度。法语、德语、欧洲西班牙语和许多其他语言使用长标度。
语法一致性
英语中数字转文字相对简单。其他语言需要:
法语:性别一致
21 → "vingt et un"(阳性)/ "vingt et une"(阴性)
德语:倒装和复合词
21 → "einundzwanzig"(一又二十,单一复合词)
俄语:格一致
1 рубль, 2 рубля, 5 рублей(1、2-4、5+ 使用不同名词形式)
日语:量词
1本(ippon)、2本(nihon)、3本(sanbon)—— 量词改变发音
中文:大写金额
123.45 → "壹佰贰拾叁元肆角伍分"(银行大写)
算法(英语)
ONES = ['', 'one', 'two', 'three', 'four', 'five',
'six', 'seven', 'eight', 'nine', 'ten',
'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen',
'sixteen', 'seventeen', 'eighteen', 'nineteen']
TENS = ['', '', 'twenty', 'thirty', 'forty',
'fifty', 'sixty', 'seventy', 'eighty', 'ninety']
SCALES = ['', 'thousand', 'million', 'billion', 'trillion']
def number_to_words(n: int) -> str:
if n == 0:
return 'zero'
if n < 0:
return 'negative ' + number_to_words(-n)
parts = []
scale_idx = 0
while n > 0:
chunk = n % 1000
if chunk:
chunk_words = _chunk_to_words(chunk)
if SCALES[scale_idx]:
chunk_words += ' ' + SCALES[scale_idx]
parts.append(chunk_words)
n //= 1000
scale_idx += 1
return ' '.join(reversed(parts))
def _chunk_to_words(n: int) -> str:
"""将 1-999 转为文字。"""
if n >= 100:
return ONES[n // 100] + ' hundred' + (
' ' + _chunk_to_words(n % 100) if n % 100 else '')
if n >= 20:
return TENS[n // 10] + ('-' + ONES[n % 10] if n % 10 else '')
return ONES[n]
字数统计:比你想象的更难
定义问题
什么构成一个"词"?
| 文本 | 朴素空格拆分 | UAX #29 词边界 | 用户预期 |
|---|---|---|---|
| "don't" | 1 | 1 | 1 |
| "e-mail" | 1 | 3(e, -, mail) | 1 |
| "3.14" | 1 | 1 | 1 个数字 |
| "今天很好" | 1 | 4(中文分词) | 4 个词 |
没有普遍正确的答案。词数取决于上下文:
- 学术论文:"don't" = 1 个词
- NLP 分词:"don't" → ["do", "n't"](Penn Treebank)
- 搜索索引:"rock'n'roll" → ["rock", "n", "roll"]
生产级字数统计算法
import re
from typing import NamedTuple
class TextStats(NamedTuple):
characters: int
characters_no_spaces: int
words: int
sentences: int
paragraphs: int
reading_time_minutes: float
def analyze_text(text: str, locale: str = 'en') -> TextStats:
characters = len(text)
characters_no_spaces = len(text.replace(' ', '').replace('\t', '').replace('\n', ''))
# 字数:CJK 字符每个计为一词
cjk_pattern = re.compile(r'[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]')
cjk_chars = len(cjk_pattern.findall(text))
# 非 CJK 词:按空格拆分
non_cjk_text = cjk_pattern.sub(' ', text)
latin_words = len([w for w in non_cjk_text.split() if w.strip()])
words = latin_words + cjk_chars
# 句子:句终标点后跟空格或结尾
sentences = len(re.findall(r'[.!?。!?]+(?:\s|$)', text)) or 1
# 段落:被空行分隔的块
paragraphs = len([p for p in text.split('\n\n') if p.strip()])
# 阅读时间:拉丁 200 wpm,CJK 300 cpm
reading_time = (latin_words / 200) + (cjk_chars / 300)
return TextStats(
characters=characters,
characters_no_spaces=characters_no_spaces,
words=words,
sentences=sentences,
paragraphs=paragraphs,
reading_time_minutes=round(reading_time, 1)
)
大规模行去重
算法复杂度
小规模输入(< 10 万行),哈希集去重直接了当:
def deduplicate(lines: list[str], ignore_case: bool = False) -> list[str]:
seen = set()
result = []
for line in lines:
key = line.casefold() if ignore_case else line
if key not in seen:
seen.add(key)
result.append(line)
return result
时间复杂度:O(n)。空间复杂度:O(n)。
大规模去重
对于无法装入内存的文件,使用概率方法:
布隆过滤器:以有界假阳性率测试集合成员资格,零假阴性。
import mmh3
from bitarray import bitarray
class BloomFilter:
def __init__(self, size: int, num_hashes: int):
self.size = size
self.num_hashes = num_hashes
self.bits = bitarray(size)
self.bits.setall(0)
def add(self, item: str):
for i in range(self.num_hashes):
idx = mmh3.hash(item, i) % self.size
self.bits[idx] = 1
def might_contain(self, item: str) -> bool:
return all(
self.bits[mmh3.hash(item, i) % self.size]
for i in range(self.num_hashes)
)
外部排序 + 合并:磁盘上排序文件,然后线性扫描移除相邻重复。适用于任意大文件,O(1) 内存。
# Unix 一行命令去重(保持顺序):
awk '!seen[$0]++' input.txt > output.txt
# 基于排序(不保持顺序,但处理巨大文件):
sort -u input.txt > output.txt
总结
| 任务 | 朴素方法 | 正确方法 |
|---|---|---|
| 字符计数 | .length |
Intl.Segmenter 字位簇 |
| 字数统计 | 按空格拆分 | UAX #29 + CJK 词典分词 |
| 大小写转换 | .toUpperCase() |
区域感知映射 + casefold 比较 |
| Slug 生成 | 正则替换非 ASCII | NFKD + 音译表 + CJK 罗马化 |
| 可读性 | 词数 / 句数 | Flesch-Kincaid 音节模型(并承认局限) |
| 去重 | Set() |
小数据用哈希集;大规模用布隆过滤器或外部排序 |
| 数字转文字 | 仅英语 | 区域感知 + 标度系统 + 性别 + 量词 |
参考文献
- Unicode Standard Annex #29 — Unicode Text Segmentation
- Unicode Standard Annex #44 — Unicode Character Database(大小写映射属性)
- Unicode Technical Standard #39 — Unicode Security Mechanisms(可混淆检测)
- Flesch, R. (1948). "A New Readability Yardstick." Journal of Applied Psychology
- Kincaid, J. P. et al. (1975). "Derivation of New Readability Formulas." Naval Technical Training Command Research Branch Report 8-75