从零开始:C# 拼音首字母搜索、字符串编码、关键词高亮的原理即实现考虑

在处理百万量级条目(如文本名)的搜索时,每一次匹配的效率对提高总搜索时间至关重要。如果在每次检查文件名与关键字时执行复杂的操作,会对总时间产生累计影响,进而影响用户体验。本文将详细分享之前 TDS 的文本搜索逻辑,希望能为大家提供一些参考。

一、拼音首字母转换

考虑字符串"123四五六78abc",我们的预期是匹配关键字["sw","六"]时能分别命中子串["四五","六"]。要获取汉字的拼音首字母,需要先获取字符的 Unicode 编码,在不考虑多音字的情况下,可直接通过查表简单实现。如果字符处于中文区间,可直接返回对应的首字母。

从零开始:C# 文件名搜索拼音首字母支持、搜索加速策略与关键词高亮显示

在处理百万量级条目(如文本名)的搜索时,每一次匹配的效率对提高总搜索时间至关重要。如果在每次检查文件名与关键字时执行复杂的操作,会对总时间产生累计影响,进而影响用户体验。本文将详细分享之前 TDS 的文本搜索逻辑,希望能为大家提供一些参考。

一、拼音首字母转换

考虑字符串"123四五六78abc",我们的预期是匹配关键字["sw","六"]时能分别命中子串["四五","六"]。要获取汉字的拼音首字母,需要先获取字符的 Unicode 编码,在不考虑多音字的情况下,可直接通过查表实现。如果字符处于中文区间,可直接返回对应的首字母。具体实现如下:

csharp 复制代码
return iCnChar switch
{
    >= 45217 and <= 45252 => 'A',
    >= 45253 and <= 45760 => 'B',
    >= 45761 and <= 46317 => 'C',
    >= 46318 and <= 46825 => 'D',
    >= 46826 and <= 47009 => 'E',
    >= 47010 and <= 47296 => 'F',
    >= 47297 and <= 47613 => 'G',
    >= 47614 and <= 48118 => 'H',
    >= 48119 and <= 49061 => 'J',
    >= 49062 and <= 49323 => 'K',
    >= 49324 and <= 49895 => 'L',
    >= 49896 and <= 50370 => 'M',
    >= 50371 and <= 50613 => 'N',
    >= 50614 and <= 50621 => 'O',
    >= 50622 and <= 50905 => 'P',
    >= 50906 and <= 51386 => 'Q',
    >= 51387 and <= 51445 => 'R',
    >= 51446 and <= 52217 => 'S',
    >= 52218 and <= 52697 => 'T',
    >= 52698 and <= 52979 => 'W',
    >= 52980 and <= 53688 => 'X',
    >= 53689 and <= 54480 => 'Y',
    >= 54481 and <= 65289 => 'Z',
    _ => throw new ArgumentOutOfRangeException(nameof(iCnChar), iCnChar, null)
};

经过上述转换,字符串"123四五六78abc"将得到一个新的字符串"123swl78abc"。因此,对源文本的搜索需要对原字符串和拼音串进行两次匹配。目前尝试了多个方法,发现String.Contains的效率最高。StringComparison.OrdinalIgnoreCase 参数是一种快速的字符串比较方式,它忽略大小写差异,直接比较字符的 Unicode 值,避免了额外的字符转换操作。使用内置方法可以充分利用底层优化,减少不必要的计算,从而提高整体性能。

二、存储及索引辅助优化

拼音首字母预先转换比实时拼音转换匹配速度要高效得多,除非存储空间实在紧张。但多了一个字符串,也意味着多了一次字符串搜索,而字符串搜索是一个较占时间的操作。有没有办法继续优化呢?

(一)模式串

首先建立一个特定长度的char数组作为模式串,以长度为 6 的["a","b","c","1","2","3"]为例,这个模式串包含了我们关心的元素。由于我们所有的中文全部映射到了字母的空间,因此这个模式串其实是有限的. 0-9共10个符号加上26个字母以及其他标点一共也不到64个,所以我们可以很方便的用一个64位长度'long'类型存储编码.

(二)字符串编码

以目标文件名"apple 1"为例,我们初始化一个对应模式串长度的二进制位,0_0_0_0_0_0,逐字符扫描时,发现模式串中a1字符命中,那么我们通过二进制位记录将得到1_0_0_1_0_0

以目标文件名"xyz7890"为例,我们初始化一个对应模式串长度的二进制位,0_0_0_0_0_0,逐字符扫描时,发现模式串均未命中,那么我们通过二进制位记录将得到0_0_0_0_0_0

以目标文件名"231cbaa"为例,我们初始化一个对应模式串长度的二进制位,0_0_0_0_0_0,逐字符扫描时,发现模式串所有字符命中,那么我们通过二进制位记录将得到1_1_1_1_1_1

有限的二进制位可以方便地用int32int64存储,已经相当够用了。这种二进制编码的方式不仅节省了存储空间,还大大提高了搜索效率。布尔运算(如或运算)的速度远超字符串操作,因为它们直接在内存的位级别上进行操作,而字符串操作则需要逐字符比较和处理。

(三)关键词初筛

以目标文件名"apple 1"为例,此时的二进制位为1_0_0_1_0_0 = 36int值表示)

  • 我们搜索关键词"apel",与模式串匹配得到关键词的二进制位1_0_0_1_0_0 = 36(十进制)
  • 我们搜索关键词"bpel",与模式串匹配得到关键词的二进制位0_1_0_1_0_0 = 20(十进制)

对关键词与目标文件名做或运算:

csharp 复制代码
int index_originTxt; // 假设已初始化
int index_keyTxt;    // 假设已初始化
if (index_originTxt | index_keyTxt != index_keyTxt) return "索引初筛失败";

如果或运算后的值不等于原值,则表示关键词与目标文件名在模式串中存在不包含的字符。

对于多个关键词:

csharp 复制代码
int index_originTxt;  //文件名
int index_keyTxt_1;  // 搜索关键字1,假设已初始化
int index_keyTxt_2;  // 搜索关键字2,假设已初始化
int index_keyTxt_3;  // 搜索关键字3,假设已初始化
int index_keyTxt_final = index_keyTxt_2 | index_keyTxt_3;

if (index_originTxt | index_keyTxt_final != index_keyTxt)
{
    return "索引初筛失败";
}
else
{
    // ...正常文本搜索
}

intlong的或运算非常轻量,速度远超string.Contains,且关键词越多,文件名越长,过滤效果越好。这种初筛机制可以快速排除大量不匹配的文件名,从而显著减少后续精确匹配的计算量,提高整体搜索效率。

底层技术细节:

  • 初筛机制: 使用位运算进行初筛,可以快速排除大量不匹配的文件名,减少后续精确匹配的计算量。
  • 性能优势: 位运算的速度极快,适合大规模数据的快速筛选。

三、关键词高亮(v1.1.7新增)

在 Avalonia 中,文件名的显示通过 TextBlock 控件实现,其 Inlines 属性绑定至一个自定义的高亮转换器.

xml 复制代码
<TextBlock Inlines="{Binding FileName, Converter={StaticResource HighlightConverter}}" />  

该转换器类需实现 IValueConverter 接口,由 Avalonia 依赖注入容器自动实例化。其核心方法是 Convert,负责将原始文本按匹配的关键词切分,并构造高亮显示的 InlineCollection。具体实现如下:

  • 初始化一个 InlineCollection 对象用于存放文本段;
  • 遍历预处理后的关键词匹配结果(通常需先按起始位置排序,并合并相邻或重叠区间);
  • 对非匹配区域,添加普通 Run 对象显示文本;
  • 对匹配区域,创建 Run 对象并设置高亮画刷(如 Brushes.Yellow)以改变前景色;
  • 需注意多个关键词可能引起的区间重叠与重复问题,需通过算法(如区间合并)确保每个字符只处理一次。
csharp 复制代码
// 创建行内元素集合,用于存储文本元素(Run)
var inlines = new InlineCollection();

// 遍历所有排序整合后的搜索结果
foreach (var result in results)
{
    // 从原始文本中提取当前片段的子字符串
    // result.Start: 片段起始位置
    // result.Length: 片段长度
    var textSegment = nameOrigin.Substring(result.Start, result.Length);
    
    // 创建文本元素(Run),用于显示文本片段
    var run = new Run(textSegment);

    // 如果当前片段是匹配项,则应用高亮样式
    // result.IsMatch: 标识该片段是否为搜索匹配项
    if (result.IsMatch) 
    {
        // 设置前景色为高亮画刷,突出显示匹配文本
        run.Foreground = highlightBrush;
        
        // 可选:添加其他高亮样式,如加粗、背景色等
        // run.FontWeight = FontWeight.Bold;
        // run.Background = Brushes.Yellow;
    }

    // 将文本运行添加到行内集合中
    inlines.Add(run);
}

// 返回构建完成的行内元素集合
// 该集合可在Avalonia TextBlock等控件中直接使用,显示带有高亮效果的文本
return inlines;

需要注意的是,多个关键词高亮后,需要对substring的区域进行重排以及去重/结合。这种设计不仅保证了高亮显示的准确性,还避免了重复处理同一段文本,从而提高了渲染效率。通过这种方式,用户可以清晰地看到搜索关键词在文本中的位置,提升了用户体验。

所有的Converter都是在虚拟模式下按需执行的,可以较好满足我们的性能需求.

四、最后

对于TDS搜索软件的其他信息信息可见此公众号的文章 https://mp.weixin.qq.com/s/inD-brKhii57UJnCYLgxKQ

目前关于TDS搜索的版本已经更新到了1.1.7, 优化了很多细节,增加了高亮,磁盘缓存索引,更多选项等一些功能. 欢迎大家拿走,分享,使用.

如果你对这款工具有任何建议或想法,欢迎随时交流!项目已在 GitHub 完全开源.

如果你觉得有用,欢迎点个 Star ⭐️支持一下! https://github.com/LdotJdot/TDS