0Pricing
DSA Interview Prep · レッスン

アナグラムと文字頻度マップ

頻度配列とハッシュマップを使い、group-anagrams、valid-anagram、permutation-in-stringをO(n)で解きます。

「アナグラムと文字頻度マップ」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン3/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。

アナグラムとは

2つの文字列が、順序は異なっていても、同じ文字を同じ頻度で含んでいる場合、それらはアナグラムです。'listen'と'silent'はアナグラムです。最も簡単な正しさの確認方法は、両方の文字列をソートして比較する方法で、計算量はO(n log n)です。O(n)の解法では、文字の頻度マップを比較します。アナグラム問題は、ハッシュ化、ソート、頻度配列という複数の技法を確認できるため、文字列面接で頻繁に出題されます。

def is_anagram_sort(s, t):
    return sorted(s) == sorted(t)  # O(n log n)

def is_anagram_counter(s, t):
    from collections import Counter
    return Counter(s) == Counter(t)  # O(n)

def is_anagram_array(s, t):
    if len(s) != len(t): return False
    freq = [0] * 26
    for a, b in zip(s, t):
        freq[ord(a) - ord('a')] += 1
        freq[ord(b) - ord('a')] -= 1
    return all(f == 0 for f in freq)  # O(n)

print(is_anagram_array('anagram', 'nagaram'))  # True
print(is_anagram_array('rat', 'car'))           # False

小文字用の頻度配列

文字集合が限定されている場合(たとえば小文字のa-zだけの場合)は、ハッシュマップの代わりにサイズ26の頻度配列を使います。ord(c) - ord('a')でインデックスを計算すると、'a'→0、'b'→1、...、'z'→25に対応付けられます。配列は、キャッシュ局所性が高く、ハッシュ計算のオーバーヘッドもないため、実際にはdictより高速です。このテクニックは、valid-anagram、anagram-permutation-in-string、palindrome-permutationの問題で使われます。

def build_freq(s):
    freq = [0] * 26
    for c in s:
        freq[ord(c) - ord('a')] += 1
    return freq

def is_anagram_fast(s, t):
    return len(s) == len(t) and build_freq(s) == build_freq(t)

# Palindrome permutation: at most one odd-count character
def can_form_palindrome(s):
    freq = build_freq(s)
    odd_count = sum(1 for f in freq if f % 2 == 1)
    return odd_count <= 1

print(can_form_palindrome('carerace'))  # True ('racecar')
print(can_form_palindrome('hello'))     # False

アナグラムのグループ化

文字列のリストを、すべてのアナグラムが同じグループに入るように分類します。標準的なO(n×m log m)の解法では、ソートした文字列をハッシュマップのキーとして使います。すべてのアナグラムは同じソート済みキーになるため、同じバケットに入ります。O(n×m)の変形では、文字のカウントをまとめたタプルをキーにします。ソートを完全に避けられますが、計算にはより時間がかかります。分かりやすさの点から、ほとんどの場合はソート済みキーの方法が推奨されます。

from collections import defaultdict

def group_anagrams(strs):
    groups = defaultdict(list)
    for s in strs:
        key = tuple(sorted(s))  # or ''.join(sorted(s))
        groups[key].append(s)
    return list(groups.values())

words = ['eat','tea','tan','ate','nat','bat']
result = group_anagrams(words)
for g in sorted(result, key=len, reverse=True):
    print(sorted(g))
# ['ate', 'eat', 'tea']
# ['nat', 'tan']
# ['bat']

カウントタプルによるアナグラムキー

O(n×m)のアナグラム分類の変形では、各文字列の頻度を26個のカウントのタプル、つまりtuple(freq_array)として表します。ソートを避けられますが、すべてのキーを作成するためにO(26×n×m)の処理が必要です。Pythonではタプルはハッシュ可能なので、dictのキーとして使えます。面接官から「O(n×m)の解法を1つ挙げてください」と聞かれたときにこの方法を説明すると、異なるトレードオフを理解していることを示せます。

from collections import defaultdict

def group_anagrams_count(strs):
    groups = defaultdict(list)
    for s in strs:
        freq = [0] * 26
        for c in s:
            freq[ord(c) - ord('a')] += 1
        key = tuple(freq)  # tuple is hashable
        groups[key].append(s)
    return list(groups.values())

print(group_anagrams_count(['eat','tea','tan','ate','nat','bat']))

頻度上位K個の要素

配列から、頻度が最も高いk個の要素を求めます。Counter + ヒープでは、まずO(n)で頻度マップを作り、サイズkの最小ヒープ、またはCounter.most_common(k)を使って頻度の高いものからk個取り出します。O(n)のバケットソートでは、頻度(0からn)をインデックスとするバケットを作り、頻度の高い順に要素を集めます。kが大きい場合に効果的な方法です。

from collections import Counter
import heapq

def top_k_frequent_heap(nums, k):
    freq = Counter(nums)
    return heapq.nlargest(k, freq, key=freq.get)

def top_k_frequent_bucket(nums, k):
    freq = Counter(nums)
    buckets = [[] for _ in range(len(nums) + 1)]
    for num, cnt in freq.items():
        buckets[cnt].append(num)
    result = []
    for i in range(len(buckets)-1, -1, -1):
        result.extend(buckets[i])
        if len(result) >= k: break
    return result[:k]

print(top_k_frequent_heap([1,1,1,2,2,3], 2))   # [1, 2]
print(top_k_frequent_bucket([1,1,1,2,2,3], 2)) # [1, 2]

文字列内の順列用の頻度マップ

文字列pの順列がsの部分文字列として存在するかを判定します。長さ|p|のウィンドウの頻度マップは、pの頻度マップと一致していなければなりません。ウィンドウをスライドさせるとき、入ってくる文字のカウントを増やし、出ていく文字のカウントを減らします。2つのCounterオブジェクトの比較には毎回O(26)かかるため、全体の計算量はO(n×26) = O(n)です。O(1)で等価性を確認できるよう、'formed'カウンタを追跡します。

def check_inclusion_fast(p, s):
    if len(p) > len(s): return False
    need = [0] * 26
    have = [0] * 26
    for c in p:
        need[ord(c)-ord('a')] += 1
    for i in range(len(p)):
        have[ord(s[i])-ord('a')] += 1
    if need == have: return True
    for i in range(len(p), len(s)):
        have[ord(s[i])-ord('a')]         += 1
        have[ord(s[i-len(p)])-ord('a')] -= 1
        if need == have: return True
    return False

print(check_inclusion_fast('ab', 'eidbaooo'))  # True
print(check_inclusion_fast('ab', 'eidboaoo'))  # False

アナグラムにするために削除する最小文字数

2つの文字列が与えられたとき、一方をもう一方のアナグラムにするために必要な文字の削除回数の最小値を求めます。両方の文字列の頻度マップを作り、各文字の頻度の差の絶対値を合計します。一方にしか存在しない文字はすべて削除する必要があります。このO(n)の解法では、頻度マップに対する「マージして差分を取る」パターンを使います。

from collections import Counter

def min_steps_to_anagram(s, t):
    freq_s = Counter(s)
    freq_t = Counter(t)
    steps = 0
    # For each unique char across both strings:
    all_chars = set(freq_s) | set(freq_t)
    for c in all_chars:
        steps += abs(freq_s.get(c, 0) - freq_t.get(c, 0))
    return steps

# Or more concisely:
def min_steps_counter(s, t):
    diff = Counter(s) - Counter(t)
    return sum(diff.values())

print(min_steps_to_anagram('leetcode', 'practice'))  # 5
print(min_steps_counter('leetcode', 'practice'))      # 5

ランサムノートの頻度マップ

note内のすべての文字を、magazine内の文字で供給できるかを確認します(magazineの各文字は1回しか使えません)。まずmagazineの文字の頻度マップを作り、次にnoteの各文字についてカウントを減らします。いずれかのカウントが負になったらFalseを返します。小文字に限定された入力で、dictの代わりに26要素の配列を使えば、計算量はO(n + m)、空間計算量はO(1)です。

def can_construct(note, magazine):
    freq = [0] * 26
    for c in magazine:
        freq[ord(c) - ord('a')] += 1
    for c in note:
        freq[ord(c) - ord('a')] -= 1
        if freq[ord(c) - ord('a')] < 0:
            return False  # insufficient supply
    return True

print(can_construct('aa', 'aab'))    # True
print(can_construct('aa', 'ab'))     # False
print(can_construct('bg', 'efjbdfbdgbjjbghiklgdch'))  # True

最長アナグラム部分文字列のハッシュ化

同じ文字列内の2つの部分文字列がアナグラムかどうかを確認するには、文字の頻度に対する多項式ハッシュを使います。このハッシュは可換的(順序に依存しない)でなければなりません。文字の値のXORは可換的でO(1)で更新できますが、衝突する確率が高くなります。よりよい方法は素数積ハッシュです。各文字を異なる素数に対応付け、その積を使うため、順序に依存しません。これは上級者向け面接で扱われる、専門的なテクニックです。

# Prime product hash: each char maps to a prime
PRIMES = [2,3,5,7,11,13,17,19,23,29,31,37,41,
          43,47,53,59,61,67,71,73,79,83,89,97,101]

def char_hash(s):
    h = 1
    for c in s:
        h *= PRIMES[ord(c) - ord('a')]
    return h

# Two windows with equal hash are likely anagrams
print(char_hash('listen'))  # same as:
print(char_hash('silent'))  # should match

頻度マップのパターンチェックリスト

面接で使われる次の頻度マップのパターンを覚えておきましょう:

  • アナグラム判定: 同じ長さ + 同じ頻度 → Counterの等価性または配列比較
  • アナグラムのグループ化: ソート済み文字列または頻度タプルをdictのキーにする
  • 頻度上位k個: Counter + ヒープまたはバケットソート
  • 文字列内の順列: スライディングウィンドウ + 頻度比較
  • ランサムノート: 供給側の頻度マップを作り、要求される文字ごとに減らす
  • 回文の順列: 奇数回出現する文字は最大1つ
これらはすべて、「頻度を指紋として使う」という同じ基本概念に帰着します。

from collections import Counter

# Palindrome permutation
def palindrome_permutation(s):
    return sum(v % 2 for v in Counter(s).values()) <= 1

# First unique character
def first_unique(s):
    freq = Counter(s)
    for i, c in enumerate(s):
        if freq[c] == 1:
            return i
    return -1

# Character replacement for longest repeat
def char_replacement(s, k):
    freq = Counter()
    left = best = max_freq = 0
    for right, c in enumerate(s):
        freq[c] += 1
        max_freq = max(max_freq, freq[c])
        if (right - left + 1) - max_freq > k:
            freq[s[left]] -= 1
            left += 1
        best = max(best, right - left + 1)
    return best

print(palindrome_permutation('carerace'))  # True
print(first_unique('leetcode'))             # 0
print(char_replacement('AABABBA', 1))      # 4

1つだけ異なる要素: 頻度に対するXOR

出現回数が奇数の要素がちょうど1つある頻度問題では、XORが強力な手段になります。数値とそれ自身のXORを取ると0になります: a XOR a = 0。1つを除くすべての値が偶数回出現する要素をすべてXORすると、奇数回出現するその要素だけが残ります。これにより、ハッシュマップを使わずにO(n)の時間計算量とO(1)の空間計算量で解けます。XORの性質を使えば、奇数回出現する要素が2つある場合にも一般化できます。

def single_number(nums):
    result = 0
    for n in nums:
        result ^= n  # XOR cancels pairs
    return result

print(single_number([4,1,2,1,2]))   # 4
print(single_number([2,2,1]))       # 1

# Find the unique character in an anagram check:
def find_difference(s, t):
    result = 0
    for c in s + t:
        result ^= ord(c)
    return chr(result)

print(find_difference('abcd', 'abcde'))  # 'e'

理解度チェック

このレッスンのData Structures & Algorithms — Coding Interview Prepの概念について、理解度を確認しましょう。

レッスンのまとめ

このレッスンでは、次のことを学びました: 文字の頻度マップはアナグラム判定の中心的なツールであり、文字集合が限定されている場合は26要素の配列、任意の文字を扱う場合はCounterを使います。また、ソート済み文字列または頻度タプルをdictのキーにすると、すべてのアナグラムをそれぞれO(n × m log m)またはO(n × m)の計算量でまとめられます。さらに、XORは単一要素の奇数回出現問題でペアをきれいに相殺するため、dictが不要な場合にO(n)の時間計算量とO(1)の空間計算量を実現できます。次は、文字列のエンコーディング、反転、回文のテクニックについて学びます。

よくある質問

「アナグラムと文字頻度マップ」レッスンは無料ですか?

はい。「アナグラムと文字頻度マップ」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。

「アナグラムと文字頻度マップ」で何を学びますか?

頻度配列とハッシュマップを使い、group-anagrams、valid-anagram、permutation-in-stringをO(n)で解きます。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

DSA Interview Prepを始めるのに経験は必要ですか?

事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン3/4です。

「アナグラムと文字頻度マップ」レッスンにはどのくらい時間がかかりますか?

ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。

このDSA Interview Prepレッスンでコードを書いて実行できますか?

はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。

このコースのすべてのレッスン

  1. 面接のためのPython文字列API
  2. 部分文字列のスライディングウィンドウ
  3. アナグラムと文字頻度マップ
  4. 文字列のエンコード、反転、回文
← DSA Interview Prepに戻る