iOS搜索引擎 - trie树

背景

前段时间产品锦鲤说APP内要做搜索关键字提示功能,后台由于能(人)力有限,所以这功能由前台来做。
Google搜索

Google、百度这类的搜索引擎,有一堆大佬对它进行优化,它的关键词提示会非常全面和精准,不过原理都差不多,就是利用了trie树。

Trie树

Trie树又称字典树,利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较。

假设现在有四个字符串,分别为aa、abc、abcd、ac,我们要在这里面查找某个字符串是否存在,就需要依次遍历这四个字符串。即使像查找“cc”这样和所有的字符串的首字符都不同的字符串也要一个个的比较,那么查询效率就比较低了。

我们可以先对这四个字符串进行处理,提取公共前缀生成trie树,这样每次查询都是在trie树里进行匹配,这时候查找“cc”字符串是否存在只需要匹配一次就可以了,极大的提升了效率。如下图所示。
Trie树
图中每一条路径代表一个字符串,每个节点对应字符串中的字符。不过看起来似乎只有三个字符串,很难看出还有abc字符串,所以需要在c节点加一个标志位,表示有一个单词到这里结束了。如果要查询“ab”字符串,当匹配到“b”时,标志位没有表明到这里结束,所以没有“ab”字符串,它是其他字符串的前缀。

节点头文件如下

@interface Node : NSObject

@property (nonatomic, copy) NSString *string;
@property (nonatomic, copy) NSString *character;
@property (nonatomic, assign) BOOL isFinish;
@property (nonatomic, strong, nullable) NSMutableDictionary *children;

- (void)add:(NSString *)str;

@end

character:当前节点的字符
string:这条路径上到当前字符前的所有字符
isFinish:字符串是否结束
children:存放了后续所有路径的首字符

@implementation Node

+ (Node *)initWithValue:(NSString *)str parent:(Node *)node
{
    Node *n = [[Node alloc] init];
    n.character = str;
    n.string = [NSString stringWithFormat:@"%@%@", node.string, node.character];
    return n;
}

- (instancetype)init
{
    if (self = [super init]) {
        self.children = [NSMutableDictionary dictionary];
        self.character = @"";
        self.string = @"";
    }
    
    return self;
}

- (void)add:(NSString *)str
{
    if (!self.children[str]) {
        self.children[str] = [Node initWithValue:str parent:self];
    }
}

@end

前两个方法很好理解,重点说下add:方法。例如在上面的trie树中,再添加一个“iOS”字符串,发现根节点下没有“i”字符,于是根节点的children新增一个键值对,key是i,值是character为i的节点node。
image.png

trie树只有两个功能,一个是添加字符串生成trie树,另一个就是在trie树种匹配字符串。创建一个管理类来处理trie树,并提供这两个方法。

@interface searchManager : NSObject

- (void)insert:(NSString *)word;
- (NSArray *)find:(NSString *)word;

@end

管理类的实现。

- (instancetype)init
{
    if (self = [super init]) {
        self.root = [[Node alloc] init];
    }
    
    return self;
}

这个root节点就是根节点,它不包含任何字符,所有的字符路径都从root根节点开始。

- (void)insert:(NSString *)word
{
    if (word.length == 0) {
        return;
    }
    word = [word lowercaseString];
    
    Node *node = self.root;
    
    int index = 0;
    NSString *str = @"";
    while (index < word.length) {
        str = [word substringWithRange:NSMakeRange(index, 1)];
        
        if (!node.children[str]) {
            [node add:str];
        }
        
        node = node.children[str];
        index++;
    }
    node.isFinish = YES;
}
- (NSArray *)find:(NSString *)word
{
    if (word.length == 0) {
        return @[];
    }
    word = [word lowercaseString];
    
    Node *node = self.root;
    
    int index = 0;
    NSString *str = @"";
    while (index < word.length) {
        str = [word substringWithRange:NSMakeRange(index, 1)];
        
        if (node.children[str]) {
            node = node.children[str];
            index++;
        } else {
            break;
        }
    }
    
    if (index == word.length) {
        NSMutableArray *arr = [NSMutableArray array];
        [self addAllData:node arr:arr];
        return arr;
    }
    return @[];
}

- (void)addAllData:(Node *)node arr:(NSMutableArray *)arr
{
    if (node.isFinish) {
        [arr addObject:[NSString stringWithFormat:@"%@%@", node.string, node.character]];
    }
    
    NSArray *arrKey = node.children.allKeys;
    
    for (int i = 0; i < arrKey.count; i++) {
        Node *n = node.children[arrKey[i]];
        
        if (n.children.count > 0) {
            [self addAllData:n arr:arr];
        } else {
            [arr addObject:[NSString stringWithFormat:@"%@%@", n.string, n.character]];
        }
    }
}

find:其实就是插入的逆过程,根据目标字符串在trie树中进行匹配查询,当遇到一个不存在的字符时,就意味着目标字符串不存在。如果目标字符串能完全匹配,那么就取出该路径下的所有字符串。例如输入“a”,匹配到“a”时,继续遍历节点“a”的children,直到“a”下的所有路径都遍历完成。
image.png

到这里,trie树的基本功能都已完成,接下来就验证一下trie树的正确性和效率。

和系统方法做对比

我从某学习网站获取了1327条课程名称,先用系统方法遍历一下看看效率如何

- (void)systemFind:(NSArray *)array
{
    NSMutableArray *arr = [NSMutableArray array];
    NSString *str = @"";
    
    NSLog(@"开始....");
    for (int i = 0; i < array.count; i++) {
        str = array[i];
            
        if ([str hasPrefix:@"java"] || [str hasPrefix:@"JAVA"] || [str hasPrefix:@"Java"]) {
            [arr addObject:str];
        }
    }
    NSLog(@"结束....");
    
    self.result1 = arr;
}
2019-05-19 13:40:33.064389+0800 trie树[9817:359745] 开始....
2019-05-19 13:40:33.064886+0800 trie树[9817:359745] 结束....

1327条里匹配了19条数据,耗时0.000497s。

- (void)trieFind:(NSArray *)array
{
    NSLog(@"开始4----------");
    searchManager *model = [[searchManager alloc] init];
    for (int i = 0; i < array.count; i++) {
        [model insert:array[i]];
    }
    
    NSMutableArray *arr = [NSMutableArray array];
    NSLog(@"开始3----------");
    [arr addObjectsFromArray:[model find:@"java"]];
    NSLog(@"结束3----------");
    
    self.result2 = arr;
}
2019-05-19 20:55:57.287762+0800 trie树[10390:399224] 开始3----------
2019-05-19 20:55:57.288423+0800 trie树[10390:399224] 结束3----------

trie树搜索,耗时0.000661s,数据太少,体现不出优势,将核心查询方法循环10w次,再比较两者的消耗时间。

//    系统方法:16.148049s
//    2019-05-19 21:04:46.893443+0800 trie树[10522:406446] 开始....
//    2019-05-19 21:05:03.041492+0800 trie树[10522:406446] 结束....
//    trie树:41.086426s
//    2019-05-19 21:05:03.084477+0800 trie树[10522:406446] 开始3----------
//    2019-05-19 21:05:44.170903+0800 trie树[10522:406446] 结束3----------

怎么肥四,写了半天的trie树还没有系统遍历来的快!通过分析发现,遍历10w次需占用内存2.5G,因为课程的名称平均长度大概是20个字,最长的有50个字,这就导致遍历的时候堆栈太深,不仅耗时,还耗内存。这就需要对生成的trie树进行缩点优化。例如有四个字符串,分别为aa、abcd、ac、iOS,就需要对abcd字符串进行缩点优化。
trie树优化

这样就可以有效的降低查询的递归深度。

- (void)optimization
{
    Node *node = self.root;
    
    NSArray *arrKey = node.children.allKeys;
    for (int i = 0; i < arrKey.count; i++) {
        [self optimization:node.children[arrKey[i]]];
    }
}

- (BOOL)optimization:(Node *)node
{
    NSArray *arrKey = node.children.allKeys;
    if (arrKey.count == 0) {
        return YES;
    }
    for (int i = 0; i < arrKey.count; i++) {
        Node *n = node.children[arrKey[i]];
        if ([self optimization:n]) {
            if (n.children.count == 0 && arrKey.count == 1 && !node.isFinish) {
                Node *n2 = [node.children.allValues lastObject];
                node.character = [NSString stringWithFormat:@"%@%@", node.character, n2.character];
                node.children = nil;
                return YES;
            }
        }
    }
    
    return NO;
}

在最终生成的trie树之后调用该方法进行缩点优化,再来看看10w次查询耗时

//    3.814531s
//    2019-05-19 21:08:43.831729+0800 trie树[10582:409365] 开始3----------
//    2019-05-19 21:08:47.646260+0800 trie树[10582:409365] 结束3----------

不到4秒就可以循环10w次查询1327条数据了,1秒大约可以查询3300w条。具体的查询效率需要根据实际的数据来看,极端情况下如果3300w条数据的首字符都不相同,那就需要依次对比每个字符串。
代码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,335评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,895评论 3 387
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,766评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,918评论 1 285
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,042评论 6 385
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,169评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,219评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,976评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,393评论 1 304
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,711评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,876评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,562评论 4 336
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,193评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,903评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,142评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,699评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,764评论 2 351