LeetCode 基本算法笔记

枚举算法

枚举算法简介

枚举算法(Enumeration Algorithm):也称为穷举算法,指的是按照问题本身的性质,一一列举出该问题所有可能的解,并在逐一列举的过程中,将它们逐一与目标状态进行比较以得出满足问题要求的解。在列举的过程中,既不能遗漏也不能重复。

枚举算法简单,适用于小规模问题

枚举算法的解题思路

好个枚举大法

以鸡兔同笼为例

domain knowledge (?)can reduce the cost of Enumeration Algorithm

枚举算法的应用

两数之和

枚举算法,使用两重循环

计数质数

204. 计数质数 - 力扣(LeetCode)
单纯枚举会超时

平方和三元组

递归算法

递归(Recursion):指的是一种通过重复将原问题分解为同类的子问题而解决的方法。在绝大数编程语言中,可以通过在函数中再次调用函数自身的方式来实现递归。

可以把「递归」分为两个部分:「递推过程」和「回归过程」。

  • 递推过程:指的是将原问题一层一层地分解为与原问题形式相同、规模更小的子问题,直到达到结束条件时停止,此时返回最底层子问题的解。
  • 回归过程:指的是从最底层子问题的解开始,逆向逐一回归,最终达到递推开始时的原问题,返回原问题的解。

递归的基本思想就是: 把规模大的问题不断分解为子问题来解决。

递归三步走

递归的基本思想就是: 把规模大的问题不断分解为子问题来解决。
在写递归的时候,我们可以按照这个思想来书写递归,具体步骤如下:

  1. 写出递推公式:找到将原问题分解为子问题的规律,并且根据规律写出递推公式。
  2. 明确终止条件:推敲出递归的终止条件,以及递归终止时的处理方法。
  3. 将递推公式和终止条件翻译成代码
    1. 定义递归函数(明确函数意义、传入参数、返回结果等)。
    2. 书写递归主体(提取重复的逻辑,缩小问题规模)。
    3. 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。

从逻辑和程序逻辑上分析递归

递归的注意点

避免栈溢出

在程序执行中,递归是利用堆栈来实现的。如果递归调用的次数过多,会导致栈空间溢出。

为了避免栈溢出,我们可以在代码中限制递归调用的最大深度来解决问题。

当然这种做法并不能完全避免栈溢出,因为系统允许的最大递归深度跟当前剩余的占空间有关,事先无法计算。

如果实在无法使用递归算法,可以考虑将递归算法变为非递归算法(即递推算法)来解决栈溢出的问题。

避免重复计算

为了避免重复计算,我们可以使用一个缓存(哈希表、集合或数组)来保存已经求解过的 $f(k)$ 的结果,这也是动态规划算法中的做法。当递归调用用到 $f(k)$ 时,先查看一下之前是否已经计算过结果,如果已经计算过,则直接从缓存中取值返回,而不用再递推下去,这样就避免了重复计算问题。

//动态规划?
//Python 中的哈希表?

递归的应用

二叉树的最大深度

maximum-depth-of-binary-tree

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):字面上的解释是「分而治之」,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。

graph TD A[原问题] --> B[子问题] A[原问题] --> C[子问题] B --> D[最小子问题] B --> E[最小子问题] C --> F[最小子问题] C --> G[最小子问题] D --> H[子问题的解] E --> H[子问题的解] F --> I[子问题的解] G --> I[子问题的解] H --> J[原问题的解] I --> J[原问题的解]

分治算法和递归算法的异同

分治算法和递归算法的关系是包含与被包含的关系,可以看做: 递归算法∈分治算法。

分治算法从实现方式上来划分,可以分为两种:「递归算法」和「迭代算法」

适用条件

分治算法能够解决的问题,一般需要满足以下 4 个条件:

  1. 可分解:原问题可以分解为若干个规模较小的相同子问题。
  2. 子问题可独立求解:分解出来的子问题可以独立求解,即子问题之间不包含公共的子子问题。
  3. 具有分解的终止条件:当问题的规模足够小时,能够用较简单的方法解决。
  4. 可合并:子问题的解可以合并为原问题的解,并且合并操作的复杂度不能太高,否则就无法起到减少算法总体复杂度的效果了。

分治算法的基本步骤

使用分治算法解决问题主要分为 3 个步骤:

  1. 分解:把要解决的问题分解为成若干个规模较小、相对独立、与原问题形式相同的子问题。
  2. 求解:递归求解各个子问题。
  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)

回溯算法三步走

//还有严谨的表达方式,在此不给出

回溯算法的基本思想是:以深度优先搜索的方式,根据产生子节点的条件约束,搜索问题的解。当发现当前节点已不满足求解条件时,就「回溯」返回,尝试其他的路径。

那么,在写回溯算法时,我们可以按照这个思想来书写回溯算法,具体步骤如下:

  1. 明确所有选择:画出搜索过程的决策树,根据决策树来确定搜索路径。
  2. 明确终止条件:推敲出递归的终止条件,以及递归终止时的要执行的处理方法。
  3. 将决策树和终止条件翻译成代码:
    1. 定义回溯函数(明确函数意义、传入参数、返回结果等)。
    2. 书写回溯函数主体(给出约束条件、选择元素、递归搜索、撤销选择部分)。
    3. 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。

//学到目前为止,回溯算法是最能引起 AIMA search 章节共鸣的

贪心算法

贪心算法简介

贪心算法的定义

贪心算法(Greedy Algorithm):一种在每次决策时,总是采取在当前状态下的最好选择,从而希望导致结果是最好或最优的算法。

将求解过程分步,采取某种度量标准,每个步骤都选取局部最优解,希望最后的结果也是全局最优解

贪心算法的特征

一般来说,这些能够使用贪心算法解决的问题必须满足下面的两个特征:

  1. 贪⼼选择性质
  2. 最优子结构

贪心选择性质:指的是一个问题的全局最优解可以通过一系列局部最优解(贪心选择)来得
到。
最优子结构性质:指的是一个问题的最优解包含其子问题的最优解。

贪心算法正确性的证明

贪心算法最难的部分不在于问题的求解,而在于是正确性的证明。我们常用的证明方法有「数学归纳法」和「交换论证法」。

在日常写题或者算法面试中,不太会要求大家去证明贪心算法的正确性

贪心算法三步走

  1. 转换问题:将优化问题转换为具有贪心选择性质的问题,即先做出选择,再解决剩下的一个子问题。
  2. 贪心选择性质:根据题意选择一种度量标准,制定贪心策略,选取当前状态下「最好 / 最优选择」,从而得到局部最优解。
  3. 最优子结构性质:根据上一步制定的贪心策略,将贪心选择的局部最优解和子问题的最优解合并起来,得到原问题的最优解。

贪心算法的应用

分发饼干

分发饼干 - 力扣

描述:一位很棒的家长为孩子们分发饼干。对于每个孩子 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

无重叠区间

无重叠区间 - 力扣

official solution

位运算

位运算简介

位运算与二进制简介

位运算(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                     # 返回所有子集
posted @ 2024-09-30 09:27  kriss-spy  阅读(263)  评论(0)    收藏  举报