【面试必备】手撕代码,你怕不怕?

大厂技术面试中的手撕代码应该如何准备? 不管是秋招还是社招,互联网大厂的技术面试中的手撕代码这一部分总是绕不过去的一关。不只是后端开发和算法岗,现在就连前端、运维、测试、数据等岗位也要考手撕代码了。 阅读详情

Part 1.生产者-消费者问题

这绝对是属于重点了,不管是考察对于该重要模型的理解还是考察代码能力,这都是一道很好的考题,所以很有必要的,我们先来回顾一下什么是生产者-消费者问题;

问题简单回顾

 

如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:854630135,群里有阿里大牛直播讲解技术,以及Java大型互联网技术的视频免费分享给大家。

生产者消费者问题(英语: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修饰;

如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:854630135,群里有阿里大牛直播讲解技术,以及Java大型互联网技术的视频免费分享给大家。

  • 内存缓冲区:要保证在多线程环境下内存缓冲区的安全,所以我们考虑使用简单的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来作为阻塞队列,所以这里除了内存缓冲区有所不同以外,其他的都差不多...当然你也可以指定一个队列的大小;

总结以及改进

如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:854630135,群里有阿里大牛直播讲解技术,以及Java大型互联网技术的视频免费分享给大家。

生产者-消费者模式很好地对生产者线程和消费者线程进行解耦,优化了系统整体的结构,同时由于缓冲区的作用,允许生产者线程和消费者线程存在执行上的性能差异,从一定程度上缓解了性能瓶颈对系统性能的影响;上面两种写法都是非常常规的写法,只能说能起码能在及格的基础上加个那么点儿分数,如果想要得高分可以去搜索搜搜 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.youkuaiyun.com/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 然后看看过程;

二叉树相关其他算法题

另外的话还有一些比较常见的关于树的算法,在文章的末尾,这里就不再赘述了:

如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:854630135,群里有阿里大牛直播讲解技术,以及Java大型互联网技术的视频免费分享给大家。


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 值判断之类的;
  • 想好再下笔:这一点其实也蛮重要的,不管是在纸上还是在我们平时的编程中,思路永远都是更重要的;

如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:854630135,群里有阿里大牛直播讲解技术,以及Java大型互联网技术的视频免费分享给大家。

说来说去还是关于代码的事,我觉得还是理清思路最重要,所以我们需要在一遍一遍熟悉代码的过程中,熟知这些代码的思路,只有搞清楚这些代码背后的原理了,我们才能正确且快速的写出我们心中想要的代码,而不是简单的去背诵,这样是没有很大效果的,以上...

几率大的现场手撕算法面试题(含答案) 现场手撕算法面试题个人经历比较少,但会收集网上面经,一般线下要求手撕算法的次数更多,其次就是来个共享链接自己在电脑上写代码。这个系列将会持续更新。 先提前了解一些常用的类以及方法: 类 Scanner: 从控制台输入: 方法一: Scanner sc = new Scanner(System.in); //构造一个Scanner对象实例 String queue = sc.next(); //接受... 阅读详情

相关推荐

算法面试必备-----手撕代码高频题

算法面试必备-----手撕代码高频题算法面试必备-----手撕代码高频题字节跳动阶乘末尾0的个数判断一颗二叉树是否为镜像对称 算法面试必备-----手撕代码高频题 字节跳动 阶乘末尾0的个数 输入描述: 输入为一行,n(1 ≤ n ≤ 1000) 输出描述: 输出一个整数,即题目所求 解法:要判断末尾有几个0就是判断可以整除几次10。10的因子有5和2,而在0~9之间5的倍数只有一个,2的倍数相对较多,所以本题也就转换成了求N阶乘中有几个5的倍数。 也就是每多出来一个5,阶乘末尾就会多出来一个0,这样n /

Avery123123的博客 3081

面试手撕-----生产者消费者模型

首先让我们回忆生产者消费者模型,其中生产者生产数据,消费者处理数据,两者通过一个共享的缓存区进行交互。其中涉及到多线程的线程同步问题,要保证当缓冲区满时生产者不会继续添加数据,缓冲区空时消费者不会尝试取数据,同时还要处理线程间的同步问题,避免竞态条件。

m0_73495584的博客 661

硬核拆解华为/大疆/小米硬件面试真题:手撕电路+考点思维导图(附答案)

设计一个增益为20dB、带宽≥100kHz的同相放大器,电源电压±5V,要求输出摆幅≥8V。12位ADC的参考电压2.5V,输入信号1kHz正弦波,峰峰值2V。输入24V转5V/10A的同步降压电路,开关频率500kHz,计算MOSFET的损耗并选择型号(需提供TI官网截图)。设计无人机图传系统,要求1080P@30fps,传输距离2km。FPGA设计中,100MHz时钟域到50MHz时钟域传输32位数据,给出同步方案并画时序图。“设计一个输入12V、输出5V/3A的同步降压电路,给出关键参数计算”

qq_63103361的博客 3540

软件测试面试中手撕代码应该准备哪些题?

把这个写会能应付90%以上的手撕代码面试!

菜鸟学识的博客 452

手撕代码:程序员基本功与面试必备技能

手撕代码是程序员通过基础语法和算法直接解决问题的核心能力,其本质在于深入理解数据结构与算法的底层实现原理。从技术实现层面看,这要求开发者掌握数组、链表等基础数据结构的内存管理,以及排序、查找等经典算法的时间复杂度分析。这种能力在工程实践中具有重要价值,既能提升代码调试和性能优化效率,也是应对大厂技术面试的关键。在应用场景上,手撕代码训练特别适合准备白板编程面试,或需要定制化开发解决方案的场合。通过每日算法题训练和边界条件处理等专项练习,开发者可以显著提升编码肌肉记忆和问题拆解能力。

weixin_29294395的博客 297

力扣、ACM与面试手撕代码的编程模式差异解析

在算法编程中,输入输出处理是基础但关键的技术环节。不同场景对程序接口的要求差异显著:力扣(LeetCode)采用隐式输入输出模式,开发者只需实现核心算法函数;ACM竞赛则要求完整的程序控制流,包括手动处理输入输出;面试手撕代码则更注重算法表达与边界处理。理解这些编程模式的差异,能帮助开发者快速适应不同场景的技术要求。从技术实现来看,输入输出处理涉及数据流控制、缓冲区管理等底层原理,良好的IO处理能力直接影响程序性能和稳定性。对于算法工程师和竞赛选手而言,掌握多模式编程能力,既能提升力扣刷题效率,也能应对AC

weixin_29758911的博客 279

常见面试手撕代码题

面试常见手撕代码题

YP_ADMIN的博客 2836

【手撕代码】单例模式及生产者/消费者模式

实现了单例模式和生产者消费者模式

love and share 615

5种主流触觉传感器对比:GelSight、DIGIT、Taxim、GelSlim和TACTO如何选?

本文深度解析了GelSight、DIGIT、Taxim、GelSlim和TACTO五款主流触觉传感器的技术原理与核心特性。通过对比视觉传感与电学传感两大技术路径,为机器人灵巧抓取、精密检测等应用场景提供清晰的选型决策指南,帮助开发者根据项目需求在分辨率、响应速度、集成度与成本之间找到最佳平衡。

Black的专栏 933

雷达截面积建模仿真(RCS)

在目标侧,其形状、材料、尺寸(相对于雷达波长)和相对于雷达观察方向的方向等特征都会影响雷达看到的RCS。严格的数学处理超出了讨论的范围,但入射波和目标之间的相互作用可以定性描述。对于给定的散射方向,可以积分求和物体表面电流的振幅相位,当电流同相时,会出现较大的回波幅度。随着目标方向和雷达视角随时间变化,电流振幅和相位会发生变化,形成看似随机的峰值和零点的散射模式,出现“相长”和“相消”干涉。除了雷达到目标的距离外,雷达截面(RCS)是RRE(雷达距离方程)中唯一与目标相关的参数,因此不在雷达设计者的。

qq_30796447的博客 3140

java生产者消费者代码_【面试必备】手撕代码,你怕不怕?

Part 1.生产者-消费者问题这绝对是属于重点了,不管是考察对于该重要模型的理解还是考察代码能力,这都是一道很好的考题,所以很有必要的,我们先来回顾一下什么是生产者-消费者问题;问题简单回顾如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:854630135,群里有阿里大牛直播讲解技术,以及Java大型互...

weixin_39598584的博客 228

CATIA二次开发CAA安装

安装资料在网盘里 Win7 64位 以下软件均已管理员身份运行: 1.安装vs2008->自定义安装->选择要安装的功能->Visual C+±>x64编译器和工具 1.2安装SP1 2.安装CATIA V5 R21->win7_XP->CATIA.P3.V5R21.Win64->setup.exe 2.1安装目录改为D盘 2.2环境目录位置默认 3.安装...

qq_39311458的博客 4642

delphi生产者消费者模式代码_【面试必备】手撕代码,你怕不怕?

Part 1.生产者-消费者问题这绝对是属于重点了,不管是考察对于该重要模型的理解还是考察代码能力,这都是一道很好的考题,所以很有必要的,我们先来回顾一下什么是生产者-消费者问题;问题简单回顾生产者消费者问题(英语:Producer-consumer problem),也称有限缓冲问题(英语:Bounded-buffer problem),是一个 问题的经典案例。该问题描述了共享固定大小 的两个线...

weixin_39610085的博客 106

计算机网络Wireshark实验(西安交通大学)

实验名称 计算机网络Wireshark实验 实验日期 2021 年 5 月 17 日 实验地点 XJTU

沉鱼叶的博客 1817

WPS和Excel通用!省市区四级联动下拉菜单保姆级教程(附最新行政区划数据包)

本文详细介绍了在WPS和Excel中实现省市区四级联动下拉菜单的完整教程,包含2024年最新行政区划数据的获取与整理方法。通过名称管理器和INDIRECT函数的组合,以及WPS的特色功能,帮助用户高效准确地录入四级行政区划信息,提升数据录入效率并减少错误。

s1t2u3的博客 615

生产者和消费者代码———操作系统_「面试必备」手撕代码,你怕不怕?

Part 1.生产者-消费者问题这绝对是属于重点了,不管是考察对于该重要模型的理解还是考察代码能力,这都是一道很好的考题,所以很有必要的,我们先来回顾一下什么是生产者-消费者问题;问题简单回顾如果想学习Java工程化、高性能及分布式、深入浅出。微服务、Spring,MyBatis,Netty源码分析的朋友可以加我的Java高级交流:854630135,群里有阿里大牛直播讲解技术,以及Java大型互...

weixin_39915500的博客 297

Python爬虫实战:高效爬取CNKI知网文献数据的技术解析

本文将详细介绍如何使用Python最新技术构建一个高效的CNKI知网文献爬虫系统。我们将从爬虫基础知识讲起,逐步深入到高级反反爬技术、分布式爬取、智能解析等前沿内容。文章包含完整的代码实现,涵盖请求模拟、数据解析、持久化存储等关键环节,并特别针对CNKI的反爬机制提供解决方案。通过本文,读者将掌握构建专业级学术文献爬虫的全套技术栈。关键词:Python爬虫、CNKI爬取、反反爬技术、文献数据挖掘、Scrapy框架本文详细介绍了如何使用Python最新技术构建CNKI知网文献爬虫。

2201_76125261的博客 2075

手撕代码相关

1.leetcode 160 python 相交链表 下面的方法感觉都需要事前知道或是推导出这个规律,然后才能写。 ###方法1: class Solution(object): def getIntersectionNode(self, headA, headB): """ :type head1, head1: ListNode :rty...

Better_Y0808的博客 365

强化学习(Q-learning、DQN) —— 理论、案例与交互式 GUI 实现

本文详细介绍了强化学习中基于值函数的 Q-learning 算法及深度强化学习的 DQN 方法,从基本理论、数学模型到算法流程均进行了全面阐述。通过迷宫导航、股票交易策略和机器人控制等典型案例,展示了强化学习在处理高维、非线性和时变问题中的强大适应性。为了帮助工程师和研究者更直观地理解强化学习的训练过程,本文还提供了一份基于 Python 与 PyQt6 实现的交互式 GUI 演示系统代码示例,用户可以在线调整参数,实时观察累计奖励和 Q 值的变化,从而更深入地理解算法收敛和策略演化的过程。

闲人编程的博客 1478

温度PID控制PLC程序

温度PID稳定控制程序,适用于欧姆龙PLC,对其他PLC也具有很大参考价值

【面经】手撕代码系列

文章目录手撕代码面试题汇总1. 必备技能2.面试中问到的问题 手撕代码面试题汇总 1. 必备技能 《剑指offer》   2.面试中问到的问题 求n个数里面前k个最大的数(除了快排,考虑其他方法) 给定两个字符串a和b(长度超过100w),在字符类型上b是a的子集,求b在a字符类型上的补集; 给定正整数N, 返回小于等于N且至少有一位重复数字的正整数的个数; 电话号码组合。下图是一个手...

aaon22357的博客 2038

贝叶斯统计 [韦来生编]

贝叶斯(Bayes)统计是近几十年来迅速发展起来的数理统计的一个重要分支。贝叶斯方法与经典统计方法的主要不同之处是进行统计推断时除了利用样本信息外,还要利用参数的先验信息,因此可以提高统计推断或统计决策的效果。它在经济、金融、生物、医学、自然科学和社会科学等许多领域具有广泛的应用。贝叶斯方法的研究已渗透到了统计学的几乎所有领域。作者在给中国科学技术大学概率论与数理统计专业本科生开设的“数理统计”课程中有一章专门讲授“贝叶斯方法和统计决策理论”,近十年来也给中国科学技术大学概率统计专业研究生开设过几次“贝叶斯分析”课。本书是在对过去讲稿的内容作了适当的增减和调整的基础上完成的。  本书共分六章。第1章是绪论,介绍了贝叶斯统计的若干基本概念,同时对必要的数理统计的基础知识有重点地作了回顾。第二章介绍了确定先验分布的若干可供选择的方法。第三章介绍了几类常见统计模型参数的后验分布的主要结果和计算方法。第四章和第五章分别介绍了“贝叶斯统计推断”和“贝叶斯统计决策”的内容。第六章介绍了贝叶斯统计计算的若干方法,包括蒙特卡洛方法和马尔可夫链蒙特卡洛(MCMC)方法以及相关统计软件的简介。本书的每一章附右大量的例题和习题,书末附有常用的几个表格和部分习题解答供读者查用。  大约可在54学时内讲授本书的第1章至第六章的主要内容,教师可根据学时需要选讲本书第1章至第六章的部分内容。书中标“*”号的章节可略去不讲,留给读者作为阅读材料。第六章例题中的R代码和部分例题的数据文件等可从作者个人主页下载。  作者在编写本书的过程中参考了J.O.Berger教授的《统计决策论及贝叶斯分析》和茆诗松教授编写的《贝叶斯统计》等书中的一些内容,以及《贝叶斯分析》一书中由张伟平博士编写的有关贝叶斯统计计算的一些内容。在此表示衷心感谢。  本书编写准备过程中,中国科学技术大学统计与金融系的几个研究生帮助完成了本书部分中文的录入和排版,作者对他们的辛勤工作表示真诚的感谢。高等教育出版社为本书的出版给予了大力支持。在此一并致谢。

大厂Java面试刷题必备:从海量面经精挑的大厂高频手撕面试题!

准备大型科技公司(如阿里巴巴、腾讯、字节跳动等)的Java面试,刷题是关键环节。建议:先用书籍系统复习,如《Java核心技术 卷I》(Cay S. Horstmann著)和《Effective Java》(Joshua Bloch著),这些覆盖90%基础考点。优先刷高频题(如LeetCode前100题),时间分配:60%算法、30%Java基础、10%系统设计。为高效备战大厂面试,我精选了7道最高频的Java手撕题,覆盖数据结构、并发、算法等核心领域。在刷题前,先巩固Java基础,避免盲目刷题。

程序员高级码农的博客 1549

IEM模型,地表粗糙度模拟

利用微波反演土壤水分时候,利用IEM模型可以实现对随机地表的模拟

ssm框架搭建

这个是一个在idea上成功跑起来的ssm框架项目,保存在这里方便以后使用直接下载

一篇速通力扣模式、ACM模式、面试手撕模式

本文系统解析了算法学习中的三种编程模式:力扣模式(核心代码模式)聚焦算法逻辑,屏蔽输入输出,适合刷题入门;ACM模式要求完整处理输入输出,严格遵循格式,是企业笔试的核心考察点;面试手撕模式则需灵活应对,在写出正确代码的同时清晰表达思路。文章通过定义、特点、适用场景和实操示例详细对比三者的差异,建议新手从力扣模式入手,逐步过渡到ACM模式,面试时注重代码与表达的结合。掌握这三种模式的区别和应用场景,能有效提升算法学习效率和实战表现。

2401_87662859的博客 2974

大模型面试手撕代码指南

本文介绍了一个大模型面试手撕代码的GitHub项目,包含Attention机制、强化学习方法和常用工具的实现。目前已实现Self-Attention、Multi-Head Attention、RoPE等核心Attention模块,以及LoRA、LayerNorm等基础组件。项目代码经过GPT优化,仅展示基本逻辑,后续将补充文字讲解。链接:https://github.com/Ashside/LLM-HandCoding-Interview

Ashside的博客 455

酒酒数字IC手撕代码总结资源介绍:数字IC设计工程师的实战宝库

酒酒数字IC手撕代码总结资源介绍:数字IC设计工程师的实战宝库 去发现同类优质开源项目:https://gitcode.com/ 数字IC设计领域的手撕代码实践总结,是每一位数字IC设计工程师提升编程能力和巩固理论知识的必备资料。今天,我们为您推荐一个开源项目——酒酒数字IC手撕代码总结,它不仅包含多个精选练习题目,还能帮助您在实际编程中游刃有余。 项目介绍 酒酒数字IC手撕代码总结是一个开源资源...

gitblog_06707的博客 817
上一篇: 微信公众号发送消息模板(java)
下一篇: Java线程池详解
javaQQ群 866865133
博客等级 码龄8年 59粉丝 309原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值