關係圖、組織樹、相依圖與帳戶網路常被當成「多跑幾層的樹」,但只要資料允許回邊、共享子節點或錯誤自我連結,它就是圖,不再是單純樹。沒有明確身分去重的遞迴可能重複走訪同一節點,深鏈可能耗盡呼叫堆疊,寬廣樞紐則可能在單次展開中突破外層上限,讓 API 回傳量、序列化與前端渲染一起失控。
本文聚焦 C# 關係圖遍歷 的三個正確性契約:以 HashSet 記錄已發現節點、以 Queue 做迭代式廣度優先搜尋,以及在吸收每一個鄰接節點時立即執行硬上限。內容屬資訊型搜尋意圖,位於 TOFU/MOFU;工程 KPI 應追蹤重複節點率、走訪節點數、被上限截斷次數、回應 payload 大小與錯誤率,而不是宣稱某種圖演算法必然更快。
C# 關係圖遍歷為何不能直接當成樹遞迴?
只要節點可能回到祖先、指向自己或被多條路徑共享,遍歷就必須以圖的身分與終止條件設計;否則同一節點會被反覆展開,遞迴深度與輸出量都不再受資料層級控制。
樹假設每個節點只有一條由根抵達的路徑,且子節點不會再指回祖先。真實關係資料常不符合這兩點:兩個節點可能互相引用;多個父節點可能共用同一個後代;資料修正前可能存在自我連結;查詢又可能把雙向關係合併成同一張圖。此時只寫 Visit(child),等於把資料品質當成控制流程的一部分。
簡單循環 A → B → C → A 已足以讓沒有 guard 的遞迴永遠沿著同一路徑前進。.NET 的 StackOverflowException 文件指出,執行堆疊耗盡會產生該例外;實務上不應把「等它堆疊溢位再處理」當成保護策略。正確邊界要在進入節點前建立,而不是在失敗後補救。
第二種風險是共享後代。假設 left 與 right 都指向 shared,若沒有 visited 集合,shared 會被加入兩次、計算兩次或序列化兩次。這不一定立即崩潰,卻會悄悄破壞計數、權限彙整與輸出契約。驗收不能只看程式是否結束,還要核對每個邏輯節點是否只出現一次。
因此,遍歷函式至少要明定四件事:節點身分如何比較、何時把節點標成已發現、採用何種等待結構、最多允許吸收多少節點。若這些規則散落在 controller、遞迴 helper 與序列化前處理中,任何一處漏掉都可能重新打開循環或膨脹路徑。
visited 應在入列時標記,還是出列後才標記?
應在節點第一次成功入列時立刻加入 visited,而不是等到出列或處理完成;這能阻止同一節點由多條邊重複進入等待佇列,讓「最多一次排程」成為可驗證契約。
HashSet.Add 在值尚不存在時回傳 true,已存在時回傳 false。這個布林結果正好可合併「檢查」與「登記」:只有 visited.Add(neighbor) 成功時才 enqueue。不要先呼叫 Contains,隔幾行後再呼叫 Add;單執行緒雖然通常仍正確,卻增加分支與維護空間,多執行緒版本更需要另外處理同步,不能把兩次呼叫當成原子操作。
身分策略必須先定義。若節點有穩定且唯一的邏輯 ID,可用 HashSet、HashSet 或專用 key record,並指定符合契約的 comparer。字串 ID 若區分大小寫,可使用 StringComparer.Ordinal;若業務規則不區分大小寫,應明確選用相對應 comparer,而不是依機器文化設定。若兩個不同物件實例代表同一資料列,使用預設參考身分會讓去重失效。
反過來說,若演算法處理的是已組裝完成的記憶體物件圖,而且「同一實例」才代表同一節點,才適合採用參考身分。不要在沒有分析物件所有權時,隨意以可變欄位覆寫 Equals 或 GetHashCode;物件加入 HashSet 後若參與雜湊的欄位改變,後續查找可能違反預期。
標記時點也會影響空間與重複工作。入列時標記代表每個節點最多進入 queue 一次;出列後才標記,則兩條父邊可在節點尚未處理前把它重複排入。最終輸出也許能再去重,但等待佇列、記憶體與前置計算已經被放大。對大型關係圖而言,這個差異正是「可控制工作量」與「先膨脹再整理」的分界。
為何節點上限必須放在鄰接吸收迴圈內?
只在外層 while 檢查上限不足以形成硬限制,因為單一高扇出節點可能一次加入大量鄰居;上限必須在每次接受新節點前檢查,並把根節點是否計入額度寫進契約。
常見寫法是 while (queue.Count > 0 && result.Count 是先進先出集合,因此在鄰接陣列順序固定時,範例的廣度優先結果可預測;但若鄰居來自未排序字典、資料庫無 ORDER BY 的結果或分散式來源,就不應把目前順序當成永久保證。需要穩定輸出時,應在入列前建立明確排序鍵,並接受排序成本。
迭代式 BFS、遞迴 DFS 與資料契約如何取捨?
未知深度或可能含循環的關係圖,優先使用顯式 queue 或 stack,讓等待量、上限與取消點可觀測;遞迴只適合深度已受可靠約束,而且仍必須有 visited 保護。
廣度優先搜尋適合需要逐層展開、最短邊數路徑或 UI lazy loading 的情境;深度優先搜尋則適合完成一條路徑後再回溯。兩者都能用迭代結構實作:BFS 使用 Queue,DFS 使用 Stack。選擇不應只看程式碼短不短,而要看輸出順序、記憶體上限與取消需求。
迭代式寫法不會自動保證資料正確。若起點不存在,需決定回空集合還是只回起點;若鄰接表含空值、空字串或未知 ID,應在資料邊界正規化;若節點在遍歷期間被其他執行緒修改,應先取不可變快照或建立同步策略。把遞迴改成 queue 只是控制流程重構,不是資料一致性的完整答案。
另外,不要把防循環與物件圖序列化混為一談。遍歷結果可避免重複節點,但回傳 DTO 若仍保留雙向 navigation,JSON serializer 仍可能遇到循環參考。對外回應應投影成節點表加邊表,或建立只朝單一方向的輸出模型,而不是把 ORM entity graph 原樣送出。
效能主張也要保守。visited 查詢通常適合用雜湊集合,但實際成本仍受 comparer、key 配置、圖密度與資料來源影響。本文不提供毫秒 benchmark,也不沿用來源筆記中的專案數量或速度;應用程式若要訂定 maxNodes,要以 payload 預算、序列化成本、前端容量與產品需求共同決定。
驗證與重現:固定 .NET 版本測試循環、上限與共享節點
以下 sandbox 以固定 .NET SDK 與純記憶體合成圖,分別驗證循環終止、內層硬上限及共享節點去重;不連接資料庫、快取、網站或正式環境。
前置條件為 .NET SDK 10.0.400 與 Microsoft.NETCore.App 10.0.11。請在全新的 GraphTraversalLab 目錄建立 global.json 與專案檔,確保版本不自動漂移。排程環境已實際執行下列三個模式及整體模式,全部結束碼為 0。
- 建立空白目錄,寫入固定 SDK 設定與專案檔;執行
dotnet --version,結果不是10.0.400就停止。 - 將下方
Program.cs完整保存。程式只建立小型字典、queue、set 與清單,不讀寫外部系統。 - 依序執行
cycle、limit、identity與all。任何命令非零結束、缺少對應 PASS、節點重複或結果超過五筆,都判定失敗。
global.json:
{"sdk":{"version":"10.0.400","rollForward":"disable"}}
GraphTraversalLab.csproj:
Exe
net10.0
enable
enable
10.0.11
Disable
Program.cs:
using System.Runtime.InteropServices;
static void Require(bool condition, string message)
{
if (!condition)
{
throw new InvalidOperationException(message);
}
}
static IReadOnlyList TraverseBounded(
IReadOnlyDictionary graph,
string start,
int maxNodes)
{
ArgumentOutOfRangeException.ThrowIfLessThan(maxNodes, 1);
var visited = new HashSet(StringComparer.Ordinal);
var queue = new Queue();
var result = new List(maxNodes);
visited.Add(start);
queue.Enqueue(start);
while (queue.Count > 0 && result.Count = maxNodes)
{
break;
}
if (visited.Add(neighbor))
{
queue.Enqueue(neighbor);
}
}
}
return result;
}
Console.WriteLine($"Runtime: {RuntimeInformation.FrameworkDescription}");
var mode = args.Length == 1 ? args[0] : "all";
Require(new[] { "all", "cycle", "limit", "identity" }.Contains(mode), "Unknown mode");
var checks = 0;
if (mode is "all" or "cycle")
{
var cycle = new Dictionary(StringComparer.Ordinal)
{
["A"] = ["B"],
["B"] = ["C"],
["C"] = ["A"]
};
var result = TraverseBounded(cycle, "A", 10);
Require(result.SequenceEqual(new[] { "A", "B", "C" }), "Cycle traversal was not finite or ordered");
Require(result.Distinct(StringComparer.Ordinal).Count() == result.Count, "Cycle emitted duplicates");
Console.WriteLine("PASS cycle: A,B,C visited once and traversal terminated");
checks++;
}
if (mode is "all" or "limit")
{
var star = new Dictionary(StringComparer.Ordinal)
{
["root"] = Enumerable.Range(1, 20).Select(number => $"n{number:D2}").ToArray()
};
var result = TraverseBounded(star, "root", 5);
Require(result.SequenceEqual(new[] { "root", "n01", "n02", "n03", "n04" }), "Inner-loop limit failed");
Require(result.Count == 5, "Traversal exceeded the exact node limit");
Console.WriteLine("PASS limit: a wide adjacency list stopped at exactly 5 nodes");
checks++;
}
if (mode is "all" or "identity")
{
var graph = new Dictionary(StringComparer.Ordinal)
{
["root"] = ["left", "right", "left"],
["left"] = ["shared"],
["right"] = ["shared"],
["shared"] = []
};
var result = TraverseBounded(graph, "root", 10);
Require(result.SequenceEqual(new[] { "root", "left", "right", "shared" }), "Stable identity or BFS order failed");
Require(result.Count(item => item == "shared") == 1, "Shared node emitted more than once");
Console.WriteLine("PASS identity: duplicate edges and shared descendants emitted one node each");
checks++;
}
Console.WriteLine($"PASS total checks: {checks}");
執行指令:
dotnet run --configuration Release -- cycle
dotnet run --configuration Release -- limit
dotnet run --configuration Release -- identity
dotnet run --configuration Release -- all
排程環境實際擷取的 all 輸出如下:
Runtime: .NET 10.0.11
PASS cycle: A,B,C visited once and traversal terminated
PASS limit: a wide adjacency list stopped at exactly 5 nodes
PASS identity: duplicate edges and shared descendants emitted one node each
PASS total checks: 3
這組測試不故意執行無限遞迴或製造真正的 stack overflow,因為驗證防護不需要先破壞程序。循環案例必須精確得到 A,B,C;上限案例必須精確得到根節點與前四個鄰居;共享節點案例必須只輸出一次 shared。三個條件分開測試,才能在回歸失敗時辨認是身分、額度還是順序契約遭到破壞。
官方查核、風險清單與結論
安全的圖遍歷不是「加一個 visited」就完成,而是把身分、標記時點、等待結構、硬上限、截斷政策與輸出形狀一起寫成契約,再用循環與寬圖測試驗收。
已成功開啟並查核的官方 HTTPS 文件包括:Microsoft 的 HashSet.Add API,支持以回傳值判斷元素是否首次加入;Microsoft 的 Queue API,說明其先進先出集合語意;以及 StackOverflowException API,說明執行堆疊耗盡的失敗類型。文章使用的網址如下:
- https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.hashset-1.add?view=net-10.0
- https://learn.microsoft.com/en-us/dotnet/api/system.collections.generic.queue-1?view=net-10.0
- https://learn.microsoft.com/en-us/dotnet/api/system.stackoverflowexception?view=net-10.0
發布或合併前可檢查:節點是否有穩定身分;visited 是否在入列時登記;根節點是否計入上限;上限是否在每次吸收鄰居前檢查;截斷是否可見;鄰接順序是否有契約;取消與逾時是否能停止資料來源;DTO 是否移除雙向循環;測試是否同時包含 cycle、wide hub、duplicate edge 與 shared descendant。
本篇來源完整讀自 T 槽筆記,只抽取可泛化的循環防護、逐筆上限與大圖風險;未帶入專案名稱、真實識別碼、網路位址、資料量、效能數字或個人成果。與既有 Parallel.For 文章的「平行結果槽位」、共用快取文章的「物件修改隔離」,以及無限清單文章的「DOM sentinel 診斷」不同,本文回答的是伺服器端關係圖如何保證有限、唯一且可截斷的遍歷結果。