简介
模拟退火算法(Simulated Annealing)是一种基于概率论的随机化算法,是对爬山算法的改进,它通过模拟退火过程来寻找全局最优解。
一般 OI 比赛中可以使用模拟退火获得更高的部分分,在部分题目可能由于数据强度不够,甚至可能通过。
算法描述
模拟退火算法随机求得一个新的解,并与当前解进行比较,如果新的解更优,则接受,否则以一定概率接受较差的解。
该算法需要三个参数:温度参数 ,降温参数 ,终止温度 。
我们记录当前的温度 ,在当前温度下进行一次 转移,得到新的温度 ,当 时终止退火。
转移是更新答案的过程。具体而言,根据当前的温度,在当前状态的基础上随机得到一个新状态,求得两个状态的答案差值 。如果新状态更优,那么将新状态作为当前状态,否则以概率:
将新状态作为当前状态。
这样描述可能很抽象,下面用几个例题来进行分析。
例题
P1337 [JSOI2004] 平衡点 / 吊打XXX
题目描述:
求给定 个点的带权费马点。具体地,若给定点点权为 ,到费马点的距离为 ,那么代价为 ,找到一个点最小化代价。输出点的坐标。
,,。
这题显然具有单调性,可以通过三分解决。但是同样可以使用模拟退火通过。
下面结合代码讲解如何使用模拟退火:
const int N = 1005;
struct Point {
int x, y, w;
} p[N];
const double D = 0.97;
const double MIN_ = -1e4, MAX_ = 1e4;
double calc(double x, double y) { // 对于一个坐标计算答案
double res = 0;
for (int i = 1; i <= n; ++i)
res += p[i].w *
sqrt((p[i].x - x) * (p[i].x - x) +
(p[i].y - y) * (p[i].y - y));
return res;
}
void simulateAnneal() {
double t = 1e10, t0 = 1e-10; // 定义初始温度和终止温度
random_device seed; // 使用 random_device 生成基于硬件的真随机数作为种子
mt19937 rad(seed()); // 定义随机数生成器
uniform_real_distribution<> random_coord(MIN_, MAX_); // 定义坐标随机生成器
uniform_real_distribution<> random_posb(0, 1); // 定义概率生成器
while (t >= t0) {
// 根据当前状态和温度随机新状态
double new_x = cur_x + random_coord(rad) * t, new_y = cur_y + random_coord(rad) * t;
// 计算新的答案
double new_ans = calc(new_x, new_y);
if (new_ans < cur_ans) {
// 如果新答案更优,直接更新
cur_ans = new_ans;
cur_x = new_x;
cur_y = new_y;
} else if (random_posb(rad) <= exp((cur_ans - new_ans) / t)) {
// 否则按照上述概率接受
cur_ans = new_ans;
cur_x = new_x;
cur_y = new_y;
}
t *= D; // 降温
}
}
其中关于随机数的部分是值得考究的:
random_device
是基于硬件的随机数发生器,生成的随机数常用作伪随机数种子。实际上如此并不是必要的,可以直接用你喜欢的数字作为种子。
mt19937
是基于 32 位梅森缠绕器的伪随机数发生器,生成随机数效果较好,常被使用。
uniform_real_distribution(l,r)
用于生成一个位于 的随机数,是左闭右开的。
更详细的内容可见 随机数与随机函数 一文。
P2503 [HAOI2006] 均分数据
将 个正整数分成 组,每组的权值为该组的数字和,希望最小化权值的方差。求最小方差的值。
。
这题应该可以使用状压 DP 进行。考虑使用模拟退火,每一次随机得到一个数组,规定每一组在数组 中都是连续的一段,使用简单 DP 可以在 的时间内计算出答案。每一次新状态由原状态随机交换位置得到。类似地进行模拟退火:
Sample Code (c++)
const int N = 21;
int a[N];
int n, m;
void upd_min(double &x, double y) {
x = min(x, y);
}
double calc(int *a) { // 使用 DP 计算最小方差
static int sum[N];
static double f[N][N];
for (int i = 0; i <= n; ++i)
for (int j = 0; j <= m; ++j)
f[i][j] = 1.0 / 0.0;
f[0][0] = 0;
for (int i = 1; i <= n; ++i)
sum[i] = sum[i - 1] + a[i];
double aver = (double)sum[n] / m;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j <= m; ++j) {
for (int k = 0; k < i; ++k) {
upd_min(f[i][j], f[k][j - 1] + (sum[i] - sum[k] - aver) * (sum[i] - sum[k] - aver));
}
}
}
return sqrt(f[n][m] / m);
}
const double D = 0.915;
double ans;
void simulateAnnel() {
double t = 100000, t0 = 1e-10;
static int cur[N], b[N];
double cur_ans = ans;
for (int i = 1; i <= n; ++i) cur[i] = a[i];
random_device seed;
mt19937 rad(seed());
uniform_real_distribution<> posb(0, 1);
while (t >= t0) {
for (int i = 1; i <= n; ++i) b[i] = cur[i];
swap(b[rad() % n + 1], b[rad() % n + 1]); // 随机交换得到新状态
double new_ans = calc(b);
if (new_ans < cur_ans) {
for (int i = 1; i <= n; ++i) cur[i] = b[i];
cur_ans = new_ans;
if (new_ans < ans) {
for (int i = 1; i <= n; ++i) a[i] = cur[i];
ans = new_ans;
}
} else if (posb(rad) <= exp(cur_ans - new_ans) / t) {
for (int i = 1; i <= n; ++i) cur[i] = b[i];
cur_ans = new_ans;
}
t *= D;
}
}
注意到进行一次模拟退火不一定得到最优解,可以多次进行模拟退火提高正确率。为了避免超时,可以使用名为 卡时 的技巧,在规定时间内尽可能多次进行模拟退火。
while ((double)clock() / CLOCKS_PER_SEC <= 0.9) {
simulateAnnel();
}
这里需要包含 ctime
头文件,clock() 会返回从程序开始到当前的时间,CLOCKS_PER_SEC
是系统定义的一个宏,这个告诉你一秒包含多少个 CLOCK 的值。0.9
为一个略小于时限的数字。
注意实际用时是 0.9
加上单次运行退火的时间,因为时间不超过 0.9 都会进行退火,请估计单次退火时间,以免超时。
习题
P5544 [JSOI2016] 炸弹攻击1
题目描述:
给定平面上 个圆和 个点。请你选择一个点和不超过 的半径作圆,在这个圆与给定圆不相交的情况下,求最多能够覆盖多少个点。
,,所有坐标绝对值不超过 。
Sample Code (C++)
#include <bits/stdc++.h>
using namespace std;
template <typename T>
void read(T &r) {
r = 0;
int f = 1;
char c = getchar();
for (; c < '0' || c > '9'; c = getchar())
if (c == '-') f = -1;
for (; c >= '0' && c <= '9'; c = getchar())
r = (r << 1) + (r << 3) + (c ^ 48);
r *= f;
}
template <typename First, typename... Rest>
void read(First &first, Rest &...rest) {
read(first);
read(rest...);
}
template <typename T>
void print(T v) {
if (v < 0) putchar('-'), v = -v;
static int sta[40], top;
top = 0;
do {
sta[++top] = v % 10;
v /= 10;
} while (v);
while (top) putchar(sta[top--] ^ 48);
}
const int N = 2e4 + 5;
struct Bulid {
int x, y, r;
}b[N];
struct Enemy {
int x, y;
}p[N];
double dist(double x1, double y1, double x2, double y2) {
return sqrt((x1 - x2) * (x1 - x2) + (y1 - y2) * (y1 - y2));
}
int n, m, r;
double calc(double x, double y) {
double rr = r;
for (int i = 1; i <= n; ++i)
rr = min(rr, dist(x, y, b[i].x, b[i].y) - b[i].r);
if (rr < 0) return 0;
int res = 0;
double minn = 1.0 / 0.0;
for (int i = 1; i <= m; ++i) {
double d = dist(x, y, p[i].x, p[i].y);
if (d > rr) minn = min(d - rr, minn);
res += (d <= rr + 1e-10);
}
if (res) return res;
return res - minn;
}
int calc2(double x, double y) {
double rr = r;
for (int i = 1; i <= n; ++i)
rr = min(rr, dist(x, y, b[i].x, b[i].y) - b[i].r);
if (rr < 0) return 0;
int res = 0;
for (int i = 1; i <= m; ++i)
res += (dist(x, y, p[i].x, p[i].y) <= rr + 1e-10);
return res;
}
double ans_x, ans_y, ans;
void simulateAnnel() {
double cur_x = ans_x, cur_y = ans_y, cur_ans = ans;
double t = 1e10, t0 = 1e-15;
const double D = 0.996;
random_device seed;
mt19937 rad(seed());
uniform_real_distribution<> diff(-100, 100);
uniform_real_distribution<> posb(0, 1);
while (t >= t0) {
double new_x = cur_x + diff(rad) * t;
double new_y = cur_y + diff(rad) * t;
double new_ans = calc(new_x, new_y);
if (new_ans > cur_ans) {
cur_ans = new_ans;
cur_x = new_x;
cur_y = new_y;
if (new_ans > ans) {
ans = new_ans;
ans_x = new_x;
ans_y = new_y;
}
} else if (posb(rad) <= exp((new_ans - ans) / t)) {
cur_ans = new_ans;
cur_x = new_x;
cur_y = new_y;
}
t *= D;
}
}
int main() {
read(n, m, r);
for (int i = 1; i <= n; ++i)
read(b[i].x, b[i].y, b[i].r);
for (int i = 1; i <= m; ++i)
read(p[i].x, p[i].y);
ans_x = 0;
ans_y = 0;
ans = calc(ans_x, ans_y);
while ((double)clock() / CLOCKS_PER_SEC <= 0.9) {
simulateAnnel();
}
print(calc2(ans_x, ans_y));
fprintf(stderr, "\n\nTime Used: %.3lfs\n", (double)clock() / CLOCKS_PER_SEC);
}
P3878 [TJOI2010] 分金币
题目描述:
给定 个数字,将 个数字分成两组,两组的数字数量相差不超过 ,求两组金币价值差的最小值。
,每一个数字 满足 。
Sample Code (C++)
#include <bits/stdc++.h>
using namespace std;
const int N = 31;
int v[N];
long long ans;
int n;
long long calc(int *a) {
long long res = 0;
for (int i = 1; i <= n / 2; ++i) res += a[i];
for (int i = n / 2 + 1; i <= n; ++i) res -= a[i];
if (res < 0) res = -res;
return res;
}
void simulated_annealing() {
double t = 100, delta_t = 0.965, tk = 1e-10;
random_device seed;
mt19937 Rad(seed());
uniform_int_distribution<> rad(1, n);
uniform_real_distribution<> pos(0, 1);
long long cur_ans = ans;
while (t >= tk) {
static int cur_v[N];
for (int i = 1; i <= n; ++i) cur_v[i] = v[i];
swap(cur_v[rad(Rad)], cur_v[rad(Rad)]);
long long nxt_ans = calc(cur_v);
if (nxt_ans < cur_ans || pos(Rad) <= exp((cur_ans - nxt_ans) / t)) {
cur_ans = nxt_ans;
ans = min(ans, nxt_ans);
for (int i = 1; i <= n; ++i) v[i] = cur_v[i];
}
t = t * delta_t;
}
}
int main() {
int tt;
scanf("%d", &tt);
double ti = 0.97 / tt;
for (int t = 1; t <= tt; ++t) {
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &v[i]);
ans = calc(v);
while ((double)clock() / CLOCKS_PER_SEC <= ti * t) {
simulated_annealing();
}
printf("%lld\n", ans);
}
}