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

# 言語
Python

# 問題の概要
rotateされたソート済み配列から特定の値`target`を検索する問題です。配列は回転されているため、通常の二分探索ではなく、回転された状態を考慮した二分探索を行う必要があります。


# 自分の解法

## step1

2回二分探索を用いて、回転されたソート済み配列から特定の値を検索する解法。1回目の二分探索で、配列の最小値を見つけ、`shift`(どのくらい配列がrotateされたか) の値を求める。2回目の二分探索で、`shift`を考慮して元の配列のインデックスを計算し、通常の二分探索で`target`を検索する。インデックスの計算が毎回必要になるため、少し複雑な実装になる。
- 時間計算量:`O(logn)`
- 空間計算量:`O(1)`

```python
class Solution:
def search(self, nums: list[int], target: int) -> int:
def find_rotated_shift() -> int:
left = 0
right = len(nums) - 1
if nums[left] <= nums[right]:
return 0
Comment on lines +25 to +26

Choose a reason for hiding this comment

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

この2行を取り除いて動くように書き換えられますか?
left と right の意味を説明できますか?

while (right - left) > 1:
Copy link

Choose a reason for hiding this comment

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

() は外してよいと思います。

mid = (right + left) // 2
if nums[mid] <= nums[right]:
right = mid
else:
left = mid
return right

def shifted_index(index: int, shift: int) -> int:
return (index + shift) % len(nums)

NOT_FOUND = -1

shift = find_rotated_shift()
left = 0
right = len(nums) - 1
if target < nums[shifted_index(left, shift)]:
return NOT_FOUND
elif target > nums[shifted_index(right, shift)]:
return NOT_FOUND
elif nums[shifted_index(right, shift)] == target:
return shifted_index(right, shift)
Comment on lines +43 to +48

Choose a reason for hiding this comment

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

上と同じですが、この6行を取り除いて動くように書き換えられますか?
left と right の意味を説明できますか?

Copy link

Choose a reason for hiding this comment

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

(私も守りきれているかどうかはともかく)個人的にはレビューにおいて能力の確認はあまり好ましくないと思っています。
疑問文を使うときには提案か意図の確認がいいでしょう。
「X という方法もあるが、こちらのほうがよいのではないか。」「A B C の中で B を選んだようだが、どうしてそれがよいと考えたのか。」
これは建設的ですね。X という方法で通じると思っています。コードを書いてしまうかリンクなどをつければより親切です。
「Y という条件を満たす解法もあるが、君は思いつくことができるか。」これ能力の確認ですね。

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

Copy link

Choose a reason for hiding this comment

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

説明できないかもと思ったならば根拠まで示すのも1つです。たとえば、これは「1つの変数に、最低何枚で作れるかと、そもそも作れるかのふたつの意味を持たせようとしている」のでどう考えているか知りたくなりました。

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

Choose a reason for hiding this comment

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

二分探索を理解されているか不安になり質問してしまいました。
確かにコードを書いてしまう方が親切ですね。

レビューコメントを書き直しました。
find_rotated_shift では (left, right] に最小値を取る index が含まれるように二分探索し、要素が一つになると right を返すようになっていると思います。left は初期値が 0 で、while right - left > 1:であるため right は left と重ならないので right は 0 になりえず、先頭が最小値の場合に見つけられません。left は left 以下に最小値がないことが確定している index を表しているので初期値を left = -1 とすれば、先頭が最小値の場合も含めて二分探索のコードで処理できます。
2番目も同様で(逆ですが)、[left, right) に traget があるとしたらここという index が含まれるように二分探索していると思います。先ほどと同じ理由で right の初期値を len(nums) - 1 とすると、left が len(nums) - 1 にはなりえないので target が末尾にある場合には見つけられません。right は right 以上に target がないことが確定している index を表しているため、right = len(nums) とすると、末尾が target の場合も含めて二分探索のコードで処理できます。
このような初期値とした方が見通しがよくなると思いますがいかがでしょうか?

class Solution:
    def search(self, nums: list[int], target: int) -> int:
        def find_rotated_shift() -> int:
            left = -1
            right = len(nums) - 1
            while right - left > 1:
                mid = (right + left) // 2
                if nums[mid] <= nums[right]:
                    right = mid
                else:
                    left = mid
            return right

        def shifted_index(index: int, shift: int) -> int:
            return (index + shift) % len(nums)

        shift = find_rotated_shift()
        left = 0
        right = len(nums)
        while right - left > 1:
            mid = (right + left) // 2
            if target < nums[shifted_index(mid, shift)]:
                right = mid
            else:
                left = mid
        if nums[shifted_index(left, shift)] == target:
            return shifted_index(left, shift)
        return -1  # not found

Copy link

Choose a reason for hiding this comment

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

ありがとうございます。
不変条件が何なのかコードから読み取れなかったのでどういう意図で書いたのか知りたい、自分だったらこのように考える、という意図の確認と提案になりましたね。仕事をよりよく進めたいという点で一致しているので、受け取り手がどう受け取るかを考えると話がスムーズに進みます。

Choose a reason for hiding this comment

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

こちらこそご指摘いただきありがとうございます。以後気を付けます。

@Kaichi-Irie
失礼いたしました。書き直したコメントをご確認いただけますと幸いです。

Copy link
Owner Author

Choose a reason for hiding this comment

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

お二方ともレビューとアドバイスをありがとうございます。
返信が遅れてしまい申し訳ありません。

>odaさん
自分自身もレビューの際には能力の確認をしないよう気をつけます。

>tokuhiratさん
丁寧なコメントをありがとうございます。left=-1right=len(nums)から始めた方が見通しが良いのはその通りだと思います。しかし、問題を見てすぐの、手探りの状態でいきなりこの方法を取ると、バグを生んでしまいかねないと自分はまだ感じています。特に、Pythonだと負の数もindexに入れられるので、left=-1から始めてプログラムを実行させると、バグっていても動いてしまうことが割とあると感じています。そのため、step1ではこのような読みづらいコードになってしまっています。

一応、このコードの心は次のとおりです。


いずれの二分探索もindexは配列の要素の位置を表すとして、区間[left, right]に対して探索を行っているつもりで書いています。

不変条件は

  • 1つ目の二分探索:nums[left] > shift >= nums[right]
  • 2つ目の二分探索:nums[left] <= target < nums[right]

です。これをはじめに満たすためにif文などで端を処理しています。

ちなみに個人的には最も書きやすい書き方(left=0, right=len(nums)-1→端の処理をして不変条件を満たす→不変条件を満たすようにwhile right-left>1: ..., left=mid, ..., right=midreturn left/ rightとテンプレ化している)だと感じています。また、閉区間の方が直感的に分かりやすいとも感じています。(問題にはよりますが)とはいえ、初めの条件分岐がadhocに見えて、読み手にも負荷がかかってしまって良くない書き方で改善したいと思っています。

Choose a reason for hiding this comment

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

step1で読みやすいコードにならないことは問題なく、他の書き方を見て整理されたコードに変形していけることが重要と思っています。

コードの心の部分が読み取れなかったので念のため確認させてください。
冒頭の文(区間[left, right]に対して探索を行っている)と、初期値の設定(left=0, right=len(nums)-1)を見ると target があるとすれば left 以上 right 以下にあるように範囲を狭めるという設定と読みました。
一方で、不変条件を見ると right は target より大きい要素を指しているようで、終了条件は while right-left>1 であり left 以上 right 未満の要素が1つになると終了するようになっています。また、if target < nums[shifted_index(mid, shift)]: right = mid をみると、target の index になりえない mid を right としているので、こちらも right 未満に target が含まれるように区間を狭めています。
閉区間 [left, right] で考えている箇所と、半開区間 [left, right) で考えている箇所があるように見え、right が指しているものが区間に含まれるのかどうかわかりませんでした。どのような意味で right を書いていますか?

Copy link
Owner Author

Choose a reason for hiding this comment

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

コメントありがとうございます。
確かにおっしゃる通り、target/shiftを含む区間はいずれも半開区間ですね。これまで、二分探索で探索する区間についてきちんと言葉を扱えていなかったことがはっきりしました。気づけて良かったです。ありがとうございます。


mid = 0
while (right - left) > 1:
mid = (right + left) // 2
if target < nums[shifted_index(mid, shift)]:
right = mid
else:
left = mid
if nums[shifted_index(left, shift)] == target:
return shifted_index(left, shift)
else:
return NOT_FOUND
```


## step2

```python
class Solution:
def search(self, nums: list[int], target: int) -> int:
left = 0
right = len(nums) - 1
while left <= right:
mid = (right + left) // 2
if nums[mid] == target:
return mid
# midが左側の領域にいる
if nums[left] <= nums[mid]:
if nums[left] <= target < nums[mid]:
right = mid - 1
else:
left = mid + 1

# midが右側の領域にいる
else:
if nums[mid] < target <= nums[right]:
left = mid + 1
else:
right = mid - 1

NOT_FOUND = -1
Copy link

Choose a reason for hiding this comment

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

定数を関数の最後で定義するのはあまり見ないように思います。クラス変数として定義してはいかがでしょうか?

return NOT_FOUND
```

- 模範解答を見て二分探索を1回だけ用いる解法(ワンパス二分探索)に変更した。条件分岐が複雑で、二回に分けて考える解法の方がわかりやすいのでは?とも考えたが、次のように考えるとこの場合分けが漏れのないものだと自信を持てるようになった。
- 原則:「回転した配列を真ん中(`mid`)で分割すると、`[left,mid]`もしくは`[mid,right]`のいずれか一方はソートされた配列になっている。」
- 場合1:`nums[left] <= nums[mid]` の場合、左側の部分配列はソートされている。
- この場合、`target`が左側にあるかどうかを確認し、そうであれば左側を探索する。もしそうでなければ左側はもう探索する必要がないので、この範囲を捨て、右側を探索するようにする。
- 場合2:`nums[left] > nums[mid]` の場合、逆に`nums[mid]<=nums[right]`が成り立ち、右側の部分配列はソートされている。
- この場合、`target`が右側にあるかどうかを確認し、もしそうであれば右側を探索する。そうでなければ右側はもう探索する必要がないので、この範囲を捨て、左側を探索するようにする。
- 本問では`target`に一致するindexを探すので、添え字の区間を`[left,mid)`,`[mid,mid]`,`(mid,right]`の3つに分けて考えるとわかりやすい。
- 基本的には閉区間でインデックスを指す二分探索がわかりやすいと感じている。
- `while left <= right`を使った二分探索は初めて。
- `while left <= right`: **特定の値を「見つける」** ための探索。ループの **中で** 答えが見つかる。答えが見つかり次第、値を返すので、ループを走査し終えたときは答えが見つからなかったことを意味する。
Copy link

Choose a reason for hiding this comment

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

while left < right と書いても特定の値を見つけることができると思います。この場合、ループの中では return せず、ループが終わったあとに left (=right) の位置の値を調べ、特定の値と一致しているかどうかを調べることになります。

  • left/right が要素自体を指しているか、要素と要素の間の境界を指しているか。
  • 区間について考えるあたり、区間には要素が含まれているか、境界が含まれているか。
  • left/right が指しているものは区間に含まれているか。
  • mid の位置のものを調べたとき、それを狭めたあとの区間に含めたいか/含めたくないか。
  • 最後の状態で区間の中には何が残っていて欲しいか。
  • 要素が少なくなった時に無限ループしないか。

といった観点で考えて、その結果をもとにコードに落とし込んでみてはいかがでしょうか?

Copy link
Owner Author

Choose a reason for hiding this comment

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

レビューありがとうございます。
脳内にこのチェックリストが叩き込まれるように練習します!

- `while left < right`: **条件を満たす「境界」**(例: 挿入位置、最初のtrueなど)を探すための探索。ループが **終わった後** に答えが確定する。
- これまでは境界を求めるために `while (right-left)>1` という条件で探索を行っていたが、これでは前処理が少し複雑になる。`while left < right` に変更することで、よりシンプルに実装できるようになるが、`left` と `right`の更新が`mid`, `mid+1`, `mid-1`のいずれかになることに気をつける必要がある。(`while (right-left)>1`の場合は、`mid`だけで更新できる)→めぐる式を採用すれば解決しそうだが、今度は読み手に伝わるかどうかが問題となる。


## step3

```python
class Solution:
def search(self, nums: list[int], target: int) -> int:
left = 0
right = len(nums) - 1
while left <= right:
mid = (right + left) // 2
if nums[mid] == target:
return mid

# [left, mid]がソート済み配列の場合
Copy link

Choose a reason for hiding this comment

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

読み手にとってやや分かりづらいロジックに対し、十分な量のソースコードコメントが書かれている点、好感が持てました。

if nums[left] <= nums[mid]:
# target が [left, mid]の範囲内にある場合
if nums[left] <= target < nums[mid]:
right = mid - 1
# それ以外([left, mid]の範囲を捨てる)
else:
left = mid + 1
# [mid, right]がソート済み配列の場合
else:
# target が [mid,right]の範囲内にある場合
if nums[mid] < target <= nums[right]:
left = mid + 1
# それ以外([mid,right]の範囲を捨てる)
else:
right = mid - 1
NOT_FOUND = -1
return NOT_FOUND
```

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

# 別解 区間を分ける
```python
class Solution:
def search(self, nums: list[int], target: int) -> int:
# 最小値のインデックスを求める
def find_min_index(nums):
left = 0
right = len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] > nums[right]:
left = mid + 1
else:
right = mid
return left

min_index = find_min_index(nums)

# min_indexを基準に2つの区間に分けて探索
left, right = 0, len(nums) - 1
while left <= right:
mid = (left + right) // 2
if nums[mid] == target:
return mid
# 左側の区間を探索
if mid < min_index:
if nums[left] <= target < nums[mid]:
right = mid - 1
else:
left = mid + 1
# 右側の区間を探索
else:
if nums[mid] < target <= nums[right]:
left = mid + 1
else:
right = mid - 1

NOT_FOUND = -1
return NOT_FOUND
```

- こちらも二分探索を2回用いる解法である。最初に最小値のインデックス`min_index`を求めた後、`min_index`を基準にして2つの区間に分けて探索を行う。
- 具体的には、`nums[:min_index]`と`nums[min_index:]`の2つの部分配列に分けて、それぞれに対して通常の二分探索を行う。
- スライスを作るとメモリを消費するので、インデックスで計算を進める。

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

# 次に解く問題の予告
- [Coin Change - LeetCode](https://leetcode.com/problems/coin-change/)
51 changes: 51 additions & 0 deletions 33_search_in_rotated_sorted_array_medium/step1.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#
# @lc app=leetcode id=33 lang=python3
#
# [33] Search in Rotated Sorted Array
#

# @lc code=start
class Solution:
def search(self, nums: list[int], target: int) -> int:
def find_rotated_shift() -> int:
left = 0
right = len(nums) - 1
if nums[left] <= nums[right]:
return 0
while (right - left) > 1:
mid = (right + left) // 2
if nums[mid] <= nums[right]:
right = mid
else:
left = mid
return right

def shifted_index(index: int, shift: int) -> int:
return (index + shift) % len(nums)

NOT_FOUND = -1

shift = find_rotated_shift()
left = 0
right = len(nums) - 1
if target < nums[shifted_index(left, shift)]:
return NOT_FOUND
elif target > nums[shifted_index(right, shift)]:
return NOT_FOUND
elif nums[shifted_index(right, shift)] == target:
return shifted_index(right, shift)

mid = 0
while (right - left) > 1:
mid = (right + left) // 2
if target < nums[shifted_index(mid, shift)]:
right = mid
else:
left = mid
if nums[shifted_index(left, shift)] == target:
return shifted_index(left, shift)
else:
return NOT_FOUND


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

# @lc code=start
class Solution:
def search(self, nums: list[int], target: int) -> int:
left = 0
right = len(nums) - 1
while left <= right:
mid = (right + left) // 2
if nums[mid] == target:
return mid
# midが左側の領域にいる
if nums[left] <= nums[mid]:
if nums[left] <= target < nums[mid]:
right = mid - 1
else:
left = mid + 1

# midが右側の領域にいる
else:
if nums[mid] < target <= nums[right]:
left = mid + 1
else:
right = mid - 1

NOT_FOUND = -1
return NOT_FOUND


# @lc code=end
45 changes: 45 additions & 0 deletions 33_search_in_rotated_sorted_array_medium/step2_exclusive_range.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#
# @lc app=leetcode id=33 lang=python3
#
# [33] Search in Rotated Sorted Array
#

# @lc code=start
class Solution:
def search(self, nums: list[int], target: int) -> int:
def find_min_index() -> int:
left = 0
right = len(nums) - 1

while left < right:
mid = (right + left) // 2
if nums[mid] > nums[right]:
left = mid + 1
else:
right = mid
return left

def binary_search(left_bound, right_bound, target) -> int:
left = left_bound
right = right_bound

while left <= right:
mid = (right + left) // 2
if nums[mid] == target:
return mid
if target < nums[mid]:
right = mid - 1
else:
left = mid + 1
return NOT_FOUND

NOT_FOUND = -1
min_idx = find_min_index()

if nums[min_idx] <= target <= nums[-1]:
return binary_search(min_idx, len(nums) - 1, target)
else:
return binary_search(0, min_idx - 1, target)


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

# @lc code=start
class Solution:
def search(self, nums: list[int], target: int) -> int:
left = 0
right = len(nums) - 1
while left <= right:
mid = (right + left) // 2
if nums[mid] == target:
return mid

# [left, mid]がソート済み配列の場合
if nums[left] <= nums[mid]:
# target が [left, mid]の範囲内にある場合
if nums[left] <= target < nums[mid]:
right = mid - 1
# それ以外([left, mid]の範囲を捨てる)
else:
left = mid + 1
# [mid, right]がソート済み配列の場合
else:
# target が [mid,right]の範囲内にある場合
if nums[mid] < target <= nums[right]:
left = mid + 1
# それ以外([mid,right]の範囲を捨てる)
else:
right = mid - 1
NOT_FOUND = -1
return NOT_FOUND


# @lc code=end
12 changes: 12 additions & 0 deletions README_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,25 @@ Python

# 自分の解法

## step1

```python

```

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

## step2

```python

```

## step3

## step4 (FB)

# 別解・模範解答

- 時間計算量:`O(n)`
Expand Down