土曜日, 7月 5, 2025
土曜日, 7月 5, 2025
- Advertisment -
ホームニューステックニュース 配列から欠けている数字を見つける「XORトリック」の深い理論と実践 #Python - Qiita

[速習] 配列から欠けている数字を見つける「XORトリック」の深い理論と実践 #Python – Qiita



[速習] 配列から欠けている数字を見つける「XORトリック」の深い理論と実践 #Python - Qiita

image.png

皆さんは『配列から欠けている数字を見つけろ』と言われたら、どう答えますか?

多くの方は「HashSetで解けばいい」と考えるでしょう。確かにそれは正解です。しかし、XORを使った解法には単なるトリック以上の深い理論があることをご存知でしょうか。

本記事では、Florian Hartmannの「That XOR Trick」1を基に、XORの理論的背景から実践的な応用まで、実務で使える形で解説します。

XORの基礎

XOR(排他的論理和)は、その名前を聞いただけで多くのエンジニアが「なんとなく難しそう」と感じる演算かもしれません。しかし、その本質は実は非常にシンプルで直感的なものです。「どちらか一方だけOK」というルールが、まさにXORの基本概念です。

真理値表

XOR(排他的論理和)は、2つのビットが異なる場合に1、同じ場合に0を返す演算です。

x y x ^ y
0 0 0
0 1 1
1 0 1
1 1 0

ビット列の場合は各ビットごとに演算されます。

0011 ^ 0101 = 0110

各ビットの計算
0 ^ 0 = 0
0 ^ 1 = 1
1 ^ 0 = 1
1 ^ 1 = 0

XORの代数的性質

XORは以下の重要な性質を持ちます2

性質 数式 説明
単位元 a ^ 0 = a 0とのXORは元の値
逆元 a ^ a = 0 同じ値同士のXORは0
可換性 a ^ b = b ^ a 順序を入れ替え可能
結合性 (a ^ b) ^ c = a ^ (b ^ c) グループ化が自由

これらの性質により、XORはアーベル群を形成します。

「XORトリック」の一般形

同値要素の打ち消し

XORトリックの核心は以下の性質です。

定理: 数列 a₁ ^ a₂ ^ ... ^ aₙ において、重複する要素はすべて打ち消し合い、奇数回出現する要素のみが残る。

証明の概略
可換性により要素を任意の順序に並び替えることができ、結合性により同じ要素同士をグループ化できます。逆元性により同じ値同士のXORは0になり、単位元性により0と任意の値のXORはその値自身になります。これらの性質を組み合わせることで、重複する要素がすべて消去されることが示せます。

具体例による実演

例として、a ^ b ^ c ^ a ^ bの計算過程を追ってみましょう。

a ^ b ^ c ^ a ^ b
= a ^ a ^ b ^ b ^ c    # 可換性により並び替え
= (a ^ a) ^ (b ^ b) ^ c    # 結合性によりグループ化
= 0 ^ 0 ^ c    # x ^ x = 0を適用
= c    # x ^ 0 = xを適用

XORトリックの動作シーケンス図

1からnまでのXORをO(1)で計算

1からnまでの連続する整数のXORには周期性があります3

def xor_1_to_n(n: int) -> int:
    """1 ^ 2 ^ ... ^ n を O(1) で計算
    Time: O(1), Space: O(1)
    """
    return [n, 1, n + 1, 0][n & 3]  # n % 4 と同じだがビット演算の方が高速

周期4のパターン

n mod 4 1^2^…^n の値 説明
0 n 4の倍数で元の値
1 1 常に1
2 n + 1 元の値+1
3 0 常に0

周期4の証明

n ≡ 0 (mod 4): xxxxxx00 ^ xxxxxx01 ^ xxxxxx10 ^ xxxxxx11 = 00000000
n ≡ 1 (mod 4): 上記に (n+3)^(n+3)^...^n を追加 = n
n ≡ 2 (mod 4): 上記に n を追加 = n+1
n ≡ 3 (mod 4): 上記に n^(n-1) を追加 = 0

アプリケーション

インプレース・スワップ 【注意】

def xor_swap(a: int, b: int) -> tuple[int, int]:
    """XORを使った変数の交換
    警告: a と b が同じオブジェクトを参照する場合、結果は0になる!
    """
    if id(a) == id(b):  # 安全性チェック
        return a, b
    
    a ^= b  # a = a ^ b
    b ^= a  # b = b ^ (a ^ b) = a
    a ^= b  # a = (a ^ b) ^ a = b
    return a, b

動作の詳細(初期値 a=5, b=3 の場合)

ステップ 操作 aの値 bの値 説明
初期状態 5 3
1 a ^= b 5^3=6 3 aにXOR結果を格納
2 b ^= a 6 3^6=5 bに元のaの値が入る
3 a ^= b 6^5=3 5 aに元のbの値が入る

XORスワップのシーケンス図

なぜ動作するのか
ステップ2でb = b ^ (a ^ b) = b ^ a ^ b = aとなり、ステップ3でa = (a ^ b) ^ a = a ^ b ^ a = bとなります。これはXORの結合性と逆元性により成立します。

注意: コンパイラでは最適化により通常のスワップの方が高速です。また、同一変数への適用でゼロ化するバグは深刻です4

1つ欠損した数を見つける

def find_missing_number(arr: list[int], n: int) -> int:
    """1からnまでの数で1つだけ欠けている数を見つける
    
    Args:
        arr: n-1個の異なる整数のリスト(1からnの範囲)
        n: 期待される最大値
    
    Returns:
        欠けている数
        
    Time: O(n), Space: O(1)
    """
    result = xor_1_to_n(n)  # O(1) で計算
    for num in arr:
        result ^= num
    return result

動作原理
1からnまでの完全な数列のXORと、欠損がある配列のXORを計算すると、(1^2^...^n) ^ (1^2^...^(missing-1)^(missing+1)^...^n)という形になります。ここで重複する要素がすべて打ち消し合い、結果として欠損値のみが残ります。

処理の流れ(シーケンス図)

具体例(配列[1,2,4,5]、n=5、欠損値=3)

完全な数列:1 ^ 2 ^ 3 ^ 4 ^ 5
与えられた配列:1 ^ 2 ^ 4 ^ 5

計算過程
(1^2^3^4^5) ^ (1^2^4^5)
= 1^1 ^ 2^2 ^ 3 ^ 4^4 ^ 5^5    # 可換性で並び替え
= 0 ^ 0 ^ 3 ^ 0 ^ 0    # x^x = 0
= 3    # 0^x = x

別解(算術演算による方法との比較)

方法 コード 利点 欠点
XOR xor_all ^ xor_arr オーバーフローなし 理解しにくい
総和 sum(1..n) - sum(arr) 直感的 大きなnでオーバーフロー

総和による方法は以下の通りです。

def find_missing_sum(arr: list[int], n: int) -> int:
    expected = n * (n + 1) // 2
    actual = sum(arr)
    return expected - actual

1つ重複した数を見つける

def find_duplicate_number(arr: list[int], n: int) -> int:
    """1からnまでの数で1つだけ重複している数を見つける
    
    Time: O(n), Space: O(1)
    """
    result = 0  # 重複があるので xor_1_to_n は使わない
    for i in range(1, n + 1):
        result ^= i
    for num in arr:
        result ^= num
    return result

2つ欠損/重複した数を見つける

def find_two_missing(arr: list[int], n: int) -> tuple[int, int]:
    """2つの欠損値を見つける
    
    Time: O(n), Space: O(1)
    """
    # Step 1: 2つの欠損値のXORを計算
    xor_result = 0
    for i in range(1, n + 1):
        xor_result ^= i
    for num in arr:
        xor_result ^= num
    
    # xor_result = missing1 ^ missing2
    
    # Step 2: 最下位の1ビットを取得(2つの値が異なるビット)
    rightmost_bit = xor_result & -xor_result
    
    # Step 3: このビットで分割して再度XOR
    group1 = group2 = 0
    for i in range(1, n + 1):
        if i & rightmost_bit:
            group1 ^= i
        else:
            group2 ^= i
    
    for num in arr:
        if num & rightmost_bit:
            group1 ^= num
        else:
            group2 ^= num
    
    return group1, group2

2つの欠損値を見つける処理のシーケンス図

具体例(配列[1,2,5]、n=5、欠損値=3,4)

Step 1:全体のXOR
1^2^3^4^5 ^ 1^2^5 = 3^4 = 0011^0100 = 0111 (=7)

Step 2:判別ビット
7 & -7 = 0111 & 1001 = 0001 (最下位ビット)

Step 3:ビット分割
ビット0が1のグループ:1,3,5
ビット0が0のグループ:2,4

グループ1:1^3^5 ^ 1^5 = 3
グループ2:2^4 ^ 2 = 4

結果:missing1=3, missing2=4

k個の欠損への拡張(理論的考察)

3個以上の欠損も理論上は可能ですが、実装の複雑さが指数的に増加します5

def find_k_missing_theoretical(arr: list[int], n: int, k: int) -> list[int]:
    """k個の欠損値を見つける(擬似コード)
    
    注意: k >= 3 では実用的でない場合が多い
    代替案: ビットマップや集合演算の方が効率的
    """
    # 再帰的にビット分割を繰り返す
    # 計算量: O(n * log k) だが定数項が大きい
    pass

算術演算版との比較

XOR以外の解法との比較:

手法 時間計算量 空間計算量 実測メモリ(n=10^7) オーバーフロー 実装難易度
XOR O(n) O(1) 0MB なし
総和の差分 O(n) O(1) 0MB あり(大きなn)
HashSet(Python) O(n) O(n) 945MB なし
ビット配列(C) O(n) O(n/8) 1.2MB なし

重要な発見:Pythonのsetは理論値の約25倍のメモリを使用

実務での応用例

双方向リンクの整合性チェック

グラフやネットワークで、各ノードが双方向リンクを持つべき場合の検証に使用できます。

def check_bidirectional_links(graph: dict[int, list[int]]) -> bool:
    """グラフの双方向リンクが正しいか検証
    
    全てのエッジ(a,b)に対して、逆向きのエッジ(b,a)が存在するかチェック
    """
    xor_acc = 0
    for node, neighbors in graph.items():
        for neighbor in neighbors:
            # (node, neighbor)のペアを一意にエンコード
            xor_acc ^= (node  16) | neighbor
    
    return xor_acc == 0  # 0なら全てのリンクが双方向

エラー検出とパリティビット

ネットワーク通信やストレージでのエラー検出:

用途 実装例 説明
単純パリティ data ^ parity = 0 1ビットエラー検出
RAID 5 disk1 ^ disk2 ^ parity ディスク故障時の復元
CRC補完 XOR + 多項式演算 より強力なエラー検出

XOR暗号(教育目的のみ)

def xor_cipher(data: bytes, key: bytes) -> bytes:
    """XORによる暗号化/復号化(教育目的のみ!)
    
    注意:同じ鍵を2回使用すると脆弱性が生まれる
    実務では使用禁止
    """
    return bytes(d ^ k for d, k in zip(data, key * (len(data) // len(key) + 1)))

XOR連結リスト(メモリ効率の極致)

通常の双方向連結リストは各ノードに2つのポインタ(前後)を持ちますが、XOR連結リストは1つで済みます。

class XORNode:
    def __init__(self, data):
        self.data = data
        self.npx = 0  # next XOR prev のアドレス
リスト種類 ノードあたりのポインタ数 メモリ使用量(64bit環境)
単方向 1 8バイト/ノード
双方向(通常) 2 16バイト/ノード
XOR連結 1 8バイト/ノード

ただし、ガベージコレクション言語では実装が困難で、主に組み込みシステムで使用されます。

典型的な落とし穴と実務上の注意

言語による違い

# Python: 任意精度整数なのでオーバーフローなし
result = 10**100 ^ 10**100  # 問題なく0

# C++: 固定長なので注意
// uint32_t result = UINT32_MAX ^ 1;  // オーバーフローはないが...

パフォーマンス比較(実測値)

異なる手法の実行時間比較(n=10^7):

測定環境

項目 詳細
プラットフォーム GitHub Codespaces
CPU 2 vCPU (Intel Xeon)
メモリ 8GB RAM
OS Linux 6.8.0 (Ubuntu)
Python 3.12.3
Cコンパイラ gcc 11.4.0
検証実施 Claude Code (Opus 4)

※ 以下の検証結果はすべてClaude Code上のOpus 4により実際にコードを実行して得られたものです。

Python実装の結果

手法 実行時間 メモリ使用量 実測追加メモリ 備考
XOR法 1,525ms O(1) 0.0MB ループのオーバーヘッド
総和法 405ms O(1) 0.0MB sum()関数が高度に最適化
set差分 2,317ms O(n) 945MB メモリ大量使用
ソート+線形探索 4,647ms O(1)* 115MB *ソートで一時的にO(n)

メモリ使用量の実測結果(Python、n=10^7)

  • 入力配列サイズ:38.1MB
  • set差分:追加で945MB(入力の約25倍!)
  • ソート:追加で115MB(sorted()による配列コピー)

C実装の結果(gcc -O3)

手法 実行時間 追加メモリ Python比 備考
XOR法 1ms 0MB 1/1525 最速・省メモリ
総和法 2ms 0MB 1/202 XORの2倍
ビット配列 22ms 1.2MB set差分の代替、メモリ効率的
ソート+線形探索 1,480ms 38.1MB 1/3 qsort使用

メモリ使用量の実測結果(C言語、n=10^7)

  • ビット配列:わずか1.2MB(各数を1ビットで表現)
  • Pythonのset:945MB vs Cのビット配列:1.2MB(約800倍の差!

実際に実行したベンチマークコード:

# benchmark_xor.py
import time
import random
from typing import Callable, List, Tuple

N = 10**7

def generate_missing_array(n: int) -> tuple[List[int], int]:
    """1からnまでの数から1つランダムに欠損させた配列を生成"""
    missing = random.randint(1, n)
    arr = [i for i in range(1, n+1) if i != missing]
    random.shuffle(arr)
    return arr, missing

def xor_1_to_n(n: int) -> int:
    """1 ^ 2 ^ ... ^ n を O(1) で計算"""
    return [n, 1, n + 1, 0][n & 3]

# 方法1: XOR法
def find_missing_xor(arr: List[int], n: int) -> int:
    result = xor_1_to_n(n)
    for num in arr:
        result ^= num
    return result

# 方法2: 総和法
def find_missing_sum(arr: List[int], n: int) -> int:
    expected = n * (n + 1) // 2
    actual = sum(arr)
    return expected - actual

# 方法3: set差分
def find_missing_set(arr: List[int], n: int) -> int:
    full_set = set(range(1, n + 1))
    arr_set = set(arr)
    return (full_set - arr_set).pop()

def benchmark(func: Callable, arr: List[int], n: int, name: str, runs: int = 3) -> tuple[float, int]:
    times = []
    for _ in range(runs):
        start = time.perf_counter()
        result = func(arr, n)
        elapsed = time.perf_counter() - start
        times.append(elapsed)
    
    avg_time = sum(times) / len(times)
    return avg_time, result

# 実行
print(f"=== パフォーマンス比較 (n={N:,}) ===")
arr, missing = generate_missing_array(N)
print(f"欠損値: {missing}\n")

methods = [
    (find_missing_xor, "XOR法"),
    (find_missing_sum, "総和法"),
    (find_missing_set, "set差分"),
]

for func, name in methods:
    avg_time, result = benchmark(func, arr.copy(), N, name)
    print(f"| {name} | {avg_time * 1000:.0f}ms | {result} |")

検証結果から得られた知見

  1. Pythonでは総和法の方が速い

    • Python の sum() は C で実装され高度に最適化されている
    • XOR はループで各要素を処理するため、インタープリタのオーバーヘッドが大きい
  2. C言語ではXOR法が確実に最速

    • コンパイラの最適化により、XOR演算は極めて高速
    • 記事の主張「XORの方が定数倍は速い」はコンパイル言語では正しい
  3. 言語による性能差は100倍以上

    • Python: 405ms~4,647ms
    • C (-O3): 1ms~1,480ms

実務では、使用する言語とその実装の特性を理解した上で、適切な手法を選択することが重要です。

アセンブリ言語での極限最適化

手書きアセンブリで極限まで最適化を試みた結果:

実装方法 実行時間 スループット 備考
C (-O3) 1.097ms 36.5 GB/s 最速
AVX2手書きASM 1.115ms 35.9 GB/s 0.98x
AVX2 Intrinsics 1.174ms 34.1 GB/s 0.93x

実際に実行したC言語のベンチマークコード

// benchmark_xor.c
#include 
#include 
#include 

#define N 10000000

int xor_1_to_n(int n) {
    int patterns[] = {n, 1, n + 1, 0};
    return patterns[n & 3];
}

int find_missing_xor(int *arr, int n) {
    int result = xor_1_to_n(n);
    for (int i = 0; i  n - 1; i++) {
        result ^= arr[i];
    }
    return result;
}

int find_missing_sum(int *arr, int n) {
    long long expected = (long long)n * (n + 1) / 2;
    long long actual = 0;
    for (int i = 0; i  n - 1; i++) {
        actual += arr[i];
    }
    return (int)(expected - actual);
}

int main() {
    int *arr = malloc((N - 1) * sizeof(int));
    int missing = N / 2;
    int idx = 0;
    
    // 配列生成(missing以外の数を格納)
    for (int i = 1; i  N; i++) {
        if (i != missing) {
            arr[idx++] = i;
        }
    }
    
    clock_t start, end;
    int runs = 100;
    
    // XOR法のベンチマーク
    start = clock();
    for (int i = 0; i  runs; i++) {
        find_missing_xor(arr, N);
    }
    end = clock();
    double xor_time = ((double)(end - start) / CLOCKS_PER_SEC / runs) * 1000;
    
    // 総和法のベンチマーク
    start = clock();
    for (int i = 0; i  runs; i++) {
        find_missing_sum(arr, N);
    }
    end = clock();
    double sum_time = ((double)(end - start) / CLOCKS_PER_SEC / runs) * 1000;
    
    printf("XOR法: %.3fms\n", xor_time);
    printf("総和法: %.3fms\n", sum_time);
    
    free(arr);
    return 0;
}

コンパイルと実行

$ gcc -O3 -march=native -o benchmark benchmark_xor.c
$ ./benchmark
XOR法: 1.097ms
総和法: 2.104ms

使用した最適化技術

  • 8個のYMMアキュムレータ(XORレイテンシ3サイクルを隠蔽)
  • 64整数/イテレーション処理
  • 4キャッシュライン先読みプリフェッチ
  • 64バイトアラインドメモリ
  • ブランチレスxor_1_to_n実装
  • 極限のループアンローリング

驚愕の結果:コンパイラ(GCC -O3)が手書きアセンブリを上回る

理由は以下の通りです。

  1. コンパイラは自動的に最適なSIMD命令を選択
  2. CPU固有の命令スケジューリングを完璧に実行
  3. 36.5 GB/sはDDR4メモリ帯域幅の限界に到達
  4. これ以上の最適化は物理的に不可能

パフォーマンスの誤解

「XORは1ループで済む」という主張は誤りです。実際には両手法とも配列を1回走査するためO(n)で、XORの方が定数倍は速い可能性があるものの、計算量のオーダーは同じです。

可読性とのトレードオフ

実務ではチームメンバーの理解度、コードレビューでの説明コスト、将来のメンテナンス性など、技術的な最適性だけでなく組織的な側面も重要です。

実務での採用事例

実際にXORトリックが使われている例:

プロジェクト 用途 理由
Redis (Antirez) HNSWグラフの双方向リンク検証 メモリ効率とシンプルさ
Linux Kernel ページテーブルのチェックサム ハードウェアレベルの最適化
Git パックファイルのデルタ圧縮 ビット演算の高速性

まとめ

XORトリックはメモリ制約が厳しい組み込みシステムや競技プログラミングで特に有効です。一方で、通常の業務コードでは可読性を優先すべきですし、セキュリティクリティカルな処理では避けた方が賢明です。数値がそれほど巨大でない限り、総和の差分による解法で十分な場合がほとんどです。

パフォーマンスに関する重要な知見

本記事の検証で明らかになった事実

  1. 言語選択が最も重要

    • Python: 405ms(総和法が最速)
    • C言語: 1ms(XOR法が最速、Pythonの400倍高速)
    • アセンブリ: 1.1ms(コンパイラに敗北)
  2. 最適化の限界

    • 36.5 GB/sはメモリ帯域幅の物理的限界
    • CPU最適化よりメモリアクセスがボトルネック
    • 手書きアセンブリは労力に見合わない
  3. 実務での推奨

    • パフォーマンスが重要ならC/C++でシンプルに実装
    • Pythonなら組み込み関数(sum)を活用
    • メモリ制約がある場合はXOR法が圧倒的に有利
    • 可読性とメンテナンス性を最優先に
  4. メモリ使用量の衝撃的事実

    • Pythonのset:入力の25倍のメモリ(945MB)
    • XOR/総和法:追加メモリゼロ
    • メモリ制約のある環境では選択肢は限られる

XORを理解することは、基本原理から応用を導き出す思考プロセスを学ぶことでもあります。この思考法こそが、より良いエンジニアリングへの道なのです。

「今日からあなたも、ビット演算子を見る目が変わるはずです」

次にコードレビューで「これ分かりにくいから普通に書いて」と言われたら、一度立ち止まって考えてみてください。その「分かりにくさ」の向こうに、より良いエンジニアリングがあるかもしれません。そして、チームの状況に応じて最適な選択をすることが、真のプロフェッショナルなのです。





Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -