🧩 LeetCode Hardを極める:複雑な7つの解法を徹底解説 🔗

LeetCodeのHard問題に挑むと、まるで高い壁にぶつかったように感じることがよくあります。しかし、動的計画法(DP)、バックトラッキング、グラフ理論といった理解しやすいパターンに分解することで、はるかに取り組みやすくなります。

この記事では、私がGoで解いたLeetCode Hard問題の中でも特に興味深い7問を紹介します。できるだけ実用的なガイドにするため、解説は**核心となる直感(Intuition)と計算量(Complexity)**に重点を置いて凝縮し、問題文から実際に動くコードへの飛躍を理解しやすくしました。

📑 目次 🔗


🔢 1. 60. Permutation Sequence(順列の配列) 🔗

❓ 問題 🔗

nn と kk が与えられたとき、11 から nn までの数字からなる順列のうち、辞書順で kthk^{\text{th}} 番目(kk 番目)の順列を返してください。 例えば、n=3n = 3 の場合、順列は "123", "132", "213", "231", "312", "321" となります。k=3k = 3 であれば、答えは "213" です。

💡 直感(Intuition) 🔗

すべての順列を生成すると遅すぎるため、階乗を活用した数学的アプローチを使います。

nn 個の数字がある場合、順列の総数は n!n! 通りです。最初の数字を固定すると、残りの数字の順列は (n−1)!(n-1)! 通りになります。したがって、kthk^{\text{th}} 番目の順列が (n−1)!(n-1)! 個ずつのどのブロックに属するかを計算すれば、最初の数字を特定できます。利用可能な数字のソート済みリストにおける次の数字のインデックスは、単純に (k−1)/(n−1)!(k-1) / (n-1)! となります。数字を1つ選んだら、kk を剰余 k mod (n−1)!k \bmod (n-1)! に更新して再帰的に処理します。

⏱️ 計算量 🔗

💻 Goによる解答 🔗

import "fmt"

func getPermutation(n int, k int) string {
	factorial := 1
	digits := []int{}

	// Calculate factorials and populate the digits slice
	for i := 1; i <= n; i++ {
		factorial *= i
		digits = append(digits, i)
	}

	return solvePermutation(n-1, k-1, factorial/n, digits)
}

func solvePermutation(n, k, factorial int, digits []int) string {
	// Find which block of permutations 'k' falls into
	index := k / factorial
	res := fmt.Sprintf("%d", digits[index])

	if n <= 0 {
		return res
	}

	// Update k for the next level: k % factorial
	// Update factorial for the next level: factorial / n
	// Remove the selected digit from the slice
	nextK := k % factorial
	nextFactorial := factorial / n
	remainingDigits := append(digits[:index], digits[index+1:]...)

	res += solvePermutation(n-1, nextK, nextFactorial, remainingDigits)
	return res
}

🔍 2. 10. Regular Expression Matching(正規表現マッチング) 🔗

❓ 問題 🔗

.(任意の1文字に一致)と *(直前の要素の0回以上の繰り返しに一致)をサポートする正規表現マッチングを実装してください。マッチングは入力文字列全体をカバーする必要があります。

💡 直感(Intuition) 🔗

これは古典的な動的計画法(DP)の問題です。メモ化(Memoization)を使って重複する計算を回避できます。

文字列 s とパターン p の任意の文字の組み合わせにおいて、パターンの次の文字が * である場合、2つの選択肢があります。

  1. スキップする: 直前の要素を0回使用するとみなす。
  2. マッチングを続ける: 現在の文字が一致している場合、s から1文字消費しつつ、さらに一致させる可能性があるため p の位置は * のまま維持する。

⏱️ 計算量 🔗

💻 Goによる解答 🔗

func isMatch(s string, p string) bool {
	sn, pn := len(s), len(p)
	memo := make([][]int, sn+1)
	for i := range memo {
		memo[i] = make([]int, pn+1)
	}
	return solveRegex(s, p, sn, pn, 0, 0, memo)
}

func solveRegex(s, p string, sn, pn, si, pi int, memo [][]int) bool {
	if memo[si][pi] == 1 { return false }
	if memo[si][pi] == 2 { return true }

	res := false
	if si == sn && pi == pn {
		res = true
	} else if pi+1 < pn && p[pi+1] == '*' {
		// Option A: Zero occurrences
		if solveRegex(s, p, sn, pn, si, pi+2, memo) {
			res = true
		// Option B: One or more occurrences (if current char matches)
		} else if si < sn && (p[pi] == '.' || p[pi] == s[si]) && solveRegex(s, p, sn, pn, si+1, pi, memo) {
			res = true
		}
	} else if si < sn && pi < pn && (p[pi] == '.' || p[pi] == s[si]) {
		// Normal character match
		res = solveRegex(s, p, sn, pn, si+1, pi+1, memo)
	}

	if res {
		memo[si][pi] = 2
	} else {
		memo[si][pi] = 1
	}
	return res
}

🌳 3. 124. Binary Tree Maximum Path Sum(二分木の最大パス和) 🔗

❓ 問題 🔗

空でない二分木が与えられたとき、最大パス和を求めてください。パスは親子間の接続を上下に辿ることができますが、根(root)を通る必要はありません。

💡 直感(Intuition) 🔗

深さ優先探索(DFS)を使用します。すべてのノードにおいて、2種類のパスを評価します。

  1. 拡張可能なパス(fromRoot): 現在のノードから始まり、どちらか一方の枝を下っていく最大パス。親ノードはこのパスを繋げて利用できます。
  2. 拡張不可能なパス(other): 現在の部分木の内部に完全に収まっている最大パス(例:左の子から現在のノードへ上がり、右の子へと下るアーチ状のパス)。このパスは「閉じて」おり、さらに上の親ノードへ伸ばすことはできません。

全体の最大値は、見つかったすべての「拡張不可能なパス」の最大値、または根ノードの「拡張可能なパス」のどちらか大きい方になります。

⏱️ 計算量 🔗

💻 Goによる解答 🔗

import "math"

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

func maxPathSum(root *TreeNode) int {
	fromRoot, other := solveTree(root)
	if fromRoot > other {
		return fromRoot
	}
	return other
}

func solveTree(root *TreeNode) (int, int) {
	if root == nil {
		return math.MinInt, math.MinInt
	}

	left, otherLeft := solveTree(root.Left)
	right, otherRight := solveTree(root.Right)

	// Extendable path (can only pick one branch or stop at node)
	currentFromRoot := max([]int{
		root.Val,
		sum([]int{root.Val, left}),
		sum([]int{root.Val, right}),
	})

	// Closed path (can arch over the current node, or be completely inside a child)
	currentOther := max([]int{
		left, right, otherLeft, otherRight,
		sum([]int{root.Val, left, right}),
	})

	return currentFromRoot, currentOther
}

func max(s []int) int {
	res := math.MinInt
	for _, v := range s {
		if v > res { res = v }
	}
	return res
}

func sum(s []int) int {
	res := 0
	for _, v := range s {
		if v == math.MinInt { return math.MinInt }
		res += v
	}
	return res
}

🧮 4. 37. Sudoku Solver(数独ソルバー) 🔗

❓ 問題 🔗

9x9の数独パズルを解くプログラムを書いてください。標準的な数独のルール(行、列、および3x3のサブボックス)を満たす必要があります。

💡 直感(Intuition) 🔗

これにはバックトラッキングアルゴリズムが必要です。効率を高めてLeetCodeでの制限時間超過(TLE)エラーを回避するため、ヒューリスティックを用いて探索を最適化します。具体的には、*「配置可能な有効な数字の候補が最も少ない空きマスから常に優先して埋める」*ようにします。ある数字が行、列、または3x3のボックス内で利用可能かどうかを即座に判定するために、真偽値(boolean)の配列を使用します。

⏱️ 計算量 🔗

💻 Goによる解答 🔗

func solveSudoku(board [][]byte) {
	var rows, cols [9][9]bool
	var boxes [3][3][9]bool

	// Populate initial state
	for i := 0; i < 9; i++ {
		for j := 0; j < 9; j++ {
			if board[i][j] != '.' {
				idx := board[i][j] - '1'
				rows[i][idx], cols[j][idx], boxes[i/3][j/3][idx] = true, true, true
			}
		}
	}
	backtrackSudoku(board, &rows, &cols, &boxes)
}

func backtrackSudoku(board [][]byte, rows, cols *[9][9]bool, boxes *[3][3][9]bool) bool {
	bestI, bestJ := -1, -1
	minOptions := 10 // Start higher than the max possible options (9)

	// Heuristic: Find the cell with the fewest valid options
	for i := 0; i < 9; i++ {
		for j := 0; j < 9; j++ {
			if board[i][j] == '.' {
				options := 0
				for val := 0; val < 9; val++ {
					if !rows[i][val] && !cols[j][val] && !boxes[i/3][j/3][val] {
						options++
					}
				}
				if options < minOptions {
					minOptions = options
					bestI, bestJ = i, j
				}
			}
		}
	}

	// Base case: No empty cells left
	if bestI == -1 { return true }

	// Try placing valid numbers in the chosen optimal cell
	for val := 0; val < 9; val++ {
		if !rows[bestI][val] && !cols[bestJ][val] && !boxes[bestI/3][bestJ/3][val] {
			board[bestI][bestJ] = byte(val + '1')
			rows[bestI][val], cols[bestJ][val], boxes[bestI/3][bestJ/3][val] = true, true, true

			if backtrackSudoku(board, rows, cols, boxes) {
				return true
			}

			// Backtrack
			board[bestI][bestJ] = '.'
			rows[bestI][val], cols[bestJ][val], boxes[bestI/3][bestJ/3][val] = false, false, false
		}
	}
	return false
}

🎓 5. 1494. Parallel Courses II(並列コース履修 II) 🔗

❓ 問題 🔗

整数 nn が与えられ、11 から nn まで番号付けされた nn 個のコースがあることを示しています。また、履修の前提条件を表す配列 relations(relations[i] = [prevCourse, nextCourse])が与えられます。1学期につき最大 kk 個のコースを履修できます。すべてのコースを履修するのに必要な最小の学期数を返してください。

💡 直感(Intuition) 🔗

nn が小さい(n≤15n \leq 15)ため、幅優先探索(BFS)とビットマスクを組み合わせて使うことができます。 ビットマスクは、すでに履修を終えたコースの状態を表します。各学期において、履修可能なすべてのコース(前提条件がすべて満たされているコース)を特定します。履修可能なコースが kk 個を超える場合は、部分マスクの列挙を用いてサイズ kk のすべての可能な組み合わせを探索する必要があります。同じ組み合わせを再訪問しないように added(訪問済み)マップを使用します。

⏱️ 計算量 🔗

💻 Goによる解答 🔗

func minNumberOfSemesters(n int, relations [][]int, k int) int {
	// prerequisites[i] is a bitmask of courses that must be taken before course i
	prerequisites := make([]int, n)
	for _, relation := range relations {
		prevCourse := relation[0] - 1
		nextCourse := relation[1] - 1
		prerequisites[nextCourse] |= 1 << prevCourse
	}

	// states and costs act as a BFS queue
	states := []int{0}
	costs := []int{0}
	added := map[int]bool{0: true}

	for cur := 0; ; cur++ {
		state := states[cur]
		cost := costs[cur]

		// Base case: All courses have been taken
		if state == (1<<n)-1 {
			return cost
		}

		// Find all courses available to take this semester
		courses := []int{}
		next := state
		for course := 0; course < n; course++ {
			bit := 1 << course
			if (state&bit) == 0 && (prerequisites[course]|state) == state {
				courses = append(courses, course)
				next |= bit
			}
		}

		add := func(next int) {
			if added[next] {
				return
			}
			added[next] = true
			states = append(states, next)
			costs = append(costs, cost+1)
		}

		// If we can take all available courses in one semester, do so
		if len(courses) <= k {
			add(next)
			continue
		}

		// Otherwise, explore all combinations of size k
		var addStates func(next, k, from int)
		addStates = func(next, k, from int) {
			if k == 0 {
				add(next)
				return
			}
			for i := from; i < len(courses); i++ {
				addStates(next|(1<<courses[i]), k-1, i+1)
			}
		}
		addStates(state, k, 0)
	}
}

📐 6. 363. Max Sum of Rectangle No Larger Than K(Kを超えない長方形の最大和) 🔗

❓ 問題 🔗

m×nm \times n の行列と整数 kk が与えられたとき、その合計が kk 以下となるような行列内の長方形の最大和を返してください。

💡 直感(Intuition) 🔗

左右の列の境界を固定することで、2次元行列を1次元配列に圧縮できます。固定した列の間の各行の合計を表す1次元配列が得られれば、問題は「合計が kk 以下となる部分配列を求める問題」に帰着します。累積和(Prefix Sum)のソート済みリストを維持する(二分探索木やソート済み配列への挿入を使う)ことで、current_prefix - past_prefix <= k を満たす最小の過去の累積和を効率的に検索できます。あるいは、2次元累積和テーブルを事前計算しておき、任意の部分長方形の和を O(1)O(1) 時間で求めることも可能です。

⏱️ 計算量 🔗

💻 Goによる解答 🔗

import "math"

func maxSumSubmatrix(matrix [][]int, k int) int {
	n := len(matrix)
	m := len(matrix[0])
	res := math.MinInt
	sums := make([][]int, n)
	for i := 0; i < n; i++ {
		sums[i] = make([]int, m)
	}

	// Precompute 2D prefix sums
	sums[0][0] = matrix[0][0]
	for i := 1; i < n; i++ {
		sums[i][0] = matrix[i][0] + sums[i-1][0]
	}
	for j := 1; j < m; j++ {
		sums[0][j] = matrix[0][j] + sums[0][j-1]
	}
	for i := 1; i < n; i++ {
		for j := 1; j < m; j++ {
			sums[i][j] = matrix[i][j] + sums[i-1][j] + sums[i][j-1] - sums[i-1][j-1]
		}
	}

	// Evaluate all possible subrectangles using inclusion-exclusion
	for starti := 0; starti < n; starti++ {
		for startj := 0; startj < m; startj++ {
			for endi := starti; endi < n; endi++ {
				for endj := startj; endj < m; endj++ {
					sum := sums[endi][endj]
					if starti > 0 {
						sum -= sums[starti-1][endj]
					}
					if startj > 0 {
						sum -= sums[endi][startj-1]
					}
					if starti > 0 && startj > 0 {
						sum += sums[starti-1][startj-1]
					}
					if sum <= k && sum > res {
						res = sum
					}
				}
			}
		}
	}
	return res
}

🔗 7. 685. Redundant Connection II(冗長な接続 II) 🔗

❓ 問題 🔗

有向グラフにおいて、親を持たない根ノードを除くすべてのノードがちょうど1つの親を持っています。ここに余分な有向辺が1つ追加されました。結果として得られるグラフが有効な根付き木になるように削除できる辺を見つけてください。複数の答えがある場合は、入力の最後に現れるものを返してください。

💡 直感(Intuition) 🔗

有向グラフであるため、追加された辺によって次の2つの問題のうちいずれか(または両方)が発生します。

  1. あるノードが2つの親を持つようになる。
  2. サイクル(閉路)が形成される。

まず、2つの親を持つノードが存在するかどうかをスキャンします。存在する場合、冗長な辺はそのノードに向かっている2本の辺のどちらか一方であるはずです。そうでない場合、余分な辺は有向サイクルを形成しています。入力の逆順で候補の辺をテストし、その候補の辺を取り除いたときに、根からすべての NN 個のノードにサイクルなしで到達できる有効な根付き木になるかどうかをDFS(またはUnion-Find)で検証できます。

⏱️ 計算量 🔗

💻 Goによる解答 🔗

func findRedundantDirectedConnection(edges [][]int) []int {
	n := len(edges)
	parents := map[int][]int{}
	outs := map[int][]int{}
	for _, edge := range edges {
		src := edge[0] - 1
		dst := edge[1] - 1
		parents[dst] = append(parents[dst], src)
		outs[src] = append(outs[src], dst)
	}

	roots := map[int]bool{}
	bad := -1
	for i := 0; i < n; i++ {
		if len(parents[i]) == 0 {
			roots[i] = true
		}
		if len(parents[i]) > 1 {
			bad = i
		}
	}

	if len(roots) == 0 {
		for i := 0; i < n; i++ {
			if check(n, outs, i, -1, -1, map[int]bool{}) {
				roots[i] = true
			}
		}
	}

	if len(roots) > 1 {
		for i := len(edges) - 1; i >= 0; i-- {
			edge := edges[i]
			src := edge[0] - 1
			dst := edge[1] - 1
			if roots[dst] {
				return []int{src + 1, dst + 1}
			}
		}
	}

	for root := range roots {
		if len(parents[root]) > 0 {
			return []int{parents[root][0] + 1, root + 1}
		}
		for from := len(parents[bad]) - 1; from >= 0; from-- {
			if check(n, outs, root, parents[bad][from], bad, map[int]bool{}) {
				return []int{parents[bad][from] + 1, bad + 1}
			}
		}
	}
	return []int{-1, -1}
}

func check(n int, outs map[int][]int, from, badfrom, badto int, visited map[int]bool) bool {
	if visited[from] {
		return false
	}
	visited[from] = true
	if len(visited) == n {
		return true
	}
	for _, to := range outs[from] {
		if from == badfrom && to == badto {
			continue
		}
		if check(n, outs, to, badfrom, badto, visited) {
			return true
		}
	}
	return false
}

この解説はいかがでしたか? LeetCodeのHard問題には忍耐が必要ですが、背後にある基本パターンを学ぶことで再現性のあるプロセスとして解けるようになります。引き続きコードを書き、練習を重ねてください。面接での健闘を祈ります!


📚 無料電子書籍をダウンロード 🔗

カウエの生き方のオフライン版を入手しましょう——🍏 健康、💰 富、❤️ 愛、🧘 意味を最適化するための実践ガイドです。

カウエの生き方

カウエの生き方

📬 ニュースレターを購読する 🔗

新しいエッセイ、本の要約、より良く生きるための実践ガイドを公開した際にお知らせします。スパムはありません——いつでも配信停止できます。


💬 コメントとディスカッション 🔗

質問、ご意見、提案はありますか?ぜひ下であなたの考えを共有してください。コメント欄は GitHub Discussions(広告なし・スパムなし)を利用しています——下の GitHub でサインイン をクリックしてコメントやリアクションを残してください(無料の GitHub アカウントは1分未満で作成できます)。



最終更新日: 2026/10/11.

免責事項:当サイトのコンテンツは情報提供および教育のみを目的としており、医療、心理、金融、投資、税務、または法律上の助言を構成するものではありません。ここに掲載されている考え、キュレーション、意見はすべて私個人のものであり、雇用主の見解を代表するものではありません。書籍、講演、記事の要約は独立した教育的な解説であり、それぞれの著者の知的財産です。編集、フォーマット、要約の補助としてAIツールを使用する場合がありますが、正確性を期すよう努めているものの誤りが含まれる可能性があります。プライバシー:当サイトでは、任意のニュースレター購読(follow.it)、コメント機能(giscus / GitHub Discussions)、および埋め込みメディア(YouTube)に外部サービスを利用しており、これらのサービスはそれぞれのプライバシーポリシーに従って訪問者のIPアドレスやCookieを処理する場合があります。