• 第五章、虚拟机栈


    一、JVM栈概述

    1.1、虚拟机栈出现的背景

    由于跨平台性的设计,java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的,寄存器和CPU的耦合性高。

     JVM为何选择基于栈的结构?

    JVM为何要基于栈来设计有几个理由。一个是JVM要设计成与平台无关的,而平台无关性就是要保证在没有或者有很少的寄存器的机器上也要同样能正确地执行Java代码。例如,在80X86的机器上寄存器就是没有规律的,很难针对某一款机器设计通用的基于寄存器的指令,所以基于寄存器的架构很难做到通用。在手机操作系统方面,Google的Android平台上的Dalvik VM就是基于特定芯片(ARM)设计的基于寄存器的架构,这样在特定芯片上实现基于寄存器的架构可能更多考虑性能,但是也牺牲了跨平台的移植性,当然在当前的手机上这个需求还不是最迫切的。

    还有一个理由是为了指令的紧凑性,因为Java的字节码可能在网络上传输,所以class文件的大小也是设计JVM字节码指令的一个重要因素,如在class文件中字节码除了处理两个表跳转的指令外,其他都是字节对齐的,操作码可以只占一个字节大小,这都是为了尽量让编译后的class文件更加紧凑。

    根据栈设计的优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

    1.2、内存中的堆与栈

      栈是运行时的单位,而堆是存储的单位。

      1. 栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪儿。

      2. 一般来讲,对象主要都是放在堆空间的,是运行时数据区比较大的一块

      3. 栈空间存放基本数据类型的局部变量以及引用数据类型的对象的引用

    1.3、JVM栈的基本内容

    (1)、虚拟机栈(Java Virtual Machine Stack)是什么?

    • java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。 每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),一个栈桢对应这一次次java方法调用。它是线程私有的。

    • 栈是一种快速有效的分配存储方式,访问速度仅次于PC寄存器(程序计数器)

    (2)、虚拟机栈(Java Virtual Machine Stack)`生命周?

      生命周期和线程是一致的,随着线程的创建而创建,消亡而消亡

    (3)、作用

      主管java程序的运行,它保存方法的局部变量8种基本数据类型对象的引用地址部分结果,并参与方法的调用和返回

      • 局部变量:相较于成员变量(成员变量或称属性)

      • 基本数据变量:8种基本数据类型

      • 引用类型变量:类,数组,接口

    (4)、栈的特点(优点)

    • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

    • JVM直接对java栈的操作只有两个

      • 每个方法执行,伴随着进栈(入栈,压栈)

      • 执行结束后的出栈工作

    • 对于栈来说不存在垃圾回收。

    (5)、栈中可能出现的异常

      Java虚拟机规范允许Java栈的大小是动态的或者是固定不变的。

    1、固定大小:如果采用固定大小的java虚拟机,那每一个线程的虚拟机栈容量可以在线程创建时独立选定,如果线程请求分配的栈容量超过Java虚拟机栈的允许的最大容量,Java虚拟机就会抛出一个堆栈溢出(StackOverFlowError)异常

    public class T2 {
        public static void main(String[] args) {
          main(args);
        }
    }

    2、动态扩展:如果Java虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机就会抛出内存溢出(OutOfMemeoryError)异常

    (6)、设置栈的大小

      我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定函数调用的最大可达深度。

    public class T2 {
    
        /**
         * 默认情况先 count:11402
         * 设置栈的大小为256k后 count:2460
         *
         */
        private  static int count =0;
        public static void main(String[] args) {
            System.out.println(count);
            count++;
            main(args);
        }
    }

      可以看出设置栈大小生效。

    二、栈的存储单位

    栈中存储什么?

    • 每个线程都有自己的栈,栈中的数据都是以栈桢(Stack Fream)的格式存在的。

    • 在这个线程上正在执行每个方法都各自对应一个栈桢(Stack Fream)。

    • 栈桢是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。

    2.1、栈运行原理

    • JVM对栈的操作只有两种,就是对栈桢的压栈出栈,遵循“先进后出/后进先出”原则。

    • 在一条线程中,一个时间点上,只会有一个活动的栈桢,即只有当前正在执行的方法的栈桢(栈顶栈桢)是有效的,这个栈桢被称为当前栈桢(Current Frame),与当前栈桢对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。

    • 执行引擎运行的所有字节码指令只针对当前栈桢进行操作

    • 如果在该方法中调用其他方法,对应的新的栈桢会被创建出来,方法栈的顶端,成为新的当前栈桢。

    • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另一个线程的栈帧

    • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果,给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

    • Java方法有两种返回函数的方式,一种是正常的函数返回使用return指令,另一种是抛出异常,不管使用哪一种方式,都会导致栈帧被弹出

    2.2、栈桢的内部结构

      每个栈帧中存储着

      1. 局部变量表(Local Variables)

      2. 操作数栈(Operand Stack)(或表达式栈)

      3. 动态链接(Dynamic Linking)(或执行运行时常量池的方法引用)

      4. 方法返回地址(Return Adress)(或方法正常退出或者异常退出的定义)

      5. 一些附加信息

    三、局部变量表(Local Variables)

    1. 局部变量表也被称之为局部变量数组本地变量表

    2. 定义为一个数字数组,主要用于存储方法参数定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型

    3. 由于局部变量表是建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题

    4. 局部变量表所需的容量大小是在编译期确定下来,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

    5. 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多对一个方法而言,他的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间。

    6. 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁

    3.1、查看帧的局部变量表

      利用javap命令对字节码文件进行解析查看main()方法对应栈帧的局部变量表,可以在IDEA 上安装jclasslib byte viewcoder插件查看方法内部字节码信息剖析,以main()方法为例

      如图:简单解释字节码指令的意义。

      length:局部边的作用域。

    3.2、变量槽(slot)的理解与演示

    1. 参数值的存放总是从局部变量数组的index0开始,到数组长度-1的索引结束

    2. 局部变量表,最基本的存储单元是Slot(变量槽)

    3. 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量。

    4. 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot

    5. byte、short、char、float在存储前被转换为int,boolean也被转换为int,0表示false,非0表示true,long和double则占据两个slot

    1. JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。

    2. 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照声明顺序被复制到局部变量表中的每一个slot上

    3. 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。(比如:访问long或者double类型变量)

    4. 如果当前帧是由构造方法或者实例方法创建的(意思是当前帧所对应的方法是构造器方法或者是普通的实例方法),那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序排列。

      • 静态方法中不能引用this,是因为静态方法所对应的栈帧当中的局部变量表中不存在this
    public class T3 {
    
         private int count = 1;
        //静态方法不能使用this
         public static void testStatic(){
             //编译错误,因为this变量不存在与当前方法的局部变量表中!!!
             System.out.println(this.count);
         }
    }

    3.3、slot的重复利用

    栈帧中的局部变量表中的槽位是可以重复利用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

    private void test2() {
            int a = 0;
            {
                int b = 0;
                b = a+1;
            }
            //变量c使用之前以及经销毁的变量b占据的slot位置
            int c = a+1;
        }

      上述代码对应的栈帧中局部变量表中一共有多少个slot,或者说局部变量表的长度是几?

      答案是3:

      变量b的作用域是

    {
         int b = 0;
         b = a+1;
    }

      this占0号、a单独占1个槽号、c重复使用了b的槽号

    3.4、静态变量与局部变量的对比

      变量的分类:

    • 按照数据类型分:

      • 基本数据类型;

      • 引用数据类型;

    • 按照在类中声明的位置分:

      • 成员变量:在使用前,都经历过默认初始化赋值

        • static修饰:类变量,类加载链接的准备preparation阶段给类变量默认赋0值—>初始化阶段initialization给类变量显式赋值即静态代码块赋值;

        • 不被static修饰:实例变量,随着对象的创建,会在堆空间分配实例变量空间,并进行默认赋值

      • 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过

    3.5、补充说明

    • 在栈帧中,与性能调优关系最为密切的部分就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递

    • 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收

    四、操作数栈(Operand Stack)

    • 栈 :可以使用数组或者链表来实现,数组和链表是真实存在的数据结构。

    • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出的操作数栈,也可以称为表达式栈

    • 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)或出栈(pop)

      某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用他们后再把结果压入栈。(如字节码指令bipush操作)比如:执行复制、交换、求和等操作。

    • bipush:将一个常量加载到操作数栈。

    • istore_<n>:将一个数值从操作数栈存储到局部变量表。

    • iload_<n>:将一个局部变量加载到操作栈。

    4.1、操作数栈特点

    • 操作数栈,主 要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间

    • 操作数栈就是jvm执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的

    • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译器就定义好了,保存在方法的code属性中,为max_stack的值

    • 栈中的任何一个元素都是可以任意的java数据类型 32bit的类型占用一个栈单位深度 64bit的类型占用两个栈深度单位

    • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈push和出栈pop操作来完成一次数据访问

    • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。

    • 操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类验证阶段的数据流分析阶段要再次验证。

    • 另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。

    4.2、操作数栈代码追踪

      结合上图结合下面的图来看一下一个方法(栈帧)的执行过程

    ①、 9入栈;

     、9弹出操作数栈,进入局部变量表 注意:局部变量表的0号位被构造器占用,这里的9从局部变量表1号索引开始

    ③、压入12;

    ④ 、12出栈,存储12进入局部变量表;

    ⑤、局部变量表中把索引为1和2的是数据取出来,放到操作数栈;

    ⑥、iadd相加操作:调用执行引擎进行计算。

    ⑦、iadd操作结果21出栈

    ⑧、将21存储在局部变量表索引为3的位置上istore_3

    4.3、栈顶缓存技术(Top-of-Stack Cashing)

    • 基于栈式架构的虚拟机所使用的零地址指令(即不考虑地址,单纯入栈出栈)更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数

    • 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

    五、动态链接(Dynamic Linking)

      动态链接:指向运行时常量池的方法引用

    运行时常量池位于方法区(注意: JDK1.7 及之后版本的 JVM 已经将运行时常量池从方法区中移了出来,在 Java 堆(Heap)中开辟了一块区域存放运行时常量池。)

    • 为什么需要常量池呢?

      常量池的作用,就是为了提供一些符号和常量,便于指令的识别。

    • 每一个栈帧内部都包含一个指向运行时常量池Constant pool或该栈帧所属方法的引用包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invokedynamic指令

    • 在Java源文件被编译成字节码文件中时,所有的变量和方法引用都作为符号引用(symbolic Refenrence)保存在class字节码文件(javap反编译查看)的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用(#)最终转换为调用方法的直接引用。

    六、方法的调用:解析与分派

      在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

      • 静态链接 当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

      • 动态链接 如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接

      对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Bingding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

      • 早期绑定 早期绑定就是指被调 用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

      • 晚期绑定 如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。

      随着高级语言的横空出世,类似于java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装,集成和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。

      Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。

    6.1、虚方法和非虚方法

      子类对象的多态性使用前提:

      1. 类的继承关系(父类的声明)
      2. 方法的重写(子类的实现)

      实际开发编写代码中用的接口,实际执行是导入的的三方jar包已经实现的功能

      非虚方法

      • 如果方法在编译器就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法

      • 静态方法、私有方法、final方法、实例构造器(实例已经确定,this()表示本类的构造器)、父类方法(super调用)都是非虚方法

      其他所有体现多态特性的方法称为虚方法

    6.2、虚拟机中提供了以下几条方法调用指令

    1. 普通调用指令:

      1. invokestatic:调用静态方法,解析阶段确定唯一方法版本;

      2. invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本;

      3. invokevirtual调用所有虚方法;

      4. invokeinterface:调用接口方法;

    2. 动态调用指令(Java7新增)

      1. invokedynamic:动态解析出需要调用的方法,然后执行 .

      2. 前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。

      其中invokestatic指令和invokespecial指令调用的方法称为非虚方法 其中invokevirtual(final修饰的除外,JVM会把final方法调用也归为invokevirtual指令,但要注意final方法调用不是虚方法)、invokeinterface指令调用的方法称称为虚方法。

    class Father {
        public Father(){
            System.out.println("Father默认构造器");
        }
    
        public static void showStatic(String s){
            System.out.println("Father show static"+s);
        }
    
        public final void showFinal(){
            System.out.println("Father show final");
        }
    
        public void showCommon(){
            System.out.println("Father show common");
        }
    
    }
    public class Son extends Father{
        public Son(){
            super();
        }
    
        public Son(int age){
            this();
        }
    
        public static void main(String[] args) {
            Son son = new Son();
            son.show();
        }
    
        //不是重写的父类方法,因为静态方法不能被重写
        public static void showStatic(String s){
            System.out.println("Son show static"+s);
        }
    
        private void showPrivate(String s){
            System.out.println("Son show private"+s);
        }
    
        public void show(){
            //invokestatic
            showStatic(" 大头儿子");
            //invokestatic
            super.showStatic(" 大头儿子");
            //invokespecial
            showPrivate(" hello!");
            //invokespecial
            super.showCommon();
            //invokevirtual 因为此方法声明有final 不能被子类重写,所以也认为该方法是非虚方法
            showFinal();
            //虚方法如下
            //invokevirtual
            showCommon();//没有显式加super,被认为是虚方法,因为子类可能重写showCommon
            info();
    
            MethodInterface in = null;
            //invokeinterface  不确定接口实现类是哪一个 需要重写
            in.methodA();
    
        }
    
        public void info(){
    
        }
    }
    interface MethodInterface {
        void methodA();
    }

    6.3、关于invokedynamic指令

    • JVM字节码指令集一直比较稳定,一直到java7才增加了一个invokedynamic指令,这是Java为了实现【动态类型语言】支持而做的一种改进

    • 但是java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令.直到Java8的Lambda表达式的出现,invokedynamic指令的生成,在java中才有了直接生成方式

    • Java7中增加的动态语言类型支持的本质是对java虚拟机规范的修改,而不是对java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在java平台的动态语言的编译器

    6.4、动态类型语言和静态类型语言

    • 动态类型语言和静态类型语言两者的却别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之则是动态类型语言。

    • 直白来说 静态语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征

    • Java是静态类型语言(尽管lambda表达式为其增加了动态特性),js,python是动态类型语言.

    Java:String info = "jdy";//静态语言
    
    JS:var name = "jdy“;var name = 10;//动态语言
    
    Pythom: info = 130;//更加彻底的动态语言

    6.5、方法重写的本质

    • 找到操作数栈的第一个元素所执行的对象的实际类型,记作 C。

    • 如果在类型C中找到与常量池中的描述符、简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。

    • 否则,按照继承关系从下往上依次对c的各个父类进行第二步的搜索和验证过程。

    • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。 IllegalAccessError介绍 程序视图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

    6.6、虚方法表

    • 在面向对象编程中,会很频繁期使用到动态分派,如果在每次动态分派的过程中都要重新在累的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,jvm采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。

    • 每个类中都有一个虚方法表,表中存放着各个方法的实际入口。

    • 那么虚方法表什么时候被创建? 虚方法表会在类加载的链接阶段被创建 并开始初始化,类的变量初始值准备完成之后,jvm会把该类的虚方法表也初始化完毕。

    七、方法返回地址(Return Address)

    • 存放调用该方法的PC寄存器的值(下一条要执行的指令的地址值)。

    • 一个方法的结束,有两种方式:

      • 正常执行完成

      • 出现未处理的异常,非正常退出

    • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者(方法的调用者可能也是一个方法)的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出时,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

    • 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

    • 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

      方法退出的方式

      当一个方法开始执行后,只有两种方式可以退出这个方法

    1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;

      • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定

      • 在字节码指令中,返回指令包含ireturn(当返回值是boolena、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn(引用类型的)

      • 另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用

    1. 在方法执行的过程中遇到了异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜素到匹配的异常处理器,就会导致方法退出,简称异常完成出口方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码。

  • 相关阅读:
    2018 桂林ccpc现场赛 总结
    2018 南京icpc现场赛总结
    nowcoder 203J Graph Coloring I(dfs)
    nowcoder 203A Knight(贪心+打表)
    nowcoder 202H-卡牌游戏
    nowcoder 202F-平衡二叉树
    topcoder srm 738 div1 FindThePerfectTriangle(枚举)
    codeforces 1041 E.Vasya and Good Sequences(暴力?)
    hdu 3507 Print Article(dp+斜率优化)
    hdu 1007 Quoit Design(分治)
  • 原文地址:https://www.cnblogs.com/jdy1022/p/13708411.html
Copyright © 2020-2023  润新知