🐣

Swift で配列の直積(Cartesian product)を作る

2020/11/08に公開4

はじめに

Swift で2つの配列から直積を作ることができれば、ループの入れ子をなくすことができます。

before
[1,2,3].forEach { i in
    ["a", "b", "c"].forEach { s in
        print(i, s)
    }
}
after
[1,2,3].product(["a", "b", "c"]).forEach { (i, s) in
    print(i, s)
}

こんな感じでコードを書きたかったので、Swift での実装方法を調べてみました。

環境

  • Apple Swift version 5.3 (swiftlang-1200.0.29.2 clang-1200.0.30.1)

直積(Cartesian product)を作る

Array の直積

2つの配列からそれぞれの要素を取り出して、すべての組み合わせを要素に持つ配列を作ります。

2つの配列
let array1 = [1, 2, 3]
let array2 = ["foo", "bar", "baz"]
変換後
[(1, "foo"), (1, "bar"), (1, "baz"), (2, "foo"), (2, "bar"), (2, "baz"), (3, "foo"), (3, "bar"), (3, "baz")]

array1 の各要素ごとに array2 の各要素と結合したタプルのリストを作ります。
二次元配列を一次元配列に変換するために flatMap を使用します。

2つの配列から直積を作るコード
let product = array1.flatMap { x in
    array2.map { y in
        (x, y)
    }
}

LazySequence を使った高速化

上記のコードは product を生成するタイミングで array1array2 に対する二重ループ処理が実行されます。これは、配列の要素数が大きいほどオブジェクトの生成に時間がかかることを意味しています。

配列要素を参照するまでループ処理を遅延させることができれば、オブジェクトの生成を高速化することができます。

Swift では Array をはじめとする Sequence に準拠した型には lazy プロパティが提供されており、要素の参照を遅延させる事ができます。

遅延処理で直積を作るコード
let product = array1.lazy.flatMap { x in
    array2.lazy.map { y in
        (x, y)
    }
}

このように記述することで、実際に要素を参照するタイミングまで二重ループ処理を遅延させることができます。

forEach の呼び出しで二重ループ処理が実行される
product.forEach { item in
    print(item)
}
first の呼び出しで二重ループ処理は最初の1回で停止する
product.first

Array vs. LazySequence

とはいえ、何でも LazySequence で遅延実行すれば良いかというとそんなこともありません。

Array の特定の要素への参照が O(1) なのに対して、LazySequenceO(N) なので、実際のところ Array にするか LazySequence するかは一長一短です。

オブジェクト生成時のコスト 特定の要素への参照時のコスト
Array O(N) O(1)
LazySequence O(1) O(N)

処理の共通化

特定の配列に対する直積の生成が理解できたところで、今度は任意の配列に対して直積を生成するべく処理の共通化を試みます。

Array

まずは Array の直積を作ってみます。

extension Sequence {
    func product<T: Sequence>(_ other: T) -> [(Element, T.Element)] {
        flatMap { x in
            other.map { y in
                (x, y)
            }
        }
    }
}

Sequence を拡張することで、配列をはじめとする Sequence に準拠した型すべてに対して、直積の Array を作ることができるようになります。

// [(1, true), (1, false), (2, true), (2, false), (3, true), (3, false)]
[1, 2, 3].product([true, false])

LazySequence

LazySequence を関数の戻り値にする場合は、どのような型を指定すれば良いでしょうか?

extension Sequence {
    func product<T: Sequence>(_ other: T) -> ??? { // ← 型は何?
        lazy.flatMap { x in
            other.lazy.map { y in
                (x, y)
            }
        }
    }
}

この場合の戻り値の型は次のようになります(とても長い!!)

LazySequence<FlattenSequence<LazyMapSequence<LazySequence<Self>.Elements, LazyMapSequence<LazySequence<T>.Elements, (Self.Element, LazySequence<T>.Element)>>>>

Swift では、lazy で遅延処理するために、flatMapmap などの演算処理を、FlattenSequenceLazyMapSequence のような型情報として保持する必要があるみたいで、演算が増えるほどに、型のネストが深くなります。

利用する側としては、内部で実行する演算処理について関心は無く、2つの組のタプルを要素にしたシーケンスであるということだけ知っていれば良いはずです。

このような場合は、AnySequence でラップすると分かりやすいです。

extension Sequence {
    func product<T: Sequence>(_ other: T) -> AnySequence<(Element, T.Element)> {
        AnySequence(lazy.flatMap { x in
            other.lazy.map { y in
                (x, y)
            }
        })
    }
}

こうすることで、要素が (Element, T.Element)Sequence に準拠した型であることを明示することができます。メソッドの戻り値の型から遅延処理されることが隠蔽されてしまったことは難点ですが、実際には遅延処理されます。

AnySequence でラップすることで可読性はアップするものの、真の型である LazySequence が隠蔽されてしまう点には一長一短があります。

たとえば、Sequence やそれに準拠している AnySequence には first(where:) しかなく、LazySequencefirst(where:) に加えて first プロパティもあります。

// first のかわりに first(where:) を利用する
[1,2,3].product([true, false]).first { _ in true }

ほとんどの場合で AnySequence でラップして返すで問題はなさそうですが、どちらが適切かはケースバイケースで考える必要がありそうです。

Discussion

LumisilkLumisilk

すでにご存知かもしれませんが、公式のswift-algorithmsは同じ機能を提供しています。
既存配列をそのまま使ってる新しい構造体になるので全ての操作が高速化されているはずです。
https://github.com/apple/swift-algorithms/blob/main/Guides/Product.md

小清水 健人小清水 健人

コメントありがとうございます。
これは凄くイイですねー!😄

Product2 型の生成コストは O(1) 。
subscript を使った参照の計算量は元の型にそのまま依存しているので、Array ならO(1)。

型パラメータ Base2 を Sequence ではなく Collection に制限している理由が、さっと見たところで判断出来なかったのですが、Collection に制限すれば無限リストは渡せなくなるので、Base1 の各要素に対する Base2 とのペアが取得できて都合良いと思いました。

https://github.com/apple/swift-algorithms/blob/main/Sources/Algorithms/Product.swift

普段使いなら swift-algorithms を使っていこうと思います!
ありがとうございます☺️

LumisilkLumisilk

Base2 が Collection の理由は、リンク先の Detailed Design 節で書いてあります
「We require Collection conformance for Base2, since it needs to be iterated over multiple times. Base1, by contrast, is only iterated over a single time, so it can be a sequence.」
何度も Base2 の要素を取得する必要があるからですね。Sequence はただひたすら「次の要素を返す」のプロトコルだけなので、一度取得した要素をもう一度取れる保証はないからです。無限リストもありえます。
for...in みたいな、すべての要素を一度だけ使う場合は、Base1 は Sequence で十分、逆に直積の結果を何度も subscript で訪問する場合は Base1 も Collection である必要があるだと思います。subscript の実現が extension Product2: Collection where Base1: Collectionに書いてあるわけです(使ったことないけど😅)
優れたデザインですね!

小清水 健人小清水 健人

おー!なるほど!理解しました💡
これはよく出来たデザインですね〜

丁寧に返信いただきありがとうございました。とても勉強になりました☺️