数据结构与算法(一)
数据结构分类
逻辑结构分类
逻辑结构是从具体问题中抽象出来的模型,是抽象意义上的结构,是按照对象中数据元之间的相互关系的分类。逻辑结构可以分为:集合结构、线性结构、树形结构和图形结构。
集合结构
集合结构:集合结构中的元素除了共同属于同一个集合有以外,没有其他的关系。
线性结构
线性结构中数据元素之间存在一对一的关系。
树形结构
树形结构中数据元素之间存在一对多的关系。
图形结构
图形结构中数据元素之间存在多对多的关系。
物理结构分类
逻辑结构在计算机中的映像称为物理结构,也可以称为存储结构。物理结构可以分为:顺序存储结构与链式存储结构。
顺序存储结构
把数据元素放到地址连续的存储单元里面,其数据间的逻辑关系和物理关系是一致的。e.g.数组及顺序存储。
链式存储结构
把数据元素随机放在任意的存储单元里面,这组存储单元有着任意的连续度,所以数据元素之间的物理关系并不对应元素的逻辑关系,因此链式存储结构使用指针存放数据元素的地址,可以通过地址找到相关数据元素的位置。
复杂度
算法复杂度是指算法在编写成可执行程序后,运行时所需要的资源,资源包括时间资源和内存资源。同一问题可用不同算法解决,而一个算法的质量优劣将影响到算法乃至程序的效率。是算法效率的度量方法。
而算法的复杂程度,我们通常分为时间复杂度与空间复杂度。
时间复杂度
时间复杂度刻画的就是运行指定代码,我们所需要耗费的时间。而度量时间复杂度的方法有两种:事后分析估算法和事前分析估算法。
事后分析估算法
事后分析估算法,即是在得到一段程序后,直接运行该程序并对其经行时间的测量,如下例子,这是一段关于求和的代码:
public static void main(String[] args){
long start = System.currentTimeMillis();
//设置代码运行前一刻时间节点
//测试的代码片
int sum =0;
int n=100;
for(int i =1; i<=n; i++){
sum +=i;
}
System.out.println("sum=" + sum);
long end = System.currentTimeMillis();
//设置测试代码结束时的时间节点
System.out.println(end-start);
//两时间节点做差,得到测试代码的运行时间
}
事前分析估算法,操作十分的简单,但是缺点也是十分明显,当我们测试十分庞大的工程时,直接运行测试的时间代价是非常大的,而且由于硬件设施的不同,不同设施对同一工程的测试结果也会存在差异。
事前分析估算法
事前分析估算法是在计算机程序编写前,依据统计方法对算法进行估算,经过研究总结,我们发现程序运行在计算机上所消耗时间主要取决于四个因素:
- 算法采用的策略和方案
- 编译产生的代码质量
- 问题的输入规模
- 机器执行指令的速度
而在上述四条因素中,第二条与第四条,分别依靠于编译器与计算机的硬件,我们可以影响到的就只有算法的策略方案与问题的输入规模。
如下还是求和的例子:
/**
*第一种算法
*如果输入量n为1,则需要计算1次;
*如果输入量n为N,则需要计算N次
/**
public static void main(String[] args) {
int sum = 0;
//执行一次
int n = 100;
//执行一次
for (int i = 0; i < n; i++) { //执行n+1次
sum += 1; //执行n次
}
System.out.println("sum=" + sum);
//执行一次
}
/**
*第二种算法
*如果输入量n为1,则需要计算1次;
*如果输入量n为N,则需要计算1次
/**
public static void main(String[] args) {
int sum = 0;
//执行一次
int n = 100;
//执行一次
sum = (1+n)*n/2;
//执行一次
System.out.println("sum=" + sum);
//执行一次
}
在我们分析算法的时候,我们可以忽略代码中条件语句,只关心核心操作的次数和输入规模的关系。
e.g.O(N2)、O(N)和O(1)三种复杂程度
我们通过图像可以看到随着输入量的不断增加,其算法的核心代码计算次数(复杂程度)呈现着不同的增长趋势。所以我们要做到预估时间复杂度,我们就需要得到这个函数。得到函数的方法被称为:函数渐进增长。
函数渐进增长
给定两个函数F(n)和G(n),如果存在一个整数N,使得对于所有的n>N,F(n)>G(n)恒成立,那么我们称F(n)的增长渐进快于G(n)。
我们可以得到一下几条性质:
- 随着输入量的增大时,算法的常数操作可以被忽略;
- 随着输入量的增大时,最高次项的系数也可以被忽略;
- 最高次项指数大的,随着输入量的增长,结果也会增长的更快,即:算法函数中n的最高次幂越小,算法的效率越高。
大O记法
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随着n的变化情况并确定T(n)的量级。算法的时间复杂度,就是算法的时间刻度,记作:T(n)=O(f(n)).它表示随着问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称时间复杂度,其中f(n)是问题规模n的某个函数。这里我们默认:执行次数=执行时间,而这里用O()来体现算法的时间复杂度的记法,我们称为:大O记法。通常情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优算法。
e.g.还是求和
//第一种算法
public static void main(String[] args) {
int sum = 0;
//执行一次
int n = 100;
//执行一次
for (int i = 0; i < n; i++) { //执行n+1次
sum += 1; //执行n次
}
System.out.println("sum=" + sum);
//执行一次
}
//第二种算法
public static void main(String[] args) {
int sum = 0;
//执行一次
int n = 100;
//执行一次
sum = (1+n)*n/2;
//执行一次
System.out.println("sum=" + sum);
//执行一次
}
//算法三
//计算100*1+100*2+···+100*100
public static void main(String[] args) {
int sum = 0;
int n = 100;
for (int i = 0; i <n ; i++) {
for (int j = 0; j <n; j++) {
sum += i;
}
}
System.out.println("sum=" + sum);
}
然后,我们可以分别用大O记法来表示,在表示的时候,我们可以运用到函数渐进增长推导的几个规则:
- 用常数1来取代运行时间中的所有的加法常数;
- 在修改后的运行次数中,只保留高阶项;
- 如果最高阶项存在,且常数因子不为1,则去除与这个项相乘的常数(去除最高阶项系数)
而这三种算法在输入规模为n时,算法执行的次数为:
- 算法一:n+3次
- 算法二:3次
- 算法三:n2+2次
所以按照上述方法,我们可以表示这三个算法的大O记法:
- 算法一:O(n)
- 算法二:O(1)
- 算法三:O(n2)
常见的大O阶
1.线性阶
O(n)就是线性阶,一般含有非嵌套循环(单层嵌套)涉及线性阶,线性阶就是随着输入规模的扩大,对应计算次数呈直线增长,e.g.
public static void main(String[] args) {
int sum = 0;
//执行一次
int n = 100;
//执行一次
for (int i = 0; i < n; i++) { //执行n+1次
sum += 1; //执行n次
}
System.out.println("sum=" + sum);
//执行一次
}
2.平方阶
平方阶即是O(n2),一般嵌套循环属于这种时间复杂度e.g.
public static void main(String[] args) {
int sum = 0;
int n = 100;
for (int i = 0; i <n ; i++) {
for (int j = 0; j <n; j++) {
sum += i;
}
}
System.out.println("sum=" + sum);
}
3.立方阶
O(n3)即为立方阶,一般三层嵌套属于这种时间复杂度e.g.
public static void main(String[] args) {
int g=0;
int n=100;
for (int i = 0; i <n ; i++) {
for (int j = 0; j <n; j++) {
for (int k = 0; k <n ; k++) {
g +=i;
}
}
}
}
对数阶
对数阶即是O(log n) e.g.
public static void main(String[] args) {
int i=1;
int n=100;
while(i<n){
i = i*2;
}
}
这里由于每次执行i=i*2操作后,i就会比i++更加接近于n。其循环次数(即计算次数)(int)N,有2^N>n,所以N>log(2) n。由于我们可以忽略n的最高次项的系数,这里我们我们也可以忽略对数阶的底,因为我们只是研究的是它们的增长趋势。
常数阶
常数阶是指O(1),一般不涉及循环操作的都是常数阶,因为它不会随着n的增长而增加操作次数。e.g.
public static void main(String[] args) {
int sum = 0;
//执行一次
int n = 100;
//执行一次
sum = (1+n)*n/2;
//执行一次
System.out.println("sum=" + sum);
//执行一次
}
在常见的时间复杂度中
他们的复杂度依次是:
O(1)<O(log n)<O(n)<O(nlog n)<O(n2)< O(n3)
且通过图像分析,我们也可以发现,从平方阶开始,随着输入规模的增大,其时间成本是急剧增长的。所以我们尽量追求的算法是O(nlog n)及以下的。
函数调用的时间复杂度分析
上述分析方法是基于对象是单个函数的分析方式,但在实际情况中,我们常常遇到的会是具有多个函数共同组建的工程。所以我们也需要一套分析混合方法的时间复杂度分析流程。
e.g.
public static void main(String[] args) {
int n=10;
for (int i = 0; i <n ; i++) {
write(i);
}
}
private static void write(int i) {
System.out.println(i);
}
这里我们是构造了一个write函数,然后将其放置于一个循环中。首先可以分层分析,write函数的时间复杂度就是O(1),可以把这个函数就当作一次计算操作,而在其外层进行了一次线阶嵌套,所以整个代码的复杂度就是O(n)。
例子二:
public static void main(String[] args) {
int n=10;
for (int i = 0; i <n ; i++) {
write(i);
}
for (int i = 0; i < n; i++) {
display(i, n);
}
}
private static void display(int i,int n) {
for (int j = 0; j < n; j++) {
write(i);
}
}
private static void write(int i) {
System.out.println(i);
}
对于 这串代码,就是在上个例子中加了一个O(n)线性复杂度的函数,这样我们就可以把display函数当作一层循环(因为同样复杂度结构之间可以相互替换),因此我们进行输入量为n时,进行的操作数是:n2+n+1,所以它的大O记法就是O(n2)。
另外,在没特殊说明的前提下,我们分析算法时间复杂度是指在最坏情况下的。
空间复杂度
内存占用(基于Java)
1.基本数据类型占据内存情况
数据类型 | 内存占用(bit) |
---|---|
byte | 1 |
short | 2 |
int | 4 |
long | 8 |
float | 4 |
double | 8 |
boolean | 1 |
char | 2 |
2.读取内存的最小量度
计算机访问内存的方式都是一次一个字节。
3.引用所占内存
一个引用(机器地址,变量)需要8个字节表示。例如
Bullet b = new Bullet();
//b这个变量需要用8个字节来表示
4.创建对象的内存分配
创建一个对象,除了给对象内部数据分配存储空间,还会对对象本身分配16个字节的内存开销,以保存对象的头信息。
5.存储的最小量度
一般内存的使用,存储内存的最小量度是8个字节,如果不够8个字节,会被自动填充为8个字节。例如:
public class A{
public int i =0;
}
这里类A内部存储int类型变量i占4个字节,A类自开销16个字节存储头信息,一共需要20个字节,20字节不为8字节的倍数,所以计算机会自动填充4个字节,一共占用24个字节。
6.数组的内存分配
数组被限定为对象,因此除了数组内部存储数据所占内存外,同样需要16个字节开销存储数组的头信息,另外,它还需要4个字节用于保存数组的长度,以及4个填充字节(16+4=20不足24需要填充4个字节,这样做是为了避免构造一个空数组而不符合内存存储的最小量度单位)。
算法空间复杂度表示
我们以一个范例说明,以下范例是一个将数组里的元素前后调换位置的两种不同算法写的方法
例子:
//方法一
public static int[] reverse1(int[] arr){
int n = arr.length; //使用4个字节
int temp; //使用4个字节
for(int start = 0,end = n-1;start <= end;start++, end--){
//int start, end; 这里需要8个字节
temp = arr[start];
arr[start] = arr[end];
arr[end] = temp;
}
return arr;
}
//上述需要16个字节,所以是O(16),空间复杂度也就是O(1)
//方法二
public static int[] reverse2(int[] arr){
int n = arr.length; //需要4个字节
int[] temp = new int[n];
//首先数组自身需开销24的字节
//然后因为申请了n个长度的int型变量就是4*n
for (int i = n-1; i >=0 ; i--) {
//这里int i;是使用了4个字节
temp[n-1-i] = arr[i];
}
return temp;
}
//算法二一共需要4*n+32个字节,空间复杂度为O(n)
在Java开发中,由于服务器的存储能力,我们一般也不会去在意空间的占用情况。