Skip to content

560. Subarray Sum Equals K #16

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions 560SubarraySumEqualsK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
Goで解いています。

### Step 1
- まず思いついたのは二重ループによる全探索
- 時間オーバーにならないかなと思い、入力の大きさを確認
- 最大入力は2e4なので、O(n^2)だと、2e4 * 2e4 / 1e8 = 4s で、GoだとCの倍くらい遅いので、見積もり最悪実行時間は8s。これだと時間制限に引っ掛かるだろう
Copy link

@Yoshiki-Iwasa Yoshiki-Iwasa Aug 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[質問]
最悪見積もり実行時間ってどうやって出してますか(?? )
時間計算量は入力サイズによってどのように計算時間が変化していくかを表す指標だと思っていて、実行時間自体を決めるものではないと思い

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#9 (comment)
以前受けた指摘を参考に見積もっています。
今回は、時間計算量がO(n^2)でnumsの最大の長さが2e4です。CPUの動作周波数から、C++なら1秒間に1~10億ステップの命令が実行されます。よって、2e4 * 2e4を1億=1e8で割って4秒という数字を得ます。
GoはC++の2倍遅いので倍して実行時間を8秒と見積もりました。

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ありがとうございます

質問の意図としては、実行"時間"には各ステップの複雑さも絡んでくるので、時間計算量と入力サイズだけで求めるのは難しいんじゃないかなと思ったので質問させていただきました

提示していただいたnodchipさんのコメントにもありましたが、実際に正確な数値を求めるのはやはり難しいですね
勉強になりました。ありがとうございます

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

実際に試してみるといいですよ。

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

あ、これは結構大事な話で、見積もりはせいぜい桁が合わせられるくらいしかどうせならないので、そこから先は、試したりプロファイラーを使ったりします。そういうものです。

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

実際の仕事で何桁時間になるかということを見積もって話すという場面はありましたでしょうか?
「実際に後で試してみるけど、取り急ぎ入力サイズがこのくらいなら大体数秒で終わるはずです」みたいな会話です

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

実際に測定してみました。numsのサイズを2e4にしたところ、
O(n^2)アルゴリズムは70ms程度
O(n)アルゴリズムは700μs程度
でした。後者は桁数が見積もりと合致するが、前者は2桁も速い。

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

今後問題やるとき、少なくとも桁が合うまでは予測と実行を繰り返してみます

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

うーん、「実行時間が数百ミリ秒である原因を探している時に、直感的に数十マイクロ秒と思われる箇所が原因であるかもしれないと強く主張されて、他のいろいろな数字から説得を試みるも納得されなかったので実験することになって非常に動揺した」という話は聞いたことがあります。

- より速くできる方法を考える
- Sliding Windowを使った方法を思いついたが、負の値も含まれるリストなので結局全探索と同じ
- 正の値しかないなら、インデックスを二つ保持して、x_iからx_jまでの和がkを超えた時点でx_iを右に一つずらす、というアルゴリズムでO(n)でできた
- いい方法を思いつかないので、ダメもとで全探索アルゴリズムを実装。すぐできた
- なんと通ってしまった。LeetCode上の実行時間は1.1s
- 空間計算量はO(1)

```Go
func subarraySum(nums []int, k int) int {
res := 0

for i := 0; i < len(nums); i++ {
tmpSum := 0

for j := i; j < len(nums); j++ {
tmpSum += nums[j]
if tmpSum == k {
res++
}
}
}

return res
}
```

### Step 2
- 以下を参考にO(n)のアルゴリズムを実装してみる
- https://github.com/seal-azarashi/leetcode/pull/16/files
- https://github.com/fhiyo/leetcode/pull/19/files
- アルゴリズムを理解し、言語化してみる
- インデックス0から累積和を計算していく
- インデックスjで累積和がnになるとする
- 累積和がn-kであるインデックスi(i<j)があるとすると、nums[i:j+1]の総和はk(n - (n-k) = k)である
- という論理を使うと、累積和をキー、その累積和になった回数を値にもつマップを作り、逐一現在の累積和と照らし合わせれば、右端がnums[j]で総和kの部分配列の数がわかる
- step1の思考ログに記載した方法は、負の値に対応できないが、この方法なら、例えばk=1, nums=[1,-1,1,-1,1]という配列も漏れなく捌ける
- 空間計算量はO(n)
- https://google.github.io/styleguide/go/best-practices#size-hints に書いてあるようにマップのキャパシティを設定し、再ハッシュを防ぐ
- Goのmapで`mapName[key]`の返り値は1)値と,2)キーが存在するか否かのbool値の二つ
- キーが存在しない場合、1)はマップのvalueのnil値を返す(今回はint型なので0)
- Pythonのdefaultdictのようにデフォルト値を自分で設定できるような機能は少なくとも標準ライブラリでは用意されていない
- エラー処理した方が良さそうなところがあるかどうか考える
- cumulativeSumのオーバーフローが気になったが、入力が負の値も含むので検知できない
- ということで特に気を使うべきところはないだろう

```Go
func subarraySum(nums []int, k int) int {
cumulativeSum := 0
cumulativeSumCount := make(map[int]int, len(nums)+1)
cumulativeSumCount[0] = 1
res := 0

for _, n := range nums {
cumulativeSum += n
res += cumulativeSumCount[cumulativeSum-k] // returns nil (0) if key not found

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

res += cumulativeSumCount[cumulativeSum - k]のように演算子の両サイドにスペースは不要でしょうか?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

はい、不要です。これはgofmtというGoのデフォルトのフォーマッターの仕様で、スペースありで書いてもスペースなしに修正されます。具体的なルールが記載されたページを見つけることはできませんでしたが、
array[n-k]for i := 0; i < n-k; i++などのn-kはスペースなしで、
diff := n - kのような単なる宣言時はスペースありです。
vscodeだとコードを書いてセーブした時にgofmtが動いてくれます。disableもできますが

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

このようなスペースなしスタイル背景には、おそらく「スペースなしで見えにくくなるような計算を[]の中でするくらいなら、新しい変数に宣言して見やすくしてくれ」というメッセージが込められていると勝手に思っています。

cumulativeSumCount[cumulativeSum]++
}

return res
}
```

### Step 3
- 修正箇所
- res -> sumKSubarrays 宣言も一番最初にした(returnする値、つまり最も重要な変数なので)
Copy link

@Yoshiki-Iwasa Yoshiki-Iwasa Aug 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sumKSubarraysは整数を表す変数名としては違和感があります
この変数名だと何らかの部分配列の集合を想起させるかなと。単純に、resultでも良いと思いました

一番最初にした(returnする値、つまり最も重要な変数なので)

これは上から読んでいって理解を妨げないのであれば、宣言はどこでも良いのでは思いました。
例えば、「 sumKSubarraysってなんだろう」と頭のメモリに残しながら読むよりは、これが実際に操作されるfor文の前で宣言するほうが読みやすいという意見を持つ人もいそうです

- step2ではわかりやすさのため、マップの存在しないキーにアクセスした時の挙動についてのコメントを書いたが、Go開発者にとっては常識に含まれるだろうと思い、省いた
Copy link

@seal-azarashi seal-azarashi Sep 2, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

良いと思います。
一方でアルゴリズムに関しては一言でも説明があるとより親切かなと思いました。仕事で何の前提知識もなくこの関数のメンテナンスを任されたと想像すると、すぐに内部の動きを理解するのは難しいだろうな、と思ったので...😇

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

たしかにそうですね。私もこの解法を自力で思いついたわけではありませんし
思考ログの部分にアルゴリズムの説明を書いて満足していましたが、実務のことを考えるとコメントにも書いた方がいいですね。簡潔に過不足なくアルゴリズムを説明する練習にもなりますし


```Go
func subarraySum(nums []int, k int) int {
sumKSubarrays := 0
cumulativeSum := 0
cumulativeSumFrequency := make(map[int]int, len(nums)+1)
cumulativeSumFrequency[0] = 1

for _, n := range nums {
cumulativeSum += n
sumKSubarrays += cumulativeSumFrequency[cumulativeSum-k]
cumulativeSumFrequency[cumulativeSum]++
}

return sumKSubarrays
}
```