Java系列

Java核心知识体系1:泛型机制详解
Java核心知识体系2:注解机制详解
Java核心知识体系3:异常机制详解
Java核心知识体系4:AOP原理和切面应用
Java核心知识体系5:反射机制详解
Java核心知识体系6:集合框架详解
Java核心知识体系7:线程不安全分析
Java核心知识体系8:Java如何保证线程安全性
Java核心知识体系9-并发与多线程:线程基础

在Java程序开发中,线程管理是一个至关重要的方面。它涉及到如何有效地创建、调度、同步和销毁线程,以确保程序的性能、响应性和稳定性。以下是对Java线程管理的详细探讨。

1 线程的基本概念

线程是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。每个线程都有一个独立的执行路径,但共享进程的资源,如内存和文件句柄。在Java中,线程可以通过继承
Thread
类或实现
Runnable
接口来创建。

此外,
Java 5
开始,引入了
java.util.concurrent
包,提供了更多的并发工具,如Callable接口与Future接口,它们主要用于任务执行。

2 线程的创建与启动

2.1 继承Thread类

  • 创建一个类继承自
    Thread
    类。
  • 重写
    run()
    方法,该方法包含了线程要执行的任务。
  • 创建该类的对象,并调用
    start()
    方法启动线程。
class MyThread extends Thread {
    public void run() {
        System.out.println("线程运行中");
    }
}

public class ThreadDemo {
    public static void main(String[] args) {
        MyThread t = new MyThread();
        t.start(); // 调用start()方法来启动线程
    }
}

2.2 实现Runnable接口

  • 创建一个类实现
    Runnable
    接口。
  • 实现
    run()
    方法,该方法同样包含了线程要执行的任务。
  • 将该类的对象作为参数传递给
    Thread
    类的构造函数,创建
    Thread
    对象。
  • 调用
    Thread
    对象的
    start()
    方法启动线程。
class MyRunnable implements Runnable {
    public void run() {
        System.out.println("线程运行中");
    }
}

public class RunnableDemo {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable());
        t.start(); // 调用start()方法来启动线程
    }
}

3 线程的同步与通信

由于多个线程可能会同时访问共享资源,因此需要使用同步机制来确保数据的正确性和一致性。Java提供了多种同步机制,如
synchronized
关键字、
wait()

notify()
方法、以及
ReentrantLock
等。

3.1 synchronized关键字

  1. 可以用于方法或代码块上,以确保同一时刻只有一个线程能够执行该方法或代码块。
  2. 当一个线程持有某个对象的锁时,其他线程将无法访问该对象的同步方法或代码块,直到锁被释放。
public class SynchronizedExample {
    private int count = 0;

    // 同步方法
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }

    public static void main(String[] args) throws InterruptedException {
        SynchronizedExample example = new SynchronizedExample();

        // 创建多个线程来测试同步
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                example.increment();
            }
        });

        t1.start();
        t2.start();

        // 等待线程执行完毕
        t1.join();
        t2.join();

        // 输出最终结果
        System.out.println("Final count: " + example.getCount()); // 最终输出2000
    }
}

3.2 wait()和notify()方法

这两个方法用于在线程之间进行通信。

  1. wait()
    方法使当前线程等待,直到其他线程调用
    notify()

    notifyAll()
    方法唤醒它。
  2. notify()
    方法唤醒一个等待该对象的线程(如果有多个线程在等待,则选择其中一个),而
    notifyAll()
    方法唤醒所有等待该对象的线程。

image

# 先写后读
public class WaitNotifyExample {
    private final Object lock = new Object();
    private boolean ready = false;

    public void writer() throws InterruptedException {
        synchronized (lock) {
            // 模拟写操作
            Thread.sleep(1000); // 假设写操作需要1秒
            System.out.println("Data is ready");
            ready = true;
            lock.notify(); // 唤醒等待的线程
        }
    }

    public void reader() throws InterruptedException {
        synchronized (lock) {
            while (!ready) {
                lock.wait(); // 等待数据准备好
            }
            // 读取数据
            System.out.println("Data has been read");
        }
    }

    public static void main(String[] args) {
        WaitNotifyExample example = new WaitNotifyExample();

        Thread writerThread = new Thread(example::writer);
        Thread readerThread = new Thread(example::reader);

        writerThread.start();
        readerThread.start();
    }
}

3.3 ReentrantLock

  1. 提供了比
    synchronized
    更灵活的锁机制。
  2. 可以显式地加锁和解锁,还支持公平锁和非公平锁等特性。

四、线程的生命周期与状态

Java线程在其生命周期中会经历多种状态,包括新建(New)、就绪(Runnable)、运行(Running)、阻塞(Blocked)、等待(Waiting)、超时等待(Timed Waiting)和终止(Terminated)。

  • 新建(New)
    :线程被创建但尚未启动。
  • 就绪(Runnable)
    :线程已启动且正在等待CPU分配时间片。
  • 运行(Running)
    :线程正在执行其任务。
  • 阻塞(Blocked)
    :线程因等待某个条件而暂时停止执行。
  • 等待(Waiting)
    :线程因调用
    wait()
    方法而等待其他线程唤醒。
  • 超时等待(Timed Waiting)
    :线程在等待某个条件的同时还设置了一个超时时间。
  • 终止(Terminated)
    :线程已完成任务并退出。

image

5 线程池

为了更有效地管理线程,Java提供了线程池机制。线程池是一种用于管理和复用线程的框架,它允许开发者以较小的开销来创建和管理大量的线程。Java中的
ExecutorService
接口及其实现类(如
ThreadPoolExecutor
)提供了强大的线程池功能。
Java中提供了几种常见的线程池类型,包括:

  1. FixedThreadPool(固定大小线程池):包含固定数量的线程,适用于需要限制并发线程数量的场景。
  2. CachedThreadPool(缓存线程池):不固定线程数量,可以根据需要自动创建新线程,适用于短期异步任务。
  3. SingleThreadPool(单线程池):只包含一个工作线程,保证所有任务按顺序执行,适用于需要保持任务顺序执行的场景。
  4. ScheduledThreadPool(定时线程池):可以执行定时任务和周期性任务。
  5. WorkStealingPool(工作窃取线程池):Java 8中引入的一种新类型的线程池,主要用于处理耗时任务,适用于需要大量并行任务、任务之间没有依赖关系的情况。

在后续的章节里面,我们会专门来详细介绍下线程池的使用

6 最佳实践

  • 避免创建过多的线程
    :过多的线程会导致上下文切换频繁,从而降低系统性能。
  • 合理设置线程优先级
    :根据任务的紧急程度和重要性来设置线程的优先级。
  • 使用线程安全的集合
    :在多线程环境下使用线程安全的集合来避免数据不一致的问题。
  • 避免死锁
    :在设计多线程程序时要特别注意避免死锁的发生。

综上所述,Java线程管理是一个复杂而重要的领域。通过合理地创建、调度、同步和销毁线程,可以显著提高程序的性能、响应性和稳定性。同时,开发者还需要遵循一些最佳实践来避免常见的问题和陷阱。

来源:晓飞的算法工程笔记 公众号,转载请注明出处

论文: Target-Aware Language Modeling via Granular Data Sampling

创新点


  • 提出了一种将预先训练好的标记符与多粒度标记符合并的算法,生成高效的
    n-gram
    特征,而且与下游任务的性能有很高的相关性。
  • 利用上述研究成果,改进了基于重要性的数据采样技术,将通用词汇集调整为目标词汇集。这样就能更好地代表数据,提高模型在目标任务中的性能,同时在非目标任务中保持良好的性能。

内容概述


语言模型的预训练通常针对广泛的使用场景,并结合来自多种来源的数据。然而,有时模型需要在特定领域中表现良好,同时又不影响其他领域的性能。这就需要使用数据选择方法来确定潜在核心数据,以及如何有效地对这些选定数据进行抽样训练。

论文使用由多粒度标记组成的
n-gram
特征进行重要性抽样,这在句子压缩和表征能力之间取得了良好的平衡。抽样得到的数据与目标下游任务性能之间有很高的相关性,同时保留了其在其他任务上的有效性,使得语言模型可以在选定文档上更高效地进行预训练。

在八个基准测试中,在使用约
1%
的数据时,预训练模型的表现与完整的
RefinedWeb
数据相当,并且在模型规模范围为
125M

1.5B
时,超越了随机选择的样本。

方法


从大规模数据集(如
RefinedWeb
)中选择样本是缓慢且昂贵的,一个可行的解决方案是使用容易计算的
n-gram
特征将每个文档编码为向量。

假设从目标分布
\(p\)
中获取了一小部分目标文本示例
\(D_{task}\)
,以及从分布
\(q\)
中获取的大量原始数据集
\(D_{raw}\)
,其中包含
\(N\)
个示例,目标是从原始数据集中选择
\(k\)
个示例(
\(k \ll N\)
),这些示例与目标相似。

重要性采样

重要性采样技术选择与目标分布对齐的示例,为每个文本提供可处理的重要性估计,并在提供必要结构的特征空间
\({\mathbb{Z}}\)
上应用重要性采样。

特征提取器
\(h: {\mathbb{X}} \rightarrow {\mathbb{Z}}\)
用于转换输入为特征,得到的原始特征分布
\(q_{\text{feat}}\)
和目标特征分布
\(p_{\text{feat}}\)
,目标是选择特征与目标特征分布
\(p_{\text{feat}}\)
对齐的数据。

为了提取特征
\(q_{\text{feat}}\)

\(p_{\text{feat}}\)
,从每个分词文档中提取
n-grams
。每个
n-gram
被映射到哈希表中的一个键,每个键映射到
n-gram
计数。将从
\(N\)
个原始示例中获得的每个特征
\(z_i = h(x_i)\)
计算重要性权重,权重为
\(w_i = \frac{\hat{p}_{\text{feat}}(z_i)}{\hat{q}_{\text{feat}}(z_i)}\)

最后进行采样,从一个分布中选择
\(k\)
个示例,且不进行替换,其概率由
\(\frac{w_i}{\sum_{i=1}^N w_i}\)
给出。

分词器适配

为了推导目标词汇
\(V(t)\)
,使用
Llama-3
分词器的词汇
\(V_{start}\)
作为起点,并将
\(V_{start}\)
与从任务数据
\(D_{task}\)
中学习到的
\(V_{task}\)
合并。在构建
\(V_{task}\)
时,确保包含多粒度的标记(即单词和多词组合),然后将
\(V_{task}\)

\(V_{start}\)
合并形成
\(v(t - 1)\)

接下来,逐步从
\(v(t - 1)\)
中移除标记,以获得
\(v(t)\)
,在此过程中,最小化与原始词汇集的距离,以便提取更少偏倚的文档特征作为
n-gram
向量。

首先定义一个度量来衡量语料库中词汇集的质量,然后通过最大化词汇效用度量 (
\(\mathcal{H}_{v}\)
) 来学习最佳词汇,该度量的计算公式为:

\[\begin{equation}
\mathcal{H}_{v} = - \frac{1}{l_{v}}\sum_{j \in v } P(j)\log P(j),
\end{equation}
\]

其中,
\(P(j)\)
是来自目标数据的标记
\(j\)
的相对频率,而
\(l_{v}\)
是词汇
\(v\)
中标记的平均长度。对于任何词汇,其熵得分
\(\mathcal{H}_{v}\)
基于其前一步的词汇进行计算,优化问题可以表述为:

\[\begin{equation}
\text{arg\ min}_{v(t-1), v(t)} \big [ \mathcal{H}{v(t)} - \mathcal{H}{v(t-1)} \big ]
\end{equation}
\]

其中,
\(v(t)\)

\(v(t - 1)\)
是包含所有词汇的两个集合,大小的上限分别为
\(|v(t)|\)

\(|v(t - 1)|\)
。设置
\(|v(t)| = 10k\)
,其中
\(t=10\)
,而
\(|v(0)|\)
是默认的
Llama-3 tokenizer
的词汇大小。

主要实验




如果本文对你有帮助,麻烦点个赞或在看呗~
更多内容请关注 微信公众号【晓飞的算法工程笔记】

work-life balance.

让我们仔细看看是怎么访问数据库的

package sql;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public class Conn { // 创建类Conn
    Connection con; // 声明Connection对象
    public static String user;
    public static  String password;
    public Connection getConnection() { // 建立返回值为Connection的方法
        try { // 加载数据库驱动类
            Class.forName("com.mysql.cj.jdbc.Driver");
            System.out.println("数据库驱动加载成功");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        user = "root";//数据库登录名
        password = "root";//密码
        try { // 通过访问数据库的URL获取数据库连接对象
            con = DriverManager.getConnection("jdbc:mysql://localhost:3306/test1?useUnicode=true&characterEncoding=gbk", user, password);
            System.out.println("数据库连接成功");
        } catch (SQLException e) {
            e.printStackTrace();
        }
        return con; // 按方法要求返回一个Connection对象
    }
    public static void main(String[] args) { // 主方法,测试连接
        Conn c = new Conn(); // 创建本类对象
        c.getConnection(); // 调用连接数据库的方法
    }
}


具体用法

我们直接看下列的代码

package Main;

import java.sql.*;

public class JDBC {
    public static void main(String[] args) throws SQLException, ClassNotFoundException {
//        1.加载驱动
        Class.forName("com.mysql.cj.jdbc.Driver");
//        2.用户信息和url
        String url = "jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&useSSL=true";
        String username="root";
        String password="root";
//        3.连接成功,数据库对象 Connection
        Connection connection = DriverManager.getConnection(url,username,password);
//        4.执行SQL对象Statement,执行SQL的对象
        Statement statement = connection.createStatement();
//        5.执行SQL的对象去执行SQL,返回结果集
        String sql = "SELECT *FROM studentinfo;";
        ResultSet resultSet = statement.executeQuery(sql);
        while(resultSet.next()){
            System.out.println("SNo="+resultSet.getString("SNo"));
            System.out.println("SName="+resultSet.getString("SName"));
            System.out.println("Birth="+resultSet.getString("Birth"));
            System.out.println("SPNo="+resultSet.getString("SPNo"));
            System.out.println("Major="+resultSet.getString("Major"));
            System.out.println("Grade="+resultSet.getString("Grade"));
            System.out.println("SInstructor="+resultSet.getString("SInstructor"));
            System.out.println("SPwd="+resultSet.getString("SPwd"));
        }
//        6.释放连接
        resultSet.close();
        statement.close();
        connection.close();
    }
}


加载数据库类

Class.forName("com.mysql.cj.jdbc.Driver");
是用来
加载数据库驱动
的。用于我们的 Java 程序与数据库通信。

Class.forName()
函数的作用是用于动态加载一个类,其中的参数自然也是数据库的驱动类
com.mysql.cj.jdbc.Driver

连接数据库

我们通常使用
DriverManager.getConnection(url,username,passwd)
方法来连接数据库,这里面需要我们填入三个参数:

  • url
    :数据库的URL,格式很重要


    • "jdbc:mysql"
      :这是告诉程序使用 JDBC 驱动来连接 MySQL 数据库。

    • "localhost"
      :指向本地计算机的数据库服务器。

    • "3306"
      :MySQL 服务的默认端口号。

    • "test1"
      :这是要访问的数据库名称。

    • ?useUnicode=true&characterEncoding=gbk
      :这些是查询参数,用于指定数据库的配置。它们表示:

    • useUnicode=true
      :启用 Unicode 支持,确保可以存储和读取 Unicode 字符。

    • characterEncoding=gbk
      :设置字符编码为 GBK,通常用于处理中文字符。

  • username
    :数据库的用户名

  • passwd
    :数据库的密码

实例化一个SQL对象Statement

这没啥好说的,就是实例化一个对象,以便于我们能调用其中的各种方法

执行SQL语句,查询数据库

查询

要执行数据库的查询我们直接使用
executeQuery(String sql)
方法,然后里面写入我们的sql语句就行,之后我们就能从返回值得到查询的结果了
ResultSet resultSet = statement.executeQuery(sql);

结果

得到了一个
ResultSet
结果对象之后,想要的得到字符串直接使用
getString()
方法就行,除此之外还有
getLong()

getInt()
等,就对应了其中的数据类型。

然后就是这些get方法的参数,有两种参数:

  • 列名称:就是你查询出来后,直接输入列表的名称就给你输出了查询的结果,名称的类型自然就是String类型的
  • 列索引:直接从中输入列的索引就会输入第几列的查询结果,但是注意!!!!这里的所以不再是从0开始,而是从1开始,这是非常值得注意的地方,所以的参数类型自然就是int了

释放链接

为了资源不要浪费,使用完了就应该直接释放了

        resultSet.close();
        statement.close();
        connection.close();

防止sql注入的改良

发现问题

又细心的人就会发现前面的代码使用
Statement
拼字符串非常容易引发SQL注入的问题。

什么是SQL注入呢?我们一般查询数据库靠着相对的命令来实现,如果SQL语句是靠要查的字符拼接出来的,一般是没有问题的,但是我们输入一些特定的字符的时候是可能会让sql语句去做其他的事情。总之SQL一般都是因为字符的拼接漏洞实现的。

解决问题

所以我们就得想办法去解决这个问题,有一个方法是转义特定的字符,但这终究是治标不治本的。

前面我们提到,最根本的问题是字符拼接带来的漏洞,如果我们不进行字符拼接,直接传递要查的字符,那问题就引刃而解了


Statement
换成
PreparedStatement
可以
完全避免SQL注入
的问题,因为
PreparedStatement
始终使用
?
作为占位符,并且把数据连同SQL本身传给数据库,这样可以保证每次传给数据库的SQL语句是相同的,只是占位符的数据不同,还能高效利用数据库本身对查询的缓存。

//使用prepareStatement查询
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    try (PreparedStatement ps = conn.prepareStatement("SELECT id, grade, name, gender FROM students WHERE gender=? AND grade=?")) {
        ps.setObject(1, "M"); // 注意:索引从1开始
        ps.setObject(2, 3);
        try (ResultSet rs = ps.executeQuery()) {
            while (rs.next()) {
                long id = rs.getLong("id");
                long grade = rs.getLong("grade");
                String name = rs.getString("name");
                String gender = rs.getString("gender");
            }
        }
    }
}
//使用Statement查询
try (Connection conn = DriverManager.getConnection(JDBC_URL, JDBC_USER, JDBC_PASSWORD)) {
    try (Statement stmt = conn.createStatement()) {
        try (ResultSet rs = stmt.executeQuery("SELECT id, grade, name, gender FROM students WHERE gender=1")) {
            while (rs.next()) {
                long id = rs.getLong(1); // 注意:索引从1开始
                long grade = rs.getLong(2);
                String name = rs.getString(3);
                int gender = rs.getInt(4);
            }
        }
    }
}

我们来看看这个例子,上面的是使用的prepareStatement,下面使用的是Statement。虽然两者之间没有太大的区别,但是还有值得我们注意的地方:

  • sql语句插入的函数不同,前者是在prepareStatement就已经插入,而后者是在executeQuery才插入
  • prepareStatement是需要使用setObject方法来指定我们查询的字符的,但是Statement是不用的,至于为什么不行我们后文再说
  • ResultSet

    next()
    方法用于
    移动游标

    结果集中的下一行
    ,返回的数据类型看代码无疑是一个
    boolean

解决完问题带来的思考

我们从代码层面分析完成之后,我想很多人跟我有一样的提问,
prepareStatement
为什么能够做到防止SQL的注入,这里我们在稍微升入SQL注入一点,在详细剖析SQL注入是怎么完成的。

SQL注入的本质

SQL注入漏洞出现的原因就是用户的输入会直接嵌入到查询语句中,一旦出现精心设计的输入就会改变整个SQL语句的结构

比如现在有这样的语句

String query = "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'";

如果输入了恶意的内容
username = "admin' --"

SELECT * FROM users WHERE username = 'admin' --' AND password = 'password';

后面输入的
AND password = 'password';
就直接被注释掉了,这样就会只查询前面的
username = 'admin'

prepareStatement
的防御原理

前面不是说了就是因为用户输入和查询语句不是分离的吗,那思路就很简单了,那将两者分离不就行了

占位符分离

在预编译阶段,SQL 查询的结构被解析并发送到数据库中,这时
占位符

?
)会被数据库视为参数的占位符,而不是 SQL 语句的一部分。

例如:

String query = "SELECT * FROM users WHERE username = ? AND password = ?";
PreparedStatement ps = conn.prepareStatement(query);
ps.setString(1, username);
ps.setString(2, password);

在这里:

  • ?
    是占位符,它在查询执行时被替换为实际的参数。
  • ps.setString(1, username)

    ps.setString(2, password)
    将用户输入的值安全地绑定到查询中。
  • 在执行查询时,数据库知道
    ?
    只是占位符,它不会将用户输入作为 SQL 代码的一部分来解析,而是将其作为数据处理。

即使用户输入恶意的内容,例如:

  • username = "admin' OR 1=1 --"
  • password = "password"

构造的 SQL 查询也不会发生注入,因为数据库会将这些输入当作普通的字符串处理,而不会将其作为 SQL 语句的一部分。执行时的 SQL 查询将是:

SELECT * FROM users WHERE username = 'admin'' OR 1=1 --' AND password = 'password'

这个查询在数据库端仍然会被正确地作为两条字符串值传递,而不会被解析为恶意的 SQL 代码

自动转译用户输入

PreparedStatement
会自动转义参数中的特殊字符,如单引号(
'
)等,使其在数据库中正确地作为字符串处理。这进一步防止了 SQL 注入攻击。

例如,如果用户输入的用户名是:

  • admin' --

PreparedStatement
会自动将这个字符串转义为:

  • 'admin'' --'

这样,即使用户输入恶意的内容,数据库也会将其作为普通字符串处理,而不会被当作 SQL 语句的一部分执行。

简介

反射,反射,程序员的快乐。

前期绑定与后期绑定

在.NET中,前期绑定(Early Binding)是指在编译时就确定了对象的类型和方法,而后期绑定(Late Binding)或动态绑定是在运行时确定对象的类型和方法。

前置知识:C#类型系统结构

C#作为C++++ ,在类型系统上沿用C++的类型系统

前期绑定

在代码能执行之前,将代码中依赖的assembly,module,class,method,field等
类型系统
的元素提前构建好。
前期绑定的优点是编译时类型检查,提高了类型安全性和性能。缺点是如果需要更换类型,需要重新编译代码。灵活性不够
image

比如一个简单的的控制台,就自动提前加载了各种需要的DLL文件。完成前期绑定。

后期绑定

后期绑定的优点是可以在运行时更改类型,无需重新编译代码。缺点是在编译时不进行类型检查,可能导致运行时错误。
几个常用的场景,比如
dynamic ,多态,System.Reflection

举个例子,使用Reflection下的“元数据查询API”,动态加载DLL

            var dllpath = "xxx.dll";
            Assembly assembly = Assembly.LoadFrom(dllpath);//构建Assembly+Module

            Type dataAccessType = assembly.GetType("xxxxx");//构建Class(MethodTable+EEClass)

            object dataAccess = Activator.CreateInstance(dataAccessType);//在托管堆中创建MT实例

            MethodInfo addMethod = dataAccessType.GetMethod("Add");//构建MethodDesc
			
            addMethod.Invoke(dataAccess, new object[] { "hello world" });//调用方法

反射

反射的本质就是
“操作元数据”

什么是元数据?

MetaData,本是上就是存储在dll中的一个信息数据库,记录了这个assembled中有哪些方法,哪些类,哪些属性等等信息
image
可以看到,各种Table组成的信息,是不是类似一个数据库?

举个例子:
执行Type.GetType("int"),反射会在MetaData寻找"int"的类型。但在运行时会返回null.因为MetaData中只有"System.Int32"这个字符串。

反射如何查询MetaData?

通过Reflection XXXInfo系列API 查询所有细节

Type t = typeof(System.IO.FileStream);
FieldInfo[] fi = t.GetFields(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
PropertyInfo[] pi = t.GetProperties(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
EventInfo[] ei = t.GetEvents(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
......

反射如何构建类型系统

通过Reflection XXXBuilder系列API 构建一个全新的类型

            AssemblyBuilder ab = AssemblyBuilder.DefineDynamicAssembly(new AssemblyName("MyAssembly"), AssemblyBuilderAccess.RunAndCollect);//创建Assembly
            ModuleBuilder mob = ab.DefineDynamicModule("MyModule");//创建Module
            TypeBuilder tb = mob.DefineType("MyType", TypeAttributes.Public | TypeAttributes.Class);//创建Class
            MethodBuilder mb = tb.DefineMethod("SumMethod", MethodAttributes.Public | MethodAttributes.Static, typeof(int), new Type[] { typeof(int), typeof(int) });//创建MethodTable

            ILGenerator il = mb.GetILGenerator();//通过IL API 动态构建MethodDesc
            il.Emit(OpCodes.Ldarg_0);
            il.Emit(OpCodes.Ldarg_1);
            il.Emit(OpCodes.Add);
            il.Emit(OpCodes.Ret);

            Type type = tb.CreateType();  //mt + eeclass

            MethodInfo method = type.GetMethod("SumMethod");
            Console.WriteLine(method.Invoke(null, new object[] { 5, 10 }));

反射底层调用

C#的类型系统,与C++的类型系统是一一对应的。因此其底层必定是调用C++的方法。
示意图如下,有兴趣的小伙伴可以去参考coreclr的源码
image

眼见为实,以Invoke为例

image

反射到底慢在哪?

  1. 动态解析
    从上面可知道,反射作为后期绑定,在runtime中要根据metadata查询出信息,严重依赖字符串匹配,这本身就增加了额外的操作
  2. 动态调用
    使用反射调用方法时,先要将参数打包成数组,再解包到线程栈上。又是额外操作。
  3. 无法在编译时优化
    反射是动态的临时调用,JIT无法优化。只能根据代码一步一步执行。
  4. 额外的安全检查
    反射会涉及到访问和修改只读字段等操作,运行时需要进行额外的安全性检查,这也会增加一定的开销
  5. 缓存易失效
    反射如果参数发生变化,那么缓存的汇编就会失效。又需要重新查找与解析。

总之,千言万语汇成一句话。最好的反射就是不要用反射。除非你能保证
对性能要求不高/缓存高命中率

CLR的对反射的优化

除了缓存反射的汇编,.NET 中提供了一系列新特性来尽可能的绕开“反射”

Emit

Emit 是 .NET 提供的一种动态生成和编译代码的技术。通过 Emit,我们可以动态生成一个新的方法,这个方法可以直接访问私有成员,这对于一些特殊场景非常有用,比如动态代理、代码生成器、AOP(面向切面编程)等.

public class Person
{
    private int _age;
    public override string ToString()
    {
        return _age.ToString();
    }
}
static void EmitTest(Person person)
{
    // 获取Person类的类型对象
    Type personType = typeof(Person);

    // 获取私有字段_age的FieldInfo,无法避免部分使用反射
    FieldInfo ageField = personType.GetField("_age", BindingFlags.Instance | BindingFlags.NonPublic);

    if (ageField == null)
    {
        throw new ArgumentException("未找到指定的私有字段");
    }

    // 创建一个动态方法
    DynamicMethod dynamicMethod = new DynamicMethod("SetAgeValue", null, new Type[] { typeof(Person), typeof(int) }, personType);

    // 获取IL生成器
    ILGenerator ilGenerator = dynamicMethod.GetILGenerator();

    // 将传入的Person对象加载到计算栈上(this指针)
    ilGenerator.Emit(OpCodes.Ldarg_0);

    // 将传入的新值加载到计算栈上
    ilGenerator.Emit(OpCodes.Ldarg_1);

    // 将新值存储到对应的私有字段中
    ilGenerator.Emit(OpCodes.Stfld, ageField);

    // 返回(因为方法无返回值,这里只是结束方法执行)
    ilGenerator.Emit(OpCodes.Ret);

    // 创建委托类型,其签名与动态方法匹配
    Action<Person, int> setAgeAction = (Action<Person, int>)dynamicMethod.CreateDelegate(typeof(Action<Person, int>));

    // 通过委托调用动态生成的方法来修改私有字段的值
    setAgeAction(person, 100);
}

切构建代码又臭又长。

Expression

Expression 是 .NET 提供的一种表达式树的技术。通过 Expression,我们可以创建一个表达式树,然后编译这个表达式树,生成一个可以访问私有成员的方法

static void ExpressionTest(Person person)
{
    // 获取Person类的类型对象
    Type personType = typeof(Person);

    // 获取私有字段_age的FieldInfo,无法避免部分使用反射
    FieldInfo ageField = personType.GetField("_age", BindingFlags.Instance | BindingFlags.NonPublic);

    if (ageField == null)
    {
        throw new ArgumentException("未找到指定的私有字段");
    }

    // 创建参数表达式,对应传入的Person对象实例
    ParameterExpression instanceParam = Expression.Parameter(personType, "instance");

    // 创建参数表达式,对应传入的新值
    ParameterExpression newValueParam = Expression.Parameter(typeof(int), "newValue");

    // 创建一个赋值表达式,将新值赋给私有字段
    BinaryExpression assignExpression = Expression.Assign(Expression.Field(instanceParam, ageField), newValueParam);

    // 创建一个包含赋值表达式的表达式块,这里因为只有一个赋值操作,所以块里就这一个表达式
    BlockExpression blockExpression = Expression.Block(assignExpression);

    // 创建一个可执行的委托,其类型与表达式块的逻辑匹配
    Action<Person, int> setAgeAction = Expression.Lambda<Action<Person, int>>(blockExpression, instanceParam, newValueParam).Compile();

    // 通过委托调用表达式树生成的逻辑来修改私有字段的值
    setAgeAction(person, 100);
}

切构建代码又臭又长。

UnsafeAccessorAttribute

.Net 8中引入了新特性
UnsafeAccessorAttribute

使用该特性,来提供对私有字段的快速修改

static void New()
{
    var person = new Person();
    GetAgeField(person) = 100;
}
[UnsafeAccessor(UnsafeAccessorKind.Field, Name = "_age")]
static extern ref int GetAgeField(Person counter);

为什么它这么快?

对于C#来说,私有类型是OOP语言的定义。它定义了什么是私有类型,它的行为是什么。
但对于程序本身来说,代码和数据都只是一段内存,实际上你的指针想访问哪就访问哪。哪管你什么私有类型。换一个指向地址不就得了。因此CLR开放了这么一个口子,利用外部访问直接操作内存。看它的命名
Unsafe
Accessor就能猜到意图了

3,2,1. 上汇编!!!
image
直接将rax寄存器偏移量+8,直接返回int(占用4字节,偏移量8)类型的_age。 没有Emit,Expression的弯弯绕绕,丝毫不拖泥带水。

.NET 9中的改进

支持泛型,更优雅。
https://learn.microsoft.com/zh-cn/dotnet/core/compatibility/core-libraries/9.0/unsafeaccessor-generics

参考资料

https://blog.csdn.net/sD7O95O/article/details/133002995
https://learn.microsoft.com/zh-cn/dotnet/api/system.runtime.compilerservices.unsafeaccessorattribute?view=net-8.0

前言

许多算法的本质是统计。线段树用于统计,是沟通原数组与前缀和的桥梁。

《统计的力量》清华大学-张昆玮

关于线段树

前置知识:
线段树 OIWiki

线段树是一种专门
维护区间问题
的数据结构。
线段树对信息进行
二进制化处理
并在树形结构上维护,以此让处理速度达到
\(O(\log{n})\)
级别。

线段树的实现方式

由于线段树的树形结构特点,每次修改查询可以从根节点向下二分查找需要用到的节点,因此较为普遍且快捷的线段树会使用
递归
实现。

但递归实现的线段树由于每次要从根节点
递归
向下传递子树信息,导致常数较大,容易被卡常,所以出现了
常数更小的递推实现
的线段树(膜拜 zkw 大佬)。


zkw 线段树

先来讲一些小原理。

一、原理

由于
递归
实现的线段树不是一棵满二叉树,其叶子节点位置不确定,导致每次操作都需要从根节点开始
自上而下递归
依次寻找叶子节点,回溯时进行维护,递归过程常数就比较大了。

所以 zkw 线段树就直接建出一棵
满二叉树
,原序列信息都维护在最底层。
严格规定
父子节点关系,同层节点的子树大小相等。
这样每个叶子节点都可以直接找到并修改,由于二叉树父子节点的
二进制关系
,就可以递推直接找到对应节点的父亲节点
自下而上
地维护节点关系。

二、初始化

1、建树

对长度为
\(n\)
的序列建一棵 zkw 线段树,其
至少有
\(n+2\)
个叶子节点

。其中有 2 个用来帮助维护区间信息的
虚点
,有
\(n\)
个用来存原序列信息的节点。
如图(
【模板】线段树 1
的样例为例,下同):

建树时先求出虚点
\(P\)
位置,然后直接向其他叶子节点读入信息即可:

//先求虚点 P
  P = 1;
  while(P<=n+1) P<<=1;//节点深度每增加一层,当前层节点数量扩大一倍
  for(int i=1;i<=n;++i) read(tr[P+i]);

2、维护

根据上文所说,由于严格确定了父子关系,所以直接自下而上遍历所有节点维护父子关系做初始化:

//push_up
  for(int i=P-1;i;--i){//i=(P+n)>>1
  	tr[i] = tr[i<<1|1]+tr[i<<1]; 
  	tr[i] = min(tr[i<<1|1],tr[i<<1]);
  	tr[i] = max(tr[i<<1|1],tr[i<<1]);
  	//...
  }

三、概念介绍

1、永久化懒标记

与递归线段树的
\(lazy\)
\(tag\)
不同,其每次向下递归时都需要先下放标记并清空以维护信息。

但在维护
存在结合律
的运算时,zkw 线段树的
\(lazy\)
\(tag\)
只会
累加
,而不会在修改和查询前下放清空。

2、“哨兵”节点

在区间操作时,引入两个哨兵节点,分别在区间的左右两侧,
把闭区间变成开区间进行处理

两个哨兵节点到根有两条链,与两条链相邻且在中间部分的节点,就是这次操作需要用到其信息的所有节点。

如图(沿用了第一个图,节点中的数的为区间和):
例如:
【模板】线段树 1
第一个操作
\(query(2,4)\)

同时,这也解释了为什么建树时叶子节点上会有
\(2\)
个虚点(当然是为了不越界)。

(1)为什么可以确定需要用到哪些节点

操作时,只需要操作区间中单元素区间的公共祖先即可。
我们选取的两条链,中间部分正好包含了与操作区间有关的所有节点,与两条链相邻的节点显然的所有区间的公共祖先。

操作时只需要操作这些节点上的信息就可以了。

(2)在递推过程中怎么判断要用到哪些节点

观察我们刚才手推出来的图片,注意到:
对于左哨兵
\(S\)
,当它是左儿子时,其兄弟节点是需要用到的;
对于右哨兵
\(T\)
,当它是右儿子时,其兄弟节点是需要用到的。

每次操作完后
\(S\)

\(T\)
向上走到自己的父亲节点,然后维护父子关系,再进行新一轮操作。

\(S\)

\(T\)
互为兄弟节点时(走到了两条链的交点),就停止操作,然后向上维护信息到根节点。

四、基于结合律的查询与修改

1、区间修改

以区间加为例

类似递归线段树操作,更新时需要知道当前节点的
子树大小

每次更新时,当前节点的值增加的是其标记乘子树大小;其标记的值正常累加即可。
永久化懒标记
减少了标记下放
带来的常数。

//
  inline void update_add(int l,int r,ll k){
  	l=P+l-1; r=P+r+1;//哨兵位置 
  	int siz = 1;//记录当前子树大小 
  	
  	while(l^1^r){//当l与r互为兄弟时,只有最后一位不同 
  		if(~l&1) tr[l^1]+=siz*k,sum[l^1]+=k;
  		if(r&1) tr[r^1]+=siz*k,sum[r^1]+=k;
  		//类似递归线段树 tr[p] += tag[p]*(r-l+1) 
  		l>>=1; r>>=1; siz<<=1;
  		//每次向上走时子树大小都会增加一倍 
  		tr[l] = tr[l<<1]+tr[l<<1|1]+sum[l]*siz;//维护父子关系 
  		tr[r] = tr[r<<1]+tr[r<<1|1]+sum[r]*siz;
  	}
  	for(l>>=1,siz<<=1;l;l>>=1,siz<<=1) tr[l] = tr[l<<1]+tr[l<<1|1]+sum[l]*siz;//更新上传至根节点
  } 

2、区间查询

由于我们需要查询的区间被左右哨兵分为了两个部分,但两部分子树大小不一定相等。
所以要分别维护左右哨兵到达的节点所包含查询区间的子树的大小。

//
  inline ll query_sum(int l,int r){
  	l=l+P-1; r=r+P+1;
  	ll res = 0;
  	int sizl = 0,sizr = 0,siz = 1;//分别维护左右两侧子树大小

  	while(l^1^r){
  		if(~l&1) res+=tr[l^1],sizl+=siz;//更新答案及子树大小 
  		if(r&1) res+=tr[r^1],sizr+=siz;
  		l>>=1; r>>=1; siz<<=1;
		
  		res += sum[l]*sizl+sum[r]*sizr;
  		//即使当前节点所存的区间和不需要用,但因为其是两个哨兵的父亲节点,且 tag 不会下传,
  		//所以其 tag 会对答案有贡献,所以需要加上 tag 的贡献
  	}
  	for(l>>=1,sizl+=sizr;l;l>>=1) res+=sum[l]*sizl;//累加至根节点 
	return res;
  }

如果维护
区间最大值
也同理:

//
  inline void update_add(int l,int r,ll k){
  	l=P+l-1; r=P+r+1;
  	while(l^1^r){
  		if(~l&1) sum[l^1]+=k,maxn[l^1]+=d;
  		if(r&1) sum[r^1]+=k,maxn[r^1]+=d;
  		l>>=1; r>>=1;
        maxn[l] = max(maxn[l<<1],maxn[l<<1|1])+sum[l];
        maxn[r] = max(maxn[r<<1],maxn[r<<1|1])+sum[r];
  	}
  	for(l>>=1;l;l>>=1) maxn[l]=max(maxn[l<<1],maxn[l<<1|1])+sum[l];//更新上传至根节点
  } 
  inline ll query_max(int l,int r){
  	l=l+P-1; r=r+P+1;
  	ll resl = 0,resr = 0;//分别记录左右两侧最大值 
  	while(l^1^r){
  		if(~l&1) resl=max(resl,maxn[l^1]);
  		if(r&1) resr=max(resr,maxn[r^1]);
  		l>>=1; r>>=1;
  		resl += sum[l];//标记永久化,所以要累加标记值
  		resr += sum[r];
  	}
  	for(resl=max(resl,resr),l>>=1;l;l>>=1) res1+=sum[l];//累加至根节点
	return resl;
  }

某些时候,只会用到单点修改区间查询和区间修改单点查询,此时 zkw 线段树
码量
优势很大。

3、单点修改下的区间查询

修改:直接改叶子结点的值然后向上维护。
查询:哨兵向上走时直接累加节点值。

//
  inline update(int x,ll k){
  	x += P; tr[x] = k;
  	for(x>>=1; x ;x>>=1) tr[x] = tr[x<<1]+tr[x<<1|1]; 
  }
  inline ll query(int l,int r){
  	l += P-1; r += P+1;
  	ll res = 0;
  	while(l^1^r){
  		if(~l&1) res+=tr[l^1];
  		if(r&1) res+=tr[r^1];
  		l>>=1; r>>=1;
  	}
  	return res;
  }

4、区间修改下的单点查询

将赋初值的过程看作是在叶子节点上打标记,区间修改也是在节点上打标记。
由于 zkw 线段树的标记是永久化的,所以此时将标记的值看作节点的真实值。
但这种做法显然
只对于单点查询
有效,在查询时需要加上节点到根沿途的所有标记。

//
  inline void update_add(int l,int r,ll k){
  	l += P-1; r += P+1;
  	while(l^1^r){
  		if(~l&1) tr[l^1]+=k;
  		if(r&1) tr[r^1]+=k;
  		l>>=1; r>>=1;
  	}
  }
  inline ll query(int x){
  	ll res = 0;
  	for(x+=P; x ;x>>=1) res+=tr[x];
  	return res;
  }

5、标记永久化的局限性

以上修改与查询方式,全部基于
运算具有结合律
,所以标记可以永久化,以此减少标记下放增加的常数。

但如果运算
存在优先级
,标记就不能再永久化了。考虑在更新时将先标记下放(类似递归线段树)然后再从叶子节点向上更新。

但是如果像递归线段树一样从根开始逐次寻找子节点下放一遍的话,那优化等于没有。
所以要考虑基于 zkw 线段树的特点进行下放操作,而且要尽可能的简洁方便。


So easy,搜一紫衣。

五、有运算优先级的修改与查询

1、标记去永久化

在进行区间修改时,我们会用到的节点只存在于哨兵节点到根的链上。
所以只考虑将这两条链上的节点标记进行下放即可。

(1)如何得到有哪些需要下放标记的节点

考虑最暴力的方法:
每次从哨兵节点向上递归直至根节点,回溯时下放标记。
显然这样的方式常数优化约等于零。

考虑优化肯定是基于 zkw 线段树的特点。
还是由于 zkw 线段树是满二叉树结构,所以可以通过节点
编号移位
的方式找到其所有父子节点的编号。
显然哨兵到根节点的链,是哨兵的所有父亲组成的,所以只要让哨兵编号移位就可以了。

(2)如何自上而下的传递标记

再记录一下叶子节点的深度。
思考满二叉树的性质:当节点编号右移位节点深度时就指向根节点编号。
所以节点右移的位数,从节点深度依次递减,就可以自上而下得到其所有父亲节点的编号。

2、区间修改

先下放标记,然后正常做标记更新。
传递标记时可能要考虑子树大小,直接通过深度计算就可以了。
以区间加及乘为例

//建树时记录叶子节点深度 
  P = 1;DEP = 0;
  while(P<=n+1) P<<=1,++DEP;
  //...
  //...
  //...
  inline void update_add(int l,int r,ll k){
  	l=P+l-1; r=P+r+1;
  	//先下放标记 
  	for(int i=DEP;i;--i) push_down(l>>i,1<<i),push_down(r>>i,1<<i); 
  	//push_dwon( 链上节点 , 当前子树大小 );

  	int siz = 1;
  	while(l^1^r){
  		if(~l&1) tr[l^1]+=siz*k,sum[l^1]+=k;//正常更新
  		if(r&1) tr[r^1]+=siz*k,sum[r^1]+=k;
  		l>>=1; r>>=1; siz<<=1;

  		//维护父子关系 
  		tr[l] = tr[l<<1]+tr[l<<1|1];//由于标记已下放,所以维护时不再考虑累加标记 
  		tr[r] = tr[r<<1]+tr[r<<1|1];
  	}
  	for(l>>=1; l ;l>>=1) tr[l] = tr[l<<1]+tr[l<<1|1];//上传至根节点 
  }
  //
  inline void update_mul(int l,int r,ll k){
  	l += P-1; r += P+1;
  	for(int i=DEP;i;--i) push_down(l>>i,1<<i),push_down(r>>i,1<<i);
  	while(l^1^r){
  		if(~l&1) tr[l^1]*=k,mul[l^1]*=k,sum[l^1]*=k;//标记覆盖
  		if(r&1) tr[r^1]*=k,mul[r^1]*=k,sum[r^1]*=k;
  		l>>=1; r>>=1;
  		tr[l] = tr[l<<1]+tr[l<<1|1];
  		tr[r] = tr[r<<1]+tr[r<<1|1];
  	}
  	for(l>>=1; l ;l>>=1) tr[l] = tr[l<<1]+tr[l<<1|1];
  }

3、区间查询

先下放标记。
由于标记已经去永久化,所以直接累加节点值即可。

//
  inline ll query(int l,int r){
  	l = l+P-1;r = r+P+1;
  	//先下放标记 
  	for(int i=DEP;i;--i) push_down(l>>i,1<<i),push_down(r>>i,1<<i); 
  	ll res = 0;
  	while(l^1^r){
  		if(~l&1) res+=tr[l^1]; 
  		if(r&1) res+=tr[r^1]; 
  		//由于标记已下放,所以无需再累加标记的贡献 
  		l>>=1; r>>=1;
  	}
  	return res;
  }

六、优化效果

1、时间复杂度

开始的时候也提到了:递归线段树常数大的瓶颈在于其需要对树进行
递归遍历
以找到目标节点,然后回溯进行信息维护。
zkw 线段树
仅仅只是
优化了递归寻找目标节点这样的
遍历过程
的常数。

如果是追求常数或者注重优化遍历,那 zkw 线段树的优化就比较明显了;如果要维护较为复杂的信息,那么显然这点常数并不是很够看,此时就需要在其他地方上做改进了。

2、空间复杂度

zkw 线段树需要开
三倍空间
,普通线段树如果不使用动态开点需要开四倍空间。

相较于普通线段树,zkw 线段树代码好理解也比较简洁,不会出现
忘建树

忘终止递归
的问题,而且满二叉树结构的确定性让
手造数据
也比较方便。
对于一些维护信息复杂的题目,zkw 线段树的优势在于手推时思路更加清晰。


如果性格比较内向,不敢用递归线段树进行递归维护信息。
想用 zkw 递推实现更多更强的操作怎么办!

zkw 线段树实现其他线段树结构

一、引入

1、关于 zkw 线段树

本人认为:
狭义
的 zkw 线段树是指建立出满二叉树结构、节点间的父子关系严格规定、一切信息从叶子节点开始向上维护、通过循环递推实现维护过程。
另外:张昆玮大佬的 PPT 中提到,为了减小递归带来的常数,出现了
汇编版的非递归
线段树。
所以本人的理解是:
广义
的 zkw 线段树指通过
循环递推而非递归
实现的线段树。

2、关于优化效果

基于多数线段树结构的特点,导致大部分时候必须上下循环两次维护信息,所以此时 zkw 线段树更多优化的是代码的
简洁程度

理解难度
(当然了,对常数也有一些优化)。

二、可持久化线段树

1、介绍

可持久化线段树与普通线段树的区别在于,其支持修改和访问
任意版本

举个例子:给定一个序列
\(a_N\)
,对它进行一百万次操作,然后突然问你第十次操作后的序列信息。

朴素的想法是对于每次操作都建一棵线段树,空间复杂度是
\(O(3mn)\)
的。
可以发现:
修改后,大部分节点并没有受到影响,所以考虑只对受影响的节点新建对应节点。其余没受影响的节点直接
与原树共用节点
,就等同于新建了一棵修改后的线段树。

2、单点修改单点查询

每次单点修改后,只有叶子节点到根节点的那一条链上的点会受到影响。
所以我们只需要对受影响的这条链新建一条对应的链,其余没受影响的节点直接和待修改版本共用即可。

对于本次要修改的位置,在以原始序列
\(a_N\)
建立的初始线段树中,其对应的叶子节点到根的链上的节点分别为
\(tl\)
,当前新节点为
\(now\)
,下一个新节点为
\(new\)

如果
\(tl\)
为左儿子,那么
\(now\)
的左儿子为
\(new\)
,右儿子为
\(tl\)
对应在待修改树上节点的兄弟节点;
如果
\(tl\)
为右儿子,那么
\(now\)
的右儿子为
\(new\)
,左儿子为
\(tl\)
对应在待修改树上节点的兄弟节点。

其实就是新建节点的位置与初始树上的节点位置
分别对应

看图(节点内数字为节点编号):在
原序列
上修改一个位置:


第一次修改后
的序列上,再修改一次:

继续在
第一次修改后
的序列上做修改:

我们发现新建的链在新树上的位置,与初始树上的链在初始树上的位置,是
相同
的。
所以我们新建节点时,新节点的位置
跟随
对应的初始树上的节点的位置进行
移动

由于版本间需要
以根节点做区分
(因为使用叶子节点会非常麻烦),所以
修改和查询
操作只能从根节点开始
自上而下
进行,防止不同版本的存储出现问题。
所以我们需要多一个记录:当前节点的左右儿子。

对于
\(tl\)
到根的链如何快速求得,我们前面讲“哨兵”的时候已经讲过实现,接下来就是模拟整个新建节点过程即可。
同时,新建节点的节点编号依次递增,操作后进行
自下而上维护
信息也很方便:

//建初始线段树
  while(P<=n+1) P<<=1,++DEP; NOW = (1<<(DEP+1))-1;//最后一个节点的编号
  for(int i=1;i<=n;++i) read(tr[P+i]); rt[0] = 1;//初始树根为1
  for(int i=P-1;i;--i) son[i][0]=i<<1,son[i][1]=i<<1|1;//记录子节点 0为左儿子;1为右儿子
//...
//...
  inline void update(int i,int vi,int val,int l){
  	int tl = l+P;//在初始树上对应的叶子节点编号
  	int v = rt[vi];//待修改线段树的根
  	rt[i] = l = ++NOW;//新线段树的根
  	for(int dep=DEP-1; dep>=0 ;--dep,l = NOW){
        //模拟节点更新过程
		if((tl>>dep)&1) son[l][0] = son[v][0],son[l][1] = ++NOW,v = son[v][1];
  		else son[l][0] = ++NOW,son[l][1] = son[v][1],v = son[v][0];
  	}
  	tr[l] = val;//更新最后的叶子节点

    //自下而上维护信息(如果有需要的话)
    //for(int dep=1;dep<=DEP;++dep) tr[l-dep]=tr[son[l-dep][0]]+tr[son[l-dep][1]];
  }

版本查询与修改相同,从根开始模拟子树选取:

//
  inline int query(int vi,int l){
  	int tl = l+P;//在初始树上对应的叶子节点编号
  	l = rt[vi];//当前版本的根
  	for(int dep=DEP-1; dep>=0 ;--dep) l=son[l][(tl>>dep)&1];
  	return tr[l];//返回叶子节点值
  }

3、区间修改区间查询

目前我了解到的信息是:
只能做区间加

可持久化线段树中有大量的公用节点,所以标记不能下放且修改要能够用永久化标记维护,否则会
对其他版本产生影响

那么考虑如何做区间加。

  1. 标记永久化:省去标记下放以减小常数同时防止对其他版本产生影响;
  2. 预处理时记录子树大小,查讯时累加标记值。

不同的是:

  1. 需要对区间新建节点;
  2. 修改时对照初始树上节点的轨迹进行移动;
  3. 修改需要自上而下进行,然后再自下而上做一遍维护(类似递归回溯)。

三、权值线段树

1、介绍

普通线段树维护的是信息,权值线段树维护的是
信息的个数

权值线段树相当于在普通线段树上开了一个

,用于处理信息个数,以
单点修改和区间查询
实现
动态全局第
\(k\)

2、查询全局排名

在权值线段树中,节点存信息出现的次数:

//
  inline void update(int l,int k){
  	l += P; tr[l] += k;//k为信息出现次数 
  	for(l>>=1; l ;l>>=1) tr[l] = tr[l<<1]+tr[l<<1|1];
  }

当前数字的相对大小位置向前的前缀和,即为当前数字在
全局
中的排名:

//
  inline int get_rank(int r){//查询第r个数的全局排名 
  	int l = 1+P-1;//做区间[1,r]的前缀和 
  	r += P+1;
  	int res = 0;
  	while(l^1^r){
  		if(~l&1) res+=tr[l^1];
  		if(r&1) res+=tr[r^1];
  		l>>=1; r>>=1; 
  	}
  	return res;
  }

3、动态全局第
\(k\)

基于线段树的结构,第
\(k\)
大的
二分
实现其实就在线段树上
查找左右子树
的过程。
查询第
\(k\)
大时,借助线段树的结构,以左右子树选取来模拟
二分过程
即可:

//
  inline int K_th(int k){
  	int l = 1,dep = 0;
  	while(dep<DEP){
  		if(tr[l<<1]>=k) l=l<<1;//模拟二分 
  		else k-=tr[l<<1],l=l<<1|1;
  		++dep;
  	}
  	return l-P;//减去虚点编号,得到原数组中的编号 
  }

4、前驱与后继

有时还需要查询
\(k\)
的前驱和后继。
\(k\)
的前驱为:最大的小于
\(k\)
的数;
\(k\)
的后继为:最小的大于
\(k\)
的数。

\(k\)
的前驱可以看作:查与
\(k-1\)
的排名相同数;

\(k\)
的后继可以看作:查比
\(k\)
的排名靠后一位的数。
结合一下
\(get\_rank\)

\(K\_th\)
即可:

//
  inline int pre(int k){
  	int rk = get_rank(k-1);
  	return K_th(rk);
  } 
  inline int nex(int k){
  	int rk = get_rank(k)+1; 
  	return K-th(rk);
  }

四、可持久化权值线段树(主席树)

有人说 zkw 做不了主席树,我急了。

1、介绍

顾名思义,就是
可持久化线段树和权值线段树
结合。
大部分情况下只需要支持区间查询,常用于解决
静态区间第
\(k\)

,因为单独的主席树不太好进行修改操作。
当然,
动态区间第
\(k\)

的实现——树套树,可以直接跳到
目录五
去看。

2、静态区间第
\(k\)

主席树对序列的每个位置都维护一棵线段树,其节点值为对应序列上值的范围。
在第
\(m\)
棵线段树上,区间
\([L,R]\)
维护的是:序列上
\(a_i\sim a_m\)
中,有多少数字在
\([L,R]\)
范围内。

我们对序列中
每一个数的权值
都开一棵线段树,一共开
\(N\)
棵树,存不下,所以使用可持久化线段树。
由于权值线段树存下了数的权值,每个节点上存的是前缀和,信息具有可加性。所以查
\([L,R]\)
等于查
\([1,R]-[1,L-1]\)

可持久化线段树的新建书和权值线段树的查询结合一下就好了:

//可持久化线段树的建新树
  inline void update(int i,int vi,int l,int k){
  	int tl = l+P;
  	int v = rt[vi];
  	rt[i] = l = ++NOW;
  	for(int dep=DEP-1; dep>=0 ;--dep,l=NOW){
		if((tl>>dep)&1) son[l][0] = son[v][0],son[l][1] = ++NOW,v = son[v][1];
  		else son[l][1] = son[v][1],son[l][0] = ++NOW,v = son[v][0];
  	}
  	tr[l] = tr[v]+k;//需要维护前缀和
    //向上维护信息
  	for(int dep=1;dep<=DEP;++dep) tr[l-dep]=tr[son[l-dep][0]]+tr[son[l-dep][1]];
  }
//权值线段树的查询
  inline int query(int l,int r,int k){
    //查 [l,r] 相当于查 [1,r]-[1,l-1]
  	l = rt[l-1];r = rt[r];
    int tl = 1;//答案
  	for(int dep=0;dep<DEP;++dep){
  		int num = tr[son[r][0]]-tr[son[l][0]];//左子树大小
		if(num>=k){//不比左子树大,说明在左子树中
			l = son[l][0];
			r = son[r][0];
            tl = tl<<1;
		}
		else{//比左子树大,说明在右子树中
			k -= num;
			l = son[l][1];
			r = son[r][1];
            tl = tl<<1|1;
		}
	}
	return tl-P;//当前权值为:对应在初始树上位置减虚点编号
  }

五、树状数组套权值线段树

1、介绍

上文说,单独的主席树不方便维护
动态区间第
\(k\)

,主要是因为主席树修改时,对应的其他版本关系被破坏了。
实现动态第
\(k\)
大的朴素想法当然还是对序列的每个位置都开一棵权值线段树,那么难点就在于我们到底要对哪些树做修改。

由于
权值线段树具有可加性
的性质,所以我们可以拿一个树状数组维护线段树的前缀和,用于求出要修改哪些树。这个过程我们可以用
\(lowbit\)
来实现。

把要修改的树编号存下来,然后做线段树相加的操作,此时操作就从多棵线段树变成了在一棵线段树上操作。

2、初始化

对序列的每个点建一棵 zkw 线段树的话,空间会变成
\(Q(3n^2)\)
的,所以我们需要
动态开点
,空间复杂度变成
\(O(n\log^2{n})\)

(存个节点而已,我们 zkw 也要动态开点,父子关系对应初始树就可以了)。

为了保证修改和查询时新树节点与序列的对应关系,以及严格确定的树形结构,所以我们先建一棵初始树(
不用真的建出来,因为我们只会用到编号
),操作时新树上的节点
跟随
对应在初始树上的节点进行移动。

//
  for(int i=1;i<=n;++i){
  	read(a[i]);
  	b[++idx] = a[i];
  }
  sort(b+1,b+1+idx);
  idx = unique(b+1,b+1+idx)-(b+1);//离散化
  while(P<=idx+1) P<<=1,++DEP;//求初始树上节点编号备用
  for(int i=1;i<=n;++i){
  	a[i] = lower_bound(b+1,b+1+idx,a[i])-b;
  	add(i,1);//对每个位置建线段树
  }
//...
  inline void update(int i,int l,int k){
  	int tl = l+P,stop = 0;
  	rt[i] ? 0 : rt[i]=++NOW;//动态开点
  	l = rt[i]; st[++stop] = l;tr[l] += k;
  	for(int dep=DEP-1;dep>=0;--dep,st[++stop]=l,tr[l]+=k){
  		if((tl>>dep)&1) son[l][1]?0:son[l][1]=++NOW,l=son[l][1];
  		else son[l][0]?0:son[l][0]=++NOW,l=son[l][0];
	}
    //为了方便也可以把链上的节点全存下来再做维护
  	//while(--stop) tr[st[stop]] = tr[son[st[stop]][0]]+tr[son[st[stop]][1]];
  }
  inline void add(int x,int k){//lowbit求需要用到的线段树
  	for(int i=x;i<=n;i+=(i&-i)) update(i,a[x],k);
  }

3、单点修改

先把原来数的权值减一,再让新的数权值加一。

//
  inline void change(int pos,int k){
  	add(pos,-1);
  	a[pos] = k;
  	add(pos,1);
  }

4、查询区间排名

由于权值线段树维护的是前缀和,所以把区间
\([L,R]\)
的查询看作查询
\([1,R]-[1,L-1]\)

先用树状数组求出需要用到的线段树,然后做线段树相加,求前缀和即可。

//
  inline int query_rank(int l){
  	l += P;
  	int res = 0;
  	for(int dep=DEP-1;dep>=0;--dep){
  		if((l>>dep)&1){//做线段树相加求前缀和
  			for(int i=1;i<=tmp0;++i) res-=tr[son[tmp[i][0]][0]],tmp[i][0]=son[tmp[i][0]][1];
  			for(int i=1;i<=tmp1;++i) res+=tr[son[tmp[i][1]][0]],tmp[i][1]=son[tmp[i][1]][1];
  		}
  		else{
  			for(int i=1;i<=tmp0;++i) tmp[i][0]=son[tmp[i][0]][0];
  			for(int i=1;i<=tmp1;++i) tmp[i][1]=son[tmp[i][1]][0];
  		}
  	}
  	return res;
  }
  inline int get_rank(int l,int r,int k){
  	tmp0 = tmp1 = 0;
  	for(int i=l-1; i ;i-=(i&-i)) tmp[++tmp0][0] = rt[i];
  	for(int i=r; i ;i-=(i&-i)) tmp[++tmp1][1] = rt[i];
  	return query_rank(k)+1;
    //query_rank求的是小于等于k的数的个数,加一就是k的排名
  }

5、动态区间第
\(k\)

和查询排名道理一样:由于权值线段树维护的是前缀和,所以把区间
\([L,R]\)
的查询看作查询
\([1,R]-[1,L-1]\)

还是先用树状数组求出需要用到的线段树,查询时做线段树相加。然后模拟线段树上二分就可以了。

//
  inline int query_num(int k){
  	int l = 1;
  	for(int dep=0,res=0;dep<DEP;++dep,res=0){
  		for(int i=1;i<=tmp0;++i) res-=tr[son[tmp[i][0]][0]];
  		for(int i=1;i<=tmp1;++i) res+=tr[son[tmp[i][1]][0]];//每棵树的节点值都满足可加
  		if(k>res){
  			k -= res;//做树上二分
  			for(int i=1;i<=tmp0;++i) tmp[i][0]=son[tmp[i][0]][1];
  			for(int i=1;i<=tmp1;++i) tmp[i][1]=son[tmp[i][1]][1];
  			l = l<<1|1;
  		} 
  		else{
  			for(int i=1;i<=tmp0;++i) tmp[i][0]=son[tmp[i][0]][0];
  			for(int i=1;i<=tmp1;++i) tmp[i][1]=son[tmp[i][1]][0];
  			l = l<<1;
  		}
  	}
  	return l-P;//叶子节点对应编号
  }
  inline int get_num(int l,int r,int k){
  	tmp0 = tmp1 = 0;//先用lowbit求需要查询的线段树
  	for(int i=l-1; i ;i-=(i&-i)) tmp[++tmp0][0] = rt[i];
  	for(int i=r; i ;i-=(i&-i)) tmp[++tmp1][1] = rt[i];
  	return query_num(k);
  }

线段树套线段树
与其原理相同。下层线段树维护序列信息,再用一棵上层线段树来维护下层线段树的前缀和。
你可以看这张图,我暂时先不多赘述了:
(真的码不动字了)

六、兔队线段树

(本人不是特别了解,所以暂时仅作信息具有
可加减性
的解释)
有人说 zkw 做不了兔队线段树,我急了。

1、介绍

兔队线段树是指一类:在信息修改
同时
,以
\(O(\log{n})\)
复杂度做维护的线段树。支持单点修改区间查询,通常用来维护
前缀最大值
的问题。
(粉兔在
这篇文章
中率先对其进行了说明)

2、处理与维护

其处理与维护信息的大致方式可以看作:

  1. 首先修改信息,然后从下到上做维护;
  2. 向上维护时每到达一个节点,都再次从下到上维护信息;
  3. 第二次从下到上维护时,左子树对答案贡献不变,只考虑右子树对答案的贡献。

由于第一次向上维护时,需要从当前节点开始对其所有子树进行第二次维护,所以递归线段树常用的方法是
二次递归
处理右子树信息。

3、具体实现

考虑如何用 zkw 线段树
递推
处理右子树信息。
首先,对单点进行修改后,
从下到上
进行处理和维护,同时记录节点深度,防止第二次维护时发生越界:

//单点修改后,每次上传更新到根节点
  inline void update(int l,ll k){
  	l += P;int dep = DEP;
  	mx[l] = k;mn[l] = k;//...
  	for(l>>=1,--dep; l ;l>>=1,--dep) push_up(l,dep);
  }

然后,再次模拟标记上传过程:

//
  inline void push_up(int l,int dep){
  	mx[l] = max(mx[l<<1],mx[l<<1|1]);
  	//...
  	ans[l] = ans[l<<1]+calc(l<<1|1,dep+1,mx[l<<1]); 
  }
  inline int calc(int l,int dep,ll mx){
  	int res = 0,tl = l;
  	while(dep<DEP){//模拟左右子树选取过程
		if(mx[l]<=k) break;//剪枝之类的
		if(mx[l<<1]<=k) l = l<<1|1;
		else{
			res += len[l]-len[l<<1];//信息有可减性,考虑左区间的覆盖 
			l <<= 1;
		}
		++dep; 
	}
	if(dep==DEP) res += (mx[l]>k);//叶子节点特判
  }

七、Kinetic Tournamen Tree

(有读者评论问能不能实现 KTT,我们讨论研究后发现是可以的。)

1、介绍

KTT 最初在
2020 年集训队论文
中由
EI
队长提出。
KTT 用来维护
动态区间最大值
问题,其基本思想为将需要维护的信息看作
一次函数
,所有修改都基于函数进行。同时设置
阈值
,表示维护的答案取值何时发生变化,当修改或查询的信息达到阈值时,暴力重构子树维护答案。

笔者觉得学习 KTT 最好还是从一些具体问题入手。所以我们下文的内容,
全部围绕
论文中提到的经典问题
P5693 EI 的第六分块
进行展开。

2、信息处理

最大子段和要记录四个信息用线段树维护,信息合并时分类讨论:

  • \(lmax = \max(lmax_{ls},sum_{ls}+lmax_{rs})\)
  • \(rmax = \max(rmax_{rs},sum_{rs}+rmax_{ls})\)
  • \(mx = \max(mx_{ls},mx_{rs},rmax_{ls}+lmax_{rs})\)

进行
动态
维护就要用 KTT 了,这是我们的重点内容。

现在每个信息记录的都不是一个具体值,而是一条
一次函数

\(f(x)=kx+b\)

其中
\(k\)
为最大子段的长度,
\(x\)
为变化量,
\(f(0)=b\)
为当前维护的具体值。
同时,对于两条函数,记录一个
阈值
\(dx\)

,表示当前区间最大值是否在两个函数间进行
交替

3、关于交替阈值

前置知识: 人教版八年级下册 19.2.3一次函数与方程、不等式
在对两条函数进行合并取
最大值
时,需要知道具体应该
何时
选取哪条函数。我们知道应该看函数的交点相对于区间的位置,来对取值情况分类讨论。
交替阈值就干了这样一件事情,维护时记录下何时应该对函数选取进行交替,并只在需要交替时交替,以此优化时间复杂度。

具体地,当
区间加
\(q\)

时,函数向上进行了移动,函数的交点相对于区间进行了左右移动。此时我们令
阈值
\(dx\)
减小

,当
\(dx<0\)
时表示此时选取的函数要进行交替了。
具体减少多少呢,由于函数都满足
\(k\ge 1\)
,所以至少要令
\(dx-=q\)
(当然最好是这个数,减多了重构次数就太多了)。
由于同一个区间可能有两个不同的函数进行维护,所以在合并区间时,阈值不仅要对左右区间取最小值,还需要包含当前两条函数的交点。

4、区间及函数合并

笔者个人建议写成
重载运算符
形式。
针对函数的操作,有求交点、函数合并、函数移动:

//struct Func
	inline Func operator + (const Func&G) const{//函数合并
		return Func(k+G.k,b+G.b);
	}
	inline ll operator & (const Func&G) const{//求交点
		return (G.b-b)/(k-G.k);
	}
	inline void operator += (const ll&G){//函数向上移动
		b += k*G;
	}

区间合并时,我们在函数操作的基础上分类讨论即可,注意同时维护阈值信息:

//struct Tree
	inline bool operator < (const Func&G) const{
        //钦定两条函数的相对位置,方便判断有没有交点
		return k==G.k && b<G.b || k<G.k;
	}
    inline void Merge_lx(Func x,Func y,Tree &tmp) const{//求lmax
		if(x<y) swap(x,y);
		if(x.b>=y.b) tmp.lx = x;//钦定过了函数位置,此时两条函数没有交点
		else tmp.lx = y,tmp.dx = Min(tmp.dx,x&y);
	}
    //...
	inline Tree operator + (const Tree&G) const{//区间合并
		Tree tmp;tmp.sum = sum+G.sum; tmp.dx = Min(dx,G.dx);//注意维护阈值信息 
		Merge_lx(lx,sum+G.lx,tmp);Merge_rx(G.rx,G.sum+rx,tmp);
		Merge_mx(G.mx,mx,tmp);Merge_mx(tmp.mx,rx+G.lx,tmp);
		return tmp;
	}

5、修改与重构

区间加按照正常的方式来,唯一不同的是在修改后需要对节点子树进行
重构

首先第一步肯定是下放标记:

//struct Tree
  inline void operator += (const ll&G){//区间加
		lx += G; rx += G; mx += G; sum += G; dx -= G;
  }
//
  inline void push_down(int p){//正常push_down
     if(tag[p]){
  		tag[p<<1] += tag[p]; tr[p<<1] += tag[p];
  		tag[p<<1|1] += tag[p]; tr[p<<1|1] += tag[p];
		tag[p] = 0;
     }
  }

然后再正常做修改:

//
  inline void update(int l,int r,ll k){
  	l += P-1; r += P+1;//先push_down
  	for(int dep=DEP;dep;--dep) push_down(l>>dep),push_down(r>>dep);
  	while(l^1^r){
  		if(~l&1) tag[l^1]+=k,tr[l^1]+=k,rebuild(l^1);//别忘了重构
  		if(r&1) tag[r^1]+=k,tr[r^1]+=k,rebuild(r^1);
  		l>>=1;r>>=1;
  		tr[l] = tr[l<<1]+tr[l<<1|1];
  		tr[r] = tr[r<<1]+tr[r<<1|1];
  	}
  	for(l>>=1; l ;l>>=1) tr[l] = tr[l<<1]+tr[l<<1|1];
  }

对于重构,从当前子树的根节点开始一层一层向下递推,直到没有节点需要重构为止:

//
  inline void rebuild(int p){
  	if(tr[p].dx>=0) return ;
  	int head = 1,tail = 0;
  	st[++tail] = p; push_down(p);
  	while(tail>=head){//模拟压栈
		int ttail = tail;
		for(int j=tail,pos;j>=head;--j){
  			pos = st[j]; //看子节点的子树是否需要更新
  			if(tr[pos<<1].dx<0) st[++tail]=pos<<1,push_down(pos<<1);//注意push_down
  			if(tr[pos<<1|1].dx<0) st[++tail]=pos<<1|1,push_down(pos<<1|1);
  		}
  		head = ttail+1;
  	}//重新维护
  	do{ tr[st[tail]]=tr[st[tail]<<1]+tr[st[tail]<<1|1]; } while(--tail); 
  }

6、查询

正常做查询就可以了。
需要注意一点,区间合并时要
按照左右顺序
进行。

//
  inline ll query(int l,int r){
  	l += P-1; r += P+1;//先push_down
  	for(int dep=DEP;dep;--dep) push_down(l>>dep),push_down(r>>dep);
  	Tree resl,resr;
  	while(l^1^r){
        //注意左右区间的合并顺序
  		if(~l&1) resl = resl+tr[l^1];
  		if(r&1) resr = tr[r^1]+resr;
  		l>>=1;r>>=1;
  	}
  	return (resl+resr).mx.b;
  }

KTT 的基本思路就是这样,将信息转换为函数进行处理,同时维护阈值进行重构。这使得 KTT 有优于分块的复杂度,但同时也对其使用产生了限制。


到现在,能肯定 zkw 线段树基本可以实现递归线段树能做的全部操作了。

一些模板题及代码


云剪贴板
了,会跟随文章更新。

后记

更新日志

笔者目前学识过于浅薄,文章大部分内容是笔者自己的理解,可能有地方讲得不是很清楚。等笔者再学会新东西,会先更新在
此文章
以及
我的博客
,然后找时间统一更新。

同时,笔者会经常对文章内容细节和代码块进行修改完善,如果您有什么想法可以提出来,我们一起来解决。作者真的真的是活的!!!

期待您提出宝贵的建议。

鸣谢

《统计的力量》清华大学-张昆玮 /hzwer整理
OIWiki
CSDN 偶耶XJX
Tifa's Blog【洛谷日报 #35】Tifa
洛谷日报 #4 皎月半洒花
NianFeng
//
EntropyIncreaser
//

如需转载,请注明出处。