Skip to content

Commit 922c64f

Browse files
committed
Add: Add 2025/11/28
1 parent 0cef5c6 commit 922c64f

File tree

3 files changed

+390
-0
lines changed

3 files changed

+390
-0
lines changed
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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)$
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
function maxKDivisibleComponents(n: number, edges: number[][], values: number[], k: number): number {
2+
// Build adjacency list using compact typed arrays for cache-efficient traversal
3+
const adjacencyHead = new Int32Array(n);
4+
adjacencyHead.fill(-1);
5+
6+
const totalEdges = (n - 1) * 2;
7+
const adjacencyNextEdgeIndex = new Int32Array(totalEdges);
8+
const adjacencyToNode = new Int32Array(totalEdges);
9+
10+
let edgeWriteIndex = 0;
11+
for (let edgeArrayIndex = 0; edgeArrayIndex < edges.length; edgeArrayIndex++) {
12+
const nodeA = edges[edgeArrayIndex][0];
13+
const nodeB = edges[edgeArrayIndex][1];
14+
15+
// Add edge nodeA -> nodeB
16+
adjacencyToNode[edgeWriteIndex] = nodeB;
17+
adjacencyNextEdgeIndex[edgeWriteIndex] = adjacencyHead[nodeA];
18+
adjacencyHead[nodeA] = edgeWriteIndex;
19+
edgeWriteIndex++;
20+
21+
// Add edge nodeB -> nodeA
22+
adjacencyToNode[edgeWriteIndex] = nodeA;
23+
adjacencyNextEdgeIndex[edgeWriteIndex] = adjacencyHead[nodeB];
24+
adjacencyHead[nodeB] = edgeWriteIndex;
25+
edgeWriteIndex++;
26+
}
27+
28+
// Precompute node values modulo k to avoid repeated modulo operations
29+
const valuesModulo = new Int32Array(n);
30+
for (let nodeIndex = 0; nodeIndex < n; nodeIndex++) {
31+
const nodeValue = values[nodeIndex];
32+
valuesModulo[nodeIndex] = nodeValue % k;
33+
}
34+
35+
// Parent array to avoid revisiting the edge back to parent
36+
const parentNode = new Int32Array(n);
37+
parentNode.fill(-1);
38+
39+
// Explicit stacks for iterative post-order DFS
40+
const nodeStack = new Int32Array(n); // Node at each stack level
41+
const edgeIteratorStack = new Int32Array(n); // Current adjacency edge index for each node
42+
const remainderStack = new Int32Array(n); // Accumulated subtree remainder for each node
43+
44+
// Sentinel value indicating that all children of the node have been processed
45+
const FINALIZE_SENTINEL = -2;
46+
47+
let stackSize: number;
48+
49+
// Initialize DFS from root node 0
50+
nodeStack[0] = 0;
51+
edgeIteratorStack[0] = adjacencyHead[0];
52+
remainderStack[0] = valuesModulo[0];
53+
parentNode[0] = -1;
54+
stackSize = 1;
55+
56+
let componentCount = 0;
57+
58+
// Single-pass iterative post-order DFS
59+
while (stackSize > 0) {
60+
const stackTopIndex = stackSize - 1;
61+
const currentNode = nodeStack[stackTopIndex];
62+
let currentEdgeIterator = edgeIteratorStack[stackTopIndex];
63+
64+
// If marked with sentinel, all children are processed and we finalize this node
65+
if (currentEdgeIterator === FINALIZE_SENTINEL) {
66+
const currentRemainder = remainderStack[stackTopIndex];
67+
68+
// If subtree sum is divisible by k, it forms a valid component
69+
if (currentRemainder === 0) {
70+
componentCount++;
71+
}
72+
73+
// Pop current node from stack
74+
stackSize--;
75+
76+
// Propagate remainder to parent if parent exists
77+
if (stackSize > 0) {
78+
const parentStackIndex = stackSize - 1;
79+
let parentRemainder = remainderStack[parentStackIndex] + currentRemainder;
80+
81+
// Keep parent remainder within [0, k) range with minimal modulo calls
82+
if (parentRemainder >= k) {
83+
parentRemainder %= k;
84+
}
85+
86+
remainderStack[parentStackIndex] = parentRemainder;
87+
}
88+
89+
continue;
90+
}
91+
92+
// Try to find an unvisited child to go deeper in DFS
93+
let hasUnvisitedChild = false;
94+
while (currentEdgeIterator !== -1) {
95+
const neighborNode = adjacencyToNode[currentEdgeIterator];
96+
currentEdgeIterator = adjacencyNextEdgeIndex[currentEdgeIterator];
97+
98+
// Skip edge back to parent
99+
if (neighborNode === parentNode[currentNode]) {
100+
continue;
101+
}
102+
103+
// Store next edge to continue from when we come back to this node
104+
edgeIteratorStack[stackTopIndex] = currentEdgeIterator;
105+
106+
// Push child node onto stack for further traversal
107+
const childStackIndex = stackSize;
108+
nodeStack[childStackIndex] = neighborNode;
109+
parentNode[neighborNode] = currentNode;
110+
edgeIteratorStack[childStackIndex] = adjacencyHead[neighborNode];
111+
remainderStack[childStackIndex] = valuesModulo[neighborNode];
112+
stackSize++;
113+
114+
hasUnvisitedChild = true;
115+
break;
116+
}
117+
118+
// If no more children, mark node for finalization on the next iteration
119+
if (!hasUnvisitedChild) {
120+
edgeIteratorStack[stackTopIndex] = FINALIZE_SENTINEL;
121+
}
122+
}
123+
124+
// componentCount now holds the maximum number of k-divisible components
125+
return componentCount;
126+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
function maxKDivisibleComponents(n: number, edges: number[][], values: number[], k: number): number {
2+
3+
}

0 commit comments

Comments
 (0)