本篇介绍算法的五大算法思想,如果还不了解算法的基础概念请看一下这篇文章:
初识数据结构和算法
把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题,直到最后子问题小到可 以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序, 归并排序),傅立叶变换(快速傅立叶变换),大数据中的MR,现实中如汉诺塔游戏。
分治法对问题有一定的要求:
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他。依次解决各子问题,最后一个子问题就是初始问题的解。
与分治法最大的不同在于,分治法的思想是并发,动态规划的思想是分步。该方法经分解后得到的子问题往往不是互相独立的,其下一个子阶段的求解往往是建立在上一个子阶段的解的基础上。动态规划算法同样有一定的适用性场景要求:
同样对问题要求作出拆解,但是每一步,以当前局部为目标,求得该局部的最优解。那么最终问题解决时,得到完整的最优解。也就是说,在对问题求解时,总是做出在当前看来是最好的选择,而不去从整体最优上加以考虑。从这一角度来讲,该算法具有一定的场景局限性:
回溯算法实际上是一个类似枚举的搜索尝试过程,在每一步的问题下,列举可能的解决方式。选择某个方案往深度 探究,寻找问题的解,当发现已不满足求解条件,或深度达到一定数量时,就返回,尝试别的路径。回溯法一般适 用于比较复杂的,规模较大的问题。有“通用解题法”之称:
与回溯法类似,也是一种在空间上枚举寻找最优解的方式。但是回溯法策略为深度优先。分支法为广度优先。分支
法一般找到所有相邻结点,先采取淘汰策略,抛弃不满足约束条件的结点,其余结点加入活结点表。然后从存活表
中选择一个结点作为下一个操作对象。
复盘一下最近面试遇到简单关于算法的面试题,本篇介绍的是大数相加的算法实现。
面试官: 请你说一下如何实现2个大数相加的业务?
面试者:这个简单,用long接收数字,再计算不就好了。
面试官:那如果是这个大数连long都无法装下呢,如100位的大数。
面试者:会不会是题目出错了?
面试官:题目没有错,今天就先面试到这里回去等通知吧。
面试者:###xxxx。。。
- 通过比较2个大数的字符长度来确认数组的长度 :maxLength+1;
- 将2个大数字符串通过字符串截取倒序的分表放到2个数组中,如 3248 放到数组中就是 {8,4,2,3} ;
- 创建一个结果数组,然后遍历数组,用2个数组相加,当前位数大于等于10则减10下一位进1 ;
- 倒序遍历结果数组,将结果拼接到stringbuilder中(要注意找到数组中真正的首位数字)。
代码如下(示例):
/**
* 大数相加实现 用2个数组来相加
* @param bigNumberA 大数a
* @param bigNumberB 大数b
* @return
*/
public static String add(String bigNumberA,String bigNumberB) {
//确认出最大的数组长度
int maxLength = bigNumberA.length() > bigNumberB.length() ? bigNumberA.length() : bigNumberB.length();
//将大数A拆成数字放到数组中 倒序插入数组中 个位数在数组的第一位
int[] arrayA = new int[maxLength + 1];
for (int i=0;i<bigNumberA.length();i++) {
arrayA[i] = Integer.parseInt(bigNumberA.substring(bigNumberA.length()-1-i,bigNumberA.length()-i));
}
//将大数B拆成数字放到数组中 倒序插入数组中 个位数在数组的第一位
int[] arrayB = new int[maxLength + 1];
for (int i = 0; i < bigNumberB.length(); i++) {
arrayB[i] = Integer.parseInt(bigNumberB.substring(bigNumberB.length()-1-i,bigNumberB.length()-i));
}
//创建一个计算结果的数组
int[] result = new int[maxLength + 1];
for(int i=0;i<result.length;i++){
int temp = result[i];
//同位数相加
temp+=arrayA[i];
temp+=arrayB[i];
//如果大于等于10则下一位数进1
if(temp>=10){
temp =temp-10;
result[i+1] =1;
}
result[i] =temp;
}
//定义一个是否找到首位的数字
boolean findFirst = false;
//将result数组转换为数字
StringBuilder builder =new StringBuilder();
for(int i=result.length-1;i>=0;i--){
if(!findFirst){
if(result[i]==0){
//首位数字为0都直接结束本次的循环
continue;
}
//找到首位数字直接赋值为true
findFirst = true ;
}
builder.append(result[i]);
}
return builder.toString();
}
public static void main(String[] args) {
String str = BigDataAddition.add("2339831","8323623");
System.out.println(str);
}
算法题难得是解题的思路,如果思路有了,代码的实现并不难,大家在刷leetcode时,不要为了刷题而刷题,尽量去理解解题的思路。
分享一下最近面试遇到过的简单算法题,本篇介绍的是如何判断链表有环相关的3种方法。
本篇需要一定的数据结构和复杂度基础。
有一个单向链表,链表中有可能出现环,如下图所示。
如何用程序判断该链表是否为环链表?
从头结点开始,依次遍历单链表中的每一个节点,每遍历一个新节点,就从头检查新节点之前的所有节点,用新节点和此节点之前所有的节点依次做比较,如果发现新的节点和之前的某个节点相同,则说明该节点被遍历过2次,则可判断为有链表有环。
有点类似于冒泡排序的意思,则这个解法时间复杂度为 O(n2),空间复杂度为 O(1).。
效率比较低,不推荐,一般面试官也不会太满意的。
这个解法的代码太简单了,就不附上代码了。
创建一个hashSet集合,遍历链表将每个节点作为key用来存储到set中,每遍历一个新节点,都用新节点和set集合中存储的节点做比较,如果发现存在与节点相同的key,则该链表为环形链表。
这个解法就是典型用空间换时间,时间复杂度为O(n),空间复杂度为O(n),相对上一个解法,效率高了很多。
但是很遗憾这个也不是面试官想要的解法(面试官就是事妈)。
这个解法的代码太简单了,就不附上代码了。
创建2个指针P1和P2,开始时都指向链表的表头节点,然后开始遍历链表,让指针向后移动,其中P1为慢指针每次向后移动1个节点,P2位快指针每次向下移动2个节点,最后比较这2个指针指向的节点是否相同,如果相同则该链表为环形链表。
以上面的链表图为例,模拟一下这个解法的图示:
如上图所示,指针向下移动4次就会指向同一个位置。
这种解法的时间复杂度是O(n),空间复杂度是O(1),也是最优的解法,面试官想要的也是这种解法。
代码如下(示例):
/**
* 判断链表是否有环
*/
public static boolean isCycle(Node node){
//2个指针初始化都是在链表的表头节点
Node p1 =node;
Node p2 =node;
//遍历链表 链表还存在下一个节点
while(p2!=null&&p2.next!=null){
//p1是慢指针,每次向下移动1个节点
p1 = p1.next;
//p2是快指针,每次向下移动2个节点
p2=p2.next.next;
//如果2个指针指向的节点相同那么就是环形链表
if(p1==p2){
return true;
}
}
return false;
}
/**
* 链表节点
*/
private static class Node{
int data;
Node next;
Node(int data){
this.data=data;
}
}
其实这是一道非常基础的算法题,我在leetcode上面也刷到了这道题,面试概率也会相对较高。
算法一般是从时间复杂度和空间复杂度这2个维度去衡量,在算法里面最常见优化效率的方法就是用空间来换时间。
复盘一下最近遇到的面试算法题,本篇主要介绍的是链表反转的3种解法和思路,其实有在LeetCode或牛客网上刷题的也知道 链表反转的面试题频率非常高,是必会的一道算法题。
先定义一个单向链表,代码如下:
/**
* 链表节点
*/
private static class Node {
int data;
Node next;
Node(int data) {
this.data = data;
}
}
先上代码,代码如下:
/**
* 递归解题法(链表反转)
* @param head
* @return
*/
public static Node recursion(Node head) {
//在原链表上找到最后一个节点,然后就可以开始往前递归反转
if (head == null || head.next == null){
return head;
}
//下一个节点
Node temp = head.next;
//递归 反转
Node newHead = recursion(head.next);
temp.next = head;
head.next = null;
return newHead;
}
递归法是从最后一个Node开始,在弹栈的过程(递归其实就是栈)中将指针顺序置换的,具体过程如下图所示:
实现代码非常简练,如果没有理解的可以去自己debug模式调试一下,该方式的实际的时间复杂度是 O(n2) ,空间复杂度是O(1),效率较低。
面试官:这是一种解法,请问如何才能更高效呢?
递归方式的反转说白也就是栈的思想,现在直接用栈来实现一个高效的链表反转,代码如下:
/**
* 栈特性解法 (链表反转)
* @param node
* @return
*/
public static Stack<Integer> reverserLinkedList(Node node) {
//栈
Stack<Integer> nodeStack = new Stack<>();
//存入栈中,模拟递归开始的栈状态
while (node != null) {
nodeStack.push(node.data);
node = node.next;
}
return nodeStack;
}
代码非常简单,直接利用栈先进后出的特性就ok了,具体过程如下:
总结一下该解法的时间复杂度为 O(n),空间复杂度为O(n),这也是典型用空间来换时间的思路。
面试官:这种解法,时间上已经是最优的,请问如何在空间上优化呢?
但凡链表相关的面试题,大多数都可以用指针法来解题,指针法就是在链表遍历的过程中将指针顺序置换具体代码如下:
/**
* 双指针解法 (链表反转)
* @param node
* @return
*/
public static Node reverse(Node node) {
//先前结点
Node pre = null;
//临时变量 下一个节点
Node next = null;
//遍历原链表
while (node != null) {
//临时记录原链表下一个节点
next = node.next;
//链表的下一个指向为 pre
node.next = pre;
//记录当前节点信息
pre = node;
//将原链表变为 next 下次遍历从第下个节点开始
node = next;
}
return pre;
}
具体解题思路过程如下:
总结一下该解法的时间复杂度为 O(n),空间复杂度为O(1),指针不算占有空间。
理解算法面试题的思路才是正确的刷题方式,常常有小伙伴们组队刷题,但是总会有人抱怨刷题过几天后就忘记了,那是因为你是为了刷题而刷题(仅仅只是当做面试的八股文)根本没有思考,要多思考才能正真掌握它,思考后甚至可以做到举一反三且可以用到实际项目中,这个才是我们刷题的根本目的。
算法题是现在面试必考题,作者通过最近面试复盘并总结一下相关的算法题,会持续更新。
算法学习不仅仅是应付面试,更重要是思维得到了提升,特别是逻辑推理能力,在遇到大流量等业务场景下都需要用算法的思维做设计和优化,还有就是极限的压榨硬件的性能等等。
ps:千万别把学习算法当成应付面试,这样的心态很难学好算法。
数据结构、算法、时间复杂度、空间复杂度基础
五大算法思想
主要收集当下大厂算法面试经常出现的面试题,也是LeetCode和牛客网上排名比较靠前的面试题。
高频算法题之链表反转
高频算法题之环形链表
高频算法题之两数之和
算法之冒泡排序优化
高频算法题之LRU
面试官: 冒泡排序了解吗?请实现一下?
面试者:了解,最基础的交换排序算法。
面试官:请介绍一下冒泡排序的特点?
面试者:时间复杂度为O(n2),空间复杂度为O(1),属于稳定的排序算法。
面试官:如果还是用冒泡排序,请问如何优化它?
面试者:(内心:啥?不是应该问快速排序什么的么?冒泡排序还怎么优化?)可以使用快速排序等算法比冒泡排序算法效率高,冒泡排序的优化没怎么了解。
面试官:那今天的面试就先到这里,你回去等通知吧
面试者:这就挂了?我准备了好多排序算法呢
public void sort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
//相邻两个元素作比较,如果前面元素大于后面,进行交换
int temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
}
}
}
}
冒泡排序的思想就是将相邻的2个元素两两比较,当元素大于右侧相邻元素时,交换它们的位置;当小于或等于右侧相邻元素时,位置不变。
用冒泡排序来给这个数组{5,8,6,3,9,2,1,7}排序过程如下:
第一轮:5,6,3,8,2,1,7,9
第二轮:5,3,6,2,1,7,8,9
第三轮:3,5,2,1,6,7,8,9
第四轮:3,2,1,5,6,7,8,9
第五轮:2,1,3,5,6,7,8,9
第六轮:1,2,3,5,6,7,8,9
第七轮:1,2,3,5,6,7,8,9
以上可以看出在六轮时已经有序,但排序算法还是会进行下一轮的排序,在这种情况下如果可以判断数组已经有序并做标记就可以不用再继续执行了,第二版冒泡排序代码如下:
public void sort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
//有序标记 每一轮的初始值都是true
boolean isSort =true;
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
//相邻两个元素作比较,如果前面元素大于后面,进行交换
int temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
//元素有交换则不是有序的
isSort =false;
}
}
//如果后续都是有序的,则直接中断遍历
if(isSort){
break;
}
}
}
场景2 如果需要排序的数组是 3,2,1,4,5,6 这种其实只需对 3,2,1排序就好,后面的4,5,6本身就是有序的。
这个问题主要关键点在于对数列有序区的界定。
第三版冒泡排序如下:
public void sort(int[] arr) {
//记录最后一次交换位置
int lastExchangeIndex = 0;
//无序数边界,每次比较都只需要比较到这里
int sortBorder = arr.length - 1;
for (int i = 0; i < arr.length - 1; i++) {
//有序标记 每一轮的初始值都是true
boolean isSort =true;
for (int j = 0; j < sortBorder; j++) {
if (arr[j] > arr[j + 1]) {
//相邻两个元素作比较,如果前面元素大于后面,进行交换
int temp = arr[j + 1];
arr[j + 1] = arr[j];
arr[j] = temp;
//元素有交换则不是有序的
isSort =false;
//记录最后一次交换的元素的位置
lastExchangeIndex = j;
}
}
sortBorder = lastExchangeIndex;
//如果后续都是有序的,则直接中断遍历
if(isSort){
break;
}
}
}
冒泡排序的介绍大概就是这么多了。
面试官:你了解过reids的内存淘汰策略么?
面试者:嗯,了解过,biubiu。。。
面试官:打断一下,如果让你去实现LRU算法,你该如何实现?
面试者:我就会用linkedHashMap实现。。。
面试官:好了,你的情况我大概知道了,你先回去等通知吧
面试者:就是又挂了呗。。
LRU 缓存淘汰算法就是一种常用策略。LRU 的全称是 Least Recently Used,也就是说我们认为最近使用过的数据应该是是「有用的」,很久都没用过的数据应该是无用的,内存满了就优先删那些很久没用过的数据。
如redis的内存淘汰策略就有LRU
- 假设定义一个哈希链表阈值为5来存放用户信息,目前缓存了4个用户信息(001,002,003,004),这4个用户按照被访问的时间顺序依次从链表右端插入(001,002,003,004)。
- 如果现在业务查询005用户,哈希链表中没有则从数据库获取,由于当前容量未超过阈值,可以直接插入到缓存链表中,此时005是最新被访问的用户,则存在哈希链表最右端,那么在最左端的001用户是最近最少访问。(001,002,003,004,005)
- 如果接下来访问的是002用户信息,则002为最新被访问的用户需要将002位置移动到最右端。(001,003,004,005,002)
- 如果当前业务查006用户信息,则由于哈希链表中没有需要先到数据库查询,然后再插入到哈希链表中,但由于当前容量为6超过了哈希链表的阈值,则需要先淘汰最近最少访问的用户,也就是要删除哈希链表最左端的001用户,然后才将006插入到哈希链表中最右端。(003,004,005,002,006)
/**
* lru
* @author winter
*/
public class LRUCache {
/**
* 最左端 也是最近最少访问的元素位置
*/
private Node head;
/**
* 最右端 也是最新被访问的元素位置
*/
private Node end;
/**
* 缓存的上限
*/
private int limit;
private HashMap<String,Node> hashMap;
public LRUCache(int limit){
this.limit =limit;
hashMap = new HashMap<String,Node>();
}
/**
* 获取缓存的内容
* @param key key
* @return
*/
public String get (String key){
//查询key是否存在,不存在直接返回
Node node = hashMap.get(key);
if (node == null){
return null;
}
//存在则需要更新key位置到最右端
refreshNode(node);
return node.value;
}
/**
* 添加元素
* @param key key
* @param value value
*/
public void put(String key,String value){
//获取到节点信息
Node node = hashMap.get(key);
//判断key是否存在
if(node == null){
//key不存在 则先判断哈希的容量是否有超过阈值
if(hashMap.size()>=limit){
//超过阈值先要删掉最左端的元素
String oldKey=removeNode(head);
hashMap.remove(oldKey);
}
//插入新节点
node = new Node(key,value);
addNode(node);
hashMap.put(key,node);
}else {
//key存在,则更新值并刷新key的位置到最右端
node.value = value;
refreshNode(node);
}
}
/**
* 删除元素
* @param key key
*/
public void remove (String key){
//获取到节点信息
Node node = hashMap.get(key);
//删除节点
refreshNode(node);
//删除元素
hashMap.remove(key);
}
/**
* 刷新被访问的位置
* @param node
*/
private void refreshNode(Node node){
//如果访问就是最右端则,不需要移动节点
if(node == end){
return;
}
//不是最右端元素,则需要先删除该元素
removeNode(node);
//新增到哈希链表的最右端
addNode(node);
}
/**
* 删除节点
* @param node
* @return
*/
private String removeNode(Node node){
if(node == head && node == end){
//只有一个节点,删除唯一节点
head = null;
end = null;
}else if (node == end){
//该节点是尾节点
end = end .pre;
end =null ;
} else if(node == head){
//该节点是头节点
head = head.next;
head.pre = null;
}else {
//该节点是中间节点
node.pre.next =node.next;
node.next.pre = node.pre;
}
return node.key;
}
/**
* 插入尾节点
* @param node
*/
private void addNode(Node node){
if (end!=null){
end.next = node;
node.pre = end;
node.next =null;
}
end =node;
if (head == null){
head = node;
}
}
/**
* 链表
*/
class Node {
Node(String key, String value) {
this.key = key;
this.value = value;
}
public Node pre;
public Node next;
public String key;
public String value;
}
}
按照上述的思路来实现LRU算法并不是太难,后续作者理解更深理解后会继续更新相关内容。