0Pricing
DSA Interview Prep · レッスン

データストリームの中央値とK-wayマージ

2つのヒープ(小さい半分の最大ヒープと大きい半分の最小ヒープ)で中央値をO(log n)で更新し、ヒープを使ってk個のソート済みリストをマージします。

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

データストリームの中央値問題

Find Median from Data Stream(LeetCode #295)では、addNum(num)で数値を追加し、findMedian()で現在の中央値を返すという2つの操作を効率よく実装します。要素数が偶数のリストでは、中央値は中央にある2つの値の平均です。ソート済みリストを単純に使う方法では、挿入にO(n)、中央値の取得にO(1)かかります。最適な解法では2つのヒープを使い、挿入をO(log n)、中央値の取得をO(1)で実現します。

import heapq

# Strategy: maintain two halves of the data
# max_heap: lower half (stores negated values for max behavior)
# min_heap: upper half
# Invariant: len(max_heap) == len(min_heap) or len(max_heap) == len(min_heap) + 1
# Invariant: max(max_heap) <= min(min_heap)
# Median:
#   odd count:  max_heap[0] (top of lower half)
#   even count: average of tops of both halves
print('Two-heap strategy for O(log n) insert, O(1) median')

2つのヒープによるMedianFinderの実装

下半分には最大ヒープ、上半分には最小ヒープを維持します。最大ヒープのサイズが最小ヒープと同じか、1つ多くなるように常に保ちます。数値を追加するときは、まず最大ヒープにプッシュします。次に、最大ヒープの先頭が最小ヒープの最小値を超えていれば、最大ヒープの先頭を最小ヒープへ移してバランスを取り、必要に応じてサイズも調整します。

import heapq

class MedianFinder:
    def __init__(self):
        self.lo = []  # max-heap (negated) for lower half
        self.hi = []  # min-heap for upper half

    def addNum(self, num):
        heapq.heappush(self.lo, -num)   # push to lower half
        # Ensure max of lower <= min of upper
        if self.hi and -self.lo[0] > self.hi[0]:
            heapq.heappush(self.hi, -heapq.heappop(self.lo))
        # Balance sizes: lo can have at most 1 more than hi
        if len(self.lo) > len(self.hi) + 1:
            heapq.heappush(self.hi, -heapq.heappop(self.lo))
        elif len(self.hi) > len(self.lo):
            heapq.heappush(self.lo, -heapq.heappop(self.hi))

    def findMedian(self):
        if len(self.lo) > len(self.hi):
            return -self.lo[0]  # odd count: top of lower half
        return (-self.lo[0] + self.hi[0]) / 2

mf = MedianFinder()
for n in [1, 2, 3, 4, 5]: mf.addNum(n)
print(mf.findMedian())  # 3.0

MedianFinderの手順を追う

2つのヒープの不変条件が維持される理由を理解することは、面接で解法を説明するうえで重要です。[5, 15, 1, 3]を追加する過程を順番に追ってみましょう。挿入するたびに、下半分を保持する最大ヒープが小さい方の要素を持つようにバランスを取ります。この不変条件により、常にmax(lo) <= min(hi)が成り立つため、中央値を一方または両方のヒープの先頭からすぐに取得できます。

import heapq

# Manual trace for [5, 15, 1, 3]:
# add 5:   lo=[-5]        hi=[]       median=5
# add 15:  lo=[-5]        hi=[15]     median=(5+15)/2=10
# add 1:   lo=[-5,-1]     hi=[15]     median=5
# add 3:   lo=[-5,-3,-1]  hi=[15]     -- lo too big
#       -> lo=[-5,-3]      hi=[1,15]  -- wait, wrong direction
# Actually:
# add 1:   push to lo -> lo=[-5,-1], then 1>lo? No, -lo[0]=5>15? No
#          lo has 2, hi has 1: balance -> move lo top to hi
#          lo=[-1], hi=[5,15]
# Median = (-lo[0] + hi[0])/2 = (1+5)/2 = 3
mf2 = MedianFinder()
for n, expected in [(5, 5.0), (15, 10.0), (1, 5.0), (3, 4.0)]:
    mf2.addNum(n)
    print(f'After adding {n}: median={mf2.findMedian()} (expected ~{expected})')

スライディングウィンドウの中央値

Sliding Window Median(LeetCode #480)は、配列上を移動するサイズkのすべてのウィンドウについて中央値を求める、より難しい応用問題です。2つのヒープによる方法を、ウィンドウから出ていく要素を処理する遅延削除集合と組み合わせます。要素がウィンドウから外れたら削除集合に記録し、その要素がどちらかのヒープの先頭に来た時点で破棄します。

import heapq

def median_sliding_window(nums, k):
    lo = []  # max-heap (negated)
    hi = []  # min-heap
    removed = {}
    result = []

    def balance():
        # Move valid tops to correct side
        while lo and removed.get(-lo[0], 0) > 0:
            removed[-lo[0]] -= 1; heapq.heappop(lo)
        while hi and removed.get(hi[0], 0) > 0:
            removed[hi[0]] -= 1; heapq.heappop(hi)

    for i, num in enumerate(nums):
        heapq.heappush(lo, -num)
        heapq.heappush(hi, -heapq.heappop(lo))
        if len(hi) > len(lo): heapq.heappush(lo, -heapq.heappop(hi))
        if i >= k:
            out = nums[i - k]
            removed[out] = removed.get(out, 0) + 1
        balance()
        if len(lo) > len(hi): heapq.heappush(hi, -heapq.heappop(lo))
        if i >= k - 1:
            if len(lo) > len(hi): result.append(float(-lo[0]))
            else: result.append((-lo[0] + hi[0]) / 2.0)
    return result

print(median_sliding_window([1,3,-1,-3,5,3,6,7], 3))  # [1,-1,-1,3,5,6]

K-wayマージ:問題

Merge K Sorted Lists(LeetCode #23)は、外部ソート、データベースのマージ、分散システムなどに応用される基本的な問題です。合計n個のノードを持つk個のソート済み連結リストが与えられたとき、それらを1つのソート済みリストにマージします。単純な方法(2つずつマージする方法)では、分割統治を使わない場合はO(kn)、分割統治を使う場合はO(n log k)です。ヒープを使う方法では、各ノードを正確に1回処理し、ノードごとの処理をO(log k)で行うため、合計でO(n log k)になります。

class ListNode:
    def __init__(self, val=0, next=None):
        self.val = val
        self.next = next

# Build a linked list from a Python list
def build_list(arr):
    dummy = ListNode(0)
    curr = dummy
    for val in arr:
        curr.next = ListNode(val)
        curr = curr.next
    return dummy.next

# Convert linked list to Python list for printing
def to_list(head):
    result = []
    while head:
        result.append(head.val)
        head = head.next
    return result

print('K-way merge: O(n log k) using a min-heap of k heads')

最小ヒープによるK-wayマージ

各リストの先頭ノードをヒープに入れて初期化します。各ステップで最小要素をポップして結果に追加し、そのリストの次のノードがあればプッシュします。ヒープの要素数は常に最大k個です。これは、アクティブな各リストにつき先頭要素を1つ持つためです。合計n個のノードをそれぞれO(log k)のヒープ操作で処理するため、計算量はO(n log k)、ヒープに必要な空間計算量はO(k)です。

import heapq

def merge_k_lists(lists):
    dummy = ListNode(0)
    curr = dummy
    heap = []
    for i, node in enumerate(lists):
        if node:
            heapq.heappush(heap, (node.val, i, node))
    while heap:
        val, i, node = heapq.heappop(heap)
        curr.next = node
        curr = curr.next
        if node.next:
            heapq.heappush(heap, (node.next.val, i, node.next))
    return dummy.next

lists = [
    build_list([1, 4, 5]),
    build_list([1, 3, 4]),
    build_list([2, 6])
]
result = merge_k_lists(lists)
print(to_list(result))  # [1, 1, 2, 3, 4, 4, 5, 6]

K個のリストを包含する最小範囲

Smallest Range(LeetCode #632)では、k個のソート済みリストそれぞれから少なくとも1つの要素を含む最小の範囲[lo, hi]を求めます。各リストの最初の要素で最小ヒープを初期化し、現在の最大値を追跡します。現在の最小値を持つリストを常に先へ進めることで、範囲を狭めます。いずれかのリストが終端に達したら停止します。

import heapq

def smallest_range(nums):
    heap = []
    current_max = float('-inf')
    for i, lst in enumerate(nums):
        heapq.heappush(heap, (lst[0], i, 0))
        current_max = max(current_max, lst[0])
    best = [float('-inf'), float('inf')]
    while heap:
        current_min, list_idx, elem_idx = heapq.heappop(heap)
        if current_max - current_min < best[1] - best[0]:
            best = [current_min, current_max]
        if elem_idx + 1 >= len(nums[list_idx]):
            break  # one list exhausted
        next_val = nums[list_idx][elem_idx + 1]
        heapq.heappush(heap, (next_val, list_idx, elem_idx + 1))
        current_max = max(current_max, next_val)
    return best

print(smallest_range([[4,10,15,24,26],[0,9,12,20],[5,18,22,30]]))
# [20, 24]

行列中のk番目に小さい要素

Kth Smallest Element in a Sorted Matrix(LeetCode #378)は、各行と各列がソートされたn×n行列から、k番目に小さい要素を求める問題です。各行をソート済みリストとみなし、ヒープを使ったk-wayマージを行います。別の方法として、値の範囲に対して二分探索を行うこともできます。ヒープによる方法はO(k log n)で、kが小さい場合に効率的です。二分探索はO(n log(max-min))で、大きなkに適しています。

import heapq

def kth_smallest_matrix(matrix, k):
    n = len(matrix)
    heap = [(matrix[0][0], 0, 0)]
    count = 0
    visited = {(0, 0)}
    while heap:
        val, r, c = heapq.heappop(heap)
        count += 1
        if count == k:
            return val
        # Push right neighbor
        if c + 1 < n and (r, c+1) not in visited:
            heapq.heappush(heap, (matrix[r][c+1], r, c+1))
            visited.add((r, c+1))
        # Push bottom neighbor
        if r + 1 < n and (r+1, c) not in visited:
            heapq.heappush(heap, (matrix[r+1][c], r+1, c))
            visited.add((r+1, c))
    return -1

matrix = [[1,5,9],[10,11,13],[12,13,15]]
print(kth_smallest_matrix(matrix, 8))  # 13

実行統計のための2つのヒープ

2つのヒープのパターンは、中央値以外にも一般化できます。たとえば、実行中の分位点(25パーセンタイルなど)を維持するために使えます。下側のヒープがp*n個の要素、上側のヒープが(1-p)*n個の要素を保持するようにサイズを調整します。要素が追加されるたびに、これまでと同じようにバランスを取り直します。このパターンは、効率的な挿入と分位点のクエリを同時に必要とするストリーミング統計の問題で使われます。

import heapq

# Generalised two-heap for arbitrary quantile p
# lo contains floor(p * count) elements
# hi contains the remaining elements
class QuantileFinder:
    def __init__(self, p):
        self.p = p  # quantile (e.g., 0.5 for median)
        self.lo = []  # max-heap
        self.hi = []  # min-heap
        self.count = 0

    def add(self, num):
        self.count += 1
        heapq.heappush(self.lo, -num)
        heapq.heappush(self.hi, -heapq.heappop(self.lo))
        # Target: lo should have floor(p * count) elements
        target_lo = int(self.p * self.count)
        while len(self.lo) < target_lo:
            heapq.heappush(self.lo, -heapq.heappop(self.hi))
        while len(self.lo) > target_lo:
            heapq.heappush(self.hi, -heapq.heappop(self.lo))

    def quantile(self):
        return -self.lo[0] if self.lo else self.hi[0]

qf = QuantileFinder(0.5)  # median
for n in [1, 2, 3, 4, 5, 6]: qf.add(n)
print(qf.quantile())  # 3 (median of 1-6)

原点に最も近いk個の点を見つける

K Closest Points to Origin(LeetCode #973)では、サイズkの最大ヒープを使います。各点の距離の二乗をプッシュします(平方根の計算を避けるためです)。ヒープのサイズがkを超えたら、最も遠い点をポップします。残ったk個の点が、原点に最も近いk個の点です。計算量はO(n log k)です。クイックセレクトを使えば平均O(n)にできますが、ヒープによる解法の方が正しく実装しやすく、面接でも説明しやすい方法です。

import heapq

def k_closest(points, k):
    heap = []  # max-heap via negation
    for x, y in points:
        dist_sq = x*x + y*y
        heapq.heappush(heap, (-dist_sq, x, y))
        if len(heap) > k:
            heapq.heappop(heap)  # remove farthest
    return [[x, y] for _, x, y in heap]

points = [[1,3], [-2,2], [5,8], [0,1], [-1,-1]]
print(k_closest(points, 2))
# Two closest to origin: [0,1] (dist=1) and [-1,-1] (dist=2)

# Verify by distances:
for x, y in points:
    print(f'({x},{y}): dist^2 = {x*x+y*y}')

2つのヒープ:時間計算量と空間計算量の分析

中央値に対する2つのヒープによる方法では、addNumあたりO(log n)、findMedianあたりO(1)を実現できます。すべての要素を格納するため、空間計算量はO(n)です。k-wayマージの計算量はO(n log k)、ヒープに必要な空間計算量はO(k)です。これらはほぼ最適です。k-wayマージには、比較に基づく下限としてOmega(n log k)があることを証明できるため、ヒープによる解法が漸近的に最適であることが分かります。面接では、これらの計算量を必ず明確に説明してください。

# Complexity summary for heap applications:
# Problem               | Time per op  | Space
# ----------------------|--------------|------
# MedianFinder.addNum   | O(log n)     | O(n)
# MedianFinder.find     | O(1)         | -
# Merge k sorted lists  | O(n log k)   | O(k)
# Kth smallest matrix   | O(k log n)   | O(n)
# K closest points      | O(n log k)   | O(k)
# Task scheduler        | O(n log 26)  | O(26)
# Kth largest stream    | O(log k)     | O(k)
# Sliding window median | O(n log k)   | O(k)

print('Heap problems: identify k (heap size) vs n (input size)')

理解度チェック

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

レッスンのまとめ

このレッスンでは、挿入O(log n)、中央値取得O(1)を実現する2つのヒープによるMedianFinder、O(n log k)の時間計算量とO(k)の空間計算量を持つ最小ヒープによるk-wayマージ、さらにスライディングウィンドウの中央値、最小範囲、k個の最近傍点などの応用を学びました。次はグラフの表現と走査の準備を扱います。

よくある質問

「データストリームの中央値とK-wayマージ」レッスンは無料ですか?

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

「データストリームの中央値とK-wayマージ」で何を学びますか?

2つのヒープ(小さい半分の最大ヒープと大きい半分の最小ヒープ)で中央値をO(log n)で更新し、ヒープを使ってk個のソート済みリストをマージします。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。

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

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

「データストリームの中央値とK-wayマージ」レッスンにはどのくらい時間がかかりますか?

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

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

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

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

  1. ヒープの性質と配列表現
  2. heapify、push、popをゼロから実装する
  3. Pythonのheapqと最大ヒープのテクニック
  4. データストリームの中央値とK-wayマージ
← DSA Interview Prepに戻る