基础数据结构和算法

基本的数据结构和算法

一、复杂度分析

  • 跑代码,通过统计,监控得到执行时间和占用内存,为什么还要做时间、空间复杂度分析呢?
    • 有局限性:1,依赖环境2,受数据规模影响大
  • 大O表示法
    • 大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度(asymptotic time complexity),简称时间复杂度。
    • 在分析一个算法、一段代码的时间复杂度的时候,也只关注循环执行次数最多的那一段代码就可以了。
    • 加法法则:总复杂度等于量级最大的那段代码的复杂度
    • 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

常见复杂度:

基础数据结构和算法_第1张图片

  1. O(1)

    一般情况下,只要算法中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度也是Ο(1)。

  2. O(logn)、O(nlogn):

    log3n 就等于 log32 * log2n,所以 O(log3n) = O(C * log2n),其中 C=log32 是一个常量。

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

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

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

    如果是嵌套代码,乘法法则依然有效,时间复杂度为0(m*n)

  • 空间复杂度分析

常见的空间复杂度就是 O(1)、O(n)、O(n2 ),像 O(logn)、O(nlogn) 这样的对数阶复杂度平时都用不到。

复杂度小结

复杂度也叫渐进复杂度,包括时间复杂度和空间复杂度,用来分析算法执行效率与数据规模之间的增长关系,可以粗略地表示,越高阶复杂度的算法,执行效率越低。常见的复杂度并不多,从低阶到高阶有:O(1)、O(logn)、O(n)、O(nlogn)、O(n2 )。

基础数据结构和算法_第2张图片

二、复杂度分析进阶

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

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

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

  • 平均情况时间复杂度

    在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。

    只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

  • 均摊时间复杂度

    均摊时间复杂度就是一种特殊的平均时间复杂度

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

三、数组

数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。

  • 为什么数组要从 0 开始编号,而不是从 1 开始呢?

从数组存储的内存模型上来看,“下标”最确切的定义应该是“偏移(offset)”。前面也讲到,如果用 a 来表示数组的首地址,a[0]就是偏移为 0 的位置,也就是首地址,a[k]就表示偏移 k 个 type_size 的位置,所以计算 a[k]的内存地址只需要用这个公式:

a[k]_address = base_address + k * type_size_

但是,如果数组从 1 开始计数,那我们计算数组元素 a[k]的内存地址就会变为:

a[k]_address = base_address + (k-1)*type_size

对比两个公式,我们不难发现,从 1 开始编号,每次随机访问数组元素都多了一次减法运算,对于 CPU 来说,就是多了一次减法指令。

  • 扩展:

    • 二维数组内存寻址:

      对于 m * n 的数组,a [ i ][ j ] (i < m,j < n)的地址为:

      address = base_address + ( i * n + j) * type_size

四、链表

  • 如何用链表来实现 LRU 缓存淘汰策略呢?

链表结构五花八门,今天我重点给你介绍三种最常见的链表结构,它们分别是:单链表、双向链表和循环链表。我们首先来看最简单、最常用的单链表

基础数据结构和算法_第3张图片

头结点用来记录链表的基地址。有了它,我们就可以遍历得到整条链表。而尾结点特殊的地方是:指针不是指向下一个结点,而是指向一个空地址 NULL,表示这是链表上最后一个结点。

基础数据结构和算法_第4张图片

可以把链表想象成一个队伍,队伍中的每个人都只知道自己后面的人是谁,所以当我们希望知道排在第 k 位的人是谁的时候,我们就需要从第一个人开始,一个一个地往下数。所以,链表随机访问的性能没有数组好,需要 O(n) 的时间复杂度。

  • 循环链表

基础数据结构和算法_第5张图片

  • 双向链表

基础数据结构和算法_第6张图片

双向链表,顾名思义,它支持两个方向,每个结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。

基础数据结构和算法_第7张图片

五、链表技巧

  • 理解指针或引用的含义
  1. 含义:将某个变量(对象)赋值给指针(引用),实际上就是就是将这个变量(对象)的地址赋值给指针(引用)。
  2. 示例:
    p—>next = q; 表示p节点的后继指针存储了q节点的内存地址。
    p—>next = p—>next—>next; 表示p节点的后继指针存储了p节点的下下个节点的内存地址。
  • 警惕指针丢失和内存泄漏(单链表)
  1. 插入节点
    在节点a和节点b之间插入节点x,b是a的下一节点,,p指针指向节点a,则造成指针丢失和内存泄漏的代码:p—>next = x;x—>next = p—>next; 显然这会导致x节点的后继指针指向自身。
    正确的写法是2句代码交换顺序,即:x—>next = p—>next; p—>next = x;
  2. 删除节点
    在节点a和节点b之间删除节点b,b是a的下一节点,p指针指向节点a:p—>next = p—>next—>next;
  • 利用“哨兵”简化实现难度
  1. 什么是“哨兵”?
    链表中的“哨兵”节点是解决边界问题的,不参与业务逻辑。如果我们引入“哨兵”节点,则不管链表是否为空,head指针都会指向这个“哨兵”节点。我们把这种有“哨兵”节点的链表称为带头链表,相反,没有“哨兵”节点的链表就称为不带头链表。
  2. 未引入“哨兵”的情况
    如果在p节点后插入一个节点,只需2行代码即可搞定:
    new_node—>next = p—>next;
    p—>next = new_node;
    但,若向空链表中插入一个节点,则代码如下:
    if(head == null){
    head = new_node;
    }
    如果要删除节点p的后继节点,只需1行代码即可搞定:
    p—>next = p—>next—>next;
    但,若是删除链表的最有一个节点(链表中只剩下这个节点),则代码如下:
    if(head—>next == null){
    head = null;
    }
    从上面的情况可以看出,针对链表的插入、删除操作,需要对插入第一个节点和删除最后一个节点的情况进行特殊处理。这样代码就会显得很繁琐,所以引入“哨兵”节点来解决这个问题。
  3. 引入“哨兵”的情况
    “哨兵”节点不存储数据,无论链表是否为空,head指针都会指向它,作为链表的头结点始终存在。这样,插入第一个节点和插入其他节点,删除最后一个节点和删除其他节点都可以统一为相同的代码实现逻辑了。
  4. “哨兵”还有哪些应用场景?
    总结起来,哨兵最大的作用就是简化边界条件的处理。
  • 重点留意边界条件处理
    经常用来检查链表是否正确的边界4个边界条件:
  1. 如果链表为空时,代码是否能正常工作?
  2. 如果链表只包含一个节点时,代码是否能正常工作?
  3. 如果链表只包含两个节点时,代码是否能正常工作?
  4. 代码逻辑在处理头尾节点时是否能正常工作?
  • 举例画图,辅助思考
    核心思想:释放脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。

  • 多写多练,没有捷径
    5个常见的链表操作:

  1. 单链表反转
  2. 链表中环的检测
  3. 两个有序链表合并
  4. 删除链表倒数第n个节点
  5. 求链表的中间节点

六、递归

一、什么是递归?

1.递归是一种非常高效、简洁的编码技巧,一种应用非常广泛的算法,比如DFS深度优先搜索、前中后序二叉树遍历等都是使用递归。
2.方法或函数调用自身的方式称为递归调用,调用称为递,返回称为归。
3.基本上,所有的递归问题都可以用递推公式来表示,比如
f(n) = f(n-1) + 1;
f(n) = f(n-1) + f(n-2);
f(n)=n*f(n-1);

二、为什么使用递归?递归的优缺点?

1.优点:代码的表达力很强,写起来简洁。
2.缺点:空间复杂度高、有堆栈溢出风险、存在重复计算、过多的函数调用会耗时较多等问题。

三、什么样的问题可以用递归解决呢?

一个问题只要同时满足以下3个条件,就可以用递归来解决:
1.问题的解可以分解为几个子问题的解。何为子问题?就是数据规模更小的问题。
2.问题与子问题,除了数据规模不同,求解思路完全一样
3.存在递归终止条件

四、如何实现递归?

1.递归代码编写
写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。
2.递归代码理解
对于递归代码,若试图想清楚整个递和归的过程,实际上是进入了一个思维误区。
那该如何理解递归代码呢?如果一个问题A可以分解为若干个子问题B、C、D,你可以假设子问题B、C、D已经解决。而且,你只需要思考问题A与子问题B、C、D两层之间的关系即可,不需要一层层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。
因此,理解递归代码,就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。

五、递归常见问题及解决方案

1.警惕堆栈溢出:可以声明一个全局变量来控制递归的深度,从而避免堆栈溢出。
2.警惕重复计算:通过某种数据结构来保存已经求解过的值,从而避免重复计算。

六、如何将递归改写为非递归代码?

笼统的讲,所有的递归代码都可以改写为迭代循环的非递归写法。如何做?抽象出递推公式、初始值和边界条件,然后用迭代循环实现。

式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。

五、递归常见问题及解决方案

1.警惕堆栈溢出:可以声明一个全局变量来控制递归的深度,从而避免堆栈溢出。
2.警惕重复计算:通过某种数据结构来保存已经求解过的值,从而避免重复计算。

六、如何将递归改写为非递归代码?

笼统的讲,所有的递归代码都可以改写为迭代循环的非递归写法。如何做?抽象出递推公式、初始值和边界条件,然后用迭代循环实现。

你可能感兴趣的:(数据结构和算法,算法,数据结构)