Search in Rotated Sorted Array
相異なる整数のリストが昇順に並べられた後、回転されています。つまり、先頭から任意の数の要素(0個の場合もあります)を取り出し、同じ順序のまま末尾に移動しています。たとえば、[2, 5, 8, 11, 15, 19, 23]を4つ分回転すると、[15, 19, 23, 2, 5, 8, 11]になります。回転されたリストnumsと整数targetが与えられます。targetがnumsにある場合は、0から数えたインデックスを返し、ない場合は-1を返してください。計算量はO(log n)としてください。
関数
- numsinteger-array
- 回転された、重複のない整数のソート済みリスト
- targetinteger
- 探す値
- 戻り値integer
- nums 内の target のインデックス。存在しない場合は -1
制約
1 ≤ nums.length ≤ 5000-104 ≤ nums[i], target ≤ 104- すべての
numsの値はそれぞれ異なります。 numsは、あるkだけ回転された昇順リストです(0 ≤ k < nums.length)。k = 0の場合は回転されていません。
例
- 入力
- nums = [15, 19, 23, 2, 5, 8, 11]target = 5
- 出力
- 4
- 説明
- 5 はインデックス 4 にあります。最初の中央の要素であるインデックス 3 には 2 があるため、右半分の
[2, 5, 8, 11]はソート済みで、5 は 2 と 11 の間にあります。次の中央の要素であるインデックス 5 には 8 があります。ソート済みの左部分[5, 8]には 5 があるため、インデックス 4 にたどり着きます。
- 入力
- nums = [40, 50, 60, 70, 10, 20, 30]target = 65
- 出力
- -1
- 説明
- 65は60と70の間に位置しますが、それを保持する要素はありません。最初の中央の要素であるインデックス3の70によって、65はソート済みの左側の部分
[40, 50, 60, 70]の中にあることになります。その範囲はこの部分の中で縮まり、空になるため、関数は-1を返します。
- 入力
- nums = [8, 13, 21, 1, 3, 5]target = 13
- 出力
- 1
- 説明
- 最初の中央の要素であるインデックス 2 には 21 があります。左側の部分
[8, 13, 21]はソート済みで、13 は 8 と 21 の間にあるため、右側の部分全体を除外します。その後、検索によってインデックス 1 にある 13 が見つかります。
提出時に隠しテスト+23件
発展問題
numsに重複が含まれる可能性がある場合、O(log n)を保証できるアルゴリズムはありません。それを証明できますか?1が並んだリストを回転させ、その中に0を1つだけ隠したものを作りましょう。0を探すには、すべての要素を読み取らなければならないようにします。
ヒント
1つずつ開いてください。開くたびに少しずつ答えに近づきます。
任意の中央のインデックスを選び、その両側にある2つの半分を見てみましょう。回転によって、値が最大から最小へと下がる場所が1か所できています。その下がり目は両方の半分に含まれるでしょうか?
少なくとも半分は常にソートされており、
nums[lo]とnums[mid]を比較すれば、どちらの半分かがわかります。ソートされた半分については、targetがその最初の値と最後の値の間にあるかどうかを1ステップで確認できます。targetがまだ含まれている可能性のある部分を、loとhiで囲みます。各ステップで、ソート済みの半分の値の範囲にtargetが含まれていれば、その半分を残します。そうでなければ、もう一方を残します。targetが見つかるか、範囲が空になったら終了します。
解説
回転したソート済みリストは、ソート済みの2つの並びを順につなげたものです。[15, 19, 23]、続いて[2, 5, 8, 11]です。通常の二分探索ではうまくいきません。targetと中央の値を比較しても、targetがどちら側にあるか分からなくなっているためです。解決の鍵は次の事実です。リストをどこで分割しても、2つの半分のうち少なくとも一方は完全にソートされており、ソートされた半分については、1回の比較でtargetがその中にある可能性があるかどうかを判断できます。
すべての要素を走査する
考え方
各インデックスを順番に調べ、値が target と等しい最初のインデックスを返します。一致するものがないままループが終了した場合は、-1 を返します。値は重複しないため、最初の一致が唯一の一致であり、リストが回転しているかどうかにかかわらず、この走査は正しく動作します。
この方法は、問題文で説明されていることをすべて無視しています。リストはソート済みの2つの区間から成っていますが、この走査では最大5000個すべての要素を調べます。一方、二分探索なら比較は約13回で済みます。入力サイズが大きくなるほど差は広がり、要素が100万個なら比較回数は100万回ですが、二分探索なら約20回です。問題が求めているのは O(log n) なので、これは答えではなく、改善の基準となる方法です。
アルゴリズム
- 0 から
n-1までの各インデックスiについて、nums[i]とtargetを比較します。 - それらが等しければ、
iを返します。 - ループの後、
-1を返します。
def search(nums, target):
for i, value in enumerate(nums):
if value == target:
return i
return -1回転点を見つけてから、二分探索する
考え方
回転されたリストは、ソート済みの2つの区間からなり、2つ目の区間は最小値から始まります。そのインデックスをkとします。kがわかれば、あとは通常の二分探索に置き換えられます。nums[k..n-1]はソート済みで、nums[k]からnums[n-1]までの値を含み、nums[0..k-1]もソート済みで、それより大きい値をすべて含みます。targetとnums[k]およびnums[n-1]を1回比較すれば、どちらの区間を探索するかを選べます。
kを見つけるには、値が下がる位置を二分探索します。中央の値と、範囲の最後の値であるnums[hi]を比較します。nums[mid] > nums[hi]なら、midより後のどこかで値が下がっているため、最小値は右側にあります。そこでlo = mid + 1とします。そうでなければ、nums[mid..hi]は下がることなく増加しているため、最小値はmidかその前にあります。midを範囲内に残すため、hi = midとします。loとhiが一致したとき、そのインデックスがkです。
最初の例、[15, 19, 23, 2, 5, 8, 11]でtarget = 5の場合を追ってみましょう。中央の2は11より大きくないので、hiは3になります。次に、19は2より大きいので、loは2になります。続いて、23は2より大きいので、loは3となり、k = 3です。5はnums[3] = 2とnums[6] = 11の間にあるため、インデックス3から6を探索し、二分探索でインデックス4にある5が見つかります。二分探索を2回行うと、計算量は約2 log2 nステップです。
アルゴリズム
lo = 0およびhi = n-1を設定します。lo < hiの間、midを計算します。nums[mid] > nums[hi]の場合はlo = mid + 1を設定し、そうでなければhi = midを設定します。- 最終的なインデックスを
kと呼びます。そこには最小値が格納されています。 nums[k] ≤ target ≤ nums[n-1]の場合はインデックスkからn-1までを検索し、そうでなければインデックス 0 からk-1までを検索します。- その範囲で通常の二分探索を行い、
targetのインデックスを返します。範囲が空になった場合は-1を返します。
def search(nums, target):
n = len(nums)
# 1. Find k, the index of the smallest value, where the second run starts.
lo, hi = 0, n - 1
while lo < hi:
mid = (lo + hi) // 2
if nums[mid] > nums[hi]:
lo = mid + 1 # the drop is right of mid
else:
hi = mid # mid is in the low run: the minimum is at mid or left of it
k = lo
# 2. nums[k..n-1] and nums[0..k-1] are sorted: search the one whose range holds target.
if nums[k] <= target <= nums[n - 1]:
lo, hi = k, n - 1
else:
lo, hi = 0, k - 1
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
if nums[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return -1ソート済みの半分に対して二分探索を1回行う
考え方
回転点がどこにあるかを知る必要はありません。二分探索の基本を守りましょう。targetがリスト内にあるなら、そのインデックスはloとhiの間にあります。中央のインデックスmidを見ます。リスト全体で値が下がるのは一度だけなので、その下がり目はmidを境にした2つの半分のうち、多くても片方にあり、もう一方はソートされています。
比較を1回行って、ソートされている半分を見つけます。nums[lo] ≤ nums[mid]なら、左半分のnums[lo..mid]には下がり目がなく、ソートされています。nums[mid]がtargetではないとすでに分かっているので、targetがその半分にあるのはnums[lo] ≤ target < nums[mid]の場合だけです。そうならhi = mid - 1に設定します。そうでなければ、targetはもう一方の半分にしかないので、lo = mid + 1に設定します。nums[lo] > nums[mid]の場合、下がり目は左側にあり、右半分のnums[mid..hi]はソートされています。対称的な条件nums[mid] < target ≤ nums[hi]で判定できます。ソートされていない半分について直接考えることはありません。ソートされた半分にtargetがない場合に限り、もう一方にあると判断できます。
最初の例、[15, 19, 23, 2, 5, 8, 11]でtarget = 5の場合をたどってみましょう。範囲0から6の中央は3で、値は2です。15は2より大きいため、右半分の[2, 5, 8, 11]はソートされており、5はその中にあるので、loを4にします。範囲4から6の中央は5で、値は8です。ここでnums[4] = 5 ≤ 8なので、左半分の[5, 8]はソートされており、5を含んでいます。そこでhiを4にします。インデックス4の値は5です。4を返します。
通常の二分探索と同様に、各ステップで範囲が半分になるため、ループの実行回数は最大でもおよそlog2(n) + 1回です。要素数が5000個なら13ステップで、追加のメモリはインデックス2つ分です。
アルゴリズム
lo = 0とhi = n-1を設定します。lo ≤ hiの間、midを計算します。nums[mid]がtargetと等しければ、midを返します。nums[lo] ≤ nums[mid]の場合、左半分はソートされています。nums[lo] ≤ target < nums[mid]ならhi = mid - 1を設定し、そうでなければlo = mid + 1を設定します。- そうでなければ、右半分がソートされています。
nums[mid] < target ≤ nums[hi]ならlo = mid + 1を設定し、そうでなければhi = mid - 1を設定します。 - ループが終了したら、
-1を返します。
def search(nums, target):
lo, hi = 0, len(nums) - 1 # target, if present, sits in nums[lo..hi]
while lo <= hi:
mid = (lo + hi) // 2
if nums[mid] == target:
return mid
if nums[lo] <= nums[mid]:
# nums[lo..mid] is sorted: target is in it only if it fits its range
if nums[lo] <= target < nums[mid]:
hi = mid - 1
else:
lo = mid + 1
else:
# nums[mid..hi] is sorted
if nums[mid] < target <= nums[hi]:
lo = mid + 1
else:
hi = mid - 1
return -1
落とし穴と境界ケース
1回のパスで行う探索は短く、バグのほとんどは比較演算子にあります。
nums[lo] < nums[mid]と書き、≤としない。要素が2つ残った場合、midはloと等しく、左半分は1つの要素からなるため、ソート済みです。この厳密な条件では、[9, 4]とtarget = 4のとき、[9, 4]をソート済みの右半分として扱い、9から4までの範囲外で4を探して、-1を返します。- 通常の二分探索のように、最初に
targetとnums[mid]を比較する。[15, 19, 23, 2, 5, 8, 11]でtarget = 19の場合、中央の値2は19より小さいため、探索は右に進み、インデックス1を見ることはありません。 - ソート済みの半分の片端だけを調べる。
[40, 50, 60, 70, 80, 10, 20]でtarget = 80の場合、中央の値は70で、左半分の[40, 50, 60, 70]はソート済みです。target ≥ nums[lo]だけを確認すると、80は40より大きいため探索は左に進みますが、80は70よりも大きいため、実際には右半分にあります。両端を確認してください。 - 2段階の方法で、回転していないケースを忘れる。
k = 0の場合、2回目の探索範囲は空で、その範囲は0から-1です。符号付きインデックスなら問題ありませんが、符号なしインデックス(Rust のusize)ではk - 1がアンダーフローします。そのため、Rust のコードでは半開区間を使います。 - Lua と R で位置そのものを返してしまう。これらのリストは1から始まるため、返す前に1を引いてください。
よくある質問4
回転されたソート済み配列を検索する時間計算量はどれくらいですか?
O(log n)時間、追加領域はO(1)です。各ステップで現在の範囲の半分を残すため、通常の二分探索と同じく、要素数が5000のリストでも必要なステップ数は最大13です。最初に回転点を見つける2段階の方法もO(log n)で、ステップ数は約2倍です。
回転された配列のどちらの半分がソートされているか、どうすればわかりますか?
nums[lo] と nums[mid] を比較します。値が下がるのは、リスト全体で一度だけです。nums[lo] ≤ nums[mid] なら、その下がりは lo と mid の間にはないため、左半分はソート済みです。そうでなければ、その下がりは左半分にあります。つまり、mid から hi までの右半分には下がりがなく、ソート済みです。
配列に重複する要素が含まれている場合でも、アルゴリズムは動作しますか?
記述どおりではありません。[1, 0, 1, 1, 1]では、nums[lo]、nums[mid]、nums[hi]はすべて1なので、どちらの半分もソート済みだと判断できません。一般的な対処法は、nums[lo]、nums[mid]、nums[hi]が等しい場合にloを1つ進めることです。これにより正しさは保たれますが、最悪の場合の計算量はO(n)になります。
最初に回転点を見つけるべきですか、それとも1回のパスで検索すべきですか?
どちらも O(log n) で実行されます。最初に最小値のインデックスを見つける方法では、問題を2つの通常の二分探索に分割するため、それぞれの部分で、すでに信頼できるコードを再利用できます。1回の走査で行う探索も同じ処理を1つのループで行い、手順が少なく、ほとんどの面接官が期待する方法です。
似た問題
同じ考え方を使う問題です。2〜3問解くとパターンが身につきます。
Python
def search(nums, target):
# ここにコードを書いてくださいケース1
ケース2
ケース3
入力
nums = [15, 19, 23, 2, 5, 8, 11] target = 5
期待値
4