软件设计师知识点总结:数据结构与算法(超级详细)
一.数据结构绪论
1.数据结构核心定义
- 数据:计算机可处理的符号集合(如数值、字符)。
- 数据元素:数据的基本单位(如 “学生” 记录),真题常称 “结点” 或 “顶点”。
- 数据项:数据元素的最小组成单位(如 “学生姓名”)。
- 数据结构:含三要素 ——
- 逻辑结构:数据元素间的逻辑关系(线性 / 非线性);
- 存储结构(物理结构):逻辑结构在内存中的实现(顺序 / 链式 / 索引 / 散列);
- 运算:对数据的操作(插入、删除、查询、排序)。
2.算法基本指标(高频考点)
- 时间复杂度:算法执行次数与问题规模 n 的关系,真题需掌握 “大 O 表示法”:
- 常数阶 O (1):如数组随机访问;
- 线性阶 O (n):如单链表遍历;
- 对数阶 O (log n):如二分查找;
- 线性对数阶 O (n log n):如归并排序、快速排序(平均);
- 平方阶 O (n²):如冒泡排序、直接插入排序。
- 空间复杂度:算法所需额外存储空间,真题常考 “原地排序”(O (1),如堆排序)与 “非原地排序”(O (n),如归并排序)。
- 算法特性:有穷性、确定性、可行性、输入(≥0)、输出(≥1)。
3.渐近符号
4.递归式主方法
二、线性结构
线性结构是元素间 “一对一” 关系的结构,包括数组、链表、栈、队列、串,栈与队列的应用、链表复杂度
1.线性表的定义和逻辑结构
- 定义:由 n 个数据元素构成的 “有限序列”,元素间是 “一对一” 的线性关系(比如 “[1,2,3,4]”“[张三,李四,王五]” 都是线性表)。
- 逻辑特征(必记!):
- 有唯一的 “首元素”(第一个元素)和 “尾元素”(最后一个元素)。
- 除首元素外,每个元素有且只有一个 “前驱”(前一个元素)。
- 除尾元素外,每个元素有且只有一个 “后继”(后一个元素)。
2.顺序表的基本操作
1. 插入操作(在第 i 个位置插入元素 e):
- 检查插入位置 i 是否合法(i 必须在 1~size+1 之间,比如 size=5,只能插在 1~6 的位置,插在 7 就错了)。
- 把第 i 个位置及后面的元素 “往后挪一位”(比如在位置 3 插元素,要把原位置 3、4、5 的元素都往后移,从最后一个元素开始挪,避免覆盖)。
- 把元素 e 放在第 i 个位置。
- size 加 1。
时间复杂度分析:
最好情况:在末尾插入(i=size+1),不用挪元素,O (1)。
最坏情况:在开头插入(i=1),要挪所有 size 个元素,O (n)(n=size)。
平均情况:平均要挪 n/2 个元素,O (n)。常考点:顺序表插入的时间复杂度(答案是 O (n))。
2. 删除操作(删除第 i 个位置的元素)步骤(必懂!):
3. 查找操作(两种:按位置找、按值找)
4.顺序表的优缺点
- 优点:
- 按位置查找效率高(O (1)),支持随机访问。
- 存储密度高(没有额外开销,比如链表的指针会占内存,顺序表只有元素本身)。
- 缺点:
- 插入 / 删除效率低(O (n)),要移动元素。
- 数组长度固定(初始化后不能改,比如数组长度 10,存满 10 个元素后就没法再插了,除非重新开一个更大的数组)
3.单链表的基本操作
1.插入操作
2.删除操作
3.查找操作
4.单链表的优缺点(和顺序表对比)
- 优点:
- 插入 / 删除效率高(如果知道前驱节点,O (1),不用移动元素)。
- 不需要预先确定长度(节点可以随时新建,链表长度灵活)。
- 缺点:
- 查找效率低(O (n)),不支持随机访问(不能直接找第 i 个节点,要遍历)。
- 存储密度低(每个节点的指针域占额外内存)。
4.循环单链表与双链表
三.栈和队列
(1)栈
1.栈的定义和逻辑特性
- 定义:限定只在 “表尾” 进行插入和删除操作的线性表(表尾叫 “栈顶”,表头叫 “栈底”)。
- 逻辑特性:先进后出(LIFO,Last In First Out)—— 先放进去的元素,最后才能拿出来(比如叠盘子,先叠的在下面,要拿先拿最上面的)。
- 核心操作(只有两个!):
- push(入栈):把元素放到栈顶(叠盘子,把新盘子放最上面)。
- pop(出栈):把栈顶元素拿出来(拿盘子,把最上面的盘子拿走)。其他操作:peek(查看栈顶元素,不拿出来)、isEmpty(判断栈是否为空)。
- 递归采用栈
2.栈的实现(两种:顺序栈、链式栈)
1. 顺序栈(用数组实现)结构:用数组存元素,加一个 “栈顶指针” top(记录栈顶元素的下标,初始时 top=-1,表示栈空)。核心操作实现(必懂!):
- 2. 链式栈(用链表实现,通常带头节点)结构:头节点的 next 指向栈顶节点(栈顶在链表的头部,不是尾部!因为链表尾部插元素要遍历,头部插更方便)。核心操作实现:
3.栈的应用
1. 括号匹配(比如判断 “(())()” 是否合法,“(()” 是否不合法)思路(必懂!):
- 遍历字符串,遇到左括号((、[、{)就 push 到栈里。
- 遇到右括号()、]、}),先看栈是否空(空的话说明没有左括号匹配,不合法)。
- 栈不空的话,pop 栈顶元素,看是否和当前右括号匹配(比如当前是),栈顶必须是 (;如果不匹配,比如栈顶是 [,就不合法)。
- 遍历结束后,看栈是否空(栈空说明所有左括号都匹配了,合法;栈不空说明有左括号没匹配,不合法)。例子:“(())()” → 遍历到 ( push,再 ( push,再) pop ( 匹配,再) pop ( 匹配,再 ( push,再) pop ( 匹配,栈空 → 合法。
2. 表达式求值(比如计算 “3+4*2-5” 的值,重点是后缀表达式)背景:我们写的 “3+4*2” 是 “中缀表达式”(运算符在两个数中间),计算机难算;“3 4 2 * +” 是 “后缀表达式”(运算符在两个数后面),计算机好算。步骤:
- 把中缀表达式转成后缀表达式(用栈存运算符,处理优先级,比如 * 的优先级比 + 高,要先算)。
- 计算后缀表达式(用栈存数字,遇到运算符就 pop 两个数算,结果 push 回去)。例子:计算 “3+42” → 转后缀是 “3 4 2 * +” → 计算时 push3、push4、push2,遇到 pop2 和 4 算 8,push8;遇到 + pop8 和 3 算 11,结果是 11。
3. 函数调用栈(理解即可,不用考代码)比如调用函数 A,A 里调用 B,B 里调用 C:执行 C 时,A 和 B 都暂停,计算机用栈存 A、B 的 “断点信息”(比如执行到哪一行);C 执行完,pop B 的断点,继续执行 B;B 执行完,pop A 的断点,继续执行 A。
(2)队列
1.队列的定义和逻辑特性
- 定义:限定只在 “表尾” 插入、“表头” 删除的线性表(表尾叫 “队尾”,表头叫 “队首”)。
- 逻辑特性:先进先出(FIFO,First In First Out)—— 先排队的先买票,后排队的后买票(比如排队,第一个人先买,最后一个人最后买)。
- 核心操作:
- enqueue(入队):把元素放到队尾(排队,站到队尾)。
- dequeue(出队):把队首元素拿出来(买票,队首的人买完走)。其他操作:peek(查看队首元素)、isEmpty(判断队列是否空)
2.队列的基本运算
代码实现:
3.循环队列解决假溢出
核心思路:把数组看成 “环形”(数组最后一个位置的下一个位置是第一个位置),用 “(rear+1)% 数组长度 == front” 判断队列是否满(不是看 rear 是否等于数组长度)。结构:和普通顺序队列一样(数组、front、rear),但指针移动时用 “取模” 实现环形。关键判断(必背!):
- 队空:front == rear。
- 队满:(rear + 1) % maxSize == front(maxSize 是数组长度,这样会浪费一个数组位置,避免和队空的条件冲突)。核心操作实现(必懂!):
- 时间复杂度:所有操作 O (1)。
4.链式队列与双端队列
基本操作
四.串
1.串的定义
- 字符串是一种特殊的线性表,其数据元素都为字符
- 空串:长度为0的字符串,没有任何字符
- 空格串:由一个或多个空格组成的串,空格是空白字符,占一个字符长度
- 子串:串中任意长度的连续字符构成的序列称为子串。含有子串的串称为主串,空串是任意串的子串
2.串的模式匹配与朴素模式匹配
3.计算next数组
4 KMP 算法(高效匹配,避免主串回溯)
KMP 算法的核心是 “主串指针 i 不回溯,只回溯模式串指针 j”,通过提前计算模式串的 “部分匹配表(next 数组)”,确定 j 回溯到哪个位置,从而减少对比次数。
为什么 BF 效率低?因为 BF 中主串指针 i 会频繁回溯(比如 S="aaaaa",T="aaab",每次 i 都要回到上一次的下一位),而 KMP 让 i 一直往前走,只调整 j,所以效率高。
关键概念:部分匹配值(PM 值)部分匹配值是模式串 T 的 “前缀” 和 “后缀” 的 “最长公共元素长度”,前缀是 “不包含最后一个字符的所有连续子串”,后缀是 “不包含第一个字符的所有连续子串”。例子:T="abcab"(长度 5),计算每个位置 j(0-4)的 PM 值:
- j=0(T [0]='a'):无前缀和后缀,PM=0;
- j=1(T [0-1]='ab'):前缀 ["a"],后缀 ["b"],无公共子串,PM=0;
- j=2(T [0-2]='abc'):前缀 ["a","ab"],后缀 ["bc","c"],无公共子串,PM=0;
- j=3(T [0-3]='abca'):前缀 ["a","ab","abc"],后缀 ["bca","ca","a"],最长公共子串是 "a",长度 1→PM=1;
- j=4(T [0-4]='abcab'):前缀 ["a","ab","abc","abca"],后缀 ["bcab","cab","ab","b"],最长公共子串是 "ab",长度 2→PM=2。
代码实现:
五.数组与矩阵
1.数组定义
- 数组是定长线性表在维度上的扩展,即线性表中的元素又是一个线性表。N维数组是一种“同构”的数据结构,
- 其每个数据元素类型相同、结构一致
- 数组结构的特点:数据元素数目固定;数据元素类型相同;数据元素的下标关系具有上下界的约束且下标有 序
- 数组数据元素固定,一般不做插入和删除运算,适合于采用顺序结构
2.数组存储地址的计算
3.矩阵
- 特殊矩阵:矩阵中的元素(或非0元素)的分布有一定的规律。常见的特殊矩阵有对称矩阵、三角矩阵和对角矩阵
- 稀疏矩阵:在一个矩阵中,若非零元素的个数远远少于零元素个数,且非零元素的分布没有规律。存储方式为三元组结构,即存储每个非零元素的(行,列,值)
六.树
1.树的核心概念
节点的度(Degree):一个节点拥有的 “子节点个数”(比如 CEO 有 3 个部门经理,CEO 的度是 3;某个经理没有员工,他的度是 0)。
树的度:树中所有节点的度的 “最大值”(比如公司里最大的度是 CEO 的 3,那树的度就是 3)。
叶子节点(Leaf Node):度为 0 的节点(没有子节点,比如公司里的普通员工、族谱里的晚辈)。
父节点 / 子节点 / 兄弟节点:
- 父节点:直接拥有某个节点的节点(比如经理是员工的父节点)。
- 子节点:直接被某个节点拥有的节点(比如员工是经理的子节点)。
- 兄弟节点:同一个父节点的子节点(比如同一经理手下的员工,互为兄弟)。
节点的层次(Level):根节点是第 1 层,根的子节点是第 2 层,以此类推(注意:有的教材从 0 层开始算,考试会明确,默认按 “1 层起” 记)。
树的深度(Depth):树中节点的 “最大层次”(比如公司架构有 3 层:CEO(1)→经理(2)→员工(3),树的深度是 3)。
森林(Forest):m 棵互不相交的树的集合(比如把两个公司的组织架构放一起,就是一个森林)。
常考点:判断术语对应关系(比如 “树的深度是指什么?”“叶子节点的度是多少?”)。
2.树的性质
3.二叉树的定义
定义:每个节点的度 “不超过 2” 的树,子节点明确分为 “左子节点” 和 “右子节点”(即使只有一个子节点,也要区分是左还是右,比如 “只有左子节点” 和 “只有右子节点” 是不同的二叉树)
4.二叉树的性质(重点)
5.满二叉树与完全二叉树
- 满二叉树:除叶子节点外,每个节点的度都是 2,且所有叶子节点都在同一层次(“满” 的意思是 “没有空位”,比如深度为 3 的满二叉树,有 1+2+4=7 个节点)。特征:节点总数 = 2^ 深度 - 1(深度 3,2³-1=7;深度 4,2⁴-1=15)。
- 完全二叉树:按 “层序遍历顺序”(从上到下、从左到右)给节点编号,所有编号≤n 的节点都 “存在”(简单说:叶子节点只能在最后两层,且最后一层的叶子节点都靠左,中间不能有空位)。特征:满二叉树是 “特殊的完全二叉树”,但完全二叉树不一定是满二叉树(比如深度 3,有 6 个节点的树,是完全二叉树;有 5 个节点,最后一层叶子只在左半部分,也是完全二叉树)。
6.二叉树的顺序存储与链式存储
(1)顺序存储(用数组存,适合完全二叉树)
- 存储规则:按 “层序遍历顺序” 给节点编号,把编号为 i 的节点存在数组下标 i-1 的位置(数组从 0 开始,编号从 1 开始,对应更方便)。
- 例子:完全二叉树节点编号 1(根)、2(左)、3(右)、4(2 的左)、5(2 的右),数组存储就是 [1,2,3,4,5](下标 0 存 1,下标 1 存 2,依此类推)。
- 优点:通过数组下标能快速找到父节点 / 子节点(用 4.2.2 的性质 5,不用遍历)。
- 缺点:如果是 “非完全二叉树”,会浪费大量数组空间(比如一棵二叉树只有根和右子节点,编号 1 和 3,数组下标 0 存 1,下标 1 空着,下标 2 存 3,中间下标 1 浪费)。
- 适用场景:只用于完全二叉树(比如后续要学的 “堆”,就是用顺序存储的完全二叉树)。
(2)链式存储(用 “二叉链表” 存,所有二叉树都适用)
- 节点结构:每个节点包含 3 部分:
- 数据域(data):存储节点的值。
- 左指针域(lchild):指向左子节点(没有左子节点则为 null)。
- 右指针域(rchild):指向右子节点(没有右子节点则为 null)。图示:data → lchild(左子节点),data → rchild(右子节点)。
- 存储结构:用一个 “根指针(root)” 指向根节点,整个树通过根指针串联(根指针为 null 时,树为空)。
- 优点:不浪费空间,所有类型的二叉树都能存,插入 / 删除操作灵活。
- 缺点:找父节点需要遍历整个树(除非用 “三叉链表”,多一个父指针域,但会增加内存开销,一般用二叉链表足够)。
- 适用场景:所有二叉树(尤其是非完全二叉树,比如普通的二叉搜索树)。
- n个结点—>2n个指针域—>(n-1个有效指针域)—>n+1个空指针域
7.二叉树的遍历
4 种核心遍历方式:前序遍历、中序遍历、后序遍历(这 3 种基于 “深度优先”,用递归实现最直观)、层序遍历(基于 “广度优先”,用队列实现)
8.平衡二叉树与二叉排序树
(1)平衡二叉树的定义:
- 二叉树中的任意一个结点的左右子树之差的绝对值不超过1。
(2)二叉排序树的定义:
- 根结点的关键字大于左子树所有结点的关键字小于右子树所有结点的关键字,并且左右子树同样也是二叉排序树。
- 中序遍历得到的是有序序列。
9.最优二叉树(哈夫曼树)
(1)定义
- 最优二叉树又称为哈夫曼树,是一类带权路径长度最短的树
- 路径:树中一个结点到另一个结点之间的通路
- 结点的路径长度:路径上的分支数目
- 树的路径长度:根节点到达每一个叶子节点之间的路径长度之和
- 权:节点代表的值
- 结点的带权路径长度:该结点到根结点之间的路径长度乘以该节点的权值
- 带权路径长度(树的代价):树的所有叶子节点的带权路径长度之和

(2)最优二叉树的构造
(3)哈夫曼编码
10.线索二叉树


七.图
1.图的定义和逻辑结构(“顶点” 和 “边” 构成)
- 定义:由 “顶点集(V)” 和 “边集(E)” 构成的结构,记为 G=(V,E)。
- 顶点(Vertex):数据元素(比如社交网络中的 “人”,地图中的 “城市”)。
- 边(Edge):顶点之间的关系(比如社交网络中 “朋友关系”,地图中 “城市间的道路”)。
- 逻辑特征:
- 没有 “一对一”“一对多” 限制,任意两个顶点之间都可以有边(“多对多”)。
- 没有 “根” 和 “父节点” 概念,所有顶点地位平等(除非是 “有向图” 的特殊结构)。
- 可以有循环(比如一个顶点自己和自己有边,叫 “自环”),也可以有多个边连接同一对顶点(叫 “平行边”)。
2. 图的核心分类(按 “边是否有方向” 和 “边是否有权重” 分)
按边的方向分:
- 无向图:边没有方向,顶点 u 和 v 之间的边表示 “双向关系”,记为 (u,v)(比如 “朋友关系”,你是我的朋友,我也是你的朋友)。例子:顶点 A、B、C,边 (A,B)、(B,C) → A 和 B 互通,B 和 C 互通。
- 有向图:边有方向,顶点 u 到 v 的边表示 “单向关系”,记为 < u,v>(比如 “关注关系”,你关注我,我不一定关注你)。例子:顶点 A、B、C,边 <A,B>、<B,C> → A 能到 B,B 能到 C,但 B 不能到 A,C 不能到 B。
按边的权重分:
- 无权图:边没有数值(只表示 “有没有关系”),上面的无向图、有向图默认都是无权图。
- 有权图(网图):边有一个 “权重”(表示关系的 “强度” 或 “代价”),记为 (u,v,w) 或 < u,v,w>(w 是权重,比如地图中 “城市间的距离”,社交网络中 “互动频率”)。例子:边 (A,B,5) 表示 A 到 B 的距离是 5,边 < B,C,3 > 表示 B 到 C 的距离是 3。
3.图的关键术语
顶点的度(Degree):
- 无向图:顶点 v 连接的边的数量(比如无向图中 A 连了 (A,B)、(A,C),A 的度是 2)。
- 有向图:分 “入度” 和 “出度”:
- 入度(In-degree):指向 v 的边的数量(比如有向图中边 <B,A>、<C,A>,A 的入度是 2)。
- 出度(Out-degree):从 v 出发的边的数量(比如有向图中边 <A,B>、<A,C>,A 的出度是 2)。常考题:无向图有 n 个顶点,e 条边,所有顶点的度之和是多少?→ 2e(每条边贡献 2 个度,比如 (A,B) 既算 A 的度,也算 B 的度)。
路径(Path):从顶点 u 到 v 的 “顶点序列”,序列中相邻顶点之间有边。
- 路径长度:路径中边的数量(比如 u→a→b→v,路径长度是 3)。
- 简单路径:路径中顶点不重复(比如 u→a→v,没有重复顶点;u→a→u,有重复顶点,不是简单路径)。
回路(Cycle):起点和终点相同的路径(比如 u→a→b→u,是回路)。
- 简单回路:除起点和终点外,其他顶点不重复(比如 u→a→v→u,是简单回路;u→a→b→a→u,不是)。
连通图(无向图):任意两个顶点之间都有路径(比如无向图 A-B-C,A 到 C 有路径 A-B-C,是连通图;如果图是 A-B 和 C-D,A 到 C 没路径,不是连通图)。
- 连通分量:非连通图中的 “最大连通子图”(比如 A-B 和 C-D 是两个连通分量)。
- 最少(n-1)个结点,最多n(n-1)/2个结点
强连通图(有向图):任意两个顶点 u 和 v,u 到 v 有路径,且 v 到 u 也有路径(比如有向图 A→B、B→C、C→A,A 到 B、B 到 A 都有路径,是强连通图)。
- 强连通分量:非强连通图中的 “最大强连通子图”。
- 最少n个结点,最多n(n-1)个结点
4.图的存储结构
(1)邻接矩阵(用二维数组存,适合顶点多、边密的图)
- 存储规则(以无向无权图为例,顶点数为 n):定义二维数组 adj [n][n],adj [i][j] 表示顶点 i 和 j 之间的边:
- adj [i][j] = 1:顶点 i 和 j 之间有边。
- adj [i][j] = 0:顶点 i 和 j 之间无边。
- 自环:adj [i][i] = 1(顶点 i 自己和自己有边)。
- 无向图的邻接矩阵特性:对称矩阵(adj [i][j] = adj [j][i],因为边 (i,j) 和 (j,i) 是同一条边)。
- 有向图的邻接矩阵:adj [i][j] = 1 表示有边 < i,j>(i 到 j),adj [j][i] 不一定等于 1(不对称)。
- 有权图的邻接矩阵:adj [i][j] = 边的权重(有边),adj [i][j] = ∞(无穷大,无边),adj [i][i] = 0(自环权重为 0)。
- 优点:判断两个顶点是否有边(O (1)),计算顶点的度(无向图看行 / 列和,有向图入度看列和、出度看行和)。
- 缺点:浪费空间(n 个顶点需要 n² 空间,边少的 “稀疏图” 会有大量 0 或∞,比如 n=1000,边 = 100,大部分空间没用)。
- 适用场景:稠密图(边多,比如 n 个顶点有 n (n-1)/2 条边的完全图)。
(2)邻接表(用 “数组 + 链表” 存,适合顶点多、边少的稀疏图)
- 存储规则(以无向无权图为例):
- 用一个数组(顶点数组)存储所有顶点,数组下标对应顶点编号(比如下标 0 存顶点 A,下标 1 存顶点 B)。
- 每个顶点对应一个 “链表”(边链表),链表中存储该顶点的 “邻接顶点”(即和该顶点有边的顶点)。
- 例子:无向图 A-B、A-C、B-C → 顶点数组 [0:A,1:B,2:C];A 的链表存 1(B)、2(C);B 的链表存 0(A)、2(C);C 的链表存 0(A)、1(B)。
- 有向图的邻接表:每个顶点的链表只存 “出边对应的邻接顶点”(比如有向边 A→B、A→C,A 的链表存 1、2;B 的链表不存 A)。
- 有权图的邻接表:链表节点除了存邻接顶点编号,还存边的权重(比如 A 到 B 的边权重 5,链表节点存 “1,5”)。
- 优点:节省空间(n 个顶点 + 2e 个边节点,e 是边数,稀疏图中 e 远小于 n²)。
- 缺点:判断两个顶点是否有边,需要遍历对应链表(O (k),k 是链表长度)。
- 适用场景:稀疏图(大部分图都是稀疏图,比如社交网络、地图,所以邻接表是最常用的存储方式)。
5.图的遍历
(1)深度优先搜索(DFS,“一条路走到黑”,用栈或递归)
- 核心思想:回溯到上一个,从起点顶点出发,优先访问 “未访问的邻接顶点”,直到走不通再 “回溯”(比如走迷宫,遇到岔路先选一条走到底,没路了退回来选另一条)。
- 关键:用一个 “访问标记数组(visited [])” 记录顶点是否已访问,避免重复。
- 步骤(递归实现,以邻接表存储的图为例):
- 初始化 visited 数组为 false(所有顶点未访问)。
- 选一个起点顶点 v,标记 visited [v] = true,访问 v(比如打印)。
- 遍历 v 的边链表,对每个邻接顶点 u:如果 visited [u] == false,递归执行步骤 2-3(访问 u)。
- 若还有未访问的顶点(比如非连通图),重复步骤 2-3。
- 非递归实现:用栈,把访问过的顶点 push,每次 pop 顶点后,push 其未访问的邻接顶点(注意顺序,要让 “先访问的邻接顶点后 push”,保证深度优先)。
(2)广度优先搜索(BFS,“一层一层走”,用队列)
- 核心思想:将对头的邻接结点入队后,队头出队。从起点顶点出发,先访问 “起点的所有邻接顶点”(第一层),再访问 “邻接顶点的邻接顶点”(第二层),依此类推(比如水面波纹,从中心向外扩散)。
- 关键:同样用 visited 数组标记,用队列保证 “层次顺序”。
- 步骤(以邻接表存储的图为例):
- 初始化 visited 数组为 false,队列初始化。
- 选起点 v,标记 visited [v] = true,访问 v,将 v enqueue。
- 队列不为空时,dequeue 顶点 u,遍历 u 的边链表:对每个邻接顶点 w,若 visited [w] == false,标记为 true,访问 w,将 w enqueue。
- 若还有未访问的顶点,重复步骤 2-3。
- 常考题:求 “两点之间的最短路径”(无权图中,BFS 遍历到目标顶点时的路径长度,就是最短路径长度)。
6.拓扑排序
八.查找
查找算法按 “数据是否有序” 分为静态查找(数据不动态变化,如顺序、二分查找)和动态查找(数据可插入 / 删除,如二叉搜索树、哈希表查找)。
1. 静态查找(数据固定,只查不改)
(1) 顺序查找(线性查找,最简单,不要求数据有序)
- 核心逻辑:从数组的第一个元素开始,逐个与 “目标值 key” 比较,直到找到或遍历完所有元素。
- 代码实现:
// 返回key在arr中的下标,没找到返回-1 public int sequentialSearch(int[] arr, int key) {for (int i = 0; i < arr.length; i++) {if (arr[i] == key) {return i;}}return -1; }
- 关键分析:
- 时间复杂度:最好 O (1)(第一个元素就是 key);最坏 O (n)(最后一个元素或没找到);平均 O (n);
- 空间复杂度:O (1);
- 适用场景:小规模数据,或数据无序(无法用二分查找)。
(2)二分查找(折半查找,效率高,但要求,顺序存储,数据必须有序)
- 核心逻辑:对有序数组,每次取 “中间元素” 与 key 比较:若中间元素 ==key,找到;若中间元素 > key,在左半部分找;若中间元素 < key,在右半部分找,直到找到或范围缩小为空。
- 代码实现:
public int binarySearch (int [] arr, int key) { int left = 0, right = arr.length - 1; while (left <= right) { // 注意是 <=,不是 <(避免漏查) int mid = left + (right - left) / 2; // 避免 (left+right) 溢出 if (arr [mid] == key) { return mid; } else if (arr [mid] > key) { right = mid - 1; // 左半部分找 } else { left = mid + 1; // 右半部分找 } } return -1; // 没找到 }
- 关键分析:
- 时间复杂度:O (logn)(每次缩小一半范围,类似二叉树的深度);
- 空间复杂度:迭代版 O (1),递归版 O (logn)(递归栈);
- 适用场景:大规模有序数据(如字典查单词、数据库索引查询)。
(3)分块查找
2.动态查找(数据可增删,需维护查找结构)
(1) 二叉搜索树查找(BST 查找,基于二叉搜索树的 “左小右大” 特性)
- 核心逻辑:从根节点开始,与 key 比较:若根 ==key,找到;若根 > key,递归查左子树;若根 < key,递归查右子树,直到找到或节点为空。
- 代码片段:
// 二叉搜索树节点 class TreeNode { int val; TreeNode left; TreeNode right; TreeNode (int val) { this.val = val; } } // 查找 key,返回节点,没找到返回 null public TreeNode bstSearch (TreeNode root, int key) { if (root == null || root.val == key) { return root; // 空树或找到,返回 } if (root.val > key) { return bstSearch (root.left, key); // 左子树找 } else { return bstSearch (root.right, key); // 右子树找 } }
(2) 哈希表查找(散列表查找,“直接通过 key 找位置”,效率最高)
- 关键分析:
- 时间复杂度:最好 O (logn)(树平衡);最坏 O (n)(树斜成链表);平均 O (logn);
- 空间复杂度:递归版 O (logn)(递归栈),迭代版 O (1);
- 适用场景:动态数据(需频繁插入 / 删除),且能保证树平衡(否则用红黑树、AVL 树)。
- 核心逻辑:用 “哈希函数” 把 key 映射为 “数组下标(哈希地址)”,把 key 存到对应下标位置;查找时,再用哈希函数算 key 的下标,直接访问数组对应位置,即可找到 key(理想情况)。
- 关键分析:
- 关键问题:
1.哈希表构造:
2.哈希冲突:不同 key 通过哈希函数得到相同下标(比如 key1=2,key2=12,哈希函数是 key%10,都映射到下标 2);
3.解决冲突的方法:
开放定址法:冲突时,按一定规则找下一个空位置(如线性探测:下标 + 1,直到找到空位置);
链地址法:每个下标对应一个链表,冲突的 key 都放到对应链表中(实际开发中最常用,如 Java 的HashMap
)。
- 代码片段
// 哈希表节点(链表节点) class HashNode { int key; HashNode next; HashNode (int key) { this.key = key; } } // 哈希表 class HashTable { private HashNode [] table; private int size; // 数组大小 public HashTable (int size) { this.size = size; table = new HashNode [size]; } // 哈希函数:key% size(简单取模) private int hash (int key) { return key % size; } // 插入 key public void insert (int key) { int index = hash (key); HashNode newNode = new HashNode (key); // 链表头插法 newNode.next = table [index]; table [index] = newNode; } // 查找 key,找到返回 true,否则 false public boolean search (int key) { int index = hash (key); HashNode curr = table [index]; while (curr != null) { if (curr.key == key) { return true; } curr = curr.next; } return false; } }
- 关键分析:
- 时间复杂度:理想 O (1)(无冲突,直接访问);最坏 O (n)(所有 key 都冲突,链表变长);平均 O (1)(合理设计哈希函数和数组大小,冲突少);
- 空间复杂度:O (n)(存储 n 个 key);
- 适用场景:大规模动态数据,对查找效率要求极高(如缓存、数据库索引、HashMap)。
3.小顶堆与大顶堆
九.排序
1.插入类排序(“逐个插入已排序区”,简单但需注意边界)
(1) 直接插入排序(基础中的基础,必写对代码)
核心逻辑:把数组分为 “已排序区(左)” 和 “未排序区(右)”,每次取未排序区第一个元素,插入到已排序区的合适位置(类似整理手牌)。
关键步骤(升序):
- 初始:已排序区只有
arr[0]
,未排序区从arr[1]
开始; - 取未排序区元素
temp = arr[i]
,在已排序区arr[0..i-1]
中从后往前找 “第一个≤temp” 的位置j
; - 把
arr[j+1..i-1]
元素后移一位,将temp
插入arr[j+1]
; - 重复直到未排序区为空。
- 初始:已排序区只有
代码实现(Java,带注释):
public void insertSort(int[] arr) {// 未排序区从i=1开始(i=0是已排序区)for (int i = 1; i < arr.length; i++) {int temp = arr[i]; // 保存当前要插入的元素(避免后移时被覆盖)int j = i - 1; // 已排序区的末尾指针// 从后往前找插入位置:比temp大的元素都后移while (j >= 0 && arr[j] > temp) { // 注意j>=0,避免数组越界arr[j + 1] = arr[j]; j--;}arr[j + 1] = temp; // 插入到j+1位置(j是最后一个≤temp的元素下标)} }
考试易错点:
- while 循环条件漏写
j >= 0
,导致数组越界(当 j=-1 时还会进入循环); - 插入位置写成
j
,实际应为j+1
(因为 j 是最后一个≤temp 的元素,插入到它后面)。
- while 循环条件漏写
核心分析:
- 时间复杂度:最好 O (n)(已升序)、最坏 O (n²)(已降序)、平均 O (n²);
- 空间复杂度:O (1)(原地排序);
- 稳定性:稳定(值相等时不移动,相对位置不变);
- 适用场景:小规模数据、接近有序的数据(如 Excel 简单排序)。
- 基本有序的序列,排序最少,局部有序,不是全局有序
- 稳定不能归位
(2) 希尔排序(直接插入的优化,考点在 “增量逻辑”)
核心逻辑:用 “增量 gap” 将数组分组(如 gap=5 时,下标 0、5、10... 为一组),每组内做直接插入排序;逐渐减小 gap(如 5→3→1),直到 gap=1(此时数组接近有序,最后一次直接插入排序效率极高)。
代码实现(Java,gap=length/2 递减):
public void shellSort(int[] arr) {// 增量gap初始为数组长度的一半,每次减为原来的一半for (int gap = arr.length / 2; gap > 0; gap /= 2) {// 遍历每组的未排序元素(从gap开始,每组元素间隔gap)for (int i = gap; i < arr.length; i++) {int temp = arr[i];int j = i - gap; // 每组已排序区的末尾指针(间隔gap)// 组内找插入位置,元素后移(步长为gap)while (j >= 0 && arr[j] > temp) {arr[j + gap] = arr[j];j -= gap;}arr[j + gap] = temp; // 插入到组内合适位置}} }
考试易错点:
- 组内元素后移时,步长写成 1(应为 gap,比如 gap=3 时,元素要移到 j+3 的位置);
- 增量序列选择不当(考试默认用 “gap=length/2→1”,不用深究其他序列)。
核心分析:
- 时间复杂度:平均 O (n^1.3)(取决于 gap 序列)、最坏 O (n²);
- 空间复杂度:O (1);
- 稳定性:不稳定(分组排序时,相等元素可能分到不同组,相对位置变化);
- 适用场景:中大规模数据(比直接插入快,比快排简单)。
- 不稳定不能归位
2.交换类排序(“通过交换逆序元素排序”,快排是核心)
(1) 冒泡排序(最简单交换排序,考点在 “优化逻辑”)
核心逻辑:相邻元素两两比较,逆序则交换(小元素 “浮” 到前面);若某一轮无交换,说明数组已有序,直接退出(优化关键)。
代码实现(Java,带优化):
public void bubbleSort(int[] arr) {boolean swapped; // 标记本轮是否发生交换(优化用)// 最多需要n-1轮(每轮确定一个最大元素的位置)for (int i = 0; i < arr.length - 1; i++) {swapped = false;// 从后往前比较:每轮后,前i个元素已有序(不用再比较)for (int j = arr.length - 1; j > i; j--) {if (arr[j] < arr[j - 1]) { // 逆序,交换int temp = arr[j];arr[j] = arr[j - 1];arr[j - 1] = temp;swapped = true;}}if (!swapped) break; // 无交换,数组已有序,退出} }
考试易错点:
- 内层循环条件写成
j < arr.length
(未排除已排序区,导致重复比较); - 漏加
swapped
优化(大数据量时效率极低,考试可能要求写出优化版)。
- 内层循环条件写成
核心分析:
- 时间复杂度:最好 O (n)(已优化)、最坏 O (n²)、平均 O (n²);
- 空间复杂度:O (1);
- 稳定性:稳定;
- 适用场景:小规模数据、判断数组是否有序(优化版效率高)。
- 稳定能归位
(2) 快速排序(“分治法” 经典,考试必写,重点在 “划分逻辑”)
核心逻辑:选 “基准元素 pivot”,通过 “划分” 将数组分为 “左半部分≤pivot” 和 “右半部分≥pivot”;递归对左右两部分重复划分,直到数组有序。
关键步骤(升序):
- 选基准:推荐选 “中间元素”(避免数组有序时的最坏情况);
- 划分:双指针
left
(左)、right
(右)遍历,把比 pivot 大的移到右,小的移到左,最后把 pivot 放到划分位置; - 递归:对左右子数组重复步骤 1-2。
代码实现(Java,双指针划分,选中间元素为基准):
public void quickSort(int[] arr) {if (arr.length <= 1) return;quickSortHelper(arr, 0, arr.length - 1); }// 递归排序arr[left..right] private void quickSortHelper(int[] arr, int left, int right) {if (left >= right) return; // 递归终止:子数组只有1个元素// 1. 选基准(中间元素),交换到left位置(方便后续处理)int mid = left + (right - left) / 2; // 避免left+right溢出swap(arr, left, mid);int pivot = arr[left]; // 基准元素// 2. 双指针划分int l = left, r = right;while (l < r) {// 从右找第一个≤pivot的元素(先动右指针,保证最后l=r位置元素≤pivot)while (l < r && arr[r] > pivot) r--;// 从左找第一个≥pivot的元素while (l < r && arr[l] <= pivot) l++;// 交换这两个元素(把大的移右,小的移左)if (l < r) swap(arr, l, r);}// 3. 把基准放到划分位置(l=r,此时该位置元素≤pivot)swap(arr, left, l);// 4. 递归排序左右子数组quickSortHelper(arr, left, l - 1);quickSortHelper(arr, l + 1, right); }// 交换数组元素 private void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp; }
考试易错点:
- 基准选择 “第一个元素” 且数组已有序(导致最坏复杂度 O (n²),选中间元素可避免);
- 双指针遍历顺序错(先动左指针,可能导致最后 l=r 位置元素 > pivot,交换后基准位置错误);
- 递归时边界错(写成
quickSortHelper(arr, left, l)
,导致死循环)。
核心分析:
- 时间复杂度:最好 O (nlogn)、最坏 O (n²)、平均 O (nlogn)(实际中最快的排序);
- 空间复杂度:O (logn)(递归栈深度,平衡时);
- 稳定性:不稳定;
- 适用场景:大规模数据(实际开发首选,如 Java 的
Arrays.sort()
对基本类型用快排)。 - 不稳定能归位
3.选择类排序(“选最小 / 大元素放到位”,重点在堆排序)
(1)直接选择排序(逻辑简单,考点在 “稳定性判断”)
(2) 堆排序(“利用堆特性排序”,考点在 “堆调整逻辑”)
核心逻辑:分 “已排序区(左)” 和 “未排序区(右)”,每次从末排序区选 “最小元素”,和未排序区第一个元素交换,加入已排序区。
代码实现(Java):
public void selectSort(int[] arr) {// 已排序区从i=0开始,每次确定i位置的元素for (int i = 0; i < arr.length - 1; i++) {int minIndex = i; // 记录未排序区最小元素的下标// 找未排序区(arr[i..n-1])的最小元素for (int j = i + 1; j < arr.length; j++) {if (arr[j] < arr[minIndex]) {minIndex = j;}}// 交换最小元素和未排序区第一个元素(避免自己和自己交换)if (minIndex != i) {swap(arr, i, minIndex);}} }
考试易错点:
- 漏加
minIndex != i
判断(当未排序区最小元素就是第一个时,没必要交换,不影响结果但浪费时间); - 误认为 “稳定”(实际不稳定,如序列
[3,2,2]
,第一次交换后变成[2,3,2]
,两个 2 的顺序变了)。
- 漏加
核心分析:
- 时间复杂度:无论好坏都是 O (n²)(每次都要遍历未排序区);
- 空间复杂度:O (1);
- 稳定性:不稳定;
- 适用场景:小规模数据、交换成本低的场景(如元素是大型对象,交换只需改指针)。
- 不稳定能归位
核心逻辑:先构建 “大根堆”(堆顶是最大元素);将堆顶与堆尾交换(最大元素放到数组末尾,成为已排序区);对剩余元素 “调整堆”,重复交换和调整,直到堆为空。
关键概念:
- 大根堆:每个父节点值≥子节点值(用于升序排序);
- 堆调整(Heapify):当某节点不符合堆特性时,与左右子节点中较大的交换,直到符合特性(核心操作)。
代码实现(Java):
public void heapSort(int[] arr) {int n = arr.length;// 1. 构建大根堆(从最后一个非叶子节点开始调整,下标n/2-1)for (int i = n / 2 - 1; i >= 0; i--) {heapify(arr, n, i);}// 2. 交换堆顶和堆尾,调整堆(每次确定一个最大元素的位置)for (int i = n - 1; i > 0; i--) {swap(arr, 0, i); // 堆顶(最大元素)放到数组末尾heapify(arr, i, 0); // 调整剩余i个元素为大根堆(堆大小变为i)} }// 调整堆:arr[0..size-1]是堆,确保节点i为根的子树是大根堆 private void heapify(int[] arr, int size, int i) {int maxIndex = i; // 初始化最大元素为当前节点int left = 2 * i + 1; // 左子节点下标(2i+1)int right = 2 * i + 2; // 右子节点下标(2i+2)// 找当前节点、左子、右子中的最大元素if (left < size && arr[left] > arr[maxIndex]) {maxIndex = left;}if (right < size && arr[right] > arr[maxIndex]) {maxIndex = right;}// 若最大元素不是当前节点,交换并递归调整子堆if (maxIndex != i) {swap(arr, i, maxIndex);heapify(arr, size, maxIndex); // 调整交换后的子堆} }
考试易错点:
- 构建堆时从 0 开始调整(应从最后一个非叶子节点
n/2-1
开始,否则叶子节点无需调整); - 堆调整时漏写
left < size
或right < size
(导致子节点下标越界); - 交换堆顶后,堆大小未更新(应传入
i
,而非n
,因为末尾元素已有序)。
- 构建堆时从 0 开始调整(应从最后一个非叶子节点
核心分析:
- 时间复杂度:构建堆 O (n)、调整堆 O (nlogn),总复杂度 O (nlogn)(无论好坏);
- 空间复杂度:O (1)(迭代版)或 O (logn)(递归版);
- 稳定性:不稳定;
- 适用场景:大规模数据、需随时获取最大元素的场景(如优先队列)。
- 每次归位最大的值不断构建大顶堆
- 不稳定能归位
(3)归并排序(“分治法 + 合并有序序列”,考点在 “合并逻辑”)
核心逻辑:用 “分治法” 把数组分成两个子数组,递归排序子数组(直到子数组只有 1 个元素,天然有序);用 “双指针” 合并两个有序子数组,直到整个数组有序。
代码实现(Java,带临时数组优化):
public void mergeSort(int[] arr) {if (arr.length <= 1) return;int[] temp = new int[arr.length]; // 临时数组(避免递归中重复创建,优化空间)mergeSortHelper(arr, 0, arr.length - 1, temp); }// 递归排序arr[left..right] private void mergeSortHelper(int[] arr, int left, int right, int[] temp) {if (left >= right) return;int mid = left + (right - left) / 2;// 1. 递归分解:排序左、右子数组mergeSortHelper(arr, left, mid, temp);mergeSortHelper(arr, mid + 1, right, temp);// 2. 合并两个有序子数组arr[left..mid]和arr[mid+1..right]merge(arr, left, mid, right, temp); }// 合并有序子数组,结果存到temp,再复制回arr private void merge(int[] arr, int left, int mid, int right, int[] temp) {int i = left; // 左子数组指针int j = mid + 1; // 右子数组指针int k = left; // 临时数组指针// 按大小合并到临时数组while (i <= mid && j <= right) {if (arr[i] <= arr[j]) { // 相等时取左子数组元素,保证稳定性temp[k++] = arr[i++];} else {temp[k++] = arr[j++];}}// 把左子数组剩余元素复制到临时数组while (i <= mid) {temp[k++] = arr[i++];}// 把右子数组剩余元素复制到临时数组while (j <= right) {temp[k++] = arr[j++];}// 把临时数组的有序元素复制回原数组for (k = left; k <= right; k++) {arr[k] = temp[k];} }
考试易错点:
- 合并时临时数组指针
k
初始化为 0(应初始化为left
,因为只合并left..right
区间,避免覆盖其他元素); - 漏复制剩余元素(左或右子数组可能有剩余,必须全部复制到临时数组);
- 误认为 “原地排序”(实际需要 O (n) 的临时数组,空间复杂度不是 O (1))。
- 合并时临时数组指针
核心分析:
- 时间复杂度:无论好坏都是 O (nlogn);
- 空间复杂度:O (n)(临时数组);
- 稳定性:稳定(合并时相等元素取左子数组的,相对位置不变);
- 适用场景:大规模数据、要求稳定排序的场景(如电商订单按 “价格 + 下单时间” 排序)。
- 稳定不归位