0Pricing
Coding Interview Prep · レッスン

文字列のエンコード、反転、回文

in-placeでの単語反転、ランレングスエンコーディング、expand-around-centre手法を含む回文判定を実装します。

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

文字列をインプレースで反転する

Pythonの文字列は不変であるため、「インプレース」での反転とは、文字のリストに変換し、2つのポインタで入れ替えてから結合することを意味します。典型的な2ポインタによる交換では、leftをインデックス0に、rightを最後のインデックスに置きます。文字を交換してポインタを内側へ移動し、両者が交差するまで続けます。これはO(n)時間、文字リストにO(n)空間を要します(文字列が不変であるため、空間は削減できません)。

def reverse_string(s):
    chars = list(s)
    left, right = 0, len(chars) - 1
    while left < right:
        chars[left], chars[right] = chars[right], chars[left]
        left  += 1
        right -= 1
    return ''.join(chars)

print(reverse_string('hello'))   # 'olleh'
print(reverse_string('Hannah'))  # 'hannaH'

# Pythonic shortcut (creates new string):
print('hello'[::-1])  # 'olleh'

文中の単語の順序を反転する

余分な空白を取り除きながら、単語の順序を反転します。Pythonでの簡潔な解決策は、split(複数の空白を処理します)を使ってリストに分割し、リストを反転してからjoinする方法です。文字配列をインプレースで反転する場合は、配列全体を反転してから、各単語を個別に反転します。この2パスの方法はO(n)時間、O(n)空間で実行されます(Pythonの文字列は不変であるため、空間は避けられません)。

def reverse_words(s):
    words = s.split()       # split and strip whitespace
    words.reverse()         # in-place reverse
    return ' '.join(words)  # single space between words

print(reverse_words('  hello   world  '))  # 'world hello'
print(reverse_words('a good example'))     # 'example good a'

# One-liner:
print(' '.join('  hello   world  '.split()[::-1]))

回文判定:基本

文字列がその反転と等しい場合、その文字列は回文です。Pythonで最も高速な判定方法は、s == s[::-1]です。大文字と小文字を区別せず、英数字だけを対象とする回文(面接で最もよく出る形式)では、まず文字列を正規化します。英数字以外の文字を取り除いて小文字に変換し、その後で比較します。どちらの方法もO(n)です。

def is_palindrome(s):
    # Filter and normalise
    cleaned = ''.join(c.lower() for c in s if c.isalnum())
    return cleaned == cleaned[::-1]

print(is_palindrome('A man, a plan, a canal: Panama'))  # True
print(is_palindrome('race a car'))                       # False
print(is_palindrome('Was it a car or a cat I saw?'))     # True

回文判定:2ポインタ

追加の空間をO(1)にするには、スライスではなく2つのポインタを使って回文を判定します。leftを0に、rightを末尾に置きます。英数字以外の文字を飛ばし、残った文字を大文字と小文字を区別せずに比較して、不一致があればFalseを返します。こちらは記述量が増えますが、整形済みの文字列を一切作成せずに済むため、メモリに制約がある場合に重要です。

def is_palindrome_twoptr(s):
    left, right = 0, len(s) - 1
    while left < right:
        while left < right and not s[left].isalnum():
            left += 1
        while left < right and not s[right].isalnum():
            right -= 1
        if s[left].lower() != s[right].lower():
            return False
        left += 1; right -= 1
    return True

print(is_palindrome_twoptr('A man, a plan, a canal: Panama'))  # True

中心からの拡張で最長回文を求める

中心からの拡張という手法を使うと、追加の空間O(1)でO(n²)時間のうちに最長回文部分文字列を見つけられます。各文字(奇数長の回文)と文字間の各隙間(偶数長の回文)を中心として、文字が一致する間、外側へ拡張します。これまでに見つけた最良の(start, end)ペアを記録します。中心は2n-1個あり、各拡張にかかる時間は最悪の場合O(n)です。

def longest_palindrome(s):
    best_start = best_end = 0

    def expand(left, right):
        while left >= 0 and right < len(s) and s[left] == s[right]:
            left -= 1; right += 1
        return left + 1, right - 1  # last valid bounds

    for i in range(len(s)):
        l, r = expand(i, i)      # odd-length
        if r - l > best_end - best_start:
            best_start, best_end = l, r
        l, r = expand(i, i + 1)  # even-length
        if r - l > best_end - best_start:
            best_start, best_end = l, r

    return s[best_start:best_end+1]

print(longest_palindrome('babad'))    # 'bab' or 'aba'
print(longest_palindrome('cbbd'))     # 'bb'

Manacher法の概要

Manacher法は、大きな回文の内部にある回文を鏡映位置から初期化できるという洞察を利用して、O(n)時間で最長回文部分文字列を見つけます。面接で実装を求められることはほとんどありませんが、存在は知っておく価値があります。ほとんどの面接官は、O(n²)の中心からの拡張法を「十分に最適」とみなします。追加質問で理論上のO(n)解を求められた場合は、Manacher法に言及してください。

# Manacher's: O(n) longest palindromic substring
def manacher(s):
    # Transform s into '#a#b#a#' to handle even/odd uniformly
    t = '#' + '#'.join(s) + '#'
    n = len(t)
    P = [0] * n  # P[i] = palindrome radius at i
    center = right = 0
    for i in range(n):
        mirror = 2 * center - i
        if i < right:
            P[i] = min(right - i, P[mirror])
        while (i + P[i] + 1 < n and i - P[i] - 1 >= 0
               and t[i+P[i]+1] == t[i-P[i]-1]):
            P[i] += 1
        if i + P[i] > right:
            center, right = i, i + P[i]
    max_len = max(P)
    center_idx = P.index(max_len)
    start = (center_idx - max_len) // 2
    return s[start:start+max_len]

print(manacher('babad'))   # 'bab'

ランレングス符号化

ランレングス符号化(RLE)は、連続して繰り返される文字を圧縮します。たとえば、'aaabbc'は'a3b2c1'になります。実装では、高速ポインタで各ランの末尾を見つけるように走査し、文字と回数を出力リストに書き込んでから結合します。短いランでは、入力の方が符号化後の出力より短くなることがあります。返す前に、符号化後の方が短いかどうかを必ず確認してください。

def encode_rle(s):
    if not s: return ''
    parts = []
    i = 0
    while i < len(s):
        char = s[i]
        j = i
        while j < len(s) and s[j] == char:
            j += 1
        count = j - i
        parts.append(char + (str(count) if count > 1 else ''))
        i = j
    encoded = ''.join(parts)
    return encoded if len(encoded) < len(s) else s

print(encode_rle('aaabbc'))    # 'a3b2c'
print(encode_rle('abc'))       # 'abc'  (no compression gain)

ランレングス符号化文字列のデコード

RLEのデコードでは、文字とその直後に続く数字列を読み取り、各ランを展開します。面接では、k[encoded_string]を使って部分文字列を繰り返すLeetCode形式の問題が出されることもあります。たとえば、3[ab] → abababとなります。このネストされた形式では、複数の入れ子レベルを処理するためにスタックが必要です。

def decode_rle(s):
    result = []
    i = 0
    while i < len(s):
        char = s[i]; i += 1
        num_str = ''
        while i < len(s) and s[i].isdigit():
            num_str += s[i]; i += 1
        count = int(num_str) if num_str else 1
        result.append(char * count)
    return ''.join(result)

print(decode_rle('a3b2c'))    # 'aaabbc'
print(decode_rle('a2b3c1'))   # 'aabbbc'

# Nested bracket decode (LeetCode 394)
def decode_bracket(s):
    stack = []
    for c in s:
        if c != ']':
            stack.append(c)
        else:
            chars = []
            while stack[-1] != '[':
                chars.append(stack.pop())
            stack.pop()  # remove '['
            k = int(stack.pop())
            stack.append(''.join(reversed(chars)) * k)
    return ''.join(stack)
print(decode_bracket('3[ab]'))  # 'ababab'

有効な回文 II:1文字削除可

文字列が与えられたとき、高々1文字を削除することで回文にできるならTrueを返します。2つのポインタを使い、最初の不一致でs[left+1:right+1]またはs[left:right]が回文かどうかを確認します。つまり、不一致だった各文字を1つずつ飛ばして試します。どちらか一方が回文ならTrueを返します。不一致の文字を飛ばす以外に有効な操作はないため、この貪欲法が機能します。

def valid_palindrome(s):
    def is_pal(l, r):
        while l < r:
            if s[l] != s[r]: return False
            l += 1; r -= 1
        return True

    left, right = 0, len(s) - 1
    while left < right:
        if s[left] != s[right]:
            # Try skipping either character
            return is_pal(left+1, right) or is_pal(left, right-1)
        left += 1; right -= 1
    return True

print(valid_palindrome('aba'))    # True
print(valid_palindrome('abca'))   # True  (delete 'c')
print(valid_palindrome('abc'))    # False

回文分割 I

文字列を、回文である部分文字列の組み合わせすべてに分割します。バックトラッキングを使い、各ステップで残りの文字列のすべての接頭辞を試し、接頭辞が回文なら残りに対して再帰します。区間DPを使って2次元の真偽値テーブルis_pal[i][j]を事前計算し、回文判定をO(1)にします。これにより、全体のバックトラッキングの計算量をO(n² × 2^n)からO(n × 2^n)に削減できます。すべての分割を生成する処理は本質的に指数時間であるため、この計算量は許容範囲です。

def partition(s):
    n = len(s)
    dp = [[False]*n for _ in range(n)]
    for i in range(n):
        dp[i][i] = True
    for length in range(2, n+1):
        for i in range(n-length+1):
            j = i + length - 1
            if s[i] == s[j]:
                dp[i][j] = length == 2 or dp[i+1][j-1]

    result = []
    def backtrack(start, path):
        if start == n: result.append(path[:]); return
        for end in range(start, n):
            if dp[start][end]:
                path.append(s[start:end+1])
                backtrack(end+1, path)
                path.pop()
    backtrack(0, [])
    return result

print(partition('aab'))  # [['a','a','b'],['aa','b']]

最短回文:文字列ハッシュ

文字列の先頭に文字を追加して作れる最短の回文を求めます。重要な洞察は、sの最長回文接頭辞を見つけ、残りの接尾辞を反転したものを先頭に追加することです。最長回文接頭辞を効率よく見つけるには、文字列s + '#' + reverse(s)に対してKMPの失敗関数を使います。失敗関数の最後の値から、最長回文接頭辞の長さが得られます。

def shortest_palindrome(s):
    rev = s[::-1]
    combined = s + '#' + rev  # '#' prevents overlap
    n = len(combined)
    kmp = [0] * n
    j = 0
    for i in range(1, n):
        while j > 0 and combined[i] != combined[j]:
            j = kmp[j-1]
        if combined[i] == combined[j]:
            j += 1
        kmp[i] = j
    # kmp[-1] = length of longest palindromic prefix
    to_add = rev[:len(s) - kmp[-1]]
    return to_add + s

print(shortest_palindrome('aacecaaa'))  # 'aaacecaaa'
print(shortest_palindrome('abcd'))      # 'dcbabcd'

確認問題

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

レッスンのまとめ

このレッスンでは、2つのポインタによる回文判定はO(n)時間、O(1)空間で行えるため、空間が重要な場合は反転コピーを割り当てるより、常にインデックスベースの判定を優先すること、中心からの拡張では、2n-1個の位置を回文の中心候補として扱うことで、O(n²)時間で最長回文部分文字列を見つけられること、そしてランレングス符号化は連続するランをO(n)時間で圧縮し、角括弧を使うネスト形式のデコードにはスタックが必要であることを学びました。次はバブルソートと挿入ソートを扱います。

よくある質問

「文字列のエンコード、反転、回文」レッスンは無料ですか?

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

「文字列のエンコード、反転、回文」で何を学びますか?

in-placeでの単語反転、ランレングスエンコーディング、expand-around-centre手法を含む回文判定を実装します。 ブラウザで直接実行するハンズオンコードでCoding Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

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

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

「文字列のエンコード、反転、回文」レッスンにはどのくらい時間がかかりますか?

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

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

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

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

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