|
| 1 | +# 1625. Lexicographically Smallest String After Applying Operations |
| 2 | + |
| 3 | +You are given a string `s` of even length consisting of digits from `0` to `9`, and two integers `a` and `b`. |
| 4 | + |
| 5 | +You can apply either of the following two operations any number of times and in any order on `s`: |
| 6 | + |
| 7 | +- Add `a` to all odd indices of `s` (0-indexed). |
| 8 | + Digits post 9 are cycled back to `0`. |
| 9 | + For example, if `s = "3456"` and `a = 5`, `s` becomes `"3951"`. |
| 10 | +- Rotate `s` to the right by `b` positions. |
| 11 | + For example, if `s = "3456"` and `b = 1`, `s` becomes `"6345"`. |
| 12 | + |
| 13 | +Return the lexicographically smallest string you can obtain by applying the above operations any number of times on `s`. |
| 14 | + |
| 15 | +A string `a` is lexicographically smaller than a string `b` (of the same length) if in the first position where `a` and `b` differ, |
| 16 | +string `a` has a letter that appears earlier in the alphabet than the corresponding letter in `b`. |
| 17 | +For example, `"0158"` is lexicographically smaller than `"0190"` because the first position they differ is at the third letter, |
| 18 | +and `'5'` comes before `'9'` |
| 19 | + |
| 20 | +**Constraints:** |
| 21 | + |
| 22 | +- `2 <= s.length <= 100` |
| 23 | +- `s.length` is even. |
| 24 | +- `s` consists of digits from `0` to `9` only. |
| 25 | +- `1 <= a <= 9` |
| 26 | +- `1 <= b <= s.length - 1`. |
| 27 | + |
| 28 | +## 基礎思路 |
| 29 | + |
| 30 | +本題要我們對一個由數字組成、長度為偶數的字串 `s`,重複執行兩種操作,使最終結果在字典序上最小: |
| 31 | + |
| 32 | +1. **加法操作**:對所有奇數索引的位數加上 `a`,若超過 9 則循環回 0; |
| 33 | +2. **旋轉操作**:將整個字串向右旋轉 `b` 位。 |
| 34 | + |
| 35 | +可任意次數與順序地應用這兩個操作,最後需找出能得到的最小字典序字串。 |
| 36 | + |
| 37 | +在思考解法時,我們需要注意以下幾點: |
| 38 | + |
| 39 | +* **操作具週期性**: |
| 40 | + 加法操作對某位數而言,每 10 次會回到原值;旋轉操作則會在經過 $\text{lcm}(b, n)$ 次後回到初始狀態。 |
| 41 | +* **可達狀態有限**: |
| 42 | + 雖然可重複操作無限次,但實際不同的結果只有有限個,因此可透過數學週期找出所有獨特狀態。 |
| 43 | +* **索引奇偶的影響**: |
| 44 | + 若旋轉步長 `b` 為奇數,則「奇偶索引」會互換,使得原本只能改變奇數位的操作,經過某些旋轉後也能影響偶數位。 |
| 45 | +* **字典序判斷**: |
| 46 | + 在生成所有可達狀態後,需有效比較候選字串的字典序以找出最小者。 |
| 47 | + |
| 48 | +為此,我們採用以下策略: |
| 49 | + |
| 50 | +* **使用數學週期化簡操作空間**: |
| 51 | + 利用最大公因數 (`gcd`) 找出旋轉與加法的最小循環週期,僅需在有限次內嘗試所有可能組合。 |
| 52 | +* **分別優化奇偶索引**: |
| 53 | + 根據 `b` 的奇偶性決定是否同時調整偶數索引。 |
| 54 | +* **直接操作字元陣列**: |
| 55 | + 避免多餘字串拼接的開銷,以陣列形式比較字典序,提升效能。 |
| 56 | + |
| 57 | +## 解題步驟 |
| 58 | + |
| 59 | +### Step 1:輔助函數 `greaterCommonDivisor` — 計算最大公因數 |
| 60 | + |
| 61 | +利用歐幾里得演算法計算兩數的最大公因數,協助求出旋轉與加法的循環週期。 |
| 62 | + |
| 63 | +```typescript |
| 64 | +/** |
| 65 | + * 使用歐幾里得演算法計算兩數的最大公因數。 |
| 66 | + * |
| 67 | + * @param valueA 第一個正整數 |
| 68 | + * @param valueB 第二個正整數 |
| 69 | + * @returns 最大公因數 |
| 70 | + */ |
| 71 | +function greaterCommonDivisor(valueA: number, valueB: number): number { |
| 72 | + while (valueB !== 0) { |
| 73 | + const remainder = valueA % valueB; |
| 74 | + valueA = valueB; |
| 75 | + valueB = remainder; |
| 76 | + } |
| 77 | + return valueA; |
| 78 | +} |
| 79 | +``` |
| 80 | + |
| 81 | +### Step 2:初始化主要變數 |
| 82 | + |
| 83 | +建立初始狀態,包括原字串、雙倍字串(方便旋轉切片)、旋轉步長與加法循環長度。 |
| 84 | + |
| 85 | +```typescript |
| 86 | +// 字串長度 |
| 87 | +const stringLength = s.length; |
| 88 | +let smallestString = s; |
| 89 | + |
| 90 | +// 建立雙倍字串,方便無模運算旋轉切片 |
| 91 | +const doubledString = s + s; |
| 92 | + |
| 93 | +// 計算旋轉步長(由 gcd 決定能形成的唯一旋轉組合數) |
| 94 | +const rotationStep = greaterCommonDivisor(b, stringLength); |
| 95 | + |
| 96 | +// 計算加法循環長度(每 cycleLength 次回到原狀) |
| 97 | +const cycleLength = 10 / greaterCommonDivisor(a, 10); |
| 98 | +``` |
| 99 | + |
| 100 | +### Step 3:輔助函數 `applyBestAddition` — 最佳化指定奇偶索引的加法操作 |
| 101 | + |
| 102 | +針對奇數或偶數索引,嘗試所有可能的加法次數,找出使該位數最小的增量,並將其應用到所有相同奇偶的位上。 |
| 103 | + |
| 104 | +```typescript |
| 105 | +/** |
| 106 | + * 對指定奇偶性的索引套用最佳加法操作。 |
| 107 | + * |
| 108 | + * @param digits 可變的字元陣列 |
| 109 | + * @param startIndex 起始索引(0 表偶數,1 表奇數) |
| 110 | + */ |
| 111 | +function applyBestAddition(digits: string[], startIndex: number) { |
| 112 | + // 取得此奇偶性下的首個數字值 |
| 113 | + const originalDigit = digits[startIndex].charCodeAt(0) - 48; |
| 114 | + let minimumDigit = 10; |
| 115 | + let bestTimes = 0; |
| 116 | + |
| 117 | + // 嘗試所有加法循環次數,找出能讓結果最小的次數 |
| 118 | + for (let times = 0; times < cycleLength; times++) { |
| 119 | + const addedDigit = (originalDigit + (times * a) % 10) % 10; |
| 120 | + if (addedDigit < minimumDigit) { |
| 121 | + minimumDigit = addedDigit; |
| 122 | + bestTimes = times; |
| 123 | + } |
| 124 | + } |
| 125 | + |
| 126 | + // 套用最佳增量至所有同奇偶索引位置 |
| 127 | + const increment = (bestTimes * a) % 10; |
| 128 | + for (let index = startIndex; index < stringLength; index += 2) { |
| 129 | + const baseDigit = digits[index].charCodeAt(0) - 48; |
| 130 | + digits[index] = String.fromCharCode(48 + ((baseDigit + increment) % 10)); |
| 131 | + } |
| 132 | +} |
| 133 | +``` |
| 134 | + |
| 135 | +### Step 4:輔助函數 `isLexicographicallySmaller` — 比較字典序 |
| 136 | + |
| 137 | +逐字比較兩組字元陣列,判斷候選字串是否比當前最佳解更小。 |
| 138 | + |
| 139 | +```typescript |
| 140 | +/** |
| 141 | + * 判斷候選字元陣列是否字典序更小。 |
| 142 | + * |
| 143 | + * @param candidateDigits 候選字元陣列 |
| 144 | + * @param currentBestString 當前最小字串 |
| 145 | + * @returns 若候選字串更小則回傳 true |
| 146 | + */ |
| 147 | +function isLexicographicallySmaller(candidateDigits: string[], currentBestString: string): boolean { |
| 148 | + for (let index = 0; index < stringLength; index++) { |
| 149 | + const candidateChar = candidateDigits[index].charCodeAt(0); |
| 150 | + const bestChar = currentBestString.charCodeAt(index); |
| 151 | + |
| 152 | + if (candidateChar < bestChar) { |
| 153 | + return true; |
| 154 | + } |
| 155 | + if (candidateChar > bestChar) { |
| 156 | + return false; |
| 157 | + } |
| 158 | + } |
| 159 | + return false; |
| 160 | +} |
| 161 | +``` |
| 162 | + |
| 163 | +### Step 5:嘗試所有有效旋轉組合 |
| 164 | + |
| 165 | +根據旋轉步長 `rotationStep`,嘗試所有唯一旋轉狀態,對每一種旋轉: |
| 166 | + |
| 167 | +1. 取出旋轉後字元陣列; |
| 168 | +2. 對奇數索引執行最佳加法; |
| 169 | +3. 若 `b` 為奇數,偶數索引經旋轉後也可能成為奇數,需再執行一次最佳化; |
| 170 | +4. 與當前最小字串比較更新。 |
| 171 | + |
| 172 | +```typescript |
| 173 | +// 嘗試所有唯一旋轉組合 |
| 174 | +for (let rotationIndex = 0; rotationIndex < stringLength; rotationIndex += rotationStep) { |
| 175 | + // 擷取當前旋轉後的子字串 |
| 176 | + const rotatedDigits = doubledString.slice(rotationIndex, rotationIndex + stringLength).split(""); |
| 177 | + |
| 178 | + // 永遠可對奇數位進行加法操作 |
| 179 | + applyBestAddition(rotatedDigits, 1); |
| 180 | + |
| 181 | + // 若旋轉步長為奇數,偶數位也可能被影響,需再執行一次 |
| 182 | + if ((b & 1) === 1) { |
| 183 | + applyBestAddition(rotatedDigits, 0); |
| 184 | + } |
| 185 | + |
| 186 | + // 比較並更新目前的最小字串 |
| 187 | + if (isLexicographicallySmaller(rotatedDigits, smallestString)) { |
| 188 | + smallestString = rotatedDigits.join(""); |
| 189 | + } |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +### Step 6:返回最小字典序結果 |
| 194 | + |
| 195 | +完成所有旋轉與加法嘗試後,回傳最小結果。 |
| 196 | + |
| 197 | +```typescript |
| 198 | +// 返回最終的最小字典序字串 |
| 199 | +return smallestString; |
| 200 | +``` |
| 201 | + |
| 202 | +## 時間複雜度 |
| 203 | + |
| 204 | +- `applyBestAddition()`:每次最多嘗試 10 次加法循環,需遍歷 $\frac{n}{2}$ 位數,為 $O(n)$。 |
| 205 | +- 旋轉嘗試次數最多為 $\frac{n}{\gcd(b, n)}$。 |
| 206 | +- 整體複雜度為 $O(\frac{n^2}{\gcd(b, n)})$,在 $n \le 100$ 時可接受。 |
| 207 | +- 總時間複雜度為 $O(n^2)$。 |
| 208 | + |
| 209 | +> $O(n^2)$ |
| 210 | +
|
| 211 | +## 空間複雜度 |
| 212 | + |
| 213 | +- 使用少量臨時陣列(旋轉後字串、副本)與常數映射表。 |
| 214 | +- 額外空間與輸入長度成正比。 |
| 215 | +- 總空間複雜度為 $O(n)$。 |
| 216 | + |
| 217 | +> $O(n)$ |
0 commit comments