2021-10-20 剑指offer2:59~75题目+思路+多种题解
- 写在前面
- 剑指 Offer 59 - I. 滑动窗口的最大值(困难)
- 剑指 Offer 59 - II. 队列的最大值(中等)
- 剑指 Offer 60. n个骰子的点数(中等)
- 剑指 Offer 61. 扑克牌中的顺子
- 剑指 Offer 62. 圆圈中最后剩下的数字
- 剑指 Offer 63. 股票的最大利润(中等)
- 剑指 Offer 64. 求1+2+…+n(中等)
- 剑指 Offer 65. 不用加减乘除做加法
- 剑指 Offer 66. 构建乘积数组(中等)
- 剑指 Offer 67. 把字符串转换成整数(中等)
- 剑指 Offer 68 - I. 二叉搜索树的最近公共祖先
- 剑指 Offer 68 - II. 二叉树的最近公共祖先
写在前面
本文是采用python为编程语言,作者自行练习使用,题目列表为:剑指 Offer(第 2 版),未使用实体书,难度未标注的均为“简单”,我也不是很清楚为什么有几个编号没有提供。“《剑指 Offer(第 2 版)》通行全球的程序员经典面试秘籍。剖析典型的编程面试题,系统整理基础知识、代码质量、解题思路、优化效率和综合能力这 5 个面试要点。”,本文中的思路来源于每道题目中的题解部分,争取提供全面,优化后的题解,其中所有代码已通过题目检验。
剑指 Offer 59 - I. 滑动窗口的最大值(困难)
题目
思路
看到难度就已经排除暴力法拉(暴力法复杂度为O(k(n-k+1))),下面思考这样一个问题即可:加入一个新元素,减少一个元素,如何在更短的时间内找到最大值?
- 堆:维护一个大根堆,每次插入需要O(logk),删除需要O(1)
- 优先队列:维护一个队列,从目的推导队列行为,希望满足队列头永远是最大值,且由于队列的性质,下标逐渐增加。则队列的删除规则:1. 只要下标处于后面的数 大于 下标处于前面的数,则前面的数可以删去 2. 下标移动,已经不在滑动窗口中
题解
- 最大堆:
import heapq
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
# 默认按照元组的第一个值进行heapify,所以后存索引
# python中的堆默认小根堆,取负号
B_heap = [(-nums[index], index) for index in range(k)]
heapq.heapify(B_heap)
res = [-B_heap[0][0]] if nums else []
for index in range(k, n):
heapq.heappush(B_heap,(-nums[index], index))
while B_heap[0][1] <= index-k:
heapq.heappop(B_heap)
res.append(-B_heap[0][0])
return res
- 优先队列:
import heapq
class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
n = len(nums)
queue = []
# 只需要存index即可,因为在添加时已经判断了大小
for index in range(k):
while queue and nums[index] >=nums[queue[-1]]:
queue.pop()
queue.append(index)
res = [nums[queue[0]]] if nums else []
for index in range(k,n):
# 维持队列的递减
while queue and nums[index] >=nums[queue[-1]]:
queue.pop()
queue.append(index)
while queue[0]<= index-k:
queue.pop(0)
res.append(nums[queue[0]])
return res
剑指 Offer 59 - II. 队列的最大值(中等)
题目
思路
类似于题目剑指 Offer 30. 包含min函数的栈,将最大值的操作放在每一个动作(插入or删除)中以避免一次性全部取出+排序的耗费,考虑使用变量维护最大值->在pop后最大值信息丢失,所以使用数据结构进行辅助。而这类题的关键key在于:同步更新。
- 插入:如果新插入的值更大,则该值之前的更小的值没有意义了,因为更小的值一定先出队。维护的是一个单调递减的数据结构,队尾操作。
- 删除:如果删除的值恰为当前最大的值,需要同步删除,队头操作。
所以在这个题中,我们的辅助结构是双端队列,而那个题中,使用栈就可以完成同步更新(原数据结构就是栈,出栈也在队尾判断)。
题解
class MaxQueue:
"""2个数组"""
def __init__(self):
self.queue = []
self.help_queue = []
def max_value(self) -> int:
return self.help_queue[0] if self.help_queue else -1
def push_back(self, value: int) -> None:
self.queue.append(value)
while self.help_queue and self.help_queue[-1] < value:
self.help_queue.pop()
self.help_queue.append(value)
def pop_front(self) -> int:
if not self.queue: return -1
res = self.queue.pop(0)
if res == self.help_queue[0]:
self.help_queue.pop(0)
return res
剑指 Offer 60. n个骰子的点数(中等)
题目
思路
动态归划,状态转移方程是dp加入一个新筛子[index] = sum(dp之前的筛子数[index-k]*1/6)
,其中k是1 ~ 6。因为加上新的筛子后,只有1到6种可能性,而他们分别和上一个状态点数相加,所有的sum的情况概率之和即是新的概率。
注意开辟数组的大小,n个筛子的涉及和的范围为n到6n,共5n+1种。
题解
- 动态规划:注意不是使用二维数组dp来记录之前所有的状态,而是使用两个数组(dp代表上一个筛子,tmp代表加入当前筛子,循环记录)
class Solution:
def dicesProbability(self, n: int) -> List[float]:
dp = [1 / 6] * 6
for cnt in range(2, n + 1):
tmp = [0] * (5 * cnt + 1)
for index in range(len(dp)):
# 对上一个状态的每一个数而言,都可以再多1~6个数字
for k in range(6):
tmp[index + k] += dp[index] / 6
dp = tmp
return dp
剑指 Offer 61. 扑克牌中的顺子
题目
思路
直接统计和排序统计都可以完成题目要求,比较简单,注意一定要充分挖掘题目信息:
- 大小王:遇见0则跳过
- 若干副扑克牌:需要去重操作
- 顺子:最大值减去最小值不超过5
题解
- 遍历+去重:
class Solution:
def isStraight(self, nums: List[int]) -> bool:
repeat = set()
maxnum, minnum = 0, 14
for num in nums:
# 跳过大小王
if num == 0: continue
maxnum = max(maxnum, num) # 最大牌
minnum = min(minnum, num) # 最小牌
# 若有重复,提前返回 false
if num in repeat: return False
repeat.add(num)
return maxnum - minnum < 5
- 遍历+排序:
class Solution:
def isStraight(self, nums: List[int]) -> bool:
joker = 0
nums.sort()
for i in range(4):
# 统计大小王数量
if nums[i] == 0: joker += 1
# 若有重复,提前返回 false
elif nums[i] == nums[i + 1]: return False
return nums[4] - nums[joker] < 5
剑指 Offer 62. 圆圈中最后剩下的数字
题目
思路
约瑟夫问题:
这里我们先给出递推公式f(n, m) = ( f(n-1, m) + m) %n
,其中这个公式的结果是最终胜利者的编号,下面借助图片(图片是n=11,m=3的情况,绿色为下标)进行推导:
- 假设已知n个人且m不超过n的情况,胜利者所处下标为index(之所以这么定义是因为每次淘汰的人是不同的,但是在只有一个人的情况下,胜利者下标是确定的0),则n-1个人的情况,胜利者所处下标为?
- 比较容易得出的一个结论是,当index处于被去掉的编号之后时,新的坐标为index-m,因为重新计数使得原来为m+1坐标的变成了1,后面的依次挪,对每一个而言都是m位
- 观察index处于被去掉的编号之前的情况,其坐标与当前人数(数组长度)有关,为
(index+n-m)
- 下面我们从n-1个人的情况反推回n个人的情况,再加上m大于n的情况,需要取余。从而得到递推关系
index-n+m)%n=(index+m)%n
,简而言之就是返回自己之前(未移动)的位置,再取余
题解
- 递归(便于理解):
# Python 默认的递归深度不够,需要手动设置
sys.setrecursionlimit(100000)
def f(n, m):
if n == 0:
return 0
x = f(n - 1, m)
return (m + x) % n
class Solution:
def lastRemaining(self, n: int, m: int) -> int:
return f(n, m)
- 迭代:递推公式的方法
class Solution:
def lastRemaining(self, n: int, m: int) -> int:
# 剩最后一个人的情况,存活者的下标一定为0
index = 0
# 递推的计算剩下cnt个人的情况下存活者的下标
for cnt in range(2, n + 1):
index = (index + m) % cnt
return index
剑指 Offer 63. 股票的最大利润(中等)
题目
思路
还是动态规划,但是可以省去两遍循环,最小值直接使用变量维护即可
题解
class Solution:
def maxProfit(self, prices: List[int]) -> int:
minprice, maxgain = float('+inf'), 0
for num in prices:
minprice = min(minprice, num)
maxgain = max(maxgain, num-minprice)
return maxgain
剑指 Offer 64. 求1+2+…+n(中等)
题目
思路
- 首先想到的就是求和公式,但是这样就使用了乘除法。但是我们知道,计算机中的本来就没有乘,乘是使用加法和位运算表示的(这里需要复习一下机组知识,我还没写),具体做法为:将B二进制展开,如果某一位index为1,那么这一位对答案的贡献为
A*(1<<index)
。而题目中规定了n的范围,可推出二进制的位数,将for循环全部展开写即可。 - 考虑for循环遍历相加,不可行,于是考虑所有的循环都可以用递归表示。 那么问题在于,如何不使用if就能完成条件判断,以终止递归?采用与运算,当一个条件不满足时,不再进行。
题解
- 模拟乘法(位运算):python中无法将表达式作为一个值,下面是java代码
class Solution {
public int sumNums(int n) {
int ans = 0, A = n, B = n + 1;
boolean flag;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
flag = ((B & 1) > 0) && (ans += A) > 0;
A <<= 1;
B >>= 1;
return ans >> 1;
}
}
作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/qiu-12n-lcof/solution/qiu-12n-by-leetcode-solution/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
- 递归+与运算终止:
class Solution:
def sumNums(self, n):
if n == 1: return 1
n += self.sumNums(n - 1)
return n
剑指 Offer 65. 不用加减乘除做加法
题目
思路
-
这题位运算还是背下来吧,毕竟位运算这种模拟加法用法基本就这题,很容易就忘掉。以下解释来源于Leetcode评论区@端粒和题解区@Krahets:
-
^ 亦或:相当于 无进位的求和, 想象10进制下的模拟情况:(如:19+1=20;无进位求和就是10,而非20;因为它不管进位情况)
-
& 与:相当于求每位的进位数, 先看定义:1&1=1;1&0=0;0&0=0;即都为1的时候才为1,正好可以模拟进位数的情况,还是想象10进制下模拟情况:(9+1=10,如果是用&的思路来处理,则9+1得到的进位数为1,而不是10,所以要用<<1向左再移动一位,这样就变为10了);
这样公式就是:
(a^b) ^ ((a&b)<<1)
, 即:每次无进位求 + 每次得到的进位数,我们需要不断重复这个过程,直到进位数为0为止 -
-
注意python没有变量位数的概念,有无限位,在python中负数也是以补码的形式存储,因此负数的高位无限补1,正数的高位无限补0,如果使用while循环至b为0会一直运算。所以我们需要进行两个事情:
- 在运算前,把负数的补码转换成高位为0的变换形式:将数字与十六进制数 0xffffffff 相与。可理解为舍去此数字 32 位以上的数字(将 32 位以上都变为 0 ),从无限长度变为一个 32 位整数
- 在运算后,把负数的补码还原成高位为1的原始形式:若补码 a 为负数( 0x7fffffff 是最大的正数的补码 ),需执行 ~(a ^ x) 操作,将补码还原至 Python 的存储格式。 a ^ x 运算将 1 至 32 位按位取反; ~ 运算是将整个数字取反;因此, ~(a ^ x) 是将 32 位以上的位取反,1 至 32 位不变。
:
题解
class Solution:
def add(self, a: int, b: int) -> int:
x = 0xffffffff
a, b = a & x, b & x
while b != 0:
a, b = (a ^ b), (a & b) << 1 & x
return a if a <= 0x7fffffff else ~(a ^ x)
作者:jyd
链接:https://leetcode-cn.com/problems/bu-yong-jia-jian-cheng-chu-zuo-jia-fa-lcof/solution/mian-shi-ti-65-bu-yong-jia-jian-cheng-chu-zuo-ji-7/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
剑指 Offer 66. 构建乘积数组(中等)
题目
思路
经典题目,看图:
构建两个dp即可,一个是从前向后的累乘积,另一个是从后向前的累乘积,然后对每个下标遍历,左边乘右边即为答案
题解
- 动态规划未优化版:
class Solution:
def constructArr(self, a: List[int]) -> List[int]:
n = len(a)
l2r,r2l = [1]*n,[1]*n
for i in range(n-1): l2r[i+1] = l2r[i]*a[i]
for i in range(n-1,0,-1): r2l[i-1] = r2l[i]*a[i]
res = []
for i in range(n):res.append(l2r[i]*r2l[i])
return res
- 动态规划优化版:第三次的for循环可以和上面任意一个for循环合并,一边dp(直接省成一个变量拉)一边出结果
class Solution:
def constructArr(self, a: List[int]) -> List[int]:
n = len(a)
l2r, res, tmp = [1]*n, [1]*n, 1
for i in range(n-1): l2r[i+1] = l2r[i]*a[i]
for i in range(n-1,-1,-1):
res[i] = l2r[i]*tmp
tmp *= a[i]
return res
- 动态规划终极优化版:res[i]只用到l2r[i],那我直接在l2r上乘就好了,下面的代码统一使用res表示这个数组
class Solution:
def constructArr(self, a: List[int]) -> List[int]:
n = len(a)
res, tmp = [1]*n, 1
for i in range(n-1): res[i+1] = res[i]*a[i]
for i in range(n-1,-1,-1):
res[i] = res[i]*tmp
tmp *= a[i]
return res
剑指 Offer 67. 把字符串转换成整数(中等)
题目
思路
- 状态机:下面是官方给的图解,状态有0起始,1负号,2数字,3终止。转移条件有空格,正负号,数字,其它
- 注意对越界数字的处理:python中没有这一说,直接比较大小即可。但是如果严谨来分析,需要使用提前判断,即
题解
- 状态机:
INT_MAX = 2**31-1
INT_MIN = -2**31
class Solution:
def strToInt(self, str: str) -> int:
states = [
{' ':0,'x':4,'s':1,'d':2},#0
{'d':2,'x':3,' ':3,'s':3},#1
{'d':2,'x':3,' ':3,'s':3},#2
{'d':3,'x':3,' ':3,'s':3},#3
]
flag,ans,cur = 1,0,0
for c in str:
if c == ' ':t=' '
elif c in '+-':t='s'
elif c.isdigit():t='d'
else:t = 'x'
# 添加一种不存在的状态代表从开始碰到字母,提前结束循环
if cur==4: return ans
cur = states[cur][t]
if cur == 2:
ans = 10*ans + int(c)
ans = min(INT_MAX,ans) if flag == 1 else min(-INT_MIN,ans)
elif cur == 1:
flag = 1 if c == '+' else -1
return flag * ans
- 顺序判断:因为题目状态较为简单,可以直接顺序执行写出下面的逻辑判断
INT_MAX = 2**31-1
INT_MIN = -2**31
# 提前判断
BNDRY = 2**31 // 10
class Solution:
def strToInt(self, str: str) -> int:
res, i, sign, length = 0, 0, 1, len(str)
if not str: return 0
while str[i] == ' ':
i += 1
# 防止空字符串的情况后续访问越界
if i == length: return 0
if str[i] == '-': sign = -1
if str[i] in '+-': i += 1
for c in str[i:]:
if not '0' <= c <= '9' : break
if res > BNDRY or res == BNDRY and c > '7':
return INT_MAX if sign == 1 else INT_MIN
# 如何从字符拼接成数字:利用乘法和ascii码的比较
res = 10 * res + ord(c) - ord('0')
return sign * res
剑指 Offer 68 - I. 二叉搜索树的最近公共祖先
题目
思路
线索二叉树,通过比较结点的大小进行迭代or递归寻找即可
题解
- 迭代(遍历查找):
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
# 直接使用root移动,无须新建结点
# 可以先来一步判断+交换让q和p保持大小顺序一定(比如p一直大于q),下面判断更简练
while root:
if root.val < p.val and root.val < q.val:
root = root.right
elif root.val > p.val and root.val > q.val:
root = root.left
else: break
return root
- 递归:
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
if root.val < p.val and root.val < q.val:
return self.lowestCommonAncestor(root.right, p, q)
if root.val > p.val and root.val > q.val:
return self.lowestCommonAncestor(root.left, p, q)
return root
剑指 Offer 68 - II. 二叉树的最近公共祖先
题目
分析
- 当然可以选择dfs递归+存储路径,但是这个方法太笨了,且dfs在前面已经练过n遍了,不再给出咯
- 下面我们考察一个结点的不同情况:
- 若树里面存在p,也存在q,则返回他们的公共祖先。
- 若树里面只存在p,或只存在q,则返回存在的那一个。
- 若树里面即不存在p,也不存在q,则返回null。
题解
- 递归:
class Solution:
def lowestCommonAncestor(self, root: TreeNode, p: TreeNode, q: TreeNode) -> TreeNode:
if not root or root == p or root == q: return root
left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
if not left: return right
if not right: return left
# 左右都有的情况,递归的性质决定了最深层的root会层层返回
return root