0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Go】Two Pointersのサンプルコードを書いてみた

0
Posted at

概要

  • GoでTwo Pointers(二つのポインタ)を実装
  • Two Pointersは配列や文字列を2つのポインタで走査する技法。左右のポインタを巧みに操作することで、効率的に問題を解く。

特徴

  • 効率的処理: 時間計算量O(n)で線形探索可能
  • 空間計算量O(1): 追加のメモリをほとんど使わない
  • 多様なパターン: 対向ポインタ、同方向ポインタ、固定+可変など
  • 幅広い応用: ソート済み配列、文字列、部分配列問題に適用可能
  • Sliding Windowとの関連: Sliding Windowは可変長Two Pointersの応用

Two Pointersとは

基本的な考え方

配列や文字列に対して2つのインデックス(ポインタ)を使い、それぞれを適切に移動させながら問題を解く手法。

基本パターン:

1. 対向ポインタ(Opposite Direction):
   配列: [1, 2, 3, 4, 5]
          ↑           ↑
        left       right
   → 左右から中央に向かって移動

2. 同方向ポインタ(Same Direction):
   配列: [1, 2, 3, 4, 5]
          ↑     ↑
        slow  fast
   → 同じ方向に異なる速度で移動

3. 固定+可変ポインタ:
   配列: [1, 2, 3, 4, 5]
          ↑     ↑
          i   left/right
   → 一方を固定、もう一方を可変

なぜ効率的か?

総当たり(Brute Force):

  • すべての組み合わせを調べる
  • 時間計算量: O(n²)
// 総当たり: 2重ループ
for i := 0; i < n; i++ {
    for j := i+1; j < n; j++ {
        if arr[i] + arr[j] == target {
            return []int{i, j}
        }
    }
}

Two Pointers:

  • ポインタを賢く移動させて探索範囲を絞る
  • 時間計算量: O(n)
// Two Pointers: 1回のループ
left, right := 0, n-1
for left < right {
    sum := arr[left] + arr[right]
    if sum == target {
        return []int{left, right}
    } else if sum < target {
        left++  // 合計を増やす
    } else {
        right-- // 合計を減らす
    }
}

性能差:

配列サイズ n=10,000
総当たり:     約5000万回の計算 (10,000 × 10,000 / 2)
Two Pointers: 約1万回の計算 (10,000)
→ 5000倍高速!

サンプルコード

1. Two Sum(ソート済み配列)

最も基本的なパターン。昇順配列から合計が目標値になる2要素を見つける。

package main

import "fmt"

// TwoSumSorted は昇順配列から合計がtargetになる2要素のインデックスを返す
func TwoSumSorted(nums []int, target int) []int {
	left := 0
	right := len(nums) - 1

	for left < right {
		sum := nums[left] + nums[right]
		if sum == target {
			return []int{left, right}
		} else if sum < target {
			left++ // 合計を増やすために左端を右に移動
		} else {
			right-- // 合計を減らすために右端を左に移動
		}
	}

	return []int{} // 見つからない場合
}

使用例:

nums := []int{2, 7, 11, 15}
target := 9
result := TwoSumSorted(nums, target)
// 結果: [0, 1] (nums[0]=2, nums[1]=7, 2+7=9)

2. 重複の削除(ソート済み配列)

ソート済み配列から重複を削除し、ユニーク要素の数を返す。

// RemoveDuplicates はソート済み配列から重複を削除
func RemoveDuplicates(nums []int) int {
	if len(nums) == 0 {
		return 0
	}

	slow := 0 // ユニーク要素を配置する位置

	for fast := 1; fast < len(nums); fast++ {
		if nums[fast] != nums[slow] {
			slow++
			nums[slow] = nums[fast]
		}
	}

	return slow + 1
}

動作イメージ:

配列: [1, 1, 2, 2, 3]
       ↑
     slow,fast

fast=1: nums[1]=1 = nums[0]=1 → スキップ
fast=2: nums[2]=2 ≠ nums[0]=1 → slow++, nums[1]=2
配列: [1, 2, 2, 2, 3]
          ↑  ↑
        slow fast

fast=3: nums[3]=2 = nums[1]=2 → スキップ
fast=4: nums[4]=3 ≠ nums[1]=2 → slow++, nums[2]=3
配列: [1, 2, 3, 2, 3]
             ↑     ↑
           slow   fast

結果: ユニーク要素数 = 3, 配列 = [1, 2, 3]

3. 文字列の反転

文字列を反転する(Two Pointers)。

// ReverseString は文字列を反転する
func ReverseString(s []byte) {
	left := 0
	right := len(s) - 1

	for left < right {
		s[left], s[right] = s[right], s[left]
		left++
		right--
	}
}

使用例:

s := []byte("hello")
ReverseString(s)
// 結果: "olleh"

4. 回文判定

文字列が回文(前から読んでも後ろから読んでも同じ)かどうかを判定。

// IsPalindrome は文字列が回文かどうかを判定
func IsPalindrome(s string) bool {
	left := 0
	right := len(s) - 1

	for left < right {
		if s[left] != s[right] {
			return false
		}
		left++
		right--
	}

	return true
}

使用例:

IsPalindrome("racecar") // true
IsPalindrome("hello")   // false

5. Three Sum(3要素の合計が0)

配列から合計が0になる3要素の組み合わせをすべて返す。

// ThreeSum は配列から合計が0になる3要素の組み合わせをすべて返す
func ThreeSum(nums []int) [][]int {
	result := [][]int{}
	n := len(nums)

	// まずソート
	sort.Ints(nums)

	for i := 0; i < n-2; i++ {
		// 重複をスキップ
		if i > 0 && nums[i] == nums[i-1] {
			continue
		}

		// Two Pointersで残り2要素を探す
		left := i + 1
		right := n - 1
		target := -nums[i]

		for left < right {
			sum := nums[left] + nums[right]

			if sum == target {
				result = append(result, []int{nums[i], nums[left], nums[right]})

				// 重複をスキップ
				for left < right && nums[left] == nums[left+1] {
					left++
				}
				for left < right && nums[right] == nums[right-1] {
					right--
				}

				left++
				right--
			} else if sum < target {
				left++
			} else {
				right--
			}
		}
	}

	return result
}

使用例:

nums := []int{-1, 0, 1, 2, -1, -4}
result := ThreeSum(nums)
// 結果: [[-1, -1, 2], [-1, 0, 1]]

アルゴリズムの流れ:

ソート後: [-4, -1, -1, 0, 1, 2]

i=0: nums[0]=-4 を固定、残り2要素の合計目標値=4
     Two Pointersで探索 → 見つからない

i=1: nums[1]=-1 を固定、残り2要素の合計目標値=1
     left=2, right=5
     -1 + 2 = 1 ✓ → [-1, -1, 2]
     left=3, right=4
     0 + 1 = 1 ✓ → [-1, 0, 1]

i=2: nums[2]=-1 は重複のためスキップ

6. Container With Most Water

最大の水を貯められる2つの垂直線を見つける。

// ContainerWithMostWater は最大の水を貯められる2つの垂直線を見つける
func ContainerWithMostWater(height []int) int {
	maxArea := 0
	left := 0
	right := len(height) - 1

	for left < right {
		// 面積 = 幅 × 高さ(低い方)
		width := right - left
		h := minInt(height[left], height[right])
		area := width * h

		if area > maxArea {
			maxArea = area
		}

		// 低い方のポインタを移動
		if height[left] < height[right] {
			left++
		} else {
			right--
		}
	}

	return maxArea
}

func minInt(a, b int) int {
	if a < b {
		return a
	}
	return b
}

なぜ低い方を移動するのか:

高さ配列: [1, 8, 6, 2, 5, 4, 8, 3, 7]
           ↑                       ↑
         left(1)              right(7)

現在の面積 = 8 × min(1, 7) = 8

もし right を移動すると:
  → 幅が減る(確実にマイナス)
  → 高さは max(1, next) = 1 (変わらないか悪化)
  → 面積は必ず悪化

もし left を移動すると:
  → 幅が減る(マイナス)
  → 高さは max(next, 7) (改善の可能性あり)
  → 面積が改善する可能性あり

結論: 低い方を移動して改善を狙う

7. Move Zeroes(0を末尾に移動)

配列内の0をすべて末尾に移動する(相対順序は保持)。

// MoveZeroes は配列内の0をすべて末尾に移動する
func MoveZeroes(nums []int) {
	slow := 0 // 非0要素を配置する位置

	// すべての非0要素を左に詰める
	for fast := 0; fast < len(nums); fast++ {
		if nums[fast] != 0 {
			nums[slow] = nums[fast]
			slow++
		}
	}

	// 残りを0で埋める
	for i := slow; i < len(nums); i++ {
		nums[i] = 0
	}
}

使用例:

nums := []int{0, 1, 0, 3, 12}
MoveZeroes(nums)
// 結果: [1, 3, 12, 0, 0]

動作原理

対向ポインタの動き

配列 [2, 7, 11, 15]、目標値 target=9 の例:

初期状態:
配列: [2, 7, 11, 15]
       ↑           ↑
     left(0)    right(3)

Step 1: sum = 2 + 15 = 17 > 9
        → 合計が大きい → right--

配列: [2, 7, 11, 15]
       ↑       ↑
     left(0) right(2)

Step 2: sum = 2 + 11 = 13 > 9
        → 合計が大きい → right--

配列: [2, 7, 11, 15]
       ↑   ↑
     left(0) right(1)

Step 3: sum = 2 + 7 = 9 ✓
        → 見つかりました!

結果: インデックス [0, 1]

同方向ポインタの動き

配列 [1, 1, 2, 2, 3] の重複削除:

初期状態:
配列: [1, 1, 2, 2, 3]
       ↑
     slow=0, fast=0

fast=1: nums[1]=1 = nums[0]=1
        → 重複のためスキップ

fast=2: nums[2]=2 ≠ nums[0]=1
        → slow++, nums[1]=2
配列: [1, 2, 2, 2, 3]
          ↑  ↑
        slow fast

fast=3: nums[3]=2 = nums[1]=2
        → 重複のためスキップ

fast=4: nums[4]=3 ≠ nums[1]=2
        → slow++, nums[2]=3
配列: [1, 2, 3, 2, 3]
             ↑     ↑
           slow   fast

結果: ユニーク要素数 = 3

3つのパターン

パターン1: 対向ポインタ(Opposite Direction)

左右から中央に向かって移動。

特徴:

  • 左端(left)と右端(right)からスタート
  • 条件に応じてどちらかを移動
  • ソート済み配列に有効

典型的な問題:

  • Two Sum(ソート済み配列)
  • 回文判定
  • Container With Most Water

実装パターン:

left := 0
right := len(arr) - 1

for left < right {
    if 条件を満たす {
        // 処理
        break または return
    } else if 値が小さい {
        left++
    } else {
        right--
    }
}

パターン2: 同方向ポインタ(Same Direction)

同じ方向に異なる速度で移動。

特徴:

  • slowとfastポインタを使用
  • fastが先行、slowが追従
  • インプレース操作に有効

典型的な問題:

  • 重複の削除
  • 特定要素の削除
  • Move Zeroes

実装パターン:

slow := 0

for fast := 0; fast < len(arr); fast++ {
    if 条件を満たす {
        arr[slow] = arr[fast]
        slow++
    }
}

パターン3: 固定+可変ポインタ

一方を固定、もう一方を可変にして探索。

特徴:

  • 外側ループで一方を固定
  • 内側でTwo Pointersを使用
  • Three Sumなどの複雑な問題に有効

典型的な問題:

  • Three Sum
  • Four Sum
  • Closest Sum

実装パターン:

for i := 0; i < n; i++ {
    // i を固定
    left := i + 1
    right := n - 1

    for left < right {
        // Two Pointers探索
        if 条件を満たす {
            // 処理
        } else if 値が小さい {
            left++
        } else {
            right--
        }
    }
}

計算量

時間計算量

実装方法 時間計算量 説明
総当たり(2要素) O(n²) すべての組み合わせを調べる
Two Pointers(2要素) O(n) 各要素を最大1回処理
総当たり(3要素) O(n³) 3重ループ
Three Sum (固定+Two Pointers) O(n²) 外側O(n)×内側O(n)

空間計算量

実装方法 空間計算量 説明
基本Two Pointers O(1) ポインタ2つのみ
インプレース操作 O(1) 追加メモリ不要
結果を保存 O(k) k個の結果を保存

なぜO(n)なのか?

対向ポインタの場合:

left := 0
right := n - 1

for left < right {  // 最大n回
    // 処理
    left++ または right--
}

重要なポイント:

  • leftは0からn-1まで最大n回増加
  • rightはn-1から0まで最大n回減少
  • 合計でも最大n回のループ
  • したがって時間計算量はO(n)
各ステップでleftまたはrightのどちらかが1つ近づく:
初期: left=0, right=n-1, 差=n-1
...
終了: left=right, 差=0

→ 最大n回のステップで終了

使いどころ

向いている場面

  1. ソート済み配列の探索
    • Two Sum、Three Sum
    • 特定の合計値を見つける
  2. インプレース操作
    • 重複削除
    • 要素の移動・整理
  3. 回文や対称性の判定
    • 回文判定
    • 対称配列のチェック
  4. 最適化問題
    • Container With Most Water
    • 最小・最大値の探索

実例1: Two Sum(未ソート配列)

ハッシュマップとTwo Pointersを組み合わせる。

// TwoSum は未ソート配列から合計がtargetになる2要素のインデックスを返す
func TwoSum(nums []int, target int) []int {
	numMap := make(map[int]int)

	for i, num := range nums {
		complement := target - num
		if index, ok := numMap[complement]; ok {
			return []int{index, i}
		}
		numMap[num] = i
	}

	return []int{}
}

// 使用例
nums := []int{2, 7, 11, 15}
target := 9
result := TwoSum(nums, target)
// 結果: [0, 1]

実例2: 配列の分割

配列を特定の条件で2つに分割する(Dutch National Flag問題)。

// PartitionArray は配列をpivot未満とpivot以上に分割
func PartitionArray(nums []int, pivot int) int {
	left := 0
	right := len(nums) - 1

	for left <= right {
		if nums[left] < pivot {
			left++
		} else {
			nums[left], nums[right] = nums[right], nums[left]
			right--
		}
	}

	return left // pivot以上の要素の開始位置
}

// 使用例
nums := []int{3, 5, 2, 1, 4}
pivot := 3
pos := PartitionArray(nums, pivot)
// 結果: nums=[2, 1, 3, 5, 4], pos=2
//       [2, 1] < 3, [3, 5, 4] >= 3

実例3: 最短距離のペア

ソート済み配列から差が最小の2要素を見つける。

// SmallestDifferencePair はソート済み配列から差が最小の2要素を返す
func SmallestDifferencePair(arr1, arr2 []int) []int {
	i, j := 0, 0
	minDiff := int(^uint(0) >> 1) // 最大int値
	result := []int{0, 0}

	for i < len(arr1) && j < len(arr2) {
		diff := abs(arr1[i] - arr2[j])

		if diff < minDiff {
			minDiff = diff
			result[0] = arr1[i]
			result[1] = arr2[j]
		}

		if arr1[i] < arr2[j] {
			i++
		} else {
			j++
		}
	}

	return result
}

func abs(x int) int {
	if x < 0 {
		return -x
	}
	return x
}

関連する問題パターン

1. 重複のない最長部分文字列

Two Pointersで重複のない最長部分文字列の長さを求める。

// LengthOfLongestSubstring は重複のない最長部分文字列の長さ
func LengthOfLongestSubstring(s string) int {
	charIndex := make(map[byte]int)
	maxLen := 0
	left := 0

	for right := 0; right < len(s); right++ {
		// 重複が見つかったら左端を移動
		if index, exists := charIndex[s[right]]; exists && index >= left {
			left = index + 1
		}

		charIndex[s[right]] = right
		currentLen := right - left + 1
		if currentLen > maxLen {
			maxLen = currentLen
		}
	}

	return maxLen
}

// 例: "abcabcbb" → 3 ("abc")
// 例: "pwwkew" → 3 ("wke")

2. Sort Colors(3色ソート)

0, 1, 2の3種類の要素を1回のパスでソートする。

// SortColors は配列を1回のパスで[0,1,2]にソート
func SortColors(nums []int) {
	low, mid, high := 0, 0, len(nums)-1

	for mid <= high {
		switch nums[mid] {
		case 0:
			nums[low], nums[mid] = nums[mid], nums[low]
			low++
			mid++
		case 1:
			mid++
		case 2:
			nums[mid], nums[high] = nums[high], nums[mid]
			high--
		}
	}
}

// 使用例
nums := []int{2, 0, 2, 1, 1, 0}
SortColors(nums)
// 結果: [0, 0, 1, 1, 2, 2]

3. Trapping Rain Water(雨水トラップ)

高さ配列から雨水がどれだけ溜まるかを計算。

// TrapRainWater は雨水が溜まる量を計算
func TrapRainWater(height []int) int {
	if len(height) == 0 {
		return 0
	}

	left, right := 0, len(height)-1
	leftMax, rightMax := 0, 0
	water := 0

	for left < right {
		if height[left] < height[right] {
			if height[left] >= leftMax {
				leftMax = height[left]
			} else {
				water += leftMax - height[left]
			}
			left++
		} else {
			if height[right] >= rightMax {
				rightMax = height[right]
			} else {
				water += rightMax - height[right]
			}
			right--
		}
	}

	return water
}

// 例: [0,1,0,2,1,0,1,3,2,1,2,1]
// 結果: 6

Sliding Windowとの関係

Sliding WindowはTwo Pointersの応用パターンの一つ。

違い

特徴 Two Pointers Sliding Window
目的 2要素の組み合わせ探索 連続部分配列の処理
ポインタの動き 独立に移動 ウィンドウとして移動
典型的な用途 ソート済み配列探索 部分配列の最大/最小
実装 left/rightを条件で移動 固定長または可変長ウィンドウ

Sliding Windowの例

// MaxSumSubarray は固定長kの部分配列の最大合計(Sliding Window)
func MaxSumSubarray(arr []int, k int) int {
	if len(arr) < k {
		return 0
	}

	// 最初のウィンドウ
	windowSum := 0
	for i := 0; i < k; i++ {
		windowSum += arr[i]
	}

	maxSum := windowSum

	// ウィンドウをスライド
	for i := k; i < len(arr); i++ {
		windowSum = windowSum + arr[i] - arr[i-k]
		if windowSum > maxSum {
			maxSum = windowSum
		}
	}

	return maxSum
}

最適化テクニック

1. 早期終了

条件を満たしたら即座に返す。

// HasPairWithSum は合計がtargetのペアが存在するか
func HasPairWithSum(nums []int, target int) bool {
	left, right := 0, len(nums)-1

	for left < right {
		sum := nums[left] + nums[right]
		if sum == target {
			return true // 早期終了
		} else if sum < target {
			left++
		} else {
			right--
		}
	}

	return false
}

2. 重複のスキップ

重複する要素をスキップして計算量を削減。

// Three Sumで重複をスキップ
for i := 0; i < n-2; i++ {
	// 同じ値をスキップ
	if i > 0 && nums[i] == nums[i-1] {
		continue
	}
	// ...
}

3. ソート済み配列の活用

配列がソート済みであることを活用して効率化。

// ソート済み配列で目標値を超えたら終了
for left < right {
	sum := nums[left] + nums[right]
	if sum > target {
		right--
	} else if sum < target {
		left++
	} else {
		return []int{left, right}
	}
}

デバッグとテスト

テストケース

func TestTwoPointers() {
	// Two Sum
	nums1 := []int{2, 7, 11, 15}
	if !reflect.DeepEqual(TwoSumSorted(nums1, 9), []int{0, 1}) {
		t.Error("TwoSumSorted failed")
	}

	// 重複削除
	nums2 := []int{1, 1, 2}
	if RemoveDuplicates(nums2) != 2 {
		t.Error("RemoveDuplicates failed")
	}

	// 回文判定
	if !IsPalindrome("racecar") {
		t.Error("IsPalindrome failed")
	}

	// エッジケース
	if len(TwoSumSorted([]int{}, 0)) != 0 {
		t.Error("Empty array should return empty")
	}

	if IsPalindrome("") {
		t.Error("Empty string should not be palindrome")
	}
}

デバッグのポイント

  1. ポインタの範囲チェック

    // 範囲外アクセスに注意
    for left < right {  // <= ではなく <
        // ...
    }
    
    
  2. 無限ループの回避

    // 必ずポインタが進むことを確認
    if condition {
        left++  // または right--
    } else {
        right-- // または left++
    }
    
    
  3. エッジケースのテスト

    • 空配列
    • 要素1つ
    • すべて同じ要素
    • 解が存在しない

まとめ

メリット

  • 時間計算量O(n)で効率的
  • 総当たりO(n²)を大幅に改善
  • 空間計算量O(1)で省メモリ
  • 実装がシンプルで理解しやすい
  • 幅広い問題に応用可能

デメリット

  • ソート済み配列に限定される場合が多い
  • 未ソート配列では事前ソートが必要(O(n log n))
  • 3要素以上の組み合わせは複雑化

使うべき時

  • ソート済み配列の探索: Two Sum、Three Sum
  • インプレース操作: 重複削除、要素移動
  • 対称性の判定: 回文、対称配列
  • 最適化問題: 最大面積、最小差
  • パフォーマンス重視: O(n²)をO(n)に改善したい

選択基準

問題の種類 アプローチ 時間計算量
ソート済み配列の2要素探索 対向Two Pointers O(n)
未ソート配列の2要素探索 ハッシュマップ O(n)
重複削除・要素移動 同方向Two Pointers O(n)
3要素以上の組み合わせ 固定+Two Pointers O(n²)
連続部分配列の処理 Sliding Window O(n)

Two Pointersは配列や文字列を効率的に処理する強力な技法。2つのポインタを巧みに操作することで、総当たりでは不可能な性能を実現する。ソート済み配列の探索、インプレース操作、最適化問題など、実用的な応用も多い重要アルゴリズム。Sliding Windowの基礎としても重要。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?