热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

Spark笔记(一)

1.简介ApacheSpark是专为大规模数据处理而设计的快速通用的计算引擎。Spark是UCBerkeleyAMPlab(加州大学伯克利分校的AMP实验室)所开源的类Hadoop


  1. 简介




  • Apache Spark 是专为大规模数据处理而设计的快速通用的计算引擎。Spark是UC Berkeley AMP lab (加州大学伯克利分校的AMP实验室)所开源的类Hadoop MapReduce的通用并行计算框架,Spark拥有Hadoop MapReduce所具有的优点;但不同于MapReduce的是Job中间输出结果可以保存在内存中,从而不再需要读写HDFS,因此Spark能更好地适用于数据挖掘与机器学习等需要迭代的MapReduce的算法。

  • Spark是Scala编写,方便快速编程。



  1. 总体技术栈

    技术分享图片


    • Mesos: 一个多租户的集群资源管理器

    • Tachyon: 一个高性能、高容错、基于内存的开源分布式存储系统

    • Spark: 快速的内存优化过的计算执行引擎(有python, java, scala 的API)

    • Spark Streaming: Spark的流计算系统

    • Graphx: 图形计算

    • MLbase: 有好的机器学习

    • Spark SQL

    • 一个用于在海量数据上进行交互式SQL的近似查询引擎

    • 一个跨语言的通讯协议,用于编写并行计算机(Java 有一个OpenMPI的实现)

    技术分享图片


    • Ad-hoc Queries (即席查询)


      • 即席查询(Ad Hoc)是用户根据自己的需求,灵活的选择查询条件,系统能够根据用户的选择生成相应的统计报表。即席查询与普通应用查询最大的不同是普通的应用查询是定制开发的,而即席查询是由用户自定义查询条件的。


    • Spark 可以做到 流处理, 批处理 和即席查询一站式运作



  2. Spark 与 MapReduce的对比

    技术分享图片


    • MapReduce

    技术分享图片


    • Spark

    技术分享图片


    • 在迭代计算中, Spark会将每次计算的中间计算结果存储到内存中, 而MapReduce在执行迭代计算时, 在 shuffle 阶段 需要不断地 从HDFS读取数据和写入数据, 大量的磁盘IO存在性能瓶颈, 必然会导致计算时长的延长。所以, 在执行迭代计算时, Spark 的性能比 MapReduce 要高100x倍。


  3. Spark 核心

    (1). RDD


    • Resilient Distributed DataSet 弹性分布式数据集。


    • RDD五大特性:


      • RDD 是由一系列的 parition 组成的

      • 函数是作用在每一个partition (split) 上的

      • RDD 之间有一系列的依赖关系

      • 分区器是作用在 K, V 格式的 RDD 上

      • RDD 提供一些列最佳的计算位置


    • RDD理解图:

      技术分享图片


      • 注意点:

        • textFile方法底层封装的是MR读取文件的方式,读取文件之前先split,默认split大小是一个block大小

        • RDD 只是数据集的抽象, 分区内部并不会存储具体的数据

        • 什么是 K, V 格式的RDD?

          • 如果RDD中存储的数据都是二元组对象, 那么这个RDD就叫做 K,V 格式的 RDD


        • 哪里体现 RDD 的弹性(容错)?

          • parition 数量, 大小没有限制且可以自定义, 体现了RDD的弹性

          • RDD 之间依赖关系, 可以基于上一个RDD重新计算出RDD


        • 哪里体现 RDD 的分布式?

          • RDD 是由 Partition 组成, parition 是分布在不同节点上的


        • RDD 提供计算最佳位置, 体现了数据本地化。 体现了大数据 "计算移动数据不一定" 的理念



    • Lineage 血统

      技术分享图片


      • 可以看作一代单传的血统, 通过代来判定程序执行到的位置


    (2). Spark 任务执行原理

    技术分享图片


    • Driver 和 Worker 是启动在节点上的进程

      • Driver 与 集群节点之间有频繁的通信


      • Driver 负责任务(tasks)的分发 和 结果的回收, 任务的调度。 如果 task 的计算结果非常大就不要回收了, 否则会造成OOM


      • Worder 是 Standalone 资源调度框架中资源管理的从节点, 也是JVM进程.


      • Master 是 Standalone 资源调度框架中资源管理的主节点, 也是JVM进程.


      • Driver 类比 ApplicationMaster 分发任务给Worker, 获取计算的结果



    (3). Spark 代码流程


    • 创建 SparkConf 对象


      • 可以设置 Application name

      • 可以设置运行模式及资源需求


    • 创建 SparkContext 对象


    • 基于 Spark 的上下文创建一个RDD, 对 RDD进行处理


    • 应用程序中要有Action类算子来触发 Transformation 类算子执行


    • 关闭 Spark 上下文对象 SparkContext


    (4). Transformations 转换算子

    技术分享图片


    • 概念:

      • Transformation(转换) 类算子是一类算子, 如 map, flatMap, reduceByKey 等。


      • Transformation 算子是延迟执行的, 也可被称为懒加载执行的。



    • Transformation 类算子:

      • filter


        • 过滤符合条件的记录数, true 保留, false 过滤掉


      • map


        • 将一个RDD中的每个数据项, 通过map中的函数映射变为一个新的元素。


        • 特点: 输入一条, 输出一条数据。



      • flatMap


        • 先 map 后 flat。 与map类似, 每个输入项可以映射为 0 到 多个输出项。


      • sample


        • 随机抽取算子, 根据传进去的小数按比例进行又放回或者无放回的抽样。


      • reduceByKey


        • 将相同的 key 根据相应的逻辑进行处理。


      • sortByKey/sortBy


        • 作用在K, V 格式的RDD上, 对 key 进行升序或者降序排序。



    (5). Action 行动算子


    • 概念:


      • Action 行动算子, 如 foreach, collect, count 等。


      • Action 类算子是触发执行。


      • 一个application应用程序中有几个 Action 类算子执行, 就有几个job运行。



    • Action 类算子


      • count


      • 返回数据集中的元素数, 会在结果计算完成后回到 Driver 端


      • take(n)


        • 返回一个包含数据集前 n 个元素的集合


      • first

        等价于 take(1), 返回数据集中的第一个元素


      • foreach


        • 循环遍历数据集中的每个元素, 运行相应的逻辑


      • collect


        • 将计算结果回收到 Driver 端



    (6). 控制算子


    • 概念:

      • 三种控制算子: cache, persist, checkpoint

      • 都可以将RDD持久化, 持久化的单位是 partition

      • cache 和 persist 都是懒执行的。必须由一个action算子引发

      • checkpoint 算子 不仅能将 RDD 持久化到磁盘, 还能切断 RDD 之间的依赖关系


    • cache:

      • 默认将 RDD 的数据持久化到内存中, 是懒执行

      • cache() <=> persist() = persist(StorageLevel.Memory_Only)


    • persist:

      • 可以指定持久化的级别。


      • 最常用的是 Memory_Only 和 Memory_AND_DISK


      • "_2" 表示有副本数

      • 持久化级别:

        /**
        * :: DeveloperApi ::
        * Flags for controlling the storage of an RDD. Each StorageLevel records whether
        * to use memory, or ExternalBlockStore, whether to drop the RDD to disk if it
        * falls out of memory or ExternalBlockStore, whether to keep the data in memory in * a serialized format, and whetherto replicate the RDD partitions on multiple
        * nodes.
        * 控制 RDD 存储的 标签, 每个存储级别记录了 是否 使用内存,或额外的 块来存储; 是否 在 RDD * 内存 或 额外块 溢出时 将它 存储到磁盘; 是否将存储在内存中的数据 按指定格式 序列化;
        * 是否将 RDD 的 分区 备份到几个节点上
        *
        * The [[org.apache.spark.storage.StorageLevel]] singleton object contains some
        * static constants for commonly useful storage levels. To create your own storage * level object, use the factory method of the singleton object
        * (`StorageLevel(...)`).
        * StorageLevel 是一个包含了一些标识着常用的存储级别的静态常量的单例。
        * 你可以通过使用该单例对象的工厂方法来自定义存储级别
        */
        @DeveloperApi
        class StorageLevel private(
        // 是否使用磁盘存储
        private var _useDisk: Boolean,
        // 是否启用内存存储
        private var _useMemory: Boolean,
        // 是否使用堆外内存
        private var _useOffHeap: Boolean,
        // 是否不实用序列化... 这个有点绕...
        private var _deserialized: Boolean,
        // 默认备份为1,可设置
        private var _replication: Int = 1)
        extends Externalizable {
        // TODO: Also add fields for caching priority, dataset ID, and flushing.
        // 添加 用于 缓存优先级, 数据集 ID 和 内存清洗 的 域
        private def this(flags: Int, replication: Int) {
        this((flags & 8) != 0, (flags & 4) != 0, (flags & 2) != 0, (flags & 1) != 0, replication)
        }
        def this() = this(false, true, false, false) // For deserialization
        def useDisk: Boolean = _useDisk
        def useMemory: Boolean = _useMemory
        def useOffHeap: Boolean = _useOffHeap
        def deserialized: Boolean = _deserialized
        def replication: Int = _replication
        // 断言副本数是否小于 40, 不满足会报错
        assert(replication <40, "Replication restricted to be less than 40 for calculating hash codes")
        if (useOffHeap) {
        // 使用序列化才能使用堆外内存
        require(!deserialized, "Off-heap storage level does not support deserialized storage")
        }
        /**
        *
        * 内存模式
        */
        private[spark] def memoryMode: MemoryMode = {
        if (useOffHeap) MemoryMode.OFF_HEAP
        else MemoryMode.ON_HEAP
        }
        /**
        * 克隆, 创建了新对象, 是深拷贝
        */
        override def clone(): StorageLevel = {
        new StorageLevel(useDisk, useMemory, useOffHeap, deserialized, replication)
        }
        /**
        *
        * 重载equals方法
        */
        override def equals(other: Any): Boolean = other match {
        case s: StorageLevel =>
        s.useDisk == useDisk &&
        s.useMemory == useMemory &&
        s.useOffHeap == useOffHeap &&
        s.deserialized == deserialized &&
        s.replication == replication
        case _ =>
        false
        }
        /**
        * 判断是否有效
        */
        def isValid: Boolean = (useMemory || useDisk) && (replication > 0)
        def toInt: Int = {
        var ret = 0
        if (_useDisk) {
        ret |= 8
        }
        if (_useMemory) {
        ret |= 4
        }
        if (_useOffHeap) {
        ret |= 2
        }
        if (_deserialized) {
        ret |= 1
        }
        ret
        }
        // 额外的写出
        override def writeExternal(out: ObjectOutput): Unit = Utils.tryOrIOException {
        // writeByte: 写出为字节码
        out.writeByte(toInt)
        out.writeByte(_replication)
        }
        override def readExternal(in: ObjectInput): Unit = Utils.tryOrIOException {
        val flags = in.readByte()
        _useDisk = (flags & 8) != 0
        _useMemory = (flags & 4) != 0
        _useOffHeap = (flags & 2) != 0
        _deserialized = (flags & 1) != 0
        _replication = in.readByte()
        }
        // 读决定, 获取当前已缓存的等级
        @throws(classOf[IOException])
        private def readResolve(): Object = StorageLevel.getCachedStorageLevel(this)
        override def toString: String = {
        val disk = if (useDisk) "disk" else ""
        val memory = if (useMemory) "memory" else ""
        val heap = if (useOffHeap) "offheap" else ""
        val deserialize = if (deserialized) "deserialized" else ""
        val output =
        Seq(disk, memory, heap, deserialize, s"$replication replicas").filter(_.nonEmpty)
        s"StorageLevel(${output.mkString(", ")})"
        }
        override def hashCode(): Int = toInt * 41 + replication
        def description: String = {
        var result = ""
        result += (if (useDisk) "Disk " else "")
        if (useMemory) {
        result += (if (useOffHeap) "Memory (off heap) " else "Memory ")
        }
        result += (if (deserialized) "Deserialized " else "Serialized ")
        result += s"${replication}x Replicated"
        result
        }
        }
        /**
        * Various [[org.apache.spark.storage.StorageLevel]] defined and utility functions * for creating new storage levels.
        * 定义的可变存储级别以及 创建新存储级别的公用方法
        */
        object StorageLevel {
        // 5 个参数依次代表: _useDisk,_useMemory,_useOffHeap,_deserialized,_replication
        // SER -> serialized 是否序列化; _2 -> 使用副本数
        val NOnE= new StorageLevel(false, false, false, false)
        val DISK_OnLY= new StorageLevel(true, false, false, false)
        val DISK_ONLY_2 = new StorageLevel(true, false, false, false, 2)
        val MEMORY_OnLY= new StorageLevel(false, true, false, true)
        val MEMORY_ONLY_2 = new StorageLevel(false, true, false, true, 2)
        val MEMORY_ONLY_SER = new StorageLevel(false, true, false, false)
        val MEMORY_ONLY_SER_2 = new StorageLevel(false, true, false, false, 2)
        val MEMORY_AND_DISK = new StorageLevel(true, true, false, true)
        val MEMORY_AND_DISK_2 = new StorageLevel(true, true, false, true, 2)
        val MEMORY_AND_DISK_SER = new StorageLevel(true, true, false, false)
        val MEMORY_AND_DISK_SER_2 = new StorageLevel(true, true, false, false, 2)
        val OFF_HEAP = new StorageLevel(true, true, true, false, 1)
        /**
        * :: DeveloperApi ::
        * Return the StorageLevel object with the specified name.
        * 返回特定名称的存储级别对象
        */
        @DeveloperApi
        def fromString(s: String): StorageLevel = s match {
        case "NONE" => NONE
        case "DISK_ONLY" => DISK_ONLY
        case "DISK_ONLY_2" => DISK_ONLY_2
        case "MEMORY_ONLY" => MEMORY_ONLY
        case "MEMORY_ONLY_2" => MEMORY_ONLY_2
        case "MEMORY_ONLY_SER" => MEMORY_ONLY_SER
        case "MEMORY_ONLY_SER_2" => MEMORY_ONLY_SER_2
        case "MEMORY_AND_DISK" => MEMORY_AND_DISK
        case "MEMORY_AND_DISK_2" => MEMORY_AND_DISK_2
        case "MEMORY_AND_DISK_SER" => MEMORY_AND_DISK_SER
        case "MEMORY_AND_DISK_SER_2" => MEMORY_AND_DISK_SER_2
        case "OFF_HEAP" => OFF_HEAP
        case _ => throw new IllegalArgumentException(s"Invalid StorageLevel: $s")
        }
        /**
        * :: DeveloperApi ::
        * Create a new StorageLevel object.
        * 创建一个新的存储级别对象
        */
        @DeveloperApi
        def apply(
        useDisk: Boolean,
        useMemory: Boolean,
        useOffHeap: Boolean,
        deserialized: Boolean,
        replication: Int): StorageLevel = {
        getCachedStorageLevel(
        new StorageLevel(useDisk, useMemory, useOffHeap, deserialized, replication))
        }
        /**
        * :: DeveloperApi ::
        * Create a new StorageLevel object without setting useOffHeap.
        * 创建一个没有设置使用堆外内存的新存储级别对象
        */
        @DeveloperApi
        def apply(
        useDisk: Boolean,
        useMemory: Boolean,
        deserialized: Boolean,
        replication: Int = 1): StorageLevel = {
        getCachedStorageLevel(new StorageLevel(useDisk, useMemory, false, deserialized, replication))
        }
        /**
        * :: DeveloperApi ::
        * Create a new StorageLevel object from its integer representation.
        * 根据 标签 的 整形 代表参数 和 副本数 创建一个新的存储级别对象
        */
        @DeveloperApi
        def apply(flags: Int, replication: Int): StorageLevel = {
        getCachedStorageLevel(new StorageLevel(flags, replication))
        }
        /**
        * :: DeveloperApi ::
        * Read StorageLevel object from ObjectInput stream.
        * 从对象输入流中读取存储级别对象
        */
        @DeveloperApi
        def apply(in: ObjectInput): StorageLevel = {
        val obj = new StorageLevel()
        obj.readExternal(in)
        getCachedStorageLevel(obj)
        }
        // 调用了 java 并发包的 ConcurrentHashMap, 将两个存储级别作为键值对传入 ConcurrentHashMap 初始化 该类的 storageLevelCache对象
        private[spark] val storageLevelCache = new ConcurrentHashMap[StorageLevel, StorageLevel]()
        // 从 storageLevelCache 中获取 存储级别对象
        private[spark] def getCachedStorageLevel(level: StorageLevel): StorageLevel = {
        storageLevelCache.putIfAbsent(level, level)
        storageLevelCache.get(level)
        }
        }



    • cache 和 persist 的注意事项

      • cache 和 persist 都是懒执行, 必须由一个 action 类触发执行

      • cache 和 persist 的返回值可以赋给一个变量, 在其他job中直接使用该变量就是使用持久化的数据了。持久化单位是partition


    • checkpoint:

      • checkpoint 将 RDD 持久化到磁盘, 还可以切断 RDD之间的依赖关系


      • checkpoint 的执行原理:

        技术分享图片


        • 当 RDD 的 job 执行 完毕后, 会从finalRDD 从后往前回溯。

        • 当回溯到某一个调用了 checkpoint 方法时, 会对当前 RDD 做一个标记

        • Spark 框架会自动启动一个新的job, 重新计算这个RDD的数据, 将数据持久化到HDFS上


      • 优化: 对 RDD 执行checkpoint之前, 最好对这个RDD先执行cache, 这样新启动的job只需要将内存中的数据拷贝到 HDFS 上就可以, 省去了重新计算的步骤。



    • 随便写的一个xx练习, 可读性 == shit, 是在不想写wordCount了

      package com.ronnie.scala
      import java.io.StringReader
      import au.com.bytecode.opencsv.CSVReader
      import org.apache.spark.rdd.RDD
      import org.apache.spark.{SparkConf, SparkContext}
      object DataExtraction {
      def main(args: Array[String]): Unit = {
      val cOnf= new SparkConf()
      conf.setMaster("local").setAppName("WC")
      val sc = new SparkContext(conf)
      val input: RDD[String] = sc.textFile("./resources/gpu.csv")
      val result: RDD[Array[String]] = input.map { line =>
      val reader = new CSVReader(new StringReader(line))
      reader.readNext()
      }
      result.foreach(x => {
      x.flatMap(_.split(" ")).foreach(_.split(" ").filter(y =>y.contains("580") ).foreach(_.split(" ").filter(x => x.equals("RX580")).foreach(println)))
      })
      }
      }




推荐阅读
  • 二分查找算法详解与应用分析:本文深入探讨了二分查找算法的实现细节及其在实际问题中的应用。通过定义 `binary_search` 函数,详细介绍了算法的逻辑流程,包括初始化上下界、循环条件以及中间值的计算方法。此外,还讨论了该算法的时间复杂度和空间复杂度,并提供了多个应用场景示例,帮助读者更好地理解和掌握这一高效查找技术。 ... [详细]
  • 深入解析Linux内核中的进程上下文切换机制
    在现代操作系统中,进程作为核心概念之一,负责管理和分配系统资源,如CPU和内存。深入了解Linux内核中的进程上下文切换机制,需要首先明确进程与程序的区别。进程是一个动态的执行流,而程序则是静态的数据和指令集合。进程上下文切换涉及保存当前进程的状态信息,并加载下一个进程的状态,以实现多任务处理。这一过程不仅影响系统的性能,还关系到资源的有效利用。通过分析Linux内核中的具体实现,可以更好地理解其背后的原理和技术细节。 ... [详细]
  • POJ 2482 星空中的星星:利用线段树与扫描线算法解决
    在《POJ 2482 星空中的星星》问题中,通过运用线段树和扫描线算法,可以高效地解决星星在窗口内的计数问题。该方法不仅能够快速处理大规模数据,还能确保时间复杂度的最优性,适用于各种复杂的星空模拟场景。 ... [详细]
  • 本指南详细介绍了如何利用华为云对象存储服务构建视频点播(VoD)平台。通过结合开源技术如Ceph、WordPress、PHP和Nginx,用户可以高效地实现数据存储、内容管理和网站搭建。主要内容涵盖华为云对象存储系统的配置步骤、性能优化及安全设置,为开发者提供全面的技术支持。 ... [详细]
  • VS2019 在创建 Windows 恢复点时出现卡顿问题及解决方法
    在使用 Visual Studio 2019 时,有时会在创建 Windows 恢复点时遇到卡顿问题。这可能是由于频繁的自动更新导致的,每次更新文件大小可能达到 1-2GB。尽管现代网络速度较快,但这些更新仍可能对系统性能产生影响。本文将探讨该问题的原因,并提供有效的解决方法,帮助用户提升开发效率。 ... [详细]
  • 为了提升单位内部沟通效率,我们开发了一套飞秋软件与OA系统的消息接口服务系统。该系统能够将OA系统中的审批、通知等信息自动同步至飞秋平台,确保员工在使用飞秋进行日常沟通的同时,也能及时获取OA系统的各类重要信息,从而实现无缝对接,提高工作效率。 ... [详细]
  • 浏览器作为我们日常不可或缺的软件工具,其背后的运作机制却鲜为人知。本文将深入探讨浏览器内核及其版本的演变历程,帮助读者更好地理解这一关键技术组件,揭示其内部运作的奥秘。 ... [详细]
  • Unity3D 中 AsyncOperation 实现异步场景加载及进度显示优化技巧
    在Unity3D中,通过使用`AsyncOperation`可以实现高效的异步场景加载,并结合进度条显示来提升用户体验。本文详细介绍了如何利用`AsyncOperation`进行异步加载,并提供了优化技巧,包括进度条的动态更新和加载过程中的性能优化方法。此外,还探讨了如何处理加载过程中可能出现的异常情况,确保加载过程的稳定性和可靠性。 ... [详细]
  • 在Conda环境中高效配置并安装PyTorch和TensorFlow GPU版的方法如下:首先,创建一个新的Conda环境以避免与基础环境发生冲突,例如使用 `conda create -n pytorch_gpu python=3.7` 命令。接着,激活该环境,确保所有依赖项都正确安装。此外,建议在安装过程中指定CUDA版本,以确保与GPU兼容性。通过这些步骤,可以确保PyTorch和TensorFlow GPU版的顺利安装和运行。 ... [详细]
  • 在跨线程调用UI控件方法时,通常使用同步调用机制,如 `控件.Invoke(Delegate, 参数)`。这里需要声明并实现一个委托,因为控件本身并不知道如何处理跨线程操作。通过将具体的实现逻辑封装在委托中,控件可以正确地执行这些操作,确保线程安全性和UI的一致性。此外,为了提高性能和可维护性,建议对频繁的跨线程调用进行优化,例如使用异步调用或批量处理请求。 ... [详细]
  • 近日,我在处理一个复杂的前端问题时遇到了极大困扰。具体来说,我之前开发了一个功能丰富的纯jQuery代码的前端GridView控件,实现了多种功能和视觉效果,并在多个项目中表现良好。然而,最近在尝试应用 `border-box` 布局模式时,却遇到了意想不到的兼容性和性能问题。这提醒我们在条件尚未完全成熟的情况下,应谨慎使用 `border-box` 布局模式,以免引入不必要的复杂性和潜在的bug。 ... [详细]
  • 在Eclipse中提升开发效率,推荐使用Google V8插件以增强Node.js的调试体验。安装方法有两种:一是通过Eclipse Marketplace搜索并安装;二是通过“Help”菜单中的“Install New Software”,在名称栏输入“googleV8”。此插件能够显著改善调试过程中的性能和响应速度,提高开发者的生产力。 ... [详细]
  • 深入解析Java虚拟机的内存分区与管理机制
    Java虚拟机的内存分区与管理机制复杂且精细。其中,某些内存区域在虚拟机启动时即创建并持续存在,而另一些则随用户线程的生命周期动态创建和销毁。例如,每个线程都拥有一个独立的程序计数器,确保线程切换后能够准确恢复到之前的执行位置。这种设计不仅提高了多线程环境下的执行效率,还增强了系统的稳定性和可靠性。 ... [详细]
  • Python 伦理黑客技术:深入探讨后门攻击(第三部分)
    在《Python 伦理黑客技术:深入探讨后门攻击(第三部分)》中,作者详细分析了后门攻击中的Socket问题。由于TCP协议基于流,难以确定消息批次的结束点,这给后门攻击的实现带来了挑战。为了解决这一问题,文章提出了一系列有效的技术方案,包括使用特定的分隔符和长度前缀,以确保数据包的准确传输和解析。这些方法不仅提高了攻击的隐蔽性和可靠性,还为安全研究人员提供了宝贵的参考。 ... [详细]
  • 题目 E. DeadLee:思维导图与拓扑结构的深度解析问题描述:给定 n 种食物,每种食物的数量由 wi 表示。同时,有 m 位朋友,每位朋友喜欢两种特定的食物 x 和 y。目标是通过合理分配食物,使尽可能多的朋友感到满意。本文将通过思维导图和拓扑排序的方法,对这一问题进行深入分析和求解。 ... [详细]
author-avatar
xinweiss
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有