写点什么

数据结构与算法之基础入门

用户头像
shirley
关注
发布于: 2020 年 06 月 08 日

基础知识就像是一座大楼的地基,它决定了我们的技术高度。而要想快速做出点事情,前提条件一定是基础能力过硬,“内功”要到位。大学学习的计算机基础知识一定要沉下心来,慢慢啃。

什么是数据结构?什么是算法?

数据结构就是指一组数据的存储结构。

算法就是操作数据的一组方法。

数据结构是为算法服务的,算法要作用在特定的数据结构之上。


20 个最常用的、最基础数据结构与算法

10 个数据结构:数组、链表、栈、队列、散列表、二叉树、堆、跳表、图、Trie 树;

10 个算法:递归、排序、二分查找、搜索、哈希算法、贪心算法、分治算法、回溯算法、动态规划、字符串匹配算法。


在学习数据结构和算法的过程中,不要只是死记硬背,不要为了学习而学习,而是要学习它的“来历”“自身的特点”“适合解决的问题”以及“实际的应用场景”。


学习技巧

  1. 边学边练

“边学边练”这一招非常有用。每周花 1~2 个小时的时间,集中把这周的三节内容涉及的数据结构和算法,全都自己写出来,用代码实现一遍。这样一定会比单纯地看或者听的效果要好很多!

可以“适度”刷题,但一定不要浪费太多时间在刷题上。我们学习的目的还是掌握,然后应用。

  1. 多问、多思考

  2. 目标升级学习法

学习的过程中,我们碰到最大的问题就是,坚持不下来。我们在枯燥的学习过程中,也可以给自己设立一个切实可行的目标。比如,学习完某个知识点后都写一篇学习笔记或者学习心得。如果你能这样学习一段时间,不仅能收获到知识,你还会有意想不到的成就感。因为,这其实帮你改掉了一点学习的坏习惯。这个习惯一旦改掉了,你的人生也会变得不一样。

  1. 知识需要沉淀,不要想试图一下子掌握所有

在学习的过程中,一定会碰到“拦路虎”。如果哪个知识点没有怎么学懂,不要着急,这是正常的。因为,想听一遍、看一遍就把所有知识掌握,这肯定是不可能的。学习知识的过程是反复迭代、不断沉淀的过程。如果碰到“拦路虎”,可以先沉淀一下,过几天再重新学一遍。所谓,书读百遍其义自见,我觉得是很有道理的!


复杂度分析

大 O 复杂度表示法

从 CPU 的角度来看,代码的每一行都执行着类似的操作:读数据-运算-写数据

 int cal(int n) {   int sum = 0;   int i = 1;   int j = 1;   for (; i <= n; ++i) {     j = 1;     for (; j <= n; ++j) {       sum = sum +  i * j;     }   } }
复制代码

我们假设每个语句的执行时间是 unit_time,第 2、3、4 行代码,每行都需要 1 个 unit_time 的执行时间,第 5、6 行代码循环执行了 n 遍,需要 2n * unit_time 的执行时间,第 7、8 行代码循环执行了 n^2 遍,所以需要 2n^2 * unit_time 的执行时间。

所以,整段代码总的执行时间 T(n) = (2n^2+2n+3)*unit_time。


T(n) 表示代码执行的时间;n 表示数据规模的大小;f(n) 表示每行代码执行的次数总和。

公式中的 O,表示代码的执行时间 T(n) 与 f(n) 表达式成正比。

上例中大 O 时间复杂度表示法为 : T(n) = O(2n^2+2n+3)


大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度,简称时间复杂度


当 n 很大时,可以把它想象成 10000、100000。而公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略。我们只需要记录一个最大量级就可以了,所以上面例子中的时间复杂度可以记为:T(n) = O(n^2)。


时间复杂度分析

  1. 只关注循环执行次数最多的一段代码

我们在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。这段核心代码执行次数的 n 的量级,就是整段要分析代码的时间复杂度。


  1. 加法法则:总复杂度等于量级最大的那段代码的复杂度

int cal(int n) {   int sum_1 = 0;   int p = 1;   for (; p < 100; ++p) {     sum_1 = sum_1 + p;   }
int sum_2 = 0; int q = 1; for (; q < n; ++q) { sum_2 = sum_2 + q; } int sum_3 = 0; int i = 1; int j = 1; for (; i <= n; ++i) { j = 1; for (; j <= n; ++j) { sum_3 = sum_3 + i * j; } } return sum_1 + sum_2 + sum_3; }
复制代码

这个代码分为三部分,分别是求 sum_1、sum_2、sum_3。我们可以分别分析每一部分的时间复杂度,然后把它们放到一块儿,再取一个量级最大的作为整段代码的复杂度。


第一段代码循环执行了 100 次,是一个常量的执行时间,跟 n 的规模无关。尽管对代码的执行时间会有很大影响,但是回到时间复杂度的概念来说,它表示的是一个算法执行效率与数据规模增长的变化趋势,所以不管常量的执行时间多大,我们都可以忽略掉。因为它本身对增长趋势并没有影响。


第二段代码和第三段代码的时间复杂度分别是 O(n) 和 O(n^2)。综合这三段代码的时间复杂度,我们取其中最大的量级。所以,整段代码的时间复杂度就为 O(n^2)。也就是说:总的时间复杂度就等于量级最大的那段代码的时间复杂度。


  1. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

以把乘法法则看成是嵌套循环,单独看 cal() 函数。假设 f() 只是一个普通的操作,那第 4~6 行的时间复杂度就是,T1(n) = O(n)。但 f() 函数本身不是一个简单的操作,它的时间复杂度是 T2(n) = O(n),所以,整个 cal() 函数的时间复杂度就是,T(n) = T1(n) * T2(n) = O(n*n) = O(n^2)。

int cal(int n) {   int ret = 0;    int i = 1;   for (; i < n; ++i) {     ret = ret + f(i);   }  }   int f(int n) {  int sum = 0;  int i = 1;  for (; i < n; ++i) {    sum = sum + i;  }   return sum; }
复制代码


几种常见时间复杂度实例分析

下图罗列了常见的复杂度量级,我们可以粗略地分为两类,多项式量级非多项式量级


其中,非多项式量级只有两个:O(2^n) 和 O(n!)。当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。

  1. O(1)

O(1) 只是常量级时间复杂度的一种表示方法,并不是指只执行了一行代码。只要代码的执行时间不随 n 的增大而增长,这样代码的时间复杂度我们都记作 O(1)。或者说,一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)。


  1. O(logn)、O(nlogn)

 i=1; while (i <= n)  {   i = i * 3; }
复制代码

这段代码的时间复杂度为 O(log3n)。实际上,不管是以 2 为底、以 3 为底,还是以 10 为底,我们可以把所有对数阶的时间复杂度都记为 O(logn)。

在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)。

如果一段代码的时间复杂度是 O(logn),我们循环执行 n 遍,时间复杂度就是 O(nlogn) 了。而且,O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。


  1. O(m+n)、O(m*n)

int cal(int m, int n) {  int sum_1 = 0;  int i = 1;  for (; i < m; ++i) {    sum_1 = sum_1 + i;  }
int sum_2 = 0; int j = 1; for (; j < n; ++j) { sum_2 = sum_2 + j; }
return sum_1 + sum_2;}
复制代码

m 和 n 是表示两个数据规模。我们无法事先评估 m 和 n 谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O(m+n)。


空间复杂度分析

时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。类比一下,空间复杂度全称就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系

void print(int n) {  int i = 0;  int[] a = new int[n];  for (i; i <n; ++i) {    a[i] = i * i;  }
for (i = n-1; i >= 0; --i) { print out a[i] }}
复制代码

跟时间复杂度分析一样,我们可以看到,第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n 没有关系,所以我们可以忽略。第 3 行申请了一个大小为 n 的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。


常见的空间复杂度就是 O(1)、O(n)、O(n^2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。而且,空间复杂度分析比时间复杂度分析要简单很多。



最好、最坏情况时间复杂度

最好情况时间复杂度:在最理想的情况下,执行这段代码的时间复杂度。

最坏情况时间复杂度:在最糟糕的情况下,执行这段代码的时间复杂度。

平均情况时间复杂度

下面例子时间复杂度:O(n)

// n表示数组array的长度int find(int[] array, int n, int x) {  int i = 0;  int pos = -1;  for (; i < n; ++i) {    if (array[i] == x) {       pos = i;       break;    }  }  return pos;}
复制代码

均摊时间复杂度

下面例子时间复杂度:O(1)

find() 函数在极端情况下,复杂度才为 O(1)。但 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。

对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的前后时序关系,一般都是一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。

 // array表示一个长度为n的数组 // 代码中的array.length就等于n/**这段代码实现了一个往数组中插入数据的功能。当数组满了之后,也就是代码中的 count == array.length 时,我们用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。*/ int[] array = new int[n]; int count = 0;  void insert(int val) {    if (count == array.length) {       int sum = 0;       for (int i = 0; i < array.length; ++i) {          sum = sum + array[i];       }       array[0] = sum;       count = 1;    }
array[count] = val; ++count; }
复制代码

继续看 insert() 函数。每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。


对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。


个人体会: 平均和平摊基本就是一个概念,平摊是特殊的平均。在分析时间复杂度是 O(1)还是 O(n)的时候最简单就是凭感觉,出现 O(1)的次数远大于出现 O(n)出现的次数,那么平均平摊时间复杂度就是 O(1)。


用户头像

shirley

关注

还未添加个人签名 2019.04.02 加入

还未添加个人简介

评论

发布
暂无评论
数据结构与算法之基础入门