cv.cm

コーディング · アルゴリズム

二本指法(ツーポインタ)とスライディングウィンドウをわかりやすく解説

約9分で読めます

結論

二本指法(ツーポインタ)は、配列や文字列の上で2つのインデックスを動かすテクニックです。通常は両端から互いに向かって動かすか、同じ向きに異なる速さで動かすことで、各要素を一定回数しか訪れないようにします。スライディングウィンドウは同じ向きに動かす派生で、左右のポインタの間の連続した範囲を保ち、ウィンドウの拡大・縮小に合わせて途中の値を更新します。どちらも、ペアや部分配列に対する O(n²) の総当たり探索を O(n) に減らせることがよくあります。

これらのテクニックが解決する問題

配列や文字列の問題の多くは、ペアや連続した範囲について問われます。合計が目標値になる2つの数、文字が重複しない最長の部分文字列、合計が k 以上になる最短の部分配列などです。すべてのペアやすべての範囲を調べると、コストは O(n²) 以上になります。二本指法では、ソート済みであることや、ウィンドウの更新が小さくて済むことといったデータの性質を利用して、多くの候補を一度に除外し、これを避けます。

パターンポインタの動き典型的な問題
両端からleft は 0 から、right は末尾から始まり、互いに近づくソート済み配列でのペアの和、回文判定、最も多く水が入る容器
速いポインタと遅いポインタ両方とも先頭から始まり、一方は毎ステップ、もう一方は時々だけ進む重複のインプレース削除、パーティション、連結リストのサイクル検出
スライディングウィンドウright がウィンドウを広げ、left が縮める条件を満たす最長または最短の部分文字列・部分配列

両端のポインタ:ソート済み配列でのペアの和

ソート済みリスト [1, 3, 4, 6, 8, 11] から、和が 10 になる2つの数を探します。最小値と最大値から始めます。和が小さすぎるなら、増やす方法は left を右に動かすことだけです。大きすぎるなら right を左に動かします。

ステップleft(値)right(値)和操作
10 (1)5 (11)12大きすぎる、right -= 1
20 (1)4 (8)9小さすぎる、left += 1
31 (3)4 (8)11大きすぎる、right -= 1
41 (3)3 (6)9小さすぎる、left += 1
52 (4)3 (6)10発見:4 + 6
def pair_with_sum(nums, target):
    left, right = 0, len(nums) - 1
    while left < right:
        s = nums[left] + nums[right]
        if s == target:
            return left, right
        if s < target:
            left += 1
        else:
            right -= 1
    return None
print(pair_with_sum([1, 3, 4, 6, 8, 11], 10))   # (2, 3)
print(pair_with_sum([1, 3, 4, 6, 8, 11], 2))    # None

各ステップでポインタが1つ内側に動くため、ステップ数は最大でも n − 1 で、時間は O(n)、空間は O(1) です。同じ形で、文字列が回文かどうかの判定や配列のインプレース反転ができます。また、外側にループを重ねれば、3Sum を O(n³) ではなく O(n²) で解けます。

速いポインタと遅いポインタ:インプレースでの絞り込み

遅いポインタは次に残す要素を置く位置を示し、速いポインタはすべての要素を走査します。これで、追加のメモリなしにソート済みリストから重複を取り除けます。

def dedupe_sorted(nums):
    """Remove duplicates in place; return the new length."""
    if not nums:
        return 0
    slow = 0
    for fast in range(1, len(nums)):
        if nums[fast] != nums[slow]:
            slow += 1
            nums[slow] = nums[fast]
    return slow + 1
a = [1, 1, 2, 3, 3, 3, 5]
k = dedupe_sorted(a)
print(k, a[:k])   # 4 [1, 2, 3, 5]

連結リストでは、速いポインタが1ステップで2ノード、遅いポインタが1ノード進みます。2つが出会えば、リストにサイクルがあります(フロイドのサイクル検出法)。速いポインタが末尾に着いたとき、遅いポインタは中央にいます。

スライディングウィンドウ:重複のない最長の部分文字列

スライディングウィンドウは、範囲 s[left:right + 1] を常に有効な状態に保ちます。right を1文字ずつ前に進め、新しい文字がルールを破ったら、ウィンドウが再び有効になるまで left を進めます。その過程で最良のウィンドウを記録します。

def longest_unique(s):
    last_seen = {}          # char -> last index
    left = best = 0
    for right, ch in enumerate(s):
        if ch in last_seen and last_seen[ch] >= left:
            left = last_seen[ch] + 1   # jump past the repeat
        last_seen[ch] = right
        best = max(best, right - left + 1)
    return best
print(longest_unique("abcabcbb"))   # 3  ("abc")
print(longest_unique("pwwkew"))     # 3  ("wke")
print(longest_unique(""))           # 0

ウィンドウには2種類あります。固定サイズ(k 要素)のウィンドウは、右から入る要素を加え、左から出る要素を引きます。たとえば移動平均の計算です。可変サイズのウィンドウは、上の例のように、条件を満たすように伸び縮みします。

def min_len_subarray(nums, k):
    """Shortest subarray with sum >= k (non-negative nums)."""
    left = total = 0
    best = float("inf")
    for right, x in enumerate(nums):
        total += x
        while total >= k:
            best = min(best, right - left + 1)
            total -= nums[left]
            left += 1
    return 0 if best == float("inf") else best
print(min_len_subarray([2, 3, 1, 2, 4, 3], 7))   # 2  ([4, 3])

時間計算量と空間計算量

問題総当たり二本指法/ウィンドウ追加の空間
ペアの和(ソート済み入力)O(n²)O(n)O(1)
重複の削除(ソート済み)新しいリストを作れば O(n)インプレースで O(n)O(1)
重複しない最長の部分文字列O(n³) または O(n²)O(n)O(k)、k は異なる文字の種類数
和が k 以上になる最短の部分配列O(n²)O(n)O(1)

while ループが入れ子になっていてもスライディングウィンドウが O(n) になる理由は、left と right がそれぞれ全体で最大 n 回しか前に進まないため、総作業量が最大でも 2n ステップだからです。

よくある間違い

  • ソートされていないデータに両端ポインタを使う。left と right のどちらを動かすかという判断が正しいのは、配列がソートされているからこそです。
  • 数値が負になりうるのに、和に基づくスライディングウィンドウを使う。ウィンドウを縮めても和が必ず減るとは限らなくなります。代わりに、累積和とハッシュマップを使ってください。
  • ウィンドウ長の off-by-one。left..right を両端含みで数えると、要素数は right - left + 1 です。
  • left < right で止めるのを忘れる。1つの要素が自分自身とペアになってしまいます。
  • left を後ろに戻す。部分文字列の例では、last_seen[ch] >= left というチェックによって、古い位置に戻ってしまうのを防いでいます。

使うべき場面と、面接でよく出る問題

入力がソート済み(または安価にソートできる)でペアを探すとき、あるいは配列をインプレースで変更したいときは、二本指法を使いましょう。問題文に連続した部分配列や部分文字列とあり、ウィンドウの拡大に伴って予測どおりに変わる条件のもとで最長・最短・最大・個数を求めるときは、スライディングウィンドウを使います。データがソートされておらずペアを探す場合は、たいていハッシュセットのほうが簡単で、これも O(n) です。

  • Two Sum II(ソート済み入力)、3Sum、Container With Most Water、Trapping Rain Water。
  • Valid Palindrome、Reverse String、Move Zeroes、Remove Duplicates from Sorted Array。
  • Longest Substring Without Repeating Characters、Minimum Window Substring、Sliding Window Maximum(deque を使用)。
  • Linked List Cycle、Middle of the Linked List。

面接以外でも、同じ考え方は、ソート済みファイルのマージ、未確認応答パケットのウィンドウを管理するネットワークプロトコル(TCPのスライディングウィンドウ)、直近 N 秒間のイベントを数えるレートリミッター、時系列データの移動平均などに現れます。

よくある質問

二本指法(ツーポインタ)とは何ですか?

配列や文字列を、単純なルールで動く2つのインデックスで走査する手法で、各要素の処理回数を一定に抑えられます。O(n²) のペア探索を O(n) にできることがよくあります。

二本指法とスライディングウィンドウの違いは何ですか?

スライディングウィンドウは二本指法の一種で、2つのインデックスが同じ向きに動き、その間の要素が現在の部分配列または部分文字列になります。両端から動く二本指法は、範囲ではなくペアを見ます。

入れ子のループがあるのに、スライディングウィンドウが O(n) になるのはなぜですか?

内側のループは left を前に進めるだけで、left は全体を通して最大 n 回しか動けません。right の n 回の移動と合わせても、最大 2n ステップです。

二本指法にはソート済みの配列が必要ですか?

両端からのペアの和のパターンには必要です。速い・遅いポインタとスライディングウィンドウには不要ですが、和に基づくウィンドウでは数値が非負である必要があります。

コーディング講座の一覧