前言:不管是远程的视频面试,还是现场的面试,都有可能会有手撕代码的环节,这也是很多童鞋包括我(虽然还没遇到过..)都很头疼的东西,可能是因为 IDE 自动提示功能用惯了或是其他一些原因,总之让我手写代码就是感觉很奇怪..但是我想的话,这应该侧重考察的是一些细节或者是习惯方面的一些东西,所以还是防患于未然吧,把一些可能手撕的代码给准备准备,分享分享,希望可以得到各位的指正,然后能有一些讨论,由于我字太丑就不上传自己默写的代码了,但还是希望各位潦草写一遍加深一下印象吧,以上;
Part 1.生产者-消费者问题
这绝对是属于重点了,不管是考察对于该重要模型的理解还是考察代码能力,这都是一道很好的考题,所以很有必要的,我们先来回顾一下什么是生产者-消费者问题;
问题简单回顾
生产者消费者问题(英语:Producer-consumer problem),也称有限缓冲问题(英语:Bounded-buffer problem),是一个多线程同步问题的经典案例。该问题描述了共享固定大小缓冲区的两个线程——即所谓的“生产者”和“消费者”——在实际运行时会发生的问题。生产者的主要作用是生成一定量的数据放到缓冲区中,然后重复此过程。与此同时,消费者也在缓冲区消耗这些数据。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。(摘自维基百科:生产者消费者问题)
- 注意: 生产者-消费者模式中的内存缓存区的主要功能是数据在多线程间的共享,此外,通过该缓冲区,可以缓解生产者和消费者的性能差;
几种实现方式
上面说到该问题的关键是:如何保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区空时消耗数据;解决思路可以简单概括为:
- 生产者持续生产,直到缓冲区满,满时阻塞;缓冲区不满后,继续生产;
- 消费者持续消费,直到缓冲区空,空时阻塞;缓冲区不空后,继续消费;
- 生产者和消费者都可以有多个;
那么在 Java 语言中,能达到上述要求的,自然而然的就会有如下的几种写法,但是问题的核心都是能够让消费者和生产者在各自满足条件需要阻塞时能够起到正确的作用:
-
wait()
/notify()
方式; -
await()
/signal()
方式; -
BlockingQueue
阻塞队列方式; -
PipedInputStream
/PipedOutputStream
方式;
手写代码,我们着重掌握上面对应的第一种和第三种写法就足够了;
wait()/notify()方式实现
在手写代码之前,我们需要现在 IDE 上实现一遍,理解其中的过程并且找到一些重点/细节,我们先来代码走一遍,然后我把我理解的重点给圈儿出来:
生产者代码
public class Producer implements Runnable {
private volatile boolean isRunning = true;
private final Vector sharedQueue; // 内存缓冲区
private final int SIZE; // 缓冲区大小
private static AtomicInteger count = new AtomicInteger(); // 总数,原子操作
private static final int SLEEPTIME = 1000;
public Producer(Vector sharedQueue, int SIZE) {
this.sharedQueue = sharedQueue;
this.SIZE = SIZE;
}
@Override
public void run() {
int data;
Random r = new Random();
System.out.println("start producer id = " + Thread.currentThread().getId());
try {
while (isRunning) {
// 模拟延迟
Thread.sleep(r.nextInt(SLEEPTIME));
// 当队列满时阻塞等待
while (sharedQueue.size() == SIZE) {
synchronized (sharedQueue) {
System.out.println("Queue is full, producer " + Thread.currentThread().getId()
+ " is waiting, size:" + sharedQueue.size());
sharedQueue.wait();
}
}
// 队列不满时持续创造新元素
synchronized (sharedQueue) {
data = count.incrementAndGet(); // 构造任务数据
sharedQueue.add(data);
System.out.println("producer create data:" + data + ", size:" + sharedQueue.size());
sharedQueue.notifyAll();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupted();
}
}
public void stop() {
isRunning = false;
}
}
有了上面的提到的解决思路,应该很容易实现,但是这里主要提一下一些细节和重点:
-
创造数据:生产者-消费者解决的问题就是数据在多线程间的共享,所以我们首要关心的问题就应该是数据,我们这里采用的是使用一个
AtomicInteger
类来为我们创造数据,使用它的好处是该类是一个保证原子操作的类,我们使用其中的incrementAndGet()
方法不仅能够保证线程安全,还可以达到一个计数的效果,所以是一个既简单又实用的选择,当然也可以使用其他的数据来代替,这里注意的是要保证该类在内存中只存在一份,使用static
修饰; -
内存缓冲区:要保证在多线程环境下内存缓冲区的安全,所以我们考虑使用简单的
Vector
类来作为我们的内存缓冲区,并且使用final
修饰保证内存缓冲区的唯一,然后的话我们需要判断队列是否满,需要手动添加一个标识缓冲区大小的变量SIZE
,注意也是final
修饰; -
模拟延迟:这里主要模拟的是一个网络延迟,我们首先定义了一个
SLEEPTIME
的延迟范围,注意使用的是static final
修饰,然后使用Random()
类的nextInt()
方法来随机选取一个该范围内的值来模拟网络环境中的延迟; -
停止方法:首先需要知道在
Thread
类中有一个弃用的stop()
方法,我们自己增加一个标志位isRunning
来完成我们自己的stop()
功能,需要注意的是使用volatile
来修饰,保证该标志位的可见性; -
错误处理:当捕获到错误时,我们应该使用
Thread
类中的interrupted()
方法来终止当前的进程; - 消息提示:我们主要是要在控制台输出该生产者的信息,包括当前队列的状态,大小,当前线程的生产者信息等,注意的是信息格式的统一(后面的消费者同样的);
消费者代码
public class Consumer implements Runnable {
private final Vector sharedQueue; // 内存缓冲区
private final int SIZE; // 缓冲区大小
private static final int SLEEPTIME = 1000;
public Consumer(Vector sharedQueue, int SIZE) {
this.sharedQueue = sharedQueue;
this.SIZE = SIZE;
}
@Override
public void run() {
Random r = new Random();
System.out.println("start consumer id = " + Thread.currentThread().getId());
try {
while (true) {
// 模拟延迟
Thread.sleep(r.nextInt(SLEEPTIME));
// 当队列空时阻塞等待
while (sharedQueue.isEmpty()) {
synchronized (sharedQueue) {
System.out.println("Queue is empty, consumer " + Thread.currentThread().getId()
+ " is waiting, size:" + sharedQueue.size());
sharedQueue.wait();
}
}
// 队列不空时持续消费元素
synchronized (sharedQueue) {
System.out.println("consumer consume data:" + sharedQueue.remove(0) + ", size:" + sharedQueue.size());
sharedQueue.notifyAll();
}
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}
跟生产者相同的,你需要注意内存缓冲区/ 模拟延迟/ 错误处理/ 消息提示这些方面的细节问题,总体来说消费者就是持续不断的消费,也比较容易实现;
主线程代码
有了我们的消费者和生产者代码,我们需要来验证一下它们的正确性,照常理来说我们直接创建一些消费者和生产者的线程让它们执行就可以了啊,但是为了“加分”考虑呢,我们还是使用线程池吧..也不是特别复杂:
public static void main(String args[]) throws InterruptedException {
// 1.构建内存缓冲区
Vector sharedQueue = new Vector();
int size = 4;
// 2.建立线程池和线程
ExecutorService service = Executors.newCachedThreadPool();
Producer prodThread1 = new Producer(sharedQueue, size);
Producer prodThread2 = new Producer(sharedQueue, size);
Producer prodThread3 = new Producer(sharedQueue, size);
Consumer consThread1 = new Consumer(sharedQueue, size);
Consumer consThread2 = new Consumer(sharedQueue, size);
Consumer consThread3 = new Consumer(sharedQueue, size);
service.execute(prodThread1);
service.execute(prodThread2);
service.execute(prodThread3);
service.execute(consThread1);
service.execute(consThread2);
service.execute(consThread3);
// 3.睡一会儿然后尝试停止生产者
Thread.sleep(10 * 1000);
prodThread1.stop();
prodThread2.stop();
prodThread3.stop();
// 4.再睡一会儿关闭线程池
Thread.sleep(3000);
service.shutdown();
}
大家可以自行去看看运行的结果,是没有问题的,不会出现多生产或者多消费之类的多线程问题,运行一段时间等生产者都停止之后,我们可以观察到控制台三个消费者都在等待数据的情况:
Queue is empty, consumer 17 is waiting, size:0
Queue is empty, consumer 15 is waiting, size:0
Queue is empty, consumer 16 is waiting, size:0
BlockingQueue阻塞队列方式实现
该方式对比起上面一种方式实现起来要简单一些,因为不需要手动的去通知其他线程了,生产者直接往队列中放数据直到队列满,消费者直接从队列中获取数据直到队列空,BlockingQueue会自动帮我们完成阻塞这个动作,还是先来看看代码
生产者代码
public class Producer implements Runnable {
private volatile boolean isRunning = true;
private BlockingQueue<Integer> queue; // 内存缓冲区
private static AtomicInteger count = new AtomicInteger(); // 总数,原子操作
private static final int SLEEPTIME = 1000;
public Producer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
int data;
Random r = new Random();
System.out.println("start producer id = " + Thread.currentThread().getId());
try {
while (isRunning) {
// 模拟延迟
Thread.sleep(r.nextInt(SLEEPTIME));
// 往阻塞队列中添加数据
data = count.incrementAndGet(); // 构造任务数据
System.out.println("producer " + Thread.currentThread().getId() + " create data:" + data
+ ", size:" + queue.size());
if (!queue.offer(data, 2, TimeUnit.SECONDS)) {
System.err.println("failed to put data:" + data);
}
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupted();
}
}
public void stop() {
isRunning = false;
}
}
跟上面一种方式没有很大的差别,倒是代码更加简单通透,不过需要注意的是对阻塞队列添加失败的错误处理;
消费者代码
public class Consumer implements Runnable {
private BlockingQueue<Integer> queue; // 内存缓冲区
private static final int SLEEPTIME = 1000;
public Consumer(BlockingQueue<Integer> queue) {
this.queue = queue;
}
@Override
public void run() {
int data;
Random r = new Random();
System.out.println("start consumer id = " + Thread.currentThread().getId());
try {
while (true) {
// 模拟延迟
Thread.sleep(r.nextInt(SLEEPTIME));
// 从阻塞队列中获取数据
if (!queue.isEmpty()) {
data = queue.take();
System.out.println("consumer " + Thread.currentThread().getId() + " consume data:" + data
+ ", size:" + queue.size());
} else {
System.out.println("Queue is empty, consumer " + Thread.currentThread().getId()
+ " is waiting, size:" + queue.size());
}
}
} catch (InterruptedException e) {
e.printStackTrace();
Thread.currentThread().interrupt();
}
}
}
主线程代码
public static void main(String args[]) throws InterruptedException {
// 1.构建内存缓冲区
BlockingQueue<Integer> queue = new LinkedBlockingDeque<>();
// 2.建立线程池和线程
ExecutorService service = Executors.newCachedThreadPool();
Producer prodThread1 = new Producer(queue);
Producer prodThread2 = new Producer(queue);
Producer prodThread3 = new Producer(queue);
Consumer consThread1 = new Consumer(queue);
Consumer consThread2 = new Consumer(queue);
Consumer consThread3 = new Consumer(queue);
service.execute(prodThread1);
service.execute(prodThread2);
service.execute(prodThread3);
service.execute(consThread1);
service.execute(consThread2);
service.execute(consThread3);
// 3.睡一会儿然后尝试停止生产者
Thread.sleep(10 * 1000);
prodThread1.stop();
prodThread2.stop();
prodThread3.stop();
// 4.再睡一会儿关闭线程池
Thread.sleep(3000);
service.shutdown();
}
因为队列中添加和删除的操作比较频繁,所以这里使用LinkedBlockingQueue
来作为阻塞队列,所以这里除了内存缓冲区有所不同以外,其他的都差不多...当然你也可以指定一个队列的大小;
总结以及改进
生产者-消费者模式很好地对生产者线程和消费者线程进行解耦,优化了系统整体的结构,同时由于缓冲区的作用,允许生产者线程和消费者线程存在执行上的性能差异,从一定程度上缓解了性能瓶颈对系统性能的影响;上面两种写法都是非常常规的写法,只能说能起码能在及格的基础上加个那么点儿分数,如果想要得高分可以去搜索搜搜 Disruptor 来实现一个无锁的生产者-消费者模型....这里就不提及了..
改进:上面的线程输出可能会有点儿不友好(不直观),因为我们这里是直接使用的线程的 ID 来作为输出,我们也可以给线程弄一个名字来作为输出,以上;
Part 2.排序算法
排序算法当然也算是重点考察的对象之一了,毕竟基础且偏算法,当然我们有必要去了解常见的排序算法以及它们采取了怎样的思想又是如何实现的还有复杂度的问题,但是这里的话,主要就提及两种考的比较常见的排序算法:冒泡和快排,以及分别对它们进行的一些优化;
冒泡排序
冒泡应该是比较基础的一种算法,我们以从小到大排序为例,它的基础思想是:从第一个数开始直到数组倒数第二个数,每一轮都去比较数组中剩下的数,如果后面的数据更小则两数交换,这样一轮一轮的比较交换下来,最大的那个数也就“沉到”了数组的最后,最小的“冒”到了数组的最前面,这样就完成了排序工作;
基础算法代码(未优化)
很简单,直接上代码:
/**
* 冒泡排序
*
* @param nums 待排序的数组
*/
public void bubbleSort(int[] nums) {
// 正确性判断
if (null == nums || nums.length <= 1) {
return;
}
// 从小到大排序
for (int i = 0; i < nums.length - 1; i++) {
for (int j = i + 1; j < nums.length; j++) {
if (nums[i] > nums[j]) {
nums[i] = nums[i] + nums[j];
nums[j] = nums[i] - nums[j];
nums[i] = nums[i] - nums[j];
}
}
}
}
这里需要注意:加上正确性判断;(讨论:其实我看大多数地方都是没有这个的,不知道有没有加上的必要...求讨论)
另外光写完实现冒泡排序的算法是不算完的,还要养成良好的习惯去写测试单元用例,而且尽可能要考虑到多的点,例如这里的负数、多个相同的数之类的特殊情况,我就大概写一个吧,也欢迎指正:
@Test
public void bubbleSortTester() {
// 测试用例1:验证负数是否满足要求
int[] nums = {1, 4, 2, -2, 5, 11, -7, 0};
bubbleSort(nums);
// 输出测试结果
for (int i = 0; i < nums.length; i++) {
System.out.print(nums[i] + ", ");
}
System.out.println();
// 测试用例2:验证多个相同的数是否满足要求
nums = new int[]{1, 1, 5, 7, 7, 3, 1};
bubbleSort(nums);
// 输出测试结果
for (int i = 0; i < nums.length; i++) {
System.out.print(nums[i] + ", ");
}
}
冒泡排序优化
想象一个这样的场景:如果该数组基本有序,或者在数组的后半段基本有序,上面的算法就会浪费许多的时间开销,所以我们不再使用双重嵌套去比较每两个元素的值,而只是不断比较数组每前后两个数值,让大的那个数不断“冒”到数组的最后,然后缩小尾边界的范围,并且增加一个标志位,表示这一趟是否发生了交换,如果没有那么证明该数组已经有序则完成了排序了:
/**
* 改进的冒泡排序
*
* @param nums 待排序的数组
*/
public void bubbleSort2(int[] nums) {
// 正确性判断
if (null == nums || nums.length <= 1) {
return;
}
// 使用一个数来记录尾边界
int length = nums.length;
boolean flag = true;// 发生了交换就为true, 没发生就为false,第一次判断时必须标志位true。
while (flag) {
flag = false;// 每次开始排序前,都设置flag为未排序过
for (int i = 1; i < length; i++) {
if (nums[i - 1] > nums[i]) {// 前面的数字大于后面的数字就交换
int temp;
temp = nums[i - 1];
nums[i - 1] = nums[i];
nums[i] = temp;
// 表示交换过数据;
flag = true;
}
}
length--; // 减小一次排序的尾边界
}
}
同样的记得写单元测试函数;
冒泡排序进一步优化
顺着这个思路,我们进一步想象一个场景:现在有一个包含 1000 个数的数组,仅有前面 100 个数无序,后面的 900 个数都比前面的 100 个数更大并且已经排好序,那么上面优化的方法又会造成一定的时间浪费,所以我们进一步增加一个变量记录最后发生交换的元素的位置,也就是排序的尾边界了:
/**
* 冒泡算法最优解
*
* @param nums 待排序的数组
*/
public static void bubbleSort3(int[] nums) {
int j, k;
int flag = nums.length;// flag来记录最后交换的位置,也就是排序的尾边界
while (flag > 0) {// 排序未结束标志
k = flag;// k 来记录遍历的尾边界
flag = 0;
for (j = 1; j < k; j++) {
if (nums[j - 1] > nums[j]) {// 前面的数字大于后面的数字就交换
// 交换a[j-1]和a[j]
int temp;
temp = nums[j - 1];
nums[j - 1] = nums[j];
nums[j] = temp;
// 表示交换过数据;
flag = j;// 记录最新的尾边界.
}
}
}
}
这应该是最优的冒泡排序了,同时也别忘记了最后要写测试单元用例代码;
快速排序
快排也是一种很经典的算法,它使用了一种分治的思想,基本思想是:通过一趟排序将待排序的数组分成两个部分,其中一部分记录的是比关键字更小的,另一部分是比关键字更大的,然后再分别对着两部分继续进行排序,直到整个序列有序;
基础实现
非常经典的代码,直接上吧:
public static void quickSort(int[] arr) {
qsort(arr, 0, arr.length - 1);
}
private static void qsort(int[] arr, int low, int high) {
if (low < high) {
int pivot = partition(arr, low, high); // 将数组分为两部分
qsort(arr, low, pivot - 1); // 递归排序左子数组
qsort(arr, pivot + 1, high); // 递归排序右子数组
}
}
private static int partition(int[] arr, int low, int high) {
int pivot = arr[low]; // 枢轴记录
while (low < high) {
while (low < high && arr[high] >= pivot) --high;
arr[low] = arr[high]; // 交换比枢轴小的记录到左端
while (low < high && arr[low] <= pivot) ++low;
arr[high] = arr[low]; // 交换比枢轴小的记录到右端
}
// 扫描完成,枢轴到位
arr[low] = pivot;
// 返回的是枢轴的位置
return low;
}
当然,在手撕的时候需要注意函数上的 Java Doc 格式的注释,这里省略掉是为了节省篇幅,另外别忘了测试单元用例代码;
上面的代码也很容易理解,其实就是一个“填坑”的过程,第一个“坑”挖在每次排序的第一个位置arr[low]
,从序列后面往前找第一个比pivot
小的数来把这个“坑”填上,这时候的“坑”就变成了当前的arr[high]
,然后再从序列前面往后用第一个比pivot
大的数把刚才的“坑”填上,如此往复,始终有一个“坑”需要我们填上,直到最后一个“坑”出现,这个“坑”使用一开始的pivot
填上就可以了,而这个“坑”的位置也就是pivot
该填上的正确位置,我们再把这个位置返回,就可以把当前序列分成两个部分再依次这样操作最终就达到排序的目的了,不得不说这样的思想挺神奇的;
算法优化
上面这个快速排序算法可以说是最基本的快速排序,因为它并没有考虑任何输入数据。但是,我们很容易发现这个算法的缺陷:这就是在我们输入数据基本有序甚至完全有序的时候,这算法退化为冒泡排序,不再是O(n㏒n),而是O(n^2)了。
究其根源,在于我们的代码实现中,每次只从数组第一个开始取。如果我们采用“三者取中”,即 arr[low], arr[high], arr[(low+high)/2] 三者的中值作为枢轴记录,则可以大大提高快速排序在最坏情况下的性能。但是,我们仍然无法将它在数组有序情形下的性能提高到O(n)。还有一些方法可以不同程度地提高快速排序在最坏情况下的时间性能。
此外,快速排序需要一个递归栈,通常情况下这个栈不会很深,为log(n)级别。但是,如果每次划分的两个数组长度严重失衡,则为最坏情况,栈的深度将增加到O(n)。此时,由栈空间带来的空间复杂度不可忽略。如果加上额外变量的开销,这里甚至可能达到恐怖的O(n^2)空间复杂度。所以,快速排序的最差空间复杂度不是一个定值,甚至可能不在一个级别。
为了解决这个问题,我们可以在每次划分后比较两端的长度,并先对短的序列进行排序(目的是先结束这些栈以释放空间),可以将最大深度降回到O(㏒n)级别。
关于优化的话,了解一个大概的思路就可以了,那在这里稍微总结一下:
- ①三数取中作为枢轴记录;
- ②当待排序序列的长度分割到一定大小之后,使用插入排序;
- ③在一次分割结束后,可以把与
pivot
相等的元素聚在一起,继续下次分割时,不用再对与pivot
相等的元素分割; - ④优化递归操作;
参考文章:http://blog.51cto.com/flyingcat2013/1281614
想要了解的更多的童鞋可以戳这里:https://blog.csdn.net/insistGoGo/article/details/7785038
Part 3.二叉树相关算法
二叉树也是一个容易提及的概念和写算法的问题,特别是它的几种递归遍历和非递归遍历,都是基础且常考的点,那在这里就稍微整理整理吧...
二叉树的几种递归遍历
前序、中序、后序遍历都是非常基础且容易的遍历方法,重点还是在后面的中序和后续的非递归遍历上,当然还有层序遍历,所以这里不多说,直接给代码;
前序遍历递归实现
public void preOrderTraverse1(TreeNode root) {
if (root != null) {
System.out.print(root.val + " ");
preOrderTraverse1(root.left);
preOrderTraverse1(root.right);
}
}
中序遍历递归实现
public void inOrderTraverse1(TreeNode root) {
if (root != null) {
preOrderTraverse1(root.left);
System.out.print(root.val + " ");
preOrderTraverse1(root.right);
}
}
后序遍历递归实现
public void postOrderTraverse1(TreeNode root) {
if (root != null) {
preOrderTraverse1(root.left);
preOrderTraverse1(root.right);
System.out.print(root.val + " ");
}
}
前面三种遍历,也就是输出结点数据的位置不同而已,所以很容易,但是如果手写,建议问清楚面试官要求,是在遍历时直接输出还是需要函数返回一个List集合,然后注意写测试用例代码!
二叉树的几种非递归遍历
★★层序遍历★★
层序遍历我们只需要增加使用一个队列即可,看代码很容易理解:
public void levelTraverse(TreeNode root) {
if (root == null) {
return;
}
LinkedList<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
TreeNode node = queue.poll();
System.out.print(node.val + " ");
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
}
前序遍历非递归实现
public void preOrderTraverse2(TreeNode root) {
if (root == null) {
return;
}
LinkedList<TreeNode> stack = new LinkedList<>();
TreeNode pNode = root;
while (pNode != null || !stack.isEmpty()) {
if (pNode != null) {
System.out.print(pNode.val + " ");
stack.push(pNode);
pNode = pNode.left;
} else { //pNode == null && !stack.isEmpty()
TreeNode node = stack.pop();
pNode = node.right;
}
}
}
★★中序遍历非递归实现★★
/**
* 非递归中序遍历二叉树
*
* @param root 二叉树根节点
* @return 中序遍历结果集
*/
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> list = new ArrayList<>();
ArrayDeque<TreeNode> stack = new ArrayDeque<>();
while (root != null || !stack.isEmpty()) {
while (root != null) {
stack.addFirst(root);
root = root.left;
}
root = stack.removeFirst();
list.add(root.val);
root = root.right;
}
return list;
}
★★后续遍历非递归实现★★
/**
* 二叉树的后序遍历
*
* @param root 二叉树根节点
* @return 后序遍历结果集
*/
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> list = new ArrayList<>();
Deque<TreeNode> stack = new ArrayDeque<>();
TreeNode pre = null;
while (!stack.isEmpty() || root != null) {
while (root != null) {
stack.push(root);
root = root.left;
}
root = stack.peek();
// i :判断如果右子树不为空且不为
if (root.right != null && root.right != pre) {
root = root.right;
} else {
root = stack.pop();
list.add(root.val);
pre = root;
root = null;
}
}
return list;
}
如果比较难以理解的话,可以自己尝试着跟跟 Debug 然后看看过程;
二叉树相关其他算法题
另外的话还有一些比较常见的关于树的算法,在文章的末尾,这里就不再赘述了:
链接:https://www.jianshu.com/p/4ef1f50d45b5
Part 4.其他重要算法
除了上面 3 Part 比较重要的点之外,还有一些其他的算法也是经常考到的,下面我们来说;
1.反转链表
这是一道很经典的题,不仅考你对链表的理解,而且还有一些细节(例如正确性判断/ 测试用例)需要你从代码层面去展现,下面我们给出两段代码,读者可以自行去比较,我只是提供一个思路;
思路一:使用一个 Node 不断链接
这是最经典的算法,也是需要我们牢牢掌握的方法,最重要的还是理解 while()
循环中的过程:
public ListNode reverseList(ListNode head) {
// 正确性判断
if (null == head || null == head.next) {
return head;
}
ListNode pre = null;
while (null != head) {
ListNode temp = head;
head = head.next;
temp.next = pre;
pre = temp;
}
return pre;
}
思路二:反转元素值然后重新赋给 Node
这是一个很简单的思路,比上个思路要多遍历一遍链表,但是好处是简单,思路清晰,实现起来容易,emm,像这一类问题我觉得另一个比较重要的就是举一反三的能力吧,在这里我只提供两个思路,其实还有很多种实现方法,当然也别忘了细节的东西~
public ListNode reverseList(ListNode head) {
// 1.正确性判断
if (null == head || null == head.next) {
return head;
}
// 2.遍历链表head并将结果保存在List集合中
List<ListNode> list = new LinkedList();
ListNode tempNode = head;
while (null != tempNode) {
list.insert(tempNode.val);
tempNode = tempNode.next;
} // end while:遍历完了链表并将结果保存在了List集合中
// 3.反转集合,并将值依次复制给链表
Collections.reverse(list);
tempNode = head;
while (null != tempNode) {
tempNode.val = list.remove(0);
tempNode = tempNode.next;
}
return head;
}
2.合并两个有序链表
问题描述:将两个有序链表合并为一个新的有序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的;
同样的经典算法,需要掌握:
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
if (l1 == null) {
return l2;
}
if (l2 == null) {
return l1;
}
ListNode head = null;
if (l1.val < l2.val) {
head = l1;
head.next = mergeTwoLists(l1.next, l2);
} else {
head = l2;
head.next = mergeTwoLists(l1, l2.next);
}
return head;
}
这道题也是 LeetCode 上的一道题,我当时的做法是下面这样的,虽然看起来代码量多了不少而且看起来蠢蠢的..但是经过 LeetCode 测试,甚至比上面的实现要快上那么 2ms,特别提醒:下面的代码只是用作一个思路的参考,着重掌握上面的代码 :
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 定义一个虚拟头结点方便遍历
ListNode dummyHead = new ListNode(-1);
dummyHead.next = l1;
ListNode pre = dummyHead;
// 遍历l1链表
int len1 = 0;
while (null != pre.next) {
len1++;
pre = pre.next;
}
int[] nums1 = new int[len1];
// 保存l1链表的数据
pre = dummyHead;
for (int i = 0; i < len1; i++) {
nums1[i] = pre.next.val;
pre = pre.next;
}
// 遍历l2链表
int len2 = 0;
dummyHead.next = l2;
pre = dummyHead;
while (null != pre.next) {
len2++;
pre = pre.next;
}
int[] nums2 = new int[len2];
// 保存l2链表的数据
pre = dummyHead;
for (int i = 0; i < len2; i++) {
nums2[i] = pre.next.val;
pre = pre.next;
}
int[] nums = new int[len1 + len2];
// 将两个链表的数据整合并排序
System.arraycopy(nums1, 0, nums, 0, len1);
System.arraycopy(nums2, 0, nums, len1, len2);
Arrays.sort(nums);
// 拼接一个链表
ListNode dummy = new ListNode(-1);
pre = dummy;
for (int i = 0; i < nums.length; i++) {
ListNode node = new ListNode(nums[i]);
pre.next = node;
pre = pre.next;
}
return dummy.next;
}
3.两个链表的第一个公共结点
题目描述:找出两个链表的第一个公共结点;
/**
* 求两个链表中第一个公共结点
*
* @param pHead1 链表1头结点
* @param pHead2 链表2头结点
* @return 链表第一个公共结点
*/
public ListNode FindFirstCommonNode(ListNode pHead1, ListNode pHead2) {
// 1.正确性判断
if (null == pHead1 || null == pHead2) {
return null;
}
// 2.遍历链表1把所有结点保存在列表中中
Vector<ListNode> nodeList1 = new Vector<>();
while (null != pHead1) {
nodeList1.add(pHead1);
pHead1 = pHead1.next;
// 判断是否成环,成环则退出循环
if (nodeList1.contains(pHead1)) {
break;
}
} // end while:链表1中的所有结点都存入了nodeList1中
// 3.遍历链表2比较列表中的数据
Vector<ListNode> nodeList2 = new Vector<>();
while (null != pHead2) {
// 先判断nodeList1中是否存在相同结点,存在则返回
if (nodeList1.contains(pHead2)) {
return pHead2;
}
// 如果不存在则继续遍历,并判断是否成环
nodeList2.add(pHead2);
pHead2 = pHead2.next;
if (nodeList2.contains(pHead2)) {
break;
}
} // end while:遍历完了链表2并将所有结点保存在了nodeList2中
// 如果遍历完链表2则返回null
return null;
}
需要注意的细节是:①正确性判断;②判断链表是否自己成环;③注释;④注意要自己写测试用例啊...
另外还有一些类似的题目像是:①链表入环结点;②链表倒数第k个结点;之类的都是需要掌握的...
4.二分查找算法
二分查找也是一类比较常考的题目,其实代码也比较容易理解,直接上吧,再再再提醒一下:注意正确性判断还有测试用例...
普通实现
/**
* 二分查找普通实现。
*
* @param srcArray 有序数组
* @param key 查找元素
* @return 不存在返回-1
*/
public static int binSearch(int srcArray[], int key) {
int mid;
int start = 0;
int end = srcArray.length - 1;
while (start <= end) {
mid = (end - start) / 2 + start;
if (key < srcArray[mid]) {
end = mid - 1;
} else if (key > srcArray[mid]) {
start = mid + 1;
} else {
return mid;
}
}
return -1;
}
递归实现
/**
* 二分查找递归实现。
*
* @param srcArray 有序数组
* @param start 数组低地址下标
* @param end 数组高地址下标
* @param key 查找元素
* @return 查找元素不存在返回-1
*/
public static int binSearch(int srcArray[], int start, int end, int key) {
int mid = (end - start) / 2 + start;
if (srcArray[mid] == key) {
return mid;
}
if (start >= end) {
return -1;
} else if (key > srcArray[mid]) {
return binSearch(srcArray, mid + 1, end, key);
} else if (key < srcArray[mid]) {
return binSearch(srcArray, start, mid - 1, key);
}
return -1;
}
5.斐波那契数列
这也是一道很经典的题,通常是要要求 N 值的范围的,常规写法应该很简单,所以需要掌握的是优化之后的算法:
public int Fibonacci(int n) {
// 正确性判断
if (0 == n || 1 == n) {
return n;
}
int nums1 = 0, nums2 = 1;
int res = 0;
for (int i = 2; i <= n; i++) {
res = nums1 + nums2;
nums1 = nums2;
nums2 = res;
}
return res;
}
还是注意正确性判断然后写测试用例...
手撕代码总结
如果用手写代码的话,确实是个挺麻烦的事儿,首先需要对代码有相当的熟悉程度,然后其次的话考察的都是一些细节的东西,例如:
- 编码规范:包括一些命名的规范/ 注释的规范等等;
- 缩进:这个我自己倒是挺在意的..关于这个可以去参考参考阿里出的那个规范手册;
- 注释:如果命名规范做得好的话其实是可以达到代码即注释的,但是仍然有一些需要标注的地方例如函数头之类的,最好还是做好注释;
- 代码的完整性:我觉得这个包括对于错误的处理/ 正确性判断这样一类的东西;
- 测试用例:每个函数都需要一定的测试来保证其正确性,所以这个还是挺有必要的,特别是一些边界情况,null 值判断之类的;
- 想好再下笔:这一点其实也蛮重要的,不管是在纸上还是在我们平时的编程中,思路永远都是更重要的;
说来说去还是关于代码的事,我觉得还是理清思路最重要,所以我们需要在一遍一遍熟悉代码的过程中,熟知这些代码的思路,只有搞清楚这些代码背后的原理了,我们才能正确且快速的写出我们心中想要的代码,而不是简单的去背诵,这样是没有很大效果的,以上...
欢迎转载,转载请注明出处!
简书ID:@我没有三颗心脏
github:wmyskxz
欢迎关注公众微信号:wmyskxz_javaweb
分享自己的Java Web学习之路以及各种Java学习资料
想要交流的朋友也可以加qq群:3382693