コールスタックを可視化する
Pythonのsysモジュールとprintによるトレースで、スタックフレームの増減を観察し、深い再帰におけるスタックオーバーフローのリスクを理解します。
「コールスタックを可視化する」はCoddyKit上の無料Coding Interview Prepレッスンです。 これはレッスン2/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはCoding Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 Coding Interview Prepコースには全4レッスンが含まれています。
呼び出しスタックとは
Python では、関数を呼び出すたびに呼び出しスタック上にスタックフレームが作成されます。フレームには、関数のローカル変数、戻りアドレス(関数の戻り後に実行を再開する場所)、現在の命令ポインタが格納されます。関数が戻ると、そのフレームは取り除かれ、制御が呼び出し元に戻ります。呼び出しのたびに呼び出しスタックは下方向へ伸び、戻るたびに縮みます。
呼び出しスタックを理解することは、再帰コードのデバッグ、メモリ使用量の見積もり、深い再帰でのスタックオーバーフローエラーの回避に不可欠です。
import traceback
def outer():
inner()
def inner():
# Print the current call stack
traceback.print_stack()
outer()
# Shows: module -> outer -> innersysでスタックフレームを観察する
Python の sys モジュールには、実行時に呼び出しスタックを調べるためのツールがあります。sys._getframe(n) は、現在の関数から n 階層上のスタックフレームを返します。各フレームには、ローカル変数を格納する f_locals 辞書と、関数名を示す f_code.co_name があります。再帰関数の中にデバッグ用の出力を挿入すると、フレームが蓄積して消えていく様子を確認できます。
import sys
def countdown(n):
depth = 0
frame = sys._getframe(0)
while frame:
depth += 1
frame = frame.f_back
print(' ' * (n * 2) + f'countdown({n}) called, stack depth={depth}')
if n <= 0:
return
countdown(n - 1)
print(' ' * (n * 2) + f'countdown({n}) returning')
countdown(3)呼び出しスタック上で階乗を追跡する
呼び出しスタック上で factorial(4) を追跡してみましょう。呼び出しは蓄積されます。factorial(4) が factorial(3) を呼び、factorial(3) が factorial(2) を呼び、factorial(2) が factorial(1) を呼び、factorial(1) が factorial(0) を呼びます。ベースケースに到達した時点で、スタックには5つのフレームがあります。戻り時には巻き戻しが起こります。factorial(0) は1を返し、factorial(1) は1×1=1を返し、factorial(2) は2×1=2を返し、factorial(3) は3×2=6を返し、factorial(4) は4×6=24を返します。深さは n+1 で、空間計算量は O(n) です。
def factorial(n, indent=0):
prefix = ' ' * indent
print(prefix + f'-> factorial({n})')
if n == 0:
print(prefix + '<- returns 1')
return 1
result = n * factorial(n - 1, indent + 1)
print(prefix + f'<- returns {result}')
return result
factorial(4)スタックオーバーフロー:Pythonの再帰制限
呼び出しスタックが制限値(デフォルトでは約1000フレーム)を超えると、Python は RecursionError を発生させます。これは、無限再帰によってすべてのメモリが消費されるのを防ぐためです。入力サイズが n = 10^4 以上の問題では、深さが O(n) になる再帰解は、制限を引き上げない限りクラッシュします。反復版は、外側の関数用に1つのフレームだけを使うため、スタック領域の空間計算量が O(1) です。
import sys
print('Recursion limit:', sys.getrecursionlimit())
def deep_recursion(n):
if n == 0:
return 0
return 1 + deep_recursion(n - 1)
# Safe: within limit
try:
print(deep_recursion(900))
except RecursionError:
print('Overflow at 900')
# Overflow
try:
print(deep_recursion(2000))
except RecursionError:
print('RecursionError at 2000 — limit exceeded!')再帰制限を引き上げる
sys.setrecursionlimit(n) を使って Python の再帰制限を引き上げることはできますが、これは応急処置にすぎません。デフォルトの制限が存在するのは、各スタックフレームがメモリを消費するためです(CPython では通常、数百バイト)。制限を 10^6 に設定してから深さ 10^5 の再帰を呼び出すと、数百メガバイトのスタック領域が確保される可能性があります。通常の正しい解決策は、反復解に変換するか、メモ化を使って深さを減らすことです。
import sys
# Only increase when you are certain of the maximum depth
# and have confirmed it is safe
original = sys.getrecursionlimit()
sys.setrecursionlimit(5000)
def sum_to(n):
if n == 0:
return 0
return n + sum_to(n - 1)
print(sum_to(3000)) # Works with increased limit
sys.setrecursionlimit(original) # restore
print('Limit restored:', sys.getrecursionlimit())相互再帰における呼び出しスタック
相互再帰とは、関数 A が関数 B を呼び出し、関数 B が関数 A を呼び出すことです。呼び出しスタックには、A と B のフレームが交互に積まれます。このパターンは、偶奇判定や状態機械のシミュレーションに登場します。スタックの深さが有界である限り正しく動作しますが、単純な線形再帰よりも深さを把握しにくい場合があります。
def is_even(n):
if n == 0:
return True
return is_odd(n - 1)
def is_odd(n):
if n == 0:
return False
return is_even(n - 1)
# Stack alternates: is_even(4)->is_odd(3)->is_even(2)->is_odd(1)->is_even(0)
print(is_even(4)) # True
print(is_odd(5)) # True
print(is_even(7)) # False末尾呼び出しとPythonが最適化しない理由
末尾呼び出しとは、戻る直前に行われる最後の操作が再帰呼び出しであり、その後に計算が残っていないものです。Haskell や Scheme のような言語では、末尾呼び出しがループに最適化されます(末尾呼び出し最適化、TCO)。そのため、スタック領域の空間計算量は O(1) になります。Python は意図的に TCO を実装していません。Guido van Rossum が説明したように、デバッグ時に完全なスタックトレースを保持することのほうが、空間の節約より価値があると考えられたためです。したがって Python では、末尾再帰のコードでもスタック領域の空間計算量は O(n) になります。
# Tail-recursive factorial (accumulator pattern)
def factorial_tail(n, acc=1):
if n == 0:
return acc
return factorial_tail(n - 1, acc * n) # tail call
# In Python, this still uses O(n) stack space (no TCO)
# But it IS semantically tail-recursive
print(factorial_tail(6)) # 720
print(factorial_tail(10)) # 3628800
# Iterative version: same logic, O(1) stack
def factorial_iter(n):
acc = 1
while n > 0:
acc *= n
n -= 1
return acc
print(factorial_iter(10)) # 3628800再帰木を出力する
再帰木を可視化すると、重複する部分問題(メモ化の対象)がどこにあるかを特定しやすくなります。木を出力する簡単な方法は、レベルごとにスペースを2つ増やす indent パラメータを追加することです。各呼び出しの開始時に引数を出力し、終了時に戻り値を出力します。これを Fibonacci(5) に対して実行すると、指数関数的な分岐と呼び出しの重複がはっきりわかります。
def fib_traced(n, indent=0):
prefix = ' ' * indent
print(prefix + f'fib({n})')
if n <= 1:
print(prefix + f'=> {n}')
return n
result = fib_traced(n-1, indent+1) + fib_traced(n-2, indent+1)
print(prefix + f'=> {result}')
return result
fib_traced(4)
# Shows the branching tree with duplicated sub-problemsスタックの深さと空間計算量
どの再帰関数でも、呼び出しスタックの最大深度は、実行中のいずれかの時点における最大の再帰深度と等しくなります。この深度は、補助空間計算量に直接対応します。線形再帰(階乗、フィボナッチ、文字列反転)では、深度は O(n) です。分割統治アルゴリズム(マージソート、二分探索)では、深度は O(log n) です。木の走査では、深度は木の高さを h とすると O(h) です(平衡木では O(log n)、最悪の場合は O(n))。
# Recursion depth = space complexity
# Linear recursion: O(n) stack
def linear_depth(n):
if n == 0: return 0
return 1 + linear_depth(n - 1) # depth = n
# Logarithmic recursion: O(log n) stack
def log_depth(n):
if n <= 1: return 0
return 1 + log_depth(n // 2) # depth = log2(n)
print('n=32 linear depth:', 32)
print('n=32 log depth:', log_depth(32)) # 5
print('n=1024 log depth:', log_depth(1024)) # 10明示的なスタックで再帰を反復に変換する
どの再帰アルゴリズムも、Python のリストを使って呼び出しスタックを明示的に管理すれば、反復処理に変換できます。OS にフレームの管理を任せる代わりに、リストへ「タスク」を追加し、ループ内で取り出します。これにより Python の再帰制限を回避し、フレームごとのオーバーヘッドを減らせますが、コードはより複雑になります。先ほど見た、明示的なスタックを使う反復版 DFS は、まさにこのパターンに従っています。
# Recursive inorder traversal -> iterative with explicit stack
class TreeNode:
def __init__(self, val=0, left=None, right=None):
self.val = val
self.left = left
self.right = right
def inorder_iterative(root):
result = []
stack = []
curr = root
while curr or stack:
while curr:
stack.append(curr)
curr = curr.left
curr = stack.pop()
result.append(curr.val)
curr = curr.right
return result
root = TreeNode(4, TreeNode(2, TreeNode(1), TreeNode(3)), TreeNode(6))
print(inorder_iterative(root)) # [1, 2, 3, 4, 6]まとめ:呼び出しスタックと空間計算量
呼び出しスタックは、あらゆる再帰の背後にある隠れたデータ構造です。その深さは、再帰アルゴリズムの空間計算量に相当します。Python の上限は約1000なので、再帰の深さが O(n) になるアルゴリズムでは、制限を引き上げる(リスクがあります)か、反復処理に書き換える必要があります。面接で再帰コードを書くときは、呼び出しスタックによる空間計算量を必ず説明してください。たとえば、「再帰の深さのため、空間計算量は O(n) です」や「平衡木の走査なので O(log n) です」のように述べます。
理解度チェック
このレッスンで扱った Data Structures & Algorithms — Coding Interview Prep の概念について、理解度を確認しましょう。
レッスンのまとめ
このレッスンでは、再帰呼び出しのたびに、ローカル変数と戻りアドレスを保持するスタックフレームが作成されること、最大スタック深度が再帰の補助空間計算量に等しいこと、そしてPython の再帰制限(約1000)により、深さが O(n) になるアルゴリズムは大きな n に対して危険であり、明示的なスタックを使った反復処理に変換すべきことを学びました。次は、再帰解と反復解を比較し、それぞれをいつ使うべきかを説明します。
よくある質問
「コールスタックを可視化する」レッスンは無料ですか?
はい。「コールスタックを可視化する」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、Coding Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 Coding Interview Prepコースには全4レッスンが含まれています。
「コールスタックを可視化する」で何を学びますか?
Pythonのsysモジュールとprintによるトレースで、スタックフレームの増減を観察し、深い再帰におけるスタックオーバーフローのリスクを理解します。 ブラウザで直接実行するハンズオンコードでCoding Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
Coding Interview Prepを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのCoding Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン2/4です。
「コールスタックを可視化する」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このCoding Interview Prepレッスンでコードを書いて実行できますか?
はい。すべてのCoding Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- 再帰の枠組み:基底ケース、信頼、構築
- コールスタックを可視化する
- 再帰と反復のトレードオフ
- メモ化:再帰結果をキャッシュする