有没有哪两个汉字相似到难以区分?

时间:2021-03-11 16:09:17 相术

知乎首答。

本学期的一项实验是聚类分析,正好看到了 @酱紫君 对这个问题的回答

有没有哪两个汉字,相似到难以区分?​

图标

深受启发,于是决定以这个题目作为我实验的题目,自己试一试。经过尝试,我发现聚类过程其实不需要十几分钟,可以在十几秒内完成。

结果

和看相同部首字哪里

和看相同部首字哪里

(图片由 Word Cloud Generator 生成)

下面是经过筛选的一部分结果,释义来自字海网和看相同部首字哪里,叶典网。

个(U+4E2A) (U+201A4)
* 个:【個】的简体字。
  ㈠拼音ge4。
  ①量词:三~月。洗~澡。
  ②单独的:~人。~性。~位。
  ③人或物体的大小:高~子。
  ④加在“昨儿”、“今儿”、“明儿”等后面,与“某日里”相近。
  ㈡拼音ge3。“自~儿”自己。亦作“自各儿”。
*  :同【丁】字。
乹(U+4E79)軋(U+8ECB)
* 乹:同【乾㈠】字。
  即乾坤之乾。 
* 軋:【轧】的繁体字。
  异体:轧 
卉(U+5349) (U+20984)
* 卉:拼音hui4
  ①草的总称:奇花异体~。
  ②花:奇~怪草。
  ③姓氏。
*  :同【卅】字。三十。
墫(U+58AB)壿(U+58FF)
* 墫:㈠拼音zun1。
  古同【樽】,酒杯。
  ㈡拼音dun1。
  古同【蹲】
  <注>~从土,【壿】从士。 
* 壿:拼音cun1
  ①舞动的样子。
  ②喜,乐。 
嬎(U+5B0E)嬔(U+5B14)
* 嬎:拼音fan4
  ①生子多而整齐划一。
  ②繁殖。
  ③方言,(禽类)生蛋:鸡~蛋。
* 嬔:拼音fu4
  兔崽。
  ②古同【逊】,谦让恭顺。
晝(U+665D)畫(U+756B)
* 晝:【昼】的繁体字。
* 畫:【画】的繁体字。
晳(U+6673)皙(U+7699)
* 晳:同【晰】字。
* 皙:拼音xi1
  ①皮肤白:“其民~而瘠。”
  ②泛指白色:“~帻而衣狸制。”
  ③一种只开花,不结果的枣树。 
由(U+7531)甴(U+7534)
* 由:拼音you2
  ①原因:原~。事~。理~。~于(介词,表示原因或理由)。
  ②自,从:~表及里。~衷(出于本心)。
  ③顺随,听从,归属:~不得。信马~缰。
  ④经过,经历:必~之路。~来已久。
  ⑤凭借:~此可知。
  ⑥古同【犹】,尚且,还。
  ⑦古同【犹】,犹如,好像。
  ⑧姓。
* 甴:字海释义「同【由】字。」
  补充:百度百科:「曱甴,多用于方言,『曱甴』多指『蟑螂』的意思,也有形容阴险恶毒之人。」
曱(U+66F1)甲(U+7532)
* 曱:拼音yue1
  取物。 
* 甲:拼音jia3
  ①天干的第一位,用于作顺序第一的代称:~子。花~(六十岁的人)。
  ②居于首位的,超过所有其它的:~等。
  ③古代科举考试成绩名次的分类:一~(名为“进士及第”);二~(名为“进士出身”);三~(名为“同进士出身”)。
  ④古代军人打仗穿的护身衣服,用皮革或金属叶片制成:盔~。~兵。~士。
  ⑤现代用金属做成有保护功能的装备:~板。装~车。
  ⑥某些动物身上有保护功能的硬壳:龟~。
  ⑦手指或脚趾上的角质硬壳:指~。
  ⑧旧时户口编制单位:保~。~长。
  ⑨植物果实的外壳:~坼(外表裂开)。
  ⑩古同【胛】,肩胛。
朏(U+670F)胐(U+80D0)
* 朏:拼音fei3。
  ①新月开始生明发光,亦用于农历每月初三日的代称。
  ②天将明:“渐~微明光八表。
  <注>~从月,【胐】从肉。
* 胐:拼音ku1。
  ①髋,胯骨。
  ②臀。
朣(U+6723)膧(U+81A7)
* 朣:拼音tong2。
  ①“~胧”朦胧,不分明,如“月~~以含光兮。” 
  ②“~朦”模糊,不分明,如“吉凶纷错,人用~~。”
  <注>~从月,【膧】从肉。
* 膧:拼音chuang2。
  “~??”尻骨。
汨(U+6C68)汩(U+6C69)
* 汨:拼音mi4
  “~罗江”水名,在中国湖南省。
* 汩:㈠拼音gu3。
  ①水流的样子:~流(急流)。~~(水流动的声音或样子)。
  ②治理,疏通:决~九川。
  ③扰乱:“天公岂物欺,若此~时序”。
  ④涌出的泉水。
  ⑤沉没:~没。
  ㈡拼音yu4。
  迅疾的样子:悲风~起。 
臼(U+81FC) (U+26951)
* 臼:拼音jiu4
  ①舂米的器具,用石头或木头制成,中间凹下。
  ②形状像臼的:~齿。
*  :㈠ju2,①叉手。②同【匊】 
  ㈡ju3,同【举】 
鶇(U+9D87)鶫(U+9DAB)
* 鶇:【鸫】的繁体字。
* 鶫:拼音dong1
  <日本释义>
  読音tsugumi。斑鶇。秋天從北方南來的候鳥。
㖈(U+3588)䎛(U+439B)
* 㖈:【䎛】的同形重复字。
  地名用字。见**标准“信息交换用汉字编码字符集 第八辅助集”(SJ/T 11239-2001)
  <韩国释义>
  读音nom。音译字。家伙,小子。
  古文書所見奴婢名也。孩童美稱也。
* 䎛:拼音lao3
  象声词。与“㖈”似乎完全相同。
曶(U+66F6)㫚(U+3ADA)
* 曶:㈠拼音hu1。
  ①古通【忽】(a.迅速:“~如神。”b.忽略;忽视:“时人皆~之。”c.极微小的数量单位)。
  ②古同【旸】。
  ③古剑名。
  ㈡拼音hu4。
  古通【笏】。
* 㫚:同【昒】字。
  拼音hu1,hu4
  ㈠音呼。①通忽。②同昒。③古剑名。 
  ㈡音户。通笏。
朌(U+670C)肦(U+80A6)
* 朌:拼音ban1。
  ①颁赐;赋与。
  ②发布。
  <注>~从月,【肦】从肉。
* 肦:拼音fen2。
  ①头大的样子。
  ②众多。
朓(U+6713)脁(U+8101)
* 朓:拼音tiao3
  ①农历月底月亮出现在西方:“朒~警阙,朏魄示冲。”
  ②盈余:“盈者谓之~,不足者谓之朒。”
  <注>~从月,【脁】从肉。
* 脁:拼音tiao4。祭祀用的肉。
朘(U+6718)脧(U+8127)
* 朘:拼音juan1。
  ①缩;减少:“民日削月~,寖以大穷。”
  ②剥削:~民脂膏。
  <注>~从月,【脧】从肉。 
* 脧:㈠拼音juan1。汁少的肉羹。
  ㈡拼音zui1。男孩的shengzhiqi:“未知牝牡之合而~作。”
芉(U+8289)芊(U+828A)
* 芉:拼音gan3
  ①薏苡子。
  ②古同【稈】。
* 芊:拼音qian1
  ①“~~”草木茂盛。
  ②“~绵”草木茂盛。亦作“芊眠”。
蕌(U+854C)藠(U+85E0)
* 蕌:同【藟】字。
  拼音lei3
  ①藤:“南有樛木,葛~萦之。”
  ②缠绕:“网罟相萦~。”
  ③古通【蕾】,花苞:“梅~粉融连夜开。”
* 藠:拼音jiao4
  薤的别称:~子。~头。 

过程

采用 DBSCAN 算法。算法的流程如下:

和看相同部首字哪里

和看相同部首字哪里

(图片来源:cnblogs.com/aijianiula/p/4339960.html)

其实也没有那么复杂,所以自己用 C++ 实现:

>
	template >
	<>
	class >
	DataType>
	, >
	class >
	DistanceType>
	>
>
	static >
	std>
	::>
	pair >
	<>
	std>
	::>
	list >
	<>
	std>
	::>
	list >
	<>
	DataType>
	>>>
	, >
	std>
	::>
	list >
	<>
	DataType>
	>> >
	dbscan
>
	(
    >
	std>
	::>
	list >
	<>
	DataType>
	> >
	dataSet>
	,
    >
	std>
	::>
	function >
	<>
	DistanceType>
	(>
	DataType>
	, >
	DataType>
	)>
	> >
	dist>
	,
    >
	DistanceType >
	maxDist>
	,
    >
	size_t >
	minPts
>
	)
>
	{
    >
	std>
	::>
	list >
	<>
	std>
	::>
	list >
	<>
	DataType>
	>> >
	clusters>
	;
    >
	std>
	::>
	list >
	<>
	DataType>
	> >
	isolatedPoints>
	;
    >
	while>
	(>
	!>
	dataSet>
	.>
	empty>
	())
    >
	{
        >
	std>
	::>
	list >
	<>
	DataType>
	> >
	thisCluster>
	;
        >
	thisCluster>
	.>
	push_back>
	(>
	dataSet>
	.>
	front>
	());
        >
	dataSet>
	.>
	pop_front>
	();
        >
	for
        >
	(
            >
	typename >
	std>
	::>
	list >
	<>
	DataType>
	>::>
	iterator >
	thisClusterIter >
	= >
	thisCluster>
	.>
	begin>
	();
            >
	thisClusterIter >
	!= >
	thisCluster>
	.>
	end>
	();
            >
	++>
	thisClusterIter
        >
	)
        >
	{
            >
	for
            >
	(
                >
	typename >
	std>
	::>
	list >
	<>
	DataType>
	>::>
	iterator >
	dataSetIter >
	= >
	dataSet>
	.>
	begin>
	();
                >
	dataSetIter >
	!= >
	dataSet>
	.>
	end>
	();
                >
	/* do not need increment here */
            >
	)
            >
	{
                >
	if>
	(>
	dist>
	(>
	*>
	thisClusterIter>
	, >
	*>
	dataSetIter>
	) >
	<= >
	maxDist>
	)
                >
	{
                    >
	thisCluster>
	.>
	push_back>
	(>
	*>
	dataSetIter>
	);
                    >
	dataSetIter >
	= >
	dataSet>
	.>
	erase>
	(>
	dataSetIter>
	);
                >
	}
                >
	else
                >
	{
                    >
	++>
	dataSetIter>
	;
                >
	}
            >
	}
        >
	}
        >
	if>
	(>
	thisCluster>
	.>
	size>
	() >
	>= >
	minPts>
	)
            >
	clusters>
	.>
	push_back>
	(>
	thisCluster>
	);
        >
	else
            >
	isolatedPoints>
	.>
	splice>
	(>
	isolatedPoints>
	.>
	cend>
	(), >
	thisCluster>
	);
    >
	}
    >
	return >
	std>
	::>
	make_pair>
	(>
	clusters>
	, >
	isolatedPoints>
	);
>
	}

接下来需要数据集,首先找了两个字体:

然后用 FreeType 2 把字形转换成点阵。

在将字体中的字形转换为点阵的过程中,会指定一个字体大小(如 21),但是转换得到的点阵不一定是 21*21 的,例如「一」这个字的高度只有 1。上述的 21 是 EM Size,而实际大小可能会更大或更小,最大大小是转换前无法得知的,即转换得到的点阵大小是不定的。

但是,如果每个字的点阵的大小都是固定的,会使后续处理更加方便。所以,需要设置两个变量 maxWidth 和 maxHeight,分别记录最大宽度和最大高度。在后续处理时利用这两个变量,把所有点阵都扩大为大小一样的点阵。

扩大有两种方法,一种是居中,即原来的小点阵位于扩大了的点阵的正中间;一种是放在左上角。最初我使用居中的做法,后来觉得放在左上角也没有什么不好,而且使用居中的方法可能出现这样的问题:如果有两个字本来很相似,但一个偏左一些,一个偏右一些,就会出现错位的情况,而放在左上角则可以避免。

考虑了这么多,功能已经有点复杂了,需要自己用 C 语言实现:

#define chkerr(errcode, errstr) 
    do { if(errcode) { fputs(errstr, stderr); exit(0);} } while (0)
static inline void fPrintPixel(FILE *fp, unsigned char ch, unsigned int count)
{
    chkerr(count > 0x8, "Error: (In function printPixel) count should not greater than 0x8.n");
    unsigned char b = 0x80;
    while(count--)
    {
        if(ch & b) fputc('#', fp);
        else fputc('-', fp);
        b >>= 1;
    }
}
static void fPrintGlyph(FILE *fp, FT_Face face, FT_ULong encoding, unsigned int *maxWidth, unsigned int *maxHeight)
{
    FT_UInt char_index = FT_Get_Char_Index(face, encoding);
    if(char_index)
    {
        fprintf(fp, "%lxn", encoding);
        chkerr(FT_Load_Glyph(face, char_index, FT_LOAD_DEFAULT), "Error when loading glyph!n");
        chkerr(FT_Render_Glyph(face->glyph, FT_RENDER_MODE_MONO), "Error when rendering glyph!n");
        unsigned int rows = face->glyph->bitmap.rows;
        unsigned int width = face->glyph->bitmap.width;
        unsigned int widthMain = width/0x8;
        unsigned int widthTail = width%0x8;
        int pitch = face->glyph->bitmap.pitch;
        unsigned char (*buf)[pitch] = (unsigned char (*)[pitch])face->glyph->bitmap.buffer;
        unsigned int i, j;
        for(i = 0; i < rows; i++)
        {
            for(j = 0; j < widthMain; j++)
            {
                fPrintPixel(fp, buf[i][j], 0x8);
            }
            fPrintPixel(fp, buf[i][j], widthTail);
            fputc('n', fp);
        }
        if(width > *maxWidth) *maxWidth = width;
        if(rows > *maxHeight) *maxHeight = rows;
    }
}

接下来指定聚类算法的距离函数,这个就简单了。因为是点阵,可以直接利用汉明距离,也就是比较两个点阵有多少个点不同。

>
	struct >
	glyph
>
	{
    >
	unsigned >
	int >
	encoding>
	;
    >
	std>
	::>
	bitset >
	<>
	GLYPH_WIDTH>
	*>
	GLYPH_HEIGHT>
	> >
	data>
	;
>
	};
>
	static >
	unsigned >
	int >
	hammingDistance>
	(>
	glyph >
	a>
	, >
	glyph >
	b>
	)
>
	{
    >
	return >
	static_cast >
	<>
	unsigned >
	int>
	> >
	((>
	a>
	.>
	data >
	^ >
	b>
	.>
	data>
	).>
	count>
	());
>
	}

然后需要不断调整字的大小和距离阈值两个参数。在某些情况下,一些只有偏旁不同的口字旁、日字旁、目字旁的字会被聚成一类,这不是我们希望的结果。

另外,多尝试几种字体,可以得到更丰富的结果。

关于「凉(U+51C9)」和「凉(U+F979)」

最初选择尝试字形聚类,就是因为看到 @酱紫君 的答案有「凉(U+51C9)」和「凉(U+F979)」,感觉十分有趣。

但是,在我最后的结果中,是没有这一组结果的。其原因有二:

原因之一是,「凉(U+F979)」在 CJK 兼容区(U+F900 - U+FAFF),我把这个区注释掉了:

    for(FT_UInt i = 0x4e00; i <= 0x9fea; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Unified Ideographs
    for(FT_UInt i = 0x3400; i <= 0x4db5; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Unified Ideographs Extension A
    for(FT_UInt i = 0x20000; i <= 0x2a6d6; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Unified Ideographs Extension B
    for(FT_UInt i = 0x2a700; i <= 0x2b734; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Unified Ideographs Extension C
    for(FT_UInt i = 0x2b740; i <= 0x2b81d; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Unified Ideographs Extension D
    for(FT_UInt i = 0x2b820; i <= 0x2cea1; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Unified Ideographs Extension E
    for(FT_UInt i = 0x2ceb0; i <= 0x2ebe0; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Unified Ideographs Extension F
    /* for(FT_UInt i = 0xf900; i <= 0xfaff; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Compatibility Ideographs
    for(FT_UInt i = 0x2f800; i <= 0x2fa1f; i++)
        fPrintGlyph(fout, face, i, &maxWidth, &maxHeight);  // CJK Compatibility Ideographs Supplement */

这是因为,CJK 兼容区里基本上都是基本区已经存在的汉字,参见

为什么 Unicode 中会存在「凉」和「凉」这样两个极其相像的字符?​

所以,如果将这个区进行聚类,基本上整个区都可以与基本区的对应汉字聚为一类,这样的结果就没有意义了。

比如,用 FontForge 打开等距更纱黑体 SC,找到 CJK 兼容区,可以看到这样的情况:

和看相同部首字哪里

和看相同部首字哪里

基本上都是基本区已有的字。

不过,在 @酱紫君 的回答中,却只有少数几组这样的字和看相同部首字哪里,没有出现预期的异常现象,由于在该回答中使用的字体是微软雅黑,我们用 FontForge 打开微软雅黑,找到 CJK 兼容区:

和看相同部首字哪里

和看相同部首字哪里

可以看到微软雅黑在 CJK 兼容区只有少数几个字的字形,其他字是不存在的,自然也就无法被聚类了。

但是,即使不将这个区注释掉,也是得不到「凉(U+51C9)」和「凉(U+F979)」这一组结果的。这是原因之二:「凉(U+51C9)」和「凉(U+F979)」这两个字符的字形,在我所使用的字体中本来就不相似。

查看将等距更纱黑体 SC 的字形转换为点阵的结果:

51c9
---------#------
##-------##-----
-#--############
-##-------------
--##-#########--
-----##------#--
-----##------#--
-----##------#--
-----#########--
--##-----##-----
--#---#--##-##--
-##--##--##--#--
-#---#---##--##-
##--##---##---##
#---#----##---#-
-------###------
f979
---------##-----
#--------##-----
-##-############
--##------------
--#-------------
------#########-
------#------##-
------#------##-
---#--#------##-
--##--#########-
-##------##-----
-#----##-##-##--
##---##--##--##-
#---##---##---##
---##----##----#
-------####-----

在转换后自动生成的 attributes.h 中,查看到参数 GLYPH_WIDTH = 18,GLYPH_HEIGHT = 17。

计算这两个字的距离时,会将这两个字转换为 18*17 的点阵,经过转换后的结果如下:

51c9: 000000000100000000110000000110000000010011111111111100011000000000000000001101111111110000000001100000010000000001100000010000000001100000010000000001111111110000001100000110000000001000100110110000011001100110010000010001000110011000110011000110001100100010000110001000000000011100000000000000000000000000

f979:

000000000110000000100000000110000000011011111111111100001100000000000000001000000000000000000000111111111000000000100000011000000000100000011000000100100000011000001100111111111000011000000110000000010000110110110000110001100110011000100011000110001100000110000110000100000000011110000000000000000000000000

经过比较可知,这两个字形的距离达到了 59,大大超过了设定的距离阈值 8,所以不会被聚为一类。

读过此篇文章的网友还读过: