常見數據結構和算法實現

常見數據結構和算法實現

數據結構和算法作爲程序員的基本功,一定得穩紮穩打的學習,我們常見的框架底層就是各類數據結構,例如跳錶之於redis、B+樹之於mysql、倒排索引之於ES,熟悉了底層數據結構,對框架有了更深層次的理解,在後續程序設計過程中就更能得心應手。掌握常見數據結構和算法的重要性顯而易見,本文主要講解了幾種常見的數據結構及基礎的排序和查找算法,最後對高頻算法筆試面試題做了總結。本文會持續補充,希望對大家日常學習或找工作有所幫忙。

文章目錄

1、什麼是數據結構?(研究應用程序中數據之間邏輯關係、存儲方式及其操作的學問就是數據結構)

  • 程序中數據大致有四種基本邏輯結構:集合(同屬一個集合)/線性關係(一對一)/樹形結構(一對多)/圖狀結構或網狀結構(多對多)
  • 物理存儲結構:順序存儲結構/非順序結構(鏈式存儲/散列結構)
  • 算法的設計取決於邏輯結構;算法的實現依賴於存儲結構

2、爲什麼學習數據結構和算法?

有3點比較重要 (王爭)

  • 1、直接好處是能夠有寫出性能更優的代碼;數據結構:存儲;算法:計算
  • 2、算法,是一種解決問題的思路和方法,有機會應用到生活和事業的其他方面;
  • 3、長期來看,大腦思考能力是個人最重要的核心競爭力,而算法是爲數不多的能夠有效訓練大腦思考能力的途徑之一。
  • 入門《大話數據結構 程傑》《算法圖解》《數據結構與算法分析:Java語言描述》(大學課本 僞代碼)《劍指offer《編程珠璣》(對大數據量處理的算法)《編程之美》(超級難)《算法導論》(很厚很無聊)《算法第四版》(推薦 本書沒有動態規劃) LeetCode 王爭google 《算法帝國》《數學之美》《算法之美》(閒暇閱讀) https://github.com/wangzheng0822/algo 《計算機程序設計藝術》面試必刷的寶典:《劍指offer》《編程珠璣》《編程之美》

3、有哪些常見的數據結構?

概念 簡介
數據結構 數組鏈表(單鏈表/雙向鏈表/循環鏈表/雙向循環/靜態鏈表)、(順序棧/鏈式棧)、隊列(雙端隊列/阻塞隊列在線程池中大量使用/併發隊列/併發阻塞隊列)、散列表(散列函數/衝突解決(鏈表法/開放尋址)/二分快速定位元素/動態擴容/位圖)、二叉樹(平衡二叉樹/二叉查找樹/mysql底層)、(b樹/B+樹/2-3樹/2-3-4樹)、(大頂堆/小頂堆/優先級隊列/大數據量求topK)、(圖的存儲(鄰接矩陣/鄰接表)/拓撲排序/最短路徑/最小生成樹/二分圖)、跳錶(鏈表可以快速二分查找元素)、Trie樹(用於字符串補全/ES底層搜索的字符串匹配)
算法 遞歸、排序(O(n2)冒泡/選擇/插入/希爾 O(lgn)歸併/快排/堆排 O(n)計數/基數/桶)、二分查找(線性表/樹結構/散列表)、搜索(深度優先/廣度優先/A啓發式)、哈希算法、字符串匹配算法(樸素/KMP/Robin-Karp/Boyer-Moore/AC自動機/Trie樹/後綴數組)、 複雜度分析(空間複雜度/時間複雜度(最好/最差/平均/均攤))、基本算法思想(貪心算法、分治算法、回溯算法、動態規劃) 、其他(數論/計算幾何/概率分佈/並查集/拓撲網絡/矩陣計算/線性規劃)
面試題 鏈表:單鏈表反轉(把指針轉向),鏈表中環的檢測(遍歷+數組保存遍歷過的元素/雙指針,前指針走兩步,後指針走一步),兩個有序的鏈表合併(雙重遍歷),刪除鏈表倒數第n個結點(雙指針,前指針比後指針先走n步),求鏈表的中間結點(雙指針,前指針走兩步,後指針走一步)等、:在函數調用中的應用,在表達式求值中的應用,在括號匹配中的應用(網頁爬蟲中< html>< script>的排除)、排序:如何在O(n)的時間複雜度內查找一個無序數組中的第 K大元素(基數排序)

4、說一下幾種常見的排序算法和分別的複雜度,java提供的默認排序算法(數組排序)

4.1、排序算法

排序算法指標

排序方法 時間複雜度(表示的是一個算法執行效率與數據規模增長的變化趨勢) 最好最差情況 穩定性 最小輔助空間(表示算法的存儲空間與數據規模之間的增長關係)
選擇排序 n^2 - 不穩定 空間O(1)
  • 選擇排序(原理:將待排序的元素分爲已排序(初始爲空)和未排序兩組,依次將未排序的元素中值最小的元素放入已排序的組中)
public static void selectSort(int[] a) {
	int temp,flag = 0;
	int n = a.length;
	for (int i = 0; i < n; i++) {
		temp = a[i]; //第一個數據給temp a[i]爲已排序區間的末尾
		flag = i;
		for (int j = i + 1; j < n; j++) {
			if (a[j] < temp) {
				temp = a[j]; // 值
				flag = j; // 位置
			}
		}
		if (flag != i) {
			// 最小數據與第一個數據進行交換
			a[flag] = a[i];
			a[i] = temp;
		}
	}
}
  • 插入排序 n^2 空間O(1) 穩定(每次將一個待排序的元素,按其關鍵字的大小插入到前面已經排好序的子文件的適當位置) 經常使用
public static void insertSort(int[] a) {
	if (a != null) {
		for (int i = 1; i < a.length; i++) {
			// 尋找插入的位置
			int temp = a[i], j = i;
			if (a[j - 1] > temp) {
				while (j >= 1 && a[j - 1] > temp) {
					a[j] = a[j - 1];//依次後移
					j--;
				}
			}
			a[j] = temp;//插入合適的位置
		}
	}
}

冒泡 n^2 穩定(相鄰兩元素進行比較,如有需要則進行交換)(兩個for循環 一輪比較9次,二輪比較8次)

public class 冒泡排序 {
	// 冒泡排序,a表示數組,n表示數組大小
	public void bubbleSort(int[] a, int n) {
		if (n <= 1)
			return;
		for (int i = 0; i < n; ++i) {
			boolean flag = false;// 提前退出冒泡循環的標誌位
			for (int j = 0; j < n - i - 1; ++j) {
				if (a[j] > a[j + 1]) { // 交換
					int tmp = a[j];
					a[j] = a[j + 1];
					a[j + 1] = tmp;
					flag = true; // 表示有數據交換
				}
			}
			if (!flag)
				break; // 沒有數據交換,提前退出
		}
	}
}
  • 希爾 nlgn~n^2 (將整個待排元素序列分割成若干個子序列,分別進行直接插入排序,待整個序列的元素基本有序,在對全體元素進行一次直接插入排序)
  • 快排 nlgn 空間複雜度O(lgn) 不穩定 基於分割交換排序的原則,這種類型的算法佔用空間較小,他將待排序列表分成三個主要部分:小於基準的元素,基準元素,大於基準的元素
  • (思想:通過一次劃分:將待排元素分爲左右兩個子序列,左側均小於基準元素排序碼,右側均大於等於基準元素排序碼,反覆遞歸,直至每一個序列只有一個元素爲止)
  • 快排的優化方法,在選擇基準元素時,可以(1、三數取中法(首/尾/中間各取一個數據作爲分區點,取中間數作爲分區點) 2、隨機法)
public static void sort(int array[], int low, int high) {
	int index;
	if (low >= high) {
		return;
	}
	int i = low;
	int j = high;
	index = array[i];//基準點
	while (i < j) {//由小到大排列   好吧,通過代碼知道了掃描的順序,從右開始向左掃描,若是交換了元素,從左往右掃描,然後依次進行
		while (i < j && array[j] >= index) {
			j--; //從右向左掃描
		}
		if (i < j) {//說明上述array[j]<index,while循環跳出,該值放置在基準左側
			array[i++] = array[j];
		}
		while (i < j && array[i] < index) {
			i++; //從左向右掃描
		}
		if (i < j) {//說明上述array[i]>index,while循環跳出,該值放置在基準右側
			array[j--] = array[i];
		}
	}
	array[i] = index;//最後把基準元素放上去
	sort(array, low, i - 1);
	sort(array, i + 1, high);
}

編程題:用快排思想在O(n)內查找第K大元素?比如,4,2,5,12,3 這樣一組數據,第3大元素就是4。

思路:選擇數組區間A[0…n-1]的最後一個元素A[n-1]作爲pivot,對數組A[0…n-1]原地分區,這樣數組就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1],如果p+1=K,那A[p]就是要求解的元素;如果K>p+1, 說明第K大元素出現在A[p+1…n-1]區間,我們再按照上面的思路遞歸地在A[p+1…n-1]這個區間內查找

public class 查找無序數組的第K大的數 {
	public static int kthSmallest(int[] arr, int k){
		if (arr == null || arr.length < k) {
			return -1;
		}
		int partition = partition(arr, 0, arr.length - 1);
		//經過一輪分區
		while(partition + 1 != k){
			if(partition + 1 < k){//說明第K大元素出現在A[p+1…n-1]區間
				partition = partition(arr, partition + 1, arr.length - 1);
			}else{//說明第K大元素出現在A[1…p-1]區間
				partition = partition(arr, 0, partition - 1);
			}
		}
		return arr[partition];//一次成功
	}
	private static int partition(int[] arr, int p, int r){
		int pivot = arr[r];
		int i = p;
		for(int j = p; j <= r-1; j++){
			// 這裏要是 <= ,不然會出現死循環,比如查找數組 [1,1,2] 的第二小的元素   這操作真的秀
			if(arr[j] < pivot){//放基準元素左側
				swap(arr, i, j);
				i++;
			}
		}
		swap(arr, i, r);
		return i;
	}
	private static void swap(int[] arr, int i, int j){
		if(i == j){
			return;
		}
		int tmp = arr[i];
		arr[i] = arr[j];
		arr[j] = tmp;
	}
}//時間複雜度O(n)
  • 堆排 nlgn 不穩定
    可以看做是選擇排序的改進,基於比較的排序算法,他將其輸入劃分爲未排序和排序的區域,通過不斷消除最小元素並將其移動到排序區域來收縮未排序區域。
  • 歸併 nlgn 穩定 jdK1.7之前集合工具包默認使用的排序算法 1.7使用的是TimSort排序方法,還沒有研究過 (可分爲二路歸併/多路歸併)
    使用分治思想,將複雜問題分解爲較小的子問題,直到分解的足夠小,可以輕鬆解決問題爲止。(將兩個有序表合併成一個有序表) 由大到小排列
//使用分治的思想
public static void MergeSort(int array[], int p, int r) {
	if (p < r) {
		int q = (p + r)/2;
		MergeSort(array, p, q);
		MergeSort(array, q + 1, r);
		Merge(array, p, q, r);
	}
}
//Merge的作用:將已經有序的A[p…q]和A[q+1…r]合併成一個有序的數組,並且放入A[p…r]。
public static void Merge(int array[], int p, int q, int r) {
	int i, j, k, n1, n2;
	n1 = q - p + 1;
	n2 = r - q;
	int[] L = new int[n1];
	int[] R = new int[n2];
	for(i = 0, k = p; i < n1; i++, k++){
		L[i] = array[k];
	}
	for(i = 0, k = q + 1; i < n2; i++, k++){
		R[i] = array[k];
	}
	//相當於合併兩條有序的鏈表  由大到小排列
	for(k = p, i = 0, j = 0; i < n1 && j < n2; k++){
		if (L[i] > R[j]) {
			array[k] = L[i];
			i++;
		} else {
			array[k] = R[j];
			j++;
		}
	}
	if(i < n1){
		for (j = i; j < n1; j++, k++){
			array[k] = L[j];
		}
	}
	if(j < n2){
		for (i = j; i < n2; i++, k++){
			array[k] = R[i];
		}
	}
}
  • 基數排序 O(n) 空間複雜度O(rd) 穩定(基數排序必須依賴於另外的排序方法 實質是多關鍵字排序)
    是通過比較數字將其分配到不同的“桶裏”來排序元素的。他是線性排序算法之一。
    解決方案:1、最高位優先法MSD 2、最低位優先法LSD

  • 桶排序 O(n) 將要排序的數據分到幾個有序的桶裏,每個桶裏的數據再單獨進行排序
    適用場景:外部排序中(磁盤中,內存有限,無法將數據全部加載到內存中)

  • 計數排序(桶排序的一種特殊形式:每個桶中的數據相同)

  • 排序方法的選擇?
    1、n較小,可以採用直接插入或直接選擇排序
    2、若文件初始狀態基本有序,應選用直接插入、冒泡或隨機的快速排序
    3、n較大,採用複雜度爲O(nlgn)的方法:快排/堆排/歸併
    4、在實際的軟件開發中,爲什麼我們更傾向於使用插入排序而不是冒泡排序算法?
    從代碼實現上來看,冒泡排序的數據交換要比插入排序的數據移動要複雜,冒泡排序需要3個賦值操作,而插入排序只需要1個,所以在對相同數組進行排序時,冒泡排序的運行時間理論上要長於插入排序。

  • 利用快排思想實現在O(n)內查找第K大的元素?

快排核心思想就是分治和分區,選擇數組區間A[0…n-1]的最後一個元素A[n-1]作爲pivot(基準元素),對數組A[0…n-1]原地分區,這樣數組就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。如果p+1=K,那A[p]就是要求解的元素;如果K>p+1, 說明第K大元素出現在A[p+1…n-1]區間,我們再按照上面的思路遞歸地在A[p+1…n-1]這個區間內查找。同理,如果K<p+1,那我們就在A[0…p-1]區間查找

爲什麼這個算法的時間複雜度爲O(n)?
第一次分區查找,我們需要對大小爲n的數組執行分區操作,需要遍歷n個元素。第二次分區查找,我們只需要對大小爲n/2的數組執行分區操作,需要遍歷n/2個元素。
依次類推,分區遍歷元素的個數分別爲、n/2、n/4、n/8、n/16.……直到區間縮小爲1。如果把每次分區遍歷的元素個數加起來,就是:n+n/2+n/4+n/8+…+1。這是一個等比數列求和,最後的和等於2n-1。所以,上述解決思路的時間複雜度就爲O(n)。

如果數據存儲在鏈表中,這三種排序算法還能工作嗎?
一般而言,考慮只能改變節點位置,冒泡排序相比於數組實現,比較次數一致,但交換時操作更復雜;
插入排序,比較次數一致,不需要再有後移操作,找到位置後可以直接插入,但排序完畢後可能需要倒置鏈表;
選擇排序比較次數一致,交換操作同樣比較麻煩。綜上,時間複雜度和空間複雜度並無明顯變化,若追求極致性能,冒泡排序的時間複雜度係數會變大,插入排序係數會減小,選擇排序無明顯變化。

4.2、排序工具類Arrays?如何實現一個通用的、高性能的排序函數?(Java語言採用堆排序實現排序函數,C語言使用快速排序實現排序函數)

  • Arrays擁有一組static方法(equals():比較兩個array是否相等/fill():將值填入array中/sort():用來對array進行排序/binarySearch():在排好序的array中尋找元素/system.arraycopy():array的複製)
  • Jdk7中Arrays.sort()和Collections.sort()排序方法使用注意: jdk1.6中的arrays.sort()和 collections.sort()使用的是MergeSort; jdk1.7中內部實現轉換成了TimSort方法,
  • 對對象間比較的實現
    1、有兩個參數,第一個是比較的數據,第二個是比較的規則,如果comparator爲空,則使用comparableTimSort的sort實現
    2、傳入的待排序數組若小於MIN_MERGE(Java實現中爲32)則從數組開始處找到一組連接升序或嚴格降序(找到後翻轉)的數
    BinarySort:使用二分查找的方法將後續的數插入之前的已排序數組
    3、開始真正的TimSort過程(選取minRun大小,之後待排序數組將被分成以minRun大小爲區塊的一塊塊子數組)
    Timsort的思想:找到已經排好序的數據子序列,然後對剩餘部分排序,最後合併起來
  • java提供的默認排序算法***
    1、對於原始數據類型,目前使用的是所謂雙軸快速排序(Dual-Pivot QuickSort),是一種改進的快速排序算法,早期版本是相對傳統的快速排序
    2、而對於對象數據類型,目前則是使用TimSort,思想上也是一種歸併(Merge)和二分插入排序(binary Sort)結合的優化排序算法。
    思路是查找數據集中已經排好序的分區(這裏叫run 連續升或降的序列),然後合併這些分區來達到排序的目的。
    Java8引入了並行排序算法(直接使用parallelSort方法),這是爲了充分利用現代多核處理器的計算能力,底層實現基於fork-join框架,當處理的數據集比較小的時候,差距不明顯,甚至還表現差一點;但是,當數據集增長到數萬或百萬以上時,提高就非常大了,具體還是取決於處理器和系統環境.

4.3、常見的查找算法?

  • 1、二分查找法(考慮好邊界條件,不要被面試官抓住漏洞)(使用Arrays工具類的binarySearch方法)
    思路:先確定數組的中間位置,然後將要查詢的值與數組中間位置的值進行比較,若小於數組中間值,則要查找的值應位於該中間值之前,依次類推;
    算法: 1、如果關鍵字小於中央元素,只需繼續在數組的前半部分進行搜索;2、如果關鍵字與中央元素相等,則搜索結束,找到匹配元素;3、如果關鍵字大於中央元素,只需繼續在數組的後半部分進行搜索
    限制:用於順序鏈表或排序後的鏈表
    注意事項:1、循環退出條件low<=high;2、mid的取值(low+(high-low)>>1)因爲相比除法運算來說,計算機處理位運算要快得多;3、low和high的更新low=mid+1,high=mid-1
    時間複雜度:o(lgn)
public int bsearch(int[] a, int n, int value) {
	int low = 0;
	int high = n - 1;
	while (low <= high) {
		int mid = (low + high) / 2;//或者int mid = low+((high-low)>>1);
		if (a[mid] == value) {
			return mid;
		} else if (a[mid] < value) {
			low = mid + 1;
		} else {
			high = mid - 1;
		}
	}
	return -1;
}
  • 2、4種常見的二分查找變形問題
    第一種:查找第一個值等於給定值的元素
public int bsearch(int[] a, int n, int value) {
	int low = 0;
	int high = n - 1;
	while (low <= high) {
		int mid = low + ((high - low) >> 1);
		if (a[mid] > value) {
			high = mid - 1;
		} else if (a[mid] < value) {
			low = mid + 1;
		} else {
			if ((mid == 0) || (a[mid - 1] != value)) 
				return mid;  //mid不是第一個數或mid左邊的數不是
			else high = mid - 1;
		}
	}
	return -1;
}

第二種:查找最後一個值等於給定值的元素

public int bsearch(int[] a, int n, int value) {
	int low = 0;
	int high = n - 1;
	while (low <= high) {
		int mid = low + ((high - low) >> 1);
		if (a[mid] > value) {
			high = mid - 1;
		} else if (a[mid] < value) {
			low = mid + 1;
		} else {
			if ((mid == n - 1) || (a[mid + 1] != value))
				return mid;
			else
				low = mid + 1;
		}
	}
	return -1;
}

第三種:查找第一個大於等於給定值的元素

public int bsearch(int[] a, int n, int value) {
	int low = 0;
	int high = n - 1;
	while (low <= high) {
		int mid = low + ((high - low) >> 1);
		if (a[mid] >= value) {
			if ((mid == 0) || (a[mid - 1] < value))
				return mid;
			else
				high = mid - 1;
		} else {
			low = mid + 1;
		}
	}
	return -1;
}

第四種:查找最後一個小於等於給定值的元素

public int bsearch7(int[] a, int n, int value) {
	int low = 0;
	int high = n - 1;
	while (low <= high) {
		int mid = low + ((high - low) >> 1);
		if (a[mid] > value) {
			high = mid - 1;
		} else {
			if ((mid == n - 1) || (a[mid + 1] > value))
				return mid;
			else
				low = mid + 1;
		}
	}
	return -1;
}
  • 3、如果有序數組是一個循環有序數組,比如4,5,6,1,2,3。針對這種情況,如何實現一個求“值等於給定值”的二分查找算法呢?
 public int search(int[] nums, int target) {
	if (nums.length == 1 && nums[0] == target)
		return 0;
	int left = 0;
	int right = nums.length - 1;
	int mid = 0;
	while (left < right) {
		mid = (left + right) >> 1;
		if (nums[left] == target)
			return left;
		if (nums[right] == target)
			return right;
		if (nums[mid] == target)
			return mid;
		if (nums[mid] > nums[left]) { // 第一種情況
			if (target > nums[mid]) {
				left = mid + 1;   //在mid到左側最大值區間
			} else {//target小於中間值
				if (target > nums[left]) {
					right = mid - 1;
				} else {
					left = mid + 1;  //在右側區間
				}
			}
		} else { // 第二種情況   mid小於最左值
			if (target > nums[mid]) {//兩種情況:1、在mid右側  2、在左側
				if (target < nums[right]) {
					left = mid + 1; //1、在mid右側 
				} else {
					right = mid - 1; //2、在左側
				}
			} else {  //在右側的左邊區域
				right = mid - 1;
			}
		}

	}
	return -1;
}
  • 4、x的平方根 LeetCode69 實現int sqrt(int x)函數。計算並返回x的平方根,其中x是非負整數,由於返回類型是整數,結果只保留整數的部分,小數部分將被捨去
    方法1:java自帶API
public int mySqrt(int x) {
	return (int)Math.sqrt(x);
}

方法2:二分搜索

int mySqrt(int x) {
	//注:在中間過程計算平方的時候可能出現溢出,所以用long long。
	long long i=0;
	long long j=x/2+1;//對於一個非負數n,它的平方根不會大於(n/2+1)
	while(i<=j)
	{
		long long mid=(i+j)/2;
		long long res=mid*mid;
		if(res==x) return mid;
		else if(res<x) i=mid+1;
		else j=mid-1;
	}
	return j;
}

方法3:牛頓迭代法 求c的算術平方根就是求f(x)=x^2-c的正根 迭代公式:xn+1=1/2(xn+c/xn)

int mySqrt(int x) {
	if (x == 0) return 0;
	double last=0;
	double res=1;
	while(res!=last)
	{
		last=res;
		res=(res+x/res)/2;
	}
	return int(res);
}

4.4、複雜度分析

常見的時間複雜度?表示的是一個算法執行效率與數據規模增長的變化趨勢

時間複雜度 概念
1. O(1) 常數階 常量級別的時間複雜度:只要代碼的執行時間不隨n的增大而增長,這樣代碼的時間複雜度我們都記作O(1)。
2、O(logn)對數階、O(nlogn)線性對數階 代碼循環執行的次數呈現對數關係
3、O(m+n)、O(m*n) 代碼的複雜度由兩個數據的規模來決定

空間複雜度:(表示算法的存儲空間與數據規模之間的增長關係)
常見的空間複雜度就是O(1)、O(n)、O(n2)

  • 平均時間複雜度(加權平均時間複雜度):加了概率
  • 均攤時間複雜度:對一個數據結構進行一組連續操作中,大部分情況下時間複雜度都很低,只有個別情況下時間複雜度比較高,而且這些操作之間存在前後連貫的時序關係,這個時候,我們就可以將這一組操作放在一塊兒分析,看是否能將較高時間複雜度那次操作的耗時,平攤到其他那些時間複雜度比較低的操作上。
  • 算法的最好情況和最壞情況?
    最好情況:算法執行最佳的數據排列。如:二分搜索時,目標值正好位於搜索的數據中心,時間複雜度爲0;
    最差情況:給定算法的最差輸入。如:快速排序中,如果選擇的關鍵值是列表中最大或最小值,最差情況就會發生,時間複雜度會變成O(n^2)

4.5、如何高效地判斷無序數組中是否包含某特定值?

  • 方法1:使用list (最常使用)

public static boolean useList(String[] arr, String targetValue) {
return Arrays.asList(arr).contains(targetValue);
}

  • 方法2:使用Set 低效

public static boolean useSet(String[] arr, String targetValue) {
Set set = new HashSet(Arrays.asList(arr));
return set.contains(targetValue);
}

  • 方法3:使用一個簡單循環 最高效

public static boolean useLoop(String[] arr, String targetValue) {
for(String s: arr){
if(s.equals(targetValue))
return true;
}
return false;
}

  • 方法4:Arrays.binarySearch()方法:數組必須是有序的(有序數組時,使用列表或樹可達到O(lgn),使用hashset可達到O(1))

4.6、查找算法實戰?

  • 1、我們要給電商交易系統中的“訂單”排序。訂單有兩個屬性(下單時間,訂單金額) 需求是按金額從小到大對訂單數據排序。對金額相等的訂單,按下單時間從早到晚排序
    穩定性概念:如果待排序的序列中存在值相等的元素,經過排序之後,相等元素之間原有的先後順序不變
    思路:先按下單時間給訂單排序,排完序之後,使用穩定排序算法,按訂單金額重新排序(穩定排序算法可以保持金額相同的兩個對象,在排序之後的前後順序不變)

  • 2、O(n)時間複雜度內求無序數組中的第K大元素?(利用分區的思想) 代碼放在eclipse中
    我們選擇數組區間A[0…n-1]的最後一個元素A[n-1]作爲pivot,對數組A[0…n-1]原地分區,這樣數組就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。
    如果p+1=K,那A[p]就是要求解的元素;如果K>p+1, 說明第K大元素出現在A[p+1…n-1]區間,我們再按照上面的思路遞歸地在A[p+1…n-1]這個區間內查找。同理,如果K<p+1,那我們就在A[0…p-1]區間查找。

  • 3、現在你有10個接口訪問日誌文件,每個日誌文件大小約300MB,每個文件裏的日誌都是按照時間戳從小到大排序的。你希望將這10個較小的日誌文件,合併爲1個日誌文件,合併之後的日誌仍然按照時間戳從小到大排列。如果處理上述排序任務的機器內存只有1GB,你有什麼好的解決思路
    answer:先構建十條io流,分別指向十個文件,每條io流讀取對應文件的第一條數據,然後比較時間戳,選擇出時間戳最小的那條數據,將其寫入一個新的文件,然後指向該時間戳的io流讀取下一行數據,然後繼續剛纔的操作,比較選出最小的時間戳數據,寫入新文件,io流讀取下一行數據,以此類推,完成文件的合併, 這種處理方式,日誌文件有n個數據就要比較n次,每次比較選出一條數據來寫入,時間複雜度是O(n),空間複雜度是O(1),幾乎不佔用內存。

4.7、哪些數據結構有序? 納尼,好奇怪的問題

5、數組部分面試題 王爭

  • 1、實現一個支持動態擴容的數組
  • 2、實現一個大小固定的有序數組,支持動態增刪改操作 實際開發中我們使用ArrayList,更高效
  • 3、實現兩個有序數組合併爲一個有序數組
  • 4、數組操作常見問題(數組腳標越界異常(ArrayIndexOutOfBoundsException)/空指針異常(NullPointerException))
  • leetcode15:三數求和
    給定一個包含 n 個整數的數組 nums,判斷 nums 中是否存在三個元素 a,b,c ,使得 a + b + c = 0 ?找出所有滿足條件且不重複的三元組
    思路:首先對數據進行排序,然後確定第一個數,使用for循環,後兩個數使用兩指針,依次嘗試,如果值大於0-num[i],右指針左移;如果值小於0-num[i],左指針右移。
class Solution {
	public List<List<Integer>> threeSum(int[] nums) {
		Arrays.sort(nums);//由小到大
		List<List<Integer>> ls = new ArrayList<>();
		for (int i = 0; i < nums.length - 2; i++) {
			if (i == 0 || (i > 0 && nums[i] != nums[i - 1])) {  // 跳過可能重複的答案
 
				int l = i + 1, r = nums.length - 1, sum = 0 - nums[i];
				while (l < r) {
					if (nums[l] + nums[r] == sum) {
						ls.add(Arrays.asList(nums[i], nums[l], nums[r]));
						while (l < r && nums[l] == nums[l + 1]) l++;
						while (l < r && nums[r] == nums[r - 1]) r--;
						l++;
						r--;
					} else if (nums[l] + nums[r] < sum) {
						while (l < r && nums[l] == nums[l + 1]) l++;   // 跳過重複值
						l++;
					} else {
						while (l < r && nums[r] == nums[r - 1]) r--;
						r--;
					}
				}
			}
		}
		return ls;
	}
}//時間複雜度是O(n^2)
  • leetcode169:求衆數 給定一個大小爲n的數組,找到其中的衆數。衆數是指在數組中出現次數大於?n/2?的元素
    先決條件:給定的數組總是存在衆數
    思路:1、利用摩爾投票法 2、利用java的api
public int majorityElement(int[] nums){
	int count = 1;
	int maj = nums[0];
	for (int i = 1; i < nums.length; i++){
		if (maj == nums[i])
			count++;
		else {
			count--;
			if (count == 0) {//說明maj所代表的數不能超過一半
				maj = nums[i + 1];
			}
		}
	}//時間複雜度O(n)
	return maj;
}
  • 第二種解法:使用java的api,排序
public int majorityElement(int[] nums){
	Arrays.sort(nums);//時間複雜度O(nlgn)
	return nums[nums.length / 2];
}
  • LeetCode41:求缺失的第一個正數
    給定一個未排序的整數數組,找出其中沒有出現的最小的正整數。
class Solution {
	public int firstMissingPositive(int[] nums) {
		//先排序,然後分兩種情況 :有1  和  沒有1 (負數略過)
		//1.沒有1,則輸出1
		//2.有1 則判斷下一個數和前一個數是否相等、差1或者差好幾個數,相等繼續,差1繼續,否則退出
		boolean flag = false;
		int i;
		Arrays.sort(nums);
		for(i=0;i<nums.length;i++)
		{
			if(nums[i]<0)
				continue;//負數略過
			if(nums[i]==1)
				flag=true;
			if(i+1<nums.length && nums[i]==nums[i+1])
				continue;
			if(i+1==nums.length || nums[i]+1!=nums[i+1])
					break;
		}
		if(flag==true)
			return nums[i]+1;
		if(flag==false)
			return 1;
		return 0;
	}
}//時間複雜度O(n)

6、鏈表部分面試題

6.1、單鏈表:next指針 (尾結點特殊的地方是:指針不是指向下一個結點,而是指向一個空地址NULL,表示這是鏈表上最後一個結點)

public class ListNode {
	int val;
	ListNode next;
	ListNode(int x) {
		val = x;
	}
}

循環鏈表:循環鏈表的優點是從鏈尾到鏈頭比較方便。當要處理的數據具有環型結構特點時,就特別適合採用循環鏈表(比如著名的約瑟夫問題)

ListNode p = null;//在單鏈表的基礎之上,鏈尾指向鏈頭
q =p;
for (int i = 2; i <= N; i++) {
	p = p.getNext();
	p.setVal(i);
}
p.setNext(q);//構建循環鏈表

在遍歷循環鏈表時得特別小心,否則將會無限地遍歷鏈表,因爲循環鏈表每一個結點都有一個後繼結點
雙向鏈表:(需要額外的兩個空間來存儲後繼結點next和前驅結點的地址prev)

public class ListNode {
	int value;
	ListNode prev;
	ListNode next;
	ListNode(int key, int val) {
		this.key = key;
		this.value = val;
	}
}

使用技巧:
1、理解指針或引用的含義:是存儲所指對象的內存地址(將某個變量賦值給指針,實際上就是將這個變量的地址賦值給指針)
2、警惕指針丟失和內存泄漏 java不需考慮(使用jvm自動管理內存)
3、利用哨兵簡化實現難度:如果我們引入哨兵結點,在任何時候,不管鏈表是不是空,head指針都會一直指向這個哨兵結點(插入排序、歸併排序、動態規劃)
刪除最後一個結點和刪除其他節點,插入第一個結點和插入其他節點可以統一爲相同的代碼邏輯。
哨兵的好處:它可以減少特殊情況的判斷,比如判空,判越界,因爲空可越界可認爲是小概率情況,如實每次執行代碼都走一遍,大多數情況下是多於的。
比如給一個哨兵節點,以及將key賦值給末尾元素,讓數組遍歷不用判斷越界也可以因爲相等停下來。
4、重點留意便捷條件處理:(如果鏈表爲空時,代碼是否能正常工作?如果鏈表只包含一個結點時,代碼是否能正常工作?代碼邏輯在處理頭結點和尾結點的時候,是否能正常工作?)
5、舉例畫圖,輔助思考:(舉例法和畫圖法)


6.2、描述一下鏈式存儲結構

  • 可以用任意一組存儲單元來存儲單鏈表中的數據結構(可以不連續),存儲每個元素的值a,還必須存儲後集結點的信息,這兩個信息組成結點。

6.3、倒排一個LinkedList(即鏈表的反轉)

開發中使用集合工具包,Collecionts.reverse(List<?> list)
原理:i m n相鄰,調整指針的指向,調整m的指向,指向結點i,鏈表會斷開,需要在調整之前把n保存起來 代碼P236

public class 鏈表反轉 {
//單鏈表的反轉 調整指針的指向,在調整next指針之前,需要保存前一個值 反轉後鏈表的頭結點爲原始鏈表的尾節點,即next爲空指針的節點
	public void reverseIteratively(Node head) {
		Node pReversedHead = head;
		Node pNode = head;
		Node pPrev = null;
		while (pNode != null) {
			Node pNext = pNode.next;
			if (pNext == null) {
				pReversedHead = pNode;//pNode此時爲最後一個結點 反轉後鏈表的頭結點爲原始鏈表的尾節點
			}
			pNode.next = pPrev;
			pPrev = pNode;
			pNode = pNext;
		}
		head = pReversedHead;
}		

6.4、判斷一個單鏈表中是否有環? 阿里 LeetCode141

  • 思路1:蠻力法
    若鏈表中出現多個結點的後繼指針重複,就表明存在環。從第一個結點開始,令其爲當前節點,然後看看鏈表中其他節點的後繼指針是否指向當前結點,如果存在,說明鏈表中存在環。
    缺點:如果不能確定鏈表的表尾,算法將會出現死循環。

*思路2:使用散列表(時間複雜度O(n),空間複雜度O(n))
從表頭節點開始,逐一遍歷鏈表中的每個結點;
對於每個結點,檢查該結點的地址是否存在於散列表中;
如果存在,則表明當前訪問的結點已經被訪問過,出現此情況的原因是給定的鏈表中存在環;
如果散列表中沒有當前節點的地址,那麼把該地址插入散列表中;
重複上述過程,直至到達表尾或找到環。

  • 思路3:如果一個單鏈表中有環,用一個指針去遍歷,永遠不會結束,所以可以用兩個指針,一個指針一次走一步,另一個指針一次走兩步,如果存在環,則這兩個指針會在環內相遇,時間複雜度爲O(n) indeed(無論環的個數是奇數還是偶)被稱爲Floyd算法
public static  boolean checkCircle(Node list){
	if (list == null) {
		return false;
	}
	Node fast = list.next;
	Node slow = list;
	while (fast != null && fast.next !=null) {
		fast = fast.next.next;
		slow = slow.next;
		if (slow ==fast) {
			return true;
		}
	}        
	return false;
}//時間複雜度O(n) 空間複雜度O(1)

對floyd算法的補充:如果兩個指針每次分別移動2個結點和3個結點,而不是移動一個和2個結點,算法仍然有效嗎?
可以,算法的複雜度可能增加


6.5、判定給定的鏈表是否已NULL結束,如果鏈表中存在環,返回環的長度?

思路:在找到鏈表中的環後,保持slowPtr指針不變,fastPtr指針則繼續移動,每次移動fastPtr指針時,計數器變量加1,直至再一次回到slowPtr指針所在的位置,即爲環的長度。

public class 檢測環的長度 {
	int FindLoopLength(ListNode head){
		ListNode slowPtr =head,fastPtr =head;
		boolean loopExists = false;
		int counter = 0;
		if (head == null) {
			return 0;
		}
		while (fastPtr.next != null && fastPtr.next.next != null) {
			slowPtr = slowPtr.next;
			fastPtr = fastPtr.next.next;
			if (slowPtr == fastPtr) {
				loopExists =true;
				break;
			}
		}
		if (loopExists) {
			fastPtr =fastPtr.next;
			while (slowPtr != fastPtr) {
				fastPtr =fastPtr.next;
				counter++;
			}
			return counter;
		}
		return 0;  //鏈表中不存在環
	}
}	//時間複雜度O(n)

補充:此思路可以引申爲 求循環小數的開始位置(小數點之後的位數)和循環長度


6.6、快慢指針能解決的問題? 阿里

  • 1、已知單鏈表的頭指針,查找到倒數第K個節點,然後刪除這個節點
    思路1:快慢指針法:
    我們定義一個快指針P和慢指針Q,先讓P指針走到K個節點位置,然後Q指針從頭指針開始和P一起移動,當P移動到尾部的時候,那麼此時Q節點所在的位置就是倒數第K個節點
public static Node deleteLastKth(Node list,int k){
	Node fast =list;
	int i =1;
	while (fast !=null && i<k) {
		fast =fast.next;
		++i;//第一個指針先走k步
	}
	if (fast ==null) {
		return list;
	}
	Node slow =list;
	Node prev =null;
	while (fast.next !=null) {
		fast = fast.next;
		prev =slow;  //prev爲倒數第k個數
		slow =slow.next;
	}
	if (prev ==null) {
		list = list.next;
	}else {
		prev.next =prev.next.next;
	}
	return list;
}//時間複雜度O(n)

思路2:蠻力法(時間複雜度最高)
從鏈表的第一個結點開始,統計當前節點後面的結點個數。如果後面的節點個數小於k-1,算法結束;如果大於k-1,則移動到下一個結點,重複該過程
思路3:散列表O(m) 爲了減少鏈表遍歷的次數
散列表的條目是<結點的位置,結點地址>,在遍歷鏈表時,可以得到鏈表的長度,令M表示鏈表的長度,這樣求鏈表的導師胡第n個結點的問題轉變爲求鏈表正數
第M-n+1個結點。返回散列表中主鍵爲M-n+1的值即可。時間複雜度O(m),空間複雜度O(m):創建一個大小爲M的散列表。

  • 2、已知單鏈表的頭結點,查找到鏈表的中間節點(只允許掃描一次)
    一個快指針P和慢指針Q,P和Q同時從頭指針出發,快指針P每次移動兩步,慢指針每次移動一步,當快指針P到尾部的時候,慢指針Q所在的位置就是中間節點的位置
public class 找到鏈表的中間節結點 {
	ListNode FindMiddle(ListNode head) {
		ListNode ptr1x, ptr2x;
		ptr1x = ptr2x = head;
		int i = 0;
		//不斷循環,直至第一個指針到達表尾
		while (ptr1x.getNext() !=null) {
			if (i == 0) {
				ptr1x =ptr1x.getNext();//只移動第一個指針
				i = 1;
			}
			else if (i== 1) {
				ptr1x = ptr1x.getNext();
				ptr2x = ptr2x.getNext();
				i =0;
			}
		}        
		return ptr2x;//返回ptr2x的值,即爲中間結點
	}
}//時間複雜度O(n)  空間複雜度O(1)

6.7、實現兩個有序的鏈表合併爲一個有序鏈表(雙重遍歷) LeetCode23 合併k個排序鏈表

思路:使用分治的思想,兩兩歸併

class Solution {
	 public ListNode mergeKLists(ListNode[] lists) {

		if (lists.length == 0)
			return null;
		if (lists.length == 1)
			return lists[0];
		if (lists.length == 2) {
			return mergeTwoLists(lists[0], lists[1]);
		}
		int mid = lists.length/2;
		ListNode[] l1 = new ListNode[mid];
		for(int i = 0; i < mid; i++){
			l1[i] = lists[i];
		}
		ListNode[] l2 = new ListNode[lists.length-mid];
		for(int i = mid,j=0; i < lists.length; i++,j++){
			l2[j] = lists[i];
		}
		return mergeTwoLists(mergeKLists(l1),mergeKLists(l2));

	}
	//兩個有序鏈表合併爲一個新的有序鏈表  遞歸的方法
	public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
		if (l1 == null) return l2;
		if (l2 == null) return l1;

		ListNode head = null;
		if (l1.val <= l2.val){
			head = l1;
			head.next = mergeTwoLists(l1.next, l2);
		} else {
			head = l2;
			head.next = mergeTwoLists(l1, l2.next);
		}
		return head;
	}
}

6.8、在有序鏈表中插入一個結點

		public class 在有序鏈表中插入一個結點 {
			ListNode InsertSortedList(ListNode head, ListNode newNode){
				ListNode current =head;
				ListNode temp = null;
				if (head ==null) {
					return newNode;
				}
				//遍歷鏈表,直至找到比新節點中數據值更大的節點
				while (current != null && current.val < newNode.val) {
					temp = current;//temp爲current的上一個節點
					current = current.next; //current爲比newNode值大的數
				}
				//在該結點前插入新節點
				newNode.setNext(current);
				temp.setNext(newNode);
				return null;  
			}   
		}//時間複雜度O(n)

6.9、求兩個單向鏈表的合併點,合併後成爲一個單向鏈表。假設鏈表list1和鏈表list2在相交前的節點數量分別爲n和m,n/m大小不確定,求兩個鏈表的合併點。

方法1:蠻力法
把第一個鏈表中的每一個結點指針與第二個鏈表中的每一個結點指針比較,當結點相等時,即爲相交結點。時間複雜度爲O(mn)
方法2:散列表
選擇結點較少的鏈表(若鏈表長度未知,那麼隨便選擇一個鏈表),將其所有結點的指針值保存在散列表中;遍歷另一個鏈表,對於該鏈表中的每一個結點,檢查散列表
中是否已經保存了其結點指針。如果兩個鏈表存在合併點,那麼必定會在散列表中找到記錄。時間複雜度O(m)+O(n);空間複雜度O(m)或O(n)
方法3:兩個棧
創建兩個棧,然後遍歷兩個鏈表,分別把所有結點存入第一個和第二個棧,兩個棧包含了對應鏈表的結點地址,比較兩個棧的棧頂元素,如果相等,則彈出兩個棧
的棧頂元素並保存在臨時變量中,繼續上述操作,直至兩個棧的棧頂元素不相等,此時即找到了兩個鏈表的合併點。時間複雜度O(m+n),空間複雜度O(m+n)
方法4:時間複雜度超低的解法
獲取兩個鏈表L1/L2的長度,O(max(m,n));計算兩個長度的差d,從較長鏈表的表頭開始,移動d步,然後兩個鏈表同時移動,直至出現兩個後繼指針相等的情況。

			public class 求兩個鏈表的合併點 {
				ListNode FindIntersectingNode(ListNode list1, ListNode list2){
					int L1=0,L2=0,diff=0;//L1爲第一個鏈表的長度,L2爲第二個鏈表的長度,diff爲兩鏈表的差值
					ListNode head1=list1,head2=list2;
					while (head1 !=null) {
						L1++;
						head1 = head1.getNext();
					}
					while (head2 !=null) {
						L2++;
						head2 = head2.getNext();
					}
					if (L1<L2) {
						head1 = list2;
						head2 = list1;
						diff = L2-L1;
					}
					else  {
						head1 = list1;
						head2 = list2;
						diff = L1-L2;
					}
					for (int i = 0; i < diff; i++) {
						head1 = head1.getNext();
					}
					while (head1 != null && head2 != null) {
						if (head1 == head2) {
						   return head1;
						}
						head1= head1.getNext();
						head2 = head2.getNext();
					}
					return null;
				}
			}//時間複雜度O(max(m,n)) 空間複雜度O(1)

6.10、如何判斷一個字符串(鏈表)是否是迴文字符串的問題(字符串是通過單鏈表來存儲)(上海自來水來自海上)

1)前提:字符串以單個字符的形式存儲在單鏈表中。
2)遍歷鏈表,判斷字符個數是否爲奇數,若爲偶數,則不是。
3)將鏈表中的字符倒序存儲一份在另一個鏈表中。
4)同步遍歷2個鏈表,比較對應的字符是否相等,若相等,則是水仙花字串,否則,不是。
思路2:使用快慢兩個指針找到鏈表中點,慢指針每次前進一步,快指針每次前進兩步。在慢指針前進的過程中,同時修改其 next 指針,使得鏈表前半部分反序。最後比較中點兩側的鏈表是否相等
時間複雜度O(n) 空間複雜度O(1)


6.11、O(1)時間內刪除單鏈表中某一個節點

把後一個元素賦值給待刪除節點,這樣也就相當於是刪除了當前元素
1. 如果待刪除節點不是最後一個節點,就用他的next節點的value覆蓋它的value,然後刪掉它的next節點
2、如果是最後一個節點,順序遍歷o(n)


6.12、如何逐對逆置鏈表?初始1->2->3->4->X,逐對轉置後,爲2->1->4->3->X。

//遞歸版本
   ListNode ReversePairRecursive(ListNode head){
	ListNode temp;
	if (head ==null || head.next == null) {
		return head;  //當前鏈表爲空或只有一個元素
	}else {
		//逆置第一對
		temp = head.next;
		head.next = temp.next;//第一個結點的下一個爲第三個結點
		temp.next = head;//第一個結點變爲第二個
		head =temp;//第二個結點變第一個
		head.next.next=ReversePairRecursive(head.next.next);
		return head;
	}
}

6.13、約瑟夫環(N個人想選出一個領頭人,他們排成一個環,沿着環每數到第M個人就排除該人,並從下一個人開始重新數,求最後留在環中的人)

		/**
		 * @param N 人數
		 * @param M 需要排除的人序號
		 * @return 最後留下來的人
		 */
		ListNode GetJosephusPosition(int N, int M){
			ListNode p = null,q;
			//建立一個包含所有人的循環鏈表
			p.setVal(1);
			q =p;
			for (int i = 2; i <= N; i++) {
				p = p.getNext();
				p.setVal(i);
			}
			p.setNext(q);//構建循環鏈表
			for (int count = N; count >1; --count) {
				for (int i = 0; i < M-1; i++) {
					p = p.getNext();
				}
				p.setNext(p.getNext().getNext());//刪除選手
			}
			return p;//最後留下的勇者
		}

7、棧(一種特殊的線性表,只能固定在一端進行插入、刪除操作 可分爲順序棧結構和鏈式棧結構)

遞歸的本質 棧
1、遞歸是函數裏調用自身
2、必須有一個明確的遞歸出口
3、在遞歸調用的過程當中系統爲每一層的返回點、局部量等開闢了棧來存儲,因此遞歸次數過多容易造成棧溢出
遞歸的基本思想:
1、是把規模較大的一個問題,分解成規模較小的多個子問題去解決
2、先解決子問題,再基於子問題來解決當前問題
遞歸和內存:
每次遞歸調用都在內存中生成一個新的函數副本(僅僅是一些相關的變量),一旦函數結束(即返回某些數據),改返回函數的副本就從內存中刪除。
遞歸一般用於解決三類問題:
1、數據的定義是按遞歸定義的。(Fibonacci函數,n的階乘)
2、問題解法按遞歸實現。(動態規劃/分治/回溯)歸併排序和快速排序用到了遞歸的思想
3、數據的結構形式是按遞歸定義的。(二叉樹的/先/中/後序遍歷,圖的深度/廣度優先搜索)
棧在表達式求值中的應用:(一個保存操作符的棧,另一個是保存運算符的棧)
我們從左向右遍歷表達式,當遇到數字,我們就直接壓入操作數棧;當遇到運算符,就與運算符棧的棧頂元素進行比較(棧頂元素優先級高就取出運算符,從操作數棧取兩個操作數,結果壓入操作數棧)

  • LeetCode150 逆波蘭表示式求值(後綴表達式)(逆波蘭式在計算機看來卻是比較簡單易懂的結構。因爲計算機普遍採用的內存結構是棧式結構,它執行先進後出的順序)
		public int evalRPN(String[] tokens) {
			Stack<Integer> stack = new Stack<>();
			for (int i = 0; i < tokens.length; i++) {
				String str = tokens[i];
				if (str.length() == 1) {
					char ch = str.charAt(0);
					if (ch - '0' >= 0 && ch - '0' <= 9) {
						Integer a = Integer.valueOf(str);
						stack.push(a);
					} else {//如果是運算符
						if (stack.size() < 2)
							return 0;
						int num2 = stack.pop();
						int num1 = stack.pop();
						switch (ch) {
						case '+':
							stack.push(num1 + num2);
							break;
						case '-':
							stack.push(num1 - num2);
							break;
						case '*':
							stack.push(num1 * num2);
							break;
						case '/':
							stack.push(num1 / num2);
							break;
						}
					}
				} else {
					int n = Integer.valueOf(str);
					stack.push(n);
				}
			}
			return stack.pop();
		}
棧在括號匹配中的應用:(我們用棧來保存未匹配的左括號,從左到右依次掃描字符串。當掃描到左括號時,則將其壓入棧中;當掃描到右括號時,從棧頂取出一個左括號)	
  • LeetCode20:有效的括號 給定一個只包括 ‘(’,’)’,’{’,’}’,’[’,’]’ 的字符串,判斷字符串是否有效
		public class 有效的括號 {
			public boolean isValid(String s) {
				Stack<Character> stack = new Stack<>();
				char[] chars = s.toCharArray();
				for (char aChar : chars) {
					if (stack.size() == 0) {
						stack.push(aChar);
					} else if (isSym(stack.peek(), aChar)) {
						stack.pop();
					} else {
						stack.push(aChar);
					}
				}
				return stack.size() == 0;
			}
			//括號是否能匹配成功
			private boolean isSym(char c1, char c2) {
				return (c1 == '(' && c2 == ')') || (c1 == '[' && c2 == ']') || (c1 == '{' && c2 == '}');
			}
		}
	變體1:給定一個只包含 '(' 和 ')' 的字符串,找出最長的包含有效括號的子串的長度 例如:輸入: "(()"輸出: 2   輸入: ")()())" 輸出: 4
		對於這種括號匹配問題,一般都是使用棧,我們先找到所有可以匹配的索引號,然後找出最長連續數列!O(nlogn)
			public class 最長有效括號 {
				public int longestValidParentheses(String s) {
					if (s == null || s.length() == 0) return 0;
					Deque<Integer> stack = new ArrayDeque<>();
					stack.push(-1);
					//System.out.println(stack);
					int res = 0;
					for (int i = 0; i < s.length(); i++) {
						if (s.charAt(i) == '(') 
							stack.push(i);
						else {
							stack.pop();
							if (stack.isEmpty()) 
								stack.push(i);
							else {
								res = Math.max(res, i - stack.peek());
							}
						}
					}
					return res;
				}
			}

思路2:動態規劃

			public int longestValidParentheses(String s) {
				if (s == null || s.length() == 0) return 0;
				int[] dp = new int[s.length()];//狀態轉移表   下標表示對應考察元素     返回值表示最長有效括弧
				int res = 0;
				for (int i = 0; i < s.length(); i++) {
					if (i > 0 && s.charAt(i) == ')') {
						if (s.charAt(i - 1) == '(') {
							dp[i] = (i - 2 >= 0 ? dp[i - 2] + 2 : 2);
						} else if (s.charAt(i - 1) == ')' && i - dp[i - 1] - 1 >= 0 && s.charAt(i - dp[i - 1] - 1) == '(') {
							dp[i] = dp[i - 1] + 2 + (i - dp[i - 1] - 2 >= 0 ? dp[i - dp[i - 1] - 2] : 0);
						}
					}
					res = Math.max(res, dp[i]);
				}
				return res;
			}

編程題5:如何實現瀏覽器的前進、後退功能?
我們使用兩個棧,X和Y,我們把首次瀏覽的頁面依次壓入棧X,當點擊後退按鈕時,再依次從棧X中出棧,並將出棧的數據依次放入棧Y.當我們點擊前進按鈕時,
我們依次從棧Y中取出數據,放入棧X中。當棧X中沒有數據時,那就說明沒有頁面可以繼續後退瀏覽了。當棧Y中沒有數據,那就說明沒有頁面可以點擊前進按鈕瀏覽了。
遞歸需要滿足的三個條件:1、一個問題的解可以分解爲幾個問題的解;2、這個問題與分解後的子問題,除了數據規模不同,求解思路完全一樣;3、存在遞歸終止條件
首先是定義ListNode,最基礎的數據結構(包含int value,指向下一個結點點的指針),然後有結點構成棧(包含pop/push/print/clear等功能),最後實現瀏覽器功能()

		public class 用棧實現瀏覽器的前進後退 {
			private String currentPage;
			//使用兩個棧,X和Y
			private LinkedListBasedStack backStack;			//LinkedListBasedStack爲基於鏈表實現的棧,功能有入棧/出棧/獲取棧頂元素/打印棧中元素
			private LinkedListBasedStack forwardStack;
			//構造函數
			public 用棧實現瀏覽器的前進後退() {
				this.backStack = new LinkedListBasedStack();//第一個棧  打開新頁面時入棧,頁面前進時入棧  後退時出棧
				this.forwardStack = new LinkedListBasedStack();//第二個棧  前進時出棧  後退時入棧
			}
			public void open(String url) {
				if (this.currentPage != null) {
					this.backStack.push(this.currentPage);//入棧 第一個棧
					this.forwardStack.clear();
				}
				showUrl(url, "Open");
			}
			public boolean canGoBack() {
				return this.backStack.size() > 0;
			}
			public boolean canGoForward() {
				return this.forwardStack.size() > 0;
			}
			//後退功能
			public String goBack() {
				if (this.canGoBack()) {
					this.forwardStack.push(this.currentPage);//第二個棧入棧
					String backUrl = this.backStack.pop();//第一個棧出棧
					showUrl(backUrl, "Back");
					return backUrl;
				}
				System.out.println("* Cannot go back, no pages behind.");
				return null;
			}
			//前進功能
			public String goForward() {
				if (this.canGoForward()) {
					this.backStack.push(this.currentPage);//第一個棧入棧
					String forwardUrl = this.forwardStack.pop();//第二個棧出棧
					showUrl(forwardUrl, "Foward");
					return forwardUrl;
				}
				System.out.println("** Cannot go forward, no pages ahead.");
				return null;
			}
			public void showUrl(String url, String prefix) {
				this.currentPage = url;
				System.out.println(prefix + " page == " + url);
			}
			public void checkCurrentPage() {
				System.out.println("Current page is: " + this.currentPage);
			}
		}

編程題3:用數組實現一個順序棧

		public class 用數組實現棧 {
			private String[] items; // 數組
			private int count; // 棧中元素個數
			private int n; // 棧的大小
			// 初始化數組,申請一個大小爲n的數組空間
			public 用數組實現棧(int n) {
				this.items = new String[n];
				this.n = n;
				this.count = 0;
			}
			// 入棧操作
			public boolean push(String item) {
				// 數組空間不夠了,直接返回false,入棧失敗。
				if (count == n)
					return false;
				// 將item放到下標爲count的位置,並且count加一
				items[count] = item;
				++count;
				return true;
			}
			// 出棧操作
			public String pop() {
				// 棧爲空,則直接返回null
				if (count == 0)
					return null;
				// 返回下標爲count-1的數組元素,並且棧中元素個數count減一
				String tmp = items[count - 1];
				--count;
				return tmp;
			}
		}

編程題4:用鏈表實現一個鏈式棧

public class 用鏈表實現棧 {
			private ListNode top = null;
			//入棧
			public void push(int value) {
				ListNode newNode = new ListNode(value, null);
				//判斷是否棧空
				if (top == null) {
					top = newNode;
				} else {
					newNode.next = top;
					top = newNode;
				}
			}
			//出棧
			public int pop() {
				if (top == null)
					return -1;
				int value = top.data;
				top = top.next;
				return value;
			}
			public void printAll() {
				ListNode p = top;
				while (p != null) {
					System.out.print(p.data + " ");
					p = p.next;
				}
				System.out.println();
			}
		}

8、隊列部分知識點(關鍵點:確定隊空/隊滿的判定條件)

8.1、具有某種特性的隊列:循環隊列、阻塞隊列、併發隊列(在片底層的系統、框架、中間件開發中,起着重要的作用,如高性能隊列Disruptor、Linux環形緩存,用到了循環併發隊列

java concurrent併發包利用ArrayBlockingQueue來實現公平鎖等)
分類:順序隊列和鏈式隊列(用數組實現的隊列和鏈表實現的隊列) 基於鏈表實現的無界隊列(可能會導致過多的請求排隊,響應時間較長),基於數組實現的有界隊列(大小有限)

應用場景:排隊請求,數據庫連接池


2、高性能隊列Disruptor(內存消息隊列) kafka

Disruptor(線程之間用於消息傳遞的隊列)(應用值apache Storm/camel/log4j2) 性能比常用的內存消息隊列ArrayblockingQueue要高出一個數量級,它還因此獲得過Oracle官方的Duke大獎
1、Disruptor詳解?
基於循環隊列保證數據被消費的順序性。(實現了一個最簡單的“生產者-消費者模型”)在這個模型中,“生產者”生產數據,並且將數據放到一箇中心存儲容器中。
之後,“消費者”從中心存儲容器中,取出數據消費。面存儲數據的中心存儲容器,是用什麼樣的數據結構來實現的呢?(1、基於鏈表實現的鏈式隊列;2、基於數組實現的順序隊列(循環隊列))
基於循環隊列的生產者/消費者模型:思路(當隊列滿了之後,生產者就輪詢等待,當隊列空了後,消費者就輪訓等待)

public class Queue {
				private Long[] data;//基於數據實現
				private int size = 0, head = 0, tail = 0;
				public Queue(int size) {
					this.data = new Long[size];
					this.size = size;
				}
				public boolean add(Long element) {
					if ((tail + 1) % size == head) return false;//循環隊列滿了
					data[tail] = element;
					tail = (tail + 1) % size;
					return true;
				}
				public Long poll() {
					if (head == tail) return null;//循環隊列爲空
					long ret = data[head];
					head = (head + 1) % size;
					return ret;
				}
			}
			public class Producer {
				private Queue queue;
				public Producer(Queue queue) {
					this.queue = queue;
				}
				public void produce(Long data) throws InterruptedException {
					while (!queue.add(data)) {
						Thread.sleep(100);//說明添加失敗,隊列爲滿,等待消費
					}
				}
			}
			public class Consumer {
				private Queue queue;
				public Consumer(Queue queue) {
					this.queue = queue;
				}
				public void comsume() throws InterruptedException {
					while (true) {
						Long data = queue.poll();
						if (data == null) {
							Thread.sleep(100);
						} else {
							// TODO:...消費數據的業務邏輯...
						}
					}
				}
			}

上述代碼存在的問題:多線程下,多個生產者寫入的數據可能會互相覆蓋,多個消費者可能會讀取重複的數據
解決方法:1、加鎖(同一時間只允許一個線程執行add()函數,相當於並行改成了串行),可以使用CAS樂觀鎖機制減少加鎖的粒度。
基於無鎖的併發“生產者-消費者模型”
對於生產者來說,它往隊列中添加數據之前,先申請可用空閒存儲單元,並且是批量地申請連續的n個(n≥1)存儲單元。後續往隊列中添加元素,就可以不用加鎖了;
對於消費者來說,處理的過程跟生產者是類似的。它先去申請一批連續可讀的存儲單元,當申請到這批存儲單元之後,後續的讀取操作就可以不用加鎖了。
源碼中,Disruptor採用的是RingBuffer和AvailableBuffer這兩個結構
需要注意的地方:生產者A申請到一組連續的存儲單元,假設下標是3到6的存儲單元,生產者B緊跟着申請到下標是7到9的存儲單元,那麼3-6沒有完全寫入數據之前,7-9的數據
是無法讀取的,這是Disruptor實現思路的一個弊端。實際上,不管架構設計還是產品設計,往往越簡單的設計思路,越能更好地解決問題。
4、實現一個消息隊列系統

編程題1:用數組實現一個順序隊列
public class 用數組實現的隊列 {
// 數組:items,數組大小:n
private String[] items;
private int n = 0;
// head表示隊頭下標,tail表示隊尾下標
private int head = 0;
private int tail = 0;
// 申請一個大小爲capacity的數組
public 用數組實現的隊列(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入隊
public boolean enqueue1(String item) {
// 如果tail == n 表示隊列已經滿了
if (tail == n)
return false;
items[tail] = item;
++tail;
return true;
}
//入隊操作,將item放入隊尾 並更新head/tail的索引 可以動態擴容的隊列
public boolean enqueue2(String item) {
// tail == n表示隊列末尾沒有空間了
if (tail == n) {
//tail n && head0,表示整個隊列都佔滿了
if (head == 0)
return false;// 表示整個隊列都佔滿了
// 數據搬移
for (int i = head; i < tail; ++i) {
items[i - head] = items[i];
}
//搬移完之後重新更新head和tail
tail -= head;
head = 0;
}
items[tail] = item;
++tail;
return true;
}
// 出隊
public String dequeue() {
// 如果head == tail 表示隊列爲空
if (head == tail)
return null;
// 爲了讓其他語言的同學看的更加明確,把–操作放到單獨一行來寫了
String ret = items[head];
++head;
return ret;
}
}

編程題2:用鏈表實現一個鏈式隊列
public class 基於鏈表實現的隊列 {
// 隊列的隊首和隊尾
private ListNode head = null;
private ListNode tail = null;
// 入隊
public void enqueue(String value) {
if (tail == null) {
//新建的隊列
ListNode newNode = new ListNode(value, null);
head = newNode;
tail = newNode;
} else {
tail.next = new ListNode(value, null);
tail = tail.next;
}
}
// 出隊
public String dequeue() {
if (head == null)
return null;
String value = head.data;
head = head.next;
if (head == null) {
tail = null;
}
return value;
}
public void printAll() {
ListNode p = head;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
}
編程題3:實現一個循環隊列(最關鍵的是,確定好隊空和隊滿的判定條件)(我使用數組實現)
隊列爲空的判斷條件仍然是head == tail,當隊滿時,(tail+1)%n=head,循環隊列會浪費一個數組的存儲空間。
public class 循環隊列 {
//數組:items,數組大小:n
private String[] items;
private int n = 0;
// head表示隊頭下標,tail表示隊尾下標
private int head = 0;
private int tail = 0;
// 申請一個大小爲capacity的數組
public 循環隊列(int capacity) {
items = new String[capacity];
n = capacity;
}
// 入隊
public boolean enqueue(String item) {
// 隊列滿了
if ((tail + 1) % n == head)
return false;
items[tail] = item;
tail = (tail + 1) % n;
return true;
}
// 出隊
public String dequeue(){
// 如果head == tail 表示隊列爲空
if (head == tail)
return null;
String ret = items[head];
head = (head + 1) % n;
return ret;
}
}

編程題4:實現一個雙端隊列 java中有工具包Deque

			public class 自己動手實現雙端隊列 {
				private Object[] data;
				private int head = 0;
				private int tail = 0;
				public 自己動手實現雙端隊列(int k) {
					data = new Object[k];
				}
				public boolean insertFront(int value) {
					if(isFull()){
						return false;
					}
					head= decr(head);
					data[head] = value;
					return true;
				}
				public boolean insertLast(int value) {
					if(isFull()){
						return false;
					}
					data[tail] = value;
					tail = incr(tail);
					return true;
				}
				public boolean deleteFront() {
					if(isEmpty()){
						return false;
					}
					data[head] = null;
					head = incr(head);
					return true;
				}
				public boolean deleteLast() {
					if(isEmpty()){
						return false;
					}
					tail = decr(tail);
					data[tail] = null;
					return true;
				}
				public int getFront() {
					if(isEmpty()){
						return -1;
					}
					return (int)data[head];
				}
				public int getRear() {
					if(isEmpty()){
						return -1;
					}
					return (int) data[decr(tail)];
				}
				public boolean isEmpty() {
					return head == tail && data[head] == null && data[tail] == null;
				}
				public boolean isFull() {
					return tail== head && data[head] != null && data[tail] != null;
				}
				//前進一步
				private int incr(int index){
					return ++index % data.length;
				}
				//後退一步
				private int decr(int index){
					return (--index + data.length) % data.length;
				}
			}

編程題5:滑動窗口最大值

		public int[] maxSlidingWindow(int[] nums, int k){
			if(nums==null||nums.length<2) 
				return nums;
			//雙向隊列 保存當前窗口最大值的數組位置 保證隊列中數組位置的數按從大到小排序
			LinkedList<Integer> list = new LinkedList();
			// 結果數組
			int[] result = new int[nums.length-k+1];
			for(int i=0;i<nums.length;i++){
				//保證從大到小 如果前面數小 彈出
				while(!list.isEmpty()&&nums[list.peekLast()]<=nums[i]){
					list.pollLast();
				}
				//添加當前值對應的數組下標
				list.addLast(i);
				//初始化窗口 等到窗口長度爲k時 下次移動在刪除過期數值
				if(list.peek()<=i-k){
					list.poll();   
				} 
				//窗口長度爲k時 再保存當前窗口中最大值
				if(i-k+1>=0){
					result[i-k+1] = nums[list.peek()];
				}
			}
			return result;
		}	

編程題6:兩個棧實現隊列
思路:用棧a棧b模擬隊列q,a爲插入棧,b爲彈出棧,棧a提供入隊功能,棧b提供出隊功能。入隊時,入棧a即可,出隊時,分兩種情況:
*1、棧b不爲空,直接彈出棧b的數據 *2、棧b爲空,則依次彈出棧a的數據,放入棧b中,再彈出棧b的數據。

			public class 兩個棧實現隊列<E> {
				Stack<E> s1 = new Stack<E>();//E爲鏈表或數組
				Stack<E> s2 = new Stack<E>();
				public synchronized void put(E e) {
					s1.push(e);
				}
				public synchronized E pop() {
					if (s2.isEmpty()) {
						while (!s1.isEmpty()) {
							s2.push(s1.pop());
						}
					}
					return s2.pop();
				}
			}

編程題7:使用兩個隊列實現棧
思路:確保有一個隊列總是空的, 入棧:在任何一個非空隊列中插入元素,檢查隊列q1是否爲空,如果q1爲空,那麼對q2執行入隊操作;
出棧:如果隊列q1非空,那麼從q1移n-1個元素到q2中,然後對q1中的最後一個元素執行出隊操作並返回該元素。

		public class 使用隊列實現棧<E> {
			Queue<E> queue1 = new LinkedBlockingQueue<E>();//E爲鏈表或數組;
			Queue<E> queue2 = new LinkedBlockingQueue<E>();;
			public void push(E data) {
				if (queue1.isEmpty()) {
					queue2.add(data);
				} else {
					queue1.add(data);
				}
			}
			public E Pop(){
				int i,size;
				if (queue2.isEmpty()) {
					size = queue1.size();
					i=0;
					while (i < size-1) {
						queue2.add(queue1.remove());
						i++;
					}
					return queue1.remove();
				}else {
					size = queue2.size();
					i=0;
					while (i < size-1) {
						queue1.add(queue2.remove());
						i++;
					}
					return queue2.remove();
				}
			}
		}

8.3、寫一個生產者-消費者隊列 政採雲問到了 ***非常好的題目 通過arrayblockingqueue的put/take+callable實現,詳見後面的阻塞隊列

  • 1、可以通過阻塞隊列實現 2、也可以通過wait-notify來實現 3、通過無鎖的內存高性能隊列Disruptor實現“生產者-消費者模型”
  • 後續補充具體例子

9、遞歸方法

使用遞歸時應該注意的問題?
1、警惕堆棧溢出:(如果遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有堆棧溢出的風險) 解決方案:遞歸調用超過一定的深度後,就停止隊規,返回錯誤(由於遞歸深度無法事先知道,這種方案不實用)
2、遞歸代碼的重複計算問題:某一個子問題被重複計算了多次。解決方案:通過一個數據結構(散列表)保存已經求結果的f(k),先看子問題是否被求解過,若是,直接從散列表中取值返回。
public int f(int n){
if(n1) return 1;
if(n
2) return 2;
//hasSolvedList可以理解爲一個Map,key是n,value是f(n)
hasSolvedList.containsKey(n){
return hasSolvedList.get(n);
}
int ret =f(n-1)+f(n-2);
hasSolvedList.put(n,ret);
return ret;
}//王爭這道題沒寫好,他的本意是記憶化遞歸
3、空間複雜度,比較大,爲O(n)
3、遞歸代碼改寫爲非遞歸代碼:f(x) =f(x-1)+1 ->
int f(int n){
int ret = 1;
for (int i = 2; i <= n; ++i) {
ret = ret + 1;
}
return ret;
}
f(n) = f(n-1)+f(n-2) ->
int f(int n){
if (n == 1) return 1;
if (n == 2) return 2;
int ret = 0;int pre = 2;int prepre = 1;
for (int i = 3; i <= n; ++i) {
ret = pre + prepre;
prepre = pre;
pre = ret;
}
return ret;
}
4、如何調試遞歸?調試遞歸:1.打印日誌發現,遞歸值。2.結合條件斷點進行調試
編程題2:如何找到“最終推薦人”?在數據庫表中,我們可以記錄兩行數據,其中actor_id表示用戶id,referrer_id表示推薦人id
long findRootReferrerId(long actorId) {
Long referrerId = select referrer_id from [table] where actor_id = actorId;
if (referrerId == null) return actorId;
return findRootReferrerId(referrerId);
}
可能出現的問題:1、遞歸很深會出現堆棧溢出的問題 2、若數據庫中存在髒數據,可能會出現無限循環的問題 (如何來檢測環的存在呢?)
檢測環可以構造一個set集合或者散列表(下面都叫散列表)。每次獲取到上層推薦人就去散列表裏先查,沒有查到的話就加入,如果存在則表示存在環了。當然,每一次查詢都是一個自己的散列表,不能共用。
檢測環的第二種方法:雙指針法(從起點開始分別以2x,1x速度出發兩個指針,當遇到null停止,相遇點爲null時說明沒有環,如果相遇點不爲null,說明有環)
編程1;實現斐波那契數列求值f(n)=f(n-1)+f(n-2) or 假如這裏有n個臺階,每次你可以跨1個臺階或者2個臺階,請問走這n個臺階有多少種走法?
遞推公式:f(n)=f(n-1)+f(n-2) 遞歸終止條件:f(1)=1,f(2)=2
最終的遞歸代碼是這樣的:
int f(int n) {
if (n == 1) return 1;
if (n == 2) return 2;
return f(n-1) + f(n-2);
}
編程題2:漢諾塔(思想:將原柱最上面的n-1個圓盤移動到輔助柱;將第n個圓盤從原柱移到目的柱;將輔助柱的n-1個圓盤移動到目的柱)
void TowersOfHanoi(int n,char frompeg,char topeg,char auxpeg){
/如果僅有一個圓盤,直接移動,然後返回/
if(n1){
syso(“Move disk 1 from peg”+frompeg+“to peg”+topeg);
return;
}
/利用c作爲輔助,將A柱最上面的n-1個圓盤移動到B柱/
TowersOfHanoi(n-1, frompeg, topeg,auxpeg);
/將餘下的圓盤從A柱移動C柱/
syso(“Move disk 1 from peg”+frompeg+“to peg”+topeg);
/利用A柱作爲輔助,將B柱上的n-1個圓盤移到C柱/
TowersOfHanoi(n-1, auxpeg,topeg,frompeg);
}
編程3;實現求階乘n!
int Fact(int n){
//基本情形:當參數爲0或1時,返回1
if (n
1) {
return 1;
}else if (n == 0) {
return 1;
}else {
return n*Fact(n-1);
}
}
編程4;實現一組數據集合的全排列
public class 實現一組數據集合的全排列 {
public void printAllSort(int[] arr) {
if (arr == null || arr.length == 0) {
return;
}
if (arr.length == 1) {
System.out.println(arr[0]);
}
List<List> result = _printAllSort(arr);
int count=0;
for (List list : result) {
count++;
System.out.println(list+" 第"+count+“種”);
}
}
private List<List> _printAllSort(int[] tmpArr) {
// 結束條件
List<List> result = new ArrayList<>();
if (tmpArr.length == 2) {
List subList = new ArrayList<>();
List subList2 = new ArrayList<>();
subList.add(tmpArr[0]);
subList.add(tmpArr[1]);
subList2.add(tmpArr[1]);
subList2.add(tmpArr[0]);
result.add(subList);
result.add(subList2);
return result;
}
// 當前層處理
for (int i = 0; i < tmpArr.length; i++) {
// 順序拿出一個參數,其餘交給下一層處理
int tmp = tmpArr[i];
int[] arr = new int[tmpArr.length - 1];
int offset = 0;
for (int j = 0; j < tmpArr.length; j++) {
if (i != j) {
arr[offset] = tmpArr[j];
offset++;
}
}
List<List> nextLevelResult = _printAllSort(arr);
// 處理下一層結果(當前值加到結果的前面、後面)
for (List nextList : nextLevelResult) {
List appendList = new ArrayList<>();
appendList.add(tmp);
appendList.addAll(nextList);
result.add(appendList);
}
}
return result;
}
}//[1, 2, 3, 4] 第1種 [1, 2, 4, 3] 第2種 [1, 3, 2, 4] 第3種 [1, 3, 4, 2] 第4種 [1, 4, 2, 3] 第5種 [1, 4, 3, 2] 第6種
編程5;爬樓梯
假設你正在爬樓梯。需要 n 階你才能到達樓頂。每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?
方法1:暴力法 使用遞歸
public int climbStairs(int n) {
return climb_Stairs(0, n);
}
public int climb_Stairs(int i, int n) {
if (i > n) {
return 0;
}
if (i == n) {
return 1;
}
return climb_Stairs(i + 1, n) + climb_Stairs(i + 2, n);
}//時間複雜度:O(2^n)
方法2:記憶化遞歸 每一步的結果存儲在 memomemo 數組之中,每當函數再次被調用,我們就直接從 memomemo 數組返回結果
public class 記憶化遞歸 {
public int climbStairs(int n) {
int memo[] = new int[n + 1];
return climb_Stairs(0, n, memo);
}
public int climb_Stairs(int i, int n, int memo[]) {
if (i > n) {
return 0;
}
if (i == n) {
return 1;
}
if (memo[i] > 0) {
return memo[i];
}
memo[i] = climb_Stairs(i + 1, n, memo) + climb_Stairs(i + 2, n, memo);
return memo[i];
}
}
方法3:動態規劃 第i階可以由以下兩種方法得到:在第(i-1)階後向上爬一階。在第(i-2)階後向上爬 2 階。狀態轉移公式:dp[i]=dp[i?1]+dp[i?2]
public class 動態規劃求解爬樓梯 {
public int climbStairs(int n) {
if (n == 1) {
return 1;
}
int[] dp = new int[n + 1];
dp[1] = 1;
dp[2] = 2;
for (int i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}


10、散列表相關知識點(HashMap/LinkedHashMap)

10.1、什麼是hash算法,他們用於什麼?

hash算法是一個hash函數,他使用任意長度的字符串,並將其減少爲唯一的固定長度字符串。他用於密碼有效性、消息和數據完整性以及許多其他加密系統。
加密算法原理:加密是將明文轉換成“密文”的過程。要轉換文本,算法使用一系列被稱爲“鍵”的位來進行計算。密鑰越大,創建密文的潛在模式越多。
大多數加密算法使用長度約爲64到128位的固定輸入塊,而有些則使用流方法。
常用的加密算法:3-way blowfish cast cmea gost des/triple des idea loki crc MD5
哈希算法的應用:安全加密(MD5/SHA)、數據校驗、唯一標識、散列函數,負載均衡、數據分片、分佈式存儲 **
安全加密:第一是很難根據哈希值反向推導出原始數據,第二是散列衝突的概率很小。
唯一標識:圖片的唯一id(我們可以把每個圖片的唯一標識,和相應的圖片文件在圖庫中的路徑信息,都存儲在散列表中)
數據校驗:我們通過哈希算法,對100個文件塊分別取哈希值,並且保存在種子文件中。哈希算法特點,對數據很敏感。只要文件塊的內容有一丁點兒的改變,最後計算出的哈希值就會完全不同。
所以,當文件塊下載完成之後,我們可以通過相同的哈希算法,對下載好的文件塊逐一求哈希值,然後跟種子文件中保存的哈希值比對。如果不同,說明這個文件塊不完整或者被篡改了,
需要再重新從其他宿主機器上下載這個文件塊。
散列函數:對於衝突的要求低很多(即便出現個別散列衝突,只要不是過於嚴重,我們都可以通過開放尋址法或者鏈表法解決),更看重的是散列的平均性和哈希算法的執行效率。

  • 在分佈式系統中的應用:
    負載均衡:(利用哈希算法替代映射表)通過哈希算法,對客戶端IP地址或者會話ID計算哈希值,將取得的哈希值與服務器列表的大小進行取模運算,最終得到的值就是應該被路由到的服務器編號。
    數據分片:(通過哈希算法對處理的海浪數據進行分片,多機分佈式處理,突破單機資源限制)
    1、如何統計“搜索關鍵詞”出現的次數?(難點:搜索日誌很大,沒辦法放到一臺機器的內存中;第二:如果只用一臺機器處理數據,時間耗費很長)
    我們可以先對數據進行分片,然後採用多臺機器處理的方法,來提高處理速度(n臺機器,從搜索記錄的日誌文件中,依次獨處每個搜索關鍵詞,並且通過哈希函數計算hash值,然後再跟n取模
    最終得到的值,就是應該被分配到的機器編號)(MapReduce的基本設計思想)
    2、如何快速判斷圖片是否在圖庫中?我們同樣可以對數據進行分片,然後採用多機處理。我們準備n臺機器,讓每臺機器只維護某一部分圖片對應的散列表。我們每次從圖庫中讀取一個圖片,計算唯
    一標識,然後與機器個數n求餘取模,得到的值就對應要分配的機器編號,然後將這個圖片的唯一標識和圖片路徑發往對應的機器構建散列表。
    當我們要判斷一個圖片是否在圖庫中的時候,我們通過同樣的哈希算法,計算這個圖片的唯一標識,然後與機器個數n求餘取模。假設得到的值是k,那就去編號k的機器構建的散列表中查找。
    分佈式存儲:(利用一致性哈希算法,解決緩存等分佈式系統的擴容/縮容導致數據大量搬移的問題)
    假設我們有k個機器,數據的哈希值的範圍是[0, MAX]。我們將整個範圍劃分成m個小區間(m遠大於k),每個機器負責m/k個小區間。當有新機器加入的時候,
    我們就將某幾個小區間的數據,從原來的機器中搬移到新的機器中。這樣,既不用全部重新哈希、搬移數據,也保持了各個機器上數據數量的均衡。

散列表:散列表用的就是數組支持按照下標隨機訪問的時候,時間複雜度是O(1)的特性。我們通過散列函數把元素的鍵值映射爲下標,然後將數據存儲在數組中對應下標的位置。
當我們按照鍵值查詢元素時,我們用同樣的散列函數,將鍵值轉化數組下標。

散列函數:1. 散列函數計算得到的散列值是一個非負整數;2. 如果key1 = key2,那hash(key1) == hash(key2);3. 如果key1 ≠ key2,那hash(key1) ≠ hash(key2)(這一點即使是MD5/CRC算法也無法完全避免散列衝突)
應用:判斷單詞是否拼寫錯誤(使用Trie樹更好);redis的字典是使用鏈式法來解決散列衝突的,並且使用了漸進式rehash方式進行hash表的彈性擴容
Q:區塊鏈使用的是哪種哈希算法?是爲了什麼問題而使用的呢?
A:區塊鏈是一塊塊區塊組成的,每個區塊分爲兩部分:區塊頭和區塊體(區塊頭保存着自己區塊體和上一個區塊頭 的哈希值),因爲這種鏈式關係和哈希值的唯一性,只要區塊鏈上任意一個區塊被修改過,
後面所有區塊保存的哈希值就不對了。區塊鏈使用的是SHA256哈希算法,計算哈希值非常耗時,如果要篡改一個區塊,就必須重新計算該區塊後面所有的區塊的哈希值,短時間內幾乎不可能做到。


10.2、hash函數是怎麼實現的?

1、散列算法 hash(key)&(capitity-1) //在插入或查找時,計算Key被映射到桶的位置。 當capacity爲2的整數倍是該公式才成立。相當於對key的hash值對錶廠取模,基於hashmap是2的冪次方特性,這種位運算速度更快。
2、hash的高16bit和低16bit做了一個異或
3、(n-1)&hash 得到下標
4、使用&代替取模,實現了均勻的散列,但效率要高很多,與運算比取模的效率高,由於計算機組成原理
代碼: int hash(Object key){
int h = key.hashCode();
return (h ^ (h >>> 16)) & (capitity -1); //capicity表示散列表的大小
}
public int hashCode(){
int var1 = this.hash;
if(var1 == 0 && this.value.length > 0){
char[] var2 = this.value;
for(int var3 = 0; var3 < this.value.length; ++var3) {
var1 = 31 * var1 + var2[var3];
}
this.hash = var1;
}
return var1;
}


10.3、hash衝突解決方案:(開放定址法/鏈表法)

使用鏈地址法,先找到下標i,KEY值找Entry對象,新值存放在數組中,舊值在新值的鏈表上,將存放在數組中的Entry設置爲新值的next
開放定址法
散列表的衝突處理?散列表的衝突處理主要分爲閉散列法和開散列法;常用:線性探測法、鏈地址法 20181230補
1、閉散列法(開放尋址法) 不開闢額外的存儲空間 (當數據量比較小、裝載因子小的時候,適合採用開放尋址法)
1、特點
1、不開闢額外的存儲空間,還是在原先hash表的空間範圍之內
2、當插入元素髮生了散列衝突,就逐個查找下一個空的散列地址供插入,直到查找失敗
2、方法
1、線性探測法:將散列表看作是一個循環向量,若初始地址是f(key)=d,則依照順序d、d+1、d+2…的順序取查找,即f(key)=(f(key)+1)mod N;(ThreadLocalMap使用的線性探測法)
2、二次探測法:基本思路和線性探測法一致,只是搜索的步長和方向更加的多樣,會交替以兩個方向,步長爲搜索次數的平方來查找 **
3、 雙重散列法:通常雙重散列法是開放地址中最好的方法,其通過提供hash()和rehash()兩個函數,前者產生衝突的時候,定製化後者rehash()重新尋址
2、開散列法(鏈地址法) 尋找額外的存儲空間(基於鏈表的散列衝突處理方法比較適合存儲大對象、大數據量的散列表,而且,比起開放尋址法,它更加靈活,支持更多的優化策略)
1、特點
1、一般通過將衝突的元素組織在鏈表中,採用鏈表遍歷的方式查找
2、解決方法直觀,實現起來簡單,尤其在刪除元素的時候此處只是簡單的鏈表操作
3、開散列法可以存儲超過散列表容量個數的元素
2、方法
1、鏈地址法:相同散列值的記錄放到同一個鏈表中,他們在同一個Bucket中(java中LinkedHashMap採用此方法)
優化方案:將鏈表法中的鏈表改造爲其他高效的動態數據結構,比如跳錶、紅黑樹。
2、公共溢出法:將所有的衝突都放到一個公共的溢出表中去,適用於衝突情況很小的時候


10.4、面試題

1、假設我們有10萬條URL訪問日誌,如何按照訪問次數給URL排序
遍歷10萬條數據,以URL爲key,訪問次數爲value,存入散列表,同時記錄訪問次數的最大值K,時間複雜度O(N),如果K不是很大,可以使用桶排序,時間複雜度O(N)。如果k非常大(10萬),就使用快速排序,複雜度O(NlgN)
2、有兩個字符串數組,每個數組大約有10萬條字符串,如何快速找出兩個數組中相同的字符串?
以第一個字符串數組構建散列表,key爲字符串,value爲出現次數。再遍歷第二個字符串數組,以字符串爲key在散列表中查找,如果value大於零,說明存在相同字符串。時間複雜度O(N)。
3、如何避免低效擴容?
當裝載因子已經到達閾值,需要先進行擴容,再插入數據,這種操作很低效。解決方案:將擴容操作穿插在插入操作的過程中,分批完成。當裝載因子觸達閾值之後,我們只申請新空間,但並不將老的數據搬移到新散列表中。
當有新數據要插入時,我們將新數據插入新散列表中,並且從老的散列表中拿出一個數據放入到新散列表。
5、如何通過哈希算法生成短網址?(王爭的第56講 http://t.cn是短網址服務的域名)
MurmurHash算法。現在它已經廣泛應用到Redis、MemCache、Cassandra、HBase、Lucene等衆多著名的軟件中。
6、編程實現一個基於鏈表法解決衝突問題的散列表

		public class HashTable {
			private int tSize;
			private int count;
		}
		public class HashTableNode {
			private int blockCount;
			private ListNode startNode;//維護的線性表
		}
		public class 基於散列表解決衝突的散列表 {
			public final static int LOADFACTOR = 20;
			public static HashTable createHashTable(int size) {
				HashTable h = new HashTable();
				//count默認設置爲0;
				h.settSize(size / LOADFACTOR);
				for (int i = 0; i < h.gettSize(); i++) {
					h.getTable()[i].setStartNode(null);
				}
				return h;
			}
			public static int hashSearch(HashTable h, int data) {
				ListNode temp;
				temp = h.getTable()[Hash(data, h.gettSize())].getStartNode();
				while (temp != null) {
					if (temp.getVal() ==data) {
					   return 1; 
					}
					temp = temp.getNext();
				}
				return 0;
			}
			//散列函數
			public static int Hash(int data, int gettSize) {
				int h = data; /* data.hashCode(); */
				return (h ^ (h >>> 16)) & (gettSize - 1); // capicity表示散列表的大小
			}
		}

7、編程實現一個LRU緩存淘汰算法 (使用散列表+鏈表組合實現緩存淘汰算法)LinkedHashMap(思路牛逼)(雙向鏈表+散列表)(使用雙向鏈表支持按照插入的順序遍歷數據,支持按照訪問順序遍歷數據)
一個緩存(cache)系統主要包含下面這幾個操作:往緩存中添加一個數據;從緩存中刪除一個數據;在緩存中查找一個數據。
①使用雙向鏈表存儲數據,鏈表中每個節點存儲數據(data)、前驅指針(prev)、後繼指針(next)和hnext指針(解決散列衝突的鏈表指針)。
②散列表通過鏈表法解決散列衝突,所以每個節點都會在兩條鏈中。一條鏈是雙向鏈表,另一條鏈是散列表中的拉鍊。前驅和後繼指針是爲了將節點串在雙
向鏈表中,hnext指針是爲了將節點串在散列表的拉鍊中。(牛逼)
往緩存中查找一個數據:在散列表中查找數據的時間複雜度爲O(1),找到後,將其移動到雙向鏈表的尾部
刪除數據:在O(1)時間複雜度裏找到要刪除的結點,雙向鏈表可以通過前驅指針O(1)時間複雜度獲取前驅結點
添加一個數據:先看這個數據是否已經在緩存中。如果已經在其中,需要將其移動到雙向鏈表的尾部;如果不在其中,還要看緩存有沒有滿。
如果滿了,則將雙向鏈表頭部的結點刪除,然後再將數據放到鏈表的尾部;如果沒有滿,就直接將數據放到鏈表的尾部。

		public class LRU緩存淘汰算法{
			private ListNode head; //最近最少使用,類似列隊的頭,出隊
			private ListNode tail; //最近最多使用,類似隊列的尾,入隊
			private Map<Integer, ListNode> cache;
			private int capacity;
			public LRU緩存淘汰算法(int capacity){
				this.cache = new HashMap<>();
				this.capacity = capacity;
			}
			public int get(int key) {
				ListNode node = cache.get(key);
				if (node == null) {
					return -1;
				} else {
					moveNode(node);//把該數據移動到鏈表尾部
					return node.value;
				}
			}
			public void put(int key, int value) {
				ListNode node = cache.get(key);
				if(node != null){
					node.value = value;
					moveNode(node);//把該數據移動到鏈表尾部
				} else{//緩存滿了,移除鏈表的頭結點
					removeHead();
					addNode(new ListNode(key, value));
				}
				cache.put(key, node);
			}
			private void removeHead() {
				if (cache.size() == capacity) {
					ListNode tempNode = head;
					cache.remove(head.key);
					head = head.next;
					tempNode.next = null;
					if (head != null)
						head.prev = null;
				}
			}
			private void addNode(ListNode node) {
				if (head == null)
					head = tail = node;
				else
					addNodeToTail(node);
			}
			private void addNodeToTail(ListNode node) {
				node.prev = tail;
				tail.next = node;
				tail = node;
			}
			//移動數據到鏈表尾部 分類討論:第一種要移動的結點是頭結點;第二種直接爲尾節點,無需處理;第三種爲鏈表中的結點
			private void moveNode(ListNode node){
				if (head == node && node != tail){
					head = node.next;
					head.prev = null;
					node.next = null;
					addNodeToTail(node);
				} else if (tail == node){
				} else {
					node.prev.next = node.next;
					node.next.prev = node.prev;
					node.next = null;
					addNodeToTail(node);
				}
			}
		}

11、字符串處理算法總結:

1、用Java寫一個遞歸遍歷目錄下面的所有文件
思路:利用File類中的一個listFiles將該文件路徑下所有的文件全部列出來,然後通過循環遍歷
public static void showDirectory(File file){
File[] files = file.listFiles();
for(File a:files){
System.out.println(a.getAbsolutePath());
if(a.isDirectory()){
showDirectory(a);
}
}
}

2、給定一個txt文件,如何得到某字符串出現的次數
File file = new File(“E://test.txt”);
InputStream is = new FileInputStream(file);
byte b[] = new byte[1024];
int a = is.read(b);
String str[] = new String(b,0,a).split("");
int count = 0;
for(int i = 0;i<str.length;i++){
if(“a”.equals(str[i]))
count++;
}
System.out.println(count);
3、實現一個字符集,只包含a~z這26個英文字母的Trie樹(也稱爲字典樹/鍵樹)

		public class TrieNode {
			public char data;
			public TrieNode[] children = new TrieNode[26];
			public boolean isEndingChar = false;
			public TrieNode(char data) {
				this.data = data;
			}
		}
		public class Trie{
			private TrieNode root = new TrieNode('/'); //存儲無意義字符
			// 往Trie樹中插入一個字符串
			public void insert(char[] text) {
				TrieNode p = root;
				for (int i = 0; i < text.length; ++i) {
					int index = text[i] - 'a';
					if (p.children[index] == null) {
						TrieNode newNode = new TrieNode(text[i]);
						p.children[index] = newNode;
					}
					p = p.children[index];
				}
				p.isEndingChar = true;
			}
			// 在Trie樹中查找一個字符串
			public boolean find(char[] pattern){
				TrieNode p = root;
				for (int i = 0; i < pattern.length; ++i){
					int index = pattern[i] - 'a';
					if (p.children[index] == null){
						return false; // 不存在pattern
					}
					p = p.children[index];
				}
				if (p.isEndingChar == false)
					return false; // 不能完全匹配,只能匹配前綴
				else
					return true; // 找到pattern
			}
		}

4、實現樸素的字符串匹配算法(暴力匹配算法/BF算法)
思路:我們在字符串A中查找字符串B,那字符串A就是主串,字符串B就是模式串 我們把主串的長度記作n,模式串的長度記作m。因爲我們是在主串中查找模式串,所以n>m
我們在主串中,檢查起始位置分別是0、1、2…n-m且長度爲m的n-m+1個子串,看有沒有跟模式串匹配的。

		public static int bF(String a, String b) {
			int m = a.length(), n = b.length(), k;
			char[] a1 = a.toCharArray();//主串
			char[] b1 = b.toCharArray();//模式串
			for(int i = 0; i <= m - n; i++){
				k = 0;
				for(int j = 0; j < n; j++){//n爲模式串的長度
					if(a1[i + j] == b1[j]){
						k++;//k的值表示匹配的長度
					}else
						break;
				}
				if(k == n){
					return i;
				}
			}
			return -1;
		}//時間複雜度是O(n*m)
	思路:我們通過哈希算法對主串中的n-m+1個子串分別求哈希值,然後逐個與模式串的哈希值比較大小。
		如果某個子串的哈希值與模式串相等,那就說明對應的子串和模式串匹配了,效率取決於哈希算法的設計方法。
		public static int rK(String a, String b){
			int m = a.length(), n = b.length(), s, j;
			int[] hash = new int[m - n + 1];//主串可以分解爲子串的個數
			int[] table = new int[26];
			char[] a1 = a.toCharArray();
			char[] b1 = b.toCharArray();
			s = 1;
			//將26的次方存儲在一個表裏,取的時候直接用,雖然溢出,但沒啥問題
			for (j = 0; j < 26; j++) {
				table[j] = s;
				s *= 26;
			}
			for (int i = 0; i <= m - n; i++) {//主串
				s = 0;
				for (j = 0; j < n; j++) {
					s += (a1[i + j] - 'a') * table[n - 1 - j];//table爲倒序
				}
				hash[i] = s;
			}
			s = 0;
			for (j = 0; j < n; j++) {//模式串
				s += (b1[j] - 'a') * table[n - 1 - j];
			}
			for (j = 0; j < m - n + 1; j++) {//兩者的hash值比較
				if (hash[j] == s) {
					return j;
				}
			}
			return -1;
		}

算法思想:編程中一定會出現的問題:變種非常多(反轉/反轉單詞/子串/最長子串/最長子序列)
1、用固定支付替換字符串中的空格
使用stringbuffer的append()
2、驗證是否是迴文串
只考慮字母和數字字符,先用isletterOrDigit來跳過其他字符,第一個字符與最後一個字符依次比較,然後I++,J–
3、數組的最長公共前綴
先用數組的sort方法升序排列,找出數組第一個字符串和最後一個的長度,按小的計算,比較字符串的元素,若相等就
保存在Stringbuffer中
4、最長迴文串 區分大小寫
字符出現次數爲雙+一個只出現一次的字符,遍歷數組,字符在hashset中就移除,count++,否則,添加進去

5、反轉字符串,你必須原地修改輸入數組、使用O(1)的額外空間解決這一問題 輸入:[“h”,“e”,“l”,“l”,“o”]輸出:[“o”,“l”,“l”,“e”,“h”]

			public void reverseString(char[] s){
				int l = 0;
				int r = s.length-1;
				int mid = (s.length)/2;
				while(l != mid ){
					char temp = s[l];
					s[l] = s[r];
					s[r] = temp;
					l++;
					r--;
				}
			}//時間複雜度O(n)

StringTokenizer詳解:(允許應用程序將字符串分解爲標記)
1、int countTokens() 計算在生成異常之前可以調用此 tokenizer 的 nextToken 方法的次數;
2、boolean hasMoreElements() 返回與 hasMoreTokens 方法相同的值;
3、String nextToken(String delim) 返回此 string tokenizer 的字符串中的下一個標記。
下面是一個使用 tokenizer 的實例。代碼如下:
StringTokenizer st = new StringTokenizer(“this is a test”);
while (st.hasMoreTokens()) {
System.out.println(st.nextToken());
}
輸出以下字符串: this is a test
StringTokenizer出於兼容性的原因而被保留,建議使用String的split方法或java.util.regex包。建議
String[] result = “this is a test”.split("\s");
for (int x=0; x<result.length; x++)
System.out.println(result[x]);
輸出以下字符串: this is a test

6、 翻轉字符串裏的單詞 輸入: “the sky is blue” 輸出: “blue is sky the”
public String reverseWords(String s) {
String[] strArr = s.split("\s+"); //正則匹配空格
StringBuilder sb = new StringBuilder();
for(int i=strArr.length-1;i>=0;i–){ //倒序遍歷,添加空格
sb.append(strArr[i]);
sb.append(" ");
}
return sb.toString().trim();//去除首尾多餘空格,toString返回
}

7、字符串轉換整數 請你來實現一個 atoi 函數,使其能將字符串轉換成整數
該函數會根據需要丟棄無用的開頭空格字符,直到尋找到第一個非空格的字符爲止。當我們尋找到的第一個非空字符爲正或者負號時,
則將該符號與之後面儘可能多的連續數字組合起來,作爲該整數的正負號;假如第一個非空字符是數字,則直接將其與之後連續的數字字符組合起來,形成整數
例如:輸入: " -42" 輸出: -42 第一個非空白字符爲 ‘-’, 它是一個負號
輸入: “4193 with words” 輸出: 4193 解釋: 轉換截止於數字 ‘3’ ,因爲它的下一個字符不爲數字

				public int myAtoi(String str) {
					int i = 0;
					int len = str.length();
					boolean flag = true;//使用flag指定數值正負
					while (i < len && str.charAt(i) == ' ') {
						i++;
					}
					if (i < len) {
						if (i == len || !Character.isDigit(str.charAt(i))) {
							if (str.charAt(i) == '+') {
								i++;
							} else if (str.charAt(i) == '-') {
								flag = false;
								i++;
							} else {
								return 0;
							}
						}
					} else {
						return 0;
					}
					StringBuilder sb = new StringBuilder();
					if (i < len && Character.isDigit(str.charAt(i))) {
						while (i < len && Character.isDigit(str.charAt(i))) {
							sb.append(str.charAt(i));
							i++;
						}
					} else
						return 0;
					String string = sb.toString();
					int parseInt = 0;
					try {
						parseInt = Integer.parseInt(string);
					} catch (Exception e) {
						if (flag == true) {
							parseInt = Integer.MAX_VALUE;
						} else {
							parseInt = Integer.MIN_VALUE;
						}
					}
					if (flag == true)
						return parseInt;
					else
						return -parseInt;
				}

12、樹部分面試題

12.1、如何遍歷一棵二叉樹?

二叉樹是n個有限元素的集合,由根元素以及左右子數組成。集合可以爲空。
概念:結點的度,結點所擁有的子樹的個數稱爲度。
葉節點,度爲o的結點。
分支節點,即非葉子結點
路徑:n1,n2,,,nk的長度爲路徑
層數:根結點層數爲1,其餘的結點++雙親
深度:最大層數
滿二叉樹:所有葉子結點在同一層
完全二叉樹:葉子結點只能出現在最下層和次下層。
性質: 非空二叉樹第i層最多2{i-1}個結點
深度爲k,最多2{k}-1個結點,最少k個結點
非空二叉樹,度爲0的節點數比度爲2的節點數多1,n0=n2+1;
n個結點的完全二叉樹的深度爲lgn+1
存儲:1、基於指針或者應用的二叉鏈式存儲法;2、基於數組的順序存儲法(適合完全二叉樹)
鏈式存儲法:每個結點有三個字段,其中一個存儲數據,另外兩個是指向左右子節點的指針
順序存儲法:節點X存儲在數據中下標爲i的位置,下標爲2i的位置存儲的是左子節點,下標爲2i+1的位置存儲的是右子節點。
遍歷:使用隊列來實現對二叉樹的層序遍歷,思路:根結點放入隊列,每次從隊列中取出一個結點,打印值。若這個值有子結點,子結點入隊尾,直至隊列爲空。 代碼P305(是一種廣度優先的遍歷算法)
遞歸實現:中序遍歷,先序遍歷,後序遍歷(表示的是節點與它的左右子樹節點遍歷打印的先後順序) 時間複雜度O(n)
非遞歸中序遍歷:首先要移動到結點的左子樹,完成左子樹的遍歷後,再將結點出棧進行

			void InOrderNonRecursive(TreeNode root){
				if (root == null) {
					return;
				}
				Stack s = new Stack();
				while(true){
					while (root !=null) {
						s.push(root);
						root = root.left;
					}
					if (s.isEmpty()) {
						break;
					}
					root = (TreeNode) s.pop();
					System.out.println(root.val);
					root = root.right;
				}
			}

非遞歸先序遍歷:需要使用一個棧來記錄當前節點,以便在完成左子樹遍歷後能返回到右子樹中進行遍歷;
* 在遍歷左子樹之前,把當前節點保存在棧中,直至遍歷完左子樹,將該元素出棧,然後找到右子樹進行遍歷。

			void PreOderNonRecursive(TreeNode root){
				if (root == null) {
					return;
				}
				Stack s= new Stack();//使用棧保存將要遍歷的結點
				while(true){
					while (root !=null) {
						System.out.println(root.val);
						s.push(root);
						root = root.left;
					}
					if (s.isEmpty()) {
						break;
					}
					root = (TreeNode) s.pop();
					root = root.right;
				}
			}
			遞歸後序遍歷:
			void PostOrder(TreeNode root){
				if (root!= null) {
					PostOrder(root.left);
					PostOrder(root.right);
					System.out.println(root.val);
				}
			}
			層序遍歷:
			void LevelOrder(TreeNode root){
				TreeNode temp;
				Queue q= new ArrayBlockingQueue(0);
				if (root == null) 
					return;
				q.add(root);
				while (!q.isEmpty()) {
					temp = (TreeNode) q.remove();
					System.out.println(temp.val);
					if (temp.left != null) {
						q.add(temp.left);
					}
					if (temp.left != null) {
						q.add(temp.right);
					}
				}
				q.clear();
			}

12.2、二叉查找樹(二叉搜索樹)(Mysql索引的底層)

二叉查找樹最大的特點就是,支持動態數據集合的快速插入、刪除、查找操作
1、查找操作:我們先去根節點,如果它等於我們要查找的數據,就返回;如果比根節點小,就在左子樹中遞歸查找;如果比根節點值大,就在右子樹中遞歸查找。
public class BinarySearchTree {
private Node tree;
public Node find(int data) {
Node p = tree;
while (p != null) {
if (data < p.data) p = p.left;
else if (data > p.data) p = p.right;
else return p;
}
return null;
}
public static class Node {
private int data;
private Node left;
private Node right;
public Node(int data) {
this.data = data;
}
}
}

2、插入操作 得先比較,從根節點開始
public void insert(int data) {
if (tree == null) {
tree = new Node(data);
return;
}
Node p = tree;
while (p != null) {
if (data > p.data) {
if (p.right == null) {
p.right = new Node(data);
return;
}
p = p.right;
} else { // data < p.data
if (p.left == null) {
p.left = new Node(data);
return;
}
p = p.left;
}
}
}

3、二叉查找樹的刪除操作(1、如果要刪除的節點沒有子節點,我們只需要直接將父節點中指向要刪除節點的指針置爲null;2、要刪除的節點有一個子節點,我們只需要更新父節點,指向要刪除節點的指針
3、要刪除的節點有兩個子節點,找到這個節點的右子樹中的最小節點,替換到要刪除的節點上)
public void delete(int data) {
Node p = tree; // p指向要刪除的節點,初始化指向根節點
Node pp = null; // pp記錄的是p的父節點
while(p != null && p.data != data) {
pp = p;
if (data > p.data) p = p.right;
else p = p.left;
}
if (p == null) return; // 沒有找到
//要刪除的節點有兩個子節點
if (p.left != null && p.right != null) {//查找右子樹中最小節點
Node minP = p.right;
Node minPP = p; // minPP表示minP的父節點
while (minP.left != null) {
minPP = minP;
minP = minP.left;
}
p.data = minP.data; // 將minP的數據替換到p中
p = minP; // 下面就變成了刪除minP了
pp = minPP;
}
//刪除節點是葉子節點或者僅有一個子節點
Node child; // p的子節點
if (p.left != null) child = p.left;
else if (p.right != null) child = p.right;
else child = null;
if (pp == null) tree = child; // 刪除的是根節點
else if (pp.left == p) pp.left = child;
else pp.right = child;
}
二叉查找樹的執行效率:若是根節點的左右子樹季度不平衡,已經退化到了鏈表,查找的時間複雜度爲O(n);平衡二叉查找樹的時間複雜度O(lgn)


12.3、紅黑樹的應用場景(TreeMap 紅黑樹:一種近似平衡的二叉查找樹:二叉樹中任意一個節點的左右子樹的高度相差不能大於1。包括完全二叉樹、滿二叉樹)

紅黑樹的特點
1、每個節點要麼是紅色,要麼是黑色
2、根節點必須是黑色
3、紅色節點不能連續(紅色節點的孩子和父親都不能是紅色)
4、對於每個節點,從該點至葉子的任何路徑,都含有相同個數的黑色節點
5、確保節點的左右子樹的高度差,不會超過二者中較低那個的一倍
5、搜索時時間複雜度O(logN)
應用場景:搜索,插入刪除次數多(爲了解決普通二叉查找樹在數據更新的過程中,複雜度退化的問題而產生的)
1、map和set都是用紅黑樹實現的
2、linux進程調度Completely Fair Scheduler,用紅黑樹管理進程控制塊
3、epoll在內核中的實現,用紅黑樹管理事件塊
4、nginx中,用紅黑樹管理timer
5、Java的TreeMap實現
AVL樹適合用於插入刪除次數比較少,但查找多的情況
關於動態數據結構:鏈表/棧/隊列/哈希表(鏈表適合遍歷的場景,插入和刪除操作方便;棧和隊列可以算一種特殊的鏈表,分別使用先進後出和先進先出的場景;哈希表適合插入和刪除比較少,查找比較多的場景;紅黑樹對數據要求有序,對數據增刪改查都有一定要求的時候)
散列表/跳錶/紅黑樹性能對比:
1、散列表:插入刪除查找都是O(1),是最常用的,缺點是不能順序遍歷以及擴容縮容的性能損耗。適用於不需要順序遍歷、數據更新不那麼頻繁的;
2、跳錶:插入刪除查找都是O(lgn),能順序遍歷,缺點是空間複雜度O(n),適用於不那麼在意內存空間的,其順序遍歷和區間查找非常方便;
3、紅黑樹:插入刪除查找都是O(lgn),中序遍歷即是順序遍歷,穩定。缺點是難以實現,去查找不方便。


12.4、數據結構 堆

滿足條件:1、堆是一個完全二叉樹;2、堆中每一個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值
堆都支持哪些操作以及如何存儲一個堆(通過數組來存儲)
缺點:對於一組已經有序的數據來說,經過建堆後,數據反而變得更無序了
堆排序的過程:1、建立初始堆(把數組中的元素的序列看成是一顆完全二叉樹,對該二叉樹進行調整,使之成爲堆) 根節點的索引是1
2、堆排序(把根元素與最右子節點交換,然後再次構建堆,再與倒數第二集結點交換,然後再構建堆) 生成由小到大排列的數組
時間複雜度:假設有n個數據,需要進行n-1次建堆,每次建堆本身耗時lgn,則其時間效率爲O(nlgn) 空間複雜度O(1)
建堆操作:
private static void buildHeap(int[] a, int n) {
for (int i = n/2; i >= 1; --i) {
heapify(a, n, i);
}
}
private static void heapify(int[] a, int n, int i) {
while (true) {
int maxPos = i;
if (i2 <= n && a[i] < a[i2]) maxPos = i2;
if (i
2+1 <= n && a[maxPos] < a[i2+1]) maxPos = i2+1;
if (maxPos == i) break;
swap(a, i, maxPos);
i = maxPos;
}
}
排序操作: //n表示數據的個數,數組a中的數據從下標1到n的位置。
public static void sort(int[] a, int n) {
buildHeap(a, n);
int k = n;
while (k > 1) {
swap(a, 1, k);
–k;
heapify(a, k, 1);
}
}
爲什麼快速排序要比堆排序性能好?
1、堆排序數據訪問的方式沒有快速排序友好(開拍是順序訪問;堆排序是跳着訪問,對cpu緩存不友好)
2、同樣的數據,在排序過程中,堆排序算法的數據交換次數要多於快速排序
應用:1、優先級隊列;2、topK;3、流裏面的中位數;


12.5、AC自動機:如何用多模式串匹配實現敏感詞過濾功能?(使用Trie樹)

字符串匹配算法:單模式串匹配算法(BF算法、RK算法、BM算法、KMP算法),多模式串匹配算法(Trie樹 最長前綴匹配)
AC自動機算法包含兩個部分,第一部分是將多個模式串構建成AC自動機,第二部分是在AC自動機中匹配主串。第一部分又分爲兩個小的步驟,一個是將模式串構建成Trie樹,另一個是在Trie樹上構建失敗指針
適用場景:
單模式串匹配:
BF(直接匹配算法 簡單場景,主串和模式串都不太長, O(mn) 效率最低)
KP(字符集範圍不要太大且模式串不要太長,否則hash值可能衝突,O(n))
naive-BM(模式串最好不要太長(因爲預處理較重),比如IDE編輯器裏的查找場景;預處理O(m
m),匹配O(n),實現較複雜,需要較多額外空間)
KMP(適合所有場景,整體實現起來也比BM簡單,O(n+m),僅需一個next數組的O(n)額外空間;但統計意義下似乎BM更快)
還有一種比BM/KMP更快,且實現+理解起來都更容易的Sunday算法
多模式串匹配:
naive-Trie(適合多模式串公共前綴較多的匹配(O(n*k)) 或者 根據公共前綴進行查找(O(k))的場景,比如搜索框的自動補全提示 root不存儲字符)
AC自動機(適合大量文本中多模式串的精確匹配查找, 查找的複雜度可以到O(n))**
定義:AC自動機實際上就是在Trie樹之上,加了類似KMP的next數組,只不過此處的next數組是構建在樹上
public class AcNode {
public char data;
public AcNode[] children = new AcNode[26]; //字符集只包含a~z這26個字符
public boolean isEndingChar = false; //結尾字符爲true
public int length = -1; //當isEndingChar=true時,記錄模式串長度
public AcNode fail; //失敗指針 相當於KMP中失效函數next數組
public AcNode(char data) {
this.data = data;
}
}


13、海量數據的處理思路問題

13.1、大數據量的問題:

10w個id,怎麼去100億個id裏找數據,怎麼做能更快,分庫分表?


13.2、有10G大小的文件,每行記錄一條運單信息,機器大小是500M,求出出現次數最多的前1000條運單號,給出思路。

典型的Top K算法(分治思想)
1、先對這批海量數據預處理,在O(N)的時間內用Hash表完成分組(相同單號被分配到Hash桶中的同一條鏈表中) %20 20個文件,每個文件500M 堆的大小取決於機器的內存值500M;
2、藉助堆這個數據結構,找出Top K,時間複雜度爲N‘logK
3、對每個堆中的TOPk,計算出前k個數(歸併排序)

13.3、給定a、b兩個文件,各存放50億個url,每個url各佔64字節,內存限制是4G,讓你找出a、b文件共同的url?

方案1:可以估計每個文件的大小爲5G×64=320G,遠遠大於內存限制的4G。所以不可能將其完全加載到內存中處理。考慮採取分而治之的方法。
遍歷文件a,對每個url求取hash(url)%1000,然後根據所取得的值將url分別存儲到1000個小文件(記爲a0,a1,…,a999)中。這樣每個小文件的大約爲300M
遍歷文件b,採取和a相同的hash函數將url分別存儲到1000小文件(記爲b0,b1,…,b999)。這樣處理後,所有可能相同的url都在對應的小文件(a0vsb0,a1vsb1,…,a999vsb999)中,
不對應的小文件不可能有相同的url。然後我們只要求出1000對小文件中相同的url即可
求每對小文件中相同的url時,可以把其中一個小文件的url存儲到hash_set中。然後遍歷另一個小文件的每個url,看其是否在剛纔構建的hash_set中,如果是,那麼就是共同的url,存到文件裏面就可以了。

13.4、在2.5億個整數中找出不重複的整數,注,內存不足以容納這2.5億個整數。

方案1:用2-Bitmap(每個數分配2bit,00表示不存在,01表示出現一次,10表示多次,11無意義)進行,共需內存內存,還可以接受。
然後掃描這2.5億個整數,查看Bitmap中相對應位,如果是00變01,01變10,10保持不變。所描完事後,查看bitmap,把對應位是01的整數輸出即可。
方案2:也可採用與第1題類似的方法,進行劃分小文件的方法。然後在小文件中找出不重複的整數,並排序。然後再進行歸併,注意去除重複的元素

13.5、怎麼在海量數據中找出重複次數最多的一個?

方案1:先做hash,然後求模映射爲小文件,求出每個小文件中重複次數最多的一個,並記錄重複次數。然後找出上一步求出的數據中重複次數最多的一個就是所求100w個數中找出最大的100個數
用一個含100個元素的最小堆完成。複雜度爲O(100w*lg100)

13.6、如果你所在的省有50萬考生,如何通過成績快速排序得出名次呢?

13.7、假設我們有10萬個手機號碼,希望將這10萬個手機號碼從小到大排序,你有什麼比較快速的排序方法呢?

13.8、假設我們有1000萬個整型數據,每個數據佔8個字節,如何設計數據結構和算法,快速判斷某個整數是否出現在這1000萬數據中? 我們希望這個功能不要佔用太多的內存空間,最多不要超過100MB,你會怎麼做呢?

13.9、如何在海量數據中快速查找某個數據?(索引)(在計算機組成中稱爲尋址)

MySQL底層依賴的是B+樹這種數據結構,Redis這樣的Key-Value數據庫中的索引,又是怎麼實現的呢?底層依賴的又是什麼數據結構呢?
索引存儲位置:在內存還是硬盤
單值查找還是區間查找?
單關鍵詞查找還是多關鍵詞組合查找?對於結構化數據的查詢需求(MYSQL),針對多個關鍵詞的組合,建立索引;對於非結構數據的查詢需求(搜索引擎),以針對單個關鍵詞構建索引,
然後通過集合操作,比如求並集、求交集等,計算出多個關鍵詞組合的查詢結果。
索引的維護成本。因爲在原始數據動態增刪改的同時,也需要動態的更新索引。
構建索引常用的數據結構?
對動態數據建立索引:散列表、紅黑樹、跳錶、B+樹;位圖、布隆過濾器可以作爲輔助索引;有序數組可以用來對靜態數據構建索引。
散列表:一些鍵值數據庫,比如Redis、Memcache,就是使用散列表來構建索引的,增刪改查的性能非常好,時間複雜度爲O(1),這類索引,一般都構建在內存中;
紅黑樹:作爲一種常用的平衡二叉查找樹,數據插入、刪除、查找的時間複雜度是O(logn),也非常適合用來構建內存索引。Ext文件系統中,對磁盤塊的索引,使用的是紅黑樹;
B+樹:比起紅黑樹來說,更加適合構建存儲在磁盤中的索引,B+樹是一個多叉樹,所以,對相同個數的數據構建索引,B+樹的高度要低於紅黑樹。當藉助索引查詢數據的時候,
讀取B+樹索引,需要的磁盤IO次數非常更少,關係型數據庫的索引:如Mysql、oracle,使用的是B+樹建立索引
跳錶:支持快速添加、刪除、查找數據。而且通過靈活調整索引結點個數和數據個數之間的比例,可以很好地平衡索引對內存的消耗及其查詢效率。Redis中的有序集合,就是用跳錶來構建的
布隆過濾器:對於判定存在的數據,有可能並不存在,但是對於判定不存在的數據,那肯定就不存在。內存佔用非常少
有序數組:如果數據是靜態的,也就是不會有插入、刪除、更新操作,那我們可以把數據的關鍵詞(查詢用的)抽取出來,組織成有序數組,然後利用二分查找算法來快速查找數據
你知道基礎系統、中間件、開源軟件等系統中,有哪些用到了索引嗎?這些系統的索引是如何實現的呢?
1、區塊鏈拿以太坊來說,存儲用的leveldb,數據存儲用的數據結構是帕特利夏樹,是一種高級的trie樹,很好的做了數據的壓縮;
2、消息中間件像kafka這種,會去做持久化,每個partition都會有很多數據,會有大量數據存儲在磁盤中,所以每個partition也會有個索引,方便去做快速訪問。
3、ES中的倒排索引用了trie樹(一種專門處理字符串匹配的數據結構),對每個需要索引的key維護了一個trie樹,用於定位到這個key在文件中的位置,然後直接用有序列表直接去訪問對應的documents
trie樹(兩個操作:一個將字符串插入到Trie樹的過程。另一個是在Trie樹中查詢一個字符串)

Q:Trie樹:如何實現搜索引擎的搜索關鍵詞提示功能?(爲了方便快速輸入,當你在搜索引擎的搜索框中,輸入要搜索的文字的某一部分的時候,搜索引擎就會自動彈出下拉框,裏面是各種關鍵詞提示)
A:‘字典樹’。顧名思義,它是一個樹形結構。它是一種專門處理字符串匹配的數據結構,用來解決在一組字符串集合中快速查找某個字符串的問題.
Trie樹的本質,就是利用字符串之間的公共前綴,將重複的前綴合並在一起(感覺有點像霍夫曼編碼:左0右1)
時間複雜度:O(k) k爲字符串長度
應用場景:自動輸入補全,比如輸入法自動補全功能、IDE代碼編輯器自動補全功能、瀏覽器網址輸入的自動補全功能等等
Q:Trie樹應用場合對數據要求比較苛刻,比如字符串的字符集不能太大,前綴重合比較多等。如果現在給你一個很大的字符串集合,比如包含1萬條記錄,如何
通過編程量化分析這組字符串集合是否比較適合用Trie樹解決呢?也就是如何統計字符串的字符集大小,以及前綴重合的程度呢?
A:依次讀取每個字符串的字符構建 Trie 樹,用散列表來存儲每一個節點。每一層樹的所有散列表的元素用一個鏈表串聯起來,求某一長度的前綴重合,在對應樹層級上遍歷該層鏈表,
求鏈表長度,除以字符集大小,值越小前綴重合率越高。遍歷所有樹層級的鏈表,存入散列表,最後散列表包含元素的個數,就代表字符集的大小

如何存儲一個Trie樹?

				public class Trie {
					private TrieNode root = new TrieNode('/'); //存儲無意義字符
					//往Trie樹中插入一個字符串
					public void insert(char[] text) {
						TrieNode p = root;
						for (int i = 0; i < text.length; ++i) {
							int index = text[i] - 'a';
							if (p.children[index] == null) {
								TrieNode newNode = new TrieNode(text[i]);
								p.children[index] = newNode;
							}
							p = p.children[index];
						}
						p.isEndingChar = true;
					}
					//在Trie樹中查找一個字符串
					public boolean find(char[] pattern) {
						TrieNode p = root;
						for(int i = 0; i < pattern.length; ++i) {
							int index = pattern[i] - 'a';
							if (p.children[index] == null) {
								return false; //不存在pattern
							}
							p = p.children[index];
						}
						if(p.isEndingChar == false) 
							return false; //不能完全匹配,只是前綴
						else 
							return true; //找到pattern
					}
					public class TrieNode {
						public char data;
						public TrieNode[] children = new TrieNode[26];
						public boolean isEndingChar = false;
						public TrieNode(char data) {
							this.data = data;
						}
					}
				}	

13.10、並行計算:利用並行處理提高算法的執行效率(分治的思想)

算法無法再繼續優化的情況下,如何來進一步提高執行效率呢?可以使用一種簡單但好用的優化方法,那就是並行計算。
1、並行排序 對時間複雜度爲O(nlgn)的三種排序算法:歸併/快排/堆排進行並行化處理
如:歸併排序時,將8G的數據先劃分爲16個小的數據集合,然後多線程處理,最後將這16個有序集合合併;快速排序中,先掃描一遍數據,遭到數據所處的範圍區間,
同樣劃分爲16個小區間,並行進行排序,等到16個線程都執行借宿之後,得到的數據就是有序數據了。
2、並行查找 散列表,給動態數據構建索引,在數據不斷加入的時候,散列表的裝載因子就會越來越大。爲了保證散列表性能不下降,我們就需要對散列表進行動態擴容,
可以將數據隨機分割成k份(比如16份),每份中的數據只有原來的1/k,然後我們針對這k個小數據集合分別構建散列表,增加存儲空間的利用率。


14、圖的應用

14.1、如何存儲微博、微信等社交網絡中的好友關係?

微博:有向圖(入度代表粉絲數,出度代表關注數)
社交關係存儲方法:鄰接表(存儲用戶關注關係)+逆鄰接表(存儲被關注信息)
需求:判斷用戶A是否關注了用戶B;判斷用戶A是否是用戶B的粉絲;用戶A關注用戶B;用戶A取消關注用戶B;
根據用戶名稱的首字母排序,分頁獲取用戶的粉絲列表;根據用戶名稱的首字母排序,分頁獲取用戶的關注列表。
如何迅速判斷倆用戶之間的關注關係?
因爲需要按首字母排序,獲取粉絲列表或關注列表,在鄰接表右邊使用跳錶是最合適的(跳錶存儲的數據有序)。這是因爲,跳錶插入、刪除、查找都非常高效,時間複雜度是O(logn),空間複雜度上稍高,是O(n)
如何解決數據量大的問題?
可以通過哈希算法等數據分片方式,將鄰接表存儲在不同的機器上。例如:在機器1上存儲頂點1,2,3的鄰接表,在機器2上,存儲頂點4,5的鄰接表
當要查詢頂點與頂點關係的時候,我們就利用同樣的哈希算法,先定位頂點所在的機器,然後再在相應的機器上查找。
持久化存儲關係?
使用數據庫

	微信:無向圖(好友間建立一條邊)
	QQ:帶權圖(每條邊都有一個權重,可以通過權重表示QQ好友間的親密度)

14.2、如何在內存中存儲圖這種數據結構?

鄰接矩陣:依賴一個二維數組,A[i][j]=w表示可達,w表示權重 (我們的掃雷遊戲就是使用的這種有向圖數據結構,帶權值(0表示沒有操作,1表示地雷,-1表示插上了紅旗)下次可以做成PPT
缺點:對於無向圖來說,浪費了一半的空間;對於稀疏矩陣,絕大多數的存儲空間都被浪費了
優點:基於矩陣,在獲取兩頂點的關係時,就非常高效;第二是方便計算,如求最短路徑
鄰接表:每個頂點對應一條鏈表,鏈表中存儲的是與這個頂點相連接的其他頂點
優點:節省空間
缺點:在鄰接表中查詢兩個頂點間的關係效率較低,改進措施(鄰接表右側的鏈表可以使用二叉樹/紅黑樹/跳錶來表示,跳錶最適合)

14.3、圖的其他領域應用?

Gradle這個編譯工具,內部組織task的方式用的是有向圖;
Android framework層提供了一個CoordinatorLayout,其內部協調子view的聯動,也是用的圖;
互聯網上網頁之間通過超鏈接連接成一張有向圖;
城市乃至全國交通網絡是一張加權圖;

14.4、如何找出社交網絡中的三度好友關係?(深度優先和廣度優先搜索算法)(存儲使用鄰接表)(無向圖)

BFS:廣度優先搜索:時間複雜度O(V+E) V爲頂點個數,E爲邊的個數(對於一個連通圖來說,E肯定要大於等於V-1,所以,廣度優先搜索的時間複雜度也可以簡寫爲O(E)。)
廣度優先搜索的空間消耗主要在幾個輔助變量visited數組、queue隊列、prev數組上.所以空間複雜度是O(V)。
DFS:深度優先搜索(深度優先搜索找出來的路徑,並不是頂點s到頂點t的最短路徑) 時間複雜度是O(E) 空間複雜度是O(V)
藉助 棧來實現 輔助變量visited數組和prev數組
社交網絡中的三度好友關係?
非常適合於圖的廣度優先搜索算法來解決,因爲它是層層往外推進的。首先,遍歷與起始頂點最近的一層頂點,也就是用戶的一度好友,然後再遍歷與用戶距離的邊數爲2的頂點,
也就是二度好友關係,以及與用戶距離的邊數爲3的頂點,也就是三度好友關係。
適用場合:狀態空間不大,也就是圖不大的搜索,屬於基本的搜索算法(高級搜搜算法有A*/IDA*)

14.5、如何確定代碼源文件的編譯依賴關係?(拓撲排序 有向無環圖)

問題闡述:一個完整的項目往往會包含很多代碼源文件。編譯器在編譯整個項目的時候,需要按照依賴關係,依次編譯每個源文件。比如,A.cpp依賴B.cpp,那在編譯的時候,
		編譯器需要先編譯B.cpp,才能編譯A.cpp。我們可以把源文件與源文件之間的依賴關係,抽象成一個有向圖。每個源文件對應圖中的一個頂點,源文件之間的依賴關係就是頂點之間的邊。
	算法解析:
//數據結構:有向無環圖,使用鄰接表來存儲
public class Graph {
	private int v; // 頂點的個數
	private LinkedList<Integer> adj[]; // 鄰接表存放頂點
	public Graph(int v) {
		this.v = v;
		adj = new LinkedList[v];
		for (int i=0; i<v; ++i) {
			adj[i] = new LinkedList<>();
		}
	}
	public void addEdge(int s, int t) { // s先於t,邊s->t
		adj[s].add(t);
	}
}

兩種實現方式:Kahn和DFS
1、Kahn基於貪心算法,思路是如果s需要先於t執行,那就添加一條s指向t的邊,如果某個頂點入度爲0, 也就表示,沒有任何頂點必須先於這個頂點執行,那麼這個頂點就可以執行。
我們先從圖中,找出一個入度爲0的頂點,將其輸出到拓撲排序的結果序列中(對應代碼中就是把它打印出來),並且把這個頂點從圖中刪除(也就是把這個頂點可達的頂點的入度都減1)
我們循環執行上面的過程,直到所有的頂點都被輸出。最後輸出的序列,就是滿足局部依賴關係的拓撲排序。

public void topoSortByKahn(){
	int[] inDegree = new int[v]; // 統計每個頂點的入度
	for (int i = 0; i < v; ++i){
		for (int j = 0; j < adj[i].size(); ++j){
			int w = adj[i].get(j); // i->w   w爲  ->w,即被指向的頂點,有某個點或幾個點只指向他人,而不會被指向
			inDegree[w]++;
		}
	}
	LinkedList<Integer> queue = new LinkedList<>();
	for (int i = 0; i < v; ++i){
		if (inDegree[i] == 0) queue.add(i);//某個頂點的入度是0,將此頂點加入隊列
	}
	while (!queue.isEmpty()){
		int i = queue.remove();
		System.out.print("->" + i);
		for (int j = 0; j < adj[i].size(); ++j){
			int k = adj[i].get(j);
			inDegree[k]--;	//刪除頂點,即此頂點可達的頂點入度都減1
			if (inDegree[k] == 0) queue.add(k);//此時,又有一個/多個頂點的入度爲0,加入到隊列中
		}
	}
}//時間複雜度就是O(V+E)(V表示頂點個數,E表示邊的個數)

2.DFS算法 時間複雜度也是O(V+E)。

public void topoSortByDFS(){
	//先構建逆鄰接表,邊s->t表示,s依賴於t,t先於s
	LinkedList<Integer> inverseAdj[] = new LinkedList[v];
	for(int i=0; i<v; ++i) {//申請空間
		inverseAdj[i] = new LinkedList<>();
	}
	for(int i=0; i<v; ++i) {//通過鄰接表生成逆鄰接表  爲什麼這麼轉換
		for (int j = 0; j < adj[i].size(); ++j) {
			int w = adj[i].get(j); // i->w
			inverseAdj[w].add(i); // w->i
		}
	}
	boolean[] visited = new boolean[v];
	for(int i=0; i<v; ++i) {//深度優先遍歷圖   
		if (visited[i] == false) {
			visited[i] = true;
			dfs(i, inverseAdj, visited);
		}
	}
}
private void dfs(int vertex, LinkedList<Integer> inverseAdj[], boolean[] visited){
	for (int i = 0; i < inverseAdj[vertex].size(); ++i){
		int w = inverseAdj[vertex].get(i);
		if (visited[w] == true) continue;
		visited[w] = true;
		dfs(w, inverseAdj, visited);
	}//先把vertex這個頂點可達的所有頂點都打印出來之後,再打印它自己
	System.out.print("->" + vertex);
}

3、拓撲排序的應用:需要通過局部順序來推導全局順序的,一般都能用拓撲排序來解決。
實現拓撲排序的Kahn算法能檢測圖中環的存在,若果最後輸出的頂點個數少於圖中頂點個數,說明圖中還有入度不是0的頂點,那就說明,圖中存在環。
這就是環的檢測問題:(只需要記錄已經訪問過的用戶ID,當用戶ID第二次被訪問的時候,就說明存在環)

HashSet<Integer> hashTable = new HashSet<>(); // 保存已經訪問過的actorId
long findRootReferrerId(long actorId) {
	if (hashTable.contains(actorId)) { // 存在環
		return;
	}
	hashTable.add(actorId);
	Long referrerId = select referrer_id from [table] where actor_id = actorId;
	if (referrerId == null) return actorId;
		return findRootReferrerId(referrerId);
}

如果想知道數據庫中所有用戶之間的推薦關係,有沒有存在環的情況,需要使用拓撲排序算法,我們把用戶之間的推薦關係,從數據庫中加載到內存中,然後構建成有向圖的數據結構,再利用拓撲排序,就可以快速檢測出是否存在環。

14.6、最短路徑算法 (Dijkstra/A*)

建模:將地圖抽象成具體的數據結構-圖,把每個岔路口看作一個頂點,岔路口與岔路口之間的路看作一條邊,路的長度就是邊的權重。如果路是單行道,
我們就在兩個頂點之間畫一條有向邊;如果路是雙行道,我們就在兩個頂點之間畫兩條方向不同的邊。這樣,整個地圖就被抽象成一個有向有權圖。
數據結構如下:

public class Graph { //有向有權圖的鄰接表表示
	private LinkedList<Edge> adj[]; //鄰接表
	private int v; //頂點個數
	public Graph(int v) {
		this.v = v;
		this.adj = new LinkedList[v];
		for (int i = 0; i < v; ++i) {
			this.adj[i] = new LinkedList<>();
		}
	}
	public void addEdge(int s, int t, int w) { //添加一條邊
		this.adj[s].add(new Edge(s, t, w));
	}
	private class Edge {
		public int sid; //邊的起始頂點編號
		public int tid; //邊的終止頂點編號
		public int w; 	//權重
		public Edge(int sid, int tid, int w) {
			this.sid = sid;
			this.tid = tid;
			this.w = w;
		}
	}
	//下面這個類是爲了dijkstra實現用的
	private class Vertex {
		public int id; //頂點編號ID
		public int dist; //從起始頂點到這個頂點的距離
		public Vertex(int id,int dist) {
			this.id = id;
			this.dist = dist;
		}
	}
}

最短路徑算法實現Dijkstra 時間複雜度是O(E*logV) E爲所有邊的個數,V表示頂點的個數
思想:1、採用貪婪法:總是選取最接近源點的頂點;2、使用優先隊列並按照到s的距離來存儲未被訪問過的頂點;3、不能用於權值爲負的情況。
具體而言:Dijkstra通過回溯窮舉所有從s到達t的不同路徑,在此基礎上,利用動態規劃的思想,對回溯搜索進行了剪枝,只保留起點到某個頂點的最短路徑,繼續往外擴展搜索,能得到最優解。

// 因爲Java提供的優先級隊列,沒有暴露更新數據的接口,所以我們需要重新實現一個
private class PriorityQueue { // 根據vertex.dist構建小頂堆
private Vertex[] nodes;
private int count;
public PriorityQueue(int v) {
	this.nodes = new Vertex[v+1];
	this.count = v;
}
public Vertex poll() { // TODO: 留給讀者實現... }
public void add(Vertex vertex) { // TODO: 留給讀者實現...}
// 更新結點的值,並且從下往上堆化,重新符合堆的定義。時間複雜度O(logn)。
public void update(Vertex vertex) { // TODO: 留給讀者實現...}
public boolean isEmpty() { // TODO: 留給讀者實現...}
}
public void dijkstra(int s, int t) { // 從頂點s到頂點t的最短路徑
	int[] predecessor = new int[this.v]; //用來還原最短路徑,用於它記錄每個頂點的前驅頂點
	Vertex[] vertexes = new Vertex[this.v]; //記錄從起始頂點到每個頂點的距離(dist),我們更新了某個頂點的dist值之後,如果這個頂點已經在優先級隊列中,就不要再將它重複添加進去了
	for (int i = 0; i < this.v; ++i) {
		vertexes[i] = new Vertex(i, Integer.MAX_VALUE);
	}
	PriorityQueue queue = new PriorityQueue(this.v);// 小頂堆
	boolean[] inqueue = new boolean[this.v]; // 標記是否進入過隊列
	vertexes[s].dist = 0;
	queue.add(vertexes[s]);
	inqueue[s] = true;
	while (!queue.isEmpty()) {
		Vertex minVertex= queue.poll(); // 取堆頂元素並刪除
		if (minVertex.id == t) break; // 最短路徑產生了
		for (int i = 0; i < adj[minVertex.id].size(); ++i) {
			Edge e = adj[minVertex.id].get(i); // 取出一條minVetex相連的邊
			Vertex nextVertex = vertexes[e.tid]; // minVertex-->nextVertex
			if (minVertex.dist + e.w < nextVertex.dist) { // 更新next的dist
				nextVertex.dist = minVertex.dist + e.w;
				predecessor[nextVertex.id] = minVertex.id;
				if (inqueue[nextVertex.id] == true) {
					queue.update(nextVertex); // 更新隊列中的dist值
				} else {
					queue.add(nextVertex);
					inqueue[nextVertex.id] = true;
				}
			}
		}
	}
	// 輸出最短路徑
	System.out.print(s);
	print(s, t, predecessor);
}
private void print(int s, int t, int[] predecessor) {
	if (s == t) return;
	print(s, predecessor[t], predecessor);
	System.out.print("->" + t);
}

實際的應用中,相比Dijkstra算法,地圖軟件更多的是A*啓發式搜索算法

實例2:在計算最短時間的出行路線中,如何獲得通過某條路的時間呢?
與時間相關的變量:1、路徑長度;2、路況;3、擁堵情況;4、紅綠燈個數,獲取這些因素後就可以建立一個迴歸模型(如線性迴歸)來評估時間
情況3是數據是動態的,可以通過與交通部門合作獲得路段擁堵情況,聯合其他導航軟件獲得該路段的在線人數

實例3:今天講的出行路線問題,我假設的是開車出行,那如果是公交出行呢?如果混合地鐵、公交、步行,又該如何規劃路線呢?
混合公交、地鐵和步行時,地鐵時刻表是固定的,容易估算。公交雖然沒那麼準時,大致時間是可以估計的,步行時間受路擁堵狀況小,基本與道路長度
成正比,也容易估算。總之,公交、地鐵、步行,時間估算會比開車更容易,也更準確些。
實例4:翻譯系統。只能針對單個詞來做翻譯。如果要翻譯一整個句子,我們需要將句子拆成一個一個的單詞,再丟給翻譯系統。針對每個單詞,翻譯系統會
返回一組可選的翻譯列表,並且針對每個翻譯打一個分,表示這個翻譯的可信程度。我們希望計算出得分最高的王倩K個翻譯結果。
解答:使用Dijkstra最短路徑算法

14.7、A*搜索算法(實現遊戲中的尋路功能)

1、與Dijkstra算法的比較:Dijkstra類似BFS算法,它每次找到跟起點最近的頂點,往外擴展
2、曼哈頓距離:兩點之間橫縱座標的距離之和,計算過程只涉及加減法,符號位反轉,比歐幾里得距離高效
int hManhattan(Vertex v1, Vertex v2) { //Vertex表示頂點,後面有定義
return Math.abs(v1.x - v2.x) + Math.abs(v1.y - v2.y);
} //啓發函數
3、A*算法是對Dijkstra算法的簡單改進

private class Vertex {
	public int id; //頂點編號ID
	public int dist; //從起始頂點,到這個頂點的距離,也就是g(i)
	public int f; //新增:f(i)=g(i)+h(i)
	public int x, y; //新增:頂點在地圖中的座標(x, y)
	public Vertex(int id, int x, int y) {
		this.id = id;
		this.x = x;
		this.y = y;
		this.f = Integer.MAX_VALUE;
		this.dist = Integer.MAX_VALUE;
	}
}
//Graph類的成員變量,在構造函數中初始化
Vertex[] vertexes = new Vertex[this.v];
//新增一個方法,添加頂點的座標
public void addVetex(int id, int x, int y) {
	vertexes[id] = new Vertex(id,x,y)
}

與Dijkstra算法的三點區別:
1、優先級隊列構建的方式不同。A算法是根據f值(也就是剛剛講到的f(i)=g(i)+h(i))來構建優先級隊列,而Dijkstra算法是根據dist值(也就是剛剛講到的g(i))來構建優先級隊列;
2、A
算法在更新頂點dist值的時候,會同步更新f值;
3、循環結束的條件也不一樣。Dijkstra算法是在終點出隊列的時候才結束,A算法是一旦遍歷到終點就結束。
(A
每次從f值最小的頂點出隊列,一旦搜索到重點就不再繼續考察其他頂點和路線,也就不可能找出最短路徑)

public void astar(int s, int t) { //從頂點s到頂點t的路徑
	int[] predecessor = new int[this.v]; //用來還原路徑
	//按照vertex的f值構建的小頂堆,而不是按照dist
	PriorityQueue queue = new PriorityQueue(this.v);
	boolean[] inqueue = new boolean[this.v]; //標記是否進入過隊列
	vertexes[s].dist = 0;
	vertexes[s].f = 0;
	queue.add(vertexes[s]);
	inqueue[s] = true;
	while (!queue.isEmpty()) {
		Vertex minVertex = queue.poll(); //取堆頂元素並刪除
		for (int i = 0; i < adj[minVertex.id].size(); ++i) {
			Edge e = adj[minVertex.id].get(i); //取出一條minVetex相連的邊
			Vertex nextVertex = vertexes[e.tid]; //minVertex-->nextVertex
			if (minVertex.dist + e.w < nextVertex.dist) { //更新next的dist,f
				nextVertex.dist = minVertex.dist + e.w;
				nextVertex.f= nextVertex.dist+hManhattan(nextVertex, vertexes[t]);//關鍵之處,使用f(i)=g(i)+h(i))來構建優先級隊列
				predecessor[nextVertex.id] = minVertex.id;//用於還原路徑
				if (inqueue[nextVertex.id] == true) {
					queue.update(nextVertex);
				} else {
					queue.add(nextVertex);//入隊
					inqueue[nextVertex.id] = true;
				}
			}
			if (nextVertex.id == t) break; //只要到達頂點t,就可以結束while了,no,這個地方王爭搞錯了
		}
	}
	//輸出路徑
	System.out.print(s);
	print(s, t, predecessor); // print函數請參看Dijkstra算法的實現
}

總結:A算法屬於一種啓發式搜索算法,還有一些其他的同類型算法:IDA算法、蟻羣算法、模擬退火算法等
啓發式搜索算法利用估價函數,避免“跑偏”,貪心地朝着最有可能到達終點的方向前進,這種算法找出的路線,並不是最短路線,但是啓發式搜索算法能很好地平衡路線質量和執行效率,
它在實際的軟件開發中的應用更加廣泛。
補充1:break的作用域
break 跳出最近的{}包裹的代碼,如果有標記,就跳出標記的{}
上述代碼更正爲:
if(nextVertex.id==t){
queue.clear();
break;
}


15、位圖(bitmap)與推薦算法

  • 利用歐幾里得公式計算倆用戶聽歌喜好的距離
  • 頭條的新聞推送,淘寶的猜你喜歡
  • bitmap(位圖)與布隆過濾器

bitmap的數據結構:

public class BitMap {
	private char[] bytes;
	private int nbits;
	public BitMap(int nbits) {
		this.nbits = nbits;
		this.bytes = new char[nbits/8+1];
	}
	public void set(int k) {
		if (k > nbits) return;
		int byteIndex = k / 8;
		int bitIndex = k % 8;
		bytes[byteIndex] |= (1 << bitIndex);
	}
	public boolean get(int k) {
		if (k > nbits) return false;
		int byteIndex = k / 8;
		int bitIndex = k % 8;
		return (bytes[byteIndex] & (1 << bitIndex)) != 0;
	}
}

好處:如果用散列表存儲着1千萬的數據,數據時32位的整型數,也就是需要4字節的存儲空間,那總共至少需要40MB的存儲空間,如果我們通過位圖的話,數字範圍在1到1億之間,
只需要1億個二進制位,也就是12MB左右的存儲空間即可。

15.1、什麼是布隆過濾器,其實現原理是?(java.util的BitSet類,實現類位圖) False positive指的是?(螞蟻問到)

布隆過濾器是由一個很長的二進制向量(位圖)加一系列隨機映射函數(例如hash函數)組成。它可以用於檢索一個元素是否在一個集合中。
例如:我們把hash函數設計成f(x)=x%n,其中,x表示數字,n表示位圖的大小(1億),也就是,對數字跟位圖的大小進行取模求餘。
hash函數的特殊設計:一個hash函數可能會存在衝突,那使用多個hash函數一塊兒定義一個數據,我們把這K個數字作爲位圖中的下標,降低衝突的概率。
當要查詢某個數字是否存在的時候,我們用同樣的K個哈希函數,對這個數字求哈希值,如果都是true,則說明,這個數字存在。(帶來了新的缺點:容易誤判)
優點是空間效率和查詢時間都遠遠超過一般的算法,缺點是有一定的誤判(即判斷一個元素存在,可能被誤判,而判斷這個元素不存在,則一定不存在)和刪除困難
數據結構採用了bitmap 位圖 解決緩存擊穿的問題,有一個攔截機制,能迅速判斷請求是否有效,他的內部維護了一系列合法有效的key,若是請求的元素在這個集合當中,說明請求有效。
false-positive (誤檢率)
布隆過濾器有一定的誤檢率,即判斷一個元素存在,可能被誤判(例如布隆過濾器中只存在A和E,但是對B進行過濾時,剛好被定位到了A的上半部分和E的下半部分,被誤判爲存在)
應用:
1、ip地址的布隆過濾器(比如統計一個大型網站的每天的UV數) 網頁爬蟲的url去重
2、redis中的防止緩存穿透(使用bloomFilter來減輕系統負擔)
3、比特幣(spv客戶端訪問full比特幣客戶端時,使用布隆過濾器進行攔截,減輕系統負擔)
4、分佈式系統MapReduce中,使用布隆過濾器判斷某個子任務是否存在某臺機器上
例子1:我們用布隆過濾器來記錄已經爬取過的網頁鏈接,假設需要判重的網頁有10億,那我們可以用一個10倍大小的位圖來存儲,也就是100億個二進制位,換算成字節,
那就是大約1.2GB。之前我們用散列表判重,需要至少100GB的空間。相比來講,布隆過濾器在存儲空間的消耗上,降低了非常多。
例子2:假設我們有1億個整數,數據範圍是從1到10億,如何快速並且省內存地給這1億個數據從小到大排序?
傳統的做法:1億個整數,存儲需要400M空間,排序時間複雜度最優 N×log(N)
使用位圖算法:數字範圍是1到10億,用位圖存儲125M就夠了,然後將1億個數字依次添加到位圖中,然後再將位圖按下標從小到大輸出值爲1的下標,排序
就完成了,時間複雜度爲 N

15.2、概率統計:如何利用樸素貝葉斯算法過濾垃圾短信?

1.基於黑名單的過濾器
①布隆過濾器:如果我們要存儲500萬個手機號碼,我們把位圖大小設置爲10倍數據大小,也就是5000萬,那也只需要使用5000萬個二進制位(5000萬bits),換算成字節,
也就是不到7MB的存儲空間。比起散列表的解決方案,內存的消耗減少了很多。
②我們可以把黑名單存儲在服務器端上,把過濾和攔截的核心工作,交給服務器端來做。手機端只負責將要檢查的號碼發送給服務器端,服務器端通過查黑名單,
判斷這個號碼是否應該被攔截,並將結果返回給手機端。網絡傳輸的速度較慢(硬性要求:必須聯網處理)
2.基於規則的過濾器
前提:有大量的樣本數據(比如1000萬條短信),並且每條短信都做好了標記,它是垃圾短信還是非垃圾短信
3.基於概率統計的過濾器
解決了基於規則的過濾器容易被繞過的缺陷。基於概率統計的基礎理論是樸素貝葉斯算法(將一個未知概率的求解,分解成其他三個已知概率的求解)
總結:可以結合上述3點共同判斷一條短信是否爲垃圾短信
評論中的觀點:機器學習尤其是NLP方向的很多算法可用於anti-spam(反垃圾郵件),判別式模型(logistic regression)效果通常好於生成式模式(naive-bayes),對於電話號碼數字,
正則或定時拉黑名單比ML模型簡單可靠。

15.3、推薦系統

原則:找到跟你口味偏好相似的用戶,把他們愛聽的歌曲推薦給你;找出跟你喜愛的歌曲特徵相似的歌曲,把這些歌曲推薦給你
1.基於相似用戶做推薦(基於用戶建模的協同過濾算法推薦)
計算多維向量(用戶對各首歌曲的喜愛程度作爲向量)之間的距離,使用歐幾里得計算公式
2.基於相似歌曲做推薦
針對每首歌曲,將每個用戶的打分作爲向量
弱點:
1、稀疏性問題:當用戶評價項目數少於中項目數時,就很容易造成評價矩陣相當稀疏,導致算法難以找到一個用戶的偏好相似鄰居。
2、冷啓動問題:基於用戶協同過濾是建立在有大量用戶對某個產品的評價上的,由於在新產品開始階段沒有人購買,也沒有對其進行評價,那麼在開始階段也將無法對其進行推薦
3、算法擴展性問題。隨着物品數尤其是用戶數的劇烈增加,最近鄰居算法的計算量也相應增加,所以不太適合數據量大的情況使用,所以推薦系統性能也會大大受影響
4、特殊用戶問題。


16、斷點續傳思路和算法

在http頭文件裏面保存了content和content-type標籤,用於記錄傳輸文件的字節段。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章