Conversation
| - ぱっと思いついた方法は、すべてのペアを生成し、各合計値を元にソートして最初のk個を取り出す方法。 | ||
| - しかし、いかにも時間制限を超えそうなので他の方法を考えることにする | ||
| - ヒープを使えば多少マシになる? | ||
| - ただし、n^2通りのペアについて考えることに変わりはないのでそこまで改善されていない気はしつつも、とりあえず実装してみる |
There was a problem hiding this comment.
改めて考えてみました。
step1のヒープを使ったやり方だと二重ループの中でheap.Pushをしているので時間計算量はO(n1n2 logk)。空間計算量はO(k)。
反対に「すべてのペアを生成し、各合計値を元にソートして最初のk個を取り出す方法」だと、長さn1n2の配列をソートする部分がボトルネックで、O(n1n2 log n1n2)。空間計算量はO(n1*n2)。
よって、結論としてはkの値が十分に小さければstep1の解法は時間・空間計算量はともに改善されている。
There was a problem hiding this comment.
Constraints:
1 <= nums1.length, nums2.length <= 105
1 <= k <= 104
で、時間計算量が O(|nums1| |nums2| log k) のとき、実行時間は最大で何秒程度になると推測できますか?
There was a problem hiding this comment.
懸念している点を先回りして書きます。
プログラムを書くにあたり、実行時間を気にしていないように見受けられます。プログラムを書くときは、書く前または書いている最中に実行時間に気を配る事をお勧めいたします。また、実行時間を推定するために、時間計算量を常に意識することをお勧めいたします。時間計算量の式にデータの最大サイズを代入すると、おおよその計算ステップ数が見積もれます。そして計算ステップ数からおおよその実行時間が見積もれます。
C++ のような高速な言語ですと、 1 秒間に 1~10 億ステップ程度実行できます。 1~10 億ステップという数字は、 CPU の動作周波数などがボトルネックとなって決まります。 CPU の動作周波数は数 GHz 程度です。これは 1 秒間に数十億回機械語が実行されることを表します。 C++ をコンパイルして機械語に変換したときのオーバーヘッドを考慮すると、 1 秒間に 1~10 億ステップ程度という数字が出てきます。実際には IPC (Instructions per cycles) などによって値は変わってきますが、詳細すぎるので割愛します。
さらに、実行時間は言語によって大きな差があります。以下は各言語ごとの速度ベンチマークの例です。
https://github.com/niklas-heer/speed-comparison
https://benchmarksgame-team.pages.debian.net/benchmarksgame/box-plot-summary-charts.html
Go だと C++ の 2 倍遅い、 Java/C# だと C++ の 3 倍遅い、 Python (CPython) だと 100倍遅い、 Ruby だと 1000 倍遅いようです。主要な言語の速度感は覚えておくとよいと思います。
元の問題に戻り、
で、時間計算量が O(|nums1| |nums2| log k) のとき、実行時間は最大で何秒程度になると推測できますか?
について推測してみます。
|nums1| |nums2| log k = 105 * 105 * log 104 ≒ 1011 くらいになると思います。実行時間を推測するために、これを 1 億で割ると、 103 になります。結果、最悪ケースで 1000 秒程度の実行時間がかかると推測できます。 LeetCode の実行時間は数秒程度に制限されていると思いますので、 Time Limited Exceeded で誤答となると考えられます。
練習として step3 のコードの実行時間を推測してみていただけますか?
There was a problem hiding this comment.
step3では、ヒープの高さの最大値がlogkであることを考慮すると、時間計算量はO(k log k)になります。
kの最大値は10^4なので、k log k = 10^4 * log 10^4 ≒ 10^5 くらいになり、これを1億で割ると、10^-3 s = 1 ms。つまり、最悪ケースで1 ms程度の実行時間??自信のない数字が出てきてしまいました、、
ちなみにGoはC++より2倍遅いということなので×2して2msということになるのでしょうか?
There was a problem hiding this comment.
はい、おおよそ正しいと思います。 LeetCode 上での実行時間と比較してみるとよいと思います。
There was a problem hiding this comment.
Leetcode上での実行時間は150~230msになっています。今回の理論値との差もそうですし、同じコードでも振れ幅が大きいので、実行時間がどのように計測されているのか気になりました。調べてもleetcodeの実行時間は当てにするなという意見ばかり見つかりました
There was a problem hiding this comment.
ありがとうございます。プロセスの起動時間などのオーバーヘッドもありますので、正確な計測は難しいかもしれませんね。
| ``` | ||
|
|
||
| - テーブルを使って下と右に攻めていく方法があるらしいとDiscordで知り、試してみた(集合を使わないver) | ||
| - 理解にかなり苦しんだ |
There was a problem hiding this comment.
| row int | ||
| col int | ||
| } | ||
|
|
There was a problem hiding this comment.
pairって名前からすると何かしらが組として入っているものをイメージします。
それに対して実際はsum含め3つ要素を持っているのは違和感を感じました。
There was a problem hiding this comment.
たしかにそうですね。当初の
type pair struct {
pair [2]int
sum int
}という構造体ならpairという名前で良かったのですが、中身が変わっても同じ名前を使ってしまっておりました。step3で最終的に
type pair struct {
sum int
idx1 int
idx2 int
}という構造体に落ち着いたのですが、こちらでしたらpairSumAndIndicesみたいな感じですかね
| } | ||
| p := pair{pair: [2]int{n1, n2}, sum: sum} | ||
| heap.Push(h, p) | ||
| if h.Len() > k { |
There was a problem hiding this comment.
Goは全く知らないのですが、質問です。heapとstackはどのように使い分けるのでしょうか?
今回の問題ではなぜheapなのでしょうか🙇
There was a problem hiding this comment.
ヒープはノード間の大小関係が定義されますが、スタックでは入った順番という情報しかありません。今回はペアの合計値の大小を知りたいのでヒープを使いました。
Goではヒープとスタックをそれぞれcontainer/heap, container/listというパッケージから使うことができ、ヒープを使うためにはheap.Interfaceをまず実装しなくてはいけません。
https://pkg.go.dev/container/heap#Interface
|
|
||
| func kSmallestPairs(nums1 []int, nums2 []int, k int) [][]int { | ||
| min := pair{sum: nums1[0] + nums2[0], idx1: 0, idx2: 0} | ||
| h := &pairSumMinHeap{min} |
There was a problem hiding this comment.
make() を使って capacity を設定してあげると多少パフォーマンスが良くなるかと思います。
| kSmallestPairs := [][]int{} | ||
|
|
||
| for len(kSmallestPairs) < k { | ||
| top := heap.Pop(h).(pair) |
There was a problem hiding this comment.
heap を Init() 関数を使って初期化していないのが気になりました。今のままでも問題なく動作しますが、ライブラリの提供者が本来意図している使い方と少し違うのかなという印象を受けます。
There was a problem hiding this comment.
ご指摘の通りInitはしておくべきでしたね。[3,1,2]という配列を渡してInitするとheapifyして[1,3,2]のようにしてくれるのですが、今回は初期値として要素を一つしか入れていないのでInitをしなくても正常に動きます。とはいえ、Initした方がいいですね。
| func (h pairSumMinHeap) Len() int { return len(h) } | ||
| func (h pairSumMinHeap) Less(i, j int) bool { return h[i].sum < h[j].sum } | ||
| func (h pairSumMinHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] } | ||
|
|
||
| func (h *pairSumMinHeap) Push(x any) { *h = append(*h, x.(pair)) } | ||
|
|
||
| func (h *pairSumMinHeap) Pop() any { | ||
| n := len(*h) | ||
| min := (*h)[n-1] | ||
| *h = (*h)[:n-1] | ||
| return min | ||
| } |
There was a problem hiding this comment.
とても細かいですが、Push() と Pop() の間には空白行が無い方が自然なように思えました。同じインターフェースに属する Len(), Less(), Swap() の3つは空白行なしで宣言されている一方、Push() と Pop() はそのようになっていないのが気になっています。
| if top.idx1 + 1 < l1 && top.idx2 == nextIdx2ForNums1[top.idx1 + 1] { | ||
| p := pair{ | ||
| sum: nums1[top.idx1 + 1] + nums2[top.idx2], | ||
| idx1: top.idx1 + 1, | ||
| idx2: top.idx2, | ||
| } | ||
| heap.Push(h, p) | ||
| } | ||
| if top.idx2 + 1 < l2 && top.idx1 == nextIdx1ForNums2[top.idx2 + 1] { | ||
| p := pair{ | ||
| sum: nums1[top.idx1] + nums2[top.idx2 + 1], | ||
| idx1: top.idx1, | ||
| idx2: top.idx2 + 1, | ||
| } | ||
| heap.Push(h, p) | ||
| } |
There was a problem hiding this comment.
おっしゃる通り8行もの似ている処理があったら関数化したいなとは僕も思いましたが、当初この部分の理解にかなり苦労したので、関数化によって読み手の負担が増えたら嫌だなと思い、このままにしました。とはいえ、読み直すと冗長さが気になるのでstep4で関数化したいと思います。
| } | ||
| ``` | ||
|
|
||
| - テーブルを使って下と右に攻めていく方法があるらしいとDiscordで知り、試してみた(集合を使わないver) |
There was a problem hiding this comment.
テーブルの一番左の列の上からk個をまず候補としてheapに入れる方法もありますね。個人的にはこちらの方法の方が理解しやすいと思います。
https://leetcode.com/problems/find-k-pairs-with-smallest-sums/description/