ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

LeetCode 1849 Splitting a String Into Descending Consecutive Values 全解:从回溯到剪枝递归与栈式 DFS

LeetCode 1849 Splitting a String Into Descending Consecutive Values 全解:从回溯到剪枝递归与栈式 DFS LeetCode 1849 Splitting a String Into Descending Consecutive Values 全解从回溯到剪枝递归与栈式 DFS【免费下载链接】leetcodeLeetcode solutions项目地址: https://gitcode.com/GitHub_Trending/leetcode1/leetcode本篇指南以 LeetCode 1849「Splitting a String Into Descending Consecutive Values」为核心系统讲解如何将一个数字字符串拆分为「每段比前一段恰好小 1」的递减连续序列。文中从最直接的收集式回溯出发逐步演进出携带前驱值的递归、加入剪枝的递归 II以及用显式栈模拟 DFS 的迭代写法并给出四种解法的复杂度对比、常见陷阱与仓库多语言源码印证。读完你将掌握这类「字符串切分 序列校验」问题的完整解题谱系以及如何用剪枝把指数级回溯优化到可接受的搜索空间。问题定义与前置知识给定一个仅含数字的字符串s判断能否将其拆分成至少两段使得每段按原顺序拼接还原出s且相邻两段代表的数值满足「后一段 前一段 − 1」。例如s 1234可以拆成[12, 34]不行因为34 ≠ 12 - 1但拆成[1, 23, 4]23 ≠ 1 - 1。而050043可以拆成[05, 004, 3]即5, 4, 3满足递减连续。s 10009998可拆为[1000, 999, 8]1000, 999, 8中1000-1999999-1998因此需拆成1000,999,98注意这里999 - 1 998所以正确拆分是[1000,999,98]三段值1000, 999, 98并不连续——真正的答案是1000、999、998无法从10009998中拆出读者可以自行验证10009998的答案是true因为可拆为1000,999,98之外还存在100,09,8等组合其中100, 99, 98是合法的。本题的关键是允许前导零例如05代表数值5。解题前需要具备四项能力回溯 / 递归穷举所有可能的切分点并对每个切分方案校验是否构成递减连续序列逐位构造数值从字符流中一位一位累乘构造数字num num * 10 digit避免依赖语言自带的整串解析栈式迭代用显式栈模拟递归 DFS规避过深递归的调用栈溢出风险剪枝技巧当前构造的数值一旦超过前驱值即可提前终止这是性能提升的关键。解法一收集式回溯Backtracking思路最直观的想法是把字符串切分成若干段收集所有段的值后统一校验。由于切分方式众多用 DFS 枚举所有切分点到达字符串末尾时再调用isValid判断整段序列是否「每项比前一项小 1」且「段数 ≥ 2」。算法步骤定义辅助函数isValid(splits)遍历splits若存在splits[i] ! splits[i-1] - 1则返回false最后要求len(splits) 1。从下标0出发携带空列表splits进行 DFS。在当前位置尝试所有可能的切分长度把从i到j的子串逐位构造成数值num加入splits后递归处理j1。递归返回后回溯弹出最后加入的数值尝试下一个切分点。若到达字符串末尾且isValid通过返回true。代码实现class Solution: def splitString(self, s: str) - bool: n len(s) def isValid(splits): for i in range(1, len(splits)): if splits[i] ! splits[i - 1] - 1: return False return len(splits) 1 def dfs(i, splits): if i n: return isValid(splits) num 0 for j in range(i, n): num num * 10 int(s[j]) splits.append(num) if dfs(j 1, splits): return True splits.pop() return False return dfs(0, [])复杂度时间复杂度$O(n^n)$。最坏情况下每个位置都可能成为切分点切分方案呈指数爆炸空间复杂度$O(n)$递归栈深度与splits列表长度均与字符串长度线性相关。这种写法逻辑最直白但把所有段全部收集后再统一校验导致大量「前缀已经明显不可能」的分支也被完整展开是四种解法中效率最低的。解法二递归 I —— 传递前驱值思路收集式解法的低效源于「先收集、后校验」。既然序列要求严格递减且差值为 1我们完全可以在 DFS 过程中携带前驱值一旦某个后续段的值等于prev - 1才继续深入否则直接放弃该分支。同时首个数值通过主函数枚举前缀确定递归只需负责匹配「恰好小 1」的链条。算法步骤主函数枚举所有可能的首段遍历i从0到n-2必须排除最后一个字符确保至少两段把前缀s[0..i]构造为val调用dfs(i1, val)。在dfs(index, prev)中若index len(s)直接返回true能走到这里说明每个后继段都恰好比前驱小 1。从index开始逐位构造num若num 1 prev且dfs(j1, num)为真则返回true。全部尝试失败返回false。代码实现class Solution: def splitString(self, s: str) - bool: def dfs(index, prev): if index len(s): return True num 0 for j in range(index, len(s)): num num * 10 int(s[j]) if num 1 prev and dfs(j 1, num): return True return False val 0 for i in range(len(s) - 1): val val * 10 int(s[i]) if dfs(i 1, val): return True return FalseJava 版与仓库实现思路一致见下文源码印证核心在于dfs中仅当num 1 prev才递归public class Solution { public boolean splitString(String s) { int n s.length(); long val 0; for (int i 0; i n - 1; i) { val val * 10 (s.charAt(i) - 0); if (dfs(s, i 1, val)) return true; } return false; } private boolean dfs(String s, int index, long prev) { if (index s.length()) return true; long num 0; for (int j index; j s.length(); j) { num num * 10 (s.charAt(j) - 0); if (num 1 prev dfs(s, j 1, num)) return true; } return false; } }复杂度时间复杂度$O(n^2)$。相比收集式回溯只展开「值恰好等于前驱减 1」的分支搜索树被大幅裁剪空间复杂度$O(n)$为递归栈深度。解法三递归 II —— 关键剪枝思路递归 I 已经不错但还存在一个可观察的浪费在dfs内部随着j增大num单调递增。序列要求严格递减因此一旦num prev继续追加更多数字只会让num更大永远不可能等于prev - 1此时可以安全break。算法步骤主循环与递归 I 相同枚举首段并调用dfs。在dfs中逐位构造num若num 1 prev递归检查剩余部分若num prevbreak提前结束内层循环。到达字符串末尾即返回true。代码实现class Solution: def splitString(self, s: str) - bool: def dfs(index, prev): if index len(s): return True num 0 for j in range(index, len(s)): num num * 10 int(s[j]) if num 1 prev and dfs(j 1, num): return True if num prev: break return False val 0 for i in range(len(s) - 1): val val * 10 int(s[i]) if dfs(i 1, val): return True return FalseC 版同样体现了num prev即break的剪枝class Solution { public: bool splitString(string s) { int n s.size(); unsigned long long val 0; for (int i 0; i n - 1; i) { val val * 10 (s[i] - 0); if (dfs(s, i 1, val)) return true; } return false; } private: bool dfs(string s, int index, long long prev) { if (index s.size()) return true; unsigned long long num 0; for (int j index; j s.size(); j) { num num * 10 (s[j] - 0); if (num 1 prev dfs(s, j 1, num)) return true; if (num prev) break; } return false; } };为什么剪枝是安全的由于num是逐位累乘构造的num num * 10 digit保证num随j严格单调不减。而目标值是prev - 1 prev。因此当num prev时继续加位只会更大必然无法命中prev - 1当num prev时同理不可能等于prev - 1。两种情形都可以直接break不会漏掉任何合法解。这个「单调性剪枝」让搜索空间从递归 I 的基础上再下降一个数量级是本题最重要的优化手段。复杂度时间复杂度$O(n^2)$空间复杂度$O(n)$递归栈。解法四栈式迭代 DFS思路递归版依赖系统调用栈在理论上深度极大时存在栈溢出风险。将「(下一个起始下标, 前驱值)」作为显式状态压入栈中即可用迭代完全模拟递归过程状态迁移更显式、可读。算法步骤主循环枚举每个可能的首段把状态(i1, val)压栈。循环弹出栈顶(index, prev)从index开始逐位构造num若num 1 prev当j 1 n时说明已用尽全部字符返回true否则把新状态(j1, num)压栈若num prevbreak与递归 II 相同的剪枝。栈空仍未找到合法序列返回false。代码实现class Solution: def splitString(self, s: str) - bool: n len(s) stack [] val 0 for i in range(n - 1): val val * 10 int(s[i]) stack.append((i 1, val)) while stack: index, prev stack.pop() num 0 for j in range(index, n): num num * 10 int(s[j]) if num 1 prev: if j 1 n: return True stack.append((j 1, num)) elif num prev: break return FalseJava 版使用Stacklong[]显式管理状态public class Solution { public boolean splitString(String s) { int n s.length(); Stacklong[] stack new Stack(); long val 0; for (int i 0; i n - 1; i) { val val * 10 (s.charAt(i) - 0); stack.push(new long[]{i 1, val}); while (!stack.isEmpty()) { long[] top stack.pop(); int index (int) top[0]; long prev top[1]; long num 0; for (int j index; j n; j) { num num * 10 (s.charAt(j) - 0); if (num 1 prev) { if (j 1 n) return true; stack.push(new long[]{j 1, num}); } else if (num prev) { break; } } } } return false; } }复杂度时间复杂度$O(n^2)$空间复杂度$O(n)$栈中最多同时保存线性数量的状态。四种解法对比总览解法是否存储全部分段是否传递前驱值是否剪枝时间复杂度空间复杂度1. 收集式回溯是否否$O(n^n)$$O(n)$2. 递归 I否是否$O(n^2)$$O(n)$3. 递归 II否是是num prev即 break$O(n^2)$$O(n)$4. 栈迭代否是是同递归 II$O(n^2)$$O(n)$实际运行中解法三、四因剪枝的存在通常比解法二快得多而解法一只适合作为理解问题的最朴素起点。常见陷阱1. 大数溢出字符串最长 20 位1 s.length 2020 位数字可能超过 64 位整数上限long最多约 9.2e18。若用int甚至标准long逐位构造会因溢出得到错误的比较结果。建议C 中使用unsigned long long仓库实现 cpp/1849-splitting-a-string-into-descending-consecutive-values.cpp 正是采用typedef unsigned long long llJava 中直接使用BigInteger规避溢出见 java/1849-splitting-a-string-into-descending-consecutive-values.javaKotlin 仓库实现 kotlin/1849-splitting-a-string-into-descending-consecutive-values.kt 采用Double浮点数值进行比较其余语言可依据各自的大数能力选择核心原则是「逐位构造时留意中间值是否会溢出」。2. 忘记「至少两段」约束若整个字符串本身是一个数就返回true则像1、10这类输入会被错误判定。解法一通过isValid中len(splits) 1校验解法二、三、四通过在首段枚举时排除最后一个字符i n - 1来强制至少存在第二段。仓库中的 Python 实现 python/1849-splitting-a-string-into-descending-consecutive-values.py 同样使用range(len(s) - 1)。3. 遗漏剪枝导致超时没有num prev就break的写法会展开大量无意义分支。因为序列必须严格递减一旦当前数达到或超过前驱继续加位只会更大绝无可能等于prev - 1。该剪枝对性能是决定性的务必保留。仓库源码印证本仓库为 LeetCode 1849 提供了多种语言的独立实现与本文的解法脉络互相印证python/1849-splitting-a-string-into-descending-consecutive-values.py采用递归 I 风格主函数range(len(s) - 1)枚举首段dfs中以val 1 prev作为递归条件cpp/1849-splitting-a-string-into-descending-consecutive-values.cpp以last num 1判定后继值并辅以num last即break的剪枝对应解法三同时用cnt 1显式保证至少两段java/1849-splitting-a-string-into-descending-consecutive-values.java递归 BigIntegerlastValue.subtract(curValue).compareTo(BigInteger.ONE) 0精确表达「差值恰好为 1」kotlin/1849-splitting-a-string-into-descending-consecutive-values.kt同样基于「前驱值传递」思路使用Double承载大数。各实现尽管在语言特性与数值类型上各有取舍但共享同一核心不变量从前往后、逐位构造数值、仅当当前值 1 前驱值时继续深入。README 中的 题目完成度表格第 322 行附近也标注了该题的 C、Java、Kotlin 实现状态可作为查阅入口。小结从收集式回溯到剪枝递归再到栈迭代「Splitting a String Into Descending Consecutive Values」完整展示了字符串切分问题的标准演进路径先用朴素回溯建立正确性再通过「传递前驱值」消除无效分支继而用「数值单调性剪枝」压缩搜索空间最后用显式栈将递归改写为迭代。掌握这条链路你就能把同样的方法论迁移到 组合类问题、分割回文串、复原 IP 地址 等一系列基于切分与回溯的题目上。【免费下载链接】leetcodeLeetcode solutions项目地址: https://gitcode.com/GitHub_Trending/leetcode1/leetcode创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表