一、数组
1)为什么很多编程语言中,数组的下标都从0开始编号?
数组(Array)是一种线性表数据结构。它用一组连续的内存空间,来存储一组具有相同类型的数据。因为有连续的内存空间和相同类型的数据,数组就可以实现随机访问。实现随机访问的方式是
a[i]_address = base_address + i * data_type_size
其中 data_type_size 表示数组中每个元素的大小。
如果用 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 来说,就是多了一次减法指令。
数组作为非常基础的数据结构,通过下标随机访问数组元素又是其非常基础的编程操作,效率的优化就要尽可能做到极致。所以为了减少一次减法操作,数组选择了从 0 开始编号,而不是从 1 开始。
2)数组和链表的区别
- 链表适合插入、删除,时间复杂度 O(1);数组支持随机访问,根据下标随机访问的时间复杂度为 O(1)。
- 对内存要求方面:数组对内存的要求更高。因为数组需要一块连续内存空间来存放数据。(可能出现的问题就是:内存总的剩余空间足够,但是申请容量较大的数组时申请失败) 链表对内存的要求较低,是因为链表不需要连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。 但是要注意:链表虽然方便。但是内存开销比数组大了将近一倍
- 数组的缺点是大小固定,一经声明就要占用整块连续内存空间。如果声明的数组过大,系统可能没有足够的连续内存空间分配给它,导致“内存不足(out of memory)”。如果声明的数组过小,则可能出现不够用的情况。这时只能再申请一个更大的内存空间,把原数组拷贝进去,非常费时。链表本身没有大小的限制,天然地支持动态扩容,我觉得这也是它与数组最大的区别。
3)容器 vs 数组:java中的ArrayList与数组相比,到底有哪些优势呢?
- 容器优势:
1.ArrayList 最大的优势就是可以将很多数组操作的细节封装起来。比如数组插入、删除数据时需要搬移其他数据等。
2.支持动态扩容。 - 数组优势:
1.Java ArrayList 无法存储基本类型,比如 int、long,需要封装为 Integer、Long 类,而 Autoboxing、Unboxing 则有一定的性能消耗,所以如果特别关注性能,或者希望使用基本类型,就可以选用数组。
2.如果数据大小事先已知,并且对数据的操作非常简单,用不到 ArrayList 提供的大部分方法,也可以直接使用数组。
3.还有一个是我个人的喜好,当要表示多维数组时,用数组往往会更加直观。比如 Object[][] array;而用容器的话则需要这样定义:ArrayList > array。 - 总结:对于业务开发,直接使用容器就足够了,省时省力。毕竟损耗一丢丢性能,完全不会影响到系统整体的性能。但如果你是做一些非常底层的开发,比如开发网络框架,性能的优化需要做到极致,这个时候数组就会优于容器,成为首选。
4)二维数组寻址
对于 m * n 的数组,a [ i ][ j ] (i < m,j < n)的地址为:address = base_address + ( i * n + j) * type_size
5)Java 中的JVM、聊一下对标记清除垃圾回收算法的理解
- 大多数主流虚拟机采用可达性分析算法来判断对象是否存活,在标记阶段,会遍历所有 GC ROOTS,将所有 GC ROOTS 可达的对象标记为存活。只有当标记工作完成后,清理工作才会开始。
- 不足:
1.效率问题。标记和清理效率都不高,但是当知道只有少量垃圾产生时会很高效。
2.空间问题。会产生不连续的内存空间碎片。
leetcode
二分查找:https://leetcode-cn.com/problems/binary-search/
测试用例:
1)[-1,0,3,5,9,12],2 返回-1
2)[-1,0,3,5,9,12],9 返回4
3) [2,5], 5 返回1
二、链表
1)三种最常见的链表结构:
- 单链表:内存块称作“结点”、链上的下一个结点的地址称作“后继指针”。头结点用于记录链表的基地址,尾结点的后继指针指向一个空地址 NULL。单向链表的插入和删除操作,我们只需要考虑相邻结点的指针改变,所以对应的时间复杂度是 O(1)。
- 双向链表:结点不止有一个后继指针 next 指向后面的结点,还有一个前驱指针 prev 指向前面的结点。
- 循环链表:循环链表的尾结点指针是指向链表的头结点。可用于解决约瑟夫问题:人们站在一个等待被处决的圈子里。 计数从圆圈中的指定点开始,并沿指定方向围绕圆圈进行。 在跳过指定数量的人之后,处刑下一个人。 对剩下的人重复该过程,从下一个人开始,朝同一方向跳过相同数量的人,直到只剩下一个人,并被释放。
2)链表应用场景
- 缓存是一种提高数据读取性能的技术,在硬件设计、软件开发中都有着非常广泛的应用,比如常见的 CPU 缓存、数据库缓存、浏览器缓存等等。缓存的大小有限,当缓存被用满时,哪些数据应该被清理出去,哪些数据应该被保留?这就需要缓存淘汰策略来决定。常见的策略有三种:先进先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。
3)如何实现LRU缓存淘汰算法?
我们维护一个有序单链表,越靠近链表尾部的结点是越早之前访问的。当有一个新的数据被访问时,我们从链表头开始顺序遍历链表。1. 如果此数据之前已经被缓存在链表中了,我们遍历得到这个数据对应的结点,并将其从原来的位置删除,然后再插入到链表的头部。2. 如果此数据没有在缓存链表中,又可以分为两种情况:如果此时缓存未满,则将此结点直接插入到链表的头部;如果此时缓存已满,则链表尾结点删除,将新的数据结点插入链表的头部。
4)写链表的6个技巧
技巧一:理解指针或引用的含义
技巧二:警惕指针丢失和内存泄漏
// 错误示范:x->next指错
p->next = x; // 将p的next指针指向x结点;
x->next = p->next; // 将x的结点的next指针指向b结点;
技巧三:利用哨兵简化实现难度:单链表的插入,第一个节点不一样,利用哨兵可以简化操作
技巧四:重点留意边界条件处理,以下情况代码是否能够工作?
- 如果链表为空时
- 如果链表只包含一个结点时
- 如果链表只包含两个结点时
- 代码逻辑在处理头结点和尾结点的时候
技巧五:技巧五:举例画图,辅助思考
技巧六:多写多练,没有捷径
leetcode
- 单链表反转(206):https://leetcode-cn.com/problems/reverse-linked-list/ 测试用例[1,2,3,4,5]、[1,2]、[1]
- 链表中环的检测(141):https://leetcode-cn.com/problems/linked-list-cycle/
- 两个有序的链表合并(21):https://leetcode-cn.com/problems/merge-two-sorted-lists/
- 删除链表倒数第 n 个结点(19):https://leetcode-cn.com/problems/remove-nth-node-from-end-of-list/
- 求链表的中间结点(876):https://leetcode-cn.com/problems/middle-of-the-linked-list/
三、栈
1)栈的结构是什么?
栈是一种操作受限的数据结构,只支持入栈和出栈操作。后进先出是它最大的特点。栈既可以通过数组实现(称为顺序栈),也可以通过链表来实现(链式栈)。不管基于数组还是链表,入栈、出栈的时间复杂度都为 O(1)。
2)栈如何完成表达式中括号的匹配?
- 背景:假设表达式中只包含三种括号,圆括号 ()、方括号[]和花括号{},并且它们可以任意嵌套。比如,{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式。那我现在给你一个包含三种括号的表达式字符串,如何检查它是否合法呢?
- 答:当扫描到左括时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号。如果能够匹配,比如“(”跟“)”匹配,“[”跟“]”匹配,“{”跟“}”匹配,则继续扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明有未匹配的左括号,为非法格式。
3)如何实现浏览器的前进和后退功能?
- 背景:当你依次访问完一串页面 a-b-c 之后,点击浏览器的后退按钮,就可以查看之前浏览过的页面 b 和 a。当你后退到页面 a,点击前进按钮,就可以重新查看页面 b 和 c。但是,如果你后退到页面 b 后,点击了新的页面 d,那就无法再通过前进、后退功能查看页面 c 了。
- 答:我们使用两个栈,X 和 Y,我们把首次浏览的页面依次压入栈 X,当点击后退按钮时,再依次从栈 X 中出栈,并将出栈的数据依次放入栈 Y。当我们点击前进按钮时,我们依次从栈 Y 中取出数据,放入栈 X 中。当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了。当栈 Y 中没有数据,那就说明没有页面可以点击前进按钮浏览了。
leetcode
有效的括号 https://leetcode-cn.com/problems/valid-parentheses/
四、队列
1)队列的应用有哪些
比如高性能队列 Disruptor、Linux 环形缓存,都用到了循环并发队列;Java concurrent 并发包利用 ArrayBlockingQueue 来实现公平锁等。
2)当我们向固定大小的线程池中请求一个线程时,如果线程池中没有空闲资源了,这个时候线程池如何处理这个请求?是拒绝请求还是排队请求?各种处理策略又是怎么实现的呢?
我们一般有两种处理策略。第一种是非阻塞的处理方式,直接拒绝任务请求;另一种是阻塞的处理方式,将请求排队,等到有空闲线程时,取出排队的请求继续处理。那如何存储排队的请求呢?我们前面说过,队列有基于链表和基于数组这两种实现方式。这两种实现方式对于排队请求又有什么区别呢?基于链表的实现方式,可以实现一个支持无限排队的无界队列(unbounded queue),但是可能会导致过多的请求排队等待,请求处理的响应时间过长。所以,针对响应时间比较敏感的系统,基于链表实现的无限排队的线程池是不合适的。而基于数组实现的有界队列(bounded queue),队列的大小有限,所以线程池中排队的请求超过队列大小时,接下来的请求就会被拒绝,这种方式对响应时间敏感的系统来说,就相对更加合理。不过,设置一个合理的队列大小,也是非常有讲究的。队列太大导致等待的请求太多,队列太小会导致无法充分利用系统资源、发挥最大性能。
leetcode
- 用队列实现栈(不是很适合用于面试) https://leetcode-cn.com/problems/implement-stack-using-queues/
- 用栈实现队列(不是很适合用于面试)https://leetcode-cn.com/problems/implement-queue-using-stacks/
五、递归
1)什么是递归?
- 递归是一种非常高效、简洁的编码技巧,一种应用非常广泛的算法,比如DFS深度优先搜索、前中后序二叉树遍历等都是使用递归。
- 方法或函数调用自身的方式称为递归调用,调用称为递,返回称为归。
- 基本上,所有的递归问题都可以用递推公式来表示,比如
f(n) = f(n-1) + 1;
f(n) = f(n-1) + f(n-2);
f(n)=n*f(n-1);
2)递归的优点与缺点
- 优点:递归代码的表达力很强,写起来非常简洁;
- 缺点:空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。
3)什么样的问题可以用递归来解决呢?递归的3个条件
- 一个问题的解可以分解为几个子问题的解
- 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
- 存在递归终止条件
4)如何实现递归
- 递归代码编写
写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码。 - 递归代码理解
对于递归代码,若试图想清楚整个递和归的过程,实际上是进入了一个思维误区。
那该如何理解递归代码呢?如果一个问题A可以分解为若干个子问题B、C、D,你可以假设子问题B、C、D已经解决。而且,你只需要思考问题A与子问题B、C、D两层之间的关系即可,不需要一层层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。
- 因此,理解递归代码,就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤。
5)递归常见问题及解决方案
1. 堆栈溢出
- 为什么会造成堆栈溢出
函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。 - 如何预防堆栈溢出呢?
通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如 1000)之后,我们就不继续往下再递归了,直接返回报错。
2. 重复计算
- 通过某种数据结构来保存已经求解过的值,从而避免重复计算。
leetcode
斐波那契数 https://leetcode-cn.com/problems/fibonacci-number/