前言:算法训练系列是做《代码随想录》一刷,个人的学习笔记和详细的解题思路,总共会有60篇博客来记录,记录结构上分为 思路,代码实现,复杂度分析,思考和收获,四个方面。如果这个系列的博客可以帮助到读者,就是我最大的开心啦,一起LeetCode一起进步呀;)
目录
LeetCode93.复原IP地址
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
Leetcode. 78 子集
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
Leetcode90.子集II
1. 思路
2. 代码实现
3. 复杂度分析
4. 思考与收获
链接:93. 复原 IP 地址 - 力扣(LeetCode)
做这道题目之前,最好先把**131.分割回文串 (opens new window)**这个做了。
这道题目相信大家刚看的时候,应该会一脸茫然。其实只要意识到这是切割问题,切割问题就可以使用回溯搜索法把所有可能性搜出来,然后根据题意进行一些合法性的判断,和刚做过的**131.分割回文串 (opens new window)**就十分类似了。切割问题可以抽象为树型结构,如图:
在**131.分割回文串 (opens new window)**中我们就提到切割问题类似组合问题。
startIndex一定是需要的,因为不能重复分割,记录下一层递归分割的起始位置;本题我们还需要一个变量pointNum,记录添加逗点的数量。
所以代码如下:
# python3
class Solution:
def __init__(self):
self.result = []
def restoreIpAddresses(self, s: str) -> List[str]:
'''
本质切割问题使用回溯搜索法,本题只能切割三次,所以纵向递归总共四层
因为不能重复分割,所以需要start_index来记录下一层递归分割的起始位置
添加变量point_num来记录逗号的数量[0,3]
'''
self.result.clear()
if len(s) > 12: return []
self.backtracking(s, 0, 0)
return self.result
def backtracking(self, s: str, start_index: int, point_num: int) -> None:
终止条件和**131.分割回文串 (opens new window)**情况就不同了,本题明确要求只会分成4段,所以不能用切割线切到最后作为终止条件,而是分割的段数作为终止条件;
pointNum表示逗点数量,pointNum为3说明字符串分成了4段了,然后验证一下第四段是否合法,如果合法就加入到结果集里;
# python3
def backtracking(self, s: str, start_index: int, point_num: int) -> None:
# Base Case
if point_num == 3:
if self.is_valid(s, start_index, len(s)-1):
self.result.append(s[:])
return
在**131.分割回文串 (opens new window)**中已经讲过在循环遍历中如何截取子串;
在for (int i = startIndex; i < s.size(); i++)
循环中 [startIndex, i] 这个区间就是截取的子串,需要判断这个子串是否合法。如果合法就在字符串后面加上符号.
表示已经分割,如果不合法就结束本层循环,如图中剪掉的分支:
然后就是递归和回溯的过程:
递归调用时,下一层递归的startIndex要从i+2开始(因为需要在字符串中加入了分隔符.
),同时记录分割符的数量pointNum 要 +1。
回溯的时候,就将刚刚加入的分隔符.
删掉就可以了,pointNum也要-1。
# python3
# 单层递归逻辑
for i in range(start_index, len(s)):
# [start_index, i]就是被截取的子串
if self.is_valid(s, start_index, i):
s = s[:i+1] + '.' + s[i+1:]
self.backtracking(s, i+2, point_num+1)
# 在填入.后,下一子串起始后移2位
s = s[:i+1] + s[i+2:] # 回溯
else:
# 若当前被截取的子串大于255或者大于三位数,直接结束本层循环
break
最后就是在写一个判断段位是否是有效段位了。
主要考虑到如下三点:
代码如下:
def is_valid(self, s: str, start: int, end: int) -> bool:
if start > end: return False
# 若数字是0开头,不合法
if s[start] == '0' and start != end:
return False
if not 0 <= int(s[start:end+1]) <= 255:
return False
return True
class Solution:
def __init__(self):
self.result = []
def restoreIpAddresses(self, s: str) -> List[str]:
'''
本质切割问题使用回溯搜索法,本题只能切割三次,所以纵向递归总共四层
因为不能重复分割,所以需要start_index来记录下一层递归分割的起始位置
添加变量point_num来记录逗号的数量[0,3]
'''
self.result.clear()
if len(s) > 12: return []
self.backtracking(s, 0, 0)
return self.result
def backtracking(self, s: str, start_index: int, point_num: int) -> None:
# Base Case
if point_num == 3:
if self.is_valid(s, start_index, len(s)-1):
self.result.append(s[:])
return
# 单层递归逻辑
for i in range(start_index, len(s)):
# [start_index, i]就是被截取的子串
if self.is_valid(s, start_index, i):
s = s[:i+1] + '.' + s[i+1:]
self.backtracking(s, i+2, point_num+1) # 在填入.后,下一子串起始后移2位
s = s[:i+1] + s[i+2:] # 回溯
else:
# 若当前被截取的子串大于255或者大于三位数,直接结束本层循环
break
def is_valid(self, s: str, start: int, end: int) -> bool:
if start > end: return False
# 若数字是0开头,不合法
if s[start] == '0' and start != end:
return False
if not 0 <= int(s[start:end+1]) <= 255:
return False
return True
(我自己这样子分析的,正确性有待考究)
时间复杂度:O(2^N)
因为每一个元素的状态无外乎割与不割,所以时间复杂度为O(2^n)
空间复杂度:O(1)
本题递归深度最多为4,递归栈所用空间为常数级别,result和path都是全局变量,传的都是引用,不会有额外申请新的空间;
Reference: 代码随想录 (programmercarl.com)
本题学习时间:90分钟。
链接:78. 子集 - 力扣(LeetCode)
求子集问题和**77.组合 (opens new window)和131.分割回文串 (opens new window)**又不一样了。
子集问题 vs 组合问题分割问题
如果把 子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
什么时候for可以从0开始呢?
求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题后续就会讲到的。
以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
全局变量数组path为子集收集元素,二维数组result存放子集组合。(也可以放到递归函数参数里);递归函数参数在上面讲到了,需要startIndex。
class Solution:
def __init__(self):
self.path: List[int] = []
self.paths: List[List[int]] = []
def subsets(self, nums: List[int]) -> List[List[int]]:
self.paths.clear()
self.path.clear()
self.backtracking(nums, 0)
return self.paths
def backtracking(self, nums: List[int], start_index: int) -> None:
从图中可以看出:
剩余集合为空的时候,就是叶子节点。
那么什么时候剩余集合为空呢?就是startIndex已经大于数组的长度了,就终止了,因为没有元素可取了,代码如下:
def backtracking(self, nums: List[int], start_index: int) -> None:
# Base Case
if start_index == len(nums):
return
其实可以不需要加终止条件,因为下面for循环的条件,startIndex >= nums.size(),本层for循环本来也结束了
求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
那么单层递归逻辑代码如下:
# 单层递归逻辑
for i in range(start_index, len(nums)):
self.path.append(nums[i])
self.backtracking(nums, i+1)
self.path.pop() # 回溯
# 回溯算法 子集问题
# time:O(2^N);space:O(N)
class Solution(object):
# 定义好全局变量
def __init__(self):
self.path = []
self.result = []
def subsets(self, nums):
"""
:type nums: List[int]
:rtype: List[List[int]]
"""
if nums == []: return [[]]
self.backtracking(nums,0)
return self.result
def backtracking(self,nums,startIndex):
# 收集结果要在终止条件前面,不然会错过叶子节点的值
self.result.append(self.path[:])
# 终止条件
if startIndex > len(nums)-1:
return
# 单层循环逻辑
for i in range(startIndex,len(nums)):
self.path.append(nums[i])
self.backtracking(nums,i+1)
self.path.pop()
时间复杂度:O(2^n)
因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2^n)
空间复杂度:O(n)
递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)
Reference: 代码随想录 (programmercarl.com)
本题学习时间:60分钟。
链接:90. 子集 II - 力扣(LeetCode)
这道题目和**78.子集 (opens new window)**区别就是集合里有重复元素了,而且求取的子集要去重。
那么关于回溯算法中的去重问题,在40.组合总和II (opens new window)中已经详细讲解过了,和本题是一个套路。剧透一下,后期要讲解的排列问题里去重也是这个套路,所以理解“树层去重”和“树枝去重”非常重要。
用示例中的[1, 2, 2] 来举例,如图所示: (注意去重需要先对集合排序)
从图中可以看出,同一树层上重复取2 就要过滤掉,同一树枝上就可以重复取2,因为同一树枝上元素的集合才是唯一子集!
# 回溯算法 子集问题
# time:O(2^N);space:O(N)
class Solution(object):
# 定义全局变量
def __init__(self):
self.path = []
self.result = []
def subsetsWithDup(self, nums):
"""
:type nums: List[int]
:rtype: List[List[int]]
"""
# 排序 time:O(NlogN)
nums.sort()
if nums == []: return [[]]
self.backtracking(nums,0,[0]*len(nums))
return self.result
def backtracking(self,nums,startIndex,used):
# 收集结果,每个节点都收集
self.result.append(self.path[:])
# 递归终止条件
if startIndex > len(nums)-1:
return
# 单层递归逻辑
for i in range(startIndex,len(nums)):
# 去重逻辑
if i>0 and nums[i] == nums[i-1] and used[i-1] == 0:
continue
self.path.append(nums[i])
used[i] = 1
self.backtracking(nums,i+1,used)
self.path.pop()
used[i] = 0
时间复杂度:O(2^n)
因为每一个元素的状态无外乎取与不取,所以时间复杂度为O(2^n)
空间复杂度:O(n)
递归深度为n,所以系统栈所用空间为O(n),每一层递归所用的空间都是常数级别,注意代码里的result和path都是全局变量,就算是放在参数里,传的也是引用,并不会新申请内存空间,最终空间复杂度为O(n)
Reference: 代码随想录 (programmercarl.com)
本题学习时间:60分钟。
本篇学习时间为3个多小时,总结字数为6000+;针对回溯算法中的切割问题和子集问题做了相应的练习,切割问题本质上也是一种组合问题,子集问题与前两者的区别是,子集问题收集所有节点上的数值。(求推荐!)