导图社区 最强力扣算法题目重点思路代码整理
整理了力扣上面的算法题目的主要思路和代码, 此思维导图会持续更新中, 购买的朋友可通过我个人介绍中的博客加我好友, 我会持续提供更新, 也可和我一起探讨算法问题。
编辑于2021-06-29 20:35:23整理了力扣上面的算法题目的主要思路和代码, 此思维导图会持续更新中, 购买的朋友可通过我个人介绍中的博客加我好友, 我会持续提供更新, 也可和我一起探讨算法问题。
整理了东南大学的英语学术写作的考试重点内容, 旨在培养学生的英语学术写作能力,帮助学生在初步掌握写作技巧的基础上把学术论文写得更加规范,为毕业论文的写作及今后学术研究打下坚实基础。
根据B站莫烦的视频教程整理而来, Matplotlib 是 Python 的绘图库。 它可与 NumPy 一起使用,提供了一种有效的 MatLab 开源替代方案。
社区模板帮助中心,点此进入>>
整理了力扣上面的算法题目的主要思路和代码, 此思维导图会持续更新中, 购买的朋友可通过我个人介绍中的博客加我好友, 我会持续提供更新, 也可和我一起探讨算法问题。
整理了东南大学的英语学术写作的考试重点内容, 旨在培养学生的英语学术写作能力,帮助学生在初步掌握写作技巧的基础上把学术论文写得更加规范,为毕业论文的写作及今后学术研究打下坚实基础。
根据B站莫烦的视频教程整理而来, Matplotlib 是 Python 的绘图库。 它可与 NumPy 一起使用,提供了一种有效的 MatLab 开源替代方案。
目录
力扣算法
0.逆转思维
6.Z字形变换
1.按行排序
0.题目
将一个给定字符串根据给定的行数,以从上往下、从左到右进行 Z 字形排列,之后,你的输出需要从左往右逐行读取,产生出一个新的字符串
1.思路
从左到右迭代 ss,将每个字符添加到合适的行。可以使用当前行和当前方向这两个变量对合适的行进行跟踪,只有当我们向上移动到最上面的行或向下移动到最下面的行时,当前方向才会发生改变
2.代码
class Solution { public String convert(String s, int numRows) { if (numRows == 1) return s; List<StringBuilder> rows = new ArrayList<>(); //存储变换后每行的字符 for (int i = 0; i < Math.min(numRows, s.length()); i++) rows.add(new StringBuilder()); int curRow = 0; boolean goingDown = false; for (char c : s.toCharArray()) { //直接遍历s,将其转化为字符数组,并且一一赋值给c rows.get(curRow).append(c); if (curRow == 0 || curRow == numRows - 1) goingDown = !goingDown; curRow += goingDown ? 1 : -1; } StringBuilder ret = new StringBuilder(); for (StringBuilder row : rows) ret.append(row); return ret.toString(); } }
3.复杂度
时间O(n),其中n=len(s),空间O(n)
1.纯算法
7.整数反转
1.题目
给出一个 32 位的有符号整数,你需要将这个整数中每位上的数字进行反转。假设我们的环境只能存储得下 32 位的有符号整数,如果反转后整数溢出那么就返回 0
2.思路
要在没有辅助堆栈/数组的帮助下 “弹出” 和 “推入” 数字,我们可以使用数学方法,先取出最后一位,然后除以10,将最后一位去掉,反转数不断将自身乘10后,加上取出的最后一位数,同时先判断是否会溢出
3.代码
class Solution { public int reverse(int x) { int rev = 0; while (x != 0) { int pop = x % 10; //取出x最后一位 x /= 10; //去掉x最后一位 //int占32位时,取值范围-2147483648~2147483647,所以pop>7 或 pop<-8 if (rev > Integer.MAX_VALUE/10 || (rev == Integer.MAX_VALUE / 10 && pop > Integer.MAX_VALUE % 10)) return 0; if (rev < Integer.MIN_VALUE/10 || (rev == Integer.MIN_VALUE / 10 && pop < Integer.MIN_VALUE % 10)) return 0; rev = rev * 10 + pop; //整体向前移动一位后,加上最后一位 } return rev; } }
4.复杂度
时间: O(log(x)), 空间O(1)
2.数组
3.链表
4.字符串
5.二分查找
6.树
1.二叉树
7.广度优先
8.深度优先
9.双指针
10.排序
11.回溯法
12.哈希表
13.栈
14.动态规划
子主题
经典面试题
15,
算法基础
1.时间复杂度
1.定义
时间复杂度是一个函数,它定性描述该算法的运行时间
2.什么是大O
大O用来表示上界的,当用它作为算法的最坏情况运行时间的上界,就是对任意数据输入的运行时间的上界,面试中说道算法的时间复杂度是多少指的都是一般情况
3.不同数据规模的差异
因为大O就是数据量级突破一个点且数据量级非常大的情况下所表现出的时间复杂度,这个数据量也就是常数项系数已经不起决定性作用的数据量,所以我们说的时间复杂度都是省略常数项系数的,是因为一般情况下都是默认数据规模足够的大,基于这样的事实,给出的算法时间复杂的的一个排行如下所示
O(1)常数阶 < O(logn)对数阶 < O(n)线性阶 < O(n^2)平方阶 < O(n^3)(立方阶) < O(2^n) (指数阶)
4.O(logn)中的log是以什么为底
但我们统一说 logn,也就是忽略底数的描述,以2为底n的对数 = 以2为底10的对数 * 以10为底n的对数,而以2为底10的对数是一个常数,可忽略
题目总结
0.有趣
一通coding猛如虎,提交击败百分五
一串思路笑开花,提交击败百分八
1.没懂
1.树
145.后序遍历莫里斯遍历
105.简洁方法中使用index映射为什么不行
2.面试频率
1.二分查找
4,50,33,167,287,315,349,29,153,240,222,327,69,378,410,162,1111,35,34,300,363,350,209,354,278,374,981,174
2.广度优先
200,279,301,199,101,127,102,407,133,107,103,126,773,994,207,111,847,417,529,130,542,690,,,743,210,913,512
3.哈希表
1,771,3,136,535,138,85,202,149,49,463,739,76,37,347,336,219,18,217,36,349,560,242,187,204,500,811,609
4.回溯法
22,17,46,10,39,37,79,78,51,93,89,357,131,140,77,306,1240,401,126,47,212,60,216,980,44,52,784,526
5.链表
2,21,206,23,237,148,138,141,24,234,445,147,143,92,25,160,328,142,203,19,86,109,83,61,82,430,817,
6.排序
148,56,147,315,349,179,253,164,242,220,75,280,327,973,324,767,350,296,969,57,1329,274,252,1122,493,1057,1152,1086
7.深度优先
200,104,1192,108,301,394,100,105,695,959,124,99,979,199,110,101,114,109,834,116,679,339,133,,,257,546,364,
8.树
104,226,96,617,173,1130,108,297,100,105,95,124,654,669,99,979,199,110,236,101,235,114,94,222,102,938,437
9.数组
1,4,11,42,53,15,121,238,561,85,169,66,88,283,16,56,122,48,31,289,41,128,152,54,26,442,39
10.双指针
11,344,3,42,15,141,88,283,16,234,26,76,27,167,18,287,349,28,142,763,19,30,75,86,345,125,457,350
11.栈
42,20,85,155,739,173,1130,316,394,341,150,224,94,84,770,232,71,496,103,144,636,856,907,682,975,503,225,145
12字符串
5,20,937,3,273,22,1249,68,49,415,76,10,17,91,6,609,93,227,680,767,12,8,67,126,13,336,
子主题
相似题目
1.两数之和
1,15,
代码相关
1.代码风格
1.核心准则
1.缩进
首选使用4个空格。 目前几乎所有的IDE都是默认tab转为4个空格,没有大问题
2.行的最大长度
79个字符,换行用反斜杠会更好看些
曾经笔者认为这是个“过时”的建议,目前做开发基本都是大屏幕,写代码全屏的时候编辑器足以容纳120字符一行或者更长。但是如果要在web上比较两次提交的代码差异,显然是会导致代码换行,或者如果左右滑动,增加了比较的难度,在多年实践之后(2016-2020),所以目前还是使用建议的最大行长度
3.导入
1.格式
导入位于文件顶部,在文件注释之后,导入通常是单独一行 import 或 from ... import
2.顺序
1.标准库导入 2.相关的第三方导入 3.特定的本地应用/库导入 在每个导入组之间放一行空行。 推荐绝对导入,因为它们更易读;处理复杂包布局时明确的相对导入可以用来替代绝对导入,因绝对导入过于冗长
3.注意
4.注释
5.文档字符串docstrings
6.命名规范
函数、变量和属性用小写字母拼写,各单词之间用下划线相连,例如lowercase_underscore。 类与异常应该以每个单词首字母大写的形式命名(大驼峰法),例如CapitalizedWord。 受保护的实例属性,应该以单个下划线开头。 私有的实例属性,应该以两个下划线开头。 模块级别的常量,应该全部采用大写字母来拼写,各单词之间用下划线相连。 类中的实例方法(instance method),应该把首个参数命名为self,表示该对象本身。 类方法(cls method)的首个参数,应该命名为cls,表示该类本身
2.注重细节
0.工具
1.代码换行
换行应该在二元操作符前面
income = (gross_wages + taxable_interest + (dividends - qualified_dividends) - ira_deduction - student_loan_interest)
2.空行
顶级函数和类的定义之间有两行空行。 类内部的函数定义之间有一行空行
3.字符串引号
双引号和单引号是一致的,但是最好遵循一种风格,笔者习惯于使用单引号,因为: 写代码的时候不需要按住Shift键,提高效率 有的语言字符串必须要使用双引号,Python处理时不需要再加反斜杠转义
4.空格
使用space来表示缩进,而不要使用tab键。 和语法相关的每一层缩进都使用四个空格。 对于占据多行的长表达式来说,除了首行之外的其余各行都应该在通常的缩进级别之上再加4个空格。 在使用下标来获取列表元素、调用函数或给关键字参数赋值时,不要在两旁添加空格。 为变量赋值时,赋值符号的左侧和右侧应该各自写上一个空格,而且只写一个
紧靠小括号、中括号或大括号内部 紧挨着逗号、分号或冒号之前,之后要空一格 紧挨着函数参数列表的左括号之前 紧挨着索引或切片操作的左括号之前 始终在以下二元操作符两侧各放1个空格:赋值(=)、增量赋值(+=,-=等)、比较(==、<、>、!=、<>、<=、>=、in、not、in、is、is not)、布尔(and、or、not) 在数学运算符两侧放置空格 在用于指定关键字参数或默认参数值时,请勿在=两边使用空格return magic(r=real, i=imag)
添加必要的空格,但是避免多余空格。 始终避免行末空白,包括一切不可见字符。 因此,如果IDE支持显示所有不可见字符, 那么请开启它! 同时,如果IDE支持删除行末空白内容,那么也请开启它! 这一部分yapf可以帮助我们解决,只需要写完代码格式化一下即可
5.复合语句
不建议一行包含多条语句
6.尾部逗号
当列表元素、参数、导入项未来可能不断增加时,留一个尾部逗号是一个很好的选择。 通常的用法是每个元素独占一行,尾部均有逗号,在最后一个元素的下一行写闭标签。 如果是所有元素在同一行,就没有必要这么做了
# Correct: FILES = [ 'setup.cfg', 'tox.ini', ] initialize(FILES, error=True, ) # Wrong: FILES = ['setup.cfg', 'tox.ini',] initialize(FILES, error=True,)
7.修正linter检测到的问题
使用flake8对Python代码进行检查,除非有充足理由,否则修改所有检查到的Error和Warning
2.常用代码
1.词典
1.统计出现次数
counts[word] = counts.get(word,0) + 1
2.列表
1.列表指定关键字排序
items.sort(key=lambda x:x[1], reverse=True)#对第二个元素进行倒序排序
2.将字符串列表中每个元素转化为数字列表
list(map(eval,list))
3.将列表中所有元素反转
res[::-1]
4.列表中两数交换
res[i],res[j] = res[j],res[i] #不需要中间变量
5.分配长度固定的列表
G = [0]*(n+1) #长度为n+1的列表
6.求arr列表中区间[i,j]中最大元素
max(arr[i:j + 1])
7.用该元素分割中序列表
left_l = inorder[:idx] right_l = inorder[idx+1:]
3.循环/判断
1.只需要确定循环次数,不需要获取值时
for _ in range(len(queue)):
2.if...else的简略写法
node.left = recur_func(left_l) if left_l else None
3.逆序循环
for i in range(len(postorder)-2, -1,-1)
4.同时获得元素和下标
for i, v in enumerate(nums):
5.同时遍历多个列表返回多个值
for net, opt, l_his in zip(nets, optimizers, losses_ his):
不用zip,每次只会输出一个值
6.遍历多个列表返回一个值
for label in ax.get_xticklabels() + ax.get_yticklabels():
4.映射
1.将可遍历结构快速转化为对应下标的映射
index = {element: i for i, element in enumerate(inorder)}
利用list.index(x)也可以实现,但每次都要遍历列表,时间为O(n),上面为O(1)
3.常用方法
1.系统自带
0.分类
1.类型转换
1.int(x)
1.浮点类型转为整数类型时,小数部分直接舍去
2.float(x)
3.str(x)
1.sorted(num)
1.对指定元素进行排序
2.map(func,list)
1.将第一个参数的功能作用于第二个参数的每一个元素
map(eval,list)
3.len(i)
1.获得长度,可以是任意的类型
4.enumerate()
1.将一个可遍历的数据对象(如列表、元组或字符串)组合为一个索引序列,同时列出数据下标和数据,一般用在 for 循环当中: for i, element in enumerate(seq):
2.enumerate(sequence, [start=0]),小标从0开始,返回 enumerate(枚举) 对象
5.type(x)
1.对变量x的类型进行判断,适用于任何数据类型
2.如果需要在条件判断中使用变量类型作为条件,可以使用type()函数进行直接比较
if type(n) == type(123):
2.列表[]
1.当堆栈使用
1.入栈
stack.append()
2.出栈
stack.pop()
弹出最后一个元素
3.返回栈顶元素
list中无peek方法,只能先pop,再append
2.当队列使用
1.入队
queue.append()
2.出队
queue.pop(0)
出队第一个元素
3.获取元素下标
m_i = nums.index(max_v)
3.队列deque
1.双端队列当队列使用
1.入队
queue.append()
2.出队
queue.popleft()
4.常见错误/不同
1.问题格式
1.IndentationError:expected an indented block
缩进出现问题,最常见的错误就是混用Tab和Space键实现代码缩进
上面的报错还有一个原因经常遇到,就是无首行缩进,例如在编写if语句时在后面加冒号,如果直接换行, 好多代码编辑器会自动首行缩进。但是有些代码编辑器可能没有这个功能,这时需要大家手动缩进,这最好养成习惯。 请大家不要连续敲几次空格键,建议直接按一下Tab键就行了
1.pycharm问题
1.pycharm 如何跳出服务器证书不受信任的提示
点击File > Settings > Tools > Server Certificates > Accept non-trusted certificates automatically
1.pycharm技巧
1.将Python文件导入到当前的项目中
直接在文件管理器中复制相应的文件到当前项目相对应的目录中,pycharm中会直接出现, 若版本问题没出现,自己折叠一下当前项目目录,再重新展开
2.运行错误
0.看错误类型,一定要看最后分割线的上一行的错误
其他错误
1.“no module named XX"
随着大家开发水平的提高和程序复杂性的提升,将会在程序中用到越来越多的模块和第三方库,这个错误的原因是没有安装库“XX”, pip install ww
1.error
1.Error:Command errored out with exit status 1
下载对应的第三方安装包,进入到下载目录进行安装下载文件的全名
2.error: Microsoft Visual C++ 14.0 is required
安装Microsoft Visual C++ 14.0,博客有下载地址visualcppbuildtools_full
3.error: invalid command 'bdist_wheel'
pip3 install wheel
2.AttributeError 属性错误
0.通用解决方法
提示哪个模块的文件出现错误,就找到这个模块删除了,用朋友的能运行的相同文替换
1.AttributeError: module 'xxx' has no attribute 'xxx'
1.文件名与python自带关键字之类冲突
2.两个py文件相互import 导致,删除其中一个
2.AttributeError: module 'six' has no attribute 'main'
pip版本问题 10.0没有main(), 需要降版本:python -m pip install –upgrade pip==9.0.1
Pip v10 后的一些 API 有变化,导致新旧版本不兼容从而影响了我们对包的安装与更新 更新 IDE 就可以了!更新的时候,IDE 也很友好的给了我们提示。 PyCharm -> Help -> Check for Updates
破解版不要更新,否则激活信息失效
3.AttributeError: module 'async io' has no attribute 'run'
你命名了一个asyncio的py文件 如果检查不是第一种 就要检查一下你的 python 版本 因为python3.7 及以后才支持run方法 1 升级python版本 2 run 改写成下面的方式 loop = asyncio.get_event_loop() result = loop.run_until_complete(coro)
4.AttributeError: module 'asyncio.constants' has no attribute '_SendfileMode'
替换asyncio中的constants文件
3.TypeError
1.“TypeError: 'tuple' object cannot be interpreted as an integer"
t=('a','b','c') for i in range(t):
典型的类型错误问题,在上述代码中,range()函数期望的传入参数是整型(integer),但是传入的参为元组(tuple) ,解决方法是将入参元组t改为元组个数 整型len(t)类型即可,例如将上述代码中的range(t)改为 range(len(t))
2.“TypeError: 'str' object does not support item assignment”
由于尝试修改string的值引起的,string 是一种不可变的数据类型
s[3] = 'a' 改为 s = s[:3] + 'a' + s[4:]
3.“TypeError: Can't convert 'int' object to str implicitly”
由于尝试连接非字符串值与字符串引起的,只需要将非字符串强制转换为字符串即可str()
4.“TypeError: unhashable type: 'list'
利用set函数构造集合时,给的参数列表中的元素不能含有列表作为参数
5.TypeError: cannot use a string pattern on a bytes-like object
python2和python3之间切换,难免会碰到一些问题,python3中Unicode字符串是默认格式(就是str类型),ASCII编码的字符串(就是bytes类型,bytes类型是包含字节值,其实不算是字符串,python3还有bytearray字节数组类型)要在前面加操作符b或B;python2中则是相反的,ASCII编码字符串是默认,Unicode字符串要在前面加操作符u或U
import chardet #需要导入这个模块,检测编码格式 encode_type = chardet.detect(html) html = html.decode(encode_type['encoding']) #进行相应解码,赋给原标识符(变量)
4.IOError
1.“IOError: File not open for writing”
>>> f=open ("hello. py") >>> f.write ("test")
出错原因是在没有在open("hello.py")的传入参数中添加读写模式参数mode,这说明默认打开文件的方式为只读方式
解决方法是更改模式mode,修改为写入模式权限w+, f = open("hello. py", "w+")
5.SyntaxError 语法错误
1.SyntaxError:invalid syntax”
忘记在if、elif、else、for、while、 class和def等语句末尾添加冒号引起的
错误的使用了“=”而不是“==”,在Python程序中,“=”是赋值操作符,而“==”是等于比较操作
6.UnicodeDecodeError 编码解读错误
1.'gbk' codec can't decode byte
大部分情况是因为文件不是 UTF8 编码的(例如,可能是 GBK 编码的),而系统默认采用 UTF8 解码。解决方法是改为对应的解码方式: with open('acl-metadata.txt','rb') as data:
open(path, ‘-模式-‘,encoding=’UTF-8’) 即open(路径+文件名, 读写模式, 编码)
常用读写模式
在python对文件进行读写操作的时候,常常涉及到“读写模式”,整理了一下常见的几种模式,如下: 读写模式:r :只读 r+ : 读写 w : 新建(会对原有文件进行覆盖) a : 追加 b : 二进制文件 常用的模式有:“a” 以“追加”模式打开, (从 EOF 开始, 必要时创建新文件) “a+” 以”读写”模式打开 “ab” 以”二进制 追加”模式打开 “ab+” 以”二进制 读写”模式打开 “w” 以”写”的方式打开 “w+” 以“读写”模式打开 “wb” 以“二进制 写”模式打开 “wb+” 以“二进制 读写”模式打开 “r+” 以”读写”模式打开 “rb” 以”二进制 读”模式打开 “rb+” 以”二进制 读写”模式打开 rU 或 Ua 以”读”方式打开, 同时提供通用换行符支持 (PEP 278)
7.ValueError
1.too many values to unpack
调用函数的时候,接受返回值的变量个数不够
8.OSError
1.WinError 1455] 页面文件太小,无法完成操作
1.重启pycharm(基本没啥用)
2.把num_works设置为0 (可能也没啥用)
3.调大页面文件的大小 (彻底解决问题)
9.ImportError
1.DLL load failed: 页面文件太小,无法完成操作
1.不止在运行一个项目,另一个项目的python程序也在运行,关掉就可以了
2.windows操作系统不支持python的多进程操作。而神经网络用到多进程的地方在数据集加载上,所以将DataLoader中的参数num_workers设置为0即可
1.DLL load failed:操作系统无法运行%1
0.最近在运行scrapy项目时,本来安装好好的scrapy框架突然报错,猝不及防
1.因为Anaconda安装scrapy并不困难,花力气去找解决方案不如重装来的简单、高效
2.我只用conda remove scrapy命令不能完全卸载掉,原因可能是安装scrapy时分别用pip和conda安装了两次。读者可以pip 和 conda 卸载命令都尝试一下
pip uninstall scrapy conda remove scrapy
重新安装 pip install scrapy
类错误
1.类的多继承的属性问题
class A (object): x = 1 class B (A): pass class C (A): pass print (A.x, B.x, C.x) # 1 1 1 B.x = 2 print (A.x, B.x, C.x) # 1 2 1 A.x = 3 print (A.x, B.x, C.x) # 3 2 3
我们只修改了 A.x,为什么C.x也被改了?在Python 程序中,类变量在内部当做字典来处理,其遵循常被引用的方法解析顺序(MRO)。所以在上面的代码中,由于class C中的x属性没有找到,它会向上找它的基类(尽管Python 支持多重继承,但上面的例子中只有A)。换句话说,class C中没有它自己的x属性,其独立于A。因此,C.x事实上 是A.x的引用
作用域错误
1.全局变量和局部变量
1.局部变量x没有初始值,外部变量X不能引入到内部
x = 10 def foo (): x += 1 print(x) foo () Traceback (most recent call last): File "D:/程序代码/Python/QRcode_Make/test.py", line 5, in <module> foo () File "D:/程序代码/Python/QRcode_Make/test.py", line 3, in foo x += 1 UnboundLocalError: local variable 'x' referenced before assignment
2.列表的不同操作
lst = [1,2,3] #给列表lst赋值 lst. append (4) #丄t后边append—*个元素4 print(lst) # [1, 2, 3, 4] lst += [5] #两个列表合并 print(lst) # [1, 2, 3, 4, 5] def fool(): lst.append(6) #函数会査找外部的:1st列表 fool () print(lst) # [1, 2, 3, 4, 5, 6] def foo2(): lst += [6] #合并列表时,不会査找外部列表,让人有 些不可思议吧 foo2 () Traceback (most recent call last): File "D:/程序代码/Python/QRcode_Make/test.py", line 26, in <module> foo2 () File "D:/程序代码/Python/QRcode_Make/test.py", line 24, in foo2 lst += [6] #合并列表时,不会査找外部列表,让人有 些不可思议吧 UnboundLocalError: local variable 'lst' referenced before assignment
fool没有对lst进行赋值操作,而fool2做了。 要知道,lst += [5]是lst = lst + [5]的缩写,我们试图对lst 进行赋值操作(Python把他当成了局部变量)。此外,我们对lst进行的赋值操作是基于lst自身(这再一次被Python 当成了局部变量),但此时还未定义,因此出错!所以在这里就需要格外区分局部变量和外部变量的使用过程了
2.1Python2升级Python3错误
1.print 变成了 print()
1.Python2版本中,print是作为一个语句使用的,在 Python3版本中print作为一个函数出现,在Python 3版本中,所有的print内容必须用小括号括起来
2.raw_Input 变成了 input
在Python 2版本中,输入功能是通过raw_input实现的。而在Python 3版本中,是通过input实现的
3.整数及除法的问题
1.“TypeError: 'float* object cannot be interpreted as an integer”
2.在Python 3中,int和long统一为int类型,int 表示任何精度的整数,long类型在Python 3中已经消失,并且后缀L也已经弃用,当使用int超过本地整数大小时,不会再导致OverflowError 异常
3.在以前的Python 2版本中,如果参数是int或者是long的话,就会返回相除后结果的向下取整(floor),而如果参数是float或者是complex的话,那么就会返回相除后结果的一个恰当的近似
4.Python 3中的“/”总是返回一个浮点数,永远表示向下除法,只需将“/”修改为 “//” 即可得到整除的结果
4.异常处理大升级
1.在Python 2程序中,捕获异常的格式如下:except Exception, identifier
except ValueError, e: # Python 2处理单个异常 except (ValueError, TypeError), e: # Python 2处理多个异常
2.在Python 3程序中,捕获异常的格式如下:except Exception as identifier
except ValueError as e: # Python3处理单个异常 except (ValueError, TypeError) as e: # Python3处理多个异常
3.在Python 2程序中,抛出异常的格式如下:raise Exception, args
4.在Python 3程序中,抛出异常的格式如下:raise Exception(args)
raise ValueError, e # Python 2 .x 的方法 raise ValueError(e) # Python 3.x 的方法
5.xrange()变为range()
“NameError: name 'xrange' is not definedw”
6.不能直接使用reload
“name 'reload' is not defined 和 AttributeError: module 'sys' has no att”
import importlib importlib.reload(sys)
7.没有了 Unicode类型
”python unicode is not defined”
在Python 3中已经没有了 Unicode类型,被全新的str类型所代替。而Python 2中原有的str类型,在Python 3中被bytes所代替
8.已经舍弃了 has_key
“AttributeError: 'diet' object has no attribute 'has_key' ”
在Python 3中已经舍弃了 has_key,修改方法 是用in来代替has_key
9.urllib2 已经被 urllib.request 替代
“lmportError: No module named urllib2”
解决方法是将urllib2修改为urllib.request
10.编码问题
TypeError: cannot use a string pattern on a bytes-like object
python2和python3之间切换,难免会碰到一些问题,python3中Unicode字符串是默认格式(就是str类型),ASCII编码的字符串(就是bytes类型,bytes类型是包含字节值,其实不算是字符串,python3还有bytearray字节数组类型)要在前面加操作符b或B;python2中则是相反的,ASCII编码字符串是默认,Unicode字符串要在前面加操作符u或U
import chardet #需要导入这个模块,检测编码格式 encode_type = chardet.detect(html) html = html.decode(encode_type['encoding']) #进行相应解码,赋给原标识符(变量)
2.1命令提示行命令
1.操作目录
1.cd 改变当前的子目录,可直接复制路径,一次性进入
2.CD命令不能改变当前所在的盘,CD..退回到上一级目录,CD\表示返回到当前盘的目录下,CD无参数时显示当前目录名
3.d: 改变所在盘
2.Python相关
1.pip
0.获取帮助
pip help
0.更换pip源
1.临时使用
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple 包名
2.设为默认
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
设为默认后,以后安装库都是从清华源下载,而且无需再加镜像源网址
3.主流镜像源地址
清华:https://pypi.tuna.tsinghua.edu.cn/simple 阿里云:http://mirrors.aliyun.com/pypi/simple/ 中国科技大学 https://pypi.mirrors.ustc.edu.cn/simple/ 华中理工大学:http://pypi.hustunique.com/ 山东理工大学:http://pypi.sdutlinux.org/ 豆瓣:http://pypi.douban.com/simple/
1.安装指定库
pip install 包名
2.安装指定库的指定版本
pip install package_name==1.1.2
3.查看安装了哪些库
pip list
4.查看库的具体信息
pip show -f 包名
5.pip安装在哪里
pip -V
6.批量安装库
pip install -r d:\\requirements.txt 文件中直接写 包名==版本号
7.使用wheel文件安装库
1.在下面网站里找相应库的.whl文件,Ctrl+F搜索,注意对应的版本 https://www.lfd.uci.edu/~gohlke/pythonlibs/
2.在.whl所在文件夹内,按Shift键+鼠标右键,打开CMD窗口或者PowerShell (或者通过命令行进入到此文件夹)
3.输入命令: pip install matplotlib‑3.4.1‑cp39‑cp39‑win_amd64.whl
8.卸载库
pip uninstall package_name
9.升级库
pip install --upgrade package_name
10.将库列表保存到指定文件中
pip freeze > 文件名.txt
11.查看需要升级的库
pip list -o
12.检查兼容问题
验证已安装的库是否有兼容依赖问题 pip check package-name
13.下载库到本地
将库下载到本地指定文件,保存为whl格式 pip download package_name -d "要保存的文件路径"
2.打包程序为可执行文件
pyinstaller -F 文件名.py
3.代码书写
1.没有的格式
1.没有i++, 只能写成i+=1
2.不同的格式
1.类
1.参数
0.定义类时,第一参数必须是self,所有方法也是一样,调用本类成员时必须用self.成员
1.类中参数,先写变量名,加上冒号,再写类型名,用逗号隔开
2.类定义结束后,可以在后面加 ->类型名,表示返回值的类型
2.调用
1.类中调用自身进行递归必须用 self.自身函数(参数中不用加self)
2.使用类时,不需要新建类,直接用就行了 root = TreeNode(max_num)
3.方法
1._一个下划线开头,定义保护方法
2.__两个下划线开头,定义私有方法
子主题
2.数字
1.正负无穷的表示
float("inf"), float("-inf")
3.符号
1.Python中 / 表示正常除,有余数, // 表示整除,没有余数
2.Python中的 非! 用 not 表示
3.Python中平方为 **
4.语句
1.所有和关键字有关的语句后都要加冒号: return除外
2.循环,判断等类似语句中的条件不需要加(),语句块也没有{},用缩进严格代表格式
5.注释
1.单行注释 #
2.多行注释''' 或者 """
4.不同代码
1.列表
1.需要明确使用列表下标时,创建列表必须用G = [0]*(n+1) #长度为n+1的列表,否则会下标越界
2.创建定长的二维列表时,如果*n失效,试试用循环
dp = [[float('inf') for _ in range(n)] for _ in range(n)]
3.如果函数返回值是一个列表,但只有一个变量接收时,加逗号
line, = ax.plot(x, np.sin(x))
5.Python编程
1.文件问题
1.导入别的项目需要用到文件时,使用文件的绝对路径
2.命令行执行文件
Python 文件名.py
3.第三方包镜像资源
在用pycharm下载第三方包时,在Manage Repositories中添加 http://mirrors.aliyun.com/pypi/simple/ ,删除原来网址
6.Pycharm快捷键
1.注释(添加/消除)
Ctrl + /
单行注释#
2.代码右移
Tab
3.代码左移
Shift + Tab
4.自动缩进
Ctrl + alt + I
5.运行
Ctrl + shift + F10
6.PEP8规范格式化
Ctrl + alt + L
也是QQ锁定快捷键,在QQ中取消设置
6.1快速修正
alt + enter 再回车
7.复制一行/多行/选中部分
Ctrl + D
8.删除一行/多行
Ctrl + Y
9.查找
Ctrl + F
9.1全局查找
Ctrl + shift + F
10.替换
Ctrl + R
10.1全局替换
Ctrl + shift + R
11.光标移动到下一行
shift + Enter
12.多行光标点击
alt + 鼠标左键点击
13.跳向下一个断点
alt + F9
14.撤销
Ctrl + Z
14.1反撤销
Ctrl + shift + Z
15.复写父类代码
Ctrl + o
16.选中单词/代码块
Ctrl + W
17.快速查看文档(代码信息)
Ctrl + Q
18.任意位置向下插入一行
shift + enter
19.任意位置向上插入一行
Ctrl + alt + enter
20.查看项目视图
alt + 1
21.查看结构视图
alt + 7
22.快速进入代码
Ctrl + 左键
23.快速查看历史
alt + 左(返回)/右键(前进)
24.快速查看不同方法
alt + 上/下
25.切换视图
Ctrl + Tab
26.查看资源文件
两次shift
27.查看方法在哪里被调用
Ctrl + alt + H 双击可确定位置
28.查看父类
Ctrl + U
29.查看继承关系
Ctrl + H
7.pycharm页面
0.菜单栏
1.View 窗口
1.Navigation Bar 导航栏
2.Refactor 重构
3.Tools 工具
4.VCS 版本控制
1.调试代码
1.单击代码前面插入断点,点击爬虫进行调试,点击爬虫旁边的方框结束调试
2.点击↘图标,跳向下一个断点,可以不停地观察变量的值
2.settings 设置
1.appearance & Behavior 界面和行为
1.appearance 整体风格
1.Theme 主题
1.Darcula 黑色主题
2.High contrast 高对比主题
3.Intellij 明亮主题
2.custom font 自定义字体
2.system settings系统设置
1.update
可关闭自动检测更新
2.keymap 快捷键
1.根据系统查看,可直接搜索(comment注释)
3.editor 仅编辑区域
1.Font 代码字体(可调大小)
2.Color Scheme 代码区域配色方案
3.Code Style 代码风格
0.Python 可对其中每个细节进行更改
1.Python
1.Indent 缩进数
2.Space 空格设置
3.Wrapping and Braces 封装和括号
1.Hard wrap at 一行最大代码数
4.Blank lines 空行
4.Inspections 检查
1.PEP 8 是一种代码规范,并不是语法错误,尽量保持规范
2.可以设置对哪些内容进行检查,也可以在Severity设置检查的严格程度如何
5.File and Code Templates 文件和代码模板
1.在Python.Script中添加每次创建新文件都会显示的信息, 可上网查找哪些可添加信息
# Author: ${USER} # CreatTime: ${DATE}${TIME} # FileName: ${NAME} # Tools: ${PRODUCT_NAME} # Description:
6.File Encodings 文件编码
1.默认UTF-8
7.Live Templates 动态模板(好用掌握)
0.可点加号自己添加模板,一定要设定使用位置
1.for循环
写iter选择循环,不断回车进行代码编写
2.用for循环写列表
写compl即可
4.Plugins 插件
5.Project
1.Project Interpret 项目解释器
1.可以管理和添加第三方库(点击加号,搜索)
2.可以为当前项目设置不同的解释器
3.Project 项目管理
1.右击文件选 Show in Explorer 可直接打开文件位置
2.New 新建
1.File
可以是其他各种文件,不一定是Python文件
2.New Scratch File
创建临时文件,相当于草稿纸,用于测试部分代码
3.Directory 目录
创建新的下层文件夹
4.Python File
和普通文件夹相比多了空的初始化Python文件
5.新建一个HTML文件时,可以右击用浏览器打开,能够看到显示效果
4.代码结果页面
1.Terminal 终端
1.和系统的CMD是一样的,可以直接在这里安装包,使用dos命令
2.Python Console 控制台
可以直接编写Python代码,交互式运行,可一行一行编辑
3.TODO
相当于备忘录,在代码处写上TODO(''),可快速地找到此处,继续工作,可用于相互配合
4.最右边头像
可调整代码检查的严格程度,省电模式相当于箭头放到最左边,所有语法错误都不会检查
5.虚拟环境
1.
2.虚拟环境的创建是因为在实际开发中需要同期用到不同版本的python解释器和同一个库的不同 版本。因此需要创建虚拟环境将项目环境与其他环境(系统环境、其他虚拟环境)隔离
3.PyCharm中虚拟环境的创建有三种方式,virtualen、conda和pipen
4.virtualen可以想象成是将当前系统环境创建一个隔离副本,使用的解释器和你安装的是同一个 (复印件)
5.conda是根据你的需要,选择特定的python版本,然后从网上下载相关版本,并 创建一个与系统环境不一样的新的环境,使用的解释器也和你安装的不是同一个
6.pipen和 virtualen类似,也是在现有系统环境的基础上创建一个副本,但是pipen使用Pipfile替代 virtualen的requirements.txt来进行依赖管理,更加方便
6.相互关系
7.SVN
1.官网下载时,选择英文版1.10,和中文版不一样
2.安装TortoiseSVN.msi时,一定要勾选command line tools
3.在安装的bin目录中会出现一系列svn.exe的文件
4.在pycharm中配置,在setting/version control/subversion找到bin目录下的svn.exe文件
5.在文件夹中右击修改过的文件,可以查看修改的地方
6.右击项目会出现新的subversion快捷方式,可以提交commit,全部撤回revert change
8.好用插件
5.常用代码段
1.输入
1.获取用户不定长度的输入
def getNum(): #获取用户不定长度的输入 nums = [] iNumStr = input("请输入数字(回车退出): ") while iNumStr != "": nums.append(eval(iNumStr)) iNumStr = input("请输入数字(回车退出): ") return nums
2.文本
1.英文文本去噪及归一化
def getText(): txt = open("hamlet.txt", "r").read() txt = txt.lower() #全部转化为小写字母 for ch in '!"#$%&()*+,-./:;<=>?@[\\]^_‘{|}~': txt = txt.replace(ch, " ") #将文本中特殊字符替换为空格 return txt
2.统计英文文本单词频率
hamletTxt = getText() words = hamletTxt.split() #以空格分隔文本,转为为列表 counts = {} #新建一个字典 for word in words: #统计每个单词出现的频率,默认值为0 counts[word] = counts.get(word,0) + 1 items = list(counts.items()) #将字典转化为列表 items.sort(key=lambda x:x[1], reverse=True)#对第二个元素进行倒序排序 for i in range(20): word, count = items[i] print ("{0:<10}{1:>5}".format(word, count))
3.统计中文文本词频
import jieba txt = open("threekingdoms.txt", "r", encoding='utf-8').read() words = jieba.lcut(txt) counts = {} for word in words: if len(word) == 1: continue else: counts[word] = counts.get(word,0) + 1 items = list(counts.items()) #转化为列表才能排序 items.sort(key=lambda x:x[1], reverse=True) for i in range(15): word, count = items[i] print ("{0:<10}{1:>5}".format(word, count))
3.数组
1.求数组中最大值
1.元素有重复
max_v, m_i = float(-inf), 0 #将一个可遍历的数据对象(如列表、元组或字符串)组合为一个索引序列, 同时列出数据下标和数据 for i, v in enumerate(nums): if v > max_v: max_v = v m_i = i
2.无重复元素
max_v = max(nums) m_i = nums.index(max_v)
2.二分法
class Solution: def searchInsert(self, nums: List[int], target: int) -> int: left, right = 0, len(nums) #采用左闭右开区间[left,right) while left < right: # 右开所以不能有=,区间不存在 mid = left + (right - left)//2 # 防止溢出, //表示整除 if nums[mid] < target: # 中点小于目标值,在右侧,可以得到相等位置 left = mid + 1 # 左闭,所以要+1 else: right = mid # 右开,真正右端点为mid-1 return left # 此算法结束时保证left = right,返回谁都一样
4.Matplotlib
1.移动坐标到(0,0)
ax = plt.gca() ax.spines['right'].set_color('none') ax.spines['top'].set_color('none') ax.xaxis.set_ticks_position('bottom') ax.spines['bottom'].set_position(('data', 0)) ax.yaxis.set_ticks_position('left') ax.spines['left'].set_position(('data', 0))
数学
1.计算平均值
def mean(numbers): #计算平均值 s = 0.0 for num in numbers: s = s + num return s / len(numbers)
2.计算方差
def dev(numbers, mean): #计算方差 sdev = 0.0 for num in numbers: sdev = sdev + (num - mean)**2 return pow(sdev / (len(numbers)-1), 0.5)
3.计算中位数
def median(numbers): #计算中位数 sorted(numbers) size = len(numbers) if size % 2 == 0: med = (numbers[size//2-1] + numbers[size//2])/2 else: med = numbers[size//2] return med
6.代码在本地运行
其实就是定义个main函数,构造个输入用例,然后定义一个solution变量,调用minCostClimbingStairs函数就可以了
#include <iostream> #include <vector> using namespace std; class Solution { public: int minCostClimbingStairs(vector<int>& cost) { vector<int> dp(cost.size()); dp[0] = cost[0]; dp[1] = cost[1]; for (int i = 2; i < cost.size(); i++) { dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i]; } return min(dp[cost.size() - 1], dp[cost.size() - 2]); } }; int main() { int a[] = {1, 100, 1, 1, 1, 100, 1, 1, 100, 1}; vector<int> cost(a, a + sizeof(a) / sizeof(int)); Solution solution; cout << solution.minCostClimbingStairs(cost) << endl; }
0.代码书写技巧
1.格式
1.想要将几行在一行中书写
在每行的最后加上分号;
2.一行太长想要换行书写
在此行的最后加上右斜杠\即可
算法相关
经典思想
0.常遇问题
1.防止溢出
1.计算很多数乘积时,为防止溢出,可以对乘积取对数,变成相加的形式
1.数据结构
1.数组
0.基础
1.只要看到面试题里给出的数组是有序数组,都可以想一想是否可以使用二分法
2.数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖
1.数组中同一个元素不能使用两遍的遍历
for (int i = 0; i < nums.length; i++) { for (int j = i + 1; j < nums.length; j++) {
2.环形数组问题
有首尾不能同时的约束时,将环形数组分解为若干个普通数组问题,求最大值
3.二分法的区间问题
1.左闭右闭[left, right]
class Solution { public: int searchInsert(vector<int>& nums, int target) { int n = nums.size(); int left = 0; int right = n - 1; // 定义target在左闭右闭的区间里,[left, right] while (left <= right) { // 当left==right,区间[left, right]依然有效 int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2 if (nums[middle] > target) { right = middle - 1; // target 在左区间,所以[left, middle - 1] } else if (nums[middle] < target) { left = middle + 1; // target 在右区间,所以[middle + 1, right] } else { // nums[middle] == target return middle; } } // 分别处理如下四种情况 // 目标值在数组所有元素之前 [0, -1] // 目标值等于数组中某一个元素 return middle; // 目标值插入数组中的位置 [left, right],return right + 1 // 目标值在数组所有元素之后的情况 [left, right], return right + 1 return right + 1; } };
int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2
while (left <= right) { // 当left==right,区间[left, right]依然有效
if (nums[middle] > target) { right = middle - 1; // target 在左区间,所以[left, middle - 1]
} else if (nums[middle] < target) { left = middle + 1; // target 在右区间,所以[middle + 1, right]
2.左闭右开[left, right)
class Solution { public: int searchInsert(vector<int>& nums, int target) { int n = nums.size(); int left = 0; int right = n; // 定义target在左闭右开的区间里,[left, right) target while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间 int middle = left + ((right - left) >> 1); if (nums[middle] > target) { right = middle; // target 在左区间,在[left, middle)中 } else if (nums[middle] < target) { left = middle + 1; // target 在右区间,在 [middle+1, right)中 } else { // nums[middle] == target return middle; // 数组中找到目标值的情况,直接返回下标 } } // 分别处理如下四种情况 // 目标值在数组所有元素之前 [0,0) // 目标值等于数组中某一个元素 return middle // 目标值插入数组中的位置 [left, right) ,return right 即可 // 目标值在数组所有元素之后的情况 [left, right),return right 即可 return right; } };
while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间
if (nums[middle] > target) { right = middle; // target 在左区间,在[left, middle)中
} else if (nums[middle] < target) { left = middle + 1; // target 在右区间,在 [middle+1, right)中
2.哈希表
0.只要涉及到统计某个数/值的出现次数,就用哈希表
1.哈希表中包含某数的对应数,且不是它本身
for (int i = 0; i < nums.length; i++) { int complement = target - nums[i]; if (map.containsKey(complement) && map.get(complement) != i)
3.链表
1.两个链表中的数相加
1.逆序时: 一定单独考虑最后一次相加可能的进位问题
2.正序时: 将链表逆转/使用堆栈的数据结构实现逆转
2.有序单链表求中位数(左闭右开)
设当前链表的左端点为left,右端点right,包含关系为「左闭右开」,给定的链表为单向链表,访问后继元素十分容易,但无法直接访问前驱元素。因此在找出链表的中位数节点mid之后,如果设定「左闭右开」的关系,就可以直接用(left,mid) 以及(mid.next,right) 来表示左右子树对应的列表了,不需要mid.pre,并且,初始的列表也可以用(head,null)方便地进行表示
4.字符
1.记录每个字符是否出现过
哈希集合: Set<Character> occ = new HashSet<Character>(); occ.contains(a)
5.数字
1.两个个位数相加的进位获取
int sum = carry+x+y; int carry = sum/10;
2.整数反转
要在没有辅助堆栈/数组的帮助下 “弹出” 和 “推入” 数字,我们可以使用数学方法,先取出最后一位,然后除以10,将最后一位去掉,反转数不断将自身乘10后,加上取出的最后一位数,同时先判断是否会溢出
6.树
1.寻找前驱结点
向左走一步,然后一直向右走至无法走为止
predecessor = root.left; while (predecessor.right != null && predecessor.right != root) { predecessor = predecessor.right; }
7.集合
1.去除列表中重复元素
s = set(ls); lt = list(s)
8.元组
1.如果不希望数据被程序所改变,转换成元组类型
lt = tuple(ls)
2.经典算法
1.双指针
0.常用于数组和链表
1.当需要枚举数组中的两个元素时,如果发现随着第一个元素的递增,第二个元素是递减的,那么就可以使用双指针的方法,将第二个指针从数组尾开始遍历,同时保证第二个指针大于第一个指针,将枚举的时间复杂度从 O(N^2)减少至 O(N)
2.查找的结果是某个范围时,利用双指针不断变化,类似于滑动窗口的机制
2.快慢指针法
初始时,快指针fast 和慢指针slow 均指向链表的左端点left。我们将快指针fast 向右移动两次的同时,将慢指针slow 向右移动一次,直到快指针到达边界(即快指针到达右端点或快指针的下一个节点是右端点)。此时,慢指针对应的元素就是中位数
3.动态规划
1.要求的数,可以通过上一个以求的数通过某种操作得到,找到动态转移方程
2.条件
如果某一问题有很多重叠子问题,使用动态规划是最有效的
3.五部曲
1.确定dp数组(dp table)以及下标的含义 2.确定递推公式 3.dp数组如何初始化 4.确定遍历顺序 5.举例推导dp数组
为什么要先确定递推公式,然后在考虑初始化呢? 因为一些情况是递推公式决定了dp数组要如何初始化
4.如何debug
1.把dp数组打印出来,看看究竟是不是按照自己思路推导的
2.写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果
5.滚动数组
当递推方程只与相邻的几个数有关时,可用滚动数组将空间复杂度优化为O(1)
4.递归
0.递归三部曲
递归终止条件,本次递归做什么,返回什么
3.常用技巧
1.巧用数组下标
1.应用
数组的下标是一个隐含的很有用的数组,特别是在统计一些数字(把相应的数组的值当做新数组的下标temp[arr[i]]++),或者判断一些整型数是否出现过的时候
2.实例
1.给你一串字母,让你判断这些字母出现的次数时,我们就可以把这些字母作为下标,在遍历的时候,如果字母a遍历到,则arr[a]就可以加1了,即 arr[a]++,通过这种巧用下标的方法,我们不需要逐个字母去判断
2.给你n个无序的int整型数组arr,并且这些整数的取值范围都在0-20之间,要你在 O(n) 的时间复杂度中把这 n 个数按照从小到大的顺序打印出来,把对应的数值作为数组下标,如果这个数出现过,则对应的数组加1
public void f(int arr[]) { int[] temp = new int[21]; for (int i = 0; i < arr.length; i++) { temp[arr[i]]++; } //顺序打印 for (int i = 0; i < 21; i++) { for (int j = 0; j < temp[i]; j++) { System.out.println(i); } } }
2.巧用取余
1.应用
在遍历数组的时候,会进行越界判断,如果下标差不多要越界了,我们就把它置为0重新遍历。特别是在一些环形的数组中,例如用数组实现的队列pos = (pos + 1) % N
for (int i = 0; i < N; i++) { //使用数组arr[pos] (我们假设刚开始的时候pos < N) pos = (pos + 1) % N; }
3.巧用双指针
1.应用
对于双指针,在做关于单链表的题是特别有用
2.实例
1.判断单链表是否有环
设置一个慢指针和一个快指针来遍历这个链表。慢指针一次移动一个节点,而快指针一次移动两个节点,如果该链表没有环,则快指针会先遍历完这个表,如果有环,则快指针会在第二次遍历时和慢指针相遇
2.如何一次遍历就找到链表中间位置节点
一样是设置一个快指针和慢指针。慢的一次移动一个节点,而快的两个。在遍历链表的时候,当快指针遍历完成时,慢指针刚好达到中点
3.单链表中倒数第 k 个节点
设置两个指针,其中一个指针先移动k个节点。之后两个指针以相同速度移动。当那个先移动的指针遍历完成的时候,第二个指针正好处于倒数第k个节点
子主题
4.巧用移位运算
1.应用
1.有时候我们在进行除数或乘数运算的时候,例如n / 2,n / 4, n / 8这些运算的时候,我们就可以用移位的方法来运算了,通过移位的运算在执行速度上是会比较快的
n / 2 等价于 n >> 1 n / 4 等价于 n >> 2 n / 8 等价于 n >> 3
2.还有一些 &(与)、|(或)的运算,也可以加快运算的速度
2.实例
1.判断一个数是否是奇数,用与运算的话会快很多
if(n % 2 == 1){ dosomething(); } if(n & 1 == 1){ dosomething(); )
5.设置哨兵位
1.应用
1.在链表的相关问题中,我们经常会设置一个头指针,而且这个头指针是不存任何有效数据的,只是为了操作方便,这个头指针我们就可以称之为哨兵位了
2.在操作数组的时候,也是可以设置一个哨兵的,把arr[0]作为哨兵
2.实例
1.我们要删除头第一个节点是时候,如果没有设置一个哨兵位,那么在操作上,它会与删除第二个节点的操作有所不同。但是我们设置了哨兵,那么删除第一个节点和删除第二个节点那么在操作上就一样了,不用做额外的判断。当然,插入节点的时候也一样
2.要判断两个相邻的元素是否相等时,设置了哨兵就不怕越界等问题了,可以直接arr[i] == arr[i-1]了。不用怕i = 0时出现越界
6.与递归有关的一些优化
1.对于可以递归的问题考虑状态保存
1.当我们使用递归来解决一个问题的时候,容易产生重复去算同一个子问题,这个时候我们要考虑状态保存以防止重复计算
2.实例
0.一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法
1.这个问题用递归很好解决。假设 f(n) 表示n级台阶的总跳数法,则有f(n) = f(n-1) + f(n - 2)
2.递归的结束条件是当0 <= n <= 2时, f(n) = n,容易写出递归的代码
public int f(int n) { if (n <= 2) { return n; } else { return f(n - 1) + f(n - 2); } }
3.不过对于可以使用递归解决的问题,我们一定要考虑是否有很多重复计算。显然对于 f(n) = f(n-1) + f(n-2) 的递归,是有很多重复计算的
4.个时候我们要考虑状态保存。例如用hashMap来进行保存,当然用一个数组也是可以的,这个时候就像我们上面说的巧用数组下标了。可以当arr[n] = 0时,表示n还没计算过,当arr[n] != 0时,表示f(n)已经计算过,这时就可以把计算过的值直接返回回去了
//数组的大小根据具体情况来,由于int数组元素的的默认值是0 //因此我们不用初始化 int[] arr = new int[1000]; public int f(int n) { if (n <= 2) { return n; } else { if (arr[n] != 0) { return arr[n];//已经计算过,直接返回 } else { arr[n] = f(n-1) + f(n-2); return arr[n]; } } }
5.这样,可以极大着提高算法的效率。也有人把这种状态保存称之为备忘录法
2.考虑自底向上
1.对于递归的问题,我们一般都是从上往下递归的,直到递归到最底,再一层一层着把值返回
2.不过,有时候当n比较大的时候,例如当 n = 10000时,那么必须要往下递归10000层直到 n <=2 才将结果慢慢返回,如果n太大的话,可能栈空间会不够用
3.对于这种情况,其实我们是可以考虑自底向上的做法的
例如我知道f(1) = 1; f(2) = 2; 那么我们就可以推出 f(3) = f(2) + f(1) = 3。从而可以推出f(4),f(5)等直到f(n)。因此,我们可以考虑使用自底向上的方法来做。 代码如下: public int f(int n) { if(n <= 2) return n; int f1 = 1; int f2 = 2; int sum = 0; for (int i = 3; i <= n; i++) { sum = f1 + f2; f1 = f2; f2 = sum; } return sum; }
4.也把这种自底向上的做法称之为递推
3.较其他语言优势
1.数组
1.参数为部分数组
在Python中参数可以直接返回部分数组nums[:i],就不用像Java还需要重新设计一个方法来根据下标来截取数组
2.同时获得元素和下标
for i, v in enumerate(nums):
常用数据结构/算法
1.链表
2.栈
1.单调栈
1.定义
栈内元素单调递增或者单调递减的栈,单调栈只能在栈顶操作
2.性质
1.单调栈里的元素具有单调性
2.元素加入栈前,会在栈顶端把破坏栈单调性的元素都删除
3.使用单调栈可以找到元素向左遍历第一个比他小的元素(递增栈)/找到元素向左遍历第一个比他大的元素
3.队列
4.树
1.BST: 二叉搜索树 二叉排序树
1.定义
1.左子树上所有结点的关键字小于根结点
2.右子树上所有结点的关键字大于根结点
3.左右子树又各是一颗二叉排序树
中序遍历可得到递增有序数列
2.AVL: 平衡二叉树
1.定义
1.平衡二叉树: 任一结点的左右子树的高度差的绝对值不超过1
2.平衡因子: 结点左右子树的高度差 -1,0,1
3.mct: 最小生成树
1.定义
任何只由G的边构成,并包含G的所有顶点的树称为G的生成树
5.图
1.术语
1.团集(完全子图)
点的集合:任两点之间均有边相连
1.1 点独立集
点的集合: 任意两点之间都没有边
2.哈密尔顿图Hamilton
无向图,由指定的起点前往指定的终点,途中经过所有其他节点且只经过一次, 闭合的哈密顿路径称作哈密顿回路,含有图中所有顶点的路径称作哈密顿路径
6.算法
1.BFS: 广度优先搜索
1.定义
类似于二叉树的层次遍历算法,优先考虑最早发现的结点
2.DFS: 深度优先搜索
1.定义
类似于树的先序遍历,优先考虑最后发现的结点
常识
1.一秒运算多少次
2.递归算法时间复杂度
递归的次数 * 每次递归中的操作次数
3.递归算法空间复杂度
递归的深度 * 每次递归的空间复杂度
labuladuo知识点
0.必读系列
1.学习算法和刷题的框架思维
1.数据结构的存储方式
1.只有两种:数组(顺序存储)和链表(链式存储)
2.不是还有散列表、栈、队列、堆、树、图等等各种数据结构,都属于「上层建筑」,而数组和链表才是「结构基础」,那些多样化的数据结构,究其源头,都是在链表或者数组上的特殊操作,API 不同而已
3.各种结构简介
1.「队列」、「栈」这两种数据结构既可以使用链表也可以使用数组实现。用数组实现,就要处理扩容缩容的问题;用链表实现,没有这个问题,但需要更多的内存空间存储节点指针
2.「图」的两种表示方法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵
3.「散列表」就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些
4.「树」,用数组实现就是「堆」,因为「堆」是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单;用链表实现就是很常见的那种「树」,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表「树」结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等,以应对不同的问题
4.优缺点
1.数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)
2.链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间
2.数据结构的基本操作
1.基本操作无非遍历 + 访问,再具体一点就是:增删查改
2.从最高层来看,各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的
3.线性就是 for/while 迭代为代表,非线性就是递归为代表
4.几种典型遍历
1.数组
void traverse(int[] arr) { for (int i = 0; i < arr.length; i++) { // 迭代访问 arr[i] } }
2.链表
/* 基本的单链表节点 */ class ListNode { int val; ListNode next; } void traverse(ListNode head) { for (ListNode p = head; p != null; p = p.next) { // 迭代访问 p.val } } void traverse(ListNode head) { // 递归访问 head.val traverse(head.next) }
3.二叉树
/* 基本的二叉树节点 */ class TreeNode { int val; TreeNode left, right; } void traverse(TreeNode root) { traverse(root.left) traverse(root.right) }
4.N叉树
/* 基本的 N 叉树节点 */ class TreeNode { int val; TreeNode[] children; } void traverse(TreeNode root) { for (TreeNode child : root.children) traverse(child); }
5.所谓框架,就是套路。不管增删查改,这些代码都是永远无法脱离的结构,你可以把这个结构作为大纲,根据具体问题在框架上添加代码就行了
3.算法刷题指南
1.先刷二叉树,先刷二叉树,先刷二叉树!
2.二叉树是最容易培养框架思维的,而且大部分算法技巧,本质上都是树的遍历问题
3.不要小看这几行破代码,几乎所有二叉树的题目都是一套这个框架就出来了
void traverse(TreeNode root) { // 前序遍历 traverse(root.left) // 中序遍历 traverse(root.right) // 后序遍历 }
4.如果你对刷题无从下手或者有畏惧心理,不妨从二叉树下手,前 10 道也许有点难受;结合框架再做 20 道,也许你就有点自己的理解了;刷完整个专题,再去做什么回溯动规分治专题,你就会发现只要涉及递归的问题,都是树的问题
5.这么多代码看不懂咋办?直接提取出框架,就能看出核心思路了:其实很多动态规划问题就是在遍历一棵树,你如果对树的遍历操作烂熟于心,起码知道怎么把思路转化成代码,也知道如何提取别人解法的核心思路
2.动态规划解题套路框架
1.基本概念
1.形式
动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离
2.核心问题
核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值
3.三要素
1.重叠子问题
0.这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算
2.最优子结构
0.动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值
3.状态转移方程
0.问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出正确的「状态转移方程」才能正确地穷举
1.思维框架
明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义
# 初始化 base case dp[0][0][...] = base # 进行状态转移 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... dp[状态1][状态2][...] = 求最值(选择1,选择2...)
2.斐波那契数列
1.暴力递归
1.代码
int fib(int N) { if (N == 1 || N == 2) return 1; return fib(N - 1) + fib(N - 2); }
2.递归树
1.但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助
2.图片

3.递归算法时间复杂度
0.用子问题个数乘以解决一个子问题需要的时间
1.首先计算子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)
2.然后计算解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 O(1)
3.这个算法的时间复杂度为二者相乘,即 O(2^n),指数级别,爆炸
4.观察递归树,很明显发现了算法低效的原因:存在大量重复计算
5.这就是动态规划问题的第一个性质:重叠子问题。下面,我们想办法解决这个问题
2.带备忘录的递归解法
1.既然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了
2.一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典)
3.代码
int fib(int N) { if (N < 1) return 0; // 备忘录全初始化为 0 vector<int> memo(N + 1, 0); // 进行带备忘录的递归 return helper(memo, N); } int helper(vector<int>& memo, int n) { // base case if (n == 1 || n == 2) return 1; // 已经计算过 if (memo[n] != 0) return memo[n]; memo[n] = helper(memo, n - 1) + helper(memo, n - 2); return memo[n]; }
4.递归树
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数
5.复杂度
本算法不存在冗余计算,子问题个数为 O(n),本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击
6.和动态规划对比
带备忘录的递归解法的效率已经和迭代的动态规划解法一样了.只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」
7.自顶向下
画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 这两个 base case,然后逐层返回答案
8.自底向上
直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算
3.dp数组的迭代解法
1.思想
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算岂不美哉
2.代码
int fib(int N) { vector<int> dp(N + 1, 0); // base case dp[1] = dp[2] = 1; for (int i = 3; i <= N; i++) dp[i] = dp[i - 1] + dp[i - 2]; return dp[N]; }
3.状态转移方程
1.它是解决问题的核心。而且很容易发现,其实状态转移方程直接代表着暴力解法
2.千万不要看不起暴力解,动态规划问题最困难的就是写出这个暴力解,即状态转移方程。只要写出暴力解,优化方法无非是用备忘录或者 DP table,再无奥妙可言
4.状态压缩
1.当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1)
2.代码
int fib(int n) { if (n == 2 || n == 1) return 1; int prev = 1, curr = 1; for (int i = 3; i <= n; i++) { int sum = prev + curr; prev = curr; curr = sum; } return curr; }
3.这个技巧就是所谓的「状态压缩」,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据,一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)
3.凑零钱问题
0.问题
给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1
1.暴力递归
1.首先,这个问题是动态规划问题,因为它具有「最优子结构」的。要符合「最优子结构」,子问题间必须互相独立
2.回到凑零钱问题,为什么说它符合最优子结构呢?比如你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1 的硬币)就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立的
3.四大步骤
1.确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0
2.确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount
3.确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」
4.明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。所以我们可以这样定义 dp 函数: dp(n) 的定义:输入一个目标金额 n,返回凑出目标金额 n 的最少硬币数量
4.伪码
def coinChange(coins: List[int], amount: int): # 定义:要凑出金额 n,至少要 dp(n) 个硬币 def dp(n): # 做选择,选择需要硬币最少的那个结果 for coin in coins: res = min(res, 1 + dp(n - coin)) return res # 题目要求的最终结果是 dp(amount) return dp(amount)
5.代码
def coinChange(coins: List[int], amount: int): def dp(n): # base case if n == 0: return 0 if n < 0: return -1 # 求最小值,所以初始化为正无穷 res = float('INF') for coin in coins: subproblem = dp(n - coin) # 子问题无解,跳过 if subproblem == -1: continue res = min(res, 1 + subproblem) return res if res != float('INF') else -1 return dp(amount)
6.状态转移方程
7.递归树
8.复杂度
子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k * n^k),指数级别
2.带备忘录的递归
1.代码
def coinChange(coins: List[int], amount: int): # 备忘录 memo = dict() def dp(n): # 查备忘录,避免重复计算 if n in memo: return memo[n] # base case if n == 0: return 0 if n < 0: return -1 res = float('INF') for coin in coins: subproblem = dp(n - coin) if subproblem == -1: continue res = min(res, 1 + subproblem) # 记入备忘录 memo[n] = res if res != float('INF') else -1 return memo[n] return dp(amount)
2.复杂度
很显然「备忘录」大大减小了子问题数目,完全消除了子问题的冗余,所以子问题总数不会超过金额数 n,即子问题数目为 O(n)。处理一个子问题的时间不变,仍是 O(k),所以总的时间复杂度是 O(kn)
3.dp 数组的迭代解法
1.dp数组的定义
dp 函数体现在函数参数,而 dp 数组体现在数组索引: dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出
2.代码
int coinChange(vector<int>& coins, int amount) { // 数组大小为 amount + 1,初始值也为 amount + 1 vector<int> dp(amount + 1, amount + 1); // base case dp[0] = 0; // 外层 for 循环在遍历所有状态的所有取值 for (int i = 0; i < dp.size(); i++) { // 内层 for 循环在求所有选择的最小值 for (int coin : coins) { // 子问题无解,跳过 if (i - coin < 0) continue; dp[i] = min(dp[i], 1 + dp[i - coin]); } } return (dp[amount] == amount + 1) ? -1 : dp[amount]; }
3.流程
4.细节
为啥 dp 数组初始化为 amount + 1 呢,因为凑成 amount 金额的硬币数最多只可能等于 amount(全用 1 元面值的硬币),所以初始化为 amount + 1 就相当于初始化为正无穷,便于后续取最小值
4.总结
1.计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”
2.列出动态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整
3.备忘录、DP table 就是在追求“如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花活
力扣算法
0.逆转思维
6.Z字形变换
1.按行排序
0.题目
将一个给定字符串根据给定的行数,以从上往下、从左到右进行 Z 字形排列,之后,你的输出需要从左往右逐行读取,产生出一个新的字符串
1.思路
从左到右迭代 ss,将每个字符添加到合适的行。可以使用当前行和当前方向这两个变量对合适的行进行跟踪,只有当我们向上移动到最上面的行或向下移动到最下面的行时,当前方向才会发生改变
2.代码
class Solution { public String convert(String s, int numRows) { if (numRows == 1) return s; List<StringBuilder> rows = new ArrayList<>(); //存储变换后每行的字符 for (int i = 0; i < Math.min(numRows, s.length()); i++) rows.add(new StringBuilder()); int curRow = 0; boolean goingDown = false; for (char c : s.toCharArray()) { //直接遍历s,将其转化为字符数组,并且一一赋值给c rows.get(curRow).append(c); if (curRow == 0 || curRow == numRows - 1) goingDown = !goingDown; curRow += goingDown ? 1 : -1; } StringBuilder ret = new StringBuilder(); for (StringBuilder row : rows) ret.append(row); return ret.toString(); } }
3.复杂度
时间O(n),其中n=len(s),空间O(n)
1.纯算法
7.整数反转
1.题目
给出一个 32 位的有符号整数,你需要将这个整数中每位上的数字进行反转。假设我们的环境只能存储得下 32 位的有符号整数,如果反转后整数溢出那么就返回 0
2.思路
要在没有辅助堆栈/数组的帮助下 “弹出” 和 “推入” 数字,我们可以使用数学方法,先取出最后一位,然后除以10,将最后一位去掉,反转数不断将自身乘10后,加上取出的最后一位数,同时先判断是否会溢出
3.代码
class Solution { public int reverse(int x) { int rev = 0; while (x != 0) { int pop = x % 10; //取出x最后一位 x /= 10; //去掉x最后一位 //int占32位时,取值范围-2147483648~2147483647,所以pop>7 或 pop<-8 if (rev > Integer.MAX_VALUE/10 || (rev == Integer.MAX_VALUE / 10 && pop > Integer.MAX_VALUE % 10)) return 0; if (rev < Integer.MIN_VALUE/10 || (rev == Integer.MIN_VALUE / 10 && pop < Integer.MIN_VALUE % 10)) return 0; rev = rev * 10 + pop; //整体向前移动一位后,加上最后一位 } return rev; } }
4.复杂度
时间: O(log(x)), 空间O(1)
2.数组
0.频率
1,4,11,42,53,15,121,238,561,85,169,66,88,283,16,56,122,48,31,289,41,128,152,54,26,442,39
35.搜索插入位置
1.二分法
0.题目
给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。你可以假设数组中无重复元素
1.思想
1.返回值为[first, last)内第一个不小于value的值的位置,注意初始区间的右边界是取不到的,也就是左闭右开,也决定了left和right的变化(left=mid+1,right=mid)
2.mid=first+(last−first)/2这样写是防止大数溢出
3.最后返回last也可以,二者是重合的,以上代码即使区间为空、答案不存在、有重复元素、搜索开/闭的上/下界也同样适用,而且加减1的位置调整只出现了一次
4.如果对于nums[mid] < value改成nums[mid] <= value又变成什么?这个不难思考出来,结果会收敛到第一个大于value的位置,因为相等的时候满足if条件,区间左端仍然会变化,得不到相等位置
5.以上数组是递增的,如果是递减的又该怎么改,实际上只需要将条件反过来看就好了,改成nums[mid]>=value,结果就会收敛到第一个不大于value的位置
6.回到这道题,在数组中找到目标值,并返回其索引,如果目标值不存在于数组中,返回它将会被按顺序插入的位置。那么,我们使用模板找到一个不小于target的值,而且是数组中第一个,那么,对于题目的要求,我们只需要返回求出的value的索引即可,即first或者last
2.代码
class Solution: def searchInsert(self, nums: List[int], target: int) -> int: left, right = 0, len(nums) #采用左闭右开区间[left,right) while left < right: # 右开所以不能有=,区间不存在 mid = left + (right - left)//2 # 防止溢出, //表示整除 if nums[mid] < target: # 中点小于目标值,在右侧,可以得到相等位置 left = mid + 1 # 左闭,所以要+1 else: right = mid # 右开,真正右端点为mid-1 return left # 此算法结束时保证left = right,返回谁都一样
3.复杂度
时间复杂度:O(logn),其中n为数组的长度。二分查找所需的时间复杂度为 O(logn)
空间复杂度:O(1)。我们只需要常数空间存放若干变量
1.两数之和
1.暴力法
0.题目
给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。假设每种输入只会对应一个答案。但是,数组中同一个元素不能使用两遍
class Solution { public int[] twoSum(int[] nums, int target) { for (int i = 0; i < nums.length; i++) { for (int j = i + 1; j < nums.length; j++) { if (nums[j] == target - nums[i]) { return new int[] { i, j }; } } } throw new IllegalArgumentException("No two sum solution"); } }
时间O(n^2), 空间O(1)
2.两遍哈希表
class Solution { public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { map.put(nums[i], i); } for (int i = 0; i < nums.length; i++) { int complement = target - nums[i]; if (map.containsKey(complement) && map.get(complement) != i) { return new int[] { i, map.get(complement) }; } } throw new IllegalArgumentException("No two sum solution"); } }
使用了两次迭代。在第一次迭代中,我们将每个元素的值和它的索引添加到表中。然后,在第二次迭代中,我们将检查每个元素所对应的目标元素(target−nums[i])是否存在于表中。注意,该目标元素不能是 nums[i]本身!此方法将时间复杂度降为O(n),同时将空间复杂度提高到了O(n)。
时间O(n), 空间O(n)
3.一遍哈希表
class Solution { public int[] twoSum(int[] nums, int target) { Map<Integer, Integer> map = new HashMap<>(); for (int i = 0; i < nums.length; i++) { int complement = target - nums[i]; if (map.containsKey(complement)) { return new int[] { map.get(complement), i }; } map.put(nums[i], i); } throw new IllegalArgumentException("No two sum solution"); } }
对于方法二还有一点优化,就是将两次迭代合并到一次中完成,创建一个哈希表,对于每一个 x,我们首先查询哈希表中是否存在 target - x,然后将 x 插入到哈希表中,即可保证不会让 x 和自己匹配
4.总结
对于使用哈希表的算法,有人提出了异议,HashMap的containsKey 里面还有一个循环,也就还是O(n^2),map还增加了空间复杂度和开销,综合来看还是暴力法最为有效,但是这个观点也有点问题:这个containsKey里的循环, 只有冲突了才会进入, 同时如果冲突频繁, 会改用getTreeNode方法去获取值, getTreeNode是从一棵红黑树中获取值, 时间复杂度顶多O(logN)。
167.两数之和II有序数组
1.二分法
0.题目
给定一个已按照升序排列的有序数组,找到两个数使得它们相加之和等于目标数。 函数应该返回这两个下标值index1和index2,其中index1必须小于index2 返回的下标值(index1 和 index2)不是从零开始的。 你可以假设每个输入只对应唯一的答案,而且你不可以重复使用相同的元素
1.代码
class Solution { public int[] twoSum(int[] numbers, int target) { for (int i = 0; i < numbers.length; ++i) { int low = i + 1, high = numbers.length - 1; while (low <= high) { int mid = (high - low) / 2 + low; if (numbers[mid] == target - numbers[i]) { return new int[]{i + 1, mid + 1}; } else if (numbers[mid] > target - numbers[i]) { high = mid - 1; } else { low = mid + 1; } } } return new int[]{-1, -1}; } }
2.复杂度
时间: O(nlogn), 空间O(1)
1.双指针
1.代码
class Solution { public int[] twoSum(int[] numbers, int target) { int low = 0, high = numbers.length - 1; while (low < high) { int sum = numbers[low] + numbers[high]; if (sum == target) { return new int[]{low + 1, high + 1}; } else if (sum < target) { ++low; } else { --high; } } return new int[]{-1, -1}; } }
2.复杂度
时间: O(n), 空间: O(1)
170.两数之和III数据结构设计vip
653.两数之和IV输入BST
1.HashSet+递归
0.题目
给定一个二叉搜索树和一个目标结果,如果 BST 中存在两个元素且它们的和等于给定的目标结果,则返回 true
1.思想
在树的每个节点上遍历它的两棵子树(左子树和右子树),寻找另外一个匹配的数。在遍历过程中,将每个节点的值都放到一个 set中
2.代码
public class Solution { public boolean findTarget(TreeNode root, int k) { Set < Integer > set = new HashSet(); return find(root, k, set); } public boolean find(TreeNode root, int k, Set < Integer > set) { if (root == null) return false; if (set.contains(k - root.val)) return true; set.add(root.val); return find(root.left, k, set) || find(root.right, k, set); } }
因为set集合每次遍历时都需要更新,应该作为参数放入新函数中,所以新增一个函数
3.复杂度
时间: O(n), 空间O(n)
2.HashSet+BFS
1.思想
使用广度优先搜索遍历二叉树
2.代码
public class Solution { public boolean findTarget(TreeNode root, int k) { Set < Integer > set = new HashSet(); Queue < TreeNode > queue = new LinkedList(); queue.add(root); while (!queue.isEmpty()) { if (queue.peek() != null) { TreeNode node = queue.remove(); if (set.contains(k - node.val)) return true; set.add(node.val); queue.add(node.right); queue.add(node.left); } else queue.remove(); } return false; } }
3.复杂度
时间: O(n), 空间O(n)
3.BST的性质+双指针
1.思想
BST 的中序遍历结果是按升序排列的。中序遍历给定的BST,并将遍历结果存储到 list中,遍历完成后,使用两个指针 l和 r作为 list的头部索引和尾部索引
2.代码
public class Solution { public boolean findTarget(TreeNode root, int k) { List < Integer > list = new ArrayList(); inorder(root, list); int l = 0, r = list.size() - 1; while (l < r) { int sum = list.get(l) + list.get(r); if (sum == k) return true; if (sum < k) l++; else r--; } return false; } public void inorder(TreeNode root, List < Integer > list) { if (root == null) return; inorder(root.left, list); list.add(root.val); inorder(root.right, list); } }
3.复杂度
时间: O(n), 空间O(n)
1214.查找两棵二叉搜索树之和vip
560.和为k的连续子数组个数
1.枚举
0.题目
给定一个整数数组和一个整数k,你需要找到该数组中和为 k的连续的子数组的个数
1.思路
我们可以枚举 [0..i]里所有的下标 j来判断是否符合条件,可能有读者会认为假定我们确定了子数组的开头和结尾,还需要 O(n)的时间复杂度遍历子数组来求和,那样复杂度就将达到 O(n^3)从而无法通过所有测试用例。但是如果我们知道 [j,i]子数组的和,就能 O(1)推出 [j-1,i]的和,因此这部分的遍历求和是不需要的,我们在枚举下标 j的时候已经能 O(1)求出 [j,i]的子数组之和
2.代码
public class Solution { public int subarraySum(int[] nums, int k) { int count = 0; for (int start = 0; start < nums.length; ++start) { int sum = 0; //从此数开始,依次向前求子数列之和 for (int end = start; end >= 0; --end) { sum += nums[end]; if (sum == k) { count++; } } } return count; } }
3.复杂度
时间: O(n^2), 空间: O(1)
2.前缀和+哈希表
1.思路
1.方法一的瓶颈在于对每个i,我们需要枚举所有的j来判断是否符合条件
2.定义pre[i]为[0..i]里所有数的和,则pre[i]可以由pre[i−1]递推而来,即:pre[i]=pre[i−1]+nums[i],那么「[j..i]这个子数组和为 k」这个条件我们可以转化为pre[i]−pre[j−1]==k,移项可得符合条件的下标j需要满足pre[j−1]==pre[i]−k
3.考虑以i结尾的和为k的连续子数组个数时只要统计有多少个前缀和为pre[i]−k 的 pre[j] 即可。我们建立哈希表mp,以和为键,出现次数为对应的值,记录 pre[i] 出现的次数,从左往右边更新mp边计算答案,那么以i结尾的答案mp[pre[i]−k] 即可在 O(1)时间内得到。最后的答案即为所有下标结尾的和为 k的子数组个数之和
4.需要注意的是,从左往右边更新边计算的时候已经保证了mp[pre[i]−k] 里记录的 pre[j]的下标范围是0≤j≤i。同时,由于pre[i]的计算只与前一项的答案有关,因此我们可以不用建立pre 数组,直接用pre 变量来记录 pre[i-1]的答案即可
2.代码
public class Solution { public int subarraySum(int[] nums, int k) { int count = 0, pre = 0; //pre为前缀和 HashMap < Integer, Integer > mp = new HashMap < > (); mp.put(0, 1); //初始化,和为0,出现次数为1 for (int i = 0; i < nums.length; i++) { pre += nums[i];//pre[i]的计算只与前一项的答案有关,不用建立pre数组 if (mp.containsKey(pre - k)) { //pre[i]−pre[j−1]==k count += mp.get(pre - k); } mp.put(pre, mp.getOrDefault(pre, 0) + 1); //key为pre,value为出现次数 } return count; } }
3.复杂度
时间复杂度:O(n),其中 n为数组的长度。我们遍历数组的时间复杂度为 O(n),中间利用哈希表查询删除的复杂度均为 O(1),因此总时间复杂度为 O(n)
空间复杂度:O(n),其中 n为数组的长度。哈希表在最坏情况下可能有 n个不同的键值,因此需要 O(n)的空间复杂度
523.判断和为k的倍数的连续子数组
1.前缀和
0.题目
给定一个包含非负数的数组和一个目标整数k,判断该数组是否含有连续的子数组,其大小至少为2,且总和为k的倍数,即总和为n*k,其中n也是一个整数
1.思路
1.用一个额外的sum数组保存数组的累积和,sum[i]保存着到第i个元素位置的前缀和
2.我们不会遍历整个子数组来求和,我们只需要使用数组的前缀和,求第 i个数到第 j个数,我们只需要求出sum[j]−sum[i]+nums[i]
2.代码
public class Solution { public boolean checkSubarraySum(int[] nums, int k) { int[] sum = new int[nums.length]; sum[0] = nums[0]; for (int i = 1; i < nums.length; i++) sum[i] = sum[i - 1] + nums[i];//sum[i]保存到第i个元素位置的前缀和 for (int start = 0; start < nums.length - 1; start++) { for (int end = start + 1; end < nums.length; end++) { //求第i个数到第j个数,只需要求出 sum[j] - sum[i] + nums[i] int summ = sum[end] - sum[start] + nums[start]; if (summ == k || (k != 0 && summ % k == 0)) return true; } } return false; } }
3.复杂度
时间: O(n^2), 空间: O(n)
2.动态规划
1.思路
分别算出长度为2,3,……m的子数组的和,判断是否为k的倍数即可 我们将nums的数据拷贝到dp中 计算长度为2的子数组和时:dp[j] = dp[j] + nums[j+1]; 这里的dp[j]就是nums[j] 计算长度为3的子数组和时:dp[j] = dp[j] + nums[j+2]; 这里的dp[j]是更新过的dp[j],一个dp[j]相当于nums[j]+nums[j+1] 这样当计算长度为p的子数组大小时,就可以利用已经计算过的长度为p-1的子数组进行更新,就可以对原来的三重循环进行优化,变为二重循环
2.代码
class Solution { int[] dp = new int[10010]; public boolean checkSubarraySum(int[] nums, int k) { if(nums.length < 2) return false; //k==0时单独考虑,其实和k!=0时只有做不做模运算的区别 if(k == 0){ for(int i = 0; i < nums.length; i++){ for(int j = 0; j < nums.length-i; j++){ dp[j] = (dp[j]+nums[j+i]); if(i != 0 && dp[j] == 0) return true; } } return false; } //当i=k时,dp[j]表示以j为起始下标,nums中连续k+1个整数的和 //如当i=0时,相当于将nums拷贝到dp //i=1时,dp[0]相当于以0为起始下标,nums中2个整数的和,即nums[0]+nums[1] //每次计算时都可以用原来的dp进行更新,而不用一个个去加 for(int i = 0; i < nums.length; i++){ for(int j = 0; j < nums.length-i; j++){ dp[j] = (dp[j]+nums[j+i]) % k; if(i != 0 && dp[j] == 0) return true; } } return false; } }
3.复杂度
时间: O(n^2), 空间: O(n)
3.哈希表
1.思路
1.使用HashMap来保存到第 i个元素为止的累积和,但我们对这个前缀和除以k取余数
2.我们遍历一遍给定的数组,记录到当前位置为止的sum%k。一旦我们找到新的sum%k的值,我们就往HashMap中插入一条记录 (sum%k, i)
3.假设第i个位置的sum%k的值为rem 。如果以i为左端点的任何子数组的和是k的倍数,比方说这个位置为j,那么HashMap中第j个元素保存的值为(rem+n∗k)%k,我们会发现(rem+n∗k)%k=rem,也就是跟第i个元素保存到 HashMap 中的值相同
4.得出结论:只要sum%k的值已经被放入HashMap中,代表着有两个索引i和j,它们之间元素的和是k的整数倍.只要HashMap中有相同的sum%k,我们就可以直接返回{True}
2.代码
public class Solution { public boolean checkSubarraySum(int[] nums, int k) { int sum = 0; HashMap < Integer, Integer > map = new HashMap < > (); map.put(0, -1); for (int i = 0; i < nums.length; i++) { sum += nums[i]; if (k != 0) sum = sum % k; //只要sum%k的值已经被放入HashMap中,代表有两个索引i和j,它们之间元素的和是k的整数倍 if (map.containsKey(sum)) { if (i - map.get(sum) > 1) return true; } else map.put(sum, i);//保存到第i个元素为止的累积和并除以k取余数 } return false; } }
3.复杂度
时间: O(n), 空间: O(min(n,k)) HashMap最多包含min(n,k)个不同的元素。
713.乘积小于k的连续子数组个数
1.二分法
0.题目
给定一个正整数数组 nums。找出该数组内乘积小于 k 的连续的子数组的个数
1.思路
1.对于固定的i,二分查找出最大的j满足nums[i]到nums[j]的乘积小于k。但由于乘积可能会非常大,会导致数值溢出,因此我们需要对nums数组取对数,将乘法转换为加法
2.对nums中的每个数取对数后,我们存储它的前缀和prefix,在二分查找时,对于i和j,我们可以用prefix[j+1]−prefix[i]得到nums[i]到nums[j]的乘积的对数。对于固定的i,当找到最大的满足条件的j 后,它会包含j−i+1个乘积小于k的连续子数组
2.代码
class Solution { public int numSubarrayProductLessThanK(int[] nums, int k) { if (k == 0) return 0; double logk = Math.log(k); double[] prefix = new double[nums.length + 1]; //取对数后的前缀和 for (int i = 0; i < nums.length; i++) { prefix[i+1] = prefix[i] + Math.log(nums[i]);//前缀和的下标从1开始 } int ans = 0; for (int i = 0; i < prefix.length; i++) { //二分法在前缀和中进行,相当于在结果中进行 int lo = i + 1, hi = prefix.length; while (lo < hi) { int mi = lo + (hi - lo) / 2; //当i=1时,prefix[1]相当于去掉了nums[0],即从nums[1]开始算起 //当i=2时,prefix[2]相当于去掉了nums[0]+nums[1],即从nums[2]开始算起 if (prefix[mi] < prefix[i] + logk - 1e-9) lo = mi + 1; else hi = mi; } ans += lo - i - 1;//当i=1时,相当于去掉第一个数之后的结果 } //当i=2时,相当于去掉前两个数之后的结果 return ans; //-1是因为lo本身已经大于目标值了 } }
3.复杂度
时间: O(nlogn), 空间: O(n)
2.双指针
1.思路
1.对于每个right,我们需要找到最小的left,满足它们之间数的乘积小于k,由于当 left增加时,这个乘积是单调不增的,因此可以使用双指针的方法,单调地移动left
2.使用一重循环枚举right,同时设置left 的初始值为 0。在循环的每一步中,表示right向右移动了一位,将乘积乘以nums[right]。此时我们需要向右移动left,直到满足乘积小于k的条件。在每次移动时,需要将乘积除以nums[left]。当left移动完成后,对于当前的right,就包含了right−left+1个乘积小于k 的连续子数组
2.代码
class Solution { public int numSubarrayProductLessThanK(int[] nums, int k) { if (k <= 1) return 0; int prod = 1, ans = 0, left = 0; for (int right = 0; right < nums.length; right++) { prod *= nums[right];//当前的前缀积 while (prod >= k) prod /= nums[left++];//不满足时,将左指针不断右移 //为了不重复计算,每个right定位好后,只计算以right指针为结尾的子串数目 //不以right为结尾的,之前都计算过了,这里不能重复计算 ans += right - left + 1; } return ans; } }
3.复杂度
时间: O(n), 空间: O(1)
53.最大子序和
1.动态规划
0.题目
给定一个整数数组nums,找到一个具有最大和的连续子数组(最少一个元素),返回其最大和
1.思路
1.用ai代表nums[i],用f(i)代表以第i个数结尾的「连续子数组的最大和」
2.如何求f(i)呢?可以考虑ai单独成为一段还是加入f(i−1)对应的那一段,这取决于ai和f(i−1)+ai的大小,写出这样的动态规划转移方程: f(i)=max{f(i−1)+ai,ai}
3.考虑到f(i)只和f(i−1)相关,于是我们可以只用一个变量 pre来维护对于当前 f(i)的f(i−1)的值是多少,从而让空间复杂度降低到O(1),这有点类似「滚动数组」的思想
2.代码
class Solution { public int maxSubArray(int[] nums) { int pre = 0, maxAns = nums[0]; for (int x : nums) { //pre来维护对于当前f(i)的f(i−1)的值是多少 pre = Math.max(pre + x, x);//判断pre是否为负数,是否要加到当前数上 maxAns = Math.max(maxAns, pre);//获取最大值 } return maxAns; } }
3.复杂度
时间: O(n), 空间: O(1)
2.分治:线段树
1.思路
1.这个分治方法类似于「线段树求解 LCIS 问题」的pushUp操作,推荐看一看线段树区间合并法解决 多次询问 的「区间最长连续上升序列问题」和「区间最大子段和问题」
2.如何通过[l,m] 区间的信息和[m+1,r]区间的信息合并成区间[l,r]的信息。最关键的两个问题是:我们要维护区间的哪些信息呢?我们如何合并这些信息呢?
3.对于一个区间[l,r],我们可以维护四个量:
4.当计算好上面的三个量之后,就很好计算[l,r]的 mSum 了。我们可以考虑[l,r] 的 mSum 对应的区间是否跨越m——它可能不跨越m,也就是说[l,r]的 mSum 可能是「左子区间」的 mSum 和 「右子区间」的 mSum 中的一个;它也可能跨越m,可能是「左子区间」的 rSum 和 「右子区间」的 lSum 求和。三者取大
2.代码
class Solution { public int maxSubArray(int[] nums) { if (nums == null || nums.length <= 0)// 输入校验 return 0; int len = nums.length;// 获取输入长度 return getInfo(nums, 0, len - 1).mSum; } class wtevTree { //线段树 int lSum;// 以左区间为端点的最大子段和 int rSum;// 以右区间为端点的最大子段和 int iSum;// 区间所有数的和 int mSum;// 该区间的最大子段和 wtevTree(int l, int r, int i, int m) { // 构造函数 lSum = l; rSum = r; iSum = i; mSum = m; } } // 通过既有的属性,计算上一层的属性,一步步往上返回,获得线段树 wtevTree pushUp(wtevTree leftT, wtevTree rightT) { // 新子段的lSum等于左区间的lSum或者左区间的 区间和 加上右区间的lSum int l = Math.max(leftT.lSum, leftT.iSum + rightT.lSum); // 新子段的rSum等于右区间的rSum或者右区间的 区间和 加上左区间的rSum int r = Math.max(leftT.rSum + rightT.iSum, rightT.rSum); // 新子段的区间和等于左右区间的区间和之和 int i = leftT.iSum + rightT.iSum; // 新子段的最大子段和,其子段有可能穿过左右区间,或左区间,或右区间 int m = Math.max(leftT.rSum + rightT.lSum, Math.max(leftT.mSum, rightT.mSum)); return new wtevTree(l, r, i, m); } // 递归建立和获得输入区间所有子段的结构 wtevTree getInfo(int[] nums, int left, int right) { if (left == right) // 若区间长度为1,其四个子段均为其值 return new wtevTree(nums[left], nums[left], nums[left], nums[left]); int mid = (left + right) >> 1;// 获得中间点mid,右移一位相当于除以2,运算更快 wtevTree leftT = getInfo(nums, left, mid); wtevTree rightT = getInfo(nums, mid + 1, right);//mid+1,左右区间没有交集。 return pushUp(leftT, rightT);//递归结束后,做最后一次合并 } }
3.复杂度
时间: O(n), 空间: O(logn) 递归栈
4.总结
1.「方法二」相较于「方法一」来说,时间复杂度相同,但是因为使用了递归,并且维护了四个信息的结构体,运行的时间略长,空间复杂度也不如方法一优秀,而且难以理解。那么这种方法存在的意义是什么呢?
2.但是仔细观察「方法二」,它不仅可以解决区间[0,n−1],还可以用于解决任意的子区间[l,r] 的问题。如果我们把[0,n−1] 分治下去出现的所有子区间的信息都用堆式存储的方式记忆化下来,即建成一颗真正的树之后,我们就可以在O(logn)的时间内求到任意区间内的答案,我们甚至可以修改序列中的值,做一些简单的维护,之后仍然可以在O(logn)的时间内求到任意区间内的答案,对于大规模查询的情况下,这种方法的优势便体现了出来。这棵树就是上文提及的一种神奇的数据结构——线段树
152.最大字序积
1.动态规划
0.题目
给你一个整数数组 nums ,请你找出数组中乘积最大的连续子数组(该子数组中至少包含一个数字),并返回该子数组所对应的乘积
1.思想
1.根据「53.最大子序和」的经验,写出动态规划转移方程: f(i)=max{f(i−1)+ai,ai}是错误的
2.这里的定义并不满足「最优子结构」,当前位置的最优解未必是由前一个位置的最优解转移得到的
3.根据正负性进行分类讨论,当前位置如果是一个负数的话,那么我们希望以它前一个位置结尾的某个段的积也是个负数,这样就可以负负得正,并且我们希望这个积尽可能「负得更多」,即尽可能小。如果当前位置是一个正数的话,我们更希望以它前一个位置结尾的某个段的积也是个正数,并且希望它尽可能地大,可以再维护一个fmin(i),它表示以第i个元素结尾的乘积最小子数组的乘积
4.状态转移方程
5.它代表第i个元素结尾的乘积最大子数组的乘积fmax(i),可以考虑把ai加入第i−1 个元素结尾的乘积最大或最小的子数组的乘积中,二者加上ai,三者取大,就是第i个元素结尾的乘积最大子数组的乘积.第i个元素结尾的乘积最小子数组的乘积fmin(i)同理
2.代码
class Solution { public int maxProduct(int[] nums) { int length = nums.length; int[] maxF = new int[length]; int[] minF = new int[length];//以第i个元素结尾的乘积最小子数组的乘积 System.arraycopy(nums, 0, maxF, 0, length);//nums从0开始的位置复制到maxF从0开始的位置,长度length System.arraycopy(nums, 0, minF, 0, length); for (int i = 1; i < length; ++i) { //两个状态转移方程 maxF[i] = Math.max(maxF[i - 1] * nums[i], Math.max(nums[i], minF[i - 1] * nums[i])); minF[i] = Math.min(minF[i - 1] * nums[i], Math.min(nums[i], maxF[i - 1] * nums[i])); } int ans = maxF[0]; for (int i = 1; i < length; ++i) { ans = Math.max(ans, maxF[i]); } return ans; } }
3.复杂度
时间,空间都是O(n)
2.优化空间
1.思想
由于第i个状态只和第i−1个状态相关,根据「滚动数组」思想,我们可以只用两个变量来维护i−1时刻的状态,一个维护fmax,一个维护fmin
2.代码
class Solution { public int maxProduct(int[] nums) { int maxF = nums[0], minF = nums[0], ans = nums[0]; int length = nums.length; for (int i = 1; i < length; ++i) { int mx = maxF, mn = minF;//只用两个变量来维护i−1时刻的状态,优化空间 maxF = Math.max(mx * nums[i], Math.max(nums[i], mn * nums[i])); minF = Math.min(mn * nums[i], Math.min(nums[i], mx * nums[i])); ans = Math.max(maxF, ans); } return ans; } }
3.复杂度
时间: O(n), 空间: O(1)
198.打家劫舍
1.动态规划+滚动数组
0.题目
你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 一夜之内能够偷窃到的最高金额。
1.思路
1.首先考虑最简单的情况。如果只有一间房屋,则偷窃该房屋,可以偷窃到最高总金额。如果只有两间房屋,则由于两间房屋相邻,不能同时偷窃,只能偷窃其中的一间房屋,因此选择其中金额较高的房屋进行偷窃,可以偷窃到最高总金额
2.如果房屋数量大于两间,应该如何计算能够偷窃到的最高总金额呢?对于第(k>2) 间房屋,有两个选项: 一.偷窃第k间房屋,那么就不能偷窃第k−1 间房屋,偷窃总金额为前k−2间房屋的最高总金额与第k间房屋的金额之和。 二.不偷窃第k间房屋,偷窃总金额为前k−1间房屋的最高总金额。 在两个选项中选择偷窃总金额较大的选项,该选项对应的偷窃总金额即为前k间房屋能偷窃到的最高总金额
3.dp[i]表示前i间房屋能偷窃到的最高总金额,状态转移方程:dp[i]=max(dp[i−2]+nums[i],dp[i−1])
4.边界条件:dp[0]=nums[0]和 dp[1]=max(nums[0],nums[1]),最终的答案即为dp[n−1]
2.代码
class Solution { public int rob(int[] nums) { if (nums == nul|| nums.length == 0) { return 0; } int length = nums.length; if (length == 1) { return nums[0]; } int[] dp = new int[length]; dp[0] = nums[0];//两个边界条件 dp[1] = Math.max(nums[0], nums[1]); for (int i = 2; i < length; i++) {//动态转移方程 dp[i] = Math.max(dp[i - 2] + nums[i], dp[i - 1]); } return dp[length - 1]; } }
3.复杂度
时间: O(n), 空间: O(n)
4.优化
上述方法使用了数组存储结果。考虑到每间房屋的最高总金额只和该房屋的前两间房屋的最高总金额相关,可以使用滚动数组,在每个时刻只需要存储前两间房屋的最高总金额
class Solution { public int rob(int[] nums) { if (nums == nul|| nums.length == 0) { return 0; } int length = nums.length; if (length == 1) { return nums[0]; } //在每个时刻只需要存储前两间房屋的最高总金额,滚动数组 int first = nums[0], second = Math.max(nums[0], nums[1]); for (int i = 2; i < length; i++) { int temp = second; second = Math.max(first + nums[i], second); first = temp; } return second; } }
时间: O(n), 空间: O(1)
213.打家劫舍II围成一圈×
1.动态规划
0.题目
你是一个专业的小偷,计划偷窃沿街的房屋,每间房内都藏有一定的现金。这个地方所有的房屋都围成一圈,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。 给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
1.思路
1.环状排列意味着第一个房子和最后一个房子中只能选择一个偷窃,因此可以把此环状排列房间问题约化为两个单排排列房间子问题:
2.在不偷窃第一个房子的情况下(即nums[1]),最大金额是p1,在不偷窃最后一个房子的情况下(即nums[n−1]),最大金额是p2,综合偷窃最大金额,即max(p1,p2)
3.状态转移方程:dp[i]=max(dp[i−2]+nums[i],dp[i−1])
4.dp[n]只与dp[n−1]和dp[n−2]有关系,因此我们可以设两个变量 cur和 pre 交替记录,将空间复杂度降到 O(1)
2.代码
class Solution { public int rob(int[] nums) { if(nums.length == 0) return 0; if(nums.length == 1) return nums[0]; //复制的长度为左闭右开[0,length-2] return Math.max(myRob(Arrays.copyOfRange(nums, 0, nums.length - 1)), myRob(Arrays.copyOfRange(nums, 1, nums.length))); } private int myRob(int[] nums) { int pre = 0, cur = 0, tmp; for(int num : nums) { tmp = cur; cur = Math.max(pre + num, cur); pre = tmp; } return cur; } }
3.复杂度
时间: O(n), 空间: O(1)
337.打家劫舍III二叉树
1.动态规划
0.题目
在上次打劫完一条街道之后和一圈房屋后,小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为“根”。 除了“根”之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果两个直接相连的房子在同一天晚上被打劫,房屋将自动报警。 计算在不触动警报的情况下,小偷一晚能够盗取的最高金额
1.思路
1.简化一下这个问题:一棵二叉树,树上的每个点都有对应的权值,每个点有两种状态(选中和不选中),问在不能同时选中有父子关系的点的情况下,能选中的点的最大权值和是多少
2.用f(o)表示选择o节点的情况下,o节点的子树上被选择的节点的最大权值和;g(o) 表示不选择o节点的情况下,o节点的子树上被选择的节点的最大权值和;l和r代表o的左右孩子
3.当o被选中时,o的左右孩子都不能被选中,故o被选中情况下子树上被选中点的最大权值和为 l和r不被选中的最大权值和相加,即f(o)= g(l) + g(r)f(o)=g(l)+g(r)
4.当o不被选中时,o的左右孩子可以被选中,也可以不被选中。对于o的某个具体的孩子x,它对o的贡献是x被选中和不被选中情况下权值和的较大值。故 g(o)=max{f(l),g(l)}+max{f(r),g(r)}
5.用哈希映射来存f和g的函数值,用深度优先搜索的办法后序遍历这棵二叉树,我们就可以得到每一个节点的f和g。根节点的f和g的最大值就是我们要找的答案
2.代码
class Solution { Map<TreeNode, Integer> f = new HashMap<TreeNode, Integer>(); Map<TreeNode, Integer> g = new HashMap<TreeNode, Integer>(); public int rob(TreeNode root) { dfs(root);//深度优先遍历根结点 return Math.max(f.getOrDefault(root, 0), g.getOrDefault(root, 0)); } public void dfs(TreeNode node) { if (node == null) { return; } dfs(node.left);//采用后序遍历方法 dfs(node.right); //node被选中情况下子树上被选中点的最大权值和为 l和r不被选中的最大权值和相加 f.put(node, node.val + g.getOrDefault(node.left, 0) + g.getOrDefault(node.right, 0)); //node不被选中情况下的某个具体的孩子x,它对node的贡献是x被选中和不被选中情况下权值和的较大值 g.put(node, Math.max(f.getOrDefault(node.left, 0), g.getOrDefault(node.left, 0)) + Math.max(f.getOrDefault(node.right, 0), g.getOrDefault(node.right, 0))); } }
3.复杂度
以上的算法对二叉树做了一次后序遍历,时间复杂度是 O(n);由于递归会使用到栈空间,空间代价是 O(n),哈希映射的空间代价也是 O(n),故空间复杂度也是 O(n)
4.优化
无论是f(o)还是 g(o),他们最终的值只和f(l)、g(l)、f(r)、g(r)有关,所以对于每个节点,我们只关心它的孩子节点们的f和g是多少。我们可以设计一个结构,表示某个节点的f和g值,在每次递归返回的时候,都把这个点对应的f和g返回给上一级调用,这样可以省去哈希映射的空间
class Solution { public int rob(TreeNode root) { int[] rootStatus = dfs(root); return Math.max(rootStatus[0], rootStatus[1]); } public int[] dfs(TreeNode node) { if (node == null) { return new int[]{0, 0}; } int[] l = dfs(node.left); int[] r = dfs(node.right); //l[0],r[0]表示抢的权值,l[1],r[1]表示不抢的权值 int selected = node.val + l[1] + r[1]; int notSelected = Math.max(l[0], l[1]) + Math.max(r[0], r[1]); return new int[]{selected, notSelected}; } }
时间复杂度:O(n)。上文中已分析。 空间复杂度:O(n)。虽然优化过的版本省去了哈希映射的空间,但是栈空间的使用代价依旧是 O(n),故空间复杂度不变
15.三数之和
1.排序+双指针
0.题目
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c 使得 a + b + c = 0 ?请你找出所有满足条件且不重复的三元组
1.不重复
无法简单地使用三重循环枚举所有的三元组,需要使用哈希表进行去重操作,将数组中的元素从小到大进行排序,随后使用普通的三重循环就可以满足上面的要求
对于每一重循环而言,相邻两次枚举的元素不能相同,否则也会造成重复
2.双指针
当需要枚举数组中的两个元素时,如果发现随着第一个元素的递增,第二个元素是递减的,那么就可以使用双指针的方法,将枚举的时间复杂度从 O(N^2)减少至 O(N)
保持第二重循环不变,而将第三重循环变成一个从数组最右端开始向左移动的指针
补充细节,需要保持左指针一直在右指针的左侧(即满足b≤c)
3.代码
class Solution { public List<List<Integer>> threeSum(int[] nums) { int n = nums.length; Arrays.sort(nums); //将数组进行排序 List<List<Integer>> ans = new ArrayList<List<Integer>>(); // 枚举 a for (int first = 0; first < n; ++first) { // 需要和上一次枚举的数不相同 if (first > 0 && nums[first] == nums[first - 1]) { continue; } // c 对应的指针初始指向数组的最右端 int third = n - 1; int target = -nums[first]; // 枚举 b for (int second = first + 1; second < n; ++second) { // 需要和上一次枚举的数不相同 if (second > first + 1 && nums[second] == nums[second - 1]) { continue; } // 需要保证 b 的指针在 c 的指针的左侧 while (second < third && nums[second] + nums[third] > target) { --third; } if (second == third) { // 如果指针重合,后续也不会满足条件,可以退出循环 break; } if (nums[second] + nums[third] == target) { ans.add(Arrays.asList(nums[first],nums[second],nums[third])); } } } return ans; } }
4.复杂度
时间复杂度:O(N^2),其中N是数组nums的长度
空间复杂度:O(logN)。我们忽略存储答案的空间,额外的排序的空间复杂度为 O(logN)。然而我们修改了输入的数组nums,在实际情况下不一定允许,因此也可以看成使用了一个额外的数组存储了nums的副本并进行排序,空间复杂度为 O(N)
18.四数之和
1.排序+双指针
0.题目
给定一个包含 n 个整数的数组 nums 和一个目标值 target,判断 nums 中是否存在四个元素 a,b,c 和 d ,使得 a + b + c + d 的值与 target 相等?找出所有满足条件且不重复的四元组。
1.思路
就是在三数之和的基础上增加了一层循环,再利用双指针,但使用了最大最小值来提前进行判断,节省了时间
2.代码
class Solution { public List<List<Integer>> fourSum(int[] nums,int target){ List<List<Integer>> result=new ArrayList<>();/*定义一个返回值*/ if(nums==null||nums.length<4){ return result; } Arrays.sort(nums); int length=nums.length; /*定义4个指针k,i,j,h k从0开始遍历,i从k+1开始遍历,留下j和h,j指向i+1,h指向数组最大值*/ for(int k=0;k<length-3;k++){ /*当k的值与前面的值相等时忽略*/ if(k>0&&nums[k]==nums[k-1]){ continue; } /*获取当前最小值,如果最小值比目标值大,说明后面越来越大的值根本没戏*/ int min1=nums[k]+nums[k+1]+nums[k+2]+nums[k+3]; if(min1>target){ break;//这里使用的break,直接退出此次循环,因为后面的数只会更大 } /*获取当前最大值,如果最大值比目标值小,说明后面越来越小的值根本没戏,忽略*/ int max1=nums[k]+nums[length-1]+nums[length-2]+nums[length-3]; if(max1<target){ continue;//这里使用continue,继续下一次循环,因为下一次循环有更大的数 } /*第二层循环i,初始值指向k+1*/ for(int i=k+1;i<length-2;i++){ if(i>k+1&&nums[i]==nums[i-1]){/*当i的值与前面的值相等时忽略*/ continue; } int j=i+1;/*定义指针j指向i+1*/ int h=length-1;/*定义指针h指向数组末尾*/ int min=nums[k]+nums[i]+nums[j]+nums[j+1]; if(min>target){ break; } int max=nums[k]+nums[i]+nums[h]+nums[h-1]; if(max<target){ continue; } /*开始j指针和h指针的表演,计算当前和,如果等于目标值,j++并去重,h--并去重,当当前和大于目标值时h--,当当前和小于目标值时j++*/ while (j<h){ int curr=nums[k]+nums[i]+nums[j]+nums[h]; if(curr==target){ result.add(Arrays.asList(nums[k],nums[i],nums[j],nums[h]));//一步完成 j++; while(j<h&&nums[j]==nums[j-1]){ j++; } h--; while(j<h&&i<h&&nums[h]==nums[h+1]){ h--; } }else if(curr>target){ h--; }else { j++; } } } } return result; } }
3.复杂度
时间: O(n^3),排序的时间复杂度是O(nlogn)
空间: O(logn),主要取决于排序额外使用的空间
454.四数组相加×
两两分组用map
0.题目
给定四个包含整数的数组列表A , B , C , D ,计算有多少个元组 (i, j, k, l),使得A[i] + B[j] + C[k] + D[l] = 0
1.思路
一.采用分为两组,HashMap存一组,另一组和HashMap进行比对
二.这样的话情况就可以分为三种: 1.HashMap存一个数组,如A。然后计算三个数组之和,如BCD。时间复杂度为:O(n)+O(n^3),得到O(n^3). 2.HashMap存三个数组之和,如ABC。然后计算一个数组,如D。时间复杂度为:O(n^3)+O(n),得到O(n^3). 3.HashMap存两个数组之和,如AB。然后计算两个数组之和,如CD。时间复杂度为:O(n^2)+O(n^2),得到O(n^2).
三.根据第二点我们可以得出要存两个数组算两个数组。
四.我们以存AB两数组之和为例。首先求出A和B任意两数之和sumAB,以sumAB为key,sumAB出现的次数为value,存入hashmap中。 然后计算C和D中任意两数之和的相反数sumCD,在hashmap中查找是否存在key为sumCD。
2.代码
class Solution { public int fourSumCount(int[] A, int[] B, int[] C, int[] D) { HashMap<Integer, Integer> map = new HashMap<Integer,Integer>(); int len=A.length; for(int a:A) { for(int b:B) { //存储a+b的结果,不存在赋值为1,存在在原来基础上+1 map.merge(a+b, 1, (old,new_)->old+1); } } int res=0; for(int c:C) { for(int d:D) { res+=map.getOrDefault(0-c-d, 0); } } return res; } }
3.复杂度
时间: O(n^2), 空间: O(1)
4.总结
算法中间使用了map的新方法merge和getOrDefault,相比较于传统的判断写法,大大提高了效率
子主题
3.链表
0.频率
2,21,206,23,237,148,138,141,24,234,445,147,143,92,25,160,328,142,203,19,86,109,83,61,82,430,817,
2.两数逆序相加
0.题目
给出两个 非空 的链表用来表示两个非负的整数。其中,它们各自的位数是按照 逆序 的方式存储的,并且它们的每个节点只能存储 一位 数字。 如果,我们将这两个数相加起来,则会返回一个新的链表来表示它们的和。 您可以假设除了数字 0 之外,这两个数都不会以 0 开头。
1.解法
public class ListNode addTwoNumbers(ListNode l1, ListNode l2) { ListNode dummyHead = new ListNode(0); ListNode p = l1, q = l2, curr = dummyHead; int carry = 0; while (p != null || q != null) { int x = (p != null) ? p.val : 0; int y = (q != null) ? q.val : 0; int sum = carry + x + y; carry = sum / 10; curr.next = new ListNode(sum % 10); curr = curr.next; if (p != null) p = p.next; if (q != null) q = q.next; } if (carry > 0) { curr.next = new ListNode(carry); } return dummyHead.next; }
这题最重要的点在于相加之后的进位情况,这是很容易忽略的,还有一个重点是最后一位相加结束后,仍然可能产生进位,这是需要单独处理此情况。刚开始的思维想要将两个链表转化为数字进行相加,但是这种方法对于过长的数字会出现溢出的情况,所以只能对链表进行处理。
时间: O(max(m, n)), 空间: O(max(m, n))
2.两数顺序相加
public ListNode addTwoNumbersExt(ListNode l1, ListNode l2) { // 定义一个新链表 用来存储计算结果,默认创建的是带头节点所以默认是0 ListNode temp = new ListNode(0); ListNode node = temp;// 记住第一个节点 int carry = 0;// 进位 也就是 如果相加的是大于10 我们在下一位的时候加1,默认是0 Stack<Integer> stackL1 = new Stack<>(); // 使用栈 接收l1链表的数据 Stack<Integer> stackL2 = new Stack<>();// 使用栈 接收l2链表的数据 // 对两个两个链表进行遍历,并且加入栈中 while (l1 != null || l2 != null) { // 判断l1链表是否为空 如果不为空则指向下一个节点 if (l1 != null) { stackL1.push(l1.val); l1 = l1.next; } // 判断l2链表是否为空 如果不为空则指向下一个节点 if (l2 != null) { stackL2.push(l2.val); l2 = l2.next; } } // 遍历两个栈 while (!stackL1.empty() || !stackL2.empty()) { int x = (!stackL1.empty()) ? stackL1.pop() : 0;// 如果stackL1栈不为null 就弹栈,否则为0 int y = (!stackL2.empty()) ? stackL2.pop() : 0;// 如果stackL2栈不为null 就弹栈,否则为0 int sum = x + y + carry;// 因为都是个位数相加 所以最大指是等于19的 ,得把进位加上 // 把得出的结果存入链表中,但是必须跟10取余 只要不管是否大于等于10 都会取得个位数的 temp.next = new ListNode(sum % 10); temp = temp.next;// 指向下一个节点 // 求进位 如果大于10 即可以求出进位为1 否则就是0 carry = sum / 10; } // 有可能存在一种情况 但两个链表最后的值计算 大于等于10 的情况且两个链表都为空了 ,得把进位放入最后一个节点 if (carry > 0) { temp.next = new ListNode(carry); temp = temp.next; } // 因为默认创建的是带头节点 所以我们要从temp链表的下一个开始 return node.next; }
不是逆序存储,应想方法将其变为逆序存储,可以使用链表自带的逆序方法,还可以使用堆栈来实现逆序,最后需要将结果逆转
4.字符串
0.频率
5,20,937,3,273,22,1249,68,49,415,76,10,17,91,6,609,93,227,680,767,12,8,67,126,13,336,
3.不含重复字符的最长子串
1.滑动窗口
1.思想前提
假设我们选择字符串中的第 k个字符作为起始位置,并且得到了不包含重复字符的最长子串的结束位置为 r_k 。那么当我们选择第 k+1个字符作为起始位置时,首先从 k+1 到 r_k的字符显然是不重复的,并且由于少了原本的第 k个字符,我们可以尝试继续增大 r_k,直到右侧出现了重复字符为止。
2.具体思想
这样一来,我们就可以使用「滑动窗口」来解决这个问题了: 我们使用两个指针表示字符串中的某个子串(的左右边界)。其中左指针代表着上文中「枚举子串的起始位置」,而右指针即为上文中的 r_k; 在每一步的操作中,我们会将左指针向右移动一格,表示 我们开始枚举下一个字符作为起始位置,然后我们可以不断地向右移动右指针,但需要保证这两个指针对应的子串中没有重复的字符。在移动结束后,这个子串就对应着 以左指针开始的,不包含重复字符的最长子串。我们记录下这个子串的长度; 在枚举结束后,我们找到的最长的子串的长度即为答案。
3.哈希表判断重复字符
我们还需要使用一种数据结构来判断 是否有重复的字符,常用的数据结构为哈希集合,在左指针向右移动的时候,我们从哈希集合中移除一个字符,在右指针向右移动的时候,我们往哈希集合中添加一个字符。
4.代码
class Solution { public int lengthOfLongestSubstring(String s) { // 哈希集合,记录每个字符是否出现过 Set<Character> occ = new HashSet<Character>(); int n = s.length(); // 右指针,初始值为 -1,相当于我们在字符串的左边界的左侧,还没有开始移动 int rk = -1, ans = 0; for (int i = 0; i < n; ++i) { if (i != 0) { // 左指针向右移动一格,移除一个字符 occ.remove(s.charAt(i - 1)); } while (rk + 1 < n && !occ.contains(s.charAt(rk + 1))) { // 不断地移动右指针 occ.add(s.charAt(rk + 1)); ++rk; } // 第 i 到 rk 个字符是一个极长的无重复字符子串 ans = Math.max(ans, rk - i + 1); } return ans; } }
5.复杂度
时间:O(N),其中N是字符串的长度, 空间: O(∣Σ∣),∣Σ∣ 表示字符集的大小
5.最长回文子串
1.动态规划dp
1.思想
对于一个子串而言,如果它是回文串,并且长度大于 2,那么将它首尾的两个字母去除之后,它仍然是个回文串
2.状态转移方程
P(i,j)=P(i+1,j−1)∧(Si == Sj)
3.边界条件
P(i,i)=true 单个字符 P(i,i+1)=(Si==Si+1) 两个字符
4.代码
class Solution { public String longestPalindrome(String s) { int n = s.length(); boolean[][] dp = new boolean[n][n]; String ans = ""; for (int k = 0; k < n; ++k) { for (int i = 0; i + k < n; ++i) { int j = i + k; //先处理两种临界情况 if (k == 0) { //k=0时,j=i,dp[i][j]相当于一个字符,一定是回文串 dp[i][j] = true; } else if (k == 1) { //k=1时,j=i+1,dp[i][j]相当于连续两个字符,满足两字符相同时一定是回文串 dp[i][j] = (s.charAt(i) == s.charAt(j)); } else { //k>1时,需满足动态规划的转移方程: P(i,j)=P(i+1,j−1)∧(Si == Sj) dp[i][j] = (s.charAt(i) == s.charAt(j) && dp[i + 1][j - 1]); } if (dp[i][j] && k + 1 > ans.length()) { //更新回文串的长度 ans = s.substring(i, i + k + 1); //注意子串方法的具体用法 } } } return ans; } }
5.复杂度
时间复杂度:O(n^2),其中 n是字符串的长度。动态规划的状态总数为 O(n^2),对于每个状态,我们需要转移的时间为 O(1)。 空间复杂度:O(n^2),即存储动态规划状态需要的空间
2.中心扩展
1.前提条件
根据状态转移方程,可以发现所有的状态在转移的时候的可能性都是唯一的。也就是说,我们可以从每一种边界情况开始「扩展」,也可以得出所有的状态对应的答案。
「边界情况」对应的子串实际上就是我们「扩展」出的回文串的「回文中心」
2.本质
枚举所有的「回文中心」并尝试「扩展」,直到无法扩展为止,此时的回文串长度即为此「回文中心」下的最长回文串长度
3.代码
class Solution { public String longestPalindrome(String s) { if (s == null || s.length() < 1){ return ""; } int start = 0;// 初始化最大回文子串的起点和终点 int end = 0; // 遍历每个位置,当做中心位 for (int i = 0; i < s.length(); i++) { // 分别拿到奇数偶数的回文子串长度 int len_odd = expandCenter(s,i,i); int len_even = expandCenter(s,i,i + 1); int len = Math.max(len_odd,len_even);// 对比最大的长度 // 计算对应最大回文子串的起点和终点,画图确定 if (len > end - start){ //这里为什么要len-1?,这里说明一下,因为for循环是从0开始的, //如果是奇数回文,假设有个回文是3个,那么len=3,此时中心i是下标1(从0开始),那么 (len-1)/2和len/2的结果都是1,因为整型会向下取整 //但是如果是偶数回文,假设有个回文是4个,那么len=4,此时的中心是一条虚线,但是i的位置 却在1,(因为S是从左向右遍历的,如果从右向左, //i的位置就会在2.)这时候,(len-1)/2=1,len/2=2.很明显为了保证下标正确,我们需要的是 (len-1)/2.原因其实是i在中心线的左边一位, //所以要少减个1. start = i - (len - 1)/2; end = i + len/2; } } return s.substring(start,end + 1);// 注意:这里的end+1是因为 java自带的左闭右开的原因 } private int expandCenter(String s,int left,int right){ //起始的左右边界 // left = right 的时候,此时回文中心是一个字符,回文串的长度是奇数 // right = left + 1 的时候,此时回文中心是一个空隙,回文串的长度是偶数 // 跳出循环的时候恰好满足 s.charAt(left) != s.charAt(right) while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)){ left--; right++; } return right - left - 1; // 回文串的长度是right-left+1-2 = right - left - 1 } }
4.复杂度
时间复杂度:O(n^2),其中n是字符串的长度。长度为 1和 2的回文中心分别有 n和 n-1个,每个回文中心最多会向外扩展 O(n)次。 空间复杂度:O(1)
3.Manacher马拉车
5.二分查找
0.频率
4,50,33,167,287,315,349,29,153,240,222,327,69,378,410,162,1111,35,34,300,363,350,209,354,278,374,981,174
4.寻找两个正序数组的中位数
1.常规思想
1.使用归并的方式,合并两个有序数组,得到一个大的有序数组。大的有序数组的中间位置的元素,即为中位数。
2.不需要合并两个有序数组,只要找到中位数的位置即可。由于两个数组的长度已知,因此中位数对应的两个数组的下标之和也是已知的。维护两个指针,初始时分别指向两个数组的下标0的位置,每次将指向较小值的指针后移一位(如果一个指针已经到达数组末尾,则只需要移动另一个数组的指针),直到到达中位数的位置
第一种思路的时间复杂度是 O(m+n),空间复杂度是 O(m+n)。第二种思路虽然可以将空间复杂度降到 O(1),但是时间复杂度仍是O(m+n)。题目要求时间复杂度是 O(log(m+n)),因此上述两种思路都不满足题目要求的时间复杂度
2.二分查找:第k小数
1.三种情况
如果 A[k/2-1] < B[k/2-1],则比 A[k/2-1] 小的数最多只有A的前k/2−1 个数和 B 的前 k/2-1个数,即比A[k/2−1] 小的数最多只有 k-2个,因此A[k/2−1]不可能是第 k个数,A[0]到A[k/2−1]也都不可能是第 k个数,可以全部排除。 如果 A[k/2-1] > B[k/2-1],则可以排除 B[0]到 B[k/2-1]。 如果 A[k/2-1] = B[k/2-1],则可以归入第一种情况处理。
2.处理结果
比较A[k/2−1]和B[k/2−1] 之后,可以排除k/2 个不可能是第 k小的数,查找范围缩小了一半。同时,我们将在排除后的新数组上继续进行二分查找,并且根据我们排除数的个数,减少 k的值,这是因为我们排除的数都不大于第 k小的数。
3.三种特殊情况
如果A[k/2−1] 或者B[k/2−1] 越界,那么我们可以选取对应数组中的最后一个元素。在这种情况下,我们必须根据排除数的个数减少 k的值,而不能直接将k减去k/2。 如果一个数组为空,说明该数组中的所有元素都被排除,我们可以直接返回另一个数组中第 k小的元素。 如果 k=1,我们只要返回两个数组首元素的最小值即可。
4.算法
class Solution { public double findMedianSortedArrays(int[] nums1, int[] nums2) { int length1 = nums1.length, length2 = nums2.length; int totalLength = length1 + length2; if (totalLength % 2 == 1) { int midIndex = totalLength / 2; double median = getKthElement(nums1, nums2, midIndex + 1); return median; } else { int midIndex1 = totalLength / 2 - 1, midIndex2 = totalLength / 2; double median = (getKthElement(nums1, nums2, midIndex1 + 1) + getKthElement(nums1, nums2, midIndex2 + 1)) / 2.0; return median; } } public int getKthElement(int[] nums1, int[] nums2, int k) { /* 主要思路:要找到第 k (k>1) 小的元素,那么就取 pivot1 = nums1[k/2-1] 和 pivot2 = nums2[k/2-1] 进行比较 * 这里的 "/" 表示整除 * nums1 中小于等于 pivot1 的元素有 nums1[0 .. k/2-2] 共计 k/2-1 个 * nums2 中小于等于 pivot2 的元素有 nums2[0 .. k/2-2] 共计 k/2-1 个 * 取 pivot = min(pivot1, pivot2),两个数组中小于等于 pivot 的元素共计不会超过 (k/2-1) + (k/2-1) <= k-2 个 * 这样 pivot 本身最大也只能是第 k-1 小的元素 * 如果 pivot = pivot1,那么 nums1[0 .. k/2-1] 都不可能是第 k 小的元素。把这些元素全部 "删除",剩下的作为新的 nums1 数组 * 如果 pivot = pivot2,那么 nums2[0 .. k/2-1] 都不可能是第 k 小的元素。把这些元素全部 "删除",剩下的作为新的 nums2 数组 * 由于我们 "删除" 了一些元素(这些元素都比第 k 小的元素要小),因此需要修改 k 的值,减去删除的数的个数 */ int length1 = nums1.length, length2 = nums2.length; int index1 = 0, index2 = 0; int kthElement = 0; while (true) { // 边界情况 if (index1 == length1) { return nums2[index2 + k - 1]; } if (index2 == length2) { return nums1[index1 + k - 1]; } if (k == 1) { return Math.min(nums1[index1], nums2[index2]); } // 正常情况,index1作为起始点在不停的更新 int half = k / 2; int newIndex1 = Math.min(index1 + half, length1) - 1; //数组长度可能小于前者 int newIndex2 = Math.min(index2 + half, length2) - 1; int pivot1 = nums1[newIndex1], pivot2 = nums2[newIndex2]; if (pivot1 <= pivot2) { //将两种情况合并 k -= (newIndex1 - index1 + 1); //下面的index1同时也在变化,这才是减去的长度 index1 = newIndex1 + 1; } else { k -= (newIndex2 - index2 + 1); index2 = newIndex2 + 1; } } } }
5.复杂度
时间复杂度:O(log(m+n)),其中 m和n分别是数组nums1 和nums2 的长度。初始时有 k=(m+n)/2 或 k=(m+n)/2+1,每一轮循环可以将查找范围减少一半,因此时间复杂度是O(log(m+n))。 空间复杂度:O(1)。
3.划分数组
1.中位数作用
将一个集合划分为两个长度相等的子集,其中一个子集中的元素总是大于另一个子集中的元素
2.两种情况
当 A 和 B 的总长度是偶数时,如果可以确认: len(left_part)=len(right_part) max(left_part)≤min(right_part) 那么,{A,B} 中的所有元素已经被划分为相同长度的两个部分,且前一部分中的元素总是小于或等于后一部分中的元素。中位数就是前一部分的最大值和后一部分的最小值的平均值
当 A 和 B 的总长度是奇数时,如果可以确认: len(left_part)=len(right_part)+1 max(left_part)≤min(right_part) 那么,{A,B} 中的所有元素已经被划分为两个部分,前一部分比后一部分多一个元素,且前一部分中的元素总是小于或等于后一部分中的元素。中位数就是前一部分的最大值
3.合并两种情况
要确保这两个条件,只需要保证: i+j=m−i+n−j(当m+n为偶数)或i+j=m−i+n−j+1(当m+n为奇数)。等号左侧为前一部分的元素个数,等号右侧为后一部分的元素个数。将 i 和 j 全部移到等号左侧,我们就可以得到i+j=(m+n+1)/2 。这里的分数结果只保留整数部分
规定A的长度小于等于B的长度,即m≤n。这样对于任意的i∈[0,m],都有j=(m+n+1)/2−i∈[0,n],如果A的长度较长,只需要交换A和B即可.如果m>n,那么得出的j有可能是负数。
B[j−1]≤A[i] 以及A[i−1]≤B[j],即前一部分的最大值小于等于后一部分的最小值
4.简化分析
假设A[i−1],B[j−1],A[i],B[j] 总是存在。对于i=0、i=m、j=0、j=n 这样的临界条件,我们只需要规定A[−1]=B[−1]=−∞,A[m]=B[n]=∞ 即可。这也是比较直观的:当一个数组不出现在前一部分时,对应的值为负无穷,就不会对前一部分的最大值产生影响;当一个数组不出现在后一部分时,对应的值为正无穷,就不会对后一部分的最小值产生影响。
5.工作
在[0,m]中找到i,使B[j-1]≤A[i]且A[i-1]≤B[j],其中j=(m+n+1)/2−i 等价于[0,m]中找到i,使A[i-1]≤B[j]
6.算法
class Solution { public double findMedianSortedArrays(int[] nums1, int[] nums2) { if (nums1.length > nums2.length) { return findMedianSortedArrays(nums2, nums1); } int m = nums1.length; int n = nums2.length; int left = 0, right = m, ansi = -1; // median1:前一部分的最大值 // median2:后一部分的最小值 int median1 = 0, median2 = 0; while (left <= right) { // 前一部分包含 nums1[0 .. i-1] 和 nums2[0 .. j-1] // 后一部分包含 nums1[i .. m-1] 和 nums2[j .. n-1] int i = (left + right) / 2; //二分法,i从区间中间开始 int j = (m + n + 1) / 2 - i;//+1的操作将总数为奇数和偶数合并为一种情况 //nums_im1, nums_i, nums_jm1, nums_j 分别表示 nums1[i-1], nums1[i], nums2[j-1], nums2[j] //当一个数组不出现在前一部分时,对应的值为负无穷,就不会对前一部分的最大值产生影响 int nums_im1 = (i == 0 ? Integer.MIN_VALUE : nums1[i - 1]); //当一个数组不出现在后一部分时,对应的值为正无穷,就不会对后一部分的最小值产生影响 int nums_i = (i == m ? Integer.MAX_VALUE : nums1[i]); int nums_jm1 = (j == 0 ? Integer.MIN_VALUE : nums2[j - 1]); int nums_j = (j == n ? Integer.MAX_VALUE : nums2[j]); if (nums_im1 <= nums_j) { ansi = i; median1 = Math.max(nums_im1, nums_jm1); median2 = Math.min(nums_i, nums_j); left = i + 1; } else { right = i - 1; } } return (m + n) % 2 == 0 ? (median1 + median2) / 2.0 : median1; } }
7.复杂度
时间复杂度: O(log min(m,n)),其中m 和n 分别是数组nums1 和nums2 的长度 空间复杂度:O(1)
6.树
0.频率
104,226,96,617,173,1130,108,297,100,105,95,124,654,669,99,979,199,110,236,101,235,114,94,222,102,938,437
1.二叉树
0.遍历通用模板整理
# Definition for a binary tree node. # class TreeNode: # def __init__(self, x): # self.val = x # self.left = None # self.right = None # 递归 # 时间复杂度:O(n),n为节点数,访问每个节点恰好一次。 # 空间复杂度:空间复杂度:O(h),h为树的高度。最坏情况下需要空间O(n),平均情况为O(logn) # 递归1:二叉树遍历最易理解和实现版本 class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: if not root: return [] # 前序递归 return [root.val] + self.preorderTraversal(root.left) + self.preorderTraversal(root.right) # # 中序递归 # return self.inorderTraversal(root.left) + [root.val] + self.inorderTraversal(root.right) # # 后序递归 # return self.postorderTraversal(root.left) + self.postorderTraversal(root.right) + [root.val] # 递归2:通用模板,可以适应不同的题目,添加参数、增加返回条件、修改进入递归条件、自定义返回值 class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: def dfs(cur): if not cur: return # 前序递归 res.append(cur.val) dfs(cur.left) dfs(cur.right) # # 中序递归 # dfs(cur.left) # res.append(cur.val) # dfs(cur.right) # # 后序递归 # dfs(cur.left) # dfs(cur.right) # res.append(cur.val) res = [] dfs(root) return res # 迭代 # 时间复杂度:O(n),n为节点数,访问每个节点恰好一次。 # 空间复杂度:O(h),h为树的高度。取决于树的结构,最坏情况存储整棵树,即O(n) # 迭代1:前序遍历最常用模板(后序同样可以用) class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: if not root: return [] res = [] stack = [root] # # 前序迭代模板:最常用的二叉树DFS迭代遍历模板 while stack: cur = stack.pop() res.append(cur.val) if cur.right: stack.append(cur.right) if cur.left: stack.append(cur.left) return res # # 后序迭代,相同模板:将前序迭代进栈顺序稍作修改,最后得到的结果反转 # while stack: # cur = stack.pop() # if cur.left: # stack.append(cur.left) # if cur.right: # stack.append(cur.right) # res.append(cur.val) # return res[::-1] # 迭代1:层序遍历最常用模板 class Solution: def levelOrder(self, root: TreeNode) -> List[List[int]]: if not root: return [] cur, res = [root], [] while cur: lay, layval = [], [] for node in cur: layval.append(node.val) if node.left: lay.append(node.left) if node.right: lay.append(node.right) cur = lay res.append(layval) return res # 迭代2:前、中、后序遍历通用模板(只需一个栈的空间) class Solution: def inorderTraversal(self, root: TreeNode) -> List[int]: res = [] stack = [] cur = root # 中序,模板:先用指针找到每颗子树的最左下角,然后进行进出栈操作 while stack or cur: while cur: stack.append(cur) cur = cur.left cur = stack.pop() res.append(cur.val) cur = cur.right return res # # 前序,相同模板 # while stack or cur: # while cur: # res.append(cur.val) # stack.append(cur) # cur = cur.left # cur = stack.pop() # cur = cur.right # return res # # 后序,相同模板 # while stack or cur: # while cur: # res.append(cur.val) # stack.append(cur) # cur = cur.right # cur = stack.pop() # cur = cur.left # return res[::-1] # 迭代3:标记法迭代(需要双倍的空间来存储访问状态): # 前、中、后、层序通用模板,只需改变进栈顺序或即可实现前后中序遍历, # 而层序遍历则使用队列先进先出。0表示当前未访问,1表示已访问。 class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: res = [] stack = [(0, root)] while stack: flag, cur = stack.pop() if not cur: continue if flag == 0: # 前序,标记法 stack.append((0, cur.right)) stack.append((0, cur.left)) stack.append((1, cur)) # # 后序,标记法 # stack.append((1, cur)) # stack.append((0, cur.right)) # stack.append((0, cur.left)) # # 中序,标记法 # stack.append((0, cur.right)) # stack.append((1, cur)) # stack.append((0, cur.left)) else: res.append(cur.val) return res # # 层序,标记法 # res = [] # queue = [(0, root)] # while queue: # flag, cur = queue.pop(0) # 注意是队列,先进先出 # if not cur: continue # if flag == 0: # 层序遍历这三个的顺序无所谓,因为是队列,只弹出队首元素 # queue.append((1, cur)) # queue.append((0, cur.left)) # queue.append((0, cur.right)) # else: # res.append(cur.val) # return res # 莫里斯遍历 # 时间复杂度:O(n),n为节点数,看似超过O(n),有的节点可能要访问两次,实际分析还是O(n),具体参考大佬博客的分析。 # 空间复杂度:O(1),如果在遍历过程中就输出节点值,则只需常数空间就能得到中序遍历结果,空间只需两个指针。 # 如果将结果储存最后输出,则空间复杂度还是O(n)。 # PS:莫里斯遍历实际上是在原有二叉树的结构基础上,构造了线索二叉树, # 线索二叉树定义为:原本为空的右子节点指向了中序遍历顺序之后的那个节点,把所有原本为空的左子节点都指向了中序遍历之前的那个节点 # emmmm,好像大学教材学过,还考过 # 此处只给出中序遍历,前序遍历只需修改输出顺序即可 # 而后序遍历,由于遍历是从根开始的,而线索二叉树是将为空的左右子节点连接到相应的顺序上,使其能够按照相应准则输出 # 但是后序遍历的根节点却已经没有额外的空间来标记自己下一个应该访问的节点, # 所以这里需要建立一个临时节点dump,令其左孩子是root。并且还需要一个子过程,就是倒序输出某两个节点之间路径上的各个节点。 # 具体参考大佬博客 # 莫里斯遍历,借助线索二叉树中序遍历(附前序遍历) class Solution: def inorderTraversal(self, root: TreeNode) -> List[int]: res = [] # cur = pre = TreeNode(None) cur = root while cur: if not cur.left: res.append(cur.val) # print(cur.val) cur = cur.right else: pre = cur.left while pre.right and pre.right != cur: pre = pre.right if not pre.right: # print(cur.val) 这里是前序遍历的代码,前序与中序的唯一差别,只是输出顺序不同 pre.right = cur cur = cur.left else: pre.right = None res.append(cur.val) # print(cur.val) cur = cur.right return res # N叉树遍历 # 时间复杂度:时间复杂度:O(M),其中 M 是 N 叉树中的节点个数。每个节点只会入栈和出栈各一次。 # 空间复杂度:O(M)。在最坏的情况下,这棵 N 叉树只有 2 层,所有第 2 层的节点都是根节点的孩子。 # 将根节点推出栈后,需要将这些节点都放入栈,共有 M−1个节点,因此栈的大小为 O(M)。 """ # Definition for a Node. class Node: def __init__(self, val=None, children=None): self.val = val self.children = children """ # N叉树简洁递归 class Solution: def preorder(self, root: 'Node') -> List[int]: if not root: return [] res = [root.val] for node in root.children: res.extend(self.preorder(node)) return res # N叉树通用递归模板 class Solution: def preorder(self, root: 'Node') -> List[int]: res = [] def helper(root): if not root: return res.append(root.val) for child in root.children: helper(child) helper(root) return res # N叉树迭代方法 class Solution: def preorder(self, root: 'Node') -> List[int]: if not root: return [] s = [root] # s.append(root) res = [] while s: node = s.pop() res.append(node.val) # for child in node.children[::-1]: # s.append(child) s.extend(node.children[::-1]) return res
144.二叉树的前序遍历
1.迭代
1.思想
和中序遍历的迭代类似,稍微改动
2.代码
class Solution { public List<Integer> preorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); //Stack基于动态数组实现,而LinkedList基于双向链表实现,LinkedList增删改的效率高于Stack Deque<TreeNode> stk = new LinkedList<TreeNode>();//双端队列 //结点不为空 或 堆栈不为空 继续循环 while (root != null || !stk.isEmpty()) { while (root != null) { res.add(root.val);//根结点直接访问,再压入栈中 stk.push(root); root = root.left;//根结点结束后访问其左孩子,成为新的根结点 } root = stk.pop();//跳出循环时,所有左孩子访问完毕,再访问右孩子 root = root.right; } return res; } }
2.1 Python
class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: res = list() if not root: return res stack = [] node = root while stack or node:#结点不为空 或 堆栈不为空 继续循环 while node: res.append(node.val) #根结点直接访问,再压入栈中 stack.append(node) node = node.left #根结点结束后访问其左孩子,成为新的根结点 node = stack.pop() #跳出循环时,所有左孩子访问完毕,再访问右孩子 node = node.right return res
3.另一种方法
从根节点开始,每次迭代弹出当前栈顶元素,并将其孩子节点压入栈中,先压右孩子再压左孩子
class Solution { public List<Integer> preorderTraversal(TreeNode root) { LinkedList<TreeNode> stack = new LinkedList<>(); LinkedList<Integer> output = new LinkedList<>(); if (root == null) { return output; } stack.add(root); while (!stack.isEmpty()) { TreeNode node = stack.pollLast();//检索并移除此列表的最后一个元素 output.add(node.val); if (node.right != null) {//只将当前结点的一个右,左孩子入栈,其他不管 stack.add(node.right); } if (node.left != null) { stack.add(node.left); } } return output; } }
4.复杂度
时间: O(n), 空间: O(n)
2.莫里斯遍历
1.思想
1.从当前节点向下访问先序遍历的前驱节点,每个前驱节点都恰好被访问两次
2.首先从当前节点开始,向左孩子走一步然后沿着右孩子一直向下访问,直到到达一个叶子节点(当前节点的中序遍历前驱节点),所以我们更新输出并建立一条伪边 predecessor.right = root 更新这个前驱的下一个点。如果我们第二次访问到前驱节点,由于已经指向了当前节点,我们移除伪边并移动到下一个顶点
3.如果第一步向左的移动不存在,就直接更新输出并向右移动
2.代码
class Solution { public List<Integer> preorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); TreeNode predecessor = null;//当前结点的前驱结点,左子树最右边结点 while (root != null) { //根据是否有左孩子,确定是找到它的前驱结点还是直接访问右孩子 if (root.left != null) { //先找到predecessor,再给它的右孩子赋值,接着再遍历当前结点的左孩子 //predecessor节点就是当前root节点向左走一步,然后一直向右走至无法走为止 predecessor = root.left; while (predecessor.right != null && predecessor.right != root) { predecessor = predecessor.right; } //根据predecessor的右孩子是否为空,确定是直接访问左子树后赋值 还是左子树访问结束 //让 predecessor的右指针指向 root,继续遍历左子树 if (predecessor.right == null) { res.add(root.val); predecessor.right = root; root = root.left; } //说明左子树已经访问完了,我们需要断开链接,继续访问右子树 else { predecessor.right = null; root = root.right; } } // 如果没有左孩子,则直接访问右孩子 else { res.add(root.val); root = root.right; } } return res; } }
2.1 Python
class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: res = list() if not root: return res p1 = root while p1: p2 = p1.left #当前结点的前驱结点,左子树最右边结点 if p2:#根据是否有左孩子,确定是找到它的前驱结点还是直接访问右孩子 while p2.right and p2.right != p1: p2 = p2.right #根据p2的右孩子是否为空,确定是直接访问左子树后赋值 还是左子树访问结束 if not p2.right:#让p2的右指针指向p1,继续遍历左子树 res.append(p1.val) p2.right = p1 p1 = p1.left continue else:#说明左子树已经访问完了,我们需要断开链接,继续访问右子树 p2.right = None else:#如果没有左孩子,则直接访问右孩子 res.append(p1.val) p1 = p1.right return res
3.复杂度
时间: O(n), 空间: O(1)
94.二叉树的中序遍历
1.递归
1.思想
按照访问左子树——根节点——右子树的方式遍历这棵树,而在访问左子树或者右子树的时候我们按照同样的方式遍历,直到遍历完整棵树。因此整个遍历过程天然具有递归的性质,我们可以直接用递归函数来模拟这一过程
2.代码
class Solution { public List<Integer> inorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); inorder(root, res); return res; } public void inorder(TreeNode root, List<Integer> res) { if (root == null) { return; } inorder(root.left, res); res.add(root.val); inorder(root.right, res); } }
2.1 Python
class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: def dfs(cur): if not cur: return # 前序递归 res.append(cur.val) dfs(cur.left) dfs(cur.right) # # 中序递归 # dfs(cur.left) # res.append(cur.val) # dfs(cur.right) # # 后序递归 # dfs(cur.left) # dfs(cur.right) # res.append(cur.val) res = [] dfs(root) return res
3.复杂度
时间复杂度:O(n),其中n为二叉树节点的个数。二叉树的遍历中每个节点会被访问一次且只会被访问一次
空间复杂度:O(n)。空间复杂度取决于递归的栈深度,而栈深度在二叉树为一条链的情况下会达到O(n)的级别
2.栈迭代
1.思路
方法一的递归函数我们也可以用迭代的方式实现,两种方式是等价的,区别在于递归的时候隐式地维护了一个栈,而我们在迭代的时候需要显式地将这个栈模拟出来
2.代码
class Solution { public List<Integer> inorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); //Stack基于动态数组实现,而LinkedList基于双向链表实现,LinkedList增删改的效率高于Stack Deque<TreeNode> stk = new LinkedList<TreeNode>();//双端队列 //结点不为空 或 堆栈不为空 继续循环 while (root != null || !stk.isEmpty()) { //先遍历左孩子,直到左孩子为空结束循环 while (root != null) { stk.push(root); root = root.left; } //跳出循环时左孩子为空,此时出栈的相当于根结点,之后轮到右孩子 root = stk.pop(); res.add(root.val); root = root.right; } return res; } }
2.1 Python
class Solution: def inorderTraversal(self, root: TreeNode) -> List[int]: res = list() if not root: return res stack = [] node = root while stack or node:#结点不为空 或 堆栈不为空 继续循环 while node: stack.append(node) #先遍历左孩子,直到左孩子为空结束循环 node = node.left node = stack.pop() #跳出循环时左孩子为空,此时出栈的相当于根结点,之后轮到右孩子 res.append(node.val) node = node.right return res
3.复杂度
时间: O(n), 空间: O(n)
3.Morris中序遍历
1.思想
1.如果x无左孩子,先将x的值加入答案数组,再访问x的右孩子,即x=x.right
2.如果x有左孩子,则找到x左子树上最右的节点(即左子树中序遍历的最后一个节点,x在中序遍历中的前驱节点),我们记为predecessor。根据predecessor的右孩子是否为空,进行如下操作
3.如果predecessor的右孩子为空,则将其右孩子指向 x,然后访问x的左孩子,即x=x.left
4.如果predecessor的右孩子不为空,则此时其右孩子指向 x,说明我们已经遍历完x的左子树,我们将predecessor的右孩子置空,将x的值加入答案数组,然后访问x的右孩子,即x=x.right
5.重复上述操作,直至访问完整棵树
6.其实整个过程我们就多做一步:假设当前遍历到的节点为 x,将x的左子树中最右边的节点的右孩子指向 x,这样在左子树遍历完成后我们通过这个指向走回了 x,且能通过这个指向知晓我们已经遍历完成了左子树,而不用再通过栈来维护,省去了栈的空间复杂度
2.代码
class Solution { public List<Integer> inorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); TreeNode predecessor = null;//当前结点的前驱结点,左子树最右边结点 while (root != null) { //根据是否有左孩子,确定是找到它的前驱结点还是直接访问右孩子 if (root.left != null) { //先找到predecessor,再给它的右孩子赋值,接着再遍历当前结点的左孩子 //predecessor节点就是当前root节点向左走一步,然后一直向右走至无法走为止 predecessor = root.left; while (predecessor.right != null && predecessor.right != root) { predecessor = predecessor.right; } //根据predecessor的右孩子是否为空,确定是赋值还是左子树访问结束 //让 predecessor的右指针指向 root,继续遍历左子树 if (predecessor.right == null) { predecessor.right = root; root = root.left; } //说明左子树已经访问完了,此时访问根结点,然后断开链接,继续访问右子树 else { res.add(root.val); predecessor.right = null; root = root.right; } } // 如果没有左孩子,则直接访问右孩子 else { res.add(root.val); root = root.right; } } return res; } }
2.1 Python
class Solution: def inorderTraversal(self, root: TreeNode) -> List[int]: res = list() if not root: return res p1 = root while p1: p2 = p1.left #当前结点的前驱结点,左子树最右边结点 if p2:#根据是否有左孩子,确定是找到它的前驱结点还是直接访问右孩子 while p2.right and p2.right != p1: p2 = p2.right #根据p2的右孩子是否为空,确定是直接访问左子树后赋值 还是左子树访问结束 if not p2.right:#让p2的右指针指向p1,继续遍历左子树 p2.right = p1 p1 = p1.left continue else:#说明左子树已经访问完了,此时访问根结点,然后断开链接,继续访问右子树 res.append(p1.val) p2.right = None else:#如果没有左孩子,则直接访问右孩子 res.append(p1.val) p1 = p1.right return res
3.复杂度
时间复杂度:O(n),其中n为二叉搜索树的节点个数。Morris 遍历中每个节点会被访问两次,因此总时间复杂度为O(2n)=O(n)
空间复杂度:O(1)
4.颜色标记法(通用)
1.思想
0.兼具栈迭代方法的高效,又像递归方法一样简洁易懂,更重要的是,这种方法对于前序、中序、后序遍历,能够写出完全一致的代码
1.使用颜色标记节点的状态,新节点为白色,已访问的节点为灰色。 如果遇到的节点为白色,则将其标记为灰色,然后将其右子节点、自身、左子节点依次入栈。 如果遇到的节点为灰色,则将节点的值输出
2.如要实现前序、后序遍历,只需要调整左右子节点的入栈顺序即可
2.代码
class Solution: def inorderTraversal(self, root: TreeNode) -> List[int]: WHITE, GRAY = 0, 1 #WHITE未访问,GRAY访问过 res = [] stack = [(WHITE, root)] while stack: color, node = stack.pop() if node is None: continue if color == WHITE: #中序顺序为左 根 右,进栈顺序为 右 根 左,调整顺序即可完成其他遍历 stack.append((WHITE, node.right)) stack.append((GRAY, node)) #当前结点变为灰色,已访问 stack.append((WHITE, node.left)) else: res.append(node.val) return res
3.复杂度
时间: O(n), 空间: O(n)
145.二叉树的后序遍历
1.迭代
1.思路
在返回到根结点时,对当前结点进行标记,判断是从左子树返回还是从右子树进行返回,从右子树返回时,开始遍历结点
2.代码
class Solution { public List<Integer> postorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); Stack<TreeNode> stack = new Stack<>(); //pre节点用于记录前一次访问的节点,区别从左子树返回根结点还是右子树返回根结点 TreeNode pre = null; while(root!=null || !stack.empty()){ while(root!=null){ stack.push(root); //不断将左节点压栈 root = root.left; } root = stack.peek();//从左子树返回,只获取根结点并不弹出,还未访问 if(root.right==null || root.right==pre){ //若右节点为空 或右节点访问过 res.add(root.val); //此时可以访问根结点啦 pre = root; stack.pop(); root = null; //此时下一轮循环不要将左子树压栈,已经压过,直接判断栈顶元素 }else{ root = root.right; //先不出栈,把它右节点入栈 } } return res; } }
2.1 Python
class Solution: def postorderTraversal(self, root: TreeNode) -> List[int]: if not root: return list() res = list() stack = list() prev = None #prev节点用于记录前一次访问的节点,区别从左子树返回根结点还是右子树返回根结点 while root or stack: while root: stack.append(root) #不断将左节点压栈 root = root.left #本应用peek,但list中无此方法,只能先pop再append root = stack.pop() #从左子树返回,还未访问,之后要再加入到栈中 if not root.right or root.right == prev:#若右节点为空 或右节点访问过 res.append(root.val) #此时可以访问根结点啦 prev = root #记录下当前的结点 root = None #此时下一轮循环不要将左子树压栈,已经压过,直接判断栈顶元素 else: stack.append(root) #之前未访问,再加入到栈中 root = root.right return res
3.改造前序遍历
//修改前序遍历代码中,节点写入结果链表的代码:将插入队尾修改为插入队首 //修改前序遍历代码中,每次先查看左节点再查看右节点的逻辑:变为先查看右节点再查看左节点 //前序遍历为 根左右,逆序后为右左根, 后序遍历为左右根 class Solution { public List<Integer> postorderTraversal(TreeNode root) { LinkedList res = new LinkedList(); Stack<TreeNode> stack = new Stack<>(); TreeNode pre = null; while(root!=null || !stack.empty()){ while(root!=null){ res.addFirst(root.val); //插入队首 stack.push(root); root = root.right; //先右后左 } root = stack.pop(); root = root.left; } return res; } }
4.复杂度
时间: O(n), 空间: O(n)
2.莫里斯遍历
1.思路
1.Morris 遍历的核心思想是利用树的大量空闲指针,实现空间开销的极限缩减。其后序遍历规则总结如下
2.如果当前节点的左子节点为空,则遍历当前节点的右子节点
3.如果当前节点的左子节点不为空,在当前节点的左子树中找到当前节点在中序遍历下的前驱节点
4.如果前驱节点的右子节点为空,将前驱节点的右子节点设置为当前节点,当前节点更新为当前节点的左子节点
5.如果前驱节点的右子节点为当前节点,将它的右子节点重新设为空。倒序输出从当前节点的左子节点到该前驱节点这条路径上的所有节点。当前节点更新为当前节点的右子节点
2.代码
class Solution { public List<Integer> postorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); if (root == null) { return res; } TreeNode p1 = root, p2 = null;//p1当前结点,p2前驱结点 while (p1 != null) { p2 = p1.left; if (p2 != null) { while (p2.right != null && p2.right != p1) { p2 = p2.right; } if (p2.right == null) { p2.right = p1; p1 = p1.left; continue; } else { p2.right = null; addPath(res, p1.left); } } p1 = p1.right; } addPath(res, root); return res; } public void addPath(List<Integer> res, TreeNode node) { int count = 0; while (node != null) { ++count; res.add(node.val); node = node.right; } //将新加入的结点进行倒序 int left = res.size() - count, right = res.size() - 1; while (left < right) { int temp = res.get(left); res.set(left, res.get(right)); res.set(right, temp); left++; right--; } } }
2.1 Python
class Solution: def postorderTraversal(self, root: TreeNode) -> List[int]: def addPath(node: TreeNode): count = 0 while node: count += 1 res.append(node.val) node = node.right #将新加入的结点进行倒序 i, j = len(res) - count, len(res) - 1 while i < j: res[i], res[j] = res[j], res[i] #不需要中间变量 i += 1 j -= 1 if not root: return list() res = list() p1 = root while p1: p2 = p1.left #p2前驱结点 if p2: while p2.right and p2.right != p1: p2 = p2.right if not p2.right: p2.right = p1 p1 = p1.left continue else: p2.right = None addPath(p1.left) p1 = p1.right addPath(root) return res
3.复杂度
时间: O(n), 空间: O(1)
102.二叉树的层序遍历
0.题目
返回二叉树的层序遍历的结点值,且每层结点放在一起
1.思路
1.最朴素的方法是用一个二元组 (node, level) 来表示状态,它表示某个节点和它所在的层数,每个新进队列的节点的 level 值都是父亲节点的 level 值加一。最后根据每个点的 level 对点进行分类,分类的时候我们可以利用哈希表,维护一个以 level 为键,对应节点值组成的数组为值,广度优先搜索结束以后按键 level 从小到大取出所有值,组成答案返回即可
2.考虑如何优化空间开销:如何不用哈希映射,并且只用一个变量 node 表示状态,实现这个功能
3.用一种巧妙的方法修改BFS:首先根元素入队,当队列不为空的时候,求当前队列的长度Si,依次从队列中取Si个元素进行拓展,然后进入下一次迭代
4.它和BFS的区别在于BFS每次只取一个元素拓展,而这里每次取Si个元素,在上述过程中的第i次迭代就得到了二叉树的第i层的Si个元素
2.代码
class Solution { public List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer>> ret = new ArrayList<List<Integer>>(); if (root == null) { return ret; } Queue<TreeNode> queue = new LinkedList<TreeNode>(); queue.offer(root);//将指定的元素加到此列表的末尾(最后一个元素) while (!queue.isEmpty()) { List<Integer> level = new ArrayList<Integer>(); int currentLevelSize = queue.size(); //将当前队列内的所有元素都出队列 for (int i = 1; i <= currentLevelSize; ++i) { TreeNode node = queue.poll(); level.add(node.val); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } } ret.add(level); } return ret; } }
2.1 Python
class Solution: def levelOrder(self, root: TreeNode) -> List[List[int]]: if not root: return [] # 特殊情况,root为空直接返回 from collections import deque # 下面就是BFS模板内容,BFS关键在于队列的使用 layer = deque() layer.append(root) # 压入初始节点 res = [] # 结果集 while layer: cur_layer = [] # 临时变量,记录当前层的节点 for _ in range(len(layer)): # 遍历某一层的节点 node = layer.popleft() # 将要处理的节点弹出 cur_layer.append(node.val) if node.left: # 如果当前节点有左右节点,则压入队列,根据题意注意先左后右 layer.append(node.left) if node.right: layer.append(node.right) res.append(cur_layer) # 某一层的节点都处理完之后,将当前层的结果压入结果集 return res
使用其他语言的朋友,保险起见 len(layer) 的值在遍历之前要先存一下,因为遍历的同时,也在往 layer 里添加元素,此时 layer 的值会变化,可能 Python 这个语言比较特殊吧,确实 for _ in range(len(layer)) 在被调用时,循环次数已经根据生成的对应序列定下来了,在循环里面 layer 的长度改变也不会造成影响了,如果使用的 while 循环。每次都会刷新 len(layer)
3.复杂度
时间复杂度:每个点进队出队各一次,故渐进时间复杂度为 O(n)
空间复杂度:队列中元素的个数不超过n个,故渐进空间复杂度为 O(n)
104.二叉树的最大深度
1.递归
1.思路
1.如果我们知道了左子树和右子树的最大深度l和r,那么该二叉树的最大深度即为max(l,r)+1
2.左子树和右子树的最大深度又可以以同样的方式进行计算。因此我们在计算当前二叉树的最大深度时,可以先递归计算出其左子树和右子树的最大深度,然后在O(1) 时间内计算出当前二叉树的最大深度。递归在访问到空节点时退出
2.代码
class Solution { public int maxDepth(TreeNode root) { if(root==null) return 0;//最后的+1在为叶子结点时,会返回高度为1 return Math.max(maxDepth(root.left),maxDepth(root.right))+1; } }
2.1 Python
class Solution: def maxDepth(self, root: TreeNode) -> int: if not root: return 0 lefth = self.maxDepth(root.left) #调用自身必须用self.自身 righth = self.maxDepth(root.right) return max(lefth,righth) + 1
3.复杂度
时间复杂度:O(n),其中n为二叉树节点的个数。每个节点在递归中只被遍历一次
空间复杂度:O(height),其中height 表示二叉树的高度。递归函数需要栈空间,而栈空间取决于递归的深度,因此空间复杂度等价于二叉树的高度
2.广度优先搜索
1.思路
广度优先搜索的队列里存放的是「当前层的所有节点」。每次拓展下一层的时候,不同于广度优先搜索的每次只从队列里拿出一个节点,我们需要将队列里的所有节点都拿出来进行拓展,这样能保证每次拓展完的时候队列里存放的是当前层的所有节点,即我们是一层一层地进行拓展,最后我们用一个变量ans 来维护拓展的次数,该二叉树的最大深度即为ans
2.算法
class Solution { public int maxDepth(TreeNode root) { if (root == null) { return 0; } Queue<TreeNode> queue = new LinkedList<TreeNode>(); queue.offer(root); int ans = 0; while (!queue.isEmpty()) { int size = queue.size(); while (size > 0) { //一次操作一层的元素 TreeNode node = queue.poll(); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } size--; } ans++;//一层操作结束后,层数+1 } return ans; } }
2.1 Python
import collections class Solution: def maxDepth(self, root: TreeNode) -> int: if not root: return 0 queue = collections.deque() queue.append(root) ans = 0 while queue: ans += 1 for _ in range(len(queue)): #每次遍历一层的元素 node = queue.popleft() if node.left: queue.append(node.left) if node.right: queue.append(node.right) return ans
3.复杂度
时间复杂度:O(n),其中n为二叉树的节点个数。与方法一同样的分析,每个节点只会被访问一次
空间复杂度:此方法空间的消耗取决于队列存储的元素数量,其在最坏情况下会达到 O(n)
226.反转二叉树
1.递归
1.思想
我们从根节点开始,递归地对树进行遍历,并从叶子结点先开始翻转。如果当前遍历到的节点root 的左右两棵子树都已经翻转,那么我们只需要交换两棵子树的位置,即可完成以root 为根节点的整棵子树的翻转
2.代码
class Solution { public TreeNode invertTree(TreeNode root) { if (root == null) { return null; } //递归地对树进行遍历,并从叶子结点先开始翻转 TreeNode left = invertTree(root.left); TreeNode right = invertTree(root.right); root.left = right; root.right = left; return root; } }
2.1 python
class Solution: def invertTree(self, root: TreeNode) -> TreeNode: if not root: return root #递归地对树进行遍历,并从叶子结点先开始翻转 left = self.invertTree(root.left) right = self.invertTree(root.right) root.left, root.right = right, left return root
3.复杂度
时间复杂度:O(N),其中N为二叉树节点的数目。我们会遍历二叉树中的每一个节点,对每个节点而言,我们在常数时间内交换其两棵子树
空间复杂度:O(N)。使用的空间由递归栈的深度决定,它等于当前节点在二叉树中的高度。在平均情况下,二叉树的高度与节点个数为对数关系,即O(logN)。而在最坏情况下,树形成链状,空间复杂度为 O(N)
96.不同的二叉搜索树个数
1.动态规划
0.题目
给定一个整数 n,求以 1 ... n 为节点组成的二叉搜索树有多少种?
1.思路
1.遍历每个数字i,将该数字作为树根,将1⋯(i−1)序列作为左子树,将(i+1)⋯n 序列作为右子树。接着我们可以按照同样的方式递归构建左子树和右子树
2.由于根的值不同,因此我们能保证每棵二叉搜索树是唯一的
3.原问题可以分解成规模较小的两个子问题,且子问题的解可以复用。可以使用动态规划来求解本题
4.我们可以定义两个函数: G(n): 长度为n的序列能构成的不同二叉搜索树的个数。 F(i,n): 以i为根、序列长度为n的不同二叉搜索树个数(1≤i≤n)。 可见,G(n)是我们求解需要的函数
5.不同的二叉搜索树的总数G(n),是对遍历所有i(1≤i≤n)的F(i,n)之和
6.对于边界情况,当序列长度为1(只有根)或为0(空树)时,只有一种情况,即:G(0)=1,G(1)=1
7.选择数字i作为根,则根为i的所有二叉搜索树的集合是左子树集合和右子树集合的笛卡尔积
8.两个公式结合起来:
2.代码
class Solution { public int numTrees(int n) { int[] G = new int[n + 1]; G[0] = 1; G[1] = 1; for (int i = 2; i <= n; ++i) { for (int j = 1; j <= i; ++j) { G[i] += G[j - 1] * G[i - j]; } } return G[n]; } }
2.1 Python
class Solution: def numTrees(self, n): G = [0]*(n+1) #长度为n+1的列表 G[0], G[1] = 1, 1 for i in range(2, n+1): #从G[2]开始遍历到n for j in range(1, i+1): #对应求和公式从1到n G[i] += G[j-1] * G[i-j] return G[n]
3.复杂度
时间复杂度: O(n^2),其中n表示二叉搜索树的节点个数。G(n)函数一共有n个值需要求解,每次求解需要O(n) 的时间复杂度,因此总时间复杂度为 O(n^2)
空间复杂度: O(n)。我们需要O(n) 的空间存储G数组
2.卡塔兰数
1.思路
1.在方法一中推导出的G(n)函数的值在数学上被称为卡塔兰数Cn
2.代码
class Solution { public int numTrees(int n) { // 提示:我们在这里需要用 long 类型防止计算过程中的溢出 long C = 1; for (int i = 0; i < n; ++i) { C = C * 2 * (2 * i + 1) / (i + 2); } return (int) C; } }
2.1 Python
class Solution(object): def numTrees(self, n): C = 1 for i in range(0, n): C = C * 2*(2*i+1)/(i+2) return int(C)
3.复杂度
时间复杂度:O(n),其中n表示二叉搜索树的节点个数。我们只需要循环遍历一次即可
空间复杂度:O(1)。我们只需要常数空间存放若干变量
95.不同的二叉搜索树组合
1.递归
0.题目
给定一个整数 n,生成所有由 1 ... n 为节点所组成的 二叉搜索树
1.思路
1.假设当前序列长度为n,如果我们枚举根节点的值为i,那么根据二叉搜索树的性质我们可以知道左子树的节点值的集合为[1…i−1],右子树的节点值的集合为[i+1…n]。而左子树和右子树的生成相较于原问题是一个序列长度缩小的子问题,因此我们可以想到用递归的方法来解决这道题目
2.定义 generateTrees(start, end) 函数表示当前值的集合为[start,end],返回序列[start,end] 生成的所有可行的二叉搜索树。按照上文的思路,我们考虑枚举[start,end] 中的值 ii 为当前二叉搜索树的根,那么序列划分为了[start,i−1] 和 [i+1,end] 两部分。我们递归调用这两部分,即generateTrees(start, i - 1) 和 generateTrees(i + 1, end),获得所有可行的左子树和可行的右子树,那么最后一步我们只要从可行左子树集合中选一棵,再从可行右子树集合中选一棵拼接到根节点上,并将生成的二叉搜索树放入答案数组即可
3.递归的入口即为 generateTrees(1, n),出口为当 \textit{start}>\textit{end}start>end 的时候,当前二叉搜索树为空,返回空节点即可
2.代码
class Solution: def generateTrees(self, n: int) -> List[TreeNode]: def generateTrees(start, end): if start > end: return [None,] allTrees = [] for i in range(start, end + 1): # 枚举可行根节点 #先完成左右子树的递归构建,再用已完成的左右子树集合构建成完整的树 leftTrees = generateTrees(start, i - 1) # 获得所有可行的左子树集合 rightTrees = generateTrees(i + 1, end) # 获得所有可行的右子树集合 # 从左子树集合中选出一棵左子树,从右子树集合中选出一棵右子树,拼接到根节点上 for l in leftTrees: #笛卡尔积的关系 for r in rightTrees: currTree = TreeNode(i) #根结点 currTree.left = l currTree.right = r allTrees.append(currTree) return allTrees return generateTrees(1, n) if n else []
3.复杂度
时间复杂度: 整个算法的时间复杂度取决于「可行二叉搜索树的个数」,而对于n个点生成的二叉搜索树数量等价于数学上第n个「卡特兰数」,用 G_n表示。卡特兰数具体的细节请读者自行查询,这里不再赘述,只给出结论。生成一棵二叉搜索树需要 O(n) 的时间复杂度,一共有G_n棵二叉搜索树,也就是 O(nG_n)
空间复杂度:n个点生成的二叉搜索树有Gn棵,每棵有n个节点,因此存储的空间需要 O(nG_n)
617.合并二叉树
1.深度优先搜索
0.题目
给定两个二叉树,想象当你将它们中的一个覆盖到另一个上时,两个二叉树的一些节点便会重叠。 你需要将他们合并为一个新的二叉树。合并的规则是如果两个节点重叠,那么将他们的值相加作为节点合并后的新值,否则不为NULL 的节点将直接作为新二叉树的节点
1.思路
1.从根节点开始同时遍历两个二叉树,并将对应的节点进行合并,两个二叉树的对应节点可能存在以下三种情况,对于每种情况使用不同的合并方式
2.如果两个二叉树的对应节点都为空,则合并后的二叉树的对应节点也为空
3.如果两个二叉树的对应节点只有一个为空,则合并后的二叉树的对应节点为其中的非空节点
4.如果两个二叉树的对应节点都不为空,则合并后的二叉树的对应节点的值为两个二叉树的对应节点的值之和,此时需要显性合并两个节点
5.对一个节点进行合并之后,还要对该节点的左右子树分别进行合并。这是一个递归的过程
2.代码
class Solution { public TreeNode mergeTrees(TreeNode t1, TreeNode t2) { if (t1 == null) { return t2; } if (t2 == null) { return t1; } //先合并当前结点,再对其左右子树进行递归 TreeNode merged = new TreeNode(t1.val + t2.val); merged.left = mergeTrees(t1.left, t2.left); merged.right = mergeTrees(t1.right, t2.right); return merged; } }
2.1 Python
class Solution: def mergeTrees(self, t1: TreeNode, t2: TreeNode) -> TreeNode: if not t1: return t2 if not t2: return t1 #先合并当前结点,再对其左右子树进行递归 merged = TreeNode(t1.val + t2.val) merged.left = self.mergeTrees(t1.left, t2.left) merged.right = self.mergeTrees(t1.right, t2.right) return merged
3.复杂度
时间复杂度:O(min(m,n)),其中m和n分别是两个二叉树的节点个数。对两个二叉树同时进行深度优先搜索,只有当两个二叉树中的对应节点都不为空时才会对该节点进行显性合并操作,因此被访问到的节点数不会超过较小的二叉树的节点数
空间复杂度:O(min(m,n)),其中m和n分别是两个二叉树的节点个数。空间复杂度取决于递归调用的层数,递归调用的层数不会超过较小的二叉树的最大高度,最坏情况下,二叉树的高度等于节点数
173.二叉搜索树迭代器
1.扁平化二叉搜索树
0.题目
实现一个二叉搜索树迭代器。你将使用二叉搜索树的根节点初始化迭代器。 调用 next() 将返回二叉搜索树中的下一个最小的数
1.思路
1.实现迭代器的最简单的方法是在类似数组的容器接口上。如果我们有一个数组,则我们只需要一个指针或者索引,就可以轻松的实现函数 next() 和 hasNext()
2.使用额外的数组,并将二叉搜索树展开存放到里面。我们想要数组的元素按升序排序,则我们应该对二叉搜索树进行中序遍历,然后我们在数组中构建迭代器函数
3.一旦所有节点都在数组中,则我们只需要一个指针或索引来实现 next() 和 hasNext 这两个函数。每当调用 hasNext() 时,我们只需要检查索引是否达到数组末尾。每当调用 next() 时,我们只需要返回索引指向的元素,并向前移动一步,以模拟迭代器的进度
2.算法
class BSTIterator { ArrayList<Integer> nodesSorted; int index; public BSTIterator(TreeNode root) { this.nodesSorted = new ArrayList<Integer>(); this.index = -1; this._inorder(root); } private void _inorder(TreeNode root) { if (root == null) return; this._inorder(root.left); this.nodesSorted.add(root.val); this._inorder(root.right); } public int next() { return this.nodesSorted.get(++this.index); } public boolean hasNext() { return this.index + 1 < this.nodesSorted.size(); } }
2.1 Python
class BSTIterator: self.nodes_sorted = [] self.index = -1 self._inorder(root) def __init__(self, root: TreeNode): self.nodes_sorted = [] self.index = -1 self._inorder(root) def _inorder(self, root): if not root: return self._inorder(root.left) self.nodes_sorted.append(root.val) self._inorder(root.right) def next(self) -> int: self.index += 1 return self.nodes_sorted[self.index] def hasNext(self) -> bool: return self.index + 1 < len(self.nodes_sorted)
3.复杂度
时间复杂度:构造迭代器花费的时间为O(N),问题陈述只要求我们分析两个函数的复杂性,但是在实现类时,还要注意初始化类对象所需的时间;在这种情况下,时间复杂度与二叉搜索树中的节点数成线性关系: next(): O(1),hasNext():O(1)
空间复杂度:O(N),由于我们创建了一个数组来包含二叉搜索树中的所有节点值,这不符合问题陈述中的要求,任一函数的最大空间复杂度应为O(h),其中h指的是树的高度,对于平衡的二叉搜索树,高度通常为logN
2.受控递归
1.思路
1.前面使用的方法在空间上与二叉搜索树中的节点数呈线性关系。然而,我们不得不使用这种方法的原因是我们可以在数组上迭代,且我们不能够暂停递归,然后在某个时候启动它。但是,如果我们可以模拟中序遍历的受控递归,那么除了堆栈用于模拟递归的空间之外,实际上不需要使用任何其他空间
2.采用迭代的方式来模拟中序遍历,而不是采用递归的方法;这样做的过程中,我们能够轻松的实现这两个函数的调用,而不是用其他额外的空间
3.初始化一个空栈 S,用于模拟二叉搜索树的中序遍历。中序遍历我们采用与之前相同的方法,只是我们现在使用的是自己的栈而不是系统的堆栈。由于我们使用自定义的数据结构,因此可以随时暂停和恢复递归
4.还要实现一个帮助函数,在实现中将一次又一次的调用它。这个函数叫 _inorder_left,它将给定节点中的所有左子节点添加到栈中,直到节点没有左子节点为止
5.第一次调用 next() 函数时,必须返回二叉搜索树的最小元素,然后我们模拟递归必须向前移动一步,即移动到二叉搜索树的下一个最小元素上。栈的顶部始终包含 next() 函数返回的元素。hasNext() 很容易实现,因为我们只需要检查栈是否为空
6.首先,给定二叉搜索树的根节点,我们调用函数 _inorder_left,这确保了栈顶部始终包含了 next() 函数返回的元素
7.假设我们调用 next(),我们需要返回二叉搜索树中的下一个最小元素,即栈的顶部元素。有两种可能性: 一个是栈顶部的节点是一个叶节点。这是最好的情况,因为我们什么都不用做,只需将节点从栈中弹出并返回其值。所以这是个常数时间的操作。 另一个情况是栈顶部的节点拥有右节点。我们不需要检查左节点,因为左节点已经添加到栈中了。栈顶元素要么没有左节点,要么左节点已经被处理了。如果栈顶部拥有右节点,那么我们需要对右节点上调用帮助函数。该时间复杂度取决于树的结构
2.算法
class BSTIterator { Stack<TreeNode> stack; public BSTIterator(TreeNode root) { this.stack = new Stack<TreeNode>();//使用堆栈模拟递归 this._leftmostInorder(root);//算法开始于帮助函数的调用 } //帮助函数,添加所有左子节点到堆栈中 private void _leftmostInorder(TreeNode root) { while (root != null) { this.stack.push(root); root = root.left; } } public int next() { TreeNode topmostNode = this.stack.pop();//栈顶元素即为最小元素 //保持栈顶元素为最小元素,若结点有右孩子,调用帮助函数 if (topmostNode.right != null) { this._leftmostInorder(topmostNode.right); } return topmostNode.val; } public boolean hasNext() { return this.stack.size() > 0; } }
2.1 Python
class BSTIterator: def __init__(self, root: TreeNode): self.stack = [] #使用堆栈模拟递归 self._leftmost_inorder(root) #算法开始于帮助函数的调用 #帮助函数,添加所有左子节点到堆栈中 def _leftmost_inorder(self, root): while root: self.stack.append(root) root = root.left def next(self) -> int: topmost_node = self.stack.pop() #栈顶元素即为最小元素 #保持栈顶元素为最小元素,若结点有右孩子,调用帮助函数 if topmost_node.right: self._leftmost_inorder(topmost_node.right) return topmost_node.val def hasNext(self) -> bool: return len(self.stack) > 0
3.复杂度
时间复杂度:hasNext():若栈中还有元素,则返回 true,反之返回 false。所以这是一个O(1)的操作。 next():包含了两个主要步骤。一个是从栈中弹出一个元素,它是下一个最小的元素。这是一个O(1)的操作。然而,随后我们要调用帮助函数 _inorder_left ,它需要递归的,将左节点添加到栈上,是线性时间的操作,最坏的情况下为O(N)。但是我们只对含有右节点的节点进行调用,它也不会总是处理 N 个节点。只有当我们有一个倾斜的树,才会有 N 个节点。因此该操作的平均时间复杂度仍然是O(1),符合问题中所要求的
空间复杂度:O(h),使用了一个栈来模拟递归
1130.叶值的最小代价生成树×
1.动态规划
0.题目
给你一个正整数数组 arr,考虑所有满足以下条件的二叉树: 每个节点都有 0 个或是 2 个子节点。 数组 arr 中的值与树的中序遍历中每个叶节点的值一一对应。 每个非叶节点的值等于其左子树和右子树中叶节点的最大值的乘积。 在所有这样的二叉树中,返回每个非叶节点的值的最小可能总和
1.思路
这道题的核心是: 要知道中序遍历就决定了arr数组(0...n-1)里的第k位元素的所有左边元素(包括它自己)都在左子树里,而其右边元素都在右子树里,而此时左右两边子树分别选出最大值的乘积就是此时的根,也就是题目中说的非叶节点,所以我们可以假定从i到j位,最小和可能是:此刻k位左右两边元素中最大值的乘积 + 子问题k左边(i,k)的最小值 + 子问题k位右边(k+1,j)的最小值, 即:dp[i][j]=min(dp[i][j], dp[i][k] + dp[k+1][j] + max[i][k]*max[k+1][j]) 这道题跟leetcode1039一个套路
2.代码
class Solution { public int mctFromLeafValues(int[] arr) { int n = arr.length; //求arr从i到j之间的元素最大值, 保存在max[i][j]中,i和j是可以相等的 int[][] max = new int[n][n]; for (int j=0;j<n;j++) { int maxValue = arr[j]; for (int i=j;i>=0;i--) { maxValue = Math.max(maxValue, arr[i]); max[i][j] = maxValue; } } int[][] dp = new int[n][n]; for (int j=0; j<n; j++) { for (int i=j; i>=0; i--) { //k是i到j之间的中间某个值,i<=k<j int min = Integer.MAX_VALUE; for (int k=i; k+1<=j; k++) { min = Math.min(min,dp[i][k] + dp[k+1][j] + max[i][k]*max[k+1][j]); dp[i][j] = min;//始终保持i到j之间的最小和 } } } return dp[0][n-1]; } }
2.1 Python
class Solution: def mctFromLeafValues(self, arr: List[int]) -> int: n = len(arr) dp = [[float('inf') for _ in range(n)] for _ in range(n)] # 初始值设为最大 maxval = [[0 for _ in range(n)] for _ in range(n)] # 初始区间查询最大值设为0 for i in range(n):# 求区间[i, j]中最大元素 for j in range(i, n): maxval[i][j] = max(arr[i:j + 1]) for i in range(n):# 叶子结点不参与计算 dp[i][i] = 0 for l in range(1, n): # 枚举区间长度 for s in range(n - l): # 枚举区间起始点 for k in range(s, s + l):# 枚举划分两棵子树,k是s到l之间的中间某个值 dp[s][s + l] = min(dp[s][s + l], dp[s][k] + dp[k + 1][s + l] + maxval[s][k] * maxval[k + 1][s + l]) return dp[0][n - 1]
3.复杂度
时间: O(n^3), 空间: O(n^2)
2.递减栈
0.链接
1.思路
1.想让 mct 值最小,那么值较小的叶子节点就要尽量放到底部,值较大的叶子节点要尽量放到靠上的部分。因为越是底部的叶子节点,被用来做乘法的次数越多。这就决定了我们有必要去寻找一个极小值。通过维护一个单调递减栈就可以找到一个极小值,因为既然是单调递减栈,左侧节点一定大于栈顶节点,而当前节点(右侧)也大于栈顶节点(因为当前节点小于栈顶的话,就被直接入栈了)
2.找到这个极小值后,就需要左右看看,左边和右边哪个值更小,目的是把较小的值尽量放到底部
2.代码
class Solution { public int mctFromLeafValues(int[] arr) { Stack<Integer> st = new Stack(); st.push(Integer.MAX_VALUE); int mct = 0; //开始构造递减栈,当前元素大于栈顶元素,则栈顶元素出栈,可找到极小值 for (int i = 0; i < arr.length; i++) { while (arr[i] >= st.peek()) { //栈顶元素出栈且和左右两边的较小元素组合在一起 mct += st.pop() * Math.min(st.peek(), arr[i]); } st.push(arr[i]); } while (st.size() > 2) { mct += st.pop() * st.peek(); } return mct; } }
2.1 Python
class Solution: def mctFromLeafValues(self, A: List[int]) -> int: res, n = 0, len(A) stack = [float('inf')] #栈中先放入最大值 #开始构造递减栈,当前元素大于栈顶元素,则栈顶元素出栈,可找到极小值 for a in A: while a >= stack[-1]: mid = stack.pop() #栈顶元素出栈且和左右两边的较小元素组合在一起 res += mid * min(stack[-1], a) stack.append(a) while len(stack) > 2: res += stack.pop() * stack[-1] return res
3.复杂度
时间: O(n), 空间: O(n)
108.有序数组转换为平衡二叉搜索树
1.中序+递归
0.题目
将一个按照升序排列的有序数组,转换为一棵高度平衡二叉搜索树。 一个高度平衡二叉树是指一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1
1.思路
1.选择中间数字作为二叉搜索树的根节点,这样分给左右子树的数字个数相同或只相差1,可以使得树保持平衡
2.确定平衡二叉搜索树的根节点之后,其余的数字分别位于平衡二叉搜索树的左子树和右子树中,左子树和右子树分别也是平衡二叉搜索树,因此可以通过递归的方式创建平衡二叉搜索树
3.为什么这么建树一定能保证是「平衡」的呢?这里可以参考「1382. 将二叉搜索树变平衡」
4.递归的基准情形是平衡二叉搜索树不包含任何数字,此时平衡二叉搜索树为空
5.在给定中序遍历序列数组的情况下,每一个子树中的数字在数组中一定是连续的,可以通过数组下标范围确定子树包含的数字,下标范围记为[left,right],当 left>right 时,平衡二叉搜索树为空
2.代码
class Solution: def sortedArrayToBST(self, nums: List[int]) -> TreeNode: def helper(left, right): if left > right: #二分法终止条件 return None mid = (left + right) // 2 #总是选择中间位置左边的数字作为根节点 root = TreeNode(nums[mid]) #先确定根结点,再递归左右子树 root.left = helper(left, mid - 1) root.right = helper(mid + 1, right) return root return helper(0, len(nums) - 1)
3.复杂度
时间复杂度:O(n),其中n是数组的长度。每个数字只访问一次
空间复杂度: O(logn),其中n是数组的长度。空间复杂度不考虑返回值,因此空间复杂度主要取决于递归栈的深度,递归栈的深度是O(logn)
109.有序链表转换平衡二叉搜索树
1.将有序链表转成有序数组
1.复杂度
时间: O(n), 空间: O(n)
2.快慢指针+分治
1.思想
1.设置左闭右开区间
设当前链表的左端点为left,右端点right,包含关系为「左闭右开」,给定的链表为单向链表,访问后继元素十分容易,但无法直接访问前驱元素。因此在找出链表的中位数节点mid之后,如果设定「左闭右开」的关系,就可以直接用(left,mid) 以及(mid.next,right) 来表示左右子树对应的列表了,不需要mid.pre,并且,初始的列表也可以用(head,null)方便地进行表示
2.快慢指针找中位数
初始时,快指针fast 和慢指针slow 均指向链表的左端点left。我们将快指针fast 向右移动两次的同时,将慢指针slow 向右移动一次,直到快指针到达边界(即快指针到达右端点或快指针的下一个节点是右端点)。此时,慢指针对应的元素就是中位数
3.递归构造树
找出了中位数节点之后,我们将其作为当前根节点的元素,并递归地构造其左侧部分的链表对应的左子树,以及右侧部分的链表对应的右子树
2.算法
class Solution: def sortedListToBST(self, head: ListNode) -> TreeNode: def getMedian(left: ListNode, right: ListNode) -> ListNode: fast = slow = left while fast != right and fast.next != right:#定义区间为左闭右开 fast = fast.next.next#快指针移动两次,慢指针一次 slow = slow.next return slow #此时慢指针即为中位数 def buildTree(left: ListNode, right: ListNode) -> TreeNode: if left == right: return None mid = getMedian(left, right) #区间为左闭右开 root = TreeNode(mid.val) #先确定根结点,再构造左右子树 root.left = buildTree(left, mid) #不需要mid.pre root.right = buildTree(mid.next, right) return root return buildTree(head, None)
3.复杂度
时间复杂度:O(nlogn),其中n是链表的长度。设长度为n的链表构造二叉搜索树的时间为T(n),递推式为T(n)=2⋅T(n/2)+O(n),根据主定理,T(n)=O(nlogn)
空间复杂度:O(logn),这里只计算除了返回答案之外的空间。平衡二叉树的高度为 O(logn),即为递归过程中栈的最大深度,也就是需要的空间
3.分治+中序遍历优化
1.思想
1.设当前链表的左端点编号为left,右端点编号为right,包含关系为「双闭」,链表节点的编号为[0,n),中序遍历的顺序是「左子树 - 根节点 - 右子树」,那么在分治的过程中,我们不用急着找出链表的中位数节点,而是使用一个占位节点,等到中序遍历到该节点时,再填充它的值
2.通过计算编号范围来进行中序遍历:中位数节点对应的编号为mid=(left+right+1)/2,左右子树对应的编号范围分别为[left,mid−1]和[mid+1,right],如果 left>right,那么遍历到的位置对应着一个空节点,否则对应着二叉搜索树中的一个节点
3.已经知道了这棵二叉搜索树的结构,并且题目给定了它的中序遍历结果,那么我们只要对其进行中序遍历,就可以还原出整棵二叉搜索树了
4.递归过程图
2.代码
class Solution: def sortedListToBST(self, head: ListNode) -> TreeNode: def getLength(head: ListNode) -> int: ret = 0 while head: ret += 1 head = head.next return ret def buildTree(left: int, right: int) -> TreeNode: if left > right: #中序遍历结束条件 return None mid = (left + right + 1) // 2 root = TreeNode() #用中序遍历,先构造左子树,再回归到根结点,再构造右子树,从第一个元素开始构造 root.left = buildTree(left, mid - 1) nonlocal head #可修改外层非全局的变量head root.val = head.val #从第一个节点开始赋值 head = head.next root.right = buildTree(mid + 1, right) return root length = getLength(head) return buildTree(0, length - 1)
3.复杂度
时间复杂度:O(n),其中n是链表的长度。设长度为n的链表构造二叉搜索树的时间为T(n),递推式为T(n)=2⋅T(n/2)+O(1),根据主定理,T(n)=O(n)
空间复杂度:O(logn),这里只计算除了返回答案之外的空间。平衡二叉树的高度为 O(logn),即为递归过程中栈的最大深度,也就是需要的空间
297.二叉树的序列化与反序列化
1.前序遍历
0.题目
序列化是将一个数据结构或者对象转换为连续的比特位的操作,进而可以将转换后的数据存储在一个文件或者内存中,同时也可以通过网络传输到另一个计算机环境,采取相反方式重构得到原数据。 请设计一个算法来实现二叉树的序列化与反序列化。这里不限定你的序列 / 反序列化算法执行逻辑,你只需要保证一个二叉树可以被序列化为一个字符串并且将这个字符串反序列化为原始的树结构
1.思路
1.序列化
1.选择前序遍历是因为根∣左∣右 的打印顺序,在反序列化时更容易定位出根节点的值
2.遇到 null 节点也要翻译成对应符号,反序列化时才知道这里对应 null
3.递归图
2.反序列化
1.要构建树,先构建根节点,辅助函数 buildTree,接收由序列化字符串转成的 list 数组
2.依次弹出list的首项,构建当前子树的根节点,顺着list数组,就会先构建根节点、构建左子树、构建右子树。 弹出的字符为 'X',则返回 null 节点。 弹出的字符不为 'X',则创建节点,并递归构建它的左右子树,返回当前子树
2.代码
class Codec: def serialize(self, root): #前序遍历实现序列化 if root == None: return 'X,' leftserilized = self.serialize(root.left) rightserilized = self.serialize(root.right) return str(root.val) + ',' + leftserilized + rightserilized def deserialize(self, data): data = data.split(',') root = self.buildTree(data) return root def buildTree(self, data): val = data.pop(0) #弹出第一个字符 if val == 'X': return None #返回空节点 node = TreeNode(val) node.left = self.buildTree(data) node.right = self.buildTree(data) return node
3.复杂度
时间: O(n), 空间: O(n)
100. 相同的树
1.深度优先
0.题目
给你两棵二叉树的根节点 p 和 q ,编写一个函数来检验这两棵树是否相同
1.思路
1.如果两个二叉树都为空,则两个二叉树相同。如果两个二叉树中有且只有一个为空,则两个二叉树一定不相同
2.如果两个二叉树都不为空,那么首先判断它们的根节点的值是否相同,若不相同则两个二叉树一定不同,若相同,再分别判断两个二叉树的左子树是否相同以及右子树是否相同。这是一个递归的过程,因此可以使用深度优先搜索,递归地判断两个二叉树是否相同
2.代码
class Solution: def isSameTree(self, p: TreeNode, q: TreeNode) -> bool: if not p and not q: #两棵树都为空,相同 return True elif not p or not q: #只有其中一棵树为空,两棵树不相同 return False elif p.val != q.val: return False else: return self.isSameTree(p.left, q.left) and self.isSameTree(p.right, q.right)
3.复杂度
时间复杂度:O(\min(m,n))O(min(m,n)),其中 mm 和n分别是两个二叉树的节点数。对两个二叉树同时进行深度优先搜索,只有当两个二叉树中的对应节点都不为空时才会访问到该节点,因此被访问到的节点数不会超过较小的二叉树的节点数
空间复杂度:O(\min(m,n))O(min(m,n)),其中 mm 和n分别是两个二叉树的节点数。空间复杂度取决于递归调用的层数,递归调用的层数不会超过较小的二叉树的最大高度,最坏情况下,二叉树的高度等于节点数
2.广度优先
1.思路
1.首先判断两个二叉树是否为空,如果两个二叉树都不为空,则从两个二叉树的根节点开始广度优先搜索
2.使用两个队列分别存储两个二叉树的节点。初始时将两个二叉树的根节点分别加入两个队列。每次从两个队列各取出一个节点,进行如下比较操作。 比较两个节点的值,如果两个节点的值不相同则两个二叉树一定不同; 如果两个节点的值相同,则判断两个节点的子节点是否为空,如果只有一个节点的左子节点为空,或者只有一个节点的右子节点为空,则两个二叉树的结构不同,因此两个二叉树一定不同; 如果两个节点的子节点的结构相同,则将两个节点的非空子节点分别加入两个队列,子节点加入队列时需要注意顺序,如果左右子节点都不为空,则先加入左子节点,后加入右子节点
3.如果搜索结束时两个队列同时为空,则两个二叉树相同。如果只有一个队列为空,则两个二叉树的结构不同,因此两个二叉树不同
2.代码
class Solution: def isSameTree(self, p: TreeNode, q: TreeNode) -> bool: if not p and not q: #两棵树都为空,相同 return True if not p or not q: #只有其中一棵树为空,两棵树不相同 return False queue1 = collections.deque([p]) queue2 = collections.deque([q]) while queue1 and queue2: node1 = queue1.popleft() node2 = queue2.popleft() if node1.val != node2.val: return False left1, right1 = node1.left, node1.right left2, right2 = node2.left, node2.right if (not left1) ^ (not left2): # ^表示异或,两者不同为1,相同为0 return False if (not right1) ^ (not right2): return False if left1: #结点不为空时入队列 queue1.append(left1) if right1: queue1.append(right1) if left2: queue2.append(left2) if right2: queue2.append(right2) return not queue1 and not queue2
3.复杂度
时间复杂度:O(\min(m,n))O(min(m,n)),其中 mm 和n分别是两个二叉树的节点数。对两个二叉树同时进行广度优先搜索,只有当两个二叉树中的对应节点都不为空时才会访问到该节点,因此被访问到的节点数不会超过较小的二叉树的节点数
空间复杂度:O(\min(m,n))O(min(m,n)),其中 mm 和n分别是两个二叉树的节点数。空间复杂度取决于队列中的元素个数,队列中的元素个数不会超过较小的二叉树的节点数
105.从前序与中序遍历序列构造二叉树
1.递归
1.思路
1.对于任意一颗树而言,前序遍历的形式总是 [ 根节点, [左子树的前序遍历结果], [右子树的前序遍历结果] ] 即根节点总是前序遍历中的第一个节点。而中序遍历的形式总是 [ [左子树的中序遍历结果], 根节点, [右子树的中序遍历结果] ]
2.只要我们在中序遍历中定位到根节点,那么我们就可以分别知道左子树和右子树中的节点数目。由于同一颗子树的前序遍历和中序遍历的长度显然是相同的,因此我们就可以对应到前序遍历的结果中,对上述形式中的所有左右括号进行定位
3.这样以来,我们就知道了左子树的前序遍历和中序遍历结果,以及右子树的前序遍历和中序遍历结果,我们就可以递归地对构造出左子树和右子树,再将这两颗子树接到根节点的左右位置
4.优化: 在中序遍历中对根节点进行定位时,一种简单的方法是直接扫描整个中序遍历的结果并找出根节点list.index(x),但这样做的时间复杂度较高。我们可以考虑使用哈希映射(HashMap)来帮助我们快速地定位根节点。对于哈希映射中的每个键值对,键表示一个元素(节点的值),值表示其在中序遍历中的出现位置。在构造二叉树的过程之前,我们可以对中序遍历的列表进行一遍扫描,就可以构造出这个哈希映射。在此后构造二叉树的过程中,我们就只需要O(1)的时间对根节点进行定位了
2.代码
1.官方优化过,但参数较多,可以使用两个参数,见后序和中序结合
class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode: def myBuildTree(preorder_left: int, preorder_right: int, inorder_left: int, inorder_right: int): if preorder_left > preorder_right: return None preorder_root = preorder_left # 前序遍历中的第一个节点就是根节点 inorder_root = index[preorder[preorder_root]] # 在中序遍历中定位根节点的下标 root = TreeNode(preorder[preorder_root]) # 先把根节点建立出来 size_left_subtree = inorder_root - inorder_left # 得到左子树中的节点数目 # 递归地构造左子树,并连接到根节点 # 先序遍历中「从 左边界+1 开始的 size_left_subtree」个元素就对应了中序遍历中「从 左边界 开始到 根节点定位-1」的元素 root.left = myBuildTree(preorder_left + 1, preorder_left + size_left_subtree, inorder_left, inorder_root - 1) # 递归地构造右子树,并连接到根节点 # 先序遍历中「从 左边界+1+左子树节点数目 开始到 右边界」的元素就对应了中序遍历中「从 根节点定位+1 到 右边界」的元素 root.right = myBuildTree(preorder_left+1 + size_left_subtree, preorder_right, inorder_root + 1, inorder_right) return root n = len(preorder) index = {element: i for i, element in enumerate(inorder)} #构造哈希映射,键为元素,值为下标,快速定位根节点 return myBuildTree(0, n - 1, 0, n - 1)
2.简洁版
class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode: def recur_func(inorder): x = preorder.pop(0) # 每次取出前序列表最左端的元素 node = TreeNode(x) # 用该元素生成一个node idx = inorder.index(x) # 找到该元素在中序列表中的索引 left_l = inorder[:idx] # 用该元素分割中序列表 right_l = inorder[idx+1:] node.left = recur_func(left_l) if left_l else None node.right = recur_func(right_l) if right_l else None # 一直探索到最底层的最左端的叶子,然后从下往上一层层返回 return node if not preorder or not inorder: return None # 判空 return recur_func(inorder)
3.复杂度
时间复杂度:O(n),其中n是树中的节点个数
空间复杂度:O(n),除去返回的答案需要的 O(n) 空间之外,我们还需要使用 O(n) 的空间存储哈希映射,以及 O(h)(其中h是树的高度)的空间表示递归时栈空间
2.迭代
1.思路
1.我们用一个栈和一个指针辅助进行二叉树的构造。初始时栈中存放了根节点(前序遍历的第一个节点),指针指向中序遍历的第一个节点「当前节点不断往左走达到的最终节点」
1.stack 来维护「当前节点的所有还没有考虑过右儿子的祖先节点」,栈顶就是当前节点。也就是说,只有在栈中的节点才可能连接一个新的右儿子
2.依次枚举前序遍历中除了第一个节点以外的每个节点。如果index 恰好指向栈顶节点,那么我们不断地弹出栈顶节点并向右移动 index,并将当前节点作为最后一个弹出的节点的右儿子
1.对于前序遍历中的任意两个连续节点u和v,根据前序遍历的流程,我们可以知道u和v 只有两种可能的关系: v是u的左儿子。这是因为在遍历到u之后,下一个遍历的节点就是u的左儿子,即v; u没有左儿子,并且v 是u的某个祖先节点(或者u本身)的右儿子。如果u没有左儿子,那么下一个遍历的节点就是u的右儿子。如果u没有右儿子,我们就会向上回溯,直到遇到第一个有右儿子(且u不在它的右儿子的子树中)的节点ua ,那么v 就是ua的右儿子
2.我们遍历 10,这时情况就不一样了。我们发现 index 恰好指向当前的栈顶节点 4,也就是说 4 没有左儿子,那么 10 必须为栈中某个节点的右儿子
3.栈中的节点的顺序和它们在前序遍历中出现的顺序是一致的,而且每一个节点的右儿子都还没有被遍历过,那么这些节点的顺序和它们在中序遍历中出现的顺序一定是相反的
4.我们可以把 index 不断向右移动,并与栈顶节点进行比较。如果 index 对应的元素恰好等于栈顶节点,那么说明我们在中序遍历中找到了栈顶节点,所以将 index 增加 1 并弹出栈顶节点,直到 index 对应的元素不等于栈顶节点。按照这样的过程,我们弹出的最后一个节点 x 就是 10 的双亲节点,这是因为 10 出现在了 x 与 x 在栈中的下一个节点的中序遍历之间,因此 10 就是 x 的右儿子
3.如果 index 和栈顶节点不同,我们将当前节点作为栈顶节点的左儿子
1.我们遍历 9。9 一定是栈顶节点 3 的左儿子。我们使用反证法,假设 9 是 3 的右儿子,那么 3 没有左儿子,index 应该恰好指向 3,但实际上为 4,因此产生了矛盾。所以我们将 9 作为 3 的左儿子,并将 9 入栈
4.无论是哪一种情况,我们最后都将当前的节点入栈
2.代码
class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode: if not preorder: return None root = TreeNode(preorder[0]) stack = [root] inorderIndex = 0 #当前节点不断往左走达到的最终节点,符合中序遍历 for i in range(1, len(preorder)):#从第二个结点开始遍历先序遍历 preorderVal = preorder[i] node = stack[-1] #栈顶结点 if node.val != inorder[inorderIndex]: #栈顶结点不相等时,为左孩子 node.left = TreeNode(preorderVal) #直接构造左孩子 stack.append(node.left) #将左孩子入栈 else: #栈顶结点相等时,已经遍历到最左边,需要出栈再遍历某个结点的右孩子 while stack and stack[-1].val == inorder[inorderIndex]: node = stack.pop() #栈顶元素出栈 inorderIndex += 1 #更换最左边的元素 node.right = TreeNode(preorderVal) #两者不同时,找到了当前元素的父节点 stack.append(node.right) #同样将当前元素入栈 return root
3.复杂度
时间复杂度:O(n),其中n是树中的节点个数
空间复杂度:O(n),除去返回的答案需要的 O(n) 空间之外,我们还需要使用 O(h)(其中h是树的高度)的空间存储栈
106.从中序与后序遍历序列构造二叉树
1.递归
1.思路
1.后序遍历的数组最后一个元素代表的即为根节点,再划分中序遍历
2.注意这里有需要先创建右子树,再创建左子树的依赖关系。可以理解为在后序遍历的数组中整个数组是先存储左子树的节点,再存储右子树的节点,最后存储根节点,如果按每次选择「后序遍历的最后一个节点」为根节点,则先被构造出来的应该为右子树
2.代码
1.官方: 两个参数
class Solution: def buildTree(self, inorder: List[int], postorder: List[int]) -> TreeNode: def helper(in_left, in_right): if in_left > in_right:# 如果这里没有节点构造二叉树了,就结束 return None val = postorder.pop()# 选择 post_idx 位置的元素作为当前子树根节点 root = TreeNode(val) index = idx_map[val] # 根据 root 所在位置分成左右两棵子树 #必须先构造右子树,因为后序遍历是从最后访问的,必然先访问到右子树 root.right = helper(index + 1, in_right) root.left = helper(in_left, index - 1) return root # 建立(元素,下标)键值对的哈希表 idx_map = {val:idx for idx, val in enumerate(inorder)} return helper(0, len(inorder) - 1)
2.简洁版
class Solution: def buildTree(self, inorder: List[int], postorder: List[int]) -> TreeNode: def recur_func(inorder): x = postorder.pop() # 每次取出前序列表最左端的元素 node = TreeNode(x) # 用该元素生成一个node idx = inorder.index(x) # 找到该元素在中序列表中的索引 left_l = inorder[:idx] # 用该元素分割中序列表 right_l = inorder[idx+1:] #必须先构造右子树,因为后序遍历是从最后访问的,必然先访问到右子树 node.right = recur_func(right_l) if right_l else None node.left = recur_func(left_l) if left_l else None # 一直探索到最底层的最左端的叶子,然后从下往上一层层返回 return node if not postorder or not inorder: return None # 判空 return recur_func(inorder)
3.复杂度
时间复杂度:O(n),其中n是树中的节点个数
空间复杂度:O(n),除去返回的答案需要的 O(n) 空间之外,我们还需要使用 O(n) 的空间存储哈希映射,以及 O(h)(其中h是树的高度)的空间表示递归时栈空间
2.迭代
1.思路
1.如果将中序遍历反序,则得到反向的中序遍历,即每次遍历右孩子,再遍历根节点,最后遍历左孩子。 如果将后序遍历反序,则得到反向的前序遍历,即每次遍历根节点,再遍历右孩子,最后遍历左孩子
2.和上一题不同在于: 左孩子全部变为右孩子,右孩子全部变为左孩子,正序遍历改为逆序遍历
2.代码
class Solution: def buildTree(self, inorder: List[int], postorder: List[int]) -> TreeNode: if not postorder: return None root = TreeNode(postorder[-1]) stack = [root] inorderIndex = -1 #当前节点不断往右走达到的最终节点,符合中序遍历 for i in range(len(postorder)-2, -1,-1):#从倒数第二个结点开始遍历后序遍历 postorderVal = postorder[i] node = stack[-1] #栈顶结点 if node.val != inorder[inorderIndex]: #栈顶结点不相等时,为右孩子 node.right = TreeNode(postorderVal) #直接构造右孩子 stack.append(node.right) #将右孩子入栈 else: #栈顶结点相等时,已经遍历到最右边,需要出栈再遍历某个结点的左孩子 while stack and stack[-1].val == inorder[inorderIndex]: node = stack.pop() #栈顶元素出栈 inorderIndex -= 1 #更换最右边的元素 node.left = TreeNode(postorderVal) #两者不同时,找到了当前元素的父节点 stack.append(node.left) #同样将当前元素入栈 return root
3.复杂度
时间复杂度:O(n),其中n是树中的节点个数
空间复杂度:O(n),除去返回的答案需要的 O(n) 空间之外,我们还需要使用 O(h)(其中h是树的高度)的空间存储栈
124.二叉树中的最大路径和
1.递归
0.题目
路径 被定义为一条从树中任意节点出发,沿父节点-子节点连接,达到任意节点的序列。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点。 路径和 是路径中各节点值的总和。 给你一个二叉树的根节点 root ,返回其 最大路径和
1.思路
1.首先,考虑实现一个简化的函数 maxGain(node),该函数计算二叉树中的一个节点的最大贡献值,具体而言,就是在以该节点为根节点的子树中寻找以该节点为起点的一条路径,使得该路径上的节点值之和最大
2.具体而言,该函数的计算如下。 空节点的最大贡献值等于 00。 非空节点的最大贡献值等于节点值与其子节点中的最大贡献值之和(对于叶节点而言,最大贡献值等于节点值
3.上述计算过程是递归的过程,因此,对根节点调用函数 maxGain,即可得到每个节点的最大贡献值
4.根据函数 maxGain 得到每个节点的最大贡献值之后,如何得到二叉树的最大路径和?对于二叉树中的一个节点,该节点的最大路径和取决于该节点的值与该节点的左右子节点的最大贡献值,如果子节点的最大贡献值为正,则计入该节点的最大路径和,否则不计入该节点的最大路径和。维护一个全局变量 maxSum 存储最大路径和,在递归过程中更新 maxSum 的值,最后得到的 maxSum 的值即为二叉树中的最大路径和
2.代码
class Solution: def __init__(self): self.maxSum = float("-inf") #最大值初始化为负无穷 def maxPathSum(self, root: TreeNode) -> int: def maxGain(node): #获得结点的最大贡献值 if not node: return 0 # 递归计算左右子节点的最大贡献值 # 只有在最大贡献值大于 0 时,才会选取对应子节点 leftGain = max(maxGain(node.left), 0) rightGain = max(maxGain(node.right), 0) # 先得到以当前结点为根结点的最大路径值后,再返回该结点的最大贡献值 # 节点的最大路径和取决于该节点的值与该节点的左右子节点的最大贡献值 priceNewpath = node.val + leftGain + rightGain self.maxSum = max(self.maxSum, priceNewpath) # 更新答案 # 返回节点的最大贡献值,由结点值和左子树/右子树中较大者决定 return node.val + max(leftGain, rightGain) maxGain(root) return self.maxSum
3.复杂度
时间复杂度:O(N)O(N),其中 NN 是二叉树中的节点个数。对每个节点访问不超过2次
空间复杂度:O(N)O(N),其中 NN 是二叉树中的节点个数。空间复杂度主要取决于递归调用层数,最大层数等于二叉树的高度,最坏情况下,二叉树的高度等于二叉树中的节点个数
654.最大二叉树
1.递归
0.题目
给定一个不含重复元素的整数数组 nums 。一个以此数组直接递归构建的 最大二叉树 定义如下: 二叉树的根是数组 nums 中的最大元素。 左子树是通过数组中 最大值左边部分 递归构造出的最大二叉树。 右子树是通过数组中 最大值右边部分 递归构造出的最大二叉树。 返回有给定数组 nums 构建的 最大二叉树
1.思路
还是三部曲 1.递归终止条件: 当左子树和右子树的数组都为空时 2.本次递归做什么: 取出数组的最大值,并把数组根据最大值分成左右两个数组,然后进行递归赋值 3.返回什么: 子树的根节点
2.代码
class Solution: def constructMaximumBinaryTree(self, nums: List[int]) -> TreeNode: if not nums: return None # 适用于有重复元素时 # max_v, m_i = float(-inf), 0 # for i, v in enumerate(nums): # if v>max_v: # max_v = v # m_i = i max_v = max(nums) m_i = nums.index(max_v) root = TreeNode(max_v) root.left = self.constructMaximumBinaryTree(nums[:m_i]) root.right = self.constructMaximumBinaryTree(nums[m_i+1:]) return root
3.复杂度
时间复杂度:O(n^2),方法 construct 一共被调用n次。每次递归寻找根节点时,需要遍历当前索引范围内所有元素找出最大值。一般情况下,每次遍历的复杂度为 O(logn),总复杂度为O(nlogn)。最坏的情况下,数组 numsnums 有序,总的复杂度为 O(n^2)
空间复杂度:O(n)O(n)。递归调用深度为 nn。平均情况下,长度为n的数组递归调用深度为 O(logn)
2.单调递减栈
1.思想
1.构造一个递减栈,依次入栈,若栈顶元素小于当前元素,则栈顶元素出栈
2.比较出栈后的栈顶元素和当前元素的大小,若大于当前元素,则当前元素作为父节点,出栈元素作为左孩子;若栈顶元素小于当前元素,则栈顶元素作为父节点并出栈,之前出栈元素作为右孩子,依次下去,直到当前结点入栈
3.直到最后一个元素结束,递减栈形成,依次出栈,出栈元素作为右孩子,最后出栈的就是根结点,返回此节点
举例
以测试案例为例,一个输入序列:[3, 2, 1, 6, 0, 5]。 设置一个辅助栈,从大到小存储。 过程如下: 首先入栈3 2 比 3 小,入栈 1 比 2 小,入栈 6 大于1,因此要弹出1,1在2和6之间选择二者之间较小的元素作为父节点,因此选择2。1在2的右侧,使得1作为2的右子节点 弹出1后,6仍然比2大,同理2要在3和6之间选择一个作为父节点。3比6小,因此选择3。2在3的右侧,因此2作为3的右子节点 同理弹出3,让3作为6的左子节点 入栈6 入栈0 入栈5的时候比0大,要弹出0,选择5作为父节点,并且0是5的左孩子 弹出5,左侧是6,作为5的父节点 6最后弹出,就是根节点
2.代码Java
public TreeNode constructMaximumBinaryTree(int[] nums) { TreeNode root = null, node = null; LinkedList<TreeNode> stack = new LinkedList<>(); for (int i = 0; i < nums.length; ++i) { node = new TreeNode(nums[i]); //构造递减栈,当栈顶元素小于当前元素时,栈顶元素就要出栈 while (!stack.isEmpty() && stack.peek().val < node.val) { root = stack.pop();//当前栈顶元素,最小的值 //比较出栈后的栈顶和当前元素大小,若大于当前元素,则当前元素作为父节点 if (stack.isEmpty() || stack.peek().val > node.val) { node.left = root; } else {//若小于当前元素,则栈顶元素作为父节点 stack.peek().right = root; } } stack.push(node);// 当前元素入栈 } // 递减栈完全形成,依次出栈作为右孩子即可 while (!stack.isEmpty()) { root = stack.pop(); if (!stack.isEmpty()) { stack.peek().right = root; } } return root;// 最后出栈的就是root,返回该结点 }
3.复杂度
时间复杂度: O(n),空间复杂度: O(n)
998.最大二叉树 II×
1.遍历插入
0.题目
最大树定义:一个树,其中每个节点的值都大于其子树中的任何其他值 给出最大树的根节点 root。就像之前的问题那样,给定的树是从列表A递归地构造的,我们没有直接给定 A,只有一个根节点 root,假设 B 是 A 的副本,并在末尾附加值 val。题目数据保证 B 中的值是不同的。返回 Construct(B)
1.思想
每次与头结点比较,小的话就检查其右节点。更新:1)已有的节点变成新节点的左接单,2)新节点变成父节点的右节点
2.代码
def insertIntoMaxTree(self, root: TreeNode, val: int) -> TreeNode: # dummy虚拟变量,其右孩子指向根结点 dummy = TreeNode(0) dummy.right = root # search 若当前根结点值大于val,则继续查询其右孩子 p, c = dummy, dummy.right while c and c.val > val: p, c = c, c.right # insert 此时val大于根结点,val作为上个根结点的右孩子,当前根结点作为val的左孩子 n = TreeNode(val) p.right = n n.left = c return dummy.right #dummy一直未变,其右孩子指向根结点
3.复杂度
2.递归
1.思想
大于根结点时同样的处理,小于根结点时递归的对根结点的右孩子进行处理
2.代码
def insertIntoMaxTree(self, root: TreeNode, val: int) -> TreeNode: if root is None: # 递归终止条件 return TreeNode(val) if val > root.val: #val大于当前根结点的处理方式 tmp = TreeNode(val) tmp.left = root return tmp # 递归处理根结点的右孩子 root.right = self.insertIntoMaxTree(root.right, val) return root # 递归返回值,根结点
3.复杂度
子主题
子主题
110.判断平衡二叉树
1.自顶向下
0.题目
给定一个二叉树,判断它是否是高度平衡的二叉树。本题中,一棵高度平衡二叉树定义为:一个二叉树每个节点的左右两个子树的高度差的绝对值不超过 1
1.思路
1.定义函数height,用于计算二叉树中的任意一个节点p的高度
2.类似于二叉树的前序遍历,即对于当前遍历到的节点,首先计算左右子树的高度,如果左右子树的高度差是否不超过1,再分别递归地遍历左右子节点,并判断左子树和右子树是否平衡。这是一个自顶向下的递归的过程
2.代码
class Solution:#自顶向下,类似前序遍历,会重复计算左右子树的高度 def isBalanced(self, root: TreeNode) -> bool: def height(root: TreeNode) -> int: if not root: return 0 return max(height(root.left), height(root.right)) + 1 if not root: return True return abs(height(root.left) - height(root.right)) <= 1 and self.isBalanced(root.left) and self.isBalanced(root.right)
3.复杂度
时间复杂度:O(n),其中n是二叉树中的节点个数。使用自底向上的递归,每个节点的计算高度和判断是否平衡都只需要处理一次,最坏情况下需要遍历二叉树中的所有节点,因此时间复杂度是O(n)
空间复杂度:O(n),其中n是二叉树中的节点个数。空间复杂度主要取决于递归调用的层数,递归调用的层数不会超过 nn
2.自底向上
1.思路
1.方法一由于是自顶向下递归,因此对于同一个节点,函数 \texttt{height}height 会被重复调用,导致时间复杂度较高。如果使用自底向上的做法,则对于每个节点,函数 \texttt{height}height 只会被调用一次
2.自底向上递归的做法类似于后序遍历,对于当前遍历到的节点,先递归地判断其左右子树是否平衡,再判断以当前节点为根的子树是否平衡。如果一棵子树是平衡的,则返回其高度(高度一定是非负整数),否则返回 -1−1。如果存在一棵子树不平衡,则整个二叉树一定不平衡
2.代码
class Solution: #自底向上,类似后序遍历,先判断左右子树,再判断根结点,保证每个节点高度只计算一次 def isBalanced(self, root: TreeNode) -> bool: def height(root: TreeNode) -> int: if not root: return 0 leftHeight = height(root.left) rightHeight = height(root.right) if leftHeight == -1 or rightHeight == -1 or abs(leftHeight - rightHeight) > 1: return -1 else: return max(leftHeight, rightHeight) + 1 return height(root) >= 0
3.复杂度
时间复杂度:O(n),其中n是二叉树中的节点个数。使用自底向上的递归,每个节点的计算高度和判断是否平衡都只需要处理一次,最坏情况下需要遍历二叉树中的所有节点,因此时间复杂度是 O(n)
空间复杂度:O(n),其中n是二叉树中的节点个数。空间复杂度主要取决于递归调用的层数,递归调用的层数不会超过 nn
111.二叉树的最小深度
1.深度优先
0.题目
给定一个二叉树,找出其最小深度。 最小深度是从根节点到最近叶子节点的最短路径上的节点数量
1.思路
1.这道题的关键是搞清楚递归结束条件 叶子节点的定义是左孩子和右孩子都为 null 时叫做叶子节点 当 root 节点左右孩子都为空时,返回 1 当 root 节点左右孩子有一个为空时,返回不为空的孩子节点的深度 当 root 节点左右孩子都不为空时,返回左右孩子较小深度的节点值
2.代码
class Solution: def minDepth(self, root: TreeNode) -> int: if not root: return 0 #左右子树都为空时才是叶子结点,返回距离为1 if not root.left and not root.right: return 1 min_depth = 10**9 if root.left: #左子树存在,求左子树最小距离与当前最小距离比较 min_depth = min(self.minDepth(root.left), min_depth) if root.right: min_depth = min(self.minDepth(root.right), min_depth) return min_depth + 1
3.复杂度
时间复杂度:O(n),其中n是树的节点数。对每个节点访问一次
空间复杂度:O(h),其中h是树的高度。空间复杂度主要取决于递归时栈空间的开销,最坏情况下,树呈现链状,空间复杂度为 O(n)。平均情况下树的高度与节点数的对数正相关,空间复杂度为 O(logN)
2.广度优先
1.思路
当我们找到一个叶子节点时,直接返回这个叶子节点的深度。广度优先搜索的性质保证了最先搜索到的叶子节点的深度一定最小
2.代码
class Solution: def minDepth(self, root: TreeNode) -> int: if not root: return 0 que = collections.deque([(root, 1)]) while que: node, depth = que.popleft() #第一个叶子结点一定是距离最近的叶子结点 if not node.left and not node.right: return depth if node.left: que.append((node.left, depth + 1)) if node.right: que.append((node.right, depth + 1)) return 0
3.复杂度
时间复杂度:O(n),其中n是树的节点数。对每个节点访问一次
空间复杂度:O(n),其中n是树的节点数。空间复杂度主要取决于队列的开销,队列中的元素个数不会超过树的节点数
104,226,96,617,173,1130,108,297,100,105,95,124,654,669,99,979,199,110,236,101,235,114,94,222,102,938,437
7.广度优先
0.频率
200,279,301,199,101,127,102,407,133,107,103,126,773,994,207,111,847,417,529,130,542,690,,,743,210,913,512
8.深度优先
0.频率
200,104,1192,108,301,394,100,105,695,959,124,99,979,199,110,101,114,109,834,116,679,339,133,,,257,546,364,
9.双指针
0.频率
11,344,3,42,15,141,88,283,16,234,26,76,27,167,18,287,349,28,142,763,19,30,75,86,345,125,457,350
10.排序
0.频率
148,56,147,315,349,179,253,164,242,220,75,280,327,973,324,767,350,296,969,57,1329,274,252,1122,493,1057,1152,1086
11.回溯法
0.频率
22,17,46,10,39,37,79,78,51,93,89,357,131,140,77,306,1240,401,126,47,212,60,216,980,44,52,784,526
12.哈希表
0.频率
1,771,3,136,535,138,85,202,149,49,463,739,76,37,347,336,219,18,217,36,349,560,242,187,204,500,811,609
13.栈
0.频率
42,20,85,155,739,173,1130,316,394,341,150,224,94,84,770,232,71,496,103,144,636,856,907,682,975,503,225,145
14.动态规划
509.斐波那契数
1.动态规划
0.题目
斐波那契数,通常用 F(n) 表示,形成的序列称为 斐波那契数列 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和
1.思路
1.确定dp数组及下标含义
dp[i]的定义为:第i个数的斐波那契数值是dp[i]
2.确定递推公式
状态转移方程 dp[i] = dp[i - 1] + dp[i - 2]
3.dp数组如何初始化
题目中把如何初始化也直接给我们了 dp[0] = 0;dp[1] = 1;
4.确定遍历顺序
dp[i]是依赖 dp[i - 1] 和 dp[i - 2],那么遍历的顺序一定是从前到后遍历的
5.举例推导dp数组
当N为10的时候,dp数组应该是如下的数列:0 1 1 2 3 5 8 13 21 34 55 如果代码写出来,发现结果不对,就把dp数组打印出来看看和我们推导的数列是不是一致的
2.代码
class Solution: def fib(self, n: int) -> int: if n < 2: return n # 滚动数组思想,优化时间复杂度 p, q, r = 0, 0, 1 # 不需要维护整个序列,只维护3个值即可 for i in range(2, n + 1): p, q = q, r r = p + q return r
3.复杂度
时间复杂度:O(n) 空间复杂度:O(1)
2.矩阵快速幂
1.思路
1.方法一的时间复杂度是 O(n)O(n)。使用矩阵快速幂的方法可以降低时间复杂度
2.递推关系
3.只要我们能快速计算矩阵 MM 的n次幂,就可以得到 F(n)F(n) 的值。如果直接求取 M^n,时间复杂度是 O(n)O(n),可以定义矩阵乘法,然后用快速幂算法来加速这里 M^n的求取
2.代码
class Solution: def fib(self, n: int) -> int: if n < 2: return n q = [[1, 1], [1, 0]] res = self.matrix_pow(q, n - 1) return res[0][0] # 矩阵n次幂,用二分法快速求解 def matrix_pow(self, a: List[List[int]], n: int) -> List[List[int]]: ret = [[1, 0], [0, 1]] while n > 0: if n & 1: # n/2余1 ret = self.matrix_multiply(ret, a) n >>= 1 # n/2,二分法求矩阵n次幂 a = self.matrix_multiply(a, a) return ret # 矩阵乘法 def matrix_multiply(self, a: List[List[int]], b: List[List[int]]) -> List[List[int]]: c = [[0, 0], [0, 0]] for i in range(2): for j in range(2): c[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] return c
3.复杂度
时间复杂度:O(logn) 空间复杂度:O(1)
3.通项公式
1.思路
1.斐波那契数 F(n)F(n) 是齐次线性递推,根据递推方程F(n)=F(n−1)+F(n−2),可以写出这样的特征方程:x^2 = x+1
2.
3.代入初始条件F(0)=0,F(1)=1,得
4.因此斐波那契数的通项公式如下:
2.代码
class Solution: def fib(self, n: int) -> int: sqrt5 = 5**0.5 # 根号5 fibN = ((1 + sqrt5) / 2) ** n - ((1 - sqrt5) / 2) ** n return round(fibN / sqrt5)
3.复杂度
代码中使用的pow 函数的时空复杂度与 CPU 支持的指令集相关,这里不深入分析
70.爬楼梯
1.动态规划
0.题目
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。 每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
1.思路
0.引导
爬到第一层楼梯有一种方法,爬到二层楼梯有两种方法。 那么第一层楼梯再跨两步就到第三层 ,第二层楼梯再跨一步就到第三层。 所以到第三层楼梯的状态可以由第二层楼梯 和 到第一层楼梯状态推导出来,那么就可以想到动态规划了
1.确定dp数组及下标含义
dp[i]:爬到第i层楼梯,有dp[i]种方法
2.确定递推公式
从dp[i]的定义可以看出,dp[i] 可以有两个方向推出来。 首先是dp[i - 1],上i-1层楼梯,有dp[i - 1]种方法,那么再一步跳一个台阶不就是dp[i]了么。 还有就是dp[i - 2],上i-2层楼梯,有dp[i - 2]种方法,那么再一步跳两个台阶不就是dp[i]了么。 那么dp[i]就是 dp[i - 1]与dp[i - 2]之和!所以dp[i] = dp[i - 1] + dp[i - 2]
3.dp数组如何初始化
那么i为0,dp[i]应该是多少呢,这个可以有很多解释,但都基本是直接奔着答案去解释的
例如强行安慰自己爬到第0层,也有一种方法,什么都不做也就是一种方法即:dp[0] = 1,相当于直接站在楼顶
我就认为跑到第0层,方法就是0啊,一步只能走一个台阶或者两个台阶,然而楼层是0,直接站楼顶上了,就是不用方法,dp[0]就应该是0
大部分解释说dp[0]应该为1的理由其实是因为dp[0]=1的话在递推的过程中i从2开始遍历本题就能过,然后就往结果上靠去解释dp[0] = 1
题目中说了n是一个正整数,题目根本就没说n有为0的情况。 所以本题其实就不应该讨论dp[0]的初始化
我的原则是:不考虑dp[0]如果初始化,只初始化dp[1] = 1,dp[2] = 2,然后从i = 3开始递推,这样才符合dp[i]的定义
4.确定遍历顺序
从递推公式dp[i] = dp[i - 1] + dp[i - 2];中可以看出,遍历顺序一定是从前向后遍历的
5.举例推导dp数组
当N为10的时候,dp数组应该是如下的数列:0 1 1 2 3 5 8 13 21 34 55
2.代码
class Solution: def climbStairs(self, n: int) -> int: # 滚动数组思想,优化时间复杂度 if n <= 1 : return n dp = [0] * (n+1) # 需要明确使用列表下标时,必须创建列表 dp[1], dp[2] = 1, 2 # 不需要维护整个序列,只维护2个值即可,直接用p,q就不用创建 for i in range(3, n + 1): sum = dp[1] + dp[2] dp[1], dp[2] = dp[2], sum return dp[2]
3.复杂度
时间复杂度:O(n) 空间复杂度:O(1)
2.矩阵快速幂
同509
3.通项公式
同509
主题
经典面试题
15,
算法基础
1.时间复杂度
1.定义
时间复杂度是一个函数,它定性描述该算法的运行时间
2.什么是大O
大O用来表示上界的,当用它作为算法的最坏情况运行时间的上界,就是对任意数据输入的运行时间的上界,面试中说道算法的时间复杂度是多少指的都是一般情况
3.不同数据规模的差异
因为大O就是数据量级突破一个点且数据量级非常大的情况下所表现出的时间复杂度,这个数据量也就是常数项系数已经不起决定性作用的数据量,所以我们说的时间复杂度都是省略常数项系数的,是因为一般情况下都是默认数据规模足够的大,基于这样的事实,给出的算法时间复杂的的一个排行如下所示
O(1)常数阶 < O(logn)对数阶 < O(n)线性阶 < O(n^2)平方阶 < O(n^3)(立方阶) < O(2^n) (指数阶)
4.O(logn)中的log是以什么为底
但我们统一说 logn,也就是忽略底数的描述,以2为底n的对数 = 以2为底10的对数 * 以10为底n的对数,而以2为底10的对数是一个常数,可忽略
题目总结
0.有趣
一通coding猛如虎,提交击败百分五
一串思路笑开花,提交击败百分八
1.没懂
1.树
145.后序遍历莫里斯遍历
105.简洁方法中使用index映射为什么不行
2.面试频率
1.二分查找
4,50,33,167,287,315,349,29,153,240,222,327,69,378,410,162,1111,35,34,300,363,350,209,354,278,374,981,174
2.广度优先
200,279,301,199,101,127,102,407,133,107,103,126,773,994,207,111,847,417,529,130,542,690,,,743,210,913,512
3.哈希表
1,771,3,136,535,138,85,202,149,49,463,739,76,37,347,336,219,18,217,36,349,560,242,187,204,500,811,609
4.回溯法
22,17,46,10,39,37,79,78,51,93,89,357,131,140,77,306,1240,401,126,47,212,60,216,980,44,52,784,526
5.链表
2,21,206,23,237,148,138,141,24,234,445,147,143,92,25,160,328,142,203,19,86,109,83,61,82,430,817,
6.排序
148,56,147,315,349,179,253,164,242,220,75,280,327,973,324,767,350,296,969,57,1329,274,252,1122,493,1057,1152,1086
7.深度优先
200,104,1192,108,301,394,100,105,695,959,124,99,979,199,110,101,114,109,834,116,679,339,133,,,257,546,364,
8.树
104,226,96,617,173,1130,108,297,100,105,95,124,654,669,99,979,199,110,236,101,235,114,94,222,102,938,437
9.数组
1,4,11,42,53,15,121,238,561,85,169,66,88,283,16,56,122,48,31,289,41,128,152,54,26,442,39
10.双指针
11,344,3,42,15,141,88,283,16,234,26,76,27,167,18,287,349,28,142,763,19,30,75,86,345,125,457,350
11.栈
42,20,85,155,739,173,1130,316,394,341,150,224,94,84,770,232,71,496,103,144,636,856,907,682,975,503,225,145
12字符串
5,20,937,3,273,22,1249,68,49,415,76,10,17,91,6,609,93,227,680,767,12,8,67,126,13,336,
子主题
相似题目
1.两数之和
1,15,
代码相关
1.代码风格
1.核心准则
1.缩进
首选使用4个空格。 目前几乎所有的IDE都是默认tab转为4个空格,没有大问题
2.行的最大长度
79个字符,换行用反斜杠会更好看些
曾经笔者认为这是个“过时”的建议,目前做开发基本都是大屏幕,写代码全屏的时候编辑器足以容纳120字符一行或者更长。但是如果要在web上比较两次提交的代码差异,显然是会导致代码换行,或者如果左右滑动,增加了比较的难度,在多年实践之后(2016-2020),所以目前还是使用建议的最大行长度
3.导入
1.格式
导入位于文件顶部,在文件注释之后,导入通常是单独一行 import 或 from ... import
2.顺序
1.标准库导入 2.相关的第三方导入 3.特定的本地应用/库导入 在每个导入组之间放一行空行。 推荐绝对导入,因为它们更易读;处理复杂包布局时明确的相对导入可以用来替代绝对导入,因绝对导入过于冗长
3.注意
1.根据实践经验,建议移除所有不必要的imports
2.导入这部分,通过Python库isort可以完美解决(vscode默认使用isort)
3.当from .. import ...超过行长度限制时,重新起一行:--sl/--force-single-line-imports
4.强制通过包名排序:--fss/--force-sort-within-sections
在vscode中配置为
{ "editor.codeActionsOnSave": { "source.organizeImports": true }, "python.sortImports.args": [ "--force-sort-within-sections", "--force-single-line-imports" ], }
4.注释
切忌注释和代码不一致!!,这比没有注释更让人抓狂。 主要遵守以下要点: 1.修改代码时,优先修改注释; 2.注释应该是完整的句子。所以第一个单词首字母必须大写,除非是第一个单词是小写字母开头的标识符; 3.短注释可以不加句号结尾,完整句子的注释必须要句号结尾; 4.注释每行以一个#加一个空格开头; 5.块注释需要使用相同级别的缩进; 6.行内注释则必须用至少两个空格和代码隔开; 7.尽量让你的代码“会说话”,不写不必要的注释
5.文档字符串docstrings
为所有公共模块,函数,类和方法书写文档字符串
对于docstrings,CLion有很好的支持,vscode可以通过插件实现Python Docstring Generator
插件管理器搜索Python Docstring Generator安装即可 使用方法很简单,在函数名后换行快捷键Ctrl+Shift+2即可,或者输入"""敲换行时也会自动添加 该插件默认使用的风格是Google,通过对比一些开源算法库的文档,使用Google风格的比如TensorFlow,PyTorch,其文档的可读性并不如使用numpy风格的NumPy和SciPy,因此建议使用numpy风格。 将vscode的设置修改如下即可: { "autoDocstring.docstringFormat": "numpy", } 具体numpy风格何如,参见官方文档numpydoc docstring guide即可
6.命名规范
函数、变量和属性用小写字母拼写,各单词之间用下划线相连,例如lowercase_underscore。 类与异常应该以每个单词首字母大写的形式命名(大驼峰法),例如CapitalizedWord。 受保护的实例属性,应该以单个下划线开头。 私有的实例属性,应该以两个下划线开头。 模块级别的常量,应该全部采用大写字母来拼写,各单词之间用下划线相连。 类中的实例方法(instance method),应该把首个参数命名为self,表示该对象本身。 类方法(cls method)的首个参数,应该命名为cls,表示该类本身
2.注重细节
0.工具
通过使用Python代码格式化工具yapf可以自动解决部分细节的格式问题,结合isort可以实现完全通过脚本完成Python代码的格式化或者格式检查。 或者配置IDE在编辑保存时自动执行代码格式化,这都是良好的实践 可以安装PEP8这个库,然后pycharm中设置一下,这样IDE就可以帮你把代码整理成PEP8的风格
1.代码换行
换行应该在二元操作符前面
income = (gross_wages + taxable_interest + (dividends - qualified_dividends) - ira_deduction - student_loan_interest)
2.空行
顶级函数和类的定义之间有两行空行。 类内部的函数定义之间有一行空行
3.字符串引号
双引号和单引号是一致的,但是最好遵循一种风格,笔者习惯于使用单引号,因为: 写代码的时候不需要按住Shift键,提高效率 有的语言字符串必须要使用双引号,Python处理时不需要再加反斜杠转义
4.空格
使用space来表示缩进,而不要使用tab键。 和语法相关的每一层缩进都使用四个空格。 对于占据多行的长表达式来说,除了首行之外的其余各行都应该在通常的缩进级别之上再加4个空格。 在使用下标来获取列表元素、调用函数或给关键字参数赋值时,不要在两旁添加空格。 为变量赋值时,赋值符号的左侧和右侧应该各自写上一个空格,而且只写一个
紧靠小括号、中括号或大括号内部 紧挨着逗号、分号或冒号之前,之后要空一格 紧挨着函数参数列表的左括号之前 紧挨着索引或切片操作的左括号之前 始终在以下二元操作符两侧各放1个空格:赋值(=)、增量赋值(+=,-=等)、比较(==、<、>、!=、<>、<=、>=、in、not、in、is、is not)、布尔(and、or、not) 在数学运算符两侧放置空格 在用于指定关键字参数或默认参数值时,请勿在=两边使用空格return magic(r=real, i=imag)
添加必要的空格,但是避免多余空格。 始终避免行末空白,包括一切不可见字符。 因此,如果IDE支持显示所有不可见字符, 那么请开启它! 同时,如果IDE支持删除行末空白内容,那么也请开启它! 这一部分yapf可以帮助我们解决,只需要写完代码格式化一下即可
5.复合语句
不建议一行包含多条语句
6.尾部逗号
当列表元素、参数、导入项未来可能不断增加时,留一个尾部逗号是一个很好的选择。 通常的用法是每个元素独占一行,尾部均有逗号,在最后一个元素的下一行写闭标签。 如果是所有元素在同一行,就没有必要这么做了
# Correct: FILES = [ 'setup.cfg', 'tox.ini', ] initialize(FILES, error=True, ) # Wrong: FILES = ['setup.cfg', 'tox.ini',] initialize(FILES, error=True,)
7.修正linter检测到的问题
使用flake8对Python代码进行检查,除非有充足理由,否则修改所有检查到的Error和Warning
2.常用代码
1.词典
1.统计出现次数
counts[word] = counts.get(word,0) + 1
2.列表
1.列表指定关键字排序
items.sort(key=lambda x:x[1], reverse=True)#对第二个元素进行倒序排序
2.将字符串列表中每个元素转化为数字列表
list(map(eval,list))
3.将列表中所有元素反转
res[::-1]
4.列表中两数交换
res[i],res[j] = res[j],res[i] #不需要中间变量
5.分配长度固定的列表
G = [0]*(n+1) #长度为n+1的列表
6.求arr列表中区间[i,j]中最大元素
max(arr[i:j + 1])
7.用该元素分割中序列表
left_l = inorder[:idx] right_l = inorder[idx+1:]
3.循环/判断
1.只需要确定循环次数,不需要获取值时
for _ in range(len(queue)):
2.if...else的简略写法
node.left = recur_func(left_l) if left_l else None
3.逆序循环
for i in range(len(postorder)-2, -1,-1)
4.同时获得元素和下标
for i, v in enumerate(nums):
5.同时遍历多个列表返回多个值
for net, opt, l_his in zip(nets, optimizers, losses_ his):
不用zip,每次只会输出一个值
6.遍历多个列表返回一个值
for label in ax.get_xticklabels() + ax.get_yticklabels():
4.映射
1.将可遍历结构快速转化为对应下标的映射
index = {element: i for i, element in enumerate(inorder)}
利用list.index(x)也可以实现,但每次都要遍历列表,时间为O(n),上面为O(1)
3.常用方法
1.系统自带
0.分类
1.类型转换
1.int(x)
1.浮点类型转为整数类型时,小数部分直接舍去
2.float(x)
3.str(x)
1.sorted(num)
1.对指定元素进行排序
2.map(func,list)
1.将第一个参数的功能作用于第二个参数的每一个元素
map(eval,list)
3.len(i)
1.获得长度,可以是任意的类型
4.enumerate()
1.将一个可遍历的数据对象(如列表、元组或字符串)组合为一个索引序列,同时列出数据下标和数据,一般用在 for 循环当中: for i, element in enumerate(seq):
2.enumerate(sequence, [start=0]),小标从0开始,返回 enumerate(枚举) 对象
5.type(x)
1.对变量x的类型进行判断,适用于任何数据类型
2.如果需要在条件判断中使用变量类型作为条件,可以使用type()函数进行直接比较
if type(n) == type(123):
2.列表[]
1.当堆栈使用
1.入栈
stack.append()
2.出栈
stack.pop()
弹出最后一个元素
3.返回栈顶元素
list中无peek方法,只能先pop,再append
2.当队列使用
1.入队
queue.append()
2.出队
queue.pop(0)
出队第一个元素
3.获取元素下标
m_i = nums.index(max_v)
3.队列deque
1.双端队列当队列使用
1.入队
queue.append()
2.出队
queue.popleft()
4.常见错误/不同
1.问题格式
1.IndentationError:expected an indented block
缩进出现问题,最常见的错误就是混用Tab和Space键实现代码缩进
上面的报错还有一个原因经常遇到,就是无首行缩进,例如在编写if语句时在后面加冒号,如果直接换行, 好多代码编辑器会自动首行缩进。但是有些代码编辑器可能没有这个功能,这时需要大家手动缩进,这最好养成习惯。 请大家不要连续敲几次空格键,建议直接按一下Tab键就行了
1.pycharm问题
1.pycharm 如何跳出服务器证书不受信任的提示
点击File > Settings > Tools > Server Certificates > Accept non-trusted certificates automatically
1.pycharm技巧
1.将Python文件导入到当前的项目中
直接在文件管理器中复制相应的文件到当前项目相对应的目录中,pycharm中会直接出现, 若版本问题没出现,自己折叠一下当前项目目录,再重新展开
2.命令行安装错误
0.看错误类型,一定要看最后分割线的上一行的错误
其他错误
1.“no module named XX"
随着大家开发水平的提高和程序复杂性的提升,将会在程序中用到越来越多的模块和第三方库,这个错误的原因是没有安装库“XX”, pip install ww
1.error
1.Error:Command errored out with exit status 1
下载对应的第三方安装包,进入到下载目录进行安装下载文件的全名
2.error: Microsoft Visual C++ 14.0 is required
安装Microsoft Visual C++ 14.0,博客有下载地址visualcppbuildtools_full
3.error: invalid command 'bdist_wheel'
pip3 install wheel
2.AttributeError 属性错误
0.通用解决方法
提示哪个模块的文件出现错误,就找到这个模块删除了,用朋友的能运行的相同文替换
1.AttributeError: module 'xxx' has no attribute 'xxx'
1.文件名与python自带关键字之类冲突
2.两个py文件相互import 导致,删除其中一个
2.AttributeError: module 'six' has no attribute 'main'
pip版本问题 10.0没有main(), 需要降版本:python -m pip install –upgrade pip==9.0.1
Pip v10 后的一些 API 有变化,导致新旧版本不兼容从而影响了我们对包的安装与更新 更新 IDE 就可以了!更新的时候,IDE 也很友好的给了我们提示。 PyCharm -> Help -> Check for Updates
破解版不要更新,否则激活信息失效
3.AttributeError: module 'async io' has no attribute 'run'
你命名了一个asyncio的py文件 如果检查不是第一种 就要检查一下你的 python 版本 因为python3.7 及以后才支持run方法 1 升级python版本 2 run 改写成下面的方式 loop = asyncio.get_event_loop() result = loop.run_until_complete(coro)
4.AttributeError: module 'asyncio.constants' has no attribute '_SendfileMode'
替换asyncio中的constants文件
3.TypeError
1.“TypeError: 'tuple' object cannot be interpreted as an integer"
t=('a','b','c') for i in range(t):
典型的类型错误问题,在上述代码中,range()函数期望的传入参数是整型(integer),但是传入的参为元组(tuple) ,解决方法是将入参元组t改为元组个数 整型len(t)类型即可,例如将上述代码中的range(t)改为 range(len(t))
2.“TypeError: 'str' object does not support item assignment”
由于尝试修改string的值引起的,string 是一种不可变的数据类型
s[3] = 'a' 改为 s = s[:3] + 'a' + s[4:]
3.“TypeError: Can't convert 'int' object to str implicitly”
由于尝试连接非字符串值与字符串引起的,只需要将非字符串强制转换为字符串即可str()
4.“TypeError: unhashable type: 'list'
利用set函数构造集合时,给的参数列表中的元素不能含有列表作为参数
5.TypeError: cannot use a string pattern on a bytes-like object
python2和python3之间切换,难免会碰到一些问题,python3中Unicode字符串是默认格式(就是str类型),ASCII编码的字符串(就是bytes类型,bytes类型是包含字节值,其实不算是字符串,python3还有bytearray字节数组类型)要在前面加操作符b或B;python2中则是相反的,ASCII编码字符串是默认,Unicode字符串要在前面加操作符u或U
import chardet #需要导入这个模块,检测编码格式 encode_type = chardet.detect(html) html = html.decode(encode_type['encoding']) #进行相应解码,赋给原标识符(变量)
4.IOError
1.“IOError: File not open for writing”
>>> f=open ("hello. py") >>> f.write ("test")
出错原因是在没有在open("hello.py")的传入参数中添加读写模式参数mode,这说明默认打开文件的方式为只读方式
解决方法是更改模式mode,修改为写入模式权限w+, f = open("hello. py", "w+")
5.SyntaxError 语法错误
1.SyntaxError:invalid syntax”
忘记在if、elif、else、for、while、 class和def等语句末尾添加冒号引起的
错误的使用了“=”而不是“==”,在Python程序中,“=”是赋值操作符,而“==”是等于比较操作
6.UnicodeDecodeError 编码解读错误
1.'gbk' codec can't decode byte
大部分情况是因为文件不是 UTF8 编码的(例如,可能是 GBK 编码的),而系统默认采用 UTF8 解码。解决方法是改为对应的解码方式: with open('acl-metadata.txt','rb') as data:
open(path, ‘-模式-‘,encoding=’UTF-8’) 即open(路径+文件名, 读写模式, 编码)
常用读写模式
在python对文件进行读写操作的时候,常常涉及到“读写模式”,整理了一下常见的几种模式,如下: 读写模式:r :只读 r+ : 读写 w : 新建(会对原有文件进行覆盖) a : 追加 b : 二进制文件 常用的模式有:“a” 以“追加”模式打开, (从 EOF 开始, 必要时创建新文件) “a+” 以”读写”模式打开 “ab” 以”二进制 追加”模式打开 “ab+” 以”二进制 读写”模式打开 “w” 以”写”的方式打开 “w+” 以“读写”模式打开 “wb” 以“二进制 写”模式打开 “wb+” 以“二进制 读写”模式打开 “r+” 以”读写”模式打开 “rb” 以”二进制 读”模式打开 “rb+” 以”二进制 读写”模式打开 rU 或 Ua 以”读”方式打开, 同时提供通用换行符支持 (PEP 278)
7.ValueError
1.too many values to unpack
调用函数的时候,接受返回值的变量个数不够
8.OSError
1.WinError 1455] 页面文件太小,无法完成操作
1.重启pycharm(基本没啥用)
2.把num_works设置为0 (可能也没啥用)
3.调大页面文件的大小 (彻底解决问题)
9.ImportError
1.DLL load failed: 页面文件太小,无法完成操作
1.不止在运行一个项目,另一个项目的python程序也在运行,关掉就可以了
2.windows操作系统不支持python的多进程操作。而神经网络用到多进程的地方在数据集加载上,所以将DataLoader中的参数num_workers设置为0即可
1.DLL load failed:操作系统无法运行%1
0.最近在运行scrapy项目时,本来安装好好的scrapy框架突然报错,猝不及防
1.因为Anaconda安装scrapy并不困难,花力气去找解决方案不如重装来的简单、高效
2.我只用conda remove scrapy命令不能完全卸载掉,原因可能是安装scrapy时分别用pip和conda安装了两次。读者可以pip 和 conda 卸载命令都尝试一下
pip uninstall scrapy conda remove scrapy
重新安装 pip install scrapy
类错误
1.类的多继承的属性问题
class A (object): x = 1 class B (A): pass class C (A): pass print (A.x, B.x, C.x) # 1 1 1 B.x = 2 print (A.x, B.x, C.x) # 1 2 1 A.x = 3 print (A.x, B.x, C.x) # 3 2 3
我们只修改了 A.x,为什么C.x也被改了?在Python 程序中,类变量在内部当做字典来处理,其遵循常被引用的方法解析顺序(MRO)。所以在上面的代码中,由于class C中的x属性没有找到,它会向上找它的基类(尽管Python 支持多重继承,但上面的例子中只有A)。换句话说,class C中没有它自己的x属性,其独立于A。因此,C.x事实上 是A.x的引用
作用域错误
1.全局变量和局部变量
1.局部变量x没有初始值,外部变量X不能引入到内部
x = 10 def foo (): x += 1 print(x) foo () Traceback (most recent call last): File "D:/程序代码/Python/QRcode_Make/test.py", line 5, in <module> foo () File "D:/程序代码/Python/QRcode_Make/test.py", line 3, in foo x += 1 UnboundLocalError: local variable 'x' referenced before assignment
2.列表的不同操作
lst = [1,2,3] #给列表lst赋值 lst. append (4) #丄t后边append—*个元素4 print(lst) # [1, 2, 3, 4] lst += [5] #两个列表合并 print(lst) # [1, 2, 3, 4, 5] def fool(): lst.append(6) #函数会査找外部的:1st列表 fool () print(lst) # [1, 2, 3, 4, 5, 6] def foo2(): lst += [6] #合并列表时,不会査找外部列表,让人有 些不可思议吧 foo2 () Traceback (most recent call last): File "D:/程序代码/Python/QRcode_Make/test.py", line 26, in <module> foo2 () File "D:/程序代码/Python/QRcode_Make/test.py", line 24, in foo2 lst += [6] #合并列表时,不会査找外部列表,让人有 些不可思议吧 UnboundLocalError: local variable 'lst' referenced before assignment
fool没有对lst进行赋值操作,而fool2做了。 要知道,lst += [5]是lst = lst + [5]的缩写,我们试图对lst 进行赋值操作(Python把他当成了局部变量)。此外,我们对lst进行的赋值操作是基于lst自身(这再一次被Python 当成了局部变量),但此时还未定义,因此出错!所以在这里就需要格外区分局部变量和外部变量的使用过程了
2.1Python2升级Python3错误
1.print 变成了 print()
1.Python2版本中,print是作为一个语句使用的,在 Python3版本中print作为一个函数出现,在Python 3版本中,所有的print内容必须用小括号括起来
2.raw_Input 变成了 input
在Python 2版本中,输入功能是通过raw_input实现的。而在Python 3版本中,是通过input实现的
3.整数及除法的问题
1.“TypeError: 'float* object cannot be interpreted as an integer”
2.在Python 3中,int和long统一为int类型,int 表示任何精度的整数,long类型在Python 3中已经消失,并且后缀L也已经弃用,当使用int超过本地整数大小时,不会再导致OverflowError 异常
3.在以前的Python 2版本中,如果参数是int或者是long的话,就会返回相除后结果的向下取整(floor),而如果参数是float或者是complex的话,那么就会返回相除后结果的一个恰当的近似
4.Python 3中的“/”总是返回一个浮点数,永远表示向下除法,只需将“/”修改为 “//” 即可得到整除的结果
4.异常处理大升级
1.在Python 2程序中,捕获异常的格式如下:except Exception, identifier
except ValueError, e: # Python 2处理单个异常 except (ValueError, TypeError), e: # Python 2处理多个异常
2.在Python 3程序中,捕获异常的格式如下:except Exception as identifier
except ValueError as e: # Python3处理单个异常 except (ValueError, TypeError) as e: # Python3处理多个异常
3.在Python 2程序中,抛出异常的格式如下:raise Exception, args
4.在Python 3程序中,抛出异常的格式如下:raise Exception(args)
raise ValueError, e # Python 2 .x 的方法 raise ValueError(e) # Python 3.x 的方法
5.xrange()变为range()
“NameError: name 'xrange' is not definedw”
6.不能直接使用reload
“name 'reload' is not defined 和 AttributeError: module 'sys' has no att”
import importlib importlib.reload(sys)
7.没有了 Unicode类型
”python unicode is not defined”
在Python 3中已经没有了 Unicode类型,被全新的str类型所代替。而Python 2中原有的str类型,在Python 3中被bytes所代替
8.已经舍弃了 has_key
“AttributeError: 'diet' object has no attribute 'has_key' ”
在Python 3中已经舍弃了 has_key,修改方法 是用in来代替has_key
9.urllib2 已经被 urllib.request 替代
“lmportError: No module named urllib2”
解决方法是将urllib2修改为urllib.request
10.编码问题
TypeError: cannot use a string pattern on a bytes-like object
python2和python3之间切换,难免会碰到一些问题,python3中Unicode字符串是默认格式(就是str类型),ASCII编码的字符串(就是bytes类型,bytes类型是包含字节值,其实不算是字符串,python3还有bytearray字节数组类型)要在前面加操作符b或B;python2中则是相反的,ASCII编码字符串是默认,Unicode字符串要在前面加操作符u或U
import chardet #需要导入这个模块,检测编码格式 encode_type = chardet.detect(html) html = html.decode(encode_type['encoding']) #进行相应解码,赋给原标识符(变量)
2.1命令提示行命令
1.操作目录
1.cd 改变当前的子目录,可直接复制路径,一次性进入
2.CD命令不能改变当前所在的盘,CD..退回到上一级目录,CD\表示返回到当前盘的目录下,CD无参数时显示当前目录名
3.d: 改变所在盘
2.Python相关
1.pip
0.获取帮助
pip help
0.更换pip源
1.临时使用
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple 包名
2.设为默认
pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple
设为默认后,以后安装库都是从清华源下载,而且无需再加镜像源网址
3.主流镜像源地址
清华:https://pypi.tuna.tsinghua.edu.cn/simple 阿里云:http://mirrors.aliyun.com/pypi/simple/ 中国科技大学 https://pypi.mirrors.ustc.edu.cn/simple/ 华中理工大学:http://pypi.hustunique.com/ 山东理工大学:http://pypi.sdutlinux.org/ 豆瓣:http://pypi.douban.com/simple/
1.安装指定库
pip install 包名
2.安装指定库的指定版本
pip install package_name==1.1.2
3.查看安装了哪些库
pip list
4.查看库的具体信息
pip show -f 包名
5.pip安装在哪里
pip -V
6.批量安装库
pip install -r d:\\requirements.txt 文件中直接写 包名==版本号
7.使用wheel文件安装库
1.在下面网站里找相应库的.whl文件,Ctrl+F搜索,注意对应的版本 https://www.lfd.uci.edu/~gohlke/pythonlibs/
2.在.whl所在文件夹内,按Shift键+鼠标右键,打开CMD窗口或者PowerShell (或者通过命令行进入到此文件夹)
3.输入命令: pip install matplotlib‑3.4.1‑cp39‑cp39‑win_amd64.whl
8.卸载库
pip uninstall package_name
9.升级库
pip install --upgrade package_name
10.将库列表保存到指定文件中
pip freeze > 文件名.txt
11.查看需要升级的库
pip list -o
12.检查兼容问题
验证已安装的库是否有兼容依赖问题 pip check package-name
13.下载库到本地
将库下载到本地指定文件,保存为whl格式 pip download package_name -d "要保存的文件路径"
2.打包程序为可执行文件
pyinstaller -F 文件名.py
3.代码书写
1.没有的格式
1.没有i++, 只能写成i+=1
2.不同的格式
1.类
1.参数
0.定义类时,第一参数必须是self,所有方法也是一样,调用本类成员时必须用self.成员
1.类中参数,先写变量名,加上冒号,再写类型名,用逗号隔开
2.类定义结束后,可以在后面加 ->类型名,表示返回值的类型
2.调用
1.类中调用自身进行递归必须用 self.自身函数(参数中不用加self)
2.使用类时,不需要新建类,直接用就行了 root = TreeNode(max_num)
3.方法
1._一个下划线开头,定义保护方法
2.__两个下划线开头,定义私有方法
子主题
2.数字
1.正负无穷的表示
float("inf"), float("-inf")
3.符号
1.Python中 / 表示正常除,有余数, // 表示整除,没有余数
2.Python中的 非! 用 not 表示
3.Python中平方为 **
4.语句
1.所有和关键字有关的语句后都要加冒号: return除外
2.循环,判断等类似语句中的条件不需要加(),语句块也没有{},用缩进严格代表格式
5.注释
1.单行注释 #
2.多行注释''' 或者 """
4.代码
1.列表
1.需要明确使用列表下标时,创建列表必须用G = [0]*(n+1) #长度为n+1的列表,否则会下标越界
2.创建定长的二维列表时,如果*n失效,试试用循环
dp = [[float('inf') for _ in range(n)] for _ in range(n)]
3.如果函数返回值是一个列表,但只有一个变量接收时,加逗号
line, = ax.plot(x, np.sin(x))
5.Python编程
1.文件问题
1.导入别的项目需要用到文件时,使用文件的绝对路径
2.命令行执行文件
Python 文件名.py
3.第三方包镜像资源
在用pycharm下载第三方包时,在Manage Repositories中添加 http://mirrors.aliyun.com/pypi/simple/ ,删除原来网址
6.Pycharm快捷键
1.注释(添加/消除)
Ctrl + /
单行注释#
2.代码右移
Tab
3.代码左移
Shift + Tab
4.自动缩进
Ctrl + alt + I
5.运行
Ctrl + shift + F10
6.PEP8规范格式化
Ctrl + alt + L
也是QQ锁定快捷键,在QQ中取消设置
6.1快速修正
alt + enter 再回车
7.复制一行/多行/选中部分
Ctrl + D
8.删除一行/多行
Ctrl + Y
9.查找
Ctrl + F
9.1全局查找
Ctrl + shift + F
10.替换
Ctrl + R
10.1全局替换
Ctrl + shift + R
11.光标移动到下一行
shift + Enter
12.多行光标点击
alt + 鼠标左键点击
13.跳向下一个断点
alt + F9
14.撤销
Ctrl + Z
14.1反撤销
Ctrl + shift + Z
15.复写父类代码
Ctrl + o
16.选中单词/代码块
Ctrl + W
17.快速查看文档(代码信息)
Ctrl + Q
18.任意位置向下插入一行
shift + enter
19.任意位置向上插入一行
Ctrl + alt + enter
20.查看项目视图
alt + 1
21.查看结构视图
alt + 7
22.快速进入代码
Ctrl + 左键
23.快速查看历史
alt + 左(返回)/右键(前进)
24.快速查看不同方法
alt + 上/下
25.切换视图
Ctrl + Tab
26.查看资源文件
两次shift
27.查看方法在哪里被调用
Ctrl + alt + H 双击可确定位置
28.查看父类
Ctrl + U
29.查看继承关系
Ctrl + H
7.pycharm页面
0.菜单栏
1.View 窗口
1.Navigation Bar 导航栏
2.Refactor 重构
3.Tools 工具
子主题
4.VCS 版本控制
1.调试代码
1.单击代码前面插入断点,点击爬虫进行调试,点击爬虫旁边的方框结束调试
2.点击↘图标,跳向下一个断点,可以不停地观察变量的值
2.settings 设置
1.appearance & Behavior 界面和行为
1.appearance 整体风格
1.Theme 主题
1.Darcula 黑色主题
2.High contrast 高对比主题
3.Intellij 明亮主题
2.custom font 自定义字体
2.system settings系统设置
1.update
可关闭自动检测更新
2.keymap 快捷键
1.根据系统查看,可直接搜索(comment注释)
3.editor 仅编辑区域
1.Font 代码字体(可调大小)
2.Color Scheme 代码区域配色方案
3.Code Style 代码风格
0.Python 可对其中每个细节进行更改
1.Python
1.Indent 缩进数
2.Space 空格设置
3.Wrapping and Braces 封装和括号
1.Hard wrap at 一行最大代码数
4.Blank lines 空行
4.Inspections 检查
1.PEP 8 是一种代码规范,并不是语法错误,尽量保持规范
2.可以设置对哪些内容进行检查,也可以在Severity设置检查的严格程度如何
5.File and Code Templates 文件和代码模板
1.在Python.Script中添加每次创建新文件都会显示的信息, 可上网查找哪些可添加信息
# Author: ${USER} # CreatTime: ${DATE}${TIME} # FileName: ${NAME} # Tools: ${PRODUCT_NAME} # Description:
6.File Encodings 文件编码
1.默认UTF-8
7.Live Templates 动态模板(好用掌握)
0.可点加号自己添加模板,一定要设定使用位置
1.for循环
写iter选择循环,不断回车进行代码编写
2.用for循环写列表
写compl即可
4.Plugins 插件
5.Project
1.Project Interpret 项目解释器
1.可以管理和添加第三方库(点击加号,搜索)
2.可以为当前项目设置不同的解释器
3.Project 项目管理
1.右击文件选 Show in Explorer 可直接打开文件位置
2.New 新建
1.File
可以是其他各种文件,不一定是Python文件
2.New Scratch File
创建临时文件,相当于草稿纸,用于测试部分代码
3.Directory 目录
创建新的下层文件夹
4.Python File
和普通文件夹相比多了空的初始化Python文件
5.新建一个HTML文件时,可以右击用浏览器打开,能够看到显示效果
4.代码结果页面
1.Terminal 终端
1.和系统的CMD是一样的,可以直接在这里安装包,使用dos命令
2.Python Console 控制台
可以直接编写Python代码,交互式运行,可一行一行编辑
3.TODO
相当于备忘录,在代码处写上TODO(''),可快速地找到此处,继续工作,可用于相互配合
4.最右边头像
可调整代码检查的严格程度,省电模式相当于箭头放到最左边,所有语法错误都不会检查
5.虚拟环境
1.
2.虚拟环境的创建是因为在实际开发中需要同期用到不同版本的python解释器和同一个库的不同 版本。因此需要创建虚拟环境将项目环境与其他环境(系统环境、其他虚拟环境)隔离
3.PyCharm中虚拟环境的创建有三种方式,virtualen、conda和pipen
4.virtualen可以想象成是将当前系统环境创建一个隔离副本,使用的解释器和你安装的是同一个 (复印件)
5.conda是根据你的需要,选择特定的python版本,然后从网上下载相关版本,并 创建一个与系统环境不一样的新的环境,使用的解释器也和你安装的不是同一个
6.pipen和 virtualen类似,也是在现有系统环境的基础上创建一个副本,但是pipen使用Pipfile替代 virtualen的requirements.txt来进行依赖管理,更加方便
6.相互关系
7.SVN
1.官网下载时,选择英文版1.10,和中文版不一样
2.安装TortoiseSVN.msi时,一定要勾选command line tools
3.在安装的bin目录中会出现一系列svn.exe的文件
4.在pycharm中配置,在setting/version control/subversion找到bin目录下的svn.exe文件
5.在文件夹中右击修改过的文件,可以查看修改的地方
6.右击项目会出现新的subversion快捷方式,可以提交commit,全部撤回revert change
8.好用插件
1.Key Promoter X
教导你,当下你的这个操作,应该使用哪个快捷操作来提高效率? 提醒你,当下你的这个操作,还没有设置快捷键,赶紧设置一个?
2.Regex Tester
可以测试正则表达式,点击 PyCharm 界面左下方的小矩形按钮,就能找到 Regex Tester 选项
3.Auto PEP8
pip install autopep8,然后在 PyCharm(Setting-Tools-External Tools) 导入这个工具,具体设置如下图
Name: AutoPep8 Description: autopep8 your code Program: autopep8 Arguments: --in-place --aggressive --aggressive $FilePath$ Working directory: $ProjectFileDir$ Output filters: $FILE_PATH$\:$LINE$\:$COLUMN$\:.*
4.CodeGlance
代码预览功能的滚动条
5.Profile in PyCharm
点击 Run -> Profile '程序',即可进行代码性能分析 点击 Call Graph(调用关系图)界面直观展示了各函数直接的调用关系、运行时间和时间百分比
表头Name显示被调用的模块或者函数;Call Count显示被调用的次数;Time(ms)显示运行时间和时间百分比,时间单位为毫秒(ms)。 点击表头上的小三角可以升序或降序排列表格。 在Name这一个列中双击某一行可以跳转到对应的代码。 以fun4这一行举例:fun4被调用了一次,运行时间为1000ms,占整个运行时间的16.7% Call Graph: 右上角的4个按钮表示放大、缩小、真实大小、合适大小; 箭头表示调用关系,由调用者指向被调用者; 矩形的左上角显示模块或者函数的名称,右上角显示被调用的次数; 矩形中间显示运行时间和时间百分比; 矩形的颜色表示运行时间或者时间百分比大小的趋势:红色 > 黄绿色 > 绿色,由图可以看出fun3的矩形为黄绿色,fun1为绿色,所有fun3运行时间比fun1长。 从图中可以看出Test.py直接调用了fun3、fun1、fun2和fun5函数;fun5函数直接调用了fun4函数;fun1、fun2、fun3、fun4和fun5都直接调用了print以及sleep函数;整个测试代码运行的总时间为6006ms,其中fun3的运行时间为1999ms,所占的时间百分比为33.3%,也就是 1999ms / 6006ms = 33.3%。
6.Json Parser
经常会把校验一串 JSON 字符串是否合法,在以前我的做法都是打开 https://tool.lu/json/ 这个在线网站,直接美化来校验,只有 JSON 格式都正确无误合法的,才能够美化,在 PyCharm 有一个插件专门来做这个事
7.HighlightBracketPair
括号颜色在编辑器中突出显示
8.Nested Brackets Colorer
显示不同括号对的颜色
9.Inspect Code in PyCharm
点击项目文件夹,然后右键,选择 Inspect Code,就可以开启静态检查
对于编译型的语言,如 Java,需要将代码编译成机器可识别的语言才可运行,在编译过程中,就可以通过分析或检查源程序的语法、结构、过程、接口等来检查程序的正确性,找出代码隐藏的错误和缺陷。这个过程叫做静态代码分析检查。 那对于 Python 这种解释型的语言来说,代码是边运行边翻译的,不需要经过编译这个过程。很多肉眼无法一下子看出的错误,通常都是跑一下(反正跑一下这么方便)才能发现。 由于Python 运行是如此的方便,以至于我们都不太需要关注静态分析工具。 但也不是说,静态分析工具完全没有用武之地,我认为还是有。 如果你的编码能力还没有很成熟,代码中可以有许许多多的隐藏bug,由于 Python 是运行到的时候才解释,导致一次运行只能发现一个错误,要发现100个bug,要运行100次,数字有点夸大,其实就是想说,如果这么多的错误都能通过一次静态检查发现就立马修改,开发调试的效率就可以有所提升。当然啦,并不是说所有的错误静态分析都能提前发现,这点希望你不要误解。 做为 Python 最强 IDE,PyCharm本身内置了这个功能,不需要你安装任何插件。
5.常用代码段
1.输入
1.获取用户不定长度的输入
def getNum(): #获取用户不定长度的输入 nums = [] iNumStr = input("请输入数字(回车退出): ") while iNumStr != "": nums.append(eval(iNumStr)) iNumStr = input("请输入数字(回车退出): ") return nums
2.文本
1.英文文本去噪及归一化
def getText(): txt = open("hamlet.txt", "r").read() txt = txt.lower() #全部转化为小写字母 for ch in '!"#$%&()*+,-./:;<=>?@[\\]^_‘{|}~': txt = txt.replace(ch, " ") #将文本中特殊字符替换为空格 return txt
2.统计英文文本单词频率
hamletTxt = getText() words = hamletTxt.split() #以空格分隔文本,转为为列表 counts = {} #新建一个字典 for word in words: #统计每个单词出现的频率,默认值为0 counts[word] = counts.get(word,0) + 1 items = list(counts.items()) #将字典转化为列表 items.sort(key=lambda x:x[1], reverse=True)#对第二个元素进行倒序排序 for i in range(20): word, count = items[i] print ("{0:<10}{1:>5}".format(word, count))
3.统计中文文本词频
import jieba txt = open("threekingdoms.txt", "r", encoding='utf-8').read() words = jieba.lcut(txt) counts = {} for word in words: if len(word) == 1: continue else: counts[word] = counts.get(word,0) + 1 items = list(counts.items()) #转化为列表才能排序 items.sort(key=lambda x:x[1], reverse=True) for i in range(15): word, count = items[i] print ("{0:<10}{1:>5}".format(word, count))
3.数组
1.求数组中最大值
1.元素有重复
max_v, m_i = float(-inf), 0 #将一个可遍历的数据对象(如列表、元组或字符串)组合为一个索引序列, 同时列出数据下标和数据 for i, v in enumerate(nums): if v > max_v: max_v = v m_i = i
2.无重复元素
max_v = max(nums) m_i = nums.index(max_v)
2.二分法
class Solution: def searchInsert(self, nums: List[int], target: int) -> int: left, right = 0, len(nums) #采用左闭右开区间[left,right) while left < right: # 右开所以不能有=,区间不存在 mid = left + (right - left)//2 # 防止溢出, //表示整除 if nums[mid] < target: # 中点小于目标值,在右侧,可以得到相等位置 left = mid + 1 # 左闭,所以要+1 else: right = mid # 右开,真正右端点为mid-1 return left # 此算法结束时保证left = right,返回谁都一样
4.Matplotlib
1.移动坐标到(0,0)
ax = plt.gca() ax.spines['right'].set_color('none') ax.spines['top'].set_color('none') ax.xaxis.set_ticks_position('bottom') ax.spines['bottom'].set_position(('data', 0)) ax.yaxis.set_ticks_position('left') ax.spines['left'].set_position(('data', 0))
数学
1.计算平均值
def mean(numbers): #计算平均值 s = 0.0 for num in numbers: s = s + num return s / len(numbers)
2.计算方差
def dev(numbers, mean): #计算方差 sdev = 0.0 for num in numbers: sdev = sdev + (num - mean)**2 return pow(sdev / (len(numbers)-1), 0.5)
3.计算中位数
def median(numbers): #计算中位数 sorted(numbers) size = len(numbers) if size % 2 == 0: med = (numbers[size//2-1] + numbers[size//2])/2 else: med = numbers[size//2] return med
6.代码在本地运行
其实就是定义个main函数,构造个输入用例,然后定义一个solution变量,调用minCostClimbingStairs函数就可以了
#include <iostream> #include <vector> using namespace std; class Solution { public: int minCostClimbingStairs(vector<int>& cost) { vector<int> dp(cost.size()); dp[0] = cost[0]; dp[1] = cost[1]; for (int i = 2; i < cost.size(); i++) { dp[i] = min(dp[i - 1], dp[i - 2]) + cost[i]; } return min(dp[cost.size() - 1], dp[cost.size() - 2]); } }; int main() { int a[] = {1, 100, 1, 1, 1, 100, 1, 1, 100, 1}; vector<int> cost(a, a + sizeof(a) / sizeof(int)); Solution solution; cout << solution.minCostClimbingStairs(cost) << endl; }
0.代码书写技巧
1.格式
1.想要将几行在一行中书写
在每行的最后加上分号;
2.一行太长想要换行书写
在此行的最后加上右斜杠\即可
算法相关
经典思想
0.常遇问题
1.防止溢出
1.计算很多数乘积时,为防止溢出,可以对乘积取对数,变成相加的形式
1.数据结构
1.数组
0.基础
1.只要看到面试题里给出的数组是有序数组,都可以想一想是否可以使用二分法
2.数组的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖
1.数组中同一个元素不能使用两遍的遍历
for (int i = 0; i < nums.length; i++) { for (int j = i + 1; j < nums.length; j++) {
2.环形数组问题
有首尾不能同时的约束时,将环形数组分解为若干个普通数组问题,求最大值
3.二分法的区间问题
1.左闭右闭[left, right]
class Solution { public: int searchInsert(vector<int>& nums, int target) { int n = nums.size(); int left = 0; int right = n - 1; // 定义target在左闭右闭的区间里,[left, right] while (left <= right) { // 当left==right,区间[left, right]依然有效 int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2 if (nums[middle] > target) { right = middle - 1; // target 在左区间,所以[left, middle - 1] } else if (nums[middle] < target) { left = middle + 1; // target 在右区间,所以[middle + 1, right] } else { // nums[middle] == target return middle; } } // 分别处理如下四种情况 // 目标值在数组所有元素之前 [0, -1] // 目标值等于数组中某一个元素 return middle; // 目标值插入数组中的位置 [left, right],return right + 1 // 目标值在数组所有元素之后的情况 [left, right], return right + 1 return right + 1; } };
int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2
while (left <= right) { // 当left==right,区间[left, right]依然有效
if (nums[middle] > target) { right = middle - 1; // target 在左区间,所以[left, middle - 1]
} else if (nums[middle] < target) { left = middle + 1; // target 在右区间,所以[middle + 1, right]
2.左闭右开[left, right)
class Solution { public: int searchInsert(vector<int>& nums, int target) { int n = nums.size(); int left = 0; int right = n; // 定义target在左闭右开的区间里,[left, right) target while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间 int middle = left + ((right - left) >> 1); if (nums[middle] > target) { right = middle; // target 在左区间,在[left, middle)中 } else if (nums[middle] < target) { left = middle + 1; // target 在右区间,在 [middle+1, right)中 } else { // nums[middle] == target return middle; // 数组中找到目标值的情况,直接返回下标 } } // 分别处理如下四种情况 // 目标值在数组所有元素之前 [0,0) // 目标值等于数组中某一个元素 return middle // 目标值插入数组中的位置 [left, right) ,return right 即可 // 目标值在数组所有元素之后的情况 [left, right),return right 即可 return right; } };
while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间
if (nums[middle] > target) { right = middle; // target 在左区间,在[left, middle)中
} else if (nums[middle] < target) { left = middle + 1; // target 在右区间,在 [middle+1, right)中
2.哈希表
0.只要涉及到统计某个数/值的出现次数,就用哈希表
1.哈希表中包含某数的对应数,且不是它本身
for (int i = 0; i < nums.length; i++) { int complement = target - nums[i]; if (map.containsKey(complement) && map.get(complement) != i)
3.链表
1.两个链表中的数相加
1.逆序时: 一定单独考虑最后一次相加可能的进位问题
2.正序时: 将链表逆转/使用堆栈的数据结构实现逆转
2.有序单链表求中位数(左闭右开)
设当前链表的左端点为left,右端点right,包含关系为「左闭右开」,给定的链表为单向链表,访问后继元素十分容易,但无法直接访问前驱元素。因此在找出链表的中位数节点mid之后,如果设定「左闭右开」的关系,就可以直接用(left,mid) 以及(mid.next,right) 来表示左右子树对应的列表了,不需要mid.pre,并且,初始的列表也可以用(head,null)方便地进行表示
4.字符
1.记录每个字符是否出现过
哈希集合: Set<Character> occ = new HashSet<Character>(); occ.contains(a)
5.数字
1.两个个位数相加的进位获取
int sum = carry+x+y; int carry = sum/10;
2.整数反转
要在没有辅助堆栈/数组的帮助下 “弹出” 和 “推入” 数字,我们可以使用数学方法,先取出最后一位,然后除以10,将最后一位去掉,反转数不断将自身乘10后,加上取出的最后一位数,同时先判断是否会溢出
6.树
1.寻找前驱结点
向左走一步,然后一直向右走至无法走为止
predecessor = root.left; while (predecessor.right != null && predecessor.right != root) { predecessor = predecessor.right; }
7.集合
1.去除列表中重复元素
s = set(ls); lt = list(s)
8.元组
1.如果不希望数据被程序所改变,转换成元组类型
lt = tuple(ls)
2.经典算法
1.双指针
0.常用于数组和链表
1.当需要枚举数组中的两个元素时,如果发现随着第一个元素的递增,第二个元素是递减的,那么就可以使用双指针的方法,将第二个指针从数组尾开始遍历,同时保证第二个指针大于第一个指针,将枚举的时间复杂度从 O(N^2)减少至 O(N)
2.查找的结果是某个范围时,利用双指针不断变化,类似于滑动窗口的机制
2.快慢指针法
初始时,快指针fast 和慢指针slow 均指向链表的左端点left。我们将快指针fast 向右移动两次的同时,将慢指针slow 向右移动一次,直到快指针到达边界(即快指针到达右端点或快指针的下一个节点是右端点)。此时,慢指针对应的元素就是中位数
3.动态规划
1.要求的数,可以通过上一个以求的数通过某种操作得到,找到动态转移方程
2.条件
如果某一问题有很多重叠子问题,使用动态规划是最有效的
3.五部曲
1.确定dp数组(dp table)以及下标的含义 2.确定递推公式 3.dp数组如何初始化 4.确定遍历顺序 5.举例推导dp数组
为什么要先确定递推公式,然后在考虑初始化呢? 因为一些情况是递推公式决定了dp数组要如何初始化
4.如何debug
1.把dp数组打印出来,看看究竟是不是按照自己思路推导的
2.写代码之前一定要把状态转移在dp数组的上具体情况模拟一遍,心中有数,确定最后推出的是想要的结果
5.滚动数组
当递推方程只与相邻的几个数有关时,可用滚动数组将空间复杂度优化为O(1)
4.递归
0.递归三部曲
递归终止条件,本次递归做什么,返回什么
3.常用技巧
1.巧用数组下标
1.应用
数组的下标是一个隐含的很有用的数组,特别是在统计一些数字(把相应的数组的值当做新数组的下标temp[arr[i]]++),或者判断一些整型数是否出现过的时候
2.实例
1.给你一串字母,让你判断这些字母出现的次数时,我们就可以把这些字母作为下标,在遍历的时候,如果字母a遍历到,则arr[a]就可以加1了,即 arr[a]++,通过这种巧用下标的方法,我们不需要逐个字母去判断
2.给你n个无序的int整型数组arr,并且这些整数的取值范围都在0-20之间,要你在 O(n) 的时间复杂度中把这 n 个数按照从小到大的顺序打印出来,把对应的数值作为数组下标,如果这个数出现过,则对应的数组加1
public void f(int arr[]) { int[] temp = new int[21]; for (int i = 0; i < arr.length; i++) { temp[arr[i]]++; } //顺序打印 for (int i = 0; i < 21; i++) { for (int j = 0; j < temp[i]; j++) { System.out.println(i); } } }
2.巧用取余
1.应用
在遍历数组的时候,会进行越界判断,如果下标差不多要越界了,我们就把它置为0重新遍历。特别是在一些环形的数组中,例如用数组实现的队列pos = (pos + 1) % N
for (int i = 0; i < N; i++) { //使用数组arr[pos] (我们假设刚开始的时候pos < N) pos = (pos + 1) % N; }
3.巧用双指针
1.应用
对于双指针,在做关于单链表的题是特别有用
2.实例
1.判断单链表是否有环
设置一个慢指针和一个快指针来遍历这个链表。慢指针一次移动一个节点,而快指针一次移动两个节点,如果该链表没有环,则快指针会先遍历完这个表,如果有环,则快指针会在第二次遍历时和慢指针相遇
2.如何一次遍历就找到链表中间位置节点
一样是设置一个快指针和慢指针。慢的一次移动一个节点,而快的两个。在遍历链表的时候,当快指针遍历完成时,慢指针刚好达到中点
3.单链表中倒数第 k 个节点
设置两个指针,其中一个指针先移动k个节点。之后两个指针以相同速度移动。当那个先移动的指针遍历完成的时候,第二个指针正好处于倒数第k个节点
子主题
4.巧用移位运算
1.应用
1.有时候我们在进行除数或乘数运算的时候,例如n / 2,n / 4, n / 8这些运算的时候,我们就可以用移位的方法来运算了,通过移位的运算在执行速度上是会比较快的
n / 2 等价于 n >> 1 n / 4 等价于 n >> 2 n / 8 等价于 n >> 3
2.还有一些 &(与)、|(或)的运算,也可以加快运算的速度
2.实例
1.判断一个数是否是奇数,用与运算的话会快很多
if(n % 2 == 1){ dosomething(); } if(n & 1 == 1){ dosomething(); )
5.设置哨兵位
1.应用
1.在链表的相关问题中,我们经常会设置一个头指针,而且这个头指针是不存任何有效数据的,只是为了操作方便,这个头指针我们就可以称之为哨兵位了
2.在操作数组的时候,也是可以设置一个哨兵的,把arr[0]作为哨兵
2.实例
1.我们要删除头第一个节点是时候,如果没有设置一个哨兵位,那么在操作上,它会与删除第二个节点的操作有所不同。但是我们设置了哨兵,那么删除第一个节点和删除第二个节点那么在操作上就一样了,不用做额外的判断。当然,插入节点的时候也一样
2.要判断两个相邻的元素是否相等时,设置了哨兵就不怕越界等问题了,可以直接arr[i] == arr[i-1]了。不用怕i = 0时出现越界
6.与递归有关的一些优化
1.对于可以递归的问题考虑状态保存
1.当我们使用递归来解决一个问题的时候,容易产生重复去算同一个子问题,这个时候我们要考虑状态保存以防止重复计算
2.实例
0.一只青蛙一次可以跳上1级台阶,也可以跳上2级。求该青蛙跳上一个n级的台阶总共有多少种跳法
1.这个问题用递归很好解决。假设 f(n) 表示n级台阶的总跳数法,则有f(n) = f(n-1) + f(n - 2)
2.递归的结束条件是当0 <= n <= 2时, f(n) = n,容易写出递归的代码
public int f(int n) { if (n <= 2) { return n; } else { return f(n - 1) + f(n - 2); } }
3.不过对于可以使用递归解决的问题,我们一定要考虑是否有很多重复计算。显然对于 f(n) = f(n-1) + f(n-2) 的递归,是有很多重复计算的
4.个时候我们要考虑状态保存。例如用hashMap来进行保存,当然用一个数组也是可以的,这个时候就像我们上面说的巧用数组下标了。可以当arr[n] = 0时,表示n还没计算过,当arr[n] != 0时,表示f(n)已经计算过,这时就可以把计算过的值直接返回回去了
//数组的大小根据具体情况来,由于int数组元素的的默认值是0 //因此我们不用初始化 int[] arr = new int[1000]; public int f(int n) { if (n <= 2) { return n; } else { if (arr[n] != 0) { return arr[n];//已经计算过,直接返回 } else { arr[n] = f(n-1) + f(n-2); return arr[n]; } } }
5.这样,可以极大着提高算法的效率。也有人把这种状态保存称之为备忘录法
2.考虑自底向上
1.对于递归的问题,我们一般都是从上往下递归的,直到递归到最底,再一层一层着把值返回
2.不过,有时候当n比较大的时候,例如当 n = 10000时,那么必须要往下递归10000层直到 n <=2 才将结果慢慢返回,如果n太大的话,可能栈空间会不够用
3.对于这种情况,其实我们是可以考虑自底向上的做法的
例如我知道f(1) = 1; f(2) = 2; 那么我们就可以推出 f(3) = f(2) + f(1) = 3。从而可以推出f(4),f(5)等直到f(n)。因此,我们可以考虑使用自底向上的方法来做。 代码如下: public int f(int n) { if(n <= 2) return n; int f1 = 1; int f2 = 2; int sum = 0; for (int i = 3; i <= n; i++) { sum = f1 + f2; f1 = f2; f2 = sum; } return sum; }
4.也把这种自底向上的做法称之为递推
3.较其他语言优势
1.数组
1.参数为部分数组
在Python中参数可以直接返回部分数组nums[:i],就不用像Java还需要重新设计一个方法来根据下标来截取数组
2.同时获得元素和下标
for i, v in enumerate(nums):
常用数据结构/算法
1.链表
2.栈
1.单调栈
1.定义
栈内元素单调递增或者单调递减的栈,单调栈只能在栈顶操作
2.性质
1.单调栈里的元素具有单调性
2.元素加入栈前,会在栈顶端把破坏栈单调性的元素都删除
3.使用单调栈可以找到元素向左遍历第一个比他小的元素(递增栈)/找到元素向左遍历第一个比他大的元素
3.队列
4.树
1.BST: 二叉搜索树 二叉排序树
1.定义
1.左子树上所有结点的关键字小于根结点
2.右子树上所有结点的关键字大于根结点
3.左右子树又各是一颗二叉排序树
中序遍历可得到递增有序数列
2.AVL: 平衡二叉树
1.定义
1.平衡二叉树: 任一结点的左右子树的高度差的绝对值不超过1
2.平衡因子: 结点左右子树的高度差 -1,0,1
3.mct: 最小生成树
1.定义
任何只由G的边构成,并包含G的所有顶点的树称为G的生成树
5.图
1.术语
1.团集(完全子图)
点的集合:任两点之间均有边相连
1.1 点独立集
点的集合: 任意两点之间都没有边
2.哈密尔顿图Hamilton
无向图,由指定的起点前往指定的终点,途中经过所有其他节点且只经过一次, 闭合的哈密顿路径称作哈密顿回路,含有图中所有顶点的路径称作哈密顿路径
6.算法
1.BFS: 广度优先搜索
1.定义
类似于二叉树的层次遍历算法,优先考虑最早发现的结点
2.DFS: 深度优先搜索
1.定义
类似于树的先序遍历,优先考虑最后发现的结点
常识
1.一秒运算多少次
2.递归算法时间复杂度
递归的次数 * 每次递归中的操作次数
3.递归算法空间复杂度
递归的深度 * 每次递归的空间复杂度
数据结构重点
树
1.BST: 二叉搜索树
1.从根结点开始查找结点其实就是二分查找的过程,所以BST也叫二分查找树
2.中序遍历BST可以得到一个有序序列
3.插入位置一定是叶子位置,不会导致树结构调整
4.Java中删除结点不能用currentNode=null
currentNode是通过方法参数传进来的,因此currentNode=null并不会导致对象被销毁,因为该对象依然被方法外的引用持有,应该通过父结点来销毁
5.面试问BST的意图
通常来说,在工程实践中是不会使用朴素的BST的,因为它存在一个致命的缺点——树结构容易不平衡,考虑一种极端情况,假如构造BST的键值序列是有序的,那逐个插入结点,得到的BST其实是一个链表,而链表的查询操作时间复杂度是达不到o(logN)的,这就违背了BST的设计初衷,即便不出现这种极端情况,BST的结构也会在不断插入和删除结点的过程中渐渐失衡,树结构越来越倾斜,这种结构的不平衡越发不利于查询操作
解决方法: 使用具备自平衡特性的BST,比如AVL和红黑树
子主题
子主题
子主题
2.红黑树RBT
1.定义
①每个结点都有颜色,黑色或者红色 ②根结点是黑色的 ③每个叶子结点(NIL空结点)都是黑色的 ④如果一个结点是红色的,则它的子结点必须是黑色的 ⑤任意一个结点到该结点的每个叶子结点的所有路径上包含相同数目的黑结点
2.图示
3.平衡性质
性质⑤,它确保从任意一个结点出发到其叶子结点的所有路径中,最长路径长度也不会超过最短路径长度的两倍。因而,红黑树是相对是接近平衡的二叉树
而且性质⑤明显指出每个结点的左右子树中黑结点的层数是相等的,因此红黑树的黑结点是完美平衡的
6.插入和删除区别
对于插入结点,插入的位置一定是叶结点,所以调整时,本身这层不会出现问题,所以从父结点和叔叔的关系调整 对于删除结点,调整位置不一定是叶结点,所以调整时,本身这层可能出现问题,所以从此结点和兄弟的关系调整
labuladuo知识点
0.必读系列
1.学习算法和刷题的框架思维
1.数据结构的存储方式
1.只有两种:数组(顺序存储)和链表(链式存储)
2.不是还有散列表、栈、队列、堆、树、图等等各种数据结构,都属于「上层建筑」,而数组和链表才是「结构基础」,那些多样化的数据结构,究其源头,都是在链表或者数组上的特殊操作,API 不同而已
3.各种结构简介
1.「队列」、「栈」这两种数据结构既可以使用链表也可以使用数组实现。用数组实现,就要处理扩容缩容的问题;用链表实现,没有这个问题,但需要更多的内存空间存储节点指针
2.「图」的两种表示方法,邻接表就是链表,邻接矩阵就是二维数组。邻接矩阵判断连通性迅速,并可以进行矩阵运算解决一些问题,但是如果图比较稀疏的话很耗费空间。邻接表比较节省空间,但是很多操作的效率上肯定比不过邻接矩阵
3.「散列表」就是通过散列函数把键映射到一个大数组里。而且对于解决散列冲突的方法,拉链法需要链表特性,操作简单,但需要额外的空间存储指针;线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些
4.「树」,用数组实现就是「堆」,因为「堆」是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单;用链表实现就是很常见的那种「树」,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表「树」结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等,以应对不同的问题
4.优缺点
1.数组由于是紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间。但正因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N);而且你如果想在数组中间进行插入和删除,每次必须搬移后面的所有数据以保持连续,时间复杂度 O(N)
2.链表因为元素不连续,而是靠指针指向下一个元素的位置,所以不存在数组的扩容问题;如果知道某一元素的前驱和后驱,操作指针即可删除该元素或者插入新元素,时间复杂度 O(1)。但是正因为存储空间不连续,你无法根据一个索引算出对应元素的地址,所以不能随机访问;而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的储存空间
2.数据结构的基本操作
1.基本操作无非遍历 + 访问,再具体一点就是:增删查改
2.从最高层来看,各种数据结构的遍历 + 访问无非两种形式:线性的和非线性的
3.线性就是 for/while 迭代为代表,非线性就是递归为代表
4.几种典型遍历
1.数组
void traverse(int[] arr) { for (int i = 0; i < arr.length; i++) { // 迭代访问 arr[i] } }
2.链表
/* 基本的单链表节点 */ class ListNode { int val; ListNode next; } void traverse(ListNode head) { for (ListNode p = head; p != null; p = p.next) { // 迭代访问 p.val } } void traverse(ListNode head) { // 递归访问 head.val traverse(head.next) }
3.二叉树
/* 基本的二叉树节点 */ class TreeNode { int val; TreeNode left, right; } void traverse(TreeNode root) { traverse(root.left) traverse(root.right) }
4.N叉树
/* 基本的 N 叉树节点 */ class TreeNode { int val; TreeNode[] children; } void traverse(TreeNode root) { for (TreeNode child : root.children) traverse(child); }
5.所谓框架,就是套路。不管增删查改,这些代码都是永远无法脱离的结构,你可以把这个结构作为大纲,根据具体问题在框架上添加代码就行了
3.算法刷题指南
1.先刷二叉树,先刷二叉树,先刷二叉树!
2.二叉树是最容易培养框架思维的,而且大部分算法技巧,本质上都是树的遍历问题
3.不要小看这几行破代码,几乎所有二叉树的题目都是一套这个框架就出来了
void traverse(TreeNode root) { // 前序遍历 traverse(root.left) // 中序遍历 traverse(root.right) // 后序遍历 }
4.如果你对刷题无从下手或者有畏惧心理,不妨从二叉树下手,前 10 道也许有点难受;结合框架再做 20 道,也许你就有点自己的理解了;刷完整个专题,再去做什么回溯动规分治专题,你就会发现只要涉及递归的问题,都是树的问题
5.这么多代码看不懂咋办?直接提取出框架,就能看出核心思路了:其实很多动态规划问题就是在遍历一棵树,你如果对树的遍历操作烂熟于心,起码知道怎么把思路转化成代码,也知道如何提取别人解法的核心思路
2.动态规划解题套路框架
1.基本概念
1.形式
动态规划问题的一般形式就是求最值。动态规划其实是运筹学的一种最优化方法,只不过在计算机问题上应用比较多,比如说让你求最长递增子序列呀,最小编辑距离
2.核心问题
核心问题是穷举。因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值
3.三要素
1.重叠子问题
0.这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程,避免不必要的计算
2.最优子结构
0.动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值
3.状态转移方程
0.问题可以千变万化,穷举所有可行解其实并不是一件容易的事,只有列出正确的「状态转移方程」才能正确地穷举
1.思维框架
明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义
# 初始化 base case dp[0][0][...] = base # 进行状态转移 for 状态1 in 状态1的所有取值: for 状态2 in 状态2的所有取值: for ... dp[状态1][状态2][...] = 求最值(选择1,选择2...)
2.斐波那契数列
1.暴力递归
1.代码
int fib(int N) { if (N == 1 || N == 2) return 1; return fib(N - 1) + fib(N - 2); }
2.递归树
1.但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复杂度,寻找算法低效的原因都有巨大帮助
2.图片

3.递归算法时间复杂度
0.用子问题个数乘以解决一个子问题需要的时间
1.首先计算子问题个数,即递归树中节点的总数。显然二叉树节点总数为指数级别,所以子问题个数为 O(2^n)
2.然后计算解决一个子问题的时间,在本算法中,没有循环,只有 f(n - 1) + f(n - 2) 一个加法操作,时间为 O(1)
3.这个算法的时间复杂度为二者相乘,即 O(2^n),指数级别,爆炸
4.观察递归树,很明显发现了算法低效的原因:存在大量重复计算
5.这就是动态规划问题的第一个性质:重叠子问题。下面,我们想办法解决这个问题
2.带备忘录的递归解法
1.既然耗时的原因是重复计算,那么我们可以造一个「备忘录」,每次算出某个子问题的答案后别急着返回,先记到「备忘录」里再返回;每次遇到一个子问题先去「备忘录」里查一查,如果发现之前已经解决过这个问题了,直接把答案拿出来用,不要再耗时去计算了
2.一般使用一个数组充当这个「备忘录」,当然你也可以使用哈希表(字典)
3.代码
int fib(int N) { if (N < 1) return 0; // 备忘录全初始化为 0 vector<int> memo(N + 1, 0); // 进行带备忘录的递归 return helper(memo, N); } int helper(vector<int>& memo, int n) { // base case if (n == 1 || n == 2) return 1; // 已经计算过 if (memo[n] != 0) return memo[n]; memo[n] = helper(memo, n - 1) + helper(memo, n - 2); return memo[n]; }
4.递归树
实际上,带「备忘录」的递归算法,把一棵存在巨量冗余的递归树通过「剪枝」,改造成了一幅不存在冗余的递归图,极大减少了子问题(即递归图中节点)的个数
5.复杂度
本算法不存在冗余计算,子问题个数为 O(n),本算法的时间复杂度是 O(n)。比起暴力算法,是降维打击
6.和动态规划对比
带备忘录的递归解法的效率已经和迭代的动态规划解法一样了.只不过这种方法叫做「自顶向下」,动态规划叫做「自底向上」
7.自顶向下
画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说 f(20),向下逐渐分解规模,直到 f(1) 和 f(2) 这两个 base case,然后逐层返回答案
8.自底向上
直接从最底下,最简单,问题规模最小的 f(1) 和 f(2) 开始往上推,直到推到我们想要的答案 f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算
3.dp数组的迭代解法
1.思想
有了上一步「备忘录」的启发,我们可以把这个「备忘录」独立出来成为一张表,就叫做 DP table 吧,在这张表上完成「自底向上」的推算岂不美哉
2.代码
int fib(int N) { vector<int> dp(N + 1, 0); // base case dp[1] = dp[2] = 1; for (int i = 3; i <= N; i++) dp[i] = dp[i - 1] + dp[i - 2]; return dp[N]; }
3.状态转移方程
1.它是解决问题的核心。而且很容易发现,其实状态转移方程直接代表着暴力解法
2.千万不要看不起暴力解,动态规划问题最困难的就是写出这个暴力解,即状态转移方程。只要写出暴力解,优化方法无非是用备忘录或者 DP table,再无奥妙可言
4.状态压缩
1.当前状态只和之前的两个状态有关,其实并不需要那么长的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行了。所以,可以进一步优化,把空间复杂度降为 O(1)
2.代码
int fib(int n) { if (n == 2 || n == 1) return 1; int prev = 1, curr = 1; for (int i = 3; i <= n; i++) { int sum = prev + curr; prev = curr; curr = sum; } return curr; }
3.这个技巧就是所谓的「状态压缩」,如果我们发现每次状态转移只需要 DP table 中的一部分,那么可以尝试用状态压缩来缩小 DP table 的大小,只记录必要的数据,一般来说是把一个二维的 DP table 压缩成一维,即把空间复杂度从 O(n^2) 压缩到 O(n)
3.凑零钱问题
0.问题
给你 k 种面值的硬币,面值分别为 c1, c2 ... ck,每种硬币的数量无限,再给一个总金额 amount,问你最少需要几枚硬币凑出这个金额,如果不可能凑出,算法返回 -1
1.暴力递归
1.首先,这个问题是动态规划问题,因为它具有「最优子结构」的。要符合「最优子结构」,子问题间必须互相独立
2.回到凑零钱问题,为什么说它符合最优子结构呢?比如你想求 amount = 11 时的最少硬币数(原问题),如果你知道凑出 amount = 10 的最少硬币数(子问题),你只需要把子问题的答案加一(再选一枚面值为 1 的硬币)就是原问题的答案。因为硬币的数量是没有限制的,所以子问题之间没有相互制,是互相独立的
3.四大步骤
1.确定 base case,这个很简单,显然目标金额 amount 为 0 时算法返回 0
2.确定「状态」,也就是原问题和子问题中会变化的变量。由于硬币数量无限,硬币的面额也是题目给定的,只有目标金额会不断地向 base case 靠近,所以唯一的「状态」就是目标金额 amount
3.确定「选择」,也就是导致「状态」产生变化的行为。目标金额为什么变化呢,因为你在选择硬币,你每选择一枚硬币,就相当于减少了目标金额。所以说所有硬币的面值,就是你的「选择」
4.明确 dp 函数/数组的定义。我们这里讲的是自顶向下的解法,所以会有一个递归的 dp 函数,一般来说函数的参数就是状态转移中会变化的量,也就是上面说到的「状态」;函数的返回值就是题目要求我们计算的量。就本题来说,状态只有一个,即「目标金额」,题目要求我们计算凑出目标金额所需的最少硬币数量。所以我们可以这样定义 dp 函数: dp(n) 的定义:输入一个目标金额 n,返回凑出目标金额 n 的最少硬币数量
4.伪码
def coinChange(coins: List[int], amount: int): # 定义:要凑出金额 n,至少要 dp(n) 个硬币 def dp(n): # 做选择,选择需要硬币最少的那个结果 for coin in coins: res = min(res, 1 + dp(n - coin)) return res # 题目要求的最终结果是 dp(amount) return dp(amount)
5.代码
def coinChange(coins: List[int], amount: int): def dp(n): # base case if n == 0: return 0 if n < 0: return -1 # 求最小值,所以初始化为正无穷 res = float('INF') for coin in coins: subproblem = dp(n - coin) # 子问题无解,跳过 if subproblem == -1: continue res = min(res, 1 + subproblem) return res if res != float('INF') else -1 return dp(amount)
6.状态转移方程
7.递归树
8.复杂度
子问题总数为递归树节点个数,这个比较难看出来,是 O(n^k),总之是指数级别的。每个子问题中含有一个 for 循环,复杂度为 O(k)。所以总时间复杂度为 O(k * n^k),指数级别
2.带备忘录的递归
1.代码
def coinChange(coins: List[int], amount: int): # 备忘录 memo = dict() def dp(n): # 查备忘录,避免重复计算 if n in memo: return memo[n] # base case if n == 0: return 0 if n < 0: return -1 res = float('INF') for coin in coins: subproblem = dp(n - coin) if subproblem == -1: continue res = min(res, 1 + subproblem) # 记入备忘录 memo[n] = res if res != float('INF') else -1 return memo[n] return dp(amount)
2.复杂度
很显然「备忘录」大大减小了子问题数目,完全消除了子问题的冗余,所以子问题总数不会超过金额数 n,即子问题数目为 O(n)。处理一个子问题的时间不变,仍是 O(k),所以总的时间复杂度是 O(kn)
3.dp 数组的迭代解法
1.dp数组的定义
dp 函数体现在函数参数,而 dp 数组体现在数组索引: dp 数组的定义:当目标金额为 i 时,至少需要 dp[i] 枚硬币凑出
2.代码
int coinChange(vector<int>& coins, int amount) { // 数组大小为 amount + 1,初始值也为 amount + 1 vector<int> dp(amount + 1, amount + 1); // base case dp[0] = 0; // 外层 for 循环在遍历所有状态的所有取值 for (int i = 0; i < dp.size(); i++) { // 内层 for 循环在求所有选择的最小值 for (int coin : coins) { // 子问题无解,跳过 if (i - coin < 0) continue; dp[i] = min(dp[i], 1 + dp[i - coin]); } } return (dp[amount] == amount + 1) ? -1 : dp[amount]; }
3.流程
4.细节
为啥 dp 数组初始化为 amount + 1 呢,因为凑成 amount 金额的硬币数最多只可能等于 amount(全用 1 元面值的硬币),所以初始化为 amount + 1 就相当于初始化为正无穷,便于后续取最小值
4.总结
1.计算机解决问题其实没有任何奇技淫巧,它唯一的解决办法就是穷举,穷举所有可能性。算法设计无非就是先思考“如何穷举”,然后再追求“如何聪明地穷举”
2.列出动态转移方程,就是在解决“如何穷举”的问题。之所以说它难,一是因为很多穷举需要递归实现,二是因为有的问题本身的解空间复杂,不那么容易穷举完整
3.备忘录、DP table 就是在追求“如何聪明地穷举”。用空间换时间的思路,是降低时间复杂度的不二法门,除此之外,试问,还能玩出啥花活