1. 爬山算法简介
爬山算法是一种启发式搜索算法,用于在解空间中寻找局部最优解(Local Optimum)。它的名字来源于一个形象的比喻:想象你身处一座山中,目标是找到山顶(最高点)。你只能看到周围的地形(局部信息),因此你总是朝着上坡方向(即目标函数值增加的方向)移动,直到到达一个没有更高邻居的点,即“山顶”。
- 核心思想:从一个初始解出发,在其邻域(附近)中寻找一个比当前解更优的解,如果找到,则移动到该解,并继续搜索;如果找不到,则停止,当前解即为局部最优解。
- 目标:最大化(或最小化)一个目标函数
f(x)。 - 特点:
- 简单易懂,实现容易。
- 内存消耗低,通常只维护当前解。
- 速度快,迭代次数相对较少。
- 缺点:容易陷入局部最优解,无法保证找到全局最优解(Global Optimum)。它可能会错过更高的山峰(全局最优),而停留在一个较低的山顶(局部最优)。
2. 算法流程
以下是爬山算法的基本步骤:
-
初始化:
- 选择一个初始解
current(可以是随机生成的,也可以是根据经验设定的)。 - 计算初始解的目标函数值
f(current)。
- 选择一个初始解
-
循环迭代:
- 生成邻居:在当前解
current的邻域(Neighborhood)内,生成所有可能的邻居解(或采样部分邻居)。 - 评估邻居:计算每个邻居解的目标函数值。
- 选择最优邻居:
- 最陡上升爬山法(Steepest-Ascent Hill Climbing):在所有邻居中,选择目标函数值最优(最大或最小)的那个。
- 首选爬山法(First-Choice Hill Climbing):按某种顺序检查邻居,一旦找到一个比当前解更优的邻居,就立即移动到该邻居(不检查所有邻居)。
- 更新解:
- 如果找到的最优邻居解
neighbor比当前解current更优(f(neighbor) > f(current)用于最大化,f(neighbor) < f(current)用于最小化),则令current = neighbor,并返回步骤 2 继续迭代。 - 如果没有邻居比当前解更优,则说明当前解是局部最优解,算法终止。
- 如果找到的最优邻居解
- 生成邻居:在当前解
-
输出结果:返回当前解
current作为最终的(局部)最优解。
3. 关键概念
- 状态空间(State Space):所有可能解的集合。
- 目标函数(Objective Function) / 适应度函数(Fitness Function):用于评估一个解优劣的函数
f(x)。算法的目标是最大化或最小化这个函数。 - 邻域(Neighborhood):对于一个给定的解
x,其邻域N(x)是指通过一次“小改动”(如翻转一个比特、交换两个元素等)可以从x得到的所有解的集合。邻域的定义对算法性能至关重要。 - 局部最优解(Local Optimum):一个解,其邻域内没有任何解比它更优。
- 全局最优解(Global Optimum):在整个状态空间中,目标函数值最优的解。
- 山脊(Ridge):一种地形,沿着某个方向可能没有明显的上升路径。
- 高原(Plateau):一片平坦区域,所有邻居的目标函数值与当前解相同,算法无法判断移动方向。
4. C++ 实现示例
下面我们以一个经典问题——最大割问题(Max-Cut Problem)——为例来实现爬山算法。
问题描述:
给定一个无向图 G = (V, E),将顶点集 V 分成两个不相交的子集 S 和 T(S ∪ T = V, S ∩ T = ∅),使得连接 S 和 T 两个集合的边(即割边)的总权重最大。
- 状态表示:一个解可以用一个
bool数组assignment表示,assignment[i] = true表示顶点i属于集合S,false表示属于集合T。 - 目标函数:割边的总权重。
- 邻域定义:通过**翻转(Flip)**一个顶点的分配(从
S到T或从T到S)得到的新解。
#include <iostream>
#include <vector>
#include <cstdlib>
#include <ctime>
#include <algorithm>
#include <random>
using namespace std;
// 图的表示:邻接矩阵,graph[i][j] 表示顶点 i 和 j 之间的边权重
using Graph = vector<vector<int>>;
class HillClimbingMaxCut {
private:
Graph graph; // 输入图
int n; // 顶点数量
vector<bool> current; // 当前解:顶点分配
mt19937 rng; // 随机数生成器
// 计算当前分配下的割边总权重
int calculateCutValue(const vector<bool>& assignment) {
int cutValue = 0;
for (int i = 0; i < n; ++i) {
for (int j = i + 1; j < n; ++j) {
if (graph[i][j] > 0 && assignment[i] != assignment[j]) {
cutValue += graph[i][j];
}
}
}
return cutValue;
}
// 执行一次最陡上升爬山步骤
// 返回:是否找到了更优的邻居
bool performSteepestAscent() {
int currentCutValue = calculateCutValue(current);
int bestImprovement = 0;
int bestVertexToFlip = -1;
// 检查翻转每一个顶点带来的变化
for (int v = 0; v < n; ++v) {
// 计算翻转顶点 v 后割值的变化量 (Δ)
int improvement = 0;
for (int u = 0; u < n; ++u) {
if (u != v && graph[u][v] > 0) {
if (current[u] != current[v]) {
// 原本是割边,翻转后不再是割边,损失权重
improvement -= graph[u][v];
} else {
// 原本不是割边,翻转后成为割边,增加权重
improvement += graph[u][v];
}
}
}
// 寻找能带来最大改进的翻转
if (improvement > bestImprovement) {
bestImprovement = improvement;
bestVertexToFlip = v;
}
}
// 如果找到了能改进的翻转
if (bestVertexToFlip != -1) {
current[bestVertexToFlip] = !current[bestVertexToFlip]; // 执行翻转
return true;
}
return false; // 没有找到更优的邻居,已到达局部最优
}
public:
// 构造函数
HillClimbingMaxCut(const Graph& g) : graph(g), n(g.size()), rng(chrono::steady_clock::now().time_since_epoch().count()) {
current.resize(n);
}
// 运行爬山算法
// 返回:找到的局部最优解及其割值
pair<vector<bool>, int> solve() {
// 1. 随机初始化当前解
for (int i = 0; i < n; ++i) {
current[i] = rng() % 2; // 随机分配到 S 或 T
}
cout << "初始割值: " << calculateCutValue(current) << endl;
// 2. 迭代直到无法改进
int iteration = 0;
while (performSteepestAscent()) {
iteration++;
// 可选:打印中间结果
// cout << "迭代 " << iteration << ", 当前割值: " << calculateCutValue(current) << endl;
}
int finalCutValue = calculateCutValue(current);
cout << "算法结束,迭代次数: " << iteration << ", 最终割值: " << finalCutValue << endl;
return {current, finalCutValue};
}
// 打印最终的划分结果
void printSolution(const vector<bool>& solution, int cutValue) {
cout << "最终划分 (S / T):" << endl;
cout << "S (true): ";
for (int i = 0; i < n; ++i) {
if (solution[i]) cout << i << " ";
}
cout << endl;
cout << "T (false): ";
for (int i = 0; i < n; ++i) {
if (!solution[i]) cout << i << " ";
}
cout << endl;
cout << "最大割值: " << cutValue << endl;
}
};
// 主函数
int main() {
// 创建一个简单的无向图作为测试用例 (5个顶点)
// 顶点: 0, 1, 2, 3, 4
// 边: (0,1)=2, (0,2)=3, (1,2)=1, (1,3)=4, (2,4)=2, (3,4)=3
Graph graph = {
{0, 2, 3, 0, 0},
{2, 0, 1, 4, 0},
{3, 1, 0, 0, 2},
{0, 4, 0, 0, 3},
{0, 0, 2, 3, 0}
};
cout << "开始运行爬山算法求解最大割问题..." << endl;
HillClimbingMaxCut hc(graph);
auto [solution, cutValue] = hc.solve();
hc.printSolution(solution, cutValue);
return 0;
}
5. 代码说明
- 类设计:
HillClimbingMaxCut类封装了算法逻辑。 - 随机初始化:使用
std::mt19937和std::chrono生成高质量的随机种子,随机分配每个顶点到S或T。 - 高效计算:
calculateCutValue函数计算总割值。performSteepestAscent函数在检查每个邻居时,并未完全重新计算割值,而是通过计算翻转一个顶点带来的增量变化(Δ) 来高效判断改进。这是优化的关键。 - 最陡上升:算法遍历所有可能的翻转(所有邻居),选择能带来最大正改进(
improvement > 0)的那个。如果bestImprovement <= 0,说明没有更优的邻居。 - 终止条件:当
performSteepestAscent返回false时,表示已到达局部最优,循环结束。
6. 算法的局限性与改进方向
-
局限性:
- 局部最优陷阱:这是最主要的缺点。算法一旦到达一个“山顶”,就无法下山去探索可能更高的“山峰”。
- 高原和山脊:在高原上,所有邻居的值相同,算法无法移动。在山脊上,可能需要连续的多个移动才能上升,但单步移动可能不优。
- 对初始解敏感:不同的初始解可能导致收敛到不同的局部最优解。
-
改进方向(跳出局部最优):
- 随机重启爬山法(Random-Restart Hill Climbing):多次运行爬山算法,每次使用不同的随机初始解,记录所有运行中找到的最优解。这增加了找到全局最优的概率。
- 模拟退火(Simulated Annealing):允许以一定概率接受比当前解更差的解,这个概率随着“温度”的降低而减小。这有助于跳出局部最优。
- 禁忌搜索(Tabu Search):引入一个“禁忌列表”来记住最近访问过的解或移动,防止算法在短时间内循环,鼓励探索新的区域。
- 遗传算法(Genetic Algorithm):使用种群和进化操作(选择、交叉、变异)来维持解的多样性,避免过早收敛。
总结
爬山算法是一个简单而强大的局部搜索框架。它易于理解、实现和应用。尽管它不能保证找到全局最优解,但在许多实际问题中,它能快速找到质量不错的解。理解其原理、优缺点以及如何定义状态、邻域和目标函数,是应用和改进此类算法的基础。在实际应用中,通常会结合随机重启或其他元启发式策略来克服其局限性。
1997

被折叠的 条评论
为什么被折叠?



