48、使用 Ant 构建 JNI 库

使用 Ant 构建 JNI 库

1. 构建 JNI 库的步骤

JNI 的核心概念是,使用 native 前缀声明的 Java 方法会绑定到本地库,这些本地库会由 Java 运行时动态加载。在 Windows 上,本地库是动态链接库(DLL),而在 Unix 上,共享库(.so)提供相同的功能,其他平台也有各自的等效形式。

编写新的 JNI 库,可按以下步骤进行:
1. 编写一个包含本地方法的 Java 类。
2. 将 Java 源文件编译为字节码。
3. 使用 javah 工具从编译后的类创建 C++ 头文件。
4. 将此头文件集成到一个 C++ 项目中,该项目的输出是一个动态或共享库。
5. 编写 C++ 代码以实现所需的实际功能。
6. 编译代码,并引入 JDK 中的 JNI 头文件。
7. 根据需要链接 JDK 中的库文件以解析外部引用。

下面是核心 JNI 构建过程的流程图:

graph LR
    A[Java Stub Class] -->|Javac| B[Stub bytecodes]
    B -->|Javah| C[C++ header]
    D[C++ source] -->|CPP compile and link| E[native binary]
    C --> E
    F[JDK headers and libraries] --> E

执行本地代码还需要额外的步骤,本地库必须位于 Java 运行时的路径中。如果库不在路径中,运行时会抛出 java.lang.UnsatisfiedLinkError 错误。在团队开发 JNI 库时,另一个小问题是导入 JNI 头文件和库,由于这些文件都在 JDK 中,所以除非所有团队成员都将 JDK 安装在同一位置,否则不能使用硬编码引用这些文件,必须使用 JAVA_HOME 环境变量作为这些文件的基础位置,并考虑特定于操作系统的目录名称。

2. 编写 Java 存根

编写用于导出本地方法调用的 Java 代码是最简单的步骤,示例代码如下:

package org.example.antbook.cpu;
public class CpuInfo {
    native long getCpuClock();

    static {
        System.loadLibrary("CpuInfo");
    }
}

这个 CpuInfo 类目前导出了一个方法 getCpuClock ,并包含一个静态初始化器,用于按名称加载共享库。编译这个类与正常的构建过程没有区别,使用以下 Ant 任务:

<target name="compile" depends="init" >
  <javac srcdir="src" includes="**/*.java"
    destdir="${classes.dir}"/>
</target>

运行此任务后,会得到一个 Java 存根 .class 文件,可用于创建头文件。

3. 创建 C++ 头文件

C++ 头文件通过 <javah> 任务从类生成,该任务是 JDK 中 javah 工具的包装器。虽然这是一个可选任务,但除了 JDK 中的库外,它不需要其他外部库:

<target name="headers" depends="compile">
  <javah destdir="${generated.dir}"
    force="yes"
    classpath="${classes.dir}">
      <class name="org.example.antbook.cpu.CpuInfo"/>
  </javah>
</target>

该任务需要一个包含存根类的类路径、类名和一个输出目录,会在该目录中生成一个名为 org_tasklibs_CpuInfo.h 的头文件。 javah 程序从包和类声明中派生文件名。 <javah> 任务不会对 Java 源文件进行依赖检查,因此不知道何时生成文件。 force="true" 属性会让任务始终创建头文件,即使目标文件已存在,这会导致效率低下,因为 C++ 编译器会始终重新构建库,即使没有任何更改。在 <javah> 任务支持依赖检查之前,需要将其排除在主构建之外,或者使用 <uptodate> 调用来使头文件生成目标依赖于目标文件是否过时。

4. 编写 C++ 类

JNI 开发过程的下一步是编写 C++ 代码。以下是 Windows 版本的示例代码,位于 org_example_antbook_cpu_CpuInfo.cpp 文件中:

#include "org_example_antbook_cpu_CpuInfo.h"
JNIEXPORT jlong JNICALL 
    Java_org_example_antbook_cpu_CpuInfo_getCpuClock
      (JNIEnv *,
      jobject) {
    __int64 timestamp;
    __int64 *pTimestamp=&timestamp;
    _asm rdtsc;
    _asm mov ecx,pTimestamp
    _asm mov [ecx],eax;
    _asm mov [ecx+4],edx;
    return timestamp;
}

rdtsc 指令将 CPU 时钟滴答计数存储到处理器的两个 32 位寄存器中,然后将结果的每一半移动到一个变量的不同部分,该变量成为 jlong 结果。在 Windows 上,这映射到非标准的 __int64 数据类型。为了创建函数原型,我们从头文件中复制函数声明,并在 C++ 文件源的开头添加对该文件的引用。这个头文件会引入 JDK 的 jni.h 头文件,该文件声明了所有 Java 数据类型和方法,因此 C++ 源文件不需要直接包含任何 JDK 文件,只需要包含提供对其他库(如操作系统 API)访问的头文件。

处理头文件时,如果使用 IDE 编译源文件,需要将 IDE 指向 JDK,这很脆弱且在不同系统上可能无法工作,更好的方法是将文件复制到源树中,可通过手动或使用 Ant 任务完成:

<target name="includes">
  <copy todir="${build.dir}/imported/jni">
    <fileset dir="${env.JAVA_HOME}/include" includes="**/*.h" />
  </copy>
</target>

不过,使用 <cc> 任务时不需要这样做,而是使用其 <include> 元素指向 JDK 中的文件。同时,需要处理 JAVA_HOME/include 下特定于平台的子目录,如 include/win32 include/linux

5. 编译 C++ 源文件

现在可以声明 <cc> 任务来构建 C++ 文件,以下是依赖于 headers 目标的任务声明:

<target name="cc-windows" depends="headers" >
  <cc debug="${build.debug}"
      link="shared" 
      outfile="${dist.filename.nosuffix}" 
      objdir="${obj.dir}" 
      multithreaded="true"
      exceptions="true" >
      <compiler name="msvc" />
      <fileset dir="src/cpp/windows"/>
      <includepath location="${generated.dir}" />
      <sysincludepath location="${env.JAVA_HOME}/include" />       
      <sysincludepath location="${env.JAVA_HOME}/include/win32" /> 
      <linker name="msvc" >
        <syslibset libs="kernel32,user32"/>
      </linker>
  </cc>
</target>

这个任务声明比较复杂,下面进行详细分析:
- 开始标签 <cc> 标签本身的所有属性都是跨平台的。例如,设置 debug 属性会告诉编译器生成调试信息,并让链接器在链接时包含这些信息。每个支持特定编译器或链接器的类都会为该工具生成适当的选项,用户只需选择选项即可。 link 属性告诉任务如何链接目标文件,以创建库文件或可执行文件,其可能的值如下表所示:
| Link type | Meaning |
| — | — |
| Shared | 共享库,.so 或 .dll |
| Application | 可执行文件;在 Windows 上以 .exe 结尾 |
| Static | 静态库,.a 或 .lib 扩展名;Unix 会添加 lib 前缀 |
| None | 不进行链接 |

outfile 属性不需要指定要生成的库或可执行文件的完整名称,只需指定基本名称,任务本身会确定最终名称。 objdir 参数指定中间目标文件的存放位置, multithread exceptions 选项为编译器和链接器指定更多选项。
- 配置编译器 :任务声明中的前五个嵌套元素是对编译器的指令:

<compiler name="msvc" />
<fileset dir="src/cpp/windows" includes="**/*.cpp"/>
<includepath location="${generated.dir}" />
<sysincludepath location="${env.JAVA_HOME}/include" />       
<sysincludepath location="${env.JAVA_HOME}/include/win32" /> 

首先选择编译器,默认是 GNU gcc。 fileset 声明用于引入源文件,在复杂项目中,可能有通用源文件以及特定于平台和编译器的文件,可使用条件模式集来管理。编译器还需要知道外部目录中包含文件的位置,使用 <includepath> <sysincludepath> 元素来描述。 <sysincludepath> 目录中列出的头文件不包含在依赖检查中,因为假定它们不会更改,而使用 <includepath> 指向生成的头文件是为了进行依赖检查。
- 配置链接器 :编译完成后进行链接,使用 linker 元素指定:

<linker name="msvc" >
  <syslibset libs="kernel32,user32"/>
</linker> 

这里不指定库文件的路径, LINK 工具必须从 LIB 环境变量中提取该信息,需要指定要包含的库。可以在 <cc> 任务中或链接器声明本身中声明库集,当向 <cc> 任务添加多个条件链接器时,在不同链接器声明中声明库会成为首选选项。
- 运行编译器 :所有内容声明完成后,就可以运行编译器,以下是输出示例:

compile:
     [javac] Compiling 1 source file to 
       C:\AntBook\Sections\Applying\cpp\build\classes 
headers:
     [javah] ClassArgument.name=
               org.example.antbook.cpu.CpuInfo 
cc-windows:
        [cc] 1 total files to be compiled.
        [cc] org_example_antbook_cpu_CpuInfo.cpp
        [cc] Creating library CpuInfo.lib and object CpuInfo.exp

compile 目标将 Java 源文件编译为 .class 文件, headers 目标使用该文件生成本地头文件, cc-windows 目标则创建 DLL 文件。虽然输出消息可以改进,但检查输出目录会发现 CpuInfo.dll 已创建。

6. 部署和测试库

现在进入测试和本地部署阶段,可以像往常一样使用 JUnit 进行测试,以下是详细介绍:
- 设计测试
- 第一个测试应验证库是否加载以及方法是否返回。
- 第二个测试可以验证每次调用时时钟计数是否增加。如果了解预期的往返时间,还可以验证往返时间是否在合理范围内,但这可能会受到 Java 运行时行为变化的影响。
- 第三个测试可以重复调用目标,以确定 JIT 优化后调用本身的开销,这个数字可以从任何实际性能测量中减去,以获得准确的测量结果。

以下是测试代码示例:

package org.example.antbook.cpu;
import junit.framework.*;
public class CpuInfoTest extends TestCase {
    private CpuInfo clock;

    public CpuInfoTest(String name) {
        super(name);
    }
    public void setUp() {
        clock=new CpuInfo();
    }

    public void testClockCallReturns() {
        long time1=clock.getCpuClock();
    }
    public void testClockCodeWorks() {
        long time1=clock.getCpuClock();
        long time2=clock.getCpuClock();
        long diff=time2-time1;
        System.out.println("Invocation time="+diff+" cycles");
        assertTrue(diff>0);
    }

    public void testJitOptimization() {
        int iterations=10000;
        long diff=spin(iterations);
        diff=spin(iterations);
        assertTrue(diff>0);
        int average=(int)(((float)diff)/iterations);
        System.out.println("Total time=" + diff+" cycles");
        System.out.println("Invocation time="+average+" cycles");
    }

    public long spin(int iterations) {
        long time1=clock.getCpuClock();
        long time2=0;
        for(int i=0;i<iterations;i++) {
            time2=clock.getCpuClock();
        }
        long diff=time2-time1;
        return diff;
    }

}

为了计算 JIT 热点优化后的平均往返时间,需要预热所有内容,通过在 spin 方法中实现迭代循环并调用两次,只记录第二次循环的迭代时间。
- 部署库 :在测试成功之前,需要将本地库部署到 Java 可以找到的位置,一个可靠的位置是 JDK 的 bin 子目录(至少在测试系统上是这样),但不能保证在其他系统上也能工作,因为这可能取决于 JRE 设置。以下是部署的 Ant 任务:

<property name ="deploy.dir"
    location="${env.JAVA_HOME}/bin" />
<target name="deploy" depends="cc-windows">
  <copy
    file="${dist.dir}/${libname}"
    todir="${deploy.dir}" />
  <echo message="deployed to ${deploy.dir}" />
</target>

运行此目标会在文件发生更改时进行复制,但由于 <javah> 每次运行都会重新生成头文件, <cc> 会解析源文件以确定头文件依赖关系,所以目前每次构建时文件都会被复制。如果目标 DLL 存在且正在使用(即有运行的 JVM 已加载它),部署目标将无法工作,复制操作会失败并导致构建中断。
- 测试目标 :可以使用 <junit> 调用测试,以下是配置示例:

<target name="test" depends="deploy">
  <junit printsummary="withOutAndErr" 
    failureproperty="tests.failed"
    fork="yes">
    <classpath>
      <pathelement location="${classes.dir}" />
      <pathelement path="${dist.dir}" />
      <pathelement path="${java.class.path}" />
    </classpath>
    <formatter type="plain" usefile="false"/>
    <test name="org.example.antbook.cpu.CpuInfoTest" />
  </junit>
  <fail if="tests.failed">Tests failed</fail>
</target>

配置 <junit> 在任何失败后继续执行,使用条件 <fail> 在任何测试不成功时停止构建。如果测试成功,执行 ant test 的输出尾部会类似如下内容:

test:
     [junit] Testsuite: org.example.antbook.cpu.CpuInfoTest
     [junit] Tests run: 3, Failures: 0, Errors: 0, 
            Time elapsed: 0.371 sec     
     [junit] ------------- Standard Output ---------------
     [junit] Invocation time=761 cycles
     [junit] Total time=1514496 cycles
     [junit] Invocation time=151 cycles
     [junit] ------------- ---------------- ---------------
     [junit]
     [junit] Testcase: testClockCallReturns took 0.221 sec
     [junit] Testcase: testClockCodeWorks took 0 sec
     [junit] Testcase: testJitOptimization took 0.02 sec
BUILD SUCCESSFUL 

这表明库正在加载( testClockCallReturns ),并且重复调用会增加计数器( testClockCodeWorks ),说明汇编代码正常工作。第一个测试耗时 0.221 秒,这是加载 Java 类和 DLL 的时间,即加载库的开销。第二个测试耗时 761 个周期,与 Win32 应用程序调用 Windows NT Ring Zero API 的 600 周期往返时间相当。优化后的往返时间降至 151 个周期,这是一个更可接受的数字,现在可以考虑使用这个库来计时应用程序中例程的执行时间。

7. 改进库查找

构建和测试过程中不太优雅的部分是需要将本地库放置在运行时的路径中。Java 1.2 增加了一个新属性 java.library.path 来指定本地库的搜索路径,以下是在 JUnit 测试中设置该属性的新目标:

<target name="test" depends="cc-windows">
  <junit printsummary="withOutAndErr" 
    failureproperty="tests.failed"
    fork="yes">
    <sysproperty key="java.library.path" 
      value="${dist.dir}"/>
    <classpath>
      <pathelement location="${classes.dir}" />
      <pathelement path="${dist.dir}" />
      <pathelement path="${java.class.path}" />
    </classpath>
    <formatter type="plain" usefile="false"/>
    <test name="org.example.antbook.cpu.CpuInfoTest" />
  </junit>
  <fail if="tests.failed">Tests failed</fail>
</target>  

这里从依赖列表中移除了 deploy 目标,不再需要它,也不需要在清理目标中从 JRE 中移除库。由于不再需要担心 <cc> 生成的共享库的特定于操作系统的名称,所以支持其他操作系统也会更容易。当清理之前的构建并运行这个目标时,如果 fork 属性设置为 true ,一切都会按计划工作;如果设置为 false ,运行时将无法获取属性设置,库将无法加载,所有测试都会失败。因此,使用 <java> 运行包含本地库的 Java 程序时,一定要分叉 JVM。

8. 跨平台支持

既然知道 Ant 可以构建和测试本地库,那么如何支持 Unix 构建以及 Windows 库呢?我们希望使用 GNU 工具(gcc 和 ar)为 Linux/x86 重新构建代码。由于所有 Java 代码都是跨平台的,所以与 Java 源文件相关的所有构建和测试阶段都可以无需更改地工作,剩下需要处理的两个领域是 C++ 源文件和编译它的目标。
- 迁移 C++ 源文件 :虽然用于测量时钟的机器代码可以独立于操作系统工作,但在 C++ 源文件中描述该机器代码则取决于操作系统。在迁移到 Linux 时,计划完全重写函数,所以目前不需要考虑可移植性。在 Windows 上使用 Visual C++ 的内联汇编器编译代码,而在 Linux 上需要使用不同的方法。例如,在 Linux 上可以使用 rdtsc 指令的汇编语言包装器,或者使用其他方法来读取时间戳计数器。
- 调整编译目标 :需要为 Linux 平台创建新的编译目标,使用 gcc 作为编译器, ar 作为静态库创建工具。以下是一个简单的示例:

<target name="cc-linux" depends="headers">
  <cc debug="${build.debug}"
      link="shared" 
      outfile="${dist.filename.nosuffix}" 
      objdir="${obj.dir}" 
      multithreaded="true"
      exceptions="true" >
      <compiler name="gcc" />
      <fileset dir="src/cpp/linux"/>
      <includepath location="${generated.dir}" />
      <sysincludepath location="${env.JAVA_HOME}/include" />       
      <sysincludepath location="${env.JAVA_HOME}/include/linux" /> 
      <linker name="gcc" >
        <syslibset libs="m,pthread"/>
      </linker>
  </cc>
</target>

这个目标与 Windows 版本类似,但使用 gcc 作为编译器和链接器,并指定了适用于 Linux 的库。同时,需要在 src/cpp 目录下创建 linux 子目录,并将 Linux 特定的 C++ 源文件放在该目录中。

通过以上步骤,就可以使用 Ant 构建跨平台的 JNI 库,并进行测试和部署。在实际项目中,可能需要根据具体需求进行更多的调整和优化。

使用 Ant 构建 JNI 库

9. 管理跨平台构建的注意事项

在进行跨平台构建时,有一些额外的注意事项需要考虑,以确保构建过程的顺利进行。
- 文件路径和分隔符 :不同操作系统使用不同的文件路径分隔符,例如 Windows 使用反斜杠 \ ,而 Unix/Linux 使用正斜杠 / 。在编写 Ant 脚本时,应尽量使用 Ant 的属性和函数来处理路径,避免硬编码分隔符。例如,可以使用 ${file.separator} 来表示当前操作系统的文件分隔符。
- 编译器和链接器选项 :不同的编译器和链接器可能有不同的选项和行为。在编写跨平台的 Ant 脚本时,需要根据不同的平台选择合适的编译器和链接器,并设置相应的选项。例如,在 Windows 上使用 Visual C++ 编译器,而在 Linux 上使用 GCC 编译器,它们的编译和链接选项可能会有所不同。
- 环境变量 :不同的操作系统可能有不同的环境变量设置。在编写 Ant 脚本时,需要确保所有必要的环境变量都已正确设置。例如,在 Windows 上需要设置 PATH INCLUDE LIB 环境变量,而在 Linux 上需要设置 PATH LD_LIBRARY_PATH 环境变量。

10. 优化构建过程

为了提高构建效率和减少不必要的构建时间,可以对构建过程进行一些优化。
- 增量构建 :Ant 支持增量构建,即只重新编译和链接那些发生了变化的文件。可以使用 <uptodate> 任务来检查文件是否有更新,从而避免不必要的编译和链接操作。例如,在 <javah> 任务中,可以添加 <uptodate> 任务来检查 Java 源文件是否有更新,只有在文件更新时才重新生成头文件。

<target name="headers" depends="compile">
  <uptodate property="headers.up.to.date" targetfile="${generated.dir}/org_example_antbook_cpu_CpuInfo.h">
    <srcfiles dir="src" includes="**/*.java" />
  </uptodate>
  <javah destdir="${generated.dir}"
    force="${!headers.up.to.date}"
    classpath="${classes.dir}">
      <class name="org.example.antbook.cpu.CpuInfo"/>
  </javah>
</target>
  • 并行构建 :如果系统资源允许,可以使用 Ant 的并行构建功能来同时执行多个任务,从而加快构建速度。可以使用 <parallel> 任务来并行执行多个目标。例如,可以并行执行 Java 编译和 C++ 编译任务:
<target name="parallel-build" depends="init">
  <parallel>
    <antcall target="compile" />
    <antcall target="cc-windows" />
  </parallel>
</target>
11. 错误处理和日志记录

在构建过程中,可能会出现各种错误,如编译错误、链接错误等。为了更好地调试和解决问题,需要进行有效的错误处理和日志记录。
- 错误处理 :可以使用 Ant 的 <fail> 任务来在出现错误时停止构建,并输出错误信息。例如,在 <junit> 任务中,如果测试失败,可以使用 <fail> 任务停止构建:

<target name="test" depends="deploy">
  <junit printsummary="withOutAndErr" 
    failureproperty="tests.failed"
    fork="yes">
    <classpath>
      <pathelement location="${classes.dir}" />
      <pathelement path="${dist.dir}" />
      <pathelement path="${java.class.path}" />
    </classpath>
    <formatter type="plain" usefile="false"/>
    <test name="org.example.antbook.cpu.CpuInfoTest" />
  </junit>
  <fail if="tests.failed">Tests failed</fail>
</target>
  • 日志记录 :Ant 提供了丰富的日志记录功能,可以使用 <echo> 任务输出日志信息,也可以使用 <logger> 任务自定义日志记录器。例如,可以在关键步骤输出日志信息,方便调试和监控构建过程:
<target name="compile">
  <echo message="Starting Java compilation..." />
  <javac srcdir="src" includes="**/*.java"
    destdir="${classes.dir}"/>
  <echo message="Java compilation completed." />
</target>
12. 总结

通过本文的介绍,我们了解了如何使用 Ant 构建 JNI 库,包括构建步骤、编写 Java 存根、创建 C++ 头文件、编写 C++ 代码、编译源文件、部署和测试库等。同时,我们还探讨了如何进行跨平台构建、优化构建过程以及错误处理和日志记录等方面的内容。

以下是整个构建过程的流程图总结:

graph LR
    A[编写 Java 存根] --> B[编译 Java 源文件]
    B --> C[创建 C++ 头文件]
    C --> D[编写 C++ 代码]
    D --> E[编译 C++ 源文件]
    E --> F[部署库]
    F --> G[测试库]
    H[迁移 C++ 源文件] --> I[调整编译目标]
    I --> E
    J[优化构建过程] --> B
    J --> E
    K[错误处理和日志记录] --> B
    K --> E
    K --> G

在实际项目中,需要根据具体需求和平台特点进行适当的调整和优化,以确保构建过程的高效性和稳定性。希望本文对您在使用 Ant 构建 JNI 库方面有所帮助。

基于数据驱动的 Koopman 算子的递归神经网络模型线性化,用于纳米定位系统的预测控制研究(Matlab代码实现)内容概要:本文围绕“基于数据驱动的 Koopman 算子的递归神经网络模型线性化,用于纳米定位系统的预测控制研究”展开,提出了一种结合数据驱动方法与Koopman算子理论的递归神经网络(RNN)模型线性化方法,旨在提升纳米定位系统的预测控制精度与动态响应能力。研究通过构建数据驱动的线性化模型,克服了传统非线性系统建模复杂、计算开销大的问题,并在Matlab平台上实现了完整的算法仿真与验证,展示了该方法在高精度定位控制中的有效性与实用性。; 适合人群:具备一定自动化、控制理论或机器学习背景的科研人员与工程技术人员,尤其是从事精密定位、智能控制、非线性系统建模与预测控制相关领域的研究生与研究人员。; 使用场景及目标:①应用于纳米级精密定位系统(如原子力显微镜、半导体制造设备)中的高性能预测控制;②为复杂非线性系统的数据驱动建模与线性化提供新思路;③结合深度学习与经典控制理论,推动智能控制算法的实际落地。; 阅读建议:建议读者结合Matlab代码实现部分,深入理解Koopman算子与RNN结合的建模范式,重点关注数据预处理、模型训练与控制系统集成等关键环节,并可通过替换实际系统数据进行迁移验证,以掌握该方法的核心思想与工程应用技巧。
基于粒子群算法优化Kmeans聚类的居民用电行为分析研究(Matlb代码实现)内容概要:本文围绕基于粒子群算法(PSO)优化Kmeans聚类的居民用电行为分析展开研究,提出了一种结合智能优化算法与传统聚类方法的技术路径。通过使用粒子群算法优化Kmeans聚类的初始聚类中心,有效克服了传统Kmeans算法易陷入局部最优、对初始值敏感的问题,提升了聚类的稳定性和准确性。研究利用Matlab实现了该算法,并应用于居民用电数据的行为模式识别与分类,有助于精细化电力需求管理、用户画像构建及个性化用电服务设计。文档还提及相关应用场景如负荷预测、电力系统优化等,并提供了配套代码资源。; 适合人群:具备一定Matlab编程基础,从事电力系统、智能优化算法、数据分析等相关领域的研究人员或工程技术人员,尤其适合研究生及科研人员。; 使用场景及目标:①用于居民用电行为的高效聚类分析,挖掘典型用电模式;②提升Kmeans聚类算法的性能,避免局部最优问题;③为电力公司开展需求响应、负荷预测和用户分群管理提供技术支持;④作为智能优化算法与机器学习结合应用的教学与科研案例。; 阅读建议:建议读者结合提供的Matlab代码进行实践操作,深入理解PSO优化Kmeans的核心机制,关注参数设置对聚类效果的影响,并尝试将其应用于其他相似的数据聚类问题中,以加深理解和拓展应用能力。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值