【数据结构与算法_学习精华】
一、学习数据结构与算法的框架思维
1、核心结论:
种种数据结构,皆为数组(顺序存储)和链表(链式存储)的变换。(索引访问与指针)
数据结构的关键点在于遍历和访问,具体一点就是:增删查改等基本操作。
种种算法,皆为穷举。穷举的关键点在于无遗漏和无冗余
各种数据结构的遍历 + 访问仅两种形式:迭代(for / while)与 递归(函数自调:自己调用自己)
2、迭代与递归
2.1 迭代
迭代(iterative)—— 自己一步一步数台阶脚下踩一根计数器 i=0→1→2…每数一步,状态全在你自己口袋里(循环变量、栈、指针)CPU 只关心“下一步去哪”,不回头找导游
线性结构,适合 循环迭代,以数组为例:
def traverse(arr: List[int]):for i in range(len(arr)):# 迭代访问 arr[i]
2.1.1 数组求和
def sum_iter(a):total = 0for x in a: # 迭代遍历total += xreturn totalprint(sum_iter([7, 3, 5])) # 15
2.1.2 链表遍历
# 基本的单链表节点
class ListNode:def __init__(self, val):self.val = valself.next = None#遍历方式1:迭代访问def traverse(head: ListNode) -> None:p = headwhile p is not None:# 迭代访问 p.valp = p.next
2.1.3 图遍历
from typing import List, Dict, Set, Deque
from collections import deque# ---------- 图定义 ----------
Graph = Dict[int, List[int]] # 邻接表:{节点: [邻居, ...]}# ---------- 广度优先 BFS(迭代) ----------
def bfs(graph: Graph, root: int) -> None:if root not in graph:returnvisited: Set[int] = set()q: Deque[int] = deque([root])visited.add(root)while q: # 标准队列迭代node = q.popleft()print(node) # 访问节点for neighbor in graph[node]:if neighbor not in visited:visited.add(neighbor)q.append(neighbor)
2.2 递归(套娃-自己调自己)
概念理解:递归(recursive)—— 导游们接力,【每层】的【导游-函数】负责一层处理你问导游 A:“到山顶多少阶?”A 只记 “【我这一层的阶数】 + 【下一层导游 B 的答案】”, 然后把问题原样扔给 导游B。每层导游拍照存档自己的阶数,直到最后一阶不再接力。回程时,照片从后往前逐层收集,结果逐层返回。每层只做“自己这一层 + 剩余结果”,而剩余结果交给下一层复制粘贴的这同一段代码去完成,这就是递归。
2.2.1 数组求和-递归方式
def sum_rec(a, i=0): # 函数签名:a是数组,i是元素索引if i == len(a): # 基准case:停止条件。到最后一阶直接返回,避免越界return 0 # 避免越界return a[i] + sum_rec(a, i + 1) # 把当前元素 a[i] “拿在手里”,每层的元素累计操作#如果是return sum_rec(a, i + 1),则表示跳过当前元素,只算“剩余的部分”运行结果:
sum_rec([7,3,5], 0)
= 7 + sum_rec([7,3,5], 1)
= 7 + (3 + sum_rec([7,3,5], 2))
= 7 + 3 + (5 + sum_rec([7,3,5], 3))
= 7 + 3 + 5 + 0 ← i==3 越界,返回 0
= 15
一些递归的概念
1、函数栈帧:就是函数被调用时,在内存里临时建立的一小块“工作台”,(如例子上的 每层的照片)
里面放着:
1)当前函数的局部变量
2)返回地址(调用完后回到哪条指令)
3)参数值
4)一些控制信息(保存的寄存器、上一帧指针等)调用函数 → 压入一帧;返回 → 弹出这一帧。所有帧按“后调用先弹出”的顺序串在系统调用栈上,这就是递归深度过深会爆 StackOverflow 的原因。上面例子中:函数栈帧里只记录“当前这一层”的局部变量名 i 和指向列表 a 的引用(指针),不会把整个列表或元素复制进帧。所以 a[i] 只是通过帧里的引用去堆上读取数据,本身不是栈帧的一部分。但其实就是可以代指栈帧
2.2.1 链表遍历
既可以迭代 又可以递归的,以链表为例:
# 基本的单链表节点
class ListNode:def __init__(self, val):self.val = valself.next = None#遍历方式2:递归访问def traverse(head: ListNode) -> None:if head is None: # 终止条件returnprint(head.val) # 访问当前节点traverse(head.next) # 处理后续链表
2.2.3 二叉树遍历
非线性结构,适合递归,以二叉树为例:
与上面的链表相似
# 基本的二叉树节点
class TreeNode:def __init__(self, val=0, left=None, right=None):self.val = valself.left = leftself.right = rightdef traverse(root: TreeNode) -> None:if root is None: # 终止条件returnprint(root.val) # 访问当前节点traverse(root.left) # 递归左子树traverse(root.right) # 递归右子树
2.2.4 N叉数的遍历
与二叉树相似
# 基本的 N 叉树节点
from typing import Listclass TreeNode:val: intchildren: List['TreeNode']def traverse(root: TreeNode) -> None:if root is None: # 基准:空树returnprint(root.val) # 先访问当前节点(前序)for child in root.children: # 再依次递归每棵子树traverse(child)
2.2.5 图的遍历
与N叉数相似,对于环,要单独使用
from typing import List, Dict, Set, Deque
from collections import deque# ---------- 图定义 ----------
Graph = Dict[int, List[int]] # 邻接表:{节点: [邻居, ...]}# ---------- 深度优先 DFS(递归) ----------
def dfs(graph: Graph, root: int, visited: Set[int]) -> None:if root not in graph: # 停止条件:空图或孤立点returnprint(root) # 访问当前节点(前序)visited.add(root)for neighbor in graph[root]: # 依次递归每条边if neighbor not in visited:dfs(graph, neighbor, visited)
带环的图,需要使用 布尔数组 visited
做标记
from typing import Dict, List, Set, Deque
from collections import dequeGraph = Dict[int, List[int]]# ---------- DFS(递归) ----------
def dfs(graph: Graph, root: int, visited: Set[int]) -> None:if root not in graph:returnprint(root)visited.add(root)for nxt in graph[root]:if nxt not in visited: # 环被这一步剪掉dfs(graph, nxt, visited)
3、数据结构根本
3.1 数组
连续存储,可通过索引随机访问元素。内存空间需要一次性分配好。如果需要扩容,时间复杂度是O(N),如果是在数组中间插入/删除元素,时间复杂度也是O(N)。因为都需要移动其他元素保证连续存储。
3.2 链表
非连续存储,所以不能随机访问元素。需要指针指向下一元素。删除/插入元素,只需要操作某一元素的前后指针,所以时间复杂度是O(1)。因为需要存储前后指针,需要额外的存储空间。
4、数据结构基本类型
4.1、数组
4.2、链表
4.3、哈希表
通过散列函数把键映射到一个大数组里。
拉链法需要链表特性。
线性探查法需要数组连续访问特性。
4.4、队列
可以使用链表也可以使用数组实现。
用数组实现,就要处理扩容缩容的问题;
用链表实现,没有扩/缩容问题,但需要更多的内存空间存储节点指针
1
4.5、栈
可以使用链表也可以使用数组实现。
用数组实现,就要处理扩容缩容的问题;
用链表实现,没有扩/缩容问题,但需要更多的内存空间存储节点指针
可以使用链表也可以使用数组实现用数组实现,就要处理扩容缩容的问题;
用链表实现,没有扩/缩容问题,但需要更多的内存空间存储节点指针
4.6、树
用数组实现的是:完全二叉树、二叉堆
用链表实现的是:二叉搜索树、红黑树、B数、AVL树
4.7、图
图的两种存储方式,邻接表就是链表,邻接矩阵就是二维数组。
5、算法的本质
计算机算法,最笨但最通用的是:穷举。然后可以变形成聪明的穷举
大部分开发岗位工作中都是基于现成的开发框架做事,不怎么会碰到底层数据结构和算法相关的问题,但另一个事实是,只要你想找技术相关的岗位,数据结构和算法的考察是绕不开的,因为这块知识点是公认的程序员基本功。为了区分,不妨称算法工程师研究的算法为「数学算法」,称刷题面试的算法为「计算机算法」,我们的目标主要聚焦的是「计算机算法」。找一份开发岗位的工作,所以你真的不需要有多少数学基础,只要学会用计算机思维解决问题就够了
5.1 排列组合问题
排列组合问题抽象成一棵树,要精确地使用代码遍历这棵树的所有节点,不能漏不能多,才能写出正确的代码
5.2 有序数组中,寻找一个元素
在有序数组中寻找一个元素,用一个 for 循环暴力穷举谁都会,但 二分搜索算法 就是更聪明的穷举方式,拥有更好的时间复杂度
5.3 动态规划
动态规划是无冗余地穷举所有解,然后找一个最值
5.4 贪心算法
贪心算法就是在题目中发现一些规律(专业点叫贪心选择性质),使得你不用完整穷举所有解就可以得出答案
5.5 计算连通分量
想判断图中的两个节点是否连通,用 DFS/BFS 暴力搜索(穷举)肯定可以做到,但 Union Find 算法硬是用数组模拟树结构,把连通性相关的操作复杂度给干到 O(1)
6、常见的算法技巧
链表和数组
1、单链表常考的技巧就是双指针
判断单链表是否成环,暴力解是用一个 HashSet
之类的数据结构来缓存走过的节点,遇到重复的就说明有环。
但用快慢指针可以避免使用额外的空间,这就是聪明地穷举
2、数组常用的技巧也是双指针
3、二分搜索技巧
可以归为两端向中心的双指针。
如果在数组中搜索元素,一个 for 循环花 O(N)时间穷举肯定能搞定,但是二分搜索告诉你,如果数组是有序的,它只要 O(logN)的复杂度,这就是一种更聪明的搜索方式。
4、滑动窗口算法
典型的快慢双指针。用嵌套 for 循环花 O(N^2) 的时间肯定可以穷举出所有子数组。但是滑动窗口算法表示,在某些场景下,它可以用一快一慢两个指针,只需 O(N) 的时间就可以找到答案,这就是更聪明地穷举方式。
5、前缀和 -技巧
频繁地让你计算子数组的和,每次用 for 循环去遍历肯定没问题,但前缀和技巧预计算一个 preSum
数组,就可以避免循环。
6、差分数组技巧
频繁地让你对子数组进行增减操作,也可以每次用 for 循环去操作,但差分数组技巧维护一个 diff
数组,也可以避免循环。
二叉树系列
二叉树模型几乎是所有高级算法的基础。叉树题目的递归解法可以分两类思路:
第一类是遍历一遍二叉树得出答案:回溯算法,
第二类是通过分解问题计算出答案:动态规划算法
1、遍历二叉树最大深度
这个逻辑就是用 traverse
函数遍历了一遍二叉树的所有节点,维护 depth
变量,在叶子节点的时候更新最大深度。
class Solution:def __init__(self):# 记录最大深度self.res = 0# 记录当前遍历节点的深度self.depth = 0def maxDepth(self, root: TreeNode) -> int:self.traverse(root)return self.resdef traverse(self, root: TreeNode) -> None:if not root:# 到达叶子节点self.res = max(self.res, self.depth)return# 前序遍历位置self.depth += 1self.traverse(root.left)self.traverse(root.right)# 后序遍历位置self.depth -= 1
2、全排列问题:
本质就是多叉树的遍历,所以说回溯算法本质就是遍历多叉树,只要能把问题抽象成树结构,就一定能用回溯算法解决。
class Solution:def permute(self, nums: List[int]) -> List[List[int]]:# 记录所有全排列res = []# 记录当前正在穷举的排列track = []# track 中的元素会被标记为 true,避免重复使用used = [False] * len(nums)# 主函数,输入一组不重复的数字,返回它们的全排列def backtrack(nums):# 到达叶子节点,track 中的元素就是一个全排列if len(track) == len(nums):res.append(track[:])returnfor i in range(len(nums)):# 排除不合法的选择if used[i]:# nums[i] 已经在 track 中,跳过continue# 做选择track.append(nums[i])used[i] = True# 进入递归树的下一层backtrack(nums)# 取消选择track.pop()used[i] = Falsebacktrack(nums)return res
3、二叉树最大深度 --分解问题
# 定义:输入根节点,返回这棵二叉树的最大深度
def maxDepth(root: TreeNode) -> int:if root is None:return 0# 递归计算左右子树的最大深度leftMax = maxDepth(root.left)rightMax = maxDepth(root.right)# 整棵树的最大深度就是左右子树的最大深度加一res = max(leftMax, rightMax) + 1return res