zoukankan      html  css  js  c++  java
  • 20155322 2017-2018-1《信息安全系统设计》第十一周学习总结

    20155322 2017-2018-1《信息安全系统设计》第十一周学习总结

    [博客目录]


    教材学习内容总结

    本周学习的内容是虚拟内存

    虚拟存储器的概念和作用

    要理解虚拟存储器,需要理解以下几个问题:

    • 什么是虚拟地址和物理地址:
      物理地址:主存被组织成一个由M个连续的字节大小的单元组成的数组。而每一个字节有一个对应的地址,这样的地址就被称作是物理地址。
      虚拟内存:针对物理地址的直接映射的许多弊端,计算机的设计中就采取了一个虚拟化设计,就是虚拟内存。CPU通过发出虚拟地址,虚拟地址再通过MMU翻译成物理地址,最后获得数据,具体的操作如下所示:

      简而言之,在每一个进程开始创建的时候,都会分配一个虚拟存储器(就是一段虚拟地址)然后通过虚拟地址和物理地址的映射来获取真实数据,这样进程就不会直接接触到物理地址,甚至不知道自己调用的那块物理地址的数据。
    • 虚拟存储器是硬件异常、硬件地址翻译、主存、磁盘文件和内核软件的完美交互。
    • 虚拟存储器的缓存作用
      针对于虚拟存储器的缓存作用可以用下图所示:

      虚拟存储器中的块分为:未分配的,缓存的,未缓存的。有效和无效通过一个valid bit(有效位)来进行判断
      • 未分配的:顾名思义,这一块的虚拟存储器不映射于任何块。
      • 缓存的:这一块的虚拟存储器映射于已经存在于DRAM中的物理页。
      • 未缓存的:这一块的虚拟存储器映射于存在于磁盘中的虚拟页。(也就是要使用就要把磁盘中的虚拟页替换到DRAM中的物理页,会发生Page Fault )
    • 虚拟存储器的其它作用:

    1.简化共享:利用虚拟地址来映射物理地址,使得可以让多个进程的不同虚拟地址映射同一块物理地址,比如类似于printf,这一类常用的库,不会把printf的代码拷贝到每一个进程,而是让不同进程都使用同一块printf.。
    2. 虚拟存储器作为存储器保护的工具,在虚拟存储器里面可以设计该PTE是可读,可写,还是可执行的。如果一旦出现只读的PTE被写入了,CPU就会发送出现segmentation fault(段错误)但并不会影响到实际存放数据的物理内存。

    返回目录

    地址翻译的概念

    要理解地址翻译,需要理解以下几个问题:

    • 地址空间的概念
      首先,对于32位的计算机,每一个地址所对应的数据空间是32位,也就是四个字节。那么如果一个地址可以用32位表示,那么对于这32位地址的所有可能就是:232种可能,那么32位地址的地址空间就为232。下面所说的,虚拟地址的地址空间和物理地址的地址空间也就是取决于虚拟地址和物理地址的位数,如果位数分别为M,N,那么地址空间也为:2M 和 2N.
    • 地址分页的概念
      对于一整块连续的内存,直接连续使用也是不太符合实际的。于是,就有分页的概念。将1024个地址分成一页,通过访问页来访问数据。那么有了页就要有如何寻找页的概念了。我们通过每一页的首地址作为页入口,即(PTE)来检索页。那么,对于这些PTE,我们也需要一个专门的数据结构来进行管理,这样的数据结构就是页表(page table)。
    • 地址翻译目的
      地址翻译的目的是通过MMU将虚拟地址翻译成物理地址。

    下面的转化图将说明虚拟地址到物理地址的一个过程

    返回目录

    存储器映射

    要理解存储器映射,需要理解以下几个问题:

    • 什么是存储器映射?
      存储器映射又叫内存映射,所谓的内存映射就是把物理内存映射到进程的地址空间之内,这些应用程序就可以直接使用输入输出的地址空间,从而提高读写的效率。

    • Linux如何实现内存映射?
      Linux提供了mmap()函数,用来映射物理内存。在驱动程序中,应用程序以设备文件为对象,调用mmap()函数,内核进行内存映射的准备工作,生成vm_area_struct结构体,然后调用设备驱动程序中定义的mmap函数

    • 我们来了解一下mmap()函数

      • void *mmap(void *start, size_t length, int prot, int flags,int fd, off_t offset);
        将一个文件或者其它对象映射进内存。文件被映射到多个页上,如果文件的大小不是所有页的大小之和,最后一个页不被使用的空间将会清零。
      • int munmap(void *start, size_t length);
        执行相反的操作,删除特定地址区域的对象映射。
      • 头文件:#include <sys/mman.h>
      • 成功执行时,mmap()返回被映射区的指针,munmap()返回0。失败时,mmap()返回MAP_FAILED[其值为(void * )-1],munmap返回-1。
      • errno被设为以下的某个值:
      EACCES:访问出错
      EAGAIN:文件已被锁定,或者太多的内存已被锁定
      EBADF:fd不是有效的文件描述词
      EINVAL:一个或者多个参数无效
      ENFILE:已达到系统对打开文件的限制
      ENODEV:指定文件所在的文件系统不支持内存映射
      ENOMEM:内存不足,或者进程已超出最大内存映射数量
      EPERM:权能不足,操作不允许
      ETXTBSY:已写的方式打开文件,同时指定MAP_DENYWRITE标志
      SIGSEGV:试着向只读区写入
      SIGBUS:试着访问不属于进程的内存区
      
    • 参数说明:

      1. start:映射区的开始地址。

      2. length:映射区的长度。

      3. prot:期望的内存保护标志,不能与文件的打开模式冲突。是以下的某个值,可以通过or运算合理地组合在一起
        PROT_EXEC //页内容可以被执行
        PROT_READ //页内容可以被读取
        PROT_WRITE //页可以被写入
        PROT_NONE //页不可访问

      4. flags:指定映射对象的类型,映射选项和映射页是否可以共享。它的值可以是一个或者多个以下位的组合体
        MAP_FIXED //使用指定的映射起始地址,如果由start和len参数指定的内存区重叠于现存的映射空间,重叠部分将会被丢弃。如果指定的起始地址不可用,操作将会失败。并且起始地址必须落在页的边界上。
        MAP_SHARED //与其它所有映射这个对象的进程共享映射空间。对共享区的写入,相当于输出到文件。直到msync()或者munmap()被调用,文件实际上不会被更新。
        MAP_PRIVATE //建立一个写入时拷贝的私有映射。内存区域的写入不会影响到原文件。这个标志和以上标志是互斥的,只能使用其中一个。
        MAP_DENYWRITE //这个标志被忽略。
        MAP_EXECUTABLE //同上
        MAP_NORESERVE //不要为这个映射保留交换空间。当交换空间被保留,对映射区修改的可能会得到保证。当交换空间不被保留,同时内存不足,对映射区的修改会引起段违例信号。
        MAP_LOCKED //锁定映射区的页面,从而防止页面被交换出内存。
        MAP_GROWSDOWN //用于堆栈,告诉内核VM系统,映射区可以向下扩展。
        MAP_ANONYMOUS //匿名映射,映射区不与任何文件关联。
        MAP_ANON //MAP_ANONYMOUS的别称,不再被使用。
        MAP_FILE //兼容标志,被忽略。
        MAP_32BIT //将映射区放在进程地址空间的低2GB,MAP_FIXED指定时会被忽略。当前这个标志只在x86-64平台上得到支持。
        MAP_POPULATE //为文件映射通过预读的方式准备好页表。随后对映射区的访问不会被页违例阻塞。
        MAP_NONBLOCK //仅和MAP_POPULATE一起使用时才有意义。不执行预读,只为已存在于内存中的页面建立页表入口。

      5. fd:有效的文件描述词。如果MAP_ANONYMOUS被设定,为了兼容问题,其值应为-1。

      6. offset:被映射对象内容的起点。

      • 当使用mmap映射文件到进程后,就可以直接操作这段虚拟地址进行文件的读写等操作,不必再调用read,write等系统调用.但需注意,直接对该段内存写时不会写入超过当前文件大小的内容.
      • 基于文件的映射,在mmap和munmap执行过程的任何时刻,被映射文件的st_atime可能被更新。如果st_atime字段在前述的情况下没有得到更新,首次对映射区的第一个页索引时会更新该字段的值。用PROT_WRITE 和 MAP_SHARED标志建立起来的文件映射,其st_ctime 和 st_mtime在对映射区写入之后,但在msync()通过MS_SYNC 和 MS_ASYNC两个标志调用之前会被更新。

    返回目录

    动态存储器分配的方法

    我们需要了解的是存储器以及其在内存中实现动态分配的方法

    • 随机访问存储器(RAM):
      • 静态RAM:用来作为高速缓存存储器,每个位存储在一个双稳态的存储器单元里。双稳态:电路可以无限期的保持在两个不同的电压配置或者状态之一。只要供电,就会保持不变。
      • 动态RAM:用来作为主存以及图形系统的帧缓冲区。将每个位存储为对一个电容的充电,当电容的电压被扰乱之后,他就永远都不会再恢复了。暴露在光线下会导致电容电压改变。优势是密集度低,成本低。
      • 传统的DRAM:DRAM芯片中的单元(位)被分成了d个超单元,每个超单元都由w个DRAM单元组成, 一个d*w的DRAM共存储dw位信息。超单元被组织成一个r行c列的长方形阵列,rc=d。每个超单元的地址用(i,j)来表示(从零开始)。设计成二维矩阵是为了降低芯片上地址引脚的数量。息通过称为引脚的外部连接器流入/流出芯片,每个引脚携带一个1位信号。DRAM芯片包装在存储器模块中,它是插到主版的扩展槽上的。
      • 内存模块:DRAM芯片封装在内存模块中,它插到主板的扩展槽上。通过将多个内存模块连接到内存控制器,能够聚合成主存。
    • 增强的DRAM:
      • 快页模式DRAM(FPM DRAM):许对同一行连续的访问可以直接从行缓冲区得到服务。

      • 扩展数据输出DRAM(EDO DRAM):允许单独的CAS信号在时间上靠的更紧密一点。

      • 同步DRAM(SDRAM):用驱动存储控制器相同的外部时钟信号的上升沿来替代许多的异步信号,比异步的更快。

      • 双倍数据速率同步DRAM(DDR DRAM):通过使用两个时钟沿作为控制信号,使得DRAM的速度翻倍。

      • 视频RAM(VRAM):用在图形系统的缓冲中。输出通过以此对内部缓冲区的整个内容进行移位得到的;允许对内存并行地读和写。

      • 非易失性存储器:如果断电,DRAM和SRAM都会丢失信息。非易失型存储器:即使在关电后,也仍然保存着它们的信息;称为ROM。

      • PROM:只能被编程一次。

      • 可擦写可编程ROM(EPROM):紫外线光照射过窗口,EPROM就被清除为0,被擦除和重编程的次数为1000次。

      • 电子可擦除ROM(EEPROM):不需要一个物理上独立的编程设备,因此可以直接在印制电路卡上编程,能够编程的次数为10^3。

      • 闪存:基于EEPROM,为大量的电子设备提供快速而持久的非易失性存储。当一个计算机系统通电之后,它会运行存储在ROM中的固件。

    • 接下来谈谈在Linux下实现动态分配的方法:
      ANSI C说明了三个用于存储空间动态分配的函数
      • malloc 分配指定字节数的存储区。此存储区中的初始值不确定
      • calloc 为指定长度的对象,分配能容纳其指定个数的存储空间。该空间中的每一位(bit)都初始化为0
      • realloc 更改以前分配区的长度(增加或减少)。当增加长度时,可能需将以前分配区的内容移到另一个足够大的区域,而新增区域内的初始值则不确定

    返回目录

    垃圾收集的概念

    垃圾收集需要完成的三件事情

    • 哪些内存需要回收?
    • 什么时候回收?
    • 如何回收?

    在网上我没有找到C语言的垃圾收集知识,我找到了Java的,这里我就以Java内存垃圾收集机制来谈谈垃圾收集的概念

    1. 垃圾收集器在对堆进行回收之前需要判断这些对象中哪些还“存活着”,哪些已经“死去”。

    2. 判断算法

    • 引用计数法(Reference Counting):许多教科书上判断对象是否存活都是这个算法,但是在主流的Java虚拟机里没有选用这个算法来管理内存,下面来简单介绍一下此算法,其实就是为对象中添加一个引用计数器,每当一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何时刻计数器为0的对象就是不可能再被使用的
    • 可达性分析算法(Reachability Analysis):在主流的商用程序语言的实现中,都是通过可达性分析法来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的
    1. 垃圾收集算法
    • 标记-清除算法
      最基本的收集算法“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,之所以说它是最基本的收集算法,是因为后续的收集算法都是基于这种思路并对其不足进行改进而得到的。它的主要不足有两个:
      一是效率问题,标记和清除效率都不高
      二是空间问题,标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序在运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
    • 复制算法
      为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,他将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活这的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。
    • 标记-整理算法
      复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是如果不想浪费50%的空间就要使用额外的空间进行分配担保(Handle Promotion当空间不够时,需要依赖其他内存),以应对被使用的内存中所有对象都100%存活的极端情况.对于“标记-整理”算法,标记过程仍与“标记-清除”算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有的存活对象都向一端移动,然后直接清理掉端边界以外的内存.
    • 分代收集算法
      当前的商业虚拟机的垃圾收集都是采用“分代收集”(Generational Collection)算法,这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存划分为几块。一般是把堆划分为新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收

    返回目录

    C语言中与存储器有关的错误

    • 内存泄漏
      内存泄漏通常是缓慢的隐形的,当我们不小心忘记释放我们分配的内存就会发生这样的问题,看下面的这段代码:
    /*mem_leak*/
    void mem_leak(int n)
    {
     int *gb_n = (int *)malloc(sizeof(int) * n);
     //gb_n 在这里没有被释放,也没有被引用
     return ;
    }
    

    这段代码分配了一个内存快,在释放之前程序就返回了,如果这是一个服务器或守护进程的程序就很糟糕了,它会造成内存空间被占满的情况,一般现象是程序运行变慢最终终止。

    • 间接引用不良指针
      间接引用不良指针是一个常见的错误,其中最为经典的应该是scanf错误了,假设我们想要是用scanf从stdin读取一个整数到一个变量,正确的方法应该是传递给scanf一个格式串和变量的地址:
    void scanf()
    {
     scanf("%d",$val);
    }
       然而有些程序员特别是初学者,很容易出现以下错误:
    /*scanf_bug*/
    void scanf_bug()
    {
     scanf("%d",val);
    }
    

    传递的是val的内容而不是他的地址,在这种情况下,scanf把变量的内容解释为一个地址,试图将一个字写到这个位置,最想的情况是程序立即终止,而在糟糕的情况下阿拉的内容被写到对应的内存中某个合法的读写区域,于是就会覆盖原来地址中的值,通常这样会造成灾难性的或令人困惑的后果.

    • 读未出世化的地址或存储器
      虽然.bss存储器的位置如未初始化的变量总是被初始化为零,但是对于堆来说就不是这样了,这种错误中最常见的是认为堆被初始化为零了:
    /*uninit*/
    int uninit_bug(int **array, int *p, int num)
    {
     int i = 0;
     int j = 0;
     int *temp_p = (int *)malloc(sizeof(int) * num);
     for(i = 0; i < num; i++)
     {
      //temp_p[i] = NULL;
      for(j = 0; j  > num; j++)
      {
          temp_p[i] = array[i][j] * p[j];
      }
      return temp_p;
     }
    }
    

    上面的这短代码中显示的是:
    不正确地认为指针temp_p被初始化为零,正确的应该在程序中把

    temp_p[i]设置为零:
    temp_p[i] = NULL;
    

    或者使用另一个方法calloc来动态分配内存:
    int *temp_p = (int *)calloc(1, sizeof(int) * num);
    方法calloc来动态分配内存时自动的将指针temp_p被初始化为零.

    • 错误的认为指针和它们指向的对象是相同大小的
      这种错误是错误的认为错误的认为指针和它们指向的对象是相同大小的:
    /*pioter_to_obj_size*/
    int **poiter_to_obj_size(int num1,  int num2)
    {
     int i = 0;
     int **p_array = (int **)malloc(sizeof(int) * num1);
     //
     *p_array = NULL;
     for(i = 0; i < num1; i++)
     {  
         p_array[i] = (int *)malloc(sizeof(int) * num2);
     }
     return p_array;
    }
    

    现在我们来分析上面这段代码,这段代码的目的是创建一个由num1个指针组成的数组,每个指针都指向一个包含num2个int类型的数组.
    在程序中我们把:
    int **p_array = (int **)malloc(sizeof(int*) * num1);
    写成了:
    int **p_array = (int **)malloc(sizeof(int) * num1);
    这时程序实际上创建的是一个int类型的数组,这段代码会在int和指向int的指针大小相同的机器上运行良好,如果把这段代码放在int和指向int的指针大小不同的机器上运行就会出现一些令人困惑的奇怪的错误.

    • 错位错误
      这种错误是一种很常见的覆盖错误看下面的这段代码:
    /*off_by_one*/
    int **off_by_one(int num 1, int num2)
    {
     int i = 0;
     int **p_array = (int **)malloc(sizeof(int*) * num1);
     *p_array = NULL;
     //for(i = 0; i < num1; i++)
     for(i = 0; i <= num1; i++)
     {
         p_array[i] = (int *)malloc(sizeof(int) * num2);
     }
     return p_array;
    }
    

    这段代码创建了一个num1个元素的指针数组,但是后面的代码却试图初始化数组的num1+1个元素,这样最后一个就会覆盖数组的后面的某个地址中的数据.

    • 错误地引用指针而不是它所指的对象
      错误地引用指针而不是它所指的对象,这种错误一般是由于C操作符的优先级和结合性引起的,这时我们会错误地操作指针而不是他所指向的对象先看一下下面的这个小的程序代码:
    /*use_another_obj*/
    int *use_another_obj(int **binheap, int *size)
    {
     int *pac = binheap[0];
     binheap[0] = binheap[*size - 1];
     *size--;
     heapify(binheap, *size, 0);
     return pac;
    }
    

    本意是减少size指针指向的整数的值,却因为运算符的优先级出现了减少指针自己的值!正确的写法应该是这样的:

        binheap[0] = binheap[*size - 1];
        (*size)--;
    
    • 误解指针运算
      首先看一下代码:
    /*wrong_poiter_op*/
    int *wrong_poiter_op(int *poiter, int num)
    {
     while(*poiter && * != num)
     {
         poiter += sizeof(int);
     }
     return poiter;
    }
    

    想用这个程序遍历一个int类型的数组,返回一个指针指向num的首次出现,但是结果却不是我期望的那样,因为每次循环时poiter += sizeof(int);都把指针加上了4,这样代码就遍历了数组中的每4个整数了,正确的应该是这样的:
    poiter++ += sizeof(int);

    • 允许栈缓冲区溢出
      假设一个程序不见查输入串的大小就写入栈的目标缓冲区,那么这个程序就会有缓冲区溢出的错误:
    /*bufoverflow*/
    void bufoverflow_bug()
    {
        char buf[256];
        gets(buf);
    }
    

    上面的这段程序代码就出现了缓冲区错误,因为gets函数拷贝一个任意长度的串到缓冲区了,所以我们必须使用fgets函数:
    fgets(buf);
    因为这个函数限制了输入串的大小.

    • 引用已经释放掉的堆中的数据
      引用已经释放掉的堆中的数据,看下面的代码:
    /*ref_freeed_heap_data*/
    int *ref_freeed_heap_data(int n, int m)
    {
        int i = 0;
        int *x = NULL;
        int *y = NULL;
        x = (int *)malloc(sizeof(int) * n);
        free(x);
        y = (int *)malloc(sizeof(int) * m);
        for(i = 0; i < m; i++)
        {
                y[i] = x[i]++;
                //x freeed!!!!!!
        }
        return y;
    }
    

    程序引用了已经释放的数据:free(x);

    • 引用不存在的变量
      下面的这个函数返回的是一个地址,指向栈里的一个局部变量然后弹出栈帧
    /*ref_no_val*/
    int *ref_no_val()
    {
     int num;
     return &num;
    }
    

    这里他已经不再指向一个合法的变量了.
    返回目录

    教材学习中的问题和解决过程

    • 问题1:我们的程序是不是可以直接可以接触到物理地址,就是是不是可以直接从物理地址当中获取数据?如果不是,为什么?
    • 解答:
    1. 主存的容量有限,但是我们的进程是无限,如果计算机上的每一个进程都独占一块物理存储器(即物理地址空间)。那么,主存就会很快被用完。实际上,每个进程在不同的时刻都是只会用同一块主存的数据,这就说明了其实只要在进程想要主存数据的时候我们把需要的主存加载上就好,换进换出。针对这样的需求,直接提供一整块主存的物理地址就明显不符合。
    2. 进程间通信的需求。如果每个进程都 独占一块物理地址,这样就只能通过socket这样的手段进行进程通信,但如果进程间能使用同一块物理地址就可以解决这个问题。
    3. 主存的保护问题。对于主存来说,需要说明这段内存是可读的,可写的,还是可执行的。针对这点,光用物理地址也是很难做到的。
    • 问题2:如何理解垃圾回收机制?
    • 解答:
      以Java为例,内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域会随着线程而生,随线程而灭;栈中的栈帧随着方法的进行有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知得,因此这几个区域的内存分配回收都具备确定性,在这几个区域就不需要过多的考虑回收的问题,因为在方法结束或线程结束时内存就被回收了。
      而Java堆和方法区则不一样,一个接口中的实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建那些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。
      返回目录

    代码调试中的问题和解决过程


    返回目录

    本周结对学习情况

    • 结对学习博客
      20155302
    • 结对学习图片
    • 结对学习内容
      • 教材第九章

    返回目录

    代码托管

    返回目录

    学习进度条

    代码行数(新增/累积) 博客量(新增/累积) 学习时间(新增/累积) 重要成长
    目标 5000行 30篇 400小时
    第一周 0/0 1/1 10/10
    第三周 200/200 2/3 10/20
    第四周 100/300 1/4 10/30
    第五周 200/500 3/7 10/40
    第六周 500/1000 2/9 30/70
    第七周 500/1500 2/11 15/85
    第八周 223/1723 3/14 15/100
    第九周 783/2506 3/17 15/115
    第九周 0/2506 3/20 12/127
    第十周 620/3126 2/22 20/147
    第十一周 390/3516 2/24 17/164
    • 计划学习时间:17小时

    • 实际学习时间:17小时

    返回目录

    参考资料

    返回目录

  • 相关阅读:
    记第一场省选
    POJ 2083 Fractal 分形
    CodeForces 605A Sorting Railway Cars 思维
    FZU 1896 神奇的魔法数 dp
    FZU 1893 内存管理 模拟
    FZU 1894 志愿者选拔 单调队列
    FZU 1920 Left Mouse Button 简单搜索
    FZU 2086 餐厅点餐
    poj 2299 Ultra-QuickSort 逆序对模版题
    COMP9313 week4a MapReduce
  • 原文地址:https://www.cnblogs.com/blackay03/p/7900866.html
Copyright © 2011-2022 走看看