跳到主要内容
机器学习 KNN 算法原理及 C++/Python 实战实现 | 极客日志
编程语言 AI 算法
机器学习 KNN 算法原理及 C++/Python 实战实现 综述由AI生成 KNN 算法是一种监督学习中的惰性学习算法,核心是通过计算新样本与训练样本的距离找 k 个最近邻,再用多数表决或均值做预测。详细讲解了 KNN 算法原理、优缺点,以及使用 ROC 曲线、AUC 等指标评估模型性能的方法。内容涵盖数据预处理(归一化、洗牌)、最佳 K 值选择策略,并提供了 C++ 和 Python 两种语言的完整实现代码,包括欧几里得距离计算、K 折交叉验证、混淆矩阵生成及 ROC 曲线可视化,帮助读者深入理解并应用 KNN 算法。
城市逃兵 发布于 2026/3/16 更新于 2026/4/27 11 浏览KNN 算法讲解
实现环境
C++:集成开发环境 VS2022,可视化库 matplot++
Python:集成开发环境 Pycharm,数学库 numpy,可视化库 matplotlib
KNN 算法原理
KNN(K-近邻算法)是一种监督学习中的惰性学习算法,核心是'相似样本在特征空间中彼此靠近',通过计算新样本与训练样本的距离找 k 个最近邻,再用多数表决(分类)或均值(回归)做预测,无需显式训练模型。
算法的优点
原理简单直观,实现难度低,无需复杂参数训练。
对异常值不敏感,适合类域交叉或重叠较多的数据集。
适配多分类任务,且可灵活切换分类与回归场景。
算法的缺点
预测阶段计算量大,时间与空间复杂度高,数据量大时效率低。
对高维数据敏感,易出现'维度灾难',需通过降维等手段优化。
样本不平衡时,多数类可能主导预测结果,需做样本均衡处理。
评估标准
用 ROC 曲线评估
ROC 曲线是二分类模型的性能评估曲线,以假正率(FPR)为横轴、真正率(TPR)为纵轴,通过遍历分类模型的分类阈值,绘制出不同阈值下模型的 TPR 与 FPR 对应点并连接成线,直观反映模型在'识别正例'和'避免误判负例'之间的权衡能力。FPR 值是所有反例中被错误当作正例的比例,TPR 值是所有正例中正确识别出来的正例的比例;模型正确识别正例的能力越高、错误识别正例的能力越低,这个模型的分类能力就越强。
计算 FPR、TPR
这里涉及到四个数据:TP(真正例)、FP(假正例)、FN(假反例)、TN(真反例)
TP: 真实正例被模型正确预测为正例;
FP: 真实负例被模型错误预测为正例(误检);
FN: 真实正例被模型错误预测为负例(漏检);
TN: 真实负例被模型正确预测为负例。
TPR 计算公式:
FPR 计算公式:
AUC
AUC(ROC 曲线下的面积)是对 ROC 曲线的量化评估,也是比 ROC 曲线更常用的模型性能指标,取值范围为 0~1。AUC 的值越接近 1,表明模型越完美,分类性能越好。
例题分析
问题
海伦一直使用在线约会网站寻找适合自己的约会对象。她曾交往过三种类型的人:
不喜欢的人
一般喜欢的人
非常喜欢的人
这些人包含以下三种特征:
每年获得的飞行常客里程数
玩视频游戏所耗时间百分比
每周消费的冰淇淋公升数
该网站现在需要尽可能向海伦推荐她喜欢的人,需要我们设计一个分类器,根据用户的以上三种特征,识别出是否该向海伦推荐。
数据集类型
每年获得的飞行常客里程数 玩视频游戏所耗时间百分比 每周消费的冰淇淋的公升数 样本分类 1 400 0.8 0.5 1
问题剖析 题目给出了三个特征维度,'每年获得的飞行常客里程数'这一特征维度的数据比另外两个大了 2-3 个数量级,我们选择了欧几里得距离作为距离标准,所以需要对数据进行预处理,让每个特征维度的权重基本一致。我们需要在导入图中数据时,将这些字符串转换为整型数据,方便使用。
具体实现
大致流程示意图
具体步骤 归一化 :将所有数据按原范围大小,均匀压缩到 0-1 的范围内,如 25、50、75、125,原范围 25-125,对应 0-1 范围 0、0.25、0.50、1。
给出公式:
洗牌:数据集的数据可能呈现出部分特征集中在某一特定位置,我们需要搅动它让数据均匀地分布在每个地方。对于数据集而言,这种数据集集中分布的情况是会影响生成的模型的能力的,所以我们需要把原来的数据集打乱,让某些过于集中的特征分散开来,提高模型预测的准确性。
k 的选取是很重要的,常见的选择方法有经验值法(直接设置参数)和交叉验证法(网格搜索 + 交叉验证)。
在计距离时用到了欧几里得距离距离计算公式。(x 和 y 是同一特征维度的,i 表示不同的特征维度):
欧几里得距离计算公式:
(3)计算 FPR 和 TPR、AUC 并可视化 ROC 曲线
上面在介绍 ROC 曲线时已经讲过了,这里就不多说了。
C++ 实现
环境准备 本次我们只需要一个额外的可视化第三方库,我们选择 matplot++
matplot++ 下载
VS2022 环境搭建 把 matplot++放到自己建立的解决方案的其中一个项目的目录下,matplot++的两个静态库,可根据实际位置调整,一般都是一个在 lib 里,一个在 lib\Matplot++里。然后在链接器中链接:
nodesoup.lib
matplot.lib
代码实现
头文件 #include <iostream>
#include <fstream>
#include <vector>
#include <memory>
#include <functional>
#include <algorithm>
#include <unordered_map>
#include <random>
#include <numeric>
#include <queue>
#include <matplot/matplot.h>
结构体 struct Data {
double length;
double game_time;
double ice_cream_eating;
int type;
};
这里 Data 结构体的前三个成员均为 double 类型而不是用 vector,这是因为 vector 是将数据分散存储到堆区,类似链表结构体,后续我们需要频繁地访问这些数据,需要不停使用指针访问内存,会让访问效率下降。
导入数据 std::vector<Data> loadData (const std::string& filepath) {
std::ifstream ifs;
ifs.open (filepath, std::ios::in);
if (!ifs.is_open ()) throw std::invalid_argument ("Failed to open file: " + filepath);
std::vector<Data> datalist;
double trail, game, eating;
std::string tag;
std::vector<std::string> searchlist = { "largeDoses" , "smallDoses" , "didntLike" };
while (ifs >> trail >> game >> eating >> tag) {
int type = -1 ;
for (int i = 0 ; i < searchlist.size (); ++i) {
if (searchlist[i] == tag) {
type = i;
break ;
}
}
datalist.push_back ({ trail, game, eating, type });
}
ifs.close ();
return datalist;
}
数据预处理 使用 lamdba 来简化三个特征维度的归一化实现:
void calculate (std::vector<double >& feature) {
double max = *max_element (feature.begin (), feature.end ());
double min = *min_element (feature.begin (), feature.end ());
double range = max - min;
for (double & val : feature) val = (val - min) / range;
}
void normalized (std::vector<Data>& data) {
if (data.empty ()) {
std::cout << "nullptr doesn't normalized!" << std::endl;
__debugbreak();
return ;
}
std::vector<std::pair< std::function<double (const Data&)>, std::function<void (Data&, double )> >> field = {
{ [](const Data& d) {return d.game_time; }, [](Data& d, double val) {d.game_time = val; } },
{ [](const Data& d) {return d.ice_cream_eating; }, [](Data& d, double val) {d.ice_cream_eating = val; } },
{ [](const Data& d) {return d.length; }, [](Data& d, double val) {d.length = val; } }
};
for (auto & f : field) {
auto & getVal = f.first;
auto & setVal = f.second;
std::vector<double > values;
for (const auto & d : data) values.push_back (getVal (d));
calculate (values);
for (int i = 0 ; i < data.size (); ++i) setVal (data[i], values[i]);
}
}
随机生成伪随机数种子,使用 shuffle 洗牌:
std::vector<Data> shuffleData (std::vector<Data>& data) {
auto shuffle_data = std::vector <Data>(data.begin (), data.end ());
std::random_device rd;
std::mt19937 g (rd()) ;
std::shuffle (shuffle_data.begin (), shuffle_data.end (), g);
return shuffle_data;
}
选择最佳 K 值
double eucliDistance (Data x, Data y) {
double trail = x.length - y.length;
double play = x.game_time - y.game_time;
double eating = x.ice_cream_eating - y.ice_cream_eating;
return trail * trail + play * play + eating * eating;
}
std::vector<int > getKNeighborProb (const Data& test, const std::vector<Data>& trains, int k) {
if (trains.empty () || k <= 0 ) throw std::invalid_argument ("Invalid input for getNeighborProb!" );
std::priority_queue<std::pair<double , int >> maxHeap;
for (const auto & d : trains) {
double dist = eucliDistance (test, d);
if (maxHeap.size () < k) maxHeap.emplace (dist, d.type);
else if (maxHeap.top ().first > dist) {
maxHeap.pop ();
maxHeap.emplace (dist, d.type);
}
}
std::vector<int > neighborProb (3 , 0 ) ;
while (!maxHeap.empty ()) {
neighborProb[maxHeap.top ().second]++;
maxHeap.pop ();
}
return neighborProb;
}
最大的 k 个邻居与它们的位置无关,只需要使用最大堆把最大的 k 个邻居和它们的类别存起来,统计最大堆前 k 个最大邻居出现的类别的数量即可。
int predictType (const Data& test, std::vector<Data>& data, int k) {
auto vote = getKNeighborProb (test, data, k);
return max_element (vote.begin (), vote.end ()) - vote.begin ();
}
使用 k 折交叉验证计算准确率和三分类混淆矩阵
std::pair<double , std::vector<std::vector<int >>> kFoldCrossVaild (std::vector<Data>& shuffle_data, int kfold, int knn_k) {
if (shuffle_data.empty ()) throw std::invalid_argument ("Invalid input for countRoc: Null data is invalid!" );
if (kfold <= 0 ) throw std::invalid_argument ("Invalid input for countRoc: kfold must be bigger than 0!" );
std::vector<std::vector<int >> cntlist (3 , std::vector <int >(3 , 0 ));
int foldSize = shuffle_data.size () / kfold;
std::vector<double > accuracies;
for (int fold = 0 ; fold < kfold; ++fold) {
int start = fold * foldSize;
int end = (kfold - 1 == fold) ? shuffle_data.size () : start + foldSize;
int correct = 0 ;
std::vector<Data> trainData (shuffle_data.begin(), shuffle_data.begin() + start) ;
trainData.insert (trainData.end (), shuffle_data.begin () + end, shuffle_data.end ());
for (int i = start; i < end; ++i) {
auto t = shuffle_data[i];
int res = predictType (t, trainData, knn_k);
cntlist[t.type][res]++;
correct += (res == t.type);
}
double accuracy = static_cast <double >(correct) / foldSize;
accuracies.push_back (accuracy);
}
return std::make_pair (std::accumulate (accuracies.begin (), accuracies.end (), 0.0 ) / kfold , cntlist);
}
网格搜索 从 3 开始,跳过偶数(偶数可能会平票,所以步长为 2),取数据集总量的开方,减少不必要的计算成本。将得到的准确率和混淆矩阵存起来,跑完网格搜素后寻找最大的准确率,换算 k 值,返回 k 和混淆矩阵。
计算 FPR 和 TPR
std::vector<double > predicProb (const Data& test, std::vector<Data>& data, int k) {
auto vote = getKNeighborProb (test, data, k);
std::vector<double > appearance (3 , 0 ) ;
for (int i = 0 ; i < appearance.size (); ++i) appearance[i] = static_cast <double >(vote[i]) / k;
return appearance;
}
计算某一类型作为正例时单个点的 FPR 和 TPR:
std::pair<double , double > countRoc (std::vector<Data>& shuffle_data, int kfold, int knn_k, double confidence, int genre) {
if (shuffle_data.empty ()) throw std::invalid_argument ("Invalid input for countRoc: Null data is invalid!" );
if (kfold <= 0 ) throw std::invalid_argument ("Invalid input for countRoc: kfold must be bigger than 0!" );
int foldSize = shuffle_data.size () / kfold;
int TP = 0 , FP = 0 , FN = 0 , TN = 0 ;
for (int fold = 0 ; fold < kfold; ++fold) {
int start = fold * foldSize;
int end = (kfold - 1 == fold) ? shuffle_data.size () : start + foldSize;
int correct = 0 ;
std::vector<Data> trainData;
trainData.reserve (shuffle_data.size () - foldSize);
trainData.assign (shuffle_data.begin (), shuffle_data.begin () + start);
trainData.insert (trainData.end (), shuffle_data.begin () + end, shuffle_data.end ());
for (int i = start; i < end; ++i) {
auto t = shuffle_data[i];
std::vector<double > res = predicProb (t, trainData, knn_k);
bool isPred = (res[genre] >= confidence);
bool isTrue = (genre == t.type);
TP += isPred && isTrue;
FP += isPred && (!isTrue);
FN += (!isPred) && isTrue;
TN += (!isPred) && (!isTrue);
}
}
double TPR = (TP+FN == 0 ? 0.0 : static_cast <double >(TP) / (TP + FN));
double FPR = (FP+TN == 0 ? 0.0 : static_cast <double >(FP) / (FP + TN));
return { TPR, FPR };
}
std::pair<std::vector<double >, std::vector<double >> getROC (std::vector<Data>& data, int kfold, int knn_k, int genre) {
std::vector<double > tprlist;
std::vector<double > fprlist;
for (double confd = 1.0 ; confd > 0 ; confd -= 0.05 ) {
auto [tpr, fpr] = countRoc (data, kfold, knn_k, confd, genre);
tprlist.push_back (tpr);
fprlist.push_back (fpr);
}
if (!(tprlist.front () == 0 && fprlist.front () == 0 )) {
tprlist.insert (tprlist.begin (), 0 );
fprlist.insert (fprlist.begin (), 0 );
}
if (!(tprlist.back () == 1 && fprlist.back () == 1 )) {
tprlist.push_back (1 );
fprlist.push_back (1 );
}
return { fprlist, tprlist };
}
由于可能出现 FPR 不为 0 和不为 1 的情况,这样面积就可能会少算一部分(最小 FPR 的左边区域和最大 FPR 的右边区域),所以需要补全。
计算 AUC 和可视化实现
double getAUC (std::pair<std::vector<double >, std::vector<double >>& data) {
double auc = 0.0 ;
auto & fprs = data.first;
auto & tprs = data.second;
for (int i = 0 ; i < fprs.size ()-1 ; ++i) {
auc += (fprs[i + 1 ] - fprs[i]) * (tprs[i + 1 ] + tprs[i]) / 2 ;
}
std::cout << auc << std::endl;
return auc;
}
void plotEvalution (std::vector<std::pair<std::vector<double >, std::vector<double >>> data, std::vector<double > aucs, std::vector<std::vector<int >>& list) {
using namespace matplot;
std::vector<std::string> colorList ({ "blue" , "yellow" , "green" , "red" }) ;
std::vector<std::string> tagName ({ "largeDoses" , "smallDoses" , "didntLike" , "Random Guess" }) ;
auto fig = figure (true );
fig->size (2560 , 1000 );
subplot (1 , 2 , 1 );
hold (on);
for (int i = 0 ; i < data.size (); ++i) {
plot (data[i].first, data[i].second)->line_width (2 ).line_style ("-" ).color (colorList[i]);
}
plot (std::vector{ 0 ,1 }, std::vector{ 0 ,1 })->line_width (1 ).line_style ("--" ).color (colorList[3 ]);
hold (off);
xlabel ("False Positive Rate" );
ylabel ("True Positive Rate" );
title ("ROC" );
xlim ({ 0 , 1 });
ylim ({ 0 , 1 });
for (int i = 0 ; i < aucs.size (); ++i) text (0.02 , 0.1 - i * 0.025 , tagName[i] + " AUC: " + std::to_string (aucs[i]))->font_size (8 );
auto l = ::matplot::legend (tagName);
l->location (legend::general_alignment::bottomright);
l->num_rows (2 );
l->font_size (5 );
subplot (1 , 2 , 2 );
heatmap (list)->normalization (matrix::color_normalization::columns);
title ("Three distribution" );
auto ax = gca ();
ax->x_axis ().ticklabels ({ "largeDoses" , "smallDoses" , "didntLike" });
ax->y_axis ().ticklabels ({ "largeDoses" , "smallDoses" , "didntLike" });
ax->x_axis ().label_font_size (5 );
ax->y_axis ().label_font_size (5 );
show ();
}
subplot(1, 2, 1) 下面的是 ROC 曲线可视化
subplot(1, 2, 2) 下面的是热力图可视化
int main () {
auto data = loadData ("source\\datingTestSet.txt" );
normalized (data);
auto shuffle_Data = shuffleData (data);
auto [best_k, list] = getBestK (data);
std::vector<std::pair<std::vector<double >, std::vector<double >>> results;
std::vector<double > AUCs;
for (int i = 0 ; i < 3 ; ++i) {
auto res = getROC (shuffle_Data, 10 , best_k, i);
auto auc = getAUC (res);
results.push_back (res);
AUCs.push_back (auc);
}
plotEvalution (results, AUCs, list);
return 0 ;
}
结果展示
Python 实现 python 也需要配置环境,但是 python 的环境配置简单很多
环境配置 在你的新的虚拟环境中,安装 numpy 和 matplotlib:
conda install numpy
conda install matplotlib
然后在创建项目时选择 conda 环境,到你的 anaconda 的文件夹中找到虚拟环境对应的 python.exe 即可。
代码实现 import numpy as np
import matplotlib.pyplot as plt
tag_map = {
"largeDoses" : 0 ,
"smallDoses" : 1 ,
"didntLike" : 2
}
def load_data (filepath ):
data = []
with open (filepath, 'r' , encoding="utf-8" ) as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split('\t' )
if len (parts) != 4 :
print (f"特征数据无法转换:{line} " )
continue
tag = parts[3 ]
data.append({
"feature" : [parts[0 ], parts[1 ], parts[2 ]],
"tag" : tag_map[tag]
})
return data
def normalized (features: np.ndarray ):
max_val = features.max (axis=0 )
min_val = features.min (axis=0 )
ranges = max_val-min_val
ranges[ranges < 1e-9 ] = 1.0
return (features-min_val)/ranges
def euclidean_dist (test, train ):
return np.sum ((test - train) ** 2 , axis=1 )
def predict_prob (test, train_data, k ):
train_features = train_data[:,:3 ]
dist = euclidean_dist(test, train_features)
k_indices = np.argpartition(dist, k)[:k]
k_tags = train_data[k_indices, 3 ].astype(int )
prob = np.bincount(k_tags, minlength=3 ) / k
return prob
def predict_type (test, train_data, k ):
return max (enumerate (predict_prob(test, train_data, k)), key=lambda x:x[1 ])[0 ]
def k_folds_cross_valid_acc (features: np.ndarray, k, k_fold ):
fold_size = int (len (features)/k_fold)
fold_accuracies = 0.0
confusion_mat = np.zeros([3 ,3 ], dtype=np.int32)
for i in range (k_fold):
start = i*fold_size
end = start+fold_size if i != k_fold-1 else len (features)
train_data = np.concatenate([features[:start], features[end:]])
correct = 0
test_data = features[start:end]
for t in test_data:
pred_type = predict_type(t[:3 ], train_data, k)
if pred_type == t[3 ]:
correct += 1
confusion_mat[int (t[3 ])][pred_type] += 1
fold_accuracies += correct/fold_size
return fold_accuracies/k_fold, confusion_mat
def count_roc (features: np.ndarray, k_fold, k_neighbor, confidence, genre ):
fold_size = int (len (features)/k_fold)
tp, tn, fp, fn = 0 , 0 , 0 , 0
for i in range (k_fold):
start = i*fold_size
end = start+fold_size if i != k_fold-1 else len (features)
train_data = np.concatenate([features[:start], features[end:]])
test_data = features[start:end]
for t in test_data:
prob = predict_prob(t[:3 ], train_data, k_neighbor)
if genre == t[3 ] and prob[genre] >= confidence:
tp += 1
elif genre != t[3 ] and prob[genre] < confidence:
tn += 1
elif genre != t[3 ] and prob[genre] >= confidence:
fp += 1
elif genre == t[3 ] and prob[genre] < confidence:
fn += 1
tpr = tp / (tp+fn) if (tp+fn) else 0.0
fpr = fp / (fp+tn) if (fp+tn) else 0.0
return fpr, tpr
def get_roc (features: np.ndarray, k_fold, k_neighbor, genre ):
confidence = np.arange(1.0 , 0 , -0.05 )
fpr_list = list ()
tpr_list = list ()
for conf in confidence:
fpr, tpr = count_roc(features, k_fold, k_neighbor, conf, genre)
fpr_list.append(fpr)
tpr_list.append(tpr)
if fpr_list[0 ] != 0.0 :
fpr_list.insert(0 , 0.0 )
tpr_list.insert(0 , 0.0 )
if fpr_list[len (fpr_list)-1 ] != 1.0 :
fpr_list.append(1.0 )
tpr_list.append(1.0 )
return [fpr_list, tpr_list]
def get_auc (result ):
auc = 0.0
fpr_list, tpr_list = result
if len (fpr_list) < 2 :
return 0.0
for i in range (len (fpr_list)-1 ):
auc += (fpr_list[i+1 ]-fpr_list[i])*(tpr_list[i]+tpr_list[i+1 ])/2
return auc
def get_value (dictionary: dict , target_value: int ):
return [key for key, value in dictionary.items() if value == target_value]
def plot_roc (results, ax ):
class_names = list (tag_map.keys())
for i in range (len (results)):
ax.plot(results[i][0 ], results[i][1 ], linewidth=2 , label=f"{class_names[i]} AUC: {results[i][2 ]:0.5 f} " )
ax.plot((0 , 1 ), (0 , 1 ), "--" , linewidth=1 , label="predicted line" )
ax.legend(loc="lower right" )
ax.set_title("ROC Curve" )
ax.set_xlabel("False Positive Rate" )
ax.set_ylabel("True Positive Rate" )
ax.set_xlim(0 ,1 )
ax.set_ylim(0 ,1 )
def plot_heatmap (heat_conf, ax ):
class_names = list (tag_map.keys())
im = ax.imshow(heat_conf)
ax.set_xticks(range (len (class_names)))
ax.set_xticklabels(labels=tag_map.keys(), rotation=45 , ha="right" , rotation_mode="anchor" )
ax.set_yticks(range (len (class_names)))
ax.set_yticklabels(labels=tag_map.keys())
ax.set_title("Three Distribution" )
for i in range (len (tag_map)):
for j in range (len (tag_map)):
value = heat_conf[i, j]
text_color = "black" if im.norm(value) > 0.5 else "white"
ax.text(j, i, value, ha="center" , va="center" , color=text_color, fontweight="bold" )
cbar = plt.colorbar(im, ax=ax)
cbar.set_label("Sample Count" )
if __name__ == "__main__" :
dataset = load_data("datingTestSet.txt" )
if not dataset:
print ("没有有效数据" )
exit()
feature_list = np.array([d["feature" ] for d in dataset], dtype=np.float64)
tag_list = np.array([d["tag" ] for d in dataset], dtype=np.int32)
normalized_feature = normalized(feature_list)
tag_list = tag_list.reshape(-1 , 1 )
feature_tag = np.hstack((normalized_feature, tag_list))
np.random.seed(42 )
np.random.shuffle(feature_tag)
best_k = 0
best_acc = 0
best_confusion = np.zeros([3 , 3 ], dtype=np.int32)
for i in range (3 , int (np.sqrt(len (feature_tag))), 2 ):
acc, confusion = k_folds_cross_valid_acc(feature_tag, i, 10 )
if best_acc < acc:
best_k = i
best_acc = acc
best_confusion = confusion
print (f"十折交叉验证的最佳 k 值为:{best_k} ,对应的准确率为:{best_acc:0.5 f} " )
results = list ()
for i in range (3 ):
res = get_roc(feature_tag, 10 , best_k, i)
auc = get_auc(res)
results.append([*res, auc])
fig, (ax1, ax2) = plt.subplots(1 , 2 , figsize=(12 , 5 ))
plot_roc(results, ax1)
plot_heatmap(best_confusion, ax2)
plt.tight_layout()
plt.show()
运行结果 相关免费在线工具 加密/解密文本 使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
RSA密钥对生成器 生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
Mermaid 预览与可视化编辑 基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
随机西班牙地址生成器 随机生成西班牙地址(支持马德里、加泰罗尼亚、安达卢西亚、瓦伦西亚筛选),支持数量快捷选择、显示全部与下载。 在线工具,随机西班牙地址生成器在线工具,online
Gemini 图片去水印 基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online