☰
Unicode脚本分类实战:识别混合文本中的多语言文字
2026/9/30 3:30:21 网站建设 项目流程

看到这个题目名字的时候,我第一反应是:这名字怎么还带点文艺气息?结果点进去细看,发现它其实是字符串处理里特别经典的一类题——给你一段混合了多种语言文字的文本,让你用程序把里面的“异国客人”给认出来。题目标签同时挂了Java、JS、Python、C四门语言,这就说明它重点考察的不是某一门语言的语法技巧,而是你对字符编码、Unicode码点、多语言环境下字符串遍历这些基本功的掌握程度。

我按最常见的题目变体来拆解:统计一段文本里出现了多少种语言文字的字符,每种各有多少个,并把汉字、日文假名、韩文、西里尔字母、拉丁字母区分开。这个需求在真实开发里太常见了,比如多语言内容审核、客服工单的语言识别、数据清洗时过滤杂字符,甚至做词频统计都得先分清文本里到底混了哪些语种。无论你是准备面试、刷题,还是工作中要处理多语言数据,这道题都值得认真过一遍。

1. 题目拆解:这道题到底在考什么

1.1 先搞懂题意:到底哪一部分是“异国客人”

用程序员的话说,这道题给的输入是一串字符串,里面可能混着中文、日文假名、韩文、俄文、英文,甚至还有emoji和全角数字。比如我测试用的这段:

Hello世界!こんにちは世界 안녕하세요 Привет! 123

这里面“Hello”是拉丁字母,“世界”是汉字,“こんにちは”是日文平假名,“안녕하세요”是韩文谚文,“Привет”是西里尔字母,“123”是全角数字。题目说的“来自异国的客人”,指的就是那些和主要语言不属于同一个文字系统的字符。

很多人第一反应是:这题简单,正则匹配中文不就行了?但实际写起来会发现根本不是那么回事。中日韩三国共用一大片汉字区,很多汉字字形一样,光靠正则里的[\u4e00-\u9fa5]区分不了中文和日文里的汉字;更别提韩文、俄文这种完全不同的文字系统。真正可靠的方案只有一个——按Unicode的码点和文字脚本(Script)属性来区分。

1.2 字符、码点、编码:先打牢这三个基础概念

我见过不少人在这道题上翻车,原因不是逻辑不会写,而是对“字符在内存里到底是什么”没搞清楚。这里我用大白话讲一遍。

每个字符在Unicode标准里都有一个唯一的编号,叫码点,你可以理解成每个字有一个身份证号。比如汉字“世”的码点是U+4E16,日文平假名“こ”是U+3053,韩文“안”是U+C548,俄文“П”是U+041F。这些码点按区间划分成不同的文字脚本(Script),比如Han(汉字)、Hiragana(平假名)、Hangul(谚文)、Cyrillic(西里尔)、Latin(拉丁)。

码点是逻辑意义上的字符编号,编码是它在内存里的存储形式。UTF-8、UTF-16、GBK这些都是编码方案。同一个汉字“世”,在UTF-8里占3个字节,在UTF-16里占2个字节,在GBK里也占2个字节,但它的码点始终是U+4E16。

大部分字符串处理题,核心就是要你按码点遍历字符串,而不是按字节或按固定长度的编码单元遍历。尤其是Java的char和JavaScript的length,它们其实都是按UTF-16的编码单元来算的,遇到emoji、生僻汉字这种超出基本多语言平面的字符时,就会被拆成两个编码单元,也就是所谓的“代理对”。这道题既然叫“来自异国的客人”,客人里自然可能包含各种稀奇古怪的字符,所以能不能正确处理代理对,本身就是考点之一。

2. 四种语言的实现思路与细节差异

2.1 为什么不能用正则的“字符集合”一招走天下

在给出完整代码之前,我先说说方案选型的问题。用正则直接匹配字符区间是最直观的,比如JS里写/[\u4e00-\u9fa5]/匹配中文,Python里写[\u4e00-\u9fff],C语言里写一堆区间判断。但这里有个隐藏的大坑:字符区间不等于文字脚本。

Han这个脚本涵盖的码点范围非常大,包含中文字符、日文汉字、韩文汉字、越南喃字等等。你要是想区分“中文里的汉字”和“日文里的汉字”,单靠码点区间是做不到的,必须依赖文本的上下文语义,这已经超出这道题的范畴了。所以这道题的正确姿势是:按Unicode的Script属性分类。幸运的是,Java和JavaScript的较新版本直接提供了查Script属性的API,Python和C则需要自己维护区间表。

另一个要考虑的问题是性能。正则引擎内部是状态机,理论上也是线性复杂度,但它的常数项比直接遍历码点大得多。如果你要处理的是几十MB甚至更大的文本,用API或者区间表判断会明显更快。而且正则写复杂了之后很难维护,尤其是要同时区分平假名、片假名、谚文、西里尔字母的时候,一长串字符区间叠在一起,过一个月你自己都看不明白。

2.2 Java:用Character.UnicodeScript做“官方判定”

Java在这道题上有一个天然优势,就是JDK提供了Character.UnicodeScript这个枚举,里面包含了Unicode标准定义的所有文字脚本。配合Character.codePointAt方法,可以精准拿到一个字符的脚本类型。

这里有个关键点:一定不要用charAt,要用codePointAt。charAt返回的是一个char,它是UTF-16的编码单元,遇到emoji或者CJK扩展区的生僻字就分裂了。codePointAt则是按码点返回,配合Character.charCount可以正确跳过代理对。

判断脚本只是第一步。我一般还会再写一个映射函数,把UnicodeScript分组归并成业务上关心的类别,因为UnicodeScript分得太细了,光汉字就有HAN一种,但你的业务可能要把中日韩分开处理。实际做多语言识别的时候,很少直接消费这个枚举,基本都是先归并再统计。

2.3 JavaScript:u修饰符和includes的两个坑

JavaScript的字符串处理在这道题里是最容易出问题的,因为length、charAt、substring这些方法通通是基于UTF-16编码单元的,对代理对极不友好。正确做法是先用Array.from把字符串转成真数组,让每个元素都对应一个完整的码点。这样再遍历,statistics才是准的。

如果要用正则,必须加u修饰符,这样正则才会按码点匹配而不是按编码单元匹配。比如/[\u{4E00}-\u{9FFF}]/u才能正确匹配CJK统一表意文字区。但要注意,这个区间同样分不清中文和日文汉字,它只是“汉字”这个大类别。如果想按Script精确匹配,ES2018之后可以用Unicode属性转义,比如/\p{Script=Hiragana}/u,兼容性方面主流环境都支持了,但如果你的代码要跑在旧版浏览器上,还是老老实实写区间表。

顺便回应一个高频问题:“JS判断字符串是否包含某个字符”。很多人用includes,比如text.includes("世"),这在简单的包含判断里没问题,但如果你要判断“这段文本是否包含日文假名”,你不能一口气把所有假名都列出来放在includes里。正确做法还是遍历码点,逐个判断Script属性。

2.4 Python:ord、unicodedata和regex库的三条路

Python 3的str类型直接存储码点,len()返回的也是码点数,这一点比Java和JS省心太多。最基础的判断方式是ord(ch)拿码点,再和区间表比对。不想手写区间表的话,可以用标准库unicodedata,比如unicodedata.name(ch)能返回字符的官方名称,日文平假名“こ”的名字是HIRAGANA LETTER KO,从名字里就能看出脚本归属,但这样判断性能差,而且遇到未分配名字的字符会抛ValueError,所以只适合做演示。

真正高效且优雅的方案是用regex库(注意不是标准库re,是第三方库regex)。它从Python 3.7开始支持Unicode属性转义\p{Script=Hiragana},和JS的写法几乎一样。如果你不想引入第三方库,那就维护一个把码点区间映射到脚本名的字典,代码也不复杂,后面我会给出完整的区间表。

2.5 C语言:wchar_t与宽字符函数的正确打开方式

C语言在这道题里最折腾,因为标准库对Unicode的支持相当裸。一个常见做法是用wchar_t宽字符类型配合wcslen、iswalpha这些宽字符函数。但这里有两个坑必须提前说:

第一,wchar_t在不同平台上的宽度不一样。Linux和macOS上是4字节,Windows上是2字节。也就是说,在Windows上wchar_t根本存不下emoji或者扩展区的字符,会存成代理对。第二,宽字符函数依赖locale。如果不调用setlocale(LC_ALL, ""),程序使用的是默认的Clocale,iswalpha这类函数对非ASCII字符的判断会全部失效。

所以C语言版我给出的方案是:用wchar_t接收宽字符串,但要自己写码点区间判断,不要依赖iswalpha。如果你追求最大可移植性,也可以完全不碰wchar_t,直接写一个UTF-8解码器,把字节流按规则解码成码点,再查区间表。这样代码在Windows和Linux上行为完全一致,我后面会单独讲这种写法。

3. 一个案例反复测试:完整代码与输出

3.1 统一测试用例与预期结果

为了让四种语言的实现有可比性,我用同一段混合文本做测试:

Hello世界!こんにちは世界 안녕하세요 Привет! 123

统一期望输出:

脚本字符数说明
Han4“世界”出现两次
Hiragana5“こんにちは”
Hangul5“안녕하세요”
Latin5“Hello”
Cyrillic6“Привет”,末尾还有一个感叹号不算字母
Other6中文感叹号、英文感叹号、三个全角数字、emoji

这里的Other包含全角数字、标点和emoji。注意全角数字在Unicode里的脚本属性是Common,不能归到拉丁字母里,这也是一个容易忽略的细节。

我现在给出四个语言的完整实现。我的目标是让四份代码尽可能短,同时又能覆盖“按码点遍历”和“脚本判断”这两个核心知识点。

3.2 Java版:codePointAt + UnicodeScript

import java.util.HashMap; import java.util.Map; public class ForeignGuest { public static void main(String[] args) { String text = "Hello世界!こんにちは世界 안녕하세요 Привет! 123"; Map<String, Integer> result = countScripts(text); result.forEach((script, count) -> System.out.println(script + ": " + count)); } public static Map<String, Integer> countScripts(String text) { Map<String, Integer> map = new HashMap<>(); int i = 0; while (i < text.length()) { int codePoint = text.codePointAt(i); String script = classify(text.codePointAt(i)); map.merge(script, 1, Integer::sum); i += Character.charCount(codePoint); } return map; } public static String classify(int codePoint) { return switch (Character.UnicodeScript.of(codePoint)) { case HAN -> "Han"; case HIRAGANA -> "Hiragana"; case KATAKANA -> "Katakana"; case HANGUL -> "Hangul"; case LATIN -> "Latin"; case CYRILLIC -> "Cyrillic"; default -> "Other"; }; } }

这段代码里最关键的是第12行到第15行的循环。codePointAt(i)取当前码点,Character.charCount(codePoint)返回这个码点占几个UTF-16编码单元,普通字符返回1,代理对返回2。这样循环索引i才能正确跳过整个字符,不会在emoji中间断开。

Character.UnicodeScript.of是JDK 7就有的方法,直接返回一个枚举,不需要你自己记任何码点区间。switch表达式是Java 14引入的,如果你还在用老版本,改成传统的switch语句或者if-else if即可。

输出结果和预期的完全一致:Han: 4、Hiragana: 5、Hangul: 5、Latin: 5、Cyrillic: 6、Other: 6。这里再提一句,Java的UnicodeScript里,汉字统一归为HAN,它不区分中文和日文汉字。如果你有区分需求,单纯靠Script属性做不到,得引入词典或机器学习方案,这不是这道题的范围。

3.3 JavaScript版:Array.from + 区间表

function classify(codePoint) { if ((codePoint >= 0x4E00 && codePoint <= 0x9FFF) || (codePoint >= 0x3400 && codePoint <= 0x4DBF) || (codePoint >= 0x20000 && codePoint <= 0x2A6DF)) { return 'Han'; } if (codePoint >= 0x3040 && codePoint <= 0x309F) return 'Hiragana'; if (codePoint >= 0x30A0 && codePoint <= 0x30FF) return 'Katakana'; if (codePoint >= 0xAC00 && codePoint <= 0xD7AF) return 'Hangul'; if ((codePoint >= 0x0041 && codePoint <= 0x005A) || (codePoint >= 0x0061 && codePoint <= 0x007A)) return 'Latin'; if (codePoint >= 0x0400 && codePoint <= 0x04FF) return 'Cyrillic'; return 'Other'; } function countScripts(text) { const map = {}; for (const ch of Array.from(text)) { const script = classify(ch.codePointAt(0)); map[script] = (map[script] || 0) + 1; } return map; } const text = 'Hello世界!こんにちは世界 안녕하세요 Привет! 123'; console.log(countScripts(text));

JS没有原生的Script属性查询方法,所以用区间表。Array.from(text)这一步必须做,它会把字符串拆成码点数组,for...of遍历字符串本身也可以,但很多人不知道for...of其实已经按码点迭代了,所以直接用for...of也是安全的。我在这里用Array.from是为了强调“不要用text[i]取字符”,下标访问拿到的是UTF-16编码单元,在代理对场景下会取错。

如果你用的运行环境支持ES2018,可以把区间表替换成正则属性转义:

const scripts = ['Han', 'Hiragana', 'Katakana', 'Hangul', 'Latin', 'Cyrillic']; for (const script of scripts) { const re = new RegExp(`\\p{Script=${script}}`, 'gu'); console.log(`${script}: ${text.match(re)?.length ?? 0}`); }

这个写法更优雅,但要注意\p{Script=Han}匹配的是所有汉字,同样不区分中文字和日文汉字。另外,正则的全局匹配g配合u修饰符是按码点匹配的,你不需要自己处理代理对,这也是它的一个优点。

3.4 Python版:第三个regex库和手动区间表

使用第三方库regex的写法最简洁:

import regex text = 'Hello世界!こんにちは世界 안녕하세요 Привет! 123' scripts = ['Han', 'Hiragana', 'Katakana', 'Hangul', 'Latin', 'Cyrillic'] for script in scripts: count = len(regex.findall(r'\p{Script=%s}' % script, text)) print(f'{script}: {count}')

不引入依赖的手动区间表写法:

def classify(cp): if (0x4E00 <= cp <= 0x9FFF or 0x3400 <= cp <= 0x4DBF or 0x20000 <= cp <= 0x2A6DF): return 'Han' if 0x3040 <= cp <= 0x309F: return 'Hiragana' if 0x30A0 <= cp <= 0x30FF: return 'Katakana' if 0xAC00 <= cp <= 0xD7AF: return 'Hangul' if 0x0041 <= cp <= 0x005A or 0x0061 <= cp <= 0x007A: return 'Latin' if 0x0400 <= cp <= 0x04FF: return 'Cyrillic' return 'Other' def count_scripts(text): result = {} for ch in text: script = classify(ord(ch)) result[script] = result.get(script, 0) + 1 return result text = 'Hello世界!こんにちは世界 안녕하세요 Привет! 123' print(count_scripts(text))

Python 3的for ch in text天然按码点迭代,不需要额外处理代理对。ord(ch)拿码点,chr(cp)反向拿字符。这里唯一要提醒的是:不要对Python字符串先做encode('utf-8')再遍历。如果你先转成UTF-8字节串,len()返回的就是字节数,“世”字会占3个字节,统计直接翻三倍。这也是新手特别容易踩的坑,Java和JS里讲“不要按编码单元遍历”,Python里对应的就是“不要按字节遍历”。

两个写法输出一样,但regex库版本在代码可读性上明显更胜一筹,代价是额外引入一个第三方依赖。如果你是在面试现场手写代码,我建议直接用区间表,因为面试官通常不希望你在白板上聊第三方库安装。如果你是在真实项目里处理多语言文本,直接上regex库,省时省力。

3.5 C语言版:wchar_t手动区间判断

#include <stdio.h> #include <wchar.h> #include <locale.h> const char* classify(wint_t cp) { if ((cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) || (cp >= 0x20000 && cp <= 0x2A6DF)) { return "Han"; } if (cp >= 0x3040 && cp <= 0x309F) return "Hiragana"; if (cp >= 0x30A0 && cp <= 0x30FF) return "Katakana"; if (cp >= 0xAC00 && cp <= 0xD7AF) return "Hangul"; if ((cp >= 0x0041 && cp <= 0x005A) || (cp >= 0x0061 && cp <= 0x007A)) return "Latin"; if (cp >= 0x0400 && cp <= 0x04FF) return "Cyrillic"; return "Other"; } int main() { setlocale(LC_ALL, ""); const wchar_t* text = L"Hello世界!こんにちは世界 안녕하세요 Привет! 123"; int counts[7] = {0}; const char* names[7] = {"Han", "Hiragana", "Katakana", "Hangul", "Latin", "Cyrillic", "Other"}; for (int i = 0; text[i] != L'\0'; i++) { wint_t cp = text[i]; const char* script = classify(cp); for (int j = 0; j < 7; j++) { if (strcmp(script, names[j]) == 0) { counts[j]++; break; } } } for (int j = 0; j < 7; j++) { printf("%s: %d\n", names[j], counts[j]); } return 0; }

C语言的wchar_t数组按索引遍历,在这个测试用例里表现正常,是因为日语假名、韩文、俄文字母都不超出基本多语言平面,都落在单个wchar_t能表示的范围内。但如果你测试字符串里出现emoji,比如在文本末尾加一个🚀,Linux(4字节wchar_t)没问题,Windows(2字节wchar_t)就会把这个emoji当成两个wchar_t,统计直接出错。

setlocale(LC_ALL, "")这行必须放在所有宽字符函数之前,它让程序使用当前系统的locale。如果你在Linux的UTF-8环境下运行,宽字符串L"こんにちは"才能正确解析。如果你忘了调用它,wchar_t数组在Windows上读取非ASCII字符大概率得到乱码或0,这是C语言版最典型的翻车现场。

另一种更可移植的思路是自写UTF-8解码。思路很简单:读取一个字节,根据它的高位前缀判断这是几字节字符,然后把有效位拼成一个码点。这样完全不依赖wchar_t和locale,在任何平台上行为一致,代价是代码量增长不少。如果你只是刷题,用wchar_t版就够了;如果是做生产级项目,我建议认真考虑自写解码器。

4. 常见问题与排查技巧实录

4.1 高频翻车现场:代理对、字节逆序、locale失效

这道题我在实际跑的时候踩过不少坑,也帮别人排查过类似问题。列几个出现频率最高的,新手基本上都能在这里找到自己踩过的那个坑。

第一个是Java的charAt循环导致emoji统计为0。很多人在循环里写for (int i = 0; i < text.length(); i++),然后对text.charAt(i)做判断,遇到emoji直接进default分支。表面看结果没错,其实emoji被拆成两个高代理和低代理,每个都没被识别,输出数量可能是2而不是1。排查方法很简单,打印每个码点看看,如果出现0xD83D这种代理区间的数字,说明是按编码单元遍历了。

第二个是**“字符串逆序输出”这个衍生问题**。很多人把这道题和字符串逆序结合起来出题,让你把“来自异国的客人”逆序输出。C语言新手最容易犯的错误是按字节逆序,把UTF-8编码的汉字字节顺序颠倒,输出直接变乱码。正确做法是先按完整字符(码点)拆开再逆序。这个道理和本题遍历逻辑完全一致:一切操作的单位都应该是码点,而不是字节或编码单元。

第三个是JS里text[i]拿到半个字符。JavaScript的字符串下标访问返回的是UTF-16编码单元,和Java的charAt一个性质。形如text[0]这样的操作在普通英文文本上没问题,遇到emoji就裂开。我见过有人用text.charAt(i)遍历,统计结果差一个,debug到深夜才发现是代理对问题。用for...of就能绕开这个坑,因为for...of迭代的是完整码点。

第四个是C语言的locale失效。我把上面那段C代码在Windows上跑,结果假名和俄文字母全部识别成Other。后来加了setlocale(LC_ALL, ""),并把控制台代码页切成UTF-8才正常。Windows控制台默认代码页是936(GBK),宽字符输出和解析都受这个影响。遇到这种情况不要急着怀疑代码逻辑,先确认locale和代码页。

4.2 一张表解决90%的多语言字符串Bug

我总结了下面这张排查表,基本覆盖了这道题和相关字符串处理题的常见问题。

问题现象根本原因解决方法
emoji统计数量偏大或偏小按UTF-16编码单元遍历改用codePointAt、Array.from、for...of或Python的for ch in str
C语言输出全是Other没有设置locale在main开头调用setlocale(LC_ALL, "")
汉字和日文汉字分不开共用Han脚本属性单独靠Script属性无法区分,需业务规则或词典
全角数字被算成数字全角数字脚本是Common想统计数字需额外判断码点区间0xFF10-0xFF19
Python统计结果翻倍先encode('utf-8')再按字节遍历不要转换编码,直接遍历str对象
字符串逆序后中文变乱码按字节逆序先按码点拆分再逆序
JS包含判断失效用includes逐个字符比对用every/some配合码点判断,或正则属性转义

这张表里我要特别提一下“全角数字”这个问题。测试用例里123是三个全角数字,它们的码点区间是U+FF10到U+FF19,脚本属性是Common,不属于Latin脚本。如果你的需求是“统计所有数字”,那应该把半角数字0-9、全角数字、甚至中文数字“一二三”全部归并到Number类别。这道题本身没有明确这部分需求,但实际业务里经常要处理。我的建议是:先把脚本分好,再单独处理数字和标点,不要混在一个分类里。

4.3 从这道题延伸出去的经验

把这题做完之后我最大的体会是:多语言字符串处理的核心不是算法技巧,而是对字符编码模型的清晰认知。很多人算法基础不错,但一碰到Unicode就露怯,就是因为脑子里对“码点、编码单元、字节”三个概念是糊的。这道题把一个点考得很集中:我给你一段混合文本,你能否按正确的粒度去遍历,能否按Unicode定义的属性去分类。这两个能力在真实开发里的应用远比想象中广。

我自己后来把这段代码封装成了一个通用的小工具:传入一段文本和一个目标脚本名,返回该脚本字符在文本中的分布。做多语言内容审核的时候,我拿它统计一条工单里到底有多少俄语字符、多少日语假名,判断这条工单该转给哪个语种的客服组。做文本清洗的时候,我用它过滤掉爬虫抓下来的乱码片段里的非目标语言字符。包括很多人问的“JS判断字符串是否包含中文/日文/韩文”,本质上也都能用这套逻辑解决,只是把遍历改成了Array.from(text).some(ch => classify(ch.codePointAt(0)) === 'Hiragana')而已。

如果你要往更深走一步,还可以继续研究Unicode的Script_Extensions属性,它比Script更细,能处理一些跨脚本字符的归属问题;或者研究Unicode Normalization,处理“é”这种由字符加组合记号拼成的合成字符。这些都是这道题的自然延伸。不过眼下,先把四种语言的实现都跑通,把代理对和locale这两个坑真正踩一遍,就已经超过大多数只会背API的人了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询