笔试中题目的时间复杂度以及快读快写介绍

   本篇主要分享下近期笔试中, 关于时间复杂度得一些经验.

1.题目格式以及快读快写

   目前比较流行的题目格式大致有两种, 一种是核心代码格式(以力扣为主), 另一种是ACM格式(NOIP为主).

   核心代码格式, 是将数据包装后交给我们, 以力扣为例, 这一题交给我们的是一个数组, 我们可以直接对数组进行操作, 不需要考虑去处理数据.

   ACM格式, 是我们从控制台读输入开始处理数据, 最后将我们得到的数据也输出到控制台上.

核心代码格式

   核心代码格式, 是出题平台帮助我们把输入输出数据读取进来, 所以我们在做题时不需要考虑读输入, 写输出这些操作, 直接读题, 写核心逻辑.

   优点就是省去了写io操作的时间, 可以更专注于题目的逻辑.

   然而, 缺点也很明显, 因为目前的笔试题, 主流还是ACM格式, 当题目的单个用例不多, 而且输入容易处理的时候, 影响相对较小, 但是当题目是树, 图这些时, 没有官方给你构造好现成的TreeNode, 可能就会卡在处理数据上.

   以力扣94题, 二叉树的中序遍历为例, 我们可以看到, 题目给到我们的数据是一个TreeNode格式的数据, 我们可以直接将其当作树去读写, 那么, 出题官方是怎么构造TreeNode的呢?

   力扣有一个PlayGround调试器, 我们可以先看一下调试器中的内容, 先以两数之和为例, 循序渐进

   首先力扣的playground调试器(以java为例), 页面可以分为四个区域,

  1. Solution类, 这是官方在题目页面呈现给我们的代码部分, 我们只需要在Solution类中填核心代码即可, 输入, 以及运行代码, 是全部交给官方去做的.

  2. MainClass类, (题外话, 这个类名挺有意思, 一般的ACM格式中, java类名都是Main)

   首先看到第41行,main()方法, 有用java做过acm格式题目的同学, 应该看到这里就明白了, 力扣的OJ(判题机)本质上也是一个ACM格式的, 只不过官方做的比较好, 把输入输出这些隐藏了起来.

   MainClass类中的main()方法, 是官方用来读取输入, 构造核心代码需要的数据结构的, 你写的函数怎么启动, 怎么输出结果, 都是在main()方法中操作的.

   main()方法的构建, 也就是读输入代码怎么写, 要和控制台的输入一一对照, 控制台输入就是接下来要讲的第3个区域,

   这题给我们的输入是两个字符串, 一个字符串占一行, 这两个字符串分别是[2,7,11,15]9

   注意, 输入是包含"[""]"以及","的.

   接下来开始逐行读官方的main()函数(考虑到阅读体验, 我将main()方法放到了MainClass的最前面)

 class Solution {
     public int[] twoSum(int[] nums, int target) {
 ​
     }
 }
 ​
 public class MainClass {
     public static void main(String[] args) throws IOException {
         //首先是读取控制台(也就是第3个区域Stdin)中的输入, 注意, 这里用的是java中的读缓存, 相较于Scanner来说, 速度更快(关于io以及和Scanner的区别这部分在下一节会讲), 缺点是很难用, 一般是按行读取, 返回的是一行中的所用内容(String格式).
         BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
         //初始声明读取的每行的数据
         String line;
         //这里while中的每一次循环,都是一个新的用例, line = in.readnLine()一是用来判断还有没有新的用例, 如果有的话就将新用例的第一行数据赋值给line
         while ((line = in.readLine()) != null) {
             //这里是力扣官方封装好的一些处理字符串的函数, 就像方法名一样, 将字符串转化为int数组, 方法内怎么操作的可以看下面具体讲解
             int[] nums = stringToIntegerArray(line);
             //这里是读取用例中的第二行数据
             line = in.readLine();
             //因为题目要求的输入是数组和整数,所以这里将字符串转化为整数
             int target = Integer.parseInt(line);
             //这里调用了我们写的方法, 获取我们代码的返回值
             int[] ret = new Solution().twoSum(nums, target);
             //对我们的返回值稍微处理下, 以力扣统一的格式进行输出
             String out = integerArrayToString(ret);
             //输出经过处理后的结果
             System.out.print(out);
         }
     }
 ​
     /**
      * 就像字面意思一样, 将力扣风格的字符串转化为int[], 注意, 仅限力扣风格, "[1,2,3]"这种
      */
     public static int[] stringToIntegerArray(String input) {
         //首先使用trim()去除掉用例头尾多余的空格
         input = input.trim();
         //力扣的输入都是用"[]"包起来的, 所以这里直接去头去尾, 舍弃掉"[]"
         input = input.substring(1, input.length() - 1);
         //这里是对用例是空集的情况进行处理, 即你的输入用例是"[]"
         if (input.length() == 0) {
             //当你输入的用例是空空集的时候, 理所应当, 交给你的int[]也是空的
             return new int[0];
         }
         //因为输入的用例数据是用","进行分开的, 所以这里根据","进行分组
         String[] parts = input.split(",");
         //这里终于, 终于声明了要直接交给我们的数组了
         int[] output = new int[parts.length];
         //整个for就是将parts中string格式的数据转化为int格式交给我们
         for (int index = 0; index < parts.length; index++) {
             //因为我们的用例中, 除了",", 可能会包含空格, 这里依旧是对每个用","分隔开的数据去除掉头部尾部的空格
             String part = parts[index].trim();
             //将字符串转化成了int, 将给output
             output[index] = Integer.parseInt(part);
         }
         //最后, 这个output就是直接交给我们的nums数组
         return output;
     }
 ​
     /**
      * 将int[]转化为字符串, 依旧是力扣风格的字符串:)
      *
      * @param nums   这是你输入的int[], 等下就是对它做变化
      * @param length 这个参数, 感觉很鸡肋
      * @return
      */
     public static String integerArrayToString(int[] nums, int length) {
         //数组长度是0的时候, 返回值就是空数组的字符串形式
         if (length == 0) {
             return "[]";
         }
         //这里写的也很不美观, 用String存储答案, 但是每次都要对String修改, 不是很合理
         String result = "";
         //这个for就是将nums中的数字, 逐个加到result中, 并且在后面加上", ", 注意是逗号和空格两个字符
         for (int index = 0; index < length; index++) {
             int number = nums[index];
             result += Integer.toString(number) + ", ";
         }
         //为最后的答案添加上"[]", 并且去除掉最后多出来的", "
         return "[" + result.substring(0, result.length() - 2) + "]";
     }
 ​
     //这个就是我觉得int[]转化为string方法写的鸡肋的原因, 不是很理解他为什么要多此一举
     public static String integerArrayToString(int[] nums) {
         return integerArrayToString(nums, nums.length);
     }
 }

   可以看出来, 力扣的判题思路, 也是从控制台读取输入, 在控制台打印输出, 这与主流的ACM格式的OJ(判题机)没有太大区别,

   playground中的代码基本上就是在处理输入和输出, 力扣将输入输出包装好, 我们在做题的时候就不用去考虑输入输出浪费时间.

  1. 输入区域, 这里就是我们填入的测试用例, 一般每个题目的测试用例都是由一定的格式, 而题目输入用例的不同, 导致力扣处理输入的代码应该也会有所不同.

  2. 额外函数, 力扣的题目已经多达几千道, 并且每道题的输入风格都差不多相同, 于是一些核心的处理数据代码就可以被抽象出来, 需要处理数据时, 直接调用之前封装好的处理数据的方法, 对力扣来说可以节省不少时间.

   力扣中如何处理简单的测试用例我们已经看过了, 接下来就轮到树了.

   我们在ACM笔试题中遇到树时, 一般处理输入输出是比较头疼的, 常用的方式是邻接表和临界矩阵.

   像力扣处理树时, 这种创建一个新的数据结构去处理输入是比较少见, 而且不是很推荐在笔试中使用, 因为大量的new操作, 相较于直接使用邻接表和邻接矩阵, 会浪费不少时间(具体使用邻接表和临界矩阵会在以后的复习笔记中讲解), 所以这里力扣这里处理树的方式, 仅供参考(技多不压身, 以防万一)

代码有点长, 我分几张截图了:)

   力扣处理树相关题目的用例输入时, 整体代码逻辑和两数之和这种简单输入相同, 交给我们去构造的Solution类, 自己处理输入输出, 并且执行交给判题机判断结果的MainClass类.

   唯一不同的就是处理输入的方式, 因为大部分理念都是相同的, 所以下面就着重介绍下力扣处理树的函数stringToTreeNode()

     public static TreeNode stringToTreeNode(String input) {
     //和上面一样, 处理输入数据中多余的空格
     input = input.trim();
     //和上面一样, 舍弃输入数据中的"[]"
     input = input.substring(1, input.length() - 1);
     //和上面一样, 输入用例时"[]"时, 交给我们的TreeNode就是null
     if (input.length() == 0) {
         return null;
     }
     //和上面一样, 数据用,分隔开
     String[] parts = input.split(",");
     //力扣给出的树的数据风格往往是数组格式的,0号位置的往往是根节点的val
     String item = parts[0];
     //这是最后要交给我们的树的根节点
     TreeNode root = new TreeNode(Integer.parseInt(item));
     //力扣中树的输入往往是按照行给出来的, 所以这里使用层次遍历(广度优先搜索的一种)去将每个点构造成树
     Queue<TreeNode> nodeQueue = new LinkedList<>();
     //初始时将根节点添加进队列中
     nodeQueue.add(root);
     //输入数据中0号位置时根节点的值, 已经使用过了, 所以这里从1号节点开始
     int index = 1;
     //bfs的经典写法, 队列不空时就一直遍历
     while (!nodeQueue.isEmpty()) {
         //从队列中弹出队首元素
         TreeNode node = nodeQueue.remove();
         //当输入的数据遍历完的时候提前结束树的构造
         if (index == parts.length) {
             break;
         }
     //            接下来的思维会有一点抽象, 因为这题的输入处理不是特别典型
         //继续依次读取输入用例的value
         item = parts[index++];
         item = item.trim();
         //当当前读到的节点不为空时, 就将其添加到树上, 并将这个新的节点插到队中去
         if (!item.equals("null")) {
             int leftNumber = Integer.parseInt(item);
             //当前val时之前弹出的node左孩子的值, 这里创建左子树
             node.left = new TreeNode(leftNumber);
             //将左子树添加到队列中去
             nodeQueue.add(node.left);
         }
         //接下来的思路和上面一样, 便不再过多赘述
         if (index == parts.length) {
             break;
         }
 ​
         item = parts[index++];
         item = item.trim();
         if (!item.equals("null")) {
             int rightNumber = Integer.parseInt(item);
             node.right = new TreeNode(rightNumber);
             nodeQueue.add(node.right);
         }
         //这层while执行结束时, 是为弹出的节点构造了左右子树, 并把左右子树添加到队中, 以供后续使用
     }
     //返回构造完成的树
     return root;
     }

   对于树相关题目, 力扣的做法也是, 从控制台读取输入, 将数据包装好交给我们, 然后再执行我们写好的方法, 将我们的方法打印, 交给OJ.

ACM格式

   ACM格式中的用例, 是在控制台给出, 我们的答案, 当然也是要打印在控制台上.

   相较于核心代码格式, ACM就显得有些平平无奇, 朴实无华.

   以牛客oj中a+b为例, 需要自己读取控制台中的输入. 并将自己的答案打印到控制台上.

   经过上面的介绍, 其实大致可以看出核心代码格式和ACM格式很像, 核心代码格式本质上就是帮我们处理了输入输出的ACM格式.

   那为什么我要花这么长的篇幅去介绍核心代码格式呢?

   主要是因为, 用习惯核心代码格式的同学, 在接触ACM时, 有可能会因)为读取测试用例而导致TLE(Time Limit Exceeded),

   所以, 推荐大家可以直接看下力扣官方读取用例的模板, 力扣的playground调试器包含了相对热门语言的快读模板(例如上面讲解过的java快读模板), C++, Java, Python这些找工作热门语言, 都可以随便打开一个题目, 打开playground调试器, 看一下官方是怎么做的.

   另外关于快读和普通读, 还有一些我个人做题时的小技巧:

      在数据量比较大(我一般是在单个用例的数据量在1e4以上)的时候, 一般推荐使用快读

      数据量在1e5级别的题目, 无脑使用快读,

      数据量在1e4的题目, 如果输出的量也很大, 请酌情考虑使用快读.

      数据量在1e3以及以下的题目, 正常使用Scanner即可

快读和普通读写的效率对比

   接下来就以牛客"a+b"为例, 验证在小数据范围内, 普通读和快读的差距.

   首先是java普通读写的耗时

   可以看到, java普通读写的耗时大概在52ms(读写消耗大量时间)

   接着, 是java的快读耗时

   可以看到, 运行时间有了明显的缩减, 这题的数据量在100以内, 即一个测试用例下, 最多有200个数需要我们去手动读入, 此时, 快读和普通读写的速度便相差三倍左右.

我个人使用的快读模板

   因为感觉力扣风格的快读不是很好用, 没有自己写的快读看起来舒服, 所以推荐大家读懂各个语言是怎么实现快读的, 然后总结出一套自己用起来最舒服的快读模板.

   我个人经常使用java和go, 下面贴一下我个人使用的模板

java快读模板

 import java.io.*;
 ​
 class Main {
     public static void main(String[] args) throws IOException {
 //        快读
         BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
 //        快写
         BufferedWriter out = new BufferedWriter(new OutputStreamWriter(System.out));
 //        读取数字(一行只有一个数字的情况)
         int n = Integer.parseInt(in.readLine());
 //        读取数组数组(数据之间以","间隔), 初始时读出来都是String格式的数组
         String[] split = in.readLine().split(",");
         //以读取int数组为例, 将string数组转化为int数组
         int[] w = new int[split.length];
         for (int i = 0; i < split.length; i++) {
             w[i] = Integer.parseInt(split[i]);
         }
 //        读取字符串(一行只有一个字符串的情况), 并将其转化为char[]数组
         char[] chars = in.readLine().toCharArray();
 ​
 //        快写, 注意, write中只能传字符串, 并且write不会自动换行
         out.write("Hello, World!");
 //        刷新缓存区, 将存入到缓存中的快写数据显示出来, 一般放到return前面, 最后一步再执行
         out.flush();
         return;
     }
 }

go的快读模板

额外附送go 的快读模板, C++ like, 可以对比下, 真的太好用了> _ <

 package main
 ​
 import (
     "bufio"
     "fmt"
     "os"
 )
 ​
 func main() {
     in := bufio.NewReader(os.Stdin)
     out := bufio.NewWriter(os.Stdout)
     //在方法结束前刷新缓存区
     defer out.Flush()
     //依旧以a+b为例
     var t, a, b int
     fmt.Fscan(in, &t)
     for ; t > 0; t-- {
         fmt.Fscan(in, &a, &b)
         fmt.Fprintln(out, a+b)
     }
 }
 ​

2.读懂数据范围

时间限制

   除了题目格式, 题目存在中一些关于数据范围的提示, 这个也是非常重要的信息, 类似于高考数学题x>0这种重要条件.

因为数据范围一旦确定, 能够选择的方法就会少很多, 甚至只剩1个方法可以选择.

在某种程度上, 我认为数据范围算是官方给出的提示, 当然力扣官方确实也把这个当作提示:)

在看数据范围之前, 我们首先要先了解题目的时间限制.

依旧以牛客oj中 a+b  为例,

可以看到, 时间限制是 c/c++要求在1s内完成, 其他语言在2s内完成. 这里的时间限制一般是以c/c++为标准来的, 其他语言速度一般略慢于c/c++, 所以给到了2s的时间限制. (可以理解为c/c++一秒做完的事情, 其他语言需要两秒才能完成)

我对c++不是很熟悉, 这里贴出其他大佬的验证博客, 1s内能执行多少次for循环, c++在1s内大概执行1e9级别的运算, 所以我们代码的运算量必须要小于这个级别

考虑到io时间, 以及进行其他运算的一些时间, 推荐代码的运算量处在1e7这个级别. 一般来说, 代码运算量在1e7级别是可以稳定通过的(被卡io除外), 代码量在1e8级别便是在超时边缘徘徊.

当然, 有的题目给出的时间限制在c/c++ 2s, 其他语言4s, 虽然时间限制放宽了, 但是只是将危险的边缘拉远了一些, 避免了一些极端情况, 此时仍然推荐代码运算量不高于1e8

时间复杂度

在上面讲完时间限制后, 接下来讲时间复杂度就会轻松很多

依旧以a+b为例,

可以看到, 在一个测试用例中, 会出现t组数, 每组是两个数字, 核心代码很简单(这里先忽视输入)

 while(t-->0){
     System.out.println(a+b);
 }

可以看到, 这一题单个测试用例最多执行t次, 那这里假设t是100, 这里的运算量就是100这个级别(先忽视掉io浪费的时间)

当题目的数据范围在100时, 我们为了让自己的运算量尽量维持在1e7级别, 在这一题, 可以进行一个O(n^3)级别的运算仍旧不会超时( 10^6<10^7)

显然, 当数据范围在 1000时, 我们的代码的时间复杂度尽力不要超过O(n^2logn), 此时的运算量大致在1e7左右

当数据量在1e4~1e5时, 时间复杂度尽量控制在O(nlogn), 此时的运算量大致在1e6-1e7左右.

当数据量大于1e5时, 复杂度尽量控制在O(n)或O(logn)级别.

特别的, 当题目中c/c++的限制不是1s的时, 而自己的想法处在超时的边缘, 可以自己大致根据运算量估计下代码会不会超时.

根据时间复杂度选择解体思路

这一小节主要摘抄自yxc的总结, 由数据范围反推算法复杂度以及算法内容

笔试题中常见的数据范围在1e3~1e6之间

这里直接给出我比较常用的算法的时间复杂度

标准库中的排序的时间复杂度视为O(nlogn),

二分的时间复杂度为O(logn)

单调栈的时间复杂度一般在O(n)级别

并查集的时间复杂度一般在O(n)级别(接近O(1), 一般可以视为O(1))

堆(优先队列)的时间复杂度一般在O(nlogn)级别( 建堆的时间复杂度为O(n), 弹出节点或者插入节点后调整堆的时间复杂度为O(logn))

Hash表查找的时间复杂度视作O(1)(极个别情况会卡, 起码我没有被卡过)

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值