package b_plus_tree import ( "errors" "slices" ) var ErrKeyNotFound = errors.New("key not found") type Entry[K any, V any] struct { key K value V } func NewEntry[K any, V any](key K, value V) *Entry[K, V] { return &Entry[K, V]{key: key, value: value} } func (e *Entry[K, V]) GetKey() K { return e.key } func (e *Entry[K, V]) GetValue() V { return e.value } type Node[K any, V any] interface { isLeaf() bool keyLen() int getParent() *InternalNode[K, V] setParent(parent *InternalNode[K, V]) } type InternalNode[K any, V any] struct { keys []K children []Node[K, V] parent *InternalNode[K, V] } func (i *InternalNode[K, V]) isLeaf() bool { return false } func (i *InternalNode[K, V]) keyLen() int { return len(i.keys) } func (i *InternalNode[K, V]) getParent() *InternalNode[K, V] { return i.parent } func (i *InternalNode[K, V]) setParent(parent *InternalNode[K, V]) { i.parent = parent } type LeafNode[K any, V any] struct { keys []K values []V next *LeafNode[K, V] previous *LeafNode[K, V] parent *InternalNode[K, V] } func (l *LeafNode[K, V]) isLeaf() bool { return true } func (l *LeafNode[K, V]) keyLen() int { return len(l.keys) } func (l *LeafNode[K, V]) getParent() *InternalNode[K, V] { return l.parent } func (l *LeafNode[K, V]) setParent(parent *InternalNode[K, V]) { l.parent = parent } func setChildrenParent[K any, V any](children []Node[K, V], parent *InternalNode[K, V]) { for _, child := range children { child.setParent(parent) } } type BPlusTree[K any, V any] struct { order int cmp func(a, b K) int root Node[K, V] } func NewBPlusTree[K any, V any](order int, cmp func(a, b K) int) *BPlusTree[K, V] { if order < 3 { panic("order must be >= 3") } if cmp == nil { panic("cmp must not be nil") } return &BPlusTree[K, V]{ order: order, cmp: cmp, root: &LeafNode[K, V]{}, } } func (t *BPlusTree[K, V]) Get(key K) (V, error) { leaf := t.findLeaf(key) index := t.findKeyIndex(leaf.keys, key) if index < len(leaf.keys) && t.cmp(leaf.keys[index], key) == 0 { return leaf.values[index], nil } var zero V return zero, ErrKeyNotFound } func (t *BPlusTree[K, V]) ContainsKey(key K) bool { _, err := t.Get(key) return err == nil } func (t *BPlusTree[K, V]) Put(key K, value V) error { leaf := t.findLeaf(key) index := t.findKeyIndex(leaf.keys, key) if index < len(leaf.keys) && t.cmp(leaf.keys[index], key) == 0 { leaf.values[index] = value return nil } leaf.keys = slices.Insert(leaf.keys, index, key) leaf.values = slices.Insert(leaf.values, index, value) if len(leaf.keys) > t.maxKeys() { t.splitLeaf(leaf) } return nil } func (t *BPlusTree[K, V]) Remove(key K) bool { leaf := t.findLeaf(key) index := t.findKeyIndex(leaf.keys, key) if index >= len(leaf.keys) || t.cmp(leaf.keys[index], key) != 0 { return false } minKeyRemoved := index == 0 leaf.keys = slices.Delete(leaf.keys, index, index+1) leaf.values = slices.Delete(leaf.values, index, index+1) if leaf == t.root { return true } if minKeyRemoved && len(leaf.keys) > 0 { t.updateAncestorKeys(leaf) } if len(leaf.keys) < t.minLeafKeys() { t.rebalanceLeaf(leaf) } return true } func (t *BPlusTree[K, V]) RangeSearch(fromKey, toKey K) []*Entry[K, V] { if t.cmp(fromKey, toKey) > 0 { panic("fromKey must be <= toKey") } result := []*Entry[K, V]{} leaf := t.findLeaf(fromKey) for leaf != nil { for i := 0; i < len(leaf.keys); i++ { key := leaf.keys[i] if t.cmp(key, fromKey) < 0 { continue } if t.cmp(key, toKey) > 0 { return result } result = append(result, NewEntry( key, leaf.values[i], )) } leaf = leaf.next } return result } func (t *BPlusTree[K, V]) findLeaf(key K) *LeafNode[K, V] { current := t.root for !current.isLeaf() { internal := current.(*InternalNode[K, V]) index := t.upperBound(internal.keys, key) current = internal.children[index] } return current.(*LeafNode[K, V]) } func (t *BPlusTree[K, V]) upperBound(keys []K, key K) int { low, high := 0, len(keys) for low < high { middle := (low + high) >> 1 if t.cmp(keys[middle], key) <= 0 { low = middle + 1 } else { high = middle } } return low } func (t *BPlusTree[K, V]) splitLeaf(leaf *LeafNode[K, V]) { splitIndex := (len(leaf.keys) + 1) / 2 right := &LeafNode[K, V]{ keys: append([]K{}, leaf.keys[splitIndex:]...), values: append([]V{}, leaf.values[splitIndex:]...), } leaf.keys = leaf.keys[:splitIndex] leaf.values = leaf.values[:splitIndex] right.next = leaf.next if leaf.next != nil { leaf.next.previous = right } leaf.next = right right.previous = leaf separator := right.keys[0] t.insertIntoParent( leaf, separator, right, ) } func (t *BPlusTree[K, V]) insertIntoParent(left Node[K, V], separator K, right Node[K, V]) { if left == t.root { newRoot := &InternalNode[K, V]{} newRoot.keys = append(newRoot.keys, separator) newRoot.children = append(newRoot.children, left) newRoot.children = append(newRoot.children, right) left.setParent(newRoot) right.setParent(newRoot) t.root = newRoot return } parent := left.getParent() childIndex := slices.Index(parent.children, left) parent.keys = slices.Insert(parent.keys, childIndex, separator) parent.children = slices.Insert(parent.children, childIndex+1, right) right.setParent(parent) if len(parent.children) > t.order { t.splitInternal(parent) } } func (t *BPlusTree[K, V]) splitInternal(node *InternalNode[K, V]) { middleIndex := len(node.keys) / 2 separator := node.keys[middleIndex] right := &InternalNode[K, V]{} for i := middleIndex + 1; i < len(node.keys); i++ { right.keys = append(right.keys, node.keys[i]) } for i := middleIndex + 1; i < len(node.children); i++ { right.children = append(right.children, node.children[i]) } setChildrenParent(right.children, right) node.keys = node.keys[:middleIndex] node.children = node.children[:middleIndex+1] t.insertIntoParent( node, separator, right, ) } func (t *BPlusTree[K, V]) rebalanceLeaf(leaf *LeafNode[K, V]) { parent := leaf.getParent() index := slices.IndexFunc(parent.children, func(child Node[K, V]) bool { return child == Node[K, V](leaf) }) var leftSibling *LeafNode[K, V] var rightSibling *LeafNode[K, V] if index > 0 { node := parent.children[index-1] if node.isLeaf() { leftSibling = node.(*LeafNode[K, V]) } } if index+1 < len(parent.children) { node := parent.children[index+1] if node.isLeaf() { rightSibling = node.(*LeafNode[K, V]) } } // 左兄弟から借りる if leftSibling != nil && len(leftSibling.keys) > t.minLeafKeys() { last := len(leftSibling.keys) - 1 borrowedKey := leftSibling.keys[last] borrowedValue := leftSibling.values[last] leftSibling.keys = leftSibling.keys[:last] leftSibling.values = leftSibling.values[:last] leaf.keys = append([]K{borrowedKey}, leaf.keys...) leaf.values = append([]V{borrowedValue}, leaf.values...) parent.keys[index-1] = leaf.keys[0] return } // 右兄弟から借りる if rightSibling != nil && len(rightSibling.keys) > t.minLeafKeys() { borrowedKey := rightSibling.keys[0] borrowedValue := rightSibling.values[0] rightSibling.keys = rightSibling.keys[1:] rightSibling.values = rightSibling.values[1:] leaf.keys = append(leaf.keys, borrowedKey) leaf.values = append(leaf.values, borrowedValue) parent.keys[index] = rightSibling.keys[0] return } // 借りられなければmerge if leftSibling != nil { t.mergeLeaves( parent, index-1, leftSibling, leaf, ) } else if rightSibling != nil { t.mergeLeaves( parent, index, leaf, rightSibling, ) } } func (t *BPlusTree[K, V]) mergeLeaves(parent *InternalNode[K, V], separatorIndex int, left, right *LeafNode[K, V]) { left.keys = append(left.keys, right.keys...) left.values = append(left.values, right.values...) left.next = right.next if right.next != nil { right.next.previous = left } // 親からseparatorを削除 parent.keys = slices.Delete(parent.keys, separatorIndex, separatorIndex+1) parent.children = slices.Delete(parent.children, separatorIndex+1, separatorIndex+2) if parent == t.root && len(parent.keys) == 0 { t.root = left left.previous = nil left.setParent(nil) return } if parent != t.root && len(parent.children) < t.minInternalChildren() { t.rebalanceInternal(parent) } t.updateAncestorKeys(left) } func (t *BPlusTree[K, V]) rebalanceInternal(node *InternalNode[K, V]) { if node == t.root { if len(node.keys) == 0 && len(node.children) == 1 { t.root = node.children[0] node.children[0].setParent(nil) } return } parent := node.getParent() index := slices.IndexFunc(parent.children, func(child Node[K, V]) bool { return child == Node[K, V](node) }) var leftSibling *InternalNode[K, V] = nil var rightSibling *InternalNode[K, V] = nil if index > 0 { sibling := parent.children[index-1] if !sibling.isLeaf() { leftSibling = sibling.(*InternalNode[K, V]) } } if index+1 < len(parent.children) { sibling := parent.children[index+1] if !sibling.isLeaf() { rightSibling = sibling.(*InternalNode[K, V]) } } // 左兄弟から借りる if leftSibling != nil && len(leftSibling.children) > t.minInternalChildren() { parentSeparator := parent.keys[index-1] lastChild := len(leftSibling.children) - 1 movedChild := leftSibling.children[lastChild] leftSibling.children = leftSibling.children[:lastChild] lastKey := len(leftSibling.keys) - 1 newSeparator := leftSibling.keys[lastKey] leftSibling.keys = leftSibling.keys[:lastKey] node.keys = append([]K{parentSeparator}, node.keys...) node.children = append([]Node[K, V]{movedChild}, node.children...) movedChild.setParent(node) parent.keys[index-1] = newSeparator return } // 右兄弟から借りる if rightSibling != nil && len(rightSibling.children) > t.minInternalChildren() { parentSeparator := parent.keys[index] movedChild := rightSibling.children[0] rightSibling.children = rightSibling.children[1:] newSeparator := rightSibling.keys[0] rightSibling.keys = rightSibling.keys[1:] node.keys = append(node.keys, parentSeparator) node.children = append(node.children, movedChild) movedChild.setParent(node) parent.keys[index] = newSeparator return } //借りられなければmerge if leftSibling != nil { t.mergeInternal( parent, index-1, leftSibling, node, ) } else if rightSibling != nil { t.mergeInternal( parent, index, node, rightSibling, ) } } // 内部ノードを結合 func (t *BPlusTree[K, V]) mergeInternal(parent *InternalNode[K, V], separatorIndex int, left, right *InternalNode[K, V]) { separator := parent.keys[separatorIndex] parent.keys = slices.Delete(parent.keys, separatorIndex, separatorIndex+1) left.keys = append(left.keys, separator) left.keys = append(left.keys, right.keys...) left.children = append(left.children, right.children...) setChildrenParent(right.children, left) parent.children = slices.Delete(parent.children, separatorIndex+1, separatorIndex+2) if parent == t.root && len(parent.keys) == 0 { t.root = left left.setParent(nil) return } if parent != t.root && len(parent.children) < t.minInternalChildren() { t.rebalanceInternal(parent) } } func (t *BPlusTree[K, V]) updateAncestorKeys(node Node[K, V]) { if node == t.root { return } parent := node.getParent() if parent == nil { return } index := slices.IndexFunc(parent.children, func(child Node[K, V]) bool { return child == node }) if index > 0 && node.keyLen() != 0 { parent.keys[index-1] = t.firstKey(node) return } if index == 0 { t.updateAncestorKeys(parent) } } func (t *BPlusTree[K, V]) firstKey(node Node[K, V]) K { current := node for !current.isLeaf() { current = current.(*InternalNode[K, V]).children[0] } leaf := current.(*LeafNode[K, V]) return leaf.keys[0] } func (t *BPlusTree[K, V]) findKeyIndex(keys []K, key K) int { var low int = 0 var high int = len(keys) for low < high { middle := (low + high) >> 1 if t.cmp(keys[middle], key) < 0 { low = middle + 1 } else { high = middle } } return low } func (t *BPlusTree[K, V]) maxKeys() int { return t.order - 1 } func (t *BPlusTree[K, V]) minLeafKeys() int { return t.order / 2 } func (t *BPlusTree[K, V]) minInternalChildren() int { return (t.order + 1) / 2 } func (t *BPlusTree[K, V]) entries() []*Entry[K, V] { result := []*Entry[K, V]{} leaf := t.firstLeaf() for leaf != nil { for i := 0; i < len(leaf.keys); i++ { result = append(result, NewEntry(leaf.keys[i], leaf.values[i])) } leaf = leaf.next } return result } func (t *BPlusTree[K, V]) firstLeaf() *LeafNode[K, V] { current := t.root for !current.isLeaf() { current = current.(*InternalNode[K, V]).children[0] } return current.(*LeafNode[K, V]) }
追記
AIにベンチを取ってもらった
B+木 (B+Tree) 実装解説
b_plus_tree.go に実装されたジェネリックな B+木 (BPlusTree[K, V]) について、アルゴリズムの解説と、テスト・ベンチマークの実行結果の解説をまとめる。
1. アルゴリズム解説
1.1 データ構造
InternalNode[K, V]:keys []Kとchildren []Node[K, V]を持つ内部ノード。len(children) == len(keys) + 1が常に成り立つ(B木の一般形と同じ)。LeafNode[K, V]:keys []Kとvalues []Vを持つ葉ノード。さらにnext/previousで兄弟の葉ノード同士を連結リストとして繋いでいる。これが B+木特有の構造で、範囲検索を葉のリストの走査だけで済ませるためのもの。- 各ノードは
parent *InternalNode[K, V]を保持し、下から上へ辿れる(分割・再構成・別ノードの先頭キー更新に使う)。 order(次数)によって「1ノードあたりの最大子数」が決まる。- 最大キー数(葉):
order - 1 - 最小キー数(葉、root除く):
order / 2 - 最小子数(内部ノード、root除く):
(order + 1) / 2
- 最大キー数(葉):
B木との違いは、すべてのデータ(value)が葉ノードにのみ格納される点。内部ノードのキーは「どちらの部分木を辿るか」を決めるための道しるべ(セパレータ)に過ぎず、実際のデータを持たない。これにより
- 内部ノードが軽量になり、1ノードに収まるセパレータ数(=ファンアウト)を増やせる → 木の高さが低くなる → ディスクI/Oやキャッシュミスが減る(実務でDB/ファイルシステムのインデックスに多用される理由)。
- 葉が連結リストになっているため、範囲検索(
RangeSearch)が「開始位置を1回だけ二分探索し、あとはnextを辿るだけ」で実現できる。
1.2 検索 (Get / findLeaf)
findLeaf はルートから葉まで、各内部ノードで upperBound(二分探索でキーより真に大きい最初の位置を探す = 何番目の子に降りるか)を使って一直線に降りていく。計算量は O(log n)。葉に着いたら findKeyIndex(下限二分探索)でキー位置を特定する。
findLeaf: ルート → 内部ノード upperBound で子を選択 → ... → 葉 Get: findLeaf → findKeyIndex で二分探索 → 一致すれば値を返す
1.3 挿入 (Put)
findLeafで挿入すべき葉を特定。findKeyIndexで挿入位置を二分探索。既存キーなら値を上書き(更新)。- 新規キーならソート順を保ったまま
slices.Insertで挿入。 - 葉のキー数が
maxKeys()(=order-1)を超えたらsplitLeafで葉を分割。- 右半分を新しい葉として切り出し、
next/previousの連結を繋ぎ直す。 - 新しい葉の先頭キーを「セパレータ」として親に挿入 (
insertIntoParent)。
- 右半分を新しい葉として切り出し、
- 親(内部ノード)の子数が
orderを超えたらsplitInternalで内部ノードも同様に分割し、これを再帰的に繰り返す(伝播)。ルートが分割されると新しいルートが1つ生成され、木の高さが1増える。
挿入は常に葉から根に向かって伝播するボトムアップ方式で、最悪計算量は O(log n)。
1.4 削除 (Remove)
findLeaf+findKeyIndexでキーを特定して削除。- 削除したキーが葉の先頭キーだった場合、親(および必要なら祖先)が持つセパレータが古くなるので
updateAncestorKeysで修正する。これは borrow(後述)によるセパレータ更新が「自分の右側」しか直さないため、削除の場合は先に明示的に直しておく必要がある、という設計上の工夫。 - 葉のキー数が
minLeafKeys()を下回ったらrebalanceLeafで再調整:- 左兄弟に余裕があれば1キー借りる(borrow)。
- なければ右兄弟から借りる。
- どちらも余裕がなければ、兄弟とマージ (
mergeLeaves)する。
- マージで親の子が減り
minInternalChildren()を下回ったらrebalanceInternalで内部ノードも同様に borrow/merge を再帰的に行う。 - ルートの子が1つだけになったらルートを1段下げる(木が低くなる)。
削除も挿入と対称的に O(log n)。borrow/merge によって「各ノードは半分以上埋まっている」という B木の不変条件を常に維持している(これによって最悪ケースでも木の高さが O(log n) に保たれる)。
1.5 範囲検索 (RangeSearch)
fromKey を含む葉を findLeaf で1回だけ特定し、そこから next ポインタを辿って toKey を超えるまで線形に集める。内部ノードを再度辿る必要がないのが B+木の最大の利点で、計算量は O(log n + m)(m は結果件数)。
2. テスト
b_plus_tree_test.go に以下を実装・実行し、全件 PASS、カバレッジ 98.1% を確認した。
$ go test ./... -v $ go test -cover ./... ok example.com/b_plus_tree 0.853s coverage: 98.1% of statements
テスト内容
| テスト | 内容 |
|---|---|
TestNewBPlusTreePanicsOnSmallOrder / ...NilCmp |
不正なコンストラクタ引数で panic することの確認 |
TestPutAndGet / TestGetMissingKeyReturnsError |
基本の Put/Get |
TestPutUpdatesExistingKey |
既存キーへの Put が上書きになり、エントリ数が増えないこと |
TestPutAllowsZeroValue |
ゼロ値でも正しく挿入されること |
TestPutMaintainsSortedOrderAcrossSplits |
order=3,4,5,7,16 で 500件をランダム順に挿入し、挿入の都度 validateInvariants で構造不変条件(子数=キー数+1、最小占有率、親ポインタ整合性)を検証しつつ、最終的に全エントリがソート順であることを確認 |
TestRemoveExistingAndMissingKeys |
削除の基本動作、二重削除・存在しないキーの削除が false を返すこと |
TestDrainToEmptyPreservesInvariants |
order×seed の組み合わせで挿入→シャッフル削除→空になるまでの全過程で不変条件を検証(最も厳しいテスト) |
TestRangeSearch* |
範囲検索の基本動作・空区間・不正区間でのpanic・マップとの突合検証(ランダム300件×50トライアル) |
TestRandomOperationsAgainstReferenceMap |
Put/Removeをランダムに3000回混在実行し、都度 map[int]string という信頼できる参照実装と突き合わせ、かつ構造不変条件も検証する統合テスト |
TestLeafNextPreviousLinksAfterMutation |
挿入・削除を経ても葉の双方向連結リスト (next/previous) が壊れていないことを確認 |
validateInvariants はテストコード内のヘルパーで、木全体を再帰的に辿り「内部ノードの子数がキー数+1」「rootを除き最小占有率を満たす」「親子ポインタの整合性」を検証する。単なる入出力テストだけでなく内部構造の正しさを継続的に検証しているのが特徴。
3. ベンチマーク
b_plus_tree_bench_test.go を以下のコマンドで実行した(AMD Ryzen 5 5500U、Go標準の testing.B によるデフォルト時間ベース実行、-benchmem でメモリ計測も有効化)。
$ go test -run=^$ -bench=. -benchmem ./...
対象パラメータ:
- order(次数): 4, 16, 64, 128
- size(事前投入件数): 1,000 / 5,000 / 20,000
3.1 Put(挿入)
| ベンチマーク | order=4 | order=16 | order=64 | order=128 |
|---|---|---|---|---|
| PutSequential (ns/op) | 439.4 | 201.3 | 154.5 | 142.9 |
| PutSequential (allocs/op) | 4 | 0 | 0 | 0 |
| PutRandom (ns/op) | 1270 | 674.9 | 581.0 | 562.4 |
| PutRandom (allocs/op) | 2 | 0 | 0 | 0 |
解説:
- order を上げるほど速くなる傾向が明確。order が大きいほど1ノードに多くのキーを詰め込めるため、同じ件数を挿入したときの木の高さが低くなり、findLeaf で辿る内部ノードの段数(ポインタ参照とキャッシュミスの回数)が減る。一方でノード分割 (splitLeaf/splitInternal) の頻度自体は減るので、slices.Insert によるスライス移動コストの総量も相対的に減る。
- Sequential(昇順キー)が Random(乱数キー)より速い理由は、逐次挿入では毎回「末尾付近」への挿入になりやすく、Go の append/スライス操作のメモリアクセスパターンが局所的でキャッシュに乗りやすいため。乱数挿入は木全体にまんべんなくアクセスするためキャッシュ効率が落ち、また分割位置も分散するので分割コストが平均化されにくい。
- allocs/op がほぼ0であるのは、b.N 回のうち大半が「スライスの再拡張(append)が不要な単純上書き・単純insert」で収まっており、Goのスライス事前確保(capacity)が効いているため。order=4だけ数allocsが残るのは、ノード分割自体の頻度が高く(maxKeys()=3ごとに分割が起きる)、新規ノード (&LeafNode{}, &InternalNode{}) や append([]K{}, ...) によるコピー割り当てが相対的に多く発生するため。
3.2 Get(検索)
| order\size | 1,000 | 5,000 | 20,000 |
|---|---|---|---|
| order=4 | 118.8 ns | 155.1 ns | 207.3 ns |
| order=16 | 90.53 ns | 118.7 ns | 144.3 ns |
| order=64 | 85.45 ns | 107.0 ns | 122.4 ns |
| order=128 | 86.26 ns | 102.1 ns | 121.9 ns |
解説:
- 全ケースで 0 B/op, 0 allocs/op。Get は読み取り専用でメモリ確保が一切発生しないことを裏付けている。
- サイズが大きくなるほど遅くなるのは O(log n) の理論通り。ただし増加は緩やかで、20倍のデータ量(1,000→20,000)でも所要時間は2倍にも満たない(対数的な伸び)。
- order が大きいほど速いのも Put と同じ理由(木の高さが低い)。ただし order=64 と order=128 でほぼ差がない、あるいは128がわずかに遅い場合があるのは、1ノードあたりのキー数が増えすぎるとノード内の二分探索コスト(upperBound)自体が無視できなくなり、「段数削減の恩恵」と「ノード内探索コストの増加」が釣り合ってくるため。B+木の次数を実運用でチューニングする際に「大きければ大きいほど良い」わけではない典型例。
3.3 Remove(削除)
| order\size | 1,000 | 5,000 | 20,000 |
|---|---|---|---|
| order=4 | 276.8 ns (19 B) | 341.9 ns (19 B) | 431.1 ns (19 B) |
| order=16 | 208.0 ns (66 B) | 217.7 ns (69 B) | 249.2 ns (69 B) |
| order=64 | 207.1 ns (155 B) | 283.1 ns (195 B) | 313.8 ns (198 B) |
| order=128 | 222.8 ns (257 B) | 320.9 ns (363 B) | 434.7 ns (383 B) |
解説:
- 削除は Get より重い。理由は削除後に rebalanceLeaf/rebalanceInternal(borrow/merge)や updateAncestorKeys が発生しうるため、単純な二分探索より処理経路が長い。
- 興味深いのは allocs/op はほぼ0なのに B/op(確保バイト数)はorderが大きいほど増える点。これは slices.Delete や mergeInternal/mergeLeaves 内の append がスライスの再アロケーションを毎回起こしているわけではないが、Goベンチマークの B/op はGC割り当て総量の推定値であり、slices.Insert/Delete/append がキャパシティ不足時にコピーする一時領域や、slices.IndexFunc のクロージャなどが積算されている。特に order=128 ではノード内のキー・子スライスが大きいため、borrow/merge時に動かすデータ量(append(left.keys, right.keys...) 等)自体が大きく、結果的に確保サイズも増える。
- サイズが大きくなるほど遅くなるのはGetと同様に O(log n) の探索コスト増加によるもの。
3.4 RangeSearch(範囲検索)
20,000件のツリーに対し、span(範囲の広さ)を 10 / 100 / 1000 で計測。
| order\span | 10 | 100 | 1000 |
|---|---|---|---|
| order=4 | 1436 ns (16 allocs) | 9046 ns (109 allocs) | 78904 ns (1012 allocs) |
| order=16 | 1080 ns (16 allocs) | 6978 ns (109 allocs) | 60939 ns (1012 allocs) |
| order=64 | 1041 ns (16 allocs) | 6724 ns (109 allocs) | 59749 ns (1012 allocs) |
| order=128 | 1035 ns (16 allocs) | 6466 ns (109 allocs) | 56697 ns (1012 allocs) |
解説:
- span(結果件数)が10倍になると所要時間もほぼ10倍になっており、O(log n + m) の m 項(結果件数に比例するコスト)が支配的であることがはっきり出ている。これは「最初の葉を見つけたあとは next を辿るだけ」という B+木の設計が効いている証拠で、内部ノードを再訪する必要がないため件数に対してほぼ線形。
- allocs/op が span にほぼ比例(span=10→16 allocs、span=1000→1012 allocs)しているのは、RangeSearch が result スライスに append する際、Goのスライス拡張戦略(capacity 2倍拡張)によって定期的に再アロケーションが起きているため。事前に make([]*Entry[K,V], 0, estimatedSize) のように容量を見積もって確保すれば allocs は減らせる余地がある(現状は result := []*Entry[K, V]{} で容量0スタート)。
- order による差は Get ほど大きくないが、それでも order=4 が最も遅い。これは最初の findLeaf 呼び出し部分(O(log n))のコストが低次数だと大きいため。span が広がるほど「葉走査コスト」が支配的になり、order間の差(=木の高さの差)は相対的に薄まっていく(span=10 では order=4 が order=128 の約1.4倍だが、span=1000ではほぼ差がない)。
4. まとめ
- 実装は挿入・削除ともに B+木の不変条件(キー数/子数のバランス、葉の連結リストの整合性)を正しく維持しており、3000回のランダムPut/Remove操作とGoの
mapを突き合わせる統合テストでも一致することを確認した。 - ベンチマークからは以下の設計上の示唆が得られる。
- order(次数)を上げると Put/Get は速くなるが、上げすぎるとノード内探索コストが無視できなくなり頭打ちになる(order=64 と 128 でほぼ差がない、または逆転するケースがある)。
- RangeSearch は結果件数に比例するコストが支配的で、B+木の葉連結リスト構造の恩恵がベンチマーク上も明確に確認できた。
- Remove は Put/Get より重く、特に高次数ではノード間のデータ移動量(borrow/merge時の
append)がメモリ確保量に反映される。