二分探索
実装上の注意
区間の抽象化
RangeBounds<T>トレイトはRange<T>をはじめとするすべての区間に実装されています。
これを活用すると配列への柔軟なアクセスを実現できます。
とくに、range_query(start.., /* something */)でstartから末尾までの区間に対する演算結果を求めるようにできます。
Rustのimpl Traitはコンパイル時に具体的な型に置き換わります。
そして、具体的なBoundに対するmatch式はゼロコストです。
結果として、区間の抽象化コストはゼロになります。
Compiler Exploderでの実験
Compiler Exploderでの実験
rustc1.89.0で実験した。
rangeの方はコンパイル時に解決され、具体的な型に置き換えられる。
具体的な型に対して最適化ビルドすると、無意味なmatch式は削除される。
#![allow(unused)] fn main() { use std::ops::{Range, RangeBounds, RangeToInclusive}; pub fn convert_range(range: RangeToInclusive<usize>) -> Range<usize> { let start = match range.start_bound() { std::ops::Bound::Included(start) => *start, std::ops::Bound::Excluded(start) => start .checked_add(1) .expect("starting point of the given range is less than `usize::MAX`"), std::ops::Bound::Unbounded => 0, }; let end = match range.end_bound() { std::ops::Bound::Excluded(end) => *end, std::ops::Bound::Included(end) => end .checked_add(1) .expect("end point of the given range is less than `usize::MAX`"), std::ops::Bound::Unbounded => 0, }; start..end } }
convert_range:
cmp rdi, -1
je .LBB0_2 // expectでerrorになると、panic処理にジャンプする
inc rdi
xor eax, eax
mov rdx, rdi
ret
.LBB0_2:
push rax
lea rdi, [rip + .Lanon.d35b42e3d053be306c0ef31fbc2b70b5.0]
lea rdx, [rip + .Lanon.d35b42e3d053be306c0ef31fbc2b70b5.2]
mov esi, 54
call qword ptr [rip + core::option::expect_failed::hfe7afbd436ce9c45@GOTPCREL]
.Lanon.d35b42e3d053be306c0ef31fbc2b70b5.0:
.ascii "end point of the given range is less than `usize::MAX`"
.Lanon.d35b42e3d053be306c0ef31fbc2b70b5.1:
.asciz "/app/example.rs"
.Lanon.d35b42e3d053be306c0ef31fbc2b70b5.2:
.quad .Lanon.d35b42e3d053be306c0ef31fbc2b70b5.1
.asciz "\020\000\000\000\000\000\000\000\026\000\000\000\016\000\000"
オーバーフロー
各セグメントに対応する区間は半開区間で表現するのが自然ですが、などが与えられた場合には、端点にを足す必要があります。
このとき、usize::MAX + 1でオーバーフローが発生する可能性があります。
配列の容量の最大値はisize::MAXなので、implicit1な実装の場合はpanic!()するべきです。
isize::MAXよりも大きなusize::MAXをインデックスを用いること自体がバグだからです。
動的木の場合は上記のような理由付けはできません。
受け取る区間をRange<usize>に限定するか、usize::MAX要素目を特別扱いするなどの方法が考えられます。
-
セグメント木を配列に格納し、インデックスを通じて暗黙的に親子関係を表現している、という意味。 ↩
サイズ
セグメント木のサイズは、要素数をとしてとかけます。
末尾の余分な要素を除去して、n.next_power_of_two() + n + (n & 1)とすることもできます。サイズを偶数にしておくと実装が楽です。
さらに、サイズをにすることもできます。 メモリの使用量が少なく、とくにのとき、使用するメモリの比はおよそです。 しかしながら、いくつかのアルゴリズムの実装が複雑になります。
の立っているビットごとに完全二分木を作ることもできます。
高々個の木を管理する必要があるため、サイズはです。
追加のメモリコストを支払う代わりに不正なセグメントができないため、実装がシンプルになる可能性があります。
添え字を木と対応づけるためにはusize::MAX - (N - i).leading_zeros()などとすればよいです1。
ただし、サイズの木が配列のk要素目に格納されています。
特別な場合
のとき、ビット演算をうまく使った実装が壊れることがあります。
たとえば、i >>= i.trailing_zeros()などです。
小さなについて、網羅的に単体テストをすると、この種のバグを発見できます。
区間和クエリを確かめる場合、で初期化してておくと、セグメントの和とサイズが一致する。
#![allow(unused)] fn main() { #[test] fn ones() { const MAX_SIZE: isize = 200; const OFFSET: isize = 10; for size in 0..MAX_SIZE { let range_sum_query = SegmentTree::<Add<isize>>::from_iter(std::iter::repeat_n(1, size as usize)); for start in 0..=size { for sum in -OFFSET..=size as isize + OFFSET { assert_eq!( range_sum_query.partition_end(start as usize, |&v| v <= sum), (start + sum).clamp(start, size) as usize, "size: {size}, start: {start}, sum: {sum}" ) } } } } }
-
区間クエリの終端が
Nでないことが分かっている場合、(N - i).ilog2()とできます。 ↩
遅延伝搬
遅延伝搬の法方には2種類あります。 遅延配列に操作を格納するときにデータ列に対して個別に反映する方法と、遅延伝搬のときにまとめて反映する方法です。 後者の方法は一見効率的ですが、再計算や区間クエリでデータ列にアクセスする度に遅延していた操作を反映する必要があります。 取得クエリが多い場合は前者、更新クエリが多い場合は後者の方が有利ですが、ほとんど同じです。 実装しやすい方法採用してよいです。
代数的構造
seg_libでは抽象化セグメント木を実装するためにいくつかのトレイトを定義しています。
この章ではこれらのトレイトが表現する代数的構造とその背景について解説します。
ただし、数学的に厳密な議論は行いません(できない)。
モノイド
セグメント木にはモノイドが乗りますが、これは十分条件です。 セグメント木には半群も乗ります。
セグメント木に半群を乗せる場合、未初期化のノードはNoneで表現することになります。
すなわち、ノードにはOption<S>が格納されます。
Option<S>上の二項演算を以下で定義すると、半群をモノイドに拡張できます。
#![allow(unused)] fn main() { fn binary_operation<S>(lhs: Option<S>, rhs: Option<S>) -> Option<S> { match (lhs, rhs) { (Some(lhs), Some(rhs)) => todo!("lhs ⋅ rhs"), (Some(lhs), None) => Some(lhs), (None, Some(rhs)) => Some(rhs), (None, None) => None } } }
このようにモノイドと半群の区別はあまり重要ではありません。
実装上単位元があると便利なので、seg_libではモノイドを採用しました。
#![allow(unused)] fn main() { pub trait Monoid { /// The set of the monoid. type Set; /// If [`Self::combine`] is commutative, some operations can be optimized. /// /// If unsure about the commutativity, use [`false`] for safety. /// /// # Commutative low /// /// ```text /// a · b = b · a ∀ a, b ∈ Set /// ``` const IS_COMMUTATIVE: bool; /// Returns the identity element. fn identity() -> Self::Set; /// Combines the two elements and returns the result. /// /// # Warning /// /// If the operation is **not** commutative, the position of the arguments matters. fn combine(lhs_or_prev: &Self::Set, rhs_or_new: &Self::Set) -> Self::Set; } }
Setをジェネリック型ではなく関連型としてもっています。
こうすることで、ジェネリックパラメーターが1つ減り、実装がシンプルになります。
#![allow(unused)] fn main() { pub struct SegmentTree<Query> where Query: Monoid, { /// Use `Box<T>` because the length is significant as follows. /// /// - data\[0\] : dummy node (meaningless) /// - data\[1..n\] : nodes to store the combined value of the children. /// - data\[n..2n\]: nodes to store value for each cell. data: Box<[<Query as Monoid>::Set]>, /// `len` (number of elements) and offset (dummy + cache) len_or_offset: usize, } }
おまけ
セグメント木には半群ですらない代数的構造も乗ります。 問題にしている範囲で半群のように振舞えば何でもよいのです。
あるデータ列について、隣り合う要素の間に結合的な二項演算が定義されているとき、これはセグメント木に乗る。 とくに、隣り合わないノードの間に演算が定義されていなくともよい。
モノイド作用
遅延伝搬セグメント木ではいくつかの要素をまとめて更新します。 取得クエリに対応するモノイドを、更新クエリに対応するモノイドをとかきます。 モノイド作用は2つのモノイドをつなぐ糊のようなものです。 遅延伝搬セグメント木が正しく動作するためには「いくつかの要素をまとめて更新する」ことで「各要素をそれぞれ更新した結果」を再現できなければなりません。
#![allow(unused)] fn main() { pub trait MonoidAction { /// The set of the monoid for update operation. type Map: Monoid; /// The set of the monoid for query operation. type Set: Monoid; /// Set [`true`] to use the segment size in [`Self::act()`] const USE_SEGMENT_SIZE: bool; /// Acts the mapping on the element and returns the result. /// /// # Size dependency /// /// You can access segment size if you want. /// This is equivalent to attaching the segment size information to [`Self::Set`] but more performant. fn act( mapping: &<Self::Map as Monoid>::Set, element: &<Self::Set as Monoid>::Set, size: Option<usize>, ) -> <Self::Set as Monoid>::Set; } }
Mapは更新クエリ、Setは取得クエリに対応します。
#![allow(unused)] fn main() { pub struct LazySegmentTree<Action> where Action: MonoidAction, { data: Box<[<<Action as MonoidAction>::Set as Monoid>::Set]>, lazy: Box<[<<Action as MonoidAction>::Map as Monoid>::Set]>, /// calculate if [`MonoidAction::USE_SEGMENT_SIZE`] is `true`. segment_size: Option<Box<[usize]>>, } }
区間幅の利用
モノイド作用に区間幅の情報を利用したいことがあります。 これは取得クエリに区間幅の情報を付加する()ことで実現できますが、 不変量を何度も計算し直すことになり非効率です。 初期化時にで計算し、計算済みの値を取得すると良いです。
定義済み演算
seg_libを利用するたびにMonoidトレイトなどを実装するのは面倒です。
そこで、典型的なモノイドのテンプレートを用意しました。
一覧はドキュメントから確認できます。
モノイドのテンプレート
#![allow(unused)] fn main() { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct Add<T>(PhantomData<T>); impl<T> Monoid for Add<T> where T: Zero, for<'a> &'a T: std::ops::Add<Output = T>, { type Set = T; const IS_COMMUTATIVE: bool = true; fn identity() -> Self::Set { T::zero() } fn combine(lhs_or_prev: &Self::Set, rhs_or_new: &Self::Set) -> Self::Set { lhs_or_prev + rhs_or_new } } }
例えば、Add<i32>はモノイド(i32, +, 0)に対応します。
トレイト境界のとり方には他にもいくつかのパターンが考えられます。
T: Copy + Zero + std::ops::Add<Output = T>T: Clone + Zero + std::ops::Add<Output = T>
これらは、combine()の挙動に影響を与えます。
最適な実装は型に依るため、ユーザーに定義してもらうことにしました。
モノイド作用について
現在の実装では関連型にモノイドのテンプレートを利用することはできますが、act()はユーザーが定義することになっています。
将来的には定義済み演算の間のモノイド作用を全て提供することも考えています。
区間幅の情報が必要な場合にusizeから適切に型を変換する必要があり、num_traitsのFromPrimitiveが利用できます。
動的セグメント木
動的セグメント木は必要なノードだけを作成することで、巨大()な配列でもセグメント木を構築できるようにしたものです。
実装方針
高速化のために以下の方針を採用します:
- ポインターではなくアリーナ1で木をつくる
- 非再帰実装
また、簡単のために、追加の制約を課します:
Range<isize>で初期化し、区間幅は固定- 単位元で初期化
#![allow(unused)] fn main() { pub struct DynamicSegmentTree<Query> where Query: Monoid, { arena: Vec<Node<<Query as Monoid>::Set>>, range: Range<isize>, // save allocation cost reusable_stack: Vec<usize>, } }
Node<T>の詳細は後述します。
非可換モノイドでは要素の合成順が重要です。
後述のように、正しい順序で要素を合成するためにスタックを使用します。
reusable_stackはスタックの長さがで抑えられることを利用して、アロケーションコストを節約するためにあります。
計算量
区間幅をとし、ある時点での更新クエリの数をとします。
| arena | range query | point update | |
|---|---|---|---|
| time/space complexity |
実装例
初期化子
seg_libにおいて動的セグメント木の区間は初期値で固定されます。
Range型は不正な区間(1..0など)を持つことができるため、これをケアする必要があります。
区間幅がゼロの場合もまとめてrange.is_empty()で処理できます。
#![allow(unused)] fn main() { pub fn with_capacity(range: Range<isize>, q: usize) -> Option<Self> { if range.is_empty() { None } else { // never panic: `range.len()` is always larger than 0 let height = range.len().ilog2() as usize + 1; Some(Self { arena: Vec::with_capacity(q * height), reusable_stack: Vec::with_capacity(height * 4), range, }) } } }
seg_libはcrates.ioで公開しているため、返り値にOption型を取ることにしました。
競プロなどではpanic!()するのもありです。
単位元以外の値で初期化することもできます。
可換モノイドの場合は簡単で、以下のようにしてモノイドに更新済みノード数を持たせるだけです。
初期値をもつノードの数は区間幅 - 更新済みノード数で与えられるので、繰り返し自乗法などをもちいてで計算できます。
#![allow(unused)] fn main() { use seg_lib::{DynamicSegmentTree, ops::Add}; let mut dst = DynamicSegmentTree::<(/* operation */, Add<usize>)>::new(/* range */); dst.point_update(/* index */, (/* value */, 1)); let (combined, n) = dst.range_query(/* sub range */); }
非可換モノイドを扱う場合、計算順序を守るために、実装に踏み入る必要があります。 ノードごとにかかるので、取得クエリ・更新クエリの計算量がに悪化します。 または、動的遅延伝搬セグメント木を利用してもよいです。
ノード
動的セグメント木では必要最小限のノードを作成することで、空間計算量を実現します。
通常のセグメント木のように、各ノードに対応する区間の途中結果をもつ場合、空間計算量がになってしまいます。 対応する区間に更新されたノードが1つしかない場合にノードの作成を遅延することで、空間計算量をに削減できます。
#![allow(unused)] fn main() { struct Node<T> { index: isize, element: T, /// may be `None` if `combined == element`, avoiding `clone()` combined: Option<T>, left_ptr: Option<NonZeroUsize>, right_ptr: Option<NonZeroUsize>, } }
seg_libの動的セグメント木では区間幅を固定しているので、各ノードが持つ区間情報は暗黙的に与えられます。 区間情報は根から子を辿る際に計算できるので、ノードには持ちません。- ノードに対応する区間の途中結果には
Option型を採用しました。 これはトレイト境界<Query as Monoid>::Set: Cloneを回避するためです。 - 通常
Option型は追加のフラグに8byte(64bitシステムの場合)利用しますが、Option<NonZero<T>>では0をNoneと扱う最適化が施されており、追加のメモリは必要ありません。arenaの0要素目は根なので、子のインデックスは常に正です。
更新クエリ
あるノードに対応する区間で2つめのノードが更新される場合、新たにノードを作成する必要があります。
このとき、左の子のインデックス < 親のインデックス < 右の子のインデックスの関係を保持する必要があります。
また、途中結果を帰りがけ順序で再計算する必要があります。
ここで、reusable_stackを活用してアロケーションコストを節約します。
取得クエリ
動的セグメント木においてもっとも複雑な部分です。
観察
区間クエリが1つのノードに収まる場合と、2つのノードにまたがる場合で場合分けができます。
タイプ1の場合、次に訪問するノードが右(左)の子ならば、合成順序は最後のノードよりも左(右)側です。
また、訪問順序が早いノードほど外側にあることが分かります。
reusable_stackを活用することで、この順序を再現できます。
タイプ2の場合に左の子を祖先に持つノード群を合成することを考えます。
L1から始めて、次に外側のL3に進むので、内側にあるL2は答えに含まれます。
L1自身の持つノードは外側から合成します。
L3の次は内側のノードであるL4に進みます。
L4の配下は明示されていないので何とも言えませんが、L3のノードを合成する場合は一番外側からであることは保証されます。
したがって、L3の持つノードへのポインター2をreusable_stackに格納します。
右側の子孫たちの合成順序で確認してみてください。
R1の次は内側に進むので、reusable_stackに格納します。
R2の次は外側に進むので、R3を右から合成し、R2を右から合成します。
reusable_stackにおける左右の区別
ここまでで、reusable_stackには0個以上のポインターが格納されています。
ポインターはただのusizeなので、左右の区別をするために工夫をする必要があります。
ポインターはアリーナのインデックスであり、アリーナはVecです。
実は、Vecの容量の最大値はisize::MAXと決まっています。
たとえば、Vec::with_capacity()でisize::MAXより大きな容量を与えるとpanicします。
したがって、usizeの最上位ビットをフラグとして利用できます。
#![allow(unused)] fn main() { while let Some(ptr) = self.reusable_stack.pop() { const MSB: usize = 1_usize.rotate_right(1); res = if ptr & MSB == 0 { <Query as Monoid>::combine(self.arena[ptr].get_element(), &res) } else { <Query as Monoid>::combine(&res, self.arena[!ptr].get_element()) } } }
resはタイプ2の場合の内側の計算結果です。
左側のノードへのポインターはそのまま、右側のノードへのポインターはビット反転してから、reusable_stackに格納しています。
-
配列上にノードを格納し、インデックスをポインター代わりにするテクニックのこと。キャッシュ効率が良く、所有権の管理が容易。 ↩
-
ノードのインデックスと区別するために、アリーナ上のインデックスをポインターと呼ぶことにします。 ↩