一、数据结构与算法
本文参考教程:01.01.01 数据结构与算法(第 01 ~ 02 天) (datawhalechina.github.io)
摘要:数据结构是程序的骨架,而算法则是程序的灵魂。本文梳理了在datawhale 01~02天学习到的数据结构与算法的入门知识,结合一些对应python中的代码的分析与解答,希望帮助大家入门数据结构及算法。
1.数据结构(Data Structure)
数据结构简单来说就是数据的组织结构,用来组织、存储数据。
1.1 数据的逻辑结构(Logical Structure)
1.集合结构:集合元素具有无序性、唯一性。
联系python中的集合特性,set集合的元素不可重复且无序,可修改,不支持下标索引:
set={1,2,3,3}
print(set)#输出结果为{1,2,3},不含重复元素
2.线性结构:数据之间具有一对一关系,线性结构中的数据元素(除了第一个和最后一个元素),左侧和右侧分别只有一个数据与其相邻。线性结构类型包括:数组、链表,以及由它们衍生出来的栈、队列、哈希表。
联系python中的数据结构,例如链表的定义,其中节点呈现出典型的一对一关系:
class Node:
def __init__(self, data):
self.data = data
self.next = None
以上是链表的数据结构示意图,与上面的线性数据结构图极其相似
3.树形结构:数据之间具有一对多关系,具体类型具有二叉树、多叉树、字典树等。
当然python中也有类似树形结构的代码:
class TreeNode:
def __init__(self, data):
self.data = data
self.children = []
def add_child(self, child):
self.children.append(child)
def remove_child(self, child):
if child in self.children:
self.children.remove(child)
root = TreeNode("A")# 创建树的节点
node_b = TreeNode("B")
node_c = TreeNode("C")
node_d = TreeNode("D")
# 构建树的关系
root.add_child(node_b)
root.add_child(node_c)
node_b.add_child(node_d)
该代码的树形结构图为:
4.图形结构
一个图形结构由结点和边组成,任意两个结点之间都可能相关,图形结构类型包括:无向图、有向图、连通图等。
python中具有图形结构的代码有:
class Graph:
def __init__(self):
self.graph = {}
def add_vertex(self, vertex):#通过add_vertex
方法,可以向图中添加一个顶点
if vertex not in self.graph:
self.graph[vertex] = []
def add_edge(self, v1, v2):#add_edge
方法用于添加两个顶点之间的边
if v1 in self.graph and v2 in self.graph:
self.graph[v1].append(v2)
self.graph[v2].append(v1)
# 创建图对象
graph = Graph()
# 添加顶点
graph.add_vertex("A")
graph.add_vertex("B")
graph.add_vertex("C")
# 添加边
graph.add_edge("A", "B")
graph.add_edge("B", "C")
graph.add_edge("A", "C")
该代码的结构图为:
1.2 数据的物理结构(Physical Structure)
数据的物理结构指数据的逻辑结构在计算机中的存储方式。
1.顺序储存结构(Sequential Storage Structure)
将数据元素存放在一片地址连续的存储单元里,数据元素之间的逻辑关系通过数据元素的存储地址来直接反映。
优点:简单、易理解,占用最少的存储空间。
缺点:需要占用一片地址连续的存储单元;并且存储分配要事先进行;另外对于移动、删除元素等操作的时间效率较低。
下面我们用python中列表元素的删除来体现一下顺序储存结构的特点:
原始列表: [A, B, C, D, E, F]
删除索引位置 2 的元素 C:
将索引位置 2 后面的所有元素向前移动一个位置 [A, B, D, E, F]
最终列表: [A, B, D, E, F]
从上图中的结构示意图我们可以发现顺序列表删除及移动元素需要对被移动元素及在移动过程中牵涉到的元素都进行顺序移动操作,所以其移动和删除元素的操作效率低下。
2.链式储存结构(Linked Storage Structure)
链式存储结构中,一般将每个数据元素占用的若干单元的组合称为链结点,链结点要存放一个数据元素的数据信息,以及存放一个指出这个数据元素在逻辑关系的直接后继元素所在链结点的地址,该地址被称为指针,数据元素之间的逻辑关系通过指针来间接反映。
优点:存储空间不必事先分配,插入、移动、删除元素的时间效率远比顺序存储结构高。
缺点:链式存储结构比顺序存储结构的空间开销大。
链式储存与顺序储存的结构图看起来差不多,为什么他会与顺序结构具有如此大的区别呢?让我们从python中的链表结构中寻找答案:
从上述示意图中可以看出,在链式存储结构中,插入元素的操作只需对相邻的几个节点进行简单的指针调整,不需要像顺序列表一样移动其他元素。这使得这些操作的时间复杂度较低,并且效率更高。因此,对于频繁进行插入、移动和删除操作的场景,链表是一个更好的选择。
2.算法(Algorithm)
解决特定问题求解步骤的准确而完整的描述,在计算机中表现为一系列指令的集合,算法代表着用系统的方法描述解决问题的策略机制。
简单而言,「算法」 指的就是解决问题的方法。
例如:python中常用的算法有:
-
排序算法:
-
冒泡排序(Bubble Sort)
-
选择排序(Selection Sort)
-
插入排序(Insertion Sort)
-
快速排序(Quick Sort)
-
归并排序(Merge Sort)
-
堆排序(Heap Sort)
-
-
查找算法:
-
顺序查找(Sequential Search)
-
二分查找(Binary Search)
-
-
图算法:
-
深度优先搜索(Depth-First Search,DFS)
-
广度优先搜索(Breadth-First Search,BFS)
-
最短路径算法(例如Dijkstra算法)
-
-
动态规划:
-
背包问题(Knapsack Problem)
-
最长公共子序列(Longest Common Subsequence)
-
最长递增子序列(Longest Increasing Subsequence)
-
-
树和图相关算法:
-
并查集(Disjoint Set)
-
最小生成树(Minimum Spanning Tree)
-
-
字符串匹配算法:
-
暴力匹配算法
-
KMP算法
-
-
数值计算和优化:
-
线性代数运算
-
梯度下降(Gradient Descent)
-
这些只是一些常见的算法示例,Python还有其他许多强大的第三方库,如NumPy、SciPy和Pandas,它们提供了更多的算法和数据处理功能。
2.1 算法的基本特性
算法其实就是一系列的运算步骤,这些运算步骤可以解决特定的问题。除此之外,算法 应必须具备以下特性:
1.输入:对于待解决的问题,都要以某种方式交给对应的算法。在算法开始之前最初赋给算法的参数称为输入。算法需要接收输入数据作为其操作的基础。在 Python 中,可以通过函数参数或用户输入来提供算法的输入。
def algorithm(input_data):
# 算法的操作
pass
# 通过函数参数提供输入数据
input_data = [1, 2, 3]
algorithm(input_data)
# 或者通过用户输入获取输入数据
input_data = input("请输入数据:")
algorithm(input_data)
2.输出:算法是为了解决问题存在的,最终总需要返回一个结果。所以至少需要一个或多个参数作为算法的输出。
def algorithm(input_data):
# 算法的操作
output_data = 1+input_data
return output_data
result = algorithm(input_data)
print(result)
3.有穷性:算法必须在有限的步骤内结束,并且应该在一个可接受的时间内完成。
def algorithm(input_data):
counter = 0
while counter < 10:
# 执行算法的循环操作
counter += 1
4.确定性:组成算法的每一条指令必须有着清晰明确的含义,不能令读者在理解时产生二义性或者多义性。就是说,算法的每一个步骤都必须准确定义而无歧义。
def algorithm(input_data):
if input_data > 0:
output_data = "正数"
elif input_data == 0:
output_data = "0"
else:
output_data = "负数"
return output_data
5.可行性:算法的每一步操作必须具有可执行性,在当前环境条件下可以通过有限次运算实现。也就是说,每一步都能通过执行有限次数完成,并且可以转换为程序在计算机上运行并得到正确的结果。
def algorithm(input_data):
# 算法的具体操作
for i in range(input_data):#在循环中修改参数会导致逻辑混乱并可能产生意外结果。
input_data+=1
return input_data
正确示范:
def algorithm(input_data):
count = 0
for i in range(input_data):
count += 1
return count+input_data
2.2 算法追求的目标
一个优秀的算法至少应该追求以下两个目标:
-
所需运行时间更少(时间复杂度更低);
-
占用内存空间更小(空间复杂度更低)。
除了对运行时间和占用内存空间的追求外,一个好的算法还应该追求以下目标:
1.正确性:能够通过典型的软件测试,达到预期的需求。
2.可读性:算法遵循标识符命名规则,简洁易懂,注释语句恰当,方便自己和他人阅读。
3.健壮性:算法对非法数据以及操作有较好的反应和处理。
即使是同一道题,根据时间复杂度和空间复杂度也可以有很大的差距,养成好的编程习惯,可以提高我们的程序水平。
二、 算法复杂度(Algorithm complexity)
摘要:「算法分析」的目的在于改进算法。正如上文中所提到的那样:算法所追求的就是 所需运行时间更少(时间复杂度更低)、占用内存空间更小(空间复杂度更低)。所以进行「算法分析」,就是从运行时间情况、空间使用情况两方面对算法进行分析。
1.时间复杂度(Time Complexity)
在问题的输入规模为 n 的条件下,算法运行所需要花费的时间,可以记作为 T(n)。
我们将 基本操作次数 作为时间复杂度的度量标准。换句话说,时间复杂度跟算法中基本操作次数的数量正相关。
计算时间复杂度的公式为:
T(n)称作算法的 渐进时间复杂度(Asymptotic Time Complexity),简称为 时间复杂度。
下面我们通过一个例子来说明如何计算时间复杂度:
def algorithm(n):
fact = 1
for i in range(1, n + 1):
fact *= i
return fact
把上述算法中所有语句的执行次数加起来 1+n+n+1=2n+2,可以用一个函数 f(n) 来表达语句的执行次数:f(n)=2n+2。
1.1 渐进符号
1.1.1渐进紧确界符号
渐进紧确界符号是指在对于一个非负的函数f(n),我们用 Θ(g(n)) 表示它的下界和上界都与一个函数 g(n) 相等。公式为:
其图示如下:
1.1.2渐进上界符号
渐进上界符号表示了一个函数的增长速度的一个上限,我们用 f(n)=O(g(n))来表示f(n)≤c⋅g(n),f(n) 的增长速度不超过g(n) 乘以一个常数。这个常数 c 可以是任意正常数,它代表着函数增速的上界。
其图示如下:
1.1.3渐进下界符号
渐进下界符号表示了一个函数增长速度的一个下限,对于一个函数 f(n),如果存在正常数 c 和 n0,对于所有 n ≥ n0,有 f(n) ≥ c · g(n),则我们可以说 f(n) 是 Ω(g(n)),记作 f(n) = Ω(g(n))
其图示如下:
1.2 时间复杂度计算
1.找出算法中的基本操作(基本语句)
2.计算基本语句执行次数的数量级
3.用大 O 表示法表示时间复杂度
同时,在求解时间复杂度还要注意一些原则:
加法原则:总的时间复杂度等于量级最大的基本语句的时间复杂度。
乘法原则:循环嵌套代码的复杂度等于嵌套内外基本语句的时间复杂度乘积。
1.2.1常数O (1)
一般情况下,只要算法中不存在循环语句、递归语句,其时间复杂度都为 O(1),只要代码的执行时间不随着问题规模的增大而增长。
1.2.2线性O (n)
一般含有非嵌套循环,且单层循环下的语句执行次数为n的算法涉及线性时间复杂度。这类算法随着问题规模n的增大,对应计算次数呈线性增长。
1.2.3平方O (
)
一般含有双层嵌套,且每层循环下的语句执行次数为 n 的算法涉及平方时间复杂度。这类算法随着问题规模 n 的增大,对应计算次数呈平方关系增长。
1.2.4阶乘O(n!)
阶乘时间复杂度一般出现在与「全排列」、「旅行商问题暴力解法」相关的算法中。这类算法随着问题规模 n 的增大,对应计算次数呈阶乘关系增长。
1.2.5对数O(log n)
对数时间复杂度一般出现在「二分查找」、「分治」这种一分为二的算法中。这类算法随着问题规模 n 的增大,对应的计算次数呈对数关系增长。
1.2.6线性对数O(n*log n)
线性对数一般出现在排序算法中,例如「快速排序」、「归并排序」、「堆排序」等。这类算法随着问题规模 n 的增大,对应的计算次数呈线性对数关系增长。
1.2.7常见的时间复杂度关系
1.3 最佳、最坏、平均时间复杂度
最佳时间复杂度:每个输入规模下用时最短的输入所对应的时间复杂度。
最坏时间复杂度:每个输入规模下用时最长的输入所对应的时间复杂度。
平均时间复杂度:每个输入规模下所有可能的输入所对应的平均用时复杂度(随机输入下期望用时的复杂度)。
2.空间复杂度(Space Complexity)
在问题的输入规模为 n 的条件下,算法所占用的空间大小,可以记作为 S(n)。一般将 算法的辅助空间 作为衡量空间复杂度的标准。
空间复杂度公式为:
S(n)=O(f(n))
2.1 空间复杂度计算
2.1.1常数O(1)
表示算法使用的额外空间是固定的,与输入规模无关。换句话说,算法所需的额外空间是常数级别的。
2.1.2线性O(n)
表示算法的额外空间使用随着输入规模的增加而线性增长。换句话说,算法所需的额外空间与输入规模成正比。可能是用一个大小与输入规模相等的数据结构来存储数据,或者使用递归调用时的栈空间。
2.1.3常见空间复杂度关系
3.时间复杂度及空间复杂度实例
通过实例表现在编程中注重时间与空间复杂度的重要性
解法一(简单直白):
class Solution:
def isReachableAtTime(self, sx: int, sy: int, fx: int, fy: int, t: int) -> bool:
#判断 sx
和 fx
的大小关系以及 sy
和 fy
的大小关系来确定需要沿着哪个方向移动。
#如果 sx
小于 fx
且 sy
小于 fy
,则需要向右上方移动
if (sx<fx)&(sy<fy):
t_0=0
while (sx<fx)&(sy<fy):
sx+=1
sy+=1
t_0+=1
return (t_0+(fx-sx)+(fy-sy))<=t
#如果 sx
小于 fx
且 sy
大于 fy
,则需要向右下方移动
if(sx<fx)&(sy>fy):
t_0=0
while (sx<fx)&(sy>fy):
sx+=1
sy-=1
t_0+=1
return (t_0+(fx-sx)+(sy-fy))<=t
#如果 sx
大于 fx
且 sy
小于 fy
,则需要向左上方移动
if (sx>fx)&(sy<fy):
t_0=0
while (sx>fx)&(sy<fy):
sx-=1
sy+=1
t_0+=1
return (t_0+(sx-fx)+(fy-sy))<=t
#如果 sx
大于 fx
且 sy
大于 fy
,则需要向左下方移动
if (sx>fx)&(sy>fy):
t_0=0
while (sx>fx)&(sy>fy):
sx-=1
sy-=1
t_0+=1
#在移动的过程中,累加移动的时间,并将其与最大时间 t
进行比较
return (t_0+(sx-fx)+(sy-fy))<=t
if (sx==fx)&(sy>fy):
return (sy-fy)<=t
if (sx==fx)&(sy<fy):
return (fy-sy)<=t
if (sx==fx)&(sy==fy):
if t!=1:
return True
else:
return False
if (sx>fx)&(sy==fy):
return (sx-fx)<=t
if (sx<fx)&(sy==fy):
return (fx-sx)<=t
#该代码运用了非常多的if else判断,可读性较差,此外还用了很多的while循环,时间复杂度较高,每次循环最多移动一个单位的距离,时间复杂度在最坏情况下是 O(d),d = |sx - fx| + |sy - fy|这会导致一个问题:
这就是在写代码的过程中不注意时间复杂度的后果。
解法二:
class Solution:
def isReachableAtTime(self, sx: int, sy: int, fx: int, fy: int, t: int) -> bool:
#通过计算起始点和目标点在 x 方向上的差值 dx 和在 y 方向上的差值 dy。
if sx>fx:
dx=sx-fx
else:
dx=fx-sx
if sy>fy:
dy=sy-fy
else:
dy=fy-sy
#如果 dx 和 dy 均大于 t,则无法在给定时间内到达目标点,返回 False。
if (dx>t)&(dy>t):
return False
else:
#如果 dx 和 dy 都为 0,则表示起始点和目标点重合。如果 t 不等于 1,则可以在给定时间内到达目标点
if (dx==0)&(dy==0):
if t!=1:
return True
else:
return False
else:
#如果 dx 和 dy 都不为 0,则需要根据它们的大小关系进行判断。如果 dx 大于 dy,则先沿 x 轴移动 dx-dy 的距离,然后按照剩余时间 t-dy 移动剩余的距离。否则,先沿 y 轴移动 dy-dx 的距离,然后按照剩余时间 t-dx 移动剩余的距离。
if (dx!=0)&(dy!=0):
if dx>dy:
dx=dx-dy
t=t-dy
dy=0
t=t-dx-dy
else:
dy=dy-dx
t=t-dx
dx=0
t=t-dx-dy
else:
#最后,根据剩余时间 t 的值判断是否能够在给定时间内到达目标点。
t=t-dx-dy
if t<0:
return False
else:
return True
而优化后的效果明显比前一个要好很多:
还有一些关于时间复杂度和空间复杂度讲解的链接:
http://https://github.com/UCodeUStory/DataStructure
三、LeetCode入门及攻略
1.LeetCode
网址:力扣(LeetCode)官网 - 全球极客挚爱的技术成长平台
「LeetCode」 是一个代码在线评测平台(Online Judge),包含了 算法、数据库、Shell、多线程 等不同分类的题目,其中以算法题目为主。我们可以通过解决 LeetCode 题库中的问题来练习编程技能,以及提高算法能力,支持 16+ 种编程语言(C、C++、Java、Python 等)。
1.1 LeetCode注册
-
打开 LeetCode 中文主页,链接:力扣(LeetCode)官网。
-
输入手机号,获取验证码。
-
输入验证码之后,点击「登录 / 注册」,就注册好了。
1.2 LeetCode题库
「题库」是 LeetCode 上最直接的练习入口,在这里可以根据题目的标签、难度、状态进行刷题。也可以按照随机一题开始刷题。
1.2.1 题目标签
LeetCode 的题目涉及了许多算法和数据结构。有贪心,搜索,动态规划,链表,二叉树,哈希表等等,可以通过选择对应标签进行专项刷题,同时也可以看到对应专题的完成度情况。
1.2.2 题目列表
LeetCode 提供了题目的搜索过滤功能。可以筛选相关题单、不同难易程度、题目完成状态、不同标签的题目。还可以根据题目编号、题解数目、通过率、难度、出现频率等进行排序。
1.2.3 当前进度
当前进度提供了一个直观的进度展示。在这里可以看到自己的练习概况。进度会自动展现当前的做题情况。也可以点击「进度设置」创建新的进度,在这里还可以修改、删除相关的进度。
1.2.4 题目详情
从题目大相关题目点击进去,就可以看到这道题目的内容描述和代码编辑器。在这里还可以查看相关的题解和自己的提交记录。
1.3 刷题语言
大厂在面试算法的时候考察的是基本功,用什么语言没有什么限制,也不会影响成绩。日常刷题建议使用自己熟悉的语言,或者语法简洁的语言刷题。
1.4 刷题流程
- 在 LeetCode 题库中选择一道自己想要解决的题目。
- 查看题目左侧的题目描述,理解题目要求。
- 思考解决思路,并在右侧代码编辑区域实现对应的方法,并返回题目要求的结果。
- 如果实在想不出解决思路,可以查看题目相关的题解,努力理解他人的解题思路和代码。
- 点击「执行代码」按钮测试结果。
- 如果输出结果与预期结果不符,则回到第 3 步重新思考解决思路,并改写代码。
- 如果输出结果与预期符合,则点击「提交」按钮。
- 如果执行结果显示「编译出错」、「解答错误」、「执行出错」、「超出时间限制」、「超出内存限制」等情况,则需要回到第 3 步重新思考解决思路,或者思考特殊数据,并改写代码。
- 如果执行结果显示「通过」,恭喜你通过了这道题目。
1.5 刷题攻略
1.5.1 前期准备
那么在刷 LeetCode 之前,建议先学习一下基础的 「数据结构」 和 「算法」 知识。
1.5.2 刷题顺序
LeetCode 官方网站上就有整理好的题目不错的刷题清单。链接为:力扣https://leetcode.cn/leetbook/。可以先刷这里边的题目卡片。我这里也做了一个整理。
推荐刷题顺序和目录如下:
1. 初级算法、2. 数组类算法、3. 数组和字符串、4. 链表类算法、5. 哈希表、6. 队列 & 栈、7. 递归、8. 二分查找、9. 二叉树、10. 中级算法、11. 高级算法、12. 算法面试题汇总。
1.5.3 刷题技术
- 五分钟思考法: 5 分钟之内有思路,就立即动手写代码解题。如果 5 分钟之后还没有思路,就直接去看题解。
- 重复刷题:遇见不会的题,多刷几遍,不断加深理解。
- 按专题分类刷题:按照专题分类刷题,既可以巩固刚学完的算法知识,还可以提高刷题效率。
- 写解题报告:如果能够用简介清晰的语言让别人听懂这道题目的思路,那就说明你真正理解了这道题的解法。
- 坚持刷题:算法刷题没有捷径,只有不断的刷题、总结,再刷题,再总结。
四、练习题目
1. 2235.两整数相加
个人题解:
class Solution:
def sum(self, num1: int, num2: int) -> int:
return num1+num2#直接返回两数之和即可
2. 1929.数组串联
个人题解:
class Solution:
def getConcatenation(self, nums: List[int]) -> List[int]:
answer=nums[:]+nums[:] #nums[:]表示遍历数组的全部元素,两数组拼接可直接用“+”
return answer
3. 0771.宝石与石头
个人题解:
class Solution:
def numJewelsInStones(self, jewels: str, stones: str) -> int:
count=0 #定义变量用于遍历计数并返回结果
for i in range(len(jewels)): #两层嵌套的循环遍历字符串 jewels
和 stones
for j in range(len(stones)):
if stones[j]==jewels[i]: #对于每一个 jewels
中的字符和 stones
中的字符进行比较
count+=1 #如果相等,则计数器 count
自增 1
return count #字符串 stones
中出现的字符同时也在字符串 jewels
中出现的次数。
4. 1480.一维数组的动态和
个人题解:
#这种方法的时间复杂度与空间复杂度均较高
class Solution:
def runningSum(self, nums: List[int]) -> List[int]:
#通过列表推导式遍历原始列表 nums
的每个元素
#然后使用切片操作取出当前元素及之前的所有元素,并计算它们的和
nums_0 = [sum(nums[:i+1]) for i in range(len(nums))]
return nums_0
5. 0709.转换为小写字母
个人题解:
class Solution:
def toLowerCase(self, s: str) -> str:
#使用 lower()
方法将输入字符串 s
转换为小写字母形式,并返回转换后的结果。
return s.lower()
6. 1672.最富有客户的资产总量
个人题解:
class Solution:
def maximumWealth(self, accounts: List[List[int]]) -> int:
count = [0] * len(accounts)
#通过两层循环遍历 accounts
列表的每一个元素
for i in range(len(accounts)):
for j in range(len(accounts[i])):
#计算子列表中所有元素的和,存储到 count
列表的对应位置
count[i]+=accounts[i][j]
#使用 max()
函数找出 count
列表中的最大值,即为子列表元素和的最大值
return max(count)
结尾附上关于 LeetCode 的一些up主的视频,不仅有力扣的每日一题,还会讲解每一次力扣周赛的
题目等等:
https://space.bilibili.com/7836741?spm_id_from=333.788.b_765f7570696e666f.1
https://space.bilibili.com/28610170
然后是书籍推荐,学习数据结构与算法的话,不要过度追求上去就看经典书 ,入门的同学可以看《大话数据结构》和《算法图解》,能让你对数据结构和算法有个大概的认识。