coding4fun 词频统计的优化思路

简介:

在这次的 coding4fun 活动中已经有很多同学分享了精彩的优化思路。我的思路其实大同小异,下面就挑一些于众不同的地方分享吧:

第一个不同点:

     在结构上选择了 简化版的 Trie 作为查找结构。简化版 Trie 的结构就是一颗 n 叉树, 每个节点对应一个状态。选择简化版 Trie 的原因是它的树状结构很容易用 CAS实现无锁并行,而相比 hashtable 没有 hash 冲突和 rehash的问题,相比复杂 Trie 结构如 Double-Array Trie 又比较容易实现:

Simple Tries

       简化版 Trie 的一个重要参数就是树的宽度(W),对应每一层有多少个子节点,它等于存放子节点的数组大小。如果 W 越大,每个节点占据的内存就越大,节点利用率就越低。Trie 的每一层可以有不同的 W,  在极限外推下,如果 Trie 树的第一层 W非常大,保证绝大部分节点在第一层能放下,这个内存结构就与 hashtable 没有太大区别了:

Hash

       对 Trie 的调优集中在节点利用率上。如果节点利用率越高, 相同单词量的情况下数据结构占据的内存就越小(假设内存能完全放下的话), CPU 随机访问这些节点的 cache hit 就会越高。—— 短时间内没法对树的查找复杂度 O(log n) 进行改造, 所以只能设法降低数据结构的内存占用(工作集)。

       Trie 树的查找是先把输入拆分成一系列 “状态” 序列, 然后根据这组状态序列在用 “树” 表示的状态迁移有向图中定位最终的节点。在简化的 Trie 结构中, “状态”的总数就决定了 Trie 树的宽度 W 。因此怎样把输入有效的拆分成 “状态” 或者说定位子节点 index 是同样重要的, 例如输入字符串 ‘ABCDEFG’:

  1. 如果按原始 char 内码拆分, 生成的子节点 index 序列是: { 65, 66, 67, 68, 69, 70, 71 },需要一棵 W = 256 的 Trie 树存放;
  2. 如果只考虑字母, 忽略大小写压缩一下, 生成的子节点 index 序列是: { 0, 1, 2, 3, 4, 5, 6 },只需要一棵 W = 26 的 Trie 树就能放下;
  3. 如果在上一条的基础下, 按 Bit 逐位拆分每个字母,则产生的 index 序列是: { 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0 }, 只需要 W = 2 的 Trie(它其实是棵二叉树) , 但是查找路径会增加很多。

BTW其实这就是Bitwise Trie,不过第一 Java 下不能利用 CPU 指令优化位运算,第二 Bitwise Trie 更适合定长输入,所以测试性能并不太友好。

  1. 更加灵活(复杂)的拆分办法,在上述按 Bit 拆分的基础上, 再合并相邻 Bit 增加宽度, 降低查找路径。例如合并 3 位: { 0, 4, 0, 4, 0, 3, 0, 2, 2, 1, 6, 0 }, 需要一个 W = 8 的 Trie 存储。这个方法可以让任意 W 值的 Trie 树接受任意的状态序列输入。

       为了方便对不同 W 和结构的 Trie 性能进行测试, 我定义了一组 Tries / Sequencer 接口, 由 Tries 接口维护 “状态” 数据结构,Sequencer 产生 “状态” 的 index 序列。 在全部的测试中我尝试了若干种不同的 Tries 和 Sequencer 实现,最后发现按照方式 2 来最大限度的压缩 index 序列的 AlphabetSequencer 与最简的 SimpleTries/ConcurrentTries(使用 CAS 并行插入节点) 实际性能最好。这一块就不多介绍了, 大家有兴趣可以阅读 Gitlab 的代码。

第二个不同点:

       与大部分同学利用内存映射文件一次性把文件读到内存不同,我在一开始就直接把文件平均分成若干个分区,让每个工作线程单独扫描一个分区进行分词,这样可以实现完全并行:

Partation

       因为平均分区这个办法太简单, 可能会出现尾部 “半个单词” 的问题, 漏掉分区结尾的单词。解决尾部半个单词的办法很简单,写成伪代码是这样的:

       If(不是从文件开头读取) {

              忽略分区的第一个单词;

       }

       读取和计算分区中的单词;

       If(没有读到文件末尾) {

              继续向后多读一个单词,直到单词结束;

       }

       具体的并发执行过程如下图。其中并发加载是最慢的,消耗一般在 3s以上,而并发排序一般是 6x-8x 毫秒:

Multithread

        实际调优中发现, 尽管多个线程并发访问相同的数据结构,  但是因为单词总量不大(3w 多), Trie 树的绝大部分节点都是在运行的最初阶段插入的, CAS 冲突消耗的时间很少(100-200ms 级别), 主要的消耗还是在树查找与内存访问。

相关文章
|
算法 Unix
socket套接字选项getsockopt&setsockopt
setsockopt()函数用于任意类型、任意状态套接口的设置选项值。尽管在不同协议层上存在选项,但本函数仅定义了最高的“套接口”层次上的选项。在Unix网络编程中通常用到getsockopt和setsockopt两个函数来获取和设置套接口的选项。getsockopt()函数用于获取任意类型、任意状态套接口的选项当前值,并把结果存入optval。
200 0
|
存储 Kubernetes 负载均衡
k8s详解
@[TOC](目录) Kubernetes(简称 k8s)是一个开源的容器编排系统,用于自动化部署、扩展和管理容器化应用程序。本篇详解将介绍 k8s 的核心概念、架构和使用方法,帮助读者深入理解 k8s 并掌握其基本操作。 # 一、k8s 核心概念 1.1 容器 容器是一种轻量级、可移植的虚拟化技术,用于打包和运行应用程序。容器可以共享主机操作系统的内核,从而提高部署效率和资源利用率。常见的容器技术包括 Docker、Kubernetes Pod、LXC 等。 1.2 Namespace Namespace 是 k8s 中的资源隔离单元,用于对 k8s 对象进行命名空间隔离。通过创建 Name
741 0
|
7月前
|
存储 人工智能 缓存
DeepSeek 开源周第三弹!DeepGEMM:FP8矩阵计算神器!JIT编译+Hopper架构优化,MoE性能飙升
DeepGEMM 是 DeepSeek 开源的专为 FP8 矩阵乘法设计的高效库,支持普通和混合专家(MoE)分组的 GEMM 操作,基于即时编译技术,动态优化矩阵运算,显著提升计算性能。
718 3
DeepSeek 开源周第三弹!DeepGEMM:FP8矩阵计算神器!JIT编译+Hopper架构优化,MoE性能飙升
|
数据采集 存储 JSON
豆瓣电影信息爬虫实战-2024年6月
使用Python和`requests`、`PyQuery`库,本文教程教你如何编写一个豆瓣电影列表页面的爬虫,抓取电影标题、导演、主演等信息。首先确保安装所需库,然后了解技术栈,包括Python、Requests、PyQuery和正则表达式。爬虫逻辑包括发送HTTP请求、解析HTML、提取数据。代码示例展示了如何实现这一过程,最后运行爬虫并将结果保存为JSON文件。注意遵守网站使用条款和应对反爬策略。
960 2
|
存储 SQL 供应链
大学教材征订管理系统数据库设计
大学教材征订管理系统数据库设计
418 0
|
小程序 JavaScript 前端开发
小程序云开发全套实战教程(最全)
小程序云开发全套实战教程(最全)
300 0
|
存储 Linux Shell
centos 部署docker容器 安装 、基本使用方法(一)
centos 部署docker容器 安装 、基本使用方法(一)
676 0
|
测试技术 Python
软件测试|教你如何离线安装第三方库
软件测试|教你如何离线安装第三方库
|
数据挖掘 数据处理 索引
数据合并与连接:Pandas中的强大数据整合功能
【4月更文挑战第16天】Pandas是Python数据分析的库,提供数据合并与连接功能。本文聚焦于`merge`和`concat`函数。`merge`基于键合并DataFrame,如示例中`df1`和`df2`按'key'列合并,支持多种连接方式。`concat`则沿轴堆叠DataFrame,如`df3`和`df4`沿行连接。注意合并连接时键的一致性、选择合适连接方式及处理索引和数据结构,以确保数据准确一致。学习这些方法能有效整合多数据源,便于分析。
|
JavaScript
新建一个vue2项目
新建一个vue2项目
801 0