洛谷-P1383 高级打字机 题解

如果使用 Haskell 语言的话,本题可以使用 containers 包中的 Data.Sequence 秒杀,因为 Seq 本身便是一颗可持久的 FingerTree,用树套树的话,便可以做到一个非常优异的时间/空间复杂度。

惰性求值但是的确是在线算法,可以撤回撤回(返回撤回前的时间点,不知道算不算)。

{-# OPTIONS_GHC -O2 #-} -- 强开O2
import           Control.Monad
import           Data.Functor  (($>))
import           Data.Maybe    (fromJust)
import           Data.Sequence
import           Text.Printf   (printf)

(!) = index -- 强行把函数变成运算符

main :: IO ()
main = do
    n <- readLn :: IO Int -- 读取第一行的整数n,需要手动指定类型,不然会有歧义
    fromList [fromList []] `foldM_'` [1..n] $ \seq _ -> do -- 函数的中缀用法,注意初始的历史记录应该有一个空串
        [[op],c] <- words <$> getLine -- 读取每一行的指令,此处使用了模式匹配(见下文)
        case op of
            'T' -> pure $ (seq!0 |> head c) <| seq  -- 历史记录第0号(即上一次修改的结果)后插入当前字符,然后前插入历史记录
            'U' -> pure $ (seq!read c) <| seq -- 将参数读取为整数(此处无需标明,因为函数有明确的类型限制,编译器将自动推导) 取历史记录的相应项,前插入历史记录
            'Q' -> printf "%c\n" (seq!0!(read c-1))  $> seq -- 将上一次修改的结果的相应位置的字符打印出来,并返回本身的历史记录(($ >)运算符特性)

foldM_' z l f = foldM_ f z l -- 调整参数位置,追求好用

代码十分朴素。

  • 空间复杂度: \(O(n\log n)\) ,其中 \(n\) 为总字符数(不是很紧确,但是..能过)
  • 每次从后面添加字符,时间复杂度: \(O(1)\)
  • 每次打印第 \(i\) 个字符,时间复杂度: \(O(\log (\min (i,n-i)))\) ,其中 \(n\) 为当前字符数, \(i\) 为需要打印的字符位置。
  • 每次撤销,时间复杂度: \(O(\log (\min(i,n-i)))\) ,其中 \(n\) 为总修改次数,\(i\) 为撤销步数。
  • 总时间复杂度可估算为: \(O(n\log n)\)

如果想看 FingerTree 原理或者希望以此为契机了解一下 Haskell 的话参见后文。也可以参考论文原文。

参考文献:

FingerTree概述

说到可持久化,就得想到 Immutable ,说到 Immutable ,自然而然就是函数式编程语言,说到函数式编程语言,自然而然就是 Haskell ,而说到纯函数式的数据结构,自然而然也绕不开 FingerTree

FingerTree 是一种理论上非常通用也非常高效的数据结构,插入头/尾都只需要摊还 \(O(1)\) 的时间, 而对其的“随机”访问只需要 \(O(\log \min(i,n-i))\)

其中 \(i\) 为你访问的下标,所以可以看出访问头/尾其实也是 \(O(1)\)

关于它的论文可以戳这里。当然这不是最早的一篇,但我看的就是这篇,才疏学浅没办法在这里列更早的。

但为什么说“理论”上呢,就和斐波那契堆类似,他的常数比较大。(主要因素是Immutable的语言必须维护一个GC来做垃圾回收。

[更新] 事后我拿 Rust 实现了一遍 FingerTree ,发现其时空消耗均比 Haskell 大,故可认为并非GC原因。

不过也有可能是我写的常数就是大呢(

详见:提交记录

那为什么说“随机”呢?因为完全泛化的 FingerTree 的访问依赖的不是下标,而是一个被称作 \(Measure\) (测度?我也不知道怎么翻译,所以直接拉的原文)的幺半群 (Monoid)。

先解释一下什么是幺半群。幺半群是一个集合 \(X\) 和集合里元素之间一个二元运算 \(\cdot:X\times X\to X\) 的统称(有序对),并要求:

  • 二元运算满足结合率,即 \(\forall x,y,z\in X: (x\cdot y)\cdot z = x\cdot (y\cdot z)\)
  • 集合 \(X\) 中存在一个单位元,即 \(\exists e:(\forall x: e\cdot x=x\cdot e=x)\)

比如说自然数集 \(\mathbb N\) 与其上的加法 \(+\) 便可以构成一个幺半群(后面我们可以用这条性质做出传统意义上的下标索引)

进一步,我们可以定义两个幺半群的笛卡尔积也是一个幺半群,即对于 \((X,\cdot_1)\)\((Y,\cdot_2)\) , 我们可以定义一个运算 \(\cdot:(X\times Y) \times (X\times Y)\to X\times Y\) ,并使其满足结合率。定义方法如下:

  • \((x_1,y_1) \cdot (x_2,y_2)=(x_1\cdot_1 x_2,y_1\cdot_2 y_2)\)

我们可以用这个条件来拓展我们索引方式(比如论文原文里可以看到用 \(\max\) 幺半群实现的最大堆/优先队列)

Haskell基础

首先,用 Haskell 的方式定义一下幺半群(实际上这个标准库有,不需要自己实现)

class Monoid a where
    mempty :: a
    mappend :: a -> a -> a

与常用的C-like语言中的 class 不同,在 Haskell 中,这代表一个类型类,是对类型 a 的一种约束,类似于接口/抽象类一类的概念。

然后是 Foldable ,它代表我们可以在一个类型(其实是一个 Kind ,拿类型生成类型的类型构造子)上按照某种顺序去遍历。类似于 Java 8 中的 reduce 。注意这个其实标准库也有,只是提一嘴。

class Foldable f where
    foldl :: (b -> a -> b) -> b -> f a -> b
    foldr :: (a -> b -> b) -> b -> f a -> b

还需要提一嘴的是 Haskell 最基本的数据结构,链表(实际上还是单端链表,所以只提供最基本的功能)。

data [] a = [] | a:([] a)

它意味着一个 \(a\) 类型的链表有两种构造方式,一种是空链表,令一种是一个 \(a\) 类型的元素用 : 运算符拼接一个 \(a\) 类型的链表。(对,中缀构造函数, C++ 是做不到这点的。)

另外还有一个比较常用的 data 被称作 Maybe ,它的定义等价于:

data Maybe a = Nothing | Just a

想一想,这个定义是什么意思?

从这里开始就可以看到 Haskell 的一些特性了。Haskell 中的数据结构并非由字段操作构成,而是由几类数据的组合组合而成。当然它是可以实现上面的俩 class 的。这里我不做过多介绍。

FingerTree 实现(简要复述论文)

FingerTree 概要图

首先是一些基础定义。

-- | Things that can be measured.
class (Monoid v) => Measured v a | a -> v where
    measure :: a -> v

data Node v a = Node2 v a a | Node3 v a a a
data Digit a
    = One a
    | Two a a
    | Three a a a
    | Four a a a a
data FingerTree v a
     = Empty
     | Single a
     | Deep v (Digit a) (FingerTree v (Node v a)) (Digit a)

这里开始出现了最开始提到的 Measured,此处将其抽象为一个可以将某个元素测量出一个 Monoid 的函数。但这个 Monoid 有大用。

此处出现的语法使用了一个 Functional Dependencies 的拓展(可参见 wiki )。大概意思是保证对于一个类型,我只能把它 measure 成一种其他类型,不能 measure 成另一种。也就是说,类型 a 到类型 v 是一个单射。

可以看到这里的 \(v\) 类型变元,这个就是稍后要维护的 Monoid,也是索引数据的依据。

可以看到一个 Node 可能有2-3个元素,保证一个 Digit 有 1-4 个元素。(图中也可以看出,不过引用链接是国外的可能比较卡) DigitNode 也可以相互转化,拼接,但后文不再说明实现。

还有一点值得注意的是,如果当前这一层的 FingerTree 缓存的是 a 类型的话,那么下一层所缓存的就是 Node a 类型了。这也就意味着,下层比上层“厚实”。

自然地,我们可以给 NodeDigit 来个 Foldable,但具体部分就不展示了。

那么我们应该如何方便的维护这个 \(v\) 类型的数据呢,答案是换一种方式重写构造函数。

deep ::  (Measured v a) =>
      Digit a -> FingerTree v (Node v a) -> Digit a -> FingerTree v a
 deep pr m sf = Deep ((measure pr `mappend` measure m) `mappend` measure sf) pr m sf

此处的 mappendHaskell 里函数的中缀用法(就是把函数当运算符用)。

此时,我们不需要每次构造树的时候手动维护 \(v\) 类型的数据了,只需要简单把 Deep 替换为 deep 即可。

另外,关于 Measured 本身,我们也可以让 FingerTree 也是 Measured,具体代码如下:

instance (Measured v a) => Measured v (FingerTree v a) where
      measure Empty           =  mempty
      measure (Single x)      =  measure x
      measure (Deep v _ _ _)  =  v

注意到此处使用了 Haskell 被称作模式匹配 (Pattern Match)的特性。(不是模式串匹配啦)把数据结构重新打回了它最开始定义时的样子,并且怎么被构造的就怎么被匹配。(真-打回娘胎)

模式匹配不止可以匹配一层,也可以匹配多层。至于用途嘛..请自行搜索 Haskell 的 20 行红黑树。

可以看到对于 Deep 一类,我们直接使用了其缓存的 \(v\) 类型字段,这样就可以在 \(O(1)\) 时间内得到一颗子树的测度。(如果对单个元素的 measure 也是 \(O(1)\) 的话)

当然 NodeDigit 也可以是 Measured,具体实现请自行脑补。

接下来是前插入的定义。

infixr 5 <|
-- | /O(1)/. Add an element to the left end of a sequence.
-- Mnemonic: a triangle with the single element at the pointy end.
(<|) :: Measured v a => a -> FingerTree v a -> FingerTree v a
a <| Empty              =  Single a
a <| Single b           =  deep (One a) Empty (One b)
a <| Deep v (Four b c d e) m sf
    = Deep (measure a `mappend` v) (Two a b) (node3 c d e <| m) sf
a <| Deep v pr m sf     = Deep (measure a `mappend` v) (consDigit a pr) m sf

注意到这里我们自定义了一个运算符,它的优先级是5,并且是右结合的。(不愧是 Haskell,轻易做到了 C++ 做不到的事情。)

consDigit 就是给 pr 前插入一个元素。当然,如果是满的肯定会报错,但是在它之前的模式匹配会避免这一点发生。

类似地还有后插入

infixl 5 .
-- | /O(1)/. Add an element to the right end of a sequence.
-- Mnemonic: a triangle with the single element at the pointy end.
(|>) :: (Measured v a) => FingerTree v a -> a -> FingerTree v a
Empty |> a              =  Single a
Single a |> b           =  deep (One a) Empty (One b)
Deep v pr m (Four a b c d) |> e
    = Deep (v `mappend` measure e) pr (m |> node3 a b c) (Two d e)
Deep v pr m sf |> x     = Deep (v `mappend` measure x) pr m (snocDigit sf x)

可以看到代码基本上和前插入是对称的。(数据结构本身也是对称的,所以这里写作 snocDigit,其中 snoc 正是 cons 的回文)后面还有很多对称的结构,均以左半部分为例。

然后是一个分割原语(当然右侧也是对称的,可以看到又出现了运算符构造子。)

-- | View of the left end of a sequence.
data ViewL s a
     = EmptyL        -- ^ empty sequence
     | a :< s a      -- ^ leftmost element and the rest of the sequence

-- | /O(1)/. Analyse the left end of a sequence.
viewl :: (Measured v a) => FingerTree v a -> ViewL (FingerTree v) a
viewl Empty                     =  EmptyL
viewl (Single x)                =  x :< Empty
viewl (Deep _ (One x) m sf)     =  x :< rotL m sf
viewl (Deep _ pr m sf)          =  lheadDigit pr :< deep (ltailDigit pr) m sf

rotL :: (Measured v a) => FingerTree v (Node v a) -> Digit a -> FingerTree v a
rotL m sf      =   case viewl m of
    EmptyL  ->  digitToTree sf
    a :< m' ->  Deep (measure m `mappend` measure sf) (nodeToDigit a) m' sf

lheadDigitltailDigit 是指一个 Digit 的最左边元素和剩下的元素,当然如果 Digit 只有一个元素的画 ltail 就会报错,但是前面的模式匹配也避免了这一点。

digitToTree 是一个将 Digit 变为一个 FingerTree 的函数,因为也就常数个元素,所以时间复杂度 \(O(1)\)

nodeToDigit 也是跟名字异样,将一个 Node(2或3个元素)变成一个 Digit

case ... of 语法是 Haskell 类似于 C++switch 语句的地方。不同之处在于其是有返回值的,更像一个多出口的 ?: 运算符。

viewl 函数构造一颗树的左分割,将其切割出最左元素(若没有则用 EmptyL 构造子)。

另外值得注意的是此处的 viewlrotL 是互相调用,也是一种递归哦。

当然其有对称实现 viewr。但不在此赘述。

有了 viewl 之后,我们就可以处理一些 prefix (即 Deep 中第一个 Digit)为空/不存在时对树的构造了

deepL :: (Measured v a) =>
    Maybe (Digit a) -> FingerTree v (Node v a) -> Digit a -> FingerTree v a
deepL Nothing m sf      =   rotL m sf
deepL (Just pr) m sf    =   deep pr m sf

此处的 Nothing 就是指 prefix 不存在的情况,此时需要左旋一波。

当然对于 Just 的情况就直接调用上面的deep了。

那么什么情况下左边/右边为空呢?当然是做分割的时候了。

那么终于,要迎来最后的部分了,搜索,与分割。

data Split t a = Split t a t
data SearchResult v a
    = Position (FingerTree v a) a (FingerTree v a)
        -- ^ A tree opened at a particular element: the prefix to the
       -- left, the element, and the suffix to the right.
   | OnLeft
       -- ^ A position to the left of the sequence, indicating that the
       -- predicate is 'True' at both ends.
   | OnRight
       -- ^ A position to the right of the sequence, indicating that the
       -- predicate is 'False' at both ends.
   | Nowhere
       -- ^ No position in the tree, returned if the predicate is 'True'
       -- at the left end and 'False' at the right end.  This will not
       -- occur if the predicate in monotonic on the tree.

search :: (Measured v a) =>
    (v -> v -> Bool) -> FingerTree v a -> SearchResult v a
search p t
  | p_left && p_right = OnLeft
  | not p_left && p_right = case searchTree p mempty t mempty of
         Split l x r -> Position l x r
  | not p_left && not p_right = OnRight
  | otherwise = Nowhere
   where
     p_left = p mempty vt
     p_right = p vt mempty
     vt = measure t

searchTree :: (Measured v a) =>
     (v -> v -> Bool) -> v -> FingerTree v a -> v -> Split (FingerTree v a) a
searchTree _ _ Empty _ = illegal_argument "searchTree"
searchTree _ _ (Single x) _ = Split Empty x Empty
searchTree p vl (Deep _ pr m sf) vr
  | p vlp vmsr  =  let  Split l x r     =  searchDigit p vl pr vmsr
                   in   Split (maybe Empty digitToTree l) x (deepL r m sf)
  | p vlpm vsr  =  let  Split ml xs mr  =  searchTree p vlp m vsr
                        Split l x r     =  searchNode p (vlp `mappend` measure ml)       xs (measure mr `mappend` vsr)
                   in   Split (deepR pr  ml l) x (deepL r mr sf)
  | otherwise   =  let  Split l x r     =  searchDigit p vlpm sf vr
                   in   Split (deepR pr  m  l) x (maybe Empty digitToTree r)
  where
    vlp     =  vl `mappend` measure pr
    vlpm    =  vlp `mappend` vm
    vmsr    =  vm `mappend` vsr
    vsr     =  measure sf `mappend` vr
    vm      =  measure m

此处只列出了 searchTree 这一子函数,且十分冗长,其实 searchNodesearchDigit 也类似,本质上就是为了找到第一个使得对于输入的函数,在值左边的分割为假,右边的为真。一步步细化下去而已。

maybe 函数的类型是 b -> (a -> b) -> Maybe a -> b,看类型签名基本可以猜到功能。所以也不细说。

这里可以看到 where 从句。where 从句和常量定义很像,但却是后置的,而且具有惰性求值的特征(对,自带Lazy,而且实际上如果不做额外标注,整个 Haskell 程序都是惰性求值的)。

到这一步为止,我们只差将原本用于索引的下标整成 Monoid 就好了,并且其和任意类型均可以构成 Measured(因为单个元素大小都是1)

newtype Size = Size Int
newtype Elem a = Elem a
instance Monoid Size where
	mempty = Size 0
	mappend (Size a) (Size b) = Size (a+b)
instance Measured Size (Elem a) where
	measure _ = Size 1

这里注意,由于我们之前使用了 Functional Dependencies,所以我们需要新建一个类型作为容器来容纳我们的类型。所以此处使用了不会导致额外开销的 newtype 关键字替代 data(在当前环境下,二者语义一致)

接下来就可以实现 Seq 了,具体可以参考 container 包的源代码或者论文原文。