内容来源力扣和算法书籍。
一、数据结构
众所周知程序 = 数据结构 + 算法,因此数据结构有那些呢?
1、线性结构
线性结构是最简单的数据结构,包括数组、链表,以及由它们衍生 出来的栈、队列、哈希表。
2、树
树是相对复杂的数据结构,其中比较有代表性的是二叉树,由它又 衍生出了二叉堆之类的数据结构。
3、 图
图是更为复杂的数据结构,因为在图中会呈现出多对多的关联关系。
4、其他数据结构
除上述所列的几种基本数据结构以外,还有一些其他的千奇百怪的 数据结构。它们由基本数据结构变形而来,用于解决某些特定问题,如 跳表、哈希链表、位图等。
二、算法的复杂度
算法最重要的指标就是这两个家伙:时间复杂度与空间复杂度。说实话我对我这个数学不好的人来说说这两个家伙真的是太难顶了。
1、时间复杂度
什么是时间复杂度举个栗子:
我和老王写了一段代码功能一样、内存一样、我的运行时间是50s,老王的是5s。好家伙直接给吊打这怎么玩只能把时间复杂度搞懂了。
根据定义,时间复杂度指输入数据大小为 N 时,算法运行所需花费的时间。
要注意的是统计的是算法的「计算操作数量」,而不是「运行的绝对时间」。计算操作数量和运行绝对时间呈正相关关系,并不相等。(算法的运行速度受多方面因素影响,比如你的电脑配置,计算机语言,使用本地 IDE 或力扣平台提交)
直白地讲,时间复杂度就是把程序的相对执行时 间函数T(n)简化为一个数量级,这个数量级可以是n、n 2 、n 3 等。
2、常见时间复杂度种类
根据从小到大排列,常见的算法时间复杂度主要有:
O(1)<O(logN)<O(N)<O(NlogN)<O(N2)<O(2N)<O(N!)
来源:力扣(LeetCode)
1.常数 O(1) :
运行次数与 N大小呈常数关系,即不随输入数据大小 N 的变化而变化。
int algorithm(int N) {
int a = 1;
int b = 2;
int x = a * b + N;
return 1;
}
无论 a 取多大,都与输入数据大小 N 无关,因此时间复杂度仍为 O(1)。
int algorithm(int N) {
int count = 0;
int a = 10000;
for (int i = 0; i < a; i++) {
count++;
}
return count;
}
2.线性 O(N) :
循环运行次数与 N 大小呈线性关系,时间复杂度为 O(N)。
int algorithm(int N) {
int count = 0;
for (int i = 0; i < N; i++)
count++;
return count;
}
对于以下代码,虽然是两层循环,但第二层与 N 大小无关,因此整体仍与 N 呈线性关系
int algorithm(int N) {
int count = 0;
int a = 10000;
for (int i = 0; i < N; i++) {
for (int j = 0; j < a; j++) {
count++;
}
}
return count;
}
3.平方 O(N^2)
两层循环相互独立,都与 N 呈线性关系,因此总体与 N 呈平方关系,时间复杂度为 O(N^2) 。
int algorithm(int N) {
int count = 0;
for (int i = 0; i < N; i++) {
for (int j = 0; j < N; j++) {
count++;
}
}
return count;
}
4. 指数 O(2^N)
生物学科中的 “细胞分裂” 即是指数级增长。初始状态为 1 个细胞,分裂一轮后为 2 个,分裂两轮后为 4 个,……,分裂 N轮后有 2^N2个细胞。
算法中,指数阶常出现于递归,算法原理图与代码如下所示。
int algorithm(int N) {
if (N <= 0) return 1;
int count_1 = algorithm(N - 1);
int count_2 = algorithm(N - 1);
return count_1 + count_2;
}
5.阶乘 O(N!):
阶乘阶对应数学上常见的 “全排列” 。即给定 N 个互不重复的元素,求其所有可能的排列方案,则方案数量为:N×(N−1)×(N−2)×⋯×2×1=N!
如下图与代码所示,阶乘常使用递归实现,算法原理:第一层分裂出 N 个,第二层分裂出 N - 1 个,…… ,直至到第 NN层时终止并回溯。
int algorithm(int N) {
if (N <= 0) return 1;
int count = 0;
for (int i = 0; i < N; i++) {
count += algorithm(N - 1);
}
return count;
}
6.对数 O(\log N) :
对数阶与指数阶相反,指数阶为 “每轮分裂出两倍的情况” ,而对数阶是 “每轮排除一半的情况” 。对数阶常出现于「二分法」、「分治」等算法中,体现着 “一分为二” 或 “一分为多” 的算法思想。
设循环次数为 mm ,则输入数据大小 N与 2 ^ m2 , 呈线性关系,两边同时取 log_2 。 对数,则得到循环次数 m 与 log_2 N 。
int algorithm(int N) {
int count = 0;
float i = N;
while (i > 1) {
i = i / 2;
count++;
}
return count;
}
7.线性对数 O(N \log N):
两层循环相互独立,第一层和第二层时间复杂度分别为 O(\log N) 和 O(N) ,则总体时间复杂度为 O(N \log N)
int algorithm(int N) {
int count = 0;
float i = N;
while (i > 1) {
i = i / 2;
for (int j = 0; j < N; j++)
count++;
}
return count;
}
3、如何推导出时间复杂度呢?有如下几个原则。
(1)如果运行时间是常数量级,则用常数1表示。
(2)只保留时间函数中的最高阶项。
(3)如果最高阶项存在,则省去最高阶项前面的系数。
举个栗子:T(n) = 3n,
最高阶项为3n,省去系数3,则转化的时间复杂度为:T(n)=O(n),这个栗子就是线性 O(n)。
再举个栗子:T(n) = 0.5n 2 + 0.5n,
最高阶项为0.5n ^2 ,省去系数0.5,则转化的时间复杂度为:T(n) =O(n^ 2 ) ,这个是平方o(n^2)。
三、空间复杂度
空间复杂度涉及的空间类型有:
输入空间: 存储输入数据所需的空间大小;
暂存空间: 算法运行过程中,存储所有中间变量和对象等数据所需的空间大小;
输出空间: 算法运行返回时,存储输出数据所需的空间大小;
通常情况下,空间复杂度指在输入数据大小为 NN 时,算法运行所使用的「暂存空间」+「输出空间」的总体大小。
根据不同来源,算法使用的内存空间分为三类:
指令空间:
编译后,程序指令所使用的内存空间。
数据空间:
算法中的各项变量使用的空间,包括:声明的常量、变量、动态数组、动态对象等使用的内存空间
栈帧空间:
程序调用函数是基于栈实现的,函数在调用期间,占用常量大小的栈帧空间,直至返回后释放。如以下代码所示,在循环中调用函数,每轮调用 test() 返回后,栈帧空间已被释放,因此空间复杂度仍为 O(1) 。
根据从小到大排列,常见的算法空间复杂度有:
O(1)<O(logN)<O(N)<O(N2)<O(2N
1、空间复杂度种类
1.常数 O(1)O :
普通常量、变量、对象、元素数量与输入数据大小 NN 无关的集合,皆使用常数大小的空间。
void algorithm(int N) { int num = 0; int[] nums = new int[10000]; Node node = new Node(0); Map<Integer, String> dic = new HashMap<>() {{ put(0, "0"); }}; }
2.线性 O(N) :
元素数量与 NN 呈线性关系的任意类型集合(常见于一维数组、链表、哈希表等),皆使用线性大小的空间。
void algorithm(int N) {
int[] nums_1 = new int[N];
int[] nums_2 = new int[N / 2];
List<Node> nodes = new ArrayList<>();
for (int i = 0; i < N; i++) {
nodes.add(new Node(i));
}
Map<Integer, String> dic = new HashMap<>();
for (int i = 0; i < N; i++) {
dic.put(i, String.valueOf(i));
}
}
3.平方 O(N^2) :
元素数量与 NN 呈平方关系的任意类型集合(常见于矩阵),皆使用平方大小的空间。
void algorithm(int N) {
int num_matrix[][] = new int[N][N];
List<List<Node>> node_matrix = new ArrayList<>();
for (int i = 0; i < N; i++) {
List<Node> nodes = new ArrayList<>();
for (int j = 0; j < N; j++) {
nodes.add(new Node(j));
}
node_matrix.add(nodes);
}
}
4.指数 O(2^N):
指数阶常见于二叉树、多叉树。例如,高度为 NN 的「满二叉树」的节点数量为 2^N ,占用 O(2^N) 大小的空间;同理,高度为 NN 的「满 mm 叉树」的节点数量为 m^N ,占用 O(m^N) = O(2^N)大小的空间。
二、时间与空间的取舍
正所谓鱼和熊掌不可兼得,由于当代计算机的内存充足,通常情况下,算法设计中一般会采取「空间换时间」的做法,即牺牲部分计算机存储空间,来提升算法的运行速度。
方法一:暴力枚举
时间复杂度 O(N^2)
空间复杂度 O(1);属于「时间换空间」,虽然仅使用常数大小的额外空间,但运行速度过慢。
class Solution {
public int[] twoSum(int[] nums, int target) {
int size = nums.length;
for (int i = 0; i < size - 1; i++) {
for (int j = i + 1; j < size; j++) {
if (nums[i] + nums[j] == target)
return new int[] { i, j };
}
}
return new int[0];
}
}
方法二:辅助哈希表
时间复杂度 O(N)O(N) ,空间复杂度 O(N)O(N) ;属于「空间换时间」,借助辅助哈希表 dic ,通过保存数组元素值与索引的映射来提升算法运行效率,是本题的最佳解法。
class Solution {
public int[] twoSum(int[] nums, int target) {
int size = nums.length;
Map<Integer, Integer> dic = new HashMap<>();
for (int i = 0; i < size; i++) {
if (dic.containsKey(target - nums[i])) {
return new int[] { dic.get(target - nums[i]), i };
}
dic.put(nums[i], i);
}
return new int[0];
}
}