程式設計師的數學思維:如何推導矩形面積

林子er發表於2022-07-11

矩形面積

我們在小學就學過矩形的面積等於長乘以寬。

但活了幾十年,你有沒有想過:矩形面積為啥等於長乘以寬?

或者說先人們為何將矩形的面積定義為長乘以寬?

繼續之前,請先忘掉矩形面積等於長乘以寬這個“簡單”的知識)。

設想自己是個好奇心極強的農夫,某天閒來無事,端著一杯茶盯著自家一塊地,就像下面這形狀:

image-20220708093649923

我們突然想搞清楚這塊地有多大。於是用尺子量了量:

image-20220708094027190

這塊土地大小是長 100 寬 50。

但我們那該死的好奇心對這個結果並不滿意:它想知道這麼大的矩形所圍出來的區域(陰影部分)到底是多大——它想用一個數字來表達這個區域。

在搞清楚答案之前,得先給它起個名字

叫什麼都行,最好直觀,比如區域、面積——不妨就叫面積吧。

另外我們想求解的是通用矩形的面積(而不是長 100 寬 50 的”這個“矩形),所以我們決定用一些符號來表示矩形的長和寬。不妨用 l(或者別的什麼符號都行)代表長,用 w 代表寬。

l 和 w 的含義是任意長寬的矩形。

image-20220708103501829

現在問題變成:求長 l 寬 w 的矩形的面積是多少?

注意我們如何將問題符號化

答案是:不知道!

到目前為止,我們腦袋裡的數學知識只有數字、加、減和乘——面對這個陌生的”面積“,我們一籌莫展。

人類有個天生的”優點“:善於自欺欺人。

我們不知道矩形面積是多少對不對?可以假裝知道啊!

就像我們將矩形的長和寬符號化一樣,我們也可以給面積一個符號

給什麼符號無所謂,X、Y、面、# 都行——不妨管它叫 S 吧(雖然後人總是從歷史的角度來考證符號的含義,但實際上給它什麼符號真的無所謂)。

現在我們好像多了一點知識:知道了長 l 寬 w 的矩形的面積是 S。

但實際上我們又什麼都不知道——其實不然,我們憑經驗感覺這個 S 和 l、w 有關係(有什麼關係還不清楚)

為了表示這個”重大“的發現(S、l、w 之間存在某種關係),我們給出如下縮寫:S(l,w)

(用其它任何縮寫都行,比如 S[l,w]、l,w -> S 等等,我們之所以用 S(l,w) 純屬個人偏好。)

S(l,w) 和 S 這兩種表達其實是一個意思,都是表示矩形的面積,只不過前者多了進一步的含義:矩形的面積和長 l、寬 w 多少存在某種關係。

接下來我們自然就會想:它們到底存在什麼關係呢?

注意我們如何將求解問題轉變成求解關係

我們低頭思考了半天不得其解。

就在我們快放棄的時候,我們的眼角餘光瞟向那一畝三分地,我們想:既然它們存在關係,那我就試著改變長和寬,看看面積會發生什麼變化?

變數變數,既然它是可變的,就讓它變化吧——只能在變化中發現關係

首先,我們把長 l 加倍:

image-20220708103729384

我們發現,長加倍後,面積也明顯加倍了,於是得到如下等式:

S(2l,w) = 2S(l,w)

我相信你第一次感受到了符號化的力量!

有點意思!

接下來將長保持不變,寬加倍試試:

image-20220708104040341

面積同樣加倍了:

S(l,2w) = 2S(l,w)

我們覺得看到希望了!

於是我們拿起樹枝在地上畫起來——我們發現,無論長、寬增加(或減少)多少倍,面積都跟著增加(或減少)一樣的倍數。於是我們寫下了如下式子:

S(#l,w) = #S(l,w)

S(l,#w) = #S(l,w)

# 表示任意數。上面的式子是說:我們可以將矩形的長拆分成兩個任意數(#、l)的乘積,然後將其中一個數從括號裡面拿出來。對於寬也是如此。

接下來就要發揮人類的推理能力了。

也是考驗人智商的時候。

S(l,w) 可以寫成 S(l*1,w)。按照上面的式子,我們可以將 l 從括號裡面拿出來得到:

S(l,w) = S(l*1,w) = l*S(1,w)

我們再對 w 做同樣的處理,得到:

S(l,w) = S(l*1,w) = l*S(1,w) = l*S(1,w*1) = l*w*S(1,1)

也就是說:S(l,w) = l*w*S(1,1)。

至此我們得到結論:長 l 寬 w 的矩形的面積是 長 1 寬 1 的矩形的面積的 l*w 倍。

我們並沒有得到一個絕對數,而是得到了一個相對於 長 1 寬 1 的長(正)方形的面積的倍數。

因為這個長 1 寬 1 的矩形面積總是固定的,所以我們並不需要關心它的面積絕對值到底是多少,我們可以把它人為地定義為 1,那麼長 l 寬 w 的矩形的面積就是 l*w。

這個思想很重要:我們在現實世界中理所當然地認為是絕對值的(比如這裡的面積),其實本質上是相對值

比如 3 米到底是多長?它取決於 1 米有多長。

1 分鐘有多久?它取決於 1 秒有多久。

這些作為度量參照的,我們稱之為單位

最底層的單位是人為定義的,而且在人類的不同歷史時期可能有不同的定義。比如中國古代的 1 斤跟現代的 1 斤在重量上並不相等,但它並不妨礙兩個時代人的日常使用。


發生了什麼?

在一般人看來,數學是人類最嚴謹的學科,就好像是神靈賜予人類的先驗禮物。

數學是嚴謹不假,但數學的基礎並不是先驗的,而是源自人類生活的經驗。

雖然數學中充斥著大量的公理、證明啥的,但數學最底層卻是一些”簡單“的公設(公理。比如平面幾何學中的五大公設)。小學老師告訴我們:所謂公理,是不需要證明的(不證自明)。這個所謂的”不證自明“,說白了就是憑經驗。

不但數學中最基本的公理憑經驗,數學中的很多求證過程也是憑經驗——雖然聽起來不可思議。

我們回頭看看矩形面積的推導過程:

一開始我們只是好奇心作怪,想知道如何用數來表示這麼一塊矩形區域(定義問題)。

我們發現自己不知道怎麼求解它,沒辦法只能給它個代號(符號化)。

另外我們發現矩形有兩個屬性:長和寬(引數化)。

經驗告訴我們,矩形面積跟矩形的長和寬存在某種關係(關聯)。

於是我們根據經驗試圖去尋找它們之間到底存在哪些關係(找規律)。

經過窮舉,我們得到了幾個很有意思的等式。

再經過一番思考和觀察,我們發現這幾個等式可以泛化成一般等式(就是裡面的具體數值可以變數化)。(關係泛化

面對一般化的等式,我們利用已有知識(如 w = w * 1,即一個數乘以 1 等於它自身,這是根據乘法的定義得出的結論),最終推匯出我們想要的東西(基於既有知識的推理

在整個過程中,經驗、觀察、實驗、符號化起到非常重要的作用。


關程式設計師何事?

這裡涉及到我們如何思考和解決問題。

當我們面臨一個陌生的(而且看起來很難的)問題時,本能反映是摸不著頭腦。

然後就開始抓瞎。

我們要做的是,在抓瞎之前冷靜一下,看看遠方,做幾次深呼吸,再多想幾次問題本身。

不要在還沒搞清楚問題是什麼之前就開始解決問題。

我們舉個字串編輯距離的例子。

問題:給定兩個字串 str1 和 str2,求 str1 至少需要經過多少次操作才能變成 str2。

你第一次遇到這個問題是什麼感受呢?反正我是一臉懵逼,狗咬刺蝟,無處下牙。

然後的感覺是:這個問題我好像沒怎麼弄明白?

所以要先分析問題本身

什麼叫”經過多少次操作“?

想要知道經過多少次操作,起碼先要知道都有什麼樣的操作吧?

我們記得前面那位老農是面對著一畝三分地思考問題——而我們現在面對的是兩個抽象的(符號)str1 和 str2。

所以接下來要將抽象的問題具象化

我們不妨寫兩個具體的字串:

str1 = "abcd"

str2 = "acdb"

現在變成了具體的問題:怎樣通過最少的操作將字串"abcd"變成"acdb"?

這個問題其實分兩部分:

  1. 怎樣的操作
  2. 最少的操作?

一個字串想變成另一個字串,它總要一個字元一個字元地處理吧(你不可能連看都不看某個字元就能做出正確決定)。

所以”操作“這個概念就變成了”一個字元怎樣變成另一個字元“的問題。

我們就看第一個字元:str1 中的 a 如何變成 str2 中的 a?

想都不用想,它倆相等,不用做任何操作——不做任何操作本身就是一種操作型別。

然後我們看看第二個字元:str1 中的 b 如何變成 str2 中的 c?

它倆不一樣,所以必須做些行動才行。

一種方案是,將 b 替換成 c。

第二種方案是,將 b 刪除掉,讓其它的字元(可能是插入的新字元)來應對 c 這個字元。

第三種方案是,在 a 和 b 之間插入 c。

沒有其他方案了。

至此我們搞明白了所謂”操作“到底是什麼:啥也不做、替換、刪除、插入,一共四種。

然後我們再把那個恐怖的修飾語”最少的“加上去——你會發現此時頭又大了。

如果不限制”最少“,其實很好辦,最無腦的做法就是先把 str1 中的字元全刪掉,然後再一股腦把 str2 中的每個字元挨個插入即可,操作步數是 n + m(兩個字串長度之和)。

這個最無腦的解法雖然無用,但它揭示了該問題的最大操作步數是 n+m,所以如果你整出的解比它還大,那肯定不對了。

我們再回到上面的具體字串:怎樣通過最少的操作將字串"abcd"變成"acdb"?

我們一個一個嘗試

其中一種方案是:第一個 a 保持不變,刪掉第二個 b,最後在 d 後面插入 b,操作步數是 2。

然後我們又人肉嘗試了其它方案,發現所有方案中最小的就是 2。

然而,問題好像並沒有什麼進展——我們仍然不知道如何求得最少步數(除了人肉)。

如果我們的思路一直停留在具體問題上,便不太可能得到問題的解。

人類區別於其他動物的地方在於,人類能夠將具象的事物(現象)提升成抽象的符號,進而形成思維模型(這也正是數學的奧祕,現在你應該能夠理解數學家為啥那麼喜歡玩符號了)。

將問題具象化有助於我們深刻理解問題本身,現在是時候讓思維從具象回到抽象層面了。

我們先對字串本身做抽象化表述。

字串就是字元序列,它的本質就是集合(外加了個限制:順序相關,即裡面的字元有特定順序)。

所以問題變成:如何通過最少操作步數將長度為 n 的字元序列 \(\{x,y,z\cdots\}_n\) 轉換成長度為 m 的字元序列 \(\{x^\prime,y^\prime,z^\prime\cdots\}_m\)

答案是:不知道!

那位農夫在求不出面積的時候做了個驚人的舉動:給它個符號!

我們也這麼辦。

我們假設 \(\{x,y,z\cdots\}_n\) -> \(\{x^\prime,y^\prime,z^\prime\cdots\}_m\) 的最小編輯距離是 N(管它怎麼求的)。

跟農夫將 S 換成 S(l,w) 以體現面積和邊長的關係一樣,我們也將引數放進去:

N(str1, str2)

這個組合符號是說:它能算出字串 str1轉換為 str2 的最少操作步數(最小編輯距離)。

至於怎麼算的,我們暫時不關心。

這玩意是不是像極了程式設計中的介面?

作為程式設計師,你可能覺得這個 N 太醜了,那我們換個”人性化“點的符號(名字):shortestEditDist(str1, str2)。

然後——我們盯著這奇怪的符號思考了半天,一籌莫展!

符號本身並不能告訴我們更多,我們必須開動推理的馬達去加工符號。

對於集合型別問題,我們常用的一種思維模式是:通過子集的解推出父集的解。

具體地,對於”串“類的問題,假如我們已經知道某個子串的解,看能否通過這個子串推出某個父串的解。

作這種解題假設的依據是:子串和父串具有相同的模式。

於是我們把那個“無知而又牛逼”的組合符號(也就是函式)稍作調整:

shortestEditDist(str1, str2, i, j)

多了兩個引數 i 和 j。它是說:我知道子串 str1[0,i] -> str2[0,j] 的最小編輯距離 M(其中 0<=i<n, 0<=j<m)——別管我是怎麼知道的。

如圖:

image-20220711063616495

我們想讓問題稍微得到推進。

於是我們問自己:我們怎樣得到 str1[0,i+1] -> str2[0,j+1] 的解?

根據我們之前對字元轉換的分析可知有四種操作:

方案一:當 d 等於 \(d^\prime\) 時,不用做任何操作。

如圖:

image-20220711064629788

公式:shortestEditDist(str1, str2, i+1, j+1) = shortestEditDist(str1, str2, i, j)

否則:

方案二:先求出 str1[0,i] -> str2[0,j+1] 的解,然後刪掉 d。

如圖:

image-20220711063533987

公式:shortestEditDist(str1, str2, i+1, j+1) = shortestEditDist(str1, str2, i, j+1) + 1

其中的 +1 表示刪除 d 的操作。

方案三:先求出 str1[0,i+1] -> str2[0,j] 的解,然後在後面插入 \(d^\prime\)

如圖:

image-20220711064131162

公式:shortestEditDist(str1, str2, i+1, j+1) = shortestEditDist(str1, str2, i+1, j) + 1

方案四:先求出 str1[0,i] -> str2[0,j] 的解,再將 d 替換成 \(d^\prime\)

如圖:

image-20220711064429270

公式:shortestEditDist(str1, str2, i+1, j+1) = shortestEditDist(str1, str2, i, j) + 1

四種方案,用誰呢?不知道,只能全部求出來取結果最小的。

看了上面的推導,你是不是心裡虛得不行?

用一堆“一無所知”的公式推來推去,自娛自樂?

不要不信邪,前面的面積公式不就是在“一無所知”的情況下自推自導搞出來的嗎?

既然我們假設已經知道 shortestEditDist(str1, str2, i, j) ,而這裡的 i、j 的範圍又是 0<=i<n, 0<=j<m,那我們自然可以在有效範圍內隨便取值(也就是說取 i、i+1、j、j+1 都是合法的)。

注意:嚴格來說 i+1、j+1 會越界的,但我們暫時不考慮越界情況。

所以我們就可以將諸如 shortestEditDist(str1, str2, i+1, j) 放在等式右邊作為“已知值”來求shortestEditDist(str1, str2, i+1, j+1)。

有同學可能會說:那我們也可以假設 shortestEditDist(str1, str2, i+1, j+1) 已知啊?

是的,你可以隨便假設,但如果所有問題都“假設已知”了,那就真的自欺欺人了,對問題求解沒有一點好處。

這種看似是文字遊戲的邏輯伎倆,本質是在尋找關係。

是的,我們最看重的是關係,而不是某個式子的絕對解。所以我們可以在推理的時候,將某些東西設為已知,而將另一些東西進一步符號化(想想高中學習的所謂的“複合函式”,大學學的高階求導——多半你是想不起來了)。

我們寫一下程式碼:

// 我能算出子串 str1[0,i] 轉換為 str2[0,j] 的最小編輯距離,其中 0 <= i < n,0 <= j < m
func shortestEditDist(str1, str2 string, i, j int) int {
	// 還沒想好怎麼實現...
}
// 求 str1 轉成 str2 的最小編輯距離
func EditDistance(str1, str2 string) int {
	// 既然你做了那樣的宣告,我就這樣用
	// 算不出來唯你是問!
	return shortestEditDist(str1, str2, len(str1)-1, len(str2)-1);
}

我們根據前面的推導(四個方案)來實現 shortestEditDist:

// 我能算出子串 str1[0,i] 轉換為 str2[0,j] 的最小編輯距離,其中 0 <= i < n,0 <= j < m
// 實現方式:用 shortestEditDist(str1, str2, i-1, j-1) 來求解  shortestEditDist(str1, str2, i, j)
// 注意:前面推導時是從 i,j -> i+1,j+1,本質是一樣的
func shortestEditDist(str1, str2 string, i, j int) int {
	// 考慮具體情況之前,先考慮遞迴函式的終止條件
	if i == -1 && j == -1 {
		// 兩個都是空串,無需做任何處理
		return 0
	}

	if j == -1 {
		// str2 的子串是空串,str1 的子串[0,i] 只能採用刪除操作才能轉為 str2 的子串
		return i + 1
	}

	if i == -1 {
		// str1 的子串是空串,此時只能採用插入操作才能轉為 str2 的子串
		return j + 1
	}

	// 一般情況,根據我們前面的討論,有四種情況

	// 情況1:當前兩個字元相同
	// 方案一
	if str1[i] == str2[j] {
		// 兩個字元相同,先將 str1[0,i-1] 轉成 str2[j-1],然後本步驟啥也不做
		return shortestEditDist(str1, str2, i-1, j-1)
	}
	
	// 情況2:兩個字元不同,有三種情況
	// 因為我們不知道本步驟到底採用什麼方案操作步數最少,所以只能都嘗試一遍,然後取最小值

	// 方案二:先用 str1[0,i-1] 轉換出 str2[0,j],再刪除 str1[i]
	c1 := shortestEditDist(str1, str2, i-1, j) + 1

	// 方案三:先用 str1[0,i] 轉換出 str2[0,j-1],再在後面插入 str2[j] 字元
	c2 := shortestEditDist(str1, str2, i, j-1) + 1

	// 方案四:先用 str1[0,i-1] 轉換出 str2[0,j-1],再將 str1[i] 替換成 str2[j]
	c3 := shortestEditDist(str1, str2, i - 1, j - 1) + 1

	// 取三種情況代價最小的
	return minInt(c1, c2, c3)
}

如此就求出來了。

有研究過最小編輯距離的同學可能知道最小編輯距離可以通過動態規劃解決,效能更高。

本文之所以使用暴力遞迴,因為一方面它和動態規劃的思路本質是一樣的,都是遞推(或說歸納法)解題。會動態規劃的同學很容易將暴力遞迴改造成動態規劃,而不會的,看遞迴寫法要比看動態規劃寫法直觀明白得多。

初識遞迴思想的同學覺得這玩意不可思議,咋在不知不覺中就把結果求出來了呢?

其實它就是我們在高中學的那個強大而陌生的數學歸納法,它要求問題具備三個特性:

  1. 一個集合和它的任意子集具有相同的模式;
  2. 可以通過子集 \(S_n\) 的解求 \(S_{n+1}\) 的解;
  3. 能夠知道初始值(如 n=0)的解;

它的思想很簡單:既然能夠通過 \(S_n\) 的解求 \(S_{n+1}\) 的解,而我們又知道 \(S_0\) 的解,那一定能知道 \(S_1\) 的解,進而知道 \(S_2\) ......

有一點需要注意的是,我們在前面推導(遞推)的時候,是通過 \(S_n\) 的解推導 \(S_{n+1}\) 的解(邏輯上是先考慮 \(S_n\) ),而在寫遞迴函式的時候恰恰是反過來的:我們是先考慮 \(S_{n+1}\),在處理過程中發現需要藉助 \(S_{n}\) 來求解。而寫在遞迴函式開頭的函式終止條件恰恰就是遞推(或說數學歸納法)中的初始值。


總結

雖然推導矩形面積和求解字串最小編輯距離在邏輯上存在很大差別,但在思維模式上存在很多相似性:

  1. 先定義清楚問題(什麼是面積;什麼是編輯距離);
  2. 用具體例項深入理解和挖掘問題(50*100 的矩形;"abcd" 和 "acdb" 兩個字串);
  3. 將求解物件引數化(矩形的長 l、寬 w;最小編輯距離中的 i、j);
  4. 將未知解符號化(面積 S;最小編輯距離 N);
  5. 將符號和引數組合成新的複合符號(函式。比如面積中的 S(l,w);編輯距離中的 N(str1,str2,i,j)),給問題和解建立初步的關係;
  6. 通過觀察、試驗,探求不同引數對解的影響(矩形邊長加倍讓面積加倍;可以用 str1[n-1] -> str2[m-1] 的解求 str1[n] -> str2[n] 的解);
  7. 泛化第 6 步,得出最終解;






相關文章