Skip to content

Conversation

@rihib
Copy link
Owner

@rihib rihib commented Aug 11, 2024

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 組み合わせ数)$ 。バックトラッキングの場合は、各candidateを選択するかしないかの2択になるため、時間計算量は $O(2^n)$ になる。空間計算量は $O(組み合わせ数)$ 。よって今回の問題の制約下ではDPの最悪時間計算量は $O(30 \times 40 \times 150)$ 、バックトラッキングは $O(2^{30})$ になるため、DPの方が良い?

バックトラッキング

外側の変数を使って組み合わせを管理するか否か

バックトラッキングで再帰を使って書く場合、無名関数の外側の変数(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であると書かれている3Go1.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

  1. SPやPCは現在の状態を示すものであり、各々のスタックフレームに含まれる性質のものではないため含まれない

  2. https://github.com/golang/go/blob/f296b7a6f045325a230f77e9bda1470b1270f817/src/runtime/proc.go#L120 / https://codereview.appspot.com/12541052/

  3. 4KBから8KBに拡張された。4KBではスタック領域が小さすぎて多くのプログラムで頻繁にスタックの拡張が発生し、その際のスタック領域スイッチング(新たにスタック領域を割り当て、もとのスタック領域からデータをコピーする)がパフォーマンスに悪影響を与えるためである

  4. 平均的なケースで、早期にスタック領域の拡張が行われ、それに伴うコピーが発生することを防ぐためだが、一方で平均以下のスタック領域しか使わないgoroutineでは最大で2倍のメモリが無駄になる可能性がある

@oda
Copy link

oda commented Aug 11, 2024

グローバル変数
なんだか用語が怪しい気がしているのですが。ここで、グローバル変数とはどういうつもりで書きましたか。
ネストした無名関数は外の変数にアクセスできる(レキシカルスコープで)ということかと思います。

@rihib
Copy link
Owner Author

rihib commented Aug 12, 2024

@oda 単に再帰的に呼び出した匿名関数が同じ外側の変数を使うという意味でグローバル変数とうっかり言ってしまいました、、(修正しました)

@rihib rihib merged commit 407cf9a into main Sep 23, 2024
@rihib rihib deleted the combination_sum branch September 23, 2024 13:34
rihib added a commit that referenced this pull request Mar 31, 2025
rihib added a commit that referenced this pull request Mar 31, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants