-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathatom.xml
More file actions
2011 lines (1888 loc) · 110 KB
/
atom.xml
File metadata and controls
2011 lines (1888 loc) · 110 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>https://hualiang.fun</id>
<title>Hualiang's Blog</title>
<updated>2026-04-16T18:06:49.993Z</updated>
<generator>https://github.com/jpmonette/feed</generator>
<link rel="alternate" href="https://hualiang.fun"/>
<link rel="self" href="https://hualiang.fun/atom.xml"/>
<subtitle><i>Unless I don't want to win, nobody can make me lose.</i><br/><br/>I always believe 天道酬勤</subtitle>
<logo>https://hualiang.fun/images/avatar.png</logo>
<icon>https://hualiang.fun/favicon.ico</icon>
<rights>All rights reserved 2026, Hualiang's Blog</rights>
<entry>
<title type="html"><![CDATA[适用于 Succinct Trie 的一种“路径压缩”的优化思路]]></title>
<id>https://hualiang.fun/post/gua-yong-yu-succinct-trie-de-yi-chong-lu-jing-ya-suo-de-shi-xian-fang-shi/</id>
<link href="https://hualiang.fun/post/gua-yong-yu-succinct-trie-de-yi-chong-lu-jing-ya-suo-de-shi-xian-fang-shi/">
</link>
<updated>2025-11-04T14:19:46.000Z</updated>
<summary type="html"><![CDATA[<p>目前实现的 Succinct Trie 结构,对于<strong>长字符串</strong>(UUID)场景<strong>查询性能较差</strong>,仅有 FSA 的一半。因此这次我将介绍一种自己琢磨出来的优化思路,关于 Succinct Trie 是如何实现“<strong>路径压缩</strong>”来加速搜索的。</p>
]]></summary>
<content type="html"><![CDATA[<p>目前实现的 Succinct Trie 结构,对于<strong>长字符串</strong>(UUID)场景<strong>查询性能较差</strong>,仅有 FSA 的一半。因此这次我将介绍一种自己琢磨出来的优化思路,关于 Succinct Trie 是如何实现“<strong>路径压缩</strong>”来加速搜索的。</p>
<!-- more -->
<p><em>p.s. 不了解 <strong>Succinct Trie</strong> 的话,可以去看这篇博客《<a href="/post/java-shi-xian-ji-yu-louds-bian-ma-de-ya-suo-qian-zhui-shu/">一种基于 LOUDS 编码的压缩字典树</a>》</em></p>
<h2 id="现状分析">现状分析</h2>
<p>目前,Succinct Trie 基于 LOUDS 编码以 BFS 的顺序编码整棵树。对于查询一个字符串是否存在(正向搜索),每成功匹配一个字符后都需要调用 <code>select1()</code> 方法计算子节点位置才能向下层转移,而 <code>select1()</code> 方法是一个比较耗时的操作。如果要查询的平均字符串较长,那么查询单个字符串就会耗费更多的时间。这也是为什么在长字符串场景,它的性能会很差的原因。</p>
<p>Succinct Trie 本质上也是 Trie 树,参考普通 Trie 树的构造情况,只压缩前缀的话,对于那些重复率低的字符串,可能会形成大量的<strong>又深又长的单链</strong>,如下图的极端情况。对于目前的简洁树来说,这些单链既没法通过压缩相同字符来减少空间,又需要多次调用 <code>select1()</code> 方法来向下层转移匹配。比如输入“abcde”,就要调用 5 次<code>select1()</code> 方法来转移状态。如果用来存存储 UUID,不算连字符就有 32 个字符,耗费的时间会更多...</p>
<figure data-type="image" tabindex="1"><img src="https://hualiang.fun/post-images/1765196310310.png" alt="1" loading="lazy"></figure>
<p><em>注:图中的字母应该放在其所在节点的扇入“边”上比较合适,这里为了好画放在节点内</em></p>
<h2 id="优化思路">优化思路</h2>
<p>那么,普通的 Tire 树如何处理这种存在大量<strong>又深又长的单链</strong>的情况 —— <strong>路径压缩</strong>。</p>
<p>这种方法通过将只有一个子节点的链状节点合并,<strong>使得一条边可以存储多个字符</strong>,从而减少树的深度和节点数量。这种方法详细各位已经耳熟能详了,不再赘述它的实现原理。</p>
<p>我们此处重点关注的不是它压缩了空间,而是关注它提升了查询性能。之所以能加速查询,根本上是<strong>减少了向下转移的次数</strong>,通过将节点压缩,使得一次转移就能将单链上的字符全部获取从而快速匹配。</p>
<p>那我们是否也能借鉴这种压缩节点的方法来<strong>减少转移次数</strong>呢?</p>
<p>直接给出我的结论:<strong>压缩节点不可照搬</strong>。因为普通 Tire 树的节点一般是以一个对象为单位,想要往一个节点对象里面多存点字符只需要将 <code>char</code> 类型的 value 字段改成 <code>String</code> 类型即可。但简洁 Trie 的节点是以数组元素为单位来节省对象头开销,是额外使用一个 <code>char[]</code> 数组来存字符,并通过计算获取对应下标。对于数组来说,每个元素的空间都固定,不可以往一个槽位里额外添加别的字符(如果你打算用 <code>char[][]</code>,那样会引入数组对象的头部开销)</p>
<p>那么如何解决?不妨换个思路,我们不要关注如何压缩节点,而是关注一些本质的东西:<strong>如何在当前节点不调用 <code>select1()</code> 方法向下转移的情况下,获取该节点下方单链上的所有字符?</strong></p>
<p>“节点压缩”通过将下方单链上的字符全都塞入当前节点中,来实现这个目的。那我们还能用什么办法来达到这个目的呢?</p>
<p>不卖关子了,这里给出我的一种思路:<strong>通过将下方的单链节点全部转换成当前节点的兄弟节点</strong>。从普通 Trie 树的角度来看很奇怪,但从以 BFS 遍历的方式将每层节点的字符存入 <code>char[]</code> 数组的 Succinct Trie 来说就太适合了,如下图所示。</p>
<figure data-type="image" tabindex="2"><img src="https://hualiang.fun/post-images/1765196310311.png" alt="2" loading="lazy"></figure>
<p>对于匹配 <code>xyabcz</code> 这个字符串</p>
<ul>
<li>第一颗树的匹配流程是:从 <code>X</code> 开始,向下转移 5 次完成匹配,期间需要调用 5 次 <code>select1()</code> 来计算出下一个字符所在的下标从而实现转移</li>
<li>第二颗树的匹配流程是:从 <code>X</code> 开始,向下转移一次到 <code>Y</code>,发现该节点为压缩节点,那么说明该节点的子节点是由原先一条单链转换成的。那么匹配这条单链只需要先向下转移到第一个 <code>a</code> 节点,然后基于 <code>a</code> 节点的下标不断<strong>自增</strong>就能获取原先单链上的所有字符,由于以 BFS 的顺序存储字符数组,因此兄弟节点字符的下标一定与当前节点在数组中相邻。至于如何判断是否将压缩节点的子节点都遍历完了,只需要检查当前节点是否有子节点即可。整个过程只需调用 3 次 <code>select1()</code>,单链越长,省去的调用越多。</li>
</ul>
<p>这个思路只适用于用 BFS 顺序额外数组存储字符的 Succinct Trie。因为需要额外付出小部分的空间来标记“压缩节点”,但实际上<strong>压缩的只有路径,也就是树高,节点本身的占用和总数都没变</strong></p>
<h2 id="总结">总结</h2>
<p>本篇文章旨在提供一个优化思路,具体的代码实现我已经实现验证过了,但由于引入了第三方依赖,没法直接将单文件放上来就能运行,所以这回就不放代码了。</p>
<p>给出一些<strong>简略</strong>的对比测试结果仅供参考,数据是随机生成的 <strong>100 万</strong>无序英文字符串:</p>
<ul>
<li><strong>长度 6 - 10 的短字符串</strong>:分词场景,查询性能提升 <strong>46%</strong>,存储空间增加 <strong>7%</strong></li>
<li><strong>长度 32 的长字符串</strong>:UUID 场景,查询性能提升 <strong>182%</strong>,存储空间增加 <strong>8%</strong></li>
</ul>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[一种基于 LOUDS 编码的压缩字典树]]></title>
<id>https://hualiang.fun/post/java-shi-xian-ji-yu-louds-bian-ma-de-ya-suo-qian-zhui-shu/</id>
<link href="https://hualiang.fun/post/java-shi-xian-ji-yu-louds-bian-ma-de-ya-suo-qian-zhui-shu/">
</link>
<updated>2025-08-27T15:54:39.000Z</updated>
<summary type="html"><![CDATA[<p>最近在学习 ElasticSearch 的 <strong>FST</strong> 索引结构发现,它的底层实现结构依然有<strong>对象头</strong>和<strong>指针</strong>的开销,如果能把这部分开销去掉,压缩率还能再上一层楼。于是经过调研发现一类叫“<strong>简洁数据结构</strong>”的结构,实现了<strong>占用极小</strong>且<strong>查询高效</strong>。遂深入探索,于是就有了这篇文章。</p>
]]></summary>
<content type="html"><![CDATA[<p>最近在学习 ElasticSearch 的 <strong>FST</strong> 索引结构发现,它的底层实现结构依然有<strong>对象头</strong>和<strong>指针</strong>的开销,如果能把这部分开销去掉,压缩率还能再上一层楼。于是经过调研发现一类叫“<strong>简洁数据结构</strong>”的结构,实现了<strong>占用极小</strong>且<strong>查询高效</strong>。遂深入探索,于是就有了这篇文章。</p>
<!-- more -->
<p><em>p.s. 部分参考图和代码源自《<a href="https://blog.csdn.net/u014470403/article/details/147883426">基于 LOUDS 的 Succinct Set 详解</a>》</em></p>
<h2 id="简介">简介</h2>
<p><strong>什么是 Succinct 数据结构?</strong></p>
<p>简洁数据结构(Succinct Data Structures)是指一类在存储数据时接近信息熵下界,同时保持高效查询性能的数据结构,也就是说一个结构表示的所占空间<strong>接近</strong>信息熵下限,那么就可以称其为“简洁的”。其核心原理就是使用 Bitmap 来表示数据结构以节省大量空间。</p>
<p>Succinct 思想常被应用于对<strong>列表</strong>和<strong>树</strong>的存储以压缩空间,也就是 “Succinct Vector” 和 “<strong>Succinct Tree</strong>”</p>
<p>一些常见的简洁数据结构实现包括:</p>
<ul>
<li><strong>小波树</strong>:在压缩的序列上支持丰富的<strong>序列查询</strong>操作,如第k小查询,区间统计</li>
<li><strong>FM-Index</strong>:在一个压缩后的文本索引中,极快地查找一个模式串 P 出现的所有位置(全文索引)</li>
<li><strong>简洁树</strong>:用极小的空间存储<strong>树结构</strong>,并支持高效的基础<strong>导航操作</strong></li>
<li><strong>简洁图</strong>:基于<strong>邻接表</strong>的简洁表示,以极小空间占用支持图的各种基本操作</li>
<li><strong>简洁向量</strong>:就是简洁数组,比一般的基本数据类型数组还要小</li>
</ul>
<h2 id="原理">原理</h2>
<blockquote>
<p><strong>Succinct Trie</strong> = Succinct Tree + Trie Label</p>
</blockquote>
<p>因为我探索的主要是如何让 FST 更省空间,FST 本质上也是个 Trie 树,就打算从 Trie 出发,结合上文简介中说到的 “Succinct Tree”,探索能否将 Trie 树和 Succinct Tree 结合在一起。当然,最终结果当然是可以的,已经有很多篇文章实现了。因此接下来我将从核心原理开始讲起,一步步去实现一个占用极小空间又能高效查询的 <strong>Succinct Trie</strong></p>
<h3 id="编码">编码</h3>
<p>结合简洁数据结构的核心思想,我们不难推测:Succinct Trie 核心原理是使用 <strong>Bitmap</strong> 来表示 Trie 树结构,因此我们先要将<strong>树结构</strong>编码成 <strong>bit 序列</strong></p>
<p>目前有两种主流编码方式,DFOUS 支持更多的功能,LOUDS 有更快的性能:</p>
<ul>
<li><strong>LOUDS</strong>:按 BFS 遍历树,对于每个节点,用一元编码(一串 1 加一个 0)表示它的度数。适用于<strong>度数大、深度浅</strong>的树,如 <strong>Trie</strong> <strong>树</strong></li>
<li><strong>DFUDS</strong>:按 DFS 遍历树,对每个有 d 个子节点的节点,用一个 “<strong>(</strong>” 和 d 个 “<strong>)</strong>” 来表示,但整个序列用额外的 “<strong>(</strong>“ 开头以保证平衡。可以理解为特殊的括号序列,编码时 “<strong>(</strong>” 为 1,“<strong>)</strong>” 为 0。适用于要深度优先遍历的场景,如<strong>语法分析树、DOM 树</strong></li>
</ul>
<p>因为我们的目标在 Trie 树上,所以我们这里主要探讨 LOUDS 编码方式</p>
<hr>
<p><strong>什么是 LOUDS 编码?</strong></p>
<p><strong>L</strong>evel-<strong>O</strong>rder <strong>U</strong>nary <strong>D</strong>egree <strong>S</strong>equence,层序一元度序列,简称 LOUDS。听起来高大上,实际内容非常简单</p>
<p>以下图这一颗 Trie 树为例,对于每个节点,用 <code>0</code> 表示子节点,<code>1</code> 表示结束。比如,节点 1 有两个子节点,那么就表示成 <code>001</code>。 最终按照 BFS 顺序把所有编码排列一起就是这棵树的 LOUDS 编码</p>
<p>综上,该树的 LOUDS 编码为 <code>001 001 01 01 01 01 01 1 1 1</code></p>
<figure data-type="image" tabindex="1"><img src="https://hualiang.fun/post-images/1757527679745.png" alt="图一" loading="lazy"></figure>
<p><em>注 1:节点标签,即图中节点间边上的那些字符,本身并不属于树结构的一部分,所以需要另外使用一个数组 <code>labels</code> 存储</em></p>
<p><em>注 2:原论文中的标准形式应该是用 <strong>1</strong> 表示子节点,<strong>0</strong> 表示结束</em></p>
<h3 id="导航">导航</h3>
<p>现在我们把一个 Trie 树压成一串 bit 序列装进位图里,那么我们现在如何从这一坨序列中还原出 Trie 树呢?或者说我们如何在这一坨被压缩的 Trie 树中进行导航操作?</p>
<p>这时候就需要额外定义<strong>四个辅助方法</strong>来操作:</p>
<p><code>rank1(pos)</code>:返回位图 <code>[0, pos]</code> 下标范围内 <code>1</code> 的个数;<code>rank0</code> 则是 <code>0</code> 的个数</p>
<p><code>select1(k)</code>:返回位图里第 <code>k</code> 个 <code>1</code> 的下标位置;<code>select0</code> 则是 <code>0</code> 的位置</p>
<p>实际进行导航中会频繁调用这几个方法,因此为了提高性能,我们会<strong>预计算</strong>这些方法的值</p>
<hr>
<p>我们用上文图一的 Trie 树来展示如何通过这四个方法来在树节点之间随意转移</p>
<figure data-type="image" tabindex="2"><img src="https://hualiang.fun/post-images/1757527757315.png" alt="图二" loading="lazy"></figure>
<p>上图是该 Trie 树经过 LOUDS 编码出的 <strong>bit 序列与其源节点的对应关系</strong>,可以帮助理解</p>
<p>以<strong>节点</strong> <strong>1</strong> 为例,它的子节点是 <strong>3</strong>、<strong>4</strong>,父节点是 <strong>0</strong>,那么:</p>
<ul>
<li>节点 1 起始 bit 位置 = <code>select1(1)</code> + <code>1</code> = <code>3</code></li>
<li>节点 1 第一子节点编号 = <code>rank0(3 + 0)</code> = <code>3</code></li>
<li>节点 1 第二子节点编号 = <code>rank0(3 + 1)</code> = <code>4</code></li>
<li>节点 1 的父节点起始 bit 位置 = <code>select0(1)</code> = <code>0</code>,父节点编号 = <code>rank1(select0(1))</code> = <code>rank1(0)</code> = <code>0</code></li>
<li>如果知道<strong>当前节点编码</strong>和<strong>当前 bit 位置</strong>,那么<strong>当前节点标签</strong> = <code>labels[index - nodeId]</code></li>
</ul>
<p>以上操作说明如何从节点 1 向上(回溯)和向下(递归)两个方向的导航,并且展示了当获取到<strong>当前节点编码</strong>和<strong>当前 bit 位置</strong>,如何获取<strong>当前节点标签</strong>。既然上下节点都能遍历,那遍历整棵树自然不在话下</p>
<h2 id="实现">实现</h2>
<p>我们在上文已经简单讲解了 Succinct Trie 的压缩方式(编码)和遍历方法(导航),说白了就是通过一种精心设计过的编码方式将树型结构的节点信息存储到 bit 序列中,然后通过四个辅助方法提取这些信息直接计算出原始信息,理解后其实也没那么难</p>
<p>下面是用 Java 简单实现的一个基于 LOUDS 编码的静态压缩 Trie 树,按照简洁数据结构的分类,可以被称为 <strong>Succinct Trie</strong>。该实现无第三方依赖,创建后不可更改结构,理论上支持各种前缀树特性,目前只实现一些主要功能:</p>
<ul>
<li><code>boolean contains(String key)</code>:判断 key 是否存在</li>
<li><code>int index(String key)</code>:若 key 存在,则会返回内部对应的节点 ID;否则,返回 -1</li>
<li><code>String get(int nodeId)</code>:通过给定节点 ID 反向搜索 key</li>
<li><code>Iterator<String> iterator(boolean orderly)</code>:以字典序或层序的顺序遍历 Trie 中所有的 key</li>
<li><code>Iterator<String> prefixKeysOf(String str)</code>:查询给定字符串在 Trie 内所有的前缀</li>
<li><code>Iterator<String> prefixSearch(String prefix)</code>:查询所有以给定前缀开头的 key</li>
</ul>
<pre><code class="language-java">import java.nio.CharBuffer;
import java.util.*;
public class SuccinctTrie {
private final char[] labels; // 存储 Trie 树的字符标签
private final BitVector labelBitmap; // 存储 LOUDS 编码的位向量
private final BitVector isLeaf; // 存储所有叶子节点标记的位向量
public static SuccinctTrie of(String... keys) {
return new SuccinctTrie(keys);
}
private SuccinctTrie(String[] keys) {
for (int i = 1; i < keys.length; i++) {
assert keys[i].compareTo(keys[i - 1]) >= 0 : "The inputs are not ordered!";
}
List<Character> labelsList = new ArrayList<>();
BitVector.Builder labelBitmapBuilder = new BitVector.Builder();
BitVector.Builder isLeafBuilder = new BitVector.Builder();
Queue<Range> queue = new ArrayDeque<>();
queue.add(new Range(0, keys.length, 0));
int bitPos = 0, nodeId = 0;
while (!queue.isEmpty()) {
Range range = queue.poll();
int L = range.L, R = range.R, index = range.index;
isLeafBuilder.set(nodeId, keys[L].length() == index);
// 处理子节点
int start = L;
while (start < R) {
// 跳过长度不足的键
if (keys[start].length() <= index) {
start++;
continue;
}
char currentChar = keys[start].charAt(index);
int end = start + 1;
while (end < R) {
if (keys[end].length() <= index || keys[end].charAt(index) != currentChar) {
break;
}
end++;
}
// 添加子节点标签
labelsList.add(currentChar);
// 设置子节点标记(0)
// labelBitmapBuilder.set(bitPos, false);
bitPos++;
// 将子节点范围加入队列
queue.add(new Range(start, end, index + 1));
start = end;
}
// 设置节点结束标记(1)
labelBitmapBuilder.set(bitPos++, true);
nodeId++;
}
// 转换并初始化位图
this.labels = new char[labelsList.size()];
for (int i = 0; i < labelsList.size(); i++) {
labels[i] = labelsList.get(i);
}
this.labelBitmap = labelBitmapBuilder.build(true);
this.isLeaf = isLeafBuilder.build(false);
}
/**
* 存储的 key 的个数
*/
public int size() {
return isLeaf.oneCount;
}
/**
* 该 Trie 树的节点个数
*/
public int nodeCount() {
return isLeaf.size;
}
/**
* 判断 key 是否存在
*
* @param key 要查询的键值
* @return 是否存在
*/
public boolean contains(String key) {
return index(key) >= 0;
}
/**
* 精确查询给定 key 在内部唯一对应的节点 ID
*
* @param key 要查询的 key
* @return 如果 key 存在,则返回对应的节点 ID;否则,返回 -1
*/
public int index(String key) {
int nodeId = extract(key);
return nodeId >= 0 && isLeaf.get(nodeId) ? nodeId : -1;
}
/**
* 反向查询给定节点 ID 在内部唯一对应的 key
*
* @param nodeId 要查询的节点 ID
* @return 如果节点 ID 在合法范围内,则返回对应的 key;否则,返回 null
*/
public String get(int nodeId) {
if (isLeaf.get(nodeId)) {
StringBuilder str = new StringBuilder();
int bitmapIndex;
while ((bitmapIndex = labelBitmap.select0(nodeId)) >= 0) {
nodeId = labelBitmap.rank1(bitmapIndex);
str.append(labels[bitmapIndex - nodeId]);
}
return str.reverse().toString();
}
return null;
}
/**
* <p>以字典序或层序的方式遍历 Trie 中所有的 key</p>
* <b>注意</b>:层序遍历的性能要优于字典序遍历,如果不追求有序,请将 {@code orderly} 设为 false 以获得最佳性能
*
* @param orderly 如果为 true,则按(DFS)字典序遍历;如果为 false,则按层序遍历。
* @return 一个用于遍历所有 key 的迭代器
*/
public Iterator<String> iterator(boolean orderly) {
if (orderly) {
return traverse(0, "");
} else {
return new Iterator<>() {
private int index = isLeaf.nextSetBit(0);
@Override
public boolean hasNext() {
return index >= 0;
}
@Override
public String next() {
String str = get(index);
index = isLeaf.nextSetBit(index + 1);
return str;
}
};
}
}
/**
* 查询给定字符串在 Trie 内所有的前缀
*
* @param str 要查询的字符串
* @return 一个用于遍历所有前缀的迭代器
*/
public Iterator<String> prefixKeysOf(String str) {
return new TermIterator() {
private final char[] chars = str.toCharArray();
private int pos = 0;
private int nodeId = 0;
private int bitmapIndex = 0;
{
advance(); // 初始化查找第一个前缀
}
@Override
protected void advance() {
while (pos < chars.length) {
int index = labelSearch(nodeId, bitmapIndex, chars[pos]);
if (index < 0) {
break;
}
nodeId = index + 1 - nodeId;
bitmapIndex = labelBitmap.select1(nodeId) + 1;
pos++;
if (isLeaf.get(nodeId)) {
next = new String(chars, 0, pos);
return;
}
}
next = null;
}
};
}
/**
* 查询所有以给定前缀开头的 key
*
* @param prefix 要搜索的前缀
* @return 一个用于遍历所有匹配前缀的 key 的迭代器
*/
public Iterator<String> prefixSearch(String prefix) {
return traverse(extract(prefix), prefix);
}
private int extract(String key) {
int nodeId = 0, bitmapIndex = 0;
for (char c : key.toCharArray()) {
if ((bitmapIndex = labelSearch(nodeId, bitmapIndex, c)) < 0) {
return -1;
}
// 向子节点转移
nodeId = bitmapIndex + 1 - nodeId;
bitmapIndex = labelBitmap.select1(nodeId) + 1;
}
return nodeId;
}
private Iterator<String> traverse(int rootId, String prefix) {
return new TermIterator() {
private final CharBuffer charBuffer = CharBuffer.allocate(256);
private int nodeId = rootId;
private int bitmapIndex = rootId < 0 ? labelBitmap.size : labelBitmap.select1(rootId) + 1;
{
charBuffer.append(prefix);
charBuffer.flip();
if (!isLeaf.get(rootId)) {
advance();
}
}
@Override
protected void advance() {
// 切换写模式
charBuffer.position(charBuffer.limit());
charBuffer.limit(charBuffer.capacity());
while (true) {
// 撞墙
while (bitmapIndex >= labelBitmap.size || labelBitmap.get(bitmapIndex)) {
// 到达根节点,遍历结束
if (nodeId == rootId) {
next = null;
return;
}
// 回溯并向右转移
bitmapIndex = labelBitmap.select0(nodeId) + 1;
nodeId = bitmapIndex - nodeId;
charBuffer.position(charBuffer.position() - 1);
}
charBuffer.put(labels[bitmapIndex - nodeId]);
// 向下转移
nodeId = bitmapIndex + 1 - nodeId;
bitmapIndex = labelBitmap.select1(nodeId) + 1;
if (isLeaf.get(nodeId)) {
charBuffer.flip();
next = charBuffer.toString();
return;
}
}
}
};
}
/**
* 搜索标签向下层转移
*
* @param nodeId 当前节点ID
* @param bitmapIndex 当前节点在 {@code labelBitmap} 中的起始下标
* @param b 要搜索的标签
* @return 目标标签在 {@code labelBitmap} 中的下标,否则返回 -1
*/
private int labelSearch(int nodeId, int bitmapIndex, char b) {
while (true) {
if (bitmapIndex >= labelBitmap.size || labelBitmap.get(bitmapIndex)) {
return -1;
}
int labelIndex = bitmapIndex - nodeId;
if (labelIndex < labels.length && labels[labelIndex] == b) {
break;
}
bitmapIndex++;
}
return bitmapIndex;
}
// 辅助类:表示键范围
private record Range(int L, int R, int index) {
}
// 词项迭代器
private abstract static class TermIterator implements Iterator<String> {
String next = "";
@Override
public boolean hasNext() {
return next != null;
}
@Override
public String next() {
if (next == null) {
throw new NoSuchElementException();
}
String term = next;
advance();
return term;
}
abstract void advance();
}
// 自实现位向量(位图)
public static class BitVector {
/**
* 数值越小,selects 预计算的间距越小,占用更高,select1 的性能越好
* 经测试,设为 1 或 2 时,性能提升明显,但占用极高,其余数值影响不大
*/
private static final int GAP = 64;
private final long[] bits;
private final int[] ranks; // 预计算rank1
private final int[] selects; // 部分预计算select1
public final int oneCount;
public final int size;
// 构建器模式
public static class Builder {
private final List<Long> bits = new ArrayList<>();
private int size = 0;
private int count = 0;
public void set(int position, boolean value) {
ensureCapacity(position);
int block = position >> 6;
int offset = position & 0x3F;
long mask = 1L << offset;
long oldBlock = bits.get(block);
if (value) {
bits.set(block, oldBlock | mask); // 设置位为1
} else {
bits.set(block, oldBlock & ~mask); // 设置位为0
}
// 仅当位的值实际发生变化时更新计数器
if ((oldBlock & mask) == 0 == value) {
count += value ? 1 : 0;
}
}
private void ensureCapacity(int position) {
int requiredBlocks = (position >> 6) + 1;
while (bits.size() < requiredBlocks) {
bits.add(0L);
}
size = Math.max(size, position + 1);
}
public BitVector build(boolean rankSelect) {
long[] array = new long[bits.size()];
for (int i = 0; i < bits.size(); i++) {
array[i] = bits.get(i);
}
return new BitVector(array, size, count, rankSelect);
}
}
private BitVector(long[] bits, int size, int count, boolean rankSelect) {
this.bits = bits;
this.size = size;
this.oneCount = count;
// 预计算rank和select
if (rankSelect) {
int totalOnes = 0;
int oneCount = 0;
this.ranks = new int[bits.length + 1];
List<Integer> selectList = new ArrayList<>();
for (int i = 0; i < bits.length; i++) {
ranks[i] = totalOnes;
int blockOnes = Long.bitCount(bits[i]);
totalOnes += blockOnes;
long block = bits[i];
for (int j = 0; j < 64; j++) {
if ((block & (1L << j)) != 0) {
oneCount++;
if (oneCount % GAP == 0) {
selectList.add(i * 64 + j);
}
}
}
}
ranks[bits.length] = totalOnes;
this.selects = new int[selectList.size()];
for (int i = 0; i < selectList.size(); i++) {
selects[i] = selectList.get(i);
}
} else {
this.ranks = null;
this.selects = null;
}
}
public int nextSetBit(int from) {
if (from < 0 || from >= size) {
return -1;
}
int u = from >> 6;
long word;
for (word = this.bits[u] & -1L << from; word == 0L; word = this.bits[u]) {
if (++u == bits.length) {
return -1;
}
}
return (u << 6) + Long.numberOfTrailingZeros(word);
}
public boolean get(int pos) {
if (pos >= size) return false;
int block = pos >> 6;
int offset = pos & 0x3F;
return (bits[block] & (1L << offset)) != 0;
}
public int rank1(int pos) {
if (pos < 0 || pos >= size) {
return 0;
}
int block = pos + 1 >> 6;
int offset = pos + 1 & 0x3F;
int count = ranks[block];
if (offset > 0) {
long mask = (1L << offset) - 1;
count += Long.bitCount(bits[block] & mask);
}
return count;
}
// 性能较差
public int select1(int k) {
if (k <= 0 || k > ranks[bits.length]) {
return -1;
}
// 使用预计算的select加速
if (k % GAP == 0) {
int idx = k / GAP - 1;
if (idx < selects.length)
return selects[idx];
}
// 二分查找块
int low = 0, high = ranks.length - 1;
while (low < high) {
int mid = low + high >>> 1;
if (ranks[mid] < k) {
low = mid + 1;
} else {
high = mid;
}
}
int block = low - 1;
// 在块内查找
int remaining = k - ranks[block];
long word = bits[block];
for (int i = 0; i < 64; i++) {
if ((word & (1L << i)) != 0) {
if (--remaining == 0) {
return block * 64 + i;
}
}
}
return -1;
}
public int rank0(int pos) {
if (pos >= size) {
pos = size - 1;
}
return pos + 1 - rank1(pos);
}
// 性能极差
public int select0(int k) {
if (k <= 0 || k > ranks[bits.length]) {
return -1;
}
int low = 0, high = size - 1;
while (low <= high) {
int mid = (low + high) >>> 1;
// 计算[0, mid]区间内的0的个数
if (rank0(mid) < k) {
low = mid + 1;
} else {
high = mid - 1;
}
}
// 满足rank0(low) >= k的最小位置即第k个0的位置
return low;
}
}
}
</code></pre>
<p>没有全面的测试,简单测试大致结果如下,测试很粗糙,仅供参考:</p>
<ul>
<li><strong>中文 key</strong>:内存占用比 FSA 小 <strong>40%</strong> 左右,查询性能比 FSA 要低 <strong>76%</strong></li>
<li><strong>英文 key</strong>:内存占用比 FSA 大 <strong>8.8%</strong> 左右,查询性能比 FSA 要低 <strong>64%</strong>(英文字符在 char 类型也是用 2 字节存,比较吃亏)</li>
</ul>
<p>因为是最简实现,没有进行任何优化,但确实能看出在内存方面有一定优势,后续引入第三方库(Sux4J)优化,表现肯定会强上不少,有很大的发展潜力</p>
<p><em>注:FSA 是没有输出 FST,即不存 Value 的 FST,因为 Succinct Trie 只能存 Key,相当于 Set 集合,直接跟存储键值对的 FST 对比不合适</em></p>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[关于我脑洞大开去用多线程优化快速排序这件事]]></title>
<id>https://hualiang.fun/post/guan-yu-nao-dong-da-kai-qu-yong-duo-xian-cheng-you-hua-kuai-su-pai-xu-zhe-jian-shi/</id>
<link href="https://hualiang.fun/post/guan-yu-nao-dong-da-kai-qu-yong-duo-xian-cheng-you-hua-kuai-su-pai-xu-zhe-jian-shi/">
</link>
<updated>2024-10-25T14:50:34.000Z</updated>
<summary type="html"><![CDATA[<p>今晚在复习 TopK 问题手写快排时,突发奇想:“既然快排每次都要划分出两个区间重复进行快排,那么我可不可以将新划分出的两个区间用两个新线程去跑 ? ” 于是就有了这篇文章。</p>
]]></summary>
<content type="html"><![CDATA[<p>今晚在复习 TopK 问题手写快排时,突发奇想:“既然快排每次都要划分出两个区间重复进行快排,那么我可不可以将新划分出的两个区间用两个新线程去跑 ? ” 于是就有了这篇文章。</p>
<!-- more -->
<h1 id="初次尝试">初次尝试</h1>
<p>如果每次划分新区间都开线程跑,那最后的线程数肯定会爆炸式增长,所以我首先想到用线程池去跑。在线程池中,多余的任务放在阻塞队列执行,保证最大执行线程数不超过 CPU 核心数。既能最大利用 CPU 多核,又不至于让线程数溢出,一举两得。</p>
<p>理论可行,开始实践!</p>
<h1 id="代码">代码</h1>
<p>下面是一个原生的快排,我的写法可能跟常规写法不一样,不过效率是一样的:</p>
<pre><code class="language-java">public static void quickSort(int nums, int l, int r) {
if (l >= r) return;
int x = nums[i], i = l, j = r + 1;
while (i < j) {
while (nums[--j] > x && i != j);
if (i == j) nums[j] = x;
else {
nums[i] = nums[j];
while (nums[++i] < x && i != j);
if (i == j) nums[i] = x;
else nums[j] = nums[i];
}
}
quickSort(nums, l, j - 1);
quickSort(nums, j + 1, r);
}
</code></pre>
<p>那如何将线程池用到快排里去呢?其实用栈实现迭代写法会更易于理解。</p>
<p>这里线程池也起到了一个存储任务的作用,即任务队列。每次对区间进行划分后,将划分的区间的左右位置存到队列中,留到之后执行,类似于迭代法中的栈。不过线程池的好处就是,它会自动执行,而不需要我们通过循环去取任务执行。</p>
<p>那么先写一个线程需要执行的方法,我们不需要返回值,所以实现 Runnable,如下:</p>
<pre><code class="language-java">class Task implements Runnable {
private int left;
private int right;
private int[] nums;
private AtomicInteger count;
private ExecutorService executor;
// 传参
public Task(int left, int right, int[] nums, AtomicInteger count, ExecutorService executor) {
this.left = left;
this.right = right;
this.nums = nums;
this.count = count;
this.executor = executor;
}
@Override
public void run() {
// 划分区间前的移位操作
int x = nums[left], i = left, j = right + 1;
while (i < j) {
while (nums[--j] > x && i != j);
if (i == j) nums[j] = x;
else {
nums[i] = nums[j];
while (nums[++i] < x && i != j);
if (i == j) nums[i] = x;
else nums[j] = nums[i];
}
}
if (left < i - 1) {
count.getAndIncrement(); // 将未完成任务数 + 1
// 将下个区间的排序任务交给新线程执行
executor.execute(new Task(left, i - 1, nums, count, executor));
}
if (i + 1 < right) {
count.getAndIncrement();
executor.execute(new Task(i + 1, right, nums, count, executor));
}
count.getAndDecrement(); // 最后记得扣除任务数
}
}
</code></pre>
<p>因为线程需要传参,所以我们通过构造函数给字段赋值来传,这里一个个解释:</p>
<ul>
<li><code>left</code> 和 <code>right</code> :区间左右两边的索引</li>
<li><code>nums</code> :数组</li>
<li><code>count</code> :计数器,用来判断线程池何时将所有任务执行完成</li>
<li><code>executor</code> :线程池</li>
</ul>
<p>接着,我们来写出主类的结构,如下:</p>
<pre><code class="language-java">public class ParallelQuickSort {
public static void main(String[] args) {
// 生成随机数据
int[] nums = generateRandomArray(10000000);
double start = System.currentTimeMillis();
// Arrays.sort(nums);
parallelQuickSort(nums);
double end = System.currentTimeMillis();
System.out.println(((end - start) / 1000) + " seconds");
// 验证排序结果
for (int i = 1; i < nums.length; i++) {
if (nums[i] < nums[i - 1]) {
System.out.println("排序失败!");
break;
}
}
}
public static void parallelQuickSort(int[] nums) {
if (nums == null || nums.length == 0) return;
// 这里图简单,直接用内置线程池
ExecutorService executor = Executors.newFixedThreadPool(20);
// count其实代表了未完成的任务数,包括正在执行和等待执行的
AtomicInteger count = new AtomicInteger(1);
executor.execute(new Task(0, nums.length - 1, nums, count, executor));
// 自旋判断是否已经执行完毕
while (count.get() > 0) {
try {
System.out.println("count:" + count.get());
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 以下皆为关闭线程池的措施
executor.shutdown();
try {
executor.awaitTermination(60, TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
// 随机生成数据
public static int[] generateRandomArray(int size) {
Random random = new Random();
int[] array = new int[size];
for (int i = 0; i < size; i++) {
array[i] = random.nextInt(size) + 1;
}
return array;
}
}
</code></pre>
<p>整个流程其实就是将迭代法中的队列换成了可以自动执行的线程池,而计数器因为存在并发操作,所以使用原子类确保线程安全。</p>
<h1 id="测试">测试</h1>
<p>我们利用随机函数生成随机整型数据,分别使用原生的 <code>Arrays.sort</code> 和我们的多线程快排来测试,结果如下:</p>
<table>
<thead>
<tr>
<th>数据量</th>
<th>Arrays.sort</th>
<th>ParallelQuickSort</th>
</tr>
</thead>
<tbody>
<tr>
<td>10000000</td>
<td>1.466 秒</td>
<td>2.305 秒</td>
</tr>
<tr>
<td>1000000</td>
<td>0.112 秒</td>
<td>0.208 秒</td>
</tr>
</tbody>
</table>
<p>可以看出,我们的多线程竟然比单线程的原生方法还慢,几乎差了一倍,这是什么原因呢?</p>
<p>经过我的一波分析和查阅资料,基本锁定原因:多线程的频繁上下文切换。</p>
<p>在代码中,我们可以看到我并没有对“划分区间给新线程跑”这一行为做限制,以至于即使区间再小也会扔到线程池去执行。而这之间消耗的线程切换时间可要比直接用单线程跑要多。所以我们可以针对这一点进行优化。</p>
<h1 id="二次优化">二次优化</h1>
<p>我们只需要对 <code>run()</code> 作以下修改并且添加一个普通的快排方法即可,经过我测试,当区间长度小于 10000 时直接使用快排效果最好,如下:</p>
<pre><code class="language-java">@Override
public void run() {
int x = nums[left], i = left, j = right + 1;
while (i < j) {
while (nums[--j] > x && i != j);
if (i == j) nums[j] = x;
else {
nums[i] = nums[j];
while (nums[++i] < x && i != j);
if (i == j) nums[i] = x;
else nums[j] = nums[i];
}
}
// 当区间长度小于 10000 时直接使用快排效果
if (right - left <= 10000) {
quicksort(nums, left, i - 1);
quicksort(nums, i + 1, right);
} else {
if (left < i - 1) {
count.getAndIncrement();
executor.execute(new MyRunnable(left, i - 1, nums, count, executor));
}
if (i + 1 < right) {
count.getAndIncrement();
executor.execute(new MyRunnable(i + 1, right, nums, count, executor));
}
}
count.getAndDecrement();
}
public static void quicksort(int[] nums, int l, int r) {
if (l >= r) return;
int x = nums[l], i = l, j = r + 1;
while (i < j) {
while (nums[--j] > x && i != j);
if (i == j) nums[j] = x;
else {
nums[i] = nums[j];
while (nums[++i] < x && i != j);
if (i == j) nums[i] = x;
else nums[j] = nums[i];
}
}
quicksort(nums, l, j - 1);
quicksort(nums, j + 1, r);
}
</code></pre>
<h1 id="最终测试">最终测试</h1>
<p>优化完后,我们再来测试,依旧是五次结果取平均,结果如下:</p>
<table>
<thead>
<tr>
<th>数据量</th>
<th>Arrays.sort</th>
<th>ParallelQuickSort</th>
</tr>
</thead>
<tbody>
<tr>
<td>10000000</td>
<td>1.544 秒</td>
<td>0.339 秒</td>
</tr>
<tr>
<td>1000000</td>
<td>0.111 秒</td>
<td>0.054 秒</td>
</tr>
</tbody>
</table>
<p>可以看出,在千万级数据量下快了将近五倍,只能说成效非常明显。</p>
<h1 id="总结">总结</h1>
<p>最后我思考了一下,<s>为什么原生的快排方法不使用多线程</s>,可能的原因又如下几点:</p>
<ul>
<li>大量数据放在内存中很占空间的,更多会采用多路归并排序(外部排序)</li>
<li>多线程会消耗 CPU 资源,仅仅用来排序感觉多少有点浪费</li>
<li>两者的差距在千万级数据量下才开始有明显差距,大部分情况下不会有这么高</li>
</ul>
<p>p.s. 我后来才知道 Java 的 <code>Arrays.parallelSort()</code> 方法就是原生的多线程快排😂</p>
]]></content>
</entry>
<entry>
<title type="html"><![CDATA[Javascript 逆向之 woff 字体反爬破解]]></title>
<id>https://hualiang.fun/post/python-pa-chong-js-ni-xiang-zhi-woff-zi-ti-fan-pa-po-jie/</id>
<link href="https://hualiang.fun/post/python-pa-chong-js-ni-xiang-zhi-woff-zi-ti-fan-pa-po-jie/">
</link>
<updated>2024-08-25T09:56:36.000Z</updated>
<summary type="html"><![CDATA[<p>转自个人博客 “<a href="https://home.cnblogs.com/u/Eeyhan">Eeyhan</a>” 的<a href="https://www.cnblogs.com/Eeyhan/p/15576450.html">《python爬虫 - js逆向之woff字体反爬破解》</a></p>
]]></summary>
<content type="html"><![CDATA[<p>转自个人博客 “<a href="https://home.cnblogs.com/u/Eeyhan">Eeyhan</a>” 的<a href="https://www.cnblogs.com/Eeyhan/p/15576450.html">《python爬虫 - js逆向之woff字体反爬破解》</a></p>
<!-- more -->
<h1 id="一-前言">一、前言</h1>
<p>本篇博文的主题就是处理字体反爬的,其实这种网上已经很多了,那为什么我还要写呢?因为无聊啊,最近是真没啥事,并且我看了下,还是有点难度的,然后这个字体反爬系列会出两到三篇博文,针对市面上主流的字体反爬,一一讲清楚</p>
<p>不多bb,先看目标站</p>
<blockquote>
<p>http://www.dianping.com/member/79399592/reviews</p>
</blockquote>
<h1 id="二-分析">二、分析</h1>
<p>打开网站发现,如下地址在源码里不显示</p>
<figure data-type="image" tabindex="1"><img src="https://hualiang.fun/post-images/1724637148523.png" alt="img" loading="lazy"></figure>
<p>再看下面的文字,网页源码里面也没有正常显示</p>
<figure data-type="image" tabindex="2"><img src="https://hualiang.fun/post-images/1724637202451.png" alt="img" loading="lazy"></figure>
<p>这种就很秀了啊,对于没搞过字体反爬的朋友来说,估计就迷糊了,不用怕,跟着我的思路来</p>
<p>先看地址栏,点下那个标签,看右边的css样式(对这个不理解的,看看html前端基础吧,最多一周就懂了),或者看看我的之前的博文,https://www.cnblogs.com/Eeyhan/category/1339041.html</p>
<figure data-type="image" tabindex="3"><img src="https://hualiang.fun/post-images/1724637280564.png" alt="img" loading="lazy"></figure>
<p>在看下面的内容:</p>
<figure data-type="image" tabindex="4"><img src="https://hualiang.fun/post-images/1724637365331.png" alt="img" loading="lazy"></figure>
<p>这种啥意思呢,首先哈,看到这种源码里面看不到的,那一定是在css样式里,用的@font-face自定义的字体,所以,上面圈出来的两个css就很重要了,点进去看看,点这个</p>
<figure data-type="image" tabindex="5"><img src="https://hualiang.fun/post-images/1724637386208.png" alt="img" loading="lazy"></figure>
<p>进去之后,格式化一下,然后就看到如下:</p>
<figure data-type="image" tabindex="6"><img src="https://hualiang.fun/post-images/1724637415873.png" alt="img" loading="lazy"></figure>
<p>果然有个@font-face,就看这个后面的url引入了啥样式的字体文件,往后面拉下滚动条,果然看到一个woff的字体文件</p>
<p>补充一下,字体文件格式有哪几种呢?常见的有woff,svg,ttf,其他的就不细说了,好的,先把这个字体下载下来,复制链接浏览器打开直接下载,不用补齐http协议直接下载:</p>
<figure data-type="image" tabindex="7"><img src="https://hualiang.fun/post-images/1724637439593.png" alt="img" loading="lazy"></figure>
<p>这个字体先放着,目前这个是地址相关的,再看内容的字体文件,同样的方式点击那个css,进入里面把链接复制出来下载:</p>
<figure data-type="image" tabindex="8"><img src="https://hualiang.fun/post-images/1724637462046.png" alt="img" loading="lazy"></figure>
<p>因为我之前分析的时候已经下载过了,所以,文件名会有个(1)。</p>
<p>好的,这两个字体文件,梳理一下,f76的是地址的,924的是内容的,这种文件怎么打开呢?用这个地址:<a href="http://font.qqe2.com/index-en.html">点我</a> ,(百度的在线字体编辑器网址已经打不开了,另外找的一个)在线打开:</p>
<figure data-type="image" tabindex="9"><img src="https://hualiang.fun/post-images/1724637481773.png" alt="img" loading="lazy"></figure>
<p>当然你也可以用fontcreator软件打开:</p>
<figure data-type="image" tabindex="10"><img src="https://hualiang.fun/post-images/1724637502129.png" alt="img" loading="lazy"></figure>
<p>果然哈,这里面就是定义好的字体了,而可以看到,这种有编码,有实际字体的,只要找到映射关系,就可以把我们要的内容给映射出来了,那么,我们怎么去找映射关系呢?</p>
<p>先看看规律哈,提前说下,这里直接是中文字,而不是网上有些老哥针对字体反爬讲解的数字,然后找到映射关系之后减2哈,所以还是要自己去找那套映射逻辑</p>
<p>怎么找?直接用一个字来看吧,就找这个【广】字</p>
<figure data-type="image" tabindex="11"><img src="https://hualiang.fun/post-images/1724637525609.png" alt="img" loading="lazy"></figure>
<p>先看网页源码里这个广是啥编码,好的,<code>&#xe2c9</code>,先放一放</p>
<figure data-type="image" tabindex="12"><img src="https://hualiang.fun/post-images/1724637550423.png" alt="img" loading="lazy"></figure>
<p>看这边woff字体里这个广是啥</p>
<p>在线网站看到的,还好,第一页就有,是 <code>unie2c9</code></p>
<figure data-type="image" tabindex="13"><img src="https://hualiang.fun/post-images/1724637587200.png" alt="img" loading="lazy"></figure>
<p><code>unie2c9</code> 跟 <code>&#xe2c9</code>,好像有点像,先不急,看下,fontCreator 软件里是啥:</p>
<figure data-type="image" tabindex="14"><img src="https://hualiang.fun/post-images/1724637652821.png" alt="img" loading="lazy"></figure>
<p>看着有点不一样哈,这不重要,接下来,我们用 Python 的库看看,有一个大佬写好的字体映射文件库,fontTools(自己用pip安装,不多介绍了)</p>
<figure data-type="image" tabindex="15"><img src="https://hualiang.fun/post-images/1724637670683.png" alt="img" loading="lazy"></figure>
<p>打印结果如下,然后它生成了一个 font 的 xml 文件,打开看看:</p>
<figure data-type="image" tabindex="16"><img src="https://hualiang.fun/post-images/1724637687037.png" alt="img" loading="lazy"></figure>
<p>里面有两个关键的节点就是 <code>GlyphOrder</code> 和 <code>cmap</code>,而这两个,刚才的代码里已经打印出来了,结果:</p>
<figure data-type="image" tabindex="17"><img src="https://hualiang.fun/post-images/1724637734981.png" alt="img" loading="lazy"></figure>
<p>那行,我们找下这个【广】在哪,搜从在线字体文件编辑网里拿到的 <code>unie2c9</code>,发现有两个:</p>
<figure data-type="image" tabindex="18"><img src="https://hualiang.fun/post-images/1724637753741.png" alt="img" loading="lazy"></figure>
<figure data-type="image" tabindex="19"><img src="https://hualiang.fun/post-images/1724637776491.png" alt="img" loading="lazy"></figure>
<p>哪个才是呢?再搜下,字体文件拿到的 <code>glyph86</code>,发现没有</p>
<figure data-type="image" tabindex="20"><img src="https://hualiang.fun/post-images/1724638532426.png" alt="img" loading="lazy"></figure>
<p>但是,目前感觉有点联系,<code>&#xe2c9</code> - <code>unie2c9</code> - <code>86</code></p>
<p>这种是啥呀,就不多说了,<code>unie2c9</code> 前面的 <code>uni</code> 就是 unicode 编码的意思,姑且认定为 &<code>#xe2c9</code> = <code>unie2c9</code> ,那 86 呢,怎么映射出【广】字的,大胆猜测,这个 86 就是索引位置,在那个 woff 文件里数一下,看是不是第 86 个,先看这个,一行是 10 个,然后第一行是没有任何编码的,所以第一行只有 9 个,</p>
<figure data-type="image" tabindex="21"><img src="https://hualiang.fun/post-images/1724637801401.png" alt="img" loading="lazy"></figure>
<p>往下数,数到第8行倒数第四个,也就是87,但是第一行只有9个,那就是86了</p>
<figure data-type="image" tabindex="22"><img src="https://hualiang.fun/post-images/1724637916709.png" alt="img" loading="lazy"></figure>
<p>哈哈哈,刚好对上,那现在就说得通了,那我们先拿到源码,然后去找映射关系,找到索引位置,再从索引位置里找到真实的文字内容就行了。</p>
<p>但有个很繁琐的,这些实际的文字内容,我们要一个一个的手写映射关系(哭了),没法啊,找好之后,写成一个 json,然后 load 吧</p>
<figure data-type="image" tabindex="23"><img src="https://hualiang.fun/post-images/1724638148671.png" alt="img" loading="lazy"></figure>
<h1 id="三-调试">三、调试</h1>
<p>先把刚才打开网页源码,直接copy到本地保存成html文件测试吧,免得一改什么就请求下,因为这个站的风控还挺强的</p>
<p>废话不多说,直接处理保存在本地的html,然后我只打印了地址信息</p>
<figure data-type="image" tabindex="24"><img src="https://hualiang.fun/post-images/1724638167364.png" alt="img" loading="lazy"></figure>
<p>感觉跟在源码里看到的&#开头的有点不一样,好像给处理成了【\u】,先看看能不能处理吧:</p>
<p>复制一个 <code>['\ue2c9', '\uef20', '\ue801', '5', '\ued77', '\ue150', '42']</code> ,拿来处理下,</p>
<figure data-type="image" tabindex="25"><img src="https://hualiang.fun/post-images/1724638205464.png" alt="img" loading="lazy"></figure>
<p>卧槽,这咋回事,打断点一看,这个参数并不是我们预期的,</p>
<figure data-type="image" tabindex="26"><img src="https://hualiang.fun/post-images/1724638222729.png" alt="img" loading="lazy"></figure>
<p>那多半就是那个被转义成【\u】的问题了,那我们直接在读取内容的时候,直接就替换一下:</p>
<figure data-type="image" tabindex="27"><img src="https://hualiang.fun/post-images/1724638245820.png" alt="img" loading="lazy"></figure>
<p>执行下:</p>
<figure data-type="image" tabindex="28"><img src="https://hualiang.fun/post-images/1724638266448.png" alt="img" loading="lazy"></figure>
<p>然后同样的,拿第一个来处理:</p>
<figure data-type="image" tabindex="29"><img src="https://hualiang.fun/post-images/1724638286373.png" alt="img" loading="lazy"></figure>
<p>完美,跟原网站的数据对上</p>
<figure data-type="image" tabindex="30"><img src="https://hualiang.fun/post-images/1724638302887.png" alt="img" loading="lazy"></figure>
<p>接着再处理内容的,这个内容原理一样,只是把woff文件替换下即可</p>
<p>打印下内容的:</p>
<figure data-type="image" tabindex="31"><img src="https://hualiang.fun/post-images/1724638321482.png" alt="img" loading="lazy"></figure>
<p>选第一个,然后执行:</p>
<figure data-type="image" tabindex="32"><img src="https://hualiang.fun/post-images/1724638339615.png" alt="img" loading="lazy"></figure>
<p>对比原网站:</p>
<figure data-type="image" tabindex="33"><img src="https://hualiang.fun/post-images/1724638355148.png" alt="img" loading="lazy"></figure>
<p>然后,有朋友要问了,那后面的emoji怎么没有搞出来,看看源码哈:</p>
<figure data-type="image" tabindex="34"><img src="https://hualiang.fun/post-images/1724638369168.png" alt="img" loading="lazy"></figure>
<p>这个emoji,是个图片资源,你要处理肯定是可以的,拼接一下就可以了</p>
<h1 id="四-python-实现">四、Python 实现</h1>
<p>提一句,那两个字体文件经过我的发现,是会不定期变的,所以你需要去请求源码,用正则匹配指定位置,然后请求css文件,再去把woff文件url匹配出来,单独请求,下载下来,接着完成后续的工作即可</p>
<p>最后用 Python 完整实现,完整的代码就不贴出来了,后续的都是一些常规且简单的操作了,再一个就是,我根本就没写完整的代码(哈哈哈哈哈),只贴出部分:</p>
<pre><code class="language-python">from fontTools.ttLib import TTFont
import re
import requests
from lxml import etree
import json
def parser_woff_font(font='4375cf76.woff', something=None):
font = TTFont(font)
glyph = font.getReverseGlyphMap()
f = open('font_template.json', encoding='utf-8')
font_template = json.load(f)
f.close()
new_str = ''
for item in something:
if not item: