0%

珂朵莉树(ChthollyTree)又称老司机树(ODT),起源于CF 896C Willem, Chtholly and Seniorious

一位用户Old Driver在给出了线段树的解法后,又发布了一份前所未有的解法,其中利用到的数据结构因此被称为珂朵莉树或老司机树。

其核心就是用二叉搜索树维护一组连续的不相交的区间,初始是覆盖了整个数轴的一个大区间,核心操作只有两个一是split(pos)将整个区间集在pos处断开并返回以pos为左端点的迭代器,assign(l, r, val)将区间[l,r)赋值为val。

先上代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
typedef long long ll;

template<typename T>
class ChthollyTree {
private:
struct ChthollyNode {
ll l, r;
mutable T w;
ChthollyNode(ll l, ll r=0, const T& w=T()): l(l),r(r),w(w) {}
bool operator < (const ChthollyNode& n) const {
return l < n.l;
}
};
set<ChthollyNode> s;
public:
ChthollyTree(ll n=1e9+2, const T& v=T()) { s.insert(ChthollyNode(-1, n, v)); }

auto split(ll pos) {
auto itr = s.lower_bound(pos);
if (itr != s.end() && itr->l == pos) return itr;
--itr;
auto l = itr->l;
auto r = itr->r;
auto w = itr->w;
s.erase(itr);
s.insert(ChthollyNode(l, pos, w));
return s.insert(ChthollyNode(pos, r, w)).first;
}

void update(ll l, ll r, function<void(ChthollyNode&)> f) {
auto right = split(r), left = split(l);
for(auto itr = left; itr != right; itr++) {
f(const_cast<ChthollyNode&>(*itr));
}
}

void assign(ll l, ll r, const T& w) {
auto right = split(r), left = split(l);
s.erase(left, right);
s.insert(ChthollyNode(l, r, w));
}

void visit(ll l, ll r, function<void(const ChthollyNode&)> f) {
auto left = s.lower_bound(l);
if (left == s.end() || left->l != l) --left;
auto right = s.lower_bound(r);
for(auto itr = left; itr != right; itr++) {
f(*itr);
}
}

private:
class ItemAccessor {
public:
ItemAccessor(ChthollyTree& ct, ll i): ct(ct), i(i) {}
ItemAccessor& operator = (const T& x) {ct.assign(i, i+1, x);return *this;}
ItemAccessor& operator += (const T& x) {ct.update(i, i+1, [x](auto& p){p.w += x;});return *this;}
ItemAccessor& operator -= (const T& x) {ct.update(i, i+1, [x](auto& p){p.w -= x;});return *this;}
operator T() const {T r;ct.visit(i, i+1, [&r](auto& p){r = p.w;}); return r;}
private:
ChthollyTree& ct;
ll i;
};

class RangeAccessor {
public:
RangeAccessor(ChthollyTree& ct, ll b, ll e):ct(ct), b(b), e(e) {}
void operator = (const T& x) {ct.assign(b, e, x);}
void operator += (const T& x) {ct.update(b, e, [&x](auto& p){p.w += x;});}
void operator -= (const T& x) {ct.update(b, e, [&x](auto& p){p.w -= x;});}
private:
ChthollyTree& ct;
ll b;
ll e;
};

public:
// 通过下标访问单点值的语法糖
ItemAccessor operator [] (ll i) {
return ItemAccessor(*this, i);
}

// 通过下标访问区间值的语法糖
RangeAccessor operator [] (const tuple<ll, ll>& range) {
return RangeAccessor(*this, std::get<0>(range), std::get<1>(range));
}

T sum(ll l, ll r) {
T ret = 0;
visit(l, r, [&](auto& p) {
ret += p.w * (std::min(p.r, r)-std::max(p.l, l));
});
return ret;
}

T min(ll l, ll r) {
bool first = true;
T ret = 0;
visit(l, r, [&](auto& p) {
if(first) {
first = false;
ret = p.w;
} else {
ret = std::min(ret, p.w);
}
});
return ret;
}

T max(ll l, ll r) {
bool first = true;
T ret = 0;
visit(l, r, [&](auto& p) {
if(first) {
first = false;
ret = p.w;
} else {
ret = std::max(ret, p.w);
}
});
return ret;
}
};

解释一下几个关键点:

1)ChthollyNode结构体中数据字段w为什么用mutable修饰,原本在对set元素遍历时是不能修改元素值的,因为set底层是一颗红黑树,如果修改值可能导致它不满足二叉搜索树的性质了。但这里比较特殊,这里的区间是不重叠的,仅通过左端点的值来定义结点的序,所以修改w不会影响到序。

2)update和assign的时候为什么都是先取右端迭代器再取左端迭代器auto right = split(r), left = split(l);,因为split可能导致迭代器失效,如果先取了左端点迭代器,右端点可能就在左端迭代器指向的节点上,将它断成两个结点后原迭代器也就是已经拿到的左端点迭代器就失效了。

我在其上进一步实现了常见的区间求和,区间最大最小值,区间赋值,区间自增,单点赋值,单点自增,单点取值。可以发现它和线段树完成了很多相似的操作,但是实现要比线段树简单很多。

更复杂的自定义操作可以通过update和visit传入自定义的操作方式,其中update会调用split断开传入的[l, r)端点,以便执行数据更新,而visit则不会断开区间,只保证迭代的首位区间能覆盖[l, r),可以用于查询时取数据进行计算。

关于效率,我们可以看到查询操作通过visit完成,不会增加珂朵莉树的复杂程度,而更新操作分两种,一种是区间的统一赋值,通过assign完成通常情况下会减少整体区间的数量,因为它会删除[l, r)上的区间合并成一个,而update操作可能会增加1个或2个区间取决于左右端点是否已经存在。

如果操作和数据是随机的,并且穿插着assign操作,那么珂朵莉树的实际效率会很好,如果操作只用到update和visit,那珂朵莉树会越来越复杂,不过如果查询只是单点仍然可以提供比较好的效率。我实际测试通常要比动态开点线段树的耗时少,甚至在只有update和区间查询最值时实际效率也不差,不明所以。

另外,我们还可以通过珂朵莉树完成线段树不方便进行的操作,比如查询区间内元素的方差。

问题描述

RMQ问题即区间最值查询问题(Range Minimum/Maximum Query)

给定一个数组,大量的查询区间的最值,我们知道如果数组长度是n,有n*(n-1)/2个不同区间,如果只有少量的查询我们可以遍历区间的元素,算法的时间复杂度显然是O(len) len是区间长度,对于随机的区间一次查询的复杂度也就是O(n)。

如果查询的次数增加到n2数量级,我们可以用递推预处理出所有区间的最值,预处理时间O(n2),查询时间O(1)。

如果查询的次数和n是同等量级,无论我们采取遍历还是预处理所有区间总体时间都是O(n^2),但在这种情况下其实有更高效的解决方案。

以查询最大值为例,对于长度为n的数组a,

dp(i,j)表示从ai起连续2j个元素的最大值dp(i, j) 表示从a_i起连续2^j个元素的最大值

即区间[i,i+2j1]的最大值即区间[i, i+2^j-1] 的最大值

长度为2j区间的最大值可以通过两个长度2{j-1}的区间最大值得到
所以有状态方程

dp(i,j)=max(dp(i,j1),dp(i+2j1,j1))dp(i, j) = max(dp(i, j-1), dp(i+2^{j-1},j-1))

对于i=0,1,…,n-1,j取值范围满足 i+2^j<n,即

j<log2(ni)j< log_2(n-i)

初始化

我们可以通过递推预处理dp所有的元素,时间复杂度O(nlogn)

查询

查询区间[i,j]的最大值记作query(i,j),我们还是可以利用拆分区间的思路把[i,j]拆成两个长度为2^k的区间这样就可以在预处理的dp表里直接找到了。

rmq-st

其中

k=log2(ji+1)k = \lfloor log_2(j-i+1) \rfloor

query(i,j)=max(dp(i,k),dp(j2k+1,k))query(i,j) = max(dp(i, k), dp(j-2^k+1,k))

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
template<typename T, typename Cmp=less<int>>
class RMQ {
vector<vector<T>> dp; //dp[i][j]表示从a[i]起连续2^j次方个数的最值

static int ceil_log2(int n) {
int r = 0;
while((1<<r)<n) r++;
return r;
}

static int floor_log2(int n) {
int r = 0;
while((1<<r)<=n) r++;
return r-1;
}

int max_or_min(const T& a, const T& b) const {
return Cmp()(a, b)?a:b;
}

public:
RMQ() = default;
RMQ(const T* a, int n) {init(a, n);}
RMQ(const vector<T>& a) {init(&a[0], a.size());}

void init(const T* a, int n) {
dp.resize(n, vector<T>(ceil_log2(n)+1));
for(int i=0;i<n;i++){
dp[i][0] = a[i];
}
for(int j=1,s=1;s<n;s=(1<<j++)){
for(int i=0;i+s<n;i++){
dp[i][j] = max_or_min(dp[i][j-1], dp[i+s][j-1]);
}
}
}

//返回[i, j]范围内的最值
T query(int i, int j) const {
assert(i<=j);
int k = floor_log2(j-i+1);
return max_or_min(dp[i][k], dp[j-(1<<k)+1][k]);
}
};

template<typename T>
using RMinQ = RMQ<T, less<T>>;
template<typename T>
using RMaxQ = RMQ<T, greater<T>>;

完整样例

一份CMakeLists.txt完整的样例和解释说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
cmake_minimum_required(VERSION 3.13)
project(ust_demo CXX C)

# 通过上面的PROJECT设置后自动就有了PROJECT_NAME变量
message(STATUS PROJECT_NAME=${PROJECT_NAME})
# 指向顶级CMakeLists.txt所在绝对路径
message(STATUS CMAKE_SOURCE_DIR=${CMAKE_SOURCE_DIR})
# CMAKE_CURRENT_SOURCE_DIR变量是指向当前CMakeLists.txt所在的绝对路径
message(STATUS CMAKE_CURRENT_SOURCE_DIR=${CMAKE_CURRENT_SOURCE_DIR})
# 指向最后一次调用project命令时CMakeLists.txt所在的绝对路径
message(STATUS PROJECT_SOURCE_DIR=${PROJECT_SOURCE_DIR})
# 只要单个CMakeLists.txt时上面三个变量没有不同,当有多级目录多个CMakeLists.txt通过add_subdirectory相互包含时会有区别

# 在cmake命令中加参数 -DCMAKE_BUILD_TYPE=Debug 来指定编译Debug版本,否则默认编译Release版本
IF (CMAKE_BUILD_TYPE MATCHES "Debug" OR CMAKE_BUILD_TYPE MATCHES "DEBUG")
set(CMAKE_BUILD_TYPE "Debug")
# 指定可执行文件输出路径
set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/out/Debug)
file(MAKE_DIRECTORY ${EXECUTABLE_OUTPUT_PATH})
ELSE ()
set(CMAKE_BUILD_TYPE "Release")
set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/out/Release)
file(MAKE_DIRECTORY ${EXECUTABLE_OUTPUT_PATH})
ENDIF ()
# 指定存放动态链接库的路径
# 生成可执行文件后可以把out文件夹下的文件整体复制到其他环境下运行,为了保证动态链接库能找到需要做两件事:
# 1.把.so文件复制到out/libs目录下
# 2.链接时通过-rpath选项指定运行时查找链接库的路径(这一步如果不指定也可以在运行前通过LD_LIBRARY_PATH环境变量指定)
set(LIBRARY_OUTPUT_PATH ${EXECUTABLE_OUTPUT_PATH}/libs)
file(MAKE_DIRECTORY ${LIBRARY_OUTPUT_PATH})

# 设置编译选项
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17")

set(CMAKE_C_FLAGS_DEBUG " -std=c99 -g -ggdb -O0 -Wall -Wno-unused-function -fpic -fPIC -D_DEBUG")
set(CMAKE_CXX_FLAGS_DEBUG " -std=c++17 -g -ggdb -O0 -Wall -Wno-unused-function -fpic -fPIC -D_DEBUG")

set(CMAKE_C_FLAGS_RELEASE " -std=c99 -O3 -Wall -Wno-unused-function -Wno-unused-result -Wno-unknown-pragmas -Wno-format-security -fpic -fPIC")
set(CMAKE_CXX_FLAGS_RELEASE " -std=c++17 -O3 -Wall -Wno-unused-function -Wno-unused-result -Wno-unknown-pragmas -Wno-format-security -fpic -fPIC")

# 添加头文件目录
set(INCLUDE_DIR ./include)
include_directories(${INCLUDE_DIR})

# 添加库文件目录
set(LIB_DIR lib/linux.x64)
link_directories(${LIB_DIR})

# 将src目录下所有源文件加入 SRC_LIST
aux_source_directory(./src SRC_LIST)
# # 也可以通过 FILE(GLOB_RECURSE <var> <parterns>) 把匹配指定模式的文件加入变量中
# 区别是这样同时会查找src子目录下的.cpp和.c文件
# file(GLOB_RECURSE SRC_LIST "src/*.cpp" "src/*.c")
# # 也可以通过set明确地逐个文件加入源文件列表
# set(SRC_LIST
# src/main.cpp
# )

# 指定生成可执行文件要编译的源文件
add_executable(${PROJECT_NAME}
${SRC_LIST}
)

# 指定生成.so动态库要编译的源文件
#add_library(${PROJECT_NAME} SHARED
# ${SRC_LIST}
# )
# 指定生成.a静态库要编译的源文件
#add_library(${PROJECT_NAME}
# ${SRC_LIST}
# )

# 将指定的.so文件的绝对路径加入 LIB_LIST
# file(GLOB_RECURSE LIB_LIST "${LIB_DIR}/*.so")

# 同样也可以通过set明确地逐个文件加入库文件列表
set(LIB_LIST
libHSSecuTradeApi.so
t2sdk
)

file(COPY ${LIB_DIR}/libHSSecuTradeApi.so DESTINATION ${LIBRARY_OUTPUT_PATH})
file(COPY ${LIB_DIR}/libt2sdk.so DESTINATION ${LIBRARY_OUTPUT_PATH})

# 指定指定生成PROJECT_NAME要连接的动态链接库
# 这里链接库的路径可以是绝对路径
# 也可以只写文件名将在通过link_directories指定的包含目录以及环境变量LIBRARY_PATH指定的目录下去查找
# 名为libxxx.so的文件可以只写xxx
target_link_libraries(${PROJECT_NAME}
${LIB_LIST}
pthread
-Wl,-rpath,'$ORIGIN'/libs
)
# -Wl 的意思是把后面参数传给链接器,多个参数用逗号分割,这里实际传给链接器的参数是 -rpath '$ORIGIN'/libs
# 其中$ORIGIN表示运行时可执行文件的位置,'$ORIGIN'/libs也就是可执行文件同目录的libs文件夹。
# 不能指定成./libs因为这会被误认为是链接时的相对路径,而不是运行时的。

# 指定PROJECT_NAME需要用到的头文件路径
# 只在目标是库文件时有意义,此项目是一个子项目时,其他项目链接这个项目生成的库时会自动添加此处指定的INCLUDE路径
target_include_directories(${PROJECT_NAME} PUBLIC
include
../include
)

# 指定PROJECT_NAME需要用到的库文件路径
# 只在目标是库文件时有意义,此项目是一个子项目时,其他项目链接这个项目生成的库时会自动添加此处指定的LINK路径(需要CMake 3.13)
target_link_directories(${PROJECT_NAME} PUBLIC ${LIB_DIR})

#file(MAKE_DIRECTORY ${PROJECT_SOURCE_DIR}/bin)
## 指定可执行文件输出路径
#set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin)
#file(MAKE_DIRECTORY ${PROJECT_SOURCE_DIR}/bin/libs)
## 指定库文件输出路径
#set(LIBRARY_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin/libs)

## 递归处理含子文件下的CMakeLists.txt,第二个参数是指定其中间文件路径相对当前文件的路径,如果不指定则会把中间文件生成在子文件夹下
#ADD_SUBDIRECTORY(subproj subproj)

简洁模板

简洁版,可以基于这个改写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
cmake_minimum_required(VERSION 3.10)
project(demo CXX C)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++17")

set(CMAKE_C_FLAGS_DEBUG " -std=c99 -g -ggdb -O0 -Wall -Wno-unused-function -fpic -fPIC -D_DEBUG")
set(CMAKE_CXX_FLAGS_DEBUG " -std=c++17 -g -ggdb -O0 -Wall -Wno-unused-function -fpic -fPIC -D_DEBUG")

set(CMAKE_C_FLAGS_RELEASE " -std=c99 -O3 -Wall -Wno-unused-function -fpic -fPIC")
set(CMAKE_CXX_FLAGS_RELEASE " -std=c++17 -O3 -Wall -Wno-unused-function -fpic -fPIC")

include_directories(
include
)
link_directories(
lib
)

aux_source_directory(src SRC_LIST)
add_executable(${PROJECT_NAME}
${SRC_LIST}
)

target_link_libraries(${PROJECT_NAME}
pthread
)

使用方式

cmake命令生成Makefile时会生成一些中间文件和一些缓存文件,我一般会在根CMakefile.txt的同级目录下新建build目录用来构建,通常的步骤是

1
2
3
4
mkdir build
cd build
cmake ..
make

cmake生成Makefile时是增量处理的,如果想重新生成,需要先删除CMakeCache.txt

make clean可以清除目标文件以保证再次make时是重新生成的。

在Windows上如果生成想用minGW的Makefile需要使用-G"Unix Makefiles"参数,即

1
2
cmake -G"Unix Makefiles" ..
make

记得用minGW/bin/mingw32-make.exe 复制一份make.exe,并把minGW/bin添加到PATH

构建后自动复制文件

有时候我们想将依赖的头文件(构建.so时需要依赖)或库文件复制到指定路径

可以使用如下命令

1
2
3
4
5
# 复制指定目录下的文件到指定的输出目录(包含子目录)
add_custom_command(TARGET <项目名> POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory
<源路径> <目标路径>
)
1
2
3
4
5
6
7
8
9
# 复制指定目录下的*.h到指定的输出目录(不包含子目录)
file(GLOB HEADER_FILES "${CMAKE_CURRENT_SOURCE_DIR}/include/*.h")
foreach(HEADER_FILE ${HEADER_FILES})
add_custom_command(TARGET amaquote POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${HEADER_FILE}
${OUTPUT_PATH}/include
)
endforeach()

执行Shell命令并保存输出结果到变量中

1
2
3
4
5
EXECUTE_PROCESS(COMMAND <Shell命令>
TIMEOUT 5
OUTPUT_VARIABLE <变量名>
OUTPUT_STRIP_TRAILING_WHITESPACE
)

样例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
EXECUTE_PROCESS(COMMAND sh ${CMAKE_SOURCE_DIR}/is_abi_gcc4.sh
TIMEOUT 5
OUTPUT_VARIABLE IS_ABI_GCC4
OUTPUT_STRIP_TRAILING_WHITESPACE
)
IF (IS_ABI_GCC4 STREQUAL "1")
message(STATUS "Use gcc4-compatible libs")
SET(ABI_VERSION gcc4-compatible)
SET(LIB_DIR utils/lib/linux.x64/abi-gcc4-compatible)
ELSE()
message(STATUS "Use cxx11 libs")
SET(ABI_VERSION cxx11)
SET(LIB_DIR utils/lib/linux.x64/abi-cxx11)
ENDIF ()

写在COMMAND后面的命令支持带参数但不支持管道,如果需要用到管道可以写在外部的.sh中再进行调用

1
2
# is_abi_gcc4.sh
g++ -v -x 2>&1|grep gcc4-compatible|wc -l

起因:在公司服务器上装了Windows和Ubuntu双系统,Windows是自带的,Ubuntu是后装的。系统有两块硬盘,参照网上的教程从C盘和D盘分别压缩出200M和100G空间分给Ubuntu使用,安装时手动设置把200M挂载到/boot,把100G挂载到/,安装一切顺利,重启后可以看到Ubuntu带的Grub菜单,默认进入Ubuntu,还有一个选项是Windows Boot Manager可以进入Windows,选择Ubuntu进入Ubuntu正常,再次重启在Grub菜单选择进入Windows也正常,但是再次重启时Grub菜单消失了,直接进入了Windows,无法选择进入Ubuntu。

猜测是Windows Boot Manager会改写MBR(主引导记录)

MBR是硬盘上的一个扇区,包含了引导程序(的第一阶段)、分区表等信息。

引导程序第一阶段需要去MBR中去读引导程序,GRUB第二阶段需要到/boot分区读系统内核和配置文件。

而MBR中的程序包括了加载第二阶段的程序。

当我只有Windows系统时,MBR中的程序会启动Windows Boot Manager,在我安装了Ubuntu之后,MBR中的程序被修改成启动Ubuntu新分出来的那个/boot下的引导程序,然后可以通过菜单再去启动原来的Windows Boot Manager。

如果启动Windows Boot Manager后它修改了MBR让其中的程序在下次启动时加载自己,那么就跳过了选择进入Ubuntu的可能。

因为我们装Ubuntu只是为了测试,所以采取了临时的解决方式。通过Ubuntu的安装U盘启动,到选择安装Ubuntu的Grub菜单界面按c进入命令模式。

因为这时候插着一个U盘两块硬盘,不知道对应的编号,通过

1
2
3
ls (hd0)
ls (hd1)
...

这样的试探操作,可以根据硬盘上的分区数量和分区大小确定Ubuntu /boot对应的硬盘和分区编号

1
2
(hd1, gpt3) 100G Ubuntu /
(hd2, gpt5) 200M Ubuntu /boot

手动指定加载已经安装的Ubuntu的Grub的配置文件/boot/grub/grub.cfg

1
configfile  (hd2, gpt5)/grub/grub.cfg

会弹出选择菜单,就和MBR在引导第二阶段加载了Ubuntu的/boot一样,然后选择Ubuntu进入即可。

BTW:准备尝试还没有尝试的命令

1
2
3
set root=(hd2, gpt5)
chainloader /efi/EFI/ubuntu/grubx64.efi
boot

给定二维平面内的一组点集,求出包住所有点的最小的凸多边形。

1
2
输入: [(1, 1), (2, 2), (2, 0), (2, 4), (3, 3), (4, 2)]
输出: [(4, 2), (2, 4), (1, 1), (2, 0)]

算法思路是先排序(优先按x轴),然后分别求出上下包围线,从左到右遍历的过程使用单调栈维护包围线,包围线只能向同一侧弯曲(上包围线向下弯曲,上包围线向下弯曲),用叉乘计算连续两个线段的弯曲方向(二维向量其实是拟叉乘,三维向量的叉乘结果应该是一个向量,屏幕上两向量叉乘是一个指向屏幕外或屏幕内的向量,这里只用一个带正负的值表示方向和长度,其实我们这里也只用到方向)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 二维向量
struct v2d {
double x;
double y;
};

// 二维向量拟叉积运算
double pseudoCross(const v2d& v1, const v2d& v2) {
return v1.x * v2.y - v1.y * v2.x;
}

v2d operator - (const v2d& v1, const v2d& v2) {
return v2d{v1.x-v2.x, v1.y-v2.y};
}

// 返回一个逆时针围成的凸包(如果要顺时针可以把上下边界换一换,改变while中的比较就可以)
vector<v2d> convexHull(vector<v2d>& pnts) {
sort(pnts.begin(), pnts.end(), [](const v2d& v1, const v2d& v2) {
return ((v1.x != v2.x)?(v1.x< v2.x):(v1.y < v2.y));
});
int n = pnts.size();
vector<int> v(n*2); // v 其实是两个单调栈在中间接起来
int p = n; // 从v中间向后增长的单调栈, p为栈顶的下一个位置(将要被使用的空间)
int q = n; // 从v中间向前增长的单调栈, q为栈顶的下一个位置(将要被使用的空间)
for(int i=0; i<n; i++){
while(p-n>=2 && pseudoCross(pnts[i]-pnts[v[p-1]], pnts[v[p-1]]-pnts[v[p-2]])>=0){
p--; // 弯曲方向不对,出栈
}
while(n-q>=2 && pseudoCross(pnts[i]-pnts[v[q+1]], pnts[v[q+1]]-pnts[v[q+2]])<=0){
q++; // 弯曲方向不对,出栈
}
v[p++] = i;
v[q--] = i;
}
vector<v2d> res;
for(int i=q+1;i<p-1;i++) {
res.push_back(pnts[v[i]]);
}
return res;
}


上次在讲幂模算法时提到,对于a^b%m,指数b不能令b=b%m使b变小,但是让b=b%phi(m),其中phi是欧拉函数。

欧拉函数phi(m)的值是模m的缩系的元素个数,小于m的与m互质的自然数构成模m的缩系。

所以phi(m)也是小于等于m并与m互质的自然数的个数。

但是用gcd(最大公约数)来统计与m互质的自然数是比较慢的。

欧拉函数有一个快速算法

1
2
3
4
5
6
7
ll phi(ll n) {
auto factors = factorize(n);
for(auto [p, k] : factors) {
n = n / p * (p-1);
}
return n;
}

其中factorize(n)是将n因数分解,返回值是一个vector<pair<ll/*因子p*/, int/*指数k*/>>

n=Πpikin = \Pi p_i ^ {k_i}

相当于对n的每个质因子做了一个操作,除以n的所有的质因子一次,乘以所有(质因子-1)一次,得到的结果就是n的欧拉函数值。

factorize可以用如下实现

1
2
3
4
5
6
7
8
9
10
11
12
13
vector<pair<ll, int>> factorize(ll n) {
vector<pair<ll, int>> result;
while(n!=1) {
ll p = min_divisor(n);
int c = 0;
do {
n/=p;
++c;
} while(n%p==0);
result.emplace_back(make_pair(p, c));
}
return result;
}

其中min_divisor(n)是n的最小因数(显然也一定是质因数)

min_divisor可以通过预处理的方式打质数表然后枚举试除,或者基于Pollard分解算法实现。更多细节参考线性时间复杂度的质数筛法

正确的做法是

1
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"

搜索时经常看到有人说

1
DIR=$(cd $(dirname $0);pwd)

这种写法是有问题的,它不能被用于通过source调用的脚本

比如我们需要通过一个.sh设置环境变量,这个环境变量的值需要用到.sh所在路径,那我们就需要通过source去调用脚本。

非负数序列的第 k 小子序列和

先把所有数从小到大排序。设一个二元组 (sum, i) 表示以第 i 位为结尾,和为 sum 的子序列,用一个小根堆来维护这些二元组,以(arr[0], 0)为初始二元组,每次取出堆顶,然后把 (sum+arr[i+1], i+1) 和 (sum+arr[i+1]−arr[i], i+1) 加入堆中,以此来生成所有的子序列和,而且生成步骤每次都是取出当前最小的。

所以第 k 个被取出的堆顶就是答案。

PS: 虽然上述步骤可以逐个生成所有的第k小子序列和,但若len(arr) = n,因为arr中每个数都可以出现在序列或不出现在序列,因为共有2n种组合,因为k的范围是很大的,而算法的复杂度是O(nlogn)+O(klogk),所以k的实际上是受限的,一般不能超过105

第 k 小子序列和(允许有负数)

当序列中有负数时,我们还是用前面的思路从最小和序列逐个生成,而这时最小和序列是包含所有负数的子序列,生成的过程是不断去掉一个负数或者加上一个正数情况会变得复杂,我们可以先算出所有负数的和作为最小和min_sum,其他的和都是在这个基础上减去一些正数或加上一些负数生成的,当arr[i]是负数时因为已经在最小基础和min_sum里了,在生成新和时总是减掉这个负数相当于加上负数的绝对值,而当arr[i]是正数时因为不在min_sum里,在生成新和时总是加上正数,因此我们可以把正负数统一按绝对值处理,用min_sum加上序列abs(arr)的某个子序列和,就是原序列的相应的子序列和,序列abs(arr)是arr的绝对值形成的序列,它的子序列和则可以规约到前面已经讨论过的非负数序列的第 k 小子序列和问题了。

第 k 大子序列和(允许有负数)

因为第k小子序列和的k受限,本问题不能简单规约到第2^n-1-k小子序列和解决,

我们可以先算出所有正数的和作为最大和max_sum,其他的和都是在这个基础上减去一些正数或加上一些负数生成的。

我们还是可以把正负数统一处理,我们可以把问题规约为 max_sum - kMinSum(abs(arr), k),其中abs(arr)表示对arr中每个元素取绝对值形成的数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

typedef long long ll;

struct Node {
ll sum;
int i;
};

// 求非负整数数组nums的第k小子序列和,k=0,1,2,...,2^n-1
// 算法复杂度是O(nlogn)+O(klogk), n=nums.size()
// 所以n和k的规模都是受限的,一般不能超过10^5
ll kMinSumBase(const vector<int>& nums, int k) {
vector<int> arr = nums;
sort(arr.begin(), arr.end());
auto cmp = [&](const Node& a, const Node& b) {
return a.sum > b.sum;
};
priority_queue<Node, vector<Node>, decltype(cmp)> PQ(cmp);
PQ.push(Node{arr[0], 0});
PQ.push(Node{0, -1}); // 特殊处理空子段
ll result = 0;
for(int i=0;i<k && !PQ.empty();i++) {
auto node = PQ.top(); PQ.pop();
if(node.i != -1 && node.i+1 < arr.size()) {
PQ.push(Node{node.sum+arr[node.i+1], node.i+1});
PQ.push(Node{node.sum+arr[node.i+1]-arr[node.i], node.i+1});
}
}
assert(!PQ.empty());
auto node = PQ.top(); PQ.pop();
return node.sum;
}

// 求数组nums(允许有负数)的第k小子序列和,k=0,1,2,...,2^n-1
// n和k的规模都是受限的,一般不能超过10^5
ll kMinSum(const vector<int>& nums, int k) {
ll min_sum = 0;
vector<int> arr(nums.size());
for(int i=0;i<nums.size();i++) {
if(nums[i] < 0) {
min_sum += nums[i];
}
arr[i] = abs(nums[i]);
}
return min_sum + kMinSumBase(arr, k);
}

// 求数组nums(允许有负数)的第k大子序列和,k=0,1,2,...,2^n-1
// n和k的规模都是受限的,一般不能超过10^5
ll kMaxSum(const vector<int>& nums, int k) {
ll max_sum = 0;
vector<int> arr(nums.size());
for(int i=0;i<nums.size();i++) {
if(nums[i] > 0) {
max_sum += nums[i];
}
arr[i] = abs(nums[i]);
}
return max_sum - kMinSumBase(arr, k);
}

幂模就是求 a^b%m 的问题,a、b、m都是整数,a和b可能很大,a^b会超过整数表示范围,而m在整数范围内的数。

首先我们知道 x * y % m = (x % m) * (y % m) % m(证明略,设x=p_1 m+q_1, y =p_2 m+q_2很容易证明)

这意味着我们可以在计算 a ^ b 的过程中对中间值进行模m操作从而缩小中间值,而这不会影响最终结果。

然后我们可以把指数b分解成二进制,以次算出a^1, a^2, a^4, a^8,… 当b的二进制从最低位算第k位是1的时候,我们就的结果就应该乘入a^(1<<k)。

1
2
3
4
5
6
7
8
9
10
11
12
//思想a^13=a^(1+4+8)=a^1*a^4*a^8
int modExp(int a,int b,int m) {
int t=1,y=a%m;
while(b){
if(b&1){
t=t*y%m;
}
y=y*y%m;
b=(b>>1);
}
return t;
}

这就是快速幂模算法。

我们进一步,如果a更大,本身就超出了int范围,我们用string类型存储,如何快速求出a^b%m呢?

其实很简单,我们可以用一个循环把a转换成整数

1
2
3
4
int r = 0;
for(int i=0;i<a.size();i++) {
r = r * 10 + (a[i]-'0');
}

这个结果当然会导致r超过int表示范围,但是我们在过程的中间值就进行模m操作就可以了

1
2
3
4
5
6
7
8
9
10
11
12
int str2int(const string& a, int m) {
int r = 0;
for(int i=0;i<a.size();i++) {
r = (r * 10 + (a[i]-'0')) % m;
}
return r;
}

int modExp(const string& a, int b, int m) {
return modExp(str2int(a, m), b, m);
}

在进一步,如果b也更大,本身就超出了int范围,我们用string类型存储,如何快速求出 a^b%m呢?

很自然我们会想能否把b像a一样也在转成int的同时处理成b%m呢?答案是否定的。我们可以用反证法证明:

设上述问题可以先把b处理成b%m,即 a^b%ma^(b%m)%m 相等,易推得 a^m%m=1,因为这里a,b,m都是任取的,所以我们只要找到一个反例不满足 a^m%m=1就可以推得矛盾,我们可以找2^3%3 = 2 != 3,证毕。

实际上我们可以先把b处理成 b%\phi(m) ,其中phi是欧拉函数,phi(m)等于1~m中和m互质的数的个数,我会另开文章讨论欧拉函数,在此先另辟蹊径解决此问题。

\begin{align} &~~~~~a ^ b\\ &= a^{b_0*10^{n-1}+b_1*10^{n-2}+...+b_{n-1}*10^0}, 其中n=len(b), b_i是b的左起第i位数字\\ &= a^{b_0*10^{n-1}}*a^{b_1*10^{n-2}}*...*a^{b_{n-1}*10^0}\\ &= (a^{10^{n-1}})^{b_0}*(a^{10^{n-2}})^{b_1}*...*(a^{10^0})^{b_{n-1}} \end{align}

其中

a10k=(a10k1)10a^{10^k} = (a ^ {10^{k-1}})^{10}

这样我们就可以倒序遍历b的每位,并在过程中用上式维护a(10k)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int modExp(int a,int b,int m) {
int t=1,y=a%m;
while(b){
if(b&1){
t=t*y%m;
}
y=y*y%m;
b=(b>>1);
}
return t;
}

int str2int(const string& a, int m) {
int r = 0;
for(int i=0;i<a.size();i++) {
r = (r * 10 + (a[i]-'0')) % m;
}
return r;
}

int modExp(const string& a, const string& b, int m) {
int r = 1;
int a10k = str2int(a, m);
for(int i=b.size()-1;i>=0;i--) {
r = r * modExp(a10k, b[i]-'0', m) % m;
a10k = modExp(a10k, 10, m);
}
return r;
}

还要注意的一点是,m虽然限定在int范围内,但是如果m*m超出了int,则计算过程中的乘法可能会超出int,为了防止溢出我们可以把数据类型换成long long,并且可以用模加来实现模乘来降低溢出的可能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
ll modMul(ll a,ll b,ll m) {
ll t=0;
a=(a%m+m)%m;
b=(b%m+m)%m;
while(b){
if(b&1){
t=(t+a)%m;
}
a=(a+a)%m;
b>>=1;
}
return t;
}

ll modExp(ll a,ll b,ll m) {
ll t=1,y=a%m;
while(b){
if(b&1){
t=modMul(t,y,m);
}
y=modMul(y,y,m);
b=(b>>1);
}
return t;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

template<typename R, typename T>
R sstream_cast(const T& o) {
stringstream ss;
ss << o;
R result;
ss >> result;
return result;
}

// 排列数 n!/(n-k)!
int P(int n,int k)
{
if(k>n)return 0;
int res = 1;
for(int i=n-k+1; i<=n; i++){
res *= i;
}
return res;
}

// 返回小于n的各个数位不同的整数的个数
int countDistinctDigitsNumbers(int n) {
string s = sstream_cast<string>(n);
int len = s.size();
// 分类考虑:
// 1. 所有位数不足len的数满足条件
// 2. 首位为 1 ~ s[0]-1 的情况,s[1:len]任意都满足条件
// 3. 首位为 s[0],二位为 0 ~ s[1]-1 的情况(但前缀用掉的digit不能再用),s[2:len]任意都满足条件
// ...
// 前缀为 s[0:i],第i位为 0 ~ s[i]-1 的情况(但前缀用掉的digit不能再用),s[i+1:len]任意都满足条件
// ...
// 前缀为 s[0:len-1],第len-1位为 0 ~s[len-1]-1 的情况(但前缀用掉的digit不能再用),空后缀

int result = 0;
// 第1类:统计任意i位数的个数
for(int i=1;i<len;i++) {
result += 9 * P(9, i-1);
}
// 第2类
result += (s[0]-'0'-1) * P(9, len-1);
// 第3类
bool used[10] = {0};
used[s[0]-'0'] = true;
for(int i=1;i<len;i++) {
int cnt = 0;
// 对于cnt的循环计数可以优化,但无必要
for(int k=0;k<s[i]-'0';k++) {
if(!used[k]) {
cnt++;
}
}
result += cnt * P(9-i, len-1-i);

// 如果s前缀本身用到了重复数字,直接跳出,终止第3类的计算
if(used[s[i]-'0']) break;
else used[s[i]-'0'] = true;
}
return result;
}