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
30 changes: 30 additions & 0 deletions 153_find_minimum_in_rotated_sorted_array_medium/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# 問題へのリンク
[Find Minimum in Rotated Sorted Array - LeetCode](https://leetcode.com/problems/find-minimum-in-rotated-sorted-array/description/)

# 言語
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`のような形になる。この性質を利用して、二分探索を行う。

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

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

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


# 次に解く問題の予告
- [Evaluate Division - LeetCode](https://leetcode.com/problems/evaluate-division/description/)
- [Subsets - LeetCode](https://leetcode.com/problems/subsets/)
27 changes: 27 additions & 0 deletions 153_find_minimum_in_rotated_sorted_array_medium/meguru.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#
# @lc app=leetcode id=153 lang=python3
#
# [153] Find Minimum in Rotated Sorted Array
#

# @lc code=start
class Solution:
def findMin(self, nums: list[int]) -> int:
def is_index_value_leq_to_last(index: int) -> bool:
if index < 0 or len(nums) <= index:
return False
return nums[-1] >= nums[index]

left = -1
right = len(nums) - 1
while (right - left) > 1:
mid = (right + left) // 2
if is_index_value_leq_to_last(mid):
right = mid
else:
left = mid

return nums[right]


# @lc code=end
35 changes: 35 additions & 0 deletions 153_find_minimum_in_rotated_sorted_array_medium/step1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#
# @lc app=leetcode id=153 lang=python3
#
# [153] Find Minimum in Rotated Sorted Array
#


# @lc code=start
class Solution:
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.

ありがとうございます!

return min(nums)

L = 0
R = len(nums) - 1
# no rotation
if nums[L] <= nums[R]:
return nums[L]

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

# R-L = 1, nums[L]: largest, and nums[R]: smallest
return nums[R]


# @lc code=end
35 changes: 35 additions & 0 deletions 153_find_minimum_in_rotated_sorted_array_medium/step2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#
# @lc app=leetcode id=153 lang=python3
#
# [153] Find Minimum in Rotated Sorted Array
#


# @lc code=start
class Solution:
def findMin(self, nums: list[int]) -> int:
if not nums:
raise ValueError("nums must have at least one element.")
elif len(nums) <= 2:
return min(nums)

left = 0
right = len(nums) - 1
# Array is not rotated (Already sorted in ascending order)
if nums[left] <= nums[right]:
return nums[left]

# nums[right] < nums[left] always holds during binary search
while (right - left) > 1:
mid = left + (right - left) // 2
if nums[mid] <= nums[right]:
right = mid
# nums[left] <= nums[mid]
else:
left = mid

# nums[left]: largest, and nums[right]: smallest
return nums[right]


# @lc code=end
29 changes: 29 additions & 0 deletions 153_find_minimum_in_rotated_sorted_array_medium/step3.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#
# @lc app=leetcode id=153 lang=python3
#
# [153] Find Minimum in Rotated Sorted Array
#


# @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.

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

left = 0
right = len(nums) - 1
# case 1. already sorted
if nums[left] <= nums[right]:
return nums[left]

# 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

if nums[left] < nums[mid]:
left = mid
else:
right = mid

return nums[right]


# @lc code=end