目录
1.3 空间复杂度 ( Space Complexity )
1. 算法复杂度分析总结
复杂度分析,它几乎占了数据结构与算法这门课的半壁江山,是数据结构和算法的精髓。时间复杂度和空间复杂度的分析有助于我们产出高质量的代码,能区分一流工程师和三流的工程师。
我们可以粗略地把复杂度分析分为两类,多项式量级和非多项式量级。其中非多项式量级只有两个:O()和 O( N ! )。
我们把时间复杂度为非多项式量级的算法问题叫做 NP(Non-Deterministic Polynomial, 非确定多项式)问题。当数据规模 N 越来越大时,非多项式量级算法执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的。
1.1 复杂度分析法则
加法法则:如果 T1 ( N ) = O ( f( N )) 且 T2 ( N ) = O ( g(n) ),
那么,T1 ( N ) + T2 ( N ) = max ( O ( f( N ) ,O ( g(n) ) )。
即,总复杂度等于量级最大的那段代码的复杂度。
乘法法则:如果 T1 ( N ) = O ( f( N )) 且 T2 ( N ) = O ( g(n) ),
那么,T1 ( N ) * T2 ( N ) = O ( f( N ) * O ( g(n) )。
即,嵌套代码的复杂度等于嵌套内外代码复杂度的乘积。
优先法则:只关注循环执行次数最多的一行代码。
1.2 时间复杂度 ( Time Complexity )
1.2.1 常数阶 O(1)
O (1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行的代码。比如这段代码,即便只有 3 行,它的时间复杂度是也是 O ( 1 ),而不是 O ( 3 )。
int i = 3;
int j = 3;
int sum = i + j;
只要代码的执行时间不随 N 的增大而增长,这样代码的时间复杂度我们都记作 O ( 1 )。或者说,一般情况下,只要算法中不存在循环语句和递归语句,即使有成千上万行的代码,其时间复杂度也是 O ( 1 )。
1.2.2 对数阶 O (log N)
对数阶时间复杂度非常常见,同时也是最难分析的一种时间复杂度。我通过一个例子来说明一下。
i = 1;
while( i <= n )
{
i = i * 2;
}
根据我们前面讲的复杂度分析算法,第三行代码是循环次数最多的。所以,我们只要能计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。
从代码中可以看出,变量 i 的值从 1 开始取,每循环一次就乘以 2。当 i > n 时,循环结束。还记记得高中学过的等比数列吗?实际上,变量 i 的取值就是一个等比数列。如果我把他一个一个列出来,就应该是这个样子的:
所以,我们只要知道 X 的值,就知道这行代码执行的次数,从而算出整段代码的时间复杂度。故 求解 X ,解出
,即这段代码的时间复杂度就是
。
现在,我把代码稍微改一下,你在看看,这段代码的时间复杂度是多少?
i = 1;
while( i <= n)
{
i = i * 3;
}
根据刚刚讲的思路,很简单就能看出来,这段代码的时间复杂度为 。
由对数换底公式可得:
所以 , 其中
是一个常量。在采用大 O 标记复杂度的时候,可以忽略系数,即
,故
。因此,在对数阶的时间复杂度表示方法里,我们忽略对数的 “底” ,统一表示为
。
所以,不管是以 2 为底、以 3 为底还是以 10 为底,我们可以把所有对数阶的时间复杂度都记为 。
1.2.3 线性阶 O ( N )
代码执行 N 次,其算法时间复杂度为线性阶,其最经典的例子就是数组的遍历。
for( int i = 0; i < N ; i++)
{
printf("%d",arr[ i ]);
}
1.2.4 线性对数阶 O ( N logN )
如果你理解了我前面讲的对数阶时间复杂度 ,那么
就很容易理解了。如果一段代码的时间复杂度是
,我们将其循环执行了 N 遍,时间复杂度就是
。而且,
也是一种非常常见的算法时间复杂度。归并排序和快速排序的时间复杂度都是
。
#include <stdio.h>
#define SIZE 6
//快速排序
void quick_sort(int num[], int low, int high )
{
int i,j,temp;
int tmp;
i = low;
j = high;
tmp = num[low]; //任命为中间分界线,左边比他小,右边比他大,通常第一个元素是基准数
if(i > j) //如果下标i大于下标j,函数结束运行
{
return;
}
while(i != j)
{
while(num[j] >= tmp && j > i)
{
j--;
}
while(num[i] <= tmp && j > i)
{
i++;
}
if(j > i)
{
temp = num[j];
num[j] = num[i];
num[i] = temp;
}
}
num[low] = num[i];
num[i] = tmp;
quick_sort(num,low,i-1);
quick_sort(num,i+1,high);
}
int main(int argc , char **argv)
{
//创建一个数组
int num[SIZE] ={0};
int i;
//输入数字
for(i =0; i < SIZE; i++)
{
scanf("%d",&num[i]);
}
quick_sort(num, 0, SIZE-1);
for(i = 0; i < SIZE; i++)
{
printf(" %d ", num[i]);
}
return 0;
}
1.2.5 平方阶 O ( N^2 )
两次嵌套的 for 循环,其时间复杂度为平方阶。
for( int i = 0; i < N; i++)
{
for(int j = 0; j < N; j++)
{
k++;
}
1.2.6 立方阶 O ( N^3 )
三层嵌套的 for 循环,其时间复杂度为立方阶。
for( int i = 0; i < N; i++)
{
for(int j = 0; j < N; j++)
{
for(int z = 0; z < N; z++)
{
k++;
}
1.2.7 O ( m + n) 和 O( m * n )
我在来描述一种跟前面都不一样的时间复杂度,代码的时间复杂度由两个数据的规模来决定。老规矩,先看代码!
int cal(int m, int n)
{
int sum1 = 0;
for(int i = 1; i < m; i++)
{
sum1 = sum1 + i;
}
int sum2 = 0;
for(int j = 1; i < n; j++)
{
sum2 = sum2 + i;
}
return sum1 + sum2;
}
从代码中可以看出,m 和 n 是表示两个数据规模。我们无法事先评估 m 和 n 谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上述代码的时间复杂度就是 O ( m + n)。
针对这种情况,原来的加法法则就不正确了,我们需要将加法法则规则改为:T1(m) + T2(n) = O ( f (m) + g (n) )。但是乘法法则继续有效:T1(m) * T2(n) = O ( f (m) * f (n) )。
综上所述,越高阶时间复杂的算法,执行效率越低;数据规模 n 越来越大时,同一时间复杂度的算法的执行时间也会越来越大,其执行效率越来越慢。
注:相对增长率越大,算法效率越低
上图给出了最常见的五种算法时间复杂度。尽管该图数据规模 n 取值较小,但是不同算法的时间复杂度相对增长率还是很明显的。
该图第二个图像是对照组,对于同一时间复杂度,数据规模 n 的增大,相对增长率如何变化?此图像数据规模 n 从 100 增长到 1000,相对增长率和原图相比发生了天翻地覆的变化。这说明,对同一个时间复杂度,其数据规模 n 增大,也会导致算法效率越来越低。
从以上描述可以看到,影响算法效率的有两大因素:所使用的算法本身和数据规模 n 。上图 MATLAB 代码如下,各位看官可以拷贝以下代码,然后改变其数据规模 n,在 MATLAB 运行一下,身临其境的体验一下,不同的时间复杂相对增长率的变化,这将带给你的震撼,是文字描述远远达不到的,也会让你更加的印象深刻。笔者亲测有效。
%第一组
subplot(2,1,1);
%数据规模 n
n1 = (1 : 0.1 :100);
%常数阶
y1 = 1;
%对数阶
y2 = log(n1);
%线性阶
y3 = n1;
%线性对数阶
y4 = n1.*log(n1);
%平方阶
y5 = n1.*n1;
%立方阶
y6 = n1.*n1.*n1;
plot(n1,y2,n1,y3,n1,y4,n1,y5,n1,y6);
title('复杂度分析');
xlabel('数据规模 n');
ylabel('ms');
axis ([1 100 1 100]);
%对照组
subplot(2,1,2);
n1 = (1 : 0.1 :1000);
%常数阶
y1 = 1;
%对数阶
y2 = log(n1);
%线性阶
y3 = n1;
%线性对数阶
y4 = n1.*log(n1);
%平方阶
y5 = n1.*n1;
%立方阶
y6 = n1.*n1.*n1;
plot(n1,y2,n1,y3,n1,y4,n1,y5,n1,y6);
title('复杂度分析');
xlabel('数据规模 n');
ylabel('ms');
axis ([1 1000 1 100]);
时间复杂度的分析总结就到这里了,接下来分析总结空间复杂度。
1.3 空间复杂度 ( Space Complexity )
前面,我花了很长的篇幅来总结时间复杂度的分析,理解了前面的内容,空间复杂度分析方法将会非常的简单。时间复杂度表示算法的执行时间与数据规模 n 之间的增长关系,而空间复杂度表示算法的存储空间与数据规模 n 之间的增长关系。
以求最大值算法为例,分析空间复杂度。
int FindLargest()
{
int arr[n] = {0,1,2, 3, 4,5, 6,7,8, 9 };
//把数组中的第一个元素赋值给变量max
int max = arr[0];
//遍历数组,寻找最大的值,并将最大的值赋给变量max
for (int i = 0; i < n; i++)
{
if (arr[i] > max)
{
max = arr[i];
}
}
return max;
}
跟时间复杂度分析一样,我们可以看到,在第 6 行代码中,申请了一个空间变量 max ,用来存储数组中第一个元素,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略。第 7 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O ( n )。
我们常见的空间复杂度就是 O ( 1 ) 、O ( n ) 和 O ( n^2 ), 像 O ( log n ) 和 O ( nlog n ) 这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单的多。所以,对于空间复杂,掌握上述所说的这些内容就已经足够了。
2. 浅析最好、最坏、平均和均摊时间复杂度
书接上文,我总结了常见的复杂度分析例子,例如 O ( 1 )、O ( log n ) 、O ( n )、O ( nlog n)、O ( n^2) 和 O ( n^3 )。掌握了这些内容,对于复杂度分析这个知识点,已经可以达到及格线了。但是,准备在计算机领域深耕下去的我们,肯定不会满足于此。
接下来,进入复杂度分析的进阶知识:最好情况时间复杂度 ( best case time complexity)、最坏情况时间复杂度 ( worsr case time complexity )、平均情况时间复杂度 ( average time complexity ) 和 均摊时间复杂度 ( amortized time complexity )。 把这几个概念掌握了,那么,复杂度这部分内容就没有什么大问题了。
2.1 最好和最坏情况时间复杂度
以顺序查找算法为例,分析其最好和最坏复杂度。
int SequentialSearch(int* arr, int n, int x)
{
//定义目标元素位置的变量
int position = -1;
for (int i = 0; i < n; i++)
{
if (x == arr[i])
{
position = i + 1;
}
}
return position;
}
这段代码实现的功能是在一个无序的数组 arr 中,查找变量 x 出现的位置。如果没有找到,就返回 -1 。经过分析,这段代码的时间复杂度是 O ( n )。
我们在数组中查找一个元素,并不需要每次吧整个数组都遍历一遍,因为有可能中途找到目标元素,就可以提前结束循环了。但是,这段代码写的不够高效,我们可以优化一下这段查找代码,增加循环终止条件 break 。
int SequentialSearch(int* arr, int sz, int x)
{
//定义目标元素位置的变量
int position = -1;
for (int i = 0; i < sz; i++)
{
if (x == arr[i])
{
position = i + 1;
//如果找到该目标元素就提前结束循环
break;
}
}
return position;
}
这个时候,问题就来了。我们优化完之后,这段代码的复杂度还是 O ( n ) 吗?很明显,咋们常规的分析思路,解决不了这个问题。
因为,要查找的变量 x 可能出现在数组的任意位置。如果数组中第一个元素正好是要查找的变量 x ,那就不需要继续遍历剩下的 n - 1 个数据了,那时间复杂度就是 O ( 1 )。但如果数组中不存在变量 x ,那么我们就需要把整个数组都遍历一遍,时间复杂度就成了 O ( n )。所以这段代码的时间复杂度是不一的。
为了表示代码在不同情况下的不同时间复杂度,我们需要引入三个概念:最好情况时间复杂度、最坏时间复杂度和平均情况时间复杂度。
顾名思义,最好情况时间复杂度就是:在理想的情况下,执行这段代码的时间复杂度。就像我们刚刚说的那样,在最理想的情况下,要查找的变量 x 正好是数组的一个元素,这个时候对应的时间复杂度就是最好情况时间复杂度。
同理,最坏情况时间复杂度就是:在最糟糕的情况下,执行这段代码的时间复杂度。就像刚才举的那个例子,如果数组中没有要查找的变量 x ,我们需要把整个数组都遍历一遍才行,所以这种最糟糕情况下对应的时间复杂度就是最坏情况时间复杂度。
2.2 平均情况时间复杂度
我们都知道,最好情况时间复杂度和最坏情况时间复杂度,对应的都是算法在极端情况下的时间复杂度,其发生的概率其实并不大。为了更好地表示平均情况下的复杂度,我们需要引入另一个概念:平均情况时间复杂度,后面我们简称为平均时间复杂度。
平均时间复杂度又该怎么分析呢?我还是借助刚才查找变量 x 的例子来解释。
要查找的变量 x 在数组中的位置,有 n + 1 种情况:在数组的 0 ~ n-1 位置中和不在数组中。我们把每种情况下,查找需要遍历的元素个数累加起来,然后再除以 n + 1,就可以得到需要遍历的元素个数的平均值,即:
我们知道,时间复杂度的大 O 标记法中,可以沈略掉系数、低阶和常量。所以,咱们把刚刚这个公式简化之后,得到的平均时间时间复杂度就是 O ( n )。
这个结论虽然是正确的,但是计算过程稍微有点问题。究竟是什么问题呢?我们刚讲的 n + 1 中情况,出现的概率并不是一样的,我们具体分析一下。
我们知道,要查找的变量 x ,要么在数组里,要么就不在数组里。这两种情况对应的概率统计起来很麻烦,为了方便我们理解,我们假设变量 x 在数组和不在数组中的概率都为 1 / 2 。另外,要查找的数据出现在 0 ~ n-1 这 n 个位置的概率也是一样的,为 1 / n 。所以,根据概率乘法法则,要查找的数据出现在 0 ~ n-1 中任意位置的概率就是 1 / ( 2n )。
因此,前面的推导过程中,存在的最大问题就是没有将各种情况发生的概率考虑进去。如果我们把每种情况发生的概率也考虑进去,那平均时间复杂度的计算过程就变成了这样:
这个值就是概率论中的加权平均值,也叫期望值。所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。
引入概率之后,前面那段代码的加权平均值为 ( 3n + 1 ) / 4 。用大 O 表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是 O ( n )。
你可能会说,平均时间复杂度分析好复杂啊,还好涉及概率论的知识。实际上,在大多数情况下,我们并不 需要区分最好,最坏和平均情况时间复杂度三种情况。很多时候,在多块代码的时候,我们使用一个时间复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。
2.3 均摊时间复杂度
到此为止,我们已经掌握了算法复杂度分析的大部分内容了。下面,我们再来看一个更加高阶的概念,均摊时间复杂度。
均摊时间复杂度,听起来跟平均时间复杂度有点像。对于初学者来说,这两个概念确实非常容易弄混。我前面说了,大部分情况下,我们并不需要区分最好、最坏和平均三种复杂度。平均复杂度只有在某种特殊情况下才会用到,而摊还时间复杂度应用的场景比它更加特殊和更加有限。
那么,什么是均摊时间复杂度呢?均摊时间复杂度就是:在代码执行的所有复杂度情况中绝大部分是低阶的复杂度,个别情况是高阶复杂度,且他们具有前后时序关系,即一低阶,一高阶;一低阶,一高阶的循环前进。其可以将算法高阶复杂度均摊到低阶复杂度上。基本上均摊的结果就等于低阶复杂度。为了方便理解,我举个形象的例子:有一堆苹果,其个头都比较小,不乏其中有几个个头大的苹果;但是,总体来看,这一堆苹果个头就是显小,那些大的苹果在其中也会变的不起眼。均摊时间复杂度就是同样的道理,如果低阶复杂度数量多,高阶复杂度数量少,高阶复杂度就会被均摊;如果低阶复杂度数量少,高阶复杂度数量多,低阶复杂度就会被均摊。
接下来,我举一个例子来详细分析均摊时间复杂度。这个例子就是 数组的操作 之数组元素的插入。如果在数组末尾插入元素,则不需要移动任何元素,直接插入末尾就行,其复杂度为 O (1);如果在数组首插入元素,则需要把数组中所有元素向后移动,其复杂度为 O ( n )。这样,在同一段代码中有了复杂度量级,所以要用到均摊时间复杂度分析。
假设有这样一个例子:在一个有序数组中 [ 2, 3, 4, 5, 6, 7, 8, 9 ] ,在首部位置插入元素 1,在末尾位置插入元素 10。当然,这里是为了深入理解均摊时间复杂度的知识点,所编造的一个例子,真实情况中没人会这么写。
2.3.1 伪代码
算法:InsertionElement(arr[], first, last)
目的:在数组首部插入元素 1,在数组末尾插入元素 10
前提:给定一个有序数组[ 2, 3, 4, 5, 6, 7, 8, 9 ] ,数组的大小 n = 10,
首部插入元素 first = 1 和末尾插入元素 last = 10
后续:无
返回值:无
{
loop( 插入位置条件判定)
{
//首部插入元素 1
if( arr[i] = 2)
{
//将所有元素往后移,为插入元素1腾出位置
loop( 数组遍历条件判定 )
{
arr[i+1] = arr[i]
}
arr[i] = 1
}
if( arr[i] = 9)
{
//末尾插入元素 10
arr[i+1] = 10
}
}
2.3.2 代码
#include<stdio.h>
//函数声明
void InsertionElement(int* arr, int first, int last);
int main()
{
int arr[10] = { 2, 3, 4, 5, 6, 7, 8, 9 };
//定义首部插入元素
int first = 1;
//定义尾部插入元素
int last = 10;
//将数组、first 和 last 传入函数
InsertionElement(arr, first, last);
//打印插入元素后的数组
for (int i = 0; i < 10; i++)
{
printf("%d ", arr[i]);
}
return 0;
}
void InsertionElement(int* arr, int first, int last)
{
for (int i = 0; i < 10; i++)
{
//首部插入元素 1
if (arr[0] == 2 )
{
//将所有元素往后移,为插入元素 1 腾出位置
for (int j = 7; j >= 0; j--)
{
arr[j + 1] = arr[j];
}
arr[i] = 1;
}
if (arr[i] == 9)
{
//末尾插入元素 10
arr[i + 1] = 10;
}
}
}
假设上述例程数组数据规模为 n ,在数组首部插入 n 个1,尾部插入 n 个 10,对其分析时间复杂度。当在数组首部插入元素 1 ,时间复杂度为 O ( n );当在数组末尾插入元素 10,时间复杂度为 O ( 1 )。O ( n ) 时间复杂度的插入和 O ( 1 ) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O ( n ) 插入之后,紧跟着 O ( 1 ) 的插入操作,循环往复。
所以,针对这种特殊的场景,我们引入了一种更加简单的分析方法:摊还分析法,通过摊还分析得到的时间复杂度我们起了一个名字,叫做均摊时间复杂度。
那究竟如何使用摊还分析法来分析算法的均摊时间复杂度呢?
我们还是继续看在数组中插入数据的这个例子。一次 O ( n ) 插入操作,一次 O ( 1 ) 插入操作,所以把耗时多的那次操作 O ( n ) 均摊给耗时少的操作 O ( 1 ) 。均摊下来,这一组连续的操作的均摊时间复杂度就是 O ( n/2 ),忽略掉系数,其均摊上时间复杂度为 O ( n ) 。这就是均摊分析的的大至思路。
均摊时间复杂度和摊还分析应用场景比较特殊,所以我们并不会经常用到。为了方便理解记忆,我这里简单总结一下他们的应用场景。如果我们遇到了,知道是怎么回事儿就行了。
对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高。而且这些操作之间存在着前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能够将较高的时间复杂度那次的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。
尽管,很多数据结构和算法书籍都花了很大力气来区分平均时间复杂度和均摊时间复杂度,但我个人认为,均摊时间复杂度是一种特殊的平均时间复杂度,我们没必要画太多精力去区分他们。我们最应该掌握的是它的分析方法,摊还分析。至于分析出来的结果是叫平均还是叫均摊,这只是个说法,并不重要。
3. 经典面试题
我们项目之前都会进行性能测试,在做代码的时间复杂度和空间复杂度分析,是不是多次一举呢?而且,每段代码都分析一下时间复杂度和空间复杂度,是不是很浪费时间呢?你怎么看待这个问题?
答:我不认为是多次一举,时间复杂度和空间复杂度分析为我们提供了一个很好的理论分析的方向,并且它是宿主平台无关的,能够让我们对我们的程序或算法有一个大概的认识,让我们知道,比如在最坏的情况下程序的执行效率如何,同时也为我们交流提供了一个不错的桥梁,我们可以说,算法 1 的时间复杂度是 O ( n ),算法 2 的时间复杂度是 O ( log n ),这样我们立刻就对不同的算法有"效率"上感性认识。
当然,时间复杂度和空间复杂度分析只是一个理论基础,只能提供粗略的估计分析,我们不能直接断定就觉得 O ( log n ) 的算法一定优于 O ( n ),针对不同的宿主环境,不同的数据集,不同的数据量大小,在实际应用上面可能真正的性能会不同,个人觉得,针对不同的实际情况,进而进行一定的性能基准测试是很有必要的,比如在统一一批手机上( 同样的硬件、系统等等)进行横向基准测试,进而选择合适特定应用场景下的最优算法。
综述所述,时间复杂度和空间复杂度分析与性能基准测试并不冲突,而是相辅相成的,但是一个低阶的时间复杂度程序有极大的可能会优于一个高阶的时间复杂度程序,所以在实际编程中,时刻关心理论时间和空间度模型是有助于产出效率高的程序的。同时,因为时间复杂度和空间复杂度只是提供一个粗略的分析模型,因此也不会浪费太多的时间。重点在于在编程时,要具有这种复杂度分析的思维。
4. 总结
我们通常听别人说,自己实际工作中根本用不到数据结构与算法。所以,就算不懂这块知识,只要 Java API 和开发框架运用的熟练,照样可以把代码写的“飞”起来。事实真的是这样吗?如果事实真的是这样,那么,为什么会有那么多的程序员把数据结构与算法奉为圭臬,推上计算机神坛呢?
我们学习数据结构和算法目的是:建立时间复杂度和空间复杂度意识,写出高质量的代码;能够设计基础框架,提升编程技能;训练逻辑思维,积攒人生经验,依次获得工作回报,实现我们的价值,完善我们的编程人生。
所以,不管你是业务工程开发师,还是基础结构工程师;不管你是初入职场的初级工程师,还是工作多年的资深架构师,又或者是想转人工智能、区块链等热门领域的程序员,数据结构与算法作为计算机的基础知识和核心知识,都是必须要掌握的。
掌握了数据结构与算法,你看待问题的深度,解决问题的角度就会不一样。 因为这样的你,就像站在巨人的肩膀上,拿着生存利器行走世界。数据结构与算法,会为你的编程之路,甚至人生之路打开一扇通往新世界的大门。
用不到数据结构与算法的原因,大概是你不会吧。各位读者觉得呢?希望这篇博客可以帮助各位读者迈过复杂度分析这道“坎”。同时也希望各位读者能沉淀下来,打磨自己的技艺。学习以深入挖掘,筑基为先,知其然,知其所以然为目的;而不是满心抱怨的去做一个 CRUD 工程师和调参侠。
参考书籍:极客时间的 《数据结构与算法之美》