|
| 1 | +# 2872. Maximum Number of K-Divisible Components |
| 2 | + |
| 3 | +There is an undirected tree with `n` nodes labeled from `0` to `n - 1`. |
| 4 | +You are given the integer `n` and a 2D integer array `edges` of length `n - 1`, |
| 5 | +where `edges[i] = [a_i, b_i]` indicates that there is an edge between nodes `a_i` and `b_i` in the tree. |
| 6 | + |
| 7 | +You are also given a 0-indexed integer array `values` of length `n`, where `values[i]` is the value associated with the $i^{th}$ node, and an integer `k`. |
| 8 | + |
| 9 | +A valid split of the tree is obtained by removing any set of edges, possibly empty, |
| 10 | +from the tree such that the resulting components all have values that are divisible by `k`, |
| 11 | +where the value of a connected component is the sum of the values of its nodes. |
| 12 | + |
| 13 | +Return the maximum number of components in any valid split. |
| 14 | + |
| 15 | +**Constraints:** |
| 16 | + |
| 17 | +- `1 <= n <= 3 * 10^4` |
| 18 | +- `edges.length == n - 1` |
| 19 | +- `edges[i].length == 2` |
| 20 | +- `0 <= a_i, b_i < n` |
| 21 | +- `values.length == n` |
| 22 | +- `0 <= values[i] <= 10^9` |
| 23 | +- `1 <= k <= 10^9` |
| 24 | +- Sum of `values` is divisible by `k`. |
| 25 | +- The input is generated such that `edges` represents a valid tree. |
| 26 | + |
| 27 | +## 基礎思路 |
| 28 | + |
| 29 | +題目給定一棵無向樹,每個節點有一個非負權重,要求透過刪除任意集合的邊(也可以一條都不刪),使得最後形成的所有連通元件,其「節點權重總和」都可以被給定的整數 `k` 整除,並在所有合法切割方案中,最大化連通元件的數量。 |
| 30 | + |
| 31 | +要理解這個問題,可以掌握以下幾個關鍵觀察: |
| 32 | + |
| 33 | +* **樹結構的任意切割等價於切斷若干邊** |
| 34 | + 由於輸入是一棵樹,不包含環,每切斷一條邊就會多出一個連通元件。因此,只要知道有哪些子樹的總和可以被 `k` 整除,就可以決定是否在該子樹與其父節點之間切邊。 |
| 35 | + |
| 36 | +* **子樹總和是否可被 `k` 整除是局部可決策的** |
| 37 | + 若某節點的整個子樹權重總和可被 `k` 整除,則可以在該子樹與父節點之間切斷邊,將這整個子樹視為一個獨立連通元件;反之,必須把這個子樹的「餘數部分」往上合併到父節點。 |
| 38 | + |
| 39 | +* **後序遍歷天然適合自底向上的子樹計算** |
| 40 | + 子樹總和必須包含所有子節點的貢獻,適合採用自底向上的 DFS 後序遍歷:先處理完所有子節點,再回到父節點,根據子樹總和是否能被 `k` 整除,決定是否增加一個新元件,並把餘數往上傳遞。 |
| 41 | + |
| 42 | +* **使用取餘而非原始總和即可判斷可否切割** |
| 43 | + 只需關心每棵子樹的權重總和對 `k` 的餘數: |
| 44 | + |
| 45 | + * 若餘數為 `0`,表示該子樹本身可以形成一個合法元件。 |
| 46 | + * 若餘數不為 `0`,則必須與父節點的餘數相加後再取餘,繼續向上合併。 |
| 47 | + |
| 48 | +* **迭代式 DFS 可避免遞迴堆疊風險** |
| 49 | + 在節點數上界為 `3 * 10^4` 的情況下,遞迴 DFS 雖然大多情況可行,但為了在語言層級避免呼叫堆疊限制,採用顯式堆疊的迭代式後序遍歷更為穩健,也便於精確控制每個節點「進入」與「完成」時機。 |
| 50 | + |
| 51 | +基於以上觀察,可以採用以下策略: |
| 52 | + |
| 53 | +* 先將樹轉換為緊湊的鄰接串列表示,以利快速遍歷。 |
| 54 | +* 將每個節點的權重預先轉為對 `k` 的餘數,後續只在餘數空間 `[0, k)` 中累積。 |
| 55 | +* 使用顯式堆疊進行後序 DFS: |
| 56 | + |
| 57 | + * 當所有子節點處理完畢後,計算當前子樹餘數; |
| 58 | + * 若餘數為 `0`,計數一個合法元件,並向父節點傳遞 `0`; |
| 59 | + * 否則將餘數加到父節點的累積餘數中並取餘。 |
| 60 | +* 最終累計的元件數,即為可以達成的最大合法連通元件數。 |
| 61 | + |
| 62 | +## 解題步驟 |
| 63 | + |
| 64 | +### Step 1:使用緊湊型別陣列建構樹的鄰接串列 |
| 65 | + |
| 66 | +首先,以預先配置好的型別陣列表示鄰接串列: |
| 67 | +使用 `adjacencyHead` 紀錄每個節點對應的第一條邊索引,並以兩個平行陣列 `adjacencyToNode`、`adjacencyNextEdgeIndex` 串起所有邊,達成記憶體連續且快取友善的結構。 |
| 68 | + |
| 69 | +```typescript |
| 70 | +// 使用緊湊的型別陣列建構鄰接串列以提升快取存取效率 |
| 71 | +const adjacencyHead = new Int32Array(n); |
| 72 | +adjacencyHead.fill(-1); |
| 73 | + |
| 74 | +const totalEdges = (n - 1) * 2; |
| 75 | +const adjacencyNextEdgeIndex = new Int32Array(totalEdges); |
| 76 | +const adjacencyToNode = new Int32Array(totalEdges); |
| 77 | + |
| 78 | +let edgeWriteIndex = 0; |
| 79 | +for (let edgeArrayIndex = 0; edgeArrayIndex < edges.length; edgeArrayIndex++) { |
| 80 | + const nodeA = edges[edgeArrayIndex][0]; |
| 81 | + const nodeB = edges[edgeArrayIndex][1]; |
| 82 | + |
| 83 | + // 新增邊 nodeA -> nodeB |
| 84 | + adjacencyToNode[edgeWriteIndex] = nodeB; |
| 85 | + adjacencyNextEdgeIndex[edgeWriteIndex] = adjacencyHead[nodeA]; |
| 86 | + adjacencyHead[nodeA] = edgeWriteIndex; |
| 87 | + edgeWriteIndex++; |
| 88 | + |
| 89 | + // 新增邊 nodeB -> nodeA |
| 90 | + adjacencyToNode[edgeWriteIndex] = nodeA; |
| 91 | + adjacencyNextEdgeIndex[edgeWriteIndex] = adjacencyHead[nodeB]; |
| 92 | + adjacencyHead[nodeB] = edgeWriteIndex; |
| 93 | + edgeWriteIndex++; |
| 94 | +} |
| 95 | +``` |
| 96 | + |
| 97 | +### Step 2:預先計算每個節點的權重對 k 的餘數 |
| 98 | + |
| 99 | +為避免在 DFS 過程中重複進行取模運算,先將每個節點的權重轉為對 `k` 的餘數,之後僅在餘數空間內進行加總與合併。 |
| 100 | + |
| 101 | +```typescript |
| 102 | +// 預先計算每個節點的權重對 k 的餘數,以避免重複取模 |
| 103 | +const valuesModulo = new Int32Array(n); |
| 104 | +for (let nodeIndex = 0; nodeIndex < n; nodeIndex++) { |
| 105 | + const nodeValue = values[nodeIndex]; |
| 106 | + valuesModulo[nodeIndex] = nodeValue % k; |
| 107 | +} |
| 108 | +``` |
| 109 | + |
| 110 | +### Step 3:準備父節點紀錄與迭代 DFS 所需堆疊結構 |
| 111 | + |
| 112 | +為了在無遞迴的情況下完成後序遍歷,需要顯式維護父節點資訊與多個堆疊: |
| 113 | + |
| 114 | +* `parentNode`:避免從子節點經由邊走回父節點形成「逆向重訪」。 |
| 115 | +* `nodeStack`:每一層堆疊上對應的當前節點。 |
| 116 | +* `edgeIteratorStack`:紀錄每個節點目前遍歷到哪一條鄰接邊。 |
| 117 | +* `remainderStack`:紀錄每個節點子樹目前累積的餘數。 |
| 118 | + 同時定義一個特殊的終結標記值,用來表示該節點的所有子節點都已處理完畢,下一步應該進行「收尾計算」。 |
| 119 | + |
| 120 | +```typescript |
| 121 | +// 父節點陣列,用來避免沿著邊走回父節點 |
| 122 | +const parentNode = new Int32Array(n); |
| 123 | +parentNode.fill(-1); |
| 124 | + |
| 125 | +// 顯式堆疊以進行迭代式後序 DFS |
| 126 | +const nodeStack = new Int32Array(n); // 每層堆疊對應的節點編號 |
| 127 | +const edgeIteratorStack = new Int32Array(n); // 每個節點目前遍歷到的鄰接邊索引 |
| 128 | +const remainderStack = new Int32Array(n); // 每個節點子樹累積的餘數值 |
| 129 | + |
| 130 | +// 特殊標記值:表示該節點所有子節點均已處理完畢,下一步需進行收尾 |
| 131 | +const FINALIZE_SENTINEL = -2; |
| 132 | + |
| 133 | +let stackSize: number; |
| 134 | +``` |
| 135 | + |
| 136 | +### Step 4:初始化 DFS 根節點與元件計數 |
| 137 | + |
| 138 | +選擇節點 `0` 作為 DFS 根,將其推入堆疊作為起始狀態: |
| 139 | +設定其初始餘數為自身權重的餘數,並記錄父節點為 `-1` 代表無父節點。 |
| 140 | +同時初始化計數器,用來累積可形成的合法元件數。 |
| 141 | + |
| 142 | +```typescript |
| 143 | +// 從根節點 0 開始初始化 DFS |
| 144 | +nodeStack[0] = 0; |
| 145 | +edgeIteratorStack[0] = adjacencyHead[0]; |
| 146 | +remainderStack[0] = valuesModulo[0]; |
| 147 | +parentNode[0] = -1; |
| 148 | +stackSize = 1; |
| 149 | + |
| 150 | +let componentCount = 0; |
| 151 | +``` |
| 152 | + |
| 153 | +### Step 5:以單次迭代式後序 DFS 計算可切成的合法元件數 |
| 154 | + |
| 155 | +透過 `while` 迴圈維護一個顯式堆疊,實作後序 DFS: |
| 156 | + |
| 157 | +* 若當前節點被標記為終結狀態,說明所有子節點都已處理完畢: |
| 158 | + |
| 159 | + * 檢查整個子樹餘數是否為 `0`,若是則記錄一個合法元件; |
| 160 | + * 將該子樹的餘數加回父節點的累積餘數並適度取模; |
| 161 | + * 將此節點從堆疊彈出。 |
| 162 | +* 若尚未終結,持續從鄰接串列中尋找尚未拜訪的子節點: |
| 163 | + |
| 164 | + * 跳過指向父節點的邊; |
| 165 | + * 將子節點推入堆疊,延伸 DFS; |
| 166 | + * 若不再有子節點可前往,將當前節點標記為終結狀態,待下一輪處理收尾。 |
| 167 | + |
| 168 | +```typescript |
| 169 | +// 單次迭代的後序 DFS,用於計算最多可切出的 k 可整除元件數 |
| 170 | +while (stackSize > 0) { |
| 171 | + const stackTopIndex = stackSize - 1; |
| 172 | + const currentNode = nodeStack[stackTopIndex]; |
| 173 | + let currentEdgeIterator = edgeIteratorStack[stackTopIndex]; |
| 174 | + |
| 175 | + // 若目前標記為終結狀態,表示所有子節點已處理完畢,進入收尾階段 |
| 176 | + if (currentEdgeIterator === FINALIZE_SENTINEL) { |
| 177 | + const currentRemainder = remainderStack[stackTopIndex]; |
| 178 | + |
| 179 | + // 若整個子樹的和可被 k 整除,則形成一個合法元件 |
| 180 | + if (currentRemainder === 0) { |
| 181 | + componentCount++; |
| 182 | + } |
| 183 | + |
| 184 | + // 將當前節點自堆疊彈出 |
| 185 | + stackSize--; |
| 186 | + |
| 187 | + // 若存在父節點,則將此子樹的餘數累加到父節點的餘數中 |
| 188 | + if (stackSize > 0) { |
| 189 | + const parentStackIndex = stackSize - 1; |
| 190 | + let parentRemainder = remainderStack[parentStackIndex] + currentRemainder; |
| 191 | + |
| 192 | + // 透過少量取模操作,將父節點餘數維持在 [0, k) 區間內 |
| 193 | + if (parentRemainder >= k) { |
| 194 | + parentRemainder %= k; |
| 195 | + } |
| 196 | + |
| 197 | + remainderStack[parentStackIndex] = parentRemainder; |
| 198 | + } |
| 199 | + |
| 200 | + continue; |
| 201 | + } |
| 202 | + |
| 203 | + // 嘗試尋找尚未拜訪的子節點以繼續向下 DFS |
| 204 | + let hasUnvisitedChild = false; |
| 205 | + while (currentEdgeIterator !== -1) { |
| 206 | + const neighborNode = adjacencyToNode[currentEdgeIterator]; |
| 207 | + currentEdgeIterator = adjacencyNextEdgeIndex[currentEdgeIterator]; |
| 208 | + |
| 209 | + // 略過指回父節點的反向邊,避免往回走 |
| 210 | + if (neighborNode === parentNode[currentNode]) { |
| 211 | + continue; |
| 212 | + } |
| 213 | + |
| 214 | + // 記錄稍後從哪條邊開始繼續遍歷此節點的其他鄰居 |
| 215 | + edgeIteratorStack[stackTopIndex] = currentEdgeIterator; |
| 216 | + |
| 217 | + // 將子節點推入堆疊以進一步遍歷 |
| 218 | + const childStackIndex = stackSize; |
| 219 | + nodeStack[childStackIndex] = neighborNode; |
| 220 | + parentNode[neighborNode] = currentNode; |
| 221 | + edgeIteratorStack[childStackIndex] = adjacencyHead[neighborNode]; |
| 222 | + remainderStack[childStackIndex] = valuesModulo[neighborNode]; |
| 223 | + stackSize++; |
| 224 | + |
| 225 | + hasUnvisitedChild = true; |
| 226 | + break; |
| 227 | + } |
| 228 | + |
| 229 | + // 若再也沒有子節點可拜訪,將此節點標記為終結狀態,等待下一輪收尾處理 |
| 230 | + if (!hasUnvisitedChild) { |
| 231 | + edgeIteratorStack[stackTopIndex] = FINALIZE_SENTINEL; |
| 232 | + } |
| 233 | +} |
| 234 | +``` |
| 235 | + |
| 236 | +### Step 6:回傳最多可切出的合法連通元件數 |
| 237 | + |
| 238 | +當 DFS 完成時,所有子樹的餘數已正確向上合併,並在每次子樹餘數為 `0` 時累加了元件數,最終直接回傳累計結果。 |
| 239 | + |
| 240 | +```typescript |
| 241 | +// componentCount 此時即為最多可切出的 k 可整除連通元件數 |
| 242 | +return componentCount; |
| 243 | +``` |
| 244 | + |
| 245 | +## 時間複雜度 |
| 246 | + |
| 247 | +- 建構樹的鄰接串列需要遍歷全部 `n - 1` 條邊,為線性時間。 |
| 248 | +- 預先計算每個節點權重對 `k` 的餘數需要遍歷所有 `n` 個節點。 |
| 249 | +- 迭代式後序 DFS 會走訪每條邊與每個節點常數次,整體仍為線性時間。 |
| 250 | +- 總時間複雜度為 $O(n)$。 |
| 251 | + |
| 252 | +> $O(n)$ |
| 253 | +
|
| 254 | +## 空間複雜度 |
| 255 | + |
| 256 | +- 鄰接串列使用大小為 `O(n)` 的型別陣列儲存所有邊。 |
| 257 | +- 額外使用 `O(n)` 大小的堆疊、父節點陣列與餘數陣列。 |
| 258 | +- 除上述結構外,僅有常數數量的輔助變數。 |
| 259 | +- 總空間複雜度為 $O(n)$。 |
| 260 | + |
| 261 | +> $O(n)$ |
0 commit comments