今天才发现,我刷题的方式不对。LeetCode 算法题,更像是披着编程语法外衣的数学题,很多典型的问题都有较优的解题思路与方法。之前我都是先尽力硬磕,几个小时肝出个解法,然后匆匆写一篇题记,观摩分析下更优的解法。但这个过程缺乏对更优解法的练习,再次遇到类似题目,首先想到的还是之前自己成功的那个思路,很难应用到本该学到的新的更优的解法。就好比曾经高考的数学题,想快速解决,看到题目就得有对应的解题思路。
比如昨天刷的那道不用乘除号来实现除法运算的问题,我沾沾自喜地使用累加配合竖式运算,然而更多的解法是应用位运算——做完题目之后,我还是没有学会位运算。再比如,之前涉及括号的题目,用到了栈、动态规划,初接触、分析时大概了解到一些,再遇到类似的题目却完全没头绪。
对于 LeetCode 题目,我该转变下思路,给自己固定一段时间(比如半个小时)来自行思索,时间一到务必学习题解,并能在学习完之后重新独立用该题解方法解答出题目,这样才能提高刷题的效率。
所以,今天的目标是,做完这两道题,可以掌握题解中更优的方法来独立解决问题。
第一题
第 31 题:下一个排列
实现获取下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。
如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。
必须原地修改,只允许使用额外常数空间。
以下是一些例子,输入位于左侧列,其相应输出位于右侧列。
1,2,3 → 1,3,2
3,2,1 → 1,2,3
1,1,5 → 1,5,1
尝试思路
给的示例比较简单,可以举一个更长的例子:比如输入 [4,2,0,2,3,2,0] 可以把它想像成数字 4202320,题目中要找的就是相同数字元素变换之后组成的稍微只比它大的数字:4203022,即 [4,2,0,3,0,2,2]。
对于这题,琢磨半天之后我的思路是从数列后面开始看:
先取后两位 [2,0] 这个没法调整、已经最大;
再取后三位 [3,2,0] 这个也是三位中最大的组合;
取后四位 [2,3,2,0] 明显有更大的组合:3220、3202、3022。但我们要找下一个比它大的,也就是找到的结果中最小的,所以选择 3022,这样便可得到结果:变换原数列最后四位为 [3,0,2,2],原数列变为 [4,2,0,3,0,2,2]。
所以代码也是围绕着从后不断扩大范围,找到类似局部不是最大组合的情况,在该情况下,变换局部首位之后取最小结果,最终将原树立变换为所求结果。
代码实现
class Solution:
def nextPermutation(self, nums: List[int]) -> None:
"""
Do not return anything, modify nums in-place instead.
"""
least = sorted(nums)
largest = least[::-1]
# 先判断整个数列是否已满足降序排列
if nums == largest:
# 若是,将 nums 由小到大排序
nums.sort()
# 题目会检测 nums 无需返回
return
# 获取列表长度
length = len(nums)
# r 用来标记我们从后截取局部的坐标起始点
r = length-2
# while 循环控制 r,不断增大我们截取的范围
while r>=0 :
# part 即我们在尾端截取的若干位
part = nums[r:]
# 先生成该部分最大的组合
part_max = sorted(part,reverse=True)
# 如果目前取到的组合与最大组合相同,则扩大截取范围
if part==part_max:
r-=1
# 若目前并非最大组合
else:
# 先取首位值作为比较的标尺
standard = nums[r]
# 用字典记录值和其索引
dic = {}
# 在该范围内寻找大于首位的数值
for i in range(r+1,length):
if nums[i]>standard:
# 记录该值与其索引
dic[nums[i]]=i
# 字典的键即所有比首位大的值
if dic!={}:
# 我们将键转为列表
higher = list(dic.keys())
# 再将其排序
higher.sort()
# 取排序后的首位,即比标尺大的值中的最小一个
higher_value,higher_index = higher[0],dic[higher[0]]
# 交换该值与首位值在 nums 中的值
nums[r],nums[higher_index] = higher_value,nums[r]
break
# 我们目前只是变换了截取部分的首位,对于首位之后的部分,我们要让它组合成最小值
# 取局部首位之后的部分
right_part = nums[r+1:]
# 排序获取最小组合
right_part.sort()
# 将 nums 在对应位置重新赋值
nums[r+1:] = right_part
# 无需返回,题目检测 nums
return
提交测试表现:
执行用时 : 44 ms, 在所有 Python3 提交中击败了 58.89% 的用户
内存消耗 : 13.5 MB, 在所有 Python3 提交中击败了 8.33% 的用户
我对自己设计的这套解法挺满意的,接下来去观摩下题解精华。
观摩题解
很开心,官方题解的“一遍扫描”解法就是刚我们尝试的思路,可能实现过程略有偏差,但整体思路是一致的。
也顺便搬运了下其更形象的动图描述:
那这个题之后再遇到也可以掌握了,需要优化的可能是具体过程中排序的细节等了。我们继续看下一题咯~
第二题
第 32 题:最长有效括号
给定一个只包含 ‘(’ 和 ‘)’ 的字符串,找出最长的包含有效括号的子串的长度。
示例 1:
输入: "(()"
输出: 2
解释: 最长有效括号子串为 "()"
示例 2:
输入: ")()())"
输出: 4
解释: 最长有效括号子串为 "()()"
尝试思路
引发我文章最开始想法的便是这道题了,之前在第 20 题“有效的括号” 和第 22 题“括号生成” 时曾接触到栈的应用,也就是通过列表来实现元素先入后出,但没有深挖和练习。到今天这道题目,除了暴力解法我没能想出其它解法。
关于暴力解法,就是遍历字符串,若该位是左括号,那么就对它之后的遍历,直到不满足括号的匹配规则结束,记录长度;对每一位字符都进行如此运算,最后取最大值。然后就因为一个特别长的测试用例,提交测试结果是超出时间限制。
所以,今天我们的任务是,弄懂题解中的做法并能够独立完成代码解答。题解中比较典型的解法有两类:栈和动态规划。这里结合着题解与个人理解来做下笔记。
栈的解法
首先应用栈的解法,一种思路就是我们用栈(即列表)来记录字符串中左括号出现的情况:我们对字符串遍历,遇到左括号,就将它记录在 record 栈(列表)中;当遇到右括号时,我们先看栈中是否有左括号记录,若有的话,就可以将栈里最新的记录取出,这时,我们再记录下取出的左括号与新遇到的右括号坐标,它们构成了我们满足条件的子串。
再遍历字符串的过程中不断记录这样可以匹配的左右括号坐标,最终可以拿到一个列表,记录着所有满足条件的子串起始、结束点坐标,这时如果我们对这个记录排序,那么连续出现的坐标即连续不断的子串,我们取其最大长度即要求的结果。
代码实现:栈的解法
class Solution:
def longestValidParentheses(self, s: str) -> int:
# 用来记录左括号的栈
record = []
# 用来记录子串起点、终点的栈
result = []
# 对 s 字符串遍历
for i in range(len(s)):
# 如果是左括号,将索引记录到 record
if s[i]=="(":
record.append(i)
# 如果是右括号,且有左括号记录
elif record and s[i]==")":
# 将最新的左括号索引剔除,但记录到 result 栈中,这是子串起点
result.append(record.pop())
# 将 i 即右括号索引记录到 result 栈中,这是子串终点
result.append(i)
# 最终 result 栈会记录所有子串起点终点,排序即可看它们的分布情况
result.sort()
# 对记录遍历,寻找其中连续坐标的最大长度
count = 1
max_len = 0
for i in range(1,len(result)):
# 如果比上一位大 1,则属于连续坐标
if result[i]-result[i-1]==1:
count+=1
else:
count = 1
if count>1:
max_len = max(count,max_len)
# 最终返回最大值
return max_len
提交测试结果:
执行用时 : 72 ms, 在所有 Python3 提交中击败了 28.46% 的用户
内存消耗 : 14.3 MB, 在所有 Python3 提交中击败了 11.11% 的用户
动态规划
关于动态规划,结合着题目,可以进一步加深理解。首先看看动态规划的概念:
动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程(decision process)最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决策过程(multistep decision process)的优化问题时,提出了著名的最优化原理(principle of optimality),把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方法——动态规划。1957年出版了他的名著《Dynamic Programming》,这是该领域的第一本著作。
百度百科:动态规划
初接触动态规划,我的感觉是升级版的递归解法,也翻到一篇对比递归和动态规划的文章:
《递归和动态规划》https://blog.csdn.net/DeepIT/article/details/6530282
OK,我们来看题目,动态规划通常都会定义 dp 列表( dynamic programming 缩写),dp[i] 表示第 i 位上我们题目中要求的最长子串括号长度。那么我们要找下 dp[i] 在不同情况下的表示或计算方法。
当第 i 位上是左括号时,dp[i] 相较 dp[i-1] 不会变化;
当第 i 位上是右括号时,那么就出现两种情况:i-1 位上左括号或右括号;
若其为左括号,那么 i-1 位上的左括号与 i 位上的右括号就完成闭合,那么此时 dp[i] 记录的子串最大长度相较 dp[i-2] 是多了 2,所以 dp[i] = dp[i-2] + 2。当然,得满足 i 不小于 2 才行。
类似地,再继续分析在 i-1 位上是右括号的情况,会更复杂,但是也能找到 dp[i] 和之前位置上 dp 值的关系。
只要我们的分析是全面涵盖所有可能性,那么便可以写出代码来运算出结果:
截图来源:https://leetcode-cn.com/problems/longest-valid-parentheses/solution/zhan-dong-tai-gui-hua-zhu-xing-jie-shi-dai-ma-pyth/
实现代码:动态规划
class Solution:
def longestValidParentheses(self, s: str) -> int:
# 空字符串返回 0
if(not s):
return 0
res=0
n=len(s)
# 初始化 dp 数列,每一位上都是 0 的数列
dp=[0]*n
# 遍历字符串 s
for i in range(1,len(s)):
# 如果第 i 位是右括号
if(s[i]==")"):
# 如果 i-1 位上是左括号
if(s[i-1]=="("):
# dp[i] 与 dp[i-2] 的关系,因为 i 在循环遍历,dp[i-2] 是先求出来的
dp[i]=dp[i-2]+2
# 如果 i-1 位上是右括号 且满足如下条件
if(s[i-1]==")" and i-dp[i-1]-1>=0 and s[i-dp[i-1]-1]=="("):
# dp[i] 与之前 dp 值的关系
dp[i]=dp[i-1]+dp[i-dp[i-1]-2]+2
# 记录最大的 dp[i] 值
res=max(res,dp[i])
return res
# 作者:wu_yan_zu
# 链接:https://leetcode-cn.com/problems/longest-valid-parentheses/solution/zhan-dong-tai-gui-hua-zhu-xing-jie-shi-dai-ma-pyth/
提交测试表现:
执行用时 : 52 ms, 在所有 Python3 提交中击败了 76.11% 的用户
内存消耗 : 13.7 MB, 在所有 Python3 提交中击败了 11.11% 的用户
结论
今天题目的难度情况:31 题是中等难度,32 题是困难级别。我只完成了前者,但这次从后者解法中学到不少,也对栈的解法独立进行重新编码尝试。但对动态规划感觉还是挺难掌握,因为要分析到所有可能性并找出相应规律,这个得多多练习才有可能。这次能理解透这道题先,之后遇到我们再总结再整理吧。
时间飞快,五月开始了,正好可以尝试下 LeetCode 官方组织的每日一题和小比赛,全新的开始,继续努力~