Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Combination Sumを解きました。レビューをお願いいたします。
問題:https://leetcode.com/problems/combination-sum/
言語:Go
下記は現在の私の理解について説明したものです。間違っている箇所や怪しい箇所があったらご指摘お願いいたします。
バックトラッキング、動的計画法について
ある条件を満たす全ての組み合わせを求めるのに使われるアルゴリズムはブルートフォース、バックトラッキング、動的計画法の3つ。
パフォーマンス
バックトラッキングは早期に枝刈りできるほど時間効率が良くなり、DPは同じ部分問題を繰り返し求める必要がある場合に効率的である。DPの空間計算量は$O(部分問題の数 \times 組み合わせ数)$ になり、バックトラッキングは $O(組み合わせ数)$ になる。そのため、バックトラッキングに比べてDPはメモリをより多く消費する傾向にある。
今回の実装では動的計画法の場合は、時間計算量は$O(len(candidates) \times target \times 組み合わせ数)$ 、空間計算量は $O(target \times 組み合わせ数)$ 。バックトラッキングの場合は、各$O(2^n)$ になる。空間計算量は $O(組み合わせ数)$ 。よって今回の問題の制約下ではDPの最悪時間計算量は $O(30 \times 40 \times 150)$ 、バックトラッキングは $O(2^{30})$ になるため、DPの方が良い?
candidateを選択するかしないかの2択になるため、時間計算量はバックトラッキング
外側の変数を使って組み合わせを管理するか否か
バックトラッキングで再帰を使って書く場合、無名関数の外側の変数(
stack)を使って組み合わせを構築するのではなく、再帰呼び出しをするたびに引数として構築中の組み合わせを渡す方法もとることはできるが、その場合は都度コピーしたものを渡していくことになるのでメモリ消費が大きくなるため、あえてこの方法を取るメリットはなさそう?スタックオーバーフローの可能性
バックトラッキングで再帰を使って書く場合、コールスタックに積まれるのは
generateCombinations関数であり、currentIndex,sum,i(全てint型)を引数やローカル変数として持ち、戻り値は持たないため、64bitシステムの場合はint型は8B(64bit)であるため、8 * 3 = 24Bになる。またそのほかにもリターンアドレス、ベースポインタ(フレームポインタ)を持ち、それぞれ8Bであるため、16Bとなり1、スタックフレームの全体サイズは約40Bになると考えることができる。また最悪の場合のコールスタックの深さはtargetに比例する(targetに対してcandidate=1を繰り返し選択する場合)。今回の場合、
targetは40以下であるため、最大で40 * 40 = 1600B(1.6KB)になる。Goのスタックサイズは64bitの場合は1GB、32bitの場合は250MB2なので、スタックオーバーフローを起こす可能性は低い。またgoroutineもGo1.2のリリースノートにはgoroutineのスタックサイズは8KBであると書かれている3(Go1.19のリリースノートによると過去のgoroutineが使用したスタックサイズの平均を使うように変更されたらしいが4)ため、スタックオーバーフローを起こす可能性は低い。Linuxスレッドのデフォルトスタックサイズは8MBほどであることから、goroutineのスタックサイズ(8KB)はとても小さいことがわかる。これにより、大量のgoroutineを高速に起動することができる(Default stack size for pthreads、スタックオーバーフローのハンドリング)。
ただし、どうやらgoroutineは、もし関数呼び出しで大きなサイズが必要なことがわかれば、別にスタックフレームを準備し、それに引数をコピーして、あたかもスタックがはじめて使われていたかのような状態で関数呼び出しを行うため、スタックサイズがギガバイトサイズになっても問題なく再帰ループを回すことができるらしく、動的にサイズが拡張されることから、スタックオーバーフローは通常のOSスレッドに比べて発生しにくくなる?(Go言語のメモリ管理)
Goは、ランタイムのデフォルトスタックサイズが小さく、かつスタックサイズの大きさを最小にするような最適化を行っていないため、深さの数だけスタックを浪費する実装になっており、再帰の深さが極端に処理性能が落ちる要因になる(Goで再帰使うと遅くなりますがそれが何だ)。そのため、「Goに慣れた人は自然と深い再帰処理はループ処理に書き換える」らしい(Goの良さをまとめてみた)。なので今回の場合は再帰ではなくスタックを使って解く方がベター?
(Goのスタックサイズについて調べたとき、みんなgoroutineのスタックサイズの話をしているが、goroutineを使わない場合はGoはOSスレッドを使用しているということで良いのだろうか?つまりその場合はスタックサイズは8MBほどで考えれば良いということになるのだろうか?)
その他
コールスタックの深さについて:
デフォルトのコールスタックの深さはPythonだと1000、Javaだと1万ぐらい。https://discord.com/channels/1084280443945353267/1233603535862628432/1237744279363915878
Exzrgs/LeetCode#13 (comment)
Rustのデフォルトスタックサイズは2MB。
https://doc.rust-lang.org/std/thread/index.html#stack-size
DPを使ったPythonの実装:
fhiyo/leetcode#52 (comment)
すでに解いた方々:
hayashi-ay/leetcode#4
hayashi-ay/leetcode#65
shining-ai/leetcode#52
SuperHotDogCat/coding-interview#11
goto-untrapped/Arai60#15
Exzrgs/LeetCode#13
nittoco/leetcode#25
fhiyo/leetcode#52
Footnotes
SPやPCは現在の状態を示すものであり、各々のスタックフレームに含まれる性質のものではないため含まれない ↩
https://github.com/golang/go/blob/f296b7a6f045325a230f77e9bda1470b1270f817/src/runtime/proc.go#L120 / https://codereview.appspot.com/12541052/ ↩
4KBから8KBに拡張された。4KBではスタック領域が小さすぎて多くのプログラムで頻繁にスタックの拡張が発生し、その際のスタック領域スイッチング(新たにスタック領域を割り当て、もとのスタック領域からデータをコピーする)がパフォーマンスに悪影響を与えるためである ↩
平均的なケースで、早期にスタック領域の拡張が行われ、それに伴うコピーが発生することを防ぐためだが、一方で平均以下のスタック領域しか使わないgoroutineでは最大で2倍のメモリが無駄になる可能性がある ↩