diff --git a/.auto-dev/issues/issue-42/prd.md b/.auto-dev/issues/issue-42/prd.md new file mode 100644 index 0000000..477b5a0 --- /dev/null +++ b/.auto-dev/issues/issue-42/prd.md @@ -0,0 +1,121 @@ +# PRD: DFS 遍历算法可视化 + +## 需求来源 + +Issue #42 — 用户通过 /submit 表单提交,要求实现 DFS(深度优先搜索)算法可视化。 + +## 算法定义 + +深度优先搜索(DFS)是一种用于遍历或搜索图/树的算法。从起始节点出发,沿着一条路径尽可能深地探索,直到无法继续时回溯,再探索下一条未访问的路径。 + +### 算法步骤(基于显式栈的迭代实现) + +1. 初始化:将起始节点压入栈,标记为已访问。 +2. 循环:弹出栈顶节点作为当前节点。 +3. 对当前节点的每个邻居: + - 如果邻居未访问,压入栈并标记已访问,记录树边。 + - 如果邻居已在栈中,记录回边(非树边)。 +4. 重复直到栈为空。 + +### 算法边界 + +- 图为无向连通图,节点用单字母标识(A-H)。 +- 邻接表表示,每条边权重可选(DFS 不使用权重,但保留以兼容图数据结构)。 +- 起始节点默认为 A。 +- 遍历模式:完整遍历(遍历所有可达节点)。 +- 时间复杂度 O(V + E),空间复杂度 O(V)。 + +## 输入规模与示例数据 + +默认图包含 7 个节点、10 条边,足以展示 DFS 的深度优先特性和回溯行为: + +``` +A -- B -- E +| | | +C -- D -- F + | + G +``` + +邻接表: +```js +{ + A: { B: 1, C: 1 }, + B: { A: 1, D: 1, E: 1 }, + C: { A: 1, D: 1 }, + D: { B: 1, C: 1, F: 1, G: 1 }, + E: { B: 1, F: 1 }, + F: { D: 1, E: 1 }, + G: { D: 1 } +} +``` + +## 可视化步骤 + +每一步包含以下信息: + +| 字段 | 说明 | +|------|------| +| `step` | 步骤编号 | +| `stack` | 当前栈内容(从底到顶) | +| `currentNode` | 当前正在探索的节点 | +| `visited` | 已访问节点集合 | +| `traversalOrder` | DFS 遍历序列(访问顺序) | +| `treeEdges` | 生成树的边 `[[u, v], ...]` | +| `backEdges` | 回边 `[[u, v], ...]` | +| `lastEdge` | 本步涉及的边 `[u, v]` 或 null | +| `phase` | `'explore'`(探索)或 `'backtrack'`(回溯) | +| `description` | 本步的人类可读说明 | + +### 典型步骤序列 + +1. 初始化:压入 A,visited = {A} +2. 弹出 A,探索邻居 B → 压入 B,treeEdge A-B +3. 弹出 B,探索邻居 D → 压入 D,treeEdge B-D +4. 弹出 D,探索邻居 C → 压入 C,treeEdge D-C +5. C 的邻居均已访问,回溯 +6. 弹出 D,探索邻居 F → 压入 F,treeEdge D-F +7. ...继续直到栈为空 + +## 交互控件 + +- **Play / Pause**:自动播放 / 暂停。 +- **Step Forward**:单步前进。 +- **Reset**:回到第 0 步。 +- 步骤说明面板:显示当前步骤描述。 +- 栈状态面板:可视化栈的内容。 +- 遍历结果面板:显示遍历序列和生成树边。 + +## 文件结构 + +- slug:`dfs` +- 目录:`src/animations/dfs/` +- 文件: + - `algorithm.js` — 纯计算模块,导出 `DEFAULT_GRAPH`、`DEFAULT_START`、`computeSteps(graph, start)`、`runAlgorithmTests()` + - `index.jsx` — React 动画组件,复用 Card/Button/Framer Motion + - `meta.js` — 元数据:`title`、`description`、`path`、`category='graph'`、`order=30` + +## 复杂度说明 + +- 算法时间复杂度:O(V + E) +- 算法空间复杂度:O(V) +- 步骤数上限:O(V + E),对默认 7 节点图约 20-30 步 + +## 验收清单 + +1. **构建**:`npm run build` 通过,无编译错误。 +2. **算法自检**:`node --input-type=module -e "import('./src/animations/dfs/algorithm.js').then(m => m.runAlgorithmTests())"` 通过。 +3. **自动发现**:首页自动出现 DFS 卡片(category=graph 分组),路由 `/animations/dfs` 可访问。 +4. **可视化正确性**: + - 起始节点 A 被正确标记和压栈。 + - 每一步栈内容、visited 集合、traversalOrder 与算法逻辑一致。 + - 树边覆盖所有可达节点。 + - 回边在遇到已访问邻居时正确记录。 + - 最终步骤显示完整遍历序列。 +5. **交互控件**: + - Play/Pause 按钮可切换状态,自动播放间隔约 1.6 秒。 + - Step Forward 在最后一步时不再前进。 + - Reset 回到第 0 步,播放状态重置。 + - 无死按钮、无重复按钮、无占位按钮。 +6. **布局留白**:卡片内容区域有正常顶部 padding(p-4/p-5/p-6),无 `pt-0`。 +7. **响应式**:两栏布局在 lg 断点以上生效,移动端单栏堆叠。 diff --git a/.auto-dev/issues/issue-42/qa-report.md b/.auto-dev/issues/issue-42/qa-report.md new file mode 100644 index 0000000..4a355c8 --- /dev/null +++ b/.auto-dev/issues/issue-42/qa-report.md @@ -0,0 +1,65 @@ +# QA Report: Issue #42 — DFS 遍历算法可视化 + +## 构建结果 + +- `npm run build` 通过,无编译错误。 +- 输出:421 modules transformed,dist/index.html + CSS + JS 生成成功。 + +## 算法自检结果 + +- `node --input-type=module -e "const m = await import('./src/animations/dfs/algorithm.js'); m.runAlgorithmTests();"` 通过。 +- 默认 7 节点图生成 20 步,遍历序列 A→C→D→G→F→E→B,6 条树边覆盖全部节点,2 条回边 (D-B, E-B)。 +- 单节点图、线性图、非连通图、无回边树图测试均通过。 + +## PRD 验收清单 + +| # | 验收项 | 结果 | +|---|--------|------| +| 1 | 构建通过 | ✅ | +| 2 | 算法自检通过 | ✅ | +| 3 | 自动发现:首页 graph 分组、路由 /animations/dfs | ✅ meta.js category='graph', path='/animations/dfs',App.jsx import.meta.glob 自动注册 | +| 4 | 可视化正确性 | ✅ 起始节点 A 压栈正确;每步栈/visited/traversalOrder 与算法一致;树边覆盖 7 节点;回边在遇已访问邻居时记录;最终步骤显示完整序列 | +| 5 | 交互控件 | ✅ Play/Pause 可切换(最后一步禁用);Step Forward 最后一步禁用;Reset 回到第 0 步并停止播放;无死/重复/占位按钮 | +| 6 | 布局留白 | ✅ 所有 CardContent 使用 p-4 或 p-5,无 pt-0 | +| 7 | 响应式 | ✅ lg:grid-cols-[1.35fr_1fr] 两栏,移动端单栏堆叠 | + +## UI 控件审计 + +- **Play/Pause 按钮**:文案随 playing 状态切换("▶ 播放" ↔ "⏸ 暂停"),语义一致。最后一步且未播放时 disabled,合理。 +- **Step Forward 按钮**:最后一步 disabled,防止越界。文案 "⏭ 前进" 与行为一致。 +- **Reset 按钮**:始终可用,点击后 currentStep=0、playing=false,行为正确。 +- **无用/重复/死按钮**:未发现。三个控件功能清晰不重复。 + +## 交互 Bug 审计 + +- **播放/暂停**:切换正常,自动播放间隔 1600ms(PRD 要求约 1.6s),到末尾自动停止。 +- **单步前进**:每步 +1,最后一步不再前进。 +- **回退**:无回退按钮(PRD 未要求),合理。 +- **重置**:回到第 0 步,播放状态清除。 +- **边界步骤**:第 0 步 Step Forward 可用(前进到 #1),最后一步 disabled。 +- **自动播放定时器**:useEffect 依赖 [playing, steps.length],playing=false 时 return undefined,playing=true 时设置 interval 并在 cleanup 中 clearInterval,无泄漏。 +- **路由**:/animations/dfs 由 AnimationLayout 包裹,带返回首页链接和标题。 +- **响应式**:移动端单栏堆叠,桌面端两栏,控件 flex-wrap 适配窄屏。 + +## 卡片布局留白审计 + +- 图结构 Card:`CardContent className="p-4"` — 正常。 +- 当前步骤 Card:`CardContent className="p-5"` — 正常。 +- 栈 Card:`CardContent className="p-5"` — 正常。 +- 遍历结果 Card:`CardContent className="p-5"` — 正常。 +- 读图方式 Card:`CardContent className="p-5"` — 正常。 +- 无 `pt-0`、`!pt-0` 或 `padding-top: 0`。 + +## 发现的问题 + +### 问题 1:回边虚线样式可能未生效(minor) + +- **位置**:`src/animations/dfs/index.jsx:49`,`getEdgeClass` 函数。 +- **现象**:回边使用 `stroke-dasharray-[6 4]` 类名,Tailwind v3 可能无法将此生成为 `stroke-dasharray: 6 4` CSS 属性。回边将显示为实线而非虚线。 +- **建议修复**:改为 Tailwind 的任意属性语法 `[stroke-dasharray:6_4]` 或使用 inline style。 +- **影响**:仅影响回边视觉样式(虚线 vs 实线),不影响算法正确性或功能。 +- **归属**:frontend。 + +## 结论 + +所有 PRD 验收项通过。发现 1 个 minor 视觉问题(回边虚线样式),不影响功能正确性和用户体验核心路径。QA 通过。 diff --git a/.auto-dev/status/issue-42.json b/.auto-dev/status/issue-42.json new file mode 100644 index 0000000..c0777da --- /dev/null +++ b/.auto-dev/status/issue-42.json @@ -0,0 +1,98 @@ +{ + "issue": 42, + "title": "[auto-dev] DFS遍历算法", + "pipeline": "auto-dev", + "current_stage": "pr_opened", + "current_owner": "qa", + "pinned_comment_id": 4364115996, + "pr_url": "https://github.com/fengwm64/AlgorithmVisualizations/pull/45", + "history": [ + { + "ts": "2026-05-02T15:17:57.195Z", + "from": "system", + "to": "pm", + "stage": "submitted", + "artifact": ".auto-dev/incoming/issue-42.md", + "message": "Issue submitted and queued for PM triage." + }, + { + "ts": "2026-05-02T15:18:16.304Z", + "from": "system", + "to": "pm", + "stage": "pm_triage", + "artifact": ".auto-dev/incoming/issue-42.md", + "message": "PM triage started." + }, + { + "ts": "2026-05-02T15:20:26.878Z", + "from": "pm", + "to": "algorithm", + "stage": "prd_done", + "artifact": ".auto-dev/issues/issue-42/prd.md", + "message": "PRD is ready." + }, + { + "ts": "2026-05-02T15:21:04.831Z", + "from": "pm", + "to": "algorithm", + "stage": "algorithm_designing", + "artifact": ".auto-dev/issues/issue-42/prd.md", + "message": "Algorithm design started." + }, + { + "ts": "2026-05-02T15:22:26.587Z", + "from": "algorithm", + "to": "frontend", + "stage": "algorithm_done", + "artifact": "src/animations/dfs/algorithm.js", + "message": "Algorithm module and tests are ready." + }, + { + "ts": "2026-05-02T15:23:22.535Z", + "from": "algorithm", + "to": "frontend", + "stage": "frontend_designing", + "artifact": ".auto-dev/issues/issue-42/prd.md", + "message": "Frontend design started." + }, + { + "ts": "2026-05-02T15:25:03.765Z", + "from": "frontend", + "to": "qa", + "stage": "frontend_done", + "artifact": "src/animations/dfs/index.jsx", + "message": "Frontend animation is ready." + }, + { + "ts": "2026-05-02T15:25:49.735Z", + "from": "frontend", + "to": "qa", + "stage": "qa_running", + "artifact": ".auto-dev/issues/issue-42/prd.md", + "message": "QA started." + }, + { + "ts": "2026-05-02T15:29:10.446Z", + "from": "qa", + "to": "qa", + "stage": "qa_passed", + "artifact": ".auto-dev/issues/issue-42/qa-report.md", + "message": "QA passed." + }, + { + "ts": "2026-05-02T15:29:45.735Z", + "from": "qa", + "to": "maintainer", + "stage": "pr_opened", + "artifact": ".auto-dev/issues/issue-42/qa-report.md", + "message": "PR opened by start.sh finalizer." + } + ], + "retry_count": { + "qa_to_frontend": 0, + "qa_to_algorithm": 0, + "frontend_to_algorithm": 0, + "algorithm_to_pm": 0, + "frontend_to_pm": 0 + } +} diff --git a/src/animations/dfs/algorithm.js b/src/animations/dfs/algorithm.js new file mode 100644 index 0000000..ae31d3c --- /dev/null +++ b/src/animations/dfs/algorithm.js @@ -0,0 +1,201 @@ +export const DEFAULT_GRAPH = { + A: { B: 1, C: 1 }, + B: { A: 1, D: 1, E: 1 }, + C: { A: 1, D: 1 }, + D: { B: 1, C: 1, F: 1, G: 1 }, + E: { B: 1, F: 1 }, + F: { D: 1, E: 1 }, + G: { D: 1 }, +}; + +export const DEFAULT_START = "A"; + +export function computeSteps(graph = DEFAULT_GRAPH, start = DEFAULT_START) { + const steps = []; + const visited = new Set(); + const stack = []; + const traversalOrder = []; + const treeEdges = []; + const backEdges = []; + + visited.add(start); + stack.push(start); + + steps.push({ + step: 0, + stack: [...stack], + currentNode: null, + visited: new Set(visited), + traversalOrder: [...traversalOrder], + treeEdges: treeEdges.map((e) => [...e]), + backEdges: backEdges.map((e) => [...e]), + lastEdge: null, + phase: "init", + description: `将起始节点 ${start} 压入栈`, + }); + + let stepCount = 1; + + while (stack.length > 0) { + const current = stack.pop(); + + if (visited.has(current) && !traversalOrder.includes(current)) { + traversalOrder.push(current); + } + + steps.push({ + step: stepCount++, + stack: [...stack], + currentNode: current, + visited: new Set(visited), + traversalOrder: [...traversalOrder], + treeEdges: treeEdges.map((e) => [...e]), + backEdges: backEdges.map((e) => [...e]), + lastEdge: null, + phase: "explore", + description: `弹出节点 ${current},开始探索`, + }); + + const neighbors = Object.keys(graph[current] || {}); + let foundUnvisited = false; + + for (const neighbor of neighbors) { + if (!visited.has(neighbor)) { + foundUnvisited = true; + visited.add(neighbor); + stack.push(neighbor); + treeEdges.push([current, neighbor]); + + steps.push({ + step: stepCount++, + stack: [...stack], + currentNode: current, + visited: new Set(visited), + traversalOrder: [...traversalOrder], + treeEdges: treeEdges.map((e) => [...e]), + backEdges: backEdges.map((e) => [...e]), + lastEdge: [current, neighbor], + phase: "explore", + description: `发现未访问邻居 ${neighbor},压入栈(树边 ${current}-${neighbor})`, + }); + } else { + const edgeKey1 = `${current}-${neighbor}`; + const edgeKey2 = `${neighbor}-${current}`; + const alreadyRecorded = backEdges.some( + ([u, v]) => + `${u}-${v}` === edgeKey1 || + `${u}-${v}` === edgeKey2 + ); + + if (!alreadyRecorded && !treeEdges.some(([u, v]) => `${u}-${v}` === edgeKey1 || `${u}-${v}` === edgeKey2)) { + backEdges.push([current, neighbor]); + + steps.push({ + step: stepCount++, + stack: [...stack], + currentNode: current, + visited: new Set(visited), + traversalOrder: [...traversalOrder], + treeEdges: treeEdges.map((e) => [...e]), + backEdges: backEdges.map((e) => [...e]), + lastEdge: [current, neighbor], + phase: "explore", + description: `邻居 ${neighbor} 已访问,记录回边 ${current}-${neighbor}`, + }); + } + } + } + + if (!foundUnvisited) { + steps.push({ + step: stepCount++, + stack: [...stack], + currentNode: current, + visited: new Set(visited), + traversalOrder: [...traversalOrder], + treeEdges: treeEdges.map((e) => [...e]), + backEdges: backEdges.map((e) => [...e]), + lastEdge: null, + phase: "backtrack", + description: `节点 ${current} 无未访问邻居,回溯`, + }); + } + } + + steps.push({ + step: stepCount++, + stack: [], + currentNode: null, + visited: new Set(visited), + traversalOrder: [...traversalOrder], + treeEdges: treeEdges.map((e) => [...e]), + backEdges: backEdges.map((e) => [...e]), + lastEdge: null, + phase: "done", + description: `遍历完成,访问顺序:${traversalOrder.join(" → ")}`, + }); + + return steps; +} + +export function runAlgorithmTests() { + const steps = computeSteps(); + const lastStep = steps[steps.length - 1]; + + console.assert(steps.length > 0, "步骤列表不应为空"); + console.assert(steps[0].phase === "init", "第一步应为初始化阶段"); + console.assert(lastStep.phase === "done", "最后一步应为完成阶段"); + console.assert(lastStep.stack.length === 0, "遍历完成后栈应为空"); + console.assert(lastStep.traversalOrder.length === 7, "默认图应遍历全部 7 个节点"); + + const allVisited = lastStep.visited; + console.assert(allVisited.size === 7, "应有 7 个已访问节点"); + for (const node of ["A", "B", "C", "D", "E", "F", "G"]) { + console.assert(allVisited.has(node), `节点 ${node} 应被访问`); + } + + console.assert(lastStep.traversalOrder[0] === "A", "遍历应从 A 开始"); + + console.assert(lastStep.treeEdges.length === 6, "生成树应有 6 条边(7 节点)"); + + console.assert(lastStep.backEdges.length >= 1, "应至少有 1 条回边"); + + const singleNodeSteps = computeSteps({ X: {} }, "X"); + const singleLast = singleNodeSteps[singleNodeSteps.length - 1]; + console.assert(singleLast.traversalOrder.length === 1, "单节点图应遍历 1 个节点"); + console.assert(singleLast.traversalOrder[0] === "X", "单节点图遍历节点应为 X"); + + const linearSteps = computeSteps( + { A: { B: 1 }, B: { A: 1, C: 1 }, C: { B: 1 } }, + "A" + ); + const linearLast = linearSteps[linearSteps.length - 1]; + console.assert(linearLast.traversalOrder.length === 3, "线性图应遍历 3 个节点"); + console.assert(linearLast.treeEdges.length === 2, "线性图生成树应有 2 条边"); + + const disconnectedSteps = computeSteps( + { A: { B: 1 }, B: { A: 1 }, C: { D: 1 }, D: { C: 1 } }, + "A" + ); + const disconnectedLast = disconnectedSteps[disconnectedSteps.length - 1]; + console.assert(disconnectedLast.traversalOrder.length === 2, "非连通图从 A 出发应只遍历 2 个节点"); + + const noBackEdgeSteps = computeSteps( + { A: { B: 1 }, B: { A: 1, C: 1 }, C: { B: 1 } }, + "A" + ); + const noBackEdgeLast = noBackEdgeSteps[noBackEdgeSteps.length - 1]; + console.assert(noBackEdgeLast.backEdges.length === 0, "树形图不应有回边"); + + for (let i = 0; i < steps.length; i++) { + console.assert(typeof steps[i].step === "number", `步骤 ${i} 应有数字类型 step`); + console.assert(Array.isArray(steps[i].stack), `步骤 ${i} stack 应为数组`); + console.assert(steps[i].visited instanceof Set, `步骤 ${i} visited 应为 Set`); + console.assert(Array.isArray(steps[i].traversalOrder), `步骤 ${i} traversalOrder 应为数组`); + console.assert(Array.isArray(steps[i].treeEdges), `步骤 ${i} treeEdges 应为数组`); + console.assert(Array.isArray(steps[i].backEdges), `步骤 ${i} backEdges 应为数组`); + console.assert(typeof steps[i].description === "string", `步骤 ${i} description 应为字符串`); + } + + console.log("DFS algorithm tests passed."); +} diff --git a/src/animations/dfs/index.jsx b/src/animations/dfs/index.jsx new file mode 100644 index 0000000..074903c --- /dev/null +++ b/src/animations/dfs/index.jsx @@ -0,0 +1,328 @@ +import React, { useEffect, useMemo, useState } from "react"; +import { motion } from "framer-motion"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { computeSteps, DEFAULT_GRAPH, DEFAULT_START } from "./algorithm"; + +const nodes = [ + { id: "A", x: 120, y: 80 }, + { id: "B", x: 280, y: 80 }, + { id: "E", x: 440, y: 80 }, + { id: "C", x: 120, y: 230 }, + { id: "D", x: 280, y: 230 }, + { id: "F", x: 440, y: 230 }, + { id: "G", x: 280, y: 370 }, +]; + +const nodeMap = Object.fromEntries(nodes.map((n) => [n.id, n])); + +const undirectedEdges = []; +for (const node of Object.keys(DEFAULT_GRAPH)) { + for (const neighbor of Object.keys(DEFAULT_GRAPH[node])) { + if (node < neighbor) { + undirectedEdges.push([node, neighbor]); + } + } +} + +function isTreeEdge(u, v, treeEdges) { + return treeEdges.some( + ([a, b]) => (a === u && b === v) || (a === v && b === u) + ); +} + +function isBackEdge(u, v, backEdges) { + return backEdges.some( + ([a, b]) => (a === u && b === v) || (a === v && b === u) + ); +} + +function isEdgeHighlight(u, v, lastEdge) { + if (!lastEdge) return false; + return (lastEdge[0] === u && lastEdge[1] === v) || + (lastEdge[0] === v && lastEdge[1] === u); +} + +function getEdgeClass(u, v, step) { + if (isEdgeHighlight(u, v, step.lastEdge)) return "stroke-amber-500 stroke-[3]"; + if (isTreeEdge(u, v, step.treeEdges)) return "stroke-emerald-500 stroke-[2.5]"; + if (isBackEdge(u, v, step.backEdges)) return "stroke-rose-400 stroke-[2] stroke-dasharray-[6 4]"; + return "stroke-slate-300 stroke-[1.5]"; +} + +function getNodeClass(nodeId, step) { + if (step.currentNode === nodeId) return "fill-amber-400 stroke-amber-600"; + if (step.visited.has(nodeId)) return "fill-indigo-100 stroke-indigo-500"; + return "fill-white stroke-slate-300"; +} + +function getNodeTextClass(nodeId, step) { + if (step.currentNode === nodeId) return "fill-amber-900"; + if (step.visited.has(nodeId)) return "fill-indigo-700"; + return "fill-slate-700"; +} + +function getPhaseLabel(phase) { + switch (phase) { + case "init": return "初始化"; + case "explore": return "探索"; + case "backtrack": return "回溯"; + case "done": return "完成"; + default: return phase; + } +} + +export default function DfsAnimation() { + const steps = useMemo(() => { + return computeSteps(DEFAULT_GRAPH, DEFAULT_START); + }, []); + + const [currentStep, setCurrentStep] = useState(0); + const [playing, setPlaying] = useState(false); + const step = steps[currentStep]; + + useEffect(() => { + if (!playing) return undefined; + const timer = window.setInterval(() => { + setCurrentStep((value) => { + if (value >= steps.length - 1) { + setPlaying(false); + return value; + } + return value + 1; + }); + }, 1600); + return () => window.clearInterval(timer); + }, [playing, steps.length]); + + return ( +
+ 从节点 A 出发,沿一条路径尽可能深地探索,直到无法继续时回溯,再探索下一条未访问的路径。使用显式栈实现迭代版本。 +
+{step.description}
+黄色节点为当前探索节点,紫色为已访问节点,绿色边为生成树边,红色虚线边为回边。栈中高亮项为栈顶元素。
+