カバーするコンセプトは

LinkedList

Greedy + Backtracking

Permutations

問題リンク: 46. Permutations

この問題は**バックトラッキング(backtracking)**というアルゴリズムが最も適しています

バックトラッキングとは?

「試行」と「戻る」を繰り返しながら、解の候補を探索する手法です。途中で解にならないと分かった場合は探索を打ち切り(枝刈り)、効率的に解を求めます

アルゴリズムの流れ

Permutations via backtracking diagram

コード

from typing import List


class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        result = []

        def backtrack(path, used):
            # パスが完成したら結果に追加
            if len(path) == len(used):
                result.append(path.copy()) # リストはポインタとして渡されるから
                return

            for i in range(len(nums)):
                if used[i]:
                    continue

                # 選択
                used[i] = True
                path.append(nums[i])

                # 探索:次の要素を選ぶ
                backtrack(path, used)

                # 戻る:選択を取り消す(backtrack)
                path.pop()
                used[i] = False

        backtrack([], [False] * len(nums))
        return result
from typing import List


class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        result = []

        def backtrack(path, used):
            # パスが完成したら結果に追加
            if len(path) == len(used):
                result.append(path.copy()) # リストはポインタとして渡されるから
                return

            for i in range(len(nums)):
                if used[i]:
                    continue

                # 選択
                used[i] = True
                path.append(nums[i])

                # 探索:次の要素を選ぶ
                backtrack(path, used)

                # 戻る:選択を取り消す(backtrack)
                path.pop()
                used[i] = False

        backtrack([], [False] * len(nums))
        return result

計算量

時間計算量: \(O(n! × n)\) 順列の数が \(n!\) 通り 各順列をコピーするのに \(O(n)\) 時間

空間計算量: O(n) 再帰のスタック深さが最大 \(n\) used配列やpathのサイズも \(O(n)\)

ポイント

  1. 小さな入力から試す: [1] → [1,2] → [1,2,3] と順にデバッグ
  2. 再帰の呼び出しを図に描くと理解しやすい
  3. 「選ぶ→進む→戻す」の3ステップを必ず守る。関数呼び出しがスタックになっていることと、実装のイメージを結びつける

Subsets

問題リンク: 78. Subsets

考え方

図を書いてみると

  • 探索範囲の値よりも後の要素をnumsから見れば良さそう
  • pop()による戻る操作は必要なさそうで、進むだけで良さそう
  • 木構造の探索

であることがわかる

78_diagram

コード

from typing import List


class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        result = []

        def backtrack(start_idx, path):
            result.append(path.copy())  # ポインタではなくオブジェクトを渡すため

            for i in range(start_idx, len(nums)):
                backtrack(i + 1, path + [nums[i]])

        backtrack(0, [])
        return result

from typing import List


class Solution:
    def subsets(self, nums: List[int]) -> List[List[int]]:
        result = []

        def backtrack(start_idx, path):
            result.append(path.copy())  # ポインタではなくオブジェクトを渡すため

            for i in range(start_idx, len(nums)):
                backtrack(i + 1, path + [nums[i]])

        backtrack(0, [])
        return result

解けるようになるポイント

  • 最初は図を描いて理解する
  • 木構造の探索と回帰での実装を手に馴染ませる

Combination Sum

問題リンク: 39. Combination Sum

解き方

図を描いてみると78. Subsets同様に根と葉のセットを再帰的に探索することで結果が得られそうと考える

39_diagram

コード

from typing import List


class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        result = []

        def backtrace(start_idx: int, current_nums: List[int], current_sum: int):
            if current_sum == target:
                result.append(current_nums)
                return
            elif current_sum > target:
                return

            for i in range(start_idx, len(candidates)):
                backtrace(
                    i, current_nums + [candidates[i]], current_sum + candidates[i]
                )

        backtrace(0, [], 0)
        return result

from typing import List


class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        result = []

        def backtrace(start_idx: int, current_nums: List[int], current_sum: int):
            if current_sum == target:
                result.append(current_nums)
                return
            elif current_sum > target:
                return

            for i in range(start_idx, len(candidates)):
                backtrace(
                    i, current_nums + [candidates[i]], current_sum + candidates[i]
                )

        backtrace(0, [], 0)
        return result

解くためのポイント

  • 図を描いて直感を得る
  • for文の外でreturnする。それ以上枝を伸ばす意味のない状況になったら早期に抜ける
  • [2,2,3], [3,2,2]などの重複を避けるために自身以降のみの探索をする(start_idxの部分)

ノート

  • current_nums + [candidates[i]]では毎回コピーが発生するので、pop()で毎回掃除をしつつ一つのオブジェクトを保持しながら探索するやり方もできるように

Generate Parentheses

問題リンク:

Binary Search

1011. Capacity To Ship Packages Within D Days

考え方

まず、二分探索でcapacityを探索する問題だと気づいた。 これはcapacityにある値以上では常に解けるが、その値未満では常に解けないという単調性があることを感じたから。 その後、この直感が正しいかを簡単にいくつかのケースで脳内で考えおそらく合っていると考えた。 次に、二分探索内で考えるべき問題の"そのcapacitydays 以内に解けるか"をどのように判別するかを考える必要がある。 これに関しては貪欲法が良いだろう。weightsから任意の数の組み合わせに分けて、そのどれの合計もcapacityを超えないといけないので、これを確かめるために全探索を考えるかもしれないが、今回は"並んでいる順番に処理する"という制約があるので、少なくとも全探索よりはいい方法があるだろうと考える。 結果的にはできるだけ前から詰めていって、その結果がdays以下になる貪欲法でいいと判断した。 ただ、貪欲法よりもっといい組み合わせが存在しないという証明は正直出来ない(TODO)

コード

はじめに書いたコードはtime exceededになってしまった。

from typing import List


class Solution:
    def shipWithinDays(self, weights: List[int], days: int) -> int:
        min_capacity = max(weights)
        max_capacity = sum(weights)

        def canCompleteWithin(weights: List[int], days: int, capacity: int) -> bool:
            required_days = 1
            current_weight = 0

            for w in weights:
                if current_weight >= capacity:
                    required_days += 1
                    current_weight = w
                    continue
                current_weight += w

            return required_days <= days

        left = min_capacity
        right = max_capacity
        while left < right:
            mid = (left + right + 1) // 2
            if canCompleteWithin(weights, days, mid):
                right = mid  # midで運べるならmid自身を残す
            else:
                left = mid + 1  # midで運べないならそれよりは大きい必要がある

        return left
from typing import List


class Solution:
    def shipWithinDays(self, weights: List[int], days: int) -> int:
        min_capacity = max(weights)
        max_capacity = sum(weights)

        def canCompleteWithin(weights: List[int], days: int, capacity: int) -> bool:
            required_days = 1
            current_weight = 0

            for w in weights:
                if current_weight >= capacity:
                    required_days += 1
                    current_weight = w
                    continue
                current_weight += w

            return required_days <= days

        left = min_capacity
        right = max_capacity
        while left < right:
            mid = (left + right + 1) // 2
            if canCompleteWithin(weights, days, mid):
                right = mid  # midで運べるならmid自身を残す
            else:
                left = mid + 1  # midで運べないならそれよりは大きい必要がある

        return left

問題点として、このコードは

  • canCompleteWithin()内で指定されたdaysを超えてしまっても一応最後の荷物まで計算してしまっている点
  • canCompleteWithin()の関数呼び出しのオーバーヘッドがある

という点が考えられた。正直、二つ目はあまり本質的ではない気がする。

これらの点を改善すると、間に合うようになる。

from typing import List

class Solution:
    def shipWithinDays(self, weights: List[int], days: int) -> int:
        left = max(weights)
        right = sum(weights)

        while left < right:
            mid = (left + right) // 2

            required_days = 1
            current_weight = 0

            for w in weights:
                if current_weight + w > mid:
                    required_days += 1
                    current_weight = w

                    if required_days > days:
                        break
                else:
                    current_weight += w

            if required_days <= days:
                right = mid
            else:
                left = mid + 1

        return left
from typing import List

class Solution:
    def shipWithinDays(self, weights: List[int], days: int) -> int:
        left = max(weights)
        right = sum(weights)

        while left < right:
            mid = (left + right) // 2

            required_days = 1
            current_weight = 0

            for w in weights:
                if current_weight + w > mid:
                    required_days += 1
                    current_weight = w

                    if required_days > days:
                        break
                else:
                    current_weight += w

            if required_days <= days:
                right = mid
            else:
                left = mid + 1

        return left

ノート

  • 最適スケジューリング系の問題なので二分探索は典型
  • パターンマッチングになってしまうだけではなく、自分で単調性を確認してから方針を確定できると良い