LeetCode 基本算法笔记
枚举算法
枚举算法简介
枚举算法(Enumeration Algorithm):也称为穷举算法,指的是按照问题本身的性质,一一列举出该问题所有可能的解,并在逐一列举的过程中,将它们逐一与目标状态进行比较以得出满足问题要求的解。在列举的过程中,既不能遗漏也不能重复。
枚举算法简单,适用于小规模问题
枚举算法的解题思路
好个枚举大法
以鸡兔同笼为例
domain knowledge (?)can reduce the cost of Enumeration Algorithm
枚举算法的应用
两数之和
枚举算法,使用两重循环
计数质数
204. 计数质数 - 力扣(LeetCode)
单纯枚举会超时
平方和三元组
递归算法
递归(Recursion):指的是一种通过重复将原问题分解为同类的子问题而解决的方法。在绝大数编程语言中,可以通过在函数中再次调用函数自身的方式来实现递归。
可以把「递归」分为两个部分:「递推过程」和「回归过程」。
- 递推过程:指的是将原问题一层一层地分解为与原问题形式相同、规模更小的子问题,直到达到结束条件时停止,此时返回最底层子问题的解。
- 回归过程:指的是从最底层子问题的解开始,逆向逐一回归,最终达到递推开始时的原问题,返回原问题的解。
递归的基本思想就是: 把规模大的问题不断分解为子问题来解决。
递归三步走
递归的基本思想就是: 把规模大的问题不断分解为子问题来解决。
在写递归的时候,我们可以按照这个思想来书写递归,具体步骤如下:
- 写出递推公式:找到将原问题分解为子问题的规律,并且根据规律写出递推公式。
- 明确终止条件:推敲出递归的终止条件,以及递归终止时的处理方法。
- 将递推公式和终止条件翻译成代码:
- 定义递归函数(明确函数意义、传入参数、返回结果等)。
- 书写递归主体(提取重复的逻辑,缩小问题规模)。
- 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。
从逻辑和程序逻辑上分析递归
递归的注意点
避免栈溢出
在程序执行中,递归是利用堆栈来实现的。如果递归调用的次数过多,会导致栈空间溢出。
为了避免栈溢出,我们可以在代码中限制递归调用的最大深度来解决问题。
当然这种做法并不能完全避免栈溢出,因为系统允许的最大递归深度跟当前剩余的占空间有关,事先无法计算。
如果实在无法使用递归算法,可以考虑将递归算法变为非递归算法(即递推算法)来解决栈溢出的问题。
避免重复计算
为了避免重复计算,我们可以使用一个缓存(哈希表、集合或数组)来保存已经求解过的 $f(k)$ 的结果,这也是动态规划算法中的做法。当递归调用用到 $f(k)$ 时,先查看一下之前是否已经计算过结果,如果已经计算过,则直接从缓存中取值返回,而不用再递推下去,这样就避免了重复计算问题。
//动态规划?
//Python 中的哈希表?
递归的应用
二叉树的最大深度
root = [3,9,20,null,null,15,7]
class Solution:
def maxDepth(self, root: Optional[TreeNode]) -> int:
if not root:
return 0
return max(self.maxDepth(root.left), self.maxDepth(root.right)) + 1
分治算法
分治算法简介
分治算法(Divide and Conquer):字面上的解释是「分而治之」,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
分治算法和递归算法的异同
分治算法和递归算法的关系是包含与被包含的关系,可以看做: 递归算法∈分治算法。
分治算法从实现方式上来划分,可以分为两种:「递归算法」和「迭代算法」
适用条件
分治算法能够解决的问题,一般需要满足以下 4 个条件:
- 可分解:原问题可以分解为若干个规模较小的相同子问题。
- 子问题可独立求解:分解出来的子问题可以独立求解,即子问题之间不包含公共的子子问题。
- 具有分解的终止条件:当问题的规模足够小时,能够用较简单的方法解决。
- 可合并:子问题的解可以合并为原问题的解,并且合并操作的复杂度不能太高,否则就无法起到减少算法总体复杂度的效果了。
分治算法的基本步骤
使用分治算法解决问题主要分为 3 个步骤:
- 分解:把要解决的问题分解为成若干个规模较小、相对独立、与原问题形式相同的子问题。
- 求解:递归求解各个子问题。
- 合并:按照原问题的要求,将子问题的解逐层合并构成原问题的解。
伪代码
def divide_and_conquer(problems_n): # problems_n 为问题规模
if problems_n < d: # 当问题规模足够小时,直接解决该问题
return solove() # 直接求解
problems_k = divide(problems_n) # 将问题分解为 k 个相同形式的子问题
res = [0 for _ in range(k)] # res 用来保存 k 个子问题的解
for problem_k in problems_k:
res[i] = divide_and_conquer(problem_k) # 递归的求解 k 个子问题
ans = merge(res) # 合并 k 个子问题的解
return ans # 返回原问题的解
分治算法的复杂度分析
分治算法的时间复杂度实际上是由「分解」和「合并」两个部分构成的
回溯算法
回溯算法简介
回溯算法(Backtracking):一种能避免不必要搜索的穷举式的搜索算法。采用试错的思想,在搜索尝试过程中寻找问题的解,当探索到某一步时,发现原先的选择并不满足求解条件,或者还需要满足更多求解条件时,就退回一步(回溯)重新选择,这种走不通就退回再走的技术称为「回溯法」,而满足回溯条件的某个状态的点称为「回溯点」。
走不通就回退
//人生,走不通就回退吗?如何知道走不走得通?
//都走的通,怎么选?
从全排列问题开始理解回溯算法
以求解 $[1,2,3]$ 的全排列为例
遍历决策树
全排列的回溯算法代码
class Solution:
def permute(self, nums: List[int]) -> List[List[int]]:
res = [] # 存放所有符合条件结果的集合
path = [] # 存放当前符合条件的结果
def backtracking(nums): # nums 为选择元素列表
if len(path) == len(nums): # 说明找到了一组符合条件的结果
res.append(path[:]) # 将当前符合条件的结果放入集合中
return
for i in range(len(nums)): # 枚举可选元素列表
if nums[i] not in path: # 从当前路径中没有出现的数字中选择
path.append(nums[i]) # 选择元素
backtracking(nums) # 递归搜索
path.pop() # 撤销选择
backtracking(nums)
return res
回溯算法的通用模板
res = [] # 存放所欲符合条件结果的集合
path = [] # 存放当前符合条件的结果
def backtracking(nums): # nums 为选择元素列表
if 遇到边界条件: # 说明找到了一组符合条件的结果
res.append(path[:]) # 将当前符合条件的结果放入集合中
return
for i in range(len(nums)): # 枚举可选元素列表
path.append(nums[i]) # 选择元素
backtracking(nums) # 递归搜索
path.pop() # 撤销选择
backtracking(nums)
回溯算法三步走
//还有严谨的表达方式,在此不给出
回溯算法的基本思想是:以深度优先搜索的方式,根据产生子节点的条件约束,搜索问题的解。当发现当前节点已不满足求解条件时,就「回溯」返回,尝试其他的路径。
那么,在写回溯算法时,我们可以按照这个思想来书写回溯算法,具体步骤如下:
- 明确所有选择:画出搜索过程的决策树,根据决策树来确定搜索路径。
- 明确终止条件:推敲出递归的终止条件,以及递归终止时的要执行的处理方法。
- 将决策树和终止条件翻译成代码:
- 定义回溯函数(明确函数意义、传入参数、返回结果等)。
- 书写回溯函数主体(给出约束条件、选择元素、递归搜索、撤销选择部分)。
- 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。
//学到目前为止,回溯算法是最能引起 AIMA search 章节共鸣的
贪心算法
贪心算法简介
贪心算法的定义
贪心算法(Greedy Algorithm):一种在每次决策时,总是采取在当前状态下的最好选择,从而希望导致结果是最好或最优的算法。
将求解过程分步,采取某种度量标准,每个步骤都选取局部最优解,希望最后的结果也是全局最优解
贪心算法的特征
一般来说,这些能够使用贪心算法解决的问题必须满足下面的两个特征:
- 贪⼼选择性质
- 最优子结构
贪心选择性质:指的是一个问题的全局最优解可以通过一系列局部最优解(贪心选择)来得
到。
最优子结构性质:指的是一个问题的最优解包含其子问题的最优解。
贪心算法正确性的证明
贪心算法最难的部分不在于问题的求解,而在于是正确性的证明。我们常用的证明方法有「数学归纳法」和「交换论证法」。
在日常写题或者算法面试中,不太会要求大家去证明贪心算法的正确性
贪心算法三步走
- 转换问题:将优化问题转换为具有贪心选择性质的问题,即先做出选择,再解决剩下的一个子问题。
- 贪心选择性质:根据题意选择一种度量标准,制定贪心策略,选取当前状态下「最好 / 最优选择」,从而得到局部最优解。
- 最优子结构性质:根据上一步制定的贪心策略,将贪心选择的局部最优解和子问题的最优解合并起来,得到原问题的最优解。
贪心算法的应用
分发饼干
描述:一位很棒的家长为孩子们分发饼干。对于每个孩子 i,都有一个胃口值 g[i],即每个小孩希望得到饼干的最小尺寸值。对于每块饼干 j,都有一个尺寸值 s[j]。只有当 s[j]>g[i] 时,我们才能将饼干 j 分配给孩子 i。每个孩子最多只能给一块饼干。
现在给定代表所有孩子胃口值的数组 g 和代表所有饼干尺寸的数组 j。
要求:尽可能满足越多数量的孩子,并求出这个最大数值。
贪心算法,简单代码:
class Solution:
def findContentChildren(self, g: List[int], s: List[int]) -> int:
g.sort()
s.sort()
index_g, index_s = 0, 0
res = 0
while index_g < len(g) and index_s < len(s):
if g[index_g] <= s[index_s]:
res += 1
index_g += 1
index_s += 1
else:
index_s += 1
return res
无重叠区间
位运算
位运算简介
位运算与二进制简介
位运算(Bit Operation):在计算机内部,数是以「二进制(Binary)」的形式来进行存储。位运算就是直接对数的二进制进行计算操作,在程序中使用位运算进行操作,会大大提高程序的性能。
位运算基础操作
| 运算符 | 描述 | 规则 |
|---|---|---|
| |
按位或运算符 | 只要对应的两个二进位有一个为 1 时,结果位就为 1。 |
& |
按位与运算符 | 只有对应的两个二进位都为 时,结果位才为 。 |
<< |
左移运算符 | 将二进制数的各个二进位全部左移若干位。<< 右侧数字指定了移动位数,高位丢弃,低位补 。 |
>> |
右移运算符 | 对二进制数的各个二进位全部右移若干位。>> 右侧数字指定了移动位数,低位丢弃,高位补 。 |
^ |
按位异或运算符 | 对应的两个二进位相异时,结果位为 ,二进位相同时则结果位为 。 |
~ |
取反运算符 | 对二进制数的每个二进位取反,使数字 变为 , 变为 。 |
位运算的应用
位运算的常用操作
isOdd
交换两个整数
a, b = 10, 20
a ^= b
b ^= a
a ^= b
print(a, b)
位运算的常用操作总结
| 功 能 | 位运算 | 示例 |
|---|---|---|
| 从右边开始,把最后一个 1 改写成 0 | x & (x - 1) |
100101000 -> 100100000 |
| 去掉右边起第一个 1 的左边 | x & (x ^ (x - 1)) 或 x & (-x) |
100101000 -> 1000 |
| 去掉最后一位 | x >> 1 |
101101 -> 10110 |
| 取右数第 k 位 | x >> (k - 1) & 1 |
1101101 -> 1, k = 4 |
| 取末尾 3 位 | x & 7 |
1101101 -> 101 |
| 取末尾 k 位 | x & 15 |
1101101 -> 1101, k = 4 |
| 只保留右边连续的 1 | (x ^ (x + 1)) >> 1 |
100101111 -> 1111 |
| 右数第 k 位取反 | x ^ (1 << (k - 1)) |
101001 -> 101101, k = 3 |
| 在最后加一个 0 | x << 1 |
101101 -> 1011010 |
| 在最后加一个 1 | (x << 1) + 1 |
101101 -> 1011011 |
| 把右数第 k 位变成 0 | x & ~(1 << (k - 1)) |
101101 -> 101001, k = 3 |
| 把右数第 k 位变成 1 | x | (1 << (k - 1)) |
101001 -> 101101, k = 3 |
| 把右边起第一个 0 变成 1 | x | (x + 1) |
100101111 -> 100111111 |
| 把右边连续的 0 变成 1 | x | (x - 1) |
11011000 -> 11011111 |
| 把右边连续的 1 变成 0 | x & (x + 1) |
100101111 -> 100100000 |
| 把最后一位变成 0 | x | 1 - 1 |
101101 -> 101100 |
| 把最后一位变成 1 | x | 1 |
101100 -> 101101 |
| 把末尾 k 位变成 1 | x | (1 << k - 1) |
101001 -> 101111, k = 4 |
| 最后一位取反 | x ^ 1 |
101101 -> 101100 |
| 末尾 k 位取反 | x ^ (1 << k - 1) |
101001 -> 100110, k = 4 |
二进制枚举子集
我想到过
二进制枚举子集代码
class Solution:
def subsets(self, S): # 返回集合 S 的所有子集
n = len(S) # n 为集合 S 的元素个数
sub_sets = [] # sub_sets 用于保存所有子集
for i in range(1 << n): # 枚举 0 ~ 2^n - 1
sub_set = [] # sub_set 用于保存当前子集
for j in range(n): # 枚举第 i 位元素
if i >> j & 1: # 如果第 i 为元素对应二进位删改为 1,则表示选取该元素
sub_set.append(S[j]) # 将选取的元素加入到子集 sub_set 中
sub_sets.append(sub_set) # 将子集 sub_set 加入到所有子集数组 sub_sets 中
return sub_sets # 返回所有子集

浙公网安备 33010602011771号