Unicode 文本分段(UAX #29)

"按空格拆分"是最常见的文本处理 Bug。它对 CJK(词间无空格)、泰语/老挝语(词间无空格)、德语复合词和 emoji 序列都会失败。Unicode Annex #29 定义了正确的算法。

三种边界类型

边界 用途 示例
字位簇 用户感知的一个"字符" 👨‍👩‍👧 = 1 个字位簇(5 个码位)
分词、光标移动、双击选择 "don't" = 1 个词还是 3 段?
句子计数、首字母大写 "Dr. Smith went to D.C." = 1 句

字位簇边界

字位簇是用户感知为单个字符的最小单元。UAX #29 使用字符属性定义断开规则:

code
不要断开:
  - 基础字符和组合标记之间(e + ◌́ = é)
  - 区域指示符对之间(🇯 + 🇵 = 🇯🇵)
  - Emoji ZWJ 序列中(👨 + ZWJ + 👩 + ZWJ + 👧 = 👨‍👩‍👧)
  - 韩文字母在音节块内
  - CR 和 LF 之间

要断开:
  - 其他所有字符对之间

实现:

javascript
// 正确: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 个码位

词边界

词分段是语言相关的:

javascript
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 的基于词典的分词。没有它,朴素的字符计数对中文、日文和泰文给出错误的词数。

句子边界

句子边界检测必须处理缩写、小数和省略号:

code
"Dr. Smith earned $3.5M in Q4. Impressive!"
  → 2 句(不是 4 句,尽管有 4 个句号)

规则:
  - 缩写后不断开(Dr., Mr., U.S. 等)
  - 数字内部不断开(3.14, $1,000.00)
  - 省略号内部不断开(...)
  - 句终标点后跟大写字母时要断开

区域感知大小写映射

突厥语 I 问题

大多数语言中,i 大写为 II 小写为 i。但在土耳其语和阿塞拜疆语中:

code
土耳其规则:
  i → İ(U+0130,带点的拉丁大写 I)
  I → ı(U+0131,无点的拉丁小写 i)

英语规则:
  i → I
  I → i

这就是为什么 "FILE".toLowerCase()tr_TRen_US 区域设置下给出不同结果:

javascript
// 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 1 → 2
小写 İ 1 → 2(非突厥语环境)
标题 dž Dž 1 → 1(特殊标题形式)

这意味着 str.length 在大小写转换后可能改变:

python
text = "straße"
print(len(text))               # 6
print(len(text.upper()))       # 7("STRASSE")
print(len(text.casefold()))    # 7("strasse")

大小写无关比较

Unicode 标准定义的正确算法:

code
1. 正规化为 NFD
2. 应用 case folding(toCasefold 映射)
3. 再次正规化为 NFD(case folding 可能引入可分解字符)
4. 逐码位比较

Python 的 str.casefold() 执行第 2 步。完全正确的方法:

python
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)需要正确的词边界检测:

python
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 年级水平

code
FKGL = 0.39 × (总词数 / 总句数)
     + 11.8 × (总音节数 / 总词数)
     − 15.59

解读:理解该文本所需的美国学校年级水平。得分 8.0 表示八年级学生能理解。

Flesch 阅读易度

code
FRE = 206.835
    − 1.015 × (总词数 / 总句数)
    − 84.6 × (总音节数 / 总词数)
分数 难度 典型读者
90–100 非常简单 五年级
60–70 标准 八到九年级
30–50 困难 大学
0–30 非常困难 研究生

Coleman-Liau 指数

与 Flesch-Kincaid 不同,Coleman-Liau 使用字符数而非音节数,使其更容易编程计算:

code
CLI = 0.0588 × L − 0.296 × S − 15.8

其中:
  L = 每 100 个词的平均字符数
  S = 每 100 个词的平均句数

可读性公式的局限

这些度量测量表面复杂度,不是理解难度:

  • "量子纠缠违反贝尔不等式"评分为简单(短词、简单结构)
  • "鉴于上述法规要求的实施需要..."评分为困难(长词),尽管概念简单

它们还对以下情况失效:

  • 非英语语言:音节计数规则不同;CJK 没有同等意义的音节
  • 技术内容:领域术语短但概念密集
  • 列表和代码:结构格式扭曲句/词比率

音节计数算法

英语音节计数是近似值(英语拼写不具有语音规则性):

python
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 文本需要:

  1. 正规化:NFKD(兼容分解)将基础字符与组合标记分离
  2. 音译:将非 ASCII 映射到 ASCII 等价物
  3. CJK 罗马化:将表意文字转换为罗马化形式
  4. 清理:移除剩余非 ASCII、合并分隔符

基于 NFKD 的音译

python
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 罗马化的挑战

中文文本需要拼音转换,在没有分词的情况下存在歧义:

code
"银行" → "yín háng"(银行)或 "yín xíng"(行走)取决于上下文
"长城" → "cháng chéng"(长城)

日文需要区分汉字读音:

code
"東京" → "tōkyō"(地名)—— 但汉字有多种读音
"今日" → "kyō"(今天)或 "konnichi"(今日)取决于上下文

生产级 slug 生成器通常使用基于词典的分词 + 最常见读音查找。

数字转文字:跨语言复杂度

短标度 vs 长标度问题

数字 短标度(美/英现代) 长标度(法/德)
10⁶ million million
10⁹ billion milliard(billion = 10¹²)
10¹² trillion billion
10¹⁵ quadrillion billiard

美式英语、现代英式英语和大多数编程上下文使用短标度。法语、德语、欧洲西班牙语和许多其他语言使用长标度。

语法一致性

英语中数字转文字相对简单。其他语言需要:

法语:性别一致

code
21 → "vingt et un"(阳性)/ "vingt et une"(阴性)

德语:倒装和复合词

code
21 → "einundzwanzig"(一又二十,单一复合词)

俄语:格一致

code
1 рубль, 2 рубля, 5 рублей(1、2-4、5+ 使用不同名词形式)

日语:量词

code
1本(ippon)、2本(nihon)、3本(sanbon)—— 量词改变发音

中文:大写金额

code
123.45 → "壹佰贰拾叁元肆角伍分"(银行大写)

算法(英语)

python
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"]

生产级字数统计算法

python
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 万行),哈希集去重直接了当:

python
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)。

大规模去重

对于无法装入内存的文件,使用概率方法:

布隆过滤器:以有界假阳性率测试集合成员资格,零假阴性。

python
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) 内存。

bash
# 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