Task SchedulerとGas Station
CPUのtask-schedulerにおけるクールダウン期間の問題と、循環するgas-stationの実現可能性問題に貪欲な考え方を適用します。
「Task SchedulerとGas Station」はCoddyKit上の無料DSA Interview Prepレッスンです。 これはレッスン4/4です。 下記で完全なレッスンを無料で読むことができます。その後、ブラウザ内の組み込みコードエディタと24時間対応のAIチューターでハンズオン演習できます。 これはDSA Interview Prep学習パスの一部であり、ウェブとCoddyKitアプリ全体で進捗が同期されます。 DSA Interview Prepコースには全4レッスンが含まれています。
Task Scheduler 問題
Task Scheduler(LeetCode 621)は、CPUタスクのリスト(それぞれ A〜Z のラベルが付いています)とクールダウン n が与えられたとき、すべてのタスクを完了するためのCPU区間の最小数を求める問題です。同じタスクを再度実行するまでに、少なくとも n 個の区間を空ける必要があります。アイドル区間は許可されています。クールダウンが2で、タスクが ['A','A','A','B','B','B'] の場合、答えは8です。スケジュールは A→B→idle→A→B→idle→A→B となります。
# Task Scheduler example
tasks = ['A','A','A','B','B','B']
n = 2 # cooldown
# One optimal schedule: A B _ A B _ A B
# Intervals: 1 2 3 4 5 6 7 8 → answer = 8
# Another example: tasks=['A','A','A','B','B','C'] n=2
# A B C A B _ A → 7 intervals
print('Understanding the cooldown constraint')
print('Same task needs n intervals gap between runs')Task Scheduler の貪欲法の公式
重要な着眼点は、合計時間が最も頻度の高いタスクによって決まることです。最頻タスクの出現回数を f、頻度が f のタスク数を max_count とすると、合計時間は max(len(tasks), (f-1) * (n+1) + max_count) です。この公式では、サイズ n+1 のフレームを f-1 個作成し、他のタスクを配置して、最後のサイクルを追加します。他のタスクですべてのアイドルスロットを埋められる場合(種類の異なるタスクが多数ある場合)は、アイドル時間なしですべてのタスクを実行します。
from collections import Counter
def least_interval(tasks, n):
count = Counter(tasks)
max_freq = max(count.values())
# How many tasks have the maximum frequency?
max_count = sum(1 for c in count.values() if c == max_freq)
# Formula: max of total tasks (no idle) or frame-based calculation
frame_time = (max_freq - 1) * (n + 1) + max_count
return max(len(tasks), frame_time)
print(least_interval(['A','A','A','B','B','B'], 2)) # 8
print(least_interval(['A','A','A','B','B','B'], 0)) # 6 (no cooldown)
print(least_interval(['A','A','A','A','B','C'], 3)) # 10この公式が成り立つ理由
スケジュールを、n+1 列のグリッドとして考えてみましょう。各行には、1つのタスクスロットと n 個のクールダウンスロットがあります。最頻タスク A(頻度 f)には f 行が必要です。最初と最後の出現の間には、n+1 個のスロットからなる完全なフレームが f-1 個あります。さらに、最大頻度のすべてのタスクを含む最後の不完全なフレームがあります。種類の異なるタスクが十分にあれば、それらですべてのアイドルスロットを埋められます。その場合、実際のタスク数がフレームに基づく時間を上回るため、2つのうち大きい方を採用します。
# Visualise frame structure for AAABBB, n=2
# Frame size = n+1 = 3
# f = 3 (A appears 3 times), max_count = 2 (A and B both appear 3 times)
# Grid:
# [A B _] ← frame 1
# [A B _] ← frame 2
# [A B ] ← last partial frame (max_count=2 cells)
# Total = (3-1)*3 + 2 = 6 + 2 = 8
# If tasks = AAAABBCC, n=2: max_freq=4 (A), max_count=1
# (4-1)*(2+1)+1 = 9+1 = 10
# But len(tasks)=8 < 10, so answer is 10
tasks2 = ['A','A','A','A','B','B','C','C']
from collections import Counter
count = Counter(tasks2)
mf = max(count.values())
mc = sum(1 for c in count.values() if c == mf)
print(f'Frame formula: ({mf}-1)*{2+1}+{mc} = {(mf-1)*(2+1)+mc}')
print(f'Max(len={len(tasks2)}, frame={max(len(tasks2),(mf-1)*3+mc)}) = {max(len(tasks2),(mf-1)*3+mc)}')ヒープによるシミュレーションの別解
ヒープを使ったシミュレーションでは、単なる合計数ではなく実際のスケジュールを求められます。各ステップで、実行可能なタスクのうち頻度が最も高いものを取り出します(最大ヒープ)。実行後はクールダウンを適用し、n ステップ後までそのタスクを再挿入しません。クールダウン中のタスクを追跡するためにキューを使用します。計算量は O(total_time × log k) です。ここで k は異なるタスクの数です。正しい方法ですが、公式を使う方が高速です。両方の方法を知っておきましょう。面接官からスケジュール自体を求められることがあります。
import heapq
from collections import deque, Counter
def task_scheduler_simulate(tasks, n):
count = Counter(tasks)
heap = [-c for c in count.values()] # max-heap using negation
heapq.heapify(heap)
time = 0
cooldown = deque() # (available_at, neg_count)
while heap or cooldown:
time += 1
if heap:
c = heapq.heappop(heap) + 1 # use one instance
if c < 0: # still has remaining tasks
cooldown.append((time + n, c))
if cooldown and cooldown[0][0] == time:
heapq.heappush(heap, cooldown.popleft()[1])
return time
print(task_scheduler_simulate(['A','A','A','B','B','B'], 2)) # 8Gas Station 問題
Gas Station(LeetCode 134)は、円環状に n 個のガソリンスタンドがある問題です。スタンド i には gas[i] のガソリンがあり、次のスタンドまで移動するには cost[i] の燃料が必要です。タンクが空の状態から開始し、周回を完了できる出発スタンドを求めます。そのようなスタンドが存在しない場合は -1 を返します。解が存在する場合、その解は高々1つであることが保証されています。
# Example:
gas = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
# net gain per station: gas[i] - cost[i]
net = [g - c for g, c in zip(gas, cost)]
print('Net gain per station:', net) # [-2, -2, -2, 3, 3]
# Only possible start: station 3 (index 3)
# Tank: 0 +3=3 → 3-1=2 → 2+1=3-2=... let's verify
print('Sum of net:', sum(net)) # 1 > 0 means solution existsGas Station の貪欲解法
貪欲アルゴリズムは次のとおりです。(1) 合計ガソリン量 < 合計コストなら、解は存在しないため -1 を返します。(2) そうでなければ、解はちょうど1つ存在します。1回の走査で解を求めます。現在の燃料量を表す tank と、出発候補のスタンドを表す start を追跡します。あるスタンドを訪れた後に tank < 0 となった場合、現在の start からそのスタンドには到達できません。そのため、tank = 0 にリセットし、start = i + 1 と設定します。最後の start が答えです。
def can_complete_circuit(gas, cost):
if sum(gas) < sum(cost):
return -1 # impossible
tank = 0
start = 0
for i in range(len(gas)):
tank += gas[i] - cost[i]
if tank < 0:
tank = 0
start = i + 1 # current start failed, try next
return start
gas = [1, 2, 3, 4, 5]
cost = [3, 4, 5, 1, 2]
print(can_complete_circuit(gas, cost)) # 3
gas2 = [2, 3, 4]
cost2 = [3, 4, 3]
print(can_complete_circuit(gas2, cost2)) # -1貪欲法で出発地点を選べる理由
正しさは次のように説明できます。start からスタンド i に到達した後で tank が負になったとします。このとき、start から i まで(両端を含む)のどのスタンドも、有効な出発地点にはなれません。どのスタンドから開始しても、スタンド i に到達した時点の燃料は、start から開始した場合より少なくなるからです。したがって、これらをすべて安全に飛ばし、i+1 から試せます。合計ガソリン量 ≥ 合計コストなので解は存在し、最後に残った候補 start は必ず機能します。
# Proof sketch: why start=i+1 is correct after tank<0 at station i
# If we start at station j (start <= j <= i), tank at j is tank_from_start(j)
# After stations start..j: tank_from_j starts at 0, but we've already used gas[start..j-1]
# Starting at j means: tank_at_i = sum(net[j..i]) = sum(net[start..i]) - sum(net[start..j-1])
# Since sum(net[start..i]) < 0 AND sum(net[start..j-1]) >= 0 (no reset before i),
# tank_at_i when starting at j is even more negative → j cannot work either
def verify_gas_solution(gas, cost, start):
tank = 0
n = len(gas)
for i in range(n):
idx = (start + i) % n
tank += gas[idx] - cost[idx]
if tank < 0: return False
return True
print(verify_gas_solution([1,2,3,4,5],[3,4,5,1,2], 3)) # TrueGas Station における総当たり法と貪欲法
総当たり法では、各スタンドを出発地点として周回全体をシミュレーションするため、時間計算量は O(n²) です。1回の走査で済む貪欲解法は、時間計算量 O(n)、空間計算量 O(1) です。スタンドが 10⁵ 個ある配列では、10¹⁰ 回の操作と 10⁵ 回の操作の違いになります。貪欲法を可能にする重要な数学的性質は、燃料の合計収支が非負なら有効な出発地点が存在し、それは累積和が最後に負になった地点の直後のスタンドになるということです。
def brute_force_gas(gas, cost):
n = len(gas)
for start in range(n):
tank = 0
valid = True
for i in range(n):
idx = (start + i) % n
tank += gas[idx] - cost[idx]
if tank < 0: valid = False; break
if valid: return start
return -1
def greedy_gas(gas, cost):
if sum(gas) < sum(cost): return -1
tank = start = 0
for i, (g, c) in enumerate(zip(gas, cost)):
tank += g - c
if tank < 0: tank = 0; start = i + 1
return start
gas = [1,2,3,4,5]; cost = [3,4,5,1,2]
print('Brute:', brute_force_gas(gas,cost), '== Greedy:', greedy_gas(gas,cost))関連: Minimum Cost to Complete Trips
Minimum Time to Complete Trips(LeetCode 2187)は、解空間に対する二分探索の問題です。時間の値Tを二分探索します。時間Tが与えられたとき、time[i]のバスはfloor(T/time[i])回のトリップを完了できます。トリップの合計がtotalTrips以上であれば、Tは十分な時間です。そのようなTの最小値を求めます。これは、対象レベルで直接的な貪欲法の規則が存在しない場合でも、解の空間を二分探索するというメタレベルで貪欲法が適用できることを示しています。
def minimum_time(time, total_trips):
def can_complete(t):
return sum(t // bus for bus in time) >= total_trips
lo, hi = 1, min(time) * total_trips # upper bound
while lo < hi:
mid = (lo + hi) // 2
if can_complete(mid):
hi = mid
else:
lo = mid + 1
return lo
print(minimum_time([1, 2, 3], 5)) # 3 (3/1=3 + 3/2=1 + 3/3=1 = 5)
print(minimum_time([2], 1)) # 2境界ケースと検証
両方の問題で重要な境界ケースを確認します。Task Schedulerでは、クールダウンn=0の場合、アイドル時間は必要ないため、答えは単純にlen(tasks)です。すべてのタスクが同じ場合(例: すべて'A')、アイドルスロットはちょうど必要な分だけ埋まります。タスクの種類が多数ある場合は、タスクがすべてのフレームを埋めるため、アイドルスロットが0になることがあります。Gas Stationでは、ガスの合計とコストの合計がちょうど等しい場合、正しい開始地点は1つだけ存在します。1つのステーションだけで一周分のガスをまかなえる場合は、そのステーションが答えです。貪欲法で得た答えは、必ずこのような退化したケースでも検証してください。
from collections import Counter
def least_interval(tasks, n):
if n == 0: return len(tasks) # no cooldown
cnt = Counter(tasks)
mf = max(cnt.values())
mc = sum(1 for c in cnt.values() if c == mf)
return max(len(tasks), (mf-1)*(n+1)+mc)
# Edge cases for task scheduler
print(least_interval(['A','A','A'], 2)) # 7: A _ _ A _ _ A
print(least_interval(['A','A','B','B'], 0)) # 4: no idle
print(least_interval(['A','B','C','D'], 3)) # 4: all diff, no idle needed
# Edge case for gas station
def gas_station(gas, cost):
if sum(gas) < sum(cost): return -1
tank = start = 0
for i,(g,c) in enumerate(zip(gas,cost)):
tank += g-c
if tank < 0: tank=0; start=i+1
return start
print(gas_station([5,1,2,3,4],[4,4,1,5,1])) # 4貪欲法のパターン認識
Task SchedulerとGas Stationは、どちらも次の貪欲法のパターンに従います。(1) ボトルネック(最も頻度の高いタスク/正味の燃料収支)を特定します。(2) 追跡変数(max_freq、tank)を使って、1回の走査で判断します。(3) 制約に違反したら、再開またはリセットします。知っておくべき代表的な貪欲法の問題には、Activity Selection、Huffman Coding、Fractional Knapsack、Jump Game、Task Scheduler、Gas Station、Merge Intervalsがあります。それぞれに、交換論法または数学的な不変条件による証明があります。
# Greedy pattern summary
# Task Scheduler:
# Bottleneck: max frequency task
# Formula: max(total_tasks, (max_freq-1)*(n+1)+max_count)
# O(n) time, O(1) space
# Gas Station:
# Bottleneck: running sum of (gas-cost) going negative
# Reset start when tank < 0, valid if total sum >= 0
# O(n) time, O(1) space
# Both avoid the need for DP by using a clever single-pass insight
from collections import Counter
def combined_demo(tasks, n, gas, cost):
ti = max(len(tasks), (max(Counter(tasks).values())-1)*(n+1) +
sum(1 for c in Counter(tasks).values() if c==max(Counter(tasks).values())))
tank = start = 0
gs = sum(g-c for g,c in zip(gas,cost)) >= 0
return ti, start if gs else -1理解度チェック
このレッスンで扱ったData Structures & Algorithms — Coding Interview Prepの概念について、理解度を確認しましょう。
レッスンのまとめ
このレッスンでは、Task Schedulerの答え = max(total_tasks, (max_freq-1)*(n+1)+max_count) — 最も頻度の高いタスクでフレームベースのグリッドを埋めることで導出できます、Gas Stationは1回の走査を行い、tankが負になったときは必ずstart=i+1にリセットします。ガスの合計がコストの合計以上であれば有効です、そしてどちらの問題も、全探索ではなく数学的な不変条件を特定することで、時間計算量O(n)、空間計算量O(1)で解けますということを学びました。次は、分割統治法のテンプレートと、マージソート以外への応用について学びます。
よくある質問
「Task SchedulerとGas Station」レッスンは無料ですか?
はい。「Task SchedulerとGas Station」の完全なテキストはこのウェブで無料で読めます。インタラクティブに演習し(組み込みコードエディタと24時間対応のAIチューター)、DSA Interview Prepコースの残りをアンロックするには、CoddyKit PROにアップグレードしてください。 DSA Interview Prepコースには全4レッスンが含まれています。
「Task SchedulerとGas Station」で何を学びますか?
CPUのtask-schedulerにおけるクールダウン期間の問題と、循環するgas-stationの実現可能性問題に貪欲な考え方を適用します。 ブラウザで直接実行するハンズオンコードでDSA Interview Prepを演習し、24時間対応のAIチューターがレッスンを進める中での質問に答えます。
DSA Interview Prepを始めるのに経験は必要ですか?
事前経験は必要ありません。CoddyKitのDSA Interview Prepは初級者から上級者向けに構成されているため、ここから始めるか最初から始めて、自分のペースで進むことができます。 これはレッスン4/4です。
「Task SchedulerとGas Station」レッスンにはどのくらい時間がかかりますか?
ほとんどのCoddyKitレッスンは約5~10分かかります。各レッスンはコンパクトでインタラクティブなので、着実に進歩し、ウェブとアプリ全体で正確に前回の場所から再開できます。
このDSA Interview Prepレッスンでコードを書いて実行できますか?
はい。すべてのDSA Interview Prepレッスンに組み込みコードエディタが含まれているため、ブラウザでリアルコードを書いて実行し、即座のAIフィードバックを取得できます。ローカル設定は不要です。
このコースのすべてのレッスン
- 貪欲法とDP:使い分け
- 区間スケジューリングとマージ
- Jump Game IとII
- Task SchedulerとGas Station