什么?导致我leetcode超时的不是算法是并发?
什么?导致我leetcode超时的不是算法是并发?
(我写这篇文章真的只是为了打发时间,内容严重逻辑不清,既不易懂也不深入,我不认为这篇文章具有阅读价值,所以看到这里就可以把它关掉了!)
问题的提出
Emmmm,我很久很久(快半年了吧)没有刷过题了,昨天晚上无聊就打开了 leetcode 随便写了一道题:
给定一个字符串 s,找到 s 中最长的回文子串。你可以假设 s 的最大长度为 1000。
示例 1:
1
2
3 输入: "babad"
输出: "bab"
注意: "aba" 也是一个有效答案。示例 2:
1
2 输入: "cbbd"
输出: "bb"来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/longest-palindromic-substring
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
emmmm,首先的想法是动规,我一般都不爱用动规,空间消耗大。做了那么久开发再回来看算法,就觉得,时间稍微慢点可以等,内存一满就彻底炸了。
然后,我想到了最长公共子串,把这个字符串和它的反串求一个最长公共子串就是最长回文子串了。。。最长公共子串又是动规。😠
不想用动规,又不齿于暴力,那就硬写吧。
我的思路是遍历一次字符串,尝试以每个字符为回文中心,尽量去往两边“拓展”,得到以每个字符为中心的最长回文串,取这些拓展得的回文串中最长的输出。
我这个思路时间应该也许大概是 $O(n^2)$ 吧(颓废到连这个都不会算了😭);空间可能比较好,都是调整指针的值,$O(1)$ 吧。
然后,我准备开始写代码,突然意识到回文有两!种!情!况!
- 第一种,形如
aba
,中心是一个字符b
; - 第二种,形如
abba
,中心在两个字符b
之间,或者也可以看作中心是两个字符bb
所以,遍历字符串时,每个迭代要拓展两次,一次是以当前字符为中心(aba
型),一次是以当前字符+后一个字符为中心(abba
型)。
Golang 实现如下:
1 | func longestPalindrome(s string) string { |
提交跑一下,4ms(击败91.02%),2.3MB(击败49.75%)。
emmm,我不理解为什么空间占用这么高,传参到时候 Golang 里的 []byte
应该是传了引用呀(可以理解成也就是传指针),其他的基本都是 int
了,我暂时想不到好办法继续在空间上做优化。
所以我考虑了一下能不能在时间上更进一步。考虑这一段代码:
1 | laba := ext(b, i, i) |
就是分别计算 aba
型和 abba
型的回文拓展。先算完一个,再算另一个,我想这里会比较耗时。如果让他们同时算会发生什么?
试试看,主要的改动就是开两个 goroutine 去执行这两个函数:
1 | func ext(b []byte, l, r int, cl chan int) { |
提交,😱😱😱,40ms,6.2MB,发生了什么?
我以为问题出在这里:
1 | laba := <- cl1 |
这里是先阻塞在 laba
这里等 cl1
传出来,然后再等 cl2
出来。万一 cl2
先算完出来了,还是要阻塞到 cl1
出来,可能这里耗时了。所以再改一下 longestPalindrome
的策略,用一个 for-select
:
select
不能 fallthrough
,所以为了及时 break,减少一次阻塞把每个 case
里都写了退出判断,恶心了一点。
1 | func longestPalindrome(s string) string { |
再提交,应该会快一点吧,结果是——超。出。时。间。限。制。而且超的还很多,只过了22个测试用例。
我哭了,并发了不是应该更快吗?是 leetcode 的锅吧。
还是在本地测试一下吧:
1 | func main() { |
随便搞几个例子循环着跑10000次,$ time go run
看一下。
第一个版本(不并发):
1 | real 0m0.246s |
最后一个版本(for-select
):
1 | real 0m1.005s |
\拍桌 还真的是并发导致了慢啊!
但是,为什么?
问题的分析
为了研究这个问题,我们把刚才三个版本的代码都简化一下:
v1,不并发:
1 | // v1.go |
v2,并发,阻塞在第一个 channel:
1 | // v2.go |
v3,运用 select
:
1 | // v3.go |
我们首先在 somethingCost
里睡眠了相同的时间,来模拟执行一个慢速任务,运行代码得到类似如下的结果:
1 | $ time go run v1.go |
【补充】real时间、user时间和sys时间。
- real时间是指挂钟时间,也就是命令开始执行到结束的时间。这个短时间包括其他进程所占用的时间片,和进程被阻塞时所花费的时间。
- user时间是指进程花费在用户模式中的CPU时间,这是唯一真正用于执行进程所花费的时间,其他进程和花费阻塞状态中的时间没有计算在内。
- sys时间是指花费在内核模式中的CPU时间,代表在内核中执系统调用所花费的时间,这也是真正由进程使用的CPU时间。
参考: Linux命令大全. time命令[OL].https://man.linuxde.net/time, 2020
用物理实验的方法,多次重复实验,得到如下结果:
【注】实验环境:
Computer: MacBook Pro (13-inch, 2017, Two Thunderbolt 3 ports)
CPU: 2.3 GHz Intel Core i5
RAM: 8 GB 2133 MHz LPDDR3
OS: macOS Mojave 10.14.6
Golang: go version go1.12 darwin/amd64
实验次数少,数据不太好,不管了,我懒得搞大规模的实验(其实我只是边研究边写这篇文章打发时间)。
从实验的结果来看,有两点比较肯定:
- Real 时间上,v3 略优于 v2 优于 v1
- User 时间上,v1 约等于 v2 优于 v3
由此,可以推知:
- 在现实环境(正常的多核计算机)使用中,如 v3 版本的程序,使用
goroutine-for-select
的方案可以运行得比较快(real time,与不并发相比),但在等待返回的 channel 不多时与不使用for-select
区别不大。 - 在 goroutine 和 channel 比较少的时候使用
for-select
的策略,进程所花费的时间(user time)会明显加大。
注意,是在 goroutine 和 channel 比较少的时候 v2 的 user time 优于 v3。当我们增多 goroutine,比如开到 1000 个,结果就不一样了(我不想用 v1 依次执行1000次函数,结果可想而知,没有可比性了):
1 | $ time go run v2.go |
所以,在慢速任务比较多的时候,使用类似于 v3 的 for-select
会大幅度提高效率。
我们再来看一下如果任务速度比较快,例如我们直接把 Sleep 注释掉,然后开最外层的循环次加到100次:
1 | $ time go run v1.go |
这种情况下 v2、 v3 就显得没有优势了,这时多线程上下午切换等等的开支会造成比较大的额外开支,导致整体运行效率低下。这也就是我们提交到 leetcode 的代码超时的问题所在。
问题的总结
这个故事告诉我们,不要在需要并发的任务少,通信不复杂的情况下(比如刷 leetcode 的算法题)尝试使用处理高并发的手段,异步的方法还是用来处理比较慢的任务比较好。