DAY 14: LeetCode 394. 字符串解码|递归和栈到底怎么处理嵌套?

这里写目录标题

LeetCode 394. 字符串解码|我是怎么从嵌套想到递归和栈的?

题目链接:394. 字符串解码

这道题刚开始看的时候,我其实没觉得特别难。

例如:3[a]

意思很直接:a 重复 3 次 → aaa

所以我最开始想的就是:

找到数字,知道重复几次;

再找到括号里的字符串,把它重复对应次数。

真正让我开始不知道怎么处理的是:3[a2[c]]

因为这里突然出现了嵌套。


一、真正的问题:怎么保证先处理里面,再处理外面?

例如:3[a2[c]]

最外面的 3[...] 暂时根本算不了。

因为它里面的:a2[c]

自己都还没有处理完成。

必须先:

text 复制代码
2[c]
↓
cc

a2[c]
↓
acc

3[acc]
↓
accaccacc

所以:

程序怎么知道要先进去解决最里面那一层?

而且如果变成:3[a2[b4[c]]]

还得继续一层一层往里面走。

为什么这时候会想到递归?

后来我注意到一件事。

最外面的:3[...]

本质上是在做:

把括号里的内容解码出来,再重复 3 次。

而里面的:2[c]

做的还是:

把括号里的内容解码出来,再重复 2 次。

也就是说,里面和外面其实是在解决同一种问题。

text 复制代码
外层
→ 解码一个 数字[...]

内层
→ 还是解码一个 数字[...]

这时候才会自然想到:

既然里面又出现了一个和外面一样的问题,那能不能让同一个函数再调用自己一次?

这就是递归。


二、递归函数应该怎么设计?

现在已经确定了一件事:

遇到嵌套时,可以让同一个函数继续进去处理下一层。

但"用递归"还不等于会写。

接下来真正要想的是:

一次递归到底要处理什么?

还是看:3[a2[c]]

最外层遇到第一个 [ 时,我们需要先把里面的:a2[c]

处理出来。

所以一次递归的任务其实可以先定成:

从当前这一层的第一个字符开始,一直向右解析,直到这一层结束。

这时候就自然碰到了两个问题:

我怎么告诉这个函数"你从哪里开始"?

它又怎么知道"处理到哪里该停"?

1. 需要一个 i

我们要处理的无论是数字、字母还是括号,本质上都是字符串里的字符。

要知道当前正在看哪个字符,最直接的办法就是记录它在字符串里的下标。

所以递归调用真正需要从上一层得到的信息,其实只有:

这一层从字符串的哪个位置开始。

于是才有:

python 复制代码
dfs(i)

这里的 i 可以先简单理解成:

当前处理到的位置。

例如:

text 复制代码
3 [ a 2 [ c ] ]
  ↑ ↑
  | 新的一层从这里开始
  当前遇到的 [

当外层走到 [ 时,[ 本身只是边界。

真正属于下一层的第一个字符是它后面的 a。

所以进入下一层时:

python 复制代码
dfs(i + 1)

也就是:

从左括号后面的第一个字符开始处理。

那这一层什么时候结束?

还是看:a2[c]

这一层不能继续把外面的内容也一起处理。

所以:

当它遇到属于自己这一层的 ] 时,这一层就结束。

于是 i 同时帮我们确定了这一层的范围:

text 复制代码
进入一层
↓
从 [ 后面的第一个字符开始

i 不断向右移动
↓

遇到当前层自己的 ]
↓
这一层结束

最外层稍微特殊一点。

它不是某个括号内部,所以:

text 复制代码
从 i = 0 开始
↓
直到整个字符串结束

2. 这一层到底要算出什么?

边界确定以后,再看这一层内部到底在做什么。

例如当前层是:a2[c]

我们从左往右看。

首先遇到:a

这个字符肯定不能丢,因为最终结果里还需要它。

所以这一层需要先保存:

目前已经解析好的字符串。

用

python 复制代码
result = ""

看到 a:

text 复制代码
result = "a"

继续往右,又看到:2

这个 2 自己不是最终答案的一部分。

它的作用是:

告诉我们后面的括号内容要重复几次。

所以它更像这一层解析过程中暂时保存的一个状态。

于是还需要:

python 复制代码
number = 0

所以一层递归里真正要维护的东西是这三个:

text 复制代码
i → 我现在读到哪里

result → 这一层目前已经解码出来的内容

number → 接下来括号内容要重复多少次

这里也可以先区分一下它们的角色:

  • result 是这一层最后真正要交给上一层的答案。
  • 而 number 只是这一层构造 result 时暂时使用的值。

3. 那从左往右扫描时,遇到不同字符怎么办?

当前字符只有四种情况:

text 复制代码
数字 → 继续组成 number

字母 → 加进当前 result

"["
→ 说明出现下一层
→ 暂停当前层
→ 调用 dfs() 进去处理

"]"
→ 当前这一层结束
→ 把结果返回上一层

所以递归的运行方式其实就是:

text 复制代码
当前层一直往右读
↓
普通内容自己处理

碰到 [
↓
进去解决下一层

下一层碰到 ]
↓
把已经解码好的字符串交回来

当前层接着往右

例如:3[a2[c]]

第二层处理到:

text 复制代码
result = "a"
number = 2

然后碰到 [。

这时候第二层暂停,让第三层去处理:c

第三层最后得到:

text 复制代码
"c"

返回以后,第二层再用自己之前保存的:

text 复制代码
number = 2

得到:

text 复制代码
"a" + "c" * 2
↓
"acc"

这里也就能看出:

哪一层保存了这个 number,就由哪一层负责使用它。

最里面那层只负责把自己的内容解出来。

外面的重复次数属于外层,不需要传进去。


4. 递归结束后要怎么继续解析下去

例如第三层已经把:c

解出来了。

它当然要把:

text 复制代码
"c"

交给第二层。

但如果只返回 "c",第二层还不知道:

刚才这一层到底走到原字符串哪个地方了?

我应该从哪里接着处理?

所以递归结束时需要同时告诉上一层:

text 复制代码
我这一层解码出来了什么
+
我停在了哪个位置

因此才有:

python 复制代码
return result, i

也就是返回:

python 复制代码
(result, i)

上一层再用:

python 复制代码
sub, i = dfs(i + 1)

分别接住:

text 复制代码
sub → 下层已经解码好的字符串

i → 下层停下来的右括号位置

这样上一层才能跨过这个已经处理完的 ],继续往后扫描。

所以 i 在这里其实有两个连续的作用:

text 复制代码
进入递归时 → 告诉这一层从哪里开始

递归结束时 → 告诉上一层我处理到哪里结束

它就像一直在原字符串上往前传递的"当前位置"。


三、现在完整走一次递归:3[a2[c]]2[b]

到这里再跑完整例子,会比一开始直接看代码清楚很多。

这个例子后面的:2[b]

正好可以用于说明:

子递归结束,不代表当前这一层也结束。

先标下标:

text 复制代码
下标:0 1 2 3 4 5 6 7 8 9 10 11
字符:3 [ a 2 [ c ] ] 2 [  b  ]

最外层从下标 0 开始。

第一层

首先看到:3

所以:

text 复制代码
number = 3
result = ""

然后看到:[

说明出现下一层。

这一层暂停,让下一层从 a 开始处理。


第二层

看到:a

所以:

text 复制代码
result = "a"

继续看到:2

所以:

text 复制代码
number = 2

接着再次碰到:[

说明还有下一层。

第二层暂停。


第三层

这一层从:c

开始。

看到字母,所以:

text 复制代码
result = "c"

接着碰到:]

这说明第三层结束了。

于是第三层返回:

text 复制代码
解析结果:"c"
当前位置:这个 ] 的下标

回到第二层

第二层之前一直保存着:

text 复制代码
result = "a"
number = 2

现在下一层返回:

text 复制代码
sub = "c"

所以:

text 复制代码
"a" + "c" * 2
↓
"acc"

这里有一个很重要的理解:

哪一层保存了这个 number,就由哪一层负责拿下一层返回的 sub 去乘。

第三层只负责得到:"c"

它根本不知道外面还有一个:2

那个 2 属于第二层。


number 用完以后要怎么处理?

因为:2[c] 已经处理完成。

这个 2 已经被消费掉了。

但当前第二层不一定马上结束。

例如如果这一层是:a2[c]3[d]

后面还有:3[d]

如果处理完 2[c] 后,还保留:

text 复制代码
number = 2

那么后面看到:3

以后:

python 复制代码
number = number * 10 + 3

就会变成:23

于是原本的:3[d]

就错误地变成:23[d]

所以一个:数字[...]

处理完成以后:

python 复制代码
number = 0

表示:

这次重复次数已经使用完了,当前层后面如果再看到数字,要重新开始记录。


第二层什么时候结束?

刚才第三层返回时,位置停在最里面那个:]

第二层处理完:"c" * 2 以后,跨过这个已经处理好的右括号,继续往右。

结果马上又看到一个:]

这一次是第二层自己的右括号。

所以:

第二层结束。

它把:"acc"

和当前的位置返回给第一层。


回到第一层

第一层之前保存着:

text 复制代码
number = 3
result = ""

现在拿到:

text 复制代码
sub = "acc"

于是:

text 复制代码
"acc" * 3
↓
"accaccacc"

但是第一层没有结束。

它只是刚刚暂停去解决了一个子问题。

回来以后,字符串后面还有:2[b]

这个例子完整的是这样的: 3[a2[c]]2[b]

所以继续向右扫描。

看到:2

重新记录:

text 复制代码
number = 2

看到:[

再次进入下一层。

下一层得到:"b"

回来以后:

text 复制代码
"b" * 2
↓
"bb"

拼到原来的结果后面:

text 复制代码
accaccaccbb

直到整个字符串结束。


四、完整递归代码

现在我们已经一步一步得到:

text 复制代码
一次递归需要知道从哪里开始 → i

当前层要保存已经处理好的内容 → result

当前层要保存重复次数 → number

数字 → 更新 number

字母 → 加入 result

[ → 让下一层递归去处理

] → 当前层结束,把 result 和位置返回

所以代码才自然变成:

python 复制代码
class Solution:
    def decodeString(self, s: str) -> str:

        n = len(s)

        def dfs(i):
            result = ""
            number = 0

            while i < n:

                if s[i].isdigit():
                    number = number * 10 + int(s[i])
                    i += 1

                elif s[i].isalpha():
                    result += s[i]
                    i += 1

                elif s[i] == "[":
                    sub, i = dfs(i + 1)

                    result += sub * number
                    number = 0

                    i += 1

                elif s[i] == "]":
                    return result, i

            return result, i

        answer, _ = dfs(0)
        return answer

这里再补两个之前容易卡住的地方。

多位数字为什么是 number * 10 + 当前数字?

例如: 123[a]

我们是一个字符一个字符看到:1 → 2 → 3

所以:

text 复制代码
看到 1
0 × 10 + 1
= 1

看到 2
1 × 10 + 2
= 12

看到 3
12 × 10 + 3
= 123

也就是把原来的数字整体向左挪一位,再把新数字放到个位。

最外层为什么还有一个 return result, i?

普通递归层会通过:] 结束。

但最外层外面没有右括号。

所以它最终会因为:

python 复制代码
i == len(s)

退出 while。

这时候仍然需要:

python 复制代码
return result, i

把最终结果返回。


五、方法二:栈

其实栈不是我写完递归以后才想到的。

我最开始看到嵌套时,就有过一个很直接的想法:

既然暂时不知道外层该怎么算,那能不能先把看到的东西都存起来?

等碰到一个完整的右括号 ],再从后往前把最近这一层处理掉。

理解完递归以后再回来想这个办法,反而清楚了很多:

递归是在进入内层之前,把外层状态交给 Python 帮我保存;

那如果不用递归,我就自己找一个东西,把暂时还处理不了的内容保存起来。

这时候栈就很合适。

栈是后进先出。

也就是后放进去的东西,会最先被拿出来。

而嵌套括号正好也是:

text 复制代码
外层先出现
↓
内层后出现
↓
内层却先结束

例如:3[a2[c]]

从左往右扫描,在第一个 ] 出现之前,可以先全部压进栈:

text 复制代码
["3", "[", "a", "2", "[", "c"]

这时候有一个很重要的观察:

第一个出现的 ],一定是在结束当前最里面那一层。

因为最里面的括号最后打开,却最先闭合,正好符合栈的后进先出。

所以真正的处理可以等到 ] 出现以后再开始。


遇到 ] 以后,到底要做什么?

还是看现在的栈:

text 复制代码
["3", "[", "a", "2", "[", "c"]

现在已经遇到了第一个 ]。

首先要解决的是:

这一层括号里的字符串是什么?

那就从栈顶开始往回拿。

第一次:

text 复制代码
pop → "c"

但 pop() 一次只能拿一个元素。

如果里面不是 c,而是 abc 呢?

这也是我当时卡住的地方:

下一个字符到底怎么继续 pop 出来?

后来发现不是 pop() 自己会连续执行,而是让 while 一轮一轮重复执行它。

只要栈顶还不是:[

就继续:

python 复制代码
stack.pop()

所以如果括号里是 abc:

text 复制代码
pop c
↓
mid = "c"

pop b
↓
mid = "bc"

pop a
↓
mid = "abc"

看到 [
↓
停止

因为是从右往左拿,所以新拿出来的内容要放在前面:

python 复制代码
mid = current + mid

而不是:

python 复制代码
mid += current

否则 abc 就会被拼成 cba。


现在字符串已经拿完,栈顶就是:[

这个左括号只是这一层的边界标记。

既然这一层的内容已经找到了,它的任务也完成了,所以直接:

python 复制代码
stack.pop()

把 [ 删除。

接下来才轮到:

这一层到底要重复几次?

也就是找左括号前面的数字。

我一开始在这里一直纠结:

数字什么时候算结束?

是不是要一直拿到碰见字母?

后来换了个角度以后就简单了:

不用猜什么时候停,只要栈顶还是数字,就继续 pop。

例如:12[abc]

压栈时:1 和 2

是分开放进去的。

往回拿的时候会先得到:2

再得到:1

所以同样要反着拼:

python 复制代码
num = current + num

过程就是:

text 复制代码
pop "2"
↓
num = "2"

pop "1"
↓
num = "12"

只要:

text 复制代码
栈空了
或者
栈顶不再是数字

数字就收集完成。


现在这一层需要的两个东西终于都有了。

例如:

text 复制代码
mid = "c"
num = "2"

所以:

text 复制代码
"c" * 2
↓
"cc"

但这里又出现了我一开始没想通的问题:

"cc" 已经算出来了,接下来放哪里?

肯定不能直接放进最终答案。

因为它仍然属于外面的:3[a2[c]] 或者说 外层 a 后面的一部分。

所以最自然的办法就是:

把已经解码好的 "cc" 再压回栈。

于是:

text 复制代码
["3", "[", "a", "2", "[", "c"]

经过一次处理以后,变成:

text 复制代码
["3", "[", "a", "cc"]

其实就相当于把:

text 复制代码
3[a2[c]]

暂时化简成了:

text 复制代码
3[acc]

然后继续扫描原字符串。

马上又遇到第二个 ]。

这一次完全不用发明新的规则,继续重复刚才的过程:

text 复制代码
往回拿字符串
↓
得到 "acc"

删掉 [

往前拿数字
↓
得到 3

"acc" * 3
↓
"accaccacc"

重新压回栈

所以嵌套其实就是这样:

每出现一个 ],就把最近的一层解码掉,再把解码结果当成普通内容放回去。

一层一层往外消。


i 和 pop() 到底谁在动?

写到这里的时候,我一直有一个很奇怪的困惑:

遇到 ] 以后,i 已经停在那里了。

那我要从右往左找前面的内容,到底是谁在往回走?

我甚至想过再创建一个:j

专门从右往左遍历。

后来才发现完全不需要。

这里其实有两条完全不同的线:

text 复制代码
i → 负责在原字符串 s 里从左往右扫描

stack.pop() → 负责在已经保存的内容里从右往左拿

所以碰到 ] 时:

text 复制代码
i 暂时停在这个 ]

接下来处理:

text 复制代码
mid
num

的整个过程中,i 都不用动。

不断变化的是:

python 复制代码
stack.pop()

等这一层:

text 复制代码
字符串找完
↓
数字找完
↓
解码完成
↓
结果重新压回栈

以后,这个 ] 才算真正处理结束。

这时候再:

python 复制代码
i += 1

继续扫描原字符串。

所以:

i 只负责往前扫原字符串;pop() 只负责从栈里往回取。

两件事不是同一个方向,也不需要再额外创建一个反向指针。


到这里,整个栈思路其实已经出来了

text 复制代码
从左往右扫描
↓
不是 ] → 先压入栈
↓
遇到 ]→ 最近的一层已经完整
↓
往回 pop, 直到看到 [ → 得到 mid
↓
删除 [
↓
继续 pop 连续数字 → 得到 num
↓
mid * num
↓
把结果重新压回 stack
↓
当前 ] 处理完成 → i += 1 → 继续向右

于是代码才变成:

python 复制代码
class Solution:
    def decodeString(self, s: str) -> str:
        stack = []
        i = 0

        while i < len(s):
            if s[i] != "]":
                stack.append(s[i])
                i += 1

            else:
                mid = ""

                while stack[-1] != "[":
                    current = stack.pop()
                    mid = current + mid

                stack.pop()

                num = ""

                while stack and stack[-1].isdigit():
                    current = stack.pop()
                    num = current + num

                stack.append(mid * int(num))
                i += 1

        return "".join(stack)

🚩 最后还有一个我自己写代码时踩到的小坑。

我一开始写的是:

python 复制代码
return mid

但拿:2[a]3[b]

一跑就错了。

因为 mid 只代表最近一次处理的那一层内容,并不代表整个字符串最后的答案。

例如最后栈里可能是:

text 复制代码
["aa", "bbb"]

所以真正要返回的是:

python 复制代码
"".join(stack)

得到:

text 复制代码
aabbb

六、最后复盘:

递归这条路:

text 复制代码
3[a2[c]]
↓
出现嵌套
怎么保证先处理里面?
↓
发现:里面和外面其实是同一种问题
↓
想到递归
↓
一次递归到底负责什么?→ 当前这一层
↓
调用它时要告诉它什么?→ 从哪里开始
↓
所以需要位置参数
↓
这一层自己需要记什么?
→ 已处理字符串 result
→ 重复次数 number
↓
什么时候进入下一层? → 遇到 [
↓
什么时候结束?→ 遇到当前层的 ]
↓
结束以后上一层还要继续 → 所以返回 result 和当前位置

栈这条路则是:

text 复制代码
如果不用递归呢?
↓
那就先把暂时处理不了的内容存起来
↓
遇到第一个 ]
↓
说明最里面一层完整了
↓
往回 pop 到 [
↓
得到这一层字符串
↓
再往前找连续数字
↓
完成这一层解码
↓
结果重新压回栈
↓
等待下一个 ]
↓
嵌套一层层被消掉

所以递归和显式栈看起来是两套完全不同的代码,但真正解决的是同一个问题:

当外层还没有处理完,却必须先去解决内层时,怎么把外层当前的状态保存下来,等内层解决以后再回来继续。

相关推荐
程序员的账号1 小时前
《Python工匠》资源分享
人工智能·python·深度学习·机器学习
随性而行3601 小时前
企业微信二次开发如何接入大模型工具?API接口实现智能任务调用的技术思路
java·前端·人工智能·python·微信·机器人·企业微信
P.D.Wei2 小时前
[Leetcode 1614] 括号的最大嵌套深度
算法·leetcode·职场和发展
2601_966949652 小时前
量化回测中分钟数据应该如何保存?从文件到数据仓库的工程化设计
开发语言·python·pandas·量化交易·股票数据·quantdash
huisheng_qaq2 小时前
【Python基础篇-05】深入理解python的异常处理与文件读写
python·异常处理·文件读写
小刘在重生~2 小时前
CSS 零基础完整学习笔记|选择器|盒子模型|布局
css·笔记·学习
Zhou1411362 小时前
SpringMVC_02_注解开发实战
开发语言·windows·python
红姐跨境书2 小时前
AI 的底层逻辑:从数据、算法到智能的本质
人工智能·算法
繁华的地方不一定留下你的脚印2 小时前
C++ 算法与 ranges:用 find_if、transform 写清数据处理意图
开发语言·c++·算法