😸

ABC 079 D - Wall のメモ

2023/06/24に公開

解いてみました。
https://atcoder.jp/contests/abc079/tasks/abc079_d

考察

ある数値を直接1へ変えることは可能ですが、それが最適とは限らない(他の数値を経由すると消費魔力が少なくて済む可能性がある)ので全部調べる必要がありそうです。
ここで、これは数値が頂点で遷移が辺の有向グラフとして表すことができそうです。消費魔力がコストの重み付きグラフです。
(と言いつつ、その発想に至るまでにやたらと時間がかかったのですが…)

なので、ある頂点から1に行くためのルートを全部調べて、最も消費魔力の合計が小さい場合の数値を頂点ごとに求めて、それを出力すればいいだろうと。0 -> 1のケース、0 -> 2 -> 1のケース、0 -> 2 -> 3 -> 1のケース... などを全部調べます。
これはDFSなどでグラフを全部走査すれば実現できます。

隣接リストでDFSする実装

まずは、こんな感じのコードで解くことができました。

import kotlin.math.min

fun main() {
    Thread(
        null,
        {
            val (h, w) = readLine()!!.split(" ").map { it.toInt() }
            val c = Array(10) {
                readLine()!!.split(" ").map { it.toInt() }.toIntArray()
            }
            val a = Array(h) {
                readLine()!!.split(" ").map { it.toInt() }.toIntArray()
            }

            val graph = Array(10) { mutableListOf<Pair<Int, Int>>() }

            for(i in 0 until 10) {
                for(j in 0 until 10) {
                    if(i == j) {
                        continue
                    }

                    graph[i].add(j to c[i][j])
                }
            }

            val seen = mutableSetOf<Int>()
            val memo = IntArray(10) { Int.MAX_VALUE }

            for(i in 0 until 10) {
                if(i == 1) {
                    continue
                }

                dfs(i, i, graph, seen, memo, 0)
            }

            var ans = 0L
            for(i in 0 until h) {
                for(j in 0 until w) {
                    if(a[i][j] == -1 || a[i][j] == 1) {
                        continue
                    }

                    ans += memo[a[i][j]]
                }
            }
            println(ans)
        },
    "",
        128 * 1024 * 1024
    ).start()
}

fun dfs(start: Int, x: Int, graph: Array<MutableList<Pair<Int, Int>>>, seen: MutableSet<Int>, memo: IntArray, sum: Int) {
    seen.add(x)

    for(node in graph[x]) {
        val currentSum = sum + node.second

        if(node.first == 1) {
            memo[start] = min(memo[start], currentSum)
            continue
        }

        if(node.first in seen) {
            continue
        }

        seen.add(node.first)

        dfs(start, node.first, graph, seen, memo, currentSum)

        seen.remove(node.first)
    }

    seen.remove(x)
}

https://atcoder.jp/contests/abc079/submissions/42839927

重み付きグラフを扱ったのが実は初めてだったかもしれません。重みについては、辺を扱う型をPairにすれば表現できます。Pairのfirstが辿る先の頂点の番号、secondが重みです。

実装はけっこう沼りました…
ポイントは訪問済の頂点には行かないようにする点です。訪問済フラグのライフサイクルに注意が必要です。今回のグラフだと、各頂点は全て相互に訪問可能となっているので、訪問済フラグを立てないと無限ループになります。たとえば0 -> 2 -> 3 -> 2のように同じ頂点に訪問することは防ぐ必要があります。
しかし、0 -> 2 -> 1と遷移した後に2を単純に訪問済とすると、0 -> 2 -> 3 -> 1のような探索をせずに打ち切られてしまうといった問題が起こります。
そのために、適宜訪問済フラグの解除を行なっています。(フラグというかSetの要素ですが)

計算量をちゃんと見積もっていなくて、大丈夫かなと心配だったのですが、大丈夫でした。この問題だとグラフの大きさは入力に関わらず一定なので、サンプルで問題なければ全てのテストケースで実行速度としては問題ないということで。

隣接行列でDFSする実装

前述のコードでACできましたが、一応解説も見ました。すると、ワーシャルフロイド法を使うと良いと書いてありました。聞いたことはあるけど知らない子です。
とりあえずググってみました。いくつかのサイトを参照すると、ワーシャルフロイド法を使う場合はグラフを隣接行列で扱うのが一般的のように見えました。
上記の解法だとグラフを隣接リストで扱っています。私はそれしか知らなかったので。
なので、ワーシャルフロイド法の前に隣接行列でグラフを扱うのを試してみようと思いました。

上記のコードを隣接行列でグラフを扱うように書き換えたのが以下です。

import kotlin.math.min

fun main() {
    Thread(
        null,
        {
            val (h, w) = readLine()!!.split(" ").map { it.toInt() }
            val c = Array(10) {
                readLine()!!.split(" ").map { it.toInt() }.toIntArray()
            }
            val a = Array(h) {
                readLine()!!.split(" ").map { it.toInt() }.toIntArray()
            }

            val seen = mutableSetOf<Int>()
            val memo = IntArray(10) { Int.MAX_VALUE }

            for(i in 0 until 10) {
                if(i == 1) {
                    continue
                }

                dfs(i, i, c, seen, memo, 0)
            }

            var ans = 0L
            for(i in 0 until h) {
                for(j in 0 until w) {
                    if(a[i][j] == -1 || a[i][j] == 1) {
                        continue
                    }

                    ans += memo[a[i][j]]
                }
            }
            println(ans)
        },
    "",
        128 * 1024 * 1024
    ).start()
}

fun dfs(start: Int, i: Int, c: Array<IntArray>, seen: MutableSet<Int>, memo: IntArray, sum: Int) {
    seen.add(i)

    for(j in c[i].indices) {
        val currentSum = sum + c[i][j]

        if(j == 1) {
            memo[start] = min(memo[start], currentSum)
            continue
        }

        if(j in seen) {
            continue
        }

        seen.add(j)

        dfs(start, j, c, seen, memo, currentSum)

        seen.remove(j)
    }

    seen.remove(i)
}

https://atcoder.jp/contests/abc079/submissions/42871499

ここで初めて、この問題のc_i,_jの入力はそのまま隣接行列として扱える構造であったことに気づきました…w
なのでグラフを扱うためのデータ構造を作る処理が丸々消えました。

また隣接行列だと二次元配列の各要素で重みを扱えるため、Pairも消えました。重み付きグラフは隣接行列のほうが扱いやすいみたいですね。

ワーシャルフロイド法を使った実装

ワーシャルフロイド法とはなにかみたいな説明は省きます。(よくわかってないので!)
実装としては以下のようになりました。

import kotlin.math.min

fun main() {
    val (h, w) = readLine()!!.split(" ").map { it.toInt() }
    val c = Array(10) {
        readLine()!!.split(" ").map { it.toInt() }.toIntArray()
    }
    val a = Array(h) {
        readLine()!!.split(" ").map { it.toInt() }.toIntArray()
    }

    // ワーシャルフロイド
    for(k in 0 until 10) {
        for(i in 0 until 10) {
            for(j in 0 until 10) {
                c[i][j] = min(c[i][j], c[i][k] + c[k][j])
            }
        }
    }

    var ans = 0L
    for(i in 0 until h) {
        for(j in 0 until w) {
            if(a[i][j] == -1 || a[i][j] == 1) {
                continue
            }

            ans += c[a[i][j]][1]
        }
    }
    println(ans)
}

https://atcoder.jp/contests/abc079/submissions/42871815

三重ループでなんかやってみるだけです。シンプルですが、これでバッチリ通りました。不思議…

意味としては、iが始点となる頂点、jが遷移先の頂点、kが経由する頂点となります。
iからjへ直接遷移する場合のコストと、kを経由する場合のコストのうち小さい方を採用して記録していっています。
3頂点しか見ていないので、4頂点以上のケースについてもこれで網羅できるのがなんか不思議な感じがします。
まあ c[i][k]c[k][j] については、直接遷移時の値とは限らず、コストが最小となるルートの場合の値で上書きされているのでOKってことなんでしょうね。
理解したとはいえないですが、ワーシャルフロイド法を使うとすごく簡単に実装できるので覚えておこうと思います。
(執筆時間: 37分36秒)

Discussion