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
92 changes: 85 additions & 7 deletions src/bin/step1.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,104 @@
// 正解したら終わり

/*
問題の理解
- ソート済の重複のない整数からなる配列numsと整数targetが与えられる。
numsにtargetが含まれる場合、numsのindexを返す。
numsにtargetが含まれない場合、numsがソートされた状態でtargetをnumsのどの位置に挿入するべきかのindexを返す。
- nums[1,3] target=2 output=1 nums[1]にtarget=2を挿入すればソートされた状態が維持できる
時間計算量がO(log n)になるようなアルゴリズムで実装する必要がある。つまり、線形探索は使えない。

何がわからなかったか
-
- binary searchをしながら、元の配列に対応するインデックスをうまく取り回す方法

何を考えて解いていたか
-
- binary searchの時間計算量はO(log n)になるので、binary searchを実装する。
- ちなみにRustでは似たような処理を行うiter::insert_positionといった感じで存在すると思うので後で実装を見てみる。
- binary searchは実装したことが無いので何をしているのか整理する。
- numsの真ん中のインデックス(middle)を取得する。
- target <= nums[middle] のように比較してtargetがどちらの集合にありそうか判断して、該当しない方は捨てる。
- binary searchするために2つに分ける。nums[0..middle],nums[middle..nums.len()]
- nums.len() == 2 になるまで行う。

- 再帰で解けそうな感じがする。
- 基本ケース
- nums.len() == 1 であれば target == nums[0] target <= nums[0]
でインデックスまたは挿入位置を特定する。
- 再帰ケース
- target <= nums[middle] で targetが含まれていると思われるnumsのスライスを引数に再帰に入る。
ここで手が止まったので答えを見る

想定ユースケース
-
解法の写経と理解
https://leetcode.com/problems/search-insert-position/solutions/7169834/search-insert-position-binary-search-for-0hjb/
- start,endで見ている区間の開始位置、終了位置を管理している。
- start,endの区間で見るものがなくなるまで繰り返しを行っている。
- 区間の真ん中の値(middle_value)とtargetの値を比較して等しければindexを返している。
- target と middle_valueを比較して、targetがmiddleで分けたときの区間の左側、右側どちらかにあるかを確認している。
- 区間の開始、終了を表す変数start,endをmiddleを基準にずらすことで、log n の範囲に区間を絞り込んでいる。
- 最後にstartをそのまま返しているのは直感的でないと感じた。
- target と middle_value の比較
- targetの方が小さい場合はmiddle - 1 をendに更新している
- targetの方が大きい場合はmiddle + 1 をstartに更新している
- targetがnums[i]のいずれよりも小さい時、startは動かないので0となり、targetはnums[0]の位置に挿入される。
- targetがnums[i]のいずれかより大きい時、startは常にnums[i] < target の位置を指し示す。
- nums[middle] == targetであれば早期リターンする。見つからない時はstartが指し示すインデックスを返せば良いというロジックになっている。
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

念のため確認させてください。早期リターンなしで書けますか?

Copy link
Copy Markdown
Owner Author

@t9a-dev t9a-dev Dec 10, 2025

Choose a reason for hiding this comment

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

いいえ。5分程度考えましたがよく分かりませんでした。早期リターンしない場合は区間を全部見終わった時(while loopの終了)のstart又はendの中身から戻り値を決定できそうだという感覚のみがあります。
他のレビューコメントでも自身の書いたコードやコメントから二分探索の理解、練習不足を指摘するものがあるので、次の問題に進む前に一度立ち止まって追加の練習を行います。
https://github.com/t9a-dev/LeetCode_arai60/pull/41/files#r2606203505

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.

改めてstep1.rsのコードを見るとstartがupper_boundを返す条件になっていることに気付きました。このコードのまま早期リターン無しで書くと探索終了後にstartの直前が有効な添字かつ、target以上であればstart - 1をlower_boundとして扱えます。

    pub fn search_insert(nums: Vec<i32>, target: i32) -> i32 {
        let mut start = 0;
        let mut end = nums.len() as i32 - 1;

        while start <= end {
            let middle = (start + end) / 2;
            let middle_value = nums[middle as usize];

            // if middle_value == target {
            //     return middle;
            // }
            if target < middle_value {
                end = middle - 1;
                continue;
            }

            start = middle + 1;
        }

        // startはupper_boundを指している。
        // 問題の制約としてnumsは重複しないのでupper_boundの1つ左がtarget以上であればlower_boundとして返せる。
        let previous_upper_bound = start - 1;
        if 0 <= previous_upper_bound && target <= nums[previous_upper_bound as usize] {
            return previous_upper_bound as i32;
        }

        start as i32
    }

最初からlower_boundを探すのであれば以下のように書けると理解しました。
lower_bound: target <= nums[i] を満たす最小のi

    pub fn search_insert(nums: Vec<i32>, target: i32) -> i32 {
        // [start,end)
        // start <= i < end
        let mut start = 0;
        let mut end = nums.len() as isize;

        while start < end {
            let middle = start + (end - start) / 2;

            if nums[middle as usize] < target {
                start = middle + 1;
            } else {
                end = middle;
            }
        }

        end as i32
    }

ありがとうございました。


正解してから気づいたこと
-
- 解法のコードは自然に理解できた。解法自体は近いところまで考えれていた気がするが、コードで表現する所までは距離があった。
- このコードは計算量や記述のシンプルさから無駄がないように見える。最適解だと思った。
- 練習として再帰に書き換えてみる。step1a_recursive.rs

n = nums.le()
時間計算量: O(log n)
空間計算量: O(1)

*/

pub struct Solution {}
impl Solution {}
impl Solution {
pub fn search_insert(nums: Vec<i32>, target: i32) -> i32 {
let mut start = 0;
let mut end = nums.len() as i32 - 1;

while start <= end {
let middle = (start + end) / 2;
let middle_value = nums[middle as usize];

if middle_value == target {
return middle;
}
if target < middle_value {
end = middle - 1;
continue;
}

start = middle + 1;
Comment on lines +77 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

自分はここif-elseで書きたいですね。場合分けの気持ちで書いており、また対比させた方が読みやすいと思うからです。

}

start
}
}

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

#[test]
fn step1_test() {}
fn playground() {
let nums = vec![1, 2];
let empty: Vec<i32> = vec![];

assert_eq!(empty, &nums[0..0]);
assert_eq!(vec![1], &nums[0..1]);
assert_eq!(vec![2], &nums[1..nums.len()]);
}

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

assert_eq!(Solution::search_insert(vec![], 8), 0);
}
}
93 changes: 93 additions & 0 deletions src/bin/step1a_recursive.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Step1a_recursive
// 目的: 別実装(再帰)への書き換えによる解法理解度の確認と実装練習

/*
問題の理解
- ソート済の重複のない整数からなる配列numsと整数targetが与えられる。
numsにtargetが含まれる場合、numsのindexを返す。
numsにtargetが含まれない場合、numsがソートされた状態でtargetをnumsのどの位置に挿入するべきかのindexを返す。
- nums[1,3] target=2 output=1 nums[1]にtarget=2を挿入すればソートされた状態が維持できる
時間計算量がO(log n)になるようなアルゴリズムで実装する必要がある。つまり、線形探索は使えない。

何がわからなかったか
- 再帰の基本ケースで end < start とするところを end <= start としてしまった。
- なぜ間違えたのか分からず end < start に直感的に修正していしまったので整理する。GPT-5.1の学習サポートモードに質問して理解を進める。
- まずこの実装が扱っているのは閉区間[start,end]。つまり、start,end両方とも区間に含める。
- 再帰に入るときの呼び出し方、再帰の基本ケースで分かる。
- [start,middle - 1]と[middle+1,end]という呼び出しになっている。
- ここで middle - 1,middle + 1 に注目するとmiddleが重複しないような呼び出しとなっている。
閉区間だと値それ自体を含むので、[start,middle],[middle,end]とすると値が重複する。
- 再帰の基本ケースで enc < start としている。
- 閉区間[start,end]を扱っているので、start == end [start..=end] は要素を1つ含む。
つまり、end <= start とすると、要素がまだ残っているのにも関わらず、基本ケースでstartを返してしまい誤りとなる。
end < start のとき、startがendを超えていることから、この区間で表せる範囲にもう見るべき値が残っていないので、startを返す。
- 再帰関数呼び出しの初期状態がnums.len() - 1 であることからも配列全体を示すときの区間の表し方として閉区間であることが分かる。[0..=nums.len() - 1]となる。

区間について
https://w3e.kanazawa-it.ac.jp/math/category/other/syuugou/henkan-tex.cgi?target=/math/category/other/syuugou/kukann.html&list=1

何を考えて解いていたか
- 再帰処理で実装するにはどうするか考える。
- 基本ケース
- target == nums[middle] return middle
- end <= start return start <- これは間違いで end < start が正しい
- 再帰ケース
- middleの計算 start + end / 2
- target < nums[middle] then end = middle - 1 else start = middle + 1
- 更新したstart,endを利用して再帰処理に入る

正解してから気づいたこと
- 再帰処理の基本ケース条件を間違えて気付いたが、区間が値自体を含む閉区間なのか、含まない開区間なのかしっかり分かっていないと危うい。
- 再帰処理にするのが難しいのではなくて、区間の開始、終了だけをずらす解法に思い至るまでに距離を感じる。
- step1.rs,本ステップの実装ともに閉区間による処理を行っている。RustのRange記法では&nums[a..b]としたときに区間[a..b)となり、left-close right-openとなる。
閉区間ではなく、左閉区間右開区間とするのが普通だということだろうか?ということが気になったのでGPT-5.1に聞いてみる。
- Off-by-oneエラーを避けるため
- &nums[a..b]がleft-close,right-openとなった根拠ではなさそうだが、&nums[a..b]のようなRange記法が[a,b)となるのは自然な感じがする。
閉区間[a..b]とした場合、&nums[nums.len()]が範囲外アクセスとなりOff-by-oneエラーになるため。
配列境界アクセス関連のエラーにOff-by-oneエラーという名前がついていること初めて知った。
https://ja.wikipedia.org/wiki/Off-by-one%E3%82%A8%E3%83%A9%E3%83%BC
こうやって見るとbinary searchを実装するときの区間の扱い方としては、left-close,right-openな半開区間が自然な気がするので、このバージョンの実装を行う。

n = nums.le()
時間計算量: O(log n)
空間計算量: O(1)
*/

pub struct Solution {}
impl Solution {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

二分探索もやっている処理は再帰的である、と捉えればこのような書き方もできるのですね。勉強になりました。

pub fn search_insert(nums: Vec<i32>, target: i32) -> i32 {
Self::search_insert_position(&nums, 0, nums.len() as i32 - 1, target)
}

fn search_insert_position(nums: &[i32], start: i32, end: i32, target: i32) -> i32 {
if end < start {
return start;
}

let middle = (start + end) / 2;
let middle_value = nums[middle as usize];
if middle_value == target {
return middle;
}

if target < middle_value {
return Self::search_insert_position(nums, start, middle - 1, target);
}
return Self::search_insert_position(nums, middle + 1, end, target);
}
}

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

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

assert_eq!(Solution::search_insert(vec![], 8), 0);
}
}
73 changes: 73 additions & 0 deletions src/bin/step1b_recursive_range_close_to_open.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// step1b_recursive_range_close_to_open
// 目的: 左閉区間右開区間(left-close,right-open)として区間を扱う実装を練習する。

/*
問題の理解
- ソート済の重複のない整数からなる配列numsと整数targetが与えられる。
numsにtargetが含まれる場合、numsのindexを返す。
numsにtargetが含まれない場合、numsがソートされた状態でtargetをnumsのどの位置に挿入するべきかのindexを返す。
- nums[1,3] target=2 output=1 nums[1]にtarget=2を挿入すればソートされた状態が維持できる
時間計算量がO(log n)になるようなアルゴリズムで実装する必要がある。つまり、線形探索は使えない。

何がわからなかったか

何を考えて解いていたか
- 区間を半開区間(left-close,right-open)として扱う時の再帰処理の設計
呼び出し時は[0..nums.len())となる。right-openなので区間に自身(nums.len())を含まないため。
- 基本ケース
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

基本ケースという言葉は見たことがないです。

アルゴリズムイントロダクションの分割統治の章では、base case(基底段階)とrecursive case(再帰段階)という言葉が使われていました。

またWikipediaのRecursionを引くと、base caseとrecursive stepが使われていたので、許容される表記揺れはこのくらいだと思います。

https://en.wikipedia.org/wiki/Recursion

- start >= end return start 比較記号を数直線上の並びにするよりも、変数の並びを数直線上にしたほうが個人的に分かりやすい
- start ~ end間に値があるかどうかで考える。left-close,right-openなので、start == end になると区間に値がなくなる。
[0..0)の間に値はない。
- target = nums[middle] return middle
- 再帰ケース
target < nums[middle] then [start..middle) else [middle+1..end) <-ここで target == nums[middle]ではないことが確定しているのでmiddle+1としてmiddle自体を除いている。

正解してから気づいたこと
- start,endをleft,rightに書き換えて見たがしっくりこなかったのでstart,endに戻した。start,endの方が単語の違いが視覚的に分かりやすい感じ。
- 区間を扱う時特に理由が無ければ半開区間(right-close,left-open)が良さそう。
- RustのRange記法[a..b]も半開区間(a..b]
- https://doc.rust-lang.org/std/ops/struct.Range.html
- Pythonのrange型も半開区間(a..b]
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

半開区間 a <= x < b を[a, b)と書くので丸括弧と角括弧の使い方が逆になってる気がします。

- https://docs.python.org/ja/3/library/stdtypes.html#typesseq-range

*/

pub struct Solution {}
impl Solution {
pub fn search_insert(nums: Vec<i32>, target: i32) -> i32 {
Self::search_insert_position(&nums, 0, nums.len() as i32, target)
}

fn search_insert_position(nums: &[i32], start: i32, end: i32, target: i32) -> i32 {
if start >= end {
return start;
}

let middle = (start + end) / 2;
let middle_value = nums[middle as usize];
if middle_value == target {
return middle;
}

if target < middle_value {
return Self::search_insert_position(nums, start, middle, target);
}

return Self::search_insert_position(nums, middle + 1, end, target);
}
}

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

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

assert_eq!(Solution::search_insert(vec![], 8), 0);
}
}
81 changes: 72 additions & 9 deletions src/bin/step2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,89 @@
// 改善する時に考えたこと

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

他の人のコードを読んで考えたこと
-
- 二分探索のコメント集
https://docs.google.com/document/d/11HV35ADPo9QxJOpJQ24FcZvtvioli770WWdZZDaLOfg/edit?tab=t.0#heading=h.c15qprmvxkc2
- 35. Search Insert Positionのコメント集
https://docs.google.com/document/d/11HV35ADPo9QxJOpJQ24FcZvtvioli770WWdZZDaLOfg/edit?tab=t.0#heading=h.e13uiztrq2u9
- 二分探索関連のコメントが豊富
https://github.com/Ryotaro25/leetcode_first60/pull/45
https://github.com/seal-azarashi/leetcode/pull/38
https://github.com/Fuminiton/LeetCode/pull/41#discussion_r2080995529

- 値が大きい場合のオーバーフローについて。
https://github.com/Ryotaro25/leetcode_first60/pull/45#discussion_r1878268512
問題を解いている時に意識の中になかった。C++でint型の値を扱っている。
(start + end) / 2 について start + (end - start) / 2 としてオーバーフローを避けるべきという内容。
endをnums.len()から持ってきている。numsのサイズが非常に大きい場合にendはintの上限まで膨らむ可能性がある。
つまり、endがINT::MAXみたいな感じになった時に、startを加算するとオーバーフローするという内容だと理解した。
自分のコードにも当てはまる指摘なので、修正する。
それにしても、(start + end) / 2 を見た時にオーバーフローするかも知れないと気付けても、start + (end - start) / 2 にしようと思いつけないと思った。
数学的なテクニックを感じた。(start + end) / 2 を代数変形すると start + (end - start) / 2 で等価になる。今回この方向の考え方に触れられたので、いつか思い出して使えそうだと思った。
start + (end - start) / 2 でやっていることとして
- オーバーフローしないよう先に減少する方向の演算を行う。
- (end - start) / 2
- 前の項の計算結果は 1 / 2 以下になっており start を加算してもオーバーフローしない。
start = 1 end = 3 middle = 2
- (start + end) / 2 -> (1 + 3) / 2 = 4 / 2 = 2
- start + (end - start) / 2 -> 1 + (3 - 1) / 2 = 1 + 2 / 2 = 1 + 1 = 2
単純に式として見ると代数変形したところで結果は変わらないが、コンピュータに計算させることを前提とすると結果が変わる(オーバーフローの有無)式になることが面白いと思った。

他の想定ユースケース
-
- どこか(見失った)のコメントからたどり着いた、ソートされていない大きなデータ10GBの中央値を2GB以内の空間計算量で求めるには?といったstack overflowの質問。
時間を書けて調べれば解法が何を言っているのかわかりそうな気もするが時間切れなのでメモのみ。
https://stackoverflow.com/questions/3572640/interview-question-find-median-from-mega-number-of-integers/3576479

改善する時に考えたこと
-
- step1.rsでは閉区間で区間を扱っていたが、半開区間(a..b]で区間を扱うように変更。特に理由が無ければOff-by-oneエラーを避けるために半開区間(left-close,right-open)を使った方が良さそう。
- middle = (start + end) / 2 についてオーバーフローしないよう middle = start + (end - start) / 2 とする。

所感
- Rustではpartition_pointメソッドで同じことができそう。
https://doc.rust-lang.org/std/primitive.slice.html#method.partition_point
実装はbinary_search_byメソッドのWrapperという感じ。
*/

pub struct Solution {}
impl Solution {}
impl Solution {
pub fn search_insert(nums: Vec<i32>, target: i32) -> i32 {
let mut start = 0;
let mut end = nums.len();

// left-close,right-open
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

imo: 区間の話を書くよりも、"探しているものをどんなものだと捉えているか"と、"それを探すにあたってleftやrightをどんな意味で使っているか"」を書くと、どんなことを考えているのか読み手に伝わりやすいかも知れません。

// [0..1) の時、区間内に値が残っている
// [0..0) の時、区間内に値が残っていない
while start < end {
let middle = start + (end - start) / 2;
let middle_value = nums[middle];
if middle_value == target {
return middle as i32;
}

if target < middle_value {
// 右開区間(right-open)であり、その値自体を含まないのでそのままmiddleを代入
end = middle;
continue;
}

// 左閉区間(left-close)であり、その値自身を含むのでmiddle自体をスキップするためにmiddle + 1を代入
start = middle + 1;
Comment on lines +73 to +80
Copy link
Copy Markdown

@naoto-iwase naoto-iwase Dec 10, 2025

Choose a reason for hiding this comment

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

これらのコメントも間違っているわけではないですが、言ってしまえば説得力がやや弱いです。さらにループ不変条件や、この区間設定からループ継続条件がstart < endになることの導出、二分探索の正当性を説明できるところまでがSWEの常識に含まれると思います。

こちら、odaさんが最近書かれた記事の一節もよければ参考にされてください。

https://nuc.hatenadiary.org/entry/2025/11/29/#二分探索を読めるか

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

あと、いつものコメント集ですね。

https://docs.google.com/document/d/11HV35ADPo9QxJOpJQ24FcZvtvioli770WWdZZDaLOfg/edit?tab=t.0#heading=h.c15qprmvxkc2

自分語りで恐縮なんですが、二分探索について自分はここのコメント集を全て漁った上で3時間ほどLLMに壁打ちし、異なる9パターン実装してだいたい把握できました。

誰もやらない(left, right]などの不確定域の区間設定や、あえてupper bound型の二分探索にしたり、過剰にkey空間を抽象化したりとさまざまなわかりにくいコードを意図的に書く練習をしたのが自分は効きました。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

さまざまなわかりにくいコードを意図的に書く練習をした

これは練習において何をするべきかよく分かっていますね。ボール球を意図的に投げてみないとストライクは投げられないです。

}

start as i32
}
}

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

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

assert_eq!(Solution::search_insert(vec![], 8), 0);
}
}
Loading