【算法分析】010 Editer
本帖最后由 currwin 于 2014-6-4 12:55 编辑【文章标题】【算法分析】010 Editer
【文章作者】currwin【F8LEFT】
【软件名称】010Editer v5.0
【软件大小】安装包 12.8 MB
【下载地址】自行搜索下载
【加壳方式】无
【编写语言】Qt (C++)
【操作平台】windows Xp sp3
【操作工具】OD,IDA Pro,VS 2010
【作者声明】在这个010Editor的破解随随便便都可以搜索出来的时候,研究它的算法就真的纯粹是感兴趣,为了学习而已,请各位大大多多指教。
------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
说实话,发完这篇帖子后就打算潜水一段时间了,最近比较忙,而且发的东西又没有什么人气,至少算法分析类是不打算再发的了。估计随便破个软件发到发布区里面都能够得到更多的热心与回复,唉,不说了,说多都是泪。直接进入正题吧。
010Editor这个软件是使用重启验证的,它的注册信息会直接保存注册表的
{HKEY_CURRENT_USER\Software\SweetScape\010 Editor}
这个位置上面,名称是Name与Password,直接就是明码保存了。
Password的格式有两个,这个我会在正文中说明。对应着Password,核心算法也可以分成两个,虽然他们都在同一个Call里面,但实际上计算部分是分开的。
另外,由于是使用了Qt进行开发,所以逆向出来的程序与我们平时看到的有点不一样,这给Keygen带来了不少的麻烦。
核心算法部分非常的长,如果贴ASM代码的话估计也没有几个人会看,就直接贴C源码了。
IDA还不会使用,所以代码阅读起来可能会有一点吃力,我尽量加上了注释,希望能够使算法流程更加清晰一点。
// 参数是固定的,u6 = 0x6,u3E08 = 0x3E08
unsigned int __thiscall sub_63CEB0(int this, unsigned int u6, unsigned int u3E08)
{
int v3; // esi@1
int v4; // eax@1
unsigned int v5; // ebp@1
int v6; // ebx@1
signed int v7; // edi@3
char P5; // bl@6
__int16 v9; // di@7
unsigned __int16 v10; // ax@7
unsigned int v11; // ecx@7
int v12; // edi@10
__int16 v13; // dx@14
unsigned __int8 v14; // cl@14
unsigned int v15; // eax@14
int v16; // eax@17
unsigned int result; // eax@23
unsigned int v18; // edx@28
bool bFlag1; // cf@28
bool bFlag2; // zf@28
int v21; // eax@30
signed __int32 *v22; // ecx@30
unsigned int v23; // edx@30
void *v24; // @21
int QName; // @17
int v26; // @7
char Pass; // @3
int v28; // @17
v3 = this;
v4 = *(_DWORD *)(this + 4);
v5 = 0;
v6 = this + 4;
*(_DWORD *)(this + 48) = 0;
*(_DWORD *)(this + 24) = 0;
*(_DWORD *)(this + 52) = 0;
if ( !*(_DWORD *)(v4 + 8) || !*(_DWORD *)(*(_DWORD *)(this + 8) + 8) )
return 0x93u;
StringToHex(Pass); // 将用户名转换为Hex
v7 = (signed int)&off_816580;
do
{
if ( (unsigned __int8)QString::operator__(v6, *(_DWORD *)v7) )
return 0xE7u;
v7 += 4;
}
while ( v7 < (signed int)&unk_816584 ); // P 用于判断注册码类型,只有 9C 和 AC 是接受的,其他的值均会失败
P5 = Pass;
if ( Pass == 0x9C ) // 当P = 9C 时V3+28为判断依据
{
LOBYTE(v26) = Pass ^ Pass;
v9 = (unsigned __int8)(Pass ^ Pass) + ((unsigned __int8)(Pass ^ Pass) << 8);// v9 = PP PP
*(_DWORD *)(v3 + 28) = (unsigned __int8)Chg_1(Pass ^ Pass);//return ((a1 ^ 0x18) + 61) ^ 0xA7;
//这里返回的值要大于6才会成功
v10 = Chg_2(v9); // 整数:(unsigned __int16)(((a1 ^ 0x7892) + 19760) ^ 0x3421) / 11;
v11 = *(_DWORD *)(v3 + 28);
*(_DWORD *)(v3 + 32) = v10;
if ( !v11 || !v10 || v10 > 0x3E8u ) // v11!=0,0<v10<3E8
return 0xE7u;
v12 = v11 < 2 ? v11 : 0;
}
else
{
if ( Pass == 0xFC )
{
v12 = 255; // P == FC时会失败,这些都可以忽略
*(_DWORD *)(v3 + 28) = 255;
*(_DWORD *)(v3 + 32) = 1;
*(_DWORD *)(v3 + 48) = 1;
}
else
{
if ( Pass != -84 // Pass == AC时,会进入这里
|| (v13 = (unsigned __int8)(Pass ^ Pass) << 8,// v13 = (P ^ P) << 8
v14 = Pass ^ Pass, // v14 = P ^ P
*(_DWORD *)(v3 // 这个参数在这种情况下是无效的
+ 28) = 2,
v15 = (unsigned __int16)Chg_2(v14 + v13),// 这里,v15 = Chg_2()的效果与上面的 Chg_2的效果是等价的
*(_DWORD *)(v3 + 32) = (unsigned __int16)v15,
!(_WORD)v15)
|| v15 > 0x3E8 ) // v15 != 0 且 v15 <= 0x3E8
return 0xE7u;
v5 = Chg_4( // 这里当P = AC时,v5为判断关键
((unsigned __int8)Pass ^ (unsigned __int8)Pass)// v5 = Chg_4(PP PP PP)
+ ((((unsigned __int8)Pass ^ (unsigned __int8)Pass)
+ (((unsigned __int8)P5 ^ (unsigned __int8)Pass) << 8)) << 8),
0x5B8C27u);
*(_DWORD *)(v3 + 52) = v5;
v12 = v5;
}
}
QString::toUtf8(v3 + 4, &QName); // 取名字QString
v28 = 0;
QByteArray::detach(&QName); // QName + C 就是指向名字的指针
v16 = Chg_3(*(char **)(QName + 0xC), Pass != -4, v12, *(_DWORD *)(v3 + 32));// Chg_3(UserName, P != FC, v12, v3 + 32)
// 利用加密表对用户名进行变换
if ( Pass != (_BYTE)v16 ) // 使P = (_BYTE)v16
goto LABEL_20; // 跳转则失败
if ( P5 != BYTE1(v16) ) // 使P = (_BYTE1(v16))
goto LABEL_50; // 跳转则失败
if ( Pass != (unsigned __int8)((unsigned int)v16 >> 16) )// 使P = BYTE2(V16)
{
LABEL_20: // 失败
v28 = -1;
if ( _InterlockedExchangeAdd((signed __int32 *)QName, 0xFFFFFFFFu) )
return 0xE7u;
v24 = (void *)QName;
LABEL_22:
qFree(v24);
return 0xE7u;
}
if ( Pass != BYTE3(v16) ) // 使P = BYTE3(v16)
{
LABEL_50: // 失败
v28 = -1;
if ( _InterlockedExchangeAdd((signed __int32 *)QName, 0xFFFFFFFFu) )
return 0xE7u;
v24 = (void *)QName;
goto LABEL_22;
}
if ( Pass == 0x9C )
{
v18 = -1;
bFlag1 = u6 < *(_DWORD *)(v3 + 28);
bFlag2 = u6 == *(_DWORD *)(v3 + 28);
goto LABEL_37; // v3 + 28 >= 6
}
if ( Pass != 0xFC )
{
if ( Pass == 0xAC && v5 )
{
v18 = -1;
bFlag1 = u3E08 < v5;
bFlag2 = u3E08 == v5;
LABEL_37:
v28 = -1;
if ( bFlag1 | bFlag2 )
{
if ( !_InterlockedExchangeAdd((signed __int32 *)QName, v18) )
qFree((void *)QName);
result = 0x2Du; // 成功
}
else
{
if ( !_InterlockedExchangeAdd((signed __int32 *)QName, v18) )
qFree((void *)QName);
result = 0x4Eu; // 早期版本
}
return result;
}
v22 = (signed __int32 *)QName;
v28 = -1;
v23 = -1;
LABEL_45:
if ( _InterlockedExchangeAdd(v22, v23) )
return 0xE7u;
v24 = (void *)QName;
goto LABEL_22;
}
v21 = Chg_4((unsigned __int8)Pass + (((unsigned __int8)Pass + ((unsigned __int8)Pass << 8)) << 8), v16);
v22 = (signed __int32 *)QName;
v23 = -1;
v28 = -1;
if ( !v21 )
goto LABEL_45;
*(_DWORD *)(v3 + 24) = v21;
if ( !_InterlockedExchangeAdd(v22, 0xFFFFFFFFu) )
qFree((void *)QName);
return 0x93u;
}
上面的就是核心的算法了,不过只需要大致的看看就可以了。
当程序返回0x2D的时候,便会注册成功,所以跟着这一条线索,
我们追踪一下它的骨架。
// 参数是固定的,u6 = 0x6,u3E08 = 0x3E08
unsigned int __thiscall sub_63CEB0(int this, unsigned int u6, unsigned int u3E08)
{... //省略无用的代码
StringToHex(Pass); // 将用户名转换为Hex
if ( Pass == 0x9C ) // 当P = 9C 时V3+28为判断依据
{
...
}
else
{
if ( Pass == 0xFC )
{
... // P == FC时会失败,这些都可以忽略
}
else
{
if ( Pass != 0xAC ) // Pass == AC时,进入这里才不会失败
return 0xE7u;
}
}
if ( Pass == 0x9C )
{
v18 = -1; //P = 0x9C
bFlag1 = u6 < *(_DWORD *)(v3 + 28); //这里开始对结果进行验证
bFlag2 = u6 == *(_DWORD *)(v3 + 28); //设置标志旗
goto LABEL_37; // v3 + 28 >= 6
}
if ( Pass != 0xFC )
{
if ( Pass == 0xAC && v5 )
{
v18 = -1; //P = 0xAC
bFlag1 = u3E08 < v5; //设置标志旗
bFlag2 = u3E08 == v5;
LABEL_37:
v28 = -1;
if ( bFlag1 | bFlag2 )
{
...
result = 0x2Du; // 成功
}
else
{
...
}
return result;
}
... //下面的都只会返回失败
}
上面一段便是验证的代码了,当然,估计如果翻译为switch语句的话会更加清晰,不过就算是用if else 的组合,也是足够让我们看到程序在做些什么的。
我们会看到,返回0x2D的地方只有一个, 并且,只有当 P = 0x9C 或者 0xAC的时候才会进入判定成功的段,其他的值都会失败。所以根据P的具体的值,算法被分成了两个基本上完全不同的两个验证部分. 现在我们来看一看P = 0x9C 的算法
// 参数是固定的,u6 = 0x6,u3E08 = 0x3E08
unsigned int __thiscall sub_63CEB0(int this, unsigned int u6, unsigned int u3E08)
{
v3 = this;
StringToHex(Pass); // 将用户名转换为Hex
P5 = Pass;
if ( Pass == 0x9C ) // 当P = 9C 时V3+28为判断依据
{
LOBYTE(v26) = Pass ^ Pass;
v9 = (unsigned __int8)(Pass ^ Pass) + ((unsigned __int8)(Pass ^ Pass) << 8);// v9 = PP PP
*(_DWORD *)(v3 + 28) = (unsigned __int8)Chg_1(Pass ^ Pass);//return ((a1 ^ 0x18) + 61) ^ 0xA7;
//这里返回的值要大于6才会成功
v10 = Chg_2(v9); // 整数:(unsigned __int16)(((a1 ^ 0x7892) + 19760) ^ 0x3421) / 11;
v11 = *(_DWORD *)(v3 + 28);
*(_DWORD *)(v3 + 32) = v10;
if ( !v11 || !v10 || v10 > 0x3E8u ) // v11!=0,0<v10<3E8
return 0xE7u;
v12 = v11 < 2 ? v11 : 0;
}
QString::toUtf8(v3 + 4, &QName); // 取名字QString
v28 = 0;
QByteArray::detach(&QName); // QName + C 就是指向名字的指针
v16 = Chg_3(*(char **)(QName + 0xC), Pass != -4, v12, *(_DWORD *)(v3 + 32));// Chg_3(UserName, P != FC, v12, v3 + 32)
// 利用加密表对用户名进行变换
if ( Pass != (_BYTE)v16 ) // 使P = (_BYTE)v16
// 失败; // 跳转则失败
if ( P5 != BYTE1(v16) ) // 使P = (_BYTE1(v16))
//失败; // 跳转则失败
if ( Pass != (unsigned __int8)((unsigned int)v16 >> 16) )// 使P = BYTE2(V16)
//失败;
if ( Pass != BYTE3(v16) ) // 使P = BYTE3(v16)
//失败;
if ( Pass == 0x9C )
{
v18 = -1;
bFlag1 = u6 < *(_DWORD *)(v3 + 28);
bFlag2 = u6 == *(_DWORD *)(v3 + 28);
goto LABEL_37; // v3 + 28 >= 6
}
LABEL_37:
v28 = -1;
if ( bFlag1 | bFlag2 )
{
if ( !_InterlockedExchangeAdd((signed __int32 *)QName, v18) )
qFree((void *)QName);
result = 0x2Du; // 成功
}
...
}
这样一来就清晰多了。
首先,
result = Chg_1(P^P]), ;这里计算出来的v11便是最终比较的结果了。用result与u6进行对比,只有当result大于6的时候才会成功。
v9 = 高位 P^P,低位 P^P;v9 = (unsigned __int8)(Pass ^ Pass) + ((unsigned __int8)(Pass ^ Pass) << 8)
紧接着便对v9进行变换
v10 = Chg_2(v9) ; 同样的,这里计算出来的结果要在 1 到 0x3E8之间。
这样,基本的数据初始化便算完成了。接着是对用户名进行加密。
Chg_3(UserName, P != FC, v12, v3 + 32);
因为这里v12 = result < 2 ? v11 : 0; 但result必须是大于6的,故这里v12 恒等于0;
而v3 + 32 便是用来存储v10的地方。所以整理一下,这个函数应该是这样的
v16 = Chg_3(UserName, 1, 0, v10);
最后,这里计算出来的v16的各个字节分别与 P,P,P,P进行比较,只有他们都相等了才会进入判断的地方。
最后再看一看,这种情况下只使用到了P - P,所以密码的长度为 8*2 = 16个。
那么,P = 9C 的情况到这里便结束了,接下来看 P = 0xAC的情况。
// 参数是固定的,u6 = 0x6,u3E08 = 0x3E08
unsigned int __thiscall sub_63CEB0(int this, unsigned int u6, unsigned int u3E08)
{
v3 = this;
v4 = *(_DWORD *)(this + 4);
v5 = 0;
v6 = this + 4;
*(_DWORD *)(this + 48) = 0;
*(_DWORD *)(this + 24) = 0;
*(_DWORD *)(this + 52) = 0;
StringToHex(Pass); // 将用户名转换为Hex
P5 = Pass;
if ( Pass != 0xAC // Pass == AC时,会进入这里
|| (v13 = (unsigned __int8)(Pass ^ Pass) << 8,// v13 = (P ^ P) << 8
v14 = Pass ^ Pass, // v14 = P ^ P
v15 = (unsigned __int16)Chg_2(v14 + v13),// 这里,v15 = Chg_2()的效果与上面的 Chg_2的效果是等价的
*(_DWORD *)(v3 + 32) = (unsigned __int16)v15,
!(_WORD)v15)
|| v15 > 0x3E8 ) // v15 != 0 且 v15 <= 0x3E8
return 0xE7u;
v5 = Chg_4( // 这里当P = AC时,v5为判断关键
((unsigned __int8)Pass ^ (unsigned __int8)Pass)// v5 = Chg_4(PP PP PP)
+ ((((unsigned __int8)Pass ^ (unsigned __int8)Pass)
+ (((unsigned __int8)P5 ^ (unsigned __int8)Pass) << 8)) << 8),
0x5B8C27u);
*(_DWORD *)(v3 + 52) = v5;
v12 = v5;
}
}
QString::toUtf8(v3 + 4, &QName); // 取名字QString
v28 = 0;
QByteArray::detach(&QName); // QName + C 就是指向名字的指针
v16 = Chg_3(*(char **)(QName + 0xC), Pass != -4, v12, *(_DWORD *)(v3 + 32));// Chg_3(UserName, P != FC, v12, v3 + 32)
// 利用加密表对用户名进行变换
if ( Pass != (_BYTE)v16 ) // 使P = (_BYTE)v16
// 失败; // 跳转则失败
if ( P5 != BYTE1(v16) ) // 使P = (_BYTE1(v16))
//失败; // 跳转则失败
if ( Pass != (unsigned __int8)((unsigned int)v16 >> 16) )// 使P = BYTE2(V16)
//失败;
if ( Pass != BYTE3(v16) ) // 使P = BYTE3(v16)
//失败;
if ( Pass == 0xAC && v5 )
{
v18 = -1;
bFlag1 = u3E08 < v5;
bFlag2 = u3E08 == v5;
v28 = -1;
if ( bFlag1 | bFlag2 )
{
result = 0x2Du; // 成功
}
...
}
当P == AC时,貌似加密方法又复杂了一点。不过也是一样,是一直线的,没有任何的循环,分析起来也是同样简单的。
首先是 v13 = (P ^ P) << 8; v14 = P ^ P
v15 = Chg_2(v13 + v14); //这里仔细想一想,v13 + v14 不就等于上面的v9么,然后 v15 就等价于上面的v10了,所以这部分是一样的。
同样的,对v15的要求是 0 < v15 < 0x3E9
接着是
v5 = Chg_4(P^P + (P^P)<<8 + (P^P)<<16); //这里对v5的要求是 v5 > 0x3E08
这些就是初始化的操作了
紧接着就是对用户名进行变换了
Chg_3(UserName, P != FC, v12, v3 + 32);
还是那个样子,P == AC != FC, v12 = v5, v3 + 32 = v15
整理一下就是
v16 = Chg_3(UserName, 1, v5, v15);
变换完的v16再各位与P,P,P,P进行比较,都相同的话就成功了。
注意一下,这里用到的密码位为 P - P,密码长度为 10 * 2 =20个。
前面的是16个,呵呵,没想到密码居然是长度可变的。
好了,主体部分的算法基本上就是这些了,接下来给出一下Chg_1,2,3,4的具体流程吧。
Chg_1:
char __cdec Chg_1(char a1)
{
return ((a1 ^ 0x18) + 61) ^ 0xA7;
}
Chg_2:
int __cdec Chg_2(__int16 a1)
{
int result; // eax@1
result = (unsigned __int16)(((a1 ^ 0x7892) + 0x4D30) ^ 0x3421) / 11;
if ( (unsigned __int16)(((a1 ^ 0x7892) + 0x4D30) ^ 0x3421) % 11 ) //这里要求经过一堆变换后的结果是11的倍数才不会返回0
result = 0;
return result;
}
Chg_4:
int __cdecl Chg_4(int a1, int Key)
{
int v2; // ecx@1
v2 = ((Key ^ a1 ^ 0x22C078) - 180597) ^ 0xFFE53167;
return (v2 & 0xFFFFFFu) % 0x11 == 0 ? (v2 & 0xFFFFFFu) / 0x11 : 0; //同样的,也是要求经过一堆变换后的结果是0x11的倍数
}
Chg_3:
_DWORD __cdecl sub_63B3C0(char *Name, int a2, int a3, int a4)
{
char *v4; // eax@1
int i; // ebx@1
int v6; // ebp@1
char *v7; // edx@1
char v8; // cl@2
int v9; // edi@4
int v10; // esi@4
int v11; // eax@5
int v13; // @4
int v14; // @4
int iNameLen; // @3
v4 = Name;
i = 0;
v6 = 0;
v7 = Name + 1;
do
v8 = *v4++;
while ( v8 );
iNameLen = v4 - v7;
if ( (signed int)(v4 - v7) > 0 )
{
v9 = 15 * a4;
v14 = 0;
v13 = 0;
v10 = 17 * a3;
do // 对名字的每一个进行转换
{
v11 = toupper((unsigned __int8)Name);// 小写转大写
if ( a2 ) // 这里总是成立,因为P != FC
v6 = Name_Table[(unsigned __int8)v13] // 取加密表进行变换
+ Name_Table[(unsigned __int8)v9]
+ Name_Table[(unsigned __int8)v10]
+ Name_Table[(unsigned __int8)(v11 + 47)]
* (Name_Table[(unsigned __int8)(v11 + 13)] ^ (v6 + Name_Table));
else // 下面的可以忽略
v6 = Name_Table[(unsigned __int8)v14]
+ Name_Table[(unsigned __int8)v9]
+ Name_Table[(unsigned __int8)v10]
+ Name_Table[(unsigned __int8)(v11 + 23)]
* (Name_Table[(unsigned __int8)(v11 + 63)] ^ (v6 + Name_Table));
v13 += 19;
v14 += 7;
++i;
v10 += 9;
v9 += 13;
}
while ( i < iNameLen );
}
return v6;
}
这几个函数都是没有什么好讲的,因为都是很简单的。
算法分析完了,要如何去进行Keygen才是关键所在。
一个最简单的办法就是随机生成P~P,然后代入算法里面验证是否满足算法,满足的话就输出结果,不满足的话就继续进行循环,直到找到一组可以使用的Key为止。
这个方法很好用,不过在这里就难说了。
只看看P = 0x9C 的情况就可以知道这方法在这里是多么的糟糕了。
v9 = (unsigned __int8)(Pass ^ Pass) + ((unsigned __int8)(Pass ^ Pass) << 8); 这里,v9由4个密码运算得出
v10 = Chg_2(v9); Chg_2的计算中,要求v9经过一堆变换后,必须是11的倍数,这是非常难以满足的。
看看 Chg_2 与 Chg_4 的代码中,要求对参数进行奇怪的变换,最后居然还要求是11 或者 是 0x11 的倍数。这得多难满足。并且他们的参数都是由多个密码组合而成的,这样想random出正确的结果就更加难了,所以这个方法不行。反正我就试过用计算机random了很久也random不出来一个正确的Key。
还有另外一个方法。
我们设 x为注册码,y为判断结果,通常是 y = f(x) 求出结果的。假如,我们可以找到一个函数g,使得 x = g(y),不就可以直接通过结果逆推出注册码么。
还是同样的例子,在上面中,假如找到了 Chg_2 的 逆函数,Chg_2_1,使得 v9 = Chg_2_1(v10)。那么我们就只需要random一个数据v10,但是却可以确定两个数据P^P,P^P。
把这个想法扩展一下,可以得到keygen的大致思路。
P == 0x9C 时,
random出 v10,和 result
调用 Chg_1_1(result) 可以确定 P^P的值。
调用 Chg_2_1(v10) 可以确定P^P,P^P的值
调用v16 = Chg_3(UserName, 1, 0, v10);可以确定 P,P,P,P的值。
最后,P = (P ^ P) ^ P 可以求出P
这样,密码就出来了。
P == 0xAC时,
random出 v5与 v15
调用 Chg_2_1(v15) 可以确定P^P, P^P的值
调用Chg_4_1(v5) 可以确定P^P, P^P, P^P的值。
调用v16 = Chg_3(UserName, 1, v5, v15);可以确定P, P, P, P的值。
最后:
P = (P^P) ^ P;
P = (P^P) ^ P;
P = (P^P) ^ P;
P = (P^P) ^ P;
P = (P^P) ^ P;
这样又一组密码出来了,那么,只需要保证能够找到Chg_1,Chg_2,Chg_4的逆算法 Chg_1_1, Chg_2_1, Chg_4_1就可以了。
Chg_1_1
char Chg_1_1(char a1) //Chg_1的逆算法
{
return ((a1 ^ 0xA7) - 61) ^ 0x18;
}
Chg_2_1:
unsigned short Chg_2_1(unsigned short a1) //Chg_2的逆函数。不求逆估计是算不出来的
{
return ((((a1 * 11) ^ 0x3421) - 0x4D30) ^ 0x7892);
}
Chg_4_1:
int Chg_4_1(int v2, int key) //Chg_4的逆函数
{
int a1;
v2 = v2 * 0x11;
a1 = ((v2 ^ 0xFFE53167) + 0x2C175) ^ 0x22C078 ^ key;
return (a1 & 0xFFFFFF);
}
虽然Keygen是出来了,不过这个软件貌似还会不定时去链接网络验证key是否有效,不过,这个也不难去掉,大家搞起吧。
具体的Keygen代码我就不给了,因为不难啊。附上几张图,玩一下:
【版权声明】: 本文原创于F8LEFT, 转载请注明作者并保持文章的完整, 谢谢!
2014.6.4
主要是现在算法分析比较高深的技术现在论坛的小白太多看不懂主要是我也是其中一员。楼主发的技术贴都比较有质量的, 希望楼主能够坚持,给我们这些小白留下以后学习的资源。 楼主分析得很详细,来学习并支持。谢谢@Thanks! 谢谢分享 很难看懂 不过慢慢来 算法这块还是比较难啊 虽然现在回复的人 比较少,楼主不要灰心,因为很多小菜还没接触到算法这一块,
也包括我,不过这个以后拿出来就火了,就跟以前的NET的帖子不火,现在NET这块
直接火爆了。 我F8大大是大牛,不解释 请问学会算法分析该从什么地方入手呢? 分析的很详细 不愧是大神,就是厉害