Skip to content
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
271 changes: 271 additions & 0 deletions arai60/Dynamic_Programming/maximum-subarray.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
# 53. Maximum Subarray

LeetCode URL: https://leetcode.com/problems/maximum-subarray/description/

この問題は Java で解いています。
各解法において、メソッドが属するクラスとして `Solution` を定義していますが、これは Java の言語仕様に従い、コードを実行可能にするために必要なものです。このクラス自体には特定の意味はなく、単にメソッドを組織化し、実行可能にするためのものです。

## Step 1

前に解いていたが解答を忘れてたので、当時見ていたであろうビデオを見て復習しながら書きました: https://www.youtube.com/watch?v=5WZl3MMT0Eg

```java
/**
* 時間計算量: O(n): 引数 nums の要素すべて走査する
* 空間計算量: O(1): 固定サイズの変数が決まった個数宣言される
*/
class Solution {
public int maxSubArray(int[] nums) {
int maxSubArray = Integer.MIN_VALUE;
int sumInWindow = 0;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sliding Windowのwindowでしょうか?
自分がSliding Windowをよく理解していないからというのもございますが、何のsumなのかわかる変数名がいいかなと感じました。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@Ryotaro25
すいません、返信大変遅くなりました。
はい、sliding window の window の意味でこの命名にしていました。しかしご存じない方が見ると意味が分かりづらいのはその通りだと思います。下の解法にあるような currentSum とかだと良いでしょうか?もっと良い案があったら教えていただけると嬉しいです🙏

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Window 単体でも A restricted range. という意味ですね。
https://en.wiktionary.org/wiki/window#Noun
ただ、それを認めたうえで、これ、日本語だと思うと、びっくりすると思うんですよ。

maxSubArray は、「部分配列の最大」ということですが、部分配列の和が最大なのは気合で読めたとしましょう。
次に、「限定された範囲の中の合計」が出てきて、限定された範囲というのはどこだ、となるでしょう。
そうすると、開始点と終了点を探すパズルが発生して、= 0 を代入した瞬間と、ループの num を足した瞬間であることが分かるのですが、このことについてのヒントが欲しいですね。

この変数が入っているのは「num を使う場合の部分配列の合計の最大」ですね。
で、これが分かると、ようやく帰納法を使って、「num の前までを使う場合の部分配列の合計の最大」が分かっているとして、「num を使う場合の部分配列の合計の最大」を計算するにはどうしたらいいのかを考えていると分かります。

GPT に聞いたところ、subArraySumSoFar, maxEndingHere, localMaxSubArraySum あたりをサジェストしてきました。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

次に、「限定された範囲の中の合計」が出てきて、限定された範囲というのはどこだ、となるでしょう。
そうすると、開始点と終了点を探すパズルが発生して、= 0 を代入した瞬間と、ループの num を足した瞬間であることが分かるのですが、このことについてのヒントが欲しいですね。

この変数が入っているのは「num を使う場合の部分配列の合計の最大」ですね。
で、これが分かると、ようやく帰納法を使って、「num の前までを使う場合の部分配列の合計の最大」が分かっているとして、「num を使う場合の部分配列の合計の最大」を計算するにはどうしたらいいのかを考えていると分かります。

いやー仰るとおりですね。

GPT に聞いたところ、subArraySumSoFar, maxEndingHere, localMaxSubArraySum あたりをサジェストしてきました。

ありがとうございます。
どれも良いですが、subArraySumSoFar は特に納得感があります。認識に個人差はあると思いますが、so far と書いてあると「(開始点から) 現時点までの」というニュアンスを含まれる気がするので。
上記採用して Step 4 に記載しました。

for (int num : nums) {
if (sumInWindow < 0) {
sumInWindow = 0;
}
sumInWindow += num;
maxSubArray = Math.max(maxSubArray, sumInWindow);
}
return maxSubArray;
}
}
```

## Step 2

### Kadane's Algorithm (Step 1 の解法のブラッシュアップ)

- 『なっとくアルゴリズム』を勧めてくださってた pco2699 さんの記事を参考に少し修正しました: https://blog.pco2699.net/entry/2020/05/15/225011
- Constraints 上不正となる引数が渡されてもエラーにならないようチェックを入れました
- 変数名が適当でないと気づいたので修正しました: maxSubArray -> maxSubArraySum

```java
/**
* 時間計算量: O(n): 引数 nums の要素すべて走査する
* 空間計算量: O(1): 固定サイズの変数が決まった個数宣言される
*/
class Solution {
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int maxSubArraySum = Integer.MIN_VALUE;
int currentSum = 0;
for (int num : nums) {
currentSum = Math.max(num, num + currentSum);
maxSubArraySum = Math.max(maxSubArraySum, currentSum);
}
return maxSubArraySum;
}
}
```

### Cubic な解法

タイムアウトするようなコードも書けるようになっておけばそこからより良い解法を考えられるようになる、とどこかで oda さんが仰ってたのを思い出して書いてみました。予想通り TLE。
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

面白いですね。
”タイムアウトするようなコードも書けるようになる” → ”良い解法を考えられる” 
こうなるために、意識することなどございますか?どこをさわればTLEになるのか把握するなどですかね。

Copy link
Copy Markdown
Owner Author

@seal-azarashi seal-azarashi Oct 28, 2024

Choose a reason for hiding this comment

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

@Ryotaro25
こちらも大変返信遅くなりました。

個人的には、ひとまず手を動かすのが大事なのかなと考えています。頭の中をテキストエディタなりノートに書き出せば脳内メモリの節約 (というか拡張?) が出来て理解が進みますし、Leetcode のような判定ロジックを提供してくれるサービスに submit すれば実装の確認も出来るので、そういった作業をしていくと良い解法に近づいていけるのかなと思います。手を動かさずにただただ考え続けるよりもずっと効率が良く感じます。


```java
/**
* 時間計算量: O(n^3):
* - O(n): 配列の全要素を走査
* - O(n): 操作中の要素以降の全要素を走査
* - O(n): 最大で配列と同じ数の要素の合計を算出
* 空間計算量: O(1): 計算に必要ないくつかの変数を宣言
*/
class Solution {
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int maxSubArraySum = Integer.MIN_VALUE;
for (int i = 0; i < nums.length; i++) {
for (int j = i; j < nums.length; j++) {
int subArraySum = 0;
for (int k = i; k <= j; k++) {
subArraySum += nums[k];
}
maxSubArraySum = Math.max(maxSubArraySum, subArraySum);
}
}
return maxSubArraySum;
}
}
```

### Squared な解法

Cubic な解法と同様のモチベーションで書きました。 TLE。

```java
/**
* 時間計算量: O(n^2):
* - O(n): 配列の全要素を走査
* - O(n): 操作中の要素以降の全要素を走査して最大値を更新
* 空間計算量: O(1): 計算に必要ないくつかの変数を宣言
*/
class Solution {
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int maxSubArraySum = Integer.MIN_VALUE;
for (int i = 0; i < nums.length; i++) {
int currentSum = 0;
for (int j = i; j < nums.length; j++) {
currentSum += nums[j];
maxSubArraySum = Math.max(maxSubArraySum, currentSum);
}
}
return maxSubArraySum;
}
}
```

Kadane's Algorithm はエンジニアの常識でもないし、問題見ただけで一発で思いつくのはあまりにも天才的すぎると [Discord でコメント](https://discord.com/channels/1084280443945353267/1206101582861697046/1207405733667410051)がありましたが、自分もこんなのは思いつかないので、暗記しておかない限りは面接で出せるとしたらこの解答になるだろうなという印象です。

また上記のコメントを一通り読んで、思いついた解法があるのなら、それをベストなものでないと切り捨ていきなり解答を見るようなことはせず、書いてみてブラッシュアップさせていくような作業をしないとなと思いました。

> 15分の使い方として、「かっこいい公式を連想してみせよう」とかやっているのは、あまり意味がなくて、とりあえず、いいから手を動かしてくれと思っています。
> かっこいい公式を思いついたからそれをどうやって振り回すかを考えている15分もあんまり意味がないという感覚です。
> [-2,1,-3,4,-1,2,1,-5,4]を例に取れば、100回ちょっとも足し算すれば、全通り出せるわけで、とりあえずそれをやってから考えたらどうでしょう。
> from: https://discord.com/channels/1084280443945353267/1206101582861697046/1207949240316338176

> (Kadane's Algorithm のような解法を思いつく過程の例を示した上で)
>
> 東京から新宿まで移動してくれと言われたら、中央線もあれば、丸ノ内線もあれば、タクシーを拾ってもいいし、自分で運転してもいいし、歩いていってもいいじゃないですか。これくらいの幅を持って見ていて、その中には愚直なのもあれば、短いのもあれば、速いのもあれば、遅いのもあり、色々なバランスを見て、今日はこれくらいにしておくかな、くらいの感覚で選んでいます。
> 上で並べたのは、「下に行けば行くほど洗練されていて無条件に良い」などではなくて、だいたいこれくらいの幅を持って見ているということを伝えたかったからです。
> どうせ、1番上は思いついたけれども、価値のないものだと思って捨てたでしょう。そこがいかんのですよ。
> 時間あるし運動もしたいから2時間くらい歩くか、という選択肢を持っているかどうかで、対応がだいぶ違います。都内の地理に詳しい人から聞いた結論だけ暗記してもあんまり意味はないのです。
> from: https://discord.com/channels/1084280443945353267/1206101582861697046/1208473290881110117

> (上記コメントの補足として)
>
> - 大事なのは、見たときに大局的に色々な手段が見えていること。
> - その中には遅いものも速いものもあり、色々な良し悪しで評価できること。例えば速度の見積もりとかもそれ。
> - それぞれの手段の間の移り変わりの関係性が見えていること。
> - 局所的に変更して、見やすくしたり、整理したりすることができること。
>
> だいたい、この辺です。移り変わり、みたいなものを上で特に表現してみました。
> from: https://discord.com/channels/1084280443945353267/1206101582861697046/1209027377397506109

### Divide and conquer

Leetcode に follow up として "try coding another solution using the divide and conquer approach" とあったので、分割統治法でも書いてみた。

```java
/**
* 時間計算量: O(n log n):
* - O(1): 配列の分割
* - O(log n): 再帰の深さ (配列を半分ずつ分割するため)
* - O(n): 各再帰処理における計算量 (中央を跨ぐ maxCrossingSubArray() の計算)
* - O(1): 結合処理 (leftMax, rightMax, crossMax の最大値の算出)
* 空間計算量: O(log n):
* - O(log n): スタックフレームの数 (再帰の深さ)
* - O(1): 計算に用いる変数
*/
class Solution {
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}
return maxSubArrayHelper(nums, 0, nums.length - 1);
}

private int maxSubArrayHelper(int[] nums, int left, int right) {
if (left > right) {
throw new IllegalArgumentException(
String.format("不正な範囲指定: left=%d > right=%d", left, right)
);
}

if (left == right) {
return nums[left];
}

// left < right のとき、必ず left <= mid < right
int mid = left + (right - left) / 2;
int leftMax = maxSubArrayHelper(nums, left, mid);
int rightMax = maxSubArrayHelper(nums, mid + 1, right);
int crossMax = maxCrossingSubArray(nums, left, mid, right);
return Math.max(Math.max(leftMax, rightMax), crossMax);
}

private int maxCrossingSubArray(int[] nums, int left, int mid, int right) {
if (left > mid || mid >= right) {
throw new IllegalArgumentException(
String.format("不正な範囲指定: 条件 left <= mid < right を満たしていません: left=%d, mid=%d, right=%d",
left, mid, right)
);
}

int leftSum = Integer.MIN_VALUE;
int currentLeftSum = 0;
for (int i = mid; i >= left; i--) {
currentLeftSum += nums[i];
leftSum = Math.max(leftSum, currentLeftSum);
}

int rightSum = Integer.MIN_VALUE;
int currentRightSum = 0;
for (int i = mid + 1; i <= right; i++) {
currentRightSum += nums[i];
rightSum = Math.max(rightSum, currentRightSum);
}
Comment on lines +210 to +215
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

mid == rightだとrightSumがInteger.MIN_VALUEになるなと思いましたが、rightが必ずmidより大きくなるようにmidを計算しているから上手くいくんですね。関数の頭に assert mid < right; みたいにアサーションを書いても良いのかもしれないなと思いました。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

確かに、閉区間でやっていて、maxSubArrayHelper は長さが1のときには early return しているので、左も右も大きさが1以上はある、というのを示さないとこのプログラムがきちんと動くことが分からないですね。

できるだけそういうことは読み手に証明させないように書きたいですね。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

@fhiyo @oda
大変返信が遅くなりました。

仰るとおり読み手が上記のようなことを証明しなければならないのは好ましくないので、次のようなチェックを加えました: c8aa40e
尚、Java の assert はプロダクション環境で使うの好ましくないとされている (exception の種類が一種に固定されるし、そもそもデフォルトでオフにされている) ので unchecked exception を使用しています。

もし上記では充分でない、やり過ぎであるといったことがあればご指摘頂けますと嬉しいです🙏


return leftSum + rightSum;
}
}
```

## Step 3

```java
/**
* 解いた時間: 2~3分
* 時間計算量: O(n): 引数 nums の要素すべて走査する
* 空間計算量: O(1): 固定サイズの変数が決まった個数宣言される
*/
class Solution {
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int maxSubArraySum = Integer.MIN_VALUE;
int currentSum = 0;
for (int num : nums) {
currentSum = Math.max(num, num + currentSum);
maxSubArraySum = Math.max(maxSubArraySum, currentSum);
}
return maxSubArraySum;
}
}
```

## Step 4

[こちらの指摘](https://github.com/seal-azarashi/leetcode/pull/30/files/286d89eec55442db67a108dd6622799403618234#r1789292251)に対応。複数該当箇所があるが、代表して step 3 の実装の修正版を書く。

```java
/**
* 時間計算量: O(n): 引数 nums の要素すべて走査する
* 空間計算量: O(1): 固定サイズの変数が決まった個数宣言される
*/
class Solution {
public int maxSubArray(int[] nums) {
if (nums == null || nums.length == 0) {
return 0;
}

int maxSubArraySum = Integer.MIN_VALUE;
int subArraySumSoFar = 0;
for (int num : nums) {
subArraySumSoFar = Math.max(num, num + subArraySumSoFar);
maxSubArraySum = Math.max(maxSubArraySum, subArraySumSoFar);
}
return maxSubArraySum;
}
}
```