算法通关村第零关——热身
1 算法是什么
我们所涉及的算法,是计算机科学领域的算法,它的本质是一系列程序指令,用于解决特定的运算和逻辑问题。
算法有高效的,也有拙劣的。
例如计算100以内的累加和,可以1+2+3+...+100,一个一个算。 也可以直接用公式(1 + 100) * 100 / 2算出来。同样的问题,会有多种解法,而不同的解法在性能、执行时间、耗费资源等方面会有差异,这个一般用空间复杂度和时间复杂度来评价。
2 数据结构是什么
计算机从第二次世界大战的时候就已经开始使用了,经过这么多年的发展,大家发现,数据再复杂也是由如下几种简单的结构组成:集合、数组、链表、树、图。再复杂的系统,他的数据也是由这几种关系组成的。
在算法里,我们接触最多的数据结构类型如下:
3 初识几种常见的数据结构
1. 集合结构
集合结构中的元素除了属于同一个集合外,它们之间没有任何关系。各个元素是平等的,这与数学中的集合是一致的。
2. 线性结构
线性结构中的数据元素是一对一的关系,相互之间只与上一个和下一个连在一起。最典型的就是火车了。数组和链表都属于线性结构,有些地方则称之为线性表,都是一个意思。
- 数组结构是所有的元素依次靠在一起,中间不能有空位,就像队伍一样。
- 链表结构,则要求每个元素只要再保存其下一个元素的位置即可,就像铁链一样,因此叫链表。
线性表经过一些特殊的约束可以形成多种结构,例如:
- 只允许一头插入删除的就是栈。
- 只允许一头插入一头输出就是队列,使用不同的限制条件,队列也可有多种形式。这种用的多,类型也多,jdk中有庞大的工具类,这种都统一称为访问受限的线性结构。
- 还有一种更特殊的结构Hash,Hash本身是用数组存储的,但是访问却不是线性的,这与其访问策略有关系。(暂且放这里)
3. 树形结构
树形结构中的元素存在单向一对多的层次关系。典型的例子有很多,例如族谱、公司部门架构、全国行政区划等等。
用计算机的术语说,在树中,元素之间是一对多的关系。特点是:
- 开始元素唯一,终端元素不唯一。
- 除终端元素以外,每个元素有一个或多个后续元素。
- 除开始元素外,每个元素有且仅有一个前驱元素。
树的拓展类型也有很多,例如堆、平衡树、红黑树、哈夫曼树等等。在算法中经常考察的是二叉树,主要是因为二叉树简洁好写,但是在实际中我们更多使用N层的结构。而且jdk等本身也提供了大量的工具类来辅助我们的工作。
4. 图形结构
图形结构的数据元素是多对多的关系,典型的例子就是全国交通网络图等等。
图形结构的构造和处理都比较复杂,因此代码量会非常大,但是基本的深度优先和广度优先遍历等问题,使用树来考察更方便,因此在工作面试的环节极少出现。
4 算法与数据结构的关系
算法是解决问题的思想,数据结构是具体的实现载体,这就像战术与武器的关系。
任何算法,如果想做成计算机可以执行的程序,那必然要借助数据结构将算法进行细化和步骤化,这就是程序开发的设计方案。
在算法考察中,有相当一部分就是数据结构本身的增删改查和各种各样的变换。而在此基础之上,再考虑一些比较复杂的实际问题。
5 算法村20关安排
6 算法体系脉络
7 时间复杂度和空间复杂度
7.1 时间复杂度
7.1.1 算法如何度量
我们前面提到过,同一个问题可以有多种解决方式,那如何评价各种方式的好或者坏呢?
这其中最重要的就是时间复杂度和空间复杂度。 例如求累加和的算法,先看依次累加的执行过程:
void addOneByOne() {
int sum = 0, n = 100; //执行1次
for (int i = 0; i <= n; i++) { //执行了n+2次,最后一次判断不再满足要求,跳出
sum += i; //执行n + 1次
}
System.out.println("result is: " + sum); //执行1次
}
我们知道,也可以直接使用公式执行,过程如下:
void addByFormula() {
int sum = 0, n = 100; //执行1次
sum = (1 + n) * n / 2; //执行1次
System.out.println("result is: " + sum); //执行1次
显然,第一种执行了1+(n+2)+n+1+1=2n+5次,而第二种只执行了3次,高下立判。 假如上面改成一个100*100的二维数组,数组的每一行都是1到100,仍然有累加和乘法两种方式。累加法:
void addOneByOne(int arr[][], int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
sum += arr[i][j];
}
}
System.out.println("result is: " + sum);
}
而公式法则容易得多:
void addByFomula(int arr[][], int n) {
int sum = 0; //执行1次
sum = (1 + n) * n * n / 2; //执行1次
System.out.println("result is: " + sum); //执行1次
可以看到累加的执行次数远远高于公式法的执行次数,这个差异就更明显了,所以执行的步骤数是考察算法好坏的一个重要标准。
我们要记住一句话:时间复杂度的时间指的是用语句的执行次数,而不是实际的时间。我们知道计算机执行乘除运算是非常消耗资源的,而加减则计算很快,因此上面的例子不能说两者执行的时间差为2n^2+4n+2,因此时间复杂度只是一个简化的描述。
除了用次数表示时间进行简化,还有一个系数和参数方面的简化。上面的例子中我们找到了非常准确的描述计算次数的方法,但其实我们更关心当元素数量非常大时的变化情况。
例如y=3x^2^和y=x^2^+x+4,当x非常大时,此时前面的系数3和4的影响也不大,我们仍然可以只保留y=x^2^。
此时我们可以只考虑不同的阶之间的变化情况,如下可以看到过了1之后,不同的表达式的差异将非常巨大。
所以上面我们可以将表达式分别简化为:y=1,y=x和y=x^2^,也就是1阶,线性阶和平方阶。
要严格证明其关系需要考虑到渐进函数等数学问题,在很多算法书中有详细的解释,但是这个在算法面试中赢用不到,不再展开,只认为是简化就好了,这种表示就是O()记法,例如:
我们需要记住的是常见的阶耗费时间的关系是:
后面我们在表述算法的好坏一般都是直接说上面的O(*),而不会具体计算到底是多少。
7.1.2 几种常见的阶
虽然我们不需要证明上面的结论,但在写算法时需要能够判断当前代码的阶是多少,特别是O(logn),所以我们本节来看一下如何计算阶。 常见的阶如下:
(1)常数阶
一般顺序执行并且只执行一次的代码就是常数阶,例如上面的sum=(1+n)*n/2。
sum=sum+1;
sum=sum+2;
sum=sum+3;
sum=sum+4
sum=sum+1;执行了4遍,即使重复了100次,也都是常数级,都用O(1)表示。
(2)线性阶
线性阶表示执行的次数随着问题规模是线性变化的,例如下面的格式:
for(int i=0; i<n; i++) {
i++
}
还有while循环:
while(i<n) {
i++;
//todo
}
这里很显然执行todo的次数随着n是线性变化的,因此是线性阶。
(3)平方阶
平方阶主要是双层循环嵌套。
int i,j;
for(i=0;i<n;i++){
for(j=0;j<n;j++){
//todo 时间复杂度为1的程序
}
}
上面的todo随着n是呈平方级别增加的,n是2,就执行4次,n是3就执行9次,所以是平方阶。 这个问题我们可以改一下,将代码换成这样子:
int i,j;
for(i=0;i<n;i++){
for(j=i;j<n;j++){
//todo 时间复杂度为1的程序
}
}
此时的执行次数明显只有上面的一半,但是从级别看,仍然是平方阶,只不过执行次数只有一半了。(下图帮助理解上面代码的执行次数)
(4)对数阶
例如在之后要学习的二分查找,二叉树等问题经常会看到O(logn)。 其本质都可以简化成如下模型:
int count=1;
n=64;
int items=0;
while(count<n){
count=count*2;//todo 注意看的是这行的执行次数与n的关系
items++;
}
凡是处理时间复杂度,一定要注意一点:那就是代码体与循环结束条件之间的关系。 在上面的例子里,我们可以看一下todo的执行次数与count<n之间的关系:
| todo执行次数 | 第一次 | 第二次 | 第三次 | 第四次 | 第五次 | 第六次 |
|---|---|---|---|---|---|---|
| count | 1 | 2 | 4 | 8 | 16 | 32 |
从上面可以看到,入股todo要执行第七次时,已经不满足count<n了,所以执行次数与n的关系正好是 2^x^=n,也就是x=。 所以说,二分查找不过是每循环一次都是减半。所以todo的执行次数是。
7.2 时间复杂度练习
设n为正整数,试确定下列各程序段中前置以记号@的语句的频度:
//第一题
int i=1, k=0;
while(i<=n-1){
@ k += 10*i;
i++;
}
//第二题
int i=1, k=0;
do{
@ k += 10*i;
i++;
} while(i<=n-1);
//第三题
i=1; k=0;
while(i<=n-1){
i++;
@ k += 10*i;
}
//第四题
k=0;
for(i=1; i<=n; i++) {
for(j=i; j<=n; j++)
@ k++;
}
//第五题
for(i=1; i<=n; i++){
for(j=1; j<=i; j++){
for(k=1; k<=j; k++)
@ x+=delta;
}
//第六题
i=1; j=0;
while(i+j<=n){
@ if(i>j){
j++;
else{
i++;
}
}
//第七题
x=n; y=0;
while(x>=(y+1)*(y+1)){
@ y++;
}
//第八题
x=91; y=100;
while(y>0){
@ if(x>100)
{ x -= 10;
y--;
}
else
x++;
解析:
7.3 最坏情况和平均情况
最好情况、最差情况和平均情况。 算法分析时,假如我们查找的时候有n个随机数字,最好的情况是我们访问的第一个正好就是要找的,最坏的情况是将所有的数据查一遍才找到。不过大部分情况下我们都是在中间某个位置找到,因此这三种情况恰好反映了算法的上下边界和平均状态。我们后面分析排序等算法时一般都会考虑某种算法的平均水平怎么样、最坏情况怎么样,而具体如何判断,我们要具体情况具体分析。
7.4 空间复杂度
上面谈论的都是时间复杂度,还有一个是空间复杂度。这个非常简单,就看需要申请多少额外的空间来做现在的事情,例如你需要申请一个或者两三个变量,那么空间复杂度就是O(1),如果需要申请一个数组、链表、队列、栈或者Hash,其空间复杂度都是O(n),如果需要申请一个二维数组就是O(n^2^)。其他的极少使用,你申请O(n^2^)就已经到面试官能接受的极限了。