演算法課堂實驗報告(二)——python遞迴和分治(第k小的數,大數乘法問題)
python實現遞迴和分治
一、開發環境
開發工具:jupyter notebook 並使用vscode,cmd命令列工具協助程式設計測試演算法,並使用codeblocks輔助編寫C++程式
程式語言:python3.6
二、實驗目標
1. 熟悉遞迴和分治演算法實現的基本方法和步驟;
2. 學會分治演算法的實現方法和分析方法:
三、實驗內容
問題1,線性時間選擇問題:
1) 在 4 59 7 23 61 55 46中找出最大值,第二大值,和第四大的值(要求不允許採用排序演算法),並與第一章實現的快速排序演算法進行比較。
2) 隨機生成10000個數,要求找出其中第4999小的數,並與第一章實現的快速排序演算法進行比較。
將4 59 7 23 61 55 46作為列表輸入函式中,並且返回輸出的結果
方法:直接遍歷找到最大值,然後刪除,再遍歷,依次找到第2,3,4大的資料並且返回
每次都需要遍歷一遍列表中所有的元素
快速排序的方法:將列表使用快速排序進行從大到小的排列,複雜度O(nlogn)然後直接返回對應的數,複雜度O(1),快速排序程式碼來自上一節。
程式碼如下所示:
# 直接遍歷找到最大值,然後刪除,再遍歷,依次找到第2,3,4大的資料並且返回 def find_1_2_4(lis): find1=lis[0];find2=lis[0];find3=lis[0]; for i in range(len(lis)): if find1<lis[i]: find1 = lis[i] lis.remove(find1) for i in range(len(lis)): if find2<lis[i]: find2=lis[i] lis.remove(find2) for i in range(len(lis)): if find3<lis[i]: find3=lis[i] lis.remove(find3) find3=lis[0] for i in range(len(lis)): if find3<lis[i]: find3=lis[i] lis.remove(find3) return find1, find2, find3 # 快速排序,劃分操作, 快速排序的輔助函式 def split(lis, first, last): pivot = lis[first] left = first right = last while left<right: while pivot < lis[right]: right=right-1 while left < right and (lis[left] < pivot or lis[left] == pivot): left=left+1 if left < right: lis[left], lis[right] = lis[right], lis[left] # 確定好基準位置 pos = right lis[first] = lis[pos] lis[pos] = pivot return pos # 快速排序實現 def quicksort(lis, first, last): if first < last: pos = split(lis, first, last) quicksort(lis, first, pos-1) quicksort(lis, pos+1, last) return lis if __name__ == '__main__': lis1 = [4, 59, 7, 23, 61, 55, 46] lis2 = [4, 59, 7, 23, 61, 55, 46] import time starttime = time.time() print(find_1_2_4(lis1)) endtime = time.time() print("執行時間為:",endtime-starttime) starttime = time.time() quicksort(lis2, 0,len(lis2)-1) print(lis2[-1], lis2[-2], lis2[-4]) endtime = time.time() print("執行時間為:",endtime-starttime)
實驗結果如下圖所示:
可見在資料量如此之小的情況下,時間相差無幾,並不能反映出效率問題。
2)首先生成10000個隨機資料,找出第4999小的數,如果直接使用排序演算法,那麼時間複雜度就是O(nlogn),然後直接輸出第4999個數就行了。
但是我們可以使用分治策略對演算法進行優化,使得演算法的複雜度小於排序演算法的O(nlogn)
演算法步驟如下:
首先選取一個基點,比支點大的放支點右邊,比支點小的放支點左邊(到這裡和快排一樣),看看支點左邊有多少個數,如果大於k-1說明在k在左邊,左邊遞迴,如果小於k-1說明在k右邊,右邊開始遞迴,並且新尋找的是k-pos小的數,如果相等,那麼返回
程式碼如下:
# 尋找第k小的數的輔助函式
def k_find(lis, k):
pivot = lis[len(lis)//2]
left = 0
right = len(lis)-1
lis[0],lis[len(lis)//2]=lis[len(lis)//2],lis[0]
while left<right:
while pivot < lis[right]:
right=right-1
while left < right and (lis[left] < pivot or lis[left] == pivot):
left=left+1
if left < right:
lis[left], lis[right] = lis[right], lis[left]
# 確定好基準位置
pos = right
lis[0] = lis[pos]
lis[pos] = pivot
count=pos+1
if count==k:
return pivot
elif count>k:
return k_find(lis[0:pos], k)
else:
return k_find(lis[pos:], k-pos)
if __name__ == "__main__":
import random
import cProfile
# 產生100000個隨機陣列
num = 100000
# array = [random.randint(1, 1000) for i in range(num)]
array=[]
a=1
for i in range(num):
a = a+random.randint(1,20)+1
array.append(a)
# 亂序操作
random.shuffle(array)
random.shuffle(array)
import copy
# 進行一個深度拷貝,用於測試
arraycopy = copy.deepcopy(array)
# 用O(n)的演算法得到第k小的數
k=4999
import time
starttime= time.time()
n = k_find(array, k)
endtime=time.time()
print("使用線性查詢的時間為:",endtime-starttime)
print("查詢得到的數為:",n)
starttime= time.time()
quicksort(arraycopy, 0, len(arraycopy)-1)
endtime=time.time()
print("使用快排查詢的時間為:",endtime-starttime)
print("查詢得到的數為:",arraycopy[k-1])
執行結果如下圖所示:
查詢到了相同的數字,然後發現效率竟然相差了十倍。
在這一版本的程式碼中,我們只要將程式碼中的
while pivot < lis[right]:
right=right-1
while left < right and (lis[left] < pivot or lis[left] == pivot):
left=left+1
這一部分的大於號與小於號略作修改,即可用於解決第k大的數這一問題,即:
while pivot > lis[right]:
right=right-1
while left < right and (lis[left] > pivot or lis[left] == pivot):
left=left+1
將修改後的程式碼用於求解leetcode上第215號題,成功通過
我實現的版本還是存在很多問題的,比如基準數的選擇上,而且在出現相同數字的時候可能會出現問題,參考教科書上的解決方案還是很好的,取中間數的中間數,保證了效率,所以我照著書上敲了一遍程式碼(下面這個方案純粹是照著敲上去的)
# 線性時間選擇
lis1 = [4, 59, 7, 23, 61, 55, 46]
# 選擇第k小的數的分治演算法
def select_fct(array, k):
if len(array) <= 10: # 邊界條件
array.sort()
return array[k]
pivot = get_pivot(array) # 得到陣列的支點數
array_lt, array_gt, array_eq = patition_array(array, pivot) # 按照支點數劃分陣列
if k<len(array_lt): # 所求數在支點數左邊
return select_fct(array_lt, k)
elif k < len(array_lt)+len(array_eq): # 所求數為支點數
return array_eq[0]
else: # 所求數在支點數右邊
normalized_k = k-(len(array_lt)+len(array_eq))
return select_fct(array_gt, normalized_k)
# 得到陣列的支點數
def get_pivot(array):
subset_size = 5 # 每一個數組有5個元素
subsets = []
num_medians = len(array) // subset_size # 用於記錄各組元素
if(len(array) % subset_size) > 0:
num_medians +=1 # 不能被5整除,組數加1
for i in range(num_medians): # 劃分成若干組,每組5個元素
beg = i*subset_size
end = min(len(array), beg+subset_size)
subset = array[beg:end]
subsets.append(subset)
medians = []
for subset in subsets:
median = select_fct(subset, len(subset)//2) # 計算每一組的中間數
medians.append(median)
pivot = select_fct(medians, len(subset)//2) # 中間數的中間數
return pivot
# 按照支點數劃分陣列
def patition_array(array, pivot):
array_lt = []
array_gt = []
array_eq = []
for item in array:
if item < pivot:
array_lt.append(item)
elif item > pivot:
array_gt.append(item)
else:
array_eq.append(item)
return array_lt, array_gt, array_eq
import random
if __name__ == "__main__":
import cProfile
# 產生100個隨機陣列
num = 100000
array = [random.randint(1, 1000) for i in range(num)]
# print(array)
random.shuffle(array)
random.shuffle(array)
# 用O(n)的演算法得到第k小的數
k=15000
# kval = select_fct(array, k)
cProfile.run("select_fct(array, k)")
# print(kval)
問題2,大數乘法問題。分別嘗試計算9*9, 9999*9999, 9999999999*8888888888的結果
問題背景:
所謂的大數相乘,就是指兩個位數比較大的數字進行相乘,一般而言相乘的結果超過了進本型別的表示範圍,所以這樣的數不能夠直接做乘法運算。
其實乘法運算可以拆成兩步,第一部,將乘數與被乘數逐位相乘,第二步,將逐位相乘得到的結果對應相加起來。
傳統的大數乘法問題就是將數值的計算利用字串來計算,從而解決位數溢位的問題。這種型別的以前曾經使用C++實現過。
然而我們現在要做的不是這個問題,我們的問題是請設計一個有效的演算法,可以進行兩個n位大整數的乘法運算,因為是nXn位的問題,解決方案偏向於定製化,我們需要的是將大數乘法O(n²)的複雜度降低,利用分治演算法,將大的問題分解成為小的問題,先分,然而再合,從而優化解的方案。
程式碼實現:
前兩個問題很簡單,不再實現
9999999999*8888888888
對於這個問題,我們首先直接使用C++進行計算,結果肯定是有問題的
初次實驗如圖所示:
下面改用python語言 利用分治演算法進行大數乘法的計算:
# 大數乘法
9*9
9999*9999
9999999999*8888888888
import math
# 計算符號
def sign(x):
if x<0:
return -1
else:
return 1
# 寫一個計算大數乘法的函式 a和b是大數 計算他們倆相乘
def divideConquer(x, y, n):
s = sign(x)*sign(y)
x = abs(x)
y = abs(y)
if x==0 or y==0: # 如果其中有一個為0 直接返回0
return 0
elif n==1: # 遞迴結束條件 n=1
return x*y*s
else:
A = x // math.pow(10, n//2) # 獲得第一個數的前半部分
B = x - A*math.pow(10, n//2) # 獲得第一個數的後半部分
C = y // math.pow(10, n//2) # 獲得第二個數的前半部分
D = y - C*math.pow(10, n//2) # 獲得第二個數的後半部分
AC = divideConquer(A, C, n//2) # AC相乘的結果
BD = divideConquer(B, D, n//2) # BD相乘的結果
ABCD = divideConquer(A-B, D-C, n//2)+AC +BD # 計算中間量AD+BC的結果 實際的計算方式是(a-b)(d-c)+ac+db
return s * (AC*math.pow(10, n)+ABCD*math.pow(10, n//2)+BD)
print(A,B,C,D)
if __name__ =='__main__':
print(int(divideConquer(9999999999,8888888888,10)))
在很快的時間之內就可以完成。
測試結果:
提出問題:是不是因為python的內部機制才使得計算這麼快的呢?
於是重新進行了測試
if __name__ =='__main__':
import cProfile
cProfile.run("divideConquer(123456789123456789123456789123456789123456789123456789123456789123456789,123456789123456789123456789123456789123456789123456789123456789123456789,72)")
cProfile.run("123456789123456789123456789123456789123456789123456789123456789123456789*123456789123456789123456789123456789123456789123456789123456789123456789")
這裡使用了72位的數字
結果如圖所示:
分治法的函式呼叫次數還是相當的多的
可是為什麼直接用py內部的機制也是這麼快呢?python到底是如何實現大數相乘的?查閱了相關資料之後發現,大整數乘法還可以採用FFT求迴圈卷積來實現,複雜度為O(nlogn)(網上這麼說的,我也不知道對不對),然而python內部實現大整數相乘還是採用的是分治演算法,理由是醬紫的:py日常計算的大整數不夠大,所以還是用的是分治。