Skip to content
Open
Show file tree
Hide file tree
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
71 changes: 62 additions & 9 deletions src/bin/step1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,79 @@
// 正解したら終わり

/*
問題の理解
- 株価を表す配列pricesが与えられる。prices[i]はある日の株価である。
- ある日に株を買った場合に、最大になる利益を値として返す。
- 利益が発生しない場合は0を返す。
e.g.
prices = [1, 2, 3, 4] out = (prices[3] - prices[0]) = 3
prices = [4, 3, 2, 1] out = 0 // どの日に株を購入しても利益が発生しない

何がわからなかったか
-
- 手作業でやることを考えたときに、
- 最小株価を見つける。
- 最大株価を見つける。
- これらの株価の差を見る。ただし、最小株価の位置iは最大株価のiよりも小さい必要がある。
この解法をより単純な考え方にする方法がわからなかった。

何を考えて解いていたか
-

想定ユースケース
-
- 2重ループで組み合わせを見れば解けると思う。ある時点の株価よりも高い株価の時の利益を都度更新していき、最終的に最大の利益を返す。
計算量はO(N ^ 2)となる。入力の制約からNは10 ^ 5なので、10 000 000 000回計算することになる。筋が悪そう。もう少し良さそうな方法を考えて思いつかなければこのナイーブな実装をする。
- pricesの中で最小を探す。indexをdayとする。線形探索なのでO(N)
pricesの中で最大を探す。indexをdayとする。線形探索なのでO(N)
min_price_day < max_price_day であれば max_price - min_price を答えとして返す。
条件を満たさなければ0を返す。
この方法だと利益自体は発生するケースを取りこぼす。
時間切れなのでナイーブな実装だけする。
Time Limit Exceeded となる実装が出来上がった。
解答を見る。

正解してから気づいたこと
-
解法の理解
https://leetcode.com/problems/best-time-to-buy-and-sell-stock/solutions/4868897/most-optimized-kadanes-algorithm-java-c-2yt85/
- pricesを先頭から全走査しながら、buyに見つけたprices[i]の最小値を入れている。
- prices[i] - buy で利益を求めて、利益が今までに見つけた利益よりも大きければ利益を更新している。
解法は理解できので、step1a.rsで実装する。
*/

pub struct Solution {}
impl Solution {}
impl Solution {
pub fn max_profit(prices: Vec<i32>) -> i32 {
/*
このコードはLeetCode採点システム上で Time Limit Exceeded となるコードです。
*/
if prices.is_empty() {
return 0;
}

let mut max_profit = 0;
for i in 0..prices.len() {
for j in i..prices.len() {
if prices[j] < prices[i] {
continue;
}

max_profit = max_profit.max(prices[j] - prices[i]);
}
}

max_profit
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn step1_test() {}
fn step1_test() {
assert_eq!(Solution::max_profit(vec![7, 1, 5, 3, 6, 4]), 5);
assert_eq!(Solution::max_profit(vec![7, 2, 5, 3, 6, 1]), 4);
assert_eq!(Solution::max_profit(vec![1, 2, 3, 4]), 3);
assert_eq!(Solution::max_profit(vec![4, 3, 2, 1]), 0);

assert_eq!(Solution::max_profit(vec![2, 1]), 0);
assert_eq!(Solution::max_profit(vec![1, 2]), 1);
assert_eq!(Solution::max_profit(vec![1]), 0);
assert_eq!(Solution::max_profit(vec![]), 0); // 株を購入できないので利益は0で意味が通ると思う
}
}
69 changes: 69 additions & 0 deletions src/bin/step1a.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Step1a
// 目的: 解答の解法を理解したことを確認する
// https://leetcode.com/problems/best-time-to-buy-and-sell-stock/solutions/4868897/most-optimized-kadanes-algorithm-java-c-2yt85/

// 方法
// 5分考えてわからなかったら答えをみる
// 答えを見て理解したと思ったら全部消して答えを隠して書く
// 5分筆が止まったらもう一回みて全部消す
// 正解したら終わり

/*
問題の理解
- 株価を表す配列pricesが与えられる。prices[i]はある日の株価である。
- ある日に株を買った場合に、最大になる利益を値として返す。
- 利益が発生しない場合は0を返す。
e.g.
prices = [1, 2, 3, 4] out = (prices[3] - prices[0]) = 3
prices = [4, 3, 2, 1] out = 0 // どの日に株を購入しても利益が発生しない

何を考えて解いていたか
- 先頭から全走査しながら
- 最小株価を見つけるたびに最小株価を更新する
- 今見ている株価prices[i]と最小株価から利益を求めて最大利益を更新する

正解してから気づいたこと
- 解法を理解した状態からコードにするまでは距離を感じない。
- 問題から解法にたどり着くまでに距離を感じる。
- この解答は最適解だと思うので、前回の問題(House Robber)でやったように再帰処理+memoといった別パターン実装を練習した方が良いと思った。step1b.rsで実装する。
*/

pub struct Solution {}
impl Solution {
pub fn max_profit(prices: Vec<i32>) -> i32 {
let mut min_price = match prices.len() {
0 => return 0,
_ => prices[0],
};
let mut max_price = 0;

for price in prices.into_iter().skip(1) {
if price < min_price {
min_price = price;
continue;
}

max_price = max_price.max(price - min_price);
}

max_price
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn step1a_test() {
assert_eq!(Solution::max_profit(vec![7, 1, 5, 3, 6, 4]), 5);
assert_eq!(Solution::max_profit(vec![7, 2, 5, 3, 6, 1]), 4);
assert_eq!(Solution::max_profit(vec![1, 2, 3, 4]), 3);
assert_eq!(Solution::max_profit(vec![4, 3, 2, 1]), 0);

assert_eq!(Solution::max_profit(vec![2, 1]), 0);
assert_eq!(Solution::max_profit(vec![1, 2]), 1);
assert_eq!(Solution::max_profit(vec![1]), 0);
assert_eq!(Solution::max_profit(vec![]), 0); // 株を購入できないので利益は0で意味が通ると思う
}
}
139 changes: 139 additions & 0 deletions src/bin/step1b.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// Step1b
// 目的: 動的計画法の問題に慣れるための練習

// 方法
// 5分考えてわからなかったら答えをみる
// 答えを見て理解したと思ったら全部消して答えを隠して書く
// 5分筆が止まったらもう一回みて全部消す
// 正解したら終わり

/*
問題の理解
- 株価を表す配列pricesが与えられる。prices[i]はある日の株価である。
- ある日に株を買った場合に、最大になる利益を値として返す。
- 利益が発生しない場合は0を返す。
e.g.
prices = [1, 2, 3, 4] out = (prices[3] - prices[0]) = 3
prices = [4, 3, 2, 1] out = 0 // どの日に株を購入しても利益が発生しない

方針
具体例の内容はHouse Robberだが、動的計画法(Dynamic Programming)自体に広く適用して解法を思いつくまでの過程を練習する。
https://leetcode.com/problems/house-robber/solutions/156523/from-good-to-great-how-to-approach-most-of-dp-problems/

何を考えて解いていたか
- 再帰処理にする場合を考える
解法を思いつかなかったのでChatGPT(GPT-5.1)の学習サポートモードで質問しながら理解を進めた。
ある時点iで
a) 株を売って利益を求める(株価の最小値を知っている必要がある。)
b) 株を売らない
といった選択肢が取れる。
- 基本ケース
- 株をそれ以上取引できない prices.len() <= i のとき0を返す。取引できないので利益は0
- 再帰ケース
- 株を売る場合
prices[i] - min_price により利益を求める
- 株を売らない場合
現在iをi+1でスキップして再帰に入る
株価の最小値を更新していく必要がある。

- どの部分をメモ化するか
- 目的として再帰呼び出しを減らしたい
iは単調増加するのでprices.len()回までのスタック深さとなる。
pricesの最大サイズは入力の制約から 10 ^ 5 となる。
1スタックフレームを50byteと見積もったとき、50byte * (10 ^ 5) = 5,000,000 となり、5,000,000 / (1024 * 1024) = 約5MBとなる
手元の実行環境で limit コマンドを実行したところスタックサイズ7MBとなったので大分ギリギリな感じがする。LeetCodeの採点システムのスタックサイズはよくわからないがstack overflowするだろうという感覚。
- iが単調増加するのでメモ化する部分がなく、入力のサイズから再帰による解法自体が筋が悪そう。
一応スタックオーバーフローするという予想でLeetCode採点システムで実行してみる。
スタックオーバーフローしなかった。空間計算量の見積もりに間違いがありそうなのでChatGPT(GPT-5.1)に聞く。
- スタック深さ(再帰呼び出しの回数)の見積もりはおk
- 1スタックフレームの見積もり50byteが悲観的過ぎる
- make_max_profitの引数のサイズ見積もり 合計28byte
- prices: &[i32] 16byte(fat pointerと呼ばれるものらしい。) https://stackoverflow.com/questions/57754901/what-is-a-fat-pointer
- i: usize プラットフォーム依存だが、8byteと見積もって良いと考える。64bitプラットフォームが一般的だという仮定。
- min_price: i32 4byte
- make_max_profitメソッド内 合計12byte
- price: &i32 8byte
- max_profit: i32 4byte
コンパイラによる最適化などを考慮せず、そのまま見積もると1スタックフレームあたり40byteとなる
40byte * (10 ^ 5) / (1024 * 1024) = 約3.8MBとなる
ここから更にコンパイラによる最適化で減る方向に値が動くことを考えるとスタックオーバーフローは大丈夫そうという見積もりになる。
ただ、入力の制約が動いたらスタックオーバーフローでプログラムがクラッシュすることを考えると、スタック深さのサイズ見積もりから再帰処理による実装は筋が悪そうだと感じる感覚は間違っていないと思った。

正解してから気づいたこと
- 入力の制約から最悪空間計算量で利用するスタックサイズが4MBあたりをうろつく時点で再帰処理による実装は忌避感を感じると思った。
入力として与えられる株価データは実務寄りで考えた場合、データの性質として増えていくものなので、再帰で実装するといずれスタックオーバーフローするだろうという感覚。

所感
- fat pointerという概念を知った。
https://stackoverflow.com/questions/57754901/what-is-a-fat-pointer
- だいぶ脱線した気がするが、空間計算量見積もりの良い練習になった
- 動的計画法の実装練習で再帰から初めてメモ化して〜という用にメモ化による最適化ができる前提で進めていた。
当たり前だがメモ化による最適化が適用できないケースが存在するので、入力データに比例してスタックサイズが大きくなるケースでは再帰による実装は筋が悪いなと思った。

スライスについて
- スライスを雰囲気で利用していることに気付いた。
https://doc.rust-jp.rs/rust-by-example-ja/primitives/array.html

プログラミングRust 第2版(https://www.oreilly.co.jp/books/9784873119786/)より引用
スライスは厳密には[T]型のデータを指すものであるが、スライスはほとんど常に参照として扱うので&[T]をスライスと呼ぶことがある。
- プログラミングRust 第2版 P.63 より
スライス[T]は任意の長さであり得るので、直接変数に格納したり関数の引数として渡すことができない。常に参照として渡される。
- プログラミングRust 第2版 P.61 より

スライス: &[i32] 連続するデータの先頭データへのポインタとスライスの長さ(コンパイル時に決定されない)を持つ参照 所有権を持たない。
スライスは2ワード(連続するデータ先頭データへのポインタ、スライスの長さ)からなる参照なので、fat pointer(太いポインタ)と呼ばれる
通常のポインタ(thin(細い) pointer)がメモリアドレスだけを持つのに対して、データへのポインタとスライスの長さ2ワード分を持つ参照なのでfat pointer(太いポインタ)と呼ばれる
Vec<T>: データ構造としてはヒープ領域に連続で配置したデータの先頭アドレスへのポインタ、データのサイズ、キャパシティをもつので、fat pointerか?と思ったが、Vec<T>自体は参照ではなく構造体なのでそもそもポインタではなかった。
&Vec<T>: Vec<T>への参照なのでthin pointer
*/

pub struct Solution {}
impl Solution {
pub fn max_profit(prices: Vec<i32>) -> i32 {
if prices.is_empty() {
return 0;
}

Self::make_max_profit(&prices, 0, prices[0])
}

fn make_max_profit(prices: &[i32], i: usize, min_price: i32) -> i32 {
let Some(price) = prices.get(i) else { return 0 };

if *price < min_price {
return Self::make_max_profit(prices, i + 1, *price);
}

let max_profit = Self::make_max_profit(prices, i + 1, min_price).max(*price - min_price);

max_profit
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
#[cfg(target_pointer_width = "64")]
fn playground() {
// https://stackoverflow.com/questions/57754901/what-is-a-fat-pointer
// 64bitプラットフォームで実行することを前提としている。仮に32bitプラットフォームで実行すると8byteになるはず。
// pointerのサイズが64bitの場合にのみテストを実行するように構成できる。面白い。
// https://doc.rust-lang.org/reference/conditional-compilation.html#r-cfg.target_pointer_width
assert_eq!(16, std::mem::size_of::<&[i32]>());
}

#[test]
fn step1b_test() {
assert_eq!(Solution::max_profit(vec![7, 1, 5, 3, 6, 4]), 5);
assert_eq!(Solution::max_profit(vec![7, 2, 5, 3, 6, 1]), 4);
assert_eq!(Solution::max_profit(vec![1, 2, 3, 4]), 3);
assert_eq!(Solution::max_profit(vec![4, 3, 2, 1]), 0);

assert_eq!(Solution::max_profit(vec![2, 1]), 0);
assert_eq!(Solution::max_profit(vec![1, 2]), 1);
assert_eq!(Solution::max_profit(vec![1]), 0);
assert_eq!(Solution::max_profit(vec![]), 0); // 株を購入できないので利益は0で意味が通ると思う
}
}
50 changes: 41 additions & 9 deletions src/bin/step2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,58 @@
// 改善する時に考えたこと

/*
講師陣はどのようなコメントを残すだろうか?
-

他の人のコードを読んで考えたこと
-
- 動的計画法の定義について。あまり深く考えたことはなかったが個人的にはそこまで重要では無いかなという感想。
https://github.com/olsen-blue/Arai60/pull/37/files#r2030022958

- 自分も初期値でprices[0]を使うなら&prices[1..]からループを回した方が違和感を感じないと思った。
ループ回数が減ってパフォーマンスが〜ということではなくて、直前でprices[0]を使っているのでこれを飛ばす方が自然だという感覚。
https://github.com/docto-rin/leetcode/pull/42#discussion_r2471529109

他の想定ユースケース
-
- min_priceの更新を常に行うという方針。ifの条件を間違える可能性を排除できるので常にminでやるのもありだと思った。
https://github.com/docto-rin/leetcode/pull/42#discussion_r2471449749

- 最初に思いつく動的計画法の解法として、一次元DPの方が自然な気がするので練習のために実装する。step2a.rs
https://github.com/olsen-blue/Arai60/pull/37/files#diff-0474f0ee7711182f0e97bb4047531dc4c65356748eafab139512400ac88c5c0bR10

改善する時に考えたこと
-
- prices.into_iter().skip(1)の部分はfor loopでは&prices[1..]の方が自然かも
prices.into_iter().skip(1).map(|x| ...) のようにするならメソッドチェーンで書けるのでskip()で良いかなという感覚。
- min_priceの更新ifで判定せずに常に最小を更新していくほうがシンプルになる
*/

pub struct Solution {}
impl Solution {}
impl Solution {
pub fn max_profit(prices: Vec<i32>) -> i32 {
let mut min_price = match prices.len() {
0 => return 0,
_ => prices[0],
};
Comment on lines +38 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ここですが、pricesが空かどうかのチェックと、空の場合のearly return、空でない場合のmin_priceの初期化と、複数の関心が1つのmatchに押し込められていて読みにくさを感じました。

一つは、シンプルに下記のようにする方法が最も読みやすいと感じます。

        if prices.is_empty() {
            return 0;
        }

        let mut min_price = prices[0];

もしくは、matchの書き方をリスペクトして、以下はどうでしょうか。

impl Solution {
    pub fn max_profit(prices: Vec<i32>) -> i32 {
        let (first, remainings) = match prices.split_first() {
            Some(x) => x,
            None => return 0,
        };

        let mut min_price = *first;
        let mut max_profit = 0;

        for &price in remainings {
            min_price = min_price.min(price);
            max_profit = max_profit.max(price - min_price);
        }

        max_profit
    }
}

少なくともmin_priceの初期化という関心は分離することができていると思います。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2つ目について、下の方が自然ですかね。

    let Some((first, remainings)) = prices.split_first() else {
        return 0;
    };

let mut max_price = 0;

for price in &prices[1..] {
min_price = min_price.min(*price);
max_price = max_price.max(*price - min_price);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

max_profitの方が適切だと思います。

}

max_price
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn step2_test() {}
fn step2_test() {
assert_eq!(Solution::max_profit(vec![7, 1, 5, 3, 6, 4]), 5);
assert_eq!(Solution::max_profit(vec![7, 2, 5, 3, 6, 1]), 4);
assert_eq!(Solution::max_profit(vec![1, 2, 3, 4]), 3);
assert_eq!(Solution::max_profit(vec![4, 3, 2, 1]), 0);

assert_eq!(Solution::max_profit(vec![2, 1]), 0);
assert_eq!(Solution::max_profit(vec![1, 2]), 1);
assert_eq!(Solution::max_profit(vec![1]), 0);
assert_eq!(Solution::max_profit(vec![]), 0); // 株を購入できないので利益は0で意味が通ると思う
}
}
Loading