Subsets II - 重複排除の部分集合生成

反復的拡張法と prev_size による重複制御で、重複要素を含む配列からユニークな全部分集合を O(n·2^n) で生成

📋 アルゴリズム概要

LeetCode 90: Subsets II では、重複要素を含む可能性のある整数配列から、重複のないすべての部分集合(パワーセット)を生成します。

核心戦略

  • 入力配列を昇順ソートし、同値を隣接させる
  • 反復的拡張法で部分集合を段階的に生成
  • prev_size 変数で重複要素の拡張範囲を制御
  • 新規要素は全体拡張、重複要素は前回追加分のみ拡張

📥 入力例

nums = [1, 2, 2]

📤 出力例

[[], [1], [1,2], [1,2,2], [2], [2,2]]

🔄 ステップバイステップ解説

1

ソートと初期化

配列を昇順ソート、空集合で初期化、prev_size = 0

2

最初の要素 (1) を追加

新規要素: 全体を拡張。[] に 1 を追加 → [1]

3

2番目の要素 (2) を追加

新規要素: 全体を拡張。[2], [1,2] を追加

4

3番目の要素 (2) - 重複

重複要素: 前回追加分のみ拡張。[2,2], [1,2,2] を追加

5

完了

6個のユニークな部分集合を生成完了

ステージ 1: ソートと初期化 1 2 2 配列: 結果 = [ ] • 空の部分集合 • prev_size = 0 • 拡張準備完了
ステージ 2: 要素 1 を追加 処理中: 1 処理前: [ ] 全て拡張 処理後: [ ] [1] 新要素 → 全ての部分集合を拡張 start = 0, size = 1, prev_size = 1
ステージ 3: 要素 2 を追加(初回出現) 処理中: 2 処理前: [ ] [1] 全て拡張 処理後: [ ] [1] [2] [1,2] 新要素 (2 ≠ 1) 全て拡張: [] → [2], [1] → [1,2] start = 0, size = 2, prev_size = 2
ステージ 4: 要素 2 を追加(重複!) 処理中: 2 (重複) 処理前: [ ] [1] [2] 前回分 [1,2] 前回分 前回分のみ 処理後: [ ] [1] [2] [1,2] [2,2] 新規 [1,2,2] 新規 重複検出: 2 == 2 前回追加分のみ拡張 (prev_size..size-1) start = prev_size = 2, size = 4, new_prev_size = 4
ステージ 5: 完了! ✓ 全ての一意な部分集合 最終結果 (6個の部分集合): [ ] [1] [2] [1,2] [2, 2] [1,2,2] アルゴリズム完了 時間計算量: O(n·2^n) | 空間計算量: O(1) 追加分 重複なし、全ての一意な組み合わせを生成

💻 Python実装(LeetCode形式)

以下は class Solution 形式の実装です。型注釈完備で、Pylance での静的解析に対応しています。

from typing import List
class Solution:
"""
LeetCode 90: Subsets II
重複要素を含む配列からユニークな全部分集合を生成
反復的拡張法 + prev_size による重複制御
Time: O(n·2^n), Space: O(1) extra (excluding output)
"""

def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
    """
    重複要素を含む配列からユニークな全部分集合を返す

    Args:
        nums: 整数配列(重複を含む可能性あり)

    Returns:
        重複のないすべての部分集合のリスト

    Examples:
        >>> Solution().subsetsWithDup([1,2,2])
        [[], [1], [1,2], [1,2,2], [2], [2,2]]

        >>> Solution().subsetsWithDup([0])
        [[], [0]]
    """
    # ステップ1: ソートして同値を隣接させる
    # これにより重複判定が O(1) で可能になる
    arr: List[int] = sorted(nums)

    # ステップ2: 空集合で初期化
    res: List[List[int]] = [[]]

    # prev_size: 直前イテレーション開始時の res の長さ
    # 重複要素の拡張範囲を制御するために使用
    prev_size: int = 0

    # ステップ3: 各要素について部分集合を拡張
    for i, v in enumerate(arr):
        # 現在の部分集合数を保存
        size: int = len(res)

        # ステップ4: 拡張開始位置を決定
        # 重複要素なら前回追加分のみ拡張、そうでなければ全体を拡張
        if i > 0 and v == arr[i - 1]:
            # 重複: 前回イテレーションで追加された部分のみ拡張
            start: int = prev_size
        else:
            # 新規要素: すべての既存部分集合を拡張
            start = 0

        # ステップ5: 部分集合を拡張
        # start から size-1 までの各部分集合に v を追加
        for j in range(start, size):
            base = res[j]
            # Python の list 連結は C 実装で高速
            res.append(base + [v])

        # ステップ6: 次回イテレーション用に現在のサイズを保存
        prev_size = size

    return res

📊 視覚的図解・フローチャート

Subsets II アルゴリズムフロー

重複要素を含む配列から重複なしの全部分集合を生成

              flowchart TD
    Start([開始: subsetsWithDup]) --> Sort["配列をソート
arr = sorted(nums)"] Sort --> Init["結果を初期化
res = [[]]
prev_size = 0"] Init --> LoopCheck{"配列の全要素を
処理したか?"} LoopCheck -->|No| GetElement["現在の要素 v を取得
i番目の要素"] LoopCheck -->|Yes| Return([結果を返す: res]) GetElement --> SaveSize["現在のサイズを保存
size = len(res)"] SaveSize --> DupCheck{"重複要素か?
i > 0 かつ
v == arr[i-1]"} DupCheck -->|Yes| SetStartPrev["拡張開始位置を設定
start = prev_size
前回追加分のみ拡張"] DupCheck -->|No| SetStartZero["拡張開始位置を設定
start = 0
全体を拡張"] SetStartPrev --> InnerLoop["内側ループ開始
j = start to size-1"] SetStartZero --> InnerLoop InnerLoop --> InnerCheck{"j < size?"} InnerCheck -->|Yes| CreateSubset["新しい部分集合を作成
base = res[j]
new = base + [v]"] InnerCheck -->|No| UpdatePrev["prev_sizeを更新
prev_size = size"] CreateSubset --> AppendRes["結果に追加
res.append(new)"] AppendRes --> IncrJ["j++"] IncrJ --> InnerCheck UpdatePrev --> IncrI["i++"] IncrI --> LoopCheck style Start fill:#e1f5e1 style Return fill:#e1f5e1 style DupCheck fill:#fff4e1 style LoopCheck fill:#fff4e1 style InnerCheck fill:#fff4e1 style CreateSubset fill:#e1e5f5 style AppendRes fill:#e1e5f5 style Sort fill:#f5e1e1

アルゴリズムの特徴

  • 時間計算量: O(n·2^n) - n個の要素から2^n個の部分集合を生成
  • 空間計算量: O(1) 追加空間(出力を除く)
  • 重複排除の仕組み: ソート後、重複要素は前回追加分のみを拡張することで重複を防ぐ
  • prev_sizeの役割: 前回のイテレーション開始時点でのresのサイズを記録し、重複要素時の拡張範囲を制限

処理例: [1, 2, 2]

  • 初期: res = [[]]
  • 要素1追加: res = [[], [1]]
  • 要素2(1つ目)追加: res = [[], [1], [2], [1,2]]
  • 要素2(2つ目・重複)追加: res = [[], [1], [2], [1,2], [2,2], [1,2,2]]

データフロー

入力フェーズ 配列を入力 配列をソート 初期化 結果 = [[]] 空の部分集合 prev_size = 0 カウンタを初期化 反復処理 各要素 v について 重複? はい 前回分を拡張 いいえ 全てを拡張 出力 返却 全ての一意な 部分集合

入力のソートと初期化後、各要素を順次処理。重複判定により拡張範囲を制御し、最終的にユニークな全部分集合を出力します。

計算量説明

⏱️ 時間計算量

O(n · 2n)

  • ソート: O(n log n) - Timsort
  • 部分集合生成: 最大 2n 個の部分集合
  • 各生成: リストコピーに O(平均サイズ) ≈ O(n)
  • 支配項: O(n · 2n) が支配

💾 空間計算量

O(1)

追加メモリ(出力除く)

  • ローカル変数: prev_size, size, start のみ
  • ソート: in-place(実質 O(1))
  • 出力配列: 問題要件のため別計上
  • 再帰なし: スタック深度の心配不要

アプローチ比較

手法 時間 空間(追加) 特徴
再帰バックトラック O(n·2n) O(n) 関数呼び出しスタック深度 n
反復拡張(採用) O(n·2n) O(1) 関数呼び出しなし、最小オーバーヘッド
ビットマスク列挙 O(n·2n) O(1) 重複排除に追加ロジック必要

🎯 計算量のポイント

  • 出力サイズが 2n のため、理論的な下限も O(n·2n)。これ以上の高速化は不可能。
  • 本実装は prev_size による O(1) の重複判定で、追加メモリを最小化。
  • CPython の list + [item] は C 実装で効率的。
  • 制約 n ≤ 10 のため、最大 1024 個の部分集合。実用上十分高速。

🔍 エッジケースと検証観点

境界ケース

最小入力

nums = [0]

期待出力: [[], [0]]

すべて同じ要素

nums = [1, 1, 1]

期待: [[], [1], [1,1], [1,1,1]]

すべて異なる要素

nums = [1, 2, 3]

期待: 23 = 8 個の部分集合

最大長

len(nums) = 10

最大 210 = 1024 個の部分集合

特殊ケース

負の数を含む

nums = [-1, 0, 1, 1]

ソートの正しさ、負数処理の確認

連続する重複

nums = [1, 1, 2, 2]

複数の重複グループの処理確認

重複が末尾

nums = [1, 2, 2]

Example 1 - 重複が最後に来る場合の動作確認

正当性の検証観点

一意性(Uniqueness)

同一の部分集合が複数生成されないこと

完全性(Completeness)

理論的な部分集合数と一致すること

順序不変性

入力の順序によらず同じ結果集合

型安全性

Pylance で警告・エラーなし