あるエンジニアのAtCoder奮闘記

東京都港区にあるアミフィアブル株式会社のエンジニアが、AtCoderで解いた問題について振り返ったりしていく会社公認のブログです。

Tenka1 Programmer Contest 2019 C - Stones をGoで解こうとしたら罠にハマりました (Go,Kotlin)

irisruneです。最近コンテスト時間に予定が被ってて参加できないのがよくないですね。

atcoder.jp

アルゴリズム面は結論から言えば累積和を使いました。それで解けるはずだったんですけどね…

package main

import (
    "fmt"
)

func minInt(a, b int) int {
    if a < b {
        return a
    } else {
        return b
    }
}

func main() {
    var n int
    var s string
    fmt.Scan(&n, &s)

    cumSum := make([]int, n+1)
    cumSum[0] = 0
    for i := 0; i < n; i++ {
        if []rune(s)[i] == '#' {
            cumSum[i+1] = cumSum[i] + 1
        } else {
            cumSum[i+1] = cumSum[i]
        }
    }

    minAns := n
    for i := 0; i <= n; i++ {
        ans := cumSum[i] + ((n - i) - (cumSum[n] - cumSum[i]))
        minAns = minInt(minAns, ans)
    }
    fmt.Println(minAns)
}

解き方の大筋は大体解説の通りです。

  1. 最終形として左から白い石が連続し、残りが黒い石であればよい。
  2. 最終形として考えられるのは高々N+1通りである。
  3. それぞれについて色を変える石の数を求めればよい。
  4. 最終形の白い石と黒い石の境目(端の場合もある)の左右について白い石と黒い石の個数がわかれば3.を求められる。
  5. 4.は黒い石の数について累積和を用いることで求められる。

といった形で解くことができます。計算量もO(N)で問題ないはずなのでジャッジにかけてみましょう。

f:id:amifiable:20190422112752p:plain

f:id:amifiable:20190422112806p:plain

どうして…

仕方ないのでKotlinで書いたほぼ同じプログラムがこちらになります。

fun main(args: Array<String>){
    val n = readLine()!!.toInt()
    val s = readLine()!!

    val lCumSum = mutableListOf(0)
    for(i in 1..n) {
        lCumSum.add(lCumSum[i - 1] + if(s[i-1] == '#') 1 else 0)
    }

    var minAns = n
    for(i in 0..n) {
        val ans = lCumSum[i] + ((n - i) - (lCumSum[n] - lCumSum[i]))
        minAns = listOf(minAns, ans).min()!!
    }
    println(minAns)
}

GoのコードとKotlinのコードの相違点は

  • Goは文字列をrune配列にキャストしてから文字比較をしている。
  • Kotlinは累積和を求める際にリストに要素を順番に追加している。

このくらいしかなく、入力も2つしかないのでそこで差が付くとも考えられないですね。

ということは文字列をrune配列にキャストする操作が計算量大きいんでしょうか。そう思って急遽書き直してみました。

package main

import (
    "fmt"
)

func minInt(a, b int) int {
    if a < b {
        return a
    } else {
        return b
    }
}

func main() {
    var n int
    var s string
    fmt.Scan(&n, &s)

    cumSum := make([]int, n+1)
    cumSum[0] = 0
    for i := 0; i < n; i++ {
        if s[i:i+1] == "#" {
            cumSum[i+1] = cumSum[i] + 1
        } else {
            cumSum[i+1] = cumSum[i]
        }
    }

    minAns := n
    for i := 0; i <= n; i++ {
        ans := cumSum[i] + ((n - i) - (cumSum[n] - cumSum[i]))
        minAns = minInt(minAns, ans)
    }
    fmt.Println(minAns)
}

f:id:amifiable:20190422112826p:plain

問題なく通りましたね。やはり長い文字列をrune配列にキャストすると計算量が増大するようです。 軽く調べた感じその辺に言及してるサイトとかは見つかりませんでした。

1文字だけ見るのに毎回部分文字列を使うのは不格好なので避けたかったのですが、こんな罠があるなら仕方ないようですね…

と思いましたが、他の人の提出を見たところfor rangeで回すと計算量かからずにrune単位で処理できるようです。 考えてみれば当然なんですが、長さNの文字列を[]runeに変換するのに計算量O(N)かかるのでループ毎にキャストしてたらO(N^2)になってしまうんですよね。

なのでfor rangeで回すのが一番スッキリしていて、それでうまくいかないケースではループの外でキャストしておけばよいという感じですね。

  • 定数倍最適化するなら部分文字列を使った方がいいかも?

雑記

  • ABC/ARC相当と言われていたコンテストのD問題に600点って見えるのですが集団幻覚でも見てるのでしょうか
    • Beginnerの方、4完4人って大惨事では?