已完成:

TODO:

  1. 代码随想录剩下的(相关题目推荐我没做,有缘再巩固)
  2. labuladong
  3. hot150
  4. 剑指offer
  5. 力扣75

代码随想录

1. 数组

1.1 数组理论基础

数组是存放在连续内存空间上的相同类型数据的集合。

因为数组在内存空间的地址是连续的,所以我们在删除或者增添元素的时候,就难免要移动其他元素的地址。

  • vector的底层实现是array
    • 在 C++ 中,vector是一种动态数组,它的底层是基于普通数组(array)来实现的。这意味着vector在内存中是以连续的存储单元来存放元素的,就像普通数组一样。这种底层实现方式使得vector能够像数组一样快速地随机访问元素,即可以通过索引快速获取到任意位置的元素。
  • vector是容器,不是数组
    • 容器的概念vector是 C++ 标准模板库(STL)中的一种容器类。容器是一种能够存储和管理其他对象的对象,它提供了一系列的成员函数和操作符,用于方便地对存储的元素进行各种操作,如插入、删除、遍历等。vector作为容器,具有很多方便的功能和特性,比如它可以自动管理内存,根据元素的添加和删除自动调整自身的大小。
    • 与数组的区别:虽然vector在底层利用了数组的存储方式,但它和普通的 C 风格数组有很多不同之处。普通数组的大小是固定的,在定义时就需要指定其大小,而且在程序运行期间大小不能改变。而vector的大小是可以动态变化的,可以在运行时根据需要添加或删除元素,它会自动分配和释放内存来适应元素数量的变化。

在C++中二维数组是连续分布的,对于int型数组,两个相邻数组元素地址差4个字节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void test_arr() {
int array[2][3] = {
{0, 1, 2},
{3, 4, 5}
};
//0x7ffee4065820 0x7ffee4065824 0x7ffee4065828
cout << &array[0][0] << " " << &array[0][1] << " " << &array[0][2] << endl;
//0x7ffee406582c 0x7ffee4065830 0x7ffee4065834
cout << &array[1][0] << " " << &array[1][1] << " " << &array[1][2] << endl;
}

int main() {
test_arr();
}

像Java是没有指针的,同时也不对程序员暴露其元素的地址,寻址操作完全交给虚拟机,所以看不到每个元素的地址情况。

1
2
3
4
5
6
7
8
public static void test_arr() {
int[][] arr = {{1, 2, 3}, {3, 4, 5}, {6, 7, 8}, {9,9,9}};
//[I@7852e922 [I@4e25154f [I@70dea4e [I@5c647e05
System.out.println(arr[0]);
System.out.println(arr[1]);
System.out.println(arr[2]);
System.out.println(arr[3]);
}
  • arr[0] 实际上是一个一维数组对象的引用
  • 输出一个类似 [I@hashcode 的字符串,其中 [I 表示这是一个一维整数数组,@hashcode 是该数组对象在内存中的哈希码,它可以在一定程度上被认为是该数组对象的地址码(但不是真正的物理地址,而是处理过后的数值)
  • 行指针数组在内存中是连续存储的,而每个行所指向的一维数组(即二维数组的每一行)在内存中的存储位置是不连续的
    • 这样可以使每行的长度不同,实现不规则的二维数组
算法通关数组3

1.2 二分查找

704. 二分查找

二分法的前提条件:有序数组+无重复元素

写二分法,区间的定义一般为两种,左闭右闭即[left, right],或者左闭右开即[left, right)。

  • 时间复杂度:O(log n)
  • 空间复杂度:O(1)
    • 这是因为search只使用left、right、middle,在执行过程中额外占用的空间是固定的,与输入数据的规模n无关
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public int search(int[] nums, int target) {
//先在开头判断下可以提升效率,避免当 target 小于nums[0] nums[nums.length - 1]时多次循环运算
if (target < nums[0] || target > nums[nums.length - 1]) {
return -1;
}
int left = 0;
int right = nums.length - 1;//注意可以写成并排,int left = 0, right = nums.length - 1;
while(left <= right){
int middle = (left + right) / 2;//当left和right都很大时可能会溢出,
// int mid = left + ((right - left) >> 1);写成位运算可以提升效率,改为减法的形式也可以避免溢出。注意要写括号,不然+的优先级大于>>有符号右移
if(nums[middle] == target){ //看target是否在[left, right]区间内
return middle;
}else if(nums[middle] < target){
left = middle + 1;
}else{
right = middle - 1;
}
}
return -1;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def search(self, nums: List[int], target: int) -> int:#-> int是返回值类型提示,List[int]是参数类型提示,self 会自动指向调用该方法的对象
if nums[0] > target or nums[len(nums) - 1] < target: #注意逻辑或
return -1
left, right = 0, len(nums) - 1
while left <= right:
mid = left + ((right - left) >> 1)
if nums[mid] == target:
return mid
elif nums[mid] < target:
left = mid + 1
else:
right = mid - 1
return -1

1.3 移除元素

27. 移除元素

不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并原地修改输入数组。不需要考虑数组中超出新长度后面的元素。

可以使用暴力解法,发现需要移除的元素,就将数组集体向前移动一位

  • 时间复杂度:O(n^2)
  • 空间复杂度:O(1)

快慢指针法

双指针法(快慢指针法): 通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。

定义快慢指针:时间复杂度:O(n)、空间复杂度:O(1)。并不改变元素的相对位置。

  • 快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
  • 慢指针:指向更新新数组下标的位置
1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public int removeElement(int[] nums, int val) {
int slow = 0;//fast指向新数组的元素,slow表示新数组的下标
for(int fast = 0; fast < nums.length; fast++){
if(nums[fast] != val){
nums[slow] = nums[fast];
slow++;
}
}
return slow;
}
}
1
2
3
4
5
6
7
8
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
fast, slow = 0, 0
for fast in range(len(nums)):#fast会遍历现有数组的每一个值,等于val的被覆盖
if nums[fast] != val:
nums[slow] = nums[fast]
slow += 1
return slow

相向双指针法

1
2
输入:nums = [0,1,2,2,3,0,4,2], val = 2
输出:5, nums = [0,1,4,0,3,_,_,_]

题目描述中的示例告诉我们不一定要按照顺序得到新数组,所以可以另一个指针从后往前

可以让右指针不断地指向不为val的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public int removeElement(int[] nums, int val) {
int left = 0, right = nums.length - 1;//fast指向新数组的元素,slow表示新数组的下标
while(right >= 0 && nums[right] == val){
right--;//先把右指针移到第一个不是val的值
}
for(; left <= right; left++){
if(nums[left] == val){
nums[left] = nums[right];
right--;
while(right >= 0 && nums[right] == val){
right--;//先把右指针移到第一个不是val的值
}
}
}
return left;
}
}

也可以不判断右指针是否为val,先用右侧覆盖左侧再说,后面再检查左指针(最简洁)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public int removeElement(int[] nums, int val) {
int left = 0, right = nums.length - 1;//fast指向新数组的元素,slow表示新数组的下标
while(left <= right){
if(nums[left] == val){
nums[left] = nums[right];
right--;//不管right指向的是不是val,先覆盖,反正马上left又会检查
}else{
left++;
}
}
return left;
}
}
1
2
3
4
5
6
7
8
9
10
class Solution:
def removeElement(self, nums: List[int], val: int) -> int:
left, right = 0, len(nums) - 1
while left <= right:
if nums[left] == val:
nums[left] = nums[right]
right -= 1
else:
left += 1
return left

1.4 有序数组的平方

977. 有序数组的平方

给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。

暴力法: O(n + nlogn)

双指针法:两侧的数字绝对值大,所以平方也大,所以可以用两侧到中间,不断比较大小。O(n)

  • 注意这道题得创建个新数组,如果在原数组上改的话两个指针不够image-20250122204856186
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int[] sortedSquares(int[] nums) {
int left = 0, right = nums.length - 1;
int i = nums.length - 1;
int[] result = new int[nums.length];
while(left <= right){
if(nums[left] * nums[left] >= nums[right] * nums[right]){
result[i--] = nums[left] * nums[left];
left++;
}else{
result[i--] = nums[right] * nums[right];
right--;
}
}
return result;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def sortedSquares(self, nums: List[int]) -> List[int]:
l, r = 0, len(nums) - 1
res = []
while l <= r:
if abs(nums[l]) >= abs(nums[r]):
res.append(nums[l] ** 2)
l += 1
else:
res.append(nums[r] ** 2)
r -= 1
return res[::-1]

1.5 长度最小的子数组

209. 长度最小的子数组

找出该数组中满足其和 ≥ s 的长度最小的 连续 子数组,并返回其长度

暴力法:两层循环,看每个起点连续多少能超过s,比较哪个最短

  • 时间复杂度:O(n^2)
  • 空间复杂度:O(1)

滑动窗口:不断调节子序列的起始位置和终止位置,从而得出我们要想的结果

只用一个for循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。(不然还要一个循环表示起始位置:暴力)

滑动窗口也可以理解为双指针法的一种:主要确定如下三点:

  • 窗口内是什么?就是 满足其和 ≥ s 的长度最小的 连续 子数组。
  • 如何移动窗口的起始位置?如果当前窗口的值大于等于s了,窗口就要向前移动了。(此时已获得一个子序列)
  • 如何移动窗口的结束位置?

精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置

  • 时间复杂度:O(n):因为每个元素在滑动窗后进来操作一次,出去操作一次,其实是O(2n)
  • 空间复杂度:O(1)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int res = Integer.MAX_VALUE;
int sum = 0;//记录当前子序列的和
int i = 0; // 记录序列的左指针
for(int j = 0; j < nums.length; j++){//记录序列的右指针
sum += nums[j];
while(sum >= target){//因为每次移动i都会产生新的子序列,所以每次都要检查
res = Math.min(j - i + 1 , res);
sum -= nums[i];
i++;
}
}
return res == Integer.MAX_VALUE? 0 : res;//Java才有的条件表达式,python没有
}
}
1
2
3
4
5
6
7
8
9
10
11
class Solution:
def minSubArrayLen(self, target: int, nums: List[int]) -> int:
l, sum = 0, 0
res = float('inf')
for r in range(len(nums)):
sum += nums[r]
while sum >= target:
res = min(res, r - l + 1)
sum -= nums[l]
l += 1
return res if res != float('inf') else 0

1.6 螺旋矩阵II

59. 螺旋矩阵 II

模拟行为:如何坚持循环不变量?确定1-4每次循环的数目为n-loop,然后用左闭右开的区间约束

img
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public int[][] generateMatrix(int n) {
int[][] nums = new int[n][n];
int loop = 1; //没必要专门记录起始点,因为每轮循环的起始点就是(loop - 1, loop - 1)
int i = 0, j = 0;
int count = 1;

while(loop <= n / 2){//采用左闭右开的写法,每次只循环n-loop个数字 注意n/2是整数
for(; j < n - loop; j++){
nums[i][j] = count++;
}
for(; i < n - loop; i++){
nums[i][j] = count++;
}
for(; j > loop - 1; j--){
nums[i][j] = count++;
}
for(; i > loop - 1; i--){
nums[i][j] = count++;
}
i++;
j++;
loop++;
}
if(n % 2 == 1){
nums[i][j] = count;//最后存中间的数字
}
return nums;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution:
def generateMatrix(self, n: int) -> List[List[int]]:
nums = [[0] * n for _ in range(n)] #这里是创建n*n的数组,其中_表示占位符,因为不需要知道此时循环到第几层,无需写for i in range(n)
i, j = 0, 0
loop = 1
count = 1

while loop <= n // 2:
while j < n - loop:
nums[i][j] = count
count += 1
j += 1
while i < n - loop:
nums[i][j] = count
count += 1
i += 1
while j > loop - 1:
nums[i][j] = count
count += 1
j -= 1
while i > loop - 1:
nums[i][j] = count
count += 1
i -= 1
loop += 1
i += 1
j += 1
if n % 2 == 1:
nums[i][j] = count
return nums

1.7 区间和

输出每个指定区间内元素的总和。

前缀和的思想是重复利用计算过的子数组之和,从而降低区间查询需要累加计算的次数。

img

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.Scanner;

public class Main{
public static void main(String[] args){
// 创建一个 Scanner 对象,用于从标准输入读取数据
Scanner scanner = new Scanner(System.in);

int n = scanner.nextInt();//跳过前面的空白字符(包括空格、制表符、换行符等),直到找到一个有效的整数输入
int[] vec = new int[n];//存储数组
int[] p = new int[n];//存储前缀和

int presum = 0;
for(int i = 0; i < n; i++){
vec[i] = scanner.nextInt();
presum += vec[i];
p[i] = presum;
}
while(scanner.hasNextInt()){//判断是否还有输入
int a = scanner.nextInt();
int b = scanner.nextInt();
int res = 0;
if(a == 0){//注意这里防止下标越界
res = p[b];
}else{
res = p[b] - p[a - 1];
}
System.out.println(res);
}
// 关闭 Scanner 对象,释放资源
scanner.close();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import sys
input = sys.stdin.read

def main():
data = input() #输入为'5\n1\n2\n3\n4\n5\n0 1\n1 3' 可以打印了解格式
data = data.split()#['5', '1', '2', '3', '4', '5', '0', '1', '1', '3']
index = 0 #表示当前遍历到的data下标
n = int(data[index])
index += 1
vec = []
p = []
presum = 0
for _ in range(n):
num = int(data[index])
presum += num
vec.append(num)
p.append(presum)
index += 1
while index < len(data):
a, b = int(data[index]), int(data[index + 1])
index += 2
res = 0
if a == 0:
res = p[b]
else:
res = p[b] - p[a - 1]
print(res)

if __name__ == "__main__":
main()

1.8 开发商购买土地

注意只能按行分或按列分:二维前缀和枚举按行分和按列分的所有情况,然后取最小值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import java.util.Scanner;
public class Main{
public static void main(String[] args){
Scanner scanner = new Scanner(System.in);
int n = scanner.nextInt();
int m = scanner.nextInt();

int[][] array = new int[n][m];
int[] row = new int[n];// 前缀和,row[i]表示前i行(包括第i行)的数据之和
int[] col = new int[m];// 前缀和,col[i]表示前i列(包括第i列)的数据之和

for(int i = 0; i < n; i++){
for(int j = 0; j < m; j++){
array[i][j] = scanner.nextInt();
}
}
//求row[i]
int rowSum = 0;
for(int i = 0; i < n; i++){
for(int j = 0; j < m; j++){
rowSum += array[i][j];
}
row[i] = rowSum;
}
//求col[i]
int colSum = 0;
for(int j = 0; j < m; j++){
for(int i = 0; i < n; i++){
colSum += array[i][j];
}
col[j] = colSum;
}
int rowResult = Main.result(row);//按行分
int colResult = Main.result(col);//按列分
System.out.println(Math.min(rowResult, colResult));
}
public static int result(int[] array){//求该前缀和划分如何最小
int total = array[array.length - 1];
int res = Integer.MAX_VALUE;
for(int i = 0; i < array.length - 1; i++){//注意total不用于划分,所以要-1
res = Math.min(res, Math.abs(total - 2 * array[i]));
}
return res;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import sys
input = sys.stdin.read

def result(arr):#这里没有class,所以不用写self
total = arr[len(arr) - 1]
res = float('inf')
for i in range(len(arr) - 1):
res = min(res, abs(total - 2 * arr[i]))
return res

def main():
data = input().split()
index = 0
n, m = int(data[index]), int(data[index + 1])
index += 2
array = [[0] * m for _ in range(n)]
row = [0] * n
col = [0] * m
for i in range(n):
for j in range(m):
array[i][j] = int(data[index])
index += 1#注意这里别忘了写

rowsum = 0
for i in range(n):
for j in range(m):
rowsum += array[i][j]
row[i] = rowsum

colsum = 0
for j in range(m):
for i in range(n):
colsum += array[i][j]
col[j] = colsum

rowresult = result(row)
colresult = result(col)
print(min(rowresult, colresult))


if __name__ == "__main__":
main()

1.9 总结篇

img

2. 链表

2.1 链表理论基础

单链表:

链表1

双链表:既可以向前查询也可以向后查询。

链表2

循环链表:链表首尾相连,可以用来解决约瑟夫环问题(n个人围成圈,报到k出列)。

链表中的节点在内存中不是连续分布的 ,而是散乱分布在内存中的某地址上,分配机制取决于操作系统的内存管理。

链表3

链表的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class ListNode {
int val; // 结点的值
ListNode next;// 下一个结点

// 节点的构造函数(无参)
public ListNode() {
}

// 节点的构造函数(有一个参数)
public ListNode(int val) {
this.val = val;
}

// 节点的构造函数(有两个参数)
public ListNode(int val, ListNode next) {
this.val = val;
this.next = next;
}
}
1
2
3
4
class ListNode:
def __init__(self, val, next=None):
self.val = val
self.next = next

删除节点:C++需要手动释放,Java、Python有自己的内存回收机制无需手动释放

就算使用C++来做leetcode,如果移除一个节点之后,没有手动在内存中删除这个节点,leetcode依然也是可以通过的,只不过,内存使用的空间大一些而已,但建议依然要养成手动清理内存的习惯。

链表-链表与数据性能对比数组在定义的时候长度是固定的,链表长度可以是不固定的,并且可以动态增删, 适合数据量不固定,频繁增删,较少查询的场景。

2.2 移除链表元素

这里就涉及如下链表操作的两种方式:

  • 直接使用原来的链表来进行删除操作。
  • 设置一个虚拟头结点在进行删除操作。dummy会简单些,统一写dummy!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public ListNode removeElements(ListNode head, int val) {
ListNode dummy = new ListNode();
dummy.next = head;
ListNode curr = dummy;
while(curr.next != null){
if(curr.next.val == val){
curr.next = curr.next.next;
}else{
curr = curr.next;
}
}
return dummy.next;
}
}
1
2
3
4
5
6
7
8
9
10
class Solution:
def removeElements(self, head: Optional[ListNode], val: int) -> Optional[ListNode]:#Optional表示可以是ListNode类型,也可以是None。
dummy = ListNode(next = head)
curr = dummy
while curr.next:
if curr.next.val == val:
curr.next = curr.next.next
else:
curr = curr.next
return dummy.next

2.3 设计链表

这道题目设计链表的五个接口:

  • 获取链表第index个节点的数值
  • 在链表的最前面插入一个节点
  • 在链表的最后面插入一个节点
  • 在链表第index个节点前面插入一个节点
  • 删除链表的第index个节点

采用设置一个虚拟头结点

  • 时间复杂度: 涉及 index 的相关操作为 O(index), 其余为 O(1)
  • 空间复杂度: O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class MyLinkedList {
class ListNode {
int val;
ListNode next;
ListNode(int val) {
this.val = val;
}
}
//MyLinkedList类的属性
private int size;
private ListNode head;//dummy结点

public MyLinkedList() {
this.size = 0;
this.head = new ListNode(0);
}

public int get(int index) {//注意index从0开始,所以实际的下标为index+1
if(index < 0 || index >= size) return -1; //私有属性,相当于this.size
ListNode cur = head;
for(int i = 0; i <= index; i++){
cur = cur.next;
}
return cur.val;
}

public void addAtHead(int val) {
ListNode newNode = new ListNode(val);
newNode.next = head.next;
head.next = newNode;
size++;
}

public void addAtTail(int val) {
ListNode cur = head;
ListNode newNode = new ListNode(val);
while(cur.next != null){
cur = cur.next;
}
cur.next = newNode;
size++;
}

public void addAtIndex(int index, int val) {
if(index == size){
addAtTail(val);
}else if(index >= 0 && index < size){//实际的下标是index + 1
ListNode newNode = new ListNode(val);
ListNode pre = head;
for(int i = 0; i < index; i++){
pre = pre.next;
}
ListNode cur = pre.next;
pre.next = newNode;
newNode.next = cur;
size++;
}
}

public void deleteAtIndex(int index) {
if(index >= 0 && index < size){
ListNode pre = head;
for(int i = 0; i < index; i++){
pre = pre.next;
}
pre.next = pre.next.next;
size--;
}
}
}

ListNode 就是一个非静态内部类。它的实例依赖于外部类的实例。内部类 ListNode 可以直接访问 MyLinkedList 的私有属性(如 headsize),但在这个例子中并没有直接访问。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class MyLinkedList:
class ListNode:
def __init__(self, val = 0, next = None):
self.val = val
self.next = next

def __init__(self):
self.head = self.ListNode()#内部类也要写self
self.size = 0

def get(self, index: int) -> int:
if index < 0 or index >= self.size: #访问内部属性必须写self
return -1
cur = self.head
for _ in range(index + 1):
cur = cur.next
return cur.val

def addAtHead(self, val: int) -> None:
newNode = self.ListNode(val)
newNode.next = self.head.next
self.head.next = newNode
self.size += 1

def addAtTail(self, val: int) -> None:
newNode = self.ListNode(val)
cur = self.head
while cur.next:
cur = cur.next
cur.next = newNode
self.size += 1

def addAtIndex(self, index: int, val: int) -> None:
if index == self.size:
self.addAtTail(val) #注意访问实例方法也要写self
elif index >= 0 and index < self.size:
newNode = self.ListNode(val)
pre = self.head
for _ in range(index):
pre = pre.next
newNode.next = pre.next
pre.next = newNode
self.size += 1

def deleteAtIndex(self, index: int) -> None:
if index >= 0 and index < self.size:
pre = self.head
for _ in range(index):
pre = pre.next
pre.next = pre.next.next
self.size -= 1

注意python访问实例属性、实例方法、内部类都要使用 self。但是访问外部类不用。

2.4 翻转链表

不需要定义一个新的链表,直接改变链表的next指针的指向即可,记得把 cur->next 节点用tmp指针保存一下即可

  • 用双指针法从前往后翻转指针指向,比较直接好理解(递归感觉这题没啥必要,反而复杂了)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public ListNode reverseList(ListNode head) {
ListNode pre = null;
ListNode cur = head;//如果直接访问head.next,那head为空时会报错,这样更有普适性
ListNode tmp = null;//写外面单纯更简洁
while(cur != null){
tmp = cur.next;
cur.next = pre;//会把head.next赋为空
pre = cur;
cur = tmp;
}
return pre;
}
}
1
2
3
4
5
6
7
8
9
10
class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
pre = None
cur = head
while cur:
tmp = cur.next#写里面也可以,与效率无关
cur.next = pre
pre = cur
cur = tmp
return pre

因为每次循环都会进行垃圾回收,所以tmp定义在循环外部内部感觉区别不大,编译器会复用栈上的同一块内存,创建和销毁都很高效。

2.5 两两交换链表中的节点

24.两两交换链表中的节点2

24.两两交换链表中的节点3

重点是每次只交换2个数,不用纠结tmp3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public ListNode swapPairs(ListNode head) {
ListNode dummy = new ListNode(0, head);
ListNode cur = dummy;
while(cur.next != null && cur.next.next != null){
ListNode tmp1 = cur.next;
ListNode tmp2 = tmp1.next;
ListNode tmp3 = tmp2.next;//可能为空
cur.next = tmp2;//每次交换2个数
tmp2.next = tmp1;
tmp1.next = tmp3;
cur = cur.next.next;
}
return dummy.next;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]:
dummy = ListNode(0, head)
cur = dummy
while cur.next and cur.next.next:
tmp1 = cur.next
tmp2 = tmp1.next
tmp3 = tmp2.next
cur.next = tmp2
tmp2.next = tmp1
tmp1.next = tmp3
cur = cur.next.next
return dummy.next

2.6 删除链表的倒数第 N 个结点

img

扫描两遍很简单,如何只扫描一遍?双指针法!

  • fast首先走n + 1步,然后fast和slow同时移动,直到fast指向末尾
  • 这样可以让slow正好在待删节点的前一个,原理是间距固定为n+1,fast为空时flow正好指向倒数n+1个节点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode dummy = new ListNode(0, head);
ListNode fast = dummy, slow = dummy;
for(int i = 0; i <= n; i++){
fast = fast.next;
}
while(fast != null){
fast = fast.next;
slow = slow.next;
}
slow.next = slow.next.next;
return dummy.next;
}
}
1
2
3
4
5
6
7
8
9
10
11
class Solution:
def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
dummy = ListNode(0, head)
fast = slow = dummy #注意可以写连等
for _ in range(n + 1):
fast = fast.next
while fast:
fast = fast.next
slow = slow.next
slow.next = slow.next.next
return dummy.next

2.7 链表相交

面试题02.07.链表相交_2.png

注意交点不是数值相等,而是指针相等,可以理解为物理结构就是相交的,先有图这样的物理结构再有题目。所以不要纠结为什么val和next一样但地址不一样,题目比较的就是地址。

  • 引用指的是链表节点在内存中的地址。当我们说两个链表相交时,意味着它们共享同一个节点(即两个链表中的某个节点的内存地址相同),即引用完全相同。

因为相交后面的节点一定一样,所以先将长一点的链表移动到相同的位置,然后同时向后移动cur比较即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class Solution {
public ListNode getIntersectionNode(ListNode headA, ListNode headB) {
ListNode curA = headA;
ListNode curB = headB;
int lenA = 0, lenB = 0;
while (curA != null) { // 求链表A的长度
lenA++;
curA = curA.next;
}
while (curB != null) { // 求链表B的长度
lenB++;
curB = curB.next;
}
int gap = Math.abs(lenA - lenB);
curA = headA;
curB = headB;
if(lenA > lenB){
while(gap != 0){
curA = curA.next;
gap--;
}
}else{
while(gap != 0){
curB = curB.next;
gap--;
}
}
//不断比较节点是否相同
while(curA != null){
if(curA == curB){
return curA;
}else{
curA = curA.next;
curB = curB.next;
}
}
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution:
def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode:
lenA, lenB = 0, 0
curA = headA
curB = headB
while curA:
curA = curA.next
lenA += 1
while curB:
curB = curB.next
lenB += 1
gap = abs(lenA - lenB)
curA = headA
curB = headB
if lenA > lenB:
while gap:
curA = curA.next
gap -= 1
else:
while gap:
curB = curB.next
gap -= 1
while curA:
if curA == curB:
return curA
else:
curA = curA.next
curB = curB.next
return None

2.8 环形链表 II

哈希表是直观的思路,时间/空间复杂度是O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Solution {
public ListNode detectCycle(ListNode head) {
ListNode pos = head;
Set<ListNode> visited = new HashSet<ListNode>();
while (pos != null) {
if (visited.contains(pos)) {
return pos;//环的入口就是第一个重复的节点
} else {
visited.add(pos);
}
pos = pos.next;
}
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
class Solution:
def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
pos = head
visited = set() # 使用 Python 的 set 来存储访问过的节点
while pos:
if pos in visited: # 如果节点已经存在于 set 中,说明是环的入口
return pos
else:
visited.add(pos) # 将当前节点添加到 set 中
pos = pos.next # 移动到下一个节点
return None # 如果没有环,返回 None

进阶:用空间O(1)实现,Floyd判圈算法

  • 判断链表是否环:可以使用快慢指针法,分别定义 fast 和 slow 指针,从头结点出发,fast指针每次移动两个节点,slow指针每次移动一个节点,如果 fast 和 slow指针在途中相遇 ,说明这个链表有环。

  • 如果有环,如何找到这个环的入口:首先可以证明第一次在环中相遇,slow的 步数 是 x+y 而不是 x + 若干环的长度 + y ,然后相遇时: slow指针走过的节点数为: x + y, fast指针走过的节点数:x + y + n (y + z),可以得到x = n (y + z) - y。

  • 从头结点出发一个指针ptr,从相遇节点也出发一个指针slow,这两个指针每次只走一个节点, 那么当这两个指针相遇的时候就是环形入口的节点。如果n=1,则slow没有在环中走多余的路,如果n>1,则相遇前会在环中转n-1圈,但最后会和ptr在入口相遇。

    img

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public class Solution {
    public ListNode detectCycle(ListNode head) {
    ListNode fast = head;
    ListNode slow = head;
    while(fast != null && fast.next != null){
    slow = slow.next;
    fast = fast.next.next;
    if(fast == slow){//在环中相遇
    ListNode ptr = head;
    while(ptr != slow){
    ptr = ptr.next;
    slow = slow.next;
    }
    return ptr;
    }
    }
    return null;
    }
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    class Solution:
    def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
    fast = slow = head
    while fast and fast.next:
    fast = fast.next.next
    slow = slow.next
    if fast == slow:
    ptr = head
    while ptr != slow:
    ptr = ptr.next
    slow = slow.next
    return ptr
    return None

2.9 总结篇

统一使用虚拟头节点dummyimg

3. 哈希表

3.1 哈希表理论基础

哈希表是根据关键码的值而直接进行访问的数据结构,用来快速判断一个元素是否出现集合里。

哈希函数:通过hashCode把名字转化为数值,一般hashcode是通过特定编码方式,可以将其他数据格式转化为不同的数值,这样就把学生名字映射为哈希表上的索引数字了。

哈希表2

哈希碰撞:小李和小王都映射到了索引下标 1 的位置

  • 拉链法:发生冲突的元素都被存储在链表中。需要选择适当的哈希表的大小,这样既不会因为数组空值而浪费大量内存,也不会因为链表太长而在查找上浪费太多时间。哈希表4
  • 线性探测法:一定要保证tableSize大于dataSize,因为需要依靠哈希表中的空位来解决碰撞问题。如向下找一个空位。哈希表5

当我们想使用哈希法来解决问题的时候,我们一般会选择如下三种数据结构。

  • 数组
  • set (集合)
  • map(映射)

在C++中,set 和 map 分别提供以下三种数据结构,其底层实现以及优劣如下表所示:

集合 底层实现 是否有序 数值是否可以重复 能否更改数值 查询效率 增删效率
std::set 红黑树 有序 O(log n) O(log n)
std::multiset 红黑树 有序 O(logn) O(logn)
std::unordered_set 哈希表 无序 O(1) O(1)

std::unordered_set底层实现为哈希表,std::set 和std::multiset 的底层实现是红黑树,红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。

映射 底层实现 是否有序 数值是否可以重复 能否更改数值 查询效率 增删效率
std::map 红黑树 key有序 key不可重复 key不可修改 O(logn) O(logn)
std::multimap 红黑树 key有序 key可重复 key不可修改 O(log n) O(log n)
std::unordered_map 哈希表 key无序 key不可重复 key不可修改 O(1) O(1)

std::unordered_map 底层实现为哈希表,std::map 和std::multimap 的底层实现是红黑树。同理,std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解)。

当我们要使用集合来解决哈希问题的时候,优先使用unordered_set,因为它的查询和增删效率是最优的,如果需要集合是有序的,那么就用set,如果要求不仅有序还要有重复数据的话,那么就用multiset。

那么再来看一下map ,在map 是一个key value 的数据结构,map中,对key是有限制,对value没有限制的,因为key的存储方式使用红黑树实现的。

虽然std::set和std::multiset 的底层实现基于红黑树而非哈希表,它们通过红黑树来索引和存储数据。不过给我们的使用方式,还是哈希法的使用方式,即依靠键(key)来访问值(value)。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。std::map也是一样的道理。

这里在说一下,一些C++的经典书籍上 例如STL源码剖析,说到了hash_set hash_map,这个与unordered_set,unordered_map又有什么关系呢?

实际上功能都是一样一样的, 但是unordered_set在C++11的时候被引入标准库了,而hash_set并没有,所以建议还是使用unordered_set比较好,这就好比一个是官方认证的,hash_set,hash_map 是C++11标准之前民间高手自发造的轮子。

哈希表6

以下是我的补充:

Java:红黑树是一种平衡二叉搜索树,所以key值是有序的,但key不可以修改,改动key值会导致整棵树的错乱,所以只能删除和增加。

集合 底层实现 是否有序 数值是否可以重复 能否更改数值 查询效率 增删效率
HashSet 哈希表 无序 O(1) O(1)
LinkedHashSet 哈希表 + 链表 有序(插入顺序) O(1) O(1)
TreeSet 红黑树 有序(自然顺序或自定义顺序) O(log n) O(log n)
映射 底层实现 是否有序 key是否可以重复 能否更改key 查询效率 增删效率
HashMap 哈希表 无序 O(1) O(1)
LinkedHashMap 哈希表 + 链表 有序(插入顺序或访问顺序) O(1) O(1)
TreeMap 红黑树 有序(自然顺序或自定义顺序) O(log n) O(log n)

Python:

集合 底层实现 是否有序 数值是否可以重复 能否更改数值 查询效率 增删效率
set 哈希表 无序 O(1) O(1)
frozenset 哈希表 无序 O(1) 不可变
映射 底层实现 是否有序 key是否可以重复 能否更改key 查询效率 增删效率
dict 哈希表 无序(Python 3.7+ 有序) O(1) O(1)
OrderedDict 哈希表 + 双向链表 有序(插入顺序) O(1) O(1)

当我们要使用集合来解决哈希问题的时候,优先使用HashSet,因为它的查询和增删效率是最优的。基于红黑树而非哈希表,它们通过红黑树来索引和存储数据。不过给我们的使用方式,还是哈希法的使用方式,即依靠键(key)来访问值(value)。所以使用这些数据结构来解决映射问题的方法,我们依然称之为哈希法。

总结一下,当我们遇到了要快速判断一个元素是否出现集合里的时候,就要考虑哈希法

但是哈希法也是牺牲了空间换取了时间,因为我们要使用额外的数组,set或者是map来存放数据,才能实现快速的查找。

3.2 有效的字母异位词

排序法时间复杂度O(nlogn)

1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) {
return false;
}
char[] str1 = s.toCharArray();
char[] str2 = t.toCharArray();
Arrays.sort(str1);
Arrays.sort(str2);
return Arrays.equals(str1, str2);
}
}
1
2
3
4
5
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
return sorted(s) == sorted(t)

数组其实就是一个简单哈希表,而且这道题目中字符串只有小写字符,那么就可以定义一个数组,来记录字符串s里字符出现的次数。

定一个数组叫做record,大小为26 就可以了,初始化为0,因为字符a到字符z的ASCII也是26个连续的数值。对一个字符串统计+1,对另一个字符串-1,最后看是否有元素不为0,有则不是字母异位词。时间复杂度: O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public boolean isAnagram(String s, String t) {
if (s.length() != t.length()) {//先看边界情况
return false;
}
int[] record = new int[26];
for(int i = 0; i < s.length(); i++){
record[s.charAt(i) - 'a']++;
}
for(int i = 0; i < t.length(); i++){
record[t.charAt(i) - 'a']--;
}
for(int i = 0; i < 26; i++){
if(record[i] != 0) return false;
}
/**for (int count: record) { 可以写作访问数组内容int的格式
if (count != 0) {
return false;
}
}**/
return true;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t): # 先看边界情况
return False
record = [0] * 26 # 初始化一个长度为26的列表,对应英文字母的数量
for char in s:
record[ord(char) - ord('a')] += 1 # 计算s中每个字符的出现次数
for char in t:
record[ord(char) - ord('a')] -= 1 # 减去t中每个字符的出现次数
for count in record:
if count != 0: # 如果record数组中有非零元素,则表示s和t不是字母异位词
return False
return True

另外python的Counter和defaultdict也可以解题:

1
2
3
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
return Counter(s) == Counter(t)
1
2
3
4
5
6
7
8
9
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
s_dict = defaultdict(int)#表示对于任何未见过的键,其初始值都会被设置为 0
t_dict = defaultdict(int)
for x in s:
s_dict[x] += 1
for x in t:
t_dict[x] += 1
return s_dict == t_dict

3.3 两个数组的交集

输出结果中的每个元素一定是唯一的,也就是说输出的结果的去重的, 同时可以不考虑输出结果的顺序。

使用数组来做哈希的题目,是因为题目都限制了数值的大小,如上一题只需要26位记录次数,但这里没有限制大小,哈希值可能比较少、特别分散、跨度非常大,使用数组就造成空间的极大浪费。此处就适合使用集合。

  • 那为什么有时哈希问题要用数组?直接使用set 不仅占用空间比数组大,而且速度要比数组慢,set把数值映射到key上都要做hash计算的。不要小瞧这个耗时,在数据量大的情况,差距是很明显的。
  • 后来此题限制了大小在1000以内,也可以用数组
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public int[] intersection(int[] nums1, int[] nums2) {
if (nums1 == null || nums1.length == 0 || nums2 == null || nums2.length == 0) {
return new int[0];
}
Set<Integer> set1 = new HashSet<>();
Set<Integer> resSet = new HashSet<>();//这里用set是因为不确定长度,同时避免重复,list不能避免重复
//遍历数组1
for (int i : nums1) {
set1.add(i);
}
//遍历数组2的过程中判断哈希表中是否存在该元素
for (int i : nums2) {
if (set1.contains(i)) {
resSet.add(i);
}
}
//将set转为int[]
//法1:将流中的每个Integer对象映射到其对应的int值
return resSet.stream().mapToInt(Integer::intValue).toArray();
/**法2:另外构造一个int[]数组,比较符合记不住简单方法时候的用法
int[] arr = new int[resSet.size()];
int j = 0;
for(int i : resSet){
arr[j++] = i;
}
return arr;
**/
}
}
1
2
3
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
return list(set(nums1) & set(nums2))
1
2
3
4
5
6
7
8
9
class Solution:
def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]:
st = set(nums1)
ans = []
for x in nums2:
if x in st:
st.remove(x)#保证nums2的重复数字不会添加到ans,相当于保证ans是set,对nums2也做了set
ans.append(x)
return ans

3.4 快乐数

注意无限循环:如果sum重复出现了,就不可能是快乐数,sum达到1了。所以需要用set存储出现过的sum值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public boolean isHappy(int n) {
Set<Integer> record = new HashSet<>();//保存出现过的sum值
while(n != 1 && !record.contains(n)){
record.add(n);//注意最开始的数字就应该加入进去了
n = getNextNum(n);
}
return n == 1;//如果无限循环也会来到这里,但不满足
}
public int getNextNum(int n){
//对每一位求平方和
int sum = 0;
while(n != 0){
sum += Math.pow(n % 10, 2);
n /= 10;
}
return sum;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def isHappy(self, n: int) -> bool:
record = set()
while n not in record:
record.add(n)
n_str = str(n)
sum = 0
for i in n_str:
sum += int(i)**2
if sum == 1:
return True
else:
n = sum
return False

3.5 两数之和

当我们需要查询一个元素是否出现过,或者一个元素是否在集合里的时候,就要第一时间想到哈希法。

本题我们不仅要知道元素有没有遍历过,还要知道这个元素对应的下标,需要使用 key value结构来存放,key来存元素,value来存下标,那么使用map正合适

再来看一下使用数组和set来做哈希法的局限。

  • 数组的大小是受限制的,而且如果元素很少,而哈希值太大会造成内存空间的浪费。
  • set是一个集合,里面放的元素只能是一个key,而两数之和这道题目,不仅要判断y是否存在而且还要记录y的下标位置,因为要返回x 和 y的下标。所以set 也不能用。

此时就要选择另一种数据结构:map ,map是一种key value的存储结构,可以用key保存数值,用value再保存数值所在的下标。

过程一
  • 因为只允许两数之和,所以可以随时作差
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public int[] twoSum(int[] nums, int target) {
int[] res = new int[2];
if(nums == null || nums.length == 0){
return res;//先看边界情况
}
Map<Integer, Integer> map = new HashMap<>();//注意里面写Integer而不是int,因为int不是类
for(int i = 0; i < nums.length; i++){
int tmp = target - nums[i];
if(map.containsKey(tmp)){
res[0] = i;
res[1] = map.get(tmp);//返回tmp对应的下标
break;
}
map.put(nums[i], i);//注意key是数组的值,value是数组的下标
}
return res;
}
}
1
2
3
4
5
6
7
8
9
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
record = dict() #python中map就是dict
for index, value in enumerate(nums):
if target - value in record:#寻找匹配的key
return [index, record[target - value]]
else:
record[value] = index #key不重复
return []

3.6 四数相加 II

参考前文的两数之和,建立map,先记录A+B数组的元素之和和出现次数,然后遍历C+D,找到如果 0-(c+d) 在map中出现过的话,就用count把map中key对应的value也就是出现次数统计出来。

  • 时间复杂度: O(n^2)
  • 空间复杂度: O(n^2),最坏情况下A和B的值各不相同,相加产生的数字个数为 n^2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public int fourSumCount(int[] nums1, int[] nums2, int[] nums3, int[] nums4) {
int count = 0;
Map<Integer, Integer> map = new HashMap<Integer, Integer>();
for(int i: nums1){
for(int j: nums2){
map.put(i + j, map.getOrDefault(i + j, 0) + 1);//如果找不到key,就返回设置的默认值0
}
}
for(int i: nums3){
for(int j: nums4){
count += map.getOrDefault(- i - j, 0);
}
}
return count;
}
}
1
2
3
4
5
6
7
8
9
10
11
class Solution:
def fourSumCount(self, nums1: List[int], nums2: List[int], nums3: List[int], nums4: List[int]) -> int:
map = dict()
count = 0
for i in nums1:
for j in nums2:
map[i + j] = map.get(i + j, 0) + 1
for i in nums3:
for j in nums4:
count += map.get( - i - j, 0)
return count

3.7 赎金信

因为题目说只有小写字母,那可以采用空间换取时间的哈希策略,用一个长度为26的数组来记录magazine里字母出现的次数。然后再用ransomNote去验证这个数组是否包含了ransomNote所需要的所有字母。

一些同学可能想,用数组干啥,都用map完事了,其实在本题的情况下,使用map的空间消耗要比数组大一些的,因为map要维护红黑树或者哈希表,而且还要做哈希函数,是费时的!数据量大的话就能体现出来差别了。 所以数组更加简单直接有效!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public boolean canConstruct(String ransomNote, String magazine) {
//ransomNote中的每个字符在magazine中都出现过
if(ransomNote.length() > magazine.length()) return false;
int[] record = new int[26];
for(int i = 0; i < magazine.length(); i++){
record[magazine.charAt(i) - 'a'] += 1;
}
for(int i = 0; i < ransomNote.length(); i++){
record[ransomNote.charAt(i) - 'a'] -= 1;
if(record[ransomNote.charAt(i) - 'a'] < 0){
return false;
}
}
return true;
}
}
1
2
3
4
5
6
7
8
9
10
11
class Solution:
def canConstruct(self, ransomNote: str, magazine: str) -> bool:
record = [0] * 26
for c in magazine:
record[ord(c) - ord('a')] += 1
for c in ransomNote:
record[ord(c) - ord('a')] -= 1
for i in record:
if i < 0:
return False
return True
1
2
3
4
5
class Solution:
def canConstruct(self, ransomNote: str, magazine: str) -> bool:
#Counter对字符串的每个字符计数,结果为空则为True
#return (Counter(ransomNote) - Counter(magazine)) == {}
return not (Counter(ransomNote) - Counter(magazine))

3.8 三数之和

两层for循环就可以确定 两个数值,可以使用哈希法来确定 第三个数 0-(a+b) 或者 0 - (a + c) 是否在 数组里出现过,其实这个思路是正确的,但是我们有一个非常棘手的问题,就是题目中说的不可以包含重复的三元组。

  • 哈希法用set存b,a是nums[i],c是nums[k](另一层循环,k=i+1…n),过程中要不断对a和c去重,比较繁琐
  • 时间复杂度: O(n^2),第二层循环有时作c有时作b,总之有点难理解,用双指针!

而排序可以去除重复解。拿这个nums数组来举例,首先将数组排序,然后有一层for循环,i从下标0的地方开始,同时定一个下标left 定义在i+1的位置上,定义下标right 在数组结尾的位置上。15.三数之和

依然还是在数组中找到 abc 使得a + b +c =0,我们这里相当于 a = nums[i],b = nums[left],c = nums[right]。

  • 接下来如何移动left 和right呢, 如果nums[i] + nums[left] + nums[right] > 0 就说明 此时三数之和大了,因为数组是排序后了,所以right下标就应该向左移动,这样才能让三数之和小一些。
  • 如果 nums[i] + nums[left] + nums[right] < 0 说明 此时 三数之和小了,left 就向右移动,才能让三数之和大一些,直到left与right相遇为止。时间复杂度:O(n^2)。

去重逻辑的思考

  1. a的去重:不是与nums[i + 1]比较,而是与nums[i - 1]比较,因为不能有重复的三元组,但三元组内的元素是可以重复的
  2. b与c的去重:在三数之和非0时,无需去重bc,因为下一个循环也会校验。等于0时要校验是否重复。

两数之和 就不能使用双指针法,因为1.两数之和 (opens new window)要求返回的是索引下标, 而双指针法一定要排序,一旦排序之后原数组的索引就被改变了(要求返回的是数值的话,就可以使用双指针法了)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
//排序,令左指针 L=i+1,右指针 R=n−1
List<List<Integer>> res = new ArrayList<>();
Arrays.sort(nums);
// a=nums[i], b=nums[left], c=nums[right]
for(int i = 0; i < nums.length; i++){
if(nums[i] > 0){//如果最小的a已然大于0,不可能和为0
return res;
}
if(i > 0 && nums[i] == nums[i - 1]){//对a去重
continue;
}
int left = i + 1;//定义双指针
int right = nums.length - 1;
while(right > left){//在找以a为基础的这一轮循环
int sum = nums[i] + nums[left] + nums[right];
if(sum > 0){
right--;
}else if(sum < 0){
left++;
}else{
res.add(Arrays.asList(nums[i], nums[left], nums[right]));
//此时要去重,直到b和c是新的数字
while(right > left && nums[right] == nums[right - 1]) right--;
while(right > left && nums[left] == nums[left + 1]) left++;
right--;//注意跳出循环后仍要移动,不然停在重复值上
left++;
}
}
}
return res;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
res = []
nums.sort()
for i in range(len(nums)):
if nums[i] > 0:
return res
if i > 0 and nums[i] == nums[i - 1]:
continue
left = i + 1
right = len(nums) - 1
while left < right:
sum_ = nums[i] + nums[left] + nums[right]
if sum_ < 0:
left += 1
elif sum_ > 0:
right -= 1
else:
res.append([nums[i], nums[left], nums[right]])
while right > left and nums[right] == nums[right - 1]:
right -= 1
while right > left and nums[left] == nums[left + 1]:
left += 1
right -= 1
left += 1
return res

3.9 四数之和

三数之和:一层for循环num[i]为确定值,然后循环内有left和right下标作为双指针,找到nums[i] + nums[left] + nums[right] == 0。

四数之和:两层for循环nums[k] + nums[i]为确定值,依然是循环内有left和right下标作为双指针,找出nums[k] + nums[i] + nums[left] + nums[right] == target的情况,三数之和的时间复杂度是O(n^2),四数之和的时间复杂度是O(n^3) 。

  • 对于15.三数之和 (opens new window)双指针法就是将原本暴力O(n^3)的解法,降为O(n^2)的解法,四数之和的双指针解法就是将原本暴力O(n^4)的解法,降为O(n^3)的解法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public List<List<Integer>> fourSum(int[] nums, int target) {
List<List<Integer>> res = new ArrayList<>();
Arrays.sort(nums);
for(int i = 0; i < nums.length; i++){//枚举a=nums[i]
if(nums[i] >= 0 && nums[i] > target){// 剪枝处理
return res;//列举可以发现相加只会越来越大
}
if(i > 0 && nums[i] == nums[i - 1]){//在i-1时已经覆盖了所有情况
continue;
}
for(int j = i + 1; j < nums.length; j++){//枚举b=nums[j]
if(nums[i] + nums[j] >= 0 && nums[i] + nums[j] > target){
break;//注意这里不应该return res,不像外层循环一样杜绝了可能的结果
}
if(j > i + 1 && nums[j] == nums[j - 1]){
continue;//遇到重复值要过
}
int left = j + 1;
int right = nums.length - 1;
while(left < right){
int sum = nums[i] + nums[j] + nums[left] + nums[right];
if(sum == target){
res.add(Arrays.asList(nums[i], nums[j], nums[left], nums[right]));
while(left < right && nums[right] == nums[right - 1]) right--;
while(left < right && nums[left] == nums[left + 1]) left++;
right--;
left++;
}else if(sum > target){
right--;
}else{
left++;
}
}
}
}
return res;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution:
def fourSum(self, nums: List[int], target: int) -> List[List[int]]:
res = []
nums.sort() #先排序
for i in range(len(nums)):
if nums[i] >= 0 and nums[i] > target:
return res
if i > 0 and nums[i] == nums[i - 1]:
continue
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] >= 0 and nums[i] + nums[j] > target:
continue #注意这里不能直接返回
if j > i + 1 and nums[j] == nums[j - 1]:
continue
left = j + 1
right = len(nums) - 1
while left < right:
sum_ = nums[i] + nums[j] + nums[left] + nums[right]#和求和函数作区分
if sum_ > target:
right -= 1
elif sum_ < target:
left += 1
else:
res.append([nums[i], nums[j], nums[left], nums[right]])
while left < right and nums[right] == nums[right - 1]:
right -= 1
while left < right and nums[left] == nums[left + 1]:
left += 1
right -= 1
left += 1
return res

3.10 总结篇

要求只有小写字母时,很合适用数组,因为空间消耗固定

没有限制数值大小时,用set做映射

当需要记录下标时,用map,因为是<key, value>结构(虽然map是万能的,但数组和set当然效率更高)

  • std::map 和std::multimap 的key也是有序的(这个问题也经常作为面试题,考察对语言容器底层的理解),并不需要key有序时选择std::unordered_map 效率更高

4. 字符串

4.1 反转字符串

对于字符串,我们定义两个指针(也可以说是索引下标),一个从字符串前面,一个从字符串后面,两个指针同时向中间移动,并交换元素。

swap可以有两种实现。

一种就是常见的交换数值:

1
2
3
int tmp = s[i];
s[i] = s[j];
s[j] = tmp;

一种就是通过位运算:01,则结果为1;11/00,则结果为0。

1
2
3
s[i] ^= s[j];//i=i^j
s[j] ^= s[i];//j=j^(i^j)=i 自己和自己异或是0
s[i] ^= s[j];//i=(i^j)^i=j
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public void reverseString(char[] s) {
//原地修改数组,就是用一个tmp存,然后把最先和最后进行交换
int left = 0;
int right = s.length - 1;
while(left < right){
char tmp = s[left];
s[left] = s[right];
s[right] = tmp;
left++;
right--;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def reverseString(self, s: List[str]) -> None:
"""
Do not return anything, modify s in-place instead.
"""
left = 0
right = len(s) - 1
while left < right:
s[left], s[right] = s[right], s[left]#在内存中会使用一个临时的元组来保存中间状态
left += 1
right -= 1

4.2 反转字符串 II

在遍历字符串的过程中,只要让 i += (2 * k),i 每次移动 2 * k 就可以了,然后判断是否需要有反转的区间。

我独立完成的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def reverse_substring(self, s:str, i:int, j:int) -> str:#反转[i,j)
before = s[:i]
reversed_part = s[i:j][::-1]
after = s[j:]
return before + reversed_part + after
def reverseStr(self, s: str, k: int) -> str:
n = len(s) // (2 * k)
while n > 0:#反转n次前k个字符
index = (n - 1)*2*k
s = self.reverse_substring(s, index, index + k)
#s[index, index + k] = s[index, index + k][::-1]这样直接修改是错的,元组不能作为索引
n -= 1
n = len(s) // (2 * k)
last = len(s) - n*2*k #7-1*2*2=3
if last > 0 and last < k:
s = self.reverse_substring(s, n*2*k, len(s))
elif last >= k and last < 2*k:
s = self.reverse_substring(s, n*2*k, n*2*k + k)
return s

改善版:其实每次看2k个,反转前k个就行,最后剩余字符的两种情况也符合

1
2
3
4
5
6
7
8
class Solution:
def reverseStr(self, s: str, k: int) -> str:
p = 0
while p < len(s):
p2 = p + k
s = s[:p] + s[p: p2][::-1] + s[p2:]
p = p + 2 * k
return s

4.3 替换数字

申请新数组的方法:

1
2
3
4
5
6
7
8
9
#数字替换为number
s = input()
result = ""
for char in s:
if char.isdigit():
result += "number"
else:
result += char
print(result)

正则表达式的方法:

1
2
3
4
5
6
7
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
System.out.println(sc.next().replaceAll("\\d", "number"));
}
}

如果想把这道题目做到极致,就不要只用额外的辅助空间了! (不过使用Java刷题的录友,一定要使用辅助空间,因为Java里的string不能修改)

注意从后往前填充:从前向后填充就是O(n^2)的算法了,因为每次添加元素都要将添加元素之后的所有元素整体向后移动。

很多数组填充类的问题,其做法都是先预先给数组扩容到填充后的大小,然后在从后向前进行操作。这么做有两个好处:

  1. 不用申请新数组。
  2. 从后向前填充元素,避免了从前向后填充元素时,每次添加元素都要将添加元素之后的所有元素向后移动的问题。

4.4 反转字符串中的单词

1
2
3
4
5
6
class Solution:
def reverseWords(self, s: str) -> str:
s_list = s.split()
reversed_list = s_list[::-1]#反转列表而不是反转字符串
res = " ".join(reversed_list)# 使用单个空格将反转后的单词列表拼接成字符串
return res
1
2
3
4
5
6
7
8
class Solution:
def reverseWords(self, s: str) -> str:
# 使用 split() 方法按任意连续空白字符分割字符串,注意不要写split(" "),否则结果是['a', 'good', '', '', 'example']
s_list = s.split()
res = ""
for index, word in enumerate(s_list):
res = word + " " + res
return res.strip() #去除字符串首尾的空白字符

不要使用辅助空间,空间复杂度要求为O(1)。所以解题思路如下:

  • 移除多余空格:包括前后的空格,中间的连续空格。要保持O(n)的话,用“1.3移除元素”的快慢指针法

    • C++的话可以在string上原地修改,但Java的String不能修改(使用final char[]数组来存储字符),所以要使用StringBuilderchar[]数组作为辅助空间
  • 将整个字符串反转:参考4.1,用双指针+一个额外空间进行交换

  • 将每个单词反转:等价于反转字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Solution {
/**
* 不使用Java内置方法实现
* <p>
* 1.去除首尾以及中间多余空格
* 2.反转整个字符串
* 3.反转各个单词
*/
public String reverseWords(String s) {
// 1.去除首尾以及中间多余空格
StringBuilder sb = removeSpace(s);
// 2.反转整个字符串
reverseString(sb, 0, sb.length() - 1);
// 3.反转各个单词
reverseEachWord(sb);
return sb.toString();
}

private StringBuilder removeSpace(String s) {
int start = 0;
int end = s.length() - 1;
while (s.charAt(start) == ' ') start++;//去除前后的空格
while (s.charAt(end) == ' ') end--;
StringBuilder sb = new StringBuilder();
while (start <= end) {//去除中间的连续空格,加入sb
char c = s.charAt(start);
if (c != ' ' || sb.charAt(sb.length() - 1) != ' ') {
sb.append(c);
}
start++;
}
return sb;
}

/**
* 反转字符串指定区间[start, end]的字符
*/
public void reverseString(StringBuilder sb, int start, int end) {
while (start < end) {//双指针
char temp = sb.charAt(start);//用一个额外空间暂存
sb.setCharAt(start, sb.charAt(end));
sb.setCharAt(end, temp);
start++;
end--;
}
}

private void reverseEachWord(StringBuilder sb) {
int start = 0;
int end = 1;
int n = sb.length();
while (start < n) {//主要是识别单词
while (end < n && sb.charAt(end) != ' ') {
end++;
}
reverseString(sb, start, end - 1);
start = end + 1;
end = start + 1;
}
}
}

4.5 右旋字符串

1
2
3
4
5
k = input()
s = input()
index = len(s) - int(k)
res = s[index:] + s[:index]
print(res)

通过 整体倒叙,把两段子串顺序颠倒,两个段子串里的的字符在倒叙一把,负负得正,这样就不影响子串里面字符的顺序了。

这样额外空间只有交换时的O(1),左反转和右反转的思路一样。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.Scanner;

public class Main {
public static void main(String[] args) {
Scanner in = new Scanner(System.in);
int n = Integer.parseInt(in.nextLine());
String s = in.nextLine();

int len = s.length(); //获取字符串长度
char[] chars = s.toCharArray();//转成数组的原因是Java字符串无法修改,但数组可以修改
reverseString(chars, 0, len - 1); //反转整个字符串
reverseString(chars, 0, n - 1); //反转前一段字符串,此时的字符串首尾尾是0,n - 1
reverseString(chars, n, len - 1); //反转后一段字符串,此时的字符串首尾尾是n,len - 1

System.out.println(chars);
}

public static void reverseString(char[] ch, int start, int end) {//只用一个额外空间
while (start < end) {
ch[start] ^= ch[end];
ch[end] ^= ch[start];
ch[start] ^= ch[end];
start++;
end--;
}
}
}

4.6 找出字符串中第一个匹配项的下标

“mississippi”注意这种用例,不能一直前移haystack,朴素做法:O(mn)

1
2
3
4
5
6
7
8
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
if len(haystack) < len(needle):
return -1
for i in range(0, len(haystack) - len(needle) + 1):#记得要写range,刚开始没注意
if haystack[i: i+len(needle)] == needle:
return i
return -1

KMP算法:O(m+n) 应用于匹配DNA片段、文本搜索

详见本人博客:超简单理解KMP算法(最长公共前后缀next数组、合并主子串、子串偏移法)-CSDN博客

获得next数组:

1
2
3
4
5
6
7
def getNext(self, next, s):
for i in range(1, len(s)):
len_ = next[i - 1]
while len_ != 0 and s[i] != s[len_]: #等于0也是没有,会跳过的
len_ = next[len_ - 1]
if s[i] == s[len_]: #跳出循环要不是0要不相等
next[i] = len_ + 1

合并主串子串的方法:

image-20250221224217526

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def strStr(self, haystack: str, needle: str) -> int:
n = len(haystack) #主串的的长度
m = len(needle) #子串的长度
if haystack == needle: #因为i从1开始,所以处理edge case
return 0
s = needle + "#" + haystack #注意把子串放前面,这样前缀和才能覆盖子串
next = [0] * len(s)
for i in range(1, n + m + 1): #注意+1是因为"#"多了一位
len_ = next[i - 1]
while len_ != 0 and s[i] != s[len_]: #等于0也是没有,会跳过的
len_ = next[len_ - 1]
if s[i] == s[len_]: #跳出循环要不是0要不相等
next[i] = len_ + 1
if next[i] == m:#返回第一个
return i - m*2 #合并串从第m+1位开始才是主串,所以主串中开始匹配的下标是i - (m+1) - m + 1=i-2*m
return -1

子串偏移的做法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution:
def getNext(self, next, s):
for i in range(1, len(s)):
len_ = next[i - 1]
while len_ != 0 and s[i] != s[len_]: #等于0也是没有,会跳过的
len_ = next[len_ - 1]
if s[i] == s[len_]: #跳出循环要不是0要不相等
next[i] = len_ + 1

def strStr(self, haystack: str, needle: str) -> int:
next = [0] * len(needle)
self.getNext(next, needle)
i = 0 #主串指针
j = 0 #子串指针
for i in range(len(haystack)):
while j > 0 and haystack[i] != needle[j]:#不匹配,子串到下一个可能匹配的地方next[j-1],注意只要j>0要一直找,而不是只试一次
j = next[j - 1]
if haystack[i] == needle[j]:#字符匹配,指针后移
j += 1
if j == len(needle):#在主串中找到了子串
return i - len(needle) + 1
return -1

4.7 重复的子字符串

暴力法:用一个for循环获取子串的终止位置(起始位置肯定是第一个字母,不然不可能重复多次构成,同时这里可以优化:子串至少重复一次,所以终止位置遍历到n/2的中间位置即可),再用一个循环判断子串是否能重复构成字符串。O(n^2)

移动匹配法:如果将两个s连在一起,并移除第一个和最后一个字符,那么得到的字符串一定包含s,即s是它的一个子串

  • 这种方法只能判断有没有,不能立马得到子串是什么,仅用于此题返回true/false的情况
  • 我们最终还是要判断 一个字符串(s + s)是否出现过 s 的过程,大家可能直接用contains,find 之类的库函数, 却忽略了实现这些函数的时间复杂度(暴力解法是m * n,一般库函数实现为 O(m + n))。也就是4.6中的KMP。
  • 这里构造 s + s 的时间复杂度是 O(n),在 s + s 中查找 s 的时间复杂度也是 O(n)。所以总的也是O(n)。
1
2
3
class Solution:
def repeatedSubstringPattern(self, s: str) -> bool:
return (s + s).find(s, 1) != len(s)

KMP法:

充分条件:如果字符串s是由重复子串组成,那么 最长相等前后缀不包含的子串 一定是 s的最小重复子串。

必要条件:如果字符串s的最长相等前后缀不包含的子串 是 s最小重复子串,那么 s是由重复子串组成。

当 最长相等前后缀不包含的子串的长度 可以被 字符串s的长度整除,那么不包含的子串 就是s的最小重复子串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def getNext(self, next, s):#不减一
for i in range(1, len(s)):
len_ = next[i - 1]
while len_ != 0 and s[i] != s[len_]: #等于0也是没有,会跳过的
len_ = next[len_ - 1]
if s[i] == s[len_]: #跳出循环要不是0要不相等
next[i] = len_ + 1

def repeatedSubstringPattern(self, s: str) -> bool:
next = [0] * len(s)
self.getNext(next, s) #最长相等前后缀在该题中就是next[-1]
if next[-1] != 0 and len(s) % (len(s) - next[-1]) == 0:#可以被整除那不包含的子串就是重复子串
return True
return False

4.8 总结篇

建议不使用库函数

很多数组填充类的问题,都可以先预先给数组扩容带填充后的大小,然后在从后向前进行操作。

双指针法是字符串处理的常客。

5. 双指针法

5.11 总结篇

双指针不能同时存下标和数值

双指针有时是快慢(一个走2,一个走1),有时是从两头向中间逼近,有时是从后往前。虽然惯性思维经常是从前往后

除了链表一些题目一定要使用双指针,其他题目都是使用双指针来提高效率,一般是将O(n^2)的时间复杂度,降为 $O(n)$

6. 栈与队列

6.1 理论基础

栈:先进后出

队列:先进先出

6.2 用栈实现队列

一定要懂得复用,功能相近的函数要抽象出来,不要大量的复制粘贴,很容易出问题!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class MyQueue:

def __init__(self):
"""
in主要负责push,out主要负责pop
"""
self.stack_in = []
self.stack_out = []


def push(self, x: int) -> None:
"""
有新元素进来,就往in里面push
"""
self.stack_in.append(x)


def pop(self) -> int:
"""
Removes the element from in front of queue and returns that element.
"""
if self.empty():
return None

if self.stack_out:
return self.stack_out.pop()
else:#[10,20,30]就以30 20 10的顺序导入 这样是先进后出
for i in range(len(self.stack_in)):
self.stack_out.append(self.stack_in.pop())
return self.stack_out.pop()#这里又会以10 20 30的顺序pop,两次先进后出实现队列的先进先出


def peek(self) -> int:
ans = self.pop()#复用了上面的方法,已经移除了不妨加入out
self.stack_out.append(ans)
return ans


def empty(self) -> bool:
"""
只要in或者out有元素,说明队列不为空
"""
return not (self.stack_in or self.stack_out)

需要两个栈一个输入栈,一个输出栈,在push数据的时候,只要数据放进输入栈就好,但在pop的时候,操作就复杂一些,输出栈如果为空,就把进栈数据全部导入进来(注意是全部导入),再从出栈弹出数据,如果输出栈不为空,则直接从出栈弹出数据就可以了。

6.3 用队列实现栈

两个队列:新来的数字入2,然后把1中的数字加入,最后返回1中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
from collections import deque
class MyStack:

def __init__(self):
self.q1 = deque()
self.q2 = deque()

def push(self, x: int) -> None:
self.q2.append(x)
while self.q1:
self.q2.append(self.q1.popleft())
while self.q2:
self.q1.append(self.q2.popleft())

def pop(self) -> int:#这里和top都m
return self.q1.popleft()

def top(self) -> int:
return self.q1[0]

def empty(self) -> bool:
return not self.q1

# Your MyStack object will be instantiated and called as such:
# obj = MyStack()
# obj.push(x)
# param_2 = obj.pop()
# param_3 = obj.top()
# param_4 = obj.empty()

一个队列:模拟栈弹出元素的时候只要将队列头部的元素(除了最后一个元素外) 重新添加到队列尾部,此时再去弹出元素就是栈的顺序了。O(n)

6.4 有效的括号

linux系统中,cd这个进入目录的命令我们应该再熟悉不过了。

1
cd a/b/c/../../

这个命令最后进入a目录,系统是如何知道进入了a目录呢 ,这就是栈的应用(..退,就是后面的目录级要出栈)

判断右括号的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def isValid(self, s: str) -> bool:
st = []
for c in s:
if c == "]" and st:
tmp = st.pop()
if tmp != "[":
return False
elif c == "}" and st:
tmp = st.pop()
if tmp != "{":
return False
elif c == ")" and st:
tmp = st.pop()
if tmp != "(":
return False
else:
st.append(c)
return False if st else True

碰到左括号,就把相应的右括号入栈,然后右括号来时对比是否一样即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 方法二,使用字典
class Solution:
def isValid(self, s: str) -> bool:
stack = []
mapping = {#keys/[] 学习字典的用法
'(': ')',
'[': ']',
'{': '}'
}
for item in s:
if item in mapping.keys():
stack.append(mapping[item])
elif not stack or stack[-1] != item:
return False
else:
stack.pop()
return True if not stack else False

6.5 删除字符串中的所有相邻重复项

这道题目就像是我们玩过的游戏对对碰,如果相同的元素挨在一起就要消除。

递归的实现就是:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。无限递归会引发调用栈溢出Segmentation fault

而且在企业项目开发中,尽量不要使用递归!在项目比较大的时候,由于参数多,全局变量等等,使用递归很容易判断不充分return的条件,非常容易无限递归(或者递归层级过深),造成栈溢出错误(这种问题还不好排查!)

如果不让用栈可以用双指针模拟

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def removeDuplicates(self, s: str) -> str:
st = []
for c in s:
if st and c == st[-1]:
st.pop()
else:
st.append(c)
res = ""
for c in st:#这里简单的写法是"".join(res) 数组转字符串
res += c
return res

6.6 逆波兰表达式求值

栈与递归之间在某种程度上是可以转换的!逆波兰表达式相当于是二叉树中的后序遍历。后缀表达式RPN对计算机来说是非常友好的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def evalRPN(self, tokens: List[str]) -> int:
st = []
sign = ['+', '-', '*', '/']
for c in tokens:
if c in sign:
num1 = st.pop()
num2 = st.pop()
if c == "+":
num = num1 + num2
elif c == "-":
num = num2 - num1
elif c == "*":
num = num1 * num2
elif c == "/":
num = num2 / num1#注意向零截断,所以要用/+int,而不能用//
st.append(int(num))
else:
st.append(int(c))
return st[-1]

可以学习下面这种调用函数的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from operator import add, sub, mul

def div(x, y):
# 使用整数除法的向零取整方式
return int(x / y)#答案是下面,但这样写也过了,感觉已经满足向0截断了
#return int(x / y) if x * y > 0 else -(abs(x) // abs(y))

class Solution(object):
op_map = {'+': add, '-': sub, '*': mul, '/': div}

def evalRPN(self, tokens: List[str]) -> int:
stack = []
for token in tokens:
if token not in {'+', '-', '*', '/'}:
stack.append(int(token))
else:
op2 = stack.pop()
op1 = stack.pop()
stack.append(self.op_map[token](op1, op2)) # 第一个出来的在运算符后面
return stack.pop()

6.7 滑动窗口最大值

不能每k个数都调用一次max,会超时(如k=5000)

每次出一个数,来一个数,和max比较,也会超时

后来我的思路是不需要维护滑动窗口中的每个数,只要维护第一大和第二大即可,这样如果最大数离开了,比较新来的数和第二大数即可

  • 我本来觉得维护第一大和第二大简单,看答案后发现复杂度是一样的,不是非要死板地保证队列长度为2,不出界就行
  • 标答的while和每次都加入巧妙地保证了队列一定单调,且无需重新计算也知道目前窗口里的最值(如果q长度为1就是1,不然长度为3就是top3)

也就是单调队列维护队列递减,不断把新的数与队尾元素比较即可

存下标非常巧妙,因为和滑动窗口的位置息息相关!!这样窗口变更后才知道现有队列中的最值是否出去了,while保证队列里的每个值都被判断,留下来的都是还在滑动窗口里的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from collections import deque
from typing import List

class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
q = deque()
result = []
for i in range(len(nums)):
# 移除不在窗口内的队首元素 保证q中的每个下标都没有超越窗口的左边界
while q and q[0] < i - k + 1:
q.popleft()
# 维护队列递减性质 如果当前元素大于队尾元素 则队尾元素出队 有可能把队列全清空
while q and nums[i] >= nums[q[-1]]:
q.pop()
q.append(i)#我不懂为啥当前的数字非得存进去,只和队尾比较一次不好吗(仔细想和维护top1/2的复杂性是一样的,这样写单调性更简单灵活,不是说非要有2个数)
if i >= k - 1:#其实除了最开始窗口小于k,后面每次都会有一个最大值
result.append(nums[q[0]])
return result

6.8 前 K 个高频元素

优先级队列:披着队列外衣的堆,看起来就是队列,但内部元素是自动依照元素的权值排列

缺省情况下priority_queue利用max-heap(大顶堆)完成对元素的排序,大顶堆就是节点的值不小于左右孩子的值

所以大家经常说的大顶堆(堆头是最大元素),小顶堆(堆头是最小元素),如果懒得自己实现的话,就直接用priority_queue(优先级队列)就可以了,底层实现都是一样的,从小到大排就是小顶堆,从大到小排就是大顶堆。

  • 维护k个有序的序列即可
  • 要用小顶堆,这样每次更新时把最小的元素弹出,留下来的就是前k个最大元素;用大顶堆,每次更新会把最大的元素弹出去,不符合逻辑

所以本题先计算频率,然后把频率放入大小为k的小顶堆中,最后留下的就是前k大的高频元素(对于我的重点是小顶堆怎么建?)

1
2
3
4
5
6
def topKFrequent(nums, k):
# 统计每个数字出现的频率
count = Counter(nums)#Counter({1: 3, 2: 2, 3: 1}),key是值,value是频率

# heapq.nsmallest用于从可迭代对象中获取最小的k个元素,而题目是最高频的,x[1]是频率,所以-x[1]让频率反向
return [item for item, freq in heapq.nsmallest(k, count.items(), key=lambda x: (-x[1]))]

注意调用heapq库很方便:排序过程的时间复杂度是 O(log k) ,整个算法的时间复杂度是 O(nlog k)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import heapq
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
map_ = {}
for num in nums:
map_[num] = map_.get(num, 0) + 1
heap = [] #从小到大排前k高的元素
for num, freq in map_.items():
if len(heap) < k:
heapq.heappush(heap, (freq, num))
else:#比较当前值和堆顶的频率最小值哪个大
if freq > heap[0][0]:
heapq.heappushpop(heap, (freq, num))
#另一种写法是先加入,让其排序,然后超过k了再弹出最小值
#for key, freq in map_.items():
# heapq.heappush(heap, (freq, key))
# if len(heap) > k: #如果堆的大小大于了K,则队列弹出,保证堆的大小一直为k
# heapq.heappop(heap)
res = []
while heap:
res.append(heapq.heappop(heap)[1])#注意加入的是值不是频率
#另一种写法是利用range可以倒序遍历索引
#res = [0] * k
#for i in range(k-1, -1, -1):#起点是k-1,终点是0(左闭右开所以写-1),步长-1实现倒序遍历
# res[i] = heapq.heappop(heap)[1]
return res

6.9 总结篇

栈里面的元素在内存中是连续分布的么?这个问题有两个陷阱:

  • 陷阱1:栈是容器适配器,底层容器使用不同的容器,导致栈内数据在内存中不一定是连续分布的。(python用list实现栈时是连续的)
  • 陷阱2:缺省情况下,默认底层容器是deque,那么deque在内存中的数据分布是什么样的呢? 答案是:不连续的,下文也会提到deque。

递归的实现是栈:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。

7. 二叉树

7.1 理论基础

分类

满二叉树:所以排序的过程的时间复杂度是 $O(\log k)$ ,整个算法的时间复杂度是 $O(n\log k)$ 。深度为k,有2^k-1个节点

img

完全二叉树:除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层(h从1开始),则该层包含 1~ 2^(h-1) 个节点。堆就是一个完全二叉树。img

二叉搜索树:有数值,有序。下面这两棵树都是搜索树

img

平衡二叉搜索树:AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。

如图:

img

最后一棵 不是平衡二叉树,因为它的左右两个子树的高度差的绝对值超过了1。

C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作时间时间复杂度是logn,注意我这里没有说unordered_map、unordered_set,unordered_map、unordered_set底层实现是哈希表。

  • 正是高度差<=1,以及红黑树的规则,保证了O(logn)的复杂度

存储方式

链式存储方式就用指针, 顺序存储的方式就是用数组。顺序存储的元素在内存是连续分布的,而链式存储则是通过指针把分布在各个地址的节点串联一起。一般是用链式表示二叉树,有助于理解。

imgimg

如果父节点的数组下标是 i,那么它的左孩子就是 i * 2 + 1,右孩子就是 i * 2 + 2。

遍历方式

  • 深度优先遍历:先往深走,遇到叶子节点再往回走。
    • 前序遍历(递归法,迭代法)中左右
    • 中序遍历(递归法,迭代法)左中右
    • 后序遍历(递归法,迭代法)左右中
  • 广度优先遍历:一层一层的去遍历。
    • 层次遍历(迭代法)
img

栈其实就是递归的一种实现结构,也就说前中后序遍历的逻辑其实都是可以借助栈使用递归的方式来实现的。而广度优先遍历的实现一般使用队列来实现,这也是队列先进先出的特点所决定的,因为需要先进先出的结构,才能一层一层的来遍历二叉树。

定义

1
2
3
4
5
class TreeNode:
def __init__(self, val, left = None, right = None):
self.val = val
self.left = left
self.right = right

7.2 二叉树的递归遍历

每次写递归,都按照这三要素来写,可以保证大家写出正确的递归算法!

  1. 确定递归函数的参数和返回值: 确定哪些参数是递归的过程中需要处理的,那么就在递归函数里加上这个参数, 并且还要明确每次递归的返回值是什么进而确定递归函数的返回类型。
  2. 确定终止条件: 写完了递归算法, 运行的时候,经常会遇到栈溢出的错误,就是没写终止条件或者终止条件写的不对,操作系统也是用一个栈的结构来保存每一层递归的信息,如果递归没有终止,操作系统的内存栈必然就会溢出。
  3. 确定单层递归的逻辑: 确定每一层递归需要处理的信息。在这里也就会重复调用自己来实现递归的过程。

145. 二叉树的后序遍历

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
# 左右中
res = []# 定义在外部函数中的局部变量
def traverse(node: Optional[TreeNode]):
if node is None:#注意没有null,以及不要用==None,写is None
return
traverse(node.left)
traverse(node.right)
res.append(node.val)# 子函数可以访问并修改外部函数的变量 属于闭包
traverse(root)
return res

94. 二叉树的中序遍历

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
res = []
def traverse(node: Optional[TreeNode]):
if node is None:
return
traverse(node.left)
res.append(node.val)
traverse(node.right)
traverse(root)
return res

144. 二叉树的前序遍历

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
res = []
def traverse(node: Optional[TreeNode]):
if node is None:
return
res.append(node.val)
traverse(node.left)
traverse(node.right)
traverse(root)
return res

7.3 二叉树的迭代遍历

前序遍历是中左右,每次先处理的是中间节点,那么先将根节点放入栈中,每次弹出来的加入res,然后将右孩子加入栈,再加入左孩子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:#检查是否为空
return []
stack = [root]
result = []
while stack:
node = stack.pop()
# 中节点先处理
result.append(node.val)
# 右孩子先入栈
if node.right:
stack.append(node.right)
# 左孩子后入栈
if node.left:
stack.append(node.left)
return result

中序遍历:需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
stack = []
result = []
cur = root #这里要先迭代到最底层的左子树节点,所以不能把root提前加进去
while cur or stack:
if cur:
stack.append(cur)
cur = cur.left
#到达最左节点后逐步处理子树
else:#else说明cur.left为空
cur = stack.pop()
result.append(cur.val)
cur = cur.right
return result

后序遍历:因为第一个是中比较好处理,所以先得到中右左再反转,跟前序很像,就是先放左节点再放右节点

前序到后序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
stack = [root]
result = []
while stack:
n = stack.pop()
result.append(n.val)
if n.left:
stack.append(n.left)
if n.right:
stack.append(n.right)
return result[::-1]

此时我们用迭代法写出了二叉树的前后中序遍历,大家可以看出前序和中序是完全两种代码风格,并不像递归写法那样代码稍做调整,就可以实现前后中序。

这是因为前序遍历中访问节点(遍历节点)和处理节点(将元素放进result数组中)可以同步处理,但是中序就无法做到同步!

7.4 二叉树的统一迭代法

方法一:就是要处理的节点放入栈之后,紧接着放入一个空指针作为标记。 这种方法可以叫做空指针标记法。这样当遇见空指针时,就应该pop出栈+加入result列表。什么时候加空?就是在当前处理的节点(中)后面加None。

先统一把root加进去,然后左中右的顺序因为是栈,反过来加入即可。要处理指只访问了,没有加入到结果集未处理的点。

前序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
result = []
st= []
if root:
st.append(root)
while st:
node = st.pop()
if node != None:
if node.right: #右
st.append(node.right)
if node.left: #左
st.append(node.left)
st.append(node) #中
st.append(None)#中是当前处理的节点
else:
node = st.pop()
result.append(node.val)
return result

中序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def inorderTraversal(self, root: TreeNode) -> List[int]:
result = []
st = []
if root:
st.append(root)
while st:
node = st.pop()
if node != None:
if node.right: #添加右节点(空节点不入栈)
st.append(node.right)
st.append(node) #添加中节点
st.append(None) #中节点访问过,但是还没有处理,加入空节点做为标记。
if node.left: #添加左节点(空节点不入栈)
st.append(node.left)
else: #只有遇到空节点的时候,才将下一个节点放进结果集
node = st.pop() #重新取出栈中元素
result.append(node.val) #加入到结果集
return result

后序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def postorderTraversal(self, root: TreeNode) -> List[int]:
result = []
st = []
if root:
st.append(root)
while st:
node = st.pop()
if node != None:
st.append(node) #中
st.append(None)

if node.right: #右
st.append(node.right)
if node.left: #左
st.append(node.left)
else:
node = st.pop()
result.append(node.val)
return result

方法二:加一个 boolean 值跟随每个节点,false (默认值) 表示需要为该节点和它的左右儿子安排在栈中的位次,true 表示该节点的位次之前已经安排过了,可以收割节点了。 这种方法可以叫做boolean 标记法,样例代码见下文C++ 和 Python 的 boolean 标记法。 这种方法更容易理解,在面试中更容易写出来。

还是一开始把root进栈,很好理解的visited数组,如果该节点和两个子节点都安排过了,就可以标为true,准备加入结果集了

前序:中左右

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
result = []
stack = [(root, False)] if root else []
while stack:
node, visited = stack.pop()
if visited:
result.append(node.val)
continue
if node.right:#右
stack.append((node.right, False))
if node.left:#左
stack.append((node.left, False))
stack.append((node, True))#中
return result

中序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def inorderTraversal(self, root: TreeNode) -> List[int]:
result = []
stack = [(root, False)] if root else []
while stack:
node, visited = stack.pop()
if visited:
result.append(node.val)
continue
if node.right:#右
stack.append((node.right, False))
stack.append((node, True))#中
if node.left:#左
stack.append((node.left, False))
return result

后序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
result = []
stack = [(root, False)] if root else [] # 多加一个参数,False 为默认值
while stack:
node, visited = stack.pop() # 多加一个 visited 参数,使“迭代统一写法”成为一件简单的事
if visited: # visited 为 True,表示该节点和两个儿子位次之前已经安排过了,现在可以收割节点了
result.append(node.val)
continue #这一轮循环过掉
# visited 当前为 False, 表示初次访问本节点,此次访问的目的是“把自己和两个儿子在栈中安排好位次” 同时,设置 visited 为 True,表示下次再访问本节点时,允许收割。
stack.append((node, True)) #中节点
if node.right:
stack.append((node.right, False)) # 右儿子位置居中
if node.left:
stack.append((node.left, False)) # 左儿子最后入栈,最先出栈
return result

7.5 二叉树层序遍历

102. 二叉树的层序遍历

栈先进后出适合模拟DFS,深度优先

队列先进先出适合模拟BFS,广度优先

难点是如何确定在某一层?用目前队列的长度len!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
if not root:
return []
queue = collections.deque([root])#创建队列并加入root
result = []#记录结果的list
while queue:
level = []#当前层的内容,可看作子数组
for _ in range(len(queue)):
cur = queue.popleft()
level.append(cur.val)
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
result.append(level)
return result

107. 二叉树的层序遍历 II

自底向上的层序遍历,最后反转一下即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def levelOrderBottom(self, root: Optional[TreeNode]) -> List[List[int]]:
if not root:
return []
queue = collections.deque([root])
result = []
while queue:
level = []
for _ in range(len(queue)):
cur = queue.popleft()
level.append(cur.val)
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
result.append(level)
return result[::-1]

199. 二叉树的右视图

取层序遍历的右值,即level[-1],其实不需要像上面一样记录每层完整的level,只需要在i==len(queue)-1时获取值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def rightSideView(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
queue = collections.deque([root])#创建队列并加入root
result = []#记录结果的list
while queue:
level_size = len(queue) #这里要记录,不然popleft后长度会变
for i in range(level_size):
cur = queue.popleft()
if i == level_size - 1:
result.append(cur.val)
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
return result

637. 二叉树的层平均值

就是把每层求累计再求平均

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def averageOfLevels(self, root: Optional[TreeNode]) -> List[float]:
if not root:
return []
queue = collections.deque([root])#创建队列并加入root
result = []#记录结果的list
while queue:
level_size = len(queue)
level_sum = 0 #这里要记录,不然popleft后长度会变
for i in range(level_size):
cur = queue.popleft()
level_sum += cur.val
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
result.append(level_sum / level_size)
return result

429. N 叉树的层序遍历

从2叉树变为n叉树,遍历每个节点的children即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def levelOrder(self, root: 'Node') -> List[List[int]]:
if not root:
return []
queue = collections.deque([root])
result = []
while queue:
level = []
for _ in range(len(queue)):
cur = queue.popleft()
level.append(cur.val)
for node in cur.children:
queue.append(node)
result.append(level)
return result

515. 在每个树行中找最大值

定义无穷小值然后去比较

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def largestValues(self, root: Optional[TreeNode]) -> List[int]:
if not root:
return []
queue = collections.deque([root])#创建队列并加入root
result = []#记录结果的list
while queue:
max_ = float('-inf')
for i in range(len(queue)):
cur = queue.popleft()
if cur.val > max_:
max_ = cur.val
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
result.append(max_)
return result

116. 填充每个节点的下一个右侧节点指针

保证是有两个子节点,仍然是遍历level进行指针的排列,最后一个节点指向null

我刚开始是先把同层节点都加入level,再遍历level进行连接,但其实不用额外的空间level存每层节点,遍历时用prev存即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def connect(self, root: 'Optional[Node]') -> 'Optional[Node]':
if not root:
return root
queue = collections.deque([root])#创建队列并加入root
while queue:
level = []#当前层的内容,可看作子数组
for _ in range(len(queue)):
cur = queue.popleft()
level.append(cur)
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
for i in range(len(level)):
if i != len(level) - 1:
level[i].next = level[i + 1]
else:
level[i].next = None
return root
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def connect(self, root: 'Optional[Node]') -> 'Optional[Node]':
if not root:
return root
queue = collections.deque([root])#创建队列并加入root
while queue:
prev = None #存上一个节点
for _ in range(len(queue)):
cur = queue.popleft()
if prev:
prev.next = cur
prev = cur #最后一个节点自然不会有next
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
return root

117. 填充每个节点的下一个右侧节点指针 II

要求只能使用常量级的额外空间,上面的两种方法均符合,时间复杂度为O(n),把树上的每个节点都遍历了

104. 二叉树的最大深度

仍然用层序遍历看,最大深度就是二叉树的层数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def maxDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
queue = collections.deque([root])#创建队列并加入root
res = 0
while queue:
for i in range(len(queue)):
cur = queue.popleft()
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
res += 1
return res

111. 二叉树的最小深度

哪一层最先出现叶子节点,该层数就是最小深度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def minDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
queue = collections.deque([root])#创建队列并加入root
res = 0
while queue:
res += 1 #记录当前的层数
for i in range(len(queue)):
cur = queue.popleft()
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
if not cur.left and not cur.right:
return res
return res

7.6 翻转二叉树

我使用的方法是迭代法的层序遍历,把每个节点的左右孩子翻转,用前中后序也是可以的

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def invertTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
if not root:
return root
queue = collections.deque([root])#存储未翻转的节点
while queue:
node = queue.popleft()
node.left, node.right = node.right, node.left
if node.left: queue.append(node.left)
if node.right: queue.append(node.right)
return root

递归法的前序遍历举例:

1
2
3
4
5
6
7
8
class Solution:
def invertTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
if not root:
return root
root.left, root.right = root.right, root.left
self.invertTree(root.left)
self.invertTree(root.right)
return root

7.7 二叉树周末总结

589. N 叉树的前序遍历

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def preorder(self, root: 'Node') -> List[int]:
res = []
def traverse(root: 'Node'):
if not root:
return
res.append(root.val)
for node in root.children:
traverse(node)
traverse(root)
return res

590. N 叉树的后序遍历

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def postorder(self, root: 'Node') -> List[int]:
res = []
def traverse(root: 'Node'):
if not root:
return
for node in root.children:
traverse(node)
res.append(root.val)
traverse(root)
return res

在实际项目开发的过程中我们是要尽量避免递归!因为项目代码参数、调用关系都比较复杂,不容易控制递归深度,甚至会栈溢出。

一定要掌握前中后序一种迭代的写法,并不因为某种场景的题目一定要用迭代,而是现场面试的时候,面试官看你顺畅的写出了递归,一般会进一步考察能不能写出相应的迭代

7.8 对称二叉树

101. 对称二叉树1

正是因为要遍历两棵树而且要比较内侧和外侧节点,所以准确的来说是一个树的遍历顺序是左右中,一个树的遍历顺序是右左中。所以要用后序遍历。

递归三部曲

  1. 确定递归函数的参数和返回值:bool

  2. 确定终止条件:

    1. 左节点为空,右节点不为空,不对称,return false
    2. 左不为空,右为空,不对称 return false
    3. 左右都为空,对称,返回true
    4. 左右都不为空,比较节点数值,不相同就return false
  3. 确定单层递归的逻辑:

    1. 比较二叉树外侧是否对称:传入的是左节点的左孩子,右节点的右孩子。
    2. 比较内侧是否对称,传入左节点的右孩子,右节点的左孩子。
    3. 如果左右都对称就返回true ,有一侧不对称就返回false 。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def isSymmetric(self, root: Optional[TreeNode]) -> bool:
if not root:
return True
return self.compare(root.left, root.right)

def compare(self, left, right):
if left == None and right != None: return False
elif left != None and right == None: return False
elif left == None and right == None: return True
#两个节点都不为空时
elif left.val != right.val: return False
#当前两个节点相等,就比较子节点的外侧和内侧
outside = self.compare(left.left, right.right)
inside = self.compare(left.right, right.left)
return outside and inside

迭代法:我使用层序遍历获得level判断是否对称,但这样有额外的空间开销

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution:
def isSymmetric(self, root: Optional[TreeNode]) -> bool:
#收集每层的内容,反转数组一样
if not root:
return True
queue = collections.deque([root])
while queue:
level_size = len(queue)#这里是代码随想录补充的剪枝,这样某一层是奇数个节点就不用看了,不是对称二叉树
if level_size % 2 != 0:
return False
level = []
for i in range(level_size):
cur = queue.popleft()
if not cur:
level.append(None)
else:
queue.append(cur.left)
queue.append(cur.right)
level.append(cur.val)
if level != level[::-1]:
return False
return True

不适用level记录,直接根据终止条件获得左右子树节点然后判断的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution:
def isSymmetric(self, root: Optional[TreeNode]) -> bool:
if not root:
return True
queue = collections.deque()
queue.append(root.left) #将左子树头结点加入队列
queue.append(root.right) #将右子树头结点加入队列
while queue: #接下来就要判断这这两个树是否相互翻转
leftNode = queue.popleft()
rightNode = queue.popleft()
if not leftNode and not rightNode: #左节点为空、右节点为空,此时说明是对称的
continue
#左右一个节点不为空,或者都不为空但数值不相同,返回false
if not leftNode or not rightNode or leftNode.val != rightNode.val:
return False
#到这一步说明左右节点不为空且数值相同,可以比接下来的。注意加入顺序:先比外侧再比内侧,所以左左+右右一组/左右+右左一组
queue.append(leftNode.left) #加入左节点左孩子
queue.append(rightNode.right) #加入右节点右孩子
queue.append(leftNode.right) #加入左节点右孩子
queue.append(rightNode.left) #加入右节点左孩子
return True

7.9 二叉树的最大深度

104. 二叉树的最大深度

递归法:左右中:后序遍历,取最大值作为深度

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def maxDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
if not root.left and not root.right:
return 1
l, r = 1, 1
if root.left:
l = self.maxDepth(root.left) + 1
if root.right:
r = self.maxDepth(root.right) + 1
return max(l, r)

迭代法:用queue,每轮存一层的所有节点,层数就是深度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def maxDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
depth = 0
queue = collections.deque([root])#创建队列的库
while queue:
depth += 1
for _ in range(len(queue)):#注意这里长度为len(queue)是最巧妙的
n = queue.popleft()
if n.left:
queue.append(n.left)
if n.right:
queue.append(n.right)
return depth

559. N 叉树的最大深度

递归法:

1
2
3
4
5
6
7
8
9
10
class Solution:
def maxDepth(self, root: 'Node') -> int:
if not root:
return 0
if not root.children:
return 1
res = 0
for n in root.children:
res = max(res, self.maxDepth(n))
return res + 1

迭代法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def maxDepth(self, root: 'Node') -> int:
if not root:
return 0
depth = 0
queue = collections.deque([root])
while queue:
depth += 1
for _ in range(len(queue)):
n = queue.popleft()
if n.children:
for node in n.children:
queue.append(node)
return depth

7.10 二叉树的最小深度

难点在于左右孩子不为空的逻辑

可以设置正无穷,这样只有一边的节点时不会取默认值0,但测出来效率低

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def minDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
if not root.left and not root.right:
return 1
l, r = float('+inf'), float('+inf')
if root.left:
l = self.minDepth(root.left)
if root.right:
r = self.minDepth(root.right)
return min(l, r) + 1

也可以老实写判断逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def minDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
leftDepth = self.minDepth(root.left) # 左
rightDepth = self.minDepth(root.right) # 右
# 当一个左子树为空,右不为空,这时并不是最低点
if root.left is None and root.right is not None:
return 1 + rightDepth
# 当一个右子树为空,左不为空,这时并不是最低点
if root.left is not None and root.right is None:
return 1 + leftDepth
result = 1 + min(leftDepth, rightDepth)
return result

迭代法就是层序遍历到第一个叶子节点就返回

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def minDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
queue = collections.deque([root])
depth = 0
while queue:
depth += 1
for _ in range(len(queue)):
n = queue.popleft()
if not n.left and not n.right:
return depth
if n.left:
queue.append(n.left)
if n.right:
queue.append(n.right)
return depth

7.11 完全二叉树的节点个数

求节点个数:O(n)

1
2
3
4
5
class Solution:
def countNodes(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
return self.countNodes(root.left) + self.countNodes(root.right) + 1

利用完全二叉树:只有两种情况

  • 一种是满二叉树:节点个数可以直接用2^树深度-1来计算
  • 一种是最后一层叶子节点没有满,但递归到某一深度后一定会有左右孩子为满二叉树,用上式计算
    • 满二叉树的判断是左右子树的深度相等
img

这个情况无需考虑因为不是完全二叉树img

别看最后还是后序遍历的递归,但用满二叉树的公式计算左右子树是一种剪枝,节约了许多递归开销

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def countNodes(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
left = root.left
right = root.right
leftDepth = 0 #这里初始为0是有目的的,为了下面求指数方便
rightDepth = 0
while left: #求左子树深度
left = left.left
leftDepth += 1
while right: #求右子树深度
right = right.right
rightDepth += 1
if leftDepth == rightDepth:
return (2 << leftDepth) - 1 #注意(2<<1) 相当于2^2,所以leftDepth初始为0
return self.countNodes(root.left) + self.countNodes(root.right) + 1

或者把left/right写在一个while里循环

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def countNodes(self, root: Optional[TreeNode]) -> int:
if not root: return 0
count = 0
left = root.left; right = root.right
while left and right:
count+=1
left = left.left; right = right.right
if not left and not right: # 如果同时到底说明是满二叉树,反之则不是,剪枝
return (2<<count)-1
return 1+self.countNodes(root.left)+self.countNodes(root.right)

7.12 平衡二叉树

平衡二叉树 是指该树所有节点的左右子树的高度相差不超过 1。

  • 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数。用前序遍历,是层数
  • 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数。用后序遍历,是倒数第几层
110.平衡二叉树2

我本来的算法把递归重复了两遍,其实没必要,因为getDepth的过程中就可以进行平衡二叉树的判断了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def isBalanced(self, root: Optional[TreeNode]) -> bool:
if not root:
return True
def getHeight(root) -> int:#计算左右子树的高度.子函数要写在前面,不然,不然访问不到,认为根节点为0
if not root:
return 0
if not root.left and not root.right:
return 1
return max(getHeight(root.left), getHeight(root.right)) + 1

l = getHeight(root.left) + 1
r = getHeight(root.right) + 1
if abs(l - r) > 1:
return False
else:
return self.isBalanced(root.right) and self.isBalanced(root.left)

定义getHeight依然返回高度,如果不是平衡二叉树就返回-1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def isBalanced(self, root: Optional[TreeNode]) -> bool:
if not root:
return True
def getHeight(root) -> int:
if not root:
return 0
if not root.left and not root.right:
return 1
l = getHeight(root.left)
r = getHeight(root.right)
if abs(l - r) > 1 or l == -1 or r == -1:#这里记得判断l/r是否为-1,会覆盖左右子树不满足平衡的情况
return -1
else:
return max(l, r) + 1
return False if getHeight(root) == -1 else True

此题用迭代法效率很低,虽然理论上所有递归都可以迭代实现,但这里需要遍历每个节点然后计算当前节点左右子树的高度,没细看感觉很麻烦

7.13 二叉树的所有路径

从根节点到叶子节点:前序遍历(中左右)递归+回溯

递归法+回溯:回溯这里的path.pop()是关键

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution:
def binaryTreePaths(self, root: Optional[TreeNode]) -> List[str]:
def traversal(self, cur, path, result):
path.append(cur.val) # 中
if not cur.left and not cur.right: # 到达叶子节点
sPath = '->'.join(map(str, path)) #把访问到的节点的值都连接起来
result.append(sPath)
return
if cur.left: # 左
self.traversal(cur.left, path, result)
path.pop() # 回溯
if cur.right: # 右
self.traversal(cur.right, path, result)
path.pop() # 回溯

def binaryTreePaths(self, root):
result = [] #存路径结果
path = [] #存每个节点的值
if not root:
return result
self.traversal(root, path, result)
return result

迭代法:刚开始我用stack不知道如何实现,发现答案用path_st存储遍历到每个节点的路径,而不是仅用一个res保存当前的访问

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def binaryTreePaths(self, root: Optional[TreeNode]) -> List[str]:
#需要深度优先搜索,前序遍历(中左右),用栈
if not root:
return []
res = []
stack = [root]
path_st = [str(root.val)]#存储遍历到每个节点时的路径
while stack:
n = stack.pop()
path = path_st.pop()
if not n.left and not n.right:
res.append(path)
if n.right:#因为是栈先进右值
stack.append(n.right)
path_st.append(path + '->' + str(n.right.val))#是下一步的内容
if n.left:
stack.append(n.left)
path_st.append(path + '->' + str(n.left.val))
return res

7.14 二叉树周末总结

100. 相同的树

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def isSameTree(self, p: Optional[TreeNode], q: Optional[TreeNode]) -> bool:
def isSame(node1: Optional[TreeNode], node2: Optional[TreeNode]) -> bool:
if node1 is None and node2 is not None:
return False
if node2 is None and node1 is not None:
return False
if node1 is None and node2 is None:
return True
if node1.val != node2.val:
return False
return isSame(node1.left, node2.left) and isSame(node1.right, node2.right)#这里isSame不是self的方法,所以调用不用写self.
return isSame(p, q)

572. 另一棵树的子树

注意判断子树的节点和root主树是否为空

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution:
def isSubtree(self, root: Optional[TreeNode], subRoot: Optional[TreeNode]) -> bool:
def isSame(node1: Optional[TreeNode], node2: Optional[TreeNode]) -> bool:
if not node1 and not node2: # 两个都是 None,匹配
return True
if not node1 or not node2: # 一个 None,另一个非 None,不匹配
return False
return (
node1.val == node2.val
and isSame(node1.left, node2.left)
and isSame(node1.right, node2.right)
)

if not subRoot: # 空树是任何树的子树
return True
if not root: # 主树为空,子树非空,不匹配
return False

# 检查当前树是否匹配,或者左/右子树是否包含 subRoot
return (
isSame(root, subRoot)
or self.isSubtree(root.left, subRoot)
or self.isSubtree(root.right, subRoot)
)

7.15 左叶子之和

给定二叉树的根节点 root ,返回所有左叶子之和。

  • 注意只加左边的叶子节点,非叶子节点不加
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
res = 0
def sumOfLeftLeaves(self, root: Optional[TreeNode]) -> int:
self.res = 0 #定义成实例变量,这样都可以访问
def sum(node: Optional[TreeNode]):
if node is None:
return
if node.left and node.left.left is None and node.left.right is None:#注意要是叶子节点
self.res += node.left.val
sum(node.left)
sum(node.right)
sum(root)
return self.res

后面发现这道题也没必要另外定义一个函数

1
2
3
4
5
6
7
8
9
class Solution:
res = 0
def sumOfLeftLeaves(self, root: Optional[TreeNode]) -> int:
res = 0 #定义成实例变量,这样都可以访问
if root is None:
return 0
if root.left and root.left.left is None and root.left.right is None:#注意要是叶子节点
res += root.left.val
return res + self.sumOfLeftLeaves(root.left) + self.sumOfLeftLeaves(root.right)

7.16 找树左下角的值

用迭代很好理解,递归没看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def findBottomLeftValue(self, root: Optional[TreeNode]) -> int:
queue = collections.deque([root])
res = 0
while queue:
for i in range(len(queue)):
node = queue.popleft()
if i == 0: #我本来在这里判断是否为叶子节点,但这样有误,不知道后面有没有新的层
res = node.val
if node.left:
queue.append(node.left)
if node.right:
queue.append(node.right)
return res #最后一次赋值一定是叶子节点

7.17 路径总和

判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。也是典型的回溯+dfs

递归函数什么时候需要返回值?什么时候不需要返回值?这里总结如下三点:

  • 如果需要搜索整棵二叉树且不用处理递归返回值,递归函数就不要返回值。(这种情况就是本文下半部分介绍的113.路径总和ii)
  • 如果需要搜索整棵二叉树且需要处理递归返回值,递归函数就需要返回值。 (这种情况我们在236. 二叉树的最近公共祖先 (opens new window)中介绍)
  • 如果要搜索其中一条符合条件的路径,那么递归一定需要返回值,因为遇到符合条件的路径了就要及时返回。(本题的情况)

其实不一定要回溯,因为路径是连续的不会断掉:

1
2
3
4
5
6
7
class Solution:
def hasPathSum(self, root: Optional[TreeNode], targetSum: int) -> bool:
if not root:
return False
if not root.left and not root.right:
return targetSum == root.val
return self.hasPathSum(root.left, targetSum - root.val) or self.hasPathSum(root.right, targetSum - root.val)

回溯:这里要搜索其中一条符合条件的路径,所以递归返回的是bool。path+这里体现的就是回溯,因为没有直接改变path

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def hasPathSum(self, root: Optional[TreeNode], targetSum: int) -> bool:
if not root:
return False
return self.dfs(root, targetSum, [root.val])

def dfs(self, root, target, path):#path记录目前选择的节点值,用数组
if not root:
return False
if sum(path) == target and not root.left and not root.right:#满足从根节点到叶子节点
return True
left_flag, right_flag = False, False
if root.left:
left_flag = self.dfs(root.left, target, path + [root.left.val])
if root.right:
right_flag = self.dfs(root.right, target, path + [root.right.val])
return left_flag or right_flag

也可以写成:看剩余的count,这里没用path作为中间值存储,所以count要先减再加(选左或者选右,不能同时选)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def traversal(self, cur: TreeNode, count: int) -> bool:
if not cur.left and not cur.right and count == 0: # 遇到叶子节点,并且计数为0
return True
if not cur.left and not cur.right: # 遇到叶子节点直接返回
return False

if cur.left: # 左
count -= cur.left.val
if self.traversal(cur.left, count): # 递归,处理节点
return True
count += cur.left.val # 回溯,撤销处理结果

if cur.right: # 右
count -= cur.right.val
if self.traversal(cur.right, count): # 递归,处理节点
return True
count += cur.right.val # 回溯,撤销处理结果

return False

def hasPathSum(self, root: Optional[TreeNode], targetSum: int) -> bool:
if root is None:
return False
return self.traversal(root, targetSum - root.val)

113. 路径总和 II

和上面相比就是要把满足条件的记录下来,就不能写简单的递归了,用dfs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def pathSum(self, root: Optional[TreeNode], targetSum: int) -> List[List[int]]:
if not root:
return []
self.res = []
self.traversal(root, targetSum, [])
return self.res

def traversal(self, root, target, path):
if not root:
return
path.append(root.val)#选这个点
#以下是选择该点的处理逻辑
if sum(path) == target and not root.left and not root.right:
#self.res.append(list(path))这样写也可以,也是创建一个全新副本
self.res.append(path.copy())#注意不能直接添加path!不然添加的是引用(内存地址),最后结果就是[]
self.traversal(root.left, target, path)
self.traversal(root.right, target, path)
#回溯,不选这个点
path.pop()

7.18 从中序与后序遍历序列构造二叉树

重点是如何切割和找边界值

  • 中序数组用root切割
  • 后序数组用左中序数组的大小切割(因为都是左开始)
  • 得到左子树和右子树的中序和后序后,就可以递归了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def buildTree(self, inorder: List[int], postorder: List[int]) -> Optional[TreeNode]:
if not postorder:#不然下面取-1就会报错了
return None
root_val = postorder[-1]
root = TreeNode(root_val)
#切割中序数组
idx = inorder.index(root_val)
inorder_left = inorder[:idx]
inorder_right = inorder[idx + 1:]
#切割后序数组
postorder_left = postorder[:len(inorder_left)]
postorder_right = postorder[len(inorder_left):len(postorder) - 1]
root.left = self.buildTree(inorder_left, postorder_left)
root.right = self.buildTree(inorder_right, postorder_right)
return root

105. 从前序与中序遍历序列构造二叉树

1
2
3
4
5
6
7
8
9
class Solution:
def buildTree(self, preorder: List[int], inorder: List[int]) -> Optional[TreeNode]:
#前序:中左右 #中序:左中右
if not preorder: # 空节点
return None
left_size = inorder.index(preorder[0]) # 左子树的大小
left = self.buildTree(preorder[1: 1 + left_size], inorder[:left_size])
right = self.buildTree(preorder[1 + left_size:], inorder[1 + left_size:])
return TreeNode(preorder[0], left, right)

前序和中序、中序和后序可以唯一确定一棵二叉树,但前序和后序不能唯一确定二叉树

7.19 最大二叉树

使用切片:

1
2
3
4
5
6
7
8
9
class Solution:
def constructMaximumBinaryTree(self, nums: List[int]) -> Optional[TreeNode]:
if not nums:
return None
root = max(nums)
idx = nums.index(root)
return TreeNode(root,
self.constructMaximumBinaryTree(nums[:idx]),
self.constructMaximumBinaryTree(nums[idx + 1:]))

7.20 二叉树周末总结

切片的题还是比较简单的,我对回溯不太熟练

7.21 合并二叉树

我的写法:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def mergeTrees(self, root1: Optional[TreeNode], root2: Optional[TreeNode]) -> Optional[TreeNode]:
if root1 and not root2:
return root1
if root2 and not root1:
return root2
if not root1 and not root2:
return None
root = TreeNode(root1.val + root2.val)
root.left = self.mergeTrees(root1.left, root2.left)
root.right = self.mergeTrees(root1.right, root2.right)
return root

简洁写法:

1
2
3
4
5
6
7
8
9
10
class Solution:
def mergeTrees(self, root1: TreeNode, root2: TreeNode) -> TreeNode:
if not root1:
return root2
if not root2:
return root1
root1.val += root2.val # 中
root1.left = self.mergeTrees(root1.left, root2.left) #左
root1.right = self.mergeTrees(root1.right, root2.right) # 右
return root1

7.22 二叉搜索树中的搜索

首先是一个我的错误写法:找到目标节点后没有立即返回,如果不加return就相当于遍历整棵树了

1
2
3
4
5
6
7
8
9
class Solution:
def searchBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
if not root:
return None
if root.val == val:
return root
else:
self.searchBST(root.left, val)
self.searchBST(root.right, val)

利用二叉搜索树右子树大于root,左子树小于root的性质

1
2
3
4
5
6
7
8
9
10
class Solution:
def searchBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
if not root:
return None
if root.val == val:
return root
elif root.val < val:
return self.searchBST(root.right, val)
else:
return self.searchBST(root.left, val)

迭代法也很简单好理解:

1
2
3
4
5
6
7
class Solution:
def searchBST(self, root: TreeNode, val: int) -> TreeNode:
while root:
if val < root.val: root = root.left
elif val > root.val: root = root.right
else: return root
return None

7.23 验证二叉搜索树

首先是我的错误写法:只检查了当前节点与其直接子节点的关系,而没有保证整个左子树是否都小于当前节点,或者整个右子树是否都大于当前节点

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def isValidBST(self, root: Optional[TreeNode]) -> bool:
if not root:
return True
if not root.left and not root.right:
return True
if root.left and root.left.val >= root.val:
return False
if root.right and root.right.val <= root.val:
return False
return self.isValidBST(root.left) and self.isValidBST(root.right)

正确写法:引入min/max表示整个子树的上界和下界

1
2
3
4
5
6
class Solution:
def isValidBST(self, root: Optional[TreeNode], left=-inf, right=inf) -> bool:
if root is None:
return True
x = root.val
return left < x < right and self.isValidBST(root.left, left, x) and self.isValidBST(root.right, x, right)

7.24 二叉搜索树的最小绝对差

二叉搜索树一定有左边<root<右边,中序遍历是升序的,最小查值一定出现在中序遍历时相邻的两个数之间,所以用prev记录前一个节点的值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def getMinimumDifference(self, root: Optional[TreeNode]) -> int:
prev = None
min_diff = float('inf')

def inorder(node): #中序遍历
nonlocal prev, min_diff # 用nonlocal访问外部数据(self/传参数也可以)
if not node:
return
inorder(node.left) # 遍历左子树
if prev is not None: # 不为空时,计算当前节点和前一个节点的查值
min_diff = min(min_diff, abs(node.val - prev))
prev = node.val # 更新前驱节点
inorder(node.right) # 遍历右子树

inorder(root)
return min_diff

7.25 二叉搜索树中的众数

众数:出现频率最高的数

如果不是BST,就遍历,然后用map统计频率,将频率排序,最后返回最高频元素的集合

是BST,那中序遍历就是有序的,用pre和cur计数,然后因为可能不止一个,要返回集合,所以用列表存储

假设可以使用额外空间,简单,用map统计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def searchBST(self, cur, freq_map):
if cur is None:
return
freq_map[cur.val] += 1
self.searchBST(cur.left, freq_map)
self.searchBST(cur.right, freq_map)

def findMode(self, root: Optional[TreeNode]) -> List[int]:
freq_map = defaultdict(int) # key:元素,value:出现频率,可以为不存在的key提供默认值,防止keyerror
result = [] # 结果集合
if root is None:
return result
self.searchBST(root, freq_map) # 记录每个元素的出现频率
max_freq = max(freq_map.values()) # 最大的频率 value
for key, freq in freq_map.items(): # 遍历找对应的key
if freq == max_freq:
result.append(key)
return result

不适用额外空间:中序遍历,其中技术的步骤在处理当前节点中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution:
def findMode(self, root: Optional[TreeNode]) -> List[int]:
self.result = []
self.maxCount = 0
self.count = 0 # 当前节点的出现次数
self.pre = None # 上一个节点,中序遍历相邻

def searchBST(cur: Optional[TreeNode]):
if cur is None:
return
searchBST(cur.left)
if self.pre is None: #说明是中序遍历的第一个节点
self.count = 1
elif self.pre.val == cur.val:# 与前一个节点数值相同
self.count += 1
else:# 与前一个节点数值不同
self.count = 1
self.pre = cur

if self.count == self.maxCount: #每遍历一个数都要比较
self.result.append(cur.val)
if self.count > self.maxCount:
self.maxCount = self.count
self.result = [cur.val] #旧列表会被垃圾回收自动释放,不用担心浪费空间

searchBST(cur.right)
return
searchBST(root)
return self.result

7.26 二叉树的最近公共祖先

如果pq不在在同一侧子树中,不然最近公共节点就是root

左右中:后序遍历,是天然的回溯,自底向上查找,如果找到就返回

为什么要遍历整棵树?因为如果想利用left和right做逻辑处理, 不能立刻返回,而是要等left与right逻辑处理完之后才能返回。

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
if root is None or root == q or root == p: return root # 感觉终止条件最重要
left = self.lowestCommonAncestor(root.left, p, q)
right = self.lowestCommonAncestor(root.right, p, q)
if left and right:
return root
if left:
return left
if right:
return right
return None

7.27 周末总结

BST注意是全局的性质,有时不能只比相邻节点;然后中序遍历是有序数组很重要,结合prev/cur进行中序遍历即可完成一些有序的任务

平衡二叉搜索树是不是二叉搜索树和平衡二叉树的结合?平衡二叉树 是指该树所有节点的左右子树的高度相差不超过 1。

  • 是的,是二叉搜索树和平衡二叉树的结合。

平衡二叉树与完全二叉树的区别在于底层节点的位置?

  • 是的,完全二叉树底层必须是从左到右连续的,且次底层是满的(像一层一层铺砖,不能跳过任何位置)

堆是完全二叉树和排序的结合,而不是平衡二叉搜索树?

  • 堆是一棵完全二叉树,同时保证父子节点的顺序关系(有序)。 但完全二叉树一定是平衡二叉树,堆的排序是父节点大于子节点,而搜索树是父节点大于左孩子,小于右孩子,所以堆不是平衡二叉搜索树

7.28 二叉搜索树的最近公共祖先

首先把二叉树的答案默写了一遍,当然是能过的,但这里是二叉搜索树,我想到的是可以多剪枝一下:把p/q和root做比较

我写的:

1
2
3
4
5
6
7
8
9
10
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
if root is None or root == p or root == q:
return root
if p.val < root.val < q.val or q.val < root.val < p.val:
return root
if p.val < root.val and q.val < root.val: # 都在左子树
return self.lowestCommonAncestor(root.left, p, q)
if p.val > root.val and q.val > root.val: # 都在右子树
return self.lowestCommonAncestor(root.right, p, q)

但其实可以观察到除了左右子树的情况其他都是return root,只是我分类讨论了,精简版的代码:

1
2
3
4
5
6
7
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
if p.val < root.val and q.val < root.val: # 都在左子树
return self.lowestCommonAncestor(root.left, p, q)
if p.val > root.val and q.val > root.val:
return self.lowestCommonAncestor(root.right, p, q)
return root

7.29 二叉搜索树中的插入操作

把一个节点插入:刚开始想的很复杂,要调整二叉树结构,但其实遍历二叉搜索树,找到空节点 插入元素就可以了。就是找到空位就行,符合大小规律。

1
2
3
4
5
6
7
8
9
class Solution:
def insertIntoBST(self, root: Optional[TreeNode], val: int) -> Optional[TreeNode]:
if root is None:
return TreeNode(val)
if root.val > val: #注意这里针对root处理,保证节点能正确加入
root.left = self.insertIntoBST(root.left, val)
if root.val < val:
root.right = self.insertIntoBST(root.right, val)
return root

7.30 删除二叉搜索树中的节点

如果该节点同时有左右节点:这个比较复杂,要找左子树最大/右子树最小进行覆盖

如果该节点只有一个子节点,那就用那一个子节点覆盖

如果没有子节点,直接删除

这里的递归注意也是和上一题一样赋值给root.left/root.right,保证整棵树的更新

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution:
def deleteNode(self, root: Optional[TreeNode], key: int) -> Optional[TreeNode]:
if not root:
return
if root.val < key: # 注意这里更新后的节点会自动赋值给父节点,不用担心父子节点对不上号
root.right = self.deleteNode(root.right, key)
elif root.val > key:
root.left = self.deleteNode(root.left, key)
else: # 已经找到了相等的节点
if not root.left and not root.right: #没有子节点,直接删除
return None
if not root.left:
return root.right
if not root.right:
return root.left
#同时有左右节点,用右子树的最小来覆盖root,把左子树赋给该节点。1.找到cur,覆盖root,2.把cur原来的位置删掉,这里简单的方法是还是root,只覆盖值,这样left不变,变right
cur = root.right
while cur.left:
cur = cur.left #下面几行找到root/cur后的处理是最细节的
root.val = cur.val
root.right = self.deleteNode(root.right, cur.val)
# return root 下面就有,不用写了
return root

7.31 修剪二叉搜索树

不在范围内的节点要删除,有唯一答案

如果root.val>=low,那肯定有root.right.val >= low,只需要看root.right.val <= high

  • 如果root.val < low,那root+整个左子树可以删除,用右子树第一个满足范围的节点替代(这里有错!因为右子树可能还有其他点不满足范围,不能直接就return了,要继续递归)

如果root.val<=high,那肯定有root.left.val <= high,只需要看root.left.val >= low

  • 如果root.val > high,那root+整个右子树可以删除

我的错误写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def trimBST(self, root: Optional[TreeNode], low: int, high: int) -> Optional[TreeNode]:
if not root:
return None
if low <= root.val <= high:
root.right = self.trimBST(root.right, low, high)
root.left = self.trimBST(root.left, low, high)
if low > root.val:
while root and root.val < low:
root = root.right
return root # 这里不能直接return了,不然右子树没有继续递归
if high < root.val:
while root and root.val > high:
root = root.left
return root
return root

正确写法是继续递归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def trimBST(self, root: Optional[TreeNode], low: int, high: int) -> Optional[TreeNode]:
if not root:
return None
if root.val < low:
# 当前节点太小,修剪右子树
return self.trimBST(root.right, low, high)
if root.val > high:
# 当前节点太大,修剪左子树
return self.trimBST(root.left, low, high)
# 当前节点在范围内,递归修剪左右子树
root.left = self.trimBST(root.left, low, high)
root.right = self.trimBST(root.right, low, high)
return root

7.32 将有序数组转换为二叉搜索树

中间值是root,分割两边的数组,递归

1
2
3
4
5
6
7
8
9
class Solution:
def sortedArrayToBST(self, nums: List[int]) -> Optional[TreeNode]:
if not nums:
return None
idx = len(nums) // 2
root = TreeNode(nums[idx])
root.left = self.sortedArrayToBST(nums[: idx])
root.right = self.sortedArrayToBST(nums[idx + 1:])
return root

7.33 把二叉搜索树转换为累加树

就是一个有序数组,求从后到前的累加数组,累加顺序是右中左(反中序遍历)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def convertBST(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
s = 0 # 记录目前的累加和
def dfs(node: TreeNode) -> None:
if node is None:
return
dfs(node.right) # 递归右子树
# 递归结束后,s 就等于右子树的所有节点值之和
nonlocal s
s += node.val
node.val = s # 此时 s 就是右子树和自己的和
dfs(node.left) # 递归左子树
dfs(root)
return root

7.34 二叉树:总结篇

深度:从上到下 高度:从下到上

  • 涉及到二叉树的构造,无论普通二叉树还是二叉搜索树一定前序,都是先构造中节点。
  • 求普通二叉树的属性,一般是后序,一般要通过递归函数的返回值做计算。不过这个类型比较多。
  • 求二叉搜索树的属性,一定是中序了,要不白瞎了有序性了。

8. 回溯算法

8.1 回溯算法理论基础

回溯的本质是穷举,穷举所有可能,然后选出我们想要的答案,如果想让回溯法高效一些,可以加一些剪枝的操作,但也改不了回溯法就是穷举的本质。并不是很高效。

回溯法,一般可以解决如下几种问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等

组合是不强调元素顺序的,排列是强调元素顺序

回溯法解决的问题都可以抽象为树形结构(N叉树),因为回溯法解决的都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度就构成了树的深度

回溯算法理论基础

for循环可以理解是横向遍历,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了,一般来说,搜索叶子节点就是找的其中一个结果了。

1
2
3
4
5
6
7
8
9
10
11
12
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}

for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}

8.2 组合

[1,n]中所有可能的k个数的组合

我的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
self.res = [] # 存储最终的结果
self.cur = [] # 存储当前选择的数
self.num = [] # [1, n]
for i in range(1, n + 1):
self.num.append(i)
def backtracking(k: int, start: int): # start表示从哪个数开始选择
if len(self.cur) == k:
self.res.append(self.cur.copy()) # 注意要浅拷贝,或者写成self.cur[:]也可以
return
for i, n in enumerate(self.num[start:]):# 不能重复选择元素
self.cur.append(n)
backtracking(k, i + start + 1)
self.cur = self.cur[:-1] # 去掉该节点
backtracking(k, 0)
return self.res

可以优化的地方:

  1. 用pop撤销节点
  2. num数组不用创建,用startindex记录当前遍历到哪个数字了即可
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
self.res = [] # 存储最终的结果
self.cur = [] # 存储当前选择的数
def backtracking(k: int, start: int): # start表示从哪个数开始选择
if len(self.cur) == k:
self.res.append(self.cur.copy()) # 注意要浅拷贝
return
for i in range(start, n + 1):# 不能重复选择元素
self.cur.append(i)
backtracking(k, i + 1)
self.cur.pop() # 去掉该节点
backtracking(k, 1)
return self.res

8.3 组合(优化)

剪枝:很多情况下继续遍历是没有意义的,因为剩下的数字不足以构成一个长度为k的组合。

  • k - len(path):还需要选择的数字个数。
  • n - (k - len(path)) + 1:最大的起始点,确保从i开始至少还有k - len(path)个数字可供选择。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
self.res = [] # 存储最终的结果
self.cur = [] # 存储当前选择的数
def backtracking(k: int, start: int): # start表示从哪个数开始选择
if len(self.cur) == k:
self.res.append(self.cur.copy()) # 注意要浅拷贝
return
for i in range(start, n - (k - len(self.cur)) + 2):# 不能重复选择元素
self.cur.append(i)
backtracking(k, i + 1)
self.cur.pop() # 去掉该节点
backtracking(k, 1)
return self.res

注意for循环是惰性对的,只进入for时计算一次,之后即使self.cur在递归中被修改,也不会重复计算

8.4 组合总和 III

刚开始照着上面的模板写会超时,加了剪枝后通过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def combinationSum3(self, k: int, n: int) -> List[List[int]]:
self.res = [] # 存储最终的结果
self.cur = [] # 存储当前选择的数
def backtracking(k: int, n: int, start: int): # start表示从哪个数开始选择
if sum(self.cur) == n and len(self.cur) == k:
self.res.append(self.cur.copy()) # 注意要浅拷贝
return
if sum(self.cur) >= n or len(self.cur) >= k: # 这里要加剪枝,不然会超时
return
for i in range(start, 11 - (k - len(self.cur))):# 不能重复选择元素
self.cur.append(i)
backtracking(k, n, i + 1)
self.cur.pop() # 去掉该节点
backtracking(k, n, 1)
return self.res

8.5 电话号码的字母组合

hot100时的答案:简短但省略了添加和回溯撤销的操作,下标也用数组划分代替了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution:
def letterCombinations(self, digits: str) -> List[str]:
if not digits: return []

phone = {'2':['a','b','c'],
'3':['d','e','f'],
'4':['g','h','i'],
'5':['j','k','l'],
'6':['m','n','o'],
'7':['p','q','r','s'],
'8':['t','u','v'],
'9':['w','x','y','z']}#创建dict

def backtrack(conbination, nextdigit):
if len(nextdigit) == 0:
res.append(conbination)
else:
for letter in phone[nextdigit[0]]:
backtrack(conbination + letter,nextdigit[1:])

res = []
backtrack('', digits)
return res

更好理解的版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution:
def letterCombinations(self, digits: str) -> List[str]:
if not digits:
return []
self.phone = {'2':['a','b','c'],
'3':['d','e','f'],
'4':['g','h','i'],
'5':['j','k','l'],
'6':['m','n','o'],
'7':['p','q','r','s'],
'8':['t','u','v'],
'9':['w','x','y','z']} # 创建dict
self.res = []
self.cur = ""
def backtracking(digits: str, start: int): # start表示现在在选digit哪个位
if len(self.cur) == len(digits):
self.res.append(self.cur)
return
digit = digits[start]
letters = self.phone[digit]
for letter in letters:
self.cur += letter
backtracking(digits, start + 1)
self.cur = self.cur[:-1] # 字符串不能进行pop/-操作
backtracking(digits, 0)
return self.res

8.6 回溯周末总结

for循环横向遍历,递归纵向遍历,回溯不断调整结果集

8.7 组合总和

首先写了一版代码,但没有去重,会有[3,5],[5,3]结果同时出现在res里。所以在加入res时加了Counter的比较操作,刚开始只比较counter是超时的,后来加了比较len的就不超时了(还跟我每次用sum求和有关,如果用一个变量记录和计算开销会少一些)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
self.res = []
self.cur = [] # 可以重复选数字
def backtracking(candidates: List[int], target: int):
if sum(self.cur) == target:
for l in self.res:
if len(l) == len(self.cur) and Counter(l) == Counter(self.cur):
return
self.res.append(self.cur.copy())
return
if sum(self.cur) > target:
return
for n in candidates:
self.cur.append(n)
backtracking(candidates, target)
self.cur.pop()
backtracking(candidates, target)
return self.res

其实应该用startIndex防止重复,不应该从0开始而应该从i开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
self.res = []
self.cur = [] # 可以重复选数字
def backtracking(candidates: List[int], target: int, startIndex: int):
if sum(self.cur) > target:
return
if sum(self.cur) == target:
self.res.append(self.cur.copy())
return

for i in range(startIndex, len(candidates)):
self.cur.append(candidates[i])
backtracking(candidates, target, i)
self.cur.pop()
backtracking(candidates, target, 0)
return self.res

进一步剪枝:把candidates排序,这样如果当前循环和超过target了,后面都不用看了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
self.res = []
self.cur = [] # 可以重复选数字
def backtracking(candidates: List[int], target: int, startIndex: int):
if sum(self.cur) > target:
return
if sum(self.cur) == target:
self.res.append(self.cur.copy())
return

for i in range(startIndex, len(candidates)):
if sum(self.cur) + candidates[i] > target:
return
self.cur.append(candidates[i])
backtracking(candidates, target, i)
self.cur.pop()
candidates.sort()
backtracking(candidates, target, 0)
return self.res

8.8 组合总和 II

和上一题相比只能使用一次,感觉去重那里没处理好,下面是错误的写法(面对全1数组会超时):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution:
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
candidates.sort()
self.cur = []
self.res = []
def backtracking(candidates: List[int], target: int, startIndex: int):
if sum(self.cur) == target:
self.res.append(self.cur.copy())
return
if sum(self.cur) > target:
return
for i in range(startIndex, len(candidates)):
if candidates[i] + sum(self.cur) > target:
return
self.cur.append(candidates[i])
backtracking(candidates, target, i + 1)
self.cur.pop()
backtracking(candidates, target, 0)
# 进行数组去重
unique_arr = []
for sub in self.res:
if sub not in unique_arr:
unique_arr.append(sub)
return unique_arr

理论上应该与前一个数比较,但第一次尝试时是可以重复的,不知道该怎么写

要在搜索过程中去重,同一个树枝无所谓,但同一个树层要去重,方法是用used bool数组,但我觉得不如用i和startIndex比较好理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution:
def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
candidates.sort()
self.cur = []
self.res = []
def backtracking(candidates: List[int], target: int, startIndex: int):
if sum(self.cur) == target:
self.res.append(self.cur.copy())
return
if sum(self.cur) > target:
return
for i in range(startIndex, len(candidates)):
if i > startIndex and candidates[i] == candidates[i - 1]:#i>startIndex说明第一个数已经选过了,后面就是判断同一数层不能重复了
continue
if candidates[i] + sum(self.cur) > target:
return
self.cur.append(candidates[i])
backtracking(candidates, target, i + 1)
self.cur.pop()
backtracking(candidates, target, 0)
return self.res

8.9 分割回文串

分割的地方有1到n-1种,怎么选不同的分割方式?类似组合问题

  • 组合问题:选取一个a之后,在bcdef中再去选取第二个,选取b之后在cdef中再选取第三个…..。
  • 切割问题:切割一个a之后,在bcdef中再去切割第二段,切割b之后在cdef中再切割第三段…..。

终止条件是分割线已经到字符串末尾

截取子串也不简单,注意start/end是开区间还是闭区间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution:
def isHuiWen(self, s: str, start: int, end: int) -> bool:#双指针判断s是否为回文串,首位是start,末位是end
i = start #或者用反转字符串s[:] = reversed(s) s[:] = s[::-1] s.reverse()比较,库函数简单
j = end
while i < j:
if s[i] != s[j]:
return False
i += 1
j -= 1
return True
def partition(self, s: str) -> List[List[str]]:
self.res = []
self.cur = []
def backtracking(s: str, start: int):# start代表切割线
if start == len(s):
self.res.append(self.cur.copy())
return
for i in range(start, len(s)):
if self.isHuiWen(s, start, i):
self.cur.append(s[start : i + 1])
backtracking(s, i + 1)
self.cur.pop()
backtracking(s, 0)
return self.res

8.10 复原 IP 地址

有效的地址是每位在0-255之间,有四位,顺序不能变,在s中插入’.’

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution:
def restoreIpAddresses(self, s: str) -> List[str]:
self.res = []
self.cur = []
def backtracking(s: str, start: int):
if len(self.cur) == 4:
self.res.append('.'.join(map(str, self.cur)))
return
for i in range(start, len(s)):#这里可优化,见下
if len(self.cur) == 3:#已经知道三个数后,最后一个数是知道的
cur_str = s[start: ]
i = len(s) # 标记已经找到当前的第四个数了(但不能改变循环,循环中的i还是原来的)
else:
cur_str = s[start: i + 1]
if len(cur_str) > 1 and cur_str[0] == '0': #不含有前导0
return
if len(cur_str) <= 3 and 0 <= int(cur_str) <= 255:
self.cur.append(cur_str)
backtracking(s, i + 1)
self.cur.pop()
if i == len(s): break # 说明此时是用前三个数推测最后的数的,不用再循环了
backtracking(s, 0)
return self.res

优化:每个数长度最多为3,可以写作 for i in range(start, min(start + 3, len(s))):

8.11 子集

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
self.cur = []
self.res = [[]]
def backtracking(nums: List[int], start: int):
if self.cur:
self.res.append(self.cur.copy()) # 这里不return,因为还要继续添加
for i in range(start, len(nums)):
self.cur.append(nums[i])
backtracking(nums, i + 1)
self.cur.pop()
backtracking(nums, 0)
return self.res

8.12 回溯周末总结

如果是一个集合来求组合的话,就需要startIndex,如组合问题;如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex

在求和问题中,排序之后加剪枝是常见的套路!

if i > startIndex and candidates[i] == candidates[i - 1]:#i>startIndex说明第一个数已经选过了,后面就是判断同一数层不能重复了 树层去重是该题的重点

8.13 子集 II

这里也是使用树层去重,同组合总和II(用used数组用过的)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def subsetsWithDup(self, nums: List[int]) -> List[List[int]]:
self.cur = []
self.res = [[]]
nums.sort()#去重需要排序,让相同的数字相邻
def backtracking(nums: List[int], start: int):
if self.cur:
self.res.append(self.cur.copy()) # 这里不return,因为还要继续添加
for i in range(start, len(nums)):
if i > start:#树层去重
if nums[i] == nums[i - 1]:
continue
self.cur.append(nums[i])
backtracking(nums, i + 1)
self.cur.pop()
backtracking(nums, 0)
return self.res

用used数组的方法:used数组存的是树层的使用情况,并不是树枝的(树枝的进入backtracking就会更新)不过上面i>start更好懂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def subsetsWithDup(self, nums):
result = []
path = []
nums.sort() # 去重需要排序
self.backtracking(nums, 0, path, result)
return result

def backtracking(self, nums, startIndex, path, result):
result.append(path[:]) # 收集子集
uset = set()
for i in range(startIndex, len(nums)):
if nums[i] in uset:
continue
uset.add(nums[i])
path.append(nums[i])
self.backtracking(nums, i + 1, path, result)
path.pop()

8.14 非递减子序列

不能改变数组现有的顺序,nums =[1,2,3,1,1,1,1,1]这个用例总是报错,这道题数组没有排序,所以不能用if i > start and nums[i] == nums[i - 1]:continue去重,要用set记录用过的数字,不然像[1, 1], [1, 1, 1], [1, 1, 1, 1]这样的用例会错过,下面是错误写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution:
def findSubsequences(self, nums: List[int]) -> List[List[int]]:
self.res = []
self.cur = [] # 递增子序列
def backtracking(nums: List[int], start: int):
if len(self.cur) >= 2:
self.res.append(self.cur.copy())
for i in range(start, len(nums)):
if i > start and nums[i] == nums[i - 1]:#不能这样去重,不然后面的1会直接漏掉
continue
if len(self.cur) == 0:
self.cur.append(nums[i])
backtracking(nums, i + 1)
self.cur.pop()
elif nums[i] > self.cur[-1]:
self.cur.append(nums[i])
backtracking(nums, i + 1)
self.cur.pop()
# 特殊情况只允许两个整数相等(这里是我理解错题意了,重复元素不限制只有2个)
elif nums[i] == self.cur[-1]:
if len(self.cur) == 1:
self.cur.append(nums[i])
backtracking(nums, i + 1)
self.cur.pop()
elif self.cur[-1] != self.cur[-2]:
self.cur.append(nums[i])
backtracking(nums, i + 1)
self.cur.pop()

backtracking(nums, 0)
return self.res

另外子函数(嵌套函数)可以访问父函数(外层函数)的变量(我上面的题解都写麻烦了,不用传,而且也不用写self),包括父函数传递进来的参数(如nums)。这是因为Python支持闭包(closure),即嵌套函数可以访问其外层作用域中的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def findSubsequences(self, nums: List[int]) -> List[List[int]]:
self.res = []
self.cur = [] # 当前递增子序列
def backtracking(start: int):
if len(self.cur) >= 2:
self.res.append(self.cur.copy())
used = set() # 记录本层已经使用过的数字
for i in range(start, len(nums)):
if nums[i] in used:
continue # 跳过本层已经使用过的数字
if not self.cur or nums[i] >= self.cur[-1]:
used.add(nums[i]) # set用add而不是append
self.cur.append(nums[i])
backtracking(i + 1)
self.cur.pop()
backtracking(0)
return self.res

8.15 全排列

因为每个元素要使用一次,但不按顺序,所以回溯时是从0开始找没用过的数字,把used定义在外面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
self.cur = []
self.res = []
self.used = [] # 不用set的原因是题目不含重复元素,而且无序pop是随机的
def backtracking(start):
if len(self.cur) == len(nums):
self.res.append(self.cur.copy())
return
for i in range(start, len(nums)):
if nums[i] in self.used:
continue
self.used.append(nums[i])
self.cur.append(nums[i])
backtracking(0)
self.cur.pop()
self.used.pop()
backtracking(0)
return self.res

根据下文的观察self.cur和self.used的操作完全一致,所以self.cur其实就足够表达数字有没有使用过了

因为start也一直从0传,这个也可以省略

比dfs方法要好理解些

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
self.cur = []
self.res = []
def backtracking():
if len(self.cur) == len(nums):
self.res.append(self.cur.copy())
return
for i in range(len(nums)):
if nums[i] in self.cur: # 如果不在self.cur里,数字未使用,可添加
continue
self.cur.append(nums[i])
backtracking()
self.cur.pop()
backtracking()
return self.res

8.16 全排列 II

区别在于要排序+通过相邻节点判断去重

如果要对树层中前一位去重,就用used[i - 1] == false,如果要对树枝前一位去重,用used[i - 1] == true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution:
def permuteUnique(self, nums: List[int]) -> List[List[int]]:
self.cur = []
self.res = []
self.used = [False] * len(nums) # 记录每个位置的数字是否使用过
nums.sort() # 方便去重
def backtracking(start):
if len(self.cur) == len(nums):
self.res.append(self.cur.copy())
return
for i in range(start, len(nums)):
if i> start and nums[i] == nums[i - 1] and not self.used[i - 1]:#这里not的意思是当前nums[i]要做第一个数字,但这种情况前面已经排列过了,所以应该去重
continue
if self.used[i]:
continue
self.used[i] = True
self.cur.append(nums[i])
backtracking(0)
self.cur.pop()
self.used[i] = False
backtracking(0)
return self.res

8.17 周末总结

排列问题的不同:

  • 每层都是从0开始搜索而不是startIndex
  • 需要used数组记录path里都放了哪些元素了

一般说道回溯算法的复杂度,都说是指数级别的时间复杂度

8.18 去重问题的另一种写法

使用set去重的版本相对于used数组的版本效率都要低很多,不仅时间复杂度高了,空间复杂度也高了

8.19 重新安排行程

从JFK开始一笔画,经过所有路径的字典排序(返回字典序最小的组合)

每个航线是一条线,最后的行程必须包含每条线

下面的写法有一个用例过不了,原始回溯法每次都要排序和尝试所有邻接节点,时间复杂度较高(接近 O(n!))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def findItinerary(self, tickets: List[List[str]]) -> List[str]:
ticket_dict = defaultdict(list) # key是出发机场,value是到达机场
for item in tickets:
ticket_dict[item[0]].append(item[1])
res = ['JFK']
def backtracking(cur_from):
if len(res) == len(tickets) + 1:# 结束条件是遍历完所有路径
return True# 第一次到达这里时就是最小的字典序
ticket_dict[cur_from].sort() # 把当前候选的到达机场按照字典序排序
for _ in ticket_dict[cur_from]:
cur_to = ticket_dict[cur_from].pop(0) # 选择可达的最小字典序机场
res.append(cur_to)
if backtracking(cur_to):#这样写的原因是第一次返回True时就找到了答案,无需继续遍历
return True
res.pop() # 不一定选最小的机场就能实现一笔画,所以还是要遍历
ticket_dict[cur_from].append(cur_to)
return False # 这里也要写 不然会超时
backtracking('JFK')
return res

使用Hierholzer算法来寻找欧拉路径(找到一条路径能够遍历所有的边恰好一次)为什么这样能保证找到欧拉路径?

  • DFS 确保所有边被访问:由于每次访问一条边后就会“拆掉”这条边(从邻接表中移除),所以不会重复访问。
  • 后序遍历保证路径连通性:在 DFS 回溯时才记录节点,可以确保路径是连续的(不会遗漏中间节点)。
  • 反转路径得到正确顺序:因为 DFS 是后序存储的,所以最终需要反转才能得到从起点到终点的路径。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def findItinerary(self, tickets: List[List[str]]) -> List[str]:
graph = defaultdict(list) # 创建默认字典,用于存储机场映射关系
for src, dst in tickets: # 构建图
graph[src].append(dst)
for src in graph:
graph[src].sort(reverse=True) # 对到达机场列表进行字母逆序排序

itinerary = []

def dfs(node):
while graph[node]:
next_node = graph[node].pop()
dfs(next_node)
itinerary.append(node)

dfs("JFK")
return itinerary[::-1]

8.20 N 皇后

把n个皇后放置在n*n的棋盘上,不在同一行/同一列/同一斜线,输出所有可能的解法

检查是否不互相攻击:不用同行检查,因为每行只选一个元素;因为后面的行还没选,是从前往后选的,所以检查同一列、45度、135度也只用看前面的行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Solution:
def solveNQueens(self, n: int) -> List[List[str]]:
res = []
chessboard = ['.' * n for _ in range(n)] # n*n
def isValid(row: int, col: int):
#因为row递增的原因,第row行肯定没有,不用检查
#1.检查列
for i in range(row):
if chessboard[i][col] == 'Q':
return False
#2.检查45°是否有(当前位置的左上方)
i, j = row - 1, col - 1
while i >= 0 and j >= 0:
if chessboard[i][j] == 'Q':
return False
i -= 1
j -= 1
#3. 检查135°是否有(当前位置的右上方)
i, j = row - 1, col + 1
while i >= 0 and j < len(chessboard):
if chessboard[i][j] == 'Q':
return False
i -= 1
j += 1
return True

def backtracking(row: int): # 表示当前在第几行放置棋子
if row == n: # 表示把n个皇后都放了
res.append(chessboard.copy())
return
for col in range(n): # 行已经知道是row了,列是col
if isValid(row, col):
chessboard[row] = chessboard[row][:col] + "Q" + chessboard[row][col+1:] # 放在该列
backtracking(row + 1)
chessboard[row] = chessboard[row][:col] + "." + chessboard[row][col+1:] # 撤销

backtracking(0)
return res

8.21 解数独

遍历行和列,遍历1-9,看(i, j)这个位置放k是否合适,如果1-9都不行就说明这个棋盘找不到解决数独问题的解

判断合法:同行、同列是否重复,九宫格里是否重复

  • 题目数据 保证 输入数独仅有一个解,所以只有一种情况会递归成功直到所有空白格已填充
  • 下面的解法通过空间换时间,用row/col/palace让判断合法变得相对简单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution:
def solveSudoku(self, board: List[List[str]]) -> None:
# 直接在board上改,不要创建新的空间,返回其他的内容
nums = {"1", "2", "3", "4", "5", "6", "7", "8", "9"} # 用来获取可用数字
row = [set() for _ in range(9)] # 每行已使用的数字
col = [set() for _ in range(9)] # 每列已使用的数字
palace = [[set() for _ in range(3)] for _ in range(3)] # 每个3x3已使用的数字
blank = [] # 存储所有空白格的位置,(i, j)

# 初始化,按照行、列、宫 分别存入哈希表
for i in range(9):
for j in range(9):
ch = board[i][j]
if ch == ".":
blank.append((i, j)) # 加入空白格
else: #第(i,j)的值是ch
row[i].add(ch)
col[j].add(ch)
palace[i//3][j//3].add(ch) #如(5,3)存入(1,1)的格子

def dfs(n):
if n == len(blank): # 如果所有空白格已填充
return True
i, j = blank[n] # 获取第n个空白格的位置
rst = nums - row[i] - col[j] - palace[i//3][j//3] # 剩余的数字 -是集合的差值运算
if not rst: # 没有可用数字,回溯
return False
for num in rst: # 尝试每个可用数字
board[i][j] = num
row[i].add(num)
col[j].add(num)
palace[i//3][j//3].add(num)
if dfs(n+1): # 递归尝试下一个空白格
return True
row[i].remove(num)
col[j].remove(num)
palace[i//3][j//3].remove(num)

dfs(0)

8.22 回溯总结

如果是一个集合来求组合的话,就需要startIndex;如果是多个集合取组合,各个集合之间相互不影响,那么就不用startIndex

“树层去重”和“树枝去重”

使用set去重的版本相对于used数组的版本效率都要低很多

9. 贪心算法

9.1 贪心算法理论基础

如何能看出局部最优是否能推出整体最优呢?要手动模拟,如果可行就贪心,不可行可能需要动态规划,举不出反例就可以试试贪心

  • 将问题分解为若干个子问题
  • 找出适合的贪心策略
  • 求解每一个子问题的最优解
  • 将局部最优解堆叠成全局最优解

9.2 分发饼干

尽可能多的满足孩子,就要从胃口值小的分起

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def findContentChildren(self, g: List[int], s: List[int]) -> int:
g.sort()
s.sort() # 用小饼干喂小胃口
g_idx = 0
s_idx = 0
res = 0
while g_idx < len(g) and s_idx < len(s):
if g[g_idx] <= s[s_idx]:
res += 1
g_idx += 1
s_idx += 1
else:
s_idx += 1
return res

9.3 摆动序列

可以直接在原始数组上删除一些元素,局部最优:删除单调坡度上的节点(不包含两端)。不需要删除,统计数组的峰值数量即可。

376.摆动序列

但有一些特殊情况,要考虑平坡!所以只在坡度 摆动变化的时候,更新 prediff,不在单调区间有平坡时变化

img
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def wiggleMaxLength(self, nums: List[int]) -> int:
if len(nums) == 1:
return 1
max_len = 1
curDiff = 0
preDiff = 0
for i in range(len(nums) - 1):
curDiff = nums[i + 1] - nums[i]
if (curDiff < 0 and preDiff >= 0) or (curDiff > 0 and preDiff <= 0):
max_len += 1
preDiff = curDiff # 重要,写在这里
return max_len

9.4 最大子数组和

负数一定会拉低总和,只要连续和为正就对后面的元素起到增大总和的作用

1
2
3
4
5
6
7
8
9
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
max_res = -inf
sum = 0
for n in nums:
sum += n
max_res = max(sum, max_res)
sum = max(0, sum)
return max_res

简洁版dp:

1
2
3
4
5
6
7
8
9
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
#dp[i]=max(dp[i-1],0)+nums[i]
ans = -inf
dp = 0
for n in nums:
dp = max(dp, 0) + n
ans = max(ans, dp)
return ans

9.5 贪心周总结

局部最优和全局最优两个关键点。

不能让“连续和”为负数的时候加上下一个元素,而不是 不让“连续和”加上一个负数

9.6 买卖股票的最佳时机 II

只要差值为正就可以加上,贪心:只要正利润

最终利润是可以分解的,可以把利润分解为每天为单位的维度,而不是从 0 天到第 3 天整体去考虑。根据 prices 可以得到每天的利润序列:(prices[i] - prices[i - 1])…..(prices[1] - prices[0])。

1
2
3
4
5
6
class Solution:
def maxProfit(self, prices: List[int]) -> int:
res = 0
for i in range(len(prices) - 1):
res += max(prices[i + 1] - prices[i], 0)
return res

9.7 跳跃游戏

不太懂怎么跳。其实跳几步无所谓,关键在于可跳的覆盖范围!每次移动取最大跳跃步数(得到最大的覆盖范围),每移动一个单位,就更新最大覆盖范围。在移动单位的同时,覆盖的点也都走了,范围也更新了。

1
2
3
4
5
6
7
8
9
10
class Solution:
def canJump(self, nums: List[int]) -> bool:
cover = 0 # 表示当前整体的覆盖范围
i = 0 # 表示当前看的位置
while i <= cover: # 保证i是可达的
cover = max(cover, nums[i] + i)
if cover >= len(nums) - 1: # 到达最后一个下标
return True
i += 1
return False

9.8 跳跃游戏 II

以最小的步数增加最大的覆盖范围,直到覆盖范围覆盖了终点,用修桥理解

45-c.png

难点是维护cur和next,用i表示目前到的位置,用next收集目前可达的位置的最远距离;然后i==cur时说明走不动了,要用next更新,很巧妙

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def jump(self, nums: List[int]) -> int:
cur_distance = 0 # 当前覆盖的最远距离下标
ans = 0 # 记录走的最大步数
next_distance = 0 # 下一步覆盖的最远距离下标

for i in range(len(nums) - 1): # 注意这里是小于len(nums) - 1,结束时说明达到了nums[n-1]
next_distance = max(nums[i] + i, next_distance) # 更新下一步覆盖的最远距离下标
if i == cur_distance: # 遇到当前覆盖的最远距离下标,说明当前可达的点的最远距离已经知道了
cur_distance = next_distance # 因为本题肯定能到达,所以如果到这里,说明next>cur
ans += 1

return ans

注意跳跃游戏I和II都要遍历每个i

9.9 K 次取反后最大化的数组和

选择k个数字,替换成相反数(如果有负值就替换为正值,尽量重复替换,如果没有负值就替换最小的正值)

对于[4, 2, 3, -2, 1, -4, -3, -6, -4],按照绝对值排序后结果是[-6, 4, -4, -4, 3, -3, 2, -2, 1]

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def largestSumAfterKNegations(self, nums: List[int], k: int) -> int:
nums.sort(key=lambda x: abs(x), reverse=True) # 按照绝对值降序排序,而不是直接排序
for i in range(len(nums)):
if k == 0: break
if nums[i] < 0 and k > 0:
nums[i] *= -1
k -= 1
# 如果k还剩次数,那已经把所有负数都变成了正数,此时nums全是正数而且降序排序,所以不可能有还没取反的负值,而是直接把绝对值最小的值取反
if k > 0 and k % 2 == 1:
nums[-1] *= -1
return sum(nums)

9.10 贪心周总结

其实代码都不难,难的是思路,需要反复练习和观察

9.11 加油站

如何获取数组中所有1的下标:list.index(val)只能获取第一个,如果val不在list里还会报错

1
indices = [i for i, x in enumerate(lst) if x == 1]

我的错误做法是找到cost最小的下标,但其实不一定,正确方法如下:i从0开始累加rest[i],和记为curSum,一旦curSum小于零,说明[0, i]区间都不能作为起始位置,因为这个区间选择任何一个位置作为起点,到i这里都会断油,那么起始位置从i+1算起,再从0计算curSum。这是局部最优的方法,但可以推出全局最优。

img
1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
curSum = 0
start = 0
if sum(cost) > sum(gas):
return -1
for i in range(len(gas)):
curSum += gas[i] - cost[i]
if curSum < 0:
start = i + 1 # [0,i]肯定不是,检查后面
curSum = 0
return start

9.12 分发糖果

规则定义: 设学生 A 和学生 B 左右相邻,A 在 B 左边;

  • 左规则: 当 ratings B > ratings A 时,B 的糖比 A 的糖数量多。
  • 右规则: 当 ratings A > ratings B 时,A 的糖比 B 的糖数量多。

先从左到右遍历ratings得到left,满足左规则;再从右到左遍历得到right,满足右规则,最后取left和right的最大值,这样同时满足左右规则(使用两次贪心,从局部最优推出全局最优,思路还是很巧妙的)

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def candy(self, ratings: List[int]) -> int:
left = [1] * len(ratings)
right = left.copy()
for i in range(1, len(ratings)):
if ratings[i] > ratings[i - 1]:
left[i] = left[i - 1] + 1
res = left[-1] # 从右到左时right[-1]肯定为1,所以直接赋值left[-1]
for i in range(len(ratings) - 2, -1, -1):
if ratings[i] > ratings[i + 1]:
right[i] = right[i + 1] + 1
res += max(left[i], right[i])
return res

9.13 柠檬水找零

记录5和10的数量即可,然后用规则找零

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution:
def lemonadeChange(self, bills: List[int]) -> bool:
num_5 = 0
num_10 = 0
for bill in bills:
if bill == 5:
num_5 += 1
elif bill == 10:
if num_5 == 0:
return False
num_5 -= 1
num_10 += 1
elif bill == 20:
if num_10 >= 1 and num_5 >= 1:
num_5 -= 1
num_10 -= 1
elif num_5 >= 3:
num_5 -= 3
else:
return False
return True

9.14 根据身高重建队列

我写的版本:如果按照k来从小到大排序,排完之后,会发现k的排列并不符合条件,身高也不符合条件,两个维度哪一个都没确定下来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
from collections import defaultdict
from typing import List

class Solution:
def reconstructQueue(self, people: List[List[int]]) -> List[List[int]]:
people.sort(key=lambda x: x[1]) # 不适合用dict,因为key为height的话不唯一
heights = [people[i][0] for i in range(len(people))]
heights.sort() # 所有身高从小到大排序
print(heights)
res = []
left_index = list(range(len(people))) # 当前空余的位置
print(left_index)
for height in heights:
# 在people中找当前第一个height对应的排在前面的人
for i in range(len(people)):
if people[i][0] == height and people[i][1] != -1:
k = people[i][1] # 应该放在当前空余的第k位
res.append([people[i][0], left_index[k]]) # 这样只考虑了大于没考虑等于
left_index.remove(left_index[k])
people[i][1] = -1
print(res)

sol = Solution()
sol.reconstructQueue([[7,0],[4,4],[7,1],[5,0],[6,1],[5,2]])

答案按身高高的来排序,这样后续插入的节点不影响

406.根据身高重建队列
1
2
3
4
5
6
7
class Solution:
def reconstructQueue(self, people: List[List[int]]) -> List[List[int]]:
people.sort(key=lambda x: (-x[0], x[1])) # 先按身高从大到小排序,同一身高k小的排前面
res = []
for hi, ki in people:
res.insert(ki, [hi, ki]) # 这样可以指定数组中插入的位置,同一位置会后移
return res

9.15 贪心周总结

9.16 vector原理

避免vector的底层扩容,尽量在原数组修改,但在原数组肯定要细致点

9.17 用最少数量的箭引爆气球

贪心:局部最优:当气球出现重叠,一起射,所用弓箭最少。全局最优:把所有气球射爆所用弓箭最少。

第一个点就放在第一个区间的右端点处,第二个点就放在剩余区间的第一个区间的右端点处。依此类推。

1
2
3
4
5
6
7
8
9
10
class Solution:
def findMinArrowShots(self, points: List[List[int]]) -> int:
points.sort(key=lambda p: p[1]) #按照右端点排序
pre = -inf
ans = 0
for start, end in points:
if start > pre: # 当前的箭不够覆盖了
pre = end
ans += 1
return ans

9.18 无重叠区间

我的写法是移除end大的区间,不断更新end进行比较:

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
intervals.sort(key=lambda p: p[1]) # 这里我本来想先end小,再start大(优先保留结尾小且短的区间),但start不排也能过
ans = 0
pre_end = intervals[0][1]
for start, end in intervals[1:]:
if start < pre_end:
ans += 1 # 删目前这个区间,留end小的,所以不更新pre_end
else:
pre_end = end
return ans

也可以计算不重叠的区间个数,然后减掉,同样是按end排序

1
2
3
4
5
6
7
8
9
10
class Solution:
def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:
intervals.sort(key=lambda x: x[1])
ans = 0
pre_r = -inf
for l, r in intervals:
if l >= pre_r:
ans += 1
pre_r = r
return len(intervals) - ans

9.19 划分字母区间

为了满足同一字母最多出现在一个片段中,选最长的区间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def partitionLabels(self, s: str) -> List[int]:
# 记录每个字母的最大索引
dict = defaultdict(int)
for i, c in enumerate(s):
dict[c] = i
res = []
curr_idx = 0
pre_idx = 0 # 记录字符串开始的下标
for i, c in enumerate(s):
if i > curr_idx:
res.append(curr_idx - pre_idx + 1)
curr_idx += 1
pre_idx = curr_idx
if curr_idx < dict[c]:
curr_idx = dict[c]
res.append(curr_idx - pre_idx + 1) # 跳出循环存最后一个字符串
return res

更简洁的写法:用end==i来判断区间该合并:

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def partitionLabels(self, s: str) -> List[int]:
last = {c: i for i, c in enumerate(s)} # 每个字母最后出现的下标 建dict
ans = []
start = end = 0
for i, c in enumerate(s):
end = max(end, last[c]) # 更新当前区间右端点的最大值
if end == i: # 当前区间合并完毕
ans.append(end - start + 1) # 区间长度加入答案
start = i + 1 # 下一个区间的左端点
return ans

9.20 合并区间

不断比较上一个的end和下一个的start即可

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def merge(self, intervals: List[List[int]]) -> List[List[int]]:
intervals.sort(key=lambda x: (x[0], x[1]))
pre_start, pre_end = intervals[0]
res = []
for start, end in intervals:
if start <= pre_end:
pre_end = max(end, pre_end)
else:
res.append([pre_start, pre_end])
pre_start, pre_end = start, end
res.append([pre_start, pre_end])
return res

9.21 贪心周总结

区间:画图+排序

9.22 单调递增的数字

10. 动态规划

10.1 动态规划理论基础

动态规划中每一个状态一定是由上一个状态推导出来的

  1. 确定dp数组(dp table)以及下标的含义
  2. 确定递推公式
  3. dp数组如何初始化
  4. 确定遍历顺序
  5. 举例推导dp数组

10.2 斐波那契数

1
2
3
4
5
6
7
8
9
class Solution:
def fib(self, n: int) -> int:
if n == 0:
return 0
dp = [0] * (n + 1)
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]

10.3 爬楼梯

1
2
3
4
5
6
7
8
class Solution:
def climbStairs(self, n: int) -> int:
dp = [0] * (n + 1)
dp[0] = 1
dp[1] = 1
for i in range(2, n + 1):
dp[i] = dp[i - 1] + dp[i - 2]
return dp[n]
  • dp[0] 表示“用若干步恰好到达楼顶”的方法数。唯一方法是一步都不走,所以是1。

扩展:每次你可以爬至多m (1 <= m < n)个台阶。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def climbStairs(self, n: int, m: int) -> int:
dp = [0] * (n + 1)
dp[0] = 1
dp[1] = 1
for i in range(2, n + 1):
for j in range(1, m + 1):
dp[i] += dp[i - j]
return dp[n]

n, m = map(int, input().split()) # map(func, iterable)将列表中的每个字符串转换为整数。
sol = Solution()
print(sol.climbStairs(n, m))

10.4 使用最小花费爬楼梯

dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2]) dp表示到达第i个位置支付的最小费用 dp[0] = 0 dp[1]=0 dp[2]=10 dp[3]就是结果

1
2
3
4
5
6
class Solution:
def minCostClimbingStairs(self, cost: List[int]) -> int:
dp = [0] * (len(cost) + 1)
for i in range(2, len(cost) + 1):
dp[i] = min(dp[i-1] + cost[i-1], dp[i-2] + cost[i-2])
return dp[len(cost)]

还可以优化空间复杂度,用dp0和dp1迭代,不用建数组了

10.5 周总结

debug技巧:检查递推公式,打印dp数组手动推理检查

完全背包问题:对比0-1背包,物品只能选 1 次

  • 物品可以无限次使用(类比:每次可以爬 1、2、…、m 阶,不限次数)。
  • 求组合数(类比:爬楼梯的顺序不同,算不同的方法)。

10.6 不同路径

dp[m][n]表示到达(m,n)的路径数,dp[m][n]=dp[m-1][n]+dp[m][n-1].当m=0时,dp[0][n]=1;当n=0时,dp[m][0]=1

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
dp = [[0] * n for _ in range(m)] # 创建m*n的数组,m是行数,n是列数
for i in range(m):
dp[i][0] = 1
for i in range(n):
dp[0][i] = 1
for i in range(1, m):#从1开始,防止数组越界
for j in range(1, n):
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]

我看hot100的题解用的是dfs,这里要注意使用@cache

10.7 不同路径 II

我的错误答案:写了很多判断条件,但还是考虑不周全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m = len(obstacleGrid)
n = len(obstacleGrid[0])
dp = [[0] * n for _ in range(m)] # 创建m*n的数组,m是行数,n是列数
if m == 1:
for i in range(n):
if obstacleGrid[0][i] == 1:
return 0
if n == 1:
for i in range(m):
if obstacleGrid[i][0] == 1:
return 0
if obstacleGrid[0][0] == 1:
return 0
for i in range(m):
if obstacleGrid[i][0] == 1:
dp[i][0] = 0
obstacleGrid[i][0] = 1
else:
dp[i][0] = 1
for i in range(n):
if obstacleGrid[0][i] == 1:
dp[0][i] = 0
else:
dp[0][i] = 1

for i in range(1, m):#从1开始,防止数组越界
for j in range(1, n):
if obstacleGrid[i][j] == 1:
dp[i][j] = 0
elif obstacleGrid[i - 1][j] == 1 and obstacleGrid[i][j - 1] == 1:
dp[i][j] = 0
obstacleGrid[i][j] = 1
elif obstacleGrid[i - 1][j] == 1:
dp[i][j] = dp[i][j - 1]
elif obstacleGrid[i][j - 1] == 1:
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]

后来我决定按规则先遍历obstacleGrid,如果上面和左边同时为1,或者在边界遇见1,则标为1,这样在写dp时就简单了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m = len(obstacleGrid)
n = len(obstacleGrid[0])
dp = [[0] * n for _ in range(m)] # 创建m*n的数组,m是行数,n是列数
if obstacleGrid[0][0] == 1:
return 0
for i in range(1, m):
if obstacleGrid[i - 1][0] == 1:
obstacleGrid[i][0] = 1
for i in range(1, n):
if obstacleGrid[0][i - 1] == 1:
obstacleGrid[0][i] = 1
for i in range(1, m):
for j in range(1, n):
if obstacleGrid[i - 1][j] == 1 and obstacleGrid[i][j - 1] == 1:
obstacleGrid[i][j] == 1
for i in range(m):
if obstacleGrid[i][0] != 1:
dp[i][0] = 1
for i in range(n):
if obstacleGrid[0][i] != 1:
dp[0][i] = 1
for i in range(1, m):#从1开始,防止数组越界
for j in range(1, n):
if obstacleGrid[i][j] != 1:
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]

简洁版:其实不用遍历更新obstacleGrid,因为如果两边dp为0的话,加起来自然为0

  • 关键点是先处理边界时遇到1就停止赋值,剩下的dp均为0,用else break写
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution:
def uniquePathsWithObstacles(self, obstacleGrid: List[List[int]]) -> int:
m = len(obstacleGrid)
n = len(obstacleGrid[0])
dp = [[0] * n for _ in range(m)] # 创建m*n的数组,m是行数,n是列数
if obstacleGrid[0][0] == 1 or obstacleGrid[m - 1][n - 1] == 1:
return 0
for i in range(m):
if obstacleGrid[i][0] != 1:
dp[i][0] = 1
else:
break
for i in range(n):
if obstacleGrid[0][i] != 1:
dp[0][i] = 1
else:
break
for i in range(1, m):#从1开始,防止数组越界
for j in range(1, n):
if obstacleGrid[i][j] != 1:
dp[i][j] = dp[i - 1][j] + dp[i][j - 1]
return dp[m - 1][n - 1]

10.8 整数拆分

拆分成两种(j * (i - j))或者拆分成三种及以上(j * dp[i - j])所以dp[i] = max({dp[i], (i - j) * j, dp[i - j] * j})

  • 关注j的取值
1
2
3
4
5
6
7
8
class Solution:
def integerBreak(self, n: int) -> int:
dp = [0] * (n + 1)
dp[2] = 1
for i in range(3, n + 1):
for j in range(1, i // 2 + 1):#两数之和,只用看前一半就行了,反过来是重复的
dp[i] = max(dp[i], (i - j) * j, dp[i - j] * j)#当前看dp[i],拆出来一个数j,j<=i//2
return dp[n]

10.9 不同的二叉搜索树

我的状态转移方程推错了,dp[3],就是 元素1为头结点搜索树的数量 + 元素2为头结点搜索树的数量 + 元素3为头结点搜索树的数量

  • 元素1为头结点搜索树的数量 = 右子树有2个元素的搜索树数量 * 左子树有0个元素的搜索树数量 dp[2]*dp[0]
  • 元素2为头结点搜索树的数量 = 右子树有1个元素的搜索树数量 * 左子树有1个元素的搜索树数量 dp[1]*dp[1]
  • 元素3为头结点搜索树的数量 = 右子树有0个元素的搜索树数量 * 左子树有2个元素的搜索树数量 dp[0]*dp[2]
1
2
3
4
5
6
7
8
class Solution:
def numTrees(self, n: int) -> int:
dp = [0] * (n + 1) # 创建一个长度为n+1的数组,初始化为0
dp[0] = 1 # 当n为0时,只有一种情况,即空树,所以dp[0] = 1
for i in range(1, n + 1): # 遍历从1到n的每个数字
for j in range(1, i + 1): # 对于每个数字i,计算以i为根节点的二叉搜索树的数量
dp[i] += dp[j - 1] * dp[i - j] # 利用动态规划的思想,累加左子树和右子树的组合数量
return dp[n] # 返回以1到n为节点的二叉搜索树的总数量

10.10 周总结

感觉还是trick居多,状态转移方程难,要多练

10.11 0-1背包理论基础(一)

有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。

暴力的解法是取或者不取,回溯搜索

dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。

dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]); 不取该物品,取该物品

初始化关注:i=0,j=0的特殊情况

1
2
3
4
5
6
7
8
9
10
11
12
13
14
m, n = map(int, input().split())
weight = list(map(int, input().split()))
value = list(map(int, input().split()))
dp = [[0] * (n + 1) for _ in range(m)] # dp[i][j]表示前i个数填满j的背包的最大价值 因为要访问到大小为n,所以要创建n+1

for j in range(weight[0], n + 1): # 可以选0物品时
dp[0][j] = value[0]
for i in range(1, m): # i = 0时就是0,跟初始化一样,没必要遍历
for j in range(1, n + 1):
if weight[i] > j: #不可能选
dp[i][j] = dp[i - 1][j]
else:
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i])
print(dp[m - 1][n])

这里的背包和物品遍历顺序可以交换,以及物品一定要从小到大,但容量j无所谓,可以从小到大或者从大到小,因为不管顺序如何dp[i-1][j]dp[i-1][j - weight[i]] 都已在上一轮计算完成。

10.12 动态规划:01背包理论基础(滚动数组)

滚动数组:把二维dp降为一维dp,需要满足的条件是上一层可以重复利用,直接拷贝到当前层。

1
dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

注意和二维不同的是:

  • 遍历背包要倒序!不然物品会被重复加入!
  • 只能先遍历物品再遍历背包,不然每个dp[j]只会被最后一个物品更新

总结:先物品再背包,背包倒序

1
2
3
4
5
6
7
8
9
10
m, n = map(int, input().split())
weight = list(map(int, input().split()))
value = list(map(int, input().split()))
dp = [0] * (n + 1)

for i in range(m): # i = 0时就是0,跟初始化一样,没必要遍历
for j in range(n, -1, -1):
if weight[i] <= j: # 防止不能选的情况导致的越界
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
print(dp[n])

10.13 分割等和子集

就是01背包,只是背包值是数组总和的一半

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def canPartition(self, nums: List[int]) -> bool:
# dp[i][j] 表示前i个数(不一定全选)可以凑到总和为j 01背包问题
# dp[i][j] = dp[i - 1][j] || dp[i - 1][j - nums[i]]
sum_ = sum(nums)
len_ = len(nums)
if sum_ % 2 == 1:
return False
total = sum_ // 2
dp = [False] * (total + 1)
dp[0] = True
for i in range(len_):
for j in range(total, -1, -1):
if nums[i] <= j: # 注意这里要判断,不然越界了会出错
dp[j] = dp[j] or dp[j - nums[i]]
return dp[total]

10.14 最后一块石头的重量 II

我的想法是先排序,然后两两做减法

但这道题其实是01背包的思路,尽量让石头分成重量相同的两堆(尽可能相同),相撞之后剩下的石头就是最小的。

此时的问题:有一堆石头,每个石头都有自己的重量,是否可以 装满 最大重量为 sum / 2的背包。

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def lastStoneWeightII(self, stones: List[int]) -> int:
# dp[j] = max(dp[j], dp[j - stones[i]] + stones[i]) 大小为j的背包最多装的石头大小
len_ = len(stones)
sum_ = sum(stones)
target = sum_ // 2 # 注意这里不用+1,不然有错
dp = [0] * (target + 1)
for stone in stones:
for j in range(target, -1, -1):
if stone <= j:
dp[j] = max(dp[j], dp[j - stone] + stone)
return abs(sum_ - 2 * dp[target])

本题其实和416. 分割等和子集 (opens new window)几乎是一样的,只是最后对dp[target]的处理方式不同。

416. 分割等和子集 (opens new window)相当于是求背包是否正好装满,而本题是求背包最多能装多少

10.15 周总结

416.分割等和子集1

10.16 目标和

返回nums可以得到target的表达式数目

dp[i][j]表示前i个数可以得到j的数目,我本来想的是dp[i][j] = dp[i - 1][j - nums[i]] + dp[i - 1][j + nums[i]],但是效率比较低

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def findTargetSumWays(self, nums: List[int], target: int) -> int:
total = sum(nums)
if abs(target) > total:
return 0
n = len(nums)
dp = [[0] * (2 * total + 1) for _ in range(n + 1)] # dp[i][j] 表示前 i 个数字,通过加减运算得到 j - total 的方法数
dp[0][total] = 1 # dp[0][0 + total] = 1
for i in range(1, n + 1): # 当前访问的是nums[i-1]
for j in range(2 * total + 1):
if j - nums[i-1] >= 0:
dp[i][j] += dp[i-1][j - nums[i-1]]
if j + nums[i-1] <= 2 * total:
dp[i][j] += dp[i-1][j + nums[i-1]]
return dp[n][target + total]

另一种方法:因为left-right=target,left+right=sum,所以left=(sum+target)//2,求总和为这个的表达式的数目

注意在这个场景下,left,right>=0,所以要剪枝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def findTargetSumWays(self, nums: List[int], target: int) -> int:
# dp[j] += dp[j - num]
total = sum(nums)
if (total + target) % 2 != 0 or (total + target) < 0:
return 0
left = (total + target) // 2
dp = [0] * (left + 1)
dp[0] = 1
for num in nums:
for j in range(left, -1, -1):
if num <= j:
dp[j] += dp[j - num]
return dp[left]

10.17 一和零

统计strs中元素的0/1个数

m和n代表两个背包,dp[i][j]:最多有i个0和j个1的strs的最大子集的大小为dp[i][j]。创建时还是以m+1,n+1为基础

dp[i][j] = max(dp[i][j], dp[i - zeroNum][j - oneNum] + 1) zero/one相当于物品的重量,字符串个数1相当于物品的价值

外层是物品,正序遍历;内层是背包,倒序遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def findMaxForm(self, strs: List[str], m: int, n: int) -> int:
# dp[i][j] = max(dp[i][j], dp[i-zeroNum][j-oneNum] + 1)
dp = [[0] * (n + 1) for _ in range(m + 1)] # 还是创建m+1和n+1
for str in strs: # 正序遍历物品
zeroNum, oneNum = 0, 0
for c in str:#这里有简略写法:ones = s.count('1') # 统计字符串中1的个数
if c == '0':
zeroNum += 1
else:
oneNum += 1
for i in range(m, -1, -1):
for j in range(n, -1, -1):
if i >= zeroNum and j >= oneNum:
dp[i][j] = max(dp[i][j], dp[i-zeroNum][j-oneNum] + 1)
return dp[m][n]

10.18 完全背包理论基础

每件物品有无数个,dp[i][j] 表示从下标为[0-i]的物品,每个物品可以取无限次,放进容量为j的背包,价值总和最大是多少。

dp[i][j]=max(dp[i][j-weight[i]]+value[i], dp[i-1][j]) 依然是分为不取该物品,取该物品

如果背包容量为0,那总和一定为0

遍历顺序:先遍历物品,或者先遍历背包都可以

往往还是m + 1, n + 1比较好写,不用处理边界条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def totalbeibao(n, v, value, weight):
dp = [[0] * (v + 1) for _ in range(n + 1)]
for i in range(1, n + 1): # 遍历物品
for j in range(1, v + 1): # 遍历背包
if j >= weight[i - 1]:
dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i - 1]] + value[i - 1])
else:
dp[i][j] = dp[i - 1][j] # 要写这里!第一次没写,说明选不了i物品
return dp[n][v]


n, v = map(int, input().split())
weight = []
value = []
for _ in range(n):
wi, vi = map(int, input().split())
weight.append(wi)
value.append(vi)
print(totalbeibao(n, v, value, weight))

10.19 零钱兑换 II

dp[i][j]表示用前i个数组成金额为j的组合数 dp[i][j]=max(dp[i-1][j],dp[i][j-coins[i]] + 1)

这里我的递推公式列错了!应该是选当前硬币i或者不选,而且需要注意dp[i][0]=1,这样j-coin才能表示正好选到

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def change(self, amount: int, coins: List[int]) -> int:
m = len(coins)
dp = [[0] * (amount + 1) for _ in range(m + 1)]
dp[0][0] = 1 # 什么都不选,注意这里的初始化重要
for i in range(1, m + 1):
for j in range(amount + 1): # 其实dp[i][0]=1,所以这里要从0开始,不然会报错
if j >= coins[i - 1]:
dp[i][j] = dp[i - 1][j] + dp[i][j - coins[i - 1]]
else:
dp[i][j] = dp[i - 1][j]
return dp[m][amount]

压缩成一维dp:dp[j] += dp[j - coins[i]]

装满背包容量为0 的方法是1,即不放任何物品,dp[0] = 1

1
2
3
4
5
6
7
8
9
class Solution:
def change(self, amount: int, coins: List[int]) -> int:
dp = [0] * (amount + 1)
dp[0] = 1 # 什么都不选,注意这里的初始化重要
for coin in coins:
for j in range(coin, amount + 1): # 注意完全背包这里是正序,倒序是01!
if j >= coin:
dp[j] += dp[j - coin]
return dp[amount]

组合与排列的区别:

  • 组合:硬币的顺序不重要,即 2+2+1 和 2+1+2 是同一种组合。
  • 排列:顺序重要,视为不同的方式。
  • 为了计算组合数,需要将硬币的循环放在外层,金额的循环放在内层。
  • 完全背包: 每种物品可以选无限次,因此内层循环是正序的,这样在计算 dp[j] 时,dp[j - coin] 已经考虑了当前硬币的多次使用。
  • 0-1背包: 每种物品只能选一次,因此内层循环是倒序的,这样在计算 dp[j] 时,dp[j - coin] 还没有被当前硬币更新,确保只使用一次。

如果求组合数就是外层for循环遍历物品,内层for遍历背包

如果求排列数就是外层for遍历背包,内层for循环遍历物品

10.20 周总结

10.21 组合总和 Ⅳ

dp[i][j]表示用0-i数表示j的个数,dp[i][j]=max(dp[i-1][j],dp[i][j-num])

dp[j] += dp[j-num] 顺序不同,说明是排序,要外层是背包内层是数字

1
2
3
4
5
6
7
8
9
class Solution:
def combinationSum4(self, nums: List[int], target: int) -> int:
dp = [0] * (target + 1)
dp[0] = 1 # 什么都不选,注意这里的初始化重要
for j in range(target + 1): # 其实dp[i][0]=1,所以这里要从0开始,不然会报错
for num in nums:
if j >= num:
dp[j] += dp[j - num]
return dp[target]

10.22 爬楼梯

之前斐波拉契的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def climbStairs(self, n: int, m: int) -> int:
dp = [0] * (n + 1)
dp[0] = 1
dp[1] = 1
for i in range(2, n + 1):
for j in range(1, m + 1):
dp[i] += dp[i - j]
return dp[n]

n, m = map(int, input().split()) # map(func, iterable)将列表中的每个字符串转换为整数。
sol = Solution()
print(sol.climbStairs(n, m))

dp[i][j]表示可以爬0-i级台阶,不限次数,到达第j级的方法次数,属于排列(这里我最开始弄错了,12/21不同)

dp[i][j]= for stair in 0-i级台阶,dp[i][j-stair]的总和,外层是背包,内层是物品0-i

1
2
3
4
5
6
7
8
9
10
11
def climbStairs(m, n):
dp = [0] * (n + 1)
dp[0] = 1
for j in range(1, n + 1): # 完全背包问题
for i in range(1, m + 1): # 这里从0开始,从1开始没区别,因为刚到dp[j]时是0
if j >= i:
dp[j] += dp[j - i]
return dp[n]

n, m = map(int, input().split())
print(climbStairs(m, n))

10.23 零钱兑换

dp[i][j]=min(dp[i-1][j],dp[i][j-num]+1) # 表示用0-i的硬币组成j需要的最少的硬币个数 本题是要求最少硬币数量,硬币是组合数还是排列数都无所谓!所以两个for循环先后顺序怎样都可以!

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
m = len(coins)
dp = [[float("+inf")]*(amount + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = 0
for i in range(1, m + 1):
for j in range(amount + 1):
if j >= coins[i - 1]:
dp[i][j] = min(dp[i-1][j], dp[i][j - coins[i - 1]] + 1)
else:
dp[i][j] = dp[i - 1][j]
return dp[m][amount] if dp[m][amount] != float("+inf") else -1

这里也是可以优化成一维dp的,因为每次更新 dp[j] 用到的 dp[j - coins[i]]上一轮尚未被更新的旧值。dp[j] = min(dp[j - coins[i]] + 1, dp[j]);

问题类型 可否优化为一维 遍历顺序要求 原因说明
完全背包 ✅ 可以 j 从小到大 可以重复选,用旧值不会出错
0-1 背包 ✅ 可以 j 从大到小 不能重复选,防止用到当前轮新值
多重背包 ✅ 可转为 0-1 类似于 0-1 背包 分解成多个 0-1 背包处理
依赖两维状态 ❌ 一般不能 无法一维表示 dp[i][j] = f(dp[i-1][j-1])
子序列类问题 一般可优化 视转移方向决定顺序 如 LCS、LIS 等,可压缩一维
1
2
3
4
5
6
7
8
9
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [float("+inf")]*(amount + 1)
dp[0] = 0
for coin in coins:
for j in range(1, amount + 1):
if j >= coin:
dp[j] = min(dp[j], dp[j - coin] + 1)
return dp[amount] if dp[amount] != float("+inf") else -1

10.24 完全平方数

物品是每个完全平方数,149…背包是n 完全平方数的最大值是根号n

1
2
3
4
5
6
7
8
9
class Solution:
def numSquares(self, n: int) -> int:
dp = [float("+inf")]*(n + 1)
dp[0] = 0
for i in range(1, int(math.sqrt(n)) + 1):
for j in range(1, n + 1):
if j >= i ** 2:
dp[j] = min(dp[j], dp[j - i ** 2] + 1)
return dp[n]

这里的遍历顺序依然不重要,可以先物品或者先背包 sqrt也可以用n**0.5代替

10.25 周总结

求最小数不同于求排列数和求组合数,遍历顺序不重要

10.26 单词拆分

单词是物品,可以重复使用,字符串是背包。强调顺序,所以是排列,外层是背包,内层是物品

dp[i][j]表示用0-i个单词可以组成字符串的前j位 dp[i][j]=dp[i-1][j] or dp[i][j-len(word)] 且str[j-len(word):j]==word

我的思路太复杂了,用一维数组,dp[i]表示s[0:i]的内容是否可以用字典拼出

方程:dp[i] = True,如果存在 j < i,使得 dp[j] == True 且 s[j:i] 在 wordDict 中

1
2
3
4
5
6
7
8
9
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
dp = [False] * (len(s) + 1) # dp[i]表示s[0:i]能否被worddict表示
dp[0] = True
for i in range(1, len(s) + 1): # 排列,顺序重要,外层是背包
for word in wordDict: # 可以重复使用,顺序遍历
if i >= len(word) and dp[i - len(word)] and s[i - len(word): i] in wordDict:
dp[i] = True
return dp[len(s)]

更为简洁的写法:就是内层遍历物品时遍历左下标就行,然后看截取的内容是否出现在wordset

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
word_set = set(wordDict) # 查询更快
n = len(s)
dp = [False] * (n + 1)
dp[0] = True # 空字符串可以拼出

for i in range(1, n + 1):
for j in range(i):
if dp[j] and s[j:i] in word_set:
dp[i] = True
break # 提前剪枝

return dp[n]

10.27 多重背包理论基础

k[i]个可用,约束了可用的数量上限,和01背包很像,把Mi件摊开,每个物品只能用一次

组合:外层物品,内层背包(01是倒序),dp[c]表示在容量为c的前提下,能获得的最大总价值

0-1的公式dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);多重改写下k即可

1
2
3
4
5
6
7
8
9
10
11
12
C, N = map(int, input().split()) # 容量和矿石种类
weight = list(map(int, input().split()))
values = list(map(int, input().split()))
counts = list(map(int, input().split()))
dp = [0] * (C + 1) # dp[i]表示容量为i的背包的最大总价值
for i in range(N): # 无所谓顺序,所以用组合
for j in range(C, weight[i] - 1, -1): # 当背包容量<weight[i]时,不可能选i,所以可以剪枝
for k in range(1, counts[i] + 1): # 因为有counts[i]个可用,所以遍历选k个矿石i的可能性
if k * weight[i] > j: # 如果选k个背包装不下,就结束本轮k
break
dp[j] = max(dp[j], dp[j - weight[i] * k] + values[i] * k)
print(dp[C])

上面这样写会超时,因为使用了三重循环,O(N * C * k)

二进制优化:O(N × C × log(k)) 利用的是任何数字可以被表示成二进制数的和,把1/2/4丢进包里,就相当于选了7个,只要不超过k就是对的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
C, N = map(int, input().split()) 
weights = list(map(int, input().split()))
values = list(map(int, input().split()))
counts = list(map(int, input().split()))

dp = [0] * (C + 1)

for i in range(N):
w, v, k = weights[i], values[i], counts[i]
num = 1
while k > 0:
take = min(num, k)
k -= take
weight = take * w
value = take * v
for j in range(C, weight - 1, -1):
dp[j] = max(dp[j], dp[j - weight] + value)
num <<= 1

print(dp[C])

10.28 背包问题总结**

416.分割等和子集1

问能否能装满背包(或者最多装多少):dp[j] = max(dp[j], dp[j - nums[i]] + nums[i]); nums是物品的重量

问装满背包有几种方法:dp[j] += dp[j - nums[i]] dp[0]=1

  • dp[i][j]=max(dp[i-1][j],dp[i][j-coins[i]] + 1) 就是选和不选的简略版

问背包装满最大价值:dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

问装满背包所有物品的最小个数:dp[j] = min(dp[j - coins[i]] + 1, dp[j]); 不同于组合/排列,顺序不重要

01背包:

  • 二维:先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。
  • 一维:只能先遍历物品再遍历背包容量,且第二层for循环是从大到小遍历。因为从小到大的话同一轮的同一物品会被反复使用

完全背包:

纯完全背包的一维:先遍历物品还是先遍历背包都是可以的,且第二层for循环是从小到大遍历。

如果求组合数就是外层for循环遍历物品,内层for遍历背包。组合不看顺序,先遍历物品的话,肯定是1,1,2,不可能出现1,2,1的顺序,顺序是从小到大固定的,先考虑所有用 1 的情况,再在这些基础上加入 2,再加入 3

如果求排列数就是外层for遍历背包,内层for循环遍历物品。区分顺序,所以先固定容量,再调顺序,这样会出现1,2,1的情况

10.29 打家劫舍

不能抢劫相邻房屋,问能偷窃到的最大数量

dp[i] 表示0-i-1间能偷窃到的最大数量,dp[i]=max(dp[i-1], dp[i -2] + nums[i])

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def rob(self, nums: List[int]) -> int:
n = len(nums)
if n == 1:
return nums[0]
dp = [0] * (n + 1)
dp[1] = nums[0]
dp[2] = max(nums[0], nums[1])
for i in range(3, n + 1):
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i - 1])
return dp[n]

10.30 打家劫舍 II

房屋围成一个圈,环状排列意味着第一个房子和最后一个房子中 只能选择一个偷窃

把环拆成两个队列,一个是从0到n-1(不偷窃最后一个房子),另一个是从1到n(不偷窃第一个房子),然后返回两个结果最大的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def rob(self, nums: List[int]) -> int:
def robRange(start, end):#表示[start, end]可偷窃的最大金额
if end == start:
return nums[start]
prev = nums[start] # 表示dp[i - 2]
curr = max(nums[start], nums[start + 1]) # 表示dp[i - 1]
for i in range(start + 2, end + 1):
tmp = curr
curr = max(curr, prev + nums[i])
prev = tmp
return curr
n = len(nums)
if n == 1:
return nums[0]
return max(robRange(0, n - 2), robRange(1, n - 1))

比较好理解的方法:第一个房间偷或者不偷。偷的话,只能从第一间累积到第n-1间房子;不偷的话,从第二间累积到最后一间。比较两种情况大小即可。

10.31 打家劫舍 III

如果抢了当前节点,两个孩子就不能动,如果没抢当前节点,就可以考虑抢左右孩子

暴力递归:分为偷父节点和不偷,过程中有重复计算,会超时

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def rob(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
# 偷该节点:
val1 = root.val
if root.left:
val1 += self.rob(root.left.left) + self.rob(root.left.right)
if root.right:
val1 += self.rob(root.right.left) + self.rob(root.right.right)
# 不偷该节点
val2 = self.rob(root.left) + self.rob(root.right)
return max(val1, val2)

记忆化递推:用map把结果保存一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
memory = {}
def rob(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
if self.memory.get(root) is not None:
return self.memory[root]
# 偷该节点:
val1 = root.val
if root.left:
val1 += self.rob(root.left.left) + self.rob(root.left.right)
if root.right:
val1 += self.rob(root.right.left) + self.rob(root.right.right)
# 不偷该节点
val2 = self.rob(root.left) + self.rob(root.right)
self.memory[root] = max(val1, val2)
return self.memory[root]

在上面两种方法,其实对一个节点 偷与不偷得到的最大金钱都没有做记录,而是需要实时计算。

而动态规划其实就是使用状态转移容器来记录状态的变化,这里可以使用一个长度为2的数组,记录当前节点偷与不偷所得到的的最大金钱。

树形DP:

  1. 确定参数和返回值,每个节点分为偷与不偷,返回值是长度为2的数组,令下标为0记录不偷该节点所得到的的最大金钱,下标为1记录偷该节点所得到的的最大金钱。
  2. 终止条件:遇到空节点是0
  3. 遍历顺序:后序遍历,因为需要子节点的值
  4. 递归逻辑:偷当前节点,那左右孩子就不能偷,不偷当前节点,那么左右孩子就可以偷,至于到底偷不偷一定是选一个最大的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def rob(self, root: Optional[TreeNode]) -> int:
def traversal(node): # 0代表不选,1代表选
if not node:
return (0, 0)
left = traversal(node.left)
right = traversal(node.right)
# 不偷该节点,子节点也不一定要偷,选大的
val0 = max(left[0], left[1]) + max(right[0], right[1])
# 偷该节点,子节点选不了
val1 = node.val + left[0] + right[0] # 注意是node.val不是root.val,别写错了
return (val0, val1)
dp = traversal(root)
return max(dp) # 可以直接选二元组的最大值

10.32 买卖股票的最佳时机

循环过程用当前节点值与之前记录的股票最小价格作比较,若小于则更新当前值为最小股票价格,若大于则用当前值减去已记录的最小股票价格,结果与记录的最大利润作比较,若大于则更新为最大利润

这是贪心的写法

1
2
3
4
5
6
7
8
9
10
class Solution:
def maxProfit(self, prices: List[int]) -> int:
res = 0
curr_min = prices[0]
for i in range(1, len(prices)):
if prices[i] < curr_min:
curr_min = prices[i]
else:
res = max(res, prices[i] - curr_min)
return res

上面的思路也可以用dp解释,可解释性更强,dp[i] 表示 从 第0 到 第i 天,必须进行一轮买卖操作,能获得的最大收益。

  • 选择在 前 i-1 天完成一轮买卖操作,则 第 i 天不能做任何操作,问题转化为 dp[i-1]
  • 选择在 前 i-1 天做买入操作,在第 i 天做卖出操作,即从 第0 到 第i 天,进行一轮买卖操作。在这种情况下,卖出操作的价格一定是 prices[i],为了使收益最大,买入操作的价格应当是 前i-1天中价格的最小值,low = min(prices[0], prices[1], ..., prices[i-1]) 。收益为 prices[i] - low

dp[i][0]代表第i天过后手上有股票时的最大收益,dp[i][1]代表第i天过后手上无股票时的最大收益

1
2
3
4
5
6
7
8
9
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
dp = [[0, 0] for _ in range(n)]
dp[0] = [-prices[0], 0]
for i in range(1, n):
dp[i][0] = max(dp[i-1][0], - prices[i])
dp[i][1] = max(dp[i-1][1], + prices[i] + dp[i-1][0])
return dp[-1][-1]

10.33 周总结

  • dp[i][0] 表示第i天持有股票所得现金。
  • dp[i][1] 表示第i天不持有股票所得最多现金

10.34 买卖股票的最佳时机 II

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def maxProfit(self, prices: List[int]) -> int:
# 贪心,只要有赚就卖
len_ = len(prices)
i = 1
curr_price = prices[0]
max_profit = 0
while i < len_:
if curr_price < prices[i]:
max_profit += prices[i] - curr_price
curr_price = prices[i]
i += 1
return max_profit
  • 持有股票(dp[i][0]:表示在第 i 天结束时,手中持有一支股票。
    • 这个状态可以由两种方式达到:
      • 在第 i-1 天已经持有股票,第 i 天不进行任何操作(即 dp[i-1][0])。
      • 在第 i-1 天不持有股票,第 i 天买入股票(即 dp[i-1][1] - prices[i])。
    • 因此,dp[i][0] = max(dp[i-1][0], dp[i-1][1] - prices[i])
  • 不持有股票(dp[i][1]:表示在第 i 天结束时,手中不持有股票。
    • 这个状态也可以由两种方式达到:
      • 在第 i-1 天已经不持有股票,第 i 天不进行任何操作(即 dp[i-1][1])。
      • 在第 i-1 天持有股票,第 i 天卖出股票(即 dp[i-1][0] + prices[i])。
    • 因此,dp[i][1] = max(dp[i-1][1], dp[i-1][0] + prices[i])
1
2
3
4
5
6
7
8
9
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
dp = [[0,0] for _ in range(n)]
dp[0] = [-prices[0], 0]
for i in range(1, n):
dp[i][0] = max(dp[i-1][0], - prices[i] + dp[i-1][1])
dp[i][1] = max(dp[i-1][1], + prices[i] + dp[i-1][0])
return dp[-1][-1]

也可以节约空间,只开两个数组:存以前和当前状态

1
2
3
4
5
6
7
8
9
10
class Solution:
def maxProfit(self, prices: List[int]) -> int:
length = len(prices)
dp = [[0] * 2 for _ in range(2)] #注意这里只开辟了一个2 * 2大小的二维数组
dp[0][0] = -prices[0]
dp[0][1] = 0
for i in range(1, length):
dp[i % 2][0] = max(dp[(i-1) % 2][0], dp[(i-1) % 2][1] - prices[i])
dp[i % 2][1] = max(dp[(i-1) % 2][1], dp[(i-1) % 2][0] + prices[i])
return dp[(length-1) % 2][1]

10.35 买卖股票的最佳时机 III

最多进行两笔交易,可以只进行一笔或不进行

dp[i][0]代表第i天过后第一次买股票时的最大收益,dp[i][1]代表第i天过后第一次卖股票时的最大收益,dp[i][2]代表第i天过后第二次买股票时的最大收益,dp[i][3]代表第i天过后第二次卖股票时的最大收益

注意并不是要在第i天买,0-i天都能买

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
dp = [[0,0,0,0] for _ in range(n)]
dp[0] = [-prices[0], 0, -prices[0], 0] # 在同一天反复买卖都是0
for i in range(1, n):
dp[i][0] = max(dp[i - 1][0], -prices[i])
# 只可能是负的
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] + prices[i]) # 第一次卖的只能是第一次买的
dp[i][2] = max(dp[i - 1][2], dp[i - 1][1] - prices[i]) # 第二次买需要把第一次卖了
dp[i][3] = max(dp[i - 1][3], dp[i - 1][2] + prices[i])
return dp[-1][-1]

10.36 买卖股票的最佳时机 IV

最多可以买k次,卖k次,比如上一道题k=2

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def maxProfit(self, k: int, prices: List[int]) -> int:
n = len(prices)
dp = [[0, 0] * k for _ in range(n)]
dp[0] = [-prices[0], 0] * k
for i in range(1, n):
for j in range(k): # 最多买卖k次
# 如果是2*j,就是买入,如果是2*j+1,就是卖出
dp[i][j * 2] = max(dp[i - 1][j * 2], -prices[i] + (dp[i - 1][j * 2 - 1] if j != 0 else 0))
dp[i][j * 2 + 1] = max(dp[i - 1][j * 2 + 1], prices[i] + dp[i - 1][j * 2])
return dp[-1][-1]

10.37 买卖股票的最佳时机含冷冻期

有一天冷冻期,注意卖没有冷冻期,买才有冷冻期!第一次写时把dp[i][1]的也写成i-2了

1
2
3
4
5
6
7
8
9
class Solution:
def maxProfit(self, prices: List[int]) -> int:
n = len(prices)
dp = [[0, 0] for _ in range(n)] #0表示第i天后持有,1表示第i天后无持有
dp[0] = [-prices[0], 0]
for i in range(1, n):
dp[i][0] = max(dp[i - 1][0], -prices[i] + (dp[i - 2][1] if i > 1 else 0)) #卖了前面的,买今天的
dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0]) # 今天卖掉,卖没有冷冻期,买才有冷冻期!
return dp[-1][-1]

10.38 周总结

https://leetcode.cn/problems/best-time-to-buy-and-sell-stock-iii/solutions/2199035/yi-tao-mo-ban-ji-xing-dai-ma-bi-zhao-yan-0ap8/ 看这个题解,最好懂,买是花钱要-prices,卖是赚钱+

10.39 买卖股票的最佳时机含手续费

注意买入持有并卖出只用付一次手续费,不用重复,统一在卖出时计算即可

1
2
3
4
5
6
7
8
9
class Solution:
def maxProfit(self, prices: List[int], fee: int) -> int:
n = len(prices)
dp = [[0, 0] for _ in range(n)] #0表示第i天后持有,1表示第i天后无持有
dp[0] = [-prices[0], 0]
for i in range(1, n):
dp[i][0] = max(dp[i - 1][0], -prices[i] + (dp[i - 1][1] if i > 0 else 0)) # 购买
dp[i][1] = max(dp[i - 1][1], prices[i] + dp[i - 1][0] - fee)
return dp[-1][-1]

10.40 股票总结

一共六道题

记住除了0以外,偶数是卖出,奇数是买入,为i天构建二维数组

10.41 最长递增子序列

试了一下dp[n]表示0-n的最长递增子序列的长度,但这样不知道现有的子序列的最大值,而且可能有同一长度多种选择,不知道该怎么写

解决方案是定义dp[i]表示i之前包括i的以nums[i]结尾的最长递增子序列的长度,这里以nums[i]结尾很重要。有了这个定义我就会写了dp[i] = max(dp[i], dp[j] + 1);,即对比之前的dp值,如果当前nums大就得到了新的递增子序列。注意最大值不一定是dp[n-1]!

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
n = len(nums)
dp = [1] * n
ans = 1
for i in range(1, n):
for j in range(0, i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j] + 1)
ans = max(ans, dp[i])
return ans # 最大值不一定是dp[n-1]取到,因为限制了结束的数值

10.42 最长连续递增序列

不断更新左右区间,因为是连续所以简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def findLengthOfLCIS(self, nums: List[int]) -> int:
n = len(nums)
if n == 1:
return 1
ans = 1
l, r = 0, 1 # 这里保证[l,r]中的子序列都是递增的,不断更新区间
while r < n:
if nums[r] <= nums[r - 1]:
ans = max(ans, r - l)
l = r
r = l + 1
else:
r += 1
# 可能一直递增,跳出循环了也没更新,所以这里要更新下
ans = max(ans, r - l)
return ans

如果用dp的定义,仍然是以i结尾的连续递增的子序列长度为dp[i],即dp[i] = dp[i - 1] + 1;

1
2
3
4
5
6
7
8
9
10
class Solution:
def findLengthOfLCIS(self, nums: List[int]) -> int:
n = len(nums)
dp = [1] * n
ans = 1
for i in range(1, n):
if nums[i] > nums[i - 1]:
dp[i] = dp[i - 1] + 1
ans = max(ans, dp[i])
return ans

10.43 最长重复子数组

就是有一小段既要数字相同,也要顺序相同

dp[i][j]中i和j表示结尾都比较奇怪

dp[i][j] 表示以 nums1[i-1]nums2[j-1] 结尾的最长公共子数组的长度(是连续的,因为是连续,所以可以-1,而不是要把前面的0-i全部遍历一遍)

如果 nums1[i-1] == nums2[j-1],那么这两个元素可以接在前一对公共子数组之后: dp[i][j] = dp[i-1][j-1] + 1

否则,这两个元素不相等,无法继续相同子数组:dp[i][j] = 0

注意dp中一般把0空出来,也就是定义dp[n + 1],这样不用处理特殊的0,也不容易出现负下表、

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def findLength(self, nums1: List[int], nums2: List[int]) -> int:
m, n = len(nums1), len(nums2)
dp = [[0] * (n + 1) for _ in range(m + 1)] # 定义dp[m + 1][n + 1],表示以 `nums1[i-1]` 和 `nums2[j-1]` 结尾的最长公共子数组的长度
ans = 0
for i in range(1, m + 1):
for j in range(1, n + 1):
if nums1[i - 1] == nums2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
ans = max(ans, dp[i][j])
return ans

这里最难的是状态转移方程,要理解最长公共子数组的表现是连续的,以及和下标的关系

10.44 最长公共子序列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m, n = len(text1), len(text2) # m=5,n=3
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i][j - 1], dp[i - 1][j])
return dp[m][n]class Solution:
def longestCommonSubsequence(self, text1: str, text2: str) -> int:
m, n = len(text1), len(text2) # m=5,n=3
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if text1[i - 1] == text2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i][j - 1], dp[i - 1][j])
return dp[m][n]

dp[i][j]是text1[i-1]和text2[j-1],列举相等和不相等的情况,用归纳法很合适

10.45 不相交的线

其实就是最大公共子数组的长度,不一定连续,但要按顺序

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def maxUncrossedLines(self, nums1: List[int], nums2: List[int]) -> int:
m, n = len(nums1), len(nums2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if nums1[i - 1] == nums2[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
return dp[m][n]

10.46 最大子数组和

dp[i]表示以nums[i-1]结尾的,一定要选

遇到全是负值的,该如何处理?下面是一种写法,但用了max库函数,增加了O(n),并不好

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
m = len(nums)
if max(nums) < 0:
return max(nums)
dp = [0] * (m + 1)
ans = 0
for i in range(1, m + 1):
dp[i] = max(0, dp[i - 1] + nums[i - 1])
ans = max(ans, dp[i])
return ans

下面这种更符合题意,因为dp的定义就是必须选nums[i-1],所以有可能是负值

1
2
3
4
5
6
7
8
9
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
m = len(nums)
dp = [0] * (m + 1) # 表示必须选nums[i - 1]
ans = -inf
for i in range(1, m + 1):
dp[i] = max(0, dp[i - 1]) + nums[i - 1] # 这个值可能是负值,因为至少包含一个元素
ans = max(ans, dp[i])
return ans

10.47 判断子序列

我就是用移动下标的方法做这道题:双指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def isSubsequence(self, s: str, t: str) -> bool:
i, j = 0, 0
m, n = len(s), len(t)
while i < m and j < n:
if s[i] == t[j]:
i += 1
j += 1
else:
j += 1
if i == m:
return True
else:
return False

后续挑战:如果有大量输入的 S,称作 S1, S2, … , Sk 其中 k >= 10 亿,你需要依次检查它们是否为 T 的子序列。在这种情况下,你会怎样改变代码?每个字符串都要遍历t,都需要O(n),太慢了

位置 剩余部分 'a' 'b' 'c' 'd' 'g' 'h'
0 ahbgdc 0 2 5 4 3 1
1 hbgdc -1 2 5 4 3 1
2 bgdc -1 2 5 4 3 -1
3 gdc -1 -1 5 4 3 -1
4 dc -1 -1 5 4 -1 -1
5 c -1 -1 5 -1 -1 -1
6 (末尾之后) -1 -1 -1 -1 -1 -1

last[t[i] - ‘a’] = i这样写会报错,要写成ord,就是把字符写成对应的unicode值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution:
def build_next(self, t: str):
n = len(t)
nxt = [[-1]*26 for _ in range(n + 1)] # 位置n默认就是-1了
last = [-1] * 26 # 记录当前扫描位置之后,字母 c 最近一次出现的下标
# 从后往前遍历是为了更新最近一次出现的下标,应对重复出现的场景
for i in range(n - 1, -1, -1):
last[ord(t[i]) - ord('a')] = i # 'a'的ascii码是97
for c in range(26):
nxt[i][c] = last[c] # 把当前位置的情况赋值给next
return nxt

def isSubsequence(self, s: str, t: str) -> bool:
nxt = self.build_next(t)
pos = 0 # 当前寻找的下标
for c in s:
j = nxt[pos][ord(c) - ord('a')] # 就是离pos最近的c出现的下标
if j == -1: # 没找到c
return False
pos = j + 1 # 从下一个地方开始找,不用担心越界,因为刚越界时是-1
return True

10.48 不同的子序列

dp[i][j]:以i-1为结尾的s子序列中出现以j-1为结尾的t的个数为dp[i][j]。 t中一定要出现

一个难点是相等的情况,也可以不选当前s[i-1],另一个难点是初始化的情况,并不是简单的只用设置0,0,这是因为递推公式一开始就涉及[0][1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def numDistinct(self, s: str, t: str) -> int:
m, n = len(s), len(t)
dp = [[0] * (n + 1) for _ in range(m + 1)]
# 需要初始化dp[0][i]或dp[i][0]
for i in range(1, m + 1): # t为空字符串的情况,个数为1,把字符删光即可
dp[i][0] = 1
for j in range(1, n + 1): # s没有字符,除了j=0,其他情况都不可能得到t
dp[0][j] = 0
dp[0][0] = 1
for i in range(1, m + 1):
for j in range(1, n + 1): # 因为t一定要有,所以j > i时肯定为0
if s[i - 1] == t[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + dp[i - 1][j] # 分为选当前s或者不选
else:
dp[i][j] = dp[i - 1][j]
return dp[m][n]

10.49 两个字符串的删除操作

dp[i][j]:以word1[i-1]结尾的字符串和以word2[j-1]结尾的字符串相同所需的最小步数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
dp[0][0] = 0
for i in range(1, m + 1):
dp[i][0] = i
for j in range(1, n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + 1 # 删了当前的值
return dp[m][n]

10.50 编辑距离

dp[i][j] 表示以下标i-1为结尾的字符串word1,和以下标j-1为结尾的字符串word2,最近编辑距离为dp[i][j]。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
m, n = len(word1), len(word2)
dp = [[0] * (n + 1) for _ in range(m + 1)]
dp[0][0] = 0
for i in range(1, m + 1):
dp[i][0] = i
for j in range(1, n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
return dp[m][n]

10.51 回文子串

虽然看提示,说xax是回文,那bxaxb也是回文,但不知道怎么写状态转移方程

还停留在dp[i][j]表示s[i][j]是回文串,不过这个定义是对的,不是定义dp[i]为结尾的字符串有几个回文子串,那就完全写不出来了

虽然这个定义是对的,但不知道i/j该从中间开始遍历吗?感觉很奇怪

5. 最长回文子串中,找到s中最长的回文子串,就是分别找奇数串和偶数串,以s[i]为中心,i不断变化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def longestPalindrome(self, s: str) -> str:
len_ = len(s)
start, end = 0, 0 # 记录最长回文子串的起点和终点,左闭右闭
max_len = 0
for i in range(2*len_ - 1):#0-9
left, right = i // 2, (i + 1) // 2
while left >= 0 and right < len_ and s[left] == s[right]:
left -= 1
right += 1
# 循环结束后,s[left + 1]到s[right - 1]是回文串,是闭区间,注意这个细节
if right - left - 1 > max_len:
max_len = right - left - 1
start, end = left + 1, right - 1
return s[start: end + 1]

简洁的写法是把两个循环合并成一个

在这道题中,也可以用中心扩展法(双指针),来统计回文串的个数

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def countSubstrings(self, s: str) -> int:
n = len(s)
ans = 0
for i in range(0, 2 * n - 1):
l, r = i // 2, (i + 1) // 2
while l >= 0 and r < n and s[l] == s[r]:
ans += 1
l -= 1
r += 1
return ans

动态规划:布尔类型的dp[i][j]:表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。

当s[i]与s[j]不相等,那没啥好说的了,dp[i][j]一定是false。

当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况

  • 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串
  • 情况二:下标i 与 j相差为1,例如aa,也是回文子串
  • 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。

初始化和遍历顺序:由于依赖i+1,j-1,所以i从大到小,j从小到大(当然本身>=i),i和j的顺序倒无所谓

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def countSubstrings(self, s: str) -> int:
n = len(s)
dp = [[False] * n for _ in range(n)]
ans = 0
for i in range(n - 1, -1, -1):
for j in range(i, n):
if s[i] == s[j]:
if j - i <= 1:
dp[i][j] = True
else:
dp[i][j] = dp[i + 1][j - 1]
if dp[i][j]:
ans += 1
return ans

10.52 最长回文子序列

本来想用上面的回文子串做的,然后发现这里的回文子序列可以不连续,所以要用动态规划

dp[i][j]表示s的i-j范围内的最长回文子序列长度

如果s[i]==s[j],则如果i==j,dp=1;j-i=1,dp=2,j - i>1,dp=dp[i + 1][j - 1] + 2

若不等,则dp=max(dp[i + 1][j], dp[i][j - 1])

同样,i从大到小,j从小到大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def longestPalindromeSubseq(self, s: str) -> int:
n = len(s)
dp = [[0] * n for _ in range(n)]
for i in range(n - 1, -1, -1):
for j in range(i, n):
if s[i] == s[j]:
if j == i:
dp[i][j] = 1
elif j - i == 1:
dp[i][j] = 2
else:
dp[i][j] = dp[i + 1][j - 1] + 2
else:
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1])
return dp[0][n - 1]

10.53 总结

背包问题:遍历顺序很重要

打家劫舍

股票

子序列

关于动规,还有 树形DP(打家劫舍系列里有一道),数位DP,区间DP ,概率型DP,博弈型DP,状态压缩dp等等等,这些我就不去做讲解了,面试中出现的概率非常低。

11. 单调栈

11.1 每日温度

只知道该存索引,但不知道该怎么存,从前往后从后往前的话该存什么

通常是一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置,此时我们就要想到可以用单调栈了。时间复杂度为O(n)。

  1. 存下标i即可,因为元素可以通过t[i]访问获得
  2. 本质是空间换时间

从右到左的写法:栈中记录的是下一个更大元素的候选项的下标,发现索引更小、数值更大的元素就开除现有的元素

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n = len(temperatures)
answer = [0] * n
st = [] # 从栈底到栈顶是递减的
for i in range(n - 1, -1, -1):
t = temperatures[i]
while st and t >= temperatures[st[-1]]:
st.pop()
if st: # 现在st里的数都大于t
answer[i] = st[-1] - i
st.append(i) # 变成新的栈顶,最小的
return answer

从左到右的写法:存的是目前还没找到比自己更大的元素的下标

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n = len(temperatures)
answer = [0] * n
st = [] # 从栈底到栈顶是递减的,存还没遇到最大值的下标
for i in range(n):
t = temperatures[i]
while st and t > temperatures[st[-1]]: # 栈
j = st.pop()
answer[j] = i - j
# 即使还有st,大于t,也不可能得到answer,因为下标小,当前的数肯定是直接加入的
st.append(i)
return answer

11.2 下一个更大元素 I

其实就是用单调栈得到nums2每个数字的下一个更大元素

我感觉必须得用一个dict建立nums2中数值和索引的映射,才能让nums1映射上。重点就是这里,直接建立数字->下一个更大元素的映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]:
m, n = len(nums1), len(nums2)
ans = [-1] * m
dict_ = {}
st = [] # 从栈顶到栈底是递减的,存的是数值
for i in range(n - 1, -1, -1):
num = nums2[i]
while st and num >= st[-1]:
st.pop()
if st:
dict_[num] = st[-1] # 建立数字->下一个更大元素的映射
else:
dict_[num] = -1 # 说明没有下一个更大元素
st.append(num)

for i in range(m):
num = nums1[i]
ans[i] = dict_[num]
return ans
方法 用于列表 用于字符串 是否原地修改 返回结果
list.reverse() ✅ 可以 ❌ 不可用 ✅ 是(原地修改) 返回 None
reversed(x) ✅ 可以 ✅ 可以 ❌ 否 返回一个反转迭代器
x[::-1] ✅ 可以 ✅ 可以 ❌ 否 返回反转后的新对象

字符串的反转可以写:''.join(reversed(s))

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def nextGreaterElement(self, nums1: List[int], nums2: List[int]) -> List[int]:
next_greater = {} # num -> 右侧第一个更大值(没有则 -1)
st = [] # 单调递减栈,存数值

for x in reversed(nums2): # 这里是反转数组,但不会原地修改nums2
while st and st[-1] <= x:
st.pop()
next_greater[x] = st[-1] if st else -1
st.append(x)

return [next_greater[x] for x in nums1]

11.3 下一个更大元素 II

就是这里的更大是循环的,也就是除了最大值没有更大的元素,其他值都是有更大的元素的

除了nums最后一个数,其他都是和前面的单调栈一样的,最后一个数又需要从nums[0]开始找

  • 这种写法不对,比如[5,4,3,2,1]的结果是[-1,5,5,5,5],而不是[-1,-1,-1,-1,5]

真正的方法就是把数组拼接起来,把数组看两遍,从右往左扫下标 i = 2n-1…0,实际位置用idx = i % n

[5,4,3,2,1,5,4,3,2,1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def nextGreaterElements(self, nums: List[int]) -> List[int]:
n = len(nums)
st = [] # 单调递减的
ans = [-1] * n
for i in range(2*n - 1, -1, -1):
idx = i % n
x = nums[idx]
while st and x >= st[-1]:
st.pop()
# 剩下的数是比x大的
if st:
ans[idx] = st[-1]
st.append(x)
return ans

11.4 接雨水

也可以用单调栈,但反而比较复杂,所以我没看

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def trap(self, height: List[int]) -> int:
# 从左到右存出现过的最大值,从右到左也是,然后用两个较大值中小的一个减去自己
n = len(height)
l, r = [0] * n, [0] * n
curr_max = 0
for i, h in enumerate(height):
curr_max = max(curr_max, h)
l[i] = curr_max
curr_max = 0
for i in range(n - 1, -1, -1):
curr_max = max(curr_max, height[i])
r[i] = curr_max
ans = 0
for i in range(n):
ans += min(l[i], r[i]) - height[i]
return ans

11.5 柱状图中最大的矩形

以每根柱子作为矩形的最低高度来扩展其左右可延伸的宽度,就能得到以这根柱子为“短板”的最大矩形面积。

如果我们知道每根柱子向左向右第一个严格小于它的柱子位置,就能立刻算出这根柱子能撑起的最大矩形面积:

areai=heighti×(rightLessi−leftLessi−1)

单调栈正是用来一次遍历就找出每根柱子的“左/右第一个更小”。

和接雨水的区别就在于,接雨水是找大的,这里是找小的,利用现在的高度,然后接雨水不需要第一个大的,但这里需要第一个小的,所以这里要使用单调栈得到l和r数组

这里单调栈是找左右第一个更小的,所以要维护单调递增的栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
n = len(heights)
l, r = [-1] * n, [n] * n
st = [] # 递增,存的是下标

# 先找左边的第一个更小元素
for i in range(n):
h = heights[i]
while st and heights[st[-1]] >= h:
st.pop()
if st:
l[i] = st[-1]
st.append(i)

st = []
# 再找右边的第一个更小元素
for i in range(n - 1, -1, -1):
h = heights[i]
while st and heights[st[-1]] >= h:
st.pop()
if st:
r[i] = st[-1]
st.append(i)

ans = 0
for i in range(n):
width = r[i] - l[i] - 1
area = heights[i] * width
ans = max(ans, area)
return ans

当然也有一种简单的写法,只需要遍历一次:这种写法是因为k就是当前高度stack[-1]的right下标,而把stack[-1]pop出来后,剩下的stack[-1]就是left的下标,这样就无需记录left和right数组了,在一次遍历O(n)的过程中可以得到res

为什么这里没有>=呢,因为上面是要找第一个严格小于的元素,不能让相等的柱子互当边界,而下面也是为了合并相同高度的矩形,其实都是处理相等的情况,是一个意思

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
res = 0
stack = [-1]
heights.append(0)
for k, num in enumerate(heights):
while stack and heights[stack[-1]] > num: #破坏了栈的单调递增性
index = stack.pop() #以该元素作为高度
res = max(res, heights[index] * (k - stack[-1] - 1))
stack.append(k)
return res

12. 图论

12.1 图论基础

度:无向图中有几条边连接该节点

连通图:在无向图中,任何两个节点都是可以到达的

在无向图中的极大连通子图称之为该图的一个连通分量。

在有向图中极大强连通子图称之为该图的强连通分量。

邻接矩阵:对于无向图,就是双向边grid[2][5] = 6,grid[5][2] = 6。缺点是图很稀疏

img

邻接表:使用数组存节点,使用链表存节点指向的每个点。遍历节点连接的情况容易,但检查任意两个节点之间是否存在边,效率就很低

img

图论,就是在图(邻接表或邻接矩阵)上进行搜索(BFS或DFS)

12.2 DFS

DFS:找到目标后再回头,回头就是回溯

用递归是最方便的,回溯就在递归函数的下面一步

1
2
3
4
5
void dfs(参数) {
处理节点
dfs(图,选择的节点); // 递归
回溯,撤销处理结果
}

回溯和dfs差不多:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
void dfs(参数) { //确定参数
if (终止条件) {
存放结果;
return;
}

for (选择:本节点所连接的其他节点) {
处理节点;
dfs(图,选择的节点); // 递归
回溯,撤销处理结果
}
}

12.3 可达路径

12.4 BFS

适合于解决两个点之间的最短路径问题,一旦遇到终点,就是最短路

岛屿问题是BFS和DFS都可以解决,因为只要能把相邻且相同属性的节点标记上就行

这一圈一圈的搜索过程是怎么做到的,是放在什么容器里,才能这样去遍历。

其实,我们仅仅需要一个容器,能保存我们要遍历过的元素就可以,那么用队列,还是用栈,甚至用数组,都是可以的

用队列的话,就是保证每一圈都是一个方向去转,例如统一顺时针或者逆时针

  • 因为队列是先进先出,加入元素和弹出元素的顺序是没有改变的。

如果用栈的话,就是第一圈顺时针遍历,第二圈逆时针遍历,第三圈有顺时针遍历

  • 因为栈是先进后出,加入元素和弹出元素的顺序改变了。

BFS本身不需要注意 转圈搜索的顺序,但大家都习惯用队列了,因为搜索顺序不会变

13. 额外题目

13.1 有多少小于当前数字的数字

想的是先排序,然后找每个数字在正序数组中的位置

排序之后,每个数值的下标就代表有几个比它小的了,用哈希表记录每个数字的下标

如果用nums.sort(),是直接对原数组进行排序,如果用sorted(nums),是产生新对象

1
2
3
4
5
6
7
8
class Solution:
def smallerNumbersThanCurrent(self, nums: List[int]) -> List[int]:
n = len(nums)
d = {} # 映射数值->下标
sorted_nums = sorted(nums)
for i in range(n - 1, -1, -1): # 如果有多个相同的数值,只需要存第一个出现的,后面出现的跟的第一个出现的一样,所以倒序遍历
d[sorted_nums[i]] = i
return [d[n] for n in nums]

O(nlogn)

13.2 有效的山脉数组

就是有且只有一个最大值,不能出现在开头或结尾,要寻找这个变化点

我没想出来,应该用双指针的方法,保证左边到中间,和右边到中间是递增的

我又犯了一个错误,while中l和r不应该一起移动

还要注意一个细节,递增和递减序列,这样也会得到l==r,要把这种情况排除

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def validMountainArray(self, arr: List[int]) -> bool:
n = len(arr)
if n <= 2:
return False
l, r = 0, n - 1
while l < n - 1 and arr[l] < arr[l + 1]:
l += 1
while r >= 1 and arr[r] < arr[r - 1]:
r -= 1
return l == r and l != 0 and r != n

13.3 独一无二的出现次数

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def uniqueOccurrences(self, arr: List[int]) -> bool:
n = len(arr)
d = {}
for n in arr:
if n not in d: # 或者写d.count(num, 0) + 1
d[n] = 1
else:
d[n] += 1
times = d.values() # 这里返回的是一个对象,可以用set和len,但如果想转为list就是加一个转换;如果想取keys,就是keys()
s = set(times)
return len(s) == len(times)

注意是出现次数这个数,要独一无二,所以先统计每个数的出现次数,再取set

用Counter库的写法:

1
2
3
4
class Solution:
def uniqueOccurrences(self, arr: List[int]) -> bool:
freq = Counter(arr) # 统计出现次数
return len(freq.values()) == len(set(freq.values()))

13.4 移动零

我的想法就是遇到0后找第一个非0的数交换,其实就是暴力

1
2
3
4
5
6
7
8
9
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
n = len(nums)
for i in range(n):
if nums[i] == 0: # 从末尾找第一个不是0的数字进行交换
for j in range(n - 1, i, -1):
if nums[j] != 0:
nums[i], nums[j] = nums[j], nums[i] # 使用python原生进行交换
return nums

但是这种暴力方法效率低,用双指针,slow指向下一个应该放置非零元素的位置,fast用于扫描数组,注意并不是每次交换后0就在最后了(fast是正序的,不能倒序!不然会覆盖还没有扫描到的元素)

1
2
3
4
5
6
7
8
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
slow = 0
for fast in range(len(nums)):
if nums[fast] != 0:
nums[slow], nums[fast] = nums[fast], nums[slow]
slow += 1
return nums

另一种直观理解的方法是先把所有非零元素填到前面,剩下的都改为0

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
pos = 0
for num in nums:
if num != 0:
nums[pos] = num
pos += 1
# 剩余部分填 0
for i in range(pos, len(nums)):
nums[i] = 0
return nums

13.5 轮转数组

先把[0,n-k)和[n-k, n)分别进行翻转,再把[0,n)进行翻转

总之记得左旋转、右旋转数组可以分段翻转即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def rotate(self, nums: List[int], k: int) -> None:
def reverse(start, end): # 这里是翻转[start,end)的数组
l, r = start, end - 1
while l <= r:
nums[l], nums[r] = nums[r], nums[l]
l += 1
r -= 1
n = len(nums)
k = k % n # 注意存在k>n的情况,要加这一步
reverse(0, n - k)
reverse(n - k, n)
reverse(0, n)
return nums

13.6 寻找数组的中心下标

注意左侧和和右侧和是不包括自己的

感觉要记录前缀和数组,这样后缀和可以通过total-前缀-自己得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution:
def pivotIndex(self, nums: List[int]) -> int:
n = len(nums)
total = sum(nums) # 这里多此一举了,没必要存储,一次遍历即可
pre = [0] * n # 存的是左侧元素的和,不包括自己
curr = 0
for i, num in enumerate(nums):
pre[i] = curr
curr += num
for i in range(n):
if pre[i] == total - pre[i] - nums[i]:
return i
return -1
class Solution:
def pivotIndex(self, nums: List[int]) -> int:
n = len(nums)
total = sum(nums)
curr = 0
for i, num in enumerate(nums):
if curr == total - curr - num:
return i
curr += num
return -1

13.7 在排序数组中查找元素的第一个和最后一个位置

递增排列,用二分法,说建议写左闭右开

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
l, r = 0, len(nums) - 1
while l <= r:
idx = (l + r) // 2
if nums[idx] < target:
l = idx + 1
elif nums[idx] > target:
r = idx - 1
else: # 说明找到了一个target的,就以它中心扩张
start, end = idx, idx
while start >= 0 and nums[start] == target:
start -= 1
while end <= len(nums) - 1 and nums[end] == target:
end += 1
return [start + 1, end - 1]
return [-1, -1]

13.8 按奇偶排序数组 II

就是把奇数都移动到奇数的下标上,偶数移动到偶数的下标上

不适用额外空间,就是用双指针,一个指向奇数下标,一个指向偶数下标

我下面的写法效率很低,这是因为没有提前判断nums[j] / nums[i] 本身是否已经是正确奇偶,可能会做多余交换,最好把两个错误的奇偶拿来交换,这样就节约交换次数了

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def sortArrayByParityII(self, nums: List[int]) -> List[int]:
i, j = 0, 1
for idx in range(len(nums)): # 原来写enumerate的问题是,交换后n变了,但没有及时更新
while idx % 2 == 0 and nums[idx] % 2 == 1 and j < len(nums): # 移动到奇数下标,但j指向的数可能也是奇,所以循环
nums[idx], nums[j] = nums[j], nums[idx]
j += 2
while idx % 2 == 1 and nums[idx] % 2 == 0 and i < len(nums):
nums[idx], nums[i] = nums[i], nums[idx]
i += 2
return nums
# 改进时是在交换前加if判断奇偶性

更简洁的写法:就是用i和j代表需要交换的位置,不需要另外用for+while的形式,肯定是能交换完的,因为数组特点是一半一半:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def sortArrayByParityII(self, nums: List[int]) -> List[int]:
n = len(nums)
i, j = 0, 1 # i 指向偶数位,j 指向奇数位
while i < n and j < n:
if nums[i] % 2 == 0:
i += 2
elif nums[j] % 2 == 1:
j += 2
else:
nums[i], nums[j] = nums[j], nums[i]
i += 2
j += 2
return nums

13.9 搜索插入位置

如果出现,就是返回下标,如果没出现,就是插入到升序排列的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
l, r = 0, len(nums)
while l < r:
idx = (l + r) // 2
if target > nums[idx]:
if idx == len(nums) - 1:
return idx + 1
elif target < nums[idx + 1]:
return idx + 1
else:
l = idx + 1
elif target < nums[idx]:
if idx == 0:
return 0
elif target > nums[idx - 1]:
return idx
else:
r = idx # 因为左闭右开
else:
return idx

简洁版的:

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
l, r = 0, len(nums)
while l < r:
idx = (l + r) // 2
if target > nums[idx]:
l = idx + 1
elif target < nums[idx]:
r = idx # 因为左闭右开
else:
return idx
return l

13.10 两两交换链表中的节点

我没想出来的地方是1. 创建dummy 2. 移动一步(这个想过,但在移动两步中徘徊)3. 返回dummy.next

0 1 2 3 变成 0 2 1 3 1 3 4变成 1 4 3

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def swapPairs(self, head: Optional[ListNode]) -> Optional[ListNode]:
dummy = ListNode(0, head)
pre, cur = dummy, head
while cur and cur.next:
nxt = cur.next
tmp = nxt.next
nxt.next = cur
pre.next = nxt
cur.next = tmp
pre, cur = cur, cur.next
return dummy.next

Hot100

1:160. 相交链表

我本来的想法是倒序看两个链表,但链表又只能从头开始。看来还是要用数学技巧

指针 A 先遍历完链表 headA ,再开始遍历链表 headB ,当走到 node 时,共走步数为:
a+(b−c)
指针 B 先遍历完链表 headB ,再开始遍历链表 headA ,当走到 node 时,共走步数为:
b+(a−c)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def getIntersectionNode(self, headA: ListNode, headB: ListNode) -> ListNode:
A, B = headA, headB
while A != B:
A = A.next if A else headB
B = B.next if B else headA
#就是进行了切换,等价于下面,这里的跳跃是技巧
if A:
A = A.next
else:
A = headB
if B:
B = B.next
else:
B = headA
return A

2:236. 二叉树的最近公共祖先

深度就是第几层,从根节点到该节点,从上往下

高度是倒数第几层,就像楼层一样从下往上

两个节点 p,q 分为两种情况:

  • pq 在相同子树中
  • pq 在不同子树中

从根节点遍历,递归向左右子树查询节点信息

递归终止条件:如果当前节点为空或等于 p 或 q,则返回当前节点

递归遍历左右子树,如果左右子树查到节点都不为空,则表明 p 和 q 分别在左右子树中,在root的两侧,因此,当前节点root即为最近公共祖先;

如果左右子树其中一个不为空,则返回非空节点。

  • 如果left不为空,说明p,q在左子树。
  • 如果right不为空,说明p,q在右子树。
  • left和right都为空,说明找不到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def lowestCommonAncestor(self, root: 'TreeNode', p: 'TreeNode', q: 'TreeNode') -> 'TreeNode':
#深度是指第几层,尽可能大说明如果在同侧要尽量往下(就是本身)
if root is None or root == q or root == p:
return root
left = self.lowestCommonAncestor(root.left, p, q)#注意自己调自己要写self
right = self.lowestCommonAncestor(root.right, p, q)
if left and right:
return root
if left:#说明在同侧的一棵子树上,p/q只能搜到一个
return left
if right:
return right
return None #这题场景中应该不存在找不到

3:234. 回文链表

  1. 找中间节点:快慢指针
  2. 反转链表:中间到最后的反转了
  3. 对比两个链表

为什么反转中间而不是一开始就反转全部,当然是为了O(n)节约复杂度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def middleNode(self, head: Optional[ListNode]) -> Optional[ListNode]:
slow = fast = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
return slow
def reverse(self, head: Optional[ListNode]) -> Optional[ListNode]:
pre, cur = None, head
while cur:
nxt = cur.next
cur.next = pre
pre = cur
cur = nxt
return pre
def isPalindrome(self, head: Optional[ListNode]) -> bool:
#回文链表是中间对称的
mid = self.middleNode(head)
head2 = self.reverse(mid)#注意是mid,不是head
while head and head2:
if head.val != head2.val:
return False
head = head.next
head2 = head2.next
return True

4:739. 每日温度 stack

单调栈(Monotonic Stack)是一种特殊的栈结构,其元素按照严格递增或严格递减的顺序排列。它的核心思想是利用栈的单调性快速找到元素的前后边界,常用于解决数组中与“相邻元素大小关系”相关的问题。

  • 下一次更高的温度出现在几天后,相当于我要把温度排序,得到它的序号作为名次,或者看作折线图(我的想法没有单调栈简单)

  • 从左到右, 更新为主, 去除为辅, 元素可重复。

    从右到左, 去除为主, 更新为辅, 元素无重复。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n = len(temperatures)
res = [0] * n
st = []#栈内元素在该题中保持递减,存的是下标
for i in range(n - 1, -1, -1):#倒序
t = temperatures[i]
while st and t >= temperatures[st[-1]]:#st的最后一个存的是离自己最近的最高温度 自己比未来>=不算数
st.pop()
if st:
res[i] = st[-1] - i#看下标的距离
st.append(i) #会把当前的加进去
return res

从左到右的方法是TODO list

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def dailyTemperatures(self, temperatures: List[int]) -> List[int]:
n = len(temperatures)
res = [0] * n
st = []#栈内元素在该题中保持递减,存的是下标
for i, t in enumerate(temperatures):#从左到右,todo
while st and t > temperatures[st[-1]]:#现在比之前的大,则前面的可以解决(注意不是>=号!)
j = st.pop()
res[j] = i - j#看下标的距离
st.append(i) #会把当前的加进去
return res

5:226. 翻转二叉树

普通递推,注意不用定义tmp,直接用=即可进行交换

1
2
3
4
5
6
7
8
class Solution:
def invertTree(self, root: Optional[TreeNode]) -> Optional[TreeNode]:
if not root:
return root
root.left, root.right = root.right, root.left
self.invertTree(root.left)
self.invertTree(root.right)
return root

6:221. 最大正方形 dp

状态转移方程根据不等式来的

=别手误写成==

image-20250401130758479

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def maximalSquare(self, matrix: List[List[str]]) -> int:
if len(matrix) == 0 or len(matrix[0]) == 0:
return 0
maxSide = 0 #记录整个图形中出现过最大的边长
rows, cols = len(matrix), len(matrix[0])
dp = [[0] * cols for _ in range(rows)]
for i in range(rows): #从左上开始
for j in range(cols):
if matrix[i][j] == '1': #如果为0那dp也为0
if i == 0 or j == 0: #在边界上
dp[i][j] = 1
else:
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
maxSide = max(maxSide, dp[i][j])
maxSquare = maxSide * maxSide
return maxSquare

7:215. 数组中的第K个最大元素

快速排序:

  • 哨兵划分: 以数组某个元素(一般选取首元素)为基准数,将所有小于基准数的元素移动至其左边,大于基准数的元素移动至其右边。

  • 递归: 对 左子数组 和 右子数组 递归执行 哨兵划分,直至子数组长度为 1 时终止递归,即可完成对整个数组的排序。

这里并没有排序好完整的序列,只是把k所在的地方排序了,节约了时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def findKthLargest(self, nums: List[int], k: int) -> int:
def quick_select(nums, k):
pivot = random.choice(nums)
big, equal, small = [], [], []
for num in nums:
if num > pivot:
big.append(num)
elif num < pivot:
small.append(num)
else:
equal.append(num)
if k <= len(big):
return quick_select(big, k)
if len(nums) - len(small) < k:#比如第9大,有10个元素,small有3,就是找small中的第二大
return quick_select(small, k - len(nums) + len(small))
return pivot
return quick_select(nums, k)

8:208. 实现 Trie (前缀树)模板题

前缀树

插入单词,检索是否存在,看前缀是否存在

创建26叉树,像桶排序一样方便查找;然后用bool表示是否结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
class Node:
__slots__ = 'son', 'end' #限制属性以节省内存

def __init__(self):
self.son = [None] * 26
self.end = False


class Trie:

def __init__(self):
self.root = Node()

def insert(self, word: str) -> None:
cur = self.root
for c in word:
c = ord(c) - ord('a')
if cur.son[c] is None:#目前没有这个
cur.son[c] = Node()
cur = cur.son[c] #变成子节点,深度搜索
cur.end = True #最后一个字符的end设置为true

def find(self, word: str) -> int:
cur = self.root
for c in word:
c = ord(c) - ord('a')
if cur.son[c] is None: #有一个字符不一样就返回0
return 0
cur = cur.son[c]
return 2 if cur.end else 1 #如果是end,说明是search


def search(self, word: str) -> bool:#每个字符都一样
return self.find(word) == 2

def startsWith(self, prefix: str) -> bool:#包括search和开头两种情况,所以返回1/2都行,但不能返回0
return self.find(prefix) != 0


# Your Trie object will be instantiated and called as such:
# obj = Trie()
# obj.insert(word)
# param_2 = obj.search(word)
# param_3 = obj.startsWith(prefix)

9:207. 课程表 拓扑

拓扑排序:给定一个包含 n 个节点的有向图 G,我们给出它的节点编号的一种排列,如果满足:对于图 G 中的任意一条有向边 (u,v),u 在排列中都出现在 v 的前面。如果有有向无环图即可

  • 例如,先修课程对 [0, 1] 表示:想要学习课程 0 ,你需要先完成课程 1

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Solution:
    def canFinish(self, numCourses: int, prerequisites: List[List[int]]) -> bool:
    indegrees = [0 for _ in range(numCourses)] #记录每个课程的入度(即有多少先修课程)
    adjacency = [[] for _ in range(numCourses)] #邻接表,用于存储每个课程的后继课程
    queue = collections.deque() #先进先出 进行BFS 存储学了的课程方便遍历后继
    for cur, pre in prerequisites:#[0,1]0是1的后继,1是0的先修
    indegrees[cur] += 1
    adjacency[pre].append(cur)
    #把所有入度为0的课程加入队列,没有先修课程可以立即开始学习
    for i in range(len(indegrees)):
    if not indegrees[i]:
    queue.append(i)
    # BFS TopSort. numCourses记录仍待学习的课程总数
    while queue:
    pre = queue.popleft()
    numCourses -= 1
    for cur in adjacency[pre]:#pre的后继课程如果只有pre一个先修,就学了
    indegrees[cur] -= 1
    if not indegrees[cur]:
    queue.append(cur)
    return not numCourses

10:206. 反转链表

1
2
3
4
5
6
7
8
9
class Solution:
def reverseList(self, head: Optional[ListNode]) -> Optional[ListNode]:
pre, cur = None, head
while cur:
nxt = cur.next
cur.next = pre
pre = cur
cur = nxt
return pre

11:200. 岛屿数量 dfs

一旦我们发现 (i,j) 是 1,就从 (i,j) 开始,DFS 这个岛。

每一步可以往左右上下四个方向走,也就是

(i,j−1),(i,j+1),(i−1,j),(i+1,j)
这四个格子。

每次到达一个新的格子,就插上旗子🚩,把 grid[i][j] 改成 2。

如果 (i,j) 出界,或者 (i,j) 是水,或者 (i,j) 已经插上了旗子🚩,就不再继续往下递归。

⚠注意:DFS 的过程中,最重要的是不能重复访问之前访问过的格子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def numIslands(self, grid: List[List[str]]) -> int:
m, n = len(grid), len(grid[0])

def dfs(i: int, j: int) -> None:#子函数可以访问外部函数的参数,外部函数调用也可以直接写
if i < 0 or i >= m or j < 0 or j >= n or grid[i][j] != '1':
return #不在往下递归的条件
grid[i][j] = '2'#插旗子
dfs(i, j - 1)
dfs(i, j + 1)
dfs(i - 1, j)
dfs(i + 1, j)

ans = 0
for i, row in enumerate(grid):
for j, c in enumerate(row):
if c == '1':
dfs(i, j)
ans += 1
return ans

12:198. 打家劫舍 dp

1.子问题:从k个房子能偷到的最大金额

2.递推关系:子问题的递推关系

3.计算顺序:k依赖K-1和k-2,所以从左到右算

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def rob(self, nums: List[int]) -> int:
if len(nums) == 0:
return 0
n = len(nums)
dp = [0] * (n + 1) #注意要设置n+1个数,这样i-2才不会越界,保证可以不选第一个数
dp[0] = 0
dp[1] = nums[0]
for i in range(2, n + 1):
dp[i] = max(dp[i - 1], nums[i - 1] + dp[i - 2])
return dp[n]

空间优化:其实只用两个值迭代,不需要一整个dp,就能推出最后的最大值

1
2
3
4
5
6
class Solution:
def rob(self, nums: List[int]) -> int:
pre, cur = 0, 0 #分别代表i-2和i-1
for i in nums:
pre, cur = cur, max(cur, i + pre)
return cur

13:169. 多数元素 投票

暴力:排序返回中间数字;哈希表统计

最佳方法:摩尔投票(就是抵消原则,一一配对,最后剩下来至少一个该元素)

  • 推论一: 若记 众数 的票数为 +1 ,非众数 的票数为 −1 ,则一定有所有数字的 票数和 >0 。
  • 推论二: 若数组的前 a 个数字的 票数和 =0 ,则 数组剩余 (n−a) 个数字的 票数和一定仍 >0 ,即后 (n−a) 个数字的 众数仍为 x 。
1
2
3
4
5
6
7
8
9
10
class Solution:
def majorityElement(self, nums: List[int]) -> int:
votes, count = 0, 0
for num in nums:
if votes == 0: x = num
votes += 1 if num == x else -1
# 验证 x 是否为众数
for num in nums:
if num == x: count += 1
return x if count > len(nums) // 2 else 0 # 当无众数时返回 0

14:238. 除自身以外数组的乘积

分别迭代计算上三角和下三角的乘积即可。前缀积和后缀积

Picture1.png
1
2
3
4
5
6
7
8
9
class Solution:
def productExceptSelf(self, nums: List[int]) -> List[int]:
ans, tmp = [1] * len(nums), 1
for i in range(1, len(nums)):
ans[i] = ans[i - 1] * nums[i -1] #下三角
for i in range(len(nums) - 2, -1, -1):
tmp *= nums[i + 1]
ans[i] *= tmp #上三角
return ans

15:155. 最小栈

为什么不能用一个stack,因为min是O(n),不是常数时间

用额外的空间辅助存最小值,用list的原因是最小值可能不止一个,可以保证数量的正确性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class MinStack:

def __init__(self):
self.stack = []
self.min_stack = []

def push(self, val: int) -> None:
self.stack.append(val)
if not self.min_stack or val <= self.min_stack[-1]: #更新栈顶的最小值,重复值也重复入(保证pop的正确性,不然数量对不上)
self.min_stack.append(val)

def pop(self) -> None:
if self.stack.pop() == self.min_stack[-1]:#这里stack是一定会pop的,如果和最小值相等把mini_stack也pop了
self.min_stack.pop()

def top(self) -> int:
return self.stack[-1]

def getMin(self) -> int:
return self.min_stack[-1]


# Your MinStack object will be instantiated and called as such:
# obj = MinStack()
# obj.push(val)
# obj.pop()
# param_3 = obj.top()
# param_4 = obj.getMin()

16:152. 乘积最大子数组

要有连续的非空子数组

如果当前的数是正数,要与前面最大的乘积相乘;如果当前的数是负数,要与前面最小的乘积相乘。这样才有可能最大(这是最关键的思路,一般题只考虑最大值,但因为连续,这里要考虑最小值)

可以合并在一起写

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def maxProduct(self, nums: List[int]) -> int:
n = len(nums)
f_max = [0] * n
f_min = [0] * n
f_max[0] = f_min[0] = nums[0]
for i in range(1, n):
x = nums[i]
f_max[i] = max(f_max[i - 1] * x, f_min[i - 1] * x, x)
f_min[i] = min(f_max[i - 1] * x, f_min[i - 1] * x, x)
return max(f_max)

注意最后返回的是过程中的最大值

空间优化:

1
2
3
4
5
6
7
8
class Solution:
def maxProduct(self, nums: List[int]) -> int:
ans = -inf # 注意答案可能是负数
f_max = f_min = 1
for x in nums:
f_max, f_min = max(f_max * x, f_min * x, x), min(f_max * x, f_min * x, x)
ans = max(ans, f_max)
return ans

17:148. 排序链表 难!

归并排序:

  • 分割:不断用快慢指针找到mid,然后mid.next=None把链表切断
    • 终止条件非常重要!当head.next为空时说明只有一个节点,返回该节点即可
  • 合并:把两个有序链表合并,不断比较头节点即可,用dummy更简单
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]:
if not head or not head.next:
return head
slow, fast = head, head.next
while fast and fast.next:
slow = slow.next
fast = fast.next.next
mid, slow.next = slow.next, None
left, right = self.sortList(head), self.sortList(mid)
res = ListNode(0)#dummy节点,不变用于返回
h = res #存储合并后的链表
while left and right:
if left.val < right.val:
h.next = left
left = left.next
else:
h.next = right
right = right.next
h = h.next
if left:
h.next = left
else:
h.next = right
return res.next

18:146. LRU 缓存模板题

最近最久未使用的缓存会被移除

函数 getput 必须以 O(1) 的平均时间复杂度运行。

看到题目要我们实现一个可以存储 key-value 形式数据的数据结构,并且可以记录最近访问的 key 值。首先想到的就是用字典来存储 key-value 结构,这样对于查找操作时间复杂度就是 O(1)。

但是因为字典本身是无序的,所以我们还需要一个类似于队列的结构来记录访问的先后顺序,这个队列需要支持如下几种操作:

  • 在末尾加入一项

  • 去除最前端一项

  • 将队列中某一项移到末尾

链表末尾就是最新访问的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
class ListNode:
def __init__(self, key=None, value=None):
self.key = key
self.value = value
self.prev = None
self.next = None

class LRUCache:

def __init__(self, capacity: int):
self.capacity = capacity #LRU的大小
self.hashmap = {} #缓存,存储key和listnode节点!而不仅仅是value!
#定义双向链表用于更新最新访问的key
self.head = ListNode()
self.tail = ListNode()
self.head.next = self.tail
self.tail.prev = self.head

def move_node_to_tail(self, key):
node = self.hashmap[key] #先从现有链表中删除key指向的节点
node.prev.next = node.next
node.next.prev = node.prev

node.prev = self.tail.prev # 之后将node插入到尾节点前
node.next = self.tail
self.tail.prev.next = node
self.tail.prev = node

def get(self, key: int) -> int:#不在缓存中就返回-1
if key in self.hashmap:
self.move_node_to_tail(key)# 如果已经在链表中了久把它移到末尾(变成最新访问的)
res = self.hashmap.get(key, -1)
if res == -1:
return res
else:
return res.value #因为找到的是listnode,返回value

def put(self, key: int, value: int) -> None:
if key in self.hashmap:
self.hashmap[key].value = value # 更新值
self.move_node_to_tail(key) # 随后将该节点移到末尾
else:#key不在里面,要逐出一个
if len(self.hashmap) == self.capacity:
self.hashmap.pop(self.head.next.key)
# 去掉最久没有被访问过的节点,即头节点之后的节点
self.head.next = self.head.next.next
self.head.next.prev = self.head

#现在容量够了,作为最新访问的节点插到tail之前即可
new = ListNode(key, value)
self.hashmap[key] = new
new.prev = self.tail.prev
new.next = self.tail
self.tail.prev.next = new
self.tail.prev = new



# Your LRUCache object will be instantiated and called as such:
# obj = LRUCache(capacity)
# param_1 = obj.get(key)
# obj.put(key,value)

19:142. 环形链表 II

第一次相遇后,令fast重新指向头节点

f=2nbs=nb,n是环的周长(根据f=2sf=s+nb求出)

此指针和 slow 一起向前走 a 步后,两者在入口节点重合。那么从哪里走到入口节点需要 a 步?答案是链表头节点head。这样可以求出a

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def detectCycle(self, head: Optional[ListNode]) -> Optional[ListNode]:
fast = slow = head
while fast and fast.next:
slow = slow.next
fast = fast.next.next
if slow == fast:
ptr = head
while head != slow:
slow = slow.next
head = head.next
return slow
return None

20:141. 环形链表 双指针

这题简单些,不必用求出环的入口,用快慢指针证明有环即可

1
2
3
4
5
6
7
8
9
class Solution:
def hasCycle(self, head: Optional[ListNode]) -> bool:
slow = fast = head # 乌龟和兔子同时从起点出发
while fast and fast.next:
slow = slow.next # 乌龟走一步
fast = fast.next.next # 兔子走两步
if fast == slow: # 兔子追上乌龟(套圈),说明有环 这里也可以写isSlow
return True
return False # 访问到了链表末尾,无环

21:139. 单词拆分 dp

动态规划(回溯暂时没看懂)

dp表示前i位是否可用wordDict表示,返回dp[-1]

trick是如果dp[i]为True,s[i:j]又在worddict中,则dp[j]为true。ij都需要枚举

1
2
3
4
5
6
7
8
9
10
class Solution:
def wordBreak(self, s: str, wordDict: List[str]) -> bool:
n=len(s)
dp=[False]*(n+1)
dp[0]=True
for i in range(n):
for j in range(i+1,n+1):
if(dp[i] and (s[i:j] in wordDict)):
dp[j]=True
return dp[-1]

22:136. 只出现一次的数字

异或:n^n=0, n^0=n

1
2
3
4
5
6
class Solution:
def singleNumber(self, nums: List[int]) -> int:
ans = 0
for n in nums:
ans ^= n
return ans

23:647. 回文子串 dp

返回数目

用dp[i][j]表示区间范围[i,j] (注意是左闭右闭)的子串是否是回文子串,如果是dp[i][j]为true,否则为false。该定义j一定>=i,所以dp只填充右上部分

当s[i]与s[j]不相等,那没啥好说的了,dp[i][j]一定是false。

当s[i]与s[j]相等时,这就复杂一些了,有如下三种情况

  • 情况一:下标i 与 j相同,同一个字符例如a,当然是回文子串

  • 情况二:下标i 与 j相差为1,例如aa,也是回文子串

  • 情况三:下标:i 与 j相差大于1的时候,例如cabac,此时s[i]与s[j]已经相同了,我们看i到j区间是不是回文子串就看aba是不是回文就可以了,那么aba的区间就是 i+1 与 j-1区间,这个区间是不是回文就看dp[i + 1][j - 1]是否为true。

  • 根据递推关系的下标,需要从下到上,从左到右遍历

image.png
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def countSubstrings(self, s: str) -> int:
dp = [[False] * len(s) for _ in range(len(s))]
result = 0
for i in range(len(s)-1, -1, -1): #注意遍历顺序
for j in range(i, len(s)):
if s[i] == s[j]:
if j - i <= 1: #情况一 和 情况二
result += 1
dp[i][j] = True
elif dp[i+1][j-1]: #情况三
result += 1
dp[i][j] = True
return result

双指针法:一个元素可以作为中心点,两个元素也可以作为中心点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def countSubstrings(self, s: str) -> int:
res = 0
for i in range(len(s)):
res += self.extend(s, i, i, len(s))
res += self.extend(s, i, i + 1, len(s))#这里最后会越界,但下面有边界校验了
return res

def extend(self, s, i, j, n):#ij是下标
res = 0
while i >= 0 and j < n and s[i] == s[j]: #先看作为中心的点相不相等
i -= 1
j += 1
res += 1
return res

24:128. 最长连续序列

这里连续的意思是下一个数字要+1

O(n)所以不能排序

把nums放进集合(去除重复元素,因为重复元素不连续)

如果 x−1 在哈希集合中,则不以 x 为起点。为什么?因为以 x−1 为起点计算出的序列长度,一定比以 x 为起点计算出的序列长度要长!这样可以避免大量重复计算。比如 nums=[3,2,4,5],从 3 开始,我们可以找到 3,4,5 这个连续序列;而从 2 开始,我们可以找到 2,3,4,5 这个连续序列,一定比从 3 开始的序列更长。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def longestConsecutive(self, nums: List[int]) -> int:
ans = 0
st = set(nums)
for x in st:
if x - 1 in st: #这里剪枝了,注意不是while,只看前一个数
continue
y = x + 1
while y in st:
y += 1
#退出循环时是没找到的值y+1,与x的距离就是序列 的长度
ans = max(ans, y - x)
return ans

25:124. 二叉树中的最大路径和 dfs

各值和,不一定经过根节点

  • 链:从下面的某个节点(不一定是叶子)到当前节点的路径。把这条链的节点值之和,作为 dfs 的返回值。如果节点值之和是负数,则返回 0。

  • 直径:等价于由两条(或者一条)链拼成的路径。我们枚举每个 node,假设直径在这里「拐弯」,也就是计算由左右两条从下面的某个节点(不一定是叶子)到 node 的链的节点值之和,去更新答案的最大值

这里dfs算的是链!

注意一定要写nonlocal,如果只是读取外层变量,不用nonlocal,但这里要修改,加入nonlocal用于在嵌套函数中修改外层函数的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def maxPathSum(self, root: Optional[TreeNode]) -> int:
ans = -inf #在这里定义了子函数也能访问到
def dfs(node: Optional[TreeNode]) -> int:
if node is None:
return 0
l_val = dfs(node.left)
r_val = dfs(node.right)
nonlocal ans
ans = max(l_val + r_val + node.val, ans)
return max(max(l_val, r_val) + node.val, 0) #这里返回的是链,并不是一定要经过node两侧的直径
dfs(root)
return ans

26:322. 零钱兑换 dp

背包问题

因为求最小值所以用inf初始化dp

1
2
3
4
5
6
7
8
class Solution:
def coinChange(self, coins: List[int], amount: int) -> int:
dp = [0] + [inf] * amount
for n in coins:#n是当前硬币的值
for c in range(n, amount + 1):#这里的范围是重点,相当于选中硬币n了,金额不可能小于n
dp[c] = min(dp[c], dp[c - n] + 1)
ans = dp[amount]
return ans if ans < inf else -1

27:494. 目标和 dfs背包

image-20250401200536138

当s-|target|是奇数时直接返回0即可

dfs(i, c)表示用i个数字凑出背包容量的方案数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def findTargetSumWays(self, nums: List[int], target: int) -> int:
s = sum(nums) - abs(target)
if s < 0 or s % 2:
return 0
m = s // 2 # 背包容量

@cache # 缓存装饰器,避免重复计算
def dfs(i: int, c: int) -> int:
# 如果已经遍历完所有数字(i < 0),检查是否恰好凑出 c
if i < 0:
return 1 if c == 0 else 0

if c < nums[i]: #选不了c
return dfs(i - 1, c)

# - 不选:直接递归到 i-1,容量 c 不变
# - 选:递归到 i-1,容量 c 减少 nums[i]
return dfs(i - 1, c) + dfs(i - 1, c - nums[i])
return dfs(len(nums) - 1, m)

28:461. 汉明距离

异或剩下不为1的数字就对应二进制位不同的位置

1
2
3
4
class Solution:
def hammingDistance(self, x: int, y: int) -> int:
return (x ^ y).bit_count()
#或者写return bin(x ^ y).count('1') bin把整数转为二进制

29:448. 找到所有数组中消失的数字

列表查询的时间为on,集合只用o1,因为list是连续内存存储,set是哈希表

1
2
3
4
5
6
7
8
9
class Solution:
def findDisappearedNumbers(self, nums: List[int]) -> List[int]:
n = len(nums)
nums = set(nums)#注意不加这句会超时,加了就保证数量小于N了
res = []
for i in range(1, n + 1):
if i not in nums:
res.append(i)
return res

30:438. 找到字符串中所有字母异位词 双指针

异位词:字母相同顺序不同

暴力法:以右侧下标做对比可以保证左侧数据早已加入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def findAnagrams(self, s: str, p: str) -> List[int]:
ans = []
cnt_p = Counter(p) # 统计 p 的每种字母的出现次数
cnt_s = Counter() # 统计 s 的长为 len(p) 的子串 s' 的每种字母的出现次数
for right, c in enumerate(s):
cnt_s[c] += 1 # 右端点字母进入窗口
left = right - len(p) + 1
if left < 0: # 窗口长度不足 len(p)
continue
if cnt_s == cnt_p: # s' 和 p 的每种字母的出现次数都相同
ans.append(left) # s' 左端点下标加入答案
cnt_s[s[left]] -= 1 # 左端点字母离开窗口
return ans

下面的方法是通过比较右侧值保证[left,right]内有cnt的内容

1.维护一个有条件的滑动窗口; 2.右端点右移,导致窗口扩大,是不满足条件的罪魁祸首; 3.左端点右移目的是为了缩小窗口,重新满足条件

如果出现一个非p的字符,cnt[c]就为-1,left会移到right+1的位置,right下一个循环也是right+1。只有字符和次数都和p正好相同,left才不会移动,right进行右移扩大范围到长度为p

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def findAnagrams(self, s: str, p: str) -> List[int]:
ans = []
cnt = Counter(p) # 统计 p 的每种字母的出现次数
left = 0
for right, c in enumerate(s):
cnt[c] -= 1 # 右端点字母进入窗口
while cnt[c] < 0: # 字母 c 太多了
cnt[s[left]] += 1 # 左端点字母离开窗口
left += 1
if right - left + 1 == len(p): # s' 和 p 的每种字母的出现次数都相同
ans.append(left) # s' 左端点下标加入答案
return ans

31:437. 路径总和 III dfs

路径必须从父节点到子节点

前缀和+回溯/DFS

dfs(node, presum)以node为最后节点的,节点和等于目标和的路径数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def pathSum(self, root: Optional[TreeNode], targetSum: int) -> int:
def dfs(node: Optional[TreeNode], presum: int) -> int:
if not node: return 0 # 空节点,满足条件路径数为0
presum += node.val # 更新节点和

path_cnt = presum_counts.get(presum - targetSum, 0) # 从哈希表中获取能和presum配对的前缀和个数
presum_counts[presum] = presum_counts.get(presum, 0) + 1 # 将当前前缀和加入哈希表
path_cnt += dfs(node.left, presum) + dfs(node.right, presum) # 递归处理左右子树
presum_counts[presum] -= 1 # 选择这个节点组成target的情况已经遍历完了,回溯方便下一个节点选择

return path_cnt # 返回总路径数

presum_counts = {0 : 1} # 记录当前路径上出现的前缀和以及数量, 有一个默认的前缀和0
return dfs(root, 0) # 从根节点开始搜索

32:416. 分割等和子集 dp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def canPartition(self, nums: List[int]) -> bool:
#dp[i][j]选择前i个数组成j的方案数,dp[i][j]=dp[i-1][j]+dp[i-1][j-nums[i]]
s = sum(nums)
n = len(nums)
if s % 2:
return False
num = s // 2
dp = [[0] * (num + 1) for _ in range(n + 1)] #一般把0空出来比较好,避免下标越界问题
dp[0][0] = 1
for i in range(1, n + 1):
for j in range(num + 1):
if j >= nums[i - 1]:
dp[i][j] = dp[i - 1][j] + dp[i - 1][j - nums[i - 1]]
else:
dp[i][j] = dp[i - 1][j]
return False if dp[n][num] == 0 else True

33:406. 根据身高重建队列 sort

people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好ki 个身高大于或等于 hi 的人。

要把乱序的数组排列成实际身高的队形

一般这种数对,还涉及排序的,根据第一个元素正向排序,根据第二个元素反向排序,或者根据第一个元素反向排序,根据第二个元素正向排序,往往能够简化解题过程。

这里首先按照数对的元素 1 降序排序,按照数对的元素 2 升序排序。保证身高高的在前面,以及同样身高的排在前面的在前面(因为>=的人少),这样也保证在插队时不会有数量错误

1
2
3
4
5
6
7
8
9
10
class Solution:
def reconstructQueue(self, people: List[List[int]]) -> List[List[int]]:
res = []
people = sorted(people, key = lambda x: (-x[0], x[1]))#key可以指定字符串排序的方式
for p in people:
if len(res) <= p[1]:#在最后加入,前面的人的个数是符合记录的
res.append(p)
elif len(res) > p[1]:
res.insert(p[1], p) #插队,在p[1]的位置插入p
return res

34:399. 除法求值 dfs

返回 所有问题的答案 。如果存在某个无法确定的答案,则用 -1.0 替代这个答案。如果问题中出现了给定的已知条件中没有出现的字符串,也需要用 -1.0 替代这个答案。

建立过渡变量,用图的形式构造双向边(dict:{}),值作为权重

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution:
def calcEquation(self, equations: List[List[str]], values: List[float], queries: List[List[str]]) -> List[float]:
# 构造图,equations的第一项除以第二项等于value里的对应值,第二项除以第一项等于其倒数(构造双向边)
graph = {}
for (x, y), v in zip(equations, values):
if x in graph:
graph[x][y] = v
else:
graph[x] = {y: v}
if y in graph:
graph[y][x] = 1/v
else:
graph[y] = {x: 1/v}

# dfs找寻从s到t的路径并返回结果叠乘后的边权重即结果
def dfs(s, t) -> int:
if s not in graph:
return -1
if t == s:
return 1
for node in graph[s].keys():
if node == t:#直接连接
return graph[s][node]
elif node not in visited:
visited.add(node) # 添加到已访问避免重复遍历
v = dfs(node, t)
if v != -1:#说明走通了,该场景并没有多路
return graph[s][node]*v
return -1

# 逐个计算query的值
res = []
for qs, qt in queries:
visited = set()#记录遍历过的节点
res.append(dfs(qs, qt))
return res

35:394. 字符串解码 stack

难点在于嵌套括号,以及数字可能也是多位数,用栈!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def decodeString(self, s: str) -> str:
stack, res, multi = [], "", 0 #stack存
for c in s:
if c == '[':
stack.append([multi, res])#存的是数字之前的字符串,不会变
res, multi = "", 0 #res开始存需要重复的字符串,同时可能继续遇到multi
elif c == ']':
cur_multi, last_res = stack.pop()
res = last_res + cur_multi * res
elif '0' <= c <= '9':#数字不一定是个位数,所以*10
multi = multi * 10 + int(c)
else:
res += c
return res

36:347. 前 K 个高频元素 heap

先获得频率,再创建小顶堆。如果比k个数的最小值大,就更换数据

heapq会自动把最小的frequency放在开头,因为封装了最小堆,从小到大排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import heapq
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
map_ = {}
for num in nums:
map_[num] = map_.get(num, 0) + 1
heap = [] #从小到大排前k高的元素,是模块提供的小顶堆(大顶堆用负数模拟)
for num, freq in map_.items():
if len(heap) < k:
heapq.heappush(heap, (freq, num))
else:#比较当前值和堆顶的频率最小值哪个大
if freq > heap[0][0]:
heapq.heappushpop(heap, (freq, num))
res = []
while heap:
res.append(heapq.heappop(heap)[1])#注意加入的是值不是频率
return res

37:338. 比特位计数 dp

内置函数就是bin和count(‘1’)

动态规划:某个偶数肯定能由前面的某个数左移一位得到,如十进制6对应的二进制为110,由十进制3对应二进制11左移一位得到。
某个奇数肯定能由前面的某个数左移一位并加上1得到,如十进制7对应的二进制为111,是由十进制3对应二进制11左移一位为110并加一得到。利用位运算的性质

1
2
3
4
5
6
7
8
class Solution:
def countBits(self, num: int) -> List[int]:
dp=[0]*(num+1)
for i in range(num//2+1):
dp[i*2]=dp[i]
if i*2+1<=num:
dp[i*2+1]=dp[i]+1
return dp

38:337. 打家劫舍 III dp+dfs

对于选择了根,那么我们就不能选它的儿子了
如果没有选根,我们就可以任意选了(即选最大的那一个)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def dp(self,root):
if not root :
return [0,0] #列表[0]代表当前节点不偷带来的钱,列表[1]代表当前节点偷带来的钱
l=self.dp(root.left) #root的左节点[不偷][偷]带来的钱
r=self.dp(root.right) #root的右节点[不偷][偷]带来的钱
#root节点不偷,则可以偷左右儿子节点,但不是一定要偷。取左儿子偷或不偷的最大值和右儿子偷或不偷的最大值;
#root节点偷,则root节点值+左儿子不偷+右儿子不偷。
return [max(l)+max(r), root.val+l[0]+r[0]]

def rob(self, root: TreeNode) -> int:
if not root:
return 0
return max(self.dp(root)) #取root节点偷或不偷的最大值

39:121. 买卖股票的最佳时机 trick

1
2
3
4
5
6
7
8
class Solution:
def maxProfit(self, prices: List[int]) -> int:
ans = 0 #存最大利润
minPrice = prices[0]#需要知道第i天之前,股票价格的最小值,作为买入价格
for p in prices:
ans = max(ans, p - minPrice)
minPrice = min(minPrice, p)
return ans

40:312. 戳气球

f[i][j] 表示戳破区间 (i,j) 内的所有气球能得到的最多硬币数,开区间

如果k是最后一个戳破的气球,那得到的硬币数量是确定的

f[i][j]=max(f[i][j],f[i][k]+f[k][j]+arr[i]×arr[k]×arr[j]) k∈(i,j) 引入k,非常巧妙

确定ij的遍历顺序:f[i][j]依赖于f[k][j],k>i,所以i要从大到小;f[i][k]<f[i][j],j要从从小到大

1
2
3
4
5
6
7
8
9
10
class Solution:
def maxCoins(self, nums: List[int]) -> int:
n = len(nums)
arr = [1] + nums + [1] #方便处理边界情况
f = [[0] * (n + 2) for _ in range(n + 2)]
for i in range(n - 1, -1, -1): #i要倒序
for j in range(i + 2, n + 2):#因为要有k的位置,所以j至少是i+2
for k in range(i + 1, j): #i<k<j
f[i][j] = max(f[i][j], f[i][k] + f[k][j] + arr[i] * arr[k] * arr[j])
return f[0][-1] #答案是f[0][n+1]

41:309. 买卖股票的最佳时机含冷冻期 dp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def maxProfit(self, prices: List[int]) -> int:
if len(prices) < 2: return 0

dp0 = 0 # 手里没股票,没有处于冷冻期
dp1 = float("-inf") # 手里没股票,并且处于冷冻期
dp2 = - prices[0] # 手里有股票

for i in range(1, len(prices)):
new_dp0 = max(dp0, dp1)#可能解冻了
new_dp1 = dp2 + prices[i] #必然是昨天卖出了
new_dp2 = max(dp2, dp0 - prices[i])#可能0状态买入了
dp0, dp1, dp2 = new_dp0, new_dp1, new_dp2
return max(dp0, dp1)#一定是手里没有股票赚的钱最多,因此最后返回dp0和dp1的最大值

42:301. 删除无效的括号 DFS

为什么判断cr>cl?如果右比左多,无论后面怎么添加字符,都无法形成有效的括号组合(因为 ')' 必须在 '(' 之后出现)。

但是在遍历过程中,'(' 可以暂时多于 ')'(例如 "(()" 是部分有效的,后面可能补上 ')')。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution:
def removeInvalidParentheses(self, s: str) -> List[str]:
l = r = 0 #l是多余的左括号,r是多余的右括号
for c in s:
if c == '(':
l += 1
elif c == ')':
if l:
l -= 1
else:
r += 1
ans = []

@cache #idx是当前处理的字符索引,cl/cr是当前路径中的()
def dfs(idx, cl, cr, dl, dr, path):#dl/dr:剩余需要删除的()
if idx == len(s):
if not dl and not dr:
ans.append(path)
return
if cr > cl or dl < 0 or dr < 0:#删多了
return
c = s[idx]#当前处理的字符
if c == '(':#可以选择删除它或保留它
dfs(idx+1,cl,cr,dl-1,dr, path)#删除
elif c == ')':#可以选择删除它或保留它
dfs(idx+1,cl,cr,dl,dr-1, path)#删除
dfs(idx+1,cl+(c=='('),cr+(c==')'),dl,dr, path+c)#保留

dfs(0, 0, 0, l, r, "")
return ans

为什么可以保证删除最小数量的无效括号?由于 dldr 的限制,DFS 只会探索恰好删除 l'('r')' 的路径。如果有更少的删除的话,最终字符串一定有多余的括号。其实我们是通过合法字符串的规则进行了约束。

43:300. 最长递增子序列 dp

dp表示以 nums[i] 结尾的最长子序列长度。

1
2
3
4
5
6
7
8
class Solution:
def lengthOfLIS(self, nums: List[int]) -> int:
dp = [1] * len(nums)
for i in range(len(nums)):
for j in range(0, i):
if nums[i] > nums[j]:
dp[i] = max(dp[i], dp[j] + 1)
return max(dp)#注意返回的是数组的最大值,并不是要以最后一个字符结尾

44:297. 二叉树的序列化与反序列化

将转换后的数据存储在一个文件或者内存中

  • 序列化:将二叉树转换为一个字符串(或比特位序列),以便可以存储在文件中或通过网络传输。
  • 反序列化:将序列化后的字符串重新构造成原始的二叉树。

用层序遍历的话还是挺直观的 空也没有引发复杂的写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Codec:
def serialize(self, root):
if not root:
return "[]"
queue = deque([root]) #用层序遍历
res = []
while queue:
node = queue.popleft()
if node:
res.append(str(node.val))
queue.append(node.left)
queue.append(node.right)
else:
res.append("null") #如果为空也会输出
while res and res[-1] == "null": #但是最后的空要被删除
res.pop()
return "[" + ",".join(res) + "]" #要返回字符串,所以不能直接返回res

def deserialize(self, data):
if data == "[]":
return None
values = data[1:-1].split(",")#把字符串变成list
root = TreeNode(int(values[0]))#第一个数是根节点
queue = deque([root])#这里的queue存的是树节点
i = 1
while queue and i < len(values):
node = queue.popleft()
if i < len(values) and values[i] != "null":#左节点 如果非空可以添加(空的不添加自动为空)
node.left = TreeNode(int(values[i]))
queue.append(node.left)
i += 1
if i < len(values) and values[i] != "null":
node.right = TreeNode(int(values[i]))
queue.append(node.right)
i += 1
return root

45:87. 寻找重复数 二分

已知数字都在[1, n]范围内,有n+1个整数,根据抽屉原理至少存在一个重复的整数

用二分法(知道最大最小界),数在范围里的数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def findDuplicate(self, nums: List[int]) -> int:
min_val = 1 # 所查找数字范围的最小值
max_val = len(nums) # 所查找数字范围的最大值

while min_val < max_val:
mid = (min_val + max_val) // 2
# 计数
cnt = sum(min_val <= num <= mid for num in nums)#数在这个区间里的数

if cnt > mid - min_val + 1: # 个数超出范围长度,即存在重复数
max_val = mid
else:
min_val = mid + 1

return min_val

46:283. 移动零 双指针

因为要保持原数组非零元素的顺序,用双指针

l 移动到自身右侧第一个元素为 0 的位置,将 r 移动到 l 右侧第一个元素非 0 的位置,然后交换元素

283_3.png

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def moveZeroes(self, nums: List[int]) -> None:
l = 0
r = 0

while r < len(nums):
if r == l or nums[r] == 0: # 优先检测 r,确保 r 在 l 右侧, 让 r 指向非0
r += 1
elif nums[l] != 0: #让l指向0
l += 1
else:
nums[l] = nums[r]#把0赋值为非0
nums[r] = 0

47:279. 完全平方数 dp

完全平方数满足n^2,用最少的完全平方数使和为n,也是DP

DFS都可以1:1翻译成递推,往往再进行空间优化

1
2
3
4
5
6
7
8
9
N = 10000
f = [0] + [inf] * N
for i in range(1, isqrt(N) + 1):
for j in range(i * i, N + 1):
f[j] = min(f[j], f[j - i * i] + 1) # 不选 vs 选

class Solution:
def numSquares(self, n: int) -> int:
return f[n]

为什么f写在类的外面而不是函数内就不会超时:作为全局变量不用重复计算

在程序启动时,f 数组被计算一次(时间复杂度 O(N√N)),之后所有测试用例的 numSquares(n) 查询都是 O(1)

48:253.会议室II 堆

img时间间隔问题,按照会议的开始时间进行排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import heapq

def minMeetingRooms(intervals):
# 如果会议安排列表为空,直接返回0
if not intervals:
return 0

# 初始化一个空的最小堆
free_rooms = []

# 先根据会议的开始时间对会议进行排序
intervals.sort(key=lambda x: x[0])

# 将第一个会议的结束时间加入到最小堆中
# 这表示目前我们有一个会议室被占用,直到这个时间点
heapq.heappush(free_rooms, intervals[0][1])

# 从第二个会议开始遍历
for i in intervals[1:]:

# 如果当前会议的开始时间大于等于最小堆中的最早结束时间
# 说明这个会议室可以被重复使用
# 因此我们可以移除堆顶元素(最早结束的会议室)
if i[0] >= free_rooms[0]:
heapq.heappop(free_rooms)

# 将当前会议的结束时间加入最小堆
# 表示新增一个会议室,或是延续使用原会议室
heapq.heappush(free_rooms, i[1])

# 堆中元素的数量,就是我们需要的会议室数量
return len(free_rooms)

49:240. 搜索二维矩阵 II trick

类似二叉搜索树,3/7这种左下角、右上角元素称为标志数flag,每次比较可以消除一行或一列

  • 若 flag > target ,则 target 一定在 flag 所在 行的上方 ,即 flag 所在行可被消去。
  • 若 flag < target ,则 target 一定在 flag 所在 列的右方 ,即 flag 所在列可被消去。
Picture1.png

我们从左下角开始找:

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def searchMatrix(self, matrix: List[List[int]], target: int) -> bool:
i, j = len(matrix) - 1, 0
while i >= 0 and j < len(matrix[0]):
if matrix[i][j] > target:
i -= 1
elif matrix[i][j] < target:
j += 1
else:
return True
return False

50:239. 滑动窗口最大值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from collections import deque
from typing import List

class Solution:
def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]:
q = deque()
result = []
for i in range(len(nums)):
# 移除不在窗口内的队首元素 保证q中的每个下标都没有超越窗口的左边界
while q and q[0] < i - k + 1:
q.popleft()
# 维护队列递减性质 如果当前元素大于队尾元素 则队尾元素出队 有可能把队列全清空
while q and nums[i] >= nums[q[-1]]:
q.pop()
q.append(i)#我不懂为啥当前的数字非得存进去,只和队尾比较一次不好吗(仔细想和维护top1/2的复杂性是一样的,这样写单调性更简单灵活,不是说非要有2个数)
if i >= k - 1:#其实除了最开始窗口小于k,后面每次都会有一个最大值
result.append(nums[q[0]])
return result

51:22. 括号生成 回溯

n为生成括号的对数

先左括号+让右小于左是为了保证括号组合有效

回溯模板:

1
2
3
4
5
6
7
8
9
10
11
12
def backtrack(路径, 选择列表):
if 满足终止条件:
结果.append(路径.copy()) # 注意深拷贝
return

for 选择 in 选择列表:
if 剪枝条件: # 可选,提前跳过无效选择
continue

做选择 # 将当前选择加入路径
backtrack(路径, 新选择列表) # 递归
撤销选择 # 从路径中移除当前选择

本题注意不等式的细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def generateParenthesis(self, n: int) -> List[str]:
ans = []
def backtrack(S, left, right):#left,right指已使用的括号数量
if len(S) == 2 * n:#总数量达到要求
ans.append(''.join(S))
return
if left < n:#left表示左括号的数量
S.append('(')
backtrack(S, left+1, right)
S.pop()
if right < left:#保证括号有效
S.append(')')
backtrack(S, left, right+1)
S.pop()

backtrack([], 0, 0)
return ans

52:49. 字母异位词分组dict

把输入数组中的字母异位词组合到一起

把字符串排序后字母异位词应该一样

1
2
3
4
5
6
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
d = defaultdict(list) #当访问不存在的key时,会创建空list作为该key的value。适用于分组
for s in strs:
d[''.join(sorted(s))].append(s) # sorted(s) 相同的字符串分到同一组
return list(d.values())

53:48. 旋转图像 trick

原地旋转二维矩阵,不采用额外的空间

如果用拷贝,记得用深拷贝!

1
2
3
4
5
6
7
8
9
class Solution:
def rotate(self, matrix: List[List[int]]) -> None:
n = len(matrix)
# 深拷贝 matrix -> tmp,若是普通赋值,matrix变化了tmp也会变
tmp = copy.deepcopy(matrix)
# 根据元素旋转公式,遍历修改原矩阵 matrix 的各元素
for i in range(n):
for j in range(n):
matrix[j][n - 1 - i] = tmp[i][j]

原地修改时只用看左上角1/4的部分,就可以实现全局修改

image-20250402121910315

1
2
3
4
5
6
7
8
9
10
class Solution:
def rotate(self, matrix: List[List[int]]) -> None:
n = len(matrix)
for i in range(n // 2):
for j in range((n + 1) // 2):
tmp = matrix[i][j]
matrix[i][j] = matrix[n - 1 - j][i]
matrix[n - 1 - j][i] = matrix[n - 1 - i][n - 1 - j]
matrix[n - 1 - i][n - 1 - j] = matrix[j][n - 1 - i]
matrix[j][n - 1 - i] = tmp

54:46. 全排列dfs

回溯算法:

终止条件:长度为len-1

递推参数:当前固定位x

递推工作:固定nums[i]作为当前位元素

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
def dfs(x):
if x == len(nums) - 1:
res.append(list(nums)) # 添加排列方案
return
for i in range(x, len(nums)):
nums[i], nums[x] = nums[x], nums[i] # 交换,将 nums[i] 固定在第 x 位
dfs(x + 1) # 开启固定第 x + 1 位元素
nums[i], nums[x] = nums[x], nums[i] # 恢复交换
res = []
dfs(0)
return res

55:42. 接雨水 trick

res[i] = min(l_max[i], r_max[i]) - height[i]:min是木桶效应,决定当前柱子i能承载的最高水位;减去height是因为柱子本身会占据一定高度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def trap(self, height: List[int]) -> int:
if not height:
return 0

n = len(height)
l_max = [0] * n
r_max = [0] * n

# 计算 l_max
l_max[0] = height[0]
for i in range(1, n):
l_max[i] = max(l_max[i-1], height[i])

# 计算 r_max
r_max[-1] = height[-1]
for i in range(n-2, -1, -1):
r_max[i] = max(r_max[i+1], height[i])

# 计算总雨水量
res = 0
for i in range(n):
res += min(l_max[i], r_max[i]) - height[i]

return res

56:39. 组合总和 回溯

dfs(i, left) i是当前选择的下标,left是剩余的数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
candidates.sort()
ans = []
path = []

def dfs(i: int, left: int) -> None:
if left == 0:
# 找到一个合法组合
ans.append(path.copy())#必须写copy,不然ans所有组合都指向同一个path,这个path最终会被清空为[]
return

if i == len(candidates) or left < candidates[i]:#剪枝:下标不越界+left不能太小
return

# 不选candidates[i]
dfs(i + 1, left)

# 选candidates[i]
path.append(candidates[i])
dfs(i, left - candidates[i])
path.pop() # 恢复现场

dfs(0, target)
return ans

57:543. 二叉树的直径 dfs

求左右子树深度值的最大值

注意返回的是子树的链长

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def diameterOfBinaryTree(self, root: Optional[TreeNode]) -> int:
ans = 0
def dfs(node: Optional[TreeNode]) -> int:
if node is None:
return -1
l_len = dfs(node.left) + 1 # 左子树最大链长+1
r_len = dfs(node.right) + 1 # 右子树最大链长+1
nonlocal ans #让外层函数的变量可以修改
ans = max(ans, l_len + r_len) # 两条链拼成路径
return max(l_len, r_len) # 当前子树最大链长
dfs(root)
return ans

58:34. 在排序数组中查找元素的第一个和最后一个位置 二分

递增的数组

开始位置和结束位置

闭区间的二分写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution:
def lower_bound(self, nums: List[int], target: int) -> int:
left, right = 0, len(nums) - 1 # 闭区间 [left, right]
while left <= right:
mid = (left + right) // 2
if nums[mid] >= target:#这里等于也不能直接return,因为要找最左的target
right = mid - 1 # 范围缩小到 [left, mid-1]
else:
left = mid + 1 # 范围缩小到 [mid+1, right]
return left

def searchRange(self, nums: List[int], target: int) -> List[int]:
start = self.lower_bound(nums, target)
if start == len(nums) or nums[start] != target:
return [-1, -1] # nums 中没有 target
# 如果 start 存在,那么 end 必定存在
end = self.lower_bound(nums, target + 1) - 1 #非常巧妙,因为是递增数列
return [start, end]

59:33. 搜索旋转排序数组 二分

排序数组被旋转了,要找目标值

  • 思路:先找排序数组的最小值,知道目标值在哪一段,然后在那一段进行二分查找
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution:
# 153. 寻找旋转排序数组中的最小值(返回的是下标)
def findMin(self, nums: List[int]) -> int:
left, right = -1, len(nums) - 1 # 开区间 (-1, n-1)
while left + 1 < right: # 开区间不为空
mid = (left + right) // 2
if nums[mid] < nums[-1]:#和最后的值比
right = mid
else:
left = mid
return right

# 有序数组中找 target 的下标
def lower_bound(self, nums: List[int], left: int, right: int, target: int) -> int:
while left + 1 < right: # 开区间不为空
mid = (left + right) // 2
if nums[mid] >= target:
right = mid # 范围缩小到 (left, mid)
else:
left = mid # 范围缩小到 (mid, right)
return right if nums[right] == target else -1

def search(self, nums: List[int], target: int) -> int:
i = self.findMin(nums)
if target > nums[-1]: # target 在第一段
return self.lower_bound(nums, -1, i, target) # 开区间 (-1, i)
# target 在第二段
return self.lower_bound(nums, i - 1, len(nums), target) # 开区间 (i-1, n)

60:32. 最长有效括号 标记+dp

只包含 '('')' 的字符串,找出最长有效(格式正确且连续)括号子串的长度。

先遍历一遍字符串,用栈把左右括号进行匹配,然后计算连续1出现的最大次数即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution:
def longestValidParentheses(self, s: str) -> int:
stack = [] #用于匹配括号
maxL = 0 #记录答案,最长有效括号子串
n = len(s)
tmp = [0] * n #标记字符串s
cur = 0 #中间变量,记录1出现次数

for i in range(n):
if s[i] == '(':
stack.append(i)
else:
if stack:
j = stack.pop()
tmp[i], tmp[j] = 1,1 #匹配成功时标记

for num in tmp: #计算连续1出现的最大次数
if num:
cur += 1
else: #遇到0时中断,进行对比,并重置
maxL = max(cur, maxL)
cur = 0
maxL = max(cur,maxL) #最后一次统计可能未终断,多做一次对比
return maxL

61:31. 下一个排列 trick

下一个排列是字典序更大的排列

最大的字典序是从左到右依次递减的,下一个是最小字典序

否则从右向左寻找第一个严格递减的位置i;然后从右向左寻找第一个严格小于该位置值的位置j;

将i右侧的所有值翻转:

1 2 1 4 2 i = 2,j=4 1 2 1 2 4 1 然后翻转 得到2 1 2 1 4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def nextPermutation(self, nums: List[int]) -> None:
if nums == sorted(nums, reverse=True):
return nums.sort()
n = len(nums)
i, j = n - 2, n - 1
while not nums[i] < nums[i + 1]:#从右到左找第一个递减的位置i
i -= 1
while not nums[j] > nums[i]:#从右向左寻找第一个严格小于该位置值的位置j;
j -= 1
nums[i], nums[j] = nums[j], nums[i]#交换

left, right = i + 1, n - 1
while left < right:
nums[left], nums[right] = nums[right], nums[left]
left += 1
right -= 1

62:538. 把二叉搜索树转换为累加树

看不太懂题,但是先递归右边,再赋给中间,再递归左边

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def convertBST(self, root: TreeNode) -> TreeNode:
s = 0
def dfs(node: TreeNode) -> None:
if node is None:
return
dfs(node.right) # 递归右子树
# 递归结束后,s 就等于右子树的所有节点值之和
nonlocal s
s += node.val
node.val = s # 此时 s 就是 >= node.val 的所有数之和
dfs(node.left) # 递归左子树
dfs(root)
return root

63:23. 合并 K 个升序链表

最小堆:这可以用最小堆实现。初始把所有链表的头节点入堆,然后不断弹出堆中最小节点 x,如果 x.next 不为空就加入堆中。循环直到堆为空。把弹出的节点按顺序拼接起来,就得到了答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ListNode.__lt__ = lambda a, b: a.val < b.val  # 让堆可以比较节点大小

class Solution:
def mergeKLists(self, lists: List[Optional[ListNode]]) -> Optional[ListNode]:
cur = dummy = ListNode() # 哨兵节点,作为合并后链表头节点的前一个节点
h = [head for head in lists if head] # 把所有非空链表的头节点入堆
heapify(h) # 堆化
while h: # 循环直到堆为空
node = heappop(h) # 剩余节点中的最小节点
if node.next: # 下一个节点不为空
heappush(h, node.next) # 下一个节点有可能是最小节点,入堆
cur.next = node # 把 node 添加到新链表的末尾
cur = cur.next # 准备合并下一个节点
return dummy.next # 哨兵节点的下一个节点就是新链表的头节点

64:560. 和为 K 的子数组 前缀和

注意要求子数组是数组中连续的非空序列,所以要用前缀和

1
2
3
4
5
6
7
8
9
10
class Solution:
def subarraySum(self, nums: List[int], k: int) -> int:
ans = s = 0
cnt = defaultdict(int)#对于没有的key默认value为0
cnt[0] = 1 # s[0]=0 单独统计
for x in nums:
s += x #s是前缀和
ans += cnt[s - k] #ans+=cnt[-1]
cnt[s] += 1 #说明有一种方案达到和为s
return ans

65:21. 合并两个有序链表 递推

1
2
3
4
5
6
7
8
9
10
class Solution:
def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]:
if not list1: return list2 # 终止条件,直到两个链表都空
if not list2: return list1
if list1.val <= list2.val: # 递归调用
list1.next = self.mergeTwoLists(list1.next, list2)
return list1
else:
list2.next = self.mergeTwoLists(list1, list2.next)
return list2

66:20. 有效的括号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution:
def isValid(self, s: str) -> bool:
st = []
for c in s:
if c == "]" and st:
tmp = st.pop()
if tmp != "[":
return False
elif c == "}" and st:
tmp = st.pop()
if tmp != "{":
return False
elif c == ")" and st:
tmp = st.pop()
if tmp != "(":
return False
else:
st.append(c)
return False if st else True

67:19.删除链表的倒数第 N 个结点 快慢指针

注意是倒数第n个,而不是正数第n个,所以slow才能找到倒数第一个

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def removeNthFromEnd(self, head: Optional[ListNode], n: int) -> Optional[ListNode]:
dummy = ListNode(0, head)
fast = slow = dummy #注意可以写连等
for _ in range(n + 1):
fast = fast.next
while fast:
fast = fast.next
slow = slow.next
slow.next = slow.next.next
return dummy.next

68:17. 电话号码的字母组合 回溯

符合回溯模板,for遍历选择,+letter写在参数里面了所以没有+ -的添加和移除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution:
def letterCombinations(self, digits: str) -> List[str]:
if not digits: return []

phone = {'2':['a','b','c'],
'3':['d','e','f'],
'4':['g','h','i'],
'5':['j','k','l'],
'6':['m','n','o'],
'7':['p','q','r','s'],
'8':['t','u','v'],
'9':['w','x','y','z']}#创建dict

def backtrack(conbination, nextdigit):
if len(nextdigit) == 0:
res.append(conbination)
else:
for letter in phone[nextdigit[0]]:
backtrack(conbination + letter,nextdigit[1:])

res = []
backtrack('', digits)
return res

69:15. 三数之和 排序+双指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
res = []
nums.sort()
for i in range(len(nums)):
if nums[i] > 0:
return res
if i > 0 and nums[i] == nums[i - 1]:
continue
left = i + 1
right = len(nums) - 1
while left < right:
sum_ = nums[i] + nums[left] + nums[right]
if sum_ < 0:
left += 1
elif sum_ > 0:
right -= 1
else:
res.append([nums[i], nums[left], nums[right]])
while right > left and nums[right] == nums[right - 1]:#关键是去除重复解
right -= 1
while right > left and nums[left] == nums[left + 1]:
left += 1
right -= 1
left += 1
return res

70:11. 盛最多水的容器 双指针

S(i,j)=min(h[i],h[j])×(ji)

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def maxArea(self, height: List[int]) -> int:
i, j, res = 0, len(height) - 1, 0
while i < j:
if height[i] < height[j]:
res = max(res, height[i] * (j - i))
i += 1
else:
res = max(res, height[j] * (j - i))
j -= 1
return res

71:10. 正则表达式匹配 没懂

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution:
def isMatch(self, s: str, p: str) -> bool:
m, n = len(s) + 1, len(p) + 1
dp = [[False] * n for _ in range(m)]
dp[0][0] = True
for j in range(2, n, 2):
dp[0][j] = dp[0][j - 2] and p[j - 1] == '*'
for i in range(1, m):
for j in range(1, n):
dp[i][j] = dp[i][j - 2] or dp[i - 1][j] and (s[i - 1] == p[j - 2] or p[j - 2] == '.') \
if p[j - 1] == '*' else \
dp[i - 1][j - 1] and (p[j - 1] == '.' or s[i - 1] == p[j - 1])
return dp[-1][-1]

72:5. 最长回文子串

使用双指针从中心往两边扩展

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution:
def longestPalindrome(self, s: str) -> str:
# 首先检查输入的字符串是否为空,如果为空则直接返回空字符串
if s == '':
return ''
n = len(s)
start = end = 0 #记录最长的子串的节点
for i in range(n):
# 处理奇数长度的回文子串
left = right = i #以 s[i] 为中心,向两边扩展,直至左右字符不相等。
while left >= 0 and right < n and s[left] == s[right]:
left -= 1
right += 1
cur_len = right - left - 1
if cur_len > end - start + 1:
start = left + 1
end = right - 1

# 处理偶数长度的回文子串
left, right = i, i+1 #以 s[i] 和 s[i+1] 为中心,向两边扩展,直至左右字符不相等。
while left >= 0 and right < n and s[left] == s[right]:
left -= 1
right += 1
cur_len = right - left - 1
if cur_len > end - start + 1:
start = left + 1
end = right - 1
return s[start:end+1]

73:4. 寻找两个正序数组的中位数

逻辑是第k小的数,要么在nums1的前k个数,要么在nums2的前k个数。利用二分思想,每次可以删k/2个不可能的数,对比当前n1[k/2]和n2[k/2](减去删除过的数index)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution:
def getKthElement(self, nums1: List[int], nums2: List[int], k: int) -> int:
"""找到两个已排序数组中的第 k 小的元素"""
m, n = len(nums1), len(nums2)
index1, index2 = 0, 0 # 记录当前数组的起始位置

while True:
# 边界情况(数组已经删光了或者求最小的数)
if index1 == m:#说明Nums1已经检查完了,第k小值在nums2里面
return nums2[index2 + k - 1]
if index2 == n:
return nums1[index1 + k - 1]
if k == 1:#减到最小值了,比较当前开头的值即可
return min(nums1[index1], nums2[index2])

# 正常情况
newIndex1 = min(index1 + k // 2 - 1, m - 1) # 记得用 min
newIndex2 = min(index2 + k // 2 - 1, n - 1)
pivot1 = nums1[newIndex1]
pivot2 = nums2[newIndex2]
if pivot1 <= pivot2:#说明nums1的前k/2个不可能是,要移动指针到没检查过的部分
k -= newIndex1 - index1 + 1
index1 = newIndex1 + 1
else:
k -= newIndex2 - index2 + 1
index2 = newIndex2 + 1

def findMedianSortedArrays(self, nums1: List[int], nums2: List[int]) -> float:
"""计算两个已排序数组的中位数"""
totalLength = len(nums1) + len(nums2)
if totalLength % 2 == 1: #奇数,中位数就是第n+1//2小的数
return self.getKthElement(nums1, nums2, (totalLength + 1) // 2)
else:#偶数,中位数就是第n//2小和第n//2+1的数
return (self.getKthElement(nums1, nums2, totalLength // 2) +
self.getKthElement(nums1, nums2, totalLength // 2 + 1)) / 2.0

74:3. 无重复字符的最长子串

子串是连续的

滑动窗口+哈希表

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def lengthOfLongestSubstring(self, s: str) -> int:
dic = {}
res = 0
i = -1
for j in range(len(s)):
if s[j] in dic:
i = max(dic[s[j]], i) # 更新左指针 i 保证指针不会往左跳
dic[s[j]] = j # 更新哈希表记录,最新的值为s[j]的下标j
res = max(res, j - i) # 更新结果
return res

75:2. 两数相加 递推

1
2
3
4
5
6
7
8
9
10
class Solution:
def addTwoNumbers(self, l1: Optional[ListNode], l2: Optional[ListNode], carry=0) -> Optional[ListNode]:
if l1 is None and l2 is None:
return ListNode(carry) if carry else None # 如果进位了,就额外创建一个节点
if l1 is None: # 如果 l1 是空的,那么此时 l2 一定不是空节点
l1, l2 = l2, l1
s = carry + l1.val + (l2.val if l2 else 0) # 节点值和进位加在一起
l1.val = s % 10 # 直接修改原链表
l1.next = self.addTwoNumbers(l1.next, l2.next if l2 else None, s // 10) # 进位
return l1 #把答案保存在l1中

76:79. 单词搜索

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def exist(self, board: List[List[str]], word: str) -> bool:
def dfs(i, j, k):
if not 0 <= i < len(board) or not 0 <= j < len(board[0]) or board[i][j] != word[k]: return False
if k == len(word) - 1: return True
board[i][j] = '' #修改为空字符,表示元素已经访问过,防止搜索时重复访问
#上下左右找下一个字符
res = dfs(i + 1, j, k + 1) or dfs(i - 1, j, k + 1) or dfs(i, j + 1, k + 1) or dfs(i, j - 1, k + 1)
board[i][j] = word[k] #回溯,恢复
return res

for i in range(len(board)):
for j in range(len(board[0])):
if dfs(i, j, 0): return True
return False

77:114. 二叉树展开为链表

链表应该是先序遍历

头插法反过来了:右左中,倒着在插

1
2
3
4
5
6
7
8
9
10
11
class Solution:
head = None

def flatten(self, root: Optional[TreeNode]) -> None:
if root is None:
return
self.flatten(root.right)
self.flatten(root.left)
root.left = None
root.right = self.head # 头插法,相当于链表的 root.next = head
self.head = root # 现在链表头节点是 root

78:621. 任务调度器

假如 只有 任务 A 且出现了 freq_A 次,那么执行这些任务的最少时间是:(freq_A - 1) * (n+1) + 1.

假设有多种任务,且需要填充空闲,A 还是出现次数最多的任务。此时 ans = (freq_A-1) * (n+1) + 1 + p p 等于和 A 出现次数一致的数目。

first_freq就是出现次数最多的任务的执行次数

1
2
3
4
5
6
7
8
9
class Solution:
def leastInterval(self, tasks: List[str], need: int) -> int:
n, c = len(tasks), Counter(tasks)
most = c.most_common() #出现次数最多的任务
first_freq, cnt = most[0][1], 1
for i in range(1, len(most)):
if most[i][1] == first_freq: cnt += 1 #因为most[i][1]只会<=first_freq,<时直接填补冻结,=时在最后加一步
res = (first_freq - 1) * (need+1) + cnt
return res if res >= n else n #这里也是有必要的,因为上面主要在处理有冻结的情况,如果res<n说明其他任务不够填补冻结

79:617. 合并二叉树

合并的规则是:如果两个节点重叠,那么将这两个节点的值相加作为合并后节点的新值;否则,不为 null 的节点将直接作为新二叉树的节点。

1
2
3
4
5
6
7
class Solution:
def mergeTrees(self, root1: Optional[TreeNode], root2: Optional[TreeNode]) -> Optional[TreeNode]:
if root1 is None: return root2
if root2 is None: return root1
return TreeNode(root1.val + root2.val, #合并后的新节点
self.mergeTrees(root1.left, root2.left), # 合并左子树
self.mergeTrees(root1.right, root2.right)) # 合并右子树

80:105. 从前序与中序遍历序列构造二叉树

知道前序(中左右)和中序(左中右),然后递推

1
2
3
4
5
6
7
8
class Solution:
def buildTree(self, preorder: List[int], inorder: List[int]) -> Optional[TreeNode]:
if not preorder: # 空节点
return None
left_size = inorder.index(preorder[0]) # 左子树的大小
left = self.buildTree(preorder[1: 1 + left_size], inorder[:left_size])
right = self.buildTree(preorder[1 + left_size:], inorder[1 + left_size:])
return TreeNode(preorder[0], left, right)

81:104. 二叉树的最大深度

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def maxDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
if not root.left and not root.right:
return 1
l, r = 1, 1
if root.left:
l = self.maxDepth(root.left) + 1
if root.right:
r = self.maxDepth(root.right) + 1
return max(l, r)

82:102. 二叉树的层序遍历

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution:
def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
if not root:
return []
queue = collections.deque([root])#创建队列并加入root
result = []#记录结果的list
while queue:
level = []#当前层的内容,可看作子数组
for _ in range(len(queue)):
cur = queue.popleft()
level.append(cur.val)
if cur.left:
queue.append(cur.left)
if cur.right:
queue.append(cur.right)
result.append(level)
return result

83:101. 对称二叉树

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def isSymmetric(self, root: Optional[TreeNode]) -> bool:
if not root:
return True
return self.compare(root.left, root.right)

def compare(self, left, right):
if left == None and right != None: return False
elif left != None and right == None: return False
elif left == None and right == None: return True
#两个节点都不为空时
elif left.val != right.val: return False
#当前两个节点相等,就比较子节点的外侧和内侧
outside = self.compare(left.left, right.right)
inside = self.compare(left.right, right.left)
return outside and inside

84:98. 验证二叉搜索树

前序遍历

1
2
3
4
5
6
7
8
class Solution:
def isValidBST(self, root: Optional[TreeNode], left=-inf, right=inf) -> bool:
if root is None:
return True
x = root.val
return left < x < right and \
self.isValidBST(root.left, left, x) and \
self.isValidBST(root.right, x, right)

85:96. 不同的二叉搜索树

f(3) = f(0)*f(2) + f(1)*f(1) + f(2)*f(0)

f(n) = Σf(i)*f(n-1-i)

1
2
3
4
5
6
7
8
9
10
11
12
class Solution:
def numTrees(self, n: int) -> int:
store = [1,1] #f(0),f(1)
if n <= 1:
return store[n]
for m in range(2,n+1):
s = m-1
count = 0
for i in range(m):
count += store[i]*store[s-i]
store.append(count)
return store[n]

86:94. 二叉树的中序遍历

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]:
res = []
def traverse(node: Optional[TreeNode]):
if node is None:
return
traverse(node.left)
res.append(node.val)
traverse(node.right)
traverse(root)
return res

87:85. 最大矩形

前缀和:预处理数组的技术,用于快速计算数组中任意区间的和。使得后续的区间和查询可以在 O(1) 时间内完成。

单调栈:满足栈内元素单调递增或单调递减的性质,通常用于解决“下一个更大/更小元素”问题

这里栈中存储的索引对应的值是单调递增的

注意为什么是求完一层j的前缀和就分析最大面积?以某一层为底,并不是最后一层为底面积就最大

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution:
def maximalRectangle(self, matrix: List[List[str]]) -> int:
if not matrix:
return 0
m, n = len(matrix), len(matrix[0]) #矩阵的行列
pre = [0] * (n+1) #记录每个位置上方(包含当前位置)连续1的个数,前缀和
res = 0
for i in range(m):
for j in range(n):
# 前缀和 要连续才行 获得每列的连续1的长度
pre[j] = pre[j] + 1 if matrix[i][j] == "1" else 0

# 单调栈
stack = [-1]
for k, num in enumerate(pre):
while stack and pre[stack[-1]] > num:#破坏了栈的单调递增性
index = stack.pop()#以该元素作为高度
res = max(res, pre[index] * (k - stack[-1] - 1))#宽度为[stack[-1],k-1]
stack.append(k)

return res

88:84. 柱状图中最大的矩形

就是没有上一步的前缀和了,直接用单调栈(还是补个0防止下标越界)

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def largestRectangleArea(self, heights: List[int]) -> int:
res = 0
stack = [-1]
heights.append(0)
for k, num in enumerate(heights):
while stack and heights[stack[-1]] > num:#破坏了栈的单调递增性
index = stack.pop()#以该元素作为高度
res = max(res, heights[index] * (k - stack[-1] - 1))
stack.append(k)
return res

89:1. 两数之和

只返回一种答案

1
2
3
4
5
6
7
8
9
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
record = dict() #python中map就是dict
for index, value in enumerate(nums):
if target - value in record:#寻找匹配的key
return [index, record[target - value]]
else:
record[value] = index #key不重复
return []

90:78. 子集

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
res = []
n = len(nums)

def helper(i, tmp):
res.append(tmp)
for j in range(i, n):#回溯的for选择
helper(j + 1,tmp + [nums[j]] )
helper(0, [])
return res

91:76. 最小覆盖子串

滑动窗口:把右指针从左到右移动,当涵盖时while移动左指针,找到更短的子串。计数器可以比较,很方便判断是否涵盖(注意不要求连续)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def minWindow(self, s: str, t: str) -> str:
ans_left, ans_right = -1, len(s)
cnt_s = Counter() # s 子串字母的出现次数
cnt_t = Counter(t) # t 中字母的出现次数

left = 0
for right, c in enumerate(s): # 移动子串右端点
cnt_s[c] += 1 # 右端点字母移入子串
while cnt_s >= cnt_t: # 涵盖
if right - left < ans_right - ans_left: # 找到更短的子串
ans_left, ans_right = left, right # 记录此时的左右端点
cnt_s[s[left]] -= 1 # 左端点字母移出子串
left += 1
return "" if ans_left < 0 else s[ans_left: ans_right + 1]

优化:

  1. 用defaultdict比Counter速度更快
  2. less 初始化为 len(cnt),即 t不同字符的个数。例如,t = "AABC" 有 3 个不同字符(ABC),所以 less = 3。表示当前窗口中还有多少种字符没有满足 t 中的要求(即窗口中该字符的出现次数 小于 t 中的出现次数)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution:
def minWindow(self, s: str, t: str) -> str:
ans_left, ans_right = -1, len(s)
cnt = defaultdict(int) # 比 Counter 更快
for c in t:
cnt[c] += 1
less = len(cnt) #t中不同字符的数目

left = 0
for right, c in enumerate(s): # 移动子串右端点
cnt[c] -= 1 # 右端点字母移入子串
if cnt[c] == 0:
less -= 1 #说明字符c满足了t中的要求
while less == 0: # 涵盖:字符串t都在s中出现了 尝试找更短的子串
if right - left < ans_right - ans_left: # 找到更短的子串
ans_left, ans_right = left, right # 记录此时的左右端点
x = s[left] # 左端点字母
if cnt[x] == 0:
less += 1 #因为我们想移除字母x,如果不移除时x满足了t的要求,less在前面会-1,这里移除就要+1
cnt[x] += 1 # 左端点字母移出子串
left += 1
return "" if ans_left < 0 else s[ans_left: ans_right + 1]

92:75. 颜色分类三指针

维护三个指针 p0:指向 0 应该放置的位置;p0左边全是0, p0本身并不包括0

p2:指向 2 应该放置的位置;p2右边全是2, p2本身并不包括2

i:当前遍历位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def sortColors(self, nums: List[int]) -> None:
p0, i , p2 = 0,0,len(nums) - 1
while i <= p2:
if nums[i] == 0:
nums[i], nums[p0] = nums[p0], nums[i]
p0 += 1
i += 1 #这里可以增加,因为p0和i都是从左边开始,nums[p0]一定是0/1
elif nums[i] == 2:
nums[i], nums[p2] = nums[p2], nums[i]
p2 -= 1
# 注意:i 不增加,因为换过来的 nums[i] 还要检查
else:
i += 1

93:72. 编辑距离

dp[i][j] 代表 word1i 位置转换成 word2j 位置需要最少步数

当 word1[i] == word2[j],dp[i][j] = dp[i-1][j-1];

当 word1[i] != word2[j],dp[i][j] = min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1]) + 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution:
def minDistance(self, word1: str, word2: str) -> int:
n1 = len(word1)
n2 = len(word2)
dp = [[0] * (n2 + 1) for _ in range(n1 + 1)] #dp[0][0]为0,不用额外赋值
for j in range(1, n2 + 1):
dp[0][j] = dp[0][j - 1] + 1
for i in range(1, n1 + 1):
dp[i][0] = dp[i - 1][0] + 1
for i in range(1, n1 + 1):
for j in range(1, n2 + 1):
if word1[i - 1] == word2[j - 1]:
dp[i][j] = dp[i - 1][j - 1]
else:
dp[i][j] = min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]) + 1
return dp[-1][-1]

94:70. 爬楼梯

#dp[n] = dp[n - 1] + dp[n - 2]

​ #dp[1] = 1

​ #dp[2] = 2

1
2
3
4
5
6
class Solution:
def climbStairs(self, n: int) -> int:
a, b = 1, 1
for _ in range(n - 1):
a, b = b, a + b
return b

95:581. 最短无序连续子数组 双指针

单调性:右边的数永远大于左边的所有数,左边的数永远小于右边的所有数。

找到的是连续子数组,所以left/right找到存在比左边数小的right和存在比右边数大的left

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution:
def findUnsortedSubarray(self, nums):
left, right, min_num, max_num = 0, 0, float("inf"), float("-inf")

for i, n in enumerate(nums): #正序遍历
if n < max_num:
right = i #在右边的数小于自己左边的数时会得到right,不一定相邻
max_num = max(max_num, n)

for i in range(len(nums) - 1, -1, -1): #倒序遍历
if nums[i] > min_num: #在左边的数大于自己右边的数时会得到right,不一定相邻
left = i
min_num = min(min_num, nums[i])
return 0 if left == right else right - left + 1

96:64. 最小路径和

找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。

1
2
3
4
5
6
7
8
9
10
class Solution:
def minPathSum(self, grid: [[int]]) -> int:
for i in range(len(grid)):
for j in range(len(grid[0])):
if i == j == 0: continue
elif i == 0: grid[i][j] = grid[i][j - 1] + grid[i][j] #朝右走
elif j == 0: grid[i][j] = grid[i - 1][j] + grid[i][j] #朝下走
else: grid[i][j] = min(grid[i - 1][j], grid[i][j - 1]) + grid[i][j] #朝右和朝下走
return grid[-1][-1]

97:62. 不同路径

dfs(i,j)表示从起点 (0,0) 走到 (i,j) 的路径数。

1
2
3
4
5
6
7
8
9
10
11
class Solution:
def uniquePaths(self, m: int, n: int) -> int:
@cache # 缓存装饰器,避免重复计算 dfs 的结果(一行代码实现记忆化)
def dfs(i: int, j: int) -> int:
if i < 0 or j < 0:
return 0
if i == 0 and j == 0: #这是因为呆在原地也算一种
return 1
return dfs(i - 1, j) + dfs(i, j - 1)
return dfs(m - 1, n - 1)

98:56. 合并区间

按照左端点排序

1
2
3
4
5
6
7
8
9
10
class Solution:
def merge(self, intervals: List[List[int]]) -> List[List[int]]:
intervals.sort(key=lambda p: p[0]) # 按照左端点从小到大排序
ans = []
for p in intervals:
if ans and p[0] <= ans[-1][1]: # 可以合并
ans[-1][1] = max(ans[-1][1], p[1]) # 更新右端点最大值
else: # 不相交,无法合并
ans.append(p) # 新的合并区间
return ans

99:55. 跳跃游戏

思路:尽可能到达最远的位置。最远能到达某个位置,就一定能到达它前面的任何位置。

1
2
3
4
5
6
7
8
9
10
class Solution:
def canJump(self, nums: List[int]) -> bool:
max_i = 0 #初始化当前能到达最远的位置
for i, jump in enumerate(nums): #i为当前位置,jump是当前位置的跳数
if max_i >= i and i + jump > max_i: #如果当前位置能到达,并且当前位置+跳数>最远位置
max_i = i + jump #更新最远能到达位置
# 提前结束
if max_i< i:
return False
return True #i是最后一个下标

100:53. 最大子数组和

1
2
3
4
5
6
7
8
9
class Solution:
def maxSubArray(self, nums: List[int]) -> int:
#dp[i]=max(dp[i-1],0)+nums[i]
ans = -inf
dp = 0
for n in nums:
dp = max(dp, 0) + n
ans = max(ans, dp)
return ans