《目錄》
難題的定義
高考的難題對出卷人不算難題;那些不知道答案覺得難,知道就不難的題也不是難題;現在沒人會解的題也不一定是難題,那麼難題是什麼?
難題在計算機科學中就是 NP問題,如著名的旅行商問題。
旅行商問題大致原意,送餐,外賣小哥帶着外賣 從任意一個外賣點(下圖的紅點)出發 送到所有外賣點 再回到出發的外賣點。
p.s. 生活中外賣小哥只能從餐館出發,但 NP 問題不是,餐館和外賣目的地只是外賣點,都是下圖的某個紅點。
NP困難
爲什麼說 NP問題 是難題 ?
因爲對於 NP問題 並沒有好的算法解決。
理論上證明,這個問題它就幾乎不可能存在什麼好的解法,幾乎就是隻能把所有外賣點的所有排列組合都計算一遍,看看其中哪個最短,時間複雜度是O(n!)。
說 "幾乎" 是因爲這裏面涉及到一個數學猜想,叫 " ",也就是說也許還有存在簡單算法的一線希望。
猜想
假設有一個規模爲 n 的問題,如果我們能在多項式時間內找到問題的正確的解,稱爲 P 問題。
是能在多項式時間內解出的問題,P 的階數 ,意思是:計算時間在 n 的常數次方內 O( )。
P 問題,四字概括:高 效 正 解。
與 P 問題對應的是 NP 問題,旅行商問題也是其中之一。
NP 問題的解往往是近似解,所以 NP 問題是值當得到一些近似解時,能否高效的找出其中某個近似解是問題的正確的解。
舉個例子,劉強西要去洗衣服,他洗 1 件衣服的時間爲 2 分鐘,洗 5 件衣服的時間爲 10 分鐘,洗 10 件衣服的時間爲 20 分鐘,這就是 P 問題。
現在我們假設劉強西洗 1 件這種衣服的時間爲 2 分鐘,但洗 5 件的時間變爲 32 分鐘,洗 10 件的時間變爲 1024 分鐘,這就是 NP 完全問題。
已經證明了所有 P 問題都是 NP 問題,但 NP 問題是不是 P 問題還沒有答案。
若 所有 NP 問題都是 P 問題, 猜想成立; 若 NP 問題中有問題不是 P 問題 , 猜想成立。
大部分計算機科學家深信後者。
猜想 :即使能夠高效的判定問題的解,也不一定能高效的找出問題的解。
NP 完全問題
有一部分 NP 問題被稱爲 NP 完全問題,一般而言,是 NP 問題中最難的問題。
只要證明任意一個 NP 完全問題是 P 問題,也就能證明 。
所以,得先找到 NP 完全問題 才能證明,也因此,第一個找到 NP 完全問題 的斯蒂芬·庫克 獲得了圖靈獎。
如果想擺弄一下,可以讀一讀《數學聊齋》。
旅行商問題的外賣解法
假設您手下,有 3 個外賣小哥,小a,小b,小c,分別根據他們的思維套路做出了不同的決定。
啓發式方法,可以理解爲 “思維快捷方式”,就是思維的套路。
比如,借刀殺人、過河拆橋、順手牽羊 這些都是基本的套路,您看電視、小說啊,基本都會用到的。
但不論如何,沒有啓發式是不行的,您不可能面對什麼事情都從頭推演。
作爲社會棟樑,您得多掌握一些高級的啓發式。
使用啓發式解決難題,得有個條件:
- 視角
所謂視角,是您怎麼看這個東西,相當於建立一個座標系。
那麼啓發式,則是有了座標系之後,您怎麼在這個座標系裏 “走”。
啓發式的定義:在某個視角里,使用這個規則能夠得到一個解 —— 您受此啓發,也許可以把這個規則用在別的問題上,得到別的解。
最近鄰居法
小a,十分憨厚的、務實。ta 認爲沒必要追求 "最短路線",找一條差不多的路線就可以了。
用計算機的話來講,這是 "啓發式算法" 的核心思想,對於這個外賣問題有一個 "最近鄰居法"。
從任何一個外賣點出發,每次訪問的下一個外賣點是距離當前外賣點且未被訪問的最近的外賣點。
最近鄰居法,計算速度十分短但模擬絕大部分情況得到的平均結果,對比最短路線的期望長了 25%。
小b,很機靈。ta認爲爲了弄到每月的獎金,肯定得比 小a 快,他們是競爭關係啊~
小b 很是認真的分析了"最近鄰居法",發現當大量外賣點是一條直線或近似直線時,最近鄰居法 效果十分差勁。
無論選擇什麼外賣點做出發點,最近鄰居法總會在某一個點集上失效。
總是挑選最近的外賣點,實在是比較死板呀。
小b 認爲應該把 每次訪問的下一個外賣點是距離當前外賣點且未被訪問的最近的外賣點 改成 最接近的端點。
端點:當前外賣點與其相連的外賣點不超過 1 的頂點。
每個外賣點(端點)形成自己的射線,最後合併如圖示,出發點就選擇只有一條射線的端點一般是最邊的端點。
小c,是一個擁有完美思想的傢伙。
只想着還能更好,但目前還沒想出來。
後來,只能退求其次。去改進 "啓發式算法" 。
ta提出了一個十分數學化的算法,能保證任何情況下此算法給出的路線最多比真實的最短路線長 50%。
後來,還是覺得不完美繼續改進。
於是,長 50% 縮短爲 49.999999999999999999999999999999999999999999999999999% ...... (p.s. 小數點後 49 個 9)
採自真實案例,不是瞎掰。走到 小c 程度的分別是 倫敦帝國學院的教授 和 斯坦福大學和麥吉爾大學的一個聯合團隊。
小談 · 圖靈停機問題
有時,會在不經意間寫出死循環的代碼。
運行後,大腦也深深的受到影響,哎呀,快死機了。
於是,就有人想可不可以寫一個函數判斷自己寫的程序是否會產生死循環 ?
That's a good idea,爲了讓更多人加入我們,不得已把其命名爲 圖靈停機問題。
大致長這樣。
這個測試函數叫 Test( ) 吧,有 content 、in 倆個參數 ,Test(content, in)。
content : 要測試的程序代碼。
in : 要測試程序的輸入。
當 content 運行時輸入 in ,Test() 返回 -1 表示出現死循環,返回 0 表示正常,自定義。
寫一個完整的程序:
int main(){
if( Test(code, str) ){
爲真,死循環
}else{
正常
}
return (0);
}
運用反證法。
反證法:假設要證明的命題不成立,從而推導出矛盾的證明方法。
如果把上面的代碼放進新程序的 Test(content, in),這時候上面的代碼簡稱爲 M。
這時, 因爲新程序的 in = M,Test 的 content = M ,in = M。
因爲一段代碼,即可以是程序,又可以是輸入的字符串。
int main(){
if( Test(M, M) ){
爲真,死循環
}else{
正常
}
return (0);
}
按照之前的設計,若 Test(M, M) 返回值爲真時是死循環,以 M 作爲輸入,如果是死循環,那麼其實我們得不到返回值;反之,也會矛盾。
因此,這是一個 不可計算問題。
不可計算問題 好像 有些事情,不能事先知道結果只能等待其發生,抑或是,有些事情看着發生卻不能解釋爲什麼發生......
其實我感覺沒寫明白,安利《程序員的數學》第 8 章。
總之,反證法特別重要需要掌握,再看看如何證明 是無理數。
使用反證法。
① 假設 是有理數,因爲那時候人們認知的數系都是由有理數組成。② 存在整數 a, b,使 = (a ≠0)
③ 將等式左右倆邊同時開方。那時候剛有乘法,就有了平方(自己乘自己),於是就誕生了平方的逆運算 --- 開方。
④ 去分母得 2a² = b²
⑤ 等式左邊有 2n+1(奇數個) 質因數 2。
⑥ 等式右邊有 2n(偶數個) 質因數 2。
⑦ 根據質因數分解唯一定理,等式左右倆邊每個質因數的個數相同,而 2n+1 = 2n 矛盾了。
⑧ 因此, 不是有理數。
基礎算法模版
-
迭代加深搜索(可代替BFS)
特點:
限定下界的深度優先搜索,允許深度優先搜索搜索 k 層搜索樹,若沒有發現可行解,再將 k+1 代入後再進行一次以上步驟,直到搜索到可行解。這個 “模仿廣度優先搜索” 搜索法比起廣搜是犧牲了時間,但節約了空間。
多用於空間不足,時間充裕時,採用 ta 代替 BFS。
void search(int depth) // depth表示深度
{
if ( finished /* 得到了合適的解 */ ){
// 已經得到了合適的解,接下來輸出或解的數量加1
return;
}
if (depth == 0) return; // 無解
// 擴展結點,嘗試每一種可能
for (int i=0; i<n; i++){
// 處理結點
…
// 繼續搜索
search(depth-1, …);
// 部分問題需要恢復狀態,如N皇后問題
…
}
}
const int max_depth = 10; // 限定的最大搜索深度
void IDS(){
for (int i=1; i<=max_depth; i++)
search(i, …);
}
-
DFS(一條路走到黑)
特點:不易立即結束,遞歸實現易棧溢出,多用於回溯類搜索。
圖示:
/* 遞歸版 */
void DFS(int depth, ...) {
if ( finished /* depth==n */ ){ // 判斷邊界
// 深度超過範圍,說明找到了一個解。
// 找到了一個解,對這個解進行處理,如:輸出、解的數量加1、更新目前搜索到的最優值等
return; // 返回上一步
}
// 擴展結點,嘗試每一種可能,
for (int i=0; i<n; i++) {
// 處理結點,標記狀態 或其TA處理...
DFS(depth+1); // 繼續搜索下一個
// 部分問題需要恢復狀態,如N皇后問題 或其TA處
}
return;
}
/* ------------------------------------------------------------------------------------------------- */
/* 遞推版 */
stack <int> s; // 存儲狀態
void DFS(int v, ...){
s.push(v); // 初始狀態入棧
while (!s.empty()) {
int x = s.top(); s.pop();
// 處理結點
if ( finished /* x達到某種條件 */ ) {
// 獲取狀態
// 輸出、解的數量加1、更新目前搜索到的最優值等 ...
return;
}
// 尋找下一狀態。當然,不是所有的搜索都要這樣尋找狀態。
// 注意,這裏尋找狀態的順序要與遞歸版本的順序相反,即逆序入棧
for (i = n-1; i>=0; i--) {
s.push(... /* i對應的狀態 */);
}
}
cout<<"No Solution."; // 如果運行到這裏,說明無解
}
-
BFS(一石激起千層浪)
特點:易立即結束,佔用空間大,訪問圖時,需要判重,多用於 最小步數、深度最小。
圖示:
queue <int> q; // 存儲狀態
bool try_to_insert(int state); // 結點入隊前判斷狀態是否重複,以免重複搜索
void init_lookup_table(); // 使用散列表可以提高查找的效率
void BFS(){
// init_lookup_table(); // 判重之前的初始化
q.push(…); // 初始狀態入隊
while ( !q.empty() ){
int s = q.front(); q.pop(); // 獲取狀態
// 處理結點
if ( finished /* s == 某種條件 */ ){
// 輸出,或做些什麼...
return;
}
// 擴展狀態,嘗試每一種可能
for(i=0; i<n; i++){
int s;
// if(try_to_insert(s))
q.push(s);
q.push(s);
}
}
cout<<"No Solution."; // 如果運行到這裏,說明無解
}
-
隨機數據生成器
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <string.h>
#include <iostream>
using namespace std;
char num[3];
/***
第一步: 把 main() 函數以外的代碼(結構、類、函數、...的聲明和定義)拷貝進來!沒有就不用。
***/
void chgnum(int n){
num[0] = num[1] = num[2] = '\0';
if( n<10 )
num[0] = n+'0'; // +48
else
num[0] = n/10+'0', num[1] = n%10+'0';
}
void ans(char *in_file,char *out_file){
freopen(in_file,"r",stdin);
freopen(out_file,"w",stdout);
/***
第二步: 把 main() 中的代碼刪掉“return 0;”之後拷貝進來!cin/cout改爲scanf/printf!
***/
return ;
}
void gen(char *file, unsigned int rand_plus){
freopen(file,"w",stdout);
srand( clock()+rand_plus );
/***
第三步: 數據生成器, 使用scanf/printf!
e.g. A+B Problem 生成器:
int a, b; a = rand()%5000, b = rand()%5000;
printf("%d, %d\n", a, b);
***/
return ;
}
int main(){
int N;
printf("輸入製作的數據組數(100以內): ");
scanf("%d",&N);
puts("-----------------------------數據製作開始----------------------------");
srand( (unsigned)time(NULL) );
int P = rand()%100;
char Fname1[512],Fname2[512];
unsigned START = clock();
for(int i=1; i<=N; i++ ){
freopen("CON","w",stdout);
printf("製作第 %d 組數據中...\n",i);
strcpy(Fname1,"***"); // 第四步!把***改爲你想要的文件名!
strcpy(Fname2,"***");
chgnum(i);
strcat(Fname1,num);
strcat(Fname2,num);
strcat(Fname1,".in ");
strcat(Fname2,".out ");
gen(Fname1,P);
ans(Fname1,Fname2);
}
freopen("CON","w",stdout);
printf("製作完成!用時 %d 毫秒\n",clock()-START);
return 0;
}
-
高精度
#include <iostream>
using namespace std;
const int MAX = 100;
struct hp
{
int num[MAX];
hp & operator =(const char *);
hp & operator =(int);
hp();
hp(int);
/* 負數運算不支持 */
bool operator > (const hp &) const;
bool operator < (const hp &) const;
bool operator <= (const hp &) const;
bool operator >= (const hp &) const;
bool operator != (const hp &) const;
bool operator ==(const hp &) const;
hp operator +(const hp &) const;
hp operator -(const hp &) const;
hp operator *(const hp &) const;
hp operator /(const hp &) const;
hp operator %(const hp &) const;
hp & operator +=(const hp &);
hp & operator -=(const hp &);
hp & operator *=(const hp &);
hp & operator /=(const hp &);
hp & operator %=(const hp &);
};
// num[0]用來保存數字位數。另外,利用10000進制可以節省空間和時間。
hp & hp::operator =(const char *c)
{
memset(num, 0, sizeof(num));
int n = strlen(c), j = 1, k = 1;
for (int i = 1; i <= n; i++)
{
if (k == 10000) // 10000進制,4個數字纔算1位。
j++, k = 1;
num[j] += k * (c[n - i] - '0');
k *= 10;
}
num[0] = j;
return *this;
}
hp & hp::operator =(int a)
{
char s[MAX];
sprintf(s, "%d", a);
return *this = s;
}
hp::hp()
{
memset(num, 0, sizeof(num));
num[0] = 1;
}
hp::hp(int n)
{
*this = n;
}
// 如果位數不等,大小是可以明顯看出來的。如果位數相等,就需要逐位比較。
bool hp::operator >(const hp & b) const
{
if (num[0] != b.num[0])
return num[0] > b.num[0];
for (int i = num[0]; i >= 1; i--)
if (num[i] != b.num[i])
return (num[i] > b.num[i]);
return false;
}
bool hp::operator <(const hp & b) const
{
return b > *this;
}
bool hp::operator <=(const hp & b) const
{
return !(*this > b);
}
bool hp::operator >=(const hp & b) const
{
return !(b > *this);
}
bool hp::operator !=(const hp & b) const
{
return (b > *this) || (*this > b);
}
bool hp::operator ==(const hp & b) const
{
return !(b > *this) && !(*this > b);
}
// 注意:最高位的位置和位數要匹配。
hp hp::operator +(const hp & b) const
{
hp c;
c.num[0] = max(num[0], b.num[0]);
for (int i = 1; i <= c.num[0]; i++)
{
c.num[i] += num[i] + b.num[i];
if (c.num[i] >= 10000) // 進位
{
c.num[i] -= 10000;
c.num[i + 1]++;
}
}
if (c.num[c.num[0] + 1] > 0)
c.num[0]++; // 9999+1,計算完成後多了一位
return c;
}
// 只支持大數減小數~
hp hp::operator -(const hp & b) const
{
hp c;
c.num[0] = num[0];
for (int i = 1; i <= c.num[0]; i++)
{
c.num[i] += num[i] - b.num[i];
if (c.num[i] < 0) // 退位
{
c.num[i] += 10000;
c.num[i + 1]--;
}
}
while (c.num[c.num[0]] == 0 && c.num[0] > 1)
c.num[0]--; // 100000000-99999999
return c;
}
hp & hp::operator +=(const hp & b)
{
return *this = *this + b;
}
hp & hp::operator -=(const hp & b)
{
return *this = *this - b;
}
hp hp::operator *(const hp & b) const
{
hp c;
c.num[0] = num[0] + b.num[0] + 1;
for (int i = 1; i <= num[0]; i++)
{
for (int j = 1; j <= b.num[0]; j++)
{
c.num[i + j - 1] += num[i] * b.num[j]; // 和小學豎式的算法一模一樣
c.num[i + j] += c.num[i + j - 1] / 10000; // 進位
c.num[i + j - 1] %= 10000;
}
}
while (c.num[c.num[0]] == 0 && c.num[0] > 1)
c.num[0]--; // 99999999*0
return c;
}
hp & hp::operator *=(const hp & b)
{
return *this = *this * b;
}
hp hp::operator /(const hp & b) const
{
hp c, d;
c.num[0] = num[0] + b.num[0] + 1;
d.num[0] = 0;
for (int i = num[0]; i >= 1; i--)
{
// 以下三行的含義是:d=d*10000+num[i];
memmove(d.num + 2, d.num + 1, sizeof(d.num) - sizeof(int) * 2);
d.num[0]++;
d.num[1] = num[i];
// 以下循環的含義是:c.num[i]=d/b; d%=b;
while (d >= b)
{
d -= b;
c.num[i]++;
}
}
while (c.num[c.num[0]] == 0 && c.num[0] > 1)
c.num[0]--; // 99999999/99999999
return c;
}
hp hp::operator %(const hp & b) const
{
hp c, d;
c.num[0] = num[0] + b.num[0] + 1;
d.num[0] = 0;
for (int i = num[0]; i >= 1; i--)
{
// 以下三行的含義是:d=d*10000+num[i];
memmove(d.num + 2, d.num + 1, sizeof(d.num) - sizeof(int) * 2);
d.num[0]++;
d.num[1] = num[i];
// 以下循環的含義是:c.num[i]=d/b; d%=b;
while (d >= b)
{
d -= b;
c.num[i]++;
}
}
while (c.num[c.num[0]] == 0 && c.num[0] > 1)
c.num[0]--; // 99999999/99999999
return d;
}
hp & hp::operator /=(const hp & b)
{
return *this = *this / b;
}
hp & hp::operator %=(const hp & b)
{
return *this = *this % b;
}
/* ------------------------------------------------------------------------------------------------- */
// 重載輸入輸出
ostream & operator <<(ostream & o, hp & n)
{
o << n.num[n.num[0]];
for (int i = n.num[0] - 1; i >= 1; i--)
{
o.width(4);
o.fill('0');
o << n.num[i];
}
return o;
}
istream & operator >>(istream & in, hp & n)
{
char s[MAX];
in >> s;
n = s;
return in;
}
// 快速冪
long long quickpow(long long a, long long b)
{
long long d = 1, t = a;
while (b > 0)
{
if (t == 1)
return d;
if (b % 2)
d = d * t;
b /= 2;
t *= t;
}
return d;
}
高精度測試>>
int main( ){
hp a , b; a = 100, b = 0;
cin >> a >> b;
hp result = a + b;
cout <<"+ " << result << endl;
result = a - b;
cout <<"- " << result << endl;
result = a * b;
cout <<"* " << result << endl;
result = a / b; // 被除數不能爲0
cout <<"/ " << result << endl;
result = a == b ? true : false;
cout << "== " << result <<endl;
result = a % b;
cout << "% " << result << endl;
return 0;
}
-
競賽測試提交模板
#include <iostream>
#include <fstream>
#include <cstring>
#include <algorithm>
#include <cstdlib>
using namespace std;
#define DEBUG
int n,m;
int a[100000];
int main()
{
ios::sync_with_stdio(false); // 取消cin與stdin同步,提高讀取速度。數據規模超過幾千時,不使用流來輸入輸出
std::cin.tie(0); // 可以通過tie(0)(0表示NULL)來解除cin與cout的綁定,進一步加快執行效率
freopen("file_name.in","r",stdin);
freopen("file_name.out","w",stdout);
cin>>n;
for(int i=0; i<n; i++) cin >> a[i];
/* 調試代碼在不用時,不應該直接刪除,而是應該註釋掉,以免給重新使用帶來麻煩。不過,忘記把調試代 碼刪除,競賽就不美好了 */
// 所以把調試代碼放到#ifdef 塊中
#ifdef DEBUG
//調試代碼
#endif
return 0;
}
在 IDE 的編譯選項中加一個參數: -DDEBUG
-
程序計時/卡點測試
C++ 程序性能吞吐量計算
double start = clock( );
//******************************
//放要測定運行時間的函數 function()
//******************************
double end = clock( );
cout << ( end - start ) / (CLOCKS_PER_SEC) << "秒" << endl;
// CLOCKS_PER_SEC * 60 就是以分鐘爲單位
/*
卡點:設一個計數器 cnt,表示程序進行運算的次數。
把ta放到循環或遞歸中,判斷,如果超過某一個
數(小於 5,000,000),就說明 "超時",應該立刻結束程序。
*/
-
自制調試器
OI簡易調試器
#include <stdio.h>
// DIY _DeBug帶參數
#define _DeBug(demo) demo ? 0 : fprintf(stdout, "Passing [ file: %s ] in [ function: %s ] in [ line: %d ]\n", __FILE__, __FUNCTION__, __LINE__)
// DIY _DEBUG無參數
#define _DEBUG printf("Pass failed:[ file xyz %s ] in (line nnn %d)\n", __FUNCTION__, __LINE__)
int main( )
{
int a = 0, b = 3;
a ? _DEBUG : _DeBug(b/a); // 被除數不能爲0
return 0;
}
/*
ANSI C 規定了以下幾個預定義宏,ta們在各個編譯器下都可以使用:
__LINE__:表示當前源代碼的行號;
__FUNCTION__:表示當前源代碼的函數名;
__FILE__:表示當前源文件的名稱;
__DATE__:表示當前的編譯日期;
__TIME__:表示當前的編譯時間;
__STDC__:當要求程序嚴格遵循ANSI C標準時該標識被賦值爲1;
__cplusplus:當編寫C++程序時該標識符被定義。
*/
軟件開發簡易調試器
#ifndef __dbg_h__
#define __dbg_h__
// 防止文件重複包含
#include <stdio.h>
#include <errno.h>
#include <string.h>
#ifdef NDEBUG
// 如果有定義 NDEBUG,那麼所有的 宏都不會有效
#define debug(M, ...)
// debug第一種實現,
#else
// 如果沒有定義 NDEBUG,就會得到下面的 debug...
#define debug(M, ...) fprintf(stderr, "DEBUG %s:%d: " M "\n",\
__FILE__, __LINE__, ##__VA_ARGS__)
// debug第二種實現,接受可變參,利用##VA把多餘的參數放入 (...)
#endif
#define clean_errno() (errno == 0 ? "None" : strerror(errno))
/* 給終端用戶看 */
#define log_err(M, ...) fprintf(stderr,\
"[ERROR] (%s:%d: errno: %s) " M "\n", __FILE__, __LINE__,\
clean_errno(), ##__VA_ARGS__)
#define log_warn(M, ...) fprintf(stderr,\
"[WARN] (%s:%d: errno: %s) " M "\n",\
__FILE__, __LINE__, clean_errno(), ##__VA_ARGS__)
#define log_info(M, ...) fprintf(stderr, "[INFO] (%s:%d) " M "\n",\
__FILE__, __LINE__, ##__VA_ARGS__)
#define check(A, M, ...) if(!(A)) {\
log_err(M, ##__VA_ARGS__); errno=0; goto error; }
// 用於判斷,確保A爲真,如果不是就記錄錯誤 M,接着跳轉到函數的 error 去清理, error標籤定義在處理函數末尾
#define sentinel(M, ...) { log_err(M, ##__VA_ARGS__);\
errno=0; goto error; }
// 放到函數中不該運行的位置(類似 default),運行了就打印一個錯誤的消息,也跳轉到 error ...
#define check_mem(A) check((A), "Out of memory.")
// 確認指針是否有效,如果無效會報告 "內存不足"
#define check_debug(A, M, ...) if(!(A)) { debug(M, ##__VA_ARGS__);\
errno=0; goto error; }
#endif
/* 教程鏈接: http://ewm.ptpress.com.cn:8085/preview?qrCode=qr2018001493&verified=true
測試代碼在ex19: https://github.com/zedshaw/learn-c-the-hard-way-lectures
*/
-
分治算法
void solve(p) // p表示問題的範圍、規模或別的東西。
{
if( finished /* p的規模夠小 */ ){
// 用簡單的辦法解決
}
// 分解: 將原問題分解爲若干個規模較小,相互獨立,與原問題形式相同的子問題
// 一般把問題分成規模大致相同的兩個子問題。
for(int i=1; i<=k; i++) // 把p分解,第i個子問題爲pi
// 解決: 若子問題規模較小而容易被解決則直接解,否則遞歸地解各個子問題
for(int i=1; i<=k; i++)
solve(pi);
// 合併: 將各個子問題的解合併爲原問題的解。
......
}
基礎算法設計
算法設計,這裏說的是設計其實是設計能力,而不是設計原則。
以我們上面的 鍥子 爲例子,
問題 :BOSS 讓我們幫助設計工業機器人(機械臂)的Tool - path , 已完成各種事情。
上面我們分析了問題,並學術表達出來(集合):
分析問題,除了搞定問題表達的意思外,還有對應的輸入輸出,從而函數原型就確定了。
分析問題的水平,是需要不斷訓練,同時對計算機知識也需要廣泛涉獵。 對編程而言,分析問題的能力比解決問題的能力重要。
- 輸入:集合P,包含n個點
- 輸出:訪問集合P中所有點的最短路徑(花費時間最少), 再回到P1
根據上面的分析,得到了輸入輸出從而確定算法的函數原型。
1. 原型設計
一個函數的原型如,void print( void ){ ... } 是由返回值、函數名、形參、函數體組成。
- 傳什什麼參數給函數
以什麼數據作爲結果返回,如果函數返回一個指針或引用,您是否天真地返回了函數內部的變量?
爲算法取一個好的函數名 (猛擊 命名法則,即可學習)
現在爲工業機器人設計以函數實現的最短路徑的算法。那麼我們該選擇什麼算法 or 算法思想解決最短路徑問題。
資料: 最短路徑漫畫指南
看了資料後您可能已經打算採用 Dijkstra 算法。可,回想一下,我們從任意點P1開始,也許ta左右倆邊都有 Pa 和 Pb,那麼在左邊的 Pa, 在機器人數軸上與P1的距離是負數,右邊的Pb與P1的距離纔是正數。這裏,安利一個解決負權邊的算法: Bellman-Ford
/* n 爲頂點個數, m 爲邊的個數 */
#define T int
T dis[n] // 存儲源點P1 到 P2、3、4、... 、n 的距離...
T u[m], v[m], w[m] // 存儲邊的信息,u[i]是第i條邊的起始頂點,v[i]是第i條邊的終止頂點,w[i]是第i條邊的距離
for i in [0, n-1): // n-1 次
for j in [0, m): // m 次,枚舉每一條邊
if ( dis[ v[j] ] > dis[ u[j] ] + w[j] )
// 鬆弛操作,如果從其ta頂點到第 v[j] 點比直接從源點到第 v[j] 點更近,就更新其ta頂點的距離
dis[ v[j] ] = dis[ u[j] ] + w[j]
那麼,你能不能爲這個算法設計一個函數原型呢,名字叫 Bellman-Ford ?回想之前分析的輸入和輸出。
bool Bellman_Ford ( int begin );
// 因爲 Bellman-Ford 還可以用來檢測是否有負權迴路,所以可用 bool, 不檢測用 void 也行, 參數begin 是源點P1
- 輸入:輸入邊數、頂點數,讀入所有邊的長度
- 輸出: dis[m] ,最短路徑保存在dis[m]裏了
對一道題完全沒想法時,多半是沒有分析好,這時候反覆要讀題。分析好以後,知道就知道該用什麼知識對答,這時候考驗的是編程能力了,分析考驗的是算法能力,當寫好了主函數模版,接下來就思考實現某個算法ta的原型是怎樣的,反覆練習讓其成爲一種習慣, 後面我還會講 DP ...
如果編寫函數時,名字類似於這樣的,
strlen_(...);
_strlen(...);
// 爲了提高可讀性,應把函數名用括號圍起來,
( strlen_ )(...);
( _strlen )(...);
因爲,人有時候比較粗心,就是看不到下劃線。
2. 參數設計
我覺得參數設計是在原型設計裏最主要的,設計的好,程序簡潔,遞歸可以避免因多層函數參數入棧而引起的爆棧。
比如,二分查找。
bool binary_search( ... );
原型如上,因爲要返回是否找到要麼直接返回數的下標,要麼就返回一個布爾值。千萬不要在這個子函數裏面輸出,找到沒找到,這樣會提高模塊化的耦合性,這樣相當於告訴別人,我是新手中的新手。
設計參數,
#define T int
bool binary_search ( T arr[], T key, int len )
// 被查找的數組 查找的值 被查找數組的長度
參數雖然正確,不過也並不好。因爲查找是不需要修改目標數據(被查找的數組),沒有保護數據。key和len,運行時,因爲是傳值,會創建(佔空間)副本給形參,我們可以改成傳引用減少不必要的空間。但如何數據量巨大,採用引用影響效率因爲引用是間接尋址,傳值是直接尋址所以速度比引用、指針快。
#define T int
bool binary_search ( const T arr[], const T& key, const int& len );
// 算法不需要修改數據時,都應設計爲 不可修改類型,這樣的設計原則其實非常敏捷
// 傳引用避免形參拷貝
還可以把數組長度的參數去掉,
#define T int
const int len = 10;
bool binary_search ( /* const T (&arr)[len], const T& key */ );
傳引用,如果把函數形參的len 換爲 10 , 就可以檢查數組是否溢出,如果傳進來的數組長度不等於[len],編譯就不過。
3. 邊界設計
- 形參指針是否爲NULL,處理字符串時,指向的是不是 '\0'。 p.s. '\0' == 0
-
ptr == NULL || *ptr == '\0‘
- 函數參數中緩存的⻓長度是否在合理理範圍
-
len <= 0
如果類型是浮點數,因爲實數在計算和存儲時會有誤差,因此本來是零的值,由於誤差的原因判斷爲非零。所以,判定方法要改。
-
// 採用絕對精度判斷, 原理:判斷ta的絕對精度是否小於一個很小的數,如 0.000 001 double eps = 1e-6 if ( fabs(a) <= eps ) // 等於0 if ( fabs(a) - fabs(b) <= eps ) // 倆個數相等 // 當變量 a、b 在 eps 精度附近時,判斷失誤。還有 相對精度 和 絕對精度+相對精度 的判斷方法
還可以用C++中的RTTI對參數的類型進⾏行行檢查。依然以二分查找爲例,判斷邊界。
#define T int
bool binary_search ( const T arr[], const T& key, const int& len )
{
if ( len <= 0 || arr == NULL || *arr == '\0' )
// 短路表達式優化,一真必真,避免不必要的判斷, 所以把最高頻的放在最左邊,其餘同理
return false
}
參數的設計,當設計到模版參數即通用類型時,要把所有類型情況都考慮進來。
乘高鐵時,保安檢查的非常嚴格,人和物品都要掃描,檢查一切違禁物品,哪怕帶的刀是切橡皮的,檢查人員也會沒收並登記不良信息。這就是我們學習的榜樣。
避免程序在運⾏中出錯,避免各種漏洞的產生。如,
曾經的SSL協議中的心臟流血漏洞,就是因爲服務端程序在處理時,沒有驗證來⾃客戶端對應的緩存⻓度有效性,造成了該漏洞的產⽣。
被號稱漏洞核彈的 微軟CVE-2017-0290漏洞,也只因在掃描引擎MsMpEng的NScript模塊中沒有對輸入的參數類型進⾏檢查,默認當做字符串來處理造成的。
更加全面的邊界設計:
數據類型與數據結構的區別,
我準備蓋棟房子,需要各種已經做好並組合的物件,ta是房子的骨架,ta也是程序中的數據結構;當使用各種不同尺寸和標號的鋼筋,如,按直徑分,鋼絲(直徑3~5mm)、細鋼筋(直徑6~10mm)、粗鋼筋(直徑大於22mm),這些鋼筋即程序中的數據類型,做橫樑也許用10mm的鋼筋,做樓梯也許用22mm的鋼筋,這和程序的 int 與 unsigned int 類似。
所以,數據類型是邊界設計必須考慮的。
數據類型有:數值、字符、位置、數量、速度、地址、尺寸等,都包含確定的邊界。
考慮的特徵:第一個/最後一個、開始/完成、空/滿、最慢/最快、相鄰/最遠、最小值/最大值、超過/在內、最短/最長、最早/最遲、最高/最低。這些都是可能出現的邊界條件。邊界條件測試通常是對最大值簡單加1或者很小的數和對最小值減少1或者很小的數,如:
-
第一個減1/最後一個加1; 開始減1/完成加1; 空了再減/滿了再加; 慢上加慢/快上加快; 最大數加1/最小數減1; 最小值減1/最大值加1; 剛好超過/剛好在內; 短了再短/長了再長; 早了更早/晚了更晚; 最高加1/最低減1。
- 隱式類型裝換,
#include <iostream>
void demo( int a )
{
std::cout << a; // a = 255
}
int main( ){
char a = -1;
demo(a);
}
若比較有符號和無符號時,C/C++編譯器會把有符號類型轉換爲無符號類型,但不會 "溢出"。
- 溢出
倆個相同類型相加或相乘產生的 "溢出" ,如 :
#include<stdio.h>
int main( )
{
int a = 2147483647; // 2的31次方
int b = 1;
a += b;
if ( a + b < 0 ) // a + b = -2147483648, 溢出
do something...
}
4. 性能設計
算法性能,我們在算法分析一起學習,這裏是代碼優化 !
程序通常由 順序、選擇、循環 三大結構構成,對程序性能影響最大是循環。我們在循環結構下功夫,性能會好很多。
非擴展循環的實現:
int a[1000];
// 要求: a[1000] 所有元素賦值爲 3
// 基本操作,循環1000次
for (int i = 0; i < 1000; i ++ )
a[i] = 3;
// 花裏胡哨, 但大大提高性能的循環
for (int i = 0; i < 1000; i += 5 )
a[i+0] = a[i+1] = a[i+2] = a[i+3] = a[i+4] = 3;
// 判斷結束條件i<1000 和 計數器i頻率 都減少了 4/5, 效率提高了80% 。
使用第二種循環,判斷結束條件i<1000 和 計數器i頻率 都減少了 4/5, 效率提高了80% 。
- 循環總次數 / 單次循環 賦值次數 = 實際循環次數
選擇合適的尺寸,蓋房子,鋼筋選長了會浪費,短了,也不合適。數據類型也如是。
使用單精度浮點數代替雙精度浮點數,可以提高單次運行時間的 2.5 倍。
定義一個單精度浮點數時,初始化或賦值在數後加 f,
-
float x = 1.732f;
不加f,1.732是一個雙精度浮點數,而後給 x 還會隱式轉換。不信,你看看彙編語言生成的代碼。另外,無符號數後綴 + u。
定義結構體或者類時,變量按照順序定義。越小的放在越前。
-
typedef struct stu{ char a; // 1 byte short b; // 2 bytes int c; // 4 bytes long long d; // 8 bytes }S;
循環中,避免不必要的計算,如
-
for (int i = 0; i < sqrt(100); i ++ ) do something...
sqrt(100) 提前算好,保存到一個變量裏面。 i < 存儲變量 就好,這樣就少了 100 次計算。
從快到慢相對速度排序,加法 >> 減法 >> 乘 >> 除 >> 取模 >> 函數調用入棧出棧,使用位運算,資料: 位操作
一部分除法、求餘可以用位運算,也可以自己實現。
-
/* 取模優化,a % b = c */ // 如果被模數 b 是 2次冪,可以運用位運算 a & (b-1) = c // 使用更相減損術 while(a >= n) a -= n; r = a; // 餘數 r, 要考慮 a == n 的情況
打開O2優化,下面代碼放到程序開頭即可
-
#pragma GCC optimize(2)
數據量成千上萬,使用讀入、輸出優化模板
-
void read(int &x){ int f = 1; x = 0; char s = getchar(); while( s < '0' || s > '9' ){ if( s == '-' ) f = -1; s = getchar(); } while( s >= '0' && s <= '9' ){ x = x*10+s-'0'; s = getchar(); } x *= f; }
-
void print(int x) { if( x < 0 ){ putchar('-'); x = -x; } if(x > 9){ print( x/10 ); putchar( x%10+'0' ); } }
使用位運算:資料
5. 出錯設計
C++、Python ... 這些語言在解決出錯的時候多通過異常處理,不過異常在 C語言 裏有一些問題。在C語言中,我們只有一個返回值,但異常是一個基於棧的返回系統,ta返回的東西是不確定的。很多編程語言直接把所有邊量都放在了堆。
我最喜歡的出錯處理方式就是 goto 了。想起來,就開心。優美,簡潔,適合小白。
-
#include<iostream> int fun(void) { if( finshied ) goto err_1; // 一定是往 return 方向跳 if( finshied ) goto err_2; /* goto語句 和 err的標籤 之間 不能有 定義變量的操作 e.g. int a = 9, 但聲明可 int a */ return 1; err_1: do something; // 只能調到當前函數, err 一般在函數分界處 err_2: do something; }
使用 goto 還可以定義程序通用宏,具體方法參見上面自制調試器模版。
6. 算法技巧設計
呀哈,昊滋。心理學的課上,你填的什麼?一下課我就迫不及待的問。
誒,你填了三個股票... 以後打算炒股嗎 ?
昊滋很隨意道,是啊,每月我都有餘額打算用來買股票。
一想起這傢伙每個月都留有 500 塊RMB,ta老媽有時候直接向ta拿錢。我就非常鬱悶了,同九義汝何秀!!
我也不知道,ta具體每個月有多少錢。倒也不會太多,因爲ta很會使用資源。比如,和ta一起喫飯,ta從來嗎剩過幾粒飯,飯喫完了盤子就像瓷器一樣,光潔。ta點的菜不會沒油吧...
突然側過臉和我說了一句:"Debroon,最近在研究公司股票,需要找股票最長的增長期,能不能寫一個程序幫我計算出來。"
啊,哈,你,說。儘管對股票一無所知,但我最近也在學習編程,再說萬一我會呢。
我已經說了呀,股票最長的增長期。
...... ? ! !
我已經手算了,一部分增長淨值。我看公司的數據有幾千個點,以後還會增加,我算不過來所以想找你幫忙。
" 行,先解釋解釋, 最長增長期 ?",雲淡風輕大師應該具備的樣子。
最長增長期啊,股票漲的最快的部分。你看一下這是茅臺的股票曲線圖,把ta們的數據記錄下來,接着分析這些數據作爲分母來衡量投資人的表現,這樣大概就能推演出現在的茅臺股票記不記得我投資。
這是一個月份的曲線圖,最長增長期就是曲線最低點到曲線最高點,不過最高點必須在最低點右邊。因爲沒買這麼賣呢?而有效增長期是比大盤漲的快的那部分。當一隻股票漲速超過股指時,購買和持有ta纔有意義。因此,扣除整個市場對股票價格的影響,當股票每天上漲的速度超過股票指數,有效增長是正數,低於就是負數,看看我計算的,使用差分即可。
天 | 參照數 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
價格 | 100 | 101.5 | 89.2 | 92.4 | 86.9 | 110.1 | 113.3 | 111.9 | 99.7 | 133.9 | 139.3 | 131.5 | 132.6 | 127.7 |
看到第 1 天的有效增長是 1.5,單位是基本點(萬分之一)。表示第 1 天比大盤多漲了 1.5 個基本點 ?,第 2 天又比大盤多跌了 12.3 個基本點 ?。上面只記錄了 13 天,還有幾千天呢!最長有效增長期是,第 5 天買入,第 10 天賣出。
哦哦,我明白了。如果我把這些基本點放入數組中,那麼最長有效增長期本質上就是數組的最大子數組。
第一步 爬取數據,採集公司的數據所有基本點並存到文件裏,資料:爬蟲專題
-
double arr[] = { 100, 101.5, 89.2, 92.4, 86.9, 110.1, 113.3, 111.9, 99.7, 133.9, 139.3, 131.5, 132.6, 127.7 }; // 部分基本點 size_t len = sizeof(arr)/sizeof(arr[0]); // 數組長度
接着,放到數組裏面,求這個數組的 最大連續子數組和。哦對了,爬下來的數據都是正數,需要提前處理,否則整個數組就是最大值因爲沒有負數。解決也簡單,使用差分,與前一天比較再存儲到數組裏。
-
for (int i = 1; i < len; i ++ ) // len 是所有基本點的數量, 從第1天開始 差分 arr[i-1] = arr[i] - arr[i-1];
現在看看,計算結果是不是上面有效增長期,
-
for (int i = 0; i < len-1; i ++ ) // len - 1 是因爲有一個參照數不能算進去 printf("%.1lf ",arr[i]);
算法設計:
最長有效增長期是由起始日期和終止日期組成,那麼我需要倆個循環找到這倆個日子,比較的就是倆個日子之間的有效期,選出一個最大的即可。
起始日可以是第 1 天到 最後一天選,可以使用循環枚舉,
-
for (int i = 0; i < len; i ++ )
終止日因爲不能早於起始日,所以最早只能等於當前起始日 到 最後一天中選,也可以循環枚舉,
-
for (int j = i; j < len; j ++ )
接着,比較出起始日第 i 天到終止日第 j 天的有效增長期,定義一個變量,持續與之前最大數比較,
-
for (int x = i; x < j; x ++ ) sum += arr[x]; if (max < sum) max = sum;
和着這個邏輯,寫好完整的代碼,
#include "stdio.h"
int main(){
double arr[] = { 100, 101.5, 89.2, 92.4, 86.9, 110.1, 113.3, 111.9, 99.7, 133.9, 139.3, 131.5, 132.6, 127.7 };
size_t len = sizeof(arr)/sizeof(arr[0]);
for (int i = 1; i < len; i ++ ) // 差分
arr[i-1] = arr[i] - arr[i-1];
double max = 0.0, sum = 0.0;
size_t begin_day = 0, end_day = 0; // 記錄起始日和終止日
for (int i = 0; i < len-1; i ++ ){ // len - 1 是因爲有一個參照數不能算進去
for (int j = i; j < len-1; j ++ ){
sum = 0; // 當前區間疊加完畢,需要清空
for (int x = i; x <= j; x ++ ) // 起始日到終止日 x 天的有效增長
sum += arr[x];
if (max < sum)
max = sum, begin_day = i, end_day = j;
}
}
printf("最長有效增長期是: %.2lf, 在第 %u 天 買, 第 %u 天賣\n",max, begin_day+1, end_day+1);
// 數組下標是從 0 開始,但日期是從 1 開始,所以 起始日期 + 1,終止日期 + 1
}
簡單分析一下,最長有效增長期的起始日共 n 種選擇,終止日共 種選擇,漸近複雜度即 n * = ,後來我們用一個循環累加了,起始日到終止日 x 天的有效增長,所以 * n = 。昊滋說,公司有上千基本點,那麼 = 1000 000 000,大概會有幾十億,計算機每秒運算10億次,等幾秒就好。
不過。。。ta好像說,基本點還會增加。啊呀,萬一哪天到上萬了,那豈不是要計算上萬秒!!
那麼還可以繼續改進不 ?
啊哈,靈機一動。發現在確定終止日時就可以統計 起始日到終止日的有效增長了。
-
for (int i = 0; i < len-1; i ++ ){ sum = 0; // 當前區間疊加完畢,需要清空 for (int j = i; j < len-1; j ++ ){ sum += arr[j]; // 確定終止日時就可以統計 起始日到終止日的有效增長 if (max < sum) max = sum, begin_day = i, end_day = j; } }
這是一個 的算法,嗷嗷~ 研究算法已經上癮了,請問還能更好嗎 ?
當然當然,敬請期待下一節分治思想導出的 n * log n 算法
7. 設計思想小百科
"銷售",推動經濟的發展。
這個禮拜,我逃課了。找了一份銷售的工作,這是我的一個習慣,"空擋時間"。
- 連續健身幾個禮拜之後,會有一個禮拜空着,
- 連續七日一日三餐的喫飯,會有一個日不喫晚飯,
- ......
所以,我跑過來專心銷售。那麼爲什麼選銷售 ?
因爲我被銷售人員說服了,ta說: "你不是在學習計算機嗎,有沒有聽過 分治策略" ?
"知道啊 分治是計算機科學本質之一。",遇到一個淪落人有點興奮的道。
"那你聽說過 "杜邦分析" 嗎 ?",眼中帶着光澤的問我。
0.0
"並不知道!很 NX 嗎 ?", 望着手錶回答。
那我和你說說,"杜邦分析",提出ta的人是杜邦公司的員工布朗,採用的就是 "分治思想",我也是後來才知道這個思想應用如此廣泛。1912年以前,財務管理非常複雜,當杜邦分析出來後,很多週轉財務變成機械計算,如淨資產收益率。
你看,ta的公式是分解後爲 3 個部分的,這樣就凸顯出 最主要的東西,銷售利潤率、資金週轉率、權益乘數。
銷售利潤率代表的是企業賣的產品是否賺錢,利潤高不高。要提高利潤率,可以提高銷售價格。
我們想一想,這個方法對房地產企業來說可行嗎?現在房價已經很高了,再提高售價能買得起房子的人就更少了,對 銷售額 定有負面影響。再加上地產行業的調控,行業平均利潤率的總體下滑趨勢非常明顯,所以這不是一個可能的路徑。權益乘數,權益乘數和負債率有關。資產負債率越高,權益乘數越大,淨資產收益率就越高。房地產公司擅長用客戶的錢來賺錢,所以負債率比其他行業的企業都高,沒什麼空間繼續增加負債了。另外,現在政策導向是“去槓桿”,地產企業也需要控制負債風險,所以這個提升收益率的路徑似乎也不可能。
資產週轉率的核心是,一個字——“快”。週轉率越高,說明公司資產運用效率越高,每塊錢的資產,能帶來更多收入。
這就是房地產企業這兩年突然轉型,開始推行高週轉模式背後的財務邏輯。
當外部銷售不能增加時,提升週轉速度是內部提升收益,也就是在企業內部找錢的一個重要方法。這就是 "杜邦分析" NP的地方之一。
嗷嗚嗷嗚,江涵秋影雁初飛,與客攜壺上翠微,不是在準備英語考試嗎!
如果採用分治策略,先定一個輸入,即考試分數提高 40 分。這時候,看着英語試卷是沒有意義的,我們要拆分,把ta分成 "閱讀理解"、"口語"、"聽力"、"寫作"。而後,把各個點 可提高的概率 排序,並按照排序級數高的點努力即可。
現在,我可以用 "分治"算法,解決股票最長的有效增長期。
哦,股票。有興趣我加入。
好,我們先看看分治算法的模板...
-
void solve(p) // p表示問題的範圍、規模或別的東西。 { if( finished /* p的規模夠小 */ ){ // 用簡單的辦法解決 } // 分解: 將原問題分解爲若干個規模較小,相互獨立,與原問題形式相同的子問題 // 一般把問題分成規模大致相同的兩個子問題。 for(int i=1; i<=k; i++) // 把p分解,第i個子問題爲pi // 解決: 若子問題規模較小而容易被解決則直接解,否則遞歸地解各個子問題 for(int i=1; i<=k; i++) solve(pi); // 合併: 將各個子問題的解合併爲原問題的解。 ...... }
那麼 模板的P 就是我們存儲基本點的 數組,這樣輸入我們也就分析出來了即 一個數組。
結束條件:
思考一下,存儲基本點的數組最小的情況是什麼?
- 沒有元素,是空數組
- 只有一個元素,只需要判斷和 0 比誰大,選最大值
考慮最常見的情況,
數組P有倆個元素及以上,顯然還需要計算,所以不是最小情況,最小情況是沒有元素和只有一個元素。
P |
分解後 :
分解爲倆個子問題,倆個數組近似
a | b |
Low Mid High
解決後:
在 a 區間,我們找到的最大子數組和,肯定是子數組 a 中的 某個區間,[Low, Mid]
在 b 區間,我們找到的最大子數組和,肯定是子數組 b 中的 某個區間,[Mid+1, High]
還有一種因分解而來的情況沒考慮,如果子數組 a 的 終止日是a 的最後一個元素,那麼子數組b的開始元素:
是正數還可以繼續銜接呀,加一個正數自然大於之前的值;
相反是負數,也得繼續掃描因爲後面的正數可能會大於倆者之間的負數而形成新的最大子數組和,[Low, Mid] [Mid+1, High]
合併:
上面的分解,我們算出來了 3 種情況的最大數組和,現在比較這 3 個最大數組和,答案就出來了,不是嗎?
#include <stdio.h>
#define MAX(x,y,z) (x>y?x:y)>z?(x>y?x:y):z
#define max(x,y) x>y?x:y
double arr[] = { 100, 101.5, 89.2, 92.4, 86.9, 110.1, 113.3, 111.9, 99.7, 133.9, 139.3, 131.5, 132.6, 127.7 };
double max_array(int l, int u){
// 沒有元素
if (l > u)
return 0;
// 有一個元素
if (l == u)
return max(arr[l],0);
double sum = 0.0;
int mid = l + ( (u - l) >> 1 ); // int mid = (l + u) / 2,這樣寫防溢出速度也比除法快
// 掃描 a 的最大子數組
double l_max = sum = 0.0;
for (int i = mid; i >= 1; i -- ){
sum += arr[i];
l_max = max(l_max, sum);
}
// 掃描 b 的最大子數組
double r_max = sum = 0.0;
for (int i = mid+1; i <= u; i ++ ){
sum += arr[i];
r_max = max(r_max, sum);
}
return MAX(max_array(l, mid), max_array(mid+1, u), l_max+r_max);
// 3 種情況的比較
}
int main(int argc, const char * argv[]) {
int len = sizeof(arr)/sizeof(arr[0]);
for (int i = 1; i < len; i ++ ) // 差分
arr[i-1] = arr[i] - arr[i-1];
double result = max_array(0, len-2);
// len-2是因爲,一: 數組下標從 0 開始 二: 再 -1 是有一個參照數 100
printf("result : %.2lf", result);
return 0;
}
簡單不簡單,這比之前的算法更快,比第一個幾個基本點時需要上萬秒算出來,現在 1 秒都不需要。
我們去找昊滋一起討論討論,打算百度一下...
等等,我知道了,"逆向":
起始日可以是第 1 天到 最後一天選,可以使用循環枚舉,
終止日因爲不能早於起始日,所以最早只能等於當前起始日 到 最後一天中選,也可以循環枚舉,
你們看,選擇終止日在不確定起始日時,可以直接從頭到尾掃描,這樣就能找到股票開始跌的日期,
如果選起始日時,也是從頭開始掃描,那有一種情況就解決不了即最高點在最低點左邊。因爲掃描的終止日是數組中的最高點,掃描的起始日是數組中的最低點,這樣掃描並沒有把順序考慮進來。
從頭掃描可以找到終止日期,那麼我們爲什麼不反過來呢?
選擇起始日時,從尾到頭的掃描,就能回溯到股票漲幅的日期。這樣即在 的(漸近意義上)線性時間裏找到了答案,也解決了順序的問題。
p.s. 在動態規劃的博客裏,也給出最大子段和 的算法......
設計思想資料 : https://wx.zsxq.com/dweb/#,微信掃一掃可以加入,接着搜索設計模式與思想即可。
8. 如何設計算法/解題
認知心理學說,人類最有效地解決問題方式,是 "目標-手段分析法"。
這是一種 確認目標,一層一層地分解,大問題變一組小問題,每一個小問題都有實現的手段,而後去做就完成哩✅。
如果 "目標-手段分析法" 用在算法設計上,流程大致是如此。
- 確認目標
- 分析過程
- 先面向過程
- 行行實現代碼
- 代碼封裝
最重要的是確認目標,最難的是分析過程,因爲這是腦力活;後面的代碼一行行實現在封裝,只是體力活。
數學說,弄懂定義的定義。
- 先讀問題
- 反覆確認定義
- 習慣 OO 指代 OO 的說法
- 用數學公式表達
- 思考這個問題的所有,是什麼、有什麼性質......
- 分析過程時,可以放寬約束和增加約束。給問題增加一些條件或刪除一些次要條件使問題變得清晰。
現實:每天刷定量的OJ題目,刷完後看題解。
基礎算法分析
算法分析,這裏說的是分析其實是分析工具,而不是分析能力。
0. 數學歸納法
您,有沒有聽過一句話 : " 計算機科學家是隻知道用歸納法來證明問題的數學家"!
說法基本屬實,計算機科學家研究的算法大多是增量式和遞歸的,一種數學歸納法就夠用了。因爲數學歸納法通常是證明一個遞歸或增量式算法的正確思路。
若有命題 P(n),需要證明對正整數 n 成立。
- 步驟1: 基底證明
- 證明 P(最小情況) 成立
- 步驟2: 歸納證明
- 證明 P(k) 成立,那麼 P(k+1) 也成立。
- 步驟3: 證畢
- 結合步驟 1、2,得出結論 P(n) 對 ba bala 成立。
e.g. 證明 0 到 n 的整數之和與 相等 。
最小情況 P(0) 代進去,, 0 = 0 ,P(1) 成立。
一般情況,把 0 以上的任意數 n 代入,若 P(n) 成立,則以下等式成立:
接着,代入 n+1。若 n+1 成立,則以下等式成立:
(n+1是增加了一項,公式需要末項+1)
n+1是因爲增加了一項則歸納法的步驟1 和 步驟2 都得到了證明,則:
0 到 n 的整數之和與 相等。
是不是依然感覺不清不楚的,其實ta和程序的遞歸是一樣的,有邊界條件和一般條件。以一般條件一步步將問題分解爲越來越小的子問題,直到達到邊界條件會終止遞歸。懂遞歸自然就懂歸納了,所以把ta當成遞歸即可,看看程序。
#include <stdio.h>
typedef int T;
void prove( T n )
{
T k = 0;
if( 0 == n ){
printf("根據步驟1(基底證明) 得出P(%d) 成立。\n\n",n);
} else {
prove( n-1 );
printf("根據步驟2(歸納證明) 可說“若 P(%d) 成立,則 P(%d)也成立”。\n\n", n-1, n);
printf("因此可說 “P(%d) 是成立的。”\n\n", n);
}
return;
}
int main(void)
{
T n;
scanf("%d",&n);
puts("證明斷言 P(n) 對於給定的 0 以上的整數 n 都成立\n");
printf("現在開始證明 P(%d) 是否成立?\n\n",n);
prove(n);
puts("證明結束");
return 0;
}
數學歸納法,重點是 "歸納"。ta可以總結事物發展的規律,只有證明這個規律是對滴,就可以得到事物任意發展階段的結果,這樣可以節省時間和計算資源。證明一般良基結構,例如:集合論中的樹。在計算機科學中的是一種特殊的數學歸納法,ta叫 "結構歸納法"。
- 遞歸,計算交給了計算機,以計算機計算成本換人的時間;
- 歸納,計算交給了人,以人的時間換計算機的計算成本;
- 遞歸的調用代碼與數學歸納法的邏輯一致,倆者互通。
歸納法: 假設一條腿可以向前邁一步,而後假設另一條腿無論什麼情況都可以邁過去,這樣就可以到無限的遠方。
1. 對數
對數是由算法單詞(algorithm)換位得到的,algorithm -> logarithm。也是初等函數中指數函數的逆運算,
< - >
- 對數和三角函數一樣,在天文學應用廣泛。對數的計算工具有: 計算尺、計數表
與之相關的例子:
- 用於算法分析中如 二分查找:O(log n)、
- 在生活中如法庭的懲處表,懲罰隨犯罪等級呈對數增長、
- 完全二叉樹的深度(高度):,給出數學證明:
完全二叉樹是除最底層之外其ta所有結點都是滿的,還有最底層的所有結點都集中中最左邊。
所以,除最底層(h = 4)之外的所有層(h = 1、2、3)結點數都滿足 ,如 第二層 h = 2,第一層結點數是 。
假設,一個完全二叉樹有 n 個結點、高度爲 h。
因爲完全二叉樹最底層結點數不確定,所以我們取一個範圍討論...
最小情況,以上圖爲例即 1 - 7 爲滿二叉數加一個結點 8 就是完全二叉數。結點數爲 ,是滿二叉樹的結點數公式, 後 + 1 是因爲還需要一個結點纔是完全二叉樹。
最大情況,第 4 層也是滿的,這是一個滿二叉數。結點數n爲
同時取以 2 爲底的對數 ,
化簡,
推出,
(去小數 取整+1)
對數的特性
< - > ,
二進制對數,廣泛用於對數算法 即 (底) a = 2 時, 可簡記爲 。
自然對數,(底) a = 2.71828... 簡記爲 。
常用對數,(底) a = 10,簡記爲 。
- : 常用於換底 ,對數的底對數據的增長量級並沒有實際影響,因此分析算法時通常忽略對數的底。
- : 用於計算,上次考試這題寫錯了 真有點鬱悶。
- :任何多項式函數取對數後的增長量級都是 。
實現 :
size_t log_2( size_t n )
{
return n > 1 ? 1 + log_2( n >> 1 ) : 0;
}
2. 主定理
掌握主定理最好先學會基本的遞推式分析,逐步提高難度。
"主定理" 的快速記憶
......
3. 漸近記號
記錄在漸進記號的博客裏
4. 高等分析
5. 分治策略
6. 組合計數
記錄在 多項式
7. 排序算法
8. 攤還分析
9. 概率論與多項式
多項式,記錄在 多項式
概率論,記錄在 概率論
10. 鍛鍊分析能力的邏輯謎題
趣味算法小集
記錄在趣味算法小集中,註釋會慢慢添上。
[ 更新ing... ]