数据结构与算法总结——LeetCode刷题随笔

数据结构与算法总结——LeetCode刷题随笔

  • 数据结构与算法总结——LeetCode刷题随笔
    • 1. 闭着眼睛都要能写出来的代码
      • 1.1 归并排序、快速排序、堆排序
      • 1.2 二分查找
      • 1.3 最小生成树和Dijkastra
      • 1.4 KMP、Manacher
      • 1.5 并查集
    • 2. 刷题必备的数据结构和C++基础知识
      • 2.1 链表
      • 2.2 栈
      • 2.3 队列
      • 2.4 堆
      • 2.5 二叉树
      • 2.6 哈希表
      • 2.7 有序表
    • 3. 一些值得总结的方法
      • 3.1 动态规划中的各种尝试模型
      • 3.2 背包系列、Combination Sum系列、股票系列、打家劫舍系列、路径和系列
      • 3.3 一些经典的业务问题

数据结构与算法总结——LeetCode刷题随笔

按照年初制定的学习计划,今年在在跟进自己感兴趣的方向外,我希望能够花一部分时间在巩固基础知识上,包括C++/Python,数据结构与算法、操作系统等。

其中,数据结构与算法一直是我个人技术栈的一个短板,优先从此开始,我给自己定的学习思路主要是以LeetCode刷题为主线进行查漏补缺。

另外,我在刷LeetCode的同时还刷了一遍B站左神的教程一周刷爆LeetCode,是一套很不错的刷题教程,推荐大家学习。

1. 闭着眼睛都要能写出来的代码

1.1 归并排序、快速排序、堆排序

  1. 归并排序先让左侧部分有序,再让右侧部分有序,通过 merge \text{merge} merge操作将左右有序数组再合并到一起, merge \text{merge} merge操作通过两个指针实现,整个过程使用递归加辅助数组实现,时间复杂度为 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)),空间复杂度为 O ( N ) O(N) O(N)。归并排序的额外空间复杂度可以降为 O ( 1 ) O(1) O(1),但是非常难,原地归并排序会让时间复杂度变成 O ( N 2 ) O(N^2) O(N2)

    void mergeSort(vector<int>& nums, int start, int end) {
        if (start >= end) {
            return;
        }
    
        int mid = (start + end) / 2;
        mergeSort(nums, start, mid);
        mergeSort(nums, mid + 1, end);
        
        merge(nums, start, mid, end);
    }
    
    void merge(vector<int>& nums, int start, int mid, int end) {
        vector<int> tmps;
        tmps.resize(nums.size(), 0);
    
        int i = start;
        int j = mid+1;
        int k = start;
        while(i <= mid && j <= end) {
            if (nums[i] < nums[j]) {
                tmps[k] = nums[i];
                i++;
            } else {
                tmps[k] = nums[j];
                j++;
            }
            k++;
        }
    
        if(i > mid) {
            while(j <= end) {
                tmps[k] = nums[j];
                j++;
                k++;
            }
        }
    
        if(j > end) {
            while(i <= mid) {
                tmps[k] = nums[i];
                i++;
                k++;
            }
        }
    
        for(int m = start; m <= end; m++) {
            nums[m] = tmps[m];
        }
    }
    
  2. 快速排序从数列中挑出一个元素,称为 pivot \text{pivot} pivot,遍历一遍数据将所有元素比 pivot \text{pivot} pivot小的摆放在基准前面,所有元素比 pivot \text{pivot} pivot大的摆在基准的后面,这个称为 partition \text{partition} partition操作; partition \text{partition} partition操作具体如下:定义一个开头处的小于等于区域和一个结尾处的大于等于区域, i i i 0 0 0 N N N 遍历,(1)如果遍历到的值小于 num \text{num} num,将 i i i 位置和小于等于区域的下一个数交换,并将 i i i 移动到下一个位置(相等);(2)如果遍历到的值大于 num \text{num} num,则直接将 i i i 移动到下一个位置;(3)如果遍历到的值大于 num \text{num} num,将 i i i 位置和大于等于区域的前一个数交换, i i i 位置不动(未考察)。
    递归地把小于基准值元素的子数列和大于基准值元素的子数列排序;快速排序要使用随机位置数进行 partition \text{partition} partition操作快排的时间复杂度才为 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)),空间复杂度 O ( l o g ( N ) ) O(log(N)) O(log(N))。快速排序是不稳定的,可以做到稳定,但是非常难。

    void quickSort(vector<int>& nums, int start, int end) {
            if (start >= end) {
                return;
            }
    
            vector<int> p = partition(nums, start, end);
            quickSort(nums, start, p[0]-1);
            quickSort(nums, p[1]+1, end);
        }
    
    vector<int> partition(vector<int>& nums, int start, int end) {
        int t = rand() % (end - start + 1) + start;
        swap(nums, t, end);
    
        int pivot = nums[end];
        int l = start;
        int r = end - 1;
        int i = start;
        while(i <= r) {
            if (nums[i] < pivot) {
                swap(nums, l, i);
                l++;
                i++;
            } else if (nums[i] > pivot) {
                swap(nums, r, i);
                r--;
            } else {
                i++;
            }
        }
        swap(nums, end, r+1);
        return {l, r, i};
    }
    
  3. 堆排序的基础逻辑是完全二叉树,节点 i i i的左节点为 2 × i + 1 2\times i+1 2×i+1,右节点为 2 × i + 2 2\times i+2 2×i+2,父节点为 ( i − 1 ) / 2 (i-1)/2 (i1)/2。实现一个大根堆最基础需要实现两个方法: heapInsert \text{heapInsert} heapInsert heapify \text{heapify} heapify,其中 heapInsert \text{heapInsert} heapInsert方法指的是在插入一个新的节点时,需要将新节点不断和自己的父节点进行大小比较,如果小则交换的过程,时间复杂度维 O ( l o g ( N ) ) O(log(N)) O(log(N)) heapify \text{heapify} heapify方法指的是堆弹出最大值(堆顶)后,将最后一个数字放到堆顶,再将堆顶数字不断和左右孩子比较,如果小则交换的过程,时间复杂度 O ( l o g ( N ) ) O(log(N)) O(log(N))。所谓堆排序指的是先把数据从头到尾进行一遍 heapInsert \text{heapInsert} heapInsert,再不断弹出堆顶进行 heapify \text{heapify} heapify。时间复杂度为 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)),空间复杂度 O ( 1 ) O(1) O(1)

    void heapSort(vector<int>& nums) {
            for (int i = 0; i < nums.size(); i++) {
                heapInsert(nums, i);
            }
    
            int heapSize = nums.size() - 1;
            while(heapSize > 0) {
                swap(nums, heapSize, 0);            
                heapSize--;
                heapify(nums, 0, heapSize);
            }        
        }
    
        void heapInsert(vector<int>& nums, int index) {
            while(index > 0 && nums[(index - 1) / 2] < nums[index]) {
                swap(nums, index, (index - 1) / 2);
                index = (index - 1) / 2;
            }
        }
    
        void heapify(vector<int>& nums, int index, int heapSize) {
            int left = index * 2 + 1;
            while(left <= heapSize) {
                int tmp = (left + 1 <= heapSize && nums[left] < nums[left+1]) ? left + 1 : left;
                tmp = nums[index] < nums[tmp] ? tmp : index;
    
                if (tmp == index) {
                    return;
                } else {
                    swap(nums, tmp, index);
                    index = tmp;
                    left = index * 2 + 1;
                }
            }
        }
    

    有很多题都是以此为基础展开的,例如小和问题、逆序对问题、荷兰国旗问题等

1.2 二分查找

  1. 二分查找的基本方法是取 start \text{start} start end \text{end} end两个位置, N / 2 N/2 N/2的位置大于目标,则 start = N / 2 + 1 \text{start}=N/2+1 start=N/2+1,反之则 end = N / 2 − 1 \text{end}= N/2 - 1 end=N/21,依次类推,直到 start \text{start} start end \text{end} end接触,时间复杂度为 O ( l o g ( N ) ) O(log(N)) O(log(N)),使用递归实现。

    二分查找同样有许多变题,例如开根号问题、翻转有序数组超等

1.3 最小生成树和Dijkastra

  1. prim算法:以节点的角度出发,从某一个节点出发,将与其关联的边进行解锁,从解锁的边里选择权值最小的边拓展节点集合,将与拓展节点关联的边进行解锁,再次从解锁的边里选择权值最小的边依次往下。所有解锁的边放入小根堆进行排序,就可以做到每次取到权值最小的边,节点使用集合进行管理可以避免重复取到相同的节点。

    const int INF = 1000000000;
    
    int prim(const vector<vector<int>>& graph, int start, vector<bool>& vis, vector<int>& dist) {
        int n = graph.size();
        
        fill(vis.begin(), vis.end(), false);
        fill(dist.begin(), dist.end(), INF);
        dist[start] = 0;
        int sum = 0;
        
        for (int i = 0; i < n; i++) {
            // choose the next node
            int next = -1;
            int minDist = INF;
            for (int j = 0; j < n; j++) { //  this step can be replaced by heap
                if (!vis[j] && dist[j] < minDist) {
                    next = j;
                    minDist = dist[j];
                }
            }
            
            // no more next node
            if (next == -1) {
                break; 
            }
            
            // visit the next node and update the dist list
            vis[next] = true;
            sum += minDist; // get the sum of the dist
            for (int j = 0; j < n; j++) {
                if (!vis[j] && graph[next][j] != INF && graph[next][j] < dist[j]) {
                    dist[j] = graph[next][j];
                }
            }
        }
        return sum;
    }
    
  2. kruskal算法:以边的角度出发,对边的权重进行排序,然后从小到大依次选择不会形成环的边。是否形成环这个判断可以通过并查集实现,将节点作为并查集的对象,如果边对应的两个节点属于同一个集合则会形成环

    struct edge {
        int u, v;
        int cost;
        edge(int x, int y, int c): u(x), v(y), cost(c) {};
    }
    
    bool cmp(edge a, edge b) {
        return a.cost < b.cost;
    }
    
    int findFather(vector<int>& father, int x) {
        int a = x;
        while (x != father[x]) {
            x = father[x];  // find father
        }
        while (a != father[a]) {
            int tmp = a;
            a = father[a];
            father[tmp] = x; // set father
        }
        return x;
    }
    
    void kruskal(int n, vector<edge>& edges) { // n is size of nodes
        int m = edges.size();
        
        vector<int> father(n);
        for (int i = 0; i < n; i++) {
            father[i] = i;
        }
        int sum = 0;
        int numEdge = 0;
    
        sort(edges.begin(), edges.end(), cmp);
        
        for (int i = 0; i < m; i++) {
            int U = findFather(father, edges[i].u);
            int V = findFather(father, edges[i].v);
            if (U != V) { // avoid circle
                father[U] = V; // union
                sum += edges.cost;
                numEdge++;
                if (numEdge == m - 1) {
                    break;
                }
            }
        }
        
        if (numEdge != m - 1) {
            return -1;
        } else {
            return sum;
        }
    }
    
  3. Dijkstra算法:通常应用在权值没有负数的边,使用一张哈希表记录从开始节点到各个节点的距离,每次从这张哈希表中选取距离最小的节点进行拓展,将拓展节点关联的边对表进行更新,拓展后的节点后序不再更新。这个距离表使用哈希表或者堆实现,已经遍历过的节点使用集合实现

    const int INF = 1000000000;
    
    void dijkstra(const vector<vector<int>>& graph, int start, vector<bool>& vis, vector<int>& dist) {
        int n = graph.size();
        
        fill(vis.begin(), vis.end(), false);
        fill(dist.begin(), dist.end(), INF);
        dist[start] = 0;
        
        for (int i = 0; i < n; i++) {
            // choose the next node
            int next = -1;
            int minDist = INF;
            for (int j = 0; j < n; j++) { //  this step can be replaced by heap
                if (!vis[j] && dist[j] < minDist) {
                    next = j;
                    minDist = dist[j];
                }
            }
            
            // no more next node
            if (next == -1) {
                break; 
            }
            
            // visit the next node and update the dist list
            vis[next] = true;
            for (int j = 0; j < n; j++) {
                if (!vis[j] && dist[next] + graph[next][j] < dist[j]) {
                    dist[j] = dist[next] + graph[next][j];
                }
            }
            
        }
    }
    

    dijkstra算法和prim算法是非常类似的,dijkstra考虑的是累计的距离,而prim算法考虑的是局部最近的距离。

1.4 KMP、Manacher

  1. KMP算法:首先需要通过 getNextArray \text{getNextArray} getNextArray方法获得 str2 \text{str2} str2 next \text{next} next数组, next [ i ] \text{next}[i] next[i]的含义是 str2 \text{str2} str2 0 0 0 i − 1 i-1 i1位置中的最长相等的前缀和后缀,其中前缀一定不包括 i − 1 i-1 i1位置字符,后缀一定不包括 0 0 0位置字符。然后使用 i 1 i1 i1 i 2 i2 i2分别遍历两个数组,如果 str2 [ i 1 ] = = str2 [ i 2 ] \text{str2}[i1] == \text{str2}[i2] str2[i1]==str2[i2],则分别挪到下一位;否则判断 next \text{next} next数组中 i 2 i2 i2位置是否为 − 1 -1 1,如果是说明 i 2 i2 i2已经到达 next \text{next} next数组最前端,此时 i 1 i1 i1挪到下一位,如果 next \text{next} next数组中 i 2 i2 i2的位置不为 − 1 -1 1,则将 i 2 i2 i2置为 next [ i 2 ] \text{next}[i2] next[i2]。KMP算法将时间复杂度 getNextArray \text{getNextArray} getNextArray方法为 O ( M ) O(M) O(M),遍历数组的时间复杂度为 O ( N ) O(N) O(N),总的时间复杂度为 O ( N ) O(N) O(N)

    vector<int> getNextArray(const string& str) {
        int n = str.size();
        
        if(n == 1) {
            return {-1}
        }
        
        vector<int> next(n, 0);
        next[0] = -1;
        next[1] = 0;
        int i = 2;
        
        int k = 0; 
        
        // 下面这个过程可以想象成,前缀是一个不断伸长变短的过程,k是匹配好的前缀的下一个字符位置
        // 后缀的结尾永远在i-1位置,如果匹配上了则前缀变长,k往后移,反之前缀就根据next将k进行回溯
        while(i < n) {
            if (str[i - 1] == str[k]) { // 
                k++;
                next[i] = k; // k后移之后同时表示了匹配好的前缀长度
                i++;
            } else {
                if (k == 0) { // k到0位置后说明已经前缀中已经没有匹配好的前缀的
                    next[i] = 0;
                    i++;
                } else {
                    k = next[k]; 
                    // 这个比较难理解,举个例子 [...A...][...B...]k[...C...][...D...]i-1
                    // [...A...][...B...]和[...C...][...D...]为匹配好的前缀和后缀
                    // [...A...][...B...]为前缀中匹配好的前缀和后缀,因此[...A...]和[...D...]也是可以匹配上的
                    // 如果k和i-1处字符没有匹配上,此时就应该尝试[...A...]k和[...D...]i-1
                }
            }
        }
        return next;
    }
    
    int kmp(const string& str1, const string& str2) {
        vector<int> next = getNextArray(str2);
        int i1 = 0;
        int i2 = 0;
        while(i1 < str1.size() && i2 < str.size()) {
            if (str1[i1] == str2[i2]) { // 匹配成功了一起往后移动
                i1++;
                i2++;
            } else {
                if (next[i2] == -1) { // 说明i2前面已经灭有前缀了
                    i1++;
                } else {
                    i2 = next[i2]; // 这个next过程和getNextArray中的next过程原理是一样的
                }
            }
        }
        return i2 == str2.size() ? i1 - i2 : -1;
    }
    
  2. Manacher算法:使用 # \# #将原始字符扩展,例如 123 123 123扩展为 # 1 # 2 # 3 # \#1\#2\#3\# #1#2#3#,扩展的目的主要是为,Manacher算法与直接暴力计算的区别是会在遍历过程中记录历史中所有回文串所能覆盖的最远位置 R R R以及该位置对应的回文子串的回文中心 C C C,根据 R R R C C C就可以得到该回文子串的左边界 L L L,在接下来的遍历过程中,判断新的位置 i i i是否在 L L L R R R覆盖范围内,
    (1)如果不在,则暴力计算以 i i i为中心的回文半径,更新 R R R C C C
    (2)如果在,则 i i i的回文半径直接 C C C为中心对称位置 j j j位置的回文半径,如果 j j j的回文区域在 L L L R R R的范围内或者超过了 L L L R R R范围,则 i i i的回文半径和 j j j的回文半径相同,如果 j j j的回文左边界 L L L正好重叠则需要进一步判断 i i i的回文半径是否可以进一步扩大,同时更新 R R R C C C
    Manacher算法在具体实现时可以直接求一个不需要检查范围,然后统计往外扩。时间复杂度从 O ( N 2 ) O(N^2) O(N2)降为了 O ( N ) O(N) O(N)

    int manancher(string s) {      
        string str = "#";
        for (const auto c : s) {
            str += c;
            str += '#';
        }
        int n = str.size();
        std::cout<<str<<std::endl;
    
    
        int R = 0; // 具备回文属性的右边界
        int C = 0; // 右边界对应的中心点
        int maxLen = 0;
        string ret;
        vector<int> len(n, 0);
        for (int i = 0; i < n; i++) {
            if (i < R) {
                len[i] = min(len[2 * C - i], R - i);
            } else {
                len[i] = 1;
            }
    
            while(i + len[i] < n && i - len[i] >= 0) {
                if (str[i + len[i]] == str[i - len[i]]) {
                    len[i]++;
                } else {
                    break;
                } 
            }
    
            if (i + len[i] > R) {
                R = i + len[i];
                C = i;
            }
    
            maxLen = max(len[i], maxLen);
        }
    
        return maxLen - 1;
    }
    

1.5 并查集

  1. 并查集结构有 isSameSe \text{isSameSe} isSameSe union \text{union} union两个共有方法,还有一个私有方法findHead,使用下标数组记录不同数字是否属于同一个集合。 findHead \text{findHead} findHead的单词执行时间复杂度为 O ( 1 ) O(1) O(1),如下:
    template<class T>
    class Element {
    public:
    	Element(T val): val(val);
    	
    private:
    	T val;
    }
    
    template<class T>
    class UnionFindSet {
    public:
    	UinionFindSet(const std::vector<T>& list) {
    		for (const auto& val: list) {
    			Element<T> element(val);
    			
    			elementMap[val] = element;
    			fathreMap[element] = element;
    			sizeMap[element] = 1;			
    		}
    	}
    	
    	// 给定一个element,往上一只找,把代表元素返回
    	Element<T>* findHead(Element<T> element) {
    		std::stack<Element<T>> path;
    		while (element != fatherMap[element]) {
    			path.push.push(element);
    			element = fatherMap[element];
    		}
    		while (!path.empty()) {
    			fatherMap[path.pop()] = element; // 这里很巧妙的使用了一个栈将集合中所有元素的代表元素设置为同一个
    		}
    		return element;
    	}
    	
    	bool isSameSet(T a, T b) {
    		if (elementMap.count(a) != 0 && elementMap.count(b) != 0) {
    			return findHead(elementMap[a]) == findHead(element[b]); // 判断两个集合的代表元素是否为同一个
    		}
    		return false;
    	}
    	
    	void union(T a, T b) {
    		if (elementMap.count(a) != 0 && elementMap.count(b) != 0) {
    			Element<T> aFather = findHead(elementMap[a]); // 找到两个集合的代表元素
    			Element<T> bFather = findHead(elementMap[b]);
    			if (aFather != bFather) {
    				Element<T> big = sizeMap[aFather] >= sizeMap[bFather] ? aFather : bFather;
    				Element<T> small = big == aFather ? bFather : aFather;
    				fatherMap[small] = big; // 将代表元素置换即将两个集合合并
    				sizeMap[big] = sizeMap[small] + sizeMap[big];
    				sizeMap[small] = 0;
    			}
    		}
    	}
    	
    private:
    	std::unordered_map<T, Element<T>> elementMap;
    	std::unordered_map<Element<T>, Element<T>> fathreMap;
    	std::unordered_map<Element<T>, int> sizeMap;
    }
    

2. 刷题必备的数据结构和C++基础知识

2.1 链表

  1. 在C++中,有 std::list \text{std::list} std::list容器,该容器实现了 push_front \text{push\_front} push_front pop_front \text{pop\_front} pop_front insert \text{insert} insert merge \text{merge} merge 等方法
  2. 链表题难度通常不大,有两个重要技巧:(1)使用额外的数据结构记录表(哈希表);(2)快慢指针。

2.2 栈

  1. 在C++中,有 std::stack \text{std::stack} std::stack容器,该容器实现了 top \text{top} top push \text{push} push pop \text{pop} pop等接口, std::stack \text{std::stack} std::stack容器是底层容器的一种封装,底层容器可以是 std::list \text{std::list} std::list std::vector \text{std::vector} std::vector std::deque \text{std::deque} std::deque,默认的底层容器是 std::deque \text{std::deque} std::deque
  2. 单调栈是一种特殊的栈结构,其要求栈中的元素是具备单调性的,那么如何保持单调性呢?通常的做法是将不满足单调性的元素先弹出直到待压入的元素不会破坏单调性。以从大到小的顺序为例,在这个过程中,弹出的元素可以直接获得历史和未来大于它且离它最近的值。比较经典的题目有柱状图中的最大矩形等

2.3 队列

  1. 在C++中,有 std::queue \text{std::queue} std::queue的容器,该容器实现了 push \text{push} push pop \text{pop} pop front \text{front} front back \text{back} back等方法,同样 std::queue \text{std::queue} std::queue也是对底层容器的一种封装;
  2. 在C++中,底层容器 std::deque \text{std::deque} std::deque是一种双端队列结构,支持 push_back \text{push\_back} push_back push_front \text{push\_front} push_front pop_back \text{pop\_back} pop_back pop_front \text{pop\_front} pop_front insert \text{insert} insert erase \text{erase} erase等方法,但和 std::list \text{std::list} std::list不同的是 std::queue \text{std::queue} std::queue是连续内存,使用的是一种指针数据结合连续内存的二级数组结构实现的。当 std::deque \text{std::deque} std::deque容器需要在头部或尾部增加存储空间时,它会申请一段新的连续空间,同时在指针数组的开头或结尾添加指向该空间的指针,由此该空间就串接到了 std::deque \text{std::deque} std::deque容器的头部或尾部。如果map数组满了就再申请一块更大的连续空间供指针数组使用,将原有数据(很多指针)拷贝到新的指针数组中,然后释放旧的空间。
  3. 类似于单调栈,当队列具备单调性后也可以解决有意思的问题,例如滑动窗口中的最大值问题

2.4 堆

  1. 在C++中,有容器 std::priority_queue \text{std::priority\_queue} std::priority_queue,其提供了 push \text{push} push pop \text{pop} pop top \text{top} top等接口,定义如下

    //升序队列,小顶堆
    priority_queue <int,vector<int>,greater<int> > q;
    //降序队列,大顶堆
    priority_queue <int,vector<int>,less<int> >q;
    
    //greater和less是std实现的两个仿函数(就是使一个类的使用看上去像一个函数。其实现就是类中实现一个operator(),这个类就有了类似函数的行为,就是一个仿函数类了)
    

    std::priority_queue \text{std::priority\_queue} std::priority_queue容器的底层实现是在 std::vector \text{std::vector} std::vector上使用了堆算法将vector中元素构造成堆的结构,我们也可以自己使用方法使得 vector \text{vector} vector变成堆结构,

    vector<int> a;
    make_heap(a.begin(),a.end(), less<int>() );//建立大根堆
    make_heap(a.begin(),a.end(), greater<int>() );//建立小根堆
    push_heap(a.begin(),a.end();//将最后一个元素插入堆中(堆自动调整)
    pop_heap(a.begin(),a.end();//将第一个元素从堆中删去(堆自动调整),并放到最后
    

    堆有些经典的问题有字符串中出现频次前 K K K的字符,哈夫曼编码问题等

    花费 costs [ i ] \text{costs}[i] costs[i],利润 profits [ i ] \text{profits}[i] profits[i] k k k个可以完成的项目个数, m m m为初始资金,问串行做项目如何获得最大钱数

    基于堆实现,按照花费组织小根堆,按照利润组织大根堆,先将所有项目放到花费小根堆中去,然后根据手头持有的资金逐个把所有能做的项目放到利润大根堆中去,将大根堆中的堆顶不断累计就可以得到最大钱数。

2.5 二叉树

  1. 在C++中应该都没有直接的二叉树结构,C++中通常通过指针实现上下游节点的关联;
  2. 二叉树相关的题目中比较重要的两类题型是:1. 二叉树的遍历;2. 树形DP;
    其中树形DP有一套固定解法,使用后序遍历,将子树的信息通过自定义的数据结构返回给父节点进行处理。
  3. 注意非递归的方法的遍历实现方式
    (1)对于前序遍历,将父节点压入栈中,弹出后打印,并将子节点按照右左的顺序压入栈中,依次往下弹出打印;
    (2)对于后序遍历,同样先将父节点压入栈中,弹出后将节点压入辅助栈,并将子节点按照右左的顺序压入栈中,依次往下弹出并压入辅助栈中,最后将辅助栈中的节点依次弹出打印。
    (3)对于中序遍历,将父节点以及左边界上所有节点压入栈中,弹出后打印,如果弹出的节点的右子节点存在左子节点,则将右子节点的左边界上所有节点压入栈用,依次往下。
    其中,非递归的后序和中序遍历是比较难一些的
  4. Morris遍历是使用底层叶节点的空指针来使得二叉树遍历的空间复杂度降为 O ( 1 ) O(1) O(1),假设来到当前节点 cur \text{cur} cur,开始时 cur \text{cur} cur来到头结点位置
    (1)如果 cur \text{cur} cur没有左子节点, cur \text{cur} cur向右子节点移动,通过这一步可以从叶节点回到头节点
    (2)如果 cur \text{cur} cur有左子节点,找到左子树上最右的节点 mostRight \text{mostRight} mostRight:如果 mostRight \text{mostRight} mostRight的右指针指向空,让其指向 cur \text{cur} cur通过这一步将叶节点和头节点关联,然后 cur \text{cur} cur向左子节点移动,如果 mostRight \text{mostRight} mostRight的右指针指向 cur \text{cur} cur,让其指向null,然后 cur \text{cur} cur向右子节点移动
    (3) cur \text{cur} cur为空时停止遍历
    如果一个节点有左子树,则一定会经过当前节点两次, mostRight \text{mostRight} mostRight节点要么指向,要么指向 cur \text{cur} cur,指向 cur \text{cur} cur则为第二次到当前节点。

2.6 哈希表

  1. 在C++中,有 std::unordered_map \text{std::unordered\_map} std::unordered_map容器和 std::unordered_set \text{std::unordered\_set} std::unordered_set,其实现了 count \text{count} count at \text{at} at find \text{find} find等方法。“链地址法”(又称“开链法”)解决数据存储位置发生冲突的哈希表。
  2. 哈希函数的特性:(1)哈希函数的自定义域的范围是无限的,值域范围是优先的;(2)没有随机性;(3)存在哈希碰撞的可能但是概率很小;(4)输出分布是均匀离散的

2.7 有序表

  1. 在C++中,有 std::map \text{std::map} std::map std::set \text{std::set} std::set的底层实现为有序表, std::map \text{std::map} std::map实现了 std::unordered_map \text{std::unordered\_map} std::unordered_map中相同的接口,map内部所有的数据都是有序的, std::map \text{std::map} std::map的查询、插入、删除操作的时间复杂度都是 O ( l o g n ) O(logn) O(logn)。此外, std::map \text{std::map} std::map key \text{key} key需要定义 operator < \text{operator <} operator <,对于一般的数据类型已被系统实现,若是用户自定义的数据类型,则要重新定义该操作符。 std::set \text{std::set} std::set集合访问操作时间复杂度是log(N)的,所涉及的操作并、交、差等,即STL提供的如交集 set_intersection() \text{set\_intersection()} set_intersection()、并集 set_union() \text{set\_union()} set_union()、差集 set_difference() \text{set\_difference()} set_difference()和对称差集 set_symmetric_difference() \text{set\_symmetric\_difference()} set_symmetric_difference(),都需要进行大量的比较工作,此时 std::unordered_set \text{std::unordered\_set} std::unordered_set std::set \text{std::set} std::set更有优势。

  2. 有序表支持哈希表相关操作且所有的操作时间复杂度为 O ( l o g ( N ) ) O(log(N)) O(log(N)),相关的有序表结构右红黑树、AVL树、Size Balance Tree、Skiplist(跳表)

    给定两个数组 money \text{money} money hard \text{hard} hard表示工作的报酬和难度,给定另一个表示每个人能力的数组 arr \text{arr} arr,问每个人选择工作后获得的报酬

    先按照工作难度由大到小排,同样难度的工作,报酬由大到小排序,然后只保留难度递增报酬递增的工作即构建了有序表。然后每个人查询能满足能力的工作报酬即可。

3. 一些值得总结的方法

3.1 动态规划中的各种尝试模型

  1. 从左往右的尝试模型,关注 i i i 位置结尾,或者 i i i 位置开头的情况,或者看 i i i 联合 i + 1 i+1 i+1 i + 2 i+2 i+2的情况,填表往往是上到下,或者下到上,左到右,右到左。

    最长有效括号

    一维动态规划, d p [ i ] dp[i] dp[i]表示以 i i i结尾的最长有效括号,公式如下:
    s [ i ] = ′ ) ′ s[i]=')' s[i]=) s [ i − 1 ] = ′ ( ′ s[i-1]='(' s[i1]=( d p [ i ] = d p [ i − 1 ] + 2 dp[i] = dp[i-1] + 2 dp[i]=dp[i1]+2 s [ i ] = ′ ) ′ , s [ i − 1 ] = ′ ) ′ s[i]=')',s[i-1]=')' s[i]=)s[i1]=) s [ i − d p [ i − 1 ] − 2 ] = = ′ ( ′ s[i - dp[i-1] -2]=='(' s[idp[i1]2]==( d p [ i ] = d p [ i − 1 ] + d p [ i − d p [ i − 1 ] − 2 ] + 2 dp[i] = dp[i-1] + dp[i -dp[i-1]-2]+2 dp[i]=dp[i1]+dp[idp[i1]2]+2

    最长递增子序列

    一维动态规划, d p [ i ] dp[i] dp[i]个位置表示以 i i i为结尾的最长递增子序列的长度,每次都去找 0 0 0 i − 1 i-1 i1位置上满足条件的最大值,时间复杂度为 O ( N 2 ) O(N^2) O(N2)。如果想优化为 O ( N l o g ( N ) ) O(Nlog(N)) O(Nlog(N)),可以添加一个新的 ends \text{ends} ends数组记录,第 i i i个位置表示长度为 i i i的递增子序列的最小值。 ends \text{ends} ends数组是有序的,找 0 0 0 i − 1 i-1 i1位置上满足条件的最大值的步骤可以修改为在 ends \text{ends} ends上二分查找小于当前值的最大值。

    没有重复字符的最长子字串

    一维动态规划, d p [ i ] dp[i] dp[i]表示以 i i i结尾的最长子串的长度,那么该子串的长度取决于两个边界:一是 i i i位置字符上一个位置,可以使用哈希表记录,二是 i − 1 i-1 i1位置结尾的最长子串的长度,二者选择较长者。

    矩阵中最大正方形

    二维动态规划,dp[i][j]表示以 ( i , j ) (i, j) (i,j)结尾的最大正方形: d p [ i ] [ j ] = m i n ( d p [ i − 1 ] [ j − 1 ] , m i n ( d p [ i − 1 ] [ j ] , d p [ i ] [ j − 1 ] ) ) dp[i][j] = min(dp[i-1][j -1], min(dp[i-1][j], dp[i][j-1])) dp[i][j]=min(dp[i1][j1],min(dp[i1][j],dp[i][j1]))

    完全平方数:求和为 n n n的最少完全平方数数量

    一维动态规划, d p [ i ] dp[i] dp[i]表示和为 i i i的最少完全平方数数量,动态规划模型为: d p [ i ] = 1 + min ⁡ j = 1 ⌊ ⌋ d p [ i − j 2 ] dp[i]=1+\min _{j=1}^{\lfloor\sqrt{ }\rfloor} dp\left[i-j^2\right] dp[i]=1+j=1min dp[ij2]

  2. L – R L–R LR范围上的尝试模型,关注 L L L R R R的情况,填表格式非常固定,主对角,副对角,倒回来填;

    拿纸牌游戏,绝顶聪明的两个玩家从左或右取纸牌,最终分数高的胜利,问给定一个数组谁会赢

    先手函数 F \text{F} F m a x ( arr [ L ] + S ( arr , L + 1 , R ) , arr [ R ] + S ( arr , L , R − 1 ) ) max(\text{arr}[L] + \text{S}(\text{arr}, L+1, R), \text{arr}[R] + \text{S}(\text{arr}, L, R-1)) max(arr[L]+S(arr,L+1,R),arr[R]+S(arr,L,R1))基本情况是 L = = R L==R L==R时返回 arr [ L ] \text{arr}[L] arr[L],后手函数 S \text{S} S m i n ( F ( arr , L + 1 , R ) , F ( arr , L , R − 1 ) ) min(\text{F}(\text{arr}, L+1, R), \text{F}(\text{arr}, L, R-1)) min(F(arr,L+1,R),F(arr,L,R1))基本情况是 L = = R L==R L==R时返回 0 0 0,因此此时先手已经拿过了。.如果需要修改为动态规划,需要使用两张二维表,两张二维表来回迭代更新。

    给定"假", “真”, “与”、“或”、"异或"五种字符组成的字符串,给定一个布尔值结果,问一共有多少种括号给定方式使得字符串输出给定的结果

    递归函数 func(L, R, desire) \text{func(L, R, desire)} func(L, R, desire) L \text{L} L为其左侧的字符串, R \text{R} R为右侧的字符串, desire \text{desire} desire为目标结果,返回值为有多少种可能性,遍历过程仅遍历字符串的运算符,可以修改为二维动态规划。

  3. 多样本位置对应的尝试模型, 2 2 2个样本,一个样本做行,一个样本做列,关注i和j对应位置的情况,先填边界,再填中间;

    编辑距离问题:给定字符串 str1 \text{str1} str1 str2 \text{str2} str2,给定插入、删除和替换一个字符的代价,求从 str1 \text{str1} str1 str2 \text{str2} str2的最小代价

    二维动态规划, d p [ i ] [ j ] dp[i][j] dp[i][j]表示前缀长 i i i的字符串编辑成前缀长 j j j的字符串的代价,那么有如下情况可以讨论:
    str1 [ i ] = str2 [ j ] \text{str1}[i] = \text{str2}[j] str1[i]=str2[j] d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] dp[i][j] = dp[i-1][j -1] dp[i][j]=dp[i1][j1] str1 [ i ] ! = str2 [ j ] \text{str1}[i] != \text{str2}[j] str1[i]!=str2[j]
    删除 str1 \text{str1} str1 i i i个字符,再变为 str2 \text{str2} str2 0 0 0 j j j的字符串 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] + cost(delete) dp[i][j] = dp[i-1][j] + \text{cost(delete)} dp[i][j]=dp[i1][j]+cost(delete) str1 \text{str1} str1 i i i个字符变为 str2 \text{str2} str2 0 0 0 j − 1 j-1 j1的字符串,再补充第 j j j个字符: d p [ i ] [ j ] = d p [ i ] [ j − 1 ] + cost(add) dp[i][j] = dp[i][j-1] + \text{cost(add)} dp[i][j]=dp[i][j1]+cost(add) str1 \text{str1} str1 i − 1 i-1 i1个字符变为 str2 \text{str2} str2 0 0 0 j − 1 j-1 j1的字符串,再替换第 j j j的字符串 d p [ i ] [ j ] = d p [ i − 1 ] [ j − 1 ] + cost(replace) dp[i][j] = dp[i-1][j-1] + \text{cost(replace)} dp[i][j]=dp[i1][j1]+cost(replace)

  4. 业务限制类的尝试模型,比如走棋盘,固定的几个方向可以走,先填边界,再填中间。

    跳马问题,从 ( 0 , 0 ) (0, 0) (0,0) k k k步跳到 ( a , b ) (a, b) (a,b)位置有多少种跳法

    递归的想法比较直接,如果要修改为动态规划需要使用三维表。

3.2 背包系列、Combination Sum系列、股票系列、打家劫舍系列、路径和系列

关于背包系列和Combination Sum系列之前有写过一个博客数据结构与算法总结——背包问题与组和问题,但是当时感觉更多的是死记硬背,没有彻底理解这个问题,这里来重新整理下:

  1. 背包问题

    01背包:有 N N N 件物品和一个容量为 V V V 的背包。放入第 i i i 件物品耗费的费用是 C i 1 C_i^1 Ci1,得到的价值是 W i W_i Wi。求解将哪些物品装入背包可使价值总和最大

    完全背包:有 N N N 种物品和一个容量为 V V V 的背包,每种物品都有无限件可用。放入第 i i i 种物品的费用是 C i C_i Ci,价值是 W i W_i Wi。求解:将哪些物品装入背包,可使这些物品的耗费的费用总和不超过背包容量,且价值总和最大。

    多重背包:有 N N N 种物品和一个容量为 V V V 的背包。第 i i i 种物品最多有 M i M_i Mi 件可用,每件耗费的空间是 C i C_i Ci,价值是 W i W_i Wi。求解将哪些物品装入背包可使这些物品的耗费的空间总和不超过背包容量,且价值总和最大。

    背包问题是一个从左往右的尝试模型,通常考虑的方式是假设 0 0 0 i − 1 i-1 i1的尝试方式已经确定,判断第 i i i个物品取或者不取后剩下的容量 v v v
    对于01背包: d p [ i , v ] = max ⁡ { d p [ i − 1 , v ] , d p [ i − 1 , v − C i ] + W i } dp[i, v]=\max \left\{dp[i-1, v], dp\left[i-1, v-C_i\right]+W_i\right\} dp[i,v]=max{dp[i1,v],dp[i1,vCi]+Wi}对于完全背包: d p [ i , v ] = max ⁡ { d p [ i − 1 , v − k C i ] + k W i ∣ 0 ≤ k C i ≤ v } dp[i, v]=\max \left\{dp\left[i-1, v-k C_i\right]+k W_i \mid 0 \leq k C_i \leq v\right\} dp[i,v]=max{dp[i1,vkCi]+kWi0kCiv}对于多重背包: d p [ i , v ] = max ⁡ { d p [ i − 1 , v − k ∗ C i ] + k ∗ W i ∣ 0 ≤ k ≤ M i } dp[i, v]=\max \left\{dp\left[i-1, v-k * C_i\right]+k * W_i \mid 0 \leq k \leq M_i\right\} dp[i,v]=max{dp[i1,vkCi]+kWi0kMi}硬币问题可以看做是背包问题的变种:

    给定一堆硬币面值,给定一个目标值,问最少使用多少硬币可以组成

    递归函数为 func(vector arr, int index, int rest) \text{func(vector arr, int index, int rest)} func(vector arr, int index, int rest),其中 arr \text{arr} arr为硬币组成, index \text{index} index为自由选择 index+1 \text{index+1} index+1及其往后的所有硬币, rest \text{rest} rest的离目标的差值,返回值是方法数。如果 rest < 0 \text{rest} < 0 rest<0或者 index > n \text{index} > n index>n则返回 − 1 -1 1,说明此方案无效,如果 rest = = 0 \text{rest} == 0 rest==0则返回 0 0 0,说明方案有效。基于此加入一个 rest × n \text{rest}\times\text{n} rest×n的二维表就可以改为二维动态规划。

    给定一堆硬币面值(每张有任意张),给定一个目标值,问最少使用多少硬币可以组成

    递归函数为 func(vector& arr, int rest) \text{func(vector\& arr, int rest)} func(vector& arr, int rest),任意张可以通过循环遍历实现,下面是一个记忆化搜索的实现:

    int coinChange(vector<int>& coins, int amount) {
        int n = coins.size();      
        dp.resize(amount, -1);  
        return core(coins, amount);
    }
    
    int core(vector<int>& coins, int rest, vector<int>& dp) {
        if (rest < 0) {
            return -1;
        }
        if (rest == 0) {
            return 0;
        }
    
        if (dp[rest - 1] != -1) {
            return dp[rest - 1];
        }
    
        int min = INT_MAX;
        for (int& coin : coins) {
            int tmp = core(coins, rest - coin, dp);
                
            if (tmp >=0 && tmp < min) {
                min = tmp + 1;
            }
        }
        dp[rest - 1] = (min == INT_MAX) ? -1 : min;
        return dp[rest - 1];
    }
    

    可以看到,这个递归过程主要还是和用第几个硬币取或者不取后剩下的容量有关,修改为动态规划后如下代码如下:

    int coinChange(vector<int>& coins, int amount) {
        int Max = amount + 1;
        vector<int> dp(amount + 1, Max);
        dp[0] = 0;
        for (int i = 1; i <= amount; ++i) {
            for (int j = 0; j < (int)coins.size(); ++j) {
                if (coins[j] <= i) {
                    dp[i] = min(dp[i], dp[i - coins[j]] + 1);
                }
            }
        }
        return dp[amount] > amount ? -1 : dp[amount];
    };
    

    下面这种机器人走法问题也可以看做是背包问题的一种变种:

    给定一个区域长度 N N N,机器人每次可以往左或者往右走 1 1 1个长度,走 K K K步从 S S S位置走向 E E E问一共有多少种走法

    递归函数为 func(int N, int E, int rest, int cur) \text{func(int N, int E, int rest, int cur)} func(int N, int E, int rest, int cur),其中 N \text{N} N E \text{E} E即为固定参数长度和最终位置, rest \text{rest} rest为剩下的步数, cur \text{cur} cur为当前的位置,返回值为方法数。如果 rest = = 0 \text{rest}==0 rest==0时,如果 cur = = E \text{cur} == \text{E} cur==E则返回 1 1 1,否则返回 0 0 0。同时还要考虑 cur = = 1 \text{cur} == 1 cur==1 cur = = N \text{cur} == \text{N} cur==N的特殊情况。基于此加入一个 K × N \text{K}\times\text{N} K×N的二维表记录递归过程中的值就可以修改为一个二维动态规划。

  2. Combination Sum问题

    Combination Sum I:给定一个无重复元素的数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。 candidates 中的数字可以无限制重复被选取。

    Combination Sum II:给定一个数组 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用一次。

    Combination Sum III:找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。

    Combination Sum IV:给定一个由正整数组成且不存在重复数字的数组,找出和为给定目标正整数的组合的个数。

  3. 股票问题
    股票问题都是求最大利润,但是不同的题型条件不同,如下:

    股票 I:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你最多只允许完成一笔交易。

    int max_profit = 0;
    for(int i = 1; i < prices.size(); i++){
        dp = max(0, prices[i] - prices[i-1] + dp);
        max_profit = max(max_profit, dp);
    }
    

    其中 d p [ i ] dp[i] dp[i]代表在第 i i i天卖出时的最大获益。

    股票 II:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你可以尽可能地完成更多的交易。

    dp1[0] = -prices[0];
    for(int i = 1; i < n; i++){             
        dp0[i] = max(dp0[i-1], dp1[i-1] + prices[i]);
        dp1[i] = max(dp1[i-1], dp0[i-1] - prices[i]);
    }
    

    其中 d p 0 [ i ] dp0[i] dp0[i]: 第 i i i天结束,手头没有股票时的最大利润, d p 1 [ i ] dp1[i] dp1[i]: 第 i i i天结束,手头有股票时的最大利润。

    股票 III:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你最多可以完成 两笔 交易。

    dp1[0][0] = -prices[0]; dp1[0][1] = -prices[0]; // i = 0
    for(int i = 1; i < n; i++){
        dp1[i][0] = max(dp1[i-1][0], -prices[i]); // j = 0
    
        // j = 1
        dp0[i][1] = max(dp0[i-1][1], dp1[i-1][0] + prices[i]); // 保持 or 卖出
        dp1[i][1] = max(dp1[i-1][1], dp0[i-1][1] - prices[i]); // 保持 or 买入
        // j = 2
        dp0[i][2] = max(dp0[i-1][2], dp1[i-1][1] + prices[i]); // 保持 or 卖出
        // dp1[i][2] = max(dp0[i-1][2], dp0[i-1][2] - prices[i]); // 保持 or 买入
    }
    

    股票 IV:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你最多可以完成 k k k 笔交易。

    for(int j = 0; j <= k; j++) dp1[0][j] = -prices[0]; // i = 0
    for(int i = 1; i < n; i++){
        dp1[i][0] = max(dp1[i-1][0], -prices[i]); // j = 0
    
        for(int j = 1; j <= k; j++){ // j > 0
            dp0[i][j] = max(dp0[i-1][j], dp1[i-1][j-1] + prices[i]); // 保持 or 卖出
            dp1[i][j] = max(dp1[i-1][j], dp0[i-1][j] - prices[i]); // 保持 or 买入
        }
    }
    

    股票 V:给定一个数组,它的第 i i i 个元素是一支给定股票第 i i i 天的价格,你可以尽可能地完成更多的交易,卖出股票后,你无法在第二天买入股票 ,冷冻期为 1 1 1 天。

     dp1[0] = - prices[0]; // i = 0
     for(int i = 1; i < n; i++){
         dp0[i] = max(dp0[i-1], dp1[i-1] + prices[i]); 
         dp1[i] = max(dp1[i-1], cool[i-1] - prices[i]);
         cool[i] = max(cool[i-1], dp0[i-1]);
     }
    

    其中 d p 0 [ i ] dp0[i] dp0[i]: 第 i i i天结束,手头没有股票时的最大利润, d p 1 [ i ] dp1[i] dp1[i]: 第 i i i天结束,手头有股票时的最大利润, c o o l [ i ] cool[i] cool[i]: 第 i i i天结束且第 i i i天处于冷冻期时的最大利润

    股票 VI:给定一个数组,其中第 i i i 个元素代表了第 i i i 天的股票价格 ;非负整数 f e e fee fee 代表了交易股票的手续费用,你可以无限次地完成交易,但是你每笔交易都需要付手续费。

    dp1[0] = - prices[0]; // i = 0
    for(int i = 1; i < n; i++){
        dp0[i] = max(dp0[i-1], dp1[i-1] + prices[i] - fee); 
        dp1[i] = max(dp1[i-1], dp0[i-1] - prices[i]);
    }
    
  4. 打家劫舍问题
    打家劫舍问题的基本定义是不能强两个连续的房子,但是房子的排布形式不同

    打家劫舍 I:房子排列成数组。

    非常简单的动态规划问题:
    d p [ i ] = max ⁡ ( d p [ i − 2 ] + n u m s [ i ] , d p [ i − 1 ] ) d p[i]=\max (d p[i-2]+n u m s[i], d p[i-1]) dp[i]=max(dp[i2]+nums[i],dp[i1])其中 d p [ i ] dp[i] dp[i]表示第 i i i个房子是否被抢。

    打家劫舍 II:房子排列成圆圈。

    在打劫1的基础上补充两个条件,即抢第一个房子则不抢最后一个房子和抢最后一个房子则不抢第一个房子。

    打家劫舍 III:房子排列成树。

    采用树形DP的套路解,动态规划修改为:

  5. 路径和问题

    路径和 I:判断是否存在根节点到叶节点和为 targetSum \text{targetSum} targetSum的路径

    路径和 II:找到所有根节点到叶节点和为 targetSum \text{targetSum} targetSum的路径

    路径和 I 和路径和 II 都采用递归的方式求解即可。

    路径和 III:找到所有和为 targetSum \text{targetSum} targetSum的路径

    路径和 III 同样使用递归的方式求解,但是在递归的过程中需要通过一个哈希表记录所有出现过的前缀和的数量

3.3 一些经典的业务问题

咖啡杯问题:数组 arr \text{arr} arr表示第 i i i咖啡机跑咖啡的时间, N N N表示有多少个人和咖啡,每个人多会排队等且只喝一杯, a a a表示洗咖啡杯的时间,杯子只能一杯一杯洗, b b b表示自然挥发干净的时间,问开始和到喝完的最短时间

分为两个阶段:
先计算重咖啡的时间,使用小根堆对 ( x , y ) (x,y) (xy)进行排序,其中 x x x表示启动时间, y y y表示工作时间,按照 x + y x+y x+y进行排序,这一部分是贪心算法,有点类似于会议室安排。使用一个数记录不同的人喝完开始洗咖啡杯的时间。
根据每个人开始洗咖啡杯的时间,使用暴力递归的方法对 a a a b b b两种选择尝试,使用 c c c记录到第 i i i个人洗时杯子什么时候空闲,递归函数为 func(vector drinks, int a, int b, int c, int i) \text{func(vector drinks, int a, int b, int c, int i)} func(vector drinks, int a, int b, int c, int i),然后修改为动态规划。

判断给定数组是否可以通过调整做到相邻两个数字相乘时 4 4 4的倍数

遍历数组统计三类数的个数:奇数个数 a a a 2 2 2因子的偶数个数 b b b 4 4 4因子的偶数个数 c c c
如果 b = = 0 b == 0 b==0,那么“奇-偶-奇-偶-奇”的摆法为临界状态,也就是说如果 a = = 1 a == 1 a==1,则 c > = 1 c >= 1 c>=1,如果 a > 1 a > 1 a>1,则 c > = a − 1 c >= a - 1 c>=a1
如果 b ! = 0 b != 0 b!=0,那么将 2 2 2因子的偶数到一起,然后再按照“偶-奇-偶-奇”的摆法为临界状态, a = = 1 a == 1 a==1,则 c > = 1 c >= 1 c>=1,如果 a > 1 a > 1 a>1,则 c > = a c >= a c>=a

定义 s = " a " ; m = s s="a";m=s s="a";m=s,操作一 m = s ; s = s + s m=s;s=s+s m=s;s=s+s,操作二 s = s + m s=s+m s=s+m,如何使用最少的操作数将 s s s拼接到长度 n n n

如果 n n n为质数,则只使用操作二最少(可以反证如果不只调用操作二,则构建的一定不是质数);如果 n n n不是质数,则假设 n n n是有若干个质数因子组成,每个质数因子都可以调用操作二操作最少,因此最少操作数为所有质数相乘减4。

给定全是小写字母的字符串,删除多余字符,让结果字符串的字典序最小

先设计一张词频表,统计字符串中每个字符出现的次数。然后,从头到尾遍历,每遇到一个字符,该词频减 1 1 1,直到遍历到某个字符的词频减完为 0 0 0时,此时从前面遍历过的所有字符中找出一个字典序最小的,确定下来一个字符,然后将选择位置的左边全部删除,后面所有这个字符也全部删除。再统计词频,继续下一轮。我们对上述操作的理解是在前面选择最小字典序的一个字符,有两种可能:
1、正好是当前词频为0的字符,则此时,其他字符词频不为0,后面还有,因此不会漏掉。
2、不是当前词频为0的字符,但是,词频为0的字符在最后的位置,因此,删除左边不可能漏掉。

给定有 x x x . . .构成的数组, . . .的位置可以放灯,每个灯影响相邻的一个位置,问最少放多少灯可以将 . . .的位置都点亮

i i i从头到尾遍历每一个位置,假定每个 i i i位置都不受 0 0 0 i − 1 i-1 i1位置如何放灯的影响,如果 i i i位置为 x x x,则 i = i + 1 i=i+1 i=i+1,如果 i i i位置为 . . .,则判断 i + 1 i + 1 i+1位置是否为 x x x,如果是,则 i = i + 2 i=i+2 i=i+2,如果不是,则认为将灯放在 i + 1 i+1 i+1位置是更合理的,那么 i + 2 i+2 i+2位置也会被影响,则 i = i + 3 i=i+3 i=i+3

洗衣机问题,给定数组表示每个洗衣机上现有衣服数量,每次移动一件,相邻洗衣机才可以移动,问组少多少轮次能够移成平均状态

先判断衣服总数除以洗衣机总数是否可以整除,假定 i i i位置左侧洗衣机需要的衣服数量为 a a a,右侧洗衣机需要的衣服数量为 b b b,分情况讨论:(1) a b ab ab都负,那么至少需要移动 a + b a+b a+b轮;(2) a b ab ab都正,则至少需要移动 max ( a , b ) \text{max}(a,b) max(a,b)轮;(3) a b ab ab一正一负,则至少需要 max ( a , b ) \text{max}(a,b) max(a,b)轮,遍历所有的位置,最大的轮数为结果。

长度为 N N N的数据 arr \text{arr} arr进行切分,求左部分最大值减右部分最大值的绝对值尽量大

记全局最大值为 max \text{max} max,当 max \text{max} max划分进左半部分时,要求右侧最大值最小,那么右侧右一定包含 N − 1 N-1 N1位置数,所以这种情况下相减最大绝对值为 max − arr [ N − 1 ] \text{max}-\text{arr}[N-1] maxarr[N1],反之为 max − arr [ 0 ] \text{max}-\text{arr}[0] maxarr[0],因此取两者的最大值即可。

两个集合 A A A B B B,从一个集合 A A A中取出一个元素放到另外一个集合 B B B中,操作后两个集合的平均值都大于操作前,问题可以进行多少次这种操作

判断两个集合的平均值分为小于、等于和大于三种情况,将集合中的数分为小于、等于和大于三种类型进行讨论。最后只有 A A A的平均值大于 B B B时,从 A A A中取小于 A A A平均值大于 B B B平均值的数才能使得两个集合的平均值都增大,然后采用贪心的策略从满足要求的最小的开始挪。

给定有 x x x . . .构成的数组, . . .的位置可以放灯,每个灯影响相邻的一个位置,问最少放多少灯可以将 . . .的位置都点亮

i i i从头到尾遍历每一个位置,假定每个 i i i位置都不受 0 0 0 i − 1 i-1 i1位置如何放灯的影响,如果 i i i位置为 x x x,则 i = i + 1 i=i+1 i=i+1,如果 i i i位置为 . . .,则判断 i + 1 i + 1 i+1位置是否为 x x x,如果是,则 i = i + 2 i=i+2 i=i+2,如果不是,则认为将灯放在 i + 1 i+1 i+1位置是更合理的,那么 i + 2 i+2 i+2位置也会被影响,则 i = i + 3 i=i+3 i=i+3

使用 6 6 6 8 8 8两个袋子分 n n n个苹果,问能否分成功,如果能最少能用多少个袋子

奇数直接返回失败,先用8袋子分,分不了了再用6袋子分即可。但是通过分析可以得到,用8袋子分的可能性由多到少排列,每种可能性都需要计算 8 8 8袋子分完剩余的袋子是否可以用 6 6 6袋子,但当 8 8 8袋子分完剩余的苹果数超过 24 24 24就不需要再考虑,直接返回失败,原因是大于 24 24 24的数如果 6 6 6袋子能分,一定可以先用 8 8 8袋子分掉。

你可能感兴趣的:(数据结构与算法,leetcode,算法)