けんちょんの競プロ精進記録

競プロの精進記録や小ネタを書いていきます

AtCoder ABC 250 E - Prefix Equality (水色, 500 点)

とても色んな解法が考えられる問題ですね。ハッシュで殴るのが最も簡単だとは思います。そのほかにもさまざまな解法が考えられます。

問題概要

2 つのサイズ  N の整数列  a_{0}, a_{1}, \dots, a_{N-1} b_{0}, b_{1}, \dots, b_{N-1} が与えられます。

これらの数列に対して  Q 回のクエリが与えられます。各クエリでは  N 以下の整数  x, y が与えられますので、

  • 数列  a の先頭から  x 個の要素: a_{0}, a_{1}, \dots, a_{x-1}
  • 数列  b の先頭から  y 個の要素: b_{0}, b_{1}, \dots, b_{y-1}

が集合として一致するかどうかを判定してください。

制約

  •  1 \le N, Q \le 2 \times 10^{5}

解法 1:ハッシュで殴る

この問題の難しさは

  1.  i = 0, 1, \dots, N に対して、各数列の先頭から  i 個とってできる集合を求めたいが、それらすべて保存するのはメモリが足りない
  2. 各クエリに対して、集合が一致するかどうかを判定するには  O(N) の時間を要する (std::set の場合は  O(N \log N))

というところにあります。しかしこれらの問題を一度にまとめて解決してしまう方法があります。それは Zobrist Hash などのハッシュを使う方法です。

 x = 0, 1, \dots, N に対して、 a_{0}, a_{1}, \dots, a_{x-1} からなる集合を 1 つの整数値として表現してしまうのです。

そのようなことが可能なハッシュであればなんでもよいのですが、集合をハッシュ値で表すには Zobrist Hash が便利です。その方法はいたって簡単です!


  1. 集合の各要素に対して乱数を割り当てる
  2. 割り当てた乱数の XOR をとる

乱数を割り当てる部分は、いい感じの関数があります。Codeforces ブログで提供されているので、これをパクらせてもらいましょう!!!

実行してみないと値が分からない chrono::steady_clock::now().time_since_epoch().count() を活用しているため、とても強いです。

codeforces.com

総合して、計算量は  O(N \log N + Q) です。

Zobrist Hash についての remark

  1. Zobrist Hash は XOR をとるものであるため、要素の「挿入」と「削除」がともに高速にできます
  2. Zobrist Hash は「要素の種類」のみに対応しているため、「要素の個数」まで知りたい場合は、XOR をとる代わりに「普通の和」をとります (参考:chokudai さんのツイート)
#include <bits/stdc++.h>
using namespace std;

// 整数値 x にハッシュ値を割り当てる関数
struct custom_hash {
    static uint64_t splitmix64(uint64_t x) {
        x += 0x9e3779b97f4a7c15;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9;
        x = (x ^ (x >> 27)) * 0x94d049bb133111eb;
        return x ^ (x >> 31);
    }

    size_t operator() (uint64_t x) const {
        static const uint64_t FIXED_RANDOM =    
            chrono::steady_clock::now().time_since_epoch().count();
        return splitmix64(x + FIXED_RANDOM);
    }
} rng;

int main() {
    // Zobrist Hash
    // hashA[i] := 数列 a の先頭から i 個分の集合を表すハッシュ値
    int N;
    cin >> N;

    // N 個の入力を受け取って、Zobrist Hash を返す関数
    auto hashing = [&]() -> vector<uint64_t> {
        vector<uint64_t> hash(N + 1, 0);
        set<long long> S;  // すでにあるかを確認する

        for (int i = 0; i < N; ++i) {
            long long x;
            cin >> x;

            // すでに含まれている場合は何もしない
            if (S.count(x)) {
                hash[i + 1] = hash[i];
                continue;
            }
            S.insert(x);
            hash[i + 1] = hash[i] ^ rng(x);
        }
        return hash;
    };

    const auto& hashA = hashing();
    const auto& hashB = hashing();

    // クエリ処理
    int Q;
    cin >> Q;
    while (Q--) {
        int x, y;
        cin >> x >> y;
        if (hashA[x] == hashB[y]) cout << "Yes" << endl;
        else cout << "No" << endl;
    }
}

解法 2:set で頑張る

明快な解説が kyopro_frineds さんによってなされています!

atcoder.jp

解法 3:数列 a を正規化して、種類数と最大値

とても頭の良い解法です。適切な数値変換により、数列  a

 0, 1, 2, 3, 1, 1, 2, 4, 3, 2, 1, 5, \dots

というように、「新しく登場する値は、今まで登場した値の最大値 + 1」という状態に変形することが可能です。

このように変形した状態ならば、A と B の集合一致性を「種類数と最大値の一致」のみで判定可能になります。

atcoder.jp

AtCoder ABC 250 D - 250-like Number (茶色, 400 点)

緑色下位 diff かなと思っていたのですが、これが茶色なのすごいですね!

前提知識

問題概要

素数  p \lt q を用いて  pq^{3} と表される数を「250 に似た数」であると言います。

整数  N が与えられますので、 1 以上  N 以下の「250 に似た数」の個数を求めてください。

制約

  •  1 \le N \le 10^{18}

考えること

まずは全探索に基づく解法を考えてみましょう!! 一般に数学的な見た目の問題では、過度に怖がらずに、落ち着いて全探索解法を考えることで活路を見出せることが多いです!!

 p, q を全探索すればよいのですから、次のように実装できます。

long long res = 0;  // 求める個数
for (long long p = 1; p <= N; ++p) {
    if (p が素数でない) continue;
    for (long long q = p + 1; p * q * q * q <= N; ++q) {
        if (q が素数) ++res;
    }
}

まずはこういう発想ができることがとても大切です。このアルゴリズムを少しずつ改善していきましょう。

 

改善 1:変数を固定して考える

今回の問題では 2 つの変数  p, q が登場します。これらを同時に動かして考えているから、アルゴリズムの効率が悪いのです。そこで重要な考え方は


変数を 1 つ固定して考える


というものです (類題たち)。ものすごく使える考え方ですので、ぜひともマスターしたいところです!!

さて、今回変数を固定するにあたって、戦略は 2 つ考えられます。

  1. 変数  p を固定する
  2. 変数  q を固定する

このうちのどちらをとっても、AC に辿り着けます。ここでは変数  q を固定する方を選択します。なお、変数  p を固定する方法については、公式解説に載っています。

atcoder.jp

変数  q の値を決め打つ前に、変数  q が動く範囲を考えてみましょう。

 pq^{3} \le N

なのですから、最悪でも  q^{3} \le N です。よって、 q \le N^{\frac{1}{3}} です。

 N \le 10^{18} より、 q \le 10^{6} の範囲を調べれば十分だと言えます。

 q を固定して考える解法は、全体としては次のように実装します。

long long res = 0;  // 求める個数
for (long long q = 2; q * q * q <= N; ++q) {
    if (q が素数でない) continue;
    
    // p は p < q と p * q^3 <= N を満たす素数である
    // そのような p の個数は、min(q - 1, N / q / q / q) 以下の素数の個数である
    long long pmax = min(q - 1, N / q / q / q);
    res += (pmax 以下の素数の個数);
}

 

改善 2:エラトステネスのふるいで素数列挙

エラトステネスのふるいを用いると、素数を列挙できます。今回は  p \lt q \le 10^{6} であると分かっているので、 10^{6} 以下の素数を調べれば十分です。

エラトステネスのふるいによって、 10^{6} 以下の素数を列挙して、以下の配列を求めましょう。


prs[i]i 番目の素数


この配列を用いると、今回の問題を解くコードは次のように改良できます。

long long res = 0;  // 求める個数
for (long long q : prs) {
    if (q * q * q > N) break;

    // p は p < q と p * q^3 <= N を満たす素数である
    // そのような p の個数は、min(q - 1, N / q / q / q) 以下の素数の個数である
    long long pmax = min(q - 1, N / q / q / q);
    res += (pmax 以下の素数の個数);
}

 

改善 3:二分探索

最後に、整数 pmax に対して「pmax 以下の素数の個数」を求める方法を考えましょう。

実は素数を列挙した配列 prs さえあれば、二分探索によって容易に求められます。

  1.  x 番目 (0-indexed) の素数pmax より大きい最小の  x を二分探索で求める
  2. このとき、 x は「pmax 以下の素数の個数」を表す

このうちの 1 の部分は、二分探索法で求められます。 計算量は 「 N^{\frac{1}{3}} 以下の素数の個数を  P としたとき、 O(\log P) です。

 10^{6} 以下のすべての素数  q に対して二分探索を実施しても、十分間に合います。

 

コード

以上の工夫をすべて取り入れたコードは次のように書けます。

#include <bits/stdc++.h>
using namespace std;

vector<int> Era(int n) {
    vector<int> res;
    vector<bool> isprime(n, true);  // ふるい
    isprime[0] = false; isprime[1] = false;
    for (int i = 2; i < n; ++i) isprime[i] = true;
    for (int i = 2; i < n; ++i) {
        if (isprime[i]) {
            res.push_back(i);
            for (int j = i*2; j < n; j += i) {
                isprime[j] = false;
            }
        }
    }
    return res;
}

int main() {
    long long N;
    cin >> N;

    // エラトステネスのふるい
    vector<int> prs = Era(1000000);

    long long res = 0;
    for (long long q : prs) {
        if (q * q * q > N) break;

        long long pmax = min(q - 1, N / q / q / q);

        // pmax 以下の素数の個数を二分探索で求める
        long long low = -1, high = prs.size();
        while (high - low > 1) {
            long long x = (low + high) / 2;
            if (prs[x] > pmax) high = x;
            else low = x;
        }
        res += high;
    }
    cout << res << endl;
}

AtCoder ABC 250 C - Adjacent Swaps (茶色, 300 点)

実はアルゴ式でもよく似た問題をすでに出していました!

algo-method.com

問題概要

 1, 2, \dots, N がこの順に並んでいます。この数列に対して  Q 回のクエリが投げられました。

各クエリでは、値  v が指定されて、次の操作を実行します。


数列中の整数  v に対し、その右隣の要素が存在するならば、その要素と swap する。存在しないならば左隣の要素と swap する


 Q 回の操作を終えたあとの数列を求めてください。

制約

  •  2 \le N \le 2 \times 10^{5}
  •  1 \le Q \le 2 \times 10^{5}

考察

最初の注意点として、問題文は  1, 2, \dots, N というように 1 始まりとなっていますが、ここでは  0, 1, \dots, N-1 というように 0 始まりで考えます。与えられるクエリ  v も 1 を引いて考えます。

さて、この問題の難しいところは「クエリが与えられたときに、整数値  v がどこにあるのかが分からなくなる」ことです。

その場合に最もよく採られる解決方法は、次のように「要素がどこにあるか」を表すデータ構造を用意することです!!!(→ 類題たち)


pos[v] ← 数列 val において、要素 v が何番目の要素であるか


このデータは、とくに数列 val が順列であるとき逆順列と呼びます。たとえば val  = (1, 2, 0) のとき、逆順列は pos  = (2, 0, 1) です。

  • 0 は 2 番目の要素です
  • 1 は 0 番目の要素です
  • 2 は 1 番目の要素です

このように逆順列も合わせて考えることで、この問題は解けます。なお、連結リストを用いる別解もあるので、それも後述します。

解法 (1):逆順列も管理する

順列を val、逆順列を pos と表すことにしましょう。そうすれば、次のように考えられます。


  1. v の位置 pp = pos[v] によって取得できる
  2. 位置 p の右隣の位置を p2 = p + 1 とする (右端の場合は p2 = p - 1)
  3. swap(val[p], val[p2]) を実行する

ただしこのとき、逆順列の方も更新が必要であることに注意しましょう。次のようにします。


  1. 位置 p2 の要素の値 v2 = val[p2] を取得する
  2. swap(pos[v], pos[v2]) を実行する

これらの作業は  O(1) で実行できますので、クエリ全体に要する計算時間は  O(Q) となります。

最初に入力を受け取ったり、配列 pos を初期化したりする処理も合わせて、全体の計算量は  O(N + Q) となります。

コード

#include <bits/stdc++.h>
using namespace std;

int main() {
    // 初期化 (最初は val, pos ともに 0, 1, ..., N-1)
    int N, Q;
    cin >> N >> Q;
    vector<int> val(N), pos(N);
    for (int i = 0; i < N; ++i) val[i] = pos[i] = i;

    // 各クエリ
    while (Q--) {
        // 入力
        int v;
        cin >> v;
        --v;  // すべて 0 始まりで考える

        int p = pos[v];
        int p2 = (p < N - 1 ? p + 1 : p - 1);
        int v2 = val[p2];

        swap(val[p], val[p2]);
        swap(pos[v], pos[v2]);
    }

    // 出力 (1 始まりに直す)
    for (auto v : val) cout << v + 1 << " ";
    cout << endl;
}

別解:連結リスト

技術的な詳しい話については、アルゴ式の一連の問題集を参考にしてください。

algo-method.com

要素をつなぎ変えたり、削除したり (今回は削除は不要) する処理は、連結リストを用いて容易に表現できます (→ 類題たち)。今回は具体的には次の 2 つのデータを用意しましょう。


  • nex[v] ← 要素 v の次の要素は何か (存在しないなら -1)
  • bak[v] ← 要素 v の前の要素は何か (存在しないなら -1)

これらのデータを逐次更新していきます。計算量はやはり  O(N + Q) となります。

#include <bits/stdc++.h>
using namespace std;

int main() {
    // 初期化
    int N, Q;
    cin >> N >> Q;
    vector<int> nex(N, -1), pre(N, -1);
    for (int i = 0; i < N; ++i) {
        if (i + 1 < N) nex[i] = i + 1;
        if (i - 1 >= 0) pre[i] = i - 1;
    }

    // 連結リストの出力
    auto print = [&]() -> void {
        // 先頭を探す
        int v = 0;
        for (; v < N; ++v) if (pre[v] == -1) break;
        while (v != -1) {
            cout << v + 1 << " ";
            v = nex[v];
        }
        cout << endl;
    };

    // 各クエリ
    while (Q--) {
        int v;
        cin >> v;
        --v;

        // v1, v2, v3, v4 の順に繋ぐようにする
        int v1, v2, v3, v4;
        if (nex[v] != -1) {
            // 右隣がある場合
            v1 = pre[v], v2 = nex[v], v3 = v, v4 = nex[v2];
        } else {
            // 右隣がない場合
            v4 = nex[v], v3 = pre[v], v2 = v, v1 = pre[v3];
        }

        // 進行方向をつなぎ変える
        if (v1 != -1) nex[v1] = v2;
        nex[v2] = v3;
        nex[v3] = v4;

        // バック方向をつなぎ変える
        if (v4 != -1) pre[v4] = v3;
        pre[v3] = v2;
        pre[v2] = v1;
    }

    // 出力
    print();
}

AtCoder ABC 249 C - Just K (茶色, 300 点)

ビット全探索もついに茶色 diff ですね!

予備知識

ビット全探索の知識があると解きやすいです!

drken1215.hatenablog.com

問題概要

英小文字のみからなる  N 個の文字列  S_{1}, S_{2}, \dots, S_{N} が与えられます。

これらの文字列の中から、いくつかの文字列を選びます。

こうして選んだ文字列のうち、ちょうど  K 個に含まれているような文字の種類を数えます。その種類数として考えられる最大値を求めてください。

制約

  •  1 \le K \le N \le 15

 

一目で bit 全探索!!

問題の意味がパッと分かりづらいですね。でも種類数がナンタラのところがパッと分からなかったとしても、「この問題が bit 全探索で解けそうだ」ということは一目でわかります。それは


  •  N 個のものから、いくつか選んだ結果を最適化する問題だということ
  • 制約が  N \le 15 というように、思わせぶりであること

の 2 つが理由です!!!

まず 1 個目についてですが、競プロではまさに  N 個のものからいくつか選んだ結果を最適化する問題が数多いです。ナップサック問題もそうですね。

そういう問題は、制約さえ小さければ、必ず bit 全探索で解けるのです。そう思っているところに「 N \le 15」という制約があるのですから、bit 全探索確定です!!

bit 全探索

 N 個のものからいくつか選んで得られる集合は  2^{N} 通りあります。そのような集合たちをビットで表します。 そして bit 全探索は、次のように書けます。

for (int bit = 0; bit < (1 << N); ++bit) {
    // ビットで表すと bit になるような集合を調べる
}

たとえば N = 8bit = 0b01100111 のとき、8 個のもの (0〜7) のうち 0, 1, 2, 5, 6 番目のものを選ぶような場合を表します。ビット表現では、右端の桁から順に i 桁目が 1 のときは「i 番目のものを選ぶ」ことを表し、i 桁目が 0 のときは「i 番目のものを選ばない」ことを表します。

なお、調べる集合の個数は  2^{N} 通りあります。多くの場合、各集合を評価するのに要する計算量は  O(N) ですので、全体の計算量は  O(N 2^{N}) になることが多いです。各集合の評価により多くの時間がかかりときは、全体の計算量もより大きくなります。

集合 bit を調べる

最後に、今回の問題において、 N 個のもののうち、ビット表現で bit と表される集合が条件を満たすかどうかを調べる部分を詰めます。

'a' から 'z' までの 26 種類の各文字について、それが何回登場するかを数えてあげます。そして、ちょうど  K 回登場するものが何文字あるかを数えましょう。

たとえば次のように実装できます。bit に対して、そのスコア ( K 回登場する文字の個数) を評価する関数を実装します。

int evaluate(int bit, int N, int K, const vector<string>& S) {
    int res = 0;

    // num[c] := 文字 c (0〜26 で表す) の登場回数
    vector<int> num(26, 0);
    for (int i = 0; i < N; ++i) {
        // bit に S[i] が含まれないならスキップ
        if (!(bit & (1 << i))) continue;

        // S[i] に含まれる文字をカウントしていく
        for (char c : S[i]) num[c - 'a']++;
    }
    
    // num[c] = K となる文字 c を数える
    for (int c = 0; c < 26; ++c) if (num[c] == K) ++res;

    return res;
}

コード

計算量は、英小文字の種類数を  A としたとき、

  • bit の個数: 2^{N} 通り
  • 関数 evaluate(bit) O(AN)

ですので、全体として  O(AN2^{N}) となります。今回は  A = 26,  N \le 15 ですので十分間に合います。

#include <bits/stdc++.h>
using namespace std;

int evaluate(int bit, int N, int K, const vector<string>& S) {
    int res = 0;

    // num[c] := 文字 c (0〜26 で表す) の登場回数
    vector<int> num(26, 0);
    for (int i = 0; i < N; ++i) {
        // bit に S[i] が含まれないならスキップ
        if (!(bit & (1 << i))) continue;

        // S[i] に含まれる文字をカウントしていく
        for (char c : S[i]) num[c - 'a']++;
    }
    
    // num[c] = K となる文字 c を数える
    for (int c = 0; c < 26; ++c) if (num[c] == K) ++res;

    return res;
}
int main() {
    int N, K;
    cin >> N >> K;
    vector<string> S(N);
    for (int i = 0; i < N; ++i) cin >> S[i];

    // ビット全探索
    int res = 0;
    for (int bit = 0; bit < (1 << N); ++bit) {
        res = max(res, evaluate(bit, N, K, S));
    }
    cout << res << endl;
}

パズルとアルゴリズムのコラボ本を書きました!

1. はじめに

お久しぶりです!
けんちょん本のけんちょんです。

最近はアルゴリズムがとても盛り上がっていますね。今回新たなアルゴリズム本を上梓させていただくことになりました!

発売予定日は 2022/4/20 です。一部大型書店では、もうすでに並んでいるはずです。今回の記事では、この本を通してお届けしたいメッセージや、想定読者、内容などについて簡単に紹介させていただきます。

amazon ページへのリンク

f:id:drken1215:20220413211445p:plain

 

2. 本書の内容と対象読者

2-1. 本書の内容

百聞は一見に如かずということで、まずは目次構成をお見せします!

第 1 章:アルゴリズム入門

  • 第 1 話:「テンパズル」 〜 力まかせ探索
  • 第 2 話:「小町算」 〜 再帰関数
  • 第 3 話:「虫食算」 〜 枝刈り

第 II 章:グラフアルゴリズム

第 III 章:発展的なアルゴリズム

  • 第 7 節:「15 パズル」 〜 反復深化 A*
  • 第 8 節:「4 × 4 オセロ」 〜 α-β 探索
  • 第 9 節:「編集距離」 〜 動的計画法
  • 第 10 節:「ドミノタイリング」 〜 マッチング法

f:id:drken1215:20220413124240p:plain

端的に言うと、


  • 誰もが馴染みのある代表的なパズルを楽しみながら、
  • パズルのソルバー作りをとおして、
  • アルゴリズムの考え方にも一通り習熟していく

ことを目指した書籍です。とにかく眺めているだけで楽しくなるような内容を目指しました!!

 

ページサンプル 1 (迷路の紹介)

f:id:drken1215:20220413212313p:plain

ページサンプル 2 (覆面算を作る!)

f:id:drken1215:20220413212323p:plain

ページサンプル 3 (数独を作る!)

f:id:drken1215:20220413212332p:plain

ページサンプル 4 (迷路ソルバーを油分け算に応用!)

f:id:drken1215:20220413212548p:plain

ページサンプル 5 (ドミノタイリング)

f:id:drken1215:20220413212824p:plain

2-2. 対象読者

本書は、「パズルが好きな方」と「アルゴリズムを学びたい方」のどちらも楽しめるものを目指しました。

  • 純粋にパズルが好きな方
  • パズルのソルバー作りに関心のある方
  • 大学のレポートで数独などのプログラムを作る必要のある方
  • アルゴリズムを一通り楽しく学びたい方
  • アルゴリズムがどんなものか雰囲気で感じたい方

などなど、さまざまな方に楽しんでいただけると思います!

予備知識として、C++ の文法に一通り習熟していることを仮定しています。しかしながらプログラミングの経験のない方も楽しめるように、ページを眺めているだけでもパズルを解くアルゴリズムの考え方が掴めるように、大量の図を用いて解説しました。

 

3. アルゴリズムと他分野のコラボ

最近アルゴリズムがとても流行っています。今後もさらに何冊ものアルゴリズム本が出版されると思います!

今後さらにアルゴリズムが広まるために大事なことは、アルゴリズムがさまざまな分野とコラボすることだと考えています。米田優峻さんによる「数学 x アルゴ」本はとても画期的でしたね。

amazon ページへのリンク

本書は、パズルとアルゴリズムのコラボを目指します。これまでも、パズル的なアルゴリズムの本はいくつか出ています。しかしながら、数独・虫食算・迷路といった、ザ・パズルとも言うべきパズルを題材にしたアルゴリズムの本は (僕の知る限り) 出ていませんでした。

そこで今回は、ザ・パズルを題材として、アルゴリズム設計技能をひととおり磨ける書籍を目指しました!

f:id:drken1215:20220413223744p:plain

 

4. 「パズル × アルゴリズム」に込めた想い

「パズル」と「アルゴリズム」を結びつけることは、一見ごく自然なようで、実はとても難しいことでした。なぜならパズルの世界では、「パズルは決まった解き方ではなく、問題ごとに工夫を凝らして解くのが楽しい」という考え方が強くあります。

一方アルゴリズムは、「その問題に対するどんな入力データに対しても、同じやり方で答えを導く」ものです。パズルをコンピュータで解くというのは、一歩間違えれば、パズルの魅力を損いかねない考え方を含むものにも思えます。

たとえば数独ソルバーを作るとき、人間が数独を解くための手筋をトレースしたソルバーは広く受け入れられますが、バックトラッキングで解くソルバーに対しては推奨されない場面をよく目にします。

f:id:drken1215:20220413222002p:plain

しかし本書の執筆を通して、実際にパズルのソルバーを作っていくうちに、パズルのことをよく知らなければよいソルバーは作れないことにしばしば気付きました。

パズルのソルバーを作ることは、単にパズルの問題を解く作業をコンピュータに任せるというだけではなく、パズルに対する理解を深める営みでもあるのです!

それだけではありません。パズルを解くアルゴリズムを考えるということは、もうそれ自体が一つのパズルなのです。そんな新たなパズルの楽しさが存分に伝われば幸いです。

 

5. おわりに

最後になりますが、本書を執筆するにあたっては、たくさんの方のお世話になりました。改めて感謝の気持ちでいっぱいです。

アルゴリズムにはものすごいポテンシャルがあると信じています。今から 600 年も前に印刷技術が発明されたことで、識字率が大きく向上したことで世界が大きく変わったように、アルゴリズムを楽しむ方が増えることで世界が大きく変わる。そんな未来に思いを馳せています。個人的な話で恐縮ですが、アルゴ式もそんな未来を思い描きながら創っています。

本書を通して、パズルが好きな方が「アルゴリズムを考えるというパズル」に関心を抱いたり、アルゴリズムが好きな方がパズルの世界を楽しんだりするキッカケを作れたならば、とても嬉しいです!!