Let's start with the description for Longest Palindromic Substring:
Given a string
s
, return the longest palindromic substring ins
.
For example:
Input: s = "babad"
Output: "bab"
Explanation: "aba" is also a valid answer.
Or:
Input: s = "cbbd"
Output: "bb"
Also, the constraints indicate that s
consists of only digits and English letters.
In this series, we've checked before if a string is a palindrome using the two pointers approach.
With two pointers, checking if a string is a palindrome is not too hard: we can initialize a left
pointer that starts off from the left and a right
pointer that starts off from the right. While they're pointing at the same character, we can keep updating them, going until the middle character in the string. If at some point they differ, the string itself is not a palindrome, so we return false
.
But, our aim in this problem is not to check for the validity of a palindromic string, it's entirely different. We need to get the result of the longest possible palindrome in the string — which doesn't have to be a palindrome itself.
We can start with initializing a maxLength
variable with the value 1
(remember that our constraints say that the minimum length of s
is 1
):
let maxLength = 1;
Then, we can initialize our starting index, which will slice our string from the starting point of the maximum length palindrome:
let start = 0;
So that at the end we can return that "window":
function longestPalindrome(s: string): string {
/* ... */
return s.slice(start, start + maxLength);
}
To find a palindrome in the string, we can use an "expand over center" approach. For each character, we'll assume that it is the middle character and expand our two pointers left
and right
accordingly.
We'll first start with getting the right
pointer to the right place: the first position that it differs from the current character:
for (let i = 0; i < s.length; i++) {
let right = i;
while (right < s.length && s[i] === s[right]) {
right++;
}
/* ... */
}
Then, we'll initialize our left
pointer to point on the left side of our current character:
for (let i = 0; i < s.length; i++) {
/* ... */
let left = i - 1;
}
After that, while we're within bounds and still looking at a palindrome, we can continue expanding:
for (let i = 0; i < s.length; i++) {
/* ... */
while (left >= 0 && right < s.length && s[left] === s[right]) {
left--;
right++;
}
}
However, the crucial part is that we need to update the maximum length if we find a longer palindrome:
for (let i = 0; i < s.length; i++) {
/* ... */
let currentLength = right - left - 1;
if (currentLength > maxLength) {
maxLength = currentLength;
start = left + 1;
}
}
And, that's it for the whole function. Here's how it looks like:
function longestPalindrome(s: string): string {
let maxLength = 1; // Constraint: 1 <= s.length <= 1000
let start = 0;
for (let i = 0; i < s.length; i++) {
let right = i;
while (right < s.length && s[i] === s[right]) {
right++;
}
let left = i - 1;
while (left >= 0 && right < s.length && s[left] === s[right]) {
left--;
right++;
}
let currentLength = right - left - 1;
if (currentLength > maxLength) {
maxLength = currentLength;
start = left + 1;
}
}
return s.slice(start, start + maxLength);
}
The time complexity for this solution is $O(n^2)$ as in the worst case we'll be iterating over the whole string for each character. The space complexity is $O(1)$ because we don't require additional storage that will grow proportionately to the input size.
Next up is another problem related to palindromes: Palindromic Substrings. Until then, happy coding.
]]>The description for House Robber II is:
You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed. All houses at this place are arranged in a circle. That means the first house is the neighbor of the last one. Meanwhile, adjacent houses have a security system connected, and it will automatically contact the police if two adjacent houses were broken into on the same night.
Given an integer array
nums
representing the amount of money of each house, return the maximum amount of money you can rob tonight without alerting the police.
For example:
Input: nums = [2, 3, 2]
Output: 3
Explanation: You cannot rob house 1 (money = 2) and then rob house 3 (money = 2), because they are adjacent houses.
Or:
Input: nums = [1, 2, 3, 1]
Output: 4
Explanation: Rob house 1 (money = 1) and then rob house 3 (money = 3).
Total amount you can rob = 1 + 3 = 4.
Or:
Input: nums = [1, 2, 3]
Output: 3
We've seen the previous problem that was very similar, except that the first and last houses weren't counted as adjacent.
In fact, for the previous problem, our final solution looked like this:
function rob(nums: number[]): number {
let twoPrevMax = 0;
let prevMax = 0;
for (const n of nums) {
let maxUpToN = Math.max(prevMax, n + twoPrevMax);
twoPrevMax = prevMax;
prevMax = maxUpToN;
}
return prevMax;
}
We're using a bottom-up approach where we keep the "bottom" two values: twoPrevMark
keeps the maximum amount of money we can have up until two houses prior. prevMax
is the maximum until the previous house.
In the for
loop, we calculate the maximum value for each house. Then, we return prevMax
as it'll hold the last value, the maximum that we can have of all the houses.
We can reuse this solution, but we can't consider the first and last houses at the same time. In order to do that, we can pass the two versions of nums
to this function as arguments: one will start from the first house and end at the house previous to the last one:
nums.slice(0, nums.length - 1)
The other will start from the second house and end at the last house:
nums.slice(1)
Then, we can get the maximum of those two values, which will be our return value.
First, we can start by renaming the above function to robHelper
.
Our base cases will stay the same as the previous problem:
function rob(nums: number[]): number {
if (nums.length === 0) {
return 0;
}
if (nums.length === 1) {
return nums[0];
}
/* ... */
}
Then, we can consider those slices separately and get the maximum value from either of them:
function rob(nums: number[]): number {
/* ... */
return Math.max(
robHelper(nums.slice(0, nums.length - 1)),
robHelper(nums.slice(1)),
);
}
And, that's pretty much it. The final version looks like this:
function rob(nums: number[]): number {
if (nums.length === 0) {
return 0;
}
if (nums.length === 1) {
return nums[0];
}
return Math.max(
robHelper(nums.slice(0, nums.length - 1)),
robHelper(nums.slice(1)),
);
}
function robHelper(houses: number[]): number {
let twoPrevMax = 0;
let prevMax = 0;
for (const n of houses) {
let maxUpToN = Math.max(prevMax, n + twoPrevMax);
twoPrevMax = prevMax;
prevMax = maxUpToN;
}
return prevMax;
}
The time complexity is $O(n)$ as we're iterating over each house twice. The space complexity is $O(1)$ because we don't require additional storage whose size will grow as the input size grows.
Next up is Longest Palindromic Substring. Until then, happy coding.
]]>Let's start with the description for House Robber:
You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed, the only constraint stopping you from robbing each of them is that adjacent houses have security systems connected and it will automatically contact the police if two adjacent houses were broken into on the same night.
Given an integer array
nums
representing the amount of money of each house, return the maximum amount of money you can rob tonight without alerting the police.
For example:
Input: nums = [1, 2, 3, 1]
Output: 4
Explanation: Rob house 1 (money = 1) and then rob house 3 (money = 3).
Total amount you can rob = 1 + 3 = 4.
Or:
Input: nums = [2, 7, 9, 3, 1]
Output: 12
Explanation: Rob house 1 (money = 2), rob house 3 (money = 9) and rob house 5 (money = 1).
Total amount you can rob = 2 + 9 + 1 = 12.
Let's start with the easiest options. What's the maximum amount of money we can have if we have no houses to rob?
Exactly. So, we can handle that case:
function rob(nums: number[]): number {
if (nums.length === 0) {
return 0;
}
/* ... */
}
What's the maximum amount of money we can have if we have only one house to rob?
That's also easy:
function rob(nums: number[]): number {
/* ... */
if (nums.length === 1) {
return nums[0];
}
}
Since this is a dynamic programming problem, we know that it contains subproblems, and if we can solve them, we'll eventually reach a solution for the problem itself.
At each house that we can possibly be in, we'll have a maximum amount that we can have so far.
So, we can first create a maxPossibles
array to hold the values that represent the maximum amount of money we can have up until the $i^\text{th}$ house, and initialize all values as 0
for now:
let maxPossibles = Array.from({ length: nums.length }, () => 0);
Now we can initialize the first item.
The maximum amount of money we can have up until the first house is just that amount in the house itself:
maxPossibles[0] = nums[0];
Up until the second house, the maximum amount is either the value in there or the value in the first house:
maxPossibles[1] = Math.max(nums[0], nums[1]);
Now that we're done with the two base cases, we can continue, starting with the third house.
Whatever house we're in, we have two options for the possible maximum we can have there:
maxPossibles[i - 1]
)nums[i] + maxPossibles[i - 2]
)So, the maximum amount of money we can have up until the house that we're currently in is the maximum of those two choices:
for (let i = 2; i < nums.length; i++) {
maxPossibles[i] = Math.max(maxPossibles[i - 1], nums[i] + maxPossibles[i - 2]);
}
Now, finally, we can return the maximum of all those maximum amounts:
function rob(nums: number[]): number {
/* ... */
return Math.max(...maxPossibles);
}
The final solution looks like this in TypeScript:
function rob(nums: number[]): number {
if (nums.length === 0) {
return 0;
}
if (nums.length === 1) {
return nums[0];
}
let maxPossibles = Array.from({ length: nums.length }, () => 0);
maxPossibles[0] = nums[0];
maxPossibles[1] = Math.max(nums[0], nums[1]);
for (let i = 2; i < nums.length; i++) {
maxPossibles[i] = Math.max(maxPossibles[i - 1], nums[i] + maxPossibles[i - 2]);
}
return Math.max(...maxPossibles);
}
The time and space complexities are both $O(n)$ since we're iterating over each house doing a constant operation, and keeping an array whose size will grow as $n$ increases.
We don't even have to keep an array to hold all the maximum values for each house. Since we're using a bottom-up approach, we can keep the bottom two values.
We can initialize two variables to do just that:
let twoPrevMax = 0;
let prevMax = 0;
twoPrevMark
will keep the maximum amount of money we can have up until two houses prior. prevMax
is the maximum until the previous house.
Then, for each house, we can get the maximum so far, like this:
for (const n of nums) {
let maxUpToN = Math.max(prevMax, n + twoPrevMax);
twoPrevMax = prevMax;
prevMax = maxUpToN;
}
At the end, we can return prevMax
as it'll hold the last value, the maximum that we can have of all the houses.
This is how this version looks like:
function rob(nums: number[]): number {
let twoPrevMax = 0;
let prevMax = 0;
for (const n of nums) {
let maxUpToN = Math.max(prevMax, n + twoPrevMax);
twoPrevMax = prevMax;
prevMax = maxUpToN;
}
return prevMax;
}
The time complexity is still $O(n)$ as we iterate over the houses, but the space complexity is $O(1)$ as we don't need additional storage whose size will grow as our input increases.
This was, in my opinion, a quite challenging problem, so it's now time to take a deep breath. Next up, we'll take a look at House Robber II. Until then, happy coding.
]]>
The description for this problem is:
You are climbing a staircase. It takes
n
steps to reach the top.Each time you can either climb
1
or2
steps. In how many distinct ways can you climb to the top?
For example:
Input: n = 2
Output: 2
Explanation: There are two ways to climb to the top.
1. 1 step + 1 step
2. 2 steps
Or:
Input: n = 3
Output: 3
Explanation: There are three ways to climb to the top.
1. 1 step + 1 step + 1 step
2. 1 step + 2 steps
3. 2 steps + 1 step
And, the constraints indicate that n
is between 1
and 45
, inclusive: 1 <= n <= 45
.
For the top-down approach, we need to look from above, so to speak, so let's imagine that we're on the $n^{\text{th}}$ step. There are two ways for us to be where we are: we're either coming from the $(n - 1)^{\text{th}}$ step which means that we took 1 step to reach where we are, or we're coming from the $(n - 2)^{\text{th}}$ step which means that we took 2 steps.
Using memoization, we'll keep a cache to store the number of ways a step can be reached, so that we don't have to recalculate the same values we've already calculated.
We can create a recursive function that does just that. Of course, we need to think about the base case(s) first.
An obvious one is when our cache has the value, in that case, we'll return that value:
function climb(nthStep: number, cache: Map<number, number>): number {
if (cache.has(nthStep)) {
return cache.get(nthStep)!;
}
/* ... */
}
Now, there are two other base cases: when we take our first step and when we haven't taken any steps at all.
In both cases, we can say that there is only one way to be where we are—when we don't take any steps, we can be there just by not going anywhere. And if we take our first step, there is only one way to do it as well: by taking only 1 step.
So, we can write it as such:
function climb(nthStep: number, cache: Map<number, number>): number {
/* ... */
if (nthStep === 0 || nthStep === 1) {
return 1;
}
}
The only thing left is to do our calculation, and put it in cache:
const result = climb(nthStep - 1, cache) + climb(nthStep - 2, cache);
cache.set(nthStep, result);
Overall, climbStairs
will use this function to calculate the number of distinct ways to reach step n
. It finally looks like this:
function climbStairs(n: number): number {
function climb(nthStep: number, cache: Map<number, number>): number {
if (cache.has(nthStep)) {
return cache.get(nthStep)!;
}
if (nthStep === 0 || nthStep === 1) {
return 1;
}
const result = climb(nthStep - 1, cache) + climb(nthStep - 2, cache);
cache.set(nthStep, result);
return result;
}
return climb(n, new Map());
}
The time and space complexity are both $O(n)$ for this version as we keep a cache, so we end up eliminating repetitive calculations, but the requirement for additional space grows as our input increases.
We can try a bottom-up approach to solving this problem. We can initialize an array that will keep the number of distinct ways to reach a step, each index corresponding to a step on the stairs.
However, we have to remember that we haven't taken any steps yet, so we're on the ground, which corresponds to step 0. It means that our array is of length n + 1
. Let's initialize it with all 0s for the time being:
let steps = Array.from({ length: n + 1 }, () => 0);
Since we're on the ground and haven't taken any steps yet, there is only one way to be there: not moving at all.
So, we'll initialize step 0 as 1
, as it is our "base case":
steps[0] = 1;
To reach step 1, we can only do one thing: take a step (if we take 2 steps, we'll go beyond our target). So, we can initialize it as 1
too:
steps[1] = 1;
Now we know how many ways there are to reach our first two cases. Reaching the next step will be the sum of the two steps below it, because we know that we can only go 1 or 2 steps at a time. Therefore, for step n
, it means that we either arrived from the $n - 1^{\text{th}}$ or $n - 2^{\text{th}}$ step. So, continuing with where we left off—the second step—we can fill in the corresponding values for each step:
for (let i = 2; i < n + 1; i++) {
steps.push(steps[i - 1] + steps[i - 2]);
}
And, that's pretty much it. The only thing left to do is to return steps[n]
.
This is how the final solution looks like:
function climbStairs(n: number): number {
let steps = [1, 1];
for (let i = 2; i < n + 1; i++) {
steps.push(steps[i - 1] + steps[i - 2]);
}
return steps[n];
}
steps[n] = 1; // The final step
steps[n - 1] = 1; // Only 1 way to reach the final step from here
For example, if n
is 4
, steps
now looks like this:
[ 0, 0, 0, 1, 1 ]
We can start from the $n - 2^{\text{th}}$ step and go until we reach the floor (where n
equals 0
). Each step will be the sum of one step and two steps after it:
for (let i = n - 2; i >= 0; i--) {
steps[i] = steps[i + 1] + steps[i + 2];
}
Now we know how many distinct ways there are to reach the $n^{\text{th}}$ step from the ground, so the return value will be steps[0]
:
return steps[0];
The time and space complexity will both be $O(n)$ as we do a constant amount of calculation for each step in $n$ steps; we also have an array, and its storage needs grow as our input increases.
As we have seen in the previous article, there is a way to make the space complexity constant with our bottom-up approach.
We can keep two separate variables for our "bottom two values," and do the same calculation we did in the first version.
It looks like this:
function climbStairs(n: number): number {
let one = 1; // No steps yet
let two = 1; // The first step
for (let i = 2; i < n + 1; i++) {
let tmp = one;
one = one + two;
two = tmp;
}
return one;
}
function climbStairs(n: number): number {
let one = 1; // At our destination
let two = 1; // One step below our destination
for (let i = n - 2; i >= 0; i--) {
let tmp = one;
one = one + two;
two = tmp;
}
return one;
}
The time complexity is still $O(n)$ since we're doing a constant amount of calculation for each step, but the space complexity is $O(1)$ as we don't need to use a data structure that will grow in size as $n$ increases.
We have seen three different ways for a solution, so it's time to take a breath. Next up, we'll look at one of the most popular problems, called House Robber. Until then, happy coding.
]]>Dynamic programming (DP) is one of those concepts that is a bit intimidating when you hear it for the first time, but the crux of it is simply breaking problems down into smaller parts and solving them, also storing those solutions so that we don't have to compute them again.
Breaking problems down into subproblems is nothing new, that's pretty much what problem-solving is all about. What dynamic programming is also specifically concerned with are overlapping subproblems that are repeating — we want to calculate solutions to those subproblems so that we won't be calculating them again each time. Put another way, we want to remember the past so that we won't be condemned to repeat it.
For example, calculating 1 + 1 + 1 + 1 + 1 is very easy, if we have already calculated 1 + 1 + 1 + 1. We can just remember the previous solution, and use it:
Calculating the Fibonacci sequence is one of the well-known examples when it comes to dynamic programming. Because we have to calculate the same functions each time for a new number, it lends itself to DP very well.
For example, to calculate fib(4)
we need to calculate fib(3)
and fib(2)
.
However, calculating fib(3)
also involves calculating fib(2)
, so we'll be doing the same calculation, again.
A classic, good old recursive Fibonacci function might look like this:
function fib(n: number): number {
if (n === 0 || n === 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Though the issue we have just mentioned remains: we'll keep calculating the same values:
Then, how can we do better?
Memoization is remembering the problems we have solved before so that we don't have to solve them again and waste our time. We can reuse the solution to the subproblem we've already memoized.
So, we can keep a cache to store those solutions and use them:
function fib(n: number, cache: Map<number, number>): number {
if (cache.has(n)) {
return cache.get(n)!;
}
if (n === 0 || n === 1) {
return n;
}
const result = fib(n - 1, cache) + fib(n - 2, cache);
cache.set(n, result);
return result;
}
For example, we can initially pass an empty Map
as the argument for cache
, and print the first 15 Fibonacci numbers:
let m = new Map<number, number>();
for (let i = 0; i < 15; i++) {
console.log(fib(i, m));
}
/*
0
1
1
2
3
5
8
13
21
34
55
89
144
233
377
*/
There are two different approaches with dynamic programming: top-down and bottom-up.
Top-down is like what it sounds: starting with a large problem, breaking it down to smaller components, memoizing them. It's what we just did with the fib
example.
Bottom-up is also like what it sounds: starting with the smallest subproblem, finding out a solution, and working our way up to the larger problem itself.
It also has an advantage: with the bottom-up approach, we don't need to store every previous value, we can only keep the two elements at the bottom so that we can use them to build up to our target.
With the bottom-up approach, our fib
function can look like this:
function fib(n: number) {
let dp = [0, 1];
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
However, note that we are keeping an array whose size will grow linearly as the input increases. So, we can do better with constant space complexity, not using an array at all:
function fib(n: number) {
if (n === 0 || n === 1) {
return n;
}
let a = 0;
let b = 1;
for (let i = 2; i <= n; i++) {
let tmp = a + b;
a = b;
b = tmp;
}
return b;
}
The time complexities for both the top-down and bottom-up approaches in the Fibonacci example are $O(n)$ as we solve each subproblem, each of which are of constant time.
However, when it comes to space complexity, the bottom-up approach (the second version) is $O(1)$.
The top-down approach has $O(n)$ space complexity because we store a Map
whose size will grow linearly as n
increases.
The first problem we're going to look at in this chapter is Climbing Stairs. Until then, happy coding.
]]>Let's start with the description for this problem:
There are a total of
numCourses
courses you have to take, labeled from0
tonumCourses - 1
. You are given an arrayprerequisites
whereprerequisites[i] = [a_i, b_i]
indicates that you must take course $b_i$ first if you want to take course $a_i$.
- For example, the pair
[0, 1]
, indicates that to take course0
you have to first take course1
.Return
true
if you can finish all courses. Otherwise, returnfalse
.
For example:
Input: numCourses = 2, prerequisites = [ [1, 0] ]
Output: true
Explanation: There are a total of 2 courses to take.
To take course 1 you should have finished course 0. So it is possible.
Or:
Input: numCourses = 2, prerequisites = [ [1, 0], [0, 1] ]
Output: false
Explanation: There are a total of 2 courses to take.
To take course 1 you should have finished course 0, and to take course 0 you should also have finished course 1. So it is impossible.
Also, we know from the constraints that all the pairs prerequisites[i]
are unique, and each $a_i$ and $b_i$ is in the range of numCourses
.
One thing that's clear is that each course is mapped to some number of prerequisite courses. If we can think of a course as a graph vertex (node), then it should have edges to all the courses that are its prerequisites. So, in a sense, it's a dependency graph.
We have seen ways to represent a graph, and one of the ideal choices is using an adjacency list. So, let's use it to map the courses to their prerequisites.
We're already given the number of courses, and we can use a Map which is perfect for the job. We'll first map each course to an empty array that will hold the prerequisites:
let adjList = new Map<number, number[]>();
// Each index corresponds to a course, and each course has an array of prerequisites
for (let i = 0; i < numCourses; i++) {
adjList.set(i, []);
}
Note that each course and prerequisite is in the range of numCourses
:
$$0 \leq a_i, b_i \lt numCourses$$
After we're done with initializing our adjacency list, we can add each prerequisite to its corresponding course:
for (const [course, prereq] of prerequisites) {
adjList.get(course)!.push(prereq);
}
Now, what we need to do is just go through each course, and see if each one of them can be completed. If so, we can return true
. But, if any of them can't be completed, we need to return false
.
So, we can do exactly that:
function canFinish(numCourses: number, prerequisites: number[][]): boolean {
...
for (let i = 0; i < numCourses; i++) {
if (!canBeCompleted(i)) {
return false;
}
}
return true;
}
It's fine so far, but how can we check if a course can be completed?
Since we're dealing with a graph, we need to do a graph traversal somehow, so it's time where we can use a depth-first search to help us with that.
Now, since DFS is going to be a recursive function, the first thing we need to consider is the base case(s).
Let's catch our breaths and think. When can a course be completed?
The answer is perhaps obvious: when there are no prerequisites to complete.
So, this is one base case for our DFS function canBeCompleted
:
// No prerequisites to complete (or, all prerequisites can be completed)
if (adjList.get(course)!.length === 0) {
return true;
}
This is nice, but from the examples given in the description, we also know that we should beware of cycles. So, we don't want to visit a node (a "course") that we have already visited. So, we can keep a visited
set, and if the course we're currently looking at is in it, we can to return false
because it means that the course can't be completed:
if (visited.has(course)) {
return false;
}
Once we've done that, we can mark the course as visited:
visited.add(course);
Now, we said that a course can be completed when there are no prerequisites to complete (or, all prerequisites can be completed).
So, if any prerequisite can't be completed, then the course itself can't be completed as well:
for (const prereq of adjList.get(course)!) {
if (!canBeCompleted(prereq)) {
return false;
}
}
Otherwise, once we're finished with the loop and have seen that the course can be completed, we can mark it as such by emptying the array in our map.
(This brilliant idea is thanks to NeetCode's video, which uses a slightly different version than this one):
// All prerequisites can be completed
adjList.set(course, []);
We can just return true
at this point.
Here's what the canBeCompleted
function looks like now:
function canBeCompleted(course: number) {
// No prerequisites to complete (or, all prerequisites can be completed)
if (adjList.get(course)!.length === 0) {
return true;
}
// Has cycle
if (visited.has(course)) {
return false;
}
visited.add(course);
for (const prereq of adjList.get(course)!) {
if (!canBeCompleted(prereq)) {
return false;
}
}
// All prerequisites can be completed
adjList.set(course, []);
return true;
}
Finally, here is the final solution in TypeScript:
function canFinish(numCourses: number, prerequisites: number[][]): boolean {
// Course: prerequisites to complete
let adjList = new Map<number, number[]>();
let visited = new Set<number>();
// Each index corresponds to a course, and each course has an array of prerequisites
for (let course = 0; course < numCourses; course++) {
adjList.set(course, []);
}
for (const [course, prereq] of prerequisites) {
adjList.get(course)!.push(prereq);
}
function canBeCompleted(course: number) {
// No prerequisites to complete (or, all prerequisites can be completed)
if (adjList.get(course)!.length === 0) {
return true;
}
// Has cycle
if (visited.has(course)) {
return false;
}
visited.add(course);
for (const prereq of adjList.get(course)!) {
if (!canBeCompleted(prereq)) {
return false;
}
}
// All prerequisites can be completed
adjList.set(course, []);
return true;
}
for (let i = 0; i < numCourses; i++) {
if (!canBeCompleted(i)) {
return false;
}
}
return true;
}
We're using a DFS function, visiting each vertex (node) and edge in the graph once, so the time complexity is $O(V + E)$ where $V$ is the number of vertices and $E$ is the number of edges.
The space complexity is also $O(V + E)$, as we keep an adjacency list. The storage requirement of it (also the visited
set) can grow as the size of our graph increases.
This was the last problem in this chapter. Next up, we'll take a look at dynamic programming. Until then, happy coding.
]]>The description for this problem states:
There is an
m x n
rectangular island that borders both the Pacific Ocean and Atlantic Ocean. The Pacific Ocean touches the island's left and top edges, and the Atlantic Ocean touches the island's right and bottom edges.The island is partitioned into a grid of square cells. You are given an
m x n
integer matrixheights
whereheights[r][c]
represents the height above sea level of the cell at coordinate(r, c)
.The island receives a lot of rain, and the rain water can flow to neighboring cells directly north, south, east, and west if the neighboring cell's height is less than or equal to the current cell's height. Water can flow from any cell adjacent to an ocean into the ocean.
Return a 2D list of grid coordinates
result
whereresult[i] = [ri, ci]
denotes that rain water can flow from cell(ri, ci)
to both the Pacific and Atlantic oceans.
For example:
Input: heights = [
[1, 2, 2, 3, 5],
[3, 2, 3, 4, 4],
[2, 4, 5, 3, 1],
[6, 7, 1, 4, 5],
[5, 1, 1, 2, 4]
]
Output: [[0, 4], [1, 3], [1, 4], [2, 2], [3, 0], [3, 1], [4, 0]]
Explanation: The following cells can flow to the Pacific and Atlantic oceans, as shown below:
[0, 4]: [0, 4] -> Pacific Ocean
[0, 4] -> Atlantic Ocean
[1, 3]: [1, 3] -> [0, 3] -> Pacific Ocean
[1, 3] -> [1, 4] -> Atlantic Ocean
[1, 4]: [1, 4] -> [1, 3] -> [0, 3] -> Pacific Ocean
[1, 4] -> Atlantic Ocean
[2, 2]: [2, 2] -> [1, 2] -> [0, 2] -> Pacific Ocean
[2, 2] -> [2, 3] -> [2, 4] -> Atlantic Ocean
[3, 0]: [3, 0] -> Pacific Ocean
[3, 0] -> [4, 0] -> Atlantic Ocean
[3, 1]: [3, 1] -> [3, 0] -> Pacific Ocean
[3, 1] -> [4, 1] -> Atlantic Ocean
[4, 0]: [4 ,0] -> Pacific Ocean
[4, 0] -> Atlantic Ocean
Note that there are other possible paths for these cells to flow to the Pacific and Atlantic oceans.
Although the description can be a challenge in itself to understand at first glance, what we need to do is essentially simple (at least, in theory). We want a cell whose neighbors are less than or equal to it, all the way to the north, south, east, and west until we reach both "oceans."
First, we can initialize two sets to store the cells that can reach "Pacific" and "Atlantic":
const reachableToPacific: Set<string> = new Set();
const reachableToAtlantic: Set<string> = new Set();
Instead of going cell by cell and checking if it can reach the oceans, we can first start by the cells that are adjacent to the oceans, and see which cells can reach us.
Since we're getting the cells that are reachable to oceans in different sets, we can return those that are in both sets (because we need to get those that are reachable to both oceans).
So, at the end, what we'll do is this:
for (const cell of reachableToPacific.values()) {
if (reachableToAtlantic.has(cell)) {
const [r, c] = cell.split(',');
result.push([+r, +c]);
}
}
We can use a breadth-first search to visit and mark the cells.
For the top and bottom edges of the grid, we'll mark the cells that can reach to Pacific and Atlantic:
for (let col = 0; col < colsLength; col++) {
bfs(0, col, reachableToPacific);
bfs(rowsLength - 1, col, reachableToAtlantic);
}
We can do the similar thing for left and right edges of the grid as well:
for (let row = 0; row < rowsLength; row++) {
bfs(row, 0, reachableToPacific);
bfs(row, colsLength - 1, reachableToAtlantic);
}
Now, on to the implementation of bfs
.
The purpose of our bfs
function is to mark the cells that can reach to an "ocean." So, it'll take three parameters: r
for row, c
for column, and reachableToOcean
for the set that stores the cells that are reachable.
As usual with BFS, we'll keep a queue that has arrays consisting of a row, a column, and the corresponding value in the grid:
let queue = [[r, c, heights[r][c]]];
As we go over the elements of queue
, we'll mark a row-column pair as reachable as long as that pair is not out of bounds, or we haven't already added it as reachable, or the value it has is greater than or equal to the previous "height" we've looked at.
While queue
is not empty, we'll first pop the current row, current column and previous height from queue
:
const [currentRow, currentCol, prevHeight] = queue.pop() as number[];
If one of the conditions we mentioned above is true, we want to continue with the next element in the queue. Otherwise, we'll add it to our set:
if (
isOutOfBounds(currentRow, currentCol) ||
reachableToOcean.has(`${currentRow},${currentCol}`) ||
heights[currentRow][currentCol] < prevHeight
) {
continue;
}
reachableToOcean.add(`${currentRow},${currentCol}`);
Then, we'll add the neighbors to the queue, as well as heights[currentRow][currentCol]
, which is going to be the "previous height" for the next element:
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [rowToGo, colToGo] of coords) {
queue.push([
currentRow + rowToGo,
currentCol + colToGo,
heights[currentRow][currentCol]
]);
}
And, that's it for the bfs
function:
function bfs(r: number, c: number, reachableToOcean: Set<string>) {
let queue = [[r, c, heights[r][c]]];
while (queue.length > 0) {
const [currentRow, currentCol, prevHeight] = queue.pop() as number[];
if (
isOutOfBounds(currentRow, currentCol) ||
reachableToOcean.has(`${currentRow},${currentCol}`) ||
heights[currentRow][currentCol] < prevHeight
) {
continue;
}
reachableToOcean.add(`${currentRow},${currentCol}`);
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [rowToGo, colToGo] of coords) {
queue.push([
currentRow + rowToGo,
currentCol + colToGo,
heights[currentRow][currentCol]
]);
}
}
}
Putting everything together, here is what our final solution looks like in TypeScript:
function pacificAtlantic(heights: number[][]): number[][] {
let result = [];
const rowsLength = heights.length;
const colsLength = heights[0].length;
const reachableToPacific: Set<string> = new Set();
const reachableToAtlantic: Set<string> = new Set();
function isOutOfBounds(r: number, c: number) {
return r < 0 || c < 0 || r >= rowsLength || c >= colsLength;
}
function bfs(r: number, c: number, reachableToOcean: Set<string>) {
let queue = [[r, c, heights[r][c]]];
while (queue.length > 0) {
const [currentRow, currentCol, prevHeight] = queue.pop() as number[];
if (
isOutOfBounds(currentRow, currentCol) ||
reachableToOcean.has(`${currentRow},${currentCol}`) ||
heights[currentRow][currentCol] < prevHeight
) {
continue;
}
reachableToOcean.add(`${currentRow},${currentCol}`);
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [rowToGo, colToGo] of coords) {
queue.push([
currentRow + rowToGo,
currentCol + colToGo,
heights[currentRow][currentCol]
]);
}
}
}
for (let col = 0; col < colsLength; col++) {
bfs(0, col, reachableToPacific);
bfs(rowsLength - 1, col, reachableToAtlantic);
}
for (let row = 0; row < rowsLength; row++) {
bfs(row, 0, reachableToPacific);
bfs(row, colsLength - 1, reachableToAtlantic);
}
for (const cell of reachableToPacific.values()) {
if (reachableToAtlantic.has(cell)) {
const [r, c] = cell.split(',');
result.push([+r, +c]);
}
}
return result;
}
The time complexity is $O(n * m)$ — where $n$ is the number of rows, and $m$ is the number of columns, as we're traversing the whole grid but making use of the Set data structure to avoid visiting the same cell.
The space complexity is—I think—also $O(n * m)$, again, where $n$ is the number of rows, and $m$ is the number of columns.
The size of our queue will grow proportionately to the size of the grid, and we also keep two sets, their sizes can also grow proportionately to the grid we're given.
The next and final problem we're going to look at in this chapter is Course Schedule. Until then, happy coding.
]]>Let's start with the description for Clone Graph:
Given a reference of a node in a connected undirected graph.
Return a deep copy (clone) of the graph.
Each node in the graph contains a value (
int
) and a list (List[Node]
) of its neighbors.class Node { public int val; public List<Node> neighbors; }
The description also indicates that the nodes are 1-indexed, and the graph is represented as an adjacency list. Also, we should return the copy of the given node.
For example:
Input: adjList = [[2, 4], [1, 3], [2, 4], [1, 3]]
Output: [[2, 4], [1, 3], [2, 4], [1, 3]]
Explanation: There are 4 nodes in the graph.
1st node (val = 1)'s neighbors are 2nd node (val = 2) and 4th node (val = 4).
2nd node (val = 2)'s neighbors are 1st node (val = 1) and 3rd node (val = 3).
3rd node (val = 3)'s neighbors are 2nd node (val = 2) and 4th node (val = 4).
4th node (val = 4)'s neighbors are 1st node (val = 1) and 3rd node (val = 3).
Our constraints are:
[0, 100]
.1 <= Node.val <= 100
Node.val
is unique for each node.This problem is, in a sense, just a graph traversal problem that happens to have the additional requirement of cloning the graph.
There are two essential ways to traverse a graph as we've seen before: with a depth-first search and a breadth-first search.
Since we shouldn't mess up the connections between the nodes, we can make use of a Map
to map the nodes in the original graph to their clones.
Let's first tackle it by using breadth-first search, after taking a deep breath.
The first thing to do is initialize our map:
const nodesMap = new Map<_Node, _Node>();
Now, we can create the clone of the node we're given, and map it to its clone:
let cloneNode = new _Node(node.val);
nodesMap.set(node, cloneNode);
As with many breadth-first search implementations, we can create a queue, which will initially hold the node we're given:
const queue = [node];
Now we can do the actual breadth-first search.
While our queue is not empty, we can iterate over the neighbors of the current node that we've dequeued (by using queue.shift()
), mapping each one to its clone, and adding it to the queue for further processing. And, the beautiful thing is, we don't have to create a whole new clone and add it to our queue
if that node is already in our map (because we have already "visited" it). We only want to do it if it's not in the map:
if (!nodesMap.has(neighbor)) {
nodesMap.set(neighbor, new _Node(neighbor.val));
queue.push(neighbor);
}
Once we map the neighbor to its clone and add it to queue
, we can now add the newly cloned neighbor to the neighbors of the clone node we're handling:
let cloneNode = nodesMap.get(currentNode!);
let cloneNeighbor = nodesMap.get(neighbor);
cloneNode!.neighbors.push(cloneNeighbor!);
The whole process looks like this:
while (queue.length > 0) {
let currentNode = queue.shift();
for (const neighbor of currentNode!.neighbors) {
if (!nodesMap.has(neighbor)) {
nodesMap.set(neighbor, new _Node(neighbor.val));
queue.push(neighbor);
}
let cloneNode = nodesMap.get(currentNode!);
let cloneNeighbor = nodesMap.get(neighbor);
cloneNode!.neighbors.push(cloneNeighbor!);
}
}
At the end of the function, we can just return the mapped clone of the node that we were given in the first place:
return nodesMap.get(node) as _Node;
Note that the return value of a Map
object can possibly be undefined
, so the TS compiler will warn us with nodesMap.get(node)
. The return value of our function can be a _Node
or null
, and we only want to return null
when the node we're given is null
:
if (node === null) {
return null;
}
So, we're already handling the case where node
can be null, and when we retrieve its mapped value from nodesMap
, we're confident that it will be a _Node
, so we're using a type assertion.
Finally, the cloneGraph
function looks like this:
/**
* Definition for _Node.
* class _Node {
* val: number
* neighbors: _Node[]
*
* constructor(val?: number, neighbors?: _Node[]) {
* this.val = (val === undefined ? 0 : val)
* this.neighbors = (neighbors === undefined ? [] : neighbors)
* }
* }
*
*/
function cloneGraph(node: _Node | null): _Node | null {
if (node === null) {
return null;
}
const nodesMap = new Map<_Node, _Node>();
let cloneNode = new _Node(node.val);
nodesMap.set(node, cloneNode);
const queue = [node];
while (queue.length > 0) {
let currentNode = queue.shift();
for (const neighbor of currentNode!.neighbors) {
if (!nodesMap.has(neighbor)) {
nodesMap.set(neighbor, new _Node(neighbor.val));
queue.push(neighbor);
}
let cloneNode = nodesMap.get(currentNode!);
let cloneNeighbor = nodesMap.get(neighbor);
cloneNode!.neighbors.push(cloneNeighbor!);
}
}
return nodesMap.get(node) as _Node;
}
The time complexity with this breadth-first search implementation is $O(V+E)$ where $V$ is the number of vertices (nodes), and $E$ is the number of edges, as we're traversing the whole graph.
The storage needs for the cloned nodes and nodesMap
will grow linearly as the number of nodes in the graph increases, so the space complexity is $O(n)$ where $n$ is the total number of nodes in the graph.
We can also use depth-first search to solve this problem, as also shown by NeetCode.
Our nodesMap
will also be here to map the nodes to their clones:
const nodesMap = new Map<_Node, _Node>();
The dfs
function will be recursive, and as with all recursive functions, the first thing that we should be thinking about is the base case(s).
A perhaps obvious one is when the given current node is null
— in that case, we can return null
:
if (currentNode === null) {
return null;
}
The whole dfs
function will eventually return the cloned graph itself (it will return the cloned node of the node we're given).
So, if the node we're looking at is in our map (that we have "visited" it), we can simply return the cloned version of it:
if (nodesMap.has(currentNode)) {
return nodesMap.get(currentNode);
}
Otherwise, we can create the clone node and set it in our map accordingly:
let cloneNode = new _Node(currentNode.val);
nodesMap.set(currentNode, cloneNode);
The only thing left is to add the neighbors of currentNode
to the neighbors of cloneNode
.
Since dfs
will be returning the cloned node of a given node, for each neighbor, we can just get its clone with dfs
and add it to cloneNode.neighbors
:
for (const neighbor of currentNode.neighbors) {
cloneNode.neighbors.push(dfs(neighbor)!);
}
The final solution with DFS looks like this:
/**
* Definition for _Node.
* class _Node {
* val: number
* neighbors: _Node[]
*
* constructor(val?: number, neighbors?: _Node[]) {
* this.val = (val === undefined ? 0 : val)
* this.neighbors = (neighbors === undefined ? [] : neighbors)
* }
* }
*
*/
function dfs(currentNode: _Node | null, nodesMap: Map<_Node, _Node>) {
if (currentNode === null) {
return null;
}
if (nodesMap.has(currentNode)) {
return nodesMap.get(currentNode);
}
let cloneNode = new _Node(currentNode.val);
nodesMap.set(currentNode, cloneNode);
for (const neighbor of currentNode.neighbors) {
cloneNode.neighbors.push(dfs(neighbor, nodesMap)!);
}
return cloneNode;
}
function cloneGraph(node: _Node | null): _Node | null {
const nodesMap = new Map<_Node, _Node>();
return dfs(node, nodesMap) as _Node;
}
Similar to the BFS version, the time complexity is $O(V + E)$ where $V$ is the number of vertices (nodes), and $E$ is the number of edges.
The space complexity will be $O(n)$ as well, where $n$ is the number of nodes as we're keeping nodesMap
to store all the nodes.
Next up is the problem called Pacific Atlantic Water Flow. Until then, happy coding.
]]>Let's start with the description for this problem:
Given an
m x n
2D binary gridgrid
which represents a map of'1'
s (land) and'0'
s (water), return the number of islands.An island is surrounded by water and is formed by connecting adjacent lands horizontally or vertically. You may assume all four edges of the grid are all surrounded by water.
For example:
Input: grid = [
['1', '1', '1', '1', '0'],
['1', '1', '0', '1', '0'],
['1', '1', '0', '0', '0'],
['0', '0', '0', '0', '0'],
]
Output: 1
Or:
Input: grid = [
['1', '1', '0', '0', '0'],
['1', '1', '0', '0', '0'],
['0', '0', '1', '0', '0'],
['0', '0', '0', '1', '1'],
]
Output: 3
This one is slightly in the spirit of the Word Search problems that we've looked at before.
We need to gather all the cells with the value '1'
as "islands" and count them up. One simple idea is that starting from a cell with the value '1'
, we can run a depth-first search to see how many '1'
-valued cells are nearby. Once we reach a boundary, we can update our count of islands and return from our DFS function.
Before we start doing that, the very first thing to do is to check if we have a grid at all. In that case, we wouldn't have any "islands," so we can return 0
:
if (!grid.length) {
return 0;
}
We're going to loop over all the cells, so, first we can keep the length of the rows and columns in variables rowsLength
and colsLength
:
const rowsLength = grid.length;
const colsLength = grid[0].length;
Then, we can initialize a set called visited
to mark the cells as "visited" as we go. We need to get the row and column numbers inside this set (for example, i
and j
for the cell grid[i][j]
), but it's a bit tricky when it comes to JavaScript/TypeScript. The reason is that since arrays are also objects, checking for the existence of an array in a set will be meaningless, as the array we're comparing will be a different object in memory. For example, we can do something like this:
let a = [1, 2];
let aSet = new Set();
aSet.add(a);
// -> Set(1) { [ 1, 2 ] }
But, checking for the existence of what seems to be the "same" array returns false
:
aSet.has([1, 2]);
// -> false
>>> n = (1, 2)
>>> n_set = set()
>>> n_set.add(n)
>>> n_set
{(1, 2)}
>>> (1, 2) in n_set
True
However, things are a bit different in JavaScript/TypeScript land. For more information, see this Stack Overflow thread.
For that reason, we can use strings to add the coordinate of a cell into our visited
set. We'll first initialize it as empty for now:
const visited: Set<string> = new Set();
We'll also keep an islandCount
variable to return the number of islands at the end:
let islandCount = 0;
Now we can simply go through each cell; if it's marked as '1'
and we haven't visited it yet, we can run dfs
from that cell onwards, and update our islandCount
:
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
if (grid[i][j] === '1' && !visited.has(`${i},${j}`)) {
dfs(i, j);
islandCount++;
}
}
}
But, how can we write the dfs
function? First, perhaps, by taking a deep breath.
Let's think about the base case for our dfs
function.
If the cell we're looking at is out of bounds or it's marked as '0'
, or we have already visited it, we can simply return because we don't want to look further:
if (outOfBounds(currentRow, currentCol) ||
grid[currentRow][currentCol] === '0' ||
visited.has(`${currentRow},${currentCol}`)) {
return;
}
Otherwise, we'll mark that cell as visited first:
visited.add(`${currentRow},${currentCol}`);
Then, for each direction from that cell, we'll run dfs
itself:
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
dfs(rowToGo, colToGo);
}
And, that's about it. This is what dfs
looks like now:
function dfs(currentRow: number, currentCol: number) {
if (outOfBounds(currentRow, currentCol) ||
grid[currentRow][currentCol] === '0' ||
visited.has(`${currentRow},${currentCol}`)) {
return;
}
visited.add(`${currentRow},${currentCol}`);
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
dfs(rowToGo, colToGo);
}
}
The final solution looks like this:
function numIslands(grid: string[][]): number {
if (!grid.length) {
return 0;
}
const rowsLength = grid.length;
const colsLength = grid[0].length;
const visited: Set<string> = new Set();
let islandCount = 0;
function outOfBounds(r: number, c: number) {
return r < 0 || c < 0 || r >= rowsLength || c >= colsLength;
}
function dfs(currentRow: number, currentCol: number) {
if (outOfBounds(currentRow, currentCol) ||
grid[currentRow][currentCol] === '0' ||
visited.has(`${currentRow},${currentCol}`)) {
return;
}
visited.add(`${currentRow},${currentCol}`);
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
dfs(rowToGo, colToGo);
}
}
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
if (grid[i][j] === '1' && !visited.has(`${i},${j}`)) {
dfs(i, j);
islandCount++;
}
}
}
return islandCount;
}
The time complexity for this solution will be $O(n * m)$ where $n$ is the number of rows and $m$ is the number of columns, as we're visiting each cell with a nested loop. Since we're marking the cells as visited as we go, the dfs
function will have a lesser contribution to the time complexity than looping over the whole grid.
The space complexity is, I think, $O(n * m)$ where $n$ is the number of rows and $m$ is the number of columns, as in the worst case the recursion stack can grow proportionately to the size of the grid.
There is also a breadth-first solution as shown by NeetCode that looks very similar to what we did with the depth-first search version.
As usual with BFS, we'll keep a queue to add the row and column indices of neighboring cells:
let queue: [number, number][] = [];
Then, we'll immediately mark the cell as visited and add it to queue
:
visited.add(`${currentRow},${currentCol}`);
queue.push([currentRow, currentCol]);
While we still have neighboring cells to look at (queue.length > 0
), we can add the ones we want to visit to our queue
, and mark them as visited. It's very similar to what we did with dfs
:
while (queue.length > 0) {
let [currentRow, currentCol] = queue.shift() as [number, number];
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
if (!outOfBounds(rowToGo, colToGo) &&
grid[rowToGo][colToGo] === '1' &&
!visited.has(`${rowToGo},${colToGo}`)
) {
queue.push([rowToGo, colToGo]);
visited.add(`${rowToGo},${colToGo}`);
}
}
}
That's pretty much it for the bfs
function:
function bfs(currentRow: number, currentCol: number) {
let queue: [number, number][] = [];
visited.add(`${currentRow},${currentCol}`);
queue.push([currentRow, currentCol]);
while (queue.length > 0) {
let [currentRow, currentCol] = queue.shift() as [number, number];
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
if (!outOfBounds(rowToGo, colToGo) &&
grid[rowToGo][colToGo] === '1' &&
!visited.has(`${rowToGo},${colToGo}`)
) {
queue.push([rowToGo, colToGo]);
visited.add(`${rowToGo},${colToGo}`);
}
}
}
}
And, the final version looks like this:
function numIslands(grid: string[][]): number {
if (!grid.length) {
return 0;
}
const rowsLength = grid.length;
const colsLength = grid[0].length;
const visited: Set<string> = new Set();
let islandCount = 0;
function outOfBounds(r: number, c: number) {
return r < 0 || c < 0 || r >= rowsLength || c >= colsLength;
}
function bfs(currentRow: number, currentCol: number) {
let queue: [number, number][] = [];
visited.add(`${currentRow},${currentCol}`);
queue.push([currentRow, currentCol]);
while (queue.length > 0) {
let [currentRow, currentCol] = queue.shift() as [number, number];
// up, down, left, right
const coords = [[-1, 0], [1, 0], [0, -1], [0, 1]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
if (!outOfBounds(rowToGo, colToGo) &&
grid[rowToGo][colToGo] === '1' &&
!visited.has(`${rowToGo},${colToGo}`)
) {
queue.push([rowToGo, colToGo]);
visited.add(`${rowToGo},${colToGo}`);
}
}
}
}
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
if (grid[i][j] === '1' && !visited.has(`${i},${j}`)) {
bfs(i, j);
islandCount++;
}
}
}
return islandCount;
}
The time complexity is again $O(n * m)$ for this version, where $n$ is the number of rows and $m$ is the number of columns, as we go through each cell using a nested for
loop. As the length of queue
doesn't substantially grow, I think the bfs
function inside won't have a huge influence on the time complexity.
The space complexity can also be $O(n * m)$ as in the worst case we have all the cells as '1'
and have to store all of them in visited
.
Next up is the second problem in this chapter, Clone Graph. Until then, happy coding.
]]>
A graph is probably the data structure that everyone is familiar with, regardless of their profession or interests.
Graph theory is a very broad topic, but we'll simply look at some of the main ingredients of what makes a graph and how to represent it, as well as basic graph traversals.
In a graph, there are two main components: vertices (or nodes) and edges that connect those vertices.
The terms "adjacent vertices" and "neighbors" are used interchangeably as well.
A graph can be directed or undirected. With a directed edge, we have an origin and a destination vertex. On the other hand, an undirected edge is bidirectional, origin and destination are not fixed.
A graph can also be weighted or unweighted, each edge can have different weights, usually representing the cost of going from one vertex to the other.
We can define a graph like this:
$$G = (V, \ E)$$
$V$ is a set of vertices, and $E$ is a set of edges.
For example, if we have a directed graph like this:
Then, we have the vertices:
$$V = {A, \ B, \ C, \ D}$$
And, the edges are:
$$E = {(A, \ B), \ (A, \ C), \ (C, \ B), \ (C, \ D), \ (D, \ C)}$$
If we have an undirected graph such as this one:
We have the same vertices:
$$V = {A, \ B, \ C, \ D}$$
But our edges can look like this:
$$E = {{B, \ A}, {A, \ C}, {C, \ B}, {D, \ C}}$$
When two vertices share an edge, they are adjacent to each other. The degree of a vertex is the number of adjacent vertices to it. We can also define the degree as the number of edges coming out of the vertex.
For example, in the above image, the vertex A has the degree of 2.
A simple path is the one that we don't repeat any vertices while traversing the graph.
An example might look like this:
A cycle is a simple path, except that we end up at the vertex we started with:
When it comes to representing graphs, there are several ways to do it, and we'll look at three of them: an edge list, an adjacency matrix, and an adjacency list.
We can simply put all the edges in an array:
[ [A, B], [A, C], [B, C], [C, D] ]
However, to find an edge in an edge list, we'll have to iterate through them, so it will have $O(E)$ time complexity, where in the worst case, we'll search the whole list to find an edge. Similarly, it needs $O(E)$ amount of space to represent all the edges.
The adjacency matrix for our example might look like this:
$$\left\lceil\begin{matrix}& A & B & C & D \A & 0 & 1 & 1 & 0 \B & 1 & 0 & 1 & 0 \C & 1 & 1 & 0 & 1 \D & 0 & 0 & 1 & 0\end{matrix}\right\rceil$$
Each row is for a vertex, and the matching column shows the relationship between those vertices. For example, the vertex A doesn't have an edge pointing to D, so the cell that matches A and D is 0. On the other hand, A is connected to B and C, so those cells have the value 1.
Let's try implementing it in TypeScript.
We'll start with a minimal graph vertex:
class GraphVertex {
public value: string | number;
constructor(value: string | number) {
this.value = value;
}
}
Now we can define our graph. We'll make it really simple with three properties to hold: matrix
to represent the graph as an adjacency matrix, vertices
to hold vertices, and isDirected
to indicate whether our graph is directed:
class Graph {
public matrix: number[][];
public vertices: GraphVertex[];
public isDirected: boolean;
constructor(vertices: GraphVertex[], isDirected = true) {
this.vertices = vertices;
this.isDirected = isDirected;
...
}
...
}
Initializing our adjacency matrix might look like this:
this.matrix = Array.from({ length: vertices.length }, () => {
return Array.from({ length: vertices.length }, () => 0)
});
We'll have an array with the length of vertices, each item in the array is an array with the length of vertices as well, but filled with zeroes.
In our example with four vertices, the initial adjacency matrix looks like this:
[ [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0] ]
Then, adding an edge is just marking the corresponding value as 1
, so that we can represent a connection between two vertices:
this.matrix[this.vertices.indexOf(v1)][this.vertices.indexOf(v2)] = 1;
if (!this.isDirected) {
this.matrix[this.vertices.indexOf(v2)][this.vertices.indexOf(v1)] = 1;
}
Removing an edge, in this case, will be just resetting the value to 0
:
this.matrix[this.vertices.indexOf(v1)][this.vertices.indexOf(v2)] = 0;
And, checking for the existence of an edge is simply checking whether the corresponding value is 0
or not:
this.matrix[this.vertices.indexOf(v1)][this.vertices.indexOf(v2)] !== 0;
And, here is the whole example:
class Graph {
public matrix: number[][];
public vertices: GraphVertex[];
public isDirected: boolean;
constructor(vertices: GraphVertex[], isDirected = true) {
this.vertices = vertices;
this.matrix = Array.from({ length: vertices.length }, () => {
return Array.from({ length: vertices.length }, () => 0)
});
this.isDirected = isDirected;
}
addEdge(v1: GraphVertex, v2: GraphVertex) {
this._checkVertexIsInGraph(v1);
this._checkVertexIsInGraph(v2);
this.matrix[this.vertices.indexOf(v1)][this.vertices.indexOf(v2)] = 1;
if (!this.isDirected) {
this.matrix[this.vertices.indexOf(v2)][this.vertices.indexOf(v1)] = 1;
}
}
/*
For a weighted graph:
addEdge(v1: GraphVertex, v2: GraphVertex, weight: number) {
this._checkVertexIsInGraph(v1);
this._checkVertexIsInGraph(v2);
this.matrix[this.vertices.indexOf(v1)][this.vertices.indexOf(v2)] = weight;
if (!this.isDirected) {
this.matrix[this.vertices.indexOf(v2)][this.vertices.indexOf(v1)] = weight;
}
}
*/
removeEdge(v1: GraphVertex, v2: GraphVertex) {
this._checkVertexIsInGraph(v1);
this._checkVertexIsInGraph(v2);
this.matrix[this.vertices.indexOf(v1)][this.vertices.indexOf(v2)] = 0;
if (!this.isDirected) {
this.matrix[this.vertices.indexOf(v2)][this.vertices.indexOf(v1)] = 0;
}
}
hasEdge(v1: GraphVertex, v2: GraphVertex) {
this._checkVertexIsInGraph(v1);
this._checkVertexIsInGraph(v2);
return this.matrix[this.vertices.indexOf(v1)][this.vertices.indexOf(v2)] !== 0;
}
getAdjacencyMatrix() {
return this.matrix;
}
_checkVertexIsInGraph(v: GraphVertex) {
if (!this.vertices.includes(v)) {
throw new Error('Vertex doesn\'t exist');
}
}
}
let a = new GraphVertex('A');
let b = new GraphVertex('B');
let c = new GraphVertex('C');
let d = new GraphVertex('D');
let graph = new Graph([a, b, c, d], false);
graph.addEdge(a, b);
graph.addEdge(a, c);
graph.addEdge(b, c);
graph.addEdge(c, d);
console.log(graph.getAdjacencyMatrix());
// -> [ [0, 1, 1, 0], [1, 0, 1, 0], [1, 1, 0, 1], [0, 0, 1, 0] ]
Operations on an adjacency matrix has $O(1)$ time complexity; however, our storage needs will be $O(V^2)$ where $V$ is the number of vertices.
In an adjacency list, usually a hashmap or an array of linked lists is used. For example:
let graph = {
'A': ['B', 'C'],
'B': ['A', 'C'],
'C': ['A', 'B', 'D'],
'D': ['C']
}
Let's see how we can modify our code above to use an adjacency list instead.
Instead of having a matrix
which is an array of arrays, we can have a Map
that maps the vertices to an array of their neighbors.
We can initialize it as a map that has the vertices as keys, each of which has a value of an empty array for now:
this.list = new Map<GraphVertex, GraphVertex[]>();
for (const v of vertices) {
this.list.set(v, []);
}
Adding an edge will be just pushing to the array of corresponding vertex:
this.list.get(v1)!.push(v2);
if (!this.isDirected) {
this.list.get(v2)!.push(v1);
}
Removing an edge will be deleting that vertex from the array:
this.list.set(v1, this.list.get(v1)!.filter(v => v !== v2));
Checking if an edge exists is just checking the existence of that vertex in the array:
this.list.get(v1)!.includes(v2);
Here is our graph:
class Graph {
public list: Map<GraphVertex, GraphVertex[]>;
public vertices: GraphVertex[];
public isDirected: boolean;
constructor(vertices: GraphVertex[], isDirected = true) {
this.vertices = vertices;
this.list = new Map();
for (const v of vertices) {
this.list.set(v, []);
}
this.isDirected = isDirected;
}
addEdge(v1: GraphVertex, v2: GraphVertex) {
this._checkVertexIsInGraph(v1);
this._checkVertexIsInGraph(v2);
this.list.get(v1)!.push(v2);
if (!this.isDirected) {
this.list.get(v2)!.push(v1);
}
}
removeEdge(v1: GraphVertex, v2: GraphVertex) {
this._checkVertexIsInGraph(v1);
this._checkVertexIsInGraph(v2);
this.list.set(v1, this.list.get(v1)!.filter(v => v !== v2));
if (!this.isDirected) {
this.list.set(v2, this.list.get(v2)!.filter(v => v !== v1));
}
}
hasEdge(v1: GraphVertex, v2: GraphVertex) {
this._checkVertexIsInGraph(v1);
this._checkVertexIsInGraph(v2);
return this.list.get(v1)!.includes(v2);
}
getAdjacencyList() {
return this.list;
}
_checkVertexIsInGraph(v: GraphVertex) {
if (!this.vertices.includes(v)) {
throw new Error('Vertex doesn\'t exist');
}
}
}
let a = new GraphVertex('A');
let b = new GraphVertex('B');
let c = new GraphVertex('C');
let d = new GraphVertex('D');
let graph = new Graph([a, b, c, d], false);
graph.addEdge(a, b);
graph.addEdge(a, c);
graph.addEdge(b, c);
graph.addEdge(c, d);
console.log(graph.getAdjacencyList());
/* Output:
Map (4) {
GraphVertex: { "value": "A" } => [
GraphVertex: { "value": "B" },
GraphVertex: { "value": "C" }
],
GraphVertex: { "value": "B" } => [
GraphVertex: { "value": "A" },
GraphVertex: { "value": "C" }
],
GraphVertex: { "value": "C" } => [
GraphVertex: { "value": "A" },
GraphVertex: { "value": "B" },
GraphVertex: { "value": "D" }
],
GraphVertex: { "value": "D" } => [
GraphVertex: { "value": "C" }
]
}
*/
Getting the neighbors of a vertex is $O(1)$ because we're just looking up a key in a map. However, finding a particular edge can be $O(d)$ where $d$ is the number of degrees of the vertex, because we might need to traverse all the neighbors to find it. And, it could be $V - 1$ where $V$ is the number of vertices in the graph. It's the case when that vertex has all the other vertices as its neighbor.
The space complexity can be $O(V + E)$ where $V$ is the number of vertices and $E$ is the number of edges.
Continuing with the adjacency list representation, let's now take a look at two (very familiar!) ways to traverse a graph: breadth-first search and depth-first search.
But first, we'll modify our graph a little bit. We'll add a new vertex 'E'
and update some edges:
let a = new GraphVertex('A');
let b = new GraphVertex('B');
let c = new GraphVertex('C');
let d = new GraphVertex('D');
let e = new GraphVertex('E');
let graph = new Graph([a, b, c, d, e], false);
graph.addEdge(a, b);
graph.addEdge(a, c);
graph.addEdge(b, d);
graph.addEdge(c, e);
The important idea to remember is that there is no hierarchy of vertices, so we don't have a root node.
For a breadth-first or depth-first search, we can use an arbitrary node as a starting point.
With our new graph, a breadth-first search traversal looks like this:
When it comes to breadth-first search, usually a queue is used, and the idea is simple: given a current node, we'll add the adjacent nodes first, marking them as visited as we go.
Inside the Graph
class, we can implement a bfs
method that does just that:
bfs(startNode: GraphVertex) {
const visited = new Set();
const queue = [startNode];
visited.add(startNode);
while (queue.length > 0) {
const currentNode = queue.shift();
// console.log(currentNode);
this.list.get(currentNode as GraphVertex)!.forEach((node) => {
if (!visited.has(node)) {
visited.add(node);
queue.push(node);
}
});
}
}
If we log currentNode
to console each time we go, it's as we expected:
GraphVertex { value: 'A' }
GraphVertex { value: 'B' }
GraphVertex { value: 'C' }
GraphVertex { value: 'D' }
GraphVertex { value: 'E' }
With the adjacency list, using a BFS has $O(V + E)$ time complexity (sum of the vertices and edges) as we're traversing the whole graph.
With the same modified graph, a depth-first search looks like this:
With depth-first search there is usually recursion involved as we're traversing through a path until we have visited all the nodes in that path. Once we hit a dead end, we'll backtrack and continue exploring until we have visited all the vertices in the graph.
dfs(startNode: GraphVertex, visited = new Set()) {
visited.add(startNode);
// console.log(startNode);
this.list.get(startNode)!.forEach((node) => {
if (!visited.has(node)) {
this.dfs(node, visited);
}
});
}
Starting with a node, we check how deep we can go from there. Once we reach a dead end (when the dfs
inside forEach
returns), we continue checking other neighbors (with forEach
) until none is left. We essentially do the same thing until all the vertices are visited.
Logging the output matches our expectation:
GraphVertex { value: 'A' }
GraphVertex { value: 'B' }
GraphVertex { value: 'D' }
GraphVertex { value: 'C' }
GraphVertex { value: 'E' }
The time complexity for a depth-first search traversal of a graph is the similar to BFS, $O(V + E)$.
The first problem we'll look at in this chapter is Number of Islands. Until then, happy coding.
Let's start with the description for Word Search II:
Given an
m x n
board
of characters and a list of stringswords
, return all words on the board.Each word must be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once in a word.
For example:
Input: board = [
['o', 'a', 'a', 'n'],
['e', 't', 'a', 'e'],
['i', 'h', 'k', 'r'],
['i', 'f', 'l', 'v'],
], words = ['oath', 'pea', 'eat', 'rain']
Output: ['eat', 'oath']
Or:
Input: board = [
['a', 'b'],
['c', 'd']
], words = ['abcb']
Output: []
Also, our constraints are:
m == board.length
n == board[i].length
1 <= m, n <= 12
board[i][j]
is a lowercase English letter.1 <= words.length <= 3 * 10^4
1 <= words[i].length <= 10
words[i]
consists of lowercase English letters.words
are unique.We've seen the first iteration of this problem where we needed to search for only one word.
It's easy to think that, well, we can just loop over the words this time, and return those that our board has. Simple as that.
For example, if you remember the exist
function (which uses depth-first search) that we implemented in the previous version of this problem, you might think that it's easy to do this:
function findWords(board: string[][], words: string[]): string[] {
let result = [];
for (const word of words) {
if (exist(board, word)) {
result.push(word);
}
}
return result;
}
However, this is going to be a terrible approach with a runtime of probably $O(\text{length of rows } * \text{length of columns } * 4^{\text{length of the word }} * \text{ number of words})$.
If we try that, we'll treat ourselves a good old Time Limit Exceeded error in one of the test cases. So, we need to find another way to solve this problem — which means it's time to take a deep breath.
Instead of going through each word in words
and looking for it in board
, we can look through board
first. If the character we're looking at is in words
, then we'll continue searching through its directions until we find the complete word (or not).
Because we're doing a character lookup (or basically, a prefix search), trie is going to be an efficient choice of data structure here.
Let's start with creating our simple trie node which has children
, and a flag isEndOfWord
to mark it as the end of the word character:
class TrieNode {
public children: Map<string, TrieNode>;
public isEndOfWord: boolean;
constructor() {
this.children = new Map();
this.isEndOfWord = false;
}
}
Then, we'll create our trie, but for now, we'll only have an addWord
method. This is exactly what we've seen for the last two problems, so it's easy:
class Trie {
public root: TrieNode;
constructor() {
this.root = new TrieNode();
}
addWord(word: string) {
let currentNode = this.root;
for (const char of word) {
if (!currentNode.children.has(char)) {
currentNode.children.set(char, new TrieNode());
}
currentNode = currentNode.children.get(char) as TrieNode;
}
currentNode.isEndOfWord = true;
}
}
Traversing each character in word
, we add it to our trie, updating the current node (which starts as our root node, of course) as we go. Once we reach the last character, we mark it as the end of the word.
Alternatively, we could've also used a non-null assertion operator that asserts values as non null
or undefined
, like this:
currentNode = currentNode.children.get(char)!;
Now, the first thing to do if we want to look up the words in our trie is... to add them to our trie, of course!
Inside the findWords
function, we can do that easily:
let trie = new Trie();
for (const word of words) {
trie.addWord(word);
}
We'll also have a result
array to add the words that are in board
:
let result: string[] = [];
This array will be modified by the function that does the depth-first search, so that at the end of our main function findWords
, we can just return it.
For each cell, we'll run a depth-first search if that character is the start of a word in words
:
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
if (trie.root.children.has(board[i][j])) {
dfs(i, j, trie.root.children.get(board[i][j]) as TrieNode, []);
}
}
}
So, if board[i][j]
(which is the current character) is the first character of a word in words
, we'll run dfs
, passing it the arguments of the current row and column, as well as the next character (trie.root.children.get(board[i][j])
). We'll also pass it an empty array to keep track of the path we're exploring.
Now let's look at the dfs
function itself.
The first thing we need to do is to add the current character (the current cell) to our path, and mark it as "visited." We can mark it with an asterisk (*
) to do that:
let currentChar = board[currentRow][currentCol];
path.push(currentChar);
board[currentRow][currentCol] = '*';
Now, if the current node we're looking at is the end character of a word, that means our path
consists of all the letters of a word in words
, so we can add it to result
as a string. After that, we'll mark that node as not the end of a word, because in our next iterations, that node might not be the end character of another word:
if (currentNode.isEndOfWord) {
result.push(path.join(''));
currentNode.isEndOfWord = false;
}
From that current cell, we'll look at all the directions we can go as long as we stay within the bounds of board
, and that next character is the next character in that word (if it's a child node of the current node):
// Coordinations to go right, left, down, and up
let coords = [[0, 1], [0, -1], [1, 0], [-1, 0]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
if (!outOfBounds(rowToGo, colToGo) && currentNode.children.has(board[rowToGo][colToGo])) {
dfs(
rowToGo,
colToGo,
currentNode.children.get(board[rowToGo][colToGo]) as TrieNode,
path
);
}
}
Once we have done exploring our options, we need to backtrack, so we need to pop the last character from our path
and reset the cell to its original character:
path.pop();
board[currentRow][currentCol] = currentChar;
And, that's pretty much it for the dfs
function:
function dfs(currentRow: number, currentCol: number, currentNode: TrieNode, path: string[]) {
let currentChar = board[currentRow][currentCol];
path.push(currentChar);
board[currentRow][currentCol] = '*';
// If we find a word, we'll add it to result, and
// mark that node as not the end character
// because it might be in another word
if (currentNode.isEndOfWord) {
result.push(path.join(''));
currentNode.isEndOfWord = false;
}
// Coordinations to go right, left, down, and up
let coords = [[0, 1], [0, -1], [1, 0], [-1, 0]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
if (!outOfBounds(rowToGo, colToGo) && currentNode.children.has(board[rowToGo][colToGo])) {
dfs(
rowToGo,
colToGo,
currentNode.children.get(board[rowToGo][colToGo]) as TrieNode,
path
);
}
}
path.pop();
board[currentRow][currentCol] = currentChar;
}
And, the whole solution looks like this:
class TrieNode {
public children: Map<string, TrieNode>;
public isEndOfWord: boolean;
constructor() {
this.children = new Map();
this.isEndOfWord = false;
}
}
class Trie {
public root: TrieNode;
constructor() {
this.root = new TrieNode();
}
addWord(word: string) {
let currentNode = this.root;
for (const char of word) {
if (!currentNode.children.has(char)) {
currentNode.children.set(char, new TrieNode());
}
currentNode = currentNode.children.get(char) as TrieNode;
}
currentNode.isEndOfWord = true;
}
}
function findWords(board: string[][], words: string[]): string[] {
const rowsLength = board.length;
const colsLength = board[0].length;
function outOfBounds(r: number, c: number) {
return r < 0 || c < 0 || r >= rowsLength || c >= colsLength;
}
let result: string[] = [];
let trie = new Trie();
for (const word of words) {
trie.addWord(word);
}
function dfs(currentRow: number, currentCol: number, currentNode: TrieNode, path: string[]) {
let currentChar = board[currentRow][currentCol];
path.push(currentChar);
board[currentRow][currentCol] = '*';
// If we find a word, we'll add it to result, and
// mark that node as not the end character
// because it might be in another word
if (currentNode.isEndOfWord) {
result.push(path.join(''));
currentNode.isEndOfWord = false;
}
// Coordinations to go right, left, down, and up
let coords = [[0, 1], [0, -1], [1, 0], [-1, 0]];
for (const [r, c] of coords) {
let [rowToGo, colToGo] = [currentRow + r, currentCol + c];
if (!outOfBounds(rowToGo, colToGo) && currentNode.children.has(board[rowToGo][colToGo])) {
dfs(
rowToGo,
colToGo,
currentNode.children.get(board[rowToGo][colToGo]) as TrieNode,
path
);
}
}
path.pop();
board[currentRow][currentCol] = currentChar;
}
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
if (trie.root.children.has(board[i][j])) {
dfs(i, j, trie.root.children.get(board[i][j]) as TrieNode, []);
}
}
}
return result;
}
The time complexity of findWords
can be, in the worst case, $O(m * n * w)$ where $m$ is the length of rows, $n$ is the length of columns, and $w$ is the total number of words — because we might explore all the cells searching for each word.
For the space complexity, first, we have our trie whose storage needs will grow as the total number of characters in words
grow. We can say that it's $O(s)$ where $s$ is the number of all characters in words
. We also store a path
array in our depth-first search, in the worst case where we need to store every unique cell, we'll end up storing the whole board, so it can have $O(m * n)$ space complexity where $m$ is the length of rows and $n$ is the length of columns.
Combining them together, I think, the space complexity might end up being $O(s + m * n)$.
If some of the parts still doesn't make sense, that's okay. This is a very, very tough problem, and honestly, backtracking is one of the most challenging concepts that's somewhat easy to wrap your mind around theoretically, but not so easy in practice.
Now that we're done with this chapter as well, it's time for another deep breath.
Next up, we'll take a look at the graph data structure. Until then, happy coding.
Let's start with the description for Design Add and Search Words Data Structure:
Design a data structure that supports adding new words and finding if a string matches any previously added string.
Implement the
WordDictionary
class:
WordDictionary()
Initializes the object.void addWord(word)
Addsword
to the data structure, it can be matched later.bool search(word)
Returnstrue
if there is any string in the data structure that matchesword
orfalse
otherwise.word
may contain dots'.'
where dots can be matched with any letter.
For example:
WordDictionary wordDictionary = new WordDictionary();
wordDictionary.addWord('bad');
wordDictionary.addWord('dad');
wordDictionary.addWord('mad');
wordDictionary.search('pad'); // return False
wordDictionary.search('bad'); // return True
wordDictionary.search('.ad'); // return True
wordDictionary.search('b..'); // return True
We also have some constraints:
1 <= word.length <= 25
word
in addWord
consists of lowercase English letters.word
in search
consists of '.'
or lowercase English letters.2
dots in word
for search
queries.10^4
calls will be made to addWord
and search
.Since we're dealing with words, especially storing and searching a lot of words, the trie data structure can be efficient to use here.
Adding words is easy — in fact, we've seen how to insert a word into a trie in the previous problem.
However, searching seems to be a bit more challenging since we have to do something similar to a regex search, using the dot character as a wildcard.
Before that, let's take a deep breath, and start with creating a simple trie node.
A simple trie node might look like this:
class TrieNode {
public children: Map<string, TrieNode>;
public isEndOfWord: boolean;
constructor() {
this.children = new Map();
this.isEndOfWord = false;
}
}
Our TrieNode
class has children
that is a Map
with string
s as keys, and TrieNode
s as values.
It also has an isEndOfWord
flag to mark the node as the end character of a word.
The WordDictionary
class is going to be a trie, so we can initialize our root node in the constructor
:
class WordDictionary {
public root: TrieNode;
constructor() {
this.root = new TrieNode();
}
...
}
Adding a word is the exact same thing we did in insert
function of the previous problem.
We'll traverse each character, and one by one, add it to our trie. We'll create a currentNode
that initially points to the root node, and update it as we go. At the end, we'll mark the last node as the end of the word:
addWord(word: string): void {
let currentNode = this.root;
for (const char of word) {
if (!currentNode.children.has(char)) {
currentNode.children.set(char, new TrieNode());
}
currentNode = currentNode.children.get(char) as TrieNode;
}
currentNode.isEndOfWord = true;
}
Alternatively, we could've also used a non-null assertion operator that asserts values as non null
or undefined
, like this:
currentNode = currentNode.children.get(char)!;
Now, it gets a little confusing when we need to implement search
. We need to be able to match any letter for a dot, and the idea here is about recursively checking the nodes.
For example, if we are to search for a.c
, first we check if a
exists, then go on to its children two levels below to see if c
exists as the last character. If we don't reach our goal on our first try, we need to backtrack, and search through the other children of a
again.
So, the idea is that if the current character of the word
we're searching for is a dot (.
), then, we'll go through the children of the current node, and do the same thing for each child, continuing with each character in the word
.
Let's see another example.
If the word is s.y
, we first check if s
exists as a child node of the root, if so, we go on to check if it has any child node that has a child node of y
, and it marks the end of the word. We could have say
or sky
or spy
, etc., it doesn't matter. As soon as it matches our criteria, we can return true
immediately.
Note that for each child, we're essentially doing the same thing, but with the next character in word
— it's a recursive function. In fact, it's a depth-first search.
We'll keep track of the current index of the character we're looking at in word
as well as the current node.
If the character is a dot (.
), we'll go on to check each child, incrementing the current character index. Otherwise, we'll do our usual search. If the character is not in the children of the current node, we can return false
immediately. If we have that character, we'll recursively search again, incrementing the character index and updating the current node:
function dfs(currentCharIdx: number, currentNode: TrieNode) {
if (currentCharIdx === word.length) {
return currentNode.isEndOfWord;
}
const char = word[currentCharIdx];
if (char === '.') {
for (const child of currentNode.children.values()) {
if (dfs(currentCharIdx + 1, child)) {
return true;
}
}
return false;
} else {
if (!currentNode.children.has(char)) {
return false;
}
return dfs(currentCharIdx + 1, currentNode.children.get(char) as TrieNode);
}
}
And, inside search
, we can simply return whatever this function returns, passing it the first index of word
and our root
as arguments:
return dfs(0, this.root);
Here is the final solution in TypeScript:
class TrieNode {
public children: Map<string, TrieNode>;
public isEndOfWord: boolean;
constructor() {
this.children = new Map();
this.isEndOfWord = false;
}
}
class WordDictionary {
public root: TrieNode;
constructor() {
this.root = new TrieNode();
}
addWord(word: string): void {
let currentNode = this.root;
for (const char of word) {
if (!currentNode.children.has(char)) {
currentNode.children.set(char, new TrieNode());
}
currentNode = currentNode.children.get(char) as TrieNode;
}
currentNode.isEndOfWord = true;
}
search(word: string): boolean {
function dfs(currentCharIdx: number, currentNode: TrieNode) {
if (currentCharIdx === word.length) {
return currentNode.isEndOfWord;
}
const char = word[currentCharIdx];
if (char === '.') {
for (const child of currentNode.children.values()) {
if (dfs(currentCharIdx + 1, child)) {
return true;
}
}
return false;
} else {
if (!currentNode.children.has(char)) {
return false;
}
return dfs(currentCharIdx + 1, currentNode.children.get(char) as TrieNode);
}
}
return dfs(0, this.root);
}
}
/**
* Your WordDictionary object will be instantiated and called as such:
* var obj = new WordDictionary()
* obj.addWord(word)
* var param_2 = obj.search(word)
*/
The time complexity of adding a word is $O(n)$ where $n$ is the length of the word — because we iterate through each character once, doing a constant operation each time. The space complexity is also $O(n)$ as our need for additional space will grow as the length of the word we're adding grows.
The time complexity of searching is—I think—$O(n * m)$ where $n$ is the length of the word and $m$ is the total number of nodes. In the worst case where all the characters are dots, we'll search the entire tree for the word. The space complexity will be $O(h)$ where $h$ is the height of the trie, because of the recursive call stack.
Next up, we'll look at the last problem in this chapter, Word Search II. Until then, happy coding.
]]>The description for this problem is:
A trie (pronounced as "try") or prefix tree is a tree data structure used to efficiently store and retrieve keys in a dataset of strings. There are various applications of this data structure, such as autocomplete and spellchecker.
Implement the Trie class:
Trie()
Initializes the trie object.void insert(String word)
Inserts the stringword
into the trie.boolean search(String word)
Returnstrue
if the stringword
is in the trie (i.e., was inserted before), andfalse
otherwise.boolean startsWith(String prefix)
Returnstrue
if there is a previously inserted stringword
that has the prefixprefix
, andfalse
otherwise.
For example:
Input
['Trie', 'insert', 'search', 'search', 'startsWith', 'insert', 'search']
[[], ['apple'], ['apple'], ['app'], ['app'], ['app'], ['app']]
Output
[null, null, true, false, true, null, true]
Explanation
Trie trie = new Trie();
trie.insert('apple');
trie.search('apple'); // return True
trie.search('app'); // return False
trie.startsWith('app'); // return True
trie.insert('app');
trie.search('app'); // return True
We have seen in the previous article how to create a trie, insert a word, and search for a word, as well as deleting a word.
This problem requires only the first three of them, and additionally a startsWith
method to search for a prefix.
In the previous version, we've created our trie using an array, but let's use another approach here. We'll make use of the Map
object, which is slightly more readable and efficient.
We used JavaScript in the previous article, but for this solution we'll continue using TypeScript.
Let's start with a trie node.
We'll create a TrieNode
class that has children
which is initiated as a Map
whose keys are string
s and the values are TrieNode
s.
Our node will also have an isEndOfWord
flag to indicate whether it represents the end character of a word:
class TrieNode {
public children: Map<string, TrieNode>;
public isEndOfWord: boolean;
constructor() {
this.children = new Map();
this.isEndOfWord = false;
}
}
Now, on to the Trie
itself.
We'll start with creating an empty root note in our constructor
:
class Trie {
public root: TrieNode;
constructor() {
this.root = new TrieNode();
}
...
}
To insert a word, we'll traverse each character, and starting with our root node, insert them one by one.
First, we'll initialize a currentNode
variable which points to our root node, and we'll update it each time we add a character. Once we add all the characters, we'll mark that node's isEndOfWord
as true
:
insert(word: string): void {
let currentNode = this.root;
for (const char of word) {
if (!currentNode.children.has(char)) {
currentNode.children.set(char, new TrieNode());
}
currentNode = currentNode.children.get(char) as TrieNode;
}
currentNode.isEndOfWord = true;
}
Alternatively, we could've also used a non-null assertion operator that asserts values as non null
or undefined
, like this:
currentNode = currentNode.children.get(char)!;
To search a word, we'll do a similar thing. We'll iterate through each character, and check if it's in our trie. If not, we can immediately return false
. Otherwise, we'll return isEndOfWord
of the last node we reach. So, if that character is indeed the end of a word, that word is in our trie:
search(word: string): boolean {
let currentNode = this.root;
for (const char of word) {
if (!currentNode.children.has(char)) {
return false;
}
currentNode = currentNode.children.get(char) as TrieNode;
}
return currentNode.isEndOfWord;
}
The startsWith
method also looks very similar, only that we don't need to check isEndOfWord
of any node. We're just checking for the existence of the prefix we're given, so we'll traverse all the characters in it, and once we reach the end (that all characters are in our trie), we can return true
:
startsWith(prefix: string): boolean {
let currentNode = this.root;
for (const char of prefix) {
if (!currentNode.children.has(char)) {
return false;
}
currentNode = currentNode.children.get(char) as TrieNode;
}
return true;
}
And, here is the whole solution:
class TrieNode {
public children: Map<string, TrieNode>;
public isEndOfWord: boolean;
constructor() {
this.children = new Map();
this.isEndOfWord = false;
}
}
class Trie {
public root: TrieNode;
constructor() {
this.root = new TrieNode();
}
insert(word: string): void {
let currentNode = this.root;
for (const char of word) {
if (!currentNode.children.has(char)) {
currentNode.children.set(char, new TrieNode());
}
currentNode = currentNode.children.get(char) as TrieNode;
}
currentNode.isEndOfWord = true;
}
search(word: string): boolean {
let currentNode = this.root;
for (const char of word) {
if (!currentNode.children.has(char)) {
return false;
}
currentNode = currentNode.children.get(char) as TrieNode;
}
return currentNode.isEndOfWord;
}
startsWith(prefix: string): boolean {
let currentNode = this.root;
for (const char of prefix) {
if (!currentNode.children.has(char)) {
return false;
}
currentNode = currentNode.children.get(char) as TrieNode;
}
return true;
}
}
/**
* Your Trie object will be instantiated and called as such:
* var obj = new Trie()
* obj.insert(word)
* var param_2 = obj.search(word)
* var param_3 = obj.startsWith(prefix)
*/
Both the time and space complexity of inserting a word are $O(n)$ where $n$ is the number of characters — we traverse through each of them once, and the space requirements will grow as the number of characters of the word grows.
search
and startsWith
both have $O(n)$ time complexity, as we're iterating through each character in a given string input. They also both have $O(1)$ space complexity because we don't need any additional space.
Next up is the problem Design Add and Search Words Data Structure. Until then, happy coding.
]]>The trie data structure gets its name from the word retrieval — and it's usually pronounced as "try," so that we don't get confused with another familiar and friendly data structure, "tree."
However, a trie is still a tree (or tree-like) data structure whose nodes usually store individual letters. So, by traversing the nodes in a trie, we can retrieve strings.
Tries are useful for applications such as autocompletion and spellchecking — and the larger our trie is, the less work we have to do for inserting a new value.
An important note before we start: using arrays is not very memory-efficient, and we'll see another way of creating tries in the next article for Implement Trie (Prefix Tree). For now, we'll stick to the array implementation.
First, let's see what a trie looks like:
In this trie, we can retrieve the strings "sea" and "see" — but not "sew" for example.
There is a lot going on, but we can try to understand it piece by piece.
Let's look at a trie node.
We'll create a TrieNode
class that has children
, which is an array of length 26 (so that each index corresponds to a letter in the English alphabet), and a flag variable isEndOfWord
to indicate whether that node represents the last character of a word:
class TrieNode {
constructor() {
this.children = Array.from({ length: 26 }, () => null);
this.isEndOfWord = false;
}
}
We're initializing children
with null
values. As we add a character to our trie, the index that corresponds to that character will be filled.
In a trie, we start with an empty root node.
class Trie {
constructor() {
this.root = new TrieNode();
}
...
}
To insert a word, we're going to loop through each character, and initialize a new TrieNode
to the corresponding index.
insert(word) {
let currentNode = this.root;
for (const char of word) {
let idx = char.charCodeAt(0) - 'a'.charCodeAt(0);
if (currentNode.children[idx] === null) {
currentNode.children[idx] = new TrieNode();
}
currentNode = currentNode.children[idx];
}
currentNode.isEndOfWord = true;
}
Once we reach the node that indicates the last character of the word we inserted, we also mark the isEndOfWord
variable as true
.
word = word.toLowerCase();
For searching a word's existence in the trie, we'll do a similar thing. We'll look at the nodes for each character, and if we reach the last one that has isEndOfWord
marked as true
, that means we've found the word:
search(word) {
let currentNode = this.root;
for (const char of word) {
let idx = char.charCodeAt(0) - 'a'.charCodeAt(0);
if (currentNode.children[idx] === null) {
return false;
}
currentNode = currentNode.children[idx];
}
return currentNode.isEndOfWord;
}
Removing a word is a bit more challenging. Let's say that we want to remove the word "see." But, there is also another word "sea," with the same prefix ('s' and 'e'). So, we should remove only the nodes that we're allowed to.
For this reason, we'll define a recursive function.
Once we reach the last character of the word we want to remove, we'll back up and remove the characters we can remove:
const removeRecursively = (node, word, depth) => {
if (node === null) {
return null;
}
if (depth === word.length) {
if (node.isEndOfWord) {
node.isEndOfWord = false;
}
if (node.children.every(child => child === null)) {
node = null;
}
return node;
}
let idx = word[depth].charCodeAt(0) - 'a'.charCodeAt(0);
node.children[idx] = removeRecursively(node.children[idx], word, depth + 1);
if (node.children.every(child => child === null) && !node.isEndOfWord) {
node = null;
}
return node;
}
depth
indicates the index of the word, or the depth of the trie we reach.
Once depth
is equal to the word's length (one past the last character), we check if it's the end of the word, if that's the case, we'll mark it as false
now, because that word won't exist from here on. Then, we can only mark the node as null
if it doesn't have any children (in other words, if all of them are null
). We'll apply this logic to each child node recursively until the word is removed as far as it can be removed.
Here is the final example implementation of a trie:
class TrieNode {
constructor() {
this.children = Array.from({ length: 26 }, () => null);
this.isEndOfWord = false;
}
}
class Trie {
constructor() {
this.root = new TrieNode();
}
insert(word) {
let currentNode = this.root;
for (const char of word) {
let idx = char.charCodeAt(0) - 'a'.charCodeAt(0);
if (currentNode.children[idx] === null) {
currentNode.children[idx] = new TrieNode();
}
currentNode = currentNode.children[idx];
}
currentNode.isEndOfWord = true;
}
search(word) {
let currentNode = this.root;
for (const char of word) {
let idx = char.charCodeAt(0) - 'a'.charCodeAt(0);
if (currentNode.children[idx] === null) {
return false;
}
currentNode = currentNode.children[idx];
}
return currentNode.isEndOfWord;
}
remove(word) {
const removeRecursively = (node, word, depth) => {
if (node === null) {
return null;
}
if (depth === word.length) {
if (node.isEndOfWord) {
node.isEndOfWord = false;
}
if (node.children.every(child => child === null)) {
node = null;
}
return node;
}
let idx = word[depth].charCodeAt(0) - 'a'.charCodeAt(0);
node.children[idx] = removeRecursively(node.children[idx], word, depth + 1);
if (node.children.every(child => child === null) && !node.isEndOfWord) {
node = null;
}
return node;
}
removeRecursively(this.root, word, 0);
}
}
let t = new Trie();
t.insert('sea');
t.insert('see');
console.log(t.search('sea')); // true
console.log(t.search('see')); // true
console.log(t.search('hey')); // false
console.log(t.search('sew')); // false
t.remove('see');
console.log(t.search('see')); // false
console.log(t.search('sea')); // true
The time complexity of creating a trie is going to be $O(m * n)$ where $m$ is the longest word and $n$ is the total number of words.
Inserting, searching, and deleting a word is $O(a * n)$ where $a$ is the length of the word and $n$ is the total number of words.
When it comes to space complexity, in the worst case, each node can have children for all the characters in the alphabet we're representing. But, the size of the alphabet is constant, so the growth of storage needs will be proportionate to the number of nodes we have, which is $O(n)$ where $n$ is the number of nodes.
We have already done most of the work for the next problem, but next time we'll be slightly more efficient. Until then, happy coding.
]]>The description for Word Search is:
Given an
m x n
grid of charactersboard
and a stringword
, returntrue
ifword
exists in the grid.The word can be constructed from letters of sequentially adjacent cells, where adjacent cells are horizontally or vertically neighboring. The same letter cell may not be used more than once.
For example:
Input: board = [['A', 'B', 'C', 'E'], ['S', 'F', 'C', 'S'], ['A', 'D', 'E', 'E']], word = 'ABCCED'
Output: true
We somehow have to look at each cell, and explore our options to see if we can find the word. This exploration lends itself perfectly to a depth-first search.
But, we can't explore other cells if the current row and column we're looking at are out of bounds, and the current character is not the character we want.
In these cases, we have to return false
:
if (outOfBounds(row, col) || word[idx] !== board[row][col]) {
return false;
}
This is one base case. Another base case is when we actually find the word.
Since we'll be keeping track of the index of the word for the current character we're exploring, once it reaches the word's length, we know that we have found the word:
if (idx === word.length) {
return true;
}
Now that the base cases are out of the way, the first thing we can do for the cell we're looking at is to mark it as visited. We can use the *
character to indicate that:
let currentCell = board[row][col];
board[row][col] = '*';
Now, we can do the exploring for the cell we're looking at:
dfs(row + 1, col, idx + 1) || // down
dfs(row - 1, col, idx + 1) || // up
dfs(row, col + 1, idx + 1) || // right
dfs(row, col - 1, idx + 1); // left
All these explorations are not for nothing of course, so we have to keep the result we have:
let result = dfs(row + 1, col, idx + 1) || // down
dfs(row - 1, col, idx + 1) || // up
dfs(row, col + 1, idx + 1) || // right
dfs(row, col - 1, idx + 1); // left
Before returning this result, we need to reset the marked cell to its original value, otherwise, in our other explorations, we would have it wrongly marked:
board[row][col] = currentCell;
This is all for our depth-first search function:
function dfs(row: number, col: number, idx: number): boolean {
if (idx === word.length) {
return true;
}
if (outOfBounds(row, col) || word[idx] !== board[row][col]) {
return false;
}
// Mark the current cell as visited
let currentCell = board[row][col];
board[row][col] = '*';
// Pass idx + 1 because we're looking for
// the next character in the word now
let result = dfs(row + 1, col, idx + 1) || // down
dfs(row - 1, col, idx + 1) || // up
dfs(row, col + 1, idx + 1) || // right
dfs(row, col - 1, idx + 1); // left
// Reset the current cell to its original value
// because we're done visiting it
board[row][col] = currentCell;
return result;
}
We'll apply depth-first search for each cell on the board:
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
if (dfs(i, j, 0)) {
return true;
}
}
}
If dfs
returns true for that cell, we can return true
immediately. Otherwise, we'll return false
at the end.
The final solution looks like this in TypeScript:
function exist(board: string[][], word: string): boolean {
const rowsLength = board.length;
const colsLength = board[0].length;
function outOfBounds(r: number, c: number) {
return r < 0 || c < 0 || r >= rowsLength || c >= colsLength;
}
// idx: the index of the current character in the word we're looking for
function dfs(row: number, col: number, idx: number): boolean {
if (idx === word.length) {
return true;
}
if (outOfBounds(row, col) || word[idx] !== board[row][col]) {
return false;
}
// Mark the current cell as visited
let currentCell = board[row][col];
board[row][col] = '*';
// Pass idx + 1 because we're looking for
// the next character in the word now
let result = dfs(row + 1, col, idx + 1) || // down
dfs(row - 1, col, idx + 1) || // up
dfs(row, col + 1, idx + 1) || // right
dfs(row, col - 1, idx + 1); // left
// Reset the current cell to its original value
// because we're done visiting it
board[row][col] = currentCell;
return result;
}
// For each cell, do a depth-first search
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
if (dfs(i, j, 0)) {
return true;
}
}
}
return false;
}
This version is adapted from NeetCode's solution.
Now, let's look at a very simple example.
Our board looks like this, and the word we're looking for is PIE
:
board = [['A', 'P'], ['E', 'I']]
word = 'PIE'
Let's modify the code a little bit, and use some helpful console.log
s:
function exist(board: string[][], word: string): boolean {
const rowsLength = board.length;
const colsLength = board[0].length;
function outOfBounds(r: number, c: number) {
return r < 0 || c < 0 || r >= rowsLength || c >= colsLength;
}
// idx: the index of the current character in the word we're looking for
function dfs(row: number, col: number, idx: number): boolean {
console.log(`\n===== row: ${row}, col: ${col} =====`);
if (idx === word.length) {
console.log(`🎉 this is dfs(${row}, ${col}, ${idx}), FINISHED ALREADY, '${word}' HAS BEEN FOUND`);
return true;
}
if (outOfBounds(row, col)) {
console.log(`\n======= OUT OF BOUNDS =======\n`);
return false;
}
if (word[idx] !== board[row][col]) {
console.log(`looking for '${word[idx]}', this is ${board[row][col]}`);
return false;
}
// Mark the current cell as visited
let currentCell = board[row][col];
console.log(`found ${currentCell}, currentCell is ${currentCell}, idx is ${idx}`);
board[row][col] = '*';
// Pass idx + 1 because we're looking for
// the next character in the word now
console.log(`this is dfs(${row}, ${col}, ${idx}), going down, searching for '${word[idx + 1]}'`);
let downResult = dfs(row + 1, col, idx + 1);
console.log(`🟣 dfs(${row + 1}, ${col}, ${idx + 1}) returned, row is ${row}, col is ${col}\n`);
console.log(`this is dfs(${row}, ${col}, ${idx}), going up, searching for '${word[idx + 1]}'`);
let upResult = dfs(row - 1, col, idx + 1);
console.log(`🟣 dfs(${row - 1}, ${col}, ${idx + 1}) returned, row is ${row}, col is ${col}\n`);
console.log(`this is dfs(${row}, ${col}, ${idx}), going right, searching for '${word[idx + 1]}'`);
let rightResult = dfs(row, col + 1, idx + 1);
console.log(`🟣 dfs(${row}, ${col + 1}, ${idx + 1}) returned, row is ${row}, col is ${col}\n`);
console.log(`this is dfs(${row}, ${col}, ${idx}), going left, searching for '${word[idx + 1]}'`);
let leftResult = dfs(row, col - 1, idx + 1);
console.log(`🟣 dfs(${row}, ${col - 1}, ${idx + 1}) returned, row is ${row}, col is ${col}\n`);
// Reset the current cell to its original value
// because we're done visiting it
board[row][col] = currentCell;
return downResult || upResult || rightResult || leftResult;
}
// For each cell, do a depth-first search
for (let i = 0; i < rowsLength; i++) {
for (let j = 0; j < colsLength; j++) {
if (dfs(i, j, 0)) {
return true;
}
}
}
return false;
}
console.log(exist([['A', 'P'],['E', 'I']], 'PIE'));
It looks overwhelming and cluttered, but looking at the output, it almost reads like a story:
===== row: 0, col: 0 =====
looking for 'P', this is A
===== row: 0, col: 1 =====
found P, currentCell is P, idx is 0
this is dfs(0, 1, 0), going down, searching for 'I'
===== row: 1, col: 1 =====
found I, currentCell is I, idx is 1
this is dfs(1, 1, 1), going down, searching for 'E'
===== row: 2, col: 1 =====
======= OUT OF BOUNDS =======
🟣 dfs(2, 1, 2) returned, row is 1, col is 1
this is dfs(1, 1, 1), going up, searching for 'E'
===== row: 0, col: 1 =====
looking for 'E', this is *
🟣 dfs(0, 1, 2) returned, row is 1, col is 1
this is dfs(1, 1, 1), going right, searching for 'E'
===== row: 1, col: 2 =====
======= OUT OF BOUNDS =======
🟣 dfs(1, 2, 2) returned, row is 1, col is 1
this is dfs(1, 1, 1), going left, searching for 'E'
===== row: 1, col: 0 =====
found E, currentCell is E, idx is 2
this is dfs(1, 0, 2), going down, searching for 'undefined'
===== row: 2, col: 0 =====
🎉 this is dfs(2, 0, 3), FINISHED ALREADY, 'PIE' HAS BEEN FOUND
🟣 dfs(2, 0, 3) returned, row is 1, col is 0
this is dfs(1, 0, 2), going up, searching for 'undefined'
===== row: 0, col: 0 =====
🎉 this is dfs(0, 0, 3), FINISHED ALREADY, 'PIE' HAS BEEN FOUND
🟣 dfs(0, 0, 3) returned, row is 1, col is 0
this is dfs(1, 0, 2), going right, searching for 'undefined'
===== row: 1, col: 1 =====
🎉 this is dfs(1, 1, 3), FINISHED ALREADY, 'PIE' HAS BEEN FOUND
🟣 dfs(1, 1, 3) returned, row is 1, col is 0
this is dfs(1, 0, 2), going left, searching for 'undefined'
===== row: 1, col: -1 =====
🎉 this is dfs(1, -1, 3), FINISHED ALREADY, 'PIE' HAS BEEN FOUND
🟣 dfs(1, -1, 3) returned, row is 1, col is 0
🟣 dfs(1, 0, 2) returned, row is 1, col is 1
🟣 dfs(1, 1, 1) returned, row is 0, col is 1
this is dfs(0, 1, 0), going up, searching for 'I'
===== row: -1, col: 1 =====
======= OUT OF BOUNDS =======
🟣 dfs(-1, 1, 1) returned, row is 0, col is 1
this is dfs(0, 1, 0), going right, searching for 'I'
===== row: 0, col: 2 =====
======= OUT OF BOUNDS =======
🟣 dfs(0, 2, 1) returned, row is 0, col is 1
this is dfs(0, 1, 0), going left, searching for 'I'
===== row: 0, col: 0 =====
looking for 'I', this is A
🟣 dfs(0, 0, 1) returned, row is 0, col is 1
true
There are several insights here — for example, note that this line:
this is dfs(1, 0, 2), going down, searching for 'undefined'
indicates that we still go looking for word[idx + 1]
even though the current index is the index of the last character in word
.
It, of course, continues exploring every direction left from there on:
this is dfs(1, 0, 2), going up, searching for 'undefined'
this is dfs(1, 0, 2), going right, searching for 'undefined'
this is dfs(1, 0, 2), going left, searching for 'undefined'
Again, also these lines:
this is dfs(0, 1, 0), going up, searching for 'I'
this is dfs(0, 1, 0), going right, searching for 'I'
this is dfs(0, 1, 0), going left, searching for 'I'
indicate that once the control returns to the function that has found 'P'
, it'll still continue exploring all the directions left from there. We've already found 'PIE'
by going down, but the other directions are still being explored.
The time complexity will be
$$O(\text{ length of rows } * \text{ length of columns } * 4^{\text{ length of the word }})$$
We can denote it succinctly as $O(n * m * 4^l)$ where $n$ is the length of rows, $m$ is the length of columns, and $l$ is the length of the word. The reason is that we call dfs
for each cell on the board ($n * m$), and there can be $4^l$ recursive calls to dfs
.
The space complexity is going to be $O(l)$ where $l$ is the length of the word, as the stack depth can reach $l$ in the worst case.
Backtracking might not be very efficient or elegant, and it's definitely a brute force solution for a given problem. Now that we're done with this chapter, we can take a deep breath.
Next up, we'll take a look at the trie data structure. Until then, happy coding.
]]>Let's start with the description for Combination Sum:
Given an array of distinct integers
candidates
and a target integertarget
, return a list of all unique combinations ofcandidates
where the chosen numbers sum totarget
. You may return the combinations in any order.The same number may be chosen from
candidates
an unlimited number of times. Two combinations are unique if the frequency of at least one of the chosen numbers is different.The test cases are generated such that the number of unique combinations that sum up to
target
is less than150
combinations for the given input.
For example:
Input: candidates = [2, 3, 6, 7], target = 7
Output: [[2, 2, 3], [7]]
Explanation:
2 and 3 are candidates, and 2 + 2 + 3 = 7. Note that 2 can be used multiple times.
7 is a candidate, and 7 = 7.
These are the only two combinations.
Or:
Input: candidates = [2, 3, 5], target = 8
Output: [[2, 2, 2, 2], [2, 3, 3], [3, 5]]
One thing to notice here is that we can have duplicate values — for instance, in the second example, [2, 2, 2, 2]
is a possible option for a given target of 8
.
We can try adding the same item to the current total, until it's equal to the target or is more than the target. If the current total ends up being equal to the target, we'll add the numbers we've gathered so far to our result. Otherwise, we'll backtrack, and try the next item in candidates
.
We'll have two important variables to help us here: currentNums
which has the current numbers we're looking at, and currentTotal
which is the sum of currentNums
.
For the first base case where we can add the currentNums
to the result, we'll check if the currentTotal
equals target
:
if (currentTotal === target) {
result.push([...currentNums]);
return;
}
Another case where we need to return is when we've looked at all the items in candidates
or when currentTotal
has surpassed target
:
if (idx >= candidates.length || currentTotal > target) {
return;
}
The process mentioned above goes like this:
currentNums.push(candidates[idx]);
backtrack(idx, currentNums, currentTotal + candidates[idx]);
currentNums.pop();
backtrack(idx + 1, currentNums, currentTotal);
And, that's all there is to the backtrack
function:
function backtrack(idx: number, currentNums: number[], currentTotal: number) {
if (currentTotal === target) {
result.push([...currentNums]);
return;
}
if (idx >= candidates.length || currentTotal > target) {
return;
}
currentNums.push(candidates[idx]);
backtrack(idx, currentNums, currentTotal + candidates[idx]);
currentNums.pop();
backtrack(idx + 1, currentNums, currentTotal);
}
For example, let's say that the candidates
array is [2, 3, 5]
and the target is 5
.
The first item is 2
, so we'll add it to itself until it reaches 6
(2 + 2 + 2
), the point where the current total is more than the target.
Now that we're over the target, we'll pop the last 2
from currentNums
, and add the next item in candidates
, which is 3
. Now, our current total is 2 + 2 + 3
, which is again more than the target, so we'll pop 3
. We'll go on to 5
, and our current total will be 2 + 2 + 5
, which is of course more than the target, so we'll pop 5
as well.
At this point, we're left with 2 + 2
, but we tried all the items in candidates
, so we'll pop the last 2
from currentNums
again.
Now, our current total is just 2
. So, we go on, and add the next item in candidates
, and now, our current total is 2 + 3
which equals our target. We'll add it to our result and return.
We'll try the next item, our current total is now 2 + 5
. It is more than the target, so we'll pop the last item, and once again we're only left with 2
as our current total. But, we tried all the items again, so we'll pop this 2
as well.
We tried every possible combination for 2
, so now it's time to look at 3
.
We'll add 3
to itself until it's more than or equal to the target. Our current total will reach 3 + 3
, which is more than the target, so we'll pop the last 3
from currentNums
. Now, we'll go on to the next item, our current total will be 3 + 5
, which exceeds the target again, so we'll pop 5
.
At this point we've tried all the items for 3
, so it's now time to pop 3
as well.
We go on to 5
, and as our current total is just 5
which is equal to the target, we'll add it to our result. There is no next item we can try with 5
, so we'll pop it off.
We don't have any items left to look at, so we're done.
Our result is [[2, 3], [5]]
.
The final solution in TypeScript looks like this:
function combinationSum(candidates: number[], target: number): number[][] {
let result: number[][] = [];
let nums = [];
function backtrack(idx: number, currentNums: number[], currentTotal: number) {
if (currentTotal === target) {
result.push([...currentNums]);
return;
}
if (idx >= candidates.length || currentTotal > target) {
return;
}
currentNums.push(candidates[idx]);
backtrack(idx, currentNums, currentTotal + candidates[idx]);
currentNums.pop();
backtrack(idx + 1, currentNums, currentTotal);
}
backtrack(0, nums, 0);
return result;
}
For a more detailed explanation of this solution, see NeetCode's video on this problem.
There are different opinions on the time complexity of this solution. The most likely one is—I think—$2^t$ where $t$ is the target number. The reason is related to the height of the decision tree. For example, if the first item in candidates
is 1
, the number of calls to backtrack
will be, in the worst case, equal to the target.
The space complexity can be $O(t)$ where $t$ is the target, because the recursive call stack can reach a depth of $t$ in the worst case.
This is, in my opinion, a very challenging problem, so it's now time to take a breath. Next, we'll look at Word Search. Until then, happy coding.
]]>Let's start with admitting this one fact: backtracking is hard. Or rather, understanding it the first time is hard.
Or, it's one of those concepts that you think you grasped it, only to realize later that you actually didn't.
We'll focus on one problem of finding the subsets of an array, but before that, let's imagine that we're walking along a path.
Then, we reach a fork. We pick one of the paths, and walk.
Then, we reach another fork in the path. We pick one of the paths again, and go on walking, then we reach a dead end. So, we backtrack to the last point we had a fork, then go through the other path that we didn't choose the first time.
Then we reach another dead end. So, we backtrack once more and realize that there are no other paths we can go from there. So we backtrack again, and explore the other path we didn't choose the first time we came to this point.
We reach yet another dead end, so we backtrack. We see that there are no more paths to explore, so we backtrack once more.
Now, we're at our starting point. There are no more paths left to explore, so we can stop walking.
It was a nice but tiring walk, and it went like this:
Now, let's take a look at an actual LeetCode problem.
The description for Subsets says:
Given an integer array
nums
of unique elements, return all possible subsets (the power set).The solution set must not contain duplicate subsets. Return the solution in any order.
For example:
Input: nums = [1, 2, 3]
Output: [[], [1], [2], [1, 2], [3], [1, 3], [2, 3], [1, 2, 3]]
Or:
Input: nums = [0]
Output: [[], [0]]
Before diving into the solution code, let's take a look at how backtracking will work in this case. Let's call the nums
array items
instead:
For each item in items
, we have initially two choices: to include the item, or not to include it.
For each level $n$ in this decision tree, we have the option to include the next item in items
. We have $2^n$ possible subsets in total.
Let's simplify the example a bit, and say that items
is now ['a', 'b']
(We'll ignore the problem specifics for now).
In this case, we can use backtracking like this:
function subsets(items: string[]) {
let result: string[][] = [];
let currentSubset: string[] = [];
function backtrack(idx: number) {
if (idx >= items.length) {
result.push([...currentSubset]);
return;
}
currentSubset.push(items[idx]);
backtrack(idx + 1);
currentSubset.pop();
backtrack(idx + 1);
}
backtrack(0);
return result;
}
console.log(subsets(['a', 'b']));
// -> [['a', 'b'], ['a'], ['b'], []]
Well, it looks simple at first glance, but what's going on?
One thing to notice is that we pop
from the currentSubset
, then call backtrack
. In our example of walking, that's the part we go back to our previous point, and continue our walk.
In the first animation, we indicated a dead end with a cross mark, and in this case, a dead end is the base case we reach.
It might still be tough to understand, so let's add some helpful console.log
s, and see the output:
function subsets(items: string[]) {
let result: string[][] = [];
let currentSubset: string[] = [];
function backtrack(idx: number) {
console.log(`======= this is backtrack(${arguments[0]}) =======`)
if (idx >= items.length) {
console.log(`idx is ${idx}, currentSubset is [${currentSubset}], adding it to result...`);
result.push([...currentSubset]);
console.log(`backtrack(${arguments[0]}) is returning...\n`)
return;
}
currentSubset.push(items[idx]);
console.log(`added ${items[idx]} to currentSubset, inside backtrack(${arguments[0]})`);
console.log(`calling backtrack(${idx + 1})...`)
backtrack(idx + 1);
let item = currentSubset.pop();
console.log(`popped ${item} from currentSubset, inside backtrack(${arguments[0]})`);
console.log(`calling backtrack(${idx + 1})...`)
backtrack(idx + 1);
console.log(`******* done with backtrack(${arguments[0]}) *******\n`);
}
backtrack(0);
return result;
}
console.log(subsets(['a', 'b']));
The output looks like this:
======= this is backtrack(0) =======
added a to currentSubset, inside backtrack(0)
calling backtrack(1)...
======= this is backtrack(1) =======
added b to currentSubset, inside backtrack(1)
calling backtrack(2)...
======= this is backtrack(2) =======
idx is 2, currentSubset is [a,b], adding it to result...
backtrack(2) is returning...
popped b from currentSubset, inside backtrack(1)
calling backtrack(2)...
======= this is backtrack(2) =======
idx is 2, currentSubset is [a], adding it to result...
backtrack(2) is returning...
******* done with backtrack(1) *******
popped a from currentSubset, inside backtrack(0)
calling backtrack(1)...
======= this is backtrack(1) =======
added b to currentSubset, inside backtrack(1)
calling backtrack(2)...
======= this is backtrack(2) =======
idx is 2, currentSubset is [b], adding it to result...
backtrack(2) is returning...
popped b from currentSubset, inside backtrack(1)
calling backtrack(2)...
======= this is backtrack(2) =======
idx is 2, currentSubset is [], adding it to result...
backtrack(2) is returning...
******* done with backtrack(1) *******
******* done with backtrack(0) *******
[ [ 'a', 'b' ], [ 'a' ], [ 'b' ], [] ]
If you noticed, Add 'a'
? and Go ahead? arrows on the first level are calls to backtrack(0)
.
Add 'b'
? and Go ahead? arrows on the second level are calls to backtrack(1)
.
backtrack(2)
calls are when we reach the "dead ends," in those cases, we add currentSubset
to the result
.
We always reach the base case in a backtrack(2)
call, obviously because it's only when the idx
equals items.length
.
let result: number[][] = [];
let currentSubset: number[] = [];
Also, the function parameter and return types are different:
function subsets(nums: number[]): number[][] { ... }
Otherwise, everything stays the same.
A subset is, in the worst case, length $n$ which is the length of our input. We'll have $2^n$ subsets and since we also use a spread operator to copy currentSubset
, the time complexity will be $O(n \cdot 2^n)$. The space complexity is—I think— $O(n \cdot 2^n)$ as well because of the recursive call stack (which is of depth n
), and the space needed for result
(which is in the worst case $2^n$).
Now it's time to take a deep breath, and maybe go on an actual walk. This has been a challenging concept to grasp, and perhaps the only thing that can make it click is a real walk in nature, with some backtracking along the way.
The first problem in this chapter is Combination Sum, until then, happy coding.
]]>Let's start with the description for this one:
The median is the middle value in an ordered integer list. If the size of the list is even, there is no middle value, and the median is the mean of the two middle values.
- For example, for
arr = [2, 3, 4]
, the median is3
.- For example, for
arr = [2, 3]
, the median is(2 + 3) / 2 = 2.5
.Implement the MedianFinder class:
MedianFinder()
initializes theMedianFinder
object.void addNum(int num)
adds the integernum
from the data stream to the data structure.double findMedian()
returns the median of all elements so far. Answers within10^-5
of the actual answer will be accepted.
For example:
Input:
["MedianFinder", "addNum", "addNum", "findMedian", "addNum", "findMedian"]
[[], [1], [2], [], [3], []]
Output:
[null, null, null, 1.5, null, 2.0]
Explanation:
MedianFinder medianFinder = new MedianFinder();
medianFinder.addNum(1); // arr = [1]
medianFinder.addNum(2); // arr = [1, 2]
medianFinder.findMedian(); // return 1.5 (i.e., (1 + 2) / 2)
medianFinder.addNum(3); // arr[1, 2, 3]
medianFinder.findMedian(); // return 2.0
This question is labeled as a hard one; however, finding the median itself is not hard.
The first idea that comes to mind is that we can continually add the numbers to an array, keep sorting it each time we do so, and return the median accordingly.
In fact, let's try it in TypeScript:
class MedianFinder {
public nums: number[];
constructor() {
this.nums = [];
}
addNum(num: number): void {
this.nums.push(num);
this.nums.sort((a, b) => a - b);
}
findMedian(): number {
let mid = Math.floor(this.nums.length / 2);
if (this.nums.length % 2 === 0) {
let mid1 = Math.floor(this.nums.length / 2) - 1;
return (this.nums[mid] + this.nums[mid1]) / 2;
}
return this.nums[mid];
}
}
/**
* Your MedianFinder object will be instantiated and called as such:
* var obj = new MedianFinder()
* obj.addNum(num)
* var param_2 = obj.findMedian()
*/
Even though it passes some of the tests, it will end up with a Time Limit Exceeded error at one point, so we have to do better.
Indeed, let's take a deep breath, because we'll make use of heaps for an elegant solution.
When we think about it, if we have an even number of sorted numbers, the median will be the average of the maximum of the smaller half and the minimum of the larger half.
For example, if our numbers are these:
[3, 4, 7, 9]
the median is the average of 4
and 7
(which is 5.5
).
The smaller half is [3, 4]
, while the larger half is [7, 9]
. Indeed, 4
is the maximum in the smaller half and 7
is the minimum in the larger half.
However, if we have an odd length of numbers, we can just get either the maximum value from the smaller half, or the minimum value from the larger half.
Say, our numbers are these:
[3, 4, 7]
In this case, the smaller half can be [3, 4]
, and the larger half can be [7]
, so we can get the maximum value from the smaller half, which is 4
, and be done with it.
Or, if the smaller half is just [3]
, and the larger half is [4, 7]
, we'll get the minimum value from the larger half, which is still 4
.
You might already have an inkling that what we're talking about as the smaller half is a perfect candidate for a max heap because we're only concerned with the maximum value. Similarly, the larger half begs to be implemented as a min heap as we only deal with the minimum value in it. Of course, the elegance lies in the fact that we can get these values in constant time.
We are going to use TypeScript for the solution, and LeetCode's TypeScript environment (well, in fact, JavaScript environment) includes a handy package for using heaps (or, priority queues): Enter @datastructures-js/priority-queue!
We can use this package to create our max and min heaps, and make use of the functionality it gives such as enqueueing/dequeueing data.
So, inside the constructor of our MedianFinder
class, we can initialize our min and max heaps:
class MedianFinder {
public minHeap;
public maxHeap;
constructor() {
this.minHeap = new MinPriorityQueue();
this.maxHeap = new MaxPriorityQueue();
}
...
}
Now, when it comes to adding a number, we need to choose a heap to add it into. We can choose either of them when we're starting off with empty heaps; however, let's use the max heap for adding a number first. That means, when we're going to add a number, we'll first try to put it in the max heap. But, we can only do it when the number we're going to add is less than the maximum value in the heap, or when the heap is empty. Otherwise, we can add it to the min heap.
So, we can have a handy getHeap
function inside our class for choosing the heap to add the number into:
getHeap(n: number) {
if (this.maxHeap.isEmpty() || n <= this.maxHeap.front().element) {
return this.maxHeap;
}
return this.minHeap;
}
However, once we add the number, we need to make sure that the sizes of the heaps don't differ by more than 1. In that case, we'll dequeue the maximum value from the max heap, and enqueue it to the min heap:
if ((this.maxHeap.size() - this.minHeap.size()) > 1) {
let num = this.maxHeap.dequeue().element;
this.minHeap.enqueue(num);
}
If, however, the size of the min heap becomes larger than the size of the max heap, we need to rearrange things, and this time dequeue from the min heap to enqueue the value to the max heap:
if (this.maxHeap.size() < this.minHeap.size()) {
let num = this.minHeap.dequeue().element;
this.maxHeap.enqueue(num);
}
We can add these conditions to a separate rebalanceHeaps
function for modularity's sake:
rebalanceHeaps(): void {
if ((this.maxHeap.size() - this.minHeap.size()) > 1) {
let num = this.maxHeap.dequeue().element;
this.minHeap.enqueue(num);
}
if (this.maxHeap.size() < this.minHeap.size()) {
let num = this.minHeap.dequeue().element;
this.maxHeap.enqueue(num);
}
}
And now, we're done with addNum
itself. We only enqueued the new number to the proper heap, and rebalanced the heaps:
addNum(num: number): void {
this.getHeap(num).enqueue(num);
this.rebalanceHeaps();
}
Now, finding the median is a piece of cake. And, it's delicious because we'll get it in constant time:
findMedian(): number {
let maxOfSmallerHalf = this.maxHeap.front().element;
if (this.maxHeap.size() === this.minHeap.size()) {
let minOfLargerHalf = this.minHeap.front().element;
return (maxOfSmallerHalf + minOfLargerHalf) / 2;
}
return maxOfSmallerHalf;
}
And finally, here is the whole solution:
class MedianFinder {
public minHeap;
public maxHeap;
constructor() {
this.minHeap = new MinPriorityQueue();
this.maxHeap = new MaxPriorityQueue();
}
addNum(num: number): void {
this.getHeap(num).enqueue(num);
this.rebalanceHeaps();
}
findMedian(): number {
let maxOfSmallerHalf = this.maxHeap.front().element;
if (this.maxHeap.size() === this.minHeap.size()) {
let minOfLargerHalf = this.minHeap.front().element;
return (maxOfSmallerHalf + minOfLargerHalf) / 2;
}
return maxOfSmallerHalf;
}
getHeap(n: number) {
if (this.maxHeap.isEmpty() || n <= this.maxHeap.front().element) {
return this.maxHeap;
}
return this.minHeap;
}
rebalanceHeaps(): void {
if ((this.maxHeap.size() - this.minHeap.size()) > 1) {
let num = this.maxHeap.dequeue().element;
this.minHeap.enqueue(num);
}
if (this.maxHeap.size() < this.minHeap.size()) {
let num = this.minHeap.dequeue().element;
this.maxHeap.enqueue(num);
}
}
}
/**
* Your MedianFinder object will be instantiated and called as such:
* var obj = new MedianFinder()
* obj.addNum(num)
* var param_2 = obj.findMedian()
*/
This version is adapted from @kx0101's solution.
The time complexity is going to be $O(log \ n)$ because of the enqueue
and dequeue
operations on the heaps. However, getting the max value (or, the min value) is always $O(1)$.
The space complexity is $O(n)$ as we need to create heaps to hold the numbers we have, and their sizes will grow as the length of the numbers increases.
And, this is the end of the chapter on heaps! Next up, we'll take a look at the technique of backtracking.
Until then, happy coding.
In this new chapter, we're going to take a look at a data structure called a heap, which is a great way to implement an abstract data type called a priority queue. They're so interrelated that priority queues are sometimes referred to as heaps — because heaps are a very efficient way to create a priority queue. But, let's not get ahead of ourselves, and take a deep breath first before we start.
The kind of heap we're interested in is also called a binary heap because it's just a binary tree that has specific properties.
One of them is that it must be a complete binary tree, meaning that all the levels must be filled, and all nodes in the last level should be as far left as possible.
For example, when it comes to shape, this is a complete binary tree:
However, heaps must also be either a max heap or a min heap — all the parent nodes must be either greater than or equal to the values of their children (if it's a max heap); or less than or equal to the values of their children (if it's a min heap).
A max heap might look like this:
A min heap, on the other hand, has the values of parent nodes less than those of their children:
We can create a heap using an array. Since the root node is the most interesting element with either a maximum or minimum value, it'll be the first element in our array, residing at the 0th index.
What's nice about using an array is that, given a parent node's index $i$, its left child will be at the index $2i + 1$, and its right child will be at the index $2i + 2$.
Given that, any child node's parent will be at the index $\lfloor{\frac{(n - 1)}{2}}\rfloor$.
One question we might ask at this moment is that why should we use an array at all?
The answer lies in the word queue of a priority queue. Since a queue is mainly concerned with the first element (following the FIFO principle), an array can be an ideal choice.
In a priority queue, each element has a priority, and the value with the highest priority is dequeued first.
Let's take a look at how we can add an element to a heap.
We know that we have to add the new element to the bottom leftmost place, but once we do that, it might violate the max heap or the min heap property.
And how can we avoid violating the heap-order property?
We'll heapify, of course!
Let's say that we want to add a node with the value 20
:
So, the heapify is the swapping of nodes until we know that the heap-order property is maintained.
A similar thing happens when we need to remove an element. But since we're mainly concerned with the maximum or the minimum element, we just need to remove the root node. So, how are we going to do that?
We start off by swapping the last element (the bottom leftmost one) with the root. Now we can easily remove the "root," which resides as a leaf node. However, we still need to maintain the heap-order property, so we need to heapify again.
Even better thing is that if we have a heap, and continually heapify it, we can sort an array!
Let's build a max heap first:
function buildMaxHeap(arr: number[]) {
/*
Index of the last internal node
(i.e., the parent of the last leaf node,
or, the last non-leaf node).
The last leaf node will reside at index arr.length - 1,
so, we're getting its parent using the formula mentioned above.
*/
let i = Math.floor((arr.length - 1) / 2);
while (i >= 0) {
heapify(arr, i, arr.length);
i--;
}
return arr;
}
Then, the heapify
function:
function heapify(arr: number[], i: number, maxLength: number) {
while (i < maxLength) {
let index = i;
let leftChildIdx = 2 * i + 1;
let rightChildIdx = leftChildIdx + 1;
if (leftChildIdx < maxLength && arr[leftChildIdx] > arr[index]) {
index = leftChildIdx;
}
if (rightChildIdx < maxLength && arr[rightChildIdx] > arr[index]) {
index = rightChildIdx;
}
if (index === i) { return; }
// Swap
[arr[i], arr[index]] = [arr[index], arr[i]];
i = index;
}
}
With a given index i
, we get its left and right children indexes, and if the indexes are within bounds, we check if they are out of order. In that case, we make the index
the index of the child, and swap the two nodes. Then, we continue with that new index, assigning it to i
.
Now, heapify
is nice and all, but how can we actually use it for sorting?
function heapSort(arr: number[]) {
buildMaxHeap(arr);
let lastElementIdx = arr.length - 1;
while (lastElementIdx > 0) {
[arr[0], arr[lastElementIdx]] = [arr[lastElementIdx], arr[0]];
heapify(arr, 0, lastElementIdx);
lastElementIdx--;
}
return arr;
}
[42, 19, 36, 17, 3, 25, 1, 2]
won't change when used in the buildMaxHeap
function, as it's already a max heap!
However, if it were to have 17
as the right child of 42
, then 17
would have 25
as a child, which breaks the heap-order property. So, using buildMaxHeap
with this broken version will correctly swap the 17
and 25
, making it a max heap:
buildMaxHeap([42, 36, 17, 19, 3, 25, 1, 2]);
// -> [42, 36, 25, 19, 3, 17, 1, 2]
In heapSort
, with our newly built max heap, we'll start with swapping the first and last nodes. Then, we'll keep heapifying until we get all the elements in their place.
If we use it with our very own max heap, we can see that it returns the sorted array:
heapSort([42, 19, 36, 17, 3, 25, 1, 2]);
// -> [1, 2, 3, 17, 19, 25, 36, 42]
The examples are adapted from Vaidehi Joshi's article.
Heap sort, as a nice sorting algorithm it is, runs in $O(n \ log \ n)$ time.
We're swapping the first and last elements, and heapifying as we go through each element, so this is also overall an $O(n \ log \ n)$ operation — which makes the time complexity of heapSort
$O(n \ log \ n)$.
Since there is no use of auxiliary space, the space complexity is constant, $O(1)$.
Now, we can take a deep breath. The one and only problem that we're going to look at in this chapter is called Find Median from Data Stream. Until then, happy coding.
Let's start with the description for Serialize and Deserialize Binary Tree:
Serialization is the process of converting a data structure or object into a sequence of bits so that it can be stored in a file or memory buffer, or transmitted across a network connection link to be reconstructed later in the same or another computer environment.
Design an algorithm to serialize and deserialize a binary tree. There is no restriction on how your serialization/deserialization algorithm should work. You just need to ensure that a binary tree can be serialized to a string and this string can be deserialized to the original tree structure.
Clarification: The input/output format is the same as how LeetCode serializes a binary tree. You do not necessarily need to follow this format, so please be creative and come up with different approaches yourself.
For example:
Input: root = [1, 2, 3, null, null, 4, 5]
Output: [1, 2, 3, null, null, 4, 5]
This one sounds easy, at least for the serialize
function. We can just get the nodes one by one, and put them into a string. What could go wrong?
We can indeed get the nodes one by one, and put them into a string for serialize
, but we need a proper method so that when it comes time to deserialize
, we can reconstruct our binary tree.
One way to do it is using breadth-first search, in other words, a level-order traversal.
We'll keep a result array which we'll transform into a string by joining the elements with the comma character (,
).
We have seen how to do a level-order traversal before, but now when we see a null
node, we don't ignore it, and instead add the string 'null'
to our result
. Otherwise, we'll add the stringified version of the node's value using a template literal:
function serialize(root: TreeNode | null): string {
if (root === null) {
return '';
}
let queue = [root];
let result = [];
while (queue.length > 0) {
let node = queue[0];
if (node === null) {
result.push('null');
} else {
queue.push(node.left);
queue.push(node.right);
result.push(`${node.val}`);
}
queue.shift();
}
return result.join(',');
}
result
will include all the nodes level by level. For example, if our tree is the one in the example image above, then the serialize
function will return this:
'1,2,3,null,null,4,5,null,null,null,null'
So, '1'
is the root node, and the next level is '2,3'
.
The next level after that is 'null,null,4,5'
. The last four 'null'
s are for the children of '4'
and '5'
.
That's fine, but how can we use this string to construct a binary tree in deserialize
?
The first thing we can do is check the edge case when the input data
is an empty string. In that case, we can return null
:
if (data === '') {
return null;
}
Otherwise, since the data
is a string that's constructed with commas, we can split it to get the values in an array.
let vals = data.split(',');
We have to start with the root node somehow, and we know that the very first element in vals
has the value for the root node. So we can construct it like this:
let root = new TreeNode(+vals[0]);
Now, we have to walk the tree, and make the tree.
Since we used a level-order traversal in serialize
, we can use a queue again, initializing it with the root
:
let queue = [root];
Now, we can start iterating from the second element of the vals
array since we already have the root, and add the nodes to our queue
. And, to get the correct right and left children, we can simply use a flag value:
let isRightChild = false;
for (let i = 1; i < vals.length; i++) {
let nodeVal = vals[i];
let node = null;
if (n !== 'null') {
node = new TreeNode(+nodeVal);
queue.push(node);
}
if (isRightChild) {
queue[0].right = node;
queue.shift();
isRightChild = false;
} else {
queue[0].left = node;
isRightChild = true;
}
}
Since we already initialize node
as null
, all the null
nodes will be "inserted" correctly.
With our data
string looking like this:
'1,2,3,null,null,4,5,null,null,null,null'
In the for
loop, we'll start with '2'
, and push it into our queue
. Our isRightChild
flag is initially false
, so we'll add 2
as the left child of the first element in the queue
, which is the root node. (Remember that we initialized our queue
with root
.)
Then, we'll go to '3'
, push it into our queue
, and since isRightChild
is now true
, we'll add it as the right child of root
.
Now that we're finished with the root
's children, we can remove it using queue.shift()
, and go on to the next value.
The next value is 'null'
. In this case, we don't construct a TreeNode
, but just point the left child of the first element in our queue
(which is the node with the value 2
) to it.
We go on until there aren't any elements left in vals
.
Finally, deserialize
looks like this:
function deserialize(data: string): TreeNode | null {
if (data === '') {
return null;
}
let vals = data.split(',');
let root = new TreeNode(+vals[0]);
let queue = [root];
let isRightChild = false;
for (let i = 1; i < vals.length; i++) {
let nodeVal = vals[i];
let node = null;
if (n !== 'null') {
node = new TreeNode(+nodeVal);
queue.push(node);
}
if (isRightChild) {
queue[0].right = node;
queue.shift();
isRightChild = false;
} else {
queue[0].left = node;
isRightChild = true;
}
}
return root;
}
And, this is the final solution in TypeScript:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
/*
* Encodes a tree to a single string.
*/
function serialize(root: TreeNode | null): string {
if (root === null) {
return '';
}
let queue = [root];
let result = [];
while (queue.length > 0) {
let node = queue[0];
if (node === null) {
result.push('null');
} else {
queue.push(node.left);
queue.push(node.right);
result.push(`${node.val}`);
}
queue.shift();
}
return result.join(',');
}
/*
* Decodes your encoded data to tree.
*/
function deserialize(data: string): TreeNode | null {
if (data === '') {
return null;
}
let vals = data.split(',');
let root = new TreeNode(+vals[0]);
let queue = [root];
let isRightChild = false;
for (let i = 1; i < vals.length; i++) {
let n = vals[i];
let node = null;
if (n !== 'null') {
node = new TreeNode(+n);
queue.push(node);
}
if (isRightChild) {
queue[0].right = node;
queue.shift();
isRightChild = false;
} else {
queue[0].left = node;
isRightChild = true;
}
}
return root;
}
/**
* Your functions will be called as such:
* deserialize(serialize(root));
*/
The time and space complexity will both be $O(n)$ for both serialize
and deserialize
: In both functions, we process each node, and use a queue to hold all the nodes.
We can take a deep breath now, because we have just finished the chapter on trees! It's time to take a short hike and appreciate the real trees around us (and maybe plant one or two!).
Next up, we'll take a look at heaps — until then, happy coding.
]]>Let's start with the description for Binary Tree Maximum Path Sum:
A path in a binary tree is a sequence of nodes where each pair of adjacent nodes in the sequence has an edge connecting them. A node can only appear in the sequence at most once. Note that the path does not need to pass through the root.
The path sum of a path is the sum of the node's values in the path.
Given the
root
of a binary tree, return the maximum path sum of any non-empty path.
For example:
Input: root = [1, 2, 3]
Output: 6
Explanation: The optimal path is 2 -> 1 -> 3 with a path sum of 2 + 1 + 3 = 6.
Or:
Input: root = [-10, 9, 20, null, null, 15, 7]
Output: 42
Explanation: The optimal path is 15 -> 20 -> 7 with a path sum of 15 + 20 + 7 = 42.
Although some people have found it quite simple, this is a challenging problem. So, we'll take a deep breath, and take a look at a recursive depth-first search approach as shown by NeetCode.
When all we have is a root node and its children, the maximum value we can have is the total values of all three of them. Of course, however, if there is a negative value that's going to lower the maximum value we can get, we shouldn't include it.
From any root node's perspective, what we should do is get the total maximum value from the left subtree and the total maximum value from the right subtree. However, if we do the same thing for a node in one of the subtrees, we'll break our path.
That is, if we are going to add the values of both of the children of a node, then we can't do that again for another node, because it will break the path. Once we have chosen both the left and right children of a node, we cannot have both children of another node again, we can only choose one of them. In other words, we can only split once.
Let's imagine that we are the root node. We'll get the values of both our left and right children. We'll get the maximum value we can get from our left subtree, but each node in the left subtree can choose only one of their children. The same is true for the right subtree as well:
/*
maxLeft: the maximum value gained from the left subtree
where each node chose only one of their children
maxRight: the maximum value gained from the right subtree
where each node chose only one of their children
*/
let currentMax = root.val + maxLeft + maxRight;
Once again, the reason that the nodes in the subtrees have to choose only one of their children is that the root node have already chosen both of its children. Otherwise, our path will break.
But, how can we get maxLeft
and maxRight
?
That's where the depth-first of depth-first search comes in:
let maxLeft = dfs(root.left);
let maxRight = dfs(root.right);
We'll initialize our result value as the minimum possible value of -Infinity
(because we want to get the possible maximum):
let result = -Infinity;
Inside dfs
, we need to update this value each time we calculate currentMax
:
result = Math.max(result, currentMax);
Remember that the nodes in the subtrees have to choose only one of their children? That's what our dfs
function will return:
return Math.max(root.val + maxLeft, root.val + maxRight, 0);
That's all there is to it. The final solution in TypeScript looks like this:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function maxPathSum(root: TreeNode | null): number {
let result = -Infinity;
function dfs(root: TreeNode | null) {
if (root === null) {
return 0;
}
let maxLeft = dfs(root.left);
let maxRight = dfs(root.right);
let currentMax = root.val + maxLeft + maxRight;
result = Math.max(result, currentMax);
return Math.max(root.val + maxLeft, root.val + maxRight, 0);
}
dfs(root);
return result;
}
The time complexity is $O(n)$ as we look at each node in the tree once. The space complexity is $O(h)$—where $h$ is the height of the tree—because of the stack frames created each time with the recursive calls.
It's time to take another deep breath, because next up is yet another problem that's labeled as hard — Serialize and Deserialize Binary Tree. Until then, happy coding.
]]>Let's look at the description for this problem:
Given two integer arrays
preorder
andinorder
wherepreorder
is the preorder traversal of a binary tree andinorder
is the inorder traversal of the same tree, construct and return the binary tree.
For example:
Input: preorder = [3, 9, 20, 15, 7], inorder = [9, 3, 15, 20, 7]
Output: [3, 9, 20, null, null, 15, 7]
Even though it has a medium difficulty label, I think this one can be quite challenging.
So, let's start with what we know.
We know that a preorder traversal first looks at the root node, then goes to the left subtree, then the right subtree.
To construct any tree, first of all, we need to start with the root. Here, we can do it easily like this:
let root = new TreeNode(preorder[0]);
And, now?
We need to add the left and right children of the root somehow.
One idea is that we can get them recursively by partitioning the arrays. Let's look at this binary tree (which is also a binary search tree):
The preorder
array in this case would be this:
[8, 3, 1, 6, 4, 7, 10, 14, 13]
And, inorder
would look like this:
[1, 3, 4, 6, 7, 8, 10, 13, 14]
What we know about the inorder
array is that all the values to the left of the root are in the left subtree. And, all the values to the right of the root are in the right subtree.
Remember that inorder traversal gets all the nodes in the left subtree first, then the root. Preorder traversal, however, gets the root first, then all the nodes in the left subtree.
Therefore, the nodes in the left subtree + the root node will be the in the same portion in both of the arrays:
preorder
: [8, 3, 1, 6, 4, 7, 10, 14, 13]
inorder
: [1, 3, 4, 6, 7, 8, 10, 13, 14]
If we get the root node's index in the inorder
array, we can easily get the left subtree in the preorder
array as well. The rest will be the right subtree:
left subtree | right subtree | |
---|---|---|
preorder | [8, 3, 1, 6, 4, 7, 10, 14, 13] | [8, 3, 1, 6, 4, 7, 10, 14, 13] |
inorder | [1, 3, 4, 6, 7, 8, 10, 13, 14] | [1, 3, 4, 6, 7, 8, 10, 13, 14] |
We can then slice the arrays to get the subtrees:
let rootIdx = inorder.findIndex(i => i === root.val);
let preorderLeft = preorder.slice(1, rootIdx + 1);
let inorderLeft = inorder.slice(0, rootIdx);
let preorderRight = preorder.slice(rootIdx + 1, preorder.length);
let inorderRight = inorder.slice(rootIdx + 1, inorder.length);
preorder
andinorder
consist of unique values.
So we'll always get the correct index.
Now that we know where the subtrees reside, we can build our tree recursively.
Our base case is when one of the arrays is empty:
if (preorder.length === 0 || inorder.length === 0) {
return null;
}
And, here's the final solution in TypeScript:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function buildTree(preorder: number[], inorder: number[]): TreeNode | null {
if (preorder.length === 0 || inorder.length === 0) {
return null;
}
let root = new TreeNode(preorder[0]);
let rootIdx = inorder.findIndex(i => i === root.val);
// Get the left and right subtrees in preorder and inorder arrays
let preorderLeft = preorder.slice(1, rootIdx + 1);
let inorderLeft = inorder.slice(0, rootIdx);
let preorderRight = preorder.slice(rootIdx + 1, preorder.length);
let inorderRight = inorder.slice(rootIdx + 1, inorder.length);
root.left = buildTree(preorderLeft, inorderLeft);
root.right = buildTree(preorderRight, inorderRight);
return root;
}
For each node that we calculate the index of, we also slice the arrays. Slicing itself is an O(n) operation, as well as finding the index. So, the overall time complexity is $O(n^2)$. The space complexity is in the worst case—I think—$O(n^2)$ as well because we create the slices in each recursive call which in the worst case can have $O(n)$ depth.
The explanation of this approach can also be found in NeetCode's video.
Next up, we'll look at the problem called Binary Tree Maximum Path Sum. Until then, happy coding.
]]>Let's start with the description for Kth Smallest Element in a BST:
Given the
root
of a binary search tree, and an integerk
, return thekth
smallest value (1-indexed) of all the values of the nodes in the tree.
For example:
Input: root = [3, 1, 4, null, 2], k = 1
Output: 1
Or:
Input: root = [5, 3, 6, 2, 4, null, null, 1], k = 3
Output: 3
This problem naturally lends itself to a neat recursive solution using a neat traversal algorithm.
Remember inorder traversal that gives us the values in a binary search tree in order? Exactly.
Here, we can use it to build a stack called values
, and get the k
th item (1-indexed of course, as the problem description states):
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function kthSmallest(root: TreeNode | null, k: number): number {
let values = [];
function inorderWalk(node: TreeNode | null) {
if (node === null || values.length === k) {
return;
}
inorderWalk(node.left);
values.push(node.val);
inorderWalk(node.right);
}
inorderWalk(root);
return values[k - 1];
}
The time complexity is going to be $O(n)$ because we traverse the whole tree and visit each node once. The space complexity is also $O(n)$ as we keep a stack that holds all the nodes.
That was a simple and elegant solution, but let's now take a breath and look at another solution that uses an iterative version of the inorder traversal:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function kthSmallest(root: TreeNode | null, k: number): number {
let stack = [];
let currentNode = root;
while (k > 0) {
while (currentNode !== null) {
stack.push(currentNode);
currentNode = currentNode.left;
}
currentNode = stack.pop();
k--;
if (k === 0) {
return currentNode.val;
}
currentNode = currentNode.right;
}
}
Here, we go as deep as we can in the left subtree, adding the nodes to our stack
as we go. Once we reach a node that doesn't have a left child, we pop from the stack. Each time we do so, we'll be getting the values in sorted order.
Once we pop the k
th time, we'll return the value of the current node and be done with it. Otherwise, we'll go to the right subtree.
The time complexity is $O(n)$ because in the worst case where k
is $n$, we'll end up visiting each node. The space complexity will be $O(h)$—where $h$ is the height of the tree—because in the worst case, we'll store all the nodes from the root to the deepest leaf node.
The next problem is called Construct Binary Tree from Preorder and Inorder Traversal. Yet another mouthful title, but we'll survive. Until then, happy coding.
]]>The description for Validate Binary Search Tree is:
Given the
root
of a binary tree, determine if it is a valid binary search tree (BST).A valid BST is defined as follows:
- The left subtree of a node contains only nodes with keys less than the node's key.
- The right subtree of a node contains only nodes with keys greater than the node's key.
- Both the left and right subtrees must also be binary search trees.
For example:
Input: root = [2, 1, 3]
Output: true
Or:
Input: root = [5, 1, 4, null, null, 3, 6]
Output: false
Explanation: The root node's value is 5 but its right child's value is 4.
Even though this one looks easy to solve recursively, there is a catch.
Let's say we naively wrote something like this:
function isValidBST(root: TreeNode | null): boolean {
if (root === null) {
return true;
}
if (
(root.left !== null && root.left.val >= root.val) ||
(root.right !== null && root.right.val <= root.val)
) {
return false;
}
return isValidBST(root.left) && isValidBST(root.right);
}
This would return true
for the second example above, which is wrong. In that example, although the right subtree is valid in its own right, the whole tree itself is not a valid binary search tree because 3 should be in the left subtree of the root.
So, let's look at a proper solution in TypeScript, adapted from NeetCode's explanation:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function isValidBST(root: TreeNode | null): boolean {
function valid(node: TreeNode | null, left: number, right: number) {
if (node === null) {
return true;
}
if (node.val >= right || node.val <= left) {
return false;
}
return valid(node.left, left, node.val) && valid(node.right, node.val, right);
}
return valid(root, -Infinity, Infinity);
}
Here, we give boundaries for the left and right values that we're going to check a node's value against. Here, the value should be greater than left
, and less than right
. We start with negative and positive infinity because the root can be any value.
Inside valid
, we only update the right boundary for the left child, and we only update the left boundary for the right child.
valid(node.left, left, node.val)
Likewise, a right child can be as large as it can be, as long as it's larger than the root. Therefore, we only update the left boundary for it:
valid(node.right, node.val, right)
Of course, we return false
when BST rules are broken: if the given node's value is greater than or equal to the right boundary, or less than or equal to the left boundary.
The time complexity is going to be $O(n)$ as we do the comparisons for each node in the tree once. The space complexity will be $O(h)$ where $h$ is the height of the tree because of the recursive function we use, as it'll create a new stack frame with each call.
The next problem is called Kth Smallest Element in a BST, which sounds exciting enough. Until then, happy coding.
]]>Let's start with the description for Binary Tree Level Order Traversal:
Given the
root
of a binary tree, return the level order traversal of its nodes' values. (i.e., from left to right, level by level).
For example:
Input: root = [3, 9, 20, null, null, 15, 7]
Output: [[3], [9, 20], [15, 7]]
Level-order traversal is another name for breadth-first search. In this problem, we only need to get the values of the nodes in each level.
My initial solution in TypeScript looked like this:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function levelOrder(root: TreeNode | null): number[][] {
if (root === null) {
return [];
}
let result = [];
let currentLevel = [root];
while (currentLevel.length > 0) {
let nextLevel = [];
for (let node of currentLevel) {
if (node !== null && node.left !== null) {
nextLevel.push(node.left);
}
if (node !== null && node.right !== null) {
nextLevel.push(node.right);
}
}
result.push(currentLevel.map(node => node.val));
currentLevel = nextLevel;
}
return result;
}
What we do is, first, check if root
is null — in that case, we immediately return an empty array.
Then, we initialize a result
array and another array to hold the nodes in the current level (which initially only has root
).
As we traverse the tree, we add the nodes in the next level — that is, the left and right children of the nodes in the current level we're looking at. Once we're done with the current level, we add the values in it to result
, and go to the next level in the tree, doing the same thing until there are no nodes left to look at.
The time complexity is $O(n)$ as we visit each node once in the tree. Since the largest level in a binary tree can be of length $\frac{n + 1}{2}$ where $n$ is the total number of nodes in the tree, the space complexity also ends up being $O(n)$ as we store each level.
Similar to the version above, another solution might look like this (as in this example by NeetCode) — let's see it in Python this time:
# Definition for a binary tree node.
# class TreeNode:
# def __init__(self, val=0, left=None, right=None):
# self.val = val
# self.left = left
# self.right = right
def levelOrder(self, root: Optional[TreeNode]) -> List[List[int]]:
result = []
queue = collections.deque()
queue.append(root)
while queue:
current_level = []
current_queue_length = len(queue)
for i in range(current_queue_length):
node = queue.popleft()
if node:
current_level.append(node.val)
queue.append(node.left)
queue.append(node.right)
if current_level:
result.append(current_level)
return result
In this version, we use a deque
for our queue. We initially append root
to it, and as we traverse the tree, we append the values to current_level
. Once we're done with a level, we append the values to result
.
We can try a similar logic in TypeScript as well:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function levelOrder(root: TreeNode | null): number[][] {
let result = [];
let queue = [root];
while (queue.length > 0) {
let currentLevel = [];
let currentQueueLength = queue.length;
for (let i = 0; i < currentQueueLength; i++) {
let node = queue.shift();
if (node !== null) {
currentLevel.push(node.val);
queue.push(node.left);
queue.push(node.right);
}
}
if (currentLevel.length > 0) {
result.push(currentLevel);
}
}
return result;
}
(Note that we're mutating queue
's length inside the for
loop with queue.push
when we add the left and right children.)
The time and space complexity are the same for this solution as well, that is, both are $O(n)$.
Next up, we'll validate a binary search tree in the problem with the appropriate title, Validate Binary Search Tree. Until then, happy coding.
]]>The description for this one states:
Given a binary search tree (BST), find the lowest common ancestor (LCA) node of two given nodes in the BST.
According to the definition of LCA on Wikipedia: “The lowest common ancestor is defined between two nodes
p
andq
as the lowest node inT
that has bothp
andq
as descendants (where we allow a node to be a descendant of itself).”
For example:
Input: root = [6, 2, 8, 0, 4, 7, 9, null, null, 3, 5], p = 2, q = 8
Output: 6
Explanation: The LCA of nodes 2 and 8 is 6.
Or:
Input: root = [6, 2, 8, 0, 4, 7, 9, null, null, 3, 5], p = 2, q = 4
Output: 2
Explanation: The LCA of nodes 2 and 4 is 2, since a node can be a descendant of itself according to the LCA definition.
If this is the first time you're hearing the term lowest common ancestor in the context of trees, it might sound a bit intimidating. But, it's indeed easy to see how it works.
Like many other problems, the solution awaits that we find which cases to look out for.
One of those cases is when p
and q
are in the different subtrees: that is, either p
is greater than root
and q
is less than root
, or p
is less than root
and q
is greater than root
. In that case, we know that the root
will be their lowest common ancestor.
Since a node can be a descendant of itself, we can also return it when it's the lowest common ancestor to both nodes, as in the second example above.
My initial solution in TypeScript was this:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function lowestCommonAncestor(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
if (
root.val === p.val ||
root.val === q.val ||
(p.val < root.val && q.val > root.val) ||
(p.val > root.val && q.val < root.val)
) {
return root;
}
if (p.val > root.val && q.val > root.val) {
return lowestCommonAncestor(root.right, p, q);
}
if (p.val < root.val && q.val < root.val) {
return lowestCommonAncestor(root.left, p, q);
}
}
We're using (yet again) recursion. If the values of p
and q
are greater than root
, we pass root.right
as root
to our function, otherwise, we pass root.left
.
The base cases are the ones mentioned above. It looks a bit confusing, so let's look at it closely.
This one:
(p.val < root.val && q.val > root.val)
And this one:
(p.val > root.val && q.val < root.val)
check whether p
and q
are in different subtrees — that is, when a split happens. In that case, root
is their lowest common ancestor. If one of them is true, we need to return root
.
Other conditions are when root.val === p.val
or root.val === q.val
. In these cases, either p
or q
is the same as the root
, which means that it is the lowest common ancestor, so we can return root
as well.
Both the time and space complexity will be $O(h)$—where $h$ is the height of the tree. We touch one node at each level as we go (hence the time complexity). And, because of the recursion, we create a new stack frame for each function call, so the additional space is proportionate to the height of the tree.
We can do better for the space complexity, and not use recursion at all — like this:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function lowestCommonAncestor(root: TreeNode | null, p: TreeNode | null, q: TreeNode | null): TreeNode | null {
let currentNode = root;
while (currentNode !== null) {
if (p.val > currentNode.val && q.val > currentNode.val) {
currentNode = currentNode.right;
} else if (p.val < currentNode.val && q.val < currentNode.val) {
currentNode = currentNode.left;
// One of them is larger and one of them is smaller than
// the currentNode (i.e., a split happened)
} else {
return currentNode;
}
}
}
This version is perhaps more readable and cleaner than the first one. When both p
and q
have greater values than currentNode
(which is initially root
), we go to the right subtree, otherwise if their values are less, we go to the left subtree. In all the other cases (the ones we've looked at in the recursive version), we just return currentNode
.
The time complexity is again $O(h)$ where $h$ is the height, for the same reason that we touch each node at every level. However, the space complexity is $O(1)$ as we don't need additional space.
The next problem is a fun and essential one, Binary Tree Level Order Traversal. Until then, happy coding.
]]>Let's start with the description for this one:
Given the roots of two binary trees
root
andsubRoot
, returntrue
if there is a subtree ofroot
with the same structure and node values ofsubRoot
andfalse
otherwise.A subtree of a binary tree
tree
is a tree that consists of a node intree
and all of this node's descendants. The treetree
could also be considered as a subtree of itself.
For example:
Input: root = [3, 4, 5, 1, 2], subRoot = [4, 1, 2]
Output: true
Or:
Input: root = [3, 4, 5, 1, 2, null, null, null, null, 0], subRoot = [4, 1, 2]
Output: false
When it comes to tree problems (at least the ones we've looked at so far), recursion seems to be a natural choice, as trees themselves are recursive structures. And this problem is no different.
What we need to do is check whether a subtree of the first tree we're given is the same as another tree.
In the previous problem, we did just that: we've checked if two trees are the same.
So, we can use the same function here as well. Here's a concise isSameTree
function:
function isSameTree(p: TreeNode | null, q: TreeNode | null): boolean {
if (p === null && q === null) {
return true;
}
if (p !== null && q !== null && p.val === q.val) {
return isSameTree(p.left, q.left) && isSameTree(p.right, q.right);
} else {
return false;
}
}
Now for this problem, we need to check if, with the given root
and subRoot
, the trees are the same. If not, we need to recursively search left and right subtrees for sameness.
However, coming up with edge cases was harder than I thought. For one thing, we don't want to check the case when both root
and subRoot
are null
— they need to be handled separately. NeetCode's explanation was very helpful to sort this out in my mind.
So, we can check the case when the subRoot
is null
, in that case we can return true
because an empty tree is a subtree of every tree.
However, when the root
is null
and the subRoot
is not null
, we need to return false
, because that means the given subtree is not in the tree.
When the current root
and subRoot
are holding the same tree, we can return true
, but in other cases, we recursively search the left and right subtrees until they result in the same tree or the root ends up being null
:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function isSubtree(root: TreeNode | null, subRoot: TreeNode | null): boolean {
// The order of these two base cases is important!
if (subRoot === null) {
return true;
}
if (root === null) { // (root === null && subRoot !== null)
return false;
}
if (isSameTree(root, subRoot)) {
return true;
} else {
return isSubtree(root.left, subRoot) || isSubtree(root.right, subRoot);
}
}
For each node in the first tree, we use the isSameTree
function to check if the tree is the same as the given subtree. Therefore the time complexity is in the worst case $O(n^2)$.
Because of the recursive calls, the space complexity is—I think—in the worst case $O(n)$.
Next up is Lowest Common Ancestor of a Binary Search Tree. That's a mouthful, but we'll manage.
Until then, happy coding.
Let's see the description for this problem:
Given the roots of two binary trees
p
andq
, write a function to check if they are the same or not.Two binary trees are considered the same if they are structurally identical, and the nodes have the same value.
For example:
Input: p = [1, 2, 3], q = [1, 2, 3]
Output: true
Or:
Input: p = [1, 2], q = [1, null, 2]
Output: false
This problem lends itself easily to recursion. After all, two trees are the same if their subtrees are the same.
My initial solution in TypeScript looked like this:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function isSameTree(p: TreeNode | null, q: TreeNode | null): boolean {
if (p !== null && q !== null && p.val !== q.val) {
return false;
}
if (p === null) {
return q === null;
}
if (q === null) {
return p === null;
}
return isSameTree(p.left, q.left) && isSameTree(p.right, q.right);
}
First, we check if both nodes are not null
and their values are not the same; in that case, we can return false
.
Then, if one of them is null
, we can return if the other one is null
as well — if it is, the return value will be true
, otherwise false
. Of course, we apply isSameTree
to the left and right children of the node recursively.
The time complexity will be proportionate to the total number of nodes in both trees, that is, $O(n)$ where $n$ is the total number of nodes. Because of the recursive calls, the space complexity, in the worst case—I think—can be said to be $O(n)$ where $n$ is the overall total number of nodes.
Note that we can also write another concise (and better?) solution like this:
function isSameTree(p: TreeNode | null, q: TreeNode | null): boolean {
if (p === null && q === null) {
return true;
}
if (p !== null && q !== null && p.val === q.val) {
return isSameTree(p.left, q.left) && isSameTree(p.right, q.right);
} else {
return false;
}
}
Since the only way both trees are the same is either when they both are empty or all their nodes are the same. We first check whether both nodes are null
, if so, we return true
. We continue checking the left and right subtrees recursively only if both nodes are not null
and their values are the same. In other cases, the trees can't be the same, so we return false
.
The next problem is called Subtree of Another Tree. Until then, happy coding.
]]>The description for Maximum Depth of Binary Tree says that:
Given the
root
of a binary tree, return its maximum depth.A binary tree's maximum depth is the number of nodes along the longest path from the root node down to the farthest leaf node.
For example:
Input: root = [3, 9, 20, null, null, 15, 7]
Output: 3
The word depth in the title (kind of) hints at a depth-first search approach. One way to do it is with recursion:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function maxDepth(root: TreeNode | null): number {
if (root === null) {
return 0;
}
return 1 + Math.max(maxDepth(root.left), maxDepth(root.right));
}
It looks elegant and simple: what we're doing is calculating the depth of left and right subtrees from the root node's perspective, and returning the larger one of them plus 1 (for the root node).
The time complexity is $O(n)$ as we visit each node in the tree. The space complexity can be said to be $O(h)$ where $h$ is the height of the tree (because each recursive call creates a new stack frame). However, in the case of an unbalanced tree, it is going to be $O(n)$.
Depth-first search doesn't always have to be recursive, so let's look at an iterative version, as shown by NeetCode (but let's write it in TypeScript):
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function maxDepth(root: TreeNode | null): number {
// Keep the current node and current depth in stack
let stack: [TreeNode | null, number][] = [[root, 1]];
let result = 0;
while (stack.length > 0) {
let [currentNode, depth] = stack.pop();
if (currentNode !== null) {
result = Math.max(result, depth);
stack.push([currentNode.left, depth + 1]);
stack.push([currentNode.right, depth + 1]);
}
}
return result;
}
Here, we use a stack where we keep the current node and current depth — starting with the root node, and depth of 1.
We pop the last item and continue pushing its left and right children, also incrementing depth
as we do so. result
is keeping track of the maximum depth, so at the end when our stack is empty, we just return it.
The time and space complexity are both $O(n)$ — we go through each node; and keep a stack whose size will grow as the number of nodes in the tree grows.
The next problem is what seems to be an easy one, called Same Tree. Until then, happy coding.
]]>Let's start with the description for Invert Binary Tree:
Given the
root
of a binary tree, invert the tree, and return its root.
For example:
Although this one has a very simple recursive solution, let's see one approach that I come up with initially:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function invertTree(root: TreeNode | null): TreeNode | null {
let queue = [];
queue.push(root);
while (queue.length > 0) {
let currentNode = queue[0];
if (currentNode !== null) {
queue.push(currentNode.left);
queue.push(currentNode.right);
[currentNode.left, currentNode.right] = [currentNode.right, currentNode.left];
}
queue.shift();
}
return root;
}
This version uses level-order traversal; we store the children of each node in a queue as we go through each level in the tree, and swap the node's left and right children.
Since we visit each node once, the time complexity is $O(n)$.
The space complexity will be proportionate to the size of the queue we keep, which holds a whole level at a time, which amounts to $O(n)$ overall.
Now, let's look at the recursive solution, which is much simpler:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
function invertTree(root: TreeNode | null): TreeNode | null {
if (root === null) {
return null;
}
[root.left, root.right] = [root.right, root.left];
invertTree(root.left);
invertTree(root.right);
return root;
}
The time complexity is $O(n)$ as we visit each node to swap its left and right children. The space complexity is also $O(h)$—where $h$ is the height of the tree—because of the recursive calls to the function on each level.
Next up is Maximum Depth of Binary Tree. Until then, happy coding.
]]>
In this new chapter of the series, we'll take a look at a non-linear data structure that is pretty familiar to many developers: trees.
Whether familiarity breeds contempt or not is arguable, so let's start with the simplest component of a tree: a node.
Trees (like linked lists) are made up of nodes. The simplest version of a tree is just the root node which doesn't have any edges (links) pointing to it; that is, it has no parent nodes. It is the starting point, in a way.
A tree can only have one root node, and when you think about it, if there are $n$ nodes in a tree, that means there are $n - 1$ edges (links) because there is no edge (link) pointing to the root node.
If you've looked at a tree long enough, you might've had a moment of epiphany—a tree has smaller trees within itself. A branch may as well be a trunk, having other branches for the little tree it constitutes.
The tree data structure is like this, it is recursive: a child node can be the root of a subtree.
Two terms that are important when it comes to a tree node are depth and height.
The depth of a node is how far away it is from the root node (how many edges (links) does it take to travel from the root node to it), and the height of a node is how far away it is from its furthest leaf node (which is the node that has no children).
A balanced tree is one where the heights of the left and right subtrees of every node differ by at most 1.
A binary tree is a tree where each node has at most two children. That is, a node can have a left child node and a right child node, and no more.
The maximum number of nodes in a binary tree is $2^h - 1$ where $h$ is the height of the tree.
This is where the binary of the binary tree makes sense: on each level, the number of nodes grows proportionately to the exponents of $2$. For example, the number of nodes on the first level (the 0th level) is $2^0 = 1$, which is just the root node. The second level has at most 2 nodes: $2^1 = 2$ (remember that we're counting from $0$, so the second level is $1$).
A binary search tree is a binary tree where the values smaller than the node go to its left and those greater than it go to its right:
$\text{left children } \lt \text{ node } \lt \text{ right children}$
Here is an example:
If we want to insert a new node into a binary search tree, we need to insert it into its proper place to keep the properties of a BST intact.
Let's see it in JavaScript.
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
/**
* @param {TreeNode} root
* @param {number} val
* @return {TreeNode}
*/
function insertIntoBST(root, val) {
if (root === null) {
return new TreeNode(val);
}
if (val < root.val) {
root.left = insertIntoBST(root.left, val);
} else {
root.right = insertIntoBST(root.right, val);
}
return root;
}
Here, we traverse the tree until we find a space (a null
position) for our value that is waiting to be a TreeNode
. We start with the root node; if the value of the node-to-be-inserted is less than the value of the root node, we go left (passing root.left
as the root
argument to the function). Otherwise, we go right (passing root.right
as the root
argument).
The time complexity is $O(h)$ where $h$ is the height of the tree. On each level in the tree, we either go left or right, so we don't necessarily visit every single node. The space complexity is also $O(h)$ because we use recursion, creating a new stack frame for each function call.
Note that if the tree is unbalanced, the time and space complexity can be said to be $O(n)$.
We can also do it iteratively, using pointers only:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
/**
* @param {TreeNode} root
* @param {number} val
* @return {TreeNode}
*/
function insertIntoBST(root, val) {
if (root === null) {
return new TreeNode(val);
}
let prevNode = null;
let currentNode = root;
while (currentNode !== null) {
prevNode = currentNode;
if (val < currentNode.val) {
currentNode = currentNode.left;
} else {
currentNode = currentNode.right;
}
}
if (val < prevNode.val) {
prevNode.left = new TreeNode(val);
} else {
prevNode.right = new TreeNode(val);
}
return root;
}
Here, we do the same thing — iterating until we find the correct place, but also keeping track of the parent node. Then, we insert the node as either the left or the right child of the parent, depending on its value.
The time complexity is again $O(h)$ (or if the tree is unbalanced, $O(n)$) for the same reason as in the recursive solution. However, the space complexity is constant — $O(1)$ as we only use pointers.
The challenging thing when deleting a node from a BST is keeping the BST as a BST. All smaller values should still go to the root node's left subtree, and all those that are larger should go to the root node's right subtree.
Let's take a look at how we might do it in JavaScript:
/**
* Definition for a binary tree node.
* class TreeNode {
* val: number
* left: TreeNode | null
* right: TreeNode | null
* constructor(val?: number, left?: TreeNode | null, right?: TreeNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.left = (left === undefined ? null : left)
* this.right = (right === undefined ? null : right)
* }
* }
*/
/**
* @param {TreeNode} root
* @param {number} key
* @return {TreeNode}
*/
function deleteNode(root, key) {
if (root === null) {
return root;
}
if (key < root.val) {
root.left = deleteNode(root.left, key);
} else if (key > root.val) {
root.right = deleteNode(root.right, key);
} else {
// Node-to-be-deleted has no children
if (root.left === null && root.right === null) {
return null;
}
// If either the left or the right child exists,
// return the one that exists as the new child
// of the parent node (of the node-to-be-deleted)
if (root.left === null || root.right === null) {
return root.left ? root.left : root.right;
}
// If both children exist, traverse the left subtree, get its maximum value...
let currentNode = root.left;
while (currentNode.right !== null) {
currentNode = currentNode.right;
}
// ...replace it with the node-to-be-deleted
root.val = currentNode.val;
// ...then apply the recursion to the left subtree to get rid of the duplicate value
root.left = deleteNode(root.left, root.val);
}
return root;
}
We traverse the tree until we find the node to be deleted. Once we find it, there are several things to do.
In the case where it doesn't have any child nodes, we can return null
and be done with it.
If it has one child node, we can return the one that exists using the ternary operation (return root.left ? root.left : root.right
).
For example, in the image, if the node-to-be-deleted is 10 (it has only right child node with the value 14), we make 14 the right child of 8. It doesn't break our BST, because those that are larger than 8 continue to be in the right subtree of 8.
Otherwise, if both the left and right children of the node-to-be-deleted exist, we need to do something different.
In this case, we'll replace the node-to-be-deleted with the largest value in the left subtree.
However, after replacing, we'll have two nodes of the same value in both places, so we need to apply deleteNode
itself to the subtree that we've taken our replacement node from.
This is all done to keep the BST as BST. It might be a bit difficult to wrap one's head around at first, but NeetCode has a detailed explanation of this problem.
let currentNode = root.right;
while (currentNode.left !== null) {
currentNode = currentNode.left;
}
root.val = currentNode.val;
root.right = deleteNode(root.right, root.val);
Similar to inserting into a BST, both the time and space complexity of deleting from a BST will be $O(h)$ where $h$ is the height of the tree.
We'll take a brief look at two of the most famous ways to traverse a tree where the order in which we visit the nodes matters: depth-first search and breadth-first search.
In a depth-first search, we traverse through a branch until we get to a leaf node. Then, we backtrack and do the same thing with another branch.
There are three common ways to do a depth-first search:
It goes like this: We first visit the node, then go on to its left subtree, then the right subtree.
$$\text{node \ -> \ left subtree \ -> \ right subtree}$$
We can do a preorder walk recursively:
function preorderWalk(node) {
if (node === null) {
return;
}
console.log(node.val);
preorderWalk(node.left);
preorderWalk(node.right);
}
It goes like this: we first visit the left subtree, then the node, then the right subtree.
$$\text{left subtree \ -> \ node \ -> \ right subtree}$$
We can do an inorder walk recursively as well:
function inorderWalk(node) {
if (node === null) {
return;
}
inorderWalk(node.left);
console.log(node.val);
inorderWalk(node.right);
}
It goes like this: we first visit the left subtree, then the right subtree, and finally the node.
$$\text{left subtree \ -> \ right subtree \ -> \ node}$$
We can do a postorder walk recursively:
function postorderWalk(node) {
if (node === null) {
return;
}
postorderWalk(node.left);
postorderWalk(node.right);
console.log(node.val);
}
In breadth-first search, we visit the nodes level by level, that is, visiting every child of a node first before moving on.
A queue is used when implementing a BFS. Since we don't have edges connecting all the children on one level together, it makes sense to keep them in a queue and visit each one when their time comes.
When a node is added to the queue and not have been visited yet, it's called a discovered node.
A simple BFS operation looks like this (which is repeated until the queue is empty):
Note that the breadth-first search is also known as level-order traversal.
Let's see a simple example of a level-order traversal in JavaScript:
function levelOrderWalk(root) {
if (root === null) {
return;
}
let queue = [];
queue.push(root);
while (queue.length > 0) {
let currentNode = queue[0];
console.log(currentNode.val);
if (currentNode.left !== null) {
queue.push(currentNode.left);
}
if (currentNode.right !== null) {
queue.push(currentNode.right);
}
// Remove the current node
queue.shift();
}
}
This example is based on Vaidehi Joshi's GitHub Gist.
This was a fresh (and long) chapter, and the first problem will be the famous (or infamous) Invert Binary Tree. Until then, happy coding.
The description of Merge K Sorted Lists states:
You are given an array of
k
linked-listslists
, each linked-list is sorted in ascending order.Merge all the linked-lists into one sorted linked-list and return it.
For example:
Input: lists = [[1, 4, 5], [1, 3, 4], [2, 6]]
Output: [1, 1, 2, 3, 4, 4, 5, 6]
Explanation: The linked-lists are:
[
1->4->5,
1->3->4,
2->6
]
merging them into one sorted list:
1->1->2->3->4->4->5->6
This problem was a bit confusing to me at first, but the explanation by NeetCode made a lot of sense.
The way to go for a solution is the Merge Sort algorithm, which is one of the most familiar algorithms you might remember from any introductory computer science course.
Now, in a usual Merge Sort when we're given an array as the input, we recursively split the array into left and right halves, and keep merging them until the whole array is sorted. Here's how our familiar friend might look like in JavaScript:
function mergeSort(arr) {
if (arr.length <= 1) {
return arr;
}
let left = arr.slice(0, Math.floor(arr.length / 2));
let right = arr.slice(Math.floor(arr.length / 2), arr.length);
mergeSort(left);
mergeSort(right);
merge(left, right, arr);
return arr;
}
function merge(left, right, arr) {
let index = 0;
while (left.length && right.length) {
if (left[0] < right[0]) {
arr[index++] = left.shift();
} else {
arr[index++] = right.shift();
}
}
while (left.length) {
arr[index++] = left.shift();
}
while (right.length) {
arr[index++] = right.shift();
}
}
However, what we're going to make use of is the idea of the merge
function.
Since we're also using linked lists, it will look a bit different. Using TypeScript, it will look like this:
function merge(list1: ListNode | null, list2: ListNode | null) {
let result = new ListNode(0);
let currentNode = result;
while (list1 !== null && list2 !== null) {
if (list1.val < list2.val) {
currentNode.next = list1;
list1 = list1.next;
} else {
currentNode.next = list2;
list2 = list2.next;
}
currentNode = currentNode.next;
}
if (list1 !== null) {
currentNode.next = list1;
}
if (list2 !== null) {
currentNode.next = list2;
}
return result.next;
}
Since we're given k
sorted lists, we'll merge pairs of lists, and keep merging while the length of lists
is greater than 1:
function mergeKLists(lists: Array<ListNode | null>): ListNode | null {
if (lists === null || lists.length === 0) {
return null;
}
while (lists.length > 1) {
let mergedLists = [];
for (let i = 0; i < lists.length; i += 2) {
let list1 = lists[i];
let list2 = i + 1 < lists.length ? lists[i + 1] : null;
mergedLists.push(merge(list1, list2));
}
lists = mergedLists;
}
return lists[0];
};
Overall, the solution looks like this:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
function mergeKLists(lists: Array<ListNode | null>): ListNode | null {
if (lists === null || lists.length === 0) {
return null;
}
while (lists.length > 1) {
let mergedLists = [];
for (let i = 0; i < lists.length; i += 2) {
let list1 = lists[i];
let list2 = i + 1 < lists.length ? lists[i + 1] : null;
mergedLists.push(merge(list1, list2));
}
lists = mergedLists;
}
return lists[0];
};
function merge(list1: ListNode | null, list2: ListNode | null) {
let result = new ListNode(0);
let currentNode = result;
while (list1 !== null && list2 !== null) {
if (list1.val < list2.val) {
currentNode.next = list1;
list1 = list1.next;
} else {
currentNode.next = list2;
list2 = list2.next;
}
currentNode = currentNode.next;
}
if (list1 !== null) {
currentNode.next = list1;
}
if (list2 !== null) {
currentNode.next = list2;
}
return result.next;
}
The time complexity is $O(n \ log \ k)$ — also see NeetCode's explanation —, if you remember that the time complexity of the Merge Sort function is $O(n \ log \ n)$: We go through each item in the merging operation, but since the input is halved each time, we do it $log \ n$ times. It is similar here, where $n$ refers to the number of nodes, and $k$ is the number of lists.
The space complexity is $O(k)$ where $k$ is the number of lists as we keep a temporary mergedLists
variable.
And, this is the last problem of the Linked Lists chapter. Next up, we'll begin looking at some trees. Until then, happy coding.
]]>Let's start with the description for Linked List Cycle:
Given
head
, the head of a linked list, determine if the linked list has a cycle in it.There is a cycle in a linked list if there is some node in the list that can be reached again by continuously following the
next
pointer. Internally,pos
is used to denote the index of the node that tail'snext
pointer is connected to. Note thatpos
is not passed as a parameter.Return
true
if there is a cycle in the linked list. Otherwise, returnfalse
.
For example:
Input: head = [3, 2, 0, -4], pos = 1
Output: true
Explanation: There is a cycle in the linked list, where the tail connects to the 1st node (0-indexed).
One easy way to do it is using a set. As we traverse the list, we can look up each node in the set, and if it's there, we know that there has to be a cycle so we can just return true
.
Here is how it looks like in TypeScript:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
function hasCycle(head: ListNode | null): boolean {
let nodes = new Set();
let currentNode = head;
while (currentNode !== null) {
if (nodes.has(currentNode)) {
return true;
}
nodes.add(currentNode);
currentNode = currentNode.next;
}
return false;
};
The time complexity of this solution is $O(n)$ as we go through every node in the list once. The space complexity is also $O(n)$ because we'll store each node in nodes
, and its size will grow with the size of the linked list.
There is another way to solve this problem that is more memory efficient using fast and slow pointers, where we don't need to store an additional data structure at all:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
function hasCycle(head: ListNode | null): boolean {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) {
return true;
}
}
return false;
};
We initialize both pointers at the head, and while fast
(or its next
pointer) is not null
, we'll update them. Of course, while slow
is moving one step at a time, fast
is increasing by two steps. And, we return true
if they both point to the same node, at which point we know there has to be a cycle. Otherwise, if fast
ever points to null
, we know that there is no cycle, so we can return false
.
This technique of detecting a cycle is also called Floyd's tortoise and hare algorithm.
The important thing is that when slow
and fast
catch up, they are going to be pointing to the same node.
The reason that this works is that while slow
increases the distance between it and fast
by 1, fast
decreases that distance by 2 — eventually making the overall distance between them 0.
It makes more sense with an example such as the one given in NeetCode's explanation.
The time complexity is $O(n)$ where $n$ is the length of the cycle (we can imagine a worst case scenario where the last node points to the head). The good thing is that the space complexity is $O(1)$ because we don't need an additional data structure that will grow with the size of the input.
The last problem in this chapter will be Merge K Sorted Lists. Until then, happy coding.
]]>The description for this problem says:
Given the
head
of a linked list, remove thenth
node from the end of the list and return its head.
For example:
Input: head = [1, 2, 3, 4, 5], n = 2
Output: [1, 2, 3, 5]
It seems like an easy question at first glance.
However, the tricky thing is that the nth node is counted from the back.
Now, if we were to do it normally from the start of the list, we could just keep a count, and when the count reached the n
th index, we could just unlink that node. Here is a basic example in JavaScript:
function removeNode(head, n) {
if (head === null) {
return;
}
let currentNode = head;
if (n === 0) {
head = currentNode.next;
return;
}
let count = 0;
while (currentNode !== null && count < n - 1) {
currentNode = currentNode.next;
count++;
}
if (currentNode === null || currentNode.next === null) {
return;
}
let nextNode = currentNode.next.next;
currentNode.next = nextNode;
return head;
}
if (n === 1) {
head = currentNode.next;
return;
}
and, use count < n - 2
instead of count < n - 1
in the while
loop condition to point currentNode
to the previous node.
However, we need to do it from the back of the list. We could try traversing in reverse, but there is a solution using the fast and slow pointers technique as shown by NeetCode.
First, we'll create a dummy node with value 0
, that I'm going to call result
:
let result = new ListNode(0);
Then, we'll point its next
pointer to head
to link the original list:
result.next = head;
Then, we'll initialize our fast
and slow
pointers:
let slow = result;
let fast = head;
Then, we'll bring our fast
pointer to the n
th node so that the number of nodes between it and the slow
pointer is initially n
:
while (n > 0 && fast !== null) {
fast = fast.next;
n--;
}
For example, in the image given at the beginning, if n
is 1
, fast
will be at the node with the value 2
, while slow
points to the dummy node with value 0
. The gap between them is exactly n
— that is, 1.
After that, we'll make the slow
pointer catch up slowly. We'll also update fast
, but the gap between them will stay as n
:
while (fast !== null) {
slow = slow.next;
fast = fast.next;
}
At this point when fast
points to null
, slow
will be at the previous node of the node we need to remove.
So, the only thing to do is to point its next
pointer to the next of the node-to-be-removed, essentially unlinking it:
slow.next = slow.next.next;
Eventually we can return the head
which is pointed to by our dummy node's next
pointer:
return result.next;
All in all, this is our solution in TypeScript:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
function removeNthFromEnd(head: ListNode | null, n: number): ListNode | null {
let result = new ListNode(0);
result.next = head;
let slow = result;
let fast = head;
while (n > 0 && fast !== null) {
fast = fast.next;
n--;
}
while (fast !== null) {
slow = slow.next;
fast = fast.next;
}
slow.next = slow.next.next;
return result.next;
};
We need to "touch" each node, so the time complexity will be linear — $O(n)$.
The space complexity is $O(1)$ as we don't need extra space that grows with the input size. Note that result
doesn't scale with the size of the input, it's just one node that has its next
pointing to the head of the original list.
The next problem is Linked List Cycle where the fast and slow pointers technique will be useful again. Until then, happy coding.
]]>Let's start with the description for Reorder List:
You are given the head of a singly linked-list. The list can be represented as:
L0 → L1 → … → Ln - 1 → Ln
Reorder the list to be on the following form:
L0 → Ln → L1 → Ln - 1 → L2 → Ln - 2 → …
You may not modify the values in the list's nodes. Only nodes themselves may be changed.
For example:
My initial solution was to get every node inside an array first, then link each value to the linked list in an alternating order.
Adding the values to an array is easy:
let nums = [];
let current = head;
while (current !== null) {
nums.push(current.val);
current = current.next;
}
Then, we can point our current
pointer back to the head
node, and slice nums
to start from the second element (since current
is already pointing to the first element). That's also easy:
current = head;
nums = nums.slice(1);
Then, we can use the Two Pointers technique to link the elements in an alternating fashion. We can also keep a flag variable to know which direction (left or right) to go next.
Since we're already pointing to the first element, we need to add the next item from the reversed portion (going left), so we can initialize this variable as false
:
let goRight = false;
Then, as we update our left
and right
pointers, we can link our newly created ListNode
, and flip the goRight
flag accordingly.
Overall, it looks like this:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
/**
Do not return anything, modify head in-place instead.
*/
function reorderList(head: ListNode | null): void {
let nums = [];
let current = head;
while (current !== null) {
nums.push(current.val);
current = current.next;
}
current = head;
nums = nums.slice(1);
let left = 0;
let right = nums.length - 1;
let goRight = false;
while (left <= right) {
if (goRight) {
current.next = new ListNode(nums[left++]);
goRight = false;
} else {
current.next = new ListNode(nums[right--]);
goRight = true;
}
current = current.next;
}
};
Adding each value to an array is an $O(n)$ operation since we visit every node in the linked list. Then, we'll traverse this array again to convert it back to a linked list — another $O(n)$ operation. Therefore, the time complexity of the overall function ends up being $O(n)$.
The space complexity is also $O(n)$ because of the array we create to store the values.
There is another way to solve this problem, as shown by NeetCode.
In this version, we'll make use of the Fast and Slow Pointers technique, where we initialize two pointers: fast
and slow
. slow
will initially point to the head
, while fast
will point to head.next
, and we'll update them until fast
(or its next
pointer) reaches the end, at which point slow
stays at the middle node.
let slow = head;
let fast = head.next;
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
}
Now that slow
is at the middle node, we can partition the list into two.
However, we need to go through the second half in reverse order. So, we can get the head of this second half (which is slow.next
), and point its next
pointer to null
.
let second = slow.next;
slow.next = null;
Then, we'll just iterate in reverse order. If you remember from Reverse Linked List, this is how we're going to do it:
let prevNode = null;
while (second !== null) {
let nextNode = second.next;
second.next = prevNode;
prevNode = second;
second = nextNode;
}
Now that the second portion is reversed, we can go ahead and do the main job of merging all the nodes.
After this reversing operation, the prevNode
is now at the last node of the list (because second
points to null
). Now, we'll alternate pointers: pointing left
's next
to the right, and right
's next
to the left one.
let left = head;
let right = prevNode;
while (right !== null) {
let nextLeft = left.next;
let nextRight = right.next;
// Rearrange pointers alternatively
left.next = right;
right.next = nextLeft;
// Update pointers
left = nextLeft;
right = nextRight;
}
All in all, the function will look like this:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
/**
Do not return anything, modify head in-place instead.
*/
function reorderList(head: ListNode | null): void {
// Get to the middle node
let slow = head;
let fast = head.next;
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
}
// Get the head of the second half of the list
let second = slow.next;
slow.next = null;
// Reverse the second half of the list
let prevNode = null;
while (second !== null) {
let nextNode = second.next;
second.next = prevNode;
prevNode = second;
second = nextNode;
}
let left = head;
let right = prevNode;
while (right !== null) {
let nextLeft = left.next;
let nextRight = right.next;
// Rearrange pointers alternatively
left.next = right;
right.next = nextLeft;
// Update pointers
left = nextLeft;
right = nextRight;
}
};
The time complexity is again $O(n)$ in this version, as we need to "touch" each node. However, space complexity is constant, that is, $O(1)$ as we don't need extra amount of space that will grow with the input size, which is better.
If you feel a bit dizzy after rearranging all those pointers, now it's time to take a breath.
The next problem will be Remove Nth Node From End of List. Until then, happy coding.
Before we start the next problem in the series, let's take a quick look at a technique that comes in handy when it comes to working with linked lists.
We can keep two pointers while traversing a linked list: fast and slow. While the fast one increases by two steps, the slow pointer will increase by just one step.
When the fast pointer reaches the end of the list, the slow pointer will be at the "middle" node.
Let's see it conceptually:
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
}
We can think of a list like [1, 2, 3, 4, 5]
(where each value is a node in the linked list).
Both fast
and slow
start pointing to the head, that is, 1
.
Then, we update the slow pointer one step, which will be 2
. And, fast
will be at 3
.
When we update slow
again, it will be at 3
.
When the fast pointer increases, it will be two steps ahead, and its next
pointer will point to the null
value, at which point our loop will stop iterating.
slow
will end up pointing to the node with the value 3
, which is the middle node.
With an even number of nodes, there are two candidates for the middle node. For example, with a list like [1, 2, 3, 4]
, our current implementation will find the middle as 3
.
This technique is also useful to detect cycles in a linked list, but we'll see that in an upcoming problem.
For now, knowing that it exists is sufficient and makes our day better. Happy coding.
The description for Merge Two Sorted Lists says:
You are given the heads of two sorted linked lists
list1
andlist2
.Merge the two lists into one sorted list. The list should be made by splicing together the nodes of the first two lists.
Return the head of the merged linked list.
For example:
mergeTwoLists([1, 2, 4], [1, 3, 4]);
// -> [1, 1, 2, 3, 4, 4]
mergeTwoLists([], []);
// -> []
mergeTwoLists([], [0]);
// -> [0]
First, let's think about how we would do it with a simple array. We would have two pointers: one for the first array, and the other for the second.
As we iterate through them both simultaneously, we'd get the smaller value in our result array. At the end of the iteration, if there are some more elements left in one of the arrays (in case of different lengths), we'd simply add them all as well.
Let's write a pseudocode of how we would go about doing it:
i = 0
j = 0
result = []
while i < arr1.length and j < arr2.length:
if arr1[i] < arr2[j]:
add arr1[i] to result
i = i + 1
else:
add arr2[j] to result
j = j + 1
while i < arr1.length:
add arr1[i] to result
i = i + 1
while j < arr2.length:
add arr2[j] to result
j = j + 1
The solution for this problem looks very similar, only difference is that we'll do it with a linked list. Let's look at it in TypeScript:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
function mergeTwoLists(
list1: ListNode | null,
list2: ListNode | null
): ListNode | null {
// Start with an initial node with value 0
let result = new ListNode(0);
let currentNode = result;
while (list1 !== null && list2 !== null) {
if (list1.val < list2.val) {
currentNode.next = list1;
list1 = list1.next;
} else {
currentNode.next = list2;
list2 = list2.next;
}
currentNode = currentNode.next;
}
if (list1 !== null) {
currentNode.next = list1;
}
if (list2 !== null) {
currentNode.next = list2;
}
// Go past the first dummy value of 0
return result.next;
}
We initialize result
with a node that holds the value of 0
. We'll also keep a pointer to currentNode
that initially points to the head of result
.
As we traverse the lists, we'll link the smaller value first by pointing currentNode
's next
pointer to that value.
If, at the end, we haven't finished looking at all the nodes in one of the lists (in case of different lengths), we'll simply link the rest of it.
To return, we need to go past the first initial dummy node with the value 0
, so we'll return result.next
which starts with the actual value that we added from one of the lists. And, we're done.
The time complexity is $O(m + n)$ —where $m$ is the length of the first list, and $n$ is the length of the second list— because we need to "touch" each node in each list once.
The space complexity is $O(n)$ because of the result
list we hold: its size will grow with the input size.
Next up is a slightly more challenging problem called Reorder List. Until then, happy coding.
]]>The description for Reverse Linked List states:
Given the
head
of a singly linked list, reverse the list, and return the reversed list.
For example:
From a single node's perspective, what we need to do is to update the next pointer to point to the previous node. But, before that, we need to hold on to the original next node, otherwise we'll unlink it, and lose all the data.
So, as we traverse the linked list, we need to keep a pointer to the next node, and update the next pointer to point to the previous node:
let nextNode = currentNode.next;
currentNode.next = prevNode;
Then, we'll go to the next node in the list. It will look like this:
prevNode = currentNode;
currentNode = nextNode;
All in all, the whole function is just about it:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
function reverseList(head: ListNode | null): ListNode | null {
let prevNode: ListNode | null = null;
let currentNode: ListNode | null = head;
while (currentNode) {
let nextNode = currentNode.next;
currentNode.next = prevNode;
prevNode = currentNode;
currentNode = nextNode;
}
return prevNode;
};
The time complexity is $O(n)$ because we traverse the whole list. Even though arranging pointers is an $O(1)$ operation, since we do it for each node, the overall time complexity is $O(n)$.
The space complexity is $O(1)$ because we don't have any additional space requirements that will grow with the input size.
We can also solve this problem recursively; however, while the time complexity stays the same, the space complexity will be worse: $O(n)$.
This is usually the case with recursive solutions, even though we gain on elegance, there is a space tradeoff.
This one looks very similar to the iterative solution. We'll keep pointers to the current and previous nodes. Our base case is when the current node is null, in that case we'll return the previous node. Otherwise, we'll just update the current node's next pointer to point to the previous node.
It will look like this:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
function reverseList(head: ListNode | null): ListNode | null {
function reverse(currentNode: ListNode | null, prevNode: ListNode | null) {
if (currentNode === null) {
return prevNode;
} else {
let nextNode = currentNode.next;
currentNode.next = prevNode;
return reverse(nextNode, currentNode);
}
}
return reverse(head, null);
};
There is also another recursive solution where we don't keep a pointer to the previous node, as in this example from NeetCode.
This one was a bit tough for me to understand initially.
But, it's again important to think about solving the subproblem.
Our base case is when the head
is null
: we'll just return null
.
Now, it's time to take a deep breath, and think about the subproblem.
From one node's perspective, what does reversing look like?
So, if we're just one node (head
), and there is a next node after us (head.next
), we need to point the next pointer of that next node to us:
head.next.next = head;
But before that, that next node should have been reversed already:
reverseList(head.next);
That's fine so far. However, we need our base case to work, so we have to set our next pointer to null
:
head.next = null;
Remember that we "reversed" the next node, with reverseList(head.next)
? Now it's supposed to be the new head, so we'll return it.
Solving the subproblem solves the whole problem in this sense, so the function will look like this:
/**
* Definition for singly-linked list.
* class ListNode {
* val: number
* next: ListNode | null
* constructor(val?: number, next?: ListNode | null) {
* this.val = (val === undefined ? 0 : val)
* this.next = (next === undefined ? null : next)
* }
* }
*/
function reverseList(head: ListNode | null): ListNode | null {
if (head === null) {
return null;
}
let currentNode: ListNode | null = head;
if (head.next) {
currentNode = reverseList(head.next);
head.next.next = head;
}
head.next = null;
return currentNode;
};
It's a bit challenging to grasp initially, but it makes sense when you work through it; well, that's recursion.
This was a great opener for the Linked Lists chapter. Next up is Merge Two Sorted Lists. Until then, happy coding.
]]>A linked list is a linear data structure that you're likely to be familiar with. It is also a dynamic data structure that can grow and shrink dynamically, so unlike arrays, there's no need to allocate memory beforehand.
An important part of a linked list is the head pointer that points to the beginning of the list. There may or may not be a tail pointer that also points to the end of the list.
The core ingredient of a linked list is a simple node, which consists of two parts: data and the next pointer.
So, it is an important idea to remember: a node only knows about its data and its neighbor.
The very last node in the linked list points to null
to indicate it's the end of the list.
However, there are different types of linked lists that differ from each other slightly, so let's briefly take a look at them.
The core idea with singly linked lists is that each node, along with the data it has, have a pointer that points only to the next node:
class Node {
constructor(data) {
this.data = data;
this.next = null;
}
}
And here is an example where we have three nodes, holding the values 1
, 2
, and 3
consecutively:
Here is a simple implementation of a singly linked list in JavaScript:
class SinglyLinkedList {
constructor() {
this.head = null;
this.tail = null;
this.length = 0;
}
// Add value to the end of the list
append(value) {
let node = new Node(value);
// If the list is empty
if (this.head === null) {
this.head = node;
this.tail = this.head;
} else {
this.tail.next = node;
this.tail = node;
}
this.length++;
return this;
}
// Add value to the beginning of the list
prepend(value) {
let node = new Node(value);
// If the list is empty
if (this.head === null) {
this.head = node;
this.tail = this.head;
} else {
node.next = this.head;
this.head = node;
}
this.length++;
return this;
}
remove(value) {
// If the list is empty, return null
if (this.head === null) {
return null;
}
// If it is the first element
if (this.head.data === value) {
this.head = this.head.next;
this.length--;
// If it is the only element
// (we don't have anything after removing it)
if (this.head === null) {
this.tail = null;
}
return;
}
let currentNode = this.head;
while (currentNode.next) {
if (currentNode.next.data === value) {
currentNode.next = currentNode.next.next;
// If it is the last element, update tail
if (currentNode.next === null) {
this.tail = currentNode;
}
this.length--;
return;
}
currentNode = currentNode.next;
}
}
search(value) {
let currentNode = this.head;
while (currentNode) {
if (currentNode.data === value) {
return currentNode;
}
currentNode = currentNode.next;
}
// If the value does not exist, return null
return null;
}
printList() {
let values = [];
let currentNode = this.head;
while (currentNode) {
values.push(currentNode.data);
currentNode = currentNode.next;
}
console.log(values);
}
}
Doubly linked lists differ from the "singly" ones in that each node also have another pointer that points to the previous element.
So, this time, a single node will look different:
class Node {
constructor(data) {
this.data = data;
this.next = null;
this.previous = null;
}
}
Here is the same example as above, but as a doubly linked list:
A simple implementation might look like this:
class DoublyLinkedList {
constructor() {
this.head = null;
this.tail = null;
this.length = 0;
}
// Add value to the end of the list
append(value) {
let node = new Node(value);
// If the list is empty
if (this.head === null) {
this.head = node;
this.tail = this.head;
} else {
node.previous = this.tail;
this.tail.next = node;
this.tail = node;
}
this.length++;
return this;
}
// Add value to the beginning of the list
prepend(value) {
let node = new Node(value);
// If the list is empty
if (this.head === null) {
this.head = node;
this.tail = this.head;
} else {
this.head.previous = node;
node.next = this.head;
this.head = node;
}
this.length++;
return this;
}
remove(value) {
// If the list is empty, return null
if (this.head === null) {
return null;
}
let currentNode = this.head;
// If it is the first element
if (currentNode.data === value) {
this.head = currentNode.next;
// If the removed element is not the only one,
// make the previous pointer of the new head null
if (this.head) {
this.head.previous = null;
// If the removed element was the only element,
// point the tail to null as well
} else {
this.tail = null;
}
this.length--;
return;
}
while (currentNode) {
if (currentNode.data === value) {
if (currentNode.previous) {
currentNode.previous.next = currentNode.next;
}
if (currentNode.next) {
currentNode.next.previous = currentNode.previous;
// If it's the last element in the list, update tail
// to point to the previous node
} else {
this.tail = currentNode.previous;
}
this.length--;
return;
}
currentNode = currentNode.next;
}
}
search(value) {
let currentNode = this.head;
while (currentNode) {
if (currentNode.data === value) {
return currentNode;
}
currentNode = currentNode.next;
}
// If the value does not exist, return null
return null;
}
printList() {
let values = [];
let currentNode = this.head;
while (currentNode) {
values.push(currentNode.data);
currentNode = currentNode.next;
}
console.log(values);
}
}
With circular linked lists, we have the last node also pointing to the first element, creating circularity.
We'll only look at the singly circular linked list for simplicity's sake, so our node will look the same as in the first example:
class Node {
constructor(data) {
this.data = data;
this.next = null;
}
}
The same example, in a circular linked list fashion:
Here is a simple implementation:
class CircularLinkedList {
constructor() {
this.head = null;
this.tail = null;
}
// Add value to the "end" of the list
append(value) {
let node = new Node(value);
// If the list is empty
if (this.head === null) {
this.head = node;
this.tail = node;
// As the only node in the list, it should point to itself
node.next = node;
} else {
// As the "last" node, it should point to the head (this.tail.next)
node.next = this.tail.next;
this.tail.next = node;
this.tail = node;
}
}
// Add value to the beginning of the list
prepend(value) {
let node = new Node(value);
node.next = this.head;
// Update last node's next pointer to point to the new node
this.tail.next = node;
this.head = node;
}
remove(value) {
// If the list is empty, return null
if (this.head === null) {
return null;
}
// If it is the first element
if (this.head.data === value) {
// If it's the only element
if (this.head.next === this.head) {
this.head = null;
this.tail = null;
return;
}
this.head = this.head.next;
this.tail.next = this.head;
this.length--;
return;
}
let currentNode = this.head;
let prevNode = null;
// Iterate until you find the value or
// you don't find it after traversing the whole list
while (currentNode.data !== value || prevNode === null) {
if (currentNode.next === this.head) {
break;
}
prevNode = currentNode;
currentNode = currentNode.next;
}
if (currentNode.data === value) {
// If there is a previous node before the element to be removed,
// update the previous node's next pointer to point to
// the one after the element to be removed
// (unlink it)
if (prevNode) {
prevNode.next = currentNode.next;
// If the element to be removed is the last one,
// update tail to be the previous node
if (this.tail === currentNode) {
this.tail = prevNode;
}
// If the element to be removed is the first one in the list
} else {
// If it's the only one in the list
if (this.head.next === this.head) {
this.head = null;
this.tail = null;
} else {
this.head = this.head.next;
this.tail.next = this.head;
}
}
}
}
printList() {
let nodes = [];
let currentNode = this.head;
if (this.head === null) {
console.log(nodes);
return;
}
// Traverse the list once to add the values,
// don't go in circles
do {
nodes.push(currentNode.data);
currentNode = currentNode.next;
} while (currentNode !== this.head);
console.log(nodes);
}
}
With linked lists, the time complexity for accessing an element is in the worst case $O(n)$.
Prepending and appending an element depends on whether we have a tail pointer; if we have it, then, both operations are $O(1)$ as we only need to arrange pointers.
However, if we don't have a tail pointer, appending an element requires traversing the whole list, so it is an $O(n)$ operation.
Removing an element is similar, in the worst case, it is $O(n)$.
The space complexity is linear—$O(n)$—, the amount of data to store grows linearly with the number of nodes in the list.
The first problem in this chapter is Reverse Linked List, until then, happy coding.
]]>The description for this problem states:
There is an integer array
nums
sorted in ascending order (with distinct values).Prior to being passed to your function,
nums
is possibly rotated at an unknown pivot indexk
(1 <= k < nums.length
) such that the resulting array is[nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]]
(0-indexed). For example,[0,1,2,4,5,6,7]
might be rotated at pivot index3
and become[4,5,6,7,0,1,2]
.Given the array
nums
after the possible rotation and an integertarget
, return the index oftarget
if it is innums
, or-1
if it is not innums
.You must write an algorithm with
O(log n)
runtime complexity.
For example:
search([4, 5, 6, 7, 0, 1, 2], 0);
// -> 4
search([4, 5, 6, 7, 0, 1, 2], 3);
// -> -1
search([1], 0);
// -> -1
This one is really a hard problem where we have to think of all the cases that can possibly occur.
We'll use binary search, and similar to the previous problem, we'll consider two sorted portions in the array: left and right. Also, as with binary search, we'll have two pointers low
and high
pointing to the 0
th and the last index respectively.
With normal binary search, we search the right portion only when the middle element is less than the target; and search the left portion when the middle element is more than the target. But here, things are different.
If we're in a sorted portion, we'll search the right if:
or
low
points to.Otherwise, we'll go left.
Else if we're not in a sorted portion, we'll search the left if:
or
high
points to.Otherwise, we'll go right.
Here is a solution in TypeScript:
function search(nums: number[], target: number): number {
let low = 0;
let high = nums.length - 1;
while (high >= low) {
let mid = Math.floor((high + low) / 2);
if (nums[mid] === target) {
return mid;
}
if (nums[low] <= nums[mid]) {
if (target > nums[mid] || target < nums[low]) {
low = mid + 1;
} else {
high = mid - 1;
}
} else {
if (target < nums[mid] || target > nums[high]) {
high = mid - 1;
} else {
low = mid + 1;
}
}
}
return -1;
};
It might look confusing and hard to wrap your mind around at first, because it is. I find this problem to be one of the most challenging ones in this series so far. It means that it's now time to take a deep breath.
The time complexity is $O(log \ n)$ as we use binary search, halving the search space with each iteration. The space complexity is $O(1)$ because we don't make use of any extra space that proportionally grows with the input size.
Next up, we'll start a chapter on Linked Lists. Until then, happy coding.
]]>Let's start with the description for this problem:
Suppose an array of length
n
sorted in ascending order is rotated between1
andn
times. For example, the arraynums = [0,1,2,4,5,6,7]
might become:
[4,5,6,7,0,1,2]
if it was rotated4
times.[0,1,2,4,5,6,7]
if it was rotated7
times.Notice that rotating an array
[a[0], a[1], a[2], ..., a[n-1]]
1 time results in the array[a[n-1], a[0], a[1], a[2], ..., a[n-2]]
.Given the sorted rotated array
nums
of unique elements, return the minimum element of this array.You must write an algorithm that runs in
O(log n) time.
For example:
findMin([3, 4, 5, 1, 2]);
// -> 1
// The original array was [1, 2, 3, 4, 5] rotated 3 times.
findMin([4, 5, 6, 7, 0, 1, 2]);
// -> 0
// The original array was [0, 1, 2, 4, 5, 6, 7] and it was rotated 4 times.
findMin([11, 13, 15, 17]);
// -> 11
// The original array was [11, 13, 15, 17] and it was rotated 4 times.
For instance, moving the rightmost element to the left once is one rotation.
It is easy to find a linear solution using linear search, like this:
function findMin(nums: number[]): number {
let min = Infinity;
for (let n of nums) {
min = Math.min(min, n);
}
return min;
};
Or, given that the numbers are sorted, we can find the first element where the number on the left side is larger:
function findMin(nums: number[]): number {
for (let i = 0; i < nums.length; i++) {
if (i - 1 >= 0 && nums[i - 1] > nums[i]) {
return nums[i];
}
}
// Not rotated or rotated (nums.length * n) times.
// So the minimum element is the first one.
return nums[0];
};
However, the problem specifically asks for a solution with logarithmic time complexity.
Let's take a deep breath, and think about one approach.
We can think of the minimum element as the pivot point where the array was rotated. With this pivot, we have two sorted portions of the array: left and right.
Binary search can still be useful here. We'll initialize low
and high
pointers to the 0
th and the last index respectively. We can leverage the fact that the both portions are sorted, so if the value at low
is less than the value at high
, that means either the array is not rotated at all or the number of times it's rotated is a multiple of its size. Either way, the array is fully sorted, so we can return the first element and be done with it.
Otherwise, we'll go on with the usual binary search, where we calculate the midpoint first. Once the midpoint is less than the element to the left of it, that means it is the pivot, so we can just return that midpoint. However, if it's greater than the value that high
points to, it means we need to search the right portion, so we update low
to be mid + 1
. Otherwise, we need to search the left portion, so we'll update high
to be mid - 1
.
Here's one way to write it in TypeScript:
function findMin(nums: number[]): number {
let low = 0;
let high = nums.length - 1;
// Not rotated or rotated (nums.length * n) times
if (nums[low] <= nums[high]) {
return nums[low];
}
while (high >= low) {
let mid = Math.floor((high + low) / 2);
if (nums[mid] < nums[mid - 1]) {
return nums[mid];
}
if (nums[mid] > nums[high]) {
low = mid + 1;
} else {
high = mid - 1;
}
}
};
The time complexity is $O(log \ n)$ as we use binary search. With each iteration, we divide the search space in half, which results in logarithmic time complexity. We don't use any additional data structures that will grow proportionally with the input size, so the space complexity is $O(1)$.
The next problem is Search in Rotated Sorted Array, until then, happy coding.
]]>Binary search is one of the most well-known algorithms. It's also a divide-and-conquer algorithm, where we break the problem into smaller components.
The crux of binary search is to find a target element in a given sorted array.
We have two pointers: high
to point to the largest element, and low
to point to the smallest element. We first initialize them for the whole array itself, with high
being the last index and low
being the first index. Then, we calculate the midpoint. If the target is greater than the midpoint, then we adjust our low
pointer to point to the mid + 1
, otherwise if the target is less than the midpoint, we adjust high
to be mid - 1
. With each iteration, we eliminate half the array until the midpoint equals target or the low
pointer passes high
.
If we find the index of the target, we can return it as soon as we find it; otherwise, we can just return -1
to indicate that the target doesn't exist in the array.
For example, if we have a nums
array [-1, 0, 3, 5, 9, 12]
and our target
is 9
, the operation looks like this:
We can write it in TypeScript like this:
function search(nums: number[], target: number): number {
let high = nums.length - 1;
let low = 0;
while (high >= low) {
let mid = Math.floor((high + low) / 2);
if (target > nums[mid]) {
low = mid + 1;
} else if (target < nums[mid]) {
high = mid - 1;
} else {
return mid;
}
}
return -1;
};
The time complexity of a binary search algorithm is $O(log \ n)$ in the worst case. (For example, if the target is not in the array, we'll be halving the array until there is one element left.) The space complexity is $O(1)$ as we don't need extra space.
The first problem in this chapter will be Find Minimum in Rotated Sorted Array, until then, happy coding.
]]>The description for this problem says:
Given a string
s
containing just the characters'('
,')'
,'{'
,'}'
,'['
and']'
, determine if the input string is valid.An input string is valid if:
- Open brackets must be closed by the same type of brackets.
- Open brackets must be closed in the correct order.
- Every close bracket has a corresponding open bracket of the same type.
For example:
isValid('()');
// -> true
isValid('()[]{}');
// -> true
isValid('(]');
// -> false
The first idea that comes to mind is, perhaps, adding each parenthesis in the string to a stack, and once we come to one of the closing parentheses, we can check if the previous parenthesis is its opening pair. If so, we can pop them both off the stack. If we are left with an empty stack at the end, that means all the parentheses match, so we can return true
; otherwise, we'll return false
.
For example, if our string is ([{}])
(which is valid), it will look like this:
My initial solution in TypeScript looked like this:
function isValid(s: string): boolean {
let stack = [];
const opening = ['(', '{', '['];
const closing = [')', '}', ']'];
for (const c of s) {
stack.push(c);
if (closing.includes(c)) {
let prevItem = stack[stack.length - 2];
if (opening.includes(prevItem) &&
opening.indexOf(prevItem) === closing.indexOf(c)) {
stack.pop();
stack.pop();
}
}
}
if (!stack.length) {
return true;
}
return false;
};
However, there is a slightly better way, where we can use a hash table for parentheses, mapping the closing ones to their opening pairs. We also don't have to push every character onto the stack, and can return immediately when the parentheses don't match:
function isValid(s: string): boolean {
let stack = [];
const parentheses = {
')': '(',
']': '[',
'}': '{'
}
for (const c of s) {
// If it's a closing parenthesis
if (c in parentheses) {
// If the stack is not empty and the previous item on the stack is its opening pair
if (stack.length && stack[stack.length - 1] === parentheses[c]) {
stack.pop();
} else {
return false;
}
} else {
stack.push(c);
}
}
if (!stack.length) {
return true;
}
return false;
};
Here, as we iterate through the characters of s
, if it's an opening parenthesis, we'll just push it to our stack. When we come to a closing parenthesis, if our stack is not empty and the last item we added to our stack matches the opening pair of the closing one we're looking at, we can just pop it off the stack. Otherwise, it's not a matching pair, so we can return false
immediately.
At the end, if our stack is empty, we can return true
, otherwise we'll return false
.
The time complexity for this solution is $O(n)$ because in the worst case where each character in the input string is an opening parenthesis, we'll loop through the entire string, so the runtime growth is proportionate to its length.
The space complexity is $O(n)$ because of our stack, which, in the worst case, will have all the characters in the input string.
Next up, we'll start a new chapter on Binary Search. Until then, happy coding.
]]>A stack data type is perhaps one of the most well-known ones. A stack of books might be a good example to visualize, however, insertion and deletion can only happen from the one end. A stack operates through the last-in first-out (LIFO) principle: the last item to go in is the first to go out.
Usually we'll have methods for pushing an element onto the stack, and popping an element from the stack.
For example, let's say we're looking for valid parentheses in a given string (more on that problem in the next post), and the operation we'll do goes like this:
As we iterate over the characters in the string, we push the character onto the stack. If we pushed a closing parenthesis (one of )
, }
, or ]
), then, if the previous pushed element is its opening pair, we'll pop that pair from the stack.
If, at the end, the stack is empty, the string consists of valid parentheses.
It looks like this:
A stack can be implemented as an array or a linked list; but using linked lists is more common because with arrays, we have a potential stack overflow when we predefine a maximum stack size. On the other hand, linked lists are not static when it comes to memory, so they are a good candidate to implement stacks.
Linked lists are also efficient because we are using one end of the stack for insertion and deletion, and doing these are constant time operations.
Let's look at one easy stack implementation in Python.
Now, we can use a list
, but a list in Python is implemented as a dynamic array underneath, so at one point, pushing an item can be $O(n)$ operation if the list needs to be copied into another memory location. For that reason, we'll use a deque
, which is implemented as a doubly-linked list, so that we know push and pop operations will be $O(1)$.
from collections import deque
class Stack:
def __init__(self):
self._stack = deque()
def push(self, item):
self._stack.append(item)
def pop(self):
return self._stack.pop()
def peek(self):
return self._stack[-1]
def is_empty(self):
return not bool(len(self._stack))
def size(self):
return len(self._stack)
In addition to push
and pop
, we'll also usually have functions like peek
to get the topmost item in the stack, is_empty
to check if the stack is empty, and size
to get the size of the stack.
We can also do it using JavaScript. Now, we can do it using an array, but we want to use a linked list instead. Since we don't have a robust built-in library like Python this time, we'll implement a very simple version of it ourselves.
Even though we haven't seen linked lists in this series so far, the basic idea is that we have nodes, each of which having a data
value, and a next
pointer pointing to the next node.
Let's create a simple node first:
class Node {
constructor(data) {
this.data = data;
this.next = null;
}
}
We can write our stack now:
class Stack {
constructor() {
this.top = null;
this.length = 0;
}
push(item) {
const node = new Node(item);
node.next = this.top;
this.top = node;
this.length++;
}
pop() {
if (this.isEmpty()) { return null; }
const data = this.top.data;
this.top = this.top.next;
this.length--;
return data;
}
peek() {
if (this.isEmpty()) { return null; }
return this.top.data;
}
isEmpty() {
return this.size() === 0;
}
size() {
return this.length;
}
}
Each method we defined has $O(1)$ time complexity, and it would be the same if we were to use an array as well. However, as mentioned above, arrays have limitations in that having to allocate a predefined stack size can lead to a stack overflow. And if we were to use a dynamic array, the whole array might need to be copied to go into another memory location after a certain size is reached, leading to $O(n)$ time. So, linked lists are ideal to implement a stack data type.
The space complexity is linear —$O(n)$—, the stack will grow linearly with the number of items in it.
The first and only problem in this chapter is Valid Parentheses, until then, happy coding.
https://medium.com/basecs/stacks-and-overflows-dbcf7854dc67
]]>Let's start with the description for Minimum Window String:
Given two strings
s
andt
of lengthsm
andn
respectively, return the minimum window substring ofs
such that every character int
(including duplicates) is included in the window. If there is no such substring, return the empty string""
.The testcases will be generated such that the answer is unique.
For example:
minWindow('ADOBECODEBANC', 'ABC');
// -> 'BANC'
// The minimum window substring "BANC" includes 'A', 'B', and 'C' from string t.
minWindow('a', 'a');
// -> 'a'
// The entire string s is the minimum window.
minWindow('a', 'aa');
// -> ''
// Both 'a's from t must be included in the window.
// Since the largest window of s only has one 'a', return empty string.
This is the first problem in this series that is labeled as having the difficulty level of hard, and rightly so.
Let's look at one solution in TypeScript:
function minWindow(s: string, t: string): string {
if (t === '') {
return '';
}
let countT = new Map();
let window = new Map();
for (let i = 0; i < t.length; i++) {
if (countT.has(t[i])) {
countT.set(t[i], countT.get(t[i]) + 1);
} else {
countT.set(t[i], 0);
}
}
let have = 0;
let need = countT.size;
let result = '';
let resultLength = Infinity;
let left = 0;
let right = 0;
while (right < s.length) {
let char = s[right];
if (window.has(char)) {
window.set(char, window.get(char) + 1);
} else {
window.set(char, 0);
}
if (countT.has(char) && window.get(char) === countT.get(char)) {
have++;
}
while (have === need) {
if ((right - left + 1) < resultLength) {
result = s.slice(left, right + 1);
resultLength = right - left + 1;
}
window.set(s[left], window.get(s[left]) - 1);
if (countT.has(s[left]) && window.get(s[left]) < countT.get(s[left])) {
have--;
}
left++;
}
right++;
}
if (resultLength !== Infinity) {
return result;
} else {
return '';
}
};
This solution is adapted from NeetCode.
First, we need to handle the case where t
is empty; if so, we'll return an empty string.
We start by initializing two hash maps to hold characters for t
and our current window. For example, if t
is 'ABC'
, then countT
will be this:
Map(3) { 'A' => 0, 'B' => 0, 'C' => 0 }
We'll have a variable named have
to keep track of how many of the letters in t
we have in our current window.
We also initialize a need
variable with the size of countT
to keep track of how many letters we need to have.
Then, we'll have result
and resultLength
that will be keeping track of the minimum window substring we've seen so far.
Then, starting with both left
and right
pointers pointing to the first index, we'll add each character to our window
hash map. If the number of times the current character occurs in t
is the same as the number of times it occurs in our current window, we'll increment our have
variable.
That's good so far. Now let's look at this part:
while (have === need) {
if ((right - left + 1) < resultLength) {
result = s.slice(left, right + 1);
resultLength = right - left + 1;
}
window.set(s[left], window.get(s[left]) - 1);
if (countT.has(s[left]) && window.get(s[left]) < countT.get(s[left])) {
have--;
}
left++;
}
Once what we have is equal to what we need, we'll do these things while they are still equal:
We'll first update our minimum result values (result
and resultLength
) if the length of our current window is less than their previous values.
Then, we'll see if we can do better. We'll first decrement the value of the left character in window
. If it reaches below the value in countT
, then we decrement our have
variable because we lack that character in our current window at this point.
Note that, here, we'll only decrement the have
variable when a character occurs less than the number of times it occurs in t
. We can have more, we'll be fine with that. For example, if t
is 'ABC'
, we'll be fine with having more than one 'A'
s in our current window.
Then, at this point, we'll also need to increment our left
pointer so that we shrink our window
and see if we can do better when it comes to the minimum value.
Once we have what we need, we'll return our result string, which is s
sliced from the left
index up to (and including) the right
index.
However, if we don't have a result, we'll just return an empty string. And, that's all there is to it.
The time complexity of this solution is $O(n)$, as we have two main loops, one iterating over the elements of t
, the other over the elements of s
; each one has $O(n)$ time complexity, making the overall time complexity $O(n)$.
The space complexity is $O(1)$, because the hash maps will be the dominant additional space, and they are constant, each having 26 items in total.
With the hardest (so far!), and the last problem of the Sliding Window chapter behind, we can finally start a new chapter on stacks. Until then, happy coding.
]]>The description for this problem says:
You are given a string
s
and an integerk
. You can choose any character of the string and change it to any other uppercase English character. You can perform this operation at mostk
times.Return the length of the longest substring containing the same letter you can get after performing the above operations.
For example:
characterReplacement('ABAB', 2);
// -> 4
// Explanation: Replace the two 'A's with two 'B's or vice versa.
characterReplacement('AABABBA', 1);
// -> 4
// Explanation: Replace the one 'A' in the middle with 'B' and form "AABBBBA".
// The substring "BBBB" has the longest repeating letters, which is 4.
// There may exists other ways to achieve this answer too.
This one is, I think, very tough, even though it's labeled as medium difficulty.
Let's take a deep breath, and look at one solution:
function characterReplacement(s: string, k: number): number {
let count = Array.from({ length : 26 }, () => 0);
let left = 0;
let right = 0;
let maxLength = 0;
while (right < s.length) {
count[s[right].charCodeAt(0) - 'A'.charCodeAt(0)]++;
while ((right - left + 1) - Math.max(...count) > k) {
count[s[left].charCodeAt(0) - 'A'.charCodeAt(0)]--;
left++;
}
maxLength = Math.max(right - left + 1, maxLength);
right++;
}
return maxLength;
};
Also, see the Group Anagrams problem, which uses a similar technique.
What we start with is an array to represent the frequency of characters that occur in the substrings we'll look at. We initialize count
with length 26
for each letter, each one initially having the value of 0
.
Then, we'll use the sliding window technique to check for substrings, so we'll initialize left
and right
pointers starting from the very first character's index: 0
.
We'll also need to keep maxLength
to keep track of the substring with the maximum length.
Then, as we look through each substring, we'll increase the character's count in the count
array.
One important—and, maybe the most confusing—part is here:
while ((right - left + 1) - Math.max(...count) > k) {
count[s[left].charCodeAt(0) - 'A'.charCodeAt(0)]--;
left++;
}
Now, right - left + 1
is the length of our current window.Math.max(...count)
will give us the maximum number of times a character occurs within our current window, that is, the number of the longest repeating letters.
Subtracting it from the length of our window will give us the number of potential replacements we can make.
Remember that we can replace a character at most k
times within a window, so, if the number of our potential replacements surpasses k
, then we need to slide our window; that is, update the left
pointer, also decreasing the value at the left
's index in the count
array.
We'll also keep track of our maxLength
as we increase our window's length, and return it at the end as the result.
The time complexity for this solution is $O(n)$ as we loop through each character in s
. The amount of space we additionally require for the count
array is constant, of length 26, so the space complexity will be $O(1)$.
This solution is adapted from NeetCode, where he also mentions a slight optimization.
I think this was a challenging problem, so it's time for another deep breath. Next up is the last problem Minimum Window Substring in the Sliding Window chapter, until then, happy coding.
]]>Let's start with the description for this problem:
Given a string
s
, find the length of the longest substring without repeating characters.
For example:
lengthOfLongestSubstring('abcabcbb');
// -> 3
// The answer is "abc", with the length of 3.
lengthOfLongestSubstring('bbbbb');
// -> 1
// The answer is "b", with the length of 1.
lengthOfLongestSubstring('pwwkew');
// -> 3
// The answer is "wke", with the length of 3.
// Notice that the answer must be a substring, "pwke" is a subsequence and not a substring.
Since this is a problem that is under the Sliding Window topic, my first intuition was to initialize left
and right
pointers, and keep growing the window until we see a duplicate character. If we see one, we can increase left
one step, and let right
be where left
is. And, we need to clear the set that keeps our unique letters as we're starting our search for a new substring anew. Otherwise, we'll just add the letters we see to our set, and keep increasing the right pointer; that is, increasing our window size from the right end. We'll also update our maximum length:
function lengthOfLongestSubstring(s: string): number {
let letters = new Set();
let left = 0;
let right = 0;
let maxLength = 0;
while (right < s.length) {
if (letters.has(s[right])) {
left++;
right = left;
letters.clear();
} else {
letters.add(s[right]);
right++;
}
maxLength = Math.max(maxLength, right - left);
}
return maxLength;
};
The time complexity for this solution is $O(n)$ as we iterate through the elements in the string once. The space complexity is also $O(n)$ as letters
can contain all the letters in the string in a worst-case scenario where all the letters are unique, making its growth proportional to our input string.
Now, let's take a deep breath because it turns out, there is a slightly better approach.
function lengthOfLongestSubstring(s: string): number {
let letters = new Set();
let left = 0;
let right = 0;
let maxLength = 0;
while (right < s.length) {
while (letters.has(s[right])) {
letters.delete(s[left]);
left++;
}
letters.add(s[right]);
maxLength = Math.max(maxLength, (right - left) + 1);
right++;
}
return maxLength;
};
This solution is adapted from NeetCode. This time we don't reset the entire substring when we find a duplicate as we did in the first version, and we remove only the repeating characters from the set instead of completely emptying it.
The time complexity of this version is $O(n)$ again as we iterate through all the characters. The space complexity is also $O(n)$; however, this version is more efficient than the first one, mainly given the reason that we don't wipe out the whole set when a duplicate is found.
Next up is Longest Repeating Character Replacement, until then, happy coding.
]]>Make sure to read the Sliding Window post first!
The description for this problem states:
You are given an array
prices
whereprices[i]
is the price of a given stock on theith
day.You want to maximize your profit by choosing a single day to buy one stock and choosing a different day in the future to sell that stock.
Return the maximum profit you can achieve from this transaction. If you cannot achieve any profit, return
0
.
For example:
maxProfit([7, 1, 5, 3, 6, 4]);
// -> 5
// Buy on day 2 (price = 1) and sell on day 5 (price = 6), profit = 6-1 = 5.
// Note that buying on day 2 and selling on day 1 is not allowed because you must buy before you sell.
maxProfit([7, 6, 4, 3, 1]);
// -> 0
// In this case, no transactions are done and the max profit = 0.
This problem is a perfect example where we can use the sliding window technique. But one important thing to notice is that we need to sell the stock at a future date; that is, the right end of our window should be at least one step ahead of the left end.
We can initialize left
and right
pointers (remember the Two Pointers technique?); left
at the first index, right
at left + 1
. If the price on the left is less than the one on the right, that means we can make a profit, so we can get their difference and update the maximum difference that we keep track of. Otherwise, we'll just update left
to be where right
is, because right
will be the minimum value we have seen so far. Either way, we'll update the right
pointer until it points to the last element.
It looks like this in TypeScript:
function maxProfit(prices: number[]): number {
let left = 0;
let right = left + 1;
let maxDiff = 0;
while (right < prices.length) {
if (prices[left] < prices[right]) {
let diff = prices[right] - prices[left];
maxDiff = Math.max(maxDiff, diff);
} else {
left = right;
}
right++;
}
return maxDiff;
};
This is an example of the dynamically sized sliding window technique, where we adjust our window size dynamically based on conditions, instead of keeping it fixed to some value. It's specifically a version of fast/catch-up because while the right
pointer is always increasing, the left
pointer jumps to catch up with the right
pointer in the else
block.
The time complexity for this solution is $O(n)$ because we iterate once through the items of the array. And, the space complexity is $O(1)$ as we don't use any additional space where the size depends on the size of the input array.
That's it for this problem, we can take a deep breath now. Next up is Longest Substring Without Repeating Characters — until then, happy coding.
]]>Now that we're familiar with the Two Pointers technique, we can add another one to our toolbox: the Sliding Window.
It's usually used for operations done on the subsets of a given data. It also comes in two flavors: fixed window size and dynamic window size.
If we have a size constraint in a given problem, say, we need to check a $k$-sized subarray, sliding window is an appropriate technique to use.
For example, getting the maximum subarray (of size $k$) sum of a given array can be done like this:
Note that the window size is $k$, and it doesn't change throughout the operation, hence, fixed size.
A very cool thing to notice here is that with each slide, what happens to our sum is that we add the right element, and decrease the left element.
Let's look at an example for getting the maximum sum of subarray with given size k
:
function maxSubarray(numbers: number[], k: number) {
if (numbers.length < k) {
return 0;
}
let currentSum = 0;
// Initial sum of the first window
for (let i = 0; i < k; i++) {
currentSum += numbers[i];
}
let maxSum = currentSum;
let left = 0;
let right = k;
while (right < numbers.length) {
currentSum = currentSum - numbers[left++] + numbers[right++];
maxSum = Math.max(maxSum, currentSum);
}
return maxSum;
}
while (right < numbers.length) {
currentSum = currentSum - numbers[left] + numbers[right];
maxSum = Math.max(maxSum, currentSum);
left++;
right++;
}
Since the postfix operator returns the value first, they can be used inside the brackets to be slightly more concise.
Here, we first get the initial sum of our window using the for
loop, and set it as the maximum sum.
Then we initialize two pointers: left
that points to the left end of the window, and right
that points to the right end of the window. As we loop, we update our currentSum
, decreasing the left
value, and adding the right
value. When our current sum is more than the maximum sum, maxSum
variable is updated as well.
As opposed to the fixed window size version, the size of the window changes dynamically this time.
For example, let's take a brief look at the problem Best Time to Buy and Sell Stock that we'll see in detail later on.
We need to choose a day to buy a stock, and sell it in the future. The numbers in the array are prices, and we need to buy the stock at as low a price as we can, and sell it as high as we can.
We can initialize left
and right
pointers again, but this time, we'll update them depending on a condition. When the left item is less than the one on the right, that means it's good, we can buy and sell at those prices, so we get their difference and update our maxDiff
variable that holds the maximum difference between the two.
If, however, the left one is greater than the right one, we update our left
pointer to be where the right
is at.
In both cases, we'll continue updating right
until we reach the end of the array.
With the blue arrow indicating the left pointer, and the red the right one, the process looks like this:
The solution looks like this:
function maxProfit(prices: number[]): number {
let left = 0;
let right = left + 1;
let maxDiff = 0;
while (right < prices.length) {
if (prices[left] < prices[right]) {
let diff = prices[right] - prices[left];
maxDiff = Math.max(maxDiff, diff);
} else {
left = right;
}
right++;
}
return maxDiff;
};
Both examples have the same time and space complexity: The time complexity is $O(n)$ because in the worst case we iterate through all the elements in the array. The space complexity is $O(1)$ as we don't need additional space.
Even though it might be slightly disorienting, sliding window technique is not too hard to fall from our grasp, so we can take a deep breath. The first problem of this chapter is Best Time to Buy and Sell Stock that we already mentioned, but we'll see in detail in the next post. Until then, happy coding.
]]>The description for this problem states:
You are given an integer array
height
of lengthn
. There aren
vertical lines drawn such that the two endpoints of theith
line are(i, 0)
and(i, height[i])
.Find two lines that together with the x-axis form a container, such that the container contains the most water.
Return the maximum amount of water a container can store.
Notice that you may not slant the container.
For example:
let height = [1, 8, 6, 2, 5, 4, 8, 3, 7];
maxArea(height);
// -> 49
// The above vertical lines are represented by array [1, 8, 6, 2, 5, 4, 8, 3, 7]. In this case, the max area of water (blue section) the container can contain is 49.
What we are looking for is the largest interval where the two heights have the smallest difference.
To put it another way, we want the width and the height to be the largest possible values.
The good thing is that we can do it with the Two Pointers technique.
We can keep left
and right
pointers that point to the two ends of the array. As we calculate the current area, we can update the maximum area. And, we need to update our pointers according to which height is less: if the left one is less than the right one, we'll increment left
, otherwise, we'll continue decrementing right
:
function maxArea(height: number[]): number {
let left = 0;
let right = height.length - 1;
let maximumArea = 0;
while (left < right) {
let containerWidth = right - left;
let containerHeight = Math.min(height[right], height[left]);
let currentArea = containerWidth * containerHeight;
if (currentArea > maximumArea) {
maximumArea = currentArea;
}
height[left] < height[right] ? left++ : right--;
}
return maximumArea;
}
The Python version is also similar:
class Solution:
def maxArea(self, height: List[int]) -> int:
left = 0
right = len(height) - 1
maximum_area = 0
while left < right:
container_width = right - left
container_height = min(height[right], height[left])
current_area = container_width * container_height
if current_area > maximum_area:
maximum_area = current_area
if height[left] < height[right]:
left += 1
else:
right -= 1
return maximum_area
The time complexity for this solution is $O(n)$ as we iterate through all the items in the array. The space complexity is just $O(1)$ because we don't need additional storage.
This was the last problem in this chapter. Next up, we'll look at the Sliding Window technique. Until then, happy coding.
]]>Let's start with the description for this one:
Given an integer array
nums
, return all the triplets[nums[i], nums[j], nums[k]]
such thati != j
,i != k
, andj != k
, andnums[i] + nums[j] + nums[k] == 0
.Notice that the solution set must not contain duplicate triplets.
For example:
threeSum([-1, 0, 1, 2, -1, -4]);
// -> [ [-1, -1, 2], [-1, 0, 1] ]
threeSum([0, 1, 1]);
// []
threeSum([0, 0, 0]);
// [ [0, 0, 0] ]
First of all, let's admit, this problem is a bit challenging. The first thing that comes to mind is that we can brute force our way to find all three. But in order to do that, we need to create three nested loops, which is not a good idea.
Still, we can try it, but beware; it is terrifying:
function threeSum(nums: number[]): number[][] {
let s: Set<string> = new Set();
for (let i = 0; i < nums.length; i++) {
for (let j = i + 1; j < nums.length; j++) {
for (let k = j + 1; k < nums.length; k++) {
if (nums[i] + nums[j] + nums[k] === 0) {
let triplets = JSON.stringify([nums[i], nums[j], nums[k]].sort((a, b) => a - b));
if (!s.has(triplets)) {
s.add(triplets)
}
}
}
}
}
return [...s].map(item => JSON.parse(item));
};
For example:
let s = new Set();
let arr1 = [1, 2, 3];
let arr2 = [1, 2, 3];
s.add(arr1);
if (!s.has(arr2)) {
s.add(arr2);
}
console.log(s);
// -> Set(2) { [ 1, 2, 3 ], [ 1, 2, 3 ] }
// But what we want is Set(1) { [ 1, 2, 3 ] }.
It passes the most of the tests, but as expected, it results in a Time Limit Exceeded error in one of them. And, it has a $O(n^3)$ time complexity.
So, let's take a deep breath and look at another approach.
We can start with the sorted version of nums
. Then, starting with the first number as the first value for the three numbers that add up to 0
, we can use the Two Pointers technique to find the rest of the two values.
function threeSum(nums: number[]): number[][] {
let result: number[][] = [];
nums = nums.sort((a, b) => a - b);
for (let i = 0; i < nums.length; i++) {
// Ignore the number if it's not the first value and a duplicate
if (i > 0 && nums[i] === nums[i - 1]) {
continue;
}
let left = i + 1;
let right = nums.length - 1;
while (left < right) {
let sum = nums[i] + nums[left] + nums[right];
if (sum > 0) {
right--;
} else if (sum < 0) {
left++;
} else {
result.push([nums[i], nums[left], nums[right]]);
left++;
while (nums[left] === nums[left - 1] && left < right) {
left++;
}
}
}
}
return result;
};
The outer for
loop picks a value for the first number to make the sum. Then, with left
and right
pointers, we check if the sum equals our target value of 0
, if it's greater than 0
, we decrement right
to find a smaller value for that position; and if the sum is less than 0
, we increment left
to find a greater value. Otherwise, if we find a triplet that adds up to 0
, we add it to our result
array, and continue incrementing left
. Note that we need another while
loop at this point to ignore a duplicate.
This solution comes from NeetCode, who does a great explanation of it in this video.
The time complexity for this solution is $O(n^2)$ because as we iterate through each item, we have an inner loop (while (left < right)
) that iterates through $n - 1$ items in the worst case.
Note that we have a sorting operation that takes $O(n \ log \ n)$ time, but in this case, $n^2$ will dominate.
The space complexity is $O(1)$ as we don't need additional space.
Next up is the problem Container with Most Water, until then, happy coding.
]]>The description for this problem is:
A phrase is a palindrome if, after converting all uppercase letters into lowercase letters and removing all non-alphanumeric characters, it reads the same forward and backward. Alphanumeric characters include letters and numbers.
Given a string
s
, returntrue
if it is a palindrome, orfalse
otherwise.
For example:
isPalindrome('A man, a plan, a canal: Panama');
// -> true
// Because "amanaplanacanalpanama" is a palindrome.
isPalindrome(' ');
// -> true
// Since an empty string reads the same forward and backward, it is a palindrome.
As we've seen in the introduction to the Two Pointers technique, checking for palindromes can be done easily. But here, we need to check only the alphanumeric and lowercase characters.
So, we can build a string getting those characters first, then use our two pointers to see if it's a palindrome or not:
function isPalindrome(s: string): boolean {
let str = '';
for (const letter of s.toLowerCase()) {
if (letter >= 'a' && letter <= 'z' || letter >= '0' && letter <= '9') {
str += letter;
}
}
let left = 0;
let right = str.length - 1;
while (left <= right) {
if (str[left++] !== str[right--]) {
return false;
}
}
return true;
};
First, we iterate through each letter in the lowercase version of s
, and concatenate it if it is alphanumeric, that is, if it is in the boundaries between 'a'
and 'z'
, or '0'
and '9'
.
Then we initialize two pointers: left
to start at the beginning and right
to start at the end of our new string. We check if two characters are different from each other, in that case, we immediately return false
, otherwise when the iteration is over, we return true
.
Python version of this code might look like this:
class Solution:
def isPalindrome(self, s: str) -> bool:
new_s = ''.join([i for i in s.lower() if i.isalnum()])
left = 0
right = len(new_s) - 1
while left <= right:
if (new_s[left] != new_s[right]):
return False
left += 1
right -= 1
return True
This time we get the alphanumeric characters with a handy method named str.isalnum()
and using list comprehensions.
The time complexity for this version is $O(n)$, because we iterate through the array once for each loop. The space complexity is $O(n)$ because in the worst case where all characters are alphanumeric, we need as much space as s
for our newly created string.
There is another solution where we don't need $O(n)$ space. We can still use the two pointers technique, and we can do it without creating additional space for building a result string.
function isPalindrome(s: string): boolean {
let left = 0;
let right = s.length - 1;
while (left <= right) {
while (left < right && !isAlphaNum(s[left])) {
left++;
}
while (right > left && !isAlphaNum(s[right])) {
right--;
}
if (s[left++].toLowerCase() !== s[right--].toLowerCase()) {
return false;
}
}
return true;
};
function isAlphaNum(c: string) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9');
}
Here we refactored checking if a character is alphanumeric—more properly, also checking for the uppercase characters. We increment the left
pointer until it's alphanumeric, and likewise, we decrement the right
pointer until it's alphanumeric too. Then, we just do the same as the first version, we check if two characters from both ends are the same, if not, return false
immediately.
Here is the Python version:
class Solution:
def isPalindrome(self, s: str) -> bool:
left = 0
right = len(s) - 1
while left < right:
while left < right and not s[left].isalnum():
left += 1
while right > left and not s[right].isalnum():
right -= 1
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return True
The time complexity of this version is still $O(n)$ as we iterate through all the characters in the string. However, we don't need to keep an extra string, so the space complexity is just $O(1)$.
You can see NeetCode's video for more explanation on this second solution with $O(1)$ space complexity.
Next problem is called 3Sum, until then, don't forget to breathe, and happy coding.
]]>One of the techniques of iterating through an array is the two pointers technique, and it is as simple as it sounds: we just keep two pointers, one starting from the left, and the other from the right, gradually getting closer to each other.
A very basic example can be the one where we check if a string is a palindrome or not. A palindrome is a string that reads the same forwards and backwards.
In an imaginary world where all the inputs always consist of lowercase English letters, we can do it like this:
// s consists of lowercase English letters
function isPalindrome(s: string) {
let left = 0;
let right = s.length - 1;
while (left <= right) {
if (s[left++] !== s[right--]) {
return false;
}
}
return true;
}
We initialize two pointers: left
and right
. left
points to the start of the array, while the right
points to the last element. As we loop while left
is less than right
, we check if they are equal. If not, we return false
immediately. Otherwise, our left
pointer is increased; that is, it's moved to the right one step, and our right
pointer is decreased, meaning that it's moved to the left one step.
When they eventually overlap, the loop terminates, and we return true
.
Let's say our string is 'racecar'
, which is a palindrome.
It will go like this:
Another example where we can use the two pointers technique is the problem Squares of a Sorted Array.
The description says:
Given an integer array
nums
sorted in non-decreasing order, return an array of the squares of each number sorted in non-decreasing order.
For example, if the input is [-4, -1, 0, 3, 10]
, the output should be [0, 1, 9, 16, 100]
.
Now obviously, we can just square each one, and then sort the array with a built-in sort method, and be done with it. But a sorting operation is never better than $O(n \ log \ n)$ runtime, so we can do it using two pointers in just $O(n)$ time:
function sortedSquares(nums: number[]): number[] {
let left = 0;
let right = nums.length - 1;
let result = [];
while (left <= right) {
if (Math.abs(nums[left]) > Math.abs(nums[right])) {
result.push(nums[left++] ** 2);
} else {
result.push(nums[right--] ** 2);
}
}
return result.reverse();
};
We compare the absolute value of the items that left
and right
are pointing to, and push the square of the greater one to our result
array. And we return the reversed version of it.
Because we only make one pass through the array while comparing, and then later reversing, it ends up being $O(n)$, a better runtime than $O(n \ log \ n)$.
The first problem we'll see in this chapter will be Valid Palindrome, which requires a more careful approach than the simplified version shown here.
Until then, happy coding.
The description for Longest Consecutive Sequence states:
Given an unsorted array of integers
nums
, return the length of the longest consecutive elements sequence.You must write an algorithm that runs in
O(n)
time.
For example:
longestConsecutive([100, 4, 200, 1, 3, 2]);
// -> 4
// The longest consecutive elements sequence is `[1, 2, 3, 4]`, the length is 4.
longestConsecutive([0, 3, 7, 2, 5, 8, 4, 6, 0, 1]);
// -> 9
The first idea is to get the non-duplicate elements, sort them, and then count how many of them follows a consecutive series. However, it won't be $O(n)$ time as the description says: any sorting can't be better than $O(n \ log \ n)$. But let's see how we can go about it nonetheless.
Getting the non-duplicate ones is easy: we can use a Set
.
Sorting them is no problem either, but how can we count the consecutive ones?
Looping through each element, we can't just check if the current element + 1 is in the set and update the count, because that doesn't mean that there is a consecutive order.
So instead, we can keep track of multiple counts, rather than holding just one count variable.
In order to do that, we need to keep multiple starting points for each sequence that potentially exists.
The tricky part is when the sequences change. For example, in a sorted array like [1, 2, 3, 4, 100, 150]
, it is obvious that the first sequence is of length $4$, but when it comes to 100
, we need to reset our count to start a new sequence.
In TypeScript, it might look like this:
function longestConsecutive(nums: number[]): number {
if (!nums.length) {
return 0;
}
let counts: number[] = [];
let count = 0;
let numsSorted = [...new Set(nums)].sort((a, b) => a - b);
for (let i = 0; i < numsSorted.length; i++) {
counts.push(++count);
if (numsSorted[i + 1] !== numsSorted[i] + 1) {
count = 0;
}
}
return Math.max(...counts);
};
So, as we loop through each element, we keep count, and add it to our counts
array, and only reset it when the next element is not the next one in sequence.
This solution passes the tests, but note that when we get to the last element, numsSorted[i + 1]
is just undefined
, so checking for inequality is meaningless.
Since we are sorting nums
, time complexity can't be better than $O(n \ log \ n)$. The space complexity will be $O(n)$ because of the additional storage for numsSorted
and counts
arrays, which will grow linearly as the length nums
increases.
In fact, there is a much better way of doing this, so let's take a deep breath, and see how we can improve.
When you notice that we use a Set
anyway, why not use it for what it's already good at, checking if an element is in it, instead of just pruning the duplicate elements?
The good part is that we don't even need to sort them.
function longestConsecutive(nums: number[]): number {
if (!nums.length) {
return 0;
}
let count: number;
let nums_ = new Set(nums);
let longestSeq = 0;
for (let n of nums) {
if (!nums_.has(n - 1)) {
count = 0;
while (nums_.has(n + count)) {
count++;
}
if (count > longestSeq) {
longestSeq = count;
}
}
}
return longestSeq;
};
This time we check if an element has a previous one that comes before it; if not, we reset count
and continue incrementing it while there is a consecutive sequence from that element onward. We update the longest sequence accordingly if the current count is greater than the previous longest sequence.
Here is the Python version:
class Solution:
def longestConsecutive(self, nums: List[int]) -> int:
if not nums:
return 0
nums_ = set(nums)
longest_seq = 0
for n in nums:
if n - 1 not in nums_:
count = 0
while n + count in nums_:
count += 1
if count > longest_seq:
longest_seq = count
return longest_seq
The time complexity is just $O(n)$ this time, as we only iterate through the nums
array.
The space complexity is again $O(n)$ though, because we need to allocate space for nums_
.
This was the last problem in Arrays & Hashing section in Blind 75. Next up, we'll look at the Two Pointers technique. Until then, happy coding.
]]>The description of this problem states that:
Given an integer array
nums
, return an arrayanswer
such thatanswer[i]
is equal to the product of all the elements ofnums
exceptnums[i]
.The product of any prefix or suffix of
nums
is guaranteed to fit in a 32-bit integer.You must write an algorithm that runs in
O(n)
time and without using the division operation.
For example:
productExceptSelf([1, 2, 3, 4]);
// -> [24, 12, 8, 6]
productExceptSelf([-1, 1, 0, -3, 3]);
// -> [0, 0, 9, 0, 0]
If we want to ignore the runtime having to be $O(n)$, a very naive idea is to get the product of the filtered version of the array... for each element (where the indices of the array do not include the current item's index).
Yes, I know that sounds terrible, but well, it works for most of the test cases until it hits one with a Time Limit Exceeded error because it's far from optimal:
function productExceptSelf(nums: number[]): number[] {
let result = [];
for (let i = 0; i < nums.length; i++) {
result[i] = nums
.filter((_, idx) => idx !== i)
.reduce((acc, item) => acc * item, 1);
}
return result;
};
This is not a solution to the problem, but the time complexity will be $O(n^3)$ as we do filter and reduce for each element. As we create another array using filter()
for each iteration, the space complexity is, I think, $O(n^2)$.
So, after a deep breath, let's see NeetCode's solution.
Here is a very clever solution. We'll make use of prefix and postfix variables. They have to be 1
as default, as it is the identity for multiplication. Prefix will start from the first element of the array and calculate the product so far up to the last element, and it'll be updated with the new value as we go.
So, for example, if the nums
array is [2, 3, 5]
, we'll go up to 5
:
[2, 3, 5] // nums
1 -> initial value of prefix
2 * 1 = 2 -> nums[0] * prefix = new prefix
3 * 2 = 6 -> nums[1] * prefix = new prefix
[1, 2, 6] // result
It might be easier to see with code:
let result: number[] = [];
let prefix = 1; // Initial value
for (let i = 0; i < nums.length; i++) {
result[i] = prefix;
prefix *= nums[i];
}
Postfix will start from the end of the array, and starting from the last item, it'll calculate the products so far as well. But we need to multiply it with the values calculated with the prefix, so that we get what we want: the total product of all elements before and after the $i$th element.
In the example above, our result looked like [1, 2, 6]
so far. We're going reverse this time, starting from the last element, up to the first one:
[2, 3, 5] // nums
[1, 2, 6] // result created so far thanks to prefix
1 -> initial value for postfix
6 * 1 = 6
-> result[result.length - 1] * postfix = new result[result.length - 1]
5 * 1 = 5
-> nums[nums.length - 1] * postfix = new postfix
2 * 5 = 10
-> result[result.length - 2] * postfix = new result[result.length - 2]
3 * 5 = 15
-> nums[nums.length - 2] * postfix = new postfix
1 * 15 = 15
-> result[result.length - 3] * postfix = new result[result.length - 3]
[15, 10, 6] // end result
Again, in code:
let result: number[] = [];
let prefix = 1; // Initial value
for (let i = 0; i < nums.length; i++) {
result[i] = prefix;
prefix *= nums[i];
}
// focus(1:6)
let postfix = 1; // Initial value
for (let i = nums.length - 1; i > -1; i--) {
result[i] *= postfix;
postfix *= nums[i];
}
One deep breath, and here is the Python version of the whole thing:
class Solution:
def productExceptSelf(self, nums: List[int]) -> List[int]:
result = [1] * (len(nums))
prefix = 1
postfix = 1
for i in range(len(nums)):
result[i] = prefix
prefix *= nums[i]
for i in range(len(nums) - 1, -1, -1):
result[i] *= postfix
postfix *= nums[i]
return result
And here is the TypeScript version:
function productExceptSelf(nums: number[]): number[] {
let result = Array.from({ length: nums.length }, () => 1);
let prefix = 1;
let postfix = 1;
for (let i = 0; i < nums.length; i++) {
result[i] = prefix;
prefix *= nums[i];
}
for (let i = nums.length - 1; i > -1; i--) {
result[i] *= postfix;
postfix *= nums[i];
}
return result;
};
Once again, to understand the idea better, if our array is [🌸, 🍁, 🍀, 🌼]
then, at the end of the first loop where we used prefix
, result
looks like this:
[
1,
🌸,
🌸 * 🍁,
🌸 * 🍁 * 🍀
]
And, after the second loop where we used postfix
, result
looks like this:
[
🍁 * 🍀 * 🌼 * (1),
🍀 * 🌼 * (🌸),
🌼 * (🌸 * 🍁),
1 * (🌸 * 🍁 * 🍀)
]
This version has $O(n)$ time complexity, as each loop just iterates through the elements of nums
once, which is linear time.
Since we use a fixed amount of space, the space complexity is technically $O(n)$ because we initialize result
with the length of nums
, but the description for this problem states that the output array does not count as extra space, so it is $O(1)$.
Next up is Longest Consecutive Sequence, until then, happy coding.
]]>Let's start with the description for Top K Frequent Elements:
Given an integer array
nums
and an integerk
, return thek
most frequent elements. You may return the answer in any order.
For example:
topKFrequent([1, 1, 1, 2, 2, 3], 2);
// -> Output: [1, 2]
topKFrequent([1], 1);
// -> Output: [1]
One of the constraints indicates that it is guaranteed that the answer is unique.
The first obvious idea is to keep a frequency map. We can do it easily like:
let count = new Map();
nums.forEach(n => {
count.set(n, (count.get(n) ?? 0) + 1);
});
Since we need to return the k most frequent elements, we need to do a bit more work. My idea is to sort the count
map by values (the frequencies) in reverse order to keep the most frequent elements in front, then get only the keys (the numbers), and slice it until k:
return [...count.entries()]
.sort(([, a], [, b]) => b - a)
.map((i) => i[0])
.slice(0, k);
All in all, it looks like this:
function topKFrequent(nums: number[], k: number): number[] {
let count = new Map();
nums.forEach(n => {
count.set(n, (count.get(n) ?? 0) + 1);
});
return [...count.entries()]
.sort(([, a], [, b]) => b - a)
.map(i => i[0])
.slice(0, k);
};
Since we have a sorting operation, the time complexity cannot be better than $O(n \ log \ n)$. The space complexity is $O(n)$ as it will grow linearly as the nums
array grows.
After one deep breath, we can try converting the above code into Python:
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
count = {}
for n in nums:
count[n] = count.get(n, 0) + 1
sorted_items = sorted(count.items(), key=lambda i: i[1], reverse=True)
return list(map(lambda x: x[0], sorted_items))[:k]
What we do is pretty much the same as the TypeScript version above.
The problem description also adds a "follow up," that the algorithm's time complexity must be better than $O(n \ log \ n)$, where $n$ is the array's size. Because we're doing the sorting, it doesn't satisfy this criterion. So, after one more deep breath, let's see NeetCode's solution.
It turns out, there is a better solution with $O(n)$ time complexity using the bucket sort algorithm.
We can create an array of size $n$ where each index corresponds to the count of elements. So, the values that occur twice will be stored in the second index, if all the elements are unique, all of them will be in the index 1
, etc.
In that case, if all elements are the same, they will be at the very last index because the count of that element will be $n$, the length of the nums
array.
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
count = {}
freq = [[] for i in range(len(nums) + 1)]
for n in nums:
count[n] = 1 + count.get(n, 0)
for n, c in count.items():
freq[c].append(n)
res = []
for i in range(len(freq) - 1, 0, -1):
for n in freq[i]:
res.append(n)
if len(res) == k:
return res
Note that in the last loop, we go in reverse, because higher the index, higher the frequency of values.
In TypeScript, it might look like this:
function topKFrequent(nums: number[], k: number): number[] {
let count = new Map();
let freq = Array.from({ length: nums.length + 1 }, () => []);
for (const n of nums) {
count.set(n, (count.get(n) ?? 0) + 1);
}
for (const [n, c] of count.entries()) {
freq[c].push(n);
}
let res = [];
for (let i = freq.length - 1; i > 0; i--) {
for (const n of freq[i]) {
res.push(n);
if (res.length === k) {
return res;
}
}
}
};
The time complexity is $O(n)$ this time, because in the worst case where each element is unique, each loop will iterate over $n$ elements at most. And, the space complexity is $O(n)$ as well, because the storage we use will grow linearly as the nums
itself grows.
Next up is the problem Product of Array Except Self. Until then, don't forget to take deep breaths, and happy coding.
]]>Let's start with the description for Group Anagrams:
Given an array of strings
strs
, group the anagrams together. You can return the answer in any order.An Anagram is a word or phrase formed by rearranging the letters of a different word or phrase, typically using all the original letters exactly once.
For example:
groupAnagrams(['eat', 'tea', 'tan', 'ate', 'nat', 'bat']);
// -> [ ['bat'], ['nat', 'tan'], ['ate', 'eat', 'tea'] ]
groupAnagrams(['']);
// -> [ [''] ]
groupAnagrams(['a']);
// -> [ ['a'] ]
And, as one of the constraints says, each of the strings will consist of lowercase English letters.
One thing to remember from the previous Valid Anagram problem is that we can easily check if two strings are anagrams of each other by comparing their sorted versions.
So, we can use a hash table to store the sorted words. In that case, all words that are anagrams of each other will be grouped together in an array, and share the same key:
function groupAnagrams(strs: string[]): string[][] {
let words: { [word: string]: string[] } = {};
for (let s of strs) {
let sortedWord = [...s].sort().join('');
(sortedWord in words) ? words[sortedWord].push(s) : words[sortedWord] = [s];
}
return Object.values(words);
};
Since we're using the sorting operation, time complexity will be $O(n \ log \ n)$ as it is the best we can do with sorting. But we're doing the sorting operation for each element in strs
, so the loop itself has an $O(n)$ time complexity.
To not confuse ourselves, we'll use another variable, $m$, to denote the length of strs
, that is, the number of times we'll iterate for each element. Overall, the time complexity will be $O(m \cdot n \ log \ n)$.
We can say that space complexity is $O(m \cdot n)$ where $m$ is the length of strs
and $n$ is the length of the longest string, because in the worst case where all strings are anagrams of each other, the value array can contain $m$ strings, and the key's length will be $n$, so, words
will grow proportionally.
(sortedWord in words) ? words[sortedWord].push(s) : words[sortedWord] = [s];
We could write it in Python like:
words[sorted_word].append(s) if sorted_word in words else words[sorted_word] = [s]
But since it's a bit clunky, we can use setdefault()
where we're setting the default value of words[sorted_word]
to []
.
It might look like this in Python:
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
words = {}
for s in strs:
sorted_word = ''.join(sorted(s))
words.setdefault(sorted_word, []).append(s)
return words.values()
Now, after taking a deep breath, we can look at NeetCode's solution.
And, voilà, a more efficient solution exists:
from collections import defaultdict
class Solution:
def groupAnagrams(self, strs: List[str]) -> List[List[str]]:
res = defaultdict(list)
for s in strs:
count = [0] * 26 # a ... z
for c in s:
count[ord(c) - ord('a')] += 1
res[tuple(count)].append(s)
return res.values()
Here, the constraint we mentioned in the beginning gives some perspective to this solution:
For each string, we can count the number of characters from 'a'
to 'z'
, because the strings will be just lowercase English letters.
We can still use a hash table, and map each of the 26 letters to an index, and increase the value at that index every time we see that letter. The keys will be these arrays of length 26, and the values will be the arrays of strings themselves.
For example, if we have these strings:
['eat', 'tea', 'tan']
Then, res
will look like this:
{
(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0): ['tan'],
(1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0): ['eat', 'tea']
}
Also note that in the code, we use the ASCII numbers of the characters to get their index, for example, the count of 'a'
will be at the 0th index, so it's basic offset arithmetic.
For instance, the ASCII number of 'z'
is 122
, and 'a'
is 97
, when you get the difference, it will be 25
, meaning that the 'z'
will be at the end of the array, that is, the 25th index.
After taking another deep breath, let's try converting it into TypeScript:
function groupAnagrams(strs: string[]): string[][] {
let result: { [count: string]: string[] } = {};
for (let s of strs) {
let count = new Array(26).fill(0);
for (let c of s) {
count[c.charCodeAt(0) - 'a'.charCodeAt(0)]++;
}
const key = count.toString();
!(key in result) ? result[key] = [s] : result[key].push(s);
}
return Object.values(result);
};
The time complexity will be $O(m \cdot n)$ where $m$ is the total number of strings and the $n$ is the length of a string.
For the space complexity, the dominant item will be the res
variable (the count
array won't matter much because it won't grow with the input size, it is constant, or $O(1)$).
In the case where each key is unique, the space complexity will be $O(m \cdot n)$ where $m$ is the total number of strings, and $n$ is the length of the longest string.
And, that's the end of Group Arrays. The next one will be Top K Frequent Elements, until then, happy coding.
]]>Let's see what the description says for this one:
Given an array of integers
nums
and an integertarget
, return indices of the two numbers such that they add up totarget
.You may assume that each input would have exactly one solution, and you may not use the same element twice.
You can return the answer in any order.
For example:
twoSum([2, 7, 11, 15], 9);
// -> [0, 1]
// Because nums[0] + nums[1] == 9, we return [0, 1].
twoSum([3, 2, 4], 6);
// -> [1, 2]
twoSum([3, 3], 6);
// -> [0, 1]
And, as the constraints say, only one valid answer exists.
The very first naive solution I thought of was this:
function twoSum(nums: number[], target: number): number[] {
for (let i = 0; i < nums.length; i++) {
for (let j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] === target) {
return [i, j];
}
}
}
};
This solution passes the tests alright, but, the time complexity is $O(n^2)$ because we have a nested loop. The good thing is that the space complexity is $O(1)$ as we don't use additional memory.
Still, the time complexity ruins our day, so there must be a better way.
A better way is a "one-pass solution," where NeetCode explains the concept around the second minute mark of the video.
The idea is that for each item, we can check if target - item
exists in the array that has a different index than that item. And the crux of the idea is that we can use a hash table to store the indices, and return immediately after finding the complementary item:
function twoSum(nums: number[], target: number): number[] {
let indicesOfNums: { [n: number]: number } = {};
for (let i = 0; i < nums.length; i++) {
if (target - nums[i] in indicesOfNums) {
return [indicesOfNums[target - nums[i]], i];
}
indicesOfNums[nums[i]] = i;
}
};
And, indeed, it passes the tests. 🎉
Here, time complexity is $O(n)$ because in the worst case, we're iterating through the whole array, so, as the length of the array increases, the time complexity will increase linearly. Nevertheless, it is better than our initial solution.
The space complexity, however, becomes $O(n)$ because we're storing an additional data structure, and in the worst case, it is proportional to the array's length.
We can translate the above code into Python:
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
indices_of_nums = {}
for i, num in enumerate(nums):
if target - num in indices_of_nums:
return [indices_of_nums[target - num], i]
indices_of_nums[num] = i
Now it's time to take a deep breath.
Let's take a look at NeetCode's solution.
NeetCode's solution turns out to be the same as the Python version above, except that it is slightly more explicit:
class Solution:
def twoSum(self, nums: List[int], target: int) -> List[int]:
prevMap = {} # val : index
for i, n in enumerate(nums):
diff = target - n
if diff in prevMap:
return [prevMap[diff], i]
prevMap[n] = i
return
And, that's it for Two Sums, we can take one more deep breath.
Next up is Group Anagrams, until then, happy coding.
]]>For this one, let's start with the description:
Given two strings
s
andt
, returntrue
ift
is an anagram ofs
, andfalse
otherwise.An Anagram is a word or phrase formed by rearranging the letters of a different word or phrase, typically using all the original letters exactly once.
And, both arguments will consist of lowercase English letters.
For example:
isAnagram('anagram', 'nagaram');
// -> true
isAnagram('rat', 'car');
// -> false
Here, we can use Map
to store the letter counts in both strings. If the same letter in the second string doesn't occur the same number of times as in the first string, we know that they are not anagrams.
Of course, the first thing to check is if the lengths of the strings are equal, because if they are not, then there is no way they are anagrams in the first place.
function isAnagram(s: string, t: string): boolean {
if (s.length !== t.length) {
return false;
}
let isValid = true;
let sDict = new Map();
let tDict = new Map();
// Initialize the objects with letters mapping to letter counts
for (const letter of s) {
const letterCount = sDict.get(letter);
!letterCount ? sDict.set(letter, 1) : sDict.set(letter, letterCount + 1);
}
for (const letter of t) {
const letterCount = tDict.get(letter);
!letterCount ? tDict.set(letter, 1) : tDict.set(letter, letterCount + 1);
}
// Check if a letter doesn't occur the same number of times
sDict.forEach((letterCount, letter) => {
if (tDict.get(letter) !== letterCount) {
isValid = false;
}
});
return isValid;
};
My guess for the time complexity is $O(n)$ as we iterate through the string's length to create the map. Space complexity would be $O(n)$ as well, because creating the map grows linearly as the length of the string increases.
Many things are potential one-liners in Python, so
collections.Counter(s) == collections.Counter(t)
is the easiest thing to do.
But to recreate the above code, it might look like this:
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
s_dict = {}
t_dict = {}
for letter in s:
s_dict[letter] = s_dict.get(letter, 0) + 1
for letter in t:
t_dict[letter] = t_dict.get(letter, 0) + 1
for letter, letter_count in s_dict.items():
if t_dict.get(letter, 0) != letter_count:
return False
return True
Note that we don't need an isValid
flag in this case, as we're not checking the letter counts inside a function with limited scope inside some function like a forEach
.
Also inside the last loop, as the letter in s_dict
may not be in t_dict
, we're using t_dict.get(letter, 0)
, so if it doesn't exist, it would be initialized with the count 0
.
I don't think that's a good solution at all, though.
So let's take a deep breath, and look at NeetCode's solution.
NeetCode's solution was pretty similar to the Python version above.
class Solution:
def isAnagram(self, s: str, t: str) -> bool:
if len(s) != len(t):
return False
countS, countT = {}, {}
for i in range(len(s)):
countS[s[i]] = 1 + countS.get(s[i], 0)
countT[t[i]] = 1 + countT.get(t[i], 0)
for c in countS:
if countS[c] != countT.get(c, 0):
return False
return True
The time and space complexity in this case are $O(n)$ as well.
To get rid of the extra memory usage and make the space complexity $O(1)$, he mentions the solution where you can compare the sorted versions of the strings:
sorted(s) == sorted(t)
In the case of TypeScript (or JavaScript) it could be:
[...s].sort().join('') === [...t].sort().join('');
[...s].sort() === [...t].sort();
Because arrays are objects, they'll be equal to each other only if they point to the same object in memory. In this case, even if [...s].sort()
and [...t].sort()
look like they are the same, they won't be equal to each other.
But, of course, sorting algorithms can't get better than $O(n \ log \ n)$ when it comes to time complexity, and some of them use $O(n)$ space to create additional storage as well, so it's another trade-off.
We can take one more deep breath now.
Next up is Two Sum, until then, happy coding.
]]>For this problem, let's start with the description:
Given an integer array
nums
, returntrue
if any value appears at least twice in the array, and returnfalse
if every element is distinct.
For example:
[1, 2, 3, 1] // true
[1, 2, 3, 4] // false
[1, 1, 1, 3, 3, 4, 3, 2, 4, 2] // true
We can use a Set
which only keeps the values without duplicates.
For each example, it would look like this:
new Set([1, 2, 3, 1]);
// -> Set(3) { 1, 2, 3 }
new Set([1, 2, 3, 4]);
// -> Set(4) { 1, 2, 3, 4 }
new Set([1, 1, 1, 3, 3, 4, 3, 2, 4, 2]);
// -> Set(4) { 1, 3, 4, 2 }
In that case, the difference between the size of the set and the length of the original array will tell us whether it contains duplicates or not. If they are not equal to each other, that means the array has duplicates.
Using TypeScript, my solution was this:
function containsDuplicate(nums: number[]): boolean {
return !(new Set(nums).size === nums.length);
};
It's obvious from the size and length comparison that this solution works, and indeed, it passes the tests.
My guess for the time complexity is that it's $O(n)$, because the Set
constructor iterates over each element in the array it is given as the argument.
I think that the space complexity is also $O(n)$, because in the worst case where each element is unique, Set
needs to allocate memory for each of them.
We can translate this solution into Python like this as well:
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
return len(set(nums)) != len(nums)
It's now time to take a breath.
Let's look at NeetCode's solution:
class Solution:
def containsDuplicate(self, nums: List[int]) -> bool:
hashset = set()
for n in nums:
if n in hashset:
return True
hashset.add(n)
return False
The worst case is still $O(n)$, and space complexity is $O(n)$ as well in the case of each element being unique.
However, I think it's an improvement as compared to my initial solution, because instead of creating the set in one go, we can return immediately if the element is in the set as we go through adding each one.
As we have reached the end of this meditation, we can take one more deep breath. Next up is the Valid Anagram problem. Until then, happy coding.
]]>Before starting the Arrays & Hashing section in the Blind 75 list, let's very briefly get to know our prerequisite topics for now:
Dynamic arrays are, well, dynamic. They're flexible, and can change their size during execution.
Python's list
type is a dynamic array.
We can create an items
list, for example:
items = [3, 5]
The length of items
is obviously 2, but its capacity is greater than or equal to its length. In fact, capacity refers to the total size, whereas length is the actual size.
Since dynamic arrays are still arrays, they need a contiguous block of memory.
We can easily add an item to items
:
items.append(7)
And add some more:
items.append(9)
items.append(11)
items.append(13)
All the while, the length and capacity of items
keeps growing dynamically.
Accessing an element is $O(1)$ as we have random access.
Inserting a new element or deleting an element is $O(n)$ (think about having to shift all the elements before inserting or after deleting an item). But, in order to not be too pessimistic, we can look at amortized analysis, in that case, inserting/deleting at the end of the array becomes $O(1)$.
Space complexity is $O(n)$, because of the excess space.
A hash table maps keys to values, implementing an associative array.
Python's dict
is one example:
number_of_petals = {
'Euphorbia': 2,
'Trillium': 3,
'Columbine': 5,
}
Also JavaScript's "object"s:
let numberOfMoons = {
'Earth': 1,
'Mars': 2,
'Jupiter': 95,
'Saturn': 146,
'Uranus': 27,
'Neptune': 14,
};
There are two important ingredients for a hash table:
Hashes are usually large integers, so to find an index, we can take the result of the hash modulo the array's length.
Here, with the hash value of each item's key, we calculate the remainder when it's divided by the length of the array to find which "bucket" it should go to.
The ratio of the number of elements to the number of buckets is called the load factor, and the higher it gets, the more collisions (when elements have to be inserted at the same place in the array) occur.
There are some collusion resolution tactics like linear probing (probing through the array until finding an empty bucket) and chaining (chaining multiple elements as linked lists), but we'll not go into those for now.
The average case for searching, inserting, and deleting operations are $O(1)$ as we use keys to look up the values.
Space complexity is $O(n)$ as it grows linearly with the amount of elements.
A prefix sum is the sequence of numbers we get after adding the sums of running totals of another sequence.
It's also called the cumulative sum.
The first element of the resulting array is the first element of the input array. That's fine. We start at the second item, and add the previous numbers each time as we go. That is:
$$result[i] = \begin{cases} nums[0] & \text{if } i \text{ is zero} \ result[i - 1] + nums[i] & \text{if } i \geq 1 \end{cases}$$
In code, we can implement that easily:
def runningSum(nums):
result = [nums[0]]
for i in range(1, len(nums)):
result.append(result[i - 1] + nums[i])
return result
Time complexity for a prefix sum is $O(n)$ because we're iterating over each of the elements in the array.
The space complexity is also $O(n)$ because the need of space grows as the length of the original array grows.
And, we're done with the introduction to the first chapter, now it's time to take a breath and notice your surroundings. Maybe it's raining, or a bird sings nearby, or there's just the silence of the night. Or neither of them, that's all fine.
The first problem to look at will be Contains Duplicate, so until then, happy coding.
It may seem like an oxymoron when the words "LeetCode" and "meditation" are used together; after all, one thing that almost everyone can agree is that LeetCode is challenging. It's called grinding LeetCode for a reason.
It doesn't have anything to do with the platform, of course, but rather what it represents: tackling problems for hours on end, usually to find a solution that is even harder to understand.
However, what is more challenging is finding a roadmap to solve those problems with very little knowledge of data structures and algorithms. There are prerequisites to solve one type of problem, and there are prerequisites for understanding those prerequisites in the first place.
It's just intimidating when you consider yourself a beginner.
That's why finding out about this roadmap from neetcode.io gave me an idea. LeetCode can be a good resource as you go about learning the concepts. In fact, why not take a more structured and calmer approach? If you don't have an interest in competitive programming or things of that sort, it doesn't have to be a pain.
We can treat learning about the topics and solving the problems on the list like taking a brief walk in nature, and maybe even pay attention to our breathing as much as we pay attention to the runtime analyses.
Also, an important point: it's not necessary to spend way too much time trying to solve one problem in one sitting; it is not only unhealthy, but it won't provide any value after some point.
The NeetCode roadmap contains a lot of problems, but in this series, I intend to follow the Blind 75 list as the roadmap also includes it. I aim to "get-to-know" (or at least, introduce myself to if I'm unfamiliar with) the "prerequisite" topics before each section, and for the problems, try my attempt at a solution one at a time, and when I get stuck, I'll at least try to reason about an idea of a solution. And, if I get really stuck and can't come up with an idea as well, then it's time to take a breath and learn about the solution(s) available.
In fact, if you've read this far, why not take a deep breath now, too?
The premium problems won't be included, as I want this series to be accessible. I'll provide NeetCode's solutions here and there for comparison at the end as well.
Now, time for some introspection. Is it likely that five years or so later I may regret putting up my first attempts for solutions to those problems? Probably. Will I be satisfied that writing about them is an aid to my learning, and shows an aspect of it? Yes, I guess.
Will I be satisfied that there is a chance this series might help someone out there? Absolutely.
Solving hundreds of LeetCode problems may be the gate to go through to get an interview at big tech companies, but learning the topics the problems are about is not under anyone's monopoly.
With that said, let's start the first chapter.
]]>But, let's take a look at one example:
from math import floor
def increment(y):
if y == 0:
return 1
elif y % 2 == 1:
return 2 * increment(floor(y / 2))
return y + 1
It's a beautiful recursive algorithm for incrementing natural numbers, taken from Steven Skiena’s The Algorithm Design Manual.
But how do we know that it's correct?
The book answers it, by using induction.
The base case is when $y$ equals $0$, and if that's the case, we return $1$. That is correct: $0 + 1 = 1$.
Our induction hypothesis is that $\text{Increment}(n - 1)$ is $n$.
We assume that is the case, and go on to show that $\text{Increment}(n)$ holds as well, that is, $\text{Increment}(n) = n + 1$.
When $y$ is an even number (when $y \text{ mod } 2 = 0$), we return $y + 1$ in the last line, so that's correct.
So what's the deal with odd numbers, then?
When $y$ is odd (when $y \text{ mod } 2 = 1$), what is returned from the increment
function is:
2 * increment(floor(y / 2))
Remember that we need to prove $\text{Increment}(n) = n + 1$, so we need to prove that what we return here is indeed y + 1
.
When $y$ is odd, we can write it as $2m + 1$, for some integer $m$. In that case, what we have is:
2 * increment(floor(((2 * m) + 1) / 2))
Or:
$$2 \cdot \text{Increment}(\lfloor(2m + 1) / 2\rfloor)$$
We can simplify it by dividing the terms inside $\text{Increment}$ by $2$:
$$2 \cdot \text{Increment}(\lfloor{m + 1 / 2}\rfloor)$$
Taking the floor of $m + 1/2$, we have just $m$ (remember that $m$ is an integer):
$$ 2 \cdot \text{Increment}(m)$$
...which is (by our induction hypothesis):
$$2(m + 1)$$
...which is:
$$2m + 2$$
We said that $y$ is $2m + 1$. And the result of our increment function returns $2m + 2$, which is the correct answer: $y + 1$ 🎉
This is certainly a bit tricky at first, but it provides an important lesson that induction is a solid way of proving correctness, even though most of it feels like magic.
The lectures based on the book The Algorithm Design Manual can be found here.
]]>sqlite3
, there's a good chance that it creates some frustration. There are libraries that add a promise-based API to sqlite3
, such as sqlite
that might be more convenient to use, but let's narrow our focus for now and take a look at a very simple (and somewhat contrived) case.
Let's say we have an insertRow
function that simply inserts data into some entries
table:
async function insertRow(db, data) {
const { title, description, date } = data;
db.run(
`INSERT INTO entries(title, description, date) VALUES(?, ?, ?);`,
title,
description,
date,
async function (error) {
if (error) {
throw new Error(error.message);
} else {
console.log(
`Inserted a row to entries with the ID: ${this.lastID}`
);
}
}
);
}
...which is called by insert
:
async function insert(data) {
const db = new sqlite3.Database(filepath);
try {
await insertRow(db, data);
} catch (error) {
throw new Error(error.message);
}
}
...which is called by createEntry
:
async function createEntry(formData) {
const data = {
title: formData.get('title'),
description: formData.get('description'),
date: formData.get('date'),
};
try {
await insert(data);
} catch (error) {
return { message: `Failed to create entry ${data.title}` };
}
}
It might indeed be a contrived example, but if you're quick to notice, we're trying to rethrow the error (which was first thrown by insertRow
) in insert
, and catch it in createEntry
. However, in insertRow
, the error is thrown inside the callback, which is passed to db.run
— which means it will never be caught, unless we do something about it.
Instead, we can return a promise from insertRow
that rejects when an error happens:
async function insertRow(db, data) {
const { title, description, date } = data;
return new Promise((resolve, reject) => {
db.run(
`INSERT INTO entries(title, description, date) VALUES(?, ?, ?);`,
title,
description,
date,
async function (error) {
if (error) {
reject(error);
} else {
console.log(
`Inserted a row to entries with the ID: ${this.lastID}`
);
resolve();
}
}
);
});
}
Since we're using try...catch
in insert
and rethrow the error, we'll eventually return with
{ message: `Failed to create entry ${data.title}` };
inside createEntry
as we catch the error there.
This might not be ideal, and using a library that provides a promise-based API would be a better idea, but when you have to use a callback-based approach, wrapping it as a Promise is something to keep in mind.
]]>What do we mean by the shape of how a function evolves?
Let's say for now that if the shape grows and shrinks, then it is recursive, if it doesn't, then it is an iterative process.
The terminology here might be a bit confusing, but one thing to keep in mind throughout this post is:
recursive function !== recursive process
Which means, what if there are recursive functions that have iterative processes?
Is this a recursive function?
function plus(a, b) {
if (a === 0) {
return b;
} else {
return 1 + plus(a - 1, b);
}
}
What about this one?
function plus(a, b) {
if (a === 0) {
return b;
} else {
return plus(a - 1, b + 1);
}
}
They are both recursive functions alright, but the processes they generate are different. In fact, the second one is what is called a tail-recursive function, but let's not get ahead of ourselves.
Let's say we want to see how plus(2, 3)
works with both examples.
With the first function, a
is not equal to 0
, so we return 1 + plus(a - 1, b)
. But we first need to compute plus(a - 1, b)
for that. So, we go on and call plus
with new arguments: plus(1, 3)
. Again, a
is not equal to 0
, so we return 1 + plus(a - 1, b)
. But again, we need to do a recursive call first, so we go on to plus(0, 3)
. Now the base case holds, and we return b
, which is 3
. As this function is popped off the stack, the previous function takes this value and adds 1
to it. Our value is now 4
. When this one is popped off the stack as well, we're left with the very first function call; it adds 1
to the value it gets, which is 4
, and the result is 5
. And, we're done. This is good old recursion as we know it.
Here is a visual display of what is just described:
To see the shape more clearly, let's give it different arguments: 5
and 3
this time. The process would look like this:
plus(5, 3)
1 + plus(4, 3)
1 + (1 + plus(3, 3))
1 + (1 + (1 + plus(2, 3)))
1 + (1 + (1 + (1 + plus(1, 3))))
1 + (1 + (1 + (1 + (1 + plus(0, 3)))))
1 + (1 + (1 + (1 + (1 + 3))))
1 + (1 + (1 + (1 + 4)))
1 + (1 + (1 + 5))
1 + (1 + 6)
1 + 7
8
Now the grow-and-shrink part is more obvious.
The actual work of adding 1
is done on the way out, so to speak.
With the second example, though, things are a bit different. With this one, note that the final operation is a recursive call, there is no waiting to add 1
like in the other example.
Let's see it again with the same arguments, plus(2, 3)
.
a
is not equal to 0
, so we call plus(1, 4)
.
Once again, a
is not equal to 0
, so we go on to plus(0, 5)
.
And now the base case holds, a
is 0
, so we return b
which is 5
.
Here's how it goes:
If we were to use 5
and 3
:
plus(5, 3)
plus(4, 4)
plus(3, 5)
plus(2, 6)
plus(1, 7)
plus(0, 8)
8
So, while the first example has a recursive process, the second one has an iterative process.
The second one is also an example of a tail-recursive function because there is nothing left to do after the recursive call.
Note that the second function is still a recursive function, it calls itself, but it has an iterative process.
Most of these sound like a confusion of terminology, but they eventually make sense.
More information can be found at https://sourceacademy.org/sicpjs/1.2.1, in fact, the example is adapted from one of the exercises.
You can also read the section from the original book or watch Brian Harvey's lecture, which dives into the subject at the 17~ minute mark.
The animated GIFs are inspired by Lydia Hallie's JavaScript Visualized series.
]]>The most noticeable effect of applicative-order evaluation is that recursive functions may not terminate.^1
Out of context, it sounds like a lot is going on.
Applicative-order evaluation?
Recursive functions that may not terminate?
It sounds very thrilling, in the sense that it might be straight out of your nightmares, especially when it comes to non-terminating recursive functions.
When it comes to functions, it is no mystery how they are evaluated in the code we write — even if you don't use the term substitution, you're aware of what is going on here:
function add(x, y) {
return x + y;
}
add(7, 5); // 12
What is happening is that 7
is substituted for x
, and 5
is substituted for y
. That's great.
But first, let's look at an example.
Let's say we have to find the square root of a number, and we want to do it using Newton's method of approximating guesses.
How it goes is simple. We take a guess for the square root of a number $a$, and we improve our guess until we have a good enough answer that is really close to the actual square root of $a$.
How to improve our guess looks like this:
$$x_{n + 1} = \frac{1}{2}\Big(x_n + \frac{a}{x_n}\Big)$$
Let's unpack it a bit.
So, the next guess will be the average of two numbers: our guess ($x_n$) and the number we want to know the square root of divided by our guess ($\frac{a}{x_n}$).
The example is not particularly important, so if you don't feel very warm with math, that's fine. The gist is that we improve our guess until it's very very close to the actual number.
In JavaScript, it can be written like this:
function square(x) {
return x * x;
}
function average(x, y) {
return (x + y) / 2;
}
function is_good_enough(guess, x) {
return Math.abs(square(guess) - x) < 0.001;
}
function improve(guess, x) {
return average(guess, x / guess);
}
function sqrt_iter(guess, x) {
return is_good_enough(guess, x) ? guess : sqrt_iter(improve(guess, x), x);
}
sqrt_iter(1, 4); // 2.0000000929222947
But, let's say we don't like ternary operations up in our face in sqrt_iter
, so we want to abstract it out still further:
function check(predicate, then_clause, else_clause) {
return predicate ? then_clause : else_clause;
}
Then we can use it like this, perhaps:
function sqrt_iter(guess, x) {
return check(
is_good_enough(guess, x),
guess,
sqrt_iter(improve(guess, x), x)
);
}
However, when we run it, we have an error that you might be very familiar when doing recursion: Maximum call stack size exceeded
.
Why is that?
In the first iteration, when sqrt_iter
was looking like this:
function sqrt_iter(guess, x) {
return is_good_enough(guess, x) ? guess : sqrt_iter(improve(guess, x), x);
}
Everything worked fine, and we got our answer.
Why do we have an error when passing the recursive call to another function, then?
Well, the quote was a foreshadowing. The reason is what is called the evaluation order.
If you've ever read or intend to read the wizard book, this is one of the subjects that is touched upon in the early pages.
The authors (or wizards, I should say) talk about two distinct evaluation orders:
With applicative-order, a function's arguments are evaluated before the function is applied, and with normal-order, none of the arguments are evaluated until they are needed in the function body.
In our example, when we pass sqrt_iter
to check
, it is evaluated first, hence the recursion error. The reason is that JavaScript makes use of applicative-order, so the arguments are evaluated first.
Let's see it with a much simpler example, using Python this time. Let's say we have a sum_of_squares
function that returns, well, the sum of squares of two numbers n
and m
:
def sum_of_squares(n, m):
return square(n) + square(m)
def square(x):
return x * x
And, we pass 5 + 1
and 5 * 2
as arguments:
sum_of_squares(5 + 1, 5 * 2)
With applicative-order, the process looks like this:
-> square(6) + square(10)
-> (6 * 6) + (10 * 10)
-> 36 + 100
-> 136
But with normal-order:
-> square(5 + 1) + square(5 * 2)
-> ((5 + 1) * (5 + 1)) + ((5 * 2) * (5 * 2))
-> (6 * 6) + (10 * 10)
-> 36 + 100
-> 136
Note that with normal-order, the evaluations of (5 + 1)
and (5 * 2)
are done twice, so we're doing extra work.
With languages like JavaScript that most people are familiar with, it looks like what's being used is applicative-order evaluation. I guess that is sort of true, but neither are said to be used in the strict sense:
In practice, no programming language uses normal-order evaluation because of the performance penalty, and it is also difficult to use strict applicative-order evaluation because of the increase in non-terminating cases. Rather, programming languages tend to use lazy-evaluation as a means of enabling the performance benefit of applicative-order evaluation without the risk.^2
As another nightmare fuel for the non-terminating recursive case, let's take a look at this one last example:
function p() {
return p();
}
function test(x, y) {
return x === 0 ? 0 : y;
}
test(0, p());
With applicative-order, what will happen when we call test
is that both of the arguments will get evaluated first. When we call p()
, you know what will happen: a good old Maximum call stack size exceeded
. So, it will never terminate.
With normal-order evaluation, though, realize what happens.
The evaluation of the arguments won't happen until test
goes on to evaluate x === 0 ? 0 : y
. Only then, 0
is substituted for x
, and since 0 === 0
is true, the function will return 0
and terminate. It doesn't need to evaluate p()
further because of short-circuiting; the else condition (:
) is not reached.
So, it is not true that both kinds of evaluations will yield the same answer all the time.
The takeaway is that, like many things, there is a tradeoff.
It is entertaining, though, that even a simple matter of substitution has intricacies, probably far more than what is mentioned in this post.
sum_of_squares
is adapted from the original Structure and Interpretation of Computer Programming, and the JavaScript examples are adapted from the JavaScript version of the book (yes, it does exist).
The most basic way to use it looks like this:
import HelloWorld from './hello.mdx'
export default function Page() {
return <HelloWorld />
}
If you get the files dynamically, though, and use a frontmatter, things might be a bit confusing.
The MDX documentation recommends using a remark plugin, remark-frontmatter
to ignore the frontmatter and get only the MDX content like this:
import fs from 'node:fs/promises'
import remarkFrontmatter from 'remark-frontmatter'
import {compile} from '@mdx-js/mdx'
console.log(
await compile(await fs.readFile(`./posts/${params.slug}.mdx`), {
remarkPlugins: [remarkFrontmatter]
})
)
Say, if we have a hello.mdx
file that looks something like this:
---
title: Hello world
---
# Hi
This is an example MDX file.
What is logged for that hello.mdx
looks like this:
VFile {
data: {},
messages: [],
history: [],
cwd: '/Users/me/projects/demo-site',
value: '/*@jsxRuntime automatic @jsxImportSource react*/\n' +
'import {Fragment as _Fragment, jsxDEV as _jsxDEV} from "react/jsx-dev-runtime";\n' +
'function _createMdxContent(props) {\n' +
' const _components = Object.assign({\n' +
' h1: "h1",\n' +
' p: "p"\n' +
' }, props.components);\n' +
' return _jsxDEV(_Fragment, {\n' +
' children: [_jsxDEV(_components.h1, {\n' +
' children: "Hi"\n' +
' }, undefined, false, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 5,\n' +
' columnNumber: 1\n' +
' }, this), "\\n", _jsxDEV(_components.p, {\n' +
' children: "This is an example MDX file."\n' +
' }, undefined, false, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 6,\n' +
' columnNumber: 1\n' +
' }, this)]\n' +
' }, undefined, true, {\n' +
' fileName: "<source.js>",\n' +
' lineNumber: 1,\n' +
' columnNumber: 1\n' +
' }, this);\n' +
'}\n' +
'function MDXContent(props = {}) {\n' +
' const {wrapper: MDXLayout} = props.components || ({});\n' +
' return MDXLayout ? _jsxDEV(MDXLayout, Object.assign({}, props, {\n' +
' children: _jsxDEV(_createMdxContent, props, undefined, false, {\n' +
' fileName: "<source.js>"\n' +
' }, this)\n' +
' }), undefined, false, {\n' +
' fileName: "<source.js>"\n' +
' }, this) : _createMdxContent(props);\n' +
'}\n' +
'export default MDXContent;\n',
map: undefined
}
That is an object called VFile
.
What matters is its value
because that is the function body that we are going to run to compile MDX content into JavaScript.
In order to do that, we need to add the outputFormat
option as 'function-body'
to the compile()
function, and also change the development to false
. We also convert it into a String
:
import fs from 'node:fs/promises'
import remarkFrontmatter from 'remark-frontmatter'
import {compile} from '@mdx-js/mdx'
const code = String(
await compile(
await fs.readFile(`./posts/${params.slug}.mdx`, {
remarkPlugins: [remarkFrontmatter],
outputFormat: 'function-body',
development: false
/* …otherOptions */
})
)
)
The default outputFormat
of compile()
is 'program'
, it uses import
and export
statements, but we need to change it into function-body
, otherwise we'll get a dreaded error: Error: Cannot use import statement outside a module
.
These are not so important details, you can read more in the docs.
The only thing left is to run it, and we can finally return it as an MDX component with these two more lines:
const {default: Content} = await run(code, runtime)
return <Content />
Putting it together, what we have written looks like this:
export default async function Page({ params }: {
params: { slug: string }
}) {
const code = String(
await compile(await fs.readFile(`./posts/${params.slug}.mdx`), {
remarkPlugins: [remarkFrontmatter],
outputFormat: 'function-body',
development: false
})
)
const {default: Content} = await run(code, runtime)
return <Content />
}
We can do many different things such as overriding the components of Content
, using plugins to add KateX support, etc. MDX offers a lot of flexibility.
As always, the first place to check out more of what we can do is the official docs.
customers
table that looks similar to this:
| customer_id | first_name | last_name |
| --- | --- | --- |
| 1 | John | Doe |
| 2 | Jane | Smith |
And an items_ordered
table that's looking like this:
Usually, we can join them using the INNER JOIN
(or just JOIN
) clause:
SELECT customer_id, first_name, last_name, order_date, item, price
FROM customers
INNER JOIN items_ordered
ON customers.customer_id = items_ordered.customer_id;
But, there is also another way to do the same thing, which is the implicit JOIN:
SELECT
customers.customer_id,
customers.first_name,
customers.last_name,
items_ordered.order_date,
items_ordered.item,
items_ordered.price
FROM customers, items_ordered
WHERE customers.customer_id = items_ordered.customer_id;
Looks a bit more cluttered.
For clarity's sake, using an explicit JOIN syntax makes more sense, and from what I've read, it seems to be no significant performance difference between the two, and the ANSI 92 standard is said to be recommending using modern explicit joins.
]]>We want to get all the cities west of Chicago.
As we go towards the west, the longitude must be less than the longitude of Chicago — the west of the prime meridian has longitudes that are negative numbers.
Here is the pseudocode:
SELECT * FROM north_american_cities
WHERE longitude < ${Chicago's longitude}
So, what we need to do is somehow get the value of Chicago's longitude and use it as comparison.
It is by itself easy:
SELECT longitude FROM north_american_cities
WHERE city = 'Chicago';
What we can do is simply put it inside the parenthesis and use it as a value itself, just like a variable:
SELECT * FROM north_american_cities
WHERE longitude < (
SELECT longitude
FROM north_american_cities
WHERE city = 'Chicago'
);
The result is this:
|city|country|population|latitude|longitude| |---|---|---|---|---| |Guadalajara|Mexico|1500800|20.659699|-103.349609| |Houston|United States|2195914|29.760427|-95.369803| |Mexico City|Mexico|8555500|19.432608|-99.133208| |Phoenix|United States|1513367|33.448377|-112.074037| |Los Angeles|United States|3884307|34.052234|-118.243685| |Ecatepec de Morelos|Mexico|1742000|19.601841|-99.050674|Furthermore, we can order the longitudes from west to east using the ORDER BY
clause:
SELECT * FROM north_american_cities
WHERE longitude < (
SELECT longitude
FROM north_american_cities
WHERE city = 'Chicago'
)
ORDER BY longitude;
And, voilà:
|city|country|population|latitude|longitude| |---|---|---|---|---| |Los Angeles|United States|3884307|34.052234|-118.243685| |Phoenix|United States|1513367|33.448377|-112.074037| |Guadalajara|Mexico|1500800|20.659699|-103.349609| |Mexico City|Mexico|8555500|19.432608|-99.133208| |Ecatepec de Morelos|Mexico|1742000|19.601841|-99.050674| |Houston|United States|2195914|29.760427|-95.369803|The example is taken from sqlbolt.com.
]]>echo `which cp`
is the same as
echo $(which cp)
It finds the directory the cp
command is in (executes the which
command), and then prints it to the screen.
Normally, when using double quotes, many types of expansions do not work (like word-splitting and brace expansions), but with command expansion, this is not the case.
Commands inside backticks are executed if they are inside double quotes.
When writing a message to git commit
, rendering the name of a function in monospace, for example, is easy to do using backticks.
So, my hypothesis is that when I do something like
git commit -m "Add `doThis` function"
If doThis
is a command, it will be executed.
So, if we write something like:
git commit -m "Add `which cp` thing"
It will replace it with the directory that cp
is in, so that our commit message will look like:
Add /bin/cp thing
In fact, after trying it, it turns out to be true, and this Stack Overflow answer has a nice short explanation.
Single quotes, on the other hand, suppress all expansions, so if written like this:
git commit -m 'Add `which cp` thing'
It won't execute which cp
, but write it as it is:
Add `which cp` thing
So, I think it is safer to use single quotes, whether you want to use a code block a la markdown styling or not.
This enlightenment is thanks to the "Seeing the World as the Shell Sees It" chapter from William E. Shotts' book The Linux Command Line: A Complete Introduction.
]]>git add
, there is a chance that you might be in a situation where you want to include a bunch of files (or folders) but exclude only some of them. Adding each file one by one manually is, of course, unthinkable, but thankfully there is a very short and sweet way to do it.
Here comes the magical command:
git add -A ':!<file_path>'
In my case, I had to rename a lot of folders, and wanted to git add
them all at once, but exclude one of the untracked folders and a couple of other modified files.
Besides tons of other deleted files, git status
was showing this:
Since I wanted to exclude only the list-ops
folder and the wordy
files, I had to use the command:
git add -A ':!list-ops' ':!wordy/wordy.py' ':!wordy/wordy_test.py'
And, it worked 🎉:
All thanks to Chirag Chhuchha's blog post.
]]>a function where the values of multiple rows are grouped together to form a single summary value.
Simply, it is the collection and summary of data.
In MongoDB, an aggregation pipeline is a series of stages completed on the data in order, where a stage is a built-in method that does not permanently alter the data.
Aggregation pipeline can be used for many tasks like filtering or grouping data.
To use an aggregation operation, we can use .aggregate()
method on the collection, like below:
db.collection.aggregate([
{
$match: {
{ size: "small" }
},
...
}
])
In this example, .aggregate()
is the aggregation method, $match
is the aggregation stage, and { size: "small" }
is the expression we use to query the collection where the size
field is "small"
. After $match
, we can continue listing stages in the aggregation pipeline. Of course, since this is a pipeline, and each stage forwards the values on to the next stage, the ordering of stages matters.
Let's take a look at some of the aggregation pipeline stages we can use in MongoDB.
$match
$match
filters the documents that match a given expression, and pass them to the next stage in the pipeline.
Since it filters the documents, it reduces the number of documents to operate on, and therefore lessens the amount of processing required. So, it is better if we use it early in our pipeline.
$group
$group
, groups the documents by a given group key, and for each unique value of a group key, the output is only one document.
Let's look at an example data first. To see a sample document in a given collection, we can use .findOne()
method.
Let's say we're inside a bird_data
database and want to look at how a document looks like in the sightings
collection:
db.sightings.findOne()
A sample document is returned:
{
_id: ObjectId("62cf32bdcfe5bbb25ee815fc"),
species_common: 'Eastern Bluebird',
species_scientific: 'Sialia sialis',
date: ISODate("2022-01-18T18:24:00.000Z"),
location: { type: 'Point', coordinates: [ 40, -73 ] }
}
Let's now use an aggregation pipeline:
db.sightings.aggregate([
{
$match: {
"species_common": "Eastern Bluebird"
}
},
{
$group: {
_id: "$location.coordinates",
number_of_sightings: { $count: { } }
}
}
])
In this example, we first want to filter a specific bird species, Eastern Bluebird. We do that with $match
.
Then we create a $group
stage where we group the documents based on location coordinates. And, within the groups, we create a field called number_of_sightings
to show how many documents there are in each group.
The output looks like this:
[
{ _id: [ 40, -74 ], number_of_sightings: 3 },
{ _id: [ 41, -74 ], number_of_sightings: 1 },
{ _id: [ 40, -73 ], number_of_sightings: 1 }
]
$sort
$sort
does what you think it does, it sorts the documents. If the value given is 1
, it sorts in the ascending order, if the value is -1
—you guessed it— it sorts in the descending order.
$limit
$limit
is also clear, it limits the number of documents to show.
We know that the order is important, and here is a chance to remember that. Say, we are going to use both $sort
and $limit
stages. If we sort before limit, the output will be the first <number-of-limit>
documents on sorted data. Otherwise, if we limit before sorting, it will return the sorted version of the first <number-of-limit>
documents in our collection.
Let's see an example of them together:
db.sightings.aggregate([
{
$sort: { "location.latitude": 1 }
},
{
$limit: 4
}
])
Here, we have a $sort
stage that sorts the data according to the location.latitude
field in the ascending order. Then, we limit the number of documents to 4.
$set
To create new fields, or change the value of existing fields, we can use the $set
stage.
db.birds.aggregate([
{
$set: { "class": "bird" }
}
])
It simply adds the field of class
with the value of "bird"
to the returned document.
$count
$count
creates a new document with the given field name with the value of the number of documents at that stage in the aggregation pipeline.
An example usage after the $match
stage:
db.sightings.aggregate([
{
$match: {
date: {
$gt: ISODate('2022-01-01T00:00:00.000Z'),
$lt: ISODate('2023-01-01T00:00:00.000Z')
},
species_common: 'Eastern Bluebird'
}
},
{
$count: 'bluebird_sightings_2022'
}
])
$project
We can choose to include or exclude fields with the $project
stage. It is similar to the .find()
method, where the second value we pass is the projection. The value 1
indicates that we want that field to be included, 0
indicates that we want it to be excluded.
$project
stage is more appropriate to use last in the aggregation pipeline, as it just projects the given fields.
Here is an example where we only want the fields of date
, species_common
, and _id
to be shown:
db.sightings.aggregate([
{
$project: { "date": 1, "species_common": 1, _id: 1 }
}
])
$out
$out
is a stage that you can only use as the last one, it just writes the documents in an aggregation pipeline into a collection. If the collection exists, it overwrites it.
db.sightings.aggregate([
{
$match: {
date: {
$gte: ISODate("2022-01-01T00:00:00.000Z")
}
}
},
{
$out: "sightings_2022"
}
])
There are a lot of aggregation pipeline stages to use, and as always, the best place to look for and learn about them is the official documentation.
Happy coding.
]]>useReducer
might be another option to consider.
The first example that I'm going to use is an extremely basic and somewhat stupid one, nevertheless, I think we need the simplest examples when learning something new. And, this is by no means how useReducer
should be used — quite the opposite, for simple state management, useState
is more than enough, and you absolutely don't even need a reducer at all. This is just for "explain me like I'm five" kind of demonstration purposes.
With that said, now let's say we have this piece of code:
import { useState } from 'react';
export default function App() {
const [text, setTextValue] = useState('');
function handleChange(e) {
setTextValue(e.target.value);
}
function handleClearClick() {
setTextValue('');
}
return (
<div>
<InputForm
text={text}
handleChange={handleChange}
handleClearClick={handleClearClick}
/>
</div>
);
}
function InputForm({ text, handleChange, handleClearClick }) {
return (
<form onClick={e => e.preventDefault()}>
<input type="text" name="text" value={text} onChange={handleChange} />
<ClearButton onClick={handleClearClick} />
</form>
);
}
function ClearButton({ onClick }) {
return (
<button onClick={onClick} type="button">
Clear
</button>
);
}
What it is, is obvious, the App
component renders the InputForm
, which returns a form element that has an <input>
field, and a ClearButton
component as a button.
Now imagine for a moment that you've read about extracting state logic into a reducer for the first time, and are still a little confused. Let's see how we might use it for our code above.
import { useReducer } from 'react';
export default function App() {
const [text, dispatch] = useReducer(textReducer, '');
function handleChange(e) {
dispatch({
type: 'changed',
text: e.target.value,
});
}
function handleClearClick() {
dispatch({
type: 'clear_click',
});
}
return (
<div>
<InputForm
text={text}
handleChange={handleChange}
handleClearClick={handleClearClick}
/>
</div>
);
}
function InputForm({ text, handleChange, handleClearClick }) {
return (
<form onClick={e => e.preventDefault()}>
<input type="text" name="text" value={text} onChange={handleChange} />
<ClearButton onClick={handleClearClick} />
</form>
);
}
function ClearButton({ onClick }) {
return (
<button onClick={onClick} type="button">
Clear
</button>
);
}
function textReducer(state, action) {
switch (action.type) {
case 'changed': {
return action.text;
}
case 'clear_click': {
return '';
}
default:
throw new Error('error: this shouldn\'t have happened');
}
}
Note that components usually have to be on their own files, but we use all of them together in this example for simplicity's sake.
You can see how unpleasant this is, especially with the unused variable state
inside textReducer()
. It is because we're missing the point, the purpose of a reducer is to accumulate actions over time. From the React docs:
[a reducer] takes the result so far and the current item, then it returns the next result.
The example above has nothing to do with the previous state, each time it is set anew. But, you can understand the idea, after all. We used the dispatch
function inside our handler functions, and give it an action object that has information about what happened. And, inside handleChange
, we also provided e.target.value
as the value of text
property.
Maybe a better example could be a simple counter that actually needs the previous state. Let's see how we can do it with useState
first:
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
function increment() {
setCount(prevCount => prevCount + 1);
}
function decrement() {
setCount(prevCount => prevCount - 1);
}
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
It is self-explanatory. Now let's see how we might do it with useReducer
:
import { useReducer } from 'react';
export default function Counter() {
const [count, dispatch] = useReducer(countReducer, 0);
function increment() {
dispatch({
type: 'increment',
});
}
function decrement() {
dispatch({
type: 'decrement',
});
}
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+</button>
<button onClick={decrement}>-</button>
</div>
);
}
function countReducer(state, action) {
switch (action.type) {
case 'increment': {
return state + 1;
}
case 'decrement': {
return state - 1;
}
default: {
throw new Error('error: this shouldn\'t have happened');
}
}
}
Realize that we don't have to separately define increment
and decrement
functions, we can pass them directly to onClick
:
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
Let's now compare both versions. This is the useState
version:
import { useState } from 'react';
export default function CounterWithState() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(prevCount => prevCount + 1)}>+</button>
<button onClick={() => setCount(prevCount => prevCount - 1)}>-</button>
</div>
);
}
And, this is the useReducer
one:
import { useReducer } from 'react';
export default function CounterWithReducer() {
const [count, dispatch] = useReducer(countReducer, 0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</div>
);
}
function countReducer(state, action) {
switch (action.type) {
case 'increment': {
return state + 1;
}
case 'decrement': {
return state - 1;
}
default: {
throw new Error('error: this shouldn\'t have happened');
}
}
}
Inside countReducer
, returning something like state + 1
is a bit ambiguous, so let's define our state as an object instead. In that case, the final code should look like this:
import { useReducer } from 'react';
export default function CounterWithReducer() {
const initialState = { count: 0 };
const [state, dispatch] = useReducer(countReducer, initialState);
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-</button>
</div>
);
}
function countReducer(state, action) {
switch (action.type) {
case 'increment': {
return { count: state.count + 1 };
}
case 'decrement': {
return { count: state.count - 1 };
}
default: {
throw new Error('error: this shouldn\'t have happened');
}
}
}
Even though useState
and useReducer
are equivalent, useReducer
can be used especially when you have to update the state in a lot of places, and for refactoring, but in the most simple cases, useState
might be a better option.
Lastly, if we try to implement the useReducer
hook ourselves, this is how it might look like:
import { useState } from 'react';
export function useReducer(reducer, initialState) {
const [state, setState] = useState(initialState);
function dispatch(action) {
setState(s => reducer(s, action));
}
return [state, dispatch];
}
There are a lot of places where you can learn about useReducer
, but as always, the first place to go is the official docs when learning about something new. I've found Dmitri Pavlutin's blog post very helpful as well. As he also points out, useReducer
introduces a lot more complexity, so, you're probably good with useState
for simple state management.
Note that this post is written assuming that you already have a working app, but want to try using Jest and Testing Library. This is of course not the way of test-driven development, but hey, we're just learning.
The very first thing is to install Jest:
npm install --save-dev jest
Since we are using Testing Library, we need to install it as well:
npm install --save-dev @testing-library/react
Of course, we need to add "test": "jest"
to "scripts"
in our package.json
so that we can use npm run test
command:
"scripts": {
"test": "jest"
}
Note that if you are using eslint
, we need to add the piece below to our .eslintrc.json
(or, your choice of eslint
config file) so that you don't get any undefined
errors:
"env": {
"jest": true
}
Considering that we already have an App.jsx
, and created an App.test.jsx
with the most basic form of something like this:
import React from 'react';
import { render, screen } from '@testing-library/react';
import App from './App';
describe('App', () => {
it('renders App component', () => {
render(<App />);
screen.getByRole('heading', { level: 1, name: 'This is a heading' });
});
});
If we are quick to run npm run test
, we might get some errors related to encountering an "unexpected token." Such as this one:
The hint might be related to Babel, indeed, we need to install Babel to get things working:
npm install --save-dev babel-jest @babel/core @babel/preset-env @babel/preset-react
Also, we need to create a babel.config.json
and include this in it:
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": "current"
}
}
],
["@babel/preset-react", {"runtime": "automatic"}]
]
}
Now, everything is almost okay, but not quite. Let's run npm run test
again, and take a look at what we might see this time:
Now, for versions previous to 28, adding this to package.json
would have been enough:
"jest": {
"testEnvironment": "jsdom"
}
But, we need to install jest-environment-jsdom
explicitly in order for this to work because Jest removed it from the default package. See this Stack Overflow answer for a better explanation.
So, let's install it as well:
npm install --save-dev jest-environment-jsdom
Now, everything is alright.
Perhaps.
If you have an import
statement for, say, a CSS file like the one below in one of your files, you might encounter yet another Jest encountered an unexpected token
error:
import 'katex/dist/katex.min.css'; // Or, any kind of css file
We can use a proxy to mock CSS modules. And yes, we can install yet another package for that:
npm install --save-dev identity-obj-proxy
We also need to update our config. You can add the below in our package.json
's jest
configuration (depending on the files you have, you might add additional extensions):
moduleNameMapper: {
"\\.(css|less)$": "identity-obj-proxy"
}
It is what the Jest docs say about mocking CSS modules as well.
If you don't like your Jest configs being in package.json
, you can create a whole new jest.config.json
as its own configuration file. This is how it looks like for now:
{
"testEnvironment": "jsdom",
"moduleNameMapper": {
"\\.(css|less)$": "identity-obj-proxy"
}
}
And, for now, it is enough to get going. Of course, there is never a catch-all solution for all kinds of problems, but it works for my current setting, and I hope someone might find it helpful as well.
]]>.then()
chains could eventually turn into a hell of their own. Since the human mind works synchronously, it might be hard to grasp asynchronous code when it becomes too tangled. Luckily, we have yet another thing to save the day — async
and await
!
We can write asynchronous functions that look like synchronous code with the keyword async
in front of the function
keyword:
async function doStuff { /* body */ }
Async functions always return a promise implicitly, even if you do something like this:
async function getTheMeaning() {
return 42;
}
What is returned will be a promise with the resolved value of 42
.
It might look similar to the piece of code below (there will come a "but"):
function getTheMeaning() {
return Promise.resolve(42);
}
But, they are not equivalent. The subtlety lies in the references these two functions will point to for a given promise. For example, let's create a new promise that both the plain function and the async function will resolve:
let theMeaning = new Promise((resolve, reject) => {
resolve(42);
});
function plainGetTheMeaning() {
return Promise.resolve(theMeaning);
}
async function asyncGetTheMeaning() {
return theMeaning;
}
When we inspect if they are pointing to the same value, we see that they behave differently:
console.log(theMeaning === plainGetTheMeaning()); // true
console.log(theMeaning === asyncGetTheMeaning()); // false
So, in fact, maybe it is better to think of an async
function as a function that returns a promise, and wrapped around our original function, like this one:
function getTheMeaning() {
return new Promise(function(resolve, reject) {
try {
resolve((function() { return 42; } )());
}
catch(e) {
reject(e);
}
});
}
Now in this case, we see that it is not like plainGetTheMeaning()
, and much more like the async
function itself when it comes to references:
let theMeaning = new Promise((resolve, reject) => {
resolve(42);
});
function getTheMeaning() {
return new Promise(function(resolve, reject) {
try {
resolve((function() { return 42; } )());
}
catch(e) {
reject(e);
}
});
}
console.log(theMeaning === getTheMeaning()); // false
async
functions can also have await
expressions. In fact, await
can only be used within async
functions (as long as it is not inside a module).
await
basically waits for a promise to settle, and returns the fulfillment value of that promise. A simple example:
async function getToDo(toDoId) {
let response = await fetch(`https://jsonplaceholder.typicode.com/todos/${toDoId}`);
/*
toDo is going to be the json object:
{ userId: 1, id: 1, title: 'delectus aut autem', completed: false }
*/
let toDo = await response.json();
return toDo.completed;
}
Here, the return value itself will be a promise, so it feels like we need to use await
for it. Like this:
let isCompleted = await getToDo(1); // Not really...
But remember, await
can be used inside another async
function! Now that we can't use await
, we need to handle this thing with our good friends .then()
and .catch()
:
getToDo(1)
.then(isCompleted => console.log(isCompleted))
.catch(e => { throw e });
The important thing to point out is that await
just waits for its promise to settle and pauses the execution of the function. It is what await
does, it just waits. So if there are multiple await
expressions in succession, each of them will be executed sequentially, instead of running in parallel. It could be a good thing if each of them depends on the expression before it, but when it is not the case, it might create a performance issue where the execution of the code is slower.
In the getToDo()
example, todo
needs response
, so it makes sense to use await
one after the other. But, let's say we have multiple URLs to fetch, multiple toDos
, and they are all independent of each other. In that case, we want them to run in parallel. We can use Promise.all()
with await
to do that:
async function getToDos(toDoIds) {
let responses = toDoIds.map(async (id) => await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`));
let [response1, response2, response3] = await Promise.all(responses);
let toDos = await Promise.all([response1.json(), response2.json(), response3.json()])
return toDos.map(toDo => toDo.completed);
}
let ids = [1, 2, 3];
getToDos(ids)
.then(toDosCompletedStatus => console.log(toDosCompletedStatus))
.catch(e => { throw e });
Inside getToDos()
, it seems like a lot going on, but it is actually simple to reason about. We take an array of ids
as argument, and map them to their fetch
responses of their corresponding URLs. Here, notice that we use an arrow function inside .map()
that is defined as async
.
Then, we use await Promise.all(responses)
to get the fulfillment values of responses
. After that, we get each response's .json()
value, since .json()
returns a promise, we again use Promise.all()
with await
. At this point, what we have is the fulfillment values of toDos
, and we map them to the completed
property of each.
And, here is the beauty of async
and await
, our code looks like it is synchronous, hence easier to read and think about.
Although using async
and await
is just a "better" way to use promises, and there is almost no difference between them, async
and await
might have a slight performance advantage when it comes to V8 JavaScript engine as the stack trace is not captured and stored when using await
. Read more about it here: https://mathiasbynens.be/notes/async-stack-traces
We have seen that promises were there to save us from callback hell, and that async functions make our code even better and simpler for us to read and write. Remember that there is always a "better" solution, depending on how you look at it, and of course, there is always the spec and perhaps the friendlier docs to consult. 💜
]]>fs.readdir(source, function (err, files) {
if (err) {
console.log('Error finding files: ' + err)
} else {
files.forEach(function (filename, fileIndex) {
console.log(filename)
gm(source + filename).size(function (err, values) {
if (err) {
console.log('Error identifying file size: ' + err)
} else {
console.log(filename + ' : ' + values)
aspect = (values.width / values.height)
widths.forEach(function (width, widthIndex) {
height = Math.round(width / aspect)
console.log('resizing ' + filename + 'to ' + height + 'x' + height)
this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {
if (err) console.log('Error writing file: ' + err)
})
}.bind(this))
}
})
})
}
})
This is an example of callback hell ― in fact, taken directly from that site. No one certainly wants to read, let alone write, such code. One clean way to deal with situations like these is through using promises. But, what is a promise in the first place?
Let's take a look at MDN:
A
Promise
is an object representing the eventual completion or failure of an asynchronous operation.
Sounds clear enough. But, is it?
Imagine for a second the everyday promises that you make to someone. It could be anything; let's say that you have promised your friend to call them as soon as you are available.
Now, one of three things can happen. They might still hold on to your promise, waiting for your call. Or, you might actually call them and fulfill your promise, showing how good of a friend you are. Or, you might outright reject to call them back, breaking your promise.
Well, how your friendships go in terms of promises is up to you, but the idea is similar with using promises in JavaScript; a promise has one of three states:
Let's take a look at this example:
let isCloseFriend = true;
let goingToCallFriend = new Promise((resolve, reject) => {
if (isCloseFriend) {
resolve('Hello, friend!');
} else {
reject(new Error('I don\'t want to talk.'));
}
});
goingToCallFriend
.then((fulfilled) => { console.log(fulfilled); })
.catch((error) => { console.log(error); });
Here, we define a global variable (for this example) called isCloseFriend
, and a Promise
object goingToCallFriend
. It takes a function as an argument, an executor function. It accepts two arguments, resolve
and reject
― each of which is a function.
Inside the body of the executor function, we check if they are a close friend of ours, if so, we resolve the promise with the resolve
function, passing it the value 'Hello, friend!'
. This is the fulfillment value that we want. This is what the docs say about it:
The argument passed to the resolve function represents the eventual value of the deferred action and can be either the actual fulfillment value or another promise which will provide the value if it is fulfilled.
Otherwise, if they are not a close friend, we call the reject
function to reject it, passing it an error that has a message of 'I don\'t want to talk.'
(the backslash here is for escaping the quote).
Also from the documentation:
The argument passed to the reject function is used as the rejection value of the promise. Typically it will be an Error object.
Here is the more interesting part, after defining goingToCallFriend
, we can invoke other methods on it, like .then()
and .catch()
.
.then()
needs a function as argument which itself takes two arguments onFulfilled
and onRejected
. This might be a bit confusing because we usually see examples of .then()
with one argument, the fulfilled value. It is the value that was passed to resolve()
. Notice that .then()
returns another promise, so we can chain many more methods.
.catch()
is just a syntactic sugar for .then()
with the first argument undefined
, and the second being the reason of error.
goingToCallFriend
.then((fulfilled) => { console.log(fulfilled); })
.catch((error) => { console.log(error); });
// ^ the shorthand for:
goingToCallFriend
.then((fulfilled) => { console.log(fulfilled); })
.then((undefined, error) => { console.log(error); });
Finally, there is another instance method called .finally()
that will be called no matter what is the state of the promise.
There is one thing to point out that a resolved promise does not necessarily mean a fulfilled promise. A promise can be resolved, but it does not mean that it is fulfilled. Just like life itself.
The famous States and Fates from the original Promises proposal details the terminology, and there is a very helpful Stack Overflow answer that explains this. Here is the table that the author uses in the answer that illustrates it clearly:
| action | dependency | state | resolved? | settled? | | --- | --- | --- | --- | --- | | `new Promise((resolve, reject) => ...)` | autonomous | pending | no | no | | ...`resolve(thenable)` | locked-in | pending* | yes | no | | ...`resolve(other)` | autonomous | fulfilled | yes | yes | | ...`reject(any)` | autonomous | rejected | yes | yes |
- The thenable is now in control over the future state of our promise object.
Oh, and a thenable is just what it sounds like — you can think of it as an object that has a .then()
method that accepts two callbacks, onFulfilled
and onRejected
.
There are a lot more things to consider when it comes to promises, such as the static methods like Promise.all()
, Promise.any()
, but they are kind of self-explanatory and clear in the docs.
One more thing that I want to mention is that promises are guaranteed to be asynchronous. Take a look at the code below:
let theMeaning = new Promise((resolve, reject) => {
resolve(42)
});
theMeaning.then(value => console.log('From inside .then()'));
console.log('Hello from the outside');
// > Hello from the outside
// > From inside .then()
'Hello from the outside'
gets logged first, even though the promise is already settled.
The reason for this has to do with the microtask queue. What it is might be slightly confusing for absolute beginners, but to very simply put it, a .then()
function goes inside the microtask queue and waits for the stack to be cleared. Only after the stack is cleared, it is pushed on to the stack and runs. In this case, theMeaning.then(value => console.log('From inside .then()'));
can't run before console.log('Hello from the outside');
and everything else is popped off the stack.
While we are here, it is important to note that learning about the event loop makes everything much clear when it comes it asynchronicity.
Philip Roberts's excellent talk is a good starting point, as well as the JavaScript Visualized article from Lydia Hallie.
There is even more delicious way to work with promises —async and await— but, that is for another article.
Until then, happy coding.
ELEMENT_NODE
— an HTML element, for example, a <div>
.TEXT_NODE
— text content, for example, Hey
in <div>Hey</div>
.COMMENT_NODE
— a comment, for example, <!-- comment -->
.DOCUMENT_NODE
— document
itself.DOCUMENT_TYPE_NODE
— the document type, <!DOCTYPE html>
.Other node types can be found here.
Node types also have values, such as 1
corresponding to ELEMENT_NODE
, 3
corresponding to TEXT_NODE
, 8
for COMMENT_NODE
, 9
for DOCUMENT_NODE
, and so on.
We can see the type of a node with its aptly named nodeType
property. For example, let's say we have only one <div>
inside the <body>
of our document (I know, not a real world example, but bear with it):
<body>
<div>Hey</div>
</body>
document.querySelector('div').nodeType; // 1
document.querySelector('div').firstChild.nodeType; // 3
Since it is an ELEMENT_NODE
, the nodeType
is 1
.
When we look at the nodeType
of its firstChild
which is the Hey
text, we can see that it is 3
, which corresponds to a TEXT_NODE
.
You can see the properties and methods list of a Node
here.
One thing to mention is that all documents have root nodes that even if the document is blank, they will be there. These root nodes arehtml
(document.documentElement
), head
(document.head
), and body
(document.body
). And, of course, document
itself is the root of all nodes.
Element
represents all element objects. It would be pretty exhausting to list all its properties and methods, since it has a lot of them. You can always refer to MDN.
One interesting aspect is that each element is constructed with a unique constructor. HTML elements inherit from HTMLElement
interface (as well as Element
, Node
, and Object
). But, each has their own constructor. For example, <form>
element is constructed with HTMLFormElement
, <img>
element with HTMLImageElement
, and so on. Read more about them in here.
We can get all the elements in the document using querySelectorAll
method:
document.querySelectorAll('*');
It returns a static NodeList
containing all the elements in the page.
To get all the child elements, we can use children
property. Let's say our body
now contains a div
element with an img
element inside of it:
<body>
<div>Hey
<img src="https://unsplash.com/photos/LaNLiftpmQc" alt="Oxalis triangularis">
</div>
</body>
If we look at the children
of our div
, it is an HTMLCollection
that only contains the img
element:
document.querySelector('div').children;
// -> HTMLCollection { 0: img, length: 1 }
An HTMLCollection
is live, which means that if there is any change in the document, it will be reflected dynamically.
Now, let's see what happens if we use childNodes
property on our div
:
document.querySelector('div').childNodes;
// -> NodeList(3) [ #text, img, #text ]
Now, it returns a NodeList
which is live. It's clear that our first #text
node is Hey
, our img
is there, but what about the last #text
?
If we take a look at this NodeList
, we can see what is going on:
0: #text "Hey\n "
1: <img src="https://images.unsplash.…f64?width=640&height=360" alt="Oxalis triangularis">
2: #text "\n "
Whitespace are also text nodes, and because we use line breaks in our HTML document, it is obvious that they are also counted as text nodes.
This is just a quick reminder of some differences between properties like childNodes
and children
, as their return values will have items depending on the types of nodes they include.
As always, when in doubt, go to the documentation.
]]> https://rivea0.github.io/blog/a-quick-refresher-on-dom-node-types-and-element-node# 📁 students.csv
first,last,house
Harry,Potter,Gryffindor
Hermione,Granger,Gryffindor
Luna,Lovegood,Ravenclaw
Draco,Malfoy,Slytherin
As in the example above, the very first row (first,last,house
) can be used for headers as column names.
Python conveniently has a built-in csv
module to work with CSV files in an efficient way. The two basic operations are reading and writing; with Python, we can do these operations using lists (or, more generally, any iterable), or dictionaries.
csv.reader()
To read a CSV file, one option is to use csv.reader()
method. Let's see an example for our simple students.csv
file:
import csv
with open('students.csv') as csvfile:
reader = csv.reader(csvfile)
for row in reader:
if row == ['first', 'last', 'house']:
continue
print(f'{row[0]} {row[1]} is in {row[2]}.')
# Harry Potter is in Gryffindor.
# Hermione Granger is in Gryffindor.
# Luna Lovegood is in Ravenclaw.
# Draco Malfoy is in Slytherin.
We use a context manager to open our file. (Note that we do not need to specify the 'r'
argument as the mode for the reading operation because it is the default; see the official docs.)
When we use csv.reader()
, it returns a reader object, which we store in the reader
variable. Then, with a for
loop, we iterate through each row, which is a list — and, because our first row is actually the headers, we pass that iteration and continue. Actually, csv.DictReader()
has better usability for this, which we will see later on. One important point here is that each row is a list
containing three elements, and we access them with indexing (row[0]
, row[1]
, row[2]
). This is not the most elegant implementation, but a simple enough example to see how csv.reader()
works.
csv.writer()
Let's say we want to add Ron Weasley to our CSV file, because we want our Golden Trio to be together. Let's see how we can go about doing it:
import csv
with open('students.csv', 'a') as csvfile:
writer = csv.writer(csvfile)
writer.writerow(['Ron', 'Weasley', 'Gryffindor'])
Here, we are again using a context manager to open our CSV file, this time with the 'a'
argument as the mode for appending to it. After that, we use csv.writer()
, passing into it our file object. It returns a writer object, which we store in the writer
variable. Then, we call writerow()
method to add Ron Weasley. One important thing is that we pass a list
as an argument, although it does not have to be a list
— we could have used an iterable like a tuple, but using lists is more common.
Now, our file looks like:
# 📁 students.csv
first,last,house
Harry,Potter,Gryffindor
Hermione,Granger,Gryffindor
Luna,Lovegood,Ravenclaw
Draco,Malfoy,Slytherin
Ron,Weasley,Gryffindor
csv.DictReader()
We can improve on our last example for reading our CSV file. Let's use csv.DictReader()
this time:
import csv
with open('students.csv') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:
print(f'{row["first"]} {row["last"]} is in {row["house"]}.')
# Harry Potter is in Gryffindor.
# Hermione Granger is in Gryffindor.
# Luna Lovegood is in Ravenclaw.
# Draco Malfoy is in Slytherin.
# Ron Weasley is in Gryffindor.
Like csv.reader()
, csv.DictReader()
returns a reader object, but this time, as the documentation says, it "maps the information in each row to a dict
whose keys are given by the optional fieldnames parameter."
If you have realized already, we have not specified the fieldnames parameter, so the first row is used as default fieldnames instead. We can see it with looking at the fieldnames
attribute of our reader object:
import csv
with open('students.csv') as csvfile:
reader = csv.DictReader(csvfile)
print(reader.fieldnames) # ['first', 'last', 'house']
csv.DictReader()
definitely made the code more readable than the previous method before. Let's see how we can use a dictionary again, this time for writing to our file.
csv.DictWriter()
We have added Ron Weasley to our students.csv
before, let's add another Weasley this time, Ginny. Let's see how we can do it with csv.DictWriter()
:
import csv
with open('students.csv', 'a') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=['first', 'last', 'house'])
writer.writerow({'first': 'Ginny', 'last': 'Weasley', 'house': 'Gryffindor'})
We are again appending to our file, like we did with csv.writer()
. We create a writer object with csv.DictWriter()
, of course passing into it the file object itself, then assigning the writer object returned to the variable writer
. Notice here that we pass an argument called fieldnames
as well, which is a list containing, well, the fieldnames. It is not an optional argument, so we have to provide it each time we use csv.DictWriter()
.
We use the writerow()
method again, but this time, we pass it a dictionary whose keys are the fieldnames we have just specified.
Now our file looks like this:
# 📁 students.csv
first,last,house
Harry,Potter,Gryffindor
Hermione,Granger,Gryffindor
Luna,Lovegood,Ravenclaw
Draco,Malfoy,Slytherin
Ron,Weasley,Gryffindor
Ginny,Weasley,Gryffindor
Now, what if inside writerow()
you pass in a key that is not in fieldnames? Obviously, we will have a ValueError
, but let's see with an example. They say a house is not a home, but imagine that we mistakenly write Gryffindor as Ginny's home
, instead of her house
as we have in the fieldnames. Let's see:
import csv
with open('students.csv', 'a') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=['first', 'last', 'house'])
writer.writerow({'first': 'Ginny', 'last': 'Weasley', 'home': 'Gryffindor'})
# ValueError: dict contains fields not in fieldnames: 'home'
This actually comes from the optional extrasaction
parameter inside csv.DictWriter()
. Its default value is 'raise'
, so when the key is not found in the fieldnames, it raises a ValueError
. However, we can force it to ignore it with passing the value 'ignore'
. So, if we do it:
import csv
with open('students.csv', 'a') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=['first', 'last', 'house'], extrasaction='ignore')
writer.writerow({'first': 'Ginny', 'last': 'Weasley', 'home': 'Gryffindor'})
In that case, we would have ignored the key altogether, resulting in our file looking like this:
# 📁 students.csv
first,last,house
Harry,Potter,Gryffindor
Hermione,Granger,Gryffindor
Luna,Lovegood,Ravenclaw
Draco,Malfoy,Slytherin
Ron,Weasley,Gryffindor
Ginny,Weasley,
This is not the wisest decision, though, it is better to have an error in such cases, unless we somehow have a reason to do otherwise.
We can also use the writerows()
method, to pass write more than one row. It takes an iterable as argument, so let's say we have two dictionaries in a list for the Weasley twins, we can add them both at once:
import csv
with open('students.csv', 'a') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=['first', 'last', 'house'])
twins = [
{'first': 'Fred', 'last': 'Weasley', 'house': 'Gryffindor'},
{'first': 'George', 'last': 'Weasley', 'house': 'Gryffindor'}
]
writer.writerows(twins)
Now, our file looks like:
# 📁 students.csv
first,last,house
Harry,Potter,Gryffindor
Hermione,Granger,Gryffindor
Luna,Lovegood,Ravenclaw
Draco,Malfoy,Slytherin
Ron,Weasley,Gryffindor
Ginny,Weasley,Gryffindor
Fred,Weasley,Gryffindor
George,Weasley,Gryffindor
Especially when working with writer objects, we may need to specify how to quote fields — for example, to handle a situation where our fields might include the delimiter character itself. For this, we can use the optional quoting
parameter for the writer methods, along with quotechar
parameter to specify which character to use for quotes.
The csv
module has four constants to use for quoting
:
csv.QUOTE_ALL
: To quote all fields.
csv.QUOTE_MINIMAL
: To quote the fields which have special characters (such as the delimiter itself).
csv.QUOTE_NONNUMERIC
: To quote all non-numeric fields.
csv.QUOTE_NONE
: To never quote fields. If the delimiter character occurs in one of the fields, then the escapechar
character is used. If escapechar
is not provided, it will raise an error.
Let's see a simple example with csv.QUOTE_ALL
using csv.DictWriter()
. As you can imagine, it is going to quote all the fields:
import csv
with open('students.csv', 'a') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=['first', 'last', 'house'], quotechar='"', quoting=csv.QUOTE_ALL)
writer.writerow({'first': 'Cho', 'last': 'Chang', 'house': 'Ravenclaw'})
Now, Cho Chang's field is all in quotes:
# 📁 students.csv
first,last,house
Harry,Potter,Gryffindor
Hermione,Granger,Gryffindor
Luna,Lovegood,Ravenclaw
Draco,Malfoy,Slytherin
Ron,Weasley,Gryffindor
Ginny,Weasley,Gryffindor
Fred,Weasley,Gryffindor
George,Weasley,Gryffindor
"Cho","Chang","Ravenclaw"
The convenience that Python provides with its built-in modules is really valuable, and it is no different with the csv
module, too. This article explored very simple operations for reading and writing CSV files, but there is always a lot more to explore, and the first stop is always the official documentation. Hopefully, you now understand some of the basics of dealing with CSV files.
Happy coding.
With a "double-ended queue", or a deque—pronounced as "deck"—, we can enqueue or dequeue, or, push and pop items from both ends at any time. Implemented as a doubly-linked list under the hood, insertion and deletion operations will take O(1), constant time. This is also another reason why a deque is great — you can imagine that we can also use a Python list
for the same goal, but in that case, if we want to insert and remove from the beginning (say, from the left end), the operation will take O(n) time, which is, well, not so good.
Let's take a look at it. Using a list
, you might have seen a stack as implemented as such*:
class Stack:
"""Stack implementation as a list."""
def __init__(self):
"""Create new stack."""
self._items = []
def is_empty(self):
"""Check if the stack is empty."""
return not bool(self._items)
def push(self, item):
"""Add an item to the stack."""
self._items.append(item)
def pop(self):
"""Remove an item from the stack."""
return self._items.pop()
def peek(self):
"""Get the value of the top item in the stack."""
return self._items[-1]
def size(self):
"""Get the number of items in the stack."""
return len(self._items)
And, a queue like this:
class Queue:
"""Queue implementation as a list."""
def __init__(self):
"""Create new queue."""
self._items = []
def is_empty(self):
"""Check if the queue is empty."""
return not bool(self._items)
def enqueue(self, item):
"""Add an item to the queue."""
self._items.insert(0, item)
def dequeue(self):
"""Remove an item from the queue."""
return self._items.pop()
def size(self):
"""Get the number of items in the queue."""
return len(self._items)
Since we want to use a deque
here instead of a list, let's take a simple look at it.
We can initialize a deque
object with optionally passing an iterable as argument. It is in the collections
module, so we also have to import it:
from collections import deque
d = deque([7, 3, 0, 1])
print(d) # deque([7, 3, 0, 1])
empty_d = deque()
print(empty_d) # deque([])
Also, remember that strings are sequences, in that case, our deque
would look like this:
d = deque('hey')
print(d) # deque(['h', 'e', 'y'])
We can also provide a maxlen
argument to specify the maximum length of items we want our deque
to have — to make it bounded.
This is a trivial example, but let's get a sense of how it is working:
from collections import deque
d = deque([4, 5, 3, 1, 8], maxlen=3)
print(d) # deque([3, 1, 8], maxlen=3)
d = deque([4, 5, 3, 1, 8], maxlen=4)
print(d) # deque([5, 3, 1, 8], maxlen=4)
As the items in the iterable are appended from one end, removing the other items (in the case of maxlen=3
example, 4 and 5) will be from the opposite end.
Of course, the efficiency of a deque
also comes from its appendleft()
and popleft()
methods, which are aptly named, and better than a list
in terms of time complexity.
from collections import deque
d = deque([7, 11])
d.appendleft(3)
print(d) # deque([3, 7, 11])
d.appendleft(1)
print(d) # deque([1, 3, 7, 11])
first_i = d.popleft()
print(first_i) # 1
print(d) # deque([3, 7, 11])
We also have the append()
and pop()
methods which do their operations to/from the right — like a regular list
:
from collections import deque
d = deque([2, 4, 6])
d.append(8)
print(d) # deque([2, 4, 6, 8])
first_popped = d.pop()
second_popped = d.pop()
print(f'Popped {first_popped} first, then {second_popped} second.')
# -> Popped 8 first, then 6 second.
print(d) # deque([2, 4])
Now that we have seen the append and pop operations from both sides, let's implement a queue first, similar to the list
version at the beginning of the article:
from collections import deque
class Queue:
"""Queue implementation as a deque."""
def __init__(self):
"""Create new queue."""
self._items = deque()
def is_empty(self):
"""Check if the queue is empty."""
return not bool(self._items)
def enqueue(self, item):
"""Add an item to the queue."""
self._items.append(item)
def dequeue(self):
"""Remove an item from the queue."""
return self._items.popleft()
def size(self):
"""Get the number of items in the queue."""
return len(self._items)
For the stack version, as we need to append and pop from the same end, append()
and pop()
methods using a list
might seem okay at first, too. But, let's modify the previous stack version above to implement it as a deque
:
from collections import deque
class Stack:
"""Stack implementation as a deque."""
def __init__(self):
"""Create new stack."""
self._items = deque()
def is_empty(self):
"""Check if the stack is empty."""
return not bool(self._items)
def push(self, item):
"""Add an item to the stack."""
self._items.append(item)
def pop(self):
"""Remove an item from the stack."""
return self._items.pop()
def peek(self):
"""Get the value of the top item in the stack."""
return self._items[-1]
Nothing much seems different, but you can also imagine using the other end, using appendleft()
together with popleft()
as well.
We have explored a very simple way to create stacks and queues using a deque, but of course, there is a lot more to dive into. The official documentation is the first place to go, and you can also check out a Real Python article on the subject. As with many things, it is up to you what you want to achieve, and a double-ended queue is just another tool in your toolkit to consider.
* The examples of stack and queue implementations as a list are from Brad Miller and David Ranum's wonderful book on algorithms and data structures.
]]>The theme of this series, as well as the course, has been one important point: when in doubt, read the documentation. Even though the official Python documentation might not seem as friendly at first, you have been using it for many weeks and must be familiar with it already. There are lots more to discover, of course, and it is our number-one friend. Some of these things to discover are already shown in the lecture, so, let's remember them with very simple examples. (And, get ready for a bunch of Harry Potter references.)
A set is, at the very basic level, a data structure that has no duplicates. So, let's say you want to look at the distinct broomsticks that Harry Potter used for Quidditch. Easy to do it with a set:
broomsticks = [
'Nimbus 2000',
'Nimbus 2000',
'Firebolt',
'Firebolt',
'Firebolt',
'Firebolt',
]
print(set(broomsticks)) # {'Firebolt', 'Nimbus 2000'}
Global variables are usually frowned upon; especially, using the global
keyword is something you must avoid unless you are absolutely sure what you are doing. You can think of a global variables as simply variables outside a function. They cannot just be changed right away inside a function, but are read only in that sense. To change the value of a global variable inside a function, you use the global
keyword. Let's say we are completing the title of our favorite book in the Harry Potter series:
half_title = 'Chamber of Secrets'
def change_half_title():
half_title = 'Goblet of Fire'
change_half_title()
print(f'Harry Potter and the {half_title}')
# -> Harry Potter and the Chamber of Secrets
Of course, it did not change as we expected. However, with the global
keyword, it works:
half_title = 'Chamber of Secrets'
def change_half_title():
global half_title
half_title = 'Goblet of Fire'
change_half_title()
print(f'Harry Potter and the {half_title}')
# -> Harry Potter and the Goblet of Fire
Again, it is not very nice to look at, so avoid this kind of implementation as much as you can.
If you have seen the lecture, you already know that Python do not have constant types. A "constant" variable, though, is indicated with capital letters:
SCHOOL_NAME = 'Hogwarts School of Witchcraft and Wizardry'
def invite_student():
return f'We are pleased to inform you that you have been accepted at {SCHOOL_NAME}.'
print(invite_student())
# -> We are pleased to inform you that you have been accepted at Hogwarts School of Witchcraft and Wizardry.
Python is a dynamically-typed language, however, we can still use type hints to make sure we avoid TypeError
s.
For example, as you can find the similar example in the documentation for typing, we can indicate the expected types for arguments and return values of a function:
def greeting(name: str) -> str:
return f'Hello, {name}!'
Also, as mentioned in the lecture, mypy
is a popular library that you can use for type hinting.
Docstrings can occur in a module, a function, or a class. The simplest one-line docstring looks like this:
def add(n, n1):
"""Add two numbers."""
return n + n1
The conventions on how to use docstrings can be found here in this PEP.
argparse
is a module that comes built-in with Python, literally a "parser for command-line options, arguments and sub-commands".
There is a great tutorial on the official documentation already, so, we are not going to dive deep into it here. The simplest thing you can do might look like this. Say, we have a file called spell.py
, and we want to pass in the argument -s
to our program to indicate the type of spell we want to create. We want the proper incantation printed on our terminal. Let's see:
# 📁 spell.py
import argparse
incantations = {
'patronus': 'Expecto Patronum!',
'summon': 'Accio!',
'unlock': 'Alohomora!',
'explode': 'Bombarda!',
'levitate': 'Wingardium Leviosa!',
'stun': 'Stupefy!'
}
parser = argparse.ArgumentParser()
parser.add_argument('-s')
args = parser.parse_args()
print(incantations[args.s])
We can see it with the right command:
$ python spell.py -s unlock
Alohomora!
We have mentioned the unpacking operators briefly in a previous post on problem set 4. The example looked like this:
values = [0, 5, 2]
print(*values) # 0 5 2
# Prints 0, 2, 4 respectively
for i in range(*values):
print(i)
houses = {
'Gryffindor': 'courage',
'Ravenclaw': 'intelligence',
'Hufflepuff': 'loyalty',
'Slytherin': 'ambition'
}
people = {
'Harry Potter': 'Gryffindor',
'Hermione Granger': 'Gryffindor',
'Luna Lovegood': 'Ravenclaw'
}
print({**houses, **people}) # {'Gryffindor': 'courage', 'Ravenclaw': 'intelligence', 'Hufflepuff': 'loyalty', 'Slytherin': 'ambition', 'Harry Potter': 'Gryffindor', 'Hermione Granger': 'Gryffindor', 'Luna Lovegood': 'Ravenclaw'}
They are super handy for many kinds of problems you encounter, so, another great tool in our toolkits.
With the map
function, we can map a function to each item of an iterable. Creating a list of the squares of each number in a "numbers" list might look like this:
numbers = [3, 5, 7, 11, 13]
squared = list(map(lambda n: n**2, numbers))
print(squared) # [9, 25, 49, 121, 169]
Notice that we also convert the return value of map
to a list
, as the map
function returns a Map
object.
If you have been following the series, you already know about the list comprehensions way back in Problem Set 2. It is a Pythonic way to append to a list, so instead of doing something like this:
word = 'CS50'
digits_in_word = []
for char in word:
if char.isdigit():
digits_in_word.append(char)
print(digits_in_word) # ['5', '0']
Just write a one-liner that achieves the same result:
word = 'CS50'
digits_in_word = [char for char in word if char.isdigit()]
print(digits_in_word) # ['5', '0']
We can also filter an iterable, returning only the values we are interested in.
The same example above in list comprehensions can also be solved like this:
word = 'CS50'
digits_in_word = list(filter(str.isdigit, word))
print(digits_in_word) # ['5', '0']
Also, just like in map
, notice we also convert the return value to a list
. We also do not call the str.isdigit
inside filter
, we only pass a reference to that function.
Similar to list comprehensions, dictionary comprehensions are also another —sometimes elegant, sometimes not— way to create dictionaries. To implement a very simple one, let's initialize all the Hogwarts house points to 0 for the start of the term:
houses = ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin']
house_points = {house: 0 for house in houses}
print(house_points) # {'Gryffindor': 0, 'Hufflepuff': 0, 'Ravenclaw': 0, 'Slytherin': 0}
It works as intended, and initializes all the house points 0.
Here is a Pythonic way to iterate over an iterable. Similar to the lecture example, let's say that this time we want to print the names of the houses, also indicated with the first value of '1', instead of '0'. We do not have to write something like this:
houses = ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin']
for i in range(len(houses)):
print(i + 1, houses[i])
# ->
# 1 Gryffindor
# 2 Hufflepuff
# 3 Ravenclaw
# 4 Slytherin
There is a more elegant way to do it:
houses = ['Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin']
for index, house in enumerate(houses, start=1):
print(index, house)
# ->
# 1 Gryffindor
# 2 Hufflepuff
# 3 Ravenclaw
# 4 Slytherin
Notice that the enumerate
function also takes a start
argument to start from the number that is passed.
Finally, also mentioned in the lecture, a generator function is a "function that returns a generator iterator". With a generator function that yields as opposed returns a value, we can save memory with lazy evaluation. The Python documentation also has a tutorial on generators, and similar to the example in the lecture, the very simplest implementation might look like this:
def main():
for _ in gen(1000000):
print(_)
def gen(n):
for _ in range(n):
yield _
if __name__ == '__main__':
main()
These are all the topics we have explored this week. From now on, we are left with our very own Final Project to implement. For this, you are free to create anything that excites you, any kind of problem that you want to solve — of course, following the given specifications for the project. And, after that, you might think that is all, and that we are finished, but, are we?
Now, looking back, we have gathered many useful tools in our toolkit to do whatever we want to do. But, should we do whatever we want to do just because we can?
You probably have ideas for the answer to that question. It is easy to get excited about all kinds of things you can create once you know how to do them. But, once you start to create things, always remember that using and trusting technology as a solution to all problems is not always the case. Now that you have the power and knowledge to do so, remember that it is absolutely vital to create software that respects users' freedom, that is open and trustworthy. Remember that privacy is a human right, even if there might have already been much talk about it — yet, usually without honesty. Do not underestimate your current level of knowledge, you have tremendous power in your hands with the tools you can use. And, yes, one more thing to remember — code is speech.
These all might sound like out of context, why should you even bother to think about them? After all, assuming you only have taken this introductory course, and are still at the beginning of your programming journey, and have a long way to go. But, hopefully, you undoubtedly agree that we need good things in life — good software that respects human dignity and helps the progress of humanity is one of them. If you think these are some grand ideologies for a "beginner" like you —which, honestly, I also consider myself a "beginner" in many things at this point—, remember that each piece of knowledge will eventually add up to another, so, even if you are not going to pursue a programming path in your life at all; that is fine, because at least this will be how you look at things, have a stronger sense of self-agency, and a more educated opinion in the decisions that affects us all.
With that in mind, that is the end of the series!
If you have read so far, thank you. And, as always, happy coding. 💜
So far, we have been writing our code procedurally, but this time, we are going to make use of the Object-Oriented programming paradigm, which is definitely exciting as we will explore each problem. Let's dive in!
For the first problem of this week, we are working with a datetime
object, instead of writing our own classes from scratch. We need to convert days to minutes; 365 days for example, results in 525600 minutes.
Many hints are given for this problem, but let's take a look. For starters, we want our code to be more modular this week as we learn to appreciate the importance of modularity in programming. The things that we need to do, is to get an input (in the YYYY-MM-DD
format), convert the input to a date object, calculate the difference of days between that given date and today, convert days to minutes, and finally convert minutes to words. Well, it may seem like a lot, but thinking this way actually makes our job easier.
First, as always we can try to get an input, and if we have a ValueError
, we can exit the program with a string like Invalid date
which will result in an exit status of 1. If we type help(sys.exit)
to get information from the documentation in this case, it literally tells you that:
Help on built-in function exit in module sys:
exit(status=None, /)
Exit the interpreter by raising SystemExit(status).
If the status is omitted or None, it defaults to zero (i.e., success).
If the status is an integer, it will be used as the system exit status.
If it is another kind of object, it will be printed and the system
exit status will be one (i.e., failure).
We have been doing this for many weeks, so, no problem.
If the given format is okay (something like 2021-06-02
, for example), we calculate how many days have passed from today. Before that, remember that input returns a string, and we need integers to construct a date object, so year, month, and day that are split from the input should be integers.
To calculate how many days have passed, datetime.date
has some methods that can come in handy here. It is already in the hints section, so if we subtract the given date from today, we have a timedelta
object returned, which has its own instance attributes like, say, days.
After we have the days, we need to convert it to minutes, and how to do it is also given in the problem explanation. Literally, it is this:
def days_to_minutes(days):
return days * 24 * 60
After we have our minutes, finally we need to convert it to a nice looking output. The inflect
library, which I have come to fall in love with, is tremendously helpful. What we need to do is literally to convert a number to words, but we only want commas and no "and words".
So, instead of looking like this:
'five hundred and twenty-five thousand, six hundred'
Our output should look like this:
'Five hundred twenty-five thousand, six hundred minutes'
Notice that we also want our output to be capitalized. Again, a way too obvious hint, but remember that the documentation is your friend.
After these, we have not much to do except writing our tests for our code. How to Test is also given in the problem explanation, all the test specifications should be enough to implement. We are already familiar with testing, and have been used to it already for many weeks, so it should be quite easy as well. Now, let's check out the next one.
Here we are, the time has come to write our first class in this course. In this problem, we are storing cookies in a jar. Simple, and really fun as it sounds.
We are given a template already, and need to implement the functions __init__
, __str__
, deposit
, withdraw
, capacity
, and size
. First of all, remember that __init__
function initializes our object construction. So, let's say you have just bought a cookie jar from the store, how many cookies are in it when you first bought it? It has a capacity (which in this case, we default to 12), but since there are no cookies in it, its size is 0 when you initially buy it. As you deposit cookies into it, its size increases, and as you withdraw cookies, its size decreases. Therefore, for the initialization, our cookie jar has the capacity of whatever is given as capacity, and the size of 0.
For deposit
and withdraw
functions, we need to be careful with some edge cases. For example, if the total number of cookies after you deposit is more than the capacity, we should raise a ValueError
. Otherwise, we increase the size. And, if the number to withdraw is more than the size, we also raise ValueError
. Otherwise, we decrease the size.
Now, here is the interesting part. While working with capacity and size, you realize that we not only read their values, but also set their values. As the template already indicates, we will use @property
decorator for the getter functions. How do we do the setters, then? Before answering that question, let's take a look at an example. Let's say we have created a class for a Hogwarts homework essay, and we are mainly concerned with the number of words in it. We have a default word limit of 2000 (which, I guess, would be nothing for Hermione), but it can be changed. We can add or remove words, but the important thing is how we handle the word limit and the number of words we have written already. It sounds complicated, but let's take a look at this:
class HogwartsEssay:
def __init__(self, word_limit=2000):
self.word_limit = word_limit
self.words_written = 0
def __str__(self):
return f'Number of words written: {self.words_written}'
def add_words(self, number_of_words_to_add):
...
def remove_words(self, number_of_words_to_remove):
...
@property
def word_limit(self):
return self._word_limit
@word_limit.setter
def word_limit(self, word_limit):
if word_limit < 0:
raise ValueError
self._word_limit = word_limit
@property
def words_written(self):
return self._words_written
@words_written.setter
def words_written(self, words_written):
self._words_written = words_written
As you can see, add_words
and remove_words
functions are omitted so as not to be way too close to the solution. But, the idea is simple. We can get the word limit as well as the number of words written, we can also set the word limit as long as it is not less than 0, and set the number of words written. It might be a weird example, but the idea is similar to the example given in the lecture. If you have seen the lecture, then you are already familiar with using getters and setters. This is really a fun problem, even though at a glance might seem complicated a bit. That is really all we need to do. And, for the tests, How to Test section is again your friend, as it tells you what to do almost step-by-step.
And, before we go into the next one, how to print the cookies? Well, we print a cookie emoji for the number of cookies in the jar, and, thank Guido that Python is an amazing language — and has a string operator that helps us repeat our strings.
On to the next (and the last) problem!
The very last problem to solve, is kind of customizable. We are making our very own I took CS50 shirts, with the help of fpdf2
library.
Here, the mantra we have for weeks is realized, has taken shape, and stares at us in flesh: "When in doubt, read the documentation." The problem has only five specifications, and beyond them, you are free to use any methods, and create any kind of shirt that you want to.
To be honest, though, this freedom may be a bit intimidating. Although, the bare minimum solution for this problem takes 15-20 lines of code, finding the right methods and attributes to use can be a bit of a pain. Let's see how we can manage to create a shirt with only the required specifications.
As the hints section suggests, we can add a subclass that inherits FPDF
class itself to write a header. In this case, our header will be the text "CS50 Shirtificate" that is centered horizontally.
The code in the tutorial for header function literally helps you with that, only we do not need to render a logo. Inside our function for header, we can just set our font, move cursor to right, print title with aligning it to center and without a border, and perform a line break. These are already given to you. After the line break, we can call the image
method to insert our image (shirtificate.png). In order to do it properly, we can set its width to effective page width (which is just the width of the page minus the horizontal margins) to make our job easier. Notice that the documentation has this to say for the width option for images:
w
:float
optional width of the image. If not specified or equal to zero, it is automatically calculated from the image size. Pass
pdf.epw
to scale horizontally to the full page width.
We also need to put our text {name} took CS50
, where name
is the return value of input
, on our shirt. We need to color our text white here. For that, you might have already checked the documentation for text styling; however, it might be still a bit confusing.
Now, not to deviate from the subject but, if you are really stuck (which was my experience at some point), the link to the documentation above has also a link to a file called test_text_mode.py
in the library's source code. Take a look at this:
def test_text_modes(tmp_path):
pdf = FPDF(format=(350, 150))
pdf.add_page()
pdf.set_font("Helvetica", size=80)
with pdf.local_context(fill_color=(255, 128, 0)):
pdf.cell(txt="FILL default")
with pdf.local_context(text_color=(0, 128, 255)):
pdf.cell(txt=" text mode")
pdf.ln()
...
And here it is, easy-to-use text color for the local context! Remember that our color should be white, whose RGB value represents all the colors to the brim. With local context, we create a cell, this time for the text on the shirt. Its height, as the hints in the problem explanation suggest, can be negative to adjust it properly, say, something like 250ish. For the width, our old friend effective page width is helpful. We also align it to the center, of course.
Now that our class seems to be done, it is time to create an instance, add page with the appropriate orientation and format, and output the shirtificate.pdf
. With that, that is the end of our problem and the problem sets!
It has been a really fun and delightful journey, but, now that the problem sets are actually over, it does not mean that we are done, yet. Next week, for the last installment in this series, we are going to think about things we have learned so far, what more to discover, and how to continue onward to the final project and beyond.
Until then, happy coding.
]]>Well, excuse the sarcastic introduction, but indeed, it is pretty sure that regex can slightly be a bit of a nightmare — especially, for beginners. However, have no doubts, it is a superpower in disguise.
Before we start, I have to give the usual disclaimer that these posts are mostly about how to approach the problems instead of giving outright the solutions. I also assume you have read the problem explanations given in the course's website, so that the references I make are clear. And, you can find all the posts on previous problem sets in the archive.
Without further ado, let's dive into this week's problems!
The first problem that we need to solve is really fun. We are checking valid IPv4 addresses, which has the format #.#.#.#
and has all its numbers in the range between 0 and 255, inclusive. The pattern is literally it; we are looking for a digit that can occur 1 to 3 times, then a dot, then another digit 1 to 3 times, then another dot, and then another digit, again 1 to 3 times, then a dot, and finally the last digit which can also occur 1 to 3 times.
One thing we should be careful is that this pattern should be the whole thing — there can be nothing before or after it. So, the input we are given should have this pattern only between the start and the end of it. Getting a digit character, and "1 to 3 times" part is easy if you have checked the documentation, or any other resource online. You are also familiar with the starting and ending restriction characters from the lecture as well. Since we also know how to put those digits into each group, we can check if each of them are within 0 to 255 range inclusive. This can be done with a simple for loop, but I want to show a Python function that can come in handy here.
Let's say we have a tuple that has Hogwarts houses in it.
We want to check if all of the items in it are Ravenclaw, because we do not want to be bothered with any other houses. We can try this:
def check_all(houses):
return all([house == 'Ravenclaw' for house in houses])
It has the same idea as this:
def check_all(houses):
result = []
for house in houses:
result.append(house == 'Ravenclaw')
if False in result:
return False
else:
return True
Maybe, not literally what is going on with all
, but the idea is similar to this one as well, only that we are returning False early here (let's not call it check_all
, but check
instead):
def check(houses):
for house in houses:
if house != 'Ravenclaw':
return False
return True
If houses
look like ('Ravenclaw', 'Ravenclaw', 'Gryfindor', 'Ravenclaw')
, check_all()
will return False
!
Notice that we have a list comprehension inside the all
function, and appending to it a conditional. Then we check if that list has any False in it, if so we return False, but otherwise we return True if all the conditionals in our list are True.
Similar idea can be applied with checking if each number in the match groups is within the range of 0 and 255 inclusive.
For the test file, considering only the cases we are given in the problem explanation and check50
is sufficient.
This was quite fun. Let's take a look at the next one.
With a graceful Rickroll, in this problem, we are extracting and parsing YouTube URLs for being able to easily embed them. The template for our program is, again, already given, we have to implement the parse()
function for it to be called on main()
. For a given string, namely s
, how can we start thinking about parsing a YouTube URL?
For starters, we know that in this problem specifically, the link is going to be inside an iframe
element. We know that it is going to be look like this in its simplest form:
<iframe src="http://www.youtube.com/embed/xvFZjo5PgG0"></iframe>
We know that it has to start with <iframe
, followed by a space character, followed by src="
. After that comes the link, as well as the closing quotation marks, closing angle bracket >
, and the closing tag </iframe>
.
There is also one more thing, we might have www
inside the link — which is to say that there can be zero or more characters before
"youtube".
If you have been following this series, you might notice that I have already given some subtle hints. Finding the corresponding regex characters is up to you to find — which is more fun, and which you can find in Python's documentation. Also, there are a lot of ways to implement a regex, so how you come up with a solution will be eventually up to you.
Before going on, you should notice that it is an http
link, which we should definitely turn into https
for encryption and security reasons. If you have captured that part as a group, it is easy to do it with a conditional, or replacement, however you would like to do it.
Let's say we have managed to get the URL http://www.youtube.com/embed/xvFZjo5PgG0
, and everything is fine. Or, is it?
That was simple for one attribute, but what if you have more than one attributes like this:
<iframe width="560" height="315" src="https://www.youtube.com/embed/xvFZjo5PgG0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
You see that src
occurs after width
and height
, and is followed by a bunch of other attributes. Now, if you do not do it in a non-greedy way, you might have something like this result as the URL you get:
https://www.youtube.com/embed/xvFZjo5PgG0" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture
That is definitely not a URL, nor the thing we are looking for. Notice that I mentioned the term "non-greedy", what does that even mean?
Let's say we have a string:
s = '"A string", and "another string".'
We only want to get "A string"
, not "another string"
. We are literally looking for a quotation mark, one or more characters in it, and then another quotation mark. For, simplicity sake, let's do it with this pattern:
import re
if matches := re.search('(".+")', s):
print(matches.group(1))
(If you are not familiar with it, see assignment expressions for the "walrus operator").
However, what we see in the terminal is not what we want: "A string", and "another string"
.
What happens is that, .+
does a greedy search. However, we can prevent this from turning it into a lazy search with just appending the question mark to it:
import re
if matches := re.search('(".+?")', s):
print(matches.group(1))
Now we see "A string"
, just like we want. You might be interested in seeing how greedy and lazy matching that actually works.
Using the lazy quantifier helps us get the URL in correct form. After that, we need to turn it into a shorter version. We do that by removing www
or embed/
, or .com
. In other words, we replace — or, substitute — these pieces of text with nothing. We also need to substitute youtube
with youtu.be
for the resulting string.
How you go on to implement these little puzzle pieces is, again, up to you as there are lots of different ways for a solution. Perhaps, one of the important takeaways is knowing the difference between greedy and lazy matching, and how to work with them. Let's see what the next one has in store.
I think this problem has also many different ways for a solution that you might even have an analysis paralysis (at least, this was my experience). What we need to do here is to convert 12-hour input format to 24-hour format. Our input should be in a certain form, though. For example, it has to have the word "to" in it, something like 9 AM to 5 PM
. We may or may not be given minutes; our input can be 9 AM to 5 PM
or 9:00 PM to 5:00 PM
. Additionally, the input can imply a night shift, so that AM and PM given might be reverse, like 10 PM to 8 AM
. All of these seem like a lot, especially if you are absolutely new to regular expressions, but again, reading the documentation and poking around might give some insights. I am not extremely satisfied with the solution I came up with, and there is definitely a more elegant way to think about it. But, let's try to understand a potential approach.
First, we know that we can capture not only the numbers for the hours and minutes, but also AM and PM, since their order matters for our resulting string. For the first number (that is the hour), we know that the number of digits it has can be either 1 or 2 (it could be 9
or 10
, for instance). We want a digit that has 1 to 2 repetitions. After that, optionally we can have a colon followed by another digit that has 1 to 2 repetitions as well. This second part is for the minutes so that if we are given 10:00
we can capture the :00
part. After that we are supposed to have a space character followed by either AM or PM. This is enough for describing 10:00 PM
. What we have after that, is another space character, followed by the string "to", then another space character followed by the pattern that we have just described for 10:00 PM
. If we capture the hour, minute, and AM/PM as groups, you might remember that we might also have an input where the minutes are not given — in this case, our minute group will result in None
. However, we can clean it and replace it with a simple :00
, since if the minutes are not given it is assumed 0. If there is no match, we should also raise a ValueError
. For determining the night shift (if PM comes before AM), we can check if the AM's index comes before PM in our cleaned groups list. In that case, we can have a flag variable where we can say that "night shift" is True or False. After that, we need to do the actual converting part. We can separate a conditional branch for "not night shift" (that is to say AM before PM), or night shift (PM before AM), and work our way with the appropriate hour and minute indices. The realization when converting is that, we do not consider minutes here, just the hours. And, for AM, if the hour is 12, we should convert it to 0 instead, otherwise, keep it like it is given. For the PM hour, if it is 12 it should stay 12, but for any other number we should add 12 to it. Because there seem to be a lot of "if conditions", I like to mention a one-liner way to do it in Python. So, it might look something like this:
hours_am = 0 if int(am_hour) == 12 else int(am_hour)
hours_pm = 12 if int(pm_hour) == 12 else int(pm_hour) + 12
The same as this:
if int(am_hour) == 12:
hours_am = 0
else:
hours_am = int(am_hour)
if int(pm_hour) == 12:
hours_pm = 12
else:
hours_pm = int(pm_hour) + 12
Of course, if it starts to get complex and reduces readability, you should avoid over-using one-liners, but it makes sense here for a small implementation.
We also need to check if the hours and minutes are valid — if the hours are within 0 and 12 inclusive, and the minutes are within 0 and 59 inclusive range. If either of them is invalid, we also need to raise a ValueError
here as well.
One more thing, we also need to create a test_working.py
file to test our code. Handling all the cases in "How to Test" section of the problem explanation is quite sufficient here, if not, we know that check50
is our friend to guide us on which tests to cover. For testing if our code indeed raises ValueError
in the right cases, we might remember how to do that from the Refueling problem from Week 5.
This one was a bit challenging, and I left some gaps on some points intentionally, but that is really the point of it. The thinking process might differ, this one is just the thinking process of the solution I came up with and hopefully provided you some insight. Let's look at the next one.
In this one, we are checking if the input we are given has "um" in it, but not counting it inside words like "yummy". The important idea is that we are looking for a word, therefore it has to have some boundaries. As the problem explanation suggests, it has to be the boundary between a word and a non-word character. Or, it can also be at the beginning or the end of the sentence. But also, we can have an input like um?
, which is followed by a non-word character, so we can have that optionally as well. We also need to take care of both uppercase and lowercase characters, and re.IGNORECASE
flag takes care of that.
The hints section already mentions re.findall()
function, since it returns a list of all the matches it finds, we can return the length of that list from our count()
function. For the tests, the edge cases to consider are already given in the problem explanation page, which will be sufficient as well. It looks daunting at first, but really, that is all there is to it. Let's look at the last problem of this week.
This problem emphasizes an important habit to have: relying on well-trusted libraries —no need to mention the importance of reading their documentation— when the time comes. And, that time might come when you need to validate an email address. In this problem, we can choose from two libraries, validator-collection or validators. We do not even have to use re
module ourselves, because these libraries handle everything for us.
Since this problem's solution depends on which library you use, there is nothing much that I can give a hint about. Documentation really helps you out for each of the libraries, we also do not need to write our own tests for this one as well. It is, of course, a good habit to handle errors, and that is pretty much it.
Dealing with regular expressions might indeed be challenging if you have never used them before. Nevertheless, we have seen that it is a superpower that comes in handy with all kinds of problems. Next week, we are going to take a look at Object-Oriented Programming. Until then, happy coding.
]]>You can find all the previous posts on past problem sets here in the archive. Now, let's dive into this week's problems!
This one was really fun to solve, even if it might get a little complicated when we start to think about some edge cases. What we want to do here is to count valid lines of code to have an understanding about the complexity of the program. We have worked with the sys
module, and try...except
blocks before, and know that they come in handy in this problem. Handling too many and too few command-line arguments is straightforward, as we have done that before; and we can also catch something like a FileNotFoundError
at this point. The hints for the problem already tells you to consider checking if a string ends with a certain substring — which is useful for checking if the filename we are given is indeed a Python file. The main thing to think about here is that considering the file is now valid, we need to count only the valid lines — that being, not blank lines or comments, but just the code itself. Let's say we want to do exactly that, ignore comments and blank lines, and count the lines of our code. So, let's say our code looks extremely silly like this:
def get_names():
"""
Prints each name in the golden trio.
Example output:
1: Harry
2: Hermione
3: Ron
"""
# The names of the golden trio
the_golden_trio = ['Harry', 'Hermione', 'Ron']
for index, name in enumerate(the_golden_trio):
print(f'{index + 1}: {name}')
The total number of valid lines to count should be four. Remember, we are ignoring the docstring altogether, as well as the comment (# The names of the golden trio
), and the blank line just after the_golden_trio
. A conditional is easy to implement in this case, we are counting lines as long as they do not start with a hashtag, and slice the lines between triple quotation marks. Or, we can calculate the number of lines of docstring and decrease it from the total value of valid lines at the end. However, we need to know the indices (or, indexes) for that job. To get the indices of lines in a file, I already have given the hint of enumerating, which might come in handy.
That is okay. However, it is easy to complicate things. Consider the example below:
def summon_item(item):
result = f'Accio {item}!' # Do the Accio spell
return result
If we were to look for a line with a hashtag in it to ignore it, in this example, we would be ignoring the line where we create the result
variable. This is not good. And, although inline comments are not very encouraged to use in Python, they exist nonetheless. The one way to get around that, of course, is to look if the left side of the hashtag is whitespace or not — which might look something like this:
has_inline_comment = '#' in line and not line.split('#')[0].isspace()
In this case, has_inline_comment
is a boolean variable that checks if the left side of the hashtag contains only whitespace characters.
Of course, this is just one way to do it for solving this specific little issue, there are surely better ways to do it. As always, there are many ways to solve a problem, and that is the beauty of programming and computer science in general. Again, at anytime in doubt, the documentation is your friend.
This problem is easy to implement if you have already watched the lecture. This time we work with csv
files, with a help from Python's own csv
module. We have two files, regular.csv
and sicilian.csv
which we can download into our directory with wget
command. We are still checking for the too few or too many command-line arguments, as well as checking if the file is in the right format and catching the FileNotFoundError
. There is no reason to go over these since we have already done implemented them exactly in the previous problem. Throughout these posts, I constantly remind the importance of the reading the documentation correctly, and again, this problem is another example to remind us of it. The tabulate
library's documentation literally tells you how to solve this problem. Using a simple reader object for our csv
files — whose first rows we can consider as headers — is more than enough to tabulate it. Remember that we are using the grid table format, and specifying the headers. Enough with the hints, the solution is already literally in the documentation itself. Let's look at the next problem.
In this problem, we are casting a spell! Well, you may already be thinking that writing in Python is like magic itself — I mean, it even reads like English. However, let's not lose ourselves in the appreciation of Python, but take a look at this problem.
We need to clean the data that we are given. In this case, we are again working with a csv
file. We take an input file and need to create an output as a "cleaned" version of the input. We have two fields, name
and house
. The name
field has the first and last name of the students all in one place inside a quotation mark, and we need to split them. We have been splitting strings for a while, so we know what to do here. We also have been checking for similar edge cases for the previous problems this week, only remember that this time, our command-line arguments has the length of three — as we include both the input and output filenames. Now, let's think about it. How to go about creating an output file that has the clean data?
The one thing we need to do is to open up an output file to write on it, create a DictWriter
instance with the appropriate fieldnames
, and write the header. These are, again, given in the "hints" section of the problem explanation, as well as literally in the documentation. I mean, the documentation actually provides you with enough knowledge on how to do it, no more no less. At this point, we need to open up the input file in reader mode, and read each row so that we can split the names appropriately. But, as we read each row, we also need to write a row to our output file — which is, again, shown to you in the example in the documentation link above. That is actually all that we need to do. Perhaps what might be tricky is when to open the files. You might already know that using with open()
for files closes them automatically so that you do not have to be bothered with closing the files manually. So, at some point in this problem, you may have come across with this beautiful looking error:
ValueError: I/O operation on closed file.
Well, now that you can guess exactly the reason of that, you may consider using the with open()
block inside another. Or, again, you can come up with many ways to solve it, this is just one way to do it. Perhaps with much more practice, we can refine our taste of solutions gradually. But now, let's take a look at the final problem of this week.
For the last problem of the week, we are to solve a fun problem, where we need to make Muppets wear I took CS50 shirts. For those who are familiar with the CS50x itself, I am also a fan of I finished Tideman shirts, which speaks a lot about that famous problem. Passing the tests of check50
for it is a kind of spiritual experience which I recommend to anyone who is willing to go through it, but anyway, let's not digress, and look at our problem at hand.
We are using the Pillow
library, perhaps the most handy library for working with images in Python. It is vast, hence its documentation; but we are given pretty much all that we need to do in the hints of the problem explanation itself. Even if this problem looks daunting, fear not, because we are going to have fun, and only barely scratching the surface of the Pillow
library.
Since the hints are already quite extensive, let's take a look at mainly the trickiest part: pasting an image onto another.
Consider this night sky image:
Let's say we want to paste this png
image representing Saturn onto our night sky:
Our code might look like this:
from PIL import Image, ImageOps
def main():
saturn = Image.open('saturn.png')
night_sky = Image.open('night-sky.jpg')
result = ImageOps.fit(night_sky, saturn.size)
result.paste(saturn, saturn)
result.save('result.jpg')
if __name__ == '__main__':
main()
In this case, our result.jpg
will look like this:
Opening the images is straightforward. If you look in the documentation for ImageOps.fit()
, it is quite explanatory as well:
Returns a resized and cropped version of the image, cropped to the requested aspect ratio and size.
And, the paste()
function, takes three arguments: im
to paste, box
for the region to paste into, and mask
for mask image. Since we adjusted sizes to fit, we do not need to specify box
. In result.paste(saturn, saturn)
, the first saturn
is the image to paste, and the second one is the mask image for updating only the specific pixels in this case. From the documentation:
If a mask is given, this method updates only the regions indicated by the mask. (...) Where the mask is 255, the given image is copied as is. Where the mask is 0, the current value is preserved.
Because our png
image has alpha channel for transparency — value of 0 usually indicates full transparency —, the original image to be pasted on will be preserved for these transparent pixels. Actually, why don't we look at some of these pixel values of our own Saturn image:
print(list(saturn.getdata(band=3))[:100])
band=3
indicates the alpha channel, and we are getting the first 100 values. We are also converting it into a list
to see it.
The output looks like this:
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 4, 2, 0, 0, 0, 0, 0, 0, 3, 22, 42, 62, 82, 111, 135, 147, 160, 173, 186, 199, 211, 224, 237, 244, 242, 241, 239, 237, 236, 234, 233, 231, 230, 221, 206, 192, 179, 165, 150, 136, 122, 108, 94, 81, 67, 51, 35, 19, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 3, 4, 3, 2, 2, 1, 0, 0, 0, 0]
We have a bunch of zeroes! In this case, we know that the pixels of our original jpg
image will not be lost when these transparent pixels are pasted onto it because the "mask" argument exactly takes care of that issue.
Since we also need to implement the error-checking (which we have done a lot and know how to think about and do at this point), and the rest is again literally given in the hints sections in the problem explanation, there is not much left to it at all. Now that we have even seen a little behind-the-scenes of the usage of Pillow
library for this problem, there is nothing to stop us from being encouraged to pass the tests for this problem. You can also take a look at this Real Python article to learn more about using Pillow
.
Next week, we are diving into the world of Regular Expressions, which is, admittedly, can be a bit of a nuisance for beginners. But, have no worries, it is actually a superpower in disguise, and it is going to be fun to use them in the next week's problem set.
Until then, happy coding.
]]>As it is pointed out in the lecture, the earlier you get into the habit of testing your code, the better. Unit tests kind of tell a story about your program, and how it should work. Writing good tests not only makes your program more robust, but it is also an indication that you have precisely defined how your program should behave and what to expect.
This week, we are visiting some old problems we have solved throughout this course to write tests for them. Instead of using Python's built-in unittest module, we are going to use the beautiful pytest library to implement our tests. Actually, you are already given hints weeks ago to solve all the problems of this week, so, in this post, there will be shorter paragraphs under each problem's header — except for the first one, which we shall see the crux of this week's problem set.
As always, I assume you have read the problem explanations, and I have to give the disclaimer that these posts are intended as a guide, or, (like today) just musings about the problem sets. Let's begin.
In this problem, we are reimplementing Setting up my twttr from Problem Set 2 to write some tests for it. Since we have already seen how that problem could be solved, there is no need to go over it again. We need to create two files, called twttr.py
and test_twttr.py
, inside a new directory called test_twttr
.
We only need to restructure our code if it is different from what is given in the problem explanation, separating shorten
and main
functions.
It is kind of like you are writing check50
for your own code, and, check50
checks your own check50
!
Now, how can we go about thinking of what kinds of tests to implement? To be honest, the answer to that question is hidden inside the original problem descriptions, specifically, the explanations of how to test them. However, there might be an issue: the tests that you write must catch the same bugs that the staff version looks for. So, this week, check50
is the kind of the hint itself: Its tests are actually the edge cases that you should be considering. It is the ultimate test to test your tests! Pretty sure, the CS50 staff wrote tests on their own to check check50
as well... But, that is another thing to think about later. For this week, the main thing to consider is to implement the original tests from the original problems themselves. So, if that original problem's check50
was testing certain inputs, you should create your tests similar to those tests that once tested your code for that problem.
I know, it was painful to read and perhaps hard to wrap your mind around, but hopefully the idea is clear. It took me longer to figure that out!
So, in the first problem, we know that the shorten
function expects a str
, so, we can test it by asserting that for a given string, our function outputs the vowel-stripped version of it. I cannot outright give a hint of what other tests should look like, but you are already given the answer three weeks ago.
The main thing to do here is to write simple assert
statements inside our test functions. Say, we want to test if a cast_spell
function works:
# 📁 spells.py
def cast_spell(incantation):
return f'{incantation.upper()}!'
A test for it would be like:
# 📁 test_spells.py
from spells import cast_spell
def test_cast_spell():
assert cast_spell('lumos') == 'LUMOS!'
assert cast_spell('expecto patronum') == 'EXPECTO PATRONUM!'
Also, we do not need any kind of try...except
, because pytest
generously takes care of that. Let's now take a look at the next one.
Again, in this problem, we are reimplementing another past problem, namely Home Federal Savings Bank. It was an easy and fun problem to solve, as we have done it before.
We know that the value
function should return an int
. And, for the tests? Well, the original problem explanation literally tells you that. Three kinds of tests (literally given to you before) — with two variations (for uppercase and lowercase) — should suffice.
In this problem, we are visiting the good old Vanity Plates from Problem Set 2. We have already went through it before. The main thing is to use the kinds of tests and inputs that the staff used to check our problem — which is also given in the original problem explanation. If they are not enough, well, remember there was also a test for the full alphabetical string — which should be valid as long as its length is within the limits.
In the last problem of this week, we go back to Fuel Gauge, which we have seen before on the week on Exceptions. Again, we need to implement the same kind of tests as the check50
for the original problem, and the cases to consider are clear in the original problem explanation once more. The new thing here is handling the exceptions with pytest
. As always, the documentation itself clearly shows you how to do that. For example, we can look at how to handle a ValueError
in our cast_spell
function:
# 📁 test_spells.py
import pytest
from spells import cast_spell
def test_valid_type():
with pytest.raises(ValueError):
cast_spell(62442)
Now, pytest
will make sure that passing 62442
as the input to cast_spell
function results in a ValueError
. And, that is pretty much it for this problem as well.
I know, this week was a bit confusing. I cannot give many hints this week, but you have a better place to go for hints: check50
itself for the original problems. The CS50 staff has already considered many edge cases, so, you do not even have to come up with your own examples. You are only implementing the same check50
tests that once checked your own code! It is quite satisfying to think about.
Next week, we are going to dive into the world of File I/O, and perhaps of some exciting libraries as well. Until then, happy coding!
]]>When I say delicious, I mean it because of our ability to have a plethora of quality choices when it comes to modules and third-party libraries in Python. Let alone third-parties, Python itself comes with a bunch of built-in modules that are pretty useful for the problems you are trying to solve. With the package manager as well, these days there is a library almost for anything, and I mean, anything.
Well, maybe you cannot actually import antigravity and fly —yet—, you can at least find a lot of helpful stuff with the Python Package Index. (And, by the way, antigravity
module is an easter egg. So you can actually import it).
Before we start, you can find the posts on previous problem sets here in the archive. And I have to give the usual disclaimer, I do not provide full solutions here, so if you are scrolling down trying to find the code block that will make you pass the tests, you are wasting your time, and that is a bit sad, my friend. It is a much stronger dopamine rush when it is you who investigate and find the solutions. I am here to talk about the problem sets and to be a guide along the way.
With all that talk, let's take a look at the problems we need to tackle this week.
In each post, I assume you have read the problem explanations already. If you did, this one is very easy, considering you already know how to import libraries and use them. And, explanation hints out the library you are going to use, namely emoji
. It provides some examples on how to use it. So in this case, you do not need to hunt for a specific usage, but it is literally in front of you. The main thing not to forget here, is to provide aliases as well.
As we will see throughout the other problems this week, clear documentation is a very important thing to have when it comes to using third-party libraries. Without the hints that the problem explanation gives, you have to look for the usage of the library pyfiglet
reading the project description. (By the way, I think the reason why CS50 put the link to an older version of the package on PyPI is that the project description is provided there. If you click on the newer version, there is no description given, but you can find here on GitHub).
We need to render an input text into a cool ASCII art version. Again, the explanation page gives a lot of hints, but let's see.
Before everything else, we need to do something only if we have zero or two command line arguments. Since the name of the program is also an argument, we have 1 command line argument by default. Such that:
$ python something.py # len(sys.argv) == 1
$ python something.py --someflag # len(sys.argv) == 2
In case of 1 argument —which is just the name of the file—, we need to make a random choice to get a font from all the fonts provided. Getting all the fonts is simple as we are given the hint:
f = Figlet()
fonts = f.getFonts()
Here, fonts
is a list that we can make a random choice out of. As to making that choice, I think I have given enough of a hint already. If you did not get it, remember that the documentation is always there to look things up.
After we have a random font and an input text as well, we just need to instantiate a Figlet, set its font, and render text (which you can just print
to see it in terminal):
f = Figlet()
f.setFont(font=random_font)
print(f.renderText(text))
This was for the random font if the user does not provide a font themselves. If they do, we need to do these exact steps with the given font instead of random_font
here (which you can get with something like sys.argv[2]
). But we do it only if the length of the arguments is 3 (remember the filename also counts), and sys.argv[1]
is actually either -f
or --font
; also as long as sys.argv[2]
is in the fonts
. You can just do it with one line of conditional, using and
and or
operators. I cannot give any more hints without giving outright the answer. After all that is done (if the command-line arguments do not fit into these two conditional branches), we just exit the program with sys.exit('Invalid argument')
.
That was actually fun. Now let's take a look at the next one.
I cannot believe I have not heard of this library before. You know, it is just things like these that make you fall in love with Python again, and again. Thank you CS50 for introducing me to it in this problem.
These kinds of problems — like joining all names and adding , and
for the last item — can be solved in many ways, including recursion. But here, with Python, it is just one line of code. Forget antigravity, this is another kind of superpower.
Borrowing from the ideas of last week's problem sets, we also need to keep getting input until the user hits control-d. This is an implication of using an infinite loop and handling EOFError
exception. And, that is really it. Considering you have looked at the project description for inflect
, everything you need is provided for you. Just remember to start the engine for the program like this:
import inflect
p = inflect.engine()
And, all you need to do is to join the names you have been collecting from the inputs. All the methods you can use from the inflect
library are in front of you. As always, learn to love the documentation.
The only thing we need to import for this problem is Python's built-in random
module. To be honest, I do not think the solution I came up with was an elegant one. Elegant or not, if we think about it, we need to continually get one input for the level, another one for the guess. When it comes to level, we should keep asking as long as it is not a positive integer; and when it comes to guess, we should keep asking as long as it is not the correct number. There are different ways to implement the solution, the certain thing is that we need to get a random integer between 1 and level — which can be solved easily with Python's random
module. One way to do it is to use a loop and try...except
block to ensure our input is of correct value. After we generate a random integer, we can use another loop to keep asking the user for a guess. If the guess matches that number generated, we simply print Just right!
and return or break out of the loop. For the cases that guess is less than or more than the number, we provide the appropriate outputs Too small!
or Too large!
, and keep asking. One thing to keep in mind is that, we need to compare the guess and that randomly generated number as long as the guess is a positive number, or just more than 0. And, that is it.
By the way, guessing game has a deep relationship with binary search algorithm, with which you can guess the answer correctly in log(n) time.
I believe, with this one, this is the time the curve becomes steeper, and the problem sets we will see from now on will become slightly heavier than what we have been seen so far. But, let's not get caught up in this, and take a look at this problem.
We have four specifications to consider. For the first one, we should get an input for level only if it is 1, 2, or 3. Say, we have a valid_inputs
tuple, we can check if the input value is in that collection:
valid_inputs = (1, 2, 3)
level = int(input('Level: ')) # Let's say it is 4
print(level in valid_inputs) # False
And what do we do with it? After getting the valid level, we need to generate an integer with level number of digits. Like this:
-> level = 1:
-> 0 <= integer <= 9
-> level = 2:
-> 10 <= integer <= 99
-> level = 3:
-> 100 <= integer <= 999
If the level is 1, the number we need to generate should be between 0 and 9 inclusive, and if the level is 2, the number should be between 10 and 99, and finally if the level is 3, the number should be between 100 and 999. All inclusive of course. Now, it is enticing to use conditionals, but there is always another approach. Let's try something different.
We want these levels to correspond with these ranges. One data structure that comes to mind for this kind of usage is a dictionary. Something like this, perhaps:
range_levels = {
1: (0, 9),
2: (10, 99),
3: (100, 999)
}
That is alright. When we print range_levels[1]
it should output (0, 9)
. But what if we want to pass these two values, 0 and 9, separately inside a function. A function that will help us get a random integer. Instead of giving you the answer, I am going to mention a cool thing you can do with Python — namely, unpacking operators. The idea is basically that you can use *
operator to unpack an iterable, and **
to unpack a key-value pair. Like this:
values = [0, 5, 2]
print(*values) # 0 5 2
# Prints 0, 2, 4 respectively
for i in range(*values):
print(i)
houses = {
'Gryffindor': 'courage',
'Ravenclaw': 'intelligence',
'Hufflepuff': 'loyalty',
'Slytherin': 'ambition'
}
people = {
'Harry Potter': 'Gryffindor',
'Hermione Granger': 'Gryffindor',
'Luna Lovegood': 'Ravenclaw'
}
print({**houses, **people}) # {'Gryffindor': 'courage', 'Ravenclaw': 'intelligence', 'Hufflepuff': 'loyalty', 'Slytherin': 'ambition', 'Harry Potter': 'Gryffindor', 'Hermione Granger': 'Gryffindor', 'Luna Lovegood': 'Ravenclaw'}
(As you can see, we cannot unpack a dictionary to a single variable, but rather we can use it to merge dictionaries!)
To be honest, you do not need to know about the unpacking feature for this problem. It is just a cool thing to use. And look at how we can use this feature inside the range
function for our for
loop. range
expects three arguments as start, end, and step. In that case, as we see in the example, for i in range(*values)
will be the same as for i in range(0, 5, 2)
. Amazing!
In the problem, we also need to print EEE
for wrong answers, and provide the user with 3 choices in total to get a right answer. But overall, we need to ask 10 questions. So, a double loop might be reasonable to use here. If the outer loop keeps track of 10 questions, the inner loop can keep track of 3 times of the same question asked. If the user gives the correct answer, we can break out of the inner loop. If the inner loop is completely done (which means asked the same question three times), we need to print the correct answer before moving on to another question. Accordingly, we also keep the score of the user. Simply increasing a score count variable when given the correct answer is sufficient. And, there is really nothing much to it if you correctly implement get_level
and generate_integer
, and are careful with the loops and when you break out of them. The unpacking examples I give here are simply fun to know, even if that is not necessary to use for this problem. Now, finally, on to the last one.
This one seems daunting at first, but it is really easy. If the number of command-line arguments is not 2, we need to exit the program with Missing command-line argument
. If we cannot convert the second argument into float, we exit with Command-line argument is not a number
. After that, using the requests
library, we get a response from the URL provided in the problem explanation. Since it is in JSON format, it will be reasonable to use just the right method for that. Here is the tricky part: how to convert the rate string, something like 37,769.6060 into float? Well, perhaps getting rid of the comma might help us. And, how to do that? If we think of removing a character as simply replacing it with nothing, we are on the right track. Afterwards, simply calculating the amount with number of bitcoins that are provided as a command-line argument and printing the formatted result is enough to finish this problem. And how to do that is literally given as a hint in the problem explanation.
Finally, this week on libraries has come to an end as well. We have seen somewhat heavier problems this time, and to be honest, I expect the problems in the upcoming weeks to become gradually harder. But it is actually something to be excited for. I hope you have learned lots of new things this week, and —if you are like me— have fallen in love with Python once again.
See you next week for the problem set of Unit Tests. Happy coding!
]]>You can also read the previous posts on Problem Set 0, Problem Set 1, and Problem Set 2.
This is a problem where we need to take a user input which we assume to be formatted as X/Y
, and display how much fuel left in the tank as a percentage. We are mostly dealing with integer division and exception handling here. Considering we have been getting input from the user for the past problems already, we know how to handle the input accordingly, say, for splitting a string with the format X/Y
. Remember that when we split a sentence into words, the default split character is the space character. So if we want to split a string like 1+2
into two numbers, using '1+2'.split('+')
will give a list containing 1
, and 2
.
Before we do any error checking though, we need to get the result as a percentage. And, before getting the result, it might be a better idea to check if the value of x
is not larger than y
; then calculating the result accordingly. You must be mostly familiar with converting decimals to percentages from elementary school math. In this case, just multiplying the result of the given fraction with 100 is enough. Then, as the problem explanation says, if the overall result is more than 99, we print F
to indicate the fuel tank is full; if it is less than 1, we print E
to indicate it is empty. Otherwise, we print the percentage, which is done easier with an f-string. We also need to print it with zero decimal places — remember putting something like :.1f
after a float type formats it to have 1 place after the decimal, but in this case, we want zero decimal places.
The main thing to consider is handling the exceptions, of course, wrapping the code in a try...except
block. As we also need to keep asking the user if there is a ValueError
, or a ZeroDivisionError
, what we need to do is similar to the example given in this week's lecture — wrapping the exception handling inside an infinite loop which we can break out of with returning the formatted percentage result. And, that is really all there is to it, let's look at the next one.
Here, we are already given the menu entrées as a dictionary, the only thing for us to do is to get the user input for an item in the menu, and accumulate the total result of each item that they put in. Of course, we also need to print the total result. Similar to the examples in this week's lecture and the Fuel Gauge problem, we can use an infinite loop to continue getting input from the user. We also need to convert the input into titlecase for it to match the keys in our given dictionary as well. In case of an invalid item which will result in a KeyError
, say Burger
, we can ignore it (simply, pass) and continue asking the user for an item. If the user hits control-d (a common way to stop the inputs) which will result in an EOFError
(end-of-file condition), it is time to stop the program, we can do that by returning from the function after printing a newline.
Well, that was easy. On to the next problem.
This problem is also similar to the ones we did before, and it is very easy to implement if you like using dictionaries. Just like the previous two problems, we need to keep getting input — which we did before by putting the try...except
block inside an infinite loop, and returning (if we use it inside a function) at the right time to break out of it. We can use a dictionary to add the items and increment each of the item's value if it is already in the dictionary. Actually, let's see something similar in action. Let's say we want to get the names of spells that Harry Potter has cast in a day, as well as how many times they are used. Perhaps the most intuitive way to do it is similar to this one:
spells = {}
while True:
try:
spell = input()
if spell in spells:
spells[spell] += 1
else:
spells[spell] = 0
except EOFError:
break
So, if our input is something like this one:
Accio
Accio
Lumos
Expelliarmus
Expelliarmus
Expelliarmus
Printing each value and key in our spells
dictionary will give this output:
2 Accio
1 Lumos
3 Expelliarmus
However, we can do a one-liner instead of the one we used with an if...else
condition. We can use this version instead:
spells = {}
while True:
try:
spell = input()
spells[spell] = spells.get(spell, 0) + 1
except EOFError:
break
What the get
function does here is literally getting the spell
from the spells
dictionary, and providing a default value of 0
if it is not in the dictionary. We add 1
to the whatever value that is returned by the get
function to increment it. This will give the same output as above if we print each value and key of our dictionary.
Of course, as in the problem demo, printing the value and keys is only done after the user hits control-d — in other words, after our program has an EOFError
. Checking the documentation as we always do before, there are many useful methods to iterate through the items of a dictionary. And, if we want our output to be sorted, well, we can literally check the documentation. Since I cannot give any more hints without giving out the solution itself, it is time to move on to the next problem.
In the last problem of this week, we need to get a user input for a date in the month-day-year format, and output it in the year-month-day format. The input we are given can look like 9/8/1636
, or September 8, 1636
(yes, an Easter egg: the year Harvard University was founded). Here, we need to do a bit of splitting and some string formatting. We can use the same idea of an infinite loop like before to keep getting the user input, only returning the result when it is appropriate. Since we have two kinds of inputs to handle — the one with forward slashes (/
), and the other with a comma and a space (,
) —, we can use two branches for a conditional. Hints in the problem description are quite helpful on splitting a string, which you must be pretty familiar with already. We are also given a list of months in the problem explanation page, and in order to get the value of an inputted month, we can add 1 to the index of that month in that list. Lastly, we can print the formatted result and break — or, if using a function return with the string of our result. But, we need to format the day and month to be two digits, and depending on how you implement it, you can format an int to have two leading zeroes with f'{n:02}'
, or a string with a very handy zfill
function. Taken from the documentation, what it does is this:
>>> "42".zfill(5)
'00042'
It is self-explanatory indeed. The main thing we always need to do is error-checking, in this case the problem description does not provide a specific exception to handle and that is mostly because you can implement the solution in many different ways. But, one thing we need to make sure of is that the month and day should be within bounds, say if a user gives an input 23
in place of the month, we should prompt them again. We can make sure of this by simply returning the result string only when this conditional is met. After that, the exceptions that you need to deal with actually depends on how your code looks like, but, it mostly makes sense that we might have a ValueError
, perhaps an IndexError
for dealing with the months list. Since it is you as the programmer who will decide what exception to handle, checking the documentation is the first thing to do. And here, checking out the built-in exceptions is the way to go.
We are at the end of the third week, and next week we are going to finish half the course already! I cannot wait to see what problems we are going to solve for the next week on Libraries, and hope you too as well. Until then, happy coding!
]]>In this problem, we need to turn a string given in camel case likeThis
into a snake case like_this
which is recommended in Python for variable names.
Well, since this week is on loops, we can loop through each character one by one. You can think of concatenating each character to a result string, which you can initialize as empty at first. When it comes to an uppercase character —you can get help from the documentation to check if a character is upper—, instead of merely concatenating the character to our result string, you can add an underscore and that character in lowercase.
One thing that Python makes it easier to write one-liners, is list comprehensions. Let's say we want to work with snake case, and turn it into a nice and readable title string. An easy way to do it would be something like this:
snake_case_str = 'a_very_important_heading'
result = snake_case_str.replace('_', ' ').title()
print(result) # A Very Important Heading
Replacing the underscores with spaces, can also be done in a different way:
snake_case_str = 'a_very_important_heading'
result = ''.join([' ' if char == '_' else char for char in snake_case_str]).title()
print(result) # A Very Important Heading
It is true that it looks uglier, but we can see how list comprehensions work. It is literally the same as this:
snake_case_str = 'a_very_important_heading'
result = []
for char in snake_case_str:
if char == '_':
result.append(' ')
else:
result.append(char)
result = ''.join(result).title()
print(result) # A Very Important Heading
The code explains itself, in the list comprehension, we simply loop over the given string, if the character is an underscore we append a space character to our result
list, else we append the character itself. In the end, we use join
with an empty string to turn the list into a readable string and use title
to make it in title case.
You can use a similar approach if you want to solve this one with a list comprehension. And that is all there is to it.
This one is reminiscent of the Cash problem in CS50's own Introduction to Computer Science, only perhaps a simplified version of it. What to do is simple: we, as a machine, will only accept 25 or 10 or 5 cents for a bottle of Coke we sell for 50 cents.
A while
loop might sound more reasonable to use on this one, we can simply check if our amount —which is 50— is still more than 0, in this case we can keep asking the user for input and decreasing from the amount accordingly. One important thing to remember is to check for error handling; now that we are working with integers, it is better to remember that input
returns a string, and we need to do type casting. Also, we need to check that the user should only insert either 25, or 10, or 5 cents. If that is the case, we can then decrease the given value from our amount. If they give in total 50 cents, our job is done, and we do not owe them any change. But what if the user gives more than 50 cents? In this case, you can see that if we keep decreasing the amount, we will reach a negative number. Well, since the value itself would still be the same, the absolute value of it would enough to solve the problem.
For example, if the user enters 25
, then 10
, then 25
again — we know that our change due would be -10
if we keep decreasing the value. We need to output Change owed: 10
, in this case, we can simply get the absolute value and be done with our work. And how to get the absolute value? Once again, when in doubt, check the documentation.
The title of this problem comes from a tweet from Jack Dorsey back in 2006. What we need to do is get an input string and drop any characters that are vowels. Instead of checking for each vowel though, using a vowels list, and checking if the character is in that list might result in a more elegant solution. Also, list comprehensions would be nice to use as well. Actually, let's see this elegance.
Let's say that we do not like spaces and commas. I know it is nonsense, but, for the sake of this example, bear with it for a minute. So, we do not want to see any spaces or any commas in our string, but we are okay with everything else. And, who cares if our string looks squeezed anyway? Let's see what we can do:
hated_ones = [' ', ',']
input_str = 'Eye of rabbit, harp string hum, turn this water into rum'
cleaned = ['' if char in hated_ones else char for char in input_str]
result = ''.join(cleaned)
print(result) # Eyeofrabbitharpstringhumturnthiswaterintorum
Yes, I know. Sorry, had to give that reference.
Now that we have seen how to solve a similar looking problem, this one is quite easy as well. Next one.
This problem requires us to check some conditionals if the given string is valid to be a vanity plate.
This one is slightly hard to explain without giving any spoilers. One thing I can tell you is that list comprehensions are a huge saver and makes it easier to solve the problem in a more clear way. Say, you want to see how many digits a string has. Easy:
s = 'CS50'
number_of_digits = len([char for char in s if char.isdigit()])
print(number_of_digits) # 2
Of course, as David Malan would say, I only know the existence of isdigit
because I checked the documentation before.
Another gem from the documentation for this problem specifically, is the checking for all the punctuation characters. While we still have not talked about libraries yet —it is coming in week 4—, if you want to go ahead, know that Python has a built-in module specifically for strings which makes life a whole lot easier.
Now, we have four specific conditions to check. The length of the input string must be within the bounds of 2 and 6 — both included. That is easy as we have done that before with a one-liner in "Meal Time" problem last week. Another condition is to check if the first two characters are alphabetical. It speaks of itself, if you have checked the documentation. Another thing to check is to see if the string is clear of spaces and punctuations. I believe, list comprehensions work pretty well here too. We have actually almost done the same thing in the last problem "Just setting up my twttr". And lastly, we need to check if the string ends with digits if it contains any — so that we know that the digits are not in the middle or the beginning.
Without giving away too much, one way to think about this would be something like this: We can get the length of the digits in our string, and check if the rest of the string from that index on is only digits. We have done that before, but it might be better understood with an example:
s = 'CS50'
number_of_digits = len([char for char in s if char.isdigit()])
print(s[-number_of_digits:]) # 50
Using negative indexing, we can traverse the string backwards from that index which would be the length of the digit characters in this case.
Checking these four cases is all we need to do. I know I have talked about list comprehensions a lot, but they go very well with loops. If that is still not clear, you can always use the normal expanded version as well, as in the Camel Case example. And, to be honest, more complicated loops makes list comprehensions much more complex. Being reasonable is way to go.
And, here is the perhaps easiest(?) problem this week, in terms of implementing logic.
A dictionary is the most obvious choice to use in this case. All we need to do is to create a dictionary — fruit names as keys, and their calories as values using this poster, and return calories for a given fruit. The lecture video already shows how to do that, so that is it. Also, if we want to return literally nothing instead of None
, we can return an empty string. And, this is the end of this problem as well as the end of the Problem Set 2.
Next week's problem set is going to be on Exceptions, and it is really exciting to wait for what kinds of problems we will be solving.
See you next week, and happy coding.
In this problem, we only need to check if the user of our program knows the answer to the Great Question of Life, the Universe, and Everything. Simple as that.
One thing that the last problem set made clear was that checking the documentation —even if it is sometimes hard to find what you are looking for!— is vital to solve the problems. It is still something to keep in mind in this problem set as well.
Now that it is well known that the answer to that great question is 42, we need to make sure the user gives the correct answer — whether they write it literally as 42
or forty-two
, (or Forty two
, etc.).
The main thing to do here is to check for two kinds of way to write it: as 42
, or a string with letters. It is clear that we will use the input
function to get input from the user. Another thing, if you remember from the last problem set, is that the input
returns a string even if the user enters a number.
A good habit to have is cleaning user inputs, as they are supposed to be messy, or we should assume them to be so. For example, to make sure that the input is not in sarcasm case or yelling case, it is always a good idea to force the string to be in lowercase. That is done easily with lower
method. Also, we can strip the input string to make sure there are no unnecessary spaces around.
Let's say we want our answer with formatting like this: the answer
, but the user typed in this: ThE-ansWer
. First step to clear it up would be to make it all lowercase and get rid of the space at the end:
user_input = user_input.lower().strip()
print(user_input) # the-answer
And, it is the-answer
now. If we want to get rid of the hyphen (-
) as well, we can split the string with that hyphen, and join the resulting list into a string again. Let's continue step by step:
user_input = user_input.split('-')
print(user_input) # ['the', 'answer']
user_input = ' '.join(user_input)
print(user_input) # the answer
It all depends on what you want to do, and how you would like to see the answer. Because we can accept multiple types of formatting in this problem as the answer, it makes sense to use all these methods on the user input. And the last thing to do afterwards is to check if the answer is correct; if so, returning Yes
; else, returning No
.
In this problem, we check the greeting. If it has any sign of hello
in it, we do not give any money, therefore output $0
. If the greeting starts with an h
though, we can output $20
. And these are our only constraints. To everything else we are quite generous, and output $100
.
Actually, Python comes with built-in methods exactly and suitably named to implement our conditions. Again, it is the documentation we should consult.
One thing that might be obvious or not be obvious to beginners, is that strings are also a sequence, much like lists or tuples. What that means is that they are linear and consist of ordered items. You already might have realized it when you use indexing on a str
type to access a character, like you use indexing to get an item from a list. The implication is that, sometimes lists —or, any other sequence— behaves similarly to str
type, and some operations are applicable to both of them. in
for example, is one operator you can use with lists. Enough for a clue, I guess. As to checking if a string starts with some character, that is also self-explanatory. You might also be interested in a custom implementation of it.
This one is probably my favorite so far. It incorporates formatting the MIME types (or, media types) that are in an HTTP header, which determines how they are displayed on the web. In this problem, we need to get an input for a file name, and return the appropriate MIME type for that file, depending on what extension it has, if it has any.
There are many ways one can solve this problem. The main resource this time —besides the Python documentation, of course—, is the list of common MIME types from MDN. To start off, you can see that we have two default types: text/plain
for textual files (with the extension of txt
), and application/octet-stream
for everything else. For all the other types to consider in this problem, instead of hard-coding a conditional for each type and extension, we can create a mapping similar to the one below:
extensions_mapping = {
'image': ['gif', 'jpg', 'jpeg', 'png'],
'application': ['pdf', 'zip'],
}
Of course, first we need to get the extension of the filename. This could be done in different ways, we can slice the string from the index of where the dot is, or split it to get the part after the dot. We also need to clear the input string to handle messy inputs. As we have done that before in Deep Thought, we can lower the string for consistency, strip it to get rid of unnecessary spacing. If we use a mapping, the only thing that is left to do would be to check if the extension is in the values of a type (which would be our key), if so, simply printing the key and the value with the format of [mime_type]/[extension]
. For example, if the filename is cat.png
, our program should print image/png
.
There is one catch, though. For the jpg
extension, the MIME type is image/jpeg
, instead of image/jpg
. We can handle that with a simple conditional as well:
if extension == 'jpg':
return 'image/jpeg'
(Of course, if you do not use a function, you can simply print the string instead of using a return statement.)
And, that is all that necessary to solve the problem. On to the next one.
Here in this problem, the conditionals if/elif/else really shine. We only need to do simple arithmetic with two operands. We also need to format our output string as a float, having one decimal place. A hint of splitting the input string is already given in the explanation. If the user types in 5 + 4
, the result of our split would be ['5', '+', '4']
(remember, the input always returns a string). The only thing that is left to do is to check the given operator for each of the four operators (*
, /
, +
, -
) and do the arithmetic. The float formatting works like below:
x = 6.5418
print(f'{x:.2f}') # 6.54
Instead of 2, we need to do format it as having one place. This, and a chain of conditions is all that there is to it.
This one was also quite enjoyable, even though at first glance it seems to be more complicated than others before. The first thing to think about is that we only have three options to consider: a time range of 7:00 - 8:00
which indicates breakfast time
, 12:00 - 13:00
which means lunch time
, and finally, 18:00 - 19:00
which is dinner time
. When we ask the time to user, the input might be in the format of #:##
or ##:##
. The program structure is also given to us, with a main
function and a convert
function, which we can call inside main
. Let's think about how we can do the converting.
For a given time with the formatting #:##
or ##:##
, we need to get the hour and minutes — which indicates that we need to split our string, so that the first item of the result of that split would be the hour, and the second one would be the minutes.
Since the hour can be written as 07
, we can check if it starts with 0, so that we can slice it appropriately.
Say, the user input is 07:30
. What does that mean? It is simply 7 hours and a half. Since an hour is 60 minutes, we can denote that part as a division. So, 30 / 60
in this case, would be 0.5
. Then, all we need to do is to add the hour and minutes, converting any of them to int
if necessary. But, if the input is 07:32
, you can imagine doing the division 32 / 60
would result in a not-so-nice-looking output, namely that of 0.5333333333333333
. One thing we can do in that case, is to use the round
function. Since we are checking clear-cut boundaries for meal hours, we can round the whole operation, so that it would be like:
round(hour + minutes / 60)
(We do not need to put parentheses around minutes / 60
since the order of operations takes care of that.)
After that, the rest is easy. We need to check for each meal time to see if the given time is between a certain meal time. Here, Python makes it easy to use comparison operators in one line, and our code is more elegant as a result. An example:
time_total = 7.5
print(7 <= time_total <= 8) # True
We do not need to use an and
operator in that case. It is pretty neat.
The only thing left to do is to return the appropriate meal time for each condition. And, that is all. For the challenge part handling time inputs with a.m.
and p.m.
, you would like to do another split on the minute part after you have already split hours and minutes. You can check for p.m.
, and if it is so, you can simply add 12 to the hour variable. Considering you have already split the time to hour and minute parts, dealing with p.m.
would be similar to this:
if ' ' in minute:
abbr = minute.split(' ')[1]
minute = int(minute.split(' ')[0])
if abbr == 'p.m.':
hour = int(hour) + 12
print(hour, minute, after) # 19 30 p.m.
For a.m.
, you do not have to do additional arithmetic, just continue as before.
And, that is all for this week's problem set. Perhaps a bit more slightly challenging than the last week, but entertaining nonetheless. We will see what will the next week's problems be like.
]]>CS50 is now not a single course on Introduction to Computer Programming, but has turned into a bigger ecosystem for different courses with various flavors, be it Web Programming, Artificial Intelligence, or, Mobile App Development.
Its latest, Introduction to Programming with Python, does not exactly focus on theoretical computer science concepts, but is a more general programming course using Python. I love Python, and I was really excited to see the new problems that CS50 would provide for us to solve in this new course.
Before I start, here is a disclaimer: I am not going to provide full solutions to Problem Sets. See academic honesty.
I am planning to write about how to start thinking about a given problem, maybe as a kind of guidance, and how one might go about solving it. I assume you already read the problem set explanations, as I might allude to them. Generally, the problems start easy and perhaps more friendly, then the curve becomes steeper. It is a nice challenge, though, that is how you realize that you are actually learning.
The first problem seems pretty straightforward. Our given input just has to be "quiet" instead of being in "yelling case". What that means is, if 'THIS IS YELLING'
, then 'this is not yelling'
. And, here is the importance of reading the documentation of the tool that you are using, in this case, Python. Because we work with str
data types in the problem, we simply have to look up if Python comes with built-in str
methods — and, it does. A lot of them. For example, it has capitalize()
, lower()
, upper()
among many others. Let's see how these would work:
s = 'This is CS50.'
print(s.capitalize()) # This is CS50.
print(s.lower()) # this is cs50.
print(s.upper()) # THIS IS CS50.
With that example, you might already see how to totally "quiet down" a given string. As for the input()
, again, the documentation helps. In the Python interpreter, for example, typing help(input)
enlightens you on how to use it.
This problem wants us to simulate a slower playback, replacing spaces with ...
(three dots). What we want to do is to split a given input string into words, and to join them back again with three dots. Or, we can simply replace the space characters with three dots. As with all kinds of problems, there are different ways for a solution. Here, the documentation again is important. We are working with str
types again, there are methods named for exactly what we want to do in this case, no matter which method you choose to implement. Simply seek, and you will find them.
With this one, we need to replace any occurrence of :)
with the emoji 🙂
and :(
with 🙁. One of the most important things to realize here is that the emojis are also str
type in this case. What we want to do is, well, literally replace characters of a given string with other characters. Again, consulting the documentation helps with what we exactly want to do.
This problem set also emphasizes the concept of modularity, splitting code into functions. For example, instead of doing everything on a main()
function like this:
def main():
incantation = 'Expecto Patronum'
print(f'{incantation.upper()}!')
main() # EXPECTO PATRONUM!
There is a slightly better way to do it:
def say_incantation(incantation):
return f'{incantation.upper()}!'
def main():
incantation = 'Expecto Patronum'
print(say_incantation(incantation))
main() # EXPECTO PATRONUM!
And similarly in this problem, we have to split the implementation using a convert
function to convert emoticons to emojis, and a main
function to call convert
inside of it.
This problem set uses Einstein's mass-energy equivalence formula E = mc^2
. For a given mass, we need to output the energy in Joules.c
in the formula, is the constant speed of light that is measured approximately as 300000000 (meters per second). The main thing to do is to plug in the variables to their equivalents in the formula, but one thing to remember is how the input
function works. Because, in this problem we do not mostly do operations with str
types this time, but rather with the int
data type. So, for any kind of string in our program, type casting is a helpful thing to do as we only want integers. For example:
# This would not work
year = '2022'
print(year - 22) # TypeError
# But this would work
year = int('2022')
print(year - 22) # 2000
Perhaps, why the int
type is great and not a slight headache like float
s can be appreciated more in later problem sets.
The last problem is mostly done, only the remaining two functions are waiting for us to be implemented.
What dollars_to_float
and percents_to_float
expect as inputs are similar in terms of formatting. The first one expects an input like $50.00
and the second one needs an input like 15%
, of course, both being strings. Just like in the Einstein problem, type casting is a useful thing to do in this problem. But, before that, we need to get rid of $
(the dollar sign) and %
(the percent sign). Realize that what we need to do in the first case is to remove a prefix (leading characters from the left), and in the second case, to remove a suffix (ending characters from the right). There are more than one way to do these things, we can even slice the string ourselves instead of using any built-in method. And, there is really not much to the solution except these. We do not need to think about edge cases yet, as the explanation says that the input values are assumed to be given in expected formats.
I do not want to give away too much, because the crux of these problems is that you should be the investigator. And, I guess the moral of the story for this problem set is a phrase that might sound annoying to some, but it is what it is: read the documentation. Or, simply, look for the thing that you need and learn to find it in the documentation. For the Problem Set 0, looking up built-in str
methods, and some type casting would suffice.
We will see what the next problem set will bring.
Until then, happy coding. 💜
]]>That was what I used to think as well, before I eventually started to like them. You could sense the power of regular expressions, and they seemed to be ubiquitous — they show up in all kinds of places, and you know that there is no escaping from a regular expression (or, a regex). And, one of the things you would keep hearing on your encounters is the concept of a greedy versus lazy matching. Just when you think you finally grasp them somehow, they might seem to be those mysterious concepts once again a week later. So, let's see what is happening here.
Let's say we have a string: 'One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.'
. (If you're not familiar with the koan — not the wind, not the flag; mind is moving.)
What we want to do is to match each of the quoted sentences: "The flag is moving."
and "The wind is moving."
. Using JavaScript, the first thing comes to mind is to perhaps use something like this regex to get a match:
let regex = /".+"/g;
However, if you try this, it gets a match for everything after the first quote:
let s = 'One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.';
let regex = /".+"/g;
s.match(regex);
// -> [ '"The flag is moving." The other said: "The wind is moving."' ]
This is not what we want. But, why that happens is because of the greedy algorithm that our regex uses. What it does is basically going to the next position if there is no match for a given position. Let's see what that means.
The first character of the pattern ".+"
to match is "
.
The regex engine starts with the 0th index of the string, which is O
, therefore gets no match. If there is no match, remember, it goes to the next position. It is n
, still no match. It continues advancing until it finds "
at index 10:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Now that the regex engine has found it, it tries to match the next part of the pattern, which is the dot (.
) character. The dot is a special quantifier that will match "any character except a newline." The character after the first quote on our string was T
, so, it matches. Because of the +
quantifier after the dot, it will repeat as long as a character matches the dot — which will be all the rest of the characters in the string in this case:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Next, the rest of the pattern ("
at the end) needs to match as well. However, we are at the end of the string already. So, what happens now is, that the engine backtracks. It goes back one character, and will try to match from that character onwards:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Since the character after the match is not a quote ("
), it does not match. Therefore, it backtracks again:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Now, e
is not "
as well. The engine backtracks again and again until it finds the "
:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Our regex engine has found it, and is happy now.
Though, we have a g
flag for global match, and the search would continue searching for the pattern again after the first match. But, the rest of the string (They argued but could not agree.
) has no other quotes ("
) — therefore, we don't get any more results.
Now that we know that was not our intention to get this result, let's take a look at lazy matching.
We can do the lazy match with the quantifier ?
after the +
. So, our new regex would be:
let regex = /".+?"/g;
It means that we want to repeat as little as possible. Now it works as intended:
let s = 'One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.';
let regex = /".+?"/g;
s.match(regex);
// -> [ '"The flag is moving."', '"The wind is moving."' ]
The first thing it does is to look for the quote ("
) to match the first character in the pattern ".+?"
. Similar to before, it tries until it finds it at index 10:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Next, it matches the dot:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Now, things are different from greedy search. Instead of matching the dot continually, the engine tries matching the rest of the pattern now. In this case, instead of trying to match h
for the dot (.
) first, it checks if the h
character matches the quote ("
). Since it does not, the engine matches it with the dot and continue:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Now, it checks e
for the quote ("
). No match again. It continues until finds the quote:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
We've found the first match! Because of the global flag, it will start again from the end of the first match, and find another result for the pattern again eventually:
One said: "The flag is moving." The other said: "The wind is moving." They argued but could not agree.
Finally, we've got the result we wanted and are now happy — thanks to understanding the difference between greedy and lazy matching.
Note: If you're not satisfied with the koan example and still confused, here is a shorter and simpler explanation: https://stackoverflow.com/a/2301298.
]]>One of those things that are beneath the surface of JavaScript is the concept of Lexical Environment. If you're familiar with closures, it is something that helps you internalize what is really going on.
We can think of the Lexical Environment as an object that every function, code block, even the whole script itself, has. It not only contains the local variables and their values, but also has a reference to an outer lexical environment.
When you create a variable, let's say, something like this:
let book = 'Harry Potter and the Prisoner of Azkaban';
Think of the book
as a property of the Lexical Environment, with the value 'Harry Potter and the Prisoner of Azkaban'
. Since it is inside the global Lexical Environment now, the outer reference is null
. Maybe another way to think about this is that the global Lexical Environment is the environment of the whole script, and it has not any reference to anything outer than itself.
How the global Lexical Environment behaves is different for variables and declared functions. Let's try to understand what we mean by that.
The global Lexical Environment is filled with all the variables, but initially, the variables are "uninitialized" — which means that the engine knows about them, but they cannot be referenced until they've been declared. So, let's say this is our script for now:
let book; // (1)
book = 'Harry Potter and the Prisoner of Azkaban'; // (2)
book = 'Harry Potter and the Goblet of Fire'; // (3)
What happens when the execution starts, is that the (global) Lexical Environment knows about the variable book
, but it is uninitialized.
On line (1), book
is now undefined
.
On line (2), book
is assigned a value, 'Harry Potter and the Prisoner of Azkaban'
.
On (3), the value of book
is changed to 'Harry Potter and the Goblet of Fire'
.
However, we said that the case is different for function declarations. It also shines light on the "hoisting" aspect of JavaScript. Let's take a look at it.
When a function is declared (we're not using a function expression), it is instantly initialized so that it is ready to be used. That's why it does not matter if we declare the function after we use them — that's why something like this works:
console.log(add(30, 3)); // 33
function add(num, num2) {
return num + num2;
}
When we say that JavaScript "hoists" a function, what actually happens is this: declared functions are instantly initialized when the Lexical Environment is created. But, let's look at this now:
let broomstick = 'Firebolt';
function summonItem(spell) {
return `${spell} ${broomstick}!`;
}
console.log(summonItem('Accio')); // Accio Firebolt!
When the execution of the above code starts, the Lexical Environment knows both broomstick
and summonItem
; however, broomstick
is uninitialized at this stage while summonItem
is initialized and ready to use.
To visualize, think of the Lexical Environment as an object with properties like below:
{
broomstick: <uninitialized>,
summonItem: function
}
Also, of course, its outer
references null
because this is the global Lexical Environment.
When a function starts running, a new Lexical Environment is created for it. So, when we call summonItem
(inside the console.log
), the Lexical Environment of that call only stores spell
having the value 'Accio'
. And, it also has its outer
referencing the global Lexical Environment itself, which stores broomstick
and summonItem
, with its own outer
referencing null
. The Lexical Environment of our function call (summonItem('Accio')
)—the Inner Lexical Environment— references the outer one, the global Lexical Environment. That is, spell
is found locally, but to reach broomstick
, the outer
reference is followed, and it is found there.
So, it is true to say that:
When the code wants to access a variable – the inner Lexical Environment is searched first, then the outer one, then the more outer one and so on until the global one.
Now, it's time to catch our breath.
It may be a lot at first, but, that's learning 💁🏻.
This time, consider this one:
function powersOfTwo() {
let start = 2;
let count = 0;
return function() {
return start ** count++;
}
}
let twoToThePower = powersOfTwo();
console.log(twoToThePower()); // 1 (2 ** 0)
console.log(twoToThePower()); // 2 (2 ** 1)
console.log(twoToThePower()); // 4 (2 ** 2)
console.log(twoToThePower()); // 8 (2 ** 3)
console.log(twoToThePower()); // 16 (2 ** 4)
console.log(twoToThePower()); // 32 (2 ** 5)
When the powersOfTwo
is called, a Lexical Environment is created for it. It now has start
and count
, and outer
referencing the global Lexical Environment which has powersOfTwo
and twoToThePower
, as well as its own outer
referencing null
.
When we call twoToThePower
inside console.log
, what happens is — you guessed it, a new Lexical Environment is created. Since start
and count
are not inside this local Lexical Environment, it follows the outer
reference (which is the Lexical Environment of powersOfTwo
). When it updates the count
, it is updated inside the Lexical Environment of powersOfTwo
. Another way to put it:
A variable is updated in the Lexical Environment where it lives.
Again, start
and count
lives inside the Lexical Environment of powersOfTwo
. When we update count
, it is updated there, not inside the Lexical Environment of the returned function which we bind to twoToThePower
.
In the first call of twoToThePower
, start
is 2 and count
is 0. In the second call, start
is still 2, but count
is updated and is now 1. And, it keeps being updated inside the Lexical Environment where it lives (powersOfTwo
) as long as we call twoToThePower
.
So, twoToThePower
has the "power" to access and modify the variables inside of a Lexical Environment that its outer
references.
This is what closures are about, a function that has access to its outer
scope.
Here comes the enlightenment: Then, are not all functions closures in JavaScript?
I guess the answer is mostly yes, with an exception.
If you remember the summonItem
example, it also accesses a variable (broomstick
) from its outer
scope, so based on the definition, we can say that it is theoretically a closure. Though, it might be better if we don't confuse ourselves a lot because when you look up closures, most basic examples you see would be similar in spirit to powersOfTwo
. It is nevertheless a nice thing to internalize, as it was our goal all along — to see how things work beneath the surface. It is an abstract surface of course, but good to dive into.
startsWith()
& endsWith()
in JavaScript, and startswith()
& endswith()
in Python. However, not to reinvent the wheel, but let's say we want to implement them our own way. Because, why not?
One thing that might be helpful, before we start off, is the concept of negative indexing. For example, not in all languages, but the last character of a string can be accessed with the index number -1. The second to last character will be -2, and so on. Python allows the use of negative indexes for strings (and for most iterables), and JavaScript's slice
method also allows negative indexing. These will come in handy.
Python example:
name = 'David'
name[-1] # d
name[-2] # i
We cannot access the character directly with negative indexes in JavaScript as it will return undefined
, but we can use slice
:
let name = 'David';
name[-1] // undefined
name.slice(-1) // d
name.slice(-2) // id
Now, let's check if a string ends with another given string. Now that we know that negative indexes start from the end of the string, we can try something like this:
Python example:
name = 'David'
target = 'vid'
name[-len(target):] == target # True
JavaScript example:
let name = 'David';
let target = 'vid';
name.slice(-target.length) === target // true
We can take a look at what we did one by one, so that it's more clear. The first thing we see is that we get target
's length, which will be in our example's case, 3 (the length of 'vid'
). And, with negative indexing, we started from -3rd index of our original string and just compared the two. name.slice(-target.length)
will start from the -3rd index of name
up to the end of the string, which will be 'vid'
and voilà! — they're the same.
It is a nice, one-liner way to do it. Now let's try our hand at startsWith
, which will be easier than this one.
We'll use the same components, slicing and using the target string's length. Let's do it.
Python example:
name = 'David'
target = 'Dav'
name[:len(target)] == target # True
JavaScript example:
let name = 'David';
let target = 'Dav';
name.slice(0, target.length) === target // true
Slicing the original string from the start to the length of the target string, gives us the string with the same length of target
. So, name.slice(0, target.length)
in this case, starts from the start of the string and goes up to the 3rd index (length of 'Dav'
). We only check if the two strings are the same, and that's it.
We have written great one-liners, and just implemented our own way to do startsWith
and endsWith
. Here are the functions (let's write the function names in snake case so as not to confuse ourselves with the built-in ones):
In Python:
def starts_with(string, target):
return string[:len(target)] == target
def ends_with(string, target):
return string[-len(target)] == target
In JavaScript:
function starts_with(string, target) {
return string.slice(0, target.length) === target;
}
function ends_with(string, target) {
return string.slice(-target.length) === target;
}
These are fine, but what about implementing the same logic another way? Maybe, with another language? One that will help us think in lower-level.
My initial thought was that it would be something like this in C (spoiler: it was naive.):
#include <stdio.h>
#include <stdbool.h>
#include <string.h>
bool starts_with(char *string, char *target) {
int target_length = strlen(target);
for (int i = 0; i < target_length; i++) {
if (string[i] != target[i]) {
return false;
}
}
return true;
}
bool ends_with(char *string, char *target) {
int target_length = strlen(target);
int starting_index = strlen(string) - target_length;
for (int i = 0; i < target_length; i++) {
if (string[starting_index + i] != target[i]) {
return false;
}
}
return true;
}
However, I was corrected that this is indeed problematic.
Here is the simpler and correct versions of starts_with
and ends_with
:
bool starts_with(char const *string, char const *target) {
for ( ; *target != '\0' && *target == *string; ++target, ++string );
return *target == '\0';
}
bool ends_with(char const *string, char const *target) {
char const *const t0 = target;
for ( ; *target != '\0'; ++string, ++target ) {
if ( *string == '\0' ) return false;
}
for ( ; *string != '\0'; ++string );
size_t const t_len = (size_t)(target - t0);
return strcmp( string - t_len, t0 ) == 0;
}
What we do in starts_with
is the same idea, only that we compare each character of our original string and the target string until target
ends; also handling the case if target
is longer than string
— in which case it would return false.
In ends_with
, we first check to see if target
is longer than string
(in that case, we would immediately return false). Then, using the target
's length (t_len
), we compare the string
's end of t_len
characters with our target string (t0
).
Here's the whole code:
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
// Function prototypes
bool starts_with(char const *string, char const *target);
bool ends_with( char const *string, char const *target );
int main(void) {
char const *str = "David";
char const *target_end = "vid";
char const *target_start = "D";
// prints "true"
printf("%s\n", starts_with(str, target_start) ? "true" : "false");
// prints "true"
printf("%s\n", ends_with(str, target_end) ? "true" : "false");
}
bool starts_with(char const *string, char const *target) {
for ( ; *target != '\0' && *target == *string; ++target, ++string );
return *target == '\0';
}
bool ends_with( char const *string, char const *target ) {
char const *const t0 = target;
for ( ; *target != '\0'; ++string, ++target ) {
if ( *string == '\0' ) return false;
}
for ( ; *string != '\0'; ++string );
size_t const t_len = (size_t)(target - t0);
return strcmp( string - t_len, t0 ) == 0;
}
And now, time for some introspection.
Did we reinvent the wheel? Maybe.
Was it a problem that already been solved? That's what it was.
But, have we had some fun along the way? Well, depends on you, but I certainly did.
]]>==
.
How wrong I was.
What I mean is, when you do something like this:
let x = [1, 3];
let y = [1, 3];
You expect something like x == y
to be true (remember we're not using the strict equality yet as we know that they are not the same object —yes, arrays are objects—, we're only looking if they are equal value-wise). For example, in Python, while they are not the same object in memory, these two lists would equal true:
x = [1, 3]
y = [1, 3]
x == y # True
x is y # (or `id(x) == id(y)`) False
Using ===
in JavaScript for both arrays would, of course, return false as they are not the same objects in memory. That's expected, but there is also not a special treatment for array comparison by value as there is no item-by-item comparison behind the scenes for ==
— so, stay away from using it to compare arrays' value equality.
We can use, however, something like this function (as defined in JavaScript: The Definitive Guide by David Flanagan), to compare two arrays for equality:
function equalArrays(a, b) {
if (a === b) return true; // Identical arrays are equal
if (a.length !== b.length) return false; // Different-size arrays are not equal
for (let i = 0; i < a.length; i++) { // Loop through all elements
if (a[i] !== b[i]) return false; // If any differ, arrays are not equal
} return true; // Otherwise they are equal
}
Of course, this would not work recursively, so it would mostly be useful for simple and one-dimensional arrays.
Quite simple, but, something to keep in mind nonetheless.
Also, to learn more about how the equality operator works with objects, reading about object to primitive conversion is a great way to spend some time learning what's going on behind the scenes.
]]>def check_holiday(season):
if season == 'Winter':
holiday = 'New Year\'s Day'
elif season == 'Spring':
holiday = 'May Day'
elif season == 'Summer':
holiday = 'Juneteenth'
elif season == 'Fall':
holiday = 'Halloween'
else:
holiday = 'Personal day off'
return holiday
It looks indeed not that good. Of course, what you would do if you are somewhat a beginner who simply wants to follow best practices, is to come up with a better and more elegant way of writing this same piece of code – even though it might be laughable and pitiable to your future self, and to anyone who would like to pity you anyway.
What I've thought at first when I saw the code above was that I would simply use a dictionary to store the seasons as keys and corresponding holidays as their values, and that's it. And, I would loop over the whole dictionary to compare the seasons and assign the holiday. It would look like this:
def check_holiday(season):
seasons = {'Winter': 'New Year\'s Day',
'Spring': 'May Day',
'Summer': 'Juneteenth',
'Fall': 'Halloween'}
holiday = 'Personal day off'
for k, v in seasons.items():
if season == k:
holiday = v
return holiday
At first glance, it seemed okay. It is definitely cleaner than the one with bunch of if-else statements. But, as always, if you have come up with a better way of doing something, know that there is even a better way of doing it. Like this:
def check_holiday(season):
holiday = {'Winter': 'New Year\'s Day',
'Spring': 'May Day',
'Summer': 'Juneteenth',
'Fall': 'Halloween'}.get(season, 'Personal day off')
return holiday
It is obvious when you realize that you've already had that better tool in your toolkit that comes built-in for you all along, and that's embarrassing.
So, what this code does is the same thing, only that we use Python's built-in get
method on a dictionary to simply return the season's value – assigning it to holiday if the season exists, if not, returning 'Personal day off' as the default value.
Of course, there might be a better way of doing this, but what I've come to realize is that there is always a better choice, and it doesn't hurt to remember that –in the case of Python– you have already tons of built-in methods in the standard library to use, which would most likely be better and more efficient than what you would write anyway.
This might be a trivial thing, but it is nevertheless good food for thought. So, if you think you have found a better way of doing something, there's even a better way than that. And it might be under your nose all along, simply waiting for you to remember to use it.
]]>We write and we later read. We save and we later retrieve. We store and we later access. The function of memory is to keep information intact between those two events.
As with all things, there are advantages and disadvantages to having a beginner's perspective. But I will simply write for myself to learn, because information is better retained when you write about it. So I intend to keep this blog as mostly notes to myself, but anyone is welcome to read.
]]>