De Bruijn Sequence
- 2018/06/23 07:35
-
大学のレポート内で De Bruijn Sequence について書く機会があった. これまた以前と同じく, 折角なのでこちらのブログにも, 若干内容を変えつつ載せておくことにした.
De Bruijn Sequence は, オランダ人の数学者 Nicolaas de Bruijn に因んで命名された系列で, 特定の長さのすべての組み合わせを含む系列である. 次数 の 種類に関する De Bruijn Sequence は, 長さ で表現可能なすべての部分列によって構成される. 次元数 (すなわちバイナリ) の De Bruijn Sequence は であり, ビットの固有な部分系列から成る ビット長の系列である. 例えば, は であり に対する有向グラフが下図1のように示される.
この系列から ビットずつ取る, または図 1 の有向グラフから を再構築していくと, 次の表で示す部分系列を構成することがわかる.
最後の つの部分系列は から ビットずつとって構成できないが, 系列の初めへ循環していると考えることで, これが成り立つ.
De Bruijn Sequence は, いくつかのコンピュータアルゴリズムで応用でき, 例えば Number of Training Zero を求める問題も, よく知られた応用例の 1 つである. これは ntz と呼ばれる. 以降, , を ビットの値, を lsb 見た値 の 番目のビット値, Number of Training Zero を ntz とする.
例えば は
であり, が解である.
ntz をプログラムで解こうとしたとき, 例えば次のような実装がよく知られる.
module Main where
import Control.Monad (void)
import Data.Bits ((.&.), shiftR)
import Data.Word (Word8)
import Data.Tuple.Extra (first, second, dupe)
import Test.HUnit (runTestText, putTextToHandle, Test (TestList), (~:), (~?=))
import System.IO (stderr)
-- | pop counting 8 bit
popcnt8 :: Word8 -> Word8
= let fol m s = uncurry (+) . first (.&. m) . second ((.&. m) . (`shiftR` s)) . dupe in
popcnt8 flip (foldr id) [fol (0x0f :: Word8) 4, fol (0x33 :: Word8) 2, fol (0x55 :: Word8) 1]
-- | ntz 8 bit version 1
ntz81 :: Word8 -> Word8
= uncurry id . first (bool (popcnt8 . pred . uncurry (.&.) . second negate . dupe) (_ -> 8 :: Word8) . (== 0)) . dupe
ntz81
main :: IO ()
= void . runTestText (putTextToHandle stderr False) $ TestList ["ntz81 192: " ~: ntz81 (192 :: Word8) ~?= 6] main
popcnt8
は, 各ビットそのものがその桁で立っているビット数と捉え, 畳み込んでいくことで最終的に立っている全体のビット数を得る関数である.
ntz81
は, まず lsb から見て一番端で立っているビットを倒し, それまでのビット列を全て立てておく. これをpopcnt8
に渡すことで ntz としての役割を果せる.
これはとても有名な方法で, よく最適化された手法であるといえるのだが, De Bruijn Sequence を利用すると, より少ない演算回数で ntz が解ける.
De Bruijn Sequence を利用して ntz を解く方法は, 随分前にこのブログさんで丁寧に解説されているので,
特別ここで改めて詳しく述べる必要はないとは思うが, 一応レポート内で書いた内容を載せておく.
- から成る部分系列を元とした集合 と, 「系列全体からみて, その部分系列を得るにいくつスライドしたか」を元とする集合 の写像 を定める(例: ).
- のうち一番右端に立っているビットのみを残し, 他を全て倒す(
x & -x
). この値は必ず である. これを とする. - と の積を得る( であるから, この演算は系列に対する ビットの左シフト演算である). これを とする.
- いま, ここまでの演算を ビットの領域上で行なったとしたとき, に対して ビット左にシフトする( を msb から数えて ビット分のみが必要であるから, それ以外を除去する). これを とする.
- の値が ntz の解である.
要するに, De Bruijn Sequence の特徴を生かして, ユニークなビット列に紐づく各値をマッピングしておき, 積がシフト演算と同等となるように調節, いらない値を省いた後にテーブルを引くのである2.
module Main where
import Data.Array (Array, listArray, (!), elems)
import Data.Tuple.Extra (dupe, first)
import Data.Bits ((.&.), shiftR)
import Data.Word (Word8)
import Test.HUnit (runTestText, putTextToHandle, Test (TestList), (~:), (~?=))
import System.IO (stderr)
import Control.Monad (void)
-- | ntz 8 bit version 2
ntz82 :: Word8 -> Word8
= (tb !) . (`shiftR` 4) . fromIntegral . ((0x1d :: Word8) *) . uncurry (.&.) . first negate . dupe
ntz82 where
= listArray (0, 14) [8, 0, 0, 1, 6, 0, 0, 2, 7, 0, 5, 0, 0, 4, 3] :: Array Int Word8
tb
main :: IO ()
= void . runTestText (putTextToHandle stderr False) $ TestList ["192 ntz: " ~: ntz82 (192 :: Word8) ~?= 6] main
ここまでは が ビットである前提を置いて述べてきたが, 任意の が求まれば, どのようなビット長のデータに対しても同様にして計算できることがわかる. これをどのように得るかであるが, ここでは Prefer One algorithm3 という比較的単純なアルゴリズムを用いて を得ることとする. このアルゴリズムに関する詳細と証明は原文を読んでほしいが, その大雑把な概要だけをここでは述べる. 任意の正整数 について, まず 個の を置く. 次に, 最後の ビットによって形成された部分系列が以前に系列内で生成されていなかった場合, その次のビットに対して を, そうでない場合 を置く. または のどちらを置いても, 以前に生成していた部分系列と一致するならば停止する. 下記に示す同アルゴリズムの実装例は, から に対応する De Bruijn Sequence を得ている.
module Main where
import Data.Array (listArray, (!))
import Data.Tuple.Extra (dupe, first)
import Control.Monad (mapM_)
import Numeric (showHex)
preferOne :: Int -> [Int]
= let s = 2 ^ n in
preferOne n let ar = listArray (1, s) $ replicate n False ++ map (not . yet) [n + 1 .. s]
= or [map (ar !) [i - n + 1 .. i - 1] ++ [True] == map (ar !) [i1 .. i1 + n - 1] | i1 <- [1 .. i - n]] in
yet i cycle $ map fromEnum $ elems ar
bin2dec :: [Int] -> Int
= sum . uncurry (zipWith (*)) . first (map (2^) . takeWhile (>=0) . iterate (subtract 1) . subtract 1 . length) . dupe
bin2dec
somebases :: Int -> IO ()
= let d = take (2^n) $ L.preferOne n in
somebases n print (d, bin2dec d, showHex (bin2dec d) "")
main :: IO ()
= mapM_ somebases [3..6] main
各 が次のように得られる.
([0,0,0,1,1,1,0,1],29,1d)
([0,0,0,0,1,1,1,1,0,1,1,0,0,1,0,1],3941,f65)
([0,0,0,0,0,1,1,1,1,1,0,1,1,1,0,0,1,1,0,1,0,1,1,0,0,0,1,0,1,0,0,1],131913257,7dcd629)
([0,0,0,0,0,0,1,1,1,1,1,1,0,1,1,1,1,0,0,1,1,1,0,1,0,1,1,1,0,0,0,1,1,0,1,1,0,1,0,0,1,1,0,0,1,0,1,1,0,0,0,0,1,0,1,0,1,0,0,0,1,0,0,1],285870213051386505,3f79d71b4cb0a89)
本エントリでは Haskell による実装を示しているが, だいぶ以前に C++ で同様の ntz を実装したのであった. この実装は, この Qiita 投稿の内容と殆ど同じ. C++ では, 簡単なテンプレートメタプログラミングにより, ビット長ごとに必要となるビットマスクや演算を, 同じ関数呼び出しから型ごとに適切に分岐するよう実装できる(Haskell でも, 似たようなことはできる).↩︎
Abbas Alhakim, “A SIMPLE COMBINATORIAL ALGORITHM FOR DE BRUIJN SEQUENCES” https://www.mimuw.edu.pl/~rytter/TEACHING/TEKSTY/PreferOpposite.pdf 2018-06-21 アクセス.↩︎
活動継続のためのご支援を募集しています