Skip to content

Conversation

@Kaichi-Irie
Copy link
Owner

問題へのリンク

Find Minimum in Rotated Sorted Array - LeetCode

言語

Python

問題の概要

回転された昇順ソート配列から最小値を見つける。配列の回転とは、要素を一つずつ右にずらす操作を指す。例えば、[3, 4, 5, 1, 2][1, 2, 3, 4, 5]を回転させた結果である。
本問では時間計算量がO(log(n))であることが求められる。

自分の解法

二分探索を用いて、回転された配列の最小値を見つける。配列の中央の要素と端の要素を比較し、どちら側に最小値が存在するかを判断する。
元の配列をa0<a1<a2<...<anとすると、回転された配列はak+1< ak+2<...<an > a0<a1<...<akのような形になる。この性質を利用して、二分探索を行う。

leftrightをぞれぞれ配列の左端と右端のインデックスとして初期化し、中央の要素を計算する。中央の要素が右端の要素より大きい場合、最小値は右側にあるため、leftmidに更新する。逆に、中央の要素が右端の要素以下の場合、最小値は左側または中央にあるため、rightmidに更新する。この操作を繰り返し、最終的にrightが最小値のインデックスとなる。
二分探索では常にnums[left] > nums[right]が成り立つように、leftrightの更新を行う。

  • 時間計算量:O(log(n))
  • 空間計算量:O(1)

step2

  • L, Rをそれぞれleft, rightに置き換える。
    • 大文字はPEP8に反する。大文字は定数に使うべき。
    • 1文字の変数名はPEP8に反する
    • L, Rが市民権を得ているのはatcoderだけ

次に解く問題の予告

def findMin(self, nums: list[int]) -> int:
if not nums:
raise ValueError("nums must have at least one element.")
elif len(nums) <= 2:
Copy link

Choose a reason for hiding this comment

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

この elif 文と

        # no rotation
        if nums[L] <= nums[R]:
            return nums[L]

が、以下の二分探索で正しい解が出力できないためハックとして入れたものに感じられました。

La[n-1]a[0] との境界より常に左にあり、 Ra[n-1]a[0] との境界より常に右にあるという設定だと思います。その場合、 L = -1R = len(nums) と初期化することで、ローテーションしていない場合に 0 を出力することができるようになるはずです。ただし、 if nums[mid] < nums[R]:if nums[mid] <= nums[-1]: と修正してあげる必要があります。

二分探索について考える場合は、

  • left と right が要素の位置を表しているのか?要素と要素の間の境界の位置を表しているのか?
  • left の位置の要素または境界を、区間に含めるか?含めないか?
  • right の位置の要素または境界を、区間に含めるか?含めないか?
  • 区間を狭めるとき、 mid の位置の要素または境界を区間の中に含める/含めないためには、いくつ mid に足せばよいか/から引けばよいか?
  • 最後の状態で left と right の位置関係はどうなっているか?
  • 最後に度の値を返せばよいか?

あたりを考えるとよいと思います。

この問題については過去に多くのレビューコメントが付けられています。「二分探索」で Discord サーバーを検索することをおすすめします。

なお私の過去のコメントの中には、二分探索について十分に理解していなかった頃に書いたものがあります。小田さんのコメントを中心に読まれることをおすすめします。

もし余力があれば、以下のソースコードが二分探索についてどのような考えに基づいて書かれているか、説明してみてください。

class Solution:
    def findMin(self, nums: list[int]) -> int:
        if not nums:
            raise ValueError("nums must have at least one element.")

        L = -1
        R = len(nums) + 1

        while L + 2 < R:
            mid = (R + L) // 2
            if nums[mid] <= nums[-1]:
                R = mid + 1
            else:
                L = mid

        return nums[L + 1]

補足となりますが、上記のソースコードは練習用に書いたコードです。他のソフトウェアエンジニアは上記のような考え方や書き方は、あまりしないと思います。実務では上記のような考え方や書き方はしないほうがよいと思います。

Copy link
Owner Author

Choose a reason for hiding this comment

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

レビューありがとうございます。
確かに前処理が少々煩雑で、ハックしたように(≒エッジケースに引っかかったから足したように)見えてしまったり、可読性を下げてしまったりしてしまいました。
私は基本的にはleft=0, right=len(nums)-1からスタートして閉区間[left, right]を探索する代わりに、面倒なケースは前処理ですべてearly returnする方針で二分探索を解いてきました。しかし、もう少し問題ごとに柔軟にわかりやすい方針で解くよう意識したいと思います。

ソースコードの二分探索について

くださったソースコードの二分探索は、以下の考えで書かれたと思います。

  • leftrightは要素の位置を表している
  • left, right の位置の要素を区間に含めない
    • 添え字を開区間(left, right)で探索している
      • 添え字は0,1,...,len(nums)-1なので、(-1, len(nums))スタート
  • 求める値の添え字はleftrightに挟まれた値
  • そのため、最後に返すべき値はnums[left+1] = nums[right-1]になる

開区間で二分探索を考えたことは無かったので、はじめはとても読みづらいと感じましたが、一度わかってしまえばすっと読めました。

Copy link

Choose a reason for hiding this comment

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

添え字は0,1,...,len(nums)-1なので、(-1, len(nums))スタート

R = len(nums) + 1

ですので (-1, len(nums) + 1) スタートとなっています。もう一度読み直してみていただけますか?

Copy link
Owner Author

Choose a reason for hiding this comment

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

確かにその通りですね。
では、left, rightは要素と要素の間の境界の位置を開区間で表していると思います。
境界の位置は0, 1, ..., len(nums) なので、それを開区間で表すと(-1, len(nums)+1)となります。
境界の位置iに対応する要素の値はnums[i]として最後に返すべき値はnums[left+1] = nums[right-1]になる

L=len(nums)-1になってしまうとまずいのではないかとも思いましたが、mid=len(nums)-1のときにはif nums[mid] <= nums[-1]に引っかかるので大丈夫、と理解しました。)

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.

ありがとうございます!


# @lc code=start
class Solution:
def findMin(self, nums: list[int]) -> int:
Copy link

Choose a reason for hiding this comment

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

step1, step2ではnumsが空配列のときのケアをしているがstep3では行っておらずIndexErrorを投げ得る点は少し気になりました。

Copy link
Owner Author

Choose a reason for hiding this comment

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

レビューありがとうございます。
たしかにその通りですね。失念していました。

# case 2. rotated
# find min using binary search. nums[left] > nums[right] always holds
while right - left > 1:
mid = left + (right - left) // 2

Choose a reason for hiding this comment

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

Python で整数は overflow しないのでこれは不要ですね

Copy link
Owner Author

Choose a reason for hiding this comment

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

レビューありがとうございます。結論その通りだと思います。
Pythonでオーバーフローしないことは知っていたのですが、right+leftが64bit整数型の範囲を超えて、多倍長整数の演算に切り替わる場合にはパフォーマンスが著しく落ちるのではないかと思って一応このように書いていました。
しかし計測してみたところ、それほど差が出ませんでした。むしろ足し算の算術演算の回数が小さくなる(right+left)//2の方が良いまでありそうですね。

num_trials = 50_000_000
big_int = pow(2, 64)
time_start = time.time()
for _ in range(num_trials):
    _ = big_int + big_int

elapsed = time.time() - time_start
print(f"{elapsed=}") # 2.343sec

small_int = pow(2, 10)
time_start = time.time()
for _ in range(num_trials):
    _ = small_int + small_int

elapsed = time.time() - time_start
print(f"{elapsed=}") # 2.320sec

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.

5 participants