高频算法及数据结构面试题目汇总 (1)

417 阅读12分钟

高频算法及数据结构面试题目汇总(1)

作者:光火

邮箱:victor_b_zhang@163.com

  • 对于初入职场的应届生而言,算法与数据结构无疑是面试中重要的一环。本文综合多方信息,尝试汇总了力扣中热度颇高的模板类题目,并辅以说明及解析,非常适合有面试和刷题需求的代码人阅读。对于文中所列举的题目,推荐读者做短暂思考后,就尝试阅读代码领悟思路,然后间隔几天后再次阅读,如此持续一段时间,就基本可以形成自己的方法论,以不变应万变了。
  • 由于不同大厂考察的面试题不尽相同且覆盖面广,笔者将通过一系列文章为各位进行梳理。

合并两个有序链表

  • 题干:21. 合并两个有序链表
  • 相较于灵活的双向链表,单向链表受限于访问形式,往往需要考虑更多的因素,也因此为各大企业的面试官所推崇。
  • 由于链表的数据结构相对固定,所以时常可以利用递归、迭代来求解有关问题。本题的难度分级为简单,是一道在理解上没有太多障碍的模板类题目。
// 递归
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
    if(l1 == nullptr) {
        return l2;
    }else if(l2 == nullptr) {
        return l1;
    }else if(l1->val < l2->val) {
        l1->next = mergeTwoLists(l1->next, l2);
        return l1;
    }else {
        l2->next = mergeTwoLists(l1, l2->next);
        return l2;
    }
}
  • 链表类题目基本都会涉及到指针操作。对于本题而言,我们需要按照大小顺序合并两个有序链表,这就要特别注意当我们意图将一个表项的next指针指向另一个表项时,应当事先保存它原来指向的表项,否则就无法再次访问到这块内存了。
  • 对于递归程序而言,上述问题其实很好解决,关注这行代码:
 l1->next = mergeTwoLists(l1->next, l2);
  • l1->next在其中出现了两次,但由于递归栈的作用,其含义却截然不同。
  • 考虑初始情况,即l1l2分别为两待合并链表的表头。倘若l1->val < l2->val,则说明应当选取l1作为合并后的表头结点。接下来,我们递归地去确认第二个结点,即head(l1)->next应当为何。此时,由于l1链表的首项已经被考虑过了,所以我们应当审视它的下一个表项,即l1->next,而l2则维持不变。所以,赋值语句左侧的l1->next代表我们希望确定的下一节点,而右侧的l1->next则是原本l1链表的下一个节点,它作为参数被传递了下去。
// 迭代
ListNode* mergeTwoLists(ListNode* l1, ListNode* l2) {
	ListNode* ptr = new ListNode(0);

	ListNode* pre = ptr;
	while(l1 != nullptr && l2 != nullptr) {
		if(l1->val < l2->val) {
			pre->next = l1;
			l1 = l1->next;
		} else {
			pre->next = l2;
			l2 = l2->next;
		}
		pre = pre->next;
	}
	pre->next = l1 == nullptr? l2 : l1;
	return ptr->next;
}
  • 笔者在一篇讲述经典排序算法的文章中曾经给出过本题的迭代解法,因为它实在是太像归并排序了(或许说就是归并排序)。感兴趣的朋友可以跳转至:经典排序算法的实现及解析 - 掘金 (juejin.cn)
  • 如前文所指出的那样,单向链表受限于访问形式,我们只能由前至后遍历,但是最终返回的结果应当是链表头,这就需要一个辅助指针ptr,在循环中我们操纵pre,但维持ptr不变,最终只需返回ptr->next即可。

二叉树的锯齿形层序遍历

vector<vector<int>> zigzagLevelOrder(TreeNode* root) {
	vector<vector<int>> result;
	if(root == nullptr) return result;

	queue<TreeNode*> base;
	bool trun_left = true;
	base.push(root);

	while(!base.empty()) {
		deque<int> buffer;
		int number = base.size();
		for(int i = 0; i < number; i ++) {
			TreeNode* node = base.front();

			if(trun_left) buffer.push_back(node->val);
			else buffer.push_front(node->val);

			base.pop();
			if(node->left) base.push(node->left);
			if(node->right) base.push(node->right);
		}
		trun_left = !trun_left;
		result.push_back(vector<int> {buffer.begin(), buffer.end()});
	}

	return result;
}
  • 这是一道二叉树的层序遍历问题,只不过依据深度的奇偶性不同,访问节点的顺序也会发生改变。对于层序遍历,我们显然可以利用BFS,这就需要借助队列queue。而之于是左起还是右起访问,则可借助双向队列deque,它允许我们自两端插入元素,然后最后将deque强制转换为vector,放到result中即可。
  • 上述代码还是值得你画一棵二叉树去走一遍的。你会发现buffer中存储的节点恰好位于同一层,而在填充buffer的过程中,我们也将当前节点的左右子节点放入了base中,为下一轮的while循环做准备。
  • 在笔者的一篇AI搜索算法的入门文章里,给出过常见搜索的基本框架,并解释了如何记录搜索的路径,感兴趣的朋友可以跳转至:Pac Man: AI 搜索算法的理解与应用 (1) - 掘金 (juejin.cn)

二叉树的最近公共祖先

TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) {
	if(!root || root == p || root == q) return root;
	
	auto left = lowestCommonAncestor(root->left, p, q);
	auto right = lowestCommonAncestor(root->right, p, q);
	
	if(!left) return right;
	if(!right) return left;
	return root;
} 
  • 本题的递归解法十分巧妙,由于二叉树的整体结构是未知的,因此我们首先需要利用root找到pq。在这一任务完成后,我们应当分别标记pq的祖先节点并返回最近的公共祖先。由此可见,我们首先需要自顶向下找到目标节点,再自底向上寻觅祖先,这正好对应了压栈与出栈过程,也就自然而然地联想到要利用递归来解决本题。
  • 在明确了大体思路后,我们定义递归算法的终止条件,即当前节点为空,亦或是为pq时就返回当前节点。否则,我们就应分别遍历当前节点的左子树和右子树去寻找pq。这时,有两种情况,pq可以位于同一子树也可位于两棵子树。对于前者,我们只需关注返回值不为空的那一侧即可,因为显然,当前节点是pq的祖先但却不是最近的那个。对于后者,这说明当前节点就是pq的最近公共祖先(可以画图辅助理解),于是我们返回root

路径总和

bool hasPathSum(TreeNode* root, int targetSum) {
	if(!root) {
		return false;
	}
	
	if(!root->left && !root->right && targetSum == root->val) {
		return true;
	}

	int rest = targetSum - root->val;
	if(hasPathSum(root->left, rest)) return true;
	else return hasPathSum(root->right, rest);
}
  • 本题是一道基础的树搜索题目,套用DFS模板,并在过程中维护currentSum或者restSum,然后判断终止条件即可。
  • 由于树是没有回路的,因此也无需担心会重复遍历同一节点(否则就要引入探索集)。上述代码的第一个if判断不仅有效过滤了不满足条件的叶子节点(或称自根节点到该叶子节点的路径),还排除了根节点直接为空的特殊情况。

K个一组翻转链表

  • 题干:25. K 个一组翻转链表
  • 按照官方的说法,本题细节较多,容易写出冗长的代码,旨在考察面试者的设计能力。那么何谓“冗长的代码”呢?先来看看笔者第一次书写的程序:
tuple<ListNode*, ListNode*> recoverLastGroup(ListNode* old_head, int cnt) {
	ListNode* pre = nullptr;
	ListNode* cur = old_head;
	ListNode* nxt = cur->next;

	while(cnt > 0) {
		cnt --;
		cur->next = pre;
		pre = cur;
		cur = nxt;
		nxt = nxt? nxt->next : nullptr;
	}

	return make_tuple(pre, nullptr);
}

tuple<ListNode*, ListNode*> reverseOneGroup(ListNode* old_head, int k) {
	int cnt = 0;
	ListNode* pre = nullptr;
	ListNode* cur = old_head;
	ListNode* nxt = cur->next;

	while(cur && cnt < k) {
		cnt ++;
		cur->next = pre;
		pre = cur;
		cur = nxt;
		nxt = nxt? nxt->next : nullptr;
	}

	if(cnt == k) {
		return make_tuple(pre, cur);
	}

	return recoverLastGroup(pre, cnt);
}

ListNode* reverseKGroup(ListNode* head, int k) {
	if(k == 1) return head;

	auto heads = reverseOneGroup(head, k);

	ListNode* old_head = head;
	ListNode* new_head = get<0>(heads);
	ListNode* next_head = get<1>(heads);

	ListNode* result = new_head;

	while(true) {
		if(old_head == new_head) {
			return result;
		}

		if(next_head) {
			heads = reverseOneGroup(next_head, k);
		} else {
			return result;
		}

		new_head = get<0>(heads);
		old_head->next = new_head;

		old_head = next_head;
		next_head = get<1>(heads);
	}
}
  • 上述代码是正确的,且在效率上可以击败85%的提交,但是实现的非常麻烦。特别是对于节点总数不整除k的情况,还要专门设置一个recoverLastGroup把最后一组已经翻转的链表再恢复回来。于是,在AC后,我立刻学习了一遍官方的题解:
pair<ListNode*, ListNode*> reverseOneGroup(ListNode* head, ListNode* tail) {
	ListNode* cur = head;
	ListNode* pre = tail->next;

	while(pre != tail) {
		ListNode* nxt = cur->next;
		cur->next = pre;
		pre = cur;
		cur = nxt;
	}

	return {tail, head};
}

ListNode* reverseKGroup(ListNode* head, int k) {
	ListNode* hair = new ListNode(0);
	ListNode* prev = hair;
	hair->next = head;

	while(head) {
		ListNode* tail = prev;

		for(int i = 0; i < k; ++ i) {
			tail = tail->next;
			if(tail == nullptr) {
				return hair->next;
			}
		}

		ListNode* next = tail->next;
		tie(head, tail) = reverseOneGroup(head, tail);

		prev->next = head;
		tail->next = next;
		head = tail->next;
		prev = tail;
	}

	return hair->next;
}
  • 好吧,其实也不过如此(x)。在执行效率方面,官方题解并没有什么优越之处。不过这份代码明显更适合面试,因此我们还是要具体地看一看。
  • 相较于常规的链表翻转,本题的最大特点在于我们所要翻转的一系列子链表都归属于同一个母链表。因此,在对每一小段表项进行翻转后,还要想办法将它们拼接起来。这就需要我们记录这段子链表的前置节点。
  • 对于表头而言,它是没有前置节点的,为此我们引入一个虚拟节点hair,这也是解决链表类问题的惯用技巧。此外,由于单链表开弓是没有回头箭的。倘若我们不特别记录表头节点,那么当指针到达链表尾端时,我们就会陷入明明操作完成了,却找不到返回值的尴尬境地。所以,这个虚拟节点还起到了充当返回值的作用,它的next指针永远指向链表头,如此只需返回hair->next即可。
  • 就本题而言,形况就更为复杂,因为表头节点可能改变(当发生了翻转时),也可能完全没变(当链表长度不足k时)。此时,引入虚拟节点,将head一般化为普通节点的优势就体现出来了。它使得以head起始的部分可以直接进行翻转,而无需特别记录些什么。如果你看了我第一次写的代码,会发现我应对表头变化这一情况的思路是提前调用一次reverseOneGroup,这肯定就没有官方题解那么优雅了。所以,在此建议各位读者掌握这一方法,它能够有效减少代码冗余,降低编写难度。
  • 上述代码中的tie(head, tail) = reverseOneGroup(head, tail);C++ 17的写法,就相当于
tie(head, tail) = reverseOneGroup(head, tail);
// 两者等价
pair<ListNode*, ListNode*> result = myReverse(head, tail);
head = result.first;
tail = result.second;

排序数组

// 递归实现
void quickSort(vector<int>& nums, int l, int r) {
	if(r - l < 1) return;

	srand(time(0));
	int m = rand() % (r - l + 1) + l;
	swap(nums[m], nums[l]);

	int i = l, j = r;
	int divider = nums[l];

	while(i < j) {
		while(nums[j] >= divider && i < j) j --;
		while(nums[i] <= divider && i < j) i ++;
		if(i < j) swap(nums[i], nums[j]);
	}

	nums[l] = nums[i];
	nums[i] = divider;
	quickSort(nums, l, i - 1);
	quickSort(nums, i + 1, r);
}

vector<int> sortArray(vector<int>& nums) {
	quickSort(nums, 0, nums.size() - 1);
	return nums;
}
  • 这是一道非常基础的题目,我们可以利用常见的排序算法进行解决。上述代码就是一份手打的递归式快排,这算是程序员的基本功。所以,目前对排序算法不熟悉的朋友,可以跳转至 经典排序算法的实现及解析 - 掘金 (juejin.cn) 进行温习巩固,自行实现一遍经典的排序算法。
  • 但是呢,只会写递归式的快排是不够的。倘若对方要求你用迭代实现个快排,不要当场愣住,只需把程序栈自行用stack实现一次就好啦:
// 迭代实现
void quickSort(vector<int>& nums) {
	stack<pair<int, int>> base;
	base.push(make_pair(0, nums.size() - 1));
	
	while(!base.empty()) {
		auto t = base.top();
		
		base.pop();
		int l = t.first;
		int r = t.second;
		if(l >= r) continue;
		
		srand(time(0));
		int m = rand() % (r - l + 1) + l;
		
		swap(nums[l], nums[m]);
		int divider = nums[l];
		int i = l, j = r;
		
		while(i < j) {
			while(nums[j] >= divider && i < j) j --;
			while(nums[i] <= divider && i < j) i ++;
			if(i < j) swap(nums[i], nums[j]);
		}
		
		nums[l] = nums[i];
		nums[i] = divider;
		base.push(make_pair(l, i - 1));
		base.push(make_pair(i + 1, r));
	}
	
}

搜索旋转排序数组

int search(vector<int>& nums, int target) {
	int n = nums.size();
	if(n == 0) return - 1;
	if(n == 1) return nums[0] == target? 0 : -1;

	int l = 0;
	int r = n - 1;
	while(l <= r) {
		int m = (l + r) / 2;
		if(nums[m] == target) {
			return m;
		}

		if(nums[0] <= nums[m]) {
			if(nums[0] <= target && target < nums[m]) {
				r = m - 1;
			} else {
				l = m + 1;
			}
		} else {
			if(nums[m] < target && target <= nums[n - 1]) {
				l = m + 1;
			} else {
				r = m - 1;
			}
		}
	}
	return -1;
}
  • 这是一道非常有意思的题目。对于有序数组,我们显然可以利用二分查找,以O(logn)O(\log n)的代价确认目标元素存在与否。但本题传入的参数却是一个中 大 | 小 中形式的vector,它在两个子段中均严格递增,但合起来却并不有序。
  • 鉴于vector的元素互不相同,且整体呈现出非常特殊的形式,我们仍旧期望以O(logn)O(\log n)的时间复杂度完成查询,而非以O(n)O(n)的方式逐个遍历。这就要求我们想尽办法利用数组的结构特征,换而言之,就是变着法地应用二分查找。没有条件,也要创造条件。
  • 我们发现,对于这种中 大 | 小 中,元素互异的排列,从中任意位置将其分开,总会有一部分是有序的。当然,如果你运气足够好,恰好从翻转位置切开,则两部分都是有序的。对于有序区间,我们可以利用二分查找快速定位目标值。
  • 这就牵扯到一个问题,怎么判断两个子数组是有序的。假设区间下标范围[l, r],根据本题的排列方式,当nums[l] < nums[r]时,即可认为该区间有序。这在于翻转前,数组本身是递增的。这意味着翻转后,位于前一段的元素严格大于后一段的元素。
  • 倘若在有序的那段子区间中没有找到目标元素,则应当去无序的那一段寻找。于是,我们再次将这个无序的区间分为两部分,这样又至少有一部分是有序的,如此循环往复。

两数相加II

ListNode* addTwoNumbers(ListNode* l1, ListNode* l2) {
	stack<int> s1;
	stack<int> s2;

	while(l1) {
		s1.push(l1->val);
		l1 = l1->next;
	}

	while(l2) {
		s2.push(l2->val);
		l2 = l2->next;
	}

	int carry = 0;
	ListNode* result = nullptr;
	while(!s1.empty() or !s2.empty() or carry != 0) {
		int a = s1.empty()? 0 : s1.top();
		int b = s2.empty()? 0 : s2.top();

		if(!s1.empty()) s1.pop();
		if(!s2.empty()) s2.pop();

		int c = a + b + carry;
		carry = c / 10;
		c %= 10;

		auto node = new ListNode(c);
		node->next = result;
		result = node;
	}

	return result;
}
  • 由于本题在“进阶”一栏中提到,倘若输入链表不可翻转,应当如何应对。那么,我们就看一下在保证输入链表结构不发生改变的前提下,如何完成加法操作。
  • 在进行加法运算时,我们习惯从低位算起,逐层进位。但是在初始状态下,我们只可获知表头,就连孰长孰短都没法直接判断。因此,我们需要利用栈的FILO特性,定位到输入链表的最低位。当然,利用栈本质上和翻转其实就没多大区别了,但是通过只记录value,我们能够确保输入链表的结构不被破坏。
  • 上述代码的原理颇为简单,不过有一些细节还是值得学习的。通过了解这些细节,我们可以写出更为简明的程序:
    • stack内部只需存储val,因此int足矣,没必要是ListNode*类型,后者会导致拷贝整个输入链表,造成额外的内存开销。
    • l1l2的长度可能不同,这将导致在计算到高位时,有一个链表会提前终止。因此每次取值时,都应先判断栈中是否还有元素剩余,没有的话就置为0。同理,pop()的时候也需要进行相同的判断,已经为空的栈就无需pop了。那么显然,用三元运算符比加几个if好看得多。
    • 十进制加法,进位carry = sum / 10sum %= 10是常规操作,写成这种形式也可以省去不必要的if判断,缩短代码行数。
    • 总体而言,上述实现的最大亮点在于这三行:
    auto node = new ListNode(c);
    node->next = result;
    result = node;
    
    它保证我们最终得到的链表一定是高位在前,低位在后的。具体来说,我们由右至左计算,每次得到的结果都应放置在上一节点的左侧,因此有node->next = result,然后我们再更新result,让它指向当前表头,如此最终只需返回result即可。
  • 以上这种做法相当于用空间换时间。倘若允许翻转,我们可以直接在长链表上进行操作,无需再new一系列节点。关于链表长度的获取,可以在翻转的过程中得到。具体来说,我们在翻转链表的函数中传个引用,然后每次迭代都自增即可。