Skip to content

153 find minimum in rotated sorted array#2

Open
takuya576 wants to merge 4 commits into
mainfrom
153-Find-Minimum-in-Rotated-Sorted-Array
Open

153 find minimum in rotated sorted array#2
takuya576 wants to merge 4 commits into
mainfrom
153-Find-Minimum-in-Rotated-Sorted-Array

Conversation

@takuya576
Copy link
Copy Markdown
Owner

Problem

153. Find Minimum in Rotated Sorted Array

Approach

2分探索で解きました。

Additional Notes

Comment on lines +16 to +29
int findMin(int* nums, int numsSize) {
int left = 0, right = numsSize - 1, target = nums[0];
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
right = mid - 1;
target = nums[mid];
}
else {
left = mid + 1;
}
}
return target;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

この書き方と、他の方の一般的な回答(while(left<right)while(right-left>1)等)と比べると、一般的な回答の方が読みやすいと思います。
主に理由は以下です。

  • target変数が削減され、考えることや覚えることが減る。
  • leftでもrightでもないものが最終的に見つけるものになっているのはあまり見かけず認知負荷が高い。
  • 終了条件がright<leftとなる二分探索はあまり見かけず認知負荷が高い。

ちなみにもしかしたらstep1-3に分けている意図をご存じないかもしれないので、一応補足しておきます。
philip82148/leetcode-swejp#10 (comment)
(↑※このやり取りで僕はコーディングに正解があるという考えになっていますが、それは間違いです。(可読性の観点では)読みやすければすべて正解です)

尤も、学習の方法は人によって相性があるので必ずしもstep1-3でやる必要はありません。

※こちらはリンク先まで読むと長いですが、参考として載せておきます。
Miyamoto-tryk/leetcode-arai60#1 (comment)

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.

レビューありがとうございます!
35.Search Insert Positionにて、left<=rightが使用されていたので、メジャーな手法なのかと思っていました。
step2,3において、他の人の視点を取り入れた上で反復し、自分がスムーズに書ける型を見つけることが大事ということですね。
アルゴリズムのインプットが完璧ではないので、一回ざっとやってしまうのも手かもしれません。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

自分がスムーズに書ける型を見つけることが大事ということですね。

そうですね。これはodaさんが言っていることに自分の解釈を入れた、もしかしたら個人の考え(なので話半分で聞いてもらえばよいの)ですが、僕もまだ60問解いてないので全てとは言い切れないものの、コーディングは日本語の文章を書くのと同じように、考えを書き起こす作業です。Miyamoto-tryk/leetcode-arai60#1 (comment)
そう考えると、考えがまとまっていれば、その書き起こしの作業自体にはあまり時間がかからないはずだ、ということですね。(とはいえもちろん日本語よりはかかると思いますけども)
(尤もコーディングに慣れないうちは書き起こす考え自体がまとまらないこともあるかもしれません…。それに現実に書くアルゴリズムは推敲が必要な複雑なものもあると思います)
(ちなみに僕は運営ではないのでこれはただの僕の思想です笑)

35.Search Insert Positionにて、left<=rightが使用されていた

なるほど、left<=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.

@philip82148
コードは読めましたか。(つまり、動くのか動かないのか、動くとしたらどうしてなのか。<= でなくてはいけないのか。target の変更を消すとしたらどうしたらいいのか。などです。)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

すみません、takuyaさんには悪いのですが、完全には読んでいませんでした。
僕が挙げた読みづらい点を改善した場合ドラスティックな変更になると思っていたのでそれ以降考えるのをやめていました。(でもwhile(left<=right)が良くあるパターンということを知っていたらtargetをどうにかするだけだったのでちゃんと読んでいたかもしれません。)(ちなみにwhile(...)と表現していますが、その条件式自体が悪いという意味ではなく、全体を指す言い方が他に思い付かなかったのでそう表現しています。)

ちなみに今改めて見てみると、二分探索というより単に最小を求めるプログラムとして読めます。
それはともかく、うろ覚えですが恐らく最初の読みでは、

  1. while(left<=right)だから[F,F,...,T,T]の配列なら、終了条件のとき、境目のFにrightが、Tにleftに来る。
  2. left=mid+1right=mid-1だから間違ってはなさそう。(それぞれがT,Fになることが許可されている。)
  3. 返り値はright+1なのか。(終了条件ではleftと同じだ)
  4. nums[mid] < targetが条件式だから、終了条件の時targetは「nums[right] < targetが初めてfalseとなるright」+1である。
  5. (エッジケースはまあいいか。)

みたいなことを考えていたと思います。target=nums[right+1]なので、rightがある限り、targetは削減できると思っていました(nums[mid] < targetの条件式にtargetが使われているのはありますが、条件式の設定方法は他にもいろいろあるなと思い)。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

レビューにおいては、書かれていることを読み取って、書いた人の頭の中を推測し、どのような情報を伝えるとよりよくなるかを考えて、その情報を出すというのが理想的なプロセスです。
読み取れなかったならば、読み取れなかったので教えて欲しい、から入って、上のプロセスに合流します。

今回、読み取れていないところで止まらず連想した情報をたくさん出していますが、分からなかったときに止まって確認に入らないのはエンジニアリング全般においてかなり悪い癖です。

一般論として、「文字を書くこと」も「コストを増やす」行為であり、読んでもらった結果として「ベネフィットがコストを上回るか」のバランスで書くかを決めましょう。

私は「何かを知っていることやできることそれ自体はいいことではない」と考えています。

Comment on lines +7 to +9
閉区間で行くか半開区間で行くか、前もって決めれば、midの動かし方で躊躇しなくなった。
midとの比較対象として、ずっと配列の左端の値(target)を使っているが、ベストかわからない。
もっとスマートな方法があるのだろうか?
Copy link
Copy Markdown

@philip82148 philip82148 Apr 17, 2025

Choose a reason for hiding this comment

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

分からなかったらすぐに他の人の回答を見るでいいと思いますよ
疲れるようでは長続きしないと思いますので
それから、while(left <= right)ではない二分探索の方が多かったのではないでしょうか?
Discordで問題文で検索すれば色々出てくると思うのですが、見つけられなかったときのために(レビューまで含めて読んで鵜呑みにはしないでほしいですが)自分のを載せておきます。
philip82148/leetcode-swejp#13

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.

アルゴリズムの知識やその実装経験が少ないので、そのようにしようかと思います。
philipさんはstep2,3で2分程度でコーディングしていますが、頭の中で2分探索の絵が描けるのでしょうか?正直自分は、色々なケースでうまく行くか紙に書いてで検証しないと、確証を持って提出できないという状況です。right = midにするのか、right = mid - 1にするのかであったり、nums[mid] > nums[right]で行くべきか、nums[mid] >= nums[right]で行くべきか、などです。

Copy link
Copy Markdown

@philip82148 philip82148 Apr 17, 2025

Choose a reason for hiding this comment

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

うーん、結論から言うと2分で考えて書いているのは本当なのですが、何故できているかに関しては(直前に書いてて覚えてるのもあるんですが)経験も含まれるんでしょうかね。

2分探索の絵が描けるのでしょうか?

この絵がどんなものか僕は分からないのですが、僕がイメージするのは左半分がFalse(またはTrue)になっていて、右半分がTrue(またはFalse)になっている棒ですね。
その棒を両手でもって、中間地点を右手か左手で持ち直して同じ位置に来たら探索終了と考えます。
(ちなみに以下では[Flase,False,False,...,True,True,True]の配列で考えていますが、PRの方は[True,True,True,...,False,False,False]の配列で考えていますので注意です)

まず僕にとって理解しやすかったのはこちらのパターンです。

// 始める前にleftがFalse、rightがTrueであることを確認しておく
int left = 0, right = numSize - 1;
while(right - left > 1) { // leftとrightが隣り合うまで持ち替え続ける
  int mid = (left + right) / 2;
  if(条件式(mid)) {
    right = mid; // rightは常に条件式をTrueにする
  } else {
    left = mid; // leftは常に条件式をFalseにする
  }
}
// この時点でleftとrightはFalseとTrueの境目
return nums[right]; // 条件式がTrueとなるものの一番左
// もしくは、
return nums[left]; // 条件式がFalseとなるものの一番右

次に、leftがTrueになることを許可することで、このパターンが理解できるようになります。

// 始める前にrightがTrueであることを確認しておく
int left = 0, right = numSize - 1;
while(left < right) { // leftとrightが同じになるまで
  int mid = (left + right) / 2;
  if(条件式(mid)) {
    right = mid; // rightは常に条件式をTrueにする
  } else {
    left = mid + 1; // midはFalseだが、leftはTrueになりうる
  }
}
// この時点でleft=rightは一番左のTrue
return nums[right]; // 条件式がTrueとなるものの一番左

もしくはrightがFalseになることを許可することで、このパターンが理解できるようになります。

// 始める前にleftがFalseであることを確認しておく
int left = 0, right = numSize - 1;
while(left < right) { // leftとrightが同じになるまで
  int mid = (left + right) / 2;
  if(条件式(mid)) {
    right = mid - 1; // midはTrueだが、rightはFalseになりうる
  } else {
    left = mid; // leftは常に条件式をFalseにする
  } 
}
// この時点でleft=rightは一番右のFalse
return nums[left]; // 条件式がFalseとなるものの一番右

以上は

right = midにするのか、right = mid - 1にするのかであったり、

に対する回答ですが、

nums[mid] > nums[right]で行くべきか、nums[mid] >= nums[right]で行くべきか

に関しては条件式に当たる部分で、問題によって異なります。
つまり、最初にどんな「左半分がFalse(またはTrue)になっていて、右半分がTrue(またはFalse)になっている棒」だと考えるかということです。

ちなみに、「二分探索は別に特別なアルゴリズムではなく、実は人間が日常生活でも自然に行っている身近な方法だ」という話をどこかで聞いたことがある気がします。
例えば本の丁度80ページ目を開くにはどうするでしょうか?電話帳で名前を探すにはどうするでしょうか?(まあ二分はしていませんが)
二分探索は実は身近なアルゴリズムであると考えるのも手かもしれません。

ちなみに、二分探索は読むよりも書く方が楽なので、正直上の説明でも分かりずらい気がしています。
後ついでに言えば、僕は競プロで二分探索を使いまくっているので(ライブラリを使っているため生で書いたことはなかったものの)、アルゴリズムに対するそういう慣れもあるのかもしれません。
(最後の最後に元も子もないのですが使う・書く練習あるのみなのかもしれません)

Copy link
Copy Markdown
Owner Author

@takuya576 takuya576 Apr 18, 2025

Choose a reason for hiding this comment

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

アドバイスありがとうございます!
[Flase,False,False,...,True,True,True]の棒という考え方をすれば、条件式の設定やright,leftの動かし方がイメージしやすくなりました。

今回の問題ですと、nums[mid]<nums[0]を条件式とする場合、
[0,1,2,3]のような全くrotateしていない配列のケースでは、[Flase,False,False,False]になってしまうので、必然的にrightがfalseになることを許容する書き方でないといけないなと思いました。([1,2,3,0]のケースでは、[Flase,False,False,True]となるので、一見一番上のパターンでもいけそうですが、、)。

もし、nums[mid]<=nums[0]を条件式とするなら、[0,1,2,3]=[True,False,False,False][1,2,3,0]=[True,False,False,True]となってしまうので、ちょっとキモいですね。
本来左がFalseで右がTrueという設定のはずですから、、
@philip82148 さんが以前解いてるように、right = numSizeを用いて以下の円環状の解き方をするしかないなと思いました。

int left = 0, right = numSize;
while(left < right) { // leftとrightが同じになるまで
  int mid = (left + right) / 2;
  if(nums[mid]<=nums[0]) {
    right = mid; // midはTrueで、rightもTrueにしかならない
  } else {
    left = mid + 1; // midはFalseだが、leftはTrueになりうる
  } 
}
return nums[right % numSize]; 

いや、この方法だと[2,1]のケースに対応できませんでした。。。
なんか、今の自分の感覚だとどうしてもパズルになってしまっています。
「<を<=にしたら、その分の帳尻を合わせるためにどこをいじればいいのか」みたいな形です。
いじらなければいけない変数が色々あって、そこの均衡点を探そうような感じです。

ともかくちょっとだけ条件式やleft,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.

そうですね…。もしかしたらstd::partition_point使ってみるといいかもしれません。
C++の標準の二分探索のライブラリなんですが、今までの問題をそれを使うとどうなるか。

どうしてもパズルになってしまっています。

(この協会ではパズルが悪い意味で使われていることもあったかもしれませんが、 @takuya576 さんのおっしゃりたいことの意味的には)その感覚はいいと思っています。パズルというか、アクロバティックな考え方というか、本来そういう考え方の自由さは(常に?)姿勢としてあった方がいいと思っています。

この方法だと[2,1]のケースに対応できませんでした。。。

(多分これ最初に早期リターン追加すれば済むんじゃないですかね。同じ設定ではないですが参考)

問題はそのパズルをどう解くかですね。
困難は分割せよっていますが、もしかしたらこれもそうかもしれません。
個人的な考え方ですが、僕にとって二分探索というのは、

  1. 条件式のアルゴリズム
  2. 棒を縮めていくアルゴリズム(+最終的に何を答えにするか)
  3. エッジケースをどうするか

っていう3つで考えていて、2はいくつかのパターンしかなく、3はおまけというか、あんまり重要ではないです。
まあ、言葉で説明するよりやってもらった方がいいと思っていて、とりあえずstd::partition_point使ってみてほしいです笑
これを使うと引数にleftの初期値、rightの初期値、条件式のアルゴリズムを渡すだけで二分探索で答えが出てきます。
色々試してみたら、1が最も重要で、3はあんまり重要じゃなくて、ってのが分かると思います。そこまでわかったら、std::partition_pointを好きな2のアルゴリズムで置き換えるだけです。
(ちなみに条件式にはmidしか渡せないので、毎回targetを変更してそれと比較するやり方は出来ないです)

あとちなみに、コメントに関しては話半分で聞くでいいと思いますよ。

Miyamoto-tryk/leetcode-arai60#1 (comment)

私のものを含め、コメントについては話半分に聞くくらいがよいと思います。基本的には、これはひどい目にあって学習していくものなので、将来どこかでひどい目にあったときに、「そういう話があったな」と思い出す、くらいのタイムスケールで考えていると思ってください。

書く側っていうのはどうしても読む側の気持ちが分からないので、人のコードを読んだ時にこの書き方読みづらいなとか、この書き方の方が読みやすいじゃん!となればいいと思います。
読むほうが大事 - コーディング練習会典型コメント集

あと、レビューコメントも「あくまで私はこう思う」というスタンスです(多分皆面倒くさくてコメントでそのニュアンスを表現できていないのですが笑)。
(多分典型コメント集のどこかに書いてあるんじゃないかなと思うんですがどこですかね…)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

// 始める前にleftがFalse、rightがTrueであることを確認しておく
int left = 0, right = numSize - 1;
while(right - left > 1) { // leftとrightが隣り合うまで持ち替え続ける
  int mid = (left + right) / 2;
  if(条件式(mid)) {
    right = mid; // rightは常に条件式をTrueにする
  } else {
    left = mid; // leftは常に条件式をFalseにする
  }
}
// この時点でleftとrightはFalseとTrueの境目
return nums[right]; // 条件式がTrueとなるものの一番左
// もしくは、
return nums[left]; // 条件式がFalseとなるものの一番右

こちらのコードに違和感を感じました。

int left = 0, right = numSize - 1;

とあることから、閉区間を想定しているように見えました。また、

while(right - left > 1) { // leftとrightが隣り合うまで持ち替え続ける

とあることから、ループを終了したタイミングで、区間の中に 2 つの要素が含まれることが分かります。この実装ですと、

  • [False, ..., False] という配列の中から条件式がTrueとなるものの一番左
  • [True, ..., True] という配列の中から条件式がFalseとなるものの一番右

の位置を求めることができないのではないでしょうか?

仮にこのパターンで書くのであれば、

int left = -1, right = numSize;

と開区間を想定して False と True の境界を求めたあと、条件式がTrueとなるものの一番左、もしくは条件式がFalseとなるものの一番右の位置を求めてあげるのが良いと思いました。これは、番兵として、一番左の左側に False を、一番右の右側に True を置いて閉区間を想定するのと等価になると思います。

残りの二つは問題ないように思いますが、 right が False になることを許可するパターンはあまり見ないように思いました。 left が True になることを許可するパターンのほうをよく見かけます。

今回の問題ですと、nums[mid]<nums[0]を条件式とする場合、

すでに他の方の解答等を見てご存じかと思いますが、 nums[mid] <= nums[numSize - 1] を条件式とすることで、 [False, ..., False, True, ..., True] と等価な配列から一番左の True を探す問題とみなすことができ、かつ rotate されていない場合に [True, ..., True] という配列から一番左の True を探す問題とみなすことができます。これにより、考え方とコードをシンプルにすることができると思います。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

...の位置を求めることができないのではないでしょうか?
仮にこのパターンで書くのであれば、

int left = -1, right = numSize;

と開区間を想定して False と True の境界を求めたあと...

こちらその通りですね。コードが間違っていました。失礼しました。

right が False になることを許可するパターンはあまり見ないように思いました。

そうなのですね。というか、こちらのコードよくよく考えたら動かないことに気づきました。(笑)
この場合、int mid = (left + right + 1) / 2;としないと、例えばleft=3,right=4;になっている時にrightが永遠に更新されませんでした。
教えてくださりありがとうございます!

Copy link
Copy Markdown
Owner Author

@takuya576 takuya576 Apr 25, 2025

Choose a reason for hiding this comment

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

@philip82148

そうですね…。もしかしたらstd::partition_point使ってみるといいかもしれません。
C++の標準の二分探索のライブラリなんですが、今までの問題をそれを使うとどうなるか。

C++の標準ライブラリを使って慣れてみようと思います!

条件式のアルゴリズム
棒を縮めていくアルゴリズム(+最終的に何を答えにするか)
エッジケースをどうするか

自分で何度か書き直したり、皆さんの議論を見ているうちに自分もこの考え方が何となく染み付いてきました。ありがとうございます!

}
}
return target;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

int findMin(int* nums, int numsSize) {
    int left = 0, right = numsSize - 1;
    int target = nums[0];
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            right = mid - 1;
        }
        else {
            left = mid + 1;
        }
    }
    return nums[left % numsSize];
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@takuya576 私がちょっと手を加えるならば、このあたりです。どういう風に考えて書いたかをもう少し教えて欲しいのと、こっちのちょっと変えた二つのコードを見て、何が違うかを考えて欲しいです。

int findMin(int* nums, int numsSize) {
    int left = 0, right = numsSize - 1;
    int target = nums[right];
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] <= target) {
            right = mid - 1;
        }
        else {
            left = mid + 1;
        }
    }
    return nums[left];
}

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.

自分の思考過程を記述しようと思います。
----------思考過程-------------------
閉区間で書くため、int left = 0, right = numsSize - 1,とする。
nums[mid]と比較を行う変数が必要だな。35番の問題で、targetという数とnums[mid]の比較を行っていたからtargetという変数でいいか。
(この辺りで、left, right, targetあたりを最終的な戻り値にできたら、余計な変数が増えずスマートだなと考えています。)
最初は、nums[0]とnums[mid]を比較して、nums[mid]が大きい場合は探索範囲を右半分に、nums[mid]が小さい場合は探索範囲を左半分に限定できるな。target = nums[0]としよう。
targetとしてnums[0]を使い回すのと、targetをより小さいnums[mid]で更新するのどっちがいいだろう。
どっちでも、2分探索の進み方は同じになるなあ。
targetを、「それまでの探索で一番小さい数を格納するもの」とすれば、最終的にtargetに答えが収まる。
だから、target = nums[mid];の更新文を入れて最後にreturn targetをすれば分かりやすいのではないか。
---------思考過程------------------
以上が自分が考えていたことです。

小田さんが提示してくださったコードについて、考えを記述します。
以下が違う点です。

  • int target = nums[right];, int target = nums[0];のように、left, rightと役割が異なる変数を分離して宣言することで、役割の違いが明確になる。
  • また、自分のコードの場合だと、targetに「閾値」と「答え」という二つの役割が課されており、そしてその「閾値」が特定の条件で変動するため、思考がややこしくなるのかなと思いました。小田さんのコードでは、targetは閾値の役割でしか使われず、定数です。
  • 最後に、答えにleftが用いられるため、終了状態でどの部分が答えになっているかを、頭でイメージを描きながら認知しやすい。
    上記三点が、小田さんのコードから感じたことです。

これらの指摘は、小田さんが考えていらっしゃることと合致していますか?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

えー、まず、改善の余地はあるものの、この構造が悪いとは思いません。
私が書いてみたのは標準的な二分探索に寄せてみただけのものです。

そのうえで、聞きたかったのは、「どう考えていたのか」「どう表現されているか」の距離感です。
これは、「最小値を探す」コードで、target という変数名の意図は「ここまで見つかった中での最小値」ですね。

これは「ターゲット」とはいい難いですね。標準的な二分探索では「ある値より小さいか等しいものの中で一番左にあるものを探したい、その目標値」という意味なので、ターゲットが満点かはともかく名付けとしては筋が通っています。

そうすると、「最小値を更新しながら、より小さいものがある可能性のある場所を絞っていき、その可能性がなくなったら終了」というプログラムですね。次に気になるのは、「一番小さい場所を見る前に終了することはないのか」ということです。これは大丈夫そうです。

そういうわけで、名前が実態を表していないのが、一番妙なところかと思います。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

言い方を変えると、left とはそこよりも左に最小値がないと確定している領域、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.

意味を考えるとここでの意味での閉区間での表現ならば終了時には入れ替わりますよね。left 以上 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.

意味を考えるとここでの意味での閉区間での表現ならば終了時には入れ替わりますよね。left 以上 right 以下の範囲は条件を満たしているか調べないといけない領域なので。

この部分の解釈に自信ありませんでした。終了時には、調べないといない領域を調べつくしたので、 left 以上 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.

まず、nums[mid] <= nums[right] のバージョンとの兼ね合いでいえば、
[F, F, F, F, ?, ?, ?, T, T, T, T, T] という風に、False が確定した場所と True が確定した場所と未確定が並んでいて、
left は F と ? の境界の右側、right は、? と T の境界の左側にありますよね。
? がなくなったときには、left 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.

target = nums[mid]; と target を更新し続ける回答との兼ね合いでいうと、target は、常にここまで出てきた最小値を指しています。また、これは、先頭との比較をしています。このコードを < か <= で理解するかは、どちらでもよく少し意味付けが変わります。target の位置を t、target よりも大きいと判明しているところを True とすると、
[T, T, ?, ?, ?, ?, t, T, T, T]
という図になって left, right は ? の両端を意味しています。? がなくなるときに left, right が入れ替わります。
t が左端にない場合は、left と t は同じ位置で終了します。

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

区間の中に要素が一つもない状態を left > right で表していると理解しました。ありがとうございます。

int mid = left + (right - left) / 2;
if (nums[mid] < target) {
right = mid - 1;
target = nums[mid];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

target が最小値、つまり二分探索で見つけたい値ということですよね?二分探索は「この範囲に目当てのものがあるはず」という区間を狭めていくアルゴリズムなので target が right より右に来ることは自然ではないように思います。

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.

確かに、一見自然でない見え方がすると思いました。
targetは各ループまでに見つけられた最小値を格納しておくもので、
次のループに移るときは、rightの右側に出てしまいます。
rightがmidを左に追い越すときに(right = mid - 1;)、rightが最小値を飛び越してしまうこともあるから、なんとなく怖いという発想で、target = nums[mid];で格納しておいた。みたいな考えでした。
left, 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.

hroc135さんやolsen-blueさんからも指摘がある通り、targetは一定であって欲しいと思いました。

自分も何度か受けた指摘になるのですが、ループ内での不変条件を意識したらいいと思いました。

@oda
Copy link
Copy Markdown

oda commented Apr 18, 2025

https://docs.google.com/document/d/11HV35ADPo9QxJOpJQ24FcZvtvioli770WWdZZDaLOfg/edit?tab=t.0#heading=h.hztxgiufh8yd
https://discord.com/channels/1084280443945353267/1355903616032178327/1359007350161412127

それはそうと、コードを書き換えたときには、古いのを残したほうがいいでしょう。

なんらかの指摘が入ったファイルは残しておいて、新しく一から書き直して付け足すほうが私はいいと思います。この練習の目的は、「同じものを見たときに同じ反応をするようになること」「ゼロから書けるようになること」であって、きれいな状態のコードを Github にアップロードすることではないので。
私は、これ見返すときの分析対象をわざわざ破壊しているように見えています。

int mid = left + (right - left) / 2;
if (nums[mid] < target) {
right = mid;
target = nums[mid];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

targetをいじりたくない気がします。
変数名の通り、目標とする値なので、これは一定であって欲しいと感じます。

```

また、他の人の回答も見てみた。
while (left < right - 1)という条件で、leftと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.

以前小田さんから追加質問を受けたものです(恥ずかしながらめちゃくちゃ躓いております)
Ryotaro25/leetcode_first60#46 (comment)

「2で割る処理がありますがこれは切り捨てでも切り上げでも構わないのでしょうか。」
「nums[middle] <= nums[right] とありますが、これは < でもいいですか。」
「nums[right] は、nums.back() でもいいですか。」
「right の初期値は nums.size() でもいいですか。」
この辺りを考えてみるのみヒントになりそうです。

@@ -0,0 +1,32 @@
int findMin(int* nums, int numsSize) {
int left = 0, right = numsSize - 1;
while (left <= right) { // leftと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.

区間を考えるにあたり、プログラムの実行中、一瞬でも left > right となる瞬間がある点に違和感を感じました。

int left = 0, right = numsSize - 1;

とあるため、区間の種類を閉区間としているのだと思いました。その場合、常に left <= right が成り立つと思いました。

// [False,...,False,True,...True]で、Trueの左端が答えになる

とあるのですが、これは False と True の境界の位置を求めようとしているのでしょうか?それとも、一番左端の True の位置を求めようとしているのでしょうか?
仮に後者だとすると、最後に一番左端の True の位置に left と right が来て、閉区間の中に一番左端の True のみが含まれる状態になるのだと思いました。

一方、コメントには

// 終了状態は以下のよう。leftの位置が答えに
// [False,...,False,True,...True]
//            right "left"

とあり、区間についての根本的な考え方に違和感を感じました。

https://discord.com/channels/1084280443945353267/1196498607977799853/1269532028819476562
このコメントや他の二分探索に関するコメントをご覧になることをお勧めします。

while (left <= right) { // leftとrightが交差して終了するケース
int mid = left + (right - left) / 2;
if (nums[mid] < nums[0]) { // [False,...,False,True,...True]で、Trueの左端が答えになる
right = mid - 1; // 基本True側にいるが、終了時にFalseに来る
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

一番左端の True の位置を求めようとしているのだとすると、区間の中に一番左端の True が常に含まれる状態になければならないように思います。ところが、 mid の位置に一番左端の True が来た場合、 -1 をしてしまうと、区間から一番左端の True が外れてしまいます。これは元の問題設定と矛盾した実装となっているように思います。

Comment on lines +7 to +9
閉区間で行くか半開区間で行くか、前もって決めれば、midの動かし方で躊躇しなくなった。
midとの比較対象として、ずっと配列の左端の値(target)を使っているが、ベストかわからない。
もっとスマートな方法があるのだろうか?
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

// 始める前にleftがFalse、rightがTrueであることを確認しておく
int left = 0, right = numSize - 1;
while(right - left > 1) { // leftとrightが隣り合うまで持ち替え続ける
  int mid = (left + right) / 2;
  if(条件式(mid)) {
    right = mid; // rightは常に条件式をTrueにする
  } else {
    left = mid; // leftは常に条件式をFalseにする
  }
}
// この時点でleftとrightはFalseとTrueの境目
return nums[right]; // 条件式がTrueとなるものの一番左
// もしくは、
return nums[left]; // 条件式がFalseとなるものの一番右

こちらのコードに違和感を感じました。

int left = 0, right = numSize - 1;

とあることから、閉区間を想定しているように見えました。また、

while(right - left > 1) { // leftとrightが隣り合うまで持ち替え続ける

とあることから、ループを終了したタイミングで、区間の中に 2 つの要素が含まれることが分かります。この実装ですと、

  • [False, ..., False] という配列の中から条件式がTrueとなるものの一番左
  • [True, ..., True] という配列の中から条件式がFalseとなるものの一番右

の位置を求めることができないのではないでしょうか?

仮にこのパターンで書くのであれば、

int left = -1, right = numSize;

と開区間を想定して False と True の境界を求めたあと、条件式がTrueとなるものの一番左、もしくは条件式がFalseとなるものの一番右の位置を求めてあげるのが良いと思いました。これは、番兵として、一番左の左側に False を、一番右の右側に True を置いて閉区間を想定するのと等価になると思います。

残りの二つは問題ないように思いますが、 right が False になることを許可するパターンはあまり見ないように思いました。 left が True になることを許可するパターンのほうをよく見かけます。

今回の問題ですと、nums[mid]<nums[0]を条件式とする場合、

すでに他の方の解答等を見てご存じかと思いますが、 nums[mid] <= nums[numSize - 1] を条件式とすることで、 [False, ..., False, True, ..., True] と等価な配列から一番左の True を探す問題とみなすことができ、かつ rotate されていない場合に [True, ..., True] という配列から一番左の True を探す問題とみなすことができます。これにより、考え方とコードをシンプルにすることができると思います。

@@ -0,0 +1,16 @@
int findMin(int* nums, int numsSize) {
int left = 0, right = numsSize - 1, target = nums[0], min = nums[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.

一行で定義している変数の数が多く、読みにくく感じました。一行に 1 つずつ定義したほうが読みやすいように思います。

min = nums[mid];
}
if (nums[mid] < target) {
right = mid - 1;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

理解の確認のため質問させてください。nums[mid] の値が最小値だった場合、上のコードですでに最小値を保存しているので、狭めたあとの区間に nums[mid] を含めなくてよい、という意図で合っていますでしょうか?もしこの意図で書いたのではないのであれば、二分探索の根本的な考え方に不安があります。

left = mid + 1;
}
if (nums[mid] < target) {
target = nums[mid];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ターゲットとなる何らかの値を更新しているという点に違和感を感じました。 min_num といった変数名にしたほうが分かりやすいと思います。

}
}
return target;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@oda さんはループが終了したタイミングで left > right となる点は許容する派ですか?閉区間であることと

left とはそこよりも左に最小値がないと確定している領域、right とはそこよりも右に最小値がないと確定している領域

と併せて考えると、最小値が存在しないことになってしまうと思います。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants