浅谈出题者如何造数据
概括一下:这篇文章列举了每种数据的构造方式,没有的可以提出哦!
有笔误也请提出。
代码均由 Gemini 编写,一些逻辑也询问了 Ta。
请注意文中的板块都没有讲的很详细,如果想深度学习内容,可以移步其他文章。
计划:后续添加图和树。
随机种子
所有的随机数生成都离不开随机种子。
通常我们会使用 time(0)(秒级) 获取当前时间来充当随机种子。
但是它的缺点很明显,如果在一秒内多次运行程序,生成的随机序列将完全相同。
更好用的是 std::random_device。它通过硬件熵源生成非确定性的随机数,可以看作是“真随机”。需要注意的是:
-
在许多 Linux 系统上,它确实利用
/dev/urandom。但在某些 Windows 编译器(如旧版 MinGW)下,它的实现可能是确定性的(每次运行结果一样)。 -
其调用比普通的伪随机数生成器慢得多,所以也只用它来当种子。
熵源:计算机利用热噪声、中断时序等不可预测的物理现象产生的随机性来源。
当然,不嫌麻烦可以用 std::chrono::steady_clock::now().time_since_epoch().count()。原理是获取系统启动后的高精度时间戳(通常是纳秒级),相比 time(0),它更难被预测且精度极高。
序列
单个数
随机整数的生成
在给定的闭区间
-
传统的方法是使用
rand() % (b - a + 1) + a。- 但需要注意的是
RAND_MAX在某些系统(如 Windows)中仅为 32767,无法生成大数据。 - 而且如果
RAND_MAX + 1不能被区间长度L = b - a + 1 整除,那么区间前半部分的数字出现的概率会略高于后半部分。这个问题被称为模运算偏差。
- 但需要注意的是
-
现目前一般使用
<random>头文件,然后通过std::mt19937或std::mt19937_64(能生成 64 位整数) 引擎配合std::uniform_int_distribution实现。- 其优点很多,可以说严格符合数学上的均匀分布,同时执行周期极长(
2^{19937}-1 )。
- 其优点很多,可以说严格符合数学上的均匀分布,同时执行周期极长(
具体实现请看伪代码:
// 1. 初始化真随机数种子源
std::random_device rd;
// 2. 以种子初始化 mt19937 引擎
std::mt19937 gen(rd());
// 3. 定义 [a, b] 闭区间的均匀分布
int a = 1, b = 1000000;
----
std::uniform_int_distribution<int> dis(a, b);
// 4. 生成随机数
int random_val = dis(gen);
std::cout << "生成的随机数是: " << random_val << std::endl;
----1
或:
----
int random_val = dis(gen) % (b - a + 1) + a;
std::cout << "生成的随机数是: " << random_val << std::endl;
----2
Ps:<random> 头文件包含在万能头中。
随机浮点数的生成
在区间
使用 std::uniform_real_distribution<double> dis(a, b)。它能自动处理浮点数的精度映射,确保在给定范围内均匀分布。它仍然需要使用 <random> 头文件。
伪代码:
// 定义 [a, b) 左闭右开区间的实数分布
double fa = 0.0, fb = 1.0;
std::uniform_real_distribution<double> fdis(fa, fb);
// 生成随机浮点数
double random_float = fdis(gen);
序列
随机排列
生成一个
先使用 std::iota 填充序列(手动填充也行),再使用 std::shuffle 打乱。
注意:应避免使用已被废弃的 std::random_shuffle,而是配合mt19937 使用 std::shuffle。
伪代码:
std::vector<int> p(n);
std::iota(p.begin(), p.end(), 1); // 填充 1, 2, ..., n
std::shuffle(p.begin(), p.end(), gen);// 这里的 gen 是随机数。
std::iota:一个工具函数,用于用连续递增的值填充指定范围的元素。定义在<numeric>(包含于万能头) 头文件中。
当然,如果你不希望依赖 std::shuffle(因为笔者不会拼),可以手动实现 Fisher-Yates Shuffle(一种将有限集合生成随机排列的算法,由于其在线性时间内完成且不产生统计偏向,被广泛应用。)。
该算法的核心思想是从后往前遍历数组,将当前元素与前面任意一个随机位置的元素进行交换。这种方法能保证每一个排列出现的概率都是
单调数列
分为严格单调或非严格单调。
-
先生成一组随机数,然后使用
std::sort进行排序。 -
也可以递增构造:
a_i = a_{i-1} + \text{rand\_delta} ,其中\text{rand\_delta} \ge 0 。
单峰数列
序列先严格递增再严格递减(形如山峰)。
构造步骤:
-
随机确定峰顶位置
k \in [1, n] 。 -
生成
n 个随机数并排序。 -
将最大的数放在位置
k 。 -
将剩余的数随机或交替填充到
k 的左侧和右侧,并分别排序。
Ps:多峰数列可由多个单峰数列组成。
区间 l,r
这里还是值得讲一下的,因为每种写法都有优缺点。
-
直接生成两个独立随机数
u, v \in [1, n] ,取l = \min(u, v), r = \max(u, v) 。- 生成的区间长度期望约为
n/3 。 - 它在区间个体的选择上是不均匀的。例如,生成特定点区间
[i, i] 的概率是1/n^2 (需u=v=i ),而生成特定的l < r 区间的概率是2/n^2 (u=l, v=r 或u=r, v=l 均可)。这意味着它更倾向于生成长度大于 1 的区间。
- 生成的区间长度期望约为
-
先随机长度
len \in [0, n-1] ,再随机起点l \in [1, n-len] 。- 优点是它保证了长度
len 在[0, n-1] 上是绝对均匀分布的。 - 缺点是会导致“长区间”个体被选中的概率远高于“短区间”。例如,全长度区间
[1, n] 只有一个,被选中的概率是1/n ;而长度为 1 的区间有n 个,每个被选中的概率仅为1/(n \times n) 。 - 话说这应该不算缺点吧...
- 优点是它保证了长度
第二种生成策略就是很常用的了。
如果实在要追求完美(指所有可能的子区间
在
先在
针对特定算法的 Hack 数据的构造
需要注意的是,对于这个板块的所有算法,在固定数据的情况下,都无法完美卡掉做题者的代码。
所以 Hack 仅供参考。
针对快速排序
::::info[原理] 快速排序作为一种基于分治法的经典排序算法,其效率高度依赖于基准值的选择。
在理想情况下,基准值能将序列平分为两个等长的子序列,从而达到
但显然这可能被卡。
注意到当快速排序采用固定位置(如第一个或最后一个元素)作为基准值时,其逻辑上的递归树将从平衡的二叉树退化为一条长链。
比如在升序或降序序列中,如果选取第一个元素作为基准值:
-
基准值在分区后依然留在最左侧,产生一个长度为
0 和一个长度为n-1 的子序列。 -
为了排好一个元素,算法需要扫描剩余的所有元素。
-
总时间复杂度为
O(n^2) 。
所以,现在的实现一般采用三数取中(Median-of-three)优化。也就是取序列首、中、尾三个数的中位数作为基准。但是这还是能卡。
M. D. McIlroy 提出的 Anti-Quicksort 是一种动态对手算法。它通过模拟排序过程,在算法进行比较时动态决定元素的大小关系,迫使“三数取中”每次都选到一个极差的基准值(如剩余元素中较小的一个)。 ::::
因为上述原因,在现代 C++ 的 std::sort 实现中,采用了内省排序策略了来防止被卡。
而且对于手写快排的随机选取基准值也是无法卡掉的。
所以还不膜拜期望真菌 %%%
针对哈希表
- 针对
std::unordered_map
::::info[原理]
因为在很多 C++ 实现(如 GCC)中,std::unordered_map 对整数的默认哈希函数是 Identity Map(恒等映射),即 hash(x) = x。
其内部使用拉链法(当多个键映射到同一个桶时,用链表将这些键连接起来的冲突解决方法。)处理冲突。
由于 `unordered_map 的桶大小遵循特定的质数序列(如
Hack:
这会使所有
- 针对
pb_ds中的gp_hash_table
::::info[原理]
gp_hash_table(探测法哈希表)通常比 unordered_map 快,因为它使用闭散列(开放寻址法)。
为了加速运算,它的桶大小通常是 2 的幂次,内部通过 (x ^ (x >> 16)) & (mask) 来定位。
对于这种哈希只需要构造在高位不同、但在低位经过位运算后相同的数值:
开放寻址法:当发生冲突时,通过某种探测序列在哈希表中寻找下一个空闲位置的解决方法。 ::::
Hack:构造序列
针对手动实现哈希
- 自然溢出
使用 unsigned long long 自动溢出而不取模(等价于对
使用 Thue-Morse 序列。该序列可以构造出两段哈希值在
具体地,
定义
随着
Thue-Morse 序列:一个二进制序列,通过反复对当前序列取反并拼接在末尾生成,常用于数学和计算机科学中构造避开特定模式的序列。
- 单模数
如果使用固定的质数(如
利用生日悖论,大约只需要生成
生日悖论:在不少于 23 人的群体中出现相同生日的概率超过 50%,60 人时概率可达 99%。
反 Hack 也很简单,添加上文中所说的扰动量,或者双模哈希,比如:
struct my_hash {
static uint64_t splitmix64(uint64_t x) {
x += 0x9e3779b97f4a7c15;
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9;
x = (x ^ (x >> 27)) * 0x94d049bb133111eb;
return x ^ (x >> 31);
}
size_t operator()(uint64_t x) const {
static const uint64_t FIXED_RANDOM =
chrono::steady_clock::now().time_since_epoch().count();
return splitmix64(x + FIXED_RANDOM);
}
// 针对 std::pair<int, int> 作为主键类型的哈希函数
size_t operator()(pair<uint64_t, uint64_t> x) const {
static const uint64_t FIXED_RANDOM =
chrono::steady_clock::now().time_since_epoch().count();
return splitmix64(x.first + FIXED_RANDOM) ^
(splitmix64(x.second + FIXED_RANDOM) >> 1);
}
};
// 使用方式:std::unordered_map<long long, int, my_hash> safe_map;
针对莫队
莫队算法的复杂度取决于区间端点
针对普通莫队,若不使用奇偶排序优化,构造
更通用的 Hack:构造一系列询问,使得
曼哈顿距离:在二维平面上,两点之间横纵坐标差的绝对值之和,莫队算法的复杂度本质上是询问点在二维平面上的曼哈顿距离之和。
针对 ODT
虽然其本身被划为暴力数据结构,但实在是没啥好说的。
出题者只需自己做下势能分析,每次取极限,也挺好卡的。
针对线段树
其本身的复杂度是完全达标的,所以这里教一下怎么恶心做题者。
::::info[线段树要开
很多萌新只知道要这么做,但不知道为什么。
线段树虽然不是完全二叉树,但它是一棵平衡二叉树。当我们使用堆式存储(即 p << 1 和 p << 1 | 1)时,数组下标取决于树的深度。
最坏情况下,当
感性理解就行。 ::::
卡极限空间只要让
当然如果作为做题者的你遇到空间限制极严(比如 看似变劣了)。
之后是常数。线段树最主要的操作是 pushdown 与 pushup。
我们构造大量的叶子节点查询,或者在区间两端频繁跳动的修改。强制线段树每次都走满
字符串
窝趣,这部分忘讲了,后面再说。
图
这次修改给图开个头吧。-- 2026/2/14。
树
树只是图的一个分支,其本身在数据这块也没啥好讲的。
只能说以下内容确实都算树吧。
-
长链:每个点只连向
i+1 ,专门卡深度相关的算法。 -
菊花图:一个中心点连向所有其他点,卡那些对节点度数敏感的逻辑。
-
扫帚图:前半段是链,后半段是菊花。
-
完全二叉树:保持高度在
\log n ,测试理想情况下的性能。笔者没了解到啥卡数据的作用。
同时真随机树的生成一般利用 Prufer 序列生成。先随机一个长度为
Prufer 序列:这里不做解释,请继续查询 Prüfer 序列。