Go刨根问底系列 gin-httprouter part1 - Radix Tree

6 阅读11分钟

前言

gin 作为go的web端高性能框架的其中一个主要原因是其核心路由组件的实现, 即httprouter. 其实现借鉴了radix tree 算法的思想.

radix tree又被译为基数树, 是一个相当抽象的概念. 不过我们通过一些简单的例子一步步剖析这个名字的含义.

谈到基数, 我们会想到二进制是以2为基数进行进位表示, 十进制是以10为基数进行进位表示. 也就是说在二进制表示数字时, 在确认高位数字的前提下, 下一位数字只有两种可能, 0 或 1. 十进制同理. 如: 假设我们知道一个数字高二位是 10, 那么高三位只有两种可能: 100, 101.

而radix tree的本质思想是通过将一堆数据通过特定的规则建立这样一种基数关系, 如前面提到的httprouter即是通过字符串的检索规则建立这种关系:

我们通过以上的基数规则进行对号入座, 确认字符串前缀为 ‘/api’, 那么下一个字符可能是, 用正则表达式表示: [A-Za-z0-9-_.~!$&'()*+,;=:@?/#], 而这些可能出现的值即是基数.

除此之外, radix tree 还有一个名字, 压缩前缀树, 这是因为除了上述的基数规则之外, 还需要满足前缀压缩的特性. 这一点在正式进行介绍时将有所体现.

本文章主要介绍radix tree的其中一个应用字符串检索, 包括后面的代码实现也以此为主. 当然需要澄清的一个误区是, radix tree 并不是单单在字符串检索方面有着突出的应用, 其核心思想甚至可以被应用在内存管理方面, golang的内存管理底层就用到了这种算法思想去检索合适的待分配内存块.

Trie树

我们首先介绍一种相对比较简单的检索结构, Trie树也被称为前缀树/字典树, 本质是没有压缩特性的radix tree.

我们先看一个场景, 在作者小时候那个时代, 刚开始学英语时并没有手机那么便利的工具, 学习英语基本人手一本英语词典. 词典有一个默认的约定: 所有单词的排列都是有序的, 且提供索引页供我们快速查找. 假设我们需要查找单词 ‘dog’, 首先通过’d‘索引查找索引页’d‘开头的全部单词, 然后通过第二个字母’o‘查找前缀为’do‘的单词块, 由于整体排列是有序, 我们可以很快从所有’d‘开头的单词中检索到连续的一片’do‘开头的单词. 以此类推, 找到单词'dog', 这时词典会告诉我们这个单词介绍位于词典的哪一页. 我们翻到那一页就可以了.

在前面场景中, 我们查找一个单词, 需要通过前面字母索引去寻找下一个字母的索引, 直到找不到下一个字母或者找到最后一个字母, 前者表示词典里没有这个单词, 后者则表示找到这个单词. 我们为这个查询场景建立一个模型, 假设我们的‘词典’仅记录如下单词: ‘an’, ‘app’, ‘apple’, ‘at’, ‘dog’, ‘duck’, ‘egg’, 则可构建这样一棵树:

image.png

树的红色节点部分表示该节点为值节点, 黑色部分表示索引节点. 这种结构即是一棵用字符串进行检索的Trie树.

这样一棵树可以大大增加我们的检索效率, 即由原先的时间复杂度为O(n*s)(暴力查找)优化为O(s). n为单词数量, s为所有单词平均长度.而其代价是这棵树的维护成本, 需要额外的操作去更新这棵树, 其更新的时间复杂度也是O(s).

Radix Tree

前面我们通过构建Trie树去优化单词的检索场景. 不过上述结构仍有可优化的空间:

  1. 上述结构中, 如果单词的检索路径唯一, 我们还是会通过指针跳转的方式查找下一个字母, 如单词 'duck' 即使我们知道以‘du’为前缀的单词仅有一个, 我们还是会循规蹈矩地通过一个个字母进行检索, 直到检索到一个完整的单词.

  2. 内存上, 每个节点的存储粒度为字母, 对一些比较长且路径比较稀疏的单词来说, 会有空间浪费.

经过上面的讨论, 一个很明显的优化点是: 我们可以压缩每个索引节点的所有子节点的最长公共左前缀.

对之前举的例子进行优化, 如下图:

image.png 这样子做的好处是:

  1. 空间上, 极致的空间压缩, 节点数量肉眼可见地减少了很多
  2. 时间上, 将大多数需要地址跳转的场景转为连续的内存比较, 提高了cpu的使用率

这样就是一棵成型的radix tree.

radix tree的代码实现

在实现之前我们先确定我们的诉求: 我们需要实现这样一种数据结构, 它是一棵radix tree, 它能帮助我们存储多对key-value对, key为字符串, value为对应的值, 且可以通过key高效检索到对应的value.

数据结构定义

type nodeType int

const (
	ValueNode nodeType = iota
	IndexNode
)

type Node[T any] struct {
	key      []byte
	value    T
	children []*Node[T]
	t        nodeType
}


type RadixTree[T any] struct {
	root *Node[T]
}

key, []byte类型是为了将树的基数控制在 0~255 的范围区间而不是utf8字符集, 明显后者的基数更大.
nodeType, 将节点分为索引类型和值类型, 值类型表示当前节点可表示一个完整的key.

我们主要需要实现的操作: 插入key, 删除key, 检索key.

AddNode

/*
1. 添加一个新节点
2. 分裂出一条边
3. 继续检索下一个节点
*/
func (t *RadixTree[T]) AddNode(key []byte, value T) {
	if t.root == nil {
		t.root = &Node[T]{
			key:   key,
			value: value,
		}

		return
	}

	curNode := t.root
	for {
		isMe, isChild, isSplit, isNext, splitIndex, nextNode := curNode.checkNewKey(key)
		if isMe {
			curNode.setValue(value)
		}

		if isNext {
			key = key[len(curNode.key):]
			curNode = nextNode
			continue
		}

		if isChild {
			curNode.addChild(key, value)
		}

		if isSplit {
			curNode.split(splitIndex)
			curNode.addChild(key, value)
		}

		break
	}
}

首先判断当前树是否为空, 如果为空, 则将root作为第一个节点.否则进入检索流程.
检索流程的核心方法:

func (n *Node[T]) checkNewKey(key []byte) (isMe bool, isChild bool, isSplit bool, isNext bool, splitIndex int, nextNode *Node[T]) {
	curi := 0
        
        // 找到最长公共左前缀
	limit := min(len(key), len(n.key))
	for curi < limit && n.key[curi] == key[curi] {
		curi++
	}

	switch {
        // 最长左前缀是当前节点key的子串, 如果是插入操作则需要边分裂
	case curi <= len(key) && curi < len(n.key):
		isSplit = true
		splitIndex = curi
        
        // 传参key等于当前节点的key, 当前节点即所查找节点
	case len(n.key) == len(key) && curi == len(key):
		isMe = true
		return
        
        // 当前节点的key为传参key的子串, 将传参key截掉公共前缀部分, 此时有两种情况, 一种是存在孩子节点跟传参key有公共前缀, 一种是没有公共前缀
	default:
		nextIndex := key[curi]
		nexti := sort.Search(len(n.children), func(i int) bool {
			index, isEmpty := n.children[i].Index()
			if isEmpty {
				panic("the empty key node must not to be a child")
			}

			return index >= nextIndex
		})
               
		if nexti == len(n.children) {
                        // 对应情况二, 剩余的key可作为新的孩子节点
			isChild = true
		} else {
                        // 对应情况一, 剩余的key可作为新的传参检索下一个节点
			isNext = true
			nextNode = n.children[nexti]
		}
	}

	return

checkNewKey(key []byte) (isMe bool, isChild bool, isSplit bool, isNext bool, splitIndex int, nextNode *Node[T]) 该方法用于判断传参key符合下面哪种情况, 假设当前该节点存储的key为 "abcd":

  1. 传参key为 "abcd", isMe 为 true.
  2. 传参key为 "abcde",
    2.1) 如果当前节点有孩子节点且前缀为"e", isNext 为true, nextNode为对应的孩子节点
    2.2) 没有孩子节点前缀为"e", isChild 为true.
  3. 传参key为 "abce", isSplit 为 true, splitIndex 为3(拥有公共前缀 "abc")

回到循环主流程, 根据checkNewKey的情况判断下一步的行为:
如果是情况1(isMe), 则确定新的节点在树中已存在, 则修改对应的值, 退出循环.

{
    if isMe {
        curNode.setValue(value)
    }
    
    break
}

如果是情况2.1(isChild), 则确定插入的节点是当前节点的子节点.
确认是新的子节点, 则应该截断公共前缀部分, 建立新的关联, 即将新子节点放在当前节点的children指针切片中.
为了方便检索, children整体应呈现有序, 方便后续进行二分查找.
至此完成插入操作, 退出循环.

{
    if isChild {
        curNode.addChild(key, value)
    }

    break
}

// 取key的第一个字节
func (n *Node[T]) Index() (index byte, isEmpty bool) {
	if len(n.key) > 0 {
		return n.key[0], false
	}
	return 0, true
}

// add child
func (n *Node[T]) addChild(key []byte, v T) {
	if len(key) == len(n.key) {
		n.setValue(v)
		return
	}

	n.children = append(n.children, &Node[T]{
		key:   key[len(n.key):],
		value: v,
		t:     ValueNode,
	})

   // 子节点通过key的第一个字节进行排序
	sort.Slice(n.children, func(i, j int) bool {
		iv, iisEmpty := n.children[i].Index()
		jv, jisEmpty := n.children[j].Index()
		if iisEmpty || jisEmpty {
                        // 防御性编码, 不应该存在key为空的孩子节点
			panic("the empty key node must notbi to be a child")
		}

		return iv < jv
	})
}

如果是情况2.2(isNext), 表示当前存在子节点并该子节点存在至少1个字符跟截断后的key有公共前缀.
就像前面的案例, abcd有子节点’e...‘, 而当前检举的key是’abcde...‘, 除去公共部分‘abcd’, 恰好存在子节点‘e...’与截断部分 ‘e...’有公共部分‘e’.

此时我们能知道插入的节点是应该跟检索出来的子节点‘e...’有关联, 故将有关联部分截断出来作为下次循环跟子节点进行关系鉴定.

{
    if isNext {
        key = key[len(curNode.key):]
        curNode = nextNode
        continue
    }
}

情况3(isSplit), 表示插入节点的位置处于当前节点的key的中间位置.
此时当前节点应该断裂为上下级节点, 并在断裂处插入当前节点.如:
存在: abc, 需要插入 ab 或 abd, 最后我们想要的效果:
ab     或     ab
|                   |  \
c                 c   d
一般断裂处的节点应为索引节点(节点‘ab’).

至此插入操作完成

{
    if isSplit {
        curNode.split(splitIndex)
        curNode.addChild(key, value) // 分裂后, 待插入节点是当前节点的子节点
    }
    break
}

// 进行上下分裂
func (n *Node[T]) split(splitIndex int) {
	var empty T
	child := &Node[T]{
		key:      n.key[splitIndex:],
		value:    n.value,
		t:        n.t,
		children: n.children,
	}

	n.key = n.key[:splitIndex]
	n.value = empty
	n.t = IndexNode
	n.children = []*Node[T]{child}
}

RemoveNode

func (t *RadixTree[T]) RemoveNode(key []byte) (T, bool) {
	var empty T
	curNode := t.root
	var pre *Node[T]
	for curNode != nil {
		isMe, _, _, isNext, _, next := curNode.checkNewKey(key)
		if isNext {
			pre = curNode
			key = key[len(curNode.key):]
			curNode = next
			continue
		}

		if isMe && curNode.t == ValueNode {
			break
		}

		curNode = nil
	}

	if curNode == nil {
		return empty, false
	}

	ans := curNode.value
	curNode.t = IndexNode
	curNode.merge()

	if len(curNode.children) == 0 && curNode.t == IndexNode {
		index, isEmpty := curNode.Index()
		if isEmpty {
			if !bytes.Equal(t.root.key, curNode.key) {
                        panic("radix tree: internal inconsistency - multiple root nodes detected")
			}
                        
			t.root = nil
			return ans, true
		}

		if pre == nil {
			if bytes.Equal(t.root.key, curNode.key) {
				t.root = nil
				return ans, true
			}

			panic("radix tree: internal inconsistency - multiple root nodes detected")
		}

		pre.remove(index)
	}

	return ans, true
}

删除操作分为三步, 第一步先找到对应的节点, 第二步删除对应的节点, 第三步调整整棵树.

上述代码中, 第一个for循环是寻找对应的节点并记录对应的父节点(pre)用于第三步回溯.
第二步删除对应的节点, 即将待删除的节点从值节点降为索引节点, 并执行合并检查(merge):

func (n *Node[T]) merge() {
	if n.t == IndexNode && len(n.children) == 1 {
		n.key = append(n.key, n.children[0].key...)
		n.t = n.children[0].t
		n.value = n.children[0].value
		n.children = n.children[0].children
	}
}

上述代码如果检测到当前节点是索引节点且仅有一个孩子节点, 此时当前节点没有保留的意义, 就将当前节点和孩子节点合并.

第三步主要做的事情是判断删除的节点是否是叶子节点, 如果是叶子节点, 则移除该节点:

{
    index, isEmpty := curNode.Index()
    if isEmpty {
            t.root = nil
            return ans, true
    }

    if pre == nil {
            if bytes.Equal(t.root.key, curNode.key) {
                    t.root = nil
                    return ans, true
            }

            panic("radix tree: internal inconsistency - multiple root nodes detected")
    }

    pre.remove(index)
}

前两个if是判断删除的节点是否是根节点, 第一个if判断当前节点key是否是空字符串, 树中只有根节点才是空字符串, 第二个if如果没有父节点, 则当前节点只能为空节点.

移除操作:

func (n *Node[T]) remove(index byte) {
	pos := sort.Search(len(n.children), func(i int) bool {
		if len(n.children) <= i {
			return false
		}

		got, isEmpty := n.children[i].Index()
		if isEmpty {
			panic("the empty key node must not to be a child")
		}

		return got >= index
	})

	if pos < 0 {
		return
	}

	if len(n.children) == 1 {
		n.children = nil
	}

	copy(n.children[pos:], n.children[pos+1:])
	n.children[len(n.children)-1] = nil
	n.children = n.children[:len(n.children)-1]
	n.merge() // 移除后当前节点如果符合合并条件, 则进行合并
}

Find

func (t *RadixTree[T]) Find(key []byte) (T, bool) {
	curNode := t.root
	for curNode != nil {
		isMe, _, _, isNext, _, next := curNode.checkNewKey(key)
		if isNext {
			key = key[len(curNode.key):]
			curNode = next
			continue
		}

		if isMe && curNode.t == ValueNode {
			return curNode.value, true
		}

		break
	}

	var empty T
	return empty, false
}

后记

至此我们简单实现了一个基于字符串检索且用字节当作基数的基数树. 该数据结构查询和操作的时间复杂度基于字符串的长度n, 即O(n), 不过有一定的应用场景限制.

初稿定于 2026.6.14